package util import ( "crypto/aes" "crypto/cipher" "crypto/hmac" "crypto/rand" "crypto/sha256" "encoding/base64" "errors" "fmt" "io" "strings" "golang.org/x/crypto/bcrypt" "hy2xs-admin/credential" ) // Функции SHA224String здесь больше нет, как и ветки сравнения паролей по ней. // // Это был формат хранения паролей предыдущего поколения: несолёный SHA-224, // то есть быстрый хеш общего назначения, по которому словарная атака идёт // на порядки быстрее, чем по bcrypt, а одинаковые пароли дают одинаковые // хеши. VerifyPassword принимал такие хеши как «legacy» и молча переписывал // их в bcrypt при первом успешном входе. // // В HY2XS v1 такого хеша не может существовать. Единственные места, создающие // учётную запись, — ensureSecureBootstrapAdmin и `reset-admin`, и оба пишут // bcrypt; миграции 002/003, переносившие таблицу `account` предыдущего // поколения, удалены; установка возможна только на чистый хост, а конфигурация // 0.x отклоняется по HY2XS_CONFIG_SCHEMA_VERSION. То есть compatibility-ветка // пережила ровно тот слой совместимости, ради которого существовала, и // осталась запасным путём проверки пароля со слабым алгоритмом — в обработчике // логина, доступном без аутентификации. // HashPassword проверяет пароль ОБЩИМ контрактом и хеширует его. // // Здесь стояла собственная проверка: // // if len(strings.TrimSpace(password)) < 6 { … } // // и она была третьей копией правила о пароле — вдобавок расходящейся с двумя // остальными сразу по двум признакам. // // Первое: `TrimSpace`. Контракт продукта прямо объявляет пробел по краям частью // пароля и не снимает его нигде — ни в нормализации DTO, ни при чтении // bootstrap-окружения. Пароль `"abcde "` считался корректным формой входа, // формой смены и оркестратором, а здесь превращался в `"abcde"` и получал // отказ. То есть значение, принятое всеми дверями продукта, невозможно было // захешировать. // // Второе: у проверки не было верхней границы вовсе, поэтому предел bcrypt в 72 // БАЙТА всплывал ошибкой хеширования уже после того, как все проверки пройдены. // // Теперь правило одно на весь продукт. Возможность позвать его отсюда — прямая // причина, по которой контракт живёт в пакете `credential`, а не в `service`: // `service` импортирует `util`, и обратный импорт был бы циклическим. func HashPassword(password string) (string, error) { if !credential.IsValidAdminPassword(password) { return "", fmt.Errorf( "пароль администратора не удовлетворяет контракту: от %d до %d символов, "+ "не более %d байт в UTF-8, без управляющих символов", credential.AdminPasswordMinLength, credential.AdminPasswordMaxLength, credential.AdminPasswordMaxBytes, ) } hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { return "", err } return string(hash), nil } func IsBcryptHash(hash string) bool { return strings.HasPrefix(hash, "$2a$") || strings.HasPrefix(hash, "$2b$") || strings.HasPrefix(hash, "$2y$") } // VerifyPassword проверяет пароль ровно против одного формата хранения. // // Хеш, который не является bcrypt, — это не «пароль в старом формате», а // повреждённая или подменённая строка в базе, и правильный ответ на неё — // отказ во входе. func VerifyPassword(password string, storedHash string) bool { if !IsBcryptHash(storedHash) { return false } return bcrypt.CompareHashAndPassword([]byte(storedHash), []byte(password)) == nil } func HmacSHA256Hex(payload string, secret string) string { mac := hmac.New(sha256.New, []byte(secret)) mac.Write([]byte(payload)) sum := mac.Sum(nil) str := "" for _, v := range sum { str += fmt.Sprintf("%02x", v) } return str } func DecodeBase64Key(raw string, expectedLen int) ([]byte, error) { raw = strings.TrimSpace(raw) if raw == "" { return nil, errors.New("empty key") } decoded, err := base64.StdEncoding.DecodeString(raw) if err != nil { return nil, err } if len(decoded) != expectedLen { return nil, fmt.Errorf("invalid key length: expected %d, got %d", expectedLen, len(decoded)) } return decoded, nil } func EncryptAESGCM(plainText string, key []byte) (string, error) { block, err := aes.NewCipher(key) if err != nil { return "", err } gcm, err := cipher.NewGCM(block) if err != nil { return "", err } nonce := make([]byte, gcm.NonceSize()) if _, err = io.ReadFull(rand.Reader, nonce); err != nil { return "", err } cipherText := gcm.Seal(nil, nonce, []byte(plainText), nil) payload := append(nonce, cipherText...) return "v1:" + base64.StdEncoding.EncodeToString(payload), nil } func DecryptAESGCM(cipherText string, key []byte) (string, error) { if !strings.HasPrefix(cipherText, "v1:") { return "", errors.New("unsupported ciphertext version") } encoded := strings.TrimPrefix(cipherText, "v1:") payload, err := base64.StdEncoding.DecodeString(encoded) if err != nil { return "", err } block, err := aes.NewCipher(key) if err != nil { return "", err } gcm, err := cipher.NewGCM(block) if err != nil { return "", err } nonceSize := gcm.NonceSize() if len(payload) < nonceSize { return "", errors.New("invalid ciphertext") } nonce, enc := payload[:nonceSize], payload[nonceSize:] plain, err := gcm.Open(nil, nonce, enc, nil) if err != nil { return "", err } return string(plain), nil }