@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.
Files changed (152) hide show
  1. package/README.md +424 -8
  2. package/dist/audit/hmac-chain.d.ts +117 -0
  3. package/dist/audit/hmac-chain.d.ts.map +1 -0
  4. package/dist/audit/hmac-chain.js +811 -0
  5. package/dist/audit/hmac-chain.js.map +1 -0
  6. package/dist/audit/ocsf.d.ts +108 -0
  7. package/dist/audit/ocsf.d.ts.map +1 -0
  8. package/dist/audit/ocsf.js +204 -0
  9. package/dist/audit/ocsf.js.map +1 -0
  10. package/dist/cli/hidden-prompt.d.ts.map +1 -1
  11. package/dist/cli/hidden-prompt.js +55 -10
  12. package/dist/cli/hidden-prompt.js.map +1 -1
  13. package/dist/cli/messages.d.ts +40 -8
  14. package/dist/cli/messages.d.ts.map +1 -1
  15. package/dist/cli/messages.js +34 -8
  16. package/dist/cli/messages.js.map +1 -1
  17. package/dist/cli/secrets-cli.d.ts.map +1 -1
  18. package/dist/cli/secrets-cli.js +191 -32
  19. package/dist/cli/secrets-cli.js.map +1 -1
  20. package/dist/client.d.ts +26 -0
  21. package/dist/client.d.ts.map +1 -0
  22. package/dist/client.js +129 -0
  23. package/dist/client.js.map +1 -0
  24. package/dist/compatibility.d.ts +199 -0
  25. package/dist/compatibility.d.ts.map +1 -0
  26. package/dist/compatibility.js +419 -0
  27. package/dist/compatibility.js.map +1 -0
  28. package/dist/crypto/darwin-keychain.d.ts +394 -0
  29. package/dist/crypto/darwin-keychain.d.ts.map +1 -0
  30. package/dist/crypto/darwin-keychain.js +793 -0
  31. package/dist/crypto/darwin-keychain.js.map +1 -0
  32. package/dist/crypto/vault-common.d.ts +55 -12
  33. package/dist/crypto/vault-common.d.ts.map +1 -1
  34. package/dist/crypto/vault-common.js +74 -71
  35. package/dist/crypto/vault-common.js.map +1 -1
  36. package/dist/environment/EnvironmentManager.d.ts.map +1 -1
  37. package/dist/environment/EnvironmentManager.js +5 -10
  38. package/dist/environment/EnvironmentManager.js.map +1 -1
  39. package/dist/index.d.ts +37 -9
  40. package/dist/index.d.ts.map +1 -1
  41. package/dist/index.js +38 -6
  42. package/dist/index.js.map +1 -1
  43. package/dist/key-providers/keychain-provider.d.ts +30 -2
  44. package/dist/key-providers/keychain-provider.d.ts.map +1 -1
  45. package/dist/key-providers/keychain-provider.js +29 -5
  46. package/dist/key-providers/keychain-provider.js.map +1 -1
  47. package/dist/key-providers/onepassword-provider.d.ts +65 -5
  48. package/dist/key-providers/onepassword-provider.d.ts.map +1 -1
  49. package/dist/key-providers/onepassword-provider.js +159 -67
  50. package/dist/key-providers/onepassword-provider.js.map +1 -1
  51. package/dist/key-providers/op-cli.d.ts +78 -0
  52. package/dist/key-providers/op-cli.d.ts.map +1 -0
  53. package/dist/key-providers/op-cli.js +123 -0
  54. package/dist/key-providers/op-cli.js.map +1 -0
  55. package/dist/key-providers/passphrase-provider.d.ts +20 -0
  56. package/dist/key-providers/passphrase-provider.d.ts.map +1 -1
  57. package/dist/key-providers/passphrase-provider.js +49 -0
  58. package/dist/key-providers/passphrase-provider.js.map +1 -1
  59. package/dist/key-providers/probe.d.ts +46 -0
  60. package/dist/key-providers/probe.d.ts.map +1 -0
  61. package/dist/key-providers/probe.js +70 -0
  62. package/dist/key-providers/probe.js.map +1 -0
  63. package/dist/key-providers/types.d.ts +51 -1
  64. package/dist/key-providers/types.d.ts.map +1 -1
  65. package/dist/platform/agent-detect.d.ts +39 -3
  66. package/dist/platform/agent-detect.d.ts.map +1 -1
  67. package/dist/platform/agent-detect.js +55 -9
  68. package/dist/platform/agent-detect.js.map +1 -1
  69. package/dist/platform/session-context.d.ts +77 -0
  70. package/dist/platform/session-context.d.ts.map +1 -0
  71. package/dist/platform/session-context.js +83 -0
  72. package/dist/platform/session-context.js.map +1 -0
  73. package/dist/policies/audit-trail.d.ts +25 -0
  74. package/dist/policies/audit-trail.d.ts.map +1 -0
  75. package/dist/policies/audit-trail.js +31 -0
  76. package/dist/policies/audit-trail.js.map +1 -0
  77. package/dist/secret-state.d.ts +54 -0
  78. package/dist/secret-state.d.ts.map +1 -0
  79. package/dist/secret-state.js +2 -0
  80. package/dist/secret-state.js.map +1 -0
  81. package/dist/sinks/index.d.ts +9 -0
  82. package/dist/sinks/index.d.ts.map +1 -0
  83. package/dist/sinks/index.js +5 -0
  84. package/dist/sinks/index.js.map +1 -0
  85. package/dist/sinks/jsonl.d.ts +16 -0
  86. package/dist/sinks/jsonl.d.ts.map +1 -0
  87. package/dist/sinks/jsonl.js +51 -0
  88. package/dist/sinks/jsonl.js.map +1 -0
  89. package/dist/sinks/otel.d.ts +22 -0
  90. package/dist/sinks/otel.d.ts.map +1 -0
  91. package/dist/sinks/otel.js +155 -0
  92. package/dist/sinks/otel.js.map +1 -0
  93. package/dist/sinks/queue.d.ts +12 -0
  94. package/dist/sinks/queue.d.ts.map +1 -0
  95. package/dist/sinks/queue.js +89 -0
  96. package/dist/sinks/queue.js.map +1 -0
  97. package/dist/sinks/syslog.d.ts +17 -0
  98. package/dist/sinks/syslog.d.ts.map +1 -0
  99. package/dist/sinks/syslog.js +103 -0
  100. package/dist/sinks/syslog.js.map +1 -0
  101. package/dist/sinks/types.d.ts +27 -0
  102. package/dist/sinks/types.d.ts.map +1 -0
  103. package/dist/sinks/types.js +18 -0
  104. package/dist/sinks/types.js.map +1 -0
  105. package/dist/vault/policy.d.ts +64 -34
  106. package/dist/vault/policy.d.ts.map +1 -1
  107. package/dist/vault/policy.js +109 -67
  108. package/dist/vault/policy.js.map +1 -1
  109. package/dist/vault/session-vault-errors.d.ts +72 -4
  110. package/dist/vault/session-vault-errors.d.ts.map +1 -1
  111. package/dist/vault/session-vault-errors.js +95 -4
  112. package/dist/vault/session-vault-errors.js.map +1 -1
  113. package/dist/vault/session-vault.d.ts +145 -2
  114. package/dist/vault/session-vault.d.ts.map +1 -1
  115. package/dist/vault/session-vault.js +548 -72
  116. package/dist/vault/session-vault.js.map +1 -1
  117. package/dist/vault/sidecar.d.ts +9 -0
  118. package/dist/vault/sidecar.d.ts.map +1 -1
  119. package/dist/vault/sidecar.js +72 -3
  120. package/dist/vault/sidecar.js.map +1 -1
  121. package/dist/vault/types.d.ts +58 -10
  122. package/dist/vault/types.d.ts.map +1 -1
  123. package/dist/vault/types.js.map +1 -1
  124. package/dist/vault/vault-env.d.ts +43 -50
  125. package/dist/vault/vault-env.d.ts.map +1 -1
  126. package/dist/vault/vault-env.js +141 -62
  127. package/dist/vault/vault-env.js.map +1 -1
  128. package/dist/vault/vault-gpg.d.ts +6 -1
  129. package/dist/vault/vault-gpg.d.ts.map +1 -1
  130. package/dist/vault/vault-gpg.js +10 -8
  131. package/dist/vault/vault-gpg.js.map +1 -1
  132. package/dist/vault/vault-libsecret.d.ts +6 -1
  133. package/dist/vault/vault-libsecret.d.ts.map +1 -1
  134. package/dist/vault/vault-libsecret.js +16 -14
  135. package/dist/vault/vault-libsecret.js.map +1 -1
  136. package/dist/vault/vault-onepassword.d.ts +119 -0
  137. package/dist/vault/vault-onepassword.d.ts.map +1 -0
  138. package/dist/vault/vault-onepassword.js +271 -0
  139. package/dist/vault/vault-onepassword.js.map +1 -0
  140. package/dist/vault/vault-utils.d.ts +37 -3
  141. package/dist/vault/vault-utils.d.ts.map +1 -1
  142. package/dist/vault/vault-utils.js +65 -3
  143. package/dist/vault/vault-utils.js.map +1 -1
  144. package/dist/vault/vault-windows.d.ts +7 -2
  145. package/dist/vault/vault-windows.d.ts.map +1 -1
  146. package/dist/vault/vault-windows.js +21 -16
  147. package/dist/vault/vault-windows.js.map +1 -1
  148. package/dist/vault/vault.d.ts +120 -9
  149. package/dist/vault/vault.d.ts.map +1 -1
  150. package/dist/vault/vault.js +222 -139
  151. package/dist/vault/vault.js.map +1 -1
  152. 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, decryptObject } from "../crypto/vault-common.js";
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 decoded = decryptObject(initialBytes, key, aad);
219
- let openedAsLegacy = false;
220
- if (decoded === null) {
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
- let payload = validatePayload(decoded);
232
- if (payload === null) {
233
- // Legacy flat-format detection: a pre-openVault CLI vault is a flat
234
- // `{ name: value, ... }` map at the top level. If every value is a string
235
- // and there's no `schema` field, accept as legacy schema-0.
236
- if (openedAsLegacy && !("schema" in decoded)) {
237
- const secrets = {};
238
- for (const [k, v] of Object.entries(decoded)) {
239
- if (typeof v !== "string") {
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 has advanced. Throws VaultError on a missing vault file (M4) and
327
- * VaultDecryptError on decrypt failure.
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
- maybeReload();
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 beforeStart = Date.now();
484
- await runBeforeHooks(op, (error) => ({
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(beforeStart).toISOString(),
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: Date.now() - beforeStart,
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(start).toISOString(),
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: Date.now() - start,
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(start).toISOString(),
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: Date.now() - start,
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", () => writeOp((current) => {
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
- delete current[name];
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