Files
honeyDueAPI/internal/services/encryption_service.go
Trey T 4abc57535e Add delete account endpoint and file encryption at rest
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)
2026-03-26 10:41:01 -05:00

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
}