Files
2026-08-09 18:03:09 +01:00

167 lines
5.3 KiB
Go

// Package crypto provides encryption-at-rest for messages, contacts, and
// calendar data. Every record gets its own key, derived from the master key
// via HKDF — compromising one encrypted file never exposes the master key or
// any other record.
package crypto
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"golang.org/x/crypto/hkdf"
)
const (
keySize = 32 // AES-256
nonceSize = 12 // GCM standard nonce size
)
// MasterKey holds the decoded master key(s) used to derive per-record keys.
// Two keys allow decrypting old data during a key-rotation window: Current is
// used for new writes, Previous (if set) is tried as a fallback on decrypt.
type MasterKey struct {
Current []byte
Previous []byte // nil if not rotating
}
// LoadMasterKey decodes hex-encoded master key(s) from config/env values.
func LoadMasterKey(currentHex, previousHex string) (*MasterKey, error) {
cur, err := decodeKey(currentHex)
if err != nil {
return nil, fmt.Errorf("master key: %w", err)
}
mk := &MasterKey{Current: cur}
if previousHex != "" {
prev, err := decodeKey(previousHex)
if err != nil {
return nil, fmt.Errorf("previous master key: %w", err)
}
mk.Previous = prev
}
return mk, nil
}
func decodeKey(hexStr string) ([]byte, error) {
b, err := hex.DecodeString(hexStr)
if err != nil {
return nil, fmt.Errorf("invalid hex: %w", err)
}
if len(b) != keySize {
return nil, fmt.Errorf("must be %d bytes (%d hex chars), got %d bytes", keySize, keySize*2, len(b))
}
return b, nil
}
// deriveKey produces a per-record 32-byte key from the master key using HKDF-SHA256.
// recordID should be a stable, unique identifier for the record (e.g. message ID,
// contact UID) — using the same recordID always derives the same key, which is
// required for decryption to work.
func deriveKey(master []byte, recordID string, purpose string) ([]byte, error) {
info := []byte(purpose + ":" + recordID)
r := hkdf.New(sha256.New, master, nil, info)
key := make([]byte, keySize)
if _, err := io.ReadFull(r, key); err != nil {
return nil, fmt.Errorf("hkdf derive: %w", err)
}
return key, nil
}
// Encrypt encrypts plaintext with a key derived from the master key and
// recordID. purpose namespaces the derivation (e.g. "message", "contact",
// "calendar", "dkim-key") so the same recordID used for different data types
// never collides. Output format: [12-byte nonce][ciphertext][16-byte GCM tag].
func Encrypt(mk *MasterKey, recordID, purpose string, plaintext []byte) ([]byte, error) {
key, err := deriveKey(mk.Current, recordID, purpose)
if err != nil {
return nil, err
}
block, err := aes.NewCipher(key)
if err != nil {
return nil, fmt.Errorf("aes cipher: %w", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("gcm: %w", err)
}
nonce := make([]byte, nonceSize)
if _, err := rand.Read(nonce); err != nil {
return nil, fmt.Errorf("nonce: %w", err)
}
ciphertext := gcm.Seal(nil, nonce, plaintext, nil)
return append(nonce, ciphertext...), nil
}
// Decrypt decrypts data produced by Encrypt. It tries the current master key
// first, then falls back to the previous key (if set) — this lets records
// written before a key rotation still be read without a bulk re-encryption
// pass; callers should re-encrypt with the current key on next write if a
// fallback decrypt succeeds.
func Decrypt(mk *MasterKey, recordID, purpose string, data []byte) ([]byte, error) {
if len(data) < nonceSize {
return nil, fmt.Errorf("ciphertext too short")
}
nonce, ciphertext := data[:nonceSize], data[nonceSize:]
if pt, err := decryptWith(mk.Current, recordID, purpose, nonce, ciphertext); err == nil {
return pt, nil
}
if mk.Previous != nil {
if pt, err := decryptWith(mk.Previous, recordID, purpose, nonce, ciphertext); err == nil {
return pt, nil
}
}
return nil, fmt.Errorf("decrypt failed with current%s key",
map[bool]string{true: " and previous", false: ""}[mk.Previous != nil])
}
func decryptWith(master []byte, recordID, purpose string, nonce, ciphertext []byte) ([]byte, error) {
key, err := deriveKey(master, recordID, purpose)
if err != nil {
return nil, err
}
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
return gcm.Open(nil, nonce, ciphertext, nil)
}
// NeedsReencryption reports whether data was decrypted using the previous
// (not current) master key — callers use this to trigger lazy re-encryption
// on read during a key rotation window.
func NeedsReencryption(mk *MasterKey, recordID, purpose string, data []byte) bool {
if mk.Previous == nil || len(data) < nonceSize {
return false
}
nonce, ciphertext := data[:nonceSize], data[nonceSize:]
if _, err := decryptWith(mk.Current, recordID, purpose, nonce, ciphertext); err == nil {
return false // current key works fine
}
_, err := decryptWith(mk.Previous, recordID, purpose, nonce, ciphertext)
return err == nil
}
// GenerateMasterKeyHex is a convenience helper for CLI tooling / setup docs —
// produces a fresh random master key as hex, ready to paste into GOMAIL_MASTER_KEY.
func GenerateMasterKeyHex() (string, error) {
b := make([]byte, keySize)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}