Files
selfpost/internal/domain/dkim.go
T
mixandClaude Opus 5.5 faa3c11ae7
test / test (push) Waiting to run
panel-dkim-signing stage 2: OpenDKIM removed
The panel's signer is the only one. Gone: the opendkim package and its
configuration, the syslog-relay and opendkim programs, /dev/log and
/run/opendkim, the reload by USR1 through supervisord
(internal/supervisor/supervisor.go), internal/syslogrelay, the port of
OpenDKIM's From: parser (internal/milter/mailparse.go and its probe,
harness and test/upstream scripts), OPENDKIM_SOCKET and OPENDKIM_DIR.

Keys: internal/domain/keys.go (Keys, NewKeys, DKIM_DIR=/data/dkim) keeps
the keys and writes a selector table, /data/dkim/selectors ("<name>
<selector>"), which the signer reads in place of the KeyTable - it reads
no database, as the sender check reads none. The table is written before
the signer serves (EnsureServerSigned moved there from the notifier).
Keys and table 0600, directories 0700, the panel's. The entrypoint copies
/data/opendkim/keys once, as the panel user, into /data/dkim.new renamed
when whole, and stops the start if the copy fails; /data/opendkim is left
for a rollback to 2.7.x.

Sender check: one RFC 5322 address as a mail program reads it, held
against the bindings; the 256-byte refusal and the texts naming OpenDKIM
go. Allowed() folded the domain through strings.ToLower, which makes the
Kelvin sign a "k" - the comparison with OpenDKIM's reading had hidden it;
it folds ASCII only now.

Health: two dkim-signer rows (applications, notifications) in Milter
sockets, opendkim out of the liveness set; the card no longer takes any
row with a note for the spam filter. Texts as in the design commit.

KILL stays in deploy/docker-compose.yml: measured on the test server,
root without it cannot signal a process of another uid - supervisord
stopping the panel, Postfix's master its children. The plan's "only use
was the reload" was wrong; the comment now gives the real uses.

e2e: one signature, the panel's, verified by dkimpy; the /data/dkim modes;
the OpenDKIM log and process checks, testNeverSignedForAnother and the
cited opendkim, libopendkim11 and libmilter1.0.1 go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 01:05:39 +03:00

95 lines
3.4 KiB
Go

// Package domain owns SelfPost's sending-domain model: per-domain DKIM key
// generation, the selector table the panel's signer reads, and the
// orchestration that keeps the SQLite registry and the on-disk keys in
// agreement (architecture.md § DKIM signing). Key material lives under /data
// so it survives container restarts.
package domain
import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"fmt"
"os"
"github.com/mixeme/selfpost/internal/atomicfile"
)
// dkimKeyBits is the RSA key size for DKIM signing keys. 2048 is the DKIM
// interoperability sweet spot: strong, and short enough that the published
// public key still fits comfortably in a DNS TXT record.
const dkimKeyBits = 2048
// generateDKIMKey creates a fresh RSA private key for signing a domain.
func generateDKIMKey() (*rsa.PrivateKey, error) {
key, err := rsa.GenerateKey(rand.Reader, dkimKeyBits)
if err != nil {
return nil, fmt.Errorf("generate dkim key: %w", err)
}
return key, nil
}
// writePrivateKeyPEM writes key to path as a PKCS#1 "RSA PRIVATE KEY" PEM,
// atomically and readable by the panel user alone (0600): the panel's signer
// is the only reader (internal/milter, readKey()). The write is atomic (temp
// file + rename) so the signer never reads a half-written key.
func writePrivateKeyPEM(path string, key *rsa.PrivateKey) error {
block := &pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: x509.MarshalPKCS1PrivateKey(key),
}
return writeFileAtomic(path, pem.EncodeToMemory(block), 0o600)
}
// loadPrivateKeyPEM reads and parses a PKCS#1 RSA private key written by
// writePrivateKeyPEM. It is used to recompute the public DNS record on demand,
// keeping the private key file the single source of truth (product.md).
func loadPrivateKeyPEM(path string) (*rsa.PrivateKey, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
block, _ := pem.Decode(data)
if block == nil || block.Type != "RSA PRIVATE KEY" {
return nil, fmt.Errorf("dkim key %s: not a PKCS#1 RSA private key", path)
}
key, err := x509.ParsePKCS1PrivateKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("parse dkim key %s: %w", path, err)
}
return key, nil
}
// DKIMRecord is the DNS TXT record a user must publish for a domain (product.md).
type DKIMRecord struct {
// Name is the record's host, e.g. "selfpost._domainkey.example.com".
Name string
// Value is the TXT payload, e.g. "v=DKIM1; h=sha256; k=rsa; p=MIIB...".
Value string
}
// dkimRecord builds the published DKIM DNS record for a public key. The value
// is the one opendkim-genkey emitted: v=DKIM1, sha256, RSA, and the public key
// as base64-encoded SubjectPublicKeyInfo (PKIX) DER.
func dkimRecord(selector, domainName string, pub *rsa.PublicKey) (DKIMRecord, error) {
der, err := x509.MarshalPKIXPublicKey(pub)
if err != nil {
return DKIMRecord{}, fmt.Errorf("marshal dkim public key: %w", err)
}
p := base64.StdEncoding.EncodeToString(der)
return DKIMRecord{
Name: fmt.Sprintf("%s._domainkey.%s", selector, domainName),
Value: fmt.Sprintf("v=DKIM1; h=sha256; k=rsa; p=%s", p),
}, nil
}
// writeFileAtomic writes data to path via a temp file in the same directory
// followed by a rename, so readers only ever see the complete old or new file.
// It is the single safe-write primitive for DKIM keys and the selector table
// (security.md).
func writeFileAtomic(path string, data []byte, perm os.FileMode) error {
return atomicfile.Write(path, data, perm)
}