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