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