@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.
- package/CHANGELOG.md +150 -0
- package/README.md +32 -30
- package/index.js +3 -3
- package/package.json +8 -7
- package/src/Arr.js +222 -58
- package/src/Async.js +322 -90
- package/src/Cache.js +148 -44
- package/src/Checker.js +257 -26
- package/src/Cli.js +404 -145
- package/src/Color.js +224 -83
- package/src/Crypto.js +481 -106
- package/src/Emitter.js +104 -37
- package/src/Env.js +176 -42
- package/src/FS.js +203 -60
- package/src/Func.js +226 -76
- package/src/Http.js +311 -149
- package/src/Id.js +281 -96
- package/src/Logger.js +302 -78
- package/src/Num.js +182 -60
- package/src/Obj.js +340 -125
- package/src/SQLite.js +426 -105
- package/src/Schema.js +602 -87
- package/src/Str.js +316 -86
- package/src/Sys.js +197 -54
- package/src/Time.js +572 -317
- package/src/Utils.js +62 -41
- package/src/errors.js +54 -11
- package/src/internal/shared.js +415 -0
- package/types/index.d.ts +59 -17
- package/types/src/Arr.d.ts +78 -0
- package/types/src/Arr.d.ts.map +1 -1
- package/types/src/Async.d.ts +52 -1
- package/types/src/Async.d.ts.map +1 -1
- package/types/src/Cache.d.ts +33 -7
- package/types/src/Cache.d.ts.map +1 -1
- package/types/src/Checker.d.ts +155 -2
- package/types/src/Checker.d.ts.map +1 -1
- package/types/src/Cli.d.ts +97 -15
- package/types/src/Cli.d.ts.map +1 -1
- package/types/src/Color.d.ts +7 -1
- package/types/src/Color.d.ts.map +1 -1
- package/types/src/Crypto.d.ts +99 -13
- package/types/src/Crypto.d.ts.map +1 -1
- package/types/src/Emitter.d.ts +36 -17
- package/types/src/Emitter.d.ts.map +1 -1
- package/types/src/Env.d.ts +31 -1
- package/types/src/Env.d.ts.map +1 -1
- package/types/src/FS.d.ts +36 -5
- package/types/src/FS.d.ts.map +1 -1
- package/types/src/Func.d.ts +147 -19
- package/types/src/Func.d.ts.map +1 -1
- package/types/src/Http.d.ts +113 -43
- package/types/src/Http.d.ts.map +1 -1
- package/types/src/Id.d.ts +60 -8
- package/types/src/Id.d.ts.map +1 -1
- package/types/src/Logger.d.ts +53 -2
- package/types/src/Logger.d.ts.map +1 -1
- package/types/src/Num.d.ts +48 -7
- package/types/src/Num.d.ts.map +1 -1
- package/types/src/Obj.d.ts +67 -4
- package/types/src/Obj.d.ts.map +1 -1
- package/types/src/SQLite.d.ts +123 -14
- package/types/src/SQLite.d.ts.map +1 -1
- package/types/src/Schema.d.ts +256 -7
- package/types/src/Schema.d.ts.map +1 -1
- package/types/src/Str.d.ts +49 -1
- package/types/src/Str.d.ts.map +1 -1
- package/types/src/Sys.d.ts +51 -6
- package/types/src/Sys.d.ts.map +1 -1
- package/types/src/Time.d.ts +130 -34
- package/types/src/Time.d.ts.map +1 -1
- package/types/src/Utils.d.ts +15 -11
- package/types/src/Utils.d.ts.map +1 -1
- package/types/src/errors.d.ts +27 -3
- package/types/src/errors.d.ts.map +1 -1
- package/types/src/internal/shared.d.ts +123 -0
- 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 {
|
|
17
|
-
const {
|
|
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
|
-
*
|
|
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
|
|
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
|
-
/**
|
|
194
|
-
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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().
|
|
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()
|
|
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 `===`
|
|
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 =
|
|
253
|
-
const y =
|
|
254
|
-
if (x
|
|
255
|
-
|
|
256
|
-
|
|
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
|
|
279
|
-
const
|
|
280
|
-
const
|
|
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 =
|
|
402
|
+
const match = typeof password === "string" && typeof stored === "string" ? _SCRYPT_HASH.exec(stored) : null;
|
|
297
403
|
if (!match) return false;
|
|
298
|
-
const
|
|
299
|
-
const expected = Buffer.from(
|
|
300
|
-
|
|
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(
|
|
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 =
|
|
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") :
|
|
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
|
|
370
|
-
const
|
|
371
|
-
const
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
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
|
|
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 {
|
|
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 =
|
|
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
|
-
|
|
412
|
-
|
|
413
|
-
|
|
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
|
|
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 {
|
|
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
|
-
|
|
439
|
-
|
|
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
|
|
451
|
-
|
|
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
|
|
469
|
-
|
|
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 {
|
|
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 } =
|
|
501
|
-
const
|
|
502
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
516
|
-
if (options.maxAge !== undefined && (
|
|
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 && !
|
|
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
|
|
525
|
-
const
|
|
526
|
-
if (!
|
|
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
|
|
558
|
-
|
|
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
|
|
583
|
-
const
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
const
|
|
587
|
-
const
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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,
|