Package: mailscripts
Version: 0.11-1
Tags: patch

Hi Sean--

Attached are two patches for mailscripts that in combination permit
decryption using OpenPGP Transferable Secret Keys that are found in the
filesystem.

I'm finding this particularly useful when i'm working on writing
specifications and documentation that use (for example) the OpenPGP
Sample Keys [0].

Here's what it looks like:

    $ email-print-mime-structure --pgpkey [email protected] < 
msg.eml
    └┬╴multipart/encrypted 2190 bytes
     ├─╴application/pgp-encrypted 11 bytes
     └─╴application/octet-stream 1613 bytes
      ↧ (decrypts to)
      └─╴text/plain 425 bytes
    $

Note that this doesn't use GnuPG or even attempt to access the user's
GnuPG secret keyring, so that it's not handling any
personally-confidential message info.

If that's something that people want (e.g. it might be useful when using
this script in encrypted-message debugging contexts), then i can work on
adding that too.

But this is already a sufficiently-useful featureset that i think it is
worth sharing.

You can also just merge these patches from the mimestructure-pgpmime
branch at https://salsa.debian.org/dkg/mailscripts

Thanks for maintaining mailscripts!

        --dkg


[0] https://datatracker.ietf.org/doc/draft-bre-openpgp-samples/

From 65fcb89b4d774d02ccafea735737a106ba05f295 Mon Sep 17 00:00:00 2001
From: Daniel Kahn Gillmor <[email protected]>
Date: Fri, 1 Nov 2019 14:13:27 -0400
Subject: [PATCH 1/2] email-print-mime-structure: be typesafe

This adds enough typechecking that the following check passes:

    mypy --strict email-print-mimestructure

Signed-off-by: Daniel Kahn Gillmor <[email protected]>
---
 email-print-mime-structure | 54 ++++++++++++++++++++++----------------
 1 file changed, 32 insertions(+), 22 deletions(-)

diff --git a/email-print-mime-structure b/email-print-mime-structure
index 7adeb2b..185173f 100755
--- a/email-print-mime-structure
+++ b/email-print-mime-structure
@@ -29,44 +29,49 @@ Example:
 If you want to number the parts, i suggest piping the output through
 something like "cat -n"
 '''
-import email
 import sys
+import email
+import logging
 
-def print_part(z, prefix):
-    fname = '' if z.get_filename() is None else ' [' + z.get_filename() + ']'
-    cset = '' if z.get_charset() is None else ' (' + z.get_charset() + ')'
-    disp = z.get_params(None, header='Content-Disposition')
-    if (disp is None):
-        disposition = ''
-    else:
-        disposition = ''
+from typing import Optional, Union, List, Tuple, Any
+from email.charset import Charset
+from email.message import Message
+
+def print_part(z:Message, prefix:str) -> None:
+    ofname:Optional[str] = z.get_filename()
+    fname:str = '' if ofname is None else f' [{ofname}]'
+    ocharset:Union[Charset, str, None] = z.get_charset()
+    cset:str = '' if ocharset is None else f' ({ocharset})'
+    disp:Union[List[Tuple[str,str]], List[str], None] = z.get_params(None, header='Content-Disposition')
+    disposition:str = ''
+    if (disp is not None):
         for d in disp:
             if d[0] in [ 'attachment', 'inline' ]:
                 disposition = ' ' + d[0]
+    nbytes:int
     if z.is_multipart():
+        # FIXME: it looks like we are counting chars here, not bytes:
         nbytes = len(z.as_string())
     else:
-        nbytes = len(z.get_payload())
+        payload:Union[List[Message], str, bytes, None] = z.get_payload()
+        if not isinstance(payload, (str,bytes)):
+            raise TypeError(f'expected payload to be either str or bytes, got {type(payload)}')
+        nbytes = len(payload)
 
-    print('{}{}{}{}{} {:d} bytes'.format(
-        prefix,
-        z.get_content_type(),
-        cset,
-        disposition,
-        fname,
-        nbytes,
-    ))
+    print(f'{prefix}{z.get_content_type()}{cset}{disposition}{fname} {nbytes} bytes')
 
-def test(z, prefix=''):
+def test(z:Message, prefix:str='') -> None:
     if (z.is_multipart()):
         print_part(z, prefix+'┬╴')
         if prefix.endswith('└'):
             prefix = prefix.rpartition('└')[0] + ' '
         if prefix.endswith('├'):
             prefix = prefix.rpartition('├')[0] + '│'
-        parts = z.get_payload()
+        parts:Union[List[Message], str, bytes, None] = z.get_payload()
+        if not isinstance(parts, list):
+            raise TypeError(f'parts was {type(parts)}, expected List[Message]')
         i = 0
-        while (i < parts.__len__()-1):
+        while (i < len(parts)-1):
             test(parts[i], prefix + '├')
             i += 1
         test(parts[i], prefix + '└')
@@ -74,4 +79,9 @@ def test(z, prefix=''):
     else:
         print_part(z, prefix+'─╴')
 
-test(email.message_from_file(sys.stdin), '└')
+msg:Union[Message, str, int, Any] = email.message_from_file(sys.stdin)
+
+if isinstance(msg, Message):
+    test(msg, '└')
+else:
+    logging.error('Input was not an e-mail message')
-- 
2.24.0.rc1

f
From 53b56fc1dfd7236a51881de373a4586db3d16966 Mon Sep 17 00:00:00 2001
From: Daniel Kahn Gillmor <[email protected]>
Date: Fri, 1 Nov 2019 15:20:23 -0400
Subject: [PATCH 2/2] email-print-mime-structure: add decryption capability
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Add simple decryption capability for email-print-mime-structure, so
that it can do stuff like this:

$ email-print-mime-structure --pgpkey [email protected] < msg.eml
└┬╴multipart/encrypted 2190 bytes
 ├─╴application/pgp-encrypted 11 bytes
 └─╴application/octet-stream 1613 bytes
  ↧ (decrypts to)
  └─╴text/plain 425 bytes
$

At the moment, it only works with keys that can be found in the
filesystem, and when the pgpy module is installed.

Possible future work:

  - try using gpg to do the decryption from whatever gpg's system
    capabilities are

I've added python3-pgpy to the list of Recommends, since it is not a
hard dependency.

Signed-off-by: Daniel Kahn Gillmor <[email protected]>
---
 debian/control                   |   1 +
 email-print-mime-structure       | 135 +++++++++++++++++++++----------
 email-print-mime-structure.1.pod |  18 ++++-
 3 files changed, 106 insertions(+), 48 deletions(-)

diff --git a/debian/control b/debian/control
index 6d3a54f..fc2bccc 100644
--- a/debian/control
+++ b/debian/control
@@ -39,6 +39,7 @@ Recommends:
  devscripts,
  git,
  notmuch,
+ python3-pgpy,
 Architecture: all
 Description: collection of scripts for manipulating e-mail on Debian
  This package provides a collection of scripts for manipulating e-mail
diff --git a/email-print-mime-structure b/email-print-mime-structure
index 185173f..c893e4f 100755
--- a/email-print-mime-structure
+++ b/email-print-mime-structure
@@ -33,55 +33,102 @@ import sys
 import email
 import logging
 
+from argparse import ArgumentParser, Namespace
 from typing import Optional, Union, List, Tuple, Any
 from email.charset import Charset
 from email.message import Message
 
-def print_part(z:Message, prefix:str) -> None:
-    ofname:Optional[str] = z.get_filename()
-    fname:str = '' if ofname is None else f' [{ofname}]'
-    ocharset:Union[Charset, str, None] = z.get_charset()
-    cset:str = '' if ocharset is None else f' ({ocharset})'
-    disp:Union[List[Tuple[str,str]], List[str], None] = z.get_params(None, header='Content-Disposition')
-    disposition:str = ''
-    if (disp is not None):
-        for d in disp:
-            if d[0] in [ 'attachment', 'inline' ]:
-                disposition = ' ' + d[0]
-    nbytes:int
-    if z.is_multipart():
-        # FIXME: it looks like we are counting chars here, not bytes:
-        nbytes = len(z.as_string())
-    else:
-        payload:Union[List[Message], str, bytes, None] = z.get_payload()
-        if not isinstance(payload, (str,bytes)):
-            raise TypeError(f'expected payload to be either str or bytes, got {type(payload)}')
-        nbytes = len(payload)
+try:
+    import pgpy #type: ignore
+except ImportError:
+    pgpy = None
 
-    print(f'{prefix}{z.get_content_type()}{cset}{disposition}{fname} {nbytes} bytes')
 
-def test(z:Message, prefix:str='') -> None:
-    if (z.is_multipart()):
-        print_part(z, prefix+'┬╴')
-        if prefix.endswith('└'):
-            prefix = prefix.rpartition('└')[0] + ' '
-        if prefix.endswith('├'):
-            prefix = prefix.rpartition('├')[0] + '│'
-        parts:Union[List[Message], str, bytes, None] = z.get_payload()
-        if not isinstance(parts, list):
-            raise TypeError(f'parts was {type(parts)}, expected List[Message]')
-        i = 0
-        while (i < len(parts)-1):
-            test(parts[i], prefix + '├')
-            i += 1
-        test(parts[i], prefix + '└')
-        # FIXME: show epilogue?
-    else:
-        print_part(z, prefix+'─╴')
+class MimePrinter(object):
+    def __init__(self, args:Namespace):
+        self.args = args
+
+    def print_part(self, z:Message, prefix:str, parent:Optional[Message], num:int) -> None:
+        ofname:Optional[str] = z.get_filename()
+        fname:str = '' if ofname is None else f' [{ofname}]'
+        ocharset:Union[Charset, str, None] = z.get_charset()
+        cset:str = '' if ocharset is None else f' ({ocharset})'
+        disp:Union[List[Tuple[str,str]], List[str], None] = z.get_params(None, header='Content-Disposition')
+        disposition:str = ''
+        if (disp is not None):
+            for d in disp:
+                if d[0] in [ 'attachment', 'inline' ]:
+                    disposition = ' ' + d[0]
+        nbytes:int
+        if z.is_multipart():
+            # FIXME: it looks like we are counting chars here, not bytes:
+            nbytes = len(z.as_string())
+        else:
+            payload:Union[List[Message], str, bytes, None] = z.get_payload()
+            if not isinstance(payload, (str,bytes)):
+                raise TypeError(f'expected payload to be either str or bytes, got {type(payload)}')
+            # FIXME: it looks like we are counting chars here, not bytes:
+            nbytes = len(payload)
+
+        print(f'{prefix}{z.get_content_type()}{cset}{disposition}{fname} {nbytes} bytes')
+        if self.args.pgpkey and \
+           (parent is not None) and \
+           (parent.get_content_type().lower() == 'multipart/encrypted') and \
+           (str(parent.get_param('protocol')).lower() == 'application/pgp-encrypted') and \
+           (num == 2):
+            if pgpy is None:
+                logging.warning(f'Python module pgpy is not available, not decrypting (try "apt install python3-pgpy")', sys.stderr)
+            else:
+                cryptopayload:Optional[Message] = None
+                keyname:str
+                for keyname in self.args.pgpkey:
+                    try:
+                        key:pgpy.PGPKey
+                        key, _ = pgpy.PGPKey.from_file(keyname)
+                        msg:pgpy.PGPMessage = pgpy.PGPMessage.from_blob(z.get_payload())
+                        msg = key.decrypt(msg)
+                        cryptopayload:Message = email.message_from_bytes(msg.message)
+                        break
+                    except:
+                        pass
+                if cryptopayload is None:
+                    logging.warning(f'Unable to decrypt', sys.stderr)
+                else:
+                    newprefix = prefix[:-3] + ' '
+                    print(f'{newprefix}↧ (decrypts to)')
+                    self.test(cryptopayload, newprefix + '└', z, 0)
 
-msg:Union[Message, str, int, Any] = email.message_from_file(sys.stdin)
+    def test(self, z:Message, prefix:str, parent:Optional[Message], num:int) -> None:
+        if (z.is_multipart()):
+            self.print_part(z, prefix+'┬╴', parent, num)
+            if prefix.endswith('└'):
+                prefix = prefix.rpartition('└')[0] + ' '
+            if prefix.endswith('├'):
+                prefix = prefix.rpartition('├')[0] + '│'
+            parts:Union[List[Message], str, bytes, None] = z.get_payload()
+            if not isinstance(parts, list):
+                raise TypeError(f'parts was {type(parts)}, expected List[Message]')
+            i = 0
+            while (i < len(parts)-1):
+                self.test(parts[i], prefix + '├', z, i+1)
+                i += 1
+            self.test(parts[i], prefix + '└', z, i+1)
+            # FIXME: show epilogue?
+        else:
+            self.print_part(z, prefix+'─╴', parent, num)
+
+def main() -> None:
+    parser:ArgumentParser = ArgumentParser(description='Display MIME structure of stdin')
+    parser.add_argument('--pgpkey', metavar='KEYFILE', nargs='*',
+                        help='OpenPGP Transferable Secret Key for decrypting')
+    args:Namespace = parser.parse_args()
+    msg:Union[Message, str, int, Any] = email.message_from_file(sys.stdin)
+
+    if isinstance(msg, Message):
+        printer:MimePrinter = MimePrinter(args)
+        printer.test(msg, '└', None, 0)
+    else:
+        logging.error('Input was not an e-mail message')
 
-if isinstance(msg, Message):
-    test(msg, '└')
-else:
-    logging.error('Input was not an e-mail message')
+if __name__ == '__main__':
+    main()
diff --git a/email-print-mime-structure.1.pod b/email-print-mime-structure.1.pod
index ab1ec05..27124e5 100644
--- a/email-print-mime-structure.1.pod
+++ b/email-print-mime-structure.1.pod
@@ -19,7 +19,16 @@ something like "cat -n".
 
 =head1 OPTIONS
 
-None.
+=over 4
+
+=item B<--pgpkey=>I<KEYFILE>
+
+I<KEYFILE> should be an unarmored OpenPGP transferable secret key.  If
+a PGP/MIME-encrypted message is found, this key will be tried for
+decryption.
+
+=back
+
 
 =head1 EXAMPLE
 
@@ -34,9 +43,10 @@ None.
 
 =head1 LIMITATIONS
 
-B<email-print-mime-structure> currently does not try to decrypt
-encrypted e-mails, so it cannot display the MIME structure that is
-inside the message's cryptographic envelope.
+B<email-print-mime-structure> only decrypts encrypted e-mails using
+raw, non-password-protected OpenPGP secret keys (see B<--pgpkey>,
+above).  If it is unable to decrypt an encrypted part with it, it will
+warn on stderr.
 
 B<email-print-mime-structure>'s output is not stable, and is not
 intended to be interpreted by machines, so please do not depend on it
-- 
2.24.0.rc1

Attachment: signature.asc
Description: PGP signature

Reply via email to