first commit
This commit is contained in:
@@ -0,0 +1,166 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user