pwnedkeys-tools 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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.
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "pwnedkeys/response"
4
+
5
+ puts Pwnedkeys::Response.new($stdin.read).to_json
@@ -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
@@ -0,0 +1,7 @@
1
+ #!/bin/sh
2
+
3
+ set -e
4
+
5
+ : ${PWNEDKEYS_TOOLS_DOCKER_IMAGE:=pwnedkeys/tools:latest}
6
+
7
+ docker run -i --rm "$PWNEDKEYS_TOOLS_DOCKER_IMAGE" pwnedkeys-prove-pwned "$@"
@@ -0,0 +1,7 @@
1
+ #!/bin/sh
2
+
3
+ set -e
4
+
5
+ : ${PWNEDKEYS_TOOLS_DOCKER_IMAGE:=pwnedkeys/tools:latest}
6
+
7
+ docker run -i --rm "$PWNEDKEYS_TOOLS_DOCKER_IMAGE" pwnedkeys-query "$@"
@@ -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 PKey
5
+ class RSA
6
+ # Generate an OpenSSL::X509::SPKI structure for this public key
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 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"