From fbbd279e9980323595f1a8b8304f1892ab38f58b Mon Sep 17 00:00:00 2001 From: chrismaz11 Date: Mon, 16 Mar 2026 17:41:51 -0500 Subject: [PATCH 1/2] Add internal API key rotation support --- .env.example | 1 + README.md | 3 +++ src/config/env.ts | 32 ++++++++++++++++++++++++++++++-- src/routes/github.ts | 9 ++++++++- tests/env.test.ts | 20 ++++++++++++++++++++ tests/server.test.ts | 17 +++++++++++++++++ 6 files changed, 79 insertions(+), 3 deletions(-) diff --git a/.env.example b/.env.example index 36a4117..99640f2 100644 --- a/.env.example +++ b/.env.example @@ -10,4 +10,5 @@ GITHUB_WEB_BASE_URL=https://github.com TRUSTSIGNAL_API_BASE_URL=https://trustsignal.example.com TRUSTSIGNAL_API_KEY=change-me-api-key INTERNAL_API_KEY=change-me-internal-api-key +INTERNAL_API_KEYS=change-me-api-key-v2,change-me-api-key-v3 LOG_LEVEL=info diff --git a/README.md b/README.md index 8ef2282..1613571 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,7 @@ Required values: - `TRUSTSIGNAL_API_BASE_URL` - `TRUSTSIGNAL_API_KEY` - `INTERNAL_API_KEY` +- `INTERNAL_API_KEYS` (optional, comma-separated; useful for key rotation) - `LOG_LEVEL` Important distinction: @@ -227,6 +228,8 @@ https:///webhooks/github `/github/installations` and `/github/check-run` are internal endpoints and require the dedicated internal API key via `Authorization: Bearer ` or `x-api-key`. +To rotate keys safely in production, you can keep `INTERNAL_API_KEY` and add `INTERNAL_API_KEYS` with a comma-separated list of accepted keys. Any listed key is accepted. + `GET /` returns a minimal service descriptor for load balancers, demos, and quick smoke checks. `GET /health` returns environment, uptime, timestamp, and deployment metadata (`gitSha`, `buildTime`, `version`) suitable for readiness checks. - `GET /version` is a compact deployment verification endpoint with the same metadata. diff --git a/src/config/env.ts b/src/config/env.ts index 02ecf97..658ccad 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -13,21 +13,46 @@ const envSchema = z.object({ GITHUB_WEB_BASE_URL: z.string().url("GITHUB_WEB_BASE_URL must be a valid URL").optional(), TRUSTSIGNAL_API_BASE_URL: z.string().url("TRUSTSIGNAL_API_BASE_URL must be a valid URL"), TRUSTSIGNAL_API_KEY: z.string().min(1, "TRUSTSIGNAL_API_KEY is required"), - INTERNAL_API_KEY: z.string().min(1, "INTERNAL_API_KEY is required"), + INTERNAL_API_KEY: z.string().min(1, "INTERNAL_API_KEY is required").optional(), + INTERNAL_API_KEYS: z.string().optional(), LOG_LEVEL: z.enum(["fatal", "error", "warn", "info", "debug", "trace"]).default("info"), }); type ParsedEnv = z.infer; -export interface AppEnv extends Omit { +export interface AppEnv extends Omit { GITHUB_PRIVATE_KEY: string; GITHUB_PRIVATE_KEY_PEM: string; + INTERNAL_API_KEY: string; + INTERNAL_API_KEYS: string[]; } export function normalizePrivateKey(value: string) { return value.replace(/\\n/g, "\n").trim(); } +function parseInternalApiKeys(parsed: ParsedEnv) { + const rawValues = [parsed.INTERNAL_API_KEY, parsed.INTERNAL_API_KEYS] + .filter((value): value is string => Boolean(value)) + .flatMap((value) => value.split(",")) + .map((value) => value.trim()) + .filter(Boolean); + + const keys = [...new Set(rawValues)]; + + if (!keys.length) { + throw new z.ZodError([ + { + code: z.ZodIssueCode.custom, + path: ["INTERNAL_API_KEY"], + message: "INTERNAL_API_KEY or INTERNAL_API_KEYS is required", + }, + ]); + } + + return keys; +} + export function parseEnv(input: NodeJS.ProcessEnv): AppEnv { const parsed = envSchema.parse(input); const privateKey = parsed.GITHUB_PRIVATE_KEY_PEM || parsed.GITHUB_PRIVATE_KEY; @@ -43,11 +68,14 @@ export function parseEnv(input: NodeJS.ProcessEnv): AppEnv { } const normalizedKey = normalizePrivateKey(privateKey); + const internalApiKeys = parseInternalApiKeys(parsed); return { ...parsed, GITHUB_PRIVATE_KEY: normalizedKey, GITHUB_PRIVATE_KEY_PEM: normalizedKey, + INTERNAL_API_KEY: internalApiKeys[0], + INTERNAL_API_KEYS: internalApiKeys, }; } diff --git a/src/routes/github.ts b/src/routes/github.ts index ec25b6c..0ee3ef3 100644 --- a/src/routes/github.ts +++ b/src/routes/github.ts @@ -49,11 +49,18 @@ function safeEqual(a: string, b: string) { } export function createInternalApiKeyMiddleware(expected: string) { + const expectedTokens = expected + .split(",") + .map((value) => value.trim()) + .filter(Boolean); + return (req: Request, _res: Response, next: NextFunction) => { const header = (req.header("authorization") || req.header("x-api-key") || "").trim(); const token = header.startsWith("Bearer ") ? header.slice(7).trim() : header; - if (!token || !safeEqual(token, expected)) { + const isAuthorized = token && expectedTokens.some((expectedToken) => safeEqual(token, expectedToken)); + + if (!isAuthorized) { next(new AuthenticationError()); return; } diff --git a/tests/env.test.ts b/tests/env.test.ts index d68816b..aba25c4 100644 --- a/tests/env.test.ts +++ b/tests/env.test.ts @@ -16,11 +16,13 @@ describe("parseEnv", () => { TRUSTSIGNAL_API_BASE_URL: "https://trustsignal.example.com", TRUSTSIGNAL_API_KEY: "api-key", INTERNAL_API_KEY: "internal-key", + INTERNAL_API_KEYS: "internal-key-2, internal-key-3", LOG_LEVEL: "info", }); expect(env.GITHUB_PRIVATE_KEY_PEM).toContain("BEGIN RSA PRIVATE KEY"); expect(env.GITHUB_API_BASE_URL).toBe("https://api.github.com"); + expect(env.INTERNAL_API_KEYS).toEqual(["internal-key", "internal-key-2", "internal-key-3"]); }); it("fails closed when required values are missing", () => { @@ -47,4 +49,22 @@ describe("parseEnv", () => { expect(env.GITHUB_PRIVATE_KEY_PEM).toContain("BEGIN RSA PRIVATE KEY"); }); + + it("accepts INTERNAL_API_KEYS when INTERNAL_API_KEY is not provided", () => { + const env = parseEnv({ + NODE_ENV: "test", + PORT: "3000", + GITHUB_APP_ID: "123", + GITHUB_APP_NAME: "TrustSignal", + GITHUB_WEBHOOK_SECRET: "secret", + GITHUB_PRIVATE_KEY_PEM: "-----BEGIN RSA PRIVATE KEY-----\\nkey\\n-----END RSA PRIVATE KEY-----", + TRUSTSIGNAL_API_BASE_URL: "https://trustsignal.example.com", + TRUSTSIGNAL_API_KEY: "api-key", + INTERNAL_API_KEYS: "internal-key-a, internal-key-b", + LOG_LEVEL: "info", + }); + + expect(env.INTERNAL_API_KEY).toBe("internal-key-a"); + expect(env.INTERNAL_API_KEYS).toEqual(["internal-key-a", "internal-key-b"]); + }); }); diff --git a/tests/server.test.ts b/tests/server.test.ts index b0b84ed..5ea703f 100644 --- a/tests/server.test.ts +++ b/tests/server.test.ts @@ -169,6 +169,23 @@ describe("route handlers", () => { expect(next.mock.calls[0]?.[0]).toMatchObject({ statusCode: 401, code: "unauthorized" }); }); + it("accepts any configured internal API key from a comma-separated list", () => { + const middleware = createInternalApiKeyMiddleware("internal-key-1, internal-key-2"); + const req = { + header: vi.fn((name: string) => { + if (name === "authorization") { + return "Bearer internal-key-2"; + } + return undefined; + }), + } as any; + const next = vi.fn(); + + middleware(req, {} as any, next); + + expect(next).toHaveBeenCalledWith(); + }); + it("rejects replayed deliveries", async () => { const services = createServices(); services.replayStore.begin = vi.fn().mockReturnValue("completed"); From bcc6b4e7e49753133ef6f1729b99144c6f488e8a Mon Sep 17 00:00:00 2001 From: chrismaz11 Date: Mon, 16 Mar 2026 17:58:05 -0500 Subject: [PATCH 2/2] Preserve rotated internal API keys in legacy env field --- src/config/env.ts | 2 +- tests/env.test.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/config/env.ts b/src/config/env.ts index 658ccad..40c8f28 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -74,7 +74,7 @@ export function parseEnv(input: NodeJS.ProcessEnv): AppEnv { ...parsed, GITHUB_PRIVATE_KEY: normalizedKey, GITHUB_PRIVATE_KEY_PEM: normalizedKey, - INTERNAL_API_KEY: internalApiKeys[0], + INTERNAL_API_KEY: internalApiKeys.join(","), INTERNAL_API_KEYS: internalApiKeys, }; } diff --git a/tests/env.test.ts b/tests/env.test.ts index aba25c4..35b9983 100644 --- a/tests/env.test.ts +++ b/tests/env.test.ts @@ -22,6 +22,7 @@ describe("parseEnv", () => { expect(env.GITHUB_PRIVATE_KEY_PEM).toContain("BEGIN RSA PRIVATE KEY"); expect(env.GITHUB_API_BASE_URL).toBe("https://api.github.com"); + expect(env.INTERNAL_API_KEY).toBe("internal-key,internal-key-2,internal-key-3"); expect(env.INTERNAL_API_KEYS).toEqual(["internal-key", "internal-key-2", "internal-key-3"]); }); @@ -64,7 +65,7 @@ describe("parseEnv", () => { LOG_LEVEL: "info", }); - expect(env.INTERNAL_API_KEY).toBe("internal-key-a"); + expect(env.INTERNAL_API_KEY).toBe("internal-key-a,internal-key-b"); expect(env.INTERNAL_API_KEYS).toEqual(["internal-key-a", "internal-key-b"]); }); });