Secure Cloud canonical runtime identity (#12766)
Accept and persist Cloud-signed canonical runtime identity before activation, then route absolute self-URLs through the durable runtime identity provider. Co-Authored-By: Codex <codex@openai.com>
This commit is contained in:
parent
2e8521e57c
commit
0798c77fde
|
|
@ -175,6 +175,18 @@ When authoring migrations or one-time backfills:
|
|||
- Do not hand-edit a snapshot to resolve a merge conflict. Renumber your migration and run `generate` again, as `packages/db/.gitattributes` describes.
|
||||
- `packages/db/src/migration-snapshot-drift.test.ts` is the enforcement backstop. It repeats the diff that `generate` performs and fails when the newest snapshot no longer matches `packages/db/src/schema/`.
|
||||
|
||||
## Cloud runtime identity singleton
|
||||
|
||||
The private `instance_settings` row whose singleton key is
|
||||
`cloud-runtime-identity/v1` records the immutable Cloud stack id, warm-pool
|
||||
claim id, previous pool origin, canonical origin, and stack slug accepted from
|
||||
Cloud's signed pre-activation assertion. It is separate from the normal
|
||||
`default` settings row and never appears in the settings API. This is
|
||||
intentionally instance-scoped rather than company-scoped: an instance has one
|
||||
public identity, and the existing unique singleton-key index makes concurrent
|
||||
or later attempts to replace it fail closed. The server loads the row before
|
||||
constructing URL-dependent runtime services on every boot.
|
||||
|
||||
## Resource membership tables
|
||||
|
||||
Paperclip stores current-user sidebar membership state in:
|
||||
|
|
|
|||
|
|
@ -64,6 +64,24 @@ Paperclip now treats **bind** as a separate concern from auth:
|
|||
- recommended bind is `loopback` behind a reverse proxy; direct `lan/custom` is advanced
|
||||
- local stdio MCP runtime slots fail closed by default; set `PAPERCLIP_TRUSTED_MCP_RUNTIME_HOST` only when a trusted worker/runtime host is configured to supervise those processes. Remote HTTP MCP remains the preferred public-hosted path.
|
||||
|
||||
### Paperclip Cloud warm-pool identity
|
||||
|
||||
A Cloud-managed warm-pool process initially boots under a `pool-*` origin. It
|
||||
receives only Cloud's public verification set in
|
||||
`PAPERCLIP_CLOUD_RUNTIME_IDENTITY_JWKS`. Before Cloud activates a claimed stack,
|
||||
the existing server-to-server health request carries a short-lived Ed25519 JWS
|
||||
that binds the immutable `PAPERCLIP_CLOUD_STACK_ID`, pool claim, previous
|
||||
origin, canonical HTTPS origin, and slug. Paperclip verifies and persists that
|
||||
one-time assertion, updates its live public/API URL provider, and acknowledges
|
||||
the exact origin in `/api/health` before the first user request is admitted.
|
||||
|
||||
The Harness signing private key is never present in Paperclip, browsers, or
|
||||
other tenant stacks. A different claim or destination cannot replace the
|
||||
persisted identity. On restart, the durable identity is loaded before auth,
|
||||
routes, and child-runtime configuration, even when provider variables are
|
||||
temporarily stale. Self-hosted deployments continue to use their configured
|
||||
`PAPERCLIP_PUBLIC_URL` and do not participate in this protocol.
|
||||
|
||||
## 4. Onboarding UX Contract
|
||||
|
||||
Default onboarding remains interactive and flagless:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,261 @@
|
|||
import { generateKeyPairSync, sign } from "node:crypto";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import { createDb, instanceSettings } from "@paperclipai/db";
|
||||
import {
|
||||
applyCloudRuntimeIdentityAssertion,
|
||||
CLOUD_RUNTIME_IDENTITY_AUDIENCE,
|
||||
CLOUD_RUNTIME_IDENTITY_ISSUER,
|
||||
CLOUD_RUNTIME_IDENTITY_JWS_TYPE,
|
||||
getCloudRuntimeIdentity,
|
||||
initializeCloudRuntimeIdentity,
|
||||
resetCloudRuntimeIdentityForTests,
|
||||
runtimePublicOrigin,
|
||||
} from "../services/cloud-runtime-identity.js";
|
||||
import { routineWebhookUrl } from "../services/routines.js";
|
||||
import { paperclipCloudConnectorEnrollmentStatus } from "../services/paperclip-cloud-connector-enrollment.js";
|
||||
import { cloudRuntimeIdentityMiddleware } from "../middleware/cloud-runtime-identity.js";
|
||||
import { healthRoutes } from "../routes/health.js";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
||||
const STACK_ID = "stack-pool-123";
|
||||
const POOL_ORIGIN = "https://pool-123.staging.paperclip.app";
|
||||
const CANONICAL_ORIGIN = "https://gonzo.staging.paperclip.app";
|
||||
const NOW = new Date("2099-01-01T00:00:00.000Z");
|
||||
|
||||
const pair = generateKeyPairSync("ed25519");
|
||||
const publicJwk = {
|
||||
...pair.publicKey.export({ format: "jwk" }),
|
||||
kid: "runtime-identity-test-key",
|
||||
use: "sig",
|
||||
alg: "EdDSA",
|
||||
};
|
||||
|
||||
function encodeJson(value: Record<string, unknown>) {
|
||||
return Buffer.from(JSON.stringify(value)).toString("base64url");
|
||||
}
|
||||
|
||||
function assertion(input: {
|
||||
claims?: Record<string, unknown>;
|
||||
header?: Record<string, unknown>;
|
||||
signingKey?: typeof pair.privateKey;
|
||||
} = {}) {
|
||||
const iat = Math.floor(NOW.getTime() / 1000);
|
||||
const header = encodeJson({
|
||||
alg: "EdDSA",
|
||||
typ: CLOUD_RUNTIME_IDENTITY_JWS_TYPE,
|
||||
kid: publicJwk.kid,
|
||||
...input.header,
|
||||
});
|
||||
const payload = encodeJson({
|
||||
v: 1,
|
||||
iss: CLOUD_RUNTIME_IDENTITY_ISSUER,
|
||||
aud: CLOUD_RUNTIME_IDENTITY_AUDIENCE,
|
||||
sub: STACK_ID,
|
||||
claimId: "pool-entry-123",
|
||||
previousOrigin: POOL_ORIGIN,
|
||||
canonicalOrigin: CANONICAL_ORIGIN,
|
||||
stackSlug: "gonzo",
|
||||
iat,
|
||||
exp: iat + 300,
|
||||
...input.claims,
|
||||
});
|
||||
const signature = sign(
|
||||
null,
|
||||
Buffer.from(`${header}.${payload}`, "ascii"),
|
||||
input.signingKey ?? pair.privateKey,
|
||||
).toString("base64url");
|
||||
return `${header}.${payload}.${signature}`;
|
||||
}
|
||||
|
||||
describeEmbeddedPostgres("Cloud runtime identity", () => {
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
beforeAll(async () => {
|
||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-cloud-runtime-identity-");
|
||||
db = createDb(tempDb.connectionString);
|
||||
}, 20_000);
|
||||
|
||||
beforeEach(async () => {
|
||||
await db.delete(instanceSettings);
|
||||
resetCloudRuntimeIdentityForTests();
|
||||
process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN = "test-tenant-server-token";
|
||||
process.env.PAPERCLIP_CLOUD_STACK_ID = STACK_ID;
|
||||
process.env.PAPERCLIP_CLOUD_API_ORIGIN = "https://my-staging.paperclip.app";
|
||||
process.env.PAPERCLIP_PUBLIC_URL = POOL_ORIGIN;
|
||||
process.env.PAPERCLIP_AUTH_PUBLIC_BASE_URL = POOL_ORIGIN;
|
||||
process.env.PAPERCLIP_API_URL = POOL_ORIGIN;
|
||||
process.env.PAPERCLIP_PRIMARY_HOST = "pool-123.staging.paperclip.app";
|
||||
process.env.PAPERCLIP_STACK_SLUG = "pool-123";
|
||||
process.env.PAPERCLIP_CLOUD_RUNTIME_IDENTITY_JWKS = JSON.stringify({ keys: [publicJwk] });
|
||||
process.env.PAPERCLIP_CLOUD_CONNECTOR_INSTANCE_ID = "managed-instance";
|
||||
process.env.PAPERCLIP_CLOUD_CONNECTOR_SIGN_PRIVATE_KEY = "managed-signing-key";
|
||||
process.env.PAPERCLIP_CLOUD_CONNECTOR_SEAL_PRIVATE_KEY = "managed-sealing-key";
|
||||
process.env.PAPERCLIP_CLOUD_CONNECTOR_ENVIRONMENT = "staging";
|
||||
process.env.PAPERCLIP_CLOUD_CONNECTOR_BASE_URL = "https://my-staging.paperclip.app";
|
||||
await initializeCloudRuntimeIdentity(db);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const key of [
|
||||
"PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN",
|
||||
"PAPERCLIP_CLOUD_STACK_ID",
|
||||
"PAPERCLIP_CLOUD_API_ORIGIN",
|
||||
"PAPERCLIP_PUBLIC_URL",
|
||||
"PAPERCLIP_AUTH_PUBLIC_BASE_URL",
|
||||
"PAPERCLIP_API_URL",
|
||||
"PAPERCLIP_PRIMARY_HOST",
|
||||
"PAPERCLIP_STACK_SLUG",
|
||||
"PAPERCLIP_CLOUD_RUNTIME_IDENTITY_JWKS",
|
||||
"PAPERCLIP_CLOUD_CONNECTOR_INSTANCE_ID",
|
||||
"PAPERCLIP_CLOUD_CONNECTOR_SIGN_PRIVATE_KEY",
|
||||
"PAPERCLIP_CLOUD_CONNECTOR_SEAL_PRIVATE_KEY",
|
||||
"PAPERCLIP_CLOUD_CONNECTOR_ENVIRONMENT",
|
||||
"PAPERCLIP_CLOUD_CONNECTOR_BASE_URL",
|
||||
"PAPERCLIP_RUNTIME_API_CANDIDATES_JSON",
|
||||
]) {
|
||||
const original = originalEnv[key];
|
||||
if (original === undefined) delete process.env[key];
|
||||
else process.env[key] = original;
|
||||
}
|
||||
resetCloudRuntimeIdentityForTests();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await tempDb?.cleanup();
|
||||
});
|
||||
|
||||
it("persists and immediately applies a valid one-time claim", async () => {
|
||||
const applied = await applyCloudRuntimeIdentityAssertion({
|
||||
db,
|
||||
compactJws: assertion(),
|
||||
now: NOW,
|
||||
});
|
||||
|
||||
expect(applied.canonicalOrigin).toBe(CANONICAL_ORIGIN);
|
||||
expect(getCloudRuntimeIdentity()?.stackSlug).toBe("gonzo");
|
||||
expect(runtimePublicOrigin()).toBe(CANONICAL_ORIGIN);
|
||||
expect(process.env.PAPERCLIP_PUBLIC_URL).toBe(CANONICAL_ORIGIN);
|
||||
expect(process.env.PAPERCLIP_AUTH_PUBLIC_BASE_URL).toBe(CANONICAL_ORIGIN);
|
||||
expect(process.env.PAPERCLIP_API_URL).toBe(CANONICAL_ORIGIN);
|
||||
expect(process.env.PAPERCLIP_PRIMARY_HOST).toBe("gonzo.staging.paperclip.app");
|
||||
expect(process.env.PAPERCLIP_STACK_SLUG).toBe("gonzo");
|
||||
expect(routineWebhookUrl("hook-1")).toBe(
|
||||
"https://gonzo.staging.paperclip.app/api/routine-triggers/public/hook-1/fire",
|
||||
);
|
||||
expect(paperclipCloudConnectorEnrollmentStatus()).toMatchObject({
|
||||
configured: true,
|
||||
status: "active",
|
||||
origins: [CANONICAL_ORIGIN],
|
||||
});
|
||||
});
|
||||
|
||||
it("applies the assertion on the existing health request and acknowledges the exact origin", async () => {
|
||||
const requestTime = Math.floor(Date.now() / 1000);
|
||||
const app = express();
|
||||
app.use(cloudRuntimeIdentityMiddleware(db));
|
||||
app.use("/api/health", healthRoutes(db, {
|
||||
deploymentMode: "authenticated",
|
||||
deploymentExposure: "public",
|
||||
authReady: true,
|
||||
companyDeletionEnabled: false,
|
||||
}));
|
||||
|
||||
const response = await request(app)
|
||||
.get("/api/health")
|
||||
.set("x-paperclip-cloud-runtime-identity", assertion({
|
||||
claims: { iat: requestTime, exp: requestTime + 300 },
|
||||
}));
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.cloud.runtimeIdentity).toEqual({
|
||||
canonicalOrigin: CANONICAL_ORIGIN,
|
||||
stackSlug: "gonzo",
|
||||
});
|
||||
});
|
||||
|
||||
it("restores the canonical identity before consumers read stale startup variables", async () => {
|
||||
await applyCloudRuntimeIdentityAssertion({ db, compactJws: assertion(), now: NOW });
|
||||
resetCloudRuntimeIdentityForTests();
|
||||
process.env.PAPERCLIP_PUBLIC_URL = POOL_ORIGIN;
|
||||
process.env.PAPERCLIP_AUTH_PUBLIC_BASE_URL = POOL_ORIGIN;
|
||||
process.env.PAPERCLIP_API_URL = POOL_ORIGIN;
|
||||
|
||||
await initializeCloudRuntimeIdentity(db);
|
||||
|
||||
expect(runtimePublicOrigin()).toBe(CANONICAL_ORIGIN);
|
||||
expect(process.env.PAPERCLIP_API_URL).toBe(CANONICAL_ORIGIN);
|
||||
});
|
||||
|
||||
it("accepts the identical claim after a restart with already-aligned provider variables", async () => {
|
||||
await applyCloudRuntimeIdentityAssertion({ db, compactJws: assertion(), now: NOW });
|
||||
resetCloudRuntimeIdentityForTests();
|
||||
process.env.PAPERCLIP_PUBLIC_URL = CANONICAL_ORIGIN;
|
||||
process.env.PAPERCLIP_AUTH_PUBLIC_BASE_URL = CANONICAL_ORIGIN;
|
||||
process.env.PAPERCLIP_API_URL = CANONICAL_ORIGIN;
|
||||
await initializeCloudRuntimeIdentity(db);
|
||||
|
||||
await expect(applyCloudRuntimeIdentityAssertion({
|
||||
db,
|
||||
compactJws: assertion(),
|
||||
now: NOW,
|
||||
})).resolves.toMatchObject({ canonicalOrigin: CANONICAL_ORIGIN });
|
||||
});
|
||||
|
||||
it("accepts an identical replay but rejects a different claim or destination", async () => {
|
||||
await applyCloudRuntimeIdentityAssertion({ db, compactJws: assertion(), now: NOW });
|
||||
await expect(applyCloudRuntimeIdentityAssertion({
|
||||
db,
|
||||
compactJws: assertion(),
|
||||
now: NOW,
|
||||
})).resolves.toMatchObject({ canonicalOrigin: CANONICAL_ORIGIN });
|
||||
await expect(applyCloudRuntimeIdentityAssertion({
|
||||
db,
|
||||
compactJws: assertion({ claims: { claimId: "another-claim" } }),
|
||||
now: NOW,
|
||||
})).rejects.toThrow("already claimed");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["expired", { exp: Math.floor(NOW.getTime() / 1000) - 1 }],
|
||||
["cross-stack", { sub: "stack-someone-else" }],
|
||||
["wrong previous origin", { previousOrigin: "https://another.staging.paperclip.app" }],
|
||||
["path-bearing destination", { canonicalOrigin: `${CANONICAL_ORIGIN}/GON` }],
|
||||
["slug mismatch", { stackSlug: "kermit" }],
|
||||
])("rejects %s assertions", async (_label, claims) => {
|
||||
await expect(applyCloudRuntimeIdentityAssertion({
|
||||
db,
|
||||
compactJws: assertion({ claims }),
|
||||
now: NOW,
|
||||
})).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("rejects unknown keys and altered signed destinations", async () => {
|
||||
await expect(applyCloudRuntimeIdentityAssertion({
|
||||
db,
|
||||
compactJws: assertion({ header: { kid: "unknown" } }),
|
||||
now: NOW,
|
||||
})).rejects.toThrow("unknown signing key");
|
||||
|
||||
const valid = assertion();
|
||||
const [header, payload, signature] = valid.split(".");
|
||||
const altered = encodeJson({
|
||||
...JSON.parse(Buffer.from(payload!, "base64url").toString("utf8")),
|
||||
canonicalOrigin: "https://attacker.example.com",
|
||||
});
|
||||
await expect(applyCloudRuntimeIdentityAssertion({
|
||||
db,
|
||||
compactJws: `${header}.${altered}.${signature}`,
|
||||
now: NOW,
|
||||
})).rejects.toThrow("signature is invalid");
|
||||
});
|
||||
});
|
||||
|
|
@ -2146,6 +2146,18 @@ describeEmbeddedPostgres("generic remote MCP connections", () => {
|
|||
expect(JSON.stringify(response.body)).not.toContain(company.id);
|
||||
});
|
||||
|
||||
it("uses the configured auth origin for self-hosted OAuth callbacks", async () => {
|
||||
vi.stubEnv("PAPERCLIP_PUBLIC_URL", "https://public.paperclip.example");
|
||||
vi.stubEnv("PAPERCLIP_AUTH_PUBLIC_BASE_URL", "https://auth.paperclip.example");
|
||||
const app = createRouteApp(db);
|
||||
|
||||
const response = await request(app).get("/api/tools/oauth/client-metadata").expect(200);
|
||||
|
||||
expect(response.body.redirect_uris).toEqual([
|
||||
"https://auth.paperclip.example/api/tools/oauth/callback",
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses the managed runtime origin when no explicit callback origin is configured", async () => {
|
||||
vi.stubEnv("PAPERCLIP_PUBLIC_URL", "");
|
||||
vi.stubEnv("PAPERCLIP_AUTH_PUBLIC_BASE_URL", "");
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ import { secretService } from "../services/secrets.ts";
|
|||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
const originalSecretsProviderEnv = process.env.PAPERCLIP_SECRETS_PROVIDER;
|
||||
const originalPaperclipApiUrlEnv = process.env.PAPERCLIP_API_URL;
|
||||
|
||||
if (!embeddedPostgresSupport.supported) {
|
||||
console.warn(
|
||||
|
|
@ -51,6 +52,7 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => {
|
|||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
|
||||
beforeAll(async () => {
|
||||
process.env.PAPERCLIP_API_URL = "http://localhost:3100";
|
||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-routines-service-");
|
||||
db = createDb(tempDb.connectionString);
|
||||
}, 20_000);
|
||||
|
|
@ -86,6 +88,11 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => {
|
|||
|
||||
afterAll(async () => {
|
||||
await tempDb?.cleanup();
|
||||
if (originalPaperclipApiUrlEnv === undefined) {
|
||||
delete process.env.PAPERCLIP_API_URL;
|
||||
} else {
|
||||
process.env.PAPERCLIP_API_URL = originalPaperclipApiUrlEnv;
|
||||
}
|
||||
});
|
||||
|
||||
async function seedFixture(opts?: {
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import {
|
|||
} from "./services/company-import-transfers.js";
|
||||
import { companyTransferRunService } from "./services/company-transfer-runs.js";
|
||||
import { healthRoutes } from "./routes/health.js";
|
||||
import { cloudRuntimeIdentityMiddleware } from "./middleware/cloud-runtime-identity.js";
|
||||
import { cloudRoutes } from "./routes/cloud.js";
|
||||
import { companyRoutes } from "./routes/companies.js";
|
||||
import { companySkillRoutes } from "./routes/company-skills.js";
|
||||
|
|
@ -364,6 +365,7 @@ export async function createApp(
|
|||
bindHost: opts.bindHost,
|
||||
}),
|
||||
);
|
||||
app.use(cloudRuntimeIdentityMiddleware(db));
|
||||
// Connection-intent tools carry their own short-lived, run-bound bearer and
|
||||
// must be reachable by remote adapters that intentionally do not receive an
|
||||
// agent API key. Every request revalidates the active heartbeat row.
|
||||
|
|
|
|||
|
|
@ -101,6 +101,7 @@ import {
|
|||
finalizeServerShutdown,
|
||||
loadWithoutCoordinatedShutdownSignalHooks,
|
||||
} from "./shutdown.js";
|
||||
import { initializeCloudRuntimeIdentity } from "./services/cloud-runtime-identity.js";
|
||||
import { systemdNotify } from "./services/systemd-notify.js";
|
||||
import { flushInFlightRunLogMirrors } from "./services/run-log-store.js";
|
||||
import {
|
||||
|
|
@ -562,6 +563,12 @@ export async function startServer(): Promise<StartedServer> {
|
|||
startupDbInfo = { mode: "embedded-postgres", dataDir, port };
|
||||
}
|
||||
|
||||
// A claimed warm-pool stack may restart while its provider environment still
|
||||
// names the pool host. Restore the signed, durable identity before Better
|
||||
// Auth, routes, or child-runtime configuration capture any public URL.
|
||||
const restoredCloudRuntimeIdentity = await initializeCloudRuntimeIdentity(db as any);
|
||||
if (restoredCloudRuntimeIdentity) config = loadConfig();
|
||||
|
||||
if (config.deploymentMode === "local_trusted" && !isLoopbackHost(config.host)) {
|
||||
throw new Error(
|
||||
`local_trusted mode requires loopback host binding (received: ${config.host}). ` +
|
||||
|
|
|
|||
|
|
@ -0,0 +1,33 @@
|
|||
import type { RequestHandler } from "express";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { logger } from "./logger.js";
|
||||
import {
|
||||
applyCloudRuntimeIdentityAssertion,
|
||||
CLOUD_RUNTIME_IDENTITY_HEADER,
|
||||
} from "../services/cloud-runtime-identity.js";
|
||||
|
||||
/**
|
||||
* Accepts Cloud's signed identity only on the existing bootstrap health call.
|
||||
* The JWS is sufficient authorization; the browser-facing proxy strips this
|
||||
* header, and possession of the shared tenant-session token cannot mint it.
|
||||
*/
|
||||
export function cloudRuntimeIdentityMiddleware(db: Db): RequestHandler {
|
||||
return async (req, res, next) => {
|
||||
const assertion = req.get(CLOUD_RUNTIME_IDENTITY_HEADER)?.trim();
|
||||
if (!assertion) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
if (req.method !== "GET" || req.path !== "/api/health") {
|
||||
res.status(400).json({ error: "cloud_runtime_identity_wrong_endpoint" });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await applyCloudRuntimeIdentityAssertion({ db, compactJws: assertion });
|
||||
next();
|
||||
} catch (error) {
|
||||
logger.warn({ err: error }, "Rejected Cloud runtime identity assertion");
|
||||
res.status(401).json({ error: "invalid_cloud_runtime_identity" });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -57,6 +57,7 @@ import {
|
|||
tooManyRequests
|
||||
} from "../errors.js";
|
||||
import { getHiddenSettings } from "../services/settings-visibility.js";
|
||||
import { runtimeCanonicalOrigin } from "../services/cloud-runtime-identity.js";
|
||||
|
||||
/**
|
||||
* Floor: when the hosting operator hides the Instance Access surface
|
||||
|
|
@ -153,6 +154,8 @@ function requestBaseUrl(req: Request) {
|
|||
}
|
||||
|
||||
function resolveBaseUrl(req: Request, authPublicBaseUrl?: string): string {
|
||||
const runtimeOrigin = runtimeCanonicalOrigin();
|
||||
if (runtimeOrigin) return runtimeOrigin;
|
||||
if (authPublicBaseUrl) return authPublicBaseUrl.replace(/\/+$/, "");
|
||||
return requestBaseUrl(req);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
isCloudManagedInstance,
|
||||
type CloudInstanceEnv,
|
||||
} from "../services/cloud-instance.js";
|
||||
import { getCloudRuntimeIdentity } from "../services/cloud-runtime-identity.js";
|
||||
import { getHiddenSettings } from "../services/settings-visibility.js";
|
||||
import {
|
||||
inspectDatabaseBackupHealth,
|
||||
|
|
@ -88,12 +89,19 @@ function redactedDatabaseBackupHealth(databaseBackup: DatabaseBackupHealthStatus
|
|||
function getCloudHealthStatus(env: CloudInstanceEnv) {
|
||||
const context = getCloudStackContext(env);
|
||||
if (!context) return undefined;
|
||||
const runtimeIdentity = env === process.env ? getCloudRuntimeIdentity() : null;
|
||||
|
||||
return {
|
||||
managed: true as const,
|
||||
managedBy: "paperclip-cloud" as const,
|
||||
stackSlug: context.stackSlug,
|
||||
cloudBaseUrl: context.cloudOrigin,
|
||||
...(runtimeIdentity ? {
|
||||
runtimeIdentity: {
|
||||
canonicalOrigin: runtimeIdentity.canonicalOrigin,
|
||||
stackSlug: runtimeIdentity.stackSlug,
|
||||
},
|
||||
} : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@ import {
|
|||
type PaperclipCloudConnector,
|
||||
paperclipCloudConnectorCapabilitiesFromEnv,
|
||||
} from "../services/paperclip-cloud-connector.js";
|
||||
import { runtimeCanonicalOrigin } from "../services/cloud-runtime-identity.js";
|
||||
import {
|
||||
completePaperclipCloudConnectorEnrollment,
|
||||
loadPaperclipCloudConnectorIdentity,
|
||||
|
|
@ -298,12 +299,14 @@ export function toolAccessRoutes(
|
|||
}
|
||||
|
||||
function configuredPublicBaseUrl() {
|
||||
const runtimeOrigin = runtimeCanonicalOrigin();
|
||||
if (runtimeOrigin) return runtimeOrigin;
|
||||
const raw = (
|
||||
process.env.PAPERCLIP_PUBLIC_URL?.trim()
|
||||
|| process.env.PAPERCLIP_AUTH_PUBLIC_BASE_URL?.trim()
|
||||
process.env.PAPERCLIP_AUTH_PUBLIC_BASE_URL?.trim()
|
||||
|| process.env.BETTER_AUTH_URL?.trim()
|
||||
|| process.env.BETTER_AUTH_BASE_URL?.trim()
|
||||
|| options.authPublicBaseUrl?.trim()
|
||||
|| process.env.PAPERCLIP_PUBLIC_URL?.trim()
|
||||
|| process.env.PAPERCLIP_MANAGED_RUNTIME_PUBLIC_URL?.trim()
|
||||
);
|
||||
if (!raw) return null;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,123 @@
|
|||
import { generateKeyPairSync, sign, type KeyObject } from "node:crypto";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
CLOUD_RUNTIME_IDENTITY_AUDIENCE,
|
||||
CLOUD_RUNTIME_IDENTITY_ISSUER,
|
||||
CLOUD_RUNTIME_IDENTITY_JWS_TYPE,
|
||||
runtimeCanonicalOrigin,
|
||||
runtimePublicOrigin,
|
||||
verifyCloudRuntimeIdentityAssertion,
|
||||
} from "./cloud-runtime-identity.js";
|
||||
|
||||
const STACK_ID = "stack-pool-123";
|
||||
const POOL_ORIGIN = "https://pool-123.staging.paperclip.app";
|
||||
const CANONICAL_ORIGIN = "https://gonzo.staging.paperclip.app";
|
||||
const NOW = new Date("2099-01-01T00:00:00.000Z");
|
||||
const pair = generateKeyPairSync("ed25519");
|
||||
const publicJwk = {
|
||||
...pair.publicKey.export({ format: "jwk" }),
|
||||
kid: "runtime-identity-test-key",
|
||||
use: "sig",
|
||||
alg: "EdDSA",
|
||||
};
|
||||
const env = {
|
||||
PAPERCLIP_CLOUD_STACK_ID: STACK_ID,
|
||||
PAPERCLIP_CLOUD_RUNTIME_IDENTITY_JWKS: JSON.stringify({ keys: [publicJwk] }),
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
function encodeJson(value: Record<string, unknown>) {
|
||||
return Buffer.from(JSON.stringify(value)).toString("base64url");
|
||||
}
|
||||
|
||||
function assertion(input: {
|
||||
claims?: Record<string, unknown>;
|
||||
header?: Record<string, unknown>;
|
||||
signingKey?: KeyObject;
|
||||
} = {}) {
|
||||
const iat = Math.floor(NOW.getTime() / 1000);
|
||||
const header = encodeJson({
|
||||
alg: "EdDSA",
|
||||
typ: CLOUD_RUNTIME_IDENTITY_JWS_TYPE,
|
||||
kid: publicJwk.kid,
|
||||
...input.header,
|
||||
});
|
||||
const payload = encodeJson({
|
||||
v: 1,
|
||||
iss: CLOUD_RUNTIME_IDENTITY_ISSUER,
|
||||
aud: CLOUD_RUNTIME_IDENTITY_AUDIENCE,
|
||||
sub: STACK_ID,
|
||||
claimId: "pool-entry-123",
|
||||
previousOrigin: POOL_ORIGIN,
|
||||
canonicalOrigin: CANONICAL_ORIGIN,
|
||||
stackSlug: "gonzo",
|
||||
iat,
|
||||
exp: iat + 300,
|
||||
...input.claims,
|
||||
});
|
||||
const signature = sign(
|
||||
null,
|
||||
Buffer.from(`${header}.${payload}`, "ascii"),
|
||||
input.signingKey ?? pair.privateKey,
|
||||
).toString("base64url");
|
||||
return `${header}.${payload}.${signature}`;
|
||||
}
|
||||
|
||||
function verifyAssertion(compactJws: string, overrides: Partial<NodeJS.ProcessEnv> = {}) {
|
||||
return verifyCloudRuntimeIdentityAssertion({
|
||||
compactJws,
|
||||
env: { ...env, ...overrides },
|
||||
now: NOW,
|
||||
expectedPreviousOrigin: POOL_ORIGIN,
|
||||
});
|
||||
}
|
||||
|
||||
describe("verifyCloudRuntimeIdentityAssertion", () => {
|
||||
it("preserves distinct self-hosted public and authentication origins", () => {
|
||||
const selfHostedEnv = {
|
||||
PAPERCLIP_PUBLIC_URL: "https://app.example.test",
|
||||
PAPERCLIP_AUTH_PUBLIC_BASE_URL: "https://auth.example.test",
|
||||
PAPERCLIP_API_URL: "https://api.example.test",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
expect(runtimePublicOrigin(selfHostedEnv)).toBe("https://app.example.test");
|
||||
expect(runtimeCanonicalOrigin()).toBeNull();
|
||||
});
|
||||
|
||||
it("accepts a Cloud-signed claim for this exact stack and pool origin", () => {
|
||||
expect(verifyAssertion(assertion())).toMatchObject({
|
||||
sub: STACK_ID,
|
||||
claimId: "pool-entry-123",
|
||||
canonicalOrigin: CANONICAL_ORIGIN,
|
||||
stackSlug: "gonzo",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects forged signatures and unknown signing keys", () => {
|
||||
const attacker = generateKeyPairSync("ed25519");
|
||||
expect(() => verifyAssertion(assertion({ signingKey: attacker.privateKey }))).toThrow("signature is invalid");
|
||||
expect(() => verifyAssertion(assertion({ header: { kid: "unknown" } }))).toThrow("unknown signing key");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["expired", { exp: Math.floor(NOW.getTime() / 1000) - 1 }],
|
||||
["wrong audience", { aud: "someone-else" }],
|
||||
["cross-stack", { sub: "stack-someone-else" }],
|
||||
["wrong pool origin", { previousOrigin: "https://someone-else.staging.paperclip.app" }],
|
||||
["non-HTTPS destination", { canonicalOrigin: "http://gonzo.staging.paperclip.app" }],
|
||||
["path-bearing destination", { canonicalOrigin: `${CANONICAL_ORIGIN}/GON` }],
|
||||
["slug mismatch", { stackSlug: "kermit" }],
|
||||
["partial claims", { claimId: undefined }],
|
||||
])("rejects %s assertions", (_label, claims) => {
|
||||
expect(() => verifyAssertion(assertion({ claims }))).toThrow();
|
||||
});
|
||||
|
||||
it("rejects an altered signed destination", () => {
|
||||
const valid = assertion();
|
||||
const [header, payload, signature] = valid.split(".");
|
||||
const altered = encodeJson({
|
||||
...JSON.parse(Buffer.from(payload!, "base64url").toString("utf8")),
|
||||
canonicalOrigin: "https://attacker.example.com",
|
||||
});
|
||||
expect(() => verifyAssertion(`${header}.${altered}.${signature}`)).toThrow("signature is invalid");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,429 @@
|
|||
import { createPublicKey, timingSafeEqual, verify, type JsonWebKey } from "node:crypto";
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { instanceSettings } from "@paperclipai/db";
|
||||
|
||||
export const CLOUD_RUNTIME_IDENTITY_HEADER = "x-paperclip-cloud-runtime-identity";
|
||||
export const CLOUD_RUNTIME_IDENTITY_AUDIENCE = "paperclip-runtime-identity/v1";
|
||||
export const CLOUD_RUNTIME_IDENTITY_ISSUER = "paperclip-cloud";
|
||||
export const CLOUD_RUNTIME_IDENTITY_JWS_TYPE = "paperclip-cloud-runtime-identity+jwt";
|
||||
|
||||
const SINGLETON_KEY = "cloud-runtime-identity/v1";
|
||||
const MAX_ASSERTION_LIFETIME_SECONDS = 10 * 60;
|
||||
const MAX_CLOCK_SKEW_SECONDS = 30;
|
||||
const STACK_SLUG_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
||||
|
||||
type PersistedRuntimeIdentity = {
|
||||
stackId: string;
|
||||
claimId: string;
|
||||
previousOrigin: string;
|
||||
canonicalOrigin: string;
|
||||
stackSlug: string;
|
||||
appliedAt: Date;
|
||||
};
|
||||
|
||||
type RuntimeIdentityDb = Pick<Db, "select" | "insert">;
|
||||
|
||||
export type CloudRuntimeIdentitySnapshot = {
|
||||
stackId: string;
|
||||
claimId: string;
|
||||
previousOrigin: string;
|
||||
canonicalOrigin: string;
|
||||
stackSlug: string;
|
||||
appliedAt: Date;
|
||||
};
|
||||
|
||||
export type RuntimeIdentityClaims = {
|
||||
v: 1;
|
||||
iss: typeof CLOUD_RUNTIME_IDENTITY_ISSUER;
|
||||
aud: typeof CLOUD_RUNTIME_IDENTITY_AUDIENCE;
|
||||
sub: string;
|
||||
claimId: string;
|
||||
previousOrigin: string;
|
||||
canonicalOrigin: string;
|
||||
stackSlug: string;
|
||||
iat: number;
|
||||
exp: number;
|
||||
};
|
||||
|
||||
let initialized = false;
|
||||
let startupOrigin: string | null = null;
|
||||
let currentIdentity: CloudRuntimeIdentitySnapshot | null = null;
|
||||
|
||||
function nonEmpty(value: string | undefined): string | null {
|
||||
const normalized = value?.trim();
|
||||
return normalized ? normalized : null;
|
||||
}
|
||||
|
||||
function exactHttpsOrigin(value: unknown): string | null {
|
||||
if (typeof value !== "string" || value.length === 0 || value.length > 2048) return null;
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
if (
|
||||
parsed.protocol !== "https:"
|
||||
|| parsed.username
|
||||
|| parsed.password
|
||||
|| parsed.pathname !== "/"
|
||||
|| parsed.search
|
||||
|| parsed.hash
|
||||
|| parsed.origin !== value
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return parsed.origin;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function configuredStartupOrigin(env: NodeJS.ProcessEnv): string | null {
|
||||
const candidate = nonEmpty(env.PAPERCLIP_PUBLIC_URL)
|
||||
?? nonEmpty(env.PAPERCLIP_AUTH_PUBLIC_BASE_URL)
|
||||
?? nonEmpty(env.PAPERCLIP_API_URL);
|
||||
return candidate ? exactHttpsOrigin(candidate) : null;
|
||||
}
|
||||
|
||||
function snapshot(row: PersistedRuntimeIdentity): CloudRuntimeIdentitySnapshot {
|
||||
return {
|
||||
stackId: row.stackId,
|
||||
claimId: row.claimId,
|
||||
previousOrigin: row.previousOrigin,
|
||||
canonicalOrigin: row.canonicalOrigin,
|
||||
stackSlug: row.stackSlug,
|
||||
appliedAt: row.appliedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function parsePersistedIdentity(row: {
|
||||
general: Record<string, unknown>;
|
||||
createdAt: Date;
|
||||
}): PersistedRuntimeIdentity {
|
||||
const value = row.general;
|
||||
if (
|
||||
value.v !== 1
|
||||
|| typeof value.stackId !== "string"
|
||||
|| typeof value.claimId !== "string"
|
||||
|| typeof value.previousOrigin !== "string"
|
||||
|| typeof value.canonicalOrigin !== "string"
|
||||
|| typeof value.stackSlug !== "string"
|
||||
) {
|
||||
throw new Error("Persisted Cloud runtime identity is malformed");
|
||||
}
|
||||
return {
|
||||
stackId: value.stackId,
|
||||
claimId: value.claimId,
|
||||
previousOrigin: value.previousOrigin,
|
||||
canonicalOrigin: value.canonicalOrigin,
|
||||
stackSlug: value.stackSlug,
|
||||
appliedAt: row.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
async function readPersistedIdentity(db: RuntimeIdentityDb): Promise<PersistedRuntimeIdentity | null> {
|
||||
const row = await db
|
||||
.select({
|
||||
general: instanceSettings.general,
|
||||
createdAt: instanceSettings.createdAt,
|
||||
})
|
||||
.from(instanceSettings)
|
||||
.where(eq(instanceSettings.singletonKey, SINGLETON_KEY))
|
||||
.limit(1)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
return row ? parsePersistedIdentity(row) : null;
|
||||
}
|
||||
|
||||
function applyCompatibilityEnvironment(identity: CloudRuntimeIdentitySnapshot, env: NodeJS.ProcessEnv) {
|
||||
const hostname = new URL(identity.canonicalOrigin).hostname;
|
||||
env.PAPERCLIP_PUBLIC_URL = identity.canonicalOrigin;
|
||||
env.PAPERCLIP_AUTH_PUBLIC_BASE_URL = identity.canonicalOrigin;
|
||||
env.PAPERCLIP_API_URL = identity.canonicalOrigin;
|
||||
env.PAPERCLIP_PRIMARY_HOST = hostname;
|
||||
env.PAPERCLIP_STACK_SLUG = identity.stackSlug;
|
||||
|
||||
const existingCandidates = (() => {
|
||||
try {
|
||||
const parsed = JSON.parse(env.PAPERCLIP_RUNTIME_API_CANDIDATES_JSON ?? "[]");
|
||||
return Array.isArray(parsed) ? parsed.filter((value): value is string => typeof value === "string") : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
})();
|
||||
env.PAPERCLIP_RUNTIME_API_CANDIDATES_JSON = JSON.stringify([
|
||||
identity.canonicalOrigin,
|
||||
...existingCandidates.filter((candidate) => candidate !== identity.canonicalOrigin),
|
||||
]);
|
||||
}
|
||||
|
||||
function assertPersistedIdentityMatchesStack(row: PersistedRuntimeIdentity, env: NodeJS.ProcessEnv) {
|
||||
const configuredStackId = nonEmpty(env.PAPERCLIP_CLOUD_STACK_ID);
|
||||
if (!configuredStackId || configuredStackId !== row.stackId) {
|
||||
throw new Error("Persisted Cloud runtime identity does not match PAPERCLIP_CLOUD_STACK_ID");
|
||||
}
|
||||
if (!exactHttpsOrigin(row.previousOrigin) || !exactHttpsOrigin(row.canonicalOrigin)) {
|
||||
throw new Error("Persisted Cloud runtime identity contains an invalid origin");
|
||||
}
|
||||
if (!STACK_SLUG_PATTERN.test(row.stackSlug) || new URL(row.canonicalOrigin).hostname.split(".")[0] !== row.stackSlug) {
|
||||
throw new Error("Persisted Cloud runtime identity contains an invalid stack slug");
|
||||
}
|
||||
}
|
||||
|
||||
/** Load the durable claim before auth, routes, and child-runtime configuration. */
|
||||
export async function initializeCloudRuntimeIdentity(
|
||||
db: Db,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): Promise<CloudRuntimeIdentitySnapshot | null> {
|
||||
startupOrigin = configuredStartupOrigin(env);
|
||||
// Self-hosted servers have no Cloud stack identity to restore. Avoid touching
|
||||
// the singleton table on that path; besides keeping the feature inert, this
|
||||
// preserves lightweight startup/test database seams that intentionally do
|
||||
// not construct a database client.
|
||||
if (!nonEmpty(env.PAPERCLIP_CLOUD_STACK_ID)) {
|
||||
initialized = true;
|
||||
currentIdentity = null;
|
||||
return null;
|
||||
}
|
||||
const row = await readPersistedIdentity(db);
|
||||
initialized = true;
|
||||
if (!row) {
|
||||
currentIdentity = null;
|
||||
return null;
|
||||
}
|
||||
assertPersistedIdentityMatchesStack(row, env);
|
||||
currentIdentity = snapshot(row);
|
||||
applyCompatibilityEnvironment(currentIdentity, env);
|
||||
return currentIdentity;
|
||||
}
|
||||
|
||||
export function getCloudRuntimeIdentity(): CloudRuntimeIdentitySnapshot | null {
|
||||
return currentIdentity ? { ...currentIdentity } : null;
|
||||
}
|
||||
|
||||
/** The live canonical origin, falling back to startup configuration off Cloud. */
|
||||
export function runtimePublicOrigin(env: NodeJS.ProcessEnv = process.env): string | null {
|
||||
if (env === process.env && currentIdentity) return currentIdentity.canonicalOrigin;
|
||||
const candidate = nonEmpty(env.PAPERCLIP_PUBLIC_URL)
|
||||
?? nonEmpty(env.PAPERCLIP_AUTH_PUBLIC_BASE_URL)
|
||||
?? nonEmpty(env.PAPERCLIP_API_URL);
|
||||
if (!candidate) return null;
|
||||
try {
|
||||
return new URL(candidate).origin;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** The asserted Cloud origin only. Callers retain their non-Cloud precedence. */
|
||||
export function runtimeCanonicalOrigin(): string | null {
|
||||
return currentIdentity?.canonicalOrigin ?? null;
|
||||
}
|
||||
|
||||
function decodeJsonPart(part: string, label: string): Record<string, unknown> {
|
||||
if (!/^[A-Za-z0-9_-]+$/.test(part)) throw new Error(`Cloud runtime identity has an invalid ${label}`);
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(Buffer.from(part, "base64url").toString("utf8"));
|
||||
} catch {
|
||||
throw new Error(`Cloud runtime identity has an invalid ${label}`);
|
||||
}
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
throw new Error(`Cloud runtime identity has an invalid ${label}`);
|
||||
}
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function publicKeyForKid(env: NodeJS.ProcessEnv, kid: string) {
|
||||
const raw = nonEmpty(env.PAPERCLIP_CLOUD_RUNTIME_IDENTITY_JWKS);
|
||||
if (!raw) throw new Error("PAPERCLIP_CLOUD_RUNTIME_IDENTITY_JWKS is not configured");
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
throw new Error("PAPERCLIP_CLOUD_RUNTIME_IDENTITY_JWKS is invalid");
|
||||
}
|
||||
const keys = parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
||||
? (parsed as { keys?: unknown }).keys
|
||||
: undefined;
|
||||
if (!Array.isArray(keys)) throw new Error("PAPERCLIP_CLOUD_RUNTIME_IDENTITY_JWKS is invalid");
|
||||
const matches = keys.filter((candidate): candidate is JsonWebKey & { kid: string } => {
|
||||
if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) return false;
|
||||
const key = candidate as JsonWebKey & { kid?: unknown };
|
||||
return key.kid === kid;
|
||||
});
|
||||
if (matches.length !== 1) throw new Error("Cloud runtime identity uses an unknown signing key");
|
||||
const jwk = matches[0];
|
||||
if (jwk.kty !== "OKP" || jwk.crv !== "Ed25519" || jwk.use !== "sig" || jwk.alg !== "EdDSA" || !jwk.x || jwk.d) {
|
||||
throw new Error("Cloud runtime identity signing key is invalid");
|
||||
}
|
||||
return createPublicKey({ key: jwk, format: "jwk" });
|
||||
}
|
||||
|
||||
function verifyClaims(input: {
|
||||
compactJws: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
now: Date;
|
||||
}): RuntimeIdentityClaims {
|
||||
const parts = input.compactJws.split(".");
|
||||
if (parts.length !== 3 || parts.some((part) => part.length === 0)) {
|
||||
throw new Error("Cloud runtime identity assertion is not a compact JWS");
|
||||
}
|
||||
const [encodedHeader, encodedPayload, encodedSignature] = parts;
|
||||
const header = decodeJsonPart(encodedHeader, "protected header");
|
||||
if (
|
||||
header.alg !== "EdDSA"
|
||||
|| header.typ !== CLOUD_RUNTIME_IDENTITY_JWS_TYPE
|
||||
|| typeof header.kid !== "string"
|
||||
|| !header.kid
|
||||
) {
|
||||
throw new Error("Cloud runtime identity protected header is invalid");
|
||||
}
|
||||
const key = publicKeyForKid(input.env, header.kid);
|
||||
const signature = Buffer.from(encodedSignature, "base64url");
|
||||
const signingInput = Buffer.from(`${encodedHeader}.${encodedPayload}`, "ascii");
|
||||
if (!verify(null, signingInput, key, signature)) {
|
||||
throw new Error("Cloud runtime identity signature is invalid");
|
||||
}
|
||||
|
||||
const payload = decodeJsonPart(encodedPayload, "payload");
|
||||
const nowSeconds = Math.floor(input.now.getTime() / 1000);
|
||||
if (
|
||||
payload.v !== 1
|
||||
|| payload.iss !== CLOUD_RUNTIME_IDENTITY_ISSUER
|
||||
|| payload.aud !== CLOUD_RUNTIME_IDENTITY_AUDIENCE
|
||||
|| typeof payload.sub !== "string"
|
||||
|| typeof payload.claimId !== "string"
|
||||
|| typeof payload.previousOrigin !== "string"
|
||||
|| typeof payload.canonicalOrigin !== "string"
|
||||
|| typeof payload.stackSlug !== "string"
|
||||
|| typeof payload.iat !== "number"
|
||||
|| !Number.isInteger(payload.iat)
|
||||
|| typeof payload.exp !== "number"
|
||||
|| !Number.isInteger(payload.exp)
|
||||
) {
|
||||
throw new Error("Cloud runtime identity claims are incomplete");
|
||||
}
|
||||
if (
|
||||
payload.exp <= nowSeconds
|
||||
|| payload.iat > nowSeconds + MAX_CLOCK_SKEW_SECONDS
|
||||
|| payload.exp <= payload.iat
|
||||
|| payload.exp - payload.iat > MAX_ASSERTION_LIFETIME_SECONDS
|
||||
) {
|
||||
throw new Error("Cloud runtime identity assertion is expired or has an invalid lifetime");
|
||||
}
|
||||
return payload as RuntimeIdentityClaims;
|
||||
}
|
||||
|
||||
/** Verify that an assertion is signed for this exact, still-unclaimed instance. */
|
||||
export function verifyCloudRuntimeIdentityAssertion(input: {
|
||||
compactJws: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
now?: Date;
|
||||
expectedPreviousOrigin: string | null;
|
||||
}): RuntimeIdentityClaims {
|
||||
const env = input.env ?? process.env;
|
||||
const claims = verifyClaims({ compactJws: input.compactJws, env, now: input.now ?? new Date() });
|
||||
const configuredStackId = nonEmpty(env.PAPERCLIP_CLOUD_STACK_ID);
|
||||
if (!configuredStackId || claims.sub !== configuredStackId) {
|
||||
throw new Error("Cloud runtime identity stack does not match this instance");
|
||||
}
|
||||
const previousOrigin = exactHttpsOrigin(claims.previousOrigin);
|
||||
const canonicalOrigin = exactHttpsOrigin(claims.canonicalOrigin);
|
||||
if (!previousOrigin || !canonicalOrigin || previousOrigin !== input.expectedPreviousOrigin) {
|
||||
throw new Error("Cloud runtime identity previous or canonical origin is invalid");
|
||||
}
|
||||
if (
|
||||
!STACK_SLUG_PATTERN.test(claims.stackSlug)
|
||||
|| new URL(canonicalOrigin).hostname.split(".")[0] !== claims.stackSlug
|
||||
|| claims.claimId.length > 256
|
||||
|| claims.claimId.trim() !== claims.claimId
|
||||
|| !claims.claimId
|
||||
) {
|
||||
throw new Error("Cloud runtime identity destination is invalid");
|
||||
}
|
||||
return claims;
|
||||
}
|
||||
|
||||
function assertionsEqual(row: PersistedRuntimeIdentity, claims: RuntimeIdentityClaims): boolean {
|
||||
const left = Buffer.from(JSON.stringify([
|
||||
row.stackId,
|
||||
row.claimId,
|
||||
row.previousOrigin,
|
||||
row.canonicalOrigin,
|
||||
row.stackSlug,
|
||||
]));
|
||||
const right = Buffer.from(JSON.stringify([
|
||||
claims.sub,
|
||||
claims.claimId,
|
||||
claims.previousOrigin,
|
||||
claims.canonicalOrigin,
|
||||
claims.stackSlug,
|
||||
]));
|
||||
return left.length === right.length && timingSafeEqual(left, right);
|
||||
}
|
||||
|
||||
/** Verify and durably apply the one-time Cloud claim assertion. */
|
||||
export async function applyCloudRuntimeIdentityAssertion(input: {
|
||||
db: Db;
|
||||
compactJws: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
now?: Date;
|
||||
}): Promise<CloudRuntimeIdentitySnapshot> {
|
||||
const env = input.env ?? process.env;
|
||||
if (!initialized) throw new Error("Cloud runtime identity provider is not initialized");
|
||||
const claims = verifyCloudRuntimeIdentityAssertion({
|
||||
compactJws: input.compactJws,
|
||||
env,
|
||||
now: input.now,
|
||||
// After a natural restart the provider env may already be canonical, but
|
||||
// an identical retry of the original claim is still safe and idempotent.
|
||||
// The durable row preserves the pool origin that assertion had to match
|
||||
// on first application.
|
||||
expectedPreviousOrigin: currentIdentity?.previousOrigin ?? startupOrigin,
|
||||
});
|
||||
const previousOrigin = claims.previousOrigin;
|
||||
const canonicalOrigin = claims.canonicalOrigin;
|
||||
|
||||
const row = await input.db.transaction(async (tx) => {
|
||||
const existing = await readPersistedIdentity(tx);
|
||||
if (existing) {
|
||||
if (!assertionsEqual(existing, claims)) {
|
||||
throw new Error("Cloud runtime identity is already claimed by another assertion");
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
|
||||
const now = input.now ?? new Date();
|
||||
await tx
|
||||
.insert(instanceSettings)
|
||||
.values({
|
||||
singletonKey: SINGLETON_KEY,
|
||||
general: {
|
||||
v: 1,
|
||||
stackId: claims.sub,
|
||||
claimId: claims.claimId,
|
||||
previousOrigin,
|
||||
canonicalOrigin,
|
||||
stackSlug: claims.stackSlug,
|
||||
},
|
||||
experimental: {},
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoNothing({ target: instanceSettings.singletonKey });
|
||||
const durable = await readPersistedIdentity(tx);
|
||||
if (!durable || !assertionsEqual(durable, claims)) {
|
||||
throw new Error("Cloud runtime identity is already claimed by another assertion");
|
||||
}
|
||||
return durable;
|
||||
});
|
||||
|
||||
currentIdentity = snapshot(row);
|
||||
applyCompatibilityEnvironment(currentIdentity, env);
|
||||
return { ...currentIdentity };
|
||||
}
|
||||
|
||||
/** Test seam for modules that intentionally share a process. */
|
||||
export function resetCloudRuntimeIdentityForTests() {
|
||||
initialized = false;
|
||||
startupOrigin = null;
|
||||
currentIdentity = null;
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSy
|
|||
import path from "node:path";
|
||||
|
||||
import { resolvePaperclipInstanceRoot } from "../home-paths.js";
|
||||
import { runtimePublicOrigin } from "./cloud-runtime-identity.js";
|
||||
|
||||
const IDENTITY_VERSION = 1;
|
||||
const ENROLLMENT_FILE = "paperclip-cloud-connector.json";
|
||||
|
|
@ -90,7 +91,8 @@ export function paperclipCloudConnectorEnrollmentStatus(
|
|||
origins: [],
|
||||
};
|
||||
}
|
||||
const publicOrigin = env.PAPERCLIP_PUBLIC_URL ? normalizeInstanceOrigin(env.PAPERCLIP_PUBLIC_URL) : undefined;
|
||||
const resolvedOrigin = runtimePublicOrigin(env);
|
||||
const publicOrigin = resolvedOrigin ? normalizeInstanceOrigin(resolvedOrigin) : undefined;
|
||||
return {
|
||||
configured: true,
|
||||
status: "active",
|
||||
|
|
|
|||
|
|
@ -77,6 +77,7 @@ import {
|
|||
import { queueIssueAssignmentWakeup, type IssueAssignmentWakeupDeps } from "./issue-assignment-wakeup.js";
|
||||
import { logActivity } from "./activity-log.js";
|
||||
import type { PluginWorkerManager } from "./plugin-worker-manager.js";
|
||||
import { runtimePublicOrigin } from "./cloud-runtime-identity.js";
|
||||
|
||||
const OPEN_ISSUE_STATUSES = ["backlog", "todo", "in_progress", "in_review", "blocked"];
|
||||
const LIVE_HEARTBEAT_RUN_STATUSES = ["queued", "running", "scheduled_retry"];
|
||||
|
|
@ -102,6 +103,12 @@ const WEEKDAY_INDEX: Record<string, number> = {
|
|||
Sat: 6,
|
||||
};
|
||||
|
||||
export function routineWebhookUrl(publicId: string): string {
|
||||
const baseUrl = runtimePublicOrigin() ?? process.env.PAPERCLIP_API_URL?.trim();
|
||||
if (!baseUrl) throw new Error("PAPERCLIP_API_URL is required to create a routine webhook");
|
||||
return `${baseUrl.replace(/\/+$/, "")}/api/routine-triggers/public/${publicId}/fire`;
|
||||
}
|
||||
|
||||
type ExecutionIssueTransientFailureStatus = (typeof EXECUTION_ISSUE_TRANSIENT_FAILURE_STATUSES)[number];
|
||||
|
||||
function executionIssueTransientFailureReason(status: ExecutionIssueTransientFailureStatus) {
|
||||
|
|
@ -2438,7 +2445,7 @@ export function routineService(
|
|||
const created = await createWebhookSecret(routine.companyId, routine.id, actor);
|
||||
secretId = created.secret.id;
|
||||
secretMaterial = {
|
||||
webhookUrl: `${process.env.PAPERCLIP_API_URL}/api/routine-triggers/public/${publicId}/fire`,
|
||||
webhookUrl: routineWebhookUrl(publicId),
|
||||
webhookSecret: created.secretValue,
|
||||
};
|
||||
}
|
||||
|
|
@ -2621,7 +2628,7 @@ export function routineService(
|
|||
return {
|
||||
trigger: trigger as RoutineTrigger,
|
||||
secretMaterial: {
|
||||
webhookUrl: `${process.env.PAPERCLIP_API_URL}/api/routine-triggers/public/${existing.publicId}/fire`,
|
||||
webhookUrl: routineWebhookUrl(existing.publicId),
|
||||
webhookSecret: secretValue,
|
||||
},
|
||||
revision,
|
||||
|
|
@ -2701,7 +2708,7 @@ export function routineService(
|
|||
secretId: created.secret.id,
|
||||
secretMaterial: {
|
||||
triggerId: trigger.id,
|
||||
webhookUrl: `${process.env.PAPERCLIP_API_URL}/api/routine-triggers/public/${publicId}/fire`,
|
||||
webhookUrl: routineWebhookUrl(publicId),
|
||||
webhookSecret: created.secretValue,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue