1 # -*- coding: iso-8859-1 -*- 2 """ 3 MoinMoin - MoinMessage library 4 5 @copyright: 2012, 2013, 2014 by Paul Boddie <paul@boddie.org.uk> 6 @license: GNU GPL (v2 or later), see COPYING.txt for details. 7 """ 8 9 from email import message_from_string 10 from email.encoders import encode_noop 11 from email.mime.multipart import MIMEMultipart 12 from email.mime.application import MIMEApplication 13 from email.mime.base import MIMEBase 14 from email.parser import Parser 15 from email.utils import formatdate, parsedate 16 from subprocess import Popen, PIPE 17 from tempfile import mkstemp 18 from urlparse import urlsplit 19 import httplib 20 import os 21 22 def is_collection(message): 23 return message.get("Update-Type") == "collection" 24 25 def to_replace(message): 26 return message.get("Update-Action") == "replace" 27 28 def to_store(message): 29 return message.get("Update-Action") == "store" 30 31 def get_update_action(message): 32 return message.get("Update-Action", "update") 33 34 class Message: 35 36 "An update message." 37 38 def __init__(self, text=None): 39 self.date = None 40 self.updates = [] 41 if text: 42 self.parse_text(text) 43 44 def init_date(self, message): 45 46 "Obtain the date of the given 'message'." 47 48 if message.has_key("Date"): 49 self.date = parsedate(message["Date"]) 50 else: 51 self.date = None 52 53 def parse_text(self, text): 54 55 "Parse the given 'text' as a message." 56 57 self.handle_message(message_from_string(text)) 58 59 def handle_message(self, message): 60 61 "Handle the given 'message', recording the separate updates." 62 63 self.init_date(message) 64 65 # The message either consists of a collection of updates. 66 67 if message.is_multipart() and is_collection(message): 68 for part in message.get_payload(): 69 self.updates.append(part) 70 71 # Or the message is a single update. 72 73 else: 74 self.updates.append(message) 75 76 def add_updates(self, parts): 77 78 """ 79 Add the given 'parts' to a message. 80 """ 81 82 for part in updates: 83 self.add_update(part) 84 85 def add_update(self, part): 86 87 """ 88 Add an update 'part' to a message. 89 """ 90 91 self.updates.append(part) 92 93 def get_update(self, alternatives): 94 95 """ 96 Return a suitable multipart object containing the supplied 97 'alternatives'. 98 """ 99 100 part = MIMEMultipart() 101 for alternative in alternatives: 102 part.attach(alternative) 103 return part 104 105 def get_payload(self, timestamped=True): 106 107 """ 108 Get the multipart payload for the message. If the 'timestamped' 109 parameter is omitted or set to a true value, the payload will be given a 110 date header set to the current date and time that can be used to assess 111 the validity of a message and to determine whether it has already been 112 received by a recipient. 113 """ 114 115 if len(self.updates) == 1: 116 message = self.updates[0] 117 else: 118 message = MIMEMultipart() 119 message.add_header("Update-Type", "collection") 120 for update in self.updates: 121 message.attach(update) 122 123 if timestamped: 124 timestamp(message) 125 self.init_date(message) 126 127 return message 128 129 class Mailbox: 130 131 "A collection of messages within a multipart message." 132 133 def __init__(self, text=None): 134 self.messages = [] 135 if text: 136 self.parse_text(text) 137 138 def parse_text(self, text): 139 140 "Parse the given 'text' as a mailbox." 141 142 message = message_from_string(text) 143 144 if message.is_multipart(): 145 for part in message.get_payload(): 146 self.messages.append(part) 147 else: 148 self.messages.append(message) 149 150 def add_message(self, message): 151 152 "Add the given 'message' to the mailbox." 153 154 self.messages.append(message) 155 156 def get_payload(self): 157 158 "Get the multipart payload for the mailbox." 159 160 mailbox = MIMEMultipart() 161 for message in self.messages: 162 mailbox.attach(message) 163 164 return mailbox 165 166 class MoinMessageError(Exception): 167 pass 168 169 class MoinMessageDecodingError(Exception): 170 pass 171 172 class MoinMessageMissingPart(MoinMessageDecodingError): 173 pass 174 175 class MoinMessageBadContent(MoinMessageDecodingError): 176 pass 177 178 class GPG: 179 180 "A wrapper around the gpg command using a particular configuration." 181 182 def __init__(self, homedir=None): 183 self.conf_args = [] 184 185 if homedir: 186 self.conf_args += ["--homedir", homedir] 187 188 self.errors = None 189 190 def run(self, args, text=None): 191 192 """ 193 Invoke gpg with the given 'args', supplying the given 'text' to the 194 command directly or, if 'text' is omitted, using a file provided as part 195 of the 'args' if appropriate. 196 197 Failure to complete the operation will result in a MoinMessageError 198 being raised. 199 """ 200 201 cmd = Popen(["gpg"] + self.conf_args + list(args), stdin=PIPE, stdout=PIPE, stderr=PIPE) 202 203 # Attempt to write input to the command and to read output from the 204 # command. 205 206 text, self.errors = cmd.communicate(text) 207 208 # Test for a zero result. 209 210 if not cmd.returncode: 211 return text 212 else: 213 raise MoinMessageError, self.errors 214 215 def verifyMessageText(self, signature, content): 216 217 "Using the given 'signature', verify the given message 'content'." 218 219 # Write the detached signature and content to files. 220 221 signature_fd, signature_filename = mkstemp() 222 content_fd, content_filename = mkstemp() 223 224 try: 225 signature_fp = os.fdopen(signature_fd, "w") 226 content_fp = os.fdopen(content_fd, "w") 227 try: 228 signature_fp.write(signature) 229 content_fp.write(content) 230 finally: 231 signature_fp.close() 232 content_fp.close() 233 234 # Verify the message text. 235 236 text = self.run(["--status-fd", "1", "--verify", signature_filename, content_filename]) 237 238 # Return the details of the signing key. 239 240 identity = None 241 fingerprint = None 242 243 for line in text.split("\n"): 244 try: 245 prefix, msgtype, digest, details = line.strip().split(" ", 3) 246 except ValueError: 247 continue 248 249 # Return the fingerprint and identity details. 250 251 if msgtype == "GOODSIG": 252 identity = details 253 elif msgtype == "VALIDSIG": 254 fingerprint = digest 255 256 if identity and fingerprint: 257 return fingerprint, identity 258 259 return None 260 261 finally: 262 os.remove(signature_filename) 263 os.remove(content_filename) 264 265 def verifyMessage(self, message): 266 267 """ 268 Verify the given RFC 3156 'message', returning a tuple of the form 269 (fingerprint, identity, content). 270 """ 271 272 content, signature = getContentAndSignature(message) 273 274 # Verify the message format. 275 276 if signature.get_content_type() != "application/pgp-signature": 277 raise MoinMessageBadContent 278 279 # Verify the message. 280 281 fingerprint, identity = self.verifyMessageText(signature.get_payload(decode=True), content.as_string()) 282 return fingerprint, identity, getOriginalContent(content) 283 284 def signMessage(self, message, keyid): 285 286 """ 287 Return a signed version of 'message' using the given 'keyid'. 288 """ 289 290 # Make a representation-insensitive container for the message. 291 292 content = MIMEApplication(message.as_string()) 293 294 # Sign the container's representation. 295 296 signature = self.run(["--armor", "-u", keyid, "--detach-sig"], content.as_string()) 297 298 # Make the container for the message. 299 300 signed_message = MIMEMultipart("signed", protocol="application/pgp-signature") 301 signed_message.attach(content) 302 303 signature_part = MIMEBase("application", "pgp-signature") 304 signature_part.set_payload(signature) 305 signed_message.attach(signature_part) 306 307 return signed_message 308 309 def decryptMessageText(self, message): 310 311 "Return a decrypted version of 'message'." 312 313 return self.run(["--decrypt"], message) 314 315 def decryptMessage(self, message): 316 317 """ 318 Decrypt the given RFC 3156 'message', returning the message text. 319 """ 320 321 try: 322 declaration, content = message.get_payload() 323 except ValueError: 324 raise MoinMessageMissingPart 325 326 # Verify the message format. 327 328 if content.get_content_type() != "application/octet-stream": 329 raise MoinMessageBadContent 330 331 # Return the decrypted message text. 332 333 return self.decryptMessageText(content.get_payload(decode=True)) 334 335 def encryptMessage(self, message, keyid): 336 337 """ 338 Return an encrypted version of 'message' using the given 'keyid'. 339 """ 340 341 text = message.as_string() 342 encrypted = self.run(["--armor", "-r", keyid, "--encrypt", "--trust-model", "always"], text) 343 344 # Make the container for the message. 345 346 encrypted_message = MIMEMultipart("encrypted", protocol="application/pgp-encrypted") 347 348 # For encrypted content, add the declaration and content. 349 350 declaration = MIMEBase("application", "pgp-encrypted") 351 declaration.set_payload("Version: 1") 352 encrypted_message.attach(declaration) 353 354 content = MIMEApplication(encrypted, "octet-stream", encode_noop) 355 encrypted_message.attach(content) 356 357 return encrypted_message 358 359 def exportKey(self, keyid): 360 361 """ 362 Return the "armoured" public key text for 'keyid' as a message part with 363 a suitable media type. 364 See: https://tools.ietf.org/html/rfc3156#section-7 365 """ 366 367 text = self.run(["--armor", "--export", keyid]) 368 return MIMEApplication(text, "pgp-keys", encode_noop) 369 370 def listKeys(self, keyid=None): 371 372 """ 373 Return a list of key details for keys on the keychain, selecting only 374 one specific key if 'keyid' is specified. 375 """ 376 377 text = self.run(["--list-keys", "--with-colons", "--with-fingerprint"] + 378 (keyid and ["0x%s" % keyid] or [])) 379 return self._getKeysFromResult(text) 380 381 def listSignatures(self, keyid=None): 382 383 """ 384 Return a list of key and signature details for keys on the keychain, 385 selecting only one specific key if 'keyid' is specified. 386 """ 387 388 text = self.run(["--list-sigs", "--with-colons", "--with-fingerprint"] + 389 (keyid and ["0x%s" % keyid] or [])) 390 return self._getKeysFromResult(text) 391 392 def getKeysFromMessagePart(self, part): 393 394 """ 395 Process an application/pgp-keys message 'part', returning a list of 396 key details. 397 """ 398 399 return self.getKeysFromString(part.get_payload(decode=True)) 400 401 def getKeysFromString(self, s): 402 403 """ 404 Return a list of key details extracted from the given key block string 405 's'. Signature information is also included through the use of the gpg 406 verbose option. 407 """ 408 409 text = self.run(["--with-colons", "--with-fingerprint", "-v"], s) 410 return self._getKeysFromResult(text) 411 412 def _getKeysFromResult(self, text): 413 414 """ 415 Return a list of key details extracted from the given command result 416 'text'. 417 """ 418 419 keys = [] 420 for line in text.split("\n"): 421 try: 422 recordtype, trust, keylength, algorithm, keyid, cdate, expdate, serial, ownertrust, _rest = line.split(":", 9) 423 except ValueError: 424 continue 425 426 if recordtype == "pub": 427 userid, _rest = _rest.split(":", 1) 428 keys.append({ 429 "type" : recordtype, "trust" : trust, "keylength" : keylength, 430 "algorithm" : algorithm, "keyid" : keyid, "cdate" : cdate, 431 "expdate" : expdate, "userid" : userid, "ownertrust" : ownertrust, 432 "fingerprint" : None, "subkeys" : [], "signatures" : [] 433 }) 434 elif recordtype == "sub" and keys: 435 keys[-1]["subkeys"].append({ 436 "trust" : trust, "keylength" : keylength, "algorithm" : algorithm, 437 "keyid" : keyid, "cdate" : cdate, "expdate" : expdate, 438 "ownertrust" : ownertrust 439 }) 440 elif recordtype == "fpr" and keys: 441 fingerprint, _rest = _rest.split(":", 1) 442 keys[-1]["fingerprint"] = fingerprint 443 elif recordtype == "sig" and keys: 444 userid, _rest = _rest.split(":", 1) 445 keys[-1]["signatures"].append({ 446 "keyid" : keyid, "cdate" : cdate, "expdate" : expdate, 447 "userid" : userid 448 }) 449 450 return keys 451 452 # Message decoding functions. 453 454 # Detect PGP/GPG-encoded payloads. 455 # See: http://tools.ietf.org/html/rfc3156 456 457 def is_signed(message): 458 mimetype = message.get_content_type() 459 encoding = message.get_content_charset() 460 461 return mimetype == "multipart/signed" and \ 462 message.get_param("protocol") == "application/pgp-signature" 463 464 def is_encrypted(message): 465 mimetype = message.get_content_type() 466 encoding = message.get_content_charset() 467 468 return mimetype == "multipart/encrypted" and \ 469 message.get_param("protocol") == "application/pgp-encrypted" 470 471 def getContentAndSignature(message): 472 473 """ 474 Return the content and signature parts of the given RFC 3156 'message'. 475 476 NOTE: RFC 3156 states that signed messages should employ a detached 477 NOTE: signature but then shows "BEGIN PGP MESSAGE" for signatures 478 NOTE: instead of "BEGIN PGP SIGNATURE". 479 NOTE: The "micalg" parameter is currently not supported. 480 """ 481 482 try: 483 content, signature = message.get_payload() 484 return content, signature 485 except ValueError: 486 raise MoinMessageMissingPart 487 488 def getOriginalContent(content): 489 490 """ 491 Extract the actual content inside the signed message. This reverses the 492 wrapping up of signed content in a representation-insensitive container. 493 """ 494 495 return Parser().parsestr(content.get_payload(decode=True)) 496 497 # Communications functions. 498 499 def timestamp(message): 500 501 """ 502 Timestamp the given 'message' so that its validity can be assessed by the 503 recipient. 504 """ 505 506 datestr = formatdate() 507 508 if not message.has_key("Date"): 509 message.add_header("Date", datestr) 510 else: 511 message["Date"] = datestr 512 513 def _getConnection(scheme): 514 515 "Return the connection class for the given 'scheme'." 516 517 if scheme == "http": 518 return httplib.HTTPConnection 519 elif scheme == "https": 520 return httplib.HTTPSConnection 521 else: 522 raise MoinMessageError, "Communications protocol not supported: %s" % scheme 523 524 def sendMessageForReading(message, url, method="PUT"): 525 526 """ 527 Send 'message' to the given 'url' using the given 'method' (using PUT as the 528 default if omitted). 529 """ 530 531 scheme, host, port, path = parseURL(url) 532 text = message.as_string() 533 534 req = _getConnection(scheme)(host, port) 535 req.request(method, path, text) 536 resp = req.getresponse() 537 538 if resp.status >= 400: 539 raise MoinMessageError, "Message sending failed (%s): %s" % (resp.status, resp.read()) 540 541 return resp 542 543 def sendMessage(message, url, method="PUT"): 544 545 """ 546 Send 'message' to the given 'url' using the given 'method' (using PUT as the 547 default if omitted). 548 """ 549 550 resp = sendMessageForReading(message, url, method) 551 return resp.read() 552 553 def parseURL(url): 554 555 "Return the scheme, host, port and path for the given 'url'." 556 557 scheme, host_port, path, query, fragment = urlsplit(url) 558 host_port = host_port.split(":") 559 560 if query: 561 path += "?" + query 562 563 if len(host_port) > 1: 564 host = host_port[0] 565 port = int(host_port[1]) 566 else: 567 host = host_port[0] 568 port = 80 569 570 return scheme, host, port, path 571 572 # vim: tabstop=4 expandtab shiftwidth=4