@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,311 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* authHelper.ts — invocation contract for `OVERSKY_AUTH_HELPER`.
|
|
3
|
+
*
|
|
4
|
+
* The helper is a user-supplied executable that prints a fresh token to
|
|
5
|
+
* stdout. It exists so operators with rotating credentials (Vault, AWS
|
|
6
|
+
* Secrets Manager, internal SSO proxy) can plug in without OverSky needing
|
|
7
|
+
* to know how their secret store works. The contract mirrors Claude Code's
|
|
8
|
+
* `apiKeyHelper`.
|
|
9
|
+
*
|
|
10
|
+
* Wire protocol:
|
|
11
|
+
* - `OVERSKY_AUTH_HELPER` env points at an absolute, executable path.
|
|
12
|
+
* - We `spawn(path, [], { shell: false })` — never via shell, so no
|
|
13
|
+
* argument injection regardless of what's in the env value.
|
|
14
|
+
* - The helper writes EITHER a bare JWT to stdout, OR a JSON object
|
|
15
|
+
* `{ "token": "...", "expiresAt"?: <ms epoch> }`.
|
|
16
|
+
* - Stderr is captured for diagnostic logging only — never returned as
|
|
17
|
+
* token material.
|
|
18
|
+
* - Stdout cap: 64 KiB (`MAX_OUTPUT_BYTES`). Real JWTs are < 4 KiB.
|
|
19
|
+
* - Default timeout: 10 s (`OVERSKY_AUTH_HELPER_TIMEOUT_MS` overrides).
|
|
20
|
+
*
|
|
21
|
+
* Safety invariants:
|
|
22
|
+
* - Relative paths rejected — explicit absolute path required.
|
|
23
|
+
* - Non-executable file rejected (any of u/g/o exec bits must be set).
|
|
24
|
+
* - Non-zero exit, timeout, oversized output, malformed JSON → null.
|
|
25
|
+
* - On any failure the resolver chain falls through to the next slot;
|
|
26
|
+
* we never throw from a helper failure.
|
|
27
|
+
* - L6.3 (cold-cache failure sentinel) lives one layer up in
|
|
28
|
+
* `credentialResolver.ts` — when the SPAWN ATTEMPT itself fails on a
|
|
29
|
+
* cold cache the resolver caches a typed `{kind:'failed'}` sentinel
|
|
30
|
+
* to prevent silent fall-through to OAuth.
|
|
31
|
+
*
|
|
32
|
+
* Kill switch: `OVERSKY_DISABLE_AUTH_HELPER=1` short-circuits to null.
|
|
33
|
+
*/
|
|
34
|
+
import { spawn } from 'node:child_process';
|
|
35
|
+
import fs from 'node:fs';
|
|
36
|
+
import path from 'node:path';
|
|
37
|
+
import { getAuthLogger } from './runtime.js';
|
|
38
|
+
const MAX_OUTPUT_BYTES = 64 * 1024;
|
|
39
|
+
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
40
|
+
const DEFAULT_TTL_MS = 5 * 60_000;
|
|
41
|
+
/**
|
|
42
|
+
* L6.3 — failure outcomes that should TRIGGER the cold-cache sentinel.
|
|
43
|
+
* Validation failures (`disabled`, `invalid-path`, `not-executable`) are
|
|
44
|
+
* deliberately excluded: those mean "helper isn't really wired up" and
|
|
45
|
+
* the resolver chain should fall through to the next slot, not fail
|
|
46
|
+
* closed.
|
|
47
|
+
*
|
|
48
|
+
* Exported as a `const` set so the resolver can do
|
|
49
|
+
* `SENTINEL_TRIGGERING_FAILURES.has(reason)` without re-encoding the
|
|
50
|
+
* policy in two places.
|
|
51
|
+
*/
|
|
52
|
+
export const SENTINEL_TRIGGERING_FAILURES = new Set([
|
|
53
|
+
'spawn-error',
|
|
54
|
+
'timeout',
|
|
55
|
+
'non-zero-exit',
|
|
56
|
+
'oversized',
|
|
57
|
+
'empty-output',
|
|
58
|
+
'parse-error',
|
|
59
|
+
]);
|
|
60
|
+
/**
|
|
61
|
+
* Diagnostic outcome of the LAST `invokeAuthHelper()` call. The resolver
|
|
62
|
+
* reads this immediately after the call to decide whether to cache a
|
|
63
|
+
* cold-cache failure sentinel.
|
|
64
|
+
*
|
|
65
|
+
* Module-level (not part of the function return) because changing
|
|
66
|
+
* `invokeAuthHelper`'s return type would ripple through every existing
|
|
67
|
+
* caller and test. Reading it BEFORE another invocation is racy in
|
|
68
|
+
* theory but safe in practice — the resolver awaits the same promise it
|
|
69
|
+
* read this from.
|
|
70
|
+
*/
|
|
71
|
+
let _lastFailureReason = null;
|
|
72
|
+
export function getLastAuthHelperFailureReason() {
|
|
73
|
+
return _lastFailureReason;
|
|
74
|
+
}
|
|
75
|
+
/** @internal test-only — clear the failure-reason latch between cases. */
|
|
76
|
+
export function __resetAuthHelperFailureReasonForTest() {
|
|
77
|
+
_lastFailureReason = null;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Invoke the helper script and return its parsed token, or null on any
|
|
81
|
+
* failure. Never throws.
|
|
82
|
+
*/
|
|
83
|
+
export async function invokeAuthHelper(scriptPath, options = {}) {
|
|
84
|
+
const log = getAuthLogger();
|
|
85
|
+
// Reset on entry so a successful call clears any prior failure latch.
|
|
86
|
+
_lastFailureReason = null;
|
|
87
|
+
if (process.env.OVERSKY_DISABLE_AUTH_HELPER === '1') {
|
|
88
|
+
log.debug?.('[authHelper] OVERSKY_DISABLE_AUTH_HELPER=1 — skipping helper');
|
|
89
|
+
_lastFailureReason = 'disabled';
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
if (!scriptPath || !path.isAbsolute(scriptPath)) {
|
|
93
|
+
log.error(`[authHelper] OVERSKY_AUTH_HELPER must be an absolute path; got "${scriptPath}"`);
|
|
94
|
+
_lastFailureReason = 'invalid-path';
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
let stat;
|
|
98
|
+
try {
|
|
99
|
+
stat = fs.statSync(scriptPath);
|
|
100
|
+
}
|
|
101
|
+
catch (err) {
|
|
102
|
+
log.error(`[authHelper] OVERSKY_AUTH_HELPER not found at "${scriptPath}": ${formatErr(err)}`);
|
|
103
|
+
_lastFailureReason = 'invalid-path';
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
if (!stat.isFile()) {
|
|
107
|
+
log.error(`[authHelper] OVERSKY_AUTH_HELPER "${scriptPath}" is not a regular file`);
|
|
108
|
+
_lastFailureReason = 'invalid-path';
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
if ((stat.mode & 0o111) === 0) {
|
|
112
|
+
log.error(`[authHelper] OVERSKY_AUTH_HELPER "${scriptPath}" is not executable`);
|
|
113
|
+
_lastFailureReason = 'not-executable';
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
const timeoutMs = options.timeoutMs ?? parseTimeoutMs();
|
|
117
|
+
return new Promise((resolve) => {
|
|
118
|
+
const stdoutChunks = [];
|
|
119
|
+
const stderrChunks = [];
|
|
120
|
+
let stdoutBytes = 0;
|
|
121
|
+
let stderrBytes = 0;
|
|
122
|
+
let truncated = false;
|
|
123
|
+
let killedForTimeout = false;
|
|
124
|
+
let resolved = false;
|
|
125
|
+
const settle = (result) => {
|
|
126
|
+
if (resolved)
|
|
127
|
+
return;
|
|
128
|
+
resolved = true;
|
|
129
|
+
clearTimeout(timer);
|
|
130
|
+
resolve(result);
|
|
131
|
+
};
|
|
132
|
+
let child;
|
|
133
|
+
try {
|
|
134
|
+
child = spawn(scriptPath, [], {
|
|
135
|
+
shell: false,
|
|
136
|
+
// detached: true makes the child its own process-group leader so
|
|
137
|
+
// we can SIGKILL the whole group on timeout. Without this, killing
|
|
138
|
+
// the script's shell leaves grandchildren (sleep, curl, vault) as
|
|
139
|
+
// orphans holding stdio open — `close` would never fire and the
|
|
140
|
+
// daemon would hang past the timeout window.
|
|
141
|
+
detached: true,
|
|
142
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
catch (err) {
|
|
146
|
+
log.error(`[authHelper] spawn failed: ${formatErr(err)}`);
|
|
147
|
+
_lastFailureReason = 'spawn-error';
|
|
148
|
+
settle(null);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
const timer = setTimeout(() => {
|
|
152
|
+
killedForTimeout = true;
|
|
153
|
+
log.warn(`[authHelper] helper timed out after ${timeoutMs}ms — killing`);
|
|
154
|
+
// Kill the entire process group so subprocess trees (a helper that
|
|
155
|
+
// shells out to vault/aws/curl) don't outlive the timeout. The
|
|
156
|
+
// negative pid form targets the process group; falls back to a
|
|
157
|
+
// single-process kill on platforms or shapes where that fails.
|
|
158
|
+
try {
|
|
159
|
+
if (typeof child.pid === 'number') {
|
|
160
|
+
process.kill(-child.pid, 'SIGKILL');
|
|
161
|
+
}
|
|
162
|
+
else {
|
|
163
|
+
child.kill('SIGKILL');
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
try {
|
|
168
|
+
child.kill('SIGKILL');
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
/* ignore */
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
// Tear down stdio so any lingering pipe holders don't keep us hung.
|
|
175
|
+
try {
|
|
176
|
+
child.stdout?.destroy();
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
/* ignore */
|
|
180
|
+
}
|
|
181
|
+
try {
|
|
182
|
+
child.stderr?.destroy();
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
/* ignore */
|
|
186
|
+
}
|
|
187
|
+
_lastFailureReason = 'timeout';
|
|
188
|
+
// Settle immediately — do not wait for `close`. Orphan grandchildren
|
|
189
|
+
// may keep the close event pending indefinitely; the resolver chain
|
|
190
|
+
// must move on within the configured timeout.
|
|
191
|
+
settle(null);
|
|
192
|
+
}, timeoutMs);
|
|
193
|
+
if (typeof timer.unref === 'function')
|
|
194
|
+
timer.unref();
|
|
195
|
+
child.stdout.on('data', (chunk) => {
|
|
196
|
+
stdoutBytes += chunk.length;
|
|
197
|
+
if (stdoutBytes > MAX_OUTPUT_BYTES) {
|
|
198
|
+
truncated = true;
|
|
199
|
+
try {
|
|
200
|
+
child.kill('SIGKILL');
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
/* ignore */
|
|
204
|
+
}
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
stdoutChunks.push(chunk);
|
|
208
|
+
});
|
|
209
|
+
child.stderr.on('data', (chunk) => {
|
|
210
|
+
// Defensive cap on stderr too (a chatty helper shouldn't OOM the daemon).
|
|
211
|
+
if (stderrBytes < MAX_OUTPUT_BYTES) {
|
|
212
|
+
stderrChunks.push(chunk);
|
|
213
|
+
stderrBytes += chunk.length;
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
child.on('error', (err) => {
|
|
217
|
+
log.error(`[authHelper] runtime error: ${formatErr(err)}`);
|
|
218
|
+
_lastFailureReason = 'spawn-error';
|
|
219
|
+
settle(null);
|
|
220
|
+
});
|
|
221
|
+
child.on('close', (code) => {
|
|
222
|
+
if (truncated) {
|
|
223
|
+
log.error(`[authHelper] stdout exceeded ${MAX_OUTPUT_BYTES} bytes — refusing to parse`);
|
|
224
|
+
_lastFailureReason = 'oversized';
|
|
225
|
+
settle(null);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
if (killedForTimeout) {
|
|
229
|
+
// _lastFailureReason already set to 'timeout' in the timer.
|
|
230
|
+
settle(null);
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
const stderr = Buffer.concat(stderrChunks).toString('utf-8').trim();
|
|
234
|
+
if (stderr)
|
|
235
|
+
log.debug?.(`[authHelper] stderr: ${stderr}`);
|
|
236
|
+
if (code !== 0) {
|
|
237
|
+
log.error(`[authHelper] helper exited with code ${code}`);
|
|
238
|
+
_lastFailureReason = 'non-zero-exit';
|
|
239
|
+
settle(null);
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
const stdout = Buffer.concat(stdoutChunks).toString('utf-8').trim();
|
|
243
|
+
if (!stdout) {
|
|
244
|
+
log.error('[authHelper] helper produced empty stdout');
|
|
245
|
+
_lastFailureReason = 'empty-output';
|
|
246
|
+
settle(null);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
const parsed = parseHelperOutput(stdout, log);
|
|
250
|
+
if (parsed === null) {
|
|
251
|
+
_lastFailureReason = 'parse-error';
|
|
252
|
+
}
|
|
253
|
+
settle(parsed);
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
function parseHelperOutput(stdout, log) {
|
|
258
|
+
// JSON path first; fall back to bare-string token.
|
|
259
|
+
const trimmed = stdout.trim();
|
|
260
|
+
const looksLikeJson = trimmed.startsWith('{') || trimmed.startsWith('"');
|
|
261
|
+
if (looksLikeJson) {
|
|
262
|
+
try {
|
|
263
|
+
const obj = JSON.parse(trimmed);
|
|
264
|
+
if (typeof obj === 'string') {
|
|
265
|
+
return obj.length > 0 ? { token: obj } : null;
|
|
266
|
+
}
|
|
267
|
+
if (obj && typeof obj === 'object') {
|
|
268
|
+
const rec = obj;
|
|
269
|
+
const token = typeof rec.token === 'string' ? rec.token : null;
|
|
270
|
+
if (!token || token.length === 0) {
|
|
271
|
+
log.error('[authHelper] JSON output missing "token" field');
|
|
272
|
+
return null;
|
|
273
|
+
}
|
|
274
|
+
const expiresAt = typeof rec.expiresAt === 'number' && Number.isFinite(rec.expiresAt)
|
|
275
|
+
? rec.expiresAt
|
|
276
|
+
: undefined;
|
|
277
|
+
return expiresAt !== undefined ? { token, expiresAt } : { token };
|
|
278
|
+
}
|
|
279
|
+
log.error('[authHelper] JSON output was not an object or string');
|
|
280
|
+
return null;
|
|
281
|
+
}
|
|
282
|
+
catch (err) {
|
|
283
|
+
log.error(`[authHelper] JSON parse failed: ${formatErr(err)}`);
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
// Bare token — single line, no JSON. Reject if it contains whitespace
|
|
288
|
+
// beyond surrounding trim (defensive — helper might have leaked logs).
|
|
289
|
+
if (/\s/.test(trimmed)) {
|
|
290
|
+
log.error('[authHelper] bare-token output contains whitespace — refusing (use JSON if helper logs to stdout)');
|
|
291
|
+
return null;
|
|
292
|
+
}
|
|
293
|
+
return { token: trimmed };
|
|
294
|
+
}
|
|
295
|
+
function parseTimeoutMs() {
|
|
296
|
+
const raw = process.env.OVERSKY_AUTH_HELPER_TIMEOUT_MS;
|
|
297
|
+
if (!raw)
|
|
298
|
+
return DEFAULT_TIMEOUT_MS;
|
|
299
|
+
const n = Number.parseInt(raw, 10);
|
|
300
|
+
return Number.isFinite(n) && n > 0 ? n : DEFAULT_TIMEOUT_MS;
|
|
301
|
+
}
|
|
302
|
+
export function getDefaultHelperTtlMs() {
|
|
303
|
+
const raw = process.env.OVERSKY_AUTH_HELPER_TTL_MS;
|
|
304
|
+
if (!raw)
|
|
305
|
+
return DEFAULT_TTL_MS;
|
|
306
|
+
const n = Number.parseInt(raw, 10);
|
|
307
|
+
return Number.isFinite(n) && n > 0 ? n : DEFAULT_TTL_MS;
|
|
308
|
+
}
|
|
309
|
+
function formatErr(err) {
|
|
310
|
+
return err instanceof Error ? err.message : String(err);
|
|
311
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/** Serialized magic prefix. Bumped only on a breaking format change. */
|
|
2
|
+
export declare const ENVELOPE_MAGIC = "osk-env1";
|
|
3
|
+
/** Key length in bytes (256 bits). */
|
|
4
|
+
export declare const KEY_LEN = 32;
|
|
5
|
+
/**
|
|
6
|
+
* In-memory representation of a sealed value. Carries a version byte so a
|
|
7
|
+
* future `osk-env2` migration can dual-read both formats from disk.
|
|
8
|
+
*/
|
|
9
|
+
export interface SealedBlob {
|
|
10
|
+
readonly v: 1;
|
|
11
|
+
readonly nonce: Buffer;
|
|
12
|
+
readonly ct: Buffer;
|
|
13
|
+
readonly tag: Buffer;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Generate a fresh 256-bit Data Encryption Key. Uses Node's crypto-random
|
|
17
|
+
* source (libsodium-grade on macOS/Linux, BCryptGenRandom on Windows).
|
|
18
|
+
*/
|
|
19
|
+
export declare function generateDek(): Buffer;
|
|
20
|
+
/**
|
|
21
|
+
* Build the canonical AAD for binding a sealed credential to a specific
|
|
22
|
+
* profile + envelope version. Including profile in the AAD prevents an
|
|
23
|
+
* attacker who copies a sealed blob from profile `dev` into profile
|
|
24
|
+
* `prod`'s slot from getting a successful decrypt — the AAD won't match.
|
|
25
|
+
*
|
|
26
|
+
* Stable string format so a re-derivation in a future process produces
|
|
27
|
+
* the exact same AAD bytes for the same profile.
|
|
28
|
+
*/
|
|
29
|
+
export declare function buildCredentialAad(profile: string, version?: number): Buffer;
|
|
30
|
+
/**
|
|
31
|
+
* Low-level AEAD seal. The shared primitive used by both DEK-wrapping and
|
|
32
|
+
* credential-encryption — the only difference between those operations is
|
|
33
|
+
* the semantic role of the key, not the cipher. Caller chooses the AAD.
|
|
34
|
+
*/
|
|
35
|
+
export declare function sealBuffer(plaintext: Buffer, key: Buffer, aad?: Buffer): SealedBlob;
|
|
36
|
+
/**
|
|
37
|
+
* Low-level AEAD open. Throws on tag mismatch (wrong key, AAD mismatch,
|
|
38
|
+
* truncated/corrupted ciphertext) — never silently returns garbage.
|
|
39
|
+
*/
|
|
40
|
+
export declare function openBuffer(sealed: SealedBlob, key: Buffer, aad?: Buffer): Buffer;
|
|
41
|
+
/**
|
|
42
|
+
* Wrap a 32-byte DEK under a KEK. The wrapped DEK is what gets persisted
|
|
43
|
+
* to disk — the KEK itself never leaves its platform store.
|
|
44
|
+
*/
|
|
45
|
+
export declare function wrap(dek: Buffer, kek: Buffer, aad?: Buffer): SealedBlob;
|
|
46
|
+
/**
|
|
47
|
+
* Unwrap a previously-wrapped DEK using the same KEK. AAD must match the
|
|
48
|
+
* value used at wrap time or the GCM tag check fails.
|
|
49
|
+
*/
|
|
50
|
+
export declare function unwrap(wrapped: SealedBlob, kek: Buffer, aad?: Buffer): Buffer;
|
|
51
|
+
/**
|
|
52
|
+
* Encrypt a credential (string or Buffer) under a DEK. Strings are
|
|
53
|
+
* encoded as UTF-8 before sealing; the open path returns a Buffer so the
|
|
54
|
+
* caller decides how to interpret it.
|
|
55
|
+
*/
|
|
56
|
+
export declare function encrypt(plaintext: Buffer | string, dek: Buffer, aad?: Buffer): SealedBlob;
|
|
57
|
+
/**
|
|
58
|
+
* Decrypt a credential previously sealed with `encrypt`. Returns the raw
|
|
59
|
+
* plaintext bytes; UTF-8-decode at the call site if the original was a
|
|
60
|
+
* string.
|
|
61
|
+
*/
|
|
62
|
+
export declare function decrypt(sealed: SealedBlob, dek: Buffer, aad?: Buffer): Buffer;
|
|
63
|
+
/**
|
|
64
|
+
* Serialize a SealedBlob to a single text token. Stable across processes
|
|
65
|
+
* and platforms — same blob serializes to the same string everywhere.
|
|
66
|
+
*/
|
|
67
|
+
export declare function serialize(sealed: SealedBlob): string;
|
|
68
|
+
/**
|
|
69
|
+
* Parse a serialized envelope string. Returns null on any malformed
|
|
70
|
+
* input — callers should treat null as "this is not an envelope" and
|
|
71
|
+
* fall through to the legacy plaintext path.
|
|
72
|
+
*
|
|
73
|
+
* Deliberately tolerant: garbage in returns null, never throws. Throws
|
|
74
|
+
* are reserved for the cipher path so a wrong key surfaces as a real
|
|
75
|
+
* authentication failure instead of being silently swallowed.
|
|
76
|
+
*/
|
|
77
|
+
export declare function deserialize(s: string): SealedBlob | null;
|
|
78
|
+
/**
|
|
79
|
+
* Cheap structural check — does this string LOOK like a serialized
|
|
80
|
+
* envelope? Used by dual-read paths to decide whether to attempt a
|
|
81
|
+
* decrypt or to treat the value as legacy plaintext. A true return does
|
|
82
|
+
* NOT guarantee the contents are valid; the actual decrypt is what
|
|
83
|
+
* authenticates the value.
|
|
84
|
+
*/
|
|
85
|
+
export declare function isEnvelopeString(s: unknown): s is string;
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* credentialEnvelope.ts — at-rest envelope encryption primitives (L12 Layer 0).
|
|
3
|
+
*
|
|
4
|
+
* NAMING NOTE — this module is the "credential at-rest envelope." It is
|
|
5
|
+
* structurally distinct from `daemon/src/envelope-encryption.ts`, which
|
|
6
|
+
* encrypts wire-protocol session envelopes with a server-known DEK. The
|
|
7
|
+
* two should never share a key, a serialized format, or a rotation
|
|
8
|
+
* policy. We deliberately do NOT use the bare word "envelope" in this
|
|
9
|
+
* module's filename to avoid that confusion; future readers grepping for
|
|
10
|
+
* "envelope" must hit a name that immediately disambiguates which layer
|
|
11
|
+
* they are looking at.
|
|
12
|
+
*
|
|
13
|
+
* Threat model — what this module protects:
|
|
14
|
+
* - Cold copy of `~/Library/Keychains/login.keychain-db` taken from a
|
|
15
|
+
* sleeping laptop and brute-forced offline.
|
|
16
|
+
* - Backup snapshot of `~/.skrr/...` in Time Machine / Backblaze /
|
|
17
|
+
* Restic that ends up on a less-trusted disk.
|
|
18
|
+
* - Same-UID malware that reads the on-disk JSON file backend without
|
|
19
|
+
* standing up an OS prompt.
|
|
20
|
+
*
|
|
21
|
+
* What it does NOT protect:
|
|
22
|
+
* - Privileged on-host malware (root) reading the daemon's process memory.
|
|
23
|
+
* - Server-side compromise of refresh tokens that the server already
|
|
24
|
+
* holds by design.
|
|
25
|
+
*
|
|
26
|
+
* Hierarchy:
|
|
27
|
+
* plaintext credential
|
|
28
|
+
* ─ AES-256-GCM(DEK, nonce, AAD) ─▶ sealed credential blob
|
|
29
|
+
* plaintext DEK (32 bytes, generated locally)
|
|
30
|
+
* ─ AES-256-GCM(KEK, nonce, AAD) ─▶ wrapped DEK blob
|
|
31
|
+
* KEK (platform-native; Secure Enclave / DPAPI / libsecret-derived)
|
|
32
|
+
* never appears in JS memory in unwrappable form.
|
|
33
|
+
*
|
|
34
|
+
* This module owns ONLY the AES-256-GCM primitive plus the serialized
|
|
35
|
+
* format. Platform KEK acquisition lives in `./kek/`. DEK lifecycle
|
|
36
|
+
* (generation, rotation, persistence) lives in the daemon-side bridge.
|
|
37
|
+
*/
|
|
38
|
+
import crypto from 'node:crypto';
|
|
39
|
+
import { aeadSeal, aeadOpen, AEAD_KEY_LEN, AEAD_NONCE_LEN, AEAD_TAG_LEN } from './aead.js';
|
|
40
|
+
/** Serialized magic prefix. Bumped only on a breaking format change. */
|
|
41
|
+
export const ENVELOPE_MAGIC = 'osk-env1';
|
|
42
|
+
/** Key length in bytes (256 bits). */
|
|
43
|
+
export const KEY_LEN = AEAD_KEY_LEN;
|
|
44
|
+
/** Nonce length in bytes (96 bits — GCM standard). */
|
|
45
|
+
const NONCE_LEN = AEAD_NONCE_LEN;
|
|
46
|
+
/** GCM authentication tag length in bytes (128 bits). */
|
|
47
|
+
const TAG_LEN = AEAD_TAG_LEN;
|
|
48
|
+
/**
|
|
49
|
+
* Generate a fresh 256-bit Data Encryption Key. Uses Node's crypto-random
|
|
50
|
+
* source (libsodium-grade on macOS/Linux, BCryptGenRandom on Windows).
|
|
51
|
+
*/
|
|
52
|
+
export function generateDek() {
|
|
53
|
+
return crypto.randomBytes(KEY_LEN);
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Build the canonical AAD for binding a sealed credential to a specific
|
|
57
|
+
* profile + envelope version. Including profile in the AAD prevents an
|
|
58
|
+
* attacker who copies a sealed blob from profile `dev` into profile
|
|
59
|
+
* `prod`'s slot from getting a successful decrypt — the AAD won't match.
|
|
60
|
+
*
|
|
61
|
+
* Stable string format so a re-derivation in a future process produces
|
|
62
|
+
* the exact same AAD bytes for the same profile.
|
|
63
|
+
*/
|
|
64
|
+
export function buildCredentialAad(profile, version = 1) {
|
|
65
|
+
if (typeof profile !== 'string' || profile.length === 0) {
|
|
66
|
+
throw new Error('buildCredentialAad: profile must be a non-empty string');
|
|
67
|
+
}
|
|
68
|
+
if (!Number.isInteger(version) || version < 1) {
|
|
69
|
+
throw new Error('buildCredentialAad: version must be a positive integer');
|
|
70
|
+
}
|
|
71
|
+
return Buffer.from(`oversky:cred:v${version}:profile=${profile}`, 'utf-8');
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Low-level AEAD seal. The shared primitive used by both DEK-wrapping and
|
|
75
|
+
* credential-encryption — the only difference between those operations is
|
|
76
|
+
* the semantic role of the key, not the cipher. Caller chooses the AAD.
|
|
77
|
+
*/
|
|
78
|
+
export function sealBuffer(plaintext, key, aad) {
|
|
79
|
+
if (!Buffer.isBuffer(plaintext)) {
|
|
80
|
+
throw new TypeError('sealBuffer: plaintext must be a Buffer');
|
|
81
|
+
}
|
|
82
|
+
if (!Buffer.isBuffer(key) || key.length !== KEY_LEN) {
|
|
83
|
+
throw new TypeError(`sealBuffer: key must be a ${KEY_LEN}-byte Buffer`);
|
|
84
|
+
}
|
|
85
|
+
const { nonce, ct, tag } = aeadSeal(plaintext, key, aad);
|
|
86
|
+
return { v: 1, nonce, ct, tag };
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Low-level AEAD open. Throws on tag mismatch (wrong key, AAD mismatch,
|
|
90
|
+
* truncated/corrupted ciphertext) — never silently returns garbage.
|
|
91
|
+
*/
|
|
92
|
+
export function openBuffer(sealed, key, aad) {
|
|
93
|
+
if (!sealed || sealed.v !== 1) {
|
|
94
|
+
throw new Error('openBuffer: unsupported envelope version');
|
|
95
|
+
}
|
|
96
|
+
if (!Buffer.isBuffer(key) || key.length !== KEY_LEN) {
|
|
97
|
+
throw new TypeError(`openBuffer: key must be a ${KEY_LEN}-byte Buffer`);
|
|
98
|
+
}
|
|
99
|
+
if (!Buffer.isBuffer(sealed.nonce) || sealed.nonce.length !== NONCE_LEN) {
|
|
100
|
+
throw new Error('openBuffer: invalid nonce length');
|
|
101
|
+
}
|
|
102
|
+
if (!Buffer.isBuffer(sealed.tag) || sealed.tag.length !== TAG_LEN) {
|
|
103
|
+
throw new Error('openBuffer: invalid auth tag length');
|
|
104
|
+
}
|
|
105
|
+
if (!Buffer.isBuffer(sealed.ct)) {
|
|
106
|
+
throw new Error('openBuffer: invalid ciphertext');
|
|
107
|
+
}
|
|
108
|
+
return aeadOpen(sealed, key, aad);
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Wrap a 32-byte DEK under a KEK. The wrapped DEK is what gets persisted
|
|
112
|
+
* to disk — the KEK itself never leaves its platform store.
|
|
113
|
+
*/
|
|
114
|
+
export function wrap(dek, kek, aad) {
|
|
115
|
+
if (!Buffer.isBuffer(dek) || dek.length !== KEY_LEN) {
|
|
116
|
+
throw new TypeError(`wrap: dek must be a ${KEY_LEN}-byte Buffer`);
|
|
117
|
+
}
|
|
118
|
+
return sealBuffer(dek, kek, aad);
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Unwrap a previously-wrapped DEK using the same KEK. AAD must match the
|
|
122
|
+
* value used at wrap time or the GCM tag check fails.
|
|
123
|
+
*/
|
|
124
|
+
export function unwrap(wrapped, kek, aad) {
|
|
125
|
+
const out = openBuffer(wrapped, kek, aad);
|
|
126
|
+
if (out.length !== KEY_LEN) {
|
|
127
|
+
throw new Error(`unwrap: unwrapped DEK has wrong length (got ${out.length})`);
|
|
128
|
+
}
|
|
129
|
+
return out;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Encrypt a credential (string or Buffer) under a DEK. Strings are
|
|
133
|
+
* encoded as UTF-8 before sealing; the open path returns a Buffer so the
|
|
134
|
+
* caller decides how to interpret it.
|
|
135
|
+
*/
|
|
136
|
+
export function encrypt(plaintext, dek, aad) {
|
|
137
|
+
const buf = typeof plaintext === 'string' ? Buffer.from(plaintext, 'utf-8') : plaintext;
|
|
138
|
+
return sealBuffer(buf, dek, aad);
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Decrypt a credential previously sealed with `encrypt`. Returns the raw
|
|
142
|
+
* plaintext bytes; UTF-8-decode at the call site if the original was a
|
|
143
|
+
* string.
|
|
144
|
+
*/
|
|
145
|
+
export function decrypt(sealed, dek, aad) {
|
|
146
|
+
return openBuffer(sealed, dek, aad);
|
|
147
|
+
}
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
// String serialization — keychain slots and JSON files store strings, so a
|
|
150
|
+
// SealedBlob has to round-trip through a single text token.
|
|
151
|
+
//
|
|
152
|
+
// Format: osk-env1:<b64u(nonce)>:<b64u(ct)>:<b64u(tag)>
|
|
153
|
+
// Length: ~88 chars for a typical 100-byte plaintext (well under the
|
|
154
|
+
// keychain's per-slot limit).
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
/** URL-safe base64 (no padding) — keychain-friendly, no `=` to confuse parsers. */
|
|
157
|
+
function b64u(buf) {
|
|
158
|
+
return buf.toString('base64').replace(/=+$/, '').replace(/\+/g, '-').replace(/\//g, '_');
|
|
159
|
+
}
|
|
160
|
+
function b64uDecode(s) {
|
|
161
|
+
const padded = s.replace(/-/g, '+').replace(/_/g, '/');
|
|
162
|
+
const pad = padded.length % 4;
|
|
163
|
+
return Buffer.from(pad === 0 ? padded : padded + '='.repeat(4 - pad), 'base64');
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Serialize a SealedBlob to a single text token. Stable across processes
|
|
167
|
+
* and platforms — same blob serializes to the same string everywhere.
|
|
168
|
+
*/
|
|
169
|
+
export function serialize(sealed) {
|
|
170
|
+
if (!sealed || sealed.v !== 1) {
|
|
171
|
+
throw new Error('serialize: unsupported envelope version');
|
|
172
|
+
}
|
|
173
|
+
return `${ENVELOPE_MAGIC}:${b64u(sealed.nonce)}:${b64u(sealed.ct)}:${b64u(sealed.tag)}`;
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Parse a serialized envelope string. Returns null on any malformed
|
|
177
|
+
* input — callers should treat null as "this is not an envelope" and
|
|
178
|
+
* fall through to the legacy plaintext path.
|
|
179
|
+
*
|
|
180
|
+
* Deliberately tolerant: garbage in returns null, never throws. Throws
|
|
181
|
+
* are reserved for the cipher path so a wrong key surfaces as a real
|
|
182
|
+
* authentication failure instead of being silently swallowed.
|
|
183
|
+
*/
|
|
184
|
+
export function deserialize(s) {
|
|
185
|
+
if (typeof s !== 'string' || s.length === 0)
|
|
186
|
+
return null;
|
|
187
|
+
const parts = s.split(':');
|
|
188
|
+
if (parts.length !== 4)
|
|
189
|
+
return null;
|
|
190
|
+
if (parts[0] !== ENVELOPE_MAGIC)
|
|
191
|
+
return null;
|
|
192
|
+
try {
|
|
193
|
+
const nonce = b64uDecode(parts[1]);
|
|
194
|
+
const ct = b64uDecode(parts[2]);
|
|
195
|
+
const tag = b64uDecode(parts[3]);
|
|
196
|
+
if (nonce.length !== NONCE_LEN || tag.length !== TAG_LEN)
|
|
197
|
+
return null;
|
|
198
|
+
return { v: 1, nonce, ct, tag };
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Cheap structural check — does this string LOOK like a serialized
|
|
206
|
+
* envelope? Used by dual-read paths to decide whether to attempt a
|
|
207
|
+
* decrypt or to treat the value as legacy plaintext. A true return does
|
|
208
|
+
* NOT guarantee the contents are valid; the actual decrypt is what
|
|
209
|
+
* authenticates the value.
|
|
210
|
+
*/
|
|
211
|
+
export function isEnvelopeString(s) {
|
|
212
|
+
return typeof s === 'string' && s.startsWith(`${ENVELOPE_MAGIC}:`);
|
|
213
|
+
}
|