@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,423 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.CredentialResolver = exports.AuthHelperUntrustedError = exports.AuthHelperUnavailableError = exports.CI_TOKEN_PREFIX = exports.CREDENTIAL_PRECEDENCE = void 0;
|
|
4
|
+
exports.classifyTokenKind = classifyTokenKind;
|
|
5
|
+
exports.__resetCredentialResolverForTest = __resetCredentialResolverForTest;
|
|
6
|
+
exports.invalidateAuthHelperCache = invalidateAuthHelperCache;
|
|
7
|
+
/**
|
|
8
|
+
* credentialResolver.ts — documented credential precedence for the
|
|
9
|
+
* OverSky daemon and CLI.
|
|
10
|
+
*
|
|
11
|
+
* Roadmap: docs/daemon-auth-roadmap.md, Phase H.
|
|
12
|
+
*
|
|
13
|
+
* The resolver walks a fixed, ordered list of credential sources and
|
|
14
|
+
* returns the first non-null token along with its source name. Source
|
|
15
|
+
* names are part of the public surface — they show up in
|
|
16
|
+
* `oversky doctor`, `oversky status`, and `sky whoami`, and operators
|
|
17
|
+
* use them to triage "where did this token come from?" tickets.
|
|
18
|
+
*
|
|
19
|
+
* Precedence (high → low):
|
|
20
|
+
*
|
|
21
|
+
* 1. flag — `--token <jwt>` CLI flag (debugging)
|
|
22
|
+
* 2. env-token — `OVERSKY_TOKEN` env (CI / static; Phase I adds
|
|
23
|
+
* server-side scope=ci validation)
|
|
24
|
+
* 3. auth-helper — `OVERSKY_AUTH_HELPER` script output (rotating
|
|
25
|
+
* creds — Vault, AWS SM, internal SSO proxy)
|
|
26
|
+
* 4. fd-handoff — `OVERSKY_AUTH_FD` parent→child IPC (Electron
|
|
27
|
+
* supervisor → daemon child). Internal; never
|
|
28
|
+
* user-configured.
|
|
29
|
+
* 5. keychain — macOS Keychain (`ai.skrr.daemon`)
|
|
30
|
+
* 6. file — `~/.skrr/auth.json` (0600)
|
|
31
|
+
* 7. interactive — Browser/device-code login (TTY only, blocked
|
|
32
|
+
* under launchd via `isHeadless()`)
|
|
33
|
+
*
|
|
34
|
+
* Skip rules (Phase H.2 + H.4):
|
|
35
|
+
* - `bareMode: true` skips slots 4–7 (no platform storage, no IPC,
|
|
36
|
+
* no interactive). Bare mode demands an explicit token from env or
|
|
37
|
+
* flag — designed for reproducible CI runs and "is the daemon
|
|
38
|
+
* broken or is my config corrupted?" triage.
|
|
39
|
+
* - `storeMode: 'env-only'` skips slots 5 + 6 (and the daemon
|
|
40
|
+
* wrapper additionally disables the auth-generation.json sidecar).
|
|
41
|
+
* - `storeMode: 'keychain'` skips slot 6 (file backend).
|
|
42
|
+
* - `storeMode: 'file'` skips slot 5 (keychain backend).
|
|
43
|
+
*
|
|
44
|
+
* Backend access uses dependency-injected callbacks
|
|
45
|
+
* (`readKeychainToken`, `readFileToken`, `runInteractiveLogin`) so this
|
|
46
|
+
* module stays free of OS-specific imports and the Sky CLI can wire its
|
|
47
|
+
* own backends without dragging the daemon's `keychain.ts` into
|
|
48
|
+
* non-daemon binaries.
|
|
49
|
+
*/
|
|
50
|
+
const authHelper_js_1 = require("./authHelper.js");
|
|
51
|
+
const fdToken_js_1 = require("./fdToken.js");
|
|
52
|
+
const jwtUtils_js_1 = require("./jwtUtils.js");
|
|
53
|
+
const runtime_js_1 = require("./runtime.js");
|
|
54
|
+
/**
|
|
55
|
+
* Ordered slots tried by `CredentialResolver.resolve()`. Excludes
|
|
56
|
+
* `'none'` (only emitted when no slot produced a token). Treat this as
|
|
57
|
+
* the canonical published precedence — flipping the order is a public
|
|
58
|
+
* API change and breaks operator runbooks.
|
|
59
|
+
*/
|
|
60
|
+
exports.CREDENTIAL_PRECEDENCE = [
|
|
61
|
+
'flag',
|
|
62
|
+
'env-token',
|
|
63
|
+
'auth-helper',
|
|
64
|
+
'fd-handoff',
|
|
65
|
+
'keychain',
|
|
66
|
+
'file',
|
|
67
|
+
'interactive',
|
|
68
|
+
];
|
|
69
|
+
/** CI-token plaintext prefix; mirrors api/server/services/Auth/daemonCiTokens.js. */
|
|
70
|
+
exports.CI_TOKEN_PREFIX = 'osk_ci_';
|
|
71
|
+
/**
|
|
72
|
+
* Classify a token by local shape. Pure function; safe to call on any string
|
|
73
|
+
* (including `null`-coerced empty). Returns `'standard'` for empty input
|
|
74
|
+
* so callers don't need to null-check before classification.
|
|
75
|
+
*
|
|
76
|
+
* Honors `OVERSKY_DISABLE_CI_TOKEN_RESOLVER=1` kill switch — when set,
|
|
77
|
+
* everything classifies as `'standard'` so the resolver can be used as
|
|
78
|
+
* a triage tool ("is the CI-token path the problem?").
|
|
79
|
+
*/
|
|
80
|
+
/**
|
|
81
|
+
* SECURITY NOTE — this reads `scope` out of an UNVERIFIED JWT payload.
|
|
82
|
+
*
|
|
83
|
+
* That is safe here, and stays safe only under one condition: the return value
|
|
84
|
+
* selects a local code path and nothing else. `'install'` means "refresh this
|
|
85
|
+
* the install-token way"; it grants no capability, skips no check, and asserts
|
|
86
|
+
* no identity. The server still validates the token on every request, so a
|
|
87
|
+
* forged `scope: daemon-install` buys an attacker a different local branch and
|
|
88
|
+
* the same 401.
|
|
89
|
+
*
|
|
90
|
+
* The forgery is trivial — no signature is checked, so anyone who can write the
|
|
91
|
+
* credential file or set the env var can put any claim in it. That is fine
|
|
92
|
+
* while the only consequence is routing. It stops being fine the moment a
|
|
93
|
+
* caller treats `TokenKind` as a permission. Do not let this value gate a
|
|
94
|
+
* capability, and do not add a branch here that returns a kind meaning "more
|
|
95
|
+
* privileged".
|
|
96
|
+
*
|
|
97
|
+
* See `jwtUtils.ts` for the general rule.
|
|
98
|
+
*/
|
|
99
|
+
function classifyTokenKind(token) {
|
|
100
|
+
if (process.env.OVERSKY_DISABLE_CI_TOKEN_RESOLVER === '1')
|
|
101
|
+
return 'standard';
|
|
102
|
+
if (typeof token !== 'string' || token.length === 0)
|
|
103
|
+
return 'standard';
|
|
104
|
+
if (token.startsWith(exports.CI_TOKEN_PREFIX))
|
|
105
|
+
return 'ci';
|
|
106
|
+
const payload = (0, jwtUtils_js_1.decodeJwtPayload)(token);
|
|
107
|
+
if (payload &&
|
|
108
|
+
typeof payload === 'object' &&
|
|
109
|
+
!Array.isArray(payload) &&
|
|
110
|
+
payload.scope === 'daemon-install') {
|
|
111
|
+
return 'install';
|
|
112
|
+
}
|
|
113
|
+
return 'standard';
|
|
114
|
+
}
|
|
115
|
+
let _helperCache = null;
|
|
116
|
+
/** @internal test-only — drop the helper cache between tests. */
|
|
117
|
+
function __resetCredentialResolverForTest() {
|
|
118
|
+
_helperCache = null;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* L6.3 — typed error thrown when the cold-cache failure sentinel is in
|
|
122
|
+
* effect. Callers (daemon refresh path, CLI) can catch this specifically
|
|
123
|
+
* to render "auth helper unavailable: <reason>" without falling through
|
|
124
|
+
* to OAuth or device-code retry.
|
|
125
|
+
*/
|
|
126
|
+
class AuthHelperUnavailableError extends Error {
|
|
127
|
+
reason;
|
|
128
|
+
cachedAt;
|
|
129
|
+
constructor(reason, cachedAt) {
|
|
130
|
+
super(`auth helper unavailable: ${reason}`);
|
|
131
|
+
this.name = 'AuthHelperUnavailableError';
|
|
132
|
+
this.reason = reason;
|
|
133
|
+
this.cachedAt = cachedAt;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
exports.AuthHelperUnavailableError = AuthHelperUnavailableError;
|
|
137
|
+
/**
|
|
138
|
+
* L6.1 — typed error thrown when the workspace-trust gate blocks a
|
|
139
|
+
* project- or local-scope helper. Surfaces the actionable next step
|
|
140
|
+
* (`oversky trust accept`) to the user; the daemon catches and presents
|
|
141
|
+
* this without falling through to OAuth.
|
|
142
|
+
*/
|
|
143
|
+
class AuthHelperUntrustedError extends Error {
|
|
144
|
+
origin;
|
|
145
|
+
reason;
|
|
146
|
+
helperPath;
|
|
147
|
+
constructor(helperPath, origin, reason) {
|
|
148
|
+
super(`OVERSKY_AUTH_HELPER (${origin}-scope) is not trusted for this workspace ` +
|
|
149
|
+
`(reason: ${reason}). Run \`oversky trust accept\` to allow it.`);
|
|
150
|
+
this.name = 'AuthHelperUntrustedError';
|
|
151
|
+
this.origin = origin;
|
|
152
|
+
this.reason = reason;
|
|
153
|
+
this.helperPath = helperPath;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
exports.AuthHelperUntrustedError = AuthHelperUntrustedError;
|
|
157
|
+
/**
|
|
158
|
+
* H3-6 — public invalidation seam for the `OVERSKY_AUTH_HELPER` result
|
|
159
|
+
* cache. 401 handlers call this right after receiving a 401 from the
|
|
160
|
+
* server so the NEXT credential resolve re-invokes the helper to mint
|
|
161
|
+
* a fresh token instead of serving the stale (cached) one for up to
|
|
162
|
+
* `OVERSKY_AUTH_HELPER_TTL_MS`.
|
|
163
|
+
*
|
|
164
|
+
* Without this seam, a rotated secret at the helper's source (Vault,
|
|
165
|
+
* AWS Secrets Manager, etc.) could take up to 5 minutes to propagate
|
|
166
|
+
* to OverSky even when the server is actively rejecting the old
|
|
167
|
+
* value. The helper is the only slot in the precedence chain that
|
|
168
|
+
* naturally caches; keychain/file reads are already one-shot per
|
|
169
|
+
* resolve.
|
|
170
|
+
*
|
|
171
|
+
* L6.3 — also clears the cold-cache failure sentinel. A 401 means the
|
|
172
|
+
* server is rejecting whatever we sent, which usually means the helper
|
|
173
|
+
* needs to retry from scratch — even when the LAST helper attempt
|
|
174
|
+
* failed and seeded the sentinel. Without this, a transient helper
|
|
175
|
+
* failure during the same TTL window as a server-issued 401 would lock
|
|
176
|
+
* the daemon out for the full TTL.
|
|
177
|
+
*
|
|
178
|
+
* Idempotent: calling this on an already-empty cache is a no-op.
|
|
179
|
+
*/
|
|
180
|
+
function invalidateAuthHelperCache() {
|
|
181
|
+
_helperCache = null;
|
|
182
|
+
}
|
|
183
|
+
class CredentialResolver {
|
|
184
|
+
opts;
|
|
185
|
+
constructor(opts = {}) {
|
|
186
|
+
this.opts = opts;
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Walk the precedence chain and return the first non-null token.
|
|
190
|
+
* Returns `{ token: null, source: 'none' }` when nothing matches.
|
|
191
|
+
*
|
|
192
|
+
* Fail-closed exceptions from the auth-helper slot
|
|
193
|
+
* (`AuthHelperUntrustedError`, `AuthHelperUnavailableError`) propagate
|
|
194
|
+
* out — they intentionally PREVENT fall-through to OAuth. Callers
|
|
195
|
+
* that want soft-fall-through behavior must catch explicitly. See
|
|
196
|
+
* L6.1 spec point 4 and L6.3.
|
|
197
|
+
*/
|
|
198
|
+
async resolve() {
|
|
199
|
+
for (const source of exports.CREDENTIAL_PRECEDENCE) {
|
|
200
|
+
if (this.shouldSkip(source))
|
|
201
|
+
continue;
|
|
202
|
+
const token = await this.tryResolveSlot(source);
|
|
203
|
+
if (token) {
|
|
204
|
+
return { token, source, kind: classifyTokenKind(token) };
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return { token: null, source: 'none', kind: 'standard' };
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Test seam: invoke a single slot in isolation. Production code should
|
|
211
|
+
* always go through `resolve()` so the chain ordering is enforced.
|
|
212
|
+
*/
|
|
213
|
+
async tryResolveSlot(source) {
|
|
214
|
+
switch (source) {
|
|
215
|
+
case 'flag':
|
|
216
|
+
return this.tryResolveFlag();
|
|
217
|
+
case 'env-token':
|
|
218
|
+
return this.tryResolveEnvToken();
|
|
219
|
+
case 'auth-helper':
|
|
220
|
+
return this.tryResolveAuthHelper();
|
|
221
|
+
case 'fd-handoff':
|
|
222
|
+
return this.tryResolveFdHandoff();
|
|
223
|
+
case 'keychain':
|
|
224
|
+
return this.tryResolveKeychain();
|
|
225
|
+
case 'file':
|
|
226
|
+
return this.tryResolveFile();
|
|
227
|
+
case 'interactive':
|
|
228
|
+
return this.tryResolveInteractive();
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
shouldSkip(source) {
|
|
232
|
+
const { bareMode } = this.opts;
|
|
233
|
+
const storeMode = this.opts.storeMode ?? 'auto';
|
|
234
|
+
if (bareMode &&
|
|
235
|
+
(source === 'fd-handoff' ||
|
|
236
|
+
source === 'keychain' ||
|
|
237
|
+
source === 'file' ||
|
|
238
|
+
source === 'interactive')) {
|
|
239
|
+
return true;
|
|
240
|
+
}
|
|
241
|
+
if (storeMode === 'env-only' && (source === 'keychain' || source === 'file')) {
|
|
242
|
+
return true;
|
|
243
|
+
}
|
|
244
|
+
if (storeMode === 'keychain' && source === 'file')
|
|
245
|
+
return true;
|
|
246
|
+
if (storeMode === 'file' && source === 'keychain')
|
|
247
|
+
return true;
|
|
248
|
+
return false;
|
|
249
|
+
}
|
|
250
|
+
// ------------------------------------------------------------------
|
|
251
|
+
// Slot implementations
|
|
252
|
+
// ------------------------------------------------------------------
|
|
253
|
+
tryResolveFlag() {
|
|
254
|
+
const v = this.opts.flagToken;
|
|
255
|
+
return typeof v === 'string' && v.length > 0 ? v : null;
|
|
256
|
+
}
|
|
257
|
+
tryResolveEnvToken() {
|
|
258
|
+
if (this.opts.envTokenOverride === null)
|
|
259
|
+
return null; // explicit force-skip
|
|
260
|
+
if (typeof this.opts.envTokenOverride === 'string') {
|
|
261
|
+
return this.opts.envTokenOverride.length > 0 ? this.opts.envTokenOverride : null;
|
|
262
|
+
}
|
|
263
|
+
const v = process.env.OVERSKY_TOKEN;
|
|
264
|
+
return typeof v === 'string' && v.length > 0 ? v : null;
|
|
265
|
+
}
|
|
266
|
+
async tryResolveAuthHelper() {
|
|
267
|
+
let helperPath;
|
|
268
|
+
let fromEnvVar = false;
|
|
269
|
+
if (this.opts.authHelperPathOverride === null)
|
|
270
|
+
return null;
|
|
271
|
+
if (typeof this.opts.authHelperPathOverride === 'string') {
|
|
272
|
+
helperPath =
|
|
273
|
+
this.opts.authHelperPathOverride.length > 0 ? this.opts.authHelperPathOverride : null;
|
|
274
|
+
}
|
|
275
|
+
else {
|
|
276
|
+
helperPath = process.env.OVERSKY_AUTH_HELPER ?? null;
|
|
277
|
+
fromEnvVar = helperPath !== null;
|
|
278
|
+
}
|
|
279
|
+
if (!helperPath)
|
|
280
|
+
return null;
|
|
281
|
+
// ---------------------------------------------------------------
|
|
282
|
+
// L6.3 — sentinel served BEFORE re-spawning. Failure cache prevents
|
|
283
|
+
// silent fall-through to OAuth within the TTL window.
|
|
284
|
+
// ---------------------------------------------------------------
|
|
285
|
+
if (_helperCache && Date.now() < _helperCache.expiresAt) {
|
|
286
|
+
if (_helperCache.kind === 'success') {
|
|
287
|
+
return _helperCache.token;
|
|
288
|
+
}
|
|
289
|
+
// Failed sentinel still warm — fail closed. The slot returns
|
|
290
|
+
// a fail-closed signal via thrown error so the chain doesn't
|
|
291
|
+
// continue to keychain/file/OAuth.
|
|
292
|
+
this.opts.onAuthHelperSentinelHit?.({
|
|
293
|
+
helperPath,
|
|
294
|
+
reason: _helperCache.reason,
|
|
295
|
+
cachedAt: _helperCache.cachedAt,
|
|
296
|
+
ttlMs: _helperCache.expiresAt - _helperCache.cachedAt,
|
|
297
|
+
});
|
|
298
|
+
throw new AuthHelperUnavailableError(_helperCache.reason, _helperCache.cachedAt);
|
|
299
|
+
}
|
|
300
|
+
// ---------------------------------------------------------------
|
|
301
|
+
// L6.1 — workspace-trust gate. Project- and local-scope helpers
|
|
302
|
+
// must have an accepted trust record for the cwd. User-scope
|
|
303
|
+
// helpers (env var, ~/.skrr/) bypass the check.
|
|
304
|
+
//
|
|
305
|
+
// Trust check runs BEFORE invocation so we never even spawn an
|
|
306
|
+
// untrusted script. Failed trust = fail-closed throw, never
|
|
307
|
+
// falls through to OAuth.
|
|
308
|
+
// ---------------------------------------------------------------
|
|
309
|
+
if (this.opts.helperTrustGate) {
|
|
310
|
+
const decision = this.opts.helperTrustGate(helperPath, fromEnvVar);
|
|
311
|
+
if (!decision.trusted) {
|
|
312
|
+
const reason = decision.reason ?? 'no-record';
|
|
313
|
+
this.opts.onAuthHelperBlocked?.({
|
|
314
|
+
helperPath,
|
|
315
|
+
origin: decision.origin,
|
|
316
|
+
reason,
|
|
317
|
+
cwd: process.cwd(),
|
|
318
|
+
});
|
|
319
|
+
throw new AuthHelperUntrustedError(helperPath, decision.origin, reason);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
const result = await (0, authHelper_js_1.invokeAuthHelper)(helperPath);
|
|
323
|
+
if (result) {
|
|
324
|
+
const ttlMs = (0, authHelper_js_1.getDefaultHelperTtlMs)();
|
|
325
|
+
const expiresAt = result.expiresAt ?? Date.now() + ttlMs;
|
|
326
|
+
_helperCache = { kind: 'success', token: result.token, expiresAt };
|
|
327
|
+
return result.token;
|
|
328
|
+
}
|
|
329
|
+
// ---------------------------------------------------------------
|
|
330
|
+
// L6.3 — invocation failed. Distinguish two failure classes:
|
|
331
|
+
//
|
|
332
|
+
// - Validation failures ('disabled', 'invalid-path',
|
|
333
|
+
// 'not-executable', null) — helper isn't really wired up.
|
|
334
|
+
// Fall through to next slot. NO sentinel.
|
|
335
|
+
//
|
|
336
|
+
// - Execution failures ('timeout', 'non-zero-exit', 'oversized',
|
|
337
|
+
// 'empty-output', 'parse-error', 'spawn-error') — the helper
|
|
338
|
+
// was wired up but couldn't deliver a token. Cache the
|
|
339
|
+
// sentinel and fail closed for the TTL window. This matches
|
|
340
|
+
// CC's `' '` literal sentinel at CC-A-34.
|
|
341
|
+
// ---------------------------------------------------------------
|
|
342
|
+
const reason = (0, authHelper_js_1.getLastAuthHelperFailureReason)();
|
|
343
|
+
if (reason && authHelper_js_1.SENTINEL_TRIGGERING_FAILURES.has(reason)) {
|
|
344
|
+
const ttlMs = (0, authHelper_js_1.getDefaultHelperTtlMs)();
|
|
345
|
+
const cachedAt = Date.now();
|
|
346
|
+
_helperCache = {
|
|
347
|
+
kind: 'failed',
|
|
348
|
+
reason,
|
|
349
|
+
cachedAt,
|
|
350
|
+
expiresAt: cachedAt + ttlMs,
|
|
351
|
+
};
|
|
352
|
+
this.opts.onAuthHelperSentinelHit?.({
|
|
353
|
+
helperPath,
|
|
354
|
+
reason,
|
|
355
|
+
cachedAt,
|
|
356
|
+
ttlMs,
|
|
357
|
+
});
|
|
358
|
+
throw new AuthHelperUnavailableError(reason, cachedAt);
|
|
359
|
+
}
|
|
360
|
+
// Validation failure — no sentinel; fall through.
|
|
361
|
+
return null;
|
|
362
|
+
}
|
|
363
|
+
async tryResolveFdHandoff() {
|
|
364
|
+
// FD handoff is daemon-only; CLI never has OVERSKY_AUTH_FD set, so
|
|
365
|
+
// the env check below makes this a near-zero-cost slot for CLI
|
|
366
|
+
// processes. The fdToken module does its own kill-switch check
|
|
367
|
+
// (`fdTokenHandoff` killSwitch) and bounded read.
|
|
368
|
+
if (!process.env.OVERSKY_AUTH_FD)
|
|
369
|
+
return null;
|
|
370
|
+
try {
|
|
371
|
+
const payload = await (0, fdToken_js_1.readAuthFromFd)();
|
|
372
|
+
if (!payload)
|
|
373
|
+
return null;
|
|
374
|
+
if (payload.accessExpiresAt && Date.now() > payload.accessExpiresAt) {
|
|
375
|
+
return null;
|
|
376
|
+
}
|
|
377
|
+
return payload.accessToken || null;
|
|
378
|
+
}
|
|
379
|
+
catch (err) {
|
|
380
|
+
(0, runtime_js_1.getAuthLogger)().warn(`[credentialResolver] FD handoff read failed: ${formatErr(err)}`);
|
|
381
|
+
return null;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
tryResolveKeychain() {
|
|
385
|
+
if (!this.opts.readKeychainToken)
|
|
386
|
+
return null;
|
|
387
|
+
try {
|
|
388
|
+
return this.opts.readKeychainToken();
|
|
389
|
+
}
|
|
390
|
+
catch (err) {
|
|
391
|
+
(0, runtime_js_1.getAuthLogger)().warn(`[credentialResolver] keychain read failed: ${formatErr(err)}`);
|
|
392
|
+
return null;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
tryResolveFile() {
|
|
396
|
+
if (!this.opts.readFileToken)
|
|
397
|
+
return null;
|
|
398
|
+
try {
|
|
399
|
+
return this.opts.readFileToken();
|
|
400
|
+
}
|
|
401
|
+
catch (err) {
|
|
402
|
+
(0, runtime_js_1.getAuthLogger)().warn(`[credentialResolver] file read failed: ${formatErr(err)}`);
|
|
403
|
+
return null;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
async tryResolveInteractive() {
|
|
407
|
+
if (!this.opts.runInteractiveLogin)
|
|
408
|
+
return null;
|
|
409
|
+
if (this.opts.isHeadless?.())
|
|
410
|
+
return null;
|
|
411
|
+
try {
|
|
412
|
+
return await this.opts.runInteractiveLogin();
|
|
413
|
+
}
|
|
414
|
+
catch (err) {
|
|
415
|
+
(0, runtime_js_1.getAuthLogger)().warn(`[credentialResolver] interactive login failed: ${formatErr(err)}`);
|
|
416
|
+
return null;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
exports.CredentialResolver = CredentialResolver;
|
|
421
|
+
function formatErr(err) {
|
|
422
|
+
return err instanceof Error ? err.message : String(err);
|
|
423
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/** Box nonce length per RFC 8439 / NaCl. */
|
|
2
|
+
export declare const PAIR_NONCE_LEN = 24;
|
|
3
|
+
/** Curve25519 public key length. */
|
|
4
|
+
export declare const PAIR_PUBKEY_LEN = 32;
|
|
5
|
+
/** Curve25519 secret key length. */
|
|
6
|
+
export declare const PAIR_SECRET_LEN = 32;
|
|
7
|
+
/** Big-endian uint16 length prefix for the AAD trailer. */
|
|
8
|
+
export declare const PAIR_AAD_LEN_PREFIX = 2;
|
|
9
|
+
/** Server validator caps `encryptedPayload` at 8192 chars (base64). */
|
|
10
|
+
export declare const PAIR_BUNDLE_MAX_BASE64_LEN = 8192;
|
|
11
|
+
/** Conservative ceiling on the trailer to keep the bundle well under cap. */
|
|
12
|
+
export declare const PAIR_AAD_MAX_LEN = 1024;
|
|
13
|
+
/** AAD prefix — version-bumped on any breaking format change. */
|
|
14
|
+
export declare const PAIR_AAD_PREFIX = "oversky:pair:v1";
|
|
15
|
+
export type PairBundleErrorCode = 'BUNDLE_TOO_SHORT' | 'BUNDLE_TOO_LONG' | 'BUNDLE_BAD_BASE64' | 'AAD_LEN_OVERFLOW' | 'AAD_LEN_UNDERFLOW' | 'AAD_MISMATCH' | 'DECRYPT_FAILED' | 'PLAINTEXT_NOT_JSON' | 'PLAINTEXT_BAD_SHAPE' | 'DAEMON_ID_MISMATCH' | 'INVALID_KEY';
|
|
16
|
+
export declare class PairBundleError extends Error {
|
|
17
|
+
readonly code: PairBundleErrorCode;
|
|
18
|
+
constructor(code: PairBundleErrorCode, message: string);
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Plaintext shape sealed inside the box. Versioned via `v` so future
|
|
22
|
+
* additions (e.g. data-encryption-key for L12 envelope handoff) extend
|
|
23
|
+
* the schema without breaking older daemons.
|
|
24
|
+
*
|
|
25
|
+
* `token` is optional because Stage-E #1 step 4 daemon-scope mints ship
|
|
26
|
+
* `opaqueAccessToken` only (no JWT). At least one of `token` or
|
|
27
|
+
* `opaqueAccessToken` MUST be present, enforced in `parsePairPlaintext`.
|
|
28
|
+
*/
|
|
29
|
+
export interface PairPlaintextV1 {
|
|
30
|
+
v: 1;
|
|
31
|
+
/** Daemon-scope JWT — optional after Stage-E #1 step 4. */
|
|
32
|
+
token?: string;
|
|
33
|
+
/** Access token expiry (epoch ms). */
|
|
34
|
+
expiresAt?: number;
|
|
35
|
+
/** Rotating refresh token. */
|
|
36
|
+
refreshToken?: string;
|
|
37
|
+
/** Refresh token expiry (epoch ms). */
|
|
38
|
+
refreshExpiresAt?: number;
|
|
39
|
+
/** Stage-E #1 — opaque `osk_dmn_*` daemon access token. */
|
|
40
|
+
opaqueAccessToken?: string;
|
|
41
|
+
/**
|
|
42
|
+
* The daemonId the credentials were minted for. Daemon MUST verify
|
|
43
|
+
* this matches the `daemonId` it advertised in the QR — guards against
|
|
44
|
+
* a misdirected /respond targeting a different daemon's pairing row.
|
|
45
|
+
*/
|
|
46
|
+
daemonId: string;
|
|
47
|
+
/** Mobile-side issuance time (epoch ms). For audit/log only. */
|
|
48
|
+
issuedAt: number;
|
|
49
|
+
}
|
|
50
|
+
export interface PairBundle {
|
|
51
|
+
/** Raw 24-byte nonce. */
|
|
52
|
+
nonce: Uint8Array;
|
|
53
|
+
/** Raw 32-byte mobile ephemeral public key. */
|
|
54
|
+
ephemeralPublicKey: Uint8Array;
|
|
55
|
+
/** Box ciphertext (plaintext + Poly1305 tag). */
|
|
56
|
+
ciphertext: Uint8Array;
|
|
57
|
+
/** Canonical AAD bytes — SHOULD match the rebuilt AAD on verify. */
|
|
58
|
+
aad: Uint8Array;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Build the canonical AAD bytes for a pairing response. Both sides
|
|
62
|
+
* (mobile-builder and daemon-verifier) call this with identical inputs:
|
|
63
|
+
* - mobile gets `responseUserId` from its own JWT (`req.user.id`)
|
|
64
|
+
* - daemon gets `responseUserId` from the `/poll` response
|
|
65
|
+
*
|
|
66
|
+
* The encoding is ASCII; non-ASCII inputs are rejected so a hostile
|
|
67
|
+
* daemonId/userId can't smuggle bytes that would shift the boundary.
|
|
68
|
+
*/
|
|
69
|
+
export declare function buildPairAad(daemonId: string, responseUserId: string): Uint8Array;
|
|
70
|
+
/**
|
|
71
|
+
* Parse and validate the JSON plaintext that came out of the box. We
|
|
72
|
+
* keep this tolerant on output (older daemons may write extra fields)
|
|
73
|
+
* but strict on the invariant fields the daemon needs to persist.
|
|
74
|
+
*/
|
|
75
|
+
export declare function parsePairPlaintext(raw: unknown): PairPlaintextV1;
|
|
76
|
+
export interface AssembleBundleOptions {
|
|
77
|
+
/** Plaintext to seal — JSON-stringified before boxing. */
|
|
78
|
+
plaintext: PairPlaintextV1;
|
|
79
|
+
/** Daemon's ephemeral X25519 public key (32 bytes). */
|
|
80
|
+
daemonPublicKey: Uint8Array;
|
|
81
|
+
/** Mobile's ephemeral X25519 secret key (32 bytes). */
|
|
82
|
+
ephemeralSecretKey: Uint8Array;
|
|
83
|
+
/** Mobile's ephemeral X25519 public key (32 bytes), echoed into bundle. */
|
|
84
|
+
ephemeralPublicKey: Uint8Array;
|
|
85
|
+
/** Canonical AAD bytes from `buildPairAad`. */
|
|
86
|
+
aad: Uint8Array;
|
|
87
|
+
/**
|
|
88
|
+
* Override the random nonce — for tests only. Production callers must
|
|
89
|
+
* leave this undefined so a fresh nonce is generated.
|
|
90
|
+
*/
|
|
91
|
+
__nonceForTest?: Uint8Array;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Mobile-side: encrypt the plaintext to the daemon's pubkey and frame
|
|
95
|
+
* the result for transport. Returns base64-encoded bundle suitable for
|
|
96
|
+
* the `encryptedPayload` field of `POST /api/auth/pair/respond`.
|
|
97
|
+
*/
|
|
98
|
+
export declare function assemblePairBundleBase64(opts: AssembleBundleOptions): string;
|
|
99
|
+
/**
|
|
100
|
+
* Parse the wire bytes into a `PairBundle` without decrypting. Used as
|
|
101
|
+
* a preflight by `openPairBundleBase64` and exposed for tests.
|
|
102
|
+
*/
|
|
103
|
+
export declare function parsePairBundle(bytes: Uint8Array): PairBundle;
|
|
104
|
+
export interface OpenBundleOptions {
|
|
105
|
+
/** Base64 wire payload from `/poll`'s `response` field. */
|
|
106
|
+
encryptedPayloadBase64: string;
|
|
107
|
+
/** Daemon's ephemeral X25519 secret key. */
|
|
108
|
+
daemonSecretKey: Uint8Array;
|
|
109
|
+
/**
|
|
110
|
+
* Canonical AAD bytes the daemon expects, rebuilt from `daemonId` +
|
|
111
|
+
* `responseUserId` returned by the /poll response.
|
|
112
|
+
*/
|
|
113
|
+
expectedAad: Uint8Array;
|
|
114
|
+
/**
|
|
115
|
+
* The daemonId the daemon advertised in its QR. We verify the
|
|
116
|
+
* decrypted plaintext echoes the SAME id, in addition to the AAD
|
|
117
|
+
* trailer the server bound. Belt-and-suspenders.
|
|
118
|
+
*/
|
|
119
|
+
expectedDaemonId: string;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Daemon-side: parse, AAD-verify, decrypt, JSON-parse, validate the
|
|
123
|
+
* pairing response. Returns the validated plaintext on success; throws
|
|
124
|
+
* `PairBundleError` on any failure.
|
|
125
|
+
*
|
|
126
|
+
* Constant-time-ish AAD comparison: tweetnacl's box.open already runs
|
|
127
|
+
* Poly1305 in constant time, and the AAD compare we do is byte-by-byte
|
|
128
|
+
* on a 1KB-bounded buffer — short side-channel exposure is negligible
|
|
129
|
+
* given we're comparing public-known canonical bytes.
|
|
130
|
+
*/
|
|
131
|
+
export declare function openPairBundleBase64(opts: OpenBundleOptions): PairPlaintextV1;
|
|
132
|
+
export interface PairUrlFields {
|
|
133
|
+
/** 32-byte daemon ephemeral public key, base64url no-pad. */
|
|
134
|
+
publicKeyBase64Url: string;
|
|
135
|
+
/** Server-issued pair id, hex. */
|
|
136
|
+
pairId: string;
|
|
137
|
+
/** Daemon id (free-form, ASCII, ≤128 chars). */
|
|
138
|
+
daemonId: string;
|
|
139
|
+
/**
|
|
140
|
+
* User-friendly machine label so the mobile can show "Approve pairing
|
|
141
|
+
* with MacBook-Pro?" — purely UX, not security-bearing.
|
|
142
|
+
*/
|
|
143
|
+
host?: string;
|
|
144
|
+
/** Server URL the daemon is paired against, for split-deployment hints. */
|
|
145
|
+
serverUrl?: string;
|
|
146
|
+
/**
|
|
147
|
+
* Wire-format protocol version. Bump on any breaking change to the
|
|
148
|
+
* bundle layout or AAD canonical form.
|
|
149
|
+
*/
|
|
150
|
+
v?: number;
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Build the URL the daemon prints as a QR code. URL-safe parameters
|
|
154
|
+
* only; the bulk is the public key, which is fine.
|
|
155
|
+
*/
|
|
156
|
+
export declare function buildPairUrl(fields: PairUrlFields): string;
|
|
157
|
+
/**
|
|
158
|
+
* Parse a scanned URL back into structured fields. Returns null on any
|
|
159
|
+
* format error so the caller can show a user-friendly "not a pair QR"
|
|
160
|
+
* message rather than a stack trace. Strict validation of every field's
|
|
161
|
+
* shape keeps a hostile QR from steering the mobile at a bad daemonId.
|
|
162
|
+
*/
|
|
163
|
+
export declare function parsePairUrl(url: string): PairUrlFields | null;
|
|
164
|
+
export declare function uint8ArrayToBase64(bytes: Uint8Array): string;
|
|
165
|
+
export declare function base64ToUint8Array(b64: string): Uint8Array;
|
|
166
|
+
export declare function uint8ArrayToBase64Url(bytes: Uint8Array): string;
|
|
167
|
+
export declare function base64UrlToUint8Array(b64url: string): Uint8Array;
|
|
168
|
+
export interface PairKeyPair {
|
|
169
|
+
publicKey: Uint8Array;
|
|
170
|
+
secretKey: Uint8Array;
|
|
171
|
+
}
|
|
172
|
+
export declare function generatePairKeyPair(): PairKeyPair;
|