Skip to content

Commit a8c88ac

Browse files
authored
Merge pull request #13 from TrustSignal-dev/cm2026-03-1622-41-49verify-trust-signal-system-functionality
Add internal API key rotation support
2 parents 396a288 + bcc6b4e commit a8c88ac

6 files changed

Lines changed: 80 additions & 3 deletions

File tree

.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,5 @@ GITHUB_WEB_BASE_URL=https://github.com
1010
TRUSTSIGNAL_API_BASE_URL=https://trustsignal.example.com
1111
TRUSTSIGNAL_API_KEY=change-me-api-key
1212
INTERNAL_API_KEY=change-me-internal-api-key
13+
INTERNAL_API_KEYS=change-me-api-key-v2,change-me-api-key-v3
1314
LOG_LEVEL=info

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ Required values:
120120
- `TRUSTSIGNAL_API_BASE_URL`
121121
- `TRUSTSIGNAL_API_KEY`
122122
- `INTERNAL_API_KEY`
123+
- `INTERNAL_API_KEYS` (optional, comma-separated; useful for key rotation)
123124
- `LOG_LEVEL`
124125

125126
Important distinction:
@@ -227,6 +228,8 @@ https://<your-tunnel-host>/webhooks/github
227228

228229
`/github/installations` and `/github/check-run` are internal endpoints and require the dedicated internal API key via `Authorization: Bearer <INTERNAL_API_KEY>` or `x-api-key`.
229230

231+
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.
232+
230233
`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.
231234

232235
- `GET /version` is a compact deployment verification endpoint with the same metadata.

src/config/env.ts

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,21 +13,46 @@ const envSchema = z.object({
1313
GITHUB_WEB_BASE_URL: z.string().url("GITHUB_WEB_BASE_URL must be a valid URL").optional(),
1414
TRUSTSIGNAL_API_BASE_URL: z.string().url("TRUSTSIGNAL_API_BASE_URL must be a valid URL"),
1515
TRUSTSIGNAL_API_KEY: z.string().min(1, "TRUSTSIGNAL_API_KEY is required"),
16-
INTERNAL_API_KEY: z.string().min(1, "INTERNAL_API_KEY is required"),
16+
INTERNAL_API_KEY: z.string().min(1, "INTERNAL_API_KEY is required").optional(),
17+
INTERNAL_API_KEYS: z.string().optional(),
1718
LOG_LEVEL: z.enum(["fatal", "error", "warn", "info", "debug", "trace"]).default("info"),
1819
});
1920

2021
type ParsedEnv = z.infer<typeof envSchema>;
2122

22-
export interface AppEnv extends Omit<ParsedEnv, "GITHUB_PRIVATE_KEY" | "GITHUB_PRIVATE_KEY_PEM"> {
23+
export interface AppEnv extends Omit<ParsedEnv, "GITHUB_PRIVATE_KEY" | "GITHUB_PRIVATE_KEY_PEM" | "INTERNAL_API_KEYS" | "INTERNAL_API_KEY"> {
2324
GITHUB_PRIVATE_KEY: string;
2425
GITHUB_PRIVATE_KEY_PEM: string;
26+
INTERNAL_API_KEY: string;
27+
INTERNAL_API_KEYS: string[];
2528
}
2629

2730
export function normalizePrivateKey(value: string) {
2831
return value.replace(/\\n/g, "\n").trim();
2932
}
3033

34+
function parseInternalApiKeys(parsed: ParsedEnv) {
35+
const rawValues = [parsed.INTERNAL_API_KEY, parsed.INTERNAL_API_KEYS]
36+
.filter((value): value is string => Boolean(value))
37+
.flatMap((value) => value.split(","))
38+
.map((value) => value.trim())
39+
.filter(Boolean);
40+
41+
const keys = [...new Set(rawValues)];
42+
43+
if (!keys.length) {
44+
throw new z.ZodError([
45+
{
46+
code: z.ZodIssueCode.custom,
47+
path: ["INTERNAL_API_KEY"],
48+
message: "INTERNAL_API_KEY or INTERNAL_API_KEYS is required",
49+
},
50+
]);
51+
}
52+
53+
return keys;
54+
}
55+
3156
export function parseEnv(input: NodeJS.ProcessEnv): AppEnv {
3257
const parsed = envSchema.parse(input);
3358
const privateKey = parsed.GITHUB_PRIVATE_KEY_PEM || parsed.GITHUB_PRIVATE_KEY;
@@ -43,11 +68,14 @@ export function parseEnv(input: NodeJS.ProcessEnv): AppEnv {
4368
}
4469

4570
const normalizedKey = normalizePrivateKey(privateKey);
71+
const internalApiKeys = parseInternalApiKeys(parsed);
4672

4773
return {
4874
...parsed,
4975
GITHUB_PRIVATE_KEY: normalizedKey,
5076
GITHUB_PRIVATE_KEY_PEM: normalizedKey,
77+
INTERNAL_API_KEY: internalApiKeys.join(","),
78+
INTERNAL_API_KEYS: internalApiKeys,
5179
};
5280
}
5381

src/routes/github.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,11 +49,18 @@ function safeEqual(a: string, b: string) {
4949
}
5050

5151
export function createInternalApiKeyMiddleware(expected: string) {
52+
const expectedTokens = expected
53+
.split(",")
54+
.map((value) => value.trim())
55+
.filter(Boolean);
56+
5257
return (req: Request, _res: Response, next: NextFunction) => {
5358
const header = (req.header("authorization") || req.header("x-api-key") || "").trim();
5459
const token = header.startsWith("Bearer ") ? header.slice(7).trim() : header;
5560

56-
if (!token || !safeEqual(token, expected)) {
61+
const isAuthorized = token && expectedTokens.some((expectedToken) => safeEqual(token, expectedToken));
62+
63+
if (!isAuthorized) {
5764
next(new AuthenticationError());
5865
return;
5966
}

tests/env.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,14 @@ describe("parseEnv", () => {
1616
TRUSTSIGNAL_API_BASE_URL: "https://trustsignal.example.com",
1717
TRUSTSIGNAL_API_KEY: "api-key",
1818
INTERNAL_API_KEY: "internal-key",
19+
INTERNAL_API_KEYS: "internal-key-2, internal-key-3",
1920
LOG_LEVEL: "info",
2021
});
2122

2223
expect(env.GITHUB_PRIVATE_KEY_PEM).toContain("BEGIN RSA PRIVATE KEY");
2324
expect(env.GITHUB_API_BASE_URL).toBe("https://api.github.com");
25+
expect(env.INTERNAL_API_KEY).toBe("internal-key,internal-key-2,internal-key-3");
26+
expect(env.INTERNAL_API_KEYS).toEqual(["internal-key", "internal-key-2", "internal-key-3"]);
2427
});
2528

2629
it("fails closed when required values are missing", () => {
@@ -47,4 +50,22 @@ describe("parseEnv", () => {
4750

4851
expect(env.GITHUB_PRIVATE_KEY_PEM).toContain("BEGIN RSA PRIVATE KEY");
4952
});
53+
54+
it("accepts INTERNAL_API_KEYS when INTERNAL_API_KEY is not provided", () => {
55+
const env = parseEnv({
56+
NODE_ENV: "test",
57+
PORT: "3000",
58+
GITHUB_APP_ID: "123",
59+
GITHUB_APP_NAME: "TrustSignal",
60+
GITHUB_WEBHOOK_SECRET: "secret",
61+
GITHUB_PRIVATE_KEY_PEM: "-----BEGIN RSA PRIVATE KEY-----\\nkey\\n-----END RSA PRIVATE KEY-----",
62+
TRUSTSIGNAL_API_BASE_URL: "https://trustsignal.example.com",
63+
TRUSTSIGNAL_API_KEY: "api-key",
64+
INTERNAL_API_KEYS: "internal-key-a, internal-key-b",
65+
LOG_LEVEL: "info",
66+
});
67+
68+
expect(env.INTERNAL_API_KEY).toBe("internal-key-a,internal-key-b");
69+
expect(env.INTERNAL_API_KEYS).toEqual(["internal-key-a", "internal-key-b"]);
70+
});
5071
});

tests/server.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,23 @@ describe("route handlers", () => {
169169
expect(next.mock.calls[0]?.[0]).toMatchObject({ statusCode: 401, code: "unauthorized" });
170170
});
171171

172+
it("accepts any configured internal API key from a comma-separated list", () => {
173+
const middleware = createInternalApiKeyMiddleware("internal-key-1, internal-key-2");
174+
const req = {
175+
header: vi.fn((name: string) => {
176+
if (name === "authorization") {
177+
return "Bearer internal-key-2";
178+
}
179+
return undefined;
180+
}),
181+
} as any;
182+
const next = vi.fn();
183+
184+
middleware(req, {} as any, next);
185+
186+
expect(next).toHaveBeenCalledWith();
187+
});
188+
172189
it("rejects replayed deliveries", async () => {
173190
const services = createServices();
174191
services.replayStore.begin = vi.fn().mockReturnValue("completed");

0 commit comments

Comments
 (0)