123 lines
3.7 KiB
Go
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
|
||
|
|
}
|