@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 +415 -0
- package/dist/index.d.cts +222 -0
- package/dist/index.d.ts +222 -0
- package/dist/index.js +410 -0
- package/package.json +1 -1
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;
|
package/dist/index.d.cts
ADDED
|
@@ -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.d.ts
ADDED
|
@@ -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 };
|