@skrr-ai/auth-core 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. package/README.md +21 -0
  2. package/dist/cjs/aead.d.ts +24 -0
  3. package/dist/cjs/aead.js +65 -0
  4. package/dist/cjs/authExpiredDetector.d.ts +65 -0
  5. package/dist/cjs/authExpiredDetector.js +89 -0
  6. package/dist/cjs/authHelper.d.ts +51 -0
  7. package/dist/cjs/authHelper.js +321 -0
  8. package/dist/cjs/credentialEnvelope.d.ts +85 -0
  9. package/dist/cjs/credentialEnvelope.js +230 -0
  10. package/dist/cjs/credentialEnvelopeBridge.d.ts +116 -0
  11. package/dist/cjs/credentialEnvelopeBridge.js +637 -0
  12. package/dist/cjs/credentialResolver.d.ts +278 -0
  13. package/dist/cjs/credentialResolver.js +423 -0
  14. package/dist/cjs/daemonPair.d.ts +172 -0
  15. package/dist/cjs/daemonPair.js +495 -0
  16. package/dist/cjs/daemonScopes.d.ts +74 -0
  17. package/dist/cjs/daemonScopes.js +84 -0
  18. package/dist/cjs/deviceIdentityBridge.d.ts +113 -0
  19. package/dist/cjs/deviceIdentityBridge.js +591 -0
  20. package/dist/cjs/deviceKey.d.ts +167 -0
  21. package/dist/cjs/deviceKey.js +332 -0
  22. package/dist/cjs/fdToken.d.ts +17 -0
  23. package/dist/cjs/fdToken.js +238 -0
  24. package/dist/cjs/harnessTrust.d.ts +72 -0
  25. package/dist/cjs/harnessTrust.js +103 -0
  26. package/dist/cjs/headers-helper.d.ts +25 -0
  27. package/dist/cjs/headers-helper.js +323 -0
  28. package/dist/cjs/index.d.ts +37 -0
  29. package/dist/cjs/index.js +305 -0
  30. package/dist/cjs/jwtUtils.d.ts +35 -0
  31. package/dist/cjs/jwtUtils.js +61 -0
  32. package/dist/cjs/kek/index.d.ts +89 -0
  33. package/dist/cjs/kek/index.js +208 -0
  34. package/dist/cjs/kek/linux.d.ts +92 -0
  35. package/dist/cjs/kek/linux.js +596 -0
  36. package/dist/cjs/kek/macos.d.ts +112 -0
  37. package/dist/cjs/kek/macos.js +599 -0
  38. package/dist/cjs/kek/types.d.ts +111 -0
  39. package/dist/cjs/kek/types.js +43 -0
  40. package/dist/cjs/kek/windows.d.ts +71 -0
  41. package/dist/cjs/kek/windows.js +350 -0
  42. package/dist/cjs/kek/zeroize-registry.d.ts +44 -0
  43. package/dist/cjs/kek/zeroize-registry.js +63 -0
  44. package/dist/cjs/legacyStatePreflight.d.ts +67 -0
  45. package/dist/cjs/legacyStatePreflight.js +78 -0
  46. package/dist/cjs/localIdentity.d.ts +54 -0
  47. package/dist/cjs/localIdentity.js +57 -0
  48. package/dist/cjs/loginLocalhost.d.ts +115 -0
  49. package/dist/cjs/loginLocalhost.js +368 -0
  50. package/dist/cjs/loginWithLocalhost.d.ts +131 -0
  51. package/dist/cjs/loginWithLocalhost.js +359 -0
  52. package/dist/cjs/machineId.d.ts +35 -0
  53. package/dist/cjs/machineId.js +239 -0
  54. package/dist/cjs/messages.d.ts +9 -0
  55. package/dist/cjs/messages.js +44 -0
  56. package/dist/cjs/package.json +3 -0
  57. package/dist/cjs/pkce.d.ts +62 -0
  58. package/dist/cjs/pkce.js +158 -0
  59. package/dist/cjs/recoveryCode.d.ts +61 -0
  60. package/dist/cjs/recoveryCode.js +213 -0
  61. package/dist/cjs/refresh.d.ts +215 -0
  62. package/dist/cjs/refresh.js +877 -0
  63. package/dist/cjs/refreshClassification.d.ts +53 -0
  64. package/dist/cjs/refreshClassification.js +105 -0
  65. package/dist/cjs/refreshScheduler.d.ts +53 -0
  66. package/dist/cjs/refreshScheduler.js +332 -0
  67. package/dist/cjs/runtime.d.ts +149 -0
  68. package/dist/cjs/runtime.js +136 -0
  69. package/dist/cjs/spawnEnv.d.ts +76 -0
  70. package/dist/cjs/spawnEnv.js +153 -0
  71. package/dist/cjs/ttlParser.d.ts +54 -0
  72. package/dist/cjs/ttlParser.js +87 -0
  73. package/dist/cjs/types.d.ts +91 -0
  74. package/dist/cjs/types.js +26 -0
  75. package/dist/esm/aead.d.ts +24 -0
  76. package/dist/esm/aead.js +57 -0
  77. package/dist/esm/authExpiredDetector.d.ts +65 -0
  78. package/dist/esm/authExpiredDetector.js +85 -0
  79. package/dist/esm/authHelper.d.ts +51 -0
  80. package/dist/esm/authHelper.js +311 -0
  81. package/dist/esm/credentialEnvelope.d.ts +85 -0
  82. package/dist/esm/credentialEnvelope.js +213 -0
  83. package/dist/esm/credentialEnvelopeBridge.d.ts +116 -0
  84. package/dist/esm/credentialEnvelopeBridge.js +620 -0
  85. package/dist/esm/credentialResolver.d.ts +278 -0
  86. package/dist/esm/credentialResolver.js +414 -0
  87. package/dist/esm/daemonPair.d.ts +172 -0
  88. package/dist/esm/daemonPair.js +476 -0
  89. package/dist/esm/daemonScopes.d.ts +74 -0
  90. package/dist/esm/daemonScopes.js +80 -0
  91. package/dist/esm/deviceIdentityBridge.d.ts +113 -0
  92. package/dist/esm/deviceIdentityBridge.js +572 -0
  93. package/dist/esm/deviceKey.d.ts +167 -0
  94. package/dist/esm/deviceKey.js +320 -0
  95. package/dist/esm/fdToken.d.ts +17 -0
  96. package/dist/esm/fdToken.js +231 -0
  97. package/dist/esm/harnessTrust.d.ts +72 -0
  98. package/dist/esm/harnessTrust.js +97 -0
  99. package/dist/esm/headers-helper.d.ts +25 -0
  100. package/dist/esm/headers-helper.js +316 -0
  101. package/dist/esm/index.d.ts +37 -0
  102. package/dist/esm/index.js +144 -0
  103. package/dist/esm/jwtUtils.d.ts +35 -0
  104. package/dist/esm/jwtUtils.js +57 -0
  105. package/dist/esm/kek/index.d.ts +89 -0
  106. package/dist/esm/kek/index.js +162 -0
  107. package/dist/esm/kek/linux.d.ts +92 -0
  108. package/dist/esm/kek/linux.js +585 -0
  109. package/dist/esm/kek/macos.d.ts +112 -0
  110. package/dist/esm/kek/macos.js +586 -0
  111. package/dist/esm/kek/types.d.ts +111 -0
  112. package/dist/esm/kek/types.js +39 -0
  113. package/dist/esm/kek/windows.d.ts +71 -0
  114. package/dist/esm/kek/windows.js +344 -0
  115. package/dist/esm/kek/zeroize-registry.d.ts +44 -0
  116. package/dist/esm/kek/zeroize-registry.js +58 -0
  117. package/dist/esm/legacyStatePreflight.d.ts +67 -0
  118. package/dist/esm/legacyStatePreflight.js +71 -0
  119. package/dist/esm/localIdentity.d.ts +54 -0
  120. package/dist/esm/localIdentity.js +54 -0
  121. package/dist/esm/loginLocalhost.d.ts +115 -0
  122. package/dist/esm/loginLocalhost.js +355 -0
  123. package/dist/esm/loginWithLocalhost.d.ts +131 -0
  124. package/dist/esm/loginWithLocalhost.js +353 -0
  125. package/dist/esm/machineId.d.ts +35 -0
  126. package/dist/esm/machineId.js +231 -0
  127. package/dist/esm/messages.d.ts +9 -0
  128. package/dist/esm/messages.js +40 -0
  129. package/dist/esm/pkce.d.ts +62 -0
  130. package/dist/esm/pkce.js +148 -0
  131. package/dist/esm/recoveryCode.d.ts +61 -0
  132. package/dist/esm/recoveryCode.js +207 -0
  133. package/dist/esm/refresh.d.ts +215 -0
  134. package/dist/esm/refresh.js +863 -0
  135. package/dist/esm/refreshClassification.d.ts +53 -0
  136. package/dist/esm/refreshClassification.js +106 -0
  137. package/dist/esm/refreshScheduler.d.ts +53 -0
  138. package/dist/esm/refreshScheduler.js +329 -0
  139. package/dist/esm/runtime.d.ts +149 -0
  140. package/dist/esm/runtime.js +121 -0
  141. package/dist/esm/spawnEnv.d.ts +76 -0
  142. package/dist/esm/spawnEnv.js +149 -0
  143. package/dist/esm/ttlParser.d.ts +54 -0
  144. package/dist/esm/ttlParser.js +83 -0
  145. package/dist/esm/types.d.ts +91 -0
  146. package/dist/esm/types.js +21 -0
  147. package/package.json +82 -0
@@ -0,0 +1,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;