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 GPG: 155 156 "A wrapper around the gpg command using a particular configuration." 157 158 def __init__(self, homedir=None): 159 self.conf_args = [] 160 161 if homedir: 162 self.conf_args += ["--homedir", homedir] 163 164 self.errors = None 165 166 def run(self, args, text=None): 167 168 """ 169 Invoke gpg with the given 'args', supplying the given 'text' to the 170 command directly or, if 'text' is omitted, using a file provided as part 171 of the 'args' if appropriate. 172 173 Failure to complete the operation will result in a MoinMessageError 174 being raised. 175 """ 176 177 cmd = Popen(["gpg"] + self.conf_args + list(args), stdin=PIPE, stdout=PIPE, stderr=PIPE) 178 179 # Attempt to write input to the command and to read output from the 180 # command. 181 182 text, self.errors = cmd.communicate(text) 183 184 # Test for a zero result. 185 186 if not cmd.returncode: 187 return text 188 else: 189 raise MoinMessageError, self.errors 190 191 def verifyMessageText(self, signature, content): 192 193 "Using the given 'signature', verify the given message 'content'." 194 195 # Write the detached signature and content to files. 196 197 signature_fd, signature_filename = mkstemp() 198 content_fd, content_filename = mkstemp() 199 200 try: 201 signature_fp = os.fdopen(signature_fd, "w") 202 content_fp = os.fdopen(content_fd, "w") 203 try: 204 signature_fp.write(signature) 205 content_fp.write(content) 206 finally: 207 signature_fp.close() 208 content_fp.close() 209 210 # Verify the message text. 211 212 text = self.run(["--status-fd", "1", "--verify", signature_filename, content_filename]) 213 214 # Return the details of the signing key. 215 216 identity = None 217 fingerprint = None 218 219 for line in text.split("\n"): 220 try: 221 prefix, msgtype, digest, details = line.strip().split(" ", 3) 222 except ValueError: 223 continue 224 225 # Return the fingerprint and identity details. 226 227 if msgtype == "GOODSIG": 228 identity = details 229 elif msgtype == "VALIDSIG": 230 fingerprint = digest 231 232 if identity and fingerprint: 233 return fingerprint, identity 234 235 return None 236 237 finally: 238 os.remove(signature_filename) 239 os.remove(content_filename) 240 241 def verifyMessage(self, message): 242 243 """ 244 Verify the given RFC 3156 'message', returning a tuple of the form 245 (fingerprint, identity, content). 246 """ 247 248 content, signature = getContentAndSignature(message) 249 250 # Verify the message format. 251 252 if signature.get_content_type() != "application/pgp-signature": 253 raise MoinMessageBadContent 254 255 # Verify the message. 256 257 fingerprint, identity = self.verifyMessageText(signature.get_payload(decode=True), as_string(content)) 258 return fingerprint, identity, content 259 260 def signMessage(self, message, keyid): 261 262 """ 263 Return a signed version of 'message' using the given 'keyid'. 264 """ 265 266 # Sign the container's representation. 267 268 signature = self.run(["--armor", "-u", keyid, "--detach-sig"], as_string(message)) 269 270 # Make the container for the message. 271 272 signed_message = MIMEMultipart("signed", protocol="application/pgp-signature") 273 signed_message.attach(message) 274 275 signature_part = MIMEBase("application", "pgp-signature") 276 signature_part.set_payload(signature) 277 signed_message.attach(signature_part) 278 279 return signed_message 280 281 def decryptMessageText(self, message): 282 283 "Return a decrypted version of 'message'." 284 285 return self.run(["--decrypt"], message) 286 287 def decryptMessage(self, message): 288 289 """ 290 Decrypt the given RFC 3156 'message', returning the message text. 291 """ 292 293 try: 294 declaration, content = message.get_payload() 295 except ValueError: 296 raise MoinMessageMissingPart 297 298 # Verify the message format. 299 300 if content.get_content_type() != "application/octet-stream": 301 raise MoinMessageBadContent 302 303 # Return the decrypted message text. 304 305 return self.decryptMessageText(content.get_payload(decode=True)) 306 307 def encryptMessage(self, message, keyid): 308 309 """ 310 Return an encrypted version of 'message' using the given 'keyid'. 311 """ 312 313 text = as_string(message) 314 encrypted = self.run(["--armor", "-r", keyid, "--encrypt", "--trust-model", "always"], text) 315 316 # Make the container for the message. 317 318 encrypted_message = MIMEMultipart("encrypted", protocol="application/pgp-encrypted") 319 320 # For encrypted content, add the declaration and content. 321 322 declaration = MIMEBase("application", "pgp-encrypted") 323 declaration.set_payload("Version: 1") 324 encrypted_message.attach(declaration) 325 326 content = MIMEApplication(encrypted, "octet-stream", encode_noop) 327 encrypted_message.attach(content) 328 329 return encrypted_message 330 331 def importKeys(self, text): 332 333 """ 334 Import the keys provided by the given 'text'. 335 """ 336 337 self.run(["--import"], text) 338 339 def exportKey(self, keyid): 340 341 """ 342 Return the "armoured" public key text for 'keyid' as a message part with 343 a suitable media type. 344 See: https://tools.ietf.org/html/rfc3156#section-7 345 """ 346 347 text = self.run(["--armor", "--export", keyid]) 348 return MIMEApplication(text, "pgp-keys", encode_noop) 349 350 def listKeys(self, keyid=None): 351 352 """ 353 Return a list of key details for keys on the keychain, selecting only 354 one specific key if 'keyid' is specified. 355 """ 356 357 text = self.run(["--list-keys", "--with-colons", "--with-fingerprint"] + 358 (keyid and ["0x%s" % keyid] or [])) 359 return self._getKeysFromResult(text) 360 361 def listSignatures(self, keyid=None): 362 363 """ 364 Return a list of key and signature details for keys on the keychain, 365 selecting only one specific key if 'keyid' is specified. 366 """ 367 368 text = self.run(["--list-sigs", "--with-colons", "--with-fingerprint"] + 369 (keyid and ["0x%s" % keyid] or [])) 370 return self._getKeysFromResult(text) 371 372 def getKeysFromMessagePart(self, part): 373 374 """ 375 Process an application/pgp-keys message 'part', returning a list of 376 key details. 377 """ 378 379 return self.getKeysFromString(part.get_payload(decode=True)) 380 381 def getKeysFromString(self, s): 382 383 """ 384 Return a list of key details extracted from the given key block string 385 's'. Signature information is also included through the use of the gpg 386 verbose option. 387 """ 388 389 text = self.run(["--with-colons", "--with-fingerprint", "-v"], s) 390 return self._getKeysFromResult(text) 391 392 def _getKeysFromResult(self, text): 393 394 """ 395 Return a list of key details extracted from the given command result 396 'text'. 397 """ 398 399 keys = [] 400 for line in text.split("\n"): 401 try: 402 recordtype, trust, keylength, algorithm, keyid, cdate, expdate, serial, ownertrust, _rest = line.split(":", 9) 403 except ValueError: 404 continue 405 406 if recordtype == "pub": 407 userid, _rest = _rest.split(":", 1) 408 keys.append({ 409 "type" : recordtype, "trust" : trust, "keylength" : keylength, 410 "algorithm" : algorithm, "keyid" : keyid, "cdate" : cdate, 411 "expdate" : expdate, "userid" : userid, "ownertrust" : ownertrust, 412 "fingerprint" : None, "subkeys" : [], "signatures" : [] 413 }) 414 elif recordtype == "sub" and keys: 415 keys[-1]["subkeys"].append({ 416 "trust" : trust, "keylength" : keylength, "algorithm" : algorithm, 417 "keyid" : keyid, "cdate" : cdate, "expdate" : expdate, 418 "ownertrust" : ownertrust 419 }) 420 elif recordtype == "fpr" and keys: 421 fingerprint, _rest = _rest.split(":", 1) 422 keys[-1]["fingerprint"] = fingerprint 423 elif recordtype == "sig" and keys: 424 userid, _rest = _rest.split(":", 1) 425 keys[-1]["signatures"].append({ 426 "keyid" : keyid, "cdate" : cdate, "expdate" : expdate, 427 "userid" : userid 428 }) 429 430 return keys 431 432 # Message serialisation functions, working around email module problems. 433 434 def as_string(message): 435 436 """ 437 Return the string representation of 'message', attempting to preserve the 438 precise original formatting. 439 """ 440 441 out = StringIO() 442 generator = Generator(out, False, 0) # disable reformatting measures 443 generator.flatten(message) 444 return out.getvalue() 445 446 # Message decoding functions. 447 448 # Detect PGP/GPG-encoded payloads. 449 # See: http://tools.ietf.org/html/rfc3156 450 451 def is_signed(message): 452 mimetype = message.get_content_type() 453 encoding = message.get_content_charset() 454 455 return mimetype == "multipart/signed" and \ 456 message.get_param("protocol") == "application/pgp-signature" 457 458 def is_encrypted(message): 459 mimetype = message.get_content_type() 460 encoding = message.get_content_charset() 461 462 return mimetype == "multipart/encrypted" and \ 463 message.get_param("protocol") == "application/pgp-encrypted" 464 465 def getContentAndSignature(message): 466 467 """ 468 Return the content and signature parts of the given RFC 3156 'message'. 469 470 NOTE: RFC 3156 states that signed messages should employ a detached 471 NOTE: signature but then shows "BEGIN PGP MESSAGE" for signatures 472 NOTE: instead of "BEGIN PGP SIGNATURE". 473 NOTE: The "micalg" parameter is currently not supported. 474 """ 475 476 try: 477 content, signature = message.get_payload() 478 return content, signature 479 except ValueError: 480 raise MoinMessageMissingPart 481 482 # Communications functions. 483 484 def timestamp(message): 485 486 """ 487 Timestamp the given 'message' so that its validity can be assessed by the 488 recipient. 489 """ 490 491 datestr = formatdate() 492 493 if not message.has_key("Date"): 494 message.add_header("Date", datestr) 495 else: 496 message["Date"] = datestr 497 498 def _getConnection(scheme): 499 500 "Return the connection class for the given 'scheme'." 501 502 if scheme == "http": 503 return httplib.HTTPConnection 504 elif scheme == "https": 505 return httplib.HTTPSConnection 506 else: 507 raise MoinMessageError, "Communications protocol not supported: %s" % scheme 508 509 def sendMessageOpener(message, url, method="PUT"): 510 511 """ 512 Send 'message' to the given 'url' using the given 'method' (using PUT as the 513 default if omitted). 514 """ 515 516 scheme, host, port, path = parseURL(url) 517 text = as_string(message) 518 519 req = _getConnection(scheme)(host, port) 520 req.request(method, path, text) 521 resp = req.getresponse() 522 523 if resp.status >= 400: 524 raise MoinMessageError, "Message sending failed (%s): %s" % (resp.status, resp.read()) 525 526 return resp 527 528 def sendMessage(message, url, method="PUT"): 529 530 """ 531 Send 'message' to the given 'url' using the given 'method' (using PUT as the 532 default if omitted). 533 """ 534 535 resp = sendMessageOpener(message, url, method) 536 return resp.read() 537 538 def parseURL(url): 539 540 "Return the scheme, host, port and path for the given 'url'." 541 542 scheme, host_port, path, query, fragment = urlsplit(url) 543 host_port = host_port.split(":") 544 545 if query: 546 path += "?" + query 547 548 if len(host_port) > 1: 549 host = host_port[0] 550 port = int(host_port[1]) 551 else: 552 host = host_port[0] 553 port = 80 554 555 return scheme, host, port, path 556 557 # Message handling. 558 559 class MessageInterface: 560 561 "A command-based interface to a message store, inspired by RFC 1939 (POP3)." 562 563 def __init__(self, store): 564 self.store = store 565 566 def execute(self, commands): 567 568 """ 569 Access messages according to the given 'commands' script, acting on the 570 store provided during initialisation and returning a message object 571 containing the results. 572 """ 573 574 # Build a container for the responses. 575 576 message = Message() 577 578 # Process each command. 579 580 for command in commands.split("\n"): 581 command = command.strip() 582 583 # Get the command and arguments. 584 585 command_parts = command.split(None, 1) 586 cmd = command_parts[0] 587 588 try: 589 if cmd in self.commands: 590 getattr(self, cmd)(command_parts, message) 591 else: 592 self.add_result(cmd, command, "ERR", message) 593 except Exception, exc: 594 self.add_result(cmd, "\n".join([command, str(exc)]), "ERR", message) 595 596 return message 597 598 def get_count(self, command_parts): 599 600 # Select all messages by default. 601 602 count = None 603 604 if len(command_parts) > 1: 605 count = int(command_parts[1]) 606 607 return count 608 609 def add_result(self, cmd, result, status, message): 610 part = MIMEText(result, "x-moinmessage-fetch-result") 611 part["Request-Type"] = cmd 612 part["Request-Status"] = status 613 message.add_update(part) 614 615 def add_messages(self, resources, message): 616 container = Message() 617 618 for message_text in resources: 619 message_item = Parser().parsestr(message_text) 620 container.add_update(message_item) 621 622 # Convert the container to a proper multipart section. 623 624 message.add_update(container.get_payload()) 625 626 def STAT(self, command_parts, message): 627 628 # A request to count the messages is returned in a part. 629 630 self.add_result("STAT", str(len(self.store)), "OK", message) 631 632 def RETR(self, command_parts, message): 633 634 # A request for specific messages returns each message 635 # in its own part within a collection part. 636 637 count = self.get_count(command_parts) 638 639 self.add_messages(islice(iter(self.store), count), message) 640 641 def DELE(self, command_parts, message): 642 643 # A request to delete messages is performed immediately. 644 645 count = self.get_count(command_parts) 646 647 keys = self.store.keys()[:count] 648 649 for key in keys: 650 del self.store[key] 651 652 self.add_result("DELE", str(len(keys)), "OK", message) 653 654 # Command manifest, may be extended by subclasses. 655 656 commands = "STAT", "RETR", "DELE" 657 658 # vim: tabstop=4 expandtab shiftwidth=4