@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.
- package/README.md +21 -0
- package/dist/cjs/aead.d.ts +24 -0
- package/dist/cjs/aead.js +65 -0
- package/dist/cjs/authExpiredDetector.d.ts +65 -0
- package/dist/cjs/authExpiredDetector.js +89 -0
- package/dist/cjs/authHelper.d.ts +51 -0
- package/dist/cjs/authHelper.js +321 -0
- package/dist/cjs/credentialEnvelope.d.ts +85 -0
- package/dist/cjs/credentialEnvelope.js +230 -0
- package/dist/cjs/credentialEnvelopeBridge.d.ts +116 -0
- package/dist/cjs/credentialEnvelopeBridge.js +637 -0
- package/dist/cjs/credentialResolver.d.ts +278 -0
- package/dist/cjs/credentialResolver.js +423 -0
- package/dist/cjs/daemonPair.d.ts +172 -0
- package/dist/cjs/daemonPair.js +495 -0
- package/dist/cjs/daemonScopes.d.ts +74 -0
- package/dist/cjs/daemonScopes.js +84 -0
- package/dist/cjs/deviceIdentityBridge.d.ts +113 -0
- package/dist/cjs/deviceIdentityBridge.js +591 -0
- package/dist/cjs/deviceKey.d.ts +167 -0
- package/dist/cjs/deviceKey.js +332 -0
- package/dist/cjs/fdToken.d.ts +17 -0
- package/dist/cjs/fdToken.js +238 -0
- package/dist/cjs/harnessTrust.d.ts +72 -0
- package/dist/cjs/harnessTrust.js +103 -0
- package/dist/cjs/headers-helper.d.ts +25 -0
- package/dist/cjs/headers-helper.js +323 -0
- package/dist/cjs/index.d.ts +37 -0
- package/dist/cjs/index.js +305 -0
- package/dist/cjs/jwtUtils.d.ts +35 -0
- package/dist/cjs/jwtUtils.js +61 -0
- package/dist/cjs/kek/index.d.ts +89 -0
- package/dist/cjs/kek/index.js +208 -0
- package/dist/cjs/kek/linux.d.ts +92 -0
- package/dist/cjs/kek/linux.js +596 -0
- package/dist/cjs/kek/macos.d.ts +112 -0
- package/dist/cjs/kek/macos.js +599 -0
- package/dist/cjs/kek/types.d.ts +111 -0
- package/dist/cjs/kek/types.js +43 -0
- package/dist/cjs/kek/windows.d.ts +71 -0
- package/dist/cjs/kek/windows.js +350 -0
- package/dist/cjs/kek/zeroize-registry.d.ts +44 -0
- package/dist/cjs/kek/zeroize-registry.js +63 -0
- package/dist/cjs/legacyStatePreflight.d.ts +67 -0
- package/dist/cjs/legacyStatePreflight.js +78 -0
- package/dist/cjs/localIdentity.d.ts +54 -0
- package/dist/cjs/localIdentity.js +57 -0
- package/dist/cjs/loginLocalhost.d.ts +115 -0
- package/dist/cjs/loginLocalhost.js +368 -0
- package/dist/cjs/loginWithLocalhost.d.ts +131 -0
- package/dist/cjs/loginWithLocalhost.js +359 -0
- package/dist/cjs/machineId.d.ts +35 -0
- package/dist/cjs/machineId.js +239 -0
- package/dist/cjs/messages.d.ts +9 -0
- package/dist/cjs/messages.js +44 -0
- package/dist/cjs/package.json +3 -0
- package/dist/cjs/pkce.d.ts +62 -0
- package/dist/cjs/pkce.js +158 -0
- package/dist/cjs/recoveryCode.d.ts +61 -0
- package/dist/cjs/recoveryCode.js +213 -0
- package/dist/cjs/refresh.d.ts +215 -0
- package/dist/cjs/refresh.js +877 -0
- package/dist/cjs/refreshClassification.d.ts +53 -0
- package/dist/cjs/refreshClassification.js +105 -0
- package/dist/cjs/refreshScheduler.d.ts +53 -0
- package/dist/cjs/refreshScheduler.js +332 -0
- package/dist/cjs/runtime.d.ts +149 -0
- package/dist/cjs/runtime.js +136 -0
- package/dist/cjs/spawnEnv.d.ts +76 -0
- package/dist/cjs/spawnEnv.js +153 -0
- package/dist/cjs/ttlParser.d.ts +54 -0
- package/dist/cjs/ttlParser.js +87 -0
- package/dist/cjs/types.d.ts +91 -0
- package/dist/cjs/types.js +26 -0
- package/dist/esm/aead.d.ts +24 -0
- package/dist/esm/aead.js +57 -0
- package/dist/esm/authExpiredDetector.d.ts +65 -0
- package/dist/esm/authExpiredDetector.js +85 -0
- package/dist/esm/authHelper.d.ts +51 -0
- package/dist/esm/authHelper.js +311 -0
- package/dist/esm/credentialEnvelope.d.ts +85 -0
- package/dist/esm/credentialEnvelope.js +213 -0
- package/dist/esm/credentialEnvelopeBridge.d.ts +116 -0
- package/dist/esm/credentialEnvelopeBridge.js +620 -0
- package/dist/esm/credentialResolver.d.ts +278 -0
- package/dist/esm/credentialResolver.js +414 -0
- package/dist/esm/daemonPair.d.ts +172 -0
- package/dist/esm/daemonPair.js +476 -0
- package/dist/esm/daemonScopes.d.ts +74 -0
- package/dist/esm/daemonScopes.js +80 -0
- package/dist/esm/deviceIdentityBridge.d.ts +113 -0
- package/dist/esm/deviceIdentityBridge.js +572 -0
- package/dist/esm/deviceKey.d.ts +167 -0
- package/dist/esm/deviceKey.js +320 -0
- package/dist/esm/fdToken.d.ts +17 -0
- package/dist/esm/fdToken.js +231 -0
- package/dist/esm/harnessTrust.d.ts +72 -0
- package/dist/esm/harnessTrust.js +97 -0
- package/dist/esm/headers-helper.d.ts +25 -0
- package/dist/esm/headers-helper.js +316 -0
- package/dist/esm/index.d.ts +37 -0
- package/dist/esm/index.js +144 -0
- package/dist/esm/jwtUtils.d.ts +35 -0
- package/dist/esm/jwtUtils.js +57 -0
- package/dist/esm/kek/index.d.ts +89 -0
- package/dist/esm/kek/index.js +162 -0
- package/dist/esm/kek/linux.d.ts +92 -0
- package/dist/esm/kek/linux.js +585 -0
- package/dist/esm/kek/macos.d.ts +112 -0
- package/dist/esm/kek/macos.js +586 -0
- package/dist/esm/kek/types.d.ts +111 -0
- package/dist/esm/kek/types.js +39 -0
- package/dist/esm/kek/windows.d.ts +71 -0
- package/dist/esm/kek/windows.js +344 -0
- package/dist/esm/kek/zeroize-registry.d.ts +44 -0
- package/dist/esm/kek/zeroize-registry.js +58 -0
- package/dist/esm/legacyStatePreflight.d.ts +67 -0
- package/dist/esm/legacyStatePreflight.js +71 -0
- package/dist/esm/localIdentity.d.ts +54 -0
- package/dist/esm/localIdentity.js +54 -0
- package/dist/esm/loginLocalhost.d.ts +115 -0
- package/dist/esm/loginLocalhost.js +355 -0
- package/dist/esm/loginWithLocalhost.d.ts +131 -0
- package/dist/esm/loginWithLocalhost.js +353 -0
- package/dist/esm/machineId.d.ts +35 -0
- package/dist/esm/machineId.js +231 -0
- package/dist/esm/messages.d.ts +9 -0
- package/dist/esm/messages.js +40 -0
- package/dist/esm/pkce.d.ts +62 -0
- package/dist/esm/pkce.js +148 -0
- package/dist/esm/recoveryCode.d.ts +61 -0
- package/dist/esm/recoveryCode.js +207 -0
- package/dist/esm/refresh.d.ts +215 -0
- package/dist/esm/refresh.js +863 -0
- package/dist/esm/refreshClassification.d.ts +53 -0
- package/dist/esm/refreshClassification.js +106 -0
- package/dist/esm/refreshScheduler.d.ts +53 -0
- package/dist/esm/refreshScheduler.js +329 -0
- package/dist/esm/runtime.d.ts +149 -0
- package/dist/esm/runtime.js +121 -0
- package/dist/esm/spawnEnv.d.ts +76 -0
- package/dist/esm/spawnEnv.js +149 -0
- package/dist/esm/ttlParser.d.ts +54 -0
- package/dist/esm/ttlParser.js +83 -0
- package/dist/esm/types.d.ts +91 -0
- package/dist/esm/types.js +21 -0
- 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
|
+
}
|