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, 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() 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 exportKey(self, keyid): 332 333 """ 334 Return the "armoured" public key text for 'keyid' as a message part with 335 a suitable media type. 336 See: https://tools.ietf.org/html/rfc3156#section-7 337 """ 338 339 text = self.run(["--armor", "--export", keyid]) 340 return MIMEApplication(text, "pgp-keys", encode_noop) 341 342 def listKeys(self, keyid=None): 343 344 """ 345 Return a list of key details for keys on the keychain, selecting only 346 one specific key if 'keyid' is specified. 347 """ 348 349 text = self.run(["--list-keys", "--with-colons", "--with-fingerprint"] + 350 (keyid and ["0x%s" % keyid] or [])) 351 return self._getKeysFromResult(text) 352 353 def listSignatures(self, keyid=None): 354 355 """ 356 Return a list of key and signature details for keys on the keychain, 357 selecting only one specific key if 'keyid' is specified. 358 """ 359 360 text = self.run(["--list-sigs", "--with-colons", "--with-fingerprint"] + 361 (keyid and ["0x%s" % keyid] or [])) 362 return self._getKeysFromResult(text) 363 364 def getKeysFromMessagePart(self, part): 365 366 """ 367 Process an application/pgp-keys message 'part', returning a list of 368 key details. 369 """ 370 371 return self.getKeysFromString(part.get_payload(decode=True)) 372 373 def getKeysFromString(self, s): 374 375 """ 376 Return a list of key details extracted from the given key block string 377 's'. Signature information is also included through the use of the gpg 378 verbose option. 379 """ 380 381 text = self.run(["--with-colons", "--with-fingerprint", "-v"], s) 382 return self._getKeysFromResult(text) 383 384 def _getKeysFromResult(self, text): 385 386 """ 387 Return a list of key details extracted from the given command result 388 'text'. 389 """ 390 391 keys = [] 392 for line in text.split("\n"): 393 try: 394 recordtype, trust, keylength, algorithm, keyid, cdate, expdate, serial, ownertrust, _rest = line.split(":", 9) 395 except ValueError: 396 continue 397 398 if recordtype == "pub": 399 userid, _rest = _rest.split(":", 1) 400 keys.append({ 401 "type" : recordtype, "trust" : trust, "keylength" : keylength, 402 "algorithm" : algorithm, "keyid" : keyid, "cdate" : cdate, 403 "expdate" : expdate, "userid" : userid, "ownertrust" : ownertrust, 404 "fingerprint" : None, "subkeys" : [], "signatures" : [] 405 }) 406 elif recordtype == "sub" and keys: 407 keys[-1]["subkeys"].append({ 408 "trust" : trust, "keylength" : keylength, "algorithm" : algorithm, 409 "keyid" : keyid, "cdate" : cdate, "expdate" : expdate, 410 "ownertrust" : ownertrust 411 }) 412 elif recordtype == "fpr" and keys: 413 fingerprint, _rest = _rest.split(":", 1) 414 keys[-1]["fingerprint"] = fingerprint 415 elif recordtype == "sig" and keys: 416 userid, _rest = _rest.split(":", 1) 417 keys[-1]["signatures"].append({ 418 "keyid" : keyid, "cdate" : cdate, "expdate" : expdate, 419 "userid" : userid 420 }) 421 422 return keys 423 424 # Message serialisation functions, working around email module problems. 425 426 def as_string(message): 427 428 """ 429 Return the string representation of 'message', attempting to preserve the 430 precise original formatting. 431 """ 432 433 out = StringIO() 434 generator = Generator(out, False, 0) # disable reformatting measures 435 generator.flatten(message) 436 return out.getvalue() 437 438 # Message decoding functions. 439 440 # Detect PGP/GPG-encoded payloads. 441 # See: http://tools.ietf.org/html/rfc3156 442 443 def is_signed(message): 444 mimetype = message.get_content_type() 445 encoding = message.get_content_charset() 446 447 return mimetype == "multipart/signed" and \ 448 message.get_param("protocol") == "application/pgp-signature" 449 450 def is_encrypted(message): 451 mimetype = message.get_content_type() 452 encoding = message.get_content_charset() 453 454 return mimetype == "multipart/encrypted" and \ 455 message.get_param("protocol") == "application/pgp-encrypted" 456 457 def getContentAndSignature(message): 458 459 """ 460 Return the content and signature parts of the given RFC 3156 'message'. 461 462 NOTE: RFC 3156 states that signed messages should employ a detached 463 NOTE: signature but then shows "BEGIN PGP MESSAGE" for signatures 464 NOTE: instead of "BEGIN PGP SIGNATURE". 465 NOTE: The "micalg" parameter is currently not supported. 466 """ 467 468 try: 469 content, signature = message.get_payload() 470 return content, signature 471 except ValueError: 472 raise MoinMessageMissingPart 473 474 # Communications functions. 475 476 def timestamp(message): 477 478 """ 479 Timestamp the given 'message' so that its validity can be assessed by the 480 recipient. 481 """ 482 483 datestr = formatdate() 484 485 if not message.has_key("Date"): 486 message.add_header("Date", datestr) 487 else: 488 message["Date"] = datestr 489 490 def _getConnection(scheme): 491 492 "Return the connection class for the given 'scheme'." 493 494 if scheme == "http": 495 return httplib.HTTPConnection 496 elif scheme == "https": 497 return httplib.HTTPSConnection 498 else: 499 raise MoinMessageError, "Communications protocol not supported: %s" % scheme 500 501 def sendMessageOpener(message, url, method="PUT"): 502 503 """ 504 Send 'message' to the given 'url' using the given 'method' (using PUT as the 505 default if omitted). 506 """ 507 508 scheme, host, port, path = parseURL(url) 509 text = as_string(message) 510 511 req = _getConnection(scheme)(host, port) 512 req.request(method, path, text) 513 resp = req.getresponse() 514 515 if resp.status >= 400: 516 raise MoinMessageError, "Message sending failed (%s): %s" % (resp.status, resp.read()) 517 518 return resp 519 520 def sendMessage(message, url, method="PUT"): 521 522 """ 523 Send 'message' to the given 'url' using the given 'method' (using PUT as the 524 default if omitted). 525 """ 526 527 resp = sendMessageOpener(message, url, method) 528 return resp.read() 529 530 def parseURL(url): 531 532 "Return the scheme, host, port and path for the given 'url'." 533 534 scheme, host_port, path, query, fragment = urlsplit(url) 535 host_port = host_port.split(":") 536 537 if query: 538 path += "?" + query 539 540 if len(host_port) > 1: 541 host = host_port[0] 542 port = int(host_port[1]) 543 else: 544 host = host_port[0] 545 port = 80 546 547 return scheme, host, port, path 548 549 # Message handling. 550 551 class MessageInterface: 552 553 "A command-based interface to a message store, inspired by RFC 1939 (POP3)." 554 555 def __init__(self, store): 556 self.store = store 557 558 def execute(self, commands): 559 560 """ 561 Access messages according to the given 'commands' script, acting on the 562 store provided during initialisation and returning a message object 563 containing the results. 564 """ 565 566 # Build a container for the responses. 567 568 message = Message() 569 570 # Process each command. 571 572 for command in commands.split("\n"): 573 command = command.strip() 574 575 # Get the command and arguments. 576 577 command_parts = command.split(None, 1) 578 cmd = command_parts[0] 579 580 try: 581 if cmd in self.commands: 582 getattr(self, cmd)(command_parts, message) 583 else: 584 self.add_result(cmd, command, "ERR", message) 585 except Exception, exc: 586 self.add_result(cmd, "\n".join([command, str(exc)]), "ERR", message) 587 588 return message 589 590 def get_count(self, command_parts): 591 592 # Select all messages by default. 593 594 count = None 595 596 if len(command_parts) > 1: 597 count = int(command_parts[1]) 598 599 return count 600 601 def add_result(self, cmd, result, status, message): 602 part = MIMEText(result, "x-moinmessage-fetch-result") 603 part["Request-Type"] = cmd 604 part["Request-Status"] = status 605 message.add_update(part) 606 607 def add_messages(self, resources, message): 608 container = Message() 609 610 for message_text in resources: 611 message_item = Parser().parsestr(message_text) 612 container.add_update(message_item) 613 614 # Convert the container to a proper multipart section. 615 616 message.add_update(container.get_payload()) 617 618 def STAT(self, command_parts, message): 619 620 # A request to count the messages is returned in a part. 621 622 self.add_result("STAT", str(len(self.store)), "OK", message) 623 624 def RETR(self, command_parts, message): 625 626 # A request for specific messages returns each message 627 # in its own part within a collection part. 628 629 count = self.get_count(command_parts) 630 631 self.add_messages(islice(iter(self.store), count), message) 632 633 def DELE(self, command_parts, message): 634 635 # A request to delete messages is performed immediately. 636 637 count = self.get_count(command_parts) 638 639 keys = self.store.keys()[:count] 640 641 for key in keys: 642 del self.store[key] 643 644 self.add_result("DELE", str(len(keys)), "OK", message) 645 646 # Command manifest, may be extended by subclasses. 647 648 commands = "STAT", "RETR", "DELE" 649 650 # vim: tabstop=4 expandtab shiftwidth=4