@ix-xs/node-comfort 2.0.0 → 2.1.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 (77) hide show
  1. package/CHANGELOG.md +150 -0
  2. package/README.md +32 -30
  3. package/index.js +3 -3
  4. package/package.json +8 -7
  5. package/src/Arr.js +222 -58
  6. package/src/Async.js +322 -90
  7. package/src/Cache.js +148 -44
  8. package/src/Checker.js +257 -26
  9. package/src/Cli.js +404 -145
  10. package/src/Color.js +224 -83
  11. package/src/Crypto.js +481 -106
  12. package/src/Emitter.js +104 -37
  13. package/src/Env.js +176 -42
  14. package/src/FS.js +203 -60
  15. package/src/Func.js +226 -76
  16. package/src/Http.js +311 -149
  17. package/src/Id.js +281 -96
  18. package/src/Logger.js +302 -78
  19. package/src/Num.js +182 -60
  20. package/src/Obj.js +340 -125
  21. package/src/SQLite.js +426 -105
  22. package/src/Schema.js +602 -87
  23. package/src/Str.js +316 -86
  24. package/src/Sys.js +197 -54
  25. package/src/Time.js +572 -317
  26. package/src/Utils.js +62 -41
  27. package/src/errors.js +54 -11
  28. package/src/internal/shared.js +415 -0
  29. package/types/index.d.ts +59 -17
  30. package/types/src/Arr.d.ts +78 -0
  31. package/types/src/Arr.d.ts.map +1 -1
  32. package/types/src/Async.d.ts +52 -1
  33. package/types/src/Async.d.ts.map +1 -1
  34. package/types/src/Cache.d.ts +33 -7
  35. package/types/src/Cache.d.ts.map +1 -1
  36. package/types/src/Checker.d.ts +155 -2
  37. package/types/src/Checker.d.ts.map +1 -1
  38. package/types/src/Cli.d.ts +97 -15
  39. package/types/src/Cli.d.ts.map +1 -1
  40. package/types/src/Color.d.ts +7 -1
  41. package/types/src/Color.d.ts.map +1 -1
  42. package/types/src/Crypto.d.ts +99 -13
  43. package/types/src/Crypto.d.ts.map +1 -1
  44. package/types/src/Emitter.d.ts +36 -17
  45. package/types/src/Emitter.d.ts.map +1 -1
  46. package/types/src/Env.d.ts +31 -1
  47. package/types/src/Env.d.ts.map +1 -1
  48. package/types/src/FS.d.ts +36 -5
  49. package/types/src/FS.d.ts.map +1 -1
  50. package/types/src/Func.d.ts +147 -19
  51. package/types/src/Func.d.ts.map +1 -1
  52. package/types/src/Http.d.ts +113 -43
  53. package/types/src/Http.d.ts.map +1 -1
  54. package/types/src/Id.d.ts +60 -8
  55. package/types/src/Id.d.ts.map +1 -1
  56. package/types/src/Logger.d.ts +53 -2
  57. package/types/src/Logger.d.ts.map +1 -1
  58. package/types/src/Num.d.ts +48 -7
  59. package/types/src/Num.d.ts.map +1 -1
  60. package/types/src/Obj.d.ts +67 -4
  61. package/types/src/Obj.d.ts.map +1 -1
  62. package/types/src/SQLite.d.ts +123 -14
  63. package/types/src/SQLite.d.ts.map +1 -1
  64. package/types/src/Schema.d.ts +256 -7
  65. package/types/src/Schema.d.ts.map +1 -1
  66. package/types/src/Str.d.ts +49 -1
  67. package/types/src/Str.d.ts.map +1 -1
  68. package/types/src/Sys.d.ts +51 -6
  69. package/types/src/Sys.d.ts.map +1 -1
  70. package/types/src/Time.d.ts +130 -34
  71. package/types/src/Time.d.ts.map +1 -1
  72. package/types/src/Utils.d.ts +15 -11
  73. package/types/src/Utils.d.ts.map +1 -1
  74. package/types/src/errors.d.ts +27 -3
  75. package/types/src/errors.d.ts.map +1 -1
  76. package/types/src/internal/shared.d.ts +123 -0
  77. package/types/src/internal/shared.d.ts.map +1 -0
package/src/Crypto.js CHANGED
@@ -13,8 +13,9 @@
13
13
  * const claims = crypto.verifyJWT(token, SECRET);
14
14
  */
15
15
 
16
- const { JWTError } = require("./errors.js");
17
- const { parseDuration } = require("./Time.js");
16
+ const { types } = require("node:util");
17
+ const { JWTError, NodeComfortError } = require("./errors.js");
18
+ const { toMs } = require("./internal/shared.js");
18
19
 
19
20
  /** @type {typeof import("node:crypto") | undefined} */
20
21
  let _nodeCrypto;
@@ -68,6 +69,20 @@ const _scrypt = (password, salt, keylen, options) =>
68
69
  * @property {"utf8" | "buffer"} [output] Get a string or a `Buffer`. Defaults to `"utf8"`.
69
70
  */
70
71
 
72
+ /**
73
+ * A key for `signJWT()` and `verifyJWT()`. For the HS algorithms it's a
74
+ * shared secret. For the others it's a key pair: a PEM string, a
75
+ * `KeyObject` or a JWK, private to sign and public to verify.
76
+ * @typedef {string | Buffer | Uint8Array | import("node:crypto").KeyObject | import("node:crypto").JsonWebKey} JWTKey
77
+ */
78
+
79
+ /**
80
+ * A pair of keys in PEM, as made by `generateKeyPair()`.
81
+ * @typedef {object} KeyPair
82
+ * @property {string} publicKey Give this one away: it only checks signatures.
83
+ * @property {string} privateKey Keep this one secret: it signs.
84
+ */
85
+
71
86
  /**
72
87
  * Standard JWT claims.
73
88
  * @typedef {object} JWTClaims
@@ -81,14 +96,16 @@ const _scrypt = (password, salt, keylen, options) =>
81
96
  */
82
97
 
83
98
  /**
84
- * Supported JWT algorithms.
85
- * @typedef {"HS256" | "HS384" | "HS512"} JWTAlgorithm
99
+ * Supported JWT algorithms. HS signs and verifies with one shared secret.
100
+ * RS, PS, ES and EdDSA sign with a private key and verify with the public
101
+ * one, so services that only check tokens never hold the signing key.
102
+ * @typedef {"HS256" | "HS384" | "HS512" | "RS256" | "RS384" | "RS512" | "PS256" | "PS384" | "PS512" | "ES256" | "ES384" | "ES512" | "EdDSA"} JWTAlgorithm
86
103
  */
87
104
 
88
105
  /**
89
106
  * Options for `signJWT()`.
90
107
  * @typedef {object} SignJWTOptions
91
- * @property {JWTAlgorithm} [algorithm] Defaults to `"HS256"`.
108
+ * @property {JWTAlgorithm} [algorithm] Defaults to what fits the key: `"HS256"` for a secret, `"RS256"` for an RSA key, `"ES256"` for a P-256 key, `"EdDSA"` for an Ed25519 key.
92
109
  * @property {string | number} [expiresIn] Lifetime in seconds or as a duration like `"15m"`. Sets `exp`.
93
110
  * @property {string | number} [notBefore] Delay before the token is valid. Sets `nbf`.
94
111
  * @property {string} [issuer] Sets `iss`.
@@ -102,14 +119,14 @@ const _scrypt = (password, salt, keylen, options) =>
102
119
  /**
103
120
  * Options for `verifyJWT()`.
104
121
  * @typedef {object} VerifyJWTOptions
105
- * @property {JWTAlgorithm[]} [algorithms] Accepted algorithms. Defaults to all three HMAC ones.
122
+ * @property {JWTAlgorithm[]} [algorithms] Accepted algorithms. Defaults to the ones that fit the key, so a token can't pick a weaker one.
106
123
  * @property {string | string[]} [issuer] Required issuer.
107
124
  * @property {string | string[]} [audience] Required audience; one match is enough.
108
125
  * @property {string} [subject] Required subject.
109
126
  * @property {number} [clockTolerance] Seconds of slack for `exp` and `nbf` when server clocks differ.
110
127
  * @property {string | number} [maxAge] Reject tokens issued longer ago than this.
111
128
  * @property {boolean} [ignoreExpiration] Skip the `exp` check.
112
- * @property {Date | number} [now] What time it is. Defaults to now.
129
+ * @property {Date | number} [now] What time it is. Defaults to now. Handy in tests.
113
130
  */
114
131
 
115
132
  /**
@@ -190,20 +207,59 @@ const _base32Decode = (text) => {
190
207
  return Buffer.from(out);
191
208
  };
192
209
 
193
- /** @param {unknown} data */
194
- const _toBuffer = (data) => (Buffer.isBuffer(data) ? data : data instanceof Uint8Array ? Buffer.from(data) : Buffer.from(String(data), "utf8"));
210
+ /**
211
+ * @param {unknown} data
212
+ * @returns {Buffer | undefined} `undefined` for what has no obvious bytes: `null`, objects...
213
+ */
214
+ const _maybeBuffer = (data) => {
215
+ if (typeof data === "string") return Buffer.from(data, "utf8");
216
+ if (Buffer.isBuffer(data)) return data;
217
+ if (ArrayBuffer.isView(data)) return Buffer.from(data.buffer, data.byteOffset, data.byteLength);
218
+ if (types.isAnyArrayBuffer(data)) return Buffer.from(data);
219
+ if (typeof data === "number" || typeof data === "bigint") return Buffer.from(String(data), "utf8");
220
+ return undefined;
221
+ };
222
+
223
+ /**
224
+ * @param {unknown} data
225
+ * @param {string} [what]
226
+ * @returns {Buffer}
227
+ */
228
+ const _toBuffer = (data, what = "the data") => {
229
+ const buffer = _maybeBuffer(data);
230
+ // Hashing "undefined" because a variable was missing is how secrets end up guessable.
231
+ if (!buffer) throw new TypeError(`Expected ${what} to be a string, a Buffer or a Uint8Array, got ${data === null ? "null" : typeof data}`);
232
+ return buffer;
233
+ };
234
+
235
+ /**
236
+ * @param {unknown} secret
237
+ * @param {string} what
238
+ * @returns {Buffer}
239
+ */
240
+ const _secret = (secret, what) => {
241
+ const buffer = _toBuffer(secret, what);
242
+ if (!buffer.length) throw new TypeError(`Expected ${what} not to be empty`);
243
+ return buffer;
244
+ };
195
245
 
196
246
  /**
197
247
  * @param {string | number} value
248
+ * @param {string} what
198
249
  * @returns {number}
199
250
  */
200
- const _seconds = (value) => {
201
- if (typeof value === "number") return value;
202
- const ms = parseDuration(value);
203
- if (ms === null) throw new TypeError(`Invalid duration "${value}"`);
204
- return Math.round(ms / 1000);
251
+ const _seconds = (value, what) => {
252
+ const seconds = typeof value === "number" ? value : Math.round(toMs(value, what) / 1000);
253
+ if (!Number.isFinite(seconds)) throw new TypeError(`Invalid ${what}: ${String(value)}`);
254
+ return seconds;
205
255
  };
206
256
 
257
+ /**
258
+ * @param {Date | number | undefined} value
259
+ * @returns {number} Milliseconds.
260
+ */
261
+ const _now = (value) => (value === undefined ? Date.now() : +value);
262
+
207
263
  /**
208
264
  * Hashes data, SHA-256 in hex by default.
209
265
  *
@@ -216,7 +272,7 @@ const _seconds = (value) => {
216
272
  * @returns {string}
217
273
  */
218
274
  function hash(data, options = {}) {
219
- return _crypto().createHash(options.algorithm ?? "sha256").update(_toBuffer(data)).digest(options.encoding ?? "hex");
275
+ return _crypto().hash(options.algorithm ?? "sha256", typeof data === "string" ? data : _toBuffer(data), options.encoding ?? "hex");
220
276
  }
221
277
 
222
278
  /**
@@ -233,13 +289,40 @@ function hash(data, options = {}) {
233
289
  * @returns {string}
234
290
  */
235
291
  function hmac(data, secret, options = {}) {
236
- return _crypto().createHmac(options.algorithm ?? "sha256", _toBuffer(secret)).update(_toBuffer(data)).digest(options.encoding ?? "hex");
292
+ return _crypto()
293
+ .createHmac(options.algorithm ?? "sha256", _secret(secret, "the secret"))
294
+ .update(typeof data === "string" ? data : _toBuffer(data))
295
+ .digest(options.encoding ?? "hex");
296
+ }
297
+
298
+ /**
299
+ * Checks an HMAC signature you received, in constant time. A leading
300
+ * `sha256=`, as GitHub and others send, is ignored.
301
+ *
302
+ * @example
303
+ * // an Express webhook route: sign the raw body, not the parsed JSON
304
+ * if (!crypto.verifyHmac(req.rawBody, req.headers["x-hub-signature-256"], process.env.WEBHOOK_SECRET)) {
305
+ * return res.status(401).end();
306
+ * }
307
+ *
308
+ * @param {string | Buffer | Uint8Array} data
309
+ * @param {unknown} signature What the sender computed. Anything but a matching string gives `false`.
310
+ * @param {string | Buffer | Uint8Array} secret
311
+ * @param {HashOptions} [options] Must match what the sender used.
312
+ * @returns {boolean}
313
+ */
314
+ function verifyHmac(data, signature, secret, options = {}) {
315
+ const expected = hmac(data, secret, options);
316
+ if (typeof signature !== "string") return false;
317
+ const given = signature.replace(/^sha(?:1|256|384|512)=/i, "");
318
+ return safeEqual(expected, (options.encoding ?? "hex") === "hex" ? given.toLowerCase() : given);
237
319
  }
238
320
 
239
321
  /**
240
322
  * Compares two secrets in constant time, so the comparison can't leak
241
- * anything through timing. Use it instead of `===` for tokens, signatures
242
- * and API keys.
323
+ * anything through timing, not even their lengths. Use it instead of `===`
324
+ * for tokens, signatures and API keys. A missing value (`undefined`, `null`)
325
+ * never equals anything.
243
326
  *
244
327
  * @example
245
328
  * if (!crypto.safeEqual(providedKey, process.env.API_KEY)) throw new Error("Forbidden");
@@ -249,15 +332,31 @@ function hmac(data, secret, options = {}) {
249
332
  * @returns {boolean}
250
333
  */
251
334
  function safeEqual(a, b) {
252
- const x = _toBuffer(a);
253
- const y = _toBuffer(b);
254
- if (x.length !== y.length) {
255
- _crypto().timingSafeEqual(x, x);
256
- return false;
257
- }
258
- return _crypto().timingSafeEqual(x, y);
335
+ const x = _maybeBuffer(a);
336
+ const y = _maybeBuffer(b);
337
+ if (!x || !y) return false;
338
+ const crypto = _crypto();
339
+ // Digests have one length, whatever went in.
340
+ return crypto.timingSafeEqual(crypto.hash("sha256", x, "buffer"), crypto.hash("sha256", y, "buffer"));
259
341
  }
260
342
 
343
+ 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;
346
+
347
+ /**
348
+ * @param {number} ln
349
+ * @param {number} r
350
+ * @param {number} p
351
+ * @returns {import("node:crypto").ScryptOptions | undefined} `undefined` if the settings are out of bounds.
352
+ */
353
+ 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;
355
+ const N = 2 ** ln;
356
+ if (128 * N * r > _SCRYPT_MAX_MEMORY) return undefined;
357
+ return { N, r, p, maxmem: 256 * N * r + 128 * r * p + 1024 * 1024 };
358
+ };
359
+
261
360
  /**
262
361
  * Hashes a password with scrypt and a random salt. The result records its
263
362
  * own settings, so you can raise the cost later without breaking old
@@ -270,14 +369,21 @@ function safeEqual(a, b) {
270
369
  * @param {string} password
271
370
  * @param {HashPasswordOptions} [options]
272
371
  * @returns {Promise<string>}
372
+ * @throws {RangeError} If the settings are out of bounds, like a cost that needs more than 1 GiB of memory.
273
373
  */
274
374
  async function hashPassword(password, options = {}) {
375
+ if (typeof password !== "string") throw new TypeError(`hashPassword(): expected the password to be a string, got ${password === null ? "null" : typeof password}`);
275
376
  const ln = options.cost ?? 15;
276
377
  const r = options.blockSize ?? 8;
277
378
  const p = options.parallelization ?? 1;
278
- const salt = _crypto().randomBytes(options.saltLength ?? 16);
279
- const N = 2 ** ln;
280
- const key = await _scrypt(password.normalize("NFKC"), salt, options.keyLength ?? 32, { N, r, p, maxmem: 256 * N * r + 1024 * 1024 });
379
+ const saltLength = options.saltLength ?? 16;
380
+ 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
+ if (!Number.isInteger(saltLength) || saltLength < 8) throw new RangeError("hashPassword(): saltLength must be at least 8 bytes");
384
+ if (!Number.isInteger(keyLength) || keyLength < 16 || keyLength > 128) throw new RangeError("hashPassword(): keyLength must be between 16 and 128 bytes");
385
+ const salt = _crypto().randomBytes(saltLength);
386
+ const key = await _scrypt(password.normalize("NFKC"), salt, keyLength, scrypt);
281
387
  return `$scrypt$ln=${ln},r=${r},p=${p}$${salt.toString("base64url")}$${key.toString("base64url")}`;
282
388
  }
283
389
 
@@ -293,15 +399,13 @@ async function hashPassword(password, options = {}) {
293
399
  * @returns {Promise<boolean>}
294
400
  */
295
401
  async function verifyPassword(password, stored) {
296
- const match = /^\$scrypt\$ln=(\d+),r=(\d+),p=(\d+)\$([\w-]+)\$([\w-]+)$/.exec(String(stored));
402
+ const match = typeof password === "string" && typeof stored === "string" ? _SCRYPT_HASH.exec(stored) : null;
297
403
  if (!match) return false;
298
- const [, ln, r, p, salt, key] = match;
299
- const expected = Buffer.from(key, "base64url");
300
- const N = 2 ** Number(ln);
404
+ const scrypt = _scryptOptions(Number(match[1]), Number(match[2]), Number(match[3]));
405
+ const expected = Buffer.from(match[5], "base64url");
406
+ if (!scrypt || !expected.length || expected.length > 128) return false;
301
407
  try {
302
- const actual = await _scrypt(String(password).normalize("NFKC"), Buffer.from(salt, "base64url"), expected.length, {
303
- N, r: Number(r), p: Number(p), maxmem: 256 * N * Number(r) + 1024 * 1024,
304
- });
408
+ const actual = await _scrypt(password.normalize("NFKC"), Buffer.from(match[4], "base64url"), expected.length, scrypt);
305
409
  return _crypto().timingSafeEqual(actual, expected);
306
410
  } catch {
307
411
  return false;
@@ -322,7 +426,7 @@ async function verifyPassword(password, stored) {
322
426
  * @returns {boolean}
323
427
  */
324
428
  function needsRehash(stored, options = {}) {
325
- const match = /^\$scrypt\$ln=(\d+),r=(\d+),p=(\d+)\$/.exec(String(stored));
429
+ const match = _SCRYPT_HASH.exec(String(stored));
326
430
  if (!match) return true;
327
431
  return Number(match[1]) < (options.cost ?? 15) || Number(match[2]) < (options.blockSize ?? 8) || Number(match[3]) < (options.parallelization ?? 1);
328
432
  }
@@ -343,8 +447,7 @@ function generateKey() {
343
447
  * @returns {Buffer}
344
448
  */
345
449
  const _deriveKey = (secret, salt) => {
346
- const raw = typeof secret === "string" && /^[A-Za-z0-9_-]{43}$/.test(secret) ? Buffer.from(secret, "base64url") : _toBuffer(secret);
347
- if (!raw.length) throw new TypeError("The encryption secret must not be empty");
450
+ const raw = typeof secret === "string" && /^[A-Za-z0-9_-]{43}$/.test(secret) ? Buffer.from(secret, "base64url") : _secret(secret, "the encryption secret");
348
451
  return Buffer.from(_crypto().hkdfSync("sha256", raw, salt, "node-comfort:aes-256-gcm", 32));
349
452
  };
350
453
 
@@ -366,17 +469,20 @@ const _deriveKey = (secret, salt) => {
366
469
  * @returns {string} A compact, URL-safe string.
367
470
  */
368
471
  function encrypt(data, secret, options = {}) {
369
- const salt = _crypto().randomBytes(16);
370
- const iv = _crypto().randomBytes(12);
371
- const cipher = _crypto().createCipheriv("aes-256-gcm", _deriveKey(secret, salt), iv);
372
- if (options.associatedData !== undefined) cipher.setAAD(_toBuffer(options.associatedData));
373
- const encrypted = Buffer.concat([cipher.update(_toBuffer(data)), cipher.final()]);
374
- return ["v1", salt, iv, cipher.getAuthTag(), encrypted].map((part) => (typeof part === "string" ? part : part.toString("base64url"))).join(".");
472
+ const crypto = _crypto();
473
+ const salt = crypto.randomBytes(28);
474
+ const iv = salt.subarray(16);
475
+ const cipher = crypto.createCipheriv("aes-256-gcm", _deriveKey(secret, salt.subarray(0, 16)), iv);
476
+ if (options.associatedData !== undefined) cipher.setAAD(_toBuffer(options.associatedData, "associatedData"));
477
+ const head = cipher.update(_toBuffer(data));
478
+ const tail = cipher.final();
479
+ const encrypted = tail.length ? Buffer.concat([head, tail]) : head;
480
+ return `v1.${salt.toString("base64url", 0, 16)}.${iv.toString("base64url")}.${cipher.getAuthTag().toString("base64url")}.${encrypted.toString("base64url")}`;
375
481
  }
376
482
 
377
483
  /**
378
- * Decrypts what `encrypt()` produced. Throws if the secret is wrong or the
379
- * data was changed.
484
+ * Decrypts what `encrypt()` produced. Throws an error with code
485
+ * `ERR_DECRYPT` if the secret is wrong or the data was changed.
380
486
  *
381
487
  * @example
382
488
  * const text = crypto.decrypt(box, SECRET);
@@ -402,54 +508,260 @@ function encrypt(data, secret, options = {}) {
402
508
  * @param {string | Buffer | Uint8Array} secret
403
509
  * @param {DecryptOptions} [options]
404
510
  * @returns {string | Buffer}
405
- * @throws {Error} If the secret is wrong or the payload was modified.
511
+ * @throws {NodeComfortError} With code `ERR_DECRYPT` if the secret is wrong or the payload was modified.
406
512
  */
407
513
  function decrypt(payload, secret, options = {}) {
408
- const parts = String(payload).split(".");
409
- if (parts.length !== 5 || parts[0] !== "v1") throw new TypeError("Invalid encrypted payload");
514
+ const parts = typeof payload === "string" ? payload.split(".") : [];
410
515
  const [salt, iv, tag, encrypted] = parts.slice(1).map((part) => Buffer.from(part, "base64url"));
411
- const decipher = _crypto().createDecipheriv("aes-256-gcm", _deriveKey(secret, salt), iv);
412
- decipher.setAuthTag(tag);
413
- if (options.associatedData !== undefined) decipher.setAAD(_toBuffer(options.associatedData));
516
+ // A tag cut short is far easier to forge, so only whole ones are read.
517
+ if (parts.length !== 5 || parts[0] !== "v1" || salt.length !== 16 || iv.length !== 12 || tag.length !== 16) {
518
+ throw new NodeComfortError("Unable to decrypt: this isn't something encrypt() produced", { code: "ERR_DECRYPT" });
519
+ }
520
+ const key = _deriveKey(secret, salt);
521
+ const associated = options.associatedData === undefined ? undefined : _toBuffer(options.associatedData, "associatedData");
414
522
  let decrypted;
415
523
  try {
524
+ const decipher = _crypto().createDecipheriv("aes-256-gcm", key, iv, { authTagLength: 16 });
525
+ decipher.setAuthTag(tag);
526
+ if (associated) decipher.setAAD(associated);
416
527
  decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]);
417
528
  } catch (error) {
418
- throw new Error("Unable to decrypt: wrong secret or tampered data", { cause: error });
529
+ throw new NodeComfortError("Unable to decrypt: wrong secret or tampered data", { code: "ERR_DECRYPT", cause: error });
419
530
  }
420
531
  return options.output === "buffer" ? decrypted : decrypted.toString("utf8");
421
532
  }
422
533
 
534
+ /** @type {readonly JWTAlgorithm[]} */
535
+ const _HS = ["HS256", "HS384", "HS512"];
536
+ /** @type {Record<string, JWTAlgorithm>} */
537
+ const _CURVE_ALGORITHMS = { prime256v1: "ES256", secp384r1: "ES384", secp521r1: "ES512" };
538
+ /** @type {Record<string, string>} */
539
+ const _ALGORITHM_CURVES = { ES256: "prime256v1", ES384: "secp384r1", ES512: "secp521r1" };
540
+
541
+ /**
542
+ * A key, ready to use, with the algorithms it may be used with.
543
+ * @typedef {{ secret: Buffer, key?: undefined, algorithms: readonly JWTAlgorithm[] } | { secret?: undefined, key: import("node:crypto").KeyObject, algorithms: readonly JWTAlgorithm[] }} _ResolvedKey
544
+ */
545
+
546
+ /**
547
+ * @param {string} message
548
+ * @param {unknown} [cause]
549
+ */
550
+ const _keyError = (message, cause) => new TypeError(message, { cause });
551
+
552
+ /**
553
+ * @param {import("node:crypto").KeyObject} key
554
+ * @returns {_ResolvedKey}
555
+ */
556
+ const _asymmetric = (key) => {
557
+ const type = key.asymmetricKeyType;
558
+ if (type === "rsa" || type === "rsa-pss") {
559
+ if ((key.asymmetricKeyDetails?.modulusLength ?? 0) < 2048) throw _keyError("RSA keys must be at least 2048 bits long");
560
+ return { key, algorithms: type === "rsa" ? ["RS256", "RS384", "RS512", "PS256", "PS384", "PS512"] : ["PS256", "PS384", "PS512"] };
561
+ }
562
+ if (type === "ec") {
563
+ const algorithm = _CURVE_ALGORITHMS[key.asymmetricKeyDetails?.namedCurve ?? ""];
564
+ if (!algorithm) throw _keyError(`Unsupported curve "${key.asymmetricKeyDetails?.namedCurve}": use P-256, P-384 or P-521`);
565
+ return { key, algorithms: [algorithm] };
566
+ }
567
+ if (type === "ed25519" || type === "ed448") return { key, algorithms: ["EdDSA"] };
568
+ throw _keyError(`Unsupported key type "${type}"`);
569
+ };
570
+
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 ");
575
+ };
576
+
577
+ /**
578
+ * @param {JWTKey} key
579
+ * @param {"sign" | "verify"} use
580
+ * @returns {_ResolvedKey}
581
+ */
582
+ const _readKey = (key, use) => {
583
+ const crypto = _crypto();
584
+ if (types.isKeyObject(key)) {
585
+ if (key.type === "secret") return { secret: key.export(), algorithms: _HS };
586
+ if (use === "sign" && key.type !== "private") throw _keyError("Signing takes a private key, this one is public");
587
+ return _asymmetric(use === "verify" && key.type === "private" ? crypto.createPublicKey(key) : key);
588
+ }
589
+ const jwk = key !== null && typeof key === "object" && !ArrayBuffer.isView(key) && typeof (/** @type {{ kty?: unknown }} */ (key).kty) === "string";
590
+ 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 };
592
+ }
593
+ if (jwk || _isPEM(key)) {
594
+ const input = /** @type {any} */ (jwk ? { key, format: "jwk" } : key);
595
+ let object;
596
+ try {
597
+ object = use === "sign" ? crypto.createPrivateKey(input) : crypto.createPublicKey(input);
598
+ } catch (error) {
599
+ throw _keyError(use === "sign" ? "Signing takes a private key, and this can't be read as one" : "This key can't be read", error);
600
+ }
601
+ return _asymmetric(object);
602
+ }
603
+ return { secret: _secret(key, "the JWT secret"), algorithms: _HS };
604
+ };
605
+
606
+ // Reading a PEM takes far longer than checking a signature, and a server
607
+ // uses the same few keys for every request.
608
+ /** @type {Record<"sign" | "verify", Map<string, _ResolvedKey>>} */
609
+ const _textKeys = { sign: new Map(), verify: new Map() };
610
+ /** @type {Record<"sign" | "verify", WeakMap<object, _ResolvedKey>>} */
611
+ const _objectKeys = { sign: new WeakMap(), verify: new WeakMap() };
612
+
613
+ /**
614
+ * @param {JWTKey} key
615
+ * @param {"sign" | "verify"} use
616
+ * @returns {_ResolvedKey}
617
+ */
618
+ const _jwtKey = (key, use) => {
619
+ if (typeof key === "string") {
620
+ if (!_isPEM(key)) return _readKey(key, use);
621
+ let resolved = _textKeys[use].get(key);
622
+ if (!resolved) {
623
+ resolved = _readKey(key, use);
624
+ if (_textKeys[use].size >= 20) _textKeys[use].clear();
625
+ _textKeys[use].set(key, resolved);
626
+ }
627
+ return resolved;
628
+ }
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);
631
+ let resolved = _objectKeys[use].get(key);
632
+ if (!resolved) _objectKeys[use].set(key, (resolved = _readKey(key, use)));
633
+ return resolved;
634
+ };
635
+
636
+ /**
637
+ * @param {JWTAlgorithm} algorithm
638
+ * @param {import("node:crypto").KeyObject} key
639
+ * @returns {any}
640
+ */
641
+ const _signingKey = (algorithm, key) => {
642
+ if (algorithm[0] === "P") {
643
+ const { constants } = _crypto();
644
+ return { key, padding: constants.RSA_PKCS1_PSS_PADDING, saltLength: constants.RSA_PSS_SALTLEN_DIGEST };
645
+ }
646
+ // JWTs carry ECDSA signatures as r and s side by side, not in DER.
647
+ return algorithm[0] === "E" && algorithm !== "EdDSA" ? { key, dsaEncoding: "ieee-p1363" } : key;
648
+ };
649
+
650
+ /** @param {JWTAlgorithm} algorithm */
651
+ const _digestOf = (algorithm) => (algorithm === "EdDSA" ? null : `sha${algorithm.slice(2)}`);
652
+
653
+ /**
654
+ * @param {JWTAlgorithm} algorithm
655
+ * @param {string} signed The header and the payload, as sent.
656
+ * @param {_ResolvedKey} key
657
+ * @returns {string}
658
+ */
659
+ const _signature = (algorithm, signed, key) => {
660
+ const crypto = _crypto();
661
+ if (key.secret) return crypto.createHmac(/** @type {string} */ (_digestOf(algorithm)), key.secret).update(signed).digest("base64url");
662
+ return crypto.sign(_digestOf(algorithm), Buffer.from(signed), _signingKey(algorithm, key.key)).toString("base64url");
663
+ };
664
+
665
+ /**
666
+ * @param {JWTAlgorithm} algorithm
667
+ * @param {string} signed
668
+ * @param {string} signature
669
+ * @param {_ResolvedKey} key
670
+ * @returns {boolean}
671
+ */
672
+ const _signatureMatches = (algorithm, signed, signature, key) => {
673
+ const crypto = _crypto();
674
+ if (key.secret) {
675
+ const expected = Buffer.from(_signature(algorithm, signed, key));
676
+ const given = Buffer.from(signature);
677
+ return expected.length === given.length && crypto.timingSafeEqual(expected, given);
678
+ }
679
+ try {
680
+ return crypto.verify(_digestOf(algorithm), Buffer.from(signed), _signingKey(algorithm, key.key), Buffer.from(signature, "base64url"));
681
+ } catch {
682
+ return false;
683
+ }
684
+ };
685
+
686
+ /** @type {Map<JWTAlgorithm, string>} */
687
+ const _plainHeaders = new Map();
688
+
689
+ /**
690
+ * @param {JWTAlgorithm} algorithm
691
+ * @param {Record<string, unknown> | undefined} extra
692
+ * @returns {string}
693
+ */
694
+ const _header = (algorithm, extra) => {
695
+ if (extra) return Buffer.from(JSON.stringify({ typ: "JWT", ...extra, alg: algorithm })).toString("base64url");
696
+ let header = _plainHeaders.get(algorithm);
697
+ if (!header) _plainHeaders.set(algorithm, (header = Buffer.from(JSON.stringify({ alg: algorithm, typ: "JWT" })).toString("base64url")));
698
+ return header;
699
+ };
700
+
701
+ /**
702
+ * @param {unknown} token
703
+ * @returns {{ header: { alg: string, typ?: string, [key: string]: unknown }, payload: Record<string, unknown>, signature: string, signed: string }}
704
+ */
705
+ const _decode = (token) => {
706
+ const text = typeof token === "string" ? token : "";
707
+ const first = text.indexOf(".");
708
+ const second = text.indexOf(".", first + 1);
709
+ if (first < 1 || second === -1 || text.includes(".", second + 1)) throw new JWTError("Malformed JWT: expected 3 parts", { code: "ERR_JWT_MALFORMED" });
710
+ try {
711
+ const header = JSON.parse(Buffer.from(text.slice(0, first), "base64url").toString("utf8"));
712
+ const payload = JSON.parse(Buffer.from(text.slice(first + 1, second), "base64url").toString("utf8"));
713
+ if (!header || typeof header !== "object" || Array.isArray(header) || !payload || typeof payload !== "object" || Array.isArray(payload)) {
714
+ throw new TypeError("not an object");
715
+ }
716
+ return { header, payload, signature: text.slice(second + 1), signed: text.slice(0, second) };
717
+ } catch (error) {
718
+ throw new JWTError("Malformed JWT: header or payload is not valid JSON", { code: "ERR_JWT_MALFORMED", cause: error });
719
+ }
720
+ };
721
+
423
722
  /**
424
723
  * Creates a signed JSON Web Token. `iat` is added for you.
425
724
  *
725
+ * With a secret, the token is signed with HMAC. With a private key, it's
726
+ * signed with that key, and anyone holding the public key can verify it
727
+ * without being able to issue tokens.
728
+ *
426
729
  * @example
427
730
  * const token = crypto.signJWT({ sub: String(user.id), role: user.role }, process.env.JWT_SECRET, {
428
731
  * expiresIn: "15m",
429
732
  * issuer: "api.example.com",
430
733
  * });
431
734
  *
735
+ * // key pair: the auth service signs, every other service verifies
736
+ * const { publicKey, privateKey } = await crypto.generateKeyPair("ES256");
737
+ * const signed = crypto.signJWT({ sub: "42" }, privateKey, { expiresIn: "15m" });
738
+ * crypto.verifyJWT(signed, publicKey);
739
+ *
432
740
  * @param {Record<string, unknown>} payload
433
- * @param {string | Buffer} secret At least 32 random bytes.
741
+ * @param {JWTKey} secret For HS algorithms, at least 32 random bytes. Otherwise a private key.
434
742
  * @param {SignJWTOptions} [options]
435
743
  * @returns {string}
744
+ * @throws {TypeError} If the key can't be used, or doesn't fit the algorithm.
436
745
  */
437
746
  function signJWT(payload, secret, options = {}) {
438
- const algorithm = options.algorithm ?? "HS256";
439
- if (!/^HS(256|384|512)$/.test(algorithm)) throw new JWTError(`Unsupported algorithm "${algorithm}"`, { code: "ERR_JWT_ALGORITHM" });
747
+ if (payload === null || typeof payload !== "object" || Array.isArray(payload)) throw new TypeError("signJWT(): the payload must be an object");
748
+ const key = _jwtKey(secret, "sign");
749
+ const algorithm = options.algorithm ?? key.algorithms[0];
750
+ if (!key.algorithms.includes(algorithm)) {
751
+ throw new TypeError(`signJWT(): algorithm "${algorithm}" doesn't fit this key, use ${key.algorithms.join(", ")}`);
752
+ }
440
753
  const now = Math.floor(Date.now() / 1000);
441
754
  /** @type {Record<string, unknown>} */
442
755
  const claims = { ...payload };
443
756
  if (!options.noTimestamp && claims.iat === undefined) claims.iat = now;
444
- if (options.expiresIn !== undefined) claims.exp = now + _seconds(options.expiresIn);
445
- if (options.notBefore !== undefined) claims.nbf = now + _seconds(options.notBefore);
757
+ if (options.expiresIn !== undefined) claims.exp = now + _seconds(options.expiresIn, "expiresIn");
758
+ if (options.notBefore !== undefined) claims.nbf = now + _seconds(options.notBefore, "notBefore");
446
759
  if (options.issuer !== undefined) claims.iss = options.issuer;
447
760
  if (options.subject !== undefined) claims.sub = options.subject;
448
761
  if (options.audience !== undefined) claims.aud = options.audience;
449
762
  if (options.jwtId !== undefined) claims.jti = options.jwtId;
450
- const header = { ...options.header, alg: algorithm, typ: "JWT" };
451
- const body = `${Buffer.from(JSON.stringify(header)).toString("base64url")}.${Buffer.from(JSON.stringify(claims)).toString("base64url")}`;
452
- return `${body}.${hmac(body, secret, { algorithm: `sha${algorithm.slice(2)}`, encoding: "base64url" })}`;
763
+ const signed = `${_header(algorithm, options.header)}.${Buffer.from(JSON.stringify(claims)).toString("base64url")}`;
764
+ return `${signed}.${_signature(algorithm, signed, key)}`;
453
765
  }
454
766
 
455
767
  /**
@@ -465,16 +777,8 @@ function signJWT(payload, secret, options = {}) {
465
777
  * @throws {JWTError} With code `ERR_JWT_MALFORMED` if the token can't be read.
466
778
  */
467
779
  function decodeJWT(token) {
468
- const parts = String(token).split(".");
469
- if (parts.length !== 3) throw new JWTError("Malformed JWT: expected 3 parts", { code: "ERR_JWT_MALFORMED" });
470
- try {
471
- const header = JSON.parse(Buffer.from(parts[0], "base64url").toString("utf8"));
472
- const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"));
473
- if (!header || typeof header !== "object" || !payload || typeof payload !== "object") throw new TypeError("not an object");
474
- return { header, payload, signature: parts[2] };
475
- } catch (error) {
476
- throw new JWTError("Malformed JWT: header or payload is not valid JSON", { code: "ERR_JWT_MALFORMED", cause: error });
477
- }
780
+ const { header, payload, signature } = _decode(token);
781
+ return { header, payload: /** @type {P & JWTClaims} */ (payload), signature };
478
782
  }
479
783
 
480
784
  /**
@@ -482,6 +786,9 @@ function decodeJWT(token) {
482
786
  * require. Returns the payload, or throws a `JWTError` whose `code` says
483
787
  * what's wrong.
484
788
  *
789
+ * The accepted algorithms follow from the key you pass, so a token signed
790
+ * with HMAC is never accepted where a public key is expected.
791
+ *
485
792
  * @example
486
793
  * try {
487
794
  * const { sub } = crypto.verifyJWT(token, process.env.JWT_SECRET, { issuer: "api.example.com" });
@@ -491,43 +798,83 @@ function decodeJWT(token) {
491
798
  *
492
799
  * @template [P=Record<string, unknown>]
493
800
  * @param {string} token
494
- * @param {string | Buffer} secret
801
+ * @param {JWTKey} secret The shared secret, or the public key for RS, PS, ES and EdDSA tokens.
495
802
  * @param {VerifyJWTOptions} [options]
496
803
  * @returns {P & JWTClaims}
497
804
  * @throws {JWTError}
498
805
  */
499
806
  function verifyJWT(token, secret, options = {}) {
500
- const { header, payload } = decodeJWT(token);
501
- const allowed = options.algorithms ?? ["HS256", "HS384", "HS512"];
502
- if (typeof header.alg !== "string" || !allowed.includes(/** @type {JWTAlgorithm} */ (header.alg))) {
807
+ const { header, payload, signature, signed } = _decode(token);
808
+ const key = _jwtKey(secret, "verify");
809
+ const algorithm = /** @type {JWTAlgorithm} */ (header.alg);
810
+ if (typeof algorithm !== "string" || !key.algorithms.includes(algorithm) || (options.algorithms !== undefined && !options.algorithms.includes(algorithm))) {
503
811
  throw new JWTError(`Algorithm "${String(header.alg)}" is not allowed`, { code: "ERR_JWT_ALGORITHM" });
504
812
  }
505
- const [head, body, signature] = String(token).split(".");
506
- const expected = hmac(`${head}.${body}`, secret, { algorithm: `sha${header.alg.slice(2)}`, encoding: "base64url" });
507
- if (!safeEqual(expected, signature)) throw new JWTError("Invalid signature", { code: "ERR_JWT_SIGNATURE" });
813
+ if (!_signatureMatches(algorithm, signed, signature, key)) throw new JWTError("Invalid signature", { code: "ERR_JWT_SIGNATURE" });
508
814
 
509
- const now = Math.floor((options.now instanceof Date ? options.now.getTime() : options.now ?? Date.now()) / 1000);
510
- const tolerance = options.clockTolerance ?? 0;
511
815
  const claims = /** @type {JWTClaims & Record<string, unknown>} */ (payload);
512
- if (!options.ignoreExpiration && typeof claims.exp === "number" && now - tolerance >= claims.exp) {
816
+ for (const name of /** @type {const} */ (["exp", "nbf", "iat"])) {
817
+ if (claims[name] !== undefined && !Number.isFinite(claims[name])) throw new JWTError(`Invalid "${name}" claim: expected a number`, { code: "ERR_JWT_CLAIM" });
818
+ }
819
+ const now = Math.floor(_now(options.now) / 1000);
820
+ const tolerance = options.clockTolerance ?? 0;
821
+ if (!options.ignoreExpiration && claims.exp !== undefined && now - tolerance >= claims.exp) {
513
822
  throw new JWTError("Token expired", { code: "ERR_JWT_EXPIRED", expiredAt: new Date(claims.exp * 1000) });
514
823
  }
515
- if (typeof claims.nbf === "number" && now + tolerance < claims.nbf) throw new JWTError("Token not active yet", { code: "ERR_JWT_NOT_BEFORE" });
516
- if (options.maxAge !== undefined && (typeof claims.iat !== "number" || now - tolerance > claims.iat + _seconds(options.maxAge))) {
824
+ if (claims.nbf !== undefined && now + tolerance < claims.nbf) throw new JWTError("Token not active yet", { code: "ERR_JWT_NOT_BEFORE" });
825
+ if (options.maxAge !== undefined && (claims.iat === undefined || now - tolerance > claims.iat + _seconds(options.maxAge, "maxAge"))) {
517
826
  throw new JWTError("Token is too old", { code: "ERR_JWT_EXPIRED" });
518
827
  }
519
- if (options.issuer !== undefined && ![options.issuer].flat().includes(/** @type {string} */ (claims.iss))) {
828
+ if (options.issuer !== undefined && !(typeof claims.iss === "string" && (Array.isArray(options.issuer) ? options.issuer.includes(claims.iss) : options.issuer === claims.iss))) {
520
829
  throw new JWTError("Invalid issuer", { code: "ERR_JWT_CLAIM" });
521
830
  }
522
831
  if (options.subject !== undefined && claims.sub !== options.subject) throw new JWTError("Invalid subject", { code: "ERR_JWT_CLAIM" });
523
832
  if (options.audience !== undefined) {
524
- const expectedAud = [options.audience].flat();
525
- const actualAud = [claims.aud ?? []].flat();
526
- if (!actualAud.some((aud) => expectedAud.includes(/** @type {string} */ (aud)))) throw new JWTError("Invalid audience", { code: "ERR_JWT_CLAIM" });
833
+ const wanted = Array.isArray(options.audience) ? options.audience : [options.audience];
834
+ const actual = Array.isArray(claims.aud) ? claims.aud : [claims.aud];
835
+ if (!actual.some((audience) => typeof audience === "string" && wanted.includes(audience))) throw new JWTError("Invalid audience", { code: "ERR_JWT_CLAIM" });
527
836
  }
528
837
  return /** @type {P & JWTClaims} */ (payload);
529
838
  }
530
839
 
840
+ /**
841
+ * Makes a pair of keys for signing JWTs: the private one signs, the public
842
+ * one verifies. Both come as PEM text, ready for an environment variable or
843
+ * a file.
844
+ *
845
+ * `"EdDSA"` (Ed25519) is the fastest with the shortest keys and signatures.
846
+ * `"ES256"` is the most widely supported after RSA. Pick `"RS256"` when the
847
+ * other side asks for it.
848
+ *
849
+ * @example
850
+ * const { publicKey, privateKey } = await crypto.generateKeyPair();
851
+ * const token = crypto.signJWT({ sub: "42" }, privateKey);
852
+ * crypto.verifyJWT(token, publicKey);
853
+ *
854
+ * @param {Exclude<JWTAlgorithm, "HS256" | "HS384" | "HS512">} [algorithm="EdDSA"] The JWT algorithm the keys are for.
855
+ * @param {{ modulusLength?: number }} [options] RSA key size in bits. Defaults to `2048`.
856
+ * @returns {Promise<KeyPair>}
857
+ */
858
+ function generateKeyPair(algorithm = "EdDSA", options = {}) {
859
+ /** @type {[string, object]} */
860
+ let spec;
861
+ if (algorithm === "EdDSA") spec = ["ed25519", {}];
862
+ else if (Object.hasOwn(_ALGORITHM_CURVES, algorithm)) spec = ["ec", { namedCurve: _ALGORITHM_CURVES[algorithm] }];
863
+ else if (/^(RS|PS)(256|384|512)$/.test(algorithm)) {
864
+ const modulusLength = options.modulusLength ?? 2048;
865
+ if (!Number.isInteger(modulusLength) || modulusLength < 2048) return Promise.reject(new RangeError("generateKeyPair(): modulusLength must be at least 2048"));
866
+ spec = ["rsa", { modulusLength }];
867
+ } else {
868
+ return Promise.reject(new TypeError(`generateKeyPair(): expected an RS, PS, ES or EdDSA algorithm, got "${String(algorithm)}"`));
869
+ }
870
+ const encoding = { publicKeyEncoding: { type: "spki", format: "pem" }, privateKeyEncoding: { type: "pkcs8", format: "pem" } };
871
+ return new Promise((resolve, reject) => {
872
+ /** @type {any} */ (_crypto().generateKeyPair)(spec[0], { ...spec[1], ...encoding }, (/** @type {Error | null} */ error, /** @type {string} */ publicKey, /** @type {string} */ privateKey) =>
873
+ error ? reject(error) : resolve({ publicKey, privateKey }),
874
+ );
875
+ });
876
+ }
877
+
531
878
  /**
532
879
  * A new two-factor secret, in the Base32 format authenticator apps expect.
533
880
  * Store it encrypted.
@@ -542,6 +889,35 @@ function totpSecret(bytes = 20) {
542
889
  return _base32Encode(_crypto().randomBytes(bytes));
543
890
  }
544
891
 
892
+ /**
893
+ * @param {Buffer} key
894
+ * @param {number} counter
895
+ * @param {number} digits
896
+ * @param {string} algorithm
897
+ * @returns {string}
898
+ */
899
+ const _hotp = (key, counter, digits, algorithm) => {
900
+ const buffer = Buffer.alloc(8);
901
+ buffer.writeUInt32BE(Math.floor(counter / 2 ** 32), 0);
902
+ buffer.writeUInt32BE(counter >>> 0, 4);
903
+ const digest = _crypto().createHmac(algorithm.toLowerCase(), key).update(buffer).digest();
904
+ const off = digest[digest.length - 1] & 0x0f;
905
+ const code = ((digest[off] & 0x7f) << 24) | (digest[off + 1] << 16) | (digest[off + 2] << 8) | digest[off + 3];
906
+ return String(code % 10 ** digits).padStart(digits, "0");
907
+ };
908
+
909
+ /**
910
+ * @param {TOTPOptions} options
911
+ * @returns {{ digits: number, period: number, algorithm: string, time: number }}
912
+ */
913
+ const _totpOptions = (options) => {
914
+ const digits = options.digits ?? 6;
915
+ const period = options.period ?? 30;
916
+ 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) };
919
+ };
920
+
545
921
  /**
546
922
  * The current 6-digit code for a two-factor secret, as shown by
547
923
  * authenticator apps.
@@ -554,16 +930,8 @@ function totpSecret(bytes = 20) {
554
930
  * @returns {string}
555
931
  */
556
932
  function totp(secret, options = {}) {
557
- const digits = options.digits ?? 6;
558
- const period = options.period ?? 30;
559
- const time = options.time instanceof Date ? options.time.getTime() : options.time ?? Date.now();
560
- const counter = Math.floor(time / 1000 / period);
561
- const buffer = Buffer.alloc(8);
562
- buffer.writeBigUInt64BE(BigInt(counter));
563
- const digest = _crypto().createHmac((options.algorithm ?? "SHA1").toLowerCase(), _base32Decode(secret)).update(buffer).digest();
564
- const off = digest[digest.length - 1] & 0x0f;
565
- const code = ((digest[off] & 0x7f) << 24) | (digest[off + 1] << 16) | (digest[off + 2] << 8) | digest[off + 3];
566
- return String(code % 10 ** digits).padStart(digits, "0");
933
+ const { digits, period, algorithm, time } = _totpOptions(options);
934
+ return _hotp(_base32Decode(secret), Math.floor(time / 1000 / period), digits, algorithm);
567
935
  }
568
936
 
569
937
  /**
@@ -579,15 +947,16 @@ function totp(secret, options = {}) {
579
947
  * @returns {boolean}
580
948
  */
581
949
  function verifyTOTP(token, secret, options = {}) {
582
- const code = String(token).replace(/\s/g, "");
583
- const digits = options.digits ?? 6;
584
- if (!new RegExp(`^\\d{${digits}}$`).test(code)) return false;
585
- const period = options.period ?? 30;
586
- const time = options.time instanceof Date ? options.time.getTime() : options.time ?? Date.now();
587
- const window = options.window ?? 1;
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);
588
956
  let valid = false;
957
+ // Every candidate is checked, so the time taken says nothing about which one matched.
589
958
  for (let i = -window; i <= window; i++) {
590
- if (safeEqual(totp(secret, { ...options, time: time + i * period * 1000 }), code)) valid = true;
959
+ if (counter + i >= 0 && _crypto().timingSafeEqual(Buffer.from(_hotp(key, counter + i, digits, algorithm)), code)) valid = true;
591
960
  }
592
961
  return valid;
593
962
  }
@@ -635,7 +1004,10 @@ function randomBytes(size) {
635
1004
  * @returns {number}
636
1005
  */
637
1006
  function randomInt(min, max) {
638
- return _crypto().randomInt(Math.ceil(min), Math.floor(max) + 1);
1007
+ const low = Math.ceil(min);
1008
+ const high = Math.floor(max);
1009
+ if (!(high >= low)) throw new RangeError(`randomInt(): expected min <= max, got ${min} and ${max}`);
1010
+ return _crypto().randomInt(low, high + 1);
639
1011
  }
640
1012
 
641
1013
  /**
@@ -679,13 +1051,15 @@ function toBase64(data, options = {}) {
679
1051
  * @returns {string | Buffer}
680
1052
  */
681
1053
  function fromBase64(text, options = {}) {
682
- const buffer = Buffer.from(String(text).replace(/-/g, "+").replace(/_/g, "/"), "base64");
1054
+ // Node reads both alphabets, padded or not.
1055
+ const buffer = Buffer.from(String(text), "base64");
683
1056
  return options.output === "buffer" ? buffer : buffer.toString("utf8");
684
1057
  }
685
1058
 
686
1059
  module.exports = {
687
1060
  hash,
688
1061
  hmac,
1062
+ verifyHmac,
689
1063
  safeEqual,
690
1064
  hashPassword,
691
1065
  verifyPassword,
@@ -696,6 +1070,7 @@ module.exports = {
696
1070
  signJWT,
697
1071
  decodeJWT,
698
1072
  verifyJWT,
1073
+ generateKeyPair,
699
1074
  totpSecret,
700
1075
  totp,
701
1076
  verifyTOTP,