@skrr-ai/cli 0.1.123 → 0.1.124

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 (85) hide show
  1. package/dist/commands/agents/delete.d.ts +8 -0
  2. package/dist/commands/agents/delete.js +16 -0
  3. package/dist/commands/context.js +7 -0
  4. package/dist/commands/search/workspace.js +1 -1
  5. package/dist/commands/store/gallery-admissions/approve.js +1 -1
  6. package/dist/commands/store/gallery-admissions/preview.js +1 -1
  7. package/dist/commands/store/gallery-admissions/refuse.js +1 -1
  8. package/dist/commands/store/gallery-admissions/show.js +1 -1
  9. package/dist/lib/auth-core-init.d.ts +15 -2
  10. package/dist/lib/auth-core-init.js +47 -6
  11. package/dist/lib/command-hygiene.js +30 -1
  12. package/dist/lib/daemonBrokerRefusal.js +5 -1
  13. package/dist/lib/keychain.js +41 -4
  14. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/cliHandoffWire.d.ts +5 -1
  15. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/cliHandoffWire.js +1 -1
  16. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/credentialEnvelopeBridge.js +13 -28
  17. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceIdentityBridge.d.ts +133 -4
  18. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceIdentityBridge.js +387 -90
  19. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceKey.d.ts +106 -7
  20. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceKey.js +77 -19
  21. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceKeyHandle.d.ts +33 -0
  22. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceKeyHandle.js +77 -0
  23. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceKeyHardware.d.ts +68 -0
  24. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceKeyHardware.js +264 -0
  25. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceKeyServerSupport.d.ts +31 -0
  26. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceKeyServerSupport.js +90 -0
  27. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/dpop.js +2 -0
  28. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/independentMachineReservation.js +2 -0
  29. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/index.d.ts +7 -3
  30. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/index.js +102 -0
  31. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/kek/index.d.ts +1 -1
  32. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/kek/index.js +6 -0
  33. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/kek/linux.d.ts +18 -0
  34. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/kek/linux.js +8 -0
  35. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/kek/macos.d.ts +22 -0
  36. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/kek/macos.js +11 -0
  37. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/kek/masterKekService.d.ts +43 -3
  38. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/kek/masterKekService.js +33 -3
  39. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/liveState.d.ts +154 -0
  40. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/liveState.js +281 -0
  41. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/machineId.js +6 -3
  42. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/machineUuid.js +8 -5
  43. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/profileStateDir.js +7 -27
  44. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/refresh.d.ts +1 -1
  45. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/refresh.js +12 -10
  46. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/stateFiles.d.ts +31 -0
  47. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/stateFiles.js +89 -0
  48. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/cliHandoffWire.d.ts +5 -1
  49. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/cliHandoffWire.js +4 -1
  50. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/credentialEnvelopeBridge.js +15 -37
  51. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceIdentityBridge.d.ts +133 -4
  52. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceIdentityBridge.js +536 -101
  53. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceKey.d.ts +106 -7
  54. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceKey.js +131 -27
  55. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceKeyHandle.d.ts +33 -0
  56. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceKeyHandle.js +81 -0
  57. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceKeyHardware.d.ts +68 -0
  58. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceKeyHardware.js +317 -0
  59. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceKeyServerSupport.d.ts +31 -0
  60. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceKeyServerSupport.js +86 -0
  61. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/dpop.js +4 -0
  62. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/independentMachineReservation.js +4 -0
  63. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/index.d.ts +7 -3
  64. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/index.js +13 -3
  65. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/kek/index.d.ts +1 -1
  66. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/kek/index.js +1 -1
  67. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/kek/linux.d.ts +18 -0
  68. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/kek/linux.js +15 -0
  69. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/kek/macos.d.ts +22 -0
  70. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/kek/macos.js +20 -0
  71. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/kek/masterKekService.d.ts +43 -3
  72. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/kek/masterKekService.js +104 -8
  73. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/liveState.d.ts +154 -0
  74. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/liveState.js +386 -0
  75. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/machineId.js +6 -3
  76. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/machineUuid.js +11 -5
  77. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/profileStateDir.js +6 -35
  78. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/refresh.d.ts +1 -1
  79. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/refresh.js +14 -10
  80. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/stateFiles.d.ts +31 -0
  81. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/stateFiles.js +73 -0
  82. package/dist/node_modules/@skrr-ai/auth-core/package.json +11 -1
  83. package/dist/node_modules/@skrr-ai/data-provider/index.js +25 -7
  84. package/oclif.manifest.json +1 -1
  85. package/package.json +4 -1
@@ -32,10 +32,31 @@
32
32
  import fs from 'node:fs';
33
33
  import path from 'node:path';
34
34
  import lockfile from 'proper-lockfile';
35
- import { buildProof as buildDeviceProof, generateDeviceKeyPair, jwkThumbprint, } from './deviceKey.js';
35
+ import { generateDeviceKeyPair, isDevicePublicJwk, jwkThumbprint, signProofWith, } from './deviceKey.js';
36
+ import { softwareEd25519Handle } from './deviceKeyHandle.js';
37
+ import { createHardwareDeviceKey, hardwareDeviceKeyHandle, parseHardwareKeyRecord, probeHardwareDeviceKey, resolveHardwareDeviceKeyHelper, serializeHardwareKeyRecord, HardwareDeviceKeyError, } from './deviceKeyHardware.js';
36
38
  import { hasCredEnvelopePathConflict, isCredEnvelopeActive, maybeDecryptOnRead, splitKeyConvergencePolicy, wrapIfActiveOrPassthrough, } from './credentialEnvelopeBridge.js';
37
- import { consolidateProfileState, profileStateFilePathsToClear, profileStateDir, resolveProfileStateDir, } from './profileStateDir.js';
39
+ import { consolidateProfileState, profileStateFilePathsToClear, profileStateDir, resolveProfileStateDir, RETIRED_COPY_SUFFIX, } from './profileStateDir.js';
40
+ import { rethrowIfLiveStateRefusal } from './liveState.js';
38
41
  import { emitAuthTelemetry, getAuthLogger } from './runtime.js';
42
+ import { atomicWrite0600, ensureStateDir, removeStateFile } from './stateFiles.js';
43
+ /**
44
+ * The `cred_envelope.read.decrypt_failed` hint for the device PRIVATE key
45
+ * (OSK-20506).
46
+ *
47
+ * The envelope's default hint says "signing in again rebuilds it", which is true
48
+ * of a credential and false of this: `skrr login` never creates or replaces a
49
+ * device key, and only an interactive daemon sign-in does (it re-pins the new key
50
+ * on the server). `loadKeypair` hands this to `maybeDecryptOnRead` so the
51
+ * producer says WHAT failed; a presenter that already prints one device-key line
52
+ * (the CLI's `recovery_required` notice) recognises it by identity and prints
53
+ * nothing for the decrypt failure, instead of two lines with two remedies. A
54
+ * logger that prints every warning, the daemon's, shows this one as written.
55
+ *
56
+ * Compared by value by the CLI presenter: change the text and the CLI prints the
57
+ * credential line again, which `auth-core-init.device-key.spec.ts` catches.
58
+ */
59
+ export const DEVICE_KEY_UNREADABLE_CREDENTIAL_HINT = 'stored device key is unreadable on this machine — only an interactive daemon sign-in replaces it';
39
60
  const PUBLIC_KEY_FILENAME = 'device-public-key.jwk';
40
61
  const PRIVATE_KEY_FILENAME = 'device-private-key';
41
62
  const ENROLLMENT_MARKER_FILENAME = 'device-enrollment.json';
@@ -85,12 +106,36 @@ function keyFilesStamp(profile) {
85
106
  return `${part(publicKeyPath(profile))}|${part(privateKeyPath(profile))}`;
86
107
  }
87
108
  /**
88
- * If a key this process could not read has been replaced on disk since, load it. Never
89
- * creates, replaces or deletes anything: it only picks up what another process wrote
90
- * under the same lock, which is not the silent replacement the `recoverUnreadable` gate
91
- * exists to prevent.
109
+ * What this process last saw of the key files while it held an ACTIVE key. A running daemon
110
+ * signs with the key it loaded at boot; when an interactive login in another process upgrades
111
+ * the device key (software to hardware), the server re-pins the machine to the new key and the
112
+ * old one starts being refused. Without this the daemon would keep signing with it until
113
+ * someone restarted it, and every refresh in between would fail as a key mismatch.
114
+ */
115
+ let _activeSeen = null;
116
+ function trackActiveKeyFiles(profile) {
117
+ try {
118
+ _activeSeen = { profile, stamp: keyFilesStamp(profile), checkedAt: Date.now() };
119
+ }
120
+ catch {
121
+ _activeSeen = null;
122
+ }
123
+ }
124
+ /**
125
+ * Pick up a key another process has written since this one loaded its own. Two cases, one
126
+ * rule: this only ever READS what another process wrote under the same lock, and it never
127
+ * creates, replaces or deletes a key, which is what keeps it clear of the silent replacement
128
+ * the `recoverUnreadable` gate exists to prevent.
129
+ *
130
+ * - A key this process could not read, since replaced by a readable one (the desktop's
131
+ * recovery, an interactive login elsewhere).
132
+ * - An ACTIVE key, since replaced by a different one (an upgrade to hardware). A change that
133
+ * does not load is ignored and not retried until the files change again: the key in
134
+ * memory is the one this process was told is pinned, and a half-understood replacement is
135
+ * not evidence that it still is not.
92
136
  */
93
137
  function reloadIfRecovered() {
138
+ reloadIfActiveKeyChanged();
94
139
  if (_state.kind !== 'disabled' || _state.reason !== 'unreadable_key')
95
140
  return;
96
141
  const seen = _unreadableSeen;
@@ -110,23 +155,69 @@ function reloadIfRecovered() {
110
155
  if (stamp === seen.stamp)
111
156
  return;
112
157
  seen.stamp = stamp;
113
- let keypair = null;
158
+ let key = null;
114
159
  try {
115
- keypair = loadKeypair(seen.profile);
160
+ key = loadStoredKey(seen.profile);
116
161
  }
117
162
  catch {
118
- keypair = null;
163
+ key = null;
119
164
  }
120
- if (!keypair)
165
+ if (!key)
121
166
  return;
122
167
  _state = {
123
168
  kind: 'active',
124
- keypair,
169
+ key,
125
170
  profile: seen.profile,
126
- envelopeProtected: isCredEnvelopeActive(),
171
+ envelopeProtected: key.custody === 'software' && isCredEnvelopeActive(),
127
172
  };
128
173
  _unreadableSeen = null;
129
- emit('reloaded_after_recovery', true, { profile: seen.profile, thumbprint: keypair.thumbprint });
174
+ trackActiveKeyFiles(seen.profile);
175
+ emit('reloaded_after_recovery', true, {
176
+ profile: seen.profile,
177
+ thumbprint: key.thumbprint,
178
+ custody: key.custody,
179
+ });
180
+ }
181
+ function reloadIfActiveKeyChanged() {
182
+ const state = _state;
183
+ const seen = _activeSeen;
184
+ if (state.kind !== 'active' || !seen || seen.profile !== state.profile || _initializing)
185
+ return;
186
+ const now = Date.now();
187
+ if (now - seen.checkedAt < _unreadableReloadIntervalMs)
188
+ return;
189
+ seen.checkedAt = now;
190
+ let stamp;
191
+ try {
192
+ stamp = keyFilesStamp(seen.profile);
193
+ }
194
+ catch {
195
+ return;
196
+ }
197
+ if (stamp === seen.stamp)
198
+ return;
199
+ seen.stamp = stamp;
200
+ let key = null;
201
+ try {
202
+ key = loadStoredKey(seen.profile);
203
+ }
204
+ catch {
205
+ key = null;
206
+ }
207
+ if (!key || key.thumbprint === state.key.thumbprint)
208
+ return;
209
+ _state = {
210
+ kind: 'active',
211
+ key,
212
+ profile: seen.profile,
213
+ envelopeProtected: key.custody === 'software' && isCredEnvelopeActive(),
214
+ };
215
+ emit('reloaded_after_key_change', true, {
216
+ profile: seen.profile,
217
+ from: state.key.thumbprint,
218
+ thumbprint: key.thumbprint,
219
+ custody: key.custody,
220
+ });
130
221
  }
131
222
  /**
132
223
  * The CLI, daemon, and desktop's short-lived signer resolve the same canonical
@@ -141,7 +232,7 @@ function reloadIfRecovered() {
141
232
  */
142
233
  async function withDeviceIdentityLock(profile, body) {
143
234
  const dir = profileStateDir(profile);
144
- fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
235
+ ensureStateDir(dir, `take the device identity lock for profile ${profile}`);
145
236
  const release = await lockfile.lock(dir, {
146
237
  realpath: false,
147
238
  lockfilePath: path.join(dir, '.device-identity.lock'),
@@ -210,33 +301,6 @@ function enrollmentMarkerPath(profile) {
210
301
  function enrollmentBackoffPath(profile) {
211
302
  return path.join(profileConfigDir(profile), ENROLLMENT_BACKOFF_FILENAME);
212
303
  }
213
- function atomicWrite0600(target, value) {
214
- const dir = path.dirname(target);
215
- fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
216
- const tmp = `${target}.tmp.${process.pid}.${Date.now()}`;
217
- const buf = typeof value === 'string' ? Buffer.from(value, 'utf-8') : value;
218
- const fd = fs.openSync(tmp, 'w', 0o600);
219
- try {
220
- fs.writeSync(fd, buf);
221
- fs.fsyncSync(fd);
222
- }
223
- finally {
224
- fs.closeSync(fd);
225
- }
226
- fs.renameSync(tmp, target);
227
- try {
228
- const dirFd = fs.openSync(dir, 'r');
229
- try {
230
- fs.fsyncSync(dirFd);
231
- }
232
- finally {
233
- fs.closeSync(dirFd);
234
- }
235
- }
236
- catch {
237
- /* best-effort; some platforms reject dir fsync */
238
- }
239
- }
240
304
  function emit(eventName, success, metadata, opts = {}) {
241
305
  emitAuthTelemetry({
242
306
  timestamp: new Date().toISOString(),
@@ -255,17 +319,49 @@ function emit(eventName, success, metadata, opts = {}) {
255
319
  }
256
320
  }
257
321
  }
322
+ /**
323
+ * The plaintext public record, written for either kind of key. `alg` and
324
+ * `custody` are for readers that want to know what they are looking at without
325
+ * opening the private slot (status, support bundles); the loader never trusts
326
+ * them over the key itself.
327
+ */
328
+ function writePublicKeyRecord(profile, publicJwk) {
329
+ atomicWrite0600(publicKeyPath(profile), JSON.stringify({
330
+ publicJwk,
331
+ thumbprint: jwkThumbprint(publicJwk),
332
+ alg: publicJwk.kty === 'EC' ? 'ES256' : 'EdDSA',
333
+ custody: publicJwk.kty === 'EC' ? 'hardware' : 'software',
334
+ createdAt: new Date().toISOString(),
335
+ }), `write the device public key for profile ${profile}`);
336
+ }
337
+ /**
338
+ * Persist a hardware key. The private slot is written FIRST and is
339
+ * authoritative: it carries its own public key and thumbprint, so a stop
340
+ * between the two writes leaves a state `loadStoredKey` repairs (public file
341
+ * stale, private slot complete) instead of one it must call corrupt (a new
342
+ * public file beside an old private key). Writing in the other order would turn
343
+ * a crash during a key upgrade into a lost identity, which is the exact outcome
344
+ * the upgrade exists to make less likely.
345
+ *
346
+ * Deliberately NOT sealed under the credential envelope; see the header of
347
+ * `deviceKeyHardware.ts`.
348
+ */
349
+ function persistHardwareKey(profile, record) {
350
+ atomicWrite0600(privateKeyPath(profile), serializeHardwareKeyRecord(record), `write the hardware device key record for profile ${profile}`);
351
+ writePublicKeyRecord(profile, record.publicJwk);
352
+ emit('keypair_persisted', true, {
353
+ profile,
354
+ thumbprint: jwkThumbprint(record.publicJwk),
355
+ custody: 'hardware',
356
+ envelopeProtected: false,
357
+ });
358
+ }
258
359
  /**
259
360
  * Persist a freshly-generated keypair. Public goes plaintext; private
260
361
  * goes through `maybeEncryptForWrite` so L12 wraps it when active.
261
362
  */
262
363
  function persistKeypair(profile, kp) {
263
- const publicPayload = JSON.stringify({
264
- publicJwk: kp.publicJwk,
265
- thumbprint: kp.thumbprint,
266
- createdAt: new Date().toISOString(),
267
- });
268
- atomicWrite0600(publicKeyPath(profile), publicPayload);
364
+ writePublicKeyRecord(profile, kp.publicJwk);
269
365
  // Use the always-fail-open variant — device-identity wants the private
270
366
  // key persisted even when the L12 envelope path is inactive (today's
271
367
  // default on every platform). The daemon's `credEnvelopeFailClosed`
@@ -273,7 +369,7 @@ function persistKeypair(profile, kp) {
273
369
  const privatePlaintext = JSON.stringify(kp.privateJwk);
274
370
  const wireForm = wrapIfActiveOrPassthrough(privatePlaintext);
275
371
  const envelopeProtected = wireForm !== privatePlaintext;
276
- atomicWrite0600(privateKeyPath(profile), wireForm);
372
+ atomicWrite0600(privateKeyPath(profile), wireForm, `write the device private key for profile ${profile}`);
277
373
  emit('keypair_persisted', true, {
278
374
  profile,
279
375
  thumbprint: kp.thumbprint,
@@ -326,11 +422,17 @@ function readIndependentIdentityFile(file, sealed) {
326
422
  }
327
423
  }
328
424
  /**
329
- * Load a previously-persisted keypair. Returns null on missing files,
330
- * decrypt failure, public/private mismatch (file-swap defense), or
425
+ * Load a previously-persisted device key as a handle. Returns null on missing
426
+ * files, decrypt failure, public/private mismatch (file-swap defense), or
331
427
  * malformed JSON. Throws only on unexpected fs errors; init catches.
428
+ *
429
+ * The private-key slot holds one of two things, told apart by what they are
430
+ * rather than by a flag anyone could forget to set: a hardware record (plain
431
+ * JSON that names its own algorithm and custody), or a sealed software key.
432
+ * A build that predates hardware keys reads a hardware record as an inconsistent
433
+ * keypair, so it fails closed and never replaces a key it does not understand.
332
434
  */
333
- function loadKeypair(profile) {
435
+ function loadStoredKey(profile) {
334
436
  const pubPath = publicKeyPath(profile);
335
437
  const privPath = privateKeyPath(profile);
336
438
  const independent = /^desktop-bg-[a-f0-9]{32}$/i.test(profile);
@@ -344,6 +446,8 @@ function loadKeypair(profile) {
344
446
  let storedThumbprint;
345
447
  try {
346
448
  const parsed = JSON.parse(pubRaw);
449
+ if (!isDevicePublicJwk(parsed.publicJwk))
450
+ throw new Error('public key is not a device key');
347
451
  publicJwk = parsed.publicJwk;
348
452
  storedThumbprint = parsed.thumbprint;
349
453
  }
@@ -354,7 +458,32 @@ function loadKeypair(profile) {
354
458
  });
355
459
  return null;
356
460
  }
357
- const decryptResult = maybeDecryptOnRead(privRaw);
461
+ // A reserved independent profile is software-only: its key has a server-pinned
462
+ // owner and is read through the strict sealed-file reader above.
463
+ const hardware = independent ? null : parseHardwareKeyRecord(privRaw);
464
+ if (hardware) {
465
+ // The private slot is authoritative for a hardware key: the record carries
466
+ // its own public key and proves its own thumbprint. A public file that
467
+ // disagrees is a commit that stopped between its two writes (see
468
+ // `persistHardwareKey`), not a swapped file, so repair it from the record.
469
+ if (jwkThumbprint(hardware.publicJwk) !== jwkThumbprint(publicJwk)) {
470
+ try {
471
+ writePublicKeyRecord(profile, hardware.publicJwk);
472
+ emit('public_key_repaired_from_hardware_record', true, { profile }, { logLevel: 'info' });
473
+ }
474
+ catch (err) {
475
+ emit('public_key_unreadable', false, { profile, message: err?.message ?? 'unknown' });
476
+ return null;
477
+ }
478
+ }
479
+ return hardwareDeviceKeyHandle(hardware);
480
+ }
481
+ // The device key's own hint, so a failure here is labelled as the DEVICE key and
482
+ // the CLI prints one device-key line instead of the generic credential one
483
+ // ("run `skrr login`", which cannot repair a device key).
484
+ const decryptResult = maybeDecryptOnRead(privRaw, {
485
+ unreadableHint: DEVICE_KEY_UNREADABLE_CREDENTIAL_HINT,
486
+ });
358
487
  if (decryptResult.plaintext === null) {
359
488
  emit('private_key_decrypt_failed', false, { profile });
360
489
  return null;
@@ -372,8 +501,10 @@ function loadKeypair(profile) {
372
501
  return null;
373
502
  }
374
503
  // public.x must match private.x — any mismatch indicates a partial
375
- // write race, manual edit, or attacker swapping one of the files.
376
- if (publicJwk.x !== privateJwk.x) {
504
+ // write race, manual edit, or attacker swapping one of the files. (A software
505
+ // key is Ed25519 and so has no `y`; an EC public key beside an Ed25519
506
+ // private one fails the `kty` test below for the same reason.)
507
+ if (publicJwk.kty !== 'OKP' || publicJwk.x !== privateJwk.x) {
377
508
  emit('keypair_inconsistent', false, { profile });
378
509
  return null;
379
510
  }
@@ -387,11 +518,16 @@ function loadKeypair(profile) {
387
518
  recomputed,
388
519
  });
389
520
  }
390
- return {
391
- publicJwk,
392
- privateJwk,
393
- thumbprint: recomputed,
394
- };
521
+ try {
522
+ return softwareEd25519Handle({ publicJwk, privateJwk, thumbprint: recomputed });
523
+ }
524
+ catch (err) {
525
+ emit('private_key_malformed', false, {
526
+ profile,
527
+ message: err?.message ?? 'unknown',
528
+ });
529
+ return null;
530
+ }
395
531
  }
396
532
  /**
397
533
  * Identity of a device-identity directory: the thumbprint of the PLAINTEXT
@@ -405,7 +541,7 @@ function loadKeypair(profile) {
405
541
  function deviceIdentityOf(dir) {
406
542
  try {
407
543
  const parsed = JSON.parse(fs.readFileSync(path.join(dir, PUBLIC_KEY_FILENAME), 'utf-8'));
408
- if (!parsed.publicJwk || typeof parsed.publicJwk.x !== 'string')
544
+ if (!isDevicePublicJwk(parsed.publicJwk))
409
545
  return null;
410
546
  return jwkThumbprint(parsed.publicJwk);
411
547
  }
@@ -473,6 +609,7 @@ async function consolidateDeviceIdentity(profile) {
473
609
  });
474
610
  }
475
611
  catch (err) {
612
+ rethrowIfLiveStateRefusal(err);
476
613
  emit('path_consolidation.failed', false, {
477
614
  profile,
478
615
  message: err?.message ?? 'unknown',
@@ -515,6 +652,7 @@ export async function initDeviceIdentity(profile, options = {}) {
515
652
  if (_state.kind === 'uninit')
516
653
  await initDeviceIdentityLocked(profile, options);
517
654
  }).catch((err) => {
655
+ rethrowIfLiveStateRefusal(err);
518
656
  _state = { kind: 'disabled', reason: 'init_failed' };
519
657
  emit('init_failed', false, {
520
658
  profile,
@@ -539,23 +677,25 @@ async function initDeviceIdentityLocked(profile, options = {}) {
539
677
  const independent = /^desktop-bg-[a-f0-9]{32}$/i.test(profile);
540
678
  if (!independent)
541
679
  await consolidateDeviceIdentity(profile);
542
- let keypair = null;
680
+ let stored = null;
543
681
  try {
544
- keypair = loadKeypair(profile);
682
+ stored = loadStoredKey(profile);
545
683
  }
546
684
  catch (err) {
547
685
  emit('load_failed', false, {
548
686
  profile,
549
687
  message: err?.message ?? 'unknown',
550
688
  });
551
- keypair = null;
689
+ stored = null;
552
690
  }
553
- if (keypair) {
554
- const envelopeProtected = isCredEnvelopeActive();
555
- _state = { kind: 'active', keypair, profile, envelopeProtected };
691
+ if (stored) {
692
+ const envelopeProtected = stored.custody === 'software' && isCredEnvelopeActive();
693
+ _state = { kind: 'active', key: stored, profile, envelopeProtected };
694
+ trackActiveKeyFiles(profile);
556
695
  emit('active', true, {
557
696
  profile,
558
- thumbprint: keypair.thumbprint,
697
+ thumbprint: stored.thumbprint,
698
+ custody: stored.custody,
559
699
  envelopeProtected,
560
700
  regenerated: false,
561
701
  });
@@ -599,6 +739,7 @@ async function initDeviceIdentityLocked(profile, options = {}) {
599
739
  persistResult = persistKeypair(profile, fresh);
600
740
  }
601
741
  catch (err) {
742
+ rethrowIfLiveStateRefusal(err);
602
743
  _state = { kind: 'disabled', reason: 'persist_failed' };
603
744
  emit('persist_failed', false, {
604
745
  profile,
@@ -606,15 +747,30 @@ async function initDeviceIdentityLocked(profile, options = {}) {
606
747
  });
607
748
  return;
608
749
  }
750
+ let freshKey;
751
+ try {
752
+ freshKey = softwareEd25519Handle(fresh);
753
+ }
754
+ catch (err) {
755
+ _state = { kind: 'disabled', reason: 'init_failed' };
756
+ emit('init_failed', false, {
757
+ profile,
758
+ reason: 'keygen_threw',
759
+ message: err?.message ?? 'unknown',
760
+ });
761
+ return;
762
+ }
609
763
  _state = {
610
764
  kind: 'active',
611
- keypair: fresh,
765
+ key: freshKey,
612
766
  profile,
613
767
  envelopeProtected: persistResult.envelopeProtected,
614
768
  };
769
+ trackActiveKeyFiles(profile);
615
770
  emit('active', true, {
616
771
  profile,
617
772
  thumbprint: fresh.thumbprint,
773
+ custody: 'software',
618
774
  envelopeProtected: persistResult.envelopeProtected,
619
775
  regenerated: true,
620
776
  });
@@ -631,13 +787,22 @@ export async function resetDeviceIdentity(opts) {
631
787
  // Every location, not just the resolved one. A reset that cleared only the
632
788
  // canonical copy would leave the pre-consolidation copy for the next init to
633
789
  // adopt — a deleted device key that reappears.
634
- const targets = [PUBLIC_KEY_FILENAME, PRIVATE_KEY_FILENAME].flatMap((name) => profileStateFilePathsToClear(opts.profile, name));
790
+ //
791
+ // The retired copy an upgrade keeps is part of the identity: a logout that left
792
+ // it would leave a sealed device key behind the person asked to be rid of.
793
+ const targets = [
794
+ ...[PUBLIC_KEY_FILENAME, PRIVATE_KEY_FILENAME].flatMap((name) => profileStateFilePathsToClear(opts.profile, name)),
795
+ ...retiredKeyCopies(opts.profile, PUBLIC_KEY_FILENAME),
796
+ ...retiredKeyCopies(opts.profile, PRIVATE_KEY_FILENAME),
797
+ pendingHardwareKeyPath(opts.profile),
798
+ ];
635
799
  await withDeviceIdentityLock(opts.profile, async () => {
636
800
  for (const p of targets) {
637
801
  try {
638
- fs.unlinkSync(p);
802
+ removeStateFile(p, `delete the device key file ${path.basename(p)}`);
639
803
  }
640
804
  catch (err) {
805
+ rethrowIfLiveStateRefusal(err);
641
806
  if (err.code !== 'ENOENT') {
642
807
  emit('clear_failed', false, {
643
808
  profile: opts.profile,
@@ -651,6 +816,212 @@ export async function resetDeviceIdentity(opts) {
651
816
  }
652
817
  _state = { kind: 'uninit' };
653
818
  _unreadableSeen = null;
819
+ _activeSeen = null;
820
+ }
821
+ /**
822
+ * Create a hardware key candidate if this machine can hold one and does not
823
+ * already. Never throws and never touches disk, so an interactive sign-in can
824
+ * call it unconditionally and fall back to the key it has on any answer but ok.
825
+ */
826
+ export async function prepareHardwareKeyCandidate(profile) {
827
+ if (/^desktop-bg-[a-f0-9]{32}$/i.test(profile))
828
+ return { ok: false, reason: 'independent_profile' };
829
+ if (_state.kind === 'active' && _state.key.custody === 'hardware') {
830
+ return { ok: false, reason: 'already_hardware' };
831
+ }
832
+ const capability = await probeHardwareDeviceKey();
833
+ if (!capability.available)
834
+ return { ok: false, reason: 'unavailable', capability };
835
+ try {
836
+ const { record, handle } = await createHardwareDeviceKey();
837
+ return { ok: true, candidate: { key: handle, record } };
838
+ }
839
+ catch (err) {
840
+ emit('hardware_key_create_failed', false, {
841
+ profile,
842
+ ...(err instanceof HardwareDeviceKeyError ? { hardwareFailure: err.code } : {}),
843
+ message: err?.message ?? 'unknown',
844
+ });
845
+ return { ok: false, reason: 'create_failed', message: err?.message ?? 'unknown' };
846
+ }
847
+ }
848
+ /** Sign a request with a candidate (the upgrade's sign-in proof). Null on failure, like `signServerRequest`. */
849
+ export async function signWithCandidate(candidate, opts) {
850
+ try {
851
+ return await signProofWith(candidate.key, opts);
852
+ }
853
+ catch (err) {
854
+ emit('sign_failed', false, {
855
+ thumbprint: candidate.key.thumbprint,
856
+ custody: 'hardware',
857
+ candidate: true,
858
+ method: opts.method,
859
+ ...(err instanceof HardwareDeviceKeyError ? { hardwareFailure: err.code } : {}),
860
+ message: err?.message ?? 'unknown',
861
+ });
862
+ return null;
863
+ }
864
+ }
865
+ /** The retired copies of a profile's keys: `<file>.retired.<stamp>`, newest last. */
866
+ function retiredKeyCopies(profile, filename) {
867
+ const dir = path.dirname(filename === PUBLIC_KEY_FILENAME ? publicKeyPath(profile) : privateKeyPath(profile));
868
+ const prefix = `${filename}${RETIRED_COPY_SUFFIX}.`;
869
+ try {
870
+ return fs
871
+ .readdirSync(dir)
872
+ .filter((name) => name.startsWith(prefix))
873
+ .sort()
874
+ .map((name) => path.join(dir, name));
875
+ }
876
+ catch {
877
+ return [];
878
+ }
879
+ }
880
+ /**
881
+ * Keep the software key an upgrade is about to replace, as a copy. The pin the
882
+ * server holds moves with the sign-in, but a bug in that move (or a server that
883
+ * accepted the sign-in and did not re-pin) would leave a daemon whose every
884
+ * refresh is refused as a key mismatch, with no way back. One retired pair is
885
+ * kept (the newest) and a logout deletes it with the rest of the profile's keys.
886
+ * Best effort: a copy that cannot be written must not block the upgrade of a
887
+ * key that is, at this point, already proven by the server.
888
+ */
889
+ function retireSoftwareKey(profile) {
890
+ try {
891
+ const priv = privateKeyPath(profile);
892
+ const raw = fs.readFileSync(priv, 'utf-8');
893
+ if (parseHardwareKeyRecord(raw))
894
+ return; // already hardware: nothing software to keep
895
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
896
+ atomicWrite0600(`${priv}${RETIRED_COPY_SUFFIX}.${stamp}`, raw, `keep the replaced software device key for profile ${profile}`);
897
+ try {
898
+ atomicWrite0600(`${publicKeyPath(profile)}${RETIRED_COPY_SUFFIX}.${stamp}`, fs.readFileSync(publicKeyPath(profile), 'utf-8'), `keep the replaced software device public key for profile ${profile}`);
899
+ }
900
+ catch (err) {
901
+ rethrowIfLiveStateRefusal(err);
902
+ /* the private copy is the one that matters; the public key derives from it */
903
+ }
904
+ for (const filename of [PRIVATE_KEY_FILENAME, PUBLIC_KEY_FILENAME]) {
905
+ for (const stale of retiredKeyCopies(profile, filename).slice(0, -1)) {
906
+ try {
907
+ removeStateFile(stale, `prune a retired device key copy for profile ${profile}`);
908
+ }
909
+ catch (err) {
910
+ rethrowIfLiveStateRefusal(err);
911
+ /* a stale copy that will not go costs a few hundred bytes */
912
+ }
913
+ }
914
+ }
915
+ }
916
+ catch (err) {
917
+ emit('retire_software_key_failed', false, { profile, message: err?.message ?? 'unknown' });
918
+ }
919
+ }
920
+ /**
921
+ * Make a candidate this profile's device identity. Call it only after the
922
+ * server has accepted a sign-in proven with `candidate`: until then the software
923
+ * key is the identity the server pins, and replacing it early is the failure
924
+ * this API exists to prevent.
925
+ *
926
+ * Under the identity lock, in the order that survives a crash: retire the old
927
+ * key, write the hardware record (authoritative), then the public file, then
928
+ * clear the enrollment marker (it records the OLD thumbprint and would otherwise
929
+ * short-circuit every later enrollment as already done).
930
+ */
931
+ export async function commitDeviceKeyCandidate(profile, candidate) {
932
+ if (_initializing)
933
+ await _initializing;
934
+ await withDeviceIdentityLock(profile, async () => {
935
+ commitRecordLocked(profile, candidate.record, candidate.key);
936
+ });
937
+ }
938
+ /** The commit itself; the caller holds the identity lock. */
939
+ function commitRecordLocked(profile, record, key) {
940
+ retireSoftwareKey(profile);
941
+ persistHardwareKey(profile, record);
942
+ clearEnrollmentMarker(profile);
943
+ removePendingHardwareKey(profile);
944
+ _state = { kind: 'active', key, profile, envelopeProtected: false };
945
+ _unreadableSeen = null;
946
+ trackActiveKeyFiles(profile);
947
+ emit('upgraded_to_hardware', true, {
948
+ profile,
949
+ thumbprint: key.thumbprint,
950
+ custody: 'hardware',
951
+ });
952
+ }
953
+ // ---------------------------------------------------------------------------
954
+ // A candidate that has to outlive its process
955
+ // ---------------------------------------------------------------------------
956
+ //
957
+ // The desktop signs the sign-in's proof in `skrrd device-proof` (one process),
958
+ // POSTs it from the app (another), and only then knows whether the server
959
+ // accepted it. The candidate therefore has to be settled by a LATER invocation,
960
+ // so it is parked in a pending file until then. That costs nothing in safety:
961
+ // the pending file holds a Secure Enclave blob, which is useless anywhere but
962
+ // this machine, and an abandoned one is never read as an identity (only the two
963
+ // anchor files are), expires on its own, and is deleted by the next attempt and
964
+ // by a logout.
965
+ const PENDING_HARDWARE_FILENAME = 'device-key.pending';
966
+ /** A sign-in that takes longer than this has been abandoned; its pending key is not committed. */
967
+ export const PENDING_HARDWARE_KEY_MAX_AGE_MS = 10 * 60 * 1000;
968
+ function pendingHardwareKeyPath(profile) {
969
+ return path.join(profileConfigDir(profile), PENDING_HARDWARE_FILENAME);
970
+ }
971
+ function removePendingHardwareKey(profile) {
972
+ try {
973
+ removeStateFile(pendingHardwareKeyPath(profile), `remove the pending hardware key for profile ${profile}`);
974
+ }
975
+ catch (err) {
976
+ rethrowIfLiveStateRefusal(err);
977
+ if (err.code !== 'ENOENT') {
978
+ emit('pending_hardware_key_clear_failed', false, { profile, message: err?.message ?? 'unknown' });
979
+ }
980
+ }
981
+ }
982
+ /** Park a candidate so a later process can commit or discard it. Replaces any earlier one. */
983
+ export async function persistPendingHardwareKey(profile, candidate) {
984
+ await withDeviceIdentityLock(profile, async () => {
985
+ atomicWrite0600(pendingHardwareKeyPath(profile), serializeHardwareKeyRecord(candidate.record), `park a pending hardware key for profile ${profile}`);
986
+ });
987
+ }
988
+ /**
989
+ * Make the parked candidate this profile's identity, if it is the one the caller
990
+ * proved the sign-in with (`thumbprint`) and it is not stale. Anything else
991
+ * commits nothing: the software key stays the identity, and the answer says why.
992
+ */
993
+ export async function commitPendingHardwareKey(profile, thumbprint) {
994
+ if (_initializing)
995
+ await _initializing;
996
+ return withDeviceIdentityLock(profile, async () => {
997
+ const file = pendingHardwareKeyPath(profile);
998
+ let raw;
999
+ let modifiedAt;
1000
+ try {
1001
+ raw = fs.readFileSync(file, 'utf-8');
1002
+ modifiedAt = fs.statSync(file).mtimeMs;
1003
+ }
1004
+ catch {
1005
+ return 'no_pending';
1006
+ }
1007
+ const record = parseHardwareKeyRecord(raw);
1008
+ if (!record)
1009
+ return 'unreadable';
1010
+ if (Date.now() - modifiedAt > PENDING_HARDWARE_KEY_MAX_AGE_MS) {
1011
+ removePendingHardwareKey(profile);
1012
+ return 'expired';
1013
+ }
1014
+ if (jwkThumbprint(record.publicJwk) !== thumbprint)
1015
+ return 'mismatch';
1016
+ commitRecordLocked(profile, record, hardwareDeviceKeyHandle(record));
1017
+ return 'committed';
1018
+ });
1019
+ }
1020
+ /** Drop the parked candidate (the sign-in failed or was refused). Idempotent. */
1021
+ export async function discardPendingHardwareKey(profile) {
1022
+ await withDeviceIdentityLock(profile, async () => {
1023
+ removePendingHardwareKey(profile);
1024
+ });
654
1025
  }
655
1026
  /** True iff init has completed and a keypair is in memory. */
656
1027
  export function isDeviceIdentityActive() {
@@ -666,7 +1037,12 @@ export function getDevicePublicKey() {
666
1037
  reloadIfRecovered();
667
1038
  if (_state.kind !== 'active')
668
1039
  return null;
669
- return { jwk: _state.keypair.publicJwk, thumbprint: _state.keypair.thumbprint };
1040
+ return {
1041
+ jwk: _state.key.publicJwk,
1042
+ thumbprint: _state.key.thumbprint,
1043
+ alg: _state.key.alg,
1044
+ custody: _state.key.custody,
1045
+ };
670
1046
  }
671
1047
  /**
672
1048
  * Operator-readable summary of device-identity state. Used by
@@ -680,28 +1056,63 @@ export function describeDeviceIdentityState() {
680
1056
  if (_state.kind === 'active') {
681
1057
  return {
682
1058
  kind: 'active',
683
- thumbprint: _state.keypair.thumbprint,
1059
+ thumbprint: _state.key.thumbprint,
684
1060
  profile: _state.profile,
685
1061
  envelopeProtected: _state.envelopeProtected,
1062
+ alg: _state.key.alg,
1063
+ custody: _state.key.custody,
1064
+ canSign: _state.key.custody === 'software' ? true : resolveHardwareDeviceKeyHelper() !== null,
686
1065
  };
687
1066
  }
688
1067
  return { kind: 'uninit' };
689
1068
  }
1069
+ /**
1070
+ * What a profile's device key IS, read from its plaintext public record alone. For
1071
+ * `status`, which is a different process from the daemon and may run while the
1072
+ * daemon is stopped: it opens nothing, needs no envelope key and writes nothing, so
1073
+ * it answers even when the private half is unreadable. Not evidence the key can be
1074
+ * used (only `describeDeviceIdentityState` in the process that loaded it says that);
1075
+ * evidence of what kind of key the profile has, which nothing else showed.
1076
+ */
1077
+ export function describeStoredDeviceKey(profile) {
1078
+ try {
1079
+ const parsed = JSON.parse(fs.readFileSync(publicKeyPath(profile), 'utf-8'));
1080
+ if (!isDevicePublicJwk(parsed.publicJwk))
1081
+ return { stored: false };
1082
+ const alg = parsed.publicJwk.kty === 'EC' ? 'ES256' : 'EdDSA';
1083
+ const custody = alg === 'ES256' ? 'hardware' : 'software';
1084
+ return {
1085
+ stored: true,
1086
+ alg,
1087
+ custody,
1088
+ thumbprint: jwkThumbprint(parsed.publicJwk),
1089
+ canSign: custody === 'software' ? true : resolveHardwareDeviceKeyHelper() !== null,
1090
+ };
1091
+ }
1092
+ catch {
1093
+ return { stored: false };
1094
+ }
1095
+ }
690
1096
  /**
691
1097
  * Sign an outgoing request with the device key. Returns the JWS Compact
692
1098
  * Serialization, or null when device identity is not active. NEVER
693
1099
  * throws — a failed sign returns null and emits a telemetry event so
694
1100
  * the caller can fall back to bearer-only auth without an outage.
695
1101
  *
1102
+ * Asynchronous because a hardware key signs in a helper process. A software key
1103
+ * resolves on the next microtask, so a caller that awaits pays nothing it can
1104
+ * measure; a caller that could not await could not have a hardware key.
1105
+ *
696
1106
  * Shape matches `DeviceProofSigner` from `./refresh.ts` so the function
697
1107
  * passes directly into `refreshDaemonToken`'s `signRequestProof` slot.
698
1108
  */
699
- export function signServerRequest(opts) {
1109
+ export async function signServerRequest(opts) {
700
1110
  reloadIfRecovered();
701
- if (_state.kind !== 'active')
1111
+ const state = _state;
1112
+ if (state.kind !== 'active')
702
1113
  return null;
703
1114
  try {
704
- return buildDeviceProof(_state.keypair.privateJwk, _state.keypair.publicJwk, {
1115
+ return await signProofWith(state.key, {
705
1116
  method: opts.method,
706
1117
  url: opts.url,
707
1118
  refreshToken: opts.refreshToken,
@@ -710,9 +1121,13 @@ export function signServerRequest(opts) {
710
1121
  }
711
1122
  catch (err) {
712
1123
  emit('sign_failed', false, {
713
- profile: _state.profile,
714
- thumbprint: _state.keypair.thumbprint,
1124
+ profile: state.profile,
1125
+ thumbprint: state.key.thumbprint,
1126
+ custody: state.key.custody,
715
1127
  method: opts.method,
1128
+ // A hardware failure names its cause (helper missing, timed out, key
1129
+ // mismatch) so a status line can say what to do about it.
1130
+ ...(err instanceof HardwareDeviceKeyError ? { hardwareFailure: err.code } : {}),
716
1131
  message: err?.message ?? 'unknown',
717
1132
  });
718
1133
  return null;
@@ -735,9 +1150,10 @@ export function __enrollmentBackoffPathForTest(profile) {
735
1150
  export function clearEnrollmentMarker(profile) {
736
1151
  for (const markerPath of [enrollmentMarkerPath(profile), enrollmentBackoffPath(profile)]) {
737
1152
  try {
738
- fs.unlinkSync(markerPath);
1153
+ removeStateFile(markerPath, `delete the enrollment file ${path.basename(markerPath)}`);
739
1154
  }
740
1155
  catch (err) {
1156
+ rethrowIfLiveStateRefusal(err);
741
1157
  if (err.code !== 'ENOENT') {
742
1158
  emit('enrollment_marker_clear_failed', false, {
743
1159
  profile,
@@ -769,7 +1185,7 @@ export async function enrollWithServer(opts) {
769
1185
  return { enrolled: false, reason: 'not_active' };
770
1186
  }
771
1187
  const profile = _state.profile;
772
- const keypair = _state.keypair;
1188
+ const key = _state.key;
773
1189
  const markerPath = enrollmentMarkerPath(profile);
774
1190
  const backoffPath = enrollmentBackoffPath(profile);
775
1191
  try {
@@ -785,33 +1201,48 @@ export async function enrollWithServer(opts) {
785
1201
  const retryAfter = Date.parse(raw.retryAfter || '');
786
1202
  if (raw.serverUrl === serverUrl &&
787
1203
  raw.machineUuid === machineUuid &&
788
- raw.thumbprint === keypair.thumbprint &&
1204
+ raw.thumbprint === key.thumbprint &&
789
1205
  Number.isFinite(retryAfter) &&
790
1206
  retryAfter > Date.now()) {
791
1207
  return { enrolled: false, reason: 'backoff_active' };
792
1208
  }
793
- fs.unlinkSync(backoffPath);
1209
+ removeStateFile(backoffPath, 'delete the stale enrollment backoff');
794
1210
  }
795
1211
  catch (err) {
796
1212
  if (err.code !== 'ENOENT') {
797
- // A corrupt/stale marker must never suppress enrollment indefinitely.
1213
+ // A corrupt/stale marker must never suppress enrollment indefinitely. A refusal from the
1214
+ // first delete above lands here too, and is asked again by this one, so this is the
1215
+ // single place it is rethrown.
798
1216
  try {
799
- fs.unlinkSync(backoffPath);
1217
+ removeStateFile(backoffPath, 'delete the corrupt enrollment backoff');
800
1218
  }
801
- catch {
1219
+ catch (clearErr) {
1220
+ rethrowIfLiveStateRefusal(clearErr);
802
1221
  /* best-effort */
803
1222
  }
804
1223
  }
805
1224
  }
806
1225
  const base = serverUrl.replace(/\/+$/, '');
807
1226
  const htu = `${base}/api/daemon-resources/enroll-device-key`;
808
- const proofJws = buildDeviceProof(keypair.privateJwk, keypair.publicJwk, {
809
- method: 'POST',
810
- url: htu,
811
- });
1227
+ let proofJws;
1228
+ try {
1229
+ proofJws = await signProofWith(key, { method: 'POST', url: htu });
1230
+ }
1231
+ catch (err) {
1232
+ // A key that cannot sign (a hardware key whose helper is missing) is not a
1233
+ // network failure and not the server's to hear about.
1234
+ const message = err?.message ?? 'unknown';
1235
+ emit('enrollment_sign_failed', false, { profile, thumbprint: key.thumbprint, custody: key.custody, message });
1236
+ return { enrolled: false, reason: 'not_active', message };
1237
+ }
1238
+ // `publicJwkX` is the original wire field and only ever described an Ed25519
1239
+ // point, so an ES256 key leaves it out rather than send a coordinate an older
1240
+ // server would read as the wrong curve. `publicJwk` is the whole key; a server
1241
+ // that understands it prefers it, and the proof's own header carries it too.
812
1242
  const body = JSON.stringify({
813
1243
  machineUuid,
814
- publicJwkX: keypair.publicJwk.x,
1244
+ ...(key.publicJwk.kty === 'OKP' ? { publicJwkX: key.publicJwk.x } : {}),
1245
+ publicJwk: key.publicJwk,
815
1246
  proofJws,
816
1247
  });
817
1248
  // 10-second bound — enrollment is best-effort at startup.
@@ -834,7 +1265,7 @@ export async function enrollWithServer(opts) {
834
1265
  const message = err?.message ?? 'unknown';
835
1266
  emit('enrollment_network_error', false, {
836
1267
  profile,
837
- thumbprint: keypair.thumbprint,
1268
+ thumbprint: key.thumbprint,
838
1269
  serverUrl,
839
1270
  message,
840
1271
  });
@@ -885,7 +1316,7 @@ export async function enrollWithServer(opts) {
885
1316
  }
886
1317
  const pinnedToOtherKey = response.status === 409 &&
887
1318
  enrolledThumbprint !== null &&
888
- enrolledThumbprint !== keypair.thumbprint;
1319
+ enrolledThumbprint !== key.thumbprint;
889
1320
  const keyPinnedElsewhere = response.status === 409 && conflictCode === 'ENROLLMENT_DUPLICATE_KEY';
890
1321
  const isBenignRejection = (response.status === 404 && responseText.includes('DAEMON_NOT_FOUND')) ||
891
1322
  (response.status === 403 && responseText.includes('OPAQUE_AUTH_REFUSED')) ||
@@ -893,7 +1324,7 @@ export async function enrollWithServer(opts) {
893
1324
  if (pinnedToOtherKey) {
894
1325
  emit('enrollment_key_mismatch', false, {
895
1326
  profile,
896
- thumbprint: keypair.thumbprint,
1327
+ thumbprint: key.thumbprint,
897
1328
  enrolledThumbprint,
898
1329
  serverUrl,
899
1330
  message: 'this machine is registered with a different device key than this profile signs ' +
@@ -906,11 +1337,12 @@ export async function enrollWithServer(opts) {
906
1337
  atomicWrite0600(backoffPath, JSON.stringify({
907
1338
  serverUrl,
908
1339
  machineUuid,
909
- thumbprint: keypair.thumbprint,
1340
+ thumbprint: key.thumbprint,
910
1341
  retryAfter: new Date(Date.now() + DAEMON_NOT_FOUND_BACKOFF_MS).toISOString(),
911
- }));
1342
+ }), 'write the enrollment backoff');
912
1343
  }
913
1344
  catch (err) {
1345
+ rethrowIfLiveStateRefusal(err);
914
1346
  emit('enrollment_backoff_write_failed', false, {
915
1347
  profile,
916
1348
  message: err?.message ?? 'unknown',
@@ -919,7 +1351,7 @@ export async function enrollWithServer(opts) {
919
1351
  }
920
1352
  emit('enrollment_rejected', false, {
921
1353
  profile,
922
- thumbprint: keypair.thumbprint,
1354
+ thumbprint: key.thumbprint,
923
1355
  serverUrl,
924
1356
  status: response.status,
925
1357
  body: responseText,
@@ -934,25 +1366,28 @@ export async function enrollWithServer(opts) {
934
1366
  const marker = JSON.stringify({
935
1367
  enrolledAt: new Date().toISOString(),
936
1368
  serverUrl,
937
- thumbprint: keypair.thumbprint,
1369
+ thumbprint: key.thumbprint,
938
1370
  machineUuid,
939
1371
  });
940
1372
  try {
941
- atomicWrite0600(markerPath, marker);
1373
+ atomicWrite0600(markerPath, marker, 'write the enrollment marker');
942
1374
  }
943
1375
  catch (err) {
1376
+ rethrowIfLiveStateRefusal(err);
944
1377
  // Non-fatal — server enrolled us; we may re-POST next run, which
945
1378
  // the server handles idempotently.
946
1379
  emit('enrollment_marker_write_failed', false, {
947
1380
  profile,
948
- thumbprint: keypair.thumbprint,
1381
+ thumbprint: key.thumbprint,
949
1382
  message: err?.message ?? 'unknown',
950
1383
  });
951
1384
  }
952
1385
  try {
953
- fs.unlinkSync(backoffPath);
1386
+ removeStateFile(backoffPath, 'delete the enrollment backoff');
954
1387
  }
955
1388
  catch (err) {
1389
+ // Not a place a refusal can arise: the marker written just above lives in the same
1390
+ // directory, and a refusal there has already ended this function.
956
1391
  if (err.code !== 'ENOENT') {
957
1392
  emit('enrollment_backoff_clear_failed', false, {
958
1393
  profile,
@@ -962,8 +1397,8 @@ export async function enrollWithServer(opts) {
962
1397
  }
963
1398
  emit('enrolled_with_server', true, {
964
1399
  profile,
965
- thumbprint: keypair.thumbprint,
1400
+ thumbprint: key.thumbprint,
966
1401
  serverUrl,
967
1402
  });
968
- return { enrolled: true, thumbprint: keypair.thumbprint };
1403
+ return { enrolled: true, thumbprint: key.thumbprint };
969
1404
  }