mailauth 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +49 -0
- data/LICENSE +21 -0
- data/README.md +211 -0
- data/lib/mailauth/arc/chain.rb +100 -0
- data/lib/mailauth/arc/message_signature.rb +158 -0
- data/lib/mailauth/arc/seal.rb +97 -0
- data/lib/mailauth/arc/set.rb +74 -0
- data/lib/mailauth/arc.rb +143 -0
- data/lib/mailauth/authentication_results.rb +164 -0
- data/lib/mailauth/dkim/algorithm.rb +28 -0
- data/lib/mailauth/dkim/canonicalization.rb +40 -0
- data/lib/mailauth/dkim/public_key.rb +54 -0
- data/lib/mailauth/dkim/signer.rb +122 -0
- data/lib/mailauth/dkim.rb +270 -0
- data/lib/mailauth/dmarc/alignment.rb +32 -0
- data/lib/mailauth/dmarc/organizational_domain.rb +22 -0
- data/lib/mailauth/dmarc/record.rb +120 -0
- data/lib/mailauth/dmarc.rb +64 -0
- data/lib/mailauth/domain.rb +34 -0
- data/lib/mailauth/message.rb +76 -0
- data/lib/mailauth/resolver.rb +92 -0
- data/lib/mailauth/result.rb +100 -0
- data/lib/mailauth/spf/budget.rb +111 -0
- data/lib/mailauth/spf/macro.rb +121 -0
- data/lib/mailauth/spf/record.rb +188 -0
- data/lib/mailauth/spf.rb +243 -0
- data/lib/mailauth/version.rb +3 -0
- data/lib/mailauth.rb +31 -0
- data/mailauth.gemspec +39 -0
- metadata +116 -0
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
module MailAuth
|
|
2
|
+
module Arc
|
|
3
|
+
# The three header fields sharing one instance value (RFC 8617 §4.2). Each
|
|
4
|
+
# is held as the list of fields claiming this instance, so a set that
|
|
5
|
+
# carries two of anything can say so rather than quietly keeping one.
|
|
6
|
+
class Set
|
|
7
|
+
attr_reader :instance
|
|
8
|
+
|
|
9
|
+
def initialize(instance)
|
|
10
|
+
@instance = instance
|
|
11
|
+
@fields = { authentication_results: [], message_signature: [], seal: [] }
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def add(kind, field)
|
|
15
|
+
@fields.fetch(kind) << field
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def complete?
|
|
19
|
+
@fields.each_value.all?(&:one?)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# A tag list that doesn't parse leaves nothing to verify against, and no
|
|
23
|
+
# reading of the rest can rescue it (RFC 6376 §3.2).
|
|
24
|
+
def readable?
|
|
25
|
+
!seal_tags.nil? && !message_signature_tags.nil?
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def authentication_results
|
|
29
|
+
@fields[:authentication_results].first
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def message_signature
|
|
33
|
+
@fields[:message_signature].first
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def seal
|
|
37
|
+
@fields[:seal].first
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# The status this hop stamped, which §5.2 step 3.3 holds to "none" at
|
|
41
|
+
# instance 1 and "pass" above it.
|
|
42
|
+
def chain_status
|
|
43
|
+
seal_tags.to_h["cv"].to_s.downcase
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def seal_tags
|
|
47
|
+
return @seal_tags if defined?(@seal_tags)
|
|
48
|
+
|
|
49
|
+
@seal_tags = Arc.tags_in(seal)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def message_signature_tags
|
|
53
|
+
return @message_signature_tags if defined?(@message_signature_tags)
|
|
54
|
+
|
|
55
|
+
@message_signature_tags = Arc.tags_in(message_signature)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# The payload an Authentication-Results header would carry, with the
|
|
59
|
+
# instance tag that prefixes it here (§4.1.1) taken off.
|
|
60
|
+
def authentication_results_payload
|
|
61
|
+
Arc.value_of(authentication_results).to_s.sub(INSTANCE_PREFIX, "").strip
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Best effort, and never load-bearing: the chain's validity is a question
|
|
65
|
+
# about signatures, so an unreadable assertion must not fail it. A name
|
|
66
|
+
# carrying a dot is one of RFC 8601's properties (`smtp.mailfrom`), not a
|
|
67
|
+
# method, and says nothing about a verdict.
|
|
68
|
+
def claimed
|
|
69
|
+
authentication_results_payload.scan(/(?<![\w.-])([a-z][a-z0-9-]*)[ \t]*=[ \t]*([a-z]+)/i).
|
|
70
|
+
each_with_object({}) { |(method, result), claims| claims[method.downcase] ||= result.downcase }
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
data/lib/mailauth/arc.rb
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
require "mailauth/dkim"
|
|
2
|
+
require "mailauth/arc/chain"
|
|
3
|
+
require "mailauth/arc/message_signature"
|
|
4
|
+
require "mailauth/arc/seal"
|
|
5
|
+
|
|
6
|
+
module MailAuth
|
|
7
|
+
# RFC 8617. An Authenticated Received Chain records what each intermediary
|
|
8
|
+
# concluded about a message as it passed through, sealed so a later reader can
|
|
9
|
+
# tell the record has not been touched since. Forwarding routinely breaks SPF
|
|
10
|
+
# and DKIM; a chain says what they said before it did.
|
|
11
|
+
#
|
|
12
|
+
# What a valid chain proves is only that each hop sealed what it sealed.
|
|
13
|
+
# Sealing a chain over one's own message costs nothing, so whether any sealer
|
|
14
|
+
# is to be believed is a decision for the caller and has no answer here.
|
|
15
|
+
module Arc
|
|
16
|
+
CRLF = "\r\n".b
|
|
17
|
+
|
|
18
|
+
# §4.2.1. The ceiling is enforced before any key is fetched: each set
|
|
19
|
+
# otherwise costs a lookup, and a long chain is cheap to write.
|
|
20
|
+
MAXIMUM_SETS = 50
|
|
21
|
+
|
|
22
|
+
CHAIN_STATUSES = %w[ none fail pass ].freeze
|
|
23
|
+
CANONICALIZATIONS = Dkim::CANONICALIZATIONS
|
|
24
|
+
|
|
25
|
+
HASHES = %w[ sha256 ].freeze
|
|
26
|
+
SIGNING_ALGORITHMS = Dkim::SIGNING_ALGORITHMS
|
|
27
|
+
|
|
28
|
+
# §4.1.1 puts the instance ahead of the payload an AAR carries, where the
|
|
29
|
+
# other two hold it among their tags.
|
|
30
|
+
INSTANCE_PREFIX = /\A[ \t\r\n]*i[ \t\r\n]*=[ \t\r\n]*(\d+)[ \t\r\n]*;/n
|
|
31
|
+
|
|
32
|
+
# `now` is taken for symmetry with the other checks and not consulted: ARC
|
|
33
|
+
# gives a chain no lifetime, and a message can sit in a queue for days
|
|
34
|
+
# without that saying anything about the hops it already crossed.
|
|
35
|
+
def self.verify(message, resolver: Resolver.new, now: Time.now)
|
|
36
|
+
Validation.new(message, resolver: resolver).result
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.value_of(field)
|
|
40
|
+
field&.split(":", 2)&.last
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def self.tags_in(field)
|
|
44
|
+
Dkim.parse_tags(value_of(field).to_s)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# sha1 is still legal for a DKIM signature and never was for ARC.
|
|
48
|
+
def self.signing_algorithm?(algorithm)
|
|
49
|
+
SIGNING_ALGORITHMS.include?(signing_algorithm(algorithm)) && HASHES.include?(hash_algorithm(algorithm))
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def self.signing_algorithm(algorithm)
|
|
53
|
+
algorithm.to_s.split("-").first.to_s.downcase
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def self.hash_algorithm(algorithm)
|
|
57
|
+
algorithm.to_s.split("-").last.to_s.downcase
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# The validator of §5.2, in its order. Every failure is permanent, DNS
|
|
61
|
+
# included (§5.2.1), so a chain is only ever pass, fail, or none — the
|
|
62
|
+
# reason a hop failed survives on that set rather than in the status.
|
|
63
|
+
class Validation
|
|
64
|
+
def initialize(message, resolver:)
|
|
65
|
+
@message, @resolver = message, resolver
|
|
66
|
+
@chain = Chain.new(message)
|
|
67
|
+
@seals = {}
|
|
68
|
+
@signatures = {}
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def result
|
|
72
|
+
if @chain.empty?
|
|
73
|
+
result_of Status::NONE, comment: "no ARC sets"
|
|
74
|
+
elsif @chain.oversized?
|
|
75
|
+
result_of Status::FAIL, comment: "more than #{MAXIMUM_SETS} ARC sets"
|
|
76
|
+
elsif sealed_as_failed?
|
|
77
|
+
result_of Status::FAIL, comment: "sealed cv=fail at i=#{@chain.newest.instance}"
|
|
78
|
+
elsif error = @chain.structural_error
|
|
79
|
+
result_of Status::FAIL, comment: error
|
|
80
|
+
elsif !newest_signature_verifies?
|
|
81
|
+
result_of Status::FAIL, comment: "ARC-Message-Signature i=#{@chain.newest.instance} did not verify"
|
|
82
|
+
elsif broken = broken_seal
|
|
83
|
+
result_of Status::FAIL, comment: "ARC-Seal i=#{broken.instance} did not verify"
|
|
84
|
+
else
|
|
85
|
+
result_of Status::PASS
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
private
|
|
90
|
+
# Step 2, ahead of the structural checks: a chain the newest hop has
|
|
91
|
+
# already declared broken is broken whatever else it looks like.
|
|
92
|
+
def sealed_as_failed?
|
|
93
|
+
@chain.newest&.complete? && @chain.newest.chain_status == "fail"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Step 4. Only the newest, and deliberately: an intermediary that
|
|
97
|
+
# rewrites a subject line invalidates the hop before it, which is the
|
|
98
|
+
# very situation ARC exists to carry a message through.
|
|
99
|
+
def newest_signature_verifies?
|
|
100
|
+
signature_for(@chain.newest).status == Status::PASS
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def signature_for(set)
|
|
104
|
+
@signatures[set.instance] ||=
|
|
105
|
+
MessageSignature.new(@message, set.message_signature, set.message_signature_tags, resolver: @resolver).result
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Step 6, newest first, each seal over the chain as it stood when that
|
|
109
|
+
# hop sealed it.
|
|
110
|
+
def broken_seal
|
|
111
|
+
@chain.ordered.reverse.find { |set| seal_for(set).status != Status::PASS }
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def seal_for(set)
|
|
115
|
+
@seals[set.instance] ||= Seal.new(@chain.through(set.instance), resolver: @resolver).result
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def result_of(status, comment: nil)
|
|
119
|
+
ArcResult.new(status: status, sets: reported_sets, comment: comment,
|
|
120
|
+
oldest_pass: (oldest_pass if status == Status::PASS))
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# A chain that never got as far as verifying anything still reports the
|
|
124
|
+
# hops it found, so a reader can see who sealed what.
|
|
125
|
+
def reported_sets
|
|
126
|
+
@chain.ordered.map do |set|
|
|
127
|
+
ArcSet.new(instance: set.instance, chain_status: set.chain_status,
|
|
128
|
+
seal: @seals[set.instance], message_signature: @signatures[set.instance],
|
|
129
|
+
authentication_results: set.authentication_results_payload, claimed: set.claimed)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Step 5, optional: the oldest hop whose signature still verifies, which
|
|
134
|
+
# says how far back the message is unmodified without bearing on whether
|
|
135
|
+
# the chain is valid.
|
|
136
|
+
def oldest_pass
|
|
137
|
+
broken = @chain.ordered[0..-2].reverse.find { |set| signature_for(set).status != Status::PASS }
|
|
138
|
+
|
|
139
|
+
broken ? broken.instance + 1 : 0
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
module MailAuth
|
|
2
|
+
# RFC 8601. States the verdicts in an Authentication-Results header for a
|
|
3
|
+
# downstream MTA to read.
|
|
4
|
+
class AuthenticationResults
|
|
5
|
+
LINE_WIDTH = 78 # RFC 5322 §2.1.1
|
|
6
|
+
INDENT = " ".freeze
|
|
7
|
+
FOLD = "\r\n#{INDENT}".freeze
|
|
8
|
+
|
|
9
|
+
# RFC 8601 §2.2 requires an authserv-id. Without one we say so out loud
|
|
10
|
+
# rather than emitting a header that appears to be signed by nobody.
|
|
11
|
+
UNKNOWN_AUTHSERV_ID = "unknown".freeze
|
|
12
|
+
NO_RESULTS = "none".freeze
|
|
13
|
+
|
|
14
|
+
# A pvalue may be an RFC 2045 token or a bare addr-spec, so "@" and "." ride
|
|
15
|
+
# along unquoted; everything else has to become a quoted-string.
|
|
16
|
+
TOKEN = /\A[!#-'*+\-.0-9@-Z^-~]+\z/
|
|
17
|
+
|
|
18
|
+
# `header_from` is optional: a DmarcResult carries a verdict, not an
|
|
19
|
+
# identity; pass the From: domain to have it reported as `header.from`.
|
|
20
|
+
def initialize(spf:, dkim:, dmarc:, arc: nil, mta: nil, header_from: nil)
|
|
21
|
+
@spf, @dkim, @dmarc, @arc, @mta, @header_from = spf, dkim, dmarc, arc, mta, header_from
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# The value alone — everything after "Authentication-Results:" — so the
|
|
25
|
+
# caller writes the field name itself.
|
|
26
|
+
def to_s
|
|
27
|
+
fold segments
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
def segments
|
|
32
|
+
clauses = [ [ authserv_id ], *method_clauses ]
|
|
33
|
+
|
|
34
|
+
clauses.each_with_index.map do |tokens, index|
|
|
35
|
+
index == clauses.length - 1 ? tokens : terminated(tokens)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def authserv_id
|
|
40
|
+
escape(@mta.to_s.strip.empty? ? UNKNOWN_AUTHSERV_ID : @mta)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def method_clauses
|
|
44
|
+
clauses = [ spf_clause, *dkim_clauses, dmarc_clause, arc_clause ].compact
|
|
45
|
+
|
|
46
|
+
# A header with nothing to report still has to report that.
|
|
47
|
+
clauses.any? ? clauses : [ [ NO_RESULTS ] ]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def spf_clause
|
|
51
|
+
if @spf
|
|
52
|
+
[ "spf=#{@spf.status}", comment(@spf.comment), property("smtp.mailfrom", @spf.domain) ].compact
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Three signatures make three dkim= results: collapsing them would lose
|
|
57
|
+
# which domain the passing one belonged to.
|
|
58
|
+
def dkim_clauses
|
|
59
|
+
@dkim&.signatures&.map do |signature|
|
|
60
|
+
[ "dkim=#{signature.status}",
|
|
61
|
+
comment(signature.comment),
|
|
62
|
+
comment(undersized(signature)),
|
|
63
|
+
property("header.d", signature.domain),
|
|
64
|
+
property("header.s", signature.selector),
|
|
65
|
+
property("header.a", signature.algorithm) ].compact
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Tells the header's reader when l= left part of the body unsigned.
|
|
70
|
+
def undersized(signature)
|
|
71
|
+
"undersized signature: #{signature.under_sized} bytes unsigned" if signature.under_sized
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def dmarc_clause
|
|
75
|
+
if @dmarc
|
|
76
|
+
[ "dmarc=#{@dmarc.status}", comment(policy_comment), property("header.from", @header_from) ].compact
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# The result says whether the message aligned; the comment says what
|
|
81
|
+
# the domain asked us to do about it.
|
|
82
|
+
def policy_comment
|
|
83
|
+
"p=#{@dmarc.policy.upcase}" if @dmarc.policy
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# RFC 8617 §5.2 registers pass, fail, and none and nothing else, so the
|
|
87
|
+
# sealer rides along as a comment rather than as a property, and a chain
|
|
88
|
+
# that passed never touches the dmarc= clause above it.
|
|
89
|
+
def arc_clause
|
|
90
|
+
if @arc
|
|
91
|
+
[ "arc=#{@arc.status}", comment(newest_sealer) ].compact
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def newest_sealer
|
|
96
|
+
if @arc.newest&.seal&.domain
|
|
97
|
+
"i=#{@arc.newest.instance} d=#{@arc.newest.seal.domain}"
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def property(name, value)
|
|
102
|
+
value = sanitize(value)
|
|
103
|
+
|
|
104
|
+
if value.empty?
|
|
105
|
+
nil
|
|
106
|
+
else
|
|
107
|
+
"#{name}=#{escape(value)}"
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def comment(text)
|
|
112
|
+
text = sanitize(text)
|
|
113
|
+
|
|
114
|
+
if text.empty?
|
|
115
|
+
nil
|
|
116
|
+
else
|
|
117
|
+
"(#{escape_comment(text)})"
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def escape(value)
|
|
122
|
+
value = sanitize(value)
|
|
123
|
+
|
|
124
|
+
if value.match?(TOKEN)
|
|
125
|
+
value
|
|
126
|
+
else
|
|
127
|
+
%("#{value.gsub(/["\\]/) { |char| "\\#{char}" }}")
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Parentheses have to balance and a backslash quotes whatever follows it,
|
|
132
|
+
# so both — and the "(" that would open a comment we never meant to start —
|
|
133
|
+
# are escaped.
|
|
134
|
+
def escape_comment(text)
|
|
135
|
+
text.gsub(/[()\\]/) { |char| "\\#{char}" }
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Everything here can come from a remote party — a surviving CR or LF
|
|
139
|
+
# would let them forge headers of their own.
|
|
140
|
+
def sanitize(value)
|
|
141
|
+
value.to_s.gsub(/[\x00-\x1f\x7f]+/, " ").squeeze(" ").strip
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def terminated(tokens)
|
|
145
|
+
tokens[..-2] + [ "#{tokens.last};" ]
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Folds at token boundaries only: a quoted string or comment can carry a
|
|
149
|
+
# space, and breaking one across lines would change what it says.
|
|
150
|
+
def fold(segments)
|
|
151
|
+
segments.flat_map { |tokens| lines(tokens) }.join(FOLD)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def lines(tokens)
|
|
155
|
+
tokens.each_with_object([]) do |token, lines|
|
|
156
|
+
if lines.any? && lines.last.length + INDENT.length + token.length < LINE_WIDTH
|
|
157
|
+
lines.last << " " << token
|
|
158
|
+
else
|
|
159
|
+
lines << token.dup
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
require "openssl"
|
|
2
|
+
|
|
3
|
+
module MailAuth
|
|
4
|
+
module Dkim
|
|
5
|
+
# Both directions of the same maths, in one place: RFC 8463 §3 has EdDSA
|
|
6
|
+
# sign the hash of the canonicalized headers where RSA signs the headers
|
|
7
|
+
# themselves, and a signer and a verifier that disagree about that produce
|
|
8
|
+
# signatures nobody can check.
|
|
9
|
+
module Algorithm
|
|
10
|
+
def self.sign(data, key:, digest: OpenSSL::Digest::SHA256)
|
|
11
|
+
case KEY_TYPES.fetch(key.oid)
|
|
12
|
+
when "rsa" then key.sign(digest.new, data)
|
|
13
|
+
when "ed25519" then key.sign(nil, digest.digest(data))
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.verified?(data, key:, signature:, digest: OpenSSL::Digest::SHA256)
|
|
18
|
+
case KEY_TYPES.fetch(key.oid)
|
|
19
|
+
when "rsa" then key.verify(digest.new, signature, data)
|
|
20
|
+
when "ed25519" then key.verify(nil, signature, digest.digest(data))
|
|
21
|
+
end
|
|
22
|
+
rescue OpenSSL::PKey::PKeyError
|
|
23
|
+
# A signature of the wrong length for the key never reaches the maths.
|
|
24
|
+
false
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
module MailAuth
|
|
2
|
+
module Dkim
|
|
3
|
+
# RFC 6376 §3.4: the bytes a signature covers. One implementation, so
|
|
4
|
+
# signing and verifying can't reduce a message differently.
|
|
5
|
+
module Canonicalization
|
|
6
|
+
class << self
|
|
7
|
+
def body(bytes, form)
|
|
8
|
+
case form
|
|
9
|
+
when "relaxed" then relaxed_body(bytes)
|
|
10
|
+
when "simple" then simple_body(bytes)
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def field(field, form)
|
|
15
|
+
case form
|
|
16
|
+
when "relaxed" then relaxed_field(field)
|
|
17
|
+
when "simple" then field.chomp(CRLF)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
# An unterminated final line is still a line (§3.4.4 a.1): its trailing
|
|
23
|
+
# whitespace goes too, and the CRLF it lacks is put back.
|
|
24
|
+
def relaxed_body(bytes)
|
|
25
|
+
body = bytes.gsub(/[ \t]+/n, " ").gsub(/ (?=\r\n|\z)/n, "").sub(/(?:\r\n)*\z/n, "")
|
|
26
|
+
body.empty? ? body : body + CRLF
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def simple_body(bytes)
|
|
30
|
+
bytes.sub(/(?:\r\n)*\z/n, CRLF)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def relaxed_field(field)
|
|
34
|
+
name, value = field.split(":", 2)
|
|
35
|
+
"#{name.strip.downcase}:#{value.to_s.gsub(/\r\n/n, "").gsub(/[ \t]+/n, " ").strip}"
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
require "openssl"
|
|
2
|
+
|
|
3
|
+
module MailAuth
|
|
4
|
+
module Dkim
|
|
5
|
+
# The key a signing domain publishes, per RFC 6376 §3.6.1. ARC keys live in
|
|
6
|
+
# the same records under the same names, so both protocols read them here.
|
|
7
|
+
module PublicKey
|
|
8
|
+
class << self
|
|
9
|
+
def retrieve(selector:, domain:, algorithm:, resolver:)
|
|
10
|
+
read record_for(name_for(selector: selector, domain: domain), resolver), algorithm
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def name_for(selector:, domain:)
|
|
14
|
+
"#{selector}._domainkey.#{domain}"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
def record_for(name, resolver)
|
|
19
|
+
tags = Dkim.parse_tags(resolver.txt(name).first.to_s)
|
|
20
|
+
|
|
21
|
+
if tags.nil?
|
|
22
|
+
raise Malformed, "invalid key record for #{name}"
|
|
23
|
+
elsif tags["v"] && tags["v"].downcase != "dkim1"
|
|
24
|
+
raise Malformed, "unknown key version #{tags["v"]}"
|
|
25
|
+
elsif tags["p"].to_s.empty?
|
|
26
|
+
raise Malformed, "key revoked"
|
|
27
|
+
else
|
|
28
|
+
tags["p"].gsub(/[ \t\r\n]+/n, "").unpack1("m")
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def read(material, algorithm)
|
|
33
|
+
key = OpenSSL::PKey.read(wrapped(material))
|
|
34
|
+
|
|
35
|
+
if KEY_TYPES[key.oid] != algorithm
|
|
36
|
+
raise Malformed, "key is not #{algorithm}"
|
|
37
|
+
elsif algorithm == "rsa" && key.n.num_bits < MINIMUM_RSA_BITS
|
|
38
|
+
raise Malformed, "rsa key shorter than #{MINIMUM_RSA_BITS} bits"
|
|
39
|
+
else
|
|
40
|
+
key
|
|
41
|
+
end
|
|
42
|
+
rescue OpenSSL::PKey::PKeyError
|
|
43
|
+
raise Malformed, "unreadable public key"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# ed25519 keys travel as 32 raw bytes (RFC 8463 §3), so we put back
|
|
47
|
+
# the SubjectPublicKeyInfo wrapper OpenSSL insists on before reading.
|
|
48
|
+
def wrapped(material)
|
|
49
|
+
material.bytesize == 32 ? ED25519_SPKI_PREFIX + material : material
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
require "openssl"
|
|
2
|
+
|
|
3
|
+
require "mailauth/message"
|
|
4
|
+
require "mailauth/domain"
|
|
5
|
+
|
|
6
|
+
module MailAuth
|
|
7
|
+
module Dkim
|
|
8
|
+
# Signs a message per RFC 6376 §5, relaxed/relaxed.
|
|
9
|
+
class Signer
|
|
10
|
+
# The RFC 6376 §5.4.1 example set: what a reader's client renders or threads on.
|
|
11
|
+
DEFAULT_SIGNED_HEADERS = %w[
|
|
12
|
+
from sender reply-to subject date message-id to cc
|
|
13
|
+
mime-version content-type content-transfer-encoding
|
|
14
|
+
in-reply-to references list-id list-help list-owner
|
|
15
|
+
list-unsubscribe list-unsubscribe-post list-subscribe list-post
|
|
16
|
+
].freeze
|
|
17
|
+
|
|
18
|
+
# Named in h= once more than they occur — a name past its count matches
|
|
19
|
+
# nothing (§5.4.2), so an instance added after signing breaks the signature.
|
|
20
|
+
OVERSIGNED_HEADERS = %w[ from to cc subject date reply-to message-id ].freeze
|
|
21
|
+
|
|
22
|
+
def initialize(raw, domain:, selector:, key:, headers: nil, oversign: OVERSIGNED_HEADERS, expires_in: nil, now: Time.now)
|
|
23
|
+
@message = Message.new(raw)
|
|
24
|
+
@domain = Domain.normalize(domain)
|
|
25
|
+
@selector = Domain.normalize(selector)
|
|
26
|
+
@key = key
|
|
27
|
+
@requested = (headers || DEFAULT_SIGNED_HEADERS).map(&:downcase)
|
|
28
|
+
@oversign = oversign.map(&:downcase)
|
|
29
|
+
@expires_in = expires_in
|
|
30
|
+
@now = now
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def signed_message
|
|
34
|
+
field + @message.raw
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def field
|
|
38
|
+
@field ||= begin
|
|
39
|
+
validate
|
|
40
|
+
unsigned_field + [ signature ].pack("m0").scan(/.{1,72}/).join("\r\n\t") + CRLF
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
def validate
|
|
46
|
+
raise ArgumentError, "unsupported key type #{@key.oid}" unless KEY_TYPES.key?(@key.oid)
|
|
47
|
+
raise ArgumentError, "rsa key shorter than #{MINIMUM_RSA_BITS} bits" if undersized_rsa?
|
|
48
|
+
raise ArgumentError, "From is not signed" unless signed_names.include?("from")
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Held to the same floor the verifier enforces, so no signature leaves
|
|
52
|
+
# here that the other side is required to reject.
|
|
53
|
+
def undersized_rsa?
|
|
54
|
+
KEY_TYPES.fetch(@key.oid) == "rsa" && @key.n.num_bits < MINIMUM_RSA_BITS
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Ends with b=, where the signature lands — in the signed bytes and the
|
|
58
|
+
# emitted field alike.
|
|
59
|
+
def unsigned_field
|
|
60
|
+
"DKIM-Signature: v=1; " + properties.join("\r\n\t")
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def properties
|
|
64
|
+
[
|
|
65
|
+
"a=#{algorithm}; c=relaxed/relaxed;",
|
|
66
|
+
"d=#{@domain};",
|
|
67
|
+
"s=#{@selector}; t=#{@now.to_i};",
|
|
68
|
+
expiration,
|
|
69
|
+
"bh=#{body_hash};",
|
|
70
|
+
"h=#{signed_names.join(":")};",
|
|
71
|
+
"b="
|
|
72
|
+
].compact
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def algorithm
|
|
76
|
+
"#{KEY_TYPES.fetch(@key.oid)}-sha256"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def expiration
|
|
80
|
+
"x=#{@now.to_i + @expires_in.to_i};" if @expires_in
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def body_hash
|
|
84
|
+
[ OpenSSL::Digest::SHA256.digest(Canonicalization.body(@message.body, "relaxed")) ].pack("m0")
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def signed_names
|
|
88
|
+
@signed_names ||= present_names + @oversign
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def present_names
|
|
92
|
+
@message.fields.map { |field| @message.field_name(field) }.select { |name| @requested.include?(name) }
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def signature
|
|
96
|
+
sign(signable)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# The emitted field itself is canonicalized and signed, unterminated —
|
|
100
|
+
# no room to sign one thing and send another.
|
|
101
|
+
def signable
|
|
102
|
+
signed_fields.map { |field| Canonicalization.field(field, "relaxed") + CRLF }.join +
|
|
103
|
+
Canonicalization.field(unsigned_field, "relaxed")
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Duplicates are taken bottom-up, the order a verifier takes them (§5.4.2).
|
|
107
|
+
def signed_fields
|
|
108
|
+
remaining = @message.fields.dup
|
|
109
|
+
|
|
110
|
+
signed_names.filter_map do |name|
|
|
111
|
+
if index = remaining.rindex { |field| @message.field_name(field) == name }
|
|
112
|
+
remaining.delete_at(index)
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def sign(data)
|
|
118
|
+
Algorithm.sign(data, key: @key)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|