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