Expand description
The user’s encryption key: one key pair per user, the same for every repository, opened by any one of the user’s unlock methods.
unlock methods (each is one age file on the server, wrapping the SAME user key)
SSH key file ──────────────┐ age ssh-ed25519 / ssh-rsa stanza (CLI)
security key (PRF secret) ─┤ v2: HKDF-SHA256 → XChaCha20-Poly1305 (browser + CLI)
│ (v1, still opened: HKDF → X25519 → age X25519)
age1pq1… key file ─────────┤ age mlkem768x25519 stanza (CLI)
printed recovery code ─────┘ age scrypt stanza (CLI + browser)
│
▼
user key (AGE-SECRET-KEY-PQ-1…, quantum-safe: the default; or AGE-SECRET-KEY-1…, classic)
│ age X25519 (or mlkem768x25519) stanza: one per member in .gunnar-crypt/keys
▼
repository key ring (id key + epochs)- A repository names only user public keys (
alice age1…in.gunnar-crypt/members). Adding or removing an unlock method changes one wrap of the user key and no repository at all. - The server keeps the user’s public key and the wraps, which are ciphertext to it. It never sees a method’s secret: an SSH private key, the security key’s PRF output or the recovery code.
- A security key’s recipient is fixed per credential: the PRF output
(WebAuthn
prf, FIDO2hmac-secretunder the CLI) is evaluated on the fixedsecurity_key::PRF_INPUT, and HKDF-SHA256 with a fixed label and the credential id as context makes the X25519 secret. The same key and credential always give the sameage1…recipient, in the browser and in the CLI. - No primitive is ours: age’s X25519, SSH, scrypt and (feature
pq) hybrid stanzas do every wrap; RustCryptohkdfandsha2do the derivation. - A new key is quantum-safe (
Level::NEW, CRYPT-PQ-DEFAULT 2026-10-30). Every method opens it. Each method’s OWN wrap is as strong as the method (wrap_is_quantum_safe): theage1pq1…key file (hybrid stanza), the recovery code (scrypt, symmetric) and the security key’s v2 wrap (symmetric, CRYPT-PQ-SK) are quantum-safe; the SSH key’s wrap (X25519 or RSA-OAEP) is not, and the CLI and the console say so (SSH_WRAP_IS_CLASSICAL). A security key’s v1 wrap (X25519) still opens and is re-wrapped to v2 on the next unlock.
Modules§
- recovery_
code - A printed recovery code as one more unlock method: 32 characters of Crockford’s base32 (160 random bits), in groups of four.
- security_
key - A security key as an unlock method: WebAuthn
prfin the browser, FIDO2hmac-secretin the CLI, one secret.
Enums§
- Security
KeyWrap - Which of a security key’s wraps opened (
open_with_security_key). - UserKey
- One user’s encryption key (the private half).
- User
KeyError - Why a user key could not be wrapped or opened. Every refusal has a name.
Constants§
- MAX_
WRAP - The largest wrap anyone parses (a hybrid stanza is ~1.6 KB).
- PAYLOAD_
TAG - The first line of a wrapped user key’s payload: a type tag, so a wrap that opens to anything else is refused.
- SSH_
WRAP_ IS_ CLASSICAL - What the CLI and the console say when an SSH key opens a quantum-safe key. The key stays quantum-safe and so do the repositories; this one wrap does not, and nothing falls back to a classic key.
Functions§
- open
- Open a stored wrap with one method’s age identity, and check the key
inside against the account’s published public key (
expected_public). - open_
with_ recovery_ code - Open a stored wrap with a printed recovery code.
- open_
with_ security_ key - Open a security key’s wrap (v2, or a v1 left from before CRYPT-PQ-SK) with the credential’s PRF / hmac-secret output, and check the key inside against the account’s published public half. Says which wrap it was: a v1 one is re-wrapped to v2 by the caller.
- wrap_
is_ quantum_ safe - Is an unlock method’s wrap of the key quantum-safe? By the method’s
kind on the wire and, for a security key, the wrap itself:
age-pq(age’s hybrid ML-KEM-768 + X25519 stanza),recovery-code(age’s scrypt) and a security key’s v2 wrap (crate::skwrap: symmetric, HKDF over the PRF output, XChaCha20-Poly1305) are, since a quantum computer only halves a symmetric key;ssh(X25519 or RSA-OAEP) and a security key’s v1 wrap (an X25519 stanza) are not, and neither is any kind this version does not know.