pwnedkeys-tools 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,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: []