Skip to content

Commit 3df349b

Browse files
authored
Merge pull request #5 from aluminumio/v0.3.0
v0.3.0: VerifyStatus enum, l= tag, testable verification, CI
2 parents 8e6bc4c + a29fa94 commit 3df349b

File tree

9 files changed

+326
-143
lines changed

9 files changed

+326
-143
lines changed

.github/workflows/ci.yml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
9+
jobs:
10+
test:
11+
runs-on: ubuntu-latest
12+
strategy:
13+
matrix:
14+
crystal: ["1.19", "latest"]
15+
steps:
16+
- uses: actions/checkout@v4
17+
- uses: crystal-lang/install-crystal@v1
18+
with:
19+
crystal: ${{ matrix.crystal }}
20+
- run: shards install
21+
- run: crystal spec
22+
- run: crystal build --no-codegen src/dkimvrfy.cr
23+
- run: crystal build --no-codegen src/dkimsign.cr

README.md

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -76,19 +76,31 @@ Verification
7676
Verify a DKIM-signed message:
7777

7878
mail = Dkim::VerifyMail.new(raw_message)
79-
if mail.verify
79+
result = mail.verify
80+
if result == Dkim::VerifyStatus::Pass
8081
puts "DKIM verified"
8182
end
8283

84+
`verify` returns a `Dkim::VerifyStatus` enum: `Pass`, `Fail`, `BodyHashFail`,
85+
`KeyRevoked`, `Expired`, `NoSignature`, `NoKey`, or `InvalidSig`.
86+
87+
When a message has multiple DKIM-Signature headers, `verify` returns `Pass` if
88+
any signature passes. Use `verify_all` to get an `Array(VerifyStatus)` with
89+
results for each signature.
90+
91+
To bypass DNS and supply a public key directly (useful for testing):
92+
93+
mail.verify(public_key: base64_encoded_public_key)
94+
8395
Supports relaxed and simple canonicalization, RSA-SHA256 and RSA-SHA1, header
8496
over-signing (duplicate `h=` entries and non-existent headers per RFC 6376
85-
§3.5 / §5.4.2), and folded tag values with continuation lines.
97+
§3.5 / §5.4.2), folded tag values with continuation lines, body length `l=`
98+
tag, `x=` signature expiration, and `p=` key revocation detection.
8699

87100
Limitations
88101
===========
89102

90103
* No support for the older Yahoo! DomainKeys standard ([RFC 4870](http://tools.ietf.org/html/rfc4870))
91-
* No support for body length `l=` tag *(planned)*
92104
* No support for copied header fields `z=`
93105

94106
Related RFCs

shard.lock

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@ version: 2.0
22
shards:
33
dns:
44
git: https://github.com/636f7374/dns.cr.git
5-
version: 1.0.3+git.commit.0e4e3d3b50e879e4dc45eac85c45deae77f05819
5+
version: 1.0.5+git.commit.87a75b08b98a8057a74cc1e0ecf61d853878301d
66

77
openssl_ext:
88
git: https://github.com/spider-gazelle/openssl_ext.git
9-
version: 2.1.5+git.commit.a6d023921da7cdc15c04c4dc835f6c92ee15b0c3
9+
version: 2.8.4+git.commit.fa66a5e79f3d4ec94dea6e2b783fdf837d8f91e2
1010

shard.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
name: dkim
2-
version: 0.2.0
2+
version: 0.3.0
33

44
dependencies:
55
openssl_ext:

spec/spec_helper.cr

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ EOF
1616
DOMAIN = "example.com"
1717
SELECTOR = "brisbane"
1818
TIME = 1234567890
19+
PUBLIC_KEY_B64 = "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDwIRP/UC3SBsEmGqZ9ZJW3/DkMoGeLnQg1fWn7/zYtIxN2SnFCjxOCKG9v3b4jYfcTNh5ijSsq631uBItLa7od+v/RtdC2UzJ1lWT947qR+Rcac2gbto/NMqJ0fzfVjH4OuKhitdY9tf6mcwGjaNBcWToIMmPSPDdQPNUYckcQ2QIDAQAB"
20+
1921
KEY = %{
2022
-----BEGIN RSA PRIVATE KEY-----
2123
MIICXwIBAAKBgQDwIRP/UC3SBsEmGqZ9ZJW3/DkMoGeLnQg1fWn7/zYtIxN2SnFC
@@ -33,3 +35,20 @@ eAYXunajbBSOLlx4D+TunwJBANkPI5S9iylsbLs6NkaMHV6k5ioHBBmgCak95JGX
3335
GMot/L2x0IYyMLAz6oLWh2hm7zwtb0CgOrPo1ke44hFYnfc=
3436
-----END RSA PRIVATE KEY-----
3537
}
38+
39+
def sign_for_test(message = MAIL,
40+
header_canonicalization = "relaxed",
41+
body_canonicalization = "relaxed",
42+
expire : Time? = nil,
43+
body_length : Int32? = nil) : {String, String}
44+
signed_mail = Dkim::SignedMail.new(message,
45+
time: Time.unix(TIME),
46+
domain: DOMAIN,
47+
private_key: KEY,
48+
selector: SELECTOR,
49+
header_canonicalization: header_canonicalization,
50+
body_canonicalization: body_canonicalization,
51+
expire: expire,
52+
body_length: body_length)
53+
{signed_mail.signed_message, PUBLIC_KEY_B64}
54+
end

spec/verify_spec.cr

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
require "./spec_helper"
2+
3+
require "spec"
4+
require "../src/dkim"
5+
6+
describe Dkim::VerifyMail do
7+
describe "round-trip sign then verify" do
8+
it "passes with relaxed/relaxed" do
9+
signed, key = sign_for_test(header_canonicalization: "relaxed", body_canonicalization: "relaxed")
10+
Dkim::VerifyMail.new(signed).verify(public_key: key).should eq Dkim::VerifyStatus::Pass
11+
end
12+
13+
it "passes with simple/simple" do
14+
signed, key = sign_for_test(header_canonicalization: "simple", body_canonicalization: "simple")
15+
Dkim::VerifyMail.new(signed).verify(public_key: key).should eq Dkim::VerifyStatus::Pass
16+
end
17+
18+
it "passes with relaxed/simple" do
19+
signed, key = sign_for_test(header_canonicalization: "relaxed", body_canonicalization: "simple")
20+
Dkim::VerifyMail.new(signed).verify(public_key: key).should eq Dkim::VerifyStatus::Pass
21+
end
22+
23+
it "passes with simple/relaxed" do
24+
signed, key = sign_for_test(header_canonicalization: "simple", body_canonicalization: "relaxed")
25+
Dkim::VerifyMail.new(signed).verify(public_key: key).should eq Dkim::VerifyStatus::Pass
26+
end
27+
end
28+
29+
describe "body hash fail" do
30+
it "returns BodyHashFail when body is modified" do
31+
signed, key = sign_for_test
32+
modified = signed.sub("Are you hungry yet?", "Are you hungry now?")
33+
Dkim::VerifyMail.new(modified).verify(public_key: key).should eq Dkim::VerifyStatus::BodyHashFail
34+
end
35+
end
36+
37+
describe "signature fail" do
38+
it "returns Fail when a signed header is modified" do
39+
signed, key = sign_for_test
40+
modified = signed.sub("Subject: Is dinner ready?", "Subject: Is lunch ready?")
41+
result = Dkim::VerifyMail.new(modified).verify(public_key: key)
42+
# Body hash still matches (body unchanged), but signature verification fails
43+
result.should eq Dkim::VerifyStatus::Fail
44+
end
45+
end
46+
47+
describe "empty body" do
48+
it "passes with empty body" do
49+
empty_body_mail = "From: test@example.com\r\nTo: other@example.com\r\nSubject: empty\r\n\r\n"
50+
signed, key = sign_for_test(message: empty_body_mail)
51+
Dkim::VerifyMail.new(signed).verify(public_key: key).should eq Dkim::VerifyStatus::Pass
52+
end
53+
end
54+
55+
describe "l= body length tag" do
56+
it "passes when content is appended beyond l= boundary" do
57+
signed, key = sign_for_test(body_length: 10)
58+
appended = signed.rstrip + "\r\nAppended extra content\r\n"
59+
Dkim::VerifyMail.new(appended).verify(public_key: key).should eq Dkim::VerifyStatus::Pass
60+
end
61+
62+
it "fails when body within l= is modified" do
63+
signed, key = sign_for_test(body_length: 10)
64+
# Modify early bytes of the body (within l= boundary)
65+
modified = signed.sub("Hi.", "XX.")
66+
Dkim::VerifyMail.new(modified).verify(public_key: key).should eq Dkim::VerifyStatus::BodyHashFail
67+
end
68+
end
69+
70+
describe "key revocation" do
71+
it "returns KeyRevoked when public key is empty" do
72+
signed, _ = sign_for_test
73+
Dkim::VerifyMail.new(signed).verify(public_key: "").should eq Dkim::VerifyStatus::KeyRevoked
74+
end
75+
end
76+
77+
describe "v= validation" do
78+
it "returns InvalidSig when v= is missing" do
79+
signed, key = sign_for_test
80+
# Remove v=1 from the DKIM-Signature header
81+
modified = signed.sub("v=1;", "")
82+
Dkim::VerifyMail.new(modified).verify(public_key: key).should eq Dkim::VerifyStatus::InvalidSig
83+
end
84+
85+
it "returns InvalidSig when v= has wrong value" do
86+
signed, key = sign_for_test
87+
modified = signed.sub("v=1;", "v=2;")
88+
Dkim::VerifyMail.new(modified).verify(public_key: key).should eq Dkim::VerifyStatus::InvalidSig
89+
end
90+
end
91+
92+
describe "c= defaults" do
93+
it "defaults body canonicalization to simple when c= has no slash" do
94+
signed, key = sign_for_test(header_canonicalization: "relaxed", body_canonicalization: "simple")
95+
# Removing "/simple" changes a signed header, so signature fails —
96+
# but body hash still passes (Fail not BodyHashFail), proving the default works
97+
modified = signed.sub("c=relaxed/simple", "c=relaxed")
98+
Dkim::VerifyMail.new(modified).verify(public_key: key).should eq Dkim::VerifyStatus::Fail
99+
end
100+
101+
it "defaults to simple/simple when c= tag is absent" do
102+
signed, key = sign_for_test(header_canonicalization: "simple", body_canonicalization: "simple")
103+
Dkim::VerifyMail.new(signed).verify(public_key: key).should eq Dkim::VerifyStatus::Pass
104+
end
105+
end
106+
107+
describe "x= expiration" do
108+
it "returns Expired when signature has expired" do
109+
signed, key = sign_for_test(expire: Time.unix(TIME + 1))
110+
Dkim::VerifyMail.new(signed).verify(public_key: key).should eq Dkim::VerifyStatus::Expired
111+
end
112+
113+
it "passes when signature has not expired" do
114+
signed, key = sign_for_test(expire: Time.utc + 1.hours)
115+
Dkim::VerifyMail.new(signed).verify(public_key: key).should eq Dkim::VerifyStatus::Pass
116+
end
117+
end
118+
119+
describe "no signature" do
120+
it "returns NoSignature when message has no DKIM-Signature" do
121+
Dkim::VerifyMail.new(MAIL).verify(public_key: PUBLIC_KEY_B64).should eq Dkim::VerifyStatus::NoSignature
122+
end
123+
end
124+
125+
describe "multiple signatures" do
126+
it "returns Pass if any signature passes" do
127+
signed, key = sign_for_test
128+
# Prepend a second (invalid) DKIM-Signature header
129+
bad_sig = "DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=bad.com; s=bad; h=from; bh=bad; b=bad\r\n"
130+
multi = bad_sig + signed
131+
Dkim::VerifyMail.new(multi).verify(public_key: key).should eq Dkim::VerifyStatus::Pass
132+
end
133+
134+
it "verify_all returns status for each signature" do
135+
signed, key = sign_for_test
136+
bad_sig = "DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=bad.com; s=bad; h=from; bh=bad; b=bad\r\n"
137+
multi = bad_sig + signed
138+
results = Dkim::VerifyMail.new(multi).verify_all(public_key: key)
139+
results.size.should eq 2
140+
results.should contain Dkim::VerifyStatus::Pass
141+
end
142+
end
143+
end

src/dkim/signed_mail.cr

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,8 @@ module Dkim
3232
@signing_algorithm : String = "rsa-sha256",
3333
@header_canonicalization : String = "relaxed",
3434
@body_canonicalization : String = "relaxed",
35-
@signable_headers : Array(String) = Dkim::DefaultHeaders)
35+
@signable_headers : Array(String) = Dkim::DefaultHeaders,
36+
@body_length : Int32? = nil)
3637

3738
message = message.to_s.gsub(/\r?\n/, "\r\n")
3839
headers, body = message.split(/\r?\n\r?\n/, 2)
@@ -86,9 +87,13 @@ module Dkim
8687
dkim_header["t"] = @time.to_unix.to_s
8788
dkim_header["x"] = @expire.as(Time).to_unix.to_s unless @expire.nil?
8889

89-
# Add body hash and blank signature
90-
dkim_header["bh"]= String.new(digest_alg.update(canonical_body).final)
91-
# dkim_header["bh"]= digest_alg.digest(canonical_body)
90+
# Add body hash (truncate if body_length set)
91+
body = canonical_body
92+
if bl = @body_length
93+
body = body.byte_slice(0, bl)
94+
dkim_header["l"] = bl.to_s
95+
end
96+
dkim_header["bh"]= String.new(digest_alg.update(body).final)
9297
dkim_header["h"] = signed_headers.join(":")
9398
dkim_header["b"] = ""
9499

0 commit comments

Comments
 (0)