feat(security): per-company JWT signing keys for multi-tenant isolation (#5864)

## 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:<companyId>")` 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:<companyId>")` 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:<companyId>`) 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:<companyId>`) 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) <noreply@anthropic.com>
This commit is contained in:
Jannes Stubbemann
2026-06-12 03:00:22 +02:00
committed by GitHub
parent 606e74d11f
commit 70357b961f
2 changed files with 175 additions and 8 deletions
+121
View File
@@ -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",
});
});
});
+54 -8
View File
@@ -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;