diff --git a/.gitignore b/.gitignore index 6db145c624ea35345ca1edac8f7dc402a75a14f5..e4f7e20f1ed85ea30415e3cadba3b0ee16acdd83 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ *.pyc .coverage .tox +/htmlcov/ diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index be668c18f8578e8595d7559e1f7c8a9359bf3eb6..11712e17fcbd83757c1d6f68020206a6e4292b06 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,5 +1,4 @@ image: "ai/test:base" run_tests: - script: "tox -e py27" - + script: "tox" diff --git a/pgp_mime_lib/__init__.py b/pgp_mime_lib/__init__.py index b3500250237b49ac368ffbb274f28cd6f724960f..1a7ce329f0061233b181471386b350a6dcd769b2 100644 --- a/pgp_mime_lib/__init__.py +++ b/pgp_mime_lib/__init__.py @@ -1,19 +1,20 @@ # Package to send PGP-encrypted emails to users using ephemeral key # storage (so, no keyserver interaction, no keyring management). +from __future__ import absolute_import 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 +import gnupg + +from ._compat import StringIO +from . import rfc3156 # Digest algorithm to use in signatures. @@ -33,20 +34,20 @@ class GPG(object): public keyring which is deleted afterwards. """ - - def __init__(self, key_id, public_keyring=None, secret_keyring=None, + + def __init__(self, key_id, public_keyring=None, secret_key_dir=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 secret_key_dir: + secret_key_dir = os.path.join(_default_gpghome(), 'private-keys-v1.d') + + for f in (public_keyring, secret_key_dir): 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_dir = secret_key_dir self.secret_key_id = key_id self.passphrase = passphrase @@ -67,7 +68,8 @@ class GPG(object): if not public_keyring: public_keyring = os.path.join(tmpdir, 'pubring.gpg') shutil.copy(self.public_keyring, public_keyring) - os.chmod(public_keyring, 0600) + os.chmod(public_keyring, 0o600) + # 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. @@ -77,14 +79,18 @@ 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 +throw-keyids default-key %s\n -''' % self.secret_key_id) +''' % self.secret_key_id) # noqa + + # symlink the private key directory + os.symlink(self.secret_key_dir, os.path.join(tmpdir, 'private-keys-v1.d')) + # Create the gnupg.GPG object to talk to gnupg. - gpg = gnupg.GPG(binary=_default_gpgbinary(), - homedir=tmpdir, - keyring=public_keyring, - secring=self.secret_keyring) + gpg = gnupg.GPG(gpgbinary=_default_gpgbinary(), + gnupghome=tmpdir, + keyring=public_keyring) + # XXX this below might be wrong. gpg.encoding = 'utf-8' yield gpg @@ -96,15 +102,14 @@ default-key %s\n # public key, but we can't do it with the gnupg module. fp = _import_one_key(gpg, public_key) s = gpg.encrypt(msg_str, fp, passphrase=self.passphrase, - throw_keyids=True, always_trust=True) + always_trust=True, extra_args=["--throw-keyids"]) 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, + s = gpg.sign(msg_str, clearsign=False, detach=True, binary=False, passphrase=self.passphrase) if not s: raise Error('gpg --sign failed (no result)') @@ -133,7 +138,8 @@ default-key %s\n def _sign_message(self, gpg, msg): """Build a multipart/signed MIME message.""" - container = rfc3156.MultipartSigned('application/pgp-signature', 'pgp-' + DIGEST_ALGO.lower()) + container = rfc3156.MultipartSigned('application/pgp-signature', + 'pgp-' + DIGEST_ALGO.lower()) # _openpgp_mangle_for_signature modifies msg as a side effect. sig_msg = rfc3156.PGPSignature( @@ -177,7 +183,7 @@ default-key %s\n """ 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. @@ -233,10 +239,10 @@ def parse_public_key(public_key): 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: + with open(os.path.join(tmpdir, p), 'w'): pass - gpg = gnupg.GPG(binary=_default_gpgbinary(), - homedir=tmpdir) + gpg = gnupg.GPG(gpgbinary=_default_gpgbinary(), + gnupghome=tmpdir) gpg.encoding = 'utf-8' return _import_one_key(gpg, public_key) @@ -251,10 +257,13 @@ def _import_one_key(gpg, public_key): # one new key. We also want to report meaningful error # messages, so we are just going to bail out on the first bad # status returned by gpg. + if import_result.count != 1: + raise Error('more than one public key found in input') + fp = None for res in import_result.results: - if res['status'].strip() != 'Entirely new key': - raise Error('no valid keys found in input: %s' % res['status']) + if "Entirely new key" not in res["text"]: + raise Error('no valid keys found in input: %r' % res['text']) if not res['fingerprint']: continue if fp: diff --git a/pgp_mime_lib/_compat.py b/pgp_mime_lib/_compat.py new file mode 100644 index 0000000000000000000000000000000000000000..e474cd5a0c19862d7c60ff2c32ed7a5db9b1b85a --- /dev/null +++ b/pgp_mime_lib/_compat.py @@ -0,0 +1,20 @@ +import sys +import base64 + + +PY2 = sys.version_info[0] == 2 + + +if not PY2: + from io import StringIO + string_types = (str,) +else: + from cStringIO import StringIO + string_types = (str, unicode) + + +def base64_encodestring(data): + if PY2: + return base64.encodestring(data) + + return base64.encodebytes(data) diff --git a/pgp_mime_lib/rfc3156.py b/pgp_mime_lib/rfc3156.py index 35503c75adf0a18e6eb37c2df42b69ffcf2efe88..a46ab4a94176611cfaddd98b8915c2d20b6524da 100644 --- a/pgp_mime_lib/rfc3156.py +++ b/pgp_mime_lib/rfc3156.py @@ -20,9 +20,7 @@ """ Implements RFC 3156: MIME Security with OpenPGP. """ - -import base64 -from StringIO import StringIO +from __future__ import absolute_import from email.mime.application import MIMEApplication from email.mime.multipart import MIMEMultipart @@ -34,6 +32,8 @@ from email.generator import ( _make_boundary, ) +from ._compat import StringIO, string_types, base64_encodestring + # # A generator that solves http://bugs.python.org/issue14983 @@ -66,7 +66,7 @@ class RFC3156CompliantGenerator(Generator): subparts = msg.get_payload() if subparts is None: subparts = [] - elif isinstance(subparts, basestring): + elif isinstance(subparts, string_types): # e.g. a non-strict parse of a message with no starting boundary. self._fp.write(subparts) return @@ -120,18 +120,18 @@ class RFC3156CompliantGenerator(Generator): # solution, but a bit modified. # -def _bencode(s): +def _bencode(data): """ - Encode C{s} in base64. + Encode C{data} in base64. - :param s: The string to be encoded. - :type s: str + :param data: The string to be encoded. + :type data: bytes """ # We can't quite use base64.encodestring() since it tacks on a "courtesy # newline". Blech! - if not s: - return s - value = base64.encodestring(s) + if not data: + return data + value = base64_encodestring(data) return value[:-1] diff --git a/pgp_mime_lib/test/fixtures/gnupghome/private-keys-v1.d/4516CCD58FA4AAD110C1263FF072A27F0D2F23E6.key b/pgp_mime_lib/test/fixtures/gnupghome/private-keys-v1.d/4516CCD58FA4AAD110C1263FF072A27F0D2F23E6.key new file mode 100644 index 0000000000000000000000000000000000000000..ae1a1568fc77edbdffafae9f65cf34410d3c8c1a Binary files /dev/null and b/pgp_mime_lib/test/fixtures/gnupghome/private-keys-v1.d/4516CCD58FA4AAD110C1263FF072A27F0D2F23E6.key differ diff --git a/pgp_mime_lib/test/fixtures/gnupghome/private-keys-v1.d/9A858840ABF0ACFBA624D9AB03BF665BF9472335.key b/pgp_mime_lib/test/fixtures/gnupghome/private-keys-v1.d/9A858840ABF0ACFBA624D9AB03BF665BF9472335.key new file mode 100644 index 0000000000000000000000000000000000000000..a5e9f14f514eedced646fb4f368a8bc77a845a66 Binary files /dev/null and b/pgp_mime_lib/test/fixtures/gnupghome/private-keys-v1.d/9A858840ABF0ACFBA624D9AB03BF665BF9472335.key differ diff --git a/pgp_mime_lib/test/fixtures/gnupghome/private-keys-v1.d/BAF156EE72BCD5DC86C631A2D0C879EE103E2B23.key b/pgp_mime_lib/test/fixtures/gnupghome/private-keys-v1.d/BAF156EE72BCD5DC86C631A2D0C879EE103E2B23.key new file mode 100644 index 0000000000000000000000000000000000000000..4975c17082c841110905a420c5341acac95eaa02 Binary files /dev/null and b/pgp_mime_lib/test/fixtures/gnupghome/private-keys-v1.d/BAF156EE72BCD5DC86C631A2D0C879EE103E2B23.key differ diff --git a/pgp_mime_lib/test/fixtures/gnupghome/private-keys-v1.d/F847C7872AF397308E1B62CCA66B103D4BD1DD61.key b/pgp_mime_lib/test/fixtures/gnupghome/private-keys-v1.d/F847C7872AF397308E1B62CCA66B103D4BD1DD61.key new file mode 100644 index 0000000000000000000000000000000000000000..053ea087ce94d2b017c56ada0a56cf80d8155f9d Binary files /dev/null and b/pgp_mime_lib/test/fixtures/gnupghome/private-keys-v1.d/F847C7872AF397308E1B62CCA66B103D4BD1DD61.key differ diff --git a/pgp_mime_lib/test/fixtures/secret/pubring.gpg b/pgp_mime_lib/test/fixtures/gnupghome/pubring.gpg similarity index 100% rename from pgp_mime_lib/test/fixtures/secret/pubring.gpg rename to pgp_mime_lib/test/fixtures/gnupghome/pubring.gpg diff --git a/pgp_mime_lib/test/fixtures/secret/pubring.gpg~ b/pgp_mime_lib/test/fixtures/gnupghome/pubring.gpg~ similarity index 100% rename from pgp_mime_lib/test/fixtures/secret/pubring.gpg~ rename to pgp_mime_lib/test/fixtures/gnupghome/pubring.gpg~ diff --git a/pgp_mime_lib/test/fixtures/secret/random_seed b/pgp_mime_lib/test/fixtures/gnupghome/random_seed similarity index 100% rename from pgp_mime_lib/test/fixtures/secret/random_seed rename to pgp_mime_lib/test/fixtures/gnupghome/random_seed diff --git a/pgp_mime_lib/test/fixtures/secret/secring.gpg b/pgp_mime_lib/test/fixtures/gnupghome/secring.gpg similarity index 100% rename from pgp_mime_lib/test/fixtures/secret/secring.gpg rename to pgp_mime_lib/test/fixtures/gnupghome/secring.gpg diff --git a/pgp_mime_lib/test/fixtures/secret/trustdb.gpg b/pgp_mime_lib/test/fixtures/gnupghome/trustdb.gpg similarity index 100% rename from pgp_mime_lib/test/fixtures/secret/trustdb.gpg rename to pgp_mime_lib/test/fixtures/gnupghome/trustdb.gpg diff --git a/pgp_mime_lib/test/test_pgp_mime_lib.py b/pgp_mime_lib/test/test_pgp_mime_lib.py index 9da4a3c06d842bb15b25efabeb386e671760d845..1539dfcd308470c4c11843aa37ff0fd6d2b6679e 100644 --- a/pgp_mime_lib/test/test_pgp_mime_lib.py +++ b/pgp_mime_lib/test/test_pgp_mime_lib.py @@ -3,7 +3,6 @@ import shutil import tempfile import unittest import smtplib -import gnupg from email.mime.image import MIMEImage from email.mime.multipart import MIMEMultipart @@ -11,8 +10,13 @@ from email.mime.text import MIMEText import pgp_mime_lib +import logging +logging.getLogger('gnupg').setLevel(logging.DEBUG) +logging.basicConfig() + fixtures_dir = os.path.join(os.path.dirname(__file__), 'fixtures') +secret_key_dir = os.path.join(fixtures_dir, "gnupghome/private-keys-v1.d") class TestBase(unittest.TestCase): @@ -22,16 +26,15 @@ class TestBase(unittest.TestCase): # Load our test secret key. self.public_keyring = os.path.join( - fixtures_dir, 'secret/pubring.gpg') - self.secret_keyring = os.path.join( - fixtures_dir, 'secret/secring.gpg') + fixtures_dir, 'gnupghome/pubring.gpg') + self.secret_key_dir = secret_key_dir 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) + return pgp_mime_lib.GPG(self.secret_key, self.public_keyring, self.secret_key_dir) class TestSign(TestBase): @@ -188,7 +191,7 @@ class TestMIME(TestBase): m = MIMEText(u'test message') m.replace_header('Content-Transfer-Encoding', '8bit') self._encrypt(m) - + def test_multipart_message_with_image(self): msg = MIMEMultipart() msg.preamble = 'This is a message with an image attachment' diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000000000000000000000000000000000000..9512d4df52196ee6861151ee7e78247116af5ddb --- /dev/null +++ b/poetry.lock @@ -0,0 +1,86 @@ +[[package]] +category = "dev" +description = "A platform independent file lock." +name = "filelock" +optional = false +python-versions = "*" +version = "3.0.10" + +[[package]] +category = "dev" +description = "plugin and hook calling mechanisms for python" +name = "pluggy" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "0.8.1" + +[[package]] +category = "dev" +description = "library with cross-python path, ini-parsing, io, code, log facilities" +name = "py" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "1.7.0" + +[[package]] +category = "main" +description = "A wrapper for the Gnu Privacy Guard (GPG or GnuPG)" +name = "python-gnupg" +optional = false +python-versions = "*" +version = "0.4.4" + +[[package]] +category = "dev" +description = "Python 2 and 3 compatibility utilities" +name = "six" +optional = false +python-versions = ">=2.6, !=3.0.*, !=3.1.*" +version = "1.12.0" + +[[package]] +category = "dev" +description = "Python Library for Tom's Obvious, Minimal Language" +name = "toml" +optional = false +python-versions = "*" +version = "0.10.0" + +[[package]] +category = "dev" +description = "virtualenv-based automation of test activities" +name = "tox" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "3.7.0" + +[package.dependencies] +filelock = ">=3.0.0,<4" +pluggy = ">=0.3.0,<1" +py = ">=1.4.17,<2" +setuptools = ">=30.0.0" +six = ">=1.0.0,<2" +toml = ">=0.9.4" +virtualenv = ">=1.11.2" + +[[package]] +category = "dev" +description = "Virtual Python Environment builder" +name = "virtualenv" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "16.4.0" + +[metadata] +content-hash = "28aa155b6cccb2f93f6e632be14599cf66f63da25a8598a423cab7aae6a6a9eb" +python-versions = "~2.7 || ^3.5" + +[metadata.hashes] +filelock = ["b8d5ca5ca1c815e1574aee746650ea7301de63d87935b3463d26368b76e31633", "d610c1bb404daf85976d7a82eb2ada120f04671007266b708606565dd03b5be6"] +pluggy = ["8ddc32f03971bfdf900a81961a48ccf2fb677cf7715108f85295c67405798616", "980710797ff6a041e9a73a5787804f848996ecaa6f8a1b1e08224a5894f2074a"] +py = ["bf92637198836372b520efcba9e020c330123be8ce527e535d185ed4b6f45694", "e76826342cefe3c3d5f7e8ee4316b80d1dd8a300781612ddbc765c17ba25a6c6"] +python-gnupg = ["45daf020b370bda13a1429c859fcdff0b766c0576844211446f9266cae97fb0e", "85c231850a0275c9722f06e34b45a22510b83a6a6e88f93b5ae32ba04c95056c"] +six = ["3350809f0555b11f552448330d0b52d5f24c91a322ea4a15ef22629740f3761c", "d16a0141ec1a18405cd4ce8b4613101da75da0e9a7aec5bdd4fa804d0e0eba73"] +toml = ["229f81c57791a41d65e399fc06bf0848bab550a9dfd5ed66df18ce5f05e73d5c", "235682dd292d5899d361a811df37e04a8828a5b1da3115886b73cf81ebc9100e", "f1db651f9657708513243e61e6cc67d101a39bad662eaa9b5546f789338e07a3"] +tox = ["04f8f1aa05de8e76d7a266ccd14e0d665d429977cd42123bc38efa9b59964e9e", "25ef928babe88c71e3ed3af0c464d1160b01fca2dd1870a5bb26c2dea61a17fc"] +virtualenv = ["8b9abfc51c38b70f61634bf265e5beacf6fae11fc25d355d1871f49b8e45f0db", "cceab52aa7d4df1e1871a70236eb2b89fcfe29b6b43510d9738689787c513261"] diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000000000000000000000000000000000000..3b4809de2cb6da9a22eb9458521ed6fef325a31b --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,16 @@ +[tool.poetry] +name = "pgp_mime_lib" +version = "0.1.1" +description = "Create PGP/MIME emails." +authors = ["AI <info@autistici.org>"] + +[tool.poetry.dependencies] +python = "~2.7 || ^3.5" +python-gnupg = "^0.4.4" + +[tool.poetry.dev-dependencies] +tox = "^3.7" + +[build-system] +requires = ["poetry>=0.12"] +build-backend = "poetry.masonry.api" diff --git a/setup.py b/setup.py index c5e06fb26217a370884c181a8acff12b651bcba4..f38b4082aed00e903bff86d62fee5b12fb460a57 100755 --- a/setup.py +++ b/setup.py @@ -9,11 +9,9 @@ setup( 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, + install_requires=[ + "python-gnupg", + ], packages=find_packages(), - package_data={}, - entry_points={}, + zip_safe=False, ) - diff --git a/tox.ini b/tox.ini index e71f54e0f5a86089595a26538b7296da2139047f..677b10850cfa7840fa7cb316d4f7c109684305ef 100644 --- a/tox.ini +++ b/tox.ini @@ -1,13 +1,31 @@ [tox] -envlist = py27 +envlist = py27, py3, flake8 [testenv] deps= - nose - coverage -commands= - /usr/bin/env LANG=en_US.UTF-8 \ - nosetests -v \ - --with-coverage --cover-package=pgp_mime_lib \ - --cover-erase --cover-html --cover-html-dir=htmlcov \ - [] + pytest + pytest-cov +setenv = + LANG=en_US.UTF-8 + GNUPG=/usr/bin/gpg +commands = + # run pytest with -x --pdb to drop into pdb at the first failure + pytest --cov=pgp_mime_lib --cov-report html:htmlcov --cov-report term {posargs} + +[testenv:flake8] +basepython = python3 +deps = + flake8 + flake8-builtins + flake8-bugbear +skip_install = true +commands = flake8 pgp_mime_lib/ setup.py + +[flake8] +exclude = .tox/*, .git/*, build/*, pgp_mime_lib/_compat.py +max-line-length = 100 +select = + E,F,W,C90,B,B902,C + +[pytest] +norecursedirs = .tox