dkimverify 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
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: []