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