Files
ma7engine/password.ts
T
maenekoandClaude Opus 5 f4f645dcd3 ma7engine: ядро, шаблон панели, скаффолдер и доктор
Первый коммит существующего состояния. Ядро кладётся под git ради того,
чтобы половины разъезжались в продуктовые репозитории через git subtree,
а не копированием: subtree делает настоящий merge, хранит историю и умеет
отдавать правки обратно.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LeBkZLiQrvdECf5nfgiMz
2026-08-26 20:39:37 +03:00

83 lines
4.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Проверка пароля единственной учётки панели.
//
// Две вещи, которые в панелях были сделаны по-разному, здесь сведены к одному
// поведению:
//
// 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) };
}