git » chasquid » commit 0b0e520

dkim: Make b= tag stripping more precise

author Alberto Bertogli
2026-10-14 19:08:35 UTC
committer Alberto Bertogli
2026-10-14 19:16:03 UTC
parent 9a59bd77d1f1151bd5ac7f6f9babc43020a1b002

dkim: Make b= tag stripping more precise

When verifying a signature we need to strip the value of the b= tag.

Today, we do this with a regexp that can accidentally match other tag
values. For example, "z=Subject:club=20".

This can cause valid emails to fail verification. But it can't cause an
invalid email to pass verification, so it's not a significant security
concern.

To fix this, this patch updates the regular expression to match the tag
more precisely. It also adds a few tests to validate the behaviour is
correct, which were cross-checked with other implementations.

Thanks to John JEAN <john@wargan.com> for reporting this bug.

internal/dkim/testdata/12-b_tag_lookalikes.dns +3 -0
internal/dkim/testdata/12-b_tag_lookalikes.msg +18 -0
internal/dkim/testdata/12-b_tag_lookalikes.readme +9 -0
internal/dkim/testdata/12-b_tag_lookalikes.result +14 -0
internal/dkim/verify.go +9 -4
internal/dkim/verify_test.go +104 -0

diff --git a/internal/dkim/testdata/12-b_tag_lookalikes.dns b/internal/dkim/testdata/12-b_tag_lookalikes.dns
new file mode 100644
index 0000000..9acad37
--- /dev/null
+++ b/internal/dkim/testdata/12-b_tag_lookalikes.dns
@@ -0,0 +1,3 @@
+brisbane._domainkey.football.example.com: \
+  v=DKIM1; k=ed25519; \
+  p=11qYAYKxCrfVS/7TyWQHOg7hcvPapiMlrwIaaPcHURo=
diff --git a/internal/dkim/testdata/12-b_tag_lookalikes.msg b/internal/dkim/testdata/12-b_tag_lookalikes.msg
new file mode 100644
index 0000000..e324010
--- /dev/null
+++ b/internal/dkim/testdata/12-b_tag_lookalikes.msg
@@ -0,0 +1,18 @@
+DKIM-Signature: v=1; a=ed25519-sha256; c=relaxed/relaxed;
+ d=football.example.com; s=brisbane; t=1528637909;
+ h=from:to:subject:date:message-id;
+ b=xjoLeGfcxnO99aQ8Tlc/unAH+heqD8dXVjKLdvzbUr7sctU9pf/V3FRluuVyLtxiZieesO7xRWJAcThkMDIqAA==;
+ bh=W3XNOuZiySVQVWa4kPgRp9uVKwEhAmhwKC5NysNOjhg=;
+ xb=1;
+ z=Subject:Is=20the=20club=20open?
+From: Joe SixPack <joe@football.example.com>
+To: Suzie Q <suzie@shopping.example.net>
+Subject: Is the club open?
+Date: Fri, 11 Jul 2003 21:00:37 -0700 (PDT)
+Message-ID: <20030712040037.46341.5F8J@football.example.com>
+
+Hi.
+
+Is the club open tonight? We could watch the game there.
+
+Joe.
diff --git a/internal/dkim/testdata/12-b_tag_lookalikes.readme b/internal/dkim/testdata/12-b_tag_lookalikes.readme
new file mode 100644
index 0000000..16505e5
--- /dev/null
+++ b/internal/dkim/testdata/12-b_tag_lookalikes.readme
@@ -0,0 +1,9 @@
+This tests a DKIM signature with tags that contain "b=" but are not the b=
+tag: an unknown "xb=" tag, and a z= tag with a quoted-printable "club=20".
+The b= tag is also not the last one.
+
+When verifying, only the value of the real b= tag must be removed before
+hashing the DKIM-Signature header.
+
+It was signed with Mail::DKIM (Perl), using the RFC 8463 ed25519 key, and
+verified with both Mail::DKIM and dkimpy.
diff --git a/internal/dkim/testdata/12-b_tag_lookalikes.result b/internal/dkim/testdata/12-b_tag_lookalikes.result
new file mode 100644
index 0000000..04f4743
--- /dev/null
+++ b/internal/dkim/testdata/12-b_tag_lookalikes.result
@@ -0,0 +1,14 @@
+{
+	"Found": 1,
+	"Valid": 1,
+	"Results": [
+		{
+			"Error": "",
+			"SignatureHeader": " v=1; a=ed25519-sha256; c=relaxed/relaxed;\r\n d=football.example.com; s=brisbane; t=1528637909;\r\n h=from:to:subject:date:message-id;\r\n b=xjoLeGfcxnO99aQ8Tlc/unAH+heqD8dXVjKLdvzbUr7sctU9pf/V3FRluuVyLtxiZieesO7xRWJAcThkMDIqAA==;\r\n bh=W3XNOuZiySVQVWa4kPgRp9uVKwEhAmhwKC5NysNOjhg=;\r\n xb=1;\r\n z=Subject:Is=20the=20club=20open?",
+			"Domain": "football.example.com",
+			"Selector": "brisbane",
+			"B": "xjoLeGfcxnO99aQ8Tlc/unAH+heqD8dXVjKLdvzbUr7sctU9pf/V3FRluuVyLtxiZieesO7xRWJAcThkMDIqAA==",
+			"State": "SUCCESS"
+		}
+	]
+}
\ No newline at end of file
diff --git a/internal/dkim/verify.go b/internal/dkim/verify.go
index b8b5d6c..89287db 100644
--- a/internal/dkim/verify.go
+++ b/internal/dkim/verify.go
@@ -141,10 +141,15 @@ func VerifyMessage(ctx context.Context, message string) (*VerifyResult, error) {
 	return results, nil
 }
 
-// Regular expression that matches the "b=" tag.
-// First capture group is the "b=" part (including any whitespace up to the
+// Regular expression that matches the "b=" tag, in a (canonicalized)
+// DKIM-Signature header, including the header name.
+// It must be anchored at the beginning of a tag, so it doesn't match other
+// tags or values that contain "b=" (e.g. "xb=", or "z=...club=20...").
+// First capture group is what precedes the tag: the header name and ':' if
+// it is the first tag, or ';' otherwise.
+// Second capture group is the "b=" part (including any whitespace up to the
 // '=').
-var bTag = regexp.MustCompile(`(b[ \t\r\n]*=)[^;]+`)
+var bTag = regexp.MustCompile(`(^[^:]*:|;)([ \t\r\n]*b[ \t\r\n]*=)[^;]*`)
 
 func verifySignature(ctx context.Context, sigH header,
 	headers headers, body string) *OneResult {
@@ -233,7 +238,7 @@ func verifySignature(ctx context.Context, sigH header,
 	// with an empty b= tag, and without a trailing \r\n.
 	// https://datatracker.ietf.org/doc/html/rfc6376#section-3.7
 	sigC := sig.cH.header(sigH)
-	sigCStr := bTag.ReplaceAllString(sigC.Source, "$1")
+	sigCStr := bTag.ReplaceAllString(sigC.Source, "$1$2")
 	trace(ctx, "Hashing header: %q", sigCStr)
 	b.Write([]byte(sigCStr))
 	bSum := b.Sum(nil)
diff --git a/internal/dkim/verify_test.go b/internal/dkim/verify_test.go
index 217f578..ceb9bd4 100644
--- a/internal/dkim/verify_test.go
+++ b/internal/dkim/verify_test.go
@@ -297,6 +297,110 @@ func TestVerifyInvalidDomain(t *testing.T) {
 	}
 }
 
+func TestBTag(t *testing.T) {
+	// Test that we remove the value of the b= tag (and only that) from the
+	// canonicalized DKIM-Signature header.
+	// The expected values match what Mail::DKIM does (except for the cases
+	// without a b= tag, where it appends one).
+	cases := []struct {
+		in, want string
+	}{
+		// Relaxed canonicalization, b= in different positions.
+		{"dkim-signature:v=1; bh=AAAA; b=SIG",
+			"dkim-signature:v=1; bh=AAAA; b="},
+		{"dkim-signature:v=1; bh=AAAA; b=SIG;",
+			"dkim-signature:v=1; bh=AAAA; b=;"},
+		{"dkim-signature:b=SIG; v=1",
+			"dkim-signature:b=; v=1"},
+		{"dkim-signature:v=1; b=SIG; bh=AAAA; a=ed25519-sha256",
+			"dkim-signature:v=1; b=; bh=AAAA; a=ed25519-sha256"},
+
+		// Simple canonicalization: the header name is unchanged, and
+		// whitespace (including folding) is preserved.
+		{"DKIM-Signature: b=SIG; v=1",
+			"DKIM-Signature: b=; v=1"},
+		{"DKIM-Signature: v=1;\r\n\tb=SIG1\r\n\t SIG2;\r\n\tbh=AAAA",
+			"DKIM-Signature: v=1;\r\n\tb=;\r\n\tbh=AAAA"},
+		{"DKIM-Signature: v=1;\r\n\t b=SIG",
+			"DKIM-Signature: v=1;\r\n\t b="},
+		{"DKIM-Signature: v=1;\r\n b\r\n =\r\n SIG\r\n ;",
+			"DKIM-Signature: v=1;\r\n b\r\n =;"},
+
+		// Whitespace around and inside the value is removed.
+		{"dkim-signature:v=1; b = SI G ;",
+			"dkim-signature:v=1; b =;"},
+
+		// Empty b= tag.
+		{"dkim-signature:v=1; b=; bh=AAAA",
+			"dkim-signature:v=1; b=; bh=AAAA"},
+		{"dkim-signature:v=1; b=",
+			"dkim-signature:v=1; b="},
+
+		// The whole value is removed, even if it's not valid base64.
+		{"dkim-signature:v=1; b=SI!G; x=1",
+			"dkim-signature:v=1; b=; x=1"},
+
+		// Degenerate folding, with a ':' at the beginning of a continuation
+		// line.
+		{"DKIM-Signature: v=1; h=from:to\r\n\t:subject;\r\n\tb=SIG",
+			"DKIM-Signature: v=1; h=from:to\r\n\t:subject;\r\n\tb="},
+
+		// Tags and values that contain "b=" but are not the b= tag must be
+		// left alone.
+		{"dkim-signature:v=1; bh=AAAA",
+			"dkim-signature:v=1; bh=AAAA"},
+		{"dkim-signature:v=1; bh=AAb=; b=SIG",
+			"dkim-signature:v=1; bh=AAb=; b="},
+		{"dkim-signature:v=1; xb=1; b=SIG",
+			"dkim-signature:v=1; xb=1; b="},
+		{"dkim-signature:v=1; x=b=1; b=SIG",
+			"dkim-signature:v=1; x=b=1; b="},
+		{"dkim-signature:v=1; x=y b=1; b=SIG",
+			"dkim-signature:v=1; x=y b=1; b="},
+		{"dkim-signature:v=1; z=Subject:Is=20the=20club=20open?; b=SIG",
+			"dkim-signature:v=1; z=Subject:Is=20the=20club=20open?; b="},
+		{"dkim-signature:z=Subject:b=20x; b=SIG",
+			"dkim-signature:z=Subject:b=20x; b="},
+		{"dkim-signature:v=1; i=bob=2B@example.com; b=SIG",
+			"dkim-signature:v=1; i=bob=2B@example.com; b="},
+		{"dkim-signature:v=1; xb=1; z=a:b=20",
+			"dkim-signature:v=1; xb=1; z=a:b=20"},
+
+		// Tag names are case-sensitive.
+		{"dkim-signature:v=1; B=SIG; b=SIG",
+			"dkim-signature:v=1; B=SIG; b="},
+	}
+
+	for _, c := range cases {
+		got := bTag.ReplaceAllString(c.in, "$1$2")
+		if got != c.want {
+			t.Errorf("bTag replace on %q:", c.in)
+			t.Errorf("      want %q", c.want)
+			t.Errorf("      got  %q", got)
+		}
+
+		// Cross-check against the tag parser: the result must have the
+		// same tags as the input, with only b= (if present) emptied.
+		_, inV, _ := strings.Cut(c.in, ":")
+		_, gotV, _ := strings.Cut(got, ":")
+		inTags, err := parseTags(inV)
+		if err != nil {
+			t.Fatalf("parseTags(%q) error: %v", inV, err)
+		}
+		gotTags, err := parseTags(gotV)
+		if err != nil {
+			t.Fatalf("parseTags(%q) error: %v", gotV, err)
+		}
+		if _, ok := inTags["b"]; ok {
+			inTags["b"] = ""
+		}
+		if diff := cmp.Diff(inTags, gotTags); diff != "" {
+			t.Errorf("bTag replace on %q: tags diff (-want +got):\n%s",
+				c.in, diff)
+		}
+	}
+}
+
 func TestHeadersToInclude(t *testing.T) {
 	// Test that headersToInclude returns the expected headers.
 	cases := []struct {