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 }