panel-dkim-signing stage 2: OpenDKIM removed
test / test (push) Canceled after 0s

The panel's signer is the only one. Gone: the opendkim package and its
configuration, the syslog-relay and opendkim programs, /dev/log and
/run/opendkim, the reload by USR1 through supervisord
(internal/supervisor/supervisor.go), internal/syslogrelay, the port of
OpenDKIM's From: parser (internal/milter/mailparse.go and its probe,
harness and test/upstream scripts), OPENDKIM_SOCKET and OPENDKIM_DIR.

Keys: internal/domain/keys.go (Keys, NewKeys, DKIM_DIR=/data/dkim) keeps
the keys and writes a selector table, /data/dkim/selectors ("<name>
<selector>"), which the signer reads in place of the KeyTable - it reads
no database, as the sender check reads none. The table is written before
the signer serves (EnsureServerSigned moved there from the notifier).
Keys and table 0600, directories 0700, the panel's. The entrypoint copies
/data/opendkim/keys once, as the panel user, into /data/dkim.new renamed
when whole, and stops the start if the copy fails; /data/opendkim is left
for a rollback to 2.7.x.

Sender check: one RFC 5322 address as a mail program reads it, held
against the bindings; the 256-byte refusal and the texts naming OpenDKIM
go. Allowed() folded the domain through strings.ToLower, which makes the
Kelvin sign a "k" - the comparison with OpenDKIM's reading had hidden it;
it folds ASCII only now.

Health: two dkim-signer rows (applications, notifications) in Milter
sockets, opendkim out of the liveness set; the card no longer takes any
row with a note for the spam filter. Texts as in the design commit.

KILL stays in deploy/docker-compose.yml: measured on the test server,
root without it cannot signal a process of another uid - supervisord
stopping the panel, Postfix's master its children. The plan's "only use
was the reload" was wrong; the comment now gives the real uses.

e2e: one signature, the panel's, verified by dkimpy; the /data/dkim modes;
the OpenDKIM log and process checks, testNeverSignedForAnother and the
cited opendkim, libopendkim11 and libmilter1.0.1 go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mix
2026-10-16 01:05:39 +03:00
co-authored by Claude Opus 5.5
parent 005922bc7d
commit faa3c11ae7
96 changed files with 971 additions and 2247 deletions
-327
View File
@@ -1,327 +0,0 @@
package milter
// The address OpenDKIM signs for, found as OpenDKIM finds it: a port of
// libopendkim's dkim_mail_parse() and its tokenizer, from Debian's
// opendkim 2.11.0~beta2-9.1+b1 (libopendkim/dkim-mailparse.c, identical
// there to upstream's develop branch), line for line. OpenDKIM calls it on the
// value of the message's first From: field (opendkim/opendkim.c, mlfi_eoh(),
// dkimf_findheader(dfc, "from", 0): build/opendkim.conf sets no SenderHeaders),
// lower-cases the domain it returns, and signs with the key whose SigningTable
// pattern matches "user@domain" (dkimf_apply_signtable()). Whatever the port
// returns, OpenDKIM does: quoted strings and domain literals are kept with
// their delimiters, comments are dropped, a source route resets the address,
// an '@' before the first ':', ';' or ',' decides where the address starts,
// and encoded words are not decoded.
//
// The C works in place on a NUL-terminated buffer, writing the result over
// the input; here the input is read through at(), which gives 0 at its end as
// the C finds the terminator, and the result goes to a buffer of its own, in
// which 0 ends a string as '\0' does there.
// mailparse errors, DKIM_MAILPARSE_ERR_* (dkim-mailparse.c).
const (
mailparseOK = 0
mailparsePUnbalanced = 1 // unbalanced parentheses
mailparseQUnbalanced = 2 // unbalanced quotes
mailparseSUnbalanced = 3 // unbalanced square brackets
)
// mailparseSpecials is SPECIALS: "<>@,;:\\\"/[]?=".
var mailparseSpecials = func() (t [256]bool) {
for _, c := range []byte("<>@,;:\\\"/[]?=") {
t[c] = true
}
return t
}()
// mailparseSpace is isascii(c) && isspace(c) in the C locale.
func mailparseSpace(c byte) bool {
return c == ' ' || (c >= '\t' && c <= '\r')
}
type mailparser struct {
b []byte // the input up to its first NUL, as strlen() sees it
}
// at is *p: the byte, or the terminator at and beyond the end.
func (m *mailparser) at(i int) byte {
if i >= len(m.b) {
return 0
}
return m.b[i]
}
// matchingParen is dkim_mail_matching_paren(): the position of the close
// character that balances the open one before s, or e. An open character of
// 0 is never met inside the string, so for quotes and brackets it only skips
// to the close, past backslash escapes.
func (m *mailparser) matchingParen(s, e int, open, close byte) int {
paren := 1
for ; s < e; s++ {
c := m.b[s]
if c == close {
paren--
if paren == 0 {
break
}
} else if c == open {
paren++
} else if c == '\\' {
if m.at(s+1) != 0 {
s++
}
}
}
return s
}
// firstSpecial is dkim_mail_first_special(): the '<' that opens an address,
// or the ':', ';' or ',' that ends a part - or the '@' seen before it - or
// the end.
func (m *mailparser) firstSpecial(p, e int) (int, int) {
atPtr := -1
for ; p < e && m.at(p) != 0; p++ {
// skip white space between tokens
for p < e && (m.at(p) == '(' || mailparseSpace(m.at(p))) {
if m.at(p) != '(' {
p++
} else {
p = m.matchingParen(p+1, e, '(', ')')
if m.at(p) == 0 {
return p, mailparsePUnbalanced
}
p++
}
}
if m.at(p) == 0 {
break
}
c := m.at(p)
switch {
case c == '"':
p = m.matchingParen(p+1, e, 0, '"')
if m.at(p) == 0 {
return p, mailparseQUnbalanced
}
case c == '[':
p = m.matchingParen(p+1, e, 0, ']')
if m.at(p) == 0 {
return p, mailparseSUnbalanced
}
case mailparseSpecials[c]:
if c == '<' {
return p, mailparseOK
} else if c == ':' || c == ';' || c == ',' {
if atPtr >= 0 {
return atPtr, mailparseOK
}
return p, mailparseOK
} else if c == '@' {
atPtr = p
}
default:
for m.at(p) != 0 && !mailparseSpecials[m.at(p)] && !mailparseSpace(m.at(p)) && m.at(p) != '(' {
p++
}
p--
}
}
return p, mailparseOK
}
// token is dkim_mail_token(): the next token from s - a quoted string or a
// domain literal with its delimiters, a special character alone, or an atom
// ('x') - its type (0 at the end), and whether whitespace outside a comment
// came before it (ws is set, never cleared, as in the C).
func (m *mailparser) token(s, e int, ws *bool) (typ int, start, end int, err int) {
p := s
// skip white space between tokens
for p < e && (m.at(p) == '(' || mailparseSpace(m.at(p))) {
if m.at(p) != '(' {
*ws = true
p++
} else {
p = m.matchingParen(p+1, e, '(', ')')
if m.at(p) == 0 {
return 0, 0, 0, mailparsePUnbalanced
}
p++
}
}
if p >= e || m.at(p) == 0 {
return 0, 0, 0, mailparseOK
}
start = p
c := m.at(p)
switch {
case c == '"':
end = m.matchingParen(p+1, e, 0, '"')
typ = '"'
if m.at(end) != 0 {
end++
} else {
err = mailparseQUnbalanced
}
case c == '[':
end = m.matchingParen(p+1, e, 0, ']')
typ = '['
if m.at(end) != 0 {
end++
} else {
err = mailparseSUnbalanced
}
case mailparseSpecials[c]:
end = p + 1
typ = int(c)
default:
for p < e && m.at(p) != 0 && !mailparseSpecials[m.at(p)] && !mailparseSpace(m.at(p)) && m.at(p) != '(' {
p++
}
end = p
typ = 'x'
}
return typ, start, end, err
}
// mailParsed is what dkim_mail_parse() returns: the local part and the
// domain, each absent (NULL) or a string, and the error. rest is not the
// C's: where in the line it stopped reading, for the caller's question
// whether anything follows the address.
type mailParsed struct {
user, domain string
hasUser, hasDomain bool
err int
rest int
}
// dkimMailParse is dkim_mail_parse().
func dkimMailParse(line string) mailParsed {
if i := indexByte(line, 0); i >= 0 {
line = line[:i] // strlen()
}
m := &mailparser{b: []byte(line)}
e := len(m.b)
var out []byte
user, domain := -1, -1
result := func(err, rest int) mailParsed {
r := mailParsed{err: err, rest: rest}
if user >= 0 {
r.user, r.hasUser = cstring(out[user:]), true
}
if domain >= 0 {
r.domain, r.hasDomain = cstring(out[domain:]), true
}
return r
}
ws := false
pos := 0
for {
special, err := m.firstSpecial(pos, e)
if err != 0 {
return result(err, special)
}
switch m.at(special) {
case '<':
// display name <address>
pos = special + 1
for {
typ, ts, te, err := m.token(pos, e, &ws)
if err != 0 {
return result(err, pos)
}
if typ == '>' || typ == 0 {
out = append(out, 0)
if typ == 0 {
te = e
}
return result(mailparseOK, te)
} else if typ == '@' {
out = append(out, 0)
domain = len(out)
} else if typ == ',' || typ == ':' {
// source route punctuation
user, domain = -1, -1
} else {
if user < 0 {
user = len(out)
}
out = append(out, m.b[ts:te]...)
}
pos = te
}
case ';', ':', ',':
// skip a group name or result
pos = special + 1
default:
// (display name) addr(display name)ess
ws = false
for {
typ, ts, te, err := m.token(pos, e, &ws)
if err != 0 {
return result(err, pos)
}
if typ == 0 || typ == ',' || typ == ';' {
out = append(out, 0)
if typ == 0 {
te = e
}
return result(mailparseOK, te)
} else if typ == '@' {
out = append(out, 0)
domain = len(out)
ws = false
} else {
if user < 0 {
user = len(out)
} else if typ == 'x' && ws {
out = append(out, ' ')
}
out = append(out, m.b[ts:te]...)
ws = false
}
pos = te
}
}
}
}
// moreAfter says whether anything but list and group punctuation follows
// position rest of line: another address, or text after the one found.
func moreAfter(line string, rest int) bool {
if i := indexByte(line, 0); i >= 0 {
line = line[:i]
}
m := &mailparser{b: []byte(line)}
ws := false
for pos := rest; ; {
typ, _, te, err := m.token(pos, len(m.b), &ws)
if err != 0 {
return true
}
switch typ {
case 0:
return false
case ',', ';':
pos = te
default:
return true
}
}
}
func indexByte(s string, c byte) int {
for i := 0; i < len(s); i++ {
if s[i] == c {
return i
}
}
return -1
}
// cstring reads a NUL-terminated string from b.
func cstring(b []byte) string {
if i := indexByte(string(b), 0); i >= 0 {
return string(b[:i])
}
return string(b)
}
-163
View File
@@ -1,163 +0,0 @@
package milter
import (
"bufio"
"encoding/hex"
"fmt"
"math/rand"
"os"
"os/exec"
"strings"
"testing"
)
// mailparseCases are lines whose result is written down here, read off
// dkim-mailparse.c by hand and confirmed against it by
// TestMailParseMatchesOpenDKIM.
var mailparseCases = []struct {
line string
user, domain string // "-" for absent
err int
}{
{"user@example.com", "user", "example.com", 0},
{" Name <user@example.com>", "user", "example.com", 0},
{`"Some One" <user@example.com>`, "user", "example.com", 0},
{"user@example.com (Some One)", "user", "example.com", 0},
{"Some One <user@ex(comment)ample.com>", "user", "example.com", 0},
// An '@' before the first ',' decides: the address is read from the start.
{"ceo@other.com <me@mine.com>", "me", "mine.com", 0},
{"me@mine.com, you@yours.com", "me", "mine.com", 0},
// Quoted local parts keep their quotes; an '@' inside them is no '@'.
{`"x@other.com"@mine.com`, `"x@other.com"`, "mine.com", 0},
{`<"a b"@mine.com>`, `"a b"`, "mine.com", 0},
// A source route is dropped.
{"<@relay.example,@r2.example:u@mine.com>", "u", "mine.com", 0},
// Group syntax: the name is skipped.
{"group: u@mine.com;", "u", "mine.com", 0},
// No local part: the domain is taken for it, so OpenDKIM has both.
{"@mine.com", "mine.com", "mine.com", 0},
// Whitespace between atoms of the default form becomes one space.
{"a b@mine.com", "a b", "mine.com", 0},
// Unbalanced.
{"Name (comment <u@mine.com>", "-", "-", mailparsePUnbalanced},
{`"open <u@mine.com>`, "-", "-", mailparseQUnbalanced},
{"u@[1.2.3.4", "-", "-", mailparseSUnbalanced},
// Encoded words are not decoded.
{"=?utf-8?q?boss=40other.com?= <u@mine.com>", "u", "mine.com", 0},
// No address at all.
{"Undisclosed recipients:;", "-", "-", 0},
{"", "-", "-", 0},
}
func parsedParts(r mailParsed) (string, string) {
u, d := "-", "-"
if r.hasUser {
u = r.user
}
if r.hasDomain {
d = r.domain
}
return u, d
}
func TestMailParseCases(t *testing.T) {
for _, c := range mailparseCases {
r := dkimMailParse(c.line)
u, d := parsedParts(r)
if r.err != c.err || (c.err == 0 && (u != c.user || d != c.domain)) {
t.Errorf("%q: user %q domain %q err %d; want %q %q %d", c.line, u, d, r.err, c.user, c.domain, c.err)
}
}
}
func TestMoreAfter(t *testing.T) {
for line, want := range map[string]bool{
"user@example.com": false,
"Name <user@example.com>": false,
"Name <user@example.com> (note)": false,
"group: u@mine.com;": false,
"me@mine.com, you@yours.com": true,
"Name <a@x>, b@y": true,
"Name <a@x> trailing": true,
} {
r := dkimMailParse(line)
if got := moreAfter(line, r.rest); got != want {
t.Errorf("%q: moreAfter %v, want %v", line, got, want)
}
}
}
// mailparseCorpus is the cases above and n random lines over an alphabet
// weighted toward what the parser treats specially.
func mailparseCorpus(n int) []string {
lines := []string{}
for _, c := range mailparseCases {
lines = append(lines, c.line)
}
alphabet := []string{"<", ">", "@", ",", ";", ":", "\\", `"`, "/", "[", "]", "?", "=", "(", ")",
" ", "\t", "\n", "\r", ".", "a", "b", "x", "mine.com", "other.com", "\xc3\xa9", "\xff", "=?utf-8?q?", "?="}
rng := rand.New(rand.NewSource(20261015))
for i := 0; i < n; i++ {
var b strings.Builder
for j := rng.Intn(24); j >= 0; j-- {
b.WriteString(alphabet[rng.Intn(len(alphabet))])
}
lines = append(lines, b.String())
}
return lines
}
// TestMailParseMatchesOpenDKIM feeds the corpus to OpenDKIM's own C
// dkim_mail_parse() through testdata/mailparse_harness.c, built against the
// opendkim source the image runs, and wants the port's answer for every line.
// It runs where MAILPARSE_C names the built harness.
func TestMailParseMatchesOpenDKIM(t *testing.T) {
bin := os.Getenv("MAILPARSE_C")
if bin == "" {
t.Skip("MAILPARSE_C not set (see testdata/mailparse_harness.c)")
}
lines := mailparseCorpus(200000)
var in strings.Builder
for _, l := range lines {
in.WriteString(hex.EncodeToString([]byte(l)))
in.WriteByte('\n')
}
cmd := exec.Command(bin)
cmd.Stdin = strings.NewReader(in.String())
out, err := cmd.Output()
if err != nil {
t.Fatalf("harness: %v", err)
}
sc := bufio.NewScanner(strings.NewReader(string(out)))
sc.Buffer(make([]byte, 1<<20), 1<<20)
enc := func(present bool, s string) string {
if !present {
return "-"
}
return "=" + hex.EncodeToString([]byte(s))
}
diffs, failed := 0, 0
for i := 0; sc.Scan(); i++ {
r := dkimMailParse(lines[i])
got := fmt.Sprintf("%d %s %s", r.err, enc(r.hasUser, r.user), enc(r.hasDomain, r.domain))
want := sc.Text()
if r.err != 0 {
// On an error the C returns before it writes the result's end, so
// what it prints of user and domain runs on into the unread
// input; OpenDKIM uses neither then (mlfi_eoh(): status != 0 is
// BADFORMAT, no signature). The error itself must agree.
failed++
got, want = fmt.Sprint(r.err), strings.Fields(want)[0]
}
if got != want {
diffs++
if diffs <= 20 {
t.Errorf("line %q: port %s, OpenDKIM %s", lines[i], got, want)
}
}
}
if diffs > 0 {
t.Fatalf("%d of %d lines differ", diffs, len(lines))
}
t.Logf("%d lines, the port and OpenDKIM agree on every one (%d of them errors, compared by code)", len(lines), failed)
}
+1 -1
View File
@@ -2,7 +2,7 @@
// sender check (sender.go) and the inbound journal (inbound.go).
//
// The journal-milter is a lightweight milter (architecture.md § Mail path)
// attached to Postfix's smtpd_milters alongside OpenDKIM. On the receive path it reads the SASL login, From, recipients and
// attached to Postfix's smtpd_milters after the sender check and the signer. On the receive path it reads the SASL login, From, recipients and
// Subject of each accepted message and records one send-log row per (queue-id,
// recipient), giving the panel a structured, filterable history that raw
// mail.log cannot.
-183
View File
@@ -1,183 +0,0 @@
package milter
import (
"bufio"
"fmt"
"os"
"strings"
"testing"
"time"
"github.com/emersion/go-milter"
)
// TestPortAgainstOpenDKIM checks the port where it matters, in OpenDKIM
// itself: for every From: line, the domain OpenDKIM signs the message for
// must be the one the port predicts - the port's address, matched against
// the SigningTable as dkimf_apply_signtable() matches it, cut to 256 bytes
// (MAXADDRESS) - or both must say no signature. It runs where
// OPENDKIM_PROBE_SOCKET names a running OpenDKIM's socket and
// OPENDKIM_PROBE_TABLE its refile SigningTable of "*@<domain>" lines:
// test/upstream/opendkim-probe.sh starts one from the image and runs this
// (audit 2026-10-12, stage 2c).
func TestPortAgainstOpenDKIM(t *testing.T) {
sock, table := os.Getenv("OPENDKIM_PROBE_SOCKET"), os.Getenv("OPENDKIM_PROBE_TABLE")
if sock == "" || table == "" {
t.Skip("OPENDKIM_PROBE_SOCKET and OPENDKIM_PROBE_TABLE not set")
}
signed := map[string]bool{}
f, err := os.Open(table)
if err != nil {
t.Fatal(err)
}
sc := bufio.NewScanner(f)
for sc.Scan() {
if fields := strings.Fields(sc.Text()); len(fields) > 0 && strings.HasPrefix(fields[0], "*@") {
signed[strings.ToLower(fields[0][2:])] = true
}
}
f.Close()
const own, other, server = "sender.e2e.test", "other.e2e.test", "mail.e2e.test"
lines := probeLines(t, own, other, server)
agree, signedCount := 0, 0
for i, line := range lines {
want := predictSigner(line, signed)
got, err := opendkimSigns(sock, line)
if err != nil {
t.Fatalf("From: %q: %v", line, err)
}
if i >= len(lines)-2 && got != other {
// The padded lines: cut at 256 bytes they end in "@<other>",
// and OpenDKIM signs them with that domain's key - the reason
// the sender check refuses an address over 256 bytes.
t.Errorf("a padded line of %d bytes: OpenDKIM signs for %q, not %q", len(line), got, other)
}
if got != "" {
signedCount++
}
if got != want {
t.Errorf("From: %q: OpenDKIM signs for %q, the port predicts %q", line, got, want)
continue
}
agree++
}
t.Logf("%d From: lines, OpenDKIM and the port agree on %d; %d signed", len(lines), agree, signedCount)
}
// predictSigner is the domain OpenDKIM signs a message with this From: line
// for, by the port: none when it reads no address; otherwise the domain of
// the SigningTable whose "^.*@<domain>$" (case-insensitive) matches
// "user@domain" as snprintf() leaves it in MAXADDRESS + 1 bytes, the domain
// first cut to DKIM_MAXHOSTNAMELEN by strlcpy() and lower-cased.
func predictSigner(line string, signed map[string]bool) string {
r := dkimMailParse(line)
if r.err != mailparseOK || !r.hasUser || !r.hasDomain || r.user == "" || r.domain == "" {
return ""
}
domain := r.domain
if len(domain) > 256 {
domain = domain[:256]
}
addr := r.user + "@" + asciiLower(domain)
if len(addr) > 256 {
addr = addr[:256]
}
d := asciiLower(addr[strings.LastIndexByte(addr, '@')+1:])
if signed[d] {
return d
}
return ""
}
// opendkimSigns plays one message to OpenDKIM as Postfix would for a client
// it trusts (127.0.0.1, its InternalHosts by default) and returns the d= of
// the signature it adds, lower-cased, or "".
func opendkimSigns(sock, from string) (string, error) {
cl := milter.NewClientWithOptions("unix", sock, milter.ClientOptions{
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
ActionMask: milter.OptAddHeader | milter.OptChangeHeader,
})
defer cl.Close()
s, err := cl.Session()
if err != nil {
return "", err
}
defer s.Close()
// A step OpenDKIM answers with anything but "continue" ends the message
// for it - an accept where it has nothing to do (libmilter, engine.c) -
// and it answers nothing after; no signature then.
step := func(act *milter.Action, err error) (bool, error) {
if err != nil {
return false, err
}
return act.Code == milter.ActContinue, nil
}
if ok, err := step(s.Conn("localhost", milter.FamilyInet, 40000, "127.0.0.1")); !ok {
return "", err
}
if ok, err := step(s.Mail("<probe@sender.e2e.test>", nil)); !ok {
return "", err
}
if ok, err := step(s.Rcpt("<user@elsewhere.example>", nil)); !ok {
return "", err
}
for _, h := range [][2]string{{"From", from}, {"To", "user@elsewhere.example"}, {"Subject", "probe"}, {"Date", "Thu, 15 Oct 2026 03:00:00 +0000"}} {
if ok, err := step(s.HeaderField(h[0], h[1])); !ok {
return "", err
}
}
if ok, err := step(s.HeaderEnd()); !ok {
return "", err
}
// No body chunk: OpenDKIM signs an empty body as well.
mods, _, err := s.End()
if err != nil {
return "", fmt.Errorf("end: %w", err)
}
for _, m := range mods {
if !strings.EqualFold(m.HeaderName, "DKIM-Signature") {
continue
}
for _, tag := range strings.Split(m.HeaderValue, ";") {
k, v, ok := strings.Cut(strings.TrimSpace(tag), "=")
if ok && strings.TrimSpace(k) == "d" {
return strings.ToLower(strings.TrimSpace(v)), nil
}
}
}
return "", nil
}
// probeLines are the shared files' lines and several hundred composed from
// pieces: display names plain, quoted, escaped and encoded, comments nested
// and left open, groups, lists, brackets, source routes, domain literals and
// quoted local parts - with the other sender's name, and the server's, in
// each place - and the line padded to be cut at 256 bytes.
func probeLines(t *testing.T, own, other, server string) []string {
var lines []string
for _, file := range []string{"from-lines-passed.txt", "from-lines-other.txt"} {
lines = append(lines, fromLines(t, file, own, other)...)
}
names := []string{"", "Shop ", `"Shop, Inc." `, `"x@` + other + `" `, "x@" + other + " ", `"a\"b <x@` + other + `>" `,
"=?utf-8?q?x=40" + other + "?= ", "(x@" + other + ") ", "((x@" + other + ")) ", "(open ", `"open `, "[x@" + other + "] ",
"group: ", other + ": "}
addrs := []string{
"a@" + own, "<a@" + own + ">", "<a@" + other + ">", "a@" + other, "<a@" + server + ">", "a@" + strings.ToUpper(own),
`<"x@` + other + `"@` + own + `>`, `"x@` + own + `"@` + other, "<@" + other + ":a@" + own + ">", "<@" + own + ":a@" + other + ">",
"a@[192.0.2.1]", "<a@" + own + ">, b@" + other, "a@" + own + ", <b@" + other + ">", "a(c)@" + own, "a@" + own + "(x@" + other + ")",
"a@ " + own, "a @" + own, "<a@" + own, "a@" + own + ";", "@" + own, "<>", "a", `"a b"@` + own, `a\@` + other + "@" + own,
"a@" + own + " x@" + other,
}
for _, n := range names {
for _, a := range addrs {
lines = append(lines, n+a)
}
}
// Cut at 256 bytes, the quoted local part ends in "@<other>".
pad := strings.Repeat("a", 256-len("@"+other)-1)
lines = append(lines, `"`+pad+`@`+other+`x"@`+own, `<"`+pad+`@`+other+`x"@`+own+`>`)
return lines
}
+29 -61
View File
@@ -22,12 +22,11 @@ import (
// The sender check: a message's From: line is held against the application
// that sends it, under the rule Postfix holds the envelope sender to
// (docs/plans/from-header-binding.md). OpenDKIM chooses the signing key by
// that line, so without the check an application could have mail signed as
// another domain of the server, or as the server itself.
//
// The line is read with OpenDKIM's own parser, ported (mailparse.go), so the
// address judged is the one OpenDKIM signs for.
// (docs/plans/from-header-binding.md). The signer signs with the key of the
// domain the login belongs to (sign.go), and a receiver holds that signature
// to the domain of this line (DMARC, RFC 7489 § 3.1.1), so without the check
// an application could send under the server's signature as another domain
// of the server, or as the server itself.
//
// Unlike the journal milter this one is attached strictly
// (default_action=tempfail in build/postfix-config.sh): while it does not
@@ -42,9 +41,7 @@ import (
// Postfix 3.10.13, src/smtpd/smtpd_check.c, reject_auth_sender_login_mismatch():
// the SASL login is compared with an owner by strcasecmp_utf8();
// src/milter/milter8.c, milter8_comm_error(): default_action=tempfail answers
// "451 4.7.1" for a milter that does not answer. OpenDKIM 2.11.0~beta2,
// opendkim/opendkim.c, mlfi_eoh(): with SenderHeaders unset the signing
// domain is that of the first From: field, as dkim_mail_parse() reads it.
// "451 4.7.1" for a milter that does not answer.
// tableFile is a table of the mail path that the panel rewrites and the check
// reads: it is read again when it has changed. An unreadable file is an error
@@ -208,8 +205,8 @@ func splitTableLine(line string) (key, value string, ok bool) {
// Case is folded in ASCII only. Postfix folds as Unicode (src/util/casefold.c
// with ICU, which Debian builds with), but every key and login the panel
// writes is ASCII; a Unicode fold would only let a look-alike pass - the
// Kelvin sign for "k" - for an address OpenDKIM, folding ASCII, does not
// sign (review of stage 2c).
// Kelvin sign for "k" - for an address that is not the domain's (review of
// stage 2c).
func (b *Bindings) Allowed(login, addr string) (bool, error) {
b.mu.Lock()
defer b.mu.Unlock()
@@ -223,8 +220,10 @@ func (b *Bindings) Allowed(login, addr string) (bool, error) {
if bare := withoutExtension(addr); bare != addr {
keys = append(keys, bare)
}
if d := domainOf(addr); d != "" {
keys = append(keys, "@"+d)
// The domain as written, folded in ASCII alone: domainOf folds as
// Unicode, which would make the Kelvin sign a "k".
if at := strings.LastIndexByte(addr, '@'); at >= 0 && at < len(addr)-1 {
keys = append(keys, addr[at:])
}
for _, k := range keys {
if owners, found := b.logins[k]; found {
@@ -380,53 +379,23 @@ func (s *senderSession) judge() (shown, why string, err error) {
default:
return printable(strings.Join(s.froms, " | ")), "the message has more than one From: line", nil
}
// The value is the one OpenDKIM gets - Postfix hands both milters the
// field as it came, folded lines and all, and with the leading space each
// asked for (OptHeaderLeadingSpace, ServeSender) - and it is read with
// OpenDKIM's own parser (mailparse.go). So the address judged here is the
// address OpenDKIM signs for, whatever the display name, a comment or a
// quoted string around it holds; the domain of the key it signs with is
// that address's (assumptions B-PM12; audit 2026-10-12, stage 2c).
// The line must be one address in RFC 5322's grammar (net/mail), the
// address a mail program shows; a display name may say anything. A line
// a lenient program could show as another address - "ceo@bank.com
// <me@mine.com>", "ceo@bank.com:me@mine.com", "<b@evil.com, a@mine.com>"
// - is refused (review of stage 2c, F1). The value comes as the client
// wrote it, folded lines and all (assumptions B-PM12). And that address,
// its local part unquoted as Postfix holds the envelope's (Postfix
// 3.10.13, src/global/tok822_parse.c), is the one held against the
// bindings: "x"@mine.com is x@mine.com, which may be another
// application's (F2).
raw := s.froms[0]
shown = printable(raw)
r := dkimMailParse(raw)
if r.err != mailparseOK || !r.hasUser || !r.hasDomain || r.user == "" || r.domain == "" {
// OpenDKIM does not sign then either (mlfi_eoh(): BADFORMAT).
return shown, "it holds no address OpenDKIM can read", nil
}
if moreAfter(raw, r.rest) {
// OpenDKIM takes the first; a reader may show the others.
return shown, "it must be one address", nil
}
signedDomain := asciiLower(r.domain)
addr := r.user + "@" + signedDomain
// OpenDKIM matches its SigningTable against "user@domain" as
// snprintf() leaves it in a buffer of MAXADDRESS + 1 = 257 bytes
// (opendkim.c, dkimf_apply_signtable(); libopendkim/dkim-internal.h).
// Cut there, a quoted local part could end in another tenant's
// "@domain" and pick that tenant's key; an address that fits is read
// whole, and its domain (DKIM_MAXHOSTNAMELEN, 256) with it.
if len(addr) > 256 {
return printable(addr), "the address is longer than OpenDKIM reads whole", nil
}
// The address a mail program shows. OpenDKIM's parser reads lines no
// standard reader takes for one address - "ceo@bank.com:me@mine.com",
// "ceo@bank.com <me@mine.com>", "<b@evil.com, a@mine.com>" - and signs
// for the last of them while a lenient program shows the first. So the
// line must also be one address in RFC 5322's grammar (net/mail), at the
// domain OpenDKIM signs for; a quoted display name may say anything.
// And that address, its local part unquoted as Postfix holds the
// envelope's (Postfix 3.10.13, src/global/tok822_parse.c), is the one
// held against the bindings: "x"@mine.com is x@mine.com, which may be
// another application's (review of stage 2c, F1, F2).
list, perr := fromParser.ParseList(unfold(raw))
if perr != nil || len(list) != 1 {
return shown, "it must be one address, as a mail program reads it", nil
}
seen := asciiLower(list[0].Address)
if domainOf(seen) != signedDomain {
return shown, "a mail program would show another address than the one signed", nil
}
shown = printable(seen)
ok, err := s.bind.Allowed(s.login, seen)
if err != nil {
@@ -452,8 +421,8 @@ func unfold(v string) string {
return strings.NewReplacer("\r\n", "", "\n", "", "\r", "").Replace(v)
}
// asciiLower lower-cases A-Z only, as OpenDKIM's dkimf_lowercase() and
// Postfix's table lookups do; strings.ToLower would also fold characters
// asciiLower lower-cases A-Z only, as Postfix's table lookups do for the
// ASCII keys the panel writes; strings.ToLower would also fold characters
// outside ASCII - the Kelvin sign to "k" - into names they are not.
func asciiLower(v string) string {
b := []byte(v)
@@ -571,12 +540,11 @@ func ServeSender(ctx context.Context, ln net.Listener, bind *Bindings, rec Refus
},
Actions: 0, // read-only
// The headers decide; and each header value with its leading space,
// as OpenDKIM asks for it (opendkim.c, mlfi_negotiate(),
// SMFIP_HDR_LEADSPC), so that both read the same bytes. Postfix
// 3.10.13, src/milter/milter8.c: milter8_negotiate() keeps the event
// mask a milter of any version asks for (go-milter v0.4.1 says
// version 2), and milter8_header() drops the space only for a milter
// without SMFIP_HDR_LEADSPC.
// as the client wrote it, the bytes the signer reads too (sign.go).
// Postfix 3.10.13, src/milter/milter8.c: milter8_negotiate() keeps
// the event mask a milter of any version asks for (go-milter v0.4.1
// says version 2), and milter8_header() drops the space only for a
// milter without SMFIP_HDR_LEADSPC.
Protocol: milter.OptNoBody | milter.OptHeaderLeadingSpace,
}
+19 -19
View File
@@ -96,9 +96,9 @@ func TestSenderCheck(t *testing.T) {
{"a name in windows-1251", "shop", [][2]string{from("=?windows-1251?B?zODj4Ofo7Q==?= <a@shop.example.org>")}, true, ""},
{"a name in koi8-r, quoted-printable", "shop", [][2]string{from("=?koi8-r?Q?=ED=C1=C7=C1=DA=C9=CE?= <a@shop.example.org>")}, true, ""},
{"a comment after the address", "shop", [][2]string{from("a@shop.example.org (the shop)")}, true, ""},
{"the leading space Postfix keeps for OpenDKIM", "shop", [][2]string{from(" a@shop.example.org")}, true, ""},
// Whatever the name, a comment or a quoted local part holds: OpenDKIM
// signs for the address it reads, and that is the one judged.
{"the leading space the check asks Postfix to keep", "shop", [][2]string{from(" a@shop.example.org")}, true, ""},
// Whatever the name, a comment or a quoted local part holds: the
// address a mail program reads is the one judged.
{"another sender in the display name", "shop", [][2]string{from(`"director@example.org" <a@shop.example.org>`)}, true, ""},
{"the server's name in a comment", "shop", [][2]string{from("a@shop.example.org (postmaster@mail.example.org)")}, true, ""},
{"a domain with a key and no application, in the display name", "shop", [][2]string{from(`"x@empty.example.com" <a@shop.example.org>`)}, true, ""},
@@ -107,9 +107,9 @@ func TestSenderCheck(t *testing.T) {
{"a stranger's address in a comment", "shop", [][2]string{from("a@shop.example.org (someone@gmail.example)")}, true, ""},
{"an unquoted address as the display name", "shop", [][2]string{from("director@example.org <a@shop.example.org>")}, false, "as a mail program reads it"},
{"another domain inside a quoted local part", "shop", [][2]string{from(`"director@example.org"@shop.example.org`)}, true, ""},
{"no local part: OpenDKIM takes the domain for it", "shop", [][2]string{from("@shop.example.org")}, false, "as a mail program reads it"},
// Lines OpenDKIM signs for the application's domain while a lenient
// mail program shows another address first (review of stage 2c, F1).
{"no local part", "shop", [][2]string{from("@shop.example.org")}, false, "as a mail program reads it"},
// Lines a lenient mail program could show as another address
// (review of stage 2c, F1).
{"another address before a colon", "shop", [][2]string{from("ceo@bank.example:me@shop.example.org")}, false, "as a mail program reads it"},
{"a route-like colon inside the brackets", "shop", [][2]string{from("Name <ceo@bank.example:me@shop.example.org>")}, false, "as a mail program reads it"},
{"a list inside the brackets", "shop", [][2]string{from("<b@evil.example, a@shop.example.org>")}, false, "as a mail program reads it"},
@@ -119,9 +119,8 @@ func TestSenderCheck(t *testing.T) {
{"another application's address, quoted", "shop", [][2]string{from(`"desk"@shop.example.org`)}, false, "not an address this application sends as"},
{"its own bound address, quoted", "shop2", [][2]string{from(`"desk"@shop.example.org`)}, true, ""},
// A look-alike of the domain in a character a Unicode fold maps
// to ASCII (F3): OpenDKIM would not sign it, and it is not the
// application's.
{"the Kelvin sign for k in the domain", "kiosk", [][2]string{from("a@\u212Aiosk.example.net")}, false, "a mail program would show another address"},
// to ASCII (F3): it is not the application's.
{"the Kelvin sign for k in the domain", "kiosk", [][2]string{from("a@\u212Aiosk.example.net")}, false, "not an address this application sends as"},
{"the same domain in ASCII", "kiosk", [][2]string{from("a@kiosk.example.net")}, true, ""},
{"another registered domain", "shop", [][2]string{from("director@example.org")}, false, "not an address this application sends as"},
@@ -134,14 +133,14 @@ func TestSenderCheck(t *testing.T) {
{"no From: line", "shop", [][2]string{{"Subject", "hello"}}, false, "no From: line"},
{"two From: lines", "shop", [][2]string{from("a@shop.example.org"), from("director@example.org")}, false, "more than one From: line"},
{"two addresses in one line", "shop", [][2]string{from("a@shop.example.org, director@example.org")}, false, "one address"},
{"not an address", "shop", [][2]string{from("the shop")}, false, "no address OpenDKIM can read"},
{"not an address", "shop", [][2]string{from("the shop")}, false, "as a mail program reads it"},
{"an address before a list comma, then another", "shop", [][2]string{from("director@example.org, <a@shop.example.org>")}, false, "one address"},
// Past 256 bytes OpenDKIM matches its SigningTable against a cut
// "user@domain" - which could end in another tenant's domain.
{"an address longer than OpenDKIM reads", "shop", [][2]string{from(`"` + strings.Repeat("a", 240) + `@example.org"@shop.example.org`)}, false, "longer than OpenDKIM reads whole"},
// The key is the login's, so a long quoted local part naming another
// domain picks nothing: OpenDKIM's 256-byte cut went with it.
{"a long quoted local part naming another domain", "shop", [][2]string{from(`"` + strings.Repeat("a", 240) + `@example.org"@shop.example.org`)}, true, ""},
{"a group", "shop", [][2]string{from("friends: a@shop.example.org, b@shop.example.org;")}, false, "one address"},
{"another sender folded onto the next line", "shop", [][2]string{from("\"x\"\r\n <director@example.org>")}, false, "not an address this application sends as"},
{"a quote left open before another address", "shop", [][2]string{from(`"a@shop.example.org <director@example.org>`)}, false, "no address OpenDKIM can read"},
{"a quote left open before another address", "shop", [][2]string{from(`"a@shop.example.org <director@example.org>`)}, false, "as a mail program reads it"},
{"no login", "", [][2]string{from("a@shop.example.org")}, false, "no authenticated application"},
{"an unknown login", "ghost", [][2]string{from("a@shop.example.org")}, false, "not an address this application sends as"},
}
@@ -389,8 +388,8 @@ func fromLines(t *testing.T, file, domain, other string) []string {
}
// No line of the shared file of lines naming another sender is passed to be
// signed with that sender's key: a line the check passes is one OpenDKIM
// reads the application's own address in (the e2e looks at the signature).
// passed as that sender: a line the check passes is one a mail program reads
// as an address of the application's own domain.
func TestSenderCheckNeverPassesTheSharedLinesForAnother(t *testing.T) {
bind := NewBindings(writeMaps(t, senderMaps))
for _, line := range fromLines(t, "from-lines-other.txt", "shop.example.org", "news.example.net") {
@@ -398,14 +397,15 @@ func TestSenderCheckNeverPassesTheSharedLinesForAnother(t *testing.T) {
if resp != milter.RespContinue {
continue
}
if r := dkimMailParse(line); asciiLower(r.domain) != "shop.example.org" {
t.Errorf("From: %q passed, and OpenDKIM reads the domain %q in it", line, r.domain)
list, err := fromParser.ParseList(unfold(line))
if err != nil || len(list) != 1 || domainOf(asciiLower(list[0].Address)) != "shop.example.org" {
t.Errorf("From: %q passed, and a mail program reads it as %v (%v)", line, list, err)
}
}
}
// Every line of the shared file is passed for the domain's application; the
// e2e then checks OpenDKIM signs each for that domain alone.
// e2e then checks each arrives signed for that domain alone.
func TestSenderCheckPassesTheSharedLines(t *testing.T) {
bind := NewBindings(writeMaps(t, senderMaps))
for _, line := range passedFromLines(t, "shop.example.org") {
+33 -31
View File
@@ -27,10 +27,11 @@ import (
"github.com/emersion/go-msgauth/dkim"
)
// signHeaders is the set of header names signed when present: OpenDKIM's
// dkim_should_signhdrs[], RFC 6376 § 5.4.1's list (OpenDKIM 2.11.0~beta2,
// libopendkim/dkim.c:223-246), which OpenDKIM signed with until now. Every
// occurrence is signed, in message order, and From once more (signLines).
// signHeaders is the set of header names signed when present: RFC 6376
// § 5.4.1's list, as OpenDKIM's dkim_should_signhdrs[] held it (OpenDKIM
// 2.11.0~beta2, libopendkim/dkim.c:223-246), which SelfPost signed with until
// 2.8.0. Every occurrence is signed, in message order, and From once more
// (signLines).
var signHeaders = map[string]bool{
"from": true, "reply-to": true, "subject": true, "date": true, "to": true, "cc": true,
"resent-date": true, "resent-from": true, "resent-sender": true, "resent-to": true, "resent-cc": true,
@@ -49,11 +50,11 @@ var signHeaders = map[string]bool{
// far above Postfix's own bounds on a transaction.
const signLimit = time.Hour
// SignKeys reads the domains the server signs for, their selectors and key
// files from the KeyTable the panel writes (internal/domain, renderTables():
// "<domain> <domain>:<selector>:<keysDir>/<domain>/<selector>.private"), the
// table OpenDKIM reads too until it leaves the image. A key file is read
// when it changes, and only from under keysDir.
// SignKeys reads the names the server signs for and their selectors from the
// selector table the panel writes (internal/domain, renderSelectors(): a line
// "<name> <selector>" each); the key of a name is
// keysDir/<name>/<selector>.private (domain.KeyPath). The table is read again
// when it changes, a key file when it does.
type SignKeys struct {
table, keysDir string
@@ -81,13 +82,13 @@ type fileStamp struct {
func stampOf(fi os.FileInfo) fileStamp { return fileStamp{fi.ModTime(), fi.Size()} }
// errNoKey is a domain the KeyTable has no line for.
// errNoKey is a domain the selector table has no line for.
var errNoKey = errors.New("no signing key")
// NewSignKeys reads keyTable; keysDir is the directory every key in it must
// be under.
func NewSignKeys(keyTable, keysDir string) *SignKeys {
return &SignKeys{table: keyTable, keysDir: keysDir, keys: map[string]loadedKey{}}
// NewSignKeys reads the selector table at selectors and the keys under
// keysDir.
func NewSignKeys(selectors, keysDir string) *SignKeys {
return &SignKeys{table: selectors, keysDir: keysDir, keys: map[string]loadedKey{}}
}
// Lookup gives the selector and key of domain.
@@ -120,39 +121,39 @@ func (k *SignKeys) Lookup(domain string) (string, crypto.Signer, error) {
func (k *SignKeys) readTable() error {
fi, err := os.Stat(k.table)
if err != nil {
return fmt.Errorf("key table: %w", err)
return fmt.Errorf("selector table: %w", err)
}
if k.rows != nil && k.stamp == stampOf(fi) {
return nil
}
data, err := os.ReadFile(k.table)
if err != nil {
return fmt.Errorf("key table: %w", err)
return fmt.Errorf("selector table: %w", err)
}
rows := map[string]keyRow{}
for n, line := range strings.Split(string(data), "\n") {
if strings.TrimSpace(line) == "" {
continue
}
name, value, ok := strings.Cut(line, " ")
// Domain and selector never hold a colon (internal/domain,
// opendkimForbidden); the path takes the rest.
parts := strings.SplitN(value, ":", 3)
if !ok || len(parts) != 3 || !strings.EqualFold(parts[0], name) {
return fmt.Errorf("key table: line %d is not \"<domain> <domain>:<selector>:<path>\"", n+1)
name, selector, ok := strings.Cut(line, " ")
// The two are names of a directory and a file under keysDir: nothing
// that would lead out of it (internal/domain, assertConfigSafe()).
if !ok || !pathToken(name) || !pathToken(selector) {
return fmt.Errorf("selector table: line %d is not \"<name> <selector>\"", n+1)
}
domain, selector, path := strings.ToLower(name), parts[1], parts[2]
// The panel writes exactly this path (renderTables); anything else
// is not read.
if want := filepath.Join(k.keysDir, domain, selector+".private"); filepath.Clean(path) != want {
return fmt.Errorf("key table: line %d names %s, not %s", n+1, path, want)
}
rows[domain] = keyRow{selector, path}
domain := strings.ToLower(name)
rows[domain] = keyRow{selector, filepath.Join(k.keysDir, domain, selector+".private")}
}
k.rows, k.stamp = rows, stampOf(fi)
return nil
}
// pathToken reports whether v can name a directory or a file of the key
// tree: not empty, no separator or white space, not dots alone.
func pathToken(v string) bool {
return v != "" && !strings.ContainsAny(v, " \t\r\n:/\\") && strings.Trim(v, ".") != ""
}
// readKey reads a PEM private key: PKCS#1 as internal/domain writes it
// (dkim.go, writePrivateKeyPEM()), PKCS#8 as well.
func readKey(path string) (crypto.Signer, error) {
@@ -281,7 +282,7 @@ func watch(signer *dkim.Signer, expired, done chan struct{}) {
// with CRLF; and the empty line that ends the block. The names: every header
// in signHeaders, in message order, and From once more, so that a From:
// added later breaks the signature (RFC 6376 § 5.4.2; OversignHeaders From in
// OpenDKIM's configuration until now; go-msgauth v0.7.0, dkim/sign.go,
// OpenDKIM's configuration until 2.8.0; go-msgauth v0.7.0, dkim/sign.go,
// NewSigner(): a name with no instance left is kept in h= and hashes
// nothing).
func signLines(headers []field) ([]string, []byte) {
@@ -315,7 +316,8 @@ func (s *signSession) BodyChunk(chunk []byte, m *milter.Modifier) (milter.Respon
}
// Body is the end of the message: the signature goes in at the top, where
// OpenDKIM put it (opendkim/opendkim.c:3673, dkimf_insheader(ctx, 0, ...)).
// OpenDKIM put it until 2.8.0 (OpenDKIM 2.11.0~beta2, opendkim/opendkim.c:3673,
// dkimf_insheader(ctx, 0, ...)).
// Postfix 3.10.13, src/milter/milter8.c:1445-1465, SMFIR_INSHEADER: the
// index is passed on plus one; src/cleanup/cleanup_milter.c:1193,
// cleanup_ins_header(): "Index 1 is the top-most header" - above Postfix's
+21 -10
View File
@@ -18,8 +18,8 @@ import (
"github.com/emersion/go-msgauth/dkim"
)
// signFixture is a key directory and KeyTable laid out as internal/domain
// writes them (renderTables(), writePrivateKeyPEM(): PKCS#1 PEM) for
// signFixture is a key directory and selector table laid out as
// internal/domain writes them (renderSelectors(), writePrivateKeyPEM(): PKCS#1 PEM) for
// example.org with selector sel, and the DNS record of the key.
type signFixture struct {
dir, table, keysDir string
@@ -35,15 +35,15 @@ func newSignFixture(t *testing.T) signFixture {
t.Fatal(err)
}
keyPath := filepath.Join(keysDir, "example.org", "sel.private")
if err := os.MkdirAll(filepath.Dir(keyPath), 0o750); err != nil {
if err := os.MkdirAll(filepath.Dir(keyPath), 0o700); err != nil {
t.Fatal(err)
}
pemBytes := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)})
if err := os.WriteFile(keyPath, pemBytes, 0o640); err != nil {
if err := os.WriteFile(keyPath, pemBytes, 0o600); err != nil {
t.Fatal(err)
}
table := filepath.Join(dir, "KeyTable")
if err := os.WriteFile(table, []byte("example.org example.org:sel:"+keyPath+"\n"), 0o640); err != nil {
table := filepath.Join(dir, "selectors")
if err := os.WriteFile(table, []byte("example.org sel\n"), 0o600); err != nil {
t.Fatal(err)
}
pub, err := x509.MarshalPKIXPublicKey(&key.PublicKey)
@@ -247,7 +247,7 @@ func TestSignerDefersWhatItCannotSign(t *testing.T) {
}{
{"a login with no domain", "stranger", nil},
{"no line for the domain", "app1", func(t *testing.T, f signFixture) {
if err := os.WriteFile(f.table, []byte("other.example other.example:sel:"+filepath.Join(f.keysDir, "other.example", "sel.private")+"\n"), 0o640); err != nil {
if err := os.WriteFile(f.table, []byte("other.example sel\n"), 0o600); err != nil {
t.Fatal(err)
}
}},
@@ -261,12 +261,23 @@ func TestSignerDefersWhatItCannotSign(t *testing.T) {
if err := os.Rename(filepath.Join(f.keysDir, "example.org", "sel.private"), outside); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(f.table, []byte("example.org example.org:sel:"+outside+"\n"), 0o640); err != nil {
if err := os.WriteFile(f.table, []byte("example.org ../../sel\n"), 0o600); err != nil {
t.Fatal(err)
}
}},
{"a key table that is not one", "app1", func(t *testing.T, f signFixture) {
if err := os.WriteFile(f.table, []byte("example.org\n"), 0o640); err != nil {
{"a name of dots", "app1", func(t *testing.T, f signFixture) {
if err := os.WriteFile(f.table, []byte("example.org sel\n.. sel\n"), 0o600); err != nil {
t.Fatal(err)
}
}},
{"a selector table that is not one", "app1", func(t *testing.T, f signFixture) {
if err := os.WriteFile(f.table, []byte("example.org\n"), 0o600); err != nil {
t.Fatal(err)
}
}},
{"an old KeyTable line", "app1", func(t *testing.T, f signFixture) {
line := "example.org example.org:sel:" + filepath.Join(f.keysDir, "example.org", "sel.private") + "\n"
if err := os.WriteFile(f.table, []byte(line), 0o600); err != nil {
t.Fatal(err)
}
}},
+6 -10
View File
@@ -1,15 +1,11 @@
# From: lines that name another sender of this server, {other}, beside or in
# place of the application's own address at {domain} (review of audit stage
# 2). None may be signed with {other}'s key. Since stage 2c the check reads a
# line with OpenDKIM's own parser, so it passes those whose address OpenDKIM
# reads at {domain} - {other} in a display name, a comment or a quoted local
# part - and refuses the rest. The unit test
# (internal/milter/sender_test.go) wants every line it passes read at
# {domain}; the e2e (test/e2e/dkim_from_test.go) sends each and wants
# nothing signed for {other}. {OTHER} is {other} in capitals; {other_qp} is {other} with each
# dot escaped as a quoted pair. One Go-quoted string per line. The lines are
# composed by hand; that OpenDKIM signs none for {other} is measured, by the
# e2e on every run.
# 2). None may pass as {other}: the check passes a line only when a mail
# program reads it as one address at {domain} - {other} in a quoted display
# name or a comment, or inside a quoted local part - and refuses the rest.
# The unit test (internal/milter/sender_test.go) wants every line it passes
# read so. {OTHER} is {other} in capitals; {other_qp} is {other} with each dot
# escaped as a quoted pair. One Go-quoted string per line.
"b@{other}"
"<b@{other}>"
"\"a@{domain}\"@{other}"
+5 -8
View File
@@ -1,15 +1,12 @@
# From: lines the sender check lets through for an application of {domain}
# (audit 2026-10-12, stage 2: which key OpenDKIM signs with, seen and not
# reasoned). One Go-quoted string per line; {domain} is the application's
# domain. Two tests read this file: the check's unit test
# (audit 2026-10-12, stage 2). One Go-quoted string per line; {domain} is the
# application's domain. Two tests read this file: the check's unit test
# (internal/milter/sender_test.go) wants each line passed, and the e2e
# (test/e2e/dkim_from_test.go) sends each one and wants the message signed
# for {domain} and nothing else. A line added here is run by both. {DOMAIN}
# for {domain} - the signer's key is the login's - with a signature that
# validates over these headers. A line added here is run by both. {DOMAIN}
# is {domain} in capitals; {other} is another sender of the server, which a
# line here names in a way the check's scan does not see (a space after the
# @) - passed by design, and so signed for {domain} alone.
# The lines are composed by hand; what OpenDKIM signs each with is measured,
# by the e2e on every run.
# line here names inside a quoted display name - passed by design.
"a@{domain}"
"<a@{domain}>"
"\"The Shop\" <Orders@{domain}>"
-56
View File
@@ -1,56 +0,0 @@
/*
* Differential harness for internal/milter/mailparse.go: OpenDKIM's own
* dkim_mail_parse() (libopendkim/dkim-mailparse.c of the opendkim source the
* image runs, compiled in by #include), fed one hex-encoded line per input
* line. Output per line: the error, then the local part and the domain, each
* "-" when NULL and "=" followed by its bytes in hex otherwise.
*
* test/upstream/mailparse-diff.sh <opendkim source dir>
*
* builds it in debian:trixie and runs TestMailParseMatchesOpenDKIM with it.
*/
#include <sys/types.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "dkim-mailparse.c"
static int hexval(int c)
{
if (c >= '0' && c <= '9') return c - '0';
if (c >= 'a' && c <= 'f') return c - 'a' + 10;
return -1;
}
static void put(const unsigned char *s)
{
if (s == NULL) {
fputs(" -", stdout);
return;
}
fputs(" =", stdout);
for (; *s != '\0'; s++)
printf("%02x", *s);
}
int main(void)
{
static char hex[1 << 20];
static unsigned char buf[1 << 19];
while (fgets(hex, sizeof hex, stdin) != NULL) {
size_t n = strcspn(hex, "\r\n"), i;
unsigned char *user, *domain;
int err;
for (i = 0; i + 1 < n; i += 2)
buf[i / 2] = (unsigned char) (hexval(hex[i]) << 4 | hexval(hex[i + 1]));
buf[n / 2] = '\0';
err = dkim_mail_parse(buf, &user, &domain);
printf("%d", err);
put(user);
put(domain);
putchar('\n');
}
return 0;
}