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