forked from
microcosm.blue/Allegedly
Server tools to backfill, tail, mirror, and verify PLC logs
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}