atproto user agency toolkit for individuals and groups
8
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add challenge-response proof-of-storage verification protocol

Implements a transport-agnostic challenge-response system for proving
peers still hold specific records. Three challenge types (mst-proof,
block-sample, combined) with deterministic generation, SQLite-backed
history/reliability tracking, and policy-driven scheduling.

Replaces L2/L3 verification stubs with real challenge-based verification
when a ChallengeTransport is available. Adds HTTP transport adapter,
three new XRPC routes, and record path tracking for challenge generation.

+2650 -24
+62
src/index.ts
··· 11 11 import * as sync from "./xrpc/sync.js"; 12 12 import * as repo from "./xrpc/repo.js"; 13 13 import * as server from "./xrpc/server.js"; 14 + import { respondToChallenge } from "./replication/challenge-response/challenge-responder.js"; 15 + import { serializeResponse } from "./replication/challenge-response/http-transport.js"; 16 + import type { StorageChallenge } from "./replication/challenge-response/types.js"; 14 17 15 18 const VERSION = "0.1.0"; 16 19 ··· 419 422 console.error("Manual sync error:", err); 420 423 }); 421 424 return c.json({ message: "Sync triggered" }); 425 + }); 426 + 427 + // ============================================ 428 + // Challenge-response verification 429 + // ============================================ 430 + 431 + app.post("/xrpc/org.p2pds.verification.challenge", async (c) => { 432 + if (!blockStore) { 433 + return c.json( 434 + { error: "BlockStoreNotAvailable", message: "Block store is not available" }, 435 + 400, 436 + ); 437 + } 438 + 439 + const challenge = (await c.req.json()) as StorageChallenge; 440 + const response = await respondToChallenge( 441 + challenge, 442 + blockStore, 443 + config.DID, 444 + ); 445 + return c.json(serializeResponse(response)); 446 + }); 447 + 448 + app.get("/xrpc/org.p2pds.verification.challengeHistory", requireAuth, (c) => { 449 + if (!replicationManager) { 450 + return c.json( 451 + { error: "ReplicationNotEnabled", message: "Replication is not enabled" }, 452 + 400, 453 + ); 454 + } 455 + const targetDid = c.req.query("targetDid"); 456 + const subjectDid = c.req.query("subjectDid"); 457 + if (!targetDid) { 458 + return c.json( 459 + { error: "MissingParameter", message: "targetDid is required" }, 460 + 400, 461 + ); 462 + } 463 + return c.json({ 464 + history: replicationManager.getChallengeResults( 465 + targetDid, 466 + subjectDid ?? undefined, 467 + ), 468 + }); 469 + }); 470 + 471 + app.get("/xrpc/org.p2pds.verification.peerReliability", requireAuth, (c) => { 472 + if (!replicationManager) { 473 + return c.json( 474 + { error: "ReplicationNotEnabled", message: "Replication is not enabled" }, 475 + 400, 476 + ); 477 + } 478 + const peerDid = c.req.query("peerDid"); 479 + return c.json({ 480 + reliability: replicationManager.getPeerReliability( 481 + peerDid ?? undefined, 482 + ), 483 + }); 422 484 }); 423 485 424 486 return app;
+156
src/replication/challenge-response/challenge-generator.ts
··· 1 + /** 2 + * Deterministic challenge generation. 3 + * 4 + * Core function is pure: same inputs produce the same challenge. 5 + * Uses node:crypto SHA-256 for seed → PRNG → record/block selection. 6 + */ 7 + 8 + import { createHash, randomBytes } from "node:crypto"; 9 + import type { StorageChallenge, ChallengeConfig, ChallengeType } from "./types.js"; 10 + import { CHALLENGE_PROTOCOL_VERSION, DEFAULT_CHALLENGE_CONFIG } from "./types.js"; 11 + 12 + /** 13 + * Compute the current epoch from a timestamp and epoch duration. 14 + */ 15 + export function computeEpoch( 16 + timestampMs: number, 17 + epochDurationMs: number, 18 + ): number { 19 + return Math.floor(timestampMs / epochDurationMs); 20 + } 21 + 22 + /** 23 + * Generate a deterministic challenge ID from inputs. 24 + */ 25 + export function generateChallengeId( 26 + challengerDid: string, 27 + targetDid: string, 28 + subjectDid: string, 29 + epoch: number, 30 + nonce: string, 31 + ): string { 32 + const seed = `${challengerDid}:${targetDid}:${subjectDid}:${epoch}:${nonce}`; 33 + return createHash("sha256").update(seed).digest("hex").slice(0, 32); 34 + } 35 + 36 + /** 37 + * Create a deterministic PRNG from a seed string. 38 + * Uses SHA-256 hash chain for reproducible selection. 39 + */ 40 + function createPrng(seed: string): () => number { 41 + let state = createHash("sha256").update(seed).digest(); 42 + let offset = 0; 43 + 44 + return () => { 45 + if (offset + 4 > state.length) { 46 + state = createHash("sha256").update(state).digest(); 47 + offset = 0; 48 + } 49 + const value = state.readUInt32BE(offset) / 0xffffffff; 50 + offset += 4; 51 + return value; 52 + }; 53 + } 54 + 55 + /** 56 + * Select items deterministically from a pool using a seeded PRNG. 57 + */ 58 + function deterministicSample<T>( 59 + items: T[], 60 + count: number, 61 + prng: () => number, 62 + ): T[] { 63 + if (items.length <= count) return [...items]; 64 + 65 + const selected: T[] = []; 66 + const pool = [...items]; 67 + 68 + for (let i = 0; i < count && pool.length > 0; i++) { 69 + const idx = Math.floor(prng() * pool.length); 70 + selected.push(pool[idx]!); 71 + pool.splice(idx, 1); 72 + } 73 + 74 + return selected; 75 + } 76 + 77 + /** 78 + * Generate a storage challenge. 79 + * 80 + * Pure function: same inputs produce the same challenge (deterministic). 81 + * The nonce is the only non-deterministic input — generate it externally 82 + * for auditability or pass a fixed value for testing. 83 + */ 84 + export function generateChallenge(params: { 85 + challengerDid: string; 86 + targetDid: string; 87 + subjectDid: string; 88 + commitCid: string; 89 + availableRecordPaths: string[]; 90 + availableBlockCids?: string[]; 91 + challengeType: ChallengeType; 92 + epoch: number; 93 + nonce?: string; 94 + config?: Partial<ChallengeConfig>; 95 + }): StorageChallenge { 96 + const config = { ...DEFAULT_CHALLENGE_CONFIG, ...params.config }; 97 + const nonce = params.nonce ?? randomBytes(16).toString("hex"); 98 + 99 + const id = generateChallengeId( 100 + params.challengerDid, 101 + params.targetDid, 102 + params.subjectDid, 103 + params.epoch, 104 + nonce, 105 + ); 106 + 107 + const prngSeed = `${id}:selection`; 108 + const prng = createPrng(prngSeed); 109 + 110 + // Select records for MST proof challenges 111 + let recordPaths: string[] = []; 112 + if ( 113 + params.challengeType === "mst-proof" || 114 + params.challengeType === "combined" 115 + ) { 116 + recordPaths = deterministicSample( 117 + params.availableRecordPaths, 118 + config.recordCount, 119 + prng, 120 + ); 121 + } 122 + 123 + // Select blocks for block-sample challenges 124 + let blockCids: string[] | undefined; 125 + if ( 126 + params.challengeType === "block-sample" || 127 + params.challengeType === "combined" 128 + ) { 129 + if (params.availableBlockCids && params.availableBlockCids.length > 0) { 130 + blockCids = deterministicSample( 131 + params.availableBlockCids, 132 + config.blockSampleSize, 133 + prng, 134 + ); 135 + } 136 + } 137 + 138 + const now = new Date(); 139 + const expiresAt = new Date(now.getTime() + config.expirationMs); 140 + 141 + return { 142 + id, 143 + version: CHALLENGE_PROTOCOL_VERSION, 144 + challengerDid: params.challengerDid, 145 + targetDid: params.targetDid, 146 + subjectDid: params.subjectDid, 147 + commitCid: params.commitCid, 148 + recordPaths, 149 + challengeType: params.challengeType, 150 + blockCids, 151 + epoch: params.epoch, 152 + nonce, 153 + issuedAt: now.toISOString(), 154 + expiresAt: expiresAt.toISOString(), 155 + }; 156 + }
+96
src/replication/challenge-response/challenge-responder.ts
··· 1 + /** 2 + * Challenge responder: produce a response given a challenge + BlockStore. 3 + * 4 + * For MST challenges: generates MST proofs via generateMstProof. 5 + * For block-sample challenges: checks block availability and returns prefixes. 6 + */ 7 + 8 + import type { BlockStore } from "../../ipfs.js"; 9 + import type { 10 + StorageChallenge, 11 + StorageChallengeResponse, 12 + BlockResult, 13 + } from "./types.js"; 14 + import { DEFAULT_CHALLENGE_CONFIG } from "./types.js"; 15 + import { generateMstProof, type MstProof } from "../mst-proof.js"; 16 + 17 + /** 18 + * Produce a challenge response. 19 + * 20 + * Calls generateMstProof for MST challenges, checks block availability 21 + * and provides prefixes for block-sample challenges. 22 + */ 23 + export async function respondToChallenge( 24 + challenge: StorageChallenge, 25 + blockStore: BlockStore, 26 + responderDid: string, 27 + config?: { blockPrefixLength?: number }, 28 + ): Promise<StorageChallengeResponse> { 29 + const prefixLength = 30 + config?.blockPrefixLength ?? DEFAULT_CHALLENGE_CONFIG.blockPrefixLength; 31 + 32 + let mstProofs: MstProof[] | undefined; 33 + let blockResults: BlockResult[] | undefined; 34 + 35 + // Handle MST proof challenges 36 + if ( 37 + challenge.challengeType === "mst-proof" || 38 + challenge.challengeType === "combined" 39 + ) { 40 + mstProofs = []; 41 + for (const recordPath of challenge.recordPaths) { 42 + try { 43 + const proof = await generateMstProof( 44 + blockStore, 45 + challenge.commitCid, 46 + recordPath, 47 + ); 48 + mstProofs.push(proof); 49 + } catch { 50 + // If we can't generate a proof (missing blocks), add an empty proof 51 + mstProofs.push({ 52 + commitBlock: { 53 + cid: challenge.commitCid, 54 + bytes: new Uint8Array(0), 55 + }, 56 + nodes: [], 57 + recordCid: null, 58 + found: false, 59 + }); 60 + } 61 + } 62 + } 63 + 64 + // Handle block-sample challenges 65 + if ( 66 + challenge.challengeType === "block-sample" || 67 + challenge.challengeType === "combined" 68 + ) { 69 + if (challenge.blockCids) { 70 + blockResults = []; 71 + for (const cid of challenge.blockCids) { 72 + const bytes = await blockStore.getBlock(cid); 73 + if (bytes) { 74 + blockResults.push({ 75 + cid, 76 + available: true, 77 + prefix: bytes.slice(0, prefixLength), 78 + }); 79 + } else { 80 + blockResults.push({ 81 + cid, 82 + available: false, 83 + }); 84 + } 85 + } 86 + } 87 + } 88 + 89 + return { 90 + challengeId: challenge.id, 91 + responderDid, 92 + mstProofs, 93 + blockResults, 94 + respondedAt: new Date().toISOString(), 95 + }; 96 + }
+663
src/replication/challenge-response/challenge-response.test.ts
··· 1 + import { describe, it, expect, beforeEach, afterEach } from "vitest"; 2 + import { mkdtempSync, rmSync } from "node:fs"; 3 + import { tmpdir } from "node:os"; 4 + import { join } from "node:path"; 5 + import Database from "better-sqlite3"; 6 + import { IpfsService } from "../../ipfs.js"; 7 + import { RepoManager } from "../../repo-manager.js"; 8 + import type { Config } from "../../config.js"; 9 + import { readCarWithRoot } from "@atproto/repo"; 10 + import { 11 + generateChallenge, 12 + computeEpoch, 13 + generateChallengeId, 14 + } from "./challenge-generator.js"; 15 + import { respondToChallenge } from "./challenge-responder.js"; 16 + import { verifyResponse } from "./challenge-verifier.js"; 17 + import type { StorageChallenge } from "./types.js"; 18 + 19 + function testConfig(dataDir: string): Config { 20 + return { 21 + DID: "did:plc:test123", 22 + HANDLE: "test.example.com", 23 + PDS_HOSTNAME: "test.example.com", 24 + AUTH_TOKEN: "test-auth-token", 25 + SIGNING_KEY: 26 + "0000000000000000000000000000000000000000000000000000000000000001", 27 + SIGNING_KEY_PUBLIC: 28 + "zQ3shP2mWsZYWgvZM9GJ3EvMfRXQJwuTh6BdXLvJB9gFhT3Lr", 29 + JWT_SECRET: "test-jwt-secret", 30 + PASSWORD_HASH: "$2a$10$test", 31 + DATA_DIR: dataDir, 32 + PORT: 3000, 33 + IPFS_ENABLED: true, 34 + IPFS_NETWORKING: false, 35 + REPLICATE_DIDS: [], 36 + FIREHOSE_URL: "wss://localhost/xrpc/com.atproto.sync.subscribeRepos", 37 + FIREHOSE_ENABLED: false, 38 + }; 39 + } 40 + 41 + describe("Challenge-Response Protocol", () => { 42 + let tmpDir: string; 43 + let db: InstanceType<typeof Database>; 44 + let ipfsService: IpfsService; 45 + let repoManager: RepoManager; 46 + 47 + beforeEach(async () => { 48 + tmpDir = mkdtempSync(join(tmpdir(), "challenge-test-")); 49 + const config = testConfig(tmpDir); 50 + 51 + db = new Database(join(tmpDir, "test.db")); 52 + ipfsService = new IpfsService({ 53 + blocksPath: join(tmpDir, "ipfs-blocks"), 54 + datastorePath: join(tmpDir, "ipfs-datastore"), 55 + networking: false, 56 + }); 57 + await ipfsService.start(); 58 + 59 + repoManager = new RepoManager(db, config); 60 + repoManager.init(undefined, ipfsService, ipfsService); 61 + }); 62 + 63 + afterEach(async () => { 64 + if (ipfsService.isRunning()) { 65 + await ipfsService.stop(); 66 + } 67 + db.close(); 68 + rmSync(tmpDir, { recursive: true, force: true }); 69 + }); 70 + 71 + async function getRepoRootCid(): Promise<string> { 72 + const carBytes = await repoManager.getRepoCar(); 73 + const { root, blocks } = await readCarWithRoot(carBytes); 74 + await ipfsService.putBlocks(blocks); 75 + return root.toString(); 76 + } 77 + 78 + async function getRecordPaths(): Promise<string[]> { 79 + const records = await repoManager.listRecords("app.bsky.feed.post", { 80 + limit: 100, 81 + }); 82 + return records.records.map((r) => { 83 + const rkey = r.uri.split("/").pop()!; 84 + return `app.bsky.feed.post/${rkey}`; 85 + }); 86 + } 87 + 88 + async function getBlockCids(): Promise<string[]> { 89 + const carBytes = await repoManager.getRepoCar(); 90 + const { blocks } = await readCarWithRoot(carBytes); 91 + const cids: string[] = []; 92 + const internalMap = ( 93 + blocks as unknown as { map: Map<string, Uint8Array> } 94 + ).map; 95 + if (internalMap) { 96 + for (const cid of internalMap.keys()) { 97 + cids.push(cid); 98 + } 99 + } 100 + return cids; 101 + } 102 + 103 + // ============================================== 104 + // Determinism tests 105 + // ============================================== 106 + 107 + describe("challenge generation", () => { 108 + it("same inputs produce the same challenge (deterministic)", () => { 109 + const params = { 110 + challengerDid: "did:plc:challenger", 111 + targetDid: "did:plc:target", 112 + subjectDid: "did:plc:subject", 113 + commitCid: 114 + "bafyreig6mxqmjlb7yjbhhhz6vqmtiw4kgipvhqoowdkggjlpzpd5tcm4", 115 + availableRecordPaths: [ 116 + "app.bsky.feed.post/abc", 117 + "app.bsky.feed.post/def", 118 + "app.bsky.feed.post/ghi", 119 + ], 120 + challengeType: "mst-proof" as const, 121 + epoch: 42, 122 + nonce: "fixed-nonce-for-test", 123 + }; 124 + 125 + const c1 = generateChallenge(params); 126 + const c2 = generateChallenge(params); 127 + 128 + expect(c1.id).toBe(c2.id); 129 + expect(c1.recordPaths).toEqual(c2.recordPaths); 130 + expect(c1.epoch).toBe(c2.epoch); 131 + expect(c1.nonce).toBe(c2.nonce); 132 + }); 133 + 134 + it("different nonces produce different challenges", () => { 135 + const base = { 136 + challengerDid: "did:plc:challenger", 137 + targetDid: "did:plc:target", 138 + subjectDid: "did:plc:subject", 139 + commitCid: 140 + "bafyreig6mxqmjlb7yjbhhhz6vqmtiw4kgipvhqoowdkggjlpzpd5tcm4", 141 + availableRecordPaths: [ 142 + "a/1", 143 + "a/2", 144 + "a/3", 145 + "a/4", 146 + "a/5", 147 + ], 148 + challengeType: "mst-proof" as const, 149 + epoch: 42, 150 + }; 151 + 152 + const c1 = generateChallenge({ ...base, nonce: "nonce-1" }); 153 + const c2 = generateChallenge({ ...base, nonce: "nonce-2" }); 154 + 155 + expect(c1.id).not.toBe(c2.id); 156 + }); 157 + 158 + it("computeEpoch returns correct epoch", () => { 159 + const epochDuration = 3600_000; // 1 hour 160 + expect(computeEpoch(0, epochDuration)).toBe(0); 161 + expect(computeEpoch(3599_999, epochDuration)).toBe(0); 162 + expect(computeEpoch(3600_000, epochDuration)).toBe(1); 163 + expect(computeEpoch(7200_000, epochDuration)).toBe(2); 164 + }); 165 + 166 + it("challenge ID is deterministic", () => { 167 + const id1 = generateChallengeId("a", "b", "c", 1, "nonce"); 168 + const id2 = generateChallengeId("a", "b", "c", 1, "nonce"); 169 + expect(id1).toBe(id2); 170 + 171 + const id3 = generateChallengeId("a", "b", "c", 2, "nonce"); 172 + expect(id1).not.toBe(id3); 173 + }); 174 + 175 + it("selects correct number of records and blocks", () => { 176 + const c = generateChallenge({ 177 + challengerDid: "did:plc:challenger", 178 + targetDid: "did:plc:target", 179 + subjectDid: "did:plc:subject", 180 + commitCid: 181 + "bafyreig6mxqmjlb7yjbhhhz6vqmtiw4kgipvhqoowdkggjlpzpd5tcm4", 182 + availableRecordPaths: [ 183 + "a/1", 184 + "a/2", 185 + "a/3", 186 + "a/4", 187 + "a/5", 188 + ], 189 + availableBlockCids: [ 190 + "cid1", 191 + "cid2", 192 + "cid3", 193 + "cid4", 194 + "cid5", 195 + "cid6", 196 + "cid7", 197 + ], 198 + challengeType: "combined", 199 + epoch: 1, 200 + nonce: "test", 201 + config: { recordCount: 2, blockSampleSize: 3 }, 202 + }); 203 + 204 + expect(c.recordPaths.length).toBe(2); 205 + expect(c.blockCids!.length).toBe(3); 206 + expect(c.challengeType).toBe("combined"); 207 + }); 208 + 209 + it("selects all items when pool is smaller than count", () => { 210 + const c = generateChallenge({ 211 + challengerDid: "did:plc:challenger", 212 + targetDid: "did:plc:target", 213 + subjectDid: "did:plc:subject", 214 + commitCid: "bafytest", 215 + availableRecordPaths: ["a/1"], 216 + challengeType: "mst-proof", 217 + epoch: 1, 218 + nonce: "test", 219 + config: { recordCount: 5 }, 220 + }); 221 + 222 + expect(c.recordPaths).toEqual(["a/1"]); 223 + }); 224 + 225 + it("block-sample challenge has no record paths", () => { 226 + const c = generateChallenge({ 227 + challengerDid: "did:plc:challenger", 228 + targetDid: "did:plc:target", 229 + subjectDid: "did:plc:subject", 230 + commitCid: "bafytest", 231 + availableRecordPaths: ["a/1", "a/2"], 232 + availableBlockCids: ["cid1", "cid2"], 233 + challengeType: "block-sample", 234 + epoch: 1, 235 + nonce: "test", 236 + }); 237 + 238 + expect(c.recordPaths).toEqual([]); 239 + expect(c.blockCids!.length).toBeGreaterThan(0); 240 + }); 241 + }); 242 + 243 + // ============================================== 244 + // MST proof challenge roundtrip 245 + // ============================================== 246 + 247 + describe("mst-proof challenge roundtrip", () => { 248 + it("generate → respond → verify passes for valid MST proof", async () => { 249 + for (let i = 0; i < 5; i++) { 250 + await repoManager.createRecord( 251 + "app.bsky.feed.post", 252 + undefined, 253 + { 254 + $type: "app.bsky.feed.post", 255 + text: `Post ${i}`, 256 + createdAt: new Date().toISOString(), 257 + }, 258 + ); 259 + } 260 + 261 + const rootCid = await getRepoRootCid(); 262 + const recordPaths = await getRecordPaths(); 263 + 264 + const challenge = generateChallenge({ 265 + challengerDid: "did:plc:verifier", 266 + targetDid: "did:plc:prover", 267 + subjectDid: "did:plc:test123", 268 + commitCid: rootCid, 269 + availableRecordPaths: recordPaths, 270 + challengeType: "mst-proof", 271 + epoch: 1, 272 + nonce: "test-nonce", 273 + config: { recordCount: 2 }, 274 + }); 275 + 276 + expect(challenge.recordPaths.length).toBeLessThanOrEqual(2); 277 + 278 + const response = await respondToChallenge( 279 + challenge, 280 + ipfsService, 281 + "did:plc:prover", 282 + ); 283 + 284 + expect(response.challengeId).toBe(challenge.id); 285 + expect(response.mstProofs).toBeDefined(); 286 + expect(response.mstProofs!.length).toBe( 287 + challenge.recordPaths.length, 288 + ); 289 + 290 + const result = await verifyResponse( 291 + challenge, 292 + response, 293 + ipfsService, 294 + ); 295 + 296 + expect(result.passed).toBe(true); 297 + expect(result.mstResults).toBeDefined(); 298 + expect(result.mstResults!.every((r) => r.valid)).toBe(true); 299 + }); 300 + }); 301 + 302 + // ============================================== 303 + // Block-sample challenge roundtrip 304 + // ============================================== 305 + 306 + describe("block-sample challenge roundtrip", () => { 307 + it("generate → respond → verify passes for available blocks", async () => { 308 + await repoManager.createRecord( 309 + "app.bsky.feed.post", 310 + undefined, 311 + { 312 + $type: "app.bsky.feed.post", 313 + text: "Block sample test", 314 + createdAt: new Date().toISOString(), 315 + }, 316 + ); 317 + 318 + const rootCid = await getRepoRootCid(); 319 + const blockCids = await getBlockCids(); 320 + 321 + const challenge = generateChallenge({ 322 + challengerDid: "did:plc:verifier", 323 + targetDid: "did:plc:prover", 324 + subjectDid: "did:plc:test123", 325 + commitCid: rootCid, 326 + availableRecordPaths: [], 327 + availableBlockCids: blockCids, 328 + challengeType: "block-sample", 329 + epoch: 1, 330 + nonce: "test-nonce", 331 + config: { blockSampleSize: 3 }, 332 + }); 333 + 334 + expect(challenge.blockCids).toBeDefined(); 335 + expect(challenge.blockCids!.length).toBeLessThanOrEqual(3); 336 + 337 + const response = await respondToChallenge( 338 + challenge, 339 + ipfsService, 340 + "did:plc:prover", 341 + ); 342 + 343 + expect(response.blockResults).toBeDefined(); 344 + expect(response.blockResults!.every((r) => r.available)).toBe( 345 + true, 346 + ); 347 + 348 + const result = await verifyResponse( 349 + challenge, 350 + response, 351 + ipfsService, 352 + ); 353 + 354 + expect(result.passed).toBe(true); 355 + expect(result.blockResults).toBeDefined(); 356 + expect( 357 + result.blockResults!.every( 358 + (r) => r.available && r.prefixValid, 359 + ), 360 + ).toBe(true); 361 + }); 362 + }); 363 + 364 + // ============================================== 365 + // Combined challenge roundtrip 366 + // ============================================== 367 + 368 + describe("combined challenge roundtrip", () => { 369 + it("generate → respond → verify passes for combined challenge", async () => { 370 + for (let i = 0; i < 3; i++) { 371 + await repoManager.createRecord( 372 + "app.bsky.feed.post", 373 + undefined, 374 + { 375 + $type: "app.bsky.feed.post", 376 + text: `Combined test ${i}`, 377 + createdAt: new Date().toISOString(), 378 + }, 379 + ); 380 + } 381 + 382 + const rootCid = await getRepoRootCid(); 383 + const recordPaths = await getRecordPaths(); 384 + const blockCids = await getBlockCids(); 385 + 386 + const challenge = generateChallenge({ 387 + challengerDid: "did:plc:verifier", 388 + targetDid: "did:plc:prover", 389 + subjectDid: "did:plc:test123", 390 + commitCid: rootCid, 391 + availableRecordPaths: recordPaths, 392 + availableBlockCids: blockCids, 393 + challengeType: "combined", 394 + epoch: 1, 395 + nonce: "test-nonce", 396 + config: { recordCount: 2, blockSampleSize: 2 }, 397 + }); 398 + 399 + const response = await respondToChallenge( 400 + challenge, 401 + ipfsService, 402 + "did:plc:prover", 403 + ); 404 + 405 + const result = await verifyResponse( 406 + challenge, 407 + response, 408 + ipfsService, 409 + ); 410 + 411 + expect(result.passed).toBe(true); 412 + expect(result.mstResults).toBeDefined(); 413 + expect(result.blockResults).toBeDefined(); 414 + }); 415 + }); 416 + 417 + // ============================================== 418 + // Failure detection 419 + // ============================================== 420 + 421 + describe("failure detection", () => { 422 + it("rejects expired challenges", async () => { 423 + await repoManager.createRecord( 424 + "app.bsky.feed.post", 425 + undefined, 426 + { 427 + $type: "app.bsky.feed.post", 428 + text: "Expiry test", 429 + createdAt: new Date().toISOString(), 430 + }, 431 + ); 432 + 433 + const rootCid = await getRepoRootCid(); 434 + const recordPaths = await getRecordPaths(); 435 + 436 + const challenge = generateChallenge({ 437 + challengerDid: "did:plc:verifier", 438 + targetDid: "did:plc:prover", 439 + subjectDid: "did:plc:test123", 440 + commitCid: rootCid, 441 + availableRecordPaths: recordPaths, 442 + challengeType: "mst-proof", 443 + epoch: 1, 444 + nonce: "test", 445 + config: { expirationMs: 1 }, 446 + }); 447 + 448 + const response = await respondToChallenge( 449 + challenge, 450 + ipfsService, 451 + "did:plc:prover", 452 + ); 453 + 454 + // Wait for expiration 455 + await new Promise((resolve) => setTimeout(resolve, 10)); 456 + 457 + const result = await verifyResponse( 458 + challenge, 459 + response, 460 + ipfsService, 461 + ); 462 + 463 + expect(result.passed).toBe(false); 464 + }); 465 + 466 + it("rejects mismatched challenge ID in response", async () => { 467 + await repoManager.createRecord( 468 + "app.bsky.feed.post", 469 + undefined, 470 + { 471 + $type: "app.bsky.feed.post", 472 + text: "ID mismatch test", 473 + createdAt: new Date().toISOString(), 474 + }, 475 + ); 476 + 477 + const rootCid = await getRepoRootCid(); 478 + const recordPaths = await getRecordPaths(); 479 + 480 + const challenge = generateChallenge({ 481 + challengerDid: "did:plc:verifier", 482 + targetDid: "did:plc:prover", 483 + subjectDid: "did:plc:test123", 484 + commitCid: rootCid, 485 + availableRecordPaths: recordPaths, 486 + challengeType: "mst-proof", 487 + epoch: 1, 488 + nonce: "test", 489 + }); 490 + 491 + const response = await respondToChallenge( 492 + challenge, 493 + ipfsService, 494 + "did:plc:prover", 495 + ); 496 + 497 + const tamperedResponse = { 498 + ...response, 499 + challengeId: "wrong-id", 500 + }; 501 + 502 + const result = await verifyResponse( 503 + challenge, 504 + tamperedResponse, 505 + ipfsService, 506 + ); 507 + 508 + expect(result.passed).toBe(false); 509 + }); 510 + 511 + it("detects tampered MST proofs", async () => { 512 + await repoManager.createRecord( 513 + "app.bsky.feed.post", 514 + undefined, 515 + { 516 + $type: "app.bsky.feed.post", 517 + text: "Tamper test", 518 + createdAt: new Date().toISOString(), 519 + }, 520 + ); 521 + 522 + const rootCid = await getRepoRootCid(); 523 + const recordPaths = await getRecordPaths(); 524 + 525 + const challenge = generateChallenge({ 526 + challengerDid: "did:plc:verifier", 527 + targetDid: "did:plc:prover", 528 + subjectDid: "did:plc:test123", 529 + commitCid: rootCid, 530 + availableRecordPaths: recordPaths, 531 + challengeType: "mst-proof", 532 + epoch: 1, 533 + nonce: "test", 534 + }); 535 + 536 + const response = await respondToChallenge( 537 + challenge, 538 + ipfsService, 539 + "did:plc:prover", 540 + ); 541 + 542 + // Tamper with MST proof bytes 543 + if (response.mstProofs && response.mstProofs.length > 0) { 544 + const tampered = { ...response }; 545 + tampered.mstProofs = response.mstProofs.map((proof) => { 546 + const nodes = proof.nodes.map((node) => { 547 + const bytes = new Uint8Array(node.bytes); 548 + if (bytes.length > 0) { 549 + bytes[bytes.length - 1] = 550 + (bytes[bytes.length - 1]! ^ 0xff) & 0xff; 551 + } 552 + return { ...node, bytes }; 553 + }); 554 + return { ...proof, nodes }; 555 + }); 556 + 557 + const result = await verifyResponse( 558 + challenge, 559 + tampered, 560 + ipfsService, 561 + ); 562 + 563 + expect(result.passed).toBe(false); 564 + } 565 + }); 566 + 567 + it("handles missing block in block-sample challenge", async () => { 568 + const challenge: StorageChallenge = { 569 + id: "test-challenge", 570 + version: 1, 571 + challengerDid: "did:plc:verifier", 572 + targetDid: "did:plc:prover", 573 + subjectDid: "did:plc:subject", 574 + commitCid: 575 + "bafyreig6mxqmjlb7yjbhhhz6vqmtiw4kgipvhqoowdkggjlpzpd5tcm4", 576 + recordPaths: [], 577 + challengeType: "block-sample", 578 + blockCids: [ 579 + "bafyreig6mxqmjlb7yjbhhhz6vqmtiw4kgipvhqoowdkggjlpzpd5tcm4", 580 + ], 581 + epoch: 1, 582 + nonce: "test", 583 + issuedAt: new Date().toISOString(), 584 + expiresAt: new Date(Date.now() + 300_000).toISOString(), 585 + }; 586 + 587 + const response = await respondToChallenge( 588 + challenge, 589 + ipfsService, 590 + "did:plc:prover", 591 + ); 592 + 593 + expect(response.blockResults![0]!.available).toBe(false); 594 + 595 + const result = await verifyResponse( 596 + challenge, 597 + response, 598 + ipfsService, 599 + ); 600 + 601 + expect(result.passed).toBe(false); 602 + }); 603 + 604 + it("detects tampered block prefix", async () => { 605 + await repoManager.createRecord( 606 + "app.bsky.feed.post", 607 + undefined, 608 + { 609 + $type: "app.bsky.feed.post", 610 + text: "Prefix tamper test", 611 + createdAt: new Date().toISOString(), 612 + }, 613 + ); 614 + 615 + const rootCid = await getRepoRootCid(); 616 + const blockCids = await getBlockCids(); 617 + 618 + const challenge = generateChallenge({ 619 + challengerDid: "did:plc:verifier", 620 + targetDid: "did:plc:prover", 621 + subjectDid: "did:plc:test123", 622 + commitCid: rootCid, 623 + availableRecordPaths: [], 624 + availableBlockCids: blockCids, 625 + challengeType: "block-sample", 626 + epoch: 1, 627 + nonce: "test", 628 + config: { blockSampleSize: 1 }, 629 + }); 630 + 631 + const response = await respondToChallenge( 632 + challenge, 633 + ipfsService, 634 + "did:plc:prover", 635 + ); 636 + 637 + // Tamper with the prefix 638 + if ( 639 + response.blockResults && 640 + response.blockResults.length > 0 && 641 + response.blockResults[0]!.prefix 642 + ) { 643 + const tampered = { ...response }; 644 + tampered.blockResults = response.blockResults.map((r) => { 645 + if (r.prefix) { 646 + const fakePrefix = new Uint8Array(r.prefix.length); 647 + fakePrefix.fill(0xff); 648 + return { ...r, prefix: fakePrefix }; 649 + } 650 + return r; 651 + }); 652 + 653 + const result = await verifyResponse( 654 + challenge, 655 + tampered, 656 + ipfsService, 657 + ); 658 + 659 + expect(result.passed).toBe(false); 660 + } 661 + }); 662 + }); 663 + });
+190
src/replication/challenge-response/challenge-scheduler.ts
··· 1 + /** 2 + * Periodic challenge orchestration. 3 + * 4 + * Reads from PolicyEngine + SyncStorage to derive challenge schedules, 5 + * then issues challenges via ChallengeTransport and records results. 6 + * 7 + * Policy integration boundary: 8 + * - preferredPeers → who to challenge 9 + * - minCopies - 1 → required successful challenges 10 + * - priority >= 70 → use "combined" type (otherwise "mst-proof") 11 + * - intervalSec * 2 → challenge frequency (2x sync interval) 12 + */ 13 + 14 + import type { PolicyEngine } from "../../policy/engine.js"; 15 + import type { SyncStorage } from "../sync-storage.js"; 16 + import type { BlockStore } from "../../ipfs.js"; 17 + import type { 18 + ChallengeConfig, 19 + ChallengeSchedule, 20 + ChallengeType, 21 + } from "./types.js"; 22 + import { DEFAULT_CHALLENGE_CONFIG } from "./types.js"; 23 + import { generateChallenge, computeEpoch } from "./challenge-generator.js"; 24 + import { verifyResponse } from "./challenge-verifier.js"; 25 + import { ChallengeStorage } from "./challenge-storage.js"; 26 + import type { ChallengeTransport } from "./transport.js"; 27 + 28 + export class ChallengeScheduler { 29 + private timer: ReturnType<typeof setInterval> | null = null; 30 + private stopped = false; 31 + private config: ChallengeConfig; 32 + 33 + constructor( 34 + private challengerDid: string, 35 + private policyEngine: PolicyEngine | null, 36 + private syncStorage: SyncStorage, 37 + private challengeStorage: ChallengeStorage, 38 + private blockStore: BlockStore, 39 + private transport: ChallengeTransport, 40 + config?: Partial<ChallengeConfig>, 41 + ) { 42 + this.config = { ...DEFAULT_CHALLENGE_CONFIG, ...config }; 43 + } 44 + 45 + /** 46 + * Derive challenge schedules from policy configuration. 47 + * 48 + * Reads existing policy fields to determine: 49 + * - Who to challenge (preferredPeers) 50 + * - How many must pass (minCopies - 1) 51 + * - What type (priority >= 70 → combined, else mst-proof) 52 + * - How often (2x sync interval) 53 + */ 54 + deriveSchedules(): ChallengeSchedule[] { 55 + if (!this.policyEngine) return []; 56 + 57 + const schedules: ChallengeSchedule[] = []; 58 + const states = this.syncStorage.getAllStates(); 59 + 60 + for (const state of states) { 61 + const effective = this.policyEngine.evaluate(state.did); 62 + if (!effective.shouldReplicate) continue; 63 + 64 + const targetPeers = effective.replication.preferredPeers ?? []; 65 + if (targetPeers.length === 0) continue; 66 + 67 + const challengeType: ChallengeType = 68 + effective.priority >= 70 ? "combined" : "mst-proof"; 69 + const intervalMs = effective.sync.intervalSec * 2 * 1000; 70 + 71 + schedules.push({ 72 + subjectDid: state.did, 73 + targetPeers, 74 + requiredSuccesses: Math.max( 75 + 0, 76 + effective.replication.minCopies - 1, 77 + ), 78 + challengeType, 79 + intervalMs, 80 + }); 81 + } 82 + 83 + return schedules; 84 + } 85 + 86 + /** 87 + * Start periodic challenge orchestration. 88 + */ 89 + start(intervalMs?: number): void { 90 + if (this.timer) return; 91 + this.stopped = false; 92 + 93 + const tickMs = intervalMs ?? 60_000; 94 + 95 + this.timer = setInterval(() => { 96 + if (!this.stopped) { 97 + this.tick().catch((err) => { 98 + console.error("[challenge-scheduler] tick error:", err); 99 + }); 100 + } 101 + }, tickMs); 102 + } 103 + 104 + /** 105 + * Stop the scheduler. 106 + */ 107 + stop(): void { 108 + this.stopped = true; 109 + if (this.timer) { 110 + clearInterval(this.timer); 111 + this.timer = null; 112 + } 113 + } 114 + 115 + /** 116 + * Execute one round of challenges based on derived schedules. 117 + */ 118 + async tick(): Promise<void> { 119 + const schedules = this.deriveSchedules(); 120 + 121 + for (const schedule of schedules) { 122 + if (this.stopped) break; 123 + 124 + const state = this.syncStorage.getState(schedule.subjectDid); 125 + if (!state?.rootCid) continue; 126 + 127 + const epoch = computeEpoch( 128 + Date.now(), 129 + this.config.epochDurationMs, 130 + ); 131 + 132 + const blockCids = this.syncStorage.getBlockCids( 133 + schedule.subjectDid, 134 + ); 135 + const recordPaths = this.syncStorage.getRecordPaths( 136 + schedule.subjectDid, 137 + ); 138 + 139 + for (const targetPeer of schedule.targetPeers) { 140 + if (this.stopped) break; 141 + 142 + try { 143 + const challenge = generateChallenge({ 144 + challengerDid: this.challengerDid, 145 + targetDid: targetPeer, 146 + subjectDid: schedule.subjectDid, 147 + commitCid: state.rootCid, 148 + availableRecordPaths: recordPaths, 149 + availableBlockCids: blockCids, 150 + challengeType: schedule.challengeType, 151 + epoch, 152 + config: this.config, 153 + }); 154 + 155 + const response = 156 + await this.transport.sendChallenge( 157 + state.pdsEndpoint, 158 + challenge, 159 + ); 160 + 161 + const result = await verifyResponse( 162 + challenge, 163 + response, 164 + this.blockStore, 165 + ); 166 + 167 + this.challengeStorage.recordResult( 168 + this.challengerDid, 169 + targetPeer, 170 + schedule.subjectDid, 171 + schedule.challengeType, 172 + result, 173 + ); 174 + } catch (err) { 175 + console.error( 176 + `[challenge-scheduler] Challenge to ${targetPeer} for ${schedule.subjectDid} failed:`, 177 + err instanceof Error ? err.message : String(err), 178 + ); 179 + } 180 + } 181 + } 182 + } 183 + 184 + /** 185 + * Get the underlying ChallengeStorage instance. 186 + */ 187 + getChallengeStorage(): ChallengeStorage { 188 + return this.challengeStorage; 189 + } 190 + }
+174
src/replication/challenge-response/challenge-storage.ts
··· 1 + /** 2 + * SQLite persistence for challenge history and peer reliability. 3 + * Pattern follows SyncStorage. 4 + */ 5 + 6 + import type Database from "better-sqlite3"; 7 + import type { StorageChallengeResult } from "./types.js"; 8 + 9 + export interface ChallengeHistoryRow { 10 + challenge_id: string; 11 + challenger_did: string; 12 + target_did: string; 13 + subject_did: string; 14 + challenge_type: string; 15 + /** SQLite stores booleans as 0/1 integers. */ 16 + passed: number; 17 + verified_at: string; 18 + duration_ms: number; 19 + } 20 + 21 + export interface PeerReliabilityRow { 22 + peer_did: string; 23 + subject_did: string; 24 + total_challenges: number; 25 + successful_challenges: number; 26 + reliability: number; 27 + last_challenge_at: string; 28 + } 29 + 30 + export class ChallengeStorage { 31 + constructor(private db: Database.Database) {} 32 + 33 + /** 34 + * Create challenge tables if they don't exist. 35 + */ 36 + initSchema(): void { 37 + this.db.exec(` 38 + CREATE TABLE IF NOT EXISTS challenge_history ( 39 + challenge_id TEXT PRIMARY KEY, 40 + challenger_did TEXT NOT NULL, 41 + target_did TEXT NOT NULL, 42 + subject_did TEXT NOT NULL, 43 + challenge_type TEXT NOT NULL, 44 + passed INTEGER NOT NULL, 45 + verified_at TEXT NOT NULL, 46 + duration_ms INTEGER NOT NULL 47 + ); 48 + 49 + CREATE INDEX IF NOT EXISTS idx_challenge_history_target 50 + ON challenge_history (target_did, subject_did); 51 + 52 + CREATE TABLE IF NOT EXISTS peer_reliability ( 53 + peer_did TEXT NOT NULL, 54 + subject_did TEXT NOT NULL, 55 + total_challenges INTEGER NOT NULL DEFAULT 0, 56 + successful_challenges INTEGER NOT NULL DEFAULT 0, 57 + reliability REAL NOT NULL DEFAULT 0.0, 58 + last_challenge_at TEXT NOT NULL, 59 + PRIMARY KEY (peer_did, subject_did) 60 + ); 61 + `); 62 + } 63 + 64 + /** 65 + * Record a challenge result and update peer reliability. 66 + */ 67 + recordResult( 68 + challengerDid: string, 69 + targetDid: string, 70 + subjectDid: string, 71 + challengeType: string, 72 + result: StorageChallengeResult, 73 + ): void { 74 + const passedInt = result.passed ? 1 : 0; 75 + 76 + this.db 77 + .prepare( 78 + `INSERT OR REPLACE INTO challenge_history 79 + (challenge_id, challenger_did, target_did, subject_did, challenge_type, passed, verified_at, duration_ms) 80 + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, 81 + ) 82 + .run( 83 + result.challengeId, 84 + challengerDid, 85 + targetDid, 86 + subjectDid, 87 + challengeType, 88 + passedInt, 89 + result.verifiedAt, 90 + result.durationMs, 91 + ); 92 + 93 + this.db 94 + .prepare( 95 + `INSERT INTO peer_reliability 96 + (peer_did, subject_did, total_challenges, successful_challenges, reliability, last_challenge_at) 97 + VALUES (?, ?, 1, ?, ?, ?) 98 + ON CONFLICT(peer_did, subject_did) DO UPDATE SET 99 + total_challenges = peer_reliability.total_challenges + 1, 100 + successful_challenges = peer_reliability.successful_challenges + ?, 101 + reliability = CAST((peer_reliability.successful_challenges + ?) AS REAL) 102 + / (peer_reliability.total_challenges + 1), 103 + last_challenge_at = ?`, 104 + ) 105 + .run( 106 + targetDid, 107 + subjectDid, 108 + passedInt, 109 + result.passed ? 1.0 : 0.0, 110 + result.verifiedAt, 111 + passedInt, 112 + passedInt, 113 + result.verifiedAt, 114 + ); 115 + } 116 + 117 + /** 118 + * Get challenge history for a target peer, optionally filtered by subject DID. 119 + */ 120 + getHistory( 121 + targetDid: string, 122 + subjectDid?: string, 123 + limit = 50, 124 + ): ChallengeHistoryRow[] { 125 + if (subjectDid) { 126 + return this.db 127 + .prepare( 128 + `SELECT * FROM challenge_history 129 + WHERE target_did = ? AND subject_did = ? 130 + ORDER BY verified_at DESC LIMIT ?`, 131 + ) 132 + .all(targetDid, subjectDid, limit) as ChallengeHistoryRow[]; 133 + } 134 + return this.db 135 + .prepare( 136 + `SELECT * FROM challenge_history 137 + WHERE target_did = ? 138 + ORDER BY verified_at DESC LIMIT ?`, 139 + ) 140 + .all(targetDid, limit) as ChallengeHistoryRow[]; 141 + } 142 + 143 + /** 144 + * Get reliability scores for a peer, optionally filtered by subject DID. 145 + */ 146 + getReliability( 147 + peerDid: string, 148 + subjectDid?: string, 149 + ): PeerReliabilityRow[] { 150 + if (subjectDid) { 151 + const row = this.db 152 + .prepare( 153 + `SELECT * FROM peer_reliability 154 + WHERE peer_did = ? AND subject_did = ?`, 155 + ) 156 + .get(peerDid, subjectDid) as PeerReliabilityRow | undefined; 157 + return row ? [row] : []; 158 + } 159 + return this.db 160 + .prepare(`SELECT * FROM peer_reliability WHERE peer_did = ?`) 161 + .all(peerDid) as PeerReliabilityRow[]; 162 + } 163 + 164 + /** 165 + * Get all peer reliability scores, sorted by reliability descending. 166 + */ 167 + getAllReliability(): PeerReliabilityRow[] { 168 + return this.db 169 + .prepare( 170 + `SELECT * FROM peer_reliability ORDER BY reliability DESC`, 171 + ) 172 + .all() as PeerReliabilityRow[]; 173 + } 174 + }
+176
src/replication/challenge-response/challenge-verifier.ts
··· 1 + /** 2 + * Challenge verifier: validate a response against the original challenge. 3 + * 4 + * Pure verification: 5 + * - For MST proofs: calls verifyMstProof for each proof. 6 + * - For block samples: checks availability and verifies prefix against local copy. 7 + */ 8 + 9 + import type { BlockStore } from "../../ipfs.js"; 10 + import type { 11 + StorageChallenge, 12 + StorageChallengeResponse, 13 + StorageChallengeResult, 14 + MstProofResult, 15 + BlockCheckResult, 16 + } from "./types.js"; 17 + import { verifyMstProof } from "../mst-proof.js"; 18 + 19 + /** 20 + * Verify a challenge response. 21 + * 22 + * Checks: 23 + * 1. Challenge not expired 24 + * 2. Response challenge ID matches 25 + * 3. MST proofs are valid (if applicable) 26 + * 4. Block prefixes match local copies (if applicable) 27 + */ 28 + export async function verifyResponse( 29 + challenge: StorageChallenge, 30 + response: StorageChallengeResponse, 31 + blockStore: BlockStore, 32 + now?: Date, 33 + ): Promise<StorageChallengeResult> { 34 + const start = Date.now(); 35 + const currentTime = now ?? new Date(); 36 + 37 + // Check expiration 38 + if (currentTime > new Date(challenge.expiresAt)) { 39 + return { 40 + challengeId: challenge.id, 41 + passed: false, 42 + verifiedAt: currentTime.toISOString(), 43 + durationMs: Date.now() - start, 44 + }; 45 + } 46 + 47 + // Check challenge ID match 48 + if (response.challengeId !== challenge.id) { 49 + return { 50 + challengeId: challenge.id, 51 + passed: false, 52 + verifiedAt: currentTime.toISOString(), 53 + durationMs: Date.now() - start, 54 + }; 55 + } 56 + 57 + let mstResults: MstProofResult[] | undefined; 58 + let blockResults: BlockCheckResult[] | undefined; 59 + let allPassed = true; 60 + 61 + // Verify MST proofs 62 + if ( 63 + challenge.challengeType === "mst-proof" || 64 + challenge.challengeType === "combined" 65 + ) { 66 + mstResults = []; 67 + 68 + if ( 69 + !response.mstProofs || 70 + response.mstProofs.length !== challenge.recordPaths.length 71 + ) { 72 + allPassed = false; 73 + } else { 74 + for (let i = 0; i < challenge.recordPaths.length; i++) { 75 + const recordPath = challenge.recordPaths[i]!; 76 + const proof = response.mstProofs[i]!; 77 + 78 + const verification = await verifyMstProof( 79 + proof, 80 + challenge.commitCid, 81 + recordPath, 82 + ); 83 + 84 + mstResults.push({ 85 + recordPath, 86 + valid: verification.valid, 87 + found: verification.found, 88 + error: verification.error, 89 + }); 90 + 91 + if (!verification.valid) { 92 + allPassed = false; 93 + } 94 + } 95 + } 96 + } 97 + 98 + // Verify block samples 99 + if ( 100 + challenge.challengeType === "block-sample" || 101 + challenge.challengeType === "combined" 102 + ) { 103 + blockResults = []; 104 + 105 + if (challenge.blockCids) { 106 + if ( 107 + !response.blockResults || 108 + response.blockResults.length !== challenge.blockCids.length 109 + ) { 110 + allPassed = false; 111 + } else { 112 + for (let i = 0; i < challenge.blockCids.length; i++) { 113 + const expectedCid = challenge.blockCids[i]!; 114 + const result = response.blockResults[i]!; 115 + 116 + if (result.cid !== expectedCid) { 117 + blockResults.push({ 118 + cid: expectedCid, 119 + available: false, 120 + prefixValid: false, 121 + }); 122 + allPassed = false; 123 + continue; 124 + } 125 + 126 + if (!result.available || !result.prefix) { 127 + blockResults.push({ 128 + cid: expectedCid, 129 + available: false, 130 + prefixValid: false, 131 + }); 132 + allPassed = false; 133 + continue; 134 + } 135 + 136 + // Verify prefix against our local copy 137 + const localBytes = await blockStore.getBlock(expectedCid); 138 + let prefixValid = false; 139 + 140 + if (localBytes) { 141 + const localPrefix = localBytes.slice( 142 + 0, 143 + result.prefix.length, 144 + ); 145 + prefixValid = Buffer.from(result.prefix).equals( 146 + Buffer.from(localPrefix), 147 + ); 148 + } else { 149 + // We don't have the block locally — accept any non-empty prefix 150 + // (we're checking that *they* have it) 151 + prefixValid = result.prefix.length > 0; 152 + } 153 + 154 + blockResults.push({ 155 + cid: expectedCid, 156 + available: result.available, 157 + prefixValid, 158 + }); 159 + 160 + if (!prefixValid) { 161 + allPassed = false; 162 + } 163 + } 164 + } 165 + } 166 + } 167 + 168 + return { 169 + challengeId: challenge.id, 170 + passed: allPassed, 171 + mstResults, 172 + blockResults, 173 + verifiedAt: currentTime.toISOString(), 174 + durationMs: Date.now() - start, 175 + }; 176 + }
+153
src/replication/challenge-response/http-transport.ts
··· 1 + /** 2 + * HTTP adapter for the challenge-response transport. 3 + * 4 + * Sends challenges via POST to /xrpc/org.p2pds.verification.challenge. 5 + * Handles Uint8Array ↔ base64 serialization for JSON transport. 6 + */ 7 + 8 + import type { StorageChallenge, StorageChallengeResponse } from "./types.js"; 9 + import type { MstProof, ProofBlock } from "../mst-proof.js"; 10 + import type { ChallengeTransport } from "./transport.js"; 11 + 12 + // ============================================ 13 + // Serialization helpers 14 + // ============================================ 15 + 16 + function uint8ArrayToBase64(bytes: Uint8Array): string { 17 + return Buffer.from(bytes).toString("base64"); 18 + } 19 + 20 + function base64ToUint8Array(base64: string): Uint8Array { 21 + return new Uint8Array(Buffer.from(base64, "base64")); 22 + } 23 + 24 + /** Serialize a response for JSON transport (Uint8Array → base64). */ 25 + export function serializeResponse( 26 + response: StorageChallengeResponse, 27 + ): unknown { 28 + return { 29 + ...response, 30 + mstProofs: response.mstProofs?.map((proof) => ({ 31 + ...proof, 32 + commitBlock: { 33 + ...proof.commitBlock, 34 + bytes: uint8ArrayToBase64(proof.commitBlock.bytes), 35 + }, 36 + nodes: proof.nodes.map((node) => ({ 37 + ...node, 38 + bytes: uint8ArrayToBase64(node.bytes), 39 + })), 40 + })), 41 + blockResults: response.blockResults?.map((result) => ({ 42 + ...result, 43 + prefix: result.prefix 44 + ? uint8ArrayToBase64(result.prefix) 45 + : undefined, 46 + })), 47 + }; 48 + } 49 + 50 + /** Deserialize a response from JSON transport (base64 → Uint8Array). */ 51 + export function deserializeResponse( 52 + raw: unknown, 53 + ): StorageChallengeResponse { 54 + const data = raw as Record<string, unknown>; 55 + 56 + const mstProofs = data.mstProofs 57 + ? (data.mstProofs as Array<Record<string, unknown>>).map( 58 + (proof): MstProof => ({ 59 + commitBlock: deserializeProofBlock( 60 + proof.commitBlock as Record<string, unknown>, 61 + ), 62 + nodes: ( 63 + proof.nodes as Array<Record<string, unknown>> 64 + ).map(deserializeProofBlock), 65 + recordCid: proof.recordCid as string | null, 66 + found: proof.found as boolean, 67 + }), 68 + ) 69 + : undefined; 70 + 71 + const blockResults = data.blockResults 72 + ? (data.blockResults as Array<Record<string, unknown>>).map( 73 + (result) => ({ 74 + cid: result.cid as string, 75 + available: result.available as boolean, 76 + prefix: result.prefix 77 + ? base64ToUint8Array(result.prefix as string) 78 + : undefined, 79 + }), 80 + ) 81 + : undefined; 82 + 83 + return { 84 + challengeId: data.challengeId as string, 85 + responderDid: data.responderDid as string, 86 + mstProofs, 87 + blockResults, 88 + respondedAt: data.respondedAt as string, 89 + }; 90 + } 91 + 92 + function deserializeProofBlock( 93 + raw: Record<string, unknown>, 94 + ): ProofBlock { 95 + return { 96 + cid: raw.cid as string, 97 + bytes: base64ToUint8Array(raw.bytes as string), 98 + }; 99 + } 100 + 101 + // ============================================ 102 + // HTTP Transport 103 + // ============================================ 104 + 105 + export class HttpChallengeTransport implements ChallengeTransport { 106 + private handler: 107 + | (( 108 + challenge: StorageChallenge, 109 + ) => Promise<StorageChallengeResponse>) 110 + | null = null; 111 + 112 + constructor(private fetchFn: typeof fetch = fetch) {} 113 + 114 + async sendChallenge( 115 + targetEndpoint: string, 116 + challenge: StorageChallenge, 117 + ): Promise<StorageChallengeResponse> { 118 + const url = `${targetEndpoint}/xrpc/org.p2pds.verification.challenge`; 119 + const res = await this.fetchFn(url, { 120 + method: "POST", 121 + headers: { "Content-Type": "application/json" }, 122 + body: JSON.stringify(challenge), 123 + }); 124 + 125 + if (!res.ok) { 126 + throw new Error( 127 + `Challenge request failed: ${res.status} ${res.statusText}`, 128 + ); 129 + } 130 + 131 + const raw = await res.json(); 132 + return deserializeResponse(raw); 133 + } 134 + 135 + onChallenge( 136 + handler: ( 137 + challenge: StorageChallenge, 138 + ) => Promise<StorageChallengeResponse>, 139 + ): void { 140 + this.handler = handler; 141 + } 142 + 143 + /** 144 + * Get the registered challenge handler (for use by route handlers). 145 + */ 146 + getHandler(): 147 + | (( 148 + challenge: StorageChallenge, 149 + ) => Promise<StorageChallengeResponse>) 150 + | null { 151 + return this.handler; 152 + } 153 + }
+49
src/replication/challenge-response/index.ts
··· 1 + /** 2 + * Challenge-response proof-of-storage verification protocol. 3 + * 4 + * Barrel exports for the public API. 5 + */ 6 + 7 + // Types 8 + export type { 9 + StorageChallenge, 10 + StorageChallengeResponse, 11 + StorageChallengeResult, 12 + BlockResult, 13 + MstProofResult, 14 + BlockCheckResult, 15 + ChallengeConfig, 16 + ChallengeSchedule, 17 + ChallengeType, 18 + } from "./types.js"; 19 + export { 20 + CHALLENGE_PROTOCOL_VERSION, 21 + DEFAULT_CHALLENGE_CONFIG, 22 + } from "./types.js"; 23 + 24 + // Generator 25 + export { 26 + generateChallenge, 27 + computeEpoch, 28 + generateChallengeId, 29 + } from "./challenge-generator.js"; 30 + 31 + // Responder 32 + export { respondToChallenge } from "./challenge-responder.js"; 33 + 34 + // Verifier 35 + export { verifyResponse } from "./challenge-verifier.js"; 36 + 37 + // Storage 38 + export { ChallengeStorage } from "./challenge-storage.js"; 39 + export type { 40 + ChallengeHistoryRow, 41 + PeerReliabilityRow, 42 + } from "./challenge-storage.js"; 43 + 44 + // Transport 45 + export type { ChallengeTransport } from "./transport.js"; 46 + export { HttpChallengeTransport } from "./http-transport.js"; 47 + 48 + // Scheduler 49 + export { ChallengeScheduler } from "./challenge-scheduler.js";
+437
src/replication/challenge-response/scheduler.test.ts
··· 1 + import { describe, it, expect, beforeEach, afterEach } from "vitest"; 2 + import { mkdtempSync, rmSync } from "node:fs"; 3 + import { tmpdir } from "node:os"; 4 + import { join } from "node:path"; 5 + import Database from "better-sqlite3"; 6 + import { PolicyEngine } from "../../policy/engine.js"; 7 + import { mutualAid } from "../../policy/presets.js"; 8 + import { SyncStorage } from "../sync-storage.js"; 9 + import { ChallengeStorage } from "./challenge-storage.js"; 10 + import { ChallengeScheduler } from "./challenge-scheduler.js"; 11 + import type { ChallengeTransport } from "./transport.js"; 12 + import type { 13 + StorageChallenge, 14 + StorageChallengeResponse, 15 + } from "./types.js"; 16 + import type { BlockStore } from "../../ipfs.js"; 17 + import type { BlockMap } from "@atproto/repo"; 18 + 19 + // ============================================ 20 + // Mock transport 21 + // ============================================ 22 + 23 + class MockTransport implements ChallengeTransport { 24 + challenges: StorageChallenge[] = []; 25 + 26 + async sendChallenge( 27 + _target: string, 28 + challenge: StorageChallenge, 29 + ): Promise<StorageChallengeResponse> { 30 + this.challenges.push(challenge); 31 + return { 32 + challengeId: challenge.id, 33 + responderDid: challenge.targetDid, 34 + respondedAt: new Date().toISOString(), 35 + mstProofs: 36 + challenge.challengeType !== "block-sample" ? [] : undefined, 37 + blockResults: 38 + challenge.challengeType !== "mst-proof" 39 + ? challenge.blockCids?.map((cid) => ({ 40 + cid, 41 + available: false, 42 + })) 43 + : undefined, 44 + }; 45 + } 46 + 47 + onChallenge( 48 + _handler: ( 49 + challenge: StorageChallenge, 50 + ) => Promise<StorageChallengeResponse>, 51 + ): void {} 52 + } 53 + 54 + // ============================================ 55 + // Mock blockstore 56 + // ============================================ 57 + 58 + class MockBlockStore implements BlockStore { 59 + private blocks = new Map<string, Uint8Array>(); 60 + 61 + async putBlock(cidStr: string, bytes: Uint8Array): Promise<void> { 62 + this.blocks.set(cidStr, bytes); 63 + } 64 + 65 + async getBlock(cidStr: string): Promise<Uint8Array | null> { 66 + return this.blocks.get(cidStr) ?? null; 67 + } 68 + 69 + async hasBlock(cidStr: string): Promise<boolean> { 70 + return this.blocks.has(cidStr); 71 + } 72 + 73 + async putBlocks(_blocks: BlockMap): Promise<void> {} 74 + } 75 + 76 + // ============================================ 77 + // Tests 78 + // ============================================ 79 + 80 + describe("ChallengeScheduler", () => { 81 + let tmpDir: string; 82 + let db: InstanceType<typeof Database>; 83 + let syncStorage: SyncStorage; 84 + let challengeStorage: ChallengeStorage; 85 + let transport: MockTransport; 86 + let blockStore: MockBlockStore; 87 + 88 + beforeEach(() => { 89 + tmpDir = mkdtempSync(join(tmpdir(), "scheduler-test-")); 90 + db = new Database(join(tmpDir, "test.db")); 91 + syncStorage = new SyncStorage(db); 92 + syncStorage.initSchema(); 93 + challengeStorage = new ChallengeStorage(db); 94 + challengeStorage.initSchema(); 95 + transport = new MockTransport(); 96 + blockStore = new MockBlockStore(); 97 + }); 98 + 99 + afterEach(() => { 100 + db.close(); 101 + rmSync(tmpDir, { recursive: true, force: true }); 102 + }); 103 + 104 + describe("deriveSchedules", () => { 105 + it("returns empty when no policy engine", () => { 106 + const scheduler = new ChallengeScheduler( 107 + "did:plc:self", 108 + null, 109 + syncStorage, 110 + challengeStorage, 111 + blockStore, 112 + transport, 113 + ); 114 + 115 + expect(scheduler.deriveSchedules()).toEqual([]); 116 + }); 117 + 118 + it("derives schedule from mutual-aid policy", () => { 119 + const peerDids = [ 120 + "did:plc:alice", 121 + "did:plc:bob", 122 + "did:plc:carol", 123 + ]; 124 + const engine = new PolicyEngine({ 125 + version: 1, 126 + policies: [mutualAid({ peerDids, minCopies: 3 })], 127 + }); 128 + 129 + // Set up sync state for a tracked DID 130 + syncStorage.upsertState({ 131 + did: "did:plc:alice", 132 + pdsEndpoint: "https://alice.pds.example", 133 + }); 134 + 135 + const scheduler = new ChallengeScheduler( 136 + "did:plc:self", 137 + engine, 138 + syncStorage, 139 + challengeStorage, 140 + blockStore, 141 + transport, 142 + ); 143 + 144 + const schedules = scheduler.deriveSchedules(); 145 + 146 + expect(schedules.length).toBe(1); 147 + expect(schedules[0]!.subjectDid).toBe("did:plc:alice"); 148 + expect(schedules[0]!.targetPeers).toEqual(peerDids); 149 + expect(schedules[0]!.requiredSuccesses).toBe(2); // minCopies(3) - 1 150 + expect(schedules[0]!.challengeType).toBe("mst-proof"); // priority 50 < 70 151 + expect(schedules[0]!.intervalMs).toBe(600 * 2 * 1000); // 2x sync interval 152 + }); 153 + 154 + it("uses combined type for high-priority policies", () => { 155 + const engine = new PolicyEngine({ 156 + version: 1, 157 + policies: [ 158 + { 159 + id: "saas-with-peers", 160 + name: "SaaS with peers", 161 + target: { 162 + type: "list", 163 + dids: ["did:plc:customer"], 164 + }, 165 + replication: { 166 + minCopies: 3, 167 + preferredPeers: [ 168 + "did:plc:peer1", 169 + "did:plc:peer2", 170 + ], 171 + }, 172 + sync: { intervalSec: 60 }, 173 + retention: { maxAgeSec: 0, keepHistory: true }, 174 + priority: 80, 175 + enabled: true, 176 + }, 177 + ], 178 + }); 179 + 180 + syncStorage.upsertState({ 181 + did: "did:plc:customer", 182 + pdsEndpoint: "https://customer.pds.example", 183 + }); 184 + 185 + const scheduler = new ChallengeScheduler( 186 + "did:plc:self", 187 + engine, 188 + syncStorage, 189 + challengeStorage, 190 + blockStore, 191 + transport, 192 + ); 193 + 194 + const schedules = scheduler.deriveSchedules(); 195 + 196 + expect(schedules.length).toBe(1); 197 + expect(schedules[0]!.challengeType).toBe("combined"); // priority 80 >= 70 198 + expect(schedules[0]!.requiredSuccesses).toBe(2); // minCopies(3) - 1 199 + expect(schedules[0]!.intervalMs).toBe(60 * 2 * 1000); // 2x 60s 200 + }); 201 + 202 + it("skips DIDs without preferred peers", () => { 203 + const engine = new PolicyEngine({ 204 + version: 1, 205 + policies: [ 206 + { 207 + id: "no-peers", 208 + name: "No peers", 209 + target: { 210 + type: "list", 211 + dids: ["did:plc:lonely"], 212 + }, 213 + replication: { minCopies: 1 }, 214 + sync: { intervalSec: 300 }, 215 + retention: { maxAgeSec: 0, keepHistory: false }, 216 + priority: 50, 217 + enabled: true, 218 + }, 219 + ], 220 + }); 221 + 222 + syncStorage.upsertState({ 223 + did: "did:plc:lonely", 224 + pdsEndpoint: "https://lonely.pds.example", 225 + }); 226 + 227 + const scheduler = new ChallengeScheduler( 228 + "did:plc:self", 229 + engine, 230 + syncStorage, 231 + challengeStorage, 232 + blockStore, 233 + transport, 234 + ); 235 + 236 + const schedules = scheduler.deriveSchedules(); 237 + expect(schedules.length).toBe(0); 238 + }); 239 + }); 240 + 241 + describe("ChallengeStorage", () => { 242 + it("records results and updates reliability", () => { 243 + challengeStorage.recordResult( 244 + "did:plc:challenger", 245 + "did:plc:target", 246 + "did:plc:subject", 247 + "mst-proof", 248 + { 249 + challengeId: "challenge-1", 250 + passed: true, 251 + verifiedAt: new Date().toISOString(), 252 + durationMs: 100, 253 + }, 254 + ); 255 + 256 + const history = challengeStorage.getHistory("did:plc:target"); 257 + expect(history.length).toBe(1); 258 + expect(history[0]!.passed).toBe(1); 259 + 260 + const reliability = challengeStorage.getReliability( 261 + "did:plc:target", 262 + "did:plc:subject", 263 + ); 264 + expect(reliability.length).toBe(1); 265 + expect(reliability[0]!.total_challenges).toBe(1); 266 + expect(reliability[0]!.successful_challenges).toBe(1); 267 + expect(reliability[0]!.reliability).toBe(1.0); 268 + }); 269 + 270 + it("tracks declining reliability", () => { 271 + // First challenge: pass 272 + challengeStorage.recordResult( 273 + "did:plc:challenger", 274 + "did:plc:target", 275 + "did:plc:subject", 276 + "mst-proof", 277 + { 278 + challengeId: "challenge-1", 279 + passed: true, 280 + verifiedAt: new Date().toISOString(), 281 + durationMs: 100, 282 + }, 283 + ); 284 + 285 + // Second challenge: fail 286 + challengeStorage.recordResult( 287 + "did:plc:challenger", 288 + "did:plc:target", 289 + "did:plc:subject", 290 + "mst-proof", 291 + { 292 + challengeId: "challenge-2", 293 + passed: false, 294 + verifiedAt: new Date().toISOString(), 295 + durationMs: 200, 296 + }, 297 + ); 298 + 299 + const reliability = challengeStorage.getReliability( 300 + "did:plc:target", 301 + "did:plc:subject", 302 + ); 303 + expect(reliability[0]!.total_challenges).toBe(2); 304 + expect(reliability[0]!.successful_challenges).toBe(1); 305 + expect(reliability[0]!.reliability).toBe(0.5); 306 + }); 307 + 308 + it("getHistory filters by subject DID", () => { 309 + challengeStorage.recordResult( 310 + "did:plc:challenger", 311 + "did:plc:target", 312 + "did:plc:subject-a", 313 + "mst-proof", 314 + { 315 + challengeId: "c-1", 316 + passed: true, 317 + verifiedAt: new Date().toISOString(), 318 + durationMs: 50, 319 + }, 320 + ); 321 + 322 + challengeStorage.recordResult( 323 + "did:plc:challenger", 324 + "did:plc:target", 325 + "did:plc:subject-b", 326 + "mst-proof", 327 + { 328 + challengeId: "c-2", 329 + passed: false, 330 + verifiedAt: new Date().toISOString(), 331 + durationMs: 60, 332 + }, 333 + ); 334 + 335 + const allHistory = challengeStorage.getHistory("did:plc:target"); 336 + expect(allHistory.length).toBe(2); 337 + 338 + const filteredHistory = challengeStorage.getHistory( 339 + "did:plc:target", 340 + "did:plc:subject-a", 341 + ); 342 + expect(filteredHistory.length).toBe(1); 343 + expect(filteredHistory[0]!.subject_did).toBe( 344 + "did:plc:subject-a", 345 + ); 346 + }); 347 + 348 + it("getAllReliability returns sorted results", () => { 349 + // High reliability peer 350 + challengeStorage.recordResult( 351 + "did:plc:c", 352 + "did:plc:good-peer", 353 + "did:plc:subject", 354 + "mst-proof", 355 + { 356 + challengeId: "c-good", 357 + passed: true, 358 + verifiedAt: new Date().toISOString(), 359 + durationMs: 50, 360 + }, 361 + ); 362 + 363 + // Low reliability peer 364 + challengeStorage.recordResult( 365 + "did:plc:c", 366 + "did:plc:bad-peer", 367 + "did:plc:subject", 368 + "mst-proof", 369 + { 370 + challengeId: "c-bad", 371 + passed: false, 372 + verifiedAt: new Date().toISOString(), 373 + durationMs: 50, 374 + }, 375 + ); 376 + 377 + const all = challengeStorage.getAllReliability(); 378 + expect(all.length).toBe(2); 379 + expect(all[0]!.peer_did).toBe("did:plc:good-peer"); 380 + expect(all[0]!.reliability).toBe(1.0); 381 + expect(all[1]!.peer_did).toBe("did:plc:bad-peer"); 382 + expect(all[1]!.reliability).toBe(0.0); 383 + }); 384 + }); 385 + 386 + describe("SyncStorage record paths", () => { 387 + it("tracks and retrieves record paths", () => { 388 + syncStorage.upsertState({ 389 + did: "did:plc:test", 390 + pdsEndpoint: "https://test.example", 391 + }); 392 + 393 + syncStorage.trackRecordPaths("did:plc:test", [ 394 + "app.bsky.feed.post/abc", 395 + "app.bsky.feed.post/def", 396 + ]); 397 + 398 + const paths = syncStorage.getRecordPaths("did:plc:test"); 399 + expect(paths).toContain("app.bsky.feed.post/abc"); 400 + expect(paths).toContain("app.bsky.feed.post/def"); 401 + expect(paths.length).toBe(2); 402 + }); 403 + 404 + it("ignores duplicate record paths", () => { 405 + syncStorage.upsertState({ 406 + did: "did:plc:test", 407 + pdsEndpoint: "https://test.example", 408 + }); 409 + 410 + syncStorage.trackRecordPaths("did:plc:test", [ 411 + "app.bsky.feed.post/abc", 412 + ]); 413 + syncStorage.trackRecordPaths("did:plc:test", [ 414 + "app.bsky.feed.post/abc", 415 + "app.bsky.feed.post/def", 416 + ]); 417 + 418 + const paths = syncStorage.getRecordPaths("did:plc:test"); 419 + expect(paths.length).toBe(2); 420 + }); 421 + 422 + it("clears record paths", () => { 423 + syncStorage.upsertState({ 424 + did: "did:plc:test", 425 + pdsEndpoint: "https://test.example", 426 + }); 427 + 428 + syncStorage.trackRecordPaths("did:plc:test", [ 429 + "app.bsky.feed.post/abc", 430 + ]); 431 + syncStorage.clearRecordPaths("did:plc:test"); 432 + 433 + const paths = syncStorage.getRecordPaths("did:plc:test"); 434 + expect(paths.length).toBe(0); 435 + }); 436 + }); 437 + });
+27
src/replication/challenge-response/transport.ts
··· 1 + /** 2 + * Transport interface for the challenge-response protocol. 3 + * 4 + * Separates the protocol logic from the communication layer. 5 + * Implementations handle the actual sending/receiving of messages. 6 + */ 7 + 8 + import type { StorageChallenge, StorageChallengeResponse } from "./types.js"; 9 + 10 + export interface ChallengeTransport { 11 + /** 12 + * Send a challenge to a target endpoint and await the response. 13 + */ 14 + sendChallenge( 15 + targetEndpoint: string, 16 + challenge: StorageChallenge, 17 + ): Promise<StorageChallengeResponse>; 18 + 19 + /** 20 + * Register a handler for incoming challenges. 21 + */ 22 + onChallenge( 23 + handler: ( 24 + challenge: StorageChallenge, 25 + ) => Promise<StorageChallengeResponse>, 26 + ): void; 27 + }
+130
src/replication/challenge-response/types.ts
··· 1 + /** 2 + * Challenge-response proof-of-storage protocol types. 3 + * 4 + * Three JSON-serializable message types form the protocol: 5 + * 1. StorageChallenge — Verifier sends to Prover 6 + * 2. StorageChallengeResponse — Prover returns to Verifier 7 + * 3. StorageChallengeResult — Computed locally by the Verifier 8 + * 9 + * All types are transport-agnostic: pure data, no I/O dependencies. 10 + */ 11 + 12 + import type { MstProof } from "../mst-proof.js"; 13 + 14 + // ============================================ 15 + // Protocol version 16 + // ============================================ 17 + 18 + export const CHALLENGE_PROTOCOL_VERSION = 1; 19 + 20 + // ============================================ 21 + // Challenge types 22 + // ============================================ 23 + 24 + export type ChallengeType = "mst-proof" | "block-sample" | "combined"; 25 + 26 + // ============================================ 27 + // StorageChallenge: Verifier → Prover 28 + // ============================================ 29 + 30 + export interface StorageChallenge { 31 + id: string; 32 + version: number; 33 + challengerDid: string; 34 + targetDid: string; 35 + subjectDid: string; 36 + commitCid: string; 37 + recordPaths: string[]; 38 + challengeType: ChallengeType; 39 + blockCids?: string[]; 40 + epoch: number; 41 + nonce: string; 42 + issuedAt: string; 43 + expiresAt: string; 44 + } 45 + 46 + // ============================================ 47 + // StorageChallengeResponse: Prover → Verifier 48 + // ============================================ 49 + 50 + export interface BlockResult { 51 + cid: string; 52 + available: boolean; 53 + /** 32-byte prefix of the block content, proves possession. */ 54 + prefix?: Uint8Array; 55 + } 56 + 57 + export interface StorageChallengeResponse { 58 + challengeId: string; 59 + responderDid: string; 60 + mstProofs?: MstProof[]; 61 + blockResults?: BlockResult[]; 62 + respondedAt: string; 63 + } 64 + 65 + // ============================================ 66 + // StorageChallengeResult: Computed locally by verifier 67 + // ============================================ 68 + 69 + export interface MstProofResult { 70 + recordPath: string; 71 + valid: boolean; 72 + found: boolean; 73 + error?: string; 74 + } 75 + 76 + export interface BlockCheckResult { 77 + cid: string; 78 + available: boolean; 79 + prefixValid: boolean; 80 + } 81 + 82 + export interface StorageChallengeResult { 83 + challengeId: string; 84 + passed: boolean; 85 + mstResults?: MstProofResult[]; 86 + blockResults?: BlockCheckResult[]; 87 + verifiedAt: string; 88 + durationMs: number; 89 + } 90 + 91 + // ============================================ 92 + // Configuration 93 + // ============================================ 94 + 95 + export interface ChallengeConfig { 96 + /** Duration of an epoch for deterministic challenges (ms). */ 97 + epochDurationMs: number; 98 + /** How many records to include in MST challenges. */ 99 + recordCount: number; 100 + /** How many blocks to sample in block-sample challenges. */ 101 + blockSampleSize: number; 102 + /** Challenge expiration time (ms). */ 103 + expirationMs: number; 104 + /** Minimum reliability score (0-1) to consider a peer trustworthy. */ 105 + reliabilityThreshold: number; 106 + /** How many bytes of block prefix to require for proof of possession. */ 107 + blockPrefixLength: number; 108 + } 109 + 110 + export const DEFAULT_CHALLENGE_CONFIG: ChallengeConfig = { 111 + epochDurationMs: 60 * 60 * 1000, 112 + recordCount: 3, 113 + blockSampleSize: 5, 114 + expirationMs: 5 * 60 * 1000, 115 + reliabilityThreshold: 0.8, 116 + blockPrefixLength: 32, 117 + }; 118 + 119 + // ============================================ 120 + // Challenge schedule (derived from policy) 121 + // ============================================ 122 + 123 + export interface ChallengeSchedule { 124 + subjectDid: string; 125 + targetPeers: string[]; 126 + /** minCopies - 1: how many peers must pass challenges. */ 127 + requiredSuccesses: number; 128 + challengeType: ChallengeType; 129 + intervalMs: number; 130 + }
+62 -2
src/replication/replication-manager.ts
··· 33 33 FirehoseSubscription, 34 34 type FirehoseCommitEvent, 35 35 } from "./firehose-subscription.js"; 36 + import { ChallengeScheduler } from "./challenge-response/challenge-scheduler.js"; 37 + import { ChallengeStorage, type ChallengeHistoryRow, type PeerReliabilityRow } from "./challenge-response/challenge-storage.js"; 38 + import type { ChallengeTransport } from "./challenge-response/transport.js"; 36 39 37 40 /** How old cached peer info can be before re-fetching (1 hour). */ 38 41 const PEER_INFO_TTL_MS = 60 * 60 * 1000; ··· 56 59 new Map(); 57 60 private firehoseSubscription: FirehoseSubscription | null = null; 58 61 private firehoseCursorSaveTimer: ReturnType<typeof setInterval> | null = null; 62 + private challengeStorage: ChallengeStorage; 63 + private challengeScheduler: ChallengeScheduler | null = null; 59 64 private stopped = false; 60 65 private policyEngine: PolicyEngine | null = null; 61 66 /** Per-DID last-sync timestamps (epoch ms) for policy-driven interval tracking. */ ··· 73 78 policyEngine?: PolicyEngine, 74 79 ) { 75 80 this.syncStorage = new SyncStorage(db); 81 + this.challengeStorage = new ChallengeStorage(db); 76 82 this.repoFetcher = new RepoFetcher(didResolver); 77 83 this.peerDiscovery = new PeerDiscovery(this.repoFetcher); 78 84 this.verifier = new BlockVerifier(blockStore); ··· 101 107 */ 102 108 async init(): Promise<void> { 103 109 this.syncStorage.initSchema(); 110 + this.challengeStorage.initSchema(); 104 111 await this.publishPeerIdentity(); 105 112 await this.syncManifests(); 106 113 } ··· 722 729 this.firehoseSubscription.stop(); 723 730 this.firehoseSubscription = null; 724 731 } 732 + // Stop challenge scheduler 733 + if (this.challengeScheduler) { 734 + this.challengeScheduler.stop(); 735 + this.challengeScheduler = null; 736 + } 725 737 } 726 738 727 739 /** ··· 756 768 // Get tracked block CIDs for this DID 757 769 const blockCids = this.syncStorage.getBlockCids(did); 758 770 759 - // Use the last sync rev as the root CID 760 - const rootCid = state.lastSyncRev ?? null; 771 + // Use the root CID (commit root) for verification 772 + const rootCid = state.rootCid ?? state.lastSyncRev ?? null; 773 + 774 + // Get tracked record paths for challenge generation 775 + const recordPaths = this.syncStorage.getRecordPaths(did); 761 776 762 777 const result = await this.remoteVerifier.verifyPeer( 763 778 did, 764 779 state.pdsEndpoint, 765 780 rootCid, 766 781 blockCids, 782 + recordPaths, 767 783 ); 768 784 769 785 if (result.overallPassed) { ··· 799 815 */ 800 816 getVerificationResults(): Map<string, LayeredVerificationResult> { 801 817 return this.lastVerificationResults; 818 + } 819 + 820 + // ============================================ 821 + // Challenge-response verification 822 + // ============================================ 823 + 824 + /** 825 + * Start the challenge scheduler for periodic proof-of-storage verification. 826 + * Requires a ChallengeTransport to communicate with peers. 827 + */ 828 + startChallengeScheduler(transport: ChallengeTransport): void { 829 + if (this.challengeScheduler) return; 830 + 831 + this.challengeScheduler = new ChallengeScheduler( 832 + this.config.DID, 833 + this.policyEngine, 834 + this.syncStorage, 835 + this.challengeStorage, 836 + this.blockStore, 837 + transport, 838 + ); 839 + this.challengeScheduler.start(); 840 + } 841 + 842 + /** 843 + * Get challenge history for a target peer. 844 + */ 845 + getChallengeResults( 846 + targetDid: string, 847 + subjectDid?: string, 848 + ): ChallengeHistoryRow[] { 849 + return this.challengeStorage.getHistory(targetDid, subjectDid); 850 + } 851 + 852 + /** 853 + * Get peer reliability scores. 854 + * If peerDid is provided, returns reliability for that specific peer. 855 + * Otherwise, returns all peer reliability scores. 856 + */ 857 + getPeerReliability(peerDid?: string): PeerReliabilityRow[] { 858 + if (peerDid) { 859 + return this.challengeStorage.getReliability(peerDid); 860 + } 861 + return this.challengeStorage.getAllReliability(); 802 862 } 803 863 804 864 /**
+50
src/replication/sync-storage.ts
··· 44 44 ); 45 45 `); 46 46 47 + // Record paths table: tracks record paths per DID for challenge generation. 48 + this.db.exec(` 49 + CREATE TABLE IF NOT EXISTS replication_record_paths ( 50 + did TEXT NOT NULL, 51 + record_path TEXT NOT NULL, 52 + PRIMARY KEY (did, record_path) 53 + ); 54 + `); 55 + 47 56 // Migration: add root_cid column if missing (for existing databases) 48 57 const columns = this.db 49 58 .prepare("PRAGMA table_info(replication_state)") ··· 256 265 this.db 257 266 .prepare("DELETE FROM firehose_cursor WHERE key = 'cursor'") 258 267 .run(); 268 + } 269 + 270 + // ============================================ 271 + // Record path tracking (for challenge generation) 272 + // ============================================ 273 + 274 + /** 275 + * Track record paths for a DID (batch insert, ignores duplicates). 276 + */ 277 + trackRecordPaths(did: string, paths: string[]): void { 278 + if (paths.length === 0) return; 279 + const insert = this.db.prepare( 280 + "INSERT OR IGNORE INTO replication_record_paths (did, record_path) VALUES (?, ?)", 281 + ); 282 + const batch = this.db.transaction((items: string[]) => { 283 + for (const path of items) { 284 + insert.run(did, path); 285 + } 286 + }); 287 + batch(paths); 288 + } 289 + 290 + /** 291 + * Get all tracked record paths for a DID. 292 + */ 293 + getRecordPaths(did: string): string[] { 294 + const rows = this.db 295 + .prepare( 296 + "SELECT record_path FROM replication_record_paths WHERE did = ?", 297 + ) 298 + .all(did) as Array<{ record_path: string }>; 299 + return rows.map((r) => r.record_path); 300 + } 301 + 302 + /** 303 + * Clear all tracked record paths for a DID. 304 + */ 305 + clearRecordPaths(did: string): void { 306 + this.db 307 + .prepare("DELETE FROM replication_record_paths WHERE did = ?") 308 + .run(did); 259 309 } 260 310 261 311 private rowToState(row: Record<string, unknown>): SyncState {
+15
src/replication/types.ts
··· 64 64 mstProofCount: number; 65 65 /** How often to run verification in ms (default 30 minutes). */ 66 66 verificationIntervalMs: number; 67 + /** Challenge epoch duration in ms (default 1 hour). */ 68 + challengeEpochDurationMs: number; 69 + /** How many records to include in challenges (default 3). */ 70 + challengeRecordCount: number; 71 + /** How many blocks to sample in challenges (default 5). */ 72 + challengeBlockSampleSize: number; 73 + /** Challenge expiration in ms (default 5 minutes). */ 74 + challengeExpirationMs: number; 75 + /** Minimum reliability score for peer trust, 0-1 (default 0.8). */ 76 + reliabilityThreshold: number; 67 77 } 68 78 69 79 export const DEFAULT_VERIFICATION_CONFIG: VerificationConfig = { ··· 71 81 bitswapSampleSize: 5, 72 82 mstProofCount: 2, 73 83 verificationIntervalMs: 30 * 60 * 1000, 84 + challengeEpochDurationMs: 60 * 60 * 1000, 85 + challengeRecordCount: 3, 86 + challengeBlockSampleSize: 5, 87 + challengeExpirationMs: 5 * 60 * 1000, 88 + reliabilityThreshold: 0.8, 74 89 }; 75 90 76 91 /** Result of a single verification layer. */
+203 -22
src/replication/verification.ts
··· 9 9 type LayeredVerificationResult, 10 10 DEFAULT_VERIFICATION_CONFIG, 11 11 } from "./types.js"; 12 + import { generateChallenge, computeEpoch } from "./challenge-response/challenge-generator.js"; 13 + import { verifyResponse } from "./challenge-response/challenge-verifier.js"; 14 + import type { ChallengeTransport } from "./challenge-response/transport.js"; 15 + 16 + /** Optional challenge transport integration for L2/L3 verification. */ 17 + export interface ChallengeIntegrationOptions { 18 + transport: ChallengeTransport; 19 + challengerDid: string; 20 + } 12 21 13 22 export interface VerificationResult { 14 23 checked: number; ··· 86 95 private config: VerificationConfig; 87 96 private blockStore: BlockStore; 88 97 private fetchFn: typeof fetch; 98 + private challengeOptions: ChallengeIntegrationOptions | null; 89 99 90 100 constructor( 91 101 blockStore: BlockStore, 92 102 config?: Partial<VerificationConfig>, 93 103 fetchFn?: typeof fetch, 104 + challengeOptions?: ChallengeIntegrationOptions, 94 105 ) { 95 106 this.blockStore = blockStore; 96 107 this.config = { ...DEFAULT_VERIFICATION_CONFIG, ...config }; 97 108 this.fetchFn = fetchFn ?? fetch; 109 + this.challengeOptions = challengeOptions ?? null; 98 110 } 99 111 100 112 /** ··· 105 117 pdsEndpoint: string, 106 118 rootCid: string | null, 107 119 blockCids: string[], 120 + recordPaths?: string[], 108 121 ): Promise<LayeredVerificationResult> { 109 122 const layers: LayerResult[] = []; 110 123 ··· 118 131 layers.push(await this.verifyViaRasl(pdsEndpoint, blockCids)); 119 132 } 120 133 121 - // Layer 2: Bitswap (stub — requires lower-level libp2p APIs) 122 - layers.push({ 123 - layer: 2, 124 - name: "bitswap", 125 - passed: true, 126 - checked: 0, 127 - available: 0, 128 - missing: [], 129 - error: "not implemented: requires IPFS networking", 130 - durationMs: 0, 131 - }); 134 + // Layer 2: Block-sample challenge (or stub if no transport) 135 + if (this.challengeOptions && rootCid && blockCids.length > 0) { 136 + layers.push( 137 + await this.verifyViaBlockChallenge( 138 + did, 139 + pdsEndpoint, 140 + rootCid, 141 + blockCids, 142 + ), 143 + ); 144 + } else { 145 + layers.push({ 146 + layer: 2, 147 + name: "block-sample", 148 + passed: true, 149 + checked: 0, 150 + available: 0, 151 + missing: [], 152 + error: "not implemented: requires challenge transport", 153 + durationMs: 0, 154 + }); 155 + } 132 156 133 - // Layer 3: MST path proof (stub — requires sync.getRecord + CAR verification) 134 - layers.push({ 135 - layer: 3, 136 - name: "mst-proof", 137 - passed: true, 138 - checked: 0, 139 - available: 0, 140 - missing: [], 141 - error: "not implemented: future enhancement", 142 - durationMs: 0, 143 - }); 157 + // Layer 3: MST path proof challenge (or stub if no transport/paths) 158 + if ( 159 + this.challengeOptions && 160 + rootCid && 161 + recordPaths && 162 + recordPaths.length > 0 163 + ) { 164 + layers.push( 165 + await this.verifyViaMstChallenge( 166 + did, 167 + pdsEndpoint, 168 + rootCid, 169 + recordPaths, 170 + ), 171 + ); 172 + } else { 173 + layers.push({ 174 + layer: 3, 175 + name: "mst-proof", 176 + passed: true, 177 + checked: 0, 178 + available: 0, 179 + missing: [], 180 + error: this.challengeOptions 181 + ? "no record paths available for MST challenge" 182 + : "not implemented: requires challenge transport", 183 + durationMs: 0, 184 + }); 185 + } 144 186 145 187 return { 146 188 did, ··· 149 191 layers, 150 192 overallPassed: layers.every((l) => l.passed), 151 193 }; 194 + } 195 + 196 + /** 197 + * Layer 2: Send a block-sample challenge via ChallengeTransport. 198 + */ 199 + private async verifyViaBlockChallenge( 200 + did: string, 201 + pdsEndpoint: string, 202 + rootCid: string, 203 + blockCids: string[], 204 + ): Promise<LayerResult> { 205 + const start = Date.now(); 206 + try { 207 + const epoch = computeEpoch( 208 + Date.now(), 209 + this.config.challengeEpochDurationMs, 210 + ); 211 + 212 + const challenge = generateChallenge({ 213 + challengerDid: this.challengeOptions!.challengerDid, 214 + targetDid: did, 215 + subjectDid: did, 216 + commitCid: rootCid, 217 + availableRecordPaths: [], 218 + availableBlockCids: blockCids, 219 + challengeType: "block-sample", 220 + epoch, 221 + config: { 222 + blockSampleSize: this.config.challengeBlockSampleSize, 223 + expirationMs: this.config.challengeExpirationMs, 224 + }, 225 + }); 226 + 227 + const response = 228 + await this.challengeOptions!.transport.sendChallenge( 229 + pdsEndpoint, 230 + challenge, 231 + ); 232 + const result = await verifyResponse( 233 + challenge, 234 + response, 235 + this.blockStore, 236 + ); 237 + 238 + return { 239 + layer: 2, 240 + name: "block-sample", 241 + passed: result.passed, 242 + checked: challenge.blockCids?.length ?? 0, 243 + available: 244 + result.blockResults?.filter( 245 + (r) => r.available && r.prefixValid, 246 + ).length ?? 0, 247 + missing: 248 + result.blockResults 249 + ?.filter((r) => !r.available || !r.prefixValid) 250 + .map((r) => r.cid) ?? [], 251 + durationMs: Date.now() - start, 252 + }; 253 + } catch (err) { 254 + return { 255 + layer: 2, 256 + name: "block-sample", 257 + passed: false, 258 + checked: 0, 259 + available: 0, 260 + missing: [], 261 + error: err instanceof Error ? err.message : String(err), 262 + durationMs: Date.now() - start, 263 + }; 264 + } 265 + } 266 + 267 + /** 268 + * Layer 3: Send an MST proof challenge via ChallengeTransport. 269 + */ 270 + private async verifyViaMstChallenge( 271 + did: string, 272 + pdsEndpoint: string, 273 + rootCid: string, 274 + recordPaths: string[], 275 + ): Promise<LayerResult> { 276 + const start = Date.now(); 277 + try { 278 + const epoch = computeEpoch( 279 + Date.now(), 280 + this.config.challengeEpochDurationMs, 281 + ); 282 + 283 + const challenge = generateChallenge({ 284 + challengerDid: this.challengeOptions!.challengerDid, 285 + targetDid: did, 286 + subjectDid: did, 287 + commitCid: rootCid, 288 + availableRecordPaths: recordPaths, 289 + challengeType: "mst-proof", 290 + epoch, 291 + config: { 292 + recordCount: this.config.challengeRecordCount, 293 + expirationMs: this.config.challengeExpirationMs, 294 + }, 295 + }); 296 + 297 + const response = 298 + await this.challengeOptions!.transport.sendChallenge( 299 + pdsEndpoint, 300 + challenge, 301 + ); 302 + const result = await verifyResponse( 303 + challenge, 304 + response, 305 + this.blockStore, 306 + ); 307 + 308 + return { 309 + layer: 3, 310 + name: "mst-proof", 311 + passed: result.passed, 312 + checked: challenge.recordPaths.length, 313 + available: 314 + result.mstResults?.filter((r) => r.valid).length ?? 0, 315 + missing: 316 + result.mstResults 317 + ?.filter((r) => !r.valid) 318 + .map((r) => r.recordPath) ?? [], 319 + durationMs: Date.now() - start, 320 + }; 321 + } catch (err) { 322 + return { 323 + layer: 3, 324 + name: "mst-proof", 325 + passed: false, 326 + checked: 0, 327 + available: 0, 328 + missing: [], 329 + error: err instanceof Error ? err.message : String(err), 330 + durationMs: Date.now() - start, 331 + }; 332 + } 152 333 } 153 334 154 335 /**
+7
src/server.ts
··· 17 17 import { ReplicationManager } from "./replication/replication-manager.js"; 18 18 import { ReplicatedRepoReader } from "./replication/replicated-repo-reader.js"; 19 19 import { PolicyEngine } from "./policy/engine.js"; 20 + import { HttpChallengeTransport } from "./replication/challenge-response/http-transport.js"; 20 21 21 22 // Load configuration 22 23 const config = loadConfig(); ··· 174 175 // Start firehose subscription for real-time updates 175 176 if (config.FIREHOSE_ENABLED) { 176 177 replicationManager.startFirehose(); 178 + } 179 + // Start challenge scheduler if policy engine is available 180 + if (policyEngine) { 181 + const challengeTransport = new HttpChallengeTransport(); 182 + replicationManager.startChallengeScheduler(challengeTransport); 183 + console.log(pc.dim(` Challenges: scheduler started`)); 177 184 } 178 185 } catch (err) { 179 186 console.error(pc.red(` Replication startup failed:`), err);