// Проверка пароля единственной учётки панели. // // Две вещи, которые в панелях были сделаны по-разному, здесь сведены к одному // поведению: // // 1. Сравнение. Везде было плоское `pass !== ADMIN_PASS` — время сравнения // строк зависит от длины общего префикса. Здесь сравниваются SHA-256 // дайджесты через timingSafeEqual: одинаковая длина (иначе timingSafeEqual // бросает) и постоянное время. // // 2. Ненастроенный пароль. Одна панель падала в открытую (дефолт "admin" и // предупреждение в консоль), другая — в закрытую (вход запрещён). Ядро // падает в закрытую и максимально рано: не на первом логине, а при старте // процесса. Тихо работающая панель с паролем по умолчанию — худший из // возможных исходов. import crypto from 'node:crypto'; /** Сравнение за постоянное время. Строки произвольной длины. */ export function timingSafeEquals(a: string, b: string): boolean { const ha = crypto.createHash('sha256').update(a).digest(); const hb = crypto.createHash('sha256').update(b).digest(); return crypto.timingSafeEqual(ha, hb); } export interface PasswordOptions { /** Пароль открытым текстом (обычно из ADMIN_PASS). */ password?: string; /** * Либо хэш вида `scrypt::` (обычно из ADMIN_PASS_HASH) — * тогда открытый пароль не хранится в окружении вовсе. * Сгенерировать: `node -e "…"` — см. hashPassword ниже. */ passwordHash?: string; } export interface PasswordCheck { verify: (input: string) => boolean; /** Как настроен пароль — для строки в логе при старте. */ kind: 'plain' | 'scrypt'; } const SCRYPT_PREFIX = 'scrypt:'; /** Готовый ADMIN_PASS_HASH для указанного пароля. */ export function hashPassword(password: string): string { const salt = crypto.randomBytes(16); const key = crypto.scryptSync(password, salt, 32); return `${SCRYPT_PREFIX}${salt.toString('hex')}:${key.toString('hex')}`; } export function createPasswordCheck(options: PasswordOptions): PasswordCheck { const { password, passwordHash } = options; if (passwordHash) { if (!passwordHash.startsWith(SCRYPT_PREFIX)) { throw new Error('panel-server: passwordHash должен начинаться с "scrypt:" — см. hashPassword()'); } const [, saltHex, keyHex] = passwordHash.split(':'); if (!saltHex || !keyHex) { throw new Error('panel-server: passwordHash повреждён, ожидается scrypt::'); } const salt = Buffer.from(saltHex, 'hex'); const expected = Buffer.from(keyHex, 'hex'); return { kind: 'scrypt', verify(input) { const actual = crypto.scryptSync(input, salt, expected.length); return crypto.timingSafeEqual(actual, expected); }, }; } if (!password) { throw new Error( 'panel-server: пароль панели не задан.\n' + ' Укажи ADMIN_PASS (или ADMIN_PASS_HASH — тогда открытого пароля в окружении не будет).\n' + ' Значения по умолчанию здесь намеренно нет: панель с паролем "admin" опаснее, чем не поднявшаяся панель.', ); } return { kind: 'plain', verify: (input) => timingSafeEquals(input, password) }; }