// Package domain owns SelfPost's sending-domain model: per-domain DKIM key // generation, the OpenDKIM KeyTable/SigningTable that drive signing, and the // orchestration that keeps the SQLite registry, the on-disk keys and OpenDKIM // in agreement (architecture.md § OpenDKIM). Key material lives under /data so // it survives container restarts (architecture.md § OpenDKIM). 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 group-readable (0640). The file is owned by the panel user and // read by OpenDKIM through the shared `selfpost` group (see build/opendkim.conf // and entrypoint.sh); the parent directory carries setgid so the group is // inherited. The write is atomic (temp file + rename) so OpenDKIM never observes // a half-written key. // // OpenDKIM 2.11.0~beta2, libopendkim/dkim.c, dkim_privkey_load(): a key that // starts as PEM goes to OpenSSL's PEM_read_bio_PrivateKey(), which takes this // PKCS#1 form; opendkim/opendkim.c, dkimf_add_signrequest(): a key file // dkimf_securefile() finds insecure is only logged while RequireSafeKeys is no // (build/opendkim.conf). 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), 0o640) } // 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 // mirrors what opendkim-genkey emits: 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 OpenDKIM tables // (security.md). func writeFileAtomic(path string, data []byte, perm os.FileMode) error { return atomicfile.Write(path, data, perm) }