Merge authenticated idle capability groundwork

* commit '9681b7343aec2ae69bc9368c35382f1a5523a1f4':
  Authenticate scoped idle capabilities on Cloud control reads
This commit is contained in:
Dotta committed 2026-11-02 11:24:52 -06:00
commit fa4fd04841
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 };