diff --git a/src/orb/broker.ts b/src/orb/broker.ts index b23c12d8ab..3891861b47 100644 --- a/src/orb/broker.ts +++ b/src/orb/broker.ts @@ -12,13 +12,8 @@ // the installation's account server-side before issuing — both bind installation_id at issue time, so the OAuth // privilege-escalation surface the red-team flagged stays closed. import { createOpaqueToken, hashToken } from "../auth/security"; -import { decryptSecret, encryptSecret } from "../utils/crypto"; import { createOrbInstallationToken } from "./app-auth"; -// A minted GitHub installation token lasts ~1h; re-mint only once it's under this margin so a near-expiry entry is -// never handed out (covers clock skew + the engine's own ~5m cache margin). -const ORB_TOKEN_CACHE_MIN_REMAINING_MS = 10 * 60_000; - export function isOrbBrokerEnabled(env: Env): boolean { return /^(1|true|yes|on)$/i.test(String(env.ORB_BROKER_ENABLED ?? "").trim()); } @@ -55,57 +50,16 @@ export type BrokerResult = { token: string; installationId: number; expiresAt: s * registered=1 and neither suspended nor removed at mint time (the gate is re-checked, not trusted from issue). */ export async function brokerOrbToken(env: Env, secret: string): Promise { const row = await env.DB - .prepare("SELECT enroll_id, installation_id, state, revoked_at, cached_token_json FROM orb_enrollments WHERE secret_hash = ?") + .prepare("SELECT enroll_id, installation_id, state, revoked_at FROM orb_enrollments WHERE secret_hash = ?") .bind(await hashToken(secret)) - .first<{ enroll_id: string; installation_id: number; state: string; revoked_at: string | null; cached_token_json: string | null }>(); + .first<{ enroll_id: string; installation_id: number; state: string; revoked_at: string | null }>(); if (!row || row.state !== "enrolled" || row.revoked_at !== null) return { error: "invalid_enrollment" }; const install = await env.DB .prepare("SELECT registered, suspended_at, removed_at FROM orb_github_installations WHERE installation_id = ?") .bind(row.installation_id) .first<{ registered: number; suspended_at: string | null; removed_at: string | null }>(); if (!install || install.registered !== 1 || install.suspended_at !== null || install.removed_at !== null) return { error: "installation_not_eligible" }; - // Serve a still-fresh cached token instead of re-minting. GitHub installation tokens last ~1h, and minting on - // EVERY broker call throttles GitHub's token endpoint (16-20s responses → engine timeouts → orb_broker_unavailable). - // The token is cached encrypted-at-rest (AES-256-GCM via TOKEN_ENCRYPTION_SECRET); with no key set the cache is - // skipped and we mint every call exactly as before. - const cached = await readCachedOrbToken(env, row.cached_token_json); - if (cached) { - await touchLastToken(env, row.enroll_id); - return { token: cached.token, installationId: row.installation_id, expiresAt: cached.expiresAt }; - } const minted = await createOrbInstallationToken(env, row.installation_id); - await cacheOrbToken(env, row.enroll_id, minted); - await touchLastToken(env, row.enroll_id); + await env.DB.prepare("UPDATE orb_enrollments SET last_token_at = CURRENT_TIMESTAMP WHERE enroll_id = ?").bind(row.enroll_id).run(); return { token: minted.token, installationId: row.installation_id, expiresAt: minted.expiresAt }; } - -async function touchLastToken(env: Env, enrollId: string): Promise { - await env.DB.prepare("UPDATE orb_enrollments SET last_token_at = CURRENT_TIMESTAMP WHERE enroll_id = ?").bind(enrollId).run(); -} - -/** Decrypt + return the cached installation token when present and still safely before expiry; null (→ re-mint) on - * no key, no cache, an expired/unparseable entry, or any decrypt failure (e.g. a rotated encryption key). */ -async function readCachedOrbToken(env: Env, cachedJson: string | null): Promise<{ token: string; expiresAt: string } | null> { - if (!env.TOKEN_ENCRYPTION_SECRET || !cachedJson) return null; - try { - const entry = JSON.parse(cachedJson) as { ciphertext: string; iv: string; salt: string | null; expiresAt: string }; - if (!(Date.parse(entry.expiresAt) - Date.now() >= ORB_TOKEN_CACHE_MIN_REMAINING_MS)) return null; - const token = await decryptSecret(entry.ciphertext, entry.iv, env.TOKEN_ENCRYPTION_SECRET, entry.salt); - return { token, expiresAt: entry.expiresAt }; - } catch { - return null; - } -} - -/** Cache the freshly-minted token (encrypted) on the enrollment row. Best-effort + fail-safe: a cache-write error - * must never fail a valid token exchange — the next call simply re-mints. No-op without an encryption key. */ -async function cacheOrbToken(env: Env, enrollId: string, minted: { token: string; expiresAt: string }): Promise { - if (!env.TOKEN_ENCRYPTION_SECRET) return; - try { - const enc = await encryptSecret(minted.token, env.TOKEN_ENCRYPTION_SECRET); - const json = JSON.stringify({ ciphertext: enc.ciphertext, iv: enc.iv, salt: enc.salt, expiresAt: minted.expiresAt }); - await env.DB.prepare("UPDATE orb_enrollments SET cached_token_json = ? WHERE enroll_id = ?").bind(json, enrollId).run(); - } catch (error) { - console.warn(JSON.stringify({ level: "warn", event: "orb_token_cache_write_failed", enrollId, message: String(error).slice(0, 120) })); - } -} diff --git a/test/integration/orb-broker.test.ts b/test/integration/orb-broker.test.ts index 5bb47ee1e8..33128be800 100644 --- a/test/integration/orb-broker.test.ts +++ b/test/integration/orb-broker.test.ts @@ -68,65 +68,6 @@ describe("brokerOrbToken", () => { await db(e).prepare("UPDATE orb_github_installations SET suspended_at=CURRENT_TIMESTAMP WHERE installation_id=302").run(); expect(await brokerOrbToken(e, secret)).toEqual({ error: "installation_not_eligible" }); }); - - it("caches the minted token (encrypted) and serves it WITHOUT re-minting on the next exchange (#12)", async () => { - const e = await brokerEnv({ TOKEN_ENCRYPTION_SECRET: "test-encryption-key-material-0001" }); - await seedInstall(e, 310, { registered: 1 }); - const { secret } = (await issueOrbEnrollment(e, 310)) as { secret: string }; - let mints = 0; - vi.stubGlobal("fetch", async () => { - mints += 1; - return Response.json({ token: `ghs_${mints}`, expires_at: new Date(Date.now() + 60 * 60_000).toISOString() }); - }); - const first = await brokerOrbToken(e, secret); - const second = await brokerOrbToken(e, secret); - expect(first).toMatchObject({ token: "ghs_1", installationId: 310 }); - expect(second).toMatchObject({ token: "ghs_1" }); // served from the cache, NOT re-minted - expect(mints).toBe(1); // GitHub's token endpoint was hit ONCE across two exchanges (no throttling) - const cached = (await db(e).prepare("SELECT cached_token_json FROM orb_enrollments WHERE installation_id=310").first<{ cached_token_json: string }>())?.cached_token_json ?? ""; - expect(cached).not.toContain("ghs_1"); // stored encrypted, never plaintext - expect(cached).toContain("ciphertext"); - }); - - it("re-mints when the cached token is within the expiry margin (never serves a near-expired token)", async () => { - const e = await brokerEnv({ TOKEN_ENCRYPTION_SECRET: "test-encryption-key-material-0001" }); - await seedInstall(e, 311, { registered: 1 }); - const { secret } = (await issueOrbEnrollment(e, 311)) as { secret: string }; - let mints = 0; - vi.stubGlobal("fetch", async () => { - mints += 1; - return Response.json({ token: `ghs_${mints}`, expires_at: new Date(Date.now() + 60 * 60_000).toISOString() }); - }); - // Seed a cache entry only ~5m from expiry (inside the 10m re-mint margin) — read returns before decrypting. - await db(e).prepare("UPDATE orb_enrollments SET cached_token_json = ? WHERE installation_id=311").bind(JSON.stringify({ ciphertext: "x", iv: "y", salt: null, expiresAt: new Date(Date.now() + 5 * 60_000).toISOString() })).run(); - expect(await brokerOrbToken(e, secret)).toMatchObject({ token: "ghs_1" }); // a fresh mint, not the near-expired entry - expect(mints).toBe(1); - }); - - it("re-mints when the cached entry is unparseable (JSON/decrypt failure falls through)", async () => { - const e = await brokerEnv({ TOKEN_ENCRYPTION_SECRET: "test-encryption-key-material-0001" }); - await seedInstall(e, 312, { registered: 1 }); - const { secret } = (await issueOrbEnrollment(e, 312)) as { secret: string }; - await db(e).prepare("UPDATE orb_enrollments SET cached_token_json = 'not-json' WHERE installation_id=312").run(); - vi.stubGlobal("fetch", async () => Response.json({ token: "ghs_fresh", expires_at: new Date(Date.now() + 60 * 60_000).toISOString() })); - expect(await brokerOrbToken(e, secret)).toMatchObject({ token: "ghs_fresh" }); // malformed cache ignored, re-minted - }); - - it("swallows a cache-write failure — a valid token exchange never fails on a cache hiccup", async () => { - const e = await brokerEnv({ TOKEN_ENCRYPTION_SECRET: "test-encryption-key-material-0001" }); - await seedInstall(e, 313, { registered: 1 }); - const { secret } = (await issueOrbEnrollment(e, 313)) as { secret: string }; - vi.stubGlobal("fetch", async () => Response.json({ token: "ghs_ok", expires_at: new Date(Date.now() + 60 * 60_000).toISOString() })); - const real = e.DB; - (e as { DB: unknown }).DB = { - prepare: (sql: string) => - sql.includes("SET cached_token_json") ? { bind: () => ({ run: () => Promise.reject(new Error("cache write boom")) }) } : real.prepare(sql), - }; - const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); - expect(await brokerOrbToken(e, secret)).toMatchObject({ token: "ghs_ok" }); // mint succeeded despite the cache write failing - expect(warn.mock.calls.some(([l]) => String(l).includes("orb_token_cache_write_failed"))).toBe(true); - warn.mockRestore(); - }); }); describe("broker endpoints", () => {