gunnar-crypt 1.5.1

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
//! **The forge door for a person's encryption key** (`gunnar-user-key`,
//! CRYPT-YUBI 2026-10-26): what the CLI asks over the SSH door git already
//! signs in with, and what the server answers. Pure text, both sides.
//!
//! ```text
//! gunnar-user-key                              your key: the answer below
//! gunnar-user-key create <public> <method>     the first time
//! gunnar-user-key add <method>                 one more unlock method
//! gunnar-user-key remove <id>                  delete one method's wrap
//!
//! answer:  public <age1…>                      (absent: no key yet)
//!          method <id> <kind> <added-ms> <label> <ref> <rp-id> <wrap>
//! ```
//!
//! `<method>` is the `method` line's seven fields. Free text (label, ref, rp
//! id, the armored wrap) travels hex-encoded, `-` for empty, so a line is
//! printable ASCII without quoting. The caller is the session's own
//! principal: there is no name to give, so the verb is no directory.

/// **The fingerprint of an encryption public key** (`SHA256:` and unpadded
/// base64 of SHA-256 over the key's text): the one the server shows
/// (`RecipientKey.fingerprint`), the CLI pins and a person reads out to the
/// member who adds them.
pub fn fingerprint(public_key: &str) -> String {
    use base64::Engine as _;
    use sha2::Digest as _;
    format!(
        "SHA256:{}",
        base64::engine::general_purpose::STANDARD_NO_PAD.encode(sha2::Sha256::digest(public_key.trim().as_bytes()))
    )
}

/// The exec verb.
pub const VERB: &str = "gunnar-user-key";
/// The longest request line (a hybrid wrap is ~2.3 KB, hex twice that).
pub const MAX_LINE: usize = 32 << 10;

/// One unlock method as the door carries it.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct MethodLine {
    /// Set by the server (`-` in a request).
    pub id: String,
    /// `ssh`, `security-key`, `age-pq` or `recovery-code`.
    pub kind: String,
    /// Set by the server.
    pub added_ms: u64,
    /// The person's own words.
    pub label: String,
    /// What names the method (see `gunnar_core::encryption_key`).
    pub method_ref: String,
    /// A security key's relying party id.
    pub rp_id: String,
    /// The armored age file.
    pub wrap: String,
}

fn hex(s: &str) -> String {
    if s.is_empty() {
        return "-".to_string();
    }
    s.bytes().map(|b| format!("{b:02x}")).collect()
}

fn unhex(s: &str) -> Option<String> {
    if s == "-" {
        return Some(String::new());
    }
    if s.len() % 2 != 0 {
        return None;
    }
    let bytes: Option<Vec<u8>> = (0..s.len()).step_by(2).map(|i| u8::from_str_radix(s.get(i..i + 2)?, 16).ok()).collect();
    String::from_utf8(bytes?).ok()
}

fn word(s: &str) -> String {
    if s.is_empty() {
        "-".to_string()
    } else {
        s.to_string()
    }
}

impl MethodLine {
    fn fields(&self) -> String {
        format!(
            "{} {} {} {} {} {} {}",
            word(&self.id),
            word(&self.kind),
            self.added_ms,
            hex(&self.label),
            hex(&self.method_ref),
            hex(&self.rp_id),
            hex(&self.wrap)
        )
    }

    fn from_fields(f: &[&str]) -> Option<MethodLine> {
        let [id, kind, added, label, method_ref, rp_id, wrap] = f else {
            return None;
        };
        let plain = |w: &str| (w != "-").then(|| w.to_string()).unwrap_or_default();
        Some(MethodLine {
            id: plain(id),
            kind: plain(kind),
            added_ms: added.parse().ok()?,
            label: unhex(label)?,
            method_ref: unhex(method_ref)?,
            rp_id: unhex(rp_id)?,
            wrap: unhex(wrap)?,
        })
    }
}

/// What the CLI asks.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Request {
    /// The caller's key and methods.
    Get,
    /// The first time.
    Create {
        /// `age1…` or `age1pq1…`.
        public_key: String,
        /// The first method.
        method: MethodLine,
    },
    /// One more method.
    Add(MethodLine),
    /// Delete one method by id.
    Remove(String),
}

impl Request {
    /// The exec line.
    pub fn line(&self) -> String {
        match self {
            Request::Get => VERB.to_string(),
            Request::Create { public_key, method } => format!("{VERB} create {} {}", word(public_key.trim()), method.fields()),
            Request::Add(m) => format!("{VERB} add {}", m.fields()),
            Request::Remove(id) => format!("{VERB} remove {}", word(id)),
        }
    }
}

/// Parse an exec line. `None`: not this verb. `Some(Err)`: this verb,
/// malformed (the usage sentence).
pub fn parse_request(raw: &[u8]) -> Option<Result<Request, String>> {
    let text = std::str::from_utf8(raw).ok()?;
    let mut words = text.split(' ').filter(|w| !w.is_empty());
    if words.next()? != VERB {
        return None;
    }
    let usage = || format!("the line is not `{VERB} [create <public> <method> | add <method> | remove <id>]`");
    if raw.len() > MAX_LINE || raw.iter().any(|b| *b < 0x20 || *b == 0x7f) {
        return Some(Err(usage()));
    }
    let rest: Vec<&str> = words.collect();
    let req = match rest.as_slice() {
        [] => Some(Request::Get),
        ["create", public, f @ ..] => MethodLine::from_fields(f).map(|method| Request::Create { public_key: public.to_string(), method }),
        ["add", f @ ..] => MethodLine::from_fields(f).map(Request::Add),
        ["remove", id] => Some(Request::Remove(id.to_string())),
        _ => None,
    };
    Some(req.ok_or_else(usage))
}

/// The answer: the public half (`None`: no key yet) and every method.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Answer {
    /// `age1…`, or `None` when the person has no key yet.
    pub public_key: Option<String>,
    /// Every method, oldest first.
    pub methods: Vec<MethodLine>,
}

impl Answer {
    /// The text the door prints.
    pub fn text(&self) -> String {
        let mut s = String::new();
        if let Some(p) = &self.public_key {
            s.push_str(&format!("public {}\n", p.trim()));
        }
        for m in &self.methods {
            s.push_str(&format!("method {}\n", m.fields()));
        }
        s
    }

    /// Read what the door printed; `None` for anything else.
    pub fn parse(text: &str) -> Option<Answer> {
        let mut a = Answer::default();
        for line in text.lines().map(str::trim).filter(|l| !l.is_empty()) {
            let f: Vec<&str> = line.split(' ').collect();
            match f.as_slice() {
                ["public", key] => a.public_key = Some(key.to_string()),
                ["method", rest @ ..] => a.methods.push(MethodLine::from_fields(rest)?),
                _ => return None,
            }
        }
        Some(a)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn method() -> MethodLine {
        MethodLine {
            id: String::new(),
            kind: "security-key".into(),
            added_ms: 0,
            label: "YubiKey 5C, the keyring one".into(),
            method_ref: "Y3JlZA".into(),
            rp_id: "console.mock.invalid".into(),
            wrap: "-----BEGIN AGE ENCRYPTED FILE-----\nYWdl\n-----END AGE ENCRYPTED FILE-----\n".into(),
        }
    }

    #[test]
    fn a_fingerprint_is_sha256_of_the_key_text() {
        assert_eq!(fingerprint("age1abc"), fingerprint(" age1abc\n"));
        assert!(fingerprint("age1abc").starts_with("SHA256:") && fingerprint("age1abc").len() == 7 + 43);
        assert_ne!(fingerprint("age1abc"), fingerprint("age1abd"));
    }

    #[test]
    fn every_request_round_trips_through_its_line() {
        for r in [
            Request::Get,
            Request::Create { public_key: "age1abc".into(), method: method() },
            Request::Add(method()),
            Request::Remove("0011223344556677".into()),
        ] {
            assert_eq!(parse_request(r.line().as_bytes()), Some(Ok(r.clone())), "{}", r.line());
            assert!(r.line().bytes().all(|b| (0x20..0x7f).contains(&b)), "printable: {}", r.line());
        }
        assert_eq!(parse_request(b"git-upload-pack 'a/b'"), None);
        assert!(matches!(parse_request(b"gunnar-user-key add 1 2"), Some(Err(_))));
        assert!(matches!(parse_request(b"gunnar-user-key steal bob"), Some(Err(_))));
        assert!(matches!(parse_request(format!("{VERB} remove {}", "a".repeat(MAX_LINE)).as_bytes()), Some(Err(_))));
    }

    #[test]
    fn an_answer_round_trips_and_no_key_is_no_public_line() {
        let mut m = method();
        m.id = "0011223344556677".into();
        m.added_ms = 1_761_500_000_000;
        let a = Answer { public_key: Some("age1abc".into()), methods: vec![m] };
        assert_eq!(Answer::parse(&a.text()), Some(a));
        assert_eq!(Answer::parse(""), Some(Answer::default()));
        assert_eq!(Answer::parse("error: no"), None);
    }
}