atproto user agency toolkit for individuals and groups
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}