vaultkeeper 0.6.0 → 0.7.1
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/README.md +611 -0
- package/dist/index.cjs +2621 -715
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1368 -139
- package/dist/index.d.ts +1368 -139
- package/dist/index.js +2593 -710
- package/dist/index.js.map +1 -1
- package/dist/one-password-worker.js +249 -39
- package/dist/one-password-worker.js.map +1 -1
- package/package.json +27 -6
package/README.md
ADDED
|
@@ -0,0 +1,611 @@
|
|
|
1
|
+
# vaultkeeper
|
|
2
|
+
|
|
3
|
+
This is the library only — it has no `bin` and installs no `vaultkeeper` command. For the CLI
|
|
4
|
+
(`vaultkeeper config init`, `vaultkeeper doctor`, etc.), install the separate
|
|
5
|
+
[`@vaultkeeper/cli`](https://www.npmjs.com/package/@vaultkeeper/cli) package.
|
|
6
|
+
|
|
7
|
+
Unified, policy-enforced secret storage across OS credential backends. By default secrets are stored
|
|
8
|
+
in a portable, self-contained AES-256-GCM encrypted `file` backend — a bare `VaultKeeper.init()`
|
|
9
|
+
never silently writes to your real OS credential store; the platform-native store (macOS Keychain,
|
|
10
|
+
Windows DPAPI) is an explicit opt-in. Secrets are accessed through short-lived JWE tokens — the raw
|
|
11
|
+
secret never appears in a return value.
|
|
12
|
+
|
|
13
|
+
## Installation
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
pnpm add vaultkeeper
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
**Requirements:** Node >= 20. **TypeScript version:** tested against TypeScript 5.0.4–7.0.2 (the stated floor plus the latest release of the 5.x, 6.x, and 7.x majors). The CI matrix (`packages/vaultkeeper/test/e2e/consumer-typecheck.test.ts`) verifies that the shipped `.d.ts` files typecheck cleanly under the strict NodeNext consumer config below across that version range, so a future `.d.ts` change that breaks a tested version fails the build. The exact `compilerOptions` that matrix uses (verified known-good — copy these):
|
|
20
|
+
|
|
21
|
+
```jsonc
|
|
22
|
+
{
|
|
23
|
+
"compilerOptions": {
|
|
24
|
+
"target": "ES2022",
|
|
25
|
+
"module": "NodeNext",
|
|
26
|
+
"moduleResolution": "NodeNext",
|
|
27
|
+
"strict": true,
|
|
28
|
+
"skipLibCheck": false,
|
|
29
|
+
"noEmit": true,
|
|
30
|
+
"types": [],
|
|
31
|
+
},
|
|
32
|
+
}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
`types: []` scopes ambient globals to none (a common strict-monorepo pattern); because the public API references `Buffer`, install `@types/node` as a devDependency — the shipped `.d.ts` resolves `Buffer` through its own import rather than an ambient global. The output relies on `verbatimModuleSyntax`; a bare `npm install -D typescript` within the tested range is fine.
|
|
36
|
+
|
|
37
|
+
## Quick start
|
|
38
|
+
|
|
39
|
+
The package ships both ESM and CommonJS builds. The `exports` map selects the correct build
|
|
40
|
+
automatically, but consumers still need the standard ESM/CJS project setup (e.g.
|
|
41
|
+
`"type": "module"` for ESM) — see the two forms below.
|
|
42
|
+
|
|
43
|
+
**ESM** (`import`) — requires `"type": "module"` in your `package.json` (or an ESM-capable
|
|
44
|
+
loader/bundler); a default `npm init -y` project is CommonJS and needs that field added first:
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
import { VaultKeeper } from 'vaultkeeper'
|
|
48
|
+
|
|
49
|
+
// 1. Initialize (runs doctor preflight checks). With no config file, the
|
|
50
|
+
// backend resolves to the safe `file` backend on every platform — never
|
|
51
|
+
// your real OS credential store. `vault.activeBackendType` is "file".
|
|
52
|
+
// To opt into the native store, pass an explicit config with
|
|
53
|
+
// `{ type: 'keychain' }` (macOS) / `{ type: 'dpapi' }` (Windows).
|
|
54
|
+
const vault = await VaultKeeper.init()
|
|
55
|
+
|
|
56
|
+
// 2. Store a secret in the configured backend
|
|
57
|
+
await vault.store('MY_API_KEY', 'my-secret-value')
|
|
58
|
+
|
|
59
|
+
// 3. Mint a JWE token for the stored secret. `setup()` requires an explicit
|
|
60
|
+
// executable-trust choice — exactly one of `executablePath` or
|
|
61
|
+
// `skipTrust: true` (the type system enforces this; omitting both, or
|
|
62
|
+
// passing both, is a compile error). Other options (ttlMinutes, useLimit,
|
|
63
|
+
// trustTier, ...) are optional; useLimit defaults to unlimited (null).
|
|
64
|
+
//
|
|
65
|
+
// LOCAL DEVELOPMENT (used here so this snippet is safe to re-run): skip
|
|
66
|
+
// verification with `{ skipTrust: true }`. It is safe to re-run after every
|
|
67
|
+
// rebuild — the token just carries no executable-identity binding, so use it
|
|
68
|
+
// only in development/tests, never in production.
|
|
69
|
+
const jwe = await vault.setup('MY_API_KEY', { skipTrust: true })
|
|
70
|
+
|
|
71
|
+
// PRODUCTION: bind the token to a STABLE executable so a swapped or tampered
|
|
72
|
+
// binary is rejected. Point `executablePath` at a released, stable artifact
|
|
73
|
+
// (e.g. '/usr/local/bin/my-tool'), or at the Node runtime itself via
|
|
74
|
+
// `process.execPath` ("trust the node binary").
|
|
75
|
+
//
|
|
76
|
+
// ⚠️ Do NOT use `executablePath: process.argv[1]` for a compiled or bundled
|
|
77
|
+
// entry point: its SHA-256 hash changes on every `tsc`/bundler rebuild, so
|
|
78
|
+
// the FIRST run records the hash and the NEXT run (after any source edit +
|
|
79
|
+
// recompile) throws `IdentityMismatchError`. For a caller you rebuild
|
|
80
|
+
// frequently but still want to verify, use Development mode instead (see
|
|
81
|
+
// "Development mode" below) rather than `skipTrust`.
|
|
82
|
+
//
|
|
83
|
+
// const jwe = await vault.setup('MY_API_KEY', { executablePath: '/usr/local/bin/my-tool' })
|
|
84
|
+
|
|
85
|
+
// 4. Authorize: decrypt and validate the token
|
|
86
|
+
const { token, vaultResponse } = await vault.authorize(jwe)
|
|
87
|
+
|
|
88
|
+
// 5. Delegated fetch — secret injected into the request, never returned
|
|
89
|
+
const { response } = await vault.fetch(token, {
|
|
90
|
+
url: 'https://api.example.com/data',
|
|
91
|
+
headers: { Authorization: 'Bearer {{secret}}' },
|
|
92
|
+
})
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
**CommonJS** (`require()`) — the `exports` map resolves the same package to a CJS build
|
|
96
|
+
automatically, no `"type"` field needed:
|
|
97
|
+
|
|
98
|
+
```js
|
|
99
|
+
const { VaultKeeper } = require('vaultkeeper')
|
|
100
|
+
|
|
101
|
+
async function main() {
|
|
102
|
+
const vault = await VaultKeeper.init()
|
|
103
|
+
await vault.store('MY_API_KEY', 'my-secret-value')
|
|
104
|
+
// `{ skipTrust: true }` (development only) keeps this snippet safe to re-run;
|
|
105
|
+
// for production bind to a stable executablePath instead — see the ESM step 3
|
|
106
|
+
// above for the rebuild caveat and the production shape.
|
|
107
|
+
const jwe = await vault.setup('MY_API_KEY', { skipTrust: true })
|
|
108
|
+
const { token } = await vault.authorize(jwe)
|
|
109
|
+
const { response } = await vault.fetch(token, {
|
|
110
|
+
url: 'https://api.example.com/data',
|
|
111
|
+
headers: { Authorization: 'Bearer {{secret}}' },
|
|
112
|
+
})
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
main()
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
> **Scope:** the access patterns below — `fetch()`, `exec()`, `getSecret()`, and `sign()`/`verify()`
|
|
119
|
+
> — are methods of **this TypeScript library's** `VaultKeeper` class. The
|
|
120
|
+
> [`@vaultkeeper/wasm`](https://www.npmjs.com/package/@vaultkeeper/wasm) SDK exposes a different,
|
|
121
|
+
> lower-level surface (`store()`/`retrieve()` plus a `setup()`/`authorize()` pair with different
|
|
122
|
+
> signatures) and does **not** provide these delegated patterns — see that package's README.
|
|
123
|
+
|
|
124
|
+
Other access patterns share the same capability-token flow (`setup()` → `authorize()`):
|
|
125
|
+
|
|
126
|
+
**Delegated `exec()`** injects the secret into a child process's environment — never its `argv`,
|
|
127
|
+
which is visible to other processes via `ps`. Placeholders are substituted in `env` values only
|
|
128
|
+
(passing `{{secret}}` in `command`/`args` throws `ExecError`):
|
|
129
|
+
|
|
130
|
+
<!-- readme-example: skip - fragment; `vault`/`token` come from the quick-start fence above -->
|
|
131
|
+
|
|
132
|
+
```ts
|
|
133
|
+
// `token` comes from authorize(), exactly like the fetch() example above.
|
|
134
|
+
const { result } = await vault.exec(token, {
|
|
135
|
+
command: 'curl',
|
|
136
|
+
args: ['-sS', 'https://api.example.com/data'],
|
|
137
|
+
env: { API_KEY: 'Bearer {{secret}}' }, // {{secret}} resolved just before spawn
|
|
138
|
+
})
|
|
139
|
+
console.log(result.stdout, result.exitCode)
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
**Output redaction (on by default).** If the child process echoes the injected secret, `exec()`
|
|
143
|
+
scrubs every occurrence of the secret value out of the captured `result.stdout` and `result.stderr`,
|
|
144
|
+
replacing each with `[REDACTED]` before returning — so the raw secret does not leak back through
|
|
145
|
+
captured output. This is the same redaction the [`@vaultkeeper/cli`](https://www.npmjs.com/package/@vaultkeeper/cli)
|
|
146
|
+
applies. With a `SecretTokenMap` (multiple secrets), **every** injected value is redacted. To opt
|
|
147
|
+
out and receive the raw, unredacted output — for example when you need to parse a payload that
|
|
148
|
+
legitimately contains the value — pass `redact: false` (mirroring the CLI's `--no-redact`):
|
|
149
|
+
|
|
150
|
+
<!-- readme-example: skip - fragment; `vault`/`token` come from the quick-start fence above -->
|
|
151
|
+
|
|
152
|
+
```ts
|
|
153
|
+
// Raw output — the injected secret is NOT scrubbed. Handle the result carefully;
|
|
154
|
+
// it may contain the secret verbatim.
|
|
155
|
+
const { result } = await vault.exec(token, {
|
|
156
|
+
command: 'my-tool',
|
|
157
|
+
env: { API_KEY: 'Bearer {{secret}}' },
|
|
158
|
+
redact: false,
|
|
159
|
+
})
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
**Controlled direct access** via `getSecret()` is different — unlike `fetch()`/`exec()`, it does
|
|
163
|
+
**not** take a callback of its own and returns **synchronously**. It hands back a `SecretAccessor`
|
|
164
|
+
whose secret is only reachable through a single-use `read(callback)` call backed by an auto-zeroing
|
|
165
|
+
buffer; no placeholder substitution is involved. `read()` returns whatever the callback returns, so
|
|
166
|
+
derive the value you need (a header string, a hash) inside the callback and capture that — never the
|
|
167
|
+
raw `Buffer`, which is zeroed before `read()` returns. A second `read()` throws `AccessorConsumedError`:
|
|
168
|
+
|
|
169
|
+
<!-- readme-example: skip - fragment; `vault`/`token` come from the quick-start fence above -->
|
|
170
|
+
|
|
171
|
+
```ts
|
|
172
|
+
// `token` comes from authorize(), exactly like the fetch()/exec() examples above.
|
|
173
|
+
const accessor = vault.getSecret(token) // synchronous — no await, no callback here
|
|
174
|
+
const authHeader = accessor.read((buf) => `Bearer ${buf.toString('utf8')}`)
|
|
175
|
+
// `authHeader` is now the derived string; the underlying buffer is already zeroed.
|
|
176
|
+
// accessor.read(...) // <- calling read() again throws AccessorConsumedError
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
`createSigningKey()`/`sign()`/`verify()` are a fourth pattern: signing keys are a distinct resource
|
|
180
|
+
whose private half never leaves the backend, and signatures are detached-payload Compact JWS values
|
|
181
|
+
any JOSE library can verify — see [Signing and verification](#signing-and-verification) below. See
|
|
182
|
+
the `SecretAccessor` and `ExecRequest` types in the package's shipped `.d.ts` for their full
|
|
183
|
+
signatures.
|
|
184
|
+
|
|
185
|
+
## Multiple secrets in one request
|
|
186
|
+
|
|
187
|
+
`fetch()` and `exec()` accept either a single `CapabilityToken` or a `SecretTokenMap`
|
|
188
|
+
(`Record<string, CapabilityToken>`) to inject several secrets into one call. With a map, reference
|
|
189
|
+
each secret by the **named** placeholder `{{secret:<name>}}`, where `<name>` is a key in the map,
|
|
190
|
+
instead of the bare `{{secret}}`:
|
|
191
|
+
|
|
192
|
+
<!-- readme-example: run - self-contained; executed against the built package with the network stubbed (issue #227) -->
|
|
193
|
+
|
|
194
|
+
```ts
|
|
195
|
+
import { VaultKeeper } from 'vaultkeeper'
|
|
196
|
+
|
|
197
|
+
const vault = await VaultKeeper.init()
|
|
198
|
+
|
|
199
|
+
// Store each secret first (as in the quick start), then authorize each
|
|
200
|
+
// independently and key the tokens by the names you'll reference in
|
|
201
|
+
// placeholders. `{ skipTrust: true }` (development only) is shown here; in
|
|
202
|
+
// production pass a stable `executablePath` instead — see [Trust tiers](#trust-tiers).
|
|
203
|
+
await vault.store('API_KEY', 'api-secret-value')
|
|
204
|
+
await vault.store('DB_PASSWORD', 'db-secret-value')
|
|
205
|
+
|
|
206
|
+
const apiJwe = await vault.setup('API_KEY', { skipTrust: true })
|
|
207
|
+
const dbJwe = await vault.setup('DB_PASSWORD', { skipTrust: true })
|
|
208
|
+
const { token: apiToken } = await vault.authorize(apiJwe)
|
|
209
|
+
const { token: dbToken } = await vault.authorize(dbJwe)
|
|
210
|
+
|
|
211
|
+
const { response } = await vault.fetch(
|
|
212
|
+
{ apiKey: apiToken, dbPassword: dbToken },
|
|
213
|
+
{
|
|
214
|
+
url: 'https://api.example.com/data',
|
|
215
|
+
headers: { Authorization: 'Bearer {{secret:apiKey}}' },
|
|
216
|
+
body: JSON.stringify({ db: '{{secret:dbPassword}}' }),
|
|
217
|
+
},
|
|
218
|
+
)
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
The same map and `{{secret:name}}` syntax work for `exec()` env values. A `{{secret:name}}` whose
|
|
222
|
+
`name` is not a key in the map fails the call, but the error type differs by method: `fetch()`
|
|
223
|
+
surfaces the underlying `VaultError`, while `exec()` wraps that same failure as an `ExecError`. The
|
|
224
|
+
two modes don't mix within a single call: a single-token call resolves only `{{secret}}`, and a map
|
|
225
|
+
call resolves only `{{secret:name}}`.
|
|
226
|
+
|
|
227
|
+
## Example config
|
|
228
|
+
|
|
229
|
+
`VaultKeeper.init()` works with no config file (it resolves to the safe `file` backend). To pin
|
|
230
|
+
the configuration explicitly — e.g. to set a non-default TTL or trust tier — write a config file
|
|
231
|
+
matching this shape. This example is safe-by-default: it uses the portable `file` backend, not
|
|
232
|
+
your OS credential store.
|
|
233
|
+
|
|
234
|
+
> **`VaultKeeper.init()` does not write a `config.json`.** `init()` is a **runtime, in-memory**
|
|
235
|
+
> operation: with no config file it loads built-in defaults into the process and writes nothing to
|
|
236
|
+
> disk (it only ever reads an existing config). To **persist** a `config.json` on disk, use the CLI
|
|
237
|
+
> `vaultkeeper config init` (from [`@vaultkeeper/cli`](https://www.npmjs.com/package/@vaultkeeper/cli)),
|
|
238
|
+
> which writes the file shown below. So `init()` never creates the config file — don't expect a
|
|
239
|
+
> `config.json` to appear after calling it; write one yourself (below) or run `config init`.
|
|
240
|
+
|
|
241
|
+
```json
|
|
242
|
+
{
|
|
243
|
+
"version": 1,
|
|
244
|
+
"backends": [{ "type": "file", "enabled": true }],
|
|
245
|
+
"keyRotation": { "gracePeriodDays": 7 },
|
|
246
|
+
"defaults": { "ttlMinutes": 60, "trustTier": 3 }
|
|
247
|
+
}
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
- `backends` — ordered list of backend configs; whichever entry is **first** with `"enabled": true`
|
|
251
|
+
becomes the single active backend — there's no automatic fallback to a later entry. To switch to
|
|
252
|
+
an OS-native store, put an entry with `"type": "keychain"` (macOS), `"dpapi"` (Windows), or
|
|
253
|
+
`"secret-tool"` (Linux) ahead of (or in place of) the `file` entry.
|
|
254
|
+
- `keyRotation.gracePeriodDays` — how many days the previous encryption key stays valid for
|
|
255
|
+
decrypting existing tokens after `rotateKey()` runs; see [Key rotation](#key-rotation) below.
|
|
256
|
+
- `defaults.ttlMinutes` / `defaults.trustTier` — applied to `setup()` when its options don't
|
|
257
|
+
override them; see [Trust tiers](#trust-tiers) below.
|
|
258
|
+
|
|
259
|
+
### Full `VaultConfig` reference
|
|
260
|
+
|
|
261
|
+
Every field of the config object (the `VaultConfig` interface, also carried on the package's
|
|
262
|
+
shipped `.d.ts`):
|
|
263
|
+
|
|
264
|
+
| Field | Type | Required | Meaning |
|
|
265
|
+
| ----------------------------- | ----------------- | -------- | ---------------------------------------------------------------------------------------------------------- |
|
|
266
|
+
| `version` | `number` | yes | Config schema version. Currently must be `1`. |
|
|
267
|
+
| `backends` | `BackendConfig[]` | yes | Ordered backend list; the **first** entry with `enabled: true` is the single active backend (no fallback). |
|
|
268
|
+
| `keyRotation.gracePeriodDays` | `number` | yes | Days the previous key stays valid for decryption after `rotateKey()` before it is retired. |
|
|
269
|
+
| `defaults.ttlMinutes` | `number` | yes | Default JWE time-to-live applied by `setup()` when its `ttlMinutes` option is omitted. |
|
|
270
|
+
| `defaults.trustTier` | `1 \| 2 \| 3` | yes | Default policy trust-tier label applied by `setup()` when its `trustTier` option is omitted. |
|
|
271
|
+
| `developmentMode.executables` | `string[]` | no | Executable paths exempted from TOFU identity verification (see [Development mode](#development-mode)). |
|
|
272
|
+
|
|
273
|
+
Each `BackendConfig` entry in `backends`:
|
|
274
|
+
|
|
275
|
+
| Field | Type | Required | Meaning |
|
|
276
|
+
| --------- | ------------------------ | -------- | ------------------------------------------------------------------------------------------------- |
|
|
277
|
+
| `type` | `string` | yes | Backend type: `'file'`, `'keychain'`, `'dpapi'`, `'secret-tool'`, `'1password'`, `'yubikey'`. |
|
|
278
|
+
| `enabled` | `boolean` | yes | Whether this backend is active. Only enabled backends are considered during initialization. |
|
|
279
|
+
| `plugin` | `boolean` | no | `true` for plugin-provided backends (1Password, YubiKey) rather than built-in ones. |
|
|
280
|
+
| `path` | `string` | no | Storage directory for file-based backends. Defaults to `<configDir>/file` for the `file` backend. |
|
|
281
|
+
| `options` | `Record<string, string>` | no | Backend-specific options collected during interactive setup. |
|
|
282
|
+
|
|
283
|
+
## Key rotation
|
|
284
|
+
|
|
285
|
+
`rotateKey()` replaces the active encryption key but keeps the previous one valid for decryption
|
|
286
|
+
for `keyRotation.gracePeriodDays` days, so tokens minted before a rotation don't break immediately.
|
|
287
|
+
Once the grace period elapses, the retired key is dropped and JWEs encrypted under it can no
|
|
288
|
+
longer be decrypted.
|
|
289
|
+
|
|
290
|
+
## Trust tiers
|
|
291
|
+
|
|
292
|
+
In this TypeScript library, executable identity verification against a local trust-on-first-use
|
|
293
|
+
(TOFU) manifest runs when `VaultKeeper.setup()` is given a real `executablePath`. **This library's
|
|
294
|
+
`setup()` requires an explicit executable-trust choice — it has no default and never silently skips
|
|
295
|
+
verification.** (The separate [`@vaultkeeper/wasm`](https://www.npmjs.com/package/@vaultkeeper/wasm)
|
|
296
|
+
SDK also requires the explicit choice, but its `executablePath` is only a **claim label** — the WASM
|
|
297
|
+
`setup()` binds that path into the token without hashing the executable or running any TOFU
|
|
298
|
+
verification, so it does not detect a changed or unrecognized binary the way this library does. Only
|
|
299
|
+
this TypeScript library's `setup()` actually verifies executable identity. See that package's API
|
|
300
|
+
reference and [#165](https://github.com/mike-north/vaultkeeper/issues/165) for the parity gap.) Pass
|
|
301
|
+
the caller's real path to
|
|
302
|
+
protect a production caller: `vault.setup('MY_API_KEY', { executablePath: '/usr/local/bin/my-tool'
|
|
303
|
+
})`. To deliberately skip verification during development, pass the explicit, greppable opt-out
|
|
304
|
+
`vault.setup('MY_API_KEY', { skipTrust: true })` instead — a token minted this way carries no
|
|
305
|
+
executable identity binding, so use it only in local development or tests, never in production.
|
|
306
|
+
Calling `setup()` with neither option (or both) is rejected at **compile time** — the `SetupOptions`
|
|
307
|
+
type requires exactly one, and the options argument is mandatory, so `vault.setup('MY_API_KEY')` and
|
|
308
|
+
`vault.setup('MY_API_KEY', {})` fail to typecheck; `ExecutableTrustRequiredError` remains the runtime
|
|
309
|
+
backstop for untyped (plain-JavaScript) callers. Once a real
|
|
310
|
+
path is passed, the caller's hash is either already approved (trusted), unrecognized (first
|
|
311
|
+
encounter, recorded automatically), or changed since it was approved (a conflict, which rejects the
|
|
312
|
+
call until re-approved via `approveExecutable()`).
|
|
313
|
+
|
|
314
|
+
> **Approving a new caller — library vs. CLI.** In this library, a new caller's first `setup()` with
|
|
315
|
+
> a real `executablePath` is **recorded automatically** on first encounter (TOFU), so no separate
|
|
316
|
+
> approval step is required — there is no interactive prompt to answer. This differs from the
|
|
317
|
+
> [`@vaultkeeper/cli`](https://www.npmjs.com/package/@vaultkeeper/cli): its `exec` **prompts** to
|
|
318
|
+
> approve an unrecognized caller, and in a **non-interactive/CI** context (no TTY) that first `exec`
|
|
319
|
+
> **requires** a prior `vaultkeeper approve` (or `--yes`) — pre-approval there is a hard prerequisite,
|
|
320
|
+
> not just a prompt-avoidance convenience. Use `approveExecutable()` in this library to pre-record a
|
|
321
|
+
> caller ahead of time, or to re-approve after a hash conflict.
|
|
322
|
+
|
|
323
|
+
Separately, `trustTier` (`1`, `2`, or `3`) is a
|
|
324
|
+
policy **label** attached to the resulting token — it does not itself reflect the outcome of that
|
|
325
|
+
verification, and it has no effect when verification is skipped via `skipTrust`. It defaults to
|
|
326
|
+
`defaults.trustTier` from the config and can be overridden per call via `setup()`'s `trustTier`
|
|
327
|
+
option.
|
|
328
|
+
|
|
329
|
+
## Development mode
|
|
330
|
+
|
|
331
|
+
The `setDevelopmentMode` allowlist is a second, separate way to bypass the TOFU check above for a
|
|
332
|
+
_specific_ executable path — distinct from the per-call `{ skipTrust: true }` opt-out (which names
|
|
333
|
+
no path and binds no executable identity). Use the allowlist when you want to keep passing a real
|
|
334
|
+
`executablePath` (so a later `setDevelopmentMode(path, false)` re-enables verification for it) while
|
|
335
|
+
a binary that's rebuilt frequently during local development doesn't get rejected as an
|
|
336
|
+
`IdentityMismatchError` every time its hash changes. Add an executable with `await vault.setDevelopmentMode(path, true)` (persisted in
|
|
337
|
+
`config.developmentMode.executables`). Only use this for local workflows — remove the executable
|
|
338
|
+
from the list (or don't add it) so a production caller stays on TOFU verification.
|
|
339
|
+
|
|
340
|
+
<!-- readme-example: skip - fragment; `vault` comes from the quick-start fence above -->
|
|
341
|
+
|
|
342
|
+
```ts
|
|
343
|
+
// Persist an executable as dev-mode-exempt across setup() calls, while still
|
|
344
|
+
// passing its real path (so re-enabling verification later is a one-line change):
|
|
345
|
+
await vault.setDevelopmentMode('/path/to/my-dev-tool', true)
|
|
346
|
+
const jwe = await vault.setup('MY_API_KEY', { executablePath: '/path/to/my-dev-tool' })
|
|
347
|
+
|
|
348
|
+
// Re-enable TOFU verification for that executable:
|
|
349
|
+
await vault.setDevelopmentMode('/path/to/my-dev-tool', false)
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
## Signing and verification
|
|
353
|
+
|
|
354
|
+
Signing keys are a distinct resource from secrets. A signing key's private half never flows through
|
|
355
|
+
`store()`/`retrieve()`/`fetch()`/`exec()` or a capability token's claims: the backend generates the
|
|
356
|
+
key, exposes only its public half, and performs each signature itself, so the key never leaves the
|
|
357
|
+
backend. Signatures are detached-payload Compact JWS values verifiable by any JOSE library.
|
|
358
|
+
|
|
359
|
+
**Name rule.** Signing keys live under a reserved internal `signing-key:<name>` namespace, so a
|
|
360
|
+
secret name and a signing-key name can never collide. To keep that guarantee, name-creating and
|
|
361
|
+
name-binding calls — `store()`, `setup()`, `createSigningKey()`, `exportPublicKey()`,
|
|
362
|
+
`authorizeSigningKey()` — reject a `name` containing `':'` with a `VaultError`. Read/delete/existence
|
|
363
|
+
calls (`delete()`, `secretExists()`) stay permissive, so a legacy secret whose name happens to
|
|
364
|
+
contain `':'` remains reachable for inspection and cleanup.
|
|
365
|
+
|
|
366
|
+
<!-- readme-example: skip - fragment; `vault` comes from the quick-start fence above -->
|
|
367
|
+
|
|
368
|
+
```ts
|
|
369
|
+
// 1. Enroll a signing key (backend-side; the `file` backend supports this today)
|
|
370
|
+
const { publicKeyPem, kid } = await vault.createSigningKey('approval-signing-key', 'EdDSA')
|
|
371
|
+
|
|
372
|
+
// 2. Export the SPKI PEM public key any time
|
|
373
|
+
const pub = await vault.exportPublicKey('approval-signing-key')
|
|
374
|
+
|
|
375
|
+
// 3. Sign an arbitrary payload. authorizeSigningKey() mints a token carrying
|
|
376
|
+
// only { kid, backendRef, keyType } — never key material.
|
|
377
|
+
const token = await vault.authorizeSigningKey('approval-signing-key')
|
|
378
|
+
const { result } = await vault.sign(token, { payload: 'payload-to-sign' })
|
|
379
|
+
console.log(result.jws) // detached compact JWS: <protected>..<signature>
|
|
380
|
+
|
|
381
|
+
// 4. Verify — a static, offline method: no instance, backend, secret, or token
|
|
382
|
+
const isValid = await VaultKeeper.verify({
|
|
383
|
+
payload: 'payload-to-sign',
|
|
384
|
+
jws: result.jws,
|
|
385
|
+
publicKey: pub.publicKeyPem,
|
|
386
|
+
})
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
The signature format is a detached-payload Compact JWS — algorithm `EdDSA` (Ed25519); base64url
|
|
390
|
+
without padding ([RFC 7515](https://www.rfc-editor.org/rfc/rfc7515)); detached payload via
|
|
391
|
+
[RFC 7797](https://www.rfc-editor.org/rfc/rfc7797) `b64:false`, `crit:["b64"]` — so any
|
|
392
|
+
standards-compliant JOSE library can verify it without vaultkeeper. `verify()` returns `false` for a
|
|
393
|
+
signature that does not check out (tampered payload, wrong key, malformed JWS) and throws
|
|
394
|
+
`InvalidKeyMaterialError` only when the public key itself is unparseable. A backend that cannot sign
|
|
395
|
+
fails with a typed `SigningNotSupportedError` naming the backends that can — never a silent
|
|
396
|
+
emulation.
|
|
397
|
+
|
|
398
|
+
## Backends
|
|
399
|
+
|
|
400
|
+
The first enabled backend in the configuration is used. With no config file, that is the safe
|
|
401
|
+
`file` backend (AES-256-GCM encrypted file, all platforms, no system dependencies) — the zero-config
|
|
402
|
+
default on every platform. Configure a different backend explicitly to opt in: `keychain` (macOS),
|
|
403
|
+
`dpapi` (Windows), or `secret-tool` (Linux, via `libsecret`). Plugin backends for 1Password and
|
|
404
|
+
YubiKey are also available.
|
|
405
|
+
|
|
406
|
+
With no explicit `path`, the `file` backend stores secrets under `<configDir>/file` — the same
|
|
407
|
+
resolved config directory (`~/.config/vaultkeeper` by default) that holds `config.json` and key
|
|
408
|
+
material.
|
|
409
|
+
|
|
410
|
+
## Presence-per-use (require a fresh human action)
|
|
411
|
+
|
|
412
|
+
Some operations should only proceed when a **fresh, deliberate human action happens for that
|
|
413
|
+
operation, right now** — a distinct touch or biometric approval that can never be satisfied from a
|
|
414
|
+
cached or session-unlocked state. This is stronger than "a vault was unlocked at some point": a
|
|
415
|
+
cached unlock would let an automated or compromised caller ride a human action taken for something
|
|
416
|
+
else.
|
|
417
|
+
|
|
418
|
+
vaultkeeper models this as a per-configured-instance backend capability, `presencePerUse`, and lets
|
|
419
|
+
you require it for a specific operation.
|
|
420
|
+
|
|
421
|
+
**Query a backend's capabilities:**
|
|
422
|
+
|
|
423
|
+
<!-- readme-example: skip - fragment; `vault` comes from the quick-start fence above -->
|
|
424
|
+
|
|
425
|
+
```ts
|
|
426
|
+
const caps = await vault.getActiveBackendCapabilities()
|
|
427
|
+
if (!caps.presencePerUse) {
|
|
428
|
+
// the active backend cannot force a fresh per-use action
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// Or, for any backend instance, without assuming from its type:
|
|
432
|
+
import { getBackendCapabilities } from 'vaultkeeper'
|
|
433
|
+
const { presencePerUse } = await getBackendCapabilities(someBackend)
|
|
434
|
+
```
|
|
435
|
+
|
|
436
|
+
`getBackendCapabilities()` returns `{ presencePerUse: false }` for any backend that does not
|
|
437
|
+
implement the capability interface — an unknown backend never silently claims presence.
|
|
438
|
+
|
|
439
|
+
**Require it for an operation:** pass `requirePresencePerUse: true` to `store`, `delete`, `setup`,
|
|
440
|
+
or `sign`. When the active backend cannot guarantee it, the call throws `NotCapableError` **before
|
|
441
|
+
any credential, session, or device is touched**. When the backend is capable, the operation forces a
|
|
442
|
+
fresh human action for that specific call (a declined action throws `PresenceDeclinedError`; a
|
|
443
|
+
timeout throws `PresenceTimeoutError`):
|
|
444
|
+
|
|
445
|
+
<!-- readme-example: skip - fragment; `vault` comes from the quick-start fence above -->
|
|
446
|
+
|
|
447
|
+
```ts
|
|
448
|
+
// Presence-gated signing: each sign performs a fresh backend round-trip, so no
|
|
449
|
+
// cached key material can satisfy it (the private key never leaves the backend).
|
|
450
|
+
const token = await vault.authorizeSigningKey('approval')
|
|
451
|
+
const { result } = await vault.sign(token, { payload }, { requirePresencePerUse: true })
|
|
452
|
+
```
|
|
453
|
+
|
|
454
|
+
Capabilities are queried **fresh on every call** and never cached across operations, so two
|
|
455
|
+
consecutive required-presence operations each demand their own distinct fresh action.
|
|
456
|
+
|
|
457
|
+
### Per-backend truth basis
|
|
458
|
+
|
|
459
|
+
| Backend | `presencePerUse` | Basis |
|
|
460
|
+
| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
461
|
+
| `file`, `keychain`, `dpapi`, `secret-tool` | always `false` | Encryption-only or a cached/unattended unlock — no distinct per-use human action. |
|
|
462
|
+
| `yubikey` | `true` only when the configured slot enforces touch-per-operation (`options.touchPolicy: "required"`) | Every challenge-response forces a physical tap. Verify the slot's real policy with `ykman otp info`. Derived from configuration, never from the backend type. |
|
|
463
|
+
| `1password` | `true` only in `per-access` mode (`options.accessMode: "per-access"`), covering `read`, `store`, and `delete` | A fresh worker/SDK client is spawned per operation — reads, stores, and deletes each trigger their own biometric approval instead of reusing the cached session client. `exists`/`list` remain read-only probes on the cached session client. |
|
|
464
|
+
|
|
465
|
+
> **Operation coverage (enforced, not advisory).** 1Password `per-access` forces a fresh biometric
|
|
466
|
+
> for **every keyed operation** — `read` (the secret read behind `setup`/`exec`), `store`, and
|
|
467
|
+
> `delete` each spawn their own worker process and their own fresh approval (issue #211 closed the
|
|
468
|
+
> earlier gap where `store`/`delete` rode the cached session client). A touch device (YubiKey with a
|
|
469
|
+
> touch slot) enforces presence for every operation the same way and has no restriction to model. This
|
|
470
|
+
> coverage is expressed generically via `BackendCapabilities.presenceEnforcedOperations` (omitted =
|
|
471
|
+
> all operations), not a per-type special case — a backend that only covers _some_ operations (were
|
|
472
|
+
> one added later) would still refuse an uncovered `--require-presence-per-use` request with
|
|
473
|
+
> `NotCapableError` rather than silently pass it through a cached session.
|
|
474
|
+
|
|
475
|
+
> **Cached-OS-unlock caveat.** A "fresh process / SDK client" is **not** the same as a guaranteed
|
|
476
|
+
> fresh hardware action. 1Password `per-access` re-creates the client per operation, but the OS may
|
|
477
|
+
> still satisfy the biometric from a cached Touch ID / Windows Hello unlock without re-prompting — so
|
|
478
|
+
> its guarantee is "fresh SDK client plus whatever the OS enforces at that moment." The strongest
|
|
479
|
+
> per-use guarantee comes from a dedicated touch device (YubiKey / gpg smartcard), where the tap is
|
|
480
|
+
> intrinsic to the cryptographic operation.
|
|
481
|
+
|
|
482
|
+
Real-hardware confirmation for each backend is documented as a manual verification test in
|
|
483
|
+
[`docs/manual-tests/presence-per-use.md`](../../docs/manual-tests/presence-per-use.md).
|
|
484
|
+
|
|
485
|
+
## Doctor / preflight checks
|
|
486
|
+
|
|
487
|
+
`VaultKeeper.init()` runs a preflight check pass (the same checks the `runDoctor()` export and the
|
|
488
|
+
CLI's `vaultkeeper doctor` / WASM `doctor()` surface). Each check reports whether a dependency
|
|
489
|
+
binary was found, and every result is classified **required** or **informational**:
|
|
490
|
+
|
|
491
|
+
> **Why entries for backends you aren't using appear:** doctor probes the tooling for **all**
|
|
492
|
+
> supported backends, not just the active one — so even with only the `file` backend enabled you'll
|
|
493
|
+
> still see `security`/`op`/`ykman` entries. That's deliberate (an at-a-glance inventory of what's
|
|
494
|
+
> installed), not noise. Whether a given entry actually gates readiness follows the required-vs-
|
|
495
|
+
> informational split below: the plugin tools (`op`/`ykman`) stay informational until you enable
|
|
496
|
+
> their backend, while the platform-native tool (`security`/`powershell`/`secret-tool`) is required
|
|
497
|
+
> by default and demoted to informational only when the run is scoped to backends (as
|
|
498
|
+
> `VaultKeeper.init()` does).
|
|
499
|
+
|
|
500
|
+
- **Required** checks gate readiness. A required check that fails makes the overall result
|
|
501
|
+
**not ready** and produces a remediation next-step. `openssl` is always required. By default —
|
|
502
|
+
when `runDoctor()` is not scoped to a set of backends — the platform's native credential tool
|
|
503
|
+
(`security` on macOS, `powershell` on Windows, `secret-tool` on Linux) is **also required**;
|
|
504
|
+
scoping the run to specific backends (e.g. via the `backends`/`configDir` inputs, as
|
|
505
|
+
`VaultKeeper.init()` does from your config) narrows that to only the native tool for an enabled
|
|
506
|
+
backend, demoting the others to informational.
|
|
507
|
+
- **Informational** checks never fail readiness — a failure is reported as a **warning** only.
|
|
508
|
+
The plugin-backend binaries `op` (1Password) and `ykman` (YubiKey) are always listed but stay
|
|
509
|
+
informational only while their backend isn't enabled. So with just the default `file` backend
|
|
510
|
+
enabled, `op`/`ykman` still appear in the output and a failing one will not block `ready`. But
|
|
511
|
+
enabling the corresponding backend (`1password` → `op`, `yubikey` → `ykman`) **promotes that
|
|
512
|
+
check to required**, so a missing tool then does block `ready`.
|
|
513
|
+
|
|
514
|
+
A checkmark next to a plugin check therefore means **the binary was detected on `PATH`**, not that
|
|
515
|
+
the backend is active or configured — e.g. a green `op` means the 1Password CLI is installed, not
|
|
516
|
+
that a 1Password backend is enabled. To actually route secrets through a plugin backend you must
|
|
517
|
+
add it to `backends` (see [Backends](#backends)).
|
|
518
|
+
|
|
519
|
+
## Error types
|
|
520
|
+
|
|
521
|
+
Every error this package throws extends `VaultError`. Catch `VaultError` to handle any of them
|
|
522
|
+
generically, or catch a specific subclass for targeted handling. The complete hierarchy — every
|
|
523
|
+
subclass, grouped by concern — follows. Classes listing extra fields expose them as strongly-typed
|
|
524
|
+
read-only properties for machine-readable context.
|
|
525
|
+
|
|
526
|
+
**Backend access**
|
|
527
|
+
|
|
528
|
+
| Class | When thrown |
|
|
529
|
+
| -------------------------- | ------------------------------------------------------------------------------------------ |
|
|
530
|
+
| `SecretNotFoundError` | Requested secret does not exist in the backend store. |
|
|
531
|
+
| `BackendUnavailableError` | No configured backend is available or reachable (fields: `reason`, `attempted`). |
|
|
532
|
+
| `BackendLockedError` | Backend/credential store is locked and needs an interactive unlock (field: `interactive`). |
|
|
533
|
+
| `DeviceNotPresentError` | A required hardware device (e.g. YubiKey) is not connected (field: `timeoutMs`). |
|
|
534
|
+
| `AuthorizationDeniedError` | The user explicitly denied an OS authorization prompt. |
|
|
535
|
+
| `PluginNotFoundError` | A required backend plugin is not installed (fields: `plugin`, `installUrl`). |
|
|
536
|
+
|
|
537
|
+
**JWE / token lifecycle**
|
|
538
|
+
|
|
539
|
+
| Class | When thrown |
|
|
540
|
+
| ------------------------- | ----------------------------------------------------------------------------------- |
|
|
541
|
+
| `TokenExpiredError` | JWE has passed its `exp` claim (field: `canRefresh`). |
|
|
542
|
+
| `KeyRotatedError` | Encryption key rotated out of its grace period; the JWE can no longer be decrypted. |
|
|
543
|
+
| `KeyRevokedError` | Encryption key referenced by the JWE's `kid` header was explicitly revoked. |
|
|
544
|
+
| `TokenRevokedError` | JWE was explicitly blocked (e.g. a single-use token that was already consumed). |
|
|
545
|
+
| `UsageLimitExceededError` | Token was presented more times than its `use` limit allows. |
|
|
546
|
+
| `InvalidTokenError` | JWE is malformed, fails decryption, or its decrypted claims do not validate. |
|
|
547
|
+
|
|
548
|
+
**Identity & trust**
|
|
549
|
+
|
|
550
|
+
| Class | When thrown |
|
|
551
|
+
| ------------------------------ | --------------------------------------------------------------------------------------------------------------------- |
|
|
552
|
+
| `IdentityMismatchError` | Executable hash changed since TOFU approval (fields: `previousHash`, `currentHash`; see [Trust tiers](#trust-tiers)). |
|
|
553
|
+
| `ExecutableTrustRequiredError` | `setup()` called with no clear trust choice — neither `executablePath` nor `skipTrust` (field: `reason`). |
|
|
554
|
+
|
|
555
|
+
**Access patterns**
|
|
556
|
+
|
|
557
|
+
| Class | When thrown |
|
|
558
|
+
| ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
559
|
+
| `FetchError` | Delegated `fetch()` failed before a `Response` (malformed URL, network failure) (field: `url`). |
|
|
560
|
+
| `ExecError` | `exec()` request was invalid, or the command could not be started (field: `command`). |
|
|
561
|
+
| `AccessorConsumedError` | `SecretAccessor.read()` called after it was already consumed. |
|
|
562
|
+
| `SigningKeyNotFoundError` | A named signing key does not exist (field: `keyName`); distinct from `SecretNotFoundError` — signing keys occupy their own namespace (see [Signing and verification](#signing-and-verification)). |
|
|
563
|
+
| `SigningKeyAlreadyExistsError` | Enrolling a signing key whose name already exists (field: `keyName`); enrollment never overwrites (that would break pinned public keys). |
|
|
564
|
+
| `SigningNotSupportedError` | The active backend does not implement the signing contract; names the built-in backend that does — a custom backend may implement `SigningBackend` (fields: `backendType`, `builtInSigningBackends`). |
|
|
565
|
+
| `InvalidAlgorithmError` | `createSigningKey()` with an unsupported signing algorithm — strict JOSE identifiers, only `EdDSA` today (fields: `algorithm`, `allowed`). |
|
|
566
|
+
| `InvalidKeyMaterialError` | `verify()` given an unparseable public key, or a corrupt/tampered stored signing key — an operational fault, distinct from a signature that simply does not verify. |
|
|
567
|
+
|
|
568
|
+
**Config, filesystem & key rotation**
|
|
569
|
+
|
|
570
|
+
| Class | When thrown |
|
|
571
|
+
| ------------------------- | -------------------------------------------------------------------------------------------------------- |
|
|
572
|
+
| `DecryptionError` | An encrypted-at-rest entry could not be decrypted (field: `path`). |
|
|
573
|
+
| `ConfigValidationError` | A config value failed structural/semantic validation (fields: `field`, `configFilePath`). |
|
|
574
|
+
| `ConfigParseError` | Config file contents are not valid JSON (fields: `path`, `location`). |
|
|
575
|
+
| `SetupError` | A required system dependency is missing or incompatible at init (field: `dependency`). |
|
|
576
|
+
| `FilesystemError` | A filesystem operation failed — permission, ENOSPC, EISDIR, etc. (fields: `path`, `permission`, `code`). |
|
|
577
|
+
| `RotationInProgressError` | `rotateKey()` called while a previous rotation's grace period is still active. |
|
|
578
|
+
|
|
579
|
+
Each class's JSDoc (carried on the package's shipped `.d.ts`) documents these same errors and
|
|
580
|
+
fields inline. The [repository README](https://github.com/mike-north/vaultkeeper#readme) covers
|
|
581
|
+
related narrative online, but this package's own README (above) and its shipped `.d.ts` are the
|
|
582
|
+
authoritative, complete list.
|
|
583
|
+
|
|
584
|
+
## Testing against this library
|
|
585
|
+
|
|
586
|
+
Use [`@vaultkeeper/test-helpers`](https://www.npmjs.com/package/@vaultkeeper/test-helpers) for an
|
|
587
|
+
in-memory backend and a pre-configured `TestVault` with zero OS dependencies in your own test
|
|
588
|
+
suite. Install it as a **devDependency** — a combined `npm i vaultkeeper @vaultkeeper/test-helpers`
|
|
589
|
+
would place it in `dependencies`, so install it separately:
|
|
590
|
+
|
|
591
|
+
```sh
|
|
592
|
+
pnpm add -D @vaultkeeper/test-helpers
|
|
593
|
+
```
|
|
594
|
+
|
|
595
|
+
> **Note:** `TestVault` and its `setup()` convenience default the trust choice to `skipTrust` so
|
|
596
|
+
> tests stay hermetic. The real `VaultKeeper.setup()` has **no default** — it always requires
|
|
597
|
+
> either `executablePath` (TOFU verification) or an explicit `{ skipTrust: true }`, and throws
|
|
598
|
+
> `ExecutableTrustRequiredError` if given neither. Don't copy a bare `setup('NAME')` out of a test
|
|
599
|
+
> into non-test code (see [Trust tiers](#trust-tiers)).
|
|
600
|
+
|
|
601
|
+
## Full documentation
|
|
602
|
+
|
|
603
|
+
This README is self-contained: the full error hierarchy, development-mode narrative, and complete
|
|
604
|
+
`VaultConfig` reference above are all shipped inside the package (no network access required). The
|
|
605
|
+
package's `.d.ts` files carry the same reference on every exported type, method, and option via
|
|
606
|
+
JSDoc. The [repository README](https://github.com/mike-north/vaultkeeper#readme) covers related
|
|
607
|
+
narrative online, but treat this package's own README as the complete, authoritative reference.
|
|
608
|
+
|
|
609
|
+
## License
|
|
610
|
+
|
|
611
|
+
MIT
|