Delete Account (Plan #2): - DELETE /api/auth/account/ with password or "DELETE" confirmation - Cascade delete across 15+ tables in correct FK order - Auth provider detection (email/apple/google) for /auth/me/ - File cleanup after account deletion - Handler + repository tests (12 tests) Encryption at Rest (Plan #3): - AES-256-GCM envelope encryption (per-file DEK wrapped by KEK) - Encrypt on upload, auto-decrypt on serve via StorageService.ReadFile() - MediaHandler serves decrypted files via c.Blob() - TaskService email image loading uses ReadFile() - cmd/migrate-encrypt CLI tool with --dry-run for existing files - Encryption service + storage service tests (18 tests)
180 lines
5.1 KiB
Go
180 lines
5.1 KiB
Go
package services
|
|
|
|
import (
|
|
"crypto/aes"
|
|
"crypto/cipher"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"io"
|
|
)
|
|
|
|
const (
|
|
// encryptionVersion is the current file format version byte.
|
|
encryptionVersion byte = 0x01
|
|
|
|
// aes256KeyLen is the required key length in bytes for AES-256.
|
|
aes256KeyLen = 32
|
|
|
|
// gcmNonceSize is the standard GCM nonce size (12 bytes).
|
|
gcmNonceSize = 12
|
|
|
|
// gcmTagSize is the standard GCM authentication tag size (16 bytes).
|
|
gcmTagSize = 16
|
|
|
|
// encryptedDEKSize is the encrypted DEK length: 32-byte DEK + 16-byte GCM tag.
|
|
encryptedDEKSize = aes256KeyLen + gcmTagSize
|
|
|
|
// headerSize is the fixed header: version(1) + KEK nonce(12) + encrypted DEK(48) + DEK nonce(12).
|
|
headerSize = 1 + gcmNonceSize + encryptedDEKSize + gcmNonceSize
|
|
)
|
|
|
|
// EncryptionService provides AES-256-GCM envelope encryption for files at rest.
|
|
type EncryptionService struct {
|
|
kek []byte // Key Encryption Key (32 bytes)
|
|
}
|
|
|
|
// NewEncryptionService creates an EncryptionService from a 64-character hex-encoded KEK.
|
|
func NewEncryptionService(hexKey string) (*EncryptionService, error) {
|
|
if len(hexKey) != 64 {
|
|
return nil, fmt.Errorf("encryption key must be exactly 64 hex characters (got %d)", len(hexKey))
|
|
}
|
|
|
|
kek, err := hex.DecodeString(hexKey)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid hex in encryption key: %w", err)
|
|
}
|
|
|
|
if len(kek) != aes256KeyLen {
|
|
return nil, fmt.Errorf("decoded key must be %d bytes", aes256KeyLen)
|
|
}
|
|
|
|
return &EncryptionService{kek: kek}, nil
|
|
}
|
|
|
|
// IsEnabled returns true if the encryption service is configured and ready.
|
|
func (s *EncryptionService) IsEnabled() bool {
|
|
return s != nil && len(s.kek) == aes256KeyLen
|
|
}
|
|
|
|
// Encrypt encrypts plaintext using envelope encryption (random DEK encrypted with KEK).
|
|
//
|
|
// File format:
|
|
//
|
|
// [1-byte version 0x01]
|
|
// [12-byte KEK nonce]
|
|
// [48-byte encrypted DEK (32-byte DEK + 16-byte GCM tag)]
|
|
// [12-byte DEK nonce]
|
|
// [ciphertext + 16-byte GCM tag]
|
|
func (s *EncryptionService) Encrypt(plaintext []byte) ([]byte, error) {
|
|
// Generate a random Data Encryption Key (DEK)
|
|
dek := make([]byte, aes256KeyLen)
|
|
if _, err := io.ReadFull(rand.Reader, dek); err != nil {
|
|
return nil, fmt.Errorf("failed to generate DEK: %w", err)
|
|
}
|
|
|
|
// Encrypt the DEK with the KEK
|
|
kekBlock, err := aes.NewCipher(s.kek)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create KEK cipher: %w", err)
|
|
}
|
|
kekGCM, err := cipher.NewGCM(kekBlock)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create KEK GCM: %w", err)
|
|
}
|
|
|
|
kekNonce := make([]byte, gcmNonceSize)
|
|
if _, err := io.ReadFull(rand.Reader, kekNonce); err != nil {
|
|
return nil, fmt.Errorf("failed to generate KEK nonce: %w", err)
|
|
}
|
|
encryptedDEK := kekGCM.Seal(nil, kekNonce, dek, nil)
|
|
|
|
// Encrypt the plaintext with the DEK
|
|
dekBlock, err := aes.NewCipher(dek)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create DEK cipher: %w", err)
|
|
}
|
|
dekGCM, err := cipher.NewGCM(dekBlock)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create DEK GCM: %w", err)
|
|
}
|
|
|
|
dekNonce := make([]byte, gcmNonceSize)
|
|
if _, err := io.ReadFull(rand.Reader, dekNonce); err != nil {
|
|
return nil, fmt.Errorf("failed to generate DEK nonce: %w", err)
|
|
}
|
|
ciphertext := dekGCM.Seal(nil, dekNonce, plaintext, nil)
|
|
|
|
// Pack the output: version + kekNonce + encryptedDEK + dekNonce + ciphertext
|
|
out := make([]byte, 0, headerSize+len(ciphertext))
|
|
out = append(out, encryptionVersion)
|
|
out = append(out, kekNonce...)
|
|
out = append(out, encryptedDEK...)
|
|
out = append(out, dekNonce...)
|
|
out = append(out, ciphertext...)
|
|
|
|
return out, nil
|
|
}
|
|
|
|
// Decrypt reverses the Encrypt operation, recovering the original plaintext.
|
|
func (s *EncryptionService) Decrypt(blob []byte) ([]byte, error) {
|
|
if len(blob) < headerSize {
|
|
return nil, fmt.Errorf("ciphertext too short (%d bytes, minimum %d)", len(blob), headerSize)
|
|
}
|
|
|
|
// Parse version
|
|
version := blob[0]
|
|
if version != encryptionVersion {
|
|
return nil, fmt.Errorf("unsupported encryption version: 0x%02x", version)
|
|
}
|
|
|
|
offset := 1
|
|
|
|
// Parse KEK nonce
|
|
kekNonce := blob[offset : offset+gcmNonceSize]
|
|
offset += gcmNonceSize
|
|
|
|
// Parse encrypted DEK
|
|
encryptedDEK := blob[offset : offset+encryptedDEKSize]
|
|
offset += encryptedDEKSize
|
|
|
|
// Parse DEK nonce
|
|
dekNonce := blob[offset : offset+gcmNonceSize]
|
|
offset += gcmNonceSize
|
|
|
|
// Remaining bytes are the ciphertext + GCM tag
|
|
ciphertext := blob[offset:]
|
|
|
|
// Decrypt the DEK with the KEK
|
|
kekBlock, err := aes.NewCipher(s.kek)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create KEK cipher: %w", err)
|
|
}
|
|
kekGCM, err := cipher.NewGCM(kekBlock)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create KEK GCM: %w", err)
|
|
}
|
|
|
|
dek, err := kekGCM.Open(nil, kekNonce, encryptedDEK, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to decrypt DEK (wrong key?): %w", err)
|
|
}
|
|
|
|
// Decrypt the plaintext with the DEK
|
|
dekBlock, err := aes.NewCipher(dek)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create DEK cipher: %w", err)
|
|
}
|
|
dekGCM, err := cipher.NewGCM(dekBlock)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create DEK GCM: %w", err)
|
|
}
|
|
|
|
plaintext, err := dekGCM.Open(nil, dekNonce, ciphertext, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to decrypt data (tampered?): %w", err)
|
|
}
|
|
|
|
return plaintext, nil
|
|
}
|