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