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:
+94
-1
@@ -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.
|
||||
|
||||
### Исправлено — правило доступа
|
||||
|
||||
- **Исчерпанная квота не отключала пира никогда.** Правило доступа
|
||||
|
||||
Reference in New Issue
Block a user