@skrr-ai/cli 0.1.122 → 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 (111) hide show
  1. package/dist/commands/agents/chat.d.ts +7 -0
  2. package/dist/commands/agents/chat.js +32 -6
  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/followups/act.js +3 -0
  7. package/dist/commands/followups/checkin.js +3 -0
  8. package/dist/commands/followups/remind.js +3 -0
  9. package/dist/commands/followups/show.js +1 -1
  10. package/dist/commands/followups/watch.js +3 -0
  11. package/dist/commands/search/workspace.js +1 -1
  12. package/dist/commands/store/gallery-admissions/approve.js +1 -1
  13. package/dist/commands/store/gallery-admissions/preview.js +1 -1
  14. package/dist/commands/store/gallery-admissions/refuse.js +1 -1
  15. package/dist/commands/store/gallery-admissions/show.js +1 -1
  16. package/dist/lib/agentic-stream.d.ts +46 -0
  17. package/dist/lib/agentic-stream.js +135 -14
  18. package/dist/lib/auth-core-init.d.ts +15 -2
  19. package/dist/lib/auth-core-init.js +47 -6
  20. package/dist/lib/auth-failure.js +12 -0
  21. package/dist/lib/command-hygiene.js +30 -1
  22. package/dist/lib/daemonBrokerRefusal.js +5 -1
  23. package/dist/lib/exec-runtime-binary.d.ts +9 -0
  24. package/dist/lib/exec-runtime-binary.js +15 -1
  25. package/dist/lib/followups.d.ts +13 -0
  26. package/dist/lib/followups.js +8 -0
  27. package/dist/lib/keychain.js +41 -4
  28. package/dist/lib/profile-machine-identity-guard.js +2 -1
  29. package/dist/lib/runtime-start-verification.js +6 -1
  30. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/cliHandoffWire.d.ts +5 -1
  31. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/cliHandoffWire.js +1 -1
  32. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/credentialEnvelopeBridge.js +69 -27
  33. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceIdentityBridge.d.ts +135 -4
  34. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceIdentityBridge.js +440 -83
  35. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceKey.d.ts +106 -7
  36. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceKey.js +77 -19
  37. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceKeyHandle.d.ts +33 -0
  38. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceKeyHandle.js +77 -0
  39. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceKeyHardware.d.ts +68 -0
  40. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceKeyHardware.js +264 -0
  41. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceKeyServerSupport.d.ts +31 -0
  42. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceKeyServerSupport.js +90 -0
  43. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/dpop.js +2 -0
  44. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/independentMachineReservation.js +2 -0
  45. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/index.d.ts +7 -3
  46. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/index.js +110 -0
  47. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/kek/index.d.ts +1 -0
  48. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/kek/index.js +13 -0
  49. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/kek/linux.d.ts +18 -0
  50. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/kek/linux.js +11 -2
  51. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/kek/macos.d.ts +22 -0
  52. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/kek/macos.js +14 -2
  53. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/kek/masterKekService.d.ts +69 -0
  54. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/kek/masterKekService.js +67 -0
  55. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/liveState.d.ts +154 -0
  56. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/liveState.js +281 -0
  57. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/machineId.js +6 -3
  58. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/machineUuid.js +8 -5
  59. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/messages.d.ts +1 -1
  60. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/messages.js +1 -0
  61. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/profileStateDir.js +7 -27
  62. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/refresh.d.ts +1 -1
  63. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/refresh.js +24 -16
  64. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/stateFiles.d.ts +31 -0
  65. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/stateFiles.js +89 -0
  66. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/telemetryRedaction.d.ts +3 -0
  67. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/telemetryRedaction.js +41 -3
  68. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/types.d.ts +7 -1
  69. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/cliHandoffWire.d.ts +5 -1
  70. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/cliHandoffWire.js +4 -1
  71. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/credentialEnvelopeBridge.js +122 -38
  72. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceIdentityBridge.d.ts +135 -4
  73. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceIdentityBridge.js +603 -90
  74. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceKey.d.ts +106 -7
  75. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceKey.js +131 -27
  76. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceKeyHandle.d.ts +33 -0
  77. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceKeyHandle.js +81 -0
  78. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceKeyHardware.d.ts +68 -0
  79. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceKeyHardware.js +317 -0
  80. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceKeyServerSupport.d.ts +31 -0
  81. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceKeyServerSupport.js +86 -0
  82. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/dpop.js +4 -0
  83. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/independentMachineReservation.js +4 -0
  84. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/index.d.ts +7 -3
  85. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/index.js +14 -4
  86. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/kek/index.d.ts +1 -0
  87. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/kek/index.js +1 -0
  88. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/kek/linux.d.ts +18 -0
  89. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/kek/linux.js +18 -2
  90. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/kek/macos.d.ts +22 -0
  91. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/kek/macos.js +23 -2
  92. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/kek/masterKekService.d.ts +69 -0
  93. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/kek/masterKekService.js +134 -0
  94. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/liveState.d.ts +154 -0
  95. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/liveState.js +386 -0
  96. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/machineId.js +6 -3
  97. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/machineUuid.js +11 -5
  98. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/messages.d.ts +1 -1
  99. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/messages.js +1 -0
  100. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/profileStateDir.js +6 -35
  101. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/refresh.d.ts +1 -1
  102. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/refresh.js +45 -16
  103. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/stateFiles.d.ts +31 -0
  104. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/stateFiles.js +73 -0
  105. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/telemetryRedaction.d.ts +3 -0
  106. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/telemetryRedaction.js +40 -3
  107. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/types.d.ts +7 -1
  108. package/dist/node_modules/@skrr-ai/auth-core/package.json +21 -1
  109. package/dist/node_modules/@skrr-ai/data-provider/index.js +7349 -7325
  110. package/oclif.manifest.json +11 -2
  111. 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';
@@ -58,6 +79,146 @@ const DEVICE_IDENTITY_FILES = [
58
79
  const DAEMON_NOT_FOUND_BACKOFF_MS = 15 * 60 * 1000;
59
80
  let _state = { kind: 'uninit' };
60
81
  let _initializing = null;
82
+ /**
83
+ * What this process saw when it gave up on a stored key it could not read: the profile, and
84
+ * the mtimes of the two key files. A process that is already running has no way to learn that
85
+ * someone else has since written a readable key (the desktop's recovery, an interactive
86
+ * login in another process), and used to sit disabled until it was restarted, signing
87
+ * nothing. `reloadIfRecovered` reads the key again, but only when one of those files has
88
+ * changed, so a key that stays unreadable costs one `stat` and no log line.
89
+ */
90
+ let _unreadableSeen = null;
91
+ let _unreadableReloadIntervalMs = 5_000;
92
+ /** @internal Test seam — how often an unreadable key is looked at again. `null` restores 5 s. */
93
+ export function __setUnreadableReloadIntervalForTest(ms) {
94
+ _unreadableReloadIntervalMs = ms ?? 5_000;
95
+ }
96
+ function keyFilesStamp(profile) {
97
+ const part = (file) => {
98
+ try {
99
+ const s = fs.statSync(file);
100
+ return `${s.mtimeMs}:${s.size}`;
101
+ }
102
+ catch {
103
+ return 'absent';
104
+ }
105
+ };
106
+ return `${part(publicKeyPath(profile))}|${part(privateKeyPath(profile))}`;
107
+ }
108
+ /**
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.
136
+ */
137
+ function reloadIfRecovered() {
138
+ reloadIfActiveKeyChanged();
139
+ if (_state.kind !== 'disabled' || _state.reason !== 'unreadable_key')
140
+ return;
141
+ const seen = _unreadableSeen;
142
+ if (!seen || _initializing)
143
+ return;
144
+ const now = Date.now();
145
+ if (now - seen.checkedAt < _unreadableReloadIntervalMs)
146
+ return;
147
+ seen.checkedAt = now;
148
+ let stamp;
149
+ try {
150
+ stamp = keyFilesStamp(seen.profile);
151
+ }
152
+ catch {
153
+ return;
154
+ }
155
+ if (stamp === seen.stamp)
156
+ return;
157
+ seen.stamp = stamp;
158
+ let key = null;
159
+ try {
160
+ key = loadStoredKey(seen.profile);
161
+ }
162
+ catch {
163
+ key = null;
164
+ }
165
+ if (!key)
166
+ return;
167
+ _state = {
168
+ kind: 'active',
169
+ key,
170
+ profile: seen.profile,
171
+ envelopeProtected: key.custody === 'software' && isCredEnvelopeActive(),
172
+ };
173
+ _unreadableSeen = null;
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
+ });
221
+ }
61
222
  /**
62
223
  * The CLI, daemon, and desktop's short-lived signer resolve the same canonical
63
224
  * profile directory even when an older private key still lives in the nested
@@ -71,7 +232,7 @@ let _initializing = null;
71
232
  */
72
233
  async function withDeviceIdentityLock(profile, body) {
73
234
  const dir = profileStateDir(profile);
74
- fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
235
+ ensureStateDir(dir, `take the device identity lock for profile ${profile}`);
75
236
  const release = await lockfile.lock(dir, {
76
237
  realpath: false,
77
238
  lockfilePath: path.join(dir, '.device-identity.lock'),
@@ -140,33 +301,6 @@ function enrollmentMarkerPath(profile) {
140
301
  function enrollmentBackoffPath(profile) {
141
302
  return path.join(profileConfigDir(profile), ENROLLMENT_BACKOFF_FILENAME);
142
303
  }
143
- function atomicWrite0600(target, value) {
144
- const dir = path.dirname(target);
145
- fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
146
- const tmp = `${target}.tmp.${process.pid}.${Date.now()}`;
147
- const buf = typeof value === 'string' ? Buffer.from(value, 'utf-8') : value;
148
- const fd = fs.openSync(tmp, 'w', 0o600);
149
- try {
150
- fs.writeSync(fd, buf);
151
- fs.fsyncSync(fd);
152
- }
153
- finally {
154
- fs.closeSync(fd);
155
- }
156
- fs.renameSync(tmp, target);
157
- try {
158
- const dirFd = fs.openSync(dir, 'r');
159
- try {
160
- fs.fsyncSync(dirFd);
161
- }
162
- finally {
163
- fs.closeSync(dirFd);
164
- }
165
- }
166
- catch {
167
- /* best-effort; some platforms reject dir fsync */
168
- }
169
- }
170
304
  function emit(eventName, success, metadata, opts = {}) {
171
305
  emitAuthTelemetry({
172
306
  timestamp: new Date().toISOString(),
@@ -185,17 +319,49 @@ function emit(eventName, success, metadata, opts = {}) {
185
319
  }
186
320
  }
187
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
+ }
188
359
  /**
189
360
  * Persist a freshly-generated keypair. Public goes plaintext; private
190
361
  * goes through `maybeEncryptForWrite` so L12 wraps it when active.
191
362
  */
192
363
  function persistKeypair(profile, kp) {
193
- const publicPayload = JSON.stringify({
194
- publicJwk: kp.publicJwk,
195
- thumbprint: kp.thumbprint,
196
- createdAt: new Date().toISOString(),
197
- });
198
- atomicWrite0600(publicKeyPath(profile), publicPayload);
364
+ writePublicKeyRecord(profile, kp.publicJwk);
199
365
  // Use the always-fail-open variant — device-identity wants the private
200
366
  // key persisted even when the L12 envelope path is inactive (today's
201
367
  // default on every platform). The daemon's `credEnvelopeFailClosed`
@@ -203,7 +369,7 @@ function persistKeypair(profile, kp) {
203
369
  const privatePlaintext = JSON.stringify(kp.privateJwk);
204
370
  const wireForm = wrapIfActiveOrPassthrough(privatePlaintext);
205
371
  const envelopeProtected = wireForm !== privatePlaintext;
206
- atomicWrite0600(privateKeyPath(profile), wireForm);
372
+ atomicWrite0600(privateKeyPath(profile), wireForm, `write the device private key for profile ${profile}`);
207
373
  emit('keypair_persisted', true, {
208
374
  profile,
209
375
  thumbprint: kp.thumbprint,
@@ -256,11 +422,17 @@ function readIndependentIdentityFile(file, sealed) {
256
422
  }
257
423
  }
258
424
  /**
259
- * Load a previously-persisted keypair. Returns null on missing files,
260
- * 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
261
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.
262
434
  */
263
- function loadKeypair(profile) {
435
+ function loadStoredKey(profile) {
264
436
  const pubPath = publicKeyPath(profile);
265
437
  const privPath = privateKeyPath(profile);
266
438
  const independent = /^desktop-bg-[a-f0-9]{32}$/i.test(profile);
@@ -274,6 +446,8 @@ function loadKeypair(profile) {
274
446
  let storedThumbprint;
275
447
  try {
276
448
  const parsed = JSON.parse(pubRaw);
449
+ if (!isDevicePublicJwk(parsed.publicJwk))
450
+ throw new Error('public key is not a device key');
277
451
  publicJwk = parsed.publicJwk;
278
452
  storedThumbprint = parsed.thumbprint;
279
453
  }
@@ -284,7 +458,32 @@ function loadKeypair(profile) {
284
458
  });
285
459
  return null;
286
460
  }
287
- 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
+ });
288
487
  if (decryptResult.plaintext === null) {
289
488
  emit('private_key_decrypt_failed', false, { profile });
290
489
  return null;
@@ -302,8 +501,10 @@ function loadKeypair(profile) {
302
501
  return null;
303
502
  }
304
503
  // public.x must match private.x — any mismatch indicates a partial
305
- // write race, manual edit, or attacker swapping one of the files.
306
- 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) {
307
508
  emit('keypair_inconsistent', false, { profile });
308
509
  return null;
309
510
  }
@@ -317,11 +518,16 @@ function loadKeypair(profile) {
317
518
  recomputed,
318
519
  });
319
520
  }
320
- return {
321
- publicJwk,
322
- privateJwk,
323
- thumbprint: recomputed,
324
- };
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
+ }
325
531
  }
326
532
  /**
327
533
  * Identity of a device-identity directory: the thumbprint of the PLAINTEXT
@@ -335,7 +541,7 @@ function loadKeypair(profile) {
335
541
  function deviceIdentityOf(dir) {
336
542
  try {
337
543
  const parsed = JSON.parse(fs.readFileSync(path.join(dir, PUBLIC_KEY_FILENAME), 'utf-8'));
338
- if (!parsed.publicJwk || typeof parsed.publicJwk.x !== 'string')
544
+ if (!isDevicePublicJwk(parsed.publicJwk))
339
545
  return null;
340
546
  return jwkThumbprint(parsed.publicJwk);
341
547
  }
@@ -403,6 +609,7 @@ async function consolidateDeviceIdentity(profile) {
403
609
  });
404
610
  }
405
611
  catch (err) {
612
+ rethrowIfLiveStateRefusal(err);
406
613
  emit('path_consolidation.failed', false, {
407
614
  profile,
408
615
  message: err?.message ?? 'unknown',
@@ -445,6 +652,7 @@ export async function initDeviceIdentity(profile, options = {}) {
445
652
  if (_state.kind === 'uninit')
446
653
  await initDeviceIdentityLocked(profile, options);
447
654
  }).catch((err) => {
655
+ rethrowIfLiveStateRefusal(err);
448
656
  _state = { kind: 'disabled', reason: 'init_failed' };
449
657
  emit('init_failed', false, {
450
658
  profile,
@@ -469,23 +677,25 @@ async function initDeviceIdentityLocked(profile, options = {}) {
469
677
  const independent = /^desktop-bg-[a-f0-9]{32}$/i.test(profile);
470
678
  if (!independent)
471
679
  await consolidateDeviceIdentity(profile);
472
- let keypair = null;
680
+ let stored = null;
473
681
  try {
474
- keypair = loadKeypair(profile);
682
+ stored = loadStoredKey(profile);
475
683
  }
476
684
  catch (err) {
477
685
  emit('load_failed', false, {
478
686
  profile,
479
687
  message: err?.message ?? 'unknown',
480
688
  });
481
- keypair = null;
689
+ stored = null;
482
690
  }
483
- if (keypair) {
484
- const envelopeProtected = isCredEnvelopeActive();
485
- _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);
486
695
  emit('active', true, {
487
696
  profile,
488
- thumbprint: keypair.thumbprint,
697
+ thumbprint: stored.thumbprint,
698
+ custody: stored.custody,
489
699
  envelopeProtected,
490
700
  regenerated: false,
491
701
  });
@@ -500,6 +710,9 @@ async function initDeviceIdentityLocked(profile, options = {}) {
500
710
  const hasStoredKey = storedKeyMaterialExists(profile);
501
711
  if (hasStoredKey && (!options.recoverUnreadable || independent)) {
502
712
  _state = { kind: 'disabled', reason: 'unreadable_key' };
713
+ _unreadableSeen = independent
714
+ ? null
715
+ : { profile, stamp: keyFilesStamp(profile), checkedAt: Date.now() };
503
716
  emit('recovery_required', false, { profile, reason: 'stored_key_unreadable' });
504
717
  return;
505
718
  }
@@ -526,6 +739,7 @@ async function initDeviceIdentityLocked(profile, options = {}) {
526
739
  persistResult = persistKeypair(profile, fresh);
527
740
  }
528
741
  catch (err) {
742
+ rethrowIfLiveStateRefusal(err);
529
743
  _state = { kind: 'disabled', reason: 'persist_failed' };
530
744
  emit('persist_failed', false, {
531
745
  profile,
@@ -533,15 +747,30 @@ async function initDeviceIdentityLocked(profile, options = {}) {
533
747
  });
534
748
  return;
535
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
+ }
536
763
  _state = {
537
764
  kind: 'active',
538
- keypair: fresh,
765
+ key: freshKey,
539
766
  profile,
540
767
  envelopeProtected: persistResult.envelopeProtected,
541
768
  };
769
+ trackActiveKeyFiles(profile);
542
770
  emit('active', true, {
543
771
  profile,
544
772
  thumbprint: fresh.thumbprint,
773
+ custody: 'software',
545
774
  envelopeProtected: persistResult.envelopeProtected,
546
775
  regenerated: true,
547
776
  });
@@ -558,13 +787,22 @@ export async function resetDeviceIdentity(opts) {
558
787
  // Every location, not just the resolved one. A reset that cleared only the
559
788
  // canonical copy would leave the pre-consolidation copy for the next init to
560
789
  // adopt — a deleted device key that reappears.
561
- 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
+ ];
562
799
  await withDeviceIdentityLock(opts.profile, async () => {
563
800
  for (const p of targets) {
564
801
  try {
565
- fs.unlinkSync(p);
802
+ removeStateFile(p, `delete the device key file ${path.basename(p)}`);
566
803
  }
567
804
  catch (err) {
805
+ rethrowIfLiveStateRefusal(err);
568
806
  if (err.code !== 'ENOENT') {
569
807
  emit('clear_failed', false, {
570
808
  profile: opts.profile,
@@ -577,9 +815,217 @@ export async function resetDeviceIdentity(opts) {
577
815
  });
578
816
  }
579
817
  _state = { kind: 'uninit' };
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
+ });
580
1025
  }
581
1026
  /** True iff init has completed and a keypair is in memory. */
582
1027
  export function isDeviceIdentityActive() {
1028
+ reloadIfRecovered();
583
1029
  return _state.kind === 'active';
584
1030
  }
585
1031
  /**
@@ -588,42 +1034,85 @@ export function isDeviceIdentityActive() {
588
1034
  * on the in-flight request.
589
1035
  */
590
1036
  export function getDevicePublicKey() {
1037
+ reloadIfRecovered();
591
1038
  if (_state.kind !== 'active')
592
1039
  return null;
593
- 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
+ };
594
1046
  }
595
1047
  /**
596
1048
  * Operator-readable summary of device-identity state. Used by
597
1049
  * `oversky status` and CLI debug commands. Never includes the private key.
598
1050
  */
599
1051
  export function describeDeviceIdentityState() {
1052
+ reloadIfRecovered();
600
1053
  if (_state.kind === 'disabled') {
601
1054
  return { kind: 'disabled', reason: _state.reason };
602
1055
  }
603
1056
  if (_state.kind === 'active') {
604
1057
  return {
605
1058
  kind: 'active',
606
- thumbprint: _state.keypair.thumbprint,
1059
+ thumbprint: _state.key.thumbprint,
607
1060
  profile: _state.profile,
608
1061
  envelopeProtected: _state.envelopeProtected,
1062
+ alg: _state.key.alg,
1063
+ custody: _state.key.custody,
1064
+ canSign: _state.key.custody === 'software' ? true : resolveHardwareDeviceKeyHelper() !== null,
609
1065
  };
610
1066
  }
611
1067
  return { kind: 'uninit' };
612
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
+ }
613
1096
  /**
614
1097
  * Sign an outgoing request with the device key. Returns the JWS Compact
615
1098
  * Serialization, or null when device identity is not active. NEVER
616
1099
  * throws — a failed sign returns null and emits a telemetry event so
617
1100
  * the caller can fall back to bearer-only auth without an outage.
618
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
+ *
619
1106
  * Shape matches `DeviceProofSigner` from `./refresh.ts` so the function
620
1107
  * passes directly into `refreshDaemonToken`'s `signRequestProof` slot.
621
1108
  */
622
- export function signServerRequest(opts) {
623
- if (_state.kind !== 'active')
1109
+ export async function signServerRequest(opts) {
1110
+ reloadIfRecovered();
1111
+ const state = _state;
1112
+ if (state.kind !== 'active')
624
1113
  return null;
625
1114
  try {
626
- return buildDeviceProof(_state.keypair.privateJwk, _state.keypair.publicJwk, {
1115
+ return await signProofWith(state.key, {
627
1116
  method: opts.method,
628
1117
  url: opts.url,
629
1118
  refreshToken: opts.refreshToken,
@@ -632,9 +1121,13 @@ export function signServerRequest(opts) {
632
1121
  }
633
1122
  catch (err) {
634
1123
  emit('sign_failed', false, {
635
- profile: _state.profile,
636
- thumbprint: _state.keypair.thumbprint,
1124
+ profile: state.profile,
1125
+ thumbprint: state.key.thumbprint,
1126
+ custody: state.key.custody,
637
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 } : {}),
638
1131
  message: err?.message ?? 'unknown',
639
1132
  });
640
1133
  return null;
@@ -657,9 +1150,10 @@ export function __enrollmentBackoffPathForTest(profile) {
657
1150
  export function clearEnrollmentMarker(profile) {
658
1151
  for (const markerPath of [enrollmentMarkerPath(profile), enrollmentBackoffPath(profile)]) {
659
1152
  try {
660
- fs.unlinkSync(markerPath);
1153
+ removeStateFile(markerPath, `delete the enrollment file ${path.basename(markerPath)}`);
661
1154
  }
662
1155
  catch (err) {
1156
+ rethrowIfLiveStateRefusal(err);
663
1157
  if (err.code !== 'ENOENT') {
664
1158
  emit('enrollment_marker_clear_failed', false, {
665
1159
  profile,
@@ -691,7 +1185,7 @@ export async function enrollWithServer(opts) {
691
1185
  return { enrolled: false, reason: 'not_active' };
692
1186
  }
693
1187
  const profile = _state.profile;
694
- const keypair = _state.keypair;
1188
+ const key = _state.key;
695
1189
  const markerPath = enrollmentMarkerPath(profile);
696
1190
  const backoffPath = enrollmentBackoffPath(profile);
697
1191
  try {
@@ -707,33 +1201,48 @@ export async function enrollWithServer(opts) {
707
1201
  const retryAfter = Date.parse(raw.retryAfter || '');
708
1202
  if (raw.serverUrl === serverUrl &&
709
1203
  raw.machineUuid === machineUuid &&
710
- raw.thumbprint === keypair.thumbprint &&
1204
+ raw.thumbprint === key.thumbprint &&
711
1205
  Number.isFinite(retryAfter) &&
712
1206
  retryAfter > Date.now()) {
713
1207
  return { enrolled: false, reason: 'backoff_active' };
714
1208
  }
715
- fs.unlinkSync(backoffPath);
1209
+ removeStateFile(backoffPath, 'delete the stale enrollment backoff');
716
1210
  }
717
1211
  catch (err) {
718
1212
  if (err.code !== 'ENOENT') {
719
- // 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.
720
1216
  try {
721
- fs.unlinkSync(backoffPath);
1217
+ removeStateFile(backoffPath, 'delete the corrupt enrollment backoff');
722
1218
  }
723
- catch {
1219
+ catch (clearErr) {
1220
+ rethrowIfLiveStateRefusal(clearErr);
724
1221
  /* best-effort */
725
1222
  }
726
1223
  }
727
1224
  }
728
1225
  const base = serverUrl.replace(/\/+$/, '');
729
1226
  const htu = `${base}/api/daemon-resources/enroll-device-key`;
730
- const proofJws = buildDeviceProof(keypair.privateJwk, keypair.publicJwk, {
731
- method: 'POST',
732
- url: htu,
733
- });
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.
734
1242
  const body = JSON.stringify({
735
1243
  machineUuid,
736
- publicJwkX: keypair.publicJwk.x,
1244
+ ...(key.publicJwk.kty === 'OKP' ? { publicJwkX: key.publicJwk.x } : {}),
1245
+ publicJwk: key.publicJwk,
737
1246
  proofJws,
738
1247
  });
739
1248
  // 10-second bound — enrollment is best-effort at startup.
@@ -756,7 +1265,7 @@ export async function enrollWithServer(opts) {
756
1265
  const message = err?.message ?? 'unknown';
757
1266
  emit('enrollment_network_error', false, {
758
1267
  profile,
759
- thumbprint: keypair.thumbprint,
1268
+ thumbprint: key.thumbprint,
760
1269
  serverUrl,
761
1270
  message,
762
1271
  });
@@ -807,7 +1316,7 @@ export async function enrollWithServer(opts) {
807
1316
  }
808
1317
  const pinnedToOtherKey = response.status === 409 &&
809
1318
  enrolledThumbprint !== null &&
810
- enrolledThumbprint !== keypair.thumbprint;
1319
+ enrolledThumbprint !== key.thumbprint;
811
1320
  const keyPinnedElsewhere = response.status === 409 && conflictCode === 'ENROLLMENT_DUPLICATE_KEY';
812
1321
  const isBenignRejection = (response.status === 404 && responseText.includes('DAEMON_NOT_FOUND')) ||
813
1322
  (response.status === 403 && responseText.includes('OPAQUE_AUTH_REFUSED')) ||
@@ -815,7 +1324,7 @@ export async function enrollWithServer(opts) {
815
1324
  if (pinnedToOtherKey) {
816
1325
  emit('enrollment_key_mismatch', false, {
817
1326
  profile,
818
- thumbprint: keypair.thumbprint,
1327
+ thumbprint: key.thumbprint,
819
1328
  enrolledThumbprint,
820
1329
  serverUrl,
821
1330
  message: 'this machine is registered with a different device key than this profile signs ' +
@@ -828,11 +1337,12 @@ export async function enrollWithServer(opts) {
828
1337
  atomicWrite0600(backoffPath, JSON.stringify({
829
1338
  serverUrl,
830
1339
  machineUuid,
831
- thumbprint: keypair.thumbprint,
1340
+ thumbprint: key.thumbprint,
832
1341
  retryAfter: new Date(Date.now() + DAEMON_NOT_FOUND_BACKOFF_MS).toISOString(),
833
- }));
1342
+ }), 'write the enrollment backoff');
834
1343
  }
835
1344
  catch (err) {
1345
+ rethrowIfLiveStateRefusal(err);
836
1346
  emit('enrollment_backoff_write_failed', false, {
837
1347
  profile,
838
1348
  message: err?.message ?? 'unknown',
@@ -841,7 +1351,7 @@ export async function enrollWithServer(opts) {
841
1351
  }
842
1352
  emit('enrollment_rejected', false, {
843
1353
  profile,
844
- thumbprint: keypair.thumbprint,
1354
+ thumbprint: key.thumbprint,
845
1355
  serverUrl,
846
1356
  status: response.status,
847
1357
  body: responseText,
@@ -856,25 +1366,28 @@ export async function enrollWithServer(opts) {
856
1366
  const marker = JSON.stringify({
857
1367
  enrolledAt: new Date().toISOString(),
858
1368
  serverUrl,
859
- thumbprint: keypair.thumbprint,
1369
+ thumbprint: key.thumbprint,
860
1370
  machineUuid,
861
1371
  });
862
1372
  try {
863
- atomicWrite0600(markerPath, marker);
1373
+ atomicWrite0600(markerPath, marker, 'write the enrollment marker');
864
1374
  }
865
1375
  catch (err) {
1376
+ rethrowIfLiveStateRefusal(err);
866
1377
  // Non-fatal — server enrolled us; we may re-POST next run, which
867
1378
  // the server handles idempotently.
868
1379
  emit('enrollment_marker_write_failed', false, {
869
1380
  profile,
870
- thumbprint: keypair.thumbprint,
1381
+ thumbprint: key.thumbprint,
871
1382
  message: err?.message ?? 'unknown',
872
1383
  });
873
1384
  }
874
1385
  try {
875
- fs.unlinkSync(backoffPath);
1386
+ removeStateFile(backoffPath, 'delete the enrollment backoff');
876
1387
  }
877
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.
878
1391
  if (err.code !== 'ENOENT') {
879
1392
  emit('enrollment_backoff_clear_failed', false, {
880
1393
  profile,
@@ -884,8 +1397,8 @@ export async function enrollWithServer(opts) {
884
1397
  }
885
1398
  emit('enrolled_with_server', true, {
886
1399
  profile,
887
- thumbprint: keypair.thumbprint,
1400
+ thumbprint: key.thumbprint,
888
1401
  serverUrl,
889
1402
  });
890
- return { enrolled: true, thumbprint: keypair.thumbprint };
1403
+ return { enrolled: true, thumbprint: key.thumbprint };
891
1404
  }