pwnedkeys-tools 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/CODE_OF_CONDUCT.md +49 -0
- data/CONTRIBUTING.md +14 -0
- data/Dockerfile +11 -0
- data/LICENCE +674 -0
- data/README.md +109 -0
- data/bin/pwnedkeys-prove-pwned +5 -0
- data/bin/pwnedkeys-query +57 -0
- data/docker-wrappers/pwnedkeys-prove-pwned +7 -0
- data/docker-wrappers/pwnedkeys-query +7 -0
- data/lib/openssl/pkey.rb +77 -0
- data/lib/openssl/pkey/ec.rb +16 -0
- data/lib/openssl/pkey/rsa.rb +12 -0
- data/lib/openssl/x509/certificate.rb +12 -0
- data/lib/openssl/x509/request.rb +12 -0
- data/lib/openssl/x509/spki.rb +177 -0
- data/lib/pwnedkeys/request.rb +159 -0
- data/lib/pwnedkeys/response.rb +107 -0
- data/pwnedkeys-tools.gemspec +46 -0
- metadata +211 -0
data/README.md
ADDED
@@ -0,0 +1,109 @@
|
|
1
|
+
This is a collection of command-line tools and utility classes which are useful
|
2
|
+
for interacting with the [pwnedkeys.com](https://pwnedkeys.com) compromised key
|
3
|
+
database. You can search the database to determine whether a key you have is
|
4
|
+
compromised, or create a signed attestation of compromise for a key you have.
|
5
|
+
|
6
|
+
These tools are all written in Ruby, and require a fairly modern Ruby installation
|
7
|
+
(version 2.5 or later). If you have such a setup, you can [install the tools
|
8
|
+
as a gem](#installation). Otherwise, if you have Docker, you can [use the
|
9
|
+
wrapper scripts to run the tools via a docker container](#docker-wrapper-scripts).
|
10
|
+
|
11
|
+
|
12
|
+
# Installation
|
13
|
+
|
14
|
+
Due to recent changes in the `openssl` standard library, the tools require
|
15
|
+
Ruby 2.5 or later with the `openssl` extension. Assuming you've got that
|
16
|
+
available, you can install the tools as a gem:
|
17
|
+
|
18
|
+
gem install pwnedkeys-tools
|
19
|
+
|
20
|
+
If you're the sturdy type that likes to run from git:
|
21
|
+
|
22
|
+
rake install
|
23
|
+
|
24
|
+
Or, if you've eschewed the convenience of Rubygems entirely, then you
|
25
|
+
presumably know what to do already.
|
26
|
+
|
27
|
+
|
28
|
+
## Docker Wrapper Scripts
|
29
|
+
|
30
|
+
For those of you who don't have a bleeding edge Ruby installation laying
|
31
|
+
around, but *do* have a Docker installation, you can copy the scripts in
|
32
|
+
the `docker-wrappers` subdirectory into a directory in your `PATH`, and
|
33
|
+
you'll be ready to go.
|
34
|
+
|
35
|
+
|
36
|
+
# Usage
|
37
|
+
|
38
|
+
Whether you're running as a gem or via Docker, the command line tools have the
|
39
|
+
same names and usage.
|
40
|
+
|
41
|
+
## Query for a pwned key
|
42
|
+
|
43
|
+
Run `pwnedkeys-query`, passing a public or private key, CSR, or X.509 certificate
|
44
|
+
via `stdin`:
|
45
|
+
|
46
|
+
pwnedkeys-query < /etc/ssl/certs/ssl-cert-snakeoil.pem
|
47
|
+
|
48
|
+
The exit status indicates whether the key is in the pwnedkeys database or not:
|
49
|
+
|
50
|
+
* **`0`** -- the key is **not** known to be compromised.
|
51
|
+
|
52
|
+
* **`1`** -- the key is known to be compromised, and should not be used.
|
53
|
+
|
54
|
+
* **`2`** -- some sort of error occurred, and the key's status is undetermined.
|
55
|
+
An error message should have been printed on `stderr`.
|
56
|
+
|
57
|
+
|
58
|
+
## Generate a compromise attestation
|
59
|
+
|
60
|
+
If you have a key you'd like to submit to the pwnedkeys database, the best way
|
61
|
+
to do it is to e-mail the key itself to `submit@pwnedkeys.com`. However, if
|
62
|
+
for some reason you really, *really* don't want to do that, you can generate
|
63
|
+
your own compromise attestation and e-mail *that* (along with the public key,
|
64
|
+
so *we* can verify the attestation is legit) to `submit@pwnedkeys.com`.
|
65
|
+
|
66
|
+
To generate an attestation, run `pwnedkeys-prove-pwned`, passing in a
|
67
|
+
private key on `stdin`:
|
68
|
+
|
69
|
+
pwnedkeys-prove-pwned < /etc/ssl/private/ssl-cert-snakeoil.key
|
70
|
+
|
71
|
+
A JSON blob, containing the attestation and signature, will be output on
|
72
|
+
`stdout`.
|
73
|
+
|
74
|
+
|
75
|
+
# Contributing
|
76
|
+
|
77
|
+
Bug reports should be sent to the [GitHub issue
|
78
|
+
tracker](https://github.com/pwnedkeys/pwnedkeys-tools/issues). Patches can be
|
79
|
+
sent as a [GitHub pull
|
80
|
+
request](https://github.com/pwnedkeys/pwnedkeys-tools/pulls).
|
81
|
+
|
82
|
+
|
83
|
+
# Licence
|
84
|
+
|
85
|
+
Unless otherwise stated, everything in this repo is covered by the following
|
86
|
+
copyright notice:
|
87
|
+
|
88
|
+
Copyright (C) 2018 Matt Palmer <matt@hezmatt.org>
|
89
|
+
|
90
|
+
This program is free software: you can redistribute it and/or modify it
|
91
|
+
under the terms of the GNU General Public License version 3, as
|
92
|
+
published by the Free Software Foundation.
|
93
|
+
|
94
|
+
This program is distributed in the hope that it will be useful,
|
95
|
+
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
96
|
+
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
97
|
+
GNU General Public License for more details.
|
98
|
+
|
99
|
+
You should have received a copy of the GNU General Public License
|
100
|
+
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
101
|
+
|
102
|
+
In addition, as a special exception, the copyright holders give permission
|
103
|
+
to link the code of portions of this program with the OpenSSL library. You
|
104
|
+
must obey the GNU General Public License in all respects for all of the
|
105
|
+
code used other than OpenSSL. If you modify file(s) with this exception,
|
106
|
+
you may extend this exception to your version of the file(s), but you are
|
107
|
+
not obligated to do so. If you do not wish to do so, delete this exception
|
108
|
+
statement from your version. If you delete this exception statement from
|
109
|
+
all source files in the program, then also delete it here.
|
data/bin/pwnedkeys-query
ADDED
@@ -0,0 +1,57 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require "pwnedkeys/request"
|
4
|
+
require "openssl/x509/spki"
|
5
|
+
require "openssl/pkey"
|
6
|
+
|
7
|
+
def main(data)
|
8
|
+
spki = spki_from_arbitrary_data(data)
|
9
|
+
|
10
|
+
Pwnedkeys::Request.new(spki).pwned? ? 1 : 0
|
11
|
+
end
|
12
|
+
|
13
|
+
def spki_from_arbitrary_data(data)
|
14
|
+
begin
|
15
|
+
spki_from_key(data)
|
16
|
+
rescue OpenSSL::PKey::PKeyError
|
17
|
+
begin
|
18
|
+
spki_from_cert(data)
|
19
|
+
rescue OpenSSL::X509::CertificateError
|
20
|
+
begin
|
21
|
+
spki_from_csr(data)
|
22
|
+
rescue OpenSSL::X509::RequestError
|
23
|
+
begin
|
24
|
+
spki_from_ssh_key(data)
|
25
|
+
rescue OpenSSL::PKey::PKeyError
|
26
|
+
raise ArgumentError, "Unknown input format -- must be an unencrypted key, X.509 certificate, or X.509 CSR in PEM or DER format, or an OpenSSH public key"
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
def spki_from_key(data)
|
34
|
+
OpenSSL::PKey.read(data).to_spki
|
35
|
+
end
|
36
|
+
|
37
|
+
def spki_from_cert(data)
|
38
|
+
OpenSSL::X509::Certificate.new(data).to_spki
|
39
|
+
end
|
40
|
+
|
41
|
+
def spki_from_csr(data)
|
42
|
+
OpenSSL::X509::Request.new(data).to_spki
|
43
|
+
end
|
44
|
+
|
45
|
+
def spki_from_ssh_key(data)
|
46
|
+
OpenSSL::PKey.from_ssh_key(data).to_spki
|
47
|
+
end
|
48
|
+
|
49
|
+
begin
|
50
|
+
exit main($stdin.read)
|
51
|
+
rescue => ex
|
52
|
+
$stderr.puts "ERROR: #{ex.message} (#{ex.class})"
|
53
|
+
if ENV["PWNEDKEYS_DEBUG"] && !ENV["PWNEDKEYS_DEBUG"].empty?
|
54
|
+
$stderr.puts ex.backtrace.map { |l| " #{l}" }.join
|
55
|
+
exit 2
|
56
|
+
end
|
57
|
+
end
|
data/lib/openssl/pkey.rb
ADDED
@@ -0,0 +1,77 @@
|
|
1
|
+
require "openssl"
|
2
|
+
|
3
|
+
module OpenSSL
|
4
|
+
module PKey
|
5
|
+
SSH_CURVE_NAME_MAP = {
|
6
|
+
"nistp256" => "prime256v1",
|
7
|
+
"nistp384" => "secp384r1",
|
8
|
+
"nistp521" => "secp521r1",
|
9
|
+
}
|
10
|
+
|
11
|
+
def self.from_ssh_key(s)
|
12
|
+
if s =~ /\Assh-[a-z0-9-]+ /
|
13
|
+
# WHOOP WHOOP prefixed key detected.
|
14
|
+
s = s.split(" ")[1]
|
15
|
+
else
|
16
|
+
# Discard any comment, etc that might be lurking around
|
17
|
+
s = s.split(" ")[0]
|
18
|
+
end
|
19
|
+
|
20
|
+
unless s =~ /\A[A-Za-z0-9\/+]+={0,2}\z/
|
21
|
+
raise OpenSSL::PKey::PKeyError,
|
22
|
+
"Invalid key encoding (not valid base64)"
|
23
|
+
end
|
24
|
+
|
25
|
+
parts = ssh_key_lv_decode(s)
|
26
|
+
|
27
|
+
case parts.first
|
28
|
+
when "ssh-rsa"
|
29
|
+
OpenSSL::PKey::RSA.new.tap do |k|
|
30
|
+
k.e = ssh_key_mpi_decode(parts[1])
|
31
|
+
k.n = ssh_key_mpi_decode(parts[2])
|
32
|
+
end
|
33
|
+
when "ssh-dss"
|
34
|
+
OpenSSL::PKey::DSA.new.tap do |k|
|
35
|
+
k.p = ssh_key_mpi_decode(parts[1])
|
36
|
+
k.q = ssh_key_mpi_decode(parts[2])
|
37
|
+
k.g = ssh_key_mpi_decode(parts[3])
|
38
|
+
end
|
39
|
+
when /ecdsa-sha2-/
|
40
|
+
begin
|
41
|
+
OpenSSL::PKey::EC.new(SSH_CURVE_NAME_MAP[parts[1]]).tap do |k|
|
42
|
+
k.public_key = OpenSSL::PKey::EC::Point.new(k.group, parts[2])
|
43
|
+
end
|
44
|
+
rescue TypeError
|
45
|
+
raise OpenSSL::PKey::PKeyError.new,
|
46
|
+
"Unknown curve identifier #{parts[1]}"
|
47
|
+
end
|
48
|
+
else
|
49
|
+
raise OpenSSL::PKey::PKeyError,
|
50
|
+
"Unknown key type #{parts.first.inspect}"
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
private
|
55
|
+
|
56
|
+
def self.ssh_key_lv_decode(s)
|
57
|
+
rest = s.unpack("m").first
|
58
|
+
|
59
|
+
[].tap do |parts|
|
60
|
+
until rest == ""
|
61
|
+
len, rest = rest.unpack("Na*")
|
62
|
+
if len > rest.length
|
63
|
+
raise OpenSSL::PKey::PKeyError,
|
64
|
+
"Invalid LV-encoded string; wanted #{len} octets, but there's only #{rest.length} octets left"
|
65
|
+
end
|
66
|
+
|
67
|
+
elem, rest = rest.unpack("a#{len}a*")
|
68
|
+
parts << elem
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
def self.ssh_key_mpi_decode(s)
|
74
|
+
s.each_char.inject(0) { |i, c| i * 256 + c.ord }
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
@@ -0,0 +1,16 @@
|
|
1
|
+
require "openssl/x509/spki"
|
2
|
+
|
3
|
+
module OpenSSL
|
4
|
+
module PKey
|
5
|
+
class EC
|
6
|
+
# Generate an OpenSSL::X509::SPKI structure for this public key
|
7
|
+
def to_spki(format = :uncompressed)
|
8
|
+
unless self.public_key?
|
9
|
+
raise ECError,
|
10
|
+
"Cannot convert non-public-key to SPKI"
|
11
|
+
end
|
12
|
+
OpenSSL::X509::SPKI.new("id-ecPublicKey", OpenSSL::ASN1::ObjectId.new(self.public_key.group.curve_name), self.public_key.to_octet_string(format))
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
@@ -0,0 +1,12 @@
|
|
1
|
+
require "openssl/x509/spki"
|
2
|
+
|
3
|
+
module OpenSSL
|
4
|
+
module X509
|
5
|
+
class Certificate
|
6
|
+
# Generate an OpenSSL::X509::SPKI structure for the public key in the cert
|
7
|
+
def to_spki(_format = nil)
|
8
|
+
OpenSSL::X509::SPKI.new(self.public_key.to_der)
|
9
|
+
end
|
10
|
+
end
|
11
|
+
end
|
12
|
+
end
|
@@ -0,0 +1,12 @@
|
|
1
|
+
require "openssl/x509/spki"
|
2
|
+
|
3
|
+
module OpenSSL
|
4
|
+
module X509
|
5
|
+
class Request
|
6
|
+
# Generate an OpenSSL::X509::SPKI structure for the public key in the CSR
|
7
|
+
def to_spki(_format = nil)
|
8
|
+
OpenSSL::X509::SPKI.new(self.public_key.to_der)
|
9
|
+
end
|
10
|
+
end
|
11
|
+
end
|
12
|
+
end
|
@@ -0,0 +1,177 @@
|
|
1
|
+
require "openssl"
|
2
|
+
|
3
|
+
module OpenSSL
|
4
|
+
module X509
|
5
|
+
# Error raised when something goes awry in an SPKI object.
|
6
|
+
#
|
7
|
+
class SPKIError < OpenSSL::OpenSSLError; end
|
8
|
+
|
9
|
+
# `subjectPublicKeyInfo` for everyone.
|
10
|
+
#
|
11
|
+
# A standardised representation of the `SubjectPublicKeyInfo` X.509
|
12
|
+
# structure, along with helper methods to construct, deconstruct,
|
13
|
+
# and derive useful results from such a structure.
|
14
|
+
#
|
15
|
+
class SPKI
|
16
|
+
# Create a new SPKI object.
|
17
|
+
#
|
18
|
+
# This method can be called in one of a few different ways:
|
19
|
+
#
|
20
|
+
# * `SPKI.new(String)` -- the provided string is interpreted as an ASN.1
|
21
|
+
# DER data stream representing a `SubjectPublicKeyInfo` structure. If
|
22
|
+
#
|
23
|
+
#
|
24
|
+
# * `SPKI.new(OpenSSL::ASN::Sequence) -- an already-decoded
|
25
|
+
# `SubjectPublicKeyInfo` structure, ready for inspection and manipulation.
|
26
|
+
#
|
27
|
+
# * `SPKI.new(String, Object, String) -- create a new SPKI from its
|
28
|
+
# component parts. The first `String` is the OID of the
|
29
|
+
# `algorithm.algorithm` field, while the second string is the content of
|
30
|
+
# the `subjectPublicKey` field. These will be converted into their ASN.1
|
31
|
+
# equivalents (ObjectID and BitString, respectively). The second
|
32
|
+
# argument, the `Object`, is an arbitrary ASN.1 object representing
|
33
|
+
# whatever should go in the `algorithm.parameters` field. If this
|
34
|
+
# field should be **absent**, this argument should be set to `nil`.
|
35
|
+
#
|
36
|
+
# * `SPKI.new(OpenSSL::ASN1::ObjectId, Object, OpenSSL::ASN1::BitString)` --
|
37
|
+
# this is equivalent to the above three-argument form, but the arguments
|
38
|
+
# are already in their ASN.1 object form, and won't be converted. The
|
39
|
+
# `Object` argument has the same semantics as above.
|
40
|
+
#
|
41
|
+
# @raise [OpenSSL::X509::SPKIError] if the parameters passed don't meet
|
42
|
+
# validation requirements. The exception message will provide more
|
43
|
+
# details as to what was unacceptable.
|
44
|
+
#
|
45
|
+
def initialize(*args)
|
46
|
+
@spki = if args.length == 1
|
47
|
+
if args.first.is_a?(String)
|
48
|
+
OpenSSL::ASN1.decode(args.first)
|
49
|
+
elsif args.first.is_a?(OpenSSL::ASN1::Sequence)
|
50
|
+
args.first
|
51
|
+
else
|
52
|
+
raise SPKIError,
|
53
|
+
"Must pass String or OpenSSL::ASN1::Sequence (you gave me an instance of #{args.first.class})"
|
54
|
+
end
|
55
|
+
elsif args.length == 3
|
56
|
+
alg_id, params, key_data = args
|
57
|
+
alg_id = alg_id.is_a?(String) ? OpenSSL::ASN1::ObjectId.new(alg_id) : alg_id
|
58
|
+
key_data = key_data.is_a?(String) ? OpenSSL::ASN1::BitString.new(key_data) : key_data
|
59
|
+
|
60
|
+
alg_info = [alg_id, params].compact
|
61
|
+
|
62
|
+
OpenSSL::ASN1::Sequence.new([
|
63
|
+
OpenSSL::ASN1::Sequence.new(alg_info),
|
64
|
+
key_data
|
65
|
+
])
|
66
|
+
else
|
67
|
+
raise SPKIError,
|
68
|
+
"SPKI.new takes either one or three arguments only"
|
69
|
+
end
|
70
|
+
|
71
|
+
validate_spki
|
72
|
+
end
|
73
|
+
|
74
|
+
# Return the DER-encoded SPKI structure.
|
75
|
+
#
|
76
|
+
# @return [String]
|
77
|
+
#
|
78
|
+
def to_der
|
79
|
+
@spki.to_der
|
80
|
+
end
|
81
|
+
|
82
|
+
# Return an OpenSSL key.
|
83
|
+
#
|
84
|
+
# @return [OpenSSL::PKey::PKey]
|
85
|
+
#
|
86
|
+
def to_key
|
87
|
+
OpenSSL::PKey.read(self.to_der)
|
88
|
+
end
|
89
|
+
|
90
|
+
# Return a digest object for the *public key* data.
|
91
|
+
#
|
92
|
+
# Some specifications (such as RFC5280's subjectKeyId) want a fingerprint
|
93
|
+
# of only the key data, rather than a fingerprint of the entire SPKI
|
94
|
+
# structure. If so, this is the method for you.
|
95
|
+
#
|
96
|
+
# Because different things want their fingerprints in different formats,
|
97
|
+
# this method returns a *digest object*, rather than a string, on which
|
98
|
+
# you can call whatever output format method you like (`#digest`, `#hexdigest`,
|
99
|
+
# or `#base64digest`, as appropriate).
|
100
|
+
#
|
101
|
+
# @param type [OpenSSL::Digest] override the default hash function used
|
102
|
+
# to calculate the digest. The default, SHA1, is in line with the most
|
103
|
+
# common use of the key fingerprint, which is RFC5280 subjectKeyId
|
104
|
+
# calculation, however if you wish to use a different hash function
|
105
|
+
# you can pass an alternate digest class to use.
|
106
|
+
#
|
107
|
+
# @return [OpenSSL::Digest]
|
108
|
+
#
|
109
|
+
def key_fingerprint(type = OpenSSL::Digest::SHA1)
|
110
|
+
type.new(@spki.value.last.value)
|
111
|
+
end
|
112
|
+
|
113
|
+
# Return a digest object for the entire DER-encoded SPKI structure.
|
114
|
+
#
|
115
|
+
# Some specifications (such as RFC7469 public key pins, and pwnedkeys.com
|
116
|
+
# key IDs) require a hash of the entire DER-encoded SPKI structure.
|
117
|
+
# If that's what you want, you're in the right place.
|
118
|
+
#
|
119
|
+
# Because different things want their fingerprints in different formats,
|
120
|
+
# this method returns a *digest object*, rather than a string, on which
|
121
|
+
# you can call whatever output format method you like (`#digest`, `#hexdigest`,
|
122
|
+
# or `#base64digest`, as appropriate).
|
123
|
+
#
|
124
|
+
# @param type [OpenSSL::Digest] override the default hash function used
|
125
|
+
# to calculate the digest. The default, SHA256, is in line with the most
|
126
|
+
# common uses of the SPKI fingerprint, however if you wish to use a
|
127
|
+
# different hash function you can pass an alternate digest class to use.
|
128
|
+
#
|
129
|
+
# @return [OpenSSL::Digest]
|
130
|
+
#
|
131
|
+
def spki_fingerprint(type = OpenSSL::Digest::SHA256)
|
132
|
+
type.new(@spki.to_der)
|
133
|
+
end
|
134
|
+
|
135
|
+
private
|
136
|
+
|
137
|
+
def validate_spki
|
138
|
+
unless @spki.is_a?(OpenSSL::ASN1::Sequence)
|
139
|
+
raise SPKIError,
|
140
|
+
"SPKI data is not an ASN1 sequence (got a #{@spki.class})"
|
141
|
+
end
|
142
|
+
|
143
|
+
if @spki.value.length != 2
|
144
|
+
raise SPKIError,
|
145
|
+
"SPKI top-level sequence must have two elements (length is #{@spki.value.length})"
|
146
|
+
end
|
147
|
+
|
148
|
+
alg_id, key_data = @spki.value
|
149
|
+
|
150
|
+
unless alg_id.is_a?(OpenSSL::ASN1::Sequence)
|
151
|
+
raise SPKIError,
|
152
|
+
"SPKI algorithm_identifier must be a sequence (got a #{alg_id.class})"
|
153
|
+
end
|
154
|
+
|
155
|
+
unless (1..2) === alg_id.value.length
|
156
|
+
raise SPKIError,
|
157
|
+
"SPKI algorithm sequence must have one or two elements (got #{alg_id.value.length} elements)"
|
158
|
+
end
|
159
|
+
|
160
|
+
unless alg_id.value.first.is_a?(OpenSSL::ASN1::ObjectId)
|
161
|
+
raise SPKIError,
|
162
|
+
"SPKI algorithm identifier does not contain an object ID (got #{alg_id.value.first.class})"
|
163
|
+
end
|
164
|
+
|
165
|
+
unless key_data.is_a?(OpenSSL::ASN1::BitString)
|
166
|
+
raise SPKIError,
|
167
|
+
"SPKI publicKeyInfo field must be a BitString (got a #{@spki.value.last.class})"
|
168
|
+
end
|
169
|
+
end
|
170
|
+
end
|
171
|
+
end
|
172
|
+
end
|
173
|
+
|
174
|
+
require_relative "../pkey/rsa"
|
175
|
+
require_relative "../pkey/ec"
|
176
|
+
require_relative "./request"
|
177
|
+
require_relative "./certificate"
|