Commit 90ea5402 authored by ale's avatar ale
Browse files

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]
# From github.com/leapcode/leap_mail
#
# -*- coding: utf-8 -*-
# rfc3156.py
# Copyright (C) 2013 LEAP
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
"""
Implements RFC 3156: MIME Security with OpenPGP.
"""
import base64
from StringIO import StringIO
from email.mime.application import MIMEApplication
from email.mime.multipart import MIMEMultipart
from email import errors
from email.generator import (
Generator,
fcre,
NL,
_make_boundary,
)
#
# A generator that solves http://bugs.python.org/issue14983
#
class RFC3156CompliantGenerator(Generator):
"""
An email generator that addresses Python's issue #14983 for multipart
messages.
This is just a copy of email.generator.Generator which fixes the following
bug: http://bugs.python.org/issue14983
"""
def _handle_multipart(self, msg):
"""
A multipart handling implementation that addresses issue #14983.
This is just a copy of the parent's method which fixes the following
bug: http://bugs.python.org/issue14983 (see the line marked with
"(***)").
:param msg: The multipart message to be handled.
:type msg: email.message.Message
"""
# The trick here is to write out each part separately, merge them all
# together, and then make sure that the boundary we've chosen isn't
# present in the payload.
msgtexts = []
subparts = msg.get_payload()
if subparts is None:
subparts = []
elif isinstance(subparts, basestring):
# e.g. a non-strict parse of a message with no starting boundary.
self._fp.write(subparts)
return
elif not isinstance(subparts, list):
# Scalar payload
subparts = [subparts]
for part in subparts:
s = StringIO()
g = self.clone(s)
g.flatten(part, unixfrom=False)
msgtexts.append(s.getvalue())
# BAW: What about boundaries that are wrapped in double-quotes?
boundary = msg.get_boundary()
if not boundary:
# Create a boundary that doesn't appear in any of the
# message texts.
alltext = NL.join(msgtexts)
boundary = _make_boundary(alltext)
msg.set_boundary(boundary)
# If there's a preamble, write it out, with a trailing CRLF
if msg.preamble is not None:
preamble = msg.preamble
if self._mangle_from_:
preamble = fcre.sub('>From ', msg.preamble)
self._fp.write(preamble + '\n')
# dash-boundary transport-padding CRLF
self._fp.write('--' + boundary + '\n')
# body-part
if msgtexts:
self._fp.write(msgtexts.pop(0))
# *encapsulation
# --> delimiter transport-padding
# --> CRLF body-part
for body_part in msgtexts:
# delimiter transport-padding CRLF
self._fp.write('\n--' + boundary + '\n')
# body-part
self._fp.write(body_part)
# close-delimiter transport-padding
self._fp.write('\n--' + boundary + '--' + '\n') # (***) Solve #14983
if msg.epilogue is not None:
self._fp.write('\n')
epilogue = msg.epilogue
if self._mangle_from_:
epilogue = fcre.sub('>From ', msg.epilogue)
self._fp.write(epilogue)
#
# Base64 encoding: these are almost the same as python's email.encoder
# solution, but a bit modified.
#
def _bencode(s):
"""
Encode C{s} in base64.
:param s: The string to be encoded.
:type s: str
"""
# We can't quite use base64.encodestring() since it tacks on a "courtesy
# newline". Blech!
if not s:
return s
value = base64.encodestring(s)
return value[:-1]
def encode_base64(msg):
"""
Encode a non-multipart message's payload in Base64 (in place).
This method modifies the message contents in place and adds or replaces an
appropriate Content-Transfer-Encoding header.
:param msg: The non-multipart message to be encoded.
:type msg: email.message.Message
"""
encoding = msg.get('Content-Transfer-Encoding', None)
if encoding is not None:
encoding = encoding.lower()
# XXX Python's email module can only decode quoted-printable, base64 and
# uuencoded data, so we might have to implement other decoding schemes in
# order to support RFC 3156 properly and correctly calculate signatures
# for multipart attachments (eg. 7bit or 8bit encoded attachments). For
# now, if content is already encoded as base64 or if it is encoded with
# some unknown encoding, we just pass.
if encoding in [None, 'quoted-printable', 'x-uuencode', 'uue', 'x-uue']:
orig = msg.get_payload(decode=True)
encdata = _bencode(orig)
msg.set_payload(encdata)
# replace or set the Content-Transfer-Encoding header.
try:
msg.replace_header('Content-Transfer-Encoding', 'base64')
except KeyError:
msg['Content-Transfer-Encoding'] = 'base64'
elif encoding not in ('7bit', 'base64'):
raise Exception('Unknown content-transfer-encoding: %s' % encoding)
def encode_base64_rec(msg):
"""
Encode (possibly multipart) messages in base64 (in place).
This method modifies the message contents in place.
:param msg: The non-multipart message to be encoded.
:type msg: email.message.Message
"""
if not msg.is_multipart():
encode_base64(msg)
else:
for sub in msg.get_payload():
encode_base64_rec(sub)
#
# RFC 1847: multipart/signed and multipart/encrypted
#
class MultipartSigned(MIMEMultipart):
"""
Multipart/Signed MIME message according to RFC 1847.
2.1. Definition of Multipart/Signed
(1) MIME type name: multipart
(2) MIME subtype name: signed
(3) Required parameters: boundary, protocol, and micalg
(4) Optional parameters: none
(5) Security considerations: Must be treated as opaque while in
transit
The multipart/signed content type contains exactly two body parts.
The first body part is the body part over which the digital signature
was created, including its MIME headers. The second body part
contains the control information necessary to verify the digital
signature. The first body part may contain any valid MIME content
type, labeled accordingly. The second body part is labeled according
to the value of the protocol parameter.
When the OpenPGP digital signature is generated:
(1) The data to be signed MUST first be converted to its content-
type specific canonical form. For text/plain, this means
conversion to an appropriate character set and conversion of
line endings to the canonical <CR><LF> sequence.
(2) An appropriate Content-Transfer-Encoding is then applied; see
section 3. In particular, line endings in the encoded data
MUST use the canonical <CR><LF> sequence where appropriate
(note that the canonical line ending may or may not be present
on the last line of encoded data and MUST NOT be included in
the signature if absent).
(3) MIME content headers are then added to the body, each ending
with the canonical <CR><LF> sequence.
(4) As described in section 3 of this document, any trailing
whitespace MUST then be removed from the signed material.
(5) As described in [2], the digital signature MUST be calculated
over both the data to be signed and its set of content headers.
(6) The signature MUST be generated detached from the signed data
so that the process does not alter the signed data in any way.
"""
def __init__(self, protocol, micalg, boundary=None, _subparts=None):
"""
Initialize the multipart/signed message.
:param boundary: the multipart boundary string. By default it is
calculated as needed.
:type boundary: str
:param _subparts: a sequence of initial subparts for the payload. It
must be an iterable object, such as a list. You can always
attach new subparts to the message by using the attach() method.
:type _subparts: iterable
"""
MIMEMultipart.__init__(
self, _subtype='signed', boundary=boundary,
_subparts=_subparts)
self.set_param('protocol', protocol)
self.set_param('micalg', micalg)
def attach(self, payload):
"""
Add the C{payload} to the current payload list.
Also prevent from adding payloads with wrong Content-Type and from
exceeding a maximum of 2 payloads.
:param payload: The payload to be attached.
:type payload: email.message.Message
"""
# second payload's content type must be equal to the protocol
# parameter given on object creation
if len(self.get_payload()) == 1:
if payload.get_content_type() != self.get_param('protocol'):
raise errors.MultipartConversionError(
'Wrong content type %s.' % payload.get_content_type)
# prevent from adding more payloads
if len(self._payload) == 2:
raise errors.MultipartConversionError(
'Cannot have more than two subparts.')
MIMEMultipart.attach(self, payload)
class MultipartEncrypted(MIMEMultipart):
"""
Multipart/encrypted MIME message according to RFC 1847.
2.2. Definition of Multipart/Encrypted
(1) MIME type name: multipart
(2) MIME subtype name: encrypted
(3) Required parameters: boundary, protocol
(4) Optional parameters: none
(5) Security considerations: none
The multipart/encrypted content type contains exactly two body parts.
The first body part contains the control information necessary to
decrypt the data in the second body part and is labeled according to
the value of the protocol parameter. The second body part contains
the data which was encrypted and is always labeled
application/octet-stream.
"""
def __init__(self, protocol, boundary=None, _subparts=None):
"""
:param protocol: The encryption protocol to be added as a parameter to
the Content-Type header.
:type protocol: str
:param boundary: the multipart boundary string. By default it is
calculated as needed.
:type boundary: str
:param _subparts: a sequence of initial subparts for the payload. It
must be an iterable object, such as a list. You can always
attach new subparts to the message by using the attach() method.
:type _subparts: iterable
"""
MIMEMultipart.__init__(
self, _subtype='encrypted', boundary=boundary,
_subparts=_subparts)
self.set_param('protocol', protocol)
def attach(self, payload):
"""
Add the C{payload} to the current payload list.
Also prevent from adding payloads with wrong Content-Type and from
exceeding a maximum of 2 payloads.
:param payload: The payload to be attached.
:type payload: email.message.Message
"""
# first payload's content type must be equal to the protocol parameter
# given on object creation
if len(self._payload) == 0:
if payload.get_content_type() != self.get_param('protocol'):
raise errors.MultipartConversionError(
'Wrong content type.')
# second payload is always application/octet-stream
if len(self._payload) == 1:
if payload.get_content_type() != 'application/octet-stream':
raise errors.MultipartConversionError(
'Wrong content type %s.' % payload.get_content_type)
# prevent from adding more payloads
if len(self._payload) == 2:
raise errors.MultipartConversionError(
'Cannot have more than two subparts.')
MIMEMultipart.attach(self, payload)
#
# RFC 3156: application/pgp-encrypted, application/pgp-signed and
# application-pgp-signature.
#
class PGPEncrypted(MIMEApplication):
"""
Application/pgp-encrypted MIME media type according to RFC 3156.