DKIM signing with dkim-milter
dkim-milter is a Postfix milter that DKIM-signs each message with the key of its From: domain. Keys live per domain in a database table, private keys are encrypted with Vault Transit, and signing happens in memory.
It runs beside the Postfix relays of yarilo-postfix, after mailfrom-milter, which refuses a message whose From: does not match its envelope sender.
Signing a message
The signing domain is the domain of the first address in From:.
- Canonicalization is
relaxed/; the algorithm is RSA or Ed25519, by the key.simple Fromis signed twice, so aFromadded later breaks the signature.- The other signed fields:
Reply-To,Subject,Date,To,Cc,Message-ID,In-Reply-To,References,MIME-Version,Content-Type,Content-Transfer-Encoding,List-Id,List-Unsubscribe,List-Unsubscribe-Post. - The
DKIM-Signaturefield goes at the top of the header.
The body is streamed into the signature and never held, so memory does not grow with the message size. A message it cannot sign (no From domain, no key, a signing error) is accepted unsigned with one log line; the milter never refuses mail.
Who may have mail signed
Only a client that logged in with SASL has mail signed; Postfix's {auth_ macro names the login. SIGN_ chooses which logins:
| Value | Signed when |
|---|---|
aligned (default) | the login's domain is the From: domain |
any | any login; for a listener where only trusted accounts can log in |
Under aligned, bob@example. may send From: alice@example.; a subdomain is another domain. Mail without a login is never signed, and no setting changes that: a signer that signs whatever reaches it would sign for anyone who reaches Postfix.
yarilo-postfix runs two releases:
dkim-milter,aligned, for relay-mta, where yarilo's Submission and JMAP sending log in as the sending user.dkim-milter-fwd,anywithSIGN_, for relay-fwd-out, where only relay-fwd logs in.DOMAIN
Forwarded mail
relay-fwd carries mail yarilo makes (vacation replies, notifications) and mail it passes on (a Sieve redirect). A redirected copy did not come from its author's domain, so that domain's key must not sign it.
With SIGN_ set, the milter tells the two apart by the Received fields Postfix passes it:
SIGN_is the number of Postfix hops between yarilo and this signer. A message with no moreHOPS_ BEFORE Receivedfields than that was made by yarilo and is signed with itsFromdomain's key.- A message with more was forwarded. It is signed with the key of
SIGN_,DOMAIN d=that domain. The author's own signature stays as it was.
With relay-fwd passing mail to relay-fwd-out, which signs, the number is 1. Every hop added between yarilo and the signer raises it by one.
SIGN_ is also the SRS domain of postsrsd, so a forwarded message carries one domain in its envelope and its signature. That domain needs its own row in the key table and its own DNS records; see DNS for a mail domain.
WARNING
Set SIGN_ only on the listener where yarilo's Sieve sender logs in. On a listener for mail clients, a client's own MTA adds a Received, and the user's mail would be signed with the forwarder's key.
Keys
Keys are rows in the table dkim: domain, selector, public_, private_, created_. The repository ships the schema for PostgreSQL and MySQL.
- A domain may have one row per selector; its newest row signs.
- The domain is compared without case and surrounding spaces.
- With
KEY_,DECRYPT=vault private_is the PEM private key encrypted with the Vault Transit keykey_ enc dkim. The database, its dumps and its backups hold ciphertext only.
Key cache
Keys load on demand. The first message of a domain reads its row, decrypts that one key and keeps it in an LRU cache of KEY_ entries.
- After
KEY_the cached key keeps signing while one background read takes the row again, so a new selector shows within that time.CACHE_ TTL_ SECONDS - A domain with no usable row is remembered as keyless for
KEY_; its mail goes unsigned.CACHE_ MISS_ TTL_ SECONDS - A parsed RSA-2048 key takes about 5 KB, Ed25519 under 1 KB. The default of 10 000 keys fits the chart's 128Mi memory limit.
When the database or Vault fails, the cache keeps answering from what it has, past the TTLs. Only a domain the cache has never seen gets a temporary failure, and Postfix keeps the message and retries.
Adding a domain
Generate the key pair and encrypt the private key with Vault Transit:
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out example.org.key
openssl pkey -in example.org.key -pubout -outform DER | base64 -w0 > example.org.pub
vault write -field=ciphertext transit/encrypt/dkim plaintext="$(base64 -w0 < example.org.key)" > example.org.encInsert the row:
INSERT INTO dkim (domain, selector, public_key, private_key_enc)
VALUES ('example.org', 's2026', '<example.org.pub>', '<example.org.enc>');Publish the public key:
s2026._domainkey.example.org. 3600 IN TXT "v=DKIM1; k=rsa; p=<example.org.pub>"This sequence:
- keeps the private key in plain text only on the machine that made it; delete
example.once the row is in;org. key - for Ed25519, use
openssl genpkey -algorithm ED25519and publishk=ed25519with the raw 32-byte public key inp=.
Rotating a selector
- Publish the new selector's TXT record and wait until it resolves.
- Insert the new row. Being the newest, it signs within
KEY_.CACHE_ TTL_ SECONDS - Once the old selector's last signatures are older than the TXT record's TTL plus
KEY_, delete the old row, then its TXT record.CACHE_ TTL_ SECONDS
In that order every signature a receiver sees has its key in DNS.
Vault Transit
Create the Transit key, a decrypt-only policy and the Kubernetes auth role:
vault secrets enable transit
vault write -f transit/keys/dkim
vault policy write dkim-milter - <<'EOF'
path "transit/decrypt/dkim" { capabilities = ["update"] }
EOF
vault write auth/kubernetes/role/dkim-milter \
bound_service_account_names=dkim-milter,dkim-milter-fwd \
bound_service_account_namespaces=<namespace> \
policies=dkim-milter ttl=10mThe signer only decrypts; whoever adds keys needs transit/.
Postfix configuration
Add the milter after mailfrom-milter:
smtpd_milters = inet:mailfrom-milter:10031, inet:dkim-milter:8891
non_smtpd_milters = inet:dkim-milter:8891
milter_default_action = tempfailtempfail keeps mail queued while the signer is down instead of sending it unsigned. yarilo-postfix sets this through dkimMilter..
Readiness
/ answers 200 once the key query has run and, with Vault, a login has succeeded and the token may decrypt with the Transit key. A wrong query, a missing table or column, or a policy without decrypt keep the pod not ready, with the reason logged.
Environment
| Variable | Default |
|---|---|
LISTEN_ | 0. |
METRICS_ | 0. |
DB_ | postgres (or mysql) |
DB_ | none |
DB_ | 10 |
DKIM_ | the newest row of one domain |
SIGN_ | aligned |
SIGN_ | none |
SIGN_ | 0 |
KEY_ | vault (or none for PEM in the column) |
KEY_ | 10000 |
KEY_ | 600 |
KEY_ | 60 |
VAULT_, VAULT_ | none, required with vault |
VAULT_ | kubernetes |
VAULT_, VAULT_ | transit, dkim |
VAULT_ | none |
MILTER_ | 3600 |
MILTER_ | 60 |
LOG_ | none (debug for more) |
The Helm chart in the repository sets each of these from its values; the repository's README lists them.
Logs and metrics
Logs are JSON on stdout, one line per message: dkim: signed with domain, selector and queue id, or the reason it was not signed.
| Metric | Meaning |
|---|---|
dkim_ | signed, no_, no_, unauthorized, tempfail, error |
dkim_ | domains with a key in the cache |
dkim_ | hit, negative_, stale, miss |
dkim_ | db, vault, parse |