atproto user agency toolkit for individuals and groups
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
8import { createHash, randomBytes } from "node:crypto";
9import type { StorageChallenge, ChallengeConfig, ChallengeType } from "./types.js";
10import { CHALLENGE_PROTOCOL_VERSION, DEFAULT_CHALLENGE_CONFIG } from "./types.js";
11
12/**
13 * Compute the current epoch from a timestamp and epoch duration.
14 */
15export 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 */
25export 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 */
40function 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 */
58function 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 */
84export 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}