use std::sync::Arc;
use crate::control::ControlStore;
use crate::doc::{impl_doc, DocRef, ShardedMap};
use crate::error::Error;
#[derive(Debug)]
pub enum KeyRefusal {
AlreadyHasKey(String),
NoKey(String),
MethodHeld(String),
NoSuchMethod(String),
LastMethod(String),
Invalid(String),
Store(Error),
}
impl std::fmt::Display for KeyRefusal {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
KeyRefusal::AlreadyHasKey(s)
| KeyRefusal::NoKey(s)
| KeyRefusal::MethodHeld(s)
| KeyRefusal::NoSuchMethod(s)
| KeyRefusal::LastMethod(s)
| KeyRefusal::Invalid(s) => f.write_str(s),
KeyRefusal::Store(e) => write!(f, "{e}"),
}
}
}
impl std::error::Error for KeyRefusal {}
impl From<Error> for KeyRefusal {
fn from(e: Error) -> Self {
KeyRefusal::Store(e)
}
}
pub type Result<T> = std::result::Result<T, KeyRefusal>;
pub const ENCRYPTION_KEYS_REF: &str = "refs/gunnar/encryption-keys";
pub const MAX_WRAP_BYTES: usize = 8 << 10;
pub const MAX_METHODS: usize = 16;
pub const RECOVERY_LOG_N: u8 = 18;
pub const MAX_LABEL_BYTES: usize = 128;
pub const MAX_REF_BYTES: usize = 4096;
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum MethodKind {
Ssh,
SecurityKey,
AgePq,
RecoveryCode,
}
impl MethodKind {
pub fn word(self) -> &'static str {
match self {
MethodKind::Ssh => "ssh",
MethodKind::SecurityKey => "security-key",
MethodKind::AgePq => "age-pq",
MethodKind::RecoveryCode => "recovery-code",
}
}
pub fn parse(word: &str) -> Result<MethodKind> {
match word {
"ssh" => Ok(MethodKind::Ssh),
"security-key" => Ok(MethodKind::SecurityKey),
"age-pq" => Ok(MethodKind::AgePq),
"recovery-code" => Ok(MethodKind::RecoveryCode),
other => Err(refuse("unlock method", format!("{other:?} is not a method (ssh, security-key, age-pq or recovery-code)"))),
}
}
fn stanzas(self) -> &'static [&'static str] {
match self {
MethodKind::Ssh => &["ssh-ed25519", "ssh-rsa"],
MethodKind::SecurityKey => &["X25519"],
MethodKind::AgePq => &[gunnar_crypt::members::PQ_STANZA],
MethodKind::RecoveryCode => &["scrypt"],
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct UnlockMethod {
pub id: String,
pub kind: MethodKind,
pub label: String,
pub method_ref: String,
#[serde(default)]
pub rp_id: String,
pub wrap: String,
pub added_ms: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct EncryptionKey {
pub public_key: String,
pub methods: Vec<UnlockMethod>,
}
#[derive(Default, Clone, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct EncryptionKeysDoc {
#[serde(default)]
keys: ShardedMap<EncryptionKey>,
}
impl_doc!(EncryptionKeysDoc { keys });
fn refuse(what: &'static str, reason: String) -> KeyRefusal {
KeyRefusal::Invalid(format!("{what}: {reason}"))
}
pub fn check_public_key(public: &str) -> Result<()> {
use gunnar_crypt::members::{classify, KeyKind};
match classify(public) {
Ok(KeyKind::Age | KeyKind::AgePq) => Ok(()),
_ => Err(refuse("encryption key", format!("{:?} is not an age1… or age1pq1… public key", public.trim()))),
}
}
pub fn check_method(m: &UnlockMethod) -> Result<()> {
if m.label.len() > MAX_LABEL_BYTES || m.label.chars().any(char::is_control) {
return Err(refuse("unlock method", format!("a label is at most {MAX_LABEL_BYTES} bytes of printable text")));
}
if m.method_ref.len() > MAX_REF_BYTES || m.method_ref.chars().any(char::is_control) {
return Err(refuse("unlock method", format!("a method ref is at most {MAX_REF_BYTES} bytes of printable text")));
}
match m.kind {
MethodKind::RecoveryCode if !m.method_ref.is_empty() => {
return Err(refuse("unlock method", "a recovery code has no ref: gunnar never holds the code".into()))
}
MethodKind::Ssh | MethodKind::SecurityKey | MethodKind::AgePq if m.method_ref.trim().is_empty() => {
return Err(refuse("unlock method", format!("a {} method names its key (the method ref)", m.kind.word())))
}
MethodKind::SecurityKey if m.rp_id.trim().is_empty() || m.rp_id.len() > 253 => {
return Err(refuse("unlock method", "a security key names the relying party id its credential is bound to".into()))
}
_ => {}
}
if m.wrap.len() > MAX_WRAP_BYTES {
return Err(refuse("unlock method", format!("the wrap is {} bytes, more than the {MAX_WRAP_BYTES} gunnar keeps", m.wrap.len())));
}
let stanzas = gunnar_crypt::policy::age_stanzas(m.wrap.as_bytes())
.ok_or_else(|| refuse("unlock method", "the wrap is not an age file".into()))?;
let real: Vec<&str> = stanzas.iter().map(|s| s.tag.as_str()).filter(|t| !t.ends_with("-grease")).collect();
if real.len() != 1 || !m.kind.stanzas().contains(&real[0]) {
return Err(refuse(
"unlock method",
format!("a {} wrap has one {} stanza; this one has {real:?}", m.kind.word(), m.kind.stanzas().join(" or ")),
));
}
if m.kind == MethodKind::RecoveryCode {
let s = stanzas.iter().find(|s| s.tag == "scrypt").expect("checked above");
let log_n = s.args.get(1).and_then(|w| w.parse::<u8>().ok());
if log_n != Some(RECOVERY_LOG_N) {
return Err(refuse(
"unlock method",
format!("a recovery code's wrap uses scrypt at work factor {} (this one: {log_n:?})", RECOVERY_LOG_N),
));
}
}
Ok(())
}
fn method_id(wrap: &str) -> String {
use sha2::Digest as _;
sha2::Sha256::digest(wrap.as_bytes())[..8].iter().map(|b| format!("{b:02x}")).collect()
}
fn same_method(a: &UnlockMethod, b: &UnlockMethod) -> bool {
a.kind == b.kind && a.kind != MethodKind::RecoveryCode && a.method_ref.trim() == b.method_ref.trim()
}
#[derive(Clone, Debug)]
pub struct EncryptionKeys {
doc: DocRef<EncryptionKeysDoc>,
}
impl EncryptionKeys {
pub fn new(store: Arc<dyn ControlStore>) -> Result<Self> {
Ok(EncryptionKeys { doc: DocRef::new(store, ENCRYPTION_KEYS_REF)? })
}
fn edit<R>(&self, mut f: impl FnMut(&mut EncryptionKeysDoc) -> Result<R>) -> Result<R> {
let mut refusal = None;
let out = self.doc.update(|doc| match f(doc) {
Ok(r) => Ok(Some(r)),
Err(KeyRefusal::Store(e)) => Err(e),
Err(r) => {
refusal = Some(r);
Ok(None)
}
})?;
match (out, refusal) {
(Some(r), _) => Ok(r),
(None, Some(r)) => Err(r),
(None, None) => unreachable!("a refusal was recorded"),
}
}
pub fn get(&self, principal: &str) -> Result<Option<EncryptionKey>> {
Ok(self.doc.load()?.keys.get(principal)?)
}
pub fn public_of(&self, principal: &str) -> Result<Option<String>> {
Ok(self.get(principal)?.map(|k| k.public_key))
}
pub fn create(&self, principal: &str, public_key: &str, mut first: UnlockMethod, now_ms: u64) -> Result<EncryptionKey> {
check_public_key(public_key)?;
check_method(&first)?;
first.id = method_id(&first.wrap);
first.added_ms = now_ms;
self.edit(|doc| {
if doc.keys.contains_key(principal)? {
return Err(KeyRefusal::AlreadyHasKey(format!(
"{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)"
)));
}
let key = EncryptionKey { public_key: public_key.trim().to_owned(), methods: vec![first.clone()] };
doc.keys.insert(principal.to_owned(), key.clone())?;
Ok(key)
})
}
pub fn add_method(&self, principal: &str, mut method: UnlockMethod, now_ms: u64) -> Result<EncryptionKey> {
check_method(&method)?;
method.id = method_id(&method.wrap);
method.added_ms = now_ms;
self.edit(|doc| {
let mut key = doc.keys.get(principal)?.ok_or_else(|| no_key(principal))?;
if key.methods.iter().any(|m| same_method(m, &method) || m.id == method.id) {
return Err(KeyRefusal::MethodHeld(format!("this {} is an unlock method already", method.kind.word())));
}
if key.methods.len() >= MAX_METHODS {
return Err(refuse("unlock method", format!("{principal} holds {MAX_METHODS} unlock methods, the most gunnar keeps; remove one first")));
}
key.methods.push(method.clone());
doc.keys.insert(principal.to_owned(), key.clone())?;
Ok(key)
})
}
pub fn remove_method(&self, principal: &str, id: &str) -> Result<EncryptionKey> {
self.edit(|doc| {
let mut key = doc.keys.get(principal)?.ok_or_else(|| no_key(principal))?;
let before = key.methods.len();
if !key.methods.iter().any(|m| m.id == id) {
return Err(KeyRefusal::NoSuchMethod(format!("there is no unlock method {id}")));
}
if before == 1 {
return Err(KeyRefusal::LastMethod(
"this is the last unlock method of your encryption key: without one, nothing opens it again. Add another method first".into(),
));
}
key.methods.retain(|m| m.id != id);
doc.keys.insert(principal.to_owned(), key.clone())?;
Ok(key)
})
}
}
impl UnlockMethod {
pub fn from_line(m: gunnar_crypt::door::MethodLine) -> Result<UnlockMethod> {
Ok(UnlockMethod {
id: m.id,
kind: MethodKind::parse(&m.kind)?,
label: m.label,
method_ref: m.method_ref,
rp_id: m.rp_id,
wrap: m.wrap,
added_ms: m.added_ms,
})
}
pub fn to_line(&self) -> gunnar_crypt::door::MethodLine {
gunnar_crypt::door::MethodLine {
id: self.id.clone(),
kind: self.kind.word().to_owned(),
added_ms: self.added_ms,
label: self.label.clone(),
method_ref: self.method_ref.clone(),
rp_id: self.rp_id.clone(),
wrap: self.wrap.clone(),
}
}
}
impl EncryptionKey {
pub fn to_answer(&self) -> gunnar_crypt::door::Answer {
gunnar_crypt::door::Answer {
public_key: Some(self.public_key.clone()),
methods: self.methods.iter().map(UnlockMethod::to_line).collect(),
}
}
}
fn no_key(principal: &str) -> KeyRefusal {
KeyRefusal::NoKey(format!("{principal} has no encryption key yet"))
}
#[cfg(test)]
mod tests {
use super::*;
fn store() -> EncryptionKeys {
EncryptionKeys::new(Arc::new(crate::control::RedbControlStore::in_memory().expect("a store"))).expect("the document")
}
fn user() -> gunnar_crypt::userkey::UserKey {
gunnar_crypt::userkey::UserKey::generate(gunnar_crypt::members::Level::Classic).unwrap()
}
fn sk_method(u: &gunnar_crypt::userkey::UserKey, fill: u8, cred: &[u8]) -> UnlockMethod {
use gunnar_crypt::userkey::security_key;
UnlockMethod {
id: String::new(),
kind: MethodKind::SecurityKey,
label: format!("key {fill}"),
method_ref: base64::Engine::encode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, cred),
rp_id: "console.mock.invalid".into(),
wrap: u.wrap_to_security_key(&security_key::recipient(&[fill; 32], cred)).unwrap(),
added_ms: 0,
}
}
#[test]
fn a_key_takes_methods_and_keeps_its_public_half() {
let s = store();
let u = user();
assert_eq!(s.get("alice").unwrap(), None);
let k = s.create("alice", &u.public(), sk_method(&u, 1, b"one"), 5).unwrap();
assert_eq!((k.public_key.as_str(), k.methods.len(), k.methods[0].added_ms), (u.public().as_str(), 1, 5));
assert_eq!(k.methods[0].id.len(), 16);
let k = s.add_method("alice", sk_method(&u, 2, b"two"), 6).unwrap();
assert_eq!(k.methods.len(), 2);
let first = k.methods[0].id.clone();
let k = s.remove_method("alice", &first).unwrap();
assert_eq!(k.methods.len(), 1);
assert_eq!(s.public_of("alice").unwrap(), Some(u.public()));
}
#[test]
fn every_refusal_is_named() {
let s = store();
let u = user();
s.create("alice", &u.public(), sk_method(&u, 1, b"one"), 1).unwrap();
let e = s.create("alice", &u.public(), sk_method(&u, 2, b"two"), 1).unwrap_err();
assert!(e.to_string().contains("orphan"), "{e}");
let e = s.add_method("alice", sk_method(&u, 9, b"one"), 1).unwrap_err();
assert!(e.to_string().contains("already"), "{e}");
let only = s.get("alice").unwrap().unwrap().methods[0].id.clone();
let e = s.remove_method("alice", &only).unwrap_err();
assert!(e.to_string().contains("last unlock method"), "{e}");
let mut wrong = sk_method(&u, 3, b"three");
wrong.wrap = u.wrap_to_recovery_code(&gunnar_crypt::userkey::recovery_code::from_bytes(&[1; 20])).unwrap();
let e = s.add_method("alice", wrong, 1).unwrap_err();
assert!(e.to_string().contains("one X25519 stanza"), "{e}");
let e = s.create("bob", "ssh-ed25519 AAAA", sk_method(&u, 4, b"x"), 1).unwrap_err();
assert!(e.to_string().contains("not an age1"), "{e}");
let e = s.add_method("carol", sk_method(&u, 5, b"y"), 1).unwrap_err();
assert!(e.to_string().contains("no encryption key yet"), "{e}");
let mut no_rp = sk_method(&u, 6, b"z");
no_rp.rp_id.clear();
assert!(s.add_method("alice", no_rp, 1).unwrap_err().to_string().contains("relying party"));
}
#[test]
fn a_recovery_wrap_at_a_cheaper_work_factor_is_refused() {
use std::io::Write as _;
assert_eq!(RECOVERY_LOG_N, gunnar_crypt::userkey::recovery_code::LOG_N);
let s = store();
let u = user();
let mut cheap = age_scrypt(10);
let e = s.create("alice", &u.public(), method(MethodKind::RecoveryCode, &mut cheap), 1).unwrap_err();
assert!(e.to_string().contains("work factor 18"), "{e}");
let good = u.wrap_to_recovery_code(&gunnar_crypt::userkey::recovery_code::from_bytes(&[2; 20])).unwrap();
let stanzas = gunnar_crypt::policy::age_stanzas(good.as_bytes()).unwrap();
assert_eq!(stanzas.iter().map(|s| (s.tag.as_str(), s.args.get(1).map(String::as_str))).collect::<Vec<_>>(), vec![("scrypt", Some("18"))]);
fn age_scrypt(log_n: u8) -> String {
let mut r = gunnar_crypt::wrap::age::scrypt::Recipient::new(gunnar_crypt::wrap::age::secrecy::SecretString::from("0123456789ABCDEFGHJKMNPQRSTVWXYZ".to_string()));
r.set_work_factor(log_n);
let enc = gunnar_crypt::wrap::age::Encryptor::with_recipients(std::iter::once(&r as &dyn gunnar_crypt::wrap::age::Recipient)).unwrap();
let mut out = Vec::new();
let armor = gunnar_crypt::wrap::age::armor::ArmoredWriter::wrap_output(&mut out, gunnar_crypt::wrap::age::armor::Format::AsciiArmor).unwrap();
let mut w = enc.wrap_output(armor).unwrap();
w.write_all(b"x").unwrap();
w.finish().and_then(|a| a.finish()).unwrap();
String::from_utf8(out).unwrap()
}
fn method(kind: MethodKind, wrap: &mut String) -> UnlockMethod {
UnlockMethod { id: String::new(), kind, label: "printed".into(), method_ref: String::new(), rp_id: String::new(), wrap: std::mem::take(wrap), added_ms: 0 }
}
}
#[test]
fn recovery_codes_carry_no_ref() {
let s = store();
let u = user();
let code = |b: u8| UnlockMethod {
id: String::new(),
kind: MethodKind::RecoveryCode,
label: "printed".into(),
method_ref: String::new(),
rp_id: String::new(),
wrap: u.wrap_to_recovery_code(&gunnar_crypt::userkey::recovery_code::from_bytes(&[b; 20])).unwrap(),
added_ms: 0,
};
s.create("alice", &u.public(), code(1), 1).unwrap();
assert_eq!(s.add_method("alice", code(2), 1).unwrap().methods.len(), 2);
let mut with_ref = code(3);
with_ref.method_ref = "the code".into();
assert!(s.add_method("alice", with_ref, 1).unwrap_err().to_string().contains("never holds the code"));
}
}