pwnedkeys-api-client 0.1.0

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.
@@ -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