Server tools to backfill, tail, mirror, and verify PLC logs
at main 404 lines 13 kB view raw
1use serde::{Deserialize, Serialize}; 2use serde_json::Value; 3use std::borrow::Cow; 4use std::collections::BTreeMap; 5 6pub type CowStr<'a> = Cow<'a, str>; 7 8#[derive(Debug, Clone, Serialize, Deserialize)] 9pub struct Service<'a> { 10 pub r#type: CowStr<'a>, 11 pub endpoint: CowStr<'a>, 12} 13 14#[derive(Debug, Clone, Serialize, Deserialize)] 15#[serde(rename_all = "camelCase")] 16pub struct DocumentData<'a> { 17 pub did: CowStr<'a>, 18 pub rotation_keys: Vec<CowStr<'a>>, 19 pub verification_methods: BTreeMap<CowStr<'a>, CowStr<'a>>, 20 pub also_known_as: Vec<CowStr<'a>>, 21 pub services: BTreeMap<CowStr<'a>, Service<'a>>, 22} 23 24#[derive(Debug, Clone, Serialize)] 25#[serde(rename_all = "camelCase")] 26pub struct DidDocument<'a> { 27 #[serde(rename = "@context")] 28 pub context: Vec<CowStr<'a>>, 29 pub id: CowStr<'a>, 30 pub also_known_as: Vec<CowStr<'a>>, 31 pub verification_method: Vec<VerificationMethod<'a>>, 32 pub service: Vec<DocService<'a>>, 33} 34 35#[derive(Debug, Clone, Serialize)] 36#[serde(rename_all = "camelCase")] 37pub struct VerificationMethod<'a> { 38 pub id: CowStr<'a>, 39 pub r#type: CowStr<'a>, 40 pub controller: CowStr<'a>, 41 pub public_key_multibase: CowStr<'a>, 42} 43 44#[derive(Debug, Clone, Serialize)] 45#[serde(rename_all = "camelCase")] 46pub struct DocService<'a> { 47 pub id: CowStr<'a>, 48 pub r#type: CowStr<'a>, 49 pub service_endpoint: CowStr<'a>, 50} 51 52const P256_PREFIX: &str = "zDn"; 53const SECP256K1_PREFIX: &str = "zQ3"; 54 55fn key_context(multibase: &str) -> Option<&'static str> { 56 if multibase.starts_with(P256_PREFIX) { 57 Some("https://w3id.org/security/suites/ecdsa-2019/v1") 58 } else if multibase.starts_with(SECP256K1_PREFIX) { 59 Some("https://w3id.org/security/suites/secp256k1-2019/v1") 60 } else { 61 None 62 } 63} 64 65pub fn format_did_doc<'a>(data: &'a DocumentData<'a>) -> DidDocument<'a> { 66 let mut context = vec![ 67 "https://www.w3.org/ns/did/v1".into(), 68 "https://w3id.org/security/multikey/v1".into(), 69 ]; 70 71 let verification_method = data 72 .verification_methods 73 .iter() 74 .map(|(keyid, did_key)| { 75 let multibase: CowStr = did_key.strip_prefix("did:key:").unwrap_or(did_key).into(); 76 77 if let Some(ctx) = key_context(&multibase) { 78 if !context.iter().any(|c| c == ctx) { 79 context.push(ctx.into()); 80 } 81 } 82 VerificationMethod { 83 id: format!("{}#{keyid}", data.did).into(), 84 r#type: "Multikey".into(), 85 controller: data.did.clone(), 86 public_key_multibase: multibase, 87 } 88 }) 89 .collect(); 90 91 let service = data 92 .services 93 .iter() 94 .map(|(service_id, svc)| DocService { 95 id: format!("#{service_id}").into(), 96 r#type: svc.r#type.clone(), 97 service_endpoint: svc.endpoint.clone(), 98 }) 99 .collect(); 100 101 DidDocument { 102 context, 103 id: data.did.clone(), 104 also_known_as: data.also_known_as.clone(), 105 verification_method, 106 service, 107 } 108} 109 110fn ensure_atproto_prefix(s: &str) -> CowStr<'_> { 111 if s.starts_with("at://") { 112 return s.into(); 113 } 114 let stripped = s 115 .strip_prefix("http://") 116 .or_else(|| s.strip_prefix("https://")) 117 .unwrap_or(s); 118 format!("at://{stripped}").into() 119} 120 121fn ensure_http_prefix(s: &str) -> CowStr<'_> { 122 if s.starts_with("http://") || s.starts_with("https://") { 123 return s.into(); 124 } 125 format!("https://{s}").into() 126} 127 128/// extract DocumentData from a single operation json blob. 129/// returns None for tombstones. 130pub fn op_to_doc_data<'a>(did: &'a str, op: &'a Value) -> Option<DocumentData<'a>> { 131 // TODO: this shouldnt just short circuit to None, we should provide better information about whats missing in an error 132 let obj = op.as_object()?; 133 let op_type = obj.get("type")?.as_str()?; 134 135 match op_type { 136 "plc_tombstone" => None, 137 "create" => { 138 let signing_key = obj.get("signingKey")?.as_str()?; 139 let recovery_key = obj.get("recoveryKey")?.as_str()?; 140 let handle = obj.get("handle")?.as_str()?; 141 let service = obj.get("service")?.as_str()?; 142 143 let mut verification_methods = BTreeMap::new(); 144 verification_methods.insert("atproto".into(), signing_key.into()); 145 146 let mut services = BTreeMap::new(); 147 services.insert( 148 "atproto_pds".into(), 149 Service { 150 r#type: "AtprotoPersonalDataServer".into(), 151 endpoint: ensure_http_prefix(service), 152 }, 153 ); 154 155 Some(DocumentData { 156 did: Cow::Borrowed(did), 157 rotation_keys: vec![Cow::Borrowed(recovery_key), Cow::Borrowed(signing_key)], 158 verification_methods, 159 also_known_as: vec![ensure_atproto_prefix(handle)], 160 services, 161 }) 162 } 163 "plc_operation" => { 164 let rotation_keys = obj 165 .get("rotationKeys")? 166 .as_array()? 167 .iter() 168 .filter_map(|v| v.as_str().map(Cow::Borrowed)) 169 .collect(); 170 171 let verification_methods = obj 172 .get("verificationMethods")? 173 .as_object()? 174 .iter() 175 .filter_map(|(k, v)| Some((k.as_str().into(), v.as_str()?.into()))) 176 .collect(); 177 178 let also_known_as = obj 179 .get("alsoKnownAs")? 180 .as_array()? 181 .iter() 182 .filter_map(|v| v.as_str().map(Cow::Borrowed)) 183 .collect(); 184 185 let services = obj 186 .get("services")? 187 .as_object()? 188 .iter() 189 .filter_map(|(k, v)| { 190 let svc: Service = Service::deserialize(v).ok()?; 191 Some((k.as_str().into(), svc)) 192 }) 193 .collect(); 194 195 Some(DocumentData { 196 did: did.into(), 197 rotation_keys, 198 verification_methods, 199 also_known_as, 200 services, 201 }) 202 } 203 _ => None, 204 } 205} 206 207/// apply a sequence of operation JSON blobs and return the current document data. 208/// returns None if the DID is tombstoned (last op is a tombstone). 209pub fn apply_op_log<'a>( 210 did: &'a str, 211 ops: impl IntoIterator<Item = &'a Value>, 212) -> Option<DocumentData<'a>> { 213 // TODO: we don't verify signature chain, we should do that... 214 ops.into_iter() 215 .last() 216 .and_then(|op| op_to_doc_data(did, op)) 217} 218 219#[cfg(test)] 220mod tests { 221 use super::*; 222 223 #[test] 224 fn normalize_legacy_create() { 225 let op = serde_json::json!({ 226 "type": "create", 227 "signingKey": "did:key:zDnaeSigningKey", 228 "recoveryKey": "did:key:zQ3shRecoveryKey", 229 "handle": "alice.bsky.social", 230 "service": "pds.example.com", 231 "prev": null, 232 "sig": "abc" 233 }); 234 235 let data = op_to_doc_data("did:plc:test", &op).unwrap(); 236 assert_eq!(data.rotation_keys.len(), 2); 237 assert_eq!(data.rotation_keys[0], "did:key:zQ3shRecoveryKey"); 238 assert_eq!(data.rotation_keys[1], "did:key:zDnaeSigningKey"); 239 assert_eq!( 240 data.verification_methods.get("atproto").unwrap(), 241 "did:key:zDnaeSigningKey" 242 ); 243 assert_eq!(data.also_known_as, vec!["at://alice.bsky.social"]); 244 let pds = data.services.get("atproto_pds").unwrap(); 245 assert_eq!(pds.endpoint, "https://pds.example.com"); 246 } 247 248 #[test] 249 fn format_doc_p256_context() { 250 let data = DocumentData { 251 did: "did:plc:test123".into(), 252 rotation_keys: vec!["did:key:zDnaeXYZ".into()], 253 verification_methods: { 254 let mut m = BTreeMap::new(); 255 m.insert("atproto".into(), "did:key:zDnaeXYZ".into()); 256 m 257 }, 258 also_known_as: vec!["at://alice.test".into()], 259 services: { 260 let mut m = BTreeMap::new(); 261 m.insert( 262 "atproto_pds".into(), 263 Service { 264 r#type: "AtprotoPersonalDataServer".into(), 265 endpoint: "https://pds.test".into(), 266 }, 267 ); 268 m 269 }, 270 }; 271 272 let doc = format_did_doc(&data); 273 assert_eq!(doc.context.len(), 3); 274 assert!( 275 doc.context 276 .iter() 277 .any(|c| c == "https://w3id.org/security/suites/ecdsa-2019/v1") 278 ); 279 assert_eq!(doc.verification_method[0].public_key_multibase, "zDnaeXYZ"); 280 assert_eq!(doc.verification_method[0].id, "did:plc:test123#atproto"); 281 } 282 283 #[test] 284 fn tombstone_returns_none() { 285 let op = serde_json::json!({ 286 "type": "plc_tombstone", 287 "prev": "bafyabc", 288 "sig": "xyz" 289 }); 290 assert!(op_to_doc_data("did:plc:test", &op).is_none()); 291 } 292 293 #[test] 294 fn apply_log_with_tombstone() { 295 let create = serde_json::json!({ 296 "type": "plc_operation", 297 "rotationKeys": ["did:key:zQ3shKey1"], 298 "verificationMethods": {"atproto": "did:key:zDnaeKey1"}, 299 "alsoKnownAs": ["at://alice.test"], 300 "services": { 301 "atproto_pds": {"type": "AtprotoPersonalDataServer", "service_endpoint": "https://pds.test"} 302 }, 303 "prev": null, 304 "sig": "abc" 305 }); 306 let tombstone = serde_json::json!({ 307 "type": "plc_tombstone", 308 "prev": "bafyabc", 309 "sig": "xyz" 310 }); 311 312 let ops = vec![create.clone()]; 313 let result = apply_op_log("did:plc:test", &ops); 314 assert!(result.is_some()); 315 316 let ops = vec![create, tombstone]; 317 let result = apply_op_log("did:plc:test", &ops); 318 assert!(result.is_none()); 319 } 320 321 fn load_fixture(name: &str) -> (String, Vec<Value>) { 322 let path = format!("tests/fixtures/{name}"); 323 let data = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{path}: {e}")); 324 let entries: Vec<Value> = serde_json::from_str(&data).unwrap(); 325 let did = entries[0]["did"].as_str().unwrap().to_string(); 326 let ops: Vec<Value> = entries 327 .iter() 328 .filter(|e| !e["nullified"].as_bool().unwrap_or(false)) 329 .map(|e| e["operation"].clone()) 330 .collect(); 331 (did, ops) 332 } 333 334 #[test] 335 fn interop_legacy_dholms() { 336 let (did, ops) = load_fixture("log_legacy_dholms.json"); 337 assert_eq!(did, "did:plc:yk4dd2qkboz2yv6tpubpc6co"); 338 339 let data = apply_op_log(&did, &ops).expect("should reconstruct"); 340 assert_eq!(data.did, did); 341 assert_eq!(data.also_known_as, vec!["at://dholms.xyz"]); 342 assert_eq!( 343 data.services.get("atproto_pds").unwrap().endpoint, 344 "https://bsky.social" 345 ); 346 assert_eq!( 347 data.verification_methods.get("atproto").unwrap(), 348 "did:key:zQ3shXjHeiBuRCKmM36cuYnm7YEMzhGnCmCyW92sRJ9pribSF" 349 ); 350 351 let doc = format_did_doc(&data); 352 assert_eq!(doc.id, did); 353 assert!( 354 doc.context 355 .iter() 356 .any(|c| c == "https://w3id.org/security/suites/secp256k1-2019/v1") 357 ); 358 } 359 360 #[test] 361 fn interop_bskyapp() { 362 let (did, ops) = load_fixture("log_bskyapp.json"); 363 assert_eq!(did, "did:plc:z72i7hdynmk6r22z27h6tvur"); 364 365 let data = apply_op_log(&did, &ops).expect("should reconstruct"); 366 println!("{:?}", data); 367 assert_eq!(data.also_known_as, vec!["at://bsky.app"]); 368 assert_eq!( 369 data.verification_methods.get("atproto").unwrap(), 370 "did:key:zQ3shXjHeiBuRCKmM36cuYnm7YEMzhGnCmCyW92sRJ9pribSF" 371 ); 372 assert_eq!( 373 data.services.get("atproto_pds").unwrap().endpoint, 374 "https://bsky.social" 375 ); 376 } 377 378 #[test] 379 fn interop_tombstone() { 380 let path = "tests/fixtures/log_tombstone.json"; 381 let data = std::fs::read_to_string(path).unwrap(); 382 let entries: Vec<Value> = serde_json::from_str(&data).unwrap(); 383 let did = entries[0]["did"].as_str().unwrap(); 384 let ops: Vec<Value> = entries.iter().map(|e| e["operation"].clone()).collect(); 385 386 assert_eq!(did, "did:plc:6adr3q2labdllanslzhqkqd3"); 387 let result = apply_op_log(did, &ops); 388 assert!(result.is_none(), "tombstoned DID should return None"); 389 } 390 391 #[test] 392 fn interop_nullification() { 393 let (did, ops) = load_fixture("log_nullification.json"); 394 assert_eq!(did, "did:plc:2s2mvm52ttz6r4hocmrq7x27"); 395 396 let data = apply_op_log(&did, &ops).expect("should reconstruct"); 397 assert_eq!(data.did, did); 398 assert_eq!(data.rotation_keys.len(), 2); 399 assert_eq!( 400 data.rotation_keys[0], 401 "did:key:zQ3shwPdax6jKMbhtzbueGwSjc7RnjsmPcNB1vQUpbKUCN1t1" 402 ); 403 } 404}