Files
HY2XS_flamy/apps/service/peer_secret.go
T
founder cf094f6e6f fix(v1): сделать отзыв доступа, бэкап и диагностику соответствующими своим именам
Проход по операциям, которые делают не то, что обещает их имя.

P0. Удаление bootstrap-admin-peer не было отзывом доступа. Признаком «создавать
пир или нет» служило наличие строки в таблице, а HY2XS_ADMIN_CON_PASS
продолжает жить в /etc/hy2xs/hy2xs.env — его читает systemd-юнит. Оператор
удалял пира, доступ исчезал, и ближайший restart возвращал того же пира с тем же
секретом. Молча. Признаком стала отметка BOOTSTRAP_PEER_SEEDED в таблице config:
«создавался когда-либо», а не «существует сейчас». Отметка и пир пишутся одной
транзакцией.

P1. Резервная копия с includeSecrets=true проглатывала и ошибку расшифровки, и
отсутствие шифртекста, отдавая пира с пустым secret и успешный ответ. Теперь
недоступный секрет любого пира отклоняет весь запрос с указанием имени.

P1. DecryptPeerSecret возвращала содержимое колонки как расшифрованный секрет,
если оно не начиналось с v1: — остаток поколения с открытыми секретами.

P1. doctor перезапускал hysteria-server и hy2xs-admin: диагностика подозрения на
проблему обрывала все живые соединения.

P1. Админка сама генерировала HYSTERIA2_TRAFFIC_STATS_SECRET, записать который в
/etc/hysteria/config.yaml она не может. Сервис объявлял себя здоровым, а machine
auth переставал совпадать.

P1. Обходы проверки зависимостей (accepted-risk/skipped) не могли произвести
артефакт: приёмка требует dependency_security_gate=true. Удалены из сборки и
документации, отсутствие проверяется приёмкой.

P2. UPDATE по отсутствующей строке config считался успехом, и cron
перепланировался при несохранённом значении. Решение по RowsAffected.

P2. Слой данных не отличал «записи нет» от «база не ответила»: sentinel-значения
ErrPeerNotFound / ErrAdminUserNotFound / ErrConfigNotFound / ErrStorage.

P2. Удалены алиасы /:id/client-url и /:id/qr.

Контракт разработки: apps/go.mod объявляет toolchain go1.26.7 (директива go —
языковой baseline, а не выбор компилятора), tools/dev/doctor.sh|.ps1 сверяют
среду с versions.env.
2026-08-30 06:48:50 +05:00

89 lines
4.5 KiB
Go

package service
import (
"fmt"
"strings"
"hy2xs-admin/dao"
"hy2xs-admin/util"
)
// Криптоматериал пиров берётся из dao, а не создаётся здесь заново.
//
// Что было. В этом файле лежали СОБСТВЕННЫЕ getOrCreateConfigKey и
// getPeerSecretEncryptionKey — построчные копии одноимённых функций из
// dao/sqlite.go. Две реализации, порождающие один и тот же материал шифрования,
// в двух пакетах: bootstrap-пир получал ключ через dao, а всё остальное — через
// service. Пока обе читали один ключ из таблицы `config`, поведение совпадало,
// но любое расхождение означало бы, что секреты пиров шифруются одним ключом, а
// расшифровываются другим, и проявилось бы это на живых пирах.
//
// Копия в service несла ещё и порядок «сначала INSERT, при ошибке UPDATE»,
// из-за которого каждая первая загрузка печатала в журнал
// `duplicated key not allowed` уровня error на здоровом старте.
// GetPeerSecretKey — HMAC-ключ, которым считается secret_digest пира.
func GetPeerSecretKey() (string, error) {
return dao.GetOrCreatePeerSecretDigestKey()
}
func PeerSecretDigest(rawSecret string) (string, error) {
secretKey, err := GetPeerSecretKey()
if err != nil {
return "", err
}
return util.HmacSHA256Hex(rawSecret, secretKey), nil
}
func EncryptPeerSecret(rawSecret string) (string, error) {
key, err := dao.GetOrCreatePeerSecretEncryptionKey()
if err != nil {
return "", err
}
return util.EncryptAESGCM(rawSecret, key)
}
// DecryptPeerSecret расшифровывает сохранённый секрет пира.
//
// Формат хранения ровно один: `v1:` + AES-GCM. Значение без этого префикса —
// не «секрет в старом формате», а повреждённые данные, и ответом на них
// является ошибка.
//
// Что было:
//
// if !strings.HasPrefix(stored, "v1:") {
// return stored, nil
// }
//
// то есть содержимое колонки возвращалось как якобы успешно расшифрованный
// секрет. Ветка досталась от поколения, в котором секреты пиров лежали в базе
// открытым текстом; при clean-install-only политике такой строки не может
// существовать — EncryptPeerSecret всегда пишет префикс, — а вред остаётся:
//
// повреждённая колонка → мусор уходит в клиентскую ссылку как секрет;
// резервная копия с секретами → мусор попадает в файл вместо credentials;
// значение, записанное в обход → принимается без единой проверки.
//
// Это тот же класс, что и удалённый SHA-224 fallback при входе: молчаливое
// «понимаем формат предыдущего поколения» превращается в молчаливое «понимаем
// что угодно».
func DecryptPeerSecret(stored string) (string, error) {
if !strings.HasPrefix(stored, peerSecretCipherPrefix) {
return "", fmt.Errorf(
"секрет пира хранится в неизвестном формате: ожидался префикс %q. "+
"HY2XS хранит секреты пиров только зашифрованными",
peerSecretCipherPrefix,
)
}
key, err := dao.GetOrCreatePeerSecretEncryptionKey()
if err != nil {
return "", err
}
return util.DecryptAESGCM(stored, key)
}
// peerSecretCipherPrefix — единственный поддерживаемый формат хранения.
// Значение задаёт util.EncryptAESGCM; здесь оно объявлено, чтобы проверка и
// сообщение об ошибке не расходились с ним по разным файлам молча.
const peerSecretCipherPrefix = "v1:"