diff --git a/server/src/middleware/cloud-control.ts b/server/src/middleware/cloud-control.ts index b726aae3092..6165af6b0b6 100644 --- a/server/src/middleware/cloud-control.ts +++ b/server/src/middleware/cloud-control.ts @@ -1,3 +1,5 @@ +import type { Request } from "express"; +import type { VerifiedIdleCapabilities } from "../services/idle-incoming-wake-capability.js"; import type { RequestHandler } from "express"; import { logger } from "./logger.js"; import { @@ -6,6 +8,12 @@ import { type CloudControlAction, } from "../services/cloud-runtime-identity.js"; +const idleCapabilities = new WeakMap(); +/** Only this middleware's verified assertion can install request capabilities. */ +export function verifiedIdleCapabilities(req: Request): VerifiedIdleCapabilities | undefined { + return idleCapabilities.get(req); +} + /** Endpoint → method → the one action a control assertion must name to take it. */ const ACTIONS_BY_ENDPOINT: Record> = { "/api/instance/connector-events/wake": { POST: "connector-events:wake" }, @@ -50,7 +58,8 @@ export function cloudControlMiddleware(): RequestHandler { return; } try { - verifyCloudControlAssertion({ compactJws: assertion, expectedAction }); + const claims = verifyCloudControlAssertion({ compactJws: assertion, expectedAction }); + if (claims.idleCapabilities) idleCapabilities.set(req, claims.idleCapabilities as VerifiedIdleCapabilities); } catch (error) { logger.warn({ err: error }, "Rejected Cloud control assertion"); res.status(401).json({ error: "invalid_cloud_control_assertion" }); diff --git a/server/src/services/cloud-runtime-identity.ts b/server/src/services/cloud-runtime-identity.ts index 80b3333b36f..cffe6a8dbfd 100644 --- a/server/src/services/cloud-runtime-identity.ts +++ b/server/src/services/cloud-runtime-identity.ts @@ -1,3 +1,4 @@ +import { parseIdleCapabilities, type IdleCapabilities } from "./idle-incoming-wake-capability.js"; import { createPublicKey, timingSafeEqual, verify, type JsonWebKey } from "node:crypto"; import { eq } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; @@ -491,6 +492,7 @@ export type CloudControlClaims = { sub: string; action: CloudControlAction; requestId: string; + idleCapabilities?: IdleCapabilities; iat: number; exp: number; }; @@ -572,6 +574,10 @@ export function verifyCloudControlAssertion(input: { ) { throw new Error("Cloud control assertion is expired or has an invalid lifetime"); } + if (payload.idleCapabilities !== undefined) { + if (payload.action !== "task-drain:read") throw new Error("Idle capabilities require task-drain:read"); + payload.idleCapabilities = parseIdleCapabilities(payload.idleCapabilities); + } // Consumed LAST, only after every other check passed: a rejected // assertion must not burn its request id, or an attacker could deny a // legitimate call by replaying a mangled copy of it first. diff --git a/server/src/services/idle-incoming-wake-capability.test.ts b/server/src/services/idle-incoming-wake-capability.test.ts new file mode 100644 index 00000000000..b917b9f0b27 --- /dev/null +++ b/server/src/services/idle-incoming-wake-capability.test.ts @@ -0,0 +1,38 @@ +import { generateKeyPairSync, sign } from "node:crypto"; +import { describe, expect, it } from "vitest"; +import { parseIdleCapabilities } from "./idle-incoming-wake-capability.js"; +import { verifyCloudControlAssertion, resetCloudControlReplayFenceForTests, CLOUD_CONTROL_AUDIENCE, CLOUD_CONTROL_JWS_TYPE, CLOUD_RUNTIME_IDENTITY_ISSUER } from "./cloud-runtime-identity.js"; +const incoming = { version: 1 as const, ownerId: "00000000-0000-4000-8000-000000000001", generation: "a".repeat(64), routes: ["tenant-api-http-v1" as const] }; +const invalid: unknown[] = [null, {}, { other: true }, { deadlineCatalog: "other" }, { incomingWake: { ...incoming, version: 2 } }, + { incomingWake: { ...incoming, routes: ["all"] } }, { incomingWake: { ...incoming, routes: ["tenant-api-http-v1", "tenant-api-http-v1"] } }, + { incomingWake: { ...incoming, ownerId: "other" } }, { incomingWake: { ...incoming, extra: true } }, + { incomingWake: { ...incoming, githubBroker: { instanceId: "instance", bindingsFingerprint: "b".repeat(64) } } }, + { incomingWake: { ...incoming, routes: ["github-broker-events-v1"] } }]; + +describe("signed idle capabilities", () => { + it.each(invalid)("rejects unknown/partial capabilities: %j", value => { expect(() => parseIdleCapabilities(value)).toThrow(); }); + it("detaches and freezes the exact known catalogs", () => { + const source = { incomingWake: { ...incoming, routes: [...incoming.routes] }, deadlineCatalog: "domain_deadlines_v1" }; + const parsed = parseIdleCapabilities(source); source.incomingWake.routes.length = 0; + expect(parsed.incomingWake?.routes).toEqual(["tenant-api-http-v1"]); + expect(Object.isFrozen(parsed.incomingWake?.routes)).toBe(true); + }); + it("binds capability to signed stack, action and replay fence", () => { + resetCloudControlReplayFenceForTests(); + const pair = generateKeyPairSync("ed25519"); const now = new Date("2099-01-01T00:00:00Z"); + const jwk = { ...pair.publicKey.export({ format: "jwk" }), kid: "idle", use: "sig", alg: "EdDSA" }; + const env = { PAPERCLIP_CLOUD_STACK_ID: "stack", PAPERCLIP_CLOUD_RUNTIME_IDENTITY_JWKS: JSON.stringify({ keys: [jwk] }) }; + const encode = (v: unknown) => Buffer.from(JSON.stringify(v)).toString("base64url"); + const token = (overrides: object = {}) => { + const header = encode({ alg: "EdDSA", typ: CLOUD_CONTROL_JWS_TYPE, kid: "idle" }); + const body = encode({ v: 1, iss: CLOUD_RUNTIME_IDENTITY_ISSUER, aud: CLOUD_CONTROL_AUDIENCE, sub: "stack", action: "task-drain:read", requestId: "same", iat: now.getTime()/1000, exp: now.getTime()/1000 + 60, idleCapabilities: { incomingWake: incoming, deadlineCatalog: "domain_deadlines_v1" }, ...overrides }); + return `${header}.${body}.${sign(null, Buffer.from(`${header}.${body}`), pair.privateKey).toString("base64url")}`; + }; + const verify = (compactJws: string) => verifyCloudControlAssertion({ compactJws, env, now, expectedAction: "task-drain:read" }); + expect(() => verify(token({ sub: "foreign" }))).toThrow(); + expect(() => verify(token({ action: "task-drain:start" }))).toThrow(); + expect(() => verify(token({ idleCapabilities: { deadlineCatalog: "unknown" } }))).toThrow(); + expect(verify(token()).idleCapabilities?.incomingWake).toEqual(incoming); + expect(() => verify(token())).toThrow(/already been used/); + }); +}); diff --git a/server/src/services/idle-incoming-wake-capability.ts b/server/src/services/idle-incoming-wake-capability.ts new file mode 100644 index 00000000000..003a857f0b1 --- /dev/null +++ b/server/src/services/idle-incoming-wake-capability.ts @@ -0,0 +1,47 @@ +/** Closed Cloud-owned ingress catalog. No credential material belongs in this proof. */ +export interface IdleIncomingWakeCapability { + version: 1; + ownerId: string; + generation: string; + routes: ("tenant-api-http-v1" | "github-broker-events-v1")[]; + githubBroker?: { instanceId: string; bindingsFingerprint: string }; +} +export interface IdleCapabilities { + incomingWake?: IdleIncomingWakeCapability; + deadlineCatalog?: "domain_deadlines_v1"; +} +const object = (v: unknown): v is Record => Boolean(v && typeof v === "object" && !Array.isArray(v)); +const exact = (v: Record, keys: string[]) => Object.keys(v).every(k => keys.includes(k)); +const digest = (v: unknown): v is string => typeof v === "string" && /^[a-f0-9]{64}$/.test(v); +export function parseIdleCapabilities(value: unknown): IdleCapabilities { + if (!object(value) || !exact(value, ["incomingWake", "deadlineCatalog"]) || !Object.keys(value).length) + throw new Error("Invalid idle capabilities"); + if (value.deadlineCatalog !== undefined && value.deadlineCatalog !== "domain_deadlines_v1") + throw new Error("Unknown idle deadline catalog"); + if (value.incomingWake !== undefined) { + const v = value.incomingWake; + if (!object(v) || !exact(v, ["version", "ownerId", "generation", "routes", "githubBroker"]) + || v.version !== 1 || typeof v.ownerId !== "string" || !/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(v.ownerId) + || !digest(v.generation) || !Array.isArray(v.routes) || v.routes.length < 1 || v.routes.length > 2 + || v.routes.some(r => !["tenant-api-http-v1", "github-broker-events-v1"].includes(r)) + || new Set(v.routes).size !== v.routes.length || [...v.routes].sort().join() !== v.routes.join()) + throw new Error("Invalid incoming wake capability"); + if (v.routes.includes("github-broker-events-v1")) { + const b = v.githubBroker; + if (!object(b) || !exact(b, ["instanceId", "bindingsFingerprint"]) || typeof b.instanceId !== "string" + || !/^[A-Za-z0-9_-]{1,200}$/.test(b.instanceId) || !digest(b.bindingsFingerprint)) + throw new Error("Invalid GitHub wake binding"); + } else if (v.githubBroker !== undefined) throw new Error("Unexpected GitHub wake binding"); + } + // Return a detached immutable snapshot; request callers cannot mutate a verified claim. + const parsed = JSON.parse(JSON.stringify(value)) as IdleCapabilities; + if (parsed.incomingWake) { + Object.freeze(parsed.incomingWake.routes); + if (parsed.incomingWake.githubBroker) Object.freeze(parsed.incomingWake.githubBroker); + Object.freeze(parsed.incomingWake); + } + return Object.freeze(parsed); +} + +declare const verified: unique symbol; +export type VerifiedIdleCapabilities = IdleCapabilities & { readonly [verified]: true };