Первый коммит существующего состояния. Ядро кладётся под git ради того, чтобы половины разъезжались в продуктовые репозитории через git subtree, а не копированием: subtree делает настоящий merge, хранит историю и умеет отдавать правки обратно. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019LeBkZLiQrvdECf5nfgiMz
83 lines
4.1 KiB
TypeScript
83 lines
4.1 KiB
TypeScript
// Проверка пароля единственной учётки панели.
|
||
//
|
||
// Две вещи, которые в панелях были сделаны по-разному, здесь сведены к одному
|
||
// поведению:
|
||
//
|
||
// 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:<salt-hex>:<key-hex>` (обычно из 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:<salt-hex>:<key-hex>');
|
||
}
|
||
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) };
|
||
}
|