1 #!/usr/bin/env python 2 3 """ 4 GPG utilities derived from the MoinMessage library. 5 6 Copyright (C) 2012, 2013, 2014 Paul Boddie <paul@boddie.org.uk> 7 8 This program is free software; you can redistribute it and/or modify it under 9 the terms of the GNU General Public License as published by the Free Software 10 Foundation; either version 3 of the License, or (at your option) any later 11 version. 12 13 This program is distributed in the hope that it will be useful, but WITHOUT 14 ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS 15 FOR A PARTICULAR PURPOSE. See the GNU General Public License for more 16 details. 17 18 You should have received a copy of the GNU General Public License along with 19 this program. If not, see <http://www.gnu.org/licenses/>. 20 """ 21 22 from email.encoders import encode_noop 23 from email.generator import Generator 24 from email.mime.multipart import MIMEMultipart 25 from email.mime.application import MIMEApplication 26 from email.mime.base import MIMEBase 27 from subprocess import Popen, PIPE 28 from tempfile import mkstemp 29 import os 30 31 try: 32 from cStringIO import StringIO 33 except ImportError: 34 from StringIO import StringIO 35 36 class GPGError(Exception): 37 pass 38 39 class GPGDecodingError(Exception): 40 pass 41 42 class GPGMissingPart(GPGDecodingError): 43 pass 44 45 class GPGBadContent(GPGDecodingError): 46 pass 47 48 class GPG: 49 50 "A wrapper around the gpg command using a particular configuration." 51 52 def __init__(self, homedir=None): 53 self.conf_args = [] 54 55 if homedir: 56 self.conf_args += ["--homedir", homedir] 57 58 self.errors = None 59 60 def run(self, args, text=None): 61 62 """ 63 Invoke gpg with the given 'args', supplying the given 'text' to the 64 command directly or, if 'text' is omitted, using a file provided as part 65 of the 'args' if appropriate. 66 67 Failure to complete the operation will result in a GPGError being 68 raised. 69 """ 70 71 cmd = Popen(["gpg"] + self.conf_args + list(args), stdin=PIPE, stdout=PIPE, stderr=PIPE) 72 73 # Attempt to write input to the command and to read output from the 74 # command. 75 76 text, self.errors = cmd.communicate(text) 77 78 # Test for a zero result. 79 80 if not cmd.returncode: 81 return text 82 else: 83 raise GPGError, self.errors 84 85 def verifyMessageText(self, signature, content): 86 87 "Using the given 'signature', verify the given message 'content'." 88 89 # Write the detached signature and content to files. 90 91 signature_fd, signature_filename = mkstemp() 92 content_fd, content_filename = mkstemp() 93 94 try: 95 signature_fp = os.fdopen(signature_fd, "w") 96 content_fp = os.fdopen(content_fd, "w") 97 try: 98 signature_fp.write(signature) 99 content_fp.write(content) 100 finally: 101 signature_fp.close() 102 content_fp.close() 103 104 # Verify the message text. 105 106 text = self.run(["--status-fd", "1", "--verify", signature_filename, content_filename]) 107 108 # Return the details of the signing key. 109 110 identity = None 111 fingerprint = None 112 113 for line in text.split("\n"): 114 try: 115 prefix, msgtype, digest, details = line.strip().split(" ", 3) 116 except ValueError: 117 continue 118 119 # Return the fingerprint and identity details. 120 121 if msgtype == "GOODSIG": 122 identity = details 123 elif msgtype == "VALIDSIG": 124 fingerprint = digest 125 126 if identity and fingerprint: 127 return fingerprint, identity 128 129 return None 130 131 finally: 132 os.remove(signature_filename) 133 os.remove(content_filename) 134 135 def verifyMessage(self, message): 136 137 """ 138 Verify the given RFC 3156 'message', returning a tuple of the form 139 (fingerprint, identity, content). 140 """ 141 142 content, signature = getContentAndSignature(message) 143 144 # Verify the message format. 145 146 if signature.get_content_type() != "application/pgp-signature": 147 raise GPGBadContent 148 149 # Verify the message. 150 151 fingerprint, identity = self.verifyMessageText(signature.get_payload(decode=True), as_string(content)) 152 return fingerprint, identity, content 153 154 def signMessage(self, message, keyid): 155 156 """ 157 Return a signed version of 'message' using the given 'keyid'. 158 """ 159 160 # Sign the container's representation. 161 162 signature = self.run(["--armor", "-u", keyid, "--detach-sig"], as_string(message)) 163 164 # Make the container for the message. 165 166 signed_message = MIMEMultipart("signed", protocol="application/pgp-signature") 167 signed_message.attach(message) 168 169 signature_part = MIMEBase("application", "pgp-signature") 170 signature_part.set_payload(signature) 171 signed_message.attach(signature_part) 172 173 return signed_message 174 175 def decryptMessageText(self, message): 176 177 "Return a decrypted version of 'message'." 178 179 return self.run(["--decrypt"], message) 180 181 def decryptMessage(self, message): 182 183 """ 184 Decrypt the given RFC 3156 'message', returning the message text. 185 """ 186 187 try: 188 declaration, content = message.get_payload() 189 except ValueError: 190 raise GPGMissingPart 191 192 # Verify the message format. 193 194 if content.get_content_type() != "application/octet-stream": 195 raise GPGBadContent 196 197 # Return the decrypted message text. 198 199 return self.decryptMessageText(content.get_payload(decode=True)) 200 201 def encryptMessage(self, message, keyid): 202 203 """ 204 Return an encrypted version of 'message' using the given 'keyid'. 205 """ 206 207 text = as_string(message) 208 encrypted = self.run(["--armor", "-r", keyid, "--encrypt", "--trust-model", "always"], text) 209 210 # Make the container for the message. 211 212 encrypted_message = MIMEMultipart("encrypted", protocol="application/pgp-encrypted") 213 214 # For encrypted content, add the declaration and content. 215 216 declaration = MIMEBase("application", "pgp-encrypted") 217 declaration.set_payload("Version: 1") 218 encrypted_message.attach(declaration) 219 220 content = MIMEApplication(encrypted, "octet-stream", encode_noop) 221 encrypted_message.attach(content) 222 223 return encrypted_message 224 225 def importKeys(self, text): 226 227 """ 228 Import the keys provided by the given 'text'. 229 """ 230 231 self.run(["--import"], text) 232 233 def exportKey(self, keyid): 234 235 """ 236 Return the "armoured" public key text for 'keyid' as a message part with 237 a suitable media type. 238 See: https://tools.ietf.org/html/rfc3156#section-7 239 """ 240 241 text = self.run(["--armor", "--export", keyid]) 242 return MIMEApplication(text, "pgp-keys", encode_noop) 243 244 def listKeys(self, keyid=None): 245 246 """ 247 Return a list of key details for keys on the keychain, selecting only 248 one specific key if 'keyid' is specified. 249 """ 250 251 text = self.run(["--list-keys", "--with-colons", "--with-fingerprint"] + 252 (keyid and ["0x%s" % keyid] or [])) 253 return self._getKeysFromResult(text) 254 255 def listSignatures(self, keyid=None): 256 257 """ 258 Return a list of key and signature details for keys on the keychain, 259 selecting only one specific key if 'keyid' is specified. 260 """ 261 262 text = self.run(["--list-sigs", "--with-colons", "--with-fingerprint"] + 263 (keyid and ["0x%s" % keyid] or [])) 264 return self._getKeysFromResult(text) 265 266 def getKeysFromMessagePart(self, part): 267 268 """ 269 Process an application/pgp-keys message 'part', returning a list of 270 key details. 271 """ 272 273 return self.getKeysFromString(part.get_payload(decode=True)) 274 275 def getKeysFromString(self, s): 276 277 """ 278 Return a list of key details extracted from the given key block string 279 's'. Signature information is also included through the use of the gpg 280 verbose option. 281 """ 282 283 text = self.run(["--with-colons", "--with-fingerprint", "-v"], s) 284 return self._getKeysFromResult(text) 285 286 def _getKeysFromResult(self, text): 287 288 """ 289 Return a list of key details extracted from the given command result 290 'text'. 291 """ 292 293 keys = [] 294 for line in text.split("\n"): 295 try: 296 recordtype, trust, keylength, algorithm, keyid, cdate, expdate, serial, ownertrust, _rest = line.split(":", 9) 297 except ValueError: 298 continue 299 300 if recordtype == "pub": 301 userid, _rest = _rest.split(":", 1) 302 keys.append({ 303 "type" : recordtype, "trust" : trust, "keylength" : keylength, 304 "algorithm" : algorithm, "keyid" : keyid, "cdate" : cdate, 305 "expdate" : expdate, "userid" : userid, "ownertrust" : ownertrust, 306 "fingerprint" : None, "subkeys" : [], "signatures" : [] 307 }) 308 elif recordtype == "sub" and keys: 309 keys[-1]["subkeys"].append({ 310 "trust" : trust, "keylength" : keylength, "algorithm" : algorithm, 311 "keyid" : keyid, "cdate" : cdate, "expdate" : expdate, 312 "ownertrust" : ownertrust 313 }) 314 elif recordtype == "fpr" and keys: 315 fingerprint, _rest = _rest.split(":", 1) 316 keys[-1]["fingerprint"] = fingerprint 317 elif recordtype == "sig" and keys: 318 userid, _rest = _rest.split(":", 1) 319 keys[-1]["signatures"].append({ 320 "keyid" : keyid, "cdate" : cdate, "expdate" : expdate, 321 "userid" : userid 322 }) 323 324 return keys 325 326 # Message serialisation functions, working around email module problems. 327 328 def as_string(message): 329 330 """ 331 Return the string representation of 'message', attempting to preserve the 332 precise original formatting. 333 """ 334 335 out = StringIO() 336 generator = Generator(out, False, 0) # disable reformatting measures 337 generator.flatten(message) 338 return out.getvalue() 339 340 # Message decoding functions. 341 342 # Detect PGP/GPG-encoded payloads. 343 # See: http://tools.ietf.org/html/rfc3156 344 345 def is_signed(message): 346 mimetype = message.get_content_type() 347 encoding = message.get_content_charset() 348 349 return mimetype == "multipart/signed" and \ 350 message.get_param("protocol") == "application/pgp-signature" 351 352 def is_encrypted(message): 353 mimetype = message.get_content_type() 354 encoding = message.get_content_charset() 355 356 return mimetype == "multipart/encrypted" and \ 357 message.get_param("protocol") == "application/pgp-encrypted" 358 359 def getContentAndSignature(message): 360 361 """ 362 Return the content and signature parts of the given RFC 3156 'message'. 363 364 NOTE: RFC 3156 states that signed messages should employ a detached 365 NOTE: signature but then shows "BEGIN PGP MESSAGE" for signatures 366 NOTE: instead of "BEGIN PGP SIGNATURE". 367 NOTE: The "micalg" parameter is currently not supported. 368 """ 369 370 try: 371 content, signature = message.get_payload() 372 return content, signature 373 except ValueError: 374 raise GPGMissingPart 375 376 # vim: tabstop=4 expandtab shiftwidth=4