Skip to main content

gunnar_crypt/
userkey.rs

1//! **The user's encryption key**: one key pair per user, the same for every
2//! repository, opened by any one of the user's unlock methods.
3//!
4//! ```text
5//! unlock methods (each is one age file on the server, wrapping the SAME user key)
6//!   SSH key file ──────────────┐  age ssh-ed25519 / ssh-rsa stanza      (CLI)
7//!   security key (PRF secret) ─┤  v2: HKDF-SHA256 → XChaCha20-Poly1305    (browser + CLI)
8//!                              │  (v1, still opened: HKDF → X25519 → age X25519)
9//!   age1pq1… key file ─────────┤  age mlkem768x25519 stanza              (CLI)
10//!   printed recovery code ─────┘  age scrypt stanza                      (CLI + browser)
11//!          │
12//!          ▼
13//! user key (AGE-SECRET-KEY-PQ-1…, quantum-safe: the default; or AGE-SECRET-KEY-1…, classic)
14//!          │ age X25519 (or mlkem768x25519) stanza: one per member in .gunnar-crypt/keys
15//!          ▼
16//! repository key ring (id key + epochs)
17//! ```
18//!
19//! * **A repository names only user public keys** (`alice age1…` in
20//!   `.gunnar-crypt/members`). Adding or removing an unlock method changes one
21//!   wrap of the user key and no repository at all.
22//! * **The server keeps the user's public key and the wraps**, which are
23//!   ciphertext to it. It never sees a method's secret: an SSH private key, the
24//!   security key's PRF output or the recovery code.
25//! * **A security key's recipient is fixed per credential**: the PRF output
26//!   (WebAuthn `prf`, FIDO2 `hmac-secret` under the CLI) is evaluated on the
27//!   fixed [`security_key::PRF_INPUT`], and HKDF-SHA256 with a fixed label and
28//!   the credential id as context makes the X25519 secret. The same key and
29//!   credential always give the same `age1…` recipient, in the browser and in
30//!   the CLI.
31//! * **No primitive is ours**: age's X25519, SSH, scrypt and (feature `pq`)
32//!   hybrid stanzas do every wrap; RustCrypto `hkdf` and `sha2` do the
33//!   derivation.
34//! * **A new key is quantum-safe** ([`Level::NEW`], CRYPT-PQ-DEFAULT
35//!   2026-10-30). Every method opens it. Each method's OWN wrap is as strong as
36//!   the method ([`wrap_is_quantum_safe`]): the `age1pq1…` key file (hybrid
37//!   stanza), the recovery code (scrypt, symmetric) and the security key's v2
38//!   wrap (symmetric, CRYPT-PQ-SK) are quantum-safe; the SSH key's wrap
39//!   (X25519 or RSA-OAEP) is not, and the CLI and the console say so
40//!   ([`SSH_WRAP_IS_CLASSICAL`]). A security key's v1 wrap (X25519) still
41//!   opens and is re-wrapped to v2 on the next unlock.
42
43use std::io::{Read, Write};
44
45use age::armor::{ArmoredReader, ArmoredWriter, Format};
46use age::secrecy::{ExposeSecret as _, SecretString};
47use zeroize::Zeroizing;
48
49use crate::members::Level;
50
51/// The first line of a wrapped user key's payload: a type tag, so a wrap that
52/// opens to anything else is refused.
53pub const PAYLOAD_TAG: &str = "gunnar-crypt user-key v1";
54
55/// The largest wrap anyone parses (a hybrid stanza is ~1.6 KB).
56pub const MAX_WRAP: usize = 64 << 10;
57
58/// **Is an unlock method's wrap of the key quantum-safe?** By the method's
59/// kind on the wire and, for a security key, the wrap itself: `age-pq` (age's
60/// hybrid ML-KEM-768 + X25519 stanza), `recovery-code` (age's scrypt) and a
61/// security key's v2 wrap ([`crate::skwrap`]: symmetric, HKDF over the PRF
62/// output, XChaCha20-Poly1305) are, since a quantum computer only halves a
63/// symmetric key; `ssh` (X25519 or RSA-OAEP) and a security key's v1 wrap (an
64/// X25519 stanza) are not, and neither is any kind this version does not know.
65pub fn wrap_is_quantum_safe(method_kind: &str, wrap: &str) -> bool {
66    match method_kind {
67        "age-pq" | "recovery-code" => true,
68        "security-key" => crate::skwrap::is_v2(wrap),
69        _ => false,
70    }
71}
72
73/// Which of a security key's wraps opened ([`open_with_security_key`]).
74#[derive(Debug, Clone, Copy, PartialEq, Eq)]
75pub enum SecurityKeyWrap {
76    /// v1: an age X25519 stanza to the credential's derived recipient. Not
77    /// quantum-safe: re-wrap it to v2 ([`UserKey::wrap_to_security_key_v2`]).
78    V1,
79    /// v2: symmetric under HKDF of the PRF output ([`crate::skwrap`]).
80    V2,
81}
82
83/// **Open a security key's wrap** (v2, or a v1 left from before CRYPT-PQ-SK)
84/// with the credential's PRF / hmac-secret output, and check the key inside
85/// against the account's published public half. Says which wrap it was: a v1
86/// one is re-wrapped to v2 by the caller.
87pub fn open_with_security_key(
88    wrap: &[u8],
89    prf_output: &[u8; 32],
90    credential_id: &[u8],
91    expected_public: &str,
92) -> Result<(UserKey, SecurityKeyWrap), UserKeyError> {
93    use chacha20poly1305::aead::{Aead as _, Payload};
94    use chacha20poly1305::KeyInit as _;
95    match std::str::from_utf8(wrap)
96        .ok()
97        .filter(|t| crate::skwrap::is_v2(t))
98    {
99        Some(text) => {
100            let parts = crate::skwrap::parse(text).ok_or_else(|| {
101                UserKeyError::Malformed("not a well-formed security-key wrap v2".into())
102            })?;
103            let key = security_key::wrap_key(prf_output, &parts.salt);
104            let cipher =
105                chacha20poly1305::XChaCha20Poly1305::new(&chacha20poly1305::Key::from(*key));
106            let aad = crate::skwrap::aad(credential_id);
107            let plain = Zeroizing::new(
108                cipher
109                    .decrypt(
110                        &chacha20poly1305::XNonce::from(parts.nonce),
111                        Payload {
112                            msg: &parts.sealed,
113                            aad: &aad,
114                        },
115                    )
116                    .map_err(|_| UserKeyError::WrongKey)?,
117            );
118            let text = std::str::from_utf8(&plain).map_err(|_| UserKeyError::NotAUserKey)?;
119            let key = UserKey::from_payload(text)?;
120            if key.public() != expected_public.trim() {
121                return Err(UserKeyError::NotThisAccountsKey);
122            }
123            Ok((key, SecurityKeyWrap::V2))
124        }
125        None => open(
126            wrap,
127            &security_key::identity(prf_output, credential_id),
128            expected_public,
129        )
130        .map(|k| (k, SecurityKeyWrap::V1)),
131    }
132}
133
134/// **What the CLI and the console say when an SSH key opens a quantum-safe
135/// key.** The key stays quantum-safe and so do the repositories; this one wrap
136/// does not, and nothing falls back to a classic key.
137pub const SSH_WRAP_IS_CLASSICAL: &str =
138    "Your encryption key is quantum-safe, but the SSH key's wrap of \
139    it is not quantum-safe: age wraps it to an ed25519 key with X25519 and to an RSA key with \
140    RSA-OAEP, which a quantum computer breaks, and Gunnar keeps that wrap. Your repositories stay \
141    quantum-safe; this one way to open your key does not. To keep the key quantum-safe at the \
142    server too, add a quantum-safe way to open it (`gunnar crypt keygen`, then `gunnar crypt key \
143    add-pq`; a security key, `gunnar crypt key add-security-key`; or a printed recovery code, \
144    `gunnar crypt key add-recovery-code`), then remove the SSH \
145    one (`gunnar crypt key remove <id>`).";
146
147/// One user's encryption key (the private half).
148// One key per person, held once per command: a box would buy nothing (OX-GUNBR 2026-10-28,
149// clippy -p gunnar-crypt).
150#[allow(clippy::large_enum_variant)]
151pub enum UserKey {
152    /// An age X25519 key: classic, for classic repositories (the opt-out).
153    X25519(age::x25519::Identity),
154    /// age's hybrid ML-KEM-768 + X25519 key, for quantum-safe repositories:
155    /// what a new key is unless asked otherwise ([`Level::NEW`]).
156    #[cfg(feature = "pq")]
157    Pq(crate::pq::Identity),
158}
159
160impl std::fmt::Debug for UserKey {
161    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
162        write!(f, "UserKey({})", self.public())
163    }
164}
165
166impl UserKey {
167    /// A new user key for repositories of `level` ([`Level::NEW`] unless the
168    /// person asked for a classic one).
169    pub fn generate(level: Level) -> Result<UserKey, UserKeyError> {
170        match level {
171            Level::Classic => Ok(UserKey::X25519(age::x25519::Identity::generate())),
172            #[cfg(feature = "pq")]
173            Level::QuantumSafe => Ok(UserKey::Pq(crate::pq::Identity::generate())),
174            #[cfg(not(feature = "pq"))]
175            Level::QuantumSafe => Err(UserKeyError::QuantumSafeNeedsTheCli),
176        }
177    }
178
179    /// The public half: what a members line and the server hold
180    /// (`age1…`, or `age1pq1…`).
181    pub fn public(&self) -> String {
182        match self {
183            UserKey::X25519(id) => id.to_public().to_string(),
184            #[cfg(feature = "pq")]
185            UserKey::Pq(id) => id.to_public().to_string(),
186        }
187    }
188
189    /// The level of repositories this key can be a member of.
190    pub fn level(&self) -> Level {
191        match self {
192            UserKey::X25519(_) => Level::Classic,
193            #[cfg(feature = "pq")]
194            UserKey::Pq(_) => Level::QuantumSafe,
195        }
196    }
197
198    /// As an age identity: what opens a repository's key ring.
199    pub fn identity(&self) -> &dyn age::Identity {
200        match self {
201            UserKey::X25519(id) => id,
202            #[cfg(feature = "pq")]
203            UserKey::Pq(id) => id,
204        }
205    }
206
207    fn payload(&self) -> Zeroizing<String> {
208        let secret = match self {
209            UserKey::X25519(id) => id.to_string(),
210            #[cfg(feature = "pq")]
211            UserKey::Pq(id) => id.to_secret_string(),
212        };
213        Zeroizing::new(format!("{PAYLOAD_TAG}\n{}\n", secret.expose_secret()))
214    }
215
216    fn from_payload(text: &str) -> Result<UserKey, UserKeyError> {
217        let mut lines = text.lines();
218        if lines.next() != Some(PAYLOAD_TAG) {
219            return Err(UserKeyError::NotAUserKey);
220        }
221        let secret = lines.next().map(str::trim).unwrap_or_default();
222        if lines.any(|l| !l.trim().is_empty()) {
223            return Err(UserKeyError::NotAUserKey);
224        }
225        if secret.starts_with("AGE-SECRET-KEY-PQ-1") {
226            #[cfg(feature = "pq")]
227            {
228                return crate::pq::parse_identity_file(secret)
229                    .map(UserKey::Pq)
230                    .map_err(|_| UserKeyError::NotAUserKey);
231            }
232            #[cfg(not(feature = "pq"))]
233            return Err(UserKeyError::QuantumSafeNeedsTheCli);
234        }
235        if secret.starts_with("AGE-SECRET-KEY-1") {
236            return secret
237                .parse::<age::x25519::Identity>()
238                .map(UserKey::X25519)
239                .map_err(|_| UserKeyError::NotAUserKey);
240        }
241        Err(UserKeyError::NotAUserKey)
242    }
243
244    /// Wrap this key to one unlock method's age recipient: one armored age
245    /// file, to be stored on the server as the method's wrap.
246    pub fn wrap_to(&self, recipient: &dyn age::Recipient) -> Result<String, UserKeyError> {
247        let enc = age::Encryptor::with_recipients(std::iter::once(recipient))
248            .map_err(|e| UserKeyError::Age(e.to_string()))?;
249        seal(enc, &self.payload())
250    }
251
252    /// Wrap this key to a security key's recipient (the `age1…` string
253    /// [`security_key::recipient`] gave at enrolment).
254    pub fn wrap_to_security_key(&self, recipient: &str) -> Result<String, UserKeyError> {
255        let r = recipient
256            .trim()
257            .parse::<age::x25519::Recipient>()
258            .map_err(|_| UserKeyError::BadRecipient(recipient.trim().to_string()))?;
259        self.wrap_to(&r)
260    }
261
262    /// **Wrap this key to a security key, v2** (CRYPT-PQ-SK): a fresh 32-byte
263    /// salt, `HKDF-SHA256(salt, prf_output, "gunnar-crypt user-key wrap v2")`
264    /// as the key, XChaCha20-Poly1305 with a fresh nonce over the key's
265    /// payload, the credential id bound in the associated data. Symmetric, so
266    /// quantum-safe; no X25519 step.
267    pub fn wrap_to_security_key_v2(
268        &self,
269        prf_output: &[u8; 32],
270        credential_id: &[u8],
271    ) -> Result<String, UserKeyError> {
272        use chacha20poly1305::aead::{Aead as _, Payload};
273        use chacha20poly1305::KeyInit as _;
274        let salt = crate::random_bytes::<{ crate::skwrap::SALT_BYTES }>();
275        let nonce = crate::random_bytes::<{ crate::skwrap::NONCE_BYTES }>();
276        let key = security_key::wrap_key(prf_output, &salt);
277        let cipher = chacha20poly1305::XChaCha20Poly1305::new(&chacha20poly1305::Key::from(*key));
278        let aad = crate::skwrap::aad(credential_id);
279        let payload = self.payload();
280        let sealed = cipher
281            .encrypt(
282                &chacha20poly1305::XNonce::from(nonce),
283                Payload {
284                    msg: payload.as_bytes(),
285                    aad: &aad,
286                },
287            )
288            .map_err(|_| UserKeyError::Age("XChaCha20-Poly1305 refused the payload".into()))?;
289        Ok(crate::skwrap::format(&crate::skwrap::Parts {
290            salt,
291            nonce,
292            sealed,
293        }))
294    }
295
296    /// Wrap this key to a printed recovery code: age's scrypt stanza at the
297    /// FIXED work factor [`recovery_code::LOG_N`] (scrypt N = 2^18, r = 8,
298    /// p = 1: about 256 MiB and a second), on top of the code's 160 random bits.
299    pub fn wrap_to_recovery_code(&self, code: &str) -> Result<String, UserKeyError> {
300        let normal = recovery_code::normalize(code).ok_or(UserKeyError::BadRecoveryCode)?;
301        let mut r = age::scrypt::Recipient::new(SecretString::from(normal.to_string()));
302        r.set_work_factor(recovery_code::LOG_N);
303        let enc = age::Encryptor::with_recipients(std::iter::once(&r as &dyn age::Recipient))
304            .map_err(|e| UserKeyError::Age(e.to_string()))?;
305        seal(enc, &self.payload())
306    }
307
308    /// Wrap this key to an SSH public key line (`ssh-ed25519 …`, `ssh-rsa …`).
309    /// The wrap is age's SSH stanza (X25519 for ed25519, RSA-OAEP for RSA):
310    /// CLASSICAL, also for a quantum-safe key ([`SSH_WRAP_IS_CLASSICAL`]).
311    #[cfg(feature = "wrap")]
312    pub fn wrap_to_ssh(&self, public_key: &str) -> Result<String, UserKeyError> {
313        let r = public_key
314            .trim()
315            .parse::<age::ssh::Recipient>()
316            .map_err(|_| UserKeyError::BadRecipient(public_key.trim().to_string()))?;
317        self.wrap_to(&r)
318    }
319
320    /// Wrap this key to an `age1pq1…` key (a key file made by
321    /// `gunnar crypt keygen`).
322    #[cfg(feature = "pq")]
323    pub fn wrap_to_pq(&self, public_key: &str) -> Result<String, UserKeyError> {
324        let r = public_key
325            .trim()
326            .parse::<crate::pq::Recipient>()
327            .map_err(|_| UserKeyError::BadRecipient(public_key.trim().to_string()))?;
328        self.wrap_to(&r)
329    }
330}
331
332fn seal(enc: age::Encryptor, payload: &str) -> Result<String, UserKeyError> {
333    let age_err = |e: std::io::Error| UserKeyError::Age(e.to_string());
334    let mut out = Vec::new();
335    let armor = ArmoredWriter::wrap_output(&mut out, Format::AsciiArmor).map_err(age_err)?;
336    let mut w = enc.wrap_output(armor).map_err(age_err)?;
337    w.write_all(payload.as_bytes()).map_err(age_err)?;
338    w.finish().and_then(|a| a.finish()).map_err(age_err)?;
339    String::from_utf8(out).map_err(|e| UserKeyError::Age(e.to_string()))
340}
341
342/// Why a user key could not be wrapped or opened. Every refusal has a name.
343#[derive(Debug, Clone, PartialEq, Eq)]
344pub enum UserKeyError {
345    /// The method's key is not the one this wrap is for: the wrong SSH key,
346    /// another security key, a mistyped recovery code.
347    WrongKey,
348    /// Not a readable age file.
349    Malformed(String),
350    /// It opened, but what is inside is not a user key.
351    NotAUserKey,
352    /// It opened to a user key, but not the one the account publishes: a
353    /// wrap substituted ALONE is refused. A server that replaces both the
354    /// public half and the wrap is caught only where the public half is
355    /// pinned: the CLI's pin (gunnar-cli `crypt_key::pins`, RA4-1).
356    NotThisAccountsKey,
357    /// A recipient string that is not one.
358    BadRecipient(String),
359    /// A recovery code that is not one (wrong length or characters).
360    BadRecoveryCode,
361    /// A quantum-safe user key in a build without feature `pq`.
362    QuantumSafeNeedsTheCli,
363    /// age itself failed.
364    Age(String),
365}
366
367impl std::fmt::Display for UserKeyError {
368    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
369        match self {
370            UserKeyError::WrongKey => write!(
371                f,
372                "this key does not open your encryption key: it is not one of your unlock methods \
373                 (the wrong SSH key, another security key, or a mistyped recovery code)"
374            ),
375            UserKeyError::Malformed(e) => write!(f, "the stored wrap of your encryption key is not readable: {e}"),
376            UserKeyError::NotAUserKey => write!(f, "the stored wrap opened, but it does not hold an encryption key; it is refused"),
377            UserKeyError::NotThisAccountsKey => write!(
378                f,
379                "the stored wrap holds an encryption key that is not your account's: it is refused \
380                 (someone replaced it on the server)"
381            ),
382            UserKeyError::BadRecipient(r) => write!(f, "{r:?} is not a key the encryption key can be wrapped to"),
383            UserKeyError::BadRecoveryCode => write!(
384                f,
385                "that is not a recovery code: it is {} characters from 0-9 and A-Z (no I, L, O or U), in groups of four",
386                recovery_code::CHARS
387            ),
388            UserKeyError::QuantumSafeNeedsTheCli => write!(
389                f,
390                "this build cannot open a quantum-safe encryption key (gunnar-crypt without its `pq` \
391                 feature); the Gunnar CLI and the console can"
392            ),
393            UserKeyError::Age(e) => write!(f, "age: {e}"),
394        }
395    }
396}
397impl std::error::Error for UserKeyError {}
398
399/// Open a stored wrap with one method's age identity, and check the key
400/// inside against the account's published public key (`expected_public`).
401pub fn open(
402    wrap: &[u8],
403    identity: &dyn age::Identity,
404    expected_public: &str,
405) -> Result<UserKey, UserKeyError> {
406    let key = open_unchecked(wrap, std::iter::once(identity))?;
407    if key.public() != expected_public.trim() {
408        return Err(UserKeyError::NotThisAccountsKey);
409    }
410    Ok(key)
411}
412
413/// Open a stored wrap with a printed recovery code.
414pub fn open_with_recovery_code(
415    wrap: &[u8],
416    code: &str,
417    expected_public: &str,
418) -> Result<UserKey, UserKeyError> {
419    let normal = recovery_code::normalize(code).ok_or(UserKeyError::BadRecoveryCode)?;
420    let mut id = age::scrypt::Identity::new(SecretString::from(normal.to_string()));
421    id.set_max_work_factor(recovery_code::LOG_N);
422    open(wrap, &id, expected_public)
423}
424
425fn open_unchecked<'a>(
426    wrap: &[u8],
427    identities: impl Iterator<Item = &'a dyn age::Identity>,
428) -> Result<UserKey, UserKeyError> {
429    if wrap.len() > MAX_WRAP {
430        return Err(UserKeyError::Malformed(format!(
431            "larger than {} KiB",
432            MAX_WRAP >> 10
433        )));
434    }
435    let decryptor = age::Decryptor::new_buffered(ArmoredReader::new(wrap))
436        .map_err(|e| UserKeyError::Malformed(e.to_string()))?;
437    let mut reader = match decryptor.decrypt(identities) {
438        Ok(r) => r,
439        Err(age::DecryptError::NoMatchingKeys | age::DecryptError::DecryptionFailed) => {
440            return Err(UserKeyError::WrongKey)
441        }
442        Err(e) => return Err(UserKeyError::Malformed(e.to_string())),
443    };
444    let mut buf = Zeroizing::new(Vec::new());
445    reader
446        .by_ref()
447        .take(MAX_WRAP as u64)
448        .read_to_end(&mut buf)
449        .map_err(|e| UserKeyError::Malformed(e.to_string()))?;
450    let text = std::str::from_utf8(&buf).map_err(|_| UserKeyError::NotAUserKey)?;
451    UserKey::from_payload(text)
452}
453
454/// **A security key as an unlock method**: WebAuthn `prf` in the browser,
455/// FIDO2 `hmac-secret` in the CLI, one secret.
456pub mod security_key {
457    use sha2::{Digest as _, Sha256};
458    use zeroize::Zeroizing;
459
460    /// The PRF input every evaluation uses (WebAuthn `prf.eval.first`). Fixed,
461    /// so a credential always gives the same secret.
462    pub const PRF_INPUT: &[u8] = b"gunnar-crypt user-key unlock v1";
463
464    /// The HKDF-SHA256 salt and label of the derivation.
465    pub const LABEL: &[u8] = b"gunnar-crypt security-key x25519 v1";
466
467    /// The `hmac-secret` salt a CTAP2 client sends so the authenticator
468    /// answers what a browser's WebAuthn `prf` answers for [`PRF_INPUT`]
469    /// (WebAuthn L3 §10.1.4: `SHA-256("WebAuthn PRF" ‖ 0x00 ‖ input)`).
470    pub fn hmac_secret_salt() -> [u8; 32] {
471        let mut h = Sha256::new();
472        h.update(b"WebAuthn PRF");
473        h.update([0u8]);
474        h.update(PRF_INPUT);
475        h.finalize().into()
476    }
477
478    /// The X25519 secret of a credential: HKDF-SHA256 (salt [`LABEL`], the
479    /// PRF output as the input key, `LABEL ‖ 0x00 ‖ credential id` as the
480    /// context), as an age identity.
481    pub fn identity(prf_output: &[u8; 32], credential_id: &[u8]) -> age::x25519::Identity {
482        use bech32::ToBase32 as _;
483        let mut info = Vec::with_capacity(LABEL.len() + 1 + credential_id.len());
484        info.extend_from_slice(LABEL);
485        info.push(0);
486        info.extend_from_slice(credential_id);
487        let mut secret = Zeroizing::new([0u8; 32]);
488        hkdf::Hkdf::<Sha256>::new(Some(LABEL), prf_output)
489            .expand(&info, secret.as_mut())
490            .expect("32 bytes is a valid HKDF-SHA256 length");
491        // age's own key text is the one constructor its API offers.
492        let text = Zeroizing::new(
493            bech32::encode(
494                "age-secret-key-",
495                secret.to_base32(),
496                bech32::Variant::Bech32,
497            )
498            .expect("the HRP is valid")
499            .to_uppercase(),
500        );
501        text.parse().expect("a 32-byte age secret key parses")
502    }
503
504    /// **The v2 wrap key** (CRYPT-PQ-SK): HKDF-SHA256 with the wrap's own
505    /// salt over the PRF output, info `gunnar-crypt user-key wrap v2`.
506    pub fn wrap_key(prf_output: &[u8; 32], salt: &[u8]) -> Zeroizing<[u8; 32]> {
507        let mut key = Zeroizing::new([0u8; 32]);
508        hkdf::Hkdf::<Sha256>::new(Some(salt), prf_output)
509            .expand(crate::skwrap::INFO, key.as_mut())
510            .expect("32 bytes is a valid HKDF-SHA256 length");
511        key
512    }
513
514    /// The credential's v1 recipient (`age1…`): what a v1 enrolment uploaded.
515    pub fn recipient(prf_output: &[u8; 32], credential_id: &[u8]) -> String {
516        identity(prf_output, credential_id).to_public().to_string()
517    }
518}
519
520/// **A printed recovery code** as one more unlock method: 32 characters of
521/// Crockford's base32 (160 random bits), in groups of four.
522pub mod recovery_code {
523    /// The alphabet: no I, L, O or U to misread.
524    pub const ALPHABET: &[u8; 32] = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ";
525    /// Characters in a code (without the dashes).
526    pub const CHARS: usize = 32;
527    /// The scrypt work factor every recovery wrap uses, and the most an opener
528    /// accepts: N = 2^18 (age's own default class; about 256 MiB).
529    pub const LOG_N: u8 = 18;
530
531    /// A new code from 20 random bytes.
532    pub fn generate() -> zeroize::Zeroizing<String> {
533        from_bytes(&crate::random_bytes::<20>())
534    }
535
536    /// The code of 20 bytes (5 bits per character), grouped `XXXX-XXXX-…`.
537    pub fn from_bytes(bytes: &[u8; 20]) -> zeroize::Zeroizing<String> {
538        let mut out = zeroize::Zeroizing::new(String::with_capacity(CHARS + CHARS / 4));
539        let (mut acc, mut bits) = (0u32, 0u32);
540        let mut n = 0;
541        for b in bytes {
542            acc = (acc << 8) | u32::from(*b);
543            bits += 8;
544            while bits >= 5 {
545                bits -= 5;
546                if n > 0 && n % 4 == 0 {
547                    out.push('-');
548                }
549                out.push(ALPHABET[((acc >> bits) & 31) as usize] as char);
550                n += 1;
551            }
552        }
553        out
554    }
555
556    /// The code as typed, normalised: upper case, no spaces or dashes, the
557    /// look-alikes read as Crockford reads them (O → 0, I and L → 1).
558    /// `None` unless it is [`CHARS`] characters of the alphabet.
559    pub fn normalize(typed: &str) -> Option<zeroize::Zeroizing<String>> {
560        let mut out = zeroize::Zeroizing::new(String::with_capacity(CHARS));
561        for c in typed.chars() {
562            let c = match c.to_ascii_uppercase() {
563                ' ' | '-' => continue,
564                'O' => '0',
565                'I' | 'L' => '1',
566                c => c,
567            };
568            if !c.is_ascii() || !ALPHABET.contains(&(c as u8)) {
569                return None;
570            }
571            out.push(c);
572        }
573        (out.len() == CHARS).then_some(out)
574    }
575}