@skrr-ai/auth-core 0.1.2

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 (147) hide show
  1. package/README.md +21 -0
  2. package/dist/cjs/aead.d.ts +24 -0
  3. package/dist/cjs/aead.js +65 -0
  4. package/dist/cjs/authExpiredDetector.d.ts +65 -0
  5. package/dist/cjs/authExpiredDetector.js +89 -0
  6. package/dist/cjs/authHelper.d.ts +51 -0
  7. package/dist/cjs/authHelper.js +321 -0
  8. package/dist/cjs/credentialEnvelope.d.ts +85 -0
  9. package/dist/cjs/credentialEnvelope.js +230 -0
  10. package/dist/cjs/credentialEnvelopeBridge.d.ts +116 -0
  11. package/dist/cjs/credentialEnvelopeBridge.js +637 -0
  12. package/dist/cjs/credentialResolver.d.ts +278 -0
  13. package/dist/cjs/credentialResolver.js +423 -0
  14. package/dist/cjs/daemonPair.d.ts +172 -0
  15. package/dist/cjs/daemonPair.js +495 -0
  16. package/dist/cjs/daemonScopes.d.ts +74 -0
  17. package/dist/cjs/daemonScopes.js +84 -0
  18. package/dist/cjs/deviceIdentityBridge.d.ts +113 -0
  19. package/dist/cjs/deviceIdentityBridge.js +591 -0
  20. package/dist/cjs/deviceKey.d.ts +167 -0
  21. package/dist/cjs/deviceKey.js +332 -0
  22. package/dist/cjs/fdToken.d.ts +17 -0
  23. package/dist/cjs/fdToken.js +238 -0
  24. package/dist/cjs/harnessTrust.d.ts +72 -0
  25. package/dist/cjs/harnessTrust.js +103 -0
  26. package/dist/cjs/headers-helper.d.ts +25 -0
  27. package/dist/cjs/headers-helper.js +323 -0
  28. package/dist/cjs/index.d.ts +37 -0
  29. package/dist/cjs/index.js +305 -0
  30. package/dist/cjs/jwtUtils.d.ts +35 -0
  31. package/dist/cjs/jwtUtils.js +61 -0
  32. package/dist/cjs/kek/index.d.ts +89 -0
  33. package/dist/cjs/kek/index.js +208 -0
  34. package/dist/cjs/kek/linux.d.ts +92 -0
  35. package/dist/cjs/kek/linux.js +596 -0
  36. package/dist/cjs/kek/macos.d.ts +112 -0
  37. package/dist/cjs/kek/macos.js +599 -0
  38. package/dist/cjs/kek/types.d.ts +111 -0
  39. package/dist/cjs/kek/types.js +43 -0
  40. package/dist/cjs/kek/windows.d.ts +71 -0
  41. package/dist/cjs/kek/windows.js +350 -0
  42. package/dist/cjs/kek/zeroize-registry.d.ts +44 -0
  43. package/dist/cjs/kek/zeroize-registry.js +63 -0
  44. package/dist/cjs/legacyStatePreflight.d.ts +67 -0
  45. package/dist/cjs/legacyStatePreflight.js +78 -0
  46. package/dist/cjs/localIdentity.d.ts +54 -0
  47. package/dist/cjs/localIdentity.js +57 -0
  48. package/dist/cjs/loginLocalhost.d.ts +115 -0
  49. package/dist/cjs/loginLocalhost.js +368 -0
  50. package/dist/cjs/loginWithLocalhost.d.ts +131 -0
  51. package/dist/cjs/loginWithLocalhost.js +359 -0
  52. package/dist/cjs/machineId.d.ts +35 -0
  53. package/dist/cjs/machineId.js +239 -0
  54. package/dist/cjs/messages.d.ts +9 -0
  55. package/dist/cjs/messages.js +44 -0
  56. package/dist/cjs/package.json +3 -0
  57. package/dist/cjs/pkce.d.ts +62 -0
  58. package/dist/cjs/pkce.js +158 -0
  59. package/dist/cjs/recoveryCode.d.ts +61 -0
  60. package/dist/cjs/recoveryCode.js +213 -0
  61. package/dist/cjs/refresh.d.ts +215 -0
  62. package/dist/cjs/refresh.js +877 -0
  63. package/dist/cjs/refreshClassification.d.ts +53 -0
  64. package/dist/cjs/refreshClassification.js +105 -0
  65. package/dist/cjs/refreshScheduler.d.ts +53 -0
  66. package/dist/cjs/refreshScheduler.js +332 -0
  67. package/dist/cjs/runtime.d.ts +149 -0
  68. package/dist/cjs/runtime.js +136 -0
  69. package/dist/cjs/spawnEnv.d.ts +76 -0
  70. package/dist/cjs/spawnEnv.js +153 -0
  71. package/dist/cjs/ttlParser.d.ts +54 -0
  72. package/dist/cjs/ttlParser.js +87 -0
  73. package/dist/cjs/types.d.ts +91 -0
  74. package/dist/cjs/types.js +26 -0
  75. package/dist/esm/aead.d.ts +24 -0
  76. package/dist/esm/aead.js +57 -0
  77. package/dist/esm/authExpiredDetector.d.ts +65 -0
  78. package/dist/esm/authExpiredDetector.js +85 -0
  79. package/dist/esm/authHelper.d.ts +51 -0
  80. package/dist/esm/authHelper.js +311 -0
  81. package/dist/esm/credentialEnvelope.d.ts +85 -0
  82. package/dist/esm/credentialEnvelope.js +213 -0
  83. package/dist/esm/credentialEnvelopeBridge.d.ts +116 -0
  84. package/dist/esm/credentialEnvelopeBridge.js +620 -0
  85. package/dist/esm/credentialResolver.d.ts +278 -0
  86. package/dist/esm/credentialResolver.js +414 -0
  87. package/dist/esm/daemonPair.d.ts +172 -0
  88. package/dist/esm/daemonPair.js +476 -0
  89. package/dist/esm/daemonScopes.d.ts +74 -0
  90. package/dist/esm/daemonScopes.js +80 -0
  91. package/dist/esm/deviceIdentityBridge.d.ts +113 -0
  92. package/dist/esm/deviceIdentityBridge.js +572 -0
  93. package/dist/esm/deviceKey.d.ts +167 -0
  94. package/dist/esm/deviceKey.js +320 -0
  95. package/dist/esm/fdToken.d.ts +17 -0
  96. package/dist/esm/fdToken.js +231 -0
  97. package/dist/esm/harnessTrust.d.ts +72 -0
  98. package/dist/esm/harnessTrust.js +97 -0
  99. package/dist/esm/headers-helper.d.ts +25 -0
  100. package/dist/esm/headers-helper.js +316 -0
  101. package/dist/esm/index.d.ts +37 -0
  102. package/dist/esm/index.js +144 -0
  103. package/dist/esm/jwtUtils.d.ts +35 -0
  104. package/dist/esm/jwtUtils.js +57 -0
  105. package/dist/esm/kek/index.d.ts +89 -0
  106. package/dist/esm/kek/index.js +162 -0
  107. package/dist/esm/kek/linux.d.ts +92 -0
  108. package/dist/esm/kek/linux.js +585 -0
  109. package/dist/esm/kek/macos.d.ts +112 -0
  110. package/dist/esm/kek/macos.js +586 -0
  111. package/dist/esm/kek/types.d.ts +111 -0
  112. package/dist/esm/kek/types.js +39 -0
  113. package/dist/esm/kek/windows.d.ts +71 -0
  114. package/dist/esm/kek/windows.js +344 -0
  115. package/dist/esm/kek/zeroize-registry.d.ts +44 -0
  116. package/dist/esm/kek/zeroize-registry.js +58 -0
  117. package/dist/esm/legacyStatePreflight.d.ts +67 -0
  118. package/dist/esm/legacyStatePreflight.js +71 -0
  119. package/dist/esm/localIdentity.d.ts +54 -0
  120. package/dist/esm/localIdentity.js +54 -0
  121. package/dist/esm/loginLocalhost.d.ts +115 -0
  122. package/dist/esm/loginLocalhost.js +355 -0
  123. package/dist/esm/loginWithLocalhost.d.ts +131 -0
  124. package/dist/esm/loginWithLocalhost.js +353 -0
  125. package/dist/esm/machineId.d.ts +35 -0
  126. package/dist/esm/machineId.js +231 -0
  127. package/dist/esm/messages.d.ts +9 -0
  128. package/dist/esm/messages.js +40 -0
  129. package/dist/esm/pkce.d.ts +62 -0
  130. package/dist/esm/pkce.js +148 -0
  131. package/dist/esm/recoveryCode.d.ts +61 -0
  132. package/dist/esm/recoveryCode.js +207 -0
  133. package/dist/esm/refresh.d.ts +215 -0
  134. package/dist/esm/refresh.js +863 -0
  135. package/dist/esm/refreshClassification.d.ts +53 -0
  136. package/dist/esm/refreshClassification.js +106 -0
  137. package/dist/esm/refreshScheduler.d.ts +53 -0
  138. package/dist/esm/refreshScheduler.js +329 -0
  139. package/dist/esm/runtime.d.ts +149 -0
  140. package/dist/esm/runtime.js +121 -0
  141. package/dist/esm/spawnEnv.d.ts +76 -0
  142. package/dist/esm/spawnEnv.js +149 -0
  143. package/dist/esm/ttlParser.d.ts +54 -0
  144. package/dist/esm/ttlParser.js +83 -0
  145. package/dist/esm/types.d.ts +91 -0
  146. package/dist/esm/types.js +21 -0
  147. package/package.json +82 -0
@@ -0,0 +1,599 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.MacosKek = void 0;
7
+ exports.__setExecFileForTest = __setExecFileForTest;
8
+ exports.__resetSecurityProbeCacheForTest = __resetSecurityProbeCacheForTest;
9
+ exports.__resetResolvedKeychainCacheForTest = __resetResolvedKeychainCacheForTest;
10
+ exports.zeroizeMacosKekCache = zeroizeMacosKekCache;
11
+ exports.__resetKekCacheForTest = __resetKekCacheForTest;
12
+ exports.__getKekCacheForTest = __getKekCacheForTest;
13
+ /**
14
+ * kek/macos.ts — macOS KEK strategy.
15
+ *
16
+ * The macOS strategy is the `security(1)` shellout to the user's login
17
+ * Keychain. It stores a 32-byte master KEK under
18
+ * `service=ai.skrr.daemon.kek`, `account=master-kek-v1` and wraps the
19
+ * DEK with AES-256-GCM via the existing credential-envelope primitives.
20
+ * No native deps — matches `daemon/src/keychain.ts`'s project precedent
21
+ * of shelling out to `security` instead of linking a `.node` binding
22
+ * (which the bun-compiled daemon binary cannot consume). A future
23
+ * Secure-Enclave helper-binary path is tracked in the L12 ADR but is not
24
+ * part of this module.
25
+ *
26
+ * Threat model the shellout backend defends against:
27
+ * - Cold-disk theft / Time Machine snapshots / restic backups: the
28
+ * login keychain is encrypted at rest under the user's login.
29
+ * - Same-UID file readers: the keychain is gated by the user's
30
+ * authenticated session.
31
+ *
32
+ * Threat model the shellout backend does NOT defend against:
33
+ * - Live-process memory access while the daemon is running.
34
+ * - Root-equivalent malware that can `security unlock-keychain`.
35
+ * - Hardware-bound non-extractable storage — that requires a future
36
+ * Secure-Enclave helper-binary tracked separately.
37
+ *
38
+ * Explicit-keychain-targeting strategy: every `find-generic-password` and
39
+ * `add-generic-password` invocation passes the resolved login-keychain
40
+ * path as a trailing positional argument. Without that argument,
41
+ * `security(1)` falls back to "the default keychain in the current exec
42
+ * context" — and when that lookup fails (LaunchAgent without a GUI
43
+ * session, headless SSH, a user with no default keychain configured) it
44
+ * triggers a system-modal `Keychain Not Found` dialog that blocks the
45
+ * daemon process and offers a destructive `Reset To Defaults` action to
46
+ * the user. A daemon must NEVER block on UI. We resolve the path once
47
+ * up-front via `security default-keychain -d user` (with canonical-path
48
+ * fallbacks) and refuse to run if nothing resolves — `available()`
49
+ * returns false and the upstream credEnvelope reports
50
+ * `disabled.kek_unavailable` cleanly instead of hanging on a dialog.
51
+ *
52
+ * Service / account override: the constructor accepts an optional
53
+ * `{ service?, account? }` pair so callers can scope a `MacosKek`
54
+ * instance to a non-default keychain entry. The intended use cases are
55
+ * (a) multi-tenant tests that need parallel isolated KEKs in one
56
+ * process and (b) the real-keychain smoke test, which generates a
57
+ * unique service per run so it can `delete-generic-password` on
58
+ * teardown without touching the developer's actual master KEK. The
59
+ * production dispatcher in `kek/index.ts` passes nothing and gets the
60
+ * canonical `ai.skrr.daemon.kek` / `master-kek-v1` pair — that
61
+ * path is the only one fleet telemetry sizes against.
62
+ */
63
+ const node_child_process_1 = require("node:child_process");
64
+ const node_util_1 = require("node:util");
65
+ const node_crypto_1 = __importDefault(require("node:crypto"));
66
+ const node_os_1 = __importDefault(require("node:os"));
67
+ const node_path_1 = __importDefault(require("node:path"));
68
+ const node_fs_1 = __importDefault(require("node:fs"));
69
+ const credentialEnvelope_js_1 = require("../credentialEnvelope.js");
70
+ const types_js_1 = require("./types.js");
71
+ const zeroize_registry_js_1 = require("./zeroize-registry.js");
72
+ /**
73
+ * Stable telemetry kind string — fleet dashboards size the macOS cohort
74
+ * by this value. Only one backend ships in this module today.
75
+ */
76
+ const KIND_SHELLOUT = 'macos-keychain-shellout';
77
+ /**
78
+ * Default keychain layout for the shellout backend. Stable across
79
+ * versions and used by the production dispatcher in `kek/index.ts`.
80
+ * Test seams and smoke tests can override either field via the
81
+ * `MacosKek` constructor without touching this canonical pair.
82
+ */
83
+ const KEYCHAIN_SERVICE = 'ai.skrr.daemon.kek';
84
+ const KEYCHAIN_ACCOUNT = 'master-kek-v1';
85
+ /** `security` exit code when the requested item does not exist. */
86
+ const SECURITY_ITEM_NOT_FOUND = 44;
87
+ const execFileAsync = (0, node_util_1.promisify)(node_child_process_1.execFile);
88
+ let _execFileForTest = null;
89
+ function __setExecFileForTest(fn) {
90
+ _execFileForTest = fn;
91
+ // Probes are cached per-process; reset so the next available() call
92
+ // re-evaluates with the new mock.
93
+ _securityProbeCache = undefined;
94
+ // The resolved-keychain cache also depends on `security default-keychain`
95
+ // output — reset it so a new mock can drive a different resolution path.
96
+ _resolvedKeychainCache = undefined;
97
+ }
98
+ /** Default execFile wrapper used when no test override is installed. */
99
+ const defaultExec = async (file, args, options) => {
100
+ // promisify(execFile)'s typing is loose — coerce the encoding option
101
+ // explicitly for the typed test seam.
102
+ const result = await execFileAsync(file, args, {
103
+ encoding: 'utf-8',
104
+ ...(options ?? {}),
105
+ });
106
+ // Defensive coercion — when callers override the encoding, stdout may
107
+ // surface as Buffer/Uint8Array; the typeof guard keeps both shapes safe.
108
+ return {
109
+ stdout: typeof result.stdout === 'string'
110
+ ? result.stdout
111
+ : Buffer.from(result.stdout).toString('utf-8'),
112
+ stderr: typeof result.stderr === 'string'
113
+ ? result.stderr
114
+ : Buffer.from(result.stderr).toString('utf-8'),
115
+ };
116
+ };
117
+ function exec(file, args) {
118
+ const impl = _execFileForTest ?? defaultExec;
119
+ return impl(file, args, { encoding: 'utf-8' });
120
+ }
121
+ // ---------------------------------------------------------------------------
122
+ // Probes — cached for the process lifetime to keep available() ~O(1).
123
+ // ---------------------------------------------------------------------------
124
+ /** `undefined` = not yet probed; `boolean` = cached probe outcome. */
125
+ let _securityProbeCache;
126
+ async function probeSecurityBinary() {
127
+ if (process.platform !== 'darwin')
128
+ return false;
129
+ if (_securityProbeCache !== undefined)
130
+ return _securityProbeCache;
131
+ try {
132
+ await exec('security', ['help']);
133
+ _securityProbeCache = true;
134
+ }
135
+ catch {
136
+ _securityProbeCache = false;
137
+ }
138
+ return _securityProbeCache;
139
+ }
140
+ /** @internal Test seam — clear the security-binary probe cache. */
141
+ function __resetSecurityProbeCacheForTest() {
142
+ _securityProbeCache = undefined;
143
+ }
144
+ // ---------------------------------------------------------------------------
145
+ // Login-keychain resolution.
146
+ //
147
+ // Memoized per-process so we make at most one `security default-keychain`
148
+ // call per probe lifecycle. `__setExecFileForTest()` and
149
+ // `__resetResolvedKeychainCacheForTest()` clear the cache so test mocks
150
+ // can drive deterministic resolution.
151
+ // ---------------------------------------------------------------------------
152
+ /**
153
+ * `undefined` = not yet probed; `string` = absolute path of the resolved
154
+ * login keychain; `null` = nothing resolved (treat as unavailable).
155
+ */
156
+ let _resolvedKeychainCache;
157
+ /**
158
+ * Resolve the canonical login keychain path for the current user.
159
+ *
160
+ * Resolution order:
161
+ * 1. Query `security default-keychain -d user`. Output is a quoted
162
+ * absolute path; parse and return.
163
+ * 2. Fall back to `~/Library/Keychains/login.keychain-db`
164
+ * (modern macOS, El Capitan and later).
165
+ * 3. Fall back to `~/Library/Keychains/login.keychain` (legacy).
166
+ * 4. Return `null` when nothing resolves — caller treats as unavailable.
167
+ *
168
+ * Caching: result is memoized for the process lifetime in
169
+ * `_resolvedKeychainCache` so we make at most one `security
170
+ * default-keychain` call per probe lifecycle. The cache is reset by
171
+ * `__resetResolvedKeychainCacheForTest()` and `__setExecFileForTest()`.
172
+ *
173
+ * Why explicit resolution: passing the resolved path as a positional
174
+ * argument to `security find-generic-password` and `add-generic-password`
175
+ * eliminates the system-modal "Keychain Not Found" dialog that
176
+ * `security` triggers when it cannot find a default keychain in the
177
+ * current exec context. A daemon must never block on UI.
178
+ */
179
+ async function resolveLoginKeychain() {
180
+ if (_resolvedKeychainCache !== undefined)
181
+ return _resolvedKeychainCache;
182
+ // Step 1 — ask `security` for the default user keychain.
183
+ try {
184
+ const { stdout } = await exec('security', ['default-keychain', '-d', 'user']);
185
+ // Output shape: ` "/Users/foo/Library/Keychains/login.keychain-db"`
186
+ // (leading whitespace, then the path quoted)
187
+ const match = stdout.match(/"([^"]+)"/);
188
+ if (match && match[1] && node_fs_1.default.existsSync(match[1])) {
189
+ _resolvedKeychainCache = match[1];
190
+ return _resolvedKeychainCache;
191
+ }
192
+ }
193
+ catch {
194
+ /* fall through to canonical paths */
195
+ }
196
+ // Step 2 — modern canonical path.
197
+ const modern = node_path_1.default.join(node_os_1.default.homedir(), 'Library', 'Keychains', 'login.keychain-db');
198
+ if (node_fs_1.default.existsSync(modern)) {
199
+ _resolvedKeychainCache = modern;
200
+ return _resolvedKeychainCache;
201
+ }
202
+ // Step 3 — legacy path (pre-El Capitan).
203
+ const legacy = node_path_1.default.join(node_os_1.default.homedir(), 'Library', 'Keychains', 'login.keychain');
204
+ if (node_fs_1.default.existsSync(legacy)) {
205
+ _resolvedKeychainCache = legacy;
206
+ return _resolvedKeychainCache;
207
+ }
208
+ // Step 4 — nothing resolves; caller treats as unavailable.
209
+ _resolvedKeychainCache = null;
210
+ return null;
211
+ }
212
+ /** @internal Test seam — clear the resolved-keychain cache. */
213
+ function __resetResolvedKeychainCacheForTest() {
214
+ _resolvedKeychainCache = undefined;
215
+ }
216
+ // ---------------------------------------------------------------------------
217
+ // Shellout backend: Keychain-resident 32-byte KEK + AES-256-GCM wrap.
218
+ // ---------------------------------------------------------------------------
219
+ /**
220
+ * Read the master KEK bytes from the keychain. Returns null on item-not-
221
+ * found (exit 44) — caller treats this as "first use, generate one."
222
+ *
223
+ * Any other failure (locked keychain, missing binary at runtime, etc.)
224
+ * throws a `KekUnavailableError` so the caller can decide between
225
+ * fail-open (default) and fail-closed (`OVERSKY_KEK_REQUIRED=1`).
226
+ *
227
+ * The `service` / `account` parameters are threaded from the calling
228
+ * `MacosKek` instance so each instance keeps its own keychain entry.
229
+ *
230
+ * Explicit-keychain target: the resolved login-keychain path is passed
231
+ * as the trailing positional argument to `find-generic-password` so
232
+ * `security(1)` cannot fall back to a "default keychain" lookup in a
233
+ * non-GUI exec context, which would otherwise trigger a system-modal
234
+ * `Keychain Not Found` dialog and block the daemon. If no keychain
235
+ * resolves, throw `KekUnavailableError` immediately rather than
236
+ * shelling out at all.
237
+ */
238
+ async function readKekFromKeychain(service, account) {
239
+ const keychainPath = await resolveLoginKeychain();
240
+ if (keychainPath === null) {
241
+ throw new types_js_1.KekUnavailableError(KIND_SHELLOUT, 'no user login keychain resolvable; cannot read master KEK');
242
+ }
243
+ try {
244
+ const { stdout } = await exec('security', [
245
+ 'find-generic-password',
246
+ '-s',
247
+ service,
248
+ '-a',
249
+ account,
250
+ '-w',
251
+ keychainPath,
252
+ ]);
253
+ const trimmed = stdout.replace(/\n$/, '').trim();
254
+ if (trimmed.length === 0)
255
+ return null;
256
+ let raw;
257
+ try {
258
+ raw = Buffer.from(trimmed, 'base64');
259
+ }
260
+ catch {
261
+ throw new types_js_1.KekUnavailableError(KIND_SHELLOUT, 'keychain KEK is not valid base64');
262
+ }
263
+ if (raw.length !== credentialEnvelope_js_1.KEY_LEN) {
264
+ throw new types_js_1.KekUnavailableError(KIND_SHELLOUT, `keychain KEK has wrong length (got ${raw.length}, want ${credentialEnvelope_js_1.KEY_LEN})`);
265
+ }
266
+ return raw;
267
+ }
268
+ catch (err) {
269
+ if (err instanceof types_js_1.KekUnavailableError)
270
+ throw err;
271
+ const status = err.code ?? err.status;
272
+ if (status === SECURITY_ITEM_NOT_FOUND)
273
+ return null;
274
+ const stderr = String(err.stderr ?? '');
275
+ if (stderr.includes('User interaction is not allowed') ||
276
+ stderr.includes('errSecInteractionNotAllowed') ||
277
+ stderr.includes('errSecAuthFailed')) {
278
+ throw new types_js_1.KekUnavailableError(KIND_SHELLOUT, `keychain locked or non-interactive context (status=${status}); ` +
279
+ `unlock the login keychain or run the daemon from a GUI session`, { cause: err });
280
+ }
281
+ throw new types_js_1.KekUnavailableError(KIND_SHELLOUT, `find-generic-password failed (status=${status})`, {
282
+ cause: err,
283
+ });
284
+ }
285
+ }
286
+ /**
287
+ * Persist a master KEK to the keychain. Uses `-U` so the upsert is
288
+ * idempotent — repeated first-use races just overwrite each other rather
289
+ * than failing with "item already exists." We accept the second-writer-
290
+ * wins outcome because (a) the daemon is single-tenant per profile and
291
+ * (b) all writers are generating the same shape of secret.
292
+ *
293
+ * Race note: if two daemon processes both miss and both store within the
294
+ * same TOCTOU window, the second overwrite wins and any DEKs already
295
+ * wrapped under the first KEK become permanently unrecoverable. The
296
+ * envelope path's caller (`credEnvelope.ts:initCredEnvelope`) handles
297
+ * this gracefully — `unwrap` failure → log + treat as first enrollment.
298
+ *
299
+ * The `service` / `account` parameters are threaded from the calling
300
+ * `MacosKek` instance so each instance keeps its own keychain entry.
301
+ *
302
+ * Explicit-keychain target: the resolved login-keychain path is passed
303
+ * as the trailing positional argument to `add-generic-password` so
304
+ * `security(1)` cannot fall back to a "default keychain" lookup that
305
+ * triggers a system-modal `Keychain Not Found` dialog. If no keychain
306
+ * resolves, throw `KekUnavailableError` immediately rather than
307
+ * shelling out at all.
308
+ */
309
+ async function writeKekToKeychain(kek, service, account) {
310
+ if (kek.length !== credentialEnvelope_js_1.KEY_LEN) {
311
+ throw new types_js_1.KekUnavailableError(KIND_SHELLOUT, `writeKekToKeychain: expected ${credentialEnvelope_js_1.KEY_LEN}-byte buffer, got ${kek.length}`);
312
+ }
313
+ const keychainPath = await resolveLoginKeychain();
314
+ if (keychainPath === null) {
315
+ throw new types_js_1.KekUnavailableError(KIND_SHELLOUT, 'no user login keychain resolvable; cannot write master KEK');
316
+ }
317
+ const b64 = kek.toString('base64');
318
+ try {
319
+ await exec('security', [
320
+ 'add-generic-password',
321
+ '-s',
322
+ service,
323
+ '-a',
324
+ account,
325
+ '-w',
326
+ b64,
327
+ '-U',
328
+ keychainPath,
329
+ ]);
330
+ }
331
+ catch (err) {
332
+ if (err instanceof types_js_1.KekUnavailableError)
333
+ throw err;
334
+ const status = err.code ?? err.status;
335
+ const stderr = String(err.stderr ?? '');
336
+ if (stderr.includes('User interaction is not allowed') ||
337
+ stderr.includes('errSecInteractionNotAllowed') ||
338
+ stderr.includes('errSecAuthFailed')) {
339
+ throw new types_js_1.KekUnavailableError(KIND_SHELLOUT, `keychain locked or non-interactive context (status=${status}); ` +
340
+ `unlock the login keychain or run the daemon from a GUI session`, { cause: err });
341
+ }
342
+ throw new types_js_1.KekUnavailableError(KIND_SHELLOUT, `add-generic-password failed (status=${status})`, {
343
+ cause: err,
344
+ });
345
+ }
346
+ }
347
+ /**
348
+ * In-process cache of master KEKs, keyed by `${service}::${account}` so
349
+ * multiple `MacosKek` instances scoped to different keychain entries
350
+ * (e.g. the smoke test's per-run unique service, or a multi-tenant
351
+ * test) keep isolated cache slots. Loaded lazily on the first
352
+ * wrap/unwrap so a daemon that never reaches the active envelope path
353
+ * never spawns a `security` process. Cleared by
354
+ * `__resetKekCacheForTest()` and by the registered zeroize hook so the
355
+ * test suite and production graceful-shutdown can simulate or enforce
356
+ * fresh-process state.
357
+ */
358
+ const _kekCacheBySvc = new Map();
359
+ // Register the zeroize hook at module-load time so any caller that
360
+ // invokes `zeroizeKekCaches()` from `@skrr-ai/auth-core` (e.g. the
361
+ // daemon's graceful-shutdown handler) clears this cache without needing
362
+ // a direct import of macos.ts.
363
+ (0, zeroize_registry_js_1.registerZeroizeHook)(() => zeroizeMacosKekCache());
364
+ /**
365
+ * Zero out and drop every cached master-KEK buffer.
366
+ *
367
+ * Iterates the cache map, fills each buffer with zeroes (so any
368
+ * residual reference held by a caller observes the wipe), then clears
369
+ * the map. The buffer-fill happens before the map clear so the typed
370
+ * test seam (`__getKekCacheForTest`) can prove the wipe targeted the
371
+ * actual cached bytes rather than a copy.
372
+ *
373
+ * Must be called on:
374
+ * - Daemon shutdown (SIGTERM / SIGINT) so the KEK does not linger in
375
+ * memory across the process exit window where coredumps could land.
376
+ * - KEK rotation, after the new KEK has been resolved and the wrapped
377
+ * DEK re-issued, so the OLD KEK does not stay resident.
378
+ * - Logout flows that wipe at-rest credentials, so there is no useful
379
+ * KEK left to wrap a re-enrolled DEK under the previous identity.
380
+ *
381
+ * Idempotent — safe to call when the cache is already empty.
382
+ */
383
+ function zeroizeMacosKekCache() {
384
+ for (const buf of _kekCacheBySvc.values()) {
385
+ buf.fill(0);
386
+ }
387
+ _kekCacheBySvc.clear();
388
+ }
389
+ /** @internal Test seam — clear the in-process master-KEK cache. Internally
390
+ * routes through the production zeroize helper so the test-reset path
391
+ * exercises the same buffer-fill that ships in production. */
392
+ function __resetKekCacheForTest() {
393
+ zeroizeMacosKekCache();
394
+ }
395
+ /**
396
+ * @internal Test seam — return the live cache buffer for the given
397
+ * `service`/`account` pair so the zeroize test can hold a reference,
398
+ * run zeroize, and verify the underlying bytes went to all-zero
399
+ * (proving the fill was on the actual buffer rather than a copy).
400
+ * Returns null when no entry is cached for that pair.
401
+ *
402
+ * Defaults to the canonical production keys so existing tests that
403
+ * call `__getKekCacheForTest()` keep working without args.
404
+ */
405
+ function __getKekCacheForTest(service = KEYCHAIN_SERVICE, account = KEYCHAIN_ACCOUNT) {
406
+ return _kekCacheBySvc.get(`${service}::${account}`) ?? null;
407
+ }
408
+ /**
409
+ * Resolve the master KEK for the given `service`/`account` pair,
410
+ * generating + storing one on first use. Cached per pair after the
411
+ * first successful resolution so subsequent wrap/unwrap calls on the
412
+ * same instance do not respawn `security`.
413
+ */
414
+ async function resolveOrCreateKek(service, account) {
415
+ const cacheKey = `${service}::${account}`;
416
+ const cached = _kekCacheBySvc.get(cacheKey);
417
+ if (cached !== undefined)
418
+ return cached;
419
+ const existing = await readKekFromKeychain(service, account);
420
+ if (existing !== null) {
421
+ _kekCacheBySvc.set(cacheKey, existing);
422
+ return existing;
423
+ }
424
+ const fresh = node_crypto_1.default.randomBytes(credentialEnvelope_js_1.KEY_LEN);
425
+ await writeKekToKeychain(fresh, service, account);
426
+ _kekCacheBySvc.set(cacheKey, fresh);
427
+ return fresh;
428
+ }
429
+ /** Resolve an already-persisted master KEK without creating one. */
430
+ async function resolveExistingKek(service, account) {
431
+ const cacheKey = `${service}::${account}`;
432
+ const cached = _kekCacheBySvc.get(cacheKey);
433
+ if (cached !== undefined)
434
+ return cached;
435
+ const existing = await readKekFromKeychain(service, account);
436
+ if (existing === null) {
437
+ throw new types_js_1.KekUnavailableError(KIND_SHELLOUT, 'macOS Keychain master KEK is missing; refusing to create it outside the enrollment lock');
438
+ }
439
+ _kekCacheBySvc.set(cacheKey, existing);
440
+ return existing;
441
+ }
442
+ /**
443
+ * Wrap a DEK using the shellout backend. Returns the serialized envelope
444
+ * bytes (a UTF-8 string encoded as Buffer) so the on-disk shape matches
445
+ * the in-memory test KEK.
446
+ */
447
+ async function shelloutWrap(dek, aad, service, account) {
448
+ const kek = await resolveOrCreateKek(service, account);
449
+ const sealed = (0, credentialEnvelope_js_1.sealBuffer)(dek, kek, aad);
450
+ return Buffer.from((0, credentialEnvelope_js_1.serialize)(sealed), 'utf-8');
451
+ }
452
+ /**
453
+ * Unwrap a previously-wrapped DEK using the shellout backend. Throws
454
+ * loudly on tag mismatch — `credEnvelope.ts` decides whether to fail-open
455
+ * or fail-closed.
456
+ */
457
+ async function shelloutUnwrap(wrapped, aad, service, account, createIfMissing = true) {
458
+ const kek = await (createIfMissing
459
+ ? resolveOrCreateKek(service, account)
460
+ : resolveExistingKek(service, account));
461
+ const text = wrapped.toString('utf-8');
462
+ const sealed = (0, credentialEnvelope_js_1.deserialize)(text);
463
+ if (!sealed) {
464
+ throw new types_js_1.KekUnavailableError(KIND_SHELLOUT, 'wrapped blob is not a valid serialized envelope');
465
+ }
466
+ const out = (0, credentialEnvelope_js_1.openBuffer)(sealed, kek, aad);
467
+ if (out.length !== credentialEnvelope_js_1.KEY_LEN) {
468
+ throw new types_js_1.KekUnavailableError(KIND_SHELLOUT, `unwrapped DEK has wrong length (got ${out.length}, want ${credentialEnvelope_js_1.KEY_LEN})`);
469
+ }
470
+ return out;
471
+ }
472
+ // ---------------------------------------------------------------------------
473
+ // MacosKek — KekStrategy implementation.
474
+ // ---------------------------------------------------------------------------
475
+ class MacosKek {
476
+ id = 'macos-secure-enclave';
477
+ /** Telemetry kind of the backend that satisfied the most recent call.
478
+ * Public so the daemon's `cred_envelope` events can include it. */
479
+ _lastKind = null;
480
+ /** Keychain `service` (the `-s` arg) this instance reads/writes. */
481
+ service;
482
+ /** Keychain `account` (the `-a` arg) this instance reads/writes. */
483
+ account;
484
+ /**
485
+ * @param opts.service Optional override for the keychain `-s` value.
486
+ * Defaults to `ai.skrr.daemon.kek` — the canonical production
487
+ * pair. Override only for hermetic multi-tenant tests or for the
488
+ * real-keychain smoke test, which generates a unique service per
489
+ * run so its `delete-generic-password` teardown cannot clobber the
490
+ * developer's actual master KEK. Production callers MUST pass
491
+ * nothing so fleet telemetry stays sized against one keychain
492
+ * entry per host.
493
+ * @param opts.account Optional override for the keychain `-a` value.
494
+ * Defaults to `master-kek-v1`. Same caveat as `service`: an
495
+ * override that points at a non-default entry must be paired with
496
+ * explicit cleanup if the entry should not persist past the
497
+ * caller's lifetime — `MacosKek` itself never deletes keychain
498
+ * items.
499
+ */
500
+ constructor(opts) {
501
+ this.service = opts?.service ?? KEYCHAIN_SERVICE;
502
+ this.account = opts?.account ?? KEYCHAIN_ACCOUNT;
503
+ }
504
+ /**
505
+ * Telemetry kind of the most recent successful wrap/unwrap. `null`
506
+ * before any call. Stable string `'macos-keychain-shellout'` after the
507
+ * first successful wrap or unwrap. The kind does not vary with
508
+ * `service`/`account` overrides — the backend is the same.
509
+ */
510
+ get kind() {
511
+ return this._lastKind;
512
+ }
513
+ /**
514
+ * `available()` is true on darwin when (a) `security` is on PATH AND
515
+ * (b) a user login keychain can be resolved. Both checks are required
516
+ * because shelling out without a resolvable keychain triggers a
517
+ * system-modal `Keychain Not Found` dialog from the daemon — which
518
+ * blocks the process and offers a destructive `Reset To Defaults`
519
+ * action. When the keychain resolution fails, we honestly return
520
+ * `false` so `credEnvelope.ts` reports `disabled.kek_unavailable`
521
+ * cleanly. Cached results — the probe spawns at most one
522
+ * `security help` and one `security default-keychain` per process.
523
+ */
524
+ async available() {
525
+ if (process.platform !== 'darwin')
526
+ return false;
527
+ if (!(await probeSecurityBinary()))
528
+ return false;
529
+ const keychainPath = await resolveLoginKeychain();
530
+ return keychainPath !== null;
531
+ }
532
+ async wrap(dek, aad) {
533
+ if (process.platform !== 'darwin') {
534
+ throw new types_js_1.KekUnavailableError(this.id, 'macOS KEK called on non-darwin platform');
535
+ }
536
+ const aadBuf = aad ?? Buffer.alloc(0);
537
+ if (!(await probeSecurityBinary())) {
538
+ throw new types_js_1.KekUnavailableError(this.id, 'macOS KEK unavailable: `security` not on PATH');
539
+ }
540
+ try {
541
+ const out = await shelloutWrap(dek, aadBuf, this.service, this.account);
542
+ this._lastKind = KIND_SHELLOUT;
543
+ return out;
544
+ }
545
+ catch (err) {
546
+ if (err instanceof types_js_1.KekUnavailableError)
547
+ throw err;
548
+ throw new types_js_1.KekUnavailableError(this.id, err?.message ?? 'shellout wrap failed', {
549
+ cause: err,
550
+ });
551
+ }
552
+ }
553
+ async unwrap(wrapped, aad) {
554
+ if (process.platform !== 'darwin') {
555
+ throw new types_js_1.KekUnavailableError(this.id, 'macOS KEK called on non-darwin platform');
556
+ }
557
+ const aadBuf = aad ?? Buffer.alloc(0);
558
+ if (!(await probeSecurityBinary())) {
559
+ throw new types_js_1.KekUnavailableError(this.id, 'macOS KEK unavailable: `security` not on PATH');
560
+ }
561
+ try {
562
+ const out = await shelloutUnwrap(wrapped, aadBuf, this.service, this.account);
563
+ this._lastKind = KIND_SHELLOUT;
564
+ return out;
565
+ }
566
+ catch (err) {
567
+ if (err instanceof types_js_1.KekUnavailableError)
568
+ throw err;
569
+ // Pass cipher errors (tag mismatch, malformed blob) through with
570
+ // no swallowing — credEnvelope.ts decides fail-open vs fail-closed.
571
+ throw err;
572
+ }
573
+ }
574
+ async unwrapExisting(wrapped, aad) {
575
+ if (process.platform !== 'darwin') {
576
+ throw new types_js_1.KekUnavailableError(this.id, 'macOS KEK called on non-darwin platform');
577
+ }
578
+ const aadBuf = aad ?? Buffer.alloc(0);
579
+ if (!(await probeSecurityBinary())) {
580
+ throw new types_js_1.KekUnavailableError(this.id, 'macOS KEK unavailable: `security` not on PATH');
581
+ }
582
+ const out = await shelloutUnwrap(wrapped, aadBuf, this.service, this.account, false);
583
+ this._lastKind = KIND_SHELLOUT;
584
+ return out;
585
+ }
586
+ describe() {
587
+ if (process.platform !== 'darwin') {
588
+ return 'macOS KEK (not darwin — unavailable)';
589
+ }
590
+ if (_securityProbeCache === true) {
591
+ return 'macOS Keychain via security(1) shellout';
592
+ }
593
+ if (_securityProbeCache === false) {
594
+ return 'macOS KEK unavailable (security binary missing)';
595
+ }
596
+ return 'macOS KEK (probe pending — call available() to materialize)';
597
+ }
598
+ }
599
+ exports.MacosKek = MacosKek;