forked from
smokesignal.events/atproto-plc
Rust and WASM did-method-plc tools and structures
1#!/usr/bin/env node
2
3/**
4 * PLC Directory Audit Log Validator (WASM version)
5 *
6 * This is a JavaScript port of the Rust plc-audit binary that uses WASM
7 * for cryptographic operations. It fetches DID audit logs from plc.directory
8 * and validates each operation cryptographically.
9 */
10
11import { WasmDid, WasmOperation, WasmVerifyingKey } from './pkg/atproto_plc.js';
12import { parseArgs } from 'node:util';
13
14// Parse command-line arguments
15const { values, positionals } = parseArgs({
16 options: {
17 verbose: {
18 type: 'boolean',
19 short: 'v',
20 default: false,
21 },
22 quiet: {
23 type: 'boolean',
24 short: 'q',
25 default: false,
26 },
27 'plc-url': {
28 type: 'string',
29 default: 'https://plc.directory',
30 },
31 },
32 allowPositionals: true,
33});
34
35const args = {
36 did: positionals[0],
37 verbose: values.verbose,
38 quiet: values.quiet,
39 plcUrl: values['plc-url'],
40};
41
42// Validate arguments
43if (!args.did) {
44 console.error('Usage: node plc-audit.js [OPTIONS] <DID>');
45 console.error('');
46 console.error('Arguments:');
47 console.error(' <DID> The DID to audit (e.g., did:plc:ewvi7nxzyoun6zhxrhs64oiz)');
48 console.error('');
49 console.error('Options:');
50 console.error(' -v, --verbose Show verbose output including all operations');
51 console.error(' -q, --quiet Only show summary (no operation details)');
52 console.error(' --plc-url <URL> Custom PLC directory URL (default: https://plc.directory)');
53 process.exit(1);
54}
55
56/**
57 * Fetch audit log from plc.directory
58 */
59async function fetchAuditLog(plcUrl, did) {
60 const url = `${plcUrl}/${did}/log/audit`;
61
62 try {
63 const response = await fetch(url, {
64 headers: {
65 'User-Agent': 'atproto-plc-audit-wasm/0.2.0',
66 },
67 });
68
69 if (!response.ok) {
70 const text = await response.text();
71 throw new Error(`HTTP error: ${response.status} - ${text}`);
72 }
73
74 return await response.json();
75 } catch (error) {
76 throw new Error(`Failed to fetch audit log: ${error.message}`);
77 }
78}
79
80/**
81 * Detect if there are fork points in the audit log
82 */
83function detectForks(operations) {
84 const prevCounts = new Map();
85
86 for (const entry of operations) {
87 const prev = entry.operation.prev();
88 if (prev) {
89 prevCounts.set(prev, (prevCounts.get(prev) || 0) + 1);
90 }
91 }
92
93 // If any prev CID is referenced by more than one operation, there's a fork
94 return Array.from(prevCounts.values()).some(count => count > 1);
95}
96
97/**
98 * Build a list of indices that form the canonical chain
99 */
100function buildCanonicalChainIndices(operations) {
101 // Build a map of prev CID to operations
102 const prevToIndices = new Map();
103
104 for (let i = 0; i < operations.length; i++) {
105 const prev = operations[i].operation.prev();
106 if (prev) {
107 if (!prevToIndices.has(prev)) {
108 prevToIndices.set(prev, []);
109 }
110 prevToIndices.get(prev).push(i);
111 }
112 }
113
114 // Start from genesis and follow the canonical chain
115 const canonical = [];
116
117 // Find genesis (first operation)
118 if (operations.length === 0) {
119 return canonical;
120 }
121
122 canonical.push(0);
123 let currentCid = operations[0].cid;
124
125 // Follow the chain, preferring non-nullified operations
126 while (true) {
127 const indices = prevToIndices.get(currentCid);
128 if (!indices || indices.length === 0) {
129 break;
130 }
131
132 // Find the first non-nullified operation
133 const nextIdx = indices.find(idx => !operations[idx].nullified);
134 if (nextIdx !== undefined) {
135 canonical.push(nextIdx);
136 currentCid = operations[nextIdx].cid;
137 } else {
138 // All operations at this point are nullified - try to find any operation
139 if (indices.length > 0) {
140 canonical.push(indices[0]);
141 currentCid = operations[indices[0]].cid;
142 } else {
143 break;
144 }
145 }
146 }
147
148 return canonical;
149}
150
151/**
152 * Display the final state after validation
153 */
154function displayFinalState(finalEntry, rawEntry) {
155 const rotationKeys = finalEntry.operation.rotationKeys();
156
157 if (!rotationKeys) {
158 console.error('❌ Error: Could not extract final state');
159 process.exit(1);
160 }
161
162 if (args.quiet) {
163 console.log('✅ VALID');
164 } else {
165 console.log('✅ Validation successful!');
166 console.log();
167 console.log('📄 Final DID State:');
168 console.log(' Rotation keys:', rotationKeys.length);
169 for (let i = 0; i < rotationKeys.length; i++) {
170 console.log(` [${i}] ${rotationKeys[i]}`);
171 }
172 console.log();
173
174 // Extract additional state from the raw operation
175 const op = rawEntry.operation;
176 if (op.verificationMethods) {
177 const vmKeys = Object.keys(op.verificationMethods);
178 console.log(' Verification methods:', vmKeys.length);
179 for (const name of vmKeys) {
180 console.log(` ${name}: ${op.verificationMethods[name]}`);
181 }
182 console.log();
183 }
184
185 if (op.alsoKnownAs && op.alsoKnownAs.length > 0) {
186 console.log(' Also known as:', op.alsoKnownAs.length);
187 for (const uri of op.alsoKnownAs) {
188 console.log(` - ${uri}`);
189 }
190 console.log();
191 }
192
193 if (op.services) {
194 const serviceNames = Object.keys(op.services);
195 if (serviceNames.length > 0) {
196 console.log(' Services:', serviceNames.length);
197 for (const name of serviceNames) {
198 const service = op.services[name];
199 console.log(` ${name}: ${service.endpoint} (${service.type})`);
200 }
201 }
202 }
203 }
204}
205
206/**
207 * Main validation logic
208 */
209async function main() {
210 try {
211 // Parse and validate the DID
212 let did;
213 try {
214 did = new WasmDid(args.did);
215 } catch (error) {
216 console.error('❌ Error: Invalid DID format:', error.message);
217 console.error(' Expected format: did:plc:<24 lowercase base32 characters>');
218 process.exit(1);
219 }
220
221 if (!args.quiet) {
222 console.log('🔍 Fetching audit log for:', did.did);
223 console.log(' Source:', args.plcUrl);
224 console.log();
225 }
226
227 // Fetch the audit log
228 let auditLog;
229 try {
230 auditLog = await fetchAuditLog(args.plcUrl, did.did);
231 } catch (error) {
232 console.error('❌ Error:', error.message);
233 process.exit(1);
234 }
235
236 if (!auditLog || auditLog.length === 0) {
237 console.error('❌ Error: No operations found in audit log');
238 process.exit(1);
239 }
240
241 // Parse operations
242 const operations = auditLog.map(entry => {
243 const op = WasmOperation.fromJson(JSON.stringify(entry.operation));
244 return {
245 did: entry.did,
246 operation: op,
247 cid: entry.cid,
248 createdAt: entry.createdAt,
249 nullified: entry.nullified || false,
250 };
251 });
252
253 if (!args.quiet) {
254 console.log('📊 Audit Log Summary:');
255 console.log(' Total operations:', auditLog.length);
256 console.log(' Genesis operation:', operations[0].cid);
257 console.log(' Latest operation:', operations[operations.length - 1].cid);
258 console.log();
259 }
260
261 // Display operations if verbose
262 if (args.verbose) {
263 console.log('📋 Operations:');
264 for (let i = 0; i < operations.length; i++) {
265 const entry = operations[i];
266 const status = entry.nullified ? '❌ NULLIFIED' : '✅';
267 console.log(` [${i}] ${status} ${entry.cid} - ${entry.createdAt}`);
268
269 if (entry.operation.isGenesis()) {
270 console.log(' Type: Genesis (creates the DID)');
271 } else {
272 console.log(' Type: Update');
273 }
274
275 const prev = entry.operation.prev();
276 if (prev) {
277 console.log(' Previous:', prev);
278 }
279 }
280 console.log();
281 }
282
283 // Detect forks and build canonical chain
284 if (!args.quiet) {
285 console.log('🔐 Analyzing operation chain...');
286 console.log();
287 }
288
289 // Detect fork points and nullified operations
290 const hasForks = detectForks(operations);
291 const hasNullified = operations.some(e => e.nullified);
292
293 if (hasForks || hasNullified) {
294 if (!args.quiet) {
295 if (hasForks) {
296 console.log('⚠️ Fork detected - multiple operations reference the same prev CID');
297 }
298 if (hasNullified) {
299 console.log('⚠️ Nullified operations detected - will validate canonical chain only');
300 }
301 console.log();
302 }
303
304 // Build canonical chain
305 if (args.verbose) {
306 console.log('Step 1: Fork Resolution & Canonical Chain Building');
307 console.log('===================================================');
308 }
309
310 const canonicalIndices = buildCanonicalChainIndices(operations);
311
312 if (args.verbose) {
313 console.log(' ✅ Fork resolution complete');
314 console.log(' ✅ Canonical chain identified');
315 console.log();
316
317 console.log('Canonical Chain Operations:');
318 console.log('===========================');
319
320 for (const idx of canonicalIndices) {
321 const entry = operations[idx];
322 console.log(` [${idx}] ✅ ${entry.cid} - ${entry.createdAt}`);
323 }
324 console.log();
325
326 if (hasNullified) {
327 console.log('Nullified/Rejected Operations:');
328 console.log('==============================');
329 for (let i = 0; i < operations.length; i++) {
330 const entry = operations[i];
331 if (entry.nullified && !canonicalIndices.includes(i)) {
332 console.log(` [${i}] ❌ ${entry.cid} - ${entry.createdAt} (nullified)`);
333 const prev = entry.operation.prev();
334 if (prev) {
335 console.log(' Referenced:', prev);
336 }
337 }
338 }
339 console.log();
340 }
341 }
342
343 // Validate signatures along canonical chain
344 if (args.verbose) {
345 console.log('Step 2: Cryptographic Signature Validation');
346 console.log('==========================================');
347 }
348
349 let currentRotationKeys = [];
350
351 for (const idx of canonicalIndices) {
352 const entry = operations[idx];
353
354 // For genesis operation, extract rotation keys
355 if (idx === 0) {
356 if (args.verbose) {
357 console.log(` [${idx}] Genesis operation - extracting rotation keys`);
358 }
359
360 const rotationKeys = entry.operation.rotationKeys();
361 if (rotationKeys) {
362 currentRotationKeys = rotationKeys;
363
364 if (args.verbose) {
365 console.log(' Rotation keys:', rotationKeys.length);
366 for (let j = 0; j < rotationKeys.length; j++) {
367 console.log(` [${j}] ${rotationKeys[j]}`);
368 }
369 console.log(' ⚠️ Genesis signature cannot be verified (bootstrapping trust)');
370 }
371 }
372 continue;
373 }
374
375 if (args.verbose) {
376 console.log(` [${idx}] Validating signature...`);
377 console.log(' CID:', entry.cid);
378 console.log(' Signature:', entry.operation.signature());
379 }
380
381 // Validate signature using current rotation keys
382 if (currentRotationKeys.length > 0) {
383 if (args.verbose) {
384 console.log(' Available rotation keys:', currentRotationKeys.length);
385 for (let j = 0; j < currentRotationKeys.length; j++) {
386 console.log(` [${j}] ${currentRotationKeys[j]}`);
387 }
388 }
389
390 // Parse verifying keys
391 const verifyingKeys = [];
392 for (const keyStr of currentRotationKeys) {
393 try {
394 verifyingKeys.push(WasmVerifyingKey.fromDidKey(keyStr));
395 } catch (error) {
396 console.error(`Warning: Failed to parse rotation key: ${keyStr}`);
397 }
398 }
399
400 if (args.verbose) {
401 console.log(` Parsed verifying keys: ${verifyingKeys.length}/${currentRotationKeys.length}`);
402 }
403
404 // Try to verify with each key and track which one worked
405 try {
406 const keyIndex = entry.operation.verifyWithKeyIndex(verifyingKeys);
407
408 if (args.verbose) {
409 console.log(` ✅ Signature verified with rotation key [${keyIndex}]`);
410 console.log(` ${currentRotationKeys[keyIndex]}`);
411 }
412 } catch (error) {
413 console.error();
414 console.error(`❌ Validation failed: Invalid signature at operation ${idx}`);
415 console.error(' Error:', error.message);
416 console.error(' CID:', entry.cid);
417 console.error(` Tried ${verifyingKeys.length} rotation keys, none verified the signature`);
418 process.exit(1);
419 }
420 }
421
422 // Update rotation keys if this operation changes them
423 const newRotationKeys = entry.operation.rotationKeys();
424 if (newRotationKeys) {
425 const keysChanged = JSON.stringify(newRotationKeys) !== JSON.stringify(currentRotationKeys);
426
427 if (keysChanged) {
428 if (args.verbose) {
429 console.log(' 🔄 Rotation keys updated by this operation');
430 console.log(' Old keys:', currentRotationKeys.length);
431 console.log(' New keys:', newRotationKeys.length);
432 for (let j = 0; j < newRotationKeys.length; j++) {
433 console.log(` [${j}] ${newRotationKeys[j]}`);
434 }
435 }
436 currentRotationKeys = newRotationKeys;
437 }
438 }
439 }
440
441 if (args.verbose) {
442 console.log();
443 console.log('✅ Cryptographic signature validation complete');
444 console.log();
445 }
446
447 // Build final state
448 const finalIdx = canonicalIndices[canonicalIndices.length - 1];
449 const finalEntry = operations[finalIdx];
450 const finalRawEntry = auditLog[finalIdx];
451 displayFinalState(finalEntry, finalRawEntry);
452 return;
453 }
454
455 // Simple linear chain validation (no forks or nullified operations)
456 if (args.verbose) {
457 console.log('Step 1: Linear Chain Validation');
458 console.log('================================');
459 }
460
461 for (let i = 1; i < operations.length; i++) {
462 const prevCid = operations[i - 1].cid;
463 const expectedPrev = operations[i].operation.prev();
464
465 if (args.verbose) {
466 console.log(` [${i}] Checking prev reference...`);
467 console.log(' Expected:', prevCid);
468 }
469
470 if (expectedPrev) {
471 if (args.verbose) {
472 console.log(' Actual: ', expectedPrev);
473 }
474
475 if (expectedPrev !== prevCid) {
476 console.error();
477 console.error(`❌ Validation failed: Chain linkage broken at operation ${i}`);
478 console.error(' Expected prev:', prevCid);
479 console.error(' Actual prev:', expectedPrev);
480 process.exit(1);
481 }
482
483 if (args.verbose) {
484 console.log(' ✅ Match - chain link valid');
485 }
486 } else if (i > 0) {
487 console.error();
488 console.error(`❌ Validation failed: Non-genesis operation ${i} missing prev field`);
489 process.exit(1);
490 }
491 }
492
493 if (args.verbose) {
494 console.log();
495 console.log('✅ Chain linkage validation complete');
496 console.log();
497 }
498
499 // Step 2: Validate cryptographic signatures
500 if (args.verbose) {
501 console.log('Step 2: Cryptographic Signature Validation');
502 console.log('==========================================');
503 }
504
505 let currentRotationKeys = [];
506
507 for (let i = 0; i < operations.length; i++) {
508 const entry = operations[i];
509
510 if (entry.nullified) {
511 if (args.verbose) {
512 console.log(` [${i}] ⊘ Skipped (nullified)`);
513 }
514 continue;
515 }
516
517 // For genesis operation, extract rotation keys
518 if (i === 0) {
519 if (args.verbose) {
520 console.log(` [${i}] Genesis operation - extracting rotation keys`);
521 }
522
523 const rotationKeys = entry.operation.rotationKeys();
524 if (rotationKeys) {
525 currentRotationKeys = rotationKeys;
526
527 if (args.verbose) {
528 console.log(' Rotation keys:', rotationKeys.length);
529 for (let j = 0; j < rotationKeys.length; j++) {
530 console.log(` [${j}] ${rotationKeys[j]}`);
531 }
532 console.log(' ⚠️ Genesis signature cannot be verified (bootstrapping trust)');
533 }
534 }
535 continue;
536 }
537
538 if (args.verbose) {
539 console.log(` [${i}] Validating signature...`);
540 console.log(' CID:', entry.cid);
541 console.log(' Signature:', entry.operation.signature());
542 }
543
544 // Validate signature using current rotation keys
545 if (currentRotationKeys.length > 0) {
546 if (args.verbose) {
547 console.log(' Available rotation keys:', currentRotationKeys.length);
548 for (let j = 0; j < currentRotationKeys.length; j++) {
549 console.log(` [${j}] ${currentRotationKeys[j]}`);
550 }
551 }
552
553 // Parse verifying keys
554 const verifyingKeys = [];
555 for (const keyStr of currentRotationKeys) {
556 try {
557 verifyingKeys.push(WasmVerifyingKey.fromDidKey(keyStr));
558 } catch (error) {
559 console.error(`Warning: Failed to parse rotation key: ${keyStr}`);
560 }
561 }
562
563 if (args.verbose) {
564 console.log(` Parsed verifying keys: ${verifyingKeys.length}/${currentRotationKeys.length}`);
565 }
566
567 // Try to verify with each key and track which one worked
568 try {
569 const keyIndex = entry.operation.verifyWithKeyIndex(verifyingKeys);
570
571 if (args.verbose) {
572 console.log(` ✅ Signature verified with rotation key [${keyIndex}]`);
573 console.log(` ${currentRotationKeys[keyIndex]}`);
574 }
575 } catch (error) {
576 console.error();
577 console.error(`❌ Validation failed: Invalid signature at operation ${i}`);
578 console.error(' Error:', error.message);
579 console.error(' CID:', entry.cid);
580 console.error(` Tried ${verifyingKeys.length} rotation keys, none verified the signature`);
581 process.exit(1);
582 }
583 }
584
585 // Update rotation keys if this operation changes them
586 const newRotationKeys = entry.operation.rotationKeys();
587 if (newRotationKeys) {
588 const keysChanged = JSON.stringify(newRotationKeys) !== JSON.stringify(currentRotationKeys);
589
590 if (keysChanged) {
591 if (args.verbose) {
592 console.log(' 🔄 Rotation keys updated by this operation');
593 console.log(' Old keys:', currentRotationKeys.length);
594 console.log(' New keys:', newRotationKeys.length);
595 for (let j = 0; j < newRotationKeys.length; j++) {
596 console.log(` [${j}] ${newRotationKeys[j]}`);
597 }
598 }
599 currentRotationKeys = newRotationKeys;
600 }
601 }
602 }
603
604 if (args.verbose) {
605 console.log();
606 console.log('✅ Cryptographic signature validation complete');
607 console.log();
608 }
609
610 // Build final state
611 const finalEntry = operations[operations.length - 1];
612 const finalRawEntry = auditLog[auditLog.length - 1];
613 displayFinalState(finalEntry, finalRawEntry);
614
615 } catch (error) {
616 console.error('❌ Fatal error:', error.message);
617 if (args.verbose) {
618 console.error(error.stack);
619 }
620 process.exit(1);
621 }
622}
623
624// Run the main function
625main().catch(error => {
626 console.error('❌ Unhandled error:', error);
627 process.exit(1);
628});