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 try: 200 # Attempt to write input to the command and to read output from the 201 # command. 202 203 try: 204 if text: 205 cmd.stdin.write(text) 206 cmd.stdin.close() 207 208 text = cmd.stdout.read() 209 210 # I/O errors can indicate the failure of the command. 211 212 except IOError: 213 pass 214 215 self.errors = cmd.stderr.read() 216 217 # Test for a zero result. 218 219 if not cmd.wait(): 220 return text 221 else: 222 raise MoinMessageError, self.errors 223 224 finally: 225 cmd.stdout.close() 226 cmd.stderr.close() 227 228 def verifyMessageText(self, signature, content): 229 230 "Using the given 'signature', verify the given message 'content'." 231 232 # Write the detached signature and content to files. 233 234 signature_fd, signature_filename = mkstemp() 235 content_fd, content_filename = mkstemp() 236 237 try: 238 signature_fp = os.fdopen(signature_fd, "w") 239 content_fp = os.fdopen(content_fd, "w") 240 try: 241 signature_fp.write(signature) 242 content_fp.write(content) 243 finally: 244 signature_fp.close() 245 content_fp.close() 246 247 # Verify the message text. 248 249 text = self.run(["--status-fd", "1", "--verify", signature_filename, content_filename]) 250 251 # Return the details of the signing key. 252 253 identity = None 254 fingerprint = None 255 256 for line in text.split("\n"): 257 try: 258 prefix, msgtype, digest, details = line.strip().split(" ", 3) 259 except ValueError: 260 continue 261 262 # Return the fingerprint and identity details. 263 264 if msgtype == "GOODSIG": 265 identity = details 266 elif msgtype == "VALIDSIG": 267 fingerprint = digest 268 269 if identity and fingerprint: 270 return fingerprint, identity 271 272 return None 273 274 finally: 275 os.remove(signature_filename) 276 os.remove(content_filename) 277 278 def verifyMessage(self, message): 279 280 """ 281 Verify the given RFC 3156 'message', returning a tuple of the form 282 (fingerprint, identity, content). 283 """ 284 285 content, signature = getContentAndSignature(message) 286 287 # Verify the message format. 288 289 if signature.get_content_type() != "application/pgp-signature": 290 raise MoinMessageBadContent 291 292 # Verify the message. 293 294 fingerprint, identity = self.verifyMessageText(signature.get_payload(), content.as_string()) 295 return fingerprint, identity, content 296 297 def signMessage(self, message, keyid): 298 299 """ 300 Return a signed version of 'message' using the given 'keyid'. 301 """ 302 303 text = message.as_string() 304 signature = self.run(["--armor", "-u", keyid, "--detach-sig"], text) 305 306 # Make the container for the message. 307 308 signed_message = MIMEMultipart("signed", protocol="application/pgp-signature") 309 signed_message.attach(message) 310 311 signature_part = MIMEBase("application", "pgp-signature") 312 signature_part.set_payload(signature) 313 signed_message.attach(signature_part) 314 315 return signed_message 316 317 def decryptMessageText(self, message): 318 319 "Return a decrypted version of 'message'." 320 321 return self.run(["--decrypt"], message) 322 323 def decryptMessage(self, message): 324 325 """ 326 Decrypt the given RFC 3156 'message', returning the message text. 327 """ 328 329 try: 330 declaration, content = message.get_payload() 331 except ValueError: 332 raise MoinMessageMissingPart 333 334 # Verify the message format. 335 336 if content.get_content_type() != "application/octet-stream": 337 raise MoinMessageBadContent 338 339 # Return the decrypted message text. 340 341 return self.decryptMessageText(content.get_payload()) 342 343 def encryptMessage(self, message, keyid): 344 345 """ 346 Return an encrypted version of 'message' using the given 'keyid'. 347 """ 348 349 text = message.as_string() 350 encrypted = self.run(["--armor", "-r", keyid, "--encrypt", "--trust-model", "always"], text) 351 352 # Make the container for the message. 353 354 encrypted_message = MIMEMultipart("encrypted", protocol="application/pgp-encrypted") 355 356 # For encrypted content, add the declaration and content. 357 358 declaration = MIMEBase("application", "pgp-encrypted") 359 declaration.set_payload("Version: 1") 360 encrypted_message.attach(declaration) 361 362 content = MIMEApplication(encrypted, "octet-stream", encode_noop) 363 encrypted_message.attach(content) 364 365 return encrypted_message 366 367 # Message decoding functions. 368 369 # Detect PGP/GPG-encoded payloads. 370 # See: http://tools.ietf.org/html/rfc3156 371 372 def is_signed(message): 373 mimetype = message.get_content_type() 374 encoding = message.get_content_charset() 375 376 return mimetype == "multipart/signed" and \ 377 message.get_param("protocol") == "application/pgp-signature" 378 379 def is_encrypted(message): 380 mimetype = message.get_content_type() 381 encoding = message.get_content_charset() 382 383 return mimetype == "multipart/encrypted" and \ 384 message.get_param("protocol") == "application/pgp-encrypted" 385 386 def getContentAndSignature(message): 387 388 """ 389 Return the content and signature parts of the given RFC 3156 'message'. 390 391 NOTE: RFC 3156 states that signed messages should employ a detached 392 NOTE: signature but then shows "BEGIN PGP MESSAGE" for signatures 393 NOTE: instead of "BEGIN PGP SIGNATURE". 394 NOTE: The "micalg" parameter is currently not supported. 395 """ 396 397 try: 398 content, signature = message.get_payload() 399 return content, signature 400 except ValueError: 401 raise MoinMessageMissingPart 402 403 # Communications functions. 404 405 def timestamp(message): 406 407 """ 408 Timestamp the given 'message' so that its validity can be assessed by the 409 recipient. 410 """ 411 412 datestr = formatdate() 413 414 if not message.has_key("Date"): 415 message.add_header("Date", datestr) 416 else: 417 message["Date"] = datestr 418 419 def sendMessage(message, url, method="PUT"): 420 421 """ 422 Send 'message' to the given 'url' using the given 'method' (using PUT as the 423 default if omitted). 424 """ 425 426 scheme, host, port, path = parseURL(url) 427 text = message.as_string() 428 429 if scheme == "http": 430 cls = httplib.HTTPConnection 431 elif scheme == "https": 432 cls = httplib.HTTPSConnection 433 else: 434 raise MoinMessageError, "Communications protocol not supported: %s" % scheme 435 436 req = cls(host, port) 437 req.request(method, path, text) 438 resp = req.getresponse() 439 440 if resp.status >= 400: 441 raise MoinMessageError, "Message sending failed: %s" % resp.status 442 443 return resp.read() 444 445 def parseURL(url): 446 447 "Return the scheme, host, port and path for the given 'url'." 448 449 scheme, host_port, path, query, fragment = urlsplit(url) 450 host_port = host_port.split(":") 451 452 if query: 453 path += "?" + query 454 455 if len(host_port) > 1: 456 host = host_port[0] 457 port = int(host_port[1]) 458 else: 459 host = host_port[0] 460 port = 80 461 462 return scheme, host, port, path 463 464 # vim: tabstop=4 expandtab shiftwidth=4