From 2826d7a9300ad75672bee1b8aea6459d6f1cdb3e Mon Sep 17 00:00:00 2001 From: Kali Kaneko Date: Tue, 22 Sep 2015 23:46:13 -0400 Subject: initial commit --- .gitignore | 65 ++++++++++ README | 3 + memoryhole/__init__.py | 0 memoryhole/memoryhole.py | 258 ++++++++++++++++++++++++++++++++++++++ requirements.pip | 2 + setup.py | 0 tests/__init__.py | 0 tests/corpus/OpenPGP/.gitignore | 1 + tests/corpus/OpenPGP/README.md | 4 + tests/corpus/OpenPGP/genkeys | 31 +++++ tests/corpus/OpenPGP/gnupg-import | 27 ++++ tests/corpus/OpenPGP/julia.key | 59 +++++++++ tests/corpus/OpenPGP/julia.pgp | 30 +++++ tests/corpus/OpenPGP/obrian.key | 59 +++++++++ tests/corpus/OpenPGP/obrian.pgp | 30 +++++ tests/corpus/OpenPGP/winston.key | 59 +++++++++ tests/corpus/OpenPGP/winston.pgp | 30 +++++ tests/corpus/expected.A.eml | 69 ++++++++++ tests/corpus/sample.A.eml | 32 +++++ 19 files changed, 759 insertions(+) create mode 100644 .gitignore create mode 100644 README create mode 100644 memoryhole/__init__.py create mode 100644 memoryhole/memoryhole.py create mode 100644 requirements.pip create mode 100644 setup.py create mode 100644 tests/__init__.py create mode 100644 tests/corpus/OpenPGP/.gitignore create mode 100644 tests/corpus/OpenPGP/README.md create mode 100755 tests/corpus/OpenPGP/genkeys create mode 100755 tests/corpus/OpenPGP/gnupg-import create mode 100644 tests/corpus/OpenPGP/julia.key create mode 100644 tests/corpus/OpenPGP/julia.pgp create mode 100644 tests/corpus/OpenPGP/obrian.key create mode 100644 tests/corpus/OpenPGP/obrian.pgp create mode 100644 tests/corpus/OpenPGP/winston.key create mode 100644 tests/corpus/OpenPGP/winston.pgp create mode 100644 tests/corpus/expected.A.eml create mode 100644 tests/corpus/sample.A.eml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..916d8e6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,65 @@ +########## Generated by gig ########### + +### Python ### +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +env/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +*.egg-info/ +.installed.cfg +*.egg + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*,cover + +# Translations +*.mo +*.pot + +# Django stuff: +*.log + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# swapfiles +*.swp +*.swo diff --git a/README b/README new file mode 100644 index 0000000..1cabddc --- /dev/null +++ b/README @@ -0,0 +1,3 @@ +Protected E-mail headers. + +http://modernpgp.org/memoryhole/ diff --git a/memoryhole/__init__.py b/memoryhole/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/memoryhole/memoryhole.py b/memoryhole/memoryhole.py new file mode 100644 index 0000000..84d607c --- /dev/null +++ b/memoryhole/memoryhole.py @@ -0,0 +1,258 @@ +import logging +import tempfile + +import email +from email.message import Message +from email.header import Header +from email.MIMEMultipart import MIMEMultipart +from email.parser import Parser +from StringIO import StringIO + +import gnupg + +logger = logging.getLogger(__name__) + +COPY_HEADERS = ('Subject', 'Message-ID', 'Date', 'To', 'From') + + +class ProtectionLevel(object): + + def __init__(self, signed_by, encrypted_by): + + self.signed_by = signed_by + self.encrypted_by = encrypted_by + + # TODO add __cmp__ + + @property + def score(self): + if self.signed_by and self.encrypted_by: + return 3 + elif self.signed_by: + return 2 + elif self.encrypted_by: + return 1 + else: + return 0 + + def __repr__(self): + return '' % ( + len(self.signed_by), len(self.encrypted_by), self.score) + + +class MemoryHoleHeader(Header): + + def __init__(self, name, value): + self._name = name + self._value = value + + self._h = Header(value, header_name=name) + + self.signed_by = set([]) + self.encrypted_by = set([]) + + self._firstlinelen = self._h._firstlinelen + self._chunks = self._h._chunks + self._continuation_ws = self._h._continuation_ws + self._charset = self._h._charset + + @property + def protection_level(self): + return ProtectionLevel(self.signed_by, self.encrypted_by) + + def __repr__(self): + return '' % ( + self.protection_level.score, self._name, self._value) + + +class MemoryHoleMessage(Message): + + def __init__(self, msg, gpg): + self._msg = msg + self._gpg = gpg + + verified = verify_signature(msg, gpg) + self.signed = verified.valid + + self._mh_headers = {} + inner_headers = extract_wrapped_headers(msg) + + for name, value in inner_headers.items(): + mhh = MemoryHoleHeader(name, value) + mhh.signed_by.add(verified.key_id) + self._mh_headers[name] = mhh + + self._charset = self._msg._charset + self._headers = self._msg._headers + self._payload = self._msg._payload + self.preamble = self._msg.preamble + + def get_protected_header(self, header_name): + return self._mh_headers.get(header_name) + + # TODO add is_tampered_header + # TODO add __getattr__, lookup the protected headers first + + +def extract_wrapped_headers(msg): + top_payload = msg.get_payload()[0] + if top_payload.get_content_type() == 'multipart/mixed': + headers = _extract_attached_headers(top_payload) + return headers + + +def verify_signature(msg, gpg): + if not msg.get_content_type() == 'multipart/signed': + logger.error('attempted to verify signature, but msg is not signed') + return False + payloads = msg.get_payload() + body = payloads[0] + attached_sig = payloads[1] + + if not attached_sig.get_content_type() == 'application/pgp-signature': + logger.error('expected an attached signature') + return False + sig = _extract_sig_block(attached_sig.as_string()) + fd, sig_fname = tempfile.mkstemp() + with open(sig_fname, 'w') as sigfile: + sigfile.write(sig) + + body = body.as_string().replace('\n', '\r\n') + verified = gpg.verify_file(StringIO(body), sig_file=sig_fname) + return verified + + +def protect_message(basemsg, gpg, do_sign=True, boundary=None, + sign_digest_algo='SHA512', passphrase=None): + + msg = email.message.Message() + _msg = MIMEMultipart( + _subtype="signed", micalg="pgp-%s" % sign_digest_algo.lower(), + protocol="application/pgp-signature") + + for hdr in COPY_HEADERS: + msg[hdr] = basemsg[hdr] + for hdr in _msg.keys(): + msg[hdr] = _msg[hdr] + if boundary: + msg.set_boundary(boundary()) + + wrapper = _wrap_with_header(basemsg, boundary=boundary) + msg.attach(wrapper) + + if do_sign: + payload = wrapper.as_string().replace('\n', '\r\n') + sig = gpg.sign( + payload, detach=True, + digest_algo=sign_digest_algo, clearsign=False, + passphrase=passphrase) + + signed_msg = _make_signature_subpart(sig) + msg.attach(signed_msg) + return msg + + +def _extract_sig_block(text): + sep = '\n' + BEGIN = '-----BEGIN PGP SIGNATURE-----' + sig_lines = text.split(sep) + return sep.join(sig_lines[sig_lines.index(BEGIN):]) + + +def _extract_attached_headers(msg): + + def split_header(line): + sep = ': ' + split = line.split(sep) + return (split[0], sep.join(split[1:])) + + first = msg.get_payload()[0] + if first.get_content_type() == 'text/rfc822-headers': + raw = first.get_payload() + headers = dict([ + split_header(line) for line in filter(None, raw.split('\n'))]) + return headers + + +def _build_embedded_header(msg): + r = '' + for x in COPY_HEADERS: + if msg.get(x): + r += x + ': ' + msg.get(x) + '\n' + return r + + +def _boundary_factory(start): + counter = {'value': ord(start)} + + def _gen_boundary(): + boundary = chr(counter['value']) * 12 + counter['value'] -= 1 + return boundary + return _gen_boundary + + + +def _wrap_with_header(msg, boundary=None): + body = email.message.Message() + body.set_payload(msg.get_payload()) + body.add_header('Content-Type', msg['Content-Type']) + + emh = email.message.Message() + emh.set_type('text/rfc822-headers') + emh.add_header('Content-Disposition', 'attachment') + emh.set_payload(_build_embedded_header(msg)) + del emh['MIME-Version'] + + wrapper = email.message.Message() + wrapper.set_type('multipart/mixed') + if boundary: + wrapper.set_boundary(boundary()) + + wrapper.set_payload([emh, body]) + del wrapper['MIME-Version'] + return wrapper + + +def _make_signature_subpart(signature): + message = Message() + message['Content-Type'] = 'application/pgp-signature; name="signature.asc"' + message.set_payload(str(signature)) + return message + +if __name__ == "__main__": + import os + import sys + import logging + if os.environ.get('DEBUG'): + logging.basicConfig(level=logging.DEBUG) + if len(sys.argv) != 3: + print "Usage: memoryhole mail_path key_path" + sys.exit() + msg_path = sys.argv[1] + key_path = sys.argv[2] + parser = Parser() + gpg = gnupg.GPG( + binary='/usr/bin/gpg', + homedir='/tmp/memoryhole-tests/', use_agent=False) + + with open('tests/corpus/OpenPGP/' + key_path + '.key') as key_f: + key_data = key_f.read() + gpg.import_keys(key_data) + with open('tests/corpus/OpenPGP/' + key_path + '.pgp') as key_f: + key_data = key_f.read() + gpg.import_keys(key_data) + + #if not gpg.list_keys(): + #key_input = gpg.gen_key_input(key_length=1024, key_type='RSA') + #gpg.gen_key(key_input) + + with open(msg_path) as f: + basetext = f.read() + basemsg = parser.parsestr(basetext) + + boundary = _boundary_factory('c') + msg = protect_message( + basemsg, gpg, boundary=boundary, + sign_digest_algo='SHA256', passphrase='_' + key_path + '_') + print msg.as_string() diff --git a/requirements.pip b/requirements.pip new file mode 100644 index 0000000..e3b45e5 --- /dev/null +++ b/requirements.pip @@ -0,0 +1,2 @@ +# use peep! +gnupg diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/corpus/OpenPGP/.gitignore b/tests/corpus/OpenPGP/.gitignore new file mode 100644 index 0000000..03f357b --- /dev/null +++ b/tests/corpus/OpenPGP/.gitignore @@ -0,0 +1 @@ +GNUPGHOME diff --git a/tests/corpus/OpenPGP/README.md b/tests/corpus/OpenPGP/README.md new file mode 100644 index 0000000..037d893 --- /dev/null +++ b/tests/corpus/OpenPGP/README.md @@ -0,0 +1,4 @@ +This directory contains OpenPGP public and private key material that +can be used to interpret messages from this corpus. + +See the genkey script for how they were generated. diff --git a/tests/corpus/OpenPGP/genkeys b/tests/corpus/OpenPGP/genkeys new file mode 100755 index 0000000..d5e74b8 --- /dev/null +++ b/tests/corpus/OpenPGP/genkeys @@ -0,0 +1,31 @@ +#!/bin/bash + +# Daniel Kahn Gillmor +# 2015-07-15 + +# How to generate the keys used for the creation of the message +# corpus. + +export WORKDIR=$(mktemp -d) +export GNUPGHOME="$WORKDIR" + +cat >"${WORKDIR}/gpg-agent.conf"<' +gpg2 --batch --pinentry-mode=loopback --passphrase=_julia_ --quick-gen-key 'Julia ' +gpg2 --batch --pinentry-mode=loopback --passphrase=_obrian_ --quick-gen-key "O'Brian " + +for x in winston julia obrian; do + gpg2 --armor --batch --pinentry-mode=loopback --passphrase=_${x}_ --output $x.key --export-secret-key $x + gpg2 --armor --batch --output $x.pgp --export $x +done + diff --git a/tests/corpus/OpenPGP/gnupg-import b/tests/corpus/OpenPGP/gnupg-import new file mode 100755 index 0000000..a92bce2 --- /dev/null +++ b/tests/corpus/OpenPGP/gnupg-import @@ -0,0 +1,27 @@ +#!/bin/bash + +mkdir -p -m 0700 GNUPGHOME +export GNUPGHOME=$(pwd)/GNUPGHOME + +cat >GNUPGHOME/gpg-agent.conf < +From: Winston + +--cccccccccccc +Content-Type: multipart/mixed; boundary="bbbbbbbbbbbb" + +--bbbbbbbbbbbb +Content-Type: text/rfc822-headers +Content-Disposition: attachment + +Date: Thu, 16 Jul 2015 11:44:44 +0200 +Subject: alternative text/html message with embedded header, signed +Message-ID: A@memoryhole.example +To: Julia +From: Winston + +--bbbbbbbbbbbb +Content-Type: multipart/alternative; boundary="aaaaaaaaaaaa" + +--aaaaaaaaaaaa +Content-Type: text/plain + +This is a test +message on multiple lines + +with a silly bit more. +-- +and a .sig here. + +--aaaaaaaaaaaa +Content-Type: text/html + + + +titles are usually unrendered + + +

This is a test
message on multiple lines

+

with a silly bit more.

+
+

and a .sig here.

+ + +--aaaaaaaaaaaa-- + +--bbbbbbbbbbbb-- + +--cccccccccccc +Content-Type: application/pgp-signature; name="signature.asc" + +-----BEGIN PGP SIGNATURE----- + +iQEcBAABCAAGBQJWAiCsAAoJEBX7TryOLWy3JpEH/Ao8INvddDi7OJhsXWlLAHiu +GaDS/xSXvsXgVe3IstKQLeepwvjkrismFNxQ7+5uBJxVXr+TgQgG8KAXZM7VYl+D +rBnZYTlLnepSeeqeS3gcB9EWNABopCuDjEdCDO+CHudR4vFaN8wlqafxMdKqR74z +i+49RXUXlBJMJJNNKrzJyatltRRyGBM0sqUIt6ZqrhwT5DwJZFbMBLx23X9heFFi +7oLGSrAzlSfVr8KM5DoniAyNXSNWtVjj1hl8xCVJtIfsOcvpwCVR15vzfuH623og +0Ag7G0GyKb1yNKlIZeUvJ7uo4Tm2+gUikn7Pk1eGqAEC5R7Ip1u8bkEtgdftinw= +=OeWg +-----END PGP SIGNATURE----- + +--cccccccccccc-- + diff --git a/tests/corpus/sample.A.eml b/tests/corpus/sample.A.eml new file mode 100644 index 0000000..cf67839 --- /dev/null +++ b/tests/corpus/sample.A.eml @@ -0,0 +1,32 @@ +Subject: alternative text/html message with embedded header, signed +Message-ID: A@memoryhole.example +Date: Thu, 16 Jul 2015 11:44:44 +0200 +To: Julia +From: Winston +MIME-Version: 1.0 +Content-Type: multipart/alternative; boundary="aaaaaaaaaaaa" + +--aaaaaaaaaaaa +Content-Type: text/plain + +This is a test +message on multiple lines + +with a silly bit more. +-- +and a .sig here. + +--aaaaaaaaaaaa +Content-Type: text/html + + + +titles are usually unrendered + + +

This is a test
message on multiple lines

+

with a silly bit more.

+
+

and a .sig here.

+ + -- cgit v1.2.3