/** * Proof Chain — Unforgeable Trajectory Chain for SpatialProofs * * Each SpatialProof references the SHA-256 hash of the previous proof, * creating a linked list of cryptographic commitments. This makes it * impossible to retroactively insert, remove, or reorder proofs without * breaking the chain. * * Use cases: * - Delivery route proof: driver can't claim they took a different path * - AV insurance: continuous trajectory evidence for accident reconstruction * - Fleet audit: tamper-evident record of where every robot has been */ import type { SpatialProof, ProofChainVerification } from '../types/index.js'; import { sha256 } from '../utils/crypto.js'; /** * Compute the canonical hash of a SpatialProof. * * Hashes the critical fields that define the proof's identity: * id, robotId, timestamp, nonce, signature, passed, composite metric, * memoryMerkleRoot, and prevProofHash (if present). * * Deliberately excludes heavy payload fields (actualFrame, expectedRender) * to keep hashing fast at chain-verification time. */ export function hashProof(proof: SpatialProof): string { const canonical = [ proof.id, proof.robotId, proof.timestamp.toString(), proof.nonce, proof.signature, proof.passed.toString(), proof.metrics.composite.toFixed(6), proof.memoryMerkleRoot, proof.prevProofHash ?? 'genesis', ].join('|'); return sha256(canonical); } /** * ProofChain — append-only chain of spatially-linked proofs. * * Maintains an ordered array of SpatialProofs where each proof * contains the SHA-256 hash of its predecessor. The chain is * validated by walking from genesis to tip and checking every link. */ export class ProofChain { private readonly chain: SpatialProof[] = []; constructor(existingChain?: readonly SpatialProof[], options: { skipValidate?: boolean } = {}) { if (existingChain && existingChain.length > 0) { for (const proof of existingChain) { this.chain.push(proof); } // Validate-on-load: reject chains tampered at rest (forged signatures, broken // hash linkage, temporal reorder, robotId mismatch). skipValidate is an internal // escape hatch for test fixtures that build deliberately-broken chains. if (!options.skipValidate) { const result = this.verify(); if (!result.valid) { throw new Error( `Chain validation failed on load: ${result.reason ?? 'unknown reason'}`, ); } } } } /** Number of proofs in the chain */ get length(): number { return this.chain.length; } /** Get the hash of the latest proof (chain tip), or undefined for empty chain */ get tipHash(): string | undefined { if (this.chain.length === 0) return undefined; return hashProof(this.chain[this.chain.length - 1]!); } /** Get the prevProofHash that should be set on the next proof to be appended */ get nextPrevHash(): string | undefined { return this.tipHash; } /** Get all proofs in chain order (defensive copy) */ getProofs(): readonly SpatialProof[] { return [...this.chain]; } /** Get a proof by index */ getProof(index: number): SpatialProof | undefined { return this.chain[index]; } /** * Append a proof to the chain. * * The proof's prevProofHash must match the hash of the current tip. * For the first proof (genesis), prevProofHash must be undefined. * * @throws Error if prevProofHash doesn't match the chain tip */ append(proof: SpatialProof): void { if (this.chain.length === 0) { // Genesis proof — prevProofHash must be undefined or absent if (proof.prevProofHash !== undefined) { throw new Error( 'Genesis proof must not have prevProofHash. ' + 'This is the first proof in the chain.', ); } } else { // Subsequent proof — prevProofHash must match tip const expectedHash = this.tipHash!; if (proof.prevProofHash !== expectedHash) { throw new Error( `Chain link broken: proof.prevProofHash (${proof.prevProofHash ?? 'undefined'}) ` + `does not match chain tip hash (${expectedHash}). ` + 'The proof cannot be appended.', ); } } this.chain.push(proof); } /** * Verify the entire chain's integrity. * * Walks from genesis to tip, checking: * 1. Genesis proof has no prevProofHash * 2. Each subsequent proof's prevProofHash == SHA-256(previous proof) * 3. Timestamps are monotonically non-decreasing * 4. All proofs belong to the same robotId (if chain is non-empty) * * Returns a detailed verification result including integrity score. */ verify(): ProofChainVerification { if (this.chain.length === 0) { return { valid: true, chainLength: 0, chainIntegrity: 1.0, brokenLinks: [], }; } const brokenLinks: number[] = []; let validLinks = 0; const totalLinks = this.chain.length; // includes genesis as a "link" // Check genesis const genesis = this.chain[0]!; if (genesis.prevProofHash !== undefined) { brokenLinks.push(0); } else { validLinks++; } // Check robotId consistency const robotId = genesis.robotId; // Walk the chain for (let i = 1; i < this.chain.length; i++) { const prev = this.chain[i - 1]!; const curr = this.chain[i]!; const expectedHash = hashProof(prev); let linkValid = true; // Check hash linkage if (curr.prevProofHash !== expectedHash) { linkValid = false; } // Check temporal ordering if (curr.timestamp < prev.timestamp) { linkValid = false; } // Check robotId consistency if (curr.robotId !== robotId) { linkValid = false; } if (linkValid) { validLinks++; } else { brokenLinks.push(i); } } const chainIntegrity = totalLinks > 0 ? validLinks / totalLinks : 1.0; const valid = brokenLinks.length === 0; return { valid, chainLength: this.chain.length, chainIntegrity, brokenLinks, reason: valid ? undefined : `Chain has ${brokenLinks.length} broken link(s) at indices: [${brokenLinks.join(', ')}]`, }; } /** * Verify a sub-range of the chain. * Useful for incremental verification of long chains. */ verifyRange(startIndex: number, endIndex: number): ProofChainVerification { if (startIndex < 0 || endIndex > this.chain.length || startIndex >= endIndex) { throw new Error( `Invalid range [${startIndex}, ${endIndex}) for chain of length ${this.chain.length}`, ); } const brokenLinks: number[] = []; let validLinks = 0; for (let i = startIndex; i < endIndex; i++) { const curr = this.chain[i]!; if (i === 0) { // Genesis check if (curr.prevProofHash !== undefined) { brokenLinks.push(i); } else { validLinks++; } } else { const prev = this.chain[i - 1]!; const expectedHash = hashProof(prev); if (curr.prevProofHash === expectedHash && curr.timestamp >= prev.timestamp) { validLinks++; } else { brokenLinks.push(i); } } } const rangeLength = endIndex - startIndex; const chainIntegrity = rangeLength > 0 ? validLinks / rangeLength : 1.0; const valid = brokenLinks.length === 0; return { valid, chainLength: rangeLength, chainIntegrity, brokenLinks, reason: valid ? undefined : `Range has ${brokenLinks.length} broken link(s) at indices: [${brokenLinks.join(', ')}]`, }; } /** * Export the chain as an array of proof hashes (lightweight summary). * Useful for transmitting chain structure without full proof payloads. */ exportHashChain(): readonly string[] { return this.chain.map(hashProof); } } /** * Convenience: verify an array of proofs without constructing a ProofChain. * Useful when you receive a chain from an external source and want to validate it. */ export function verifyProofChain(proofs: readonly SpatialProof[]): ProofChainVerification { const chain = new ProofChain(proofs, { skipValidate: true }); return chain.verify(); }