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 }