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 try: 286 content, signature = message.get_payload() 287 except ValueError: 288 raise MoinMessageMissingPart 289 290 # Verify the message format. 291 292 if signature.get_content_type() != "application/pgp-signature": 293 raise MoinMessageBadContent 294 295 # Verify the message. 296 297 fingerprint, identity = self.verifyMessageText(signature.get_payload(), content.as_string()) 298 return fingerprint, identity, content 299 300 def signMessage(self, message, keyid): 301 302 """ 303 Return a signed version of 'message' using the given 'keyid'. 304 """ 305 306 text = message.as_string() 307 signature = self.run(["--armor", "-u", keyid, "--detach-sig"], text) 308 309 # Make the container for the message. 310 311 signed_message = MIMEMultipart("signed", protocol="application/pgp-signature") 312 signed_message.attach(message) 313 314 signature_part = MIMEBase("application", "pgp-signature") 315 signature_part.set_payload(signature) 316 signed_message.attach(signature_part) 317 318 return signed_message 319 320 def decryptMessageText(self, message): 321 322 "Return a decrypted version of 'message'." 323 324 return self.run(["--decrypt"], message) 325 326 def decryptMessage(self, message): 327 328 """ 329 Decrypt the given RFC 3156 'message', returning the message text. 330 """ 331 332 try: 333 declaration, content = message.get_payload() 334 except ValueError: 335 raise MoinMessageMissingPart 336 337 # Verify the message format. 338 339 if content.get_content_type() != "application/octet-stream": 340 raise MoinMessageBadContent 341 342 # Return the decrypted message text. 343 344 return self.decryptMessageText(content.get_payload()) 345 346 def encryptMessage(self, message, keyid): 347 348 """ 349 Return an encrypted version of 'message' using the given 'keyid'. 350 """ 351 352 text = message.as_string() 353 encrypted = self.run(["--armor", "-r", keyid, "--encrypt", "--trust-model", "always"], text) 354 355 # Make the container for the message. 356 357 encrypted_message = MIMEMultipart("encrypted", protocol="application/pgp-encrypted") 358 359 # For encrypted content, add the declaration and content. 360 361 declaration = MIMEBase("application", "pgp-encrypted") 362 declaration.set_payload("Version: 1") 363 encrypted_message.attach(declaration) 364 365 content = MIMEApplication(encrypted, "octet-stream", encode_noop) 366 encrypted_message.attach(content) 367 368 return encrypted_message 369 370 # Message decoding functions. 371 372 # Detect PGP/GPG-encoded payloads. 373 # See: http://tools.ietf.org/html/rfc3156 374 375 def is_signed(message): 376 mimetype = message.get_content_type() 377 encoding = message.get_content_charset() 378 379 return mimetype == "multipart/signed" and \ 380 message.get_param("protocol") == "application/pgp-signature" 381 382 def is_encrypted(message): 383 mimetype = message.get_content_type() 384 encoding = message.get_content_charset() 385 386 return mimetype == "multipart/encrypted" and \ 387 message.get_param("protocol") == "application/pgp-encrypted" 388 389 # Communications functions. 390 391 def timestamp(message): 392 393 """ 394 Timestamp the given 'message' so that its validity can be assessed by the 395 recipient. 396 """ 397 398 datestr = formatdate() 399 400 if not message.has_key("Date"): 401 message.add_header("Date", datestr) 402 else: 403 message["Date"] = datestr 404 405 def sendMessage(message, url): 406 407 "Send 'message' to the given 'url." 408 409 scheme, host, port, path = parseURL(url) 410 text = message.as_string() 411 412 if scheme == "http": 413 cls = httplib.HTTPConnection 414 elif scheme == "https": 415 cls = httplib.HTTPSConnection 416 else: 417 raise MoinMessageError, "Communications protocol not supported: %s" % scheme 418 419 req = cls(host, port) 420 req.request("PUT", path, text) 421 resp = req.getresponse() 422 return resp.read() 423 424 def parseURL(url): 425 426 "Return the scheme, host, port and path for the given 'url'." 427 428 scheme, host_port, path, query, fragment = urlsplit(url) 429 host_port = host_port.split(":") 430 431 if query: 432 path += "?" + query 433 434 if len(host_port) > 1: 435 host = host_port[0] 436 port = int(host_port[1]) 437 else: 438 host = host_port[0] 439 port = 80 440 441 return scheme, host, port, path 442 443 # vim: tabstop=4 expandtab shiftwidth=4