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
30 .encode(sha2::Sha256::digest(public_key.trim().as_bytes()))
31 )
32}
33
34pub const VERB: &str = "gunnar-user-key";
36pub const MAX_LINE: usize = 32 << 10;
38
39#[derive(Debug, Clone, PartialEq, Eq, Default)]
41pub struct MethodLine {
42 pub id: String,
44 pub kind: String,
46 pub added_ms: u64,
48 pub label: String,
50 pub method_ref: String,
52 pub rp_id: String,
54 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#[derive(Debug, Clone, PartialEq, Eq)]
126pub enum Request {
127 Get,
129 Create {
131 public_key: String,
133 method: MethodLine,
135 },
136 Add(MethodLine),
138 Remove(String),
140}
141
142impl Request {
143 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
158pub 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#[derive(Debug, Clone, PartialEq, Eq, Default)]
188pub struct Answer {
189 pub public_key: Option<String>,
191 pub methods: Vec<MethodLine>,
193}
194
195impl Answer {
196 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 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}