From b3b1f39d855c7f26e95127626db1b1e8be317bcb Mon Sep 17 00:00:00 2001 From: Alberto Bertogli Date: Fri, 9 Oct 2026 12:28:58 +0100 Subject: [PATCH] dkim: Use same header name folding for signing and verifying MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Today, we fold header names with strings.EqualFold when signing, but strings.ToLower when verifying. This should be harmless for valid messages (that have only ASCII characters in the header names), but if one contains an (invalid) header with a non-ASCII character, we could fail to verify the message we sign. The inconsistency would only happen if a header has a non-ASCII character that strings.EqualFold and strings.ToLower handle differently (like "ſubject", with U+017F). To make signing and verifying consistent, this patch makes signing use strings.ToLower. This bug was found by fuzzing the sign-verify cycle, which will be added in a later patch. --- internal/dkim/message.go | 6 ++++- internal/dkim/message_test.go | 11 ++++++++++ internal/dkim/sign_test.go | 41 +++++++++++++++++++++++++++++++++++ 3 files changed, 57 insertions(+), 1 deletion(-) diff --git a/internal/dkim/message.go b/internal/dkim/message.go index 0cc31dd..31dd341 100644 --- a/internal/dkim/message.go +++ b/internal/dkim/message.go @@ -18,7 +18,11 @@ type headers []header func (h headers) FindAll(name string) headers { hs := make(headers, 0) for _, header := range h { - if strings.EqualFold(header.Name, name) { + // Compare using strings.ToLower and not strings.EqualFold, to be + // consistent with the verifier (see headersToInclude). They differ + // for some non-ASCII characters, which are invalid in header names + // but can still appear in messages. + if strings.ToLower(header.Name) == strings.ToLower(name) { hs = append(hs, header) } } diff --git a/internal/dkim/message_test.go b/internal/dkim/message_test.go index 1425832..9023231 100644 --- a/internal/dkim/message_test.go +++ b/internal/dkim/message_test.go @@ -182,4 +182,15 @@ func TestHeadersFindAll(t *testing.T) { t.Errorf("headers.Find() mismatch (-want +got):\n%s", diff) } + // "\u017fubject" (U+017F is a long s) must not match "Subject", even + // though they are equal under Unicode case folding. + hs = append(hs, header{Name: "\u017fubject", Value: "x"}) + subjectHs := hs.FindAll("SUBJECT") + expected = headers{ + {Name: "Subject", Value: "test", Source: "Subject: test"}, + } + if diff := cmp.Diff(expected, subjectHs); diff != "" { + t.Errorf("headers.Find() mismatch (-want +got):\n%s", diff) + } + } diff --git a/internal/dkim/sign_test.go b/internal/dkim/sign_test.go index f31e43b..f5b0239 100644 --- a/internal/dkim/sign_test.go +++ b/internal/dkim/sign_test.go @@ -210,6 +210,47 @@ Hi. } } +func TestSignNonASCIIHeaderNames(t *testing.T) { + // Header names with non-ASCII characters are invalid, but we must still + // sign them consistently with how we verify them. + // Here, "\u017fubject" (U+017F is a long s) is not the same as + // "Subject", and should not be signed as such. + ctx := t.Context() + ctx = WithTraceFunc(ctx, t.Logf) + + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("ed25519.GenerateKey: %v", err) + } + + ctx = WithLookupTXTFunc(ctx, makeLookupTXT(map[string][]string{ + "test._domainkey.example.com": { + "v=DKIM1; k=ed25519; p=" + base64.StdEncoding.EncodeToString(pub), + }, + })) + + s := &Signer{ + Domain: "example.com", + Selector: "test", + Signer: priv, + } + + message := toCRLF("From: from@example.com\n" + + "\u017fubject: Not a subject\n" + + "\n" + + "Hi.\n") + + sig, err := s.Sign(ctx, message) + if err != nil { + t.Fatalf("Sign: %v", err) + } + + res, err := VerifyMessage(ctx, addSig(sig, message)) + if err != nil || res.Valid != 1 { + t.Errorf("VerifyMessage: wanted 1 valid / nil; got %v / %v", res, err) + } +} + func addSig(sig, message string) string { return "DKIM-Signature: " + strings.ReplaceAll(sig, "\r\n", "\r\n\t") +