Files

58 lines
2.5 KiB
JavaScript

// 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$<saltB64>$<hashB64> */
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
}