From e46e80a15c145a346106ef0f2af7e2715c3c2c11 Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Mon, 15 Jun 2026 21:12:35 -0400 Subject: [PATCH] feat(12-06): validate/vapid asserts submitted key matches env public key (gap 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - read app_config.vapid_public_key and compare to process.env.VAPID_PUBLIC_KEY - mismatched/absent submitted key → 400 before the structural check - VAPID_PRIVATE_KEY still env-only, never compared or returned (T-12-06) --- apps/api/src/routes/setup.ts | 32 ++++++++++++++++++++++++++++---- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/apps/api/src/routes/setup.ts b/apps/api/src/routes/setup.ts index 737c597..e6c7b01 100644 --- a/apps/api/src/routes/setup.ts +++ b/apps/api/src/routes/setup.ts @@ -193,10 +193,17 @@ setupRouter.post('/validate/oidc', async (c) => { // --------------------------------------------------------------------------- // POST /api/setup/validate/vapid // -// Validates VAPID key pair structure by calling webpush.setVapidDetails(). -// Reads BOTH keys ONLY from process.env — NEVER from app_config (T-12-06 / D-01 / SC-3). -// VAPID_PRIVATE_KEY is never returned in any response. -// Returns 200 { ok: true } on structural validity, 400 { ok: false } on failure. +// Validates the operator-entered VAPID public key (app_config.vapid_public_key) +// against the configured key pair: +// 1. Equality (gap 2): the submitted public key MUST equal process.env.VAPID_PUBLIC_KEY. +// A wrong/typoed key (e.g. "BH123") now fails the row and gates Continue — push +// would silently break in production otherwise (SETUP-02). +// 2. Structural: webpush.setVapidDetails() validates the env pair's byte structure. +// +// VAPID_PRIVATE_KEY is read ONLY from process.env — NEVER from app_config or returned +// (T-12-06 / D-01 / SC-3). The equality check compares the submitted PUBLIC key to the +// env PUBLIC key only — the private key is never compared or echoed. +// Returns 200 { ok: true } on success, 400 { ok: false } on any failure. // --------------------------------------------------------------------------- setupRouter.post('/validate/vapid', async (c) => { @@ -210,6 +217,23 @@ setupRouter.post('/validate/vapid', async (c) => { return c.json({ ok: false, error: 'VAPID_PRIVATE_KEY and VAPID_PUBLIC_KEY env vars must be set' }, 400); } + // Gap 2: assert the operator-submitted public key matches the env public key BEFORE + // the structural check. Read the submitted key from app_config (same idiom as validate/oidc). + const [submittedRow] = await db + .select({ value: appConfig.value }) + .from(appConfig) + .where(eq(appConfig.key, 'vapid_public_key')) + .limit(1); + + const submittedPublicKey = submittedRow?.value; + if (!submittedPublicKey || submittedPublicKey !== publicKey) { + return c.json({ + ok: false, + error: + 'VAPID public key does not match the configured key pair. Paste the exact VAPID_PUBLIC_KEY printed by `npm run generate-secrets`.', + }, 400); + } + try { // setVapidDetails runs validatePrivateKey (32-byte check) and validatePublicKey (65-byte check) // internally — this is the library's own structural validation (Pattern 7).