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