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.
- checksums.yaml +7 -0
- data/.gitignore +4 -0
- data/Gemfile +3 -0
- data/LICENSE.txt +20 -0
- data/README.md +28 -0
- data/dkim-query/.gitignore +51 -0
- data/dkim-query/.rspec +1 -0
- data/dkim-query/.travis.yml +16 -0
- data/dkim-query/.yardopts +1 -0
- data/dkim-query/ChangeLog.md +25 -0
- data/dkim-query/Gemfile +19 -0
- data/dkim-query/LICENSE.txt +20 -0
- data/dkim-query/README.md +105 -0
- data/dkim-query/Rakefile +24 -0
- data/dkim-query/bin/dkim-query +34 -0
- data/dkim-query/dkim-query.gemspec +26 -0
- data/dkim-query/lib/dkim/query/domain.rb +141 -0
- data/dkim-query/lib/dkim/query/exceptions.rb +8 -0
- data/dkim-query/lib/dkim/query/key.rb +162 -0
- data/dkim-query/lib/dkim/query/malformed_key.rb +36 -0
- data/dkim-query/lib/dkim/query/parser.rb +175 -0
- data/dkim-query/lib/dkim/query/query.rb +74 -0
- data/dkim-query/lib/dkim/query/version.rb +6 -0
- data/dkim-query/lib/dkim/query.rb +4 -0
- data/dkim-query/spec/domain_spec.rb +96 -0
- data/dkim-query/spec/key_spec.rb +117 -0
- data/dkim-query/spec/malformed_key.rb +15 -0
- data/dkim-query/spec/parser_spec.rb +300 -0
- data/dkim-query/spec/query_spec.rb +68 -0
- data/dkim-query/spec/spec_helper.rb +13 -0
- data/dkim-query/tasks/alexa.rb +43 -0
- data/dkimverify.gemspec +16 -0
- data/dkimverify.rb +256 -0
- metadata +104 -0
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: []
|