capjs-core 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "capjs-core",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Stateless server-side challenge generator and verifier for Cap, a lightweight, modern open-source CAPTCHA alternative. No storage, no filesystem - bring your own.",
5
5
  "keywords": [
6
6
  "anti-abuse",
@@ -42,6 +42,7 @@
42
42
  "src/instrumentation.js",
43
43
  "src/prng.js",
44
44
  "src/crypto.js",
45
+ "src/rsw.js",
45
46
  "README.md",
46
47
  "LICENSE"
47
48
  ],
package/src/index.d.ts CHANGED
@@ -19,7 +19,36 @@ export interface GeneratedInstrumentation {
19
19
  instrumentation: string;
20
20
  }
21
21
 
22
+ export type ProtocolName = "sha256-pow" | "rsw" | "instrumentation";
23
+
24
+ export interface RswKeypair {
25
+ N: bigint;
26
+ p: bigint;
27
+ q: bigint;
28
+ bits?: number;
29
+ }
30
+
31
+ export interface SerializedRswKeypair {
32
+ N: string;
33
+ p: string;
34
+ q: string;
35
+ bits?: number | null;
36
+ }
37
+
38
+ export interface RswMinter {
39
+ N: bigint;
40
+ t: number;
41
+ g: bigint;
42
+ h: bigint;
43
+ modulusBytes: number;
44
+ N_hex: string;
45
+ g_hex: string;
46
+ h_hex: string;
47
+ mint(): { x_hex: string; y_hex: string };
48
+ }
49
+
22
50
  export interface GenerateChallengeOptions {
51
+ // ── format-1 (default) ──
23
52
  challengeCount?: number;
24
53
  challengeSize?: number;
25
54
  challengeDifficulty?: number;
@@ -30,24 +59,64 @@ export interface GenerateChallengeOptions {
30
59
  instrumentationGenerator?: (
31
60
  opts: InstrumentationOptions,
32
61
  ) => Promise<GeneratedInstrumentation>;
62
+
63
+ // ── format-2 opt-in ──
64
+ format?: 2;
65
+ protocols?: ProtocolName[];
66
+ /** RSW keypair, either as raw BigInts (from `generateRswKeypair`) or as a
67
+ * serialized JSON-friendly form (from `serializeRswKeypair`). cap-core
68
+ * builds the minter on first call and memoizes it on this object. */
69
+ keypair?: RswKeypair | SerializedRswKeypair;
70
+ /** Number of sequential squarings the client must perform. Default 75_000. */
71
+ t?: number;
72
+ /** Alternative to `keypair`: pass a pre-built minter (advanced). */
73
+ rsw?: RswMinter;
33
74
  }
34
75
 
35
- export interface ChallengeResult {
76
+ export interface Format2ChallengeEntry {
77
+ protocol: ProtocolName;
78
+ payload: Record<string, unknown>;
79
+ }
80
+
81
+ export interface Format1ChallengeResult {
36
82
  challenge: ChallengeSpec;
37
83
  token: string;
38
84
  expires: number;
39
85
  instrumentation?: string;
40
86
  }
41
87
 
88
+ export interface Format2ChallengeResult {
89
+ token: string;
90
+ format: 2;
91
+ challenges: Format2ChallengeEntry[];
92
+ expires: number;
93
+ }
94
+
95
+ export type ChallengeResult = Format1ChallengeResult | Format2ChallengeResult;
96
+
42
97
  export interface InstrumentationPayload {
43
98
  i: string;
44
99
  state: Record<string, number>;
45
100
  ts?: number;
46
101
  }
47
102
 
103
+ export interface Format2SolutionShaPow { nonce: number | string }
104
+ export interface Format2SolutionRsw { y: string }
105
+ export interface Format2SolutionInstrumentation {
106
+ instr?: InstrumentationPayload;
107
+ blocked?: boolean;
108
+ timeout?: boolean;
109
+ }
110
+ export type Format2Solution =
111
+ | Format2SolutionShaPow
112
+ | Format2SolutionRsw
113
+ | Format2SolutionInstrumentation;
114
+
48
115
  export interface ValidateChallengeBody {
49
116
  token: string;
50
- solutions: number[];
117
+ /** Format-1: an array of nonces. Format-2: an array of protocol-specific
118
+ * solution objects, in the same order as `challenges`. */
119
+ solutions: number[] | Format2Solution[];
51
120
  instr?: InstrumentationPayload;
52
121
  instr_blocked?: boolean;
53
122
  instr_timeout?: boolean;
@@ -97,3 +166,21 @@ export function validateChallenge(
97
166
  body: ValidateChallengeBody,
98
167
  opts?: ValidateChallengeOptions,
99
168
  ): Promise<ValidateChallengeResult>;
169
+
170
+ // ─── RSW (format-2) helpers ────────────────────────────────────────────────
171
+ /** Generates a fresh 2048-bit-by-default RSA-style modulus + factors. Expensive
172
+ * (~700 ms to several seconds depending on prime-roll luck). Generate once
173
+ * at boot and persist the result. */
174
+ export function generateRswKeypair(bits?: number): RswKeypair;
175
+
176
+ /** JSON-friendly form, safe to write to disk or env. */
177
+ export function serializeRswKeypair(kp: RswKeypair): SerializedRswKeypair;
178
+ export function deserializeRswKeypair(s: SerializedRswKeypair): RswKeypair;
179
+
180
+ /** Build a minter for a specific squaring count `t`. Memoized when used via
181
+ * `generateChallenge({ keypair, t })`; this direct API is for callers who
182
+ * want full control. */
183
+ export function buildRswMinter(
184
+ args: { N: bigint; p: bigint; q: bigint; t: number; g?: bigint },
185
+ opts?: { bits?: number },
186
+ ): RswMinter;
package/src/index.js CHANGED
@@ -10,11 +10,51 @@ import {
10
10
  sha256Bytes,
11
11
  sha256Hex,
12
12
  } from "./crypto.js";
13
- import {
14
- generateInstrumentation,
15
- verifyInstrumentationResult,
16
- } from "./instrumentation.js";
13
+ import { generateInstrumentation, verifyInstrumentationResult } from "./instrumentation.js";
17
14
  import { fnv1a, fnv1aResume, prngFromHash } from "./prng.js";
15
+ import {
16
+ buildRswMinter as _buildRswMinter,
17
+ deserializeRswKeypair,
18
+ verifyRswSolution,
19
+ } from "./rsw.js";
20
+
21
+ export {
22
+ buildRswMinter,
23
+ generateRswKeypair,
24
+ serializeRswKeypair,
25
+ deserializeRswKeypair,
26
+ } from "./rsw.js";
27
+
28
+ const DEFAULT_RSW_T = 75_000;
29
+
30
+ const _minterCache = new WeakMap();
31
+
32
+ function resolveRswMinter(opts) {
33
+ if (opts.rsw && typeof opts.rsw.mint === "function") return opts.rsw;
34
+
35
+ if (!opts.keypair) {
36
+ throw new Error(
37
+ "[capjs-core] format-2 with 'rsw' requires opts.keypair (from generateRswKeypair) or opts.rsw (from buildRswMinter).",
38
+ );
39
+ }
40
+
41
+ const kp = typeof opts.keypair.N === "string"
42
+ ? deserializeRswKeypair(opts.keypair)
43
+ : opts.keypair;
44
+
45
+ const t = opts.t ?? DEFAULT_RSW_T;
46
+ let byT = _minterCache.get(opts.keypair);
47
+ if (!byT) {
48
+ byT = new Map();
49
+ _minterCache.set(opts.keypair, byT);
50
+ }
51
+ let minter = byT.get(t);
52
+ if (!minter) {
53
+ minter = _buildRswMinter({ ...kp, t });
54
+ byT.set(t, minter);
55
+ }
56
+ return minter;
57
+ }
18
58
 
19
59
  const DEFAULT_CHALLENGE_COUNT = 50;
20
60
  const DEFAULT_CHALLENGE_SIZE = 32;
@@ -35,10 +75,7 @@ function assertSecret(secret) {
35
75
  if (typeof secret !== "string" && !Buffer.isBuffer(secret)) {
36
76
  throw new Error("[capjs-core] secret must be a string or Buffer");
37
77
  }
38
- const len =
39
- typeof secret === "string"
40
- ? Buffer.byteLength(secret, "utf8")
41
- : secret.length;
78
+ const len = typeof secret === "string" ? Buffer.byteLength(secret, "utf8") : secret.length;
42
79
  if (len < 16) {
43
80
  throw new Error("[capjs-core] secret must be at least 16 bytes for security");
44
81
  }
@@ -47,6 +84,10 @@ function assertSecret(secret) {
47
84
  export async function generateChallenge(secret, opts = {}) {
48
85
  assertSecret(secret);
49
86
 
87
+ if (opts.format === 2) {
88
+ return generateChallengeV2(secret, opts);
89
+ }
90
+
50
91
  const c = opts.challengeCount ?? DEFAULT_CHALLENGE_COUNT;
51
92
  const s = opts.challengeSize ?? DEFAULT_CHALLENGE_SIZE;
52
93
  const d = opts.challengeDifficulty ?? DEFAULT_CHALLENGE_DIFFICULTY;
@@ -82,8 +123,7 @@ export async function generateChallenge(secret, opts = {}) {
82
123
 
83
124
  let instrumentation;
84
125
  if (opts.instrumentation) {
85
- const instrOpts =
86
- typeof opts.instrumentation === "object" ? opts.instrumentation : {};
126
+ const instrOpts = typeof opts.instrumentation === "object" ? opts.instrumentation : {};
87
127
  const generator = opts.instrumentationGenerator || generateInstrumentation;
88
128
  const instr = await generator({ ...instrOpts, ttlMs });
89
129
  payload.ei = encryptGcm(
@@ -113,14 +153,17 @@ export async function validateChallenge(secret, body, opts = {}) {
113
153
  assertSecret(secret);
114
154
 
115
155
  if (!body || typeof body !== "object") return fail("invalid_body");
116
- if (!body.token || typeof body.token !== "string")
117
- return fail("missing_token");
156
+ if (!body.token || typeof body.token !== "string") return fail("missing_token");
118
157
  const solutions = body.solutions;
119
158
  if (!Array.isArray(solutions)) return fail("missing_solutions");
120
159
 
121
160
  const payload = jwtVerify(body.token, secret);
122
161
  if (!payload) return fail("invalid_token");
123
162
 
163
+ if (payload.f === 2) {
164
+ return validateChallengeV2(secret, body, payload, opts);
165
+ }
166
+
124
167
  if (opts.scope && payload.sk !== opts.scope) return fail("scope_mismatch");
125
168
 
126
169
  if (!payload.exp || payload.exp < Date.now()) return fail("expired");
@@ -177,8 +220,7 @@ export async function validateChallenge(secret, body, opts = {}) {
177
220
  return fail("instr_timeout", { instr_error: true });
178
221
  } else if (body.instr) {
179
222
  const r = verifyInstrumentationResult(instrMeta, body.instr);
180
- if (!r.valid)
181
- return fail(r.reason || "instr_failed", { instr_error: true });
223
+ if (!r.valid) return fail(r.reason || "instr_failed", { instr_error: true });
182
224
  } else {
183
225
  return fail("instr_missing", { instr_error: true });
184
226
  }
@@ -227,3 +269,174 @@ export async function validateChallenge(secret, body, opts = {}) {
227
269
  iat: payload.iat,
228
270
  };
229
271
  }
272
+
273
+ async function generateChallengeV2(secret, opts) {
274
+ const protocols =
275
+ Array.isArray(opts.protocols) && opts.protocols.length ? opts.protocols : ["rsw"];
276
+ const ttlMs = opts.expiresMs ?? DEFAULT_CHALLENGE_TTL_MS;
277
+ const now = Date.now();
278
+ const exp = now + ttlMs;
279
+
280
+ const challenges = [];
281
+ const expected = [];
282
+
283
+ for (const proto of protocols) {
284
+ if (proto === "sha256-pow") {
285
+ const c = opts.challengeCount ?? DEFAULT_CHALLENGE_COUNT;
286
+ const s = opts.challengeSize ?? DEFAULT_CHALLENGE_SIZE;
287
+ const d = opts.challengeDifficulty ?? DEFAULT_CHALLENGE_DIFFICULTY;
288
+ if (
289
+ !Number.isInteger(c) ||
290
+ c < 1 ||
291
+ c > MAX_CHALLENGE_COUNT ||
292
+ !Number.isInteger(s) ||
293
+ s < 1 ||
294
+ s > MAX_CHALLENGE_SIZE ||
295
+ !Number.isInteger(d) ||
296
+ d < 1 ||
297
+ d > MAX_CHALLENGE_DIFFICULTY
298
+ ) {
299
+ throw new Error(
300
+ `[capjs-core] invalid params: c≤${MAX_CHALLENGE_COUNT}, s≤${MAX_CHALLENGE_SIZE}, d∈[1,${MAX_CHALLENGE_DIFFICULTY}]`,
301
+ );
302
+ }
303
+ const target = "0".repeat(d);
304
+ for (let i = 0; i < c; i++) {
305
+ const salt = randomHex(s);
306
+ challenges.push({ protocol: "sha256-pow", payload: { salt, target } });
307
+ expected.push({ protocol: "sha256-pow", salt, target });
308
+ }
309
+ } else if (proto === "rsw") {
310
+ const minter = resolveRswMinter(opts);
311
+ const { x_hex, y_hex } = minter.mint();
312
+ challenges.push({
313
+ protocol: "rsw",
314
+ payload: { N: minter.N_hex, x: x_hex, t: minter.t },
315
+ });
316
+ expected.push({ protocol: "rsw", y: y_hex });
317
+ } else if (proto === "instrumentation") {
318
+ const instrOpts =
319
+ typeof opts.instrumentation === "object" && opts.instrumentation !== null
320
+ ? opts.instrumentation
321
+ : {};
322
+ const generator = opts.instrumentationGenerator || generateInstrumentation;
323
+ const instr = await generator({ ...instrOpts, ttlMs });
324
+ challenges.push({ protocol: "instrumentation", payload: { blob: instr.instrumentation } });
325
+ expected.push({
326
+ protocol: "instrumentation",
327
+ instrMeta: {
328
+ id: instr.id,
329
+ expectedVals: instr.expectedVals,
330
+ vars: instr.vars,
331
+ blockAutomatedBrowsers: instr.blockAutomatedBrowsers,
332
+ expires: exp,
333
+ },
334
+ });
335
+ } else {
336
+ throw new Error(`[capjs-core] unknown protocol '${proto}'`);
337
+ }
338
+ }
339
+
340
+ const tokenPayload = {
341
+ f: 2,
342
+ n: randomHex(16),
343
+ exp,
344
+ iat: now,
345
+ ev: encryptGcm({ expected }, secret, "cap:fmt2-v1"),
346
+ };
347
+ if (opts.scope) tokenPayload.sk = String(opts.scope);
348
+ if (opts.extra && typeof opts.extra === "object") tokenPayload.x = opts.extra;
349
+
350
+ const token = jwtSign(tokenPayload, secret);
351
+ return { token, format: 2, challenges, expires: exp };
352
+ }
353
+
354
+ async function validateChallengeV2(secret, body, payload, opts = {}) {
355
+ if (opts.scope && payload.sk !== opts.scope) return fail("scope_mismatch");
356
+ if (!payload.exp || payload.exp < Date.now()) return fail("expired");
357
+
358
+ const decrypted = decryptGcm(payload.ev, secret, "cap:fmt2-v1");
359
+ if (!decrypted || !Array.isArray(decrypted.expected)) return fail("invalid_token");
360
+ const expected = decrypted.expected;
361
+ const solutions = body.solutions;
362
+
363
+ if (solutions.length !== expected.length) return fail("invalid_solutions");
364
+
365
+ for (let i = 0; i < expected.length; i++) {
366
+ const e = expected[i];
367
+ const s = solutions[i];
368
+ if (!s || typeof s !== "object") return fail("invalid_solution");
369
+
370
+ if (e.protocol === "sha256-pow") {
371
+ const nonce = s.nonce;
372
+ if (typeof nonce !== "number" && typeof nonce !== "string") return fail("invalid_solution");
373
+ const hash = sha256Bytes(e.salt + nonce);
374
+ if (!powMatchesPrefix(hash, parseHexPrefix(e.target))) return fail("invalid_solution");
375
+ } else if (e.protocol === "rsw") {
376
+ if (!verifyRswSolution(e.y, s.y)) return fail("invalid_solution");
377
+ } else if (e.protocol === "instrumentation") {
378
+ const meta = e.instrMeta;
379
+ if (!meta) return fail("instr_corrupted", { instr_error: true });
380
+ if (meta.expires && Date.now() > meta.expires) {
381
+ return fail("instr_expired", { instr_error: true });
382
+ }
383
+ if (s.blocked === true) {
384
+ if (meta.blockAutomatedBrowsers) {
385
+ return fail("instr_automated_browser", { instr_error: true });
386
+ }
387
+ } else if (s.timeout === true) {
388
+ return fail("instr_timeout", { instr_error: true });
389
+ } else if (s.instr) {
390
+ const r = verifyInstrumentationResult(meta, s.instr);
391
+ if (!r.valid) return fail(r.reason || "instr_failed", { instr_error: true });
392
+ } else {
393
+ return fail("instr_missing", { instr_error: true });
394
+ }
395
+ } else {
396
+ return fail("invalid_solution");
397
+ }
398
+ }
399
+
400
+ if (typeof opts.consumeNonce === "function") {
401
+ const sig = jwtSigHex(body.token);
402
+ if (!sig) return fail("invalid_token");
403
+ const ttlMs = Math.max(1, payload.exp - Date.now());
404
+ let claimed;
405
+ try {
406
+ claimed = await opts.consumeNonce(sig, ttlMs);
407
+ } catch (err) {
408
+ return fail("nonce_store_error", { error: String(err?.message || err) });
409
+ }
410
+ if (!claimed) return fail("already_redeemed");
411
+ }
412
+
413
+ const tokenTtlMs = opts.tokenTtlMs ?? DEFAULT_TOKEN_TTL_MS;
414
+ const tokenExpires = Date.now() + tokenTtlMs;
415
+
416
+ if (typeof opts.signToken === "function") {
417
+ const signed = await opts.signToken({
418
+ scope: payload.sk ?? null,
419
+ expires: tokenExpires,
420
+ iat: payload.iat,
421
+ });
422
+ return {
423
+ success: true,
424
+ token: signed,
425
+ expires: tokenExpires,
426
+ scope: payload.sk ?? null,
427
+ iat: payload.iat,
428
+ };
429
+ }
430
+
431
+ const id = randomHex(8);
432
+ const verToken = randomHex(15);
433
+ const tokenHash = sha256Hex(verToken);
434
+ return {
435
+ success: true,
436
+ token: `${id}:${verToken}`,
437
+ tokenKey: `${id}:${tokenHash}`,
438
+ expires: tokenExpires,
439
+ scope: payload.sk ?? null,
440
+ iat: payload.iat,
441
+ };
442
+ }
@@ -151,6 +151,25 @@ const DOC_PROP_MARKERS = [
151
151
  "__webdriver_script_func",
152
152
  "__webdriver_script_function",
153
153
  ];
154
+ const NAV_OWN_PROP_MARKERS = [
155
+ "webdriver",
156
+ "userAgent",
157
+ "appVersion",
158
+ "userAgentData",
159
+ "platform",
160
+ "vendor",
161
+ "product",
162
+ "productSub",
163
+ "languages",
164
+ "language",
165
+ "plugins",
166
+ "mimeTypes",
167
+ "hardwareConcurrency",
168
+ "deviceMemory",
169
+ "maxTouchPoints",
170
+ "permissions",
171
+ "connection",
172
+ ];
154
173
  const ATTR_SUBSTRING_MARKERS = ["selenium", "webdriver", "driver"];
155
174
  const STACK_SUBSTRING_MARKERS = ["pptr:", "UtilityScript.", "PhantomJS"];
156
175
  const WINDOW_PREFIX_MARKERS = ["puppeteer_", "cdc_", "$cdc_"];
@@ -179,6 +198,7 @@ function hashWith(seed) {
179
198
  function buildBlockChecks(b, id, hF, hSet, h) {
180
199
  const winHashes = WINDOW_PROP_MARKERS.map(h);
181
200
  const docHashes = DOC_PROP_MARKERS.map(h);
201
+ const navOwnHashes = NAV_OWN_PROP_MARKERS.map(h);
182
202
  const attrSubHashes = ATTR_SUBSTRING_MARKERS.map(h);
183
203
  const stackSubHashes = STACK_SUBSTRING_MARKERS.map(h);
184
204
  const winPrefixHashes = WINDOW_PREFIX_MARKERS.map(h);
@@ -187,16 +207,19 @@ function buildBlockChecks(b, id, hF, hSet, h) {
187
207
  const webglRendererHash = h(WEBGL_RENDERER_MARKER);
188
208
  const productSubGeckoHash = h(PRODUCTSUB_GECKO);
189
209
  const sequentumHash = h(SEQUENTUM_MARKER);
190
-
210
+
191
211
  const checks = [];
192
212
 
193
213
  checks.push(
194
214
  `if (!${b}) { try { var d = Object.getOwnPropertyDescriptors(navigator); var __wh = ${h("webdriver")}; for (const k in d) { if (${hF}(k) === __wh) { ${b} = true; break; } } if (!${b}) { var p = Object.getPrototypeOf(navigator); while (p && !${b}) { for (const k of Object.getOwnPropertyNames(p)) { if (${hF}(k) === __wh) { try { if (navigator[k]) ${b} = true; } catch {} break; } } p = Object.getPrototypeOf(p); } } } catch { ${b} = true; } }`,
195
215
  );
196
216
 
197
- checks.push(
198
- `if (!${b} && Object.getOwnPropertyNames(navigator).length !== 0) ${b} = true;`,
199
- );
217
+ {
218
+ const a = rVar();
219
+ checks.push(
220
+ `if (!${b}) { try { var ${a} = ${JSON.stringify(navOwnHashes)}; for (const k of Object.getOwnPropertyNames(navigator)) { if (${hSet}(${a}, ${hF}(k))) { ${b} = true; break; } } } catch { ${b} = true; } }`,
221
+ );
222
+ }
200
223
 
201
224
  {
202
225
  const a = rVar();
package/src/rsw.js ADDED
@@ -0,0 +1,167 @@
1
+ import { randomBytes } from "node:crypto";
2
+
3
+ function bigintFromBuf(buf) {
4
+ let x = 0n;
5
+ for (const b of buf) x = (x << 8n) | BigInt(b);
6
+ return x;
7
+ }
8
+
9
+ function modpow(b, e, m) {
10
+ let r = 1n;
11
+ b %= m;
12
+ if (b < 0n) b += m;
13
+ while (e > 0n) {
14
+ if (e & 1n) r = (r * b) % m;
15
+ e >>= 1n;
16
+ b = (b * b) % m;
17
+ }
18
+ return r;
19
+ }
20
+
21
+ function modinv(a, m) {
22
+ let [old_r, r] = [a % m, m];
23
+ let [old_s, s] = [1n, 0n];
24
+ if (old_r < 0n) old_r += m;
25
+ while (r !== 0n) {
26
+ const q = old_r / r;
27
+ [old_r, r] = [r, old_r - q * r];
28
+ [old_s, s] = [s, old_s - q * s];
29
+ }
30
+ return ((old_s % m) + m) % m;
31
+ }
32
+
33
+ function isProbablePrime(n, k = 4) {
34
+ if (n < 2n) return false;
35
+ for (const p of [2n, 3n, 5n, 7n, 11n, 13n, 17n, 19n, 23n, 29n, 31n]) {
36
+ if (n === p) return true;
37
+ if (n % p === 0n) return false;
38
+ }
39
+ let d = n - 1n;
40
+ let r = 0n;
41
+ while ((d & 1n) === 0n) {
42
+ d >>= 1n;
43
+ r++;
44
+ }
45
+ const bits = n.toString(2).length;
46
+ const byteLen = (bits + 7) >> 3;
47
+ outer: for (let i = 0; i < k; i++) {
48
+ let a = 0n;
49
+ do {
50
+ a = bigintFromBuf(randomBytes(byteLen)) % (n - 3n);
51
+ } while (a < 2n);
52
+ let x = modpow(a, d, n);
53
+ if (x === 1n || x === n - 1n) continue;
54
+ for (let j = 0n; j < r - 1n; j++) {
55
+ x = (x * x) % n;
56
+ if (x === n - 1n) continue outer;
57
+ }
58
+ return false;
59
+ }
60
+ return true;
61
+ }
62
+
63
+ function randomPrime(bits) {
64
+ const byteLen = (bits + 7) >> 3;
65
+ for (;;) {
66
+ let x = bigintFromBuf(randomBytes(byteLen));
67
+ x |= 1n;
68
+ x |= 1n << BigInt(bits - 1);
69
+ x |= 1n << BigInt(bits - 2);
70
+ x &= (1n << BigInt(bits)) - 1n;
71
+ if (isProbablePrime(x, 4)) return x;
72
+ }
73
+ }
74
+
75
+ function hexOf(bi, byteLen) {
76
+ let s = bi.toString(16);
77
+ if (byteLen != null) {
78
+ const target = byteLen * 2;
79
+ if (s.length < target) s = "0".repeat(target - s.length) + s;
80
+ }
81
+ return s;
82
+ }
83
+
84
+ // generation is expensive (~700 ms for 2048-bit on a modern CPU); persist the result.
85
+ export function generateRswKeypair(bits = 2048) {
86
+ if (bits % 2 !== 0) throw new Error("rsw bits must be even");
87
+ const p = randomPrime(bits >> 1);
88
+ let q = randomPrime(bits >> 1);
89
+ while (q === p) q = randomPrime(bits >> 1);
90
+ const N = p * q;
91
+ return { N, p, q, bits };
92
+ }
93
+
94
+ export function serializeRswKeypair(kp) {
95
+ return { N: kp.N.toString(), p: kp.p.toString(), q: kp.q.toString(), bits: kp.bits ?? null };
96
+ }
97
+ export function deserializeRswKeypair(s) {
98
+ if (!s || typeof s !== "object") throw new Error("invalid serialized rsw keypair");
99
+ const N = typeof s.N === "string" ? BigInt(s.N) : s.N;
100
+ const p = typeof s.p === "string" ? BigInt(s.p) : s.p;
101
+ const q = typeof s.q === "string" ? BigInt(s.q) : s.q;
102
+ if (typeof N !== "bigint" || typeof p !== "bigint" || typeof q !== "bigint") {
103
+ throw new Error("rsw keypair fields must be bigint or string-encoded bigint");
104
+ }
105
+ return { N, p, q, bits: s.bits ?? N.toString(2).length };
106
+ }
107
+
108
+ export function buildRswMinter({ N, p, q, t, g }, opts = {}) {
109
+ if (!N || !p || !q || !t) throw new Error("rsw minter needs {N, p, q, t}");
110
+ const bits = opts.bits ?? N.toString(2).length;
111
+ const modulusBytes = Math.ceil(bits / 8);
112
+ const G = g ?? (bigintFromBuf(randomBytes(modulusBytes)) % (N - 3n)) + 2n;
113
+
114
+ const pm1 = p - 1n;
115
+ const qm1 = q - 1n;
116
+ const e_p = modpow(2n, BigInt(t), pm1);
117
+ const e_q = modpow(2n, BigInt(t), qm1);
118
+
119
+ const hp = modpow(G % p, e_p, p);
120
+ const hq = modpow(G % q, e_q, q);
121
+ const qinv_p = modinv(q % p, p);
122
+ const crtCombine = (ap, aq) => {
123
+ let s = (((ap - aq) % p) + p) % p;
124
+ s = (s * qinv_p) % p;
125
+ return aq + q * s;
126
+ };
127
+ const h = crtCombine(hp, hq);
128
+
129
+ const gp = G % p;
130
+ const gq = G % q;
131
+
132
+ function mintFromR(r) {
133
+ const rp = r % pm1;
134
+ const rq = r % qm1;
135
+ const xp = modpow(gp, rp, p);
136
+ const xq = modpow(gq, rq, q);
137
+ const yp = modpow(hp, rp, p);
138
+ const yq = modpow(hq, rq, q);
139
+ return { x: crtCombine(xp, xq), y: crtCombine(yp, yq) };
140
+ }
141
+
142
+ return {
143
+ N,
144
+ t,
145
+ g: G,
146
+ h,
147
+ modulusBytes,
148
+ N_hex: hexOf(N, modulusBytes),
149
+ g_hex: hexOf(G, modulusBytes),
150
+ h_hex: hexOf(h, modulusBytes),
151
+
152
+ mint() {
153
+ const r = bigintFromBuf(randomBytes(32));
154
+ const { x, y } = mintFromR(r);
155
+ return {
156
+ x_hex: hexOf(x, modulusBytes),
157
+ y_hex: hexOf(y, modulusBytes),
158
+ };
159
+ },
160
+ };
161
+ }
162
+
163
+ export function verifyRswSolution(expectedYHex, claimedYHex) {
164
+ if (typeof claimedYHex !== "string" || typeof expectedYHex !== "string") return false;
165
+ const norm = (s) => s.replace(/^0x/, "").toLowerCase().replace(/^0+/, "");
166
+ return norm(expectedYHex) === norm(claimedYHex);
167
+ }