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