| author | Alberto Bertogli
<albertito@blitiri.com.ar> 2026-10-14 19:08:35 UTC |
| committer | Alberto Bertogli
<albertito@blitiri.com.ar> 2026-10-14 19:16:03 UTC |
| parent | 9a59bd77d1f1151bd5ac7f6f9babc43020a1b002 |
| 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 {