Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
name: CI

on:
push:
branches: [main]
pull_request:
branches: [main]

jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
crystal: ["1.19", "latest"]
steps:
- uses: actions/checkout@v4
- uses: crystal-lang/install-crystal@v1
with:
crystal: ${{ matrix.crystal }}
- run: shards install
- run: crystal spec
- run: crystal build --no-codegen src/dkimvrfy.cr
- run: crystal build --no-codegen src/dkimsign.cr
18 changes: 15 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,19 +76,31 @@ Verification
Verify a DKIM-signed message:

mail = Dkim::VerifyMail.new(raw_message)
if mail.verify
result = mail.verify
if result == Dkim::VerifyStatus::Pass
puts "DKIM verified"
end

`verify` returns a `Dkim::VerifyStatus` enum: `Pass`, `Fail`, `BodyHashFail`,
`KeyRevoked`, `Expired`, `NoSignature`, `NoKey`, or `InvalidSig`.

When a message has multiple DKIM-Signature headers, `verify` returns `Pass` if
any signature passes. Use `verify_all` to get an `Array(VerifyStatus)` with
results for each signature.

To bypass DNS and supply a public key directly (useful for testing):

mail.verify(public_key: base64_encoded_public_key)

Supports relaxed and simple canonicalization, RSA-SHA256 and RSA-SHA1, header
over-signing (duplicate `h=` entries and non-existent headers per RFC 6376
§3.5 / §5.4.2), and folded tag values with continuation lines.
§3.5 / §5.4.2), folded tag values with continuation lines, body length `l=`
tag, `x=` signature expiration, and `p=` key revocation detection.

Limitations
===========

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

Related RFCs
Expand Down
4 changes: 2 additions & 2 deletions shard.lock
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ version: 2.0
shards:
dns:
git: https://github.com/636f7374/dns.cr.git
version: 1.0.3+git.commit.0e4e3d3b50e879e4dc45eac85c45deae77f05819
version: 1.0.5+git.commit.87a75b08b98a8057a74cc1e0ecf61d853878301d

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

2 changes: 1 addition & 1 deletion shard.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
name: dkim
version: 0.2.0
version: 0.3.0

dependencies:
openssl_ext:
Expand Down
19 changes: 19 additions & 0 deletions spec/spec_helper.cr
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ EOF
DOMAIN = "example.com"
SELECTOR = "brisbane"
TIME = 1234567890
PUBLIC_KEY_B64 = "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDwIRP/UC3SBsEmGqZ9ZJW3/DkMoGeLnQg1fWn7/zYtIxN2SnFCjxOCKG9v3b4jYfcTNh5ijSsq631uBItLa7od+v/RtdC2UzJ1lWT947qR+Rcac2gbto/NMqJ0fzfVjH4OuKhitdY9tf6mcwGjaNBcWToIMmPSPDdQPNUYckcQ2QIDAQAB"

KEY = %{
-----BEGIN RSA PRIVATE KEY-----
MIICXwIBAAKBgQDwIRP/UC3SBsEmGqZ9ZJW3/DkMoGeLnQg1fWn7/zYtIxN2SnFC
Expand All @@ -33,3 +35,20 @@ eAYXunajbBSOLlx4D+TunwJBANkPI5S9iylsbLs6NkaMHV6k5ioHBBmgCak95JGX
GMot/L2x0IYyMLAz6oLWh2hm7zwtb0CgOrPo1ke44hFYnfc=
-----END RSA PRIVATE KEY-----
}

def sign_for_test(message = MAIL,
header_canonicalization = "relaxed",
body_canonicalization = "relaxed",
expire : Time? = nil,
body_length : Int32? = nil) : {String, String}
signed_mail = Dkim::SignedMail.new(message,
time: Time.unix(TIME),
domain: DOMAIN,
private_key: KEY,
selector: SELECTOR,
header_canonicalization: header_canonicalization,
body_canonicalization: body_canonicalization,
expire: expire,
body_length: body_length)
{signed_mail.signed_message, PUBLIC_KEY_B64}
end
143 changes: 143 additions & 0 deletions spec/verify_spec.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
require "./spec_helper"

require "spec"
require "../src/dkim"

describe Dkim::VerifyMail do
describe "round-trip sign then verify" do
it "passes with relaxed/relaxed" do
signed, key = sign_for_test(header_canonicalization: "relaxed", body_canonicalization: "relaxed")
Dkim::VerifyMail.new(signed).verify(public_key: key).should eq Dkim::VerifyStatus::Pass
end

it "passes with simple/simple" do
signed, key = sign_for_test(header_canonicalization: "simple", body_canonicalization: "simple")
Dkim::VerifyMail.new(signed).verify(public_key: key).should eq Dkim::VerifyStatus::Pass
end

it "passes with relaxed/simple" do
signed, key = sign_for_test(header_canonicalization: "relaxed", body_canonicalization: "simple")
Dkim::VerifyMail.new(signed).verify(public_key: key).should eq Dkim::VerifyStatus::Pass
end

it "passes with simple/relaxed" do
signed, key = sign_for_test(header_canonicalization: "simple", body_canonicalization: "relaxed")
Dkim::VerifyMail.new(signed).verify(public_key: key).should eq Dkim::VerifyStatus::Pass
end
end

describe "body hash fail" do
it "returns BodyHashFail when body is modified" do
signed, key = sign_for_test
modified = signed.sub("Are you hungry yet?", "Are you hungry now?")
Dkim::VerifyMail.new(modified).verify(public_key: key).should eq Dkim::VerifyStatus::BodyHashFail
end
end

describe "signature fail" do
it "returns Fail when a signed header is modified" do
signed, key = sign_for_test
modified = signed.sub("Subject: Is dinner ready?", "Subject: Is lunch ready?")
result = Dkim::VerifyMail.new(modified).verify(public_key: key)
# Body hash still matches (body unchanged), but signature verification fails
result.should eq Dkim::VerifyStatus::Fail
end
end

describe "empty body" do
it "passes with empty body" do
empty_body_mail = "From: test@example.com\r\nTo: other@example.com\r\nSubject: empty\r\n\r\n"
signed, key = sign_for_test(message: empty_body_mail)
Dkim::VerifyMail.new(signed).verify(public_key: key).should eq Dkim::VerifyStatus::Pass
end
end

describe "l= body length tag" do
it "passes when content is appended beyond l= boundary" do
signed, key = sign_for_test(body_length: 10)
appended = signed.rstrip + "\r\nAppended extra content\r\n"
Dkim::VerifyMail.new(appended).verify(public_key: key).should eq Dkim::VerifyStatus::Pass
end

it "fails when body within l= is modified" do
signed, key = sign_for_test(body_length: 10)
# Modify early bytes of the body (within l= boundary)
modified = signed.sub("Hi.", "XX.")
Dkim::VerifyMail.new(modified).verify(public_key: key).should eq Dkim::VerifyStatus::BodyHashFail
end
end

describe "key revocation" do
it "returns KeyRevoked when public key is empty" do
signed, _ = sign_for_test
Dkim::VerifyMail.new(signed).verify(public_key: "").should eq Dkim::VerifyStatus::KeyRevoked
end
end

describe "v= validation" do
it "returns InvalidSig when v= is missing" do
signed, key = sign_for_test
# Remove v=1 from the DKIM-Signature header
modified = signed.sub("v=1;", "")
Dkim::VerifyMail.new(modified).verify(public_key: key).should eq Dkim::VerifyStatus::InvalidSig
end

it "returns InvalidSig when v= has wrong value" do
signed, key = sign_for_test
modified = signed.sub("v=1;", "v=2;")
Dkim::VerifyMail.new(modified).verify(public_key: key).should eq Dkim::VerifyStatus::InvalidSig
end
end

describe "c= defaults" do
it "defaults body canonicalization to simple when c= has no slash" do
signed, key = sign_for_test(header_canonicalization: "relaxed", body_canonicalization: "simple")
# Removing "/simple" changes a signed header, so signature fails —
# but body hash still passes (Fail not BodyHashFail), proving the default works
modified = signed.sub("c=relaxed/simple", "c=relaxed")
Dkim::VerifyMail.new(modified).verify(public_key: key).should eq Dkim::VerifyStatus::Fail
end

it "defaults to simple/simple when c= tag is absent" do
signed, key = sign_for_test(header_canonicalization: "simple", body_canonicalization: "simple")
Dkim::VerifyMail.new(signed).verify(public_key: key).should eq Dkim::VerifyStatus::Pass
end
end

describe "x= expiration" do
it "returns Expired when signature has expired" do
signed, key = sign_for_test(expire: Time.unix(TIME + 1))
Dkim::VerifyMail.new(signed).verify(public_key: key).should eq Dkim::VerifyStatus::Expired
end

it "passes when signature has not expired" do
signed, key = sign_for_test(expire: Time.utc + 1.hours)
Dkim::VerifyMail.new(signed).verify(public_key: key).should eq Dkim::VerifyStatus::Pass
end
end

describe "no signature" do
it "returns NoSignature when message has no DKIM-Signature" do
Dkim::VerifyMail.new(MAIL).verify(public_key: PUBLIC_KEY_B64).should eq Dkim::VerifyStatus::NoSignature
end
end

describe "multiple signatures" do
it "returns Pass if any signature passes" do
signed, key = sign_for_test
# Prepend a second (invalid) DKIM-Signature header
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"
multi = bad_sig + signed
Dkim::VerifyMail.new(multi).verify(public_key: key).should eq Dkim::VerifyStatus::Pass
end

it "verify_all returns status for each signature" do
signed, key = sign_for_test
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"
multi = bad_sig + signed
results = Dkim::VerifyMail.new(multi).verify_all(public_key: key)
results.size.should eq 2
results.should contain Dkim::VerifyStatus::Pass
end
end
end
13 changes: 9 additions & 4 deletions src/dkim/signed_mail.cr
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ module Dkim
@signing_algorithm : String = "rsa-sha256",
@header_canonicalization : String = "relaxed",
@body_canonicalization : String = "relaxed",
@signable_headers : Array(String) = Dkim::DefaultHeaders)
@signable_headers : Array(String) = Dkim::DefaultHeaders,
@body_length : Int32? = nil)

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

# Add body hash and blank signature
dkim_header["bh"]= String.new(digest_alg.update(canonical_body).final)
# dkim_header["bh"]= digest_alg.digest(canonical_body)
# Add body hash (truncate if body_length set)
body = canonical_body
if bl = @body_length
body = body.byte_slice(0, bl)
dkim_header["l"] = bl.to_s
end
dkim_header["bh"]= String.new(digest_alg.update(body).final)
dkim_header["h"] = signed_headers.join(":")
dkim_header["b"] = ""

Expand Down
Loading