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