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.generator import Generator 12 from email.mime.multipart import MIMEMultipart 13 from email.mime.application import MIMEApplication 14 from email.mime.base import MIMEBase 15 from email.mime.text import MIMEText 16 from email.parser import Parser 17 from email.utils import formatdate 18 from itertools import islice 19 from subprocess import Popen, PIPE 20 from tempfile import mkstemp 21 from urlparse import urlsplit 22 from DateSupport import getDateTimeFromRFC2822 23 import httplib 24 import os 25 26 try: 27 from cStringIO import StringIO 28 except ImportError: 29 from StringIO import StringIO 30 31 # Message inspection functions. 32 33 def is_collection(message): 34 return message.get("Update-Type") == "collection" 35 36 def to_replace(message): 37 return message.get("Update-Action") == "replace" 38 39 def to_store(message): 40 return message.get("Update-Action") == "store" 41 42 def get_update_action(message): 43 return message.get("Update-Action", "update") 44 45 # Core abstractions. 46 47 class Message: 48 49 "An update message." 50 51 def __init__(self, text=None): 52 self.date = None 53 self.updates = [] 54 if text: 55 self.parse_text(text) 56 57 def init_date(self, message): 58 59 "Obtain the date of the given 'message'." 60 61 if message.has_key("Date"): 62 self.date = getDateTimeFromRFC2822(message["Date"]) 63 else: 64 self.date = None 65 66 def parse_text(self, text): 67 68 "Parse the given 'text' as a message." 69 70 self.handle_message(message_from_string(text)) 71 72 def handle_message(self, message): 73 74 "Handle the given 'message', recording the separate updates." 75 76 self.init_date(message) 77 78 # The message either consists of a collection of updates. 79 80 if message.is_multipart() and is_collection(message): 81 for part in message.get_payload(): 82 self.updates.append(part) 83 84 # Or the message is a single update. 85 86 else: 87 self.updates.append(message) 88 89 def add_updates(self, parts): 90 91 """ 92 Add the given 'parts' to a message. 93 """ 94 95 for part in updates: 96 self.add_update(part) 97 98 def add_update(self, part): 99 100 """ 101 Add an update 'part' to a message. 102 """ 103 104 self.updates.append(part) 105 106 def get_update(self, alternatives): 107 108 """ 109 Return a suitable multipart object containing the supplied 110 'alternatives'. 111 """ 112 113 part = MIMEMultipart("alternative") 114 for alternative in alternatives: 115 part.attach(alternative) 116 return part 117 118 def get_payload(self, subtype="mixed", timestamped=True): 119 120 """ 121 Get the multipart payload for the message. If the 'timestamped' 122 parameter is omitted or set to a true value, the payload will be given a 123 date header set to the current date and time that can be used to assess 124 the validity of a message and to determine whether it has already been 125 received by a recipient. 126 """ 127 128 if len(self.updates) == 1: 129 message = self.updates[0] 130 else: 131 message = MIMEMultipart(subtype) 132 message.add_header("Update-Type", "collection") 133 for update in self.updates: 134 message.attach(update) 135 136 if timestamped: 137 timestamp(message) 138 self.init_date(message) 139 140 return message 141 142 class MoinMessageError(Exception): 143 pass 144 145 class MoinMessageDecodingError(Exception): 146 pass 147 148 class MoinMessageMissingPart(MoinMessageDecodingError): 149 pass 150 151 class MoinMessageBadContent(MoinMessageDecodingError): 152 pass 153 154 class MoinMessageTransferError(MoinMessageError): 155 def __init__(self, code, message, body): 156 MoinMessageError.__init__(self, message) 157 self.code = code 158 self.body = body 159 160 class GPG: 161 162 "A wrapper around the gpg command using a particular configuration." 163 164 def __init__(self, homedir=None): 165 self.conf_args = [] 166 167 if homedir: 168 self.conf_args += ["--homedir", homedir] 169 170 self.errors = None 171 172 def run(self, args, text=None): 173 174 """ 175 Invoke gpg with the given 'args', supplying the given 'text' to the 176 command directly or, if 'text' is omitted, using a file provided as part 177 of the 'args' if appropriate. 178 179 Failure to complete the operation will result in a MoinMessageError 180 being raised. 181 """ 182 183 cmd = Popen(["gpg"] + self.conf_args + list(args), stdin=PIPE, stdout=PIPE, stderr=PIPE) 184 185 # Attempt to write input to the command and to read output from the 186 # command. 187 188 text, self.errors = cmd.communicate(text) 189 190 # Test for a zero result. 191 192 if not cmd.returncode: 193 return text 194 else: 195 raise MoinMessageError, self.errors 196 197 def verifyMessageText(self, signature, content): 198 199 "Using the given 'signature', verify the given message 'content'." 200 201 # Write the detached signature and content to files. 202 203 signature_fd, signature_filename = mkstemp() 204 content_fd, content_filename = mkstemp() 205 206 try: 207 signature_fp = os.fdopen(signature_fd, "w") 208 content_fp = os.fdopen(content_fd, "w") 209 try: 210 signature_fp.write(signature) 211 content_fp.write(content) 212 finally: 213 signature_fp.close() 214 content_fp.close() 215 216 # Verify the message text. 217 218 text = self.run(["--status-fd", "1", "--verify", signature_filename, content_filename]) 219 220 # Return the details of the signing key. 221 222 identity = None 223 fingerprint = None 224 225 for line in text.split("\n"): 226 try: 227 prefix, msgtype, digest, details = line.strip().split(" ", 3) 228 except ValueError: 229 continue 230 231 # Return the fingerprint and identity details. 232 233 if msgtype == "GOODSIG": 234 identity = details 235 elif msgtype == "VALIDSIG": 236 fingerprint = digest 237 238 if identity and fingerprint: 239 return fingerprint, identity 240 241 return None 242 243 finally: 244 os.remove(signature_filename) 245 os.remove(content_filename) 246 247 def verifyMessage(self, message): 248 249 """ 250 Verify the given RFC 3156 'message', returning a tuple of the form 251 (fingerprint, identity, content). 252 """ 253 254 content, signature = getContentAndSignature(message) 255 256 # Verify the message format. 257 258 if signature.get_content_type() != "application/pgp-signature": 259 raise MoinMessageBadContent 260 261 # Verify the message. 262 263 fingerprint, identity = self.verifyMessageText(signature.get_payload(decode=True), as_string(content)) 264 return fingerprint, identity, content 265 266 def signMessage(self, message, keyid): 267 268 """ 269 Return a signed version of 'message' using the given 'keyid'. 270 """ 271 272 # Sign the container's representation. 273 274 signature = self.run(["--armor", "-u", keyid, "--detach-sig"], as_string(message)) 275 276 # Make the container for the message. 277 278 signed_message = MIMEMultipart("signed", protocol="application/pgp-signature") 279 signed_message.attach(message) 280 281 signature_part = MIMEBase("application", "pgp-signature") 282 signature_part.set_payload(signature) 283 signed_message.attach(signature_part) 284 285 return signed_message 286 287 def decryptMessageText(self, message): 288 289 "Return a decrypted version of 'message'." 290 291 return self.run(["--decrypt"], message) 292 293 def decryptMessage(self, message): 294 295 """ 296 Decrypt the given RFC 3156 'message', returning the message text. 297 """ 298 299 try: 300 declaration, content = message.get_payload() 301 except ValueError: 302 raise MoinMessageMissingPart 303 304 # Verify the message format. 305 306 if content.get_content_type() != "application/octet-stream": 307 raise MoinMessageBadContent 308 309 # Return the decrypted message text. 310 311 return self.decryptMessageText(content.get_payload(decode=True)) 312 313 def encryptMessage(self, message, keyid): 314 315 """ 316 Return an encrypted version of 'message' using the given 'keyid'. 317 """ 318 319 text = as_string(message) 320 encrypted = self.run(["--armor", "-r", keyid, "--encrypt", "--trust-model", "always"], text) 321 322 # Make the container for the message. 323 324 encrypted_message = MIMEMultipart("encrypted", protocol="application/pgp-encrypted") 325 326 # For encrypted content, add the declaration and content. 327 328 declaration = MIMEBase("application", "pgp-encrypted") 329 declaration.set_payload("Version: 1") 330 encrypted_message.attach(declaration) 331 332 content = MIMEApplication(encrypted, "octet-stream", encode_noop) 333 encrypted_message.attach(content) 334 335 return encrypted_message 336 337 def importKeys(self, text): 338 339 """ 340 Import the keys provided by the given 'text'. 341 """ 342 343 self.run(["--import"], text) 344 345 def exportKey(self, keyid): 346 347 """ 348 Return the "armoured" public key text for 'keyid' as a message part with 349 a suitable media type. 350 See: https://tools.ietf.org/html/rfc3156#section-7 351 """ 352 353 text = self.run(["--armor", "--export", keyid]) 354 return MIMEApplication(text, "pgp-keys", encode_noop) 355 356 def listKeys(self, keyid=None): 357 358 """ 359 Return a list of key details for keys on the keychain, selecting only 360 one specific key if 'keyid' is specified. 361 """ 362 363 text = self.run(["--list-keys", "--with-colons", "--with-fingerprint"] + 364 (keyid and ["0x%s" % keyid] or [])) 365 return self._getKeysFromResult(text) 366 367 def listSignatures(self, keyid=None): 368 369 """ 370 Return a list of key and signature details for keys on the keychain, 371 selecting only one specific key if 'keyid' is specified. 372 """ 373 374 text = self.run(["--list-sigs", "--with-colons", "--with-fingerprint"] + 375 (keyid and ["0x%s" % keyid] or [])) 376 return self._getKeysFromResult(text) 377 378 def getKeysFromMessagePart(self, part): 379 380 """ 381 Process an application/pgp-keys message 'part', returning a list of 382 key details. 383 """ 384 385 return self.getKeysFromString(part.get_payload(decode=True)) 386 387 def getKeysFromString(self, s): 388 389 """ 390 Return a list of key details extracted from the given key block string 391 's'. Signature information is also included through the use of the gpg 392 verbose option. 393 """ 394 395 text = self.run(["--with-colons", "--with-fingerprint", "-v"], s) 396 return self._getKeysFromResult(text) 397 398 def _getKeysFromResult(self, text): 399 400 """ 401 Return a list of key details extracted from the given command result 402 'text'. 403 """ 404 405 keys = [] 406 for line in text.split("\n"): 407 try: 408 recordtype, trust, keylength, algorithm, keyid, cdate, expdate, serial, ownertrust, _rest = line.split(":", 9) 409 except ValueError: 410 continue 411 412 if recordtype == "pub": 413 userid, _rest = _rest.split(":", 1) 414 keys.append({ 415 "type" : recordtype, "trust" : trust, "keylength" : keylength, 416 "algorithm" : algorithm, "keyid" : keyid, "cdate" : cdate, 417 "expdate" : expdate, "userid" : userid, "ownertrust" : ownertrust, 418 "fingerprint" : None, "subkeys" : [], "signatures" : [] 419 }) 420 elif recordtype == "sub" and keys: 421 keys[-1]["subkeys"].append({ 422 "trust" : trust, "keylength" : keylength, "algorithm" : algorithm, 423 "keyid" : keyid, "cdate" : cdate, "expdate" : expdate, 424 "ownertrust" : ownertrust 425 }) 426 elif recordtype == "fpr" and keys: 427 fingerprint, _rest = _rest.split(":", 1) 428 keys[-1]["fingerprint"] = fingerprint 429 elif recordtype == "sig" and keys: 430 userid, _rest = _rest.split(":", 1) 431 keys[-1]["signatures"].append({ 432 "keyid" : keyid, "cdate" : cdate, "expdate" : expdate, 433 "userid" : userid 434 }) 435 436 return keys 437 438 # Message serialisation functions, working around email module problems. 439 440 def as_string(message): 441 442 """ 443 Return the string representation of 'message', attempting to preserve the 444 precise original formatting. 445 """ 446 447 out = StringIO() 448 generator = Generator(out, False, 0) # disable reformatting measures 449 generator.flatten(message) 450 return out.getvalue() 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 # Communications functions. 489 490 def timestamp(message): 491 492 """ 493 Timestamp the given 'message' so that its validity can be assessed by the 494 recipient. 495 """ 496 497 datestr = formatdate() 498 499 if not message.has_key("Date"): 500 message.add_header("Date", datestr) 501 else: 502 message.replace_header("Date", datestr) 503 504 def _getConnection(scheme): 505 506 "Return the connection class for the given 'scheme'." 507 508 if scheme == "http": 509 return httplib.HTTPConnection 510 elif scheme == "https": 511 return httplib.HTTPSConnection 512 else: 513 raise MoinMessageError, "Communications protocol not supported: %s" % scheme 514 515 def sendMessageOpener(message, url, method="PUT"): 516 517 """ 518 Send 'message' to the given 'url' using the given 'method' (using PUT as the 519 default if omitted). 520 """ 521 522 scheme, host, port, path = parseURL(url) 523 text = as_string(message) 524 525 req = _getConnection(scheme)(host, port) 526 req.request(method, path, text) 527 resp = req.getresponse() 528 529 if resp.status >= 400: 530 raise MoinMessageTransferError(resp.status, "Message sending failed (%s)" % resp.status, resp.read()) 531 532 return resp 533 534 def sendMessage(message, url, method="PUT"): 535 536 """ 537 Send 'message' to the given 'url' using the given 'method' (using PUT as the 538 default if omitted). 539 """ 540 541 resp = sendMessageOpener(message, url, method) 542 return resp.read() 543 544 def parseURL(url): 545 546 "Return the scheme, host, port and path for the given 'url'." 547 548 scheme, host_port, path, query, fragment = urlsplit(url) 549 host_port = host_port.split(":") 550 551 if query: 552 path += "?" + query 553 554 if len(host_port) > 1: 555 host = host_port[0] 556 port = int(host_port[1]) 557 else: 558 host = host_port[0] 559 port = 80 560 561 return scheme, host, port, path 562 563 # Message handling. 564 565 class MessageInterface: 566 567 "A command-based interface to a message store, inspired by RFC 1939 (POP3)." 568 569 def __init__(self, store): 570 self.store = store 571 572 def execute(self, commands): 573 574 """ 575 Access messages according to the given 'commands' script, acting on the 576 store provided during initialisation and returning a message object 577 containing the results. 578 """ 579 580 # Build a container for the responses. 581 582 message = Message() 583 584 # Process each command. 585 586 for command in commands.split("\n"): 587 command = command.strip() 588 589 # Get the command and arguments. 590 591 command_parts = command.split(None, 1) 592 cmd = command_parts[0] 593 594 try: 595 if cmd in self.commands: 596 getattr(self, cmd)(command_parts, message) 597 else: 598 self.add_result(cmd, command, "ERR", message) 599 except Exception, exc: 600 self.add_result(cmd, "\n".join([command, str(exc)]), "ERR", message) 601 602 return message 603 604 def get_count(self, command_parts): 605 606 # Select all messages by default. 607 608 count = None 609 610 if len(command_parts) > 1: 611 count = int(command_parts[1]) 612 613 return count 614 615 def add_result(self, cmd, result, status, message): 616 part = MIMEText(result, "x-moinmessage-fetch-result") 617 part["Request-Type"] = cmd 618 part["Request-Status"] = status 619 message.add_update(part) 620 621 def add_messages(self, resources, message): 622 container = Message() 623 624 for message_text in resources: 625 message_item = Parser().parsestr(message_text) 626 container.add_update(message_item) 627 628 # Convert the container to a proper multipart section. 629 630 message.add_update(container.get_payload()) 631 632 def STAT(self, command_parts, message): 633 634 # A request to count the messages is returned in a part. 635 636 self.add_result("STAT", str(len(self.store)), "OK", message) 637 638 def RETR(self, command_parts, message): 639 640 # A request for specific messages returns each message 641 # in its own part within a collection part. 642 643 count = self.get_count(command_parts) 644 645 self.add_messages(islice(iter(self.store), count), message) 646 647 def DELE(self, command_parts, message): 648 649 # A request to delete messages is performed immediately. 650 651 count = self.get_count(command_parts) 652 653 keys = self.store.keys()[:count] 654 655 for key in keys: 656 del self.store[key] 657 658 self.add_result("DELE", str(len(keys)), "OK", message) 659 660 # Command manifest, may be extended by subclasses. 661 662 commands = "STAT", "RETR", "DELE" 663 664 # vim: tabstop=4 expandtab shiftwidth=4