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:
@@ -1117,6 +1117,18 @@ B входит со своим устаревшим 0 -> lastOnline снова 0
|
||||
Если переменной нет, а создавать учётную запись нужно, админка **отказывает в
|
||||
старте** с сообщением, называющим причину и способ починки.
|
||||
|
||||
То же и при значении вне контракта пароля: `HY2XS_ADMIN_INITIAL_PASSWORD`
|
||||
проверяется против того же правила, что и форма входа
|
||||
(`apps/credential/admin.go`), и непригодное значение роняет старт с внятным
|
||||
текстом, а не доходит до `bcrypt.GenerateFromPassword`, чтобы вернуться оттуда
|
||||
строкой `password length exceeds 72 bytes`. Учётная запись при этом не
|
||||
создаётся: установка иначе завершилась бы успешно, а войти было бы нельзя.
|
||||
|
||||
Пароль читается **как есть**: пробелы по краям объявлены его частью и не
|
||||
снимаются ни здесь, ни при хешировании, ни на форме входа. Раньше bootstrap
|
||||
делал `strings.TrimSpace`, и учётная запись создавалась не с тем паролем,
|
||||
который оператор записал в `hy2xs.env`.
|
||||
|
||||
Раньше она в этом случае придумывала пароль сама и печатала его двумя
|
||||
`logrus.Warnf` — открытым текстом в `/var/log/hy2xs/hy2xs-admin.log`, то есть в
|
||||
файл, который отдаётся кнопкой выгрузки и попадает в diagnostics-бандл. Помимо
|
||||
|
||||
@@ -270,24 +270,33 @@ control plane показывал всех пиров офлайн. Теперь
|
||||
### Где живёт контракт
|
||||
|
||||
Требования к логину и паролю администратора объявлены **один раз**, в
|
||||
`apps/service/admin_credentials.go`:
|
||||
`apps/credential/admin.go`:
|
||||
|
||||
| Что | Значение | Владелец |
|
||||
| --- | --- | --- |
|
||||
| Длина логина | 6-32 символа | `AdminUsernameMinLength` / `AdminUsernameMaxLength` |
|
||||
| Набор символов логина | `a-z A-Z 0-9 !@#$%^&*()_+,-./:;<=` | `AdminUsernameCharset` |
|
||||
| Длина пароля | 6-64 символа | `AdminPasswordMinLength` / `AdminPasswordMaxLength` |
|
||||
| Набор символов пароля | не ограничен | — |
|
||||
| Длина пароля | 6-64 символа Unicode | `AdminPasswordMinLength` / `AdminPasswordMaxLength` |
|
||||
| Размер пароля | не более 72 байт в UTF-8 | `AdminPasswordMaxBytes` |
|
||||
| Набор символов пароля | не ограничен, кроме управляющих | — |
|
||||
| Пробелы по краям пароля | часть значения, не снимаются | — |
|
||||
|
||||
Остальные три стороны продукта только повторяют этот контракт, и каждая копия
|
||||
Контракт живёт в отдельном **leaf-пакете**, а не в `service`, и это не
|
||||
вкусовщина. Его зовут `util.HashPassword` и слой данных при создании первой
|
||||
учётной записи, а `service` импортирует `util` — обратный импорт был бы
|
||||
циклическим. Пока контракт лежал в `service`, `HashPassword` завёл собственную
|
||||
проверку `len(strings.TrimSpace(password)) < 6`, и она разошлась с остальным
|
||||
продуктом.
|
||||
|
||||
Остальные стороны продукта только повторяют этот контракт, и каждая копия
|
||||
сверяется с оригиналом тестом, читающим Go-исходник:
|
||||
|
||||
* панель — `apps/frontend/src/constants/credentials.ts`
|
||||
(`tools/test/frontend-contract.test.ts`);
|
||||
* оркестратор — `orchestrator/src/config/profile.ts`
|
||||
(`orchestrator/test/admin-credentials.test.ts`);
|
||||
* правило валидатора — `credentialStr` в `apps/controller/validator.go`, длина
|
||||
живёт ВНУТРИ него.
|
||||
* правила валидатора — `credentialStr` и `adminPassword` в
|
||||
`apps/controller/validator.go`, длина живёт ВНУТРИ них.
|
||||
|
||||
### Почему у пароля нет набора символов
|
||||
|
||||
@@ -296,12 +305,64 @@ control plane показывал всех пиров офлайн. Теперь
|
||||
bcrypt-хешем. Ограничение набора на форме не защищает ничего и умеет только
|
||||
отвергнуть пароль, который сервер принял бы.
|
||||
|
||||
Верхняя граница в 64 символа выбрана не круглым числом: bcrypt читает первые 72
|
||||
БАЙТА и молча отбрасывает остаток, поэтому предел обязан быть заведомо ниже.
|
||||
Единственное исключение — **управляющие символы** (C0 и DEL). Они запрещены не
|
||||
формой, а транспортом: первый пароль администратора уезжает в
|
||||
`/etc/hy2xs/hy2xs.env`, который systemd читает как `EnvironmentFile=`, и у
|
||||
перевода строки там нет представления, переживающего запись и чтение. Такой
|
||||
пароль всё равно невозможно ввести в однострочное поле формы входа, то есть он
|
||||
умеет ровно одно — запереть оператора снаружи.
|
||||
|
||||
Длина считается в **символах**, а не в байтах: `go-playground/validator` считает
|
||||
`min`/`max` на строке в рунах, и проверка по байтам отвергла бы пароль из 32
|
||||
кириллических букв, который сервер принимает.
|
||||
### Почему границ у пароля две
|
||||
|
||||
Их две потому, что они в **разных единицах**, и вывести одну из другой нельзя.
|
||||
|
||||
Граница в символах — та, которую видит оператор. Она считается в code points, а
|
||||
не в байтах и не в единицах UTF-16: `go-playground/validator` считает `min`/`max`
|
||||
на строке через `utf8.RuneCountInString`, и «пароль из 64 символов» обязано
|
||||
означать одно и то же для латиницы и для кириллицы.
|
||||
|
||||
Граница в байтах — та, которую ставит bcrypt. `golang.org/x/crypto/bcrypt`
|
||||
отвечает `ErrPasswordTooLong` на пароль длиннее **72 байт**
|
||||
(`GenerateFromPassword`, `bcrypt.go:96`). У 64 символов длина от 64 до 256 байт:
|
||||
|
||||
```text
|
||||
64 x "a" = 64 байта -> принимается
|
||||
36 x "я" = 72 байта -> принимается (граница)
|
||||
37 x "я" = 74 байта -> отвергается
|
||||
18 x "😀" = 72 байта -> принимается (граница)
|
||||
19 x "😀" = 76 байт -> отвергается
|
||||
64 x "я" = 128 байт -> отвергается
|
||||
```
|
||||
|
||||
Здесь был дефект. Верхняя граница в 64 символа объявлялась «заведомо ниже 72
|
||||
байт» — верно только для ASCII, — а сопровождающий текст утверждал, что bcrypt
|
||||
«молча отбрасывает остаток». Так вела себя редакция пакета до v0.28;
|
||||
действующая отвечает ошибкой. Следствие: пароль из 64 кириллических букв
|
||||
проходил панель, оркестратор и DTO, а отказ приходил из хеширования — системной
|
||||
ошибкой на штатной смене пароля, а после установки — отсутствием администратора
|
||||
вовсе.
|
||||
|
||||
### Панель считает длину так же, как сервер
|
||||
|
||||
Встроенных `min`/`max` Element Plus у пароля **нет**. Правила формы Element Plus
|
||||
делегирует библиотеке `async-validator`, а та сравнивает `min`/`max` строки с
|
||||
`String.prototype.length`, то есть считает единицы UTF-16:
|
||||
|
||||
```text
|
||||
"😀😀😀" Go: 3 руны -> сервер отказывает (минимум 6)
|
||||
JS: length === 6 -> форма считала минимум достигнутым
|
||||
```
|
||||
|
||||
Панель отправляла бы заведомо отвергаемый пароль и не могла бы объяснить отказ.
|
||||
Поэтому у обеих форм одно общее правило `adminPasswordFormRule`, и оно считает
|
||||
code points итератором строки, а байты — через `TextEncoder`.
|
||||
|
||||
### Границы обеих форм обязаны совпадать
|
||||
|
||||
Форма входа и форма смены пароля предъявляют к паролю **одно и то же**
|
||||
требование. Расхождение здесь запирает оператора снаружи после операции,
|
||||
которую панель ему же и предложила: пароль длиннее предела формы входа
|
||||
назначается успешно и после этого не вводится.
|
||||
|
||||
### Границы обеих форм обязаны совпадать
|
||||
|
||||
@@ -337,7 +398,7 @@ Element Plus рисует состояние отказа на `el-input__wrappe
|
||||
|
||||
### Требование называется, а не нарушается
|
||||
|
||||
Фразы `credentials.usernameFormat` и `credentials.passwordLength` перечисляют
|
||||
Фразы `credentials.usernameFormat` и `credentials.passwordFormat` перечисляют
|
||||
границы и набор символов. Набор логина приходит из `HY2XS_ADMIN_USER`, и
|
||||
посмотреть его в панели больше негде — сообщение «Неверный формат логина» не
|
||||
давало оператору ни одного способа узнать, что от него хотят.
|
||||
@@ -346,3 +407,9 @@ Element Plus рисует состояние отказа на `el-input__wrappe
|
||||
(`min`, `max`, `charset`), и фраза панели обязана их использовать: правило одно
|
||||
и проверяет и длину, и набор, поэтому описывать его только через символы —
|
||||
значит описывать отказ по длине неверно.
|
||||
|
||||
У пароля причина отдельная — `admin_password_format` с `params`
|
||||
(`min`, `max`, `maxBytes`), — и фраза обязана называть **обе** границы. Пароль
|
||||
из 40 эмодзи укладывается в 64 символа и не укладывается в 72 байта: сообщение
|
||||
«не длиннее 64 символов» отправило бы оператора сокращать пароль, отвергнутый
|
||||
не за это.
|
||||
|
||||
Reference in New Issue
Block a user