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 <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-11-02 11:21:39 -06:00
1 parent 0748b90062
commit 9681b7343a
4 files changed
+101 -1

No files matched your search

+10 -1
View File
@@ -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<Request, VerifiedIdleCapabilities>();
/** 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<string, Record<string, CloudControlAction>> = {
"/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" });
@@ -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.
@@ -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/);
});
});
@@ -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<string, unknown> => Boolean(v && typeof v === "object" && !Array.isArray(v));
const exact = (v: Record<string, unknown>, 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 };