// panel-auth — credential hashing helpers (pure functions, no harness deps). import { createHmac, randomBytes, scryptSync, timingSafeEqual } from 'node:crypto' /** Self-describing scrypt encoding: $scrypt$N=16384,r=8,p=1$$ */ export function hashPassword(password, { N = 16384, r = 8, p = 1, keylen = 32 } = {}) { const salt = randomBytes(16) const hash = scryptSync(String(password), salt, keylen, { N, r, p }) return `$scrypt$N=${N},r=${r},p=${p}$${salt.toString('base64')}$${hash.toString('base64')}` } /** Constant-time verification against a hashPassword() encoding. */ export function verifyPassword(password, encoded) { if (typeof encoded !== 'string' || typeof password !== 'string') return false const parts = encoded.split('$') if (parts.length !== 5 || parts[1] !== 'scrypt') return false const params = {} for (const kv of parts[2].split(',')) { const [k, v] = kv.split('=') params[k] = Number(v) } if (!params.N || !params.r || !params.p) return false try { const expected = Buffer.from(parts[4], 'base64') const actual = scryptSync(password, Buffer.from(parts[3], 'base64'), expected.length, { N: params.N, r: params.r, p: params.p, }) return expected.length === actual.length && timingSafeEqual(actual, expected) } catch { return false } } /** HMAC-signed cookie payload: base64url(username).expirySeconds.signature */ export function signSession(secret, username, expirySeconds) { const payload = `${Buffer.from(username, 'utf8').toString('base64url')}.${expirySeconds}` const sig = createHmac('sha256', secret).update(payload).digest('base64url') return `${payload}.${sig}` } /** Returns the username when the cookie is intact and unexpired, else null. */ export function verifySession(secret, cookieValue, nowSeconds) { if (typeof cookieValue !== 'string') return null const dot = cookieValue.lastIndexOf('.') if (dot <= 0) return null const payload = cookieValue.slice(0, dot) const sig = cookieValue.slice(dot + 1) const expected = createHmac('sha256', secret).update(payload).digest('base64url') if (sig.length !== expected.length || !timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) return null const sep = payload.lastIndexOf('.') if (sep <= 0) return null const username = Buffer.from(payload.slice(0, sep), 'base64url').toString('utf8') const expiry = Number(payload.slice(sep + 1)) if (!Number.isFinite(expiry) || expiry <= nowSeconds) return null return username }