Skip to main content

gunnar_core/
encryption_key.rs

1//! **People's encryption keys** (`refs/gunnar/encryption-keys`, CRYPT-YUBI
2//! 2026-10-26): one key pair per person for every encrypted repository.
3//!
4//! gunnar keeps the PUBLIC half (what members wrap a repository's key ring to;
5//! `Repos.RecipientKeys` hands it out) and the private half only as
6//! ciphertext: one armored age file per UNLOCK METHOD (an SSH key, a security
7//! key's PRF secret, an `age1pq1…` key, a printed recovery code). Nothing here
8//! can open a wrap: gunnar never holds a method's secret. What it checks is
9//! FORMAT, and every refusal names its rule:
10//!
11//! * the public half is an `age1…` or `age1pq1…` key;
12//! * a wrap is an age file, at most [`MAX_WRAP_BYTES`], with exactly one
13//!   recipient stanza and of the kind its method says (`ssh-ed25519`/`ssh-rsa`,
14//!   `X25519`, `mlkem768x25519`, `scrypt`), beside age's one grease stanza — or,
15//!   for a security key, a well-formed wrap v2 (`gunnar_crypt::skwrap`,
16//!   CRYPT-PQ-SK), which replaces that credential's v1 wrap when added;
17//! * at most [`MAX_METHODS`] methods; the same method (kind and ref) once;
18//! * the LAST method is never removed: nothing would open the key again.
19//!
20//! Who may call is the gRPC door's: the person themselves only.
21
22use std::sync::Arc;
23
24use crate::control::ControlStore;
25use crate::doc::{impl_doc, DocRef, ShardedMap};
26use crate::error::Error;
27
28/// Why a call was refused. Every variant says its rule.
29#[derive(Debug)]
30pub enum KeyRefusal {
31    /// The person has a key already.
32    AlreadyHasKey(String),
33    /// The person has no key yet.
34    NoKey(String),
35    /// The same method is held already.
36    MethodHeld(String),
37    /// No method with that id.
38    NoSuchMethod(String),
39    /// The last method.
40    LastMethod(String),
41    /// A field or a wrap that breaks a rule.
42    Invalid(String),
43    /// The control store failed.
44    Store(Error),
45}
46
47impl std::fmt::Display for KeyRefusal {
48    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
49        match self {
50            KeyRefusal::AlreadyHasKey(s)
51            | KeyRefusal::NoKey(s)
52            | KeyRefusal::MethodHeld(s)
53            | KeyRefusal::NoSuchMethod(s)
54            | KeyRefusal::LastMethod(s)
55            | KeyRefusal::Invalid(s) => f.write_str(s),
56            KeyRefusal::Store(e) => write!(f, "{e}"),
57        }
58    }
59}
60impl std::error::Error for KeyRefusal {}
61
62impl From<Error> for KeyRefusal {
63    fn from(e: Error) -> Self {
64        KeyRefusal::Store(e)
65    }
66}
67
68/// This module's result.
69pub type Result<T> = std::result::Result<T, KeyRefusal>;
70
71/// **The name the document is stored under** in the control store.
72pub const ENCRYPTION_KEYS_REF: &str = "refs/gunnar/encryption-keys";
73/// The largest wrap, in bytes (a hybrid stanza's armored file is ~2.3 KB).
74pub const MAX_WRAP_BYTES: usize = 8 << 10;
75/// The most unlock methods one person holds.
76pub const MAX_METHODS: usize = 16;
77/// The scrypt work factor of every recovery-code wrap (`gunnar_crypt::userkey::recovery_code::LOG_N`).
78pub const RECOVERY_LOG_N: u8 = 18;
79/// The longest label, in bytes.
80pub const MAX_LABEL_BYTES: usize = 128;
81/// The longest method ref (an RSA public key line is the longest), in bytes.
82pub const MAX_REF_BYTES: usize = 4096;
83
84/// What opens a person's encryption key.
85#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
86#[serde(rename_all = "kebab-case")]
87pub enum MethodKind {
88    /// An SSH key file (the CLI).
89    Ssh,
90    /// A security key: WebAuthn PRF in the browser, FIDO2 hmac-secret in the CLI.
91    SecurityKey,
92    /// An `age1pq1…` key file (the CLI).
93    AgePq,
94    /// A printed recovery code.
95    RecoveryCode,
96}
97
98impl MethodKind {
99    /// The word on the wire.
100    pub fn word(self) -> &'static str {
101        match self {
102            MethodKind::Ssh => "ssh",
103            MethodKind::SecurityKey => "security-key",
104            MethodKind::AgePq => "age-pq",
105            MethodKind::RecoveryCode => "recovery-code",
106        }
107    }
108
109    /// The kind a word names, or the refusal.
110    pub fn parse(word: &str) -> Result<MethodKind> {
111        match word {
112            "ssh" => Ok(MethodKind::Ssh),
113            "security-key" => Ok(MethodKind::SecurityKey),
114            "age-pq" => Ok(MethodKind::AgePq),
115            "recovery-code" => Ok(MethodKind::RecoveryCode),
116            other => Err(refuse(
117                "unlock method",
118                format!("{other:?} is not a method (ssh, security-key, age-pq or recovery-code)"),
119            )),
120        }
121    }
122
123    /// The age stanza types a wrap of this kind may carry.
124    fn stanzas(self) -> &'static [&'static str] {
125        match self {
126            MethodKind::Ssh => &["ssh-ed25519", "ssh-rsa"],
127            MethodKind::SecurityKey => &["X25519"],
128            MethodKind::AgePq => &[gunnar_crypt::members::PQ_STANZA],
129            MethodKind::RecoveryCode => &["scrypt"],
130        }
131    }
132}
133
134/// One unlock method: its kind, what names it, and the key wrapped to it.
135#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
136pub struct UnlockMethod {
137    /// 16 hex characters of SHA-256 over the wrap; set by [`EncryptionKeys`].
138    pub id: String,
139    /// What opens it.
140    pub kind: MethodKind,
141    /// The person's own words.
142    pub label: String,
143    /// The SSH public key line, the credential id (base64url), the `age1pq1…`
144    /// key; empty for a recovery code.
145    pub method_ref: String,
146    /// A security key's WebAuthn relying party id.
147    #[serde(default)]
148    pub rp_id: String,
149    /// The armored age file.
150    pub wrap: String,
151    /// Unix ms; set by [`EncryptionKeys`].
152    pub added_ms: u64,
153}
154
155/// One person's encryption key: the public half and its methods.
156#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
157pub struct EncryptionKey {
158    /// `age1…` or `age1pq1…`.
159    pub public_key: String,
160    /// Every unlock method, oldest first.
161    pub methods: Vec<UnlockMethod>,
162}
163
164#[derive(Default, Clone, serde::Serialize, serde::Deserialize)]
165#[serde(deny_unknown_fields)]
166struct EncryptionKeysDoc {
167    /// Principal id → their key.
168    #[serde(default)]
169    keys: ShardedMap<EncryptionKey>,
170}
171
172impl_doc!(EncryptionKeysDoc { keys });
173
174fn refuse(what: &'static str, reason: String) -> KeyRefusal {
175    KeyRefusal::Invalid(format!("{what}: {reason}"))
176}
177
178/// Pure: **is `public` a person's encryption public key**.
179pub fn check_public_key(public: &str) -> Result<()> {
180    use gunnar_crypt::members::{classify, KeyKind};
181    match classify(public) {
182        Ok(KeyKind::Age | KeyKind::AgePq) => Ok(()),
183        _ => Err(refuse(
184            "encryption key",
185            format!("{:?} is not an age1… or age1pq1… public key", public.trim()),
186        )),
187    }
188}
189
190/// Pure: **is `m` a well-formed method** (its fields, and its wrap's shape).
191pub fn check_method(m: &UnlockMethod) -> Result<()> {
192    if m.label.len() > MAX_LABEL_BYTES || m.label.chars().any(char::is_control) {
193        return Err(refuse(
194            "unlock method",
195            format!("a label is at most {MAX_LABEL_BYTES} bytes of printable text"),
196        ));
197    }
198    if m.method_ref.len() > MAX_REF_BYTES || m.method_ref.chars().any(char::is_control) {
199        return Err(refuse(
200            "unlock method",
201            format!("a method ref is at most {MAX_REF_BYTES} bytes of printable text"),
202        ));
203    }
204    match m.kind {
205        MethodKind::RecoveryCode if !m.method_ref.is_empty() => {
206            return Err(refuse(
207                "unlock method",
208                "a recovery code has no ref: gunnar never holds the code".into(),
209            ))
210        }
211        MethodKind::Ssh | MethodKind::SecurityKey | MethodKind::AgePq
212            if m.method_ref.trim().is_empty() =>
213        {
214            return Err(refuse(
215                "unlock method",
216                format!("a {} method names its key (the method ref)", m.kind.word()),
217            ))
218        }
219        MethodKind::SecurityKey if m.rp_id.trim().is_empty() || m.rp_id.len() > 253 => {
220            return Err(refuse(
221                "unlock method",
222                "a security key names the relying party id its credential is bound to".into(),
223            ))
224        }
225        _ => {}
226    }
227    if m.wrap.len() > MAX_WRAP_BYTES {
228        return Err(refuse(
229            "unlock method",
230            format!(
231                "the wrap is {} bytes, more than the {MAX_WRAP_BYTES} gunnar keeps",
232                m.wrap.len()
233            ),
234        ));
235    }
236    // A security key's wrap v2 (CRYPT-PQ-SK): symmetric under HKDF of its PRF output,
237    // XChaCha20-Poly1305. Its shape is checked; gunnar holds no key to open it.
238    if gunnar_crypt::skwrap::is_v2(&m.wrap) {
239        if m.kind != MethodKind::SecurityKey {
240            return Err(refuse(
241                "unlock method",
242                format!(
243                    "a wrap v2 is a security key's only; this is a {} method",
244                    m.kind.word()
245                ),
246            ));
247        }
248        if gunnar_crypt::skwrap::parse(&m.wrap).is_none() {
249            return Err(refuse(
250                "unlock method",
251                "the security key's wrap v2 is not well formed (a 32-byte salt, a 24-byte nonce and the sealed key)".into(),
252            ));
253        }
254        return Ok(());
255    }
256    let stanzas = gunnar_crypt::policy::age_stanzas(m.wrap.as_bytes())
257        .ok_or_else(|| refuse("unlock method", "the wrap is not an age file".into()))?;
258    let real: Vec<&str> = stanzas
259        .iter()
260        .map(|s| s.tag.as_str())
261        .filter(|t| !t.ends_with("-grease"))
262        .collect();
263    if real.len() != 1 || !m.kind.stanzas().contains(&real[0]) {
264        return Err(refuse(
265            "unlock method",
266            format!(
267                "a {} wrap has one {} stanza; this one has {real:?}",
268                m.kind.word(),
269                m.kind.stanzas().join(" or ")
270            ),
271        ));
272    }
273    // A recovery code goes through scrypt at gunnar's work factor, never a cheaper one.
274    if m.kind == MethodKind::RecoveryCode {
275        let s = stanzas
276            .iter()
277            .find(|s| s.tag == "scrypt")
278            .expect("checked above");
279        let log_n = s.args.get(1).and_then(|w| w.parse::<u8>().ok());
280        if log_n != Some(RECOVERY_LOG_N) {
281            return Err(refuse(
282                "unlock method",
283                format!(
284                    "a recovery code's wrap uses scrypt at work factor {} (this one: {log_n:?})",
285                    RECOVERY_LOG_N
286                ),
287            ));
288        }
289    }
290    Ok(())
291}
292
293fn method_id(wrap: &str) -> String {
294    use sha2::Digest as _;
295    sha2::Sha256::digest(wrap.as_bytes())[..8]
296        .iter()
297        .map(|b| format!("{b:02x}"))
298        .collect()
299}
300
301fn same_method(a: &UnlockMethod, b: &UnlockMethod) -> bool {
302    a.kind == b.kind
303        && a.kind != MethodKind::RecoveryCode
304        && a.method_ref.trim() == b.method_ref.trim()
305}
306
307/// Read and write people's encryption keys. Cheap to clone.
308#[derive(Clone, Debug)]
309pub struct EncryptionKeys {
310    doc: DocRef<EncryptionKeysDoc>,
311}
312
313impl EncryptionKeys {
314    /// Open the document held in `store`.
315    pub fn new(store: Arc<dyn ControlStore>) -> Result<Self> {
316        Ok(EncryptionKeys {
317            doc: DocRef::new(store, ENCRYPTION_KEYS_REF)?,
318        })
319    }
320
321    /// One update of the document; a refusal leaves it as it was.
322    fn edit<R>(&self, mut f: impl FnMut(&mut EncryptionKeysDoc) -> Result<R>) -> Result<R> {
323        let mut refusal = None;
324        let out = self.doc.update(|doc| match f(doc) {
325            Ok(r) => Ok(Some(r)),
326            Err(KeyRefusal::Store(e)) => Err(e),
327            Err(r) => {
328                refusal = Some(r);
329                Ok(None)
330            }
331        })?;
332        match (out, refusal) {
333            (Some(r), _) => Ok(r),
334            (None, Some(r)) => Err(r),
335            (None, None) => unreachable!("a refusal was recorded"),
336        }
337    }
338
339    /// `principal`'s key, or `None`.
340    pub fn get(&self, principal: &str) -> Result<Option<EncryptionKey>> {
341        Ok(self.doc.load()?.keys.get(principal)?)
342    }
343
344    /// `principal`'s public half, or `None`: what a member wraps a ring to.
345    pub fn public_of(&self, principal: &str) -> Result<Option<String>> {
346        Ok(self.get(principal)?.map(|k| k.public_key))
347    }
348
349    /// The first time: the public half and the first method. Refused when the
350    /// person has a key already (a new one would orphan every ring).
351    pub fn create(
352        &self,
353        principal: &str,
354        public_key: &str,
355        mut first: UnlockMethod,
356        now_ms: u64,
357    ) -> Result<EncryptionKey> {
358        check_public_key(public_key)?;
359        check_method(&first)?;
360        first.id = method_id(&first.wrap);
361        first.added_ms = now_ms;
362        self.edit(|doc| {
363            if doc.keys.contains_key(principal)? {
364                return Err(KeyRefusal::AlreadyHasKey(format!(
365                    "{principal} has an encryption key already; add an unlock method to it instead (a new key would orphan every repository wrapped to the old one)"
366                )));
367            }
368            let key = EncryptionKey { public_key: public_key.trim().to_owned(), methods: vec![first.clone()] };
369            doc.keys.insert(principal.to_owned(), key.clone())?;
370            Ok(key)
371        })
372    }
373
374    /// One more method for `principal`'s key.
375    pub fn add_method(
376        &self,
377        principal: &str,
378        mut method: UnlockMethod,
379        now_ms: u64,
380    ) -> Result<EncryptionKey> {
381        check_method(&method)?;
382        method.id = method_id(&method.wrap);
383        method.added_ms = now_ms;
384        self.edit(|doc| {
385            let mut key = doc.keys.get(principal)?.ok_or_else(|| no_key(principal))?;
386            // **The v1 → v2 re-wrap** (CRYPT-PQ-SK): a security key's v2 wrap for a
387            // credential held with a v1 (X25519) wrap replaces it, keeping its label,
388            // relying party and date. Never the other way round.
389            if gunnar_crypt::skwrap::is_v2(&method.wrap) {
390                if let Some(held) = key
391                    .methods
392                    .iter_mut()
393                    .find(|m| same_method(m, &method) && !gunnar_crypt::skwrap::is_v2(&m.wrap) && m.rp_id == method.rp_id)
394                {
395                    held.wrap = method.wrap.clone();
396                    held.id = method.id.clone();
397                    doc.keys.insert(principal.to_owned(), key.clone())?;
398                    return Ok(key);
399                }
400            }
401            if key.methods.iter().any(|m| same_method(m, &method) || m.id == method.id) {
402                return Err(KeyRefusal::MethodHeld(format!("this {} is an unlock method already", method.kind.word())));
403            }
404            if key.methods.len() >= MAX_METHODS {
405                return Err(refuse("unlock method", format!("{principal} holds {MAX_METHODS} unlock methods, the most gunnar keeps; remove one first")));
406            }
407            key.methods.push(method.clone());
408            doc.keys.insert(principal.to_owned(), key.clone())?;
409            Ok(key)
410        })
411    }
412
413    /// Delete one method's wrap. The last one is refused.
414    pub fn remove_method(&self, principal: &str, id: &str) -> Result<EncryptionKey> {
415        self.edit(|doc| {
416            let mut key = doc.keys.get(principal)?.ok_or_else(|| no_key(principal))?;
417            let before = key.methods.len();
418            if !key.methods.iter().any(|m| m.id == id) {
419                return Err(KeyRefusal::NoSuchMethod(format!("there is no unlock method {id}")));
420            }
421            if before == 1 {
422                return Err(KeyRefusal::LastMethod(
423                    "this is the last unlock method of your encryption key: without one, nothing opens it again. Add another method first".into(),
424                ));
425            }
426            key.methods.retain(|m| m.id != id);
427            doc.keys.insert(principal.to_owned(), key.clone())?;
428            Ok(key)
429        })
430    }
431}
432
433impl UnlockMethod {
434    /// From the forge door's line (`gunnar-user-key`).
435    pub fn from_line(m: gunnar_crypt::door::MethodLine) -> Result<UnlockMethod> {
436        Ok(UnlockMethod {
437            id: m.id,
438            kind: MethodKind::parse(&m.kind)?,
439            label: m.label,
440            method_ref: m.method_ref,
441            rp_id: m.rp_id,
442            wrap: m.wrap,
443            added_ms: m.added_ms,
444        })
445    }
446
447    /// As the forge door's line.
448    pub fn to_line(&self) -> gunnar_crypt::door::MethodLine {
449        gunnar_crypt::door::MethodLine {
450            id: self.id.clone(),
451            kind: self.kind.word().to_owned(),
452            added_ms: self.added_ms,
453            label: self.label.clone(),
454            method_ref: self.method_ref.clone(),
455            rp_id: self.rp_id.clone(),
456            wrap: self.wrap.clone(),
457        }
458    }
459}
460
461impl EncryptionKey {
462    /// As the forge door's answer.
463    pub fn to_answer(&self) -> gunnar_crypt::door::Answer {
464        gunnar_crypt::door::Answer {
465            public_key: Some(self.public_key.clone()),
466            methods: self.methods.iter().map(UnlockMethod::to_line).collect(),
467        }
468    }
469}
470
471fn no_key(principal: &str) -> KeyRefusal {
472    KeyRefusal::NoKey(format!("{principal} has no encryption key yet"))
473}
474
475#[cfg(test)]
476mod tests {
477    use super::*;
478
479    fn store() -> EncryptionKeys {
480        EncryptionKeys::new(Arc::new(
481            crate::control::RedbControlStore::in_memory().expect("a store"),
482        ))
483        .expect("the document")
484    }
485
486    fn user() -> gunnar_crypt::userkey::UserKey {
487        gunnar_crypt::userkey::UserKey::generate(gunnar_crypt::members::Level::Classic).unwrap()
488    }
489
490    fn sk_method(u: &gunnar_crypt::userkey::UserKey, fill: u8, cred: &[u8]) -> UnlockMethod {
491        use gunnar_crypt::userkey::security_key;
492        UnlockMethod {
493            id: String::new(),
494            kind: MethodKind::SecurityKey,
495            label: format!("key {fill}"),
496            method_ref: base64::Engine::encode(
497                &base64::engine::general_purpose::URL_SAFE_NO_PAD,
498                cred,
499            ),
500            rp_id: "console.mock.invalid".into(),
501            wrap: u
502                .wrap_to_security_key(&security_key::recipient(&[fill; 32], cred))
503                .unwrap(),
504            added_ms: 0,
505        }
506    }
507
508    /// ★ Created once, a method added and removed, the public half never changes.
509    #[test]
510    fn a_key_takes_methods_and_keeps_its_public_half() {
511        let s = store();
512        let u = user();
513        assert_eq!(s.get("alice").unwrap(), None);
514        let k = s
515            .create("alice", &u.public(), sk_method(&u, 1, b"one"), 5)
516            .unwrap();
517        assert_eq!(
518            (
519                k.public_key.as_str(),
520                k.methods.len(),
521                k.methods[0].added_ms
522            ),
523            (u.public().as_str(), 1, 5)
524        );
525        assert_eq!(k.methods[0].id.len(), 16);
526        let k = s.add_method("alice", sk_method(&u, 2, b"two"), 6).unwrap();
527        assert_eq!(k.methods.len(), 2);
528        let first = k.methods[0].id.clone();
529        let k = s.remove_method("alice", &first).unwrap();
530        assert_eq!(k.methods.len(), 1);
531        assert_eq!(s.public_of("alice").unwrap(), Some(u.public()));
532    }
533
534    /// ★ Every refusal by name: a second key, the same method twice, the last
535    /// method, a wrap of the wrong kind, a public half that is no key.
536    #[test]
537    fn every_refusal_is_named() {
538        let s = store();
539        let u = user();
540        s.create("alice", &u.public(), sk_method(&u, 1, b"one"), 1)
541            .unwrap();
542        let e = s
543            .create("alice", &u.public(), sk_method(&u, 2, b"two"), 1)
544            .unwrap_err();
545        assert!(e.to_string().contains("orphan"), "{e}");
546        let e = s
547            .add_method("alice", sk_method(&u, 9, b"one"), 1)
548            .unwrap_err();
549        assert!(e.to_string().contains("already"), "{e}");
550        let only = s.get("alice").unwrap().unwrap().methods[0].id.clone();
551        let e = s.remove_method("alice", &only).unwrap_err();
552        assert!(e.to_string().contains("last unlock method"), "{e}");
553        // A security-key method whose wrap is a recovery-code (scrypt) file.
554        let mut wrong = sk_method(&u, 3, b"three");
555        wrong.wrap = u
556            .wrap_to_recovery_code(&gunnar_crypt::userkey::recovery_code::from_bytes(&[1; 20]))
557            .unwrap();
558        let e = s.add_method("alice", wrong, 1).unwrap_err();
559        assert!(e.to_string().contains("one X25519 stanza"), "{e}");
560        let e = s
561            .create("bob", "ssh-ed25519 AAAA", sk_method(&u, 4, b"x"), 1)
562            .unwrap_err();
563        assert!(e.to_string().contains("not an age1"), "{e}");
564        let e = s
565            .add_method("carol", sk_method(&u, 5, b"y"), 1)
566            .unwrap_err();
567        assert!(e.to_string().contains("no encryption key yet"), "{e}");
568        let mut no_rp = sk_method(&u, 6, b"z");
569        no_rp.rp_id.clear();
570        assert!(s
571            .add_method("alice", no_rp, 1)
572            .unwrap_err()
573            .to_string()
574            .contains("relying party"));
575    }
576
577    /// ★ A recovery code goes through scrypt at gunnar's work factor: a cheaper
578    /// wrap is refused, and the client's constant is the server's.
579    #[test]
580    fn a_recovery_wrap_at_a_cheaper_work_factor_is_refused() {
581        use std::io::Write as _;
582        assert_eq!(RECOVERY_LOG_N, gunnar_crypt::userkey::recovery_code::LOG_N);
583        let s = store();
584        let u = user();
585        let mut cheap = age_scrypt(10);
586        let e = s
587            .create(
588                "alice",
589                &u.public(),
590                method(MethodKind::RecoveryCode, &mut cheap),
591                1,
592            )
593            .unwrap_err();
594        assert!(e.to_string().contains("work factor 18"), "{e}");
595        let good = u
596            .wrap_to_recovery_code(&gunnar_crypt::userkey::recovery_code::from_bytes(&[2; 20]))
597            .unwrap();
598        let stanzas = gunnar_crypt::policy::age_stanzas(good.as_bytes()).unwrap();
599        assert_eq!(
600            stanzas
601                .iter()
602                .map(|s| (s.tag.as_str(), s.args.get(1).map(String::as_str)))
603                .collect::<Vec<_>>(),
604            vec![("scrypt", Some("18"))]
605        );
606        fn age_scrypt(log_n: u8) -> String {
607            let mut r = gunnar_crypt::wrap::age::scrypt::Recipient::new(
608                gunnar_crypt::wrap::age::secrecy::SecretString::from(
609                    "0123456789ABCDEFGHJKMNPQRSTVWXYZ".to_string(),
610                ),
611            );
612            r.set_work_factor(log_n);
613            let enc = gunnar_crypt::wrap::age::Encryptor::with_recipients(std::iter::once(
614                &r as &dyn gunnar_crypt::wrap::age::Recipient,
615            ))
616            .unwrap();
617            let mut out = Vec::new();
618            let armor = gunnar_crypt::wrap::age::armor::ArmoredWriter::wrap_output(
619                &mut out,
620                gunnar_crypt::wrap::age::armor::Format::AsciiArmor,
621            )
622            .unwrap();
623            let mut w = enc.wrap_output(armor).unwrap();
624            w.write_all(b"x").unwrap();
625            w.finish().and_then(|a| a.finish()).unwrap();
626            String::from_utf8(out).unwrap()
627        }
628        fn method(kind: MethodKind, wrap: &mut String) -> UnlockMethod {
629            UnlockMethod {
630                id: String::new(),
631                kind,
632                label: "printed".into(),
633                method_ref: String::new(),
634                rp_id: String::new(),
635                wrap: std::mem::take(wrap),
636                added_ms: 0,
637            }
638        }
639    }
640
641    /// ★ A recovery code carries no ref; two codes are two methods.
642    #[test]
643    fn recovery_codes_carry_no_ref() {
644        let s = store();
645        let u = user();
646        let code = |b: u8| UnlockMethod {
647            id: String::new(),
648            kind: MethodKind::RecoveryCode,
649            label: "printed".into(),
650            method_ref: String::new(),
651            rp_id: String::new(),
652            wrap: u
653                .wrap_to_recovery_code(&gunnar_crypt::userkey::recovery_code::from_bytes(&[b; 20]))
654                .unwrap(),
655            added_ms: 0,
656        };
657        s.create("alice", &u.public(), code(1), 1).unwrap();
658        assert_eq!(s.add_method("alice", code(2), 1).unwrap().methods.len(), 2);
659        let mut with_ref = code(3);
660        with_ref.method_ref = "the code".into();
661        assert!(s
662            .add_method("alice", with_ref, 1)
663            .unwrap_err()
664            .to_string()
665            .contains("never holds the code"));
666    }
667
668    /// ★ CRYPT-PQ-SK: a security key's v2 wrap (symmetric, HKDF over the PRF
669    /// output, XChaCha20-Poly1305) is taken; added for a credential held with a
670    /// v1 (X25519) wrap, it REPLACES that wrap (the re-wrap on the next unlock);
671    /// a v1 never replaces a v2; a malformed v2 is refused by name.
672    #[test]
673    fn a_v2_security_key_wrap_replaces_its_v1_and_never_the_other_way() {
674        let s = store();
675        let u = user();
676        let v1 = sk_method(&u, 1, b"one");
677        let k = s.create("alice", &u.public(), v1.clone(), 1).unwrap();
678        let old_id = k.methods[0].id.clone();
679        let mut v2 = v1.clone();
680        v2.wrap = u.wrap_to_security_key_v2(&[1; 32], b"one").unwrap();
681        let k = s.add_method("alice", v2.clone(), 2).unwrap();
682        assert_eq!(k.methods.len(), 1, "the v2 wrap replaced the v1 one");
683        assert!(gunnar_crypt::skwrap::is_v2(&k.methods[0].wrap));
684        assert_ne!(k.methods[0].id, old_id);
685        assert_eq!(
686            (
687                k.methods[0].label.as_str(),
688                k.methods[0].rp_id.as_str(),
689                k.methods[0].added_ms
690            ),
691            (v1.label.as_str(), v1.rp_id.as_str(), 1)
692        );
693        let e = s.add_method("alice", v1.clone(), 3).unwrap_err();
694        assert!(e.to_string().contains("already"), "{e}");
695        assert!(
696            gunnar_crypt::skwrap::is_v2(&s.get("alice").unwrap().unwrap().methods[0].wrap),
697            "never back to X25519"
698        );
699        let e = s.add_method("alice", v2.clone(), 4).unwrap_err();
700        assert!(e.to_string().contains("already"), "the same v2 again: {e}");
701        let mut bad = sk_method(&u, 2, b"two");
702        bad.wrap = format!("{} AAAA AAAA AAAA", gunnar_crypt::skwrap::TAG);
703        let e = s.add_method("alice", bad, 5).unwrap_err();
704        assert!(e.to_string().contains("v2"), "{e}");
705        let mut ssh_v2 = v2;
706        ssh_v2.kind = MethodKind::Ssh;
707        ssh_v2.method_ref = "ssh-ed25519 AAAA".into();
708        let e = s.add_method("alice", ssh_v2, 6).unwrap_err();
709        assert!(
710            e.to_string().contains("ssh"),
711            "a v2 wrap is a security key's only: {e}"
712        );
713    }
714}