@centient/secrets 0.9.0 → 0.11.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 +424 -8
- package/dist/audit/hmac-chain.d.ts +117 -0
- package/dist/audit/hmac-chain.d.ts.map +1 -0
- package/dist/audit/hmac-chain.js +811 -0
- package/dist/audit/hmac-chain.js.map +1 -0
- package/dist/audit/ocsf.d.ts +108 -0
- package/dist/audit/ocsf.d.ts.map +1 -0
- package/dist/audit/ocsf.js +204 -0
- package/dist/audit/ocsf.js.map +1 -0
- package/dist/cli/hidden-prompt.d.ts.map +1 -1
- package/dist/cli/hidden-prompt.js +55 -10
- package/dist/cli/hidden-prompt.js.map +1 -1
- package/dist/cli/messages.d.ts +40 -8
- package/dist/cli/messages.d.ts.map +1 -1
- package/dist/cli/messages.js +34 -8
- package/dist/cli/messages.js.map +1 -1
- package/dist/cli/secrets-cli.d.ts.map +1 -1
- package/dist/cli/secrets-cli.js +191 -32
- package/dist/cli/secrets-cli.js.map +1 -1
- package/dist/client.d.ts +26 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +129 -0
- package/dist/client.js.map +1 -0
- package/dist/compatibility.d.ts +199 -0
- package/dist/compatibility.d.ts.map +1 -0
- package/dist/compatibility.js +419 -0
- package/dist/compatibility.js.map +1 -0
- package/dist/crypto/darwin-keychain.d.ts +394 -0
- package/dist/crypto/darwin-keychain.d.ts.map +1 -0
- package/dist/crypto/darwin-keychain.js +793 -0
- package/dist/crypto/darwin-keychain.js.map +1 -0
- package/dist/crypto/vault-common.d.ts +55 -12
- package/dist/crypto/vault-common.d.ts.map +1 -1
- package/dist/crypto/vault-common.js +74 -71
- package/dist/crypto/vault-common.js.map +1 -1
- package/dist/environment/EnvironmentManager.d.ts.map +1 -1
- package/dist/environment/EnvironmentManager.js +5 -10
- package/dist/environment/EnvironmentManager.js.map +1 -1
- package/dist/index.d.ts +37 -9
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +38 -6
- package/dist/index.js.map +1 -1
- package/dist/key-providers/keychain-provider.d.ts +30 -2
- package/dist/key-providers/keychain-provider.d.ts.map +1 -1
- package/dist/key-providers/keychain-provider.js +29 -5
- package/dist/key-providers/keychain-provider.js.map +1 -1
- package/dist/key-providers/onepassword-provider.d.ts +65 -5
- package/dist/key-providers/onepassword-provider.d.ts.map +1 -1
- package/dist/key-providers/onepassword-provider.js +159 -67
- package/dist/key-providers/onepassword-provider.js.map +1 -1
- package/dist/key-providers/op-cli.d.ts +78 -0
- package/dist/key-providers/op-cli.d.ts.map +1 -0
- package/dist/key-providers/op-cli.js +123 -0
- package/dist/key-providers/op-cli.js.map +1 -0
- package/dist/key-providers/passphrase-provider.d.ts +20 -0
- package/dist/key-providers/passphrase-provider.d.ts.map +1 -1
- package/dist/key-providers/passphrase-provider.js +49 -0
- package/dist/key-providers/passphrase-provider.js.map +1 -1
- package/dist/key-providers/probe.d.ts +46 -0
- package/dist/key-providers/probe.d.ts.map +1 -0
- package/dist/key-providers/probe.js +70 -0
- package/dist/key-providers/probe.js.map +1 -0
- package/dist/key-providers/types.d.ts +51 -1
- package/dist/key-providers/types.d.ts.map +1 -1
- package/dist/platform/agent-detect.d.ts +39 -3
- package/dist/platform/agent-detect.d.ts.map +1 -1
- package/dist/platform/agent-detect.js +55 -9
- package/dist/platform/agent-detect.js.map +1 -1
- package/dist/platform/session-context.d.ts +77 -0
- package/dist/platform/session-context.d.ts.map +1 -0
- package/dist/platform/session-context.js +83 -0
- package/dist/platform/session-context.js.map +1 -0
- package/dist/policies/audit-trail.d.ts +25 -0
- package/dist/policies/audit-trail.d.ts.map +1 -0
- package/dist/policies/audit-trail.js +31 -0
- package/dist/policies/audit-trail.js.map +1 -0
- package/dist/secret-state.d.ts +54 -0
- package/dist/secret-state.d.ts.map +1 -0
- package/dist/secret-state.js +2 -0
- package/dist/secret-state.js.map +1 -0
- package/dist/sinks/index.d.ts +9 -0
- package/dist/sinks/index.d.ts.map +1 -0
- package/dist/sinks/index.js +5 -0
- package/dist/sinks/index.js.map +1 -0
- package/dist/sinks/jsonl.d.ts +16 -0
- package/dist/sinks/jsonl.d.ts.map +1 -0
- package/dist/sinks/jsonl.js +51 -0
- package/dist/sinks/jsonl.js.map +1 -0
- package/dist/sinks/otel.d.ts +22 -0
- package/dist/sinks/otel.d.ts.map +1 -0
- package/dist/sinks/otel.js +155 -0
- package/dist/sinks/otel.js.map +1 -0
- package/dist/sinks/queue.d.ts +12 -0
- package/dist/sinks/queue.d.ts.map +1 -0
- package/dist/sinks/queue.js +89 -0
- package/dist/sinks/queue.js.map +1 -0
- package/dist/sinks/syslog.d.ts +17 -0
- package/dist/sinks/syslog.d.ts.map +1 -0
- package/dist/sinks/syslog.js +103 -0
- package/dist/sinks/syslog.js.map +1 -0
- package/dist/sinks/types.d.ts +27 -0
- package/dist/sinks/types.d.ts.map +1 -0
- package/dist/sinks/types.js +18 -0
- package/dist/sinks/types.js.map +1 -0
- package/dist/vault/policy.d.ts +64 -34
- package/dist/vault/policy.d.ts.map +1 -1
- package/dist/vault/policy.js +109 -67
- package/dist/vault/policy.js.map +1 -1
- package/dist/vault/session-vault-errors.d.ts +72 -4
- package/dist/vault/session-vault-errors.d.ts.map +1 -1
- package/dist/vault/session-vault-errors.js +95 -4
- package/dist/vault/session-vault-errors.js.map +1 -1
- package/dist/vault/session-vault.d.ts +145 -2
- package/dist/vault/session-vault.d.ts.map +1 -1
- package/dist/vault/session-vault.js +548 -72
- package/dist/vault/session-vault.js.map +1 -1
- package/dist/vault/sidecar.d.ts +9 -0
- package/dist/vault/sidecar.d.ts.map +1 -1
- package/dist/vault/sidecar.js +72 -3
- package/dist/vault/sidecar.js.map +1 -1
- package/dist/vault/types.d.ts +58 -10
- package/dist/vault/types.d.ts.map +1 -1
- package/dist/vault/types.js.map +1 -1
- package/dist/vault/vault-env.d.ts +43 -50
- package/dist/vault/vault-env.d.ts.map +1 -1
- package/dist/vault/vault-env.js +141 -62
- package/dist/vault/vault-env.js.map +1 -1
- package/dist/vault/vault-gpg.d.ts +6 -1
- package/dist/vault/vault-gpg.d.ts.map +1 -1
- package/dist/vault/vault-gpg.js +10 -8
- package/dist/vault/vault-gpg.js.map +1 -1
- package/dist/vault/vault-libsecret.d.ts +6 -1
- package/dist/vault/vault-libsecret.d.ts.map +1 -1
- package/dist/vault/vault-libsecret.js +16 -14
- package/dist/vault/vault-libsecret.js.map +1 -1
- package/dist/vault/vault-onepassword.d.ts +119 -0
- package/dist/vault/vault-onepassword.d.ts.map +1 -0
- package/dist/vault/vault-onepassword.js +271 -0
- package/dist/vault/vault-onepassword.js.map +1 -0
- package/dist/vault/vault-utils.d.ts +37 -3
- package/dist/vault/vault-utils.d.ts.map +1 -1
- package/dist/vault/vault-utils.js +65 -3
- package/dist/vault/vault-utils.js.map +1 -1
- package/dist/vault/vault-windows.d.ts +7 -2
- package/dist/vault/vault-windows.d.ts.map +1 -1
- package/dist/vault/vault-windows.js +21 -16
- package/dist/vault/vault-windows.js.map +1 -1
- package/dist/vault/vault.d.ts +120 -9
- package/dist/vault/vault.d.ts.map +1 -1
- package/dist/vault/vault.js +222 -139
- package/dist/vault/vault.js.map +1 -1
- package/package.json +8 -2
|
@@ -37,18 +37,21 @@
|
|
|
37
37
|
* Keychain access even if another process holds the vault open.
|
|
38
38
|
* Keychain ACLs are per-process, not per-vault-file.
|
|
39
39
|
*/
|
|
40
|
-
import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, statSync, writeFileSync, } from "node:fs";
|
|
41
|
-
import { dirname, join, resolve as pathResolve } from "node:path";
|
|
40
|
+
import { closeSync, existsSync, linkSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
41
|
+
import { basename, dirname, isAbsolute, join, relative, resolve as pathResolve, sep, } from "node:path";
|
|
42
42
|
import { homedir } from "node:os";
|
|
43
43
|
import { createHash, randomBytes } from "node:crypto";
|
|
44
|
-
import { encryptObject,
|
|
44
|
+
import { decryptObject, encryptObject, KEY_LENGTH, } from "../crypto/vault-common.js";
|
|
45
45
|
import { resolveKeyProvider } from "../key-providers/resolve.js";
|
|
46
46
|
import { runBeforeHooks, runAfterHooks, rejectedEventType, } from "./policy.js";
|
|
47
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";
|
|
48
|
+
import { createSidecar, readSidecar, writeSidecar, checkSidecarPerms, VAULT_FILE_MODE, VAULT_DIR_MODE, } from "./sidecar.js";
|
|
49
|
+
import { VaultError, VaultUnlockError, VaultDecryptError, VaultRollbackError, VaultClosedError, VaultLockError, VaultRestoreError, InvalidCredentialKeyError, } from "./session-vault-errors.js";
|
|
50
50
|
// Re-export errors so the public surface (index.ts) stays stable.
|
|
51
|
-
export { VaultError, VaultUnlockError, VaultDecryptError, VaultRollbackError, VaultClosedError, VaultLockError,
|
|
51
|
+
export { VaultError, VaultUnlockError, VaultDecryptError, VaultRollbackError, VaultClosedError, VaultLockError, VaultRestoreError,
|
|
52
|
+
// Raised by the credential cascade, not by SessionVault — re-exported here
|
|
53
|
+
// because this module is index.ts's single door onto the VaultError family.
|
|
54
|
+
InvalidCredentialKeyError, };
|
|
52
55
|
// =============================================================================
|
|
53
56
|
// Constants
|
|
54
57
|
// =============================================================================
|
|
@@ -96,6 +99,90 @@ function resolveVaultPath(rawPath) {
|
|
|
96
99
|
throw err;
|
|
97
100
|
}
|
|
98
101
|
}
|
|
102
|
+
/**
|
|
103
|
+
* Resolve a path that may not exist yet through its nearest existing parent.
|
|
104
|
+
*
|
|
105
|
+
* `realpathSync(file)` cannot canonicalize a first-use path. Walking upward
|
|
106
|
+
* first preserves symlink-aware AAD binding without making callers duplicate
|
|
107
|
+
* the package's private path recipe.
|
|
108
|
+
*/
|
|
109
|
+
function resolveProspectiveVaultPath(rawPath) {
|
|
110
|
+
const resolved = pathResolve(rawPath);
|
|
111
|
+
let cursor = resolved;
|
|
112
|
+
const missing = [];
|
|
113
|
+
while (true) {
|
|
114
|
+
try {
|
|
115
|
+
return join(realpathSync(cursor), ...missing);
|
|
116
|
+
}
|
|
117
|
+
catch (err) {
|
|
118
|
+
if (err.code !== "ENOENT")
|
|
119
|
+
throw err;
|
|
120
|
+
const parent = dirname(cursor);
|
|
121
|
+
if (parent === cursor)
|
|
122
|
+
return resolved;
|
|
123
|
+
missing.unshift(basename(cursor));
|
|
124
|
+
cursor = parent;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
function inspectFileIdentity(path) {
|
|
129
|
+
try {
|
|
130
|
+
const { dev, ino } = statSync(path);
|
|
131
|
+
return { state: "found", identity: { dev, ino } };
|
|
132
|
+
}
|
|
133
|
+
catch (err) {
|
|
134
|
+
if (err.code === "ENOENT") {
|
|
135
|
+
return { state: "absent" };
|
|
136
|
+
}
|
|
137
|
+
return { state: "unknown", cause: err };
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
/** True only when both pathnames can be proven to identify the same object. */
|
|
141
|
+
function sameFileIdentity(left, right) {
|
|
142
|
+
const leftResult = inspectFileIdentity(left);
|
|
143
|
+
const rightResult = inspectFileIdentity(right);
|
|
144
|
+
return leftResult.state === "found" &&
|
|
145
|
+
rightResult.state === "found" &&
|
|
146
|
+
leftResult.identity.dev === rightResult.identity.dev &&
|
|
147
|
+
leftResult.identity.ino === rightResult.identity.ino;
|
|
148
|
+
}
|
|
149
|
+
function isDescendantPath(parent, candidate) {
|
|
150
|
+
const rel = relative(parent, candidate);
|
|
151
|
+
return rel !== "" && rel !== ".." &&
|
|
152
|
+
!rel.startsWith(`..${sep}`) && !isAbsolute(rel);
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Lock both transaction members in one stable order.
|
|
156
|
+
*
|
|
157
|
+
* Different vault names may intentionally or accidentally resolve to the same
|
|
158
|
+
* sidecar. Locking only the vault lets those creators race and couple their
|
|
159
|
+
* rollback high-water marks. The identity check deduplicates lock pathnames
|
|
160
|
+
* that alias on a case-insensitive filesystem.
|
|
161
|
+
*/
|
|
162
|
+
async function acquireCreateLocks(vaultPath, sidecarPath) {
|
|
163
|
+
const releases = [];
|
|
164
|
+
const heldLockPaths = [];
|
|
165
|
+
const targets = [...new Set([vaultPath, sidecarPath])].sort();
|
|
166
|
+
try {
|
|
167
|
+
for (const target of targets) {
|
|
168
|
+
const lockPath = `${target}.lock`;
|
|
169
|
+
if (heldLockPaths.some((held) => sameFileIdentity(held, lockPath))) {
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
releases.push(await acquireWriteLock(target));
|
|
173
|
+
heldLockPaths.push(lockPath);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
catch (err) {
|
|
177
|
+
for (const release of releases.reverse())
|
|
178
|
+
release();
|
|
179
|
+
throw err;
|
|
180
|
+
}
|
|
181
|
+
return () => {
|
|
182
|
+
for (const release of releases.reverse())
|
|
183
|
+
release();
|
|
184
|
+
};
|
|
185
|
+
}
|
|
99
186
|
// =============================================================================
|
|
100
187
|
// AAD derivation
|
|
101
188
|
// =============================================================================
|
|
@@ -134,6 +221,178 @@ function checkVaultPerms(path) {
|
|
|
134
221
|
}
|
|
135
222
|
}
|
|
136
223
|
// =============================================================================
|
|
224
|
+
// createVault — first-use factory
|
|
225
|
+
// =============================================================================
|
|
226
|
+
/**
|
|
227
|
+
* Create an empty, AAD-bound session vault and its rollback sidecar.
|
|
228
|
+
*
|
|
229
|
+
* Creation is idempotent only for a complete pair: if both files already
|
|
230
|
+
* exist they are left byte-for-byte untouched and `created` is false. A
|
|
231
|
+
* vault-only or sidecar-only state is refused rather than "repaired" because
|
|
232
|
+
* replacing either half could erase rollback evidence or make existing
|
|
233
|
+
* ciphertext permanently unreadable.
|
|
234
|
+
*
|
|
235
|
+
* Both destinations use no-clobber publication: the completed vault temp is
|
|
236
|
+
* linked into place first, then the sidecar is the logical commit point. A
|
|
237
|
+
* successful return therefore means both public paths exist, and a competing
|
|
238
|
+
* destination is never replaced. This is temp-file/link crash safety, not an
|
|
239
|
+
* fsync or power-loss durability guarantee.
|
|
240
|
+
*
|
|
241
|
+
* The caller retains ownership of `opts.key`; this function snapshots it for
|
|
242
|
+
* the duration of the call, zeroes that private copy, and never mutates,
|
|
243
|
+
* zeroes, or stores the caller's buffer.
|
|
244
|
+
*/
|
|
245
|
+
export async function createVault(opts) {
|
|
246
|
+
if (!Buffer.isBuffer(opts.key) || opts.key.length !== KEY_LENGTH) {
|
|
247
|
+
throw new VaultError("VAULT_INVALID_KEY", `Vault creation requires a ${KEY_LENGTH}-byte AES-256 key.`);
|
|
248
|
+
}
|
|
249
|
+
// Snapshot before the first await. A caller may zero or reuse its Buffer as
|
|
250
|
+
// soon as this function yields while waiting for another creator's lock.
|
|
251
|
+
const creationKey = Buffer.from(opts.key);
|
|
252
|
+
const requestedVaultPath = opts.path ?? DEFAULT_VAULT_PATH;
|
|
253
|
+
// Retain a useful target even when canonicalization itself fails. Once
|
|
254
|
+
// resolution succeeds this becomes the canonical path used everywhere else.
|
|
255
|
+
let vaultPath = requestedVaultPath;
|
|
256
|
+
let release = null;
|
|
257
|
+
let tmpVault = null;
|
|
258
|
+
let vaultPublished = false;
|
|
259
|
+
try {
|
|
260
|
+
vaultPath = resolveProspectiveVaultPath(requestedVaultPath);
|
|
261
|
+
const sidecarPath = resolveProspectiveVaultPath(opts.sidecarPath ?? join(dirname(vaultPath), "vault.seen-version"));
|
|
262
|
+
if (sidecarPath === vaultPath) {
|
|
263
|
+
throw new VaultError("VAULT_CREATE_CONFLICT", "Vault and sidecar paths must be different files.");
|
|
264
|
+
}
|
|
265
|
+
if (isDescendantPath(vaultPath, sidecarPath) ||
|
|
266
|
+
isDescendantPath(sidecarPath, vaultPath)) {
|
|
267
|
+
throw new VaultError("VAULT_CREATE_CONFLICT", "Vault and sidecar paths must not contain one another.");
|
|
268
|
+
}
|
|
269
|
+
mkdirSync(dirname(vaultPath), { recursive: true, mode: VAULT_DIR_MODE });
|
|
270
|
+
mkdirSync(dirname(sidecarPath), { recursive: true, mode: VAULT_DIR_MODE });
|
|
271
|
+
release = await acquireCreateLocks(vaultPath, sidecarPath);
|
|
272
|
+
const vaultExists = existsSync(vaultPath);
|
|
273
|
+
const sidecarExists = existsSync(sidecarPath);
|
|
274
|
+
if (vaultExists && sidecarExists) {
|
|
275
|
+
if (sameFileIdentity(vaultPath, sidecarPath)) {
|
|
276
|
+
throw new VaultError("VAULT_CREATE_CONFLICT", "Vault and sidecar paths identify the same filesystem object.");
|
|
277
|
+
}
|
|
278
|
+
return { created: false, path: vaultPath };
|
|
279
|
+
}
|
|
280
|
+
if (vaultExists || sidecarExists) {
|
|
281
|
+
const present = vaultExists ? "vault" : "sidecar";
|
|
282
|
+
const missing = vaultExists ? "sidecar" : "vault";
|
|
283
|
+
throw new VaultError("VAULT_CREATE_CONFLICT", `Refusing to create vault: ${present} exists but ${missing} is missing. ` +
|
|
284
|
+
"Investigate the incomplete pair; first-use creation never repairs or overwrites it.");
|
|
285
|
+
}
|
|
286
|
+
const payload = {
|
|
287
|
+
schema: VAULT_SCHEMA_VERSION,
|
|
288
|
+
vaultVersion: 1,
|
|
289
|
+
secrets: {},
|
|
290
|
+
};
|
|
291
|
+
const encrypted = encryptObject(payload, creationKey, deriveAad(vaultPath, VAULT_SCHEMA_VERSION));
|
|
292
|
+
if (encrypted === null) {
|
|
293
|
+
throw new VaultError("VAULT_ENCRYPT_FAILED", "Encryption returned null; vault was not created.");
|
|
294
|
+
}
|
|
295
|
+
const tmpVaultCandidate = `${vaultPath}.${randomBytes(8).toString("hex")}.tmp`;
|
|
296
|
+
const tmpFd = openSync(tmpVaultCandidate, "wx", VAULT_FILE_MODE);
|
|
297
|
+
tmpVault = tmpVaultCandidate;
|
|
298
|
+
let tmpClosed = false;
|
|
299
|
+
try {
|
|
300
|
+
writeFileSync(tmpFd, encrypted);
|
|
301
|
+
closeSync(tmpFd);
|
|
302
|
+
tmpClosed = true;
|
|
303
|
+
}
|
|
304
|
+
finally {
|
|
305
|
+
if (!tmpClosed) {
|
|
306
|
+
try {
|
|
307
|
+
closeSync(tmpFd);
|
|
308
|
+
}
|
|
309
|
+
catch {
|
|
310
|
+
// Preserve the write/first-close failure; cleanup follows below.
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
linkSync(tmpVault, vaultPath);
|
|
315
|
+
vaultPublished = true;
|
|
316
|
+
createSidecar(sidecarPath, { highestSeenVersion: 1 });
|
|
317
|
+
// Both public paths are committed. link(2) keeps the source temp, so
|
|
318
|
+
// remove it without converting an otherwise successful creation into a
|
|
319
|
+
// false failure if cleanup itself is unavailable.
|
|
320
|
+
try {
|
|
321
|
+
unlinkSync(tmpVault);
|
|
322
|
+
tmpVault = null;
|
|
323
|
+
}
|
|
324
|
+
catch (cleanupErr) {
|
|
325
|
+
const detail = cleanupErr instanceof Error
|
|
326
|
+
? cleanupErr.message
|
|
327
|
+
: String(cleanupErr);
|
|
328
|
+
process.stderr.write(`[secrets] WARNING: vault created but temp file ${tmpVault} could not be removed: ${detail}.\n`);
|
|
329
|
+
}
|
|
330
|
+
return { created: true, path: vaultPath };
|
|
331
|
+
}
|
|
332
|
+
catch (err) {
|
|
333
|
+
let cleanupFailure = null;
|
|
334
|
+
// If the sidecar did not commit, remove only the vault inode published by
|
|
335
|
+
// this call. A non-cooperating writer may have replaced the pathname after
|
|
336
|
+
// publication; filesystem identity prevents us from deleting its file.
|
|
337
|
+
if (vaultPublished && tmpVault !== null) {
|
|
338
|
+
const tmpIdentity = inspectFileIdentity(tmpVault);
|
|
339
|
+
const vaultIdentity = inspectFileIdentity(vaultPath);
|
|
340
|
+
if (vaultIdentity.state === "found") {
|
|
341
|
+
if (tmpIdentity.state !== "found") {
|
|
342
|
+
cleanupFailure = tmpIdentity.state === "unknown"
|
|
343
|
+
? tmpIdentity.cause
|
|
344
|
+
: new Error(`Cannot verify ownership of published vault ${vaultPath}: its source temp is missing.`);
|
|
345
|
+
}
|
|
346
|
+
else if (tmpIdentity.identity.dev === vaultIdentity.identity.dev &&
|
|
347
|
+
tmpIdentity.identity.ino === vaultIdentity.identity.ino) {
|
|
348
|
+
try {
|
|
349
|
+
unlinkSync(vaultPath);
|
|
350
|
+
}
|
|
351
|
+
catch (cleanupErr) {
|
|
352
|
+
if (cleanupErr.code !== "ENOENT") {
|
|
353
|
+
cleanupFailure = cleanupErr;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
// A different identity means a non-cooperating writer replaced the
|
|
358
|
+
// pathname; it is not ours to remove.
|
|
359
|
+
}
|
|
360
|
+
else if (vaultIdentity.state === "unknown") {
|
|
361
|
+
cleanupFailure = vaultIdentity.cause;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
if (tmpVault !== null) {
|
|
365
|
+
try {
|
|
366
|
+
unlinkSync(tmpVault);
|
|
367
|
+
}
|
|
368
|
+
catch (cleanupErr) {
|
|
369
|
+
if (cleanupErr.code !== "ENOENT" &&
|
|
370
|
+
cleanupFailure === null) {
|
|
371
|
+
cleanupFailure = cleanupErr;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
if (cleanupFailure !== null) {
|
|
376
|
+
const createDetail = err instanceof Error ? err.message : String(err);
|
|
377
|
+
const cleanupDetail = cleanupFailure instanceof Error
|
|
378
|
+
? cleanupFailure.message
|
|
379
|
+
: String(cleanupFailure);
|
|
380
|
+
throw new VaultError("VAULT_CREATE_ROLLBACK_FAILED", `Vault creation failed (${createDetail}) and its temporary artifacts could not be removed: ${cleanupDetail}`);
|
|
381
|
+
}
|
|
382
|
+
if (err instanceof VaultError)
|
|
383
|
+
throw err;
|
|
384
|
+
if (err.code === "EEXIST") {
|
|
385
|
+
throw new VaultError("VAULT_CREATE_CONFLICT", "Refusing to create vault: a vault or sidecar destination appeared during creation and was not replaced.");
|
|
386
|
+
}
|
|
387
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
388
|
+
throw new VaultError("VAULT_CREATE_FAILED", `Failed to create vault at ${vaultPath}: ${detail}`);
|
|
389
|
+
}
|
|
390
|
+
finally {
|
|
391
|
+
creationKey.fill(0);
|
|
392
|
+
release?.();
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
// =============================================================================
|
|
137
396
|
// openVault — factory
|
|
138
397
|
// =============================================================================
|
|
139
398
|
/**
|
|
@@ -215,41 +474,19 @@ export async function openVault(opts = {}) {
|
|
|
215
474
|
// write, at which point they're upgraded automatically and become
|
|
216
475
|
// AAD-bound going forward.
|
|
217
476
|
const initialBytes = readFileSync(vaultPath);
|
|
218
|
-
let
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
const legacy = decryptObject(initialBytes, key);
|
|
222
|
-
if (legacy !== null) {
|
|
223
|
-
decoded = legacy;
|
|
224
|
-
openedAsLegacy = true;
|
|
225
|
-
}
|
|
226
|
-
else {
|
|
227
|
-
key.fill(0);
|
|
228
|
-
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).`);
|
|
229
|
-
}
|
|
477
|
+
let decodeResult;
|
|
478
|
+
try {
|
|
479
|
+
decodeResult = decodeVaultBytes(initialBytes, key, aad, vaultPath);
|
|
230
480
|
}
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
key.fill(0);
|
|
241
|
-
throw new VaultDecryptError(`Vault decrypted without AAD but contained a non-string value at key "${k}" — not a legacy CLI vault; possible corruption.`);
|
|
242
|
-
}
|
|
243
|
-
secrets[k] = v;
|
|
244
|
-
}
|
|
245
|
-
payload = { schema: 0, vaultVersion: 0, secrets };
|
|
246
|
-
process.stderr.write(`[secrets] Opened legacy (pre-schema, AAD-less) vault at ${vaultPath}; ` +
|
|
247
|
-
`will auto-upgrade to schema ${VAULT_SCHEMA_VERSION} with AAD binding on next write.\n`);
|
|
248
|
-
}
|
|
249
|
-
else {
|
|
250
|
-
key.fill(0);
|
|
251
|
-
throw new VaultDecryptError("Decrypted payload has invalid shape — possible corruption or format mismatch.");
|
|
252
|
-
}
|
|
481
|
+
catch (err) {
|
|
482
|
+
key.fill(0);
|
|
483
|
+
throw err;
|
|
484
|
+
}
|
|
485
|
+
const payload = decodeResult.payload;
|
|
486
|
+
const openedAsLegacy = decodeResult.openedAsLegacy;
|
|
487
|
+
if (decodeResult.payload.schema === 0) {
|
|
488
|
+
process.stderr.write(`[secrets] Opened legacy (pre-schema, AAD-less) vault at ${vaultPath}; ` +
|
|
489
|
+
`will auto-upgrade to schema ${VAULT_SCHEMA_VERSION} with AAD binding on next write.\n`);
|
|
253
490
|
}
|
|
254
491
|
// --- Rollback check ---
|
|
255
492
|
const sidecar = readSidecar(sidecarPath);
|
|
@@ -301,6 +538,168 @@ export async function openVault(opts = {}) {
|
|
|
301
538
|
ttlMs,
|
|
302
539
|
});
|
|
303
540
|
}
|
|
541
|
+
/**
|
|
542
|
+
* Re-encrypt a vault in place under a different master key.
|
|
543
|
+
*
|
|
544
|
+
* Needed whenever the new key cannot be *chosen* — a `PassphraseProvider`
|
|
545
|
+
* derives its key from what the operator types and cannot store a
|
|
546
|
+
* caller-supplied one, so moving a vault to it means rewriting the ciphertext
|
|
547
|
+
* rather than copying the key to a new home. Provider-to-provider moves that
|
|
548
|
+
* only relocate the same key do not need this.
|
|
549
|
+
*
|
|
550
|
+
* The AAD binds the vault's resolved real path and schema, not the key, so the
|
|
551
|
+
* rekeyed ciphertext stays bound to the same file identity. Legacy AAD-less
|
|
552
|
+
* vaults are upgraded to schema 1 in the same step. The new version is one
|
|
553
|
+
* above both the payload's version and the sidecar's high-water mark, so a
|
|
554
|
+
* rekeyed vault can never land below a version already seen.
|
|
555
|
+
*
|
|
556
|
+
* Any process holding this vault open under the old key will fail its next
|
|
557
|
+
* decrypt with {@link VaultDecryptError} — an honest failure, by design: the
|
|
558
|
+
* old key genuinely no longer opens this vault.
|
|
559
|
+
*
|
|
560
|
+
* **Post-commit guarantee.** With one named exception, a throw means `commit`
|
|
561
|
+
* did not succeed and the prior ciphertext is back in place — nothing after a
|
|
562
|
+
* successful `commit` can throw, because the only step that follows it (the
|
|
563
|
+
* sidecar bump) is reported via {@link RekeyVaultResult.sidecarPublished}
|
|
564
|
+
* rather than thrown. Callers may therefore treat a throw as "nothing was
|
|
565
|
+
* committed" and clean up accordingly; that is what makes it safe for the CLI
|
|
566
|
+
* to delete freshly written passphrase metadata on failure.
|
|
567
|
+
*
|
|
568
|
+
* **The exception: {@link VaultRestoreError}.** If `commit` throws *and* the
|
|
569
|
+
* rollback write then fails, the file on disk may still be the new ciphertext.
|
|
570
|
+
* That state is neither committed nor undone, so it gets its own error type —
|
|
571
|
+
* callers MUST special-case it and must NOT discard key material for either
|
|
572
|
+
* key. Every other throw keeps the plain guarantee.
|
|
573
|
+
*
|
|
574
|
+
* @param opts - {@link RekeyVaultOptions}. `currentKey`/`nextKey` are required;
|
|
575
|
+
* both remain owned by the caller and are never zeroed here.
|
|
576
|
+
* @returns {@link RekeyVaultResult}.
|
|
577
|
+
* @throws {@link VaultError} `VAULT_NOT_FOUND` when the vault file is absent.
|
|
578
|
+
* @throws {@link VaultDecryptError} when `currentKey` does not open the vault.
|
|
579
|
+
* @throws {@link VaultError} `VAULT_SIDECAR_MISSING` when the sidecar is absent
|
|
580
|
+
* and neither `acceptMissingSidecar` nor the legacy exemption applies.
|
|
581
|
+
* @throws {@link VaultRollbackError} when the vault version trails the sidecar
|
|
582
|
+
* and `acceptRollback` is not set.
|
|
583
|
+
* @throws {@link VaultError} `VAULT_ENCRYPT_FAILED` when re-encryption fails.
|
|
584
|
+
* @throws Whatever `commit` throws, after restoring the prior ciphertext.
|
|
585
|
+
*/
|
|
586
|
+
export async function rekeyVault(opts) {
|
|
587
|
+
const vaultPath = resolveVaultPath(opts.path ?? DEFAULT_VAULT_PATH);
|
|
588
|
+
const sidecarPath = pathResolve(opts.sidecarPath ?? join(dirname(vaultPath), "vault.seen-version"));
|
|
589
|
+
if (!existsSync(vaultPath)) {
|
|
590
|
+
throw new VaultError("VAULT_NOT_FOUND", `Vault file not found at ${vaultPath}. Initialize with \`centient secrets init\`.`);
|
|
591
|
+
}
|
|
592
|
+
checkVaultPerms(vaultPath);
|
|
593
|
+
checkSidecarPerms(sidecarPath);
|
|
594
|
+
const aad = deriveAad(vaultPath, VAULT_SCHEMA_VERSION);
|
|
595
|
+
const release = await acquireWriteLock(vaultPath);
|
|
596
|
+
try {
|
|
597
|
+
const originalBytes = readFileSync(vaultPath);
|
|
598
|
+
const { payload, openedAsLegacy } = decodeVaultBytes(originalBytes, opts.currentKey, aad, vaultPath);
|
|
599
|
+
// Rollback protection, same invariant openVault enforces. A rekey that
|
|
600
|
+
// accepted a rolled-back vault would be strictly worse than an open: it
|
|
601
|
+
// would re-publish the stale secrets at a fresh version and launder the
|
|
602
|
+
// rollback past every later check.
|
|
603
|
+
const sidecar = readSidecar(sidecarPath);
|
|
604
|
+
if (sidecar === null) {
|
|
605
|
+
if (opts.acceptMissingSidecar !== true && !openedAsLegacy) {
|
|
606
|
+
throw new VaultError("VAULT_SIDECAR_MISSING", `Sidecar file ${sidecarPath} is missing. Rollback protection requires ` +
|
|
607
|
+
`the sidecar to exist. If this is a legitimate first-use context, ` +
|
|
608
|
+
`pass { acceptMissingSidecar: true } to rekeyVault(). If the sidecar ` +
|
|
609
|
+
`was unexpectedly deleted, investigate before re-encrypting.`);
|
|
610
|
+
}
|
|
611
|
+
process.stderr.write(`[secrets] WARNING: sidecar file ${sidecarPath} is missing; ` +
|
|
612
|
+
`re-encrypting anyway per ` +
|
|
613
|
+
`${openedAsLegacy ? "legacy vault migration" : "acceptMissingSidecar: true"}.\n`);
|
|
614
|
+
}
|
|
615
|
+
else if (payload.vaultVersion < sidecar.highestSeenVersion) {
|
|
616
|
+
if (opts.acceptRollback !== true) {
|
|
617
|
+
throw new VaultRollbackError(sidecar.highestSeenVersion, payload.vaultVersion);
|
|
618
|
+
}
|
|
619
|
+
process.stderr.write(`[secrets] WARNING: re-encrypting a vault at version ` +
|
|
620
|
+
`${payload.vaultVersion} below the highest seen version ` +
|
|
621
|
+
`${sidecar.highestSeenVersion} per acceptRollback: true.\n`);
|
|
622
|
+
}
|
|
623
|
+
// Stay above the sidecar high-water mark as well as the payload's own
|
|
624
|
+
// version — an accepted rollback must still not land below a version the
|
|
625
|
+
// sidecar has already recorded.
|
|
626
|
+
const nextVersion = Math.max(payload.vaultVersion, sidecar?.highestSeenVersion ?? 0) + 1;
|
|
627
|
+
const nextPayload = {
|
|
628
|
+
schema: VAULT_SCHEMA_VERSION,
|
|
629
|
+
vaultVersion: nextVersion,
|
|
630
|
+
secrets: payload.secrets,
|
|
631
|
+
};
|
|
632
|
+
const encrypted = encryptObject(nextPayload, opts.nextKey, aad);
|
|
633
|
+
if (encrypted === null) {
|
|
634
|
+
throw new VaultError("VAULT_ENCRYPT_FAILED", "Re-encryption returned null — corrupted state; vault left unchanged.");
|
|
635
|
+
}
|
|
636
|
+
// Atomic swap, same temp-then-rename discipline as writeOp.
|
|
637
|
+
mkdirSync(dirname(vaultPath), { recursive: true, mode: VAULT_DIR_MODE });
|
|
638
|
+
const writeAtomically = (bytes) => {
|
|
639
|
+
const tmp = `${vaultPath}.${randomBytes(8).toString("hex")}.tmp`;
|
|
640
|
+
writeFileSync(tmp, bytes, { mode: VAULT_FILE_MODE });
|
|
641
|
+
renameSync(tmp, vaultPath);
|
|
642
|
+
};
|
|
643
|
+
writeAtomically(encrypted);
|
|
644
|
+
if (opts.commit !== undefined) {
|
|
645
|
+
try {
|
|
646
|
+
await opts.commit();
|
|
647
|
+
}
|
|
648
|
+
catch (err) {
|
|
649
|
+
// Put the original ciphertext back. The sidecar has not moved, so the
|
|
650
|
+
// restored (lower) version is still at or above its high-water mark.
|
|
651
|
+
try {
|
|
652
|
+
writeAtomically(originalBytes);
|
|
653
|
+
}
|
|
654
|
+
catch (restoreErr) {
|
|
655
|
+
// Both halves failed: the commit did not land AND the prior
|
|
656
|
+
// ciphertext is not reliably back. Whatever is on disk may be the
|
|
657
|
+
// NEW ciphertext, so this is NOT the ordinary "nothing committed"
|
|
658
|
+
// failure and must not be reported as one — a caller that cleans up
|
|
659
|
+
// on failure would discard key material the vault might now depend
|
|
660
|
+
// on. Distinct error type, distinct handling.
|
|
661
|
+
throw new VaultRestoreError(vaultPath, nextVersion, err, restoreErr);
|
|
662
|
+
}
|
|
663
|
+
throw err;
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
// Publish the version bump only once the whole step has committed.
|
|
667
|
+
//
|
|
668
|
+
// Past this line the rekey IS committed — the ciphertext landed and the
|
|
669
|
+
// caller's external commitment succeeded — so a sidecar failure here must
|
|
670
|
+
// NOT throw. Unwinding would report a committed rekey as a failed one, and
|
|
671
|
+
// a caller that cleans up on failure would then destroy key material the
|
|
672
|
+
// vault now depends on (for `migrate --to passphrase`, the metadata holding
|
|
673
|
+
// the salt: the vault would be permanently unopenable). A lagging sidecar
|
|
674
|
+
// is the benign direction — the next open sees `vaultVersion >
|
|
675
|
+
// highestSeenVersion` and catches it up — which is the same asymmetry
|
|
676
|
+
// `writeOp` relies on for a crash between the two writes.
|
|
677
|
+
let sidecarPublished = true;
|
|
678
|
+
try {
|
|
679
|
+
writeSidecar(sidecarPath, {
|
|
680
|
+
highestSeenVersion: Math.max(sidecar?.highestSeenVersion ?? 0, nextVersion),
|
|
681
|
+
});
|
|
682
|
+
}
|
|
683
|
+
catch (err) {
|
|
684
|
+
sidecarPublished = false;
|
|
685
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
686
|
+
process.stderr.write(`[secrets] WARNING: vault re-encrypted successfully at version ` +
|
|
687
|
+
`${nextVersion}, but the sidecar ${sidecarPath} could not be ` +
|
|
688
|
+
`updated: ${message}. The rekey is committed and the vault is ` +
|
|
689
|
+
`usable; the sidecar catches up on the next successful write. ` +
|
|
690
|
+
`Rollback protection is degraded until it does.\n`);
|
|
691
|
+
}
|
|
692
|
+
return {
|
|
693
|
+
secretCount: Object.keys(payload.secrets).length,
|
|
694
|
+
vaultVersion: nextVersion,
|
|
695
|
+
upgradedFromLegacy: openedAsLegacy,
|
|
696
|
+
sidecarPublished,
|
|
697
|
+
};
|
|
698
|
+
}
|
|
699
|
+
finally {
|
|
700
|
+
release();
|
|
701
|
+
}
|
|
702
|
+
}
|
|
304
703
|
function buildVault(args) {
|
|
305
704
|
let key = args.key;
|
|
306
705
|
let secrets = args.currentSecrets;
|
|
@@ -321,13 +720,22 @@ function buildVault(args) {
|
|
|
321
720
|
if (closed || key === null)
|
|
322
721
|
throw new VaultClosedError();
|
|
323
722
|
};
|
|
723
|
+
const assertVersionNotRolledBack = (candidateVersion) => {
|
|
724
|
+
const sidecar = readSidecar(args.sidecarPath);
|
|
725
|
+
const highestTrustedVersion = Math.max(vaultVersion, sidecar?.highestSeenVersion ?? vaultVersion);
|
|
726
|
+
if (candidateVersion < highestTrustedVersion) {
|
|
727
|
+
throw new VaultRollbackError(highestTrustedVersion, candidateVersion);
|
|
728
|
+
}
|
|
729
|
+
return sidecar;
|
|
730
|
+
};
|
|
324
731
|
/**
|
|
325
732
|
* Refresh in-memory state from disk if the coherence strategy says to and
|
|
326
|
-
* mtime
|
|
327
|
-
*
|
|
733
|
+
* mtime changed. Writers force a refresh after taking the lock because mtime
|
|
734
|
+
* equality is not a safe write-side freshness check. Throws VaultError on a
|
|
735
|
+
* missing vault file (M4) and VaultDecryptError on decrypt failure.
|
|
328
736
|
*/
|
|
329
|
-
const maybeReload = () => {
|
|
330
|
-
if (args.coherence === "best-effort")
|
|
737
|
+
const maybeReload = (forceFromDisk = false) => {
|
|
738
|
+
if (!forceFromDisk && args.coherence === "best-effort")
|
|
331
739
|
return;
|
|
332
740
|
// Drop existsSync — statSync already throws ENOENT. Translating the error
|
|
333
741
|
// gives us one clean code path and one fewer syscall (M4).
|
|
@@ -341,7 +749,7 @@ function buildVault(args) {
|
|
|
341
749
|
}
|
|
342
750
|
throw err;
|
|
343
751
|
}
|
|
344
|
-
if (st.mtimeMs === mtimeMs)
|
|
752
|
+
if (!forceFromDisk && st.mtimeMs === mtimeMs)
|
|
345
753
|
return;
|
|
346
754
|
if (args.coherence === "strict" && st.mtimeMs > mtimeMs) {
|
|
347
755
|
// `strict` means the caller wants an explicit reload(); block reads.
|
|
@@ -380,10 +788,19 @@ function buildVault(args) {
|
|
|
380
788
|
throw new VaultDecryptError("Decrypted payload has invalid shape after external change — possible corruption.");
|
|
381
789
|
}
|
|
382
790
|
}
|
|
791
|
+
const sidecar = assertVersionNotRolledBack(payload.vaultVersion);
|
|
792
|
+
// A same-mtime replacement is possible on fast or coarse filesystems.
|
|
793
|
+
// Writes force a disk read while holding the file lock, so strict mode
|
|
794
|
+
// must still reject a snapshot whose encrypted version advanced even when
|
|
795
|
+
// the timestamp could not distinguish the replacement.
|
|
796
|
+
if (forceFromDisk &&
|
|
797
|
+
args.coherence === "strict" &&
|
|
798
|
+
payload.vaultVersion !== vaultVersion) {
|
|
799
|
+
throw new VaultError("VAULT_STALE_SNAPSHOT", `Vault file version advanced externally (${payload.vaultVersion} vs session ${vaultVersion}); call reload() to continue.`);
|
|
800
|
+
}
|
|
383
801
|
secrets = { ...payload.secrets };
|
|
384
802
|
vaultVersion = payload.vaultVersion;
|
|
385
803
|
mtimeMs = st.mtimeMs;
|
|
386
|
-
const sidecar = readSidecar(args.sidecarPath);
|
|
387
804
|
if (sidecar === null || payload.vaultVersion > sidecar.highestSeenVersion) {
|
|
388
805
|
writeSidecar(args.sidecarPath, { highestSeenVersion: payload.vaultVersion });
|
|
389
806
|
}
|
|
@@ -401,9 +818,14 @@ function buildVault(args) {
|
|
|
401
818
|
// Re-check open after awaiting the lock — TTL or a sibling close()
|
|
402
819
|
// could have fired while we were queued (H2).
|
|
403
820
|
assertOpen();
|
|
404
|
-
|
|
821
|
+
// The lock serializes writers, but mtime alone cannot prove that the
|
|
822
|
+
// ciphertext is unchanged: two atomic replacements can share an mtime
|
|
823
|
+
// on fast or coarse filesystems. Re-read under the lock before applying
|
|
824
|
+
// the mutation so a later writer always merges the prior commit.
|
|
825
|
+
maybeReload(true);
|
|
405
826
|
const next = { ...secrets };
|
|
406
|
-
mutator(next)
|
|
827
|
+
if (!mutator(next))
|
|
828
|
+
return false;
|
|
407
829
|
const nextVersion = vaultVersion + 1;
|
|
408
830
|
const payload = {
|
|
409
831
|
schema: VAULT_SCHEMA_VERSION,
|
|
@@ -434,6 +856,7 @@ function buildVault(args) {
|
|
|
434
856
|
secrets = next;
|
|
435
857
|
vaultVersion = nextVersion;
|
|
436
858
|
mtimeMs = statSync(args.vaultPath).mtimeMs;
|
|
859
|
+
return true;
|
|
437
860
|
}
|
|
438
861
|
finally {
|
|
439
862
|
release();
|
|
@@ -480,44 +903,48 @@ function buildVault(args) {
|
|
|
480
903
|
*/
|
|
481
904
|
const withAudit = async (op, successType, missingType, failType, fn, extras) => {
|
|
482
905
|
assertOpen();
|
|
483
|
-
const
|
|
484
|
-
|
|
906
|
+
const wallStartedAt = Date.now();
|
|
907
|
+
const monotonicStartedAt = performance.now();
|
|
908
|
+
const policyFrame = await runBeforeHooks(op, (error) => ({
|
|
485
909
|
type: rejectedEventType(op),
|
|
486
|
-
timestamp: new Date(
|
|
910
|
+
timestamp: new Date(wallStartedAt).toISOString(),
|
|
487
911
|
backend: "session-vault",
|
|
912
|
+
operation: op,
|
|
488
913
|
key: op.key,
|
|
489
914
|
prefix: op.prefix,
|
|
490
915
|
error,
|
|
491
|
-
durationMs:
|
|
916
|
+
durationMs: performance.now() - monotonicStartedAt,
|
|
492
917
|
}));
|
|
493
|
-
// Re-check after the await — TTL or sibling close() could have fired
|
|
494
|
-
// while before-hooks awaited (H2).
|
|
495
|
-
assertOpen();
|
|
496
|
-
const start = Date.now();
|
|
497
918
|
try {
|
|
919
|
+
// Re-check after the await — TTL or sibling close() could have fired
|
|
920
|
+
// while before-hooks awaited (H2). Keep this inside the audited region
|
|
921
|
+
// so the captured policy frame observes the resulting failure.
|
|
922
|
+
assertOpen();
|
|
498
923
|
const value = await fn();
|
|
499
924
|
const isMissing = missingType !== null && (value === null || value === false);
|
|
500
925
|
runAfterHooks({
|
|
501
926
|
type: isMissing ? missingType : successType,
|
|
502
|
-
timestamp: new Date(
|
|
927
|
+
timestamp: new Date(wallStartedAt).toISOString(),
|
|
503
928
|
backend: "session-vault",
|
|
929
|
+
operation: op,
|
|
504
930
|
key: op.key,
|
|
505
931
|
prefix: op.prefix,
|
|
506
932
|
...(extras !== undefined ? extras(value) : {}),
|
|
507
|
-
durationMs:
|
|
508
|
-
});
|
|
933
|
+
durationMs: performance.now() - monotonicStartedAt,
|
|
934
|
+
}, policyFrame);
|
|
509
935
|
return value;
|
|
510
936
|
}
|
|
511
937
|
catch (err) {
|
|
512
938
|
runAfterHooks({
|
|
513
939
|
type: failType,
|
|
514
|
-
timestamp: new Date(
|
|
940
|
+
timestamp: new Date(wallStartedAt).toISOString(),
|
|
515
941
|
backend: "session-vault",
|
|
942
|
+
operation: op,
|
|
516
943
|
key: op.key,
|
|
517
944
|
prefix: op.prefix,
|
|
518
945
|
error: err instanceof Error ? err.message : String(err),
|
|
519
|
-
durationMs:
|
|
520
|
-
});
|
|
946
|
+
durationMs: performance.now() - monotonicStartedAt,
|
|
947
|
+
}, policyFrame);
|
|
521
948
|
throw err;
|
|
522
949
|
}
|
|
523
950
|
};
|
|
@@ -541,20 +968,22 @@ function buildVault(args) {
|
|
|
541
968
|
// `async` keyword ensures a sync throw from validateName surfaces as a
|
|
542
969
|
// promise rejection, matching the declared `Promise<void>` contract.
|
|
543
970
|
validateName(name);
|
|
544
|
-
return withAudit({ type: "write", key: name }, "credential_written", null, "credential_write_failed",
|
|
545
|
-
current[name] = value;
|
|
546
|
-
}));
|
|
547
|
-
},
|
|
548
|
-
delete(name) {
|
|
549
|
-
return withAudit({ type: "delete", key: name }, "credential_deleted", "credential_delete_failed", "credential_delete_failed", async () => {
|
|
550
|
-
if (!(name in secrets))
|
|
551
|
-
return false;
|
|
971
|
+
return withAudit({ type: "write", key: name }, "credential_written", null, "credential_write_failed", async () => {
|
|
552
972
|
await writeOp((current) => {
|
|
553
|
-
|
|
973
|
+
current[name] = value;
|
|
974
|
+
return true;
|
|
554
975
|
});
|
|
555
|
-
return true;
|
|
556
976
|
});
|
|
557
977
|
},
|
|
978
|
+
delete(name) {
|
|
979
|
+
return withAudit({ type: "delete", key: name }, "credential_deleted", "credential_delete_failed", "credential_delete_failed", () => writeOp((current) => {
|
|
980
|
+
if (name in current) {
|
|
981
|
+
delete current[name];
|
|
982
|
+
return true;
|
|
983
|
+
}
|
|
984
|
+
return false;
|
|
985
|
+
}));
|
|
986
|
+
},
|
|
558
987
|
async reload() {
|
|
559
988
|
assertOpen();
|
|
560
989
|
let st;
|
|
@@ -599,6 +1028,7 @@ function buildVault(args) {
|
|
|
599
1028
|
throw new VaultDecryptError("Decrypted payload has invalid shape during reload — possible corruption.");
|
|
600
1029
|
}
|
|
601
1030
|
}
|
|
1031
|
+
assertVersionNotRolledBack(payload.vaultVersion);
|
|
602
1032
|
secrets = { ...payload.secrets };
|
|
603
1033
|
vaultVersion = payload.vaultVersion;
|
|
604
1034
|
mtimeMs = st.mtimeMs;
|
|
@@ -665,6 +1095,52 @@ function validatePayload(decoded) {
|
|
|
665
1095
|
secrets,
|
|
666
1096
|
};
|
|
667
1097
|
}
|
|
1098
|
+
/**
|
|
1099
|
+
* Decrypt and validate raw vault bytes.
|
|
1100
|
+
*
|
|
1101
|
+
* Layered decrypt: AAD-bound v1 first, then the pre-`openVault` AAD-less CLI
|
|
1102
|
+
* shape. A v1 decrypt that yields an unrecognized payload is a hard failure;
|
|
1103
|
+
* only an AAD-less decrypt may fall through to the legacy flat
|
|
1104
|
+
* `{ name: value }` reconstruction, which is surfaced as `schema: 0`.
|
|
1105
|
+
*
|
|
1106
|
+
* Shared by {@link openVault} and {@link rekeyVault} so the two entry points
|
|
1107
|
+
* that must agree on "what is on disk" cannot drift. The in-session reload
|
|
1108
|
+
* paths keep their own copies deliberately — their failure messages name the
|
|
1109
|
+
* reload context, and their legacy handling is intentionally looser (a vault
|
|
1110
|
+
* already opened as v1 can still be reloaded from a legacy write).
|
|
1111
|
+
*
|
|
1112
|
+
* @throws {@link VaultDecryptError} when neither decrypt succeeds, or when the
|
|
1113
|
+
* decrypted payload has no recognizable shape.
|
|
1114
|
+
*/
|
|
1115
|
+
function decodeVaultBytes(bytes, key, aad, vaultPath) {
|
|
1116
|
+
let decoded = decryptObject(bytes, key, aad);
|
|
1117
|
+
let openedAsLegacy = false;
|
|
1118
|
+
if (decoded === null) {
|
|
1119
|
+
const legacy = decryptObject(bytes, key);
|
|
1120
|
+
if (legacy === null) {
|
|
1121
|
+
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).`);
|
|
1122
|
+
}
|
|
1123
|
+
decoded = legacy;
|
|
1124
|
+
openedAsLegacy = true;
|
|
1125
|
+
}
|
|
1126
|
+
const payload = validatePayload(decoded);
|
|
1127
|
+
if (payload !== null)
|
|
1128
|
+
return { payload, openedAsLegacy };
|
|
1129
|
+
// Legacy flat-shape detection: a pre-openVault CLI vault is a flat
|
|
1130
|
+
// `{ name: value, ... }` map at the top level. If every value is a string
|
|
1131
|
+
// and there's no `schema` field, accept as legacy schema-0.
|
|
1132
|
+
if (!openedAsLegacy || "schema" in decoded) {
|
|
1133
|
+
throw new VaultDecryptError("Decrypted payload has invalid shape — possible corruption or format mismatch.");
|
|
1134
|
+
}
|
|
1135
|
+
const secrets = {};
|
|
1136
|
+
for (const [k, v] of Object.entries(decoded)) {
|
|
1137
|
+
if (typeof v !== "string") {
|
|
1138
|
+
throw new VaultDecryptError(`Vault decrypted without AAD but contained a non-string value at key "${k}" — not a legacy CLI vault; possible corruption.`);
|
|
1139
|
+
}
|
|
1140
|
+
secrets[k] = v;
|
|
1141
|
+
}
|
|
1142
|
+
return { payload: { schema: 0, vaultVersion: 0, secrets }, openedAsLegacy };
|
|
1143
|
+
}
|
|
668
1144
|
/**
|
|
669
1145
|
* Reject names with control characters, path separators, null bytes, or
|
|
670
1146
|
* Unicode oddities that can confuse log scrapers, terminals, and path
|