From 239e2c214c698e776326f74899f3c2f7eb95f812 Mon Sep 17 00:00:00 2001 From: salvacybersec Date: Sun, 5 Apr 2026 00:04:33 +0300 Subject: [PATCH] feat(01-foundation-03): implement AES-256-GCM encryption and Argon2id key derivation - Encrypt/Decrypt using AES-256-GCM with random nonce prepended to ciphertext - ErrCiphertextTooShort sentinel error for malformed ciphertext - DeriveKey using Argon2id RFC 9106 params (time=1, mem=64MB, threads=4, keyLen=32) - NewSalt generates cryptographically random 16-byte salt --- pkg/storage/crypto.go | 32 ++++++++++++++++++++++++++ pkg/storage/encrypt.go | 52 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+) create mode 100644 pkg/storage/crypto.go create mode 100644 pkg/storage/encrypt.go 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) +}