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 getKeysFromMessagePart(self, part): 362 363 """ 364 Process an application/pgp-keys message 'part', returning a list of 365 key details. 366 """ 367 368 return self.getKeysFromString(part.get_payload()) 369 370 def getKeysFromString(self, s): 371 372 "Return a list of key details extracted from the given string 's'." 373 374 text = self.run(["--with-colons", "--with-fingerprint"], s) 375 keys = [] 376 377 for line in text.split("\n"): 378 try: 379 recordtype, trust, keylength, algorithm, keyid, cdate, expdate, serial, ownertrust, _rest = line.split(":", 9) 380 except ValueError: 381 break 382 383 if recordtype == "pub": 384 userid, _rest = _rest.split(":", 1) 385 keys.append({ 386 "type" : recordtype, "trust" : trust, "keylength" : keylength, 387 "algorithm" : algorithm, "keyid" : keyid, "cdate" : cdate, 388 "expdate" : expdate, "userid" : userid, "ownertrust" : ownertrust, 389 "fingerprint" : None, "subkeys" : [] 390 }) 391 elif recordtype == "sub" and keys: 392 keys[-1]["subkeys"].append({ 393 "trust" : trust, "keylength" : keylength, "algorithm" : algorithm, 394 "keyid" : keyid, "cdate" : cdate, "expdate" : expdate, 395 "ownertrust" : ownertrust 396 }) 397 elif recordtype == "fpr" and keys: 398 fingerprint, _rest = _rest.split(":", 1) 399 keys[-1]["fingerprint"] = fingerprint 400 401 return keys 402 403 # Message decoding functions. 404 405 # Detect PGP/GPG-encoded payloads. 406 # See: http://tools.ietf.org/html/rfc3156 407 408 def is_signed(message): 409 mimetype = message.get_content_type() 410 encoding = message.get_content_charset() 411 412 return mimetype == "multipart/signed" and \ 413 message.get_param("protocol") == "application/pgp-signature" 414 415 def is_encrypted(message): 416 mimetype = message.get_content_type() 417 encoding = message.get_content_charset() 418 419 return mimetype == "multipart/encrypted" and \ 420 message.get_param("protocol") == "application/pgp-encrypted" 421 422 def getContentAndSignature(message): 423 424 """ 425 Return the content and signature parts of the given RFC 3156 'message'. 426 427 NOTE: RFC 3156 states that signed messages should employ a detached 428 NOTE: signature but then shows "BEGIN PGP MESSAGE" for signatures 429 NOTE: instead of "BEGIN PGP SIGNATURE". 430 NOTE: The "micalg" parameter is currently not supported. 431 """ 432 433 try: 434 content, signature = message.get_payload() 435 return content, signature 436 except ValueError: 437 raise MoinMessageMissingPart 438 439 # Communications functions. 440 441 def timestamp(message): 442 443 """ 444 Timestamp the given 'message' so that its validity can be assessed by the 445 recipient. 446 """ 447 448 datestr = formatdate() 449 450 if not message.has_key("Date"): 451 message.add_header("Date", datestr) 452 else: 453 message["Date"] = datestr 454 455 def sendMessage(message, url, method="PUT"): 456 457 """ 458 Send 'message' to the given 'url' using the given 'method' (using PUT as the 459 default if omitted). 460 """ 461 462 scheme, host, port, path = parseURL(url) 463 text = message.as_string() 464 465 if scheme == "http": 466 cls = httplib.HTTPConnection 467 elif scheme == "https": 468 cls = httplib.HTTPSConnection 469 else: 470 raise MoinMessageError, "Communications protocol not supported: %s" % scheme 471 472 req = cls(host, port) 473 req.request(method, path, text) 474 resp = req.getresponse() 475 476 if resp.status >= 400: 477 raise MoinMessageError, "Message sending failed: %s" % resp.status 478 479 return resp.read() 480 481 def parseURL(url): 482 483 "Return the scheme, host, port and path for the given 'url'." 484 485 scheme, host_port, path, query, fragment = urlsplit(url) 486 host_port = host_port.split(":") 487 488 if query: 489 path += "?" + query 490 491 if len(host_port) > 1: 492 host = host_port[0] 493 port = int(host_port[1]) 494 else: 495 host = host_port[0] 496 port = 80 497 498 return scheme, host, port, path 499 500 # vim: tabstop=4 expandtab shiftwidth=4