Initial commit
This commit is contained in:
118
server/internal/security/security.go
Normal file
118
server/internal/security/security.go
Normal file
@@ -0,0 +1,118 @@
|
||||
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
|
||||
}
|
||||
79
server/internal/security/security_test.go
Normal file
79
server/internal/security/security_test.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCipherRoundTripAndAADBinding(t *testing.T) {
|
||||
cipher, err := NewCipher(bytes.Repeat([]byte{1}, 32), bytes.Repeat([]byte{2}, 32))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ciphertext, nonce, err := cipher.Encrypt("+8613812345678", []byte("ticket:project-a:phone"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := cipher.Decrypt(ciphertext, nonce, []byte("ticket:project-a:phone"))
|
||||
if err != nil || got != "+8613812345678" {
|
||||
t.Fatalf("round trip got %q, %v", got, err)
|
||||
}
|
||||
if _, err := cipher.Decrypt(ciphertext, nonce, []byte("ticket:project-b:phone")); err == nil {
|
||||
t.Fatal("expected AAD mismatch to fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCipherUsesUniqueNoncesAndStableHMAC(t *testing.T) {
|
||||
cipher, _ := NewCipher(bytes.Repeat([]byte{1}, 32), bytes.Repeat([]byte{2}, 32))
|
||||
one, nonceOne, _ := cipher.Encrypt("13812345678", nil)
|
||||
two, nonceTwo, _ := cipher.Encrypt("13812345678", nil)
|
||||
if bytes.Equal(nonceOne, nonceTwo) || bytes.Equal(one, two) {
|
||||
t.Fatal("expected randomized encryption")
|
||||
}
|
||||
if cipher.Digest("13812345678") != cipher.Digest("13812345678") {
|
||||
t.Fatal("expected stable HMAC digest")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizePhone(t *testing.T) {
|
||||
got, err := NormalizePhone("+86 (138) 1234-5678")
|
||||
if err != nil || got != "+8613812345678" {
|
||||
t.Fatalf("got %q, %v", got, err)
|
||||
}
|
||||
for _, invalid := range []string{"123", "+01234567", "138-ABC-0000", strings.Repeat("1", 16)} {
|
||||
if _, err := NormalizePhone(invalid); err == nil {
|
||||
t.Fatalf("expected %q to fail", invalid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenHasHighEntropyAndHashes(t *testing.T) {
|
||||
one, err := GenerateToken()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
two, _ := GenerateToken()
|
||||
if one == two || len(one) < 40 {
|
||||
t.Fatalf("unexpected tokens %q and %q", one, two)
|
||||
}
|
||||
if HashToken(one) == HashToken(two) || len(HashToken(one)) != 64 {
|
||||
t.Fatal("unexpected token hashes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasswordHash(t *testing.T) {
|
||||
hash, err := HashPassword("correct horse battery staple")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !VerifyPassword(hash, "correct horse battery staple") || VerifyPassword(hash, "wrong password") {
|
||||
t.Fatal("password verification mismatch")
|
||||
}
|
||||
if _, err := HashPassword("short"); err == nil {
|
||||
t.Fatal("expected short password rejection")
|
||||
}
|
||||
if _, err := HashPassword("XQK123456"); err != nil {
|
||||
t.Fatal("expected a nine-character password to satisfy the API policy")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user