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,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