70357b961f
## 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>
188 lines
6.6 KiB
TypeScript
188 lines
6.6 KiB
TypeScript
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
|
|
interface JwtHeader {
|
|
alg: string;
|
|
typ?: string;
|
|
}
|
|
|
|
export interface LocalAgentJwtClaims {
|
|
sub: string;
|
|
company_id: string;
|
|
adapter_type: string;
|
|
run_id: string;
|
|
iat: number;
|
|
exp: number;
|
|
iss?: string;
|
|
aud?: string;
|
|
jti?: string;
|
|
}
|
|
|
|
const JWT_ALGORITHM = "HS256";
|
|
|
|
function parseNumber(value: string | undefined, fallback: number) {
|
|
const parsed = Number(value);
|
|
if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
|
|
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),
|
|
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");
|
|
}
|
|
|
|
function base64UrlDecode(value: string) {
|
|
return Buffer.from(value, "base64url").toString("utf8");
|
|
}
|
|
|
|
function signPayload(secret: string, signingInput: string) {
|
|
return createHmac("sha256", secret).update(signingInput).digest("base64url");
|
|
}
|
|
|
|
function parseJson(value: string): Record<string, unknown> | null {
|
|
try {
|
|
const parsed = JSON.parse(value);
|
|
return parsed && typeof parsed === "object" ? parsed as Record<string, unknown> : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function safeCompare(a: string, b: string) {
|
|
const left = Buffer.from(a);
|
|
const right = Buffer.from(b);
|
|
if (left.length !== right.length) return false;
|
|
return timingSafeEqual(left, right);
|
|
}
|
|
|
|
export function createLocalAgentJwt(agentId: string, companyId: string, adapterType: string, runId: string) {
|
|
const config = jwtConfig();
|
|
if (!config) return null;
|
|
|
|
const now = Math.floor(Date.now() / 1000);
|
|
const claims: LocalAgentJwtClaims = {
|
|
sub: agentId,
|
|
company_id: companyId,
|
|
adapter_type: adapterType,
|
|
run_id: runId,
|
|
iat: now,
|
|
exp: now + config.ttlSeconds,
|
|
iss: config.issuer,
|
|
aud: config.audience,
|
|
};
|
|
|
|
const header = {
|
|
alg: JWT_ALGORITHM,
|
|
typ: "JWT",
|
|
};
|
|
|
|
const signingInput = `${base64UrlEncode(JSON.stringify(header))}.${base64UrlEncode(JSON.stringify(claims))}`;
|
|
// 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}`;
|
|
}
|
|
|
|
export function verifyLocalAgentJwt(token: string): LocalAgentJwtClaims | null {
|
|
if (!token) return null;
|
|
const config = jwtConfig();
|
|
if (!config) return null;
|
|
|
|
const parts = token.split(".");
|
|
if (parts.length !== 3) return null;
|
|
const [headerB64, claimsB64, signature] = parts;
|
|
|
|
const header = parseJson(base64UrlDecode(headerB64));
|
|
if (!header || header.alg !== JWT_ALGORITHM) 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 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 || !adapterType || !runId || !iat || !exp) return null;
|
|
const companyId = claimedCompanyId;
|
|
|
|
const now = Math.floor(Date.now() / 1000);
|
|
if (exp < now) return null;
|
|
|
|
const issuer = typeof claims.iss === "string" ? claims.iss : undefined;
|
|
const audience = typeof claims.aud === "string" ? claims.aud : undefined;
|
|
if (issuer && issuer !== config.issuer) return null;
|
|
if (audience && audience !== config.audience) return null;
|
|
|
|
return {
|
|
sub,
|
|
company_id: companyId,
|
|
adapter_type: adapterType,
|
|
run_id: runId,
|
|
iat,
|
|
exp,
|
|
...(issuer ? { iss: issuer } : {}),
|
|
...(audience ? { aud: audience } : {}),
|
|
jti: typeof claims.jti === "string" ? claims.jti : undefined,
|
|
};
|
|
}
|