@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.
- package/README.md +70 -2
- 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/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 +20 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +23 -5
- 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/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/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 +102 -2
- package/dist/vault/session-vault.d.ts.map +1 -1
- package/dist/vault/session-vault.js +225 -36
- package/dist/vault/session-vault.js.map +1 -1
- package/dist/vault/types.d.ts +42 -1
- package/dist/vault/types.d.ts.map +1 -1
- package/dist/vault/types.js.map +1 -1
- package/dist/vault/vault-env.d.ts +14 -0
- package/dist/vault/vault-env.d.ts.map +1 -1
- package/dist/vault/vault-env.js +15 -0
- package/dist/vault/vault-env.js.map +1 -1
- package/dist/vault/vault-gpg.d.ts +5 -1
- package/dist/vault/vault-gpg.d.ts.map +1 -1
- package/dist/vault/vault-gpg.js +9 -8
- package/dist/vault/vault-gpg.js.map +1 -1
- package/dist/vault/vault-libsecret.d.ts +5 -1
- package/dist/vault/vault-libsecret.d.ts.map +1 -1
- package/dist/vault/vault-libsecret.js +9 -8
- package/dist/vault/vault-libsecret.js.map +1 -1
- package/dist/vault/vault-onepassword.d.ts +118 -0
- package/dist/vault/vault-onepassword.d.ts.map +1 -0
- package/dist/vault/vault-onepassword.js +270 -0
- package/dist/vault/vault-onepassword.js.map +1 -0
- package/dist/vault/vault-utils.d.ts +33 -0
- package/dist/vault/vault-utils.d.ts.map +1 -1
- package/dist/vault/vault-utils.js +61 -0
- package/dist/vault/vault-utils.js.map +1 -1
- package/dist/vault/vault-windows.d.ts +5 -1
- package/dist/vault/vault-windows.d.ts.map +1 -1
- package/dist/vault/vault-windows.js +9 -8
- package/dist/vault/vault-windows.js.map +1 -1
- package/dist/vault/vault.d.ts +116 -4
- package/dist/vault/vault.d.ts.map +1 -1
- package/dist/vault/vault.js +251 -12
- package/dist/vault/vault.js.map +1 -1
- 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
|