Files
mailgoserver/internal/mailstore/mailstore.go
T

123 lines
3.7 KiB
Go

// Package mailstore handles local mailbox storage: per-mailbox encryption at rest
// (a random AES-256 key per mailbox, sealed with one server-held master key so a raw
// DB/backup theft alone can't decrypt mail — see MasterKey below), on-disk ciphertext
// layout, and quota accounting. Message retrieval/IMAP serving is a later milestone;
// this package is the storage engine underneath it.
package mailstore
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"mailgoserver/internal/db"
)
// masterKeySize is 32 bytes (AES-256).
const masterKeySize = 32
// LoadOrCreateMasterKey reads the server's master encryption key from path, generating
// a fresh random one on first run if the file doesn't exist yet — mirrors
// tlsutil.GenerateSelfSignedCert's generate-if-missing pattern. This file must be
// backed up separately from the database: losing it makes every stored mailbox's mail
// permanently unrecoverable, even for admins.
func LoadOrCreateMasterKey(path string) ([]byte, error) {
if b, err := os.ReadFile(path); err == nil {
if len(b) != masterKeySize {
return nil, fmt.Errorf("master key at %s is %d bytes, want %d", path, len(b), masterKeySize)
}
return b, nil
} else if !os.IsNotExist(err) {
return nil, err
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return nil, err
}
key := make([]byte, masterKeySize)
if _, err := rand.Read(key); err != nil {
return nil, err
}
if err := os.WriteFile(path, key, 0o600); err != nil {
return nil, err
}
return key, nil
}
// Store is the local mailbox storage engine: one per running server, shared across
// connections (analogous to smtpserver.Backend).
type Store struct {
DB *db.DB
MasterKey []byte
BasePath string
}
func New(database *db.DB, masterKey []byte, basePath string) *Store {
return &Store{DB: database, MasterKey: masterKey, BasePath: basePath}
}
// GenerateDEK returns a fresh random AES-256 data encryption key for one mailbox.
func GenerateDEK() []byte {
dek := make([]byte, masterKeySize)
rand.Read(dek)
return dek
}
// WrapDEK seals dek with the server master key, returning the ciphertext and the
// nonce used for that one seal operation (both stored on the mailbox row).
func (s *Store) WrapDEK(dek []byte) (wrapped, nonce []byte, err error) {
return sealAESGCM(s.MasterKey, dek)
}
// UnwrapDEK reverses WrapDEK.
func (s *Store) UnwrapDEK(wrapped, nonce []byte) ([]byte, error) {
return openAESGCM(s.MasterKey, wrapped, nonce)
}
func sealAESGCM(key, plaintext []byte) (ciphertext, nonce []byte, err error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, nil, err
}
nonce = make([]byte, gcm.NonceSize())
if _, err := rand.Read(nonce); err != nil {
return nil, nil, err
}
return gcm.Seal(nil, nonce, plaintext, nil), nonce, nil
}
func openAESGCM(key, ciphertext, nonce []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
if len(nonce) != gcm.NonceSize() {
return nil, errors.New("mailstore: invalid nonce size")
}
return gcm.Open(nil, nonce, ciphertext, nil)
}
// sanitizePathSegment neuters filesystem-unsafe characters in a mailbox email address
// so it can be used directly as a directory name, mirroring
// smtpserver.sanitizePathSegment's spirit (that one only handles domains; this one
// also strips "@" and ":" since a full address is used here, not just a domain).
func sanitizePathSegment(s string) string {
for _, c := range []string{"/", "\\", ":", "@"} {
s = strings.ReplaceAll(s, c, "_")
}
return s
}