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.
- checksums.yaml +7 -0
- data/CODE_OF_CONDUCT.md +49 -0
- data/CONTRIBUTING.md +14 -0
- data/LICENCE +674 -0
- data/README.md +74 -0
- data/lib/pwnedkeys/request.rb +203 -0
- data/lib/pwnedkeys/response.rb +146 -0
- data/pwnedkeys-api-client.gemspec +37 -0
- metadata +204 -0
data/README.md
ADDED
@@ -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
|