This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

core / knot2 / crates / knot-atproto / src / pubkeys.rs
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}