// Package crypto provides symmetric secret encryption helpers. // // The Cipher type wraps AES-256-GCM. A random 12-byte nonce is prepended to // the ciphertext so callers persist a single opaque blob. KEK material is // expected to be 32 bytes long; helpers accept hex/base64 encoded strings. package crypto import ( "crypto/aes" "crypto/cipher" "crypto/rand" "encoding/base64" "encoding/hex" "fmt" "io" ) // AES-256 key length in bytes. const aes256KeyBytes = 32 // Sentinel errors for callers that need to distinguish failure modes. var ( ErrInvalidKey = fmt.Errorf("crypto: invalid kek length") ErrCipherTextTooShort = fmt.Errorf("crypto: ciphertext too short") ) // Cipher encrypts and decrypts opaque byte payloads with AES-256-GCM. type Cipher struct { gcm cipher.AEAD } // NewAESGCM constructs a Cipher from a 32-byte key. func NewAESGCM(key []byte) (*Cipher, error) { if len(key) != aes256KeyBytes { return nil, fmt.Errorf("%w: expect %d bytes, got %d", ErrInvalidKey, aes256KeyBytes, len(key)) } block, err := aes.NewCipher(key) if err != nil { return nil, fmt.Errorf("crypto: create aes block: %w", err) } gcm, err := cipher.NewGCM(block) if err != nil { return nil, fmt.Errorf("crypto: create gcm: %w", err) } return &Cipher{gcm: gcm}, nil } // NewAESGCMFromString decodes the key as hex (64 chars) or standard base64 // (44 chars padded) and constructs an AES-256-GCM Cipher. func NewAESGCMFromString(encoded string) (*Cipher, error) { if encoded == "" { return nil, fmt.Errorf("%w: empty kek", ErrInvalidKey) } if len(encoded) == aes256KeyBytes*2 { key, err := hex.DecodeString(encoded) if err == nil { return NewAESGCM(key) } } if key, err := base64.StdEncoding.DecodeString(encoded); err == nil { return NewAESGCM(key) } if key, err := base64.RawStdEncoding.DecodeString(encoded); err == nil { return NewAESGCM(key) } return nil, fmt.Errorf("%w: must be hex(64) or base64(32 bytes)", ErrInvalidKey) } // Encrypt produces "nonce||ciphertext" raw bytes. func (c *Cipher) Encrypt(plaintext []byte) ([]byte, error) { nonce := make([]byte, c.gcm.NonceSize()) if _, err := io.ReadFull(rand.Reader, nonce); err != nil { return nil, fmt.Errorf("crypto: random nonce: %w", err) } ct := c.gcm.Seal(nil, nonce, plaintext, nil) out := make([]byte, 0, len(nonce)+len(ct)) out = append(out, nonce...) out = append(out, ct...) return out, nil } // Decrypt accepts the "nonce||ciphertext" raw bytes returned by Encrypt. func (c *Cipher) Decrypt(blob []byte) ([]byte, error) { ns := c.gcm.NonceSize() if len(blob) < ns+c.gcm.Overhead() { return nil, ErrCipherTextTooShort } nonce, ct := blob[:ns], blob[ns:] pt, err := c.gcm.Open(nil, nonce, ct, nil) if err != nil { return nil, fmt.Errorf("crypto: decrypt: %w", err) } return pt, nil } // EncryptToString returns base64 (std, no padding) for storage. func (c *Cipher) EncryptToString(plaintext []byte) (string, error) { blob, err := c.Encrypt(plaintext) if err != nil { return "", err } return base64.RawStdEncoding.EncodeToString(blob), nil } // DecryptFromString reverses EncryptToString. func (c *Cipher) DecryptFromString(s string) ([]byte, error) { blob, err := base64.RawStdEncoding.DecodeString(s) if err != nil { return nil, fmt.Errorf("crypto: decode base64: %w", err) } return c.Decrypt(blob) }