diff --git a/packages/api-client/src/schema.d.ts b/packages/api-client/src/schema.d.ts index a910f64..1e4bd5e 100644 --- a/packages/api-client/src/schema.d.ts +++ b/packages/api-client/src/schema.d.ts @@ -1,5 +1,85 @@ // Generated from packages/docs/openapi.yaml. Do not edit. export interface paths { + "/api/v1/model-accounts": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List model accounts available to the owner + * @description Requires an interactive owner browser session. Returns account metadata and the collection revision only; encrypted account caches and credentials are never returned. + * + * Requires the integrations:read scope. + * + * An interactive owner session is required; bearer tokens cannot call this operation. + */ + get: operations["get_api_v1_model_accounts"]; + put?: never; + /** + * Create a model account + * @description Requires an interactive owner browser session and the current collection revision. Accepts only user-encrypted initial account state; credentials are established directly with the credential proxy. + * + * Requires the integrations:write scope. + * + * An interactive owner session is required; bearer tokens cannot call this operation. + */ + post: operations["post_api_v1_model_accounts"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/model-accounts/{accountId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** + * Delete a model account + * @description Requires an interactive owner browser session and the current collection revision. Deletion is recorded as a tombstone so future snapshots and unlock reconciliation cannot restore the account. + * + * Requires the integrations:write scope. + * + * An interactive owner session is required; bearer tokens cannot call this operation. + */ + delete: operations["delete_api_v1_model_accounts_accountId"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/model-accounts/{accountId}/browser-capability": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Issue a short-lived browser capability for a paired model account + * @description Requires an interactive owner browser session. Returns a single-use, 60-second capability scoped to the account, action, proxy boot and exact application origin. The browser sends it directly to the paired proxy; Factorize never receives model-account tokens. + * + * Requires the integrations:write scope. + * + * An interactive owner session is required; bearer tokens cannot call this operation. + */ + post: operations["post_api_v1_model_accounts_accountId_browser_capability"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/unlock-key/verification": { parameters: { query?: never; @@ -2683,6 +2763,176 @@ export interface components { } export type $defs = Record; export interface operations { + get_api_v1_model_accounts: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Model account metadata and collection revision */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + revision: number; + accounts: { + /** Format: uuid */ + id: string; + name: string; + description: string | null; + revision: number; + }[]; + }; + }; + }; + 400: components["responses"]["Error400"]; + 401: components["responses"]["Error401"]; + 403: components["responses"]["Error403"]; + 404: components["responses"]["Error404"]; + 409: components["responses"]["Error409"]; + 429: components["responses"]["Error429"]; + 500: components["responses"]["Error500"]; + 503: components["responses"]["Error503"]; + }; + }; + post_api_v1_model_accounts: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** Format: uuid */ + id: string; + expectedRevision: number; + /** Format: uuid */ + idempotencyKey: string; + name: string; + description: string | null; + encryptedPayload: string; + }; + }; + }; + responses: { + /** @description Created model account */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + revision: number; + duplicate: boolean; + }; + }; + }; + 400: components["responses"]["Error400"]; + 401: components["responses"]["Error401"]; + 403: components["responses"]["Error403"]; + 404: components["responses"]["Error404"]; + 409: components["responses"]["Error409"]; + 415: components["responses"]["Error415"]; + 429: components["responses"]["Error429"]; + 500: components["responses"]["Error500"]; + 503: components["responses"]["Error503"]; + }; + }; + delete_api_v1_model_accounts_accountId: { + parameters: { + query?: never; + header?: never; + path: { + accountId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + expectedRevision: number; + /** Format: uuid */ + idempotencyKey: string; + }; + }; + }; + responses: { + /** @description Deleted model account */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** Format: uuid */ + id: string; + /** @constant */ + deleted: true; + revision: number; + duplicate: boolean; + }; + }; + }; + 400: components["responses"]["Error400"]; + 401: components["responses"]["Error401"]; + 403: components["responses"]["Error403"]; + 404: components["responses"]["Error404"]; + 409: components["responses"]["Error409"]; + 415: components["responses"]["Error415"]; + 429: components["responses"]["Error429"]; + 500: components["responses"]["Error500"]; + 503: components["responses"]["Error503"]; + }; + }; + post_api_v1_model_accounts_accountId_browser_capability: { + parameters: { + query?: never; + header?: never; + path: { + accountId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** @enum {string} */ + action: "authorize" | "status" | "cancel"; + }; + }; + }; + responses: { + /** @description Paired proxy endpoint and one-use capability */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** Format: uri */ + endpoint: string; + capability: string; + }; + }; + }; + 400: components["responses"]["Error400"]; + 401: components["responses"]["Error401"]; + 403: components["responses"]["Error403"]; + 404: components["responses"]["Error404"]; + 409: components["responses"]["Error409"]; + 415: components["responses"]["Error415"]; + 429: components["responses"]["Error429"]; + 500: components["responses"]["Error500"]; + 503: components["responses"]["Error503"]; + }; + }; get_api_v1_unlock_key_verification: { parameters: { query?: never; diff --git a/packages/api/migrations/0036_proxy_control_credentials.sql b/packages/api/migrations/0036_proxy_control_credentials.sql new file mode 100644 index 0000000..6c7d8b3 --- /dev/null +++ b/packages/api/migrations/0036_proxy_control_credentials.sql @@ -0,0 +1,10 @@ +CREATE TABLE app.credential_proxy_control_credentials ( + tenant_id uuid NOT NULL REFERENCES app.tenants(id) ON DELETE CASCADE, + proxy_identity text NOT NULL, + boot_epoch uuid NOT NULL, + token_digest bytea NOT NULL UNIQUE, + revoked_at timestamptz, + consumed_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (tenant_id, proxy_identity) +); diff --git a/packages/api/migrations/0037_proxy_control_credentials.sql b/packages/api/migrations/0037_proxy_control_credentials.sql new file mode 100644 index 0000000..b3c69e7 --- /dev/null +++ b/packages/api/migrations/0037_proxy_control_credentials.sql @@ -0,0 +1,2 @@ +ALTER TABLE app.credential_proxy_control_credentials + ADD COLUMN proxy_token_digest bytea UNIQUE; diff --git a/packages/api/migrations/0038_proxy_browser_control_pairings.sql b/packages/api/migrations/0038_proxy_browser_control_pairings.sql new file mode 100644 index 0000000..45a8eb2 --- /dev/null +++ b/packages/api/migrations/0038_proxy_browser_control_pairings.sql @@ -0,0 +1,8 @@ +CREATE TABLE app.credential_proxy_browser_pairings ( + tenant_id uuid PRIMARY KEY REFERENCES app.tenants(id) ON DELETE CASCADE, + proxy_identity text NOT NULL, + boot_epoch uuid NOT NULL, + browser_origin text NOT NULL, + updated_at timestamptz NOT NULL DEFAULT now(), + CHECK (browser_origin ~ '^https://[^/]+$') +); diff --git a/packages/api/src/api-contract.ts b/packages/api/src/api-contract.ts index aa6e4b4..6ea38cd 100644 --- a/packages/api/src/api-contract.ts +++ b/packages/api/src/api-contract.ts @@ -131,10 +131,19 @@ const bearerIntegrationCreate = z.object({ const bearerIntegrationUpdate = bearerIntegrationCreate.omit({ id: true }); const bearerIntegrationDelete = z.object({ expectedRevision: z.number().int().nonnegative(), idempotencyKey: z.uuid() }).strict(); const bearerIntegrationList = z.object({ revision: z.number().int().nonnegative(), integrations: z.array(bearerIntegrationMetadata) }); +const modelAccountMetadata = z.object({ id: z.uuid(), name: z.string(), description: z.string().nullable(), revision: z.number().int().nonnegative() }); +const modelAccountCreate = z.object({ + id: z.uuid(), expectedRevision: z.number().int().nonnegative(), idempotencyKey: z.uuid(), + name: z.string().trim().min(1).max(120), description: z.string().max(500).nullable(), + encryptedPayload: z.string().min(1).max(65_536), +}).strict(); +const modelAccountDelete = z.object({ expectedRevision: z.number().int().nonnegative(), idempotencyKey: z.uuid() }).strict(); +const modelAccountList = z.object({ revision: z.number().int().nonnegative(), accounts: z.array(modelAccountMetadata) }); const bearerIntegrationWriteResult = z.object({ revision: z.number().int().nonnegative(), duplicate: z.boolean() }); const bearerIntegrationDeleteResult = z.object({ id: z.uuid(), deleted: z.literal(true), revision: z.number().int().nonnegative(), duplicate: z.boolean() }); const unlockKeyVerification = z.object({ ownerId: z.string(), verification: z.object({ generationId: z.uuid(), encryptedVerification: z.string() }).strict().nullable() }).strict(); const unlockKeyVerificationInput = z.object({ generationId: z.uuid(), encryptedVerification: z.string().min(1).max(4096) }).strict(); +const modelAccountBrowserCapabilityInput = z.object({ action: z.enum(["authorize", "status", "cancel"]) }).strict(); function route( method: string, @@ -213,6 +222,26 @@ function authRoute(method: string, path: string, action: AuthAction, summary: st } export const API_ROUTES: RouteContract[] = [ + route("GET", "/api/v1/model-accounts", "integrations:read", { + summary: "List model accounts available to the owner", + description: "Requires an interactive owner browser session. Returns account metadata and the collection revision only; encrypted account caches and credentials are never returned.", + responses: { "200": { description: "Model account metadata and collection revision", content: { "application/json": { schema: jsonSchema(modelAccountList, "output") } } } }, + }, service => service.listModelAccounts(), { ownerSession: true }), + route("POST", "/api/v1/model-accounts", "integrations:write", { + summary: "Create a model account", + description: "Requires an interactive owner browser session and the current collection revision. Accepts only user-encrypted initial account state; credentials are established directly with the credential proxy.", + responses: { "201": { description: "Created model account", content: { "application/json": { schema: jsonSchema(bearerIntegrationWriteResult, "output") } } } }, + }, (service, { body }) => service.createModelAccount(body), { body: modelAccountCreate, maxBodyBytes: 70_000, ownerSession: true }), + route("DELETE", "/api/v1/model-accounts/{accountId}", "integrations:write", { + summary: "Delete a model account", + description: "Requires an interactive owner browser session and the current collection revision. Deletion is recorded as a tombstone so future snapshots and unlock reconciliation cannot restore the account.", + responses: { "200": { description: "Deleted model account", content: { "application/json": { schema: jsonSchema(bearerIntegrationDeleteResult, "output") } } } }, + }, (service, { params, body }) => service.deleteModelAccount(params.accountId, body), { body: modelAccountDelete, maxBodyBytes: 4096, ownerSession: true, parameters: z.object({ accountId: z.uuid() }) }), + route("POST", "/api/v1/model-accounts/{accountId}/browser-capability", "integrations:write", { + summary: "Issue a short-lived browser capability for a paired model account", + description: "Requires an interactive owner browser session. Returns a single-use, 60-second capability scoped to the account, action, proxy boot and exact application origin. The browser sends it directly to the paired proxy; Factorize never receives model-account tokens.", + responses: { "200": { description: "Paired proxy endpoint and one-use capability", content: { "application/json": { schema: jsonSchema(z.object({ endpoint: z.url(), capability: z.string() }), "output") } } } }, + }, (service, { params, body }) => service.issueModelAccountBrowserCapability(params.accountId, body.action), { body: modelAccountBrowserCapabilityInput, maxBodyBytes: 1024, ownerSession: true, parameters: z.object({ accountId: z.uuid() }) }), route("GET", "/api/v1/unlock-key/verification", "integrations:read", { summary: "Get the current encrypted unlock-key verification record", description: "Requires an interactive owner browser session. Returns user-encrypted verification ciphertext and its key generation; the unlock key is never accepted or returned.", responses: { "200": { description: "Encrypted verification record or empty setup state", content: { "application/json": { schema: jsonSchema(unlockKeyVerification, "output") } } } } }, service => service.getUnlockKeyVerification(), { ownerSession: true }), route("POST", "/api/v1/unlock-key/verification", "integrations:write", { summary: "Create the encrypted unlock-key verification record", description: "Requires an interactive owner browser session. Stores only browser-encrypted verification ciphertext and permits one setup per account.", responses: { "200": { description: "Created verification record", content: { "application/json": { schema: jsonSchema(z.object({ generationId: z.uuid() }), "output") } } } } }, (service, { body }) => service.createUnlockKeyVerification(body), { body: unlockKeyVerificationInput, maxBodyBytes: 8192, ownerSession: true }), route("GET", "/api/v1/integrations/bearer", "flows:read", { diff --git a/packages/api/src/flow-service.ts b/packages/api/src/flow-service.ts index 0b1ede5..896dfd4 100644 --- a/packages/api/src/flow-service.ts +++ b/packages/api/src/flow-service.ts @@ -17,6 +17,8 @@ import type { JobPageQuery } from "./job-contracts"; import { PostgresJobRepository, StaleJobEdit } from "./postgres/job-repository"; import { ConnectionRepository } from "./postgres/connection-repository"; import { EncryptedIntegrationRepository, IntegrationIdempotencyConflictError, IntegrationValidationError, StaleIntegrationRevisionError } from "./postgres/encrypted-integration-repository"; +import { ProxyControlRepository } from "./postgres/proxy-control-repository"; +import { issueModelAccountCapability } from "./proxy-browser-capability"; import { decrypt, encrypt } from "./crypto"; import { InvocationService, type Job, type Trigger } from "./job-domain"; import { RunQueryRepository } from "./postgres/run-query-repository"; @@ -571,6 +573,69 @@ export class ApiService { if (!rows.length) throw new ServiceError(409, "conflict", "An unlock key is already set up for this account."); return { generationId: input.generationId }; } + async issueModelAccountBrowserCapability(accountId: string, action: "authorize" | "status" | "cancel") { + await this.authorizeOwnerSession("integrations:write"); + if (!this.env.MODEL_ACCOUNT_CAPABILITY_PRIVATE_KEY) throw new ServiceError(503, "operation_failed", "Model-account browser authorization is unavailable."); + const [pairing, accounts] = await Promise.all([ + new ProxyControlRepository(databaseFor(this.env)).browserPairing(this.auth.tenantId), + this.encryptedIntegrations().records("model"), + ]); + if (!pairing) throw new ServiceError(503, "operation_failed", "The credential proxy is not paired for browser authorization."); + if (!accounts.some(account => account.id === accountId)) throw new ServiceError(404, "not_found", "Model account not found."); + const capability = await issueModelAccountCapability({ + privateKeyPem: this.env.MODEL_ACCOUNT_CAPABILITY_PRIVATE_KEY, + proxyIdentity: pairing.proxyIdentity, ownerId: this.auth.tenantId, accountId, action, + appOrigin: this.env.APP_ORIGIN, generation: pairing.bootEpoch, + }); + return { endpoint: `${pairing.browserOrigin}/model-accounts/${accountId}/${action}`, capability }; + } + async listModelAccounts() { + await this.authorizeOwnerSession("integrations:read"); + const repository = this.encryptedIntegrations(); + const [revision, accounts] = await Promise.all([repository.revision(), repository.records("model")]); + return { revision, accounts: accounts.map(({ id, name, description, revision }) => ({ id, name, description, revision })) }; + } + private async modelAccountWrite(input: { id: string; expectedRevision: number; idempotencyKey: string; name: string; description: string | null; encryptedPayload?: string; operation: "upsert" | "delete" }) { + try { return await this.encryptedIntegrations().write({ ...input, kind: "model", origin: null }); } + catch (error) { + if (error instanceof StaleIntegrationRevisionError) throw new ServiceError(409, "stale_revision", `The model account collection changed. Current revision is ${error.currentRevision}.`); + if (error instanceof IntegrationIdempotencyConflictError) throw new ServiceError(409, "conflict", "The idempotency key was already used for a different model account change."); + if (error instanceof IntegrationValidationError) throw new ServiceError(400, "invalid_request", error.message); + throw error; + } + } + async createModelAccount(input: { id: string; expectedRevision: number; idempotencyKey: string; name: string; description: string | null; encryptedPayload: string }) { + await this.authorizeOwnerSession("integrations:write"); + const repository = this.encryptedIntegrations(); + try { + const replay = await repository.replay({ ...input, kind: "model", origin: null, operation: "upsert" }); + if (replay) return replay; + } catch (error) { + if (error instanceof IntegrationIdempotencyConflictError) throw new ServiceError(409, "conflict", "The idempotency key was already used for a different model account change."); + if (error instanceof IntegrationValidationError) throw new ServiceError(400, "invalid_request", error.message); + throw error; + } + if ((await repository.records()).some(record => record.id === input.id)) throw new ServiceError(409, "conflict", "An integration with this id already exists."); + return this.modelAccountWrite({ ...input, operation: "upsert" }); + } + async deleteModelAccount(id: string, input: { expectedRevision: number; idempotencyKey: string }) { + await this.authorizeOwnerSession("integrations:write"); + const repository = this.encryptedIntegrations(); + const existing = (await repository.records("model")).find(record => record.id === id); + if (!existing) { + try { + const replay = await repository.replayDelete(id, "model", input.idempotencyKey); + if (replay) return { id, deleted: true as const, ...replay }; + } catch (error) { + if (error instanceof IntegrationIdempotencyConflictError) throw new ServiceError(409, "conflict", "The idempotency key was already used for a different model account change."); + if (error instanceof IntegrationValidationError) throw new ServiceError(400, "invalid_request", error.message); + throw error; + } + throw new ServiceError(404, "not_found", "Model account not found."); + } + const result = await this.modelAccountWrite({ id, name: existing.name, description: existing.description, ...input, operation: "delete" }); + return { id, deleted: true as const, ...result }; + } async removeTailIntegration(id: string) { await this.authorize("integrations:write"); if (!await this.connections().delete(`cloudflare-tail:${id}`)) throw new ServiceError(404, "not_found", "Integration not found"); return { id, deleted: true }; } async linearProjects() { await this.authorize("integrations:read"); return publicValue(z.array(namedOption), await this.providers().linearProjects()); } async linearOptions() { await this.authorize("integrations:read"); return publicValue(providerOptions, await this.providers().linearOptions()); } diff --git a/packages/api/src/fly-credential-proxy-provisioner.ts b/packages/api/src/fly-credential-proxy-provisioner.ts new file mode 100644 index 0000000..c959537 --- /dev/null +++ b/packages/api/src/fly-credential-proxy-provisioner.ts @@ -0,0 +1,165 @@ +import { provisionCredentialProxy, type ProxyBootstrapProvisioning } from "./proxy-provisioner"; +import type { ProxyControlRepository } from "./postgres/proxy-control-repository"; +import type { FlyMachine, FlyMachineConfig } from "./fly-machines-client"; +import { createFlyGuestTransport } from "./fly-guest-transport"; + +const IMAGE_DIGEST = /@sha256:[a-f0-9]{64}$/i; +const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +export type FlyCredentialProxyConfig = { + tenantId: string; + app: string; + /** Only reviewed credential-proxy images pinned by digest are accepted. */ + image: string; + region: string; + size: string; + browserCapabilityPublicKey: string; + memoryMb?: number; + controlOrigin: string; + /** Stable across retries of one provisioning operation. */ + operationId: string; +}; + +export type FlyCredentialProxyMachineClient = { + createMachine(app: string, config: FlyMachineConfig, operationId: string): Promise; + startMachine(app: string, machine: string): Promise; + destroyMachine(app: string, machine: string): Promise; +}; + +export type FlyCredentialProxyGuest = { + /** The reviewed image startup command consumes this payload from stdin only. */ + startProxy(input: ProxyBootstrapProvisioning & { credential: string }): Promise; + /** Signs a VM-generated CSR over the provider's private guest channel. Only + * the public certificate and CA trust bundle return to the caller. */ + issueVmCertificate(input: { bootEpoch: string; vmIdentity: string; csr: string }): Promise<{ certificate: string; trustBundle: string }>; +}; + +/** Provider exec adapter: the bootstrap record is the command's stdin payload. */ +export function createFlyCredentialProxyGuest(options: { + app: string; + machine: string; + token: string; + cliPath?: string; + spawnProcess?: typeof import("node:child_process").spawn; +}): FlyCredentialProxyGuest { + const transport = createFlyGuestTransport({ ...options, timeoutMs: 30_000, outputLimitBytes: 16 * 1024 }); + return { + async startProxy(input) { + const result = await transport.run(["/usr/local/bin/factorize-proxy-startup"], `${JSON.stringify({ + type: "register-boot", + controlOrigin: input.controlOrigin, + browserOrigin: input.browserOrigin, + bootEpoch: input.bootEpoch, + credential: input.credential, + })}\n`); + if (!result.ok) throw new Error("Credential proxy startup command failed"); + if (result.stdout.trim() !== "Credential proxy started") throw new Error("Credential proxy startup was not confirmed"); + }, + async issueVmCertificate(input) { + if (!input || !UUID_V4.test(input.bootEpoch) || !/^[A-Za-z0-9:._-]{1,255}$/.test(input.vmIdentity) + || typeof input.csr !== "string" || input.csr.length < 100 || input.csr.length > 16_384) throw new TypeError("Invalid VM certificate provisioning request"); + const result = await transport.run(["/usr/local/bin/factorize-proxy-certify-vm"], `${JSON.stringify(input)}\n`); + if (!result.ok) throw new Error("Credential proxy VM certificate provisioning failed"); + let response: unknown; + try { response = JSON.parse(result.stdout); } catch { throw new Error("Credential proxy returned an invalid VM certificate response"); } + if (!response || typeof response !== "object" || Array.isArray(response) + || Object.keys(response).sort().join(",") !== "bootEpoch,certificate,trustBundle,vmIdentity" + || (response as any).bootEpoch !== input.bootEpoch || (response as any).vmIdentity !== input.vmIdentity + || typeof (response as any).certificate !== "string" || !(response as any).certificate.startsWith("-----BEGIN CERTIFICATE-----") + || typeof (response as any).trustBundle !== "string" || !(response as any).trustBundle.startsWith("-----BEGIN CERTIFICATE-----")) { + throw new Error("Credential proxy returned an invalid VM certificate response"); + } + return { certificate: (response as any).certificate, trustBundle: (response as any).trustBundle }; + }, + }; +} + +export type FlyCredentialProxyProvisionerOptions = { + control: Pick; + machines: FlyCredentialProxyMachineClient; + guest: (machine: FlyMachine) => FlyCredentialProxyGuest; + /** Injectable to keep boot identity deterministic in boundary tests. */ + newBootEpoch?: () => string; +}; + +function validate(input: FlyCredentialProxyConfig): void { + if (!input.tenantId || !input.app || !input.operationId || input.operationId.length > 200) throw new TypeError("Invalid credential proxy provisioning identity"); + if (!/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(input.app)) throw new TypeError("Invalid credential proxy Fly app name"); + if (!IMAGE_DIGEST.test(input.image)) throw new TypeError("Credential proxy image must be pinned by sha256 digest"); + if (!/^[a-z0-9]{3}$/.test(input.region)) throw new TypeError("Invalid credential proxy region"); + if (!/^[a-z0-9-]{1,64}$/.test(input.size)) throw new TypeError("Invalid credential proxy size"); + if (!input.browserCapabilityPublicKey.startsWith("-----BEGIN PUBLIC KEY-----")) throw new TypeError("Proxy browser capability public key is required"); + if (input.memoryMb !== undefined && (!Number.isInteger(input.memoryMb) || input.memoryMb < 256 || input.memoryMb > 8192)) throw new TypeError("Credential proxy memory must be between 256 and 8192 MiB"); +} + +/** + * Provisions one dedicated Fly Machine for the credential proxy and wires its + * startup through the one-use bootstrap boundary. The bootstrap credential is + * never part of the Machine config; the guest adapter must deliver it over its + * private stdin startup channel. + */ +export function createFlyCredentialProxyProvisioner(options: FlyCredentialProxyProvisionerOptions) { + return { + async start(input: FlyCredentialProxyConfig): Promise<{ proxyIdentity: string; bootEpoch: string; machine: FlyMachine }> { + validate(input); + const origin = new URL(input.controlOrigin); + if (origin.protocol !== "https:" || origin.username || origin.password || origin.pathname !== "/" || origin.search || origin.hash) { + throw new TypeError("controlOrigin must be an HTTPS origin"); + } + const candidateBootEpoch = (options.newBootEpoch ?? (() => crypto.randomUUID()))(); + if (!UUID_V4.test(candidateBootEpoch)) throw new TypeError("boot epoch must be a UUID v4"); + const config: FlyMachineConfig = { + image: input.image, + region: input.region, + cmd: ["/usr/local/bin/factorize-credential-proxy", "--boot-epoch", candidateBootEpoch], + guest: { cpu_kind: "shared", cpus: 1, memory_mb: input.memoryMb ?? 512 }, + env: { + FACTORIZE_PROXY_TUNNEL_SERVER_NAME: `${input.app}.internal`, + FACTORIZE_PROXY_TUNNEL_PORT: "9090", + FACTORIZE_PROXY_BROWSER_CAPABILITY_PUBLIC_KEY: input.browserCapabilityPublicKey, + }, + services: [{ protocol: "tcp", internal_port: 8080, ports: [{ port: 443, handlers: ["tls", "http"] }], force_instance_key: true }], + metadata: { + factorize_role: "credential-proxy", + factorize_tenant_id: input.tenantId, + factorize_operation_id: input.operationId, + factorize_boot_epoch: candidateBootEpoch, + }, + // The proxy must be explicitly started and must not be restarted after + // process failure with a stale one-use bootstrap credential. + auto_start: false, + auto_stop: false, + restart: { policy: "no" }, + }; + const machine = await options.machines.createMachine(input.app, config, input.operationId); + if (!machine.id) throw new Error("Fly returned a credential proxy Machine without an id"); + const bootEpoch = machine.config?.metadata?.factorize_boot_epoch ?? candidateBootEpoch; + if (!UUID_V4.test(bootEpoch)) throw new Error("Fly returned a credential proxy Machine with an invalid boot epoch"); + const proxyIdentity = `fly:${input.app}:${machine.id}`; + const bootstrap = { + tenantId: input.tenantId, + proxyIdentity, + bootEpoch, + controlOrigin: origin.origin, + browserOrigin: `https://${input.app}.fly.dev`, + }; + try { + await provisionCredentialProxy(bootstrap, { + control: options.control, + startProxy: async (request) => { + // Fly Machine startup is explicit. Machine config and metadata only + // hold public identity/configuration; the token crosses stdin. + await options.machines.startMachine(input.app, machine.id); + await options.guest(machine).startProxy(request); + }, + }); + } catch (error) { + // Preserve the sanitized original provisioning failure while making a + // best-effort cleanup of a machine that never completed startup. + await options.machines.destroyMachine(input.app, machine.id).catch(() => undefined); + throw error; + } + return { proxyIdentity, bootEpoch, machine }; + }, + }; +} diff --git a/packages/api/src/postgres/encrypted-integration-repository.ts b/packages/api/src/postgres/encrypted-integration-repository.ts index 095f6d0..04bf9da 100644 --- a/packages/api/src/postgres/encrypted-integration-repository.ts +++ b/packages/api/src/postgres/encrypted-integration-repository.ts @@ -77,6 +77,33 @@ export class EncryptedIntegrationRepository { return rows.length ? safeRevision(rows[0]!.revision) : 0; } + /** Recognize an exact retry before collection-level create checks run. */ + async replay(input: IntegrationWrite): Promise<{ revision: number; duplicate: true } | null> { + validate(input); + const { rows } = await this.database.pool.query("SELECT revision,operation,record_id,kind,name,origin,description,encrypted_payload FROM app.integration_changes WHERE tenant_id=$1 AND idempotency_key=$2", [this.tenantId, input.idempotencyKey]); + const row = rows[0]; + if (!row) return null; + const sameCiphertext = row.encrypted_payload === null + ? input.operation === "delete" && input.encryptedPayload === undefined + : input.operation === "upsert" && typeof input.encryptedPayload === "string" && await decrypt(row.encrypted_payload, this.serverEncryptionKey) === input.encryptedPayload; + if (row.record_id !== input.id || row.kind !== input.kind || row.name !== input.name || row.origin !== input.origin + || row.description !== input.description || row.operation !== input.operation || !sameCiphertext) { + throw new IntegrationIdempotencyConflictError(); + } + return { revision: safeRevision(row.revision), duplicate: true }; + } + + /** Recognize a delete retry after its active record has already become a tombstone. */ + async replayDelete(id: string, kind: EncryptedIntegrationKind, idempotencyKey: string): Promise<{ revision: number; duplicate: true } | null> { + const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + if (!uuid.test(id) || !uuid.test(idempotencyKey)) throw new IntegrationValidationError("id and idempotencyKey must be UUIDs"); + const { rows } = await this.database.pool.query("SELECT revision,operation,record_id,kind,name,origin,description,encrypted_payload FROM app.integration_changes WHERE tenant_id=$1 AND idempotency_key=$2", [this.tenantId, idempotencyKey]); + const row = rows[0]; + if (!row) return null; + if (row.record_id !== id || row.kind !== kind || row.operation !== "delete" || row.encrypted_payload !== null) throw new IntegrationIdempotencyConflictError(); + return { revision: safeRevision(row.revision), duplicate: true }; + } + async write(input: IntegrationWrite): Promise<{ revision: number; duplicate: boolean }> { validate(input); const encryptedPayload = input.operation === "upsert" ? await encrypt(input.encryptedPayload!, this.serverEncryptionKey) : null; @@ -126,6 +153,43 @@ export class EncryptedIntegrationRepository { return Promise.all(rows.map(async row => ({ id: row.id, kind: row.kind, name: row.name, origin: row.origin, description: row.description, revision: safeRevision(row.revision), encryptedPayload: await decrypt(row.encrypted_payload, this.serverEncryptionKey) }))); } + /** + * Replace only a model account's user-encrypted Codex cache. The account + * revision is independent of the collection revision, so unrelated bearer + * edits do not invalidate the cache's authenticated-encryption binding. + */ + async saveModelCache(input: { id: string; expectedRevision: number; encryptedPayload: string; idempotencyKey: string }): Promise<{ revision: number; collectionRevision: number; duplicate: boolean }> { + const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + if (!uuid.test(input.id) || !uuid.test(input.idempotencyKey) || !Number.isSafeInteger(input.expectedRevision) || input.expectedRevision < 0 + || !isUserCiphertext(input.encryptedPayload)) throw new IntegrationValidationError("model cache binding is invalid"); + const encryptedPayload = await encrypt(input.encryptedPayload, this.serverEncryptionKey); + return this.database.transaction(async client => { + await client.query("INSERT INTO app.integration_collections(tenant_id) VALUES ($1) ON CONFLICT DO NOTHING", [this.tenantId]); + const collection = (await client.query<{ revision: string | number }>("SELECT revision FROM app.integration_collections WHERE tenant_id=$1 FOR UPDATE", [this.tenantId])).rows[0]!; + const currentCollectionRevision = safeRevision(collection.revision); + const current = (await client.query<{ kind: EncryptedIntegrationKind; name: string; origin: string | null; description: string | null; revision: string | number; encrypted_payload: string | null; deleted_at: Date | null }>( + "SELECT kind,name,origin,description,revision,encrypted_payload,deleted_at FROM app.integration_records WHERE tenant_id=$1 AND id=$2 FOR UPDATE", [this.tenantId, input.id])).rows[0]; + if (!current || current.kind !== "model" || current.deleted_at || current.revision === null) throw new IntegrationValidationError("model account is unavailable"); + const currentRevision = safeRevision(current.revision); + const previousChange = (await client.query<{ revision: string | number; record_id: string; operation: string; encrypted_payload: string | null }>( + "SELECT revision,record_id,operation,encrypted_payload FROM app.integration_changes WHERE tenant_id=$1 AND idempotency_key=$2", [this.tenantId,input.idempotencyKey])).rows[0]; + if (previousChange) { + const same = previousChange.record_id === input.id && previousChange.operation === "upsert" && previousChange.encrypted_payload !== null + && await decrypt(previousChange.encrypted_payload, this.serverEncryptionKey) === input.encryptedPayload; + if (!same) throw new IntegrationIdempotencyConflictError(); + return { revision: Math.max(0, input.expectedRevision + 1), collectionRevision: safeRevision(previousChange.revision), duplicate: true }; + } + if (currentRevision !== input.expectedRevision) throw new StaleIntegrationRevisionError(currentRevision); + const collectionRevision = currentCollectionRevision + 1; + const revision = currentRevision + 1; + await client.query("UPDATE app.integration_collections SET revision=$2,updated_at=now() WHERE tenant_id=$1", [this.tenantId, collectionRevision]); + await client.query("UPDATE app.integration_records SET revision=$3,encrypted_payload=$4,updated_at=now() WHERE tenant_id=$1 AND id=$2", [this.tenantId,input.id,revision,encryptedPayload]); + await client.query(`INSERT INTO app.integration_changes(tenant_id,revision,id,idempotency_key,operation,record_id,kind,name,origin,description,encrypted_payload) + VALUES ($1,$2,$3,$4,'upsert',$5,'model',$6,NULL,$7,$8)`, [this.tenantId,collectionRevision,crypto.randomUUID(),input.idempotencyKey,input.id,current.name,current.description,encryptedPayload]); + return { revision, collectionRevision, duplicate: false }; + }); + } + async changesSince(revision: number, limit = 100): Promise { if (!Number.isSafeInteger(revision) || revision < 0) throw new IntegrationValidationError("revision must be a non-negative safe integer"); if (!Number.isSafeInteger(limit) || limit < 1 || limit > 500) throw new IntegrationValidationError("limit must be between 1 and 500"); diff --git a/packages/api/src/postgres/proxy-control-repository.ts b/packages/api/src/postgres/proxy-control-repository.ts new file mode 100644 index 0000000..047d083 --- /dev/null +++ b/packages/api/src/postgres/proxy-control-repository.ts @@ -0,0 +1,83 @@ +import type { Database } from "./database"; + +export type RegisteredProxy = { tenantId: string; proxyIdentity: string; bootEpoch: string; controlCredential: string }; +const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const digest = async (token: string) => new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(token))); + +/** Credentials for the Factorize-to-proxy control plane. These are never job run credentials. */ +export class ProxyControlRepository { + constructor(private database: Database) {} + + /** Called only from the authenticated infrastructure provisioning path; the token is returned once. */ + async provisionCredential(tenantId: string, proxyIdentity: string, bootEpoch: string): Promise { + if (!uuid.test(tenantId) || !proxyIdentity.trim() || proxyIdentity.length > 255 || !uuid.test(bootEpoch)) throw new TypeError("Invalid proxy identity or boot epoch"); + const token = `fzc_${Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString("base64url")}`; + await this.database.pool.query(`INSERT INTO app.credential_proxy_control_credentials(tenant_id,proxy_identity,boot_epoch,token_digest,revoked_at,consumed_at) + VALUES ($1,$2,$3,$4,NULL,NULL) ON CONFLICT (tenant_id,proxy_identity) DO UPDATE SET boot_epoch=excluded.boot_epoch,token_digest=excluded.token_digest,proxy_token_digest=NULL,revoked_at=NULL,consumed_at=NULL,created_at=now()`, + [tenantId, proxyIdentity, bootEpoch, Buffer.from(await digest(token))]); + return token; + } + + async authenticate(token: string): Promise | null> { + if (typeof token !== "string" || !/^fzc_[A-Za-z0-9_-]{43}$/.test(token)) return null; + const result = await this.database.pool.query<{ tenant_id: string; proxy_identity: string }>(`SELECT tenant_id,proxy_identity + FROM app.credential_proxy_control_credentials WHERE token_digest=$1 AND revoked_at IS NULL`, [Buffer.from(await digest(token))]); + const row = result.rows[0]; + return row ? { tenantId: row.tenant_id, proxyIdentity: row.proxy_identity } : null; + } + + async registerBoot(token: string, bootEpoch: string, browserOrigin: string): Promise { + if (!uuid.test(bootEpoch)) throw new TypeError("bootEpoch must be a UUID"); + const parsedOrigin = new URL(browserOrigin); + if (parsedOrigin.protocol !== "https:" || parsedOrigin.origin !== browserOrigin || parsedOrigin.username || parsedOrigin.password) throw new TypeError("browser origin must be canonical HTTPS"); + const tokenDigest = Buffer.from(await digest(token)); + return this.database.transaction(async client => { + const credential = await client.query<{ tenant_id: string; proxy_identity: string }>(`SELECT tenant_id,proxy_identity + FROM app.credential_proxy_control_credentials WHERE token_digest=$1 AND boot_epoch=$2 AND revoked_at IS NULL AND consumed_at IS NULL FOR UPDATE`, [tokenDigest, bootEpoch]); + const identity = credential.rows[0]; + if (!identity) return null; + const saved = await client.query(`INSERT INTO app.credential_proxy_states(tenant_id,proxy_identity,boot_epoch) + VALUES ($1,$2,$3) ON CONFLICT (tenant_id) DO UPDATE SET proxy_identity=excluded.proxy_identity,boot_epoch=excluded.boot_epoch,updated_at=now() + WHERE app.credential_proxy_states.proxy_identity=excluded.proxy_identity + AND app.credential_proxy_states.disabled_at IS NULL AND app.credential_proxy_states.revoked_at IS NULL RETURNING tenant_id`, + [identity.tenant_id, identity.proxy_identity, bootEpoch]); + if (!saved.rowCount) return null; + const proxyToken = `fzc_${Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString("base64url")}`; + await client.query(`UPDATE app.credential_proxy_control_credentials SET consumed_at=now(),proxy_token_digest=$3 + WHERE token_digest=$1 AND boot_epoch=$2 AND consumed_at IS NULL`, [tokenDigest, bootEpoch, Buffer.from(await digest(proxyToken))]); + await client.query(`UPDATE app.credential_run_grants SET revoked_at=now() + WHERE tenant_id=$1 AND proxy_identity=$2 AND boot_epoch<>$3 AND revoked_at IS NULL`, [identity.tenant_id, identity.proxy_identity, bootEpoch]); + await client.query(`INSERT INTO app.credential_proxy_browser_pairings(tenant_id,proxy_identity,boot_epoch,browser_origin) + VALUES ($1,$2,$3,$4) ON CONFLICT (tenant_id) DO UPDATE SET proxy_identity=excluded.proxy_identity, + boot_epoch=excluded.boot_epoch,browser_origin=excluded.browser_origin,updated_at=now()`, + [identity.tenant_id, identity.proxy_identity, bootEpoch, browserOrigin]); + return { tenantId: identity.tenant_id, proxyIdentity: identity.proxy_identity, bootEpoch, controlCredential: proxyToken }; + }); + } + + async browserPairing(tenantId: string): Promise<{ proxyIdentity: string; bootEpoch: string; browserOrigin: string } | null> { + const result = await this.database.pool.query<{ proxy_identity: string; boot_epoch: string; browser_origin: string }>( + `SELECT b.proxy_identity,b.boot_epoch,b.browser_origin FROM app.credential_proxy_browser_pairings b + JOIN app.credential_proxy_states p ON p.tenant_id=b.tenant_id AND p.proxy_identity=b.proxy_identity AND p.boot_epoch=b.boot_epoch + WHERE b.tenant_id=$1 AND p.disabled_at IS NULL AND p.revoked_at IS NULL`, [tenantId]); + const row = result.rows[0]; + return row ? { proxyIdentity: row.proxy_identity, bootEpoch: row.boot_epoch, browserOrigin: row.browser_origin } : null; + } + + /** Authenticates the proxy's memory-held control credential against its current registered boot. */ + async authenticateProxy(token: string): Promise | null> { + if (typeof token !== "string" || !/^fzc_[A-Za-z0-9_-]{43}$/.test(token)) return null; + const result = await this.database.pool.query<{ tenant_id: string; proxy_identity: string; boot_epoch: string }>(`SELECT c.tenant_id,c.proxy_identity,c.boot_epoch + FROM app.credential_proxy_control_credentials c JOIN app.credential_proxy_states p + ON p.tenant_id=c.tenant_id AND p.proxy_identity=c.proxy_identity AND p.boot_epoch=c.boot_epoch + WHERE c.proxy_token_digest=$1 AND c.revoked_at IS NULL AND p.disabled_at IS NULL AND p.revoked_at IS NULL`, [Buffer.from(await digest(token))]); + const row = result.rows[0]; + return row ? { tenantId: row.tenant_id, proxyIdentity: row.proxy_identity, bootEpoch: row.boot_epoch } : null; + } + + async revokeCredential(tenantId: string, proxyIdentity: string): Promise { + const result = await this.database.pool.query(`UPDATE app.credential_proxy_control_credentials SET revoked_at=now() + WHERE tenant_id=$1 AND proxy_identity=$2 AND revoked_at IS NULL`, [tenantId, proxyIdentity]); + return Boolean(result.rowCount); + } +} diff --git a/packages/api/src/postgres/schema.ts b/packages/api/src/postgres/schema.ts index 5bb398a..5092f71 100644 --- a/packages/api/src/postgres/schema.ts +++ b/packages/api/src/postgres/schema.ts @@ -53,6 +53,12 @@ export const integrationChanges = app.table("integration_changes", { export const credentialProxyStates = app.table("credential_proxy_states", { tenantId: uuid("tenant_id").primaryKey().references(() => tenants.id, { onDelete: "cascade" }), proxyIdentity: text("proxy_identity").notNull(), bootEpoch: uuid("boot_epoch").notNull(), disabledAt: timestamp("disabled_at", { withTimezone: true }), revokedAt: timestamp("revoked_at", { withTimezone: true }), updatedAt: updatedAt(), }); +export const credentialProxyBrowserPairings = app.table("credential_proxy_browser_pairings", { + tenantId: uuid("tenant_id").primaryKey().references(() => tenants.id, { onDelete: "cascade" }), + proxyIdentity: text("proxy_identity").notNull(), bootEpoch: uuid("boot_epoch").notNull(), + browserOrigin: text("browser_origin").notNull(), updatedAt: updatedAt(), +}); +export const credentialProxyControlCredentials = app.table("credential_proxy_control_credentials", { tenantId: uuid("tenant_id").notNull().references(() => tenants.id, { onDelete: "cascade" }), proxyIdentity: text("proxy_identity").notNull(), bootEpoch: uuid("boot_epoch").notNull(), tokenDigest: bytea("token_digest").notNull().unique(), proxyTokenDigest: bytea("proxy_token_digest").unique(), revokedAt: timestamp("revoked_at", { withTimezone: true }), consumedAt: timestamp("consumed_at", { withTimezone: true }), createdAt: createdAt() }, t => [primaryKey({ columns: [t.tenantId, t.proxyIdentity] })]); export const credentialRunGrants = app.table("credential_run_grants", { tenantId: uuid("tenant_id").notNull(), runId: uuid("run_id").notNull(), vmIdentity: text("vm_identity").notNull(), proxyIdentity: text("proxy_identity").notNull(), bootEpoch: uuid("boot_epoch").notNull(), grantRevision: bigint("grant_revision", { mode: "number" }).notNull(), revokedAt: timestamp("revoked_at", { withTimezone: true }), createdAt: createdAt(), }, t => [primaryKey({ columns: [t.tenantId, t.runId] }), foreignKey({ columns: [t.tenantId, t.runId], foreignColumns: [jobRuns.tenantId, jobRuns.id] }).onDelete("cascade"), check("credential_run_grants_revision_check", sql`${t.grantRevision} > 0`)]); @@ -155,7 +161,7 @@ export const runRelations = relations(runs, ({ one, many }) => ({ export const databaseSchema = { schemaMigrations, tenants, oauthConnections, oauthConsentPreviews, authUsers, authAccounts, authResetTokens, authSessions, oauthDeviceAuthorizations, members, accessTokens, - connections, integrationCollections, integrationRecords, integrationChanges, credentialProxyStates, credentialRunGrants, credentialRunEnrollments, linearWorkspaces, githubInstallations, githubSetupStates, projects, jobs, vmImageVerifications, skills, jobSkills, triggers, webhookConditionsCutover, invocations, manualInvocationPermissions, jobRuns, runCredentials, runs, runArtifacts, + connections, integrationCollections, integrationRecords, integrationChanges, credentialProxyStates, credentialProxyControlCredentials, credentialRunGrants, credentialRunEnrollments, linearWorkspaces, githubInstallations, githubSetupStates, projects, jobs, vmImageVerifications, skills, jobSkills, triggers, webhookConditionsCutover, invocations, manualInvocationPermissions, jobRuns, runCredentials, runs, runArtifacts, runTraceEvents, runActivity, activeClaims, scheduleState, automaticWakes, lifecycleDeliveries, lifecycleEvents, jobEditDeliveries, jobEvents, webhookDeliveries, webhookDeliveryEvents, pendingVerifications, tailFingerprints, wakeHints, runTraceCursors, runTraceChunks, runTraceProjections, traceReplayLimits, traceReplayOperations, diff --git a/packages/api/src/proxy-browser-capability.ts b/packages/api/src/proxy-browser-capability.ts new file mode 100644 index 0000000..fcb3ed9 --- /dev/null +++ b/packages/api/src/proxy-browser-capability.ts @@ -0,0 +1,38 @@ +const encoder = new TextEncoder(); +const base64url = (bytes: Uint8Array) => btoa(String.fromCharCode(...bytes)).replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_"); + +function decodePem(pem: string): Uint8Array { + const body = pem.replace(/-----BEGIN PRIVATE KEY-----|-----END PRIVATE KEY-----|\s/g, ""); + if (!/^[A-Za-z0-9+/]+={0,2}$/.test(body)) throw new TypeError("invalid browser capability signing key"); + const binary = atob(body); + return Uint8Array.from(binary, character => character.charCodeAt(0)); +} + +/** Issue a one-use capability accepted only by the paired proxy listener. */ +export async function issueModelAccountCapability(input: { + privateKeyPem: string; + proxyIdentity: string; + ownerId: string; + accountId: string; + action: "authorize" | "status" | "cancel"; + appOrigin: string; + generation: number | string; + now?: number; + jti?: string; +}): Promise { + const now = input.now ?? Math.floor(Date.now() / 1000); + const header = base64url(encoder.encode(JSON.stringify({ alg: "EdDSA", typ: "JWT" }))); + const claims = base64url(encoder.encode(JSON.stringify({ + iss: "factorize", aud: input.proxyIdentity, sub: input.ownerId, + action: `model-account:${input.action}`, accountId: input.accountId, + origin: input.appOrigin, generation: input.generation, exp: now + 60, + jti: input.jti ?? base64url(crypto.getRandomValues(new Uint8Array(24))), + }))); + const payload = `${header}.${claims}`; + const rawKey = decodePem(input.privateKeyPem); + const keyData = new ArrayBuffer(rawKey.byteLength); + new Uint8Array(keyData).set(rawKey); + const key = await crypto.subtle.importKey("pkcs8", keyData, { name: "Ed25519" }, false, ["sign"]); + const signature = await crypto.subtle.sign("Ed25519", key, encoder.encode(payload)); + return `${payload}.${base64url(new Uint8Array(signature))}`; +} diff --git a/packages/api/src/proxy-control.ts b/packages/api/src/proxy-control.ts new file mode 100644 index 0000000..64db6ad --- /dev/null +++ b/packages/api/src/proxy-control.ts @@ -0,0 +1,146 @@ +import { databaseFor } from "./postgres/database"; +import { ProxyControlRepository } from "./postgres/proxy-control-repository"; +import { RunEnrollmentRepository } from "./postgres/run-enrollment-repository"; +import { EncryptedIntegrationRepository } from "./postgres/encrypted-integration-repository"; +import { authenticateRunCredential } from "./run-credentials"; + +const json = (body: unknown, status = 200) => Response.json(body, { status, headers: { "Cache-Control": "no-store" } }); + +/** Dedicated service-to-service endpoint. The run API credential is intentionally not accepted here. */ +export async function proxyControlFetch(request: Request, env: Parameters[0]): Promise { + const url = new URL(request.url); + if (!["/internal/credential-proxy/register-boot", "/internal/credential-proxy/enroll", "/internal/credential-proxy/consume-enrollment", "/internal/credential-proxy/integration-snapshot", "/internal/credential-proxy/model-account-cache", "/internal/credential-proxy/revocations"].includes(url.pathname)) return json({ error: "not_found" }, 404); + if (request.method !== "POST") return json({ error: "method_not_allowed" }, 405); + if (request.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase() !== "application/json") return json({ error: "unsupported_media_type" }, 415); + const authorization = request.headers.get("authorization") ?? ""; + const token = authorization.startsWith("Bearer ") ? authorization.slice(7) : ""; + if (!/^fzc_[A-Za-z0-9_-]{43}$/.test(token)) return json({ error: "unauthorized" }, 401); + const declaredLength = Number(request.headers.get("content-length") ?? "0"); + const maxBodyBytes = url.pathname === "/internal/credential-proxy/model-account-cache" ? 70_000 : 1024; + if (declaredLength > maxBodyBytes) return json({ error: "invalid_request" }, 413); + let body: unknown; + try { + const reader = request.body?.getReader(); + const chunks: Uint8Array[] = []; let length = 0; + if (reader) for (;;) { + const { done, value } = await reader.read(); if (done) break; + length += value.byteLength; + if (length > maxBodyBytes) { await reader.cancel(); return json({ error: "invalid_request" }, 413); } + chunks.push(value); + } + const bytes = new Uint8Array(length); let offset = 0; + for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength; } + body = JSON.parse(new TextDecoder().decode(bytes)); + } catch { return json({ error: "invalid_request" }, 400); } + const repository = new ProxyControlRepository(databaseFor(env)); + if (url.pathname === "/internal/credential-proxy/register-boot") { + if (!body || typeof body !== "object" || Array.isArray(body) || Object.keys(body).sort().join(",") !== "bootEpoch,browserOrigin" || typeof (body as any).bootEpoch !== "string" || typeof (body as any).browserOrigin !== "string") return json({ error: "invalid_request" }, 400); + const registration = await repository.registerBoot(token, (body as any).bootEpoch, (body as any).browserOrigin); + if (!registration) return json({ error: "proxy_registration_unavailable" }, 403); + return json({ registered: true, tenantId: registration.tenantId, proxyIdentity: registration.proxyIdentity, bootEpoch: registration.bootEpoch, controlCredential: registration.controlCredential }); + } + + const proxy = await repository.authenticateProxy(token); + if (!proxy) return json({ error: "unauthorized" }, 401); + if (url.pathname === "/internal/credential-proxy/revocations") { + if (!body || typeof body !== "object" || Array.isArray(body) || Object.keys(body).some(key => key !== "bootEpoch") + || typeof (body as any).bootEpoch !== "string" || (body as any).bootEpoch !== proxy.bootEpoch) return json({ error: "invalid_request" }, 400); + const revoked = await databaseFor(env).pool.query<{ run_id: string }>(`SELECT run_id FROM app.credential_run_grants + WHERE tenant_id=$1 AND proxy_identity=$2 AND boot_epoch=$3 AND revoked_at IS NOT NULL ORDER BY revoked_at,run_id`, + [proxy.tenantId, proxy.proxyIdentity, proxy.bootEpoch]); + return json({ revokedRunIds: revoked.rows.map(row => row.run_id) }); + } + if (url.pathname === "/internal/credential-proxy/model-account-cache") { + if (!body || typeof body !== "object" || Array.isArray(body)) return json({ error: "invalid_request" }, 400); + const input = body as Record; + const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + if (input.operation === "list" && Object.keys(input).length === 1) { + const accounts = await new EncryptedIntegrationRepository(databaseFor(env), proxy.tenantId, env.CREDENTIAL_ENCRYPTION_KEY).records("model"); + const keyState = await databaseFor(env).pool.query<{ generation_id: string }>("SELECT generation_id FROM app.unlock_key_verifications WHERE tenant_id=$1", [proxy.tenantId]); + if (!keyState.rows[0]) return json({ error: "model_account_unavailable" }, 404); + return json({ ownerId: proxy.tenantId, keyGeneration: keyState.rows[0].generation_id, + accounts: accounts.map(({ id, revision }) => ({ id, revision, kind: "model", origin: null })) }); + } + if (input.operation === "read" && Object.keys(input).sort().join(",") === "accountId,operation" + && typeof input.accountId === "string" && uuid.test(input.accountId)) { + const account = (await new EncryptedIntegrationRepository(databaseFor(env), proxy.tenantId, env.CREDENTIAL_ENCRYPTION_KEY).records("model")) + .find(record => record.id === input.accountId); + if (!account) return json({ error: "model_account_unavailable" }, 404); + const keyState = await databaseFor(env).pool.query<{ generation_id: string }>("SELECT generation_id FROM app.unlock_key_verifications WHERE tenant_id=$1", [proxy.tenantId]); + if (!keyState.rows[0]) return json({ error: "model_account_unavailable" }, 404); + return json({ ownerId: proxy.tenantId, account: { ...account, keyGeneration: keyState.rows[0].generation_id } }); + } + if (input.operation !== "save" || Object.keys(input).sort().join(",") !== "accountId,encryptedPayload,expectedRevision,idempotencyKey,operation" + || typeof input.accountId !== "string" || !uuid.test(input.accountId) + || typeof input.idempotencyKey !== "string" || !uuid.test(input.idempotencyKey) + || !Number.isSafeInteger(input.expectedRevision) || (input.expectedRevision as number) < 0 + || typeof input.encryptedPayload !== "string") return json({ error: "invalid_request" }, 400); + try { + const saved = await new EncryptedIntegrationRepository(databaseFor(env), proxy.tenantId, env.CREDENTIAL_ENCRYPTION_KEY).saveModelCache({ + id: input.accountId, expectedRevision: input.expectedRevision as number, + idempotencyKey: input.idempotencyKey, encryptedPayload: input.encryptedPayload, + }); + return json({ ...saved, accountId: input.accountId }); + } catch (error) { + if (error instanceof Error && error.name === "StaleIntegrationRevisionError") return json({ error: "stale_revision" }, 409); + if (error instanceof Error && error.name === "IntegrationIdempotencyConflictError") return json({ error: "idempotency_conflict" }, 409); + if (error instanceof Error && error.name === "IntegrationValidationError") return json({ error: "model_account_unavailable" }, 404); + throw error; + } + } + if (url.pathname === "/internal/credential-proxy/integration-snapshot") { + if (!body || typeof body !== "object" || Array.isArray(body) || Object.keys(body).some(key => !["runId", "grantRevision"].includes(key)) + || typeof (body as any).runId !== "string" || !Number.isSafeInteger((body as any).grantRevision) || (body as any).grantRevision < 0) return json({ error: "invalid_request" }, 400); + const database = databaseFor(env); + const active = await database.pool.query<{ execution_config: Record; grant_revision: string | number }>(`SELECT r.execution_config,g.grant_revision + FROM app.runs r JOIN app.credential_run_grants g ON g.tenant_id=r.tenant_id AND g.run_id=r.id + JOIN app.credential_proxy_states p ON p.tenant_id=g.tenant_id AND p.proxy_identity=g.proxy_identity AND p.boot_epoch=g.boot_epoch + WHERE r.tenant_id=$1 AND r.id=$2 AND r.state IN ('starting','running','blocked') + AND g.proxy_identity=$3 AND g.boot_epoch=$4 AND g.grant_revision=$5 AND g.revoked_at IS NULL + AND p.disabled_at IS NULL AND p.revoked_at IS NULL`, [proxy.tenantId, (body as any).runId, proxy.proxyIdentity, proxy.bootEpoch, (body as any).grantRevision]); + const run = active.rows[0]; + if (!run) return json({ error: "integration_snapshot_unavailable" }, 403); + const selected = run.execution_config?.portableIntegrationIds; + if (!Array.isArray(selected) || selected.some(id => typeof id !== "string") || new Set(selected).size !== selected.length) return json({ error: "integration_snapshot_unavailable" }, 403); + const records = selected.length + ? (await new EncryptedIntegrationRepository(database, proxy.tenantId, env.CREDENTIAL_ENCRYPTION_KEY).records("bearer")) + .filter(record => selected.includes(record.id)) + : []; + if (records.length !== selected.length) return json({ error: "integration_snapshot_unavailable" }, 403); + const modelAccess = run.execution_config?.modelAccess ?? "native"; + const modelAccountId = run.execution_config?.modelAccountId; + let modelAccount = null; + if (modelAccess === "portable") { + if (typeof modelAccountId !== "string") return json({ error: "integration_snapshot_unavailable" }, 403); + modelAccount = (await new EncryptedIntegrationRepository(database, proxy.tenantId, env.CREDENTIAL_ENCRYPTION_KEY).records("model")) + .find(record => record.id === modelAccountId) ?? null; + if (!modelAccount) return json({ error: "integration_snapshot_unavailable" }, 403); + } else if (modelAccess !== "native" || modelAccountId !== undefined) { + return json({ error: "integration_snapshot_unavailable" }, 403); + } + // Only the selected model account's user-encrypted payload crosses the + // control plane. It remains encrypted until the proxy is unlocked. + return json({ ownerId: proxy.tenantId, records, modelAccount }); + } + const enrollments = new RunEnrollmentRepository(databaseFor(env)); + if (url.pathname === "/internal/credential-proxy/enroll") { + if (!body || typeof body !== "object" || Array.isArray(body) || Object.keys(body).some(key => !["runId", "vmIdentity", "runCredential"].includes(key)) + || typeof (body as any).runId !== "string" || typeof (body as any).vmIdentity !== "string" || typeof (body as any).runCredential !== "string" + || (body as any).vmIdentity.length === 0 || (body as any).vmIdentity.length > 255) return json({ error: "invalid_request" }, 400); + // A proxy-authenticated request alone must not let a peer that can reach + // the proxy claim another active run. The VM's distinct run credential is + // validated against the current run and tenant before issuing enrollment. + const runAuth = await authenticateRunCredential(new Request(request.url, { + headers: { Authorization: `Bearer ${(body as any).runCredential}` }, + }), env); + if (!runAuth || !runAuth.runId || runAuth.runId !== (body as any).runId || runAuth.tenantId !== proxy.tenantId + || !runAuth.scopes.includes("integrations:read")) return json({ error: "enrollment_unavailable" }, 403); + const enrollment = await enrollments.issue({ tenantId: proxy.tenantId, runId: runAuth.runId, vmIdentity: (body as any).vmIdentity, proxyIdentity: proxy.proxyIdentity, bootEpoch: proxy.bootEpoch }, new Date(Date.now() + 30_000)); + return enrollment ? json(enrollment) : json({ error: "enrollment_unavailable" }, 403); + } + if (!body || typeof body !== "object" || Array.isArray(body) || Object.keys(body).some(key => !["runId", "vmIdentity", "enrollmentToken"].includes(key)) + || typeof (body as any).runId !== "string" || typeof (body as any).vmIdentity !== "string" || typeof (body as any).enrollmentToken !== "string") return json({ error: "invalid_request" }, 400); + const binding = { tenantId: proxy.tenantId, runId: (body as any).runId, vmIdentity: (body as any).vmIdentity, proxyIdentity: proxy.proxyIdentity, bootEpoch: proxy.bootEpoch }; + const consumed = await enrollments.consume((body as any).enrollmentToken, binding); + return consumed ? json({ consumed: true, binding: consumed }) : json({ error: "enrollment_unavailable" }, 403); +} diff --git a/packages/api/src/proxy-provisioner.ts b/packages/api/src/proxy-provisioner.ts new file mode 100644 index 0000000..c32e7e5 --- /dev/null +++ b/packages/api/src/proxy-provisioner.ts @@ -0,0 +1,40 @@ +import type { ProxyControlRepository } from "./postgres/proxy-control-repository"; + +export type ProxyBootstrapProvisioning = { + tenantId: string; + proxyIdentity: string; + /** A new UUID for every process boot. */ + bootEpoch: string; + controlOrigin: string; + browserOrigin: string; +}; + +/** + * Connects one-use bootstrap credential issuance to the infrastructure + * provisioner's private control channel. Implementations must deliver the + * credential directly to proxy stdin (or an equally short-lived memory-only + * channel), never through Machine config, environment, metadata, disk, or logs. + */ +export async function provisionCredentialProxy( + input: ProxyBootstrapProvisioning, + dependencies: { + control: Pick; + startProxy: (request: ProxyBootstrapProvisioning & { credential: string }) => Promise; + }, +): Promise<{ proxyIdentity: string; bootEpoch: string }> { + const origin = new URL(input.controlOrigin); + if (origin.protocol !== "https:" || origin.username || origin.password || origin.pathname !== "/" || origin.search || origin.hash) { + throw new TypeError("controlOrigin must be an HTTPS origin"); + } + const browserOrigin = new URL(input.browserOrigin); + if (browserOrigin.protocol !== "https:" || browserOrigin.origin !== input.browserOrigin || browserOrigin.username || browserOrigin.password) throw new TypeError("browserOrigin must be an HTTPS origin"); + const credential = await dependencies.control.provisionCredential(input.tenantId, input.proxyIdentity, input.bootEpoch); + try { + await dependencies.startProxy({ ...input, controlOrigin: origin.origin, browserOrigin: browserOrigin.origin, credential }); + } catch (error) { + // A failed/ambiguous start must not leave a valid bootstrap credential. + await dependencies.control.revokeCredential(input.tenantId, input.proxyIdentity).catch(() => false); + throw error; + } + return { proxyIdentity: input.proxyIdentity, bootEpoch: input.bootEpoch }; +} diff --git a/packages/api/src/types.ts b/packages/api/src/types.ts index 3aa3b95..250dfe3 100644 --- a/packages/api/src/types.ts +++ b/packages/api/src/types.ts @@ -15,6 +15,9 @@ export interface Env { CLICKUP_CLIENT_SECRET?: string; CREDENTIAL_ENCRYPTION_KEY: string; SESSION_SIGNING_SECRET: string; + /** PKCS#8 Ed25519 key used only for short-lived proxy browser capabilities. */ + MODEL_ACCOUNT_CAPABILITY_PRIVATE_KEY?: string; + MODEL_ACCOUNT_CAPABILITY_PUBLIC_KEY?: string; GITHUB_APP_ID?: string; GITHUB_APP_SLUG?: string; GITHUB_APP_PRIVATE_KEY?: string; diff --git a/packages/api/src/worker.ts b/packages/api/src/worker.ts index d786834..e32c339 100644 --- a/packages/api/src/worker.ts +++ b/packages/api/src/worker.ts @@ -14,6 +14,7 @@ import { artifactUpload } from "./artifact-upload"; import { traceChunkUpload } from "./trace-chunk-upload"; import { databaseFor } from "./postgres/database"; import { IdentityRepository } from "./postgres/identity-repository"; +import { proxyControlFetch } from "./proxy-control"; const scopes = [...ACCESS_SCOPES]; // Keep accepting grants issued by the pre-resource-scope protocol. New @@ -85,6 +86,7 @@ function provider(env: Env) { return new OAuthProvider(providerOptions(env) export { AlarmCoordinator } from "./alarm-coordinator"; const backend = { async fetch(request: Request, env: Env, ctx: ExecutionContext) { + if (new URL(request.url).pathname.startsWith("/internal/credential-proxy/")) return proxyControlFetch(request, env); const staticResponse = !["/authorize", "/device"].includes(new URL(request.url).pathname) ? await staticApp(request, env) : null; if (staticResponse) return staticResponse; const oauth = provider(env); diff --git a/packages/api/test/api-contract.test.ts b/packages/api/test/api-contract.test.ts index 688d752..abfbcb0 100644 --- a/packages/api/test/api-contract.test.ts +++ b/packages/api/test/api-contract.test.ts @@ -64,6 +64,31 @@ describe("versioned API contract", () => { expect(post?.body?.safeParse({ generationId: "00000000-0000-4000-8000-000000000001", encryptedVerification: "ciphertext", key: "plain" }).success).toBe(false); expect(openapi.paths["/api/v1/unlock-key/verification"].post.requestBody).toBeDefined(); }); + it("publishes model-account proxy capabilities as owner-only API operations", () => { + const route = API_ROUTES.find(item => item.method === "POST" && item.path === "/api/v1/model-accounts/{accountId}/browser-capability"); + expect(route?.scope).toBe("integrations:write"); + expect(route?.ownerSession).toBe(true); + expect(route?.parameters.shape.accountId).toBeDefined(); + expect(route?.body?.safeParse({ action: "authorize" }).success).toBe(true); + expect(route?.body?.safeParse({ action: "authorize", accountId: "attacker" }).success).toBe(false); + expect(openapi.paths["/api/v1/model-accounts/{accountId}/browser-capability"].post.requestBody).toBeDefined(); + }); + it("lists only safe model account metadata for the owner browser", () => { + const route = API_ROUTES.find(item => item.method === "GET" && item.path === "/api/v1/model-accounts"); + const create = API_ROUTES.find(item => item.method === "POST" && item.path === "/api/v1/model-accounts"); + const remove = API_ROUTES.find(item => item.method === "DELETE" && item.path === "/api/v1/model-accounts/{accountId}"); + expect(route?.scope).toBe("integrations:read"); + expect(route?.ownerSession).toBe(true); + expect(route?.documentation.responses["200"].content["application/json"].schema.properties.accounts.items.properties.encryptedPayload).toBeUndefined(); + expect(create?.scope).toBe("integrations:write"); expect(create?.ownerSession).toBe(true); + expect(create?.body?.safeParse({ id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", expectedRevision: 0, idempotencyKey: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", name: "Account", description: null, encryptedPayload: "cipher" }).success).toBe(true); + expect(create?.body?.safeParse({ id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", expectedRevision: 0, idempotencyKey: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", name: "Account", description: null, encryptedPayload: "cipher", unlockKey: "must-not-be-accepted" }).success).toBe(false); + expect(remove?.scope).toBe("integrations:write"); expect(remove?.ownerSession).toBe(true); + expect(remove?.parameters.shape.accountId).toBeDefined(); + expect(openapi.paths["/api/v1/model-accounts"].post).toBeDefined(); + expect(openapi.paths["/api/v1/model-accounts/{accountId}"].delete).toBeDefined(); + expect(openapi.paths["/api/v1/model-accounts"].get).toBeDefined(); + }); it("accepts code jobs without agent prompts or models and rejects missing scripts", () => { const code = { name: "Export", slug: "export", executionType: "code", script: "#!/bin/sh\nprintf '{\"status\":\"succeeded\"}' > \"$FACTORIZE_RESULT_PATH\"", executionConfigId: "exe", triggers: [] }; expect(jobInputSchema.safeParse(code).success).toBe(true); diff --git a/packages/api/test/encrypted-integration-api.postgres.test.ts b/packages/api/test/encrypted-integration-api.postgres.test.ts index 7aa42ad..4a5622b 100644 --- a/packages/api/test/encrypted-integration-api.postgres.test.ts +++ b/packages/api/test/encrypted-integration-api.postgres.test.ts @@ -76,6 +76,43 @@ describe.skipIf(!databaseUrl)("browser-only encrypted bearer integration API (Po } finally { await f.cleanup(); } }); + it("creates and deletes model accounts through owner-only revision-checked writes", async () => { + const f = await loadingFixture(databaseUrl!); + try { + const path = f.env.APP_ORIGIN + "/api/v1/model-accounts", id = crypto.randomUUID(); + const request = (url: string, method = "GET", body?: unknown, cookie = f.cookie) => worker.fetch(new Request(url, { + method, headers: { Cookie: cookie, Origin: f.env.APP_ORIGIN, "Content-Type": "application/json" }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }), f.env, { waitUntil() {} } as any); + const ciphertext = await userEncrypt("{}"), createBody = { + id, expectedRevision: 0, idempotencyKey: crypto.randomUUID(), name: "Work account", + description: "Personal subscription", encryptedPayload: ciphertext, + }; + const created = await request(path, "POST", createBody); + expect(created.status).toBe(201); + expect(await created.json()).toEqual({ revision: 1, duplicate: false }); + expect(await (await request(path, "POST", createBody)).json()).toEqual({ revision: 1, duplicate: true }); + + const listed = await request(path); + expect(listed.status).toBe(200); + expect(await listed.json()).toEqual({ revision: 1, accounts: [{ id, name: "Work account", description: "Personal subscription", revision: 1 }] }); + + const stale = await request(path + `/${id}`, "DELETE", { expectedRevision: 0, idempotencyKey: crypto.randomUUID() }); + expect(stale.status).toBe(409); + expect((await stale.json() as any).error.code).toBe("stale_revision"); + + const deleteBody = { expectedRevision: 1, idempotencyKey: crypto.randomUUID() }; + const deleted = await request(path + `/${id}`, "DELETE", deleteBody); + expect(deleted.status).toBe(200); + expect(await deleted.json()).toEqual({ id, deleted: true, revision: 2, duplicate: false }); + expect(await (await request(path + `/${id}`, "DELETE", deleteBody)).json()).toEqual({ id, deleted: true, revision: 2, duplicate: true }); + expect(await (await request(path)).json()).toEqual({ revision: 2, accounts: [] }); + + const agent = await protectedApiFetch(new Request(path, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ...createBody, id: crypto.randomUUID() }) }), f.env, { tenantId: f.tenantId, userId: f.userId, sessionVersion: 0, scopes: ["integrations:write"], authMethod: "access_token" } as any, {} as any); + expect(agent.status).toBe(403); + } finally { await f.cleanup(); } + }); + it("requires a browser session and isolates records across tenants", async () => { const f = await loadingFixture(databaseUrl!); try { diff --git a/packages/api/test/fly-credential-proxy-provisioner.test.ts b/packages/api/test/fly-credential-proxy-provisioner.test.ts new file mode 100644 index 0000000..098dcc0 --- /dev/null +++ b/packages/api/test/fly-credential-proxy-provisioner.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it, vi } from "vitest"; +import { createFlyCredentialProxyProvisioner, createFlyCredentialProxyGuest } from "../src/fly-credential-proxy-provisioner"; + +const epoch = "11111111-1111-4111-8111-111111111111"; +const machine = { id: "machine-1", state: "created" }; +const input = { + tenantId: "11111111-1111-4111-8111-111111111112", + app: "factorize-proxies", + image: `ghcr.io/generationsoftware/credential-proxy@sha256:${"a".repeat(64)}`, + region: "iad", + size: "shared-cpu-1x", + browserCapabilityPublicKey: "-----BEGIN PUBLIC KEY-----\nfixture\n-----END PUBLIC KEY-----", + controlOrigin: "https://factorize.example", + operationId: "tenant-proxy-start-1", +}; + +function fixture() { + const control = { + provisionCredential: vi.fn(async () => `fzc_${"b".repeat(43)}`), + revokeCredential: vi.fn(async () => true), + }; + const machines = { + createMachine: vi.fn(async () => machine), + startMachine: vi.fn(async () => undefined), + destroyMachine: vi.fn(async () => undefined), + }; + const guest = vi.fn(() => ({ startProxy: vi.fn(async () => undefined), issueVmCertificate: vi.fn(async () => ({ certificate: "", trustBundle: "" })) })); + const provisioner = createFlyCredentialProxyProvisioner({ control, machines, guest, newBootEpoch: () => epoch }); + return { control, machines, guest, provisioner }; +} + +describe("Fly credential proxy startup", () => { + it("creates a digest-pinned dedicated Machine and delivers bootstrap only through startup", async () => { + const { control, machines, guest, provisioner } = fixture(); + const started = await provisioner.start(input); + expect(started).toMatchObject({ proxyIdentity: "fly:factorize-proxies:machine-1", bootEpoch: epoch, machine }); + expect(machines.createMachine).toHaveBeenCalledWith(input.app, expect.objectContaining({ + image: input.image, + region: input.region, + cmd: ["/usr/local/bin/factorize-credential-proxy", "--boot-epoch", epoch], + env: { FACTORIZE_PROXY_TUNNEL_SERVER_NAME: "factorize-proxies.internal", FACTORIZE_PROXY_TUNNEL_PORT: "9090", FACTORIZE_PROXY_BROWSER_CAPABILITY_PUBLIC_KEY: input.browserCapabilityPublicKey }, + services: [{ protocol: "tcp", internal_port: 8080, ports: [{ port: 443, handlers: ["tls", "http"] }], force_instance_key: true }], + restart: { policy: "no" }, + auto_start: false, + metadata: expect.objectContaining({ factorize_role: "credential-proxy", factorize_tenant_id: input.tenantId }), + }), input.operationId); + expect(JSON.stringify(machines.createMachine.mock.calls[0])).not.toContain("fzc_"); + expect(machines.startMachine).toHaveBeenCalledWith(input.app, machine.id); + expect(control.provisionCredential).toHaveBeenCalledWith(input.tenantId, "fly:factorize-proxies:machine-1", epoch); + expect(guest).toHaveBeenCalledWith(machine); + const guestAdapter = guest.mock.results[0]!.value; + expect(guestAdapter.startProxy).toHaveBeenCalledWith(expect.objectContaining({ + tenantId: input.tenantId, + proxyIdentity: "fly:factorize-proxies:machine-1", + bootEpoch: epoch, + controlOrigin: input.controlOrigin, + browserOrigin: `https://${input.app}.fly.dev`, + credential: `fzc_${"b".repeat(43)}`, + })); + }); + + it("revokes bootstrap authority and removes a Machine when startup fails", async () => { + const { control, machines, guest } = fixture(); + guest.mockReturnValue({ startProxy: vi.fn().mockRejectedValue(new Error("private startup channel failed")), issueVmCertificate: vi.fn() }); + const provisioner = createFlyCredentialProxyProvisioner({ control, machines, guest, newBootEpoch: () => epoch }); + await expect(provisioner.start(input)).rejects.toThrow("private startup channel failed"); + expect(control.revokeCredential).toHaveBeenCalledWith(input.tenantId, "fly:factorize-proxies:machine-1"); + expect(machines.destroyMachine).toHaveBeenCalledWith(input.app, machine.id); + }); + + it("rejects mutable images and unsafe control origins before provisioning", async () => { + const { machines, provisioner } = fixture(); + await expect(provisioner.start({ ...input, image: "ghcr.io/example/proxy:latest" })).rejects.toThrow("pinned by sha256 digest"); + await expect(provisioner.start({ ...input, controlOrigin: "http://factorize.example" })).rejects.toThrow("HTTPS origin"); + await expect(provisioner.start({ ...input, app: "invalid/app" })).rejects.toThrow("Fly app name"); + expect(machines.createMachine).not.toHaveBeenCalled(); + }); +}); + +describe("Fly credential proxy guest adapter", () => { + it("sends the bootstrap credential as stdin to the reviewed startup command", async () => { + const calls: unknown[][] = []; + const spawnProcess = ((command: string, args: string[], options: { stdio: string[] }) => { + calls.push([command, args, options]); + const stdoutHandlers: Array<(chunk: Buffer) => void> = []; + const child: any = { pid: undefined, stdin: { end: (value: string) => { calls.push(["stdin", value]); } }, + stdout: { on: (_event: string, callback: (chunk: Buffer) => void) => stdoutHandlers.push(callback) }, + stderr: { on: () => undefined }, on: (event: string, callback: (code: number) => void) => { + if (event === "close") queueMicrotask(() => { stdoutHandlers.forEach(handler => handler(Buffer.from("Credential proxy started\n"))); callback(0); }); + }, kill: () => undefined }; + return child; + }) as any; + const guest = createFlyCredentialProxyGuest({ app: input.app, machine: machine.id, token: "server-token-fixture", spawnProcess }); + await guest.startProxy({ tenantId: input.tenantId, proxyIdentity: "fly:factorize-proxies:machine-1", bootEpoch: epoch, controlOrigin: input.controlOrigin, browserOrigin: `https://${input.app}.fly.dev`, credential: `fzc_${"c".repeat(43)}` }); + expect(calls[0]?.[1]).toContain("/usr/local/bin/factorize-proxy-startup"); + expect(String(calls[1]?.[1])).toContain(`"type":"register-boot"`); + expect(String(calls[1]?.[1])).toContain(`"credential":"fzc_${"c".repeat(43)}"`); + }); + + it("requests a VM CSR signature through the private guest channel and verifies both bindings", async () => { + const calls: unknown[][] = []; + const spawnProcess = ((command: string, args: string[], options: { stdio: string[] }) => { + calls.push([command, args, options]); + const stdoutHandlers: Array<(chunk: Buffer) => void> = []; + const child: any = { pid: undefined, stdin: { end: (value: string) => { calls.push(["stdin", value]); } }, + stdout: { on: (_event: string, callback: (chunk: Buffer) => void) => stdoutHandlers.push(callback) }, + stderr: { on: () => undefined }, on: (event: string, callback: (code: number) => void) => { + if (event === "close") queueMicrotask(() => { stdoutHandlers.forEach(handler => handler(Buffer.from(JSON.stringify({ + bootEpoch: epoch, vmIdentity: "fly:job-1", certificate: "-----BEGIN CERTIFICATE-----cert", trustBundle: "-----BEGIN CERTIFICATE-----ca", + }) + "\n"))); callback(0); }); + }, kill: () => undefined }; + return child; + }) as any; + const guest = createFlyCredentialProxyGuest({ app: input.app, machine: machine.id, token: "server-token-fixture", spawnProcess }); + await expect(guest.issueVmCertificate({ bootEpoch: epoch, vmIdentity: "fly:job-1", csr: `-----BEGIN CERTIFICATE REQUEST-----\n${"A".repeat(120)}\n-----END CERTIFICATE REQUEST-----` })) + .resolves.toEqual({ certificate: "-----BEGIN CERTIFICATE-----cert", trustBundle: "-----BEGIN CERTIFICATE-----ca" }); + expect(calls[0]?.[1]).toContain("/usr/local/bin/factorize-proxy-certify-vm"); + expect(String(calls[1]?.[1])).toContain("\"vmIdentity\":\"fly:job-1\""); + await expect(guest.issueVmCertificate({ bootEpoch: epoch, vmIdentity: "fly:job-1", csr: "bad" })).rejects.toThrow("Invalid VM certificate provisioning request"); + }); +}); diff --git a/packages/api/test/proxy-browser-capability.test.ts b/packages/api/test/proxy-browser-capability.test.ts new file mode 100644 index 0000000..3729c98 --- /dev/null +++ b/packages/api/test/proxy-browser-capability.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { generateKeyPairSync, verify } from "node:crypto"; +import { issueModelAccountCapability } from "../src/proxy-browser-capability"; + +describe("proxy browser capabilities", () => { + it("signs an account/action/origin/boot-bound one-minute EdDSA token", async () => { + const { privateKey, publicKey } = generateKeyPairSync("ed25519"); + const token = await issueModelAccountCapability({ + privateKeyPem: privateKey.export({ type: "pkcs8", format: "pem" }).toString(), + proxyIdentity: "fly:proxy:machine-1", ownerId: "11111111-1111-4111-8111-111111111111", + accountId: "22222222-2222-4222-8222-222222222222", action: "authorize", + appOrigin: "https://factorize.example", generation: "33333333-3333-4333-8333-333333333333", + now: 1_900_000_000, jti: "one-use-fixture-nonce-123456", + }); + const [header, claims, signature] = token.split("."); + expect(JSON.parse(Buffer.from(header!, "base64url").toString())).toEqual({ alg: "EdDSA", typ: "JWT" }); + expect(JSON.parse(Buffer.from(claims!, "base64url").toString())).toMatchObject({ + iss: "factorize", aud: "fly:proxy:machine-1", sub: "11111111-1111-4111-8111-111111111111", + action: "model-account:authorize", accountId: "22222222-2222-4222-8222-222222222222", + origin: "https://factorize.example", generation: "33333333-3333-4333-8333-333333333333", + exp: 1_900_000_060, jti: "one-use-fixture-nonce-123456", + }); + expect(verify(null, Buffer.from(`${header}.${claims}`), publicKey, Buffer.from(signature!, "base64url"))).toBe(true); + }); +}); diff --git a/packages/api/test/proxy-control-repository.test.ts b/packages/api/test/proxy-control-repository.test.ts new file mode 100644 index 0000000..b622e0d --- /dev/null +++ b/packages/api/test/proxy-control-repository.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it, vi } from "vitest"; +import { ProxyControlRepository } from "../src/postgres/proxy-control-repository"; + +function database(query: ReturnType, transactionQuery = query) { + return { pool: { query }, transaction: (work: (client: any) => Promise) => work({ query: transactionQuery }) } as any; +} + +describe("dedicated credential proxy control identity", () => { + it("issues a scoped one-time secret and stores only its digest", async () => { + const query = vi.fn().mockResolvedValue({ rows: [], rowCount: 1 }); + const repository = new ProxyControlRepository(database(query)); + const token = await repository.provisionCredential("11111111-1111-4111-8111-111111111111", "proxy-a", "22222222-2222-4222-8222-222222222222"); + expect(token).toMatch(/^fzc_[A-Za-z0-9_-]{43}$/); + const stored = query.mock.calls[0][1][3] as Buffer; + expect(stored).toBeInstanceOf(Buffer); + expect(stored.toString()).not.toContain(token); + expect(stored.byteLength).toBe(32); + }); + + it("authenticates only a live credential and never accepts a guest run token", async () => { + const query = vi.fn().mockResolvedValue({ rows: [{ tenant_id: "tenant-a", proxy_identity: "proxy-a" }], rowCount: 1 }); + const repository = new ProxyControlRepository(database(query)); + expect(await repository.authenticate("fzt_run_" + "a".repeat(43))).toBeNull(); + expect(query).not.toHaveBeenCalled(); + expect(await repository.authenticate("fzc_" + "a".repeat(43))).toEqual({ tenantId: "tenant-a", proxyIdentity: "proxy-a" }); + }); + + it("registers a fresh boot for its credential identity and rejects revoked, disabled, or mismatched identities", async () => { + const identity = { tenant_id: "tenant-a", proxy_identity: "proxy-a" }; + const credentialQuery = vi.fn().mockResolvedValue({ rows: [identity], rowCount: 1 }); + const stateQuery = vi.fn().mockResolvedValue({ rows: [{ tenant_id: identity.tenant_id }], rowCount: 1 }); + const repository = new ProxyControlRepository(database(vi.fn(), vi.fn())); + const tx = { query: vi.fn().mockImplementation((sql: string) => sql.includes("credential_proxy_control_credentials") ? credentialQuery() : sql.includes("INSERT INTO app.credential_proxy_states") ? stateQuery() : Promise.resolve({ rows: [], rowCount: 1 })) }; + (repository as any).database = { transaction: (work: (client: any) => Promise) => work(tx) }; + const epoch = "22222222-2222-4222-8222-222222222222"; + const registration = await repository.registerBoot("fzc_" + "a".repeat(43), epoch, "https://proxy.example"); + expect(registration).toMatchObject({ tenantId: identity.tenant_id, proxyIdentity: identity.proxy_identity, bootEpoch: epoch }); + expect(registration?.controlCredential).toMatch(/^fzc_[A-Za-z0-9_-]{43}$/); + expect(tx.query.mock.calls.map(call => call[0])).toHaveLength(5); + expect(tx.query.mock.calls[0][0]).toContain("boot_epoch=$2 AND revoked_at IS NULL AND consumed_at IS NULL"); + expect(tx.query.mock.calls[2][0]).toContain("consumed_at=now()"); + expect(tx.query.mock.calls[3][0]).toContain("boot_epoch<>$3"); + expect(tx.query.mock.calls[4][0]).toContain("credential_proxy_browser_pairings"); + credentialQuery.mockResolvedValueOnce({ rows: [], rowCount: 0 }); + expect(await repository.registerBoot("fzc_" + "a".repeat(43), epoch, "https://proxy.example")).toBeNull(); + }); + + it("rejects malformed boot epochs before opening a transaction", async () => { + const transaction = vi.fn(); + const repository = new ProxyControlRepository({ transaction, pool: { query: vi.fn() } } as any); + await expect(repository.registerBoot("fzc_" + "a".repeat(43), "old-boot", "https://proxy.example")).rejects.toThrow("bootEpoch must be a UUID"); + expect(transaction).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/api/test/proxy-control.test.ts b/packages/api/test/proxy-control.test.ts new file mode 100644 index 0000000..3bf692d --- /dev/null +++ b/packages/api/test/proxy-control.test.ts @@ -0,0 +1,177 @@ +import { describe, expect, it, vi } from "vitest"; +import { proxyControlFetch } from "../src/proxy-control"; +import { encrypt } from "../src/crypto"; + +const token = `fzc_${"A".repeat(43)}`; +const bootEpoch = "22222222-2222-4222-8222-222222222222"; +const runId = "33333333-3333-4333-8333-333333333333"; +const runCredential = `fzt_run_${Buffer.from(runId).toString("base64url")}_${"R".repeat(43)}`; + +function envFor({ credentialFound = true, stateSaved = true } = {}) { + const transactionQuery = vi.fn(async (sql: string) => { + if (sql.includes("credential_proxy_control_credentials")) return { rows: credentialFound ? [{ tenant_id: "11111111-1111-4111-8111-111111111111", proxy_identity: "proxy-fixture" }] : [], rowCount: credentialFound ? 1 : 0 }; + if (sql.includes("INSERT INTO app.credential_proxy_states")) return { rows: stateSaved ? [{ tenant_id: "11111111-1111-4111-8111-111111111111" }] : [], rowCount: stateSaved ? 1 : 0 }; + return { rows: [], rowCount: 1 }; + }); + const query = vi.fn(async (sql: string) => sql.includes("proxy_token_digest") + ? { rows: credentialFound ? [{ tenant_id: "11111111-1111-4111-8111-111111111111", proxy_identity: "proxy-fixture", boot_epoch: bootEpoch }] : [], rowCount: credentialFound ? 1 : 0 } + : sql.includes("FROM app.run_credentials") + ? { rows: credentialFound ? [{ tenant_id: "11111111-1111-4111-8111-111111111111", user_id: "owner-fixture", session_version: 1, job_id: "job-fixture", scopes: ["integrations:read"] }] : [], rowCount: credentialFound ? 1 : 0 } + : { rows: [], rowCount: 0 }); + return { DATABASE: { pool: { query }, transaction: (work: (client: any) => Promise) => work({ query: transactionQuery }) }, transactionQuery, query } as any; +} + +function request({ authorization, body = { bootEpoch, browserOrigin: "https://proxy.example" }, method = "POST", path = "/internal/credential-proxy/register-boot" }: { authorization?: string; body?: unknown; method?: string; path?: string } = {}) { + return new Request(`https://factorize.example${path}`, { method, headers: { "Content-Type": "application/json", ...(authorization ? { Authorization: authorization } : {}) }, body: method === "POST" ? JSON.stringify(body) : undefined }); +} + +describe("dedicated proxy boot registration endpoint", () => { + it("binds registration to the proxy identity owned by its distinct control credential", async () => { + const env = envFor(); + const response = await proxyControlFetch(request({ authorization: `Bearer ${token}` }), env); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ registered: true, tenantId: "11111111-1111-4111-8111-111111111111", proxyIdentity: "proxy-fixture", bootEpoch, controlCredential: expect.stringMatching(/^fzc_[A-Za-z0-9_-]{43}$/) }); + expect(env.transactionQuery.mock.calls.map((call: any[]) => call[0])).toHaveLength(5); + expect(env.transactionQuery.mock.calls[0][0]).toContain("boot_epoch=$2 AND revoked_at IS NULL AND consumed_at IS NULL"); + expect(env.transactionQuery.mock.calls[2][0]).toContain("consumed_at=now()"); + expect(env.transactionQuery.mock.calls[4][0]).toContain("credential_proxy_browser_pairings"); + }); + + it("rejects guest job tokens before database access", async () => { + const env = envFor(); + const response = await proxyControlFetch(request({ authorization: `Bearer fzt_run_${"B".repeat(32)}` }), env); + expect(response.status).toBe(401); + expect(env.transactionQuery).not.toHaveBeenCalled(); + }); + + it("rejects revoked credentials, disabled proxy identities and tenant or proxy substitution", async () => { + for (const env of [envFor({ credentialFound: false }), envFor({ stateSaved: false })]) { + const response = await proxyControlFetch(request({ authorization: `Bearer ${token}` }), env); + expect(response.status).toBe(403); + } + }); + + it("validates the endpoint, method and exact boot payload", async () => { + const env = envFor(); + expect((await proxyControlFetch(new Request("https://factorize.example/internal/credential-proxy/register-boot", { method: "GET" }), env)).status).toBe(405); + expect((await proxyControlFetch(request({ authorization: `Bearer ${token}`, body: { bootEpoch, tenantId: "attacker-tenant" } }), env)).status).toBe(400); + expect(env.transactionQuery).not.toHaveBeenCalled(); + }); + + it("bounds registration payloads before parsing", async () => { + const env = envFor(); + const response = await proxyControlFetch(request({ authorization: `Bearer ${token}`, body: { bootEpoch, padding: "x".repeat(2_000) } }), env); + expect(response.status).toBe(413); + expect(env.transactionQuery).not.toHaveBeenCalled(); + }); + + it("issues a short-lived enrollment only through the registered proxy identity", async () => { + const env = envFor(); + env.transactionQuery.mockImplementation(async (sql: string) => sql.includes("SELECT g.grant_revision") + ? { rows: [{ grant_revision: 3 }], rowCount: 1 } + : { rows: [], rowCount: 1 }); + const response = await proxyControlFetch(request({ + authorization: `Bearer ${token}`, + path: "/internal/credential-proxy/enroll", + body: { runId, vmIdentity: "fly:vm-123", runCredential }, + }), env); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ grantRevision: 3, enrollmentId: expect.any(String), token: expect.any(String) }); + expect(env.query).toHaveBeenCalled(); + expect(env.query.mock.calls.some((call: any[]) => call[0].includes("FROM app.run_credentials"))).toBe(true); + expect(env.transactionQuery.mock.calls[0][0]).toContain("JOIN app.credential_proxy_states"); + }); + + it("refuses enrollment when the run credential is absent, invalid, cross-tenant, mismatched, or lacks integration scope", async () => { + for (const [body, expectedStatus] of [ + [{ runId, vmIdentity: "fly:vm-123" }, 400], + [{ runId, vmIdentity: "fly:vm-123", runCredential: "fzt_run_invalid" }, 403], + [{ runId: "44444444-4444-4444-8444-444444444444", vmIdentity: "fly:vm-123", runCredential }, 403], + ] as const) { + const env = envFor(); + expect((await proxyControlFetch(request({ authorization: `Bearer ${token}`, path: "/internal/credential-proxy/enroll", body }), env)).status).toBe(expectedStatus); + expect(env.transactionQuery).not.toHaveBeenCalled(); + } + const crossTenant = envFor(); + crossTenant.query.mockImplementation(async (sql: string) => sql.includes("proxy_token_digest") + ? { rows: [{ tenant_id: "other-tenant", proxy_identity: "proxy-fixture", boot_epoch: bootEpoch }], rowCount: 1 } + : { rows: [], rowCount: 0 }); + expect((await proxyControlFetch(request({ authorization: `Bearer ${token}`, path: "/internal/credential-proxy/enroll", body: { runId, vmIdentity: "fly:vm-123", runCredential } }), crossTenant)).status).toBe(403); + const noScope = envFor(); + noScope.query.mockImplementation(async (sql: string) => sql.includes("proxy_token_digest") + ? { rows: [{ tenant_id: "11111111-1111-4111-8111-111111111111", proxy_identity: "proxy-fixture", boot_epoch: bootEpoch }], rowCount: 1 } + : sql.includes("FROM app.run_credentials") + ? { rows: [{ tenant_id: "11111111-1111-4111-8111-111111111111", user_id: "owner-fixture", session_version: 1, job_id: "job-fixture", scopes: ["runs:read"] }], rowCount: 1 } + : { rows: [], rowCount: 0 }); + expect((await proxyControlFetch(request({ authorization: `Bearer ${token}`, path: "/internal/credential-proxy/enroll", body: { runId, vmIdentity: "fly:vm-123", runCredential } }), noScope)).status).toBe(403); + expect(noScope.transactionQuery).not.toHaveBeenCalled(); + }); + + it("consumes enrollment against the authenticated proxy boot and rejects untrusted extra bindings", async () => { + const env = envFor(); + env.transactionQuery.mockImplementation(async (sql: string) => sql.includes("UPDATE app.credential_run_enrollments") + ? { rows: [{ id: "44444444-4444-4444-8444-444444444444", grant_revision: 3 }], rowCount: 1 } + : { rows: [], rowCount: 1 }); + const accepted = await proxyControlFetch(request({ + authorization: `Bearer ${token}`, + path: "/internal/credential-proxy/consume-enrollment", + body: { runId: "33333333-3333-4333-8333-333333333333", vmIdentity: "fly:vm-123", enrollmentToken: "e".repeat(43) }, + }), env); + expect(accepted.status).toBe(200); + expect(await accepted.json()).toMatchObject({ consumed: true, binding: { bootEpoch, proxyIdentity: "proxy-fixture", vmIdentity: "fly:vm-123" } }); + const rejected = await proxyControlFetch(request({ authorization: `Bearer ${token}`, path: "/internal/credential-proxy/enroll", body: { runId: "33333333-3333-4333-8333-333333333333", vmIdentity: "fly:vm-123", tenantId: "other" } }), env); + expect(rejected.status).toBe(400); + }); + + it("returns only the current run's selected user-encrypted bearer and model records to its registered proxy", async () => { + const integrationId = "55555555-5555-4555-8555-555555555555"; + const modelAccountId = "77777777-7777-4777-8777-777777777777"; + const key = Buffer.alloc(32, 7).toString("base64"); + const env = envFor(); + env.CREDENTIAL_ENCRYPTION_KEY = key; + env.query.mockImplementation(async (sql: string, values: unknown[] = []) => { + if (sql.includes("proxy_token_digest")) return { rows: [{ tenant_id: "11111111-1111-4111-8111-111111111111", proxy_identity: "proxy-fixture", boot_epoch: bootEpoch }], rowCount: 1 }; + if (sql.includes("FROM app.runs r")) return { rows: [{ execution_config: { portableIntegrationIds: [integrationId], modelAccess: "portable", modelAccountId }, grant_revision: 3 }], rowCount: 1 }; + if (sql.includes("FROM app.integration_records")) return values[1] === "bearer" ? { rows: [ + { id: integrationId, kind: "bearer", name: "selected", origin: "https://api.example", description: null, revision: 4, encrypted_payload: await encrypt("user-ciphertext", key) }, + { id: "66666666-6666-4666-8666-666666666666", kind: "bearer", name: "not-selected", origin: "https://other.example", description: null, revision: 5, encrypted_payload: await encrypt("other-ciphertext", key) }, + ], rowCount: 2 } : { rows: [ + { id: modelAccountId, kind: "model", name: "selected-model", origin: null, description: "fixture account", revision: 6, encrypted_payload: await encrypt("model-user-ciphertext", key) }, + { id: "88888888-8888-4888-8888-888888888888", kind: "model", name: "other-model", origin: null, description: null, revision: 7, encrypted_payload: await encrypt("other-model-ciphertext", key) }, + ], rowCount: 2 }; + return { rows: [], rowCount: 0 }; + }); + const response = await proxyControlFetch(request({ + authorization: `Bearer ${token}`, + path: "/internal/credential-proxy/integration-snapshot", + body: { runId, grantRevision: 3 }, + }), env); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ ownerId: "11111111-1111-4111-8111-111111111111", records: [ + { id: integrationId, kind: "bearer", name: "selected", origin: "https://api.example", description: null, revision: 4, encryptedPayload: "user-ciphertext" }, + ], modelAccount: { id: modelAccountId, kind: "model", name: "selected-model", origin: null, description: "fixture account", revision: 6, encryptedPayload: "model-user-ciphertext" } }); + expect(env.query.mock.calls.map((call: any[]) => call[0])).toEqual(expect.arrayContaining([expect.stringContaining("grant_revision=$5"), expect.stringContaining("FROM app.integration_records")])); + }); + + it("returns durable revoked runs only for the authenticated current proxy boot", async () => { + const env = envFor(); + env.query.mockImplementation(async (sql: string) => sql.includes("proxy_token_digest") + ? { rows: [{ tenant_id: "11111111-1111-4111-8111-111111111111", proxy_identity: "proxy-fixture", boot_epoch: bootEpoch }], rowCount: 1 } + : { rows: [{ run_id: runId }], rowCount: 1 }); + const response = await proxyControlFetch(request({ + authorization: `Bearer ${token}`, + path: "/internal/credential-proxy/revocations", + body: { bootEpoch }, + }), env); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ revokedRunIds: [runId] }); + expect(env.query.mock.calls[1][0]).toContain("revoked_at IS NOT NULL"); + expect(env.query.mock.calls[1][1]).toEqual(["11111111-1111-4111-8111-111111111111", "proxy-fixture", bootEpoch]); + const stale = await proxyControlFetch(request({ + authorization: `Bearer ${token}`, + path: "/internal/credential-proxy/revocations", + body: { bootEpoch: "44444444-4444-4444-8444-444444444444" }, + }), env); + expect(stale.status).toBe(400); + }); +}); diff --git a/packages/api/test/proxy-provisioner.test.ts b/packages/api/test/proxy-provisioner.test.ts new file mode 100644 index 0000000..bc6e9ec --- /dev/null +++ b/packages/api/test/proxy-provisioner.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it, vi } from "vitest"; +import { provisionCredentialProxy } from "../src/proxy-provisioner"; + +const input = { + tenantId: "11111111-1111-4111-8111-111111111111", + proxyIdentity: "fly-app/proxy-machine", + bootEpoch: "22222222-2222-4222-8222-222222222222", + controlOrigin: "https://factorize.example/", + browserOrigin: "https://proxy.example", +}; + +describe("credential proxy bootstrap provisioning", () => { + it("issues an epoch-bound credential only to the provisioner's private start channel", async () => { + const control = { + provisionCredential: vi.fn().mockResolvedValue("fzc_one-use-secret"), + revokeCredential: vi.fn().mockResolvedValue(true), + }; + const startProxy = vi.fn().mockResolvedValue(undefined); + + await expect(provisionCredentialProxy(input, { control, startProxy })).resolves.toEqual({ + proxyIdentity: input.proxyIdentity, + bootEpoch: input.bootEpoch, + }); + + expect(control.provisionCredential).toHaveBeenCalledWith(input.tenantId, input.proxyIdentity, input.bootEpoch); + expect(startProxy).toHaveBeenCalledWith({ ...input, controlOrigin: "https://factorize.example", credential: "fzc_one-use-secret" }); + expect(control.revokeCredential).not.toHaveBeenCalled(); + }); + + it("revokes the one-use credential if proxy startup fails", async () => { + const control = { + provisionCredential: vi.fn().mockResolvedValue("fzc_one-use-secret"), + revokeCredential: vi.fn().mockResolvedValue(true), + }; + const failure = new Error("startup failed"); + await expect(provisionCredentialProxy(input, { control, startProxy: vi.fn().mockRejectedValue(failure) })).rejects.toBe(failure); + expect(control.revokeCredential).toHaveBeenCalledWith(input.tenantId, input.proxyIdentity); + }); + + it("rejects non-HTTPS or non-origin control URLs before issuing a credential", async () => { + const control = { provisionCredential: vi.fn(), revokeCredential: vi.fn() }; + await expect(provisionCredentialProxy({ ...input, controlOrigin: "http://factorize.example" }, { control, startProxy: vi.fn() })).rejects.toThrow("HTTPS origin"); + await expect(provisionCredentialProxy({ ...input, controlOrigin: "https://factorize.example/path" }, { control, startProxy: vi.fn() })).rejects.toThrow("HTTPS origin"); + await expect(provisionCredentialProxy({ ...input, browserOrigin: "http://proxy.example" }, { control, startProxy: vi.fn() })).rejects.toThrow("HTTPS origin"); + expect(control.provisionCredential).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/app/src/features/settings/integrations.tsx b/packages/app/src/features/settings/integrations.tsx index dfa08fb..f8c72b9 100644 --- a/packages/app/src/features/settings/integrations.tsx +++ b/packages/app/src/features/settings/integrations.tsx @@ -10,6 +10,7 @@ import { IntegrationForm } from "./integration-form"; import { messageOf } from "../auth/session"; import { UnlockKeySetup } from "./unlock-key-setup"; import { BearerIntegrations } from "./bearer-integrations"; +import { ModelAccounts } from "./model-accounts"; const linkClass = "inline-flex min-h-10 items-center rounded-lg border border-stone-200 px-3 py-2 text-sm font-semibold hover:bg-stone-100 dark:border-slate-700 dark:hover:bg-slate-800"; @@ -53,7 +54,7 @@ export function Integrations() { {status.data?.cloudflareTail.installations.map(c =>

Cloudflare Tail · {c.name}

Referenced by {c.referencedJobCount} {c.referencedJobCount === 1 ? "job" : "jobs"}

Connected{buttons("cloudflareTail", c.integrationId)}
{manage(`tail:${c.integrationId}`, )}
)} } - +

Available integrations

Connect a provider to add it to your workspace. Connected providers remain available for additional connections.

setOpenSetup(open ? "linear-new" : null)}>{status.data?.linear ? "Add another Linear account" : "Connect Linear"} setOpenSetup(open ? "clickup-new" : null)}>{status.data?.clickup ? "Add another ClickUp workspace" : "Connect ClickUp"} diff --git a/packages/app/src/features/settings/model-accounts.tsx b/packages/app/src/features/settings/model-accounts.tsx new file mode 100644 index 0000000..3283878 --- /dev/null +++ b/packages/app/src/features/settings/model-accounts.tsx @@ -0,0 +1,146 @@ +import { useEffect, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { api } from "factorize-api-client"; +import { Button, Card, Input, Label } from "../../shared/ui"; +import { encryptModelAccountCacheRecord, verifyUnlockKey } from "../../lib/unlock-crypto"; +import { getUnlockVerification } from "../../lib/unlock-key"; +import { messageOf } from "../auth/session"; + +type DeviceState = { state: string; verificationUrl?: string; userCode?: string }; +type ModelAccount = { id: string; name: string; description: string | null; revision: number }; +const accountsQuery = { queryKey: ["settings", "model-accounts"], queryFn: async ({ signal }: { signal: AbortSignal }) => { + const { data, error } = await api.GET("/api/v1/model-accounts", { signal }); + if (!data || error) throw new Error(messageOf(error)); + return data; +} }; +const verificationQuery = { queryKey: ["unlock-key-verification"], queryFn: ({ signal }: { signal: AbortSignal }) => getUnlockVerification(signal), retry: false as const }; + +async function proxyAction(accountId: string, action: "authorize" | "status" | "cancel"): Promise { + const capability = await api.POST("/api/v1/model-accounts/{accountId}/browser-capability", { + params: { path: { accountId } }, body: { action }, + }); + if (!capability.data || capability.error) throw new Error(messageOf(capability.error)); + const response = await fetch(capability.data.endpoint, { + method: "POST", mode: "cors", credentials: "omit", cache: "no-store", + headers: { Authorization: `Bearer ${capability.data.capability}`, "Content-Type": "application/json" }, + body: "{}", + }); + let result: DeviceState; + try { result = await response.json() as DeviceState; } + catch { throw new Error("The credential proxy returned an invalid response."); } + if (!response.ok) { + const messages: Record = { 423: "Unlock the credential proxy before connecting a model account.", 503: "The credential proxy is unavailable. Try again shortly." }; + throw new Error(messages[response.status] ?? "The credential proxy could not complete this request."); + } + return result; +} + +export function ModelAccounts() { + const cache = useQueryClient(), accounts = useQuery(accountsQuery), verification = useQuery(verificationQuery); + const [active, setActive] = useState(null); + const [states, setStates] = useState>({}); + const [error, setError] = useState(""); + const [name, setName] = useState(""), [description, setDescription] = useState(""), [key, setKey] = useState(""); + + const create = useMutation({ retry: false, mutationFn: async () => { + const cleanName = name.trim(), cleanDescription = description.trim(); + if (!cleanName || cleanName.length > 120) throw new Error("Enter a name up to 120 characters."); + if (cleanDescription.length > 500) throw new Error("Description must be at most 500 characters."); + const setup = verification.data?.verification; + if (!setup || !verification.data) throw new Error("Set up an unlock key before adding a model account."); + if (!accounts.data) throw new Error("Model accounts are still loading. Try again shortly."); + try { await verifyUnlockKey(key, verification.data.ownerId, setup.generationId, JSON.parse(setup.encryptedVerification)); } + catch { throw new Error("Unlock key could not be verified for this account and generation."); } + const id = crypto.randomUUID(), expectedRevision = accounts.data.revision; + const encryptedPayload = await encryptModelAccountCacheRecord(new TextEncoder().encode("{}"), key, { + ownerId: verification.data.ownerId, recordId: id, recordType: "model", destination: "factorize", + keyGeneration: setup.generationId, revision: expectedRevision + 1, + }); + const { data, error } = await api.POST("/api/v1/model-accounts", { body: { + id, expectedRevision, idempotencyKey: crypto.randomUUID(), name: cleanName, + description: cleanDescription || null, encryptedPayload, + } }); + if (!data || error) throw new Error(messageOf(error)); + return data; + }, onSuccess: async () => { + setName(""); setDescription(""); setKey(""); setError(""); + await cache.invalidateQueries({ queryKey: accountsQuery.queryKey }); + }, onError: async reason => { + setKey(""); setError(reason.message); + if (reason.message.includes("changed") || reason.message.includes("revision")) await cache.invalidateQueries({ queryKey: accountsQuery.queryKey }); + } }); + + const remove = useMutation({ retry: false, mutationFn: async (account: ModelAccount) => { + if (!accounts.data) throw new Error("Model accounts are still loading. Try again shortly."); + const { data, error } = await api.DELETE("/api/v1/model-accounts/{accountId}", { + params: { path: { accountId: account.id } }, + body: { expectedRevision: accounts.data.revision, idempotencyKey: crypto.randomUUID() }, + }); + if (!data || error) throw new Error(messageOf(error)); + return data; + }, onSuccess: async () => { + setError(""); await cache.invalidateQueries({ queryKey: accountsQuery.queryKey }); + }, onError: async reason => { + setError(reason.message); await cache.invalidateQueries({ queryKey: accountsQuery.queryKey }); + } }); + + useEffect(() => { + if (!active) return; + let stopped = false; + const poll = async () => { + try { + const next = await proxyAction(active, "status"); + if (stopped) return; + setStates(current => ({ ...current, [active]: next })); + if (next.state !== "pending") setActive(null); + } catch (reason) { + if (!stopped) setError(reason instanceof Error ? reason.message : "Could not check model account authorization."); + } + }; + const timer = window.setInterval(poll, 2000); + return () => { stopped = true; window.clearInterval(timer); }; + }, [active]); + + const run = async (account: ModelAccount, action: "authorize" | "cancel") => { + setError(""); + try { + const next = await proxyAction(account.id, action); + setStates(current => ({ ...current, [account.id]: next })); + setActive(action === "authorize" && next.state === "pending" ? account.id : null); + } catch (reason) { + setError(reason instanceof Error ? reason.message : "Could not connect the model account."); + } + }; + + return
+

ChatGPT model accounts

Connect each account through Codex device authorization. Credentials stay in the credential proxy.

+ {accounts.isPending &&

Loading model accounts…

} + {accounts.error &&

{accounts.error.message}

} + {error &&

{error}

} + {!accounts.isPending && accounts.data?.accounts.length === 0 &&

No model accounts are configured yet.

} + {accounts.data?.accounts.map(account => { + const state = states[account.id] ?? { state: "idle" }; + return +

{account.name}

{account.description &&

{account.description}

} + {state.state === "pending" &&

Waiting for device authorization.

{state.userCode &&

Code: {state.userCode}

}{state.verificationUrl && OpenAI device authorization}
} + {state.state === "authorized" &&

ChatGPT account connected.

} + {state.state === "authorized-unsaved" &&

ChatGPT account connected, but its encrypted token cache could not be saved. Keep the proxy running and retry after checking its connection.

} + {state.state === "reauthentication-required" &&

Sign in again to reconnect this account.

} + {state.state === "failed" &&

Device authorization failed. You can try again.

} + {state.state === "canceled" &&

Device authorization canceled.

} +
+ {state.state === "pending" ? : } + +
; + })} +

Add a model account

+ {!verification.data?.verification && verification.data &&

Set up your unlock key above before adding a model account.

} +
{ event.preventDefault(); setError(""); create.mutate(); }}> + + + + +
+
+
; +} diff --git a/packages/app/src/lib/unlock-crypto.ts b/packages/app/src/lib/unlock-crypto.ts index 900aabc..7648d97 100644 --- a/packages/app/src/lib/unlock-crypto.ts +++ b/packages/app/src/lib/unlock-crypto.ts @@ -91,6 +91,22 @@ export async function encryptRecord(plaintext: Uint8Array, key: string, binding: return { version: VERSION, algorithm: "A256GCM", nonce: encodeBase64Url(nonce), ciphertext: encodeBase64Url(new Uint8Array(ciphertext)) }; } +/** Encrypt an initial model-account cache with the same key-generation-bound AAD used by the proxy. */ +export async function encryptModelAccountCacheRecord(plaintext: Uint8Array, key: string, binding: RecordBinding & { keyGeneration: string }): Promise { + if (!(plaintext instanceof Uint8Array) || !binding || typeof binding.ownerId !== "string" || !binding.ownerId + || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu.test(binding.recordId) + || typeof binding.keyGeneration !== "string" || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu.test(binding.keyGeneration) + || !Number.isSafeInteger(binding.revision) || binding.revision < 1) { + throw new TypeError("Malformed model-account encryption binding"); + } + const aad = encoder.encode(JSON.stringify(["factorize-record-v1", binding.ownerId, binding.recordId, "model", "factorize", binding.keyGeneration, binding.revision])); + const nonce = crypto.getRandomValues(new Uint8Array(NONCE_BYTES)); + const aesKey = await deriveAesKey(key, ["encrypt"]); + const ciphertext = await crypto.subtle.encrypt({ name: "AES-GCM", iv: toArrayBuffer(nonce), additionalData: toArrayBuffer(aad), tagLength: 128 }, aesKey, toArrayBuffer(plaintext)); + const encode = (bytes: Uint8Array) => btoa(Array.from(bytes, byte => String.fromCharCode(byte)).join("")); + return `${encode(nonce)}.${encode(new Uint8Array(ciphertext))}`; +} + /** Decrypt a supported envelope; malformed metadata, bindings and authentication fail closed. */ export async function decryptRecord(envelope: unknown, key: string, binding: RecordBinding): Promise { if (!envelope || typeof envelope !== "object" || Array.isArray(envelope)) throw new TypeError("Malformed encrypted envelope"); diff --git a/packages/app/test/settings.browser.test.mjs b/packages/app/test/settings.browser.test.mjs index bc3368e..0d5ca6f 100644 --- a/packages/app/test/settings.browser.test.mjs +++ b/packages/app/test/settings.browser.test.mjs @@ -18,6 +18,103 @@ test("integration setup tests credentials, submits typed fields, clears secrets await form.getByLabel("exe.dev account token").fill("private-input"); await form.getByRole("button", { name: "Test credentials" }).click(); await form.getByText("✓ Credentials verified.", { exact: true }).waitFor(); await form.getByLabel("VM tags").selectOption(["dev"]); await form.getByRole("button", { name: "Save integration", exact: true }).click(); await form.getByText("Integration saved", { exact: true }).waitFor(); assert.deepEqual(body.tags, ["dev"]); assert.equal(body.agentKind, "codex"); assert.equal(await form.getByLabel("exe.dev account token").inputValue(), ""); assert.equal(await page.evaluate(() => JSON.stringify(localStorage).includes("private-input")), false); await page.getByText("dev", { exact: true }).waitFor(); await context.close(); }); +test("model account settings issue short-lived capabilities and display the bounded device challenge", async () => { + const { context, page } = await setup(); + const accountId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", proxyOrigin = "https://proxy.example"; + await page.route("**/api/v1/providers/github/installations", route => route.fulfill({ json: [] })); + await page.route("**/api/v1/integrations", route => route.fulfill({ json: { linear: null, clickup: null, exe: null, exeConnections: [], ampConnections: [], cloudflareTail: { count: 0, installations: [] } } })); + await page.route("**/api/v1/model-accounts", async route => { + if (route.request().method() === "GET") return route.fulfill({ json: { revision: 1, accounts: [{ id: accountId, name: "Work account", description: null, revision: 1 }] } }); + const action = route.request().postDataJSON().action; + return route.fulfill({ json: { endpoint: `${proxyOrigin}/model-accounts/${accountId}/${action}`, capability: `one-use-${action}` } }); + }); + await page.route("**/api/v1/model-accounts/*/browser-capability", async route => { + const action = route.request().postDataJSON().action; + return route.fulfill({ json: { endpoint: `${proxyOrigin}/model-accounts/${accountId}/${action}`, capability: `one-use-${action}` } }); + }); + const proxyCalls = []; + await page.route(`${proxyOrigin}/model-accounts/**`, async route => { + const request = route.request(); + if (request.method() === "OPTIONS") return route.fulfill({ status: 204, headers: { "Access-Control-Allow-Origin": origin, "Access-Control-Allow-Methods": "POST", "Access-Control-Allow-Headers": "Authorization, Content-Type" } }); + proxyCalls.push({ path: new URL(request.url()).pathname, authorization: request.headers().authorization, body: request.postData() }); + const response = request.url().endsWith("/authorize") ? { state: "pending", verificationUrl: "https://auth.openai.com/codex/device", userCode: "ABCD-1234" } : { state: "canceled" }; + return route.fulfill({ json: response, headers: { "Access-Control-Allow-Origin": origin, "Cache-Control": "no-store" } }); + }); + await page.goto(origin + "/settings/integrations"); + const models = page.getByRole("region", { name: "ChatGPT model accounts" }); + await models.getByRole("button", { name: "Connect ChatGPT" }).click(); + await models.getByText("ABCD-1234", { exact: false }).waitFor(); + await models.getByRole("link", { name: "OpenAI device authorization" }).waitFor(); + await models.getByRole("button", { name: "Cancel authorization" }).click(); + await models.getByText("Device authorization canceled.", { exact: true }).waitFor(); + assert.deepEqual(proxyCalls.map(call => call.authorization), ["Bearer one-use-authorize", "Bearer one-use-cancel"]); + assert.deepEqual(proxyCalls.map(call => call.body), ["{}", "{}"]); + await context.close(); +}); +test("model account create/delete encrypts initial state and uses the current collection revision", async () => { + const { context, page } = await setup(); + await page.route("**/api/v1/providers/github/installations", route => route.fulfill({ json: [] })); + await page.route("**/api/v1/integrations", route => route.fulfill({ json: { linear: null, clickup: null, exe: null, exeConnections: [], ampConnections: [], cloudflareTail: { count: 0, installations: [] } } })); + let verification = null, revision = 0, account = null, createBody = null, deleteBodies = [], staleOnce = true; + await page.route("**/api/v1/unlock-key/verification", async route => { + if (route.request().method() === "GET") return route.fulfill({ json: { ownerId: "owner", verification } }); + const body = route.request().postDataJSON(); verification = { generationId: body.generationId, encryptedVerification: body.encryptedVerification }; + return route.fulfill({ json: { generationId: body.generationId } }); + }); + await page.route("**/api/v1/model-accounts", async route => { + if (route.request().method() === "GET") return route.fulfill({ json: { revision, accounts: account ? [account] : [] } }); + createBody = route.request().postDataJSON(); + assert.deepEqual(Object.keys(createBody).sort(), ["description", "encryptedPayload", "expectedRevision", "id", "idempotencyKey", "name"]); + assert.equal(createBody.expectedRevision, revision); + assert.equal(createBody.name, "Work account"); + assert.equal(createBody.encryptedPayload.includes("{}"), false); + assert.equal(createBody.encryptedPayload.includes("fzk1."), false); + assert.match(createBody.encryptedPayload, /^[A-Za-z0-9+/]+=*\.[A-Za-z0-9+/]+=*$/u); + revision++; account = { id: createBody.id, name: createBody.name, description: createBody.description, revision }; + return route.fulfill({ status: 201, json: { revision, duplicate: false } }); + }); + await page.route("**/api/v1/model-accounts/*", async route => { + assert.equal(route.request().method(), "DELETE"); + const body = route.request().postDataJSON(); deleteBodies.push(body); + assert.deepEqual(Object.keys(body).sort(), ["expectedRevision", "idempotencyKey"]); + if (staleOnce) { + staleOnce = false; revision++; + return route.fulfill({ status: 409, json: { error: { code: "stale_revision", message: "The model account collection changed." } } }); + } + assert.equal(body.expectedRevision, revision); + revision++; account = null; + return route.fulfill({ json: { id: createBody.id, deleted: true, revision, duplicate: false } }); + }); + + await page.goto(origin + "/settings/integrations"); + await page.getByRole("button", { name: "Generate unlock key" }).click(); + const unlockKey = await page.getByLabel("New unlock key").inputValue(); + await page.getByRole("checkbox", { name: "I saved this key somewhere safe." }).check(); + await page.getByRole("button", { name: "I saved my key" }).click(); + await page.getByText("Unlock key verification is set up.", { exact: false }).waitFor(); + + const models = page.getByRole("region", { name: "ChatGPT model accounts" }); + await models.getByLabel("Name").fill("Work account"); + await models.getByLabel("Description (optional)").fill("Personal account"); + await models.getByLabel("Unlock key").fill(unlockKey); + await models.getByRole("button", { name: "Add model account" }).click(); + await models.getByText("Work account", { exact: true }).waitFor(); + assert.equal(createBody.expectedRevision, 0); + assert.equal(createBody.description, "Personal account"); + + page.once("dialog", dialog => dialog.accept()); + const refreshAfterConflict = page.waitForResponse(response => response.url().includes("/api/v1/model-accounts") && response.request().method() === "GET" && response.status() === 200); + await models.getByRole("button", { name: "Delete account" }).click(); + await models.getByRole("alert").filter({ hasText: "collection changed" }).waitFor(); + await refreshAfterConflict; + page.once("dialog", dialog => dialog.accept()); + await models.getByRole("button", { name: "Delete account" }).click(); + await models.getByText("No model accounts are configured yet.", { exact: true }).waitFor(); + assert.equal(deleteBodies.length, 2); + assert.equal(deleteBodies[0].expectedRevision, 1); + assert.equal(deleteBodies[1].expectedRevision, 2); + await context.close(); +}); test("unlock-key setup requires acknowledgement, stores ciphertext only, verifies entered keys and survives refresh without a saved key", async () => { const { context, page } = await setup(); await page.route("**/api/v1/providers/github/installations", route => route.fulfill({ json: [] })); diff --git a/packages/app/test/unlock-crypto.test.mjs b/packages/app/test/unlock-crypto.test.mjs index 2ac5469..ab04eb4 100644 --- a/packages/app/test/unlock-crypto.test.mjs +++ b/packages/app/test/unlock-crypto.test.mjs @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { createUnlockVerification, decryptRecord, encryptAfterUnlockVerification, encryptRecord, generateUnlockKey, verifyUnlockKey } from "../src/lib/unlock-crypto.ts"; +import { createUnlockVerification, decryptRecord, encryptAfterUnlockVerification, encryptModelAccountCacheRecord, encryptRecord, generateUnlockKey, verifyUnlockKey } from "../src/lib/unlock-crypto.ts"; const key = "fzk1.AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8"; const binding = { ownerId: "owner-vector", recordId: "record-vector", recordType: "integration", destination: "https://api.example.test", revision: 7 }; @@ -25,6 +25,25 @@ test("fresh nonces make repeated encryptions differ", async () => { assert.notEqual(first.ciphertext, second.ciphertext); }); +test("initial model-account cache uses the proxy key-generation and record-revision binding", async () => { + const unlockKey = generateUnlockKey(), modelBinding = { + ownerId: "owner-a", recordId: "11111111-1111-4111-8111-111111111111", recordType: "model", + destination: "factorize", keyGeneration: "22222222-2222-4222-8222-222222222222", revision: 4, + }; + const encrypted = await encryptModelAccountCacheRecord(encoder.encode("{}"), unlockKey, modelBinding); + const [nonce, ciphertext] = encrypted.split(".").map(value => Uint8Array.from(atob(value), char => char.charCodeAt(0))); + const raw = Uint8Array.from(atob(unlockKey.slice(5).replaceAll("-", "+").replaceAll("_", "/") + "="), char => char.charCodeAt(0)); + try { + const material = await crypto.subtle.importKey("raw", raw, "HKDF", false, ["deriveKey"]); + const aes = await crypto.subtle.deriveKey({ name: "HKDF", hash: "SHA-256", salt: encoder.encode("factorize.unlock-key.v1"), info: encoder.encode("factorize.record.aes-256-gcm.v1") }, material, { name: "AES-GCM", length: 256 }, false, ["decrypt"]); + const aad = encoder.encode(JSON.stringify(["factorize-record-v1", modelBinding.ownerId, modelBinding.recordId, "model", "factorize", modelBinding.keyGeneration, modelBinding.revision])); + const plain = await crypto.subtle.decrypt({ name: "AES-GCM", iv: nonce, additionalData: aad, tagLength: 128 }, aes, ciphertext); + assert.equal(new TextDecoder().decode(plain), "{}"); + const staleAad = encoder.encode(JSON.stringify(["factorize-record-v1", modelBinding.ownerId, modelBinding.recordId, "model", "factorize", modelBinding.keyGeneration, modelBinding.revision + 1])); + await assert.rejects(crypto.subtle.decrypt({ name: "AES-GCM", iv: nonce, additionalData: staleAad, tagLength: 128 }, aes, ciphertext)); + } finally { raw.fill(0); } +}); + test("wrong key and every authenticated binding change fail closed", async () => { await assert.rejects(decryptRecord(vector, "fzk1.AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE", binding)); for (const field of Object.keys(binding)) { diff --git a/packages/credential-proxy/src/bearer-forwarder.mjs b/packages/credential-proxy/src/bearer-forwarder.mjs new file mode 100644 index 0000000..37ea185 --- /dev/null +++ b/packages/credential-proxy/src/bearer-forwarder.mjs @@ -0,0 +1,178 @@ +import https from "node:https"; +import { Transform } from "node:stream"; +import { pipeline } from "node:stream/promises"; +import { createOriginConnector } from "./destination-policy.mjs"; + +export const DEFAULT_FORWARD_LIMITS = Object.freeze({ + connectMs: 10_000, + headersMs: 60_000, + idleMs: 120_000, + requestMs: 15 * 60_000, + requestBytes: 32 * 1024 * 1024, + responseBytes: 64 * 1024 * 1024, + headerBytes: 32 * 1024, +}); + +const HOP_BY_HOP = new Set([ + "connection", "keep-alive", "proxy-authenticate", "proxy-authorization", + "te", "trailer", "transfer-encoding", "upgrade", "host", "authorization", + "content-length", "expect", +]); + +function safeHeaders(input, credential, maxBytes) { + const source = input instanceof Headers ? [...input.entries()] : Object.entries(input ?? {}); + const connectionTokens = new Set(); + for (const [name, value] of source) { + if (name.toLowerCase() === "connection") { + for (const token of String(value).split(",")) connectionTokens.add(token.trim().toLowerCase()); + } + } + const headers = Object.create(null); + let size = 0; + for (const [name, value] of source) { + const lower = name.toLowerCase(); + if (HOP_BY_HOP.has(lower) || lower.startsWith("x-forwarded-") || lower === "forwarded" || connectionTokens.has(lower)) continue; + const text = Array.isArray(value) ? value.join(", ") : String(value); + size += Buffer.byteLength(name) + Buffer.byteLength(text) + 4; + if (size > maxBytes) throw new RangeError("request headers exceed configured limit"); + headers[lower] = text; + } + headers.authorization = `Bearer ${credential}`; + return headers; +} + +function byteLimit(limit, label, onLimit) { + let bytes = 0; + return new Transform({ + transform(chunk, _encoding, callback) { + bytes += chunk.length; + if (bytes > limit) { + onLimit?.(); + callback(new RangeError(`${label} exceeds configured limit`)); + } else callback(null, chunk); + }, + }); +} + +function sendHttpsRequest({ socket, url, method, headers, body, limits, signal }) { + return new Promise((resolve, reject) => { + let settled = false; + let responseTimer; + let idleTimer; + let responseStream; + const agent = new https.Agent({ keepAlive: false }); + agent.createConnection = () => socket; + const finishError = (error) => { + if (settled) { + responseStream?.destroy(error); + return; + } + settled = true; + clearTimeout(responseTimer); + clearTimeout(idleTimer); + socket.destroy(error); + agent.destroy(); + reject(error); + }; + const request = https.request(url, { + method, + headers, + agent, + maxHeaderSize: limits.headerBytes, + signal, + }, (response) => { + clearTimeout(responseTimer); + let responseBytes = 0; + const resetIdle = () => { + clearTimeout(idleTimer); + idleTimer = setTimeout(() => responseStream.destroy(new Error("upstream response idle deadline exceeded")), limits.idleMs); + idleTimer.unref?.(); + }; + responseStream = new Transform({ + transform(chunk, _encoding, callback) { + responseBytes += chunk.length; + if (responseBytes > limits.responseBytes) return callback(new RangeError("upstream response exceeds configured limit")); + resetIdle(); + callback(null, chunk); + }, + }); + response.once("error", (error) => responseStream.destroy(error)); + responseStream.once("error", (error) => { + response.destroy(error); + request.destroy(error); + }); + responseStream.once("close", () => clearTimeout(idleTimer)); + responseStream.once("close", () => agent.destroy()); + if (response.statusCode >= 300 && response.statusCode < 400) { + response.resume(); + return finishError(new Error("upstream redirects are not permitted")); + } + resetIdle(); + response.pipe(responseStream); + settled = true; + resolve({ status: response.statusCode, headers: response.headers, body: responseStream }); + }); + request.once("error", finishError); + responseTimer = setTimeout(() => finishError(new Error("upstream response headers deadline exceeded")), limits.headersMs); + responseTimer.unref?.(); + if (!body) request.end(); + else { + const input = body[Symbol.asyncIterator] ? body : [body]; + pipeline(input, byteLimit(limits.requestBytes, "request body", () => request.destroy()), request, { signal }).catch(finishError); + } + }); +} + +/** + * Forward one request through a grant selected by authenticated connection state. + * The caller supplies the decrypted integration record; agent-controlled data + * cannot select either its credential or origin. + */ +export function createBearerForwarder({ connectorFactory = createOriginConnector, request = sendHttpsRequest, limits = DEFAULT_FORWARD_LIMITS } = {}) { + const active = new Set(); + return Object.freeze({ + async forward(integration, input, { signal } = {}) { + if (!integration || typeof integration.origin !== "string" || typeof integration.credential !== "string" || !integration.credential) { + throw new TypeError("integration grant is unavailable"); + } + if (active.size >= 16) throw new Error("concurrent request limit exceeded"); + const controller = new AbortController(); + const abort = () => controller.abort(signal?.reason); + signal?.addEventListener("abort", abort, { once: true }); + const timeout = setTimeout(() => controller.abort(new Error("request duration limit exceeded")), limits.requestMs); + timeout.unref?.(); + const key = {}; + active.add(key); + let released = false; + const release = () => { + if (released) return; + released = true; + clearTimeout(timeout); + signal?.removeEventListener("abort", abort); + active.delete(key); + }; + try { + const connect = connectorFactory(integration.origin, { timeoutMs: limits.connectMs }); + const { socket, url } = await connect(input.url); + if (controller.signal.aborted) { + socket.destroy(controller.signal.reason); + throw controller.signal.reason ?? new Error("request cancelled"); + } + const method = String(input.method ?? "GET").toUpperCase(); + if (!/^[A-Z!#$%&'*+.^_`|~-]+$/.test(method)) throw new TypeError("invalid HTTP method"); + const headers = safeHeaders(input.headers, integration.credential, limits.headerBytes); + const result = await request({ socket, url, method, headers, body: input.body, limits, signal: controller.signal }); + if (result?.body && typeof result.body.once === "function") { + result.body.once("end", release); + result.body.once("close", release); + result.body.once("error", release); + } else release(); + return result; + } catch (error) { + release(); + throw error; + } + }, + get activeRequests() { return active.size; }, + }); +} diff --git a/packages/credential-proxy/src/bootstrap-control.mjs b/packages/credential-proxy/src/bootstrap-control.mjs new file mode 100644 index 0000000..68ba7eb --- /dev/null +++ b/packages/credential-proxy/src/bootstrap-control.mjs @@ -0,0 +1,93 @@ +import { createServer } from "node:net"; +import { chmod, mkdir, rm } from "node:fs/promises"; +import { dirname } from "node:path"; +import { registerProxyBoot } from "./control-client.mjs"; + +const MAX_BOOTSTRAP_BYTES = 4096; + +/** Local-only receiver for a provider exec channel; credentials never enter env or files. */ +export async function startProxyBootstrapControl({ runtime, socketPath = "/run/factorize-proxy/bootstrap.sock", register = registerProxyBoot, onRegistered = () => {}, issueVmCertificate } = {}) { + if (!runtime || typeof register !== "function" || typeof onRegistered !== "function" + || (issueVmCertificate !== undefined && typeof issueVmCertificate !== "function")) throw new TypeError("proxy bootstrap control requires a runtime, registrar, and registration callback"); + await mkdir(dirname(socketPath), { recursive: true, mode: 0o700 }); + await chmod(dirname(socketPath), 0o700); + await rm(socketPath, { force: true }); + const server = createServer((socket) => { + let data = Buffer.alloc(0); + let settled = false; + let processing = false; + const finish = (value) => { + if (settled) return; + settled = true; + socket.end(`${JSON.stringify(value)}\n`); + }; + socket.setTimeout(5000, () => finish({ error: "invalid bootstrap request" })); + socket.on("data", async (chunk) => { + if (settled || processing) return; + if (data.byteLength + chunk.byteLength > MAX_BOOTSTRAP_BYTES) return finish({ error: "invalid bootstrap request" }); + data = Buffer.concat([data, chunk]); + const end = data.indexOf(10); + if (end < 0) return; + if (end !== data.byteLength - 1) return finish({ error: "invalid bootstrap request" }); + let request; + try { request = JSON.parse(data.subarray(0, end).toString("utf8")); } + catch { return finish({ error: "invalid bootstrap request" }); } + if (request?.type === "issue-vm-certificate") { + if (!issueVmCertificate || request.bootEpoch !== runtime.bootEpoch + || typeof request.vmIdentity !== "string" || typeof request.csr !== "string" + || Object.keys(request).sort().join(",") !== "bootEpoch,csr,type,vmIdentity") { + return finish({ error: "invalid certificate request" }); + } + processing = true; + socket.pause(); + socket.removeAllListeners("data"); + try { + const issued = await issueVmCertificate({ vmIdentity: request.vmIdentity, csr: request.csr, bootEpoch: runtime.bootEpoch }); + if (typeof issued?.certificate !== "string" || typeof issued?.trustBundle !== "string") throw new Error("invalid certificate response"); + finish({ issued: true, bootEpoch: runtime.bootEpoch, vmIdentity: request.vmIdentity, certificate: issued.certificate, trustBundle: issued.trustBundle }); + } catch { + finish({ error: "certificate provisioning failed" }); + } finally { + request.csr = ""; + } + return; + } + if (request?.type !== "register-boot") return finish({ error: "invalid bootstrap request" }); + if (!request || typeof request !== "object" || Array.isArray(request) + || Object.keys(request).sort().join(",") !== "bootEpoch,browserOrigin,controlOrigin,credential,type" + || request.bootEpoch !== runtime.bootEpoch || typeof request.credential !== "string" + || typeof request.browserOrigin !== "string" + || !/^fzc_[A-Za-z0-9_-]{43}$/.test(request.credential)) { + return finish({ error: "invalid bootstrap request" }); + } + processing = true; + socket.pause(); + socket.removeAllListeners("data"); + let credential = request.credential; + try { + const registration = await register({ controlOrigin: request.controlOrigin, token: credential, bootEpoch: runtime.bootEpoch, browserOrigin: request.browserOrigin }); + // Keep the exchanged proxy credential inside the proxy process. The + // one-shot startup command receives only the public registration result. + await onRegistered(registration, { controlOrigin: request.controlOrigin, browserOrigin: request.browserOrigin, bootEpoch: runtime.bootEpoch }); + finish({ registered: true, proxyIdentity: registration.proxyIdentity, bootEpoch: registration.bootEpoch }); + } catch { + finish({ error: "proxy boot registration failed" }); + } finally { + credential = ""; + request.credential = ""; + } + }); + socket.on("error", () => {}); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(socketPath, () => { server.removeListener("error", reject); resolve(); }); + }); + await chmod(socketPath, 0o600); + return Object.freeze({ + close: async () => { + await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())); + await rm(socketPath, { force: true }); + }, + }); +} diff --git a/packages/credential-proxy/src/browser-control.mjs b/packages/credential-proxy/src/browser-control.mjs new file mode 100644 index 0000000..930498b --- /dev/null +++ b/packages/credential-proxy/src/browser-control.mjs @@ -0,0 +1,165 @@ +import { createServer } from "node:http"; +import { createPublicKey, verify } from "node:crypto"; + +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const MAX_BODY_BYTES = 4096; + +function decodePart(value) { + if (!/^[A-Za-z0-9_-]+$/.test(value)) throw new Error("invalid token"); + return Buffer.from(value, "base64url"); +} + +function parseCapability(token, publicKey) { + if (typeof token !== "string" || token.length > 4096) throw new Error("invalid capability"); + const parts = token.split("."); + if (parts.length !== 3) throw new Error("invalid capability"); + const [headerPart, claimsPart, signaturePart] = parts; + const header = JSON.parse(decodePart(headerPart).toString("utf8")); + const claims = JSON.parse(decodePart(claimsPart).toString("utf8")); + if (header?.alg !== "EdDSA" || header?.typ !== "JWT" + || !verify(null, Buffer.from(`${headerPart}.${claimsPart}`), publicKey, decodePart(signaturePart))) { + throw new Error("invalid capability"); + } + return claims; +} + +function json(response, status, body, origin) { + const headers = { "Cache-Control": "no-store", "Content-Type": "application/json; charset=utf-8" }; + if (origin) { + headers["Access-Control-Allow-Origin"] = origin; + headers.Vary = "Origin"; + } + response.writeHead(status, headers).end(JSON.stringify(body)); +} + +function publicAuthorization(value) { + if (!value || typeof value !== "object") return { state: "idle" }; + const result = { state: value.state }; + const challenge = value.challenge; + if (challenge?.verificationUrl === "https://auth.openai.com/codex/device" + && typeof challenge.userCode === "string" && (/^[A-Z0-9]{4,6}-[A-Z0-9]{4,6}$/u.test(challenge.userCode) || /^[A-Z0-9]{8,12}$/u.test(challenge.userCode))) { + result.verificationUrl = challenge.verificationUrl; + result.userCode = challenge.userCode; + } + return result; +} + +async function readJson(request) { + const chunks = []; + let size = 0; + for await (const chunk of request) { + size += chunk.byteLength; + if (size > MAX_BODY_BYTES) throw Object.assign(new Error("request too large"), { status: 413 }); + chunks.push(chunk); + } + try { return JSON.parse(Buffer.concat(chunks).toString("utf8")); } + catch { throw Object.assign(new Error("invalid request"), { status: 400 }); } +} + +/** Private Fly service endpoint for owner-browser model-account operations. + * The Fly edge terminates public HTTPS; this listener must only be reachable + * through that service and never through the agent SSH integration channel. */ +export async function startBrowserControl({ + runtime, + lifecycle, + publicKey, + proxyId, + ownerId, + generation, + appOrigin, + host = "0.0.0.0", + port = 8080, + now = () => Math.floor(Date.now() / 1000), +} = {}) { + if (!runtime || !lifecycle || !publicKey || typeof proxyId !== "string" || !proxyId + || !UUID.test(ownerId ?? "") || !(Number.isSafeInteger(generation) && generation > 0 || UUID.test(generation ?? "")) + || !Number.isInteger(port) || port < 0 || port > 65535) { + throw new TypeError("browser control configuration is invalid"); + } + const expectedOrigin = new URL(appOrigin); + if (expectedOrigin.protocol !== "https:" || expectedOrigin.origin !== appOrigin) throw new TypeError("browser control origin must be canonical HTTPS"); + const verifyKey = publicKey?.type === "public" ? publicKey : createPublicKey(publicKey); + const consumed = new Map(); + const authorizations = new Map(); + + const server = createServer(async (request, response) => { + const origin = request.headers.origin; + if (origin !== appOrigin) return json(response, 403, { error: "origin_rejected" }); + if (request.method === "OPTIONS") { + if (request.headers["access-control-request-method"] !== "POST" + || request.headers["access-control-request-headers"]?.split(",").map(value => value.trim().toLowerCase()).sort().join(",") !== "authorization,content-type") { + return json(response, 403, { error: "preflight_rejected" }); + } + response.writeHead(204, { + "Access-Control-Allow-Origin": appOrigin, + "Access-Control-Allow-Methods": "POST", + "Access-Control-Allow-Headers": "Authorization, Content-Type", + "Access-Control-Max-Age": "60", + "Cache-Control": "no-store", + Vary: "Origin", + }).end(); + return; + } + if (request.method !== "POST") return json(response, 405, { error: "method_not_allowed" }, appOrigin); + const match = /^\/model-accounts\/([0-9a-f-]{36})\/(authorize|status|cancel)$/.exec(new URL(request.url ?? "/", "http://proxy.internal").pathname); + if (!match || !UUID.test(match[1])) return json(response, 404, { error: "not_found" }, appOrigin); + const [, accountId, operation] = match; + if (request.headers["content-type"]?.split(";", 1)[0]?.trim().toLowerCase() !== "application/json") { + return json(response, 415, { error: "unsupported_media_type" }, appOrigin); + } + const authorization = request.headers.authorization ?? ""; + if (!authorization.startsWith("Bearer ")) return json(response, 401, { error: "invalid_capability" }, appOrigin); + let claims; + try { claims = parseCapability(authorization.slice(7), verifyKey); } + catch { return json(response, 401, { error: "invalid_capability" }, appOrigin); } + const expectedAction = `model-account:${operation}`; + if (claims.iss !== "factorize" || claims.aud !== proxyId || claims.sub !== ownerId + || claims.action !== expectedAction || claims.accountId !== accountId || claims.origin !== appOrigin + || claims.generation !== generation || !Number.isSafeInteger(claims.exp) || claims.exp <= now() + || claims.exp > now() + 60 || typeof claims.jti !== "string" || !/^[A-Za-z0-9_-]{16,128}$/.test(claims.jti)) { + return json(response, 401, { error: "invalid_capability" }, appOrigin); + } + for (const [id, expiry] of consumed) if (expiry <= now()) consumed.delete(id); + if (consumed.has(claims.jti)) return json(response, 401, { error: "capability_replayed" }, appOrigin); + consumed.set(claims.jti, claims.exp); + + try { + const body = await readJson(request); + if (!body || typeof body !== "object" || Array.isArray(body) || Object.keys(body).length) { + return json(response, 400, { error: "invalid_request" }, appOrigin); + } + if (operation === "authorize") { + if (runtime.state !== "unlocked") return json(response, 423, { error: "proxy_locked" }, appOrigin); + const login = await lifecycle.deviceLogin(accountId); + const state = { state: "pending", challenge: login.challenge }; + authorizations.set(accountId, state); + Promise.resolve(login.completion).then(result => { + if (authorizations.get(accountId) === state) { + const next = result?.state === "saved" ? "authorized" : result?.state === "unsaved" ? "authorized-unsaved" : result?.state ?? "reauthentication-required"; + authorizations.set(accountId, { state: next }); + } + }, () => { + if (authorizations.get(accountId) === state) authorizations.set(accountId, { state: "failed" }); + }); + return json(response, 202, publicAuthorization(state), appOrigin); + } + if (operation === "status") return json(response, 200, publicAuthorization(authorizations.get(accountId)), appOrigin); + const result = await lifecycle.cancel(accountId); + authorizations.set(accountId, { state: "canceled" }); + return json(response, 200, { state: result?.state === "idle" ? "idle" : "canceled" }, appOrigin); + } catch (error) { + return json(response, Number.isInteger(error?.status) ? error.status : 400, + { error: "model_account_operation_failed" }, appOrigin); + } + }); + server.on("connect", (_request, socket) => socket.end("HTTP/1.1 405 Method Not Allowed\r\nConnection: close\r\n\r\n")); + server.on("upgrade", (_request, socket) => socket.destroy()); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(port, host, () => { server.removeListener("error", reject); resolve(); }); + }); + return Object.freeze({ + address: server.address(), + close: () => new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())), + }); +} diff --git a/packages/credential-proxy/src/codex-auth-helper.mjs b/packages/credential-proxy/src/codex-auth-helper.mjs new file mode 100644 index 0000000..8f88887 --- /dev/null +++ b/packages/credential-proxy/src/codex-auth-helper.mjs @@ -0,0 +1,163 @@ +import { mkdir, lstat } from "node:fs/promises"; +import { resolve, sep } from "node:path"; +import { spawn as spawnDefault } from "node:child_process"; + +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const PINNED_VERSION = "codex-cli 0.167.1"; +const METHODS = new Set(["device-login", "account-status", "refresh", "cancel", "logout"]); + +function accountHome(root, accountId) { + if (typeof accountId !== "string" || !UUID.test(accountId)) throw new TypeError("invalid model account id"); + const base = resolve(root); + const home = resolve(base, accountId); + if (!home.startsWith(`${base}${sep}`)) throw new TypeError("invalid model account id"); + return { base, home }; +} + +/** + * Narrow in-process adapter for Codex authentication commands. Callers must + * keep this object on the proxy's private control channel; it is not a job API. + * Each account gets its own normal Codex credential home. + */ +export function createCodexAuthHelper({ + codexPath = "/usr/local/bin/codex", + accountHomeRoot = "/var/lib/factorize/codex-accounts", + spawnProcess = spawnDefault, + expectedVersion = PINNED_VERSION, +} = {}) { + const logins = new Map(); + + async function prepareHome(accountId) { + const { base, home } = accountHome(accountHomeRoot, accountId); + await mkdir(base, { recursive: true, mode: 0o700 }); + await mkdir(home, { recursive: true, mode: 0o700 }); + const stat = await lstat(home); + if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("Codex account home is not a private directory"); + return home; + } + + function run(accountId, args, { timeoutMs = 10_000, input, onStdout } = {}) { + return prepareHome(accountId).then(home => new Promise((resolveRun, rejectRun) => { + let stdout = "", stderr = "", settled = false; + const child = spawnProcess(codexPath, args, { + env: { PATH: process.env.PATH ?? "", CODEX_HOME: home, HOME: home }, + stdio: ["pipe", "pipe", "pipe"], + windowsHide: true, + }); + const finish = (error, result) => { + if (settled) return; + settled = true; + clearTimeout(timer); + if (error) rejectRun(error); + else resolveRun(result); + }; + const collect = (current, chunk, limit) => { + const next = current + chunk.toString("utf8"); + if (Buffer.byteLength(next) > limit) { + child.kill("SIGTERM"); + finish(new Error("Codex authentication helper output exceeded its limit")); + return current; + } + return next; + }; + child.stdout.on("data", chunk => { + stdout = collect(stdout, chunk, 16_384); + if (!settled) onStdout?.(chunk.toString("utf8")); + }); + child.stderr.on("data", chunk => { stderr = collect(stderr, chunk, 16_384); }); + child.once("error", () => finish(new Error("Codex authentication helper could not start"))); + child.once("close", (code, signal) => { + if (code === 0) finish(null, { code, stdout, stderr }); + else finish(new Error(signal ? `Codex authentication helper stopped (${signal})` : `Codex authentication helper failed (${code ?? "unknown"})`)); + }); + const timer = setTimeout(() => { + child.kill("SIGTERM"); + finish(new Error("Codex authentication helper timed out")); + }, timeoutMs); + timer.unref?.(); + if (input !== undefined) child.stdin.end(input); + else child.stdin.end(); + return child; + })); + } + + async function checkVersion(accountId) { + // `--version` is the only non-authentication command accepted by this helper. + const result = await run(accountId, ["--version"]); + if (result.stdout.trim() !== expectedVersion) throw new Error("Unsupported Codex version for model account authentication"); + } + + async function invoke(request) { + if (!request || typeof request !== "object" || Array.isArray(request) + || !METHODS.has(request.method) || Object.keys(request).some(key => !["method", "accountId"].includes(key))) { + throw new TypeError("unsupported Codex authentication operation"); + } + const { accountId } = request; + accountHome(accountHomeRoot, accountId); + await checkVersion(accountId); + if (request.method === "device-login") { + if (logins.has(accountId)) throw new Error("device authorization is already running for this account"); + const home = await prepareHome(accountId); + let child; + const challenge = {}; + const completion = new Promise((resolveRun, rejectRun) => { + child = spawnProcess(codexPath, ["login", "--device-auth"], { + env: { PATH: process.env.PATH ?? "", CODEX_HOME: home, HOME: home }, + stdio: ["ignore", "pipe", "pipe"], windowsHide: true, + }); + let stdout = ""; + const timer = setTimeout(() => { child.kill("SIGTERM"); rejectRun(new Error("Codex device authorization timed out")); }, 15 * 60_000); + timer.unref?.(); + child.stdout.on("data", chunk => { + stdout = (stdout + chunk.toString("utf8")).slice(-16_384); + const url = stdout.match(/https:\/\/auth\.openai\.com\/codex\/device(?:\b|\/)/u)?.[0]; + const code = stdout.match(/\b(?:user\s+)?code\s*:\s*([A-Z0-9]{4,6}-[A-Z0-9]{4,6}|[A-Z0-9]{8,12})\b/iu)?.[1]; + if (url) challenge.verificationUrl = "https://auth.openai.com/codex/device"; + if (code) challenge.userCode = code.toUpperCase(); + }); + child.stderr.on("data", chunk => { + // Retain no provider error text, which could include device details. + if (chunk.byteLength > 16_384) child.kill("SIGTERM"); + }); + child.once("error", () => { clearTimeout(timer); rejectRun(new Error("Codex authentication helper could not start")); }); + child.once("close", (code, signal) => { + clearTimeout(timer); + if (code === 0) resolveRun({ state: "authorized", output: stdout }); + else rejectRun(new Error(signal ? `Codex device authorization stopped (${signal})` : `Codex device authorization failed (${code ?? "unknown"})`)); + }); + }); + const entry = { child, completion }; + logins.set(accountId, entry); + completion.finally(() => { if (logins.get(accountId) === entry) logins.delete(accountId); }).catch(() => {}); + return { state: "pending", challenge, completion }; + } + if (request.method === "cancel") { + const login = logins.get(accountId); + if (!login) return { state: "idle" }; + login.child.kill("SIGTERM"); + logins.delete(accountId); + return { state: "canceled" }; + } + if (request.method === "logout") { + const login = logins.get(accountId); + if (login) { + login.child.kill("SIGTERM"); + logins.delete(accountId); + } + await run(accountId, ["logout"]); + return { state: "logged-out" }; + } + if (request.method === "account-status") { + const result = await run(accountId, ["login", "status"]); + return { state: result.stdout.trim() ? "authenticated" : "unauthenticated" }; + } + // Codex owns refresh semantics in its authentication manager. A status call + // exercises that manager without opening the agent command surface. + const result = await run(accountId, ["login", "status"]); + return { state: result.stdout.trim() ? "refreshed" : "reauthentication-required" }; + } + + return Object.freeze({ invoke }); +} + +export { PINNED_VERSION as CODEX_AUTH_HELPER_VERSION }; diff --git a/packages/credential-proxy/src/control-client.mjs b/packages/credential-proxy/src/control-client.mjs new file mode 100644 index 0000000..0a4c889 --- /dev/null +++ b/packages/credential-proxy/src/control-client.mjs @@ -0,0 +1,230 @@ +const bootEpochPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +/** Register one proxy process boot using a provisioning-scoped credential. The token is only held in this call's memory. */ +function validateOrigin(controlOrigin) { + const origin = new URL(controlOrigin); + if (origin.protocol !== "https:" || origin.pathname !== "/" || origin.username || origin.password || origin.search || origin.hash) throw new TypeError("proxy control origin must be an HTTPS origin"); + return origin; +} + +export async function registerProxyBoot({ controlOrigin, token, bootEpoch, browserOrigin, fetchImpl = fetch }) { + const origin = validateOrigin(controlOrigin); + if (!/^fzc_[A-Za-z0-9_-]{43}$/.test(token ?? "")) throw new TypeError("invalid proxy control credential"); + if (!bootEpochPattern.test(bootEpoch ?? "")) throw new TypeError("invalid proxy boot epoch"); + const browser = new URL(browserOrigin); + if (browser.protocol !== "https:" || browser.origin !== browserOrigin || browser.username || browser.password) throw new TypeError("invalid browser control origin"); + let credential = token; + try { + const response = await fetchImpl(new URL("/internal/credential-proxy/register-boot", origin), { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${credential}` }, + body: JSON.stringify({ bootEpoch, browserOrigin }), + signal: AbortSignal.timeout(10_000), + redirect: "error", + }); + if (!response.ok) throw new Error("proxy boot registration rejected"); + const result = await response.json(); + if (result?.registered !== true || result.bootEpoch !== bootEpoch || typeof result.proxyIdentity !== "string" + || !uuidPattern.test(result.tenantId ?? "") || !/^fzc_[A-Za-z0-9_-]{43}$/.test(result.controlCredential ?? "")) throw new Error("invalid proxy boot registration response"); + return { tenantId: result.tenantId, proxyIdentity: result.proxyIdentity, bootEpoch: result.bootEpoch, controlCredential: result.controlCredential }; + } finally { + credential = ""; + } +} + +async function proxyControlRequest({ controlOrigin, token, path, body, fetchImpl }) { + const origin = validateOrigin(controlOrigin); + if (!/^fzc_[A-Za-z0-9_-]{43}$/.test(token ?? "")) throw new TypeError("invalid proxy control credential"); + const response = await fetchImpl(new URL(path, origin), { + method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, + body: JSON.stringify(body), signal: AbortSignal.timeout(10_000), redirect: "error", + }); + if (!response.ok) { + let code; + try { code = (await response.clone().json())?.error; } catch { /* Keep error handling bounded to the HTTP status. */ } + const error = new Error("proxy control request rejected"); + error.name = "ProxyControlRequestError"; + error.status = response.status; + if (typeof code === "string") error.code = code; + throw error; + } + return response.json(); +} + +export async function issueProxyEnrollment(options) { + const result = await proxyControlRequest({ ...options, path: "/internal/credential-proxy/enroll" }); + if (typeof result?.token !== "string" || typeof result?.enrollmentId !== "string" || !Number.isSafeInteger(result?.grantRevision)) throw new Error("invalid proxy enrollment response"); + return result; +} + +export async function consumeProxyEnrollment(options) { + const result = await proxyControlRequest({ ...options, path: "/internal/credential-proxy/consume-enrollment" }); + if (result?.consumed !== true || typeof result?.binding?.enrollmentId !== "string") throw new Error("invalid consumed enrollment response"); + return result.binding; +} + +/** Read the current user-encrypted integration records selected by an active run. + * The control plane decrypts only Factorize's outer encryption layer. */ +export async function readProxyIntegrationSnapshot({ controlOrigin, token, binding, fetchImpl = fetch } = {}) { + if (!binding || !uuidPattern.test(binding.runId ?? "") || !Number.isSafeInteger(binding.grantRevision) || binding.grantRevision < 0) { + throw new TypeError("invalid proxy integration snapshot binding"); + } + const result = await proxyControlRequest({ + controlOrigin, + token, + path: "/internal/credential-proxy/integration-snapshot", + body: { runId: binding.runId, grantRevision: binding.grantRevision }, + fetchImpl, + }); + const modelAccount = result?.modelAccount; + if (typeof result?.ownerId !== "string" || !Array.isArray(result.records) + || result.records.some(record => !record || typeof record.id !== "string" || record.kind !== "bearer" + || typeof record.origin !== "string" || typeof record.encryptedPayload !== "string" || !Number.isSafeInteger(record.revision)) + || !(modelAccount === null || (modelAccount && typeof modelAccount.id === "string" && modelAccount.kind === "model" + && modelAccount.origin === null && typeof modelAccount.encryptedPayload === "string" && Number.isSafeInteger(modelAccount.revision)))) { + throw new Error("invalid proxy integration snapshot response"); + } + return result; +} + +/** Concrete proxy-side adapters for revision-bound encrypted model caches. */ +export function createModelAccountPersistenceAdapter({ controlOrigin, controlCredential, ownerId, fetchImpl = fetch } = {}) { + if (typeof ownerId !== "string" || !ownerId || !/^fzc_[A-Za-z0-9_-]{43}$/.test(controlCredential ?? "")) { + throw new TypeError("model account persistence requires the registered proxy identity"); + } + const read = async accountId => { + if (!uuidPattern.test(accountId ?? "")) throw new TypeError("invalid model account id"); + const result = await proxyControlRequest({ controlOrigin, token: controlCredential, path: "/internal/credential-proxy/model-account-cache", body: { operation: "read", accountId }, fetchImpl }); + const account = result?.account; + if (result?.ownerId !== ownerId || !account || account.id !== accountId || account.kind !== "model" + || account.origin !== null || typeof account.encryptedPayload !== "string" + || !Number.isSafeInteger(account.revision) || !uuidPattern.test(account.keyGeneration ?? "")) { + throw new Error("invalid model account persistence response"); + } + return { ...account, ownerId }; + }; + return Object.freeze({ + async listAccounts() { + const result = await proxyControlRequest({ controlOrigin, token: controlCredential, path: "/internal/credential-proxy/model-account-cache", body: { operation: "list" }, fetchImpl }); + if (result?.ownerId !== ownerId || !Array.isArray(result.accounts) + || !uuidPattern.test(result.keyGeneration ?? "") + || result.accounts.some(account => !account || !uuidPattern.test(account.id ?? "") || account.kind !== "model" + || account.origin !== null || !Number.isSafeInteger(account.revision))) { + throw new Error("invalid model account list response"); + } + return result.accounts.map(account => ({ ...account, ownerId, keyGeneration: result.keyGeneration })); + }, + getAccount: read, + async getEncryptedCache(accountId) { + const account = await read(accountId); + return { accountId, ownerId, keyGeneration: account.keyGeneration, revision: account.revision, encryptedPayload: account.encryptedPayload }; + }, + async persistEncrypted({ id, expectedRevision, encryptedPayload, idempotencyKey }) { + if (!uuidPattern.test(id ?? "") || !uuidPattern.test(idempotencyKey ?? "") || !Number.isSafeInteger(expectedRevision) + || typeof encryptedPayload !== "string" || encryptedPayload.length > 65_536) throw new TypeError("invalid encrypted model account update"); + const result = await proxyControlRequest({ controlOrigin, token: controlCredential, path: "/internal/credential-proxy/model-account-cache", + body: { operation: "save", accountId: id, expectedRevision, encryptedPayload, idempotencyKey }, fetchImpl }); + if (result?.accountId !== id || result.revision !== expectedRevision + 1 || typeof result.duplicate !== "boolean") { + throw new Error("invalid model account persistence response"); + } + return result; + }, + }); +} + +/** Read durable revocations for the registered proxy boot. Replaying the full + * set is intentional: it closes runs revoked while this proxy was offline and + * runtime.revokeRun is idempotent. */ +export async function readProxyRunRevocations({ controlOrigin, token, bootEpoch, fetchImpl = fetch } = {}) { + if (!bootEpochPattern.test(bootEpoch ?? "")) throw new TypeError("invalid proxy boot epoch"); + const result = await proxyControlRequest({ + controlOrigin, + token, + path: "/internal/credential-proxy/revocations", + body: { bootEpoch }, + fetchImpl, + }); + if (!Array.isArray(result?.revokedRunIds) || result.revokedRunIds.some(id => !uuidPattern.test(id))) { + throw new Error("invalid proxy revocation response"); + } + return result.revokedRunIds; +} + +/** Poll durable control-plane state. A failed request leaves established + * connections running and the next bounded poll retries when communication + * recovers; it does not add an authorization lease or expiry. */ +export function startProxyRunRevocationMonitor({ controlOrigin, controlCredential, bootEpoch, runtime, intervalMs = 5_000, fetchImpl = fetch } = {}) { + if (!runtime || typeof runtime.revokeRun !== "function" || !Number.isInteger(intervalMs) || intervalMs < 100 || intervalMs > 60_000) { + throw new TypeError("invalid proxy revocation monitor configuration"); + } + let stopped = false; + let timer; + let polling; + const poll = async () => { + if (stopped || polling) return; + polling = (async () => { + try { + const runIds = await readProxyRunRevocations({ controlOrigin, token: controlCredential, bootEpoch, fetchImpl }); + for (const runId of runIds) await runtime.revokeRun(runId); + } catch { + // Keep existing connections alive during an outage and retry on schedule. + } finally { + polling = undefined; + if (!stopped) timer = setTimeout(poll, intervalMs); + } + })(); + }; + void poll(); + return Object.freeze({ + async close() { + stopped = true; + clearTimeout(timer); + await polling; + }, + }); +} + +const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +/** + * Create the proxy-side half of enrollment. The job VM supplies only its run + * ID over the already authenticated TLS connection; the proxy derives VM and + * boot identity locally, then issues and consumes a fresh one-use enrollment + * using its private control credential. Integration secrets stay in the + * proxy's local grant resolver and never cross this control API. + */ +export function createProxyEnrollmentAuthenticator({ controlOrigin, controlCredential, bootEpoch, resolveGrants, fetchImpl = fetch } = {}) { + if (!/^fzc_[A-Za-z0-9_-]{43}$/.test(controlCredential ?? "")) throw new TypeError("invalid proxy control credential"); + if (!bootEpochPattern.test(bootEpoch ?? "")) throw new TypeError("invalid proxy boot epoch"); + if (typeof resolveGrants !== "function") throw new TypeError("proxy enrollment requires a local grant resolver"); + validateOrigin(controlOrigin); + let credential = controlCredential; + return async function authenticateRun({ runId, vmIdentity, runCredential, bootEpoch: requestedBootEpoch }) { + if (!uuidPattern.test(runId ?? "") || typeof vmIdentity !== "string" || !vmIdentity.trim() || vmIdentity.length > 255 + || !/^fzt_run_[A-Za-z0-9_-]{43,200}$/.test(runCredential ?? "") || requestedBootEpoch !== bootEpoch) throw new Error("invalid proxy enrollment identity"); + const options = { controlOrigin, token: credential, fetchImpl }; + const requested = { runId, vmIdentity, runCredential }; + const enrollment = await issueProxyEnrollment({ ...options, body: requested }); + const binding = await consumeProxyEnrollment({ ...options, body: { runId, vmIdentity, enrollmentToken: enrollment.token } }); + if (binding.runId !== runId || binding.vmIdentity !== vmIdentity || binding.bootEpoch !== bootEpoch + || typeof binding.tenantId !== "string" || typeof binding.proxyIdentity !== "string" + || binding.enrollmentId !== enrollment.enrollmentId || binding.grantRevision !== enrollment.grantRevision) { + throw new Error("consumed enrollment binding did not match the authenticated connection"); + } + const grants = await resolveGrants(binding); + if (!Array.isArray(grants)) throw new Error("proxy grants are unavailable"); + return { + authenticated: true, + enrollmentConsumed: true, + connectionId: binding.enrollmentId, + runId: binding.runId, + vmId: binding.vmIdentity, + vmIdentity: binding.vmIdentity, + bootEpoch: binding.bootEpoch, + tenantId: binding.tenantId, + proxyIdentity: binding.proxyIdentity, + grantRevision: binding.grantRevision, + grants, + }; + }; +} diff --git a/packages/credential-proxy/src/decrypted-grants.mjs b/packages/credential-proxy/src/decrypted-grants.mjs new file mode 100644 index 0000000..247a1bf --- /dev/null +++ b/packages/credential-proxy/src/decrypted-grants.mjs @@ -0,0 +1,121 @@ +import { webcrypto } from "node:crypto"; +import { readProxyIntegrationSnapshot } from "./control-client.mjs"; + +const KEY_PREFIX = "fzk1."; +const KEY_BYTES = 32; +const NONCE_BYTES = 12; +const SALT = new TextEncoder().encode("factorize.unlock-key.v1"); +const INFO = new TextEncoder().encode("factorize.record.aes-256-gcm.v1"); +const encoder = new TextEncoder(); +const decoder = new TextDecoder("utf-8", { fatal: true }); + +function decodeBase64Url(value, label) { + if (typeof value !== "string" || !/^[A-Za-z0-9_-]+$/.test(value)) throw new TypeError(`malformed ${label}`); + const bytes = Buffer.from(value, "base64url"); + if (bytes.toString("base64url") !== value) throw new TypeError(`malformed ${label}`); + return new Uint8Array(bytes); +} + +function decodeStoredCiphertext(value) { + if (typeof value !== "string" || value.length > 65_536) throw new TypeError("malformed integration ciphertext"); + const parts = value.split("."); + if (parts.length !== 2 || !parts.every(part => /^[A-Za-z0-9+/]+={0,2}$/.test(part))) throw new TypeError("malformed integration ciphertext"); + const nonce = Buffer.from(parts[0], "base64"); + const ciphertext = Buffer.from(parts[1], "base64"); + if (nonce.toString("base64") !== parts[0] || ciphertext.toString("base64") !== parts[1] + || nonce.byteLength !== NONCE_BYTES || ciphertext.byteLength < 16) throw new TypeError("malformed integration ciphertext"); + return { nonce: new Uint8Array(nonce), ciphertext: new Uint8Array(ciphertext) }; +} + +function recordAad(ownerId, record) { + if (typeof ownerId !== "string" || !ownerId || typeof record?.id !== "string" || !record.id + || typeof record.origin !== "string" || !record.origin || !Number.isSafeInteger(record.revision) || record.revision < 1) { + throw new TypeError("malformed integration record binding"); + } + return encoder.encode(JSON.stringify(["factorize-record-v1", ownerId, record.id, "bearer", record.origin, record.revision])); +} + +async function deriveKey(key) { + if (typeof key !== "string" || !key.startsWith(KEY_PREFIX)) throw new TypeError("malformed unlock key"); + const raw = decodeBase64Url(key.slice(KEY_PREFIX.length), "unlock key"); + if (raw.byteLength !== KEY_BYTES) throw new TypeError("malformed unlock key"); + try { + const material = await webcrypto.subtle.importKey("raw", raw, "HKDF", false, ["deriveKey"]); + return await webcrypto.subtle.deriveKey({ name: "HKDF", hash: "SHA-256", salt: SALT, info: INFO }, material, + { name: "AES-GCM", length: 256 }, false, ["decrypt"]); + } finally { + raw.fill(0); + } +} + +function validateOrigin(origin) { + let parsed; + try { parsed = new URL(origin); } catch { throw new TypeError("invalid integration origin"); } + if (parsed.protocol !== "https:" || parsed.origin !== origin || parsed.username || parsed.password) throw new TypeError("invalid integration origin"); + return parsed.origin; +} + +/** + * Resolve only the selected bearer records from a current, server-decrypted + * snapshot. The API supplies ciphertext after removing its own encryption + * layer; this helper never sends the user's unlock key or plaintext upstream. + */ +export function createDecryptedGrantResolver({ readSnapshot, getUnlockKey, resolveIntegrationIds } = {}) { + if (typeof readSnapshot !== "function" || typeof getUnlockKey !== "function" + || (resolveIntegrationIds !== undefined && typeof resolveIntegrationIds !== "function")) throw new TypeError("grant resolver requires snapshot and unlock-key readers"); + return async function resolveGrants(binding) { + if (!binding || typeof binding.tenantId !== "string" || !binding.tenantId + || (binding.integrationIds !== undefined && !Array.isArray(binding.integrationIds))) throw new TypeError("invalid run integration selection"); + const requestedIds = binding.integrationIds ?? await resolveIntegrationIds?.(binding); + if (requestedIds !== undefined && (!Array.isArray(requestedIds) || requestedIds.some(id => typeof id !== "string" || !id) + || new Set(requestedIds).size !== requestedIds.length)) throw new TypeError("invalid run integration selection"); + if (requestedIds?.length === 0) return []; + const unlockKey = await getUnlockKey(); + if (typeof unlockKey !== "string" || !unlockKey) throw new Error("proxy is locked"); + const snapshot = await readSnapshot(binding); + if (!snapshot || !Array.isArray(snapshot.records) || typeof snapshot.ownerId !== "string" || !snapshot.ownerId) throw new Error("integration snapshot is unavailable"); + // Production snapshots are already filtered by the control plane using the + // active run's immutable selection and grant revision. Local callers may + // still provide IDs explicitly for additional narrowing. + const integrationIds = requestedIds ?? snapshot.records.map(record => record?.id); + if (!Array.isArray(integrationIds) || integrationIds.some(id => typeof id !== "string" || !id) + || new Set(integrationIds).size !== integrationIds.length) throw new TypeError("invalid run integration selection"); + if (integrationIds.length === 0) { + if (snapshot.records.length) throw new Error("integration snapshot does not match the run selection"); + return []; + } + const selected = new Set(integrationIds); + const records = snapshot.records.filter(record => selected.has(record?.id) && record.kind === "bearer" && record.deletedAt == null); + const selectionWasNarrowedLocally = binding.integrationIds !== undefined || resolveIntegrationIds !== undefined; + if (records.length !== selected.size || (!selectionWasNarrowedLocally && snapshot.records.length !== records.length)) throw new Error("selected integration is unavailable"); + const aesKey = await deriveKey(unlockKey); + const grants = []; + for (const record of records) { + const origin = validateOrigin(record.origin); + const { nonce, ciphertext } = decodeStoredCiphertext(record.encryptedPayload); + const plaintext = new Uint8Array(await webcrypto.subtle.decrypt({ name: "AES-GCM", iv: nonce, + additionalData: recordAad(snapshot.ownerId, record), tagLength: 128 }, aesKey, ciphertext)); + try { + const credential = decoder.decode(plaintext); + if (!credential || credential.length > 16_384 || /[\u0000-\u001f\u007f]/.test(credential)) throw new TypeError("invalid bearer credential"); + grants.push(Object.freeze({ id: record.id, origin, credential })); + } finally { + plaintext.fill(0); + nonce.fill(0); + ciphertext.fill(0); + } + } + return grants; + }; +} + +/** Compose the boot-registered control client, current snapshot reader, and + * process-local unlock key into the resolver used by authenticated tunnels. */ +export function createProxyIntegrationGrantResolver({ runtime, controlOrigin, controlCredential, fetchImpl = fetch } = {}) { + if (!runtime || typeof runtime.getUnlockKey !== "function") throw new TypeError("proxy runtime does not expose its unlocked key to the grant resolver"); + if (typeof controlOrigin !== "string" || typeof controlCredential !== "string") throw new TypeError("proxy control registration is required"); + return createDecryptedGrantResolver({ + readSnapshot: binding => readProxyIntegrationSnapshot({ controlOrigin, token: controlCredential, binding, fetchImpl }), + getUnlockKey: () => runtime.getUnlockKey(), + }); +} diff --git a/packages/credential-proxy/src/destination-policy.mjs b/packages/credential-proxy/src/destination-policy.mjs index 0b71dbb..e0bbba9 100644 --- a/packages/credential-proxy/src/destination-policy.mjs +++ b/packages/credential-proxy/src/destination-policy.mjs @@ -109,7 +109,7 @@ async function resolveAll(hostname) { return lookup(hostname, { all: true, verbatim: true }); } -function openTlsSocket({ hostname, port, address }) { +function openTlsSocket({ hostname, port, address, timeoutMs = 10_000 }) { return new Promise((resolve, reject) => { const socket = tlsConnect({ host: address, @@ -117,9 +117,12 @@ function openTlsSocket({ hostname, port, address }) { servername: isIP(hostname) ? undefined : hostname, checkServerIdentity: (_server, certificate) => checkServerIdentity(hostname, certificate), }); - const failed = (error) => { socket.destroy(); reject(error); }; + const timer = setTimeout(() => failed(new Error("upstream connect deadline exceeded")), timeoutMs); + timer.unref?.(); + const failed = (error) => { clearTimeout(timer); socket.destroy(); reject(error); }; socket.once("error", failed); socket.once("secureConnect", () => { + clearTimeout(timer); socket.removeListener("error", failed); resolve(socket); }); @@ -132,7 +135,7 @@ function openTlsSocket({ hostname, port, address }) { * so a second DNS lookup cannot change the destination between validation and * connection. Paths, queries, methods, and bodies remain available to callers. */ -export function createOriginConnector(configuredOrigin, { resolve = resolveAll, connect = openTlsSocket } = {}) { +export function createOriginConnector(configuredOrigin, { resolve = resolveAll, connect = openTlsSocket, timeoutMs = 10_000 } = {}) { const configured = parseHttpsUrl(configuredOrigin, "configured origin"); if (configured.url.pathname !== "/" || configured.url.search || configured.url.hash) { throw new TypeError("configured origin must contain only an HTTPS origin"); @@ -148,7 +151,7 @@ export function createOriginConnector(configuredOrigin, { resolve = resolveAll, if (!answer || !isPublicAddress(answer.address)) throw new Error("origin resolved to a non-public address"); } const address = answers[0].address; - const socket = await connect({ hostname: target.hostname, port: target.port, address, servername: target.hostname }); + const socket = await connect({ hostname: target.hostname, port: target.port, address, servername: target.hostname, timeoutMs }); const rawPeerAddress = socket?.remoteAddress ?? ""; const peerAddress = normalizeAddress(rawPeerAddress); if (!peerAddress || peerAddress !== normalizeAddress(address) || !isPublicAddress(rawPeerAddress)) { diff --git a/packages/credential-proxy/src/main.mjs b/packages/credential-proxy/src/main.mjs index 20c3237..50f1ba2 100644 --- a/packages/credential-proxy/src/main.mjs +++ b/packages/credential-proxy/src/main.mjs @@ -1,7 +1,91 @@ import { createInterface } from "node:readline"; +import { randomUUID } from "node:crypto"; import { createProxyRuntime } from "./runtime.mjs"; +import { registerProxyBoot, startProxyRunRevocationMonitor } from "./control-client.mjs"; +import { startProxyBootstrapControl } from "./bootstrap-control.mjs"; +import { createProxyIntegrationGrantResolver } from "./decrypted-grants.mjs"; +import { startAuthenticatedProxyTunnel } from "./tunnel-transport.mjs"; +import { createProxyTlsAuthority } from "./tls-identity.mjs"; +import { createCodexAuthHelper } from "./codex-auth-helper.mjs"; +import { createModelAccountLifecycle } from "./model-account-lifecycle.mjs"; +import { createModelAccountPersistenceAdapter } from "./control-client.mjs"; +import { decryptModelAccountCache, encryptModelAccountCache } from "./model-account-crypto.mjs"; +import { startBrowserControl } from "./browser-control.mjs"; -const runtime = createProxyRuntime(); +const epochIndex = process.argv.indexOf("--boot-epoch"); +const bootEpoch = epochIndex < 0 ? randomUUID() : process.argv[epochIndex + 1]; +const runtime = createProxyRuntime({ bootEpoch }); +const tunnelPort = Number(process.env.FACTORIZE_PROXY_TUNNEL_PORT ?? "9090"); +if (!Number.isInteger(tunnelPort) || tunnelPort < 1 || tunnelPort > 65535) throw new Error("invalid proxy tunnel port"); +const tlsAuthority = await createProxyTlsAuthority({ + serverName: process.env.FACTORIZE_PROXY_TUNNEL_SERVER_NAME ?? "localhost", + bootEpoch: runtime.bootEpoch, +}); +let controlCredential; +let controlOrigin; +let tunnel; +let revocationMonitor; +let modelAccounts; +let modelAccountLifecycle; +let browserControl; + +async function activateRegistration(registration, origin, browserOrigin) { + if (tunnel) throw new Error("proxy boot is already registered"); + const persistence = createModelAccountPersistenceAdapter({ + controlOrigin: origin, + controlCredential: registration.controlCredential, + ownerId: registration.tenantId, + }); + modelAccounts = persistence; + modelAccountLifecycle = createModelAccountLifecycle({ + authHelper: createCodexAuthHelper(), + getUnlockKey: () => runtime.getUnlockKey(), + getAccount: persistence.getAccount, + getEncryptedCache: persistence.getEncryptedCache, + persistEncrypted: persistence.persistEncrypted, + encryptCache: encryptModelAccountCache, + decryptCache: decryptModelAccountCache, + }); + if (!process.env.FACTORIZE_PROXY_BROWSER_CAPABILITY_PUBLIC_KEY) throw new Error("proxy browser capability verification key is unavailable"); + browserControl = await startBrowserControl({ + runtime, + lifecycle: modelAccountLifecycle, + publicKey: process.env.FACTORIZE_PROXY_BROWSER_CAPABILITY_PUBLIC_KEY, + proxyId: registration.proxyIdentity, + ownerId: registration.tenantId, + generation: registration.bootEpoch, + appOrigin: process.env.FACTORIZE_APP_ORIGIN ?? "https://app.factorize.sh", + }); + const resolveGrants = createProxyIntegrationGrantResolver({ runtime, controlOrigin: origin, controlCredential: registration.controlCredential }); + const started = await startAuthenticatedProxyTunnel({ + tls: tlsAuthority.tls, + runtime, + controlOrigin: origin, + controlCredential: registration.controlCredential, + resolveGrants, + host: process.env.FACTORIZE_PROXY_TUNNEL_HOST ?? "0.0.0.0", + port: tunnelPort, + }); + controlCredential = registration.controlCredential; + controlOrigin = origin; + tunnel = started; + revocationMonitor = startProxyRunRevocationMonitor({ + controlOrigin: origin, + controlCredential: registration.controlCredential, + bootEpoch: runtime.bootEpoch, + runtime, + }); +} + +const bootstrapControl = await startProxyBootstrapControl({ + runtime, + socketPath: process.env.FACTORIZE_PROXY_BOOTSTRAP_SOCKET, + onRegistered: (registration, request) => activateRegistration(registration, request.controlOrigin, request.browserOrigin), + issueVmCertificate: ({ vmIdentity, csr }) => { + if (!tunnel) throw new Error("proxy tunnel is not registered"); + return tlsAuthority.signVmCsr({ vmIdentity, csr }); + }, +}); const lines = createInterface({ input: process.stdin, crlfDelay: Infinity }); let closing = false; let shutdownDeadline; @@ -10,10 +94,24 @@ function respond(value) { process.stdout.write(`${JSON.stringify(value)}\n`); } -function stop() { +function respondStatus() { + respond({ type: "status", state: runtime.state, proxyControlRegistered: Boolean(controlCredential && controlOrigin) }); +} + +async function stop() { if (closing) return; closing = true; - runtime.shutdown(); + controlCredential = undefined; + controlOrigin = undefined; + await revocationMonitor?.close(); + revocationMonitor = undefined; + await browserControl?.close().catch(() => {}); + browserControl = undefined; + await runtime.shutdown(); + await tunnel?.close().catch(() => {}); + tunnel = undefined; + await tlsAuthority.close(); + await bootstrapControl.close(); lines.close(); clearTimeout(shutdownDeadline); process.exit(0); @@ -23,20 +121,39 @@ process.once("SIGTERM", stop); process.once("SIGINT", stop); shutdownDeadline = setTimeout(() => process.exit(1), 2_000); shutdownDeadline.unref(); -respond({ type: "ready", state: runtime.state }); +respond({ type: "ready", state: runtime.state, bootEpoch: runtime.bootEpoch }); -lines.on("line", (line) => { +lines.on("line", async (line) => { if (closing) return; let command; try { command = JSON.parse(line); - if (command?.type === "status") respond({ type: "status", state: runtime.state }); + if (command?.type === "status") respondStatus(); else if (command?.type === "unlock") { runtime.unlock(command.key); - respond({ type: "status", state: runtime.state }); + if (modelAccountLifecycle && modelAccounts) { + try { + const accounts = await modelAccounts.listAccounts(); + await modelAccountLifecycle.reconcileAll(accounts); + } catch (error) { + await runtime.lock(); + throw error; + } + } + respondStatus(); } else if (command?.type === "lock") { - runtime.lock(); - respond({ type: "status", state: runtime.state }); + await runtime.lock(); + respondStatus(); + } else if (command?.type === "register-boot") { + let credential = command.credential; + command.credential = undefined; + try { + const registration = await registerProxyBoot({ controlOrigin: command.controlOrigin, browserOrigin: command.browserOrigin, token: credential, bootEpoch: runtime.bootEpoch }); + await activateRegistration(registration, command.controlOrigin, command.browserOrigin); + respond({ type: "boot-registered", proxyIdentity: registration.proxyIdentity, bootEpoch: registration.bootEpoch }); + } catch { + respond({ type: "error", error: "proxy boot registration failed" }); + } finally { credential = ""; } } else if (command?.type === "stop") stop(); else respond({ type: "error", error: "unsupported command" }); } catch { diff --git a/packages/credential-proxy/src/model-account-crypto.mjs b/packages/credential-proxy/src/model-account-crypto.mjs new file mode 100644 index 0000000..4602c48 --- /dev/null +++ b/packages/credential-proxy/src/model-account-crypto.mjs @@ -0,0 +1,61 @@ +import { webcrypto } from "node:crypto"; + +const KEY_PREFIX = "fzk1."; +const KEY_BYTES = 32; +const NONCE_BYTES = 12; +const SALT = new TextEncoder().encode("factorize.unlock-key.v1"); +const INFO = new TextEncoder().encode("factorize.record.aes-256-gcm.v1"); +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +function bindingData(binding) { + if (!binding || typeof binding.ownerId !== "string" || !binding.ownerId + || !UUID.test(binding.accountId ?? "") || !UUID.test(binding.keyGeneration ?? "") + || !Number.isSafeInteger(binding.revision) || binding.revision < 1) { + throw new TypeError("invalid model account encryption binding"); + } + return new TextEncoder().encode(JSON.stringify([ + "factorize-record-v1", binding.ownerId, binding.accountId, "model", "factorize", binding.keyGeneration, binding.revision, + ])); +} + +async function encryptionKey(unlockKey, usage) { + if (typeof unlockKey !== "string" || !unlockKey.startsWith(KEY_PREFIX)) throw new TypeError("malformed unlock key"); + const encoded = unlockKey.slice(KEY_PREFIX.length); + if (!/^[A-Za-z0-9_-]{43}$/.test(encoded)) throw new TypeError("malformed unlock key"); + const raw = Buffer.from(encoded, "base64url"); + if (raw.byteLength !== KEY_BYTES || raw.toString("base64url") !== encoded) throw new TypeError("malformed unlock key"); + try { + const material = await webcrypto.subtle.importKey("raw", raw, "HKDF", false, ["deriveKey"]); + return await webcrypto.subtle.deriveKey({ name: "HKDF", hash: "SHA-256", salt: SALT, info: INFO }, material, + { name: "AES-GCM", length: 256 }, false, [usage]); + } finally { raw.fill(0); } +} + +function decodeStored(value, label) { + if (typeof value !== "string" || !/^[A-Za-z0-9+/]+={0,2}$/.test(value)) throw new TypeError(`malformed ${label}`); + const bytes = Buffer.from(value, "base64"); + if (bytes.toString("base64") !== value) throw new TypeError(`malformed ${label}`); + return new Uint8Array(bytes); +} + +/** Encrypt a Codex auth cache for persistence. The cache and unlock key remain in the proxy process. */ +export async function encryptModelAccountCache(plaintext, unlockKey, binding) { + if (!(plaintext instanceof Uint8Array) || plaintext.byteLength === 0 || plaintext.byteLength > 65_536) throw new TypeError("invalid model account cache"); + const aad = bindingData(binding), nonce = webcrypto.getRandomValues(new Uint8Array(NONCE_BYTES)); + const key = await encryptionKey(unlockKey, "encrypt"); + const ciphertext = new Uint8Array(await webcrypto.subtle.encrypt({ name: "AES-GCM", iv: nonce, additionalData: aad, tagLength: 128 }, key, plaintext)); + return `${Buffer.from(nonce).toString("base64")}.${Buffer.from(ciphertext).toString("base64")}`; +} + +/** Decrypt only after the selected account, owner, key generation, and revision are verified. */ +export async function decryptModelAccountCache(encryptedPayload, unlockKey, binding) { + if (typeof encryptedPayload !== "string" || encryptedPayload.length > 65_536) throw new TypeError("malformed model account ciphertext"); + const parts = encryptedPayload.split("."); + if (parts.length !== 2) throw new TypeError("malformed model account ciphertext"); + const nonce = decodeStored(parts[0], "model account nonce"), ciphertext = decodeStored(parts[1], "model account ciphertext"); + if (nonce.byteLength !== NONCE_BYTES || ciphertext.byteLength < 16) throw new TypeError("malformed model account ciphertext"); + const aad = bindingData(binding), key = await encryptionKey(unlockKey, "decrypt"); + try { + return new Uint8Array(await webcrypto.subtle.decrypt({ name: "AES-GCM", iv: nonce, additionalData: aad, tagLength: 128 }, key, ciphertext)); + } finally { nonce.fill(0); ciphertext.fill(0); } +} diff --git a/packages/credential-proxy/src/model-account-lifecycle.mjs b/packages/credential-proxy/src/model-account-lifecycle.mjs new file mode 100644 index 0000000..565abde --- /dev/null +++ b/packages/credential-proxy/src/model-account-lifecycle.mjs @@ -0,0 +1,282 @@ +import { mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises"; +import { randomUUID } from "node:crypto"; +import { join, resolve, sep } from "node:path"; + +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +function cachePath(root, accountId) { + if (!UUID.test(accountId ?? "")) throw new TypeError("invalid model account id"); + const base = resolve(root), path = resolve(base, accountId, "auth.json"); + if (!path.startsWith(`${base}${sep}`)) throw new TypeError("invalid model account id"); + return path; +} + +/** + * Keep Codex token refresh and encrypted persistence inside the proxy. The + * persistence callback receives only user-encrypted auth-cache bytes; token + * JSON and the unlock key never leave this process in plaintext. + */ +export function createModelAccountLifecycle({ + authHelper, + accountHomeRoot = "/var/lib/factorize/codex-accounts", + getUnlockKey, + getAccount, + encryptCache, + persistEncrypted, + onUnsaved = () => {}, + retryDelaysMs = [100, 500, 2_000], + staleRevisionRetries = 3, + sleep = ms => new Promise(resolveSleep => setTimeout(resolveSleep, ms)), + removeCache = rm, + readCache = readFile, + writeCache = writeFile, + makeDirectory = mkdir, + readDirectory = readdir, + replaceFile = rename, + getEncryptedCache, + decryptCache, +} = {}) { + if (!authHelper || typeof authHelper.invoke !== "function" || typeof getUnlockKey !== "function" + || typeof getAccount !== "function" || typeof encryptCache !== "function" || typeof persistEncrypted !== "function" + || !Array.isArray(retryDelaysMs) || retryDelaysMs.length > 5 + || retryDelaysMs.some(ms => !Number.isSafeInteger(ms) || ms < 0 || ms > 30_000) + || !Number.isSafeInteger(staleRevisionRetries) || staleRevisionRetries < 0 || staleRevisionRetries > 5) { + throw new TypeError("model account lifecycle configuration is invalid"); + } + if ((getEncryptedCache === undefined) !== (decryptCache === undefined) + || (getEncryptedCache !== undefined && typeof getEncryptedCache !== "function") + || (decryptCache !== undefined && typeof decryptCache !== "function")) { + throw new TypeError("model account cache restoration configuration is invalid"); + } + + const generations = new Map(); + const queues = new Map(); + const unsaved = new Set(); + const currentGeneration = id => generations.get(id) ?? 0; + + function serialize(id, operation) { + const previous = queues.get(id) ?? Promise.resolve(); + const next = previous.catch(() => {}).then(operation); + queues.set(id, next); + return next.finally(() => { if (queues.get(id) === next) queues.delete(id); }); + } + + async function persist(id, generation) { + let account = await getAccount(id); + if (!account || account.id !== id || account.kind !== "model" || !Number.isSafeInteger(account.revision) + || typeof account.ownerId !== "string" || !account.ownerId || typeof account.keyGeneration !== "string" || !account.keyGeneration) { + throw new Error("model account persistence binding is unavailable"); + } + const unlockKey = await getUnlockKey(); + if (typeof unlockKey !== "string" || !unlockKey) throw new Error("proxy is locked"); + const plain = await readCache(cachePath(accountHomeRoot, id)); + if (!Buffer.isBuffer(plain) || plain.byteLength === 0 || plain.byteLength > 65_536) throw new Error("Codex token cache is missing or exceeds its limit"); + let staleRetries = 0; + let attempts = 0; + try { + for (;;) { + let encrypted; + try { + encrypted = await encryptCache(new Uint8Array(plain), unlockKey, { + ownerId: account.ownerId, + recordId: id, + recordType: "model", + destination: "factorize", + revision: account.revision + 1, + keyGeneration: account.keyGeneration, + }); + } catch (error) { + throw error; + } + if (currentGeneration(id) !== generation) throw new Error("model account changed before token persistence"); + const request = { id, expectedRevision: account.revision, keyGeneration: account.keyGeneration, encryptedPayload: encrypted, idempotencyKey: randomUUID() }; + let retryWithLatestRevision = false; + for (let attempt = 0; ; attempt++) { + if (currentGeneration(id) !== generation) throw new Error("model account changed before token persistence"); + attempts++; + try { + await persistEncrypted(request); + unsaved.delete(id); + onUnsaved({ accountId: id, unsaved: false }); + return { state: "saved" }; + } catch (error) { + if (error?.code === "stale_revision") { + if (staleRetries >= staleRevisionRetries) break; + let latest; + try { latest = await getAccount(id); } + catch { throw new Error("model account changed before token persistence"); } + if (!latest || latest.id !== id || latest.kind !== "model" || !Number.isSafeInteger(latest.revision) + || latest.revision <= account.revision || latest.ownerId !== account.ownerId + || latest.keyGeneration !== account.keyGeneration) { + throw new Error("model account changed before token persistence"); + } + account = latest; + staleRetries++; + retryWithLatestRevision = true; + break; + } + if (attempt >= retryDelaysMs.length) { + unsaved.add(id); + onUnsaved({ accountId: id, unsaved: true }); + return { state: "unsaved", attempts }; + } + await sleep(retryDelaysMs[attempt]); + if (currentGeneration(id) !== generation) throw new Error("model account changed before token persistence"); + let latest; + try { latest = await getAccount(id); } + catch { throw new Error("model account changed before token persistence"); } + if (!latest || latest.id !== id || latest.kind !== "model" || latest.ownerId !== account.ownerId + || latest.keyGeneration !== account.keyGeneration) { + throw new Error("model account changed before token persistence"); + } + } + } + if (!retryWithLatestRevision) { + unsaved.add(id); + onUnsaved({ accountId: id, unsaved: true }); + return { state: "unsaved", attempts }; + } + } + } finally { + plain.fill(0); + } + } + + async function refresh(id) { + return serialize(id, async () => { + const generation = currentGeneration(id); + const result = await authHelper.invoke({ method: "refresh", accountId: id }); + if (result.state === "reauthentication-required") return result; + return persist(id, generation); + }); + } + + async function deviceLogin(id) { + return serialize(id, async () => { + const generation = currentGeneration(id); + const result = await authHelper.invoke({ method: "device-login", accountId: id }); + if (result.state !== "pending" || !result.completion || typeof result.completion.then !== "function") throw new Error("Codex device authorization did not start"); + const completion = Promise.resolve(result.completion).then(async outcome => { + if (currentGeneration(id) !== generation) return { state: "deleted" }; + if (outcome?.state !== "authorized") return { state: outcome?.state ?? "reauthentication-required" }; + return persist(id, generation); + }); + return { state: "pending", challenge: result.challenge, completion }; + }); + } + + async function cancel(id) { + return serialize(id, async () => authHelper.invoke({ method: "cancel", accountId: id })); + } + + async function deleteAccount(id) { + if (!UUID.test(id ?? "")) throw new TypeError("invalid model account id"); + // Invalidate in-flight save retries immediately; cleanup then waits behind + // any already running helper process before deleting its cache. + generations.set(id, currentGeneration(id) + 1); + return serialize(id, async () => { + await authHelper.invoke({ method: "cancel", accountId: id }).catch(() => {}); + await authHelper.invoke({ method: "logout", accountId: id }).catch(() => {}); + await removeCache(resolve(accountHomeRoot, id), { recursive: true, force: true }); + unsaved.delete(id); + onUnsaved({ accountId: id, unsaved: false }); + return { state: "deleted" }; + }); + } + + async function restore(id) { + if (!getEncryptedCache || !decryptCache) throw new Error("model account cache restoration is unavailable"); + const account = await getAccount(id); + if (!account || account.id !== id || account.kind !== "model" || !Number.isSafeInteger(account.revision) + || typeof account.ownerId !== "string" || !account.ownerId || typeof account.keyGeneration !== "string" || !account.keyGeneration) { + await removeCache(resolve(accountHomeRoot, id), { recursive: true, force: true }); + return { state: "reauthentication-required" }; + } + const encrypted = await getEncryptedCache(id); + if (!encrypted || encrypted.accountId !== id || encrypted.ownerId !== account.ownerId + || encrypted.keyGeneration !== account.keyGeneration || !Number.isSafeInteger(encrypted.revision) + || typeof encrypted.encryptedPayload !== "string") { + await removeCache(resolve(accountHomeRoot, id), { recursive: true, force: true }); + return { state: "reauthentication-required" }; + } + const unlockKey = await getUnlockKey(); + if (typeof unlockKey !== "string" || !unlockKey) throw new Error("proxy is locked"); + let plain; + try { + plain = await decryptCache(encrypted.encryptedPayload, unlockKey, { + ownerId: account.ownerId, + recordId: id, + recordType: "model", + destination: "factorize", + revision: encrypted.revision, + keyGeneration: account.keyGeneration, + }); + if (!(plain instanceof Uint8Array) || plain.byteLength === 0 || plain.byteLength > 65_536) { + throw new Error("restored Codex token cache is invalid"); + } + const destination = cachePath(accountHomeRoot, id); + await makeDirectory(resolve(accountHomeRoot, id), { recursive: true, mode: 0o700 }); + const temporary = `${destination}.${randomUUID()}.tmp`; + try { + await writeCache(temporary, plain, { mode: 0o600, flag: "wx" }); + await replaceFile(temporary, destination); + } finally { + await rm(temporary, { force: true }); + } + } finally { + plain?.fill(0); + } + return { state: "restored", revision: encrypted.revision }; + } + + async function reconcile(id) { + return serialize(id, async () => { + if (!await getAccount(id)) { + await removeCache(resolve(accountHomeRoot, id), { recursive: true, force: true }); + unsaved.delete(id); + return { state: "deleted" }; + } + if (getEncryptedCache) { + const restored = await restore(id); + if (restored.state !== "restored") return restored; + } + const result = await authHelper.invoke({ method: "account-status", accountId: id }); + return result.state === "authenticated" ? persist(id, currentGeneration(id)) : { state: "reauthentication-required" }; + }); + } + + return Object.freeze({ + deviceLogin, + cancel, + refresh, + deleteAccount, + restore, + reconcile, + isUnsaved: id => unsaved.has(id), + /** Reconcile a complete server snapshot after unlock. A reset removes every + * prior model account from that snapshot, so stale local Codex homes must + * be logged out and removed before any current account is admitted. */ + reconcileAll: async accounts => { + if (!Array.isArray(accounts) || accounts.some(account => !account || !UUID.test(account.id ?? "") || account.kind !== "model") + || new Set(accounts.map(account => account.id)).size !== accounts.length) { + throw new TypeError("model account snapshot is invalid"); + } + const activeIds = new Set(accounts.map(account => account.id)); + const entries = await readDirectory(accountHomeRoot, { withFileTypes: true }).catch(error => { + if (error?.code === "ENOENT") return []; + throw error; + }); + const removed = []; + for (const entry of entries) { + if (!UUID.test(entry.name) || activeIds.has(entry.name)) continue; + await deleteAccount(entry.name); + removed.push(entry.name); + } + const states = []; + for (const account of accounts) states.push({ accountId: account.id, ...(await reconcile(account.id)) }); + return { removed, accounts: states }; + }, + }); +} + +export { cachePath as modelAccountCachePath }; diff --git a/packages/credential-proxy/src/proxy-listener.mjs b/packages/credential-proxy/src/proxy-listener.mjs new file mode 100644 index 0000000..3def152 --- /dev/null +++ b/packages/credential-proxy/src/proxy-listener.mjs @@ -0,0 +1,117 @@ +import { createServer } from "node:http"; +import { pipeline } from "node:stream/promises"; +import { createBearerForwarder } from "./bearer-forwarder.mjs"; + +const RESPONSE_HOP_BY_HOP = new Set([ + "connection", "keep-alive", "proxy-authenticate", "proxy-authorization", "te", + "trailer", "transfer-encoding", "upgrade", +]); + +function grantTable(grants) { + if (!Array.isArray(grants)) throw new TypeError("authenticated connection grants must be an array"); + const table = new Map(); + for (const grant of grants) { + if (!grant || typeof grant.id !== "string" || !grant.id || table.has(grant.id) + || typeof grant.origin !== "string" || typeof grant.credential !== "string" || !grant.credential) { + throw new TypeError("authenticated connection contains an invalid integration grant"); + } + table.set(grant.id, Object.freeze({ id: grant.id, origin: grant.origin, credential: grant.credential })); + } + return table; +} + +function requestTarget(origin, suffix, search) { + const base = new URL(origin); + if (base.protocol !== "https:" || base.pathname !== "/" || base.search || base.hash) { + throw new TypeError("integration grant origin must be an HTTPS origin"); + } + const path = suffix || "/"; + if (!path.startsWith("/") || path.startsWith("//") || path.includes("\\")) { + throw new TypeError("invalid integration request path"); + } + return `${base.origin}${path}${search}`; +} + +/** + * Start one loopback HTTP endpoint for a single already-authenticated tunnel + * connection. The connection's consumed grants are held in this closure and + * can never be selected or supplied by an HTTP caller. + */ +export async function createAuthenticatedProxyListener({ connection, forwarder = createBearerForwarder(), host = "127.0.0.1", port = 0 } = {}) { + if (!connection || connection.authenticated !== true || connection.enrollmentConsumed !== true + || !connection.connectionId || !connection.runId || !connection.vmId) { + throw new TypeError("proxy listener requires an authenticated consumed connection"); + } + const grants = grantTable(connection.grants); + const controllers = new Set(); + const server = createServer(async (request, response) => { + const controller = new AbortController(); + controllers.add(controller); + const cancel = () => { + if (!response.writableEnded) controller.abort(new Error("tunnel request cancelled")); + }; + request.once("aborted", cancel); + response.once("close", cancel); + try { + const parsed = new URL(request.url ?? "/", "http://proxy.loopback"); + const match = /^\/integrations\/([^/]+)(\/.*)?$/.exec(parsed.pathname); + if (!match) { + response.writeHead(404).end(); + return; + } + let integrationId; + try { integrationId = decodeURIComponent(match[1]); } catch { + response.writeHead(400).end(); + return; + } + if (!integrationId || integrationId.includes("/") || integrationId.includes("\\")) { + response.writeHead(400).end(); + return; + } + const integration = grants.get(integrationId); + if (!integration) { + response.writeHead(403).end(); + return; + } + const target = requestTarget(integration.origin, match[2] ?? "/", parsed.search); + const result = await forwarder.forward(integration, { + url: target, + method: request.method, + headers: request.headers, + body: request, + }, { signal: controller.signal }); + const headers = Object.create(null); + for (const [name, value] of Object.entries(result.headers ?? {})) { + if (value !== undefined && !RESPONSE_HOP_BY_HOP.has(name.toLowerCase())) headers[name] = value; + } + response.writeHead(result.status, headers); + await pipeline(result.body, response, { signal: controller.signal }); + } catch (error) { + if (!response.headersSent && !response.destroyed) { + response.writeHead(controller.signal.aborted ? 499 : 502, { "content-type": "text/plain; charset=utf-8" }); + response.end(controller.signal.aborted ? "request cancelled" : "upstream request failed"); + } else if (!response.destroyed) response.destroy(error); + } finally { + controllers.delete(controller); + request.removeListener("aborted", cancel); + response.removeListener("close", cancel); + } + }); + server.on("connect", (_request, socket) => { socket.end("HTTP/1.1 405 Method Not Allowed\r\nConnection: close\r\n\r\n"); }); + server.on("upgrade", (_request, socket) => socket.destroy()); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(port, host, () => { + server.removeListener("error", reject); + resolve(); + }); + }); + return Object.freeze({ + connectionId: connection.connectionId, + address: server.address(), + close: () => { + for (const controller of controllers) controller.abort(new Error("authenticated tunnel disconnected")); + return new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())); + }, + }); +} diff --git a/packages/credential-proxy/src/runtime.mjs b/packages/credential-proxy/src/runtime.mjs index e85401c..ac5db60 100644 --- a/packages/credential-proxy/src/runtime.mjs +++ b/packages/credential-proxy/src/runtime.mjs @@ -1,37 +1,93 @@ +import { createAuthenticatedProxyListener } from "./proxy-listener.mjs"; +import { randomUUID } from "node:crypto"; + /** - * Internal adapter contracts for the follow-up record, enrollment and forwarding - * slices. This runtime deliberately does not call or implement these adapters. - * - * @typedef {object} ProxyAdapters - * @property {(identity: object) => Promise} loadRecords - * @property {(request: object) => Promise} createEnrollment - * @property {(request: object) => Promise} forwardRequest + * Process-local runtime for decrypted records and authorized tunnel listeners. + * The transport adapter must pass a connection only after it has consumed and + * authenticated its enrollment against the current run and proxy boot epoch. */ -export function createProxyRuntime() { +export function createProxyRuntime({ listenerFactory = createAuthenticatedProxyListener, bootEpoch = randomUUID() } = {}) { + if (typeof bootEpoch !== "string" || !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(bootEpoch)) { + throw new TypeError("proxy boot epoch must be a UUID v4"); + } let unlockMaterial; let state = "locked"; let shutdown = false; + let locking = false; + const listeners = new Set(); + const listenerRuns = new Map(); + const revokedRuns = new Set(); + + async function closeListeners() { + const current = [...listeners]; + listeners.clear(); + listenerRuns.clear(); + await Promise.allSettled(current.map((listener) => listener.close())); + } return Object.freeze({ + // A fresh process identity invalidates enrollments from every prior boot. + bootEpoch, get state() { return state; }, unlock(key) { if (shutdown) throw new Error("runtime is shutting down"); + if (locking) throw new Error("proxy is locking"); if (typeof key !== "string" || key.length === 0) throw new TypeError("unlock key must be a non-empty string"); unlockMaterial?.fill(0); unlockMaterial = Buffer.from(key, "utf8"); state = "unlocked"; }, - lock() { + /** Return a transient copy only to the local grant resolver. */ + getUnlockKey() { + if (shutdown || locking || state !== "unlocked" || !unlockMaterial) throw new Error("proxy is locked"); + return unlockMaterial.toString("utf8"); + }, + async lock() { + locking = true; + state = "locked"; unlockMaterial?.fill(0); unlockMaterial = undefined; - if (!shutdown) state = "locked"; + try { await closeListeners(); } + finally { locking = false; } + }, + async acceptAuthenticatedConnection(connection) { + if (shutdown) throw new Error("runtime is shutting down"); + if (state !== "unlocked") throw new Error("proxy is locked"); + if (connection?.bootEpoch !== bootEpoch) throw new Error("enrollment belongs to a different proxy boot"); + if (typeof connection.runId !== "string" || !connection.runId) throw new TypeError("authenticated connection requires a run identity"); + if (revokedRuns.has(connection.runId)) throw new Error("run grants have been revoked"); + // The transport adapter calls this only after atomically consuming and + // authenticating enrollment. Grants are the consumed, current run state. + const listener = await listenerFactory({ connection }); + if (state !== "unlocked" || shutdown || revokedRuns.has(connection.runId)) { + await listener.close(); + throw new Error(revokedRuns.has(connection.runId) ? "run grants have been revoked" : "proxy locked while accepting the connection"); + } + listeners.add(listener); + // Keep the run identity beside the listener so a control-plane revocation + // can tear down every established connection without retaining grants. + listenerRuns.set(listener, connection.runId); + return listener; + }, + async disconnect(connectionId) { + const matching = [...listeners].filter((listener) => listener.connectionId === connectionId); + await Promise.allSettled(matching.map((listener) => listener.close())); + for (const listener of matching) { listeners.delete(listener); listenerRuns.delete(listener); } + }, + async revokeRun(runId) { + if (typeof runId !== "string" || !runId) throw new TypeError("run identity is required for revocation"); + revokedRuns.add(runId); + const matching = [...listeners].filter((listener) => listenerRuns.get(listener) === runId); + await Promise.allSettled(matching.map((listener) => listener.close())); + for (const listener of matching) { listeners.delete(listener); listenerRuns.delete(listener); } + return matching.length; }, - shutdown() { - this.lock(); + async shutdown() { shutdown = true; + await this.lock(); state = "stopped"; }, }); diff --git a/packages/credential-proxy/src/startup.mjs b/packages/credential-proxy/src/startup.mjs new file mode 100644 index 0000000..1eb7d16 --- /dev/null +++ b/packages/credential-proxy/src/startup.mjs @@ -0,0 +1,46 @@ +import { connect } from "node:net"; +import { once } from "node:events"; + +const SOCKET_PATH = "/run/factorize-proxy/bootstrap.sock"; +const MAX_BYTES = 4096; + +async function readRequest() { + let bytes = 0; + const chunks = []; + for await (const chunk of process.stdin) { + bytes += chunk.byteLength; + if (bytes > MAX_BYTES) throw new Error("invalid bootstrap request"); + chunks.push(chunk); + } + const data = Buffer.concat(chunks); + if (!data.byteLength || data[data.byteLength - 1] !== 10) throw new Error("invalid bootstrap request"); + return data; +} + +async function main() { + const request = await readRequest(); + const socket = connect(SOCKET_PATH); + const result = new Promise((resolve, reject) => { + let data = Buffer.alloc(0); + socket.on("data", chunk => { + data = Buffer.concat([data, chunk]); + const end = data.indexOf(10); + if (end >= 0) { + try { resolve(JSON.parse(data.subarray(0, end).toString("utf8"))); } + catch { reject(new Error("invalid bootstrap response")); } + socket.end(); + } + }); + socket.once("error", () => reject(new Error("proxy startup control unavailable"))); + socket.setTimeout(7000, () => reject(new Error("proxy startup control timed out"))); + }); + socket.write(request); + const response = await result; + if (response.registered !== true) throw new Error("proxy boot registration failed"); + process.stdout.write("Credential proxy started\n"); +} + +main().catch(() => { + process.stderr.write("Credential proxy startup failed\n"); + process.exitCode = 1; +}); diff --git a/packages/credential-proxy/src/tls-identity.mjs b/packages/credential-proxy/src/tls-identity.mjs new file mode 100644 index 0000000..933fde5 --- /dev/null +++ b/packages/credential-proxy/src/tls-identity.mjs @@ -0,0 +1,87 @@ +import { execFile as execFileCallback } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { chmod, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { promisify } from "node:util"; + +const execFile = promisify(execFileCallback); +const SAFE_VM_IDENTITY = /^[A-Za-z0-9:._-]{1,255}$/; +const SAFE_DNS_NAME = /^(?=.{1,253}$)(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)(?:\.(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?))*$/; + +async function openssl(args) { + try { + await execFile("openssl", args, { timeout: 10_000, maxBuffer: 64 * 1024, windowsHide: true }); + } catch { + // Never include command output or key material in errors. + throw new Error("proxy TLS identity operation failed"); + } +} + +function pem(bytes) { + return Buffer.from(bytes).toString("utf8"); +} + +/** + * Creates a per-process private CA and proxy server identity on a memory-backed + * filesystem. The CA private key never leaves this process or tmpfs. Job VMs + * generate their own private keys and submit CSRs; only their signed public + * certificate and the public trust root are returned for secure distribution. + */ +export async function createProxyTlsAuthority({ serverName, bootEpoch } = {}) { + if (typeof serverName !== "string" || !SAFE_DNS_NAME.test(serverName)) throw new TypeError("proxy TLS server name is invalid"); + if (typeof bootEpoch !== "string" || !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(bootEpoch)) throw new TypeError("proxy TLS boot epoch is invalid"); + const directory = await mkdtemp(join("/dev/shm", "factorize-proxy-tls-")); + const caKeyPath = join(directory, "ca-key.pem"); + const caCertPath = join(directory, "ca-cert.pem"); + const serverKeyPath = join(directory, "server-key.pem"); + const serverCertPath = join(directory, "server-cert.pem"); + try { + await openssl(["genpkey", "-algorithm", "EC", "-pkeyopt", "ec_paramgen_curve:P-256", "-out", caKeyPath]); + await chmod(caKeyPath, 0o600); + await openssl(["req", "-new", "-x509", "-key", caKeyPath, "-sha256", "-days", "1", "-subj", `/CN=factorize-proxy-${bootEpoch}`, "-addext", "basicConstraints=critical,CA:TRUE,pathlen:0", "-addext", "keyUsage=critical,keyCertSign,cRLSign", "-out", caCertPath]); + await openssl(["genpkey", "-algorithm", "EC", "-pkeyopt", "ec_paramgen_curve:P-256", "-out", serverKeyPath]); + await chmod(serverKeyPath, 0o600); + await openssl(["req", "-new", "-key", serverKeyPath, "-subj", `/CN=${serverName}`, "-out", join(directory, "server.csr")]); + await writeFile(join(directory, "server.ext"), `basicConstraints=critical,CA:FALSE\nkeyUsage=critical,digitalSignature,keyAgreement\nextendedKeyUsage=serverAuth\nsubjectAltName=DNS:${serverName}\n`, { mode: 0o600 }); + await openssl(["x509", "-req", "-in", join(directory, "server.csr"), "-CA", caCertPath, "-CAkey", caKeyPath, "-CAcreateserial", "-days", "1", "-sha256", "-extfile", join(directory, "server.ext"), "-out", serverCertPath]); + const ca = await readFile(caCertPath); + const key = await readFile(serverKeyPath); + const cert = await readFile(serverCertPath); + + let closed = false; + return Object.freeze({ + tls: Object.freeze({ key, cert, ca }), + trustBundle: pem(ca), + async signVmCsr({ vmIdentity, csr } = {}) { + if (closed) throw new Error("proxy TLS identity authority is closed"); + if (typeof vmIdentity !== "string" || !SAFE_VM_IDENTITY.test(vmIdentity) + || typeof csr !== "string" || csr.length < 100 || csr.length > 16_384 + || !csr.startsWith("-----BEGIN CERTIFICATE REQUEST-----\n") + || !csr.trimEnd().endsWith("-----END CERTIFICATE REQUEST-----")) { + throw new TypeError("invalid VM certificate request"); + } + const identityUri = `urn:factorize:vm:${Buffer.from(vmIdentity, "utf8").toString("base64url")}`; + const csrPath = join(directory, `vm-${randomUUID()}.csr`); + const certPath = join(directory, `vm-${randomUUID()}.pem`); + const extensionPath = join(directory, `vm-${randomUUID()}.ext`); + await writeFile(csrPath, csr, { mode: 0o600 }); + await writeFile(extensionPath, `basicConstraints=critical,CA:FALSE\nkeyUsage=critical,digitalSignature,keyAgreement\nextendedKeyUsage=clientAuth\nsubjectAltName=URI:${identityUri}\n`, { mode: 0o600 }); + try { + await openssl(["req", "-in", csrPath, "-verify", "-noout"]); + await openssl(["x509", "-req", "-in", csrPath, "-CA", caCertPath, "-CAkey", caKeyPath, "-CAcreateserial", "-days", "1", "-sha256", "-subj", `/CN=${vmIdentity}`, "-extfile", extensionPath, "-out", certPath]); + return Object.freeze({ certificate: pem(await readFile(certPath)), trustBundle: pem(ca) }); + } finally { + await Promise.all([rm(csrPath, { force: true }), rm(certPath, { force: true }), rm(extensionPath, { force: true })]); + } + }, + async close() { + if (closed) return; + closed = true; + await rm(directory, { recursive: true, force: true }); + }, + }); + } catch (error) { + await rm(directory, { recursive: true, force: true }); + throw error; + } +} diff --git a/packages/credential-proxy/src/tunnel-transport.mjs b/packages/credential-proxy/src/tunnel-transport.mjs new file mode 100644 index 0000000..d61f6b9 --- /dev/null +++ b/packages/credential-proxy/src/tunnel-transport.mjs @@ -0,0 +1,232 @@ +import { connect as connectTls, createServer as createTlsServer } from "node:tls"; +import { connect as connectTcp, createServer as createTcpServer } from "node:net"; +import { once } from "node:events"; +import { createProxyEnrollmentAuthenticator } from "./control-client.mjs"; + +const HANDSHAKE_LIMIT = 4096; +const HANDSHAKE_TIMEOUT_MS = 10_000; +const CONTROL_RETRY_WINDOW_MS = 5 * 60_000; +const CONTROL_RETRY_MAX_MS = 30_000; +const CONTROL_RETRY_SLOW_MS = 60_000; + +export function peerIdentity(socket) { + const certificate = socket.getPeerCertificate(); + if (!socket.authorized || !certificate?.subject?.CN || typeof certificate.subjectaltname !== "string") { + throw new Error("tunnel client certificate is not authorized"); + } + const identities = certificate.subjectaltname.split(/,\s*/).filter(value => value.startsWith("URI:")); + if (identities.length !== 1) throw new Error("tunnel client certificate must contain one VM identity URI"); + const match = /^URI:urn:factorize:vm:([A-Za-z0-9_-]+)$/.exec(identities[0]); + if (!match) throw new Error("tunnel client certificate has an invalid VM identity URI"); + const identityBytes = Buffer.from(match[1], "base64url"); + const identity = identityBytes.toString("utf8"); + if (!identity || identityBytes.toString("base64url") !== match[1] + || identity.length > 255 || /[\u0000-\u001f\u007f]/.test(identity) + || certificate.subject.CN !== identity) { + throw new Error("tunnel client certificate identity is invalid or inconsistent"); + } + return identity; +} + +/** + * Accept only a TLS client with a trusted VM certificate, consume its one-use + * enrollment, and bridge that stream to the connection-scoped HTTP listener. + * The bridge does not expose a shell or arbitrary destination forwarding. + */ +export function createAuthenticatedTunnelServer({ tls, runtime, authenticateEnrollment, authenticateRun, host = "127.0.0.1", port = 0 } = {}) { + if (!tls || !runtime || (typeof authenticateEnrollment !== "function" && typeof authenticateRun !== "function")) throw new TypeError("tunnel server requires TLS, runtime and enrollment authentication"); + const connections = new Set(); + const server = createTlsServer({ ...tls, requestCert: true, rejectUnauthorized: true }, (socket) => { + connections.add(socket); + let handshake = Buffer.alloc(0); + let settled = false; + const timeout = setTimeout(() => socket.destroy(new Error("tunnel enrollment timed out")), HANDSHAKE_TIMEOUT_MS); + timeout.unref(); + const reject = () => { + if (settled) return; + settled = true; + clearTimeout(timeout); + socket.destroy(); + }; + socket.on("data", async (chunk) => { + if (settled) return; + handshake = Buffer.concat([handshake, chunk]); + const end = handshake.indexOf("\n"); + if (end < 0) return handshake.length > HANDSHAKE_LIMIT ? reject() : undefined; + if (end > HANDSHAKE_LIMIT) return reject(); + settled = true; + clearTimeout(timeout); + socket.pause(); + const line = handshake.subarray(0, end).toString("utf8").replace(/\r$/, ""); + const rest = handshake.subarray(end + 1); + const match = /^FACTORIZE-TUNNEL\/1 ([A-Za-z0-9_-]{32,128})$/.exec(line); + const runMatch = /^FACTORIZE-TUNNEL\/2 ([0-9a-f-]{36}) (fzt_run_[A-Za-z0-9_-]{43,200})$/i.exec(line); + if ((!match || typeof authenticateEnrollment !== "function") && (!runMatch || typeof authenticateRun !== "function")) return reject(); + let listener; + let target; + try { + const vmIdentity = peerIdentity(socket); + const connection = runMatch + ? await authenticateRun({ runId: runMatch[1], runCredential: runMatch[2], vmIdentity, bootEpoch: runtime.bootEpoch }) + : await authenticateEnrollment({ token: match[1], vmIdentity, bootEpoch: runtime.bootEpoch }); + // The production control plane returns the consumed enrollment binding. + // Compare its VM and boot identities with the authenticated TLS peer and + // this process so an adapter cannot accidentally attach another run's + // grants to the tunnel. + if (!connection || connection.authenticated !== true || connection.enrollmentConsumed !== true + || connection.vmIdentity !== peerIdentity(socket) || connection.bootEpoch !== runtime.bootEpoch + || typeof connection.connectionId !== "string" || connection.connectionId.length === 0) { + throw new Error("enrollment binding does not match the authenticated tunnel"); + } + listener = await runtime.acceptAuthenticatedConnection(connection); + const address = listener.address; + target = connectTcp(address.port, address.address); + await once(target, "connect"); + if (rest.length) target.write(rest); + socket.write("FACTORIZE-TUNNEL/1 ACCEPTED\n"); + socket.pipe(target); + target.pipe(socket); + socket.resume(); + const close = () => { + socket.unpipe(target); + target.unpipe(socket); + target.destroy(); + void runtime.disconnect(connection.connectionId); + }; + socket.once("close", close); + target.once("close", close); + } catch { + listener && await runtime.disconnect(listener.connectionId); + socket.destroy(); + } + }); + socket.once("close", () => { clearTimeout(timeout); connections.delete(socket); }); + socket.once("error", () => {}); + }); + server.listen(port, host); + return { + server, + listening: once(server, "listening"), + address: () => server.address(), + close: async () => { + for (const socket of connections) socket.destroy(); + await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())); + }, + }; +} + +/** Start the proxy's production-shaped mTLS listener using its boot-registered + * control credential and process-local decrypted integration grants. */ +export async function startAuthenticatedProxyTunnel({ + tls, runtime, controlOrigin, controlCredential, resolveGrants, fetchImpl = fetch, host = "127.0.0.1", port = 0, +} = {}) { + const authenticateRun = createProxyEnrollmentAuthenticator({ + controlOrigin, controlCredential, bootEpoch: runtime?.bootEpoch, resolveGrants, fetchImpl, + }); + const tunnel = createAuthenticatedTunnelServer({ tls, runtime, authenticateRun, host, port }); + try { + await tunnel.listening; + return tunnel; + } catch (error) { + await tunnel.close().catch(() => {}); + throw error; + } +} + +/** Start a loopback-only HTTP tunnel helper. Each accepted stream gets fresh enrollment. */ +export async function startVmTunnelHelper({ tls, enrollment, runId, runCredential, proxyHost, proxyPort, host = "127.0.0.1", port = 0, + now = Date.now, random = Math.random, wait = delay } = {}) { + const runBasedEnrollment = typeof runId === "string" && /^[0-9a-f-]{36}$/i.test(runId); + if (!tls || (!runBasedEnrollment && typeof enrollment !== "function") || (runBasedEnrollment && !/^fzt_run_[A-Za-z0-9_-]{43,200}$/.test(runCredential ?? "")) + || typeof proxyHost !== "string" || !Number.isInteger(proxyPort)) { + throw new TypeError("VM tunnel helper requires TLS, enrollment and proxy address"); + } + const sockets = new Set(); + const server = createTcpServer((local) => { + sockets.add(local); + local.pause(); + void (async () => { + const startedAt = now(); + let attempt = 0; + while (!local.destroyed) { + let remote; + try { + const grant = runBasedEnrollment ? undefined : await enrollment(); + if (local.destroyed) return; + if (!runBasedEnrollment && (!grant || typeof grant.token !== "string" || !/^[A-Za-z0-9_-]{32,128}$/.test(grant.token))) throw new Error("fresh enrollment unavailable"); + remote = connectTls({ ...tls, host: proxyHost, port: proxyPort, servername: tls.servername ?? proxyHost, rejectUnauthorized: true }); + sockets.add(remote); + const connectTimer = setTimeout(() => remote.destroy(new Error("proxy TLS connection timed out")), HANDSHAKE_TIMEOUT_MS); + try { await once(remote, "secureConnect"); } + finally { clearTimeout(connectTimer); } + remote.write(runBasedEnrollment ? `FACTORIZE-TUNNEL/2 ${runId} ${runCredential}\n` : `FACTORIZE-TUNNEL/1 ${grant.token}\n`); + await waitForTunnelAcceptance(remote); + local.pipe(remote); + remote.pipe(local); + local.resume(); + remote.once("close", () => { sockets.delete(remote); local.destroy(); }); + local.once("close", () => remote.destroy()); + return; + } catch { + remote?.destroy(); + if (remote) sockets.delete(remote); + if (local.destroyed) return; + const elapsed = now() - startedAt; + const base = elapsed < CONTROL_RETRY_WINDOW_MS + ? Math.min(1000 * (2 ** Math.min(attempt, 5)), CONTROL_RETRY_MAX_MS) + : CONTROL_RETRY_SLOW_MS; + const jittered = Math.round(base * (0.5 + random())); + const delayMs = Math.max(1, elapsed < CONTROL_RETRY_WINDOW_MS ? Math.min(CONTROL_RETRY_MAX_MS, jittered) : jittered); + attempt++; + try { await wait(delayMs); } catch { local.destroy(); return; } + } + } + })(); + const remove = () => { sockets.delete(local); }; + local.once("close", remove); + local.once("error", remove); + }); + server.listen(port, host); + await once(server, "listening"); + const address = server.address(); + return Object.freeze({ + address, + close: async () => { + for (const socket of sockets) socket.destroy(); + await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())); + }, + }); +} + +function delay(milliseconds) { + return new Promise(resolve => setTimeout(resolve, milliseconds)); +} + +function waitForTunnelAcceptance(socket) { + return new Promise((resolve, reject) => { + let response = Buffer.alloc(0); + const cleanup = () => { + clearTimeout(timer); + socket.removeListener("data", onData); + socket.removeListener("error", onError); + socket.removeListener("close", onClose); + }; + const onError = error => { cleanup(); reject(error); }; + const onClose = () => { cleanup(); reject(new Error("proxy closed before accepting the tunnel")); }; + const onData = chunk => { + response = Buffer.concat([response, chunk]); + if (response.length > HANDSHAKE_LIMIT) { cleanup(); reject(new Error("invalid proxy tunnel response")); return; } + const end = response.indexOf(10); + if (end < 0) return; + const accepted = response.subarray(0, end).toString("utf8") === "FACTORIZE-TUNNEL/1 ACCEPTED"; + const trailingData = response.subarray(end + 1); + cleanup(); + if (!accepted || trailingData.length) reject(new Error("proxy tunnel was rejected")); + else resolve(); + }; + const timer = setTimeout(() => { cleanup(); socket.destroy(); reject(new Error("proxy tunnel acceptance timed out")); }, HANDSHAKE_TIMEOUT_MS); + socket.on("data", onData); + socket.once("error", onError); + socket.once("close", onClose); + }); +} diff --git a/packages/credential-proxy/src/vm-certificate.mjs b/packages/credential-proxy/src/vm-certificate.mjs new file mode 100644 index 0000000..a06a743 --- /dev/null +++ b/packages/credential-proxy/src/vm-certificate.mjs @@ -0,0 +1,61 @@ +import { connect } from "node:net"; + +const SOCKET_PATH = "/run/factorize-proxy/bootstrap.sock"; +const MAX_REQUEST_BYTES = 20 * 1024; +const MAX_RESPONSE_BYTES = 40 * 1024; + +async function readRequest() { + let length = 0; + const chunks = []; + for await (const chunk of process.stdin) { + length += chunk.byteLength; + if (length > MAX_REQUEST_BYTES) throw new Error("invalid certificate request"); + chunks.push(chunk); + } + if (!length || chunks.at(-1).at(-1) !== 10) throw new Error("invalid certificate request"); + const value = JSON.parse(Buffer.concat(chunks).toString("utf8")); + if (!value || typeof value !== "object" || Array.isArray(value) + || Object.keys(value).sort().join(",") !== "bootEpoch,csr,vmIdentity" + || typeof value.bootEpoch !== "string" || typeof value.csr !== "string" || typeof value.vmIdentity !== "string") { + throw new Error("invalid certificate request"); + } + return { type: "issue-vm-certificate", ...value }; +} + +async function send(request) { + const socket = connect(SOCKET_PATH); + try { + return await new Promise((resolve, reject) => { + let data = Buffer.alloc(0); + socket.on("data", chunk => { + data = Buffer.concat([data, chunk]); + if (data.length > MAX_RESPONSE_BYTES) { + reject(new Error("invalid certificate response")); + socket.destroy(); + return; + } + const end = data.indexOf(10); + if (end < 0) return; + try { resolve(JSON.parse(data.subarray(0, end).toString("utf8"))); } + catch { reject(new Error("invalid certificate response")); } + socket.end(); + }); + socket.once("error", () => reject(new Error("proxy certificate service unavailable"))); + socket.setTimeout(7000, () => reject(new Error("proxy certificate service timed out"))); + socket.once("connect", () => socket.write(`${JSON.stringify(request)}\n`)); + }); + } finally { + socket.destroy(); + } +} + +try { + const request = await readRequest(); + const result = await send(request); + if (result?.issued !== true || result.bootEpoch !== request.bootEpoch || result.vmIdentity !== request.vmIdentity + || typeof result.certificate !== "string" || typeof result.trustBundle !== "string") throw new Error("VM certificate was not issued"); + process.stdout.write(`${JSON.stringify({ bootEpoch: result.bootEpoch, vmIdentity: result.vmIdentity, certificate: result.certificate, trustBundle: result.trustBundle })}\n`); +} catch { + process.stderr.write("VM certificate provisioning failed\n"); + process.exitCode = 1; +} diff --git a/packages/credential-proxy/test/bearer-forwarder.test.mjs b/packages/credential-proxy/test/bearer-forwarder.test.mjs new file mode 100644 index 0000000..9284b49 --- /dev/null +++ b/packages/credential-proxy/test/bearer-forwarder.test.mjs @@ -0,0 +1,79 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { Readable } from "node:stream"; +import { createBearerForwarder } from "../src/bearer-forwarder.mjs"; + +const integration = { id: "integration-1", origin: "https://api.example.test", credential: "synthetic-token" }; + +test("forwards only through the granted origin and replaces unsafe routing and authorization headers", async () => { + const connections = []; + const calls = []; + const forwarder = createBearerForwarder({ + connectorFactory: origin => async url => { + connections.push({ origin, url }); + return { socket: { fixture: true }, url: new URL(url) }; + }, + request: async request => { calls.push(request); return { status: 201, headers: {}, body: Readable.from([Buffer.from("created")]) }; }, + }); + const result = await forwarder.forward(integration, { + method: "post", + url: "https://api.example.test/v1/items?source=job", + headers: { + Authorization: "Bearer attacker-token", + Host: "attacker.example", + Connection: "keep-alive, x-routing-hint", + "X-Routing-Hint": "attacker", + "X-Forwarded-Host": "attacker.example", + "Content-Type": "application/json", + }, + body: Buffer.from('{"ok":true}'), + }); + assert.equal(result.status, 201); + assert.deepEqual(Buffer.concat(await Array.fromAsync(result.body)), Buffer.from("created")); + assert.deepEqual(connections, [{ origin: integration.origin, url: "https://api.example.test/v1/items?source=job" }]); + assert.equal(calls[0].method, "POST"); + assert.equal(calls[0].headers.authorization, "Bearer synthetic-token"); + assert.equal(calls[0].headers.host, undefined); + assert.equal(calls[0].headers["x-routing-hint"], undefined); + assert.equal(calls[0].headers["x-forwarded-host"], undefined); + assert.equal(calls[0].headers["content-type"], "application/json"); +}); + +test("rejects requests outside the grant origin without sending them upstream", async () => { + let sent = false; + const forwarder = createBearerForwarder({ + connectorFactory: () => async () => { throw new TypeError("request URL does not match the configured HTTPS origin"); }, + request: async () => { sent = true; }, + }); + await assert.rejects(forwarder.forward(integration, { url: "https://169.254.169.254/latest/meta-data" }), /configured HTTPS origin/); + assert.equal(sent, false); + assert.equal(forwarder.activeRequests, 0); +}); + +test("applies an explicit per-proxy concurrent request bound", async () => { + let release; + const gate = new Promise(resolve => { release = resolve; }); + const forwarder = createBearerForwarder({ + connectorFactory: () => async url => ({ socket: {}, url: new URL(url) }), + request: async () => gate, + limits: { requestMs: 10_000 }, + }); + const started = []; + for (let i = 0; i < 16; i++) started.push(forwarder.forward(integration, { url: "https://api.example.test/" })); + await new Promise(resolve => setImmediate(resolve)); + await assert.rejects(forwarder.forward(integration, { url: "https://api.example.test/" }), /concurrent request limit/); + release({ status: 200 }); + await Promise.all(started); + assert.equal(forwarder.activeRequests, 0); +}); + +test("rejects oversized caller headers before upstream dispatch", async () => { + let sent = false; + const forwarder = createBearerForwarder({ + connectorFactory: () => async url => ({ socket: {}, url: new URL(url) }), + request: async () => { sent = true; }, + limits: { headerBytes: 20 }, + }); + await assert.rejects(forwarder.forward(integration, { url: "https://api.example.test/", headers: { "x-large": "x".repeat(50) } }), /headers exceed/); + assert.equal(sent, false); +}); diff --git a/packages/credential-proxy/test/bootstrap-control.test.mjs b/packages/credential-proxy/test/bootstrap-control.test.mjs new file mode 100644 index 0000000..de122d5 --- /dev/null +++ b/packages/credential-proxy/test/bootstrap-control.test.mjs @@ -0,0 +1,85 @@ +import { strict as assert } from "node:assert"; +import { afterEach, test } from "node:test"; +import { connect } from "node:net"; +import { mkdtemp, rm, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { once } from "node:events"; +import { startProxyBootstrapControl } from "../src/bootstrap-control.mjs"; + +const epoch = "11111111-1111-4111-8111-111111111111"; +const token = `fzc_${"a".repeat(43)}`; +let directory; +let service; +afterEach(async () => { + await service?.close(); service = undefined; + if (directory) await rm(directory, { recursive: true, force: true }); + directory = undefined; +}); + +async function fixture(register, onRegistered, issueVmCertificate) { + directory = await mkdtemp(join(tmpdir(), "factorize-proxy-startup-")); + const socketPath = join(directory, "control.sock"); + const runtime = { bootEpoch: epoch }; + service = await startProxyBootstrapControl({ runtime, socketPath, register, onRegistered, issueVmCertificate }); + return socketPath; +} + +async function request(socketPath, payload) { + const socket = connect(socketPath); + let response = ""; + socket.on("data", chunk => { response += chunk.toString(); }); + await once(socket, "connect"); + socket.end(`${JSON.stringify(payload)}\n`); + await once(socket, "close"); + return JSON.parse(response); +} + +test("local startup control redeems the one-use credential and leaves a private socket", async () => { + const calls = []; + const registered = []; + const socketPath = await fixture( + async value => { calls.push(value); return { proxyIdentity: "fly:app:machine", bootEpoch: epoch, controlCredential: token }; }, + (value, request) => registered.push({ value, request }), + ); + const result = await request(socketPath, { type: "register-boot", controlOrigin: "https://factorize.example", browserOrigin: "https://proxy.example", bootEpoch: epoch, credential: token }); + assert.deepEqual(result, { registered: true, proxyIdentity: "fly:app:machine", bootEpoch: epoch }); + assert.deepEqual(calls, [{ controlOrigin: "https://factorize.example", token, bootEpoch: epoch, browserOrigin: "https://proxy.example" }]); + assert.deepEqual(registered, [{ + value: { proxyIdentity: "fly:app:machine", bootEpoch: epoch, controlCredential: token }, + request: { controlOrigin: "https://factorize.example", browserOrigin: "https://proxy.example", bootEpoch: epoch }, + }]); + assert.equal(JSON.stringify(result).includes(token), false, "startup response never returns the proxy control credential"); + assert.equal((await stat(socketPath)).mode & 0o777, 0o600); +}); + +test("local startup control rejects run credentials, epoch substitution, and extra fields", async () => { + const calls = []; + const socketPath = await fixture(async value => { calls.push(value); return {}; }); + for (const payload of [ + { type: "register-boot", controlOrigin: "https://factorize.example", browserOrigin: "https://proxy.example", bootEpoch: epoch, credential: `fzt_run_${"b".repeat(32)}` }, + { type: "register-boot", controlOrigin: "https://factorize.example", browserOrigin: "https://proxy.example", bootEpoch: "22222222-2222-4222-8222-222222222222", credential: token }, + { type: "register-boot", controlOrigin: "https://factorize.example", browserOrigin: "https://proxy.example", bootEpoch: epoch, credential: token, tenantId: "attacker" }, + ]) assert.deepEqual(await request(socketPath, payload), { error: "invalid bootstrap request" }); + assert.deepEqual(calls, []); +}); + +test("local provider channel returns only a boot-bound VM certificate and public trust bundle", async () => { + const csr = `-----BEGIN CERTIFICATE REQUEST-----\n${"A".repeat(120)}\n-----END CERTIFICATE REQUEST-----`; + let registered = false; + const socketPath = await fixture( + async () => ({ proxyIdentity: "fly:app:machine", bootEpoch: epoch, controlCredential: token }), + async () => { registered = true; }, + async request => { + assert.equal(registered, true); + return { certificate: `cert-for:${request.vmIdentity}`, trustBundle: "public-ca" }; + }, + ); + const payload = { type: "issue-vm-certificate", bootEpoch: epoch, vmIdentity: "fly:job-1", csr }; + assert.deepEqual(await request(socketPath, payload), { error: "certificate provisioning failed" }); + await request(socketPath, { type: "register-boot", controlOrigin: "https://factorize.example", browserOrigin: "https://proxy.example", bootEpoch: epoch, credential: token }); + assert.deepEqual(await request(socketPath, payload), { + issued: true, bootEpoch: epoch, vmIdentity: "fly:job-1", certificate: "cert-for:fly:job-1", trustBundle: "public-ca", + }); + assert.equal(JSON.stringify(await request(socketPath, payload)).includes(token), false); +}); diff --git a/packages/credential-proxy/test/browser-control.test.mjs b/packages/credential-proxy/test/browser-control.test.mjs new file mode 100644 index 0000000..918c699 --- /dev/null +++ b/packages/credential-proxy/test/browser-control.test.mjs @@ -0,0 +1,76 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { generateKeyPairSync, sign } from "node:crypto"; +import { startBrowserControl } from "../src/browser-control.mjs"; + +const ownerId = "11111111-1111-4111-8111-111111111111"; +const accountId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; +const proxyId = "fly:factorize-proxy:machine-1"; +const generation = 4; +const appOrigin = "https://factorize.example"; +const { publicKey, privateKey } = generateKeyPairSync("ed25519"); + +function capability({ action, exp = 1_900_000_030, aud = proxyId, sub = ownerId, origin = appOrigin, gen = generation, account = accountId, jti = `nonce-${crypto.randomUUID()}` }) { + const head = Buffer.from(JSON.stringify({ alg: "EdDSA", typ: "JWT" })).toString("base64url"); + const claims = Buffer.from(JSON.stringify({ iss: "factorize", aud, sub, action, origin, generation: gen, exp, accountId: account, jti })).toString("base64url"); + const input = `${head}.${claims}`; + return `${input}.${sign(null, Buffer.from(input), privateKey).toString("base64url")}`; +} + +test("private browser control requires exact origin and one-use owner/proxy/action capabilities", async t => { + let now = 1_900_000_000; + const calls = []; + const runtime = { state: "unlocked" }; + const lifecycle = { + async deviceLogin(id) { calls.push(["authorize", id]); return { state: "pending", challenge: { verificationUrl: "https://auth.openai.com/codex/device", userCode: "ABCD-1234" }, completion: Promise.resolve({ state: "saved" }) }; }, + async cancel(id) { calls.push(["cancel", id]); return { state: "canceled" }; }, + }; + const server = await startBrowserControl({ runtime, lifecycle, publicKey, proxyId, ownerId, generation, appOrigin, port: 0, host: "127.0.0.1", now: () => now }); + t.after(() => server.close()); + const origin = `http://127.0.0.1:${server.address.port}`; + const post = (action, token, path = `/model-accounts/${accountId}/${action.split(":")[1]}`, requestOrigin = appOrigin) => fetch(origin + path, { + method: "POST", headers: { Origin: requestOrigin, "Content-Type": "application/json", Authorization: `Bearer ${token}` }, body: "{}", + }); + + const preflight = await fetch(origin + `/model-accounts/${accountId}/authorize`, { + method: "OPTIONS", headers: { Origin: appOrigin, "Access-Control-Request-Method": "POST", "Access-Control-Request-Headers": "content-type,authorization" }, + }); + assert.equal(preflight.status, 204); + assert.equal(preflight.headers.get("access-control-allow-origin"), appOrigin); + const foreign = await fetch(origin + `/model-accounts/${accountId}/authorize`, { method: "OPTIONS", headers: { Origin: "https://attacker.example", "Access-Control-Request-Method": "POST", "Access-Control-Request-Headers": "authorization,content-type" } }); + assert.equal(foreign.status, 403); + assert.equal(foreign.headers.get("access-control-allow-origin"), null); + + const authToken = capability({ action: "model-account:authorize" }); + const started = await post("model-account:authorize", authToken); + assert.equal(started.status, 202); + assert.deepEqual(await started.json(), { state: "pending", verificationUrl: "https://auth.openai.com/codex/device", userCode: "ABCD-1234" }); + assert.deepEqual(calls[0], ["authorize", accountId]); + const replay = await post("model-account:authorize", authToken); + assert.equal(replay.status, 401); + + const status = await post("model-account:status", capability({ action: "model-account:status" })); + assert.deepEqual(await status.json(), { state: "authorized" }); + const wrongAudience = await post("model-account:cancel", capability({ action: "model-account:cancel", aud: "fly:other:machine" })); + assert.equal(wrongAudience.status, 401); + const expired = await post("model-account:cancel", capability({ action: "model-account:cancel", exp: now - 1 })); + assert.equal(expired.status, 401); + const wrongOrigin = await post("model-account:cancel", capability({ action: "model-account:cancel" }), undefined, "https://attacker.example"); + assert.equal(wrongOrigin.status, 403); + + const canceled = await post("model-account:cancel", capability({ action: "model-account:cancel" })); + assert.deepEqual(await canceled.json(), { state: "canceled" }); + assert.deepEqual(calls.at(-1), ["cancel", accountId]); + now += 61; +}); + +test("authorization is refused while locked and expiry remains bounded to one minute", async t => { + const runtime = { state: "locked" }; + const lifecycle = { async deviceLogin() { assert.fail("locked proxy must not start authorization"); }, async cancel() { return { state: "idle" }; } }; + const server = await startBrowserControl({ runtime, lifecycle, publicKey, proxyId, ownerId, generation, appOrigin, port: 0, host: "127.0.0.1", now: () => 1_900_000_000 }); + t.after(() => server.close()); + const response = await fetch(`http://127.0.0.1:${server.address.port}/model-accounts/${accountId}/authorize`, { + method: "POST", headers: { Origin: appOrigin, "Content-Type": "application/json", Authorization: `Bearer ${capability({ action: "model-account:authorize", exp: 1_900_000_061 })}` }, body: "{}", + }); + assert.equal(response.status, 401); +}); diff --git a/packages/credential-proxy/test/codex-auth-helper.test.mjs b/packages/credential-proxy/test/codex-auth-helper.test.mjs new file mode 100644 index 0000000..9528f76 --- /dev/null +++ b/packages/credential-proxy/test/codex-auth-helper.test.mjs @@ -0,0 +1,87 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { EventEmitter } from "node:events"; +import { PassThrough } from "node:stream"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createCodexAuthHelper, CODEX_AUTH_HELPER_VERSION } from "../src/codex-auth-helper.mjs"; + +const accountA = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; +const accountB = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"; + +function fakeCodex() { + const calls = [], children = []; + const spawnProcess = (command, args, options) => { + calls.push({ command, args, options }); + const child = new EventEmitter(); + child.stdout = new PassThrough(); + child.stderr = new PassThrough(); + child.stdin = new PassThrough(); + child.kill = signal => { + child.killed = signal; + queueMicrotask(() => child.emit("close", null, signal)); + return true; + }; + children.push(child); + if (args[0] !== "login" || args[1] !== "--device-auth") { + queueMicrotask(() => { + child.stdout.end(args[0] === "--version" ? `${CODEX_AUTH_HELPER_VERSION}\n` : "authenticated\n"); + child.stderr.end(); + child.emit("close", 0, null); + }); + } + return child; + }; + return { calls, children, spawnProcess }; +} + +test("Codex auth helper constrains commands and isolates account credential homes", async () => { + const directory = await mkdtemp(join(tmpdir(), "factorize-codex-auth-")); + try { + const fake = fakeCodex(); + const helper = createCodexAuthHelper({ codexPath: "/opt/codex", accountHomeRoot: directory, spawnProcess: fake.spawnProcess }); + assert.deepEqual(await helper.invoke({ method: "account-status", accountId: accountA }), { state: "authenticated" }); + assert.deepEqual(await helper.invoke({ method: "refresh", accountId: accountB }), { state: "refreshed" }); + assert.deepEqual(fake.calls.map(call => call.args), [ + ["--version"], ["login", "status"], ["--version"], ["login", "status"], + ]); + assert.ok(fake.calls.every(call => call.command === "/opt/codex")); + assert.equal(fake.calls[0].options.env.CODEX_HOME, join(directory, accountA)); + assert.equal(fake.calls[3].options.env.CODEX_HOME, join(directory, accountB)); + assert.equal(fake.calls[0].options.env.HOME, join(directory, accountA)); + assert.equal("OPENAI_API_KEY" in fake.calls[0].options.env, false); + await assert.rejects(helper.invoke({ method: "exec", accountId: accountA, args: ["--dangerously-bypass-approvals-and-sandbox"] }), /unsupported Codex authentication operation/); + await assert.rejects(helper.invoke({ method: "logout", accountId: "../../outside" }), /invalid model account id/); + } finally { await rm(directory, { recursive: true, force: true }); } +}); + +test("device authorization runs as a separate cancellable login process", async () => { + const directory = await mkdtemp(join(tmpdir(), "factorize-codex-auth-")); + try { + const fake = fakeCodex(); + const helper = createCodexAuthHelper({ accountHomeRoot: directory, spawnProcess: fake.spawnProcess }); + const result = await helper.invoke({ method: "device-login", accountId: accountA }); + assert.equal(result.state, "pending"); + assert.deepEqual(result.challenge, {}); + assert.deepEqual(fake.calls.map(call => call.args), [["--version"], ["login", "--device-auth"]]); + assert.equal(fake.calls[1].options.env.CODEX_HOME, join(directory, accountA)); + fake.children[1].stdout.write("Open this URL in your browser: https://auth.openai.com/codex/device\nEnter user code: abcd-1234\n"); + assert.deepEqual(result.challenge, { verificationUrl: "https://auth.openai.com/codex/device", userCode: "ABCD-1234" }); + assert.deepEqual(await helper.invoke({ method: "cancel", accountId: accountA }), { state: "canceled" }); + await assert.rejects(result.completion, /stopped/); + assert.deepEqual(await helper.invoke({ method: "cancel", accountId: accountA }), { state: "idle" }); + } finally { await rm(directory, { recursive: true, force: true }); } +}); + +test("logout terminates pending device login and invokes only Codex logout", async () => { + const directory = await mkdtemp(join(tmpdir(), "factorize-codex-auth-")); + try { + const fake = fakeCodex(); + const helper = createCodexAuthHelper({ accountHomeRoot: directory, spawnProcess: fake.spawnProcess }); + const result = await helper.invoke({ method: "device-login", accountId: accountA }); + assert.deepEqual(await helper.invoke({ method: "logout", accountId: accountA }), { state: "logged-out" }); + await assert.rejects(result.completion, /stopped/); + assert.deepEqual(fake.calls.map(call => call.args), [["--version"], ["login", "--device-auth"], ["--version"], ["logout"]]); + } finally { await rm(directory, { recursive: true, force: true }); } +}); diff --git a/packages/credential-proxy/test/control-client.test.mjs b/packages/credential-proxy/test/control-client.test.mjs new file mode 100644 index 0000000..449cf99 --- /dev/null +++ b/packages/credential-proxy/test/control-client.test.mjs @@ -0,0 +1,171 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { consumeProxyEnrollment, createModelAccountPersistenceAdapter, createProxyEnrollmentAuthenticator, issueProxyEnrollment, readProxyIntegrationSnapshot, readProxyRunRevocations, registerProxyBoot, startProxyRunRevocationMonitor } from "../src/control-client.mjs"; + +const token = `fzc_${"A".repeat(43)}`; +const bootEpoch = "22222222-2222-4222-8222-222222222222"; +const runId = "33333333-3333-4333-8333-333333333333"; +const runCredential = `fzt_run_${Buffer.from(runId).toString("base64url")}_${"R".repeat(43)}`; + +test("boot registration uses a one-call control credential, HTTPS, exact path and fresh process epoch", async () => { + let request; + const result = await registerProxyBoot({ controlOrigin: "https://factorize.example", browserOrigin: "https://proxy.example", token, bootEpoch, fetchImpl: async (url, init) => { + request = { url: String(url), init }; + return Response.json({ registered: true, tenantId: "11111111-1111-4111-8111-111111111111", proxyIdentity: "proxy-fixture", bootEpoch, controlCredential: `fzc_${"c".repeat(43)}` }); + } }); + assert.deepEqual(result, { tenantId: "11111111-1111-4111-8111-111111111111", proxyIdentity: "proxy-fixture", bootEpoch, controlCredential: `fzc_${"c".repeat(43)}` }); + assert.equal(request.url, "https://factorize.example/internal/credential-proxy/register-boot"); + assert.equal(request.init.headers.Authorization, `Bearer ${token}`); + assert.equal(request.init.redirect, "error"); + assert.deepEqual(JSON.parse(request.init.body), { bootEpoch, browserOrigin: "https://proxy.example" }); +}); + +test("proxy snapshot reader requests only the bound run revision and validates encrypted records", async () => { + let request; + const snapshot = { ownerId: "owner-1", records: [{ id: "integration-1", kind: "bearer", origin: "https://api.example", revision: 4, encryptedPayload: "ciphertext" }], modelAccount: { id: "model-1", kind: "model", origin: null, revision: 5, encryptedPayload: "model-ciphertext" } }; + const result = await readProxyIntegrationSnapshot({ controlOrigin: "https://factorize.example", token, binding: { runId, grantRevision: 7 }, fetchImpl: async (url, init) => { + request = { url: String(url), init, body: JSON.parse(init.body) }; + return Response.json(snapshot); + } }); + assert.deepEqual(result, snapshot); + assert.equal(request.url, "https://factorize.example/internal/credential-proxy/integration-snapshot"); + assert.deepEqual(request.body, { runId, grantRevision: 7 }); + assert.equal(request.init.headers.Authorization, `Bearer ${token}`); + await assert.rejects(readProxyIntegrationSnapshot({ controlOrigin: "https://factorize.example", token, binding: { runId, grantRevision: 7 }, fetchImpl: async () => Response.json({ ownerId: "owner-1", records: [{ ...snapshot.records[0], kind: "model" }] }) }), /invalid proxy integration snapshot response/); + await assert.rejects(readProxyIntegrationSnapshot({ controlOrigin: "https://factorize.example", token, binding: { runId, grantRevision: 7 }, fetchImpl: async () => Response.json({ ...snapshot, modelAccount: { ...snapshot.modelAccount, kind: "bearer" } }) }), /invalid proxy integration snapshot response/); +}); + +test("model account persistence adapter lists owner-bound account revisions over proxy authentication", async () => { + const ownerId = "11111111-1111-4111-8111-111111111111"; + let request; + const adapter = createModelAccountPersistenceAdapter({ + controlOrigin: "https://factorize.example", controlCredential: token, ownerId, + fetchImpl: async (url, init) => { + request = { url: String(url), init, body: JSON.parse(init.body) }; + return Response.json({ ownerId, keyGeneration: "22222222-2222-4222-8222-222222222222", + accounts: [{ id: "33333333-3333-4333-8333-333333333333", kind: "model", origin: null, revision: 4 }] }); + }, + }); + assert.deepEqual(await adapter.listAccounts(), [{ id: "33333333-3333-4333-8333-333333333333", kind: "model", origin: null, revision: 4, ownerId, keyGeneration: "22222222-2222-4222-8222-222222222222" }]); + assert.equal(request.url, "https://factorize.example/internal/credential-proxy/model-account-cache"); + assert.deepEqual(request.body, { operation: "list" }); + assert.equal(request.init.headers.Authorization, `Bearer ${token}`); + await assert.rejects(adapter.listAccounts && createModelAccountPersistenceAdapter({ + controlOrigin: "https://factorize.example", controlCredential: token, ownerId, + fetchImpl: async () => Response.json({ ownerId: "22222222-2222-4222-8222-222222222222", keyGeneration: "22222222-2222-4222-8222-222222222222", accounts: [] }), + }).listAccounts(), /invalid model account list response/); +}); + +test("proxy revocation monitor applies durable run revocations and tolerates an outage", async () => { + const requests = []; + const revoked = []; + let failFirst = true; + const fetchImpl = async (url, init) => { + requests.push({ url: String(url), init, body: JSON.parse(init.body) }); + if (failFirst) { failFirst = false; throw new Error("offline fixture"); } + return Response.json({ revokedRunIds: [runId] }); + }; + const runtime = { revokeRun: async id => { revoked.push(id); } }; + const monitor = startProxyRunRevocationMonitor({ + controlOrigin: "https://factorize.example", controlCredential: token, bootEpoch, runtime, intervalMs: 100, fetchImpl, + }); + try { + const deadline = Date.now() + 1_000; + while (!revoked.length && Date.now() < deadline) await new Promise(resolve => setTimeout(resolve, 10)); + assert.deepEqual(revoked, [runId]); + assert.equal(requests.length, 2, "a failed poll leaves the proxy live and retries through the control plane"); + assert.ok(requests.every(request => request.url === "https://factorize.example/internal/credential-proxy/revocations")); + assert.ok(requests.every(request => request.init.headers.Authorization === `Bearer ${token}`)); + assert.ok(requests.every(request => request.body.bootEpoch === bootEpoch)); + } finally { + await monitor.close(); + } + await assert.rejects(readProxyRunRevocations({ controlOrigin: "https://factorize.example", token, bootEpoch, fetchImpl: async () => Response.json({ revokedRunIds: ["invalid"] }) }), /invalid proxy revocation response/); +}); + +test("boot registration rejects unsafe origins, invalid credentials and mismatched response bindings", async () => { + await assert.rejects(registerProxyBoot({ controlOrigin: "http://factorize.example", token, bootEpoch, browserOrigin: "https://proxy.example" }), /HTTPS origin/); + await assert.rejects(registerProxyBoot({ controlOrigin: "https://factorize.example", token: "fzt_run_token", bootEpoch, browserOrigin: "https://proxy.example" }), /credential/); + await assert.rejects(registerProxyBoot({ controlOrigin: "https://factorize.example", token, bootEpoch, browserOrigin: "https://proxy.example", fetchImpl: async () => Response.json({ registered: true, proxyIdentity: "proxy-fixture", bootEpoch: "33333333-3333-4333-8333-333333333333" }) }), /invalid proxy boot registration response/); +}); + +test("proxy control client requests and consumes a one-use enrollment over authenticated HTTPS", async () => { + const controlToken = `fzc_${"c".repeat(43)}`; + const requests = []; + const fetchImpl = async (url, init) => { + requests.push({ url: String(url), init }); + return requests.length === 1 + ? Response.json({ enrollmentId: "enrollment-1", token: "e".repeat(43), grantRevision: 2 }) + : Response.json({ consumed: true, binding: { enrollmentId: "enrollment-1" } }); + }; + const common = { controlOrigin: "https://factorize.example", token: controlToken, fetchImpl }; + const enrollment = await issueProxyEnrollment({ ...common, body: { runId: "33333333-3333-4333-8333-333333333333", vmIdentity: "fly:vm-1" } }); + assert.equal(enrollment.token, "e".repeat(43)); + const consumed = await consumeProxyEnrollment({ ...common, body: { runId: "33333333-3333-4333-8333-333333333333", vmIdentity: "fly:vm-1", enrollmentToken: enrollment.token } }); + assert.equal(consumed.enrollmentId, "enrollment-1"); + assert.deepEqual(requests.map(request => request.url), [ + "https://factorize.example/internal/credential-proxy/enroll", + "https://factorize.example/internal/credential-proxy/consume-enrollment", + ]); + assert.ok(requests.every(request => request.init.headers.Authorization === `Bearer ${controlToken}` && request.init.redirect === "error")); +}); + +test("proxy enrollment authenticator binds issued and consumed state to the mTLS VM and current boot", async () => { + const controlToken = `fzc_${"d".repeat(43)}`; + const requests = []; + const grants = [{ id: "tool-1", origin: "https://api.example.test", credential: "synthetic-secret" }]; + const fetchImpl = async (url, init) => { + const request = { url: String(url), init, body: JSON.parse(init.body) }; + requests.push(request); + if (requests.length === 1) return Response.json({ enrollmentId: "enrollment-1", token: "e".repeat(43), grantRevision: 7 }); + return Response.json({ consumed: true, binding: { + tenantId: "11111111-1111-4111-8111-111111111111", + runId: request.body.runId, + vmIdentity: request.body.vmIdentity, + proxyIdentity: "fly:proxy-1", + bootEpoch, + enrollmentId: "enrollment-1", + grantRevision: 7, + } }); + }; + const authenticateRun = createProxyEnrollmentAuthenticator({ + controlOrigin: "https://factorize.example", + controlCredential: controlToken, + bootEpoch, + fetchImpl, + resolveGrants: async binding => { + assert.equal(binding.runId, "33333333-3333-4333-8333-333333333333"); + return grants; + }, + }); + const connection = await authenticateRun({ + runId, + runCredential, + vmIdentity: "fly:vm-1", + bootEpoch, + }); + assert.deepEqual(requests.map(request => request.url), [ + "https://factorize.example/internal/credential-proxy/enroll", + "https://factorize.example/internal/credential-proxy/consume-enrollment", + ]); + assert.deepEqual(requests.map(request => request.body), [ + { runId, vmIdentity: "fly:vm-1", runCredential }, + { runId, vmIdentity: "fly:vm-1", enrollmentToken: "e".repeat(43) }, + ]); + assert.ok(requests.every(request => request.init.headers.Authorization === `Bearer ${controlToken}`)); + assert.equal(connection.authenticated, true); + assert.equal(connection.enrollmentConsumed, true); + assert.equal(connection.vmId, "fly:vm-1"); + assert.equal(connection.bootEpoch, bootEpoch); + assert.equal(connection.connectionId, "enrollment-1"); + assert.deepEqual(connection.grants, grants); + await assert.rejects(authenticateRun({ + runId, + runCredential, + vmIdentity: "fly:vm-1", + bootEpoch: "44444444-4444-4444-8444-444444444444", + }), /invalid proxy enrollment identity/); + assert.equal(requests.length, 2, "a stale boot cannot issue another enrollment"); + await assert.rejects(authenticateRun({ runId, vmIdentity: "fly:vm-1", bootEpoch }), /invalid proxy enrollment identity/); + assert.equal(requests.length, 2, "a missing job run credential cannot issue enrollment"); +}); diff --git a/packages/credential-proxy/test/decrypted-grants.test.mjs b/packages/credential-proxy/test/decrypted-grants.test.mjs new file mode 100644 index 0000000..5b41501 --- /dev/null +++ b/packages/credential-proxy/test/decrypted-grants.test.mjs @@ -0,0 +1,98 @@ +import assert from "node:assert/strict"; +import { webcrypto } from "node:crypto"; +import { test } from "node:test"; +import { createDecryptedGrantResolver, createProxyIntegrationGrantResolver } from "../src/decrypted-grants.mjs"; +import { createProxyRuntime } from "../src/runtime.mjs"; + +const encoder = new TextEncoder(); +const salt = encoder.encode("factorize.unlock-key.v1"); +const info = encoder.encode("factorize.record.aes-256-gcm.v1"); +const ownerId = "owner-fixture"; +const unlockKey = `fzk1.${Buffer.from(webcrypto.getRandomValues(new Uint8Array(32))).toString("base64url")}`; + +async function encryptCredential({ id, origin, revision, credential }) { + const raw = Buffer.from(unlockKey.slice("fzk1.".length), "base64url"); + const material = await webcrypto.subtle.importKey("raw", raw, "HKDF", false, ["deriveKey"]); + const aesKey = await webcrypto.subtle.deriveKey({ name: "HKDF", hash: "SHA-256", salt, info }, material, + { name: "AES-GCM", length: 256 }, false, ["encrypt"]); + const nonce = webcrypto.getRandomValues(new Uint8Array(12)); + const additionalData = encoder.encode(JSON.stringify(["factorize-record-v1", ownerId, id, "bearer", origin, revision])); + const ciphertext = new Uint8Array(await webcrypto.subtle.encrypt({ name: "AES-GCM", iv: nonce, additionalData, tagLength: 128 }, aesKey, encoder.encode(credential))); + return `${Buffer.from(nonce).toString("base64")}.${Buffer.from(ciphertext).toString("base64")}`; +} + +test("resolver decrypts only selected active bearer records using the shared record binding", async () => { + const first = { id: "integration-a", kind: "bearer", origin: "https://api.example.test", revision: 3, deletedAt: null }; + const second = { id: "integration-b", kind: "bearer", origin: "https://other.example.test", revision: 4, deletedAt: null }; + const records = [ + { ...first, encryptedPayload: await encryptCredential({ ...first, credential: "first-token" }) }, + { ...second, encryptedPayload: await encryptCredential({ ...second, credential: "second-token" }) }, + { id: "deleted", kind: "bearer", origin: "https://api.example.test", revision: 5, deletedAt: new Date(), encryptedPayload: "unused" }, + ]; + const resolver = createDecryptedGrantResolver({ + readSnapshot: async () => ({ ownerId, records }), + getUnlockKey: async () => unlockKey, + }); + assert.deepEqual(await resolver({ tenantId: "tenant-fixture", integrationIds: ["integration-b"] }), [ + { id: "integration-b", origin: "https://other.example.test", credential: "second-token" }, + ]); + assert.deepEqual(await resolver({ tenantId: "tenant-fixture", integrationIds: [] }), []); + const runResolver = createDecryptedGrantResolver({ + readSnapshot: async () => ({ ownerId, records }), + resolveIntegrationIds: async binding => binding.runId === "run-fixture" ? ["integration-a"] : [], + getUnlockKey: async () => unlockKey, + }); + assert.deepEqual(await runResolver({ tenantId: "tenant-fixture", runId: "run-fixture" }), [ + { id: "integration-a", origin: "https://api.example.test", credential: "first-token" }, + ]); +}); + +test("resolver rejects missing, deleted, ambiguous, malformed, and misbound grants", async () => { + const record = { id: "integration-a", kind: "bearer", origin: "https://api.example.test", revision: 3, deletedAt: null }; + const encryptedPayload = await encryptCredential({ ...record, credential: "synthetic-token" }); + const resolver = createDecryptedGrantResolver({ readSnapshot: async () => ({ ownerId, records: [{ ...record, encryptedPayload }] }), getUnlockKey: async () => unlockKey }); + await assert.rejects(resolver({ tenantId: "tenant-fixture", integrationIds: ["missing"] }), /unavailable/); + await assert.rejects(resolver({ tenantId: "tenant-fixture", integrationIds: ["integration-a", "integration-a"] }), /selection/); + const tampered = createDecryptedGrantResolver({ readSnapshot: async () => ({ ownerId: "other-owner", records: [{ ...record, encryptedPayload }] }), getUnlockKey: async () => unlockKey }); + await assert.rejects(tampered({ tenantId: "tenant-fixture", integrationIds: ["integration-a"] })); + const locked = createDecryptedGrantResolver({ readSnapshot: async () => ({ ownerId, records: [{ ...record, encryptedPayload }] }), getUnlockKey: async () => undefined }); + await assert.rejects(locked({ tenantId: "tenant-fixture", integrationIds: ["integration-a"] }), /locked/); +}); + +test("resolver rejects non-HTTPS destinations and invalid credential bytes", async () => { + const badOrigin = { id: "integration-a", kind: "bearer", origin: "http://api.example.test", revision: 3, deletedAt: null, + encryptedPayload: await encryptCredential({ id: "integration-a", origin: "http://api.example.test", revision: 3, credential: "token" }) }; + const badOriginResolver = createDecryptedGrantResolver({ readSnapshot: async () => ({ ownerId, records: [badOrigin] }), getUnlockKey: async () => unlockKey }); + await assert.rejects(badOriginResolver({ tenantId: "tenant-fixture", integrationIds: ["integration-a"] }), /integration origin/); + const badCredential = { ...badOrigin, origin: "https://api.example.test", encryptedPayload: await encryptCredential({ id: "integration-a", origin: "https://api.example.test", revision: 3, credential: "bad\ncredential" }) }; + const badCredentialResolver = createDecryptedGrantResolver({ readSnapshot: async () => ({ ownerId, records: [badCredential] }), getUnlockKey: async () => unlockKey }); + await assert.rejects(badCredentialResolver({ tenantId: "tenant-fixture", integrationIds: ["integration-a"] }), /credential/); +}); + +test("production grant resolver reads the run-bound snapshot and only decrypts while the runtime is unlocked", async () => { + const record = { id: "integration-a", kind: "bearer", origin: "https://api.example.test", revision: 3, deletedAt: null }; + const encryptedPayload = await encryptCredential({ ...record, credential: "snapshot-token" }); + const runtime = createProxyRuntime({ bootEpoch: "11111111-1111-4111-8111-111111111111" }); + const requests = []; + const resolve = createProxyIntegrationGrantResolver({ + runtime, + controlOrigin: "https://factorize.example", + controlCredential: `fzc_${"c".repeat(43)}`, + fetchImpl: async (url, init) => { + requests.push({ url: String(url), authorization: new Headers(init.headers).get("authorization"), body: JSON.parse(init.body) }); + return Response.json({ ownerId, records: [{ ...record, encryptedPayload }], modelAccount: null }); + }, + }); + const binding = { tenantId: ownerId, runId: "22222222-2222-4222-8222-222222222222", grantRevision: 7 }; + await assert.rejects(resolve(binding), /locked/); + runtime.unlock(unlockKey); + assert.deepEqual(await resolve(binding), [{ id: record.id, origin: record.origin, credential: "snapshot-token" }]); + assert.deepEqual(requests, [{ + url: "https://factorize.example/internal/credential-proxy/integration-snapshot", + authorization: `Bearer fzc_${"c".repeat(43)}`, + body: { runId: binding.runId, grantRevision: binding.grantRevision }, + }]); + await runtime.lock(); + await assert.rejects(resolve(binding), /locked/); + await runtime.shutdown(); +}); diff --git a/packages/credential-proxy/test/destination-policy.test.mjs b/packages/credential-proxy/test/destination-policy.test.mjs index d626663..264a305 100644 --- a/packages/credential-proxy/test/destination-policy.test.mjs +++ b/packages/credential-proxy/test/destination-policy.test.mjs @@ -18,7 +18,7 @@ test("URL policy permits paths and queries only on the configured HTTPS origin", const result = await connect("https://api.example.test:8443/v1/items?limit=5"); assert.equal(result.url.pathname, "/v1/items"); assert.equal(result.url.search, "?limit=5"); - assert.deepEqual(calls, [{ hostname: "api.example.test", port: 8443, address: "93.184.216.34", servername: "api.example.test" }]); + assert.deepEqual(calls, [{ hostname: "api.example.test", port: 8443, address: "93.184.216.34", servername: "api.example.test", timeoutMs: 10_000 }]); }); test("rejects unsafe and malformed request URLs before resolution", async () => { diff --git a/packages/credential-proxy/test/fixtures/upstream-cert.pem b/packages/credential-proxy/test/fixtures/upstream-cert.pem new file mode 100644 index 0000000..afec81f --- /dev/null +++ b/packages/credential-proxy/test/fixtures/upstream-cert.pem @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDTjCCAjagAwIBAgIUPy+fWru0gF7QNal9Xg4vXdIuUCgwDQYJKoZIhvcNAQEL +BQAwGzEZMBcGA1UEAwwQYXBpLmV4YW1wbGUudGVzdDAeFw0yNjExMTEyMzA3Mjla +Fw0zNjExMDgyMzA3MjlaMBsxGTAXBgNVBAMMEGFwaS5leGFtcGxlLnRlc3QwggEi +MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDClgGTbXnob9GHbVxMt1lSmsfW +6lSfuOEBEuE5n96bKcfSsVKMRKvw3j8PyGasF+DTQTwqFlcavHzmmVkOBpQYLSz3 +bvD0RnKJ8CXhZwcRSA77BENRJQJ3TRr9WIAVrz3dgkAEl5Tv4Y+0KILRqZMx+vg1 +R0CuAzt6JbhGTvFugRAgeSJootV/O9Uu+z4SqtEbOkpj7isRUJa1uirAmKoFucIU +sa/4wTBkWIL74Z2iEMdmNM0HQ0xTsoVmFVJb+yxCoCr5rzIdPuGDyQeqWaWtju7Z +kVJjUskyYgSrycUivxAElY9aedgmyZxoBIHiwdFsSt/Nu5Gj9mOahqAfAPdjAgMB +AAGjgYkwgYYwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAqQwRAYDVR0R +BD0wO4IQYXBpLmV4YW1wbGUudGVzdIYndXJuOmZhY3Rvcml6ZTp2bTpZWEJwTG1W +NFlXMXdiR1V1ZEdWemRBMB0GA1UdDgQWBBRnufSGyhOp/K9MV0Q1L6bh3O/uyzAN +BgkqhkiG9w0BAQsFAAOCAQEASBvpzgJtGPUxB8ao20lUU2J95DRPFH6BEPoJq38+ +62D7YTNIesVPykdnZQy/8uVWczIAKURAU/MRv10N8K6nfo/PAx3Hi2m8qbnoTcRa +2JLoE++bQltKI7GAg//wviMpb3CjgObjoIYVa8pdHvR2PLEIroJuGitOyZTWDBFh +bu5VcH5FDkCLD8DzatgGyEVDDR9vELzS5m1j9WgVXAHnMhnKegmvhhJhsdT+/LEE +gtNFdHYNRPGbxhJ6Kc8CbmSZN7T8zUjCTHfcAB5iz6lrQRbbbxzIekXeqzv+Ya0C +vnY+CxOlQab0mac1Y72EnoDMSkvAywIbdwe9qcpITlrKmQ== +-----END CERTIFICATE----- diff --git a/packages/credential-proxy/test/fixtures/upstream-key.pem b/packages/credential-proxy/test/fixtures/upstream-key.pem new file mode 100644 index 0000000..69f0bca --- /dev/null +++ b/packages/credential-proxy/test/fixtures/upstream-key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDClgGTbXnob9GH +bVxMt1lSmsfW6lSfuOEBEuE5n96bKcfSsVKMRKvw3j8PyGasF+DTQTwqFlcavHzm +mVkOBpQYLSz3bvD0RnKJ8CXhZwcRSA77BENRJQJ3TRr9WIAVrz3dgkAEl5Tv4Y+0 +KILRqZMx+vg1R0CuAzt6JbhGTvFugRAgeSJootV/O9Uu+z4SqtEbOkpj7isRUJa1 +uirAmKoFucIUsa/4wTBkWIL74Z2iEMdmNM0HQ0xTsoVmFVJb+yxCoCr5rzIdPuGD +yQeqWaWtju7ZkVJjUskyYgSrycUivxAElY9aedgmyZxoBIHiwdFsSt/Nu5Gj9mOa +hqAfAPdjAgMBAAECggEAHCixGbCiD6z+2WZOERwHZRjZCpTmoiaTCSC+TyR/hlzJ +eggHNh1J9JV08sfBc+4QrwGlv0F0YX4dZRGT/Z+eFtGSzEvVusPnF2cVVEUglLOk +11aKl1ePv8v8t+TyxDCT91Y+BWwaNYyiyyOBqu2rS3RSLwOcrJHzLX3/6MzLy33I +1++WK9wGUKEyzfQ8k363yUv1CmhN/4kJs9a00OLc9pvBKO/LLxMxGZ3lMUsVhVUc +3h0yjlRvhvYDvZeLJKGhWVhn1wPfa6BNPwMNatJ6O5ubdA1qegOOxi+VDheSg05H +MVILqoFWt6B7AY+iMHbzbR1YJxSYWNVA9X9KWqtWoQKBgQD7EnZ1137IBFeC0UwK +bvjnh92lPCg3Ek8iBaH+tHga08KZr4xR10tsbqXIKxoGBeE0m3uOQbnZ0cDYSlSP +MsEXk3hthnpSNT1Ln/K1sc5ht7wb0xhDzbVsRjhtH0Ae64LrExhn/wlTirrtwDcx +6Dw1zymbr7a6LflUtHNB7V28VwKBgQDGZ7kWt7w7yDZlLQPkEmJ3BIXTvDKPsfCv +1R4Xwn/ZHaXA8J6CHZ3tEzOSWSPU7TOCSwu3mrMS+CGK5GINOYJ54YfNcgbQJ+7G +Yzw7yUFidbhJ6TBulhmCO8gdR743CRweC0K28bVe5suPnNNdlJDyowwtboua9Vn5 +H0sxcNz11QKBgQDmqxDAtYqaUzrfSlE1aL0vYqklLP65kg238Tri2oVaCGHKdvlR +G58E8SdInyjajb5Zadv7V7FbSJWHYcsMgRHcCjXjUmDBdH5rI3mGquQ07OxbF/c8 +HBtpbZ5byxFLFw8B0Fpk+XBlG5kf47ASHw5qVXkhuy8DIEfpJZya3mx3IwKBgFNN +IU0Ef6ep/AymxcdwYJ8P6xBnJ+NlFmT82dGn+/2wkPNdE3AV90SelzyO6Jhxrvq5 +f4XrEdyZCZxDH1fmGX4KzvXXNj0XRw1MhPGlIiqwultt6MPFiZhAeVix0o9Q59lF +aRFekoPx6u21LplP0zvMcURx8XNyKACMvwlXFa6xAoGAK5qaim0rJzwrtlr8T1z/ +HxzM4ZAZJchb8fN7LExj2ZrzCmZY2pnpS8oH3ae9iopV9LKUR+b6OwgnV8eWv1qx +Ldc3l7R/8AD9MOmuFwU36VozB/esox6r6MKaBl8m009wceouZG7uV4KvKr2E2Kry +dfnJ6oBHEnM8GcAQjZ3tqJA= +-----END PRIVATE KEY----- diff --git a/packages/credential-proxy/test/model-account-crypto.test.mjs b/packages/credential-proxy/test/model-account-crypto.test.mjs new file mode 100644 index 0000000..2308a6d --- /dev/null +++ b/packages/credential-proxy/test/model-account-crypto.test.mjs @@ -0,0 +1,37 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { randomBytes } from "node:crypto"; +import { decryptModelAccountCache, encryptModelAccountCache } from "../src/model-account-crypto.mjs"; + +const ownerId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; +const accountId = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"; +const otherAccountId = "cccccccc-cccc-4ccc-8ccc-cccccccccccc"; +const keyGeneration = "dddddddd-dddd-4ddd-8ddd-dddddddddddd"; +const unlockKey = `fzk1.${randomBytes(32).toString("base64url")}`; +const binding = { ownerId, accountId, keyGeneration, revision: 7 }; + +test("model account cache round-trips only for its complete owner/account/generation/revision binding", async () => { + const plaintext = new TextEncoder().encode('{"access_token":"synthetic-model-token"}'); + const encrypted = await encryptModelAccountCache(plaintext, unlockKey, binding); + assert.equal(encrypted.includes("synthetic-model-token"), false); + const restored = await decryptModelAccountCache(encrypted, unlockKey, binding); + assert.deepEqual(restored, plaintext); + restored.fill(0); + plaintext.fill(0); + + for (const changed of [ + { ...binding, ownerId: "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee" }, + { ...binding, accountId: otherAccountId }, + { ...binding, keyGeneration: "ffffffff-ffff-4fff-8fff-ffffffffffff" }, + { ...binding, revision: 8 }, + ]) await assert.rejects(decryptModelAccountCache(encrypted, unlockKey, changed)); + await assert.rejects(decryptModelAccountCache(encrypted, `fzk1.${randomBytes(32).toString("base64url")}`, binding)); +}); + +test("model account cache rejects malformed keys, ciphertext, and metadata", async () => { + const plaintext = new TextEncoder().encode("synthetic-cache"); + await assert.rejects(encryptModelAccountCache(plaintext, "not-an-unlock-key", binding), /malformed unlock key/); + await assert.rejects(encryptModelAccountCache(plaintext, unlockKey, { ...binding, revision: 0 }), /invalid model account encryption binding/); + await assert.rejects(decryptModelAccountCache("bad.payload.extra", unlockKey, binding), /malformed model account ciphertext/); + await assert.rejects(decryptModelAccountCache("AA==.AA==", unlockKey, binding), /malformed model account ciphertext/); +}); diff --git a/packages/credential-proxy/test/model-account-lifecycle.test.mjs b/packages/credential-proxy/test/model-account-lifecycle.test.mjs new file mode 100644 index 0000000..310b4df --- /dev/null +++ b/packages/credential-proxy/test/model-account-lifecycle.test.mjs @@ -0,0 +1,332 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createModelAccountLifecycle } from "../src/model-account-lifecycle.mjs"; + +const accountId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; +const resetAccountId = "cccccccc-cccc-4ccc-8ccc-cccccccccccc"; +const account = { id: accountId, kind: "model", ownerId: "owner", keyGeneration: "generation", revision: 4 }; + +async function fixture(t, overrides = {}) { + const root = await mkdtemp(join(tmpdir(), "factorize-model-account-")); + t.after(() => rm(root, { recursive: true, force: true })); + const home = join(root, accountId); + await mkdir(home, { recursive: true }); + await writeFile(join(home, "auth.json"), JSON.stringify({ tokens: { access_token: "synthetic-access", refresh_token: "synthetic-refresh" } })); + const calls = [], events = [], methods = []; + const helper = { invoke: async request => { + methods.push(request.method); + if (request.method === "device-login") return { state: "pending", completion: overrides.completion ?? Promise.resolve({ state: "authorized" }) }; + if (request.method === "refresh") { calls.push("refresh"); return overrides.refreshResult ?? { state: "refreshed" }; } + if (request.method === "account-status") return { state: "authenticated" }; + return { state: request.method === "cancel" ? "canceled" : "logged-out" }; + } }; + let saves = 0; + const lifecycle = createModelAccountLifecycle({ + authHelper: helper, + accountHomeRoot: root, + getUnlockKey: async () => "synthetic-unlock-key", + getAccount: async () => overrides.getAccount ? overrides.getAccount() : account, + encryptCache: async (plain, key, binding) => { + assert.equal(key, "synthetic-unlock-key"); + events.push({ type: "encrypt", binding, plaintext: new TextDecoder().decode(plain) }); + return `encrypted.${Buffer.from(plain).toString("base64")}`; + }, + persistEncrypted: async request => { + saves++; + events.push({ type: "persist", request }); + if (overrides.persistEncrypted) return overrides.persistEncrypted(request, saves); + }, + onUnsaved: value => events.push({ type: "unsaved", ...value }), + retryDelaysMs: overrides.retryDelaysMs ?? [], + sleep: async () => {}, + ...overrides.options, + }); + return { root, home, lifecycle, events, methods, calls, get saves() { return saves; } }; +} + +test("device authorization persists only encrypted, account-bound cache after success", async t => { + const f = await fixture(t); + const login = await f.lifecycle.deviceLogin(accountId); + assert.equal(login.state, "pending"); + assert.deepEqual(await login.completion, { state: "saved" }); + const [encrypted, saved] = f.events.filter(event => event.type === "encrypt" || event.type === "persist"); + assert.equal(encrypted.binding.ownerId, "owner"); + assert.equal(encrypted.binding.recordType, "model"); + assert.equal(encrypted.binding.revision, 5); + assert.equal(saved.request.expectedRevision, 4); + assert.match(saved.request.encryptedPayload, /^encrypted\./); + assert.equal("unlockKey" in saved.request, false); +}); + +test("denied or expired device authorization does not persist token state", async t => { + const f = await fixture(t, { completion: Promise.resolve({ state: "reauthentication-required" }) }); + const login = await f.lifecycle.deviceLogin(accountId); + assert.deepEqual(await login.completion, { state: "reauthentication-required" }); + assert.equal(f.saves, 0); +}); + +test("expired refresh explicitly requires reauthentication without saving stale tokens", async t => { + const f = await fixture(t, { refreshResult: { state: "reauthentication-required" } }); + assert.deepEqual(await f.lifecycle.refresh(accountId), { state: "reauthentication-required" }); + assert.equal(f.saves, 0); +}); + +test("refresh keeps the working local cache and reports bounded encrypted-save failures", async t => { + const f = await fixture(t, { retryDelaysMs: [0, 0], persistEncrypted: async () => { throw new Error("fixture outage"); } }); + assert.deepEqual(await f.lifecycle.refresh(accountId), { state: "unsaved", attempts: 3 }); + assert.equal(f.lifecycle.isUnsaved(accountId), true); + assert.equal(f.calls.length, 1); + assert.equal(f.saves, 3); + assert.deepEqual(f.events.filter(event => event.type === "unsaved").map(event => event.unsaved), [true]); +}); + +test("refresh requests for one account serialize and use the latest revision", async t => { + let revision = 0, inFlight = 0, maximumInFlight = 0; + const f = await fixture(t, { + getAccount: () => ({ ...account, revision }), + persistEncrypted: async () => { + inFlight++; + maximumInFlight = Math.max(maximumInFlight, inFlight); + await new Promise(resolve => setImmediate(resolve)); + revision++; + inFlight--; + }, + }); + await Promise.all([f.lifecycle.refresh(accountId), f.lifecycle.refresh(accountId)]); + assert.equal(maximumInFlight, 1); + assert.equal(f.saves, 2); +}); + +test("refresh re-encrypts against a newer account revision after a concurrent edit", async t => { + let revision = 4; + const f = await fixture(t, { + getAccount: () => ({ ...account, revision }), + persistEncrypted: async request => { + if (request.expectedRevision !== revision || f.saves === 1) { + revision++; + const error = new Error("stale model account revision"); + error.code = "stale_revision"; + throw error; + } + revision++; + }, + }); + assert.deepEqual(await f.lifecycle.refresh(accountId), { state: "saved" }); + const writes = f.events.filter(event => event.type === "persist").map(event => event.request); + assert.deepEqual(writes.map(request => request.expectedRevision), [4, 5]); + assert.notEqual(writes[0].idempotencyKey, writes[1].idempotencyKey); + const encryptions = f.events.filter(event => event.type === "encrypt"); + assert.deepEqual(encryptions.map(event => event.binding.revision), [5, 6]); +}); + +test("refresh does not retry a stale cache write after the unlock-key generation rotates", async t => { + let generation = "generation"; + const f = await fixture(t, { + getAccount: () => ({ ...account, keyGeneration: generation }), + persistEncrypted: async () => { + generation = "rotated-generation"; + const error = new Error("stale model account revision"); + error.code = "stale_revision"; + throw error; + }, + }); + + await assert.rejects(f.lifecycle.refresh(accountId), /changed before token persistence/); + assert.equal(f.saves, 1); + assert.deepEqual(f.events.filter(event => event.type === "encrypt").map(event => event.binding.keyGeneration), ["generation"]); +}); + +test("refresh does not retry a transient cache write after the key generation rotates during backoff", async t => { + let generation = "generation"; + const f = await fixture(t, { + retryDelaysMs: [1], + getAccount: () => ({ ...account, keyGeneration: generation }), + options: { sleep: async () => { generation = "rotated-generation"; } }, + persistEncrypted: async () => { throw new Error("temporary persistence failure"); }, + }); + + await assert.rejects(f.lifecycle.refresh(accountId), /changed before token persistence/); + assert.equal(f.saves, 1); + assert.deepEqual(f.events.filter(event => event.type === "encrypt").map(event => event.binding.keyGeneration), ["generation"]); +}); + +test("deletion invalidates a retry and removes the account cache", async t => { + let release; + const waiting = new Promise(resolve => { release = resolve; }); + const f = await fixture(t, { + retryDelaysMs: [0, 0, 0], + persistEncrypted: async (_request, attempt) => { + if (attempt === 1) { await waiting; throw new Error("first save failed"); } + }, + }); + const refreshing = f.lifecycle.refresh(accountId); + while (!f.saves) await new Promise(resolve => setImmediate(resolve)); + const deleting = f.lifecycle.deleteAccount(accountId); + release(); + await assert.rejects(refreshing, /changed before token persistence/); + assert.deepEqual(await deleting, { state: "deleted" }); + await assert.rejects(readFile(join(f.home, "auth.json")), { code: "ENOENT" }); + assert.ok(f.methods.includes("logout")); +}); + +test("reset snapshot invalidates a refresh cache write already in flight", async t => { + let release; + let entered; + const waiting = new Promise(resolve => { release = resolve; }); + const started = new Promise(resolve => { entered = resolve; }); + const f = await fixture(t, { + persistEncrypted: async () => { + entered(); + await waiting; + const error = new Error("stale model account revision"); + error.code = "stale_revision"; + throw error; + }, + }); + + const refreshing = f.lifecycle.refresh(accountId); + await started; + const resetting = f.lifecycle.reconcileAll([]); + await new Promise(resolve => setImmediate(resolve)); + release(); + + await assert.rejects(refreshing, /changed before token persistence/); + assert.deepEqual(await resetting, { removed: [accountId], accounts: [] }); + assert.equal(f.saves, 1); + await assert.rejects(readFile(join(f.home, "auth.json")), { code: "ENOENT" }); + assert.ok(f.methods.includes("logout")); +}); + +test("deletion during refresh persistence backoff prevents a delayed retry", async t => { + let releaseBackoff; + let enteredBackoff; + const waiting = new Promise(resolve => { releaseBackoff = resolve; }); + const started = new Promise(resolve => { enteredBackoff = resolve; }); + const f = await fixture(t, { + retryDelaysMs: [1], + options: { sleep: async () => { enteredBackoff(); await waiting; } }, + persistEncrypted: async () => { throw new Error("temporary persistence failure"); }, + }); + + const refreshing = f.lifecycle.refresh(accountId); + await started; + const deleting = f.lifecycle.deleteAccount(accountId); + releaseBackoff(); + + await assert.rejects(refreshing, /changed before token persistence/); + assert.deepEqual(await deleting, { state: "deleted" }); + assert.equal(f.saves, 1); + await assert.rejects(readFile(join(f.home, "auth.json")), { code: "ENOENT" }); + assert.ok(f.methods.includes("logout")); +}); + +test("reset during refresh persistence backoff prevents a delayed retry and clears the cache", async t => { + let releaseBackoff; + let enteredBackoff; + let releaseSnapshot; + let enteredSnapshot; + const waiting = new Promise(resolve => { releaseBackoff = resolve; }); + const started = new Promise(resolve => { enteredBackoff = resolve; }); + const snapshotWaiting = new Promise(resolve => { releaseSnapshot = resolve; }); + const snapshotStarted = new Promise(resolve => { enteredSnapshot = resolve; }); + const f = await fixture(t, { + retryDelaysMs: [1], + options: { + sleep: async () => { enteredBackoff(); await waiting; }, + readDirectory: async () => { enteredSnapshot(); await snapshotWaiting; return [{ name: accountId }]; }, + }, + persistEncrypted: async () => { throw new Error("temporary persistence failure"); }, + }); + + const refreshing = f.lifecycle.refresh(accountId); + await started; + const resetting = f.lifecycle.reconcileAll([]); + await snapshotStarted; + releaseSnapshot(); + await new Promise(resolve => setImmediate(resolve)); + releaseBackoff(); + + await assert.rejects(refreshing, /changed before token persistence/); + assert.deepEqual(await resetting, { removed: [accountId], accounts: [] }); + assert.equal(f.saves, 1); + await assert.rejects(readFile(join(f.home, "auth.json")), { code: "ENOENT" }); + assert.ok(f.methods.includes("logout")); +}); + +test("deletion racing pending device login prevents a late authorization from restoring its cache", async t => { + let complete; + const f = await fixture(t, { completion: new Promise(resolve => { complete = resolve; }) }); + const login = await f.lifecycle.deviceLogin(accountId); + const deleting = f.lifecycle.deleteAccount(accountId); + complete({ state: "authorized" }); + assert.deepEqual(await login.completion, { state: "deleted" }); + assert.deepEqual(await deleting, { state: "deleted" }); + await assert.rejects(readFile(join(f.home, "auth.json")), { code: "ENOENT" }); + assert.equal(f.saves, 0); +}); + +test("unlock reconciliation restores the encrypted Codex cache before checking account status", async t => { + const persisted = new TextEncoder().encode(JSON.stringify({ tokens: { access_token: "restored-access", refresh_token: "restored-refresh" } })); + const encryptedPayload = `cache.${Buffer.from(persisted).toString("base64")}`; + const f = await fixture(t, { options: { + getEncryptedCache: async id => ({ + accountId: id, ownerId: "owner", keyGeneration: "generation", revision: 8, encryptedPayload, + }), + decryptCache: async (payload, key, binding) => { + assert.equal(payload, encryptedPayload); + assert.equal(key, "synthetic-unlock-key"); + assert.deepEqual(binding, { + ownerId: "owner", recordId: accountId, recordType: "model", destination: "factorize", + revision: 8, keyGeneration: "generation", + }); + return new Uint8Array(persisted); + }, + } }); + + assert.deepEqual(await f.lifecycle.reconcile(accountId), { state: "saved" }); + assert.deepEqual(JSON.parse(await readFile(join(f.home, "auth.json"), "utf8")), { + tokens: { access_token: "restored-access", refresh_token: "restored-refresh" }, + }); + assert.ok(f.methods.indexOf("account-status") > -1); + assert.equal(f.events.find(event => event.type === "persist").request.expectedRevision, 4); +}); + +test("unlock reconciliation removes stale cache when the account key generation changed", async t => { + const f = await fixture(t, { options: { + getEncryptedCache: async id => ({ + accountId: id, ownerId: "owner", keyGeneration: "old-generation", revision: 8, encryptedPayload: "cache.payload", + }), + decryptCache: async () => { throw new Error("must not decrypt stale cache"); }, + } }); + + assert.deepEqual(await f.lifecycle.reconcile(accountId), { state: "reauthentication-required" }); + await assert.rejects(readFile(join(f.home, "auth.json")), { code: "ENOENT" }); + assert.equal(f.methods.includes("account-status"), false); +}); + +test("unlock snapshot reconciliation logs out and removes cached accounts deleted by reset", async t => { + const f = await fixture(t); + const staleHome = join(f.root, resetAccountId); + await mkdir(staleHome, { recursive: true }); + await writeFile(join(staleHome, "auth.json"), JSON.stringify({ tokens: { access_token: "stale-reset-token" } })); + + assert.deepEqual(await f.lifecycle.reconcileAll([account]), { + removed: [resetAccountId], + accounts: [{ accountId, state: "saved" }], + }); + await assert.rejects(readFile(join(staleHome, "auth.json")), { code: "ENOENT" }); + assert.ok(f.methods.includes("logout")); + assert.equal(f.saves, 1); +}); + +test("unlock snapshot rejects duplicate account identities before touching local state", async t => { + const f = await fixture(t); + await assert.rejects(f.lifecycle.reconcileAll([account, account]), /snapshot is invalid/); + assert.deepEqual(JSON.parse(await readFile(join(f.home, "auth.json"), "utf8")), { + tokens: { access_token: "synthetic-access", refresh_token: "synthetic-refresh" }, + }); + assert.equal(f.methods.length, 0); +}); diff --git a/packages/credential-proxy/test/proxy-listener.e2e.test.mjs b/packages/credential-proxy/test/proxy-listener.e2e.test.mjs new file mode 100644 index 0000000..a470bf0 --- /dev/null +++ b/packages/credential-proxy/test/proxy-listener.e2e.test.mjs @@ -0,0 +1,296 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { createServer as createHttpsServer } from "node:https"; +import { once } from "node:events"; +import { connect as tlsConnect } from "node:tls"; +import { test } from "node:test"; +import { createBearerForwarder } from "../src/bearer-forwarder.mjs"; +import { createAuthenticatedProxyListener } from "../src/proxy-listener.mjs"; +import { createProxyRuntime } from "../src/runtime.mjs"; +import { createAuthenticatedTunnelServer, peerIdentity, startAuthenticatedProxyTunnel, startVmTunnelHelper } from "../src/tunnel-transport.mjs"; + +const fixtures = new URL("./fixtures/", import.meta.url); +const key = await readFile(new URL("upstream-key.pem", fixtures)); +const cert = await readFile(new URL("upstream-cert.pem", fixtures)); + +async function waitForEvent(emitter, event) { + const pending = once(emitter, event); + return pending; +} + +test("mTLS VM identity comes from one canonical URI SAN bound to the certificate CN", () => { + const identity = "api.example.test"; + const encoded = Buffer.from(identity).toString("base64url"); + const socket = (subjectaltname, commonName = identity, authorized = true) => ({ + authorized, + getPeerCertificate: () => ({ subject: { CN: commonName }, subjectaltname }), + }); + assert.equal(peerIdentity(socket(`DNS:api.example.test, URI:urn:factorize:vm:${encoded}`)), identity); + assert.throws(() => peerIdentity(socket("DNS:api.example.test")), /one VM identity URI/); + assert.throws(() => peerIdentity(socket(`URI:urn:factorize:vm:${encoded}, URI:urn:factorize:vm:${encoded}`)), /one VM identity URI/); + assert.throws(() => peerIdentity(socket("URI:urn:factorize:vm:not_canonical=")), /invalid VM identity URI/); + assert.throws(() => peerIdentity(socket(`URI:urn:factorize:vm:${encoded}`, "other-vm")), /inconsistent/); + assert.throws(() => peerIdentity(socket(`URI:urn:factorize:vm:${encoded}`, identity, false)), /not authorized/); +}); + +test("authenticated tunnel listener streams over TLS, confines grants and cancels upstream work", { timeout: 5_000 }, async () => { + let observedAuthorization; + let upstreamEnded = false; + let cancelUpstream; + let finishUpstream; + let cancelStartedResolve; + const cancelStarted = new Promise(resolve => { cancelStartedResolve = resolve; }); + const upstream = createHttpsServer({ key, cert }, async (request, response) => { + observedAuthorization = request.headers.authorization; + if (request.url === "/cancel") { + const closed = new Promise(resolve => request.socket.once("close", resolve)); + response.writeHead(200, { "content-type": "text/plain" }); + response.write("first chunk"); + cancelUpstream = closed; + cancelStartedResolve(); + return; + } + const received = []; + for await (const chunk of request) received.push(chunk); + assert.equal(Buffer.concat(received).toString(), "request-body"); + response.writeHead(200, { "content-type": "text/plain" }); + response.write("first chunk"); + response.once("close", () => { upstreamEnded = true; }); + await new Promise(resolve => { finishUpstream = resolve; }); + response.end(" second chunk"); + }); + upstream.listen(0, "127.0.0.1"); + await once(upstream, "listening"); + const upstreamPort = upstream.address().port; + const forwarder = createBearerForwarder({ + connectorFactory: origin => async requestUrl => { + assert.equal(origin, `https://api.example.test:${upstreamPort}`); + const url = new URL(requestUrl); + const socket = tlsConnect({ host: "127.0.0.1", port: upstreamPort, servername: url.hostname, ca: cert }); + await once(socket, "secureConnect"); + return { socket, url }; + }, + }); + const runtime = createProxyRuntime({ + listenerFactory: ({ connection }) => createAuthenticatedProxyListener({ connection, forwarder }), + }); + let listener; + try { + runtime.unlock("synthetic-unlock-key"); + listener = await runtime.acceptAuthenticatedConnection({ + authenticated: true, + enrollmentConsumed: true, + bootEpoch: runtime.bootEpoch, + connectionId: "consumed-enrollment-1", + runId: "run-1", + vmId: "vm-1", + grants: [{ id: "tool-1", origin: `https://api.example.test:${upstreamPort}`, credential: "synthetic-bearer" }], + }); + const address = listener.address; + assert.equal(address.address, "127.0.0.1"); + const base = `http://127.0.0.1:${address.port}`; + const response = await fetch(`${base}/integrations/tool-1/echo?source=vm`, { + method: "POST", + headers: { authorization: "Bearer caller-token", "content-type": "text/plain" }, + body: "request-body", + }); + assert.equal(response.status, 200); + assert.equal(observedAuthorization, "Bearer synthetic-bearer"); + const reader = response.body.getReader(); + const first = await reader.read(); + assert.equal(Buffer.from(first.value).toString(), "first chunk"); + assert.equal(upstreamEnded, false, "response was delivered before the upstream finished"); + assert.equal(forwarder.activeRequests, 1, "streaming response retains its concurrency slot"); + finishUpstream(); + const second = await reader.read(); + assert.equal(Buffer.from(second.value).toString(), " second chunk"); + assert.equal((await reader.read()).done, true); + assert.equal(forwarder.activeRequests, 0); + + const denied = await fetch(`${base}/integrations/other-tool/echo`); + assert.equal(denied.status, 403); + assert.equal(observedAuthorization, "Bearer synthetic-bearer", "ungranted request never reached the upstream"); + + const abort = new AbortController(); + const cancelled = fetch(`${base}/integrations/tool-1/cancel`, { signal: abort.signal }); + await cancelStarted; + abort.abort(); + await assert.rejects(cancelled, error => error.name === "AbortError"); + assert.ok(cancelUpstream, "upstream cancellation hook was installed"); + await Promise.race([cancelUpstream, new Promise((_, reject) => setTimeout(() => reject(new Error("upstream was not cancelled")), 1_000))]); + assert.equal(forwarder.activeRequests, 0); + } finally { + finishUpstream?.(); + if (listener) await runtime.disconnect("consumed-enrollment-1"); + await runtime.shutdown(); + await new Promise((resolve, reject) => upstream.close(error => error ? reject(error) : resolve())); + } +}); + +test("listener refuses an unconsumed or unauthenticated connection", async () => { + await assert.rejects(createAuthenticatedProxyListener({ connection: { + authenticated: true, connectionId: "connection", runId: "run", vmId: "vm", grants: [], + } }), /authenticated consumed connection/); +}); + +test("runtime rejects a consumed enrollment from an earlier proxy boot", async () => { + const runtime = createProxyRuntime({ bootEpoch: "11111111-1111-4111-8111-111111111111" }); + runtime.unlock("synthetic-unlock-key"); + try { + await assert.rejects(runtime.acceptAuthenticatedConnection({ + authenticated: true, + enrollmentConsumed: true, + bootEpoch: "22222222-2222-4222-8222-222222222222", + connectionId: "stale-enrollment", + runId: "run-1", + vmId: "vm-1", + grants: [], + }), /different proxy boot/); + } finally { + await runtime.shutdown(); + } +}); + +test("VM helper uses mutual TLS and consumed enrollment to reach only its granted integration listener", { timeout: 5_000 }, async () => { + let observedAuthorization; + let enrollmentCount = 0; + let authenticationCount = 0; + let mismatchReachedResolve; + const mismatchReached = new Promise(resolve => { mismatchReachedResolve = resolve; }); + const upstream = createHttpsServer({ key, cert }, (request, response) => { + observedAuthorization = request.headers.authorization; + response.end("transport-ok"); + }); + upstream.listen(0, "127.0.0.1"); + await once(upstream, "listening"); + const upstreamPort = upstream.address().port; + const forwarder = createBearerForwarder({ + connectorFactory: origin => async requestUrl => { + assert.equal(origin, `https://api.example.test:${upstreamPort}`); + const url = new URL(requestUrl); + const socket = tlsConnect({ host: "127.0.0.1", port: upstreamPort, servername: url.hostname, ca: cert }); + await once(socket, "secureConnect"); + return { socket, url }; + }, + }); + const runtime = createProxyRuntime({ listenerFactory: ({ connection }) => createAuthenticatedProxyListener({ connection, forwarder }) }); + const tunnel = createAuthenticatedTunnelServer({ + tls: { key, cert, ca: cert }, + runtime, + authenticateEnrollment: async ({ token, vmIdentity, bootEpoch }) => { + authenticationCount++; + assert.ok(["A", "B", "C", "X"].some(value => token === value.repeat(43))); + assert.equal(vmIdentity, "api.example.test"); + assert.equal(bootEpoch, runtime.bootEpoch); + if (token === "A".repeat(43)) throw new Error("temporary control-plane outage"); + if (token === "X".repeat(43)) mismatchReachedResolve(); + return { + authenticated: true, + enrollmentConsumed: true, + vmIdentity: token === "X".repeat(43) ? "other-vm" : vmIdentity, + bootEpoch, + connectionId: "transport-enrollment-1", + runId: "run-transport-1", + vmId: vmIdentity, + grants: [{ id: "tool-1", origin: `https://api.example.test:${upstreamPort}`, credential: "synthetic-bearer" }], + }; + }, + }); + await tunnel.listening; + runtime.unlock("synthetic-unlock-key"); + const local = await startVmTunnelHelper({ + tls: { key, cert, ca: cert, servername: "api.example.test" }, + enrollment: async () => ({ token: ["A", "B", "C"][enrollmentCount++ % 3].repeat(43) }), + proxyHost: "127.0.0.1", + proxyPort: tunnel.address().port, + wait: async () => {}, + }); + let mismatchLocal; + try { + const response = await fetch(`http://127.0.0.1:${local.address.port}/integrations/tool-1/echo`, { + headers: { authorization: "Bearer caller-token" }, + }); + assert.equal(response.status, 200); + assert.equal(await response.text(), "transport-ok"); + assert.equal(observedAuthorization, "Bearer synthetic-bearer"); + const denied = await fetch(`http://127.0.0.1:${local.address.port}/integrations/other-tool/echo`); + assert.equal(denied.status, 403); + assert.ok(enrollmentCount >= 3, "failed setup and local connections get fresh one-use enrollment"); + assert.equal(enrollmentCount, authenticationCount, "each enrollment is independently consumed by the proxy transport"); + mismatchLocal = await startVmTunnelHelper({ + tls: { key, cert, ca: cert, servername: "api.example.test" }, + enrollment: async () => ({ token: (enrollmentCount++, "X".repeat(43)) }), + proxyHost: "127.0.0.1", + proxyPort: tunnel.address().port, + }); + const rejectedRequest = fetch(`http://127.0.0.1:${mismatchLocal.address.port}/integrations/tool-1/echo`); + await mismatchReached; + await mismatchLocal.close(); + mismatchLocal = undefined; + await assert.rejects(rejectedRequest, /fetch failed/, "mismatched VM binding is rejected"); + assert.equal(authenticationCount, enrollmentCount); + } finally { + await mismatchLocal?.close(); + await local.close(); + await tunnel.close(); + await runtime.shutdown(); + await new Promise((resolve, reject) => upstream.close(error => error ? reject(error) : resolve())); + } +}); + +test("VM helper authenticates the active run while the proxy performs authenticated enrollment", { timeout: 5_000 }, async () => { + const runtime = createProxyRuntime(); + const requests = []; + const enrollmentId = "11111111-1111-4111-8111-111111111111"; + const runId = "33333333-3333-4333-8333-333333333333"; + const runCredential = `fzt_run_${Buffer.from(runId).toString("base64url")}_${"R".repeat(43)}`; + const tunnel = await startAuthenticatedProxyTunnel({ + tls: { key, cert, ca: cert }, + runtime, + controlOrigin: "https://factorize.example", + controlCredential: `fzc_${"c".repeat(43)}`, + fetchImpl: async (url, init) => { + const body = JSON.parse(init.body); + requests.push({ url: String(url), authorization: init.headers.Authorization, body }); + if (String(url).endsWith("/enroll")) { + return Response.json({ enrollmentId, token: "e".repeat(43), grantRevision: 7 }); + } + return Response.json({ consumed: true, binding: { + tenantId: "22222222-2222-4222-8222-222222222222", + runId, + vmIdentity: "api.example.test", + proxyIdentity: "proxy-fixture", + bootEpoch: runtime.bootEpoch, + enrollmentId, + grantRevision: 7, + } }); + }, + resolveGrants: async binding => { + assert.equal(binding.runId, runId); + assert.equal(binding.bootEpoch, runtime.bootEpoch); + return []; + }, + }); + runtime.unlock("synthetic-unlock-key"); + const local = await startVmTunnelHelper({ + tls: { key, cert, ca: cert, servername: "api.example.test" }, + runId, + runCredential, + proxyHost: "127.0.0.1", + proxyPort: tunnel.address().port, + }); + try { + const response = await fetch(`http://127.0.0.1:${local.address.port}/unrecognized`); + assert.equal(response.status, 404); + assert.deepEqual(requests.map(({ url, authorization, body }) => ({ + path: new URL(url).pathname, authorization, body, + })), [ + { path: "/internal/credential-proxy/enroll", authorization: `Bearer fzc_${"c".repeat(43)}`, body: { runId, vmIdentity: "api.example.test", runCredential } }, + { path: "/internal/credential-proxy/consume-enrollment", authorization: `Bearer fzc_${"c".repeat(43)}`, body: { runId, vmIdentity: "api.example.test", enrollmentToken: "e".repeat(43) } }, + ]); + } finally { + await local.close(); + await tunnel.close(); + await runtime.shutdown(); + } +}); diff --git a/packages/credential-proxy/test/runtime-process.test.mjs b/packages/credential-proxy/test/runtime-process.test.mjs index 64fb7fe..1e4d5d9 100644 --- a/packages/credential-proxy/test/runtime-process.test.mjs +++ b/packages/credential-proxy/test/runtime-process.test.mjs @@ -19,8 +19,8 @@ async function waitFor(predicate, label) { } } -async function startProxy() { - const child = spawn(process.execPath, [entry], { stdio: ["pipe", "pipe", "pipe"] }); +async function startProxy(directory) { + const child = spawn(process.execPath, [entry], { stdio: ["pipe", "pipe", "pipe"], env: { ...process.env, FACTORIZE_PROXY_BOOTSTRAP_SOCKET: join(directory, "bootstrap.sock") } }); let pending = ""; let log = ""; let stderr = ""; @@ -38,9 +38,13 @@ async function startProxy() { }); child.stderr.setEncoding("utf8").on("data", (chunk) => { stderr += chunk; }); await waitFor(() => messages.length > 0, "proxy startup"); - assert.deepEqual(messages.shift(), { type: "ready", state: "locked" }); + const ready = messages.shift(); + assert.equal(ready.type, "ready"); + assert.equal(ready.state, "locked"); + assert.match(ready.bootEpoch, /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i); return { child, + bootEpoch: ready.bootEpoch, get logs() { return `${log}${stderr}`; }, command(command) { child.stdin.write(`${JSON.stringify(command)}\n`); @@ -55,32 +59,33 @@ test("process starts locked, unlocks in memory, shuts down bounded, and restarts try { await writeFile(join(directory, "identity.json"), JSON.stringify({ identity: "public-fixture", generation: 1 })); await writeFile(join(directory, "public-metadata.json"), JSON.stringify({ displayName: "fixture" })); - const first = await startProxy(); + const first = await startProxy(directory); processes.push(first); - assert.deepEqual(await first.command({ type: "status" }), { type: "status", state: "locked" }); - assert.deepEqual(await first.command({ type: "unlock", key: keyMarker }), { type: "status", state: "unlocked" }); + assert.deepEqual(await first.command({ type: "status" }), { type: "status", state: "locked", proxyControlRegistered: false }); + assert.deepEqual(await first.command({ type: "unlock", key: keyMarker }), { type: "status", state: "unlocked", proxyControlRegistered: false }); first.child.kill("SIGTERM"); await waitFor(() => first.child.exitCode !== null || first.child.signalCode !== null, "first process stop"); assert.equal(first.child.exitCode, 0); - const restarted = await startProxy(); + const restarted = await startProxy(directory); processes.push(restarted); - assert.deepEqual(await restarted.command({ type: "status" }), { type: "status", state: "locked" }); - assert.deepEqual(await restarted.command({ type: "unlock", key: tokenMarker }), { type: "status", state: "unlocked" }); + assert.notEqual(restarted.bootEpoch, first.bootEpoch, "every process restart gets a fresh enrollment epoch"); + assert.deepEqual(await restarted.command({ type: "status" }), { type: "status", state: "locked", proxyControlRegistered: false }); + assert.deepEqual(await restarted.command({ type: "unlock", key: tokenMarker }), { type: "status", state: "unlocked", proxyControlRegistered: false }); restarted.child.stdin.write('{"type":"stop"}\n'); await waitFor(() => restarted.child.exitCode !== null || restarted.child.signalCode !== null, "command shutdown"); assert.equal(restarted.child.exitCode, 0); - const crashed = await startProxy(); + const crashed = await startProxy(directory); processes.push(crashed); - assert.deepEqual(await crashed.command({ type: "unlock", key: keyMarker }), { type: "status", state: "unlocked" }); + assert.deepEqual(await crashed.command({ type: "unlock", key: keyMarker }), { type: "status", state: "unlocked", proxyControlRegistered: false }); crashed.child.kill("SIGKILL"); await waitFor(() => crashed.child.exitCode !== null || crashed.child.signalCode !== null, "crash stop"); assert.equal(crashed.child.signalCode, "SIGKILL"); - const afterCrash = await startProxy(); + const afterCrash = await startProxy(directory); processes.push(afterCrash); - assert.deepEqual(await afterCrash.command({ type: "status" }), { type: "status", state: "locked" }); + assert.deepEqual(await afterCrash.command({ type: "status" }), { type: "status", state: "locked", proxyControlRegistered: false }); afterCrash.child.kill("SIGTERM"); await waitFor(() => afterCrash.child.exitCode !== null || afterCrash.child.signalCode !== null, "post-crash shutdown"); assert.equal(afterCrash.child.exitCode, 0); diff --git a/packages/credential-proxy/test/runtime-revocation.test.mjs b/packages/credential-proxy/test/runtime-revocation.test.mjs new file mode 100644 index 0000000..76bd6f1 --- /dev/null +++ b/packages/credential-proxy/test/runtime-revocation.test.mjs @@ -0,0 +1,57 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { createProxyRuntime } from "../src/runtime.mjs"; + +const bootEpoch = "11111111-1111-4111-8111-111111111111"; + +function connection(runId, connectionId) { + return { authenticated: true, enrollmentConsumed: true, runId, connectionId, bootEpoch }; +} + +test("run revocation closes every established connection and rejects later enrollment", async () => { + const closed = []; + const runtime = createProxyRuntime({ + bootEpoch, + listenerFactory: async ({ connection: value }) => ({ + connectionId: value.connectionId, + close: async () => { closed.push(value.connectionId); }, + }), + }); + runtime.unlock("synthetic-unlock-key"); + try { + await runtime.acceptAuthenticatedConnection(connection("run-a", "connection-a1")); + await runtime.acceptAuthenticatedConnection(connection("run-a", "connection-a2")); + await runtime.acceptAuthenticatedConnection(connection("run-b", "connection-b1")); + + assert.equal(await runtime.revokeRun("run-a"), 2); + assert.deepEqual(closed.sort(), ["connection-a1", "connection-a2"]); + await assert.rejects(runtime.acceptAuthenticatedConnection(connection("run-a", "connection-a3")), /revoked/); + + const stillActive = await runtime.acceptAuthenticatedConnection(connection("run-b", "connection-b2")); + assert.equal(stillActive.connectionId, "connection-b2"); + assert.equal(await runtime.revokeRun("run-a"), 0); + assert.deepEqual(closed.sort(), ["connection-a1", "connection-a2"]); + } finally { + await runtime.shutdown(); + } +}); + +test("revocation racing listener creation closes the new listener before accepting it", async () => { + let resolveListener; + let closed = false; + const runtime = createProxyRuntime({ + bootEpoch, + listenerFactory: async () => new Promise(resolve => { resolveListener = resolve; }), + }); + runtime.unlock("synthetic-unlock-key"); + try { + const accepting = runtime.acceptAuthenticatedConnection(connection("run-race", "connection-race")); + await new Promise(resolve => setImmediate(resolve)); + await runtime.revokeRun("run-race"); + resolveListener({ connectionId: "connection-race", close: async () => { closed = true; } }); + await assert.rejects(accepting, /revoked/); + assert.equal(closed, true); + } finally { + await runtime.shutdown(); + } +}); diff --git a/packages/credential-proxy/test/tls-identity.test.mjs b/packages/credential-proxy/test/tls-identity.test.mjs new file mode 100644 index 0000000..dd09554 --- /dev/null +++ b/packages/credential-proxy/test/tls-identity.test.mjs @@ -0,0 +1,50 @@ +import assert from "node:assert/strict"; +import { execFile as execFileCallback } from "node:child_process"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { test } from "node:test"; +import { createProxyTlsAuthority } from "../src/tls-identity.mjs"; + +const execFile = promisify(execFileCallback); +const bootEpoch = "11111111-1111-4111-8111-111111111111"; + +test("proxy TLS authority provisions a boot-bound server cert and signs only canonical VM identities", async () => { + const directory = await mkdtemp(join(tmpdir(), "factorize-tls-test-")); + const authority = await createProxyTlsAuthority({ serverName: "proxy.internal.test", bootEpoch }); + try { + const keyPath = join(directory, "vm-key.pem"); + const csrPath = join(directory, "vm.csr"); + await execFile("openssl", ["genpkey", "-algorithm", "EC", "-pkeyopt", "ec_paramgen_curve:P-256", "-out", keyPath]); + await execFile("openssl", ["req", "-new", "-key", keyPath, "-subj", "/CN=ignored-by-issuer", "-out", csrPath]); + const csr = await readFile(csrPath, "utf8"); + const issued = await authority.signVmCsr({ vmIdentity: "fly:machine-7", csr }); + assert.equal(issued.trustBundle, authority.trustBundle); + assert.match(issued.certificate, /BEGIN CERTIFICATE/); + await writeFile(join(directory, "root.pem"), issued.trustBundle, { mode: 0o600 }); + await writeFile(join(directory, "client.pem"), issued.certificate, { mode: 0o600 }); + await execFile("openssl", ["verify", "-CAfile", join(directory, "root.pem"), join(directory, "client.pem")]); + const { stdout: clientText } = await execFile("openssl", ["x509", "-in", join(directory, "client.pem"), "-noout", "-subject", "-ext", "subjectAltName"]); + assert.match(clientText, /CN\s*=\s*fly:machine-7/); + assert.match(clientText, /URI:urn:factorize:vm:Zmx5Om1hY2hpbmUtNw/); + const { stdout: usageText } = await execFile("openssl", ["x509", "-in", join(directory, "client.pem"), "-noout", "-ext", "extendedKeyUsage"]); + assert.match(usageText, /TLS Web Client Authentication/); + + await writeFile(join(directory, "server.pem"), authority.tls.cert, { mode: 0o600 }); + await writeFile(join(directory, "server-root.pem"), authority.tls.ca, { mode: 0o600 }); + await execFile("openssl", ["verify", "-CAfile", join(directory, "server-root.pem"), "-verify_hostname", "proxy.internal.test", join(directory, "server.pem")]); + await assert.rejects(authority.signVmCsr({ vmIdentity: "../machine", csr }), /invalid VM certificate request/); + await assert.rejects(authority.signVmCsr({ vmIdentity: "fly:machine-7", csr: "not a CSR" })); + await authority.close(); + await assert.rejects(authority.signVmCsr({ vmIdentity: "fly:machine-7", csr }), /authority is closed/); + } finally { + await authority.close(); + await rm(directory, { recursive: true, force: true }); + } +}); + +test("proxy TLS authority rejects malformed server names and boot identities", async () => { + await assert.rejects(createProxyTlsAuthority({ serverName: "https://proxy.test", bootEpoch })); + await assert.rejects(createProxyTlsAuthority({ serverName: "proxy.test", bootEpoch: "old" })); +}); diff --git a/packages/docs/openapi.yaml b/packages/docs/openapi.yaml index 62e4f65..32c63f8 100644 --- a/packages/docs/openapi.yaml +++ b/packages/docs/openapi.yaml @@ -1806,6 +1806,337 @@ components: schema: $ref: "#/components/schemas/Error" paths: + /api/v1/model-accounts: + get: + summary: List model accounts available to the owner + description: |- + Requires an interactive owner browser session. Returns account metadata and the collection revision only; encrypted account caches and credentials are never returned. + + Requires the integrations:read scope. + + An interactive owner session is required; bearer tokens cannot call this operation. + responses: + "200": + description: Model account metadata and collection revision + content: + application/json: + schema: + type: object + properties: + revision: + type: integer + minimum: 0 + maximum: 9007199254740991 + accounts: + type: array + items: + type: object + properties: + id: + type: string + format: uuid + pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ + name: + type: string + description: + type: + - string + - "null" + revision: + type: integer + minimum: 0 + maximum: 9007199254740991 + required: + - id + - name + - description + - revision + additionalProperties: false + required: + - revision + - accounts + additionalProperties: false + "400": + $ref: "#/components/responses/Error400" + "401": + $ref: "#/components/responses/Error401" + "403": + $ref: "#/components/responses/Error403" + "404": + $ref: "#/components/responses/Error404" + "409": + $ref: "#/components/responses/Error409" + "429": + $ref: "#/components/responses/Error429" + "500": + $ref: "#/components/responses/Error500" + "503": + $ref: "#/components/responses/Error503" + operationId: get_api_v1_model_accounts + tags: + - model-accounts + security: + - bearerAuth: [] + - cookieAuth: [] + x-required-scope: integrations:read + x-owner-session-required: true + post: + summary: Create a model account + description: |- + Requires an interactive owner browser session and the current collection revision. Accepts only user-encrypted initial account state; credentials are established directly with the credential proxy. + + Requires the integrations:write scope. + + An interactive owner session is required; bearer tokens cannot call this operation. + responses: + "201": + description: Created model account + content: + application/json: + schema: + type: object + properties: + revision: + type: integer + minimum: 0 + maximum: 9007199254740991 + duplicate: + type: boolean + required: + - revision + - duplicate + additionalProperties: false + "400": + $ref: "#/components/responses/Error400" + "401": + $ref: "#/components/responses/Error401" + "403": + $ref: "#/components/responses/Error403" + "404": + $ref: "#/components/responses/Error404" + "409": + $ref: "#/components/responses/Error409" + "415": + $ref: "#/components/responses/Error415" + "429": + $ref: "#/components/responses/Error429" + "500": + $ref: "#/components/responses/Error500" + "503": + $ref: "#/components/responses/Error503" + operationId: post_api_v1_model_accounts + tags: + - model-accounts + security: + - bearerAuth: [] + - cookieAuth: [] + x-required-scope: integrations:write + x-owner-session-required: true + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + id: + type: string + format: uuid + pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ + expectedRevision: + type: integer + minimum: 0 + maximum: 9007199254740991 + idempotencyKey: + type: string + format: uuid + pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ + name: + type: string + minLength: 1 + maxLength: 120 + description: + anyOf: + - type: string + maxLength: 500 + - type: "null" + encryptedPayload: + type: string + minLength: 1 + maxLength: 65536 + required: + - id + - expectedRevision + - idempotencyKey + - name + - description + - encryptedPayload + additionalProperties: false + /api/v1/model-accounts/{accountId}: + delete: + summary: Delete a model account + description: |- + Requires an interactive owner browser session and the current collection revision. Deletion is recorded as a tombstone so future snapshots and unlock reconciliation cannot restore the account. + + Requires the integrations:write scope. + + An interactive owner session is required; bearer tokens cannot call this operation. + responses: + "200": + description: Deleted model account + content: + application/json: + schema: + type: object + properties: + id: + type: string + format: uuid + pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ + deleted: + type: boolean + const: true + revision: + type: integer + minimum: 0 + maximum: 9007199254740991 + duplicate: + type: boolean + required: + - id + - deleted + - revision + - duplicate + additionalProperties: false + "400": + $ref: "#/components/responses/Error400" + "401": + $ref: "#/components/responses/Error401" + "403": + $ref: "#/components/responses/Error403" + "404": + $ref: "#/components/responses/Error404" + "409": + $ref: "#/components/responses/Error409" + "415": + $ref: "#/components/responses/Error415" + "429": + $ref: "#/components/responses/Error429" + "500": + $ref: "#/components/responses/Error500" + "503": + $ref: "#/components/responses/Error503" + operationId: delete_api_v1_model_accounts_accountId + tags: + - model-accounts + security: + - bearerAuth: [] + - cookieAuth: [] + x-required-scope: integrations:write + x-owner-session-required: true + parameters: + - name: accountId + in: path + required: true + schema: + type: string + format: uuid + pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + expectedRevision: + type: integer + minimum: 0 + maximum: 9007199254740991 + idempotencyKey: + type: string + format: uuid + pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ + required: + - expectedRevision + - idempotencyKey + additionalProperties: false + /api/v1/model-accounts/{accountId}/browser-capability: + post: + summary: Issue a short-lived browser capability for a paired model account + description: |- + Requires an interactive owner browser session. Returns a single-use, 60-second capability scoped to the account, action, proxy boot and exact application origin. The browser sends it directly to the paired proxy; Factorize never receives model-account tokens. + + Requires the integrations:write scope. + + An interactive owner session is required; bearer tokens cannot call this operation. + responses: + "200": + description: Paired proxy endpoint and one-use capability + content: + application/json: + schema: + type: object + properties: + endpoint: + type: string + format: uri + capability: + type: string + required: + - endpoint + - capability + additionalProperties: false + "400": + $ref: "#/components/responses/Error400" + "401": + $ref: "#/components/responses/Error401" + "403": + $ref: "#/components/responses/Error403" + "404": + $ref: "#/components/responses/Error404" + "409": + $ref: "#/components/responses/Error409" + "415": + $ref: "#/components/responses/Error415" + "429": + $ref: "#/components/responses/Error429" + "500": + $ref: "#/components/responses/Error500" + "503": + $ref: "#/components/responses/Error503" + operationId: post_api_v1_model_accounts_accountId_browser_capability + tags: + - model-accounts + security: + - bearerAuth: [] + - cookieAuth: [] + x-required-scope: integrations:write + x-owner-session-required: true + parameters: + - name: accountId + in: path + required: true + schema: + type: string + format: uuid + pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + action: + type: string + enum: + - authorize + - status + - cancel + required: + - action + additionalProperties: false /api/v1/unlock-key/verification: get: summary: Get the current encrypted unlock-key verification record