import { createCipheriv, createDecipheriv, createHash, randomBytes, scrypt as scryptCb, timingSafeEqual } from 'node:crypto' import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs' import { join } from 'node:path' import { promisify } from 'node:util' import type { H3Event } from 'h3' const scryptAsync = promisify(scryptCb) const loginAttempts = new Map() interface StoredCredentials { username: string passwordHash: string updatedAt: number } function libraryRoot() { const config = useRuntimeConfig() return (config.libraryDir || process.env.LIBRARY_DIR || '/data/library').replace(/\/$/, '') } function authPath() { return join(libraryRoot(), 'auth.enc') } function instancePath() { return join(libraryRoot(), 'instance.id') } function key() { return createHash('sha256').update(String(useRuntimeConfig().sessionSecret || 'dev-only-change-me')).digest() } function encryptJson(data: StoredCredentials) { const iv = randomBytes(12) const cipher = createCipheriv('aes-256-gcm', key(), iv) const payload = Buffer.from(JSON.stringify(data), 'utf8') const enc = Buffer.concat([cipher.update(payload), cipher.final()]) const tag = cipher.getAuthTag() return `v1:${iv.toString('hex')}:${tag.toString('hex')}:${enc.toString('hex')}` } function decryptJson(raw: string): StoredCredentials | null { const [version, ivHex, tagHex, dataHex] = raw.split(':') if (version !== 'v1' || !ivHex || !tagHex || !dataHex) return null try { const decipher = createDecipheriv('aes-256-gcm', key(), Buffer.from(ivHex, 'hex')) decipher.setAuthTag(Buffer.from(tagHex, 'hex')) const json = Buffer.concat([decipher.update(Buffer.from(dataHex, 'hex')), decipher.final()]).toString('utf8') return JSON.parse(json) as StoredCredentials } catch { return null } } async function hashPassword(password: string) { const salt = randomBytes(16) const hash = await scryptAsync(password, salt, 64) as Buffer return `scrypt:${salt.toString('hex')}:${hash.toString('hex')}` } async function verifyPassword(stored: string, password: string) { const parts = stored.split(':') if (parts.length !== 3 || parts[0] !== 'scrypt') return false const salt = Buffer.from(parts[1], 'hex') const expected = Buffer.from(parts[2], 'hex') const actual = await scryptAsync(password, salt, 64) as Buffer return actual.length === expected.length && timingSafeEqual(actual, expected) } function safeEqual(a: string, b: string) { const left = Buffer.from(a) const right = Buffer.from(b) if (left.length !== right.length) { timingSafeEqual(left, createHash('sha256').update(left).digest().subarray(0, left.length)) return false } return timingSafeEqual(left, right) } export function passwordAuthEnabled() { const config = useRuntimeConfig() return (config.authMode || config.public.authMode) === 'password' } export function oidcAuthEnabled() { if (passwordAuthEnabled()) return false const config = useRuntimeConfig() return Boolean(config.public.authEnabled && config.oidcClientId && config.oidcClientSecret) } export function privateInstanceId() { mkdirSync(libraryRoot(), { recursive: true }) if (existsSync(instancePath())) return readFileSync(instancePath(), 'utf8').trim() const id = createHash('sha256').update(randomBytes(32)).digest('hex').slice(0, 24) writeFileSync(instancePath(), id) return id } async function writeCredentials(creds: StoredCredentials) { mkdirSync(libraryRoot(), { recursive: true }) const tmp = `${authPath()}.tmp` writeFileSync(tmp, encryptJson(creds), 'utf8') renameSync(tmp, authPath()) } export async function loadCredentials(): Promise { if (existsSync(authPath())) { const stored = decryptJson(readFileSync(authPath(), 'utf8').trim()) if (stored?.username && stored.passwordHash) return stored } const config = useRuntimeConfig() const username = String(config.authUsername || '').trim() const password = String(config.authPassword || '') if (!username || !password) return null const created: StoredCredentials = { username, passwordHash: await hashPassword(password), updatedAt: Date.now() } await writeCredentials(created) return created } export async function updateCredentials(username: string, password: string) { const nextUser = username.trim().slice(0, 80) if (nextUser.length < 2) throw createError({ statusCode: 400, statusMessage: 'Username must be at least 2 characters' }) if (password.length < 8) throw createError({ statusCode: 400, statusMessage: 'Password must be at least 8 characters' }) await writeCredentials({ username: nextUser, passwordHash: await hashPassword(password), updatedAt: Date.now() }) return { username: nextUser } } function clientKey(event: H3Event) { return getRequestHeader(event, 'x-forwarded-for')?.split(',')[0]?.trim() || getRequestIP(event) || 'unknown' } export function assertLoginAllowed(event: H3Event) { const key = clientKey(event) const now = Date.now() const current = loginAttempts.get(key) if (current && current.resetAt < now) loginAttempts.delete(key) const next = loginAttempts.get(key) if (next && next.count >= 8) { throw createError({ statusCode: 429, statusMessage: 'Too many login attempts. Wait a few minutes.' }) } } export function recordLoginFailure(event: H3Event) { const key = clientKey(event) const now = Date.now() const current = loginAttempts.get(key) if (!current || current.resetAt < now) { loginAttempts.set(key, { count: 1, resetAt: now + 15 * 60 * 1000 }) return } current.count += 1 } export function clearLoginFailures(event: H3Event) { loginAttempts.delete(clientKey(event)) } export async function verifyLocalLogin(username: string, password: string) { const creds = await loadCredentials() if (!creds) throw createError({ statusCode: 503, statusMessage: 'Login is not configured yet' }) const userOk = safeEqual(creds.username, username.trim()) const passOk = await verifyPassword(creds.passwordHash, password) if (!userOk || !passOk) return null return creds.username } export function publicUsername() { if (!existsSync(authPath())) return String(useRuntimeConfig().authUsername || '').trim() const stored = decryptJson(readFileSync(authPath(), 'utf8').trim()) return stored?.username || '' }