ma7engine: ядро, шаблон панели, скаффолдер и доктор
Первый коммит существующего состояния. Ядро кладётся под git ради того, чтобы половины разъезжались в продуктовые репозитории через git subtree, а не копированием: subtree делает настоящий merge, хранит историю и умеет отдавать правки обратно. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019LeBkZLiQrvdECf5nfgiMz
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
// Проверка пароля единственной учётки панели.
|
||||
//
|
||||
// Две вещи, которые в панелях были сделаны по-разному, здесь сведены к одному
|
||||
// поведению:
|
||||
//
|
||||
// 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) };
|
||||
}
|
||||
Reference in New Issue
Block a user