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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 77dfb8831d86e9be13316fe465a7655bf0aedf4da6164cdd0d2c4ab51cb5f6a4
4
+ data.tar.gz: dd67a9d118d2410deeb00d57f907075c9d77e58637f69b2989ed7c1066e01fd8
5
+ SHA512:
6
+ metadata.gz: 3ebf36b61620326e742d838d3472dc32f8e9fb43812126e27e00daed54e947711167ab42742622432f28344b5ceeeee3d1d2348f1187212a4708c2138a821770
7
+ data.tar.gz: c35623984649fb47802e1a61386bbb8cdb91f192b2597209d5556ccafba35c97099afc482fa531837d94e54ce29a72131b82f6f25fdb2f19e446bbe03b7effd9
data/CHANGELOG.md ADDED
@@ -0,0 +1,49 @@
1
+ # Changelog
2
+
3
+ ## 0.3.0
4
+
5
+ **A nameserver failing is no longer read as a domain publishing nothing.** Expect
6
+ your verdict distribution to shift: lookups that failed against SERVFAIL or
7
+ REFUSED previously returned `none` from SPF and DMARC and `permerror` from DKIM,
8
+ and now return `temperror`. Nothing was wrong at the call sites — they always
9
+ handled a server failure correctly — but the resolver beneath them could not
10
+ raise one, so the branch never ran.
11
+
12
+ The cause was in the standard library. `Resolv::DNS#getresources` turns an rcode
13
+ into an exception and then discards it unless the failure was a timeout, so
14
+ SERVFAIL and REFUSED came back as the same empty array as NXDOMAIN and NODATA. A
15
+ domain publishing `p=reject` whose nameserver was unwell was reported as a domain
16
+ publishing no DMARC policy at all, and SPF would state that it "does not
17
+ designate permitted sender hosts" on the strength of an answer nobody gave.
18
+
19
+ - Resolution moved from `Resolv` to [dnsruby](https://github.com/alexdalitz/dnsruby), which reports each rcode as a
20
+ distinct class, so the distinction is read off the wire rather than inferred.
21
+ This adds a runtime dependency; its own dependencies are `base64`, `logger`,
22
+ and `simpleidn`, all of them default gems and the last already ours
23
+ - `temperror` for SERVFAIL and REFUSED, across SPF, DMARC, DKIM, and
24
+ `Authentication-Results`. NXDOMAIN and NOERROR-with-no-records still mean
25
+ absence, and still read `none`
26
+ - An unrecognised resolver error now reports a server failure rather than a
27
+ missing record. "We could not tell" must not be delivered as "there is nothing
28
+ there"
29
+ - DNS answers are no longer cached inside the library. Dnsruby caches by default
30
+ in a store shared across every resolver in the process; a caller who wants
31
+ caching has the `resolver:` seam to put it behind
32
+ - Errors name the record type as DNS does. `Resolv` names some of its generated
33
+ classes after their type and class numbers, so a deferral could read "no
34
+ Type16_Class1 records for example.com"
35
+
36
+ ## 0.2.0
37
+
38
+ - ARC verification (RFC 8617)
39
+
40
+ ## 0.1.0
41
+
42
+ Initial release.
43
+
44
+ - SPF verification (RFC 7208), checked against the official 200-case test suite
45
+ - DKIM verification (RFC 6376), RSA and Ed25519 (RFC 8463), all four canonicalizations
46
+ - DKIM signing, RSA and Ed25519, oversigning by default, checked byte-for-byte against the RFC 8463 sample signatures
47
+ - DMARC verification (RFC 7489), strict and relaxed alignment, organizational domains via the Public Suffix List
48
+ - `Authentication-Results` header generation (RFC 8601)
49
+ - Injectable resolver with an error taxonomy that distinguishes a void lookup from a server failure
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 PostRider
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,211 @@
1
+ # mailauth
2
+
3
+ ✉️ Email authentication for Ruby
4
+
5
+ **mailauth** verifies SPF, DKIM, DMARC, and ARC on received mail, and signs outgoing mail with DKIM. Pure Ruby, no dependencies beyond `dnsruby`.
6
+
7
+ It answers one question about a message: is it really from who it says it's from?
8
+
9
+ ## Features
10
+
11
+ - SPF verification (RFC 7208)
12
+ - DKIM verification (RFC 6376), RSA and Ed25519
13
+ - DKIM signing (RFC 6376), RSA and Ed25519, oversigning by default
14
+ - DMARC verification (RFC 7489), strict and relaxed alignment
15
+ - Authentication-Results header generation (RFC 8601)
16
+ - ARC verification (RFC 8617), reporting what each hop sealed and left to you to trust
17
+ - Injectable resolver, so tests run against a hash instead of a network
18
+
19
+ Not planned: MTA-STS (outbound TLS policy, not message auth), `Received-SPF` (Authentication-Results already covers it), `%{p}` (RFC 7208 discourages PTR).
20
+
21
+ > Not a spam filter.
22
+
23
+
24
+ ## Getting Started
25
+
26
+ Add this line to your application's Gemfile:
27
+
28
+ ```ruby
29
+ gem "mailauth"
30
+ ```
31
+
32
+ Requires Ruby >= 3.3.4
33
+
34
+ ## Authentication
35
+
36
+ ```ruby
37
+ result = MailAuth.authenticate(raw_message, ip:, helo:, mail_from:, mta:, resolver:, now:)
38
+ # => #<MailAuth::Result spf:, dkim:, dmarc:, arc:, authentication_results:>
39
+ ```
40
+
41
+ - **raw_message** - the RFC 822 bytes as they arrived
42
+ - **ip** - IP address of the connecting client
43
+ - **helo** - optional, hostname from the HELO/EHLO command
44
+ - **mail_from** - optional, envelope sender; empty means `postmaster@helo` is checked
45
+ - **mta** - optional, your authserv-id, for the generated header
46
+ - **resolver** - optional, defaults to `MailAuth::Resolver.new`
47
+ - **now** - optional `Time`, for signature expiry, defaults to `Time.now`
48
+
49
+ ```ruby
50
+ require "mailauth"
51
+
52
+ result = MailAuth.authenticate(raw_message,
53
+ ip: "209.85.220.41",
54
+ helo: "mail-sor-f41.google.com",
55
+ mail_from: "someone@gmail.com",
56
+ mta: "mx.example.com")
57
+
58
+ puts result.authentication_results
59
+ ```
60
+
61
+ ```ruby
62
+ mx.example.com;
63
+ spf=pass
64
+ (domain of someone@gmail.com designates 209.85.220.41 as permitted sender)
65
+ smtp.mailfrom=gmail.com;
66
+ dkim=pass header.d=gmail.com header.s=20251104 header.a=rsa-sha256;
67
+ dmarc=pass (p=NONE) header.from=gmail.com
68
+ ```
69
+
70
+ ## Results
71
+
72
+ ```ruby
73
+ result.spf.status # => "pass"
74
+ result.spf.domain # => "gmail.com"
75
+
76
+ result.dkim.status # => "pass" - the best of every signature on the message
77
+ result.dkim.signatures # => [#<MailAuth::Signature status: "pass", domain: "gmail.com", ...>]
78
+
79
+ result.dmarc.status # => "pass"
80
+ result.dmarc.policy # => "reject" - what the domain asked for
81
+ result.dmarc.alignment # => { dkim: true, spf: false }
82
+ ```
83
+
84
+ Every status is one of `pass`, `fail`, `softfail`, `neutral`, `none`, `temperror`, `permerror` - the RFC 8601 vocabulary as plain strings, so a verdict persists without translation.
85
+
86
+ `status` and `policy` stay separate. A DMARC `fail` under `p=none` and one under `p=reject` are the same failure; only the second is a reason to refuse the mail.
87
+
88
+ ## Individual Checks
89
+
90
+ Each protocol is usable on its own. SPF needs no message at all, so it can answer at `RCPT TO` time, before any data has arrived:
91
+
92
+ ```ruby
93
+ MailAuth::Spf.check(ip: "209.85.220.41", helo: "mail-sor-f41.google.com",
94
+ mail_from: "someone@gmail.com")
95
+ # => #<MailAuth::SpfResult status: "pass", domain: "gmail.com", lookups: 1, ...>
96
+
97
+ message = MailAuth::Message.new(raw_message)
98
+
99
+ MailAuth::Dkim.verify(message)
100
+ # => #<MailAuth::DkimResult signatures: [...]>
101
+
102
+ MailAuth::Dmarc.check(header_from: message.header_from_domain, spf: spf, dkim: dkim)
103
+ # => #<MailAuth::DmarcResult status: "pass", policy: "none", ...>
104
+
105
+ MailAuth::Arc.verify(message)
106
+ # => #<MailAuth::ArcResult status: "pass", sets: [...], ...>
107
+ ```
108
+
109
+ DMARC takes the SPF and DKIM results rather than recomputing them - alignment is a comparison, not a check of its own. ARC takes neither: a chain says what an earlier hop concluded, a separate question from what this one measured.
110
+
111
+ ## When It Doesn't Pass
112
+
113
+ Nothing raises. A verdict is always returned, and the interesting ones carry a `comment`:
114
+
115
+ ```ruby
116
+ result.dkim.signatures.each do |signature|
117
+ puts "#{signature.domain}: #{signature.status} #{signature.comment}"
118
+ end
119
+ # gmail.com: fail body hash did not verify
120
+ # example.com: temperror DNS failure resolving sel._domainkey.example.com
121
+ ```
122
+
123
+ - `fail` - the check ran and the message did not pass it
124
+ - `temperror` - DNS didn't answer; the same message may well pass later, don't treat it as a failure
125
+ - `permerror` - the record or signature is broken; retrying changes nothing
126
+ - `none` - the domain published no policy, or the message carried no signature
127
+
128
+
129
+
130
+ ## Resolver
131
+
132
+ Every lookup goes through one object with five methods - `txt`, `a`, `aaaa`, `mx`, `ptr`:
133
+
134
+ ```ruby
135
+ MailAuth.authenticate(raw, ip: ip, resolver: MyCachingResolver.new)
136
+ ```
137
+
138
+ Raise `NotFound` for a void lookup (SPF counts these against a limit), `Timeout` or `ServerFailure` for no answer, which produces a `temperror` that stops evaluation. Collapsing those into empty arrays loses a distinction SPF spends much of its rulebook on.
139
+
140
+ The default resolver uses [dnsruby](https://github.com/alexdalitz/dnsruby) rather than `Resolv`, which collapses SERVFAIL/REFUSED into the same empty answer as NXDOMAIN. It caches nothing and does not validate DNSSEC.
141
+
142
+ ## ARC
143
+
144
+ Forwarding breaks SPF, and mailing lists that rewrite a subject line break DKIM. An Authenticated Received Chain carries what each hop concluded before that happened, sealed against later editing:
145
+
146
+ ```ruby
147
+ result.arc.status # => "pass" - the chain is intact
148
+ result.arc.sealers # => ["lists.example.org"] - oldest hop first
149
+ result.arc.newest.claimed
150
+ # => { "spf" => "pass", "dkim" => "pass", "dmarc" => "pass" }
151
+ ```
152
+
153
+ An intact chain is not a reason to believe it. Sealing a chain over a message of one's own costs nothing, so a `pass` from a sealer you've never heard of means only that they signed what they signed. This library reports who sealed and what they asserted; deciding whose word counts is yours:
154
+
155
+ ```ruby
156
+ if result.arc.intact? && TRUSTED_SEALERS.include?(result.arc.newest.seal.domain)
157
+ result.arc.newest.claimed["dmarc"]
158
+ end
159
+ ```
160
+
161
+ For the same reason a chain never touches the DMARC verdict. `result.dmarc` is what this hop measured; letting a chain speak for it would sell a pass for the price of a signature.
162
+
163
+ ## Signing
164
+
165
+ ```ruby
166
+ signed = MailAuth::Dkim.sign(raw_message,
167
+ domain: "example.com", selector: "mail",
168
+ key: OpenSSL::PKey.read(File.read("private.pem")))
169
+ # => the message with a DKIM-Signature field prepended
170
+ ```
171
+
172
+ The algorithm comes from the key: an RSA key signs `rsa-sha256`, an Ed25519 key `ed25519-sha256` (RFC 8463). Canonicalization is `relaxed/relaxed`.
173
+
174
+ - **headers** - names to sign; defaults to the RFC 6376 §5.4.1 set
175
+ - **oversign** - names listed in `h=` once more than they occur, so an instance added after signing breaks it. Defaults to `from`, `to`, `cc`, `subject`, `date`, `reply-to`, `message-id`. Pass `[]` to disable
176
+ - **expires_in** - seconds, writes `x=`; omitted by default
177
+ - **now** - the signing instant for `t=`, defaults to `Time.now`
178
+
179
+ `MailAuth::Dkim::Signer#field` returns just the `DKIM-Signature` field, for callers assembling messages themselves.
180
+
181
+ ## Testing
182
+
183
+ ```sh
184
+ bundle install
185
+ rake
186
+ ```
187
+
188
+ SPF runs against the [RFC 7208 test suite](http://www.openspf.org/Test_Suite) - 200 official cases covering every mechanism, the lookup limits, and the error semantics. One case is skipped: `p-macro-multiple`, which needs PTR forward-confirmation this library doesn't do.
189
+
190
+ DKIM runs against the sample message in RFC 8463 Appendix A, which carries both an RSA and an Ed25519 signature over the same body. Signing is held to the same standard: both algorithms are deterministic, so signing the sample message with its published private keys must reproduce the published signatures byte for byte.
191
+
192
+ ARC runs against the [ValiMail ARC test suite](https://github.com/ValiMail/arc_test_suite) - 171 cases over chain validation, header structure, tag grammar, and key strength.
193
+
194
+ ## History
195
+
196
+ View the changelog
197
+
198
+ ## Contributing
199
+
200
+ Everyone is encouraged to help improve this project:
201
+
202
+ - [Report bugs](https://github.com/example/mailauth/issues)
203
+ - Fix bugs and submit pull requests
204
+ - Write, clarify, or fix documentation
205
+ - Suggest or add new features
206
+
207
+
208
+
209
+ ## License
210
+
211
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,100 @@
1
+ require "mailauth/arc/set"
2
+
3
+ module MailAuth
4
+ module Arc
5
+ # Every ARC header on a message, grouped into the sets they claim to belong
6
+ # to, and held to the structure RFC 8617 §5.2 step 3 requires of them.
7
+ class Chain
8
+ KINDS = {
9
+ "arc-authentication-results" => :authentication_results,
10
+ "arc-message-signature" => :message_signature,
11
+ "arc-seal" => :seal
12
+ }.freeze
13
+
14
+ def initialize(message)
15
+ @message = message
16
+ end
17
+
18
+ def empty?
19
+ sets.empty?
20
+ end
21
+
22
+ # Instance values run 1..50 (§4.2.1), so a chain longer than that is over
23
+ # the ceiling however its instances are numbered.
24
+ def oversized?
25
+ sets.size > MAXIMUM_SETS
26
+ end
27
+
28
+ def sets
29
+ @sets ||= collect
30
+ end
31
+
32
+ def ordered
33
+ sets.values.sort_by(&:instance)
34
+ end
35
+
36
+ def newest
37
+ ordered.last
38
+ end
39
+
40
+ # Everything up to and including one instance — what that instance's seal
41
+ # signs (§5.1.1).
42
+ def through(instance)
43
+ ordered.take_while { |set| set.instance <= instance }
44
+ end
45
+
46
+ def structural_error
47
+ if incomplete = ordered.find { |set| !set.complete? }
48
+ "ARC set i=#{incomplete.instance} does not carry exactly one of each header"
49
+ elsif unreadable = ordered.find { |set| !set.readable? }
50
+ "ARC set i=#{unreadable.instance} carries an invalid tag list"
51
+ elsif ordered.map(&:instance) != (1..sets.size).to_a
52
+ "ARC instances are not a continuous sequence"
53
+ else
54
+ chain_status_error
55
+ end
56
+ end
57
+
58
+ private
59
+ # A header whose instance can't be read joins no set at all, which
60
+ # leaves the set it was meant for incomplete — the failure it deserves,
61
+ # without a rule of its own.
62
+ def collect
63
+ @message.fields.each_with_object({}) do |field, sets|
64
+ kind = KINDS[@message.field_name(field)]
65
+ instance = kind && instance_of(kind, field)
66
+
67
+ (sets[instance] ||= Set.new(instance)).add(kind, field) if instance
68
+ end
69
+ end
70
+
71
+ # Read before it is parsed: an instance runs to MAXIMUM_SETS, so a longer
72
+ # run of digits is no instance at all, and turning one into an integer to
73
+ # discover that is arbitrary work for whoever sent it.
74
+ INSTANCE = /\A0*[1-9]\d{0,2}\z/n
75
+
76
+ def instance_of(kind, field)
77
+ position = if kind == :authentication_results
78
+ Arc.value_of(field).to_s[INSTANCE_PREFIX, 1]
79
+ else
80
+ Arc.tags_in(field).to_h["i"]
81
+ end
82
+
83
+ Integer(position, 10) if position&.match?(INSTANCE)
84
+ end
85
+
86
+ # §5.2 step 3.3: instance 1 opens a chain and every hop above it says
87
+ # the chain was intact when it arrived. "fail" is caught earlier, at
88
+ # step 2, when the newest hop is the one saying it.
89
+ def chain_status_error
90
+ if wrong = ordered.find { |set| set.chain_status != expected_chain_status(set) }
91
+ "ARC set i=#{wrong.instance} reports cv=#{wrong.chain_status.inspect}"
92
+ end
93
+ end
94
+
95
+ def expected_chain_status(set)
96
+ set.instance == 1 ? "none" : "pass"
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,158 @@
1
+ require "openssl"
2
+
3
+ module MailAuth
4
+ module Arc
5
+ # An ARC-Message-Signature (RFC 8617 §4.1.2): a DKIM signature over the
6
+ # message in all but name, differing in carrying no version tag, in taking
7
+ # i= as the instance rather than an identity, and in leaving From free —
8
+ # what it covers is the hop's business, not ours.
9
+ class MessageSignature
10
+ REQUIRED_TAGS = %w[ a b bh d h s ].freeze
11
+
12
+ # §4.1.2 gives the AMS DKIM's syntax and semantics bar three named
13
+ # differences, none of them canonicalization, which would leave an absent
14
+ # c= meaning simple/simple (RFC 6376 §3.5). Signers read it the other way
15
+ # and the conformance suite requires relaxed, so relaxed it is: simple
16
+ # canonicalization survives no intermediary, which is the one thing every
17
+ # message carrying this header has crossed.
18
+ DEFAULT_CANONICALIZATION = "relaxed/relaxed".freeze
19
+
20
+ def initialize(message, field, tags, resolver:)
21
+ @message, @field, @tags, @resolver = message, field, tags, resolver
22
+ end
23
+
24
+ def result
25
+ validate
26
+
27
+ if body_hash_matches?
28
+ verified? ? signature(Status::PASS) : failed("bad signature")
29
+ else
30
+ failed("body hash did not verify")
31
+ end
32
+ rescue Dkim::Malformed => error
33
+ signature Status::PERMERROR, comment: error.message
34
+ rescue Resolver::NotFound
35
+ signature Status::PERMERROR, comment: "no key for #{key_name}"
36
+ rescue Resolver::Timeout, Resolver::ServerFailure
37
+ signature Status::TEMPERROR, comment: "DNS failure resolving #{key_name}"
38
+ end
39
+
40
+ private
41
+ def validate
42
+ raise Dkim::Malformed, "invalid tag list" if @tags.nil?
43
+
44
+ if missing = REQUIRED_TAGS.find { |tag| !@tags.key?(tag) }
45
+ raise Dkim::Malformed, "missing #{missing}= tag"
46
+ end
47
+
48
+ raise Dkim::Malformed, "empty #{empty_tag}= tag" if empty_tag
49
+ raise Dkim::Malformed, "empty c= tag" if @tags.key?("c") && @tags["c"].strip.empty?
50
+ raise Dkim::Malformed, "unsupported algorithm #{@tags["a"]}" unless Arc.signing_algorithm?(@tags["a"])
51
+
52
+ # §4.1.2 bars an AMS from covering ARC header fields. The seal is the
53
+ # one that bites: it signs this signature, so a signature signing it
54
+ # back leaves each waiting on the other.
55
+ raise Dkim::Malformed, "h= covers the ARC-Seal" if signed_names.include?("arc-seal")
56
+
57
+ unless CANONICALIZATIONS.include?(header_canonicalization) && CANONICALIZATIONS.include?(body_canonicalization)
58
+ raise Dkim::Malformed, "unsupported canonicalization #{canonicalization}"
59
+ end
60
+
61
+ raise Dkim::Malformed, "invalid t= tag" if @tags["t"] && !@tags["t"].match?(/\A\d+\z/n)
62
+ raise Dkim::Malformed, "invalid l= tag" if @tags["l"] && !@tags["l"].match?(/\A\d+\z/n)
63
+ end
64
+
65
+ # h= may name nothing at all, but the tags carrying an identity or the
66
+ # maths cannot be present and blank.
67
+ def empty_tag
68
+ (REQUIRED_TAGS - %w[ h ]).find { |tag| @tags[tag].to_s.empty? }
69
+ end
70
+
71
+ def body_hash_matches?
72
+ digest.digest(signed_body) == unfolded("bh").unpack1("m")
73
+ end
74
+
75
+ def signed_body
76
+ body_limit ? canonical_body.byteslice(0, body_limit) : canonical_body
77
+ end
78
+
79
+ def body_limit
80
+ @tags["l"].to_i if @tags["l"]&.match?(/\A\d+\z/n)
81
+ end
82
+
83
+ def canonical_body
84
+ @canonical_body ||= Dkim::Canonicalization.body(@message.body, body_canonicalization)
85
+ end
86
+
87
+ def verified?
88
+ Dkim::Algorithm.verified?(canonical_headers, key: public_key, digest: digest,
89
+ signature: unfolded("b").unpack1("m"))
90
+ end
91
+
92
+ def public_key
93
+ Dkim::PublicKey.retrieve(selector: @tags["s"], domain: @tags["d"],
94
+ algorithm: Arc.signing_algorithm(@tags["a"]), resolver: @resolver)
95
+ end
96
+
97
+ def key_name
98
+ Dkim::PublicKey.name_for(selector: @tags["s"], domain: @tags["d"])
99
+ end
100
+
101
+ # The signature's own field closes the input with its b= emptied and no
102
+ # terminator, exactly as a DKIM signature does (RFC 6376 §3.7).
103
+ def canonical_headers
104
+ signed_fields.map { |field| canonicalize(field) + CRLF }.join +
105
+ canonicalize(@field).sub(/([;:\s]+b=)[^;]*/n, '\1')
106
+ end
107
+
108
+ # The last unused instance of each name, walking upward through
109
+ # duplicates (RFC 6376 §5.4.2).
110
+ def signed_fields
111
+ remaining = @message.fields.dup
112
+
113
+ signed_names.filter_map do |name|
114
+ if index = remaining.rindex { |field| @message.field_name(field) == name }
115
+ remaining.delete_at(index)
116
+ end
117
+ end
118
+ end
119
+
120
+ def signed_names
121
+ unfolded("h").to_s.downcase.split(":").reject(&:empty?)
122
+ end
123
+
124
+ def canonicalize(field)
125
+ Dkim::Canonicalization.field(field, header_canonicalization)
126
+ end
127
+
128
+ def digest
129
+ Dkim::HASHES.fetch(Arc.hash_algorithm(@tags["a"]))
130
+ end
131
+
132
+ def header_canonicalization
133
+ canonicalization.split("/").first
134
+ end
135
+
136
+ def body_canonicalization
137
+ canonicalization.split("/")[1] || "simple"
138
+ end
139
+
140
+ def canonicalization
141
+ @tags["c"].to_s.downcase.strip.empty? ? DEFAULT_CANONICALIZATION : @tags["c"].downcase.strip
142
+ end
143
+
144
+ def unfolded(tag)
145
+ @tags[tag]&.gsub(/[ \t\r\n]+/n, "")
146
+ end
147
+
148
+ def failed(comment)
149
+ signature(Status::FAIL, comment: comment)
150
+ end
151
+
152
+ def signature(status, comment: nil)
153
+ ArcSignature.new(status: status, domain: @tags&.[]("d")&.downcase, selector: @tags&.[]("s"),
154
+ algorithm: @tags&.[]("a"), comment: comment)
155
+ end
156
+ end
157
+ end
158
+ end
@@ -0,0 +1,97 @@
1
+ require "openssl"
2
+
3
+ module MailAuth
4
+ module Arc
5
+ # An ARC-Seal (RFC 8617 §4.1.3), which signs the chain rather than the
6
+ # message: no body, no h= naming what it covers — its scope is fixed by
7
+ # §5.1.1 — and relaxed header canonicalization only.
8
+ class Seal
9
+ REQUIRED_TAGS = %w[ i a b d s cv ].freeze
10
+
11
+ def initialize(sets, resolver:)
12
+ @sets, @resolver = sets, resolver
13
+ @set = sets.last
14
+ @tags = @set.seal_tags
15
+ end
16
+
17
+ def result
18
+ validate
19
+
20
+ verified? ? seal(Status::PASS) : seal(Status::FAIL, comment: "bad signature")
21
+ rescue Dkim::Malformed => error
22
+ seal Status::PERMERROR, comment: error.message
23
+ rescue Resolver::NotFound
24
+ seal Status::PERMERROR, comment: "no key for #{key_name}"
25
+ rescue Resolver::Timeout, Resolver::ServerFailure
26
+ seal Status::TEMPERROR, comment: "DNS failure resolving #{key_name}"
27
+ end
28
+
29
+ private
30
+ def validate
31
+ raise Dkim::Malformed, "invalid tag list" if @tags.nil?
32
+
33
+ # §4.1.3: a seal names no headers, and one that tries is fatal to the
34
+ # chain rather than merely ignored.
35
+ raise Dkim::Malformed, "h= is not allowed on an ARC-Seal" if @tags.key?("h")
36
+
37
+ if missing = REQUIRED_TAGS.find { |tag| @tags[tag].to_s.empty? }
38
+ raise Dkim::Malformed, "missing #{missing}= tag"
39
+ end
40
+
41
+ raise Dkim::Malformed, "unsupported algorithm #{@tags["a"]}" unless Arc.signing_algorithm?(@tags["a"])
42
+ raise Dkim::Malformed, "invalid cv= tag" unless CHAIN_STATUSES.include?(@tags["cv"].downcase)
43
+ raise Dkim::Malformed, "invalid t= tag" if @tags["t"] && !@tags["t"].match?(/\A\d+\z/n)
44
+ end
45
+
46
+ def verified?
47
+ Dkim::Algorithm.verified?(signed_headers, key: public_key, digest: digest,
48
+ signature: unfolded("b").unpack1("m"))
49
+ end
50
+
51
+ # §5.1.1: every set from instance 1 upward, each contributing its AAR,
52
+ # then its AMS, then its seal — this one's with b= emptied, and nothing
53
+ # terminating the last line.
54
+ def signed_headers
55
+ @sets.flat_map { |set| headers_of(set) }.join(CRLF)
56
+ end
57
+
58
+ def headers_of(set)
59
+ [ canonicalize(set.authentication_results), canonicalize(set.message_signature), canonical_seal(set) ]
60
+ end
61
+
62
+ def canonical_seal(set)
63
+ if set.equal?(@set)
64
+ canonicalize(set.seal).sub(/([;:\s]+b=)[^;]*/n, '\1')
65
+ else
66
+ canonicalize(set.seal)
67
+ end
68
+ end
69
+
70
+ def canonicalize(field)
71
+ Dkim::Canonicalization.field(field, "relaxed")
72
+ end
73
+
74
+ def public_key
75
+ Dkim::PublicKey.retrieve(selector: @tags["s"], domain: @tags["d"],
76
+ algorithm: Arc.signing_algorithm(@tags["a"]), resolver: @resolver)
77
+ end
78
+
79
+ def key_name
80
+ Dkim::PublicKey.name_for(selector: @tags["s"], domain: @tags["d"])
81
+ end
82
+
83
+ def digest
84
+ Dkim::HASHES.fetch(Arc.hash_algorithm(@tags["a"]))
85
+ end
86
+
87
+ def unfolded(tag)
88
+ @tags[tag]&.gsub(/[ \t\r\n]+/n, "")
89
+ end
90
+
91
+ def seal(status, comment: nil)
92
+ ArcSeal.new(status: status, domain: @tags&.[]("d")&.downcase, selector: @tags&.[]("s"),
93
+ algorithm: @tags&.[]("a"), comment: comment)
94
+ end
95
+ end
96
+ end
97
+ end