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 # Message decoding functions. 351 352 # Detect PGP/GPG-encoded payloads. 353 # See: http://tools.ietf.org/html/rfc3156 354 355 def is_signed(message): 356 mimetype = message.get_content_type() 357 encoding = message.get_content_charset() 358 359 return mimetype == "multipart/signed" and \ 360 message.get_param("protocol") == "application/pgp-signature" 361 362 def is_encrypted(message): 363 mimetype = message.get_content_type() 364 encoding = message.get_content_charset() 365 366 return mimetype == "multipart/encrypted" and \ 367 message.get_param("protocol") == "application/pgp-encrypted" 368 369 def getContentAndSignature(message): 370 371 """ 372 Return the content and signature parts of the given RFC 3156 'message'. 373 374 NOTE: RFC 3156 states that signed messages should employ a detached 375 NOTE: signature but then shows "BEGIN PGP MESSAGE" for signatures 376 NOTE: instead of "BEGIN PGP SIGNATURE". 377 NOTE: The "micalg" parameter is currently not supported. 378 """ 379 380 try: 381 content, signature = message.get_payload() 382 return content, signature 383 except ValueError: 384 raise MoinMessageMissingPart 385 386 # Communications functions. 387 388 def timestamp(message): 389 390 """ 391 Timestamp the given 'message' so that its validity can be assessed by the 392 recipient. 393 """ 394 395 datestr = formatdate() 396 397 if not message.has_key("Date"): 398 message.add_header("Date", datestr) 399 else: 400 message["Date"] = datestr 401 402 def sendMessage(message, url, method="PUT"): 403 404 """ 405 Send 'message' to the given 'url' using the given 'method' (using PUT as the 406 default if omitted). 407 """ 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(method, path, text) 421 resp = req.getresponse() 422 423 if resp.status >= 400: 424 raise MoinMessageError, "Message sending failed: %s" % resp.status 425 426 return resp.read() 427 428 def parseURL(url): 429 430 "Return the scheme, host, port and path for the given 'url'." 431 432 scheme, host_port, path, query, fragment = urlsplit(url) 433 host_port = host_port.split(":") 434 435 if query: 436 path += "?" + query 437 438 if len(host_port) > 1: 439 host = host_port[0] 440 port = int(host_port[1]) 441 else: 442 host = host_port[0] 443 port = 80 444 445 return scheme, host, port, path 446 447 # vim: tabstop=4 expandtab shiftwidth=4