gunnar-crypt 1.6.0

Encrypted repositories: the content envelope, the per-repo key ring, its members, the server's plaintext check, each person's one encryption key and its unlock methods, and the ring's wrap to members' encryption keys
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
//! **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.
//!
//! ```text
//! 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`, FIDO2 `hmac-secret` under the CLI) is evaluated on the
//!   fixed [`security_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 same `age1…` 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; RustCrypto `hkdf` and `sha2` do 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`]): the `age1pq1…` 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.

use std::io::{Read, Write};

use age::armor::{ArmoredReader, ArmoredWriter, Format};
use age::secrecy::{ExposeSecret as _, SecretString};
use zeroize::Zeroizing;

use crate::members::Level;

/// The first line of a wrapped user key's payload: a type tag, so a wrap that
/// opens to anything else is refused.
pub const PAYLOAD_TAG: &str = "gunnar-crypt user-key v1";

/// The largest wrap anyone parses (a hybrid stanza is ~1.6 KB).
pub const MAX_WRAP: usize = 64 << 10;

/// **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.
pub fn wrap_is_quantum_safe(method_kind: &str, wrap: &str) -> bool {
    match method_kind {
        "age-pq" | "recovery-code" => true,
        "security-key" => crate::skwrap::is_v2(wrap),
        _ => false,
    }
}

/// Which of a security key's wraps opened ([`open_with_security_key`]).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SecurityKeyWrap {
    /// v1: an age X25519 stanza to the credential's derived recipient. Not
    /// quantum-safe: re-wrap it to v2 ([`UserKey::wrap_to_security_key_v2`]).
    V1,
    /// v2: symmetric under HKDF of the PRF output ([`crate::skwrap`]).
    V2,
}

/// **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.
pub fn open_with_security_key(
    wrap: &[u8],
    prf_output: &[u8; 32],
    credential_id: &[u8],
    expected_public: &str,
) -> Result<(UserKey, SecurityKeyWrap), UserKeyError> {
    use chacha20poly1305::aead::{Aead as _, Payload};
    use chacha20poly1305::KeyInit as _;
    match std::str::from_utf8(wrap)
        .ok()
        .filter(|t| crate::skwrap::is_v2(t))
    {
        Some(text) => {
            let parts = crate::skwrap::parse(text).ok_or_else(|| {
                UserKeyError::Malformed("not a well-formed security-key wrap v2".into())
            })?;
            let key = security_key::wrap_key(prf_output, &parts.salt);
            let cipher =
                chacha20poly1305::XChaCha20Poly1305::new(&chacha20poly1305::Key::from(*key));
            let aad = crate::skwrap::aad(credential_id);
            let plain = Zeroizing::new(
                cipher
                    .decrypt(
                        &chacha20poly1305::XNonce::from(parts.nonce),
                        Payload {
                            msg: &parts.sealed,
                            aad: &aad,
                        },
                    )
                    .map_err(|_| UserKeyError::WrongKey)?,
            );
            let text = std::str::from_utf8(&plain).map_err(|_| UserKeyError::NotAUserKey)?;
            let key = UserKey::from_payload(text)?;
            if key.public() != expected_public.trim() {
                return Err(UserKeyError::NotThisAccountsKey);
            }
            Ok((key, SecurityKeyWrap::V2))
        }
        None => open(
            wrap,
            &security_key::identity(prf_output, credential_id),
            expected_public,
        )
        .map(|k| (k, SecurityKeyWrap::V1)),
    }
}

/// **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.
pub const SSH_WRAP_IS_CLASSICAL: &str =
    "Your encryption key is quantum-safe, but the SSH key's wrap of \
    it is not quantum-safe: age wraps it to an ed25519 key with X25519 and to an RSA key with \
    RSA-OAEP, which a quantum computer breaks, and Gunnar keeps that wrap. Your repositories stay \
    quantum-safe; this one way to open your key does not. To keep the key quantum-safe at the \
    server too, add a quantum-safe way to open it (`gunnar crypt keygen`, then `gunnar crypt key \
    add-pq`; a security key, `gunnar crypt key add-security-key`; or a printed recovery code, \
    `gunnar crypt key add-recovery-code`), then remove the SSH \
    one (`gunnar crypt key remove <id>`).";

/// One user's encryption key (the private half).
// One key per person, held once per command: a box would buy nothing (OX-GUNBR 2026-10-28,
// clippy -p gunnar-crypt).
#[allow(clippy::large_enum_variant)]
pub enum UserKey {
    /// An age X25519 key: classic, for classic repositories (the opt-out).
    X25519(age::x25519::Identity),
    /// age's hybrid ML-KEM-768 + X25519 key, for quantum-safe repositories:
    /// what a new key is unless asked otherwise ([`Level::NEW`]).
    #[cfg(feature = "pq")]
    Pq(crate::pq::Identity),
}

impl std::fmt::Debug for UserKey {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "UserKey({})", self.public())
    }
}

impl UserKey {
    /// A new user key for repositories of `level` ([`Level::NEW`] unless the
    /// person asked for a classic one).
    pub fn generate(level: Level) -> Result<UserKey, UserKeyError> {
        match level {
            Level::Classic => Ok(UserKey::X25519(age::x25519::Identity::generate())),
            #[cfg(feature = "pq")]
            Level::QuantumSafe => Ok(UserKey::Pq(crate::pq::Identity::generate())),
            #[cfg(not(feature = "pq"))]
            Level::QuantumSafe => Err(UserKeyError::QuantumSafeNeedsTheCli),
        }
    }

    /// The public half: what a members line and the server hold
    /// (`age1…`, or `age1pq1…`).
    pub fn public(&self) -> String {
        match self {
            UserKey::X25519(id) => id.to_public().to_string(),
            #[cfg(feature = "pq")]
            UserKey::Pq(id) => id.to_public().to_string(),
        }
    }

    /// The level of repositories this key can be a member of.
    pub fn level(&self) -> Level {
        match self {
            UserKey::X25519(_) => Level::Classic,
            #[cfg(feature = "pq")]
            UserKey::Pq(_) => Level::QuantumSafe,
        }
    }

    /// As an age identity: what opens a repository's key ring.
    pub fn identity(&self) -> &dyn age::Identity {
        match self {
            UserKey::X25519(id) => id,
            #[cfg(feature = "pq")]
            UserKey::Pq(id) => id,
        }
    }

    fn payload(&self) -> Zeroizing<String> {
        let secret = match self {
            UserKey::X25519(id) => id.to_string(),
            #[cfg(feature = "pq")]
            UserKey::Pq(id) => id.to_secret_string(),
        };
        Zeroizing::new(format!("{PAYLOAD_TAG}\n{}\n", secret.expose_secret()))
    }

    fn from_payload(text: &str) -> Result<UserKey, UserKeyError> {
        let mut lines = text.lines();
        if lines.next() != Some(PAYLOAD_TAG) {
            return Err(UserKeyError::NotAUserKey);
        }
        let secret = lines.next().map(str::trim).unwrap_or_default();
        if lines.any(|l| !l.trim().is_empty()) {
            return Err(UserKeyError::NotAUserKey);
        }
        if secret.starts_with("AGE-SECRET-KEY-PQ-1") {
            #[cfg(feature = "pq")]
            {
                return crate::pq::parse_identity_file(secret)
                    .map(UserKey::Pq)
                    .map_err(|_| UserKeyError::NotAUserKey);
            }
            #[cfg(not(feature = "pq"))]
            return Err(UserKeyError::QuantumSafeNeedsTheCli);
        }
        if secret.starts_with("AGE-SECRET-KEY-1") {
            return secret
                .parse::<age::x25519::Identity>()
                .map(UserKey::X25519)
                .map_err(|_| UserKeyError::NotAUserKey);
        }
        Err(UserKeyError::NotAUserKey)
    }

    /// Wrap this key to one unlock method's age recipient: one armored age
    /// file, to be stored on the server as the method's wrap.
    pub fn wrap_to(&self, recipient: &dyn age::Recipient) -> Result<String, UserKeyError> {
        let enc = age::Encryptor::with_recipients(std::iter::once(recipient))
            .map_err(|e| UserKeyError::Age(e.to_string()))?;
        seal(enc, &self.payload())
    }

    /// Wrap this key to a security key's recipient (the `age1…` string
    /// [`security_key::recipient`] gave at enrolment).
    pub fn wrap_to_security_key(&self, recipient: &str) -> Result<String, UserKeyError> {
        let r = recipient
            .trim()
            .parse::<age::x25519::Recipient>()
            .map_err(|_| UserKeyError::BadRecipient(recipient.trim().to_string()))?;
        self.wrap_to(&r)
    }

    /// **Wrap this key to a security key, v2** (CRYPT-PQ-SK): a fresh 32-byte
    /// salt, `HKDF-SHA256(salt, prf_output, "gunnar-crypt user-key wrap v2")`
    /// as the key, XChaCha20-Poly1305 with a fresh nonce over the key's
    /// payload, the credential id bound in the associated data. Symmetric, so
    /// quantum-safe; no X25519 step.
    pub fn wrap_to_security_key_v2(
        &self,
        prf_output: &[u8; 32],
        credential_id: &[u8],
    ) -> Result<String, UserKeyError> {
        use chacha20poly1305::aead::{Aead as _, Payload};
        use chacha20poly1305::KeyInit as _;
        let salt = crate::random_bytes::<{ crate::skwrap::SALT_BYTES }>();
        let nonce = crate::random_bytes::<{ crate::skwrap::NONCE_BYTES }>();
        let key = security_key::wrap_key(prf_output, &salt);
        let cipher = chacha20poly1305::XChaCha20Poly1305::new(&chacha20poly1305::Key::from(*key));
        let aad = crate::skwrap::aad(credential_id);
        let payload = self.payload();
        let sealed = cipher
            .encrypt(
                &chacha20poly1305::XNonce::from(nonce),
                Payload {
                    msg: payload.as_bytes(),
                    aad: &aad,
                },
            )
            .map_err(|_| UserKeyError::Age("XChaCha20-Poly1305 refused the payload".into()))?;
        Ok(crate::skwrap::format(&crate::skwrap::Parts {
            salt,
            nonce,
            sealed,
        }))
    }

    /// Wrap this key to a printed recovery code: age's scrypt stanza at the
    /// FIXED work factor [`recovery_code::LOG_N`] (scrypt N = 2^18, r = 8,
    /// p = 1: about 256 MiB and a second), on top of the code's 160 random bits.
    pub fn wrap_to_recovery_code(&self, code: &str) -> Result<String, UserKeyError> {
        let normal = recovery_code::normalize(code).ok_or(UserKeyError::BadRecoveryCode)?;
        let mut r = age::scrypt::Recipient::new(SecretString::from(normal.to_string()));
        r.set_work_factor(recovery_code::LOG_N);
        let enc = age::Encryptor::with_recipients(std::iter::once(&r as &dyn age::Recipient))
            .map_err(|e| UserKeyError::Age(e.to_string()))?;
        seal(enc, &self.payload())
    }

    /// Wrap this key to an SSH public key line (`ssh-ed25519 …`, `ssh-rsa …`).
    /// The wrap is age's SSH stanza (X25519 for ed25519, RSA-OAEP for RSA):
    /// CLASSICAL, also for a quantum-safe key ([`SSH_WRAP_IS_CLASSICAL`]).
    #[cfg(feature = "wrap")]
    pub fn wrap_to_ssh(&self, public_key: &str) -> Result<String, UserKeyError> {
        let r = public_key
            .trim()
            .parse::<age::ssh::Recipient>()
            .map_err(|_| UserKeyError::BadRecipient(public_key.trim().to_string()))?;
        self.wrap_to(&r)
    }

    /// Wrap this key to an `age1pq1…` key (a key file made by
    /// `gunnar crypt keygen`).
    #[cfg(feature = "pq")]
    pub fn wrap_to_pq(&self, public_key: &str) -> Result<String, UserKeyError> {
        let r = public_key
            .trim()
            .parse::<crate::pq::Recipient>()
            .map_err(|_| UserKeyError::BadRecipient(public_key.trim().to_string()))?;
        self.wrap_to(&r)
    }
}

fn seal(enc: age::Encryptor, payload: &str) -> Result<String, UserKeyError> {
    let age_err = |e: std::io::Error| UserKeyError::Age(e.to_string());
    let mut out = Vec::new();
    let armor = ArmoredWriter::wrap_output(&mut out, Format::AsciiArmor).map_err(age_err)?;
    let mut w = enc.wrap_output(armor).map_err(age_err)?;
    w.write_all(payload.as_bytes()).map_err(age_err)?;
    w.finish().and_then(|a| a.finish()).map_err(age_err)?;
    String::from_utf8(out).map_err(|e| UserKeyError::Age(e.to_string()))
}

/// Why a user key could not be wrapped or opened. Every refusal has a name.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum UserKeyError {
    /// The method's key is not the one this wrap is for: the wrong SSH key,
    /// another security key, a mistyped recovery code.
    WrongKey,
    /// Not a readable age file.
    Malformed(String),
    /// It opened, but what is inside is not a user key.
    NotAUserKey,
    /// It opened to a user key, but not the one the account publishes: a
    /// wrap substituted ALONE is refused. A server that replaces both the
    /// public half and the wrap is caught only where the public half is
    /// pinned: the CLI's pin (gunnar-cli `crypt_key::pins`, RA4-1).
    NotThisAccountsKey,
    /// A recipient string that is not one.
    BadRecipient(String),
    /// A recovery code that is not one (wrong length or characters).
    BadRecoveryCode,
    /// A quantum-safe user key in a build without feature `pq`.
    QuantumSafeNeedsTheCli,
    /// age itself failed.
    Age(String),
}

impl std::fmt::Display for UserKeyError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            UserKeyError::WrongKey => write!(
                f,
                "this key does not open your encryption key: it is not one of your unlock methods \
                 (the wrong SSH key, another security key, or a mistyped recovery code)"
            ),
            UserKeyError::Malformed(e) => write!(f, "the stored wrap of your encryption key is not readable: {e}"),
            UserKeyError::NotAUserKey => write!(f, "the stored wrap opened, but it does not hold an encryption key; it is refused"),
            UserKeyError::NotThisAccountsKey => write!(
                f,
                "the stored wrap holds an encryption key that is not your account's: it is refused \
                 (someone replaced it on the server)"
            ),
            UserKeyError::BadRecipient(r) => write!(f, "{r:?} is not a key the encryption key can be wrapped to"),
            UserKeyError::BadRecoveryCode => write!(
                f,
                "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",
                recovery_code::CHARS
            ),
            UserKeyError::QuantumSafeNeedsTheCli => write!(
                f,
                "this build cannot open a quantum-safe encryption key (gunnar-crypt without its `pq` \
                 feature); the Gunnar CLI and the console can"
            ),
            UserKeyError::Age(e) => write!(f, "age: {e}"),
        }
    }
}
impl std::error::Error for UserKeyError {}

/// Open a stored wrap with one method's age identity, and check the key
/// inside against the account's published public key (`expected_public`).
pub fn open(
    wrap: &[u8],
    identity: &dyn age::Identity,
    expected_public: &str,
) -> Result<UserKey, UserKeyError> {
    let key = open_unchecked(wrap, std::iter::once(identity))?;
    if key.public() != expected_public.trim() {
        return Err(UserKeyError::NotThisAccountsKey);
    }
    Ok(key)
}

/// Open a stored wrap with a printed recovery code.
pub fn open_with_recovery_code(
    wrap: &[u8],
    code: &str,
    expected_public: &str,
) -> Result<UserKey, UserKeyError> {
    let normal = recovery_code::normalize(code).ok_or(UserKeyError::BadRecoveryCode)?;
    let mut id = age::scrypt::Identity::new(SecretString::from(normal.to_string()));
    id.set_max_work_factor(recovery_code::LOG_N);
    open(wrap, &id, expected_public)
}

fn open_unchecked<'a>(
    wrap: &[u8],
    identities: impl Iterator<Item = &'a dyn age::Identity>,
) -> Result<UserKey, UserKeyError> {
    if wrap.len() > MAX_WRAP {
        return Err(UserKeyError::Malformed(format!(
            "larger than {} KiB",
            MAX_WRAP >> 10
        )));
    }
    let decryptor = age::Decryptor::new_buffered(ArmoredReader::new(wrap))
        .map_err(|e| UserKeyError::Malformed(e.to_string()))?;
    let mut reader = match decryptor.decrypt(identities) {
        Ok(r) => r,
        Err(age::DecryptError::NoMatchingKeys | age::DecryptError::DecryptionFailed) => {
            return Err(UserKeyError::WrongKey)
        }
        Err(e) => return Err(UserKeyError::Malformed(e.to_string())),
    };
    let mut buf = Zeroizing::new(Vec::new());
    reader
        .by_ref()
        .take(MAX_WRAP as u64)
        .read_to_end(&mut buf)
        .map_err(|e| UserKeyError::Malformed(e.to_string()))?;
    let text = std::str::from_utf8(&buf).map_err(|_| UserKeyError::NotAUserKey)?;
    UserKey::from_payload(text)
}

/// **A security key as an unlock method**: WebAuthn `prf` in the browser,
/// FIDO2 `hmac-secret` in the CLI, one secret.
pub mod security_key {
    use sha2::{Digest as _, Sha256};
    use zeroize::Zeroizing;

    /// The PRF input every evaluation uses (WebAuthn `prf.eval.first`). Fixed,
    /// so a credential always gives the same secret.
    pub const PRF_INPUT: &[u8] = b"gunnar-crypt user-key unlock v1";

    /// The HKDF-SHA256 salt and label of the derivation.
    pub const LABEL: &[u8] = b"gunnar-crypt security-key x25519 v1";

    /// The `hmac-secret` salt a CTAP2 client sends so the authenticator
    /// answers what a browser's WebAuthn `prf` answers for [`PRF_INPUT`]
    /// (WebAuthn L3 §10.1.4: `SHA-256("WebAuthn PRF" ‖ 0x00 ‖ input)`).
    pub fn hmac_secret_salt() -> [u8; 32] {
        let mut h = Sha256::new();
        h.update(b"WebAuthn PRF");
        h.update([0u8]);
        h.update(PRF_INPUT);
        h.finalize().into()
    }

    /// The X25519 secret of a credential: HKDF-SHA256 (salt [`LABEL`], the
    /// PRF output as the input key, `LABEL ‖ 0x00 ‖ credential id` as the
    /// context), as an age identity.
    pub fn identity(prf_output: &[u8; 32], credential_id: &[u8]) -> age::x25519::Identity {
        use bech32::ToBase32 as _;
        let mut info = Vec::with_capacity(LABEL.len() + 1 + credential_id.len());
        info.extend_from_slice(LABEL);
        info.push(0);
        info.extend_from_slice(credential_id);
        let mut secret = Zeroizing::new([0u8; 32]);
        hkdf::Hkdf::<Sha256>::new(Some(LABEL), prf_output)
            .expand(&info, secret.as_mut())
            .expect("32 bytes is a valid HKDF-SHA256 length");
        // age's own key text is the one constructor its API offers.
        let text = Zeroizing::new(
            bech32::encode(
                "age-secret-key-",
                secret.to_base32(),
                bech32::Variant::Bech32,
            )
            .expect("the HRP is valid")
            .to_uppercase(),
        );
        text.parse().expect("a 32-byte age secret key parses")
    }

    /// **The v2 wrap key** (CRYPT-PQ-SK): HKDF-SHA256 with the wrap's own
    /// salt over the PRF output, info `gunnar-crypt user-key wrap v2`.
    pub fn wrap_key(prf_output: &[u8; 32], salt: &[u8]) -> Zeroizing<[u8; 32]> {
        let mut key = Zeroizing::new([0u8; 32]);
        hkdf::Hkdf::<Sha256>::new(Some(salt), prf_output)
            .expand(crate::skwrap::INFO, key.as_mut())
            .expect("32 bytes is a valid HKDF-SHA256 length");
        key
    }

    /// The credential's v1 recipient (`age1…`): what a v1 enrolment uploaded.
    pub fn recipient(prf_output: &[u8; 32], credential_id: &[u8]) -> String {
        identity(prf_output, credential_id).to_public().to_string()
    }
}

/// **A printed recovery code** as one more unlock method: 32 characters of
/// Crockford's base32 (160 random bits), in groups of four.
pub mod recovery_code {
    /// The alphabet: no I, L, O or U to misread.
    pub const ALPHABET: &[u8; 32] = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ";
    /// Characters in a code (without the dashes).
    pub const CHARS: usize = 32;
    /// The scrypt work factor every recovery wrap uses, and the most an opener
    /// accepts: N = 2^18 (age's own default class; about 256 MiB).
    pub const LOG_N: u8 = 18;

    /// A new code from 20 random bytes.
    pub fn generate() -> zeroize::Zeroizing<String> {
        from_bytes(&crate::random_bytes::<20>())
    }

    /// The code of 20 bytes (5 bits per character), grouped `XXXX-XXXX-…`.
    pub fn from_bytes(bytes: &[u8; 20]) -> zeroize::Zeroizing<String> {
        let mut out = zeroize::Zeroizing::new(String::with_capacity(CHARS + CHARS / 4));
        let (mut acc, mut bits) = (0u32, 0u32);
        let mut n = 0;
        for b in bytes {
            acc = (acc << 8) | u32::from(*b);
            bits += 8;
            while bits >= 5 {
                bits -= 5;
                if n > 0 && n % 4 == 0 {
                    out.push('-');
                }
                out.push(ALPHABET[((acc >> bits) & 31) as usize] as char);
                n += 1;
            }
        }
        out
    }

    /// The code as typed, normalised: upper case, no spaces or dashes, the
    /// look-alikes read as Crockford reads them (O → 0, I and L → 1).
    /// `None` unless it is [`CHARS`] characters of the alphabet.
    pub fn normalize(typed: &str) -> Option<zeroize::Zeroizing<String>> {
        let mut out = zeroize::Zeroizing::new(String::with_capacity(CHARS));
        for c in typed.chars() {
            let c = match c.to_ascii_uppercase() {
                ' ' | '-' => continue,
                'O' => '0',
                'I' | 'L' => '1',
                c => c,
            };
            if !c.is_ascii() || !ALPHABET.contains(&(c as u8)) {
                return None;
            }
            out.push(c);
        }
        (out.len() == CHARS).then_some(out)
    }
}