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 GPG: 166 167 "A wrapper around the gpg command using a particular configuration." 168 169 def __init__(self, homedir=None): 170 self.conf_args = [] 171 172 if homedir: 173 self.conf_args += ["--homedir", homedir] 174 175 self.errors = None 176 177 def run(self, args, text=None): 178 179 """ 180 Invoke gpg with the given 'args', supplying the given 'text' to the 181 command directly or, if 'text' is omitted, using a file provided as part 182 of the 'args' if appropriate. 183 184 Failure to complete the operation will result in a MoinMessageError 185 being raised. 186 """ 187 188 cmd = Popen(["gpg"] + self.conf_args + list(args), stdin=PIPE, stdout=PIPE, stderr=PIPE) 189 190 try: 191 # Attempt to write input to the command and to read output from the 192 # command. 193 194 try: 195 if text: 196 cmd.stdin.write(text) 197 cmd.stdin.close() 198 199 text = cmd.stdout.read() 200 201 # I/O errors can indicate the failure of the command. 202 203 except IOError: 204 pass 205 206 self.errors = cmd.stderr.read() 207 208 # Test for a zero result. 209 210 if not cmd.wait(): 211 return text 212 else: 213 raise MoinMessageError, self.errors 214 215 finally: 216 cmd.stdout.close() 217 cmd.stderr.close() 218 219 def verifyMessage(self, signature, content): 220 221 "Using the given 'signature', verify the given message 'content'." 222 223 # Write the detached signature and content to files. 224 225 signature_fd, signature_filename = mkstemp() 226 content_fd, content_filename = mkstemp() 227 228 try: 229 signature_fp = os.fdopen(signature_fd, "w") 230 content_fp = os.fdopen(content_fd, "w") 231 try: 232 signature_fp.write(signature) 233 content_fp.write(content) 234 finally: 235 signature_fp.close() 236 content_fp.close() 237 238 # Verify the message text. 239 240 text = self.run(["--status-fd", "1", "--verify", signature_filename, content_filename]) 241 242 # Return the details of the signing key. 243 244 identity = None 245 fingerprint = None 246 247 for line in text.split("\n"): 248 try: 249 prefix, msgtype, digest, details = line.strip().split(" ", 3) 250 except ValueError: 251 continue 252 253 # Return the fingerprint and identity details. 254 255 if msgtype == "GOODSIG": 256 identity = details 257 elif msgtype == "VALIDSIG": 258 fingerprint = digest 259 260 if identity and fingerprint: 261 return fingerprint, identity 262 263 return None 264 265 finally: 266 os.remove(signature_filename) 267 os.remove(content_filename) 268 269 def signMessage(self, message, keyid): 270 271 """ 272 Return a signed version of 'message' using the given 'keyid'. 273 """ 274 275 text = message.as_string() 276 signature = self.run(["--armor", "-u", keyid, "--detach-sig"], text) 277 278 # Make the container for the message. 279 280 signed_message = MIMEMultipart("signed", protocol="application/pgp-signature") 281 signed_message.attach(message) 282 283 signature_part = MIMEBase("application", "pgp-signature") 284 signature_part.set_payload(signature) 285 signed_message.attach(signature_part) 286 287 return signed_message 288 289 def decryptMessage(self, message): 290 291 "Return a decrypted version of 'message'." 292 293 return self.run(["--decrypt"], message) 294 295 def encryptMessage(self, message, keyid): 296 297 """ 298 Return an encrypted version of 'message' using the given 'keyid'. 299 """ 300 301 text = message.as_string() 302 encrypted = self.run(["--armor", "-r", keyid, "--encrypt", "--trust-model", "always"], text) 303 304 # Make the container for the message. 305 306 encrypted_message = MIMEMultipart("encrypted", protocol="application/pgp-encrypted") 307 308 # For encrypted content, add the declaration and content. 309 310 declaration = MIMEBase("application", "pgp-encrypted") 311 declaration.set_payload("Version: 1") 312 encrypted_message.attach(declaration) 313 314 content = MIMEApplication(encrypted, "octet-stream", encode_noop) 315 encrypted_message.attach(content) 316 317 return encrypted_message 318 319 # Communications functions. 320 321 def timestamp(message): 322 323 """ 324 Timestamp the given 'message' so that its validity can be assessed by the 325 recipient. 326 """ 327 328 datestr = formatdate() 329 330 if not message.has_key("Date"): 331 message.add_header("Date", datestr) 332 else: 333 message["Date"] = datestr 334 335 def sendMessage(message, url): 336 337 "Send 'message' to the given 'url." 338 339 scheme, host, port, path = parseURL(url) 340 text = message.as_string() 341 342 if scheme == "http": 343 cls = httplib.HTTPConnection 344 elif scheme == "https": 345 cls = httplib.HTTPSConnection 346 else: 347 raise MoinMessageError, "Communications protocol not supported: %s" % scheme 348 349 req = cls(host, port) 350 req.request("PUT", path, text) 351 resp = req.getresponse() 352 return resp.read() 353 354 def parseURL(url): 355 356 "Return the scheme, host, port and path for the given 'url'." 357 358 scheme, host_port, path, query, fragment = urlsplit(url) 359 host_port = host_port.split(":") 360 361 if query: 362 path += "?" + query 363 364 if len(host_port) > 1: 365 host = host_port[0] 366 port = int(host_port[1]) 367 else: 368 host = host_port[0] 369 port = 80 370 371 return scheme, host, port, path 372 373 # vim: tabstop=4 expandtab shiftwidth=4