Skip to content

Instantly share code, notes, and snippets.

@c0m1c5an5
Created May 8, 2026 09:03
Show Gist options
  • Select an option

  • Save c0m1c5an5/43775bec56e08dc1b689a9c4a1220c30 to your computer and use it in GitHub Desktop.

Select an option

Save c0m1c5an5/43775bec56e08dc1b689a9c4a1220c30 to your computer and use it in GitHub Desktop.
Back up trezor agent ssh/gpg keys
#!/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