This is the normative companion to Zero-knowledge backups: your recovery keys. It pins every construction used by the recovery rungs, with test vectors, so that any party can independently verify or reimplement recovery — with no SHC software and no SHC involvement.
age file (age-encryption.org/v1, X25519 recipients per the age spec; SSH keys use age's native ssh-ed25519 recipient type).AGE-SECRET-KEY-1..., and a recipient (public), age1....sha256(raw 32-byte public key), hex.age -d -i <identity> backup.dek.age.age uses bech32 (BIP-173, not bech32m):
bech32("age", pubkey_32_bytes) — lowercase, 62 characters.UPPERCASE( bech32("age-secret-key-", secret_32_bytes) ).Implementation note (the identity gotcha): the bech32 checksum of an age identity is computed over the lowercase human-readable part age-secret-key-, and then the entire string is uppercased. Computing the checksum over the uppercase HRP produces a string that age and rage reject.
The secret scalar is used as stored (age clamps at use, per X25519); the public key is X25519_base_point_multiply(secret). age-keygen -y converts an identity to its recipient and is the reference oracle for all vectors below.
seed = argon2id13( password = ctx || 0x00 || passphrase,
salt = 16 random bytes (client-generated, per service),
opslimit = 3, memlimit = 268435456 (256 MiB), out = 32 )
secret = SHA-512(seed)[0..31] # == libsodium crypto_box_seed_keypair
ctx = "shc-vps-backup-v1" # domain separation, fixed
The KDF config {v:1, alg:"argon2id13", ctx, ops:3, mem:268435456, salt} is immutable per key generation and returned (public) by the data-preferences API.
Known-answer test:
passphrase = "correct horse battery staple"
salt = 42424242424242424242424242424242 (hex)
pubkey = 2ad1adcf9951bc9a6eefeb93b9a1949e28270c05afb7f850cbfca44ddf627d55
recipient = age19tg6mnue2x7f5mh0awfmngv5nc5zwrq947mls5xtljjymhmz042schme60
The 24 words are the 32-byte age secret — raw BIP-39 entropy encoding, no KDF, no 12-word variant:
checksum = first 8 bits of SHA-256(entropy_32); the 264 bits entropy || checksum split into 24 groups of 11 bits, each indexing the canonical BIP-39 English word list (2048 words; sha256 of the newline-joined list = 2f5eed53a4727b4bf8880d8f3f199efc90e58503646d9ff8eff3a2ed3b24dbda).secret = entropy; recipient = age1 of X25519_base(secret).Known-answer test:
entropy = 4242...42 (0x42 * 32)
words 1-4 = drastic bamboo mountain loyal (pattern repeats every 8 words)
recipient = age1zvkyg2lqzraa2lnjvqej32nkuu0ues2s82hzrye869xeexvn73equnujwj
Reference: BIP-85, “Deterministic Entropy From BIP32 Keychains”. BIP-85 derives application entropy from a BIP-32 root as HMAC-SHA512(key="bip-entropy-from-k", msg=child_private_key) at hardened path m/83696968'/{app}'/... — the derivation runs on the wallet (Coldcard, Sparrow, etc.); the wallet seed never leaves it.
Our published, fixed convention:
path = m/83696968'/128169'/32'/0' # hex application (app 128169'), 32 bytes, index 0
secret = the 32 derived bytes, used directly as the age secret (no further KDF)
Also accepted: the BIP-39 application m/83696968'/39'/0'/24'/{index}' — decode the 24 words with the recovery-card codec above to obtain the 32 bytes. Whichever path you register with is the one you re-derive at recovery. The derivation accepts only derived child entropy, never a bitcoin private key.
End-to-end vector (from the BIP-85 specification's public test master key; verifiable on any BIP-85 wallet):
master = xprv9s21ZrQH143K2LBWUUQRFXhucrQqBpKdRRxNVq2zBqsx8HVqFk2uYo8kmbaLLHRdqtQpUm98uKfu3vca1LqdGhUtyoFnCNkfmXRyPXLjbKb
m/83696968'/128169'/32'/0' entropy =
ea3ceb0b02ee8e587779c63f4b7b3a21e950a213f1ec53cab608d13e8796e6dc
recipient =
age1m0hhzxelxsxnxm4ennvdpk75j8s7mn5w4tt3e4ntug5qx256wslqmdz8e9
secret = HKDF-SHA256( ikm = 32-byte nsec (decoded from nsec1 bech32 or hex),
salt = "" (empty; per RFC 5869 equal to 32 zero bytes),
info = "shc-age-nostr-v1",
L = 32 )
One-way (HKDF) and domain-separated (the info string). Software keys only — remote signers cannot export the nsec; those users register a recovery card or a plain age-keygen key instead.
Known-answer test:
nsec (hex) = SHA-256("shc-age-nostr-kat-v1")
= efee7e0291d682cc13c54f2541b513ebce9710e4ff9e7a58be69ce4b1c7e518d
recipient = age1e3gysn8mzau3kp4tv5ru2xwvl62x83y06zle4uxvd7pymuxrt3gsjy83yw
(HKDF implementation check: RFC 5869 test cases 1 and 3 must pass; test case 3 is the zero-length-salt mode used here.)
There is deliberately no derivation from a PGP key: OpenPGP keys are frequently hardware-bound (unexportable), and deriving from exportable PGP secrets would be unsound across implementations. The pattern:
age-keygen).gpg -ea -r <your-key> — an armored envelope that you produced. We never see the identity.pgp with the identity's X25519 public key (hex).gpg -d envelope.asc yields AGE-SECRET-KEY-1..., then age -d.Backups are sealed to the write-once registered recipient, pinned at registration — a lost or corrupted envelope copy is at worst a recovery inconvenience (keep your own!), never a disclosure and never a change of what your backups are sealed to.
A pq-hybrid recipient pairs one X25519 key (produced by any construction above, or plain age-keygen) with one ML-KEM-1024 key pair (FIPS 203), so sealed DEKs resist “harvest now, decrypt later” against a future quantum adversary. The hybrid seal is additive: it accompanies the age seal of the DEK rather than replacing it, and only the hybrid blob carries the post-quantum property. Construction:
x_shared = X25519(ephemeral_secret, recipient_x25519_public)
mlkem_shared, mlkem_ct = ML-KEM-1024.Encaps(recipient_mlkem_public)
AESKEY = SHA-256(x_shared || mlkem_shared)
sealed = AES-256-GCM(AESKEY, nonce, DEK, aad) # alg "X25519+ML-KEM-1024/AES-256-GCM"
Opening requires both private legs: a wrong X25519 identity or a wrong ML-KEM private key derives a different AES key and fails GCM authentication. The published vectors pin both failure cases as mandatory negative tests alongside the full positive path — keys, canonical-JSON sealed blob with its exact AAD and nonce, and digests: shc-pq-test-vectors.json is the byte-level authority. Everything is reproducible with any ML-KEM-1024 implementation plus standard X25519, SHA-256, and AES-256-GCM.
Registration submits kind: pq-hybrid with pubkey = the X25519 public key (64 hex) and mlkem_pubkey = the ML-KEM-1024 encapsulation key (3136 hex, 1568 bytes). Both public halves are write-once, and both — together with the derived reader_id = SHA-256(pubkey_x25519_hex || mlkem_pubkey) returned by the recipients endpoint — are folded into the recipient-set hash and the active seal digest, so rekey provenance covers the pair. The X25519 half is served in the age seal like every other recipient; ordinary age -d recovery is unchanged.
Each recipient registers as {kind, pubkey, label}. Kinds: password, recovery-key, ssh-ed25519, secp256k1 (alias nostr), btc, pgp, pq-hybrid (submits mlkem_pubkey alongside pubkey), plus passkey, shamir, and other for self-managed material. pubkey is the 64-hex X25519 public key — except for SSH keys, where you submit the OpenSSH public key line and the native line is served back, so vanilla age -d -i ~/.ssh/id_ed25519 works. Registration is fail-visible: a recipient that cannot be served as an age recipient is rejected up front with a 422 — nothing registers and then gets silently skipped at seal time. Exact routes and payloads: the API reference.
Everything above is reproducible with age/age-keygen, any argon2id implementation, SHA-2, HMAC, and a BIP-39 word list. No SHC dependency. SHC's own reference implementation is shc-age.py (python3 standard library plus the age binaries; the passphrase rung also uses the system libsodium), and it ships a self-test that re-derives every pinned vector above — run python3 shc-age.py selftest to confirm your environment reproduces these recipients before you rely on them.