74e276af92
- 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>
126 lines
3.4 KiB
JavaScript
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,
|
|
}
|