@centient/secrets 0.9.0 → 0.10.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 (93) hide show
  1. package/README.md +70 -2
  2. package/dist/cli/secrets-cli.d.ts.map +1 -1
  3. package/dist/cli/secrets-cli.js +191 -32
  4. package/dist/cli/secrets-cli.js.map +1 -1
  5. package/dist/crypto/darwin-keychain.d.ts +394 -0
  6. package/dist/crypto/darwin-keychain.d.ts.map +1 -0
  7. package/dist/crypto/darwin-keychain.js +793 -0
  8. package/dist/crypto/darwin-keychain.js.map +1 -0
  9. package/dist/crypto/vault-common.d.ts +55 -12
  10. package/dist/crypto/vault-common.d.ts.map +1 -1
  11. package/dist/crypto/vault-common.js +74 -71
  12. package/dist/crypto/vault-common.js.map +1 -1
  13. package/dist/environment/EnvironmentManager.d.ts.map +1 -1
  14. package/dist/environment/EnvironmentManager.js +5 -10
  15. package/dist/environment/EnvironmentManager.js.map +1 -1
  16. package/dist/index.d.ts +20 -6
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +23 -5
  19. package/dist/index.js.map +1 -1
  20. package/dist/key-providers/keychain-provider.d.ts +30 -2
  21. package/dist/key-providers/keychain-provider.d.ts.map +1 -1
  22. package/dist/key-providers/keychain-provider.js +29 -5
  23. package/dist/key-providers/keychain-provider.js.map +1 -1
  24. package/dist/key-providers/onepassword-provider.d.ts +65 -5
  25. package/dist/key-providers/onepassword-provider.d.ts.map +1 -1
  26. package/dist/key-providers/onepassword-provider.js +159 -67
  27. package/dist/key-providers/onepassword-provider.js.map +1 -1
  28. package/dist/key-providers/op-cli.d.ts +78 -0
  29. package/dist/key-providers/op-cli.d.ts.map +1 -0
  30. package/dist/key-providers/op-cli.js +123 -0
  31. package/dist/key-providers/op-cli.js.map +1 -0
  32. package/dist/key-providers/passphrase-provider.d.ts +20 -0
  33. package/dist/key-providers/passphrase-provider.d.ts.map +1 -1
  34. package/dist/key-providers/passphrase-provider.js +49 -0
  35. package/dist/key-providers/passphrase-provider.js.map +1 -1
  36. package/dist/key-providers/probe.d.ts +46 -0
  37. package/dist/key-providers/probe.d.ts.map +1 -0
  38. package/dist/key-providers/probe.js +70 -0
  39. package/dist/key-providers/probe.js.map +1 -0
  40. package/dist/key-providers/types.d.ts +51 -1
  41. package/dist/key-providers/types.d.ts.map +1 -1
  42. package/dist/platform/agent-detect.d.ts +39 -3
  43. package/dist/platform/agent-detect.d.ts.map +1 -1
  44. package/dist/platform/agent-detect.js +55 -9
  45. package/dist/platform/agent-detect.js.map +1 -1
  46. package/dist/platform/session-context.d.ts +77 -0
  47. package/dist/platform/session-context.d.ts.map +1 -0
  48. package/dist/platform/session-context.js +83 -0
  49. package/dist/platform/session-context.js.map +1 -0
  50. package/dist/secret-state.d.ts +54 -0
  51. package/dist/secret-state.d.ts.map +1 -0
  52. package/dist/secret-state.js +2 -0
  53. package/dist/secret-state.js.map +1 -0
  54. package/dist/vault/session-vault-errors.d.ts +72 -4
  55. package/dist/vault/session-vault-errors.d.ts.map +1 -1
  56. package/dist/vault/session-vault-errors.js +95 -4
  57. package/dist/vault/session-vault-errors.js.map +1 -1
  58. package/dist/vault/session-vault.d.ts +102 -2
  59. package/dist/vault/session-vault.d.ts.map +1 -1
  60. package/dist/vault/session-vault.js +225 -36
  61. package/dist/vault/session-vault.js.map +1 -1
  62. package/dist/vault/types.d.ts +42 -1
  63. package/dist/vault/types.d.ts.map +1 -1
  64. package/dist/vault/types.js.map +1 -1
  65. package/dist/vault/vault-env.d.ts +14 -0
  66. package/dist/vault/vault-env.d.ts.map +1 -1
  67. package/dist/vault/vault-env.js +15 -0
  68. package/dist/vault/vault-env.js.map +1 -1
  69. package/dist/vault/vault-gpg.d.ts +5 -1
  70. package/dist/vault/vault-gpg.d.ts.map +1 -1
  71. package/dist/vault/vault-gpg.js +9 -8
  72. package/dist/vault/vault-gpg.js.map +1 -1
  73. package/dist/vault/vault-libsecret.d.ts +5 -1
  74. package/dist/vault/vault-libsecret.d.ts.map +1 -1
  75. package/dist/vault/vault-libsecret.js +9 -8
  76. package/dist/vault/vault-libsecret.js.map +1 -1
  77. package/dist/vault/vault-onepassword.d.ts +118 -0
  78. package/dist/vault/vault-onepassword.d.ts.map +1 -0
  79. package/dist/vault/vault-onepassword.js +270 -0
  80. package/dist/vault/vault-onepassword.js.map +1 -0
  81. package/dist/vault/vault-utils.d.ts +33 -0
  82. package/dist/vault/vault-utils.d.ts.map +1 -1
  83. package/dist/vault/vault-utils.js +61 -0
  84. package/dist/vault/vault-utils.js.map +1 -1
  85. package/dist/vault/vault-windows.d.ts +5 -1
  86. package/dist/vault/vault-windows.d.ts.map +1 -1
  87. package/dist/vault/vault-windows.js +9 -8
  88. package/dist/vault/vault-windows.js.map +1 -1
  89. package/dist/vault/vault.d.ts +116 -4
  90. package/dist/vault/vault.d.ts.map +1 -1
  91. package/dist/vault/vault.js +251 -12
  92. package/dist/vault/vault.js.map +1 -1
  93. package/package.json +1 -1
@@ -0,0 +1,793 @@
1
+ /**
2
+ * Hardened macOS Keychain core (#188).
3
+ *
4
+ * ## The defect this module exists to end
5
+ *
6
+ * Every `security` invocation in this package used to be wrong in the same two
7
+ * ways, and both ways only bite in a context that cannot complain:
8
+ *
9
+ * 1. **Writes preauthorized nothing.** `add-generic-password … -T ""` trusts
10
+ * *no* application. An item written that way is readable only by granting
11
+ * consent through a dialog — and a launchd daemon or an SSH login has no
12
+ * UI in which such a dialog can render. The item is therefore unreadable
13
+ * in exactly the contexts a credential store is for.
14
+ * 2. **Reads consulted the search list.** `find-generic-password` with no
15
+ * keychain argument walks the session search list, which leads with the
16
+ * login keychain. In a sessionless context that keychain is locked, and a
17
+ * locked copy *shadows* a readable System-keychain copy — so the read
18
+ * fails even though the credential is right there.
19
+ *
20
+ * Combined with the old accessors' habit of collapsing every failure into
21
+ * `null`, the result was a store that silently reported "nothing configured"
22
+ * to a headless reader. See issue #188 and `docs/threat-model.md`.
23
+ *
24
+ * ## The posture
25
+ *
26
+ * 1. **Session-aware reads.** An interactive session keeps its existing
27
+ * search-list read (so vaults that open today keep opening unchanged). A
28
+ * *sessionless* context reads the System keychain by **explicit
29
+ * positional target** first — no search list, so nothing can shadow it —
30
+ * and only consults the search list when the System keychain genuinely
31
+ * has no copy. That ordering is what makes a misclassified context
32
+ * harmless: the fallback can only ever add a result, never lose one.
33
+ * 2. **Preauthorized writes.** `-T /usr/bin/security` grants the reader path
34
+ * at write time, on every write, everywhere in this package. Never
35
+ * `-T ""`.
36
+ * 3. **`-U` replace-in-place.** A rotation is one command and never leaves
37
+ * two generations of the same account. The old delete-then-add left a
38
+ * window in which the credential existed nowhere.
39
+ * 4. **Discriminated results.** Reads, probes, writes, deletes and the
40
+ * migration all answer with a discriminated union. `absent` and `locked`
41
+ * are never merged (see {@link ../secret-state.js}).
42
+ *
43
+ * ## Never logs a value
44
+ *
45
+ * No function here logs or formats a credential into a diagnostic. Every
46
+ * `detail`/`reason`/`command` string is value-free by construction — the
47
+ * operator command reads the secret with `read -s` into a shell variable, so
48
+ * it appears in neither the printed text nor the shell history.
49
+ *
50
+ * ## Residual, stated rather than hidden
51
+ *
52
+ * A programmatic write passes the value in `argv` (`-w <value>`), briefly
53
+ * visible to a local `ps`. That is unchanged from this package's previous
54
+ * write path, so it is not a regression — but it is why the OPERATOR-facing
55
+ * command printed by {@link privilegedWriteCommand} keeps the value out of the
56
+ * command text entirely. `docs/threat-model.md` §4.3 tracks the argv exposure.
57
+ */
58
+ import { execFileSync } from "child_process";
59
+ import { detectSessionContext } from "../platform/session-context.js";
60
+ // =============================================================================
61
+ // Constants
62
+ // =============================================================================
63
+ /** The macOS system keychain — readable from the launchd system domain. */
64
+ export const SYSTEM_KEYCHAIN_PATH = "/Library/Keychains/System.keychain";
65
+ /**
66
+ * The reader path preauthorized on write (`-T`), and the binary this module
67
+ * spawns. Absolute deliberately: the `-T` argument names a path, so the two
68
+ * must be the same path or the grant does not describe the reader.
69
+ */
70
+ export const SECURITY_BINARY = "/usr/bin/security";
71
+ /** Cap on the bytes of a `security` diagnostic carried into a result. */
72
+ const DETAIL_LIMIT = 200;
73
+ function defaultRunSecurity(args, options = {}) {
74
+ try {
75
+ const stdout = execFileSync(SECURITY_BINARY, [...args], {
76
+ encoding: "utf8",
77
+ stdio: ["pipe", "pipe", "pipe"],
78
+ ...(options.maxBuffer !== undefined ? { maxBuffer: options.maxBuffer } : {}),
79
+ });
80
+ return { status: 0, stderr: "", stdout: stdout ?? "" };
81
+ }
82
+ catch (e) {
83
+ const err = e;
84
+ const status = typeof err.status === "number" ? err.status : -1;
85
+ const stderr = typeof err.stderr === "string"
86
+ ? err.stderr
87
+ : err.stderr !== undefined
88
+ ? err.stderr.toString("utf8")
89
+ : (err.message ?? "");
90
+ const stdout = typeof err.stdout === "string"
91
+ ? err.stdout
92
+ : err.stdout !== undefined
93
+ ? err.stdout.toString("utf8")
94
+ : "";
95
+ return { status, stderr, stdout };
96
+ }
97
+ }
98
+ /**
99
+ * Resolve the injected seam against the live process. Defaults are read on
100
+ * every call rather than captured at module load, so a caller that adjusts
101
+ * `process.platform` (several suites in this package do) is honoured.
102
+ */
103
+ function resolveDeps(deps) {
104
+ return {
105
+ runSecurity: deps.runSecurity ?? defaultRunSecurity,
106
+ platform: deps.platform ?? process.platform,
107
+ env: deps.env ?? process.env,
108
+ };
109
+ }
110
+ // =============================================================================
111
+ // Classification
112
+ // =============================================================================
113
+ function notApplicable(platform) {
114
+ return {
115
+ kind: "not-applicable",
116
+ reason: `platform ${platform} has no macOS keychain`,
117
+ };
118
+ }
119
+ /**
120
+ * Classify a `security` outcome.
121
+ *
122
+ * Stderr patterns are checked BEFORE exit codes on purpose: Apple has shifted
123
+ * `security` exit codes between releases while the user-facing strings have
124
+ * stayed stable, so pinning on the code alone silently reclassifies an
125
+ * unreadable item as a missing one on the next OS bump — the very collapse
126
+ * this module exists to prevent.
127
+ */
128
+ export function classifyKeychainRead(status, stderr, stdout) {
129
+ const lower = stderr.toLowerCase();
130
+ if (lower.includes("interaction is not allowed") ||
131
+ lower.includes("interactionnotallowed") ||
132
+ lower.includes("user name or passphrase you entered is not correct")) {
133
+ return { kind: "locked" };
134
+ }
135
+ if (status === 51)
136
+ return { kind: "locked" };
137
+ if (status === 44)
138
+ return { kind: "absent" };
139
+ if (status === 0) {
140
+ // `-w` prints ONLY the password, newline-terminated. Strip that one
141
+ // newline and nothing else — a general `trim()` would silently mutate a
142
+ // credential whose leading or trailing whitespace is significant.
143
+ const value = stdout.replace(/\n$/, "");
144
+ // An empty value from a status-0 read is a corrupt item, not a credential.
145
+ // Returning it would hand the reader an empty token and look configured.
146
+ return value.length > 0
147
+ ? { kind: "found", value }
148
+ : {
149
+ kind: "failed",
150
+ detail: "security returned an empty value for an existing item",
151
+ };
152
+ }
153
+ const detail = stderr.trim().slice(0, DETAIL_LIMIT) || `security exited ${status}`;
154
+ return { kind: "failed", detail };
155
+ }
156
+ /** Drop the value from a read outcome, yielding the value-free state. */
157
+ export function toSecretState(read) {
158
+ return read.kind === "found" ? { kind: "found" } : read;
159
+ }
160
+ /** True when the failure is "you are not allowed to write this keychain". */
161
+ function isPrivilegeFailure(status, stderr) {
162
+ const lower = stderr.toLowerCase();
163
+ return (lower.includes("write permissions") ||
164
+ lower.includes("not permitted") ||
165
+ lower.includes("permission denied") ||
166
+ lower.includes("authorization") ||
167
+ // errSecWrPerm / errSecAuthFailed families observed on a non-root write.
168
+ status === 45 ||
169
+ status === 51);
170
+ }
171
+ // =============================================================================
172
+ // Operator command construction
173
+ // =============================================================================
174
+ /**
175
+ * Shell-quote one argument for the copy/paste operator command.
176
+ *
177
+ * The service and account reach this from caller-supplied strings, so an
178
+ * unquoted interpolation turns a remediation command into a DIFFERENT command
179
+ * the moment one of them carries a shell metacharacter. Single-quote wrapping
180
+ * is the only form that neutralizes every metacharacter; an embedded single
181
+ * quote is closed, escaped and reopened (the POSIX `'\''` idiom).
182
+ */
183
+ export function shellQuote(value) {
184
+ return `'${value.replace(/'/g, "'\\''")}'`;
185
+ }
186
+ /**
187
+ * The operator command for a privileged System-keychain write.
188
+ *
189
+ * The command TEXT is value-free: the secret is read by `read -s` (silent, and
190
+ * present in both zsh and bash) into a shell variable, so it never appears in
191
+ * the printed command, in a log line, or in shell history — history records
192
+ * the literal `"$PW"`, not its expansion.
193
+ *
194
+ * Why not a `security` prompt form: `-w` consumes any following token as the
195
+ * password, so a trailing keychain path would silently BECOME the stored
196
+ * value, and the bare dangling `-w` form is not a reliable terminal prompt.
197
+ * The `read -s` flow sidesteps `security`'s prompt semantics entirely and
198
+ * RESTORES the explicit System-keychain positional target, so the write cannot
199
+ * land in the wrong keychain.
200
+ */
201
+ export function privilegedWriteCommand(service, account) {
202
+ const write = [
203
+ "sudo",
204
+ SECURITY_BINARY,
205
+ "add-generic-password",
206
+ "-U",
207
+ "-s",
208
+ shellQuote(service),
209
+ "-a",
210
+ shellQuote(account),
211
+ "-T",
212
+ SECURITY_BINARY,
213
+ "-w",
214
+ '"$PW"',
215
+ SYSTEM_KEYCHAIN_PATH,
216
+ ].join(" ");
217
+ return [
218
+ `printf 'value for %s (input hidden): ' ${shellQuote(account)}`,
219
+ "IFS= read -rs PW",
220
+ `printf '\\n'`,
221
+ write,
222
+ "unset PW",
223
+ ].join(" && ");
224
+ }
225
+ // =============================================================================
226
+ // Reads
227
+ // =============================================================================
228
+ function systemReadArgs(service, account) {
229
+ return [
230
+ "find-generic-password",
231
+ "-s",
232
+ service,
233
+ "-a",
234
+ account,
235
+ "-w",
236
+ SYSTEM_KEYCHAIN_PATH,
237
+ ];
238
+ }
239
+ function searchListReadArgs(service, account) {
240
+ return ["find-generic-password", "-s", service, "-a", account, "-w"];
241
+ }
242
+ /**
243
+ * Read one credential from the System keychain, addressing it EXPLICITLY.
244
+ *
245
+ * The trailing keychain path is the whole point: without it `security` walks
246
+ * the session search list, which in a sessionless context leads with the
247
+ * locked login keychain — so a stale login copy shadows the readable System
248
+ * one and the read fails in exactly the context that has no way to prompt.
249
+ */
250
+ export function readSystemKeychainCredential(service, account, deps = {}) {
251
+ const { runSecurity, platform } = resolveDeps(deps);
252
+ if (platform !== "darwin")
253
+ return notApplicable(platform);
254
+ const { status, stderr, stdout } = runSecurity(systemReadArgs(service, account));
255
+ return classifyKeychainRead(status, stderr, stdout);
256
+ }
257
+ /**
258
+ * Read one credential through the session search list — the historical path,
259
+ * and still the right one in an interactive session where the login keychain
260
+ * is unlocked and is where the item lives.
261
+ */
262
+ export function readSearchListCredential(service, account, deps = {}) {
263
+ const { runSecurity, platform } = resolveDeps(deps);
264
+ if (platform !== "darwin")
265
+ return notApplicable(platform);
266
+ const { status, stderr, stdout } = runSecurity(searchListReadArgs(service, account));
267
+ return classifyKeychainRead(status, stderr, stdout);
268
+ }
269
+ /**
270
+ * Read a credential using the path appropriate to THIS execution context.
271
+ *
272
+ * - Interactive session: the search-list read, byte-for-byte what this package
273
+ * did before #188. Vaults that open today keep opening.
274
+ * - Sessionless (launchd system domain, SSH): the explicit System-keychain
275
+ * read first, so no login copy can shadow it — then, only if the System
276
+ * keychain genuinely has no such item, the search-list read.
277
+ *
278
+ * The fallback ordering is deliberate and load-bearing. Session detection is a
279
+ * heuristic (see `../platform/session-context.ts`); with System consulted
280
+ * first, a *false* sessionless verdict cannot lose a credential that used to
281
+ * be readable, and a false interactive verdict is no worse than today. The
282
+ * shadowing bug cannot come back either way, because the search list is only
283
+ * ever reached after the System keychain has answered `absent`.
284
+ */
285
+ export function readKeychainCredential(service, account, deps = {}) {
286
+ const resolved = resolveDeps(deps);
287
+ if (resolved.platform !== "darwin")
288
+ return notApplicable(resolved.platform);
289
+ if (sessionContext(resolved).kind === "interactive") {
290
+ return readSearchListCredential(service, account, resolved);
291
+ }
292
+ const system = readSystemKeychainCredential(service, account, resolved);
293
+ if (system.kind !== "absent")
294
+ return system;
295
+ return readSearchListCredential(service, account, resolved);
296
+ }
297
+ function sessionContext(deps) {
298
+ return detectSessionContext({ platform: deps.platform, env: deps.env });
299
+ }
300
+ // =============================================================================
301
+ // Probes (metadata only — no value reaches any stream)
302
+ // =============================================================================
303
+ /**
304
+ * **Presence, not readability.** Metadata-only existence probe against the
305
+ * System keychain.
306
+ *
307
+ * No `-w`, so `security` prints no value on any stream. That is also its
308
+ * limit: a `found` here means only that an item with these coordinates exists.
309
+ * An item can exist and still be unreadable — locked keychain, an ACL that
310
+ * does not preauthorize the reader, a partially written item — so this must
311
+ * NOT be used to answer "what is the state of this credential?". Reporting
312
+ * `found` for an item the reader cannot open is the exact failure mode #188
313
+ * exists to end.
314
+ *
315
+ * Its correct use is the non-destructive residency question inside
316
+ * {@link migrateLoginCredentialToSystem} ("is a copy already here, so that I
317
+ * need not move one?"), which is deliberately answered without reading the
318
+ * secret. Credential STATE is {@link probeKeychainCredentialState}.
319
+ */
320
+ export function probeSystemKeychainEntry(service, account, deps = {}) {
321
+ const { runSecurity, platform } = resolveDeps(deps);
322
+ if (platform !== "darwin")
323
+ return notApplicable(platform);
324
+ const { status, stderr } = runSecurity([
325
+ "find-generic-password",
326
+ "-s",
327
+ service,
328
+ "-a",
329
+ account,
330
+ SYSTEM_KEYCHAIN_PATH,
331
+ ]);
332
+ if (status === 0)
333
+ return { kind: "found" };
334
+ return toSecretState(classifyKeychainRead(status, stderr, ""));
335
+ }
336
+ /**
337
+ * **Presence, not readability.** Metadata-only existence probe through the
338
+ * session search list. Same caveat as {@link probeSystemKeychainEntry}: use
339
+ * {@link probeKeychainCredentialState} for credential state.
340
+ */
341
+ export function probeSearchListEntry(service, account, deps = {}) {
342
+ const { runSecurity, platform } = resolveDeps(deps);
343
+ if (platform !== "darwin")
344
+ return notApplicable(platform);
345
+ const { status, stderr } = runSecurity([
346
+ "find-generic-password",
347
+ "-s",
348
+ service,
349
+ "-a",
350
+ account,
351
+ ]);
352
+ if (status === 0)
353
+ return { kind: "found" };
354
+ return toSecretState(classifyKeychainRead(status, stderr, ""));
355
+ }
356
+ /**
357
+ * **Presence, not readability.** Metadata-only probe along the same keychain
358
+ * the reader would consult in this context.
359
+ *
360
+ * Same caveat as the two functions above, and for the same reason: a status-0
361
+ * metadata hit says an item exists, not that opening it yields bytes. Use
362
+ * {@link probeKeychainCredentialState} for credential state.
363
+ */
364
+ export function probeKeychainEntry(service, account, deps = {}) {
365
+ const resolved = resolveDeps(deps);
366
+ if (resolved.platform !== "darwin")
367
+ return notApplicable(resolved.platform);
368
+ if (sessionContext(resolved).kind === "interactive") {
369
+ return probeSearchListEntry(service, account, resolved);
370
+ }
371
+ const system = probeSystemKeychainEntry(service, account, resolved);
372
+ if (system.kind !== "absent")
373
+ return system;
374
+ return probeSearchListEntry(service, account, resolved);
375
+ }
376
+ /**
377
+ * The credential STATE probe: what will the reader in THIS context actually
378
+ * get?
379
+ *
380
+ * Runs the real read — {@link readKeychainCredential}, the same path, the same
381
+ * explicit target, the same `-w` — and then **discards the value**, returning
382
+ * only the value-free {@link SecretState}. So `found` here means "the reader
383
+ * will get bytes", which is the claim a caller needs and the claim a metadata
384
+ * probe cannot make.
385
+ *
386
+ * Presence is not readability, and the gap between them is not hypothetical:
387
+ * an item whose ACL does not preauthorize the reader, or that sits in a locked
388
+ * keychain, or that was written empty, is *present* and *unreadable*. A probe
389
+ * that answered `found` for those would hand a caller trusted state that is
390
+ * wrong in precisely the direction #188 exists to fix — a reader told the
391
+ * credential is there, failing anyway, with no signal that says why.
392
+ *
393
+ * The trade is deliberate: this reads secret material into the process to
394
+ * answer, where {@link probeKeychainEntry} does not. Nothing leaves — the
395
+ * value never reaches a return value, a log, or a stream. Callers that must
396
+ * not read the secret at all (the migration's residency check) use the
397
+ * metadata probe under its own name, and accept that it answers a narrower
398
+ * question.
399
+ */
400
+ export function probeKeychainCredentialState(service, account, deps = {}) {
401
+ return toSecretState(readKeychainCredential(service, account, deps));
402
+ }
403
+ // =============================================================================
404
+ // Writes
405
+ // =============================================================================
406
+ /**
407
+ * Store a credential in the System keychain, preauthorized for the reader path.
408
+ *
409
+ * `-U` replaces an existing item in place (the rotation path, and the reason
410
+ * there is no delete-then-add window here); `-T` grants
411
+ * `/usr/bin/security` up front because no consent prompt can render in the
412
+ * launchd or SSH contexts that have to read it.
413
+ */
414
+ export function writeSystemKeychainCredential(service, account, value, deps = {}) {
415
+ const { runSecurity, platform } = resolveDeps(deps);
416
+ if (platform !== "darwin")
417
+ return notApplicable(platform);
418
+ if (value.length === 0) {
419
+ return { kind: "failed", detail: "refusing to store an empty value" };
420
+ }
421
+ const { status, stderr } = runSecurity([
422
+ "add-generic-password",
423
+ "-U",
424
+ "-s",
425
+ service,
426
+ "-a",
427
+ account,
428
+ "-T",
429
+ SECURITY_BINARY,
430
+ "-w",
431
+ value,
432
+ SYSTEM_KEYCHAIN_PATH,
433
+ ]);
434
+ if (status === 0)
435
+ return { kind: "stored" };
436
+ if (isPrivilegeFailure(status, stderr)) {
437
+ return { kind: "needs-privilege", command: privilegedWriteCommand(service, account) };
438
+ }
439
+ return {
440
+ kind: "failed",
441
+ detail: stderr.trim().slice(0, DETAIL_LIMIT) || `security exited ${status}`,
442
+ };
443
+ }
444
+ /**
445
+ * Store a credential in the session's default (login) keychain — the
446
+ * historical destination — but with the two flags that were missing: `-U` so a
447
+ * rotation replaces in place, and `-T /usr/bin/security` so the item is
448
+ * readable without a consent dialog.
449
+ *
450
+ * A permission failure here is reported as `failed`, not `needs-privilege`:
451
+ * `sudo` is the wrong remedy for a login keychain (it would write root's), and
452
+ * offering it would send an operator down a path that cannot work.
453
+ */
454
+ export function writeDefaultKeychainCredential(service, account, value, deps = {}) {
455
+ const { runSecurity, platform } = resolveDeps(deps);
456
+ if (platform !== "darwin")
457
+ return notApplicable(platform);
458
+ if (value.length === 0) {
459
+ return { kind: "failed", detail: "refusing to store an empty value" };
460
+ }
461
+ const { status, stderr } = runSecurity([
462
+ "add-generic-password",
463
+ "-U",
464
+ "-s",
465
+ service,
466
+ "-a",
467
+ account,
468
+ "-T",
469
+ SECURITY_BINARY,
470
+ "-w",
471
+ value,
472
+ ]);
473
+ if (status === 0)
474
+ return { kind: "stored" };
475
+ return {
476
+ kind: "failed",
477
+ detail: stderr.trim().slice(0, DETAIL_LIMIT) || `security exited ${status}`,
478
+ };
479
+ }
480
+ /**
481
+ * Store a credential using the destination appropriate to THIS context.
482
+ *
483
+ * - Interactive: the default (login) keychain, unchanged destination, now with
484
+ * `-U` and `-T`.
485
+ * - Sessionless: the System keychain, so the reader that has no session can
486
+ * actually open it. If that write needs privileges the credential is still
487
+ * written to the default keychain rather than failing outright — but the
488
+ * result says so (`keychain`) and carries the value-free command that
489
+ * promotes it (`privilegedCommand`). Reporting the lesser destination is
490
+ * what keeps this from being a silent downgrade.
491
+ */
492
+ export function writeKeychainCredential(service, account, value, deps = {}) {
493
+ const resolved = resolveDeps(deps);
494
+ if (resolved.platform !== "darwin")
495
+ return notApplicable(resolved.platform);
496
+ if (value.length === 0) {
497
+ return { kind: "failed", detail: "refusing to store an empty value" };
498
+ }
499
+ if (sessionContext(resolved).kind === "interactive") {
500
+ const written = writeDefaultKeychainCredential(service, account, value, resolved);
501
+ return written.kind === "stored" ? { kind: "stored", keychain: "default" } : written;
502
+ }
503
+ const system = writeSystemKeychainCredential(service, account, value, resolved);
504
+ if (system.kind === "stored") {
505
+ return { kind: "stored", keychain: SYSTEM_KEYCHAIN_PATH };
506
+ }
507
+ if (system.kind !== "needs-privilege")
508
+ return system;
509
+ const fallback = writeDefaultKeychainCredential(service, account, value, resolved);
510
+ if (fallback.kind !== "stored")
511
+ return system;
512
+ return {
513
+ kind: "stored",
514
+ keychain: "default",
515
+ privilegedCommand: system.command,
516
+ };
517
+ }
518
+ /**
519
+ * Did a write land where THIS context's reader will look for it?
520
+ *
521
+ * `kind: "stored"` alone does not answer that. In a sessionless context a
522
+ * System-keychain write that needs privileges falls back to the default
523
+ * keychain — the value is stored, and the result says so and carries the
524
+ * command that promotes it, but a launchd or SSH reader will not find it
525
+ * there. A boolean caller that treated that as success would record a write
526
+ * the reader cannot see: a silent downgrade, which is the same class of lie as
527
+ * the read-side collapse this module exists to fix.
528
+ *
529
+ * So: interactive → the default keychain IS where the reader looks, so any
530
+ * `stored` is reachable. Sessionless → only the System keychain is.
531
+ *
532
+ * Callers that want the nuance rather than the boolean use the discriminated
533
+ * {@link KeychainWriteResult} directly; it carries both the destination and
534
+ * the value-free promotion command.
535
+ */
536
+ export function storedWithinReaderReach(result, deps = {}) {
537
+ if (result.kind !== "stored")
538
+ return false;
539
+ const resolved = resolveDeps(deps);
540
+ if (sessionContext(resolved).kind === "interactive")
541
+ return true;
542
+ return result.keychain === SYSTEM_KEYCHAIN_PATH;
543
+ }
544
+ // =============================================================================
545
+ // Deletes
546
+ // =============================================================================
547
+ function classifyDelete(status, stderr) {
548
+ if (status === 0)
549
+ return { kind: "deleted" };
550
+ if (status === 44)
551
+ return { kind: "absent" };
552
+ const lower = stderr.toLowerCase();
553
+ if (lower.includes("could not be found") || lower.includes("the specified item could not be found")) {
554
+ return { kind: "absent" };
555
+ }
556
+ return {
557
+ kind: "failed",
558
+ detail: stderr.trim().slice(0, DETAIL_LIMIT) || `security exited ${status}`,
559
+ };
560
+ }
561
+ /**
562
+ * Resolve the LOGIN keychain's path (`security login-keychain`) so a delete can
563
+ * be aimed at it explicitly. `null` when it cannot be resolved — and that
564
+ * `null` is load-bearing: the caller then SKIPS the delete rather than running
565
+ * an untargeted one.
566
+ */
567
+ function resolveLoginKeychainPath(runSecurity) {
568
+ const { status, stdout } = runSecurity(["login-keychain"]);
569
+ if (status !== 0)
570
+ return null;
571
+ const resolved = stdout.trim().replace(/^"|"$/g, "");
572
+ if (resolved.length === 0)
573
+ return null;
574
+ // Never treat the System keychain as the login keychain, however we got here
575
+ // — the entire purpose of this resolution is to keep a delete off it.
576
+ if (resolved === SYSTEM_KEYCHAIN_PATH)
577
+ return null;
578
+ return resolved;
579
+ }
580
+ /**
581
+ * Delete the LOGIN-keychain copy of an account — TARGETED at the login
582
+ * keychain, never resolved through the search list.
583
+ *
584
+ * An untargeted `delete-generic-password -s <svc> -a <acct>` resolves through
585
+ * the user search list, which commonly INCLUDES
586
+ * `/Library/Keychains/System.keychain`. On a host with no login shadow such a
587
+ * delete matches the System item and removes the credential just verified,
588
+ * turning a cleanup into the outage it exists to prevent.
589
+ *
590
+ * Two guards: the delete names the login keychain explicitly, and when that
591
+ * path cannot be resolved the delete is SKIPPED. An unswept login shadow is a
592
+ * degraded state the caller reports; a deleted System credential is an outage.
593
+ */
594
+ function deleteLoginCopy(service, account, runSecurity) {
595
+ const loginKeychain = resolveLoginKeychainPath(runSecurity);
596
+ if (loginKeychain === null)
597
+ return false;
598
+ const { status } = runSecurity([
599
+ "delete-generic-password",
600
+ "-s",
601
+ service,
602
+ "-a",
603
+ account,
604
+ loginKeychain,
605
+ ]);
606
+ return status === 0;
607
+ }
608
+ /**
609
+ * Delete a credential from wherever this context can read it.
610
+ *
611
+ * Interactive: the historical default-destination delete, unchanged.
612
+ *
613
+ * Sessionless: both copies the read path can see — the System copy (explicit
614
+ * target) and the login copy (targeted through `login-keychain`, skipped when
615
+ * unresolvable). Deleting only one would leave the other to be resurrected by
616
+ * the very fallback that makes sessionless reads work.
617
+ */
618
+ export function deleteKeychainCredential(service, account, deps = {}) {
619
+ const resolved = resolveDeps(deps);
620
+ const { runSecurity, platform } = resolved;
621
+ if (platform !== "darwin")
622
+ return notApplicable(platform);
623
+ if (sessionContext(resolved).kind === "interactive") {
624
+ const { status, stderr } = runSecurity([
625
+ "delete-generic-password",
626
+ "-s",
627
+ service,
628
+ "-a",
629
+ account,
630
+ ]);
631
+ return classifyDelete(status, stderr);
632
+ }
633
+ const system = runSecurity([
634
+ "delete-generic-password",
635
+ "-s",
636
+ service,
637
+ "-a",
638
+ account,
639
+ SYSTEM_KEYCHAIN_PATH,
640
+ ]);
641
+ const systemResult = classifyDelete(system.status, system.stderr);
642
+ const loginDeleted = deleteLoginCopy(service, account, runSecurity);
643
+ if (systemResult.kind === "failed")
644
+ return systemResult;
645
+ if (systemResult.kind === "deleted" || loginDeleted)
646
+ return { kind: "deleted" };
647
+ return { kind: "absent" };
648
+ }
649
+ // =============================================================================
650
+ // Migration
651
+ // =============================================================================
652
+ /**
653
+ * The pre-delete gate: is the System copy READABLE through the exact path a
654
+ * sessionless reader uses?
655
+ *
656
+ * Presence is NOT readability. An item can exist and still be unreadable —
657
+ * locked keychain, an ACL that does not preauthorize the reader, a partially
658
+ * written item — so gating the delete on the metadata probe could green-light
659
+ * removing a working login fallback and leave a daemon credential-blind. Only
660
+ * a `found` from the FULL read proves bytes come back. The metadata probe
661
+ * remains for NON-destructive presence checks only.
662
+ */
663
+ function systemCopyIsReadable(service, account, deps) {
664
+ return readSystemKeychainCredential(service, account, deps).kind === "found";
665
+ }
666
+ /**
667
+ * Move one credential from the login keychain to the System keychain, ONCE,
668
+ * then remove the login copy so nothing can shadow it.
669
+ *
670
+ * Idempotent: a System copy that already exists short-circuits to
671
+ * `already-resident` (and still sweeps a login shadow if one is left).
672
+ *
673
+ * Fail-loud-but-SAFE ordering — the login copy is deleted LAST, and only after
674
+ * the System copy is verified readable through the explicit-target read. Every
675
+ * earlier failure returns `blocked` with the login copy intact, so the previous
676
+ * read path keeps working: a failed migration can never produce "reader up with
677
+ * no credential".
678
+ */
679
+ export function migrateLoginCredentialToSystem(service, account, deps = {}) {
680
+ const resolved = resolveDeps(deps);
681
+ const { runSecurity, platform } = resolved;
682
+ if (platform !== "darwin") {
683
+ return { kind: "blocked", reason: `platform ${platform} has no macOS keychain` };
684
+ }
685
+ // Already resident? Probe metadata only — no need to read the secret to
686
+ // answer this, and not reading it keeps the idempotent path value-free.
687
+ const resident = probeSystemKeychainEntry(service, account, resolved);
688
+ if (resident.kind === "found") {
689
+ // Presence is not readability. Sweep the login shadow ONLY when the System
690
+ // copy actually yields bytes through the reader's own path — otherwise the
691
+ // login copy IS the working credential and deleting it blinds the reader.
692
+ if (!systemCopyIsReadable(service, account, resolved)) {
693
+ return {
694
+ kind: "blocked",
695
+ reason: "a System-keychain item exists but is NOT readable (locked, or its ACL does not preauthorize the reader); the login copy has been LEFT IN PLACE",
696
+ };
697
+ }
698
+ const deleted = deleteLoginCopy(service, account, runSecurity);
699
+ return { kind: "already-resident", loginCopyDeleted: deleted };
700
+ }
701
+ if (resident.kind === "locked") {
702
+ return {
703
+ kind: "blocked",
704
+ reason: "the System keychain is locked; cannot determine whether the credential is resident",
705
+ };
706
+ }
707
+ // Read the login copy through the search list (that is where it lives).
708
+ const login = runSecurity(searchListReadArgs(service, account));
709
+ const loginRead = classifyKeychainRead(login.status, login.stderr, login.stdout);
710
+ if (loginRead.kind !== "found") {
711
+ return loginRead.kind === "absent"
712
+ ? { kind: "nothing-to-migrate" }
713
+ : {
714
+ kind: "blocked",
715
+ reason: loginRead.kind === "locked"
716
+ ? "the login keychain is locked, so the source copy cannot be read (run this from an unlocked GUI session)"
717
+ : loginRead.kind === "failed"
718
+ ? `login-keychain read failed: ${loginRead.detail}`
719
+ : loginRead.reason,
720
+ };
721
+ }
722
+ const written = writeSystemKeychainCredential(service, account, loginRead.value, resolved);
723
+ if (written.kind !== "stored") {
724
+ // Login copy untouched — the pre-migration read path is exactly as it was.
725
+ return written.kind === "needs-privilege"
726
+ ? {
727
+ kind: "blocked",
728
+ reason: "writing the System keychain requires root",
729
+ command: written.command,
730
+ }
731
+ : {
732
+ kind: "blocked",
733
+ reason: written.kind === "failed"
734
+ ? `System-keychain write failed: ${written.detail}`
735
+ : written.reason,
736
+ };
737
+ }
738
+ // Verify through the SAME explicit-target read a sessionless reader will use
739
+ // — a FULL read, not a metadata probe: an item can exist and still be
740
+ // unreadable, and the delete below is irreversible.
741
+ if (!systemCopyIsReadable(service, account, resolved)) {
742
+ return {
743
+ kind: "blocked",
744
+ reason: "the System copy was written but could not be verified; the login copy has been LEFT IN PLACE",
745
+ };
746
+ }
747
+ const deleted = deleteLoginCopy(service, account, runSecurity);
748
+ return { kind: "migrated", loginCopyDeleted: deleted };
749
+ }
750
+ // =============================================================================
751
+ // Diagnostics
752
+ // =============================================================================
753
+ /**
754
+ * The keychain search list, for diagnostics. Names only — a search list
755
+ * carries no secret material, and knowing that the login keychain leads it is
756
+ * exactly what explains a shadowed read.
757
+ */
758
+ export function keychainSearchList(deps = {}) {
759
+ const { runSecurity, platform } = resolveDeps(deps);
760
+ if (platform !== "darwin")
761
+ return [];
762
+ const { status, stdout } = runSecurity(["list-keychains", "-d", "user"]);
763
+ if (status !== 0)
764
+ return [];
765
+ return stdout
766
+ .split("\n")
767
+ .map((line) => line.trim().replace(/^"|"$/g, ""))
768
+ .filter((line) => line.length > 0);
769
+ }
770
+ /**
771
+ * Raw `security dump-keychain` output, for the enumeration path in
772
+ * `vault-common.ts`.
773
+ *
774
+ * Deliberately NOT platform-guarded: enumeration is only reachable when the
775
+ * Keychain backend is the active one, and guarding here would change a
776
+ * spawn-and-fail into a silent empty answer on a host whose platform string
777
+ * has been adjusted (several suites in this package do exactly that).
778
+ *
779
+ * `-d` is deliberately omitted, so no password data is in the output.
780
+ */
781
+ export function dumpKeychain(deps = {}) {
782
+ const { runSecurity } = resolveDeps(deps);
783
+ const { status, stderr, stdout } = runSecurity(["dump-keychain"], {
784
+ maxBuffer: 32 * 1024 * 1024,
785
+ });
786
+ if (status === 0)
787
+ return { kind: "ok", output: stdout };
788
+ return {
789
+ kind: "failed",
790
+ detail: stderr.trim().slice(0, DETAIL_LIMIT) || `security exited ${status}`,
791
+ };
792
+ }
793
+ //# sourceMappingURL=darwin-keychain.js.map