[object Object]

← back to Norma Sdcc Pitch

fix(auth): replace plain-text password compare with scrypt + timingSafeEqual

88191f52491ad564bace8a134b19e2fb95ff49ed · 2026-05-21 18:59:17 -0700 · SteveStudio2

Was:
  if (LOGINS[u] && password === LOGINS[u])  ← plaintext compare, timing-leak

Now:
  scrypt-hash env-var plaintexts at module load (N=16384, keylen=64,
  random per-user salt). Compare submitted password via scrypt + the same
  salt, then crypto.timingSafeEqual against the stored hash. Plaintexts
  are scrubbed from process.env immediately after hashing so they don't
  sit in /proc/<pid>/environ for the life of the process.

No new deps — uses node built-in crypto.scryptSync. Defaults
(Pulse-2026 / DWSecure2024!) preserved for dev convenience; a stderr
warning fires when NODE_ENV=production and the env var is unset.

Verified live: 3 wrong-pw → 302 each, right-pw → 302 + Set-Cookie,
process.env no longer contains SDCC_PASSWORD/STEVE_PASSWORD.

Files touched

Diff

commit 88191f52491ad564bace8a134b19e2fb95ff49ed
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Thu May 21 18:59:17 2026 -0700

    fix(auth): replace plain-text password compare with scrypt + timingSafeEqual
    
    Was:
      if (LOGINS[u] && password === LOGINS[u])  ← plaintext compare, timing-leak
    
    Now:
      scrypt-hash env-var plaintexts at module load (N=16384, keylen=64,
      random per-user salt). Compare submitted password via scrypt + the same
      salt, then crypto.timingSafeEqual against the stored hash. Plaintexts
      are scrubbed from process.env immediately after hashing so they don't
      sit in /proc/<pid>/environ for the life of the process.
    
    No new deps — uses node built-in crypto.scryptSync. Defaults
    (Pulse-2026 / DWSecure2024!) preserved for dev convenience; a stderr
    warning fires when NODE_ENV=production and the env var is unset.
    
    Verified live: 3 wrong-pw → 302 each, right-pw → 302 + Set-Cookie,
    process.env no longer contains SDCC_PASSWORD/STEVE_PASSWORD.
---
 server.js | 50 +++++++++++++++++++++++++++++++++++++++++++-------
 1 file changed, 43 insertions(+), 7 deletions(-)

diff --git a/server.js b/server.js
index fdc2416..1efd3b5 100644
--- a/server.js
+++ b/server.js
@@ -26,6 +26,7 @@
 const express = require('express');
 const fs = require('fs');
 const path = require('path');
+const crypto = require('crypto');
 const { spawn } = require('child_process');
 
 const PORT = parseInt(process.env.PORT || '9876', 10);
@@ -33,11 +34,46 @@ const ROOT = __dirname;
 const PITCH_DIR = path.join(ROOT, 'data', 'pitch-state');
 const VIDEO_DIR = path.join(ROOT, 'videos');
 const NORMA_BASE = process.env.NORMA_BASE || 'http://localhost:7400';
-// Login gate (one credential pair per role)
-const LOGINS = {
-  sdcc: process.env.SDCC_PASSWORD || 'Pulse-2026',
-  steve: process.env.STEVE_PASSWORD || 'DWSecure2024!',
-};
+// ── Login gate ─────────────────────────────────────────────────────────────
+// Env-var plaintexts are scrypt-hashed at module load. The plaintexts are
+// cleared from process.env immediately after hashing so they only exist in
+// memory for the duration of the hash call. Login comparison uses scrypt +
+// crypto.timingSafeEqual (constant-time) — no plaintext compare on any
+// request path. Defaults are kept for dev convenience but trigger a stderr
+// warning in production. `crypto` is already required at the top of this file.
+const SCRYPT_N = 16384;       // 2^14 — fast enough for login, slow enough vs brute force
+const SCRYPT_KEYLEN = 64;
+function deriveLoginHash(plaintext, salt) {
+  return crypto.scryptSync(plaintext, salt, SCRYPT_KEYLEN, { N: SCRYPT_N });
+}
+
+const LOGIN_RECORDS = (() => {
+  const out = {};
+  const seed = {
+    sdcc:  { env: 'SDCC_PASSWORD',  default: 'Pulse-2026'    },
+    steve: { env: 'STEVE_PASSWORD', default: 'DWSecure2024!' },
+  };
+  for (const [user, { env, default: dflt }] of Object.entries(seed)) {
+    const pt = process.env[env] || dflt;
+    if (!process.env[env] && process.env.NODE_ENV === 'production') {
+      console.warn(`[auth] WARNING: ${env} not set in env; using hard-coded default for "${user}"`);
+    }
+    const salt = crypto.randomBytes(16);
+    out[user] = { salt, hash: deriveLoginHash(pt, salt) };
+    // Best-effort scrub: blank out the env var so the plaintext doesn't sit
+    // in process.env for the life of the process.
+    delete process.env[env];
+  }
+  return out;
+})();
+
+function verifyLogin(user, plaintext) {
+  const rec = LOGIN_RECORDS[user];
+  if (!rec) return false;
+  const candidate = deriveLoginHash(String(plaintext), rec.salt);
+  // Constant-time compare. Lengths are always equal by construction (both 64).
+  return crypto.timingSafeEqual(candidate, rec.hash);
+}
 
 // ── Login brute-force protection ───────────────────────────────────────────
 // Per-IP fixed-window rate limit on POST /login. 10 fails per 15 min → 30 min
@@ -95,7 +131,7 @@ setInterval(() => {
 }, 5 * 60 * 1000).unref();
 const COOKIE_SECRET = process.env.COOKIE_SECRET || 'norma-sdcc-pitch-dev-secret-change-in-prod';
 const COOKIE_NAME = 'sdcc-pitch-auth';
-const crypto = require('crypto');
+// crypto is required at the top of this file.
 
 function sign(value) {
   return value + '.' + crypto.createHmac('sha256', COOKIE_SECRET).update(value).digest('hex');
@@ -204,7 +240,7 @@ app.post('/login', (req, res) => {
 
   const { username = '', password = '', next = '/' } = req.body || {};
   const u = String(username).toLowerCase().trim();
-  if (LOGINS[u] && password === LOGINS[u]) {
+  if (verifyLogin(u, password)) {
     clearLoginCounter(ip);
     const value = u + '|' + Date.now();
     res.set('Set-Cookie', `${COOKIE_NAME}=${encodeURIComponent(sign(value))}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${30 * 86400}`);

← 418a7c6 feat(auth): rate-limit POST /login on norma-sdcc-pitch (10/1  ·  back to Norma Sdcc Pitch  ·  harden(auth): refuse to boot pitch deck in NODE_ENV=producti 6be4a5a →