1use std::sync::Arc;
23
24use crate::control::ControlStore;
25use crate::doc::{impl_doc, DocRef, ShardedMap};
26use crate::error::Error;
27
28#[derive(Debug)]
30pub enum KeyRefusal {
31 AlreadyHasKey(String),
33 NoKey(String),
35 MethodHeld(String),
37 NoSuchMethod(String),
39 LastMethod(String),
41 Invalid(String),
43 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
68pub type Result<T> = std::result::Result<T, KeyRefusal>;
70
71pub const ENCRYPTION_KEYS_REF: &str = "refs/gunnar/encryption-keys";
73pub const MAX_WRAP_BYTES: usize = 8 << 10;
75pub const MAX_METHODS: usize = 16;
77pub const RECOVERY_LOG_N: u8 = 18;
79pub const MAX_LABEL_BYTES: usize = 128;
81pub const MAX_REF_BYTES: usize = 4096;
83
84#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
86#[serde(rename_all = "kebab-case")]
87pub enum MethodKind {
88 Ssh,
90 SecurityKey,
92 AgePq,
94 RecoveryCode,
96}
97
98impl MethodKind {
99 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 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 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#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
136pub struct UnlockMethod {
137 pub id: String,
139 pub kind: MethodKind,
141 pub label: String,
143 pub method_ref: String,
146 #[serde(default)]
148 pub rp_id: String,
149 pub wrap: String,
151 pub added_ms: u64,
153}
154
155#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
157pub struct EncryptionKey {
158 pub public_key: String,
160 pub methods: Vec<UnlockMethod>,
162}
163
164#[derive(Default, Clone, serde::Serialize, serde::Deserialize)]
165#[serde(deny_unknown_fields)]
166struct EncryptionKeysDoc {
167 #[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
178pub 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
190pub 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 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 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#[derive(Clone, Debug)]
309pub struct EncryptionKeys {
310 doc: DocRef<EncryptionKeysDoc>,
311}
312
313impl EncryptionKeys {
314 pub fn new(store: Arc<dyn ControlStore>) -> Result<Self> {
316 Ok(EncryptionKeys {
317 doc: DocRef::new(store, ENCRYPTION_KEYS_REF)?,
318 })
319 }
320
321 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 pub fn get(&self, principal: &str) -> Result<Option<EncryptionKey>> {
341 Ok(self.doc.load()?.keys.get(principal)?)
342 }
343
344 pub fn public_of(&self, principal: &str) -> Result<Option<String>> {
346 Ok(self.get(principal)?.map(|k| k.public_key))
347 }
348
349 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 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 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 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 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 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 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 #[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 #[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 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 #[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 #[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 #[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}