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