first commit
This commit is contained in:
@@ -0,0 +1,239 @@
|
||||
// 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()
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package dkim
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
msgdkim "github.com/emersion/go-msgauth/dkim"
|
||||
"mailgoserver/internal/db"
|
||||
)
|
||||
|
||||
func TestGenerateSignVerify(t *testing.T) {
|
||||
f, err := os.CreateTemp("", "dkim-test-*.db")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.Close()
|
||||
defer os.Remove(f.Name())
|
||||
|
||||
database, err := db.Open(f.Name())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer database.Close()
|
||||
|
||||
if _, err := database.Exec(`INSERT INTO esrv_domains (domain_name, is_active) VALUES ('example.com', 1)`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
mgr := New(database, 1024) // small key for test speed
|
||||
ok, err := mgr.GenerateDKIMKeypair("example.com", "sel1", false)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("GenerateDKIMKeypair: ok=%v err=%v", ok, err)
|
||||
}
|
||||
|
||||
rec, err := mgr.GetDKIMPublicKeyRecord("example.com")
|
||||
if err != nil || rec == nil {
|
||||
t.Fatalf("GetDKIMPublicKeyRecord: %v %v", rec, err)
|
||||
}
|
||||
if rec.Name != "sel1._domainkey.example.com" || rec.Type != "TXT" || !strings.HasPrefix(rec.Value, `"v=DKIM1; k=rsa; p=`) {
|
||||
t.Fatalf("unexpected DNS record: %+v", rec)
|
||||
}
|
||||
|
||||
msg := "From: sender@example.com\r\nTo: rcpt@example.org\r\nSubject: hi\r\nDate: Mon, 01 Jan 2024 00:00:00 +0000\r\nMessage-ID: <abc@example.com>\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=UTF-8\r\nContent-Transfer-Encoding: 7bit\r\n\r\nhello world\r\n"
|
||||
signed := mgr.Sign(msg, "example.com")
|
||||
if signed == msg {
|
||||
t.Fatal("Sign did not add a signature")
|
||||
}
|
||||
if !strings.HasPrefix(signed, "DKIM-Signature:") {
|
||||
t.Fatalf("expected DKIM-Signature as first header, got: %s", signed[:60])
|
||||
}
|
||||
|
||||
// Verify against the key we just generated instead of a live DNS lookup
|
||||
// (example.com has no real TXT record for our test selector).
|
||||
verifications, err := msgdkim.VerifyWithOptions(strings.NewReader(signed), &msgdkim.VerifyOptions{
|
||||
LookupTXT: func(domain string) ([]string, error) {
|
||||
if domain != rec.Name {
|
||||
t.Fatalf("unexpected TXT lookup domain: %s (want %s)", domain, rec.Name)
|
||||
}
|
||||
return []string{strings.Trim(rec.Value, `"`)}, nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Verify error: %v", err)
|
||||
}
|
||||
if len(verifications) != 1 {
|
||||
t.Fatalf("expected 1 verification, got %d", len(verifications))
|
||||
}
|
||||
if verifications[0].Err != nil {
|
||||
t.Fatalf("verification failed: %v", verifications[0].Err)
|
||||
}
|
||||
|
||||
// Re-signing must strip the old signature, not stack two.
|
||||
resigned := mgr.Sign(signed, "example.com")
|
||||
if strings.Count(resigned, "DKIM-Signature:") != 1 {
|
||||
t.Fatalf("expected exactly one DKIM-Signature after re-sign, got: %s", resigned)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user