@rezwanul7/secret-vault 0.0.0 → 0.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/dist/index.cjs ADDED
@@ -0,0 +1,415 @@
1
+ 'use strict';
2
+
3
+ // src/encoding.ts
4
+ var encoder = new TextEncoder();
5
+ var decoder = new TextDecoder("utf-8", { fatal: true });
6
+ function utf8Encode(text) {
7
+ return encoder.encode(text);
8
+ }
9
+ function utf8Decode(bytes) {
10
+ return decoder.decode(bytes);
11
+ }
12
+ function base64urlEncode(bytes) {
13
+ let binary = "";
14
+ for (const byte of bytes) binary += String.fromCharCode(byte);
15
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
16
+ }
17
+ function base64urlDecode(text) {
18
+ if (!/^[A-Za-z0-9_-]*$/.test(text) || text.length % 4 === 1) return void 0;
19
+ const binary = atob(text.replace(/-/g, "+").replace(/_/g, "/"));
20
+ const bytes = new Uint8Array(binary.length);
21
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
22
+ return base64urlEncode(bytes) === text ? bytes : void 0;
23
+ }
24
+
25
+ // src/errors.ts
26
+ var VaultError = class extends Error {
27
+ /** A stable, machine-readable code such as `VAULT_CONFIG_INVALID`. */
28
+ code;
29
+ /**
30
+ * @param message - The error message.
31
+ * @param code - The stable error code.
32
+ * @param options - The underlying `cause`, if any.
33
+ */
34
+ constructor(message, code, options) {
35
+ super(message, options);
36
+ this.name = "VaultError";
37
+ this.code = code;
38
+ }
39
+ };
40
+ var VaultConfigError = class extends VaultError {
41
+ /**
42
+ * @param message - What is wrong, naming the option but never its value.
43
+ * @param options - The underlying `cause`, if any.
44
+ */
45
+ constructor(message, options) {
46
+ super(message, "VAULT_CONFIG_INVALID", options);
47
+ this.name = "VaultConfigError";
48
+ }
49
+ };
50
+ var VaultDecryptError = class extends VaultError {
51
+ /** Why the Sealed value couldn't be opened. */
52
+ reason;
53
+ /**
54
+ * The Context the Sealed value was opened under, or `undefined` when the
55
+ * method takes no Context (`needsRotation`).
56
+ */
57
+ context;
58
+ /**
59
+ * @param reason - Why the Sealed value couldn't be opened.
60
+ * @param context - The Context it was opened under, or `undefined` when
61
+ * there is none.
62
+ * @param options - The underlying `cause`, if any.
63
+ */
64
+ constructor(reason, context, options) {
65
+ super(
66
+ context === void 0 ? `Could not read Sealed value: ${reason}` : `Could not open Sealed value (context "${context}"): ${reason}`,
67
+ `VAULT_DECRYPT_${reason.toUpperCase()}`,
68
+ options
69
+ );
70
+ this.name = "VaultDecryptError";
71
+ this.reason = reason;
72
+ this.context = context;
73
+ }
74
+ };
75
+
76
+ // src/format.ts
77
+ var PREFIX = "vault";
78
+ var FORMAT_VERSION = "v1";
79
+ var KINDS = ["s", "j", "b"];
80
+ function looksSealed(value) {
81
+ return typeof value === "string" && value.startsWith(`${PREFIX}:`);
82
+ }
83
+ function formatSealed(parts) {
84
+ return [
85
+ PREFIX,
86
+ FORMAT_VERSION,
87
+ parts.keyId,
88
+ parts.kind,
89
+ base64urlEncode(parts.iv),
90
+ base64urlEncode(parts.ciphertext)
91
+ ].join(":");
92
+ }
93
+ function parseSealed(value, context) {
94
+ if (!looksSealed(value)) throw new VaultDecryptError("not_sealed", context);
95
+ const parts = value.split(":");
96
+ const [, version, keyId, kind, iv, ciphertext] = parts;
97
+ if (parts.length !== 6 || !/^v[0-9]+$/.test(version)) {
98
+ throw new VaultDecryptError("not_sealed", context);
99
+ }
100
+ if (version !== FORMAT_VERSION) {
101
+ throw new VaultDecryptError("unsupported_version", context);
102
+ }
103
+ const ivBytes = base64urlDecode(iv);
104
+ const ciphertextBytes = base64urlDecode(ciphertext);
105
+ if (!/^[0-9a-f]{8}$/.test(keyId) || !isSealedKind(kind) || !ivBytes || !ciphertextBytes) {
106
+ throw new VaultDecryptError("not_sealed", context);
107
+ }
108
+ return { keyId, kind, iv: ivBytes, ciphertext: ciphertextBytes };
109
+ }
110
+ function isSealedKind(kind) {
111
+ return KINDS.includes(kind);
112
+ }
113
+ function additionalData(kind, context) {
114
+ return utf8Encode(`${FORMAT_VERSION}:${kind}:${context}`);
115
+ }
116
+
117
+ // src/keys.ts
118
+ var SALT = utf8Encode("secret-vault/v1");
119
+ var KEY_ID_BYTES = 4;
120
+ async function deriveVaultKey(secret, purpose) {
121
+ const material = await crypto.subtle.importKey(
122
+ "raw",
123
+ utf8Encode(secret),
124
+ "HKDF",
125
+ false,
126
+ ["deriveKey", "deriveBits"]
127
+ );
128
+ const hkdf = (info) => ({
129
+ name: "HKDF",
130
+ hash: "SHA-256",
131
+ salt: SALT,
132
+ info: utf8Encode(info)
133
+ });
134
+ const [key, idBits] = await Promise.all([
135
+ crypto.subtle.deriveKey(
136
+ hkdf(purpose),
137
+ material,
138
+ { name: "AES-GCM", length: 256 },
139
+ false,
140
+ ["encrypt", "decrypt"]
141
+ ),
142
+ crypto.subtle.deriveBits(
143
+ hkdf(`${purpose}:key-id`),
144
+ material,
145
+ KEY_ID_BYTES * 8
146
+ )
147
+ ]);
148
+ const id = Array.from(
149
+ new Uint8Array(idBits),
150
+ (byte) => byte.toString(16).padStart(2, "0")
151
+ ).join("");
152
+ return { id, key };
153
+ }
154
+ var KeyRing = class {
155
+ #secrets;
156
+ #purpose;
157
+ #loaded;
158
+ /**
159
+ * @param secrets - The current Vault secret first, then any previous ones.
160
+ * @param purpose - The Vault's Purpose.
161
+ */
162
+ constructor(secrets, purpose) {
163
+ this.#secrets = secrets;
164
+ this.#purpose = purpose;
165
+ }
166
+ /** Derives the keys, or returns the cached ones. */
167
+ load() {
168
+ this.#loaded ??= this.#derive().catch((error) => {
169
+ this.#loaded = void 0;
170
+ throw error;
171
+ });
172
+ return this.#loaded;
173
+ }
174
+ async #derive() {
175
+ const keys = await Promise.all(
176
+ this.#secrets.map((secret) => deriveVaultKey(secret, this.#purpose))
177
+ );
178
+ const byId = /* @__PURE__ */ new Map();
179
+ for (const key of keys) {
180
+ if (byId.has(key.id)) {
181
+ throw new VaultConfigError(
182
+ "Vault options `secret` and `previousSecrets` contain two Vault secrets with the same key id; remove the duplicate."
183
+ );
184
+ }
185
+ byId.set(key.id, key);
186
+ }
187
+ return { current: keys[0], byId };
188
+ }
189
+ };
190
+
191
+ // src/random.ts
192
+ function randomBytes(length) {
193
+ return crypto.getRandomValues(new Uint8Array(length));
194
+ }
195
+
196
+ // src/vault.ts
197
+ var MIN_SECRET_LENGTH = 32;
198
+ var IV_BYTES = 12;
199
+ var Vault = class {
200
+ #keys;
201
+ /**
202
+ * Checks the options right away; keys are derived on first use, so this
203
+ * needs no `await`.
204
+ *
205
+ * @param options - The Vault secrets and Purpose.
206
+ * @throws {@link VaultConfigError} when an option breaks a rule in
207
+ * {@link VaultOptions}, except a duplicated secret, which is reported on
208
+ * first use. The message never contains a secret.
209
+ */
210
+ constructor(options) {
211
+ assertSecret(options.secret, "secret");
212
+ const previousSecrets = options.previousSecrets ?? [];
213
+ if (!Array.isArray(previousSecrets)) {
214
+ throw new VaultConfigError(
215
+ "Vault option `previousSecrets` must be an array of strings."
216
+ );
217
+ }
218
+ previousSecrets.forEach(
219
+ (secret, index) => assertSecret(secret, `previousSecrets[${index}]`)
220
+ );
221
+ if (typeof options.purpose !== "string" || options.purpose === "") {
222
+ throw new VaultConfigError("Vault option `purpose` must not be empty.");
223
+ }
224
+ this.#keys = new KeyRing(
225
+ [options.secret, ...previousSecrets],
226
+ options.purpose
227
+ );
228
+ }
229
+ /**
230
+ * True when `value` is a Sealed value: a string starting with `vault:`. Use
231
+ * it to tell Sealed values from plain text still stored in a column.
232
+ *
233
+ * @param value - Any value.
234
+ */
235
+ static isSealed(value) {
236
+ return looksSealed(value);
237
+ }
238
+ /**
239
+ * Seals a string.
240
+ *
241
+ * @param plaintext - The string to seal. May be empty.
242
+ * @param context - Where the Sealed value is stored.
243
+ * @returns The Sealed value, `vault:v1:<keyId>:s:<iv>:<ciphertext>`.
244
+ */
245
+ encrypt(plaintext, context) {
246
+ return this.#seal("s", utf8Encode(plaintext), context);
247
+ }
248
+ /**
249
+ * Opens a Sealed value made by {@link Vault.encrypt}.
250
+ *
251
+ * @param sealed - The Sealed value.
252
+ * @param context - The Context it was sealed under.
253
+ * @returns The Plaintext string.
254
+ * @throws {@link VaultDecryptError} when the value isn't a Sealed value, was
255
+ * sealed with an unknown Vault secret or another Purpose, was edited, or is
256
+ * opened under another Context, or `wrong_kind` when it isn't a string
257
+ * Sealed value. A string Sealed value that opens to bytes that aren't valid
258
+ * UTF-8 also rejects as `authentication_failed`, with the decoder's
259
+ * `TypeError` as its `cause`.
260
+ */
261
+ async decrypt(sealed, context) {
262
+ const plaintext = await this.#openKind("s", sealed, context);
263
+ try {
264
+ return utf8Decode(plaintext);
265
+ } catch (cause) {
266
+ throw new VaultDecryptError("authentication_failed", context, { cause });
267
+ }
268
+ }
269
+ /**
270
+ * Seals JSON data with plain `JSON.stringify`.
271
+ *
272
+ * @param value - Any value `JSON.stringify` can encode.
273
+ * @param context - Where the Sealed value is stored.
274
+ * @returns The Sealed value, `vault:v1:<keyId>:j:<iv>:<ciphertext>`.
275
+ * @throws The promise rejects with a `TypeError` when `JSON.stringify`
276
+ * can't encode `value`, such as `undefined`, a function, a `BigInt` or a
277
+ * cyclic object, before anything is encrypted. It never throws
278
+ * synchronously.
279
+ */
280
+ async encryptJson(value, context) {
281
+ const json = JSON.stringify(value);
282
+ if (typeof json !== "string") {
283
+ throw new TypeError("Vault.encryptJson: value can't be encoded as JSON.");
284
+ }
285
+ return this.#seal("j", utf8Encode(json), context);
286
+ }
287
+ /**
288
+ * Opens a Sealed value made by {@link Vault.encryptJson}.
289
+ *
290
+ * @typeParam T - The type the caller expects. It is asserted, not validated.
291
+ * @param sealed - The Sealed value.
292
+ * @param context - The Context it was sealed under.
293
+ * @returns The JSON data, parsed with plain `JSON.parse`.
294
+ * @throws {@link VaultDecryptError} for the same reasons as
295
+ * {@link Vault.decrypt}, `wrong_kind` when it isn't a JSON Sealed value, or
296
+ * `invalid_json` when it opens to text that isn't JSON.
297
+ */
298
+ async decryptJson(sealed, context) {
299
+ const plaintext = await this.#openKind("j", sealed, context);
300
+ try {
301
+ return JSON.parse(utf8Decode(plaintext));
302
+ } catch (cause) {
303
+ throw new VaultDecryptError("invalid_json", context, { cause });
304
+ }
305
+ }
306
+ /**
307
+ * Seals bytes.
308
+ *
309
+ * @param plaintext - The bytes to seal. May be empty.
310
+ * @param context - Where the Sealed value is stored.
311
+ * @returns The Sealed value, `vault:v1:<keyId>:b:<iv>:<ciphertext>`.
312
+ */
313
+ encryptBytes(plaintext, context) {
314
+ return this.#seal("b", Uint8Array.from(plaintext), context);
315
+ }
316
+ /**
317
+ * Opens a Sealed value made by {@link Vault.encryptBytes}.
318
+ *
319
+ * @param sealed - The Sealed value.
320
+ * @param context - The Context it was sealed under.
321
+ * @returns The Plaintext bytes, as a plain `Uint8Array`.
322
+ * @throws {@link VaultDecryptError} for the same reasons as
323
+ * {@link Vault.decrypt}, or `wrong_kind` when it isn't a bytes Sealed value.
324
+ */
325
+ decryptBytes(sealed, context) {
326
+ return this.#openKind("b", sealed, context);
327
+ }
328
+ /** Opens a Sealed value, then checks it holds the `expected` kind. */
329
+ async #openKind(expected, sealed, context) {
330
+ const { kind, plaintext } = await this.#open(sealed, context);
331
+ if (kind !== expected) throw new VaultDecryptError("wrong_kind", context);
332
+ return plaintext;
333
+ }
334
+ /**
335
+ * True when a Sealed value was made with a previous Vault secret, so it
336
+ * should be re-sealed with {@link Vault.reseal} before that secret is
337
+ * dropped. Reads only the key id; it doesn't open the value, so it needs no
338
+ * Context. It is async, unlike {@link Vault.isSealed}, because the current
339
+ * key id is derived on first use: `await` it.
340
+ *
341
+ * @param sealed - The Sealed value.
342
+ * @returns A promise of `true` when the key id isn't the current one.
343
+ * @throws The promise rejects with {@link VaultDecryptError} `not_sealed`
344
+ * (or `unsupported_version`) when the value isn't a Sealed value this
345
+ * package can read; its `context` is `undefined`. It never throws
346
+ * synchronously.
347
+ */
348
+ async needsRotation(sealed) {
349
+ const { keyId } = parseSealed(sealed, void 0);
350
+ const { current } = await this.#keys.load();
351
+ return keyId !== current.id;
352
+ }
353
+ /**
354
+ * Opens a Sealed value with whichever Vault secret made it and seals it
355
+ * again with the current one, keeping its kind (string, JSON or bytes). Use
356
+ * it on values where {@link Vault.needsRotation} is true.
357
+ *
358
+ * @param sealed - The Sealed value.
359
+ * @param context - The Context it was sealed under; the new Sealed value
360
+ * is bound to the same Context.
361
+ * @returns A new Sealed value with the current key id.
362
+ * @throws {@link VaultDecryptError} for the same reasons as
363
+ * {@link Vault.decrypt}.
364
+ */
365
+ async reseal(sealed, context) {
366
+ const { kind, plaintext } = await this.#open(sealed, context);
367
+ return this.#seal(kind, plaintext, context);
368
+ }
369
+ async #seal(kind, plaintext, context) {
370
+ const { current } = await this.#keys.load();
371
+ const iv = randomBytes(IV_BYTES);
372
+ const ciphertext = await crypto.subtle.encrypt(
373
+ aesGcmParams(iv, kind, context),
374
+ current.key,
375
+ plaintext
376
+ );
377
+ return formatSealed({
378
+ keyId: current.id,
379
+ kind,
380
+ iv,
381
+ ciphertext: new Uint8Array(ciphertext)
382
+ });
383
+ }
384
+ async #open(sealed, context) {
385
+ const { keyId, kind, iv, ciphertext } = parseSealed(sealed, context);
386
+ const { byId } = await this.#keys.load();
387
+ const vaultKey = byId.get(keyId);
388
+ if (!vaultKey) throw new VaultDecryptError("unknown_key", context);
389
+ try {
390
+ const plaintext = await crypto.subtle.decrypt(
391
+ aesGcmParams(iv, kind, context),
392
+ vaultKey.key,
393
+ ciphertext
394
+ );
395
+ return { kind, plaintext: new Uint8Array(plaintext) };
396
+ } catch (cause) {
397
+ throw new VaultDecryptError("authentication_failed", context, { cause });
398
+ }
399
+ }
400
+ };
401
+ function aesGcmParams(iv, kind, context) {
402
+ return { name: "AES-GCM", iv, additionalData: additionalData(kind, context) };
403
+ }
404
+ function assertSecret(secret, option) {
405
+ if (typeof secret !== "string" || secret.length < MIN_SECRET_LENGTH) {
406
+ throw new VaultConfigError(
407
+ `Vault option \`${option}\` must be a string of at least ${MIN_SECRET_LENGTH} characters.`
408
+ );
409
+ }
410
+ }
411
+
412
+ exports.Vault = Vault;
413
+ exports.VaultConfigError = VaultConfigError;
414
+ exports.VaultDecryptError = VaultDecryptError;
415
+ exports.VaultError = VaultError;
@@ -0,0 +1,222 @@
1
+ /** Options for building a {@link Vault}. */
2
+ interface VaultOptions {
3
+ /**
4
+ * The current Vault secret, which seals every new value. At least 32
5
+ * characters of high-entropy key material, such as the output of
6
+ * `openssl rand -base64 32`.
7
+ */
8
+ secret: string;
9
+ /**
10
+ * Earlier Vault secrets, still accepted when opening Sealed values but never
11
+ * used to seal new ones. During a Rotation, move the old `secret` here until
12
+ * every value sealed with it has been re-sealed with {@link Vault.reseal}.
13
+ * Each must be at least 32 characters. A secret listed twice (here or
14
+ * alongside `secret`) is a {@link VaultConfigError}, reported by the first
15
+ * call that needs keys rather than by the constructor.
16
+ */
17
+ previousSecrets?: readonly string[];
18
+ /**
19
+ * The name of this Vault, such as `myapp:settings`. Vaults with the same
20
+ * Vault secret but different Purposes can't read each other's Sealed
21
+ * values. It must never change once values are stored.
22
+ */
23
+ purpose: string;
24
+ }
25
+ /**
26
+ * Turns Plaintexts into Sealed values and back, with AES-256-GCM on Web
27
+ * Crypto. Build one per Purpose, at module level, and reuse it.
28
+ *
29
+ * Every method that seals or opens takes a Context: where the Sealed value is
30
+ * stored, such as `settings:smtp_pass`. A Sealed value only opens under the
31
+ * Context it was sealed with. `""` is allowed and means no binding.
32
+ *
33
+ * @example
34
+ * const vault = new Vault({ secret: process.env.VAULT_SECRET!, purpose: "myapp:settings" });
35
+ * const sealed = await vault.encrypt("hunter2", "settings:smtp_pass");
36
+ * await vault.decrypt(sealed, "settings:smtp_pass"); // "hunter2"
37
+ */
38
+ declare class Vault {
39
+ #private;
40
+ /**
41
+ * Checks the options right away; keys are derived on first use, so this
42
+ * needs no `await`.
43
+ *
44
+ * @param options - The Vault secrets and Purpose.
45
+ * @throws {@link VaultConfigError} when an option breaks a rule in
46
+ * {@link VaultOptions}, except a duplicated secret, which is reported on
47
+ * first use. The message never contains a secret.
48
+ */
49
+ constructor(options: VaultOptions);
50
+ /**
51
+ * True when `value` is a Sealed value: a string starting with `vault:`. Use
52
+ * it to tell Sealed values from plain text still stored in a column.
53
+ *
54
+ * @param value - Any value.
55
+ */
56
+ static isSealed(value: unknown): value is string;
57
+ /**
58
+ * Seals a string.
59
+ *
60
+ * @param plaintext - The string to seal. May be empty.
61
+ * @param context - Where the Sealed value is stored.
62
+ * @returns The Sealed value, `vault:v1:<keyId>:s:<iv>:<ciphertext>`.
63
+ */
64
+ encrypt(plaintext: string, context: string): Promise<string>;
65
+ /**
66
+ * Opens a Sealed value made by {@link Vault.encrypt}.
67
+ *
68
+ * @param sealed - The Sealed value.
69
+ * @param context - The Context it was sealed under.
70
+ * @returns The Plaintext string.
71
+ * @throws {@link VaultDecryptError} when the value isn't a Sealed value, was
72
+ * sealed with an unknown Vault secret or another Purpose, was edited, or is
73
+ * opened under another Context, or `wrong_kind` when it isn't a string
74
+ * Sealed value. A string Sealed value that opens to bytes that aren't valid
75
+ * UTF-8 also rejects as `authentication_failed`, with the decoder's
76
+ * `TypeError` as its `cause`.
77
+ */
78
+ decrypt(sealed: string, context: string): Promise<string>;
79
+ /**
80
+ * Seals JSON data with plain `JSON.stringify`.
81
+ *
82
+ * @param value - Any value `JSON.stringify` can encode.
83
+ * @param context - Where the Sealed value is stored.
84
+ * @returns The Sealed value, `vault:v1:<keyId>:j:<iv>:<ciphertext>`.
85
+ * @throws The promise rejects with a `TypeError` when `JSON.stringify`
86
+ * can't encode `value`, such as `undefined`, a function, a `BigInt` or a
87
+ * cyclic object, before anything is encrypted. It never throws
88
+ * synchronously.
89
+ */
90
+ encryptJson(value: unknown, context: string): Promise<string>;
91
+ /**
92
+ * Opens a Sealed value made by {@link Vault.encryptJson}.
93
+ *
94
+ * @typeParam T - The type the caller expects. It is asserted, not validated.
95
+ * @param sealed - The Sealed value.
96
+ * @param context - The Context it was sealed under.
97
+ * @returns The JSON data, parsed with plain `JSON.parse`.
98
+ * @throws {@link VaultDecryptError} for the same reasons as
99
+ * {@link Vault.decrypt}, `wrong_kind` when it isn't a JSON Sealed value, or
100
+ * `invalid_json` when it opens to text that isn't JSON.
101
+ */
102
+ decryptJson<T = unknown>(sealed: string, context: string): Promise<T>;
103
+ /**
104
+ * Seals bytes.
105
+ *
106
+ * @param plaintext - The bytes to seal. May be empty.
107
+ * @param context - Where the Sealed value is stored.
108
+ * @returns The Sealed value, `vault:v1:<keyId>:b:<iv>:<ciphertext>`.
109
+ */
110
+ encryptBytes(plaintext: Uint8Array, context: string): Promise<string>;
111
+ /**
112
+ * Opens a Sealed value made by {@link Vault.encryptBytes}.
113
+ *
114
+ * @param sealed - The Sealed value.
115
+ * @param context - The Context it was sealed under.
116
+ * @returns The Plaintext bytes, as a plain `Uint8Array`.
117
+ * @throws {@link VaultDecryptError} for the same reasons as
118
+ * {@link Vault.decrypt}, or `wrong_kind` when it isn't a bytes Sealed value.
119
+ */
120
+ decryptBytes(sealed: string, context: string): Promise<Uint8Array>;
121
+ /**
122
+ * True when a Sealed value was made with a previous Vault secret, so it
123
+ * should be re-sealed with {@link Vault.reseal} before that secret is
124
+ * dropped. Reads only the key id; it doesn't open the value, so it needs no
125
+ * Context. It is async, unlike {@link Vault.isSealed}, because the current
126
+ * key id is derived on first use: `await` it.
127
+ *
128
+ * @param sealed - The Sealed value.
129
+ * @returns A promise of `true` when the key id isn't the current one.
130
+ * @throws The promise rejects with {@link VaultDecryptError} `not_sealed`
131
+ * (or `unsupported_version`) when the value isn't a Sealed value this
132
+ * package can read; its `context` is `undefined`. It never throws
133
+ * synchronously.
134
+ */
135
+ needsRotation(sealed: string): Promise<boolean>;
136
+ /**
137
+ * Opens a Sealed value with whichever Vault secret made it and seals it
138
+ * again with the current one, keeping its kind (string, JSON or bytes). Use
139
+ * it on values where {@link Vault.needsRotation} is true.
140
+ *
141
+ * @param sealed - The Sealed value.
142
+ * @param context - The Context it was sealed under; the new Sealed value
143
+ * is bound to the same Context.
144
+ * @returns A new Sealed value with the current key id.
145
+ * @throws {@link VaultDecryptError} for the same reasons as
146
+ * {@link Vault.decrypt}.
147
+ */
148
+ reseal(sealed: string, context: string): Promise<string>;
149
+ }
150
+
151
+ /**
152
+ * Base class of every error the package throws, so `instanceof VaultError`
153
+ * catches them all. The underlying error, if any, is the native `cause`.
154
+ */
155
+ declare class VaultError extends Error {
156
+ /** A stable, machine-readable code such as `VAULT_CONFIG_INVALID`. */
157
+ readonly code: string;
158
+ /**
159
+ * @param message - The error message.
160
+ * @param code - The stable error code.
161
+ * @param options - The underlying `cause`, if any.
162
+ */
163
+ constructor(message: string, code: string, options?: ErrorOptions);
164
+ }
165
+ /**
166
+ * The Vault's options break a rule in {@link VaultOptions}. Thrown by the
167
+ * `Vault` constructor, so a config mistake fails at startup, except a
168
+ * duplicated Vault secret: that is found when keys are derived, so the
169
+ * Vault's first keyed call, and every later one, rejects with this error. The
170
+ * message names the bad option, never its value.
171
+ */
172
+ declare class VaultConfigError extends VaultError {
173
+ /** Always `VAULT_CONFIG_INVALID`. */
174
+ readonly code: "VAULT_CONFIG_INVALID";
175
+ /**
176
+ * @param message - What is wrong, naming the option but never its value.
177
+ * @param options - The underlying `cause`, if any.
178
+ */
179
+ constructor(message: string, options?: ErrorOptions);
180
+ }
181
+ /**
182
+ * Why a Sealed value couldn't be opened.
183
+ *
184
+ * - `not_sealed`: the value isn't a well-formed Sealed value, such as plain
185
+ * text, a value with missing parts, or a part that isn't canonical
186
+ * base64url.
187
+ * - `unsupported_version`: a well-formed Sealed value in a format version
188
+ * this package can't read.
189
+ * - `unknown_key`: no configured Vault secret matches the Sealed value's key id.
190
+ * - `wrong_kind`: the Sealed value holds another kind of Plaintext than the method reads.
191
+ * - `authentication_failed`: the Sealed value was edited, or is opened under
192
+ * another Context or Purpose, or a string Sealed value opened to bytes that
193
+ * aren't valid UTF-8.
194
+ * - `invalid_json`: a JSON Sealed value opened to text that isn't JSON.
195
+ */
196
+ type VaultDecryptReason = "not_sealed" | "unsupported_version" | "unknown_key" | "wrong_kind" | "authentication_failed" | "invalid_json";
197
+ /** The code of a {@link VaultDecryptError}, one per {@link VaultDecryptReason}. */
198
+ type VaultDecryptErrorCode = `VAULT_DECRYPT_${Uppercase<VaultDecryptReason>}`;
199
+ /**
200
+ * A Sealed value couldn't be opened. The message names the reason and the
201
+ * Context, if there is one, never the Plaintext or the Sealed value.
202
+ */
203
+ declare class VaultDecryptError extends VaultError {
204
+ /** Why the Sealed value couldn't be opened. */
205
+ readonly reason: VaultDecryptReason;
206
+ /**
207
+ * The Context the Sealed value was opened under, or `undefined` when the
208
+ * method takes no Context (`needsRotation`).
209
+ */
210
+ readonly context: string | undefined;
211
+ /** The code for {@link VaultDecryptError.reason}, such as `VAULT_DECRYPT_UNKNOWN_KEY`. */
212
+ readonly code: VaultDecryptErrorCode;
213
+ /**
214
+ * @param reason - Why the Sealed value couldn't be opened.
215
+ * @param context - The Context it was opened under, or `undefined` when
216
+ * there is none.
217
+ * @param options - The underlying `cause`, if any.
218
+ */
219
+ constructor(reason: VaultDecryptReason, context: string | undefined, options?: ErrorOptions);
220
+ }
221
+
222
+ export { Vault, VaultConfigError, VaultDecryptError, type VaultDecryptErrorCode, type VaultDecryptReason, VaultError, type VaultOptions };
@@ -0,0 +1,222 @@
1
+ /** Options for building a {@link Vault}. */
2
+ interface VaultOptions {
3
+ /**
4
+ * The current Vault secret, which seals every new value. At least 32
5
+ * characters of high-entropy key material, such as the output of
6
+ * `openssl rand -base64 32`.
7
+ */
8
+ secret: string;
9
+ /**
10
+ * Earlier Vault secrets, still accepted when opening Sealed values but never
11
+ * used to seal new ones. During a Rotation, move the old `secret` here until
12
+ * every value sealed with it has been re-sealed with {@link Vault.reseal}.
13
+ * Each must be at least 32 characters. A secret listed twice (here or
14
+ * alongside `secret`) is a {@link VaultConfigError}, reported by the first
15
+ * call that needs keys rather than by the constructor.
16
+ */
17
+ previousSecrets?: readonly string[];
18
+ /**
19
+ * The name of this Vault, such as `myapp:settings`. Vaults with the same
20
+ * Vault secret but different Purposes can't read each other's Sealed
21
+ * values. It must never change once values are stored.
22
+ */
23
+ purpose: string;
24
+ }
25
+ /**
26
+ * Turns Plaintexts into Sealed values and back, with AES-256-GCM on Web
27
+ * Crypto. Build one per Purpose, at module level, and reuse it.
28
+ *
29
+ * Every method that seals or opens takes a Context: where the Sealed value is
30
+ * stored, such as `settings:smtp_pass`. A Sealed value only opens under the
31
+ * Context it was sealed with. `""` is allowed and means no binding.
32
+ *
33
+ * @example
34
+ * const vault = new Vault({ secret: process.env.VAULT_SECRET!, purpose: "myapp:settings" });
35
+ * const sealed = await vault.encrypt("hunter2", "settings:smtp_pass");
36
+ * await vault.decrypt(sealed, "settings:smtp_pass"); // "hunter2"
37
+ */
38
+ declare class Vault {
39
+ #private;
40
+ /**
41
+ * Checks the options right away; keys are derived on first use, so this
42
+ * needs no `await`.
43
+ *
44
+ * @param options - The Vault secrets and Purpose.
45
+ * @throws {@link VaultConfigError} when an option breaks a rule in
46
+ * {@link VaultOptions}, except a duplicated secret, which is reported on
47
+ * first use. The message never contains a secret.
48
+ */
49
+ constructor(options: VaultOptions);
50
+ /**
51
+ * True when `value` is a Sealed value: a string starting with `vault:`. Use
52
+ * it to tell Sealed values from plain text still stored in a column.
53
+ *
54
+ * @param value - Any value.
55
+ */
56
+ static isSealed(value: unknown): value is string;
57
+ /**
58
+ * Seals a string.
59
+ *
60
+ * @param plaintext - The string to seal. May be empty.
61
+ * @param context - Where the Sealed value is stored.
62
+ * @returns The Sealed value, `vault:v1:<keyId>:s:<iv>:<ciphertext>`.
63
+ */
64
+ encrypt(plaintext: string, context: string): Promise<string>;
65
+ /**
66
+ * Opens a Sealed value made by {@link Vault.encrypt}.
67
+ *
68
+ * @param sealed - The Sealed value.
69
+ * @param context - The Context it was sealed under.
70
+ * @returns The Plaintext string.
71
+ * @throws {@link VaultDecryptError} when the value isn't a Sealed value, was
72
+ * sealed with an unknown Vault secret or another Purpose, was edited, or is
73
+ * opened under another Context, or `wrong_kind` when it isn't a string
74
+ * Sealed value. A string Sealed value that opens to bytes that aren't valid
75
+ * UTF-8 also rejects as `authentication_failed`, with the decoder's
76
+ * `TypeError` as its `cause`.
77
+ */
78
+ decrypt(sealed: string, context: string): Promise<string>;
79
+ /**
80
+ * Seals JSON data with plain `JSON.stringify`.
81
+ *
82
+ * @param value - Any value `JSON.stringify` can encode.
83
+ * @param context - Where the Sealed value is stored.
84
+ * @returns The Sealed value, `vault:v1:<keyId>:j:<iv>:<ciphertext>`.
85
+ * @throws The promise rejects with a `TypeError` when `JSON.stringify`
86
+ * can't encode `value`, such as `undefined`, a function, a `BigInt` or a
87
+ * cyclic object, before anything is encrypted. It never throws
88
+ * synchronously.
89
+ */
90
+ encryptJson(value: unknown, context: string): Promise<string>;
91
+ /**
92
+ * Opens a Sealed value made by {@link Vault.encryptJson}.
93
+ *
94
+ * @typeParam T - The type the caller expects. It is asserted, not validated.
95
+ * @param sealed - The Sealed value.
96
+ * @param context - The Context it was sealed under.
97
+ * @returns The JSON data, parsed with plain `JSON.parse`.
98
+ * @throws {@link VaultDecryptError} for the same reasons as
99
+ * {@link Vault.decrypt}, `wrong_kind` when it isn't a JSON Sealed value, or
100
+ * `invalid_json` when it opens to text that isn't JSON.
101
+ */
102
+ decryptJson<T = unknown>(sealed: string, context: string): Promise<T>;
103
+ /**
104
+ * Seals bytes.
105
+ *
106
+ * @param plaintext - The bytes to seal. May be empty.
107
+ * @param context - Where the Sealed value is stored.
108
+ * @returns The Sealed value, `vault:v1:<keyId>:b:<iv>:<ciphertext>`.
109
+ */
110
+ encryptBytes(plaintext: Uint8Array, context: string): Promise<string>;
111
+ /**
112
+ * Opens a Sealed value made by {@link Vault.encryptBytes}.
113
+ *
114
+ * @param sealed - The Sealed value.
115
+ * @param context - The Context it was sealed under.
116
+ * @returns The Plaintext bytes, as a plain `Uint8Array`.
117
+ * @throws {@link VaultDecryptError} for the same reasons as
118
+ * {@link Vault.decrypt}, or `wrong_kind` when it isn't a bytes Sealed value.
119
+ */
120
+ decryptBytes(sealed: string, context: string): Promise<Uint8Array>;
121
+ /**
122
+ * True when a Sealed value was made with a previous Vault secret, so it
123
+ * should be re-sealed with {@link Vault.reseal} before that secret is
124
+ * dropped. Reads only the key id; it doesn't open the value, so it needs no
125
+ * Context. It is async, unlike {@link Vault.isSealed}, because the current
126
+ * key id is derived on first use: `await` it.
127
+ *
128
+ * @param sealed - The Sealed value.
129
+ * @returns A promise of `true` when the key id isn't the current one.
130
+ * @throws The promise rejects with {@link VaultDecryptError} `not_sealed`
131
+ * (or `unsupported_version`) when the value isn't a Sealed value this
132
+ * package can read; its `context` is `undefined`. It never throws
133
+ * synchronously.
134
+ */
135
+ needsRotation(sealed: string): Promise<boolean>;
136
+ /**
137
+ * Opens a Sealed value with whichever Vault secret made it and seals it
138
+ * again with the current one, keeping its kind (string, JSON or bytes). Use
139
+ * it on values where {@link Vault.needsRotation} is true.
140
+ *
141
+ * @param sealed - The Sealed value.
142
+ * @param context - The Context it was sealed under; the new Sealed value
143
+ * is bound to the same Context.
144
+ * @returns A new Sealed value with the current key id.
145
+ * @throws {@link VaultDecryptError} for the same reasons as
146
+ * {@link Vault.decrypt}.
147
+ */
148
+ reseal(sealed: string, context: string): Promise<string>;
149
+ }
150
+
151
+ /**
152
+ * Base class of every error the package throws, so `instanceof VaultError`
153
+ * catches them all. The underlying error, if any, is the native `cause`.
154
+ */
155
+ declare class VaultError extends Error {
156
+ /** A stable, machine-readable code such as `VAULT_CONFIG_INVALID`. */
157
+ readonly code: string;
158
+ /**
159
+ * @param message - The error message.
160
+ * @param code - The stable error code.
161
+ * @param options - The underlying `cause`, if any.
162
+ */
163
+ constructor(message: string, code: string, options?: ErrorOptions);
164
+ }
165
+ /**
166
+ * The Vault's options break a rule in {@link VaultOptions}. Thrown by the
167
+ * `Vault` constructor, so a config mistake fails at startup, except a
168
+ * duplicated Vault secret: that is found when keys are derived, so the
169
+ * Vault's first keyed call, and every later one, rejects with this error. The
170
+ * message names the bad option, never its value.
171
+ */
172
+ declare class VaultConfigError extends VaultError {
173
+ /** Always `VAULT_CONFIG_INVALID`. */
174
+ readonly code: "VAULT_CONFIG_INVALID";
175
+ /**
176
+ * @param message - What is wrong, naming the option but never its value.
177
+ * @param options - The underlying `cause`, if any.
178
+ */
179
+ constructor(message: string, options?: ErrorOptions);
180
+ }
181
+ /**
182
+ * Why a Sealed value couldn't be opened.
183
+ *
184
+ * - `not_sealed`: the value isn't a well-formed Sealed value, such as plain
185
+ * text, a value with missing parts, or a part that isn't canonical
186
+ * base64url.
187
+ * - `unsupported_version`: a well-formed Sealed value in a format version
188
+ * this package can't read.
189
+ * - `unknown_key`: no configured Vault secret matches the Sealed value's key id.
190
+ * - `wrong_kind`: the Sealed value holds another kind of Plaintext than the method reads.
191
+ * - `authentication_failed`: the Sealed value was edited, or is opened under
192
+ * another Context or Purpose, or a string Sealed value opened to bytes that
193
+ * aren't valid UTF-8.
194
+ * - `invalid_json`: a JSON Sealed value opened to text that isn't JSON.
195
+ */
196
+ type VaultDecryptReason = "not_sealed" | "unsupported_version" | "unknown_key" | "wrong_kind" | "authentication_failed" | "invalid_json";
197
+ /** The code of a {@link VaultDecryptError}, one per {@link VaultDecryptReason}. */
198
+ type VaultDecryptErrorCode = `VAULT_DECRYPT_${Uppercase<VaultDecryptReason>}`;
199
+ /**
200
+ * A Sealed value couldn't be opened. The message names the reason and the
201
+ * Context, if there is one, never the Plaintext or the Sealed value.
202
+ */
203
+ declare class VaultDecryptError extends VaultError {
204
+ /** Why the Sealed value couldn't be opened. */
205
+ readonly reason: VaultDecryptReason;
206
+ /**
207
+ * The Context the Sealed value was opened under, or `undefined` when the
208
+ * method takes no Context (`needsRotation`).
209
+ */
210
+ readonly context: string | undefined;
211
+ /** The code for {@link VaultDecryptError.reason}, such as `VAULT_DECRYPT_UNKNOWN_KEY`. */
212
+ readonly code: VaultDecryptErrorCode;
213
+ /**
214
+ * @param reason - Why the Sealed value couldn't be opened.
215
+ * @param context - The Context it was opened under, or `undefined` when
216
+ * there is none.
217
+ * @param options - The underlying `cause`, if any.
218
+ */
219
+ constructor(reason: VaultDecryptReason, context: string | undefined, options?: ErrorOptions);
220
+ }
221
+
222
+ export { Vault, VaultConfigError, VaultDecryptError, type VaultDecryptErrorCode, type VaultDecryptReason, VaultError, type VaultOptions };
package/dist/index.js ADDED
@@ -0,0 +1,410 @@
1
+ // src/encoding.ts
2
+ var encoder = new TextEncoder();
3
+ var decoder = new TextDecoder("utf-8", { fatal: true });
4
+ function utf8Encode(text) {
5
+ return encoder.encode(text);
6
+ }
7
+ function utf8Decode(bytes) {
8
+ return decoder.decode(bytes);
9
+ }
10
+ function base64urlEncode(bytes) {
11
+ let binary = "";
12
+ for (const byte of bytes) binary += String.fromCharCode(byte);
13
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
14
+ }
15
+ function base64urlDecode(text) {
16
+ if (!/^[A-Za-z0-9_-]*$/.test(text) || text.length % 4 === 1) return void 0;
17
+ const binary = atob(text.replace(/-/g, "+").replace(/_/g, "/"));
18
+ const bytes = new Uint8Array(binary.length);
19
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
20
+ return base64urlEncode(bytes) === text ? bytes : void 0;
21
+ }
22
+
23
+ // src/errors.ts
24
+ var VaultError = class extends Error {
25
+ /** A stable, machine-readable code such as `VAULT_CONFIG_INVALID`. */
26
+ code;
27
+ /**
28
+ * @param message - The error message.
29
+ * @param code - The stable error code.
30
+ * @param options - The underlying `cause`, if any.
31
+ */
32
+ constructor(message, code, options) {
33
+ super(message, options);
34
+ this.name = "VaultError";
35
+ this.code = code;
36
+ }
37
+ };
38
+ var VaultConfigError = class extends VaultError {
39
+ /**
40
+ * @param message - What is wrong, naming the option but never its value.
41
+ * @param options - The underlying `cause`, if any.
42
+ */
43
+ constructor(message, options) {
44
+ super(message, "VAULT_CONFIG_INVALID", options);
45
+ this.name = "VaultConfigError";
46
+ }
47
+ };
48
+ var VaultDecryptError = class extends VaultError {
49
+ /** Why the Sealed value couldn't be opened. */
50
+ reason;
51
+ /**
52
+ * The Context the Sealed value was opened under, or `undefined` when the
53
+ * method takes no Context (`needsRotation`).
54
+ */
55
+ context;
56
+ /**
57
+ * @param reason - Why the Sealed value couldn't be opened.
58
+ * @param context - The Context it was opened under, or `undefined` when
59
+ * there is none.
60
+ * @param options - The underlying `cause`, if any.
61
+ */
62
+ constructor(reason, context, options) {
63
+ super(
64
+ context === void 0 ? `Could not read Sealed value: ${reason}` : `Could not open Sealed value (context "${context}"): ${reason}`,
65
+ `VAULT_DECRYPT_${reason.toUpperCase()}`,
66
+ options
67
+ );
68
+ this.name = "VaultDecryptError";
69
+ this.reason = reason;
70
+ this.context = context;
71
+ }
72
+ };
73
+
74
+ // src/format.ts
75
+ var PREFIX = "vault";
76
+ var FORMAT_VERSION = "v1";
77
+ var KINDS = ["s", "j", "b"];
78
+ function looksSealed(value) {
79
+ return typeof value === "string" && value.startsWith(`${PREFIX}:`);
80
+ }
81
+ function formatSealed(parts) {
82
+ return [
83
+ PREFIX,
84
+ FORMAT_VERSION,
85
+ parts.keyId,
86
+ parts.kind,
87
+ base64urlEncode(parts.iv),
88
+ base64urlEncode(parts.ciphertext)
89
+ ].join(":");
90
+ }
91
+ function parseSealed(value, context) {
92
+ if (!looksSealed(value)) throw new VaultDecryptError("not_sealed", context);
93
+ const parts = value.split(":");
94
+ const [, version, keyId, kind, iv, ciphertext] = parts;
95
+ if (parts.length !== 6 || !/^v[0-9]+$/.test(version)) {
96
+ throw new VaultDecryptError("not_sealed", context);
97
+ }
98
+ if (version !== FORMAT_VERSION) {
99
+ throw new VaultDecryptError("unsupported_version", context);
100
+ }
101
+ const ivBytes = base64urlDecode(iv);
102
+ const ciphertextBytes = base64urlDecode(ciphertext);
103
+ if (!/^[0-9a-f]{8}$/.test(keyId) || !isSealedKind(kind) || !ivBytes || !ciphertextBytes) {
104
+ throw new VaultDecryptError("not_sealed", context);
105
+ }
106
+ return { keyId, kind, iv: ivBytes, ciphertext: ciphertextBytes };
107
+ }
108
+ function isSealedKind(kind) {
109
+ return KINDS.includes(kind);
110
+ }
111
+ function additionalData(kind, context) {
112
+ return utf8Encode(`${FORMAT_VERSION}:${kind}:${context}`);
113
+ }
114
+
115
+ // src/keys.ts
116
+ var SALT = utf8Encode("secret-vault/v1");
117
+ var KEY_ID_BYTES = 4;
118
+ async function deriveVaultKey(secret, purpose) {
119
+ const material = await crypto.subtle.importKey(
120
+ "raw",
121
+ utf8Encode(secret),
122
+ "HKDF",
123
+ false,
124
+ ["deriveKey", "deriveBits"]
125
+ );
126
+ const hkdf = (info) => ({
127
+ name: "HKDF",
128
+ hash: "SHA-256",
129
+ salt: SALT,
130
+ info: utf8Encode(info)
131
+ });
132
+ const [key, idBits] = await Promise.all([
133
+ crypto.subtle.deriveKey(
134
+ hkdf(purpose),
135
+ material,
136
+ { name: "AES-GCM", length: 256 },
137
+ false,
138
+ ["encrypt", "decrypt"]
139
+ ),
140
+ crypto.subtle.deriveBits(
141
+ hkdf(`${purpose}:key-id`),
142
+ material,
143
+ KEY_ID_BYTES * 8
144
+ )
145
+ ]);
146
+ const id = Array.from(
147
+ new Uint8Array(idBits),
148
+ (byte) => byte.toString(16).padStart(2, "0")
149
+ ).join("");
150
+ return { id, key };
151
+ }
152
+ var KeyRing = class {
153
+ #secrets;
154
+ #purpose;
155
+ #loaded;
156
+ /**
157
+ * @param secrets - The current Vault secret first, then any previous ones.
158
+ * @param purpose - The Vault's Purpose.
159
+ */
160
+ constructor(secrets, purpose) {
161
+ this.#secrets = secrets;
162
+ this.#purpose = purpose;
163
+ }
164
+ /** Derives the keys, or returns the cached ones. */
165
+ load() {
166
+ this.#loaded ??= this.#derive().catch((error) => {
167
+ this.#loaded = void 0;
168
+ throw error;
169
+ });
170
+ return this.#loaded;
171
+ }
172
+ async #derive() {
173
+ const keys = await Promise.all(
174
+ this.#secrets.map((secret) => deriveVaultKey(secret, this.#purpose))
175
+ );
176
+ const byId = /* @__PURE__ */ new Map();
177
+ for (const key of keys) {
178
+ if (byId.has(key.id)) {
179
+ throw new VaultConfigError(
180
+ "Vault options `secret` and `previousSecrets` contain two Vault secrets with the same key id; remove the duplicate."
181
+ );
182
+ }
183
+ byId.set(key.id, key);
184
+ }
185
+ return { current: keys[0], byId };
186
+ }
187
+ };
188
+
189
+ // src/random.ts
190
+ function randomBytes(length) {
191
+ return crypto.getRandomValues(new Uint8Array(length));
192
+ }
193
+
194
+ // src/vault.ts
195
+ var MIN_SECRET_LENGTH = 32;
196
+ var IV_BYTES = 12;
197
+ var Vault = class {
198
+ #keys;
199
+ /**
200
+ * Checks the options right away; keys are derived on first use, so this
201
+ * needs no `await`.
202
+ *
203
+ * @param options - The Vault secrets and Purpose.
204
+ * @throws {@link VaultConfigError} when an option breaks a rule in
205
+ * {@link VaultOptions}, except a duplicated secret, which is reported on
206
+ * first use. The message never contains a secret.
207
+ */
208
+ constructor(options) {
209
+ assertSecret(options.secret, "secret");
210
+ const previousSecrets = options.previousSecrets ?? [];
211
+ if (!Array.isArray(previousSecrets)) {
212
+ throw new VaultConfigError(
213
+ "Vault option `previousSecrets` must be an array of strings."
214
+ );
215
+ }
216
+ previousSecrets.forEach(
217
+ (secret, index) => assertSecret(secret, `previousSecrets[${index}]`)
218
+ );
219
+ if (typeof options.purpose !== "string" || options.purpose === "") {
220
+ throw new VaultConfigError("Vault option `purpose` must not be empty.");
221
+ }
222
+ this.#keys = new KeyRing(
223
+ [options.secret, ...previousSecrets],
224
+ options.purpose
225
+ );
226
+ }
227
+ /**
228
+ * True when `value` is a Sealed value: a string starting with `vault:`. Use
229
+ * it to tell Sealed values from plain text still stored in a column.
230
+ *
231
+ * @param value - Any value.
232
+ */
233
+ static isSealed(value) {
234
+ return looksSealed(value);
235
+ }
236
+ /**
237
+ * Seals a string.
238
+ *
239
+ * @param plaintext - The string to seal. May be empty.
240
+ * @param context - Where the Sealed value is stored.
241
+ * @returns The Sealed value, `vault:v1:<keyId>:s:<iv>:<ciphertext>`.
242
+ */
243
+ encrypt(plaintext, context) {
244
+ return this.#seal("s", utf8Encode(plaintext), context);
245
+ }
246
+ /**
247
+ * Opens a Sealed value made by {@link Vault.encrypt}.
248
+ *
249
+ * @param sealed - The Sealed value.
250
+ * @param context - The Context it was sealed under.
251
+ * @returns The Plaintext string.
252
+ * @throws {@link VaultDecryptError} when the value isn't a Sealed value, was
253
+ * sealed with an unknown Vault secret or another Purpose, was edited, or is
254
+ * opened under another Context, or `wrong_kind` when it isn't a string
255
+ * Sealed value. A string Sealed value that opens to bytes that aren't valid
256
+ * UTF-8 also rejects as `authentication_failed`, with the decoder's
257
+ * `TypeError` as its `cause`.
258
+ */
259
+ async decrypt(sealed, context) {
260
+ const plaintext = await this.#openKind("s", sealed, context);
261
+ try {
262
+ return utf8Decode(plaintext);
263
+ } catch (cause) {
264
+ throw new VaultDecryptError("authentication_failed", context, { cause });
265
+ }
266
+ }
267
+ /**
268
+ * Seals JSON data with plain `JSON.stringify`.
269
+ *
270
+ * @param value - Any value `JSON.stringify` can encode.
271
+ * @param context - Where the Sealed value is stored.
272
+ * @returns The Sealed value, `vault:v1:<keyId>:j:<iv>:<ciphertext>`.
273
+ * @throws The promise rejects with a `TypeError` when `JSON.stringify`
274
+ * can't encode `value`, such as `undefined`, a function, a `BigInt` or a
275
+ * cyclic object, before anything is encrypted. It never throws
276
+ * synchronously.
277
+ */
278
+ async encryptJson(value, context) {
279
+ const json = JSON.stringify(value);
280
+ if (typeof json !== "string") {
281
+ throw new TypeError("Vault.encryptJson: value can't be encoded as JSON.");
282
+ }
283
+ return this.#seal("j", utf8Encode(json), context);
284
+ }
285
+ /**
286
+ * Opens a Sealed value made by {@link Vault.encryptJson}.
287
+ *
288
+ * @typeParam T - The type the caller expects. It is asserted, not validated.
289
+ * @param sealed - The Sealed value.
290
+ * @param context - The Context it was sealed under.
291
+ * @returns The JSON data, parsed with plain `JSON.parse`.
292
+ * @throws {@link VaultDecryptError} for the same reasons as
293
+ * {@link Vault.decrypt}, `wrong_kind` when it isn't a JSON Sealed value, or
294
+ * `invalid_json` when it opens to text that isn't JSON.
295
+ */
296
+ async decryptJson(sealed, context) {
297
+ const plaintext = await this.#openKind("j", sealed, context);
298
+ try {
299
+ return JSON.parse(utf8Decode(plaintext));
300
+ } catch (cause) {
301
+ throw new VaultDecryptError("invalid_json", context, { cause });
302
+ }
303
+ }
304
+ /**
305
+ * Seals bytes.
306
+ *
307
+ * @param plaintext - The bytes to seal. May be empty.
308
+ * @param context - Where the Sealed value is stored.
309
+ * @returns The Sealed value, `vault:v1:<keyId>:b:<iv>:<ciphertext>`.
310
+ */
311
+ encryptBytes(plaintext, context) {
312
+ return this.#seal("b", Uint8Array.from(plaintext), context);
313
+ }
314
+ /**
315
+ * Opens a Sealed value made by {@link Vault.encryptBytes}.
316
+ *
317
+ * @param sealed - The Sealed value.
318
+ * @param context - The Context it was sealed under.
319
+ * @returns The Plaintext bytes, as a plain `Uint8Array`.
320
+ * @throws {@link VaultDecryptError} for the same reasons as
321
+ * {@link Vault.decrypt}, or `wrong_kind` when it isn't a bytes Sealed value.
322
+ */
323
+ decryptBytes(sealed, context) {
324
+ return this.#openKind("b", sealed, context);
325
+ }
326
+ /** Opens a Sealed value, then checks it holds the `expected` kind. */
327
+ async #openKind(expected, sealed, context) {
328
+ const { kind, plaintext } = await this.#open(sealed, context);
329
+ if (kind !== expected) throw new VaultDecryptError("wrong_kind", context);
330
+ return plaintext;
331
+ }
332
+ /**
333
+ * True when a Sealed value was made with a previous Vault secret, so it
334
+ * should be re-sealed with {@link Vault.reseal} before that secret is
335
+ * dropped. Reads only the key id; it doesn't open the value, so it needs no
336
+ * Context. It is async, unlike {@link Vault.isSealed}, because the current
337
+ * key id is derived on first use: `await` it.
338
+ *
339
+ * @param sealed - The Sealed value.
340
+ * @returns A promise of `true` when the key id isn't the current one.
341
+ * @throws The promise rejects with {@link VaultDecryptError} `not_sealed`
342
+ * (or `unsupported_version`) when the value isn't a Sealed value this
343
+ * package can read; its `context` is `undefined`. It never throws
344
+ * synchronously.
345
+ */
346
+ async needsRotation(sealed) {
347
+ const { keyId } = parseSealed(sealed, void 0);
348
+ const { current } = await this.#keys.load();
349
+ return keyId !== current.id;
350
+ }
351
+ /**
352
+ * Opens a Sealed value with whichever Vault secret made it and seals it
353
+ * again with the current one, keeping its kind (string, JSON or bytes). Use
354
+ * it on values where {@link Vault.needsRotation} is true.
355
+ *
356
+ * @param sealed - The Sealed value.
357
+ * @param context - The Context it was sealed under; the new Sealed value
358
+ * is bound to the same Context.
359
+ * @returns A new Sealed value with the current key id.
360
+ * @throws {@link VaultDecryptError} for the same reasons as
361
+ * {@link Vault.decrypt}.
362
+ */
363
+ async reseal(sealed, context) {
364
+ const { kind, plaintext } = await this.#open(sealed, context);
365
+ return this.#seal(kind, plaintext, context);
366
+ }
367
+ async #seal(kind, plaintext, context) {
368
+ const { current } = await this.#keys.load();
369
+ const iv = randomBytes(IV_BYTES);
370
+ const ciphertext = await crypto.subtle.encrypt(
371
+ aesGcmParams(iv, kind, context),
372
+ current.key,
373
+ plaintext
374
+ );
375
+ return formatSealed({
376
+ keyId: current.id,
377
+ kind,
378
+ iv,
379
+ ciphertext: new Uint8Array(ciphertext)
380
+ });
381
+ }
382
+ async #open(sealed, context) {
383
+ const { keyId, kind, iv, ciphertext } = parseSealed(sealed, context);
384
+ const { byId } = await this.#keys.load();
385
+ const vaultKey = byId.get(keyId);
386
+ if (!vaultKey) throw new VaultDecryptError("unknown_key", context);
387
+ try {
388
+ const plaintext = await crypto.subtle.decrypt(
389
+ aesGcmParams(iv, kind, context),
390
+ vaultKey.key,
391
+ ciphertext
392
+ );
393
+ return { kind, plaintext: new Uint8Array(plaintext) };
394
+ } catch (cause) {
395
+ throw new VaultDecryptError("authentication_failed", context, { cause });
396
+ }
397
+ }
398
+ };
399
+ function aesGcmParams(iv, kind, context) {
400
+ return { name: "AES-GCM", iv, additionalData: additionalData(kind, context) };
401
+ }
402
+ function assertSecret(secret, option) {
403
+ if (typeof secret !== "string" || secret.length < MIN_SECRET_LENGTH) {
404
+ throw new VaultConfigError(
405
+ `Vault option \`${option}\` must be a string of at least ${MIN_SECRET_LENGTH} characters.`
406
+ );
407
+ }
408
+ }
409
+
410
+ export { Vault, VaultConfigError, VaultDecryptError, VaultError };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rezwanul7/secret-vault",
3
- "version": "0.0.0",
3
+ "version": "0.1.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",