Files
ZachariahSharma 74e276af92 fix: bind published ports to loopback and fix runHermes hang
- Ports now bind to HERMES_PUBLISHED_BIND_IP (default 127.0.0.1) so
  NPM on the same host proxies to 127.0.0.1:7843/8645/8646 and direct
  LAN/internet access is blocked without firewall rules
- runHermes: settle promise immediately on timeout (SIGKILL) instead of
  waiting for close event — prevents hanging when hermes spawns children
  that keep stdout/stderr open after the parent is killed
- Add HERMES_ADMIN_COOKIE_SECURE env var to set Secure flag on admin
  session cookie when the admin UI is served over HTTPS
- Document NPM deployment shapes in README

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 21:01:10 -06:00

126 lines
3.4 KiB
JavaScript

"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<string, string>}
*/
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, options = {}) {
const secure = options.secure ? "; Secure" : ""
return `hermes_admin=${plaintext}; HttpOnly; Path=/; SameSite=Lax; Max-Age=${ttlSeconds}${secure}`
}
/**
* Return a Set-Cookie header value that expires/clears the admin cookie.
* @returns {string}
*/
function clearAdminCookie(options = {}) {
const secure = options.secure ? "; Secure" : ""
return `hermes_admin=; HttpOnly; Path=/; SameSite=Lax; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT${secure}`
}
module.exports = {
adminCredentialsMatch,
createSessionToken,
createApiKey,
hashSecret,
parseCookies,
serializeAdminCookie,
clearAdminCookie,
}