[object Object]

← back to Letsbegin

HMAC-sign the local letsbegin_session cookie (close forgeable-session bypass)

db94bc1a69d89399456f5ea3def89aac351ef8ca · 2026-09-22 11:46:44 -0700 · Steve Abrams

Same class as TK-11776 #4/#1: the local session was an UNSIGNED base64(JSON) cookie that
middleware.ts and /api/auth/session decoded and trusted on `authenticated` alone, so
btoa('{"authenticated":true,"loginTime":<now>}') was a valid session on every gated route.

New lib/session-token.ts signs the JSON payload with HMAC-SHA256 under DW_SESSION_SECRET
(envelope <payloadB64>.<sigB64>, constant-time crypto.subtle.verify, fails closed on unset
secret). login mints a signed token; middleware + /api/auth/session now VERIFY the signature
before trusting `authenticated` (+ the existing 30-day age check). Kept separate from the
lockstep dw-central-session.ts (that is the DW Central username:timestamp/24h contract; this
is the local letsbegin_session JSON/30-day session). +7-assertion negative test.

NOTE: existing logged-in users are bounced to /login once (old cookies now rejected), and
DW_SESSION_SECRET must be set in Letsbegin's env or login 500s / middleware fails closed.

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

Files touched

Diff

commit db94bc1a69d89399456f5ea3def89aac351ef8ca
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue Sep 22 11:46:44 2026 -0700

    HMAC-sign the local letsbegin_session cookie (close forgeable-session bypass)
    
    Same class as TK-11776 #4/#1: the local session was an UNSIGNED base64(JSON) cookie that
    middleware.ts and /api/auth/session decoded and trusted on `authenticated` alone, so
    btoa('{"authenticated":true,"loginTime":<now>}') was a valid session on every gated route.
    
    New lib/session-token.ts signs the JSON payload with HMAC-SHA256 under DW_SESSION_SECRET
    (envelope <payloadB64>.<sigB64>, constant-time crypto.subtle.verify, fails closed on unset
    secret). login mints a signed token; middleware + /api/auth/session now VERIFY the signature
    before trusting `authenticated` (+ the existing 30-day age check). Kept separate from the
    lockstep dw-central-session.ts (that is the DW Central username:timestamp/24h contract; this
    is the local letsbegin_session JSON/30-day session). +7-assertion negative test.
    
    NOTE: existing logged-in users are bounced to /login once (old cookies now rejected), and
    DW_SESSION_SECRET must be set in Letsbegin's env or login 500s / middleware fails closed.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01DwQJGSbGmK5rDzRsCqvVEc
---
 __tests__/session-token.test.mjs |  68 ++++++++++++++++++++++++
 app/api/auth/login/route.ts      |   6 ++-
 app/api/auth/session/route.ts    |  26 +++++-----
 lib/session-token.ts             | 109 +++++++++++++++++++++++++++++++++++++++
 middleware.ts                    |  39 +++++++-------
 5 files changed, 215 insertions(+), 33 deletions(-)

diff --git a/__tests__/session-token.test.mjs b/__tests__/session-token.test.mjs
new file mode 100644
index 0000000..2ace2a0
--- /dev/null
+++ b/__tests__/session-token.test.mjs
@@ -0,0 +1,68 @@
+/**
+ * session-token.test.mjs — PROOF for the TK-11776 follow-up (Letsbegin local session).
+ *
+ * The local `letsbegin_session` cookie used to be an UNSIGNED base64(JSON) blob that
+ * middleware.ts + /api/auth/session trusted on its `authenticated` flag alone, so
+ * `btoa('{"authenticated":true,"loginTime":<now>}')` was a valid session. This proves the
+ * new HMAC signer REJECTS that forgery, ACCEPTS a validly-signed session, and fails closed
+ * on wrong-secret / tampered / unset-secret. Runnable with plain
+ * `node __tests__/session-token.test.mjs` (Node 23.6+/26 imports the .ts lib directly).
+ */
+import assert from 'node:assert/strict';
+import { signSession, verifySession } from '../lib/session-token.ts';
+
+const FIXTURE = 'hmac-test-fixture';
+// the exact live forgery: an unsigned base64(JSON) cookie claiming authenticated:true
+const forge = (extra = {}) =>
+  Buffer.from(JSON.stringify({ authenticated: true, loginTime: Date.now(), ...extra })).toString('base64');
+
+// the vulnerable OLD logic, verbatim — used only to prove this test is a real detector
+function oldVerify(token) {
+  try {
+    const d = JSON.parse(Buffer.from(token, 'base64').toString());
+    return d.authenticated === true;
+  } catch {
+    return false;
+  }
+}
+
+let pass = 0;
+const check = async (name, fn) => { await fn(); pass++; console.log(`  ✓ ${name}`); };
+
+console.log('TK-11776 follow-up — forgeable letsbegin_session is closed');
+
+await check('detector sanity: OLD logic ACCEPTS the forged authenticated cookie', async () => {
+  assert.equal(oldVerify(forge()), true);
+});
+await check('REJECTS the forged unsigned base64(JSON) cookie (the live exploit) -> null', async () => {
+  assert.equal(await verifySession(forge(), FIXTURE), null);
+});
+await check('ACCEPTS a validly-signed session and returns its fields', async () => {
+  const token = await signSession({ authenticated: true, username: 'steve', loginTime: Date.now() }, FIXTURE);
+  const s = await verifySession(token, FIXTURE);
+  assert.ok(s && s.authenticated === true, 'authenticated should be true');
+  assert.equal(s.username, 'steve');
+  assert.equal(typeof s.loginTime, 'number');
+});
+await check('REJECTS a valid token under the WRONG secret -> null', async () => {
+  const token = await signSession({ authenticated: true, loginTime: Date.now() }, FIXTURE);
+  assert.equal(await verifySession(token, 'a-different-secret'), null);
+});
+await check('REJECTS a tampered payload with a stolen signature -> null', async () => {
+  const good = await signSession({ authenticated: false, loginTime: Date.now() }, FIXTURE);
+  const sig = good.slice(good.lastIndexOf('.'));
+  const tampered = `${Buffer.from(JSON.stringify({ authenticated: true, loginTime: Date.now() })).toString('base64')}${sig}`;
+  assert.equal(await verifySession(tampered, FIXTURE), null);
+});
+await check('FAILS CLOSED when the secret is unset -> verify null, sign throws', async () => {
+  const token = await signSession({ authenticated: true, loginTime: Date.now() }, FIXTURE);
+  assert.equal(await verifySession(token, undefined), null);
+  await assert.rejects(() => signSession({ authenticated: true }, undefined));
+});
+await check('REJECTS junk / signatureless / empty tokens -> null', async () => {
+  for (const t of ['', 'x', 'not.base64.parts', forge(), null, undefined]) {
+    assert.equal(await verifySession(t, FIXTURE), null);
+  }
+});
+
+console.log(`\n${pass} passed`);
diff --git a/app/api/auth/login/route.ts b/app/api/auth/login/route.ts
index 41e4d3e..47e8264 100644
--- a/app/api/auth/login/route.ts
+++ b/app/api/auth/login/route.ts
@@ -1,6 +1,7 @@
 import { NextRequest, NextResponse } from 'next/server'
 import { validateCredentials, AUTH_CONFIG } from '@/lib/auth'
 import { cookies } from 'next/headers'
+import { signSession } from '@/lib/session-token'
 
 export async function POST(request: NextRequest) {
   try {
@@ -51,7 +52,10 @@ export async function POST(request: NextRequest) {
       username,
       loginTime: Date.now()
     }
-    const sessionToken = Buffer.from(JSON.stringify(sessionData)).toString('base64')
+    // HMAC-sign the session token (TK-11776 follow-up): middleware + /api/auth/session now
+    // VERIFY the signature, so an unsigned base64(JSON) cookie is no longer accepted. Throws if
+    // DW_SESSION_SECRET is unset → the outer catch returns 500 (never mint an unsigned session).
+    const sessionToken = await signSession(sessionData, process.env.DW_SESSION_SECRET)
 
     // Set cookie
     const cookieStore = await cookies()
diff --git a/app/api/auth/session/route.ts b/app/api/auth/session/route.ts
index 3083c5d..0f40e86 100644
--- a/app/api/auth/session/route.ts
+++ b/app/api/auth/session/route.ts
@@ -1,6 +1,7 @@
 import { NextRequest, NextResponse } from 'next/server'
 import { AUTH_CONFIG, isWhitelistedIP } from '@/lib/auth'
 import { cookies } from 'next/headers'
+import { verifySession } from '@/lib/session-token'
 
 export async function GET(request: NextRequest) {
   try {
@@ -28,18 +29,19 @@ export async function GET(request: NextRequest) {
       return NextResponse.json({ authenticated: false }, { status: 401 })
     }
 
-    try {
-      const sessionData = JSON.parse(Buffer.from(sessionCookie.value, 'base64').toString())
-
-      if (sessionData.authenticated) {
-        return NextResponse.json({
-          authenticated: true,
-          username: sessionData.username,
-          loginTime: sessionData.loginTime
-        })
-      }
-    } catch {
-      // Invalid session format
+    // HMAC-VERIFY the session token (TK-11776 follow-up), not JSON-decode-and-trust — otherwise
+    // this endpoint would report a forged cookie as authenticated.
+    const sessionData = await verifySession<{ authenticated?: boolean; username?: string; loginTime?: number }>(
+      sessionCookie.value,
+      process.env.DW_SESSION_SECRET,
+    )
+
+    if (sessionData?.authenticated) {
+      return NextResponse.json({
+        authenticated: true,
+        username: sessionData.username,
+        loginTime: sessionData.loginTime
+      })
     }
 
     return NextResponse.json({ authenticated: false }, { status: 401 })
diff --git a/lib/session-token.ts b/lib/session-token.ts
new file mode 100644
index 0000000..0a5cec0
--- /dev/null
+++ b/lib/session-token.ts
@@ -0,0 +1,109 @@
+/**
+ * session-token.ts — the HMAC-signed local ("letsbegin_session") session cookie.
+ *
+ * SECURITY (TK-11776 follow-up): the previous local session was an UNSIGNED
+ * base64(JSON.stringify({ authenticated: true, ... })) cookie that middleware.ts and
+ * /api/auth/session decoded and TRUSTED on the `authenticated` flag alone — so
+ * `btoa('{"authenticated":true,"loginTime":<now>}')` was a valid session on every gated
+ * route, with no signature (the same forgeable class TK-11776 #4 closed for the DW Central
+ * cookie). This signs the payload with HMAC-SHA256 under DW_SESSION_SECRET so only a
+ * secret-holder can mint one.
+ *
+ * Envelope:  token = <payloadB64> + "." + <sigB64>
+ *   payload  = utf8(JSON.stringify(state))          // the session object, unchanged
+ *   sig      = HMAC-SHA256( ascii(payloadB64), DW_SESSION_SECRET )
+ * Standard base64 has no ".", so lastIndexOf('.') splits cleanly and every legacy unsigned
+ * base64(JSON) cookie (which has no ".") is REJECTED.
+ *
+ * Runtime-agnostic (Web Crypto + btoa/atob): works in the Edge middleware runtime, in
+ * Node 18+ (the route handlers), and in jest's node env. crypto.subtle.verify is itself
+ * constant-time. Fails CLOSED: sign() throws on a missing secret (never mint an unsigned
+ * token); verify() returns null on a missing secret, a missing/bad signature, or any decode
+ * error. This intentionally does NOT reuse dw-central-session.ts — that module is the
+ * lockstep-copied DW Central `username:timestamp` / 24h contract; this is the local
+ * `letsbegin_session` JSON / 30-day session, a separate concern.
+ */
+
+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 to satisfy crypto.subtle's typing across
+// tsconfigs (same shim as dw-central-session.ts).
+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'],
+  );
+}
+
+/**
+ * Sign a session state object into a tamper-evident token. Throws if the secret is unset —
+ * an unsigned session token must never be minted.
+ */
+export async function signSession(
+  state: Record<string, unknown>,
+  secret: string | undefined,
+): Promise<string> {
+  if (!secret) throw new Error('DW_SESSION_SECRET is required to sign a session');
+  const payloadB64 = utf8ToBase64(JSON.stringify(state));
+  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 session token and return its decoded state, or null when the signature is
+ * invalid / secret unset / token malformed. Callers still enforce their own semantics
+ * (e.g. `authenticated === true`, age window) on the returned object.
+ */
+export async function verifySession<T = Record<string, unknown>>(
+  token: string | undefined | null,
+  secret: string | undefined,
+): Promise<T | null> {
+  try {
+    if (!secret) return null; // fail closed — never accept-all
+    if (!token || typeof token !== 'string') return null;
+
+    const dot = token.lastIndexOf('.');
+    if (dot <= 0 || dot >= token.length - 1) return null; // rejects legacy unsigned base64(JSON) cookies
+    const payloadB64 = token.slice(0, dot);
+    const sigB64 = token.slice(dot + 1);
+    if (!payloadB64 || !sigB64) return null;
+
+    const key = await hmacKey(secret);
+    const ok = await crypto.subtle.verify( // constant-time HMAC verification
+      'HMAC',
+      key,
+      bs(base64ToBytes(sigB64)),
+      bs(new TextEncoder().encode(payloadB64)),
+    );
+    if (!ok) return null;
+
+    return JSON.parse(base64ToUtf8(payloadB64)) as T;
+  } catch {
+    return null;
+  }
+}
diff --git a/middleware.ts b/middleware.ts
index fd06335..6d17cc5 100644
--- a/middleware.ts
+++ b/middleware.ts
@@ -2,6 +2,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'
+import { verifySession } from '@/lib/session-token'
 
 // Routes that don't require authentication
 const publicPaths = [
@@ -53,31 +54,29 @@ export async function middleware(request: NextRequest) {
     return NextResponse.redirect(loginUrl)
   }
 
-  // Validate session token
-  try {
-    const sessionData = JSON.parse(Buffer.from(sessionCookie.value, 'base64').toString())
+  // Validate session token — HMAC-VERIFIED (TK-11776 follow-up), not JSON-decode-and-trust.
+  // Previously any base64(JSON) with authenticated:true passed; verifySession rejects every
+  // unsigned/forged cookie (returns null), so only a token minted by /api/auth/login under
+  // DW_SESSION_SECRET is honored.
+  const sessionData = await verifySession<{ authenticated?: boolean; loginTime?: number }>(
+    sessionCookie.value,
+    process.env.DW_SESSION_SECRET,
+  )
 
-    if (!sessionData.authenticated) {
-      // Invalid session - redirect to login
-      const loginUrl = new URL('/login', request.url)
-      return NextResponse.redirect(loginUrl)
-    }
-
-    // Check session age (30 days max)
-    const sessionAge = Date.now() - sessionData.loginTime
-    if (sessionAge > AUTH_CONFIG.cookieMaxAge) {
-      // Session expired - redirect to login
-      const loginUrl = new URL('/login', request.url)
-      return NextResponse.redirect(loginUrl)
-    }
+  if (!sessionData || !sessionData.authenticated) {
+    const loginUrl = new URL('/login', request.url)
+    loginUrl.searchParams.set('redirect', pathname)
+    return NextResponse.redirect(loginUrl)
+  }
 
-    // Valid session - proceed
-    return NextResponse.next()
-  } catch (error) {
-    // Invalid session data - redirect to login
+  // Check session age (30 days max) — a non-numeric/absent loginTime is treated as expired.
+  if (typeof sessionData.loginTime !== 'number' || Date.now() - sessionData.loginTime > AUTH_CONFIG.cookieMaxAge) {
     const loginUrl = new URL('/login', request.url)
     return NextResponse.redirect(loginUrl)
   }
+
+  // Valid, signed, in-window session - proceed
+  return NextResponse.next()
 }
 
 export const config = {

← 9761423 security(TK-11776 #4): extend HMAC DW Central session fix to  ·  back to Letsbegin  ·  (newest)