From 3b440c2a53621339d6358a7635149326b9c81eb7 Mon Sep 17 00:00:00 2001 From: Mix Date: Thu, 15 Oct 2026 22:44:58 +0300 Subject: [PATCH] plan: panel-dkim-signing, the draft with three open questions The signer in the panel's milter with go-msgauth/dkim v0.7.0 (sign.go read: streaming NewSigner, HeaderKeys over-signing, last-first picking), relaxed/simple, OpenDKIM's dkim_should_signhdrs[] plus From, the key by the login's domain and by the server's name for local mail, strict. From Postfix 3.10.13 milter8.c: multi-line headers come \n-separated, so the signer rebuilds them with CRLF. disable_mime_output_conversion=yes, unset until now. The oracle dkimpy (python3-dkim 1.1.8-2 in trixie). Three stages, 2.8.0. Open for the owner: where the signer runs, the key directory's name, Health's label. Co-Authored-By: Claude Opus 5.5 --- docs/plans/panel-dkim-signing.md | 185 +++++++++++++++++++++++++++++++ docs/roadmap.md | 2 +- 2 files changed, 186 insertions(+), 1 deletion(-) create mode 100644 docs/plans/panel-dkim-signing.md diff --git a/docs/plans/panel-dkim-signing.md b/docs/plans/panel-dkim-signing.md new file mode 100644 index 0000000..8247b38 --- /dev/null +++ b/docs/plans/panel-dkim-signing.md @@ -0,0 +1,185 @@ +# Plan: panel-dkim-signing + +**Status:** draft, 2026-10-15 - the row is agreed (owner, 2026-10-15, the +order port25-bare-newline -> base-trixie -> panel-dkim-signing); three +questions below are open and go to the owner one at a time. +**Date:** 2026-10-15 +**Version:** `2.8.0` MINOR (after base-trixie's `2.7.0`). No migration; +nothing for an operator to do. + +--- + +## Goal + +The panel signs outgoing mail itself, in its own milter, and OpenDKIM +leaves the image. The analysis ([own-mta-builds](../own-mta-builds-2026-10-15.md) +§ 3 E) and the roadmap section give the reasons: OpenDKIM is a 2018 beta +with three open 2026 CVEs and nobody upstream; every OpenDKIM limit +SelfPost works around goes with it (the syslog relay, the From: parser +port and its 256-byte refusal, the tables reloaded by a cross-uid signal +and `CAP_KILL`, keys shared through a group). + +What stays the same for an operator: the keys, the selectors, the DNS +records, the panel's pages (one label on Health excepted, Q3). + +## What the signer does + +- **Library:** `github.com/emersion/go-msgauth/dkim` v0.7.0 (MIT; the + author of the go-milter v0.4.1 we run; the e2e suite verifies with its + v0.6.8 today). Read for this plan, `dkim/sign.go`: `NewSigner` hashes the + body as it is written (an `io.Pipe` into a goroutine), so a message is + not buffered; `HeaderKeys` is written to `h=` in the order given, each + name picked last instance first (`header.go`, `newHeaderPicker`), and a + name listed once more than it occurs is kept in `h=` with nothing hashed + for it - the over-signing RFC 6376 § 5.4.2 describes; RSA-SHA256; the + signature is the caller's to insert. +- **What is signed:** `relaxed/simple`, as `build/opendkim.conf` signs + today. `h=`: every header of the message whose name is in OpenDKIM's + `dkim_should_signhdrs[]` (`libopendkim/dkim.c:223-246`: from, reply-to, + subject, date, to, cc, the five resent-*, in-reply-to, references, the + seven list-*), each occurrence, in message order, plus `From` once more + (`OversignHeaders From` today). Absent names are not listed, as + OpenDKIM does not list them. +- **Which key:** on 465/587 the key of the domain the SASL login belongs + to - `{auth_authen}`, then `Bindings.DomainOf(login)`, the table the + sender check already reads (`internal/milter/sender.go:262-269`). For the + panel's own notifications (`sendmail(1)`, `non_smtpd_milters`) the key of + the server's name, as `EnsureServerSigned` provides it today. The sender + check has already held `From:` to that same domain, so the signature's + `d=` is the `From:` domain DMARC aligns on. +- **What Postfix hands a milter,** read in Postfix 3.10.13, + `src/milter/milter8.c:2329-2400`, `milter8_header()`: Postfix's own + `Received:` is not shown (and an inserted header at index 0 lands above + it, "relative to the message as delivered"); a multi-line header comes + as text separated by `\n`; one space after the colon is eaten unless the + milter asks for `SMFIP_HDR_LEADSPC` (`OptHeaderLeadingSpace`, which the + sender check already asks for); body records end in `\r\n` + (`milter8_body()`, :2470-2500). The signer rebuilds each header as + `Name:` + value with `\n` made `\r\n` - go-msgauth's own + `cmd/dkim-milter` writes `name + ": " + value + "\r\n"` and would sign a + folded header wrongly. A unit test signs folded, tab-led and + space-free headers and an independent verifier checks them. +- **When it cannot sign** (no key file, a login with no domain, a read + error): temporary failure, `default_action=tempfail`, as OpenDKIM is + attached today. A message is never sent unsigned by accident. +- **8-bit mail:** `disable_mime_output_conversion=yes`. Postfix converts an + 8-bit message to 7-bit for a server that does not announce 8BITMIME + (default `no`, `src/global/mail_params.h:3088`), which breaks any body + hash - OpenDKIM's today as well. Not set until now; set with this row. + +## Decisions settled before this plan + +From the roadmap section and the analysis, agreed with the row: the +library; `relaxed/simple`; the header set and `From` over-signed; the key +by the login's domain and by the server's name for local mail; strict; +the removals below; keys, selectors and records unchanged; the e2e oracle +an independent verifier; a message to a real mailbox before the cut. + +Decided here (technical, the owner may overrule): + +- **The oracle is dkimpy** (`python3-dkim` 1.1.8-2 in trixie, + `/usr/bin/dkimverify`, seen in `debian:trixie` 2026-10-15) - a separate + implementation in another language, maintained, packaged by Debian; not + OpenDKIM's `opendkim-testmsg` (the code this row removes) and not + go-msgauth (the signer's own library). It runs in the e2e sink's + network against the stand's CoreDNS. +- **A cross-check before OpenDKIM goes:** on the stand of stage 1 both + sign the same messages for a while (OpenDKIM after the panel in the + chain) and the two signatures' `bh=` and `h=` are compared on every + e2e message; they must agree before stage 2 removes OpenDKIM. +- **The From: parser port goes.** It exists so that the sender check + judges the address OpenDKIM chooses the key by. With the key chosen by + the login, the check keeps `net/mail` (one RFC 5322 address at an + allowed domain) and the 256-byte refusal goes; the refusal texts that + name OpenDKIM are rewritten. + +## Open questions (to the owner, one at a time) + +- **Q1. Where the signer runs.** (a) Its own role in the panel process on + two sockets of its own: `sign.sock` for 465/587 (the key by the login) + and `sign-local.sock` for local submission (the server's key only), in + the chain where OpenDKIM is now: sender check, signer, journal. (b) + Inside the sender check's milter: one socket fewer, but the check would + receive every body, and local mail - which has no login - would need the + same milter to tell "no login" from "local", deciding a key by the + absence of something. **Recommended: (a)** - the socket a message comes + through, not a missing login, decides that the server's key may sign + it; the sender check stays a header-only milter; and moving the signer + into a process of its own later is a change of where it is started. +- **Q2. The key directory's name.** `/data/opendkim/keys//.private` + stays as it is, or becomes `/data/dkim/...` (the entrypoint moves the + tree once; KeyTable and SigningTable are deleted). A backup restores + only into the image of its own version, so no older archive meets the + new name. **Recommended: rename** - the cost is the entrypoint's move, + the paths in the code and the backup tests; a directory named after a + program the image no longer has would mislead whoever reads `/data`. +- **Q3. Health.** The Overview's socket check and the process list name + OpenDKIM and the syslog relay. **Recommended:** the socket row becomes + "DKIM signer" (the panel's `sign.sock`, checked the same way), the two + programs leave the process list. A mockup first, as for any page + change. + +## Stages + +### Stage 1 - the signer, beside OpenDKIM (no release) + +- [ ] `internal/milter/sign.go`: the session (Connect, MailFrom with + `{auth_authen}`, Header, Headers, BodyChunk, Body), `OptHeaderLeadingSpace`, + `InsertHeader(0, "DKIM-Signature", ...)` (the server's `Actions` gain + add-header), the key read per message from the key file (a small cache + keyed by path and mtime), the selector from the store as `Record` reads + it. +- [ ] Unit tests: sign and verify with go-msgauth; folded and tab-led + headers; a header absent and present twice; an empty body; a body + without a final newline; a login with no domain and a missing key file + answer tempfail; the server's socket never signs with a domain's key. +- [ ] `cmd/panel`: the role, its two sockets (`listenMilter`, group + selfpost, 0660), the wrapper waits for them. +- [ ] `build/postfix-config.sh`: `smtpd_milters` = sender, sign, OpenDKIM, + journal (OpenDKIM kept for the cross-check); `non_smtpd_milters` = + sign-local, OpenDKIM; `disable_mime_output_conversion=yes`. +- [ ] e2e: dkimpy in the stand; every positive send and the notification + verified by dkimpy against the panel's signature; the panel's and + OpenDKIM's `bh=`/`h=` compared. + +### Stage 2 - OpenDKIM removed (no release) + +- [ ] Chain: OpenDKIM out of both milter lists. +- [ ] Go: `internal/domain/opendkim.go` reduced to key storage (tables, + `Rebuild`, `assertConfigSafe`, the USR1 reload go), `internal/supervisor` + (its only caller is the reload), `internal/syslogrelay`, + `internal/milter/mailparse.go` and its tests and testdata, the + sender-check texts; Resync's "OpenDKIM's tables" step; Health's + liveness set and socket row (Q3); `OPENDKIM_SOCKET`/`OPENDKIM_DIR`. +- [ ] Image: the `opendkim` package, `opendkim.conf`, the two supervisord + programs, the `/dev/log` link and `/run/opendkim`, `usermod -aG selfpost + opendkim`, crashexit's spared list; `deploy/docker-compose.yml`'s `KILL` + capability (its only use was the reload). +- [ ] Keys: Q2. +- [ ] Tests: `test/upstream/*.sh`, e2e's OpenDKIM log and process checks, + the 256-byte test rewritten for the new rule. + +### Stage 3 - documents, the canary, the cut + +- [ ] architecture § OpenDKIM -> § DKIM signing; security.md (the + mail-path bullets, the accepted risks that were OpenDKIM's); guide; + development's cited-package table (opendkim and libopendkim11 leave); + the assumptions register's header notes what left; help (design commit + first) and Health's label (Q3, mockup first). +- [ ] Reviews: technical and security. +- [ ] On the test server: a day of the owner's mail; one message from an + application through 465 and one notification to a real mailbox, the + headers read (dkim=pass with the panel's `d=`). +- [ ] Cut `2.8.0`. + +## Risks + +- **A signing defect fails DMARC for every tenant at once.** The answer is + the oracle (another implementation), the cross-check against OpenDKIM on + the same messages before it goes, and the canary before the cut. +- **go-msgauth is small and released slowly.** It can be vendored if it + ever matters; the signing path is about 300 lines of it. +- **Rollback:** until stage 2 ships, the chain change is one line; after + the cut, the previous image (2.7.x) still has OpenDKIM and reads the same + keys - under Q2's rename, from the old path, which the move leaves as a + copy until the next release. diff --git a/docs/roadmap.md b/docs/roadmap.md index 37be878..9a85e0f 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -44,7 +44,7 @@ in `git log` and [CHANGELOG.md](../CHANGELOG.md). | own-mta-builds | Analysis: build Postfix and OpenDKIM ourselves, patched, instead of Debian's packages | analysed 2026-10-15: no own builds; three rows proposed instead | — | [own-mta-builds-2026-10-15.md](own-mta-builds-2026-10-15.md) | | port25-bare-newline | The inbound relay's port 25 refuses bare newlines in DATA (`smtpd_forbid_bare_newline=normalize`) | shipped `2.6.1` | — | — | | base-trixie | The image's base from Debian bookworm (LTS since 2026-07-12) to trixie: Postfix 3.10 with the 2026 security batches | agreed (2026-10-15; after port25-bare-newline, MINOR) | — | — | -| panel-dkim-signing | DKIM signing in the panel's own milter; OpenDKIM, the syslog relay and the parser port removed | agreed (2026-10-15; after base-trixie, MINOR) | — | — | +| panel-dkim-signing | DKIM signing in the panel's own milter; OpenDKIM, the syslog relay and the parser port removed | agreed (2026-10-15; after base-trixie, MINOR); plan drafted, three questions open | 0/14 | [plans/panel-dkim-signing.md](plans/panel-dkim-signing.md) | | phone-desktop-mode | A phone browser in "desktop site" mode gets the phone navigation | agreed (2026-10-13: after stages 1 and 2 of audit-2026-10-12) | — | — | | from-header-binding | Hold a message's `From:` header against the application that sends it | shipped `2.4.0` | 9/9 | [plans/from-header-binding.md](plans/from-header-binding.md) | | panel-redesign | Panel redesign (accepted mockups, design contract) | shipped `2.0.0` | 27/27 | [plans/panel-redesign.md](plans/panel-redesign.md) |