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(), content.as_string()) 282 283 # Extract the actual content inside the signed message. 284 285 return fingerprint, identity, Parser().parsestr(content.get_payload(decode=True)) 286 287 def signMessage(self, message, keyid): 288 289 """ 290 Return a signed version of 'message' using the given 'keyid'. 291 """ 292 293 # Make a representation-insensitive container for the message. 294 295 text = message.as_string() 296 content = MIMEApplication(text) 297 298 # Sign the container's representation. 299 300 signature = self.run(["--armor", "-u", keyid, "--detach-sig"], content.as_string()) 301 302 # Make the container for the message. 303 304 signed_message = MIMEMultipart("signed", protocol="application/pgp-signature") 305 signed_message.attach(content) 306 307 signature_part = MIMEBase("application", "pgp-signature") 308 signature_part.set_payload(signature) 309 signed_message.attach(signature_part) 310 311 return signed_message 312 313 def decryptMessageText(self, message): 314 315 "Return a decrypted version of 'message'." 316 317 return self.run(["--decrypt"], message) 318 319 def decryptMessage(self, message): 320 321 """ 322 Decrypt the given RFC 3156 'message', returning the message text. 323 """ 324 325 try: 326 declaration, content = message.get_payload() 327 except ValueError: 328 raise MoinMessageMissingPart 329 330 # Verify the message format. 331 332 if content.get_content_type() != "application/octet-stream": 333 raise MoinMessageBadContent 334 335 # Return the decrypted message text. 336 337 return self.decryptMessageText(content.get_payload()) 338 339 def encryptMessage(self, message, keyid): 340 341 """ 342 Return an encrypted version of 'message' using the given 'keyid'. 343 """ 344 345 text = message.as_string() 346 encrypted = self.run(["--armor", "-r", keyid, "--encrypt", "--trust-model", "always"], text) 347 348 # Make the container for the message. 349 350 encrypted_message = MIMEMultipart("encrypted", protocol="application/pgp-encrypted") 351 352 # For encrypted content, add the declaration and content. 353 354 declaration = MIMEBase("application", "pgp-encrypted") 355 declaration.set_payload("Version: 1") 356 encrypted_message.attach(declaration) 357 358 content = MIMEApplication(encrypted, "octet-stream", encode_noop) 359 encrypted_message.attach(content) 360 361 return encrypted_message 362 363 def exportKey(self, keyid): 364 365 """ 366 Return the "armoured" public key text for 'keyid' as a message part with 367 a suitable media type. 368 See: https://tools.ietf.org/html/rfc3156#section-7 369 """ 370 371 text = self.run(["--armor", "--export", keyid]) 372 return MIMEApplication(text, "pgp-keys", encode_noop) 373 374 def listKeys(self, keyid=None): 375 376 """ 377 Return a list of key details for keys on the keychain, selecting only 378 one specific key if 'keyid' is specified. 379 """ 380 381 text = self.run(["--list-keys", "--with-colons", "--with-fingerprint"] + 382 (keyid and ["0x%s" % keyid] or [])) 383 return self._getKeysFromResult(text) 384 385 def listSignatures(self, keyid=None): 386 387 """ 388 Return a list of key and signature details for keys on the keychain, 389 selecting only one specific key if 'keyid' is specified. 390 """ 391 392 text = self.run(["--list-sigs", "--with-colons", "--with-fingerprint"] + 393 (keyid and ["0x%s" % keyid] or [])) 394 return self._getKeysFromResult(text) 395 396 def getKeysFromMessagePart(self, part): 397 398 """ 399 Process an application/pgp-keys message 'part', returning a list of 400 key details. 401 """ 402 403 return self.getKeysFromString(part.get_payload()) 404 405 def getKeysFromString(self, s): 406 407 """ 408 Return a list of key details extracted from the given key block string 409 's'. Signature information is also included through the use of the gpg 410 verbose option. 411 """ 412 413 text = self.run(["--with-colons", "--with-fingerprint", "-v"], s) 414 return self._getKeysFromResult(text) 415 416 def _getKeysFromResult(self, text): 417 418 """ 419 Return a list of key details extracted from the given command result 420 'text'. 421 """ 422 423 keys = [] 424 for line in text.split("\n"): 425 try: 426 recordtype, trust, keylength, algorithm, keyid, cdate, expdate, serial, ownertrust, _rest = line.split(":", 9) 427 except ValueError: 428 continue 429 430 if recordtype == "pub": 431 userid, _rest = _rest.split(":", 1) 432 keys.append({ 433 "type" : recordtype, "trust" : trust, "keylength" : keylength, 434 "algorithm" : algorithm, "keyid" : keyid, "cdate" : cdate, 435 "expdate" : expdate, "userid" : userid, "ownertrust" : ownertrust, 436 "fingerprint" : None, "subkeys" : [], "signatures" : [] 437 }) 438 elif recordtype == "sub" and keys: 439 keys[-1]["subkeys"].append({ 440 "trust" : trust, "keylength" : keylength, "algorithm" : algorithm, 441 "keyid" : keyid, "cdate" : cdate, "expdate" : expdate, 442 "ownertrust" : ownertrust 443 }) 444 elif recordtype == "fpr" and keys: 445 fingerprint, _rest = _rest.split(":", 1) 446 keys[-1]["fingerprint"] = fingerprint 447 elif recordtype == "sig" and keys: 448 userid, _rest = _rest.split(":", 1) 449 keys[-1]["signatures"].append({ 450 "keyid" : keyid, "cdate" : cdate, "expdate" : expdate, 451 "userid" : userid 452 }) 453 454 return keys 455 456 # Message decoding functions. 457 458 # Detect PGP/GPG-encoded payloads. 459 # See: http://tools.ietf.org/html/rfc3156 460 461 def is_signed(message): 462 mimetype = message.get_content_type() 463 encoding = message.get_content_charset() 464 465 return mimetype == "multipart/signed" and \ 466 message.get_param("protocol") == "application/pgp-signature" 467 468 def is_encrypted(message): 469 mimetype = message.get_content_type() 470 encoding = message.get_content_charset() 471 472 return mimetype == "multipart/encrypted" and \ 473 message.get_param("protocol") == "application/pgp-encrypted" 474 475 def getContentAndSignature(message): 476 477 """ 478 Return the content and signature parts of the given RFC 3156 'message'. 479 480 NOTE: RFC 3156 states that signed messages should employ a detached 481 NOTE: signature but then shows "BEGIN PGP MESSAGE" for signatures 482 NOTE: instead of "BEGIN PGP SIGNATURE". 483 NOTE: The "micalg" parameter is currently not supported. 484 """ 485 486 try: 487 content, signature = message.get_payload() 488 return content, signature 489 except ValueError: 490 raise MoinMessageMissingPart 491 492 # Communications functions. 493 494 def timestamp(message): 495 496 """ 497 Timestamp the given 'message' so that its validity can be assessed by the 498 recipient. 499 """ 500 501 datestr = formatdate() 502 503 if not message.has_key("Date"): 504 message.add_header("Date", datestr) 505 else: 506 message["Date"] = datestr 507 508 def sendMessage(message, url, method="PUT"): 509 510 """ 511 Send 'message' to the given 'url' using the given 'method' (using PUT as the 512 default if omitted). 513 """ 514 515 scheme, host, port, path = parseURL(url) 516 text = message.as_string() 517 518 if scheme == "http": 519 cls = httplib.HTTPConnection 520 elif scheme == "https": 521 cls = httplib.HTTPSConnection 522 else: 523 raise MoinMessageError, "Communications protocol not supported: %s" % scheme 524 525 req = cls(host, port) 526 req.request(method, path, text) 527 resp = req.getresponse() 528 529 if resp.status >= 400: 530 raise MoinMessageError, "Message sending failed: %s" % resp.status 531 532 return resp.read() 533 534 def parseURL(url): 535 536 "Return the scheme, host, port and path for the given 'url'." 537 538 scheme, host_port, path, query, fragment = urlsplit(url) 539 host_port = host_port.split(":") 540 541 if query: 542 path += "?" + query 543 544 if len(host_port) > 1: 545 host = host_port[0] 546 port = int(host_port[1]) 547 else: 548 host = host_port[0] 549 port = 80 550 551 return scheme, host, port, path 552 553 # vim: tabstop=4 expandtab shiftwidth=4