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 на здоровом старте. // generatedSecretRandomLength — длина случайной части автогенерируемого // секрета. // // 24 символа из алфавита util.RandomString (62 символа) дают примерно 143 бита // энтропии. Источник — crypto/rand с отбрасыванием смещённых байтов, то есть // тот же генератор, которым создаются JWT_SECRET и ключи шифрования секретов. const generatedSecretRandomLength = 24 // GeneratePeerSecret создаёт секрет подключения пира. // // Владелец автогенерации — сервисный слой, и это существенно. Панель обещает // оператору «оставьте пустым — сгенерируем автоматически», и то же обещание // обязано действовать для прямого вызова API, для импорта и для будущих // клиентов. Генерация во frontend означала бы, что обещание выполняется ровно // для одной двери из четырёх, а остальные тихо получают пустое значение. // // Имя пира входит в секрет префиксом: в клиенте секрет виден оператору, и // узнать по нему, какому пиру он принадлежит, полезнее, чем скрыть эту связь. // Стойкость от этого не страдает — она обеспечивается случайной частью, а имя // пира и так публично известно из ссылки. func GeneratePeerSecret(peerName string) (string, error) { generated, err := util.RandomString(generatedSecretRandomLength) if err != nil { return "", err } if peerName == "" { return generated, nil } return fmt.Sprintf("%s.%s", peerName, generated), nil } // resolvePeerSecret возвращает секрет, который следует сохранить: заданный // оператором либо сгенерированный. // // К этому моменту нормализация DTO уже привела «поле отсутствует», «пустая // строка» и «одни пробелы» к одному состоянию — nil. Повторный TrimSpace здесь // нужен для вызовов мимо слоя DTO (тесты, внутренние пути): «сгенерировать» // обязано означать одно и то же на всех входах. func resolvePeerSecret(peerName string, provided *string) (string, error) { if provided != nil { if manual := strings.TrimSpace(*provided); manual != "" { return manual, nil } } return GeneratePeerSecret(peerName) } // 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:"