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.
- package/README.md +53 -11
- package/dist/cjs/index.d.ts +103 -0
- package/dist/cjs/index.d.ts.map +1 -1
- package/dist/cjs/index.js +466 -44
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/redis.d.ts +16 -0
- package/dist/cjs/redis.d.ts.map +1 -1
- package/dist/cjs/redis.js +30 -0
- package/dist/cjs/redis.js.map +1 -1
- package/dist/cjs/risk.d.ts +4 -0
- package/dist/cjs/risk.d.ts.map +1 -1
- package/dist/cjs/risk.js +4 -0
- package/dist/cjs/risk.js.map +1 -1
- package/dist/hash-wasm.js +2671 -0
- package/dist/index.d.ts +103 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +429 -43
- package/dist/index.js.map +1 -1
- package/dist/redis.d.ts +16 -0
- package/dist/redis.d.ts.map +1 -1
- package/dist/redis.js +30 -0
- package/dist/redis.js.map +1 -1
- package/dist/risk.d.ts +4 -0
- package/dist/risk.d.ts.map +1 -1
- package/dist/risk.js +4 -0
- package/dist/risk.js.map +1 -1
- package/dist/solver-worker.d.ts.map +1 -1
- package/dist/solver-worker.js +17 -2
- package/dist/solver-worker.js.map +1 -1
- package/dist/solver.d.ts +23 -1
- package/dist/solver.d.ts.map +1 -1
- package/dist/solver.js +176 -1
- package/dist/solver.js.map +1 -1
- package/dist/wasm-solver.d.ts +29 -0
- package/dist/wasm-solver.d.ts.map +1 -1
- package/dist/wasm-solver.js +39 -5
- package/dist/wasm-solver.js.map +1 -1
- package/dist/widget-browser.d.ts +1 -1
- package/dist/widget-browser.d.ts.map +1 -1
- package/dist/widget-browser.js +1 -1
- package/dist/widget-browser.js.map +1 -1
- package/dist/widget-react.d.ts.map +1 -1
- package/dist/widget-react.js +12 -0
- package/dist/widget-react.js.map +1 -1
- package/dist/worker-client.d.ts +20 -1
- package/dist/worker-client.d.ts.map +1 -1
- package/dist/worker-client.js +18 -0
- package/dist/worker-client.js.map +1 -1
- package/package.json +9 -3
- package/SECURITY.md +0 -36
- package/demo/lib/ribaunt-solver.wasm +0 -0
- package/demo/lib/solver-worker.js +0 -174
- package/demo/lib/solver-worker.js.map +0 -1
- package/demo/lib/solver.js +0 -135
- package/demo/lib/solver.js.map +0 -1
- package/demo/lib/wasm-solver.js +0 -296
- package/demo/lib/wasm-solver.js.map +0 -1
- package/demo/lib/widget-browser.js +0 -9
- package/demo/lib/widget-browser.js.map +0 -1
- package/demo/lib/widget.js +0 -942
- package/demo/lib/widget.js.map +0 -1
- package/demo/lib/worker-client.js +0 -128
- 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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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 ??
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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) :
|
|
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 (
|
|
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) :
|
|
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 (
|
|
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
|
|
322
|
-
|
|
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
|
|
325
|
-
?? (typeof difficultyOrOptions === 'number' ? difficultyOrOptions : 5)
|
|
326
|
-
|
|
327
|
-
|
|
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
|
-
|
|
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
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
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
|
}
|