Skip to main content

gunnar_crypt/
door.rs

1//! **The forge door for a person's encryption key** (`gunnar-user-key`,
2//! CRYPT-YUBI 2026-10-26): what the CLI asks over the SSH door git already
3//! signs in with, and what the server answers. Pure text, both sides.
4//!
5//! ```text
6//! gunnar-user-key                              your key: the answer below
7//! gunnar-user-key create <public> <method>     the first time
8//! gunnar-user-key add <method>                 one more unlock method
9//! gunnar-user-key remove <id>                  delete one method's wrap
10//!
11//! answer:  public <age1…>                      (absent: no key yet)
12//!          method <id> <kind> <added-ms> <label> <ref> <rp-id> <wrap>
13//! ```
14//!
15//! `<method>` is the `method` line's seven fields. Free text (label, ref, rp
16//! id, the armored wrap) travels hex-encoded, `-` for empty, so a line is
17//! printable ASCII without quoting. The caller is the session's own
18//! principal: there is no name to give, so the verb is no directory.
19
20/// **The fingerprint of an encryption public key** (`SHA256:` and unpadded
21/// base64 of SHA-256 over the key's text): the one the server shows
22/// (`RecipientKey.fingerprint`), the CLI pins and a person reads out to the
23/// member who adds them.
24pub fn fingerprint(public_key: &str) -> String {
25    use base64::Engine as _;
26    use sha2::Digest as _;
27    format!(
28        "SHA256:{}",
29        base64::engine::general_purpose::STANDARD_NO_PAD
30            .encode(sha2::Sha256::digest(public_key.trim().as_bytes()))
31    )
32}
33
34/// The exec verb.
35pub const VERB: &str = "gunnar-user-key";
36/// The longest request line (a hybrid wrap is ~2.3 KB, hex twice that).
37pub const MAX_LINE: usize = 32 << 10;
38
39/// One unlock method as the door carries it.
40#[derive(Debug, Clone, PartialEq, Eq, Default)]
41pub struct MethodLine {
42    /// Set by the server (`-` in a request).
43    pub id: String,
44    /// `ssh`, `security-key`, `age-pq` or `recovery-code`.
45    pub kind: String,
46    /// Set by the server.
47    pub added_ms: u64,
48    /// The person's own words.
49    pub label: String,
50    /// What names the method (see `gunnar_core::encryption_key`).
51    pub method_ref: String,
52    /// A security key's relying party id.
53    pub rp_id: String,
54    /// The armored age file.
55    pub wrap: String,
56}
57
58fn hex(s: &str) -> String {
59    if s.is_empty() {
60        return "-".to_string();
61    }
62    s.bytes().map(|b| format!("{b:02x}")).collect()
63}
64
65fn unhex(s: &str) -> Option<String> {
66    if s == "-" {
67        return Some(String::new());
68    }
69    if s.len() % 2 != 0 {
70        return None;
71    }
72    let bytes: Option<Vec<u8>> = (0..s.len())
73        .step_by(2)
74        .map(|i| u8::from_str_radix(s.get(i..i + 2)?, 16).ok())
75        .collect();
76    String::from_utf8(bytes?).ok()
77}
78
79fn word(s: &str) -> String {
80    if s.is_empty() {
81        "-".to_string()
82    } else {
83        s.to_string()
84    }
85}
86
87impl MethodLine {
88    fn fields(&self) -> String {
89        format!(
90            "{} {} {} {} {} {} {}",
91            word(&self.id),
92            word(&self.kind),
93            self.added_ms,
94            hex(&self.label),
95            hex(&self.method_ref),
96            hex(&self.rp_id),
97            hex(&self.wrap)
98        )
99    }
100
101    fn from_fields(f: &[&str]) -> Option<MethodLine> {
102        let [id, kind, added, label, method_ref, rp_id, wrap] = f else {
103            return None;
104        };
105        let plain = |w: &str| {
106            if w == "-" {
107                String::new()
108            } else {
109                w.to_string()
110            }
111        };
112        Some(MethodLine {
113            id: plain(id),
114            kind: plain(kind),
115            added_ms: added.parse().ok()?,
116            label: unhex(label)?,
117            method_ref: unhex(method_ref)?,
118            rp_id: unhex(rp_id)?,
119            wrap: unhex(wrap)?,
120        })
121    }
122}
123
124/// What the CLI asks.
125#[derive(Debug, Clone, PartialEq, Eq)]
126pub enum Request {
127    /// The caller's key and methods.
128    Get,
129    /// The first time.
130    Create {
131        /// `age1…` or `age1pq1…`.
132        public_key: String,
133        /// The first method.
134        method: MethodLine,
135    },
136    /// One more method.
137    Add(MethodLine),
138    /// Delete one method by id.
139    Remove(String),
140}
141
142impl Request {
143    /// The exec line.
144    pub fn line(&self) -> String {
145        match self {
146            Request::Get => VERB.to_string(),
147            Request::Create { public_key, method } => format!(
148                "{VERB} create {} {}",
149                word(public_key.trim()),
150                method.fields()
151            ),
152            Request::Add(m) => format!("{VERB} add {}", m.fields()),
153            Request::Remove(id) => format!("{VERB} remove {}", word(id)),
154        }
155    }
156}
157
158/// Parse an exec line. `None`: not this verb. `Some(Err)`: this verb,
159/// malformed (the usage sentence).
160pub fn parse_request(raw: &[u8]) -> Option<Result<Request, String>> {
161    let text = std::str::from_utf8(raw).ok()?;
162    let mut words = text.split(' ').filter(|w| !w.is_empty());
163    if words.next()? != VERB {
164        return None;
165    }
166    let usage = || {
167        format!("the line is not `{VERB} [create <public> <method> | add <method> | remove <id>]`")
168    };
169    if raw.len() > MAX_LINE || raw.iter().any(|b| *b < 0x20 || *b == 0x7f) {
170        return Some(Err(usage()));
171    }
172    let rest: Vec<&str> = words.collect();
173    let req = match rest.as_slice() {
174        [] => Some(Request::Get),
175        ["create", public, f @ ..] => MethodLine::from_fields(f).map(|method| Request::Create {
176            public_key: public.to_string(),
177            method,
178        }),
179        ["add", f @ ..] => MethodLine::from_fields(f).map(Request::Add),
180        ["remove", id] => Some(Request::Remove(id.to_string())),
181        _ => None,
182    };
183    Some(req.ok_or_else(usage))
184}
185
186/// The answer: the public half (`None`: no key yet) and every method.
187#[derive(Debug, Clone, PartialEq, Eq, Default)]
188pub struct Answer {
189    /// `age1…`, or `None` when the person has no key yet.
190    pub public_key: Option<String>,
191    /// Every method, oldest first.
192    pub methods: Vec<MethodLine>,
193}
194
195impl Answer {
196    /// The text the door prints.
197    pub fn text(&self) -> String {
198        let mut s = String::new();
199        if let Some(p) = &self.public_key {
200            s.push_str(&format!("public {}\n", p.trim()));
201        }
202        for m in &self.methods {
203            s.push_str(&format!("method {}\n", m.fields()));
204        }
205        s
206    }
207
208    /// Read what the door printed; `None` for anything else.
209    pub fn parse(text: &str) -> Option<Answer> {
210        let mut a = Answer::default();
211        for line in text.lines().map(str::trim).filter(|l| !l.is_empty()) {
212            let f: Vec<&str> = line.split(' ').collect();
213            match f.as_slice() {
214                ["public", key] => a.public_key = Some(key.to_string()),
215                ["method", rest @ ..] => a.methods.push(MethodLine::from_fields(rest)?),
216                _ => return None,
217            }
218        }
219        Some(a)
220    }
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226
227    fn method() -> MethodLine {
228        MethodLine {
229            id: String::new(),
230            kind: "security-key".into(),
231            added_ms: 0,
232            label: "YubiKey 5C, the keyring one".into(),
233            method_ref: "Y3JlZA".into(),
234            rp_id: "console.mock.invalid".into(),
235            wrap: "-----BEGIN AGE ENCRYPTED FILE-----\nYWdl\n-----END AGE ENCRYPTED FILE-----\n"
236                .into(),
237        }
238    }
239
240    #[test]
241    fn a_fingerprint_is_sha256_of_the_key_text() {
242        assert_eq!(fingerprint("age1abc"), fingerprint(" age1abc\n"));
243        assert!(
244            fingerprint("age1abc").starts_with("SHA256:") && fingerprint("age1abc").len() == 7 + 43
245        );
246        assert_ne!(fingerprint("age1abc"), fingerprint("age1abd"));
247    }
248
249    #[test]
250    fn every_request_round_trips_through_its_line() {
251        for r in [
252            Request::Get,
253            Request::Create {
254                public_key: "age1abc".into(),
255                method: method(),
256            },
257            Request::Add(method()),
258            Request::Remove("0011223344556677".into()),
259        ] {
260            assert_eq!(
261                parse_request(r.line().as_bytes()),
262                Some(Ok(r.clone())),
263                "{}",
264                r.line()
265            );
266            assert!(
267                r.line().bytes().all(|b| (0x20..0x7f).contains(&b)),
268                "printable: {}",
269                r.line()
270            );
271        }
272        assert_eq!(parse_request(b"git-upload-pack 'a/b'"), None);
273        assert!(matches!(
274            parse_request(b"gunnar-user-key add 1 2"),
275            Some(Err(_))
276        ));
277        assert!(matches!(
278            parse_request(b"gunnar-user-key steal bob"),
279            Some(Err(_))
280        ));
281        assert!(matches!(
282            parse_request(format!("{VERB} remove {}", "a".repeat(MAX_LINE)).as_bytes()),
283            Some(Err(_))
284        ));
285    }
286
287    #[test]
288    fn an_answer_round_trips_and_no_key_is_no_public_line() {
289        let mut m = method();
290        m.id = "0011223344556677".into();
291        m.added_ms = 1_761_500_000_000;
292        let a = Answer {
293            public_key: Some("age1abc".into()),
294            methods: vec![m],
295        };
296        assert_eq!(Answer::parse(&a.text()), Some(a));
297        assert_eq!(Answer::parse(""), Some(Answer::default()));
298        assert_eq!(Answer::parse("error: no"), None);
299    }
300}