dkim

package module
v0.2.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 25, 2026 License: MIT Imports: 16 Imported by: 0

README

go-dkim

CI Go Reference Go Report Card

DKIM (RFC 6376) message signing and verification for Go — standard library only, no external dependencies.

About

DomainKeys Identified Mail lets a domain take responsibility for a message by attaching a cryptographic signature over selected header fields and the body. A verifier fetches the signer's public key from DNS (at <selector>._domainkey.<domain>) and confirms the message was not altered in transit.

This package signs and verifies over the raw RFC 5322 message bytes — never a parsed or reconstructed representation — so a signature is checked against exactly what was transmitted. That is the difference between a signature that survives real-world relays and one that only verifies against your own serializer.

Features

  • Sign with rsa-sha256; verify rsa-sha256 and legacy rsa-sha1, per RFC 6376.
  • Both simple and relaxed header and body canonicalization.
  • Operates on raw message bytes; the sign and verify paths share one canonicalizer.
  • Keypair generation and DNS TXT record rendering (GenerateKey, RecordValue, RecordFragment).
  • RFC 8601 result values (pass, fail, neutral, none, temperror, permerror).
  • Pluggable DNS resolver (TXTResolver) for tests and custom lookups.
  • Exported canonicalization primitives, so layered schemes such as ARC reuse the exact same code path.
  • Zero external dependencies.

Install

go get github.com/rest-mail/go-dkim

Quickstart

A full sign-then-verify round trip. In production you generate the keypair once, sign with the private half, and publish the public half as the DNS TXT record; verifiers then resolve it over real DNS (pass nil for the resolver). Here an in-memory resolver keeps the example self-contained.

package main

import (
	"context"
	"fmt"

	"github.com/rest-mail/go-dkim"
)

func main() {
	// Generate a keypair (do this once; keep the private key, publish the public).
	privPEM, pubPEM, err := dkim.GenerateKey(2048)
	if err != nil {
		panic(err)
	}
	key, err := dkim.ParsePrivateKey(privPEM)
	if err != nil {
		panic(err)
	}

	raw := []byte("From: alice@example.com\r\n" +
		"To: bob@example.net\r\n" +
		"Subject: hello\r\n" +
		"Date: Thu, 23 Jul 2026 10:00:00 +0000\r\n" +
		"Message-ID: <1@example.com>\r\n" +
		"\r\n" +
		"Hello, world!\r\n")

	// Sign returns the DKIM-Signature field VALUE; prepend the header yourself.
	value, err := dkim.Sign(raw, dkim.SignOptions{
		Domain:     "example.com",
		Selector:   "default",
		PrivateKey: key,
	})
	if err != nil {
		panic(err)
	}
	signed := append([]byte("DKIM-Signature: "+value+"\r\n"), raw...)

	// Verify. Pass nil for the resolver to use system DNS; here we serve the
	// public key we just generated from memory.
	txt, _ := dkim.RecordValue(pubPEM)
	resolver := func(_ context.Context, _ string) ([]string, error) {
		return []string{txt}, nil
	}
	for _, r := range dkim.Verify(context.Background(), signed, resolver) {
		fmt.Printf("d=%s s=%s -> %s\n", r.Domain, r.Selector, r.Result)
	}
	// Prints: d=example.com s=default -> pass
}

Signing

Sign computes a DKIM-Signature field value over a raw message and returns it; the caller prepends DKIM-Signature: and a trailing CRLF. Unset SignOptions fields fall back to documented defaults: relaxed/relaxed canonicalization and a signed header set of from:to:subject:date:message-id. Only headers actually present in the message are included in h=.

Generate a keypair and render its DNS TXT record with GenerateKey, RecordName, RecordValue and RecordFragment (the last splits values over 255 characters into multiple quoted strings so 2048-bit records stay valid).

Verifying

Verify returns one VerifyResult per DKIM-Signature header, in header order; an empty slice means the message carried no signature. Pass nil for the resolver to use system DNS, or inject a TXTResolver (its signature matches net.Resolver.LookupTXT) in tests. Each result's Result field is one of dkim.ResultPass, ResultFail, ResultNeutral, ResultNone, ResultTempError or ResultPermError (RFC 8601 dkim= values).

Primitives

The canonicalization and single-signature building blocks are exported — SplitMessage, CanonicalizeHeader, CanonicalizeBody, BuildSignedHeaders, VerifySignature, VerifySignatureBare, FetchKey, ParseTagList, ParseTagListStrict, RemoveBValue, StripWSP and HashBytes — so a layered scheme whose signature is structurally a DKIM-Signature (such as ARC's ARC-Message-Signature) can reuse the exact same canonicalization and crypto path.

For such a scheme, prefer VerifySignatureBare: it runs only the cryptographic mechanism (header/body canonicalization, hashing, and the RSA check against the d=/s= DNS key) and applies none of the RFC 6376 DKIM-Signature policy — no v= requirement, no From-signed rule, no i= alignment, no timing, no key-record t=s / s= flags — so a versionless ARC-Message-Signature (RFC 8617 §4.1.2) verifies and the scheme applies its own policy on top. VerifySignature is that same primitive plus the full DKIM policy, for standalone DKIM.

Documentation

Full API reference: pkg.go.dev/github.com/rest-mail/go-dkim.

Changelog

Recent releases (full history and detail in CHANGELOG.md):

  • 0.2.1 — add VerifySignatureBare, a policy-free verification primitive, restoring ARC compatibility (a versionless ARC-Message-Signature can be verified without inheriting DKIM policy).
  • 0.2.0 — enforce the key record's v=, s= service-type, t=s no-subdomain and h= hash-algorithm policy; require the signature v= tag; require From in h= when signing; accept unpadded base64. (FetchKey signature changed.)
  • 0.1.3 — reject From not signed, unaligned i=, duplicate tags, and expired/malformed signature timing; export ParseTagListStrict.
  • 0.1.2 — reject a non-digit l= body-length tag, fixing a remotely triggerable panic.
  • 0.1.1 — rename the module path to github.com/rest-mail/go-dkim.
  • 0.1.0 — initial release: RFC 6376 signing and verification, standard library only.

License

MIT © 2026 rest-mail

Documentation

Overview

Package dkim signs and verifies email messages with DomainKeys Identified Mail (DKIM, RFC 6376).

DKIM lets a domain take responsibility for a message by attaching a cryptographic signature over selected header fields and the body. A verifier fetches the signer's public key from DNS (at <selector>._domainkey.<domain>) and confirms the message was not altered in transit.

Signing and verification both operate on the raw RFC 5322 message bytes — never on a parsed or reconstructed representation — so a signature is checked against exactly what was transmitted. Both "simple" and "relaxed" header and body canonicalization are supported; signing uses rsa-sha256, and verification additionally accepts the legacy rsa-sha1 algorithm. The package depends only on the Go standard library.

Signing

Sign returns a DKIM-Signature field value over a raw message; the caller prepends the header field name and a trailing CRLF:

value, err := dkim.Sign(raw, dkim.SignOptions{
	Domain:     "example.com",
	Selector:   "default",
	PrivateKey: key,
})
signed := append([]byte("DKIM-Signature: "+value+"\r\n"), raw...)

GenerateKey, RecordName, RecordValue and RecordFragment produce a keypair and render the public half as the DNS TXT record verifiers look up.

Verifying

Verify returns one VerifyResult per DKIM-Signature header, in header order. A nil resolver uses the system DNS resolver:

for _, r := range dkim.Verify(ctx, raw, nil) {
	fmt.Println(r.Domain, r.Result)
}

Primitives

The canonicalization and single-signature primitives (SplitMessage, CanonicalizeHeader, CanonicalizeBody, BuildSignedHeaders, VerifySignature, FetchKey, ParseTagList, RemoveBValue, StripWSP, HashBytes) are exported so that layered schemes such as ARC — whose ARC-Message-Signature is structurally a DKIM-Signature — can reuse the exact same code path.

Example

Example signs a message and then verifies it. It uses an in-memory DNS resolver so the round trip is self-contained. In production you generate the keypair once, sign with the private half, publish the public half as the DNS TXT record at <selector>._domainkey.<domain>, and let Verify resolve it over system DNS by passing a nil resolver.

package main

import (
	"context"
	"fmt"

	"github.com/rest-mail/go-dkim"
)

func main() {
	// Generate a keypair. Keep the private key for signing; publish the public
	// half as the DNS record verifiers look up.
	privPEM, pubPEM, err := dkim.GenerateKey(2048)
	if err != nil {
		panic(err)
	}
	key, err := dkim.ParsePrivateKey(privPEM)
	if err != nil {
		panic(err)
	}

	raw := []byte("From: alice@example.com\r\n" +
		"To: bob@example.net\r\n" +
		"Subject: hello\r\n" +
		"Date: Thu, 23 Jul 2026 10:00:00 +0000\r\n" +
		"Message-ID: <1@example.com>\r\n" +
		"\r\n" +
		"Hello, world!\r\n")

	// Sign returns the DKIM-Signature field value; prepend the header yourself.
	value, err := dkim.Sign(raw, dkim.SignOptions{
		Domain:     "example.com",
		Selector:   "default",
		PrivateKey: key,
	})
	if err != nil {
		panic(err)
	}
	signed := append([]byte("DKIM-Signature: "+value+"\r\n"), raw...)

	// Serve the public key we just generated from memory, so the example needs
	// no real DNS. In production, pass nil to use the system resolver.
	txt, err := dkim.RecordValue(pubPEM)
	if err != nil {
		panic(err)
	}
	resolver := func(_ context.Context, _ string) ([]string, error) {
		return []string{txt}, nil
	}

	results := dkim.Verify(context.Background(), signed, resolver)
	r := results[0]
	fmt.Printf("d=%s s=%s -> %s\n", r.Domain, r.Selector, r.Result)
}
Output:
d=example.com s=default -> pass

Index

Examples

Constants

View Source
const (
	ResultPass      = "pass"
	ResultFail      = "fail"
	ResultNeutral   = "neutral"
	ResultNone      = "none"
	ResultTempError = "temperror"
	ResultPermError = "permerror"
)

Verification result strings, mirroring RFC 8601 dkim= values.

View Source
const DefaultSelector = "default"

DefaultSelector is the selector RecordName and RecordFragment fall back to when none is given: "default", i.e. default._domainkey.<domain>.

Variables

This section is empty.

Functions

func BuildSignedHeaders

func BuildSignedHeaders(hTag string, allHeaders []Header, sig Header, canon string) string

BuildSignedHeaders assembles the canonicalized header block that a signature's b= tag signs: each header named in hTag (a colon-separated list, matched bottom-up per RFC 6376 §5.4.2), followed by the signature header (sig) itself with its b= value emptied and NO trailing CRLF.

It is exported so a signer or a layered scheme (ARC's ARC-Message-Signature) can produce the exact bytes VerifySignature will hash.

func CanonicalizeBody

func CanonicalizeBody(body, canon string) string

CanonicalizeBody applies simple or relaxed body canonicalization (RFC 6376 §3.4.3 / §3.4.4). Input is a CRLF-normalized body (as returned by SplitMessage). The result always ends in exactly one CRLF, except that a relaxed canonicalization of an empty body is the empty string.

func CanonicalizeHeader

func CanonicalizeHeader(h Header, canon string) string

CanonicalizeHeader canonicalizes a single header field per RFC 6376 §3.4, using either "relaxed" or "simple" canonicalization. The returned string has NO trailing CRLF — the caller appends one between signed headers (and none after the trailing signature header being verified).

func GenerateKey

func GenerateKey(bits int) (privatePEM, publicPEM string, err error)

GenerateKey creates an RSA DKIM keypair of the given size in bits and returns both halves PEM-encoded. The private PEM is the signing key (pass it to ParsePrivateKey); the public PEM feeds RecordValue to build the DNS record. Use at least 2048 bits for production keys.

func HashBytes

func HashBytes(h crypto.Hash, data []byte) []byte

HashBytes hashes data with the given hash (crypto.SHA256 or crypto.SHA1, the two algorithms RFC 6376 defines) and returns the digest.

func ParsePrivateKey

func ParsePrivateKey(pemStr string) (*rsa.PrivateKey, error)

ParsePrivateKey parses a PEM-encoded RSA private key in PKCS#1 or PKCS#8 form.

func ParseTagList

func ParseTagList(s string) map[string]string

ParseTagList parses a DKIM tag=value list ("k=v; k2=v2") into a map. Keys and the ends of values are trimmed; internal FWS in values is preserved (callers strip it via StripWSP where it is insignificant, e.g. b=, bh=, p=). It parses DKIM-Signature, DKIM key records, and ARC header (i=, cv=, …) tag lists alike.

func ParseTagListStrict added in v0.1.3

func ParseTagListStrict(s string) (map[string]string, error)

ParseTagListStrict parses a DKIM tag=value list like ParseTagList but enforces the RFC 6376 §3.2 well-formedness rules the verification paths require:

  • "Tags with duplicate names MUST NOT occur within a single tag-list; if a tag name does occur more than once, the entire tag-list is invalid." Silently taking one of the values (as a last-wins parse does) lets two verifiers reach different verdicts about the same message, so a repeated tag name is an error.
  • A non-empty segment that lacks "=" is a malformed tag-spec and is an error (§6.1.1 cautions against being liberal in what is accepted). An empty segment — e.g. from the optional trailing ";" the ABNF allows, or surrounding folding whitespace — is ignored, not rejected.
  • A segment with an empty tag name ("=value") is malformed (tag-name = ALPHA *ALNUMPUNC) and is an error.

On any violation it returns a nil map and an error, so a caller verifying a DKIM-Signature or a DNS key record can PERMFAIL (or skip the record) rather than resolve a malformed list to an arbitrary value.

func RecordFragment

func RecordFragment(name, value string) string

RecordFragment renders a dnsmasq txt-record line for a DKIM record, splitting the value into <=255-char strings (the DNS TXT per-string limit) so that 2048-bit records — whose p= value exceeds 255 chars — stay valid:

txt-record=default._domainkey.d,"chunk1","chunk2"

func RecordName

func RecordName(selector, domain string) string

RecordName returns the DKIM record name <selector>._domainkey.<domain>.

func RecordValue

func RecordValue(publicKeyPEM string) (string, error)

RecordValue renders the DKIM DNS TXT value (v=DKIM1; k=rsa; p=<base64 DER>) from a PEM-encoded RSA public key (as produced by GenerateKey).

func RemoveBValue

func RemoveBValue(field string) string

RemoveBValue blanks the value of the b= tag in a signature field (a DKIM-Signature, ARC-Message-Signature, or ARC-Seal value) while preserving every other byte (including the "b=" itself), as required before canonicalizing the signature header for verification.

func Sign

func Sign(rawMessage []byte, opt SignOptions) (string, error)

Sign computes a DKIM-Signature header field VALUE (everything after "DKIM-Signature:") over the given raw RFC 5322 message. The caller prepends it as "DKIM-Signature: " + value + "\r\n".

Signing operates on the message's ACTUAL header/body bytes and shares its canonicalization with Verify (via BuildSignedHeaders and CanonicalizeBody), so a message signed here verifies here — and, because it signs the real transmitted bytes rather than a reconstruction, at any RFC 6376 verifier. Only headers that are actually present are included in h=.

func StripWSP

func StripWSP(s string) string

StripWSP removes all whitespace (SP, TAB, CR, LF) — used on base64 tag values (b=, bh=, p=) that may be folded across lines.

Types

type Header struct {
	// Name is the field name, e.g. "From" (whitespace-trimmed, original case).
	Name string
	// Value is everything after the colon, with folding CRLFs preserved and the
	// trailing CRLF stripped.
	Value string
	// Raw is the full field exactly as it appeared (name, colon, value, folds),
	// with no trailing CRLF — used by simple header canonicalization.
	Raw string
}

Header is one parsed header field of an RFC 5322 message: its name, its value (everything after the colon, folding CRLFs preserved, trailing CRLF stripped) and the full raw field. It is the unit SplitMessage produces and that CanonicalizeHeader / VerifySignature / BuildSignedHeaders consume.

func SplitMessage

func SplitMessage(raw []byte) ([]Header, string)

SplitMessage normalizes line endings to CRLF and splits a raw RFC 5322 message into ordered header fields and the body. Line-ending normalization (bare LF / lone CR → CRLF) reconstructs the canonical wire form the signer hashed, in case an intermediate stored the message with LF-only endings.

It is exported so that layered signature schemes (e.g. ARC) can parse a raw message into the same Header slice / body that Sign and Verify operate on.

type KeyFlags added in v0.2.0

type KeyFlags struct {
	// NoSubdomain reflects the key record's t=s flag (RFC 6376 §3.6.1): when set,
	// any signature carrying an i= (AUID) tag MUST have its domain equal to d=
	// exactly — subdomain AUIDs are not permitted. Absent the flag (the default),
	// a subdomain i= is allowed.
	NoSubdomain bool

	// NotForEmail reflects the key record's s= service-type tag (RFC 6376
	// §3.6.1): s= is a colon-separated list of the service types the key may be
	// used for, defaulting to "*" (all). When set — meaning s= was present and
	// listed neither "email" nor the wildcard "*" — the key MUST NOT be used to
	// verify an email signature. Absent the tag (default "*"), or a list that
	// includes "email" or "*", leaves it unset and the key usable for email.
	NotForEmail bool
}

KeyFlags carries the policy-bearing tags of the DKIM key record the verifier selected — the flags it must apply against the signature after the key itself resolves, as distinct from the record fields (p=, k=, h=) FetchKey consumes to produce the key. It is returned by FetchKey so a caller (VerifySignature, or a layered scheme reusing the same key path) can enforce them.

func FetchKey

func FetchKey(ctx context.Context, selector, domain, hashAlg string, resolver TXTResolver) (*rsa.PublicKey, KeyFlags, string)

FetchKey resolves and parses a signer's RSA public key from its DKIM key record at <selector>._domainkey.<domain>. hashAlg is the hash half of the verifying signature's a= tag ("sha256" / "sha1"); a key record whose h= tag is present but does not list hashAlg is ignored (RFC 6376 §3.6.1 / §6.1.2). On success it returns (key, flags, "") where flags carries the selected key record's policy tags (the t= flags, §3.6.1) for the caller to enforce against the signature; on failure it returns (nil, KeyFlags{}, result) where result is ResultTempError (transient DNS failure) or ResultPermError (missing, revoked, malformed, or hash-algorithm-disallowed key).

type SignOptions

type SignOptions struct {
	Domain      string          // d= signing domain (required)
	Selector    string          // s= selector (required)
	PrivateKey  *rsa.PrivateKey // signing key (required)
	Headers     []string        // headers to sign; default from:to:subject:date:message-id
	HeaderCanon string          // "relaxed" (default) or "simple"
	BodyCanon   string          // "relaxed" (default) or "simple"
	Time        int64           // t= value; 0 omits the tag
}

SignOptions configures DKIM signing. Zero-value fields fall back to the documented defaults.

type TXTResolver

type TXTResolver func(ctx context.Context, name string) ([]string, error)

TXTResolver looks up DNS TXT records for a name. It matches the signature of net.Resolver.LookupTXT so the default resolver can be used directly, and a stub can be injected in tests.

type VerifyResult

type VerifyResult struct {
	Domain   string // d= signing domain
	Selector string // s= selector
	Result   string // one of the Result* constants
	Reason   string // human-readable detail
}

VerifyResult is the outcome of verifying a single DKIM-Signature.

func Verify

func Verify(ctx context.Context, rawMessage []byte, resolver TXTResolver) []VerifyResult

Verify performs RFC 6376 DKIM verification against a raw RFC 5322 message.

Verification MUST run over the exact bytes that were signed — the header and body as transmitted — so this operates on the raw message, never on a parsed or reconstructed representation (reconstructing headers/body from structured fields would not reproduce the signer's canonicalization for anything but the simplest messages).

It returns one VerifyResult per DKIM-Signature header found, in header order. An empty slice means the message carried no DKIM-Signature. A nil resolver uses the system DNS resolver.

func VerifySignature

func VerifySignature(ctx context.Context, sig Header, allHeaders []Header, body string, resolver TXTResolver) VerifyResult

VerifySignature verifies a single DKIM-Signature header (sig) against the message it covers: allHeaders is the full ordered header block the signature's h= tag selects from, and body is the CRLF-normalized body (both as returned by SplitMessage). It performs body-hash, header-hash and public key checks and returns a VerifyResult.

It is the per-signature verification primitive underneath Verify and applies the FULL RFC 6376 DKIM-Signature policy: v= is required and must be 1, From must be signed, an i= (AUID) must be aligned with d=, timing (t=/x=) is enforced, and the key record's t=s / s= policy flags are applied.

A layered scheme (e.g. ARC) whose signature is structurally a DKIM-Signature but versionless and governed by its own rules should NOT call this — it would inherit DKIM policy that does not apply. Call VerifySignatureBare instead: the same canonicalization and crypto path with none of the DKIM policy, and apply the scheme's own policy on top.

func VerifySignatureBare added in v0.2.1

func VerifySignatureBare(ctx context.Context, sig Header, allHeaders []Header, body string, resolver TXTResolver) VerifyResult

VerifySignatureBare verifies ONLY the cryptographic mechanism of a DKIM-shaped signature and applies NONE of the RFC 6376 DKIM-Signature policy. It selects the h= headers, canonicalizes header and body per c= (honoring l=), hashes per a=, resolves the signer's public key from d=/s= via DNS, and checks the b= signature. It does NOT require a v= version tag, does NOT require From to be signed, does NOT check i= (AUID) alignment to d=, does NOT apply signature timing (t=/x=), and does NOT enforce the key record's t=s / s= policy flags.

It exists so a layered scheme can verify a signature that is structurally a DKIM-Signature but governed by its OWN policy, then apply that policy itself. The canonical case is ARC: an ARC-Message-Signature is versionless by design (RFC 8617 §4.1.2) and carries no author-alignment semantics, so verifying it under full DKIM policy is wrong. An ARC verifier calls VerifySignatureBare to check the AMS mechanism and then enforces RFC 8617 itself. Selecting the right key (the key record's own v=DKIM1 / h= constraints) is part of the mechanism and still applies.

For standalone DKIM use Verify or VerifySignature — which is exactly this primitive plus the full RFC 6376 policy — NOT this function.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL