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.
@@ -0,0 +1,243 @@
1
+ require "ipaddr"
2
+ require "socket"
3
+
4
+ require "mailauth/spf/budget"
5
+ require "mailauth/spf/macro"
6
+ require "mailauth/spf/record"
7
+
8
+ module MailAuth
9
+ # RFC 7208. Was this IP allowed to send for the envelope domain? Note how
10
+ # little a `pass` says: sender and domain are self-declared, and `+all`
11
+ # passes everyone.
12
+ module Spf
13
+ # An `mx` may resolve ten exchanges and a `ptr` ten names — past that a
14
+ # stranger's zone is choosing how much work we do.
15
+ MAX_MX_EXCHANGES = 10
16
+ MAX_PTR_NAMES = 10
17
+
18
+ QUALIFIERS = {
19
+ "+" => Status::PASS, "-" => Status::FAIL, "~" => Status::SOFTFAIL, "?" => Status::NEUTRAL
20
+ }.freeze
21
+
22
+ # Results check_host() reaches without any mechanism matching. Travel as
23
+ # exceptions because they can happen several `include`s deep and end the
24
+ # whole evaluation, not just the record where they were found.
25
+ class Error < StandardError
26
+ def status
27
+ Status::TEMPERROR
28
+ end
29
+ end
30
+
31
+ # The DNS didn't answer; the same message may pass later.
32
+ class TempError < Error; end
33
+
34
+ # Broken, or asking more work than it's allowed. Retrying changes nothing.
35
+ class PermError < Error
36
+ def status
37
+ Status::PERMERROR
38
+ end
39
+ end
40
+
41
+ # Not a failure: the domain publishes no policy at all.
42
+ class NoRecord < Error
43
+ def status
44
+ Status::NONE
45
+ end
46
+ end
47
+
48
+ # What a macro can expand to. `domain` is whose record we're reading now,
49
+ # which an `include` or `redirect` moves away from the sender's.
50
+ Context = Data.define(:ip, :sender, :helo, :domain) do
51
+ def local
52
+ sender.split("@", 2).first
53
+ end
54
+
55
+ def sender_domain
56
+ sender.split("@", 2).last
57
+ end
58
+
59
+ def for_domain(domain)
60
+ with(domain: domain)
61
+ end
62
+ end
63
+
64
+ # Raises IPAddr::InvalidAddressError on a bad `ip` rather than guessing,
65
+ # which would risk authenticating the wrong host.
66
+ def self.check(ip:, helo: nil, mail_from: nil, resolver: Resolver.new)
67
+ Evaluation.new(ip: ip, helo: helo, mail_from: mail_from, resolver: resolver).run
68
+ end
69
+
70
+ # One run of RFC 7208's check_host(), plus every recursion an `include` or
71
+ # `redirect` drags in. Single use: it carries the shared lookup budget.
72
+ class Evaluation
73
+ LABEL = /[a-z0-9](?:[a-z0-9\-]{0,61}[a-z0-9])?/i
74
+ DOMAIN = /\A(?:#{LABEL}\.)+#{Macro::TOPLABEL}\z/o
75
+
76
+ def initialize(ip:, helo:, mail_from:, resolver:)
77
+ @ip = client_address(ip)
78
+ @helo = helo
79
+ @sender = identity(mail_from)
80
+ @domain = @sender.split("@", 2).last.downcase
81
+ @context = Context.new(ip: @ip, sender: @sender, helo: @helo, domain: @domain)
82
+ @budget = Budget.new(resolver, charging: false)
83
+ end
84
+
85
+ def run
86
+ if checkable?(@domain)
87
+ result check(@domain)
88
+ else
89
+ result Status::NONE
90
+ end
91
+ rescue Error => error
92
+ result error.status, error.message
93
+ end
94
+
95
+ private
96
+ # An IPv4 sender that arrived over IPv6 is still IPv4, and matches
97
+ # only `ip4:` mechanisms.
98
+ def client_address(ip)
99
+ address = IPAddr.new(ip.to_s)
100
+ address.ipv4_mapped? ? address.native : address
101
+ end
102
+
103
+ # A bounce has no envelope sender, so there is no domain to check but
104
+ # the one the client announced itself as.
105
+ def identity(mail_from)
106
+ case mail_from.to_s
107
+ when "" then "postmaster@#{@helo}"
108
+ when /\A@/ then "postmaster#{mail_from}"
109
+ when /@/ then mail_from.to_s
110
+ else "postmaster@#{mail_from}"
111
+ end
112
+ end
113
+
114
+ # A HELO of "OEMCOMPUTER", a domain literal, anything no record could
115
+ # be published under — answers `none` rather than feigning a check.
116
+ def checkable?(domain)
117
+ domain.length <= Budget::MAX_NAME_LENGTH && domain.match?(DOMAIN)
118
+ end
119
+
120
+ # check_host() itself. First match decides; `redirect` is consulted only
121
+ # if none matched, which is why any `all` stops a record redirecting.
122
+ def check(domain)
123
+ record = Record.fetch(domain, budget: @budget, context: @context.for_domain(domain))
124
+
125
+ if matched = record.terms.find { |term| matches?(term) }
126
+ QUALIFIERS.fetch(matched.qualifier)
127
+ elsif record.redirect
128
+ redirected(record.redirect)
129
+ else
130
+ Status::NEUTRAL
131
+ end
132
+ end
133
+
134
+ def matches?(term)
135
+ case term.mechanism
136
+ when :all then true
137
+ when :ip4, :ip6 then matching_network?(term.network)
138
+ when :a then matching_address?(term.target, term, @budget)
139
+ when :mx then matching_exchange?(term)
140
+ when :exists then exists?(term.target)
141
+ when :include then included?(term.target)
142
+ when :ptr then matching_ptr?(term.target)
143
+ end
144
+ end
145
+
146
+ def matching_network?(network)
147
+ network.family == @ip.family && network.include?(@ip)
148
+ end
149
+
150
+ def matching_address?(name, term, budget)
151
+ addresses(name, budget).any? { |address| address.mask(prefix_for(term)).include?(@ip) }
152
+ end
153
+
154
+ # Only the client's own address family is worth asking about: an IPv6
155
+ # client can never match an A record.
156
+ def addresses(name, budget)
157
+ records = @ip.ipv4? ? budget.a(name) : budget.aaaa(name)
158
+
159
+ records.filter_map { |record| client_family_address(record) }
160
+ end
161
+
162
+ def client_family_address(record)
163
+ address = IPAddr.new(record.to_s)
164
+ address if address.family == @ip.family
165
+ rescue IPAddr::Error
166
+ nil # a resolver handing back something that isn't an address tells us nothing
167
+ end
168
+
169
+ def prefix_for(term)
170
+ @ip.ipv4? ? term.cidr4 || 32 : term.cidr6 || 128
171
+ end
172
+
173
+ def matching_exchange?(term)
174
+ exchanges = @budget.mx(term.target).reject { |exchange| exchange.to_s.chomp(".").empty? }
175
+
176
+ if exchanges.length > MAX_MX_EXCHANGES
177
+ raise PermError, "more than #{MAX_MX_EXCHANGES} MX hosts for #{term.target}"
178
+ else
179
+ addresses = @budget.branch
180
+ exchanges.any? { |exchange| matching_address?(exchange, term, addresses) }
181
+ end
182
+ end
183
+
184
+ # Always an A query, never AAAA, regardless of how the client connected.
185
+ def exists?(name)
186
+ @budget.a(name).any?
187
+ end
188
+
189
+ # Only `pass` in the included record counts — `include` borrows
190
+ # another domain's permissions, not its refusals. Its `none` is our
191
+ # permerror.
192
+ def included?(domain)
193
+ check(domain) == Status::PASS
194
+ rescue NoRecord
195
+ raise PermError, "include target #{domain} publishes no SPF record"
196
+ end
197
+
198
+ def redirected(domain)
199
+ check(domain)
200
+ rescue NoRecord
201
+ raise PermError, "redirect target #{domain} publishes no SPF record"
202
+ end
203
+
204
+ # Counts only once the name's own address records point back at the
205
+ # client — the reverse zone belongs to whoever holds the address.
206
+ def matching_ptr?(target)
207
+ names = @budget.ptr(@ip.to_s)
208
+ addresses = @budget.branch
209
+
210
+ names.first(MAX_PTR_NAMES).any? { |name| confirmed?(name, addresses) && under?(name, target) }
211
+ end
212
+
213
+ def confirmed?(name, budget)
214
+ addresses(name, budget).include?(@ip)
215
+ rescue Error
216
+ # A name whose address lookup fails is skipped; the search moves on.
217
+ false
218
+ end
219
+
220
+ def under?(name, target)
221
+ name = name.downcase.chomp(".")
222
+
223
+ name == target || name.end_with?(".#{target}")
224
+ end
225
+
226
+ def result(status, detail = nil)
227
+ SpfResult.new(status: status, domain: @domain, comment: comment(status, detail), lookups: @budget.lookups)
228
+ end
229
+
230
+ def comment(status, detail)
231
+ case status
232
+ when Status::PASS then "domain of #{@sender} designates #{@ip} as permitted sender"
233
+ when Status::FAIL then "domain of #{@sender} does not designate #{@ip} as permitted sender"
234
+ when Status::SOFTFAIL then "domain of transitioning #{@sender} does not designate #{@ip} as permitted sender"
235
+ when Status::NEUTRAL then "#{@ip} is neither permitted nor denied by domain of #{@sender}"
236
+ when Status::NONE then "#{@domain} does not designate permitted sender hosts"
237
+ when Status::PERMERROR then "permanent error in processing #{@domain}: #{detail}"
238
+ when Status::TEMPERROR then "temporary error in processing #{@domain}: #{detail}"
239
+ end
240
+ end
241
+ end
242
+ end
243
+ end
@@ -0,0 +1,3 @@
1
+ module MailAuth
2
+ VERSION = "0.3.0"
3
+ end
data/lib/mailauth.rb ADDED
@@ -0,0 +1,31 @@
1
+ require "mailauth/version"
2
+ require "mailauth/result"
3
+ require "mailauth/resolver"
4
+ require "mailauth/message"
5
+ require "mailauth/spf"
6
+ require "mailauth/dkim"
7
+ require "mailauth/dmarc"
8
+ require "mailauth/arc"
9
+ require "mailauth/authentication_results"
10
+
11
+ # Authenticates a received message against SPF, DKIM, DMARC, and ARC, returning
12
+ # the verdicts and the RFC 8601 Authentication-Results header stating them.
13
+ # Says nothing about whether the message is wanted.
14
+ module MailAuth
15
+ def self.authenticate(raw, ip:, helo: nil, mail_from: nil, mta: nil, resolver: Resolver.new, now: Time.now)
16
+ message = Message.new(raw)
17
+
18
+ spf = Spf.check(ip: ip, helo: helo, mail_from: mail_from, resolver: resolver)
19
+ dkim = Dkim.verify(message, resolver: resolver, now: now)
20
+ dmarc = Dmarc.check(header_from: message.header_from_domain, spf: spf, dkim: dkim, resolver: resolver)
21
+
22
+ # DMARC is measured against this hop and ARC reports on earlier ones, so the
23
+ # two are answered apart. Letting a chain speak for DMARC would hand any
24
+ # sender a pass for the price of sealing their own message.
25
+ arc = Arc.verify(message, resolver: resolver, now: now)
26
+
27
+ Result.new(spf:, dkim:, dmarc:, arc:,
28
+ authentication_results: AuthenticationResults.new(spf:, dkim:, dmarc:, arc:, mta:,
29
+ header_from: message.header_from_domain).to_s)
30
+ end
31
+ end
data/mailauth.gemspec ADDED
@@ -0,0 +1,39 @@
1
+ $LOAD_PATH.push File.expand_path('lib', __dir__)
2
+ require_relative "lib/mailauth/version"
3
+
4
+ Gem::Specification.new do |spec|
5
+ spec.name = "mailauth"
6
+ spec.version = MailAuth::VERSION
7
+ spec.platform = Gem::Platform::RUBY
8
+ spec.required_ruby_version = ">= 3.3.4"
9
+ spec.authors = [ "Simon Lev" ]
10
+ spec.email = [ "support@postrider.dev" ]
11
+
12
+ spec.summary = "SPF, DKIM, DMARC, and ARC verification for received mail, in pure Ruby."
13
+ spec.description = "Answers one question about a received message: is it really from who it says it's from?"
14
+
15
+ spec.homepage = "https://github.com/mailpiece/mailauth"
16
+ spec.license = "MIT"
17
+
18
+ spec.metadata["source_code_uri"] = spec.homepage
19
+ spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
20
+ spec.metadata["bug_tracker_uri"] = "#{spec.homepage}/issues"
21
+ spec.metadata["rubygems_mfa_required"] = "true"
22
+
23
+ # Listed rather than taken from `git ls-files`, which needs a committed git
24
+ # checkout to be evaluated in — and answers an empty list where there isn't
25
+ # one, building a gem that installs nothing and says so only in a warning.
26
+ spec.files = Dir[
27
+ "lib/**/*.rb",
28
+ "CHANGELOG.md",
29
+ "README.md",
30
+ "LICENSE",
31
+ "mailauth.gemspec"
32
+ ]
33
+ spec.require_paths = [ "lib" ]
34
+
35
+ # Resolv collapses SERVFAIL/REFUSED into empty answers; dnsruby does not.
36
+ spec.add_dependency "dnsruby", "~> 1.72"
37
+ spec.add_dependency "public_suffix", ">= 5.0"
38
+ spec.add_dependency "simpleidn", "~> 0.2"
39
+ end
metadata ADDED
@@ -0,0 +1,116 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: mailauth
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.3.0
5
+ platform: ruby
6
+ authors:
7
+ - Simon Lev
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: dnsruby
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '1.72'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '1.72'
26
+ - !ruby/object:Gem::Dependency
27
+ name: public_suffix
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '5.0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '5.0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: simpleidn
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '0.2'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '0.2'
54
+ description: 'Answers one question about a received message: is it really from who
55
+ it says it''s from?'
56
+ email:
57
+ - support@postrider.dev
58
+ executables: []
59
+ extensions: []
60
+ extra_rdoc_files: []
61
+ files:
62
+ - CHANGELOG.md
63
+ - LICENSE
64
+ - README.md
65
+ - lib/mailauth.rb
66
+ - lib/mailauth/arc.rb
67
+ - lib/mailauth/arc/chain.rb
68
+ - lib/mailauth/arc/message_signature.rb
69
+ - lib/mailauth/arc/seal.rb
70
+ - lib/mailauth/arc/set.rb
71
+ - lib/mailauth/authentication_results.rb
72
+ - lib/mailauth/dkim.rb
73
+ - lib/mailauth/dkim/algorithm.rb
74
+ - lib/mailauth/dkim/canonicalization.rb
75
+ - lib/mailauth/dkim/public_key.rb
76
+ - lib/mailauth/dkim/signer.rb
77
+ - lib/mailauth/dmarc.rb
78
+ - lib/mailauth/dmarc/alignment.rb
79
+ - lib/mailauth/dmarc/organizational_domain.rb
80
+ - lib/mailauth/dmarc/record.rb
81
+ - lib/mailauth/domain.rb
82
+ - lib/mailauth/message.rb
83
+ - lib/mailauth/resolver.rb
84
+ - lib/mailauth/result.rb
85
+ - lib/mailauth/spf.rb
86
+ - lib/mailauth/spf/budget.rb
87
+ - lib/mailauth/spf/macro.rb
88
+ - lib/mailauth/spf/record.rb
89
+ - lib/mailauth/version.rb
90
+ - mailauth.gemspec
91
+ homepage: https://github.com/mailpiece/mailauth
92
+ licenses:
93
+ - MIT
94
+ metadata:
95
+ source_code_uri: https://github.com/mailpiece/mailauth
96
+ changelog_uri: https://github.com/mailpiece/mailauth/blob/main/CHANGELOG.md
97
+ bug_tracker_uri: https://github.com/mailpiece/mailauth/issues
98
+ rubygems_mfa_required: 'true'
99
+ rdoc_options: []
100
+ require_paths:
101
+ - lib
102
+ required_ruby_version: !ruby/object:Gem::Requirement
103
+ requirements:
104
+ - - ">="
105
+ - !ruby/object:Gem::Version
106
+ version: 3.3.4
107
+ required_rubygems_version: !ruby/object:Gem::Requirement
108
+ requirements:
109
+ - - ">="
110
+ - !ruby/object:Gem::Version
111
+ version: '0'
112
+ requirements: []
113
+ rubygems_version: 4.0.3
114
+ specification_version: 4
115
+ summary: SPF, DKIM, DMARC, and ARC verification for received mail, in pure Ruby.
116
+ test_files: []