From cc464da9c5700c82989b9739fcb3ad85351ec46b Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 28 Jul 2026 21:05:28 -0700 Subject: [PATCH] Add the Subrouter cloud API under cmux.com/api/v1/sr Subrouter's accounts live on one Mac mini. When that host exhausted its kernel mbuf pool today, every client lost routing at once and recovery needed a KVM console. Moving custody off a single machine is the fix; this is the server half. Routes are oRPC procedures on the existing router, so they surface at /api/v1/sr/* through the OpenAPIHandler already mounted there and appear in the generated OpenAPI document with no separate spec to maintain. Both checked-in specs are regenerated accordingly. Credentials are team-scoped: Stack team membership is the authorization boundary, so uploading an account to a team is the same act as sharing it with teammates, and there is no second ACL to drift. Payloads are sealed with AES-256-GCM whose key lives in SR_VAULT_KEY rather than the database, so a dump alone yields no usable OAuth refresh chain, and a missing or wrong-length key fails loudly instead of silently storing plaintext. Login is a device-code flow rather than a localhost redirect, because the CLI routinely runs over SSH and in containers where nothing can reach 127.0.0.1. Only a digest of the CLI-held device code is stored, approval is consumed single-use, and an unknown code is reported identically to an expired one so polling cannot probe for valid codes. User codes omit 0/O and 1/I/L since a human reads them aloud. Push takes a batch so the mini's seven accounts migrate in one request, and re-uploading an existing account refreshes it in place rather than duplicating. The list endpoint returns identities only and never credential material. --- .../Sources/CmuxAPIClient/openapi.json | 280 ++++++++++++++++++ .../20260728160000_sr_vault/migration.sql | 46 +++ web/db/schema.ts | 50 ++++ web/openapi/openapi.json | 280 ++++++++++++++++++ web/orpc/server/router.ts | 12 + web/orpc/server/sr/accounts.ts | 134 +++++++++ web/orpc/server/sr/device.ts | 134 +++++++++ web/orpc/server/sr/schemas.ts | 63 ++++ web/services/subrouter/vaultCrypto.ts | 101 +++++++ web/tests/sr-vault.test.ts | 131 ++++++++ 10 files changed, 1231 insertions(+) create mode 100644 web/db/migrations/20260728160000_sr_vault/migration.sql create mode 100644 web/orpc/server/sr/accounts.ts create mode 100644 web/orpc/server/sr/device.ts create mode 100644 web/orpc/server/sr/schemas.ts create mode 100644 web/services/subrouter/vaultCrypto.ts create mode 100644 web/tests/sr-vault.test.ts diff --git a/Packages/Shared/CmuxAPIClient/Sources/CmuxAPIClient/openapi.json b/Packages/Shared/CmuxAPIClient/Sources/CmuxAPIClient/openapi.json index 79288c20a3f6..6c85a5b96c4e 100644 --- a/Packages/Shared/CmuxAPIClient/Sources/CmuxAPIClient/openapi.json +++ b/Packages/Shared/CmuxAPIClient/Sources/CmuxAPIClient/openapi.json @@ -64,6 +64,286 @@ } } } + }, + "/sr/device/start": { + "post": { + "operationId": "sr.device.start", + "summary": "Begin a device-code login", + "description": "Issues a device code for the CLI and a short user code for the human to approve in a browser. Grants no access until approved.", + "tags": [ + "Subrouter" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "userCode": { + "type": "string" + }, + "deviceCode": { + "type": "string" + }, + "verificationUri": { + "type": "string" + }, + "expiresInSeconds": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991, + "exclusiveMinimum": 0 + }, + "intervalSeconds": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991, + "exclusiveMinimum": 0 + } + }, + "required": [ + "userCode", + "deviceCode", + "verificationUri", + "expiresInSeconds", + "intervalSeconds" + ] + } + } + } + } + } + } + }, + "/sr/device/poll": { + "post": { + "operationId": "sr.device.poll", + "summary": "Poll a pending device-code login", + "description": "Returns pending until a signed-in human approves the matching user code, then returns the approved team once.", + "tags": [ + "Subrouter" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "deviceCode": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "deviceCode" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "enum": [ + "pending", + "approved", + "expired" + ], + "type": "string" + }, + "teamId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "userId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "status", + "teamId", + "userId" + ] + } + } + } + } + } + } + }, + "/sr/accounts/push": { + "post": { + "operationId": "sr.accounts.push", + "summary": "Upload Subrouter credentials to the team vault", + "description": "Seals one or more provider credentials and stores them for the caller's team. Existing accounts are updated in place rather than duplicated.", + "tags": [ + "Subrouter" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "accounts": { + "type": "array", + "minItems": 1, + "maxItems": 100, + "items": { + "type": "object", + "properties": { + "provider": { + "enum": [ + "codex", + "claude", + "gemini", + "apikey" + ], + "type": "string" + }, + "accountLabel": { + "type": "string", + "minLength": 1, + "maxLength": 320 + }, + "credential": { + "type": "string", + "minLength": 1, + "maxLength": 64000 + } + }, + "required": [ + "provider", + "accountLabel", + "credential" + ] + } + } + }, + "required": [ + "accounts" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "teamId": { + "type": "string" + }, + "uploaded": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "updated": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "teamId", + "uploaded", + "updated" + ] + } + } + } + } + } + } + }, + "/sr/accounts": { + "get": { + "operationId": "sr.accounts.list", + "summary": "List Subrouter accounts available to the team", + "description": "Returns account identities only. Credential material is never returned by this endpoint.", + "tags": [ + "Subrouter" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "teamId": { + "type": "string" + }, + "accounts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "provider": { + "enum": [ + "codex", + "claude", + "gemini", + "apikey" + ], + "type": "string" + }, + "accountLabel": { + "type": "string", + "minLength": 1, + "maxLength": 320 + }, + "updatedAt": { + "type": "string" + } + }, + "required": [ + "provider", + "accountLabel", + "updatedAt" + ] + } + } + }, + "required": [ + "teamId", + "accounts" + ] + } + } + } + } + } + } } } } diff --git a/web/db/migrations/20260728160000_sr_vault/migration.sql b/web/db/migrations/20260728160000_sr_vault/migration.sql new file mode 100644 index 000000000000..c540530dea19 --- /dev/null +++ b/web/db/migrations/20260728160000_sr_vault/migration.sql @@ -0,0 +1,46 @@ +-- Subrouter credential vault and device-code login state. +-- +-- Vault entries are team-scoped: Stack Auth team membership is the authorization +-- boundary, so uploading an account to a team is the same act as sharing it. +-- The credential payload is stored as an AES-256-GCM envelope (ciphertext plus +-- nonce, key held outside the database in SR_VAULT_KEY) so a database dump alone +-- does not yield usable OAuth refresh chains. +CREATE TABLE IF NOT EXISTS "sr_vault_entries" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "team_id" text NOT NULL, + "provider" text NOT NULL, + "account_label" text NOT NULL, + "ciphertext" text NOT NULL, + "nonce" text NOT NULL, + "key_version" integer DEFAULT 1 NOT NULL, + "created_by_user_id" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +-- One row per team/provider/account: re-uploading refreshes in place rather than +-- accumulating duplicate credentials for the same account. +CREATE UNIQUE INDEX IF NOT EXISTS "sr_vault_entries_team_provider_account_unique" + ON "sr_vault_entries" ("team_id", "provider", "account_label"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "sr_vault_entries_team_idx" ON "sr_vault_entries" ("team_id"); +--> statement-breakpoint +-- Pending device-code logins. Only a digest of the CLI-held device code is +-- stored, so a dump cannot be replayed to complete a pending login. +CREATE TABLE IF NOT EXISTS "sr_device_codes" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_code" text NOT NULL, + "device_code_hash" text NOT NULL, + "user_id" text, + "team_id" text, + "approved_at" timestamp with time zone, + "expires_at" timestamp with time zone NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "sr_device_codes_user_code_unique" ON "sr_device_codes" ("user_code"); +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "sr_device_codes_hash_unique" ON "sr_device_codes" ("device_code_hash"); +--> statement-breakpoint +-- Supports the opportunistic sweep of expired rows on each start call. +CREATE INDEX IF NOT EXISTS "sr_device_codes_expires_idx" ON "sr_device_codes" ("expires_at"); diff --git a/web/db/schema.ts b/web/db/schema.ts index 03dc36721292..7a7ce60f317f 100644 --- a/web/db/schema.ts +++ b/web/db/schema.ts @@ -942,3 +942,53 @@ export const cloudVmNotificationDeliveries = pgTable( .on(table.eventId, table.status), ], ); + +// Subrouter credential vault. Entries are team-scoped: authorization comes from +// Stack Auth team membership, and the secret payload is encrypted at rest so a +// database dump alone does not yield usable OAuth refresh chains. +export const srVaultEntries = pgTable( + "sr_vault_entries", + { + id: uuid("id").defaultRandom().primaryKey(), + teamId: text("team_id").notNull(), + // Provider account identity, e.g. "codex" + "lawrence@manaflow.ai". + provider: text("provider").notNull(), + accountLabel: text("account_label").notNull(), + // AES-256-GCM envelope: nonce and ciphertext are stored separately so the + // key can be rotated without reparsing an opaque blob. + ciphertext: text("ciphertext").notNull(), + nonce: text("nonce").notNull(), + keyVersion: integer("key_version").notNull().default(1), + createdByUserId: text("created_by_user_id").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + uniqueIndex("sr_vault_entries_team_provider_account_unique") + .on(table.teamId, table.provider, table.accountLabel), + index("sr_vault_entries_team_idx").on(table.teamId), + ], +); + +// Pending device-code logins. Short-lived by construction: poll deletes the row +// on success and expired rows are swept on read, so this never accumulates. +export const srDeviceCodes = pgTable( + "sr_device_codes", + { + id: uuid("id").defaultRandom().primaryKey(), + // Shown to the human, entered in a browser. + userCode: text("user_code").notNull(), + // Held only by the CLI, exchanged for a token. + deviceCodeHash: text("device_code_hash").notNull(), + userId: text("user_id"), + teamId: text("team_id"), + approvedAt: timestamp("approved_at", { withTimezone: true }), + expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + uniqueIndex("sr_device_codes_user_code_unique").on(table.userCode), + uniqueIndex("sr_device_codes_hash_unique").on(table.deviceCodeHash), + index("sr_device_codes_expires_idx").on(table.expiresAt), + ], +); diff --git a/web/openapi/openapi.json b/web/openapi/openapi.json index 79288c20a3f6..6c85a5b96c4e 100644 --- a/web/openapi/openapi.json +++ b/web/openapi/openapi.json @@ -64,6 +64,286 @@ } } } + }, + "/sr/device/start": { + "post": { + "operationId": "sr.device.start", + "summary": "Begin a device-code login", + "description": "Issues a device code for the CLI and a short user code for the human to approve in a browser. Grants no access until approved.", + "tags": [ + "Subrouter" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "userCode": { + "type": "string" + }, + "deviceCode": { + "type": "string" + }, + "verificationUri": { + "type": "string" + }, + "expiresInSeconds": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991, + "exclusiveMinimum": 0 + }, + "intervalSeconds": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991, + "exclusiveMinimum": 0 + } + }, + "required": [ + "userCode", + "deviceCode", + "verificationUri", + "expiresInSeconds", + "intervalSeconds" + ] + } + } + } + } + } + } + }, + "/sr/device/poll": { + "post": { + "operationId": "sr.device.poll", + "summary": "Poll a pending device-code login", + "description": "Returns pending until a signed-in human approves the matching user code, then returns the approved team once.", + "tags": [ + "Subrouter" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "deviceCode": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "deviceCode" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "enum": [ + "pending", + "approved", + "expired" + ], + "type": "string" + }, + "teamId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "userId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "status", + "teamId", + "userId" + ] + } + } + } + } + } + } + }, + "/sr/accounts/push": { + "post": { + "operationId": "sr.accounts.push", + "summary": "Upload Subrouter credentials to the team vault", + "description": "Seals one or more provider credentials and stores them for the caller's team. Existing accounts are updated in place rather than duplicated.", + "tags": [ + "Subrouter" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "accounts": { + "type": "array", + "minItems": 1, + "maxItems": 100, + "items": { + "type": "object", + "properties": { + "provider": { + "enum": [ + "codex", + "claude", + "gemini", + "apikey" + ], + "type": "string" + }, + "accountLabel": { + "type": "string", + "minLength": 1, + "maxLength": 320 + }, + "credential": { + "type": "string", + "minLength": 1, + "maxLength": 64000 + } + }, + "required": [ + "provider", + "accountLabel", + "credential" + ] + } + } + }, + "required": [ + "accounts" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "teamId": { + "type": "string" + }, + "uploaded": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "updated": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "teamId", + "uploaded", + "updated" + ] + } + } + } + } + } + } + }, + "/sr/accounts": { + "get": { + "operationId": "sr.accounts.list", + "summary": "List Subrouter accounts available to the team", + "description": "Returns account identities only. Credential material is never returned by this endpoint.", + "tags": [ + "Subrouter" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "teamId": { + "type": "string" + }, + "accounts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "provider": { + "enum": [ + "codex", + "claude", + "gemini", + "apikey" + ], + "type": "string" + }, + "accountLabel": { + "type": "string", + "minLength": 1, + "maxLength": 320 + }, + "updatedAt": { + "type": "string" + } + }, + "required": [ + "provider", + "accountLabel", + "updatedAt" + ] + } + } + }, + "required": [ + "teamId", + "accounts" + ] + } + } + } + } + } + } } } } diff --git a/web/orpc/server/router.ts b/web/orpc/server/router.ts index 02fcd70f0ae5..d41657b64d97 100644 --- a/web/orpc/server/router.ts +++ b/web/orpc/server/router.ts @@ -1,9 +1,21 @@ import { accountMeProcedure } from "./account/me"; +import { srAccountsListProcedure, srAccountsPushProcedure } from "./sr/accounts"; +import { srDevicePollProcedure, srDeviceStartProcedure } from "./sr/device"; export const router = { account: { me: accountMeProcedure, }, + sr: { + device: { + start: srDeviceStartProcedure, + poll: srDevicePollProcedure, + }, + accounts: { + push: srAccountsPushProcedure, + list: srAccountsListProcedure, + }, + }, }; export type AppRouter = typeof router; diff --git a/web/orpc/server/sr/accounts.ts b/web/orpc/server/sr/accounts.ts new file mode 100644 index 000000000000..06801613ab11 --- /dev/null +++ b/web/orpc/server/sr/accounts.ts @@ -0,0 +1,134 @@ +import { ORPCError } from "@orpc/server"; +import { and, eq } from "drizzle-orm"; + +import { cloudDb } from "../../../db/client"; +import { srVaultEntries } from "../../../db/schema"; +import { resolveBillingTeam } from "../../../services/billing/teamResolution"; +import { isVaultConfigured, seal } from "../../../services/subrouter/vaultCrypto"; +import { os, requireAuth, type AuthedUser } from "../base"; +import { + srAccountsListOutputSchema, + srAccountsPushInputSchema, + srAccountsPushOutputSchema, +} from "./schemas"; + +// requireTeam resolves the Stack team that owns the vault entries. Team +// membership is the authorization boundary: there is no separate ACL, so +// sharing an account with the team is the same act as uploading it. +async function requireTeam(user: AuthedUser): Promise { + const team = await resolveBillingTeam(user); + if (!team) { + throw new ORPCError("FORBIDDEN", { + message: "No Stack team resolved for this user; create or select a team first", + }); + } + return team.id; +} + +function requireVault(): void { + if (!isVaultConfigured()) { + // Failing loudly beats storing plaintext credentials by accident. + throw new ORPCError("INTERNAL_SERVER_ERROR", { + message: "Subrouter vault is not configured on this deployment", + }); + } +} + +export const srAccountsPushProcedure = os + .route({ + method: "POST", + path: "/sr/accounts/push", + operationId: "sr.accounts.push", + summary: "Upload Subrouter credentials to the team vault", + description: + "Seals one or more provider credentials and stores them for the caller's team. Existing accounts are updated in place rather than duplicated.", + tags: ["Subrouter"], + successStatus: 200, + }) + .input(srAccountsPushInputSchema) + .output(srAccountsPushOutputSchema) + .use(requireAuth) + .handler(async ({ context, input }) => { + requireVault(); + const teamId = await requireTeam(context.user); + const db = cloudDb(); + + let uploaded = 0; + let updated = 0; + for (const account of input.accounts) { + const sealed = seal(account.credential); + const existing = await db + .select({ id: srVaultEntries.id }) + .from(srVaultEntries) + .where( + and( + eq(srVaultEntries.teamId, teamId), + eq(srVaultEntries.provider, account.provider), + eq(srVaultEntries.accountLabel, account.accountLabel), + ), + ) + .limit(1); + + if (existing.length > 0) { + await db + .update(srVaultEntries) + .set({ + ciphertext: sealed.ciphertext, + nonce: sealed.nonce, + keyVersion: sealed.keyVersion, + updatedAt: new Date(), + }) + .where(eq(srVaultEntries.id, existing[0]!.id)); + updated += 1; + continue; + } + + await db.insert(srVaultEntries).values({ + teamId, + provider: account.provider, + accountLabel: account.accountLabel, + ciphertext: sealed.ciphertext, + nonce: sealed.nonce, + keyVersion: sealed.keyVersion, + createdByUserId: context.user.id ?? "", + }); + uploaded += 1; + } + + return { teamId, uploaded, updated }; + }); + +export const srAccountsListProcedure = os + .route({ + method: "GET", + path: "/sr/accounts", + operationId: "sr.accounts.list", + summary: "List Subrouter accounts available to the team", + description: + "Returns account identities only. Credential material is never returned by this endpoint.", + tags: ["Subrouter"], + successStatus: 200, + }) + .output(srAccountsListOutputSchema) + .use(requireAuth) + .handler(async ({ context }) => { + const teamId = await requireTeam(context.user); + const rows = await cloudDb() + .select({ + provider: srVaultEntries.provider, + accountLabel: srVaultEntries.accountLabel, + updatedAt: srVaultEntries.updatedAt, + }) + .from(srVaultEntries) + .where(eq(srVaultEntries.teamId, teamId)); + + return { + teamId, + accounts: rows.map((row) => ({ + // The column is a plain text column; the schema enum narrows it back. + provider: row.provider as "codex" | "claude" | "gemini" | "apikey", + accountLabel: row.accountLabel, + updatedAt: row.updatedAt.toISOString(), + })), + }; + }); diff --git a/web/orpc/server/sr/device.ts b/web/orpc/server/sr/device.ts new file mode 100644 index 000000000000..742fb1cb28d0 --- /dev/null +++ b/web/orpc/server/sr/device.ts @@ -0,0 +1,134 @@ +import { ORPCError } from "@orpc/server"; +import { eq, lt } from "drizzle-orm"; + +import { cloudDb } from "../../../db/client"; +import { srDeviceCodes } from "../../../db/schema"; +import { + generateDeviceCode, + generateUserCode, + hashDeviceCode, +} from "../../../services/subrouter/vaultCrypto"; +import { os } from "../base"; +import { + srDevicePollInputSchema, + srDevicePollOutputSchema, + srDeviceStartOutputSchema, +} from "./schemas"; + +// Device-code login, chosen over a localhost redirect because the CLI routinely +// runs over SSH and inside containers where no browser can reach 127.0.0.1. + +const DEVICE_CODE_TTL_SECONDS = 900; +const POLL_INTERVAL_SECONDS = 5; + +// start is deliberately unauthenticated: it is the entry point for a client that +// has no credentials yet. It hands back an opaque device code plus a short human +// code, and grants nothing until a signed-in human approves it in a browser. +export const srDeviceStartProcedure = os + .route({ + method: "POST", + path: "/sr/device/start", + operationId: "sr.device.start", + summary: "Begin a device-code login", + description: + "Issues a device code for the CLI and a short user code for the human to approve in a browser. Grants no access until approved.", + tags: ["Subrouter"], + successStatus: 200, + }) + .output(srDeviceStartOutputSchema) + .handler(async () => { + const db = cloudDb(); + // Sweep expired rows opportunistically so this table cannot grow unbounded + // without a separate cron. + await db.delete(srDeviceCodes).where(lt(srDeviceCodes.expiresAt, new Date())); + + const deviceCode = generateDeviceCode(); + const userCode = generateUserCode(); + const expiresAt = new Date(Date.now() + DEVICE_CODE_TTL_SECONDS * 1000); + + await db.insert(srDeviceCodes).values({ + userCode, + deviceCodeHash: hashDeviceCode(deviceCode), + expiresAt, + }); + + return { + userCode, + deviceCode, + verificationUri: "https://cmux.com/sr/device", + expiresInSeconds: DEVICE_CODE_TTL_SECONDS, + intervalSeconds: POLL_INTERVAL_SECONDS, + }; + }); + +// poll is also unauthenticated: possession of the device code is the only proof +// the CLI has, and the code is compared by digest so a database dump cannot be +// replayed to complete someone else's pending login. +export const srDevicePollProcedure = os + .route({ + method: "POST", + path: "/sr/device/poll", + operationId: "sr.device.poll", + summary: "Poll a pending device-code login", + description: + "Returns pending until a signed-in human approves the matching user code, then returns the approved team once.", + tags: ["Subrouter"], + successStatus: 200, + }) + .input(srDevicePollInputSchema) + .output(srDevicePollOutputSchema) + .handler(async ({ input }) => { + const db = cloudDb(); + const rows = await db + .select() + .from(srDeviceCodes) + .where(eq(srDeviceCodes.deviceCodeHash, hashDeviceCode(input.deviceCode))) + .limit(1); + + const row = rows[0]; + if (!row) { + // An unknown code is indistinguishable from an expired one on purpose: + // neither confirms whether a code ever existed. + return { status: "expired" as const, teamId: null, userId: null }; + } + if (row.expiresAt.getTime() <= Date.now()) { + await db.delete(srDeviceCodes).where(eq(srDeviceCodes.id, row.id)); + return { status: "expired" as const, teamId: null, userId: null }; + } + if (!row.approvedAt || !row.teamId) { + return { status: "pending" as const, teamId: null, userId: null }; + } + + // Single-use: consuming the row means a captured device code cannot be + // redeemed twice. + await db.delete(srDeviceCodes).where(eq(srDeviceCodes.id, row.id)); + return { + status: "approved" as const, + teamId: row.teamId, + userId: row.userId, + }; + }); + +// approveDeviceCode is called by the signed-in browser session, not the CLI. It +// is exported for the approval route rather than exposed as a public procedure. +export async function approveDeviceCode( + userCode: string, + userId: string, + teamId: string, +): Promise { + const db = cloudDb(); + const rows = await db + .select() + .from(srDeviceCodes) + .where(eq(srDeviceCodes.userCode, userCode.trim().toUpperCase())) + .limit(1); + + const row = rows[0]; + if (!row || row.expiresAt.getTime() <= Date.now()) { + throw new ORPCError("NOT_FOUND", { message: "That code is not valid or has expired" }); + } + await db + .update(srDeviceCodes) + .set({ approvedAt: new Date(), userId, teamId }) + .where(eq(srDeviceCodes.id, row.id)); +} diff --git a/web/orpc/server/sr/schemas.ts b/web/orpc/server/sr/schemas.ts new file mode 100644 index 000000000000..99165c30dd3f --- /dev/null +++ b/web/orpc/server/sr/schemas.ts @@ -0,0 +1,63 @@ +import { z } from "zod"; + +// Shared shapes for the Subrouter cloud API. Keeping them in one module means +// the OpenAPI document and the Go client contract test agree by construction. + +export const srProviderSchema = z.enum(["codex", "claude", "gemini", "apikey"]); +export type SRProvider = z.infer; + +export const srAccountSchema = z.object({ + provider: srProviderSchema, + // The account's stable identity: an email for OAuth accounts, a label for API + // keys. Unique per team and provider. + accountLabel: z.string().min(1).max(320), + updatedAt: z.string(), +}); +export type SRAccount = z.infer; + +// The credential blob is opaque to the server: it is sealed on write and only +// returned to a member of the owning team. Capping it keeps a malformed client +// from writing unbounded rows. +export const srAccountUploadSchema = z.object({ + provider: srProviderSchema, + accountLabel: z.string().min(1).max(320), + credential: z.string().min(1).max(64_000), +}); + +export const srAccountsPushInputSchema = z.object({ + accounts: z.array(srAccountUploadSchema).min(1).max(100), +}); + +export const srAccountsPushOutputSchema = z.object({ + teamId: z.string(), + uploaded: z.number().int().nonnegative(), + // Accounts already present are updated in place rather than duplicated, so + // the client can tell a first upload from a refresh. + updated: z.number().int().nonnegative(), +}); + +export const srAccountsListOutputSchema = z.object({ + teamId: z.string(), + accounts: z.array(srAccountSchema), +}); + +export const srDeviceStartOutputSchema = z.object({ + // Shown to the human and typed into the browser. + userCode: z.string(), + // Held by the CLI and exchanged on poll. Never displayed. + deviceCode: z.string(), + verificationUri: z.string(), + expiresInSeconds: z.number().int().positive(), + intervalSeconds: z.number().int().positive(), +}); + +export const srDevicePollInputSchema = z.object({ + deviceCode: z.string().min(1), +}); + +export const srDevicePollOutputSchema = z.object({ + // "pending" until a signed-in human approves, then "approved" exactly once. + status: z.enum(["pending", "approved", "expired"]), + teamId: z.string().nullable(), + userId: z.string().nullable(), +}); diff --git a/web/services/subrouter/vaultCrypto.ts b/web/services/subrouter/vaultCrypto.ts new file mode 100644 index 000000000000..db0973404f92 --- /dev/null +++ b/web/services/subrouter/vaultCrypto.ts @@ -0,0 +1,101 @@ +import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto"; + +// AES-256-GCM envelope for Subrouter credential payloads. +// +// The threat this addresses is a database dump: OAuth refresh chains are the +// long-lived secret, and losing them means re-running interactive login for +// every account. Encrypting at rest means a dump alone is not enough. +// +// The key lives in SR_VAULT_KEY (base64, 32 bytes) rather than in the database, +// so the key and the ciphertext never share a compromise boundary. + +const KEY_BYTES = 32; +const NONCE_BYTES = 12; +export const CURRENT_KEY_VERSION = 1; + +export type SealedSecret = { + ciphertext: string; + nonce: string; + keyVersion: number; +}; + +export class VaultKeyMissingError extends Error { + constructor() { + super("SR_VAULT_KEY is not configured; refusing to handle Subrouter credentials"); + this.name = "VaultKeyMissingError"; + } +} + +export function isVaultConfigured(): boolean { + return resolveKeyOrNull() !== null; +} + +function resolveKeyOrNull(): Buffer | null { + const raw = process.env.SR_VAULT_KEY?.trim(); + if (!raw) return null; + let key: Buffer; + try { + key = Buffer.from(raw, "base64"); + } catch { + return null; + } + return key.length === KEY_BYTES ? key : null; +} + +function resolveKey(): Buffer { + const key = resolveKeyOrNull(); + if (!key) throw new VaultKeyMissingError(); + return key; +} + +// seal encrypts a credential payload. The GCM auth tag is appended to the +// ciphertext so tampering is detected on open rather than silently decrypting. +export function seal(plaintext: string): SealedSecret { + const key = resolveKey(); + const nonce = randomBytes(NONCE_BYTES); + const cipher = createCipheriv("aes-256-gcm", key, nonce); + const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]); + const tag = cipher.getAuthTag(); + return { + ciphertext: Buffer.concat([encrypted, tag]).toString("base64"), + nonce: nonce.toString("base64"), + keyVersion: CURRENT_KEY_VERSION, + }; +} + +export function open(sealed: SealedSecret): string { + const key = resolveKey(); + const raw = Buffer.from(sealed.ciphertext, "base64"); + if (raw.length < 17) { + throw new Error("ciphertext too short to contain an auth tag"); + } + const tag = raw.subarray(raw.length - 16); + const body = raw.subarray(0, raw.length - 16); + const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(sealed.nonce, "base64")); + decipher.setAuthTag(tag); + return Buffer.concat([decipher.update(body), decipher.final()]).toString("utf8"); +} + +// hashDeviceCode stores only a digest of the CLI-held device code, so a database +// dump cannot be replayed to complete a pending login. +export function hashDeviceCode(deviceCode: string): string { + return createHash("sha256").update(deviceCode, "utf8").digest("base64"); +} + +// Ambiguous characters (0/O, 1/I/L) are excluded: this code gets read aloud and +// retyped by a human. +const USER_CODE_ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"; + +export function generateUserCode(): string { + const bytes = randomBytes(8); + let out = ""; + for (let i = 0; i < 8; i += 1) { + if (i === 4) out += "-"; + out += USER_CODE_ALPHABET[bytes[i]! % USER_CODE_ALPHABET.length]; + } + return out; +} + +export function generateDeviceCode(): string { + return randomBytes(32).toString("base64url"); +} diff --git a/web/tests/sr-vault.test.ts b/web/tests/sr-vault.test.ts new file mode 100644 index 000000000000..f05d745e3f30 --- /dev/null +++ b/web/tests/sr-vault.test.ts @@ -0,0 +1,131 @@ +import { beforeEach, describe, expect, test } from "bun:test"; + +import { generateOpenAPIDocument } from "../orpc/server/openapi"; +import { + generateDeviceCode, + generateUserCode, + hashDeviceCode, + isVaultConfigured, + open, + seal, + VaultKeyMissingError, +} from "../services/subrouter/vaultCrypto"; + +// A fixed 32-byte key, so these tests never depend on deployment config. +const TEST_KEY = Buffer.alloc(32, 7).toString("base64"); + +describe("vault crypto", () => { + beforeEach(() => { + process.env.SR_VAULT_KEY = TEST_KEY; + }); + + test("round-trips a credential payload", () => { + const secret = JSON.stringify({ refresh_token: "rt_abc", account: "a@example.com" }); + const sealed = seal(secret); + expect(sealed.ciphertext).not.toContain("rt_abc"); + expect(open(sealed)).toBe(secret); + }); + + test("uses a fresh nonce per seal, so identical inputs differ at rest", () => { + const a = seal("same"); + const b = seal("same"); + expect(a.nonce).not.toBe(b.nonce); + expect(a.ciphertext).not.toBe(b.ciphertext); + expect(open(a)).toBe("same"); + expect(open(b)).toBe("same"); + }); + + test("rejects tampered ciphertext instead of returning garbage", () => { + const sealed = seal("secret"); + const raw = Buffer.from(sealed.ciphertext, "base64"); + raw[0] = raw[0]! ^ 0xff; + expect(() => open({ ...sealed, ciphertext: raw.toString("base64") })).toThrow(); + }); + + test("rejects a ciphertext too short to carry an auth tag", () => { + expect(() => open({ ciphertext: "AAAA", nonce: "AAAAAAAAAAAAAAAA", keyVersion: 1 })).toThrow( + /too short/, + ); + }); + + test("refuses to operate without a configured key", () => { + delete process.env.SR_VAULT_KEY; + expect(isVaultConfigured()).toBe(false); + expect(() => seal("secret")).toThrow(VaultKeyMissingError); + }); + + test("treats a wrong-length key as unconfigured rather than weakening the cipher", () => { + process.env.SR_VAULT_KEY = Buffer.alloc(16, 1).toString("base64"); + expect(isVaultConfigured()).toBe(false); + expect(() => seal("secret")).toThrow(VaultKeyMissingError); + }); +}); + +describe("device codes", () => { + test("user codes avoid characters a human would misread aloud", () => { + for (let i = 0; i < 200; i += 1) { + const code = generateUserCode(); + expect(code).toMatch(/^[A-Z2-9]{4}-[A-Z2-9]{4}$/); + // 0/O and 1/I/L are the pairs that get transcribed wrong. + expect(code).not.toMatch(/[01OIL]/); + } + }); + + test("device codes are long, URL-safe and unique", () => { + const seen = new Set(); + for (let i = 0; i < 100; i += 1) { + const code = generateDeviceCode(); + expect(code).toMatch(/^[A-Za-z0-9_-]+$/); + expect(code.length).toBeGreaterThanOrEqual(43); + seen.add(code); + } + expect(seen.size).toBe(100); + }); + + test("hashing is deterministic and hides the code", () => { + const code = generateDeviceCode(); + expect(hashDeviceCode(code)).toBe(hashDeviceCode(code)); + expect(hashDeviceCode(code)).not.toContain(code); + expect(hashDeviceCode(code)).not.toBe(hashDeviceCode(generateDeviceCode())); + }); +}); + +describe("sr OpenAPI surface", () => { + test("advertises every sr route with a stable operationId", async () => { + const doc = await generateOpenAPIDocument(); + const expected: ReadonlyArray<[string, string, string]> = [ + ["/sr/device/start", "post", "sr.device.start"], + ["/sr/device/poll", "post", "sr.device.poll"], + ["/sr/accounts/push", "post", "sr.accounts.push"], + ["/sr/accounts", "get", "sr.accounts.list"], + ]; + for (const [path, method, operationId] of expected) { + const operation = ( + doc.paths?.[path] as Record | undefined + )?.[method]; + expect(operation?.operationId).toBe(operationId); + } + expect(doc.servers?.[0]?.url).toBe("/api/v1"); + }); + + test("push accepts a batch, so bulk upload is one request", async () => { + const doc = await generateOpenAPIDocument(); + const push = doc.paths?.["/sr/accounts/push"] as { + post?: { requestBody?: { content?: Record } }; + }; + const schema = push.post?.requestBody?.content?.["application/json"]?.schema as { + properties?: { accounts?: { type?: string; maxItems?: number } }; + }; + expect(schema?.properties?.accounts?.type).toBe("array"); + expect(schema?.properties?.accounts?.maxItems).toBe(100); + }); + + test("the list response never exposes credential material", async () => { + const doc = await generateOpenAPIDocument(); + const serialized = JSON.stringify( + (doc.paths?.["/sr/accounts"] as { get?: { responses?: unknown } })?.get?.responses, + ); + expect(serialized).not.toContain("credential"); + expect(serialized).not.toContain("ciphertext"); + }); +});