@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/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,57 @@
1
1
  {
2
2
  "name": "@rezwanul7/secret-vault",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0",
4
+ "license": "MIT",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/rezwanul7/turbopack-package-development.git",
8
+ "directory": "packages/secret-vault"
9
+ },
10
+ "type": "module",
11
+ "main": "./dist/index.cjs",
12
+ "module": "./dist/index.js",
13
+ "types": "./dist/index.d.ts",
14
+ "sideEffects": false,
15
+ "engines": {
16
+ "node": ">=22"
17
+ },
18
+ "publishConfig": {
19
+ "access": "public"
20
+ },
21
+ "files": [
22
+ "dist"
23
+ ],
24
+ "exports": {
25
+ ".": {
26
+ "import": {
27
+ "types": "./dist/index.d.ts",
28
+ "default": "./dist/index.js"
29
+ },
30
+ "require": {
31
+ "types": "./dist/index.d.cts",
32
+ "default": "./dist/index.cjs"
33
+ }
34
+ }
35
+ },
36
+ "devDependencies": {
37
+ "@edge-runtime/vm": "^5.0.0",
38
+ "@repo/eslint-config": "0.0.0",
39
+ "@repo/typescript-config": "0.0.0",
40
+ "@vitest/coverage-v8": "4.0.18",
41
+ "eslint": "^9.39.1",
42
+ "tsup": "^8.5.1",
43
+ "typedoc-plugin-markdown": "4.14.0",
44
+ "vitest": "^4.0.18"
45
+ },
46
+ "scripts": {
47
+ "dev": "tsup --watch",
48
+ "build": "tsup",
49
+ "check-types": "tsc --noEmit",
50
+ "check-docs": "typedoc --readme none --disableSources",
51
+ "check-package": "publint && attw --pack . --profile node16",
52
+ "lint": "eslint . --max-warnings 0",
53
+ "test": "vitest run",
54
+ "test:watch": "vitest",
55
+ "test:coverage": "vitest run --coverage"
56
+ }
6
57
  }