From 65fcb89b4d774d02ccafea735737a106ba05f295 Mon Sep 17 00:00:00 2001 From: Daniel Kahn Gillmor Date: Fri, 1 Nov 2019 14:13:27 -0400 Subject: 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-print-mime-structure | 54 +++++++++++++++++++++++++++------------------- 1 file changed, 32 insertions(+), 22 deletions(-) (limited to 'email-print-mime-structure') 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') -- cgit v1.2.3 From 75dbd9eb55cae90d6c962e9eb914ffa05d05d69e Mon Sep 17 00:00:00 2001 From: Daniel Kahn Gillmor Date: Sat, 2 Nov 2019 01:28:17 -0400 Subject: email-print-mime-structure: refactor to a class We will need to send arguments to the printer, so it's handy to wrap the functionality in a class. No functional changes. This diff is probably best reviewed with whitespace changes ignored. Signed-off-by: Daniel Kahn Gillmor --- email-print-mime-structure | 82 ++++++++++++++++++++++++---------------------- 1 file changed, 42 insertions(+), 40 deletions(-) (limited to 'email-print-mime-structure') diff --git a/email-print-mime-structure b/email-print-mime-structure index 185173f..b78ae91 100755 --- a/email-print-mime-structure +++ b/email-print-mime-structure @@ -37,51 +37,53 @@ 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) +class MimePrinter(object): + def print_part(self, 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) - print(f'{prefix}{z.get_content_type()}{cset}{disposition}{fname} {nbytes} bytes') + 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+'─╴') + def test(self, z:Message, prefix:str='') -> None: + if (z.is_multipart()): + self.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): + self.test(parts[i], prefix + '├') + i += 1 + self.test(parts[i], prefix + '└') + # FIXME: show epilogue? + else: + self.print_part(z, prefix+'─╴') msg:Union[Message, str, int, Any] = email.message_from_file(sys.stdin) if isinstance(msg, Message): - test(msg, '└') + printer:MimePrinter = MimePrinter() + printer.test(msg, '└') else: logging.error('Input was not an e-mail message') -- cgit v1.2.3 From a9a3a085c2f407f1dac144eba58087a7ebcf4e35 Mon Sep 17 00:00:00 2001 From: Daniel Kahn Gillmor Date: Sat, 2 Nov 2019 01:28:18 -0400 Subject: email-print-mime-structure: put main() into its own function No functional changes. This is a refactoring commit to provide some non-global scoping and easier readability. Signed-off-by: Daniel Kahn Gillmor --- email-print-mime-structure | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) (limited to 'email-print-mime-structure') diff --git a/email-print-mime-structure b/email-print-mime-structure index b78ae91..5eb94e0 100755 --- a/email-print-mime-structure +++ b/email-print-mime-structure @@ -80,10 +80,14 @@ class MimePrinter(object): else: self.print_part(z, prefix+'─╴') -msg:Union[Message, str, int, Any] = email.message_from_file(sys.stdin) +def main() -> None: + msg:Union[Message, str, int, Any] = email.message_from_file(sys.stdin) -if isinstance(msg, Message): - printer:MimePrinter = MimePrinter() - printer.test(msg, '└') -else: - logging.error('Input was not an e-mail message') + if isinstance(msg, Message): + printer:MimePrinter = MimePrinter() + printer.test(msg, '└') + else: + logging.error('Input was not an e-mail message') + +if __name__ == '__main__': + main() -- cgit v1.2.3 From 38e7f88f670589e1ffd61083a0f551369ba96d98 Mon Sep 17 00:00:00 2001 From: Daniel Kahn Gillmor Date: Sat, 2 Nov 2019 01:28:19 -0400 Subject: email-print-mime-structure: parse argments This adds a -h and --help option, which is currently pretty useless. But the argparse will become useful shortly. Signed-off-by: Daniel Kahn Gillmor --- email-print-mime-structure | 9 ++++++++- email-print-mime-structure.1.pod | 9 ++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) (limited to 'email-print-mime-structure') diff --git a/email-print-mime-structure b/email-print-mime-structure index 5eb94e0..38dc8d9 100755 --- a/email-print-mime-structure +++ b/email-print-mime-structure @@ -33,11 +33,15 @@ 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 class MimePrinter(object): + def __init__(self, args:Namespace): + self.args = args + def print_part(self, z:Message, prefix:str) -> None: ofname:Optional[str] = z.get_filename() fname:str = '' if ofname is None else f' [{ofname}]' @@ -81,10 +85,13 @@ class MimePrinter(object): self.print_part(z, prefix+'─╴') def main() -> None: + parser:ArgumentParser = ArgumentParser(description='Read RFC2822 MIME message from stdin and emit a tree diagram to stdout.', + epilog="Example: email-print-mime-structure < message.eml") + args:Namespace = parser.parse_args() msg:Union[Message, str, int, Any] = email.message_from_file(sys.stdin) if isinstance(msg, Message): - printer:MimePrinter = MimePrinter() + printer:MimePrinter = MimePrinter(args) printer.test(msg, '└') else: logging.error('Input was not an e-mail message') diff --git a/email-print-mime-structure.1.pod b/email-print-mime-structure.1.pod index ab1ec05..03a8e29 100644 --- a/email-print-mime-structure.1.pod +++ b/email-print-mime-structure.1.pod @@ -19,7 +19,14 @@ something like "cat -n". =head1 OPTIONS -None. +=over 4 + +=item B<--help>, B<-h> + +Show usage instructions. + +=back + =head1 EXAMPLE -- cgit v1.2.3 From abaf880e9bacd32f86c8210ab1489330320c2113 Mon Sep 17 00:00:00 2001 From: Daniel Kahn Gillmor Date: Sat, 2 Nov 2019 01:28:20 -0400 Subject: email-print-mime-structure: nbytes should show as a decimal integer No functional changes. Signed-off-by: Daniel Kahn Gillmor --- email-print-mime-structure | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'email-print-mime-structure') diff --git a/email-print-mime-structure b/email-print-mime-structure index 38dc8d9..8fc8774 100755 --- a/email-print-mime-structure +++ b/email-print-mime-structure @@ -63,7 +63,7 @@ class MimePrinter(object): raise TypeError(f'expected payload to be either str or bytes, got {type(payload)}') nbytes = len(payload) - print(f'{prefix}{z.get_content_type()}{cset}{disposition}{fname} {nbytes} bytes') + print(f'{prefix}{z.get_content_type()}{cset}{disposition}{fname} {nbytes:d} bytes') def test(self, z:Message, prefix:str='') -> None: if (z.is_multipart()): -- cgit v1.2.3 From 8dd6783b9bf37d0e12b343da6105cd92a5ebef1d Mon Sep 17 00:00:00 2001 From: Daniel Kahn Gillmor Date: Sat, 2 Nov 2019 01:28:21 -0400 Subject: email-print-mime-structure: Pass parent and nth child info during walk No functional change. This is preparatory work to be able to consider the structure of each part and determine whether we should consider trying to decrypt it. Signed-off-by: Daniel Kahn Gillmor --- email-print-mime-structure | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) (limited to 'email-print-mime-structure') diff --git a/email-print-mime-structure b/email-print-mime-structure index 8fc8774..98b35fe 100755 --- a/email-print-mime-structure +++ b/email-print-mime-structure @@ -42,7 +42,7 @@ class MimePrinter(object): def __init__(self, args:Namespace): self.args = args - def print_part(self, z:Message, prefix:str) -> None: + 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() @@ -65,9 +65,9 @@ class MimePrinter(object): print(f'{prefix}{z.get_content_type()}{cset}{disposition}{fname} {nbytes:d} bytes') - def test(self, z:Message, prefix:str='') -> None: + def test(self, z:Message, prefix:str, parent:Optional[Message], num:int) -> None: if (z.is_multipart()): - self.print_part(z, prefix+'┬╴') + self.print_part(z, prefix+'┬╴', parent, num) if prefix.endswith('└'): prefix = prefix.rpartition('└')[0] + ' ' if prefix.endswith('├'): @@ -77,12 +77,12 @@ class MimePrinter(object): raise TypeError(f'parts was {type(parts)}, expected List[Message]') i = 0 while (i < len(parts)-1): - self.test(parts[i], prefix + '├') + self.test(parts[i], prefix + '├', z, i+1) i += 1 - self.test(parts[i], prefix + '└') + self.test(parts[i], prefix + '└', z, i+1) # FIXME: show epilogue? else: - self.print_part(z, prefix+'─╴') + self.print_part(z, prefix+'─╴', parent, num) def main() -> None: parser:ArgumentParser = ArgumentParser(description='Read RFC2822 MIME message from stdin and emit a tree diagram to stdout.', @@ -92,7 +92,7 @@ def main() -> None: if isinstance(msg, Message): printer:MimePrinter = MimePrinter(args) - printer.test(msg, '└') + printer.test(msg, '└', None, 0) else: logging.error('Input was not an e-mail message') -- cgit v1.2.3 From d6dc4aaf0d465c3c267b9c8a663011b0b01fadcd Mon Sep 17 00:00:00 2001 From: Daniel Kahn Gillmor Date: Sat, 2 Nov 2019 01:28:22 -0400 Subject: email-print-mime-structure: add another FIXME about bytecounting Signed-off-by: Daniel Kahn Gillmor --- email-print-mime-structure | 1 + 1 file changed, 1 insertion(+) (limited to 'email-print-mime-structure') diff --git a/email-print-mime-structure b/email-print-mime-structure index 98b35fe..c1476d2 100755 --- a/email-print-mime-structure +++ b/email-print-mime-structure @@ -61,6 +61,7 @@ class MimePrinter(object): 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:d} bytes') -- cgit v1.2.3 From a858e19c0eb6c2a12d832b3dd256ef64c72f0fc1 Mon Sep 17 00:00:00 2001 From: Daniel Kahn Gillmor Date: Sat, 2 Nov 2019 01:28:23 -0400 Subject: email-print-mime-structure: renamed MimePrinter.test() to print_tree() No functional changes. This is just a more readable function name. Signed-off-by: Daniel Kahn Gillmor --- email-print-mime-structure | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) (limited to 'email-print-mime-structure') diff --git a/email-print-mime-structure b/email-print-mime-structure index c1476d2..33579a7 100755 --- a/email-print-mime-structure +++ b/email-print-mime-structure @@ -66,7 +66,7 @@ class MimePrinter(object): print(f'{prefix}{z.get_content_type()}{cset}{disposition}{fname} {nbytes:d} bytes') - def test(self, z:Message, prefix:str, parent:Optional[Message], num:int) -> None: + def print_tree(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('└'): @@ -78,9 +78,9 @@ class MimePrinter(object): 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) + self.print_tree(parts[i], prefix + '├', z, i+1) i += 1 - self.test(parts[i], prefix + '└', z, i+1) + self.print_tree(parts[i], prefix + '└', z, i+1) # FIXME: show epilogue? else: self.print_part(z, prefix+'─╴', parent, num) @@ -93,7 +93,7 @@ def main() -> None: if isinstance(msg, Message): printer:MimePrinter = MimePrinter(args) - printer.test(msg, '└', None, 0) + printer.print_tree(msg, '└', None, 0) else: logging.error('Input was not an e-mail message') -- cgit v1.2.3 From 67a847605769d5e255168a65d780594383569b75 Mon Sep 17 00:00:00 2001 From: Daniel Kahn Gillmor Date: Sat, 2 Nov 2019 01:28:24 -0400 Subject: 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 alice@openpgp.example.sec.asc < 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 --- debian/control | 1 + email-print-mime-structure | 34 ++++++++++++++++++++++++++++++++++ email-print-mime-structure.1.pod | 8 ++++++++ 3 files changed, 43 insertions(+) (limited to 'email-print-mime-structure') 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 33579a7..eb513b3 100755 --- a/email-print-mime-structure +++ b/email-print-mime-structure @@ -38,6 +38,11 @@ from typing import Optional, Union, List, Tuple, Any from email.charset import Charset from email.message import Message +try: + import pgpy #type: ignore +except ImportError: + pgpy = None + class MimePrinter(object): def __init__(self, args:Namespace): self.args = args @@ -66,6 +71,33 @@ class MimePrinter(object): print(f'{prefix}{z.get_content_type()}{cset}{disposition}{fname} {nbytes:d} 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")') + 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 = email.message_from_bytes(msg.message) + break + except: + pass + if cryptopayload is None: + logging.warning(f'Unable to decrypt') + else: + newprefix = prefix[:-3] + ' ' + print(f'{newprefix}↧ (decrypts to)') + self.print_tree(cryptopayload, newprefix + '└', z, 0) + def print_tree(self, z:Message, prefix:str, parent:Optional[Message], num:int) -> None: if (z.is_multipart()): self.print_part(z, prefix+'┬╴', parent, num) @@ -88,6 +120,8 @@ class MimePrinter(object): def main() -> None: parser:ArgumentParser = ArgumentParser(description='Read RFC2822 MIME message from stdin and emit a tree diagram to stdout.', epilog="Example: email-print-mime-structure < message.eml") + parser.add_argument('--pgpkey', metavar='KEYFILE', action='append', + help='OpenPGP Transferable Secret Key for decrypting') args:Namespace = parser.parse_args() msg:Union[Message, str, int, Any] = email.message_from_file(sys.stdin) diff --git a/email-print-mime-structure.1.pod b/email-print-mime-structure.1.pod index 03a8e29..209c725 100644 --- a/email-print-mime-structure.1.pod +++ b/email-print-mime-structure.1.pod @@ -21,6 +21,14 @@ something like "cat -n". =over 4 +=item B<--pgpkey=>I + +I should name an OpenPGP transferable secret key that is not +password-protected. If a PGP/MIME-encrypted message is found on +standard input, this key will be tried for decryption. May be used +multiple times if you want to try decrypting with more than one secret +key. + =item B<--help>, B<-h> Show usage instructions. -- cgit v1.2.3 From 7bb2f4ff1bdaf75ec33c5f885a47793f2508eedf Mon Sep 17 00:00:00 2001 From: Sean Whitton Date: Sat, 2 Nov 2019 08:59:55 -0700 Subject: drop space before shell redirection operator For consistency with the manpage. Signed-off-by: Sean Whitton --- email-print-mime-structure | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'email-print-mime-structure') diff --git a/email-print-mime-structure b/email-print-mime-structure index eb513b3..644efb1 100755 --- a/email-print-mime-structure +++ b/email-print-mime-structure @@ -119,7 +119,7 @@ class MimePrinter(object): def main() -> None: parser:ArgumentParser = ArgumentParser(description='Read RFC2822 MIME message from stdin and emit a tree diagram to stdout.', - epilog="Example: email-print-mime-structure < message.eml") + epilog="Example: email-print-mime-structure Date: Sat, 9 Nov 2019 16:48:11 -0500 Subject: email-print-mime-structure: sanity check cryptographic payload We want to make sure we're decrypting the thing that we expect. This typecheck should keep us honest. Signed-off-by: Daniel Kahn Gillmor Acked-by: Sean Whitton --- email-print-mime-structure | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) (limited to 'email-print-mime-structure') diff --git a/email-print-mime-structure b/email-print-mime-structure index 644efb1..2cbf6ed 100755 --- a/email-print-mime-structure +++ b/email-print-mime-structure @@ -76,16 +76,20 @@ class MimePrinter(object): (parent.get_content_type().lower() == 'multipart/encrypted') and \ (str(parent.get_param('protocol')).lower() == 'application/pgp-encrypted') and \ (num == 2): + cryptopayload:Optional[Message] = None + ciphertext:Union[List[Message],str,bytes,None] = z.get_payload() + if not isinstance(ciphertext, str): + logging.warning('encrypted part was not a leaf mime part somehow') + return if pgpy is None: logging.warning(f'Python module pgpy is not available, not decrypting (try "apt install python3-pgpy")') 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:pgpy.PGPMessage = pgpy.PGPMessage.from_blob(ciphertext) msg = key.decrypt(msg) cryptopayload = email.message_from_bytes(msg.message) break -- cgit v1.2.3 From b7a26158a2a87b51de86621107c01b24c2c8952d Mon Sep 17 00:00:00 2001 From: Daniel Kahn Gillmor Date: Sat, 9 Nov 2019 16:48:12 -0500 Subject: email-print-mime-structure: Restructure pgpy decryption This has no functional changes, it's just a reorganization for easier readability. Thanks to Sean Whitton for the suggestion. Signed-off-by: Daniel Kahn Gillmor Acked-by: Sean Whitton --- email-print-mime-structure | 44 ++++++++++++++++++++++++-------------------- 1 file changed, 24 insertions(+), 20 deletions(-) (limited to 'email-print-mime-structure') diff --git a/email-print-mime-structure b/email-print-mime-structure index 2cbf6ed..4f46f58 100755 --- a/email-print-mime-structure +++ b/email-print-mime-structure @@ -81,26 +81,30 @@ class MimePrinter(object): if not isinstance(ciphertext, str): logging.warning('encrypted part was not a leaf mime part somehow') return - if pgpy is None: - logging.warning(f'Python module pgpy is not available, not decrypting (try "apt install python3-pgpy")') - else: - 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(ciphertext) - msg = key.decrypt(msg) - cryptopayload = email.message_from_bytes(msg.message) - break - except: - pass - if cryptopayload is None: - logging.warning(f'Unable to decrypt') - else: - newprefix = prefix[:-3] + ' ' - print(f'{newprefix}↧ (decrypts to)') - self.print_tree(cryptopayload, newprefix + '└', z, 0) + cryptopayload = self.pgpy_decrypt(self.args.pgpkey, ciphertext) + if cryptopayload is None: + logging.warning(f'Unable to decrypt') + return + newprefix = prefix[:-3] + ' ' + print(f'{newprefix}↧ (decrypts to)') + self.print_tree(cryptopayload, newprefix + '└', z, 0) + + def pgpy_decrypt(self, keys:List[str], ciphertext:str) -> Optional[Message]: + if pgpy is None: + logging.warning(f'Python module pgpy is not available, not decrypting (try "apt install python3-pgpy")') + return None + keyname:str + ret:Optional[Message] = None + for keyname in keys: + try: + key:pgpy.PGPKey + key, _ = pgpy.PGPKey.from_file(keyname) + msg:pgpy.PGPMessage = pgpy.PGPMessage.from_blob(ciphertext) + msg = key.decrypt(msg) + return email.message_from_bytes(msg.message) + except: + pass + return None def print_tree(self, z:Message, prefix:str, parent:Optional[Message], num:int) -> None: if (z.is_multipart()): -- cgit v1.2.3 From bc35cd2bd19d4e29c46289c831170327f5c8e161 Mon Sep 17 00:00:00 2001 From: Daniel Kahn Gillmor Date: Sat, 9 Nov 2019 16:48:13 -0500 Subject: email-print-mime-structure: prepare for other decryption mechanisms No functional change here: this just prepares for adding other decryption capabilities. Signed-off-by: Daniel Kahn Gillmor Acked-by: Sean Whitton --- email-print-mime-structure | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) (limited to 'email-print-mime-structure') diff --git a/email-print-mime-structure b/email-print-mime-structure index 4f46f58..c22d556 100755 --- a/email-print-mime-structure +++ b/email-print-mime-structure @@ -70,8 +70,9 @@ class MimePrinter(object): nbytes = len(payload) print(f'{prefix}{z.get_content_type()}{cset}{disposition}{fname} {nbytes:d} bytes') + try_decrypt:bool = True if self.args.pgpkey else False - if self.args.pgpkey and \ + if try_decrypt and \ (parent is not None) and \ (parent.get_content_type().lower() == 'multipart/encrypted') and \ (str(parent.get_param('protocol')).lower() == 'application/pgp-encrypted') and \ @@ -81,7 +82,8 @@ class MimePrinter(object): if not isinstance(ciphertext, str): logging.warning('encrypted part was not a leaf mime part somehow') return - cryptopayload = self.pgpy_decrypt(self.args.pgpkey, ciphertext) + if self.args.pgpkey: + cryptopayload = self.pgpy_decrypt(self.args.pgpkey, ciphertext) if cryptopayload is None: logging.warning(f'Unable to decrypt') return -- cgit v1.2.3 From e910230a9fb8a5151bede6d043679ec50570290f Mon Sep 17 00:00:00 2001 From: Daniel Kahn Gillmor Date: Sat, 9 Nov 2019 16:48:14 -0500 Subject: email-print-mime-structure: Add --use-gpg-agent for decryption In some cases, the user may want to try to use their own GnuPG secret keys to decrypt encrypted parts of the message. By default it is disabled so that we aren't accidentally triggering the use of user secret key material. Note that gpg(1) says: It is highly recommended to use [--batch] along with the options --status-fd and --with-colons for any unattended use of gpg. I am deliberately choosing to not use either --status-fd or --with-colons for email-print-mime-structure. I'm not using --with-colons because there is no output from GnuPG that we expect to be machine-readable -- we're just looking for the cleartext of whatever ciphertext is in the message part. I'm not using --status-fd because there is nothing actionable we can do with GnuPG status messages, and asking for them would require switching from subprocess.run to subprocess.Popen to take advantage of the pass_fds argument, which in turn would make the script only work in a POSIX environment (I believe, but have not tested, that the script can currently be used on Windows). Signed-off-by: Daniel Kahn Gillmor --- debian/control | 2 ++ email-print-mime-structure | 22 +++++++++++++++++++++- email-print-mime-structure.1.pod | 24 +++++++++++++++++++----- 3 files changed, 42 insertions(+), 6 deletions(-) (limited to 'email-print-mime-structure') diff --git a/debian/control b/debian/control index fc2bccc..4c3b956 100644 --- a/debian/control +++ b/debian/control @@ -38,6 +38,8 @@ Depends: Recommends: devscripts, git, + gpg, + gpg-agent, notmuch, python3-pgpy, Architecture: all diff --git a/email-print-mime-structure b/email-print-mime-structure index c22d556..5497597 100755 --- a/email-print-mime-structure +++ b/email-print-mime-structure @@ -29,9 +29,11 @@ Example: If you want to number the parts, i suggest piping the output through something like "cat -n" ''' +import os import sys import email import logging +import subprocess from argparse import ArgumentParser, Namespace from typing import Optional, Union, List, Tuple, Any @@ -70,7 +72,7 @@ class MimePrinter(object): nbytes = len(payload) print(f'{prefix}{z.get_content_type()}{cset}{disposition}{fname} {nbytes:d} bytes') - try_decrypt:bool = True if self.args.pgpkey else False + try_decrypt:bool = self.args.pgpkey or self.args.use_gpg_agent if try_decrypt and \ (parent is not None) and \ @@ -84,6 +86,8 @@ class MimePrinter(object): return if self.args.pgpkey: cryptopayload = self.pgpy_decrypt(self.args.pgpkey, ciphertext) + if cryptopayload is None and self.args.use_gpg_agent: + cryptopayload = self.gpg_decrypt(ciphertext) if cryptopayload is None: logging.warning(f'Unable to decrypt') return @@ -108,6 +112,19 @@ class MimePrinter(object): pass return None + def gpg_decrypt(self, ciphertext:str) -> Optional[Message]: + inp:int + outp:int + inp, outp = os.pipe() + with open(outp, 'w') as outf: + outf.write(ciphertext) + out:subprocess.CompletedProcess[bytes] = subprocess.run(['gpg', '--batch', '--decrypt'], + stdin=inp, + capture_output=True) + if out.returncode == 0: + return email.message_from_bytes(out.stdout) + return None + def print_tree(self, z:Message, prefix:str, parent:Optional[Message], num:int) -> None: if (z.is_multipart()): self.print_part(z, prefix+'┬╴', parent, num) @@ -132,6 +149,9 @@ def main() -> None: epilog="Example: email-print-mime-structure are used ephemerally, and +do not interact with any local GnuPG keyring. + +=item B<--use-gpg-agent=>I|I + +If I, and B encounters a +PGP/MIME-encrypted part, it will try to decrypt the part using the +secret keys found in the local installation of GnuPG. (default: +I) + +If both B<--pgpkey=>I and B<--use-gpg-agent=true> are +supplied, I arguments will be tried before falling back to +GnuPG. + +If B has been asked to decrypt parts with +either B<--pgpkey=>I or with B<--use-gpg-agent=true>, and it +is unable to decrypt an encrypted part, it will emit a warning to +stderr. + =item B<--help>, B<-h> Show usage instructions. @@ -49,11 +68,6 @@ Show usage instructions. =head1 LIMITATIONS -B 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 the -supplied keys, it will warn on stderr. - B's output is not stable, and is not intended to be interpreted by machines, so please do not depend on it in scripts! -- cgit v1.2.3 From 818dba1efe67f7b01f6d601c6462a40567c9ed7f Mon Sep 17 00:00:00 2001 From: Daniel Kahn Gillmor Date: Sun, 10 Nov 2019 09:31:58 -0500 Subject: email-print-mime-structure: add tab completion This is modeled after the use of argcomplete in diffoscope, and it should be possible to use it for any other pythonic mailscript that uses argparse. Signed-off-by: Daniel Kahn Gillmor --- Makefile | 9 ++++++++- debian/control | 3 +++ debian/mailscripts.bash-completion | 1 + debian/rules | 2 +- email-print-mime-structure | 15 +++++++++++++++ 5 files changed, 28 insertions(+), 2 deletions(-) create mode 100644 debian/mailscripts.bash-completion (limited to 'email-print-mime-structure') diff --git a/Makefile b/Makefile index 352f6f0..0cd06b7 100644 --- a/Makefile +++ b/Makefile @@ -3,14 +3,21 @@ MANPAGES=mdmv.1 mbox2maildir.1 \ email-extract-openpgp-certs.1 \ email-print-mime-structure.1 \ notmuch-import-patch.1 +COMPLETIONS=completions/bash/email-print-mime-structure -all: $(MANPAGES) +all: $(MANPAGES) $(COMPLETIONS) clean: rm -f $(MANPAGES) + rm -rf completions %.1: %.1.pod pod2man --section=1 --date="Debian Project" --center="User Commands" \ --utf8 \ --name=$(subst .1,,$@) \ $^ $@ + +completions/bash/%: + mkdir -p completions/bash + register-python-argcomplete3 $(notdir $@) > $@.tmp + mv $@.tmp $@ diff --git a/debian/control b/debian/control index f92f7a1..782636f 100644 --- a/debian/control +++ b/debian/control @@ -4,9 +4,11 @@ Priority: optional Maintainer: Sean Whitton Standards-Version: 4.1.5 Build-Depends: + bash-completion, debhelper (>= 10), dh-elpa, perl, + python3-argcomplete, Vcs-Git: https://git.spwhitton.name/mailscripts Vcs-Browser: https://git.spwhitton.name/mailscripts @@ -39,6 +41,7 @@ Recommends: devscripts, git, notmuch, + python3-argcomplete, python3-pgpy, Suggests: gpg, diff --git a/debian/mailscripts.bash-completion b/debian/mailscripts.bash-completion new file mode 100644 index 0000000..435576f --- /dev/null +++ b/debian/mailscripts.bash-completion @@ -0,0 +1 @@ +completions/bash/email-print-mime-structure diff --git a/debian/rules b/debian/rules index e8e22ba..6d50bf4 100755 --- a/debian/rules +++ b/debian/rules @@ -1,4 +1,4 @@ #!/usr/bin/make -f %: - dh $@ --with elpa + dh $@ --with elpa --with bash-completion diff --git a/email-print-mime-structure b/email-print-mime-structure index 5497597..aac8194 100755 --- a/email-print-mime-structure +++ b/email-print-mime-structure @@ -1,4 +1,5 @@ #!/usr/bin/env python3 +# PYTHON_ARGCOMPLETE_OK # -*- coding: utf-8 -*- # Copyright (C) 2019 Daniel Kahn Gillmor @@ -45,6 +46,11 @@ try: except ImportError: pgpy = None +try: + import argcomplete #type: ignore +except ImportError: + argcomplete = None + class MimePrinter(object): def __init__(self, args:Namespace): self.args = args @@ -152,6 +158,15 @@ def main() -> None: parser.add_argument('--use-gpg-agent', metavar='true|false', type=bool, default=False, help='Ask local GnuPG installation for decryption') + + if argcomplete: + argcomplete.autocomplete(parser) + elif '_ARGCOMPLETE' in os.environ: + logging.error('Argument completion requested but the "argcomplete" ' + 'module is not installed. ' + 'Maybe you want to "apt install python3-argcomplete"') + sys.exit(1) + args:Namespace = parser.parse_args() msg:Union[Message, str, int, Any] = email.message_from_file(sys.stdin) -- cgit v1.2.3 From b9a31898cfe7fd448754a98fdaa8b4145509150d Mon Sep 17 00:00:00 2001 From: Daniel Kahn Gillmor Date: Sun, 10 Nov 2019 11:47:36 -0500 Subject: email-print-mime-structure: change --use-gpg-agent to a simple flag Turns out that type=bool doesn't really do what we want it to do (see https://bugs.python.org/issue37564), and there is no built-in easy answer for argparse to accept a boolean value sensibly (e.g. type='bool', which might be able to handle "yes" and "no" and "1" and "0" and "on" and "off" as well as "true" and "false", etc) So rather than implement all of that here, we'll just have --use-gpg-agent as a simple flag. This is an API change, but the previous API has only been out for a few days, and the tool is documented for interactive use. Signed-off-by: Daniel Kahn Gillmor --- email-print-mime-structure | 4 ++-- email-print-mime-structure.1.pod | 13 ++++++------- 2 files changed, 8 insertions(+), 9 deletions(-) (limited to 'email-print-mime-structure') diff --git a/email-print-mime-structure b/email-print-mime-structure index aac8194..3f29fb9 100755 --- a/email-print-mime-structure +++ b/email-print-mime-structure @@ -155,9 +155,9 @@ def main() -> None: epilog="Example: email-print-mime-structure are used ephemerally, and do not interact with any local GnuPG keyring. -=item B<--use-gpg-agent=>I|I +=item B<--use-gpg-agent> -If I, and B encounters a -PGP/MIME-encrypted part, it will try to decrypt the part using the -secret keys found in the local installation of GnuPG. (default: -I) +If this flag is present, and B encounters +a PGP/MIME-encrypted part, it will try to decrypt the part using the +secret keys found in the local installation of GnuPG. -If both B<--pgpkey=>I and B<--use-gpg-agent=true> are +If both B<--pgpkey=>I and B<--use-gpg-agent> are supplied, I arguments will be tried before falling back to GnuPG. If B has been asked to decrypt parts with -either B<--pgpkey=>I or with B<--use-gpg-agent=true>, and it +either B<--pgpkey=>I or with B<--use-gpg-agent>, and it is unable to decrypt an encrypted part, it will emit a warning to stderr. -- cgit v1.2.3 From 2bb2b573ad9ceaee58986b04fd4688fec4129569 Mon Sep 17 00:00:00 2001 From: Sean Whitton Date: Fri, 15 Nov 2019 18:00:44 -0700 Subject: email-print-mime-structure: add --no-use-gpg-agent This allows the user to avoid being affected by any future change in the default. Signed-off-by: Sean Whitton --- email-print-mime-structure | 2 ++ email-print-mime-structure.1.pod | 5 +++++ 2 files changed, 7 insertions(+) (limited to 'email-print-mime-structure') diff --git a/email-print-mime-structure b/email-print-mime-structure index 3f29fb9..4f165b1 100755 --- a/email-print-mime-structure +++ b/email-print-mime-structure @@ -157,6 +157,8 @@ def main() -> None: help='OpenPGP Transferable Secret Key for decrypting') parser.add_argument('--use-gpg-agent', action='store_true', help='Ask local GnuPG installation for decryption') + parser.add_argument('--no-use-gpg-agent', action='store_false', + help='Don\'t ask local GnuPG installation for decryption') parser.set_defaults(use_gpg_agent=False) if argcomplete: diff --git a/email-print-mime-structure.1.pod b/email-print-mime-structure.1.pod index 7201f48..d8545ad 100644 --- a/email-print-mime-structure.1.pod +++ b/email-print-mime-structure.1.pod @@ -47,6 +47,11 @@ either B<--pgpkey=>I or with B<--use-gpg-agent>, and it is unable to decrypt an encrypted part, it will emit a warning to stderr. +=item B<--no-use-gpg-agent> + +Don't try to decrypt PGP/MIME-encrypted parts using secret keys found +in the local installation of GnuPG. This is the default. + =item B<--help>, B<-h> Show usage instructions. -- cgit v1.2.3