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;
15//! * at most [`MAX_METHODS`] methods; the same method (kind and ref) once;
16//! * the LAST method is never removed: nothing would open the key again.
17//!
18//! Who may call is the gRPC door's: the person themselves only.
19
20use std::sync::Arc;
21
22use crate::control::ControlStore;
23use crate::doc::{impl_doc, DocRef, ShardedMap};
24use crate::error::Error;
25
26/// Why a call was refused. Every variant says its rule.
27#[derive(Debug)]
28pub enum KeyRefusal {
29    /// The person has a key already.
30    AlreadyHasKey(String),
31    /// The person has no key yet.
32    NoKey(String),
33    /// The same method is held already.
34    MethodHeld(String),
35    /// No method with that id.
36    NoSuchMethod(String),
37    /// The last method.
38    LastMethod(String),
39    /// A field or a wrap that breaks a rule.
40    Invalid(String),
41    /// The control store failed.
42    Store(Error),
43}
44
45impl std::fmt::Display for KeyRefusal {
46    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47        match self {
48            KeyRefusal::AlreadyHasKey(s)
49            | KeyRefusal::NoKey(s)
50            | KeyRefusal::MethodHeld(s)
51            | KeyRefusal::NoSuchMethod(s)
52            | KeyRefusal::LastMethod(s)
53            | KeyRefusal::Invalid(s) => f.write_str(s),
54            KeyRefusal::Store(e) => write!(f, "{e}"),
55        }
56    }
57}
58impl std::error::Error for KeyRefusal {}
59
60impl From<Error> for KeyRefusal {
61    fn from(e: Error) -> Self {
62        KeyRefusal::Store(e)
63    }
64}
65
66/// This module's result.
67pub type Result<T> = std::result::Result<T, KeyRefusal>;
68
69/// **The name the document is stored under** in the control store.
70pub const ENCRYPTION_KEYS_REF: &str = "refs/gunnar/encryption-keys";
71/// The largest wrap, in bytes (a hybrid stanza's armored file is ~2.3 KB).
72pub const MAX_WRAP_BYTES: usize = 8 << 10;
73/// The most unlock methods one person holds.
74pub const MAX_METHODS: usize = 16;
75/// The scrypt work factor of every recovery-code wrap (`gunnar_crypt::userkey::recovery_code::LOG_N`).
76pub const RECOVERY_LOG_N: u8 = 18;
77/// The longest label, in bytes.
78pub const MAX_LABEL_BYTES: usize = 128;
79/// The longest method ref (an RSA public key line is the longest), in bytes.
80pub const MAX_REF_BYTES: usize = 4096;
81
82/// What opens a person's encryption key.
83#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
84#[serde(rename_all = "kebab-case")]
85pub enum MethodKind {
86    /// An SSH key file (the CLI).
87    Ssh,
88    /// A security key: WebAuthn PRF in the browser, FIDO2 hmac-secret in the CLI.
89    SecurityKey,
90    /// An `age1pq1…` key file (the CLI).
91    AgePq,
92    /// A printed recovery code.
93    RecoveryCode,
94}
95
96impl MethodKind {
97    /// The word on the wire.
98    pub fn word(self) -> &'static str {
99        match self {
100            MethodKind::Ssh => "ssh",
101            MethodKind::SecurityKey => "security-key",
102            MethodKind::AgePq => "age-pq",
103            MethodKind::RecoveryCode => "recovery-code",
104        }
105    }
106
107    /// The kind a word names, or the refusal.
108    pub fn parse(word: &str) -> Result<MethodKind> {
109        match word {
110            "ssh" => Ok(MethodKind::Ssh),
111            "security-key" => Ok(MethodKind::SecurityKey),
112            "age-pq" => Ok(MethodKind::AgePq),
113            "recovery-code" => Ok(MethodKind::RecoveryCode),
114            other => Err(refuse("unlock method", format!("{other:?} is not a method (ssh, security-key, age-pq or recovery-code)"))),
115        }
116    }
117
118    /// The age stanza types a wrap of this kind may carry.
119    fn stanzas(self) -> &'static [&'static str] {
120        match self {
121            MethodKind::Ssh => &["ssh-ed25519", "ssh-rsa"],
122            MethodKind::SecurityKey => &["X25519"],
123            MethodKind::AgePq => &[gunnar_crypt::members::PQ_STANZA],
124            MethodKind::RecoveryCode => &["scrypt"],
125        }
126    }
127}
128
129/// One unlock method: its kind, what names it, and the key wrapped to it.
130#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
131pub struct UnlockMethod {
132    /// 16 hex characters of SHA-256 over the wrap; set by [`EncryptionKeys`].
133    pub id: String,
134    /// What opens it.
135    pub kind: MethodKind,
136    /// The person's own words.
137    pub label: String,
138    /// The SSH public key line, the credential id (base64url), the `age1pq1…`
139    /// key; empty for a recovery code.
140    pub method_ref: String,
141    /// A security key's WebAuthn relying party id.
142    #[serde(default)]
143    pub rp_id: String,
144    /// The armored age file.
145    pub wrap: String,
146    /// Unix ms; set by [`EncryptionKeys`].
147    pub added_ms: u64,
148}
149
150/// One person's encryption key: the public half and its methods.
151#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
152pub struct EncryptionKey {
153    /// `age1…` or `age1pq1…`.
154    pub public_key: String,
155    /// Every unlock method, oldest first.
156    pub methods: Vec<UnlockMethod>,
157}
158
159#[derive(Default, Clone, serde::Serialize, serde::Deserialize)]
160#[serde(deny_unknown_fields)]
161struct EncryptionKeysDoc {
162    /// Principal id → their key.
163    #[serde(default)]
164    keys: ShardedMap<EncryptionKey>,
165}
166
167impl_doc!(EncryptionKeysDoc { keys });
168
169fn refuse(what: &'static str, reason: String) -> KeyRefusal {
170    KeyRefusal::Invalid(format!("{what}: {reason}"))
171}
172
173/// Pure: **is `public` a person's encryption public key**.
174pub fn check_public_key(public: &str) -> Result<()> {
175    use gunnar_crypt::members::{classify, KeyKind};
176    match classify(public) {
177        Ok(KeyKind::Age | KeyKind::AgePq) => Ok(()),
178        _ => Err(refuse("encryption key", format!("{:?} is not an age1… or age1pq1… public key", public.trim()))),
179    }
180}
181
182/// Pure: **is `m` a well-formed method** (its fields, and its wrap's shape).
183pub fn check_method(m: &UnlockMethod) -> Result<()> {
184    if m.label.len() > MAX_LABEL_BYTES || m.label.chars().any(char::is_control) {
185        return Err(refuse("unlock method", format!("a label is at most {MAX_LABEL_BYTES} bytes of printable text")));
186    }
187    if m.method_ref.len() > MAX_REF_BYTES || m.method_ref.chars().any(char::is_control) {
188        return Err(refuse("unlock method", format!("a method ref is at most {MAX_REF_BYTES} bytes of printable text")));
189    }
190    match m.kind {
191        MethodKind::RecoveryCode if !m.method_ref.is_empty() => {
192            return Err(refuse("unlock method", "a recovery code has no ref: gunnar never holds the code".into()))
193        }
194        MethodKind::Ssh | MethodKind::SecurityKey | MethodKind::AgePq if m.method_ref.trim().is_empty() => {
195            return Err(refuse("unlock method", format!("a {} method names its key (the method ref)", m.kind.word())))
196        }
197        MethodKind::SecurityKey if m.rp_id.trim().is_empty() || m.rp_id.len() > 253 => {
198            return Err(refuse("unlock method", "a security key names the relying party id its credential is bound to".into()))
199        }
200        _ => {}
201    }
202    if m.wrap.len() > MAX_WRAP_BYTES {
203        return Err(refuse("unlock method", format!("the wrap is {} bytes, more than the {MAX_WRAP_BYTES} gunnar keeps", m.wrap.len())));
204    }
205    let stanzas = gunnar_crypt::policy::age_stanzas(m.wrap.as_bytes())
206        .ok_or_else(|| refuse("unlock method", "the wrap is not an age file".into()))?;
207    let real: Vec<&str> = stanzas.iter().map(|s| s.tag.as_str()).filter(|t| !t.ends_with("-grease")).collect();
208    if real.len() != 1 || !m.kind.stanzas().contains(&real[0]) {
209        return Err(refuse(
210            "unlock method",
211            format!("a {} wrap has one {} stanza; this one has {real:?}", m.kind.word(), m.kind.stanzas().join(" or ")),
212        ));
213    }
214    // A recovery code goes through scrypt at gunnar's work factor, never a cheaper one.
215    if m.kind == MethodKind::RecoveryCode {
216        let s = stanzas.iter().find(|s| s.tag == "scrypt").expect("checked above");
217        let log_n = s.args.get(1).and_then(|w| w.parse::<u8>().ok());
218        if log_n != Some(RECOVERY_LOG_N) {
219            return Err(refuse(
220                "unlock method",
221                format!("a recovery code's wrap uses scrypt at work factor {} (this one: {log_n:?})", RECOVERY_LOG_N),
222            ));
223        }
224    }
225    Ok(())
226}
227
228fn method_id(wrap: &str) -> String {
229    use sha2::Digest as _;
230    sha2::Sha256::digest(wrap.as_bytes())[..8].iter().map(|b| format!("{b:02x}")).collect()
231}
232
233fn same_method(a: &UnlockMethod, b: &UnlockMethod) -> bool {
234    a.kind == b.kind && a.kind != MethodKind::RecoveryCode && a.method_ref.trim() == b.method_ref.trim()
235}
236
237/// Read and write people's encryption keys. Cheap to clone.
238#[derive(Clone, Debug)]
239pub struct EncryptionKeys {
240    doc: DocRef<EncryptionKeysDoc>,
241}
242
243impl EncryptionKeys {
244    /// Open the document held in `store`.
245    pub fn new(store: Arc<dyn ControlStore>) -> Result<Self> {
246        Ok(EncryptionKeys { doc: DocRef::new(store, ENCRYPTION_KEYS_REF)? })
247    }
248
249    /// One update of the document; a refusal leaves it as it was.
250    fn edit<R>(&self, mut f: impl FnMut(&mut EncryptionKeysDoc) -> Result<R>) -> Result<R> {
251        let mut refusal = None;
252        let out = self.doc.update(|doc| match f(doc) {
253            Ok(r) => Ok(Some(r)),
254            Err(KeyRefusal::Store(e)) => Err(e),
255            Err(r) => {
256                refusal = Some(r);
257                Ok(None)
258            }
259        })?;
260        match (out, refusal) {
261            (Some(r), _) => Ok(r),
262            (None, Some(r)) => Err(r),
263            (None, None) => unreachable!("a refusal was recorded"),
264        }
265    }
266
267    /// `principal`'s key, or `None`.
268    pub fn get(&self, principal: &str) -> Result<Option<EncryptionKey>> {
269        Ok(self.doc.load()?.keys.get(principal)?)
270    }
271
272    /// `principal`'s public half, or `None`: what a member wraps a ring to.
273    pub fn public_of(&self, principal: &str) -> Result<Option<String>> {
274        Ok(self.get(principal)?.map(|k| k.public_key))
275    }
276
277    /// The first time: the public half and the first method. Refused when the
278    /// person has a key already (a new one would orphan every ring).
279    pub fn create(&self, principal: &str, public_key: &str, mut first: UnlockMethod, now_ms: u64) -> Result<EncryptionKey> {
280        check_public_key(public_key)?;
281        check_method(&first)?;
282        first.id = method_id(&first.wrap);
283        first.added_ms = now_ms;
284        self.edit(|doc| {
285            if doc.keys.contains_key(principal)? {
286                return Err(KeyRefusal::AlreadyHasKey(format!(
287                    "{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)"
288                )));
289            }
290            let key = EncryptionKey { public_key: public_key.trim().to_owned(), methods: vec![first.clone()] };
291            doc.keys.insert(principal.to_owned(), key.clone())?;
292            Ok(key)
293        })
294    }
295
296    /// One more method for `principal`'s key.
297    pub fn add_method(&self, principal: &str, mut method: UnlockMethod, now_ms: u64) -> Result<EncryptionKey> {
298        check_method(&method)?;
299        method.id = method_id(&method.wrap);
300        method.added_ms = now_ms;
301        self.edit(|doc| {
302            let mut key = doc.keys.get(principal)?.ok_or_else(|| no_key(principal))?;
303            if key.methods.iter().any(|m| same_method(m, &method) || m.id == method.id) {
304                return Err(KeyRefusal::MethodHeld(format!("this {} is an unlock method already", method.kind.word())));
305            }
306            if key.methods.len() >= MAX_METHODS {
307                return Err(refuse("unlock method", format!("{principal} holds {MAX_METHODS} unlock methods, the most gunnar keeps; remove one first")));
308            }
309            key.methods.push(method.clone());
310            doc.keys.insert(principal.to_owned(), key.clone())?;
311            Ok(key)
312        })
313    }
314
315    /// Delete one method's wrap. The last one is refused.
316    pub fn remove_method(&self, principal: &str, id: &str) -> Result<EncryptionKey> {
317        self.edit(|doc| {
318            let mut key = doc.keys.get(principal)?.ok_or_else(|| no_key(principal))?;
319            let before = key.methods.len();
320            if !key.methods.iter().any(|m| m.id == id) {
321                return Err(KeyRefusal::NoSuchMethod(format!("there is no unlock method {id}")));
322            }
323            if before == 1 {
324                return Err(KeyRefusal::LastMethod(
325                    "this is the last unlock method of your encryption key: without one, nothing opens it again. Add another method first".into(),
326                ));
327            }
328            key.methods.retain(|m| m.id != id);
329            doc.keys.insert(principal.to_owned(), key.clone())?;
330            Ok(key)
331        })
332    }
333}
334
335impl UnlockMethod {
336    /// From the forge door's line (`gunnar-user-key`).
337    pub fn from_line(m: gunnar_crypt::door::MethodLine) -> Result<UnlockMethod> {
338        Ok(UnlockMethod {
339            id: m.id,
340            kind: MethodKind::parse(&m.kind)?,
341            label: m.label,
342            method_ref: m.method_ref,
343            rp_id: m.rp_id,
344            wrap: m.wrap,
345            added_ms: m.added_ms,
346        })
347    }
348
349    /// As the forge door's line.
350    pub fn to_line(&self) -> gunnar_crypt::door::MethodLine {
351        gunnar_crypt::door::MethodLine {
352            id: self.id.clone(),
353            kind: self.kind.word().to_owned(),
354            added_ms: self.added_ms,
355            label: self.label.clone(),
356            method_ref: self.method_ref.clone(),
357            rp_id: self.rp_id.clone(),
358            wrap: self.wrap.clone(),
359        }
360    }
361}
362
363impl EncryptionKey {
364    /// As the forge door's answer.
365    pub fn to_answer(&self) -> gunnar_crypt::door::Answer {
366        gunnar_crypt::door::Answer {
367            public_key: Some(self.public_key.clone()),
368            methods: self.methods.iter().map(UnlockMethod::to_line).collect(),
369        }
370    }
371}
372
373fn no_key(principal: &str) -> KeyRefusal {
374    KeyRefusal::NoKey(format!("{principal} has no encryption key yet"))
375}
376
377#[cfg(test)]
378mod tests {
379    use super::*;
380
381    fn store() -> EncryptionKeys {
382        EncryptionKeys::new(Arc::new(crate::control::RedbControlStore::in_memory().expect("a store"))).expect("the document")
383    }
384
385    fn user() -> gunnar_crypt::userkey::UserKey {
386        gunnar_crypt::userkey::UserKey::generate(gunnar_crypt::members::Level::Classic).unwrap()
387    }
388
389    fn sk_method(u: &gunnar_crypt::userkey::UserKey, fill: u8, cred: &[u8]) -> UnlockMethod {
390        use gunnar_crypt::userkey::security_key;
391        UnlockMethod {
392            id: String::new(),
393            kind: MethodKind::SecurityKey,
394            label: format!("key {fill}"),
395            method_ref: base64::Engine::encode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, cred),
396            rp_id: "console.mock.invalid".into(),
397            wrap: u.wrap_to_security_key(&security_key::recipient(&[fill; 32], cred)).unwrap(),
398            added_ms: 0,
399        }
400    }
401
402    /// ★ Created once, a method added and removed, the public half never changes.
403    #[test]
404    fn a_key_takes_methods_and_keeps_its_public_half() {
405        let s = store();
406        let u = user();
407        assert_eq!(s.get("alice").unwrap(), None);
408        let k = s.create("alice", &u.public(), sk_method(&u, 1, b"one"), 5).unwrap();
409        assert_eq!((k.public_key.as_str(), k.methods.len(), k.methods[0].added_ms), (u.public().as_str(), 1, 5));
410        assert_eq!(k.methods[0].id.len(), 16);
411        let k = s.add_method("alice", sk_method(&u, 2, b"two"), 6).unwrap();
412        assert_eq!(k.methods.len(), 2);
413        let first = k.methods[0].id.clone();
414        let k = s.remove_method("alice", &first).unwrap();
415        assert_eq!(k.methods.len(), 1);
416        assert_eq!(s.public_of("alice").unwrap(), Some(u.public()));
417    }
418
419    /// ★ Every refusal by name: a second key, the same method twice, the last
420    /// method, a wrap of the wrong kind, a public half that is no key.
421    #[test]
422    fn every_refusal_is_named() {
423        let s = store();
424        let u = user();
425        s.create("alice", &u.public(), sk_method(&u, 1, b"one"), 1).unwrap();
426        let e = s.create("alice", &u.public(), sk_method(&u, 2, b"two"), 1).unwrap_err();
427        assert!(e.to_string().contains("orphan"), "{e}");
428        let e = s.add_method("alice", sk_method(&u, 9, b"one"), 1).unwrap_err();
429        assert!(e.to_string().contains("already"), "{e}");
430        let only = s.get("alice").unwrap().unwrap().methods[0].id.clone();
431        let e = s.remove_method("alice", &only).unwrap_err();
432        assert!(e.to_string().contains("last unlock method"), "{e}");
433        // A security-key method whose wrap is a recovery-code (scrypt) file.
434        let mut wrong = sk_method(&u, 3, b"three");
435        wrong.wrap = u.wrap_to_recovery_code(&gunnar_crypt::userkey::recovery_code::from_bytes(&[1; 20])).unwrap();
436        let e = s.add_method("alice", wrong, 1).unwrap_err();
437        assert!(e.to_string().contains("one X25519 stanza"), "{e}");
438        let e = s.create("bob", "ssh-ed25519 AAAA", sk_method(&u, 4, b"x"), 1).unwrap_err();
439        assert!(e.to_string().contains("not an age1"), "{e}");
440        let e = s.add_method("carol", sk_method(&u, 5, b"y"), 1).unwrap_err();
441        assert!(e.to_string().contains("no encryption key yet"), "{e}");
442        let mut no_rp = sk_method(&u, 6, b"z");
443        no_rp.rp_id.clear();
444        assert!(s.add_method("alice", no_rp, 1).unwrap_err().to_string().contains("relying party"));
445    }
446
447    /// ★ A recovery code goes through scrypt at gunnar's work factor: a cheaper
448    /// wrap is refused, and the client's constant is the server's.
449    #[test]
450    fn a_recovery_wrap_at_a_cheaper_work_factor_is_refused() {
451        use std::io::Write as _;
452        assert_eq!(RECOVERY_LOG_N, gunnar_crypt::userkey::recovery_code::LOG_N);
453        let s = store();
454        let u = user();
455        let mut cheap = age_scrypt(10);
456        let e = s.create("alice", &u.public(), method(MethodKind::RecoveryCode, &mut cheap), 1).unwrap_err();
457        assert!(e.to_string().contains("work factor 18"), "{e}");
458        let good = u.wrap_to_recovery_code(&gunnar_crypt::userkey::recovery_code::from_bytes(&[2; 20])).unwrap();
459        let stanzas = gunnar_crypt::policy::age_stanzas(good.as_bytes()).unwrap();
460        assert_eq!(stanzas.iter().map(|s| (s.tag.as_str(), s.args.get(1).map(String::as_str))).collect::<Vec<_>>(), vec![("scrypt", Some("18"))]);
461        fn age_scrypt(log_n: u8) -> String {
462            let mut r = gunnar_crypt::wrap::age::scrypt::Recipient::new(gunnar_crypt::wrap::age::secrecy::SecretString::from("0123456789ABCDEFGHJKMNPQRSTVWXYZ".to_string()));
463            r.set_work_factor(log_n);
464            let enc = gunnar_crypt::wrap::age::Encryptor::with_recipients(std::iter::once(&r as &dyn gunnar_crypt::wrap::age::Recipient)).unwrap();
465            let mut out = Vec::new();
466            let armor = gunnar_crypt::wrap::age::armor::ArmoredWriter::wrap_output(&mut out, gunnar_crypt::wrap::age::armor::Format::AsciiArmor).unwrap();
467            let mut w = enc.wrap_output(armor).unwrap();
468            w.write_all(b"x").unwrap();
469            w.finish().and_then(|a| a.finish()).unwrap();
470            String::from_utf8(out).unwrap()
471        }
472        fn method(kind: MethodKind, wrap: &mut String) -> UnlockMethod {
473            UnlockMethod { id: String::new(), kind, label: "printed".into(), method_ref: String::new(), rp_id: String::new(), wrap: std::mem::take(wrap), added_ms: 0 }
474        }
475    }
476
477    /// ★ A recovery code carries no ref; two codes are two methods.
478    #[test]
479    fn recovery_codes_carry_no_ref() {
480        let s = store();
481        let u = user();
482        let code = |b: u8| UnlockMethod {
483            id: String::new(),
484            kind: MethodKind::RecoveryCode,
485            label: "printed".into(),
486            method_ref: String::new(),
487            rp_id: String::new(),
488            wrap: u.wrap_to_recovery_code(&gunnar_crypt::userkey::recovery_code::from_bytes(&[b; 20])).unwrap(),
489            added_ms: 0,
490        };
491        s.create("alice", &u.public(), code(1), 1).unwrap();
492        assert_eq!(s.add_method("alice", code(2), 1).unwrap().methods.len(), 2);
493        let mut with_ref = code(3);
494        with_ref.method_ref = "the code".into();
495        assert!(s.add_method("alice", with_ref, 1).unwrap_err().to_string().contains("never holds the code"));
496    }
497}