@centient/secrets 0.5.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.
- package/README.md +2 -0
- package/dist/cli/hidden-input.d.ts +52 -0
- package/dist/cli/hidden-input.d.ts.map +1 -0
- package/dist/cli/hidden-input.js +109 -0
- package/dist/cli/hidden-input.js.map +1 -0
- package/dist/cli/secrets-cli.d.ts +7 -0
- package/dist/cli/secrets-cli.d.ts.map +1 -1
- package/dist/cli/secrets-cli.js +234 -161
- package/dist/cli/secrets-cli.js.map +1 -1
- package/dist/crypto/vault-common.d.ts +17 -4
- package/dist/crypto/vault-common.d.ts.map +1 -1
- package/dist/crypto/vault-common.js +23 -6
- package/dist/crypto/vault-common.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -1
- package/dist/vault/file-lock.d.ts +33 -0
- package/dist/vault/file-lock.d.ts.map +1 -0
- package/dist/vault/file-lock.js +143 -0
- package/dist/vault/file-lock.js.map +1 -0
- package/dist/vault/session-vault-errors.d.ts +38 -0
- package/dist/vault/session-vault-errors.d.ts.map +1 -0
- package/dist/vault/session-vault-errors.js +67 -0
- package/dist/vault/session-vault-errors.js.map +1 -0
- package/dist/vault/session-vault.d.ts +147 -0
- package/dist/vault/session-vault.d.ts.map +1 -0
- package/dist/vault/session-vault.js +667 -0
- package/dist/vault/session-vault.js.map +1 -0
- package/dist/vault/sidecar.d.ts +37 -0
- package/dist/vault/sidecar.d.ts.map +1 -0
- package/dist/vault/sidecar.js +84 -0
- package/dist/vault/sidecar.js.map +1 -0
- package/dist/vault/types.d.ts +1 -1
- package/dist/vault/types.d.ts.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"session-vault.d.ts","sourceRoot":"","sources":["../../src/vault/session-vault.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsCG;AAiBH,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,2BAA2B,CAAC;AAejE,OAAO,EACL,UAAU,EACV,gBAAgB,EAChB,iBAAiB,EACjB,kBAAkB,EAClB,gBAAgB,EAChB,cAAc,EACf,MAAM,2BAA2B,CAAC;AAGnC,OAAO,EACL,UAAU,EACV,gBAAgB,EAChB,iBAAiB,EACjB,kBAAkB,EAClB,gBAAgB,EAChB,cAAc,GACf,CAAC;AAMF,yEAAyE;AACzE,eAAO,MAAM,oBAAoB,IAAI,CAAC;AAEtC,iFAAiF;AACjF,eAAO,MAAM,kBAAkB,QAAuD,CAAC;AAEvF,yEAAyE;AACzE,eAAO,MAAM,oBAAoB,QAKhC,CAAC;AAKF;;;;;;GAMG;AACH,eAAO,MAAM,gBAAgB,2BAA2B,CAAC;AA2BzD;;;GAGG;AACH,MAAM,MAAM,iBAAiB,GAAG,aAAa,GAAG,QAAQ,GAAG,aAAa,CAAC;AAEzE,8DAA8D;AAC9D,MAAM,WAAW,gBAAgB;IAC/B,yEAAyE;IACzE,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,kFAAkF;IAClF,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;;OAIG;IACH,SAAS,CAAC,EAAE,iBAAiB,CAAC;IAC9B;;;;OAIG;IACH,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB;;;;;;;OAOG;IACH,oBAAoB,CAAC,EAAE,OAAO,CAAC;IAC/B;;;;OAIG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED;;;;GAIG;AACH,MAAM,WAAW,YAAY;IAC3B,0EAA0E;IAC1E,GAAG,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IAC1C,2EAA2E;IAC3E,IAAI,CAAC,MAAM,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;IACzC,uEAAuE;IACvE,GAAG,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAChD,yEAAyE;IACzE,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IACvC,4EAA4E;IAC5E,MAAM,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACxB,4EAA4E;IAC5E,KAAK,IAAI,IAAI,CAAC;IACd,+DAA+D;IAC/D,QAAQ,CAAC,QAAQ,EAAE,eAAe,CAAC;IACnC,oDAAoD;IACpD,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,wDAAwD;IACxD,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;CAC/B;AA2ED;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,wBAAsB,SAAS,CAAC,IAAI,GAAE,gBAAqB,GAAG,OAAO,CAAC,YAAY,CAAC,CAoJlF"}
|
|
@@ -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
|