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