@rezwanul7/secret-vault 0.0.0-stage → 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Rezwanul Islam
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,243 @@
1
- # Temporary Holding Version
1
+ # @rezwanul7/secret-vault
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Seals values an app stores, such as API keys, passwords and settings objects, so they can sit in a database and be
4
+ read back only by an app holding the right Vault secret. It uses AES-256-GCM on Web Crypto, so the same code runs on
5
+ Node, Edge routes, middleware and Workers, and it has no runtime dependencies. The terms used here are defined in the
6
+ package [glossary](./GLOSSARY.md).
7
+
8
+ ## Installation
9
+
10
+ ```bash
11
+ npm install @rezwanul7/secret-vault
12
+ # or
13
+ pnpm add @rezwanul7/secret-vault
14
+ ```
15
+
16
+ Everything is imported from the package root; the exports are listed in the [API reference](./docs/api/README.md).
17
+
18
+ ## Building a Vault
19
+
20
+ Build one Vault per Purpose, at module level, and export it. The constructor is synchronous and checks its options
21
+ straight away, so invalid options throw `VaultConfigError` at startup instead of on the first request. The rules for
22
+ each option are in [`VaultOptions`](./docs/api/README.md#vaultoptions). Keys are derived on first use, so no top-level
23
+ `await` is needed.
24
+
25
+ ```ts
26
+ // src/server/vault.ts
27
+ import "server-only";
28
+ import { Vault } from "@rezwanul7/secret-vault";
29
+
30
+ export const vault = new Vault({
31
+ secret: process.env.VAULT_SECRET!,
32
+ purpose: "myapp:settings",
33
+ });
34
+ ```
35
+
36
+ ### Keep it on the server
37
+
38
+ The package has no `server-only` directive, so it works in any framework. A Vault holds your Vault secret, so it must
39
+ never reach a browser bundle. In a Next.js app, put `import "server-only";` at the top of the module that builds the
40
+ Vault, as above, and import the Vault only from there. The build then fails if a client component imports it.
41
+
42
+ ### Generating a Vault secret
43
+
44
+ A Vault secret is high-entropy key material with a minimum length (see [`VaultOptions`](./docs/api/README.md#secret)).
45
+ It is not a password, and nothing stretches it, so don't type one by hand. Generate it:
46
+
47
+ ```bash
48
+ openssl rand -base64 32
49
+ ```
50
+
51
+ Keep it wherever the app keeps its other secrets, such as an environment variable. Anyone with the Vault secret, the
52
+ Purpose and the database can read every Sealed value.
53
+
54
+ ### Choosing a Purpose
55
+
56
+ The Purpose names the Vault, such as `myapp:settings`. It goes into key derivation, so two Vaults with the same Vault
57
+ secret but different Purposes can't read each other's Sealed values. That keeps one app's leaked database from
58
+ opening another's, even if they share a secret.
59
+
60
+ The Purpose must never change once values are stored. Changing it changes every key, and every stored Sealed value then
61
+ fails to open with reason `unknown_key`. To change Vault secrets, do a [Rotation](#rotation) instead.
62
+
63
+ ## Sealing and opening
64
+
65
+ Every call is async. A Sealed value is a plain string, `vault:v1:<keyId>:<kind>:<iv>:<ciphertext>`, so it fits a text
66
+ column in any database. Each kind of Plaintext has its own pair of methods: `encrypt` and `decrypt` for strings,
67
+ `encryptJson` and `decryptJson` for JSON data, and `encryptBytes` and `decryptBytes` for bytes. Each takes the
68
+ [Context](#choosing-a-context) as its second argument; the [API reference](./docs/api/README.md#vault) has the full
69
+ signatures.
70
+
71
+ ```ts
72
+ const smtpPass = await vault.encrypt("hunter2", "settings:smtp_pass");
73
+ await vault.decrypt(smtpPass, "settings:smtp_pass"); // "hunter2"
74
+
75
+ const smtp = await vault.encryptJson(
76
+ { host: "smtp.example.com", port: 587, user: "mailer" },
77
+ "settings:smtp",
78
+ );
79
+ const config = await vault.decryptJson<SmtpConfig>(smtp, "settings:smtp");
80
+
81
+ const key = await vault.encryptBytes(
82
+ privateKeyBytes,
83
+ "signing_keys:private_key",
84
+ );
85
+ await vault.decryptBytes(key, "signing_keys:private_key"); // Uint8Array
86
+ ```
87
+
88
+ Opening a Sealed value with the method for another kind, such as `decrypt` on a bytes Sealed value, throws
89
+ `VaultDecryptError` with reason `wrong_kind`, so you never get garbled data back.
90
+
91
+ `encryptJson` uses plain `JSON.stringify`, and `decryptJson` plain `JSON.parse`, so `Date`, `Map` and class instances
92
+ come back as whatever JSON makes of them. The `T` in `decryptJson<T>` is asserted, not checked; validate the result
93
+ with your own schema if it matters. A value `JSON.stringify` can't encode, such as `undefined`, a function, a `BigInt`
94
+ or a cyclic object, rejects with a `TypeError` before anything is encrypted.
95
+
96
+ ### Size of bytes
97
+
98
+ A Sealed value is base64url text, so it is about 1.33 times the size of the Plaintext, plus about 60 characters for
99
+ the prefix, key id, IV and authentication tag. A 1 MB file becomes a Sealed value of about 1.33 MB. The whole value is
100
+ also held in memory while it is sealed and opened; there is no streaming. Seal small binary values such as keys or
101
+ certificates, and keep large files in object storage.
102
+
103
+ ## Choosing a Context
104
+
105
+ Every method that seals or opens takes a Context: a string naming where the Sealed value is stored. It is bound into
106
+ the authentication tag, so a Sealed value only opens under the Context it was sealed with. If someone copies a Sealed
107
+ value into another column or row, it fails to open with reason `authentication_failed`.
108
+
109
+ The package doesn't format or check the Context; pick a convention and keep to it:
110
+
111
+ | Context | Use when |
112
+ | -------------------------- | ------------------------------------------------------------------------------------------ |
113
+ | `<table>:<column>` | The value belongs to a column, such as `settings:smtp_pass`. |
114
+ | `<table>:<column>:<rowId>` | The value belongs to one row, such as `users:totp_secret:42`. A copy to another row fails. |
115
+ | `""` | You deliberately don't want any binding. The value opens wherever it is moved. |
116
+
117
+ Use the row form for per-user secrets, so one user's Sealed value pasted into another user's row won't open. Use a
118
+ stable row id, because a Sealed value can't be opened once its row id changes. Pass `""` only as a deliberate choice,
119
+ never as a default.
120
+
121
+ Like the Purpose, a Context must not change while values are stored under it. Renaming a table or column means
122
+ re-sealing its values: open each under the old Context and seal it under the new one.
123
+
124
+ ## Rotation
125
+
126
+ A Rotation replaces the Vault secret without downtime. The Vault seals new values with `secret` and still opens values
127
+ made with any of `previousSecrets`.
128
+
129
+ 1. Generate a new Vault secret with `openssl rand -base64 32`.
130
+ 2. Make it the `secret`, and move the old one into `previousSecrets`. Deploy. Every value still opens, and every new
131
+ value is sealed with the new secret.
132
+
133
+ ```ts
134
+ export const vault = new Vault({
135
+ secret: process.env.VAULT_SECRET!, // the new one
136
+ previousSecrets: [process.env.VAULT_SECRET_PREVIOUS!], // the old one
137
+ purpose: "myapp:settings",
138
+ });
139
+ ```
140
+
141
+ 3. Find the Sealed values made with the old secret and re-seal them with the current one:
142
+
143
+ ```ts
144
+ for (const row of await db.settings.findMany()) {
145
+ if (!(await vault.needsRotation(row.smtpPass))) continue;
146
+ const smtpPass = await vault.reseal(row.smtpPass, "settings:smtp_pass");
147
+ await db.settings.update({ where: { id: row.id }, data: { smtpPass } });
148
+ }
149
+ ```
150
+
151
+ `reseal` opens a value with whichever Vault secret made it and seals it again with the current one, keeping its
152
+ kind (string, JSON or bytes) and its Context. Do this for every column holding Sealed values.
153
+
154
+ 4. When `needsRotation` is false for every stored value, remove the old secret from `previousSecrets` and deploy.
155
+ A value you missed then fails to open with reason `unknown_key`.
156
+
157
+ `needsRotation` reads only the key id, so it takes no Context. Unlike `isSealed` it is async, because the current key
158
+ id is derived on first use, so `await` it. For a value that isn't a Sealed value it rejects with `VaultDecryptError`
159
+ reason `not_sealed`; it never throws synchronously.
160
+
161
+ The secrets in `previousSecrets` follow the same rules as `secret`, and listing one Vault secret twice is reported only
162
+ on first use; see [`previousSecrets`](./docs/api/README.md#previoussecrets).
163
+
164
+ ## Migrating plain-text columns
165
+
166
+ `Vault.isSealed(value)` is true for a string starting with `vault:`. Use it to seal a column that still holds plain text,
167
+ one row at a time:
168
+
169
+ ```ts
170
+ if (!Vault.isSealed(row.apiKey)) {
171
+ const apiKey = await vault.encrypt(row.apiKey, "integrations:api_key");
172
+ await db.integrations.update({ where: { id: row.id }, data: { apiKey } });
173
+ }
174
+ ```
175
+
176
+ `isSealed` checks only the prefix. A value that passes can still fail to open.
177
+
178
+ ## Errors
179
+
180
+ Every error the package throws extends `VaultError`, which has a stable `code` and the underlying error as the native
181
+ `cause`. Messages name the bad option or the Context, never a Vault secret, a Plaintext or a Sealed value, so they are
182
+ safe to log.
183
+
184
+ - `VaultConfigError` (`VAULT_CONFIG_INVALID`) means the Vault's options are wrong. It is a deploy problem: fix the
185
+ config.
186
+ - `VaultDecryptError` means a Sealed value couldn't be opened. Its `reason` says why, and its `code` is
187
+ `VAULT_DECRYPT_<REASON>`, such as `VAULT_DECRYPT_UNKNOWN_KEY`. It also carries the `context`.
188
+
189
+ | `reason` | What happened | Typical app response |
190
+ | ----------------------- | --------------------------------------------------------------------- | --------------------------------------------------------- |
191
+ | `not_sealed` | The value isn't a Sealed value, such as plain text still in a column. | Data problem: migrate it, or ask the user to re-enter it. |
192
+ | `unsupported_version` | The Sealed value uses a format version this package can't read. | Upgrade the package. |
193
+ | `unknown_key` | No configured Vault secret made it, or the Purpose changed. | Config problem: a secret was dropped too early. |
194
+ | `wrong_kind` | It holds another kind than the method reads. | Bug in the app: call the matching method. |
195
+ | `authentication_failed` | It was edited, or is opened under another Context or Purpose. | Treat as tampered: ask the user to re-enter the secret. |
196
+ | `invalid_json` | A JSON Sealed value opened to text that isn't JSON. | Treat as corrupt: ask the user to re-enter it. |
197
+
198
+ Map them onto your app's own errors at one place:
199
+
200
+ ```ts
201
+ import { VaultConfigError, VaultDecryptError } from "@rezwanul7/secret-vault";
202
+
203
+ export async function readSmtpPass(sealed: string): Promise<string> {
204
+ try {
205
+ return await vault.decrypt(sealed, "settings:smtp_pass");
206
+ } catch (error) {
207
+ if (error instanceof VaultDecryptError) {
208
+ switch (error.reason) {
209
+ case "not_sealed":
210
+ case "authentication_failed":
211
+ case "invalid_json":
212
+ throw new AppError(
213
+ "The saved SMTP password is unreadable; enter it again.",
214
+ {
215
+ status: 422,
216
+ cause: error,
217
+ },
218
+ );
219
+ default:
220
+ throw new AppError("Secret storage is misconfigured.", {
221
+ status: 500,
222
+ cause: error,
223
+ });
224
+ }
225
+ }
226
+ if (error instanceof VaultConfigError) {
227
+ throw new AppError("Secret storage is misconfigured.", {
228
+ status: 500,
229
+ cause: error,
230
+ });
231
+ }
232
+ throw error;
233
+ }
234
+ }
235
+ ```
236
+
237
+ ## API reference
238
+
239
+ Every export, parameter, and default is listed in the [API reference](./docs/api/README.md), generated from the source.
240
+
241
+ ## License
242
+
243
+ MIT License. See [LICENSE](./LICENSE) for details.
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;