diff --git a/pkg/storage/crypto.go b/pkg/storage/crypto.go new file mode 100644 index 0000000..fd1d5a4 --- /dev/null +++ b/pkg/storage/crypto.go @@ -0,0 +1,32 @@ +package storage + +import ( + "crypto/rand" + + "golang.org/x/crypto/argon2" +) + +const ( + argon2Time uint32 = 1 + argon2Memory uint32 = 64 * 1024 // 64 MB — RFC 9106 Section 7.3 + argon2Threads uint8 = 4 + argon2KeyLen uint32 = 32 // AES-256 key length + saltSize = 16 +) + +// DeriveKey produces a 32-byte AES-256 key from a passphrase and salt using Argon2id. +// Uses RFC 9106 Section 7.3 recommended parameters. +// Given the same passphrase and salt, always returns the same key. +func DeriveKey(passphrase []byte, salt []byte) []byte { + return argon2.IDKey(passphrase, salt, argon2Time, argon2Memory, argon2Threads, argon2KeyLen) +} + +// NewSalt generates a cryptographically random 16-byte salt. +// Store alongside the database and reuse on each key derivation. +func NewSalt() ([]byte, error) { + salt := make([]byte, saltSize) + if _, err := rand.Read(salt); err != nil { + return nil, err + } + return salt, nil +} diff --git a/pkg/storage/encrypt.go b/pkg/storage/encrypt.go new file mode 100644 index 0000000..fec367d --- /dev/null +++ b/pkg/storage/encrypt.go @@ -0,0 +1,52 @@ +package storage + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "errors" + "io" +) + +// ErrCiphertextTooShort is returned when ciphertext is shorter than the GCM nonce size. +var ErrCiphertextTooShort = errors.New("ciphertext too short") + +// Encrypt encrypts plaintext using AES-256-GCM with a random nonce. +// The nonce is prepended to the returned ciphertext. +// key must be exactly 32 bytes (AES-256). +func Encrypt(plaintext []byte, key []byte) ([]byte, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + nonce := make([]byte, gcm.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return nil, err + } + // Seal appends encrypted data to nonce, so nonce is prepended + ciphertext := gcm.Seal(nonce, nonce, plaintext, nil) + return ciphertext, nil +} + +// Decrypt decrypts ciphertext produced by Encrypt. +// Expects the nonce to be prepended to the ciphertext. +func Decrypt(ciphertext []byte, key []byte) ([]byte, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + nonceSize := gcm.NonceSize() + if len(ciphertext) < nonceSize { + return nil, ErrCiphertextTooShort + } + nonce, ciphertext := ciphertext[:nonceSize], ciphertext[nonceSize:] + return gcm.Open(nil, nonce, ciphertext, nil) +}