package crypto import ( "crypto/aes" "crypto/cipher" "crypto/rand" "encoding/hex" "errors" "fmt" "io" "strings" ) const encPrefix = "enc:" // Cipher encrypts and decrypts short string values using AES-256-GCM. // The encrypted format is: "enc:" + hex(nonce + ciphertext + tag). // // Values that do not start with "enc:" are treated as plain text — // this allows safe migration of existing unencrypted records. type Cipher struct { gcm cipher.AEAD } // NewCipher creates a Cipher from a 64-hex-character (32-byte) key. func NewCipher(keyHex string) (*Cipher, error) { key, err := hex.DecodeString(keyHex) if err != nil { return nil, fmt.Errorf("crypto: invalid hex key: %w", err) } if len(key) != 32 { return nil, fmt.Errorf("crypto: key must be 32 bytes (64 hex chars), got %d bytes", len(key)) } block, err := aes.NewCipher(key) if err != nil { return nil, fmt.Errorf("crypto: failed to create AES cipher: %w", err) } gcm, err := cipher.NewGCM(block) if err != nil { return nil, fmt.Errorf("crypto: failed to create GCM: %w", err) } return &Cipher{gcm: gcm}, nil } // Encrypt encrypts plaintext and returns "enc:". // Empty input is returned as-is (no encryption needed for empty/unset fields). func (c *Cipher) Encrypt(plaintext string) (string, error) { if plaintext == "" { return "", nil } nonce := make([]byte, c.gcm.NonceSize()) if _, err := io.ReadFull(rand.Reader, nonce); err != nil { return "", fmt.Errorf("crypto: failed to generate nonce: %w", err) } ciphertext := c.gcm.Seal(nonce, nonce, []byte(plaintext), nil) return encPrefix + hex.EncodeToString(ciphertext), nil } // Decrypt decrypts a value produced by Encrypt. // If the value does not start with "enc:", it is returned unchanged (plain text migration path). func (c *Cipher) Decrypt(value string) (string, error) { if value == "" { return "", nil } if !strings.HasPrefix(value, encPrefix) { // Plain text — not yet encrypted; return as-is. return value, nil } data, err := hex.DecodeString(strings.TrimPrefix(value, encPrefix)) if err != nil { return "", fmt.Errorf("crypto: malformed ciphertext hex: %w", err) } nonceSize := c.gcm.NonceSize() if len(data) < nonceSize { return "", errors.New("crypto: ciphertext too short") } nonce, ciphertext := data[:nonceSize], data[nonceSize:] plaintext, err := c.gcm.Open(nil, nonce, ciphertext, nil) if err != nil { return "", fmt.Errorf("crypto: decryption failed: %w", err) } return string(plaintext), nil }