Knowledge Base

Zero-knowledge backups: key derivation reference

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.

Model

  • The backup system seals each backup's 32-byte data-encryption key (DEK) to your registered recipient set as one ASCII-armored age file (age-encryption.org/v1, X25519 recipients per the age spec; SSH keys use age's native ssh-ed25519 recipient type).
  • Every recovery rung is a deterministic, re-derivable mapping from a secret only you hold to one X25519 key pair, presented as an identity (secret), AGE-SECRET-KEY-1..., and a recipient (public), age1....
  • Registration submits only the public half: the recipient's raw 32-byte X25519 public key, lowercase hex (64 characters). The fingerprint we store and return is sha256(raw 32-byte public key), hex.
  • Recovery = re-derive the identity, then age -d -i <identity> backup.dek.age.

Encoding: X25519 keys as age strings (bech32)

age uses bech32 (BIP-173, not bech32m):

  • recipient = bech32("age", pubkey_32_bytes) — lowercase, 62 characters.
  • identity = 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.

Passphrase (kind: password)

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

Recovery card, 24 words (kind: recovery-key)

The 24 words are the 32-byte age secret — raw BIP-39 entropy encoding, no KDF, no 12-word variant:

  • encode: 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).
  • decode: reject unknown words; reject unless the checksum matches (catches about 255 of 256 single-word errors).
  • 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

Bitcoin wallet, BIP-85 (kind: btc)

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

Nostr (kind: secp256k1; input alias: nostr)

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.)

PGP (kind: pgp) — a wrap, not a derivation

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:

  • Mint a dedicated age identity (age-keygen).
  • Self-wrap the identity to your own PGP key with your own gpg: gpg -ea -r <your-key> — an armored envelope that you produced. We never see the identity.
  • Register kind pgp with the identity's X25519 public key (hex).
  • Recovery: 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.

Post-quantum hybrid (kind: pq-hybrid)

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.

Registration kinds

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.

Reference tooling

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.

Please rate this article to help us improve our Knowledge Base.

0 0