@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.
Files changed (71) hide show
  1. package/CHANGELOG.md +153 -0
  2. package/README.md +2 -2
  3. package/package.json +2 -2
  4. package/src/Arr.js +13 -5
  5. package/src/Checker.js +56 -8
  6. package/src/Cli.js +60 -18
  7. package/src/Color.js +32 -6
  8. package/src/Crypto.js +429 -78
  9. package/src/Emitter.js +9 -9
  10. package/src/Env.js +90 -8
  11. package/src/FS.js +369 -59
  12. package/src/Func.js +1 -1
  13. package/src/Http.js +266 -75
  14. package/src/Id.js +53 -15
  15. package/src/Logger.js +100 -22
  16. package/src/Num.js +3 -2
  17. package/src/Obj.js +225 -36
  18. package/src/SQLite.js +155 -98
  19. package/src/Schema.js +281 -40
  20. package/src/Str.js +77 -29
  21. package/src/Sys.js +124 -23
  22. package/src/Time.js +98 -24
  23. package/src/Utils.js +18 -3
  24. package/src/errors.js +75 -4
  25. package/src/internal/shared.js +68 -14
  26. package/src/internal/terminal.js +49 -0
  27. package/types/index.d.ts +28 -5
  28. package/types/src/Arr.d.ts +4 -2
  29. package/types/src/Arr.d.ts.map +1 -1
  30. package/types/src/Checker.d.ts +29 -3
  31. package/types/src/Checker.d.ts.map +1 -1
  32. package/types/src/Cli.d.ts +6 -4
  33. package/types/src/Cli.d.ts.map +1 -1
  34. package/types/src/Color.d.ts +2 -2
  35. package/types/src/Color.d.ts.map +1 -1
  36. package/types/src/Crypto.d.ts +126 -28
  37. package/types/src/Crypto.d.ts.map +1 -1
  38. package/types/src/Emitter.d.ts +6 -6
  39. package/types/src/Emitter.d.ts.map +1 -1
  40. package/types/src/Env.d.ts +19 -1
  41. package/types/src/Env.d.ts.map +1 -1
  42. package/types/src/FS.d.ts +30 -23
  43. package/types/src/FS.d.ts.map +1 -1
  44. package/types/src/Func.d.ts +1 -1
  45. package/types/src/Http.d.ts +45 -8
  46. package/types/src/Http.d.ts.map +1 -1
  47. package/types/src/Id.d.ts.map +1 -1
  48. package/types/src/Logger.d.ts +11 -3
  49. package/types/src/Logger.d.ts.map +1 -1
  50. package/types/src/Num.d.ts +1 -1
  51. package/types/src/Num.d.ts.map +1 -1
  52. package/types/src/Obj.d.ts +15 -2
  53. package/types/src/Obj.d.ts.map +1 -1
  54. package/types/src/SQLite.d.ts +105 -13
  55. package/types/src/SQLite.d.ts.map +1 -1
  56. package/types/src/Schema.d.ts +61 -5
  57. package/types/src/Schema.d.ts.map +1 -1
  58. package/types/src/Str.d.ts +32 -7
  59. package/types/src/Str.d.ts.map +1 -1
  60. package/types/src/Sys.d.ts +43 -10
  61. package/types/src/Sys.d.ts.map +1 -1
  62. package/types/src/Time.d.ts +12 -7
  63. package/types/src/Time.d.ts.map +1 -1
  64. package/types/src/Utils.d.ts +16 -6
  65. package/types/src/Utils.d.ts.map +1 -1
  66. package/types/src/errors.d.ts +6 -2
  67. package/types/src/errors.d.ts.map +1 -1
  68. package/types/src/internal/shared.d.ts +7 -0
  69. package/types/src/internal/shared.d.ts.map +1 -1
  70. package/types/src/internal/terminal.d.ts +13 -0
  71. 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, two-factor
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 {number} [cost] Work factor as a power of two; each +1 doubles the time. Defaults to `15`.
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} [saltLength] In bytes. Defaults to `16`.
56
- * @property {number} [keyLength] In bytes. Defaults to `32`.
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] Seconds of slack for `exp` and `nbf` when server clocks differ.
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()`. `window` is how many periods before and after
152
- * now are accepted (defaults to `1`).
153
- * @typedef {TOTPOptions & { window?: number }} VerifyTOTPOptions
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) => (value === undefined ? Date.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
- // scrypt needs 128 * N * r bytes. Past 1 GiB it's a mistake, or a doctored hash meant to stall the server.
345
- const _SCRYPT_MAX_MEMORY = 2 ** 30;
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 | undefined} `undefined` if the settings are out of bounds.
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 || ln > 30 || r < 1 || p < 1 || p > 1024) return undefined;
398
+ if (![ln, r, p].every(Number.isInteger) || ln < 1 || r < 1 || p < 1) return "invalid";
355
399
  const N = 2 ** ln;
356
- if (128 * N * r > _SCRYPT_MAX_MEMORY) return undefined;
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
- * Hashes a password with scrypt and a random salt. The result records its
362
- * own settings, so you can raise the cost later without breaking old
363
- * hashes.
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 ln = options.cost ?? 15;
377
- const r = options.blockSize ?? 8;
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. A wrong
392
- * password or a broken hash gives `false`, never an error.
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
- const match = typeof password === "string" && typeof stored === "string" ? _SCRYPT_HASH.exec(stored) : null;
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
- if (!scrypt || !expected.length || expected.length > 128) return false;
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"), Buffer.from(match[4], "base64url"), expected.length, scrypt);
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 weaker settings than yours? If so, re-hash the
417
- * password right after a successful login.
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 match = _SCRYPT_HASH.exec(String(stored));
430
- if (!match) return true;
431
- return Number(match[1]) < (options.cost ?? 15) || Number(match[2]) < (options.blockSize ?? 8) || Number(match[3]) < (options.parallelization ?? 1);
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
- /** @param {unknown} key */
572
- const _isPEM = (key) => {
573
- const start = typeof key === "string" ? key.slice(0, 64) : Buffer.isBuffer(key) ? key.toString("latin1", 0, 64) : "";
574
- return start.trimStart().startsWith("-----BEGIN ");
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
- if (jwk || _isPEM(key)) {
594
- const input = /** @type {any} */ (jwk ? { key, format: "jwk" } : key);
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 takes far longer than checking a signature, and a server
607
- // uses the same few keys for every request.
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
- // Buffers can change after the call, so only immutable key objects and JWKs are remembered.
630
- if (key === null || typeof key !== "object" || ArrayBuffer.isView(key)) return _readKey(key, use);
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: text.slice(second + 1), signed: text.slice(0, second) };
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(_base32Decode(secret), Math.floor(time / 1000 / period), digits, algorithm);
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
- const { digits, period, algorithm, time } = _totpOptions(options);
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,