add DKIM2 signing
Raphael Sprenger
Tue, 06 Oct 2026 00:38:14 +0200
7 files changed,
162 insertions(+),
31 deletions(-)
M
go.mod
→
go.mod
@@ -12,15 +12,18 @@ github.com/google/uuid v1.6.0
github.com/minio/sio v0.5.1 github.com/spf13/cobra v1.10.2 go.starlark.net v0.0.0-20260904161901-6ecada49e42f + go.turscar.ie/dkim2 v0.3.0 golang.org/x/crypto v0.56.0 modernc.org/sqlite v1.58.0 ) require ( + cloudeng.io/algo v0.0.0-20260424210818-1a3a569736a9 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/mattn/go-isatty v0.0.24 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/pkg/diff v0.0.0-20241224192749-4e6772a4315c // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/spf13/pflag v1.0.10 // indirect golang.org/x/sys v0.48.0 // indirect
M
go.sum
→
go.sum
@@ -1,3 +1,7 @@
+cloudeng.io/algo v0.0.0-20260424210818-1a3a569736a9 h1:HTKPq2VHKT7EagjnkunOblImEGxQcOJxHdmG+lQJV5Q= +cloudeng.io/algo v0.0.0-20260424210818-1a3a569736a9/go.mod h1:pjXLOZUvlHDj9nF7WsB2ECQVxmwneZ/VVOAkMAnaxaE= +cloudeng.io/errors v0.0.9 h1:gHv7vqtI8pvhjJFsu4d60bYwOaT9sM6tn0ykoaw11WA= +cloudeng.io/errors v0.0.9/go.mod h1:xWamLL6tn3roKI6MRRFkw1jUkJL9s7CJzFYfaxuhHZk= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=@@ -27,6 +31,10 @@ github.com/minio/sio v0.5.1 h1:sqtImrnCSHbDqO/lVy3tfbsctHzfelDv3NbXWEVcWT8=
github.com/minio/sio v0.5.1/go.mod h1:4ANoe4CCXqnt1FCiLM0+vlBUhhWZzVOhYCz0069KtFc= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc= +github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pkg/diff v0.0.0-20241224192749-4e6772a4315c h1:8TRxBMS/YsupXoOiGKHr9ZOXo+5DezGWPgBAhBHEHto= +github.com/pkg/diff v0.0.0-20241224192749-4e6772a4315c/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=@@ -38,6 +46,8 @@ github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.starlark.net v0.0.0-20260904161901-6ecada49e42f h1:m+AwfAsRB2E9t6xjYPQ/0quPxDjXihZSjVr03BmwTs8= go.starlark.net v0.0.0-20260904161901-6ecada49e42f/go.mod h1:Iue6g6iirlfLoVi/DYCi5/x0h/bAOuWF3dULTKpt2Vo= +go.turscar.ie/dkim2 v0.3.0 h1:0ZWzIrPotfEy2fCWcn86O4u47qNCSxGhYn/+9q1CP6Q= +go.turscar.ie/dkim2 v0.3.0/go.mod h1:3DGBsF9vbbAVcPPK8/0ms1751k56a5yS+VNhq4GnU4A= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
M
internal/dkim/dkim.go
→
internal/dkim/dkim.go
@@ -74,11 +74,7 @@
// PublicDNSRecord returns the expected DNS TXT record for the given selector/domain // as it should be published under selector._domainkey.domain. func (d *DKIM) PublicDNSRecord(ctx context.Context, selector, domain string) (string, error) { - dbRecord, err := d.db.GetDKIMForSelectorDomain(ctx, selector, domain) - if err != nil { - return "", err - } - privKey, err := d.readPrivateKey(dbRecord.BlobAddress) + privKey, _, err := d.retrievePrivateKey(ctx, selector, domain) if err != nil { return "", err }
M
internal/dkim/keys.go
→
internal/dkim/keys.go
@@ -1,6 +1,7 @@
package dkim import ( + "context" "crypto" "crypto/ed25519" "crypto/rand"@@ -9,6 +10,7 @@ "crypto/x509"
"encoding/base64" "encoding/pem" "fmt" + "io" "log" "strings"@@ -115,3 +117,32 @@ "p=" + base64.StdEncoding.EncodeToString(pubBytes),
} return strings.Join(params, "; ") } + +func (d *DKIM) retrievePrivateKey(ctx context.Context, selector string, domain string) (privateKey, string, error) { + var dbRecord model.DKIM + var err error + if selector == "" { + dbRecord, err = d.db.GetDKIMForDomain(ctx, domain) + } else { + dbRecord, err = d.db.GetDKIMForSelectorDomain(ctx, selector, domain) + } + if err != nil { + return nil, "", err + } + + pk, err := d.persistence.BlobReader(dbRecord.BlobAddress) + if err != nil { + return nil, "", err + } + defer func(privKey io.ReadCloser) { + _ = privKey.Close() + }(pk) + + privKeyBytes, err := io.ReadAll(pk) + if err != nil { + return nil, "", err + } + + privKey, err := decodePrivateKey(string(privKeyBytes)) + return privKey, dbRecord.Selector, err +}
M
internal/dkim/sign.go
→
internal/dkim/sign.go
@@ -12,6 +12,7 @@ "strings"
"time" "github.com/emersion/go-msgauth/dkim" + "go.turscar.ie/dkim2" ) func rfc6376Section541() []string {@@ -39,7 +40,7 @@ }
} // Sign signs the message with the first enabled selector for the From: domain and returns the signed message. -func (d *DKIM) Sign(ctx context.Context, message []byte) ([]byte, error) { +func (d *DKIM) Sign(ctx context.Context, message []byte, mailFrom string, rcptTo []string) ([]byte, error) { // TODO check if we can make this a zero-copy operation with io.Reader instead of message []byte // for DMARC alignment, we first need to find the From: domain msg, err := mail.ReadMessage(bytes.NewReader(message))@@ -63,12 +64,7 @@ domain := parsedFromHeader.Address[i+1:]
slog.Info("[DKIM] signing based on From: header", "domain", domain, "from", fromHeader) - dRec, err := d.db.GetDKIMForDomain(ctx, domain) - if err != nil { - return nil, fmt.Errorf("get DKIM for domain: %w", err) - } - - signer, err := d.readPrivateKey(dRec.BlobAddress) + signer, selector, err := d.retrievePrivateKey(ctx, "", domain) if err != nil { return nil, fmt.Errorf("reading signing key: %w", err) }@@ -81,7 +77,7 @@ }
err = dkim.Sign(&buffer, &buffer, &dkim.SignOptions{ Domain: domain, - Selector: dRec.Selector, + Selector: selector, Identifier: "", Signer: signer.(crypto.Signer), Hash: crypto.SHA256,@@ -92,26 +88,27 @@ Expiration: time.Time{},
QueryMethods: nil, }) if err != nil { - return nil, fmt.Errorf("creating signer: %w", err) + return nil, fmt.Errorf("signing DKIM: %w", err) } - return buffer.Bytes(), nil -} - -// readPrivateKey loads and decodes the private key blob referenced by nonceIdentifier. -func (d *DKIM) readPrivateKey(nonceIdentifier string) (privateKey, error) { - pk, err := d.persistence.BlobReader(nonceIdentifier) + err = dkim2.Sign(&buffer, &buffer, dkim2.SignOptions{ + Timestamp: time.Now().UTC().Unix(), + Domain: domain, + Exploded: false, + DoNotExplode: true, + DoNotModify: true, + Feedback: false, + FeedHere: false, + MailFrom: mailFrom, + RcptTo: rcptTo, + Keys: []dkim2.SigningKey{{ + Selector: selector, + Signer: signer.(crypto.Signer), + }}, + }) if err != nil { - return nil, err + return nil, fmt.Errorf("signing DKIM2: %w", err) } - defer func(privKey io.ReadCloser) { - _ = privKey.Close() - }(pk) - privKeyBytes, err := io.ReadAll(pk) - if err != nil { - return nil, err - } - - return decodePrivateKey(string(privKeyBytes)) + return buffer.Bytes(), nil }
A
internal/dkim/sign_test.go
@@ -0,0 +1,94 @@
+package dkim + +import ( + "bytes" + "context" + "crypto/rand" + "os" + "path" + "testing" + + "postern/internal/db" + "postern/internal/persistence" + + "go.turscar.ie/dkim2" +) + +type lookup struct { + pubRecord string +} + +func (l lookup) LookupTXT(_ context.Context, _ string) ([]string, error) { + return []string{l.pubRecord}, nil +} + +func TestSign(t *testing.T) { + d := setupTestCommand(t) + + pubRecord, err := d.Add(t.Context(), "sel1", "example.com") + if err != nil { + t.Fatal(err) + } + err = d.SetEnabled(t.Context(), "sel1", "example.com", true) + if err != nil { + t.Fatal(err) + } + + signedMessage, err := d.Sign(t.Context(), []byte(exampleMessage), "<raphael@example.com>", []string{"<someone@example.net>"}) + if err != nil { + t.Fatal(err) + } + + result := dkim2.Verify(t.Context(), bytes.NewReader(signedMessage), dkim2.VerifyOptions{ + IgnoreTimestamp: false, + Resolver: lookup{pubRecord}, + MailFrom: "<raphael@example.com>", + RcptTo: []string{"<someone@example.net>"}, + }) + + if result.Err != nil { + t.Fatal(result.Err) + } +} + +func setupTestCommand(t *testing.T) *DKIM { + t.Helper() + f := t.TempDir() + + mkeyPath := path.Join(f, "masterkey") + secretKey := make([]byte, 32) + _, _ = rand.Read(secretKey) + if err := os.WriteFile(mkeyPath, secretKey, 0666); err != nil { + t.Fatal(err) + } + + p, err := persistence.New(persistence.Config{ + BlobRoot: path.Join(f, "blobs"), + MasterkeyFile: mkeyPath, + }) + if err != nil { + t.Fatal(err) + } + + posternDB, err := db.OpenDB(&db.Config{ + DBPath: path.Join(f, "meta.db"), + }) + if err != nil { + t.Fatal(err) + } + + dkim := New(&Config{ + DB: posternDB, + Persistence: p, + }) + return dkim +} + +var exampleMessage = `Message-ID: <WIULIIOKPTXYAOVEIXQJJ@p25.dev> +From: "aw@example.com" <aw@example.com> +To: <raphael@p25.dev> +Subject: Hello from Postern! +Date: Sat, 18 Jun 2005 01:57:22 +0100 + +Hello test message. +`
M
internal/submission/submission.go
→
internal/submission/submission.go
@@ -226,7 +226,7 @@ }
message := buffer.Bytes() - signed, err := s.dkim.Sign(ctx, message) + signed, err := s.dkim.Sign(ctx, message, s.mailFrom, s.rcptTo) if err != nil { s.logger.ErrorContext(ctx, "dkim sign", "error", err) return errInternalServerError