From 9681b7343aec2ae69bc9368c35382f1a5523a1f4 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 2 Nov 2026 11:21:39 -0600 Subject: [PATCH] Authenticate scoped idle capabilities on Cloud control reads Preserve stack, action, expiry and replay checks while verifying the closed ingress and deadline catalogs. Keep verified request capabilities private to the control middleware. Co-Authored-By: Paperclip --- server/src/middleware/cloud-control.ts | 11 ++++- server/src/services/cloud-runtime-identity.ts | 6 +++ .../idle-incoming-wake-capability.test.ts | 38 +++++++++++++++ .../services/idle-incoming-wake-capability.ts | 47 +++++++++++++++++++ 4 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 server/src/services/idle-incoming-wake-capability.test.ts create mode 100644 server/src/services/idle-incoming-wake-capability.ts 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 };