ribaunt 0.2.5 → 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 +6 -3
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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 ??
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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) :
|
|
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 (
|
|
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) :
|
|
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 (
|
|
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
|
|
336
|
-
|
|
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
|
|
339
|
-
?? (typeof difficultyOrOptions === 'number' ? difficultyOrOptions : 5)
|
|
340
|
-
|
|
341
|
-
|
|
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
|
-
|
|
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
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
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
|
}
|