← 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 →