diff --git a/securedrop/pretty_bad_protocol/_parsers.py b/securedrop/pretty_bad_protocol/_parsers.py index c29a8ec917..e5ff49b921 100644 --- a/securedrop/pretty_bad_protocol/_parsers.py +++ b/securedrop/pretty_bad_protocol/_parsers.py @@ -1433,13 +1433,20 @@ def _handle_status(self, key, value): # type: ignore[no-untyped-def] :raises ValueError: if the status message is unknown. """ - informational_keys = ["KEY_CONSIDERED"] - if key in ("EXPORTED"): + informational_keys = ["KEY_CONSIDERED", "USERID_HINT", "INQUIRE_MAXLEN"] + if key in ("EXPORTED",): self.fingerprints.append(value) elif key == "EXPORT_RES": export_res = value.split() for x in self.counts: self.counts[x] += int(export_res.pop(0)) + elif key in ( + "NEED_PASSPHRASE", + "BAD_PASSPHRASE", + "GOOD_PASSPHRASE", + "MISSING_PASSPHRASE", + ): + self.status = key.replace("_", " ").lower() elif key not in informational_keys: raise ValueError("Unknown status message: %r" % key) diff --git a/securedrop/pretty_bad_protocol/gnupg.py b/securedrop/pretty_bad_protocol/gnupg.py index ba5b923248..c16d2fd413 100644 --- a/securedrop/pretty_bad_protocol/gnupg.py +++ b/securedrop/pretty_bad_protocol/gnupg.py @@ -30,6 +30,7 @@ import functools import os import re +import tempfile import textwrap import time from codecs import open @@ -409,32 +410,36 @@ def delete_keys(self, fingerprints, secret=False, subkeys=False): # type: ignor self._collect_output(p, result, stdin=p.stdin) return result - def export_keys(self, keyids, secret=False, subkeys=False): # type: ignore[no-untyped-def] # noqa + def export_keys(self, keyids, secret=False, subkeys=False, passphrase=None): # type: ignore[no-untyped-def] # noqa """Export the indicated ``keyids``. :param str keyids: A keyid or fingerprint in any format that GnuPG will accept. :param bool secret: If True, export only the secret key. :param bool subkeys: If True, export the secret subkeys. + :param Optional[str] passphrase: if exporting secret keys, use this + passphrase to authenticate """ which = "" if subkeys: which = "-secret-subkeys" elif secret: which = "-secret-keys" + else: + # No secret key material, ignore passphrase arg + passphrase = None if _is_list_or_tuple(keyids): keyids = " ".join(["%s" % k for k in keyids]) - args = ["--armor"] - args.append(f"--export{which} {keyids}") - - p = self._open_subprocess(args) - # gpg --export produces no status-fd output; stdout will be empty in - # case of failure - # stdout, stderr = p.communicate() + args = ["--armor", f"--export{which} {keyids}"] result = self._result_map["export"](self) - self._collect_output(p, result, stdin=p.stdin) + + # Yes we need to pass in an empty temporary file here, + # please don't ask me why. I can't get it to work otherwise. + with tempfile.NamedTemporaryFile() as tmp: + self._handle_io(args, tmp.name, result, passphrase, binary=True) + log.debug(f"Exported:{os.linesep}{result.fingerprints!r}") return result.data.decode(self._encoding, self._decode_errors) diff --git a/securedrop/tests/test_pretty_bad_protocol.py b/securedrop/tests/test_pretty_bad_protocol.py new file mode 100644 index 0000000000..d7aa9055c9 --- /dev/null +++ b/securedrop/tests/test_pretty_bad_protocol.py @@ -0,0 +1,44 @@ +from pathlib import Path + +import pretty_bad_protocol as gnupg + +import redwood + + +def test_gpg_export_keys(tmp_path): + gpg = gnupg.GPG( + binary="gpg2", + homedir=str(tmp_path), + options=["--pinentry-mode loopback", "--trust-model direct"], + ) + passphrase = "correcthorsebatterystaple" + gen_key_input = gpg.gen_key_input( + passphrase=passphrase, + name_email="example@example.org", + key_type="RSA", + key_length=4096, + name_real="example", + ) + fingerprint = gpg.gen_key(gen_key_input) + print(fingerprint) + public_key = gpg.export_keys(fingerprint) + assert public_key.startswith("-----BEGIN PGP PUBLIC KEY BLOCK-----") + secret_key = gpg.export_keys(fingerprint, secret=True, passphrase=passphrase) + assert secret_key.startswith("-----BEGIN PGP PRIVATE KEY BLOCK-----") + + # Now verify the exported key pair is usable by Sequoia + message = "GPG to Sequoia-PGP, yippe!" + ciphertext = tmp_path / "encrypted.asc" + redwood.encrypt_message([public_key], message, ciphertext) + decrypted = redwood.decrypt(ciphertext.read_bytes(), secret_key, passphrase) + assert decrypted == message + + # Test some failure cases for exporting the secret key: + # bad passphrase + assert gpg.export_keys(fingerprint, secret=True, passphrase="wrong") == "" + # exporting a non-existent secret key (import just the public key and try to export) + journalist_public_key = ( + Path(__file__).parent / "files" / "test_journalist_key.pub" + ).read_text() + journalist_fingerprint = gpg.import_keys(journalist_public_key).fingerprints[0] + assert gpg.export_keys(journalist_fingerprint, secret=True, passphrase=passphrase) == ""