forked from
smokesignal.events/atproto-plc
Rust and WASM did-method-plc tools and structures
1//! PLC Directory Audit Log Validator
2//!
3//! This binary fetches DID audit logs from plc.directory and validates
4//! each operation cryptographically to ensure the chain is valid.
5
6use atproto_plc::{Did, Operation, OperationChainValidator, PlcState};
7use clap::Parser;
8use reqwest::blocking::Client;
9use serde::Deserialize;
10use std::process;
11
12/// Command-line arguments
13#[derive(Parser, Debug)]
14#[command(
15 name = "plc-audit",
16 about = "Validate DID audit logs from plc.directory",
17 long_about = "Fetches and validates the complete audit log for a did:plc identifier from plc.directory"
18)]
19struct Args {
20 /// The DID to audit (e.g., did:plc:ewvi7nxzyoun6zhxrhs64oiz)
21 #[arg(value_name = "DID")]
22 did: String,
23
24 /// Show verbose output including all operations
25 #[arg(short, long)]
26 verbose: bool,
27
28 /// Only show summary (no operation details)
29 #[arg(short, long)]
30 quiet: bool,
31
32 /// Custom PLC directory URL (default: https://plc.directory)
33 #[arg(long, default_value = "https://plc.directory")]
34 plc_url: String,
35}
36
37/// Audit log response from plc.directory
38#[derive(Debug, Deserialize)]
39struct AuditLogEntry {
40 /// The DID this operation is for
41 #[allow(dead_code)]
42 did: String,
43
44 /// The operation itself
45 operation: Operation,
46
47 /// CID of this operation
48 cid: String,
49
50 /// Timestamp when this operation was created
51 #[serde(rename = "createdAt")]
52 created_at: String,
53
54 /// Nullified flag (if this operation was invalidated)
55 #[serde(default)]
56 nullified: bool,
57}
58
59fn main() {
60 let args = Args::parse();
61
62 // Parse and validate the DID
63 let did = match Did::parse(&args.did) {
64 Ok(did) => did,
65 Err(e) => {
66 eprintln!("❌ Error: Invalid DID format: {}", e);
67 eprintln!(" Expected format: did:plc:<24 lowercase base32 characters>");
68 process::exit(1);
69 }
70 };
71
72 if !args.quiet {
73 println!("🔍 Fetching audit log for: {}", did);
74 println!(" Source: {}", args.plc_url);
75 println!();
76 }
77
78 // Fetch the audit log
79 let audit_log = match fetch_audit_log(&args.plc_url, &did) {
80 Ok(log) => log,
81 Err(e) => {
82 eprintln!("❌ Error: Failed to fetch audit log: {}", e);
83 process::exit(1);
84 }
85 };
86
87 if audit_log.is_empty() {
88 eprintln!("❌ Error: No operations found in audit log");
89 process::exit(1);
90 }
91
92 if !args.quiet {
93 println!("📊 Audit Log Summary:");
94 println!(" Total operations: {}", audit_log.len());
95 println!(" Genesis operation: {}", audit_log[0].cid);
96 println!(" Latest operation: {}", audit_log.last().unwrap().cid);
97 println!();
98 }
99
100 // Display operations if verbose
101 if args.verbose {
102 println!("📋 Operations:");
103 for (i, entry) in audit_log.iter().enumerate() {
104 let status = if entry.nullified { "❌ NULLIFIED" } else { "✅" };
105 println!(
106 " [{}] {} {} - {}",
107 i,
108 status,
109 entry.cid,
110 entry.created_at
111 );
112 if entry.operation.is_genesis() {
113 println!(" Type: Genesis (creates the DID)");
114 } else {
115 println!(" Type: Update");
116 }
117 if let Some(prev) = entry.operation.prev() {
118 println!(" Previous: {}", prev);
119 }
120 }
121 println!();
122 }
123
124 // Detect forks and build canonical chain
125 if !args.quiet {
126 println!("🔐 Analyzing operation chain...");
127 println!();
128 }
129
130 // Detect fork points and nullified operations
131 let has_forks = detect_forks(&audit_log);
132 let has_nullified = audit_log.iter().any(|e| e.nullified);
133
134 if has_forks || has_nullified {
135 if !args.quiet {
136 if has_forks {
137 println!("⚠️ Fork detected - multiple operations reference the same prev CID");
138 }
139 if has_nullified {
140 println!("⚠️ Nullified operations detected - will validate canonical chain only");
141 }
142 println!();
143 }
144
145 // Use fork resolution to build canonical chain
146 if args.verbose {
147 println!("Step 1: Fork Resolution & Canonical Chain Building");
148 println!("===================================================");
149 }
150
151 // Build operations and timestamps for fork resolution
152 let operations: Vec<_> = audit_log.iter().map(|e| e.operation.clone()).collect();
153 let timestamps: Vec<_> = audit_log
154 .iter()
155 .map(|e| {
156 e.created_at
157 .parse::<chrono::DateTime<chrono::Utc>>()
158 .unwrap_or_else(|_| chrono::Utc::now())
159 })
160 .collect();
161
162 // Resolve forks and get canonical chain
163 match OperationChainValidator::validate_chain_with_forks(&operations, ×tamps) {
164 Ok(final_state) => {
165 if args.verbose {
166 println!(" ✅ Fork resolution complete");
167 println!(" ✅ Canonical chain validated successfully");
168 println!();
169
170 // Show which operations are in the canonical chain
171 println!("Canonical Chain Operations:");
172 println!("===========================");
173
174 // Build the canonical chain by following non-nullified operations
175 let canonical_indices = build_canonical_chain_indices(&audit_log);
176
177 for idx in &canonical_indices {
178 let entry = &audit_log[*idx];
179 println!(" [{}] ✅ {} - {}", idx, entry.cid, entry.created_at);
180 }
181 println!();
182
183 if has_nullified {
184 println!("Nullified/Rejected Operations:");
185 println!("==============================");
186 for (i, entry) in audit_log.iter().enumerate() {
187 if entry.nullified && !canonical_indices.contains(&i) {
188 println!(" [{}] ❌ {} - {} (nullified)", i, entry.cid, entry.created_at);
189 if let Some(prev) = entry.operation.prev() {
190 println!(" Referenced: {}", prev);
191 }
192 }
193 }
194 println!();
195 }
196 }
197
198 // Display final state
199 display_final_state(&final_state, args.quiet);
200 return;
201 }
202 Err(e) => {
203 eprintln!();
204 eprintln!("❌ Validation failed: {}", e);
205 process::exit(1);
206 }
207 }
208 }
209
210 // Simple linear chain validation (no forks or nullified operations)
211 if args.verbose {
212 println!("Step 1: Linear Chain Validation");
213 println!("================================");
214 }
215
216 for i in 1..audit_log.len() {
217 let prev_cid = audit_log[i - 1].cid.clone();
218 let expected_prev = audit_log[i].operation.prev();
219
220 if args.verbose {
221 println!(" [{}] Checking prev reference...", i);
222 println!(" Expected: {}", prev_cid);
223 }
224
225 if let Some(actual_prev) = expected_prev {
226 if args.verbose {
227 println!(" Actual: {}", actual_prev);
228 }
229
230 if actual_prev != prev_cid {
231 eprintln!();
232 eprintln!("❌ Validation failed: Chain linkage broken at operation {}", i);
233 eprintln!(" Expected prev: {}", prev_cid);
234 eprintln!(" Actual prev: {}", actual_prev);
235 process::exit(1);
236 }
237
238 if args.verbose {
239 println!(" ✅ Match - chain link valid");
240 }
241 } else if i > 0 {
242 eprintln!();
243 eprintln!("❌ Validation failed: Non-genesis operation {} missing prev field", i);
244 process::exit(1);
245 }
246 }
247
248 if args.verbose {
249 println!();
250 println!("✅ Chain linkage validation complete");
251 println!();
252 }
253
254 // Step 2: Validate cryptographic signatures
255 if args.verbose {
256 println!("Step 2: Cryptographic Signature Validation");
257 println!("==========================================");
258 }
259
260 let mut current_rotation_keys: Vec<String> = Vec::new();
261
262 for (i, entry) in audit_log.iter().enumerate() {
263 if entry.nullified {
264 if args.verbose {
265 println!(" [{}] ⊘ Skipped (nullified)", i);
266 }
267 continue;
268 }
269
270 // For genesis operation, we can't validate signature without rotation keys
271 // But we can extract them and validate subsequent operations
272 if i == 0 {
273 if args.verbose {
274 println!(" [{}] Genesis operation - extracting rotation keys", i);
275 }
276
277 if let Some(rotation_keys) = entry.operation.rotation_keys() {
278 current_rotation_keys = rotation_keys.to_vec();
279
280 if args.verbose {
281 println!(" Rotation keys: {}", rotation_keys.len());
282 for (j, key) in rotation_keys.iter().enumerate() {
283 println!(" [{}] {}", j, key);
284 }
285 println!(" ⚠️ Genesis signature cannot be verified (bootstrapping trust)");
286 }
287 }
288 continue;
289 }
290
291 if args.verbose {
292 println!(" [{}] Validating signature...", i);
293 println!(" CID: {}", entry.cid);
294 println!(" Signature: {}", entry.operation.signature());
295 }
296
297 // Validate signature using current rotation keys
298 if !current_rotation_keys.is_empty() {
299 use atproto_plc::VerifyingKey;
300
301 if args.verbose {
302 println!(" Available rotation keys: {}", current_rotation_keys.len());
303 for (j, key) in current_rotation_keys.iter().enumerate() {
304 println!(" [{}] {}", j, key);
305 }
306 }
307
308 let verifying_keys: Vec<VerifyingKey> = current_rotation_keys
309 .iter()
310 .filter_map(|k| VerifyingKey::from_did_key(k).ok())
311 .collect();
312
313 if args.verbose {
314 println!(" Parsed verifying keys: {}/{}", verifying_keys.len(), current_rotation_keys.len());
315 }
316
317 // Try to verify with each key and track which one worked
318 let mut verified = false;
319 let mut verification_key_index = None;
320
321 for (j, key) in verifying_keys.iter().enumerate() {
322 if entry.operation.verify(&[*key]).is_ok() {
323 verified = true;
324 verification_key_index = Some(j);
325 break;
326 }
327 }
328
329 if !verified {
330 // Final attempt with all keys (for comprehensive error)
331 if let Err(e) = entry.operation.verify(&verifying_keys) {
332 eprintln!();
333 eprintln!("❌ Validation failed: Invalid signature at operation {}", i);
334 eprintln!(" Error: {}", e);
335 eprintln!(" CID: {}", entry.cid);
336 eprintln!(" Tried {} rotation keys, none verified the signature", verifying_keys.len());
337 process::exit(1);
338 }
339 }
340
341 if args.verbose {
342 if let Some(key_idx) = verification_key_index {
343 println!(" ✅ Signature verified with rotation key [{}]", key_idx);
344 println!(" {}", current_rotation_keys[key_idx]);
345 } else {
346 println!(" ✅ Signature verified");
347 }
348 }
349 }
350
351 // Update rotation keys if this operation changes them
352 if let Some(new_rotation_keys) = entry.operation.rotation_keys() {
353 if new_rotation_keys != ¤t_rotation_keys {
354 if args.verbose {
355 println!(" 🔄 Rotation keys updated by this operation");
356 println!(" Old keys: {}", current_rotation_keys.len());
357 println!(" New keys: {}", new_rotation_keys.len());
358 for (j, key) in new_rotation_keys.iter().enumerate() {
359 println!(" [{}] {}", j, key);
360 }
361 }
362 current_rotation_keys = new_rotation_keys.to_vec();
363 }
364 }
365 }
366
367 if args.verbose {
368 println!();
369 println!("✅ Cryptographic signature validation complete");
370 println!();
371 }
372
373 // Build final state
374 let final_entry = audit_log.last().unwrap();
375 if let Some(_rotation_keys) = final_entry.operation.rotation_keys() {
376 let final_state = match &final_entry.operation {
377 Operation::PlcOperation {
378 rotation_keys,
379 verification_methods,
380 also_known_as,
381 services,
382 ..
383 } => {
384 PlcState {
385 rotation_keys: rotation_keys.clone(),
386 verification_methods: verification_methods.clone(),
387 also_known_as: also_known_as.clone(),
388 services: services.clone(),
389 }
390 }
391 _ => {
392 PlcState::new()
393 }
394 };
395
396 display_final_state(&final_state, args.quiet);
397 } else {
398 eprintln!("❌ Error: Could not extract final state");
399 process::exit(1);
400 }
401}
402
403/// Detect if there are fork points in the audit log
404fn detect_forks(audit_log: &[AuditLogEntry]) -> bool {
405 use std::collections::HashMap;
406
407 let mut prev_counts: HashMap<String, usize> = HashMap::new();
408
409 for entry in audit_log {
410 if let Some(prev) = entry.operation.prev() {
411 *prev_counts.entry(prev.to_string()).or_insert(0) += 1;
412 }
413 }
414
415 // If any prev CID is referenced by more than one operation, there's a fork
416 prev_counts.values().any(|&count| count > 1)
417}
418
419/// Build a list of indices that form the canonical chain
420fn build_canonical_chain_indices(audit_log: &[AuditLogEntry]) -> Vec<usize> {
421 use std::collections::HashMap;
422
423 // Build a map of prev CID to operations
424 let mut prev_to_indices: HashMap<String, Vec<usize>> = HashMap::new();
425
426 for (i, entry) in audit_log.iter().enumerate() {
427 if let Some(prev) = entry.operation.prev() {
428 prev_to_indices
429 .entry(prev.to_string())
430 .or_default()
431 .push(i);
432 }
433 }
434
435 // Start from genesis and follow the canonical chain
436 let mut canonical = Vec::new();
437
438 // Find genesis (first operation)
439 let genesis = match audit_log.first() {
440 Some(g) => g,
441 None => return canonical,
442 };
443
444 canonical.push(0);
445 let mut current_cid = genesis.cid.clone();
446
447 // Follow the chain, preferring non-nullified operations
448 loop {
449 if let Some(indices) = prev_to_indices.get(¤t_cid) {
450 // Find the first non-nullified operation
451 if let Some(&next_idx) = indices.iter().find(|&&idx| !audit_log[idx].nullified) {
452 canonical.push(next_idx);
453 current_cid = audit_log[next_idx].cid.clone();
454 } else {
455 // All operations at this point are nullified - try to find any operation
456 if let Some(&next_idx) = indices.first() {
457 canonical.push(next_idx);
458 current_cid = audit_log[next_idx].cid.clone();
459 } else {
460 break;
461 }
462 }
463 } else {
464 // No more operations
465 break;
466 }
467 }
468
469 canonical
470}
471
472/// Display the final state after validation
473fn display_final_state(final_state: &PlcState, quiet: bool) {
474 if quiet {
475 println!("✅ VALID");
476 } else {
477 println!("✅ Validation successful!");
478 println!();
479 println!("📄 Final DID State:");
480 println!(" Rotation keys: {}", final_state.rotation_keys.len());
481 for (i, key) in final_state.rotation_keys.iter().enumerate() {
482 println!(" [{}] {}", i, key);
483 }
484 println!();
485 println!(" Verification methods: {}", final_state.verification_methods.len());
486 for (name, key) in &final_state.verification_methods {
487 println!(" {}: {}", name, key);
488 }
489 println!();
490 if !final_state.also_known_as.is_empty() {
491 println!(" Also known as: {}", final_state.also_known_as.len());
492 for uri in &final_state.also_known_as {
493 println!(" - {}", uri);
494 }
495 println!();
496 }
497 if !final_state.services.is_empty() {
498 println!(" Services: {}", final_state.services.len());
499 for (name, service) in &final_state.services {
500 println!(" {}: {} ({})", name, service.endpoint, service.service_type);
501 }
502 }
503 }
504}
505
506/// Fetch the audit log for a DID from plc.directory
507fn fetch_audit_log(plc_url: &str, did: &Did) -> Result<Vec<AuditLogEntry>, Box<dyn std::error::Error>> {
508 let url = format!("{}/{}/log/audit", plc_url, did);
509
510 let client = Client::builder()
511 .user_agent("atproto-plc-audit/0.2.0")
512 .timeout(std::time::Duration::from_secs(30))
513 .build()?;
514
515 let response = client.get(&url).send()?;
516
517 if !response.status().is_success() {
518 return Err(format!(
519 "HTTP error: {} - {}",
520 response.status(),
521 response.text().unwrap_or_default()
522 )
523 .into());
524 }
525
526 let audit_log: Vec<AuditLogEntry> = response.json()?;
527 Ok(audit_log)
528}