dkimverify 0.0.1

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.
data/dkimverify.rb ADDED
@@ -0,0 +1,256 @@
1
+ require 'mail'
2
+ require 'digest'
3
+ require 'openssl'
4
+ require 'base64'
5
+ require_relative './dkim-query/lib/dkim/query'
6
+
7
+
8
+ # TODO make this an option somehow
9
+ $debuglog = nil # alternatively, set this to `STDERR` to log to stdout.
10
+
11
+ module Dkim
12
+ # what are these magic numbers?!
13
+ # These values come from RFC 3447, section 9.2 Notes, page 43.
14
+ # cf. https://github.com/emboss/krypt/blob/c804f736d4dbaa4425014d036d2e68d8ee66d559/lib/krypt/asn1/common.rb
15
+ # SHA1 = algorithm_null_params('1.3.14.3.2.26')
16
+ # SHA256 = algorithm_null_params('2.16.840.1.101.3.4.2.1')
17
+ OpenSSL::ASN1::ObjectId.register('1.3.14.3.2.26', 'sha1', 'HASHID_SHA1')
18
+ OpenSSL::ASN1::ObjectId.register('2.16.840.1.101.3.4.2.1', 'sha256', 'HASHID_SHA256')
19
+ HASHID_SHA1 = OpenSSL::ASN1::ObjectId.new('sha1')
20
+ HASHID_SHA256 = OpenSSL::ASN1::ObjectId.new('sha256')
21
+
22
+ class DkimError < StandardError; end
23
+ class InvalidDkimSignature < DkimError; end
24
+ class DkimVerificationFailure < DkimError; end
25
+
26
+ class Verifier
27
+ def initialize(email_filename)
28
+ mail = Mail.read(email_filename)
29
+ @headers = mail.header
30
+ @body = mail.body.raw_source
31
+ dkim_signature_str = @headers["DKIM-Signature"].value.to_s
32
+ @dkim_signature = {}
33
+ dkim_signature_str.split(/\s*;\s*/).each do |key_val|
34
+ if m = key_val.match(/(\w+)\s*=\s*(.*)/)
35
+ @dkim_signature[m[1]] = m[2]
36
+ end
37
+ end
38
+ validate_signature! # just checking to make sure we have all the ingredients we need to actually verify the signature
39
+ end
40
+
41
+
42
+ def verify!
43
+ figure_out_canonicalization_methods!
44
+ verify_body_hash!
45
+
46
+ # 'b=' is the signed message headers' hash.
47
+ # we need to decrypt the 'b=' value (with the public key)
48
+ # and compare it with the computed headers_hash.
49
+ # decrypted_header_hash is the "expected" value.
50
+ my_headers_hash = headers_hash
51
+ my_decrypted_header_hash = decrypted_header_hash
52
+
53
+ raise DkimVerificationFailure.new("header hash signatures sizes don't match") if my_decrypted_header_hash.size != my_headers_hash.size
54
+
55
+ # Byte-by-byte compare of signatures
56
+ does_signature_match = my_decrypted_header_hash.bytes.zip(my_headers_hash.bytes).all?{|exp, got| exp == got }
57
+ raise DkimVerificationFailure.new("header hash signatures don't match. expected #{my_decrypted_header_hash}, got #{my_headers_hash}") unless does_signature_match
58
+ return does_signature_match # always true, but this is a good guarantee of somebody accidentally refactoring this to always return true
59
+ end
60
+
61
+ private
62
+
63
+
64
+ def verify_body_hash!
65
+ # here we're figuring out what algorithm to use for computing the signature
66
+ hasher, @hashid = if @dkim_signature['a'] == "rsa-sha1"
67
+ [Digest::SHA1, HASHID_SHA1]
68
+ elsif @dkim_signature['a'] == "rsa-sha256"
69
+ [Digest::SHA256, HASHID_SHA256]
70
+ else
71
+ puts "couldn't figure out the right algorithm to use"
72
+ exit 1
73
+ end
74
+
75
+ body = Dkim.canonicalize_body(@body, @how_to_canonicalize_body)
76
+
77
+
78
+ bodyhash = hasher.digest(body)
79
+
80
+ $debuglog.puts "bh: #{Base64.encode64(bodyhash)}" unless $debuglog.nil?
81
+
82
+ if bodyhash != Base64.decode64(@dkim_signature['bh'].gsub(/\s+/, ''))
83
+ error_msg = "body hash mismatch (got #{Base64.encode64(bodyhash)}, expected #{@dkim_signature['bh']})"
84
+ $debuglog.puts error_msg unless $debuglog.nil?
85
+ raise DkimVerificationFailure.new(error_msg)
86
+ end
87
+ nil
88
+ end
89
+
90
+
91
+ # here we're figuring out the canonicalization algorithm for the body and for the headers
92
+ def figure_out_canonicalization_methods!
93
+ c_match = @dkim_signature['c'].match(/(\w+)(?:\/(\w+))?$/)
94
+ if not c_match
95
+ puts "can't figure out canonicalization ('c=')"
96
+ return false
97
+ end
98
+ @how_to_canonicalize_headers = c_match[1]
99
+ if c_match[2]
100
+ @how_to_canonicalize_body = c_match[2]
101
+ else
102
+ @how_to_canonicalize_body = "simple"
103
+ end
104
+ raise ArgumentError, "invalid canonicalization method for headers" unless ["relaxed", "simple"].include?(@how_to_canonicalize_headers)
105
+ raise ArgumentError, "invalid canonicalization method for body" unless ["relaxed", "simple"].include?(@how_to_canonicalize_body)
106
+ end
107
+
108
+ def public_key
109
+ # here we're getting the website's actual public key from the DNS system
110
+ # s = dnstxt(sig['s']+"._domainkey."+sig['d']+".")
111
+ dkim_record_from_dns = DKIM::Query::Domain.query(@dkim_signature['d'], {:selectors => [@dkim_signature['s']]}).keys[@dkim_signature['s']]
112
+ x = OpenSSL::ASN1.decode(Base64.decode64(dkim_record_from_dns.public_key.to_s))
113
+ publickey = x.value[1].value
114
+ end
115
+
116
+ def headers_to_sign
117
+
118
+ # we figure out which headers we care about, then canonicalize them
119
+ header_fields_to_include = @dkim_signature['h'].split(/\s*:\s*/)
120
+ $debuglog.puts "header_fields_to_include: #{header_fields_to_include}" unless $debuglog.nil?
121
+ canonicalized_headers = []
122
+ canonicalized_headers = Dkim.canonicalize_headers(header_fields_to_include.map{|header_name| [header_name, @headers[header_name].value] }, @how_to_canonicalize_headers)
123
+ # def _remove(s, t):
124
+ # i = s.find(t)
125
+ # assert i >= 0
126
+ # return s[:i] + s[i+len(t):]
127
+
128
+
129
+ # The call to _remove() assumes that the signature b= only appears once in the signature header
130
+ canonicalized_headers += Dkim.canonicalize_headers([
131
+ [
132
+ @headers["DKIM-Signature"].name.to_s,
133
+ @headers["DKIM-Signature"].value.to_s.split(@dkim_signature['b']).join('')
134
+ ]
135
+ ], @how_to_canonicalize_headers).map{|x| [x[0], x[1].rstrip()] }
136
+
137
+ $debuglog.puts "verify headers: #{canonicalized_headers}" unless $debuglog.nil?
138
+ canonicalized_headers
139
+ end
140
+
141
+ def headers_digest
142
+ hasher = if @dkim_signature['a'] == "rsa-sha1"
143
+ Digest::SHA1
144
+ elsif @dkim_signature['a'] == "rsa-sha256"
145
+ Digest::SHA256
146
+ else
147
+ puts "couldn't figure out the right algorithm to use"
148
+ exit 1
149
+ end.new
150
+ headers_to_sign.each do |header|
151
+ hasher.update(header[0])
152
+ hasher.update(":")
153
+ hasher.update(header[1])
154
+ end
155
+ digest = hasher.digest
156
+ $debuglog.puts "verify digest: #{ Base64.encode64(digest) }" unless $debuglog.nil?
157
+ digest
158
+ end
159
+
160
+
161
+ def headers_hash
162
+ dinfo = OpenSSL::ASN1::Sequence.new([
163
+ OpenSSL::ASN1::Sequence.new([
164
+ @hashid,
165
+ OpenSSL::ASN1::Null.new(nil),
166
+ ]),
167
+ OpenSSL::ASN1::OctetString.new(headers_digest),
168
+ ])
169
+ $debuglog.puts "dinfo: #{ dinfo.to_der }" unless $debuglog.nil?
170
+ headers_der = Base64.encode64(dinfo.to_der).gsub(/\s+/, '')
171
+ $debuglog.puts "headers_hash: #{headers_der}" unless $debuglog.nil?
172
+ headers_der
173
+ end
174
+
175
+ def decrypted_header_hash
176
+ decrypted_header_hash_bytes = OpenSSL::PKey::RSA.new(public_key).public_decrypt(Base64.decode64(@dkim_signature['b']))
177
+ ret = Base64.encode64(decrypted_header_hash_bytes).gsub(/\s+/, '')
178
+ $debuglog.puts "decrypted_header_hash: #{ret}" unless $debuglog.nil?
179
+ ret
180
+ end
181
+
182
+ def validate_signature!
183
+ # version: only version 1 is defined
184
+ raise InvalidDkimSignature("DKIM signature is missing required tag v=") unless @dkim_signature.include?('v')
185
+ raise InvalidDkimSignature("DKIM signature v= value is invalid (got \"#{@dkim_signature['v']}\"; expected \"1\")") unless @dkim_signature['v'] == "1"
186
+
187
+ # encryption algorithm
188
+ raise InvalidDkimSignature("DKIM signature is missing required tag a=") unless @dkim_signature.include?('a')
189
+
190
+ # header hash
191
+ raise InvalidDkimSignature("DKIM signature is missing required tag b=") unless @dkim_signature.include?('b')
192
+ raise InvalidDkimSignature("DKIM signature b= value is not valid base64") unless @dkim_signature['b'].match(/[\s0-9A-Za-z+\/]+=*$/)
193
+ raise InvalidDkimSignature("DKIM signature is missing required tag h=") unless @dkim_signature.include?('h')
194
+
195
+ # body hash (not directly encrypted)
196
+ raise InvalidDkimSignature("DKIM signature is missing required tag bh=") unless @dkim_signature.include?('bh')
197
+ raise InvalidDkimSignature("DKIM signature bh= value is not valid base64") unless @dkim_signature['bh'].match(/[\s0-9A-Za-z+\/]+=*$/)
198
+
199
+ # domain selector
200
+ raise InvalidDkimSignature("DKIM signature is missing required tag d=") unless @dkim_signature.include?('d')
201
+ raise InvalidDkimSignature("DKIM signature is missing required tag s=") unless @dkim_signature.include?('s')
202
+
203
+ # these are expiration dates, which are not checked above.
204
+ raise InvalidDkimSignature("DKIM signature t= value is not a valid decimal integer") unless @dkim_signature['t'].nil? || @dkim_signature['t'].match(/\d+$/)
205
+ raise InvalidDkimSignature("DKIM signature x= value is not a valid decimal integer") unless @dkim_signature['x'].nil? || @dkim_signature['x'].match(/\d+$/)
206
+ raise InvalidDkimSignature("DKIM signature x= value is less than t= (and must be greater than or equal to t=). (x=#{@dkim_signature['x']}, t=#{@dkim_signature['t']}) ") unless @dkim_signature['x'].nil? || @dkim_signature['x'].to_i >= @dkim_signature['t'].to_i
207
+
208
+ # other unimplemented stuff
209
+ raise InvalidDkimSignature("DKIM signature i= domain is not a subdomain of d= (i=#{@dkim_signature[i]} d=#{@dkim_signature[d]})") if @dkim_signature['i'] && !(@dkim_signature['i'].end_with?(@dkim_signature['d']) || ["@", ".", "@."].include?(@dkim_signature['i'][-@dkim_signature['d'].size-1]))
210
+ raise InvalidDkimSignature("DKIM signature l= value is invalid") if @dkim_signature['l'] && !@dkim_signature['l'].match(/\d{,76}$/)
211
+ raise InvalidDkimSignature("DKIM signature q= value is invalid (got \"#{@dkim_signature['q']}\"; expected \"dns/txt\")") if @dkim_signature['q'] && @dkim_signature['q'] != "dns/txt"
212
+ end
213
+ end
214
+
215
+ # these two canonicalization methods are defined in the DKIM RFC
216
+ def self.canonicalize_headers(headers, how)
217
+ if how == "simple"
218
+ # No changes to headers.
219
+ $debuglog.puts "canonicalizing headers with 'simple'" unless $debuglog.nil?
220
+ return headers
221
+ elsif how == "relaxed"
222
+ # Convert all header field names to lowercase.
223
+ # Unfold all header lines.
224
+ # Compress WSP to single space.
225
+ # Remove all WSP at the start or end of the field value (strip).
226
+ $debuglog.puts "canonicalizing headers with 'relaxed'" unless $debuglog.nil?
227
+ headers.map{|k, v| [k.downcase, v.gsub(/\r\n/, '').gsub(/\s+/, " ").strip + "\r\n"] }
228
+ end
229
+ end
230
+ def self.canonicalize_body(body, how)
231
+ if how == "simple"
232
+ $debuglog.puts "canonicalizing body with 'simple'" unless $debuglog.nil?
233
+ # Ignore all empty lines at the end of the message body.
234
+ body.gsub(/(\r\n)*$/, "\r\n")
235
+ elsif how == "relaxed"
236
+ $debuglog.puts "canonicalizing body with 'relaxed'" unless $debuglog.nil?
237
+
238
+ body.gsub(/[\x09\x20]+\r\n/, "\r\n") # Remove all trailing WSP at end of lines.
239
+ .gsub(/[\x09\x20]+/, " ") # Compress non-line-ending WSP to single space.
240
+ .gsub(/(\r\n)+\Z/, "\r\n") # Ignore all empty lines at the end of the message body.
241
+ # POTENTIAL PROBLEM: the python source has /(\r\n)*$/ so the + / * change is dubious
242
+ end
243
+ end
244
+
245
+ end
246
+
247
+ if __FILE__ == $0
248
+ emlfn = ARGV[0] || "/Users/204434/code/stevedore-uploader-internal/inputs/podesta-part36/59250.eml"
249
+ begin
250
+ Dkim::Verifier.new(emlfn).verify!
251
+ rescue Dkim::DkimError
252
+ puts "uh oh, something went wrong, the signature did not verify correctly"
253
+ exit 1
254
+ end
255
+ puts "signature verified correctly"
256
+ end
metadata ADDED
@@ -0,0 +1,104 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: dkimverify
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - Jeremy B. Merrill
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2017-01-25 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: mail
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - '='
18
+ - !ruby/object:Gem::Version
19
+ version: 2.6.4
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - '='
25
+ - !ruby/object:Gem::Version
26
+ version: 2.6.4
27
+ - !ruby/object:Gem::Dependency
28
+ name: parslet
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '1.6'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '1.6'
41
+ description: " A pure-Ruby library for validating/verifying DKIM signatures. "
42
+ email:
43
+ - jeremybmerrill@gmail.com
44
+ executables: []
45
+ extensions: []
46
+ extra_rdoc_files: []
47
+ files:
48
+ - ".gitignore"
49
+ - Gemfile
50
+ - LICENSE.txt
51
+ - README.md
52
+ - dkim-query/.gitignore
53
+ - dkim-query/.rspec
54
+ - dkim-query/.travis.yml
55
+ - dkim-query/.yardopts
56
+ - dkim-query/ChangeLog.md
57
+ - dkim-query/Gemfile
58
+ - dkim-query/LICENSE.txt
59
+ - dkim-query/README.md
60
+ - dkim-query/Rakefile
61
+ - dkim-query/bin/dkim-query
62
+ - dkim-query/dkim-query.gemspec
63
+ - dkim-query/lib/dkim/query.rb
64
+ - dkim-query/lib/dkim/query/domain.rb
65
+ - dkim-query/lib/dkim/query/exceptions.rb
66
+ - dkim-query/lib/dkim/query/key.rb
67
+ - dkim-query/lib/dkim/query/malformed_key.rb
68
+ - dkim-query/lib/dkim/query/parser.rb
69
+ - dkim-query/lib/dkim/query/query.rb
70
+ - dkim-query/lib/dkim/query/version.rb
71
+ - dkim-query/spec/domain_spec.rb
72
+ - dkim-query/spec/key_spec.rb
73
+ - dkim-query/spec/malformed_key.rb
74
+ - dkim-query/spec/parser_spec.rb
75
+ - dkim-query/spec/query_spec.rb
76
+ - dkim-query/spec/spec_helper.rb
77
+ - dkim-query/tasks/alexa.rb
78
+ - dkimverify.gemspec
79
+ - dkimverify.rb
80
+ homepage: https://github.com/jeremybmerrill/dkimverify
81
+ licenses:
82
+ - MIT
83
+ metadata: {}
84
+ post_install_message:
85
+ rdoc_options: []
86
+ require_paths:
87
+ - dkim-query
88
+ required_ruby_version: !ruby/object:Gem::Requirement
89
+ requirements:
90
+ - - ">="
91
+ - !ruby/object:Gem::Version
92
+ version: '0'
93
+ required_rubygems_version: !ruby/object:Gem::Requirement
94
+ requirements:
95
+ - - ">="
96
+ - !ruby/object:Gem::Version
97
+ version: '0'
98
+ requirements: []
99
+ rubyforge_project:
100
+ rubygems_version: 2.5.2
101
+ signing_key:
102
+ specification_version: 4
103
+ summary: A pure-Ruby library for validating/verifying DKIM signatures.
104
+ test_files: []