"use strict" const crypto = require("crypto") /** * Compare admin credentials in a timing-safe manner. * @param {string} inputUsername * @param {string} inputPassword * @param {{ username: string, password: string }} config * @returns {boolean} */ function adminCredentialsMatch(inputUsername, inputPassword, config) { try { if ( typeof inputUsername !== "string" || typeof inputPassword !== "string" || !config || typeof config.username !== "string" || typeof config.password !== "string" ) { return false } // Username: standard string comparison const usernameMatch = inputUsername === config.username // Password: timing-safe comparison via sha256 hashes (ensures equal buffer lengths) const inputHash = Buffer.from(hashSecret(inputPassword)) const storedHash = Buffer.from(hashSecret(config.password)) const passwordMatch = crypto.timingSafeEqual(inputHash, storedHash) return usernameMatch && passwordMatch } catch (_err) { return false } } /** * Generate a new session token. * @returns {{ plaintext: string, hash: string }} */ function createSessionToken() { const rawBytes = crypto.randomBytes(32) const plaintext = rawBytes.toString("base64url") const hash = hashSecret(plaintext) return { plaintext, hash } } /** * Generate a new API key. * @returns {{ plaintext: string, hash: string, suffix: string }} */ function createApiKey() { // 40 URL-safe base64 chars requires at least 30 random bytes (30 * 4/3 = 40) // Use 32 bytes to ensure we get >= 40 chars after base64url encoding (32 * 4/3 ≈ 43 chars) const rawBytes = crypto.randomBytes(32) const encoded = rawBytes.toString("base64url") const plaintext = "hms_" + encoded const hash = hashSecret(plaintext) const suffix = plaintext.slice(-4) return { plaintext, hash, suffix } } /** * Compute sha256 hex digest of a string value. * @param {string} value * @returns {string} */ function hashSecret(value) { return crypto.createHash("sha256").update(value).digest("hex") } /** * Parse a Cookie header string into a key/value object. * @param {string|null|undefined} cookieHeader * @returns {Record} */ function parseCookies(cookieHeader) { if (!cookieHeader) return {} const result = {} for (const part of cookieHeader.split(";")) { const idx = part.indexOf("=") if (idx === -1) continue const key = part.slice(0, idx).trim() const val = part.slice(idx + 1).trim() if (key) { try { result[key] = decodeURIComponent(val) } catch (_e) { result[key] = val } } } return result } /** * Serialize the admin session cookie as a Set-Cookie header value. * @param {string} plaintext * @param {number} ttlSeconds * @returns {string} */ function serializeAdminCookie(plaintext, ttlSeconds) { return `hermes_admin=${plaintext}; HttpOnly; Path=/; SameSite=Lax; Max-Age=${ttlSeconds}` } /** * Return a Set-Cookie header value that expires/clears the admin cookie. * @returns {string} */ function clearAdminCookie() { return "hermes_admin=; HttpOnly; Path=/; SameSite=Lax; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT" } module.exports = { adminCredentialsMatch, createSessionToken, createApiKey, hashSecret, parseCookies, serializeAdminCookie, clearAdminCookie, }