diff --git a/doc/idle-incoming-wake.md b/doc/idle-incoming-wake.md index 633d679540f..8b4b123d5d4 100644 --- a/doc/idle-incoming-wake.md +++ b/doc/idle-incoming-wake.md @@ -13,7 +13,7 @@ Older Cloud/app combinations retain their conservative behavior. ## Supported profiles -`tenant-api-http-v1` allows saved agent and board API key rows to remain while +`tenant-api-http-v2` allows saved agent and board API key rows to remain while sleeping. Cloud resolves the target from its verified host registry, coalesces a bounded wake, waits for tenant readiness, refreshes routing, then forwards the original HTTP request once. Ordinary `/api/instance` administration routes are @@ -23,9 +23,10 @@ DELETE do not gain this wake path. The Cloud capability authenticates the **host's ability to receive work**. Opaque tenant keys are authenticated only by the awakened tenant, under current key, -expiry, user, membership, company, agent and resource permissions. No private -credential registry is copied into Cloud. Unknown/revoked keys can cause a -bounded wake and then receive a normal denial; they cannot execute work. +expiry, user, membership, company, agent and resource permissions. No raw key or original +credential hash is copied into Cloud. A signed final-drain projection supplies +only per-cycle derived wake fingerprints. Unknown inputs cannot consume +provider-start quota. Tenant authentication still rejects any later revocation. Successful current board/agent key authentication followed by a successful HTTP response retains ten minutes of local API activity, measured with a monotonic @@ -93,3 +94,10 @@ The source/loopback/PostgreSQL tests establish the exercised protocol boundaries they do not establish a fleet deployment, actual provider/Neon suspension, provider startup latency or cost savings. Deployed combined qualification remains separate and must include every enabled transport. + + +## Pre-wake API admission + +The v2 API profile requires an authenticated per-sleep-cycle key projection before compute stops. The signed challenge is derived from the newly random owned idle hold and stack. In the same read-only snapshot, the app projects up to 1000 active key rows into domain-separated salted fingerprints; original key hashes and raw keys stay private. Board-key expiry is carried and enforced before wake. Unknown, duplicate, malformed or overflow projection fails closed. The complete initial and final receipts must match; changing a credential or challenge refuses stop. + +Cloud stores the final receipt in one internal stack column under the sleep operation lock. Unknown requests cannot consume provider-start quota. Possession of a matching snapshot key only admits a bounded wake; tenant authentication and current permissions still run on delivery. A key revoked after the snapshot can therefore receive no tenant authority, even if it once qualified for wake. Wake claims recheck the exact cycle, matching key and expiry atomically. Normal next sleep re-reads revocations. Successful wake, manual/archive/delete or a known sleep abort clears the receipt; uncertain stop and a same-cycle failed wake retain recovery. A restarted host reads the durable receipt. No global TTL strands nonexpiring saved keys during long sleep, and no periodic credential refresh worker is added. diff --git a/server/src/__tests__/idle-incoming-wake.test.ts b/server/src/__tests__/idle-incoming-wake.test.ts index 6ffd6a927fd..077e583e537 100644 --- a/server/src/__tests__/idle-incoming-wake.test.ts +++ b/server/src/__tests__/idle-incoming-wake.test.ts @@ -1,3 +1,4 @@ +import { matchesApiWakeAdmission } from "../services/idle-api-wake-admission.js"; import { generateKeyPairSync, randomUUID, sign } from "node:crypto"; import express from "express"; import request from "supertest"; @@ -81,7 +82,8 @@ const capability: IdleIncomingWakeCapability = { version: 1, ownerId, generation: "a".repeat(64), - routes: ["tenant-api-http-v1"], + apiKeyProof: { version: 1 as const, challenge: "c".repeat(64) }, + routes: ["tenant-api-http-v2"], }; function verified(incomingWake = capability): VerifiedIdleCapabilities { const pair = generateKeyPairSync("ed25519"), @@ -135,6 +137,7 @@ function verified(incomingWake = capability): VerifiedIdleCapabilities { db = createDb(url ?? database!.connectionString, { maxConnections: 4 }); await db.execute(sql`SELECT 1 FROM agent_api_keys LIMIT 1`); // Coordinator migrates the fresh fixture; no embedded fallback. vi.stubEnv("PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN", "fixture-only"); + vi.stubEnv("PAPERCLIP_CLOUD_STACK_ID", "fixture-stack"); setCloudRuntimeConfigSnapshot({ version: 1, revision: 0, config: null }); }); afterAll(async () => { @@ -238,6 +241,17 @@ function verified(incomingWake = capability): VerifiedIdleCapabilities { backgroundWork: "none", incomingWake: capability, }); + const receipt = (await read(verified())).apiWakeAdmission!; + expect(receipt.keys).toHaveLength(2); + expect(matchesApiWakeAdmission(receipt, { stackId: "fixture-stack", token: f.board.token, now })).toBe(true); + expect(matchesApiWakeAdmission(receipt, { stackId: "fixture-stack", token: f.agent.token, now })).toBe(true); + const serialized = JSON.stringify(receipt); + expect(serialized).not.toContain(f.board.token); + expect(serialized).not.toContain(f.agent.token); + await db.update(boardApiKeys).set({ revokedAt: new Date() }).where(eq(boardApiKeys.id, f.board.id)); + const changedReceipt = (await read(verified())).apiWakeAdmission!; + expect(changedReceipt.generation).not.toBe(receipt.generation); + expect(matchesApiWakeAdmission(changedReceipt, { stackId: "fixture-stack", token: f.board.token, now })).toBe(false); expect( (await read(verified({ ...capability, ownerId: randomUUID() }))) .backgroundWork, @@ -456,7 +470,7 @@ function verified(incomingWake = capability): VerifiedIdleCapabilities { const stop = registerConnectionEventWake(db, () => {}); const botCap: IdleIncomingWakeCapability = { ...capability, - routes: ["github-broker-events-v1", "tenant-api-http-v1"], + routes: ["github-broker-events-v1", "tenant-api-http-v2"], githubBroker: { instanceId: "fixture-instance", bindingsFingerprint: incomingWakeFingerprint([botTuple]), @@ -534,7 +548,7 @@ function verified(incomingWake = capability): VerifiedIdleCapabilities { vi.stubEnv("PAPERCLIP_CLOUD_CONNECTOR_BASE_URL", "http://127.0.0.1"); const brokerCap: IdleIncomingWakeCapability = { ...capability, - routes: ["github-broker-events-v1", "tenant-api-http-v1"], + routes: ["github-broker-events-v1", "tenant-api-http-v2"], githubBroker: { instanceId: "fixture-instance", bindingsFingerprint: incomingWakeFingerprint([ diff --git a/server/src/__tests__/instance-settings-routes.test.ts b/server/src/__tests__/instance-settings-routes.test.ts index 08123e23a55..e34dd5cc08b 100644 --- a/server/src/__tests__/instance-settings-routes.test.ts +++ b/server/src/__tests__/instance-settings-routes.test.ts @@ -1,3 +1,4 @@ +import { generateKeyPairSync, randomUUID, sign } from "node:crypto"; import express from "express"; import request from "supertest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -81,21 +82,24 @@ function localAdminActor() { describe("instance settings routes", () => { const routeModules = hoistModuleGraph(registerModuleMocks, async () => { - const [{ errorHandler }, { instanceSettingsRoutes }] = await Promise.all([ + const [{ errorHandler }, { instanceSettingsRoutes }, { cloudControlMiddleware }, controlIdentity] = await Promise.all([ vi.importActual("../middleware/index.js"), vi.importActual("../routes/instance-settings.js"), + vi.importActual("../middleware/cloud-control.js"), + vi.importActual("../services/cloud-runtime-identity.js"), ]); - return { errorHandler, instanceSettingsRoutes }; + return { errorHandler, instanceSettingsRoutes, cloudControlMiddleware, controlIdentity }; }); - function createApp(actor: any, pluginWorkers?: PluginWorkerManager, prepareBackup?: () => Promise) { - const { errorHandler, instanceSettingsRoutes } = routeModules.value; + function createApp(actor: any, pluginWorkers?: PluginWorkerManager, prepareBackup?: () => Promise, verifyControl = false) { + const { errorHandler, instanceSettingsRoutes, cloudControlMiddleware } = routeModules.value; const app = express(); app.use(express.json()); app.use((req, _res, next) => { req.actor = actor; next(); }); + if (verifyControl) app.use(cloudControlMiddleware()); app.use("/api", instanceSettingsRoutes(mockDb as any, pluginWorkers, prepareBackup)); app.use(errorHandler); return app; @@ -1101,7 +1105,7 @@ describe("instance settings routes", () => { await request(createApp(adminActor)).post("/api/instance/task-drain").send({ purpose: "idle", ttlMs: 60_000 }).expect(200); expect(mockHeartbeatService.computeTaskDrain).toHaveBeenCalledWith({ purpose: "idle", ttlMs: 60_000 }); await request(createApp(adminActor)).get(`/api/instance/task-drain?idleSleepSafety=1&ownerId=${ownerId}`).expect(200); - expect(mockReadIdleSleepSafety).toHaveBeenLastCalledWith(mockDb, expect.any(Function), Date.now, ownerId, expect.any(Function), undefined, undefined, false, false); + expect(mockReadIdleSleepSafety).toHaveBeenLastCalledWith(mockDb, expect.any(Function), Date.now, ownerId, expect.any(Function), undefined, undefined, false, false, { incomingWake: undefined, deadlineCapability: undefined }); }); it.each([{}, { ttlMs: null }, { ttlMs: 1 }, { ttlMs: 300_001 }])("rejects an unbounded or invalid idle TTL: %j", async body => { @@ -1128,7 +1132,7 @@ describe("instance settings routes", () => { const res = await request(createApp(adminActor)).get("/api/instance/task-drain?idleSleepSafety=1"); expect(res.status).toBe(200); expect(res.body).toEqual({ ...idleStatus, idleSleepSafety: report }); - expect(mockReadIdleSleepSafety).toHaveBeenCalledWith(mockDb, expect.any(Function), Date.now, undefined, expect.any(Function), undefined, undefined, false, false); + expect(mockReadIdleSleepSafety).toHaveBeenCalledWith(mockDb, expect.any(Function), Date.now, undefined, expect.any(Function), undefined, undefined, false, false, { incomingWake: undefined, deadlineCapability: undefined }); expect(mockReadIdleSleepSafety.mock.calls[0][1]()).toEqual(idleStatus); }); @@ -1142,7 +1146,7 @@ describe("instance settings routes", () => { .get("/api/instance/task-drain?idleSleepSafety=1&idleSleepDiagnostics=1&ownerId=fixture-owner").expect(200); expect(response.body.idleSleepSafety.diagnostics.checks).toEqual([{ reason: "agent_api_keys", state: "present" }]); expect(mockReadIdleSleepSafety).toHaveBeenCalledWith(mockDb, expect.any(Function), Date.now, - "fixture-owner", expect.any(Function), undefined, undefined, false, false, { collectDiagnostics: true }); + "fixture-owner", expect.any(Function), undefined, undefined, false, false, { incomingWake: undefined, deadlineCapability: undefined, collectDiagnostics: true }); mockReadIdleSleepSafety.mockClear(); for (const actor of [nonAdminActor, { type: "agent", agentId: "agent-1", companyId: "company-1", source: "api_key" }]) { await request(createApp(actor)) @@ -1164,7 +1168,104 @@ describe("instance settings routes", () => { await request(createApp({ ...adminActor, source: "cloud_control" })) .get(`/api/instance/task-drain?idleSleepSafety=1&scheduledWake=${version}&ownerId=fixture-owner`).expect(200); expect(mockReadIdleSleepSafety).toHaveBeenCalledWith(mockDb, expect.any(Function), Date.now, - "fixture-owner", expect.any(Function), undefined, undefined, true, version === "2"); + "fixture-owner", expect.any(Function), undefined, undefined, true, version === "2", { incomingWake: undefined, deadlineCapability: undefined }); + }); + + describe("signed incoming capability handoff", () => { + const createControlApp = (actor: any) => createApp(actor, undefined, undefined, true); + const keyPair = generateKeyPairSync("ed25519"); + const publicJwk = { ...keyPair.publicKey.export({ format: "jwk" }), kid: "idle-route-test", alg: "EdDSA", use: "sig" }; + const ownerId = "d0b833f4-4098-42de-8420-1907f3aa4895"; + const catalog = { + incomingWake: { version: 1, ownerId, generation: "a".repeat(64), apiKeyProof: { version: 1, challenge: "c".repeat(64) }, routes: ["tenant-api-http-v2"] }, + deadlineCatalog: "domain_deadlines_v1", + }; + const path = `/api/instance/task-drain?idleSleepSafety=1&ownerId=${ownerId}`; + function assertion(claims: Record = {}) { + const identity = routeModules.value.controlIdentity; + const encode = (value: unknown) => Buffer.from(JSON.stringify(value)).toString("base64url"); + const iat = Math.floor(Date.now() / 1000); + const header = encode({ alg: "EdDSA", typ: identity.CLOUD_CONTROL_JWS_TYPE, kid: publicJwk.kid }); + const payload = encode({ v: 1, iss: identity.CLOUD_RUNTIME_IDENTITY_ISSUER, aud: identity.CLOUD_CONTROL_AUDIENCE, + sub: "stack-idle-route-test", action: "task-drain:read", requestId: randomUUID(), iat, exp: iat + 60, + idleCapabilities: catalog, ...claims }); + return `${header}.${payload}.${sign(null, Buffer.from(`${header}.${payload}`, "ascii"), keyPair.privateKey).toString("base64url")}`; + } + beforeEach(() => { + vi.stubEnv("PAPERCLIP_CLOUD_RUNTIME_IDENTITY_JWKS", JSON.stringify({ keys: [publicJwk] })); + vi.stubEnv("PAPERCLIP_CLOUD_STACK_ID", "stack-idle-route-test"); + routeModules.value.controlIdentity.resetCloudControlReplayFenceForTests(); + mockHeartbeatService.getTaskDrainStatus.mockReturnValue(idleStatus); + mockReadIdleSleepSafety.mockResolvedValue({ version: 3, backgroundWork: "none" }); + }); + afterEach(() => vi.unstubAllEnvs()); + + it("passes only the immutable signed catalog through real middleware and the production route", async () => { + const token = assertion(); + const response = await request(createControlApp({ type: "none" })) + .get(`${path}&incomingWake=${encodeURIComponent(JSON.stringify({ ...catalog.incomingWake, generation: "b".repeat(64) }))}`) + .set("x-paperclip-cloud-control", token) + .send({ idleCapabilities: { incomingWake: { ...catalog.incomingWake, ownerId: randomUUID() } } }).expect(200); + expect(response.body.idleSleepSafety).toEqual({ version: 3, backgroundWork: "none" }); + expect(mockReadIdleSleepSafety).toHaveBeenCalledExactlyOnceWith(mockDb, expect.any(Function), Date.now, + ownerId, expect.any(Function), undefined, undefined, false, false, + { incomingWake: catalog, deadlineCapability: undefined }); + const options = mockReadIdleSleepSafety.mock.calls[0][9]; + expect(options.deadlineCapability).toBeUndefined(); + expect(Object.isFrozen(options.incomingWake)).toBe(true); + expect(Object.isFrozen(options.incomingWake.incomingWake)).toBe(true); + expect(Object.isFrozen(options.incomingWake.incomingWake.routes)).toBe(true); + await request(createControlApp({ type: "none" })).get(path).set("x-paperclip-cloud-control", token).expect(401); + expect(mockReadIdleSleepSafety).toHaveBeenCalledTimes(1); + }); + + it("passes the signed deadline catalog only for its explicit named query", async () => { + await request(createControlApp({ type: "none" })).get(`${path}&scheduledWake=domain-deadlines-v1`) + .set("x-paperclip-cloud-control", assertion()).expect(200); + expect(mockReadIdleSleepSafety).toHaveBeenCalledExactlyOnceWith(mockDb, expect.any(Function), Date.now, + ownerId, expect.any(Function), undefined, undefined, true, true, + { incomingWake: catalog, deadlineCapability: catalog }); + const options = mockReadIdleSleepSafety.mock.calls[0][9]; + expect(options.incomingWake).toBe(options.deadlineCapability); + expect(Object.isFrozen(options.deadlineCapability)).toBe(true); + }); + + it.each([ + ["wrong stack", () => ({ sub: "other-stack" })], + ["wrong action", () => ({ action: "task-drain:start" })], + ["expired", () => ({ iat: Math.floor(Date.now() / 1000) - 600, exp: Math.floor(Date.now() / 1000) - 300 })], + ["unsupported catalog", () => ({ idleCapabilities: { incomingWake: { ...catalog.incomingWake, routes: ["unverified-path"] } } })], + ])("rejects %s before safety inspection", async (_name, claims) => { + await request(createControlApp(adminActor)).get(path).set("x-paperclip-cloud-control", assertion(claims())).expect(401); + expect(mockReadIdleSleepSafety).not.toHaveBeenCalled(); + }); + + it("rejects a tampered catalog signature before safety inspection", async () => { + const [header, payload, signature] = assertion().split("."); + const changed = JSON.parse(Buffer.from(payload!, "base64url").toString()); + changed.idleCapabilities.incomingWake.generation = "b".repeat(64); + const tampered = `${header}.${Buffer.from(JSON.stringify(changed)).toString("base64url")}.${signature}`; + await request(createControlApp(adminActor)).get(path).set("x-paperclip-cloud-control", tampered).expect(401); + expect(mockReadIdleSleepSafety).not.toHaveBeenCalled(); + }); + + it("leaves legacy signed reads without a catalog conservative", async () => { + await request(createControlApp({ type: "none" })).get(path) + .set("x-paperclip-cloud-control", assertion({ idleCapabilities: undefined })).expect(200); + expect(mockReadIdleSleepSafety.mock.calls[0][9]).toEqual({ incomingWake: undefined, deadlineCapability: undefined }); + }); + + it("cannot install capabilities from tenant, actor, query or body values", async () => { + for (const source of ["session", "cloud_tenant", "local_implicit", "cloud_control"]) { + const actor = { ...adminActor, source, idleCapabilities: catalog, incomingWake: catalog }; + await request(createControlApp(actor)).get(`${path}&incomingWake=${encodeURIComponent(JSON.stringify(catalog))}`) + .send({ idleCapabilities: catalog }).expect(200); + expect(mockReadIdleSleepSafety.mock.lastCall?.[9]).toEqual({ incomingWake: undefined, deadlineCapability: undefined }); + } + mockReadIdleSleepSafety.mockClear(); + await request(createControlApp(nonAdminActor)).get(path).send({ idleCapabilities: catalog }).expect(403); + expect(mockReadIdleSleepSafety).not.toHaveBeenCalled(); + }); }); it("uses the live worker manager for inspection and releases only the owned hold", async () => { @@ -1199,7 +1300,7 @@ describe("instance settings routes", () => { expect(mockReadIdleSleepSafety).not.toHaveBeenCalled(); await request(app).get("/api/instance/task-drain?idleSleepSafety=1&ownerId=fixture-owner").expect(200); expect(mockReadIdleSleepSafety).toHaveBeenCalledWith(mockDb, expect.any(Function), Date.now, - "fixture-owner", expect.any(Function), undefined, backup, false, false); + "fixture-owner", expect.any(Function), undefined, backup, false, false, { incomingWake: undefined, deadlineCapability: undefined }); mockReadIdleSleepSafety.mockClear(); await request(createApp(nonAdminActor, undefined, backup)).get("/api/instance/task-drain?idleSleepSafety=1").expect(403); expect(mockReadIdleSleepSafety).not.toHaveBeenCalled(); @@ -1221,7 +1322,7 @@ describe("instance settings routes", () => { const control = createApp({ ...adminActor, source: "cloud_control" }, undefined, backup); await request(control).get("/api/instance/task-drain?idleSleepSafety=1&ownerId=fixture-owner").expect(200); expect(mockReadIdleSleepSafety).toHaveBeenCalledWith(mockDb, expect.any(Function), Date.now, - "fixture-owner", expect.any(Function), undefined, backup, false, false); + "fixture-owner", expect.any(Function), undefined, backup, false, false, { incomingWake: undefined, deadlineCapability: undefined }); } finally { delete process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN; } diff --git a/server/src/services/idle-api-wake-admission.test.ts b/server/src/services/idle-api-wake-admission.test.ts new file mode 100644 index 00000000000..604eb2cb4ef --- /dev/null +++ b/server/src/services/idle-api-wake-admission.test.ts @@ -0,0 +1,33 @@ +import { createHash } from "node:crypto"; +import { describe, expect, it } from "vitest"; +import { apiWakeFingerprint, createApiWakeAdmission, parseApiWakeAdmission, matchesApiWakeAdmission } from "./idle-api-wake-admission.js"; + +const token = "api-wake-vector-token"; +const keyHash = createHash("sha256").update(token).digest("hex"); +const input = { stackId: "vector-stack", ownerId: "00000000-0000-4000-8000-000000000001", capabilityGeneration: "a".repeat(64), challenge: "c".repeat(64) }; +const fingerprint = apiWakeFingerprint({ ...input, keyHash }); +const receipt = createApiWakeAdmission({ ...input, keys: [{ fingerprint, expiresAt: null }] }); +describe("API wake receipt wire vectors", () => { + it("uses exact domain-separated vectors and exports neither token nor database hash", () => { + expect(fingerprint).toBe("6478041b90f4665eeca8403186fefedc456bf1322f38dab9fb89212eecaafd43"); + expect(JSON.stringify(receipt)).not.toContain(token); + expect(JSON.stringify(receipt)).not.toContain(keyHash); + expect(matchesApiWakeAdmission(receipt, { stackId: input.stackId, token, now: new Date("2099-01-01").getTime() })).toBe(true); + expect(matchesApiWakeAdmission(receipt, { stackId: "other", token, now: 0 })).toBe(false); + expect(matchesApiWakeAdmission(receipt, { stackId: input.stackId, token: "unknown", now: 0 })).toBe(false); + }); + it("enforces board expiry and canonical unique bounded complete receipts", () => { + const changedChallenge = createApiWakeAdmission({ ...input, challenge: "b".repeat(64), keys: receipt.keys }); + expect(matchesApiWakeAdmission(changedChallenge, { stackId: input.stackId, token, now: 0 })).toBe(false); + const expiring = createApiWakeAdmission({ ...input, keys: [{ fingerprint, expiresAt: new Date(1000).toISOString() }] }); + expect(matchesApiWakeAdmission(expiring, { stackId: input.stackId, token, now: 999 })).toBe(true); + expect(matchesApiWakeAdmission(expiring, { stackId: input.stackId, token, now: 1000 })).toBe(false); + for (const invalid of [ + { ...receipt, generation: "b".repeat(64) }, { ...receipt, challenge: "b".repeat(64) }, + { ...receipt, extra: true }, { ...receipt, keys: [...receipt.keys, ...receipt.keys] }, + { ...receipt, keys: Array.from({ length: 1001 }, () => receipt.keys[0]) }, + { ...receipt, keys: [{ fingerprint, expiresAt: "2099-01-01" }] }, + { ...receipt, keys: [{ fingerprint: "d".repeat(64), expiresAt: null }, { fingerprint: "a".repeat(64), expiresAt: null }] }, + ]) expect(() => parseApiWakeAdmission(invalid)).toThrow(); + }); +}); diff --git a/server/src/services/idle-api-wake-admission.ts b/server/src/services/idle-api-wake-admission.ts new file mode 100644 index 00000000000..26ed499a231 --- /dev/null +++ b/server/src/services/idle-api-wake-admission.ts @@ -0,0 +1,56 @@ +import { createHash } from "node:crypto"; + +export type ApiWakeClaim = { generation: string; fingerprint: string }; +export interface ApiWakeAdmission { + version: 1; + stackId: string; + ownerId: string; + capabilityGeneration: string; + challenge: string; + generation: string; + keys: { fingerprint: string; expiresAt: string | null }[]; +} +const digest = (value: unknown): value is string => typeof value === "string" && /^[a-f0-9]{64}$/.test(value); +const object = (value: unknown): value is Record => Boolean(value && typeof value === "object" && !Array.isArray(value)); +const exact = (value: Record, keys: string[]) => Object.keys(value).length === keys.length && Object.keys(value).every(key => keys.includes(key)); +const generation = (value: Omit) => createHash("sha256").update(JSON.stringify([ + "paperclip-api-wake-receipt-v1", value.version, value.stackId, value.ownerId, value.capabilityGeneration, value.challenge, + value.keys.map(key => [key.fingerprint, key.expiresAt]), +])).digest("hex"); +export function apiWakeFingerprint(input: { stackId: string; ownerId: string; challenge: string; keyHash: string }): string { + if (!digest(input.keyHash) || !digest(input.challenge)) throw new Error("Invalid API wake fingerprint input"); + return createHash("sha256").update(JSON.stringify(["paperclip-api-wake-v1", input.stackId, input.ownerId, input.challenge, input.keyHash])).digest("hex"); +} +/** Strict bounded canonical wire contract. Parsing never grants tenant permissions. */ +export function parseApiWakeAdmission(value: unknown): ApiWakeAdmission { + if (!object(value) || !exact(value, ["version", "stackId", "ownerId", "capabilityGeneration", "challenge", "generation", "keys"]) + || value.version !== 1 || typeof value.stackId !== "string" || !/^[A-Za-z0-9_-]{1,200}$/.test(value.stackId) + || typeof value.ownerId !== "string" || !/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(value.ownerId) + || !digest(value.capabilityGeneration) || !digest(value.challenge) || !digest(value.generation) + || !Array.isArray(value.keys) || value.keys.length > 1000) throw new Error("Invalid API wake receipt"); + let previous = ""; + for (const key of value.keys) { + if (!object(key) || !exact(key, ["fingerprint", "expiresAt"]) || !digest(key.fingerprint) || key.fingerprint <= previous + || !(key.expiresAt === null || typeof key.expiresAt === "string" && key.expiresAt.length <= 30 && Number.isFinite(Date.parse(key.expiresAt)) + && new Date(key.expiresAt).toISOString() === key.expiresAt)) throw new Error("Invalid API wake receipt key"); + previous = key.fingerprint; + } + const parsed = value as unknown as ApiWakeAdmission; + if (generation(parsed) !== parsed.generation) throw new Error("API wake receipt generation changed"); + return { version: 1, stackId: parsed.stackId, ownerId: parsed.ownerId, capabilityGeneration: parsed.capabilityGeneration, + challenge: parsed.challenge, generation: parsed.generation, + keys: parsed.keys.map(key => ({ fingerprint: key.fingerprint, expiresAt: key.expiresAt })) }; +} +export function createApiWakeAdmission(input: Omit): ApiWakeAdmission { + const canonical = { version: 1 as const, ...input, keys: [...input.keys].sort((a, b) => a.fingerprint.localeCompare(b.fingerprint)) }; + return parseApiWakeAdmission({ ...canonical, generation: generation(canonical) }); +} +export function authorizeApiWakeAdmission(receipt: ApiWakeAdmission, input: { stackId: string; token: string; now: number }): ApiWakeClaim | undefined { + if (receipt.stackId !== input.stackId || input.token.length < 1 || input.token.length > 8192 || /\s/.test(input.token)) return; + const fingerprint = apiWakeFingerprint({ ...receipt, keyHash: createHash("sha256").update(input.token).digest("hex") }); + return receipt.keys.some(key => key.fingerprint === fingerprint && (key.expiresAt === null || Date.parse(key.expiresAt) > input.now)) + ? { generation: receipt.generation, fingerprint } : undefined; +} +export function matchesApiWakeAdmission(receipt: ApiWakeAdmission, input: { stackId: string; token: string; now: number }): boolean { + return authorizeApiWakeAdmission(receipt, input) !== undefined; +} diff --git a/server/src/services/idle-incoming-wake-capability.test.ts b/server/src/services/idle-incoming-wake-capability.test.ts index 69e6f28707a..c3b9cd70da6 100644 --- a/server/src/services/idle-incoming-wake-capability.test.ts +++ b/server/src/services/idle-incoming-wake-capability.test.ts @@ -12,7 +12,8 @@ const incoming = { version: 1 as const, ownerId: "00000000-0000-4000-8000-000000000001", generation: "a".repeat(64), - routes: ["tenant-api-http-v1" as const], + apiKeyProof: { version: 1 as const, challenge: "c".repeat(64) }, + routes: ["tenant-api-http-v2" as const], }; const invalid: unknown[] = [ null, @@ -24,7 +25,7 @@ const invalid: unknown[] = [ { incomingWake: { ...incoming, - routes: ["tenant-api-http-v1", "tenant-api-http-v1"], + routes: ["tenant-api-http-v2", "tenant-api-http-v2"], }, }, { incomingWake: { ...incoming, ownerId: "other" } }, @@ -52,7 +53,7 @@ describe("signed idle capabilities", () => { }; const parsed = parseIdleCapabilities(source); source.incomingWake.routes.length = 0; - expect(parsed.incomingWake?.routes).toEqual(["tenant-api-http-v1"]); + expect(parsed.incomingWake?.routes).toEqual(["tenant-api-http-v2"]); expect(Object.isFrozen(parsed.incomingWake?.routes)).toBe(true); }); it("binds capability to signed stack, action and replay fence", () => { diff --git a/server/src/services/idle-incoming-wake-capability.ts b/server/src/services/idle-incoming-wake-capability.ts index af3d3d906c3..48dfe19c654 100644 --- a/server/src/services/idle-incoming-wake-capability.ts +++ b/server/src/services/idle-incoming-wake-capability.ts @@ -3,7 +3,8 @@ export interface IdleIncomingWakeCapability { version: 1; ownerId: string; generation: string; - routes: ("tenant-api-http-v1" | "github-broker-events-v1")[]; + routes: ("tenant-api-http-v2" | "github-broker-events-v1")[]; + apiKeyProof?: { version: 1; challenge: string }; githubBroker?: { instanceId: string; bindingsFingerprint: string }; } export interface IdleCapabilities { @@ -38,6 +39,7 @@ export function parseIdleCapabilities(value: unknown): IdleCapabilities { "generation", "routes", "githubBroker", + "apiKeyProof", ]) || v.version !== 1 || typeof v.ownerId !== "string" || @@ -49,12 +51,17 @@ export function parseIdleCapabilities(value: unknown): IdleCapabilities { v.routes.length < 1 || v.routes.length > 2 || v.routes.some( - (r) => !["tenant-api-http-v1", "github-broker-events-v1"].includes(r), + (r) => !["tenant-api-http-v2", "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("tenant-api-http-v2")) { + const proof = v.apiKeyProof; + if (!object(proof) || Object.keys(proof).length !== 2 || !exact(proof, ["version", "challenge"]) + || proof.version !== 1 || !digest(proof.challenge)) throw new Error("Invalid API wake challenge"); + } else if (v.apiKeyProof !== undefined) throw new Error("Unexpected API wake challenge"); if (v.routes.includes("github-broker-events-v1")) { const b = v.githubBroker; if ( @@ -72,6 +79,7 @@ export function parseIdleCapabilities(value: unknown): IdleCapabilities { const parsed = JSON.parse(JSON.stringify(value)) as IdleCapabilities; if (parsed.incomingWake) { Object.freeze(parsed.incomingWake.routes); + if (parsed.incomingWake.apiKeyProof) Object.freeze(parsed.incomingWake.apiKeyProof); if (parsed.incomingWake.githubBroker) Object.freeze(parsed.incomingWake.githubBroker); Object.freeze(parsed.incomingWake); diff --git a/server/src/services/idle-incoming-wake.test.ts b/server/src/services/idle-incoming-wake.test.ts index 2596d11a3b0..d432a3083fe 100644 --- a/server/src/services/idle-incoming-wake.test.ts +++ b/server/src/services/idle-incoming-wake.test.ts @@ -156,3 +156,29 @@ describe("bounded incoming wake owner projection", () => { expect(sql.select).toHaveBeenCalledTimes(2); }); }); + +describe("bounded API admission projection", () => { + const api = { incomingWake: { version: 1, ownerId, generation: "a".repeat(64), routes: ["tenant-api-http-v2"], + apiKeyProof: { version: 1, challenge: "c".repeat(64) } } } as VerifiedIdleCapabilities; + it.each(["overflow", "duplicate", "malformed"])("refuses complete eligibility for %s key state", async kind => { + vi.stubEnv("PAPERCLIP_CLOUD_STACK_ID", "fixture-stack"); + const row = { keyHash: "a".repeat(64), expiresAt: null }; + const rows = kind === "overflow" ? Array.from({ length: 1001 }, () => row) + : kind === "duplicate" ? [row, row] : [{ keyHash: "not-a-digest", expiresAt: null }]; + const execute = vi.fn().mockResolvedValueOnce([]).mockResolvedValueOnce(rows); + await expect(readIdleIncomingWakeProof({ execute } as unknown as Pick, + { ownerId, receiverOwner: {}, capabilities: api })).rejects.toThrow(); + expect(execute).toHaveBeenCalledTimes(2); + }); + it("has an explicit complete empty-key receipt and shares the inspection time budget", async () => { + vi.stubEnv("PAPERCLIP_CLOUD_STACK_ID", "fixture-stack"); + const execute = vi.fn(async () => []); + const proof = await readIdleIncomingWakeProof({ execute } as unknown as Pick, + { ownerId, receiverOwner: {}, capabilities: api }); + expect(proof?.apiWakeAdmission?.keys).toEqual([]); + let clock = 0; + const slow = vi.fn(async () => { clock += 1100; return []; }); + await expect(readIdleIncomingWakeProof({ execute: slow } as unknown as Pick, + { ownerId, receiverOwner: {}, capabilities: api, now: () => clock })).rejects.toThrow(/budget exhausted/); + }); +}); diff --git a/server/src/services/idle-incoming-wake.ts b/server/src/services/idle-incoming-wake.ts index e19c76a898e..cf7d4b39694 100644 --- a/server/src/services/idle-incoming-wake.ts +++ b/server/src/services/idle-incoming-wake.ts @@ -1,3 +1,4 @@ +import { apiWakeFingerprint, createApiWakeAdmission, type ApiWakeAdmission } from "./idle-api-wake-admission.js"; import { createHash } from "node:crypto"; import { and, eq, inArray, sql, type SQL } from "drizzle-orm"; import { @@ -17,6 +18,7 @@ import { hasConnectionEventWakeReceiver } from "./connection-event-wake.js"; export interface IdleIncomingWakeProof { capability: IdleIncomingWakeCapability; + apiWakeAdmission?: ApiWakeAdmission; connectionIds: string[]; endpointIds: string[]; /** A broker enrollment cannot discharge these credential continuity obligations. */ @@ -59,7 +61,6 @@ export async function readIdleIncomingWakeProof( endpointIds: [], requiredContinuityConnectionIds: [], }; - if (!capability.routes.includes("github-broker-events-v1")) return proof; const monotonic = input.now ?? (() => performance.now()); const started = monotonic(); const remaining = () => { @@ -75,6 +76,28 @@ export async function readIdleIncomingWakeProof( ); remaining(); }; + if (capability.routes.includes("tenant-api-http-v2")) { + const stackId = process.env.PAPERCLIP_CLOUD_STACK_ID; + if (!stackId || !capability.apiKeyProof) throw new Error("API wake challenge is missing"); + await beforeRead(); + const rows = await tx.execute<{ keyHash: string; expiresAt: Date | null }>(sql` + SELECT key_hash AS "keyHash", NULL::timestamptz AS "expiresAt" FROM agent_api_keys WHERE revoked_at IS NULL + UNION ALL SELECT key_hash AS "keyHash", expires_at AS "expiresAt" FROM board_api_keys + WHERE revoked_at IS NULL AND (expires_at IS NULL OR expires_at > now()) LIMIT 1001`); + remaining(); + if (rows.length > 1000) throw new Error("Too many API keys to certify wake admission"); + proof.apiWakeAdmission = createApiWakeAdmission({ stackId, ownerId: input.ownerId, + capabilityGeneration: capability.generation, challenge: capability.apiKeyProof.challenge, + keys: rows.map(row => ({ fingerprint: apiWakeFingerprint({ stackId, ownerId: input.ownerId, + challenge: capability.apiKeyProof!.challenge, keyHash: row.keyHash }), + expiresAt: row.expiresAt === null ? null : new Date(row.expiresAt).toISOString() })), + }); + } + if (!capability.routes.includes("github-broker-events-v1")) { + await tx.execute(sql`SET LOCAL statement_timeout = '5s'`); + remaining(); + return proof; + } const broker = capability.githubBroker; const config = paperclipCloudConnectorConfigFromEnv(); if ( @@ -264,11 +287,11 @@ export function incomingWakeWorkQuery( ) ?? []), ]; if (reason === "agent_api_keys") - return proof?.capability.routes.includes("tenant-api-http-v1") + return proof?.apiWakeAdmission !== undefined ? sql`SELECT 1 WHERE false` : sql`SELECT 1 FROM agent_api_keys WHERE revoked_at IS NULL`; if (reason === "board_api_keys") - return proof?.capability.routes.includes("tenant-api-http-v1") + return proof?.apiWakeAdmission !== undefined ? sql`SELECT 1 WHERE false` : sql`SELECT 1 FROM board_api_keys WHERE revoked_at IS NULL AND (expires_at IS NULL OR expires_at > now())`; diff --git a/server/src/services/idle-sleep-safety.ts b/server/src/services/idle-sleep-safety.ts index b5502eabca0..0ef095fe3dd 100644 --- a/server/src/services/idle-sleep-safety.ts +++ b/server/src/services/idle-sleep-safety.ts @@ -1,6 +1,7 @@ import { readIdleDeadlineProof, deadlineWorkReplacement, type IdleDeadlineProof } from "./idle-deadline-proof.js"; import type { DomainScheduledWake, IdleDeadlineReceipt } from "./idle-deadline-types.js"; import type { IdlePluginCatalogEntry } from "./plugin-idle-catalog.js"; +import type { ApiWakeAdmission } from "./idle-api-wake-admission.js"; import { readIdleIncomingWakeProof, incomingWakeWorkQuery, type IdleIncomingWakeProof } from "./idle-incoming-wake.js"; import type { VerifiedIdleCapabilities, IdleIncomingWakeCapability } from "./idle-incoming-wake-capability.js"; import { idleWorkSnapshot } from "./task-admission.js"; @@ -32,6 +33,7 @@ export interface IdleSleepSafety { diagnostics?: IdleSleepDiagnostics; incomingWake?: IdleIncomingWakeCapability; deadlineProof?: IdleDeadlineReceipt; + apiWakeAdmission?: ApiWakeAdmission; } export type InspectIdlePlugins = (hold: { ownerId: string; expiresAt: number }) => Promise<{ @@ -311,7 +313,8 @@ export async function readIdleSleepSafety( if (backgroundWork === "none" && deadlines && (!deadlineProof || deadlineProof.blocked || !deadlineProof.generation)) backgroundWork = "unknown"; const wake = deadlineProof ? deadlineProof.scheduledWake : scheduledWake; return { version: backgroundWork === "none" && (incomingProof || deadlineProof) ? 3 : backgroundWork === "none" && wake ? 2 : 1, backgroundWork, - ...(backgroundWork === "none" && incomingProof ? { incomingWake: incomingProof.capability } : {}), + ...(backgroundWork === "none" && incomingProof ? { incomingWake: incomingProof.capability, + ...(incomingProof.apiWakeAdmission ? { apiWakeAdmission: incomingProof.apiWakeAdmission } : {}) } : {}), ...(backgroundWork === "none" && deadlineProof?.generation ? { deadlineProof: { catalog: "domain_deadlines_v1" as const, generation: deadlineProof.generation } } : {}), ...(backgroundWork === "none" && wake ? { scheduledWake: wake } : {}),