Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

pretty_bad_protocol: Support exporting encrypted secret keys #6907

Merged
merged 1 commit into from
Aug 16, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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) == ""