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