Files
mailgoserver/internal/dkim/dkim.go
T
2026-08-12 12:56:22 +01:00

240 lines
6.7 KiB
Go

// Package dkim manages per-domain DKIM keys and signs outbound mail, mirroring
// email_server/dkim_manager.py.
package dkim
import (
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"fmt"
"math/big"
"strings"
"time"
msgdkim "github.com/emersion/go-msgauth/dkim"
"mailgoserver/internal/db"
)
// FixedHeaders is the exact 8-header list DKIM signs over, in this fixed order,
// mirroring dkim_manager.sign_email's `headers` list.
var FixedHeaders = []string{
"from", "to", "subject", "date", "message-id", "mime-version", "content-type", "content-transfer-encoding",
}
const selectorChars = "abcdefghijklmnopqrstuvwxyz0123456789"
// GenerateSelector mirrors DKIMManager._generate_random_selector(length=12).
func GenerateSelector() string {
b := make([]byte, 12)
max := big.NewInt(int64(len(selectorChars)))
for i := range b {
n, _ := rand.Int(rand.Reader, max)
b[i] = selectorChars[n.Int64()]
}
return string(b)
}
// Manager mirrors DKIMManager, keyed to a DB handle.
type Manager struct {
DB *db.DB
KeySize int
}
func New(database *db.DB, keySize int) *Manager {
if keySize == 0 {
keySize = 2048
}
return &Manager{DB: database, KeySize: keySize}
}
// GenerateDKIMKeypair mirrors DKIMManager.generate_dkim_keypair. Returns false if the
// domain doesn't exist (looked up by exact name, active or not — matching the Python
// query, which has no is_active filter here).
func (m *Manager) GenerateDKIMKeypair(domainName, selector string, forceNewKey bool) (bool, error) {
dom, err := m.DB.GetDomainByNameExact(domainName)
if err != nil {
return false, err
}
if dom == nil {
return false, nil
}
now := time.Now()
if _, err := m.DB.Exec(`UPDATE esrv_dkim_keys SET is_active = 0, replaced_at = ? WHERE domain_id = ? AND is_active = 1`, now, dom.ID); err != nil {
return false, err
}
if selector == "" {
selector = GenerateSelector()
}
if !forceNewKey {
existing, err := m.DB.GetDKIMKeyByDomainAndSelector(dom.ID, selector)
if err != nil {
return false, err
}
if existing != nil {
if _, err := m.DB.Exec(`UPDATE esrv_dkim_keys SET is_active = 1, replaced_at = NULL WHERE id = ?`, existing.ID); err != nil {
return false, err
}
return true, nil
}
}
priv, err := rsa.GenerateKey(rand.Reader, m.KeySize)
if err != nil {
return false, err
}
privPEM, pubPEM, err := encodeKeyPair(priv)
if err != nil {
return false, err
}
if _, err := m.DB.Exec(`INSERT INTO esrv_dkim_keys (domain_id, selector, private_key, public_key, is_active, created_at)
VALUES (?, ?, ?, ?, 1, ?)`, dom.ID, selector, privPEM, pubPEM, now); err != nil {
return false, err
}
return true, nil
}
func encodeKeyPair(priv *rsa.PrivateKey) (privPEM, pubPEM string, err error) {
privBytes, err := x509.MarshalPKCS8PrivateKey(priv)
if err != nil {
return "", "", err
}
privPEM = string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: privBytes}))
pubBytes, err := x509.MarshalPKIXPublicKey(&priv.PublicKey)
if err != nil {
return "", "", err
}
pubPEM = string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubBytes}))
return privPEM, pubPEM, nil
}
// GetActiveDKIMKey mirrors DKIMManager.get_active_dkim_key.
func (m *Manager) GetActiveDKIMKey(domainName string) (*db.DKIMKey, error) {
dom, err := m.DB.GetDomainByName(domainName)
if err != nil || dom == nil {
return nil, err
}
return m.DB.GetActiveDKIMKeyByDomainID(dom.ID)
}
// DNSRecord is the DNS TXT record for a domain's active DKIM key, mirroring
// DKIMManager.get_dkim_public_key_record's return shape.
type DNSRecord struct {
Name string
Type string
Value string
}
// GetDKIMPublicKeyRecord mirrors DKIMManager.get_dkim_public_key_record.
func (m *Manager) GetDKIMPublicKeyRecord(domainName string) (*DNSRecord, error) {
key, err := m.GetActiveDKIMKey(domainName)
if err != nil || key == nil {
return nil, err
}
raw := rawBase64FromPEM(key.PublicKey)
return &DNSRecord{
Name: fmt.Sprintf("%s._domainkey.%s", key.Selector, domainName),
Type: "TXT",
Value: fmt.Sprintf(`"v=DKIM1; k=rsa; p=%s"`, raw),
}, nil
}
func rawBase64FromPEM(pemStr string) string {
block, _ := pem.Decode([]byte(pemStr))
if block == nil {
return ""
}
return base64.StdEncoding.EncodeToString(block.Bytes)
}
// Sign mirrors DKIMManager.sign_email: strips any existing DKIM-Signature header,
// signs over the fixed 8-header list with relaxed/relaxed canonicalization, and
// returns the original content unmodified on any failure (including "no active key").
func (m *Manager) Sign(content, domainName string) string {
key, err := m.GetActiveDKIMKey(domainName)
if err != nil || key == nil {
return content
}
block, _ := pem.Decode([]byte(key.PrivateKey))
if block == nil {
return content
}
privAny, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
return content
}
priv, ok := privAny.(*rsa.PrivateKey)
if !ok {
return content
}
stripped := stripExistingSignature(content)
var out strings.Builder
err = msgdkim.Sign(&out, strings.NewReader(stripped), &msgdkim.SignOptions{
Domain: domainName,
Selector: key.Selector,
Signer: priv,
Hash: crypto.SHA256,
HeaderCanonicalization: msgdkim.CanonicalizationRelaxed,
BodyCanonicalization: msgdkim.CanonicalizationRelaxed,
HeaderKeys: FixedHeaders,
})
if err != nil {
return content
}
return out.String()
}
// stripExistingSignature removes a pre-existing DKIM-Signature header (including any
// folded continuation lines), mirroring the regex in dkim_manager.sign_email.
func stripExistingSignature(content string) string {
lines := strings.Split(content, "\n")
var out []string
skipping := false
for _, line := range lines {
lower := strings.ToLower(line)
if !skipping && strings.HasPrefix(lower, "dkim-signature:") {
skipping = true
continue
}
if skipping {
if len(line) > 0 && (line[0] == ' ' || line[0] == '\t') {
continue // folded continuation line
}
skipping = false
}
out = append(out, line)
}
return strings.Join(out, "\n")
}
// GetActiveCustomHeaders mirrors DKIMManager.get_active_custom_headers.
func (m *Manager) GetActiveCustomHeaders(domainName string) ([][2]string, error) {
dom, err := m.DB.GetDomainByName(domainName)
if err != nil || dom == nil {
return nil, err
}
rows, err := m.DB.Query(`SELECT header_name, header_value FROM esrv_custom_headers WHERE domain_id = ? AND is_active = 1`, dom.ID)
if err != nil {
return nil, err
}
defer rows.Close()
var out [][2]string
for rows.Next() {
var name, value string
if err := rows.Scan(&name, &value); err != nil {
return nil, err
}
out = append(out, [2]string{name, value})
}
return out, rows.Err()
}