@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 +21 -0
- package/README.md +242 -2
- 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 +54 -3
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
|
-
#
|
|
1
|
+
# @rezwanul7/secret-vault
|
|
2
2
|
|
|
3
|
-
|
|
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;
|