Files
XQKqueue/server/internal/security/security.go
2026-07-12 15:53:24 +08:00

119 lines
3.3 KiB
Go

package security
import (
"crypto/aes"
"crypto/cipher"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"strings"
"unicode"
"golang.org/x/crypto/bcrypt"
)
const PasswordCost = 12
type Cipher struct {
aead cipher.AEAD
hmacKey []byte
}
func NewCipher(encryptionKey, hmacKey []byte) (*Cipher, error) {
if len(encryptionKey) != 32 || len(hmacKey) != 32 {
return nil, errors.New("encryption and HMAC keys must each be exactly 32 bytes")
}
block, err := aes.NewCipher(encryptionKey)
if err != nil {
return nil, fmt.Errorf("create AES cipher: %w", err)
}
aead, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("create AES-GCM: %w", err)
}
return &Cipher{aead: aead, hmacKey: append([]byte(nil), hmacKey...)}, nil
}
func (c *Cipher) Encrypt(plaintext string, additionalData []byte) (ciphertext, nonce []byte, err error) {
nonce = make([]byte, c.aead.NonceSize())
if _, err := rand.Read(nonce); err != nil {
return nil, nil, fmt.Errorf("generate nonce: %w", err)
}
ciphertext = c.aead.Seal(nil, nonce, []byte(plaintext), additionalData)
return ciphertext, nonce, nil
}
func (c *Cipher) Decrypt(ciphertext, nonce, additionalData []byte) (string, error) {
if len(nonce) != c.aead.NonceSize() {
return "", errors.New("invalid AES-GCM nonce length")
}
plaintext, err := c.aead.Open(nil, nonce, ciphertext, additionalData)
if err != nil {
return "", errors.New("encrypted field authentication failed")
}
return string(plaintext), nil
}
func (c *Cipher) Digest(value string) string {
mac := hmac.New(sha256.New, c.hmacKey)
_, _ = mac.Write([]byte(value))
return hex.EncodeToString(mac.Sum(nil))
}
func NormalizePhone(input string) (string, error) {
var b strings.Builder
for i, r := range strings.TrimSpace(input) {
switch {
case unicode.IsDigit(r) && r <= unicode.MaxASCII:
b.WriteRune(r)
case r == '+' && i == 0:
b.WriteRune(r)
case unicode.IsSpace(r) || r == '-' || r == '(' || r == ')':
continue
default:
return "", errors.New("phone may contain only digits, an optional leading +, spaces, dashes and parentheses")
}
}
normalized := b.String()
digits := strings.TrimPrefix(normalized, "+")
if len(digits) < 7 || len(digits) > 15 {
return "", errors.New("phone must contain 7 to 15 digits")
}
if strings.HasPrefix(digits, "0") && strings.HasPrefix(normalized, "+") {
return "", errors.New("international phone cannot start with +0")
}
return normalized, nil
}
func GenerateToken() (string, error) {
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
return "", fmt.Errorf("generate random token: %w", err)
}
return base64.RawURLEncoding.EncodeToString(raw), nil
}
func HashToken(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}
func HashPassword(password string) (string, error) {
if len(password) < 8 || len(password) > 128 {
return "", errors.New("password must be 8 to 128 characters")
}
encoded, err := bcrypt.GenerateFromPassword([]byte(password), PasswordCost)
if err != nil {
return "", fmt.Errorf("hash password: %w", err)
}
return string(encoded), nil
}
func VerifyPassword(encoded, password string) bool {
return bcrypt.CompareHashAndPassword([]byte(encoded), []byte(password)) == nil
}