vaultkeeper 0.6.0 → 0.7.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/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