yayi C++, python, image processing, hacking, etc

Extracting GPG subkeys for limiting their context: a scripted approach

Why a script

A while ago I wrote an article on GPG subkeys explaining why partitioning a GPG identity into subkeys matters, and how to extract a minimal set of subkeys by hand for a single trust context — Thunderbird in that case. The manual procedure works, but it is fiddly and easy to get wrong, and "easy to get wrong" is exactly what you do not want when you are handling secret key material.

This is a short companion to that article. The need is almost the same — give a context only the keys it needs and nothing more — and here the context is a bot identity that gets baked into a machine image and provisioned within a cloud provider. Something that ends up on a shared image really should not carry your master key, so the extraction has to be deliberate, repeatable and auditable. A script delivers all three. Of course, the same can be used to address exactly the needs specified in the Thunderbird article with trivial changes.

What it does

The script reproduces the manual steps from the original article inside a throwaway keystore, so the original identity is never touched:

  1. it imports the full identity into a temporary GPG home,
  2. it deletes the master secret, so the result can never certify new keys or impersonate the full identity,
  3. it strips the encryption secrets entirely: the secret part of every encryption subkey is removed — including the one we keep — and any encryption subkey not in the keep list is removed altogether (public part too). The kept encryption subkey therefore survives as public only,
  4. it keeps exactly one signing subkey intact (secret and public): the single signing subkey in the keep list is left untouched, while every other signing subkey is removed altogether,
  5. it changes the passphrase to a freshly generated random one — the context gets its own secret, unrelated to the master passphrase,
  6. it exports the trimmed, re-keyed identity to an armored file that is eg. then baked into the image, alongside the new passphrase.

The asymmetry between the two loops is deliberate and is where most of the security value sits: a signing bot only ever needs to sign, never to decrypt what it has encrypted, so the script ships the signing secret but no encryption secret at all. The kept encryption subkey is left as a public part only, so the identity stays well-formed and people can still encrypt to it, while the image itself is structurally incapable of decrypting anything.

The resulting security properties are the same as in the original article, just enforced mechanically: the exported material cannot be used to forge the full identity (no master secret) and cannot decrypt anything (no encryption secret); a compromise of the image only burns the single signing subkey that lives there. Everything is isolated to that one trust context.

The script is meant to run locally on a machine you trust, as you need to pass the passphrase of the full identity. It is rough on the edges: no command line options or argparse, absolute paths, no good error messages and everything that makes it a bad command line tool... but trivial to adapt to your needs.

The script

from pathlib import Path
import random
import re
import string
import subprocess
import tempfile
from typing import Final

import gnupg

# this is the location where the resulting key will be written
folder_output: Final = Path("some/output/folder/")

# this is the name of the file we will save
output_key_filename = folder_output / "key-bot_image.gpg.key"

# this file (input) is protected with the passphrase indicated in .passphrase.gpg
# it is a protected export of the original GPG key.
input_key_filename: Final = Path("./full-identity.gpg.key")

# this file contains the passphrase associated to the key above
# Change this to provide the script with the passphrase by any other means as needed.
# Be careful though: the passphrase should not appear in any bash/shell history nor
# as part of the command lines of the processes listing.
with Path(".passphrase.gpg").open() as f:
    passphrase = f.readline().strip()

delete_master_key = True
fingerprint = "F2B392FD82AAC3918DEB784A2F6DF57519C4FAE4"

# we identify the subkeys by their full fingerprint, see in the script below.
signing_keys_to_keep: Final = {"7135C1D5DCE440753A45ABF326FA7F15B19D9A6A"}
encryption_keys_public_part_to_keep: Final = {
    "BB34789428292150A76183EF013F56799F32866A"
}


with tempfile.TemporaryDirectory() as d:
    gpg = gnupg.GPG(gnupghome=str(d))

    import_key = gpg.import_keys_file(input_key_filename)
    list_keys = gpg.list_keys(secret=True)

    keys_match = [_ for _ in list_keys if _["fingerprint"] == fingerprint]
    assert len(keys_match) == 1, f"# matching keys = {len(keys_match)}"
    key_considered = keys_match[0]

    if delete_master_key:
        delkey_ret = gpg.delete_keys(
            key_considered["fingerprint"],
            True,
            passphrase=passphrase,
            exclamation_mode=True,
        )

        assert (
            delkey_ret.returncode == 0
        )  # not enough, should inspect the return of stderr from time to time

    # loop over all subkeys
    for k in key_considered["subkeys"]:

        # "cap" indicates if this is an encryption "e" or signing "s" key
        # k[0] is the keyid
        if key_considered["subkey_info"][k[0]]["cap"] == "e":
            print(
                f"Deleting encryption secret for key:\n\tfingerprint: {k[2]}\n\tkeygrip: {k[3]}"
            )
            delkey_ret = gpg.delete_keys(
                k[2],  # fingerprint
                True,
                passphrase=passphrase,
                exclamation_mode=True,  # important
            )
            assert delkey_ret.returncode == 0

            # k[0] is the keyid
            # k[2] is the full fingerprint
            # you can choose any of those, but they have to match the way you identify
            # the keys in your variables
            if k[2] in encryption_keys_public_part_to_keep:
                print(f"Keeping public part of {k[2]}")
                continue

            print(f"Deleting encryption key:\n\tfingerprint: {k[2]}\n\tkeygrip: {k[3]}")
            delkey_ret = gpg.delete_keys(
                k[2],  # fingerprint
                False,
                passphrase=passphrase,
                exclamation_mode=True,  # important
            )
            assert delkey_ret.returncode == 0

    # signature key
    for k in key_considered["subkeys"]:
        if key_considered["subkey_info"][k[0]]["cap"] == "s":
            if k[2] in signing_keys_to_keep:
                continue

            print(
                f"Deleting signature secret for key:\n\tfingerprint: {k[2]}\n\tkeygrip: {k[3]}"
            )
            delkey_ret = gpg.delete_keys(
                k[2],  # fingerprint
                True,
                passphrase=passphrase,
                exclamation_mode=True,  # important
            )
            assert delkey_ret.returncode == 0

            print(f"Deleting signature key:\n\tfingerprint: {k[2]}\n\tkeygrip: {k[3]}")
            delkey_ret = gpg.delete_keys(
                k[2],  # fingerprint
                False,
                passphrase=passphrase,
                exclamation_mode=True,  # important
            )
            assert delkey_ret.returncode == 0

    # change the passphrase
    random_passphrase = "".join(
        random.choices(string.ascii_letters + string.digits, k=30)
    )

    cmd_change_passphrase = f"""gpg \
    --homedir={d} \
    --command-fd 0 \
    --pinentry-mode loopback \
    --change-passphrase {key_considered["fingerprint"]}
    """

    # we pass the new passwords directly in stdin so that the shell does not interact
    # with various chars in the password
    input_feed = passphrase + "\n" + random_passphrase + "\n"

    ret = subprocess.run(cmd_change_passphrase, input=input_feed.encode(), shell=True)
    assert ret.returncode == 0

    # export the key
    armored_simplified_key = gpg.export_keys(
        key_considered["fingerprint"], True, armor=True, passphrase=random_passphrase
    )

folder_output.mkdir(parents=True, exist_ok=True)

# save the passphrase to use next to the key.
# IMPORTANT: you will have to securely store this
with (folder_output / ".gpg_passphrase").open("w") as f:
    f.write(random_passphrase)

with output_key_filename.open("w") as f:
    f.write(armored_simplified_key)

# verification step on the structure of the resulting key
# this is an automated way to check that the resulting key has the right structure:
# - number of subkeys
# - secrets for each of the subkey (encryption/signature)
# This would need to be adapted to your needs. For the expert eyes', the result
# of the key as seen by "gpg" is printed at the end.
with tempfile.TemporaryDirectory() as d:
    gpg = gnupg.GPG(gnupghome=str(d))

    _ = gpg.import_keys_file(output_key_filename)

    out = subprocess.run(
        [
            "gpg",
            "--homedir",
            d,
            "--keyid-format",
            "long",
            "--with-fingerprint",
            "--list-secret-keys",
            "--with-subkey-fingerprint",
        ],
        capture_output=True,
        text=True,
    ).stdout

    nb_keys = 0
    for line in out.splitlines():
        line = line.strip()

        if line.startswith("sec"):
            # master key: looks for "[SC]" or anything that looks like
            assert re.search(r"\[\w*C\w*\]", line) is not None, "Cannot find master key"
            # fingerprint of the master key
            assert fingerprint[-16:] in line, fingerprint[-16:]
            # no secret key
            assert line.startswith("sec#")
            nb_keys += 1

        if line.startswith("ssb"):
            if "[E]" in line:
                # fingerprint of the encryption key
                assert any(
                    _[-16:] in line for _ in encryption_keys_public_part_to_keep
                ), ("encryption key still present:" + line)
                # no secret for encryption subkey
                assert line.startswith("ssb#")

            if "[S]" in line:
                # fingerprint of the signing key
                assert any(_[-16:] in line for _ in signing_keys_to_keep), (
                    "signing key still present:" + line
                )
                # secret for signing subkey
                assert not line.startswith("ssb#")
            nb_keys += 1

    # +1 for the master key
    assert (
        nb_keys
        == len(encryption_keys_public_part_to_keep) + len(signing_keys_to_keep) + 1
    )
    print()
    print(f"Verification of the resulting key '{output_key_filename}' done")
    print()
    print(out)

print("Done: removed identity elements")

A few notes worth keeping in mind:

  • the gpg program should be available in $PATH, and the python-gpg package should be installed,
  • the whole thing runs in a tempfile.TemporaryDirectory(), the equivalent of the --homedir temporary keystore from the original article: the real keyring is never at risk,
  • exclamation_mode=True is the library's way of passing the trailing !, which — as the first article stressed — is what makes GPG act on exactly that key and nothing else,
  • deleting the master secret (delete_master_key) is the single most important line: without the master secret the shipped identity is structurally incapable of doing anything beyond its narrow signing role,
  • mind the asymmetry in the two loops: the encryption loop deletes the secret unconditionally and only the public part is spared for the kept key (continue happens after the secret deletion), whereas the signing loop continues before any deletion, leaving the kept signing subkey whole. That is what gives a key that can sign but cannot decrypt,
  • the random passphrase means the secret guarding the shipped key is unique to that context, exactly like the "one password, one site" rule the original article drew the parallel to.

Closing

This does not replace the original subkeys article — that one explains the why and walks the manual steps in detail. This is just the how, made reproducible, for the case where the trust context is automated infrastructure rather than a desktop email client. The principle is unchanged: give each context the minimum key material it needs, isolate it, and make sure a leak there can never reach back to the master identity.