Created
May 8, 2026 09:03
-
-
Save c0m1c5an5/43775bec56e08dc1b689a9c4a1220c30 to your computer and use it in GitHub Desktop.
Back up trezor agent ssh/gpg keys
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| """ | |
| Required packages: | |
| ecdsa | |
| pynacl | |
| trezor | |
| unidecode | |
| Extract plaintext GPG / SSH private keys derived by Trezor, for offline backup. | |
| The Trezor derives keys deterministically: | |
| BIP-39 mnemonic + passphrase → 64-byte seed | |
| SLIP-10 (BIP-32 variant) → private key at SLIP-0013/0017 path | |
| Path is SHA-256 of → "<proto>://<identity_string>" | |
| GPG backup (nist256p1 is the default curve used by trezor-gpg): | |
| extract_gpg_key.py gpg \\ | |
| --mnemonic "word1 ... word24" \\ | |
| --passphrase "trezor_passphrase" \\ | |
| --user-id "Name <you@example.com>" \\ | |
| --curve nist256p1 \\ | |
| --time 0 \\ | |
| --output gpg_backup.asc | |
| The --time value must match the timestamp passed to `trezor-gpg init`. | |
| Default is 0. To check an existing key: | |
| gpg --list-keys --with-colons <uid> | grep '^pub' | cut -d: -f6 | |
| SSH backup (identity comes from the .pub file label or is given directly): | |
| extract_gpg_key.py ssh \\ | |
| --mnemonic "word1 ... word24" \\ | |
| --passphrase "trezor_passphrase" \\ | |
| --identity "ssh://user@host" \\ | |
| --curve ed25519 \\ | |
| --output id_ed25519_trezor | |
| Alternatively, point at the .pub file trezor-agent wrote: | |
| --pub-file ~/.ssh/m.kondratenko@quintagroup.org.pub | |
| """ | |
| import argparse | |
| import base64 | |
| import hashlib | |
| import hmac | |
| import io | |
| import os | |
| import re | |
| import struct | |
| import sys | |
| import unicodedata | |
| import ecdsa | |
| import nacl.bindings | |
| import nacl.encoding | |
| import nacl.signing | |
| import unidecode as _unidecode_mod | |
| from libagent import formats, util | |
| from libagent.gpg import encode, protocol | |
| def mnemonic_to_seed(mnemonic: str, passphrase: str = "") -> bytes: | |
| """Convert BIP-39 mnemonic + passphrase to a 64-byte seed.""" | |
| m = unicodedata.normalize("NFKD", mnemonic) | |
| p = unicodedata.normalize("NFKD", passphrase) | |
| salt = ("mnemonic" + p).encode("utf-8") | |
| return hashlib.pbkdf2_hmac("sha512", m.encode("utf-8"), salt, 2048) | |
| _SLIP10_HMAC_KEY = { | |
| "nist256p1": b"Nist256p1 seed", | |
| "ed25519": b"ed25519 seed", | |
| "curve25519": b"curve25519 seed", | |
| } | |
| _NIST256P1_ORDER = ecdsa.NIST256p.order | |
| def _h512(key: bytes, data: bytes) -> bytes: | |
| """HMAC-SHA512 helper.""" | |
| return hmac.new(key, data, hashlib.sha512).digest() | |
| def _slip10_master(seed: bytes, curve: str): | |
| """Derive SLIP-10 master key and chain code from seed.""" | |
| hmac_key = _SLIP10_HMAC_KEY[curve] | |
| hmac_out = _h512(hmac_key, seed) | |
| if curve == "nist256p1": | |
| while True: | |
| il = int.from_bytes(hmac_out[:32], "big") | |
| if 0 < il < _NIST256P1_ORDER: | |
| break | |
| hmac_out = _h512(hmac_key, hmac_out) | |
| return hmac_out[:32], hmac_out[32:] | |
| def _slip10_child(parent_key: bytes, parent_chain: bytes, index: int, curve: str): | |
| """Derive hardened SLIP-10 child key from parent key and chain code.""" | |
| assert index >= 0x80000000, "Only hardened derivation is used here" | |
| data = b"\x00" + parent_key + struct.pack(">I", index) | |
| if curve == "nist256p1": | |
| while True: | |
| hmac_out = _h512(parent_chain, data) | |
| il = int.from_bytes(hmac_out[:32], "big") | |
| child = (il + int.from_bytes(parent_key, "big")) % _NIST256P1_ORDER | |
| if il < _NIST256P1_ORDER and child != 0: | |
| return child.to_bytes(32, "big"), hmac_out[32:] | |
| data = b"\x01" + hmac_out[32:] + struct.pack(">I", index) | |
| else: | |
| # SLIP-10 for ed25519/curve25519: child key = IL directly | |
| hmac_out = _h512(parent_chain, data) | |
| return hmac_out[:32], hmac_out[32:] | |
| def derive_private_key(seed: bytes, path: list, curve: str) -> bytes: | |
| """Derive private key bytes at the given SLIP-10 path.""" | |
| key, chain = _slip10_master(seed, curve) | |
| for idx in path: | |
| key, chain = _slip10_child(key, chain, idx, curve) | |
| return key | |
| def slip_path(identity_str: str, ecdh: bool = False) -> list: | |
| """ | |
| Compute SLIP-0013 (ecdh=False) or SLIP-0017 (ecdh=True) path. | |
| identity_str must be the ASCII result of identity_to_string(), e.g. | |
| "gpg://Name <you@example.com>" or "ssh://user@host" | |
| Mirrors libagent/device/interface.py :: Identity.get_bip32_address() | |
| """ | |
| identity_bytes = _unidecode_mod.unidecode(identity_str).encode("ascii") | |
| digest = hashlib.sha256(struct.pack("<I", 0) + identity_bytes).digest() | |
| hardened = 0x80000000 | |
| addr_0 = 17 if ecdh else 13 | |
| s = io.BytesIO(digest) | |
| tail = [struct.unpack("<I", s.read(4))[0] for _ in range(4)] | |
| return [(hardened | v) for v in ([addr_0] + tail)] | |
| def _to_verifying_key(privkey: bytes, curve: str): | |
| """Return the verifying key object for the given private key and curve.""" | |
| if curve == "nist256p1": | |
| sk = ecdsa.SigningKey.from_string( | |
| privkey, curve=ecdsa.NIST256p, hashfunc=hashlib.sha256 | |
| ) | |
| return sk.get_verifying_key() | |
| if curve == "ed25519": | |
| return nacl.signing.SigningKey(privkey).verify_key | |
| if curve == "curve25519": | |
| # Trezor firmware: curve25519_scalarmult_basepoint(scalar) — same | |
| # clamping behaviour as libsodium's crypto_scalarmult_base. | |
| pub = nacl.bindings.crypto_scalarmult_base(privkey) | |
| # VerifyKey is just used as a 32-byte container by _serialize_ed25519. | |
| return nacl.signing.VerifyKey(pub, encoder=nacl.encoding.RawEncoder) | |
| raise ValueError(f"Unsupported curve: {curve}") | |
| def _make_signer(privkey: bytes, curve: str): | |
| """Return a signer function compatible with protocol.make_signature.""" | |
| if curve == "nist256p1": | |
| sk = ecdsa.SigningKey.from_string( | |
| privkey, curve=ecdsa.NIST256p, hashfunc=hashlib.sha256 | |
| ) | |
| def _sign(digest): | |
| sig = sk.sign_digest(digest[:32], sigencode=ecdsa.util.sigencode_string) | |
| return util.bytes2num(sig[:32]), util.bytes2num(sig[32:]) | |
| return _sign | |
| if curve == "ed25519": | |
| sk = nacl.signing.SigningKey(privkey) | |
| def _sign(digest): | |
| # Trezor signs the raw digest bytes as the Ed25519 "message". | |
| sig = sk.sign(digest).signature # 64 bytes: R || S | |
| return util.bytes2num(sig[:32]), util.bytes2num(sig[32:]) | |
| return _sign | |
| raise ValueError(f"Signing not supported for curve: {curve}") | |
| def _gpg_secret_bytes(privkey: bytes, curve: str = "nist256p1") -> bytes: | |
| """Encode private key as OpenPGP secret key material (\\x00 || MPI(scalar) || checksum).""" | |
| if curve == "curve25519": | |
| # libgcrypt reverses MPI bytes to recover the LE scalar, so store the | |
| # scalar with LE byte order. Clamp explicitly so GPG doesn't warn. | |
| scalar = bytearray(privkey) | |
| scalar[0] &= 0xF8 # clear bits 0-2 of LE LSB | |
| scalar[31] &= 0x7F # clear bit 255 | |
| scalar[31] |= 0x40 # set bit 254 | |
| mpi_blob = protocol.mpi(int.from_bytes(bytes(scalar), "little")) | |
| else: | |
| mpi_blob = protocol.mpi(int.from_bytes(privkey, "big")) | |
| checksum = sum(mpi_blob) & 0xFFFF | |
| return b"\x00" + mpi_blob + struct.pack(">H", checksum) | |
| def _ssh_frame(data: bytes) -> bytes: | |
| """Length-prefix a blob (big-endian uint32).""" | |
| return struct.pack(">I", len(data)) + data | |
| def openssh_private_key(privkey: bytes, pubkey: bytes, comment: str = "") -> str: | |
| """ | |
| Encode an Ed25519 key pair as an OpenSSH private key file (unencrypted). | |
| privkey: 32-byte seed | |
| pubkey: 32-byte public key | |
| """ | |
| pub_wire = _ssh_frame(b"ssh-ed25519") + _ssh_frame(pubkey) | |
| # Two identical random check integers (used by ssh to detect bad decryption; | |
| # for unencrypted keys any fixed value is fine). | |
| check = os.urandom(4) | |
| private_blob = ( | |
| check | |
| + check | |
| + _ssh_frame(b"ssh-ed25519") | |
| + _ssh_frame(pubkey) | |
| + _ssh_frame(privkey + pubkey) # OpenSSH stores seed || pubkey (64 bytes) | |
| + _ssh_frame(comment.encode()) | |
| ) | |
| # Pad to 8-byte boundary with 0x01 0x02 0x03 … | |
| pad_needed = (8 - len(private_blob) % 8) % 8 | |
| private_blob += bytes(range(1, pad_needed + 1)) | |
| body = ( | |
| b"openssh-key-v1\x00" | |
| + _ssh_frame(b"none") # cipher | |
| + _ssh_frame(b"none") # kdf | |
| + _ssh_frame(b"") # kdf options | |
| + struct.pack(">I", 1) # num keys | |
| + _ssh_frame(pub_wire) | |
| + _ssh_frame(private_blob) | |
| ) | |
| b64 = base64.b64encode(body).decode("ascii") | |
| lines = [b64[i : i + 70] for i in range(0, len(b64), 70)] | |
| return ( | |
| "-----BEGIN OPENSSH PRIVATE KEY-----\n" | |
| + "\n".join(lines) | |
| + "\n" | |
| + "-----END OPENSSH PRIVATE KEY-----\n" | |
| ) | |
| def openssh_public_key(pubkey: bytes, label: str) -> str: | |
| """Return the one-line .pub file content.""" | |
| wire = _ssh_frame(b"ssh-ed25519") + _ssh_frame(pubkey) | |
| return "ssh-ed25519 {} {}\n".format(base64.b64encode(wire).decode(), label) | |
| def parse_pub_file(path: str): | |
| """Return (identity_str, curve_name) from a trezor-agent .pub file.""" | |
| content = open(path).read() | |
| m = re.search(r"<(.*?)\|(.*?)>", content) | |
| if not m: | |
| raise ValueError( | |
| f"No trezor-agent identity label found in {path}.\n" | |
| "Expected format: <proto://[user@]host|curve>" | |
| ) | |
| identity_str, curve = m.group(1), m.group(2) | |
| return identity_str, curve | |
| def cmd_gpg(args): | |
| """Export a Trezor-derived GPG secret key block.""" | |
| sign_curve = args.curve | |
| ecdh_curve = formats.get_ecdh_curve_name(sign_curve) | |
| _info(f"Sign curve : {sign_curve}") | |
| _info(f"ECDH curve : {ecdh_curve}") | |
| seed = mnemonic_to_seed(args.mnemonic, args.passphrase) | |
| # GPG identity string: "gpg://<user_id>" (host field = user_id) | |
| gpg_identity = "gpg://" + args.user_id | |
| sign_path = slip_path(gpg_identity, ecdh=False) | |
| ecdh_path = slip_path(gpg_identity, ecdh=True) | |
| _info(f"Sign path : {[hex(p) for p in sign_path]}") | |
| _info(f"ECDH path : {[hex(p) for p in ecdh_path]}") | |
| sign_priv = derive_private_key(seed, sign_path, sign_curve) | |
| ecdh_priv = derive_private_key(seed, ecdh_path, ecdh_curve) | |
| sign_vk = _to_verifying_key(sign_priv, sign_curve) | |
| ecdh_vk = _to_verifying_key(ecdh_priv, ecdh_curve) | |
| primary = protocol.PublicKey( | |
| curve_name=sign_curve, created=args.time, verifying_key=sign_vk, ecdh=False | |
| ) | |
| subkey = protocol.PublicKey( | |
| curve_name=ecdh_curve, created=args.time, verifying_key=ecdh_vk, ecdh=True | |
| ) | |
| _info(f"Primary fingerprint : {util.hexlify(primary.fingerprint())}") | |
| _info(f"Subkey fingerprint : {util.hexlify(subkey.fingerprint())}") | |
| signer = _make_signer(sign_priv, sign_curve) | |
| # decode.parse_packets computes fingerprints from the raw packet bytes, so | |
| # passing a secret-key packet to create_subkey would include the secret | |
| # material in the fingerprint and produce a wrong issuer key ID in the | |
| # subkey binding signature. Use a public-only primary block for signing, | |
| # then splice in the secret primary. | |
| public_primary = encode.create_primary( | |
| user_id=args.user_id, pubkey=primary, signer_func=signer, secret_bytes=b"" | |
| ) | |
| secret_primary = encode.create_primary( | |
| user_id=args.user_id, | |
| pubkey=primary, | |
| signer_func=signer, | |
| secret_bytes=_gpg_secret_bytes(sign_priv, sign_curve), | |
| ) | |
| result_pub = encode.create_subkey( | |
| primary_bytes=public_primary, | |
| subkey=subkey, | |
| signer_func=signer, | |
| secret_bytes=_gpg_secret_bytes(ecdh_priv, ecdh_curve), | |
| ) | |
| # create_subkey returns primary_bytes + subkey_packet + sig_packet | |
| result = secret_primary + result_pub[len(public_primary) :] | |
| armored = protocol.armor(result, "PRIVATE KEY BLOCK") | |
| _write(args.output, armored, binary=False) | |
| def cmd_ssh(args): | |
| """Export a Trezor-derived SSH private key.""" | |
| # Resolve identity string and curve | |
| if args.pub_file: | |
| identity_str, curve = parse_pub_file(args.pub_file) | |
| _info(f"Loaded from .pub : identity={identity_str!r} curve={curve}") | |
| else: | |
| if not args.identity: | |
| print("ERROR: provide --identity or --pub-file", file=sys.stderr) | |
| sys.exit(1) | |
| identity_str = args.identity | |
| curve = args.curve | |
| # Ensure proto is set to 'ssh' (trezor-agent always forces this) | |
| if not identity_str.startswith("ssh://"): | |
| identity_str = "ssh://" + identity_str.lstrip("/") | |
| _info(f"SSH identity : {identity_str}") | |
| _info(f"Curve : {curve}") | |
| if curve != "ed25519": | |
| print("ERROR: only ed25519 is supported for SSH key export", file=sys.stderr) | |
| sys.exit(1) | |
| seed = mnemonic_to_seed(args.mnemonic, args.passphrase) | |
| path = slip_path(identity_str, ecdh=False) | |
| _info(f"BIP-32 path : {[hex(p) for p in path]}") | |
| privkey = derive_private_key(seed, path, curve) | |
| pubkey = nacl.signing.SigningKey(privkey).verify_key.encode( | |
| nacl.encoding.RawEncoder | |
| ) | |
| _info(f"Public key : {base64.b64encode(pubkey).decode()}") | |
| # Write private key file | |
| private_pem = openssh_private_key(privkey, pubkey, comment=identity_str) | |
| _write(args.output, private_pem, binary=False) | |
| # Optionally write the matching .pub file | |
| if args.output and args.output != "-": | |
| pub_path = args.output + ".pub" | |
| label = "<{}|{}>".format(identity_str, curve) | |
| pub_line = openssh_public_key(pubkey, label) | |
| with open(pub_path, "w") as f: | |
| f.write(pub_line) | |
| _info(f"Public key → {pub_path}") | |
| # Set standard permissions | |
| os.chmod(args.output, 0o600) | |
| def _info(msg): | |
| """Print informational message to stderr.""" | |
| print(msg, file=sys.stderr) | |
| def _write(path, data, binary=False): | |
| """Write data to file, or to stdout if path is '-' or empty.""" | |
| if path == "-" or not path: | |
| sys.stdout.write(data) | |
| else: | |
| mode = "wb" if binary else "w" | |
| with open(path, mode) as f: | |
| f.write(data) | |
| _info(f"Saved to {path}") | |
| def main(): | |
| """Parse CLI arguments and dispatch to subcommand.""" | |
| parser = argparse.ArgumentParser( | |
| description="Export Trezor-derived GPG/SSH private keys for offline backup.", | |
| formatter_class=argparse.RawDescriptionHelpFormatter, | |
| ) | |
| common = argparse.ArgumentParser(add_help=False) | |
| common.add_argument( | |
| "--mnemonic", required=True, help="BIP-39 mnemonic (12 or 24 words)" | |
| ) | |
| common.add_argument( | |
| "--passphrase", default="", help="Trezor passphrase (default: empty)" | |
| ) | |
| common.add_argument("--output", default="-", help="Output file (default: stdout)") | |
| sub = parser.add_subparsers(dest="cmd", required=True) | |
| p_gpg = sub.add_parser("gpg", parents=[common], help="Export GPG private key block") | |
| p_gpg.add_argument( | |
| "--user-id", required=True, help='GPG user ID, e.g. "Name <you@example.com>"' | |
| ) | |
| p_gpg.add_argument( | |
| "--curve", | |
| default="nist256p1", | |
| choices=["nist256p1", "ed25519"], | |
| help="ECDSA curve (default: nist256p1)", | |
| ) | |
| p_gpg.add_argument( | |
| "--time", | |
| type=int, | |
| default=0, | |
| help="Key creation timestamp used with trezor-gpg init (default: 0)", | |
| ) | |
| p_ssh = sub.add_parser( | |
| "ssh", parents=[common], help="Export SSH private key (Ed25519 only)" | |
| ) | |
| id_src = p_ssh.add_mutually_exclusive_group(required=True) | |
| id_src.add_argument( | |
| "--identity", help='SSH identity string, e.g. "ssh://user@host"' | |
| ) | |
| id_src.add_argument( | |
| "--pub-file", | |
| help="Path to existing .pub file written by trezor-agent " | |
| "(identity and curve are read from its label)", | |
| ) | |
| p_ssh.add_argument( | |
| "--curve", | |
| default="ed25519", | |
| choices=["ed25519"], | |
| help="Curve (only ed25519 supported, default: ed25519)", | |
| ) | |
| args = parser.parse_args() | |
| if args.cmd == "gpg": | |
| cmd_gpg(args) | |
| elif args.cmd == "ssh": | |
| cmd_ssh(args) | |
| if __name__ == "__main__": | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment