pwnedkeys-api-client 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,74 @@
1
+ This is a library for querying the [pwnedkeys.com](https://pwnedkeys.com) API.
2
+ It allows you to provide a public key, and determine whether or not the
3
+ corresponding public key has ever been exposed to the public, rendering it
4
+ permanently unsafe to use.
5
+
6
+ Due to recent changes in the `openssl` standard library, this code requires
7
+ at least Ruby 2.5 to run.
8
+
9
+
10
+ # Installation
11
+
12
+ It's a gem!
13
+
14
+ gem install pwnedkeys-api-client
15
+
16
+ If you're the sturdy type that likes to run from git:
17
+
18
+ rake install
19
+
20
+ Or, if you've eschewed the convenience of Rubygems entirely, then you
21
+ presumably know what to do already.
22
+
23
+
24
+ # Usage
25
+
26
+ To query for a pwned key, simply create a new `Pwnedkeys::Request`, passing in
27
+ the public key you want to query for:
28
+
29
+ require "pwnedkeys/request"
30
+
31
+ key = OpenSSL::PKey.read("/tmp/suss_key")
32
+ query = Pwnedkeys::Request.new(key)
33
+
34
+ Then, just ask whether it's pwned!
35
+
36
+ query.pwned?
37
+
38
+ You'll get back a `true` or `false` answer in next-to-no-time. If any problems
39
+ crop up (like the signature can't be validated, or the API doesn't respond) you'll
40
+ get a `Pwnedkeys::Request::Error` exception.
41
+
42
+
43
+ # Contributing
44
+
45
+ See `CONTRIBUTING.md`.
46
+
47
+
48
+ # Licence
49
+
50
+ Unless otherwise stated, everything in this repo is covered by the following
51
+ copyright notice:
52
+
53
+ Copyright (C) 2018 Matt Palmer <matt@hezmatt.org>
54
+
55
+ This program is free software: you can redistribute it and/or modify it
56
+ under the terms of the GNU General Public License version 3, as
57
+ published by the Free Software Foundation.
58
+
59
+ This program is distributed in the hope that it will be useful,
60
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
61
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
62
+ GNU General Public License for more details.
63
+
64
+ You should have received a copy of the GNU General Public License
65
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
66
+
67
+ In addition, as a special exception, the copyright holders give permission
68
+ to link the code of portions of this program with the OpenSSL library. You
69
+ must obey the GNU General Public License in all respects for all of the
70
+ code used other than OpenSSL. If you modify file(s) with this exception,
71
+ you may extend this exception to your version of the file(s), but you are
72
+ not obligated to do so. If you do not wish to do so, delete this exception
73
+ statement from your version. If you delete this exception statement from
74
+ all source files in the program, then also delete it here.
@@ -0,0 +1,203 @@
1
+ require "base64"
2
+ require "json"
3
+ require "net/http"
4
+ require "openssl"
5
+
6
+ require "openssl/x509/spki"
7
+
8
+ # All the keys that are fit to be pwned.
9
+ module Pwnedkeys
10
+ # Make a query against the pwnedkeys.com API.
11
+ #
12
+ class Request
13
+ # Generic error relating to API requests.
14
+ #
15
+ class Error < StandardError; end
16
+
17
+ # Raised if the signature on the pwnedkeys response cannot be
18
+ # validated. This almost certainly indicates something very wrong
19
+ # in the API itself, as the correctness of the key is ensured by the
20
+ # query protocol.
21
+ #
22
+ class VerificationError < Error; end
23
+
24
+ # Prepare to the make a request to the pwnedkeys API.
25
+ #
26
+ # @param spki [OpenSSL::X509::SPKI, OpenSSL::PKey::PKey, String] the
27
+ # public key to query for. Can be provided as a key object itself,
28
+ # an SPKI object (most likely derived from an X509 certificate or
29
+ # CSR), or a string containing a DER-encoded `SubjectPublicKeyInfo`
30
+ # ASN.1 structure.
31
+ #
32
+ # If in doubt, just pass in a key and we'll take care of the rest.
33
+ #
34
+ # @raise [Pwnedkeys::Request::Error] if the passed-in key representation
35
+ # can't be induced into something useable.
36
+ #
37
+ def initialize(spki)
38
+ @spki = if spki.is_a?(OpenSSL::X509::SPKI)
39
+ spki
40
+ elsif spki.is_a?(String)
41
+ begin
42
+ OpenSSL::X509::SPKI.new(spki)
43
+ rescue OpenSSL::ASN1::ASN1Error, OpenSSL::X509::SPKIError
44
+ raise Error,
45
+ "Invalid SPKI ASN.1 string"
46
+ end
47
+ elsif spki.is_a?(OpenSSL::PKey::PKey)
48
+ spki.to_spki
49
+ else
50
+ raise Error,
51
+ "Invalid argument type passed to Pwnedkeys::Request.new (need OpenSSL::X509::SPKI, PKey, or string, got #{spki.class})"
52
+ end
53
+
54
+ # Verify key type is OK
55
+ key_params
56
+ end
57
+
58
+ # Query the pwnedkeys API and tell whether the key is exposed.
59
+ #
60
+ # @return [Boolean] whether the key embodied in this request is contained
61
+ # within the pwnedkeys database.
62
+ #
63
+ # @raise [VerificationError] if a response was provided, but the signature
64
+ # on the response was not able to be verified.
65
+ #
66
+ # @raise [Error] if the request to the API could not be successfully
67
+ # completed.
68
+ #
69
+ def pwned?
70
+ retry_count = 10
71
+ uri = URI(ENV["PWNEDKEYS_API_URL"] || "https://v1.pwnedkeys.com")
72
+ uri.path += "/#{@spki.spki_fingerprint.hexdigest}"
73
+
74
+ loop do
75
+ res = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |http|
76
+ req = Net::HTTP::Get.new(uri.path)
77
+ req["User-Agent"] = "pwnedkeys-tools/0.0.0"
78
+ http.request(req)
79
+ end
80
+
81
+ if res.code == "200"
82
+ verify!(res.body)
83
+ return true
84
+ elsif res.code == "404"
85
+ return false
86
+ elsif (500..599) === res.code.to_i && retry_count > 0
87
+ # Server-side error, let's try a few more times
88
+ sleep 1
89
+ retry_count -= 1
90
+ else
91
+ raise Error,
92
+ "Unable to determine pwnage, error status code returned from #{uri}: #{res.code}"
93
+ end
94
+ end
95
+ end
96
+
97
+ private
98
+
99
+ # Do the dance of the signature verification.
100
+ #
101
+ def verify!(res)
102
+ json = JSON.parse(res)
103
+ header = JSON.parse(unb64(json["protected"]))
104
+
105
+ key = @spki.to_key
106
+
107
+ verify_data = "#{json["protected"]}.#{json["payload"]}"
108
+
109
+ unless key.verify(hash_func.new, format_sig(unb64(json["signature"])), verify_data)
110
+ raise VerificationError,
111
+ "Response signature cannot be validated by provided key"
112
+ end
113
+
114
+ unless header["alg"] == key_alg
115
+ raise VerificationError,
116
+ "Incorrect alg parameter. Got #{header["alg"]}, expected #{key_alg} for #{key.class} key"
117
+ end
118
+
119
+ unless header["kid"] == @spki.spki_fingerprint.hexdigest
120
+ raise VerificationError,
121
+ "Key ID in response doesn't match. Got #{header["kid"]}, expected #{@spki.spki_fingerprint.hexdigest}"
122
+ end
123
+
124
+ unless unb64(json["payload"]) =~ /key is pwned/
125
+ raise VerificationError,
126
+ "Response payload does not include magic string 'key is pwned', got #{unb64(json["payload"])}"
127
+ end
128
+
129
+ # The gauntlet has been run and you have been found... worthy
130
+ end
131
+
132
+ # Strip off the base64 barnacles.
133
+ #
134
+ def unb64(s)
135
+ Base64.urlsafe_decode64(s)
136
+ end
137
+
138
+ def key_alg
139
+ key_params[:key_alg]
140
+ end
141
+
142
+ def hash_func
143
+ key_params[:hash_func]
144
+ end
145
+
146
+ def format_sig(sig)
147
+ key_params[:format_sig].call(sig)
148
+ end
149
+
150
+ # Turn a JOSE EC sig into a "proper" EC sig that OpenSSL can use.
151
+ #
152
+ def ec_sig(jose_sig)
153
+ # *Real* EC signatures are a two-element ASN.1 sequence containing
154
+ # the R and S values. RFC7518, in its infinite wisdom, has decided that
155
+ # that is not good enough, and instead it wants the signatures in raw
156
+ # concatenated R/S as octet strings. Because of *course* it does.
157
+ OpenSSL::ASN1::Sequence.new(split_in_two_equal_halves(jose_sig).map do |i|
158
+ OpenSSL::ASN1::Integer.new(i.unpack("C*").inject(0) { |v, i| v * 256 + i })
159
+ end).to_der
160
+ end
161
+
162
+ def split_in_two_equal_halves(s)
163
+ [s[0..(s.length / 2 - 1)], s[(s.length / 2)..(s.length - 1)]]
164
+ end
165
+
166
+ # Return all the relevant parameters required to validate the API response,
167
+ # based on the type of key being queried.
168
+ #
169
+ def key_params
170
+ case @spki.to_key
171
+ when OpenSSL::PKey::RSA then {
172
+ key_alg: "RS256",
173
+ hash_func: OpenSSL::Digest::SHA256,
174
+ format_sig: ->(sig) { sig },
175
+ }
176
+ when OpenSSL::PKey::EC
177
+ case @spki.to_key.public_key.group.curve_name
178
+ when "prime256v1" then {
179
+ key_alg: "ES256",
180
+ hash_func: OpenSSL::Digest::SHA256,
181
+ format_sig: ->(sig) { ec_sig(sig) },
182
+ }
183
+ when "secp384r1" then {
184
+ key_alg: "ES384",
185
+ hash_func: OpenSSL::Digest::SHA384,
186
+ format_sig: ->(sig) { ec_sig(sig) },
187
+ }
188
+ when "secp521r1" then {
189
+ key_alg: "ES512",
190
+ hash_func: OpenSSL::Digest::SHA512,
191
+ # The components of P-521 keys are 521 bits each, which is padded
192
+ # out to be 528 bits -- 66 octets.
193
+ format_sig: ->(sig) { ec_sig(sig) },
194
+ }
195
+ else
196
+ raise Error, "EC key containing unsupported curve #{@spki.to_key.group.curve_name}"
197
+ end
198
+ else
199
+ raise Error, "Unsupported key type #{@key.class}"
200
+ end
201
+ end
202
+ end
203
+ end
@@ -0,0 +1,146 @@
1
+ require "base64"
2
+ require "json"
3
+ require "openssl"
4
+
5
+ require "openssl/x509/spki"
6
+
7
+ module Pwnedkeys
8
+ # Generate a v1 compromise attestation.
9
+ #
10
+ class Response
11
+ # Raised in the event of any inability to generate the response.
12
+ #
13
+ class Error < StandardError; end
14
+
15
+ # Create a new response.
16
+ #
17
+ # @param key [OpenSSL::PKey::PKey, String] the key for which to generate
18
+ # the compromise attestation. It can either be an OpenSSL key object
19
+ # itself, or a string that `OpenSSL::PKey.read` will accept (so
20
+ # a PEM or DER format PKCS#8-like key).
21
+ #
22
+ # @raise [Error] if an invalid argument type was passed, or if the key
23
+ # given is not, in fact, a private key.
24
+ #
25
+ def initialize(key)
26
+ @key = if key.kind_of?(OpenSSL::PKey::PKey)
27
+ key
28
+ elsif key.is_a?(String)
29
+ begin
30
+ OpenSSL::PKey.read(key)
31
+ rescue OpenSSL::PKey::PKeyError
32
+ raise Error,
33
+ "Unable to parse provided key data"
34
+ end
35
+ else
36
+ raise Error,
37
+ "Invalid argument type passed to Pwnedkeys::Response.new (need OpenSSL::PKey::PKey or string, got #{key.class})"
38
+ end
39
+
40
+ unless @key.private?
41
+ raise Error,
42
+ "Provided key is not a private key."
43
+ end
44
+ end
45
+
46
+ # Produce a JSON format compromise attestation.
47
+ #
48
+ # @param spki_format [Object] some key types (specifically, ECDSA keys) can
49
+ # generate multiple formats of public key info, which hash to different
50
+ # key fingerprints. This parameter allows you to specify which format of
51
+ # SPKI should be generated. See the relevant key type's `#to_spki`
52
+ # method to see what the valid values are.
53
+ #
54
+ # @return [String] the JSON response body, which is a JSON Web Signature
55
+ # containing proof of possession of the private key.
56
+ #
57
+ def to_json(*spki_format)
58
+ header = {
59
+ alg: key_alg,
60
+ kid: @key.to_spki(*spki_format).spki_fingerprint.hexdigest,
61
+ }
62
+
63
+ obj = {
64
+ payload: b64("This key is pwned! See https://pwnedkeys.com for more info."),
65
+ protected: b64(header.to_json),
66
+ }
67
+
68
+ obj[:signature] = b64(sign(obj))
69
+ obj.to_json
70
+ end
71
+
72
+ private
73
+
74
+ # URL-safe base64 encoding.
75
+ #
76
+ def b64(s)
77
+ Base64.urlsafe_encode64(s).sub(/=*\z/, '')
78
+ end
79
+
80
+ def key_alg
81
+ key_params[:key_alg]
82
+ end
83
+
84
+ def hash_func
85
+ key_params[:hash_func]
86
+ end
87
+
88
+ def format_sig(sig)
89
+ key_params[:format_sig].call(sig)
90
+ end
91
+
92
+ # Turn an OpenSSL-style ECDSA signature into the frankly unhinged form
93
+ # required by JOSE.
94
+ #
95
+ def jose_sig(ec_sig, len)
96
+ # EC signatures are a two-element ASN.1 sequence containing
97
+ # the R and S values. RFC7518, in its infinite wisdom, has decided that
98
+ # that is not good enough, and instead it wants the signatures in raw
99
+ # concatenated R/S as octet strings. Because of *course* it does.
100
+ OpenSSL::ASN1.decode(ec_sig).value.map { |n| [sprintf("%0#{len * 2}x", n.value)].pack("H*") }.join
101
+ end
102
+
103
+ # Return all the relevant parameters required to generate the JWS, based
104
+ # on the type of key we're dealing with.
105
+ #
106
+ def key_params
107
+ case @key
108
+ when OpenSSL::PKey::RSA then {
109
+ key_alg: "RS256",
110
+ hash_func: OpenSSL::Digest::SHA256,
111
+ format_sig: ->(sig) { sig },
112
+ }
113
+ when OpenSSL::PKey::EC
114
+ case @key.public_key.group.curve_name
115
+ when "prime256v1" then {
116
+ key_alg: "ES256",
117
+ hash_func: OpenSSL::Digest::SHA256,
118
+ format_sig: ->(sig) { jose_sig(sig, 32) },
119
+ }
120
+ when "secp384r1" then {
121
+ key_alg: "ES384",
122
+ hash_func: OpenSSL::Digest::SHA384,
123
+ format_sig: ->(sig) { jose_sig(sig, 48) },
124
+ }
125
+ when "secp521r1" then {
126
+ key_alg: "ES512",
127
+ hash_func: OpenSSL::Digest::SHA512,
128
+ # The components of P-521 keys are 521 bits each, which is padded
129
+ # out to be 528 bits -- 66 octets.
130
+ format_sig: ->(sig) { jose_sig(sig, 66) },
131
+ }
132
+ else
133
+ raise Error, "EC key containing unsupported curve #{@key.public_key.group.curve_name}"
134
+ end
135
+ else
136
+ raise Error, "Unsupported key type #{@key.class}"
137
+ end
138
+ end
139
+
140
+ # Generate the signature over the signable parts of the JWS.
141
+ #
142
+ def sign(obj)
143
+ format_sig(@key.sign(hash_func.new, obj[:protected] + "." + obj[:payload]))
144
+ end
145
+ end
146
+ end
@@ -0,0 +1,37 @@
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-api-client"
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 = "Library to query the pwnedkeys.com API"
16
+
17
+ s.authors = ["Matt Palmer"]
18
+ s.email = ["matt@hezmatt.org"]
19
+ s.homepage = "https://github.com/pwnedkeys/pwnedkeys-api-client"
20
+
21
+ s.files = `git ls-files -z`.split("\0").reject { |f| f =~ /^(\.|G|spec|Rakefile)/ }
22
+
23
+ s.required_ruby_version = ">= 2.5.0"
24
+
25
+ s.add_runtime_dependency "openssl-additions"
26
+
27
+ s.add_development_dependency 'bundler'
28
+ s.add_development_dependency 'github-release'
29
+ s.add_development_dependency 'git-version-bump'
30
+ s.add_development_dependency 'guard-rspec'
31
+ s.add_development_dependency 'rack-test'
32
+ s.add_development_dependency 'rake', "~> 12.0"
33
+ s.add_development_dependency 'redcarpet'
34
+ s.add_development_dependency 'rspec'
35
+ s.add_development_dependency 'simplecov'
36
+ s.add_development_dependency 'yard'
37
+ end