Commit 90ea5402 authored by ale's avatar ale

initial commit

parents
*.egg-info
*.pyc
.coverage
.tox
This diff is collapsed.
pgp-mime-lib
============
Simple Python package to send
[PGP/MIME](https://www.ietf.org/rfc/rfc3156.txt) multipart/encrypted
and multipart/signed emails. Uses the
[gnupg](https://pypi.python.org/pypi/gnupg) Python module to talk to
gpg.
The package is agnostic with respect to key management: recipients'
public keys are not permanently stored in a keyring, but are passed
along with each message by the caller.
# Package to send PGP-encrypted emails to users using ephemeral key
# storage (so, no keyserver interaction, no keyring management).
import contextlib
import gnupg
import os
import re
import shutil
import tempfile
from cStringIO import StringIO
from email import encoders
from email.mime.multipart import MIMEMultipart
from email.mime.application import MIMEApplication
from pgp_mime_lib import rfc3156
# Digest algorithm to use in signatures.
DIGEST_ALGO = 'SHA256'
class Error(Exception):
pass
class GPG(object):
"""GPG encrypted email generator.
Uses a specific key from a secret keyring, but it does not
maintain a public keyring. Instead, it is given the ASCII-encoded
public key for every recipient, and it imports it to a temporary
public keyring which is deleted afterwards.
"""
def __init__(self, key_id, public_keyring=None, secret_keyring=None,
passphrase=None):
if not public_keyring:
public_keyring = os.path.join(_default_gpghome(), 'pubring.gpg')
if not secret_keyring:
secret_keyring = os.path.join(_default_gpghome(), 'secring.gpg')
for f in (public_keyring, secret_keyring):
if not os.path.exists(f):
raise Error('no such file or directory: %s' % f)
self.public_keyring = public_keyring
self.secret_keyring = secret_keyring
self.secret_key_id = key_id
self.passphrase = passphrase
@contextlib.contextmanager
def _sane_gpg(self, public_keyring=None):
"""Create a temporary GPG environment.
If public_keyring is None, the temporary environment will
contain a writable public keyring, that will be a copy of the
default one. If we don't need to import keys, we can pass
self.public_keyring as an optimization (avoids a file copy).
Note that we must always have our public key available for
signing.
"""
with _temp_dir() as tmpdir:
if not public_keyring:
public_keyring = os.path.join(tmpdir, 'pubring.gpg')
shutil.copy(self.public_keyring, public_keyring)
os.chmod(public_keyring, 0600)
# Create a gpg.conf with some options. Not all are used
# and for some of them there are equivalent command-line
# options available through the gnupg module.
with open(os.path.join(tmpdir, 'gpg.conf'), 'w') as fd:
fd.write('''
personal-digest-preferences SHA512 SHA384 SHA256 SHA224
cert-digest-algo SHA256
default-preference-list SHA512 SHA384 SHA256 SHA224 AES256 AES192 AES CAST5 ZLIB BZIP2 ZIP Uncompressed
sig-notation issuer-fpr@notations.openpgp.fifthhorseman.net=%%g
#throw-keyids
default-key %s\n
''' % self.secret_key_id)
# Create the gnupg.GPG object to talk to gnupg.
gpg = gnupg.GPG(homedir=tmpdir,
keyring=public_keyring,
secring=self.secret_keyring)
gpg.encoding = 'utf-8'
yield gpg
def _encrypt(self, gpg, msg_str, public_key):
"""Encrypt msg_str to the given public_key."""
# Import the key (or keys) provided to us into a temporary
# keyring. We would really like to call gpg with the
# --hidden-recipients-file option, to avoid importing the
# public key, but we can't do it with the gnupg module.
import_result = gpg.import_keys(public_key)
fp = [x['fingerprint'] for x in import_result.results]
if not fp:
raise Error('no valid keys found')
s = gpg.encrypt(msg_str, fp[0], passphrase=self.passphrase,
throw_keyids=True, always_trust=True)
if not s:
raise Error('gpg --encrypt failed (no result)')
return str(s)
def _sign(self, gpg, msg_str):
"""Sign msg_str and return the detached signature."""
s = gpg.sign(msg_str, digest_algo=DIGEST_ALGO,
clearsign=False, detach=True, binary=False,
passphrase=self.passphrase)
if not s:
raise Error('gpg --sign failed (no result)')
if not s.data:
raise Error('gpg --sign failed (no result data)')
return s.data
def _encrypt_message(self, gpg, msg, public_key):
"""Build a multipart/encrypted MIME message."""
container = rfc3156.MultipartEncrypted('application/pgp-encrypted')
enc_msg = MIMEApplication(
self._encrypt(gpg, msg.as_string(unixfrom=False), public_key),
_subtype='octet-stream',
_encoder=encoders.encode_noop)
enc_msg.add_header('Content-Disposition', 'attachment',
filename='msg.asc')
meta_msg = rfc3156.PGPEncrypted()
meta_msg.add_header('Content-Disposition', 'attachment')
container.attach(meta_msg)
container.attach(enc_msg)
return container
def _sign_message(self, gpg, msg):
"""Build a multipart/signed MIME message."""
container = rfc3156.MultipartSigned('application/pgp-signature', 'pgp-' + DIGEST_ALGO.lower())
# _openpgp_mangle_for_signature modifies msg as a side effect.
sig_msg = rfc3156.PGPSignature(
self._sign(gpg, _openpgp_mangle_for_signature(msg)))
container.attach(msg)
container.attach(sig_msg)
return container
def encrypt_message(self, msg, public_key):
"""Encrypt msg to public_key.
Args:
msg (email.Message) message to sign
public_key (str) public key of the recipient
Returns:
An email.Message object.
"""
with self._sane_gpg() as gpg:
return self._encrypt_message(gpg, msg, public_key)
def sign_and_encrypt_message(self, msg, public_key):
"""Sign and encrypt a message to public_key.
Args:
msg (email.Message) message to sign
public_key (str) public key of the recipient
Returns:
An email.Message object.
"""
with self._sane_gpg() as gpg:
return self._encrypt_message(gpg, self._sign_message(gpg, msg), public_key)
def sign_message(self, msg):
"""Sign the given message.
Args:
msg (email.Message) message to sign
Returns:
An email.Message object.
"""
with self._sane_gpg(self.public_keyring) as gpg:
return self._sign_message(gpg, msg)
def _openpgp_mangle_for_signature(msg):
"""Return a message suitable for signing.
Encodes multipart message parts in msg as base64, then renders the
message to string enforcing the right newline conventions. The
returned value is suitable for signing according to RFC 3156.
The incoming message is modified in-place.
"""
rfc3156.encode_base64_rec(msg)
fp = StringIO()
g = rfc3156.RFC3156CompliantGenerator(
fp, mangle_from_=False, maxheaderlen=76)
g.flatten(msg)
s = re.sub('\r?\n', '\r\n', fp.getvalue())
if msg.is_multipart():
if not s.endswith('\r\n'):
s += '\r\n'
return s
def _default_gpghome():
if 'GNUPGHOME' in os.environ:
return os.environ['GNUPGHOME']
return os.path.expanduser('~/.gnupg')
@contextlib.contextmanager
def _temp_dir():
"""Create a temporary directory, as a context manager."""
tmpdir = tempfile.mkdtemp()
try:
yield tmpdir
finally:
shutil.rmtree(tmpdir)
def parse_public_key(public_key):
"""Parses a PGP public key.
The input is expected to contain a single PGP public key, either
in ASCII-armored format or not. The function will return the key
fingerprint, or raise an Error (possibly with a descriptive
message) if there was an error.
"""
with _temp_dir() as tmpdir:
# Create empty pubrings.
for p in 'pubring.gpg', 'secring.gpg':
with open(os.path.join(tmpdir, p), 'w') as fd:
pass
gpg = gnupg.GPG(homedir=tmpdir)
gpg.encoding = 'utf-8'
import_result = gpg.import_keys(public_key)
if not import_result:
raise Error('Generic GPG import error')
fp = [x['fingerprint'] for x in import_result.results]
if not fp:
raise Error('No valid keys found in input')
if len(fp) > 1:
raise Error('More than one valid key found in input')
return fp[0]
This diff is collapsed.
-----BEGIN PGP PUBLIC KEY BLOCK-----
Version: GnuPG v1.4.12 (GNU/Linux)
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
T+KyEbprFS4EQYnc8t0=
=QsDH
-----END PGP PUBLIC KEY BLOCK-----
import os
import shutil
import tempfile
import unittest
import smtplib
import gnupg
from email.mime.text import MIMEText
import pgp_mime_lib
fixtures_dir = os.path.join(os.path.dirname(__file__), 'fixtures')
class TestBase(unittest.TestCase):
def setUp(self):
self.dir = tempfile.mkdtemp()
# Create a temporary secret key.
self.public_keyring = os.path.join(
fixtures_dir, 'secret/pubring.gpg')
self.secret_keyring = os.path.join(
fixtures_dir, 'secret/secring.gpg')
self.secret_key = '0x56A1E35992BCB5CF'
def tearDown(self):
shutil.rmtree(self.dir)
def _gpg(self):
return pgp_mime_lib.GPG(self.secret_key, self.public_keyring, self.secret_keyring)
class TestGPG(TestBase):
def test_sign_ok(self):
result = self._gpg().sign_message(MIMEText('test message'))
self.assertTrue(result)
def test_encrypt_and_sign_ok(self):
with open(os.path.join(fixtures_dir, 'pubkey1.asc')) as fd:
pubkey = fd.read()
msg = MIMEText('test message')
result = self._gpg().sign_and_encrypt_message(msg, pubkey)
self.assertTrue(result)
def test_encrypt_fails_with_bad_public_key(self):
self.assertRaises(
pgp_mime_lib.Error,
self._gpg().encrypt_message, MIMEText('test_message'), 'this is not a pgp key')
def test_parse_public_key_ok(self):
with open(os.path.join(fixtures_dir, 'pubkey1.asc')) as fd:
pubkey = fd.read()
result = pgp_mime_lib.parse_public_key(pubkey)
self.assertTrue(result)
def test_parse_public_key_fail(self):
self.assertRaises(
pgp_mime_lib.Error, pgp_mime_lib.parse_public_key, 'this is not a pgp key')
def test_encrypt_and_sign_send_via_smtp(self):
# Send an email to yourself if you want to manually check the
# results, by defining some environment variables.
try:
smtp_server = os.environ['SMTP_SERVER']
smtp_user = os.environ['SMTP_USER']
smtp_password = os.environ['SMTP_PASSWORD']
smtp_recip = os.environ['SMTP_RECIPIENT']
smtp_pubkey = os.environ['SMTP_PUBLIC_KEY']
except KeyError:
raise unittest.SkipTest()
with open(smtp_pubkey) as fd:
pubkey = fd.read()
msg = MIMEText('test message')
result = self._gpg().sign_and_encrypt_message(msg, pubkey)
result['To'] = smtp_recip
result['Subject'] = 'test output'
s = smtplib.SMTP_SSL(smtp_server, 465)
s.login(smtp_user, smtp_password)
s.sendmail(smtp_recip, [smtp_recip], result.as_string(unixfrom=False))
s.quit()
#!/usr/bin/python
from setuptools import setup, find_packages
setup(
name="pgp-mime-lib",
version="0.1",
description="Create PGP/MIME emails.",
author="Autistici/Inventati",
author_email="info@autistici.org",
url="https://git.autistici.org/ai/pgp-mime-lib.git",
install_requires=["gnupg"],
setup_requires=[],
zip_safe=False,
packages=find_packages(),
package_data={},
entry_points={},
)
[testenv]
deps=
nose
coverage
commands=
/usr/bin/env LANG=en_US.UTF-8 \
nosetests \
--with-coverage --cover-package=pgp_mime_lib \
[]
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment