@skrr-ai/auth-core 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. package/README.md +21 -0
  2. package/dist/cjs/aead.d.ts +24 -0
  3. package/dist/cjs/aead.js +65 -0
  4. package/dist/cjs/authExpiredDetector.d.ts +65 -0
  5. package/dist/cjs/authExpiredDetector.js +89 -0
  6. package/dist/cjs/authHelper.d.ts +51 -0
  7. package/dist/cjs/authHelper.js +321 -0
  8. package/dist/cjs/credentialEnvelope.d.ts +85 -0
  9. package/dist/cjs/credentialEnvelope.js +230 -0
  10. package/dist/cjs/credentialEnvelopeBridge.d.ts +116 -0
  11. package/dist/cjs/credentialEnvelopeBridge.js +637 -0
  12. package/dist/cjs/credentialResolver.d.ts +278 -0
  13. package/dist/cjs/credentialResolver.js +423 -0
  14. package/dist/cjs/daemonPair.d.ts +172 -0
  15. package/dist/cjs/daemonPair.js +495 -0
  16. package/dist/cjs/daemonScopes.d.ts +74 -0
  17. package/dist/cjs/daemonScopes.js +84 -0
  18. package/dist/cjs/deviceIdentityBridge.d.ts +113 -0
  19. package/dist/cjs/deviceIdentityBridge.js +591 -0
  20. package/dist/cjs/deviceKey.d.ts +167 -0
  21. package/dist/cjs/deviceKey.js +332 -0
  22. package/dist/cjs/fdToken.d.ts +17 -0
  23. package/dist/cjs/fdToken.js +238 -0
  24. package/dist/cjs/harnessTrust.d.ts +72 -0
  25. package/dist/cjs/harnessTrust.js +103 -0
  26. package/dist/cjs/headers-helper.d.ts +25 -0
  27. package/dist/cjs/headers-helper.js +323 -0
  28. package/dist/cjs/index.d.ts +37 -0
  29. package/dist/cjs/index.js +305 -0
  30. package/dist/cjs/jwtUtils.d.ts +35 -0
  31. package/dist/cjs/jwtUtils.js +61 -0
  32. package/dist/cjs/kek/index.d.ts +89 -0
  33. package/dist/cjs/kek/index.js +208 -0
  34. package/dist/cjs/kek/linux.d.ts +92 -0
  35. package/dist/cjs/kek/linux.js +596 -0
  36. package/dist/cjs/kek/macos.d.ts +112 -0
  37. package/dist/cjs/kek/macos.js +599 -0
  38. package/dist/cjs/kek/types.d.ts +111 -0
  39. package/dist/cjs/kek/types.js +43 -0
  40. package/dist/cjs/kek/windows.d.ts +71 -0
  41. package/dist/cjs/kek/windows.js +350 -0
  42. package/dist/cjs/kek/zeroize-registry.d.ts +44 -0
  43. package/dist/cjs/kek/zeroize-registry.js +63 -0
  44. package/dist/cjs/legacyStatePreflight.d.ts +67 -0
  45. package/dist/cjs/legacyStatePreflight.js +78 -0
  46. package/dist/cjs/localIdentity.d.ts +54 -0
  47. package/dist/cjs/localIdentity.js +57 -0
  48. package/dist/cjs/loginLocalhost.d.ts +115 -0
  49. package/dist/cjs/loginLocalhost.js +368 -0
  50. package/dist/cjs/loginWithLocalhost.d.ts +131 -0
  51. package/dist/cjs/loginWithLocalhost.js +359 -0
  52. package/dist/cjs/machineId.d.ts +35 -0
  53. package/dist/cjs/machineId.js +239 -0
  54. package/dist/cjs/messages.d.ts +9 -0
  55. package/dist/cjs/messages.js +44 -0
  56. package/dist/cjs/package.json +3 -0
  57. package/dist/cjs/pkce.d.ts +62 -0
  58. package/dist/cjs/pkce.js +158 -0
  59. package/dist/cjs/recoveryCode.d.ts +61 -0
  60. package/dist/cjs/recoveryCode.js +213 -0
  61. package/dist/cjs/refresh.d.ts +215 -0
  62. package/dist/cjs/refresh.js +877 -0
  63. package/dist/cjs/refreshClassification.d.ts +53 -0
  64. package/dist/cjs/refreshClassification.js +105 -0
  65. package/dist/cjs/refreshScheduler.d.ts +53 -0
  66. package/dist/cjs/refreshScheduler.js +332 -0
  67. package/dist/cjs/runtime.d.ts +149 -0
  68. package/dist/cjs/runtime.js +136 -0
  69. package/dist/cjs/spawnEnv.d.ts +76 -0
  70. package/dist/cjs/spawnEnv.js +153 -0
  71. package/dist/cjs/ttlParser.d.ts +54 -0
  72. package/dist/cjs/ttlParser.js +87 -0
  73. package/dist/cjs/types.d.ts +91 -0
  74. package/dist/cjs/types.js +26 -0
  75. package/dist/esm/aead.d.ts +24 -0
  76. package/dist/esm/aead.js +57 -0
  77. package/dist/esm/authExpiredDetector.d.ts +65 -0
  78. package/dist/esm/authExpiredDetector.js +85 -0
  79. package/dist/esm/authHelper.d.ts +51 -0
  80. package/dist/esm/authHelper.js +311 -0
  81. package/dist/esm/credentialEnvelope.d.ts +85 -0
  82. package/dist/esm/credentialEnvelope.js +213 -0
  83. package/dist/esm/credentialEnvelopeBridge.d.ts +116 -0
  84. package/dist/esm/credentialEnvelopeBridge.js +620 -0
  85. package/dist/esm/credentialResolver.d.ts +278 -0
  86. package/dist/esm/credentialResolver.js +414 -0
  87. package/dist/esm/daemonPair.d.ts +172 -0
  88. package/dist/esm/daemonPair.js +476 -0
  89. package/dist/esm/daemonScopes.d.ts +74 -0
  90. package/dist/esm/daemonScopes.js +80 -0
  91. package/dist/esm/deviceIdentityBridge.d.ts +113 -0
  92. package/dist/esm/deviceIdentityBridge.js +572 -0
  93. package/dist/esm/deviceKey.d.ts +167 -0
  94. package/dist/esm/deviceKey.js +320 -0
  95. package/dist/esm/fdToken.d.ts +17 -0
  96. package/dist/esm/fdToken.js +231 -0
  97. package/dist/esm/harnessTrust.d.ts +72 -0
  98. package/dist/esm/harnessTrust.js +97 -0
  99. package/dist/esm/headers-helper.d.ts +25 -0
  100. package/dist/esm/headers-helper.js +316 -0
  101. package/dist/esm/index.d.ts +37 -0
  102. package/dist/esm/index.js +144 -0
  103. package/dist/esm/jwtUtils.d.ts +35 -0
  104. package/dist/esm/jwtUtils.js +57 -0
  105. package/dist/esm/kek/index.d.ts +89 -0
  106. package/dist/esm/kek/index.js +162 -0
  107. package/dist/esm/kek/linux.d.ts +92 -0
  108. package/dist/esm/kek/linux.js +585 -0
  109. package/dist/esm/kek/macos.d.ts +112 -0
  110. package/dist/esm/kek/macos.js +586 -0
  111. package/dist/esm/kek/types.d.ts +111 -0
  112. package/dist/esm/kek/types.js +39 -0
  113. package/dist/esm/kek/windows.d.ts +71 -0
  114. package/dist/esm/kek/windows.js +344 -0
  115. package/dist/esm/kek/zeroize-registry.d.ts +44 -0
  116. package/dist/esm/kek/zeroize-registry.js +58 -0
  117. package/dist/esm/legacyStatePreflight.d.ts +67 -0
  118. package/dist/esm/legacyStatePreflight.js +71 -0
  119. package/dist/esm/localIdentity.d.ts +54 -0
  120. package/dist/esm/localIdentity.js +54 -0
  121. package/dist/esm/loginLocalhost.d.ts +115 -0
  122. package/dist/esm/loginLocalhost.js +355 -0
  123. package/dist/esm/loginWithLocalhost.d.ts +131 -0
  124. package/dist/esm/loginWithLocalhost.js +353 -0
  125. package/dist/esm/machineId.d.ts +35 -0
  126. package/dist/esm/machineId.js +231 -0
  127. package/dist/esm/messages.d.ts +9 -0
  128. package/dist/esm/messages.js +40 -0
  129. package/dist/esm/pkce.d.ts +62 -0
  130. package/dist/esm/pkce.js +148 -0
  131. package/dist/esm/recoveryCode.d.ts +61 -0
  132. package/dist/esm/recoveryCode.js +207 -0
  133. package/dist/esm/refresh.d.ts +215 -0
  134. package/dist/esm/refresh.js +863 -0
  135. package/dist/esm/refreshClassification.d.ts +53 -0
  136. package/dist/esm/refreshClassification.js +106 -0
  137. package/dist/esm/refreshScheduler.d.ts +53 -0
  138. package/dist/esm/refreshScheduler.js +329 -0
  139. package/dist/esm/runtime.d.ts +149 -0
  140. package/dist/esm/runtime.js +121 -0
  141. package/dist/esm/spawnEnv.d.ts +76 -0
  142. package/dist/esm/spawnEnv.js +149 -0
  143. package/dist/esm/ttlParser.d.ts +54 -0
  144. package/dist/esm/ttlParser.js +83 -0
  145. package/dist/esm/types.d.ts +91 -0
  146. package/dist/esm/types.js +21 -0
  147. package/package.json +82 -0
@@ -0,0 +1,572 @@
1
+ /**
2
+ * deviceIdentityBridge.ts — composed L11 runtime (init, sign, enroll).
3
+ *
4
+ * Lifts the previously-duplicated `daemon/src/deviceIdentity.ts` and
5
+ * `cli/src/lib/device-identity.ts` modules into a single shared
6
+ * implementation. Daemon and CLI both consume this module so they land
7
+ * on the SAME on-disk artifacts:
8
+ *
9
+ * <configDir>/profiles/<p>/device-public-key.jwk — plaintext JWK
10
+ * <configDir>/profiles/<p>/device-private-key — L12-wrapped JSON
11
+ * <configDir>/profiles/<p>/device-enrollment.json — server-ack marker
12
+ *
13
+ * Architectural decision (locked): CLI shares the daemon's keypair on
14
+ * the same machine. Same machine = same device identity. The scope
15
+ * discriminator is the JWT claim (`scope: 'cli'` vs `scope: 'daemon'`),
16
+ * not the key.
17
+ *
18
+ * NAMING NOTE — the suffix `Bridge` distinguishes this composed runtime
19
+ * (init + sign + enroll + telemetry) from the pure-crypto primitive
20
+ * file `deviceKey.ts` it composes.
21
+ */
22
+ import fs from 'node:fs';
23
+ import path from 'node:path';
24
+ import { buildProof as buildDeviceProof, generateDeviceKeyPair, jwkThumbprint, } from './deviceKey.js';
25
+ import { isCredEnvelopeActive, maybeDecryptOnRead, wrapIfActiveOrPassthrough, } from './credentialEnvelopeBridge.js';
26
+ import { emitAuthTelemetry, getAuthConfigDir, getAuthLogger } from './runtime.js';
27
+ const PUBLIC_KEY_FILENAME = 'device-public-key.jwk';
28
+ const PRIVATE_KEY_FILENAME = 'device-private-key';
29
+ const ENROLLMENT_MARKER_FILENAME = 'device-enrollment.json';
30
+ const ENROLLMENT_BACKOFF_FILENAME = 'device-enrollment-backoff.json';
31
+ // A missing Daemon row cannot become enrollable until registration changes
32
+ // server state. Persist the negative result across short-lived `sky` processes
33
+ // so every CLI command does not repeat the same guaranteed 404.
34
+ const DAEMON_NOT_FOUND_BACKOFF_MS = 15 * 60 * 1000;
35
+ let _state = { kind: 'uninit' };
36
+ /** @internal Test seam — force-set state. */
37
+ export function __setStateForTest(next) {
38
+ const prev = _state;
39
+ _state = next;
40
+ return prev;
41
+ }
42
+ /** @internal Test seam — read state. */
43
+ export function __getStateForTest() {
44
+ return _state;
45
+ }
46
+ function profileConfigDir(profile) {
47
+ return path.join(getAuthConfigDir(), 'profiles', profile);
48
+ }
49
+ function publicKeyPath(profile) {
50
+ return path.join(profileConfigDir(profile), PUBLIC_KEY_FILENAME);
51
+ }
52
+ function privateKeyPath(profile) {
53
+ return path.join(profileConfigDir(profile), PRIVATE_KEY_FILENAME);
54
+ }
55
+ function enrollmentMarkerPath(profile) {
56
+ return path.join(profileConfigDir(profile), ENROLLMENT_MARKER_FILENAME);
57
+ }
58
+ function enrollmentBackoffPath(profile) {
59
+ return path.join(profileConfigDir(profile), ENROLLMENT_BACKOFF_FILENAME);
60
+ }
61
+ function atomicWrite0600(target, value) {
62
+ const dir = path.dirname(target);
63
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
64
+ const tmp = `${target}.tmp.${process.pid}.${Date.now()}`;
65
+ const buf = typeof value === 'string' ? Buffer.from(value, 'utf-8') : value;
66
+ const fd = fs.openSync(tmp, 'w', 0o600);
67
+ try {
68
+ fs.writeSync(fd, buf);
69
+ fs.fsyncSync(fd);
70
+ }
71
+ finally {
72
+ fs.closeSync(fd);
73
+ }
74
+ fs.renameSync(tmp, target);
75
+ try {
76
+ const dirFd = fs.openSync(dir, 'r');
77
+ try {
78
+ fs.fsyncSync(dirFd);
79
+ }
80
+ finally {
81
+ fs.closeSync(dirFd);
82
+ }
83
+ }
84
+ catch {
85
+ /* best-effort; some platforms reject dir fsync */
86
+ }
87
+ }
88
+ function emit(eventName, success, metadata, opts = {}) {
89
+ emitAuthTelemetry({
90
+ timestamp: new Date().toISOString(),
91
+ eventType: 'auth',
92
+ success,
93
+ metadata: { source: 'deviceIdentity', event: eventName, ...metadata },
94
+ });
95
+ if (!success) {
96
+ const level = opts.logLevel ?? 'warn';
97
+ const log = getAuthLogger();
98
+ if (level === 'info') {
99
+ log.info(`[deviceIdentity] ${eventName}`, metadata);
100
+ }
101
+ else {
102
+ log.warn(`[deviceIdentity] ${eventName}`, metadata);
103
+ }
104
+ }
105
+ }
106
+ /**
107
+ * Persist a freshly-generated keypair. Public goes plaintext; private
108
+ * goes through `maybeEncryptForWrite` so L12 wraps it when active.
109
+ */
110
+ function persistKeypair(profile, kp) {
111
+ const publicPayload = JSON.stringify({
112
+ publicJwk: kp.publicJwk,
113
+ thumbprint: kp.thumbprint,
114
+ createdAt: new Date().toISOString(),
115
+ });
116
+ atomicWrite0600(publicKeyPath(profile), publicPayload);
117
+ // Use the always-fail-open variant — device-identity wants the private
118
+ // key persisted even when the L12 envelope path is inactive (today's
119
+ // default on every platform). The daemon's `credEnvelopeFailClosed`
120
+ // policy applies only to the keychain.ts write path.
121
+ const privatePlaintext = JSON.stringify(kp.privateJwk);
122
+ const wireForm = wrapIfActiveOrPassthrough(privatePlaintext);
123
+ const envelopeProtected = wireForm !== privatePlaintext;
124
+ atomicWrite0600(privateKeyPath(profile), wireForm);
125
+ emit('keypair_persisted', true, {
126
+ profile,
127
+ thumbprint: kp.thumbprint,
128
+ envelopeProtected,
129
+ });
130
+ return { envelopeProtected };
131
+ }
132
+ /**
133
+ * Load a previously-persisted keypair. Returns null on missing files,
134
+ * decrypt failure, public/private mismatch (file-swap defense), or
135
+ * malformed JSON. Throws only on unexpected fs errors; init catches.
136
+ */
137
+ function loadKeypair(profile) {
138
+ const pubPath = publicKeyPath(profile);
139
+ const privPath = privateKeyPath(profile);
140
+ if (!fs.existsSync(pubPath) || !fs.existsSync(privPath)) {
141
+ return null;
142
+ }
143
+ const pubRaw = fs.readFileSync(pubPath, 'utf-8');
144
+ const privRaw = fs.readFileSync(privPath, 'utf-8');
145
+ let publicJwk;
146
+ let storedThumbprint;
147
+ try {
148
+ const parsed = JSON.parse(pubRaw);
149
+ publicJwk = parsed.publicJwk;
150
+ storedThumbprint = parsed.thumbprint;
151
+ }
152
+ catch (err) {
153
+ emit('public_key_unreadable', false, {
154
+ profile,
155
+ message: err?.message ?? 'unknown',
156
+ });
157
+ return null;
158
+ }
159
+ const decryptResult = maybeDecryptOnRead(privRaw);
160
+ if (decryptResult.plaintext === null) {
161
+ emit('private_key_decrypt_failed', false, { profile });
162
+ return null;
163
+ }
164
+ const privatePlaintext = decryptResult.plaintext;
165
+ let privateJwk;
166
+ try {
167
+ privateJwk = JSON.parse(privatePlaintext);
168
+ }
169
+ catch (err) {
170
+ emit('private_key_malformed', false, {
171
+ profile,
172
+ message: err?.message ?? 'unknown',
173
+ });
174
+ return null;
175
+ }
176
+ // public.x must match private.x — any mismatch indicates a partial
177
+ // write race, manual edit, or attacker swapping one of the files.
178
+ if (publicJwk.x !== privateJwk.x) {
179
+ emit('keypair_inconsistent', false, { profile });
180
+ return null;
181
+ }
182
+ // Recompute the thumbprint; mismatch with stored is informational
183
+ // (we trust the recomputed value), not a failure.
184
+ const recomputed = jwkThumbprint(publicJwk);
185
+ if (recomputed !== storedThumbprint) {
186
+ emit('thumbprint_drift', false, {
187
+ profile,
188
+ stored: storedThumbprint,
189
+ recomputed,
190
+ });
191
+ }
192
+ return {
193
+ publicJwk,
194
+ privateJwk,
195
+ thumbprint: recomputed,
196
+ };
197
+ }
198
+ /**
199
+ * Initialize device identity. Idempotent.
200
+ *
201
+ * Order:
202
+ * 1. Try to load existing keypair from disk.
203
+ * - Success → state = active.
204
+ * - Missing files / corruption / decrypt failure → step 2.
205
+ * 2. Generate fresh keypair and persist. Failure to persist is a hard
206
+ * error landing in disabled+persist_failed.
207
+ */
208
+ export async function initDeviceIdentity(profile) {
209
+ if (_state.kind !== 'uninit')
210
+ return;
211
+ if (typeof profile !== 'string' || profile.length === 0) {
212
+ _state = { kind: 'disabled', reason: 'init_failed' };
213
+ emit('init_failed', false, { reason: 'empty_profile' });
214
+ return;
215
+ }
216
+ let keypair = null;
217
+ try {
218
+ keypair = loadKeypair(profile);
219
+ }
220
+ catch (err) {
221
+ emit('load_failed', false, {
222
+ profile,
223
+ message: err?.message ?? 'unknown',
224
+ });
225
+ keypair = null;
226
+ }
227
+ if (keypair) {
228
+ const envelopeProtected = isCredEnvelopeActive();
229
+ _state = { kind: 'active', keypair, profile, envelopeProtected };
230
+ emit('active', true, {
231
+ profile,
232
+ thumbprint: keypair.thumbprint,
233
+ envelopeProtected,
234
+ regenerated: false,
235
+ });
236
+ return;
237
+ }
238
+ let fresh;
239
+ try {
240
+ fresh = generateDeviceKeyPair();
241
+ }
242
+ catch (err) {
243
+ _state = { kind: 'disabled', reason: 'init_failed' };
244
+ emit('init_failed', false, {
245
+ profile,
246
+ reason: 'keygen_threw',
247
+ message: err?.message ?? 'unknown',
248
+ });
249
+ return;
250
+ }
251
+ let persistResult;
252
+ try {
253
+ persistResult = persistKeypair(profile, fresh);
254
+ }
255
+ catch (err) {
256
+ _state = { kind: 'disabled', reason: 'persist_failed' };
257
+ emit('persist_failed', false, {
258
+ profile,
259
+ message: err?.message ?? 'unknown',
260
+ });
261
+ return;
262
+ }
263
+ _state = {
264
+ kind: 'active',
265
+ keypair: fresh,
266
+ profile,
267
+ envelopeProtected: persistResult.envelopeProtected,
268
+ };
269
+ emit('active', true, {
270
+ profile,
271
+ thumbprint: fresh.thumbprint,
272
+ envelopeProtected: persistResult.envelopeProtected,
273
+ regenerated: true,
274
+ });
275
+ }
276
+ /**
277
+ * Reset state. With `clearOnDisk=true` deletes both keypair files —
278
+ * used by `oversky logout` / `sky logout` so a freshly-logged-in user
279
+ * gets a fresh device identity that the daemon will see on its next start.
280
+ */
281
+ export async function resetDeviceIdentity(opts) {
282
+ if (opts?.clearOnDisk && opts.profile) {
283
+ for (const p of [publicKeyPath(opts.profile), privateKeyPath(opts.profile)]) {
284
+ try {
285
+ fs.unlinkSync(p);
286
+ }
287
+ catch (err) {
288
+ if (err.code !== 'ENOENT') {
289
+ emit('clear_failed', false, {
290
+ profile: opts.profile,
291
+ file: path.basename(p),
292
+ message: err?.message ?? 'unknown',
293
+ });
294
+ }
295
+ }
296
+ }
297
+ }
298
+ _state = { kind: 'uninit' };
299
+ }
300
+ /** True iff init has completed and a keypair is in memory. */
301
+ export function isDeviceIdentityActive() {
302
+ return _state.kind === 'active';
303
+ }
304
+ /**
305
+ * Export the public key for server enrollment / dashboard display.
306
+ * Returns null when not active so callers know to skip the JWS header
307
+ * on the in-flight request.
308
+ */
309
+ export function getDevicePublicKey() {
310
+ if (_state.kind !== 'active')
311
+ return null;
312
+ return { jwk: _state.keypair.publicJwk, thumbprint: _state.keypair.thumbprint };
313
+ }
314
+ /**
315
+ * Operator-readable summary of device-identity state. Used by
316
+ * `oversky status` and CLI debug commands. Never includes the private key.
317
+ */
318
+ export function describeDeviceIdentityState() {
319
+ if (_state.kind === 'disabled') {
320
+ return { kind: 'disabled', reason: _state.reason };
321
+ }
322
+ if (_state.kind === 'active') {
323
+ return {
324
+ kind: 'active',
325
+ thumbprint: _state.keypair.thumbprint,
326
+ profile: _state.profile,
327
+ envelopeProtected: _state.envelopeProtected,
328
+ };
329
+ }
330
+ return { kind: 'uninit' };
331
+ }
332
+ /**
333
+ * Sign an outgoing request with the device key. Returns the JWS Compact
334
+ * Serialization, or null when device identity is not active. NEVER
335
+ * throws — a failed sign returns null and emits a telemetry event so
336
+ * the caller can fall back to bearer-only auth without an outage.
337
+ *
338
+ * Shape matches `DeviceProofSigner` from `./refresh.ts` so the function
339
+ * passes directly into `refreshDaemonToken`'s `signRequestProof` slot.
340
+ */
341
+ export function signServerRequest(opts) {
342
+ if (_state.kind !== 'active')
343
+ return null;
344
+ try {
345
+ return buildDeviceProof(_state.keypair.privateJwk, _state.keypair.publicJwk, {
346
+ method: opts.method,
347
+ url: opts.url,
348
+ refreshToken: opts.refreshToken,
349
+ accessToken: opts.accessToken,
350
+ });
351
+ }
352
+ catch (err) {
353
+ emit('sign_failed', false, {
354
+ profile: _state.profile,
355
+ thumbprint: _state.keypair.thumbprint,
356
+ method: opts.method,
357
+ message: err?.message ?? 'unknown',
358
+ });
359
+ return null;
360
+ }
361
+ }
362
+ /** @internal Test seams — file path resolvers. */
363
+ export function __publicKeyPathForTest(profile) {
364
+ return publicKeyPath(profile);
365
+ }
366
+ export function __privateKeyPathForTest(profile) {
367
+ return privateKeyPath(profile);
368
+ }
369
+ export function __enrollmentMarkerPathForTest(profile) {
370
+ return enrollmentMarkerPath(profile);
371
+ }
372
+ export function __enrollmentBackoffPathForTest(profile) {
373
+ return enrollmentBackoffPath(profile);
374
+ }
375
+ /** Remove the enrollment marker so re-login gets a fresh enrollment. */
376
+ export function clearEnrollmentMarker(profile) {
377
+ for (const markerPath of [enrollmentMarkerPath(profile), enrollmentBackoffPath(profile)]) {
378
+ try {
379
+ fs.unlinkSync(markerPath);
380
+ }
381
+ catch (err) {
382
+ if (err.code !== 'ENOENT') {
383
+ emit('enrollment_marker_clear_failed', false, {
384
+ profile,
385
+ file: path.basename(markerPath),
386
+ message: err?.message ?? 'unknown',
387
+ });
388
+ }
389
+ }
390
+ }
391
+ }
392
+ /**
393
+ * POST the device public key to the server for enrollment.
394
+ *
395
+ * Idempotent across boots — the marker file short-circuits subsequent
396
+ * calls. The marker stores serverUrl + thumbprint so a URL change
397
+ * (staging → prod swap) forces fresh enrollment.
398
+ *
399
+ * Fire-and-forget contract — callers should `.catch()` so a network
400
+ * partition at startup cannot stall the consumer's command path.
401
+ *
402
+ * NOTE: Both `sky` and `oversky` may race to enroll the same shared
403
+ * keypair. The server's `enroll-device-key` route is idempotent on
404
+ * `(machineUuid, publicJwkX)` so a duplicate POST is safe; whichever
405
+ * binary writes its marker file first short-circuits the other.
406
+ */
407
+ export async function enrollWithServer(opts) {
408
+ const { serverUrl, accessToken, machineUuid } = opts;
409
+ if (_state.kind !== 'active') {
410
+ return { enrolled: false, reason: 'not_active' };
411
+ }
412
+ const profile = _state.profile;
413
+ const keypair = _state.keypair;
414
+ const markerPath = enrollmentMarkerPath(profile);
415
+ const backoffPath = enrollmentBackoffPath(profile);
416
+ try {
417
+ if (fs.existsSync(markerPath)) {
418
+ return { enrolled: false, reason: 'already_persisted' };
419
+ }
420
+ }
421
+ catch {
422
+ /* best-effort */
423
+ }
424
+ try {
425
+ const raw = JSON.parse(fs.readFileSync(backoffPath, 'utf-8'));
426
+ const retryAfter = Date.parse(raw.retryAfter || '');
427
+ if (raw.serverUrl === serverUrl &&
428
+ raw.machineUuid === machineUuid &&
429
+ raw.thumbprint === keypair.thumbprint &&
430
+ Number.isFinite(retryAfter) &&
431
+ retryAfter > Date.now()) {
432
+ return { enrolled: false, reason: 'backoff_active' };
433
+ }
434
+ fs.unlinkSync(backoffPath);
435
+ }
436
+ catch (err) {
437
+ if (err.code !== 'ENOENT') {
438
+ // A corrupt/stale marker must never suppress enrollment indefinitely.
439
+ try {
440
+ fs.unlinkSync(backoffPath);
441
+ }
442
+ catch {
443
+ /* best-effort */
444
+ }
445
+ }
446
+ }
447
+ const base = serverUrl.replace(/\/+$/, '');
448
+ const htu = `${base}/api/daemon-resources/enroll-device-key`;
449
+ const proofJws = buildDeviceProof(keypair.privateJwk, keypair.publicJwk, {
450
+ method: 'POST',
451
+ url: htu,
452
+ });
453
+ const body = JSON.stringify({
454
+ machineUuid,
455
+ publicJwkX: keypair.publicJwk.x,
456
+ proofJws,
457
+ });
458
+ // 10-second bound — enrollment is best-effort at startup.
459
+ const ownAbort = new AbortController();
460
+ const timer = setTimeout(() => ownAbort.abort(), 10_000);
461
+ let response;
462
+ try {
463
+ response = await fetch(htu, {
464
+ method: 'POST',
465
+ headers: {
466
+ 'Content-Type': 'application/json',
467
+ Authorization: `Bearer ${accessToken}`,
468
+ },
469
+ body,
470
+ signal: opts.signal ?? ownAbort.signal,
471
+ });
472
+ }
473
+ catch (err) {
474
+ clearTimeout(timer);
475
+ const message = err?.message ?? 'unknown';
476
+ emit('enrollment_network_error', false, {
477
+ profile,
478
+ thumbprint: keypair.thumbprint,
479
+ serverUrl,
480
+ message,
481
+ });
482
+ return { enrolled: false, reason: 'network_error', message };
483
+ }
484
+ finally {
485
+ clearTimeout(timer);
486
+ }
487
+ const responseText = await response.text().catch(() => '');
488
+ if (!response.ok) {
489
+ // Some rejection statuses are expected, non-actionable states rather
490
+ // than failures — log them at info so they don't read as auth errors or
491
+ // spam stderr on every invocation. Other statuses (proof failures, 5xx,
492
+ // etc.) keep the louder warn level for ops visibility.
493
+ // • 404 DAEMON_NOT_FOUND — pre-enrollment window: the daemon row for
494
+ // this (user, machineUuid) pair does not exist yet (fresh install,
495
+ // server swap, or daemon hasn't started). Fires every CLI command
496
+ // in that window.
497
+ // • 403 OPAQUE_AUTH_REFUSED — the enroll route is JWT-only (M11); an
498
+ // opaque `osk_dmn_*` daemon token is refused by design (OSK-1660 #5).
499
+ // Enrollment is the CLI's (JWT) job; the daemon holding an opaque
500
+ // token should not enroll. Guarded at the daemon call site, this is
501
+ // defense-in-depth for any other opaque caller.
502
+ // • 409 already-enrolled — a concurrent binary won the enrollment race
503
+ // for the shared keypair; idempotent, nothing to do.
504
+ const isBenignRejection = (response.status === 404 && responseText.includes('DAEMON_NOT_FOUND')) ||
505
+ (response.status === 403 && responseText.includes('OPAQUE_AUTH_REFUSED')) ||
506
+ response.status === 409;
507
+ if (response.status === 404 && responseText.includes('DAEMON_NOT_FOUND')) {
508
+ try {
509
+ atomicWrite0600(backoffPath, JSON.stringify({
510
+ serverUrl,
511
+ machineUuid,
512
+ thumbprint: keypair.thumbprint,
513
+ retryAfter: new Date(Date.now() + DAEMON_NOT_FOUND_BACKOFF_MS).toISOString(),
514
+ }));
515
+ }
516
+ catch (err) {
517
+ emit('enrollment_backoff_write_failed', false, {
518
+ profile,
519
+ message: err?.message ?? 'unknown',
520
+ });
521
+ }
522
+ }
523
+ emit('enrollment_rejected', false, {
524
+ profile,
525
+ thumbprint: keypair.thumbprint,
526
+ serverUrl,
527
+ status: response.status,
528
+ body: responseText,
529
+ }, { logLevel: isBenignRejection ? 'info' : 'warn' });
530
+ return {
531
+ enrolled: false,
532
+ reason: 'http_error',
533
+ status: response.status,
534
+ body: responseText,
535
+ };
536
+ }
537
+ const marker = JSON.stringify({
538
+ enrolledAt: new Date().toISOString(),
539
+ serverUrl,
540
+ thumbprint: keypair.thumbprint,
541
+ machineUuid,
542
+ });
543
+ try {
544
+ atomicWrite0600(markerPath, marker);
545
+ }
546
+ catch (err) {
547
+ // Non-fatal — server enrolled us; we may re-POST next run, which
548
+ // the server handles idempotently.
549
+ emit('enrollment_marker_write_failed', false, {
550
+ profile,
551
+ thumbprint: keypair.thumbprint,
552
+ message: err?.message ?? 'unknown',
553
+ });
554
+ }
555
+ try {
556
+ fs.unlinkSync(backoffPath);
557
+ }
558
+ catch (err) {
559
+ if (err.code !== 'ENOENT') {
560
+ emit('enrollment_backoff_clear_failed', false, {
561
+ profile,
562
+ message: err?.message ?? 'unknown',
563
+ });
564
+ }
565
+ }
566
+ emit('enrolled_with_server', true, {
567
+ profile,
568
+ thumbprint: keypair.thumbprint,
569
+ serverUrl,
570
+ });
571
+ return { enrolled: true, thumbprint: keypair.thumbprint };
572
+ }