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