Files
HY2XS_flamy/apps/credential/admin.go
T
Crimson 65042ee335 fix(auth): контракт пароля администратора расходился с bcrypt в четырёх местах
Верхняя граница пароля была объявлена в 64 СИМВОЛА и обоснована пределом
bcrypt в 72 БАЙТА. Обоснование верно только для ASCII: у 64 символов длина от
64 до 256 байт. golang.org/x/crypto@v0.55.0 (bcrypt.go:96) отвечает на пароль
длиннее 72 байт ErrPasswordTooLong, а не «молча отбрасывает остаток», как
утверждал комментарий, — так вела себя редакция пакета до v0.28.

Следствие: пароль из 64 кириллических букв (128 байт) проходил панель,
оркестратор и DTO, а отказ приходил из хеширования — системной ошибкой на
штатной смене пароля, а при установке падением старта админки, то есть
сервером без администратора после INSTALL EXIT CODE: 0. Хуже самого дефекта
было то, что тест закреплял это значение как ожидаемое.

Вместе с ним закрыты три соседних расхождения того же контракта.

Пароль триммился вопреки собственному контракту. util.HashPassword вёл
проверку len(strings.TrimSpace(password)) < 6, а bootstrap читал
strings.TrimSpace(os.Getenv("HY2XS_ADMIN_INITIAL_PASSWORD")). Значение
"abcde " принимали все двери продукта и не мог захешировать никто, а первая
учётная запись создавалась не с тем паролем, который оператор записал в
hy2xs.env.

Панель считала длину в единицах UTF-16. Element Plus делегирует правила формы
async-validator, а он сравнивает min/max с String.prototype.length: пароль из
трёх эмодзи имел length 6, проходил минимум формы и получал отказ сервера,
который панель не могла объяснить.

hy2xs.env не был форматом. Значения писались интерполяцией, а читались
split("=") с trim(); при этом файл читает не только оркестратор — он объявлен
EnvironmentFile= в юните hy2xs-admin, и у незакавыченного значения systemd
срезает краевые пробелы и трактует обратный слеш как escape.

Что сделано:

- контракт переехал в leaf-пакет apps/credential: его зовут util.HashPassword
  и dao, а service импортирует util — обратный импорт был бы циклическим, и
  именно поэтому HashPassword завёл собственную копию правила;
- AdminPasswordMaxBytes = 72 объявлен отдельной константой и зеркально в
  оркестраторе и панели; сверяется тестами, читающими Go-исходник;
- одно правило adminPassword вместо min=6,max=64 в тегах DTO (границу в
  байтах тегом валидатора не выразить) и код причины admin_password_format,
  называющий обе границы;
- TrimSpace убран из хеширования и из bootstrap-пути; bootstrap проверяет
  контракт сам и падает с текстом, называющим переменную и файл;
- панель считает code points и UTF-8 байты общим adminPasswordFormRule на
  обеих формах вместо встроенных min/max;
- orchestrator/src/lib/envFile.ts — порт конечного автомата
  parse_env_file_internal из systemd и обратный ему кодировщик; экранируются
  только обратный слеш и двойная кавычка, оба из SHELL_NEED_ESCAPE. Обычные
  значения остаются без кавычек, поэтому релизные гейты не меняются. Тем же
  кодировщиком пишется bootstrap-admin.secret;
- управляющие символы запрещены контрактом: формат KEY=VALUE их не несёт, а
  ввести такой пароль в форму входа всё равно нельзя;
- отрицательная проба smoke сверяет конверт отказа (code 50000,
  invalid_credentials, отсутствие accessToken) вместо HTTP 200, а пароль
  генерирует, а не берёт из литерала;
- положительная проба читает bootstrap-секрет парсером формата вместо
  grep | cut -d= -f2- с trim() — третьего по счёту слоя, срезавшего пробелы.

Тесты: граничная таблица (36 x «я», 37 x «я», 18 и 19 эмодзи, 64 x «я»,
«abcde ») прогоняется в четырёх слоях; тест с 64 кириллическими буквами
инвертирован; round-trip env-формата на значениях с кавычками, слешами и
краевыми пробелами; bootstrap-путь на настоящей SQLite. 14 новых гейтов
приёмки.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 18:38:04 +05:00

174 lines
12 KiB
Go
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.
// Package credential объявляет контракт учётных данных администратора — ОДИН
// на весь продукт.
//
// Почему это отдельный пакет, а не файл в service. Контракт нужен четырём
// слоям сразу, и два из них лежат НИЖЕ service по графу импортов:
//
// controller -> service -> util (хеширование пароля)
// dao -> util (создание первой учётной записи)
// cmd -> dao, util (reset-admin)
//
// Пока контракт жил в service, `util.HashPassword` не мог его позвать — импорт
// был бы циклическим, — и завёл собственную проверку `len(TrimSpace(p)) < 6`.
// Эта третья копия правила разошлась с остальными ровно тем способом, ради
// предотвращения которого контракт и объявлен: LoginDto считал `"abcde "`
// корректным паролем, а HashPassword отказывался его хешировать. Пакет-лист
// ниже всех потребителей — единственная запись, при которой у правила
// физически не может появиться второго экземпляра.
package credential
import (
"regexp"
"strconv"
"strings"
"unicode/utf8"
)
// Контракт учётных данных администратора.
//
// Про логин и пароль администратора одновременно знали четыре места: регексп в
// слое контроллеров, границы длины в тегах LoginDto, правило формы входа в
// панели и правило формы смены пароля в ней же. Ни одно из них не ссылалось на
// остальные, и все четыре успели разойтись:
//
// - тег `validateStr` на Username пережил переименование правила и остался
// ссылкой на несуществующую функцию — валидатор паниковал на КАЖДОМ входе,
// а gin.Recovery превращал панику в HTTP 500;
// - форма входа ограничивала пароль 32 символами, форма смены пароля — 64,
// сервер — тоже 64. То есть пароль, назначенный штатной формой смены,
// форма входа отказывалась отправлять: панель запирала оператора снаружи
// после операции, которую сама же и предложила;
// - оркестратор не проверял HY2XS_ADMIN_USER вовсе и по умолчанию писал в
// hy2xs.env `admin` — пять символов при минимуме в шесть. Установка
// завершалась успешно, а войти в панель было нельзя.
//
// Общее у всех трёх — контракт существовал только как совпадение литералов,
// которое ничто не проверяло. Теперь он объявлен здесь, слой контроллеров зовёт
// IsValidAdminUsername и IsValidAdminPassword, а расхождение с панелью и
// оркестратором ловится тестами, читающими этот же файл.
const (
AdminUsernameMinLength = 6
AdminUsernameMaxLength = 32
// Границы пароля в СИМВОЛАХ Unicode (code points).
AdminPasswordMinLength = 6
AdminPasswordMaxLength = 64
// AdminPasswordMaxBytes — предел, установленный bcrypt.
//
// Он объявлен ОТДЕЛЬНО от AdminPasswordMaxLength, потому что это ДРУГАЯ
// единица измерения, и подменять одну другой нельзя.
//
// Здесь стоял ровно этот дефект. Верхняя граница в 64 символа была выбрана
// «заведомо ниже 72 байт», и обоснование выглядело убедительно, пока пароль
// оставался ASCII. Но `golang.org/x/crypto/bcrypt` считает БАЙТЫ:
//
// 64 x "a" = 64 байта -> GenerateFromPassword принимает
// 64 x "я" = 128 байт -> ErrPasswordTooLong
// 19 x "😀" = 76 байт -> ErrPasswordTooLong
//
// причём отказ приходил НЕ на форме, а из хеширования: смена пароля на 64
// кириллические буквы проходила все проверки продукта и заканчивалась
// системной ошибкой на штатной операции. Комментарий в прежней редакции
// вдобавок утверждал, что bcrypt «молча отбрасывает остаток» — так вело
// себя издание пакета до v0.28; действующее (v0.55.0, bcrypt.go:96)
// отвечает ошибкой:
//
// if len(password) > 72 { return nil, ErrPasswordTooLong }
AdminPasswordMaxBytes = 72
)
// adminUsernameCharacterClass — набор символов логина в записи регекспа.
//
// Класс записан ЯВНО и повторяет прежнее ФАКТИЧЕСКОЕ множество, включая
// последствия неэкранированного дефиса в исходной записи `_+-=`: там `+-=`
// образовывал ДИАПАЗОН и впускал `, - . / 0-9 : ; < =`. Сужать набор здесь
// нельзя в одиночку — имя администратора приходит из HY2XS_ADMIN_USER, и
// установка с логином вроде `admin.ops` перестала бы пускать оператора в
// панель. Набор и проверка на стороне оркестратора меняются только вместе.
const adminUsernameCharacterClass = `a-zA-Z0-9!@#$%^&*()_+,\-./:;<=`
// AdminUsernameCharset — тот же набор в том виде, в каком его показывают
// оператору. Объявлен рядом с регекспом, чтобы сообщение об отказе не заводило
// собственную копию того же множества.
const AdminUsernameCharset = `a-z A-Z 0-9 !@#$%^&*()_+,-./:;<=`
// adminUsernamePattern собирается из констант, а не пишется литералом: границы
// длины обязаны быть одними и теми же для правила и для сообщения о нём.
var adminUsernamePattern = regexp.MustCompile(
"^[" + adminUsernameCharacterClass + "]{" +
strconv.Itoa(AdminUsernameMinLength) + "," +
strconv.Itoa(AdminUsernameMaxLength) + "}$",
)
// IsValidAdminUsername сообщает, пригодно ли имя администратора.
//
// Пробелы по краям снимаются здесь повторно: через форму входа они уже сняты
// нормализацией DTO, но эту же функцию зовёт проверка HY2XS_ADMIN_USER, куда
// значение приходит из файла окружения, а не из DTO.
func IsValidAdminUsername(username string) bool {
return adminUsernamePattern.MatchString(strings.TrimSpace(username))
}
// IsValidAdminPassword — единственное правило пароля администратора.
//
// Правило состоит из трёх частей и НИ ОДНА из них не является набором
// символов:
//
// 6-64 code points — граница, которую видит оператор;
// <= 72 UTF-8 bytes — граница, которую ставит bcrypt;
// без управляющих — граница, которую ставит транспорт.
//
// Длина в СИМВОЛАХ, а не в байтах, потому что именно так её считает
// go-playground/validator (`utf8.RuneCountInString` в baked_in.go) и так её
// видит оператор: «пароль из 64 символов» обязано означать одно и то же для
// латиницы и для кириллицы.
//
// Предел в БАЙТАХ существует одновременно с ним, потому что это ограничение
// другого слоя — хранилища пароля, — и выразить его через символы нельзя: у
// 64 символов длина от 64 до 256 байт в зависимости от алфавита.
//
// Управляющие символы запрещены, и это единственное ограничение НАБОРА в
// контракте. Оно введено не ради строгости: первый пароль администратора
// уезжает в /etc/hy2xs/hy2xs.env, который systemd читает как EnvironmentFile, и
// у перевода строки там нет представления, переживающего запись и чтение. При
// этом такой пароль всё равно невозможно ввести в однострочное поле формы
// входа, то есть он умеет ровно одно — запереть оператора снаружи. Запрет
// делает транспорт ПОЛНЫМ: всякий пароль, принятый этим правилом, доезжает до
// bcrypt побайтово тем же, каким его задал оператор.
//
// Набор символов НЕ ограничивается ничем сверх этого. Пароль назначает
// оператор — установкой через HY2XS_ADMIN_INITIAL_PASSWORD или формой смены, —
// и сервер нигде его набор не проверяет. Ограничение на форме входа не
// защищает ничего: значение всё равно сравнивается с хешем, — зато отвергает
// пароль, который сервер принял бы.
//
// Пробелы по краям — ЧАСТЬ пароля и не снимаются нигде: ни здесь, ни в
// нормализации DTO, ни при чтении bootstrap-окружения.
func IsValidAdminPassword(password string) bool {
runes := utf8.RuneCountInString(password)
if runes < AdminPasswordMinLength || runes > AdminPasswordMaxLength {
return false
}
if len(password) > AdminPasswordMaxBytes {
return false
}
return !hasControlRune(password)
}
// hasControlRune сообщает, есть ли в строке управляющий символ.
//
// Проверяются C0 (U+0000-U+001F) и DEL (U+007F). Управляющие символы Unicode
// вне ASCII сюда не попадают намеренно: у systemd границей строки является
// байт `\n` или `\r`, и U+2028 для него — три обычных байта, а не перевод
// строки. Правило описывает ровно то, что ломает транспорт, и ни символом
// больше.
func hasControlRune(value string) bool {
for _, r := range value {
if r < 0x20 || r == 0x7F {
return true
}
}
return false
}