Files
HY2XS_flamy/apps/util/encrypt.go
T

163 lines
6.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, без управляющих символов и U+FEFF",
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
}