@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,637 @@
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.initCredEnvelope = initCredEnvelope;
9
+ exports.resetCredEnvelope = resetCredEnvelope;
10
+ exports.shutdownCredEnvelope = shutdownCredEnvelope;
11
+ exports.__resetShutdownGuardForTest = __resetShutdownGuardForTest;
12
+ exports.isCredEnvelopeActive = isCredEnvelopeActive;
13
+ exports.describeCredEnvelopeState = describeCredEnvelopeState;
14
+ exports.maybeEncryptForWrite = maybeEncryptForWrite;
15
+ exports.wrapIfActiveOrPassthrough = wrapIfActiveOrPassthrough;
16
+ exports.maybeDecryptOnRead = maybeDecryptOnRead;
17
+ exports.__wrappedDekFilePathForTest = __wrappedDekFilePathForTest;
18
+ /**
19
+ * credentialEnvelopeBridge.ts — composed L12 runtime (init, transforms, lock).
20
+ *
21
+ * Lifts the previously-duplicated `daemon/src/credEnvelope.ts` and
22
+ * `cli/src/lib/cred-envelope.ts` modules into a single shared
23
+ * implementation. The daemon and CLI both consume this module so they
24
+ * land on the SAME on-disk artifacts:
25
+ *
26
+ * <configDir>/profiles/<p>/cred-dek-wrapped — KEK-wrapped DEK
27
+ * <configDir>/profiles/<p>/cred-envelope.lock — proper-lockfile sentinel
28
+ *
29
+ * `<configDir>` is supplied by the consumer via `configureAuthCore({configDir})`
30
+ * — both binaries today resolve to `~/.skrr` so first-enrollment races
31
+ * between `sky` and `oversky` serialize against the same lockfile.
32
+ *
33
+ * NAMING NOTE — the suffix `Bridge` distinguishes this composed runtime
34
+ * (init + sign + persist + telemetry) from the pure-crypto primitive
35
+ * file `credentialEnvelope.ts` it composes.
36
+ *
37
+ * Failure mode contract:
38
+ * - KEK unavailable on this platform: transforms are passthrough; one
39
+ * `cred_envelope.kek_unavailable` telemetry event fires at init.
40
+ * - Init failure: transforms passthrough; never crashes the consumer.
41
+ * - Daemon configures `credEnvelopeFailClosed=true` so writes throw
42
+ * when not active. CLI leaves it unset; legacy `OVERSKY_KEK_REQUIRED=1`
43
+ * env var is still honored as a back-compat trigger.
44
+ */
45
+ const node_fs_1 = __importDefault(require("node:fs"));
46
+ const node_path_1 = __importDefault(require("node:path"));
47
+ const proper_lockfile_1 = __importDefault(require("proper-lockfile"));
48
+ const credentialEnvelope_js_1 = require("./credentialEnvelope.js");
49
+ const index_js_1 = require("./kek/index.js");
50
+ const runtime_js_1 = require("./runtime.js");
51
+ /**
52
+ * Account name the wrapped DEK lives under (file fallback). Keeping the
53
+ * literal here so a future rename surfaces as a single grep target.
54
+ */
55
+ const ACCOUNT_CRED_DEK_WRAPPED = 'cred-dek-wrapped';
56
+ let _state = { kind: 'uninit' };
57
+ /**
58
+ * Sticky flag set by `initCredEnvelope` when the on-disk wrapped DEK
59
+ * exists but unwrap fails under a currently-available KEK — strong
60
+ * evidence the KEK was rotated underneath us. Persists across the init
61
+ * pass so `describeCredEnvelopeState()` and the `oversky status` surface
62
+ * can explain why local credentials were just re-enrolled.
63
+ */
64
+ let _kekRotationDetected = false;
65
+ /** @internal Test seam — force-set state, returns previous. */
66
+ function __setStateForTest(next) {
67
+ const prev = _state;
68
+ _state = next;
69
+ return prev;
70
+ }
71
+ /** @internal Test seam — read state for assertions. */
72
+ function __getStateForTest() {
73
+ return _state;
74
+ }
75
+ /**
76
+ * Per-profile config directory. Uses the consumer-supplied configDir from
77
+ * `configureAuthCore()`; the whole point of this module is shared on-disk
78
+ * artifacts, so daemon/CLI MUST configure the same root.
79
+ */
80
+ function profileConfigDir(profile) {
81
+ return node_path_1.default.join((0, runtime_js_1.getAuthConfigDir)(), 'profiles', profile);
82
+ }
83
+ function wrappedDekFilePath(profile) {
84
+ return node_path_1.default.join(profileConfigDir(profile), ACCOUNT_CRED_DEK_WRAPPED);
85
+ }
86
+ /**
87
+ * Atomic 0600 write — tmp+fsync+rename+dir-fsync. Same shape as the
88
+ * original daemon/CLI implementations so partial-write races between
89
+ * `sky` and `oversky` produce the same on-disk transitions.
90
+ */
91
+ function atomicWrite0600(target, value) {
92
+ const dir = node_path_1.default.dirname(target);
93
+ node_fs_1.default.mkdirSync(dir, { recursive: true, mode: 0o700 });
94
+ const tmp = `${target}.tmp.${process.pid}.${Date.now()}`;
95
+ const fd = node_fs_1.default.openSync(tmp, 'w', 0o600);
96
+ try {
97
+ node_fs_1.default.writeSync(fd, value);
98
+ node_fs_1.default.fsyncSync(fd);
99
+ }
100
+ finally {
101
+ node_fs_1.default.closeSync(fd);
102
+ }
103
+ node_fs_1.default.renameSync(tmp, target);
104
+ try {
105
+ const dirFd = node_fs_1.default.openSync(dir, 'r');
106
+ try {
107
+ node_fs_1.default.fsyncSync(dirFd);
108
+ }
109
+ finally {
110
+ node_fs_1.default.closeSync(dirFd);
111
+ }
112
+ }
113
+ catch {
114
+ /* best-effort — Windows / some BSDs reject dir fsync */
115
+ }
116
+ }
117
+ /**
118
+ * Telemetry shim. Routes through the consumer-supplied logEvent sink
119
+ * (daemon: structured event log; CLI: typically no-op or stderr at DEBUG).
120
+ * Also writes to `getAuthLogger().warn` on failure so an operator running
121
+ * with verbose logs sees the signal even without a configured event sink.
122
+ *
123
+ * The event name passed in is the FULLY-QUALIFIED `cred_envelope.*` form
124
+ * because daemon-side telemetry consumers (`event-log.ts`, dashboards,
125
+ * spec assertions) key off the prefixed name. Do not strip the prefix.
126
+ */
127
+ function emit(eventName, success, metadata) {
128
+ (0, runtime_js_1.emitAuthTelemetry)({
129
+ timestamp: new Date().toISOString(),
130
+ eventType: 'auth',
131
+ success,
132
+ metadata: { event: eventName, ...metadata },
133
+ });
134
+ if (!success) {
135
+ (0, runtime_js_1.getAuthLogger)().warn(`[credEnvelope] ${eventName}`, metadata);
136
+ }
137
+ }
138
+ // ---------------------------------------------------------------------------
139
+ // First-enrollment lockfile (gap G5).
140
+ //
141
+ // Two daemon processes that race to enroll a fresh DEK under the same
142
+ // profile would each generate a different DEK, wrap each, and the second
143
+ // rename-write would silently win — making any envelopes wrapped under
144
+ // the loser's DEK permanently unrecoverable. The lock serializes this
145
+ // window. Mirrors `withAuthLock` in `packages/auth-core/src/refresh.ts`.
146
+ // ---------------------------------------------------------------------------
147
+ const CRED_LOCK_STALE_MS = 30_000;
148
+ const CRED_LOCK_RETRY_MS = 100;
149
+ // Exponential retry capped at 400 ms: 50 retries are ~20 s total, not 5 s.
150
+ const CRED_LOCK_MAX_RETRIES = 50;
151
+ function credEnvelopeLockPath(profile) {
152
+ return node_path_1.default.join(profileConfigDir(profile), 'cred-envelope.lock');
153
+ }
154
+ /**
155
+ * Acquire the cross-process credential-envelope lock for a profile, run
156
+ * the body, release in `finally`. Emits `cred_envelope.lock_acquired`
157
+ * and `cred_envelope.lock_released` so contention shows up in telemetry.
158
+ *
159
+ * The lock file is a sentinel (mode 0600) — `proper-lockfile` locks the
160
+ * sentinel rather than the real wrapped-DEK file because the latter is
161
+ * subject to atomic tmp+rename writes that would race with the lock
162
+ * adapter on some filesystems.
163
+ */
164
+ async function withCredEnvelopeLock(profile, body) {
165
+ const lockPath = credEnvelopeLockPath(profile);
166
+ const dir = node_path_1.default.dirname(lockPath);
167
+ node_fs_1.default.mkdirSync(dir, { recursive: true, mode: 0o700 });
168
+ if (!node_fs_1.default.existsSync(lockPath)) {
169
+ node_fs_1.default.writeFileSync(lockPath, '', { mode: 0o600 });
170
+ }
171
+ const acquireStart = Date.now();
172
+ const release = await proper_lockfile_1.default.lock(lockPath, {
173
+ stale: CRED_LOCK_STALE_MS,
174
+ retries: {
175
+ retries: CRED_LOCK_MAX_RETRIES,
176
+ minTimeout: CRED_LOCK_RETRY_MS,
177
+ maxTimeout: CRED_LOCK_RETRY_MS * 4,
178
+ factor: 1.5,
179
+ },
180
+ });
181
+ const acquiredAt = Date.now();
182
+ emit('cred_envelope.lock_acquired', true, {
183
+ profile,
184
+ waitedMs: acquiredAt - acquireStart,
185
+ });
186
+ try {
187
+ return await body();
188
+ }
189
+ finally {
190
+ const heldForMs = Date.now() - acquiredAt;
191
+ emit('cred_envelope.lock_released', true, {
192
+ profile,
193
+ waitedMs: heldForMs,
194
+ });
195
+ try {
196
+ await release();
197
+ }
198
+ catch {
199
+ /* best-effort — stale-lock fallback handles next-acquire */
200
+ }
201
+ }
202
+ }
203
+ /**
204
+ * Initialize the envelope path. Idempotent — second call when state is
205
+ * non-uninit returns immediately.
206
+ *
207
+ * Order:
208
+ * 1. Probe KEK; unavailable → disabled+kek_unavailable.
209
+ * 2. Try to read+unwrap an existing DEK; success → active.
210
+ * 3. Generate fresh DEK, wrap, persist atomically; → active.
211
+ * 4. Any throw → disabled+init_failed; transforms stay passthrough.
212
+ */
213
+ async function initCredEnvelope(profile) {
214
+ if (_state.kind !== 'uninit')
215
+ return;
216
+ if (typeof profile !== 'string' || profile.length === 0) {
217
+ _state = { kind: 'disabled', reason: 'init_failed' };
218
+ emit('cred_envelope.init_failed', false, { reason: 'empty_profile' });
219
+ return;
220
+ }
221
+ let kek;
222
+ try {
223
+ kek = (0, index_js_1.getKekStrategy)();
224
+ }
225
+ catch (err) {
226
+ _state = { kind: 'disabled', reason: 'platform_unsupported' };
227
+ emit('cred_envelope.init_failed', false, {
228
+ reason: 'kek_resolve_threw',
229
+ message: err?.message ?? 'unknown',
230
+ });
231
+ return;
232
+ }
233
+ let kekAvailable = false;
234
+ try {
235
+ kekAvailable = await kek.available();
236
+ }
237
+ catch {
238
+ kekAvailable = false;
239
+ }
240
+ if (!kekAvailable) {
241
+ _state = { kind: 'disabled', reason: 'kek_unavailable' };
242
+ emit('cred_envelope.kek_unavailable', false, {
243
+ kekId: kek.id,
244
+ profile,
245
+ describe: kek.describe(),
246
+ });
247
+ return;
248
+ }
249
+ const aad = (0, credentialEnvelope_js_1.buildCredentialAad)(profile);
250
+ const targetPath = wrappedDekFilePath(profile);
251
+ // Capture the kek's tier-aware kind once at init so transform-side events
252
+ // can include it without holding a live `kek` reference. macOS sets `kind`
253
+ // only after the first wrap/unwrap; null here is honest.
254
+ const captureKind = () => typeof kek.kind === 'string' || kek.kind === null ? (kek.kind ?? null) : null;
255
+ // Existing wrapped DEKs are immutable between enrollments and are published
256
+ // with atomic rename. Reading and unwrapping one therefore does not need the
257
+ // first-enrollment mutex. Keeping this hot path outside the lock matters for
258
+ // short-lived CLI processes: a burst of read-only `sky` commands used to
259
+ // serialize here, and contenders could outlive the CLI's bounded auth
260
+ // prefetch even though there was no write for them to coordinate.
261
+ //
262
+ // Any miss or unwrap failure falls through to the locked path below. That
263
+ // path re-reads the file after acquiring the mutex before it decides whether
264
+ // to enroll or rotate, so a concurrent writer can never make this optimistic
265
+ // read create a second DEK or produce a false rotation signal.
266
+ try {
267
+ if (!kek.unwrapExisting)
268
+ throw new Error('KEK has no read-only unwrap path');
269
+ const wrappedBytes = node_fs_1.default.readFileSync(targetPath);
270
+ const candidate = await kek.unwrapExisting(wrappedBytes, aad);
271
+ if (candidate.length === 32) {
272
+ _state = {
273
+ kind: 'active',
274
+ dek: candidate,
275
+ kekId: kek.id,
276
+ kekKind: captureKind(),
277
+ profile,
278
+ };
279
+ emit('cred_envelope.active', true, {
280
+ kekId: kek.id,
281
+ kekKind: captureKind(),
282
+ profile,
283
+ existingDekFastPath: true,
284
+ });
285
+ return;
286
+ }
287
+ candidate.fill(0);
288
+ }
289
+ catch {
290
+ // The locked path owns diagnostics and recovery. In particular, it
291
+ // distinguishes a stable unwrap failure (probable KEK rotation) from a
292
+ // file that another process replaced between this read and lock acquire.
293
+ }
294
+ let dek = null;
295
+ let unwrapAttemptedAndFailed = false;
296
+ await withCredEnvelopeLock(profile, async () => {
297
+ // Re-stat after acquiring the lock — a sibling sky/oversky may have
298
+ // enrolled while we were waiting. Without this, both processes would
299
+ // proceed to "first-run enrollment" and the second writer's DEK
300
+ // would silently win, orphaning the first writer's DEK and every
301
+ // credential it has already wrapped.
302
+ let wrappedBytes = null;
303
+ try {
304
+ if (node_fs_1.default.existsSync(targetPath)) {
305
+ wrappedBytes = node_fs_1.default.readFileSync(targetPath);
306
+ }
307
+ }
308
+ catch (err) {
309
+ emit('cred_envelope.init_failed', false, {
310
+ reason: 'wrapped_dek_read_threw',
311
+ message: err?.message ?? 'unknown',
312
+ });
313
+ wrappedBytes = null;
314
+ }
315
+ if (wrappedBytes !== null) {
316
+ try {
317
+ const candidate = await kek.unwrap(wrappedBytes, aad);
318
+ if (candidate.length !== 32) {
319
+ emit('cred_envelope.init_failed', false, {
320
+ reason: 'unwrapped_dek_wrong_length',
321
+ length: candidate.length,
322
+ kekKind: captureKind(),
323
+ });
324
+ unwrapAttemptedAndFailed = true;
325
+ }
326
+ else {
327
+ dek = candidate;
328
+ }
329
+ }
330
+ catch (err) {
331
+ emit('cred_envelope.unwrap.failed', false, {
332
+ reason: 'wrapped_dek_unwrap_threw',
333
+ kekId: kek.id,
334
+ kekKind: captureKind(),
335
+ message: err?.message ?? 'unknown',
336
+ });
337
+ unwrapAttemptedAndFailed = true;
338
+ }
339
+ }
340
+ // KEK rotation detection (gap G4). If the wrapped file was present,
341
+ // KEK reported `available=true`, but unwrap threw — the most likely
342
+ // explanation is the KEK was rotated underneath us. Surface this as
343
+ // a distinct event so dashboards can count rotations separately
344
+ // from genuinely-corrupt blobs, while still falling through to a
345
+ // fresh enrollment so the daemon stays operational.
346
+ if (unwrapAttemptedAndFailed && dek === null) {
347
+ _kekRotationDetected = true;
348
+ emit('cred_envelope.kek_rotated', false, {
349
+ kekId: kek.id,
350
+ kekKind: captureKind(),
351
+ profile,
352
+ message: 'wrapped DEK present but unwrap failed under available KEK; treating as rotation and re-enrolling',
353
+ });
354
+ }
355
+ // First-run enrollment if no DEK yet.
356
+ if (dek === null) {
357
+ try {
358
+ dek = (0, credentialEnvelope_js_1.generateDek)();
359
+ const wrapped = await kek.wrap(dek, aad);
360
+ atomicWrite0600(targetPath, wrapped);
361
+ emit('cred_envelope.dek_enrolled', true, {
362
+ kekId: kek.id,
363
+ kekKind: captureKind(),
364
+ profile,
365
+ ...(unwrapAttemptedAndFailed ? { reEnrolledAfterRotation: true } : {}),
366
+ });
367
+ }
368
+ catch (err) {
369
+ _state = { kind: 'disabled', reason: 'init_failed' };
370
+ emit('cred_envelope.init_failed', false, {
371
+ reason: 'enrollment_failed',
372
+ kekId: kek.id,
373
+ kekKind: captureKind(),
374
+ message: err?.message ?? 'unknown',
375
+ });
376
+ return;
377
+ }
378
+ }
379
+ _state = {
380
+ kind: 'active',
381
+ dek: dek,
382
+ kekId: kek.id,
383
+ kekKind: captureKind(),
384
+ profile,
385
+ };
386
+ emit('cred_envelope.active', true, {
387
+ kekId: kek.id,
388
+ kekKind: captureKind(),
389
+ profile,
390
+ ...(unwrapAttemptedAndFailed ? { reEnrolledAfterRotation: true } : {}),
391
+ });
392
+ });
393
+ }
394
+ /**
395
+ * Reset state. With `clearOnDisk` deletes the wrapped DEK file too —
396
+ * used by `oversky logout` / `sky logout`. Zeroes the in-memory DEK
397
+ * before dropping state for forward secrecy on the active credential.
398
+ */
399
+ async function resetCredEnvelope(opts) {
400
+ if (opts?.clearOnDisk && opts.profile) {
401
+ try {
402
+ node_fs_1.default.unlinkSync(wrappedDekFilePath(opts.profile));
403
+ }
404
+ catch (err) {
405
+ if (err.code !== 'ENOENT') {
406
+ emit('cred_envelope.clear_failed', false, {
407
+ profile: opts.profile,
408
+ message: err?.message ?? 'unknown',
409
+ });
410
+ }
411
+ }
412
+ }
413
+ if (_state.kind === 'active') {
414
+ _state.dek.fill(0);
415
+ }
416
+ _state = { kind: 'uninit' };
417
+ _kekRotationDetected = false;
418
+ }
419
+ /**
420
+ * Idempotent shutdown — must be called from the daemon's graceful-exit
421
+ * handler (SIGTERM / SIGINT). Zeroes the in-memory DEK and drops the
422
+ * platform KEK caches so neither lingers in process memory after the
423
+ * exit path begins. The KEK-cache zeroize hooks come from the platform
424
+ * registry exposed by `@skrr-ai/auth-core` (`zeroizeKekCaches()`); each
425
+ * platform module registers its hook at module-load time.
426
+ *
427
+ * Synchronous so the caller's exit path can run it without managing a
428
+ * promise chain. Distinct from `resetCredEnvelope` because:
429
+ * - shutdown does NOT delete on-disk state (the next process restart
430
+ * should still find the wrapped DEK).
431
+ * - shutdown ALSO zeroes platform KEK caches (resetCredEnvelope leaves
432
+ * them resident so the next reactivation can reuse them).
433
+ */
434
+ let _shutdownDone = false;
435
+ function shutdownCredEnvelope() {
436
+ if (_shutdownDone)
437
+ return;
438
+ _shutdownDone = true;
439
+ try {
440
+ if (_state.kind === 'active') {
441
+ _state.dek.fill(0);
442
+ }
443
+ }
444
+ catch {
445
+ /* swallow — shutdown is best-effort */
446
+ }
447
+ try {
448
+ (0, index_js_1.zeroizeKekCaches)();
449
+ }
450
+ catch {
451
+ /* swallow — shutdown is best-effort */
452
+ }
453
+ _state = { kind: 'uninit' };
454
+ _kekRotationDetected = false;
455
+ }
456
+ /** @internal Test seam — re-arm `shutdownCredEnvelope` so it can run again. */
457
+ function __resetShutdownGuardForTest() {
458
+ _shutdownDone = false;
459
+ }
460
+ /**
461
+ * True iff the envelope path is initialized AND active. Hot-path
462
+ * predicate used by sync transforms; never throws.
463
+ */
464
+ function isCredEnvelopeActive() {
465
+ return _state.kind === 'active';
466
+ }
467
+ /**
468
+ * Operator-readable summary of envelope state. Used by `oversky status`
469
+ * and CLI debug commands. Never includes the DEK or any key material.
470
+ *
471
+ * `kekRotated` is true when the most recent init pass detected a wrapped
472
+ * DEK that could not be unwrapped under the currently-available KEK and
473
+ * had to fall back to a fresh enrollment. Operators see this in
474
+ * `oversky status`; clears on the next clean `resetCredEnvelope`.
475
+ */
476
+ function describeCredEnvelopeState() {
477
+ if (_state.kind === 'disabled') {
478
+ return {
479
+ kind: 'disabled',
480
+ reason: _state.reason,
481
+ ...(_kekRotationDetected ? { kekRotated: true } : {}),
482
+ };
483
+ }
484
+ if (_state.kind === 'active') {
485
+ return {
486
+ kind: 'active',
487
+ kekId: _state.kekId,
488
+ kekKind: _state.kekKind,
489
+ profile: _state.profile,
490
+ ...(_kekRotationDetected ? { kekRotated: true } : {}),
491
+ };
492
+ }
493
+ return { kind: 'uninit' };
494
+ }
495
+ /**
496
+ * Sync write transform. Returns the wrapped serialized form when active.
497
+ *
498
+ * Behavior when not active:
499
+ * - If `credEnvelopeFailClosed` was set via `configureAuthCore()` (or
500
+ * `OVERSKY_KEK_REQUIRED=1` is set as a back-compat trigger), emits
501
+ * `cred_envelope.wrap.refused` and throws — the caller must not
502
+ * write plaintext.
503
+ * - Otherwise, returns the plaintext (legacy fail-open behavior used
504
+ * by CLI today during dark-ship rollout).
505
+ *
506
+ * Empty-string input is universally passthrough — there is no secret
507
+ * to protect.
508
+ */
509
+ function maybeEncryptForWrite(plaintext) {
510
+ if (typeof plaintext !== 'string' || plaintext.length === 0)
511
+ return plaintext;
512
+ if (_state.kind !== 'active') {
513
+ const stateKind = _state.kind;
514
+ const reason = _state.kind === 'disabled' ? _state.reason : undefined;
515
+ if ((0, runtime_js_1.getCredEnvelopeFailClosed)()) {
516
+ emit('cred_envelope.wrap.refused', false, {
517
+ stateKind,
518
+ ...(reason !== undefined ? { reason } : {}),
519
+ });
520
+ throw new Error(`cred_envelope: envelope path is ${stateKind}` +
521
+ (reason ? ` (${reason})` : '') +
522
+ '; refusing to write credential as plaintext');
523
+ }
524
+ return plaintext;
525
+ }
526
+ try {
527
+ const aad = (0, credentialEnvelope_js_1.buildCredentialAad)(_state.profile);
528
+ const sealed = (0, credentialEnvelope_js_1.encrypt)(plaintext, _state.dek, aad);
529
+ const out = (0, credentialEnvelope_js_1.serialize)(sealed);
530
+ emit('cred_envelope.wrap.success', true, {
531
+ kekId: _state.kekId,
532
+ kekKind: _state.kekKind,
533
+ });
534
+ return out;
535
+ }
536
+ catch (err) {
537
+ const message = err?.message ?? 'unknown';
538
+ emit('cred_envelope.wrap.failure', false, {
539
+ kekId: _state.kekId,
540
+ kekKind: _state.kekKind,
541
+ message,
542
+ });
543
+ if ((0, runtime_js_1.getCredEnvelopeFailClosed)()) {
544
+ emit('cred_envelope.wrap.refused', false, {
545
+ stateKind: 'active',
546
+ kekId: _state.kekId,
547
+ kekKind: _state.kekKind,
548
+ message,
549
+ });
550
+ throw err;
551
+ }
552
+ return plaintext;
553
+ }
554
+ }
555
+ /**
556
+ * Always-fail-open variant of `maybeEncryptForWrite` for callers that
557
+ * cannot afford to abort their write when the envelope path is inactive
558
+ * (today: device-identity persistence, which writes the private key as
559
+ * plaintext JSON before the L12 KEK ships per-platform). The wire form
560
+ * is identical to `maybeEncryptForWrite` when active (envelope-prefixed)
561
+ * and identical to the input when not.
562
+ *
563
+ * Distinct from `maybeEncryptForWrite` because that helper honors the
564
+ * daemon's `credEnvelopeFailClosed: true` policy — designed for callers
565
+ * that MUST refuse a plaintext write rather than silently degrade.
566
+ */
567
+ function wrapIfActiveOrPassthrough(plaintext) {
568
+ if (typeof plaintext !== 'string' || plaintext.length === 0)
569
+ return plaintext;
570
+ if (_state.kind !== 'active')
571
+ return plaintext;
572
+ try {
573
+ const aad = (0, credentialEnvelope_js_1.buildCredentialAad)(_state.profile);
574
+ const sealed = (0, credentialEnvelope_js_1.encrypt)(plaintext, _state.dek, aad);
575
+ const out = (0, credentialEnvelope_js_1.serialize)(sealed);
576
+ emit('cred_envelope.wrap.success', true, {
577
+ kekId: _state.kekId,
578
+ kekKind: _state.kekKind,
579
+ });
580
+ return out;
581
+ }
582
+ catch (err) {
583
+ emit('cred_envelope.wrap.failure', false, {
584
+ kekId: _state.kekId,
585
+ kekKind: _state.kekKind,
586
+ message: err?.message ?? 'unknown',
587
+ });
588
+ return plaintext;
589
+ }
590
+ }
591
+ function maybeDecryptOnRead(stored) {
592
+ if (typeof stored !== 'string' || stored.length === 0) {
593
+ return { plaintext: stored, needsMigration: false };
594
+ }
595
+ if ((0, credentialEnvelope_js_1.isEnvelopeString)(stored)) {
596
+ if (_state.kind !== 'active') {
597
+ emit('cred_envelope.read.envelope_but_inactive', false, {
598
+ stateKind: _state.kind,
599
+ });
600
+ return { plaintext: null, needsMigration: false };
601
+ }
602
+ const sealed = (0, credentialEnvelope_js_1.deserialize)(stored);
603
+ if (!sealed) {
604
+ emit('cred_envelope.read.malformed', false, {
605
+ kekId: _state.kekId,
606
+ kekKind: _state.kekKind,
607
+ });
608
+ return { plaintext: null, needsMigration: false };
609
+ }
610
+ try {
611
+ const aad = (0, credentialEnvelope_js_1.buildCredentialAad)(_state.profile);
612
+ const pt = (0, credentialEnvelope_js_1.decrypt)(sealed, _state.dek, aad).toString('utf-8');
613
+ return { plaintext: pt, needsMigration: false };
614
+ }
615
+ catch (err) {
616
+ emit('cred_envelope.read.decrypt_failed', false, {
617
+ kekId: _state.kekId,
618
+ kekKind: _state.kekKind,
619
+ message: err?.message ?? 'unknown',
620
+ });
621
+ return { plaintext: null, needsMigration: false };
622
+ }
623
+ }
624
+ // Legacy plaintext path.
625
+ if (_state.kind === 'active') {
626
+ emit('cred_envelope.unwrap.fallback_to_legacy', true, {
627
+ kekId: _state.kekId,
628
+ kekKind: _state.kekKind,
629
+ });
630
+ return { plaintext: stored, needsMigration: true };
631
+ }
632
+ return { plaintext: stored, needsMigration: false };
633
+ }
634
+ /** @internal Test seam — wrapped-DEK path resolver. */
635
+ function __wrappedDekFilePathForTest(profile) {
636
+ return wrappedDekFilePath(profile);
637
+ }