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 class Message: 25 26 "An update message." 27 28 def __init__(self, text=None): 29 self.date = None 30 self.updates = [] 31 if text: 32 self.parse_text(text) 33 34 def init_date(self, message): 35 36 "Obtain the date of the given 'message'." 37 38 if message.has_key("Date"): 39 self.date = parsedate(message["Date"]) 40 else: 41 self.date = None 42 43 def parse_text(self, text): 44 45 "Parse the given 'text' as a message." 46 47 self.handle_message(message_from_string(text)) 48 49 def handle_message(self, message): 50 51 "Handle the given 'message', recording the separate updates." 52 53 self.init_date(message) 54 55 # The message either consists of a collection of updates. 56 57 if message.is_multipart() and is_collection(message): 58 for part in message.get_payload(): 59 self.updates.append(part) 60 61 # Or the message is a single update. 62 63 else: 64 self.updates.append(message) 65 66 def add_updates(self, parts): 67 68 """ 69 Add the given 'parts' to a message. 70 """ 71 72 for part in updates: 73 self.add_update(part) 74 75 def add_update(self, part): 76 77 """ 78 Add an update 'part' to a message. 79 """ 80 81 self.updates.append(part) 82 83 def get_update(self, alternatives): 84 85 """ 86 Return a suitable multipart object containing the supplied 87 'alternatives'. 88 """ 89 90 part = MIMEMultipart() 91 for alternative in alternatives: 92 part.attach(alternative) 93 return part 94 95 def get_payload(self, timestamped=True): 96 97 """ 98 Get the multipart payload for the message. If the 'timestamped' 99 parameter is omitted or set to a true value, the payload will be given a 100 date header set to the current date and time that can be used to assess 101 the validity of a message and to determine whether it has already been 102 received by a recipient. 103 """ 104 105 if len(self.updates) == 1: 106 message = self.updates[0] 107 else: 108 message = MIMEMultipart() 109 message.add_header("Update-Type", "collection") 110 for update in self.updates: 111 message.attach(update) 112 113 if timestamped: 114 timestamp(message) 115 self.init_date(message) 116 117 return message 118 119 class Mailbox: 120 121 "A collection of messages within a multipart message." 122 123 def __init__(self, text=None): 124 self.messages = [] 125 if text: 126 self.parse_text(text) 127 128 def parse_text(self, text): 129 130 "Parse the given 'text' as a mailbox." 131 132 message = message_from_string(text) 133 134 if message.is_multipart(): 135 for part in message.get_payload(): 136 self.messages.append(part) 137 else: 138 self.messages.append(message) 139 140 def add_message(self, message): 141 142 "Add the given 'message' to the mailbox." 143 144 self.messages.append(message) 145 146 def get_payload(self): 147 148 "Get the multipart payload for the mailbox." 149 150 mailbox = MIMEMultipart() 151 for message in self.messages: 152 mailbox.attach(message) 153 154 return mailbox 155 156 class MoinMessageError(Exception): 157 pass 158 159 class GPG: 160 161 "A wrapper around the gpg command using a particular configuration." 162 163 def __init__(self, homedir=None): 164 self.conf_args = [] 165 166 if homedir: 167 self.conf_args += ["--homedir", homedir] 168 169 self.errors = None 170 171 def run(self, args, text=None): 172 173 """ 174 Invoke gpg with the given 'args', supplying the given 'text' to the 175 command directly or, if 'text' is omitted, using a file provided as part 176 of the 'args' if appropriate. 177 178 Failure to complete the operation will result in a MoinMessageError 179 being raised. 180 """ 181 182 cmd = Popen(["gpg"] + self.conf_args + list(args), stdin=PIPE, stdout=PIPE, stderr=PIPE) 183 184 try: 185 # Attempt to write input to the command and to read output from the 186 # command. 187 188 try: 189 if text: 190 cmd.stdin.write(text) 191 cmd.stdin.close() 192 193 text = cmd.stdout.read() 194 195 # I/O errors can indicate the failure of the command. 196 197 except IOError: 198 pass 199 200 self.errors = cmd.stderr.read() 201 202 # Test for a zero result. 203 204 if not cmd.wait(): 205 return text 206 else: 207 raise MoinMessageError, self.errors 208 209 finally: 210 cmd.stdout.close() 211 cmd.stderr.close() 212 213 def verifyMessage(self, signature, content): 214 215 "Using the given 'signature', verify the given message 'content'." 216 217 # Write the detached signature and content to files. 218 219 signature_fd, signature_filename = mkstemp() 220 content_fd, content_filename = mkstemp() 221 222 try: 223 signature_fp = os.fdopen(signature_fd, "w") 224 content_fp = os.fdopen(content_fd, "w") 225 try: 226 signature_fp.write(signature) 227 content_fp.write(content) 228 finally: 229 signature_fp.close() 230 content_fp.close() 231 232 # Verify the message text. 233 234 text = self.run(["--status-fd", "1", "--verify", signature_filename, content_filename]) 235 236 # Return the details of the signing key. 237 238 identity = None 239 fingerprint = None 240 241 for line in text.split("\n"): 242 try: 243 prefix, msgtype, digest, details = line.strip().split(" ", 3) 244 except ValueError: 245 continue 246 247 # Return the fingerprint and identity details. 248 249 if msgtype == "GOODSIG": 250 identity = details 251 elif msgtype == "VALIDSIG": 252 fingerprint = digest 253 254 if identity and fingerprint: 255 return fingerprint, identity 256 257 return None 258 259 finally: 260 os.remove(signature_filename) 261 os.remove(content_filename) 262 263 def signMessage(self, message, keyid): 264 265 """ 266 Return a signed version of 'message' using the given 'keyid'. 267 """ 268 269 text = message.as_string() 270 signature = self.run(["--armor", "-u", keyid, "--detach-sig"], text) 271 272 # Make the container for the message. 273 274 signed_message = MIMEMultipart("signed", protocol="application/pgp-signature") 275 signed_message.attach(message) 276 277 signature_part = MIMEBase("application", "pgp-signature") 278 signature_part.set_payload(signature) 279 signed_message.attach(signature_part) 280 281 return signed_message 282 283 def decryptMessage(self, message): 284 285 "Return a decrypted version of 'message'." 286 287 return self.run(["--decrypt"], message) 288 289 def encryptMessage(self, message, keyid): 290 291 """ 292 Return an encrypted version of 'message' using the given 'keyid'. 293 """ 294 295 text = message.as_string() 296 encrypted = self.run(["--armor", "-r", keyid, "--encrypt", "--trust-model", "always"], text) 297 298 # Make the container for the message. 299 300 encrypted_message = MIMEMultipart("encrypted", protocol="application/pgp-encrypted") 301 302 # For encrypted content, add the declaration and content. 303 304 declaration = MIMEBase("application", "pgp-encrypted") 305 declaration.set_payload("Version: 1") 306 encrypted_message.attach(declaration) 307 308 content = MIMEApplication(encrypted, "octet-stream", encode_noop) 309 encrypted_message.attach(content) 310 311 return encrypted_message 312 313 # Communications functions. 314 315 def timestamp(message): 316 317 """ 318 Timestamp the given 'message' so that its validity can be assessed by the 319 recipient. 320 """ 321 322 datestr = formatdate() 323 324 if not message.has_key("Date"): 325 message.add_header("Date", datestr) 326 else: 327 message["Date"] = datestr 328 329 def sendMessage(message, url): 330 331 "Send 'message' to the given 'url." 332 333 scheme, host, port, path = parseURL(url) 334 text = message.as_string() 335 336 if scheme == "http": 337 cls = httplib.HTTPConnection 338 elif scheme == "https": 339 cls = httplib.HTTPSConnection 340 else: 341 raise MoinMessageError, "Communications protocol not supported: %s" % scheme 342 343 req = cls(host, port) 344 req.request("PUT", path, text) 345 resp = req.getresponse() 346 return resp.read() 347 348 def parseURL(url): 349 350 "Return the scheme, host, port and path for the given 'url'." 351 352 scheme, host_port, path, query, fragment = urlsplit(url) 353 host_port = host_port.split(":") 354 355 if query: 356 path += "?" + query 357 358 if len(host_port) > 1: 359 host = host_port[0] 360 port = int(host_port[1]) 361 else: 362 host = host_port[0] 363 port = 80 364 365 return scheme, host, port, path 366 367 # vim: tabstop=4 expandtab shiftwidth=4