mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-07 16:35:48 +01:00
Merge authenticated idle capability groundwork
* commit '9681b7343aec2ae69bc9368c35382f1a5523a1f4': Authenticate scoped idle capabilities on Cloud control reads
This commit is contained in:
commit
fa4fd04841
4 files changed
+101
-1
No files matched your search
@@ -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 };
|
||||
Reference in new issue
Block a user