@centient/secrets 0.4.0 → 0.6.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 (64) hide show
  1. package/README.md +2 -0
  2. package/dist/cli/hidden-input.d.ts +52 -0
  3. package/dist/cli/hidden-input.d.ts.map +1 -0
  4. package/dist/cli/hidden-input.js +109 -0
  5. package/dist/cli/hidden-input.js.map +1 -0
  6. package/dist/cli/secrets-cli.d.ts +9 -0
  7. package/dist/cli/secrets-cli.d.ts.map +1 -1
  8. package/dist/cli/secrets-cli.js +251 -169
  9. package/dist/cli/secrets-cli.js.map +1 -1
  10. package/dist/crypto/vault-common.d.ts +23 -4
  11. package/dist/crypto/vault-common.d.ts.map +1 -1
  12. package/dist/crypto/vault-common.js +47 -6
  13. package/dist/crypto/vault-common.js.map +1 -1
  14. package/dist/index.d.ts +5 -0
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +8 -0
  17. package/dist/index.js.map +1 -1
  18. package/dist/vault/file-lock.d.ts +33 -0
  19. package/dist/vault/file-lock.d.ts.map +1 -0
  20. package/dist/vault/file-lock.js +143 -0
  21. package/dist/vault/file-lock.js.map +1 -0
  22. package/dist/vault/policy.d.ts +50 -0
  23. package/dist/vault/policy.d.ts.map +1 -0
  24. package/dist/vault/policy.js +68 -0
  25. package/dist/vault/policy.js.map +1 -0
  26. package/dist/vault/session-vault-errors.d.ts +38 -0
  27. package/dist/vault/session-vault-errors.d.ts.map +1 -0
  28. package/dist/vault/session-vault-errors.js +67 -0
  29. package/dist/vault/session-vault-errors.js.map +1 -0
  30. package/dist/vault/session-vault.d.ts +147 -0
  31. package/dist/vault/session-vault.d.ts.map +1 -0
  32. package/dist/vault/session-vault.js +667 -0
  33. package/dist/vault/session-vault.js.map +1 -0
  34. package/dist/vault/sidecar.d.ts +37 -0
  35. package/dist/vault/sidecar.d.ts.map +1 -0
  36. package/dist/vault/sidecar.js +84 -0
  37. package/dist/vault/sidecar.js.map +1 -0
  38. package/dist/vault/types.d.ts +7 -4
  39. package/dist/vault/types.d.ts.map +1 -1
  40. package/dist/vault/vault-env.d.ts +1 -1
  41. package/dist/vault/vault-env.d.ts.map +1 -1
  42. package/dist/vault/vault-env.js +1 -1
  43. package/dist/vault/vault-env.js.map +1 -1
  44. package/dist/vault/vault-gpg.d.ts +1 -1
  45. package/dist/vault/vault-gpg.d.ts.map +1 -1
  46. package/dist/vault/vault-gpg.js +1 -1
  47. package/dist/vault/vault-gpg.js.map +1 -1
  48. package/dist/vault/vault-libsecret.d.ts +28 -14
  49. package/dist/vault/vault-libsecret.d.ts.map +1 -1
  50. package/dist/vault/vault-libsecret.js +76 -15
  51. package/dist/vault/vault-libsecret.js.map +1 -1
  52. package/dist/vault/vault-utils.d.ts +12 -2
  53. package/dist/vault/vault-utils.d.ts.map +1 -1
  54. package/dist/vault/vault-utils.js +21 -4
  55. package/dist/vault/vault-utils.js.map +1 -1
  56. package/dist/vault/vault-windows.d.ts +1 -1
  57. package/dist/vault/vault-windows.d.ts.map +1 -1
  58. package/dist/vault/vault-windows.js +1 -1
  59. package/dist/vault/vault-windows.js.map +1 -1
  60. package/dist/vault/vault.d.ts +5 -4
  61. package/dist/vault/vault.d.ts.map +1 -1
  62. package/dist/vault/vault.js +83 -8
  63. package/dist/vault/vault.js.map +1 -1
  64. package/package.json +4 -1
@@ -0,0 +1,667 @@
1
+ /**
2
+ * SessionVault — public session-backed envelope vault API.
3
+ *
4
+ * Opens the CLI's encrypted vault file once per session (one KeyProvider
5
+ * prompt), caches the decrypted contents in RAM, and serves reads without
6
+ * further prompts. External writes (e.g. the CLI in another shell) become
7
+ * visible via mtime-check coherence on every read.
8
+ *
9
+ * Addresses the per-item Keychain-prompt problem flagged in issue #40:
10
+ * long-running daemons (centient-labs/maintainer) holding N credentials
11
+ * across a long lifetime should not reach into the OS keychain on every
12
+ * access. Envelope encryption with a single master-key unlock matches
13
+ * industry standard (KMS, HashiCorp Vault, 1Password, Bitwarden).
14
+ *
15
+ * ## Threat model (what this protects and doesn't)
16
+ *
17
+ * - Protects against filesystem-read-only adversaries (ciphertext is AEAD
18
+ * encrypted; forging plaintext requires the master key).
19
+ * - Protects against live-session and cold-start vault-file rollback by a
20
+ * filesystem-write-only adversary via the combined in-payload
21
+ * `vaultVersion` + sidecar-file `highestSeenVersion` scheme.
22
+ * - Does NOT protect against an adversary with **both** master-key access
23
+ * and filesystem write — game over for any local envelope vault.
24
+ * - Does NOT protect against an adversary with write access to the vault
25
+ * directory who chooses to downgrade both vault and sidecar in lockstep
26
+ * — the sidecar lives next to the vault. If your threat model includes
27
+ * adversarial writes to `~/.centient/secrets/`, use a secrets service
28
+ * with remote attestation (HashiCorp Vault, AWS Secrets Manager,
29
+ * 1Password Connect) instead.
30
+ * - Session key is in process RAM for the full session lifetime. Any code
31
+ * with execution in the process has access to all secrets in the vault.
32
+ * Operators running daemons with this API SHOULD disable core dumps
33
+ * (`ulimit -c 0` / `prlimit --core=0`) and disable the Node.js inspector
34
+ * (`NODE_OPTIONS=--inspect` grants heap read to anyone on the inspector
35
+ * socket — a full master-key compromise vector).
36
+ * - On macOS, a newly-started process will still prompt the user for
37
+ * Keychain access even if another process holds the vault open.
38
+ * Keychain ACLs are per-process, not per-vault-file.
39
+ */
40
+ import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, statSync, writeFileSync, } from "node:fs";
41
+ import { dirname, join, resolve as pathResolve } from "node:path";
42
+ import { homedir } from "node:os";
43
+ import { createHash, randomBytes } from "node:crypto";
44
+ import { encryptObject, decryptObject } from "../crypto/vault-common.js";
45
+ import { resolveKeyProvider } from "../key-providers/resolve.js";
46
+ import { runBeforeHooks, runAfterHooks, } from "./policy.js";
47
+ import { acquireWriteLock } from "./file-lock.js";
48
+ import { readSidecar, writeSidecar, checkSidecarPerms, VAULT_FILE_MODE, VAULT_DIR_MODE, } from "./sidecar.js";
49
+ import { VaultError, VaultUnlockError, VaultDecryptError, VaultRollbackError, VaultClosedError, VaultLockError, } from "./session-vault-errors.js";
50
+ // Re-export errors so the public surface (index.ts) stays stable.
51
+ export { VaultError, VaultUnlockError, VaultDecryptError, VaultRollbackError, VaultClosedError, VaultLockError, };
52
+ // =============================================================================
53
+ // Constants
54
+ // =============================================================================
55
+ /** Current payload schema version — bump requires a compat migration. */
56
+ export const VAULT_SCHEMA_VERSION = 1;
57
+ /** Default vault file location — same path the CLI uses, so they share state. */
58
+ export const DEFAULT_VAULT_PATH = join(homedir(), ".centient", "secrets", "vault.enc");
59
+ /** Default sidecar location — stores highest-ever-seen vault version. */
60
+ export const DEFAULT_SIDECAR_PATH = join(homedir(), ".centient", "secrets", "vault.seen-version");
61
+ /** Maximum allowed secret-name length. */
62
+ const MAX_NAME_LENGTH = 256;
63
+ /**
64
+ * AAD prefix — static byte header mixed into the vault ciphertext's
65
+ * Additional Authenticated Data. Binding this prefix into AAD means a
66
+ * ciphertext from some other AES-GCM user with the same key cannot be
67
+ * substituted into the vault. Exported so test fixtures can produce AAD
68
+ * consistent with the real implementation without duplicating the constant.
69
+ */
70
+ export const VAULT_AAD_PREFIX = "centient-secrets-vault";
71
+ // =============================================================================
72
+ // Path resolution (C2 — symlink-aware)
73
+ // =============================================================================
74
+ /**
75
+ * Resolve a vault path to its canonical real path so the AAD binds to the
76
+ * actual file identity rather than any one alias. Symlinks (`~/.centient`
77
+ * → `/home/user/.centient`, bind mounts, etc.) would otherwise produce
78
+ * distinct AADs for the same underlying file and fail decrypt.
79
+ *
80
+ * Intentional consequence: moving the vault to a new real path permanently
81
+ * invalidates the ciphertext (the attacker-moves-vault attack is the same
82
+ * as the rename-it attack — we prefer an honest decrypt failure to silent
83
+ * acceptance). See C2 in PR #41 review.
84
+ */
85
+ function resolveVaultPath(rawPath) {
86
+ const resolved = pathResolve(rawPath);
87
+ try {
88
+ return realpathSync(resolved);
89
+ }
90
+ catch (err) {
91
+ // ENOENT is expected when the vault hasn't been created yet; fall back
92
+ // to the lexical path so openVault can produce its own "vault not found"
93
+ // error with a clean message.
94
+ if (err.code === "ENOENT")
95
+ return resolved;
96
+ throw err;
97
+ }
98
+ }
99
+ // =============================================================================
100
+ // AAD derivation
101
+ // =============================================================================
102
+ /**
103
+ * Derive Additional Authenticated Data binding ciphertext to its vault
104
+ * identity. A payload encrypted for vault A cannot be substituted into
105
+ * vault B (different path) without failing auth-tag verification.
106
+ *
107
+ * AAD binds to the **resolved real path** (symlinks followed) so that a vault
108
+ * reachable via multiple aliases (symlinks, bind mounts) still produces a
109
+ * single canonical AAD. Moving the vault to a new real path permanently
110
+ * invalidates the ciphertext — intentional (see {@link resolveVaultPath}).
111
+ */
112
+ function deriveAad(absoluteRealVaultPath, schema) {
113
+ return createHash("sha256")
114
+ .update(`${VAULT_AAD_PREFIX}:v${schema}:${absoluteRealVaultPath}`)
115
+ .digest();
116
+ }
117
+ // =============================================================================
118
+ // Vault permission check
119
+ // =============================================================================
120
+ function checkVaultPerms(path) {
121
+ if (!existsSync(path))
122
+ return;
123
+ try {
124
+ const st = statSync(path);
125
+ const worldOrGroup = st.mode & 0o077;
126
+ if (worldOrGroup !== 0) {
127
+ process.stderr.write(`[secrets] WARNING: vault file ${path} has permissive mode ` +
128
+ `${(st.mode & 0o777).toString(8).padStart(3, "0")}; expected 600. ` +
129
+ `Fix with: chmod 600 ${path}\n`);
130
+ }
131
+ }
132
+ catch {
133
+ // Stat failure is handled by subsequent read attempts.
134
+ }
135
+ }
136
+ // =============================================================================
137
+ // openVault — factory
138
+ // =============================================================================
139
+ /**
140
+ * Open an encrypted session vault.
141
+ *
142
+ * Resolves the configured {@link KeyProvider} to obtain the master key,
143
+ * decrypts the vault file bound to its resolved real path (symlink-aware),
144
+ * checks rollback detection via the sidecar, and returns a long-lived
145
+ * {@link SessionVault} handle that serves reads from memory.
146
+ *
147
+ * @param opts - {@link OpenVaultOptions}. All fields are optional; defaults
148
+ * use the same paths the `centient secrets` CLI uses.
149
+ * @returns An open {@link SessionVault}. Call `close()` when done.
150
+ * @throws {@link VaultError} `VAULT_NOT_FOUND` when the vault file is absent.
151
+ * @throws {@link VaultUnlockError} when the KeyProvider cannot return a key.
152
+ * @throws {@link VaultDecryptError} when decryption fails (wrong key, AAD
153
+ * mismatch, corrupted payload).
154
+ * @throws {@link VaultRollbackError} when the sidecar indicates a rollback
155
+ * and `acceptRollback` is not set.
156
+ *
157
+ * @example
158
+ * ```ts
159
+ * const vault = await openVault({ ttlMs: 60_000 });
160
+ * const apiKey = await vault.get("openai-api-key");
161
+ * vault.close();
162
+ * ```
163
+ */
164
+ export async function openVault(opts = {}) {
165
+ const vaultPath = resolveVaultPath(opts.path ?? DEFAULT_VAULT_PATH);
166
+ const sidecarPath = pathResolve(opts.sidecarPath ?? join(dirname(vaultPath), "vault.seen-version"));
167
+ const coherence = opts.coherence ?? "mtime-check";
168
+ const ttlMs = opts.ttlMs;
169
+ if (!existsSync(vaultPath)) {
170
+ throw new VaultError("VAULT_NOT_FOUND", `Vault file not found at ${vaultPath}. Initialize with \`centient secrets init\`.`);
171
+ }
172
+ checkVaultPerms(vaultPath);
173
+ checkSidecarPerms(sidecarPath);
174
+ // --- Unlock via configured KeyProvider ---
175
+ const providerResult = resolveKeyProvider();
176
+ if (!providerResult.ok) {
177
+ throw new VaultUnlockError(providerResult.error.message);
178
+ }
179
+ const provider = providerResult.provider;
180
+ const key = provider.getKey();
181
+ if (!key) {
182
+ throw new VaultUnlockError(`KeyProvider ${provider.name} returned no key — master key not configured or access denied.`);
183
+ }
184
+ const aad = deriveAad(vaultPath, VAULT_SCHEMA_VERSION);
185
+ // --- Load initial snapshot ---
186
+ //
187
+ // Compatibility layer for CLI-written (AAD-less) vaults:
188
+ // 1. Try to decrypt with AAD (the v1 format written by openVault).
189
+ // 2. If that fails, try to decrypt WITHOUT AAD. If this succeeds, the
190
+ // vault was written by a pre-openVault CLI and is in the "legacy flat
191
+ // format" (`{ name: value, ... }` at the top level). The payload will
192
+ // be upgraded to v1 (with AAD) on the next successful write.
193
+ // 3. If both fail, it's a genuine decrypt error (wrong key / corruption).
194
+ //
195
+ // This is a bounded migration window — after consumers have all migrated,
196
+ // the legacy path can be removed in a subsequent major release. It is NOT
197
+ // a silent downgrade: legacy-opened vaults remain AAD-less until the next
198
+ // write, at which point they're upgraded automatically and become
199
+ // AAD-bound going forward.
200
+ const initialBytes = readFileSync(vaultPath);
201
+ let decoded = decryptObject(initialBytes, key, aad);
202
+ let openedAsLegacy = false;
203
+ if (decoded === null) {
204
+ const legacy = decryptObject(initialBytes, key);
205
+ if (legacy !== null) {
206
+ decoded = legacy;
207
+ openedAsLegacy = true;
208
+ }
209
+ else {
210
+ key.fill(0);
211
+ throw new VaultDecryptError(`Failed to decrypt vault at ${vaultPath} — wrong key, corrupted file, or AAD mismatch (schema version ${VAULT_SCHEMA_VERSION}; also tried legacy no-AAD format).`);
212
+ }
213
+ }
214
+ let payload = validatePayload(decoded);
215
+ if (payload === null) {
216
+ // Legacy flat-format detection: a pre-openVault CLI vault is a flat
217
+ // `{ name: value, ... }` map at the top level. If every value is a string
218
+ // and there's no `schema` field, accept as legacy schema-0.
219
+ if (openedAsLegacy && !("schema" in decoded)) {
220
+ const secrets = {};
221
+ for (const [k, v] of Object.entries(decoded)) {
222
+ if (typeof v !== "string") {
223
+ key.fill(0);
224
+ throw new VaultDecryptError(`Vault decrypted without AAD but contained a non-string value at key "${k}" — not a legacy CLI vault; possible corruption.`);
225
+ }
226
+ secrets[k] = v;
227
+ }
228
+ payload = { schema: 0, vaultVersion: 0, secrets };
229
+ process.stderr.write(`[secrets] Opened legacy (pre-schema, AAD-less) vault at ${vaultPath}; ` +
230
+ `will auto-upgrade to schema ${VAULT_SCHEMA_VERSION} with AAD binding on next write.\n`);
231
+ }
232
+ else {
233
+ key.fill(0);
234
+ throw new VaultDecryptError("Decrypted payload has invalid shape — possible corruption or format mismatch.");
235
+ }
236
+ }
237
+ // --- Rollback check ---
238
+ const sidecar = readSidecar(sidecarPath);
239
+ if (sidecar === null) {
240
+ // Default is REFUSE when sidecar is missing (security invariant:
241
+ // rollback protection must be in effect at all times). Callers with
242
+ // legitimate first-use contexts (fresh install, post-migration, test
243
+ // fixtures) must explicitly opt in via `acceptMissingSidecar: true`.
244
+ //
245
+ // Exception: legacy vaults (pre-openVault CLI-written, AAD-less) never
246
+ // had a sidecar by construction — refusing them would brick the CLI
247
+ // migration path. Legacy detection implicitly permits sidecar auto-init.
248
+ if (opts.acceptMissingSidecar !== true && !openedAsLegacy) {
249
+ key.fill(0);
250
+ throw new VaultError("VAULT_SIDECAR_MISSING", `Sidecar file ${sidecarPath} is missing. Rollback protection requires ` +
251
+ `the sidecar to exist. If this is a legitimate first-use context ` +
252
+ `(fresh install, post-migration), pass { acceptMissingSidecar: true } ` +
253
+ `to openVault(); the sidecar will be initialized automatically. ` +
254
+ `If the sidecar was unexpectedly deleted, investigate before opening.`);
255
+ }
256
+ const reason = openedAsLegacy ? "legacy vault migration" : "acceptMissingSidecar: true";
257
+ process.stderr.write(`[secrets] WARNING: sidecar file ${sidecarPath} is missing; ` +
258
+ `auto-initializing seenVersion=${payload.vaultVersion} per ${reason}.\n`);
259
+ writeSidecar(sidecarPath, { highestSeenVersion: payload.vaultVersion });
260
+ }
261
+ else if (payload.vaultVersion < sidecar.highestSeenVersion) {
262
+ if (opts.acceptRollback !== true) {
263
+ key.fill(0);
264
+ throw new VaultRollbackError(sidecar.highestSeenVersion, payload.vaultVersion);
265
+ }
266
+ process.stderr.write(`[secrets] WARNING: accepting intentional rollback from version ` +
267
+ `${sidecar.highestSeenVersion} down to ${payload.vaultVersion}. ` +
268
+ `This weakens rollback-detection protection. Sidecar will be ` +
269
+ `updated to match.\n`);
270
+ writeSidecar(sidecarPath, { highestSeenVersion: payload.vaultVersion });
271
+ }
272
+ else if (payload.vaultVersion > sidecar.highestSeenVersion) {
273
+ writeSidecar(sidecarPath, { highestSeenVersion: payload.vaultVersion });
274
+ }
275
+ return buildVault({
276
+ vaultPath,
277
+ sidecarPath,
278
+ provider: provider.name,
279
+ coherence,
280
+ key,
281
+ aad,
282
+ currentSecrets: { ...payload.secrets },
283
+ currentVersion: payload.vaultVersion,
284
+ ttlMs,
285
+ });
286
+ }
287
+ function buildVault(args) {
288
+ let key = args.key;
289
+ let secrets = args.currentSecrets;
290
+ let vaultVersion = args.currentVersion;
291
+ // Capture mtime here (L9) — openVault already confirmed the file exists and
292
+ // decrypted it, so a follow-up stat races the narrowest possible window and
293
+ // avoids duplicating the mtime in the BuildVaultArgs contract.
294
+ let mtimeMs = statSync(args.vaultPath).mtimeMs;
295
+ let closed = false;
296
+ let ttlTimer = null;
297
+ if (args.ttlMs !== undefined) {
298
+ ttlTimer = setTimeout(() => {
299
+ doClose();
300
+ }, args.ttlMs);
301
+ ttlTimer.unref();
302
+ }
303
+ const assertOpen = () => {
304
+ if (closed || key === null)
305
+ throw new VaultClosedError();
306
+ };
307
+ /**
308
+ * Refresh in-memory state from disk if the coherence strategy says to and
309
+ * mtime has advanced. Throws VaultError on a missing vault file (M4) and
310
+ * VaultDecryptError on decrypt failure.
311
+ */
312
+ const maybeReload = () => {
313
+ if (args.coherence === "best-effort")
314
+ return;
315
+ // Drop existsSync — statSync already throws ENOENT. Translating the error
316
+ // gives us one clean code path and one fewer syscall (M4).
317
+ let st;
318
+ try {
319
+ st = statSync(args.vaultPath);
320
+ }
321
+ catch (err) {
322
+ if (err.code === "ENOENT") {
323
+ throw new VaultError("VAULT_FILE_MISSING", `Vault file ${args.vaultPath} was removed while open.`);
324
+ }
325
+ throw err;
326
+ }
327
+ if (st.mtimeMs === mtimeMs)
328
+ return;
329
+ if (args.coherence === "strict" && st.mtimeMs > mtimeMs) {
330
+ // `strict` means the caller wants an explicit reload(); block reads.
331
+ throw new VaultError("VAULT_STALE_SNAPSHOT", `Vault file modified externally (mtime ${st.mtimeMs} vs session ${mtimeMs}); call reload() to continue.`);
332
+ }
333
+ const bytes = readFileSync(args.vaultPath);
334
+ // Try AAD first (v1 format); fall back to no-AAD (legacy CLI format) —
335
+ // same layered decrypt as openVault so a legacy vault remains readable
336
+ // across mtime-check reloads until the first write upgrades it.
337
+ let decoded = decryptObject(bytes, key, args.aad);
338
+ if (decoded === null) {
339
+ decoded = decryptObject(bytes, key);
340
+ if (decoded === null) {
341
+ throw new VaultDecryptError("Failed to decrypt vault after external change — key may have rotated or file may be corrupted.");
342
+ }
343
+ }
344
+ let payload = validatePayload(decoded);
345
+ if (payload === null) {
346
+ // Legacy flat-format (no schema field); reconstruct a schema-0 view.
347
+ if (!("schema" in decoded)) {
348
+ const legacySecrets = {};
349
+ let ok = true;
350
+ for (const [k, v] of Object.entries(decoded)) {
351
+ if (typeof v !== "string") {
352
+ ok = false;
353
+ break;
354
+ }
355
+ legacySecrets[k] = v;
356
+ }
357
+ if (!ok) {
358
+ throw new VaultDecryptError("Decrypted payload has invalid shape after external change — possible corruption.");
359
+ }
360
+ payload = { schema: 0, vaultVersion: 0, secrets: legacySecrets };
361
+ }
362
+ else {
363
+ throw new VaultDecryptError("Decrypted payload has invalid shape after external change — possible corruption.");
364
+ }
365
+ }
366
+ secrets = { ...payload.secrets };
367
+ vaultVersion = payload.vaultVersion;
368
+ mtimeMs = st.mtimeMs;
369
+ const sidecar = readSidecar(args.sidecarPath);
370
+ if (sidecar === null || payload.vaultVersion > sidecar.highestSeenVersion) {
371
+ writeSidecar(args.sidecarPath, { highestSeenVersion: payload.vaultVersion });
372
+ }
373
+ };
374
+ /**
375
+ * Perform a vault mutation. The lock-acquire step yields the event loop
376
+ * (C1); the critical section between acquire and release runs
377
+ * synchronously so we never deadlock against another async task in this
378
+ * process waiting on the same lock.
379
+ */
380
+ const writeOp = async (mutator) => {
381
+ assertOpen();
382
+ const release = await acquireWriteLock(args.vaultPath);
383
+ try {
384
+ // Re-check open after awaiting the lock — TTL or a sibling close()
385
+ // could have fired while we were queued (H2).
386
+ assertOpen();
387
+ maybeReload();
388
+ const next = { ...secrets };
389
+ mutator(next);
390
+ const nextVersion = vaultVersion + 1;
391
+ const payload = {
392
+ schema: VAULT_SCHEMA_VERSION,
393
+ vaultVersion: nextVersion,
394
+ secrets: next,
395
+ };
396
+ const encrypted = encryptObject(payload, key, args.aad);
397
+ if (encrypted === null) {
398
+ throw new VaultError("VAULT_ENCRYPT_FAILED", "Encryption returned null — corrupted state.");
399
+ }
400
+ // Atomic vault write: temp file (mode 0600) + rename. POSIX `rename`
401
+ // preserves mode, and `writeFileSync` honours the `mode` option on the
402
+ // initial create, so we deliberately do NOT chmod the committed file
403
+ // afterwards (M5). If a hostile umask or exotic filesystem produced a
404
+ // too-permissive file, the permission check on the next open will
405
+ // warn.
406
+ mkdirSync(dirname(args.vaultPath), { recursive: true, mode: VAULT_DIR_MODE });
407
+ const tmpVault = `${args.vaultPath}.${randomBytes(8).toString("hex")}.tmp`;
408
+ writeFileSync(tmpVault, encrypted, { mode: VAULT_FILE_MODE });
409
+ renameSync(tmpVault, args.vaultPath);
410
+ // Sidecar update trails the vault write so a crash between them leaves
411
+ // the sidecar lagging (graceful: catches up on next write) rather than
412
+ // ahead (would false-positive rollback detection).
413
+ const sidecar = readSidecar(args.sidecarPath);
414
+ const newHighest = Math.max(sidecar?.highestSeenVersion ?? 0, nextVersion);
415
+ writeSidecar(args.sidecarPath, { highestSeenVersion: newHighest });
416
+ // Commit in-memory state only after both files land successfully.
417
+ secrets = next;
418
+ vaultVersion = nextVersion;
419
+ mtimeMs = statSync(args.vaultPath).mtimeMs;
420
+ }
421
+ finally {
422
+ release();
423
+ }
424
+ };
425
+ const doClose = () => {
426
+ if (closed)
427
+ return;
428
+ closed = true;
429
+ if (ttlTimer !== null) {
430
+ clearTimeout(ttlTimer);
431
+ ttlTimer = null;
432
+ }
433
+ if (key !== null) {
434
+ // Best-effort key zeroing. Note: `Buffer.fill(0)` zeroes the allocation,
435
+ // but if the key ever transited through a string (accidental `String(buf)`,
436
+ // `util.inspect`, `console.log`), those copies linger until V8 GC. This
437
+ // API can't guarantee full memory wipe; callers concerned about residue
438
+ // should also restrict inspector access and disable core dumps.
439
+ key.fill(0);
440
+ key = null;
441
+ }
442
+ // Wipe plaintext secret values too (best-effort, same caveats).
443
+ for (const k of Object.keys(secrets)) {
444
+ secrets[k] = "";
445
+ }
446
+ secrets = {};
447
+ };
448
+ /**
449
+ * Audit-scaffolding wrapper — extracted from per-method boilerplate (M1).
450
+ *
451
+ * Every vault operation shares the same shape: `assertOpen`, run before
452
+ * hooks, time the work, fire an after hook (success/missing/failure). Re-
453
+ * checks `assertOpen` after the before-hook await so a TTL expiry or a
454
+ * sibling `close()` can't drop us into `fn()` with `key === null` (H2).
455
+ *
456
+ * `missingType` is distinct from `successType` because reads can return
457
+ * null/false without being a failure (credential not present, delete of
458
+ * absent key) — audit logs must distinguish those from a successful hit.
459
+ *
460
+ * `extras(value)` lets callers mix additional event fields derived from
461
+ * the operation result (e.g. `keyCount` for list) without forcing every
462
+ * call site to build its own success event.
463
+ */
464
+ const withAudit = async (op, successType, missingType, failType, fn, extras) => {
465
+ assertOpen();
466
+ await runBeforeHooks(op);
467
+ // Re-check after the await — TTL or sibling close() could have fired
468
+ // while before-hooks awaited (H2).
469
+ assertOpen();
470
+ const start = Date.now();
471
+ try {
472
+ const value = await fn();
473
+ const isMissing = missingType !== null && (value === null || value === false);
474
+ runAfterHooks({
475
+ type: isMissing ? missingType : successType,
476
+ timestamp: new Date(start).toISOString(),
477
+ backend: "session-vault",
478
+ key: op.key,
479
+ prefix: op.prefix,
480
+ ...(extras !== undefined ? extras(value) : {}),
481
+ durationMs: Date.now() - start,
482
+ });
483
+ return value;
484
+ }
485
+ catch (err) {
486
+ runAfterHooks({
487
+ type: failType,
488
+ timestamp: new Date(start).toISOString(),
489
+ backend: "session-vault",
490
+ key: op.key,
491
+ prefix: op.prefix,
492
+ error: err instanceof Error ? err.message : String(err),
493
+ durationMs: Date.now() - start,
494
+ });
495
+ throw err;
496
+ }
497
+ };
498
+ return {
499
+ get(name) {
500
+ return withAudit({ type: "read", key: name }, "credential_read", "credential_read_missing", "credential_read_failed", () => {
501
+ maybeReload();
502
+ return name in secrets ? secrets[name] : null;
503
+ });
504
+ },
505
+ list(prefix) {
506
+ return withAudit({ type: "enumerate", prefix }, "credential_enumerated", null, "credential_enumerate_failed", () => {
507
+ maybeReload();
508
+ const names = Object.keys(secrets).sort();
509
+ return prefix === undefined
510
+ ? names
511
+ : names.filter((n) => n.startsWith(prefix));
512
+ }, (value) => ({ keyCount: value.length }));
513
+ },
514
+ async set(name, value) {
515
+ // `async` keyword ensures a sync throw from validateName surfaces as a
516
+ // promise rejection, matching the declared `Promise<void>` contract.
517
+ validateName(name);
518
+ return withAudit({ type: "write", key: name }, "credential_written", null, "credential_write_failed", () => writeOp((current) => {
519
+ current[name] = value;
520
+ }));
521
+ },
522
+ delete(name) {
523
+ return withAudit({ type: "delete", key: name }, "credential_deleted", "credential_delete_failed", "credential_delete_failed", async () => {
524
+ if (!(name in secrets))
525
+ return false;
526
+ await writeOp((current) => {
527
+ delete current[name];
528
+ });
529
+ return true;
530
+ });
531
+ },
532
+ async reload() {
533
+ assertOpen();
534
+ let st;
535
+ try {
536
+ st = statSync(args.vaultPath);
537
+ }
538
+ catch (err) {
539
+ if (err.code === "ENOENT") {
540
+ throw new VaultError("VAULT_FILE_MISSING", `Vault file ${args.vaultPath} was removed while open.`);
541
+ }
542
+ throw err;
543
+ }
544
+ const bytes = readFileSync(args.vaultPath);
545
+ // Re-check open across the IO boundary (H2).
546
+ assertOpen();
547
+ // Layered decrypt: v1 (AAD) → legacy (no AAD). Mirrors openVault and
548
+ // maybeReload so legacy vaults remain usable across explicit reload().
549
+ let decoded = decryptObject(bytes, key, args.aad);
550
+ if (decoded === null)
551
+ decoded = decryptObject(bytes, key);
552
+ if (decoded === null) {
553
+ throw new VaultDecryptError("Failed to decrypt vault during reload — key may have rotated or file may be corrupted.");
554
+ }
555
+ let payload = validatePayload(decoded);
556
+ if (payload === null) {
557
+ if (!("schema" in decoded)) {
558
+ const legacySecrets = {};
559
+ let ok = true;
560
+ for (const [k, v] of Object.entries(decoded)) {
561
+ if (typeof v !== "string") {
562
+ ok = false;
563
+ break;
564
+ }
565
+ legacySecrets[k] = v;
566
+ }
567
+ if (!ok) {
568
+ throw new VaultDecryptError("Decrypted payload has invalid shape during reload — possible corruption.");
569
+ }
570
+ payload = { schema: 0, vaultVersion: 0, secrets: legacySecrets };
571
+ }
572
+ else {
573
+ throw new VaultDecryptError("Decrypted payload has invalid shape during reload — possible corruption.");
574
+ }
575
+ }
576
+ secrets = { ...payload.secrets };
577
+ vaultVersion = payload.vaultVersion;
578
+ mtimeMs = st.mtimeMs;
579
+ },
580
+ close: doClose,
581
+ get provider() {
582
+ return args.provider;
583
+ },
584
+ get path() {
585
+ return args.vaultPath;
586
+ },
587
+ get vaultVersion() {
588
+ return vaultVersion;
589
+ },
590
+ };
591
+ }
592
+ // =============================================================================
593
+ // Validation helpers
594
+ // =============================================================================
595
+ /**
596
+ * Validate a decoded vault payload. Rejects NaN, Infinity, non-integer, and
597
+ * out-of-range numeric fields — the payload is untrusted input post-decrypt
598
+ * (a corrupted-but-authenticated payload could still carry garbage integers)
599
+ * so we fail closed (H1). Only `schema === VAULT_SCHEMA_VERSION` is accepted;
600
+ * unknown future schemas must be handled by a future migration, not silently
601
+ * let through as v1.
602
+ */
603
+ function validatePayload(decoded) {
604
+ const schemaRaw = decoded["schema"];
605
+ const vvRaw = decoded["vaultVersion"];
606
+ const secretsRaw = decoded["secrets"];
607
+ if (typeof schemaRaw !== "number" ||
608
+ !Number.isInteger(schemaRaw) ||
609
+ schemaRaw < 0 ||
610
+ schemaRaw > Number.MAX_SAFE_INTEGER) {
611
+ return null;
612
+ }
613
+ // Only v1 is valid in this build. Reject unknowns explicitly rather than
614
+ // coercing them into v1 handling.
615
+ if (schemaRaw !== VAULT_SCHEMA_VERSION) {
616
+ return null;
617
+ }
618
+ if (typeof vvRaw !== "number" ||
619
+ !Number.isInteger(vvRaw) ||
620
+ vvRaw < 0 ||
621
+ vvRaw > Number.MAX_SAFE_INTEGER) {
622
+ return null;
623
+ }
624
+ if (typeof secretsRaw !== "object" ||
625
+ secretsRaw === null ||
626
+ Array.isArray(secretsRaw)) {
627
+ return null;
628
+ }
629
+ const secretsObj = secretsRaw;
630
+ const secrets = {};
631
+ for (const [k, v] of Object.entries(secretsObj)) {
632
+ if (typeof v !== "string")
633
+ return null;
634
+ secrets[k] = v;
635
+ }
636
+ return {
637
+ schema: schemaRaw,
638
+ vaultVersion: vvRaw,
639
+ secrets,
640
+ };
641
+ }
642
+ /**
643
+ * Reject names with control characters, path separators, null bytes, or
644
+ * Unicode oddities that can confuse log scrapers, terminals, and path
645
+ * libraries (L4). The CLI-facing library accepts anything historically;
646
+ * the public API is a good place to constrain against callers that might
647
+ * route user-controlled input through `set()`.
648
+ */
649
+ function validateName(name) {
650
+ if (name.length === 0) {
651
+ throw new VaultError("INVALID_NAME", "Secret name must be non-empty.");
652
+ }
653
+ if (name.length > MAX_NAME_LENGTH) {
654
+ throw new VaultError("INVALID_NAME", `Secret name must be ${MAX_NAME_LENGTH} characters or fewer.`);
655
+ }
656
+ if (name !== name.trim()) {
657
+ throw new VaultError("INVALID_NAME", `Secret name must not have leading or trailing whitespace: ${JSON.stringify(name)}`);
658
+ }
659
+ // Denylist: ASCII control (0x00–0x1f, 0x7f), path separators, plus explicit
660
+ // Unicode directional overrides and line/paragraph separators that can
661
+ // disguise names in logs and terminals.
662
+ // eslint-disable-next-line no-control-regex
663
+ if (/[\x00-\x1f\x7f/\\\u202E\u2028\u2029]/.test(name)) {
664
+ throw new VaultError("INVALID_NAME", `Secret name contains invalid characters (control chars, slashes, null bytes, or Unicode separators): ${JSON.stringify(name)}`);
665
+ }
666
+ }
667
+ //# sourceMappingURL=session-vault.js.map