[object Object]

← back to Letsbegin

security(TK-11776 #4): extend HMAC DW Central session fix to Letsbegin

976142356fb6116a5818cf644b274795e6bbcd5d · 2026-09-20 10:56:42 -0700 · Steve Abrams

Letsbegin/middleware.ts carried the identical forgeable verifyDWCentralSession
(base64 user:ts, no signature). Now imports the shared @/lib/dw-central-session (byte-
identical to the other two apps) and awaits verifyDWCentralSession(token,
DW_SESSION_SECRET). middleware() made async for the await. No legacy-unsigned accept
path; fails closed when secret unset.

- lib/dw-central-session.ts: shared HMAC signer+verifier (Web Crypto, Next 16 Edge-safe).
- __tests__/dw-central-session.test.mjs: node-run negative proof, 8/8 (forged REJECTED,
  signed ACCEPTED, expired REJECTED, wrong-secret/tampered/no-secret rejected;
  detector-sanity: OLD logic ACCEPTS the forgery).

Deploy (pm2 letsbegin :7300) + secret provisioning stay Steve-gated (go-live memo appended).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A3PFQP7b2cFzSLAN6gB4ZB

Files touched

Diff

commit 976142356fb6116a5818cf644b274795e6bbcd5d
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sun Sep 20 10:56:42 2026 -0700

    security(TK-11776 #4): extend HMAC DW Central session fix to Letsbegin
    
    Letsbegin/middleware.ts carried the identical forgeable verifyDWCentralSession
    (base64 user:ts, no signature). Now imports the shared @/lib/dw-central-session (byte-
    identical to the other two apps) and awaits verifyDWCentralSession(token,
    DW_SESSION_SECRET). middleware() made async for the await. No legacy-unsigned accept
    path; fails closed when secret unset.
    
    - lib/dw-central-session.ts: shared HMAC signer+verifier (Web Crypto, Next 16 Edge-safe).
    - __tests__/dw-central-session.test.mjs: node-run negative proof, 8/8 (forged REJECTED,
      signed ACCEPTED, expired REJECTED, wrong-secret/tampered/no-secret rejected;
      detector-sanity: OLD logic ACCEPTS the forgery).
    
    Deploy (pm2 letsbegin :7300) + secret provisioning stay Steve-gated (go-live memo appended).
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01A3PFQP7b2cFzSLAN6gB4ZB
---
 __tests__/dw-central-session.test.mjs |  64 +++++++++++++++++
 lib/dw-central-session.ts             | 131 ++++++++++++++++++++++++++++++++++
 middleware.ts                         |  26 ++-----
 3 files changed, 202 insertions(+), 19 deletions(-)

diff --git a/__tests__/dw-central-session.test.mjs b/__tests__/dw-central-session.test.mjs
new file mode 100644
index 0000000..2651f8e
--- /dev/null
+++ b/__tests__/dw-central-session.test.mjs
@@ -0,0 +1,64 @@
+/**
+ * dw-central-session.test.mjs — PROOF for TK-11776 finding #4 (Letsbegin).
+ *
+ * This app has no jest runner, so this is a self-contained negative test runnable with
+ * plain `node __tests__/dw-central-session.test.mjs` (Node 18+; Node 23.6+/26 imports the
+ * .ts lib directly via type-stripping). It injects the exact forged cookie the live
+ * exploit used and proves it is now REJECTED, a validly-signed cookie ACCEPTED, an
+ * expired one REJECTED — plus wrong-secret / tampered / no-secret rejections. It also
+ * proves the test is a real detector by showing the OLD verifier logic ACCEPTS the forgery.
+ */
+import assert from 'node:assert/strict';
+import { signDWCentralSession, verifyDWCentralSession } from '../lib/dw-central-session.ts';
+
+const FIXTURE = 'hmac-test-fixture';
+const forge = (u = 'admin', now = Date.now()) => Buffer.from(`${u}:${now}`).toString('base64');
+
+// the vulnerable OLD verifier, verbatim — used only to prove this test is a real detector
+function oldVerify(token) {
+  try {
+    const d = Buffer.from(token, 'base64').toString('utf-8');
+    const [u, t] = d.split(':');
+    if (u && t) return Date.now() - parseInt(t, 10) < 86_400_000;
+    return false;
+  } catch { return false; }
+}
+
+let pass = 0;
+const check = async (name, fn) => { await fn(); pass++; console.log(`  ✓ ${name}`); };
+
+console.log('TK-11776 #4 — forgeable DW Central session is closed (Letsbegin)');
+await check('detector sanity: OLD verifier ACCEPTS the forged admin cookie', async () => {
+  assert.equal(oldVerify(forge('admin')), true);
+});
+await check('REJECTS the forged unsigned cookie (the live exploit)', async () => {
+  assert.equal(await verifyDWCentralSession(forge('admin'), FIXTURE), false);
+});
+await check('ACCEPTS a validly-signed, in-window cookie', async () => {
+  assert.equal(await verifyDWCentralSession(await signDWCentralSession('steve', FIXTURE), FIXTURE), true);
+});
+await check('REJECTS a validly-signed cookie that is EXPIRED (>24h)', async () => {
+  const expired = await signDWCentralSession('steve', FIXTURE, Date.now() - (86_400_000 + 60_000));
+  assert.equal(await verifyDWCentralSession(expired, FIXTURE), false);
+});
+await check('REJECTS a valid token under the WRONG secret', async () => {
+  const good = await signDWCentralSession('steve', FIXTURE);
+  assert.equal(await verifyDWCentralSession(good, 'a-different-secret'), false);
+});
+await check('REJECTS a tampered payload with a stolen signature', async () => {
+  const good = await signDWCentralSession('lowpriv', FIXTURE);
+  const sig = good.slice(good.lastIndexOf('.'));
+  const tampered = `${Buffer.from(`admin:${Date.now()}`).toString('base64')}${sig}`;
+  assert.equal(await verifyDWCentralSession(tampered, FIXTURE), false);
+});
+await check('FAILS CLOSED when DW_SESSION_@@@ is unset', async () => {
+  const good = await signDWCentralSession('steve', FIXTURE);
+  assert.equal(await verifyDWCentralSession(good, undefined), false);
+  assert.equal(await verifyDWCentralSession(forge('admin'), undefined), false);
+});
+await check('REJECTS junk / signature-less tokens', async () => {
+  for (const t of ['', 'not-a-token', '.', 'abc.', '.abc']) {
+    assert.equal(await verifyDWCentralSession(t, FIXTURE), false);
+  }
+});
+console.log(`\n${pass}/8 passed`);
diff --git a/lib/dw-central-session.ts b/lib/dw-central-session.ts
new file mode 100644
index 0000000..a9bf761
--- /dev/null
+++ b/lib/dw-central-session.ts
@@ -0,0 +1,131 @@
+/**
+ * dw-central-session.ts — the ONE shared, signed DW Central SSO cookie contract.
+ *
+ * SECURITY (TK-11776 finding #4): the previous `verifyDWCentralSession` accepted ANY
+ * cookie that base64-decoded to `username:timestamp` within 24h, with NO signature.
+ * So `base64('admin:'+Date.now())` was a valid session on every gated route — a live,
+ * confirmed auth bypass on an endpoint that mints Shopify products. This module closes
+ * it by requiring an HMAC-SHA256 signature that only a holder of DW_SESSION_SECRET can
+ * produce.
+ *
+ * LOCKSTEP: the ISSUER (DW Central @ dw.greendomainbrokers.com) and EVERY verifier
+ * (this app, Letsbegin, room-setting-app) MUST use THIS exact logic with the SAME
+ * DW_SESSION_SECRET, or real logins break. The signer + verifier live together here on
+ * purpose so both sides are provably identical — the issuer copies/imports this file.
+ *
+ * SIGNED TOKEN FORMAT (a strict superset of the old payload, minimal issuer diff):
+ *   token   = <payloadB64> + "." + <sigB64>
+ *   payload = utf8("<username>:<timestampMillis>")   // unchanged from the old format
+ *   sig     = HMAC-SHA256( ascii(payloadB64), DW_SESSION_SECRET )
+ * base64 is standard (its alphabet has no ".", so lastIndexOf('.') splits cleanly).
+ *
+ * There is NO legacy-unsigned acceptance path. A token without a valid signature —
+ * including every old forgeable cookie — is REJECTED. Fails CLOSED when the secret is
+ * unset (rejects; never accept-all).
+ *
+ * Runtime-agnostic on purpose: uses Web Crypto (globalThis.crypto.subtle) + btoa/atob,
+ * which exist in the Next.js Edge runtime (where middleware runs), in Node 18+, and in
+ * jest's node test env. `crypto.subtle.verify('HMAC', ...)` is itself constant-time, so
+ * we do NOT hand-roll a compare (and node:crypto.timingSafeEqual is unavailable on Edge).
+ */
+
+const MAX_AGE_MS = 86_400_000; // 24 hours (unchanged)
+
+// --- base64 helpers (standard alphabet), runtime-agnostic --------------------------
+function bytesToBase64(bytes: Uint8Array): string {
+  let bin = '';
+  for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
+  return btoa(bin);
+}
+function base64ToBytes(b64: string): Uint8Array {
+  const bin = atob(b64);
+  const out = new Uint8Array(bin.length);
+  for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
+  return out;
+}
+function utf8ToBase64(str: string): string {
+  return bytesToBase64(new TextEncoder().encode(str));
+}
+function base64ToUtf8(b64: string): string {
+  return new TextDecoder().decode(base64ToBytes(b64));
+}
+
+// Uint8Array -> BufferSource: a runtime no-op cast. TS 5.7+ types encode()/atob bytes as
+// Uint8Array<ArrayBufferLike>, which some app tsconfigs reject where crypto.subtle wants
+// BufferSource. This keeps the module byte-identical + compiling across all consumers.
+function bs(u: Uint8Array): BufferSource {
+  return u as unknown as BufferSource;
+}
+
+async function hmacKey(secret: string): Promise<CryptoKey> {
+  return crypto.subtle.importKey(
+    'raw',
+    bs(new TextEncoder().encode(secret)),
+    { name: 'HMAC', hash: 'SHA-256' },
+    false,
+    ['sign', 'verify'],
+  );
+}
+
+/**
+ * Issue a signed DW Central session token. Used by DW Central at login (and by tests).
+ * Throws if the secret is missing — an unsigned token must never be minted.
+ */
+export async function signDWCentralSession(
+  username: string,
+  secret: string | undefined,
+  now: number = Date.now(),
+): Promise<string> {
+  if (!secret) throw new Error('DW_SESSION_SECRET is required to sign a DW Central session');
+  if (!username) throw new Error('username is required to sign a DW Central session');
+  const payloadB64 = utf8ToBase64(`${username}:${now}`);
+  const key = await hmacKey(secret);
+  const sig = new Uint8Array(
+    await crypto.subtle.sign('HMAC', key, bs(new TextEncoder().encode(payloadB64))),
+  );
+  return `${payloadB64}.${bytesToBase64(sig)}`;
+}
+
+/**
+ * Verify a DW Central session token. Returns true ONLY when the signature is valid for
+ * the configured secret AND the payload is within the 24h window. Fails CLOSED on a
+ * missing secret, a missing/short signature, a bad signature, or any decode error.
+ */
+export async function verifyDWCentralSession(
+  token: string | undefined | null,
+  secret: string | undefined,
+): Promise<boolean> {
+  try {
+    if (!secret) return false;                     // fail closed — never accept-all
+    if (!token || typeof token !== 'string') return false;
+
+    const dot = token.lastIndexOf('.');
+    if (dot <= 0 || dot >= token.length - 1) return false; // rejects ALL legacy unsigned cookies
+    const payloadB64 = token.slice(0, dot);
+    const sigB64 = token.slice(dot + 1);
+    if (!payloadB64 || !sigB64) return false;
+
+    const key = await hmacKey(secret);
+    const sigBytes = base64ToBytes(sigB64);
+    const ok = await crypto.subtle.verify(     // constant-time HMAC verification
+      'HMAC',
+      key,
+      bs(sigBytes),
+      bs(new TextEncoder().encode(payloadB64)),
+    );
+    if (!ok) return false;
+
+    // Signature is valid → the payload is authentic. Now enforce the 24h age.
+    const decoded = base64ToUtf8(payloadB64);
+    const idx = decoded.indexOf(':');
+    if (idx <= 0) return false;
+    const username = decoded.slice(0, idx);
+    const timestamp = decoded.slice(idx + 1);
+    if (!username || !timestamp) return false;
+    const sessionTime = parseInt(timestamp, 10);
+    if (!Number.isFinite(sessionTime)) return false;
+    return Date.now() - sessionTime < MAX_AGE_MS;
+  } catch {
+    return false;
+  }
+}
diff --git a/middleware.ts b/middleware.ts
index b5053b7..fd06335 100644
--- a/middleware.ts
+++ b/middleware.ts
@@ -1,6 +1,7 @@
 import { NextResponse } from 'next/server'
 import type { NextRequest } from 'next/server'
 import { AUTH_CONFIG } from '@/lib/auth'
+import { verifyDWCentralSession } from '@/lib/dw-central-session'
 
 // Routes that don't require authentication
 const publicPaths = [
@@ -17,25 +18,12 @@ const publicPaths = [
   '/favicon.ico',
 ]
 
-// DW Central SSO: Check if user has a valid DW Central session
-function verifyDWCentralSession(token: string): boolean {
-  try {
-    // DW Central uses base64 encoded "username:timestamp" format
-    const decoded = Buffer.from(token, 'base64').toString('utf-8');
-    const [username, timestamp] = decoded.split(':');
-    if (username && timestamp) {
-      const sessionTime = parseInt(timestamp, 10);
-      const now = Date.now();
-      const maxAge = 86400000; // 24 hours
-      return (now - sessionTime) < maxAge;
-    }
-    return false;
-  } catch {
-    return false;
-  }
-}
+// DW Central SSO cookies are now HMAC-signed (TK-11776 finding #4). The verifier in
+// @/lib/dw-central-session REQUIRES DW_SESSION_SECRET and rejects any unsigned/forged
+// cookie (incl. the legacy base64('user:'+Date.now()) bypass). The DW Central issuer
+// must sign with the SAME secret via that module's signDWCentralSession. Fails closed.
 
-export function middleware(request: NextRequest) {
+export async function middleware(request: NextRequest) {
   const { pathname } = request.nextUrl
 
   // Allow public paths
@@ -45,7 +33,7 @@ export function middleware(request: NextRequest) {
 
   // Check for DW Central SSO session first (from dw.greendomainbrokers.com)
   const dwCentralToken = request.cookies.get('dw_central_session')?.value;
-  if (dwCentralToken && verifyDWCentralSession(dwCentralToken)) {
+  if (dwCentralToken && (await verifyDWCentralSession(dwCentralToken, process.env.DW_SESSION_SECRET))) {
     return NextResponse.next()
   }
 

← 8bf96b1 governance(TK-11370): make live-by-default Shopify writers d  ·  back to Letsbegin  ·  HMAC-sign the local letsbegin_session cookie (close forgeabl db94bc1 →