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