forked from dsh/panel-auth
58 lines
2.5 KiB
JavaScript
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
|
|
}
|