ribaunt 0.2.4 → 0.2.6

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.
Files changed (63) hide show
  1. package/README.md +53 -11
  2. package/dist/cjs/index.d.ts +103 -0
  3. package/dist/cjs/index.d.ts.map +1 -1
  4. package/dist/cjs/index.js +466 -44
  5. package/dist/cjs/index.js.map +1 -1
  6. package/dist/cjs/redis.d.ts +16 -0
  7. package/dist/cjs/redis.d.ts.map +1 -1
  8. package/dist/cjs/redis.js +30 -0
  9. package/dist/cjs/redis.js.map +1 -1
  10. package/dist/cjs/risk.d.ts +4 -0
  11. package/dist/cjs/risk.d.ts.map +1 -1
  12. package/dist/cjs/risk.js +4 -0
  13. package/dist/cjs/risk.js.map +1 -1
  14. package/dist/hash-wasm.js +2671 -0
  15. package/dist/index.d.ts +103 -0
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +429 -43
  18. package/dist/index.js.map +1 -1
  19. package/dist/redis.d.ts +16 -0
  20. package/dist/redis.d.ts.map +1 -1
  21. package/dist/redis.js +30 -0
  22. package/dist/redis.js.map +1 -1
  23. package/dist/risk.d.ts +4 -0
  24. package/dist/risk.d.ts.map +1 -1
  25. package/dist/risk.js +4 -0
  26. package/dist/risk.js.map +1 -1
  27. package/dist/solver-worker.d.ts.map +1 -1
  28. package/dist/solver-worker.js +17 -2
  29. package/dist/solver-worker.js.map +1 -1
  30. package/dist/solver.d.ts +23 -1
  31. package/dist/solver.d.ts.map +1 -1
  32. package/dist/solver.js +176 -1
  33. package/dist/solver.js.map +1 -1
  34. package/dist/wasm-solver.d.ts +29 -0
  35. package/dist/wasm-solver.d.ts.map +1 -1
  36. package/dist/wasm-solver.js +39 -5
  37. package/dist/wasm-solver.js.map +1 -1
  38. package/dist/widget-browser.d.ts +1 -1
  39. package/dist/widget-browser.d.ts.map +1 -1
  40. package/dist/widget-browser.js +1 -1
  41. package/dist/widget-browser.js.map +1 -1
  42. package/dist/widget-react.d.ts.map +1 -1
  43. package/dist/widget-react.js +12 -0
  44. package/dist/widget-react.js.map +1 -1
  45. package/dist/worker-client.d.ts +20 -1
  46. package/dist/worker-client.d.ts.map +1 -1
  47. package/dist/worker-client.js +18 -0
  48. package/dist/worker-client.js.map +1 -1
  49. package/package.json +9 -3
  50. package/SECURITY.md +0 -36
  51. package/demo/lib/ribaunt-solver.wasm +0 -0
  52. package/demo/lib/solver-worker.js +0 -174
  53. package/demo/lib/solver-worker.js.map +0 -1
  54. package/demo/lib/solver.js +0 -135
  55. package/demo/lib/solver.js.map +0 -1
  56. package/demo/lib/wasm-solver.js +0 -296
  57. package/demo/lib/wasm-solver.js.map +0 -1
  58. package/demo/lib/widget-browser.js +0 -9
  59. package/demo/lib/widget-browser.js.map +0 -1
  60. package/demo/lib/widget.js +0 -942
  61. package/demo/lib/widget.js.map +0 -1
  62. package/demo/lib/worker-client.js +0 -128
  63. package/demo/lib/worker-client.js.map +0 -1
package/dist/index.js CHANGED
@@ -2,6 +2,16 @@ import crypto from 'crypto';
2
2
  import jwt from 'jsonwebtoken';
3
3
  import { DEFAULT_RISK_THRESHOLDS, defaultScorer, validateRiskThresholds, validateScorerOutput, } from './risk.js';
4
4
  export { DEFAULT_RISK_THRESHOLDS };
5
+ export const HARD_MAX = {
6
+ m: 32 * 1024,
7
+ t: 3,
8
+ p: 1,
9
+ hashLen: 32,
10
+ };
11
+ export const ARGON_PROFILES = {
12
+ mobile: { m: 8 * 1024, t: 1, p: 1, hashLen: 32 },
13
+ standard: { m: 8 * 1024, t: 1, p: 1, hashLen: 32 },
14
+ };
5
15
  export class RateLimitedError extends Error {
6
16
  constructor(message = 'Rate limit exceeded') {
7
17
  super(message);
@@ -42,9 +52,69 @@ const DEFAULT_BOUNDS = {
42
52
  minAmount: 1,
43
53
  maxAmount: 8,
44
54
  };
55
+ const DEFAULT_BOUNDS_SHA = DEFAULT_BOUNDS;
56
+ const DEFAULT_BOUNDS_ARGON = {
57
+ minDifficulty: 1,
58
+ maxDifficulty: 2,
59
+ minAmount: 1,
60
+ maxAmount: 8,
61
+ };
45
62
  const MAX_WORKLOAD_DIFFICULTY = 64;
63
+ const MAX_WORKLOAD_ARGON_DIFFICULTY = 8;
46
64
  const MAX_WORKLOAD_AMOUNT = 64;
47
65
  const MAX_WORKLOAD_CANDIDATES = 10000;
66
+ let cachedArgon2id = null;
67
+ let argon2idLoadPromise = null;
68
+ async function getArgon2idFn() {
69
+ if (cachedArgon2id)
70
+ return cachedArgon2id;
71
+ if (argon2idLoadPromise)
72
+ return argon2idLoadPromise;
73
+ argon2idLoadPromise = (async () => {
74
+ // hash-wasm is CJS; handle both default and named interop
75
+ const mod = await import('hash-wasm');
76
+ const pkg = mod.default ?? mod;
77
+ const fn = pkg.argon2id;
78
+ if (typeof fn !== 'function')
79
+ throw new Error('hash-wasm argon2id not available');
80
+ cachedArgon2id = fn;
81
+ return fn;
82
+ })();
83
+ return argon2idLoadPromise;
84
+ }
85
+ function padSalt(challenge) {
86
+ if (challenge.length >= 16)
87
+ return challenge.slice(0, 16);
88
+ return challenge.padEnd(16, '0');
89
+ }
90
+ function resolveArgonParams(profile) {
91
+ const normalized = profile ?? 'mobile';
92
+ if (normalized !== 'mobile' && normalized !== 'standard') {
93
+ throw new Error('argonProfile must be "mobile" or "standard"');
94
+ }
95
+ return ARGON_PROFILES[normalized];
96
+ }
97
+ async function argon2idHash(challenge, nonce, params) {
98
+ const argon2id = await getArgon2idFn();
99
+ const salt = padSalt(challenge);
100
+ const password = `${challenge}${String(nonce)}`;
101
+ return argon2id({
102
+ password,
103
+ salt,
104
+ parallelism: params.p,
105
+ iterations: params.t,
106
+ memorySize: params.m,
107
+ hashLength: params.hashLen,
108
+ outputType: 'hex',
109
+ });
110
+ }
111
+ function assertAlgorithm(value) {
112
+ if (value === undefined)
113
+ return 'sha256';
114
+ if (value === 'sha256' || value === 'argon2id')
115
+ return value;
116
+ throw new Error('algorithm must be "sha256" or "argon2id"');
117
+ }
48
118
  function assertFiniteInteger(value, name, minimum) {
49
119
  if (!Number.isFinite(value))
50
120
  throw new Error(`${name} must be a finite number`);
@@ -57,17 +127,40 @@ function isValidPayload(payload) {
57
127
  if (!payload || typeof payload !== 'object')
58
128
  return false;
59
129
  const value = payload;
60
- return typeof value.challenge === 'string'
61
- && value.challenge.length > 0
62
- && typeof value.difficulty === 'number'
63
- && Number.isInteger(value.difficulty)
64
- && value.difficulty >= 1
65
- && value.difficulty <= 64
66
- && typeof value.expires === 'number'
67
- && Number.isInteger(value.expires)
68
- && typeof value.jti === 'string'
69
- && value.jti.length > 0
70
- && (value.contextHash === undefined || /^[a-f0-9]{64}$/.test(value.contextHash));
130
+ if (typeof value.challenge !== 'string' || value.challenge.length === 0)
131
+ return false;
132
+ if (typeof value.difficulty !== 'number' || !Number.isInteger(value.difficulty) || value.difficulty < 1)
133
+ return false;
134
+ const alg = (value.alg ?? 'sha256');
135
+ if (alg === 'argon2id') {
136
+ if (value.difficulty > MAX_WORKLOAD_ARGON_DIFFICULTY)
137
+ return false;
138
+ if (typeof value.m !== 'number' || !Number.isInteger(value.m) || value.m < 8 || value.m > HARD_MAX.m)
139
+ return false;
140
+ if (typeof value.t !== 'number' || !Number.isInteger(value.t) || value.t < 1 || value.t > HARD_MAX.t)
141
+ return false;
142
+ if (typeof value.p !== 'number' || !Number.isInteger(value.p) || value.p < 1 || value.p > HARD_MAX.p)
143
+ return false;
144
+ if (value.hashLen !== undefined && value.hashLen !== 32)
145
+ return false;
146
+ // enforce at least pow of two? not needed
147
+ }
148
+ else if (alg === 'sha256') {
149
+ if (value.difficulty > MAX_WORKLOAD_DIFFICULTY)
150
+ return false;
151
+ if (value.m !== undefined || value.t !== undefined || value.p !== undefined || value.hashLen !== undefined)
152
+ return false;
153
+ }
154
+ else {
155
+ return false;
156
+ }
157
+ if (typeof value.expires !== 'number' || !Number.isInteger(value.expires))
158
+ return false;
159
+ if (typeof value.jti !== 'string' || value.jti.length === 0)
160
+ return false;
161
+ if (value.contextHash !== undefined && !/^[a-f0-9]{64}$/.test(value.contextHash))
162
+ return false;
163
+ return true;
71
164
  }
72
165
  function assertRange(value, name, minimum, maximum) {
73
166
  if (!Number.isFinite(value) || value < minimum || value > maximum) {
@@ -75,20 +168,32 @@ function assertRange(value, name, minimum, maximum) {
75
168
  }
76
169
  return value;
77
170
  }
171
+ function getDefaultBounds(algorithm) {
172
+ return algorithm === 'argon2id' ? DEFAULT_BOUNDS_ARGON : DEFAULT_BOUNDS_SHA;
173
+ }
174
+ function getMaxDifficulty(algorithm) {
175
+ return algorithm === 'argon2id' ? MAX_WORKLOAD_ARGON_DIFFICULTY : MAX_WORKLOAD_DIFFICULTY;
176
+ }
78
177
  function normalizeBounds(options) {
79
- const minDifficulty = assertFiniteInteger(options.minDifficulty ?? DEFAULT_BOUNDS.minDifficulty, 'Minimum difficulty', 1);
80
- if (minDifficulty > MAX_WORKLOAD_DIFFICULTY) {
81
- throw new Error(`Minimum difficulty must be at most ${MAX_WORKLOAD_DIFFICULTY}`);
82
- }
83
- const maxDifficulty = assertFiniteInteger(options.maxDifficulty ?? DEFAULT_BOUNDS.maxDifficulty, 'Maximum difficulty', minDifficulty);
84
- if (maxDifficulty > MAX_WORKLOAD_DIFFICULTY) {
85
- throw new Error(`Maximum difficulty must be at most ${MAX_WORKLOAD_DIFFICULTY}`);
86
- }
87
- const minAmount = assertFiniteInteger(options.minAmount ?? DEFAULT_BOUNDS.minAmount, 'Minimum amount', 1);
178
+ const algorithm = assertAlgorithm(options.algorithm);
179
+ return normalizeBoundsInternal(options, algorithm);
180
+ }
181
+ function normalizeBoundsInternal(options, algorithm = 'sha256') {
182
+ const defaults = getDefaultBounds(algorithm);
183
+ const maxDiff = getMaxDifficulty(algorithm);
184
+ const minDifficulty = assertFiniteInteger(options.minDifficulty ?? defaults.minDifficulty, 'Minimum difficulty', 1);
185
+ if (minDifficulty > maxDiff) {
186
+ throw new Error(`Minimum difficulty must be at most ${maxDiff}`);
187
+ }
188
+ const maxDifficulty = assertFiniteInteger(options.maxDifficulty ?? defaults.maxDifficulty, 'Maximum difficulty', minDifficulty);
189
+ if (maxDifficulty > maxDiff) {
190
+ throw new Error(`Maximum difficulty must be at most ${maxDiff}`);
191
+ }
192
+ const minAmount = assertFiniteInteger(options.minAmount ?? defaults.minAmount, 'Minimum amount', 1);
88
193
  if (minAmount > MAX_WORKLOAD_AMOUNT) {
89
194
  throw new Error(`Minimum amount must be at most ${MAX_WORKLOAD_AMOUNT}`);
90
195
  }
91
- const maxAmount = assertFiniteInteger(options.maxAmount ?? DEFAULT_BOUNDS.maxAmount, 'Maximum amount', minAmount);
196
+ const maxAmount = assertFiniteInteger(options.maxAmount ?? defaults.maxAmount, 'Maximum amount', minAmount);
92
197
  if (maxAmount > MAX_WORKLOAD_AMOUNT) {
93
198
  throw new Error(`Maximum amount must be at most ${MAX_WORKLOAD_AMOUNT}`);
94
199
  }
@@ -96,18 +201,25 @@ function normalizeBounds(options) {
96
201
  if (candidateCount > MAX_WORKLOAD_CANDIDATES) {
97
202
  throw new Error(`Workload bounds too large: candidate count ${candidateCount} exceeds ${MAX_WORKLOAD_CANDIDATES}`);
98
203
  }
99
- return { minDifficulty, maxDifficulty, minAmount, maxAmount };
204
+ return { minDifficulty, maxDifficulty, minAmount, maxAmount, algorithm };
100
205
  }
101
206
  function closestWorkload(targetAttempts, bounds) {
102
207
  let best;
103
208
  let bestDistance = Number.POSITIVE_INFINITY;
209
+ const algorithm = bounds.algorithm ?? 'sha256';
210
+ const argon = algorithm === 'argon2id' ? resolveArgonParams(bounds.argonProfile) : undefined;
104
211
  for (let difficulty = bounds.minDifficulty; difficulty <= bounds.maxDifficulty; difficulty++) {
105
212
  for (let amount = bounds.minAmount; amount <= bounds.maxAmount; amount++) {
106
213
  const estimatedAttempts = (16 ** difficulty) * amount;
107
214
  const distance = Math.abs(Math.log(estimatedAttempts / targetAttempts));
108
215
  if (distance < bestDistance) {
109
216
  bestDistance = distance;
110
- best = { difficulty, amount, estimatedAttempts };
217
+ if (algorithm === 'argon2id') {
218
+ best = { difficulty, amount, estimatedAttempts, algorithm, argon: argon };
219
+ }
220
+ else {
221
+ best = { difficulty, amount, estimatedAttempts, algorithm };
222
+ }
111
223
  }
112
224
  }
113
225
  }
@@ -115,9 +227,28 @@ function closestWorkload(targetAttempts, bounds) {
115
227
  }
116
228
  /**
117
229
  * Selects bounded proof-of-work using a server-owned risk floor and untrusted timing calibration.
230
+ * For `algorithm:'argon2id'` difficulty is capped to the argon max (default 2) and the returned
231
+ * workload carries `argon:{m,t,p}` derived from `argonProfile` so the device can comfortably solve it.
118
232
  */
119
233
  export function selectWorkload(options = {}) {
120
- const bounds = normalizeBounds(options);
234
+ const algorithm = assertAlgorithm(options.algorithm);
235
+ if (options.argonProfile !== undefined) {
236
+ if (algorithm !== 'argon2id')
237
+ throw new Error('argonProfile is only valid when algorithm is "argon2id"');
238
+ if (options.argonProfile !== 'mobile' && options.argonProfile !== 'standard') {
239
+ throw new Error('argonProfile must be "mobile" or "standard"');
240
+ }
241
+ }
242
+ // Validate argonProfile early
243
+ if (algorithm === 'argon2id') {
244
+ // Trigger validation (defaults to mobile when undefined)
245
+ resolveArgonParams(options.argonProfile);
246
+ }
247
+ const bounds = normalizeBounds({ ...options, algorithm });
248
+ // Carry argonProfile through bounds object for closestWorkload to consume (without polluting validation)
249
+ if (algorithm === 'argon2id' && options.argonProfile !== undefined) {
250
+ bounds.argonProfile = options.argonProfile;
251
+ }
121
252
  const riskScore = assertRange(options.riskScore ?? 50, 'Risk score', 0, 100);
122
253
  const targetDurationMs = assertFiniteInteger(options.targetDurationMs ?? 750, 'Target duration', 1);
123
254
  const minimumAttempts = 16 ** bounds.minDifficulty
@@ -131,33 +262,54 @@ export function selectWorkload(options = {}) {
131
262
  targetAttempts = Math.max(minimumAttempts, calibratedAttempts);
132
263
  }
133
264
  const maximumAttempts = (16 ** bounds.maxDifficulty) * bounds.maxAmount;
134
- return closestWorkload(Math.min(targetAttempts, maximumAttempts), bounds);
265
+ const workload = closestWorkload(Math.min(targetAttempts, maximumAttempts), bounds);
266
+ // Ensure argon carries profile-resolved params if not already via closestWorkload
267
+ if (algorithm === 'argon2id' && !workload.argon) {
268
+ workload.argon = resolveArgonParams(options.argonProfile);
269
+ }
270
+ return workload;
135
271
  }
136
272
  function validateAssessWorkloadOptions(workload) {
273
+ const alg = assertAlgorithm(workload.algorithm);
274
+ const defaults = getDefaultBounds(alg);
275
+ const maxDiffForAlg = getMaxDifficulty(alg);
137
276
  // Reuse same validation messages as normalizeBounds / selectWorkload for regression safety
138
277
  if (workload.minDifficulty !== undefined) {
139
278
  assertFiniteInteger(workload.minDifficulty, 'Minimum difficulty', 1);
279
+ if (Math.floor(workload.minDifficulty) > maxDiffForAlg) {
280
+ throw new Error(`Minimum difficulty must be at most ${maxDiffForAlg}`);
281
+ }
140
282
  }
141
283
  if (workload.maxDifficulty !== undefined) {
142
284
  // Need to ensure we validate max >= min (using resolved min)
143
- const min = workload.minDifficulty !== undefined ? Math.floor(workload.minDifficulty) : DEFAULT_BOUNDS.minDifficulty;
285
+ const min = workload.minDifficulty !== undefined ? Math.floor(workload.minDifficulty) : defaults.minDifficulty;
144
286
  assertFiniteInteger(workload.maxDifficulty, 'Maximum difficulty', min);
287
+ if (Math.floor(workload.maxDifficulty) > maxDiffForAlg) {
288
+ throw new Error(`Maximum difficulty must be at most ${maxDiffForAlg}`);
289
+ }
145
290
  }
146
291
  else if (workload.minDifficulty !== undefined) {
147
292
  // If only minDifficulty provided, still need to ensure default max >= min
148
- if (DEFAULT_BOUNDS.maxDifficulty < Math.floor(workload.minDifficulty)) {
293
+ if (defaults.maxDifficulty < Math.floor(workload.minDifficulty)) {
149
294
  throw new Error(`Maximum difficulty must be at least ${Math.floor(workload.minDifficulty)}`);
150
295
  }
151
296
  }
297
+ if (workload.argonProfile !== undefined) {
298
+ const p = workload.argonProfile;
299
+ if (alg !== 'argon2id')
300
+ throw new Error('argonProfile is only valid when algorithm is "argon2id"');
301
+ if (p !== 'mobile' && p !== 'standard')
302
+ throw new Error('argonProfile must be "mobile" or "standard"');
303
+ }
152
304
  if (workload.minAmount !== undefined) {
153
305
  assertFiniteInteger(workload.minAmount, 'Minimum amount', 1);
154
306
  }
155
307
  if (workload.maxAmount !== undefined) {
156
- const minAmt = workload.minAmount !== undefined ? Math.floor(workload.minAmount) : DEFAULT_BOUNDS.minAmount;
308
+ const minAmt = workload.minAmount !== undefined ? Math.floor(workload.minAmount) : defaults.minAmount;
157
309
  assertFiniteInteger(workload.maxAmount, 'Maximum amount', minAmt);
158
310
  }
159
311
  else if (workload.minAmount !== undefined) {
160
- if (DEFAULT_BOUNDS.maxAmount < Math.floor(workload.minAmount)) {
312
+ if (defaults.maxAmount < Math.floor(workload.minAmount)) {
161
313
  throw new Error(`Maximum amount must be at least ${Math.floor(workload.minAmount)}`);
162
314
  }
163
315
  }
@@ -182,9 +334,14 @@ function validateAssessWorkloadOptions(workload) {
182
334
  if (workload.minDifficulty !== undefined ||
183
335
  workload.maxDifficulty !== undefined ||
184
336
  workload.minAmount !== undefined ||
185
- workload.maxAmount !== undefined) {
337
+ workload.maxAmount !== undefined ||
338
+ workload.algorithm !== undefined ||
339
+ workload.argonProfile !== undefined) {
186
340
  normalizeBounds(workload);
187
341
  }
342
+ if (workload.algorithm !== undefined) {
343
+ assertAlgorithm(workload.algorithm);
344
+ }
188
345
  }
189
346
  /**
190
347
  * Programmable risk-assessment subsystem.
@@ -258,6 +415,13 @@ export async function assess(options) {
258
415
  }
259
416
  return { risk, action };
260
417
  }
418
+ /**
419
+ * Measures SHA-256 hashing speed on Node.js to calibrate adaptive workload selection.
420
+ * Runs a small benchmark to estimate device performance.
421
+ *
422
+ * @param iterations - Number of SHA-256 hashes to run (default: 128)
423
+ * @returns Calibration object with iterations and measured duration
424
+ */
261
425
  export function calibrateNode(iterations = 128) {
262
426
  if (!Number.isFinite(iterations) || iterations < 1) {
263
427
  throw new Error('Calibration iterations must be at least 1');
@@ -272,7 +436,71 @@ export function calibrateNode(iterations = 128) {
272
436
  durationMs: Math.max(1, Math.round(performance.now() - startedAt)),
273
437
  };
274
438
  }
439
+ /**
440
+ * Alias for calibrateNode - measures device hashing speed for adaptive workload.
441
+ */
275
442
  export const calibrateClient = calibrateNode;
443
+ let cachedArgonCalibrationWarmup = null;
444
+ async function ensureArgonWarmup() {
445
+ if (cachedArgonCalibrationWarmup)
446
+ return cachedArgonCalibrationWarmup;
447
+ // Warm up WASM once (first argon hash triggers compilation)
448
+ cachedArgonCalibrationWarmup = (async () => {
449
+ try {
450
+ await getArgon2idFn();
451
+ // tiny probe to force WASM compile — one hash at mobile params
452
+ const argon = await getArgon2idFn();
453
+ await argon({
454
+ password: 'ribaunt-warmup',
455
+ salt: 'ribaunt-warmup-16b',
456
+ parallelism: 1,
457
+ iterations: 1,
458
+ memorySize: 8 * 1024,
459
+ hashLength: 32,
460
+ outputType: 'hex',
461
+ });
462
+ }
463
+ catch {
464
+ // warmup best-effort; calibration will still surface errors
465
+ }
466
+ })();
467
+ return cachedArgonCalibrationWarmup;
468
+ }
469
+ /**
470
+ * Calibrate Argon2id on Node — measures device speed for the memory-hard algorithm.
471
+ * Uses a smaller iteration default (16) because each hash is ~6ms (mobile) vs microseconds for SHA.
472
+ * The returned shape is identical to `calibrateNode()` so `selectWorkload({calibration, algorithm:'argon2id'})` can consume it.
473
+ * Keep SHA `calibrateNode()` for `algorithm:'sha256'` — mismatched calibration will over/under-estimate.
474
+ */
475
+ export async function calibrateArgonNode(iterations = 16) {
476
+ if (!Number.isFinite(iterations) || iterations < 1) {
477
+ throw new Error('Calibration iterations must be at least 1');
478
+ }
479
+ const normalizedIterations = Math.floor(iterations);
480
+ await ensureArgonWarmup();
481
+ const profile = resolveArgonParams('mobile');
482
+ const startedAt = performance.now();
483
+ for (let index = 0; index < normalizedIterations; index++) {
484
+ await argon2idHash(`ribaunt-calibration:${index}`, '0', profile);
485
+ }
486
+ return {
487
+ iterations: normalizedIterations,
488
+ durationMs: Math.max(1, Math.round(performance.now() - startedAt)),
489
+ };
490
+ }
491
+ /**
492
+ * Alias for calibrateArgonNode - measures Argon2id performance for adaptive workload.
493
+ */
494
+ export const calibrateArgonClient = calibrateArgonNode;
495
+ /**
496
+ * Resets Argon2id module cache for testing purposes.
497
+ * Should not be called in production code.
498
+ */
499
+ export function __resetArgonForTesting() {
500
+ cachedArgon2id = null;
501
+ argon2idLoadPromise = null;
502
+ cachedArgonCalibrationWarmup = null;
503
+ }
276
504
  function generateChallenge(length = 8) {
277
505
  return crypto.randomBytes(length).toString('base64url').slice(0, length);
278
506
  }
@@ -294,7 +522,7 @@ function getSecret() {
294
522
  }
295
523
  return secret;
296
524
  }
297
- function createSingleChallenge(difficulty, ttlSeconds, context) {
525
+ function createSingleChallenge(difficulty, ttlSeconds, context, algorithm = 'sha256', argonParams) {
298
526
  const jti = crypto.randomUUID();
299
527
  const payload = {
300
528
  challenge: generateChallenge(),
@@ -302,6 +530,22 @@ function createSingleChallenge(difficulty, ttlSeconds, context) {
302
530
  expires: Math.floor(Date.now() / 1000) + ttlSeconds,
303
531
  jti,
304
532
  };
533
+ if (algorithm === 'argon2id') {
534
+ payload.alg = 'argon2id';
535
+ const p = argonParams ?? resolveArgonParams('mobile');
536
+ payload.m = p.m;
537
+ payload.t = p.t;
538
+ payload.p = p.p;
539
+ payload.hashLen = p.hashLen;
540
+ if (difficulty < 1 || difficulty > MAX_WORKLOAD_ARGON_DIFFICULTY) {
541
+ throw new Error(`Challenge difficulty must be between 1 and ${MAX_WORKLOAD_ARGON_DIFFICULTY} for argon2id`);
542
+ }
543
+ }
544
+ else {
545
+ if (difficulty < 1 || difficulty > MAX_WORKLOAD_DIFFICULTY) {
546
+ throw new Error(`Challenge difficulty must be at most ${MAX_WORKLOAD_DIFFICULTY}`);
547
+ }
548
+ }
305
549
  if (context !== undefined)
306
550
  payload.contextHash = hashContext(context, jti);
307
551
  return jwt.sign(payload, getSecret(), { algorithm: 'HS256' });
@@ -318,13 +562,51 @@ function emitEvent(options, event) {
318
562
  }
319
563
  export async function createChallenge(difficultyOrOptions = 5, amount = 4, ttlSeconds = 30) {
320
564
  const options = typeof difficultyOrOptions === 'object' ? difficultyOrOptions : undefined;
321
- const selectedWorkload = options?.workload
322
- ?? (options?.difficulty === 'auto' ? selectWorkload(options) : undefined);
565
+ const algorithm = assertAlgorithm(options?.algorithm);
566
+ if (options?.argonProfile !== undefined && algorithm !== 'argon2id') {
567
+ throw new Error('argonProfile is only valid when algorithm is "argon2id"');
568
+ }
569
+ let selectedWorkload;
570
+ if (options?.workload) {
571
+ const w = options.workload;
572
+ const wDiff = assertFiniteInteger(w.difficulty, 'Challenge difficulty', 1);
573
+ const maxForAlg = getMaxDifficulty(algorithm);
574
+ if (wDiff > maxForAlg)
575
+ throw new Error(`Challenge difficulty must be at most ${maxForAlg} for ${algorithm}`);
576
+ const wAmount = assertFiniteInteger(w.amount, 'Challenge amount', 1);
577
+ selectedWorkload = {
578
+ difficulty: wDiff,
579
+ amount: wAmount,
580
+ estimatedAttempts: (16 ** wDiff) * wAmount,
581
+ algorithm,
582
+ ...(algorithm === 'argon2id' ? { argon: resolveArgonParams(options.argonProfile) } : {}),
583
+ };
584
+ }
585
+ else if (options?.difficulty === 'auto') {
586
+ const workloadInput = {
587
+ ...options,
588
+ algorithm,
589
+ ...(options.argonProfile !== undefined ? { argonProfile: options.argonProfile } : {}),
590
+ };
591
+ selectedWorkload = selectWorkload(workloadInput);
592
+ }
593
+ // When algorithm is argon2id and workload was explicit or auto, resolve argon params for token
594
+ let workloadArgon;
595
+ if (algorithm === 'argon2id') {
596
+ if (selectedWorkload?.argon) {
597
+ workloadArgon = selectedWorkload.argon;
598
+ }
599
+ else {
600
+ workloadArgon = resolveArgonParams(options?.argonProfile);
601
+ }
602
+ }
323
603
  const configuredDifficulty = options?.difficulty === 'auto' ? undefined : options?.difficulty;
324
- const difficulty = assertFiniteInteger(selectedWorkload?.difficulty ?? configuredDifficulty
325
- ?? (typeof difficultyOrOptions === 'number' ? difficultyOrOptions : 5), 'Challenge difficulty', 1);
326
- if (difficulty > 64)
327
- throw new Error('Challenge difficulty must be at most 64');
604
+ const difficultyRaw = selectedWorkload?.difficulty ?? configuredDifficulty
605
+ ?? (typeof difficultyOrOptions === 'number' ? difficultyOrOptions : 5);
606
+ const difficulty = assertFiniteInteger(difficultyRaw, 'Challenge difficulty', 1);
607
+ const maxForAlg = getMaxDifficulty(algorithm);
608
+ if (difficulty > maxForAlg)
609
+ throw new Error(`Challenge difficulty must be at most ${maxForAlg} for ${algorithm}`);
328
610
  const normalizedAmount = assertFiniteInteger(selectedWorkload?.amount ?? options?.amount ?? (options ? 1 : amount), 'Challenge amount', 1);
329
611
  const ttlValue = options?.ttlSeconds ?? (options ? 30 : ttlSeconds);
330
612
  if (Number.isFinite(ttlValue) && Math.floor(ttlValue) < 1) {
@@ -336,8 +618,13 @@ export async function createChallenge(difficultyOrOptions = 5, amount = 4, ttlSe
336
618
  if (!allowed)
337
619
  throw new RateLimitedError('Challenge issuance rate limited');
338
620
  }
339
- const challenges = Array.from({ length: normalizedAmount }, () => createSingleChallenge(difficulty, normalizedTtl, options?.context));
340
- emitEvent(options, { type: 'challenge-issued', difficulty, amount: normalizedAmount });
621
+ const challenges = Array.from({ length: normalizedAmount }, () => createSingleChallenge(difficulty, normalizedTtl, options?.context, algorithm, workloadArgon));
622
+ if (algorithm === 'argon2id') {
623
+ emitEvent(options, { type: 'challenge-issued', difficulty, amount: normalizedAmount, algorithm });
624
+ }
625
+ else {
626
+ emitEvent(options, { type: 'challenge-issued', difficulty, amount: normalizedAmount });
627
+ }
341
628
  return challenges;
342
629
  }
343
630
  function normalizeMaxIterations(options) {
@@ -356,6 +643,10 @@ function solveSingleChallenge(token, options) {
356
643
  const payload = jwt.decode(token);
357
644
  if (!payload)
358
645
  return undefined;
646
+ // Sync solver only supports sha256; argon2id requires async path
647
+ const alg = payload.alg ?? 'sha256';
648
+ if (alg === 'argon2id')
649
+ return undefined;
359
650
  const prefix = '0'.repeat(payload.difficulty);
360
651
  const maxIterations = normalizeMaxIterations(options);
361
652
  const maxDurationMs = normalizeMaxDurationMs(options);
@@ -387,6 +678,68 @@ export function solveChallenge(token, options) {
387
678
  }
388
679
  return solveSingleChallenge(token, options);
389
680
  }
681
+ async function solveSingleChallengeAsync(token, options) {
682
+ try {
683
+ const payload = jwt.decode(token);
684
+ if (!payload)
685
+ return undefined;
686
+ const alg = payload.alg ?? 'sha256';
687
+ const prefix = '0'.repeat(payload.difficulty);
688
+ const maxIterations = normalizeMaxIterations(options);
689
+ const maxDurationMs = normalizeMaxDurationMs(options);
690
+ const startedAt = Date.now();
691
+ if (alg === 'argon2id') {
692
+ const params = {
693
+ m: payload.m ?? HARD_MAX.m,
694
+ t: payload.t ?? 1,
695
+ p: payload.p ?? 1,
696
+ hashLen: payload.hashLen ?? 32,
697
+ };
698
+ // Validate params match token (already validated by isValidPayload, but double-check)
699
+ if (params.m > HARD_MAX.m || params.t > HARD_MAX.t || params.p > HARD_MAX.p)
700
+ return undefined;
701
+ for (let nonce = 0;; nonce++) {
702
+ if (maxIterations !== undefined && nonce >= maxIterations)
703
+ return undefined;
704
+ if (Date.now() - startedAt >= maxDurationMs)
705
+ return undefined;
706
+ const hash = await argon2idHash(payload.challenge, nonce, params);
707
+ if (hash.startsWith(prefix))
708
+ return { nonce: String(nonce), hash };
709
+ // Cooperative yield every 1 iteration for argon (hash is already ~6ms, but allow abort checks)
710
+ if (nonce % 1 === 0)
711
+ await new Promise(r => setTimeout(r, 0));
712
+ }
713
+ }
714
+ else {
715
+ for (let nonce = 0;; nonce++) {
716
+ if (maxIterations !== undefined && nonce >= maxIterations)
717
+ return undefined;
718
+ if (Date.now() - startedAt >= maxDurationMs)
719
+ return undefined;
720
+ const hash = crypto.createHash('sha256').update(`${payload.challenge}${nonce}`).digest('hex');
721
+ if (hash.startsWith(prefix))
722
+ return { nonce: String(nonce), hash };
723
+ }
724
+ }
725
+ }
726
+ catch {
727
+ return undefined;
728
+ }
729
+ }
730
+ export async function solveChallengeAsync(token, options) {
731
+ if (Array.isArray(token)) {
732
+ const solutions = [];
733
+ for (const item of token) {
734
+ const solution = await solveSingleChallengeAsync(item, options);
735
+ if (!solution)
736
+ return undefined;
737
+ solutions.push(solution);
738
+ }
739
+ return solutions;
740
+ }
741
+ return solveSingleChallengeAsync(token, options);
742
+ }
390
743
  function shouldDebug(options) {
391
744
  return options?.debug ?? process.env.NODE_ENV === 'development';
392
745
  }
@@ -437,6 +790,22 @@ function contextMatches(payload, suppliedContext) {
437
790
  const actual = Buffer.from(hashContext(suppliedContext, payload.jti), 'hex');
438
791
  return expected.length === actual.length && crypto.timingSafeEqual(expected, actual);
439
792
  }
793
+ /**
794
+ * Verifies that a proof-of-work solution correctly solves the challenge token(s).
795
+ *
796
+ * Validation steps:
797
+ * 1. JWT signature and expiration check
798
+ * 2. Hash verification (recomputed hash matches difficulty)
799
+ * 3. Context binding check (if context was provided)
800
+ * 4. Replay prevention (via ReplayStore if configured)
801
+ *
802
+ * Supports batch verification with atomic replay detection.
803
+ *
804
+ * @param token - Challenge token(s) to verify
805
+ * @param nonce - Solution nonce(s) or ChallengeSolution object(s)
806
+ * @param options - Verification options (replay prevention, context, rate limiting)
807
+ * @returns Result indicating validity or specific failure reason
808
+ */
440
809
  export async function verifySolution(token, nonce, options) {
441
810
  if (options?.rateLimiter) {
442
811
  const allowed = await options.rateLimiter.check(options?.context);
@@ -484,10 +853,27 @@ export async function verifySolution(token, nonce, options) {
484
853
  if (!contextMatches(payload, options?.context)) {
485
854
  return warn('context-mismatch', 'verifySolution rejected a mismatched challenge context', options);
486
855
  }
487
- const hash = crypto
488
- .createHash('sha256')
489
- .update(`${payload.challenge}${String(currentNonce)}`)
490
- .digest('hex');
856
+ const alg = payload.alg ?? 'sha256';
857
+ let hash;
858
+ if (alg === 'argon2id') {
859
+ const params = {
860
+ m: payload.m ?? HARD_MAX.m,
861
+ t: payload.t ?? 1,
862
+ p: payload.p ?? 1,
863
+ hashLen: payload.hashLen ?? 32,
864
+ };
865
+ // Strict validation — tokens with out-of-bounds params already rejected by isValidPayload, but double-check for safety
866
+ if (params.m < 8 || params.m > HARD_MAX.m || params.t < 1 || params.t > HARD_MAX.t || params.p < 1 || params.p > HARD_MAX.p) {
867
+ return warn('invalid-token', 'verifySolution rejected an invalid argon2id token', options);
868
+ }
869
+ hash = await argon2idHash(payload.challenge, currentNonce, params);
870
+ }
871
+ else {
872
+ hash = crypto
873
+ .createHash('sha256')
874
+ .update(`${payload.challenge}${String(currentNonce)}`)
875
+ .digest('hex');
876
+ }
491
877
  if (!hash.startsWith('0'.repeat(payload.difficulty))) {
492
878
  return warn('invalid-solution', 'verifySolution rejected an invalid nonce', options);
493
879
  }