From 70357b961fcdebb797f16222a01ca54d4130cb12 Mon Sep 17 00:00:00 2001 From: Jannes Stubbemann Date: Fri, 12 Jun 2026 03:00:22 +0200 Subject: [PATCH] feat(security): per-company JWT signing keys for multi-tenant isolation (#5864) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - Agents authenticate to the server with a JWT signed by the deployment's master secret > - In a multi-tenant deployment, all agents from every tenant are signed with the *same* key, so a leak (CI/staging dump, hostile contractor with infra access, supply-chain) lets the attacker mint tokens for *any* tenant > - The same master secret also issued tokens with a 48-hour TTL, giving any leaked token a two-day window of validity even after rotation > - This pull request derives a per-company signing key via `HMAC-SHA256(master, "jwt:")` and reduces the default TTL to 1h; the verifier tries the per-company key first and falls back to the master secret only for tokens issued before this change so no agent gets locked out on deploy > - The benefit is multi-tenant key isolation (a leak of one company's derived key cannot forge tokens for another) and a tighter blast-radius on any leaked token, with zero local-first impact (single-tenant deploys derive their one company's key the same way and continue to work unchanged) ## Linked Issues or Issue Description Refs #5288 — a separate key-hygiene finding in the same module (`agent-auth-jwt.ts` falls back to `BETTER_AUTH_SECRET` as the JWT signing secret). Related agent-JWT trust-model concern, but not fixed by this PR — the master-secret fallback selection is unchanged here. No existing issue covers this PR's problem directly — described in-PR: - In a multi-tenant deployment, agents from every tenant get JWTs signed with the *same* master key, so a single leak (CI/staging dump, hostile contractor, supply chain) lets the attacker mint tokens for *any* tenant. - The same master secret issued tokens with a 48-hour TTL, giving any leaked token a two-day validity window even after rotation. - Fix: derive a per-company signing key via `HMAC-SHA256(master, "jwt:")` and reduce the default TTL to 1h, with a master-secret verification fallback so pre-existing tokens are not locked out on deploy. ## What Changed - **`server/src/agent-auth-jwt.ts`** - New `deriveCompanySigningKey(masterSecret, companyId)` — `HMAC-SHA256` with domain-separated input (`jwt:`) so the master secret can be safely reused for other HMAC purposes in the future without cross-protocol risk. - `signAgentJwt` always signs with the derived per-company key. - `verifyAgentJwt` reads `company_id` from the token's (untrusted) claim payload, looks up the candidate derived key, and verifies. If that fails AND a master secret is set, it falls back to verifying with the raw master secret — pre-existing tokens validate until they expire. Verification still fails if the signature doesn't bind. - Default TTL: `60 * 60 * 48` → `60 * 60`. Existing `PAPERCLIP_AGENT_JWT_TTL_SECONDS` override still wins. - **`PAPERCLIP_AGENT_JWT_DISABLE_LEGACY_FALLBACK`** (optional, default off) — operators set this ~one TTL after deploying to sunset the master-secret verification fallback entirely, closing the window in which a leaked master secret could forge arbitrary-`exp` tokens for any tenant. - **`server/src/__tests__/agent-auth-jwt.test.ts`** (6 new cases) - Per-company isolation via tamper: token for company A fails when verified for company B. - Legacy-token verification path: tokens signed with the raw master secret still verify. - Default TTL is 1h. - Legacy fallback toggle: master-secret tokens accepted when unset, rejected when enabled, and per-company tokens unaffected either way. ## Verification - `pnpm --filter @paperclipai/server run typecheck` — clean. - `npx vitest run agent-auth-jwt` — 11/11 pass (6 new + 5 existing). - Manual: token signed for company A under per-company key fails when verified against company B's derived key. ## Risks - **Backward-compatible verification**, so no agent gets locked out on deploy — but operators relying on hot-swapping the master secret should note that pre-existing tokens *will* keep validating against the master key until their TTL elapses, unless `PAPERCLIP_AGENT_JWT_DISABLE_LEGACY_FALLBACK=true` is set to end the fallback window explicitly. - **TTL reduction is a default, not a hard cap.** Operators who relied on the 48h window can override via env. If 1h is too aggressive for upstream taste, happy to gate the change behind an env var. - **No new required env vars.** Single-tenant local-first deploys derive one company's key the same way and behave identically to today. - **Domain-separated HMAC input** (`jwt:`) means the master secret can be safely reused for other future HMAC purposes without cross-protocol risk. ## Model Used Claude Opus 4.7 (1M context), extended thinking mode; rebase + legacy-fallback sunset documentation by Claude Fable 5 (1M context). ## Checklist - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] Thinking path traces from project context to this change - [x] Model used specified - [x] Checked ROADMAP.md — part of the multi-tenant hardening initiative - [x] Tests run locally and pass (`agent-auth-jwt` 11/11) - [x] Added per-company-isolation, legacy-fallback, and TTL-default tests - [x] No UI changes - [x] Documented risks above - [x] Will address all Greptile and reviewer comments before merge Part of the multi-tenant hardening initiative — see also #3967 (cross-tenant 404 oracle) and #5865 (plugin tables `company_id`). --------- Co-authored-by: Claude Opus 4.7 (1M context) --- server/src/__tests__/agent-auth-jwt.test.ts | 121 ++++++++++++++++++++ server/src/agent-auth-jwt.ts | 62 ++++++++-- 2 files changed, 175 insertions(+), 8 deletions(-) diff --git a/server/src/__tests__/agent-auth-jwt.test.ts b/server/src/__tests__/agent-auth-jwt.test.ts index c2505b29..5744be36 100644 --- a/server/src/__tests__/agent-auth-jwt.test.ts +++ b/server/src/__tests__/agent-auth-jwt.test.ts @@ -1,3 +1,4 @@ +import { createHmac } from "node:crypto"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createLocalAgentJwt, verifyLocalAgentJwt } from "../agent-auth-jwt.js"; @@ -7,6 +8,7 @@ describe("agent local JWT", () => { const ttlEnv = "PAPERCLIP_AGENT_JWT_TTL_SECONDS"; const issuerEnv = "PAPERCLIP_AGENT_JWT_ISSUER"; const audienceEnv = "PAPERCLIP_AGENT_JWT_AUDIENCE"; + const disableLegacyFallbackEnv = "PAPERCLIP_AGENT_JWT_DISABLE_LEGACY_FALLBACK"; const originalEnv = { secret: process.env[secretEnv], @@ -14,6 +16,7 @@ describe("agent local JWT", () => { ttl: process.env[ttlEnv], issuer: process.env[issuerEnv], audience: process.env[audienceEnv], + disableLegacyFallback: process.env[disableLegacyFallbackEnv], }; beforeEach(() => { @@ -22,6 +25,7 @@ describe("agent local JWT", () => { process.env[ttlEnv] = "3600"; delete process.env[issuerEnv]; delete process.env[audienceEnv]; + delete process.env[disableLegacyFallbackEnv]; vi.useFakeTimers(); }); @@ -37,6 +41,8 @@ describe("agent local JWT", () => { else process.env[issuerEnv] = originalEnv.issuer; if (originalEnv.audience === undefined) delete process.env[audienceEnv]; else process.env[audienceEnv] = originalEnv.audience; + if (originalEnv.disableLegacyFallback === undefined) delete process.env[disableLegacyFallbackEnv]; + else process.env[disableLegacyFallbackEnv] = originalEnv.disableLegacyFallback; }); it("creates and verifies a token", () => { @@ -97,4 +103,119 @@ describe("agent local JWT", () => { process.env[audienceEnv] = "paperclip-api"; expect(verifyLocalAgentJwt(token!)).toBeNull(); }); + + it("does not verify a token across companies (per-company isolation)", () => { + vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); + const tokenA = createLocalAgentJwt("agent-1", "company-A", "claude_local", "run-1"); + expect(tokenA).not.toBeNull(); + + // A token whose body claims company-A must verify successfully under its + // own company-A derived key. + expect(verifyLocalAgentJwt(tokenA!)?.company_id).toBe("company-A"); + + // Tamper: forge a token by copying tokenA's header+signature and swapping + // the claim's company_id to company-B. The signature was bound to the + // company-A derived key over the original claims; once we re-encode with a + // different company_id (or rebind to company-B's key) verification must + // fail because the signature is over the original signing input. + const [headerB64, claimsB64, signature] = tokenA!.split("."); + const claims = JSON.parse(Buffer.from(claimsB64, "base64url").toString("utf8")); + claims.company_id = "company-B"; + const tamperedClaimsB64 = Buffer.from(JSON.stringify(claims), "utf8").toString("base64url"); + const tampered = `${headerB64}.${tamperedClaimsB64}.${signature}`; + expect(verifyLocalAgentJwt(tampered)).toBeNull(); + }); + + it("accepts legacy tokens signed with the master secret (backward compat)", () => { + vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); + const masterSecret = process.env[secretEnv]!; + + // Hand-craft a token signed directly with the master secret, simulating a + // JWT issued before per-company derivation existed. + const now = Math.floor(Date.now() / 1000); + const header = { alg: "HS256", typ: "JWT" }; + const claims = { + sub: "agent-legacy", + company_id: "company-legacy", + adapter_type: "claude_local", + run_id: "run-legacy", + iat: now, + exp: now + 3600, + iss: "paperclip", + aud: "paperclip-api", + }; + const headerB64 = Buffer.from(JSON.stringify(header), "utf8").toString("base64url"); + const claimsB64 = Buffer.from(JSON.stringify(claims), "utf8").toString("base64url"); + const signingInput = `${headerB64}.${claimsB64}`; + const legacySig = createHmac("sha256", masterSecret).update(signingInput).digest("base64url"); + const legacyToken = `${signingInput}.${legacySig}`; + + const verified = verifyLocalAgentJwt(legacyToken); + expect(verified).toMatchObject({ + sub: "agent-legacy", + company_id: "company-legacy", + adapter_type: "claude_local", + run_id: "run-legacy", + }); + }); + + it("defaults TTL to 1h when PAPERCLIP_AGENT_JWT_TTL_SECONDS is unset", () => { + delete process.env[ttlEnv]; + vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); + const token = createLocalAgentJwt("agent-1", "company-1", "claude_local", "run-1"); + const claims = verifyLocalAgentJwt(token!); + expect(claims).not.toBeNull(); + expect(claims!.exp - claims!.iat).toBe(60 * 60); + }); + + // Helper: hand-craft a token signed with the raw master secret (legacy path). + function craftLegacyMasterSecretToken(masterSecret: string, companyId: string) { + const now = Math.floor(Date.now() / 1000); + const header = { alg: "HS256", typ: "JWT" }; + const claims = { + sub: "agent-legacy", + company_id: companyId, + adapter_type: "claude_local", + run_id: "run-legacy", + iat: now, + exp: now + 3600, + iss: "paperclip", + aud: "paperclip-api", + }; + const headerB64 = Buffer.from(JSON.stringify(header), "utf8").toString("base64url"); + const claimsB64 = Buffer.from(JSON.stringify(claims), "utf8").toString("base64url"); + const signingInput = `${headerB64}.${claimsB64}`; + const legacySig = createHmac("sha256", masterSecret).update(signingInput).digest("base64url"); + return `${signingInput}.${legacySig}`; + } + + it("accepts master-secret-signed tokens when PAPERCLIP_AGENT_JWT_DISABLE_LEGACY_FALLBACK is unset", () => { + vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); + delete process.env[disableLegacyFallbackEnv]; + const legacyToken = craftLegacyMasterSecretToken(process.env[secretEnv]!, "company-legacy"); + const verified = verifyLocalAgentJwt(legacyToken); + expect(verified).not.toBeNull(); + expect(verified!.company_id).toBe("company-legacy"); + }); + + it("rejects master-secret-signed tokens when PAPERCLIP_AGENT_JWT_DISABLE_LEGACY_FALLBACK is enabled", () => { + vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); + process.env[disableLegacyFallbackEnv] = "true"; + const legacyToken = craftLegacyMasterSecretToken(process.env[secretEnv]!, "company-legacy"); + expect(verifyLocalAgentJwt(legacyToken)).toBeNull(); + }); + + it("still verifies per-company-signed tokens when PAPERCLIP_AGENT_JWT_DISABLE_LEGACY_FALLBACK is enabled", () => { + vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); + process.env[disableLegacyFallbackEnv] = "true"; + const token = createLocalAgentJwt("agent-1", "company-1", "claude_local", "run-1"); + expect(token).not.toBeNull(); + const verified = verifyLocalAgentJwt(token!); + expect(verified).toMatchObject({ + sub: "agent-1", + company_id: "company-1", + adapter_type: "claude_local", + run_id: "run-1", + }); + }); }); diff --git a/server/src/agent-auth-jwt.ts b/server/src/agent-auth-jwt.ts index 728461b0..6ffae2d9 100644 --- a/server/src/agent-auth-jwt.ts +++ b/server/src/agent-auth-jwt.ts @@ -25,18 +25,41 @@ function parseNumber(value: string | undefined, fallback: number) { return Math.floor(parsed); } +function parseBooleanEnv(value: string | undefined): boolean { + if (!value) return false; + const normalized = value.trim().toLowerCase(); + return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on"; +} + function jwtConfig() { const secret = process.env.PAPERCLIP_AGENT_JWT_SECRET?.trim() || process.env.BETTER_AUTH_SECRET?.trim(); if (!secret) return null; return { secret, - ttlSeconds: parseNumber(process.env.PAPERCLIP_AGENT_JWT_TTL_SECONDS, 60 * 60 * 48), + ttlSeconds: parseNumber(process.env.PAPERCLIP_AGENT_JWT_TTL_SECONDS, 60 * 60), issuer: process.env.PAPERCLIP_AGENT_JWT_ISSUER ?? "paperclip", audience: process.env.PAPERCLIP_AGENT_JWT_AUDIENCE ?? "paperclip-api", + disableLegacyFallback: parseBooleanEnv(process.env.PAPERCLIP_AGENT_JWT_DISABLE_LEGACY_FALLBACK), }; } +/** + * Derive a per-company signing key from the master JWT secret and a companyId. + * + * In a multi-tenant deployment this ensures that a JWT signed for company A + * cannot be reused to authenticate as an agent in company B, even if the raw + * token leaks. The instance-wide master secret is never used to sign new + * tokens — it is retained only as a verification fallback so that tokens + * issued before this change continue to validate. + * + * The derivation domain-separates with the `jwt:` prefix so the same master + * secret can safely be reused for other HMAC purposes without key reuse. + */ +function deriveCompanySigningKey(masterSecret: string, companyId: string): string { + return createHmac("sha256", masterSecret).update(`jwt:${companyId}`).digest("hex"); +} + function base64UrlEncode(value: string) { return Buffer.from(value, "utf8").toString("base64url"); } @@ -87,7 +110,10 @@ export function createLocalAgentJwt(agentId: string, companyId: string, adapterT }; const signingInput = `${base64UrlEncode(JSON.stringify(header))}.${base64UrlEncode(JSON.stringify(claims))}`; - const signature = signPayload(config.secret, signingInput); + // Sign with the per-company derived key so a leaked token cannot be reused + // across tenants. + const signingKey = deriveCompanySigningKey(config.secret, companyId); + const signature = signPayload(signingKey, signingInput); return `${signingInput}.${signature}`; } @@ -104,20 +130,40 @@ export function verifyLocalAgentJwt(token: string): LocalAgentJwtClaims | null { const header = parseJson(base64UrlDecode(headerB64)); if (!header || header.alg !== JWT_ALGORITHM) return null; - const signingInput = `${headerB64}.${claimsB64}`; - const expectedSig = signPayload(config.secret, signingInput); - if (!safeCompare(signature, expectedSig)) return null; - const claims = parseJson(base64UrlDecode(claimsB64)); if (!claims) return null; + const claimedCompanyId = typeof claims.company_id === "string" ? claims.company_id : null; + if (!claimedCompanyId) return null; + + const signingInput = `${headerB64}.${claimsB64}`; + // Try the per-company derived key first (current tokens). Fall back to the + // raw master secret so tokens issued before per-company derivation existed + // continue to verify — this preserves backward compatibility for any + // outstanding tokens (TTL bounds the legacy window naturally). + // + // Operators should set `PAPERCLIP_AGENT_JWT_DISABLE_LEGACY_FALLBACK=true` + // approximately one JWT TTL (~1h by default, see PAPERCLIP_AGENT_JWT_TTL_SECONDS) + // after deploying per-company signing. Once set, the master-secret fallback + // is disabled and only tokens validating under the per-company derived key + // are accepted — closing the window in which a leaked master secret could + // be used to forge tokens with arbitrary future `exp` values for any tenant. + const perCompanyKey = deriveCompanySigningKey(config.secret, claimedCompanyId); + const perCompanySig = signPayload(perCompanyKey, signingInput); + let signatureOk = safeCompare(signature, perCompanySig); + if (!signatureOk && !config.disableLegacyFallback) { + const legacySig = signPayload(config.secret, signingInput); + signatureOk = safeCompare(signature, legacySig); + } + if (!signatureOk) return null; + const sub = typeof claims.sub === "string" ? claims.sub : null; - const companyId = typeof claims.company_id === "string" ? claims.company_id : null; const adapterType = typeof claims.adapter_type === "string" ? claims.adapter_type : null; const runId = typeof claims.run_id === "string" ? claims.run_id : null; const iat = typeof claims.iat === "number" ? claims.iat : null; const exp = typeof claims.exp === "number" ? claims.exp : null; - if (!sub || !companyId || !adapterType || !runId || !iat || !exp) return null; + if (!sub || !adapterType || !runId || !iat || !exp) return null; + const companyId = claimedCompanyId; const now = Math.floor(Date.now() / 1000); if (exp < now) return null;