/* The hub's own accounts: roles, password hashes and the users file. `${HUB_DATA}/users.json` holds `{ users: [{ name, role, hash, disabled? }] }`, where hash is `scrypt$N$r$p$saltB64$keyB64`. The pure functions below are what the CLI (users-cli.ts) and the tests call; `Users` is what the running hub asks. */ import { randomBytes, scrypt, scryptSync, timingSafeEqual } from 'node:crypto' import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync, chmodSync, rmSync } from 'node:fs' import path from 'node:path' import type { Bootstrap } from './config.ts' export type Role = 'viewer' | 'operator' | 'apps-admin' | 'admin' export type Capability = 'read' | 'apps.write' | 'operate' | 'audit.read' | 'admin' export const ROLES: readonly Role[] = ['viewer', 'operator', 'apps-admin', 'admin'] /* A simple rank. operator and apps-admin are siblings: use `can` to ask about a capability. */ const RANK: Record = { viewer: 1, operator: 2, 'apps-admin': 2, admin: 3 } const CAPABILITIES: Record = { viewer: ['read'], operator: ['read', 'operate'], 'apps-admin': ['read', 'apps.write'], admin: ['read', 'apps.write', 'operate', 'audit.read', 'admin'], } export const isRole = (value: unknown): value is Role => typeof value === 'string' && (ROLES as readonly string[]).includes(value) export const rank = (role: Role): number => RANK[role] /** True when `role` ranks at least as high as `min`. */ export const requires = (role: Role, min: Role): boolean => RANK[role] >= RANK[min] export const can = (role: Role, capability: Capability): boolean => CAPABILITIES[role].includes(capability) export interface UserRecord { name: string role: Role hash: string disabled?: boolean } /* ---- Passwords ---- */ const N = 16384 const R = 8 const P = 1 const KEYLEN = 64 export function hashPassword(password: string): string { const salt = randomBytes(16) const key = scryptSync(password, salt, KEYLEN, { N, r: R, p: P }) return `scrypt$${N}$${R}$${P}$${salt.toString('base64')}$${key.toString('base64')}` } interface Parsed { n: number r: number p: number salt: Buffer key: Buffer } function parseHash(stored: string): Parsed | null { const parts = stored.split('$') if (parts.length !== 6 || parts[0] !== 'scrypt') return null const [n, r, p] = [Number(parts[1]), Number(parts[2]), Number(parts[3])] if (![n, r, p].every((v) => Number.isInteger(v) && v > 0) || n > 1 << 20 || r > 32 || p > 16) return null const salt = Buffer.from(parts[4], 'base64') const key = Buffer.from(parts[5], 'base64') return salt.length === 0 || key.length === 0 ? null : { n, r, p, salt, key } } export const isHash = (stored: string): boolean => parseHash(stored) !== null function derive(password: string, h: Parsed): Promise { return new Promise((resolve, reject) => { scrypt(password, h.salt, h.key.length, { N: h.n, r: h.r, p: h.p, maxmem: 256 * h.n * h.r }, (err, key) => err ? reject(err) : resolve(key), ) }) } /** Checks a password against a stored hash; false for a malformed hash. Does not block the loop. */ export async function verifyPassword(password: string, stored: string): Promise { const h = parseHash(stored) if (h === null) return false try { return timingSafeEqual(await derive(password, h), h.key) } catch { return false } } /* ---- The file: parse and pure edits ---- */ const NAME = /^[A-Za-z0-9._@-]{1,64}$/ export function validateName(name: string): void { if (!NAME.test(name)) throw new Error('A user name is 1 to 64 letters, digits, . _ @ -.') } const same = (a: string, b: string): boolean => a.toLowerCase() === b.toLowerCase() export function parseUsersFile(text: string): UserRecord[] { let data: unknown try { data = JSON.parse(text) } catch { throw new Error('users.json is not valid JSON.') } const list = (data as { users?: unknown } | null)?.users if (!Array.isArray(list)) throw new Error('users.json must be {"users": [...]}.') const users: UserRecord[] = [] for (const item of list as Record[]) { const name = item?.name if (typeof name !== 'string' || !NAME.test(name)) throw new Error(`users.json: invalid user name ${JSON.stringify(name)}.`) if (!isRole(item.role)) throw new Error(`users.json: user "${name}" has an unknown role.`) if (typeof item.hash !== 'string' || !isHash(item.hash)) throw new Error(`users.json: user "${name}" has no valid hash.`) if (users.some((u) => same(u.name, name))) throw new Error(`users.json lists "${name}" twice.`) const record: UserRecord = { name, role: item.role, hash: item.hash } if (item.disabled === true) record.disabled = true users.push(record) } return users } export const serializeUsers = (users: UserRecord[]): string => JSON.stringify({ users }, null, 2) + '\n' export const findUser = (users: UserRecord[], name: string): UserRecord | undefined => users.find((u) => same(u.name, name)) function must(users: UserRecord[], name: string): UserRecord { const found = findUser(users, name) if (found === undefined) throw new Error(`No user "${name}".`) return found } export function checkPassword(password: string): void { if (password.length < 8) throw new Error('The password must be at least 8 characters.') } function checkRole(role: string): Role { if (!isRole(role)) throw new Error(`The role must be one of: ${ROLES.join(', ')}.`) return role } /* Each edit returns a new list; the caller writes it. */ export function addUser(users: UserRecord[], name: string, role: string, password: string): UserRecord[] { validateName(name) const checked = checkRole(role) checkPassword(password) if (findUser(users, name) !== undefined) throw new Error(`User "${name}" already exists.`) return [...users, { name, role: checked, hash: hashPassword(password) }] } export function setPassword(users: UserRecord[], name: string, password: string): UserRecord[] { checkPassword(password) const target = must(users, name) return users.map((u) => (u === target ? { ...u, hash: hashPassword(password) } : u)) } export function setRole(users: UserRecord[], name: string, role: string): UserRecord[] { const checked = checkRole(role) const target = must(users, name) return users.map((u) => (u === target ? { ...u, role: checked } : u)) } export function setDisabled(users: UserRecord[], name: string, disabled: boolean): UserRecord[] { const target = must(users, name) return users.map((u) => { if (u !== target) return u const { disabled: _old, ...rest } = u return disabled ? { ...rest, disabled: true } : rest }) } /** One line per user, never the hash. */ export const describeUsers = (users: UserRecord[]): string[] => users.map((u) => `${u.name}\t${u.role}${u.disabled === true ? '\tdisabled' : ''}`) /* ---- The file: disk ---- */ export const usersPath = (dir: string): string => path.join(dir, 'users.json') /** The users, or null when the file does not exist. Throws when it exists but is invalid. */ export function readUsersFile(dir: string): UserRecord[] | null { const file = usersPath(dir) if (!existsSync(file)) return null return parseUsersFile(readFileSync(file, 'utf8')) } /** Writes a temp file and renames it over users.json; mode 0600 where the platform honours it. */ export function writeUsersFile(dir: string, users: UserRecord[]): void { mkdirSync(dir, { recursive: true }) const file = usersPath(dir) const temp = `${file}.${process.pid}.tmp` try { writeFileSync(temp, serializeUsers(users), { mode: 0o600 }) try { chmodSync(temp, 0o600) } catch { // Not every platform has modes. } renameSync(temp, file) } catch (e) { rmSync(temp, { force: true }) throw e } } /* ---- The running hub's view ---- */ /* Compared when the role, hash or disabled flag changes, so a session can tell its user was edited. */ export const signature = (u: UserRecord): string => `${u.role}|${u.hash}|${u.disabled === true ? 'x' : ''}` export class Users { #dir: string #users: UserRecord[] #fromFile: boolean #mtime = 0 #warned = false /** A one-line notice for the start-up log, or null. */ readonly notice: string | null /* Checked when a name is unknown, so an unknown name costs the same as a wrong password. */ #dummy = hashPassword(randomBytes(8).toString('hex')) private constructor(dir: string, users: UserRecord[], fromFile: boolean, notice: string | null) { this.#dir = dir this.#users = users this.#fromFile = fromFile this.notice = notice if (fromFile) this.#mtime = this.#stat() } /* users.json when it exists (HUB_USER and HUB_PASSWORD are then ignored); else the one admin from the environment; else an error. The file is never written here. */ static load(dir: string, bootstrap: Bootstrap | null): Users { const fromFile = readUsersFile(dir) if (fromFile !== null) { const notice = bootstrap === null ? null : `${usersPath(dir)} exists: HUB_USER and HUB_PASSWORD are ignored.` return new Users(dir, fromFile, true, notice) } if (bootstrap === null) { throw new Error( `No users: ${usersPath(dir)} does not exist and HUB_USER / HUB_PASSWORD are not set. ` + 'Create a user with: node --experimental-strip-types hub/users-cli.ts add admin', ) } validateName(bootstrap.user) const record: UserRecord = { name: bootstrap.user, role: 'admin', hash: hashPassword(bootstrap.password) } return new Users(dir, [record], false, `No ${usersPath(dir)}: the single admin from HUB_USER / HUB_PASSWORD is in use.`) } #stat(): number { try { return statSync(usersPath(this.#dir)).mtimeMs } catch { return 0 } } /* Cheap enough to run on every request: one stat. A broken file keeps the last good list. */ refresh(): void { if (!this.#fromFile) return const mtime = this.#stat() if (mtime === this.#mtime) return this.#mtime = mtime try { const next = readUsersFile(this.#dir) this.#users = next ?? [] this.#warned = false } catch (e) { if (!this.#warned) console.error(`hub: ${e instanceof Error ? e.message : String(e)} Keeping the previous users.`) this.#warned = true } } find(name: string): UserRecord | undefined { this.refresh() return findUser(this.#users, name) } /** The enabled user when the password is right, else null. Costs the same either way. */ async authenticate(name: string, password: string): Promise { const user = this.find(name.trim()) const good = await verifyPassword(password, user?.hash ?? this.#dummy) return user !== undefined && good && user.disabled !== true ? user : null } /** True when the user was found and is disabled (for the audit log only). */ isDisabled(name: string): boolean { return this.find(name.trim())?.disabled === true } }