This repository has no description
8.4 kB
231 lines
1use base64::Engine;
2use base64::engine::general_purpose::STANDARD;
3use knot_types::OfferedKey;
4use serde::{Deserialize, Serialize};
5
6#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
7#[serde(transparent)]
8pub(crate) struct Cursor(String);
9
10impl Cursor {
11 #[cfg(test)]
12 pub(crate) fn new(value: impl Into<String>) -> Self {
13 Self(value.into())
14 }
15
16 pub(crate) fn as_str(&self) -> &str {
17 &self.0
18 }
19}
20
21#[derive(Debug, thiserror::Error)]
22pub enum KeyParseError {
23 #[error("public key line is missing algorithm and blob")]
24 Incomplete,
25 #[error("public key blob isn't valid base64: {0}")]
26 Base64(String),
27 #[error("public key blob is truncated")]
28 Truncated,
29 #[error("declared algorithm {declared:?} doesn't match blob's {embedded:?}")]
30 AlgorithmMismatch { declared: String, embedded: String },
31}
32
33pub fn parse_authorized_key(line: &str) -> Result<OfferedKey, KeyParseError> {
34 let parts: Vec<&str> = line.split_whitespace().take(2).collect();
35 let [algo, blob_b64] = parts.as_slice() else {
36 return Err(KeyParseError::Incomplete);
37 };
38 let blob = STANDARD
39 .decode(blob_b64)
40 .map_err(|error| KeyParseError::Base64(error.to_string()))?;
41 let embedded = embedded_algorithm(&blob)?;
42 if embedded != algo.as_bytes() {
43 return Err(KeyParseError::AlgorithmMismatch {
44 declared: (*algo).to_string(),
45 embedded: String::from_utf8_lossy(embedded).into_owned(),
46 });
47 }
48 Ok(OfferedKey::from_bytes(blob))
49}
50
51fn embedded_algorithm(blob: &[u8]) -> Result<&[u8], KeyParseError> {
52 let length = blob
53 .get(..4)
54 .map(|head| u32::from_be_bytes(head.try_into().expect("four bytes")) as usize)
55 .ok_or(KeyParseError::Truncated)?;
56 let end = length.checked_add(4).ok_or(KeyParseError::Truncated)?;
57 blob.get(4..end).ok_or(KeyParseError::Truncated)
58}
59
60#[derive(Deserialize)]
61struct ListRecords {
62 records: Vec<Envelope>,
63 #[serde(default)]
64 cursor: Option<Cursor>,
65}
66
67#[derive(Deserialize)]
68struct Envelope {
69 value: KeyRecord,
70}
71
72#[derive(Deserialize)]
73struct KeyRecord {
74 key: String,
75}
76
77pub(crate) struct PubkeyPage {
78 pub keys: Vec<OfferedKey>,
79 pub cursor: Option<Cursor>,
80}
81
82pub(crate) fn offered_page(body: &[u8], max_keys: usize) -> Result<PubkeyPage, serde_json::Error> {
83 let listing: ListRecords = serde_json::from_slice(body)?;
84 let keys = listing
85 .records
86 .iter()
87 .take(max_keys)
88 .filter_map(|envelope| parse_authorized_key(&envelope.value.key).ok())
89 .collect();
90 Ok(PubkeyPage {
91 keys,
92 cursor: listing.cursor.filter(|cursor| !cursor.as_str().is_empty()),
93 })
94}
95
96#[cfg(test)]
97mod tests {
98 use super::*;
99 use crate::test_support::*;
100
101 fn ssh_string(bytes: &[u8]) -> Vec<u8> {
102 [&(bytes.len() as u32).to_be_bytes()[..], bytes].concat()
103 }
104
105 fn wire_blob(material: &[u8]) -> OfferedKey {
106 OfferedKey::from_bytes([ssh_string(b"ssh-ed25519"), ssh_string(material)].concat())
107 }
108
109 struct KeyCase {
110 name: &'static str,
111 line: fn() -> String,
112 expect: fn(&Result<OfferedKey, KeyParseError>) -> bool,
113 }
114
115 const KEY_CASES: &[KeyCase] = &[
116 KeyCase {
117 name: "genuine ed25519 key with a trailing comment",
118 line: || ssh_line("ssh-ed25519", &[7u8; 32], "nel@oyster.cafe"),
119 expect: |r| matches!(r, Ok(key) if *key == wire_blob(&[7u8; 32])),
120 },
121 KeyCase {
122 name: "the same key material with no comment",
123 line: || ssh_line("ssh-ed25519", &[7u8; 32], ""),
124 expect: |r| matches!(r, Ok(key) if *key == wire_blob(&[7u8; 32])),
125 },
126 KeyCase {
127 name: "bare algorithm with no blob",
128 line: || "ssh-ed25519".to_string(),
129 expect: |r| matches!(r, Err(KeyParseError::Incomplete)),
130 },
131 KeyCase {
132 name: "whitespace-only line",
133 line: || " ".to_string(),
134 expect: |r| matches!(r, Err(KeyParseError::Incomplete)),
135 },
136 KeyCase {
137 name: "blob that isn't base64",
138 line: || "ssh-ed25519 not-base64!!!".to_string(),
139 expect: |r| matches!(r, Err(KeyParseError::Base64(_))),
140 },
141 KeyCase {
142 name: "declared algorithm lying about the blob",
143 line: || {
144 let blob = [ssh_string(b"ssh-ed25519"), ssh_string(&[1u8; 32])].concat();
145 format!("ssh-rsa {}", STANDARD.encode(blob))
146 },
147 expect: |r| matches!(r, Err(KeyParseError::AlgorithmMismatch { .. })),
148 },
149 KeyCase {
150 name: "authorized_keys options prefix",
151 line: || {
152 let blob = [ssh_string(b"ssh-ed25519"), ssh_string(&[7u8; 32])].concat();
153 format!(
154 "command=\"true\",no-pty ssh-ed25519 {} nel@oyster.cafe",
155 STANDARD.encode(blob)
156 )
157 },
158 expect: |r| r.is_err(),
159 },
160 KeyCase {
161 name: "overlong length prefix",
162 line: || {
163 let lying = [&u32::MAX.to_be_bytes()[..], b"short"].concat();
164 format!("ssh-ed25519 {}", STANDARD.encode(lying))
165 },
166 expect: |r| matches!(r, Err(KeyParseError::Truncated)),
167 },
168 ];
169
170 #[test]
171 fn parse_authorized_key_accepts_genuine_lines_and_rejects_malformed_ones() {
172 KEY_CASES.iter().for_each(|case| {
173 let result = parse_authorized_key(&(case.line)());
174 assert!(
175 (case.expect)(&result),
176 "case {:?} got {result:?}",
177 case.name
178 );
179 });
180 }
181
182 #[test]
183 fn list_records_yields_every_well_formed_key_and_skips_the_rest() {
184 let good_one = ssh_line("ssh-ed25519", &[1u8; 32], "one");
185 let good_two = ssh_line("ssh-ed25519", &[2u8; 32], "two");
186 let body = serde_json::json!({
187 "records": [
188 { "uri": "at://did:plc:squid/sh.tangled.publicKey/a", "value": { "$type": "sh.tangled.publicKey", "key": good_one, "name": "laptop", "createdAt": "2026-06-08T00:00:00Z" } },
189 { "uri": "at://did:plc:squid/sh.tangled.publicKey/b", "value": { "$type": "sh.tangled.publicKey", "key": "garbage line", "name": "broken", "createdAt": "2026-06-08T00:00:00Z" } },
190 { "uri": "at://did:plc:squid/sh.tangled.publicKey/c", "value": { "$type": "sh.tangled.publicKey", "key": good_two, "name": "desktop", "createdAt": "2026-06-08T00:00:00Z" } }
191 ],
192 "cursor": "c"
193 });
194 let page = offered_page(serde_json::to_vec(&body).unwrap().as_slice(), 100).unwrap();
195 assert_eq!(page.keys.len(), 2);
196 assert_eq!(page.keys[0], parse_authorized_key(&good_one).unwrap());
197 assert_eq!(page.keys[1], parse_authorized_key(&good_two).unwrap());
198 assert_eq!(page.cursor.as_ref().map(Cursor::as_str), Some("c"));
199 }
200
201 #[test]
202 fn a_cursor_serializes_as_a_plain_json_string() {
203 let cursor = Cursor::new("page-token");
204 assert_eq!(cursor.as_str(), "page-token");
205 assert_eq!(serde_json::to_string(&cursor).unwrap(), "\"page-token\"");
206 let parsed: Cursor = serde_json::from_str("\"page-token\"").unwrap();
207 assert_eq!(parsed, cursor);
208 }
209
210 #[test]
211 fn a_page_is_bounded_by_the_requested_record_limit() {
212 let records: Vec<_> = (0u32..50)
213 .map(|seed| {
214 let mut material = [0u8; 32];
215 material[..4].copy_from_slice(&seed.to_be_bytes());
216 let line = ssh_line("ssh-ed25519", &material, "k");
217 serde_json::json!({
218 "uri": "at://did:plc:squid/sh.tangled.publicKey/x",
219 "value": { "$type": "sh.tangled.publicKey", "key": line, "name": "k", "createdAt": "2026-06-08T00:00:00Z" }
220 })
221 })
222 .collect();
223 let body = serde_json::json!({ "records": records });
224 let page = offered_page(serde_json::to_vec(&body).unwrap().as_slice(), 10).unwrap();
225 assert_eq!(
226 page.keys.len(),
227 10,
228 "page yields at most requested record limit, no matter how many the PDS returns"
229 );
230 }
231}