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/cjs/index.js CHANGED
@@ -1,19 +1,65 @@
1
1
  "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
2
35
  var __importDefault = (this && this.__importDefault) || function (mod) {
3
36
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
37
  };
5
38
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.calibrateClient = exports.LocalReplayStore = exports.RateLimitedError = exports.DEFAULT_RISK_THRESHOLDS = void 0;
39
+ exports.calibrateArgonClient = exports.calibrateClient = exports.LocalReplayStore = exports.RateLimitedError = exports.ARGON_PROFILES = exports.HARD_MAX = exports.DEFAULT_RISK_THRESHOLDS = void 0;
7
40
  exports.selectWorkload = selectWorkload;
8
41
  exports.assess = assess;
9
42
  exports.calibrateNode = calibrateNode;
43
+ exports.calibrateArgonNode = calibrateArgonNode;
44
+ exports.__resetArgonForTesting = __resetArgonForTesting;
10
45
  exports.createChallenge = createChallenge;
11
46
  exports.solveChallenge = solveChallenge;
47
+ exports.solveChallengeAsync = solveChallengeAsync;
12
48
  exports.verifySolution = verifySolution;
13
49
  const crypto_1 = __importDefault(require("crypto"));
14
50
  const jsonwebtoken_1 = __importDefault(require("jsonwebtoken"));
15
51
  const risk_js_1 = require("./risk.js");
16
52
  Object.defineProperty(exports, "DEFAULT_RISK_THRESHOLDS", { enumerable: true, get: function () { return risk_js_1.DEFAULT_RISK_THRESHOLDS; } });
53
+ exports.HARD_MAX = {
54
+ m: 32 * 1024,
55
+ t: 3,
56
+ p: 1,
57
+ hashLen: 32,
58
+ };
59
+ exports.ARGON_PROFILES = {
60
+ mobile: { m: 8 * 1024, t: 1, p: 1, hashLen: 32 },
61
+ standard: { m: 8 * 1024, t: 1, p: 1, hashLen: 32 },
62
+ };
17
63
  class RateLimitedError extends Error {
18
64
  constructor(message = 'Rate limit exceeded') {
19
65
  super(message);
@@ -56,9 +102,69 @@ const DEFAULT_BOUNDS = {
56
102
  minAmount: 1,
57
103
  maxAmount: 8,
58
104
  };
105
+ const DEFAULT_BOUNDS_SHA = DEFAULT_BOUNDS;
106
+ const DEFAULT_BOUNDS_ARGON = {
107
+ minDifficulty: 1,
108
+ maxDifficulty: 2,
109
+ minAmount: 1,
110
+ maxAmount: 8,
111
+ };
59
112
  const MAX_WORKLOAD_DIFFICULTY = 64;
113
+ const MAX_WORKLOAD_ARGON_DIFFICULTY = 8;
60
114
  const MAX_WORKLOAD_AMOUNT = 64;
61
115
  const MAX_WORKLOAD_CANDIDATES = 10000;
116
+ let cachedArgon2id = null;
117
+ let argon2idLoadPromise = null;
118
+ async function getArgon2idFn() {
119
+ if (cachedArgon2id)
120
+ return cachedArgon2id;
121
+ if (argon2idLoadPromise)
122
+ return argon2idLoadPromise;
123
+ argon2idLoadPromise = (async () => {
124
+ // hash-wasm is CJS; handle both default and named interop
125
+ const mod = await Promise.resolve().then(() => __importStar(require('hash-wasm')));
126
+ const pkg = mod.default ?? mod;
127
+ const fn = pkg.argon2id;
128
+ if (typeof fn !== 'function')
129
+ throw new Error('hash-wasm argon2id not available');
130
+ cachedArgon2id = fn;
131
+ return fn;
132
+ })();
133
+ return argon2idLoadPromise;
134
+ }
135
+ function padSalt(challenge) {
136
+ if (challenge.length >= 16)
137
+ return challenge.slice(0, 16);
138
+ return challenge.padEnd(16, '0');
139
+ }
140
+ function resolveArgonParams(profile) {
141
+ const normalized = profile ?? 'mobile';
142
+ if (normalized !== 'mobile' && normalized !== 'standard') {
143
+ throw new Error('argonProfile must be "mobile" or "standard"');
144
+ }
145
+ return exports.ARGON_PROFILES[normalized];
146
+ }
147
+ async function argon2idHash(challenge, nonce, params) {
148
+ const argon2id = await getArgon2idFn();
149
+ const salt = padSalt(challenge);
150
+ const password = `${challenge}${String(nonce)}`;
151
+ return argon2id({
152
+ password,
153
+ salt,
154
+ parallelism: params.p,
155
+ iterations: params.t,
156
+ memorySize: params.m,
157
+ hashLength: params.hashLen,
158
+ outputType: 'hex',
159
+ });
160
+ }
161
+ function assertAlgorithm(value) {
162
+ if (value === undefined)
163
+ return 'sha256';
164
+ if (value === 'sha256' || value === 'argon2id')
165
+ return value;
166
+ throw new Error('algorithm must be "sha256" or "argon2id"');
167
+ }
62
168
  function assertFiniteInteger(value, name, minimum) {
63
169
  if (!Number.isFinite(value))
64
170
  throw new Error(`${name} must be a finite number`);
@@ -71,17 +177,40 @@ function isValidPayload(payload) {
71
177
  if (!payload || typeof payload !== 'object')
72
178
  return false;
73
179
  const value = payload;
74
- return typeof value.challenge === 'string'
75
- && value.challenge.length > 0
76
- && typeof value.difficulty === 'number'
77
- && Number.isInteger(value.difficulty)
78
- && value.difficulty >= 1
79
- && value.difficulty <= 64
80
- && typeof value.expires === 'number'
81
- && Number.isInteger(value.expires)
82
- && typeof value.jti === 'string'
83
- && value.jti.length > 0
84
- && (value.contextHash === undefined || /^[a-f0-9]{64}$/.test(value.contextHash));
180
+ if (typeof value.challenge !== 'string' || value.challenge.length === 0)
181
+ return false;
182
+ if (typeof value.difficulty !== 'number' || !Number.isInteger(value.difficulty) || value.difficulty < 1)
183
+ return false;
184
+ const alg = (value.alg ?? 'sha256');
185
+ if (alg === 'argon2id') {
186
+ if (value.difficulty > MAX_WORKLOAD_ARGON_DIFFICULTY)
187
+ return false;
188
+ if (typeof value.m !== 'number' || !Number.isInteger(value.m) || value.m < 8 || value.m > exports.HARD_MAX.m)
189
+ return false;
190
+ if (typeof value.t !== 'number' || !Number.isInteger(value.t) || value.t < 1 || value.t > exports.HARD_MAX.t)
191
+ return false;
192
+ if (typeof value.p !== 'number' || !Number.isInteger(value.p) || value.p < 1 || value.p > exports.HARD_MAX.p)
193
+ return false;
194
+ if (value.hashLen !== undefined && value.hashLen !== 32)
195
+ return false;
196
+ // enforce at least pow of two? not needed
197
+ }
198
+ else if (alg === 'sha256') {
199
+ if (value.difficulty > MAX_WORKLOAD_DIFFICULTY)
200
+ return false;
201
+ if (value.m !== undefined || value.t !== undefined || value.p !== undefined || value.hashLen !== undefined)
202
+ return false;
203
+ }
204
+ else {
205
+ return false;
206
+ }
207
+ if (typeof value.expires !== 'number' || !Number.isInteger(value.expires))
208
+ return false;
209
+ if (typeof value.jti !== 'string' || value.jti.length === 0)
210
+ return false;
211
+ if (value.contextHash !== undefined && !/^[a-f0-9]{64}$/.test(value.contextHash))
212
+ return false;
213
+ return true;
85
214
  }
86
215
  function assertRange(value, name, minimum, maximum) {
87
216
  if (!Number.isFinite(value) || value < minimum || value > maximum) {
@@ -89,20 +218,32 @@ function assertRange(value, name, minimum, maximum) {
89
218
  }
90
219
  return value;
91
220
  }
221
+ function getDefaultBounds(algorithm) {
222
+ return algorithm === 'argon2id' ? DEFAULT_BOUNDS_ARGON : DEFAULT_BOUNDS_SHA;
223
+ }
224
+ function getMaxDifficulty(algorithm) {
225
+ return algorithm === 'argon2id' ? MAX_WORKLOAD_ARGON_DIFFICULTY : MAX_WORKLOAD_DIFFICULTY;
226
+ }
92
227
  function normalizeBounds(options) {
93
- const minDifficulty = assertFiniteInteger(options.minDifficulty ?? DEFAULT_BOUNDS.minDifficulty, 'Minimum difficulty', 1);
94
- if (minDifficulty > MAX_WORKLOAD_DIFFICULTY) {
95
- throw new Error(`Minimum difficulty must be at most ${MAX_WORKLOAD_DIFFICULTY}`);
96
- }
97
- const maxDifficulty = assertFiniteInteger(options.maxDifficulty ?? DEFAULT_BOUNDS.maxDifficulty, 'Maximum difficulty', minDifficulty);
98
- if (maxDifficulty > MAX_WORKLOAD_DIFFICULTY) {
99
- throw new Error(`Maximum difficulty must be at most ${MAX_WORKLOAD_DIFFICULTY}`);
100
- }
101
- const minAmount = assertFiniteInteger(options.minAmount ?? DEFAULT_BOUNDS.minAmount, 'Minimum amount', 1);
228
+ const algorithm = assertAlgorithm(options.algorithm);
229
+ return normalizeBoundsInternal(options, algorithm);
230
+ }
231
+ function normalizeBoundsInternal(options, algorithm = 'sha256') {
232
+ const defaults = getDefaultBounds(algorithm);
233
+ const maxDiff = getMaxDifficulty(algorithm);
234
+ const minDifficulty = assertFiniteInteger(options.minDifficulty ?? defaults.minDifficulty, 'Minimum difficulty', 1);
235
+ if (minDifficulty > maxDiff) {
236
+ throw new Error(`Minimum difficulty must be at most ${maxDiff}`);
237
+ }
238
+ const maxDifficulty = assertFiniteInteger(options.maxDifficulty ?? defaults.maxDifficulty, 'Maximum difficulty', minDifficulty);
239
+ if (maxDifficulty > maxDiff) {
240
+ throw new Error(`Maximum difficulty must be at most ${maxDiff}`);
241
+ }
242
+ const minAmount = assertFiniteInteger(options.minAmount ?? defaults.minAmount, 'Minimum amount', 1);
102
243
  if (minAmount > MAX_WORKLOAD_AMOUNT) {
103
244
  throw new Error(`Minimum amount must be at most ${MAX_WORKLOAD_AMOUNT}`);
104
245
  }
105
- const maxAmount = assertFiniteInteger(options.maxAmount ?? DEFAULT_BOUNDS.maxAmount, 'Maximum amount', minAmount);
246
+ const maxAmount = assertFiniteInteger(options.maxAmount ?? defaults.maxAmount, 'Maximum amount', minAmount);
106
247
  if (maxAmount > MAX_WORKLOAD_AMOUNT) {
107
248
  throw new Error(`Maximum amount must be at most ${MAX_WORKLOAD_AMOUNT}`);
108
249
  }
@@ -110,18 +251,25 @@ function normalizeBounds(options) {
110
251
  if (candidateCount > MAX_WORKLOAD_CANDIDATES) {
111
252
  throw new Error(`Workload bounds too large: candidate count ${candidateCount} exceeds ${MAX_WORKLOAD_CANDIDATES}`);
112
253
  }
113
- return { minDifficulty, maxDifficulty, minAmount, maxAmount };
254
+ return { minDifficulty, maxDifficulty, minAmount, maxAmount, algorithm };
114
255
  }
115
256
  function closestWorkload(targetAttempts, bounds) {
116
257
  let best;
117
258
  let bestDistance = Number.POSITIVE_INFINITY;
259
+ const algorithm = bounds.algorithm ?? 'sha256';
260
+ const argon = algorithm === 'argon2id' ? resolveArgonParams(bounds.argonProfile) : undefined;
118
261
  for (let difficulty = bounds.minDifficulty; difficulty <= bounds.maxDifficulty; difficulty++) {
119
262
  for (let amount = bounds.minAmount; amount <= bounds.maxAmount; amount++) {
120
263
  const estimatedAttempts = (16 ** difficulty) * amount;
121
264
  const distance = Math.abs(Math.log(estimatedAttempts / targetAttempts));
122
265
  if (distance < bestDistance) {
123
266
  bestDistance = distance;
124
- best = { difficulty, amount, estimatedAttempts };
267
+ if (algorithm === 'argon2id') {
268
+ best = { difficulty, amount, estimatedAttempts, algorithm, argon: argon };
269
+ }
270
+ else {
271
+ best = { difficulty, amount, estimatedAttempts, algorithm };
272
+ }
125
273
  }
126
274
  }
127
275
  }
@@ -129,9 +277,28 @@ function closestWorkload(targetAttempts, bounds) {
129
277
  }
130
278
  /**
131
279
  * Selects bounded proof-of-work using a server-owned risk floor and untrusted timing calibration.
280
+ * For `algorithm:'argon2id'` difficulty is capped to the argon max (default 2) and the returned
281
+ * workload carries `argon:{m,t,p}` derived from `argonProfile` so the device can comfortably solve it.
132
282
  */
133
283
  function selectWorkload(options = {}) {
134
- const bounds = normalizeBounds(options);
284
+ const algorithm = assertAlgorithm(options.algorithm);
285
+ if (options.argonProfile !== undefined) {
286
+ if (algorithm !== 'argon2id')
287
+ throw new Error('argonProfile is only valid when algorithm is "argon2id"');
288
+ if (options.argonProfile !== 'mobile' && options.argonProfile !== 'standard') {
289
+ throw new Error('argonProfile must be "mobile" or "standard"');
290
+ }
291
+ }
292
+ // Validate argonProfile early
293
+ if (algorithm === 'argon2id') {
294
+ // Trigger validation (defaults to mobile when undefined)
295
+ resolveArgonParams(options.argonProfile);
296
+ }
297
+ const bounds = normalizeBounds({ ...options, algorithm });
298
+ // Carry argonProfile through bounds object for closestWorkload to consume (without polluting validation)
299
+ if (algorithm === 'argon2id' && options.argonProfile !== undefined) {
300
+ bounds.argonProfile = options.argonProfile;
301
+ }
135
302
  const riskScore = assertRange(options.riskScore ?? 50, 'Risk score', 0, 100);
136
303
  const targetDurationMs = assertFiniteInteger(options.targetDurationMs ?? 750, 'Target duration', 1);
137
304
  const minimumAttempts = 16 ** bounds.minDifficulty
@@ -145,33 +312,54 @@ function selectWorkload(options = {}) {
145
312
  targetAttempts = Math.max(minimumAttempts, calibratedAttempts);
146
313
  }
147
314
  const maximumAttempts = (16 ** bounds.maxDifficulty) * bounds.maxAmount;
148
- return closestWorkload(Math.min(targetAttempts, maximumAttempts), bounds);
315
+ const workload = closestWorkload(Math.min(targetAttempts, maximumAttempts), bounds);
316
+ // Ensure argon carries profile-resolved params if not already via closestWorkload
317
+ if (algorithm === 'argon2id' && !workload.argon) {
318
+ workload.argon = resolveArgonParams(options.argonProfile);
319
+ }
320
+ return workload;
149
321
  }
150
322
  function validateAssessWorkloadOptions(workload) {
323
+ const alg = assertAlgorithm(workload.algorithm);
324
+ const defaults = getDefaultBounds(alg);
325
+ const maxDiffForAlg = getMaxDifficulty(alg);
151
326
  // Reuse same validation messages as normalizeBounds / selectWorkload for regression safety
152
327
  if (workload.minDifficulty !== undefined) {
153
328
  assertFiniteInteger(workload.minDifficulty, 'Minimum difficulty', 1);
329
+ if (Math.floor(workload.minDifficulty) > maxDiffForAlg) {
330
+ throw new Error(`Minimum difficulty must be at most ${maxDiffForAlg}`);
331
+ }
154
332
  }
155
333
  if (workload.maxDifficulty !== undefined) {
156
334
  // Need to ensure we validate max >= min (using resolved min)
157
- const min = workload.minDifficulty !== undefined ? Math.floor(workload.minDifficulty) : DEFAULT_BOUNDS.minDifficulty;
335
+ const min = workload.minDifficulty !== undefined ? Math.floor(workload.minDifficulty) : defaults.minDifficulty;
158
336
  assertFiniteInteger(workload.maxDifficulty, 'Maximum difficulty', min);
337
+ if (Math.floor(workload.maxDifficulty) > maxDiffForAlg) {
338
+ throw new Error(`Maximum difficulty must be at most ${maxDiffForAlg}`);
339
+ }
159
340
  }
160
341
  else if (workload.minDifficulty !== undefined) {
161
342
  // If only minDifficulty provided, still need to ensure default max >= min
162
- if (DEFAULT_BOUNDS.maxDifficulty < Math.floor(workload.minDifficulty)) {
343
+ if (defaults.maxDifficulty < Math.floor(workload.minDifficulty)) {
163
344
  throw new Error(`Maximum difficulty must be at least ${Math.floor(workload.minDifficulty)}`);
164
345
  }
165
346
  }
347
+ if (workload.argonProfile !== undefined) {
348
+ const p = workload.argonProfile;
349
+ if (alg !== 'argon2id')
350
+ throw new Error('argonProfile is only valid when algorithm is "argon2id"');
351
+ if (p !== 'mobile' && p !== 'standard')
352
+ throw new Error('argonProfile must be "mobile" or "standard"');
353
+ }
166
354
  if (workload.minAmount !== undefined) {
167
355
  assertFiniteInteger(workload.minAmount, 'Minimum amount', 1);
168
356
  }
169
357
  if (workload.maxAmount !== undefined) {
170
- const minAmt = workload.minAmount !== undefined ? Math.floor(workload.minAmount) : DEFAULT_BOUNDS.minAmount;
358
+ const minAmt = workload.minAmount !== undefined ? Math.floor(workload.minAmount) : defaults.minAmount;
171
359
  assertFiniteInteger(workload.maxAmount, 'Maximum amount', minAmt);
172
360
  }
173
361
  else if (workload.minAmount !== undefined) {
174
- if (DEFAULT_BOUNDS.maxAmount < Math.floor(workload.minAmount)) {
362
+ if (defaults.maxAmount < Math.floor(workload.minAmount)) {
175
363
  throw new Error(`Maximum amount must be at least ${Math.floor(workload.minAmount)}`);
176
364
  }
177
365
  }
@@ -196,9 +384,14 @@ function validateAssessWorkloadOptions(workload) {
196
384
  if (workload.minDifficulty !== undefined ||
197
385
  workload.maxDifficulty !== undefined ||
198
386
  workload.minAmount !== undefined ||
199
- workload.maxAmount !== undefined) {
387
+ workload.maxAmount !== undefined ||
388
+ workload.algorithm !== undefined ||
389
+ workload.argonProfile !== undefined) {
200
390
  normalizeBounds(workload);
201
391
  }
392
+ if (workload.algorithm !== undefined) {
393
+ assertAlgorithm(workload.algorithm);
394
+ }
202
395
  }
203
396
  /**
204
397
  * Programmable risk-assessment subsystem.
@@ -272,6 +465,13 @@ async function assess(options) {
272
465
  }
273
466
  return { risk, action };
274
467
  }
468
+ /**
469
+ * Measures SHA-256 hashing speed on Node.js to calibrate adaptive workload selection.
470
+ * Runs a small benchmark to estimate device performance.
471
+ *
472
+ * @param iterations - Number of SHA-256 hashes to run (default: 128)
473
+ * @returns Calibration object with iterations and measured duration
474
+ */
275
475
  function calibrateNode(iterations = 128) {
276
476
  if (!Number.isFinite(iterations) || iterations < 1) {
277
477
  throw new Error('Calibration iterations must be at least 1');
@@ -286,7 +486,71 @@ function calibrateNode(iterations = 128) {
286
486
  durationMs: Math.max(1, Math.round(performance.now() - startedAt)),
287
487
  };
288
488
  }
489
+ /**
490
+ * Alias for calibrateNode - measures device hashing speed for adaptive workload.
491
+ */
289
492
  exports.calibrateClient = calibrateNode;
493
+ let cachedArgonCalibrationWarmup = null;
494
+ async function ensureArgonWarmup() {
495
+ if (cachedArgonCalibrationWarmup)
496
+ return cachedArgonCalibrationWarmup;
497
+ // Warm up WASM once (first argon hash triggers compilation)
498
+ cachedArgonCalibrationWarmup = (async () => {
499
+ try {
500
+ await getArgon2idFn();
501
+ // tiny probe to force WASM compile — one hash at mobile params
502
+ const argon = await getArgon2idFn();
503
+ await argon({
504
+ password: 'ribaunt-warmup',
505
+ salt: 'ribaunt-warmup-16b',
506
+ parallelism: 1,
507
+ iterations: 1,
508
+ memorySize: 8 * 1024,
509
+ hashLength: 32,
510
+ outputType: 'hex',
511
+ });
512
+ }
513
+ catch {
514
+ // warmup best-effort; calibration will still surface errors
515
+ }
516
+ })();
517
+ return cachedArgonCalibrationWarmup;
518
+ }
519
+ /**
520
+ * Calibrate Argon2id on Node — measures device speed for the memory-hard algorithm.
521
+ * Uses a smaller iteration default (16) because each hash is ~6ms (mobile) vs microseconds for SHA.
522
+ * The returned shape is identical to `calibrateNode()` so `selectWorkload({calibration, algorithm:'argon2id'})` can consume it.
523
+ * Keep SHA `calibrateNode()` for `algorithm:'sha256'` — mismatched calibration will over/under-estimate.
524
+ */
525
+ async function calibrateArgonNode(iterations = 16) {
526
+ if (!Number.isFinite(iterations) || iterations < 1) {
527
+ throw new Error('Calibration iterations must be at least 1');
528
+ }
529
+ const normalizedIterations = Math.floor(iterations);
530
+ await ensureArgonWarmup();
531
+ const profile = resolveArgonParams('mobile');
532
+ const startedAt = performance.now();
533
+ for (let index = 0; index < normalizedIterations; index++) {
534
+ await argon2idHash(`ribaunt-calibration:${index}`, '0', profile);
535
+ }
536
+ return {
537
+ iterations: normalizedIterations,
538
+ durationMs: Math.max(1, Math.round(performance.now() - startedAt)),
539
+ };
540
+ }
541
+ /**
542
+ * Alias for calibrateArgonNode - measures Argon2id performance for adaptive workload.
543
+ */
544
+ exports.calibrateArgonClient = calibrateArgonNode;
545
+ /**
546
+ * Resets Argon2id module cache for testing purposes.
547
+ * Should not be called in production code.
548
+ */
549
+ function __resetArgonForTesting() {
550
+ cachedArgon2id = null;
551
+ argon2idLoadPromise = null;
552
+ cachedArgonCalibrationWarmup = null;
553
+ }
290
554
  function generateChallenge(length = 8) {
291
555
  return crypto_1.default.randomBytes(length).toString('base64url').slice(0, length);
292
556
  }
@@ -308,7 +572,7 @@ function getSecret() {
308
572
  }
309
573
  return secret;
310
574
  }
311
- function createSingleChallenge(difficulty, ttlSeconds, context) {
575
+ function createSingleChallenge(difficulty, ttlSeconds, context, algorithm = 'sha256', argonParams) {
312
576
  const jti = crypto_1.default.randomUUID();
313
577
  const payload = {
314
578
  challenge: generateChallenge(),
@@ -316,6 +580,22 @@ function createSingleChallenge(difficulty, ttlSeconds, context) {
316
580
  expires: Math.floor(Date.now() / 1000) + ttlSeconds,
317
581
  jti,
318
582
  };
583
+ if (algorithm === 'argon2id') {
584
+ payload.alg = 'argon2id';
585
+ const p = argonParams ?? resolveArgonParams('mobile');
586
+ payload.m = p.m;
587
+ payload.t = p.t;
588
+ payload.p = p.p;
589
+ payload.hashLen = p.hashLen;
590
+ if (difficulty < 1 || difficulty > MAX_WORKLOAD_ARGON_DIFFICULTY) {
591
+ throw new Error(`Challenge difficulty must be between 1 and ${MAX_WORKLOAD_ARGON_DIFFICULTY} for argon2id`);
592
+ }
593
+ }
594
+ else {
595
+ if (difficulty < 1 || difficulty > MAX_WORKLOAD_DIFFICULTY) {
596
+ throw new Error(`Challenge difficulty must be at most ${MAX_WORKLOAD_DIFFICULTY}`);
597
+ }
598
+ }
319
599
  if (context !== undefined)
320
600
  payload.contextHash = hashContext(context, jti);
321
601
  return jsonwebtoken_1.default.sign(payload, getSecret(), { algorithm: 'HS256' });
@@ -332,13 +612,51 @@ function emitEvent(options, event) {
332
612
  }
333
613
  async function createChallenge(difficultyOrOptions = 5, amount = 4, ttlSeconds = 30) {
334
614
  const options = typeof difficultyOrOptions === 'object' ? difficultyOrOptions : undefined;
335
- const selectedWorkload = options?.workload
336
- ?? (options?.difficulty === 'auto' ? selectWorkload(options) : undefined);
615
+ const algorithm = assertAlgorithm(options?.algorithm);
616
+ if (options?.argonProfile !== undefined && algorithm !== 'argon2id') {
617
+ throw new Error('argonProfile is only valid when algorithm is "argon2id"');
618
+ }
619
+ let selectedWorkload;
620
+ if (options?.workload) {
621
+ const w = options.workload;
622
+ const wDiff = assertFiniteInteger(w.difficulty, 'Challenge difficulty', 1);
623
+ const maxForAlg = getMaxDifficulty(algorithm);
624
+ if (wDiff > maxForAlg)
625
+ throw new Error(`Challenge difficulty must be at most ${maxForAlg} for ${algorithm}`);
626
+ const wAmount = assertFiniteInteger(w.amount, 'Challenge amount', 1);
627
+ selectedWorkload = {
628
+ difficulty: wDiff,
629
+ amount: wAmount,
630
+ estimatedAttempts: (16 ** wDiff) * wAmount,
631
+ algorithm,
632
+ ...(algorithm === 'argon2id' ? { argon: resolveArgonParams(options.argonProfile) } : {}),
633
+ };
634
+ }
635
+ else if (options?.difficulty === 'auto') {
636
+ const workloadInput = {
637
+ ...options,
638
+ algorithm,
639
+ ...(options.argonProfile !== undefined ? { argonProfile: options.argonProfile } : {}),
640
+ };
641
+ selectedWorkload = selectWorkload(workloadInput);
642
+ }
643
+ // When algorithm is argon2id and workload was explicit or auto, resolve argon params for token
644
+ let workloadArgon;
645
+ if (algorithm === 'argon2id') {
646
+ if (selectedWorkload?.argon) {
647
+ workloadArgon = selectedWorkload.argon;
648
+ }
649
+ else {
650
+ workloadArgon = resolveArgonParams(options?.argonProfile);
651
+ }
652
+ }
337
653
  const configuredDifficulty = options?.difficulty === 'auto' ? undefined : options?.difficulty;
338
- const difficulty = assertFiniteInteger(selectedWorkload?.difficulty ?? configuredDifficulty
339
- ?? (typeof difficultyOrOptions === 'number' ? difficultyOrOptions : 5), 'Challenge difficulty', 1);
340
- if (difficulty > 64)
341
- throw new Error('Challenge difficulty must be at most 64');
654
+ const difficultyRaw = selectedWorkload?.difficulty ?? configuredDifficulty
655
+ ?? (typeof difficultyOrOptions === 'number' ? difficultyOrOptions : 5);
656
+ const difficulty = assertFiniteInteger(difficultyRaw, 'Challenge difficulty', 1);
657
+ const maxForAlg = getMaxDifficulty(algorithm);
658
+ if (difficulty > maxForAlg)
659
+ throw new Error(`Challenge difficulty must be at most ${maxForAlg} for ${algorithm}`);
342
660
  const normalizedAmount = assertFiniteInteger(selectedWorkload?.amount ?? options?.amount ?? (options ? 1 : amount), 'Challenge amount', 1);
343
661
  const ttlValue = options?.ttlSeconds ?? (options ? 30 : ttlSeconds);
344
662
  if (Number.isFinite(ttlValue) && Math.floor(ttlValue) < 1) {
@@ -350,8 +668,13 @@ async function createChallenge(difficultyOrOptions = 5, amount = 4, ttlSeconds =
350
668
  if (!allowed)
351
669
  throw new RateLimitedError('Challenge issuance rate limited');
352
670
  }
353
- const challenges = Array.from({ length: normalizedAmount }, () => createSingleChallenge(difficulty, normalizedTtl, options?.context));
354
- emitEvent(options, { type: 'challenge-issued', difficulty, amount: normalizedAmount });
671
+ const challenges = Array.from({ length: normalizedAmount }, () => createSingleChallenge(difficulty, normalizedTtl, options?.context, algorithm, workloadArgon));
672
+ if (algorithm === 'argon2id') {
673
+ emitEvent(options, { type: 'challenge-issued', difficulty, amount: normalizedAmount, algorithm });
674
+ }
675
+ else {
676
+ emitEvent(options, { type: 'challenge-issued', difficulty, amount: normalizedAmount });
677
+ }
355
678
  return challenges;
356
679
  }
357
680
  function normalizeMaxIterations(options) {
@@ -370,6 +693,10 @@ function solveSingleChallenge(token, options) {
370
693
  const payload = jsonwebtoken_1.default.decode(token);
371
694
  if (!payload)
372
695
  return undefined;
696
+ // Sync solver only supports sha256; argon2id requires async path
697
+ const alg = payload.alg ?? 'sha256';
698
+ if (alg === 'argon2id')
699
+ return undefined;
373
700
  const prefix = '0'.repeat(payload.difficulty);
374
701
  const maxIterations = normalizeMaxIterations(options);
375
702
  const maxDurationMs = normalizeMaxDurationMs(options);
@@ -401,6 +728,68 @@ function solveChallenge(token, options) {
401
728
  }
402
729
  return solveSingleChallenge(token, options);
403
730
  }
731
+ async function solveSingleChallengeAsync(token, options) {
732
+ try {
733
+ const payload = jsonwebtoken_1.default.decode(token);
734
+ if (!payload)
735
+ return undefined;
736
+ const alg = payload.alg ?? 'sha256';
737
+ const prefix = '0'.repeat(payload.difficulty);
738
+ const maxIterations = normalizeMaxIterations(options);
739
+ const maxDurationMs = normalizeMaxDurationMs(options);
740
+ const startedAt = Date.now();
741
+ if (alg === 'argon2id') {
742
+ const params = {
743
+ m: payload.m ?? exports.HARD_MAX.m,
744
+ t: payload.t ?? 1,
745
+ p: payload.p ?? 1,
746
+ hashLen: payload.hashLen ?? 32,
747
+ };
748
+ // Validate params match token (already validated by isValidPayload, but double-check)
749
+ if (params.m > exports.HARD_MAX.m || params.t > exports.HARD_MAX.t || params.p > exports.HARD_MAX.p)
750
+ return undefined;
751
+ for (let nonce = 0;; nonce++) {
752
+ if (maxIterations !== undefined && nonce >= maxIterations)
753
+ return undefined;
754
+ if (Date.now() - startedAt >= maxDurationMs)
755
+ return undefined;
756
+ const hash = await argon2idHash(payload.challenge, nonce, params);
757
+ if (hash.startsWith(prefix))
758
+ return { nonce: String(nonce), hash };
759
+ // Cooperative yield every 1 iteration for argon (hash is already ~6ms, but allow abort checks)
760
+ if (nonce % 1 === 0)
761
+ await new Promise(r => setTimeout(r, 0));
762
+ }
763
+ }
764
+ else {
765
+ for (let nonce = 0;; nonce++) {
766
+ if (maxIterations !== undefined && nonce >= maxIterations)
767
+ return undefined;
768
+ if (Date.now() - startedAt >= maxDurationMs)
769
+ return undefined;
770
+ const hash = crypto_1.default.createHash('sha256').update(`${payload.challenge}${nonce}`).digest('hex');
771
+ if (hash.startsWith(prefix))
772
+ return { nonce: String(nonce), hash };
773
+ }
774
+ }
775
+ }
776
+ catch {
777
+ return undefined;
778
+ }
779
+ }
780
+ async function solveChallengeAsync(token, options) {
781
+ if (Array.isArray(token)) {
782
+ const solutions = [];
783
+ for (const item of token) {
784
+ const solution = await solveSingleChallengeAsync(item, options);
785
+ if (!solution)
786
+ return undefined;
787
+ solutions.push(solution);
788
+ }
789
+ return solutions;
790
+ }
791
+ return solveSingleChallengeAsync(token, options);
792
+ }
404
793
  function shouldDebug(options) {
405
794
  return options?.debug ?? process.env.NODE_ENV === 'development';
406
795
  }
@@ -451,6 +840,22 @@ function contextMatches(payload, suppliedContext) {
451
840
  const actual = Buffer.from(hashContext(suppliedContext, payload.jti), 'hex');
452
841
  return expected.length === actual.length && crypto_1.default.timingSafeEqual(expected, actual);
453
842
  }
843
+ /**
844
+ * Verifies that a proof-of-work solution correctly solves the challenge token(s).
845
+ *
846
+ * Validation steps:
847
+ * 1. JWT signature and expiration check
848
+ * 2. Hash verification (recomputed hash matches difficulty)
849
+ * 3. Context binding check (if context was provided)
850
+ * 4. Replay prevention (via ReplayStore if configured)
851
+ *
852
+ * Supports batch verification with atomic replay detection.
853
+ *
854
+ * @param token - Challenge token(s) to verify
855
+ * @param nonce - Solution nonce(s) or ChallengeSolution object(s)
856
+ * @param options - Verification options (replay prevention, context, rate limiting)
857
+ * @returns Result indicating validity or specific failure reason
858
+ */
454
859
  async function verifySolution(token, nonce, options) {
455
860
  if (options?.rateLimiter) {
456
861
  const allowed = await options.rateLimiter.check(options?.context);
@@ -498,10 +903,27 @@ async function verifySolution(token, nonce, options) {
498
903
  if (!contextMatches(payload, options?.context)) {
499
904
  return warn('context-mismatch', 'verifySolution rejected a mismatched challenge context', options);
500
905
  }
501
- const hash = crypto_1.default
502
- .createHash('sha256')
503
- .update(`${payload.challenge}${String(currentNonce)}`)
504
- .digest('hex');
906
+ const alg = payload.alg ?? 'sha256';
907
+ let hash;
908
+ if (alg === 'argon2id') {
909
+ const params = {
910
+ m: payload.m ?? exports.HARD_MAX.m,
911
+ t: payload.t ?? 1,
912
+ p: payload.p ?? 1,
913
+ hashLen: payload.hashLen ?? 32,
914
+ };
915
+ // Strict validation — tokens with out-of-bounds params already rejected by isValidPayload, but double-check for safety
916
+ if (params.m < 8 || params.m > exports.HARD_MAX.m || params.t < 1 || params.t > exports.HARD_MAX.t || params.p < 1 || params.p > exports.HARD_MAX.p) {
917
+ return warn('invalid-token', 'verifySolution rejected an invalid argon2id token', options);
918
+ }
919
+ hash = await argon2idHash(payload.challenge, currentNonce, params);
920
+ }
921
+ else {
922
+ hash = crypto_1.default
923
+ .createHash('sha256')
924
+ .update(`${payload.challenge}${String(currentNonce)}`)
925
+ .digest('hex');
926
+ }
505
927
  if (!hash.startsWith('0'.repeat(payload.difficulty))) {
506
928
  return warn('invalid-solution', 'verifySolution rejected an invalid nonce', options);
507
929
  }