@ix-xs/node-comfort 2.1.0 → 2.2.0
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/CHANGELOG.md +153 -0
- package/README.md +2 -2
- package/package.json +2 -2
- package/src/Arr.js +13 -5
- package/src/Checker.js +56 -8
- package/src/Cli.js +60 -18
- package/src/Color.js +32 -6
- package/src/Crypto.js +429 -78
- package/src/Emitter.js +9 -9
- package/src/Env.js +90 -8
- package/src/FS.js +369 -59
- package/src/Func.js +1 -1
- package/src/Http.js +266 -75
- package/src/Id.js +53 -15
- package/src/Logger.js +100 -22
- package/src/Num.js +3 -2
- package/src/Obj.js +225 -36
- package/src/SQLite.js +155 -98
- package/src/Schema.js +281 -40
- package/src/Str.js +77 -29
- package/src/Sys.js +124 -23
- package/src/Time.js +98 -24
- package/src/Utils.js +18 -3
- package/src/errors.js +75 -4
- package/src/internal/shared.js +68 -14
- package/src/internal/terminal.js +49 -0
- package/types/index.d.ts +28 -5
- package/types/src/Arr.d.ts +4 -2
- package/types/src/Arr.d.ts.map +1 -1
- package/types/src/Checker.d.ts +29 -3
- package/types/src/Checker.d.ts.map +1 -1
- package/types/src/Cli.d.ts +6 -4
- package/types/src/Cli.d.ts.map +1 -1
- package/types/src/Color.d.ts +2 -2
- package/types/src/Color.d.ts.map +1 -1
- package/types/src/Crypto.d.ts +126 -28
- package/types/src/Crypto.d.ts.map +1 -1
- package/types/src/Emitter.d.ts +6 -6
- package/types/src/Emitter.d.ts.map +1 -1
- package/types/src/Env.d.ts +19 -1
- package/types/src/Env.d.ts.map +1 -1
- package/types/src/FS.d.ts +30 -23
- package/types/src/FS.d.ts.map +1 -1
- package/types/src/Func.d.ts +1 -1
- package/types/src/Http.d.ts +45 -8
- package/types/src/Http.d.ts.map +1 -1
- package/types/src/Id.d.ts.map +1 -1
- package/types/src/Logger.d.ts +11 -3
- package/types/src/Logger.d.ts.map +1 -1
- package/types/src/Num.d.ts +1 -1
- package/types/src/Num.d.ts.map +1 -1
- package/types/src/Obj.d.ts +15 -2
- package/types/src/Obj.d.ts.map +1 -1
- package/types/src/SQLite.d.ts +105 -13
- package/types/src/SQLite.d.ts.map +1 -1
- package/types/src/Schema.d.ts +61 -5
- package/types/src/Schema.d.ts.map +1 -1
- package/types/src/Str.d.ts +32 -7
- package/types/src/Str.d.ts.map +1 -1
- package/types/src/Sys.d.ts +43 -10
- package/types/src/Sys.d.ts.map +1 -1
- package/types/src/Time.d.ts +12 -7
- package/types/src/Time.d.ts.map +1 -1
- package/types/src/Utils.d.ts +16 -6
- package/types/src/Utils.d.ts.map +1 -1
- package/types/src/errors.d.ts +6 -2
- package/types/src/errors.d.ts.map +1 -1
- package/types/src/internal/shared.d.ts +7 -0
- package/types/src/internal/shared.d.ts.map +1 -1
- package/types/src/internal/terminal.d.ts +13 -0
- package/types/src/internal/terminal.d.ts.map +1 -0
package/src/Crypto.js
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Crypto without the footguns, built on `node:crypto`: hashes, HMAC,
|
|
5
|
-
* password hashing (scrypt), encryption (AES-256-GCM), JWT,
|
|
6
|
-
* codes (TOTP) and secure random values.
|
|
5
|
+
* password hashing (scrypt or Argon2id), encryption (AES-256-GCM), JWT,
|
|
6
|
+
* two-factor codes (TOTP) and secure random values.
|
|
7
7
|
*
|
|
8
8
|
* @example
|
|
9
9
|
* const stored = await crypto.hashPassword("hunter2");
|
|
@@ -34,6 +34,27 @@ const _scrypt = (password, salt, keylen, options) =>
|
|
|
34
34
|
_crypto().scrypt(password, salt, keylen, options, (error, key) => (error ? reject(error) : resolve(key)));
|
|
35
35
|
});
|
|
36
36
|
|
|
37
|
+
/** `crypto.argon2` only exists since Node.js 24.7. */
|
|
38
|
+
const _hasArgon2 = () => typeof (/** @type {any} */ (_crypto()).argon2) === "function";
|
|
39
|
+
|
|
40
|
+
/** @param {string} what */
|
|
41
|
+
const _noArgon2 = (what) =>
|
|
42
|
+
new NodeComfortError(`${what} needs Node.js 24.7 or later, this is Node.js ${process.versions.node}`, { code: "ERR_ARGON2_UNAVAILABLE" });
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @param {"argon2id" | "argon2i" | "argon2d"} algorithm
|
|
46
|
+
* @param {string} password
|
|
47
|
+
* @param {Buffer} salt
|
|
48
|
+
* @param {number} keylen
|
|
49
|
+
* @param {{ memory: number, passes: number, parallelism: number }} cost
|
|
50
|
+
* @returns {Promise<Buffer>}
|
|
51
|
+
*/
|
|
52
|
+
const _argon2 = (algorithm, password, salt, keylen, cost) =>
|
|
53
|
+
new Promise((resolve, reject) => {
|
|
54
|
+
const params = { message: password, nonce: salt, tagLength: keylen, memory: cost.memory, passes: cost.passes, parallelism: cost.parallelism };
|
|
55
|
+
/** @type {any} */ (_crypto()).argon2(algorithm, params, (/** @type {Error | null} */ error, /** @type {Buffer} */ key) => (error ? reject(error) : resolve(key)));
|
|
56
|
+
});
|
|
57
|
+
|
|
37
58
|
/**
|
|
38
59
|
* Text encodings for digests.
|
|
39
60
|
* @typedef {"hex" | "base64" | "base64url"} DigestEncoding
|
|
@@ -47,13 +68,17 @@ const _scrypt = (password, salt, keylen, options) =>
|
|
|
47
68
|
*/
|
|
48
69
|
|
|
49
70
|
/**
|
|
50
|
-
* Options for `hashPassword()`.
|
|
71
|
+
* Options for `hashPassword()` and `needsRehash()`.
|
|
51
72
|
* @typedef {object} HashPasswordOptions
|
|
52
|
-
* @property {
|
|
73
|
+
* @property {"scrypt" | "argon2id"} [algorithm] Defaults to `"scrypt"`, which every supported Node.js version can verify. `"argon2id"` needs Node.js 24.7 or later, to hash and to verify.
|
|
74
|
+
* @property {number} [cost] scrypt work factor as a power of two; each +1 doubles the time. Defaults to `15`.
|
|
53
75
|
* @property {number} [blockSize] scrypt `r`. Defaults to `8`.
|
|
54
|
-
* @property {number} [parallelization] scrypt `p`. Defaults to `1`.
|
|
55
|
-
* @property {number} [
|
|
56
|
-
* @property {number} [
|
|
76
|
+
* @property {number} [parallelization] scrypt `p`, at most `16`. Defaults to `1`.
|
|
77
|
+
* @property {number} [memory] Argon2id memory, in KiB, at most `1048576` (1 GiB). Defaults to `19456` (19 MiB), as OWASP recommends.
|
|
78
|
+
* @property {number} [passes] Argon2id passes over the memory (`t`), from `1` to `10`. Defaults to `2`.
|
|
79
|
+
* @property {number} [parallelism] Argon2id lanes (`p`), from `1` to `16`. Defaults to `1`.
|
|
80
|
+
* @property {number} [saltLength] In bytes, at least `8`. Defaults to `16`.
|
|
81
|
+
* @property {number} [keyLength] In bytes, from `16` to `128`. Defaults to `32`.
|
|
57
82
|
*/
|
|
58
83
|
|
|
59
84
|
/**
|
|
@@ -123,10 +148,12 @@ const _scrypt = (password, salt, keylen, options) =>
|
|
|
123
148
|
* @property {string | string[]} [issuer] Required issuer.
|
|
124
149
|
* @property {string | string[]} [audience] Required audience; one match is enough.
|
|
125
150
|
* @property {string} [subject] Required subject.
|
|
126
|
-
* @property {number} [clockTolerance]
|
|
151
|
+
* @property {number | string} [clockTolerance] Slack for `exp` and `nbf` when server clocks differ: seconds (`0` or more) or a duration like `"30s"`. Defaults to `0`.
|
|
127
152
|
* @property {string | number} [maxAge] Reject tokens issued longer ago than this.
|
|
128
153
|
* @property {boolean} [ignoreExpiration] Skip the `exp` check.
|
|
129
|
-
* @property {Date | number} [now] What time it is. Defaults to now. Handy in tests.
|
|
154
|
+
* @property {Date | number} [now] What time it is, as a `Date` or Unix milliseconds. Defaults to now. Handy in tests.
|
|
155
|
+
* @property {string} [typ] Required `typ` header, like `"at+jwt"` for OAuth access tokens. Compared ignoring case, with or without `application/`. A token with another `typ`, or none, is refused.
|
|
156
|
+
* @property {string[]} [crit] Header extensions your code understands. A token whose `crit` header lists anything else is refused, as RFC 7515 requires.
|
|
130
157
|
*/
|
|
131
158
|
|
|
132
159
|
/**
|
|
@@ -144,13 +171,18 @@ const _scrypt = (password, salt, keylen, options) =>
|
|
|
144
171
|
* @property {number} [digits] Defaults to `6`.
|
|
145
172
|
* @property {number} [period] How long a code lasts, in seconds. Defaults to `30`.
|
|
146
173
|
* @property {"SHA1" | "SHA256" | "SHA512"} [algorithm] Defaults to `"SHA1"`, which is what authenticator apps expect.
|
|
147
|
-
* @property {Date | number} [time] Compute the code for this moment. Defaults to now.
|
|
174
|
+
* @property {Date | number} [time] Compute the code for this moment, as a `Date` or Unix milliseconds. Defaults to now.
|
|
148
175
|
*/
|
|
149
176
|
|
|
150
177
|
/**
|
|
151
|
-
* Options for `verifyTOTP()
|
|
152
|
-
*
|
|
153
|
-
* @
|
|
178
|
+
* Options for `verifyTOTP()` and `totpStep()`.
|
|
179
|
+
* @typedef {object} VerifyTOTPOptions
|
|
180
|
+
* @property {number} [digits] Defaults to `6`.
|
|
181
|
+
* @property {number} [period] How long a code lasts, in seconds. Defaults to `30`.
|
|
182
|
+
* @property {"SHA1" | "SHA256" | "SHA512"} [algorithm] Defaults to `"SHA1"`, which is what authenticator apps expect.
|
|
183
|
+
* @property {Date | number} [time] Check the code at this moment, as a `Date` or Unix milliseconds. Defaults to now.
|
|
184
|
+
* @property {number} [window] How many periods before and after now are accepted, from `0` to `10`. Defaults to `1`.
|
|
185
|
+
* @property {number} [after] The time step of the last code you accepted, from `totpStep()`. Codes from that step or an earlier one are refused, so a code can't be used twice.
|
|
154
186
|
*/
|
|
155
187
|
|
|
156
188
|
/**
|
|
@@ -256,9 +288,16 @@ const _seconds = (value, what) => {
|
|
|
256
288
|
|
|
257
289
|
/**
|
|
258
290
|
* @param {Date | number | undefined} value
|
|
291
|
+
* @param {string} what
|
|
259
292
|
* @returns {number} Milliseconds.
|
|
260
293
|
*/
|
|
261
|
-
const _now = (value) =>
|
|
294
|
+
const _now = (value, what) => {
|
|
295
|
+
if (value === undefined) return Date.now();
|
|
296
|
+
const ms = value instanceof Date || typeof value === "number" ? +value : NaN;
|
|
297
|
+
// NaN would make every time comparison false, and so skip the expiry checks.
|
|
298
|
+
if (!Number.isFinite(ms)) throw new TypeError(`Invalid ${what}: expected a valid Date or Unix milliseconds, got ${String(value)}`);
|
|
299
|
+
return ms;
|
|
300
|
+
};
|
|
262
301
|
|
|
263
302
|
/**
|
|
264
303
|
* Hashes data, SHA-256 in hex by default.
|
|
@@ -322,74 +361,134 @@ function verifyHmac(data, signature, secret, options = {}) {
|
|
|
322
361
|
* Compares two secrets in constant time, so the comparison can't leak
|
|
323
362
|
* anything through timing, not even their lengths. Use it instead of `===`
|
|
324
363
|
* for tokens, signatures and API keys. A missing value (`undefined`, `null`)
|
|
325
|
-
* never equals anything
|
|
364
|
+
* or an empty one (`""`) never equals anything, not even another empty
|
|
365
|
+
* value, so an unset secret can't match an empty header.
|
|
326
366
|
*
|
|
327
367
|
* @example
|
|
328
368
|
* if (!crypto.safeEqual(providedKey, process.env.API_KEY)) throw new Error("Forbidden");
|
|
329
369
|
*
|
|
330
370
|
* @param {string | Buffer | Uint8Array} a
|
|
331
371
|
* @param {string | Buffer | Uint8Array} b
|
|
332
|
-
* @returns {boolean}
|
|
372
|
+
* @returns {boolean} `false` when either side is missing or empty.
|
|
333
373
|
*/
|
|
334
374
|
function safeEqual(a, b) {
|
|
335
375
|
const x = _maybeBuffer(a);
|
|
336
376
|
const y = _maybeBuffer(b);
|
|
337
|
-
if (!x || !y) return false;
|
|
377
|
+
if (!x?.length || !y?.length) return false;
|
|
338
378
|
const crypto = _crypto();
|
|
339
379
|
// Digests have one length, whatever went in.
|
|
340
380
|
return crypto.timingSafeEqual(crypto.hash("sha256", x, "buffer"), crypto.hash("sha256", y, "buffer"));
|
|
341
381
|
}
|
|
342
382
|
|
|
343
383
|
const _SCRYPT_HASH = /^\$scrypt\$ln=(\d{1,2}),r=(\d{1,4}),p=(\d{1,4})\$([\w-]+)\$([\w-]+)$/;
|
|
344
|
-
//
|
|
345
|
-
const
|
|
384
|
+
// The PHC string of the Argon2 reference implementation: standard Base64, no padding.
|
|
385
|
+
const _ARGON2_HASH = /^\$(argon2id|argon2i|argon2d)\$v=19\$m=(\d{1,8}),t=(\d{1,3}),p=(\d{1,3})\$([A-Za-z0-9+/]+)\$([A-Za-z0-9+/]+)$/;
|
|
386
|
+
// Past 1 GiB of memory, or 2**30 bytes of total scrypt work (32 times the default cost), it's a
|
|
387
|
+
// mistake, or a doctored hash meant to stall the server.
|
|
388
|
+
const _MAX_MEMORY = 2 ** 30;
|
|
389
|
+
const _ARGON2_DEFAULTS = { memory: 19456, passes: 2, parallelism: 1 };
|
|
346
390
|
|
|
347
391
|
/**
|
|
348
392
|
* @param {number} ln
|
|
349
393
|
* @param {number} r
|
|
350
394
|
* @param {number} p
|
|
351
|
-
* @returns {import("node:crypto").ScryptOptions |
|
|
395
|
+
* @returns {import("node:crypto").ScryptOptions | "invalid" | "too costly"}
|
|
352
396
|
*/
|
|
353
397
|
const _scryptOptions = (ln, r, p) => {
|
|
354
|
-
if (![ln, r, p].every(Number.isInteger) || ln < 1 ||
|
|
398
|
+
if (![ln, r, p].every(Number.isInteger) || ln < 1 || r < 1 || p < 1) return "invalid";
|
|
355
399
|
const N = 2 ** ln;
|
|
356
|
-
|
|
400
|
+
// Version 2.1 never made nor accepted more than this, so such a hash is broken, not merely costly.
|
|
401
|
+
if (p > 1024 || 128 * N * r > _MAX_MEMORY) return "invalid";
|
|
402
|
+
// Each lane needs 128 * N * r bytes, and the lanes run one after the other.
|
|
403
|
+
if (p > 16 || 128 * N * r * p > _MAX_MEMORY) return "too costly";
|
|
357
404
|
return { N, r, p, maxmem: 256 * N * r + 128 * r * p + 1024 * 1024 };
|
|
358
405
|
};
|
|
359
406
|
|
|
360
407
|
/**
|
|
361
|
-
*
|
|
362
|
-
*
|
|
363
|
-
*
|
|
408
|
+
* @param {number} memory In KiB.
|
|
409
|
+
* @param {number} passes
|
|
410
|
+
* @param {number} parallelism
|
|
411
|
+
* @returns {"ok" | "invalid" | "too costly"}
|
|
412
|
+
*/
|
|
413
|
+
const _argon2Cost = (memory, passes, parallelism) => {
|
|
414
|
+
if (![memory, passes, parallelism].every(Number.isInteger) || parallelism < 1 || passes < 1 || memory < 8 * parallelism) return "invalid";
|
|
415
|
+
return parallelism > 16 || passes > 10 || memory * 1024 > _MAX_MEMORY ? "too costly" : "ok";
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* A stored hash over the limits is refused without doing the work, but not
|
|
420
|
+
* with `false`: the password may well be right, the hash has to be replaced.
|
|
421
|
+
* @param {string} settings
|
|
422
|
+
* @param {string} limits
|
|
423
|
+
*/
|
|
424
|
+
const _tooCostly = (settings, limits) =>
|
|
425
|
+
new RangeError(`verifyPassword(): this hash asks for more work than allowed (${settings}, the limits are ${limits}). It isn't a wrong password: rehash it with hashPassword() and settings within the limits`);
|
|
426
|
+
|
|
427
|
+
/** @param {Buffer} buffer */
|
|
428
|
+
const _phcBase64 = (buffer) => buffer.toString("base64").replace(/=+$/, "");
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Hashes a password with a random salt: scrypt by default, or Argon2id. The
|
|
432
|
+
* result records its own settings, so you can raise the cost, or move to
|
|
433
|
+
* Argon2id, later without breaking old hashes.
|
|
434
|
+
*
|
|
435
|
+
* Argon2id hashes are standard PHC strings, readable by the other Argon2
|
|
436
|
+
* libraries. Hashing and verifying them needs Node.js 24.7 or later; scrypt
|
|
437
|
+
* works on every supported version.
|
|
364
438
|
*
|
|
365
439
|
* @example
|
|
366
440
|
* const stored = await crypto.hashPassword(password);
|
|
367
441
|
* // "$scrypt$ln=15,r=8,p=1$4xY...$kq9...", store this
|
|
368
442
|
*
|
|
443
|
+
* const modern = await crypto.hashPassword(password, { algorithm: "argon2id" });
|
|
444
|
+
* // "$argon2id$v=19$m=19456,t=2,p=1$Zm9v...$YmFy..."
|
|
445
|
+
*
|
|
369
446
|
* @param {string} password
|
|
370
447
|
* @param {HashPasswordOptions} [options]
|
|
371
448
|
* @returns {Promise<string>}
|
|
372
449
|
* @throws {RangeError} If the settings are out of bounds, like a cost that needs more than 1 GiB of memory.
|
|
450
|
+
* @throws {NodeComfortError} With code `ERR_ARGON2_UNAVAILABLE` for `"argon2id"` on a Node.js older than 24.7.
|
|
373
451
|
*/
|
|
374
452
|
async function hashPassword(password, options = {}) {
|
|
375
453
|
if (typeof password !== "string") throw new TypeError(`hashPassword(): expected the password to be a string, got ${password === null ? "null" : typeof password}`);
|
|
376
|
-
const
|
|
377
|
-
|
|
378
|
-
const p = options.parallelization ?? 1;
|
|
454
|
+
const algorithm = options.algorithm ?? "scrypt";
|
|
455
|
+
if (algorithm !== "scrypt" && algorithm !== "argon2id") throw new TypeError(`hashPassword(): expected the algorithm "scrypt" or "argon2id", got ${JSON.stringify(algorithm) ?? String(algorithm)}`);
|
|
379
456
|
const saltLength = options.saltLength ?? 16;
|
|
380
457
|
const keyLength = options.keyLength ?? 32;
|
|
381
|
-
const scrypt = _scryptOptions(ln, r, p);
|
|
382
|
-
if (!scrypt) throw new RangeError(`hashPassword(): cost ${ln}, blockSize ${r}, parallelization ${p} is out of bounds`);
|
|
383
458
|
if (!Number.isInteger(saltLength) || saltLength < 8) throw new RangeError("hashPassword(): saltLength must be at least 8 bytes");
|
|
384
459
|
if (!Number.isInteger(keyLength) || keyLength < 16 || keyLength > 128) throw new RangeError("hashPassword(): keyLength must be between 16 and 128 bytes");
|
|
460
|
+
|
|
461
|
+
if (algorithm === "argon2id") {
|
|
462
|
+
if (!_hasArgon2()) throw _noArgon2("hashPassword(): Argon2id");
|
|
463
|
+
const memory = options.memory ?? _ARGON2_DEFAULTS.memory;
|
|
464
|
+
const passes = options.passes ?? _ARGON2_DEFAULTS.passes;
|
|
465
|
+
const parallelism = options.parallelism ?? _ARGON2_DEFAULTS.parallelism;
|
|
466
|
+
if (_argon2Cost(memory, passes, parallelism) !== "ok") {
|
|
467
|
+
throw new RangeError(`hashPassword(): memory ${memory} KiB, passes ${passes}, parallelism ${parallelism} is out of bounds (memory up to 1048576 KiB and at least 8 per lane, passes 1 to 10, parallelism 1 to 16)`);
|
|
468
|
+
}
|
|
469
|
+
const salt = _crypto().randomBytes(saltLength);
|
|
470
|
+
const key = await _argon2("argon2id", password.normalize("NFKC"), salt, keyLength, { memory, passes, parallelism });
|
|
471
|
+
return `$argon2id$v=19$m=${memory},t=${passes},p=${parallelism}$${_phcBase64(salt)}$${_phcBase64(key)}`;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
const ln = options.cost ?? 15;
|
|
475
|
+
const r = options.blockSize ?? 8;
|
|
476
|
+
const p = options.parallelization ?? 1;
|
|
477
|
+
const scrypt = _scryptOptions(ln, r, p);
|
|
478
|
+
if (typeof scrypt === "string") throw new RangeError(`hashPassword(): cost ${ln}, blockSize ${r}, parallelization ${p} is out of bounds`);
|
|
385
479
|
const salt = _crypto().randomBytes(saltLength);
|
|
386
480
|
const key = await _scrypt(password.normalize("NFKC"), salt, keyLength, scrypt);
|
|
387
481
|
return `$scrypt$ln=${ln},r=${r},p=${p}$${salt.toString("base64url")}$${key.toString("base64url")}`;
|
|
388
482
|
}
|
|
389
483
|
|
|
390
484
|
/**
|
|
391
|
-
* Checks a password against a stored hash, in constant time
|
|
392
|
-
*
|
|
485
|
+
* Checks a password against a stored hash, in constant time: scrypt hashes
|
|
486
|
+
* from `hashPassword()`, and Argon2 PHC strings (`$argon2id$`, `$argon2i$`,
|
|
487
|
+
* `$argon2d$`) from here or from another library. A wrong password or a
|
|
488
|
+
* broken hash gives `false`. A hash asking for more work than allowed (more
|
|
489
|
+
* than 1 GiB of memory, for instance) throws instead, without doing that
|
|
490
|
+
* work: a doctored hash would stall the server that way, and a genuine one
|
|
491
|
+
* isn't a wrong password but a hash to replace.
|
|
393
492
|
*
|
|
394
493
|
* @example
|
|
395
494
|
* if (!(await crypto.verifyPassword(input, user.passwordHash))) throw new Error("Invalid credentials");
|
|
@@ -397,15 +496,38 @@ async function hashPassword(password, options = {}) {
|
|
|
397
496
|
* @param {string} password
|
|
398
497
|
* @param {string} stored
|
|
399
498
|
* @returns {Promise<boolean>}
|
|
499
|
+
* @throws {RangeError} If the hash asks for more work than allowed: scrypt with `p` over 16 or `128 * N * r * p` over 2**30 bytes (up to what version 2.1 accepted: past that, the hash is broken and gives `false`), Argon2 with more than 1 GiB of memory, 10 passes or 16 lanes. Rehash such passwords with settings within these limits.
|
|
500
|
+
* @throws {NodeComfortError} With code `ERR_ARGON2_UNAVAILABLE` if the hash is Argon2 and this Node.js is older than 24.7. That is a deployment problem, not a wrong password, so it isn't hidden behind `false`.
|
|
400
501
|
*/
|
|
401
502
|
async function verifyPassword(password, stored) {
|
|
402
|
-
|
|
503
|
+
if (typeof password !== "string" || typeof stored !== "string") return false;
|
|
504
|
+
if (/^\$argon2(?:id|i|d)\$/.test(stored)) {
|
|
505
|
+
if (!_hasArgon2()) throw _noArgon2("verifyPassword(): this hash uses Argon2, which");
|
|
506
|
+
const match = _ARGON2_HASH.exec(stored);
|
|
507
|
+
if (!match) return false;
|
|
508
|
+
const [memory, passes, parallelism] = [Number(match[2]), Number(match[3]), Number(match[4])];
|
|
509
|
+
const salt = Buffer.from(match[5], "base64");
|
|
510
|
+
const expected = Buffer.from(match[6], "base64");
|
|
511
|
+
const cost = _argon2Cost(memory, passes, parallelism);
|
|
512
|
+
if (cost === "invalid" || salt.length < 8 || expected.length < 16 || expected.length > 128) return false;
|
|
513
|
+
if (cost === "too costly") throw _tooCostly(`${match[1]} m=${memory},t=${passes},p=${parallelism}`, "m up to 1048576 KiB, t up to 10 and p up to 16");
|
|
514
|
+
try {
|
|
515
|
+
const actual = await _argon2(/** @type {"argon2id" | "argon2i" | "argon2d"} */ (match[1]), password.normalize("NFKC"), salt, expected.length, { memory, passes, parallelism });
|
|
516
|
+
return _crypto().timingSafeEqual(actual, expected);
|
|
517
|
+
} catch {
|
|
518
|
+
return false;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
const match = _SCRYPT_HASH.exec(stored);
|
|
403
522
|
if (!match) return false;
|
|
404
523
|
const scrypt = _scryptOptions(Number(match[1]), Number(match[2]), Number(match[3]));
|
|
524
|
+
const salt = Buffer.from(match[4], "base64url");
|
|
405
525
|
const expected = Buffer.from(match[5], "base64url");
|
|
406
|
-
|
|
526
|
+
// A key cut short would let some wrong passwords match.
|
|
527
|
+
if (scrypt === "invalid" || salt.length < 8 || expected.length < 16 || expected.length > 128) return false;
|
|
528
|
+
if (scrypt === "too costly") throw _tooCostly(`scrypt ln=${match[1]},r=${match[2]},p=${match[3]}`, "p up to 16 and 128 * N * r * p up to 2**30 bytes");
|
|
407
529
|
try {
|
|
408
|
-
const actual = await _scrypt(password.normalize("NFKC"),
|
|
530
|
+
const actual = await _scrypt(password.normalize("NFKC"), salt, expected.length, scrypt);
|
|
409
531
|
return _crypto().timingSafeEqual(actual, expected);
|
|
410
532
|
} catch {
|
|
411
533
|
return false;
|
|
@@ -413,22 +535,49 @@ async function verifyPassword(password, stored) {
|
|
|
413
535
|
}
|
|
414
536
|
|
|
415
537
|
/**
|
|
416
|
-
* Was this hash made with
|
|
417
|
-
*
|
|
538
|
+
* Was this hash made with another algorithm, or with weaker settings than
|
|
539
|
+
* yours (lower cost, a key under 32 bytes, a salt under 16)? If so, re-hash
|
|
540
|
+
* the password right after a successful login.
|
|
418
541
|
*
|
|
419
542
|
* @example
|
|
420
543
|
* if (await crypto.verifyPassword(input, user.hash) && crypto.needsRehash(user.hash, { cost: 16 })) {
|
|
421
544
|
* user.hash = await crypto.hashPassword(input, { cost: 16 });
|
|
422
545
|
* }
|
|
423
546
|
*
|
|
547
|
+
* // moving everyone to Argon2id, one login at a time
|
|
548
|
+
* if (await crypto.verifyPassword(input, user.hash) && crypto.needsRehash(user.hash, { algorithm: "argon2id" })) {
|
|
549
|
+
* user.hash = await crypto.hashPassword(input, { algorithm: "argon2id" });
|
|
550
|
+
* }
|
|
551
|
+
*
|
|
424
552
|
* @param {string} stored
|
|
425
|
-
* @param {HashPasswordOptions} [options] The settings you use today.
|
|
553
|
+
* @param {HashPasswordOptions} [options] The settings you use today. `algorithm` defaults to `"scrypt"`.
|
|
426
554
|
* @returns {boolean}
|
|
427
555
|
*/
|
|
428
556
|
function needsRehash(stored, options = {}) {
|
|
429
|
-
const
|
|
430
|
-
|
|
431
|
-
|
|
557
|
+
const text = String(stored);
|
|
558
|
+
const algorithm = options.algorithm ?? "scrypt";
|
|
559
|
+
const keyLength = options.keyLength ?? 32;
|
|
560
|
+
const saltLength = options.saltLength ?? 16;
|
|
561
|
+
const argon2 = _ARGON2_HASH.exec(text);
|
|
562
|
+
if (argon2) {
|
|
563
|
+
if (argon2[1] !== algorithm) return true;
|
|
564
|
+
return (
|
|
565
|
+
Number(argon2[2]) < (options.memory ?? _ARGON2_DEFAULTS.memory) ||
|
|
566
|
+
Number(argon2[3]) < (options.passes ?? _ARGON2_DEFAULTS.passes) ||
|
|
567
|
+
Number(argon2[4]) < (options.parallelism ?? _ARGON2_DEFAULTS.parallelism) ||
|
|
568
|
+
Buffer.from(argon2[5], "base64").length < saltLength ||
|
|
569
|
+
Buffer.from(argon2[6], "base64").length < keyLength
|
|
570
|
+
);
|
|
571
|
+
}
|
|
572
|
+
const match = _SCRYPT_HASH.exec(text);
|
|
573
|
+
if (!match || algorithm !== "scrypt") return true;
|
|
574
|
+
return (
|
|
575
|
+
Number(match[1]) < (options.cost ?? 15) ||
|
|
576
|
+
Number(match[2]) < (options.blockSize ?? 8) ||
|
|
577
|
+
Number(match[3]) < (options.parallelization ?? 1) ||
|
|
578
|
+
Buffer.from(match[4], "base64url").length < saltLength ||
|
|
579
|
+
Buffer.from(match[5], "base64url").length < keyLength
|
|
580
|
+
);
|
|
432
581
|
}
|
|
433
582
|
|
|
434
583
|
/**
|
|
@@ -568,10 +717,91 @@ const _asymmetric = (key) => {
|
|
|
568
717
|
throw _keyError(`Unsupported key type "${type}"`);
|
|
569
718
|
};
|
|
570
719
|
|
|
571
|
-
/**
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
720
|
+
/**
|
|
721
|
+
* PEM text, as a string or bytes, ready for `createPublicKey()`. A BOM or an
|
|
722
|
+
* OpenSSL preamble ("Bag Attributes", "subject=") can come before the
|
|
723
|
+
* `-----BEGIN` line, so it's looked for anywhere.
|
|
724
|
+
* @param {unknown} key
|
|
725
|
+
* @returns {string | Buffer | undefined} `undefined` if it isn't PEM.
|
|
726
|
+
*/
|
|
727
|
+
const _pem = (key) => {
|
|
728
|
+
if (typeof key === "string") return key.includes("-----BEGIN") ? key.replace(/^/, "") : undefined;
|
|
729
|
+
if (!ArrayBuffer.isView(key) && !types.isAnyArrayBuffer(key)) return undefined;
|
|
730
|
+
const buffer = /** @type {Buffer} */ (_maybeBuffer(key));
|
|
731
|
+
if (!buffer.includes("-----BEGIN")) return undefined;
|
|
732
|
+
return buffer[0] === 0xef && buffer[1] === 0xbb && buffer[2] === 0xbf ? buffer.subarray(3) : buffer;
|
|
733
|
+
};
|
|
734
|
+
|
|
735
|
+
/** @type {[keyof Pick<typeof import("node:crypto"), "createPublicKey" | "createPrivateKey">, string][]} */
|
|
736
|
+
const _DER_TYPES = [
|
|
737
|
+
["createPublicKey", "spki"],
|
|
738
|
+
["createPublicKey", "pkcs1"],
|
|
739
|
+
["createPrivateKey", "pkcs8"],
|
|
740
|
+
["createPrivateKey", "pkcs1"],
|
|
741
|
+
["createPrivateKey", "sec1"],
|
|
742
|
+
];
|
|
743
|
+
|
|
744
|
+
/**
|
|
745
|
+
* Does this read as a public or private key, in DER?
|
|
746
|
+
* @param {Buffer} bytes
|
|
747
|
+
* @returns {boolean}
|
|
748
|
+
*/
|
|
749
|
+
const _isDER = (bytes) => {
|
|
750
|
+
// Every DER key is an ASN.1 SEQUENCE, so anything else is skipped for free.
|
|
751
|
+
if (bytes[0] !== 0x30) return false;
|
|
752
|
+
const crypto = _crypto();
|
|
753
|
+
for (const [create, type] of _DER_TYPES) {
|
|
754
|
+
try {
|
|
755
|
+
crypto[create](/** @type {any} */ ({ key: bytes, format: "der", type }));
|
|
756
|
+
return true;
|
|
757
|
+
} catch {}
|
|
758
|
+
}
|
|
759
|
+
return false;
|
|
760
|
+
};
|
|
761
|
+
|
|
762
|
+
/**
|
|
763
|
+
* Is this text a JWK in JSON, an object with a `kty` member?
|
|
764
|
+
* @param {string} text
|
|
765
|
+
* @returns {boolean}
|
|
766
|
+
*/
|
|
767
|
+
const _isJWKText = (text) => {
|
|
768
|
+
const json = text.replace(/^/, "").trim();
|
|
769
|
+
if (json[0] !== "{") return false;
|
|
770
|
+
try {
|
|
771
|
+
const value = JSON.parse(json);
|
|
772
|
+
return value !== null && typeof value === "object" && Object.hasOwn(value, "kty");
|
|
773
|
+
} catch {
|
|
774
|
+
return false;
|
|
775
|
+
}
|
|
776
|
+
};
|
|
777
|
+
|
|
778
|
+
/**
|
|
779
|
+
* Checks a shared secret before HMAC uses it. A public key is public: used
|
|
780
|
+
* as an HMAC secret, it would let anyone sign tokens (the "algorithm
|
|
781
|
+
* confusion" attack), so whatever looks like an asymmetric key is refused.
|
|
782
|
+
* @param {Buffer} secret
|
|
783
|
+
* @param {unknown} [original] What the caller passed, when it was text.
|
|
784
|
+
* @returns {Buffer}
|
|
785
|
+
*/
|
|
786
|
+
const _hmacSecret = (secret, original) => {
|
|
787
|
+
const candidates = [secret];
|
|
788
|
+
if (typeof original === "string") {
|
|
789
|
+
// A JWK kept in an environment variable is JSON text.
|
|
790
|
+
if (_isJWKText(original)) {
|
|
791
|
+
throw _keyError("This looks like a JWK written as JSON, not a shared secret: its text is never used as an HMAC secret. Pass the parsed object (JSON.parse()) instead, or use a random secret from crypto.generateKey()");
|
|
792
|
+
}
|
|
793
|
+
candidates.push(Buffer.from(original, "latin1"));
|
|
794
|
+
// A PEM body without its header lines is Base64 DER.
|
|
795
|
+
if (/^[A-Za-z0-9+/_\-\s]+={0,2}\s*$/.test(original)) candidates.push(Buffer.from(original.replace(/\s/g, ""), "base64"));
|
|
796
|
+
// And DER is sometimes written out in hex. 32 bytes is the shortest secret allowed anyway.
|
|
797
|
+
if (/^(?:[0-9A-Fa-f]{2}){32,}$/.test(original)) candidates.push(Buffer.from(original, "hex"));
|
|
798
|
+
}
|
|
799
|
+
if (candidates.some((bytes) => bytes.includes("-----BEGIN") || _isDER(bytes))) {
|
|
800
|
+
throw _keyError("This looks like a public or private key, not a shared secret: an asymmetric key is never used as an HMAC secret. Pass a key as PEM text, a KeyObject or a JWK, or use a random secret from crypto.generateKey()");
|
|
801
|
+
}
|
|
802
|
+
// RFC 7518, section 3.2: a shorter secret can be brute-forced from one token.
|
|
803
|
+
if (secret.length < 32) throw _keyError(`A JWT secret must be at least 32 bytes long, this one has ${secret.length}: crypto.generateKey() gives a good one`);
|
|
804
|
+
return secret;
|
|
575
805
|
};
|
|
576
806
|
|
|
577
807
|
/**
|
|
@@ -582,16 +812,17 @@ const _isPEM = (key) => {
|
|
|
582
812
|
const _readKey = (key, use) => {
|
|
583
813
|
const crypto = _crypto();
|
|
584
814
|
if (types.isKeyObject(key)) {
|
|
585
|
-
if (key.type === "secret") return { secret: key.export(), algorithms: _HS };
|
|
815
|
+
if (key.type === "secret") return { secret: _hmacSecret(key.export()), algorithms: _HS };
|
|
586
816
|
if (use === "sign" && key.type !== "private") throw _keyError("Signing takes a private key, this one is public");
|
|
587
817
|
return _asymmetric(use === "verify" && key.type === "private" ? crypto.createPublicKey(key) : key);
|
|
588
818
|
}
|
|
589
819
|
const jwk = key !== null && typeof key === "object" && !ArrayBuffer.isView(key) && typeof (/** @type {{ kty?: unknown }} */ (key).kty) === "string";
|
|
590
820
|
if (jwk && /** @type {import("node:crypto").JsonWebKey} */ (key).kty === "oct") {
|
|
591
|
-
return { secret: _secret(Buffer.from(String(/** @type {import("node:crypto").JsonWebKey} */ (key).k ?? ""), "base64url"), "the JWT secret"), algorithms: _HS };
|
|
821
|
+
return { secret: _hmacSecret(_secret(Buffer.from(String(/** @type {import("node:crypto").JsonWebKey} */ (key).k ?? ""), "base64url"), "the JWT secret")), algorithms: _HS };
|
|
592
822
|
}
|
|
593
|
-
|
|
594
|
-
|
|
823
|
+
const pem = jwk ? undefined : _pem(key);
|
|
824
|
+
if (jwk || pem !== undefined) {
|
|
825
|
+
const input = /** @type {any} */ (jwk ? { key, format: "jwk" } : pem);
|
|
595
826
|
let object;
|
|
596
827
|
try {
|
|
597
828
|
object = use === "sign" ? crypto.createPrivateKey(input) : crypto.createPublicKey(input);
|
|
@@ -600,15 +831,18 @@ const _readKey = (key, use) => {
|
|
|
600
831
|
}
|
|
601
832
|
return _asymmetric(object);
|
|
602
833
|
}
|
|
603
|
-
return { secret: _secret(key, "the JWT secret"), algorithms: _HS };
|
|
834
|
+
return { secret: _hmacSecret(_secret(key, "the JWT secret"), key), algorithms: _HS };
|
|
604
835
|
};
|
|
605
836
|
|
|
606
|
-
// Reading a PEM
|
|
607
|
-
// uses the same few keys for
|
|
837
|
+
// Reading a PEM, or making sure a secret isn't a key in disguise, takes far
|
|
838
|
+
// longer than checking a signature, and a server uses the same few keys for
|
|
839
|
+
// every request.
|
|
608
840
|
/** @type {Record<"sign" | "verify", Map<string, _ResolvedKey>>} */
|
|
609
841
|
const _textKeys = { sign: new Map(), verify: new Map() };
|
|
610
842
|
/** @type {Record<"sign" | "verify", WeakMap<object, _ResolvedKey>>} */
|
|
611
843
|
const _objectKeys = { sign: new WeakMap(), verify: new WeakMap() };
|
|
844
|
+
/** @type {Record<"sign" | "verify", WeakMap<object, { bytes: Buffer, resolved: _ResolvedKey }>>} */
|
|
845
|
+
const _byteKeys = { sign: new WeakMap(), verify: new WeakMap() };
|
|
612
846
|
|
|
613
847
|
/**
|
|
614
848
|
* @param {JWTKey} key
|
|
@@ -617,7 +851,6 @@ const _objectKeys = { sign: new WeakMap(), verify: new WeakMap() };
|
|
|
617
851
|
*/
|
|
618
852
|
const _jwtKey = (key, use) => {
|
|
619
853
|
if (typeof key === "string") {
|
|
620
|
-
if (!_isPEM(key)) return _readKey(key, use);
|
|
621
854
|
let resolved = _textKeys[use].get(key);
|
|
622
855
|
if (!resolved) {
|
|
623
856
|
resolved = _readKey(key, use);
|
|
@@ -626,8 +859,18 @@ const _jwtKey = (key, use) => {
|
|
|
626
859
|
}
|
|
627
860
|
return resolved;
|
|
628
861
|
}
|
|
629
|
-
|
|
630
|
-
|
|
862
|
+
if (ArrayBuffer.isView(key) || types.isAnyArrayBuffer(key)) {
|
|
863
|
+
// Bytes can change after the call: the result is read from a copy, and
|
|
864
|
+
// reused only while the bytes still match it.
|
|
865
|
+
const bytes = /** @type {Buffer} */ (_maybeBuffer(key));
|
|
866
|
+
const cached = _byteKeys[use].get(key);
|
|
867
|
+
if (cached?.bytes.equals(bytes)) return cached.resolved;
|
|
868
|
+
const copy = Buffer.from(bytes);
|
|
869
|
+
const resolved = _readKey(copy, use);
|
|
870
|
+
_byteKeys[use].set(key, { bytes: copy, resolved });
|
|
871
|
+
return resolved;
|
|
872
|
+
}
|
|
873
|
+
if (key === null || typeof key !== "object") return _readKey(key, use);
|
|
631
874
|
let resolved = _objectKeys[use].get(key);
|
|
632
875
|
if (!resolved) _objectKeys[use].set(key, (resolved = _readKey(key, use)));
|
|
633
876
|
return resolved;
|
|
@@ -698,6 +941,33 @@ const _header = (algorithm, extra) => {
|
|
|
698
941
|
return header;
|
|
699
942
|
};
|
|
700
943
|
|
|
944
|
+
const _BASE64URL = /^[A-Za-z0-9_-]*$/;
|
|
945
|
+
|
|
946
|
+
/**
|
|
947
|
+
* @param {number | string | undefined} value
|
|
948
|
+
* @returns {number} Seconds.
|
|
949
|
+
*/
|
|
950
|
+
const _tolerance = (value) => {
|
|
951
|
+
if (value === undefined) return 0;
|
|
952
|
+
// A bare "60" would read as milliseconds where numbers mean seconds, so text needs its unit.
|
|
953
|
+
const seconds = typeof value === "string" ? (/[a-z]/i.test(value) ? toMs(value, "clockTolerance") / 1000 : NaN) : value;
|
|
954
|
+
// NaN or a string here would turn every expiry check off.
|
|
955
|
+
if (typeof seconds !== "number" || !Number.isFinite(seconds) || seconds < 0) {
|
|
956
|
+
throw new TypeError(`verifyJWT(): clockTolerance must be 0 or more seconds, or a duration like "30s", got ${typeof value === "string" ? JSON.stringify(value) : String(value)}`);
|
|
957
|
+
}
|
|
958
|
+
return seconds;
|
|
959
|
+
};
|
|
960
|
+
|
|
961
|
+
/**
|
|
962
|
+
* RFC 7515, section 4.1.9: `typ` is a media type, case-insensitive, whose
|
|
963
|
+
* `application/` prefix may be left out.
|
|
964
|
+
* @param {string} type
|
|
965
|
+
*/
|
|
966
|
+
const _mediaType = (type) => {
|
|
967
|
+
const lower = type.toLowerCase();
|
|
968
|
+
return lower.startsWith("application/") && !lower.includes("/", 12) ? lower.slice(12) : lower;
|
|
969
|
+
};
|
|
970
|
+
|
|
701
971
|
/**
|
|
702
972
|
* @param {unknown} token
|
|
703
973
|
* @returns {{ header: { alg: string, typ?: string, [key: string]: unknown }, payload: Record<string, unknown>, signature: string, signed: string }}
|
|
@@ -707,13 +977,21 @@ const _decode = (token) => {
|
|
|
707
977
|
const first = text.indexOf(".");
|
|
708
978
|
const second = text.indexOf(".", first + 1);
|
|
709
979
|
if (first < 1 || second === -1 || text.includes(".", second + 1)) throw new JWTError("Malformed JWT: expected 3 parts", { code: "ERR_JWT_MALFORMED" });
|
|
980
|
+
// Node's Base64 decoder skips what it doesn't know and ignores padding and
|
|
981
|
+
// unused bits, so one signature could be written many ways. Only the one
|
|
982
|
+
// canonical spelling is read.
|
|
983
|
+
const signature = text.slice(second + 1);
|
|
984
|
+
if (!_BASE64URL.test(text.slice(0, second).replace(".", "")) || !_BASE64URL.test(signature)) {
|
|
985
|
+
throw new JWTError("Malformed JWT: expected Base64URL parts", { code: "ERR_JWT_MALFORMED" });
|
|
986
|
+
}
|
|
987
|
+
if (Buffer.from(signature, "base64url").toString("base64url") !== signature) throw new JWTError("Malformed JWT: the signature isn't canonical Base64URL", { code: "ERR_JWT_MALFORMED" });
|
|
710
988
|
try {
|
|
711
989
|
const header = JSON.parse(Buffer.from(text.slice(0, first), "base64url").toString("utf8"));
|
|
712
990
|
const payload = JSON.parse(Buffer.from(text.slice(first + 1, second), "base64url").toString("utf8"));
|
|
713
991
|
if (!header || typeof header !== "object" || Array.isArray(header) || !payload || typeof payload !== "object" || Array.isArray(payload)) {
|
|
714
992
|
throw new TypeError("not an object");
|
|
715
993
|
}
|
|
716
|
-
return { header, payload, signature
|
|
994
|
+
return { header, payload, signature, signed: text.slice(0, second) };
|
|
717
995
|
} catch (error) {
|
|
718
996
|
throw new JWTError("Malformed JWT: header or payload is not valid JSON", { code: "ERR_JWT_MALFORMED", cause: error });
|
|
719
997
|
}
|
|
@@ -798,12 +1076,20 @@ function decodeJWT(token) {
|
|
|
798
1076
|
*
|
|
799
1077
|
* @template [P=Record<string, unknown>]
|
|
800
1078
|
* @param {string} token
|
|
801
|
-
* @param {JWTKey} secret The shared secret, or the public key for RS, PS, ES and EdDSA tokens.
|
|
1079
|
+
* @param {JWTKey} secret The shared secret (at least 32 bytes), or the public key for RS, PS, ES and EdDSA tokens.
|
|
802
1080
|
* @param {VerifyJWTOptions} [options]
|
|
803
1081
|
* @returns {P & JWTClaims}
|
|
804
|
-
* @throws {JWTError}
|
|
1082
|
+
* @throws {JWTError} If the token is malformed, badly signed, expired or doesn't meet your options.
|
|
1083
|
+
* @throws {TypeError} If the key can't be used (a secret under 32 bytes, a public key passed as raw bytes...) or an option is invalid.
|
|
805
1084
|
*/
|
|
806
1085
|
function verifyJWT(token, secret, options = {}) {
|
|
1086
|
+
// Checked first: a bad option is a bug in the caller, whatever the token.
|
|
1087
|
+
const now = Math.floor(_now(options.now, "now") / 1000);
|
|
1088
|
+
const tolerance = _tolerance(options.clockTolerance);
|
|
1089
|
+
if (options.typ !== undefined && typeof options.typ !== "string") throw new TypeError("verifyJWT(): typ must be a string");
|
|
1090
|
+
if (options.crit !== undefined && !(Array.isArray(options.crit) && options.crit.every((name) => typeof name === "string"))) {
|
|
1091
|
+
throw new TypeError("verifyJWT(): crit must be an array of header names");
|
|
1092
|
+
}
|
|
807
1093
|
const { header, payload, signature, signed } = _decode(token);
|
|
808
1094
|
const key = _jwtKey(secret, "verify");
|
|
809
1095
|
const algorithm = /** @type {JWTAlgorithm} */ (header.alg);
|
|
@@ -812,12 +1098,22 @@ function verifyJWT(token, secret, options = {}) {
|
|
|
812
1098
|
}
|
|
813
1099
|
if (!_signatureMatches(algorithm, signed, signature, key)) throw new JWTError("Invalid signature", { code: "ERR_JWT_SIGNATURE" });
|
|
814
1100
|
|
|
1101
|
+
// RFC 7515, section 4.1.11: an extension the token marks as critical must be understood, or the token refused.
|
|
1102
|
+
if (header.crit !== undefined) {
|
|
1103
|
+
const crit = header.crit;
|
|
1104
|
+
const known = options.crit ?? [];
|
|
1105
|
+
if (!Array.isArray(crit) || !crit.length || !crit.every((name) => typeof name === "string" && known.includes(name) && Object.hasOwn(header, name))) {
|
|
1106
|
+
throw new JWTError(`Unsupported critical header: ${JSON.stringify(crit)}`, { code: "ERR_JWT_CLAIM" });
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
if (options.typ !== undefined && (typeof header.typ !== "string" || _mediaType(header.typ) !== _mediaType(options.typ))) {
|
|
1110
|
+
throw new JWTError(`Invalid "typ" header: expected "${options.typ}"`, { code: "ERR_JWT_CLAIM" });
|
|
1111
|
+
}
|
|
1112
|
+
|
|
815
1113
|
const claims = /** @type {JWTClaims & Record<string, unknown>} */ (payload);
|
|
816
1114
|
for (const name of /** @type {const} */ (["exp", "nbf", "iat"])) {
|
|
817
1115
|
if (claims[name] !== undefined && !Number.isFinite(claims[name])) throw new JWTError(`Invalid "${name}" claim: expected a number`, { code: "ERR_JWT_CLAIM" });
|
|
818
1116
|
}
|
|
819
|
-
const now = Math.floor(_now(options.now) / 1000);
|
|
820
|
-
const tolerance = options.clockTolerance ?? 0;
|
|
821
1117
|
if (!options.ignoreExpiration && claims.exp !== undefined && now - tolerance >= claims.exp) {
|
|
822
1118
|
throw new JWTError("Token expired", { code: "ERR_JWT_EXPIRED", expiredAt: new Date(claims.exp * 1000) });
|
|
823
1119
|
}
|
|
@@ -882,10 +1178,12 @@ function generateKeyPair(algorithm = "EdDSA", options = {}) {
|
|
|
882
1178
|
* @example
|
|
883
1179
|
* user.totpSecret = crypto.totpSecret();
|
|
884
1180
|
*
|
|
885
|
-
* @param {number} [bytes=20]
|
|
1181
|
+
* @param {number} [bytes=20] At least `16` (128 bits, the minimum of RFC 4226). `20` is what most apps use.
|
|
886
1182
|
* @returns {string}
|
|
1183
|
+
* @throws {RangeError} If `bytes` is under 16.
|
|
887
1184
|
*/
|
|
888
1185
|
function totpSecret(bytes = 20) {
|
|
1186
|
+
if (!Number.isInteger(bytes) || bytes < 16) throw new RangeError(`totpSecret(): a TOTP secret needs at least 16 bytes, got ${bytes}`);
|
|
889
1187
|
return _base32Encode(_crypto().randomBytes(bytes));
|
|
890
1188
|
}
|
|
891
1189
|
|
|
@@ -914,8 +1212,22 @@ const _totpOptions = (options) => {
|
|
|
914
1212
|
const digits = options.digits ?? 6;
|
|
915
1213
|
const period = options.period ?? 30;
|
|
916
1214
|
if (!Number.isInteger(digits) || digits < 4 || digits > 10) throw new RangeError(`TOTP codes have 4 to 10 digits, got ${digits}`);
|
|
917
|
-
if (!(period > 0)) throw new RangeError(`The TOTP period must be a positive number of seconds, got ${period}`);
|
|
918
|
-
return { digits, period, algorithm: options.algorithm ?? "SHA1", time: _now(options.time) };
|
|
1215
|
+
if (!(period > 0) || !Number.isFinite(period)) throw new RangeError(`The TOTP period must be a positive number of seconds, got ${period}`);
|
|
1216
|
+
return { digits, period, algorithm: options.algorithm ?? "SHA1", time: _now(options.time, "time") };
|
|
1217
|
+
};
|
|
1218
|
+
|
|
1219
|
+
/**
|
|
1220
|
+
* @param {unknown} secret
|
|
1221
|
+
* @returns {Buffer}
|
|
1222
|
+
*/
|
|
1223
|
+
const _totpKey = (secret) => {
|
|
1224
|
+
if (typeof secret !== "string") throw new TypeError(`Expected the TOTP secret to be a Base32 string, got ${secret === null ? "null" : typeof secret}`);
|
|
1225
|
+
const key = _base32Decode(secret);
|
|
1226
|
+
// An empty secret makes codes anyone can compute. 10 bytes (16 Base32
|
|
1227
|
+
// characters) is what many authenticator setups made, so it still works;
|
|
1228
|
+
// totpSecret() makes longer ones.
|
|
1229
|
+
if (key.length < 10) throw new TypeError(`A TOTP secret must be at least 10 bytes (16 Base32 characters), this one has ${key.length}: crypto.totpSecret() makes one`);
|
|
1230
|
+
return key;
|
|
919
1231
|
};
|
|
920
1232
|
|
|
921
1233
|
/**
|
|
@@ -925,40 +1237,78 @@ const _totpOptions = (options) => {
|
|
|
925
1237
|
* @example
|
|
926
1238
|
* crypto.totp(user.totpSecret); // "492039"
|
|
927
1239
|
*
|
|
928
|
-
* @param {string} secret
|
|
1240
|
+
* @param {string} secret Base32, at least 10 bytes once decoded (16 characters), so older 80-bit secrets keep working. Make new ones with `totpSecret()`, which gives 20 bytes.
|
|
929
1241
|
* @param {TOTPOptions} [options]
|
|
930
1242
|
* @returns {string}
|
|
1243
|
+
* @throws {TypeError} If the secret isn't Base32 or is too short.
|
|
931
1244
|
*/
|
|
932
1245
|
function totp(secret, options = {}) {
|
|
933
1246
|
const { digits, period, algorithm, time } = _totpOptions(options);
|
|
934
|
-
return _hotp(
|
|
1247
|
+
return _hotp(_totpKey(secret), Math.floor(time / 1000 / period), digits, algorithm);
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
/**
|
|
1251
|
+
* Finds which time step a code typed by a user belongs to. Codes from the
|
|
1252
|
+
* neighbouring periods are accepted too, to cope with clock drift.
|
|
1253
|
+
*
|
|
1254
|
+
* Store the step of the last code you accepted and pass it as `after` next
|
|
1255
|
+
* time: the same code, or an older one, is then refused, so a code seen
|
|
1256
|
+
* over someone's shoulder can't be used again.
|
|
1257
|
+
*
|
|
1258
|
+
* @example
|
|
1259
|
+
* const step = crypto.totpStep(req.body.code, user.totpSecret, { after: user.totpLastStep });
|
|
1260
|
+
* if (step === undefined) throw new Error("Invalid code");
|
|
1261
|
+
* user.totpLastStep = step; // save it with the user
|
|
1262
|
+
*
|
|
1263
|
+
* @param {string} code Spaces are ignored.
|
|
1264
|
+
* @param {string} secret Base32, at least 10 bytes once decoded (16 characters), so older 80-bit secrets keep working. Make new ones with `totpSecret()`, which gives 20 bytes.
|
|
1265
|
+
* @param {VerifyTOTPOptions} [options]
|
|
1266
|
+
* @returns {number | undefined} The time step of the code (Unix time divided by the period), or `undefined` if it doesn't match.
|
|
1267
|
+
* @throws {RangeError} If `window` isn't an integer from 0 to 10.
|
|
1268
|
+
* @throws {TypeError} If the secret isn't Base32 or is too short, or `after` isn't an integer.
|
|
1269
|
+
*/
|
|
1270
|
+
function totpStep(code, secret, options = {}) {
|
|
1271
|
+
const { digits, period, algorithm, time } = _totpOptions(options);
|
|
1272
|
+
const key = _totpKey(secret);
|
|
1273
|
+
const window = options.window ?? 1;
|
|
1274
|
+
// Every period of the window costs an HMAC, and a wide window lets guesses through.
|
|
1275
|
+
if (!Number.isInteger(window) || window < 0 || window > 10) throw new RangeError(`The TOTP window must be an integer from 0 to 10, got ${window}`);
|
|
1276
|
+
const after = options.after;
|
|
1277
|
+
if (after !== undefined && !Number.isSafeInteger(after)) throw new TypeError(`The TOTP "after" option must be a time step from totpStep(), got ${String(after)}`);
|
|
1278
|
+
if (typeof code !== "string" && typeof code !== "number") return undefined;
|
|
1279
|
+
const given = Buffer.from(String(code).replace(/\s/g, ""));
|
|
1280
|
+
if (given.length !== digits) return undefined;
|
|
1281
|
+
const counter = Math.floor(time / 1000 / period);
|
|
1282
|
+
/** @type {number | undefined} */
|
|
1283
|
+
let step;
|
|
1284
|
+
// Every candidate is checked, so the time taken says nothing about which one matched.
|
|
1285
|
+
for (let i = -window; i <= window; i++) {
|
|
1286
|
+
const candidate = counter + i;
|
|
1287
|
+
if (candidate >= 0 && _crypto().timingSafeEqual(Buffer.from(_hotp(key, candidate, digits, algorithm)), given) && (after === undefined || candidate > after)) step = candidate;
|
|
1288
|
+
}
|
|
1289
|
+
return step;
|
|
935
1290
|
}
|
|
936
1291
|
|
|
937
1292
|
/**
|
|
938
1293
|
* Checks a code typed by a user. Codes from the neighbouring periods are
|
|
939
1294
|
* accepted too, to cope with clock drift.
|
|
940
1295
|
*
|
|
1296
|
+
* On its own it accepts the same code again until it expires. To refuse
|
|
1297
|
+
* reuse, keep the step of the last accepted code from `totpStep()` and
|
|
1298
|
+
* pass it as `after`.
|
|
1299
|
+
*
|
|
941
1300
|
* @example
|
|
942
1301
|
* if (!crypto.verifyTOTP(req.body.code, user.totpSecret)) throw new Error("Invalid code");
|
|
943
1302
|
*
|
|
944
1303
|
* @param {string} token Spaces are ignored.
|
|
945
|
-
* @param {string} secret
|
|
1304
|
+
* @param {string} secret Base32, at least 10 bytes once decoded (16 characters), so older 80-bit secrets keep working. Make new ones with `totpSecret()`, which gives 20 bytes.
|
|
946
1305
|
* @param {VerifyTOTPOptions} [options]
|
|
947
1306
|
* @returns {boolean}
|
|
1307
|
+
* @throws {RangeError} If `window` isn't an integer from 0 to 10.
|
|
1308
|
+
* @throws {TypeError} If the secret isn't Base32 or is too short.
|
|
948
1309
|
*/
|
|
949
1310
|
function verifyTOTP(token, secret, options = {}) {
|
|
950
|
-
|
|
951
|
-
const key = _base32Decode(secret);
|
|
952
|
-
const code = Buffer.from(String(token).replace(/\s/g, ""));
|
|
953
|
-
if (code.length !== digits) return false;
|
|
954
|
-
const window = Math.max(0, Math.floor(options.window ?? 1));
|
|
955
|
-
const counter = Math.floor(time / 1000 / period);
|
|
956
|
-
let valid = false;
|
|
957
|
-
// Every candidate is checked, so the time taken says nothing about which one matched.
|
|
958
|
-
for (let i = -window; i <= window; i++) {
|
|
959
|
-
if (counter + i >= 0 && _crypto().timingSafeEqual(Buffer.from(_hotp(key, counter + i, digits, algorithm)), code)) valid = true;
|
|
960
|
-
}
|
|
961
|
-
return valid;
|
|
1311
|
+
return totpStep(token, secret, options) !== undefined;
|
|
962
1312
|
}
|
|
963
1313
|
|
|
964
1314
|
/**
|
|
@@ -1074,6 +1424,7 @@ module.exports = {
|
|
|
1074
1424
|
totpSecret,
|
|
1075
1425
|
totp,
|
|
1076
1426
|
verifyTOTP,
|
|
1427
|
+
totpStep,
|
|
1077
1428
|
totpURI,
|
|
1078
1429
|
randomBytes,
|
|
1079
1430
|
randomInt,
|