@ix-xs/node-comfort 1.1.31 → 2.0.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 +190 -0
- package/LICENSE +21 -0
- package/README.md +210 -748
- package/index.js +84 -13
- package/package.json +201 -21
- package/src/Arr.js +901 -0
- package/src/Async.js +585 -0
- package/src/Cache.js +329 -0
- package/src/Checker.js +979 -188
- package/src/Cli.js +1034 -0
- package/src/Color.js +415 -0
- package/src/Crypto.js +707 -0
- package/src/Emitter.js +289 -0
- package/src/Env.js +555 -0
- package/src/FS.js +1483 -768
- package/src/Func.js +692 -0
- package/src/Http.js +538 -0
- package/src/Id.js +418 -0
- package/src/Logger.js +739 -307
- package/src/Num.js +849 -0
- package/src/Obj.js +904 -0
- package/src/SQLite.js +1308 -994
- package/src/Schema.js +1586 -0
- package/src/Str.js +999 -0
- package/src/Sys.js +511 -0
- package/src/Time.js +1323 -0
- package/src/Utils.js +321 -325
- package/src/errors.js +292 -0
- package/types/index.d.ts +693 -120
- package/types/src/Arr.d.ts +562 -0
- package/types/src/Arr.d.ts.map +1 -0
- package/types/src/Async.d.ts +321 -0
- package/types/src/Async.d.ts.map +1 -0
- package/types/src/Cache.d.ts +195 -0
- package/types/src/Cache.d.ts.map +1 -0
- package/types/src/Checker.d.ts +609 -81
- package/types/src/Checker.d.ts.map +1 -1
- package/types/src/Cli.d.ts +582 -0
- package/types/src/Cli.d.ts.map +1 -0
- package/types/src/Color.d.ts +294 -0
- package/types/src/Color.d.ts.map +1 -0
- package/types/src/Crypto.d.ts +534 -0
- package/types/src/Crypto.d.ts.map +1 -0
- package/types/src/Emitter.d.ts +170 -0
- package/types/src/Emitter.d.ts.map +1 -0
- package/types/src/Env.d.ts +382 -0
- package/types/src/Env.d.ts.map +1 -0
- package/types/src/FS.d.ts +709 -201
- package/types/src/FS.d.ts.map +1 -1
- package/types/src/Func.d.ts +455 -0
- package/types/src/Func.d.ts.map +1 -0
- package/types/src/Http.d.ts +374 -0
- package/types/src/Http.d.ts.map +1 -0
- package/types/src/Id.d.ts +200 -0
- package/types/src/Id.d.ts.map +1 -0
- package/types/src/Logger.d.ts +374 -24
- package/types/src/Logger.d.ts.map +1 -1
- package/types/src/Num.d.ts +578 -0
- package/types/src/Num.d.ts.map +1 -0
- package/types/src/Obj.d.ts +475 -0
- package/types/src/Obj.d.ts.map +1 -0
- package/types/src/SQLite.d.ts +647 -305
- package/types/src/SQLite.d.ts.map +1 -1
- package/types/src/Schema.d.ts +940 -0
- package/types/src/Schema.d.ts.map +1 -0
- package/types/src/Str.d.ts +696 -0
- package/types/src/Str.d.ts.map +1 -0
- package/types/src/Sys.d.ts +269 -0
- package/types/src/Sys.d.ts.map +1 -0
- package/types/src/Time.d.ts +664 -0
- package/types/src/Time.d.ts.map +1 -0
- package/types/src/Utils.d.ts +154 -102
- package/types/src/Utils.d.ts.map +1 -1
- package/types/src/errors.d.ts +292 -0
- package/types/src/errors.d.ts.map +1 -0
- package/tsconfig.json +0 -18
- package/types/index.d.ts.map +0 -1
package/src/Crypto.js
ADDED
|
@@ -0,0 +1,707 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Crypto without the footguns, built on `node:crypto`: hashes, HMAC,
|
|
5
|
+
* password hashing (scrypt), encryption (AES-256-GCM), JWT, two-factor
|
|
6
|
+
* codes (TOTP) and secure random values.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* const stored = await crypto.hashPassword("hunter2");
|
|
10
|
+
* await crypto.verifyPassword("hunter2", stored); // true
|
|
11
|
+
*
|
|
12
|
+
* const token = crypto.signJWT({ sub: user.id }, SECRET, { expiresIn: "1h" });
|
|
13
|
+
* const claims = crypto.verifyJWT(token, SECRET);
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const { JWTError } = require("./errors.js");
|
|
17
|
+
const { parseDuration } = require("./Time.js");
|
|
18
|
+
|
|
19
|
+
/** @type {typeof import("node:crypto") | undefined} */
|
|
20
|
+
let _nodeCrypto;
|
|
21
|
+
// node:crypto takes a few milliseconds to initialize: load it on first use.
|
|
22
|
+
const _crypto = () => (_nodeCrypto ??= require("node:crypto"));
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param {import("node:crypto").BinaryLike} password
|
|
26
|
+
* @param {import("node:crypto").BinaryLike} salt
|
|
27
|
+
* @param {number} keylen
|
|
28
|
+
* @param {import("node:crypto").ScryptOptions} options
|
|
29
|
+
* @returns {Promise<Buffer>}
|
|
30
|
+
*/
|
|
31
|
+
const _scrypt = (password, salt, keylen, options) =>
|
|
32
|
+
new Promise((resolve, reject) => {
|
|
33
|
+
_crypto().scrypt(password, salt, keylen, options, (error, key) => (error ? reject(error) : resolve(key)));
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Text encodings for digests.
|
|
38
|
+
* @typedef {"hex" | "base64" | "base64url"} DigestEncoding
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Options for `hash()` and `hmac()`.
|
|
43
|
+
* @typedef {object} HashOptions
|
|
44
|
+
* @property {"sha256" | "sha384" | "sha512" | "sha1" | "md5" | "sha3-256" | "sha3-512" | (string & {})} [algorithm] Any algorithm OpenSSL supports. Defaults to `"sha256"`.
|
|
45
|
+
* @property {DigestEncoding} [encoding] Defaults to `"hex"`.
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Options for `hashPassword()`.
|
|
50
|
+
* @typedef {object} HashPasswordOptions
|
|
51
|
+
* @property {number} [cost] Work factor as a power of two; each +1 doubles the time. Defaults to `15`.
|
|
52
|
+
* @property {number} [blockSize] scrypt `r`. Defaults to `8`.
|
|
53
|
+
* @property {number} [parallelization] scrypt `p`. Defaults to `1`.
|
|
54
|
+
* @property {number} [saltLength] In bytes. Defaults to `16`.
|
|
55
|
+
* @property {number} [keyLength] In bytes. Defaults to `32`.
|
|
56
|
+
*/
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Options for `encrypt()`.
|
|
60
|
+
* @typedef {object} EncryptOptions
|
|
61
|
+
* @property {string | Buffer} [associatedData] Data that's authenticated but not encrypted, like a user id. Pass the same value to `decrypt()`.
|
|
62
|
+
*/
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Options for `decrypt()`.
|
|
66
|
+
* @typedef {object} DecryptOptions
|
|
67
|
+
* @property {string | Buffer} [associatedData] The value given to `encrypt()`, if any.
|
|
68
|
+
* @property {"utf8" | "buffer"} [output] Get a string or a `Buffer`. Defaults to `"utf8"`.
|
|
69
|
+
*/
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Standard JWT claims.
|
|
73
|
+
* @typedef {object} JWTClaims
|
|
74
|
+
* @property {string} [iss] Issuer.
|
|
75
|
+
* @property {string} [sub] Subject, usually the user id.
|
|
76
|
+
* @property {string | string[]} [aud] Audience.
|
|
77
|
+
* @property {number} [exp] Expiry, in Unix seconds.
|
|
78
|
+
* @property {number} [nbf] Not valid before, in Unix seconds.
|
|
79
|
+
* @property {number} [iat] Issued at, in Unix seconds.
|
|
80
|
+
* @property {string} [jti] Unique token id.
|
|
81
|
+
*/
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Supported JWT algorithms.
|
|
85
|
+
* @typedef {"HS256" | "HS384" | "HS512"} JWTAlgorithm
|
|
86
|
+
*/
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Options for `signJWT()`.
|
|
90
|
+
* @typedef {object} SignJWTOptions
|
|
91
|
+
* @property {JWTAlgorithm} [algorithm] Defaults to `"HS256"`.
|
|
92
|
+
* @property {string | number} [expiresIn] Lifetime in seconds or as a duration like `"15m"`. Sets `exp`.
|
|
93
|
+
* @property {string | number} [notBefore] Delay before the token is valid. Sets `nbf`.
|
|
94
|
+
* @property {string} [issuer] Sets `iss`.
|
|
95
|
+
* @property {string} [subject] Sets `sub`.
|
|
96
|
+
* @property {string | string[]} [audience] Sets `aud`.
|
|
97
|
+
* @property {string} [jwtId] Sets `jti`, handy for revocation lists.
|
|
98
|
+
* @property {boolean} [noTimestamp] Don't add `iat`.
|
|
99
|
+
* @property {Record<string, unknown>} [header] Extra header fields, like `{ kid: "2024-key" }`.
|
|
100
|
+
*/
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Options for `verifyJWT()`.
|
|
104
|
+
* @typedef {object} VerifyJWTOptions
|
|
105
|
+
* @property {JWTAlgorithm[]} [algorithms] Accepted algorithms. Defaults to all three HMAC ones.
|
|
106
|
+
* @property {string | string[]} [issuer] Required issuer.
|
|
107
|
+
* @property {string | string[]} [audience] Required audience; one match is enough.
|
|
108
|
+
* @property {string} [subject] Required subject.
|
|
109
|
+
* @property {number} [clockTolerance] Seconds of slack for `exp` and `nbf` when server clocks differ.
|
|
110
|
+
* @property {string | number} [maxAge] Reject tokens issued longer ago than this.
|
|
111
|
+
* @property {boolean} [ignoreExpiration] Skip the `exp` check.
|
|
112
|
+
* @property {Date | number} [now] What time it is. Defaults to now.
|
|
113
|
+
*/
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* A decoded JWT.
|
|
117
|
+
* @template [P=Record<string, unknown>]
|
|
118
|
+
* @typedef {object} DecodedJWT
|
|
119
|
+
* @property {{ alg: string, typ?: string, [key: string]: unknown }} header
|
|
120
|
+
* @property {P & JWTClaims} payload
|
|
121
|
+
* @property {string} signature Base64URL.
|
|
122
|
+
*/
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Options for `totp()` and `verifyTOTP()`.
|
|
126
|
+
* @typedef {object} TOTPOptions
|
|
127
|
+
* @property {number} [digits] Defaults to `6`.
|
|
128
|
+
* @property {number} [period] How long a code lasts, in seconds. Defaults to `30`.
|
|
129
|
+
* @property {"SHA1" | "SHA256" | "SHA512"} [algorithm] Defaults to `"SHA1"`, which is what authenticator apps expect.
|
|
130
|
+
* @property {Date | number} [time] Compute the code for this moment. Defaults to now.
|
|
131
|
+
*/
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Options for `verifyTOTP()`. `window` is how many periods before and after
|
|
135
|
+
* now are accepted (defaults to `1`).
|
|
136
|
+
* @typedef {TOTPOptions & { window?: number }} VerifyTOTPOptions
|
|
137
|
+
*/
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Options for `totpURI()`.
|
|
141
|
+
* @typedef {object} TOTPURIOptions
|
|
142
|
+
* @property {string} label The account shown in the app, usually the email.
|
|
143
|
+
* @property {string} [issuer] Your app's name.
|
|
144
|
+
* @property {number} [digits] Defaults to `6`.
|
|
145
|
+
* @property {number} [period] Defaults to `30`.
|
|
146
|
+
* @property {"SHA1" | "SHA256" | "SHA512"} [algorithm] Defaults to `"SHA1"`.
|
|
147
|
+
*/
|
|
148
|
+
|
|
149
|
+
const _BASE32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* @param {Buffer} buffer
|
|
153
|
+
* @returns {string}
|
|
154
|
+
*/
|
|
155
|
+
const _base32Encode = (buffer) => {
|
|
156
|
+
let bits = 0;
|
|
157
|
+
let value = 0;
|
|
158
|
+
let out = "";
|
|
159
|
+
for (const byte of buffer) {
|
|
160
|
+
value = (value << 8) | byte;
|
|
161
|
+
bits += 8;
|
|
162
|
+
while (bits >= 5) {
|
|
163
|
+
out += _BASE32[(value >>> (bits - 5)) & 31];
|
|
164
|
+
bits -= 5;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
if (bits > 0) out += _BASE32[(value << (5 - bits)) & 31];
|
|
168
|
+
return out;
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* @param {string} text
|
|
173
|
+
* @returns {Buffer}
|
|
174
|
+
*/
|
|
175
|
+
const _base32Decode = (text) => {
|
|
176
|
+
const clean = text.toUpperCase().replace(/[\s=-]/g, "");
|
|
177
|
+
let bits = 0;
|
|
178
|
+
let value = 0;
|
|
179
|
+
const out = [];
|
|
180
|
+
for (const char of clean) {
|
|
181
|
+
const index = _BASE32.indexOf(char);
|
|
182
|
+
if (index === -1) throw new TypeError(`Invalid Base32 character "${char}"`);
|
|
183
|
+
value = (value << 5) | index;
|
|
184
|
+
bits += 5;
|
|
185
|
+
if (bits >= 8) {
|
|
186
|
+
out.push((value >>> (bits - 8)) & 255);
|
|
187
|
+
bits -= 8;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return Buffer.from(out);
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
/** @param {unknown} data */
|
|
194
|
+
const _toBuffer = (data) => (Buffer.isBuffer(data) ? data : data instanceof Uint8Array ? Buffer.from(data) : Buffer.from(String(data), "utf8"));
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* @param {string | number} value
|
|
198
|
+
* @returns {number}
|
|
199
|
+
*/
|
|
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);
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Hashes data, SHA-256 in hex by default.
|
|
209
|
+
*
|
|
210
|
+
* @example
|
|
211
|
+
* crypto.hash("hello");
|
|
212
|
+
* crypto.hash("hello", { algorithm: "sha512", encoding: "base64url" });
|
|
213
|
+
*
|
|
214
|
+
* @param {string | Buffer | Uint8Array} data Strings are read as UTF-8.
|
|
215
|
+
* @param {HashOptions} [options]
|
|
216
|
+
* @returns {string}
|
|
217
|
+
*/
|
|
218
|
+
function hash(data, options = {}) {
|
|
219
|
+
return _crypto().createHash(options.algorithm ?? "sha256").update(_toBuffer(data)).digest(options.encoding ?? "hex");
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* An HMAC signature: a hash keyed with a secret. The usual way to sign and
|
|
224
|
+
* check webhooks.
|
|
225
|
+
*
|
|
226
|
+
* @example
|
|
227
|
+
* const signature = crypto.hmac(rawBody, process.env.WEBHOOK_SECRET);
|
|
228
|
+
* crypto.safeEqual(signature, req.headers["x-signature"]);
|
|
229
|
+
*
|
|
230
|
+
* @param {string | Buffer | Uint8Array} data
|
|
231
|
+
* @param {string | Buffer | Uint8Array} secret
|
|
232
|
+
* @param {HashOptions} [options]
|
|
233
|
+
* @returns {string}
|
|
234
|
+
*/
|
|
235
|
+
function hmac(data, secret, options = {}) {
|
|
236
|
+
return _crypto().createHmac(options.algorithm ?? "sha256", _toBuffer(secret)).update(_toBuffer(data)).digest(options.encoding ?? "hex");
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* 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.
|
|
243
|
+
*
|
|
244
|
+
* @example
|
|
245
|
+
* if (!crypto.safeEqual(providedKey, process.env.API_KEY)) throw new Error("Forbidden");
|
|
246
|
+
*
|
|
247
|
+
* @param {string | Buffer | Uint8Array} a
|
|
248
|
+
* @param {string | Buffer | Uint8Array} b
|
|
249
|
+
* @returns {boolean}
|
|
250
|
+
*/
|
|
251
|
+
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);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Hashes a password with scrypt and a random salt. The result records its
|
|
263
|
+
* own settings, so you can raise the cost later without breaking old
|
|
264
|
+
* hashes.
|
|
265
|
+
*
|
|
266
|
+
* @example
|
|
267
|
+
* const stored = await crypto.hashPassword(password);
|
|
268
|
+
* // "$scrypt$ln=15,r=8,p=1$4xY...$kq9...", store this
|
|
269
|
+
*
|
|
270
|
+
* @param {string} password
|
|
271
|
+
* @param {HashPasswordOptions} [options]
|
|
272
|
+
* @returns {Promise<string>}
|
|
273
|
+
*/
|
|
274
|
+
async function hashPassword(password, options = {}) {
|
|
275
|
+
const ln = options.cost ?? 15;
|
|
276
|
+
const r = options.blockSize ?? 8;
|
|
277
|
+
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 });
|
|
281
|
+
return `$scrypt$ln=${ln},r=${r},p=${p}$${salt.toString("base64url")}$${key.toString("base64url")}`;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Checks a password against a stored hash, in constant time. A wrong
|
|
286
|
+
* password or a broken hash gives `false`, never an error.
|
|
287
|
+
*
|
|
288
|
+
* @example
|
|
289
|
+
* if (!(await crypto.verifyPassword(input, user.passwordHash))) throw new Error("Invalid credentials");
|
|
290
|
+
*
|
|
291
|
+
* @param {string} password
|
|
292
|
+
* @param {string} stored
|
|
293
|
+
* @returns {Promise<boolean>}
|
|
294
|
+
*/
|
|
295
|
+
async function verifyPassword(password, stored) {
|
|
296
|
+
const match = /^\$scrypt\$ln=(\d+),r=(\d+),p=(\d+)\$([\w-]+)\$([\w-]+)$/.exec(String(stored));
|
|
297
|
+
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);
|
|
301
|
+
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
|
+
});
|
|
305
|
+
return _crypto().timingSafeEqual(actual, expected);
|
|
306
|
+
} catch {
|
|
307
|
+
return false;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Was this hash made with weaker settings than yours? If so, re-hash the
|
|
313
|
+
* password right after a successful login.
|
|
314
|
+
*
|
|
315
|
+
* @example
|
|
316
|
+
* if (await crypto.verifyPassword(input, user.hash) && crypto.needsRehash(user.hash, { cost: 16 })) {
|
|
317
|
+
* user.hash = await crypto.hashPassword(input, { cost: 16 });
|
|
318
|
+
* }
|
|
319
|
+
*
|
|
320
|
+
* @param {string} stored
|
|
321
|
+
* @param {HashPasswordOptions} [options] The settings you use today.
|
|
322
|
+
* @returns {boolean}
|
|
323
|
+
*/
|
|
324
|
+
function needsRehash(stored, options = {}) {
|
|
325
|
+
const match = /^\$scrypt\$ln=(\d+),r=(\d+),p=(\d+)\$/.exec(String(stored));
|
|
326
|
+
if (!match) return true;
|
|
327
|
+
return Number(match[1]) < (options.cost ?? 15) || Number(match[2]) < (options.blockSize ?? 8) || Number(match[3]) < (options.parallelization ?? 1);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* A random 256-bit key for `encrypt()`, as text you can put in an
|
|
332
|
+
* environment variable.
|
|
333
|
+
*
|
|
334
|
+
* @returns {string}
|
|
335
|
+
*/
|
|
336
|
+
function generateKey() {
|
|
337
|
+
return _crypto().randomBytes(32).toString("base64url");
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* @param {string | Buffer | Uint8Array} secret
|
|
342
|
+
* @param {Buffer} salt
|
|
343
|
+
* @returns {Buffer}
|
|
344
|
+
*/
|
|
345
|
+
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");
|
|
348
|
+
return Buffer.from(_crypto().hkdfSync("sha256", raw, salt, "node-comfort:aes-256-gcm", 32));
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Encrypts data with AES-256-GCM, which also detects tampering. Every call
|
|
353
|
+
* uses a fresh salt and nonce, so the same text never encrypts the same way
|
|
354
|
+
* twice.
|
|
355
|
+
*
|
|
356
|
+
* Use a key from `generateKey()` or another long random secret, not a
|
|
357
|
+
* password someone typed: that would be easy to brute-force.
|
|
358
|
+
*
|
|
359
|
+
* @example
|
|
360
|
+
* const box = crypto.encrypt(JSON.stringify(card), process.env.ENCRYPTION_KEY);
|
|
361
|
+
* const card = JSON.parse(crypto.decrypt(box, process.env.ENCRYPTION_KEY));
|
|
362
|
+
*
|
|
363
|
+
* @param {string | Buffer | Uint8Array} data
|
|
364
|
+
* @param {string | Buffer | Uint8Array} secret
|
|
365
|
+
* @param {EncryptOptions} [options]
|
|
366
|
+
* @returns {string} A compact, URL-safe string.
|
|
367
|
+
*/
|
|
368
|
+
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(".");
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Decrypts what `encrypt()` produced. Throws if the secret is wrong or the
|
|
379
|
+
* data was changed.
|
|
380
|
+
*
|
|
381
|
+
* @example
|
|
382
|
+
* const text = crypto.decrypt(box, SECRET);
|
|
383
|
+
* const bytes = crypto.decrypt(box, SECRET, { output: "buffer" });
|
|
384
|
+
*
|
|
385
|
+
* @overload
|
|
386
|
+
* @param {string} payload
|
|
387
|
+
* @param {string | Buffer | Uint8Array} secret
|
|
388
|
+
* @param {DecryptOptions & { output: "buffer" }} options
|
|
389
|
+
* @returns {Buffer}
|
|
390
|
+
*/
|
|
391
|
+
/**
|
|
392
|
+
* Decrypts what `encrypt()` produced, as text.
|
|
393
|
+
*
|
|
394
|
+
* @overload
|
|
395
|
+
* @param {string} payload
|
|
396
|
+
* @param {string | Buffer | Uint8Array} secret
|
|
397
|
+
* @param {DecryptOptions} [options]
|
|
398
|
+
* @returns {string}
|
|
399
|
+
*/
|
|
400
|
+
/**
|
|
401
|
+
* @param {string} payload
|
|
402
|
+
* @param {string | Buffer | Uint8Array} secret
|
|
403
|
+
* @param {DecryptOptions} [options]
|
|
404
|
+
* @returns {string | Buffer}
|
|
405
|
+
* @throws {Error} If the secret is wrong or the payload was modified.
|
|
406
|
+
*/
|
|
407
|
+
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");
|
|
410
|
+
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));
|
|
414
|
+
let decrypted;
|
|
415
|
+
try {
|
|
416
|
+
decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]);
|
|
417
|
+
} catch (error) {
|
|
418
|
+
throw new Error("Unable to decrypt: wrong secret or tampered data", { cause: error });
|
|
419
|
+
}
|
|
420
|
+
return options.output === "buffer" ? decrypted : decrypted.toString("utf8");
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Creates a signed JSON Web Token. `iat` is added for you.
|
|
425
|
+
*
|
|
426
|
+
* @example
|
|
427
|
+
* const token = crypto.signJWT({ sub: String(user.id), role: user.role }, process.env.JWT_SECRET, {
|
|
428
|
+
* expiresIn: "15m",
|
|
429
|
+
* issuer: "api.example.com",
|
|
430
|
+
* });
|
|
431
|
+
*
|
|
432
|
+
* @param {Record<string, unknown>} payload
|
|
433
|
+
* @param {string | Buffer} secret At least 32 random bytes.
|
|
434
|
+
* @param {SignJWTOptions} [options]
|
|
435
|
+
* @returns {string}
|
|
436
|
+
*/
|
|
437
|
+
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" });
|
|
440
|
+
const now = Math.floor(Date.now() / 1000);
|
|
441
|
+
/** @type {Record<string, unknown>} */
|
|
442
|
+
const claims = { ...payload };
|
|
443
|
+
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);
|
|
446
|
+
if (options.issuer !== undefined) claims.iss = options.issuer;
|
|
447
|
+
if (options.subject !== undefined) claims.sub = options.subject;
|
|
448
|
+
if (options.audience !== undefined) claims.aud = options.audience;
|
|
449
|
+
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" })}`;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Reads a JWT without checking it. Fine for display; call `verifyJWT()`
|
|
457
|
+
* before trusting anything in it.
|
|
458
|
+
*
|
|
459
|
+
* @example
|
|
460
|
+
* crypto.decodeJWT(token).payload.exp;
|
|
461
|
+
*
|
|
462
|
+
* @template [P=Record<string, unknown>]
|
|
463
|
+
* @param {string} token
|
|
464
|
+
* @returns {DecodedJWT<P>}
|
|
465
|
+
* @throws {JWTError} With code `ERR_JWT_MALFORMED` if the token can't be read.
|
|
466
|
+
*/
|
|
467
|
+
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
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Verifies a JWT: algorithm, signature, expiry, and whichever claims you
|
|
482
|
+
* require. Returns the payload, or throws a `JWTError` whose `code` says
|
|
483
|
+
* what's wrong.
|
|
484
|
+
*
|
|
485
|
+
* @example
|
|
486
|
+
* try {
|
|
487
|
+
* const { sub } = crypto.verifyJWT(token, process.env.JWT_SECRET, { issuer: "api.example.com" });
|
|
488
|
+
* } catch (error) {
|
|
489
|
+
* res.status(401).json({ error: error.code }); // "ERR_JWT_EXPIRED"...
|
|
490
|
+
* }
|
|
491
|
+
*
|
|
492
|
+
* @template [P=Record<string, unknown>]
|
|
493
|
+
* @param {string} token
|
|
494
|
+
* @param {string | Buffer} secret
|
|
495
|
+
* @param {VerifyJWTOptions} [options]
|
|
496
|
+
* @returns {P & JWTClaims}
|
|
497
|
+
* @throws {JWTError}
|
|
498
|
+
*/
|
|
499
|
+
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))) {
|
|
503
|
+
throw new JWTError(`Algorithm "${String(header.alg)}" is not allowed`, { code: "ERR_JWT_ALGORITHM" });
|
|
504
|
+
}
|
|
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" });
|
|
508
|
+
|
|
509
|
+
const now = Math.floor((options.now instanceof Date ? options.now.getTime() : options.now ?? Date.now()) / 1000);
|
|
510
|
+
const tolerance = options.clockTolerance ?? 0;
|
|
511
|
+
const claims = /** @type {JWTClaims & Record<string, unknown>} */ (payload);
|
|
512
|
+
if (!options.ignoreExpiration && typeof claims.exp === "number" && now - tolerance >= claims.exp) {
|
|
513
|
+
throw new JWTError("Token expired", { code: "ERR_JWT_EXPIRED", expiredAt: new Date(claims.exp * 1000) });
|
|
514
|
+
}
|
|
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))) {
|
|
517
|
+
throw new JWTError("Token is too old", { code: "ERR_JWT_EXPIRED" });
|
|
518
|
+
}
|
|
519
|
+
if (options.issuer !== undefined && ![options.issuer].flat().includes(/** @type {string} */ (claims.iss))) {
|
|
520
|
+
throw new JWTError("Invalid issuer", { code: "ERR_JWT_CLAIM" });
|
|
521
|
+
}
|
|
522
|
+
if (options.subject !== undefined && claims.sub !== options.subject) throw new JWTError("Invalid subject", { code: "ERR_JWT_CLAIM" });
|
|
523
|
+
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" });
|
|
527
|
+
}
|
|
528
|
+
return /** @type {P & JWTClaims} */ (payload);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* A new two-factor secret, in the Base32 format authenticator apps expect.
|
|
533
|
+
* Store it encrypted.
|
|
534
|
+
*
|
|
535
|
+
* @example
|
|
536
|
+
* user.totpSecret = crypto.totpSecret();
|
|
537
|
+
*
|
|
538
|
+
* @param {number} [bytes=20]
|
|
539
|
+
* @returns {string}
|
|
540
|
+
*/
|
|
541
|
+
function totpSecret(bytes = 20) {
|
|
542
|
+
return _base32Encode(_crypto().randomBytes(bytes));
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* The current 6-digit code for a two-factor secret, as shown by
|
|
547
|
+
* authenticator apps.
|
|
548
|
+
*
|
|
549
|
+
* @example
|
|
550
|
+
* crypto.totp(user.totpSecret); // "492039"
|
|
551
|
+
*
|
|
552
|
+
* @param {string} secret
|
|
553
|
+
* @param {TOTPOptions} [options]
|
|
554
|
+
* @returns {string}
|
|
555
|
+
*/
|
|
556
|
+
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");
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* Checks a code typed by a user. Codes from the neighbouring periods are
|
|
571
|
+
* accepted too, to cope with clock drift.
|
|
572
|
+
*
|
|
573
|
+
* @example
|
|
574
|
+
* if (!crypto.verifyTOTP(req.body.code, user.totpSecret)) throw new Error("Invalid code");
|
|
575
|
+
*
|
|
576
|
+
* @param {string} token Spaces are ignored.
|
|
577
|
+
* @param {string} secret
|
|
578
|
+
* @param {VerifyTOTPOptions} [options]
|
|
579
|
+
* @returns {boolean}
|
|
580
|
+
*/
|
|
581
|
+
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;
|
|
588
|
+
let valid = false;
|
|
589
|
+
for (let i = -window; i <= window; i++) {
|
|
590
|
+
if (safeEqual(totp(secret, { ...options, time: time + i * period * 1000 }), code)) valid = true;
|
|
591
|
+
}
|
|
592
|
+
return valid;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* The `otpauth://` link to show as a QR code when a user turns on 2FA.
|
|
597
|
+
*
|
|
598
|
+
* @example
|
|
599
|
+
* const uri = crypto.totpURI(user.totpSecret, { label: user.email, issuer: "My App" });
|
|
600
|
+
*
|
|
601
|
+
* @param {string} secret
|
|
602
|
+
* @param {TOTPURIOptions} options
|
|
603
|
+
* @returns {string}
|
|
604
|
+
*/
|
|
605
|
+
function totpURI(secret, options) {
|
|
606
|
+
const label = options.issuer ? `${encodeURIComponent(options.issuer)}:${encodeURIComponent(options.label)}` : encodeURIComponent(options.label);
|
|
607
|
+
const params = new URLSearchParams({ secret: secret.replace(/\s/g, "").toUpperCase() });
|
|
608
|
+
if (options.issuer) params.set("issuer", options.issuer);
|
|
609
|
+
params.set("algorithm", options.algorithm ?? "SHA1");
|
|
610
|
+
params.set("digits", String(options.digits ?? 6));
|
|
611
|
+
params.set("period", String(options.period ?? 30));
|
|
612
|
+
return `otpauth://totp/${label}?${params.toString().replace(/\+/g, "%20")}`;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/**
|
|
616
|
+
* Secure random bytes.
|
|
617
|
+
*
|
|
618
|
+
* @param {number} size
|
|
619
|
+
* @returns {Buffer}
|
|
620
|
+
*/
|
|
621
|
+
function randomBytes(size) {
|
|
622
|
+
return _crypto().randomBytes(size);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* A secure random integer from `min` to `max`, both included, with no bias.
|
|
627
|
+
* For anything that has to be fair or unpredictable.
|
|
628
|
+
*
|
|
629
|
+
* @example
|
|
630
|
+
* crypto.randomInt(1, 6); // a fair dice roll
|
|
631
|
+
* crypto.randomInt(0, 999_999); // a 6-digit code
|
|
632
|
+
*
|
|
633
|
+
* @param {number} min
|
|
634
|
+
* @param {number} max
|
|
635
|
+
* @returns {number}
|
|
636
|
+
*/
|
|
637
|
+
function randomInt(min, max) {
|
|
638
|
+
return _crypto().randomInt(Math.ceil(min), Math.floor(max) + 1);
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* Encodes to Base64, or URL-safe Base64.
|
|
643
|
+
*
|
|
644
|
+
* @example
|
|
645
|
+
* crypto.toBase64("héllo"); // "aMOpbGxv"
|
|
646
|
+
* crypto.toBase64("héllo", { urlSafe: true });
|
|
647
|
+
*
|
|
648
|
+
* @param {string | Buffer | Uint8Array} data
|
|
649
|
+
* @param {{ urlSafe?: boolean }} [options]
|
|
650
|
+
* @returns {string}
|
|
651
|
+
*/
|
|
652
|
+
function toBase64(data, options = {}) {
|
|
653
|
+
return _toBuffer(data).toString(options.urlSafe ? "base64url" : "base64");
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/**
|
|
657
|
+
* Decodes Base64 or URL-safe Base64.
|
|
658
|
+
*
|
|
659
|
+
* @example
|
|
660
|
+
* crypto.fromBase64("aMOpbGxv"); // "héllo"
|
|
661
|
+
* crypto.fromBase64("aMOpbGxv", { output: "buffer" }); // <Buffer 68 c3 a9 6c 6c 6f>
|
|
662
|
+
*
|
|
663
|
+
* @overload
|
|
664
|
+
* @param {string} text
|
|
665
|
+
* @param {{ output: "buffer" }} options
|
|
666
|
+
* @returns {Buffer}
|
|
667
|
+
*/
|
|
668
|
+
/**
|
|
669
|
+
* Decodes Base64 or URL-safe Base64 into text.
|
|
670
|
+
*
|
|
671
|
+
* @overload
|
|
672
|
+
* @param {string} text
|
|
673
|
+
* @param {{ output?: "utf8" }} [options]
|
|
674
|
+
* @returns {string}
|
|
675
|
+
*/
|
|
676
|
+
/**
|
|
677
|
+
* @param {string} text
|
|
678
|
+
* @param {{ output?: "utf8" | "buffer" }} [options]
|
|
679
|
+
* @returns {string | Buffer}
|
|
680
|
+
*/
|
|
681
|
+
function fromBase64(text, options = {}) {
|
|
682
|
+
const buffer = Buffer.from(String(text).replace(/-/g, "+").replace(/_/g, "/"), "base64");
|
|
683
|
+
return options.output === "buffer" ? buffer : buffer.toString("utf8");
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
module.exports = {
|
|
687
|
+
hash,
|
|
688
|
+
hmac,
|
|
689
|
+
safeEqual,
|
|
690
|
+
hashPassword,
|
|
691
|
+
verifyPassword,
|
|
692
|
+
needsRehash,
|
|
693
|
+
generateKey,
|
|
694
|
+
encrypt,
|
|
695
|
+
decrypt,
|
|
696
|
+
signJWT,
|
|
697
|
+
decodeJWT,
|
|
698
|
+
verifyJWT,
|
|
699
|
+
totpSecret,
|
|
700
|
+
totp,
|
|
701
|
+
verifyTOTP,
|
|
702
|
+
totpURI,
|
|
703
|
+
randomBytes,
|
|
704
|
+
randomInt,
|
|
705
|
+
toBase64,
|
|
706
|
+
fromBase64,
|
|
707
|
+
};
|