atproto user agency toolkit for individuals and groups
at main 442 lines 11 kB view raw
1/** 2 * Block verification: local spot-checks and layered remote verification. 3 */ 4 5import type { BlockStore } from "../ipfs.js"; 6import { 7 type VerificationConfig, 8 type LayerResult, 9 type LayeredVerificationResult, 10 DEFAULT_VERIFICATION_CONFIG, 11} from "./types.js"; 12import { generateChallenge, computeEpoch } from "./challenge-response/challenge-generator.js"; 13import { verifyResponse } from "./challenge-response/challenge-verifier.js"; 14import type { ChallengeTransport } from "./challenge-response/transport.js"; 15 16/** Optional challenge transport integration for L2/L3 verification. */ 17export interface ChallengeIntegrationOptions { 18 transport: ChallengeTransport; 19 challengerDid: string; 20} 21 22export interface VerificationResult { 23 checked: number; 24 available: number; 25 missing: string[]; 26} 27 28export class BlockVerifier { 29 private blockStore: BlockStore; 30 31 constructor(blockStore: BlockStore) { 32 this.blockStore = blockStore; 33 } 34 35 /** 36 * Verify that a random sample of blocks are available in our blockstore. 37 * If sampleSize >= array length, checks all blocks. 38 */ 39 async verifyBlockAvailability( 40 blockCids: string[], 41 sampleSize: number = 5, 42 ): Promise<VerificationResult> { 43 if (blockCids.length === 0) { 44 return { checked: 0, available: 0, missing: [] }; 45 } 46 47 // Sample randomly, or check all if sample >= total 48 const toCheck = 49 sampleSize >= blockCids.length 50 ? [...blockCids] 51 : this.randomSample(blockCids, sampleSize); 52 53 const missing: string[] = []; 54 let available = 0; 55 56 for (const cid of toCheck) { 57 const has = await this.blockStore.hasBlock(cid); 58 if (has) { 59 available++; 60 } else { 61 missing.push(cid); 62 } 63 } 64 65 return { 66 checked: toCheck.length, 67 available, 68 missing, 69 }; 70 } 71 72 private randomSample(arr: string[], size: number): string[] { 73 const shuffled = [...arr]; 74 // Fisher-Yates partial shuffle 75 for (let i = shuffled.length - 1; i > 0 && i >= shuffled.length - size; i--) { 76 const j = Math.floor(Math.random() * (i + 1)); 77 [shuffled[i], shuffled[j]] = [shuffled[j]!, shuffled[i]!]; 78 } 79 return shuffled.slice(-size); 80 } 81} 82 83/** 84 * Layered verification. 85 * 86 * Layer 0: Commit root — verify local root matches source PDS head via standard atproto API. 87 * Layer 1: Local block sampling — verify we actually have the blocks we claim to store. 88 * Layer 2: Block-sample challenge — p2pds peer-to-peer challenge-response. 89 * Layer 3: MST path proof challenge — p2pds peer-to-peer challenge-response. 90 */ 91export class RemoteVerifier { 92 private config: VerificationConfig; 93 private blockStore: BlockStore; 94 private fetchFn: typeof fetch; 95 private challengeOptions: ChallengeIntegrationOptions | null; 96 97 constructor( 98 blockStore: BlockStore, 99 config?: Partial<VerificationConfig>, 100 fetchFn?: typeof fetch, 101 challengeOptions?: ChallengeIntegrationOptions, 102 ) { 103 this.blockStore = blockStore; 104 this.config = { ...DEFAULT_VERIFICATION_CONFIG, ...config }; 105 this.fetchFn = fetchFn ?? fetch; 106 this.challengeOptions = challengeOptions ?? null; 107 } 108 109 /** 110 * Run all verification layers for a replicated DID. 111 */ 112 async verifyPeer( 113 did: string, 114 pdsEndpoint: string, 115 rootCid: string | null, 116 blockCids: string[], 117 recordPaths?: string[], 118 ): Promise<LayeredVerificationResult> { 119 const layers: LayerResult[] = []; 120 121 // Layer 0: Commit root — verify local root matches source PDS head 122 layers.push(await this.verifyCommitRoot(did, pdsEndpoint, rootCid)); 123 124 // Layer 1: Local block availability sampling 125 if (blockCids.length > 0) { 126 layers.push(await this.verifyLocalBlocks(blockCids)); 127 } 128 129 // Layer 2: Block-sample challenge (or stub if no transport) 130 if (this.challengeOptions && rootCid && blockCids.length > 0) { 131 layers.push( 132 await this.verifyViaBlockChallenge( 133 did, 134 pdsEndpoint, 135 rootCid, 136 blockCids, 137 ), 138 ); 139 } else { 140 layers.push({ 141 layer: 2, 142 name: "block-sample", 143 passed: true, 144 checked: 0, 145 available: 0, 146 missing: [], 147 error: "not implemented: requires challenge transport", 148 durationMs: 0, 149 }); 150 } 151 152 // Layer 3: MST path proof challenge (or stub if no transport/paths) 153 if ( 154 this.challengeOptions && 155 rootCid && 156 recordPaths && 157 recordPaths.length > 0 158 ) { 159 layers.push( 160 await this.verifyViaMstChallenge( 161 did, 162 pdsEndpoint, 163 rootCid, 164 recordPaths, 165 ), 166 ); 167 } else { 168 layers.push({ 169 layer: 3, 170 name: "mst-proof", 171 passed: true, 172 checked: 0, 173 available: 0, 174 missing: [], 175 error: this.challengeOptions 176 ? "no record paths available for MST challenge" 177 : "not implemented: requires challenge transport", 178 durationMs: 0, 179 }); 180 } 181 182 return { 183 did, 184 pdsEndpoint, 185 timestamp: new Date().toISOString(), 186 layers, 187 overallPassed: layers.every((l) => l.passed), 188 }; 189 } 190 191 /** 192 * Layer 2: Send a block-sample challenge via ChallengeTransport. 193 */ 194 private async verifyViaBlockChallenge( 195 did: string, 196 pdsEndpoint: string, 197 rootCid: string, 198 blockCids: string[], 199 ): Promise<LayerResult> { 200 const start = Date.now(); 201 try { 202 const epoch = computeEpoch( 203 Date.now(), 204 this.config.challengeEpochDurationMs, 205 ); 206 207 const challenge = generateChallenge({ 208 challengerDid: this.challengeOptions!.challengerDid, 209 targetDid: did, 210 subjectDid: did, 211 commitCid: rootCid, 212 availableRecordPaths: [], 213 availableBlockCids: blockCids, 214 challengeType: "block-sample", 215 epoch, 216 config: { 217 blockSampleSize: this.config.challengeBlockSampleSize, 218 expirationMs: this.config.challengeExpirationMs, 219 }, 220 }); 221 222 const response = 223 await this.challengeOptions!.transport.sendChallenge( 224 pdsEndpoint, 225 challenge, 226 ); 227 const result = await verifyResponse( 228 challenge, 229 response, 230 this.blockStore, 231 ); 232 233 return { 234 layer: 2, 235 name: "block-sample", 236 passed: result.passed, 237 checked: challenge.blockCids?.length ?? 0, 238 available: 239 result.blockResults?.filter( 240 (r) => r.available && r.prefixValid, 241 ).length ?? 0, 242 missing: 243 result.blockResults 244 ?.filter((r) => !r.available || !r.prefixValid) 245 .map((r) => r.cid) ?? [], 246 durationMs: Date.now() - start, 247 }; 248 } catch (err) { 249 return { 250 layer: 2, 251 name: "block-sample", 252 passed: false, 253 checked: 0, 254 available: 0, 255 missing: [], 256 error: err instanceof Error ? err.message : String(err), 257 durationMs: Date.now() - start, 258 }; 259 } 260 } 261 262 /** 263 * Layer 3: Send an MST proof challenge via ChallengeTransport. 264 */ 265 private async verifyViaMstChallenge( 266 did: string, 267 pdsEndpoint: string, 268 rootCid: string, 269 recordPaths: string[], 270 ): Promise<LayerResult> { 271 const start = Date.now(); 272 try { 273 const epoch = computeEpoch( 274 Date.now(), 275 this.config.challengeEpochDurationMs, 276 ); 277 278 const challenge = generateChallenge({ 279 challengerDid: this.challengeOptions!.challengerDid, 280 targetDid: did, 281 subjectDid: did, 282 commitCid: rootCid, 283 availableRecordPaths: recordPaths, 284 challengeType: "mst-proof", 285 epoch, 286 config: { 287 recordCount: this.config.challengeRecordCount, 288 expirationMs: this.config.challengeExpirationMs, 289 }, 290 }); 291 292 const response = 293 await this.challengeOptions!.transport.sendChallenge( 294 pdsEndpoint, 295 challenge, 296 ); 297 const result = await verifyResponse( 298 challenge, 299 response, 300 this.blockStore, 301 ); 302 303 return { 304 layer: 3, 305 name: "mst-proof", 306 passed: result.passed, 307 checked: challenge.recordPaths.length, 308 available: 309 result.mstResults?.filter((r) => r.valid).length ?? 0, 310 missing: 311 result.mstResults 312 ?.filter((r) => !r.valid) 313 .map((r) => r.recordPath) ?? [], 314 durationMs: Date.now() - start, 315 }; 316 } catch (err) { 317 return { 318 layer: 3, 319 name: "mst-proof", 320 passed: false, 321 checked: 0, 322 available: 0, 323 missing: [], 324 error: err instanceof Error ? err.message : String(err), 325 durationMs: Date.now() - start, 326 }; 327 } 328 } 329 330 /** 331 * Layer 0: Verify local commit root matches the source PDS head. 332 * Uses standard atproto com.atproto.sync.getHead to get the current rev. 333 */ 334 private async verifyCommitRoot( 335 did: string, 336 pdsEndpoint: string, 337 localRootCid: string | null, 338 ): Promise<LayerResult> { 339 const start = Date.now(); 340 if (!localRootCid) { 341 return { 342 layer: 0, 343 name: "commit-root", 344 passed: false, 345 checked: 1, 346 available: 0, 347 missing: [], 348 error: "no local root CID available", 349 durationMs: Date.now() - start, 350 }; 351 } 352 try { 353 const url = `${pdsEndpoint}/xrpc/com.atproto.sync.getHead?did=${encodeURIComponent(did)}`; 354 const res = await this.fetchFn(url); 355 if (res.status !== 200) { 356 return { 357 layer: 0, 358 name: "commit-root", 359 passed: false, 360 checked: 1, 361 available: 0, 362 missing: [localRootCid], 363 error: `getHead returned ${res.status}`, 364 durationMs: Date.now() - start, 365 }; 366 } 367 const data = await res.json() as { root: string }; 368 const passed = data.root === localRootCid; 369 return { 370 layer: 0, 371 name: "commit-root", 372 passed, 373 checked: 1, 374 available: passed ? 1 : 0, 375 missing: passed ? [] : [localRootCid], 376 error: passed ? undefined : `local root ${localRootCid} != remote ${data.root}`, 377 durationMs: Date.now() - start, 378 }; 379 } catch (err) { 380 return { 381 layer: 0, 382 name: "commit-root", 383 passed: false, 384 checked: 1, 385 available: 0, 386 missing: [localRootCid], 387 error: err instanceof Error ? err.message : String(err), 388 durationMs: Date.now() - start, 389 }; 390 } 391 } 392 393 /** 394 * Layer 1: Verify a random sample of tracked blocks exist in our local blockstore. 395 * Confirms we actually hold the data we claim to replicate. 396 */ 397 private async verifyLocalBlocks( 398 cids: string[], 399 ): Promise<LayerResult> { 400 const start = Date.now(); 401 const sampleSize = Math.min(this.config.raslSampleSize, cids.length); 402 const sample = 403 sampleSize >= cids.length 404 ? [...cids] 405 : this.randomSample(cids, sampleSize); 406 407 const missing: string[] = []; 408 let available = 0; 409 410 for (const cid of sample) { 411 const has = await this.blockStore.hasBlock(cid); 412 if (has) { 413 available++; 414 } else { 415 missing.push(cid); 416 } 417 } 418 419 return { 420 layer: 1, 421 name: "local-blocks", 422 passed: missing.length === 0, 423 checked: sample.length, 424 available, 425 missing, 426 durationMs: Date.now() - start, 427 }; 428 } 429 430 private randomSample(arr: string[], size: number): string[] { 431 const shuffled = [...arr]; 432 for ( 433 let i = shuffled.length - 1; 434 i > 0 && i >= shuffled.length - size; 435 i-- 436 ) { 437 const j = Math.floor(Math.random() * (i + 1)); 438 [shuffled[i], shuffled[j]] = [shuffled[j]!, shuffled[i]!]; 439 } 440 return shuffled.slice(-size); 441 } 442}