2026-08-09 18:03:09 +01:00
|
|
|
// 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"
|
2026-08-10 21:15:19 +01:00
|
|
|
"sync"
|
2026-08-09 18:03:09 +01:00
|
|
|
|
|
|
|
|
"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
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-10 21:15:19 +01:00
|
|
|
// keyCache memoizes deriveKey results so repeatedly encrypting/decrypting the
|
|
|
|
|
// same record (e.g. re-viewing the same folder) skips the HKDF recompute —
|
|
|
|
|
// this is a real, measured cost when done per-message across a folder
|
|
|
|
|
// listing. Keyed on the raw master key bytes (as a string; no new exposure,
|
|
|
|
|
// the same bytes already live in-process via MasterKey.Current/Previous)
|
|
|
|
|
// alongside recordID/purpose so a key-rotation's Current vs Previous never
|
|
|
|
|
// collide.
|
|
|
|
|
// ponytail: unbounded — every distinct (recordID,purpose) ever seen stays
|
|
|
|
|
// cached for the process lifetime. Add an LRU/TTL eviction if a long-uptime
|
|
|
|
|
// instance with a very large distinct-message count ever makes this a real
|
|
|
|
|
// memory concern; not needed for a first pass.
|
|
|
|
|
var keyCache sync.Map
|
|
|
|
|
|
|
|
|
|
type keyCacheKey struct {
|
|
|
|
|
master string
|
|
|
|
|
recordID string
|
|
|
|
|
purpose string
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-09 18:03:09 +01:00
|
|
|
// 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) {
|
2026-08-10 21:15:19 +01:00
|
|
|
ck := keyCacheKey{master: string(master), recordID: recordID, purpose: purpose}
|
|
|
|
|
if v, ok := keyCache.Load(ck); ok {
|
|
|
|
|
return v.([]byte), nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-09 18:03:09 +01:00
|
|
|
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)
|
|
|
|
|
}
|
2026-08-10 21:15:19 +01:00
|
|
|
keyCache.Store(ck, key)
|
2026-08-09 18:03:09 +01:00
|
|
|
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
|
|
|
|
|
}
|