Skip to content

Commit

Permalink
pretty_bad_protocol: Support exporting encrypted secret keys
Browse files Browse the repository at this point in the history
As part of the Sequoia migration, we want to export secret keys out
of the GPG keyring and into our new database storage.

So, have the gpg.export_keys() function accept a passphrase to export
encrypted keys that GPG wants a passphrase for. Internally we switch it
to use the `_handle_io` function since that has support for taking
passphrases; the one weird thing is that it requires an input file (e.g.
if you were encrypting a file) but since there's no input here an empty
temporary file works just fine.

For whatever reason I couldn't get a simple `p.stdin.write(passphrase)`
to work, it would hang on stdout, which is probably related to the
pre-existing comment about "stdout will be empty in case of failure"...

A new test generates a new GPG key pair, exports the public and secret
keys from the keyring and then encrypts and decrypts a message using
Sequoia to test compatibility. It also checks a few error cases
like invalid fingerprint and missing secret key.

Refs #6802.
  • Loading branch information
legoktm committed Aug 7, 2023
1 parent 81a3c49 commit 37a8079
Show file tree
Hide file tree
Showing 3 changed files with 67 additions and 11 deletions.
11 changes: 9 additions & 2 deletions securedrop/pretty_bad_protocol/_parsers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
23 changes: 14 additions & 9 deletions securedrop/pretty_bad_protocol/gnupg.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
import functools
import os
import re
import tempfile
import textwrap
import time
from codecs import open
Expand Down Expand Up @@ -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)

Expand Down
44 changes: 44 additions & 0 deletions securedrop/tests/test_pretty_bad_protocol.py
Original file line number Diff line number Diff line change
@@ -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="[email protected]",
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) == ""

0 comments on commit 37a8079

Please sign in to comment.