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