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>
This commit is contained in:
2026-09-06 18:38:04 +05:00
parent a8407cf16b
commit 65042ee335
38 changed files with 2646 additions and 400 deletions
+94 -1
View File
@@ -107,6 +107,25 @@ Debian 13. Тема: **разница между «поднялось» и «р
настоящий вход. Прогон задокументирован в
[docs/acceptance/2026-09-04-v1.0.0-rc2-runtime-findings.md](docs/acceptance/2026-09-04-v1.0.0-rc2-runtime-findings.md).
Пятнадцатый проход — разбор того, что четырнадцатый построил, со сверкой по
исходникам используемых библиотек, а не по их описаниям. Тема прохода: **новый
контракт, объявленный точнее, чем он есть на самом деле**. Четырнадцатый проход
свёл правило о пароле в одно место и объявил его границей в 64 символа,
обосновав выбор пределом bcrypt в 72 байта. Обоснование верно только для ASCII:
`golang.org/x/crypto/bcrypt` считает БАЙТЫ и отвечает `ErrPasswordTooLong`, а не
«молча отбрасывает остаток», как утверждал комментарий, — так вела себя редакция
пакета до v0.28. Пароль из 64 кириллических букв (128 байт) проходил панель,
оркестратор и DTO и падал в хешировании: системной ошибкой на штатной смене
пароля, а после установки — отсутствием администратора вовсе. Хуже самого
дефекта было то, что новый тест закреплял это значение как ожидаемое.
Вместе с ним закрыты три соседних расхождения того же контракта: `HashPassword`
и bootstrap триммили пароль, у которого краевой пробел объявлен частью значения;
панель считала длину в единицах UTF-16 (`async-validator`), тогда как сервер
считает code points; и сам транспорт — `hy2xs.env` — не был форматом,
переживающим произвольное значение, хотя его читает не только оркестратор, но и
systemd.
### Исправлено — вход в панель
- **`POST /api/auth/login` паниковал до проверки учётных данных.** На
@@ -144,7 +163,7 @@ Debian 13. Тема: **разница между «поднялось» и «р
смены пароля назначала до 64: пароль, назначенный штатной операцией, после
этого не вводился. Набор символов на пароле отвергал значение, которое сервер
принял бы, — сервер его не ограничивает нигде. Контракт учётных данных теперь
объявлен один раз в `apps/service/admin_credentials.go`, а копии в панели и
объявлен один раз в `apps/credential/admin.go`, а копии в панели и
оркестраторе сверяются с ним тестами, читающими Go-исходник.
- **Класс символов логина был записан диапазоном по опечатке.** Неэкранированный
@@ -172,6 +191,80 @@ Debian 13. Тема: **разница между «поднялось» и «р
ставится в `service.Login` сразу после успешной проверки пароля; отказ записи
вход не отменяет, но попадает в журнал.
### Исправлено — контракт пароля администратора
- **Граница пароля не совпадала с границей bcrypt.** Предел был объявлен в 64
СИМВОЛА и обоснован пределом bcrypt в 72 БАЙТА — верно только для ASCII. У 64
символов длина от 64 до 256 байт: `64 x "я"` — это 128 байт, `19 x "😀"` — 76.
`bcrypt.GenerateFromPassword` (`golang.org/x/crypto@v0.55.0`, `bcrypt.go:96`)
отвечает на такое `ErrPasswordTooLong`. Отказ приходил не с формы, а из
хеширования: на смене пароля — системной ошибкой, при установке — падением
старта админки, то есть сервером без администратора после
`INSTALL EXIT CODE: 0`. Введена отдельная константа `AdminPasswordMaxBytes =
72`, зеркальная в оркестраторе и панели; граничные значения (36 x `я`, 18 x
`😀` = 72 байта) закреплены тестами во всех четырёх слоях.
- **Тест закреплял дефект как ожидаемое поведение.** Проверка «длина считается в
символах, а не в байтах» требовала ПРИЁМА пароля из 64 кириллических букв —
ровно того значения, на котором продукт ломался. Тест инвертирован.
- **Пароль триммился в двух местах вопреки собственному контракту.** Контракт
объявляет краевой пробел частью пароля и не снимает его ни на форме входа, ни
при смене. При этом `util.HashPassword` вёл собственную проверку
`len(strings.TrimSpace(password)) < 6`, а bootstrap читал
`strings.TrimSpace(os.Getenv("HY2XS_ADMIN_INITIAL_PASSWORD"))`. Следствия:
пароль `abcde ` принимали все двери продукта и не мог захешировать никто, а
первая учётная запись создавалась не с тем паролем, который оператор записал в
`hy2xs.env`. Оба тримминга убраны.
- **Контракт переехал в leaf-пакет.** Собственная проверка в `HashPassword`
существовала не по недосмотру: `service` импортирует `util`, и позвать оттуда
контракт было невозможно. Правило переехало в `apps/credential`, ниже `util`,
`dao` и `service`, — теперь у него физически не может появиться второго
экземпляра.
- **Границы пароля стояли рядом с правилом, а не внутри него, и были неполны.**
`min=6,max=64` в тегах DTO — та же конструкция, от которой уже отказались на
логине; у пароля она вдобавок не могла выразить границу в байтах, потому что
правила длины в байтах у `go-playground/validator` нет. Введено одно правило
`adminPassword` и код причины `admin_password_format`, называющий обе границы.
- **Панель считала длину в единицах UTF-16.** Element Plus делегирует правила
формы библиотеке `async-validator`, а она сравнивает `min`/`max` строки с
`String.prototype.length`. Пароль из трёх эмодзи имел `length === 6`, проходил
минимум формы и получал отказ сервера, который панель не могла объяснить:
сервер видит три руны. Встроенные `min`/`max` на пароле заменены общим
правилом `adminPasswordFormRule`, считающим code points и UTF-8 байты.
- **`hy2xs.env` не был форматом.** Значения писались интерполяцией, а читались
построчным `split("=")` с `trim()`; совпадение поведения держалось на том, что
в них не встречалось ни пробелов по краям, ни кавычек, ни обратных слешей. При
этом файл читает не только оркестратор: он объявлен `EnvironmentFile=` в юните
`hy2xs-admin`, и у незакавыченного значения systemd срезает краевые пробелы и
трактует `\` как escape. Пароль с краевым пробелом терялся ещё до запуска
админки. Добавлен `orchestrator/src/lib/envFile.ts` — порт конечного автомата
`parse_env_file_internal` из systemd и обратный ему кодировщик; тем же
кодировщиком пишется `bootstrap-admin.secret`. Обычные значения остаются без
кавычек, поэтому релизные гейты и инструкции оператора не меняются.
- **Управляющие символы запрещены контрактом.** Формат `KEY=VALUE` их не несёт —
перевод строки является границей записи, — а ввести такой пароль в
однострочное поле формы входа всё равно нельзя: он умел только запереть
оператора. Запрет делает транспорт полным: всякий принятый контрактом пароль
доезжает до bcrypt побайтово тем же, каким его задал оператор.
- **Отрицательная проба smoke не доказывала отказа.** Она сверяла `%{http_code}`
с `200` и доказывала ровно одно: что запрос не закончился пятисоткой — админка
отвечает `200 OK` и на успешный вход тоже. Пароль пробы вдобавок был записан
литералом и теоретически мог оказаться настоящим. Теперь пароль генерируется в
момент пробы, а результат сверяется с конвертом: `code: 50000`, причина
`invalid_credentials`, отсутствие `accessToken`.
- **Положительная проба smoke читала пароль шелл-конвейером.** `grep … | cut -d=
-f2-` с `.trim()` был третьим по счёту слоем, срезающим краевые пробелы:
проба взяла бы не тот пароль и объявила бы рабочую установку сломанной.
Bootstrap-секрет читается парсером собственного формата, минуя shell.
### Исправлено — правило доступа
- **Исчерпанная квота не отключала пира никогда.** Правило доступа