@skrr-ai/cli 0.1.123 → 0.1.125

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 (89) hide show
  1. package/dist/commands/agents/chat.d.ts +26 -0
  2. package/dist/commands/agents/chat.js +48 -10
  3. package/dist/commands/agents/delete.d.ts +8 -0
  4. package/dist/commands/agents/delete.js +16 -0
  5. package/dist/commands/context.js +7 -0
  6. package/dist/commands/search/workspace.js +1 -1
  7. package/dist/commands/store/gallery-admissions/approve.js +1 -1
  8. package/dist/commands/store/gallery-admissions/preview.js +1 -1
  9. package/dist/commands/store/gallery-admissions/refuse.js +1 -1
  10. package/dist/commands/store/gallery-admissions/show.js +1 -1
  11. package/dist/generated/agent-guides.json +19 -19
  12. package/dist/lib/agentic-stream.js +15 -1
  13. package/dist/lib/auth-core-init.d.ts +15 -2
  14. package/dist/lib/auth-core-init.js +47 -6
  15. package/dist/lib/command-hygiene.js +30 -1
  16. package/dist/lib/daemonBrokerRefusal.js +5 -1
  17. package/dist/lib/keychain.js +41 -4
  18. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/cliHandoffWire.d.ts +5 -1
  19. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/cliHandoffWire.js +1 -1
  20. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/credentialEnvelopeBridge.js +13 -28
  21. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceIdentityBridge.d.ts +133 -4
  22. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceIdentityBridge.js +387 -90
  23. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceKey.d.ts +106 -7
  24. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceKey.js +77 -19
  25. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceKeyHandle.d.ts +33 -0
  26. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceKeyHandle.js +77 -0
  27. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceKeyHardware.d.ts +68 -0
  28. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceKeyHardware.js +264 -0
  29. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceKeyServerSupport.d.ts +31 -0
  30. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceKeyServerSupport.js +90 -0
  31. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/dpop.js +2 -0
  32. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/independentMachineReservation.js +2 -0
  33. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/index.d.ts +7 -3
  34. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/index.js +102 -0
  35. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/kek/index.d.ts +1 -1
  36. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/kek/index.js +6 -0
  37. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/kek/linux.d.ts +18 -0
  38. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/kek/linux.js +8 -0
  39. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/kek/macos.d.ts +22 -0
  40. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/kek/macos.js +11 -0
  41. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/kek/masterKekService.d.ts +43 -3
  42. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/kek/masterKekService.js +33 -3
  43. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/liveState.d.ts +154 -0
  44. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/liveState.js +281 -0
  45. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/machineId.js +6 -3
  46. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/machineUuid.js +8 -5
  47. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/profileStateDir.js +7 -27
  48. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/refresh.d.ts +1 -1
  49. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/refresh.js +12 -10
  50. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/stateFiles.d.ts +31 -0
  51. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/stateFiles.js +89 -0
  52. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/cliHandoffWire.d.ts +5 -1
  53. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/cliHandoffWire.js +4 -1
  54. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/credentialEnvelopeBridge.js +15 -37
  55. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceIdentityBridge.d.ts +133 -4
  56. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceIdentityBridge.js +536 -101
  57. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceKey.d.ts +106 -7
  58. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceKey.js +131 -27
  59. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceKeyHandle.d.ts +33 -0
  60. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceKeyHandle.js +81 -0
  61. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceKeyHardware.d.ts +68 -0
  62. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceKeyHardware.js +317 -0
  63. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceKeyServerSupport.d.ts +31 -0
  64. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceKeyServerSupport.js +86 -0
  65. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/dpop.js +4 -0
  66. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/independentMachineReservation.js +4 -0
  67. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/index.d.ts +7 -3
  68. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/index.js +13 -3
  69. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/kek/index.d.ts +1 -1
  70. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/kek/index.js +1 -1
  71. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/kek/linux.d.ts +18 -0
  72. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/kek/linux.js +15 -0
  73. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/kek/macos.d.ts +22 -0
  74. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/kek/macos.js +20 -0
  75. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/kek/masterKekService.d.ts +43 -3
  76. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/kek/masterKekService.js +104 -8
  77. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/liveState.d.ts +154 -0
  78. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/liveState.js +386 -0
  79. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/machineId.js +6 -3
  80. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/machineUuid.js +11 -5
  81. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/profileStateDir.js +6 -35
  82. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/refresh.d.ts +1 -1
  83. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/refresh.js +14 -10
  84. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/stateFiles.d.ts +31 -0
  85. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/stateFiles.js +73 -0
  86. package/dist/node_modules/@skrr-ai/auth-core/package.json +11 -1
  87. package/dist/node_modules/@skrr-ai/data-provider/index.js +1094 -1067
  88. package/oclif.manifest.json +16 -3
  89. package/package.json +4 -1
@@ -0,0 +1,317 @@
1
+ /**
2
+ * deviceKeyHardware.ts — the hardware-bound device key (ES256, non-exportable),
3
+ * reached through a small native helper.
4
+ *
5
+ * WHAT IS STORED. Not a key. CryptoKit's `SecureEnclave.P256.Signing.PrivateKey`
6
+ * hands back an opaque `dataRepresentation` (284 bytes on the machine this was
7
+ * measured on): the key wrapped by that machine's Secure Enclave. Only the same
8
+ * Secure Enclave can unwrap it, so the blob is useless on any other machine, and
9
+ * nothing in it is a scalar. We keep it in `device-key.se` beside the public key.
10
+ *
11
+ * Three consequences, each measured rather than assumed (2026-10-30, Apple
12
+ * silicon, a binary signed ad hoc with NO entitlements, NO keychain access group
13
+ * and NO provisioning profile; the hardened runtime made no difference):
14
+ *
15
+ * 1. It needs none of the restricted entitlements the design doc feared. That
16
+ * fear was about persistent Keychain-resident Secure Enclave keys. A blob we
17
+ * store ourselves is not a Keychain item.
18
+ * 2. It is NOT bound to the app that created it: a differently-identified
19
+ * binary loaded the same blob and signed with the same public key. What the
20
+ * hardware buys is that the key cannot leave the machine (a copied disk, a
21
+ * backup, a restored image, malware that uploads files). It does not stop a
22
+ * process running as the same user on the same machine from asking the
23
+ * Enclave to sign. Binding use to one app needs a Keychain-resident key and
24
+ * the entitlements; that is a further hardening, recorded in the ADR, not
25
+ * a prerequisite.
26
+ * 3. The blob is NOT sealed under the credential envelope. Sealing it would
27
+ * re-create the coupling that lost a device key on 2026-10-29 (key life tied
28
+ * to the DEK, which is tied to a machine-wide KEK): it is harmless to a thief
29
+ * and a liability to its owner. A lost KEK now costs the credentials, which a
30
+ * sign-in rebuilds, and not the device identity, which needs a person to
31
+ * re-pin.
32
+ *
33
+ * WHY A ONE-SHOT HELPER, NOT A SERVICE. Load is ~2 ms and a signature ~5 ms
34
+ * inside the helper; process start dominates. A device proof is signed on a
35
+ * refresh or a hand-off, not per request, so spawning per signature is cheap and
36
+ * removes a whole class of state: no long-lived child to supervise, restart, leak
37
+ * or race, and nothing to be stale after a daemon upgrade. The helper holds no
38
+ * state between calls; the blob in the request is the whole key.
39
+ *
40
+ * PROTOCOL (version 1). One JSON object on stdin, one JSON object on stdout,
41
+ * exit 0 on success. Binary values are standard base64.
42
+ *
43
+ * probe {} -> {v, secureEnclave: boolean}
44
+ * create {} -> {v, blob, publicKey} publicKey = x||y, 64 bytes
45
+ * sign {blob, input} -> {v, signature, publicKey} signature = r||s, 64 bytes
46
+ *
47
+ * A failure exits non-zero with {v, error: {code, message}} on stdout. `sign`
48
+ * returns the public key it derived from the blob so a blob that was swapped for
49
+ * another machine's key, or another key's, is caught here and not at the server.
50
+ */
51
+ import crypto from 'node:crypto';
52
+ import { execFile } from 'node:child_process';
53
+ import fs from 'node:fs';
54
+ import path from 'node:path';
55
+ import { isDevicePublicJwk, jwkThumbprint, p256PublicJwkFromRaw, } from './deviceKey.js';
56
+ import { getAuthMachineConfigDir } from './runtime.js';
57
+ /** File name of the helper next to the daemon binary and under `<machine root>/bin`. */
58
+ export const HARDWARE_KEY_HELPER_NAME = 'skrr-native-device-key';
59
+ export const HARDWARE_KEY_PROTOCOL = 1;
60
+ const PROBE_TIMEOUT_MS = 5_000;
61
+ const CREATE_TIMEOUT_MS = 15_000;
62
+ const SIGN_TIMEOUT_MS = 10_000;
63
+ /** A failed probe is looked at again after this long; a successful one never is. */
64
+ const PROBE_RETRY_MS = 60_000;
65
+ export class HardwareDeviceKeyError extends Error {
66
+ code;
67
+ constructor(code, message) {
68
+ super(message);
69
+ this.code = code;
70
+ this.name = 'HardwareDeviceKeyError';
71
+ }
72
+ }
73
+ // ---------------------------------------------------------------------------
74
+ // Locating the helper
75
+ // ---------------------------------------------------------------------------
76
+ let _resolver = null;
77
+ /**
78
+ * Tell auth-core where the helper is. The daemon calls this at boot with the
79
+ * path next to its own binary; a process that never does (the CLI, a test)
80
+ * falls back to the places the helper is installed: beside this process's
81
+ * executable, then `<machine root>/bin`. Pass `null` to forget a resolver.
82
+ */
83
+ export function configureHardwareDeviceKeyHelper(resolver) {
84
+ _resolver = resolver;
85
+ _probe = null;
86
+ }
87
+ function isExecutableFile(file) {
88
+ try {
89
+ if (!fs.statSync(file).isFile())
90
+ return false;
91
+ fs.accessSync(file, fs.constants.X_OK);
92
+ return true;
93
+ }
94
+ catch {
95
+ return false;
96
+ }
97
+ }
98
+ /** Absolute path of an executable helper, or null when this machine has none. */
99
+ export function resolveHardwareDeviceKeyHelper() {
100
+ const candidates = [];
101
+ try {
102
+ candidates.push(_resolver ? _resolver() : null);
103
+ }
104
+ catch {
105
+ /* a resolver that throws is a resolver with no answer */
106
+ }
107
+ candidates.push(path.join(path.dirname(process.execPath), HARDWARE_KEY_HELPER_NAME));
108
+ try {
109
+ candidates.push(path.join(getAuthMachineConfigDir(), 'bin', HARDWARE_KEY_HELPER_NAME));
110
+ }
111
+ catch {
112
+ /* auth-core not configured yet */
113
+ }
114
+ for (const candidate of candidates) {
115
+ if (candidate && path.isAbsolute(candidate) && isExecutableFile(candidate))
116
+ return candidate;
117
+ }
118
+ return null;
119
+ }
120
+ // ---------------------------------------------------------------------------
121
+ // Running it
122
+ // ---------------------------------------------------------------------------
123
+ let _timeoutOverrideMs = null;
124
+ /** @internal Test seam — bound every helper call by `ms`. `null` restores the real limits. */
125
+ export function __setHardwareHelperTimeoutForTest(ms) {
126
+ _timeoutOverrideMs = ms;
127
+ }
128
+ function runHelper(op, request, timeoutMs) {
129
+ const helper = resolveHardwareDeviceKeyHelper();
130
+ if (!helper) {
131
+ return Promise.reject(new HardwareDeviceKeyError('helper_missing', `${HARDWARE_KEY_HELPER_NAME} is not installed on this machine`));
132
+ }
133
+ return new Promise((resolve, reject) => {
134
+ const child = execFile(helper, [op],
135
+ // No shell, no inherited environment: the helper needs nothing from ours,
136
+ // and a signing process should not see session tokens it has no use for.
137
+ {
138
+ timeout: _timeoutOverrideMs ?? timeoutMs,
139
+ maxBuffer: 64 * 1024,
140
+ windowsHide: true,
141
+ env: {},
142
+ encoding: 'utf8',
143
+ }, (error, stdout) => {
144
+ let parsed = null;
145
+ try {
146
+ parsed = JSON.parse(stdout);
147
+ }
148
+ catch {
149
+ parsed = null;
150
+ }
151
+ if (error) {
152
+ if (error.killed) {
153
+ reject(new HardwareDeviceKeyError('timeout', `${op} did not finish within ${_timeoutOverrideMs ?? timeoutMs} ms`));
154
+ return;
155
+ }
156
+ const code = parsed?.error?.code;
157
+ const message = parsed?.error?.message;
158
+ reject(new HardwareDeviceKeyError(code === 'unsupported' ? 'unsupported' : 'helper_failed', typeof message === 'string' ? message : `${op} failed: ${error.message}`));
159
+ return;
160
+ }
161
+ if (!parsed || parsed.v !== HARDWARE_KEY_PROTOCOL) {
162
+ reject(new HardwareDeviceKeyError('bad_response', `${op} returned an unexpected response`));
163
+ return;
164
+ }
165
+ resolve(parsed);
166
+ });
167
+ child.stdin?.on('error', () => {
168
+ /* the helper exiting before it read stdin surfaces through the callback */
169
+ });
170
+ child.stdin?.end(JSON.stringify({ v: HARDWARE_KEY_PROTOCOL, ...request }));
171
+ });
172
+ }
173
+ function decodeFixed(value, bytes, what) {
174
+ if (typeof value !== 'string')
175
+ throw new HardwareDeviceKeyError('bad_response', `${what} is missing`);
176
+ const buf = Buffer.from(value, 'base64');
177
+ if (buf.length !== bytes) {
178
+ throw new HardwareDeviceKeyError('bad_response', `${what} is ${buf.length} bytes, expected ${bytes}`);
179
+ }
180
+ return buf;
181
+ }
182
+ let _probe = null;
183
+ /**
184
+ * Can this machine create and use a hardware-bound device key right now? Looked
185
+ * up, never assumed: the answer needs both a helper that runs and hardware that
186
+ * answers. A positive result is kept for the life of the process; a negative one
187
+ * is retried after a minute, so a helper installed by an update is picked up
188
+ * without a restart and a broken one is not spawned on every proof.
189
+ */
190
+ export async function probeHardwareDeviceKey() {
191
+ if (_probe && (_probe.result.available || Date.now() - _probe.at < PROBE_RETRY_MS))
192
+ return _probe.result;
193
+ let result;
194
+ try {
195
+ const res = await runHelper('probe', {}, PROBE_TIMEOUT_MS);
196
+ result =
197
+ res.secureEnclave === true
198
+ ? { available: true }
199
+ : { available: false, reason: 'no_secure_hardware' };
200
+ }
201
+ catch (err) {
202
+ const e = err;
203
+ result = {
204
+ available: false,
205
+ reason: e.code === 'helper_missing'
206
+ ? 'helper_missing'
207
+ : e.code === 'unsupported'
208
+ ? 'no_secure_hardware'
209
+ : 'probe_failed',
210
+ message: e.message,
211
+ };
212
+ }
213
+ _probe = { result, at: Date.now() };
214
+ return result;
215
+ }
216
+ /** @internal Test seam — forget the cached capability. */
217
+ export function __resetHardwareProbeForTest() {
218
+ _probe = null;
219
+ }
220
+ export function serializeHardwareKeyRecord(record, createdAt = new Date()) {
221
+ return JSON.stringify({
222
+ v: 1,
223
+ alg: 'ES256',
224
+ custody: 'hardware',
225
+ blob: record.blob,
226
+ publicJwk: record.publicJwk,
227
+ thumbprint: jwkThumbprint(record.publicJwk),
228
+ createdAt: createdAt.toISOString(),
229
+ });
230
+ }
231
+ /**
232
+ * Parse `device-key.se`. Null for anything that is not a well-formed v1 record
233
+ * whose stored thumbprint matches its own public key: a record is trusted for
234
+ * exactly what it proves about itself, so a half-written or hand-edited file
235
+ * reads as unreadable (which never replaces it) and not as a different key.
236
+ */
237
+ export function parseHardwareKeyRecord(text) {
238
+ try {
239
+ const parsed = JSON.parse(text);
240
+ if (parsed.v !== 1 ||
241
+ parsed.alg !== 'ES256' ||
242
+ typeof parsed.blob !== 'string' ||
243
+ parsed.blob.length === 0) {
244
+ return null;
245
+ }
246
+ if (!isDevicePublicJwk(parsed.publicJwk) || parsed.publicJwk.kty !== 'EC')
247
+ return null;
248
+ if (parsed.thumbprint !== jwkThumbprint(parsed.publicJwk))
249
+ return null;
250
+ return { blob: parsed.blob, publicJwk: parsed.publicJwk };
251
+ }
252
+ catch {
253
+ return null;
254
+ }
255
+ }
256
+ // ---------------------------------------------------------------------------
257
+ // The key
258
+ // ---------------------------------------------------------------------------
259
+ /** A handle whose `sign` asks the helper, so no scalar is ever in this process. */
260
+ export function hardwareDeviceKeyHandle(record) {
261
+ const publicRaw = Buffer.concat([
262
+ Buffer.from(record.publicJwk.x, 'base64url'),
263
+ Buffer.from(record.publicJwk.y, 'base64url'),
264
+ ]);
265
+ return {
266
+ alg: 'ES256',
267
+ custody: 'hardware',
268
+ publicJwk: record.publicJwk,
269
+ thumbprint: jwkThumbprint(record.publicJwk),
270
+ sign: async (signingInput) => {
271
+ const res = await runHelper('sign', { blob: record.blob, input: Buffer.from(signingInput).toString('base64') }, SIGN_TIMEOUT_MS);
272
+ const signature = decodeFixed(res.signature, 64, 'signature');
273
+ const reported = decodeFixed(res.publicKey, 64, 'publicKey');
274
+ if (!reported.equals(publicRaw)) {
275
+ // The blob unwrapped to a different key than the record names. Signing
276
+ // with it would produce a proof the server pins nothing to.
277
+ throw new HardwareDeviceKeyError('key_mismatch', 'the stored hardware key is not the key this record names');
278
+ }
279
+ return new Uint8Array(signature);
280
+ },
281
+ };
282
+ }
283
+ /**
284
+ * Create a hardware key and PROVE it before anyone relies on it: sign random
285
+ * bytes and verify them with Node's own crypto against the public key the helper
286
+ * reported. A helper that returns a mis-encoded signature, or a public key that
287
+ * is not the signer's, fails here with nothing yet persisted and nothing yet sent
288
+ * to the server, instead of as a rejected login that leaves a pin half-moved.
289
+ *
290
+ * The returned record is NOT persisted; the caller decides when it becomes the
291
+ * device's identity (see `commitDeviceKeyCandidate`). Dropping it destroys the
292
+ * key: the Secure Enclave keeps nothing the blob does not.
293
+ */
294
+ export async function createHardwareDeviceKey() {
295
+ const res = await runHelper('create', {}, CREATE_TIMEOUT_MS);
296
+ if (typeof res.blob !== 'string' || res.blob.length === 0) {
297
+ throw new HardwareDeviceKeyError('bad_response', 'create returned no key');
298
+ }
299
+ const publicJwk = p256PublicJwkFromRaw(decodeFixed(res.publicKey, 64, 'publicKey'));
300
+ if (!isDevicePublicJwk(publicJwk))
301
+ throw new HardwareDeviceKeyError('bad_response', 'create returned an invalid key');
302
+ const record = { blob: res.blob, publicJwk };
303
+ const handle = hardwareDeviceKeyHandle(record);
304
+ const challenge = crypto.randomBytes(32);
305
+ const signature = await handle.sign(challenge);
306
+ const verified = crypto.verify('sha256', challenge, {
307
+ key: crypto.createPublicKey({
308
+ format: 'jwk',
309
+ key: publicJwk,
310
+ }),
311
+ dsaEncoding: 'ieee-p1363',
312
+ }, Buffer.from(signature));
313
+ if (!verified) {
314
+ throw new HardwareDeviceKeyError('key_mismatch', 'a signature from the new hardware key did not verify');
315
+ }
316
+ return { record, handle };
317
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * deviceKeyServerSupport.ts — which device-key algorithms does THIS server pin?
3
+ *
4
+ * A hardware-bound key is ES256, and a server that predates ES256 cannot pin one:
5
+ * enrolling it would fail, and a profile that had swapped its working key for it
6
+ * would be unable to sign in at all. So a client creates a hardware key only when
7
+ * the server has said it will accept one, and everything else (an older server,
8
+ * an unreachable one, a malformed answer, a redirect) is "unknown", which means
9
+ * "keep the key you have". The cost of being wrong in that direction is a
10
+ * software key a little longer; in the other it is a machine that cannot log in.
11
+ *
12
+ * The server says it in the RFC 8414 authorization-server metadata it already
13
+ * publishes, in the same `_values_supported` style that document uses for its own
14
+ * algorithm lists.
15
+ */
16
+ import { type DeviceKeyAlg } from './deviceKey.js';
17
+ /** Path of the metadata document (RFC 8414 §3), relative to the server's origin. */
18
+ export declare const AUTH_SERVER_METADATA_PATH = "/.well-known/oauth-authorization-server";
19
+ /** The metadata field listing the device-key algorithms the server will pin. */
20
+ export declare const SERVER_DEVICE_KEY_ALGS_FIELD = "device_key_signing_alg_values_supported";
21
+ /** @internal Test seam. */
22
+ export declare function __resetServerDeviceKeyAlgsForTest(): void;
23
+ /**
24
+ * The device-key algorithms the server at `serverUrl` accepts, or null when that
25
+ * is not known. Never throws and never sends a credential: it is a plain GET of a
26
+ * public document.
27
+ */
28
+ export declare function readServerDeviceKeyAlgs(serverUrl: string, opts?: {
29
+ fetchImpl?: typeof fetch;
30
+ timeoutMs?: number;
31
+ }): Promise<readonly DeviceKeyAlg[] | null>;
@@ -0,0 +1,86 @@
1
+ /**
2
+ * deviceKeyServerSupport.ts — which device-key algorithms does THIS server pin?
3
+ *
4
+ * A hardware-bound key is ES256, and a server that predates ES256 cannot pin one:
5
+ * enrolling it would fail, and a profile that had swapped its working key for it
6
+ * would be unable to sign in at all. So a client creates a hardware key only when
7
+ * the server has said it will accept one, and everything else (an older server,
8
+ * an unreachable one, a malformed answer, a redirect) is "unknown", which means
9
+ * "keep the key you have". The cost of being wrong in that direction is a
10
+ * software key a little longer; in the other it is a machine that cannot log in.
11
+ *
12
+ * The server says it in the RFC 8414 authorization-server metadata it already
13
+ * publishes, in the same `_values_supported` style that document uses for its own
14
+ * algorithm lists.
15
+ */
16
+ import { DEVICE_KEY_ALGS } from './deviceKey.js';
17
+ /** Path of the metadata document (RFC 8414 §3), relative to the server's origin. */
18
+ export const AUTH_SERVER_METADATA_PATH = '/.well-known/oauth-authorization-server';
19
+ /** The metadata field listing the device-key algorithms the server will pin. */
20
+ export const SERVER_DEVICE_KEY_ALGS_FIELD = 'device_key_signing_alg_values_supported';
21
+ const TIMEOUT_MS = 5_000;
22
+ const MAX_BYTES = 64 * 1024;
23
+ /** A positive answer is kept for the process; an unknown one is looked at again after this. */
24
+ const UNKNOWN_RETRY_MS = 60_000;
25
+ const cache = new Map();
26
+ /** @internal Test seam. */
27
+ export function __resetServerDeviceKeyAlgsForTest() {
28
+ cache.clear();
29
+ }
30
+ /**
31
+ * The device-key algorithms the server at `serverUrl` accepts, or null when that
32
+ * is not known. Never throws and never sends a credential: it is a plain GET of a
33
+ * public document.
34
+ */
35
+ export async function readServerDeviceKeyAlgs(serverUrl, opts = {}) {
36
+ let origin;
37
+ try {
38
+ origin = new URL(serverUrl).origin;
39
+ }
40
+ catch {
41
+ return null;
42
+ }
43
+ if (origin === 'null')
44
+ return null;
45
+ const hit = cache.get(origin);
46
+ if (hit && (hit.algs !== null || Date.now() - hit.at < UNKNOWN_RETRY_MS))
47
+ return hit.algs;
48
+ const algs = await fetchAlgs(origin, opts);
49
+ cache.set(origin, { algs, at: Date.now() });
50
+ return algs;
51
+ }
52
+ async function fetchAlgs(origin, opts) {
53
+ const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
54
+ if (typeof fetchImpl !== 'function')
55
+ return null;
56
+ const controller = new AbortController();
57
+ const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? TIMEOUT_MS);
58
+ try {
59
+ const res = await fetchImpl(`${origin}${AUTH_SERVER_METADATA_PATH}`, {
60
+ method: 'GET',
61
+ headers: { Accept: 'application/json' },
62
+ // The metadata lives at the issuer. A redirect is a server we did not ask.
63
+ redirect: 'manual',
64
+ signal: controller.signal,
65
+ });
66
+ if (res.status !== 200)
67
+ return null;
68
+ const text = await res.text();
69
+ if (text.length > MAX_BYTES)
70
+ return null;
71
+ const body = JSON.parse(text);
72
+ const listed = body[SERVER_DEVICE_KEY_ALGS_FIELD];
73
+ if (!Array.isArray(listed))
74
+ return null;
75
+ // Only algorithms this build can also sign and verify; an unknown name is not
76
+ // evidence that the server accepts one we know.
77
+ const known = DEVICE_KEY_ALGS.filter((alg) => listed.includes(alg));
78
+ return known.length > 0 ? known : null;
79
+ }
80
+ catch {
81
+ return null;
82
+ }
83
+ finally {
84
+ clearTimeout(timer);
85
+ }
86
+ }
@@ -116,6 +116,10 @@ export function verifyDpopProof(jws, opts) {
116
116
  // rth/pin are device-proof-only checks the options above never request.
117
117
  return { valid: false, reason: result.reason };
118
118
  }
119
+ // The shared verifier was told EdDSA only, and EdDSA means an OKP key; this is
120
+ // the type narrowing for that guarantee, and a refusal if it is ever wrong.
121
+ if (result.publicJwk.kty !== 'OKP')
122
+ return { valid: false, reason: 'bad_jwk' };
119
123
  // An empty jti cannot be claimed in a replay cache, so it is not a nonce.
120
124
  if (result.claims.jti.length === 0)
121
125
  return { valid: false, reason: 'malformed' };
@@ -1,6 +1,7 @@
1
1
  import crypto from 'node:crypto';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
+ import { assertMayMutateLiveState } from './liveState.js';
4
5
  /** The desktop reserves a new profile for every background authorization. */
5
6
  export const INDEPENDENT_MACHINE_PROFILE_RE = /^desktop-bg-[a-f0-9]{32}$/;
6
7
  export const INDEPENDENT_MACHINE_RESERVATION_FILE = 'independent-machine-attempt.json';
@@ -42,6 +43,9 @@ export function reserveIndependentMachineProfile(options) {
42
43
  throw new Error('Background authorization secure storage is unavailable');
43
44
  }
44
45
  const profiles = path.join(options.root, 'profiles');
46
+ // Everything below creates directories and an O_EXCL file under `options.root`; the shared
47
+ // writers have no exclusive create, so the refusal is asked once, for the whole tree.
48
+ assertMayMutateLiveState(profiles, 'reserve a background machine profile');
45
49
  const rootExisted = fs.existsSync(options.root);
46
50
  const profilesExisted = fs.existsSync(profiles);
47
51
  fs.mkdirSync(profiles, { recursive: true, mode: 0o700 });
@@ -25,11 +25,15 @@ export { loginWithLocalhost, LoginInitError, LoginExchangeError, type BrowserOpe
25
25
  export { parseTtlDays, MIN_TTL_DAYS, MAX_TTL_DAYS, type ParseTtlResult, type ParseTtlSuccess, type ParseTtlError, } from './ttlParser.js';
26
26
  export { formatRecoveryCode, parseRecoveryCode, normalizeRecoveryCode, isValidRecoveryCode, } from './recoveryCode.js';
27
27
  export { buildCredentialAad, decrypt as credEnvelopeDecrypt, deserialize as credEnvelopeDeserialize, encrypt as credEnvelopeEncrypt, ENVELOPE_MAGIC as CRED_ENVELOPE_MAGIC, generateDek, isEnvelopeString as isCredEnvelopeString, KEY_LEN as CRED_KEY_LEN, openBuffer as credOpenBuffer, sealBuffer as credSealBuffer, serialize as credEnvelopeSerialize, unwrap as credEnvelopeUnwrap, wrap as credEnvelopeWrap, type SealedBlob as CredSealedBlob, } from './credentialEnvelope.js';
28
- export { __setKekStrategyForTest, getKekStrategy, InMemoryKek, UnavailableKek, KekUnavailableError, isMasterKekKeychainService, LinuxKek, MacosKek, MASTER_KEK_KEYCHAIN_ACCOUNT, MASTER_KEK_KEYCHAIN_SERVICE, registerZeroizeHook, WindowsKek, zeroizeKekCaches, type KekStrategy, } from './kek/index.js';
29
- export { buildProof as buildDeviceProof, generateDeviceKeyPair, jwkThumbprint, normalizeHtu, publicJwkFromX, tokenHash, verifyProof as verifyDeviceProof, type DeviceKeyPair, type DeviceProofClaims, type DeviceProofHeader, type Ed25519PrivateJwk, type Ed25519PublicJwk, type VerifyFailureReason as DeviceProofVerifyFailureReason, type VerifyFailure as DeviceProofVerifyFailure, type VerifyOptions as DeviceProofVerifyOptions, type VerifySuccess as DeviceProofVerifySuccess, } from './deviceKey.js';
28
+ export { __setKekStrategyForTest, getKekStrategy, InMemoryKek, UnavailableKek, KekUnavailableError, classifyDaemonKeychainService, isMasterKekKeychainService, isSessionKeychainService, isUserOwnedSecretKeychainService, LinuxKek, MacosKek, MASTER_KEK_KEYCHAIN_ACCOUNT, MASTER_KEK_KEYCHAIN_SERVICE, registerZeroizeHook, WindowsKek, zeroizeKekCaches, type DaemonKeychainServiceClass, type KekStrategy, } from './kek/index.js';
29
+ export { ALLOW_LIVE_STATE_ENV, __setAccountHomeForTest, DAEMON_BYOK_KEYCHAIN_SERVICE_BASE, DAEMON_KEYCHAIN_SERVICE_BASE, HERMETIC_HOME_ENV, LEGACY_DAEMON_KEYCHAIN_SERVICE, LIVE_DEFAULT_PROFILE, LIVE_STATE_REFUSED_CODE, LiveStateRefusedError, accountHomeDir, assertMayMutateLiveKeychain, assertMayMutateLiveState, isLiveKeychainService, isLiveStateRefusal, isTestProcess, keychainSuffixForDir, liveKeychainServices, liveStateRoot, refusesLiveKeychainMutation, refusesLiveStateInTest, rethrowIfLiveStateRefusal, testProcessSignal, type LiveKeychainProbe, type LiveStateProbe, } from './liveState.js';
30
+ export { algForJwk, buildProof as buildDeviceProof, DEVICE_KEY_ALGS, generateDeviceKeyPair, isDevicePublicJwk, jwkThumbprint, normalizeHtu, p256PublicJwkFromRaw, proofSigningInput, publicJwkFromX, signProofWith, tokenHash, verifyProof as verifyDeviceProof, type DeviceKeyAlg, type DeviceKeyPair, type DeviceKeySigner, type DevicePublicJwk, type DeviceProofClaims, type DeviceProofHeader, type Ed25519PrivateJwk, type Ed25519PublicJwk, type P256PublicJwk, type VerifyFailureReason as DeviceProofVerifyFailureReason, type VerifyFailure as DeviceProofVerifyFailure, type VerifyOptions as DeviceProofVerifyOptions, type VerifySuccess as DeviceProofVerifySuccess, } from './deviceKey.js';
31
+ export { softwareEd25519Handle, type DeviceKeyCustody, type DeviceKeyHandle } from './deviceKeyHandle.js';
32
+ export { AUTH_SERVER_METADATA_PATH, readServerDeviceKeyAlgs, SERVER_DEVICE_KEY_ALGS_FIELD, } from './deviceKeyServerSupport.js';
33
+ export { configureHardwareDeviceKeyHelper, HARDWARE_KEY_HELPER_NAME, HardwareDeviceKeyError, probeHardwareDeviceKey, resolveHardwareDeviceKeyHelper, type HardwareDeviceKeyErrorCode, type HardwareKeyCapability, } from './deviceKeyHardware.js';
30
34
  export { DPOP_HEADER, DPOP_MAX_AGE_SECONDS, DPOP_PROOF_INVALID, DPOP_PROOF_REQUIRED, DPOP_PROOF_TYP, DPOP_PROOF_VERIFY_UNAVAILABLE, accessTokenHash, createDpopKey, isAth, isJwkThumbprint, normalizeDpopHtu, verifyDpopProof, type DpopKey, type DpopProofClaims, type DpopProofRequest, type DpopVerifyFailureReason, type DpopVerifyOptions, type DpopVerifyResult, } from './dpop.js';
31
35
  export { initCredEnvelope, initExistingCredEnvelope, initNewIndependentCredEnvelope, resetCredEnvelope, shutdownCredEnvelope, isCredEnvelopeActive, hasCredEnvelopePathConflict, describeCredEnvelopeState, splitKeyConvergencePolicy, maybeEncryptForWrite, maybeDecryptOnRead, DEFAULT_UNREADABLE_CREDENTIAL_HINT, type DecryptOnReadOptions, __wrappedDekFilePathForTest, __setStateForTest as __setCredEnvelopeStateForTest, __getStateForTest as __getCredEnvelopeStateForTest, __resetShutdownGuardForTest, type ReadTransformResult, } from './credentialEnvelopeBridge.js';
32
- export { initDeviceIdentity, resetDeviceIdentity, signServerRequest, enrollWithServer, clearEnrollmentMarker, isDeviceIdentityActive, getDevicePublicKey, describeDeviceIdentityState, describeDeviceKeySplit, __publicKeyPathForTest, __privateKeyPathForTest, __enrollmentMarkerPathForTest, __enrollmentBackoffPathForTest, __setUnreadableReloadIntervalForTest, __setStateForTest as __setDeviceIdentityStateForTest, __getStateForTest as __getDeviceIdentityStateForTest, type EnrollmentResult, } from './deviceIdentityBridge.js';
36
+ export { initDeviceIdentity, resetDeviceIdentity, signServerRequest, prepareHardwareKeyCandidate, commitDeviceKeyCandidate, signWithCandidate, persistPendingHardwareKey, commitPendingHardwareKey, discardPendingHardwareKey, PENDING_HARDWARE_KEY_MAX_AGE_MS, type DeviceKeyCandidate, type DeviceKeyCandidateResult, type PendingHardwareKeyOutcome, enrollWithServer, clearEnrollmentMarker, isDeviceIdentityActive, getDevicePublicKey, describeDeviceIdentityState, describeDeviceKeySplit, describeStoredDeviceKey, DEVICE_KEY_UNREADABLE_CREDENTIAL_HINT, type StoredDeviceKeyDescription, __publicKeyPathForTest, __privateKeyPathForTest, __enrollmentMarkerPathForTest, __enrollmentBackoffPathForTest, __setUnreadableReloadIntervalForTest, __setStateForTest as __setDeviceIdentityStateForTest, __getStateForTest as __getDeviceIdentityStateForTest, type EnrollmentResult, } from './deviceIdentityBridge.js';
33
37
  export { getMachineUuid, getMachineUuidForProfile, describeMachineIdentity, type MachineIdentityDescription, type MachineIdentityReason, getHardwareMachineUuid, setMachineUuidProfile, getMachineUuidProfile, qualifyMachineUuid, MACHINE_IDENTITY_FILENAME, hostedRuntimeIdOverride, HOSTED_RUNTIME_MACHINE_SOURCES, __resetMachineUuidCacheForTest, } from './machineUuid.js';
34
38
  export { ensureMachineDaemonId, legacyDaemonUuids, __resetCachedDaemonIdForTest, } from './machineId.js';
35
39
  export { PAIR_AAD_PREFIX, PAIR_AAD_MAX_LEN, PAIR_BUNDLE_MAX_BASE64_LEN, PAIR_NONCE_LEN, PAIR_PUBKEY_LEN, PAIR_SECRET_LEN, PairBundleError, assemblePairBundleBase64, base64ToUint8Array as pairBase64ToUint8Array, base64UrlToUint8Array as pairBase64UrlToUint8Array, buildPairAad, buildPairUrl, generatePairKeyPair, openPairBundleBase64, parsePairBundle, parsePairPlaintext, parsePairUrl, uint8ArrayToBase64 as pairUint8ArrayToBase64, uint8ArrayToBase64Url as pairUint8ArrayToBase64Url, type AssembleBundleOptions as PairAssembleBundleOptions, type OpenBundleOptions as PairOpenBundleOptions, type PairBundle, type PairBundleErrorCode, type PairKeyPair, type PairPlaintextV1, type PairUrlFields, } from './daemonPair.js';
@@ -98,12 +98,22 @@ export { buildCredentialAad, decrypt as credEnvelopeDecrypt, deserialize as cred
98
98
  // Tests that need the unavailable branch must inject `UnavailableKek`;
99
99
  // `__setKekStrategyForTest(null)` restores the PLATFORM default, which is not
100
100
  // the same thing and differs per OS.
101
- export { __setKekStrategyForTest, getKekStrategy, InMemoryKek, UnavailableKek, KekUnavailableError, isMasterKekKeychainService, LinuxKek, MacosKek, MASTER_KEK_KEYCHAIN_ACCOUNT, MASTER_KEK_KEYCHAIN_SERVICE, registerZeroizeHook, WindowsKek, zeroizeKekCaches, } from './kek/index.js';
101
+ export { __setKekStrategyForTest, getKekStrategy, InMemoryKek, UnavailableKek, KekUnavailableError, classifyDaemonKeychainService, isMasterKekKeychainService, isSessionKeychainService, isUserOwnedSecretKeychainService, LinuxKek, MacosKek, MASTER_KEK_KEYCHAIN_ACCOUNT, MASTER_KEK_KEYCHAIN_SERVICE, registerZeroizeHook, WindowsKek, zeroizeKekCaches, } from './kek/index.js';
102
+ // A test process does not mutate the state of the product installed on the machine running it.
103
+ // The refusal lives at auth-core's write boundary (`stateFiles.ts`) so it covers every runner at
104
+ // once; daemon, cli and desktop import the same definitions of "test process" and "real home"
105
+ // from here (or from the `live-state` subpath) instead of keeping their own.
106
+ export { ALLOW_LIVE_STATE_ENV, __setAccountHomeForTest, DAEMON_BYOK_KEYCHAIN_SERVICE_BASE, DAEMON_KEYCHAIN_SERVICE_BASE, HERMETIC_HOME_ENV, LEGACY_DAEMON_KEYCHAIN_SERVICE, LIVE_DEFAULT_PROFILE, LIVE_STATE_REFUSED_CODE, LiveStateRefusedError, accountHomeDir, assertMayMutateLiveKeychain, assertMayMutateLiveState, isLiveKeychainService, isLiveStateRefusal, isTestProcess, keychainSuffixForDir, liveKeychainServices, liveStateRoot, refusesLiveKeychainMutation, refusesLiveStateInTest, rethrowIfLiveStateRefusal, testProcessSignal, } from './liveState.js';
102
107
  // L11 Phase 1 — Device identity (Ed25519) + DPoP-shaped proof JWS.
103
108
  // The crypto half of the two-axis identity story; daemon-side
104
109
  // persistence + signing lives in `daemon/src/deviceIdentity.ts`.
105
110
  // Server-side verification middleware is a later PR.
106
- export { buildProof as buildDeviceProof, generateDeviceKeyPair, jwkThumbprint, normalizeHtu, publicJwkFromX, tokenHash, verifyProof as verifyDeviceProof, } from './deviceKey.js';
111
+ export { algForJwk, buildProof as buildDeviceProof, DEVICE_KEY_ALGS, generateDeviceKeyPair, isDevicePublicJwk, jwkThumbprint, normalizeHtu, p256PublicJwkFromRaw, proofSigningInput, publicJwkFromX, signProofWith, tokenHash, verifyProof as verifyDeviceProof, } from './deviceKey.js';
112
+ // The handle every signing site holds instead of a private key, and the
113
+ // hardware-bound backend behind it (docs/architecture/device-key-custody-2026-10-29.md).
114
+ export { softwareEd25519Handle } from './deviceKeyHandle.js';
115
+ export { AUTH_SERVER_METADATA_PATH, readServerDeviceKeyAlgs, SERVER_DEVICE_KEY_ALGS_FIELD, } from './deviceKeyServerSupport.js';
116
+ export { configureHardwareDeviceKeyHelper, HARDWARE_KEY_HELPER_NAME, HardwareDeviceKeyError, probeHardwareDeviceKey, resolveHardwareDeviceKeyHelper, } from './deviceKeyHardware.js';
107
117
  // OSK-12020 — RFC 9449 access-token binding (`cnf.jkt` + `DPoP` proof). A
108
118
  // different protocol from the device proof above (`typ: dpop+jwt`, header
109
119
  // `DPoP`), signed by a dedicated in-memory key the daemon holds.
@@ -119,7 +129,7 @@ initCredEnvelope, initExistingCredEnvelope, initNewIndependentCredEnvelope, rese
119
129
  __wrappedDekFilePathForTest, __setStateForTest as __setCredEnvelopeStateForTest, __getStateForTest as __getCredEnvelopeStateForTest, __resetShutdownGuardForTest, } from './credentialEnvelopeBridge.js';
120
130
  export {
121
131
  // deviceIdentity bridge
122
- initDeviceIdentity, resetDeviceIdentity, signServerRequest, enrollWithServer, clearEnrollmentMarker, isDeviceIdentityActive, getDevicePublicKey, describeDeviceIdentityState, describeDeviceKeySplit,
132
+ initDeviceIdentity, resetDeviceIdentity, signServerRequest, prepareHardwareKeyCandidate, commitDeviceKeyCandidate, signWithCandidate, persistPendingHardwareKey, commitPendingHardwareKey, discardPendingHardwareKey, PENDING_HARDWARE_KEY_MAX_AGE_MS, enrollWithServer, clearEnrollmentMarker, isDeviceIdentityActive, getDevicePublicKey, describeDeviceIdentityState, describeDeviceKeySplit, describeStoredDeviceKey, DEVICE_KEY_UNREADABLE_CREDENTIAL_HINT,
123
133
  // Test seams — file path resolvers + state setters for spec files.
124
134
  __publicKeyPathForTest, __privateKeyPathForTest, __enrollmentMarkerPathForTest, __enrollmentBackoffPathForTest, __setUnreadableReloadIntervalForTest, __setStateForTest as __setDeviceIdentityStateForTest, __getStateForTest as __getDeviceIdentityStateForTest, } from './deviceIdentityBridge.js';
125
135
  // Hardware machine identity sent as `machineUuid` (IOPlatformUUID /
@@ -19,7 +19,7 @@ export { MacosKek } from './macos.js';
19
19
  export { LinuxKek } from './linux.js';
20
20
  export { WindowsKek } from './windows.js';
21
21
  export { zeroizeKekCaches, registerZeroizeHook } from './zeroize-registry.js';
22
- export { isMasterKekKeychainService, MASTER_KEK_KEYCHAIN_ACCOUNT, MASTER_KEK_KEYCHAIN_SERVICE, } from './masterKekService.js';
22
+ export { classifyDaemonKeychainService, isMasterKekKeychainService, isSessionKeychainService, isUserOwnedSecretKeychainService, MASTER_KEK_KEYCHAIN_ACCOUNT, MASTER_KEK_KEYCHAIN_SERVICE, type DaemonKeychainServiceClass, } from './masterKekService.js';
23
23
  /**
24
24
  * @internal Test-only seam — inject a synthetic KEK strategy that wraps
25
25
  * with a fixed in-memory key. Resets to platform default when called
@@ -7,7 +7,7 @@ export { MacosKek } from './macos.js';
7
7
  export { LinuxKek } from './linux.js';
8
8
  export { WindowsKek } from './windows.js';
9
9
  export { zeroizeKekCaches, registerZeroizeHook } from './zeroize-registry.js';
10
- export { isMasterKekKeychainService, MASTER_KEK_KEYCHAIN_ACCOUNT, MASTER_KEK_KEYCHAIN_SERVICE, } from './masterKekService.js';
10
+ export { classifyDaemonKeychainService, isMasterKekKeychainService, isSessionKeychainService, isUserOwnedSecretKeychainService, MASTER_KEK_KEYCHAIN_ACCOUNT, MASTER_KEK_KEYCHAIN_SERVICE, } from './masterKekService.js';
11
11
  /**
12
12
  * Strategy used when the current platform has no implementation. Always
13
13
  * unavailable; never throws on probe. Provides an honest `id` so fleet
@@ -27,6 +27,24 @@ type ReadFileSyncLike = (path: string, encoding: BufferEncoding) => string;
27
27
  * also clear the tier selection + KEK cache so the next call re-probes.
28
28
  */
29
29
  export declare function __setFsForTest(fn: ReadFileSyncLike | null): void;
30
+ /**
31
+ * Write the master KEK to the Secret Service. Uses stdin (base64-encoded)
32
+ * as required by `secret-tool store`. Idempotent — `secret-tool store`
33
+ * overwrites an existing item silently.
34
+ *
35
+ * `service` and `account` are passed in (rather than read from the
36
+ * module-level constants) so a `LinuxKek` instance constructed with an
37
+ * override stores under a unique Secret Service entry.
38
+ *
39
+ * Race note: see module-level comment above.
40
+ */
41
+ declare function writeKekToSecretService(kek: Buffer, service: string, account: string): Promise<void>;
42
+ /**
43
+ * @internal Test seam — the Secret Service write on its own, so a spec can reach the live-state
44
+ * guard without the tier probe getting in the way. A spec using it empties `PATH`, so that a
45
+ * guard which stopped firing reaches no `secret-tool` binary.
46
+ */
47
+ export declare const __writeKekToSecretServiceForTest: typeof writeKekToSecretService;
30
48
  /**
31
49
  * Zero out and drop every in-process master-KEK cache buffer (both the
32
50
  * Tier-2 by-service map and the Tier-3 single buffer).
@@ -61,6 +61,7 @@ import { openBuffer, sealBuffer, deserialize as envelopeDeserialize, serialize a
61
61
  import { KekUnavailableError } from './types.js';
62
62
  import { registerZeroizeHook } from './zeroize-registry.js';
63
63
  import { MASTER_KEK_KEYCHAIN_ACCOUNT, MASTER_KEK_KEYCHAIN_SERVICE } from './masterKekService.js';
64
+ import { assertMayMutateLiveKeychain, rethrowIfLiveStateRefusal } from '../liveState.js';
64
65
  /** Stable telemetry kind strings — one per backend tier. */
65
66
  const KIND_LIBSECRET = 'linux-libsecret-shellout';
66
67
  const KIND_HKDF = 'linux-machineid-hkdf';
@@ -351,6 +352,11 @@ async function writeKekToSecretService(kek, service, account) {
351
352
  if (kek.length !== KEY_LEN) {
352
353
  throw new KekUnavailableError(KIND_LIBSECRET, `writeKekToSecretService: expected ${KEY_LEN}-byte buffer, got ${kek.length}`);
353
354
  }
355
+ // A test that injected a fake `secret-tool` writes nothing real. One that did not is about to
356
+ // store the machine's master KEK in the Secret Service.
357
+ if (_execFileForTest === null) {
358
+ assertMayMutateLiveKeychain(service, `store the Secret Service item ${account} (a master KEK)`);
359
+ }
354
360
  const b64 = kek.toString('base64');
355
361
  try {
356
362
  await exec('secret-tool', ['store', '--label=skrr daemon master KEK', 'service', service, 'account', account], { input: b64 });
@@ -359,6 +365,12 @@ async function writeKekToSecretService(kek, service, account) {
359
365
  throw new KekUnavailableError(KIND_LIBSECRET, 'secret-tool store failed', { cause: err });
360
366
  }
361
367
  }
368
+ /**
369
+ * @internal Test seam — the Secret Service write on its own, so a spec can reach the live-state
370
+ * guard without the tier probe getting in the way. A spec using it empties `PATH`, so that a
371
+ * guard which stopped firing reaches no `secret-tool` binary.
372
+ */
373
+ export const __writeKekToSecretServiceForTest = writeKekToSecretService;
362
374
  // ---------------------------------------------------------------------------
363
375
  // In-process KEK cache + tier selection.
364
376
  // ---------------------------------------------------------------------------
@@ -597,6 +609,9 @@ export class LinuxKek {
597
609
  catch (err) {
598
610
  if (err instanceof KekUnavailableError)
599
611
  throw err;
612
+ // A refusal is not "the KEK is unavailable": wrapping it would let the credential
613
+ // envelope read a test aimed at the real Keychain as a locked one and carry on.
614
+ rethrowIfLiveStateRefusal(err);
600
615
  throw new KekUnavailableError(this.id, err?.message ?? 'shellout wrap failed', {
601
616
  cause: err,
602
617
  });
@@ -21,6 +21,28 @@ export declare function __setExecFileForTest(fn: ExecFileLike | null): void;
21
21
  export declare function __resetSecurityProbeCacheForTest(): void;
22
22
  /** @internal Test seam — clear the resolved-keychain cache. */
23
23
  export declare function __resetResolvedKeychainCacheForTest(): void;
24
+ /**
25
+ * Create a master KEK without replacing an existing item. This key is shared
26
+ * by profiles, so even a first-use race in a different process must not
27
+ * overwrite the key protecting already wrapped credentials.
28
+ *
29
+ * The `service` / `account` parameters are threaded from the calling
30
+ * `MacosKek` instance so each instance keeps its own keychain entry.
31
+ *
32
+ * Explicit-keychain target: the resolved login-keychain path is passed
33
+ * as the trailing positional argument to `add-generic-password` so
34
+ * `security(1)` cannot fall back to a "default keychain" lookup that
35
+ * triggers a system-modal `Keychain Not Found` dialog. If no keychain
36
+ * resolves, throw `KekUnavailableError` immediately rather than
37
+ * shelling out at all.
38
+ */
39
+ declare function writeKekToKeychain(kek: Buffer, service: string, account: string): Promise<void>;
40
+ /**
41
+ * @internal Test seam — the Keychain write on its own, so a spec can reach the live-state
42
+ * guard without `wrap()`'s `security` probe getting in the way. A spec using it empties `PATH`,
43
+ * so that a guard which stopped firing reaches no `security` binary.
44
+ */
45
+ export declare const __writeKekToKeychainForTest: typeof writeKekToKeychain;
24
46
  /**
25
47
  * Zero out and drop every cached master-KEK buffer.
26
48
  *