#!/usr/bin/env node /** * PLC Directory Audit Log Validator (WASM version) * * This is a JavaScript port of the Rust plc-audit binary that uses WASM * for cryptographic operations. It fetches DID audit logs from plc.directory * and validates each operation cryptographically. */ import { WasmDid, WasmOperation, WasmVerifyingKey } from './pkg/atproto_plc.js'; import { parseArgs } from 'node:util'; // Parse command-line arguments const { values, positionals } = parseArgs({ options: { verbose: { type: 'boolean', short: 'v', default: false, }, quiet: { type: 'boolean', short: 'q', default: false, }, 'plc-url': { type: 'string', default: 'https://plc.directory', }, }, allowPositionals: true, }); const args = { did: positionals[0], verbose: values.verbose, quiet: values.quiet, plcUrl: values['plc-url'], }; // Validate arguments if (!args.did) { console.error('Usage: node plc-audit.js [OPTIONS] '); console.error(''); console.error('Arguments:'); console.error(' The DID to audit (e.g., did:plc:ewvi7nxzyoun6zhxrhs64oiz)'); console.error(''); console.error('Options:'); console.error(' -v, --verbose Show verbose output including all operations'); console.error(' -q, --quiet Only show summary (no operation details)'); console.error(' --plc-url Custom PLC directory URL (default: https://plc.directory)'); process.exit(1); } /** * Fetch audit log from plc.directory */ async function fetchAuditLog(plcUrl, did) { const url = `${plcUrl}/${did}/log/audit`; try { const response = await fetch(url, { headers: { 'User-Agent': 'atproto-plc-audit-wasm/0.2.0', }, }); if (!response.ok) { const text = await response.text(); throw new Error(`HTTP error: ${response.status} - ${text}`); } return await response.json(); } catch (error) { throw new Error(`Failed to fetch audit log: ${error.message}`); } } /** * Detect if there are fork points in the audit log */ function detectForks(operations) { const prevCounts = new Map(); for (const entry of operations) { const prev = entry.operation.prev(); if (prev) { prevCounts.set(prev, (prevCounts.get(prev) || 0) + 1); } } // If any prev CID is referenced by more than one operation, there's a fork return Array.from(prevCounts.values()).some(count => count > 1); } /** * Build a list of indices that form the canonical chain */ function buildCanonicalChainIndices(operations) { // Build a map of prev CID to operations const prevToIndices = new Map(); for (let i = 0; i < operations.length; i++) { const prev = operations[i].operation.prev(); if (prev) { if (!prevToIndices.has(prev)) { prevToIndices.set(prev, []); } prevToIndices.get(prev).push(i); } } // Start from genesis and follow the canonical chain const canonical = []; // Find genesis (first operation) if (operations.length === 0) { return canonical; } canonical.push(0); let currentCid = operations[0].cid; // Follow the chain, preferring non-nullified operations while (true) { const indices = prevToIndices.get(currentCid); if (!indices || indices.length === 0) { break; } // Find the first non-nullified operation const nextIdx = indices.find(idx => !operations[idx].nullified); if (nextIdx !== undefined) { canonical.push(nextIdx); currentCid = operations[nextIdx].cid; } else { // All operations at this point are nullified - try to find any operation if (indices.length > 0) { canonical.push(indices[0]); currentCid = operations[indices[0]].cid; } else { break; } } } return canonical; } /** * Display the final state after validation */ function displayFinalState(finalEntry, rawEntry) { const rotationKeys = finalEntry.operation.rotationKeys(); if (!rotationKeys) { console.error('❌ Error: Could not extract final state'); process.exit(1); } if (args.quiet) { console.log('✅ VALID'); } else { console.log('✅ Validation successful!'); console.log(); console.log('📄 Final DID State:'); console.log(' Rotation keys:', rotationKeys.length); for (let i = 0; i < rotationKeys.length; i++) { console.log(` [${i}] ${rotationKeys[i]}`); } console.log(); // Extract additional state from the raw operation const op = rawEntry.operation; if (op.verificationMethods) { const vmKeys = Object.keys(op.verificationMethods); console.log(' Verification methods:', vmKeys.length); for (const name of vmKeys) { console.log(` ${name}: ${op.verificationMethods[name]}`); } console.log(); } if (op.alsoKnownAs && op.alsoKnownAs.length > 0) { console.log(' Also known as:', op.alsoKnownAs.length); for (const uri of op.alsoKnownAs) { console.log(` - ${uri}`); } console.log(); } if (op.services) { const serviceNames = Object.keys(op.services); if (serviceNames.length > 0) { console.log(' Services:', serviceNames.length); for (const name of serviceNames) { const service = op.services[name]; console.log(` ${name}: ${service.endpoint} (${service.type})`); } } } } } /** * Main validation logic */ async function main() { try { // Parse and validate the DID let did; try { did = new WasmDid(args.did); } catch (error) { console.error('❌ Error: Invalid DID format:', error.message); console.error(' Expected format: did:plc:<24 lowercase base32 characters>'); process.exit(1); } if (!args.quiet) { console.log('🔍 Fetching audit log for:', did.did); console.log(' Source:', args.plcUrl); console.log(); } // Fetch the audit log let auditLog; try { auditLog = await fetchAuditLog(args.plcUrl, did.did); } catch (error) { console.error('❌ Error:', error.message); process.exit(1); } if (!auditLog || auditLog.length === 0) { console.error('❌ Error: No operations found in audit log'); process.exit(1); } // Parse operations const operations = auditLog.map(entry => { const op = WasmOperation.fromJson(JSON.stringify(entry.operation)); return { did: entry.did, operation: op, cid: entry.cid, createdAt: entry.createdAt, nullified: entry.nullified || false, }; }); if (!args.quiet) { console.log('📊 Audit Log Summary:'); console.log(' Total operations:', auditLog.length); console.log(' Genesis operation:', operations[0].cid); console.log(' Latest operation:', operations[operations.length - 1].cid); console.log(); } // Display operations if verbose if (args.verbose) { console.log('📋 Operations:'); for (let i = 0; i < operations.length; i++) { const entry = operations[i]; const status = entry.nullified ? '❌ NULLIFIED' : '✅'; console.log(` [${i}] ${status} ${entry.cid} - ${entry.createdAt}`); if (entry.operation.isGenesis()) { console.log(' Type: Genesis (creates the DID)'); } else { console.log(' Type: Update'); } const prev = entry.operation.prev(); if (prev) { console.log(' Previous:', prev); } } console.log(); } // Detect forks and build canonical chain if (!args.quiet) { console.log('🔐 Analyzing operation chain...'); console.log(); } // Detect fork points and nullified operations const hasForks = detectForks(operations); const hasNullified = operations.some(e => e.nullified); if (hasForks || hasNullified) { if (!args.quiet) { if (hasForks) { console.log('⚠️ Fork detected - multiple operations reference the same prev CID'); } if (hasNullified) { console.log('⚠️ Nullified operations detected - will validate canonical chain only'); } console.log(); } // Build canonical chain if (args.verbose) { console.log('Step 1: Fork Resolution & Canonical Chain Building'); console.log('==================================================='); } const canonicalIndices = buildCanonicalChainIndices(operations); if (args.verbose) { console.log(' ✅ Fork resolution complete'); console.log(' ✅ Canonical chain identified'); console.log(); console.log('Canonical Chain Operations:'); console.log('==========================='); for (const idx of canonicalIndices) { const entry = operations[idx]; console.log(` [${idx}] ✅ ${entry.cid} - ${entry.createdAt}`); } console.log(); if (hasNullified) { console.log('Nullified/Rejected Operations:'); console.log('=============================='); for (let i = 0; i < operations.length; i++) { const entry = operations[i]; if (entry.nullified && !canonicalIndices.includes(i)) { console.log(` [${i}] ❌ ${entry.cid} - ${entry.createdAt} (nullified)`); const prev = entry.operation.prev(); if (prev) { console.log(' Referenced:', prev); } } } console.log(); } } // Validate signatures along canonical chain if (args.verbose) { console.log('Step 2: Cryptographic Signature Validation'); console.log('=========================================='); } let currentRotationKeys = []; for (const idx of canonicalIndices) { const entry = operations[idx]; // For genesis operation, extract rotation keys if (idx === 0) { if (args.verbose) { console.log(` [${idx}] Genesis operation - extracting rotation keys`); } const rotationKeys = entry.operation.rotationKeys(); if (rotationKeys) { currentRotationKeys = rotationKeys; if (args.verbose) { console.log(' Rotation keys:', rotationKeys.length); for (let j = 0; j < rotationKeys.length; j++) { console.log(` [${j}] ${rotationKeys[j]}`); } console.log(' ⚠️ Genesis signature cannot be verified (bootstrapping trust)'); } } continue; } if (args.verbose) { console.log(` [${idx}] Validating signature...`); console.log(' CID:', entry.cid); console.log(' Signature:', entry.operation.signature()); } // Validate signature using current rotation keys if (currentRotationKeys.length > 0) { if (args.verbose) { console.log(' Available rotation keys:', currentRotationKeys.length); for (let j = 0; j < currentRotationKeys.length; j++) { console.log(` [${j}] ${currentRotationKeys[j]}`); } } // Parse verifying keys const verifyingKeys = []; for (const keyStr of currentRotationKeys) { try { verifyingKeys.push(WasmVerifyingKey.fromDidKey(keyStr)); } catch (error) { console.error(`Warning: Failed to parse rotation key: ${keyStr}`); } } if (args.verbose) { console.log(` Parsed verifying keys: ${verifyingKeys.length}/${currentRotationKeys.length}`); } // Try to verify with each key and track which one worked try { const keyIndex = entry.operation.verifyWithKeyIndex(verifyingKeys); if (args.verbose) { console.log(` ✅ Signature verified with rotation key [${keyIndex}]`); console.log(` ${currentRotationKeys[keyIndex]}`); } } catch (error) { console.error(); console.error(`❌ Validation failed: Invalid signature at operation ${idx}`); console.error(' Error:', error.message); console.error(' CID:', entry.cid); console.error(` Tried ${verifyingKeys.length} rotation keys, none verified the signature`); process.exit(1); } } // Update rotation keys if this operation changes them const newRotationKeys = entry.operation.rotationKeys(); if (newRotationKeys) { const keysChanged = JSON.stringify(newRotationKeys) !== JSON.stringify(currentRotationKeys); if (keysChanged) { if (args.verbose) { console.log(' 🔄 Rotation keys updated by this operation'); console.log(' Old keys:', currentRotationKeys.length); console.log(' New keys:', newRotationKeys.length); for (let j = 0; j < newRotationKeys.length; j++) { console.log(` [${j}] ${newRotationKeys[j]}`); } } currentRotationKeys = newRotationKeys; } } } if (args.verbose) { console.log(); console.log('✅ Cryptographic signature validation complete'); console.log(); } // Build final state const finalIdx = canonicalIndices[canonicalIndices.length - 1]; const finalEntry = operations[finalIdx]; const finalRawEntry = auditLog[finalIdx]; displayFinalState(finalEntry, finalRawEntry); return; } // Simple linear chain validation (no forks or nullified operations) if (args.verbose) { console.log('Step 1: Linear Chain Validation'); console.log('================================'); } for (let i = 1; i < operations.length; i++) { const prevCid = operations[i - 1].cid; const expectedPrev = operations[i].operation.prev(); if (args.verbose) { console.log(` [${i}] Checking prev reference...`); console.log(' Expected:', prevCid); } if (expectedPrev) { if (args.verbose) { console.log(' Actual: ', expectedPrev); } if (expectedPrev !== prevCid) { console.error(); console.error(`❌ Validation failed: Chain linkage broken at operation ${i}`); console.error(' Expected prev:', prevCid); console.error(' Actual prev:', expectedPrev); process.exit(1); } if (args.verbose) { console.log(' ✅ Match - chain link valid'); } } else if (i > 0) { console.error(); console.error(`❌ Validation failed: Non-genesis operation ${i} missing prev field`); process.exit(1); } } if (args.verbose) { console.log(); console.log('✅ Chain linkage validation complete'); console.log(); } // Step 2: Validate cryptographic signatures if (args.verbose) { console.log('Step 2: Cryptographic Signature Validation'); console.log('=========================================='); } let currentRotationKeys = []; for (let i = 0; i < operations.length; i++) { const entry = operations[i]; if (entry.nullified) { if (args.verbose) { console.log(` [${i}] ⊘ Skipped (nullified)`); } continue; } // For genesis operation, extract rotation keys if (i === 0) { if (args.verbose) { console.log(` [${i}] Genesis operation - extracting rotation keys`); } const rotationKeys = entry.operation.rotationKeys(); if (rotationKeys) { currentRotationKeys = rotationKeys; if (args.verbose) { console.log(' Rotation keys:', rotationKeys.length); for (let j = 0; j < rotationKeys.length; j++) { console.log(` [${j}] ${rotationKeys[j]}`); } console.log(' ⚠️ Genesis signature cannot be verified (bootstrapping trust)'); } } continue; } if (args.verbose) { console.log(` [${i}] Validating signature...`); console.log(' CID:', entry.cid); console.log(' Signature:', entry.operation.signature()); } // Validate signature using current rotation keys if (currentRotationKeys.length > 0) { if (args.verbose) { console.log(' Available rotation keys:', currentRotationKeys.length); for (let j = 0; j < currentRotationKeys.length; j++) { console.log(` [${j}] ${currentRotationKeys[j]}`); } } // Parse verifying keys const verifyingKeys = []; for (const keyStr of currentRotationKeys) { try { verifyingKeys.push(WasmVerifyingKey.fromDidKey(keyStr)); } catch (error) { console.error(`Warning: Failed to parse rotation key: ${keyStr}`); } } if (args.verbose) { console.log(` Parsed verifying keys: ${verifyingKeys.length}/${currentRotationKeys.length}`); } // Try to verify with each key and track which one worked try { const keyIndex = entry.operation.verifyWithKeyIndex(verifyingKeys); if (args.verbose) { console.log(` ✅ Signature verified with rotation key [${keyIndex}]`); console.log(` ${currentRotationKeys[keyIndex]}`); } } catch (error) { console.error(); console.error(`❌ Validation failed: Invalid signature at operation ${i}`); console.error(' Error:', error.message); console.error(' CID:', entry.cid); console.error(` Tried ${verifyingKeys.length} rotation keys, none verified the signature`); process.exit(1); } } // Update rotation keys if this operation changes them const newRotationKeys = entry.operation.rotationKeys(); if (newRotationKeys) { const keysChanged = JSON.stringify(newRotationKeys) !== JSON.stringify(currentRotationKeys); if (keysChanged) { if (args.verbose) { console.log(' 🔄 Rotation keys updated by this operation'); console.log(' Old keys:', currentRotationKeys.length); console.log(' New keys:', newRotationKeys.length); for (let j = 0; j < newRotationKeys.length; j++) { console.log(` [${j}] ${newRotationKeys[j]}`); } } currentRotationKeys = newRotationKeys; } } } if (args.verbose) { console.log(); console.log('✅ Cryptographic signature validation complete'); console.log(); } // Build final state const finalEntry = operations[operations.length - 1]; const finalRawEntry = auditLog[auditLog.length - 1]; displayFinalState(finalEntry, finalRawEntry); } catch (error) { console.error('❌ Fatal error:', error.message); if (args.verbose) { console.error(error.stack); } process.exit(1); } } // Run the main function main().catch(error => { console.error('❌ Unhandled error:', error); process.exit(1); });