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