// 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 }