mailauth 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +49 -0
- data/LICENSE +21 -0
- data/README.md +211 -0
- data/lib/mailauth/arc/chain.rb +100 -0
- data/lib/mailauth/arc/message_signature.rb +158 -0
- data/lib/mailauth/arc/seal.rb +97 -0
- data/lib/mailauth/arc/set.rb +74 -0
- data/lib/mailauth/arc.rb +143 -0
- data/lib/mailauth/authentication_results.rb +164 -0
- data/lib/mailauth/dkim/algorithm.rb +28 -0
- data/lib/mailauth/dkim/canonicalization.rb +40 -0
- data/lib/mailauth/dkim/public_key.rb +54 -0
- data/lib/mailauth/dkim/signer.rb +122 -0
- data/lib/mailauth/dkim.rb +270 -0
- data/lib/mailauth/dmarc/alignment.rb +32 -0
- data/lib/mailauth/dmarc/organizational_domain.rb +22 -0
- data/lib/mailauth/dmarc/record.rb +120 -0
- data/lib/mailauth/dmarc.rb +64 -0
- data/lib/mailauth/domain.rb +34 -0
- data/lib/mailauth/message.rb +76 -0
- data/lib/mailauth/resolver.rb +92 -0
- data/lib/mailauth/result.rb +100 -0
- data/lib/mailauth/spf/budget.rb +111 -0
- data/lib/mailauth/spf/macro.rb +121 -0
- data/lib/mailauth/spf/record.rb +188 -0
- data/lib/mailauth/spf.rb +243 -0
- data/lib/mailauth/version.rb +3 -0
- data/lib/mailauth.rb +31 -0
- data/mailauth.gemspec +39 -0
- metadata +116 -0
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
require "openssl"
|
|
2
|
+
|
|
3
|
+
require "mailauth/dmarc/alignment"
|
|
4
|
+
require "mailauth/dkim/canonicalization"
|
|
5
|
+
require "mailauth/dkim/algorithm"
|
|
6
|
+
require "mailauth/dkim/public_key"
|
|
7
|
+
require "mailauth/dkim/signer"
|
|
8
|
+
|
|
9
|
+
module MailAuth
|
|
10
|
+
# RFC 6376. Verifies the DKIM-Signature headers on a message against the
|
|
11
|
+
# public keys their domains publish, and signs outgoing messages with the
|
|
12
|
+
# private ones.
|
|
13
|
+
module Dkim
|
|
14
|
+
CRLF = "\r\n".b
|
|
15
|
+
|
|
16
|
+
# Always maps to permerror: retrying changes nothing when a tag is missing,
|
|
17
|
+
# the algorithm is unsupported, or the key is unreadable.
|
|
18
|
+
class Malformed < StandardError; end
|
|
19
|
+
|
|
20
|
+
HASHES = { "sha1" => OpenSSL::Digest::SHA1, "sha256" => OpenSSL::Digest::SHA256 }.freeze
|
|
21
|
+
SIGNING_ALGORITHMS = %w[ rsa ed25519 ].freeze
|
|
22
|
+
|
|
23
|
+
# What OpenSSL calls a key, mapped to what a= calls it.
|
|
24
|
+
KEY_TYPES = { "rsaEncryption" => "rsa", "ED25519" => "ed25519" }.freeze
|
|
25
|
+
|
|
26
|
+
CANONICALIZATIONS = %w[ simple relaxed ].freeze
|
|
27
|
+
REQUIRED_TAGS = %w[ v a b bh d h s ].freeze
|
|
28
|
+
|
|
29
|
+
# RFC 6376 §3.2: ALPHA *ALNUMPUNC.
|
|
30
|
+
TAG_NAME = /\A[a-zA-Z][a-zA-Z0-9_]*\z/n
|
|
31
|
+
|
|
32
|
+
# Shorter RSA keys are factorable cheaply enough to prove nothing.
|
|
33
|
+
MINIMUM_RSA_BITS = 1024
|
|
34
|
+
|
|
35
|
+
# ed25519 keys travel as 32 raw bytes (RFC 8463 §3), so we put back the
|
|
36
|
+
# SubjectPublicKeyInfo wrapper OpenSSL insists on before reading one.
|
|
37
|
+
ED25519_SPKI_PREFIX = [ "302a300506032b6570032100" ].pack("H*")
|
|
38
|
+
|
|
39
|
+
def self.verify(message, resolver: Resolver.new, now: Time.now)
|
|
40
|
+
signatures = message.fields_named("dkim-signature").map do |field|
|
|
41
|
+
Verification.new(message, field, resolver:, now:).result
|
|
42
|
+
end
|
|
43
|
+
signatures << Signature.new(status: Status::NONE, comment: "message not signed") if signatures.empty?
|
|
44
|
+
|
|
45
|
+
DkimResult.new(signatures:)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def self.sign(raw, domain:, selector:, key:, headers: nil, oversign: Signer::OVERSIGNED_HEADERS, expires_in: nil, now: Time.now)
|
|
49
|
+
Signer.new(raw, domain:, selector:, key:, headers:, oversign:, expires_in:, now:).signed_message
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# RFC 6376 §3.2. A malformed tag name, a repeated one, or an empty entry
|
|
53
|
+
# invalidates the whole list rather than the one tag: reading the rest
|
|
54
|
+
# would take a forged tag as read beside the one silently dropped. Values
|
|
55
|
+
# fold across lines, so callers strip whitespace themselves on the tags
|
|
56
|
+
# where it matters.
|
|
57
|
+
def self.parse_tags(text)
|
|
58
|
+
specs = text.split(";", -1)
|
|
59
|
+
specs.pop if specs.last&.strip&.empty? # the tag-list's optional trailing ";"
|
|
60
|
+
|
|
61
|
+
specs.each_with_object({}) do |spec, tags|
|
|
62
|
+
name, value = spec.split("=", 2)
|
|
63
|
+
name = name.to_s.strip
|
|
64
|
+
|
|
65
|
+
return nil unless value && name.match?(TAG_NAME) && !tags.key?(name)
|
|
66
|
+
|
|
67
|
+
tags[name] = value.strip
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# One DKIM-Signature header, taken from its tags to a verdict.
|
|
72
|
+
class Verification
|
|
73
|
+
def initialize(message, field, resolver:, now:)
|
|
74
|
+
@message, @field, @resolver, @now = message, field, resolver, now
|
|
75
|
+
tags = Dkim.parse_tags(field.split(":", 2).last.to_s)
|
|
76
|
+
@well_formed = !tags.nil?
|
|
77
|
+
@tags = tags || {}
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def result
|
|
81
|
+
validate
|
|
82
|
+
|
|
83
|
+
if expired?
|
|
84
|
+
failed("signature expired")
|
|
85
|
+
elsif postdated?
|
|
86
|
+
failed("signature timestamp in the future")
|
|
87
|
+
elsif body_hash_matches?
|
|
88
|
+
verified? ? signature(Status::PASS) : failed("bad signature")
|
|
89
|
+
else
|
|
90
|
+
# Some verifiers call this neutral. We call it failure: the signer
|
|
91
|
+
# said what the body would hash to, and it hashes to something else.
|
|
92
|
+
failed("body hash did not verify")
|
|
93
|
+
end
|
|
94
|
+
rescue Malformed => error
|
|
95
|
+
signature(Status::PERMERROR, comment: error.message)
|
|
96
|
+
rescue Resolver::NotFound
|
|
97
|
+
signature(Status::PERMERROR, comment: "no key for #{key_name}")
|
|
98
|
+
rescue Resolver::Timeout, Resolver::ServerFailure
|
|
99
|
+
signature(Status::TEMPERROR, comment: "DNS failure resolving #{key_name}")
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
def validate
|
|
104
|
+
raise Malformed, "invalid tag list" unless @well_formed
|
|
105
|
+
raise Malformed, "unsupported signature version" unless @tags["v"] == "1"
|
|
106
|
+
|
|
107
|
+
if missing = REQUIRED_TAGS.find { |tag| @tags[tag].to_s.empty? }
|
|
108
|
+
raise Malformed, "missing #{missing}= tag"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
unless SIGNING_ALGORITHMS.include?(signing_algorithm) && HASHES.key?(hash_algorithm)
|
|
112
|
+
raise Malformed, "unsupported algorithm #{@tags["a"]}"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
unless CANONICALIZATIONS.include?(header_canonicalization) && CANONICALIZATIONS.include?(body_canonicalization)
|
|
116
|
+
raise Malformed, "unsupported canonicalization #{canonicalization}"
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# A signature that leaves From unsigned says nothing about who the
|
|
120
|
+
# message claims to be from, which is the only question here.
|
|
121
|
+
raise Malformed, "From is not signed" unless signed_header_names.include?("from")
|
|
122
|
+
|
|
123
|
+
raise Malformed, "invalid l= tag" if @tags["l"] && !@tags["l"].match?(/\A\d+\z/n)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def expired?
|
|
127
|
+
expires_at && expires_at < @now
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def expires_at
|
|
131
|
+
Time.at(@tags["x"].to_i) if @tags["x"]&.match?(/\A\d+\z/n)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def postdated?
|
|
135
|
+
signed_at && signed_at > @now
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def signed_at
|
|
139
|
+
Time.at(@tags["t"].to_i) if @tags["t"]&.match?(/\A\d+\z/n)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def body_hash_matches?
|
|
143
|
+
digest.digest(signed_body) == unfolded("bh").unpack1("m")
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# l= counts canonicalized bytes, not the bytes that arrived — the tag
|
|
147
|
+
# signs a prefix of the canonical body, not of the message on the wire.
|
|
148
|
+
def signed_body
|
|
149
|
+
if body_limit
|
|
150
|
+
canonical_body.byteslice(0, body_limit)
|
|
151
|
+
else
|
|
152
|
+
canonical_body
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def body_limit
|
|
157
|
+
@tags["l"].to_i if @tags["l"]&.match?(/\A\d+\z/n)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def canonical_body
|
|
161
|
+
@canonical_body ||= Canonicalization.body(@message.body, body_canonicalization)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def verified?
|
|
165
|
+
Algorithm.verified?(canonical_headers, key: public_key, digest: digest,
|
|
166
|
+
signature: unfolded("b").unpack1("m"))
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def public_key
|
|
170
|
+
PublicKey.retrieve(selector: @tags["s"], domain: @tags["d"],
|
|
171
|
+
algorithm: signing_algorithm, resolver: @resolver)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def key_name
|
|
175
|
+
PublicKey.name_for(selector: @tags["s"], domain: @tags["d"])
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# No trailing CRLF after the signature field: the bytes after b= are
|
|
179
|
+
# what the signature covers.
|
|
180
|
+
def canonical_headers
|
|
181
|
+
signed_fields.map { |field| canonicalize_field(field) + CRLF }.join + canonical_signature_field
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Takes the last unused instance of each named header, walking upward
|
|
185
|
+
# through duplicates. Naming a header that isn't present (oversigning)
|
|
186
|
+
# contributes nothing but forbids one being added later.
|
|
187
|
+
def signed_fields
|
|
188
|
+
remaining = @message.fields.dup
|
|
189
|
+
|
|
190
|
+
signed_header_names.filter_map do |name|
|
|
191
|
+
if index = remaining.rindex { |field| @message.field_name(field) == name }
|
|
192
|
+
remaining.delete_at(index)
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def signed_header_names
|
|
198
|
+
unfolded("h").to_s.downcase.split(":").reject(&:empty?)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def canonical_signature_field
|
|
202
|
+
canonicalize_field(@field).sub(/([;:\s]+b=)[^;]*/n, '\1')
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def canonicalize_field(field)
|
|
206
|
+
Canonicalization.field(field, header_canonicalization)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def digest
|
|
210
|
+
HASHES.fetch(hash_algorithm)
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def signing_algorithm
|
|
214
|
+
@tags["a"].to_s.split("-").first.to_s.downcase
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def hash_algorithm
|
|
218
|
+
@tags["a"].to_s.split("-").last.to_s.downcase
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def header_canonicalization
|
|
222
|
+
canonicalization.split("/").first
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def body_canonicalization
|
|
226
|
+
canonicalization.split("/")[1] || "simple"
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def canonicalization
|
|
230
|
+
@tags["c"].to_s.downcase.strip.empty? ? "simple/simple" : @tags["c"].downcase.strip
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# Tags whose value is base64 or a colon-separated list carry folding
|
|
234
|
+
# whitespace that was never part of the value.
|
|
235
|
+
def unfolded(tag)
|
|
236
|
+
@tags[tag]&.gsub(/[ \t\r\n]+/n, "")
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def failed(comment)
|
|
240
|
+
signature(Status::FAIL, comment:)
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def signature(status, comment: nil)
|
|
244
|
+
Signature.new(status:, domain: @tags["d"]&.downcase, selector: @tags["s"], algorithm: @tags["a"],
|
|
245
|
+
canonicalization: canonicalization, comment:, aligned: aligned?, under_sized: under_sized)
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Relaxed DMARC alignment: the From: domain and the signing domain need
|
|
249
|
+
# only share an organizational domain, not match exactly.
|
|
250
|
+
def aligned?
|
|
251
|
+
from, signing_domain = @message.header_from_domain, @tags["d"]&.downcase
|
|
252
|
+
|
|
253
|
+
if from && signing_domain
|
|
254
|
+
Dmarc::Alignment.aligned?(from, signing_domain, strict: false)
|
|
255
|
+
else
|
|
256
|
+
false
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# In canonicalized bytes — the tail a signature says nothing about,
|
|
261
|
+
# and where content gets smuggled.
|
|
262
|
+
def under_sized
|
|
263
|
+
if body_limit && CANONICALIZATIONS.include?(body_canonicalization)
|
|
264
|
+
uncovered = canonical_body.bytesize - body_limit
|
|
265
|
+
uncovered if uncovered.positive?
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
require "mailauth/domain"
|
|
2
|
+
require "mailauth/dmarc/organizational_domain"
|
|
3
|
+
|
|
4
|
+
module MailAuth
|
|
5
|
+
# RFC 7489 §3.1. Does a domain that SPF or DKIM authenticated stand for the
|
|
6
|
+
# same organization as the From: domain a reader sees?
|
|
7
|
+
module Dmarc
|
|
8
|
+
module Alignment
|
|
9
|
+
class << self
|
|
10
|
+
# Strict and relaxed are separate rules, not a fallback pair — falling
|
|
11
|
+
# back to relaxed after a strict miss would make `adkim=s`/`aspf=s`
|
|
12
|
+
# decorative.
|
|
13
|
+
def aligned?(from, candidate, strict:)
|
|
14
|
+
from, candidate = normalize(from), normalize(candidate)
|
|
15
|
+
|
|
16
|
+
if from.empty? || candidate.empty?
|
|
17
|
+
false
|
|
18
|
+
elsif strict
|
|
19
|
+
from == candidate
|
|
20
|
+
else
|
|
21
|
+
OrganizationalDomain.of(from) == OrganizationalDomain.of(candidate)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
def normalize(domain)
|
|
27
|
+
Domain.normalize(domain)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
require "public_suffix"
|
|
2
|
+
|
|
3
|
+
require "mailauth/domain"
|
|
4
|
+
|
|
5
|
+
module MailAuth
|
|
6
|
+
module Dmarc
|
|
7
|
+
# The registrable domain — "bar.co.uk" for "foo.bar.co.uk", never "co.uk".
|
|
8
|
+
# Only the Public Suffix List can answer this; counting labels would put
|
|
9
|
+
# bank.co.uk and attacker.co.uk in the same organization.
|
|
10
|
+
module OrganizationalDomain
|
|
11
|
+
def self.of(name)
|
|
12
|
+
domain = Domain.normalize(name)
|
|
13
|
+
|
|
14
|
+
# Private suffixes are boundaries too: two tenants of one host
|
|
15
|
+
# (user1.blogspot.com, user2.blogspot.com) are separate organizations.
|
|
16
|
+
PublicSuffix.domain(domain, ignore_private: false) || domain
|
|
17
|
+
rescue PublicSuffix::Error
|
|
18
|
+
domain # under no known suffix, a name can only align with itself
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
require "mailauth/dmarc/organizational_domain"
|
|
2
|
+
|
|
3
|
+
module MailAuth
|
|
4
|
+
module Dmarc
|
|
5
|
+
# The policy a domain publishes at _dmarc.<domain>, plus the one thing the
|
|
6
|
+
# text itself doesn't say: whether we had to walk up to the organizational
|
|
7
|
+
# domain to find it, which is what decides between `p` and `sp`.
|
|
8
|
+
class Record
|
|
9
|
+
DMARC1 = /\Av=DMARC1\b/i
|
|
10
|
+
TAGS = %w[ v p sp adkim aspf pct rua ruf fo rf ri ].freeze
|
|
11
|
+
POLICIES = %w[ none quarantine reject ].freeze
|
|
12
|
+
STRICT = "s".freeze
|
|
13
|
+
DEFAULT_PCT = 100
|
|
14
|
+
|
|
15
|
+
class << self
|
|
16
|
+
# DNS failures are raised rather than swallowed: "we couldn't ask" is
|
|
17
|
+
# a different answer from "there is nothing there".
|
|
18
|
+
def find(domain, resolver:)
|
|
19
|
+
at(domain, resolver: resolver) || at_organizational_domain(domain, resolver: resolver)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
def at(domain, resolver:, organizational: false)
|
|
24
|
+
texts = resolver.txt("_dmarc.#{domain}").map(&:strip).grep(DMARC1)
|
|
25
|
+
|
|
26
|
+
# Two policies are as good as none: RFC 7489 §6.6.3 would rather
|
|
27
|
+
# apply nothing than guess which one the domain meant.
|
|
28
|
+
if texts.one? && (record = new(texts.first, organizational: organizational)).usable?
|
|
29
|
+
record
|
|
30
|
+
end
|
|
31
|
+
rescue Resolver::NotFound
|
|
32
|
+
nil
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# A subdomain with no record of its own — or one too broken to apply —
|
|
36
|
+
# falls back to its parent, which is how a single record at the
|
|
37
|
+
# organizational domain covers a whole tree of hostnames.
|
|
38
|
+
def at_organizational_domain(domain, resolver:)
|
|
39
|
+
organizational = OrganizationalDomain.of(domain)
|
|
40
|
+
|
|
41
|
+
at(organizational, resolver: resolver, organizational: true) unless organizational == domain
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
attr_reader :tags
|
|
46
|
+
|
|
47
|
+
def initialize(text, organizational: false)
|
|
48
|
+
@text, @organizational = text, organizational
|
|
49
|
+
@tags = parse(text)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# `p` is the one required tag beyond the version; without it the record
|
|
53
|
+
# may as well not exist.
|
|
54
|
+
def usable?
|
|
55
|
+
!requested_policy.nil?
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# RFC 7489 §6.6.3: mail from a subdomain answers to the parent's `sp`,
|
|
59
|
+
# deliberately not the parent's own `p` — an organization can reject
|
|
60
|
+
# forgeries of unused hostnames while still easing its own mail through
|
|
61
|
+
# under p=none.
|
|
62
|
+
def policy
|
|
63
|
+
if organizational? && subdomain_policy
|
|
64
|
+
subdomain_policy
|
|
65
|
+
else
|
|
66
|
+
requested_policy
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Surfaced, never applied: rolling the `pct` dice here would make the
|
|
71
|
+
# same message pass and fail at random, which an authentication result
|
|
72
|
+
# must never do.
|
|
73
|
+
def pct
|
|
74
|
+
Integer(tags["pct"], exception: false)&.clamp(0, 100) || DEFAULT_PCT
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Both alignment tags default to relaxed, so only an explicit "s" is strict.
|
|
78
|
+
def strict_dkim?
|
|
79
|
+
tags["adkim"]&.downcase == STRICT
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def strict_spf?
|
|
83
|
+
tags["aspf"]&.downcase == STRICT
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def organizational?
|
|
87
|
+
@organizational
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def to_s
|
|
91
|
+
@text
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
# Unknown tags are dropped rather than treated as an error, so a
|
|
96
|
+
# record using a tag from a later extension still governs the mail it
|
|
97
|
+
# was published for.
|
|
98
|
+
def parse(text)
|
|
99
|
+
text.to_s.split(";").filter_map { |tag|
|
|
100
|
+
name, value = tag.split("=", 2)
|
|
101
|
+
name = name.to_s.strip.downcase
|
|
102
|
+
|
|
103
|
+
[ name, value.strip ] if value && TAGS.include?(name)
|
|
104
|
+
}.to_h
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def requested_policy
|
|
108
|
+
validated(tags["p"])
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def subdomain_policy
|
|
112
|
+
validated(tags["sp"])
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def validated(policy)
|
|
116
|
+
policy&.downcase&.then { |value| value if POLICIES.include?(value) }
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
require "mailauth/dmarc/alignment"
|
|
2
|
+
require "mailauth/dmarc/record"
|
|
3
|
+
|
|
4
|
+
module MailAuth
|
|
5
|
+
# RFC 7489. Do the SPF or DKIM results line up with the From: domain a reader
|
|
6
|
+
# sees, and what does that domain ask us to do when they don't?
|
|
7
|
+
module Dmarc
|
|
8
|
+
class << self
|
|
9
|
+
# Takes the already-computed SPF and DKIM results — alignment is a
|
|
10
|
+
# comparison, not a check of its own. None or several From: domains is
|
|
11
|
+
# `permerror`, not `none`: the message is malformed, not simply
|
|
12
|
+
# unpolicied.
|
|
13
|
+
def check(header_from:, spf:, dkim:, resolver: Resolver.new)
|
|
14
|
+
if domain = sole_domain(header_from)
|
|
15
|
+
evaluate(domain, spf: spf, dkim: dkim, resolver: resolver)
|
|
16
|
+
else
|
|
17
|
+
DmarcResult.new(status: Status::PERMERROR)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
def sole_domain(header_from)
|
|
23
|
+
domains = Array(header_from).map { |domain| domain.to_s.strip.downcase.chomp(".") }.reject(&:empty?)
|
|
24
|
+
|
|
25
|
+
domains.first if domains.one?
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def evaluate(domain, spf:, dkim:, resolver:)
|
|
29
|
+
if record = Record.find(domain, resolver: resolver)
|
|
30
|
+
verdict(domain, record, spf: spf, dkim: dkim)
|
|
31
|
+
else
|
|
32
|
+
DmarcResult.new(status: Status::NONE)
|
|
33
|
+
end
|
|
34
|
+
rescue Resolver::Timeout, Resolver::ServerFailure
|
|
35
|
+
# Calling this `none` would hand a spoofer a free pass every time a
|
|
36
|
+
# nameserver hiccups.
|
|
37
|
+
DmarcResult.new(status: Status::TEMPERROR)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def verdict(domain, record, spf:, dkim:)
|
|
41
|
+
alignment = { dkim: dkim_aligned?(domain, dkim, record), spf: spf_aligned?(domain, spf, record) }
|
|
42
|
+
|
|
43
|
+
DmarcResult.new \
|
|
44
|
+
status: alignment.values.any? ? Status::PASS : Status::FAIL,
|
|
45
|
+
policy: record.policy,
|
|
46
|
+
pct: record.pct,
|
|
47
|
+
alignment: alignment,
|
|
48
|
+
record: record.to_s
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# .any? because a forwarder's own valid signature can sit happily
|
|
52
|
+
# beside the author's — one aligned pass is enough.
|
|
53
|
+
def dkim_aligned?(domain, dkim, record)
|
|
54
|
+
Array(dkim&.passing_domains).any? do |signing_domain|
|
|
55
|
+
Alignment.aligned?(domain, signing_domain, strict: record.strict_dkim?)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def spf_aligned?(domain, spf, record)
|
|
60
|
+
spf&.status == Status::PASS && Alignment.aligned?(domain, spf.domain, strict: record.strict_spf?)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
require "simpleidn"
|
|
2
|
+
|
|
3
|
+
module MailAuth
|
|
4
|
+
# One spelling for a domain name, so two of them can be compared. An
|
|
5
|
+
# internationalized domain travels both as the U-label a person reads
|
|
6
|
+
# (münchen.example) and the A-label DNS and DKIM's d= carry
|
|
7
|
+
# (xn--mnchen-3ya.example); comparing those byte-wise fails DMARC alignment
|
|
8
|
+
# on perfectly good mail.
|
|
9
|
+
module Domain
|
|
10
|
+
class << self
|
|
11
|
+
def normalize(name)
|
|
12
|
+
name = printable(name).strip.chomp(".")
|
|
13
|
+
|
|
14
|
+
ascii(name).downcase
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
# These names come from headers and DNS records, so they can hold any
|
|
19
|
+
# bytes at all — and invalid UTF-8 raises on the first thing done to it.
|
|
20
|
+
def printable(name)
|
|
21
|
+
name = name.to_s
|
|
22
|
+
name.valid_encoding? ? name : name.scrub("")
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def ascii(name)
|
|
26
|
+
name.ascii_only? ? name : SimpleIDN.to_ascii(name)
|
|
27
|
+
rescue StandardError
|
|
28
|
+
# A name punycode can't encode can't be looked up either, so let it
|
|
29
|
+
# fail to match rather than raising here.
|
|
30
|
+
name
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
module MailAuth
|
|
2
|
+
# A received message, split once into the parts every check needs. Binary
|
|
3
|
+
# throughout: DKIM hashes bytes, so transcoding changes the answer.
|
|
4
|
+
class Message
|
|
5
|
+
HEADER_SEPARATOR = "\r\n\r\n".b
|
|
6
|
+
|
|
7
|
+
attr_reader :raw, :headers, :body
|
|
8
|
+
|
|
9
|
+
def initialize(raw)
|
|
10
|
+
@raw = normalize(raw)
|
|
11
|
+
@headers, @body = @raw.split(HEADER_SEPARATOR, 2)
|
|
12
|
+
@headers = @headers.to_s
|
|
13
|
+
@body = @body.to_s
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Order is load-bearing: DKIM walks duplicate headers from the bottom up.
|
|
17
|
+
def fields
|
|
18
|
+
@fields ||= headers.lines.each_with_object([]) do |line, fields|
|
|
19
|
+
if fields.any? && line.start_with?(" ", "\t")
|
|
20
|
+
fields.last << line
|
|
21
|
+
else
|
|
22
|
+
fields << line
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def fields_named(name)
|
|
28
|
+
fields.select { |field| field_name(field) == name.downcase }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def field_name(field)
|
|
32
|
+
field.split(":", 2).first.to_s.strip.downcase
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# What DMARC alignment is measured against. Quoted display names are
|
|
36
|
+
# stripped first: `From: "Foo <evil@attacker.example>" <real@bank.example>`
|
|
37
|
+
# renders as bank.example, and taking the first address found would align
|
|
38
|
+
# against the attacker's domain instead. Several From addresses (RFC 5322
|
|
39
|
+
# permits it; DMARC doesn't) is no domain rather than a guess.
|
|
40
|
+
def header_from_domain
|
|
41
|
+
addresses = from_addresses
|
|
42
|
+
|
|
43
|
+
if addresses.one?
|
|
44
|
+
addresses.first.downcase.chomp(".")
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
# Bare LF becomes CRLF so a message that took a lossy path still
|
|
50
|
+
# canonicalizes as its signer intended; a lone CR is left alone.
|
|
51
|
+
def normalize(raw)
|
|
52
|
+
raw.to_s.b.gsub(/\r?\n/n, "\r\n")
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# RFC 5322 §3.6 permits exactly one From field, and a second one is how a
|
|
56
|
+
# message says two different things at once: DKIM signs the last From
|
|
57
|
+
# (§5.4.2) while a reader is shown the first, so a signature over an
|
|
58
|
+
# attacker's own domain would align against a name nobody sees. Anything
|
|
59
|
+
# but one field carrying one address is no domain at all.
|
|
60
|
+
def from_addresses
|
|
61
|
+
fields = fields_named("from")
|
|
62
|
+
|
|
63
|
+
if fields.one?
|
|
64
|
+
unquoted(fields.first.split(":", 2).last.to_s).scan(/@([^\s>,;()]+)/).flatten
|
|
65
|
+
else
|
|
66
|
+
[]
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Quoted strings and comments can hold anything, including something that
|
|
71
|
+
# reads as an address; neither is one.
|
|
72
|
+
def unquoted(value)
|
|
73
|
+
value.gsub(/"(?:\\.|[^"\\])*"/n, " ").gsub(/\((?:\\.|[^()\\])*\)/n, " ")
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|