1pub 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.encode(sha2::Sha256::digest(public_key.trim().as_bytes()))
30 )
31}
32
33pub const VERB: &str = "gunnar-user-key";
35pub const MAX_LINE: usize = 32 << 10;
37
38#[derive(Debug, Clone, PartialEq, Eq, Default)]
40pub struct MethodLine {
41 pub id: String,
43 pub kind: String,
45 pub added_ms: u64,
47 pub label: String,
49 pub method_ref: String,
51 pub rp_id: String,
53 pub wrap: String,
55}
56
57fn hex(s: &str) -> String {
58 if s.is_empty() {
59 return "-".to_string();
60 }
61 s.bytes().map(|b| format!("{b:02x}")).collect()
62}
63
64fn unhex(s: &str) -> Option<String> {
65 if s == "-" {
66 return Some(String::new());
67 }
68 if s.len() % 2 != 0 {
69 return None;
70 }
71 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();
72 String::from_utf8(bytes?).ok()
73}
74
75fn word(s: &str) -> String {
76 if s.is_empty() {
77 "-".to_string()
78 } else {
79 s.to_string()
80 }
81}
82
83impl MethodLine {
84 fn fields(&self) -> String {
85 format!(
86 "{} {} {} {} {} {} {}",
87 word(&self.id),
88 word(&self.kind),
89 self.added_ms,
90 hex(&self.label),
91 hex(&self.method_ref),
92 hex(&self.rp_id),
93 hex(&self.wrap)
94 )
95 }
96
97 fn from_fields(f: &[&str]) -> Option<MethodLine> {
98 let [id, kind, added, label, method_ref, rp_id, wrap] = f else {
99 return None;
100 };
101 let plain = |w: &str| (w != "-").then(|| w.to_string()).unwrap_or_default();
102 Some(MethodLine {
103 id: plain(id),
104 kind: plain(kind),
105 added_ms: added.parse().ok()?,
106 label: unhex(label)?,
107 method_ref: unhex(method_ref)?,
108 rp_id: unhex(rp_id)?,
109 wrap: unhex(wrap)?,
110 })
111 }
112}
113
114#[derive(Debug, Clone, PartialEq, Eq)]
116pub enum Request {
117 Get,
119 Create {
121 public_key: String,
123 method: MethodLine,
125 },
126 Add(MethodLine),
128 Remove(String),
130}
131
132impl Request {
133 pub fn line(&self) -> String {
135 match self {
136 Request::Get => VERB.to_string(),
137 Request::Create { public_key, method } => format!("{VERB} create {} {}", word(public_key.trim()), method.fields()),
138 Request::Add(m) => format!("{VERB} add {}", m.fields()),
139 Request::Remove(id) => format!("{VERB} remove {}", word(id)),
140 }
141 }
142}
143
144pub fn parse_request(raw: &[u8]) -> Option<Result<Request, String>> {
147 let text = std::str::from_utf8(raw).ok()?;
148 let mut words = text.split(' ').filter(|w| !w.is_empty());
149 if words.next()? != VERB {
150 return None;
151 }
152 let usage = || format!("the line is not `{VERB} [create <public> <method> | add <method> | remove <id>]`");
153 if raw.len() > MAX_LINE || raw.iter().any(|b| *b < 0x20 || *b == 0x7f) {
154 return Some(Err(usage()));
155 }
156 let rest: Vec<&str> = words.collect();
157 let req = match rest.as_slice() {
158 [] => Some(Request::Get),
159 ["create", public, f @ ..] => MethodLine::from_fields(f).map(|method| Request::Create { public_key: public.to_string(), method }),
160 ["add", f @ ..] => MethodLine::from_fields(f).map(Request::Add),
161 ["remove", id] => Some(Request::Remove(id.to_string())),
162 _ => None,
163 };
164 Some(req.ok_or_else(usage))
165}
166
167#[derive(Debug, Clone, PartialEq, Eq, Default)]
169pub struct Answer {
170 pub public_key: Option<String>,
172 pub methods: Vec<MethodLine>,
174}
175
176impl Answer {
177 pub fn text(&self) -> String {
179 let mut s = String::new();
180 if let Some(p) = &self.public_key {
181 s.push_str(&format!("public {}\n", p.trim()));
182 }
183 for m in &self.methods {
184 s.push_str(&format!("method {}\n", m.fields()));
185 }
186 s
187 }
188
189 pub fn parse(text: &str) -> Option<Answer> {
191 let mut a = Answer::default();
192 for line in text.lines().map(str::trim).filter(|l| !l.is_empty()) {
193 let f: Vec<&str> = line.split(' ').collect();
194 match f.as_slice() {
195 ["public", key] => a.public_key = Some(key.to_string()),
196 ["method", rest @ ..] => a.methods.push(MethodLine::from_fields(rest)?),
197 _ => return None,
198 }
199 }
200 Some(a)
201 }
202}
203
204#[cfg(test)]
205mod tests {
206 use super::*;
207
208 fn method() -> MethodLine {
209 MethodLine {
210 id: String::new(),
211 kind: "security-key".into(),
212 added_ms: 0,
213 label: "YubiKey 5C, the keyring one".into(),
214 method_ref: "Y3JlZA".into(),
215 rp_id: "console.mock.invalid".into(),
216 wrap: "-----BEGIN AGE ENCRYPTED FILE-----\nYWdl\n-----END AGE ENCRYPTED FILE-----\n".into(),
217 }
218 }
219
220 #[test]
221 fn a_fingerprint_is_sha256_of_the_key_text() {
222 assert_eq!(fingerprint("age1abc"), fingerprint(" age1abc\n"));
223 assert!(fingerprint("age1abc").starts_with("SHA256:") && fingerprint("age1abc").len() == 7 + 43);
224 assert_ne!(fingerprint("age1abc"), fingerprint("age1abd"));
225 }
226
227 #[test]
228 fn every_request_round_trips_through_its_line() {
229 for r in [
230 Request::Get,
231 Request::Create { public_key: "age1abc".into(), method: method() },
232 Request::Add(method()),
233 Request::Remove("0011223344556677".into()),
234 ] {
235 assert_eq!(parse_request(r.line().as_bytes()), Some(Ok(r.clone())), "{}", r.line());
236 assert!(r.line().bytes().all(|b| (0x20..0x7f).contains(&b)), "printable: {}", r.line());
237 }
238 assert_eq!(parse_request(b"git-upload-pack 'a/b'"), None);
239 assert!(matches!(parse_request(b"gunnar-user-key add 1 2"), Some(Err(_))));
240 assert!(matches!(parse_request(b"gunnar-user-key steal bob"), Some(Err(_))));
241 assert!(matches!(parse_request(format!("{VERB} remove {}", "a".repeat(MAX_LINE)).as_bytes()), Some(Err(_))));
242 }
243
244 #[test]
245 fn an_answer_round_trips_and_no_key_is_no_public_line() {
246 let mut m = method();
247 m.id = "0011223344556677".into();
248 m.added_ms = 1_761_500_000_000;
249 let a = Answer { public_key: Some("age1abc".into()), methods: vec![m] };
250 assert_eq!(Answer::parse(&a.text()), Some(a));
251 assert_eq!(Answer::parse(""), Some(Answer::default()));
252 assert_eq!(Answer::parse("error: no"), None);
253 }
254}