/** * Block verification: local spot-checks and layered remote verification. */ import type { BlockStore } from "../ipfs.js"; import { type VerificationConfig, type LayerResult, type LayeredVerificationResult, DEFAULT_VERIFICATION_CONFIG, } from "./types.js"; import { generateChallenge, computeEpoch } from "./challenge-response/challenge-generator.js"; import { verifyResponse } from "./challenge-response/challenge-verifier.js"; import type { ChallengeTransport } from "./challenge-response/transport.js"; /** Optional challenge transport integration for L2/L3 verification. */ export interface ChallengeIntegrationOptions { transport: ChallengeTransport; challengerDid: string; } export interface VerificationResult { checked: number; available: number; missing: string[]; } export class BlockVerifier { private blockStore: BlockStore; constructor(blockStore: BlockStore) { this.blockStore = blockStore; } /** * Verify that a random sample of blocks are available in our blockstore. * If sampleSize >= array length, checks all blocks. */ async verifyBlockAvailability( blockCids: string[], sampleSize: number = 5, ): Promise { if (blockCids.length === 0) { return { checked: 0, available: 0, missing: [] }; } // Sample randomly, or check all if sample >= total const toCheck = sampleSize >= blockCids.length ? [...blockCids] : this.randomSample(blockCids, sampleSize); const missing: string[] = []; let available = 0; for (const cid of toCheck) { const has = await this.blockStore.hasBlock(cid); if (has) { available++; } else { missing.push(cid); } } return { checked: toCheck.length, available, missing, }; } private randomSample(arr: string[], size: number): string[] { const shuffled = [...arr]; // Fisher-Yates partial shuffle for (let i = shuffled.length - 1; i > 0 && i >= shuffled.length - size; i--) { const j = Math.floor(Math.random() * (i + 1)); [shuffled[i], shuffled[j]] = [shuffled[j]!, shuffled[i]!]; } return shuffled.slice(-size); } } /** * Layered verification. * * Layer 0: Commit root — verify local root matches source PDS head via standard atproto API. * Layer 1: Local block sampling — verify we actually have the blocks we claim to store. * Layer 2: Block-sample challenge — p2pds peer-to-peer challenge-response. * Layer 3: MST path proof challenge — p2pds peer-to-peer challenge-response. */ export class RemoteVerifier { private config: VerificationConfig; private blockStore: BlockStore; private fetchFn: typeof fetch; private challengeOptions: ChallengeIntegrationOptions | null; constructor( blockStore: BlockStore, config?: Partial, fetchFn?: typeof fetch, challengeOptions?: ChallengeIntegrationOptions, ) { this.blockStore = blockStore; this.config = { ...DEFAULT_VERIFICATION_CONFIG, ...config }; this.fetchFn = fetchFn ?? fetch; this.challengeOptions = challengeOptions ?? null; } /** * Run all verification layers for a replicated DID. */ async verifyPeer( did: string, pdsEndpoint: string, rootCid: string | null, blockCids: string[], recordPaths?: string[], ): Promise { const layers: LayerResult[] = []; // Layer 0: Commit root — verify local root matches source PDS head layers.push(await this.verifyCommitRoot(did, pdsEndpoint, rootCid)); // Layer 1: Local block availability sampling if (blockCids.length > 0) { layers.push(await this.verifyLocalBlocks(blockCids)); } // Layer 2: Block-sample challenge (or stub if no transport) if (this.challengeOptions && rootCid && blockCids.length > 0) { layers.push( await this.verifyViaBlockChallenge( did, pdsEndpoint, rootCid, blockCids, ), ); } else { layers.push({ layer: 2, name: "block-sample", passed: true, checked: 0, available: 0, missing: [], error: "not implemented: requires challenge transport", durationMs: 0, }); } // Layer 3: MST path proof challenge (or stub if no transport/paths) if ( this.challengeOptions && rootCid && recordPaths && recordPaths.length > 0 ) { layers.push( await this.verifyViaMstChallenge( did, pdsEndpoint, rootCid, recordPaths, ), ); } else { layers.push({ layer: 3, name: "mst-proof", passed: true, checked: 0, available: 0, missing: [], error: this.challengeOptions ? "no record paths available for MST challenge" : "not implemented: requires challenge transport", durationMs: 0, }); } return { did, pdsEndpoint, timestamp: new Date().toISOString(), layers, overallPassed: layers.every((l) => l.passed), }; } /** * Layer 2: Send a block-sample challenge via ChallengeTransport. */ private async verifyViaBlockChallenge( did: string, pdsEndpoint: string, rootCid: string, blockCids: string[], ): Promise { const start = Date.now(); try { const epoch = computeEpoch( Date.now(), this.config.challengeEpochDurationMs, ); const challenge = generateChallenge({ challengerDid: this.challengeOptions!.challengerDid, targetDid: did, subjectDid: did, commitCid: rootCid, availableRecordPaths: [], availableBlockCids: blockCids, challengeType: "block-sample", epoch, config: { blockSampleSize: this.config.challengeBlockSampleSize, expirationMs: this.config.challengeExpirationMs, }, }); const response = await this.challengeOptions!.transport.sendChallenge( pdsEndpoint, challenge, ); const result = await verifyResponse( challenge, response, this.blockStore, ); return { layer: 2, name: "block-sample", passed: result.passed, checked: challenge.blockCids?.length ?? 0, available: result.blockResults?.filter( (r) => r.available && r.prefixValid, ).length ?? 0, missing: result.blockResults ?.filter((r) => !r.available || !r.prefixValid) .map((r) => r.cid) ?? [], durationMs: Date.now() - start, }; } catch (err) { return { layer: 2, name: "block-sample", passed: false, checked: 0, available: 0, missing: [], error: err instanceof Error ? err.message : String(err), durationMs: Date.now() - start, }; } } /** * Layer 3: Send an MST proof challenge via ChallengeTransport. */ private async verifyViaMstChallenge( did: string, pdsEndpoint: string, rootCid: string, recordPaths: string[], ): Promise { const start = Date.now(); try { const epoch = computeEpoch( Date.now(), this.config.challengeEpochDurationMs, ); const challenge = generateChallenge({ challengerDid: this.challengeOptions!.challengerDid, targetDid: did, subjectDid: did, commitCid: rootCid, availableRecordPaths: recordPaths, challengeType: "mst-proof", epoch, config: { recordCount: this.config.challengeRecordCount, expirationMs: this.config.challengeExpirationMs, }, }); const response = await this.challengeOptions!.transport.sendChallenge( pdsEndpoint, challenge, ); const result = await verifyResponse( challenge, response, this.blockStore, ); return { layer: 3, name: "mst-proof", passed: result.passed, checked: challenge.recordPaths.length, available: result.mstResults?.filter((r) => r.valid).length ?? 0, missing: result.mstResults ?.filter((r) => !r.valid) .map((r) => r.recordPath) ?? [], durationMs: Date.now() - start, }; } catch (err) { return { layer: 3, name: "mst-proof", passed: false, checked: 0, available: 0, missing: [], error: err instanceof Error ? err.message : String(err), durationMs: Date.now() - start, }; } } /** * Layer 0: Verify local commit root matches the source PDS head. * Uses standard atproto com.atproto.sync.getHead to get the current rev. */ private async verifyCommitRoot( did: string, pdsEndpoint: string, localRootCid: string | null, ): Promise { const start = Date.now(); if (!localRootCid) { return { layer: 0, name: "commit-root", passed: false, checked: 1, available: 0, missing: [], error: "no local root CID available", durationMs: Date.now() - start, }; } try { const url = `${pdsEndpoint}/xrpc/com.atproto.sync.getHead?did=${encodeURIComponent(did)}`; const res = await this.fetchFn(url); if (res.status !== 200) { return { layer: 0, name: "commit-root", passed: false, checked: 1, available: 0, missing: [localRootCid], error: `getHead returned ${res.status}`, durationMs: Date.now() - start, }; } const data = await res.json() as { root: string }; const passed = data.root === localRootCid; return { layer: 0, name: "commit-root", passed, checked: 1, available: passed ? 1 : 0, missing: passed ? [] : [localRootCid], error: passed ? undefined : `local root ${localRootCid} != remote ${data.root}`, durationMs: Date.now() - start, }; } catch (err) { return { layer: 0, name: "commit-root", passed: false, checked: 1, available: 0, missing: [localRootCid], error: err instanceof Error ? err.message : String(err), durationMs: Date.now() - start, }; } } /** * Layer 1: Verify a random sample of tracked blocks exist in our local blockstore. * Confirms we actually hold the data we claim to replicate. */ private async verifyLocalBlocks( cids: string[], ): Promise { const start = Date.now(); const sampleSize = Math.min(this.config.raslSampleSize, cids.length); const sample = sampleSize >= cids.length ? [...cids] : this.randomSample(cids, sampleSize); const missing: string[] = []; let available = 0; for (const cid of sample) { const has = await this.blockStore.hasBlock(cid); if (has) { available++; } else { missing.push(cid); } } return { layer: 1, name: "local-blocks", passed: missing.length === 0, checked: sample.length, available, missing, durationMs: Date.now() - start, }; } private randomSample(arr: string[], size: number): string[] { const shuffled = [...arr]; for ( let i = shuffled.length - 1; i > 0 && i >= shuffled.length - size; i-- ) { const j = Math.floor(Math.random() * (i + 1)); [shuffled[i], shuffled[j]] = [shuffled[j]!, shuffled[i]!]; } return shuffled.slice(-size); } }