diff --git a/.env.example b/.env.example index de660f7..030b521 100644 --- a/.env.example +++ b/.env.example @@ -22,5 +22,8 @@ R2_SECRET_ACCESS_KEY= # JWKS (RS256), so no signing secret is needed - or wanted - here. SUPABASE_URL= -# Minted for continuum-proxy edge gateways via POST /auth/edge-token +# Signs the edge JWTs minted for continuum-proxy gateways via +# POST /auth/edge-token and verified by /ws/edge/v1. Required - the process +# refuses to start without it, because anything that knows this secret can +# connect as any farm's gateway. Generate one with: openssl rand -hex 32 EDGE_JWT_SECRET= diff --git a/src/auth/edge_secret.ts b/src/auth/edge_secret.ts new file mode 100644 index 0000000..4bb751b --- /dev/null +++ b/src/auth/edge_secret.ts @@ -0,0 +1,34 @@ +/** + * The HS256 secret that signs and verifies edge-gateway JWTs, read once at + * module load so a misconfigured deployment fails at boot rather than on the + * first gateway connection. Same reasoning as `verifier.ts`, and the same + * shape deliberately. + * + * It replaces a literal `"dev-edge-secret-change-me"` fallback that was + * duplicated in `routes/auth.ts` and `routes/ws/edge.ts`. Two problems with + * that, beyond the duplication: + * + * - The fallback is in a git repository, so it is not a secret. Anything that + * knows it can mint an edge JWT for any `farm_id` it likes and open + * `/ws/edge/v1` as that farm's gateway - which is enough to feed a farm + * false telemetry and to receive the commands meant for its printers. + * - It was reached with `??`, which only catches null and undefined. The + * committed `.env.example` ships `EDGE_JWT_SECRET=` *empty*, so a + * deployment that copies the template and fills in everything else got an + * empty-string secret and no fallback at all - the one case the fallback + * was there to cover, missed. + * + * There is no development default on purpose. A secret that works out of the + * box is a secret that reaches production: `openssl rand -hex 32` into + * `.env` is a one-time cost, and unlike `SUPABASE_URL` there is nothing to + * look up to get it. Tests that need one set it before importing this. + */ +const secret = process.env.EDGE_JWT_SECRET; +if (!secret) { + throw new Error( + "EDGE_JWT_SECRET is not set - it signs the edge JWTs that /ws/edge/v1 trusts, " + + "so there is no safe default. Generate one with: openssl rand -hex 32", + ); +} + +export const edgeJwtSecret = secret; diff --git a/src/routes/auth.ts b/src/routes/auth.ts index 76cb90c..808ccbf 100644 --- a/src/routes/auth.ts +++ b/src/routes/auth.ts @@ -5,6 +5,7 @@ import { UnauthorizedError } from "@continuum/ts-core"; import { db } from "../db"; import { farms } from "../db/schema"; import { listMemberships, requireUser } from "../auth"; +import { edgeJwtSecret } from "../auth/edge_secret"; const EDGE_TOKEN_TTL_SECONDS = 3600; @@ -31,7 +32,7 @@ export const authRoutes = new Elysia({ prefix: "/auth" }) .use( jwt({ name: "edgeJwt", - secret: process.env.EDGE_JWT_SECRET ?? "dev-edge-secret-change-me", + secret: edgeJwtSecret, }), ) .post( diff --git a/src/routes/ws/edge.ts b/src/routes/ws/edge.ts index 21e8a25..c9111e7 100644 --- a/src/routes/ws/edge.ts +++ b/src/routes/ws/edge.ts @@ -10,6 +10,7 @@ import { import { db } from "../../db"; import { printers } from "../../db/schema"; import { enqueueFrameIngestionBatch } from "../../queue"; +import { edgeJwtSecret } from "../../auth/edge_secret"; /** * Edge-gateway uplink. A continuum-proxy gateway connects here with a @@ -217,7 +218,7 @@ export function isGatewayOnline(farmId: string): boolean { } export const edgeWsRoute = new Elysia() - .use(jwt({ name: "edgeJwt", secret: process.env.EDGE_JWT_SECRET ?? "dev-edge-secret-change-me" })) + .use(jwt({ name: "edgeJwt", secret: edgeJwtSecret })) .ws("/ws/edge/v1", { body: EdgeClientMessageSchema, diff --git a/tests/authz.test.ts b/tests/authz.test.ts index 7651397..011cef2 100644 --- a/tests/authz.test.ts +++ b/tests/authz.test.ts @@ -130,4 +130,43 @@ describe("configuration", () => { expect(await proc.exited).not.toBe(0); expect(await new Response(proc.stderr).text()).toContain("SUPABASE_URL"); }); + + // An empty value, not an unset one, because that is what the committed + // `.env.example` ships: the old `process.env.EDGE_JWT_SECRET ?? "dev-..."` + // only caught null and undefined, so copying the template and filling in + // everything else left the edge JWTs signed with "". Anything that knew + // that - or the committed fallback literal - could mint a token for any + // farm_id and open /ws/edge/v1 as that farm's gateway. + test("the process refuses to start with an empty EDGE_JWT_SECRET", async () => { + const proc = Bun.spawn(["bun", "-e", "await import('./src/auth/edge_secret.ts')"], { + cwd: import.meta.dir + "/..", + env: { ...process.env, EDGE_JWT_SECRET: "" }, + stderr: "pipe", + stdout: "pipe", + }); + expect(await proc.exited).not.toBe(0); + expect(await new Response(proc.stderr).text()).toContain("EDGE_JWT_SECRET"); + }); + + test("no route holds a hardcoded signing secret", async () => { + // The old fallback was duplicated across two routes, and a third copy + // would be just as exploitable as the first two, so this greps the tree + // rather than the files that happened to have it. Two shapes, because + // either one reintroduces the hole: a literal passed to `jwt()`, and a + // defaulted read of the variable (`?? "..."` / `|| "..."`), which is + // what let an empty `EDGE_JWT_SECRET` through. + const grep = async (pattern: string) => { + const proc = Bun.spawn(["grep", "-rnE", pattern, "src"], { + cwd: import.meta.dir + "/..", + stdout: "pipe", + }); + return new Response(proc.stdout).text(); + }; + expect(await grep('secret:\\s*["\'`]')).toBe(""); + expect(await grep('EDGE_JWT_SECRET\\s*(\\?\\?|\\|\\|)')).toBe(""); + // And the variable is read in exactly one place, so there is only ever + // one line to get right. + expect(await grep("process\\.env\\.EDGE_JWT_SECRET")).toContain("src/auth/edge_secret.ts"); + expect((await grep("process\\.env\\.EDGE_JWT_SECRET")).trim().split("\n")).toHaveLength(1); + }); });