pwnedkeys-tools 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,159 @@
1
+ require "base64"
2
+ require "json"
3
+ require "net/http"
4
+ require "openssl"
5
+
6
+ require "openssl/pkey/rsa"
7
+ require "openssl/pkey/ec"
8
+
9
+ module Pwnedkeys
10
+ class Request
11
+ class Error < StandardError; end
12
+ class VerificationError < Error; end
13
+
14
+ def initialize(spki)
15
+ @spki = if spki.is_a?(OpenSSL::X509::SPKI)
16
+ spki
17
+ elsif spki.is_a?(String)
18
+ begin
19
+ OpenSSL::X509::SPKI.new(spki)
20
+ rescue OpenSSL::ASN1::ASN1Error, OpenSSL::X509::SPKIError
21
+ raise Error,
22
+ "Invalid SPKI ASN.1 string"
23
+ end
24
+ else
25
+ raise Error,
26
+ "Invalid argument type passed to Pwnedkeys::Request.new (need OpenSSL::X509::SPKI or string, got #{spki.class})"
27
+ end
28
+
29
+ # Verify key type is OK
30
+ key_params
31
+ end
32
+
33
+ def pwned?
34
+ retry_count = 10
35
+
36
+ loop do
37
+ uri = URI(ENV["PWNEDKEYS_API_URL"] || "https://v1.pwnedkeys.com")
38
+ uri.path += "/#{@spki.spki_fingerprint.hexdigest}"
39
+
40
+ res = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |http|
41
+ req = Net::HTTP::Get.new(uri.path)
42
+ req["User-Agent"] = "pwnedkeys-tools/0.0.0"
43
+ http.request(req)
44
+ end
45
+
46
+ if res.code == "200"
47
+ verify!(res.body)
48
+ return true
49
+ elsif res.code == "404"
50
+ return false
51
+ elsif (500..599) === res.code.to_i && retry_count > 0
52
+ # Server-side error, let's try a few more times
53
+ sleep 1
54
+ retry_count -= 1
55
+ else
56
+ raise Error,
57
+ "Unable to determine pwnage, error status code returned from #{uri}: #{res.code}"
58
+ end
59
+ end
60
+ end
61
+
62
+ private
63
+
64
+ def verify!(res)
65
+ json = JSON.parse(res)
66
+ header = JSON.parse(unb64(json["protected"]))
67
+
68
+ key = @spki.to_key
69
+
70
+ verify_data = "#{json["protected"]}.#{json["payload"]}"
71
+
72
+ unless key.verify(hash_func.new, format_sig(unb64(json["signature"])), verify_data)
73
+ raise VerificationError,
74
+ "Response signature cannot be validated by provided key"
75
+ end
76
+
77
+ unless header["alg"] == key_alg
78
+ raise VerificationError,
79
+ "Incorrect alg parameter. Got #{header["alg"]}, expected #{key_alg} for #{key.class} key"
80
+ end
81
+
82
+ unless header["kid"] == @spki.spki_fingerprint.hexdigest
83
+ raise VerificationError,
84
+ "Key ID in response doesn't match. Got #{header["kid"]}, expected #{@spki.spki_fingerprint.hexdigest}"
85
+ end
86
+
87
+ unless unb64(json["payload"]) =~ /key is pwned/
88
+ raise VerificationError,
89
+ "Response payload does not include magic string 'key is pwned', got #{unb64(json["payload"])}"
90
+ end
91
+
92
+ # The gauntlet has been run and you have been found... worthy
93
+ end
94
+
95
+ def unb64(s)
96
+ Base64.urlsafe_decode64(s)
97
+ end
98
+
99
+ def key_alg
100
+ key_params[:key_alg]
101
+ end
102
+
103
+ def hash_func
104
+ key_params[:hash_func]
105
+ end
106
+
107
+ def format_sig(sig)
108
+ key_params[:format_sig].call(sig)
109
+ end
110
+
111
+ def ec_sig(jose_sig)
112
+ # *Real* EC signatures are a two-element ASN.1 sequence containing
113
+ # the R and S values. RFC7518, in its infinite wisdom, has decided that
114
+ # that is not good enough, and instead it wants the signatures in raw
115
+ # concatenated R/S as octet strings. Because of *course* it does.
116
+ OpenSSL::ASN1::Sequence.new(split_in_two_equal_halves(jose_sig).map do |i|
117
+ OpenSSL::ASN1::Integer.new(i.unpack("C*").inject(0) { |v, i| v * 256 + i })
118
+ end).to_der
119
+ end
120
+
121
+ def split_in_two_equal_halves(s)
122
+ [s[0..(s.length / 2 - 1)], s[(s.length / 2)..(s.length - 1)]]
123
+ end
124
+
125
+ def key_params
126
+ case @spki.to_key
127
+ when OpenSSL::PKey::RSA then {
128
+ key_alg: "RS256",
129
+ hash_func: OpenSSL::Digest::SHA256,
130
+ format_sig: ->(sig) { sig },
131
+ }
132
+ when OpenSSL::PKey::EC
133
+ case @spki.to_key.public_key.group.curve_name
134
+ when "prime256v1" then {
135
+ key_alg: "ES256",
136
+ hash_func: OpenSSL::Digest::SHA256,
137
+ format_sig: ->(sig) { ec_sig(sig) },
138
+ }
139
+ when "secp384r1" then {
140
+ key_alg: "ES384",
141
+ hash_func: OpenSSL::Digest::SHA384,
142
+ format_sig: ->(sig) { ec_sig(sig) },
143
+ }
144
+ when "secp521r1" then {
145
+ key_alg: "ES512",
146
+ hash_func: OpenSSL::Digest::SHA512,
147
+ # The components of P-521 keys are 521 bits each, which is padded
148
+ # out to be 528 bits -- 66 octets.
149
+ format_sig: ->(sig) { ec_sig(sig) },
150
+ }
151
+ else
152
+ raise Error, "EC key containing unsupported curve #{@spki.to_key.group.curve_name}"
153
+ end
154
+ else
155
+ raise Error, "Unsupported key type #{@key.class}"
156
+ end
157
+ end
158
+ end
159
+ end
@@ -0,0 +1,107 @@
1
+ require "base64"
2
+ require "json"
3
+ require "openssl"
4
+
5
+ require "openssl/pkey/rsa"
6
+ require "openssl/pkey/ec"
7
+
8
+ module Pwnedkeys
9
+ class Response
10
+ class Error < StandardError; end
11
+
12
+ def initialize(key)
13
+ @key = if key.kind_of?(OpenSSL::PKey::PKey)
14
+ key
15
+ elsif key.is_a?(String)
16
+ begin
17
+ OpenSSL::PKey.read(key)
18
+ rescue OpenSSL::PKey::PKeyError
19
+ raise Error,
20
+ "Unable to parse provided key data"
21
+ end
22
+ else
23
+ raise Error,
24
+ "Invalid argument type passed to Pwnedkeys::Response.new (need OpenSSL::PKey::PKey or string, got #{key.class})"
25
+ end
26
+ end
27
+
28
+ def to_json(*spki_format)
29
+ header = {
30
+ alg: key_alg,
31
+ kid: @key.to_spki(*spki_format).spki_fingerprint.hexdigest,
32
+ }
33
+
34
+ obj = {
35
+ payload: b64("This key is pwned! See https://pwnedkeys.com for more info."),
36
+ protected: b64(header.to_json),
37
+ }
38
+
39
+ obj[:signature] = b64(sign(obj))
40
+ obj.to_json
41
+ end
42
+
43
+ private
44
+
45
+ def b64(s)
46
+ Base64.urlsafe_encode64(s).sub(/=*\z/, '')
47
+ end
48
+
49
+ def key_alg
50
+ key_params[:key_alg]
51
+ end
52
+
53
+ def hash_func
54
+ key_params[:hash_func]
55
+ end
56
+
57
+ def format_sig(sig)
58
+ key_params[:format_sig].call(sig)
59
+ end
60
+
61
+ def jose_sig(ec_sig, len)
62
+ # EC signatures are a two-element ASN.1 sequence containing
63
+ # the R and S values. RFC7518, in its infinite wisdom, has decided that
64
+ # that is not good enough, and instead it wants the signatures in raw
65
+ # concatenated R/S as octet strings. Because of *course* it does.
66
+ OpenSSL::ASN1.decode(ec_sig).value.map { |n| [sprintf("%0#{len * 2}x", n.value)].pack("H*") }.join
67
+ end
68
+
69
+ def key_params
70
+ case @key
71
+ when OpenSSL::PKey::RSA then {
72
+ key_alg: "RS256",
73
+ hash_func: OpenSSL::Digest::SHA256,
74
+ format_sig: ->(sig) { sig },
75
+ }
76
+ when OpenSSL::PKey::EC
77
+ case @key.public_key.group.curve_name
78
+ when "prime256v1" then {
79
+ key_alg: "ES256",
80
+ hash_func: OpenSSL::Digest::SHA256,
81
+ format_sig: ->(sig) { jose_sig(sig, 32) },
82
+ }
83
+ when "secp384r1" then {
84
+ key_alg: "ES384",
85
+ hash_func: OpenSSL::Digest::SHA384,
86
+ format_sig: ->(sig) { jose_sig(sig, 48) },
87
+ }
88
+ when "secp521r1" then {
89
+ key_alg: "ES512",
90
+ hash_func: OpenSSL::Digest::SHA512,
91
+ # The components of P-521 keys are 521 bits each, which is padded
92
+ # out to be 528 bits -- 66 octets.
93
+ format_sig: ->(sig) { jose_sig(sig, 66) },
94
+ }
95
+ else
96
+ raise Error, "EC key containing unsupported curve #{@key.public_key.group.curve_name}"
97
+ end
98
+ else
99
+ raise Error, "Unsupported key type #{@key.class}"
100
+ end
101
+ end
102
+
103
+ def sign(obj)
104
+ format_sig(@key.sign(hash_func.new, obj[:protected] + "." + obj[:payload]))
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,46 @@
1
+ begin
2
+ require 'git-version-bump'
3
+ rescue LoadError
4
+ nil
5
+ end
6
+
7
+ Gem::Specification.new do |s|
8
+ s.name = "pwnedkeys-tools"
9
+
10
+ s.version = GVB.version rescue "0.0.0.1.NOGVB"
11
+ s.date = GVB.date rescue Time.now.strftime("%Y-%m-%d")
12
+
13
+ s.platform = Gem::Platform::RUBY
14
+
15
+ s.summary = "A set of command-line tools useful for working with the pwnedkeys.com service"
16
+ s.description = <<~EOF
17
+ The scripts in this package are designed to be used in conjunction with the
18
+ pwnedkeys.com compromised keys database. They include:
19
+
20
+ * `pwnedkeys-prove-pwned`, which generates a signed attestation of
21
+ compromise suitable for being served by the pwnedkeys.com V1 API.
22
+
23
+ * `pwnedkeys-query`, which takes a public or private key, CSR, or X.509
24
+ certificate and looks it up in the pwnedkeys.com database.
25
+ EOF
26
+
27
+ s.authors = ["Matt Palmer"]
28
+ s.email = ["matt@hezmatt.org"]
29
+ s.homepage = "https://github.com/pwnedkeys/pwnedkeys-tools"
30
+
31
+ s.files = `git ls-files -z`.split("\0").reject { |f| f =~ /^(\.|G|spec|Rakefile)/ }
32
+ s.executables = ["pwnedkeys-prove-pwned", "pwnedkeys-query"]
33
+
34
+ s.required_ruby_version = ">= 2.5.0"
35
+
36
+ s.add_development_dependency 'bundler'
37
+ s.add_development_dependency 'github-release'
38
+ s.add_development_dependency 'git-version-bump'
39
+ s.add_development_dependency 'guard-rspec'
40
+ s.add_development_dependency 'rack-test'
41
+ s.add_development_dependency 'rake', "~> 12.0"
42
+ s.add_development_dependency 'redcarpet'
43
+ s.add_development_dependency 'rspec'
44
+ s.add_development_dependency 'simplecov'
45
+ s.add_development_dependency 'yard'
46
+ end
metadata ADDED
@@ -0,0 +1,211 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: pwnedkeys-tools
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Matt Palmer
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2018-12-18 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: bundler
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: github-release
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: git-version-bump
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: guard-rspec
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: rack-test
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: rake
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '12.0'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '12.0'
97
+ - !ruby/object:Gem::Dependency
98
+ name: redcarpet
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - ">="
102
+ - !ruby/object:Gem::Version
103
+ version: '0'
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - ">="
109
+ - !ruby/object:Gem::Version
110
+ version: '0'
111
+ - !ruby/object:Gem::Dependency
112
+ name: rspec
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - ">="
116
+ - !ruby/object:Gem::Version
117
+ version: '0'
118
+ type: :development
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - ">="
123
+ - !ruby/object:Gem::Version
124
+ version: '0'
125
+ - !ruby/object:Gem::Dependency
126
+ name: simplecov
127
+ requirement: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - ">="
130
+ - !ruby/object:Gem::Version
131
+ version: '0'
132
+ type: :development
133
+ prerelease: false
134
+ version_requirements: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - ">="
137
+ - !ruby/object:Gem::Version
138
+ version: '0'
139
+ - !ruby/object:Gem::Dependency
140
+ name: yard
141
+ requirement: !ruby/object:Gem::Requirement
142
+ requirements:
143
+ - - ">="
144
+ - !ruby/object:Gem::Version
145
+ version: '0'
146
+ type: :development
147
+ prerelease: false
148
+ version_requirements: !ruby/object:Gem::Requirement
149
+ requirements:
150
+ - - ">="
151
+ - !ruby/object:Gem::Version
152
+ version: '0'
153
+ description: |
154
+ The scripts in this package are designed to be used in conjunction with the
155
+ pwnedkeys.com compromised keys database. They include:
156
+
157
+ * `pwnedkeys-prove-pwned`, which generates a signed attestation of
158
+ compromise suitable for being served by the pwnedkeys.com V1 API.
159
+
160
+ * `pwnedkeys-query`, which takes a public or private key, CSR, or X.509
161
+ certificate and looks it up in the pwnedkeys.com database.
162
+ email:
163
+ - matt@hezmatt.org
164
+ executables:
165
+ - pwnedkeys-prove-pwned
166
+ - pwnedkeys-query
167
+ extensions: []
168
+ extra_rdoc_files: []
169
+ files:
170
+ - CODE_OF_CONDUCT.md
171
+ - CONTRIBUTING.md
172
+ - Dockerfile
173
+ - LICENCE
174
+ - README.md
175
+ - bin/pwnedkeys-prove-pwned
176
+ - bin/pwnedkeys-query
177
+ - docker-wrappers/pwnedkeys-prove-pwned
178
+ - docker-wrappers/pwnedkeys-query
179
+ - lib/openssl/pkey.rb
180
+ - lib/openssl/pkey/ec.rb
181
+ - lib/openssl/pkey/rsa.rb
182
+ - lib/openssl/x509/certificate.rb
183
+ - lib/openssl/x509/request.rb
184
+ - lib/openssl/x509/spki.rb
185
+ - lib/pwnedkeys/request.rb
186
+ - lib/pwnedkeys/response.rb
187
+ - pwnedkeys-tools.gemspec
188
+ homepage: https://github.com/pwnedkeys/pwnedkeys-tools
189
+ licenses: []
190
+ metadata: {}
191
+ post_install_message:
192
+ rdoc_options: []
193
+ require_paths:
194
+ - lib
195
+ required_ruby_version: !ruby/object:Gem::Requirement
196
+ requirements:
197
+ - - ">="
198
+ - !ruby/object:Gem::Version
199
+ version: 2.5.0
200
+ required_rubygems_version: !ruby/object:Gem::Requirement
201
+ requirements:
202
+ - - ">="
203
+ - !ruby/object:Gem::Version
204
+ version: '0'
205
+ requirements: []
206
+ rubyforge_project:
207
+ rubygems_version: 2.7.6
208
+ signing_key:
209
+ specification_version: 4
210
+ summary: A set of command-line tools useful for working with the pwnedkeys.com service
211
+ test_files: []