capjs-core 0.1.0 → 0.1.1
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 +2 -1
- package/src/index.d.ts +89 -2
- package/src/index.js +234 -14
- package/src/rsw.js +167 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "capjs-core",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
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
|
|
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
|
-
|
|
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,58 @@ 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
|
+
// Cache the minter on the keypair object identity, keyed by t. Callers who
|
|
31
|
+
// pass the same `keypair` object back across requests get zero per-call
|
|
32
|
+
// overhead beyond the mint itself.
|
|
33
|
+
const _minterCache = new WeakMap(); // keypair → Map<t, minter>
|
|
34
|
+
|
|
35
|
+
function resolveRswMinter(opts) {
|
|
36
|
+
// explicit minter takes precedence (existing API)
|
|
37
|
+
if (opts.rsw && typeof opts.rsw.mint === "function") return opts.rsw;
|
|
38
|
+
|
|
39
|
+
if (!opts.keypair) {
|
|
40
|
+
throw new Error(
|
|
41
|
+
"[capjs-core] format-2 with 'rsw' requires opts.keypair (from generateRswKeypair) or opts.rsw (from buildRswMinter).",
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Normalize: serialized (strings) → BigInt
|
|
46
|
+
const kp = typeof opts.keypair.N === "string"
|
|
47
|
+
? deserializeRswKeypair(opts.keypair)
|
|
48
|
+
: opts.keypair;
|
|
49
|
+
|
|
50
|
+
// Memoize {keypair, t} → minter. Reuses the precomputed h across calls so
|
|
51
|
+
// the first request after boot is the only one that pays the ~5 ms setup.
|
|
52
|
+
const t = opts.t ?? DEFAULT_RSW_T;
|
|
53
|
+
let byT = _minterCache.get(opts.keypair);
|
|
54
|
+
if (!byT) {
|
|
55
|
+
byT = new Map();
|
|
56
|
+
_minterCache.set(opts.keypair, byT);
|
|
57
|
+
}
|
|
58
|
+
let minter = byT.get(t);
|
|
59
|
+
if (!minter) {
|
|
60
|
+
minter = _buildRswMinter({ ...kp, t });
|
|
61
|
+
byT.set(t, minter);
|
|
62
|
+
}
|
|
63
|
+
return minter;
|
|
64
|
+
}
|
|
18
65
|
|
|
19
66
|
const DEFAULT_CHALLENGE_COUNT = 50;
|
|
20
67
|
const DEFAULT_CHALLENGE_SIZE = 32;
|
|
@@ -35,10 +82,7 @@ function assertSecret(secret) {
|
|
|
35
82
|
if (typeof secret !== "string" && !Buffer.isBuffer(secret)) {
|
|
36
83
|
throw new Error("[capjs-core] secret must be a string or Buffer");
|
|
37
84
|
}
|
|
38
|
-
const len =
|
|
39
|
-
typeof secret === "string"
|
|
40
|
-
? Buffer.byteLength(secret, "utf8")
|
|
41
|
-
: secret.length;
|
|
85
|
+
const len = typeof secret === "string" ? Buffer.byteLength(secret, "utf8") : secret.length;
|
|
42
86
|
if (len < 16) {
|
|
43
87
|
throw new Error("[capjs-core] secret must be at least 16 bytes for security");
|
|
44
88
|
}
|
|
@@ -47,6 +91,10 @@ function assertSecret(secret) {
|
|
|
47
91
|
export async function generateChallenge(secret, opts = {}) {
|
|
48
92
|
assertSecret(secret);
|
|
49
93
|
|
|
94
|
+
if (opts.format === 2) {
|
|
95
|
+
return generateChallengeV2(secret, opts);
|
|
96
|
+
}
|
|
97
|
+
|
|
50
98
|
const c = opts.challengeCount ?? DEFAULT_CHALLENGE_COUNT;
|
|
51
99
|
const s = opts.challengeSize ?? DEFAULT_CHALLENGE_SIZE;
|
|
52
100
|
const d = opts.challengeDifficulty ?? DEFAULT_CHALLENGE_DIFFICULTY;
|
|
@@ -82,8 +130,7 @@ export async function generateChallenge(secret, opts = {}) {
|
|
|
82
130
|
|
|
83
131
|
let instrumentation;
|
|
84
132
|
if (opts.instrumentation) {
|
|
85
|
-
const instrOpts =
|
|
86
|
-
typeof opts.instrumentation === "object" ? opts.instrumentation : {};
|
|
133
|
+
const instrOpts = typeof opts.instrumentation === "object" ? opts.instrumentation : {};
|
|
87
134
|
const generator = opts.instrumentationGenerator || generateInstrumentation;
|
|
88
135
|
const instr = await generator({ ...instrOpts, ttlMs });
|
|
89
136
|
payload.ei = encryptGcm(
|
|
@@ -113,14 +160,17 @@ export async function validateChallenge(secret, body, opts = {}) {
|
|
|
113
160
|
assertSecret(secret);
|
|
114
161
|
|
|
115
162
|
if (!body || typeof body !== "object") return fail("invalid_body");
|
|
116
|
-
if (!body.token || typeof body.token !== "string")
|
|
117
|
-
return fail("missing_token");
|
|
163
|
+
if (!body.token || typeof body.token !== "string") return fail("missing_token");
|
|
118
164
|
const solutions = body.solutions;
|
|
119
165
|
if (!Array.isArray(solutions)) return fail("missing_solutions");
|
|
120
166
|
|
|
121
167
|
const payload = jwtVerify(body.token, secret);
|
|
122
168
|
if (!payload) return fail("invalid_token");
|
|
123
169
|
|
|
170
|
+
if (payload.f === 2) {
|
|
171
|
+
return validateChallengeV2(secret, body, payload, opts);
|
|
172
|
+
}
|
|
173
|
+
|
|
124
174
|
if (opts.scope && payload.sk !== opts.scope) return fail("scope_mismatch");
|
|
125
175
|
|
|
126
176
|
if (!payload.exp || payload.exp < Date.now()) return fail("expired");
|
|
@@ -177,8 +227,7 @@ export async function validateChallenge(secret, body, opts = {}) {
|
|
|
177
227
|
return fail("instr_timeout", { instr_error: true });
|
|
178
228
|
} else if (body.instr) {
|
|
179
229
|
const r = verifyInstrumentationResult(instrMeta, body.instr);
|
|
180
|
-
if (!r.valid)
|
|
181
|
-
return fail(r.reason || "instr_failed", { instr_error: true });
|
|
230
|
+
if (!r.valid) return fail(r.reason || "instr_failed", { instr_error: true });
|
|
182
231
|
} else {
|
|
183
232
|
return fail("instr_missing", { instr_error: true });
|
|
184
233
|
}
|
|
@@ -227,3 +276,174 @@ export async function validateChallenge(secret, body, opts = {}) {
|
|
|
227
276
|
iat: payload.iat,
|
|
228
277
|
};
|
|
229
278
|
}
|
|
279
|
+
|
|
280
|
+
async function generateChallengeV2(secret, opts) {
|
|
281
|
+
const protocols =
|
|
282
|
+
Array.isArray(opts.protocols) && opts.protocols.length ? opts.protocols : ["rsw"];
|
|
283
|
+
const ttlMs = opts.expiresMs ?? DEFAULT_CHALLENGE_TTL_MS;
|
|
284
|
+
const now = Date.now();
|
|
285
|
+
const exp = now + ttlMs;
|
|
286
|
+
|
|
287
|
+
const challenges = [];
|
|
288
|
+
const expected = [];
|
|
289
|
+
|
|
290
|
+
for (const proto of protocols) {
|
|
291
|
+
if (proto === "sha256-pow") {
|
|
292
|
+
const c = opts.challengeCount ?? DEFAULT_CHALLENGE_COUNT;
|
|
293
|
+
const s = opts.challengeSize ?? DEFAULT_CHALLENGE_SIZE;
|
|
294
|
+
const d = opts.challengeDifficulty ?? DEFAULT_CHALLENGE_DIFFICULTY;
|
|
295
|
+
if (
|
|
296
|
+
!Number.isInteger(c) ||
|
|
297
|
+
c < 1 ||
|
|
298
|
+
c > MAX_CHALLENGE_COUNT ||
|
|
299
|
+
!Number.isInteger(s) ||
|
|
300
|
+
s < 1 ||
|
|
301
|
+
s > MAX_CHALLENGE_SIZE ||
|
|
302
|
+
!Number.isInteger(d) ||
|
|
303
|
+
d < 1 ||
|
|
304
|
+
d > MAX_CHALLENGE_DIFFICULTY
|
|
305
|
+
) {
|
|
306
|
+
throw new Error(
|
|
307
|
+
`[capjs-core] invalid params: c≤${MAX_CHALLENGE_COUNT}, s≤${MAX_CHALLENGE_SIZE}, d∈[1,${MAX_CHALLENGE_DIFFICULTY}]`,
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
const target = "0".repeat(d);
|
|
311
|
+
for (let i = 0; i < c; i++) {
|
|
312
|
+
const salt = randomHex(s);
|
|
313
|
+
challenges.push({ protocol: "sha256-pow", payload: { salt, target } });
|
|
314
|
+
expected.push({ protocol: "sha256-pow", salt, target });
|
|
315
|
+
}
|
|
316
|
+
} else if (proto === "rsw") {
|
|
317
|
+
const minter = resolveRswMinter(opts);
|
|
318
|
+
const { x_hex, y_hex } = minter.mint();
|
|
319
|
+
challenges.push({
|
|
320
|
+
protocol: "rsw",
|
|
321
|
+
payload: { N: minter.N_hex, x: x_hex, t: minter.t },
|
|
322
|
+
});
|
|
323
|
+
expected.push({ protocol: "rsw", y: y_hex });
|
|
324
|
+
} else if (proto === "instrumentation") {
|
|
325
|
+
const instrOpts =
|
|
326
|
+
typeof opts.instrumentation === "object" && opts.instrumentation !== null
|
|
327
|
+
? opts.instrumentation
|
|
328
|
+
: {};
|
|
329
|
+
const generator = opts.instrumentationGenerator || generateInstrumentation;
|
|
330
|
+
const instr = await generator({ ...instrOpts, ttlMs });
|
|
331
|
+
challenges.push({ protocol: "instrumentation", payload: { blob: instr.instrumentation } });
|
|
332
|
+
expected.push({
|
|
333
|
+
protocol: "instrumentation",
|
|
334
|
+
instrMeta: {
|
|
335
|
+
id: instr.id,
|
|
336
|
+
expectedVals: instr.expectedVals,
|
|
337
|
+
vars: instr.vars,
|
|
338
|
+
blockAutomatedBrowsers: instr.blockAutomatedBrowsers,
|
|
339
|
+
expires: exp,
|
|
340
|
+
},
|
|
341
|
+
});
|
|
342
|
+
} else {
|
|
343
|
+
throw new Error(`[capjs-core] unknown protocol '${proto}'`);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const tokenPayload = {
|
|
348
|
+
f: 2,
|
|
349
|
+
n: randomHex(16),
|
|
350
|
+
exp,
|
|
351
|
+
iat: now,
|
|
352
|
+
ev: encryptGcm({ expected }, secret, "cap:fmt2-v1"),
|
|
353
|
+
};
|
|
354
|
+
if (opts.scope) tokenPayload.sk = String(opts.scope);
|
|
355
|
+
if (opts.extra && typeof opts.extra === "object") tokenPayload.x = opts.extra;
|
|
356
|
+
|
|
357
|
+
const token = jwtSign(tokenPayload, secret);
|
|
358
|
+
return { token, format: 2, challenges, expires: exp };
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
async function validateChallengeV2(secret, body, payload, opts = {}) {
|
|
362
|
+
if (opts.scope && payload.sk !== opts.scope) return fail("scope_mismatch");
|
|
363
|
+
if (!payload.exp || payload.exp < Date.now()) return fail("expired");
|
|
364
|
+
|
|
365
|
+
const decrypted = decryptGcm(payload.ev, secret, "cap:fmt2-v1");
|
|
366
|
+
if (!decrypted || !Array.isArray(decrypted.expected)) return fail("invalid_token");
|
|
367
|
+
const expected = decrypted.expected;
|
|
368
|
+
const solutions = body.solutions;
|
|
369
|
+
|
|
370
|
+
if (solutions.length !== expected.length) return fail("invalid_solutions");
|
|
371
|
+
|
|
372
|
+
for (let i = 0; i < expected.length; i++) {
|
|
373
|
+
const e = expected[i];
|
|
374
|
+
const s = solutions[i];
|
|
375
|
+
if (!s || typeof s !== "object") return fail("invalid_solution");
|
|
376
|
+
|
|
377
|
+
if (e.protocol === "sha256-pow") {
|
|
378
|
+
const nonce = s.nonce;
|
|
379
|
+
if (typeof nonce !== "number" && typeof nonce !== "string") return fail("invalid_solution");
|
|
380
|
+
const hash = sha256Bytes(e.salt + nonce);
|
|
381
|
+
if (!powMatchesPrefix(hash, parseHexPrefix(e.target))) return fail("invalid_solution");
|
|
382
|
+
} else if (e.protocol === "rsw") {
|
|
383
|
+
if (!verifyRswSolution(e.y, s.y)) return fail("invalid_solution");
|
|
384
|
+
} else if (e.protocol === "instrumentation") {
|
|
385
|
+
const meta = e.instrMeta;
|
|
386
|
+
if (!meta) return fail("instr_corrupted", { instr_error: true });
|
|
387
|
+
if (meta.expires && Date.now() > meta.expires) {
|
|
388
|
+
return fail("instr_expired", { instr_error: true });
|
|
389
|
+
}
|
|
390
|
+
if (s.blocked === true) {
|
|
391
|
+
if (meta.blockAutomatedBrowsers) {
|
|
392
|
+
return fail("instr_automated_browser", { instr_error: true });
|
|
393
|
+
}
|
|
394
|
+
} else if (s.timeout === true) {
|
|
395
|
+
return fail("instr_timeout", { instr_error: true });
|
|
396
|
+
} else if (s.instr) {
|
|
397
|
+
const r = verifyInstrumentationResult(meta, s.instr);
|
|
398
|
+
if (!r.valid) return fail(r.reason || "instr_failed", { instr_error: true });
|
|
399
|
+
} else {
|
|
400
|
+
return fail("instr_missing", { instr_error: true });
|
|
401
|
+
}
|
|
402
|
+
} else {
|
|
403
|
+
return fail("invalid_solution");
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
if (typeof opts.consumeNonce === "function") {
|
|
408
|
+
const sig = jwtSigHex(body.token);
|
|
409
|
+
if (!sig) return fail("invalid_token");
|
|
410
|
+
const ttlMs = Math.max(1, payload.exp - Date.now());
|
|
411
|
+
let claimed;
|
|
412
|
+
try {
|
|
413
|
+
claimed = await opts.consumeNonce(sig, ttlMs);
|
|
414
|
+
} catch (err) {
|
|
415
|
+
return fail("nonce_store_error", { error: String(err?.message || err) });
|
|
416
|
+
}
|
|
417
|
+
if (!claimed) return fail("already_redeemed");
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const tokenTtlMs = opts.tokenTtlMs ?? DEFAULT_TOKEN_TTL_MS;
|
|
421
|
+
const tokenExpires = Date.now() + tokenTtlMs;
|
|
422
|
+
|
|
423
|
+
if (typeof opts.signToken === "function") {
|
|
424
|
+
const signed = await opts.signToken({
|
|
425
|
+
scope: payload.sk ?? null,
|
|
426
|
+
expires: tokenExpires,
|
|
427
|
+
iat: payload.iat,
|
|
428
|
+
});
|
|
429
|
+
return {
|
|
430
|
+
success: true,
|
|
431
|
+
token: signed,
|
|
432
|
+
expires: tokenExpires,
|
|
433
|
+
scope: payload.sk ?? null,
|
|
434
|
+
iat: payload.iat,
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const id = randomHex(8);
|
|
439
|
+
const verToken = randomHex(15);
|
|
440
|
+
const tokenHash = sha256Hex(verToken);
|
|
441
|
+
return {
|
|
442
|
+
success: true,
|
|
443
|
+
token: `${id}:${verToken}`,
|
|
444
|
+
tokenKey: `${id}:${tokenHash}`,
|
|
445
|
+
expires: tokenExpires,
|
|
446
|
+
scope: payload.sk ?? null,
|
|
447
|
+
iat: payload.iat,
|
|
448
|
+
};
|
|
449
|
+
}
|
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
|
+
}
|