Files
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

94 lines
6.3 KiB
Go

package constant
const (
SysError string = "system error"
UnauthorizedError string = "unauthorized"
ForbiddenError string = "permission denied"
InvalidError string = "invalid"
IllegalTokenError string = "authentication failed"
TokenExpiredError string = "token expired"
WrongPassword string = "wrong password"
ConfigNotExist string = "config not exist"
)
// Коды структурированных ошибок.
//
// Зачем они есть. Раньше единственным машиночитаемым признаком ошибки был
// числовой `code` ответа, а всё остальное жило в человеческом тексте: слой vo
// выбирал HTTP-семантику СРАВНЕНИЕМ строки сообщения, а панель показывала
// оператору голое «invalid» на любую ошибку любого поля формы. Оба места
// разбирали прозу — то есть договор между сервером и панелью держался на
// совпадении литералов, которое ничто не проверяло.
//
// Теперь у ошибки есть код и — там, где ошибка относится к полю, — имя поля.
// Панель выбирает по коду свою локализованную строку и не разбирает текст;
// `message` остаётся человекочитаемым ответом для клиента без UI и запасным
// вариантом для кода, которого панель ещё не знает.
//
// Коды — часть публичного контракта API: их значения не меняются вместе с
// формулировками сообщений.
const (
// ErrCodeBodyInvalid — тело запроса не разобралось: не JSON, не тот тип
// поля, сломанная query-строка. Это отказ ДО проверки правил.
ErrCodeBodyInvalid string = "body_invalid"
// ErrCodeValidationFailed — общий код ответа, у которого есть errors[].
ErrCodeValidationFailed string = "validation_failed"
// Коды правил. Совпадают с именами тегов валидатора: одно правило — один
// код, и никакого второго словаря соответствий.
ErrCodeRequired string = "required"
// Границы числа и границы длины строки различаются кодом, хотя тег
// валидатора у них один. Оператору это разные фразы: «не меньше 1
// устройства» и «не короче 6 символов», — и панель обязана уметь их
// различить, не заводя у себя таблицу «какое поле какого рода».
ErrCodeMin string = "min"
ErrCodeMax string = "max"
ErrCodeMinLength string = "min_length"
ErrCodeMaxLength string = "max_length"
ErrCodeLen string = "len"
ErrCodeOneOf string = "oneof"
ErrCodeGreaterThan string = "gt"
ErrCodePeerName string = "peer_name"
ErrCodeCredentialStr string = "credential_format"
// ErrCodeAdminPassword — пароль администратора не удовлетворяет контракту.
//
// Отдельный код, а не пара min_length/max_length, потому что правило одно и
// проверяет три вещи сразу: длину в символах, длину в байтах (предел
// bcrypt) и отсутствие управляющих символов. Пока границы стояли двумя
// тегами, отказ по байтам было нечем назвать — его просто не существовало,
// и пароль из 64 кириллических букв доходил до хеширования, где становился
// системной ошибкой.
ErrCodeAdminPassword string = "admin_password_format"
ErrCodeRuleUnknown string = "rule_violated"
// Доменные коды: правило соблюдено, но операция всё равно невозможна.
ErrCodePeerNameTaken string = "peer_name_taken"
ErrCodePeerNameReserved string = "peer_name_reserved"
ErrCodePeerBootstrapLocked string = "peer_bootstrap_identity_locked"
ErrCodeInvalidCredentials string = "invalid_credentials"
ErrCodeImportFileExtension string = "import_file_extension"
// ErrCodePeerDisconnectFailed — ЧАСТИЧНЫЙ результат, а не отказ операции.
//
// Отзыв доступа состоит из двух половин: долговременной записи в базе,
// которая закрывает новые подключения, и обращения к Traffic Stats `/kick`,
// которое рвёт уже установленную сессию. Первая половина к моменту этого
// кода уже применена и НЕ откатывается: достигнутое безопасное состояние
// нельзя отменять из-за неудачи второго шага — откат вернул бы пиру полный
// доступ.
//
// Поэтому код нужен отдельный. Без него оператор видел бы обычную ошибку и
// сделал бы ровно неверный вывод: «не сработало, состояние прежнее».
ErrCodePeerDisconnectFailed string = "peer_disconnect_failed"
// Коды состояния сессии. Панель различает «войдите» и «сессия кончилась»:
// во втором случае оператор находится на рабочей странице, и молча
// выбрасывать его на форму входа без объяснения нельзя.
ErrCodeUnauthorized string = "unauthorized"
ErrCodeSessionExpired string = "session_expired"
ErrCodeTokenInvalid string = "token_invalid"
ErrCodeAccountDisabled string = "account_disabled"
)