openssl-additions 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,90 @@
1
+ This is a collection of miscellaneous quality-of-life helpers to Ruby's core
2
+ OpenSSL module. They're intended to make working with OpenSSL a little less
3
+ frustrating.
4
+
5
+
6
+ # Installation
7
+
8
+ Due to recent changes in the `openssl` standard library, this gem requires
9
+ Ruby 2.5 or later with the `openssl` extension. Assuming you've got that
10
+ available, you can install as a gem:
11
+
12
+ gem install openssl-additions
13
+
14
+ If you're the sturdy type that likes to run from git:
15
+
16
+ rake install
17
+
18
+ Or, if you've eschewed the convenience of Rubygems entirely, then you
19
+ presumably know what to do already.
20
+
21
+
22
+ # Usage
23
+
24
+ All classes are fully documented with YARD comments, so [the
25
+ online docs](https://rubydoc.info/gems/openssl-additions) are actually useful.
26
+ A brief summary of features, though, appears below.
27
+
28
+
29
+ ## Consistent SPKIs
30
+
31
+ Not all OpenSSL key types provide a consistent `SubjectPublicKeyInfo` data
32
+ structure to work with, so I added one, along with helpers on the existing
33
+ SPKI-related classes to extract one.
34
+
35
+ require "openssl/x509/spki"
36
+
37
+ key = OpenSSL::PKey::EC.new("prime256v1").generate_key
38
+ spki = key.to_spki
39
+ spki.to_der # => bundle of gibberish
40
+ spki.spki_fingerprint.hexdigest # => lots of hex characters
41
+
42
+ cert = OpenSSL::X509::Certificate.new(File.read("/tmp/cert.pem"))
43
+ spki = cert.to_spki
44
+ # ... and so on
45
+
46
+ ## Parsing SSH public keys into PKeys
47
+
48
+ Ever needed an SSH public key in an OpenSSL-compatible object? Neither did I
49
+ until recently, but once I did, I wrote this.
50
+
51
+ require "openssl/pkey"
52
+
53
+ key = OpenSSL::PKey.from_ssh_key(File.read("~/.ssh/id_rsa.pub"))
54
+ key.class # => OpenSSL::PKey::RSA
55
+ key.public? # => true
56
+ key.private? # => false
57
+
58
+
59
+ # Contributing
60
+
61
+ See `CONTRIBUTING.md`.
62
+
63
+
64
+ # Licence
65
+
66
+ Unless otherwise stated, everything in this repo is covered by the following
67
+ copyright notice:
68
+
69
+ Copyright (C) 2018 Matt Palmer <matt@hezmatt.org>
70
+
71
+ This program is free software: you can redistribute it and/or modify it
72
+ under the terms of the GNU General Public License version 3, as
73
+ published by the Free Software Foundation.
74
+
75
+ This program is distributed in the hope that it will be useful,
76
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
77
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
78
+ GNU General Public License for more details.
79
+
80
+ You should have received a copy of the GNU General Public License
81
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
82
+
83
+ In addition, as a special exception, the copyright holders give permission
84
+ to link the code of portions of this program with the OpenSSL library. You
85
+ must obey the GNU General Public License in all respects for all of the
86
+ code used other than OpenSSL. If you modify file(s) with this exception,
87
+ you may extend this exception to your version of the file(s), but you are
88
+ not obligated to do so. If you do not wish to do so, delete this exception
89
+ statement from your version. If you delete this exception statement from
90
+ all source files in the program, then also delete it here.
@@ -0,0 +1,100 @@
1
+ require "openssl"
2
+
3
+ # Enhancements to the core asymmetric key handling.
4
+ module OpenSSL::PKey
5
+ # A mapping of the "SSH" names for various curves, to their OpenSSL
6
+ # equivalent names.
7
+ SSH_CURVE_NAME_MAP = {
8
+ "nistp256" => "prime256v1",
9
+ "nistp384" => "secp384r1",
10
+ "nistp521" => "secp521r1",
11
+ }
12
+
13
+ # Create a new `OpenSSL::PKey` from an SSH public key.
14
+ #
15
+ # Given an OpenSSL 2 public key (with or without the `ssh-rsa` / `ecdsa-etc`
16
+ # prefix), create an equivalent instance of an `OpenSSL::PKey::PKey` subclass
17
+ # which represents the same key parameters.
18
+ #
19
+ # If you've got an SSH *private* key, you don't need this method, as they're
20
+ # already PKCS#8 ("PEM") private keys, which OpenSSL is happy to read
21
+ # directly (using `OpenSSL::PKey.read`).
22
+ #
23
+ # @param s [String] the SSH public key to convert, in its usual
24
+ # base64-encoded form, with or without key type prefix.
25
+ #
26
+ # @return [OpenSSL::PKey::PKey] the OpenSSL-compatible key object. Note
27
+ # that this can only ever be a *public* key, never a private key, because
28
+ # SSH public keys are, well, public.
29
+ #
30
+ def self.from_ssh_key(s)
31
+ if s =~ /\Assh-[a-z0-9-]+ /
32
+ # WHOOP WHOOP prefixed key detected.
33
+ s = s.split(" ")[1]
34
+ else
35
+ # Discard any comment, etc that might be lurking around
36
+ s = s.split(" ")[0]
37
+ end
38
+
39
+ unless s =~ /\A[A-Za-z0-9\/+]+={0,2}\z/
40
+ raise OpenSSL::PKey::PKeyError,
41
+ "Invalid key encoding (not valid base64)"
42
+ end
43
+
44
+ parts = ssh_key_lv_decode(s)
45
+
46
+ case parts.first
47
+ when "ssh-rsa"
48
+ OpenSSL::PKey::RSA.new.tap do |k|
49
+ k.e = ssh_key_mpi_decode(parts[1])
50
+ k.n = ssh_key_mpi_decode(parts[2])
51
+ end
52
+ when "ssh-dss"
53
+ OpenSSL::PKey::DSA.new.tap do |k|
54
+ k.p = ssh_key_mpi_decode(parts[1])
55
+ k.q = ssh_key_mpi_decode(parts[2])
56
+ k.g = ssh_key_mpi_decode(parts[3])
57
+ end
58
+ when /ecdsa-sha2-/
59
+ begin
60
+ OpenSSL::PKey::EC.new(SSH_CURVE_NAME_MAP[parts[1]]).tap do |k|
61
+ k.public_key = OpenSSL::PKey::EC::Point.new(k.group, parts[2])
62
+ end
63
+ rescue TypeError
64
+ raise OpenSSL::PKey::PKeyError.new,
65
+ "Unknown curve identifier #{parts[1]}"
66
+ end
67
+ else
68
+ raise OpenSSL::PKey::PKeyError,
69
+ "Unknown key type #{parts.first.inspect}"
70
+ end
71
+ end
72
+
73
+ private
74
+
75
+ # Take the base64 string and split it into its component parts.
76
+ #
77
+ def self.ssh_key_lv_decode(s)
78
+ rest = s.unpack("m").first
79
+
80
+ [].tap do |parts|
81
+ until rest == ""
82
+ len, rest = rest.unpack("Na*")
83
+ if len > rest.length
84
+ raise OpenSSL::PKey::PKeyError,
85
+ "Invalid LV-encoded string; wanted #{len} octets, but there's only #{rest.length} octets left"
86
+ end
87
+
88
+ elem, rest = rest.unpack("a#{len}a*")
89
+ parts << elem
90
+ end
91
+ end
92
+ end
93
+
94
+ # Turn an SSH "MPI" (encoded arbitrary-length integer) string into a real
95
+ # Ruby integer.
96
+ #
97
+ def self.ssh_key_mpi_decode(s)
98
+ s.each_char.inject(0) { |i, c| i * 256 + c.ord }
99
+ end
100
+ end
@@ -0,0 +1,25 @@
1
+ require "openssl"
2
+
3
+ require_relative "../x509/spki"
4
+
5
+ # Additional helpers for ECDSA keys.
6
+ #
7
+ class OpenSSL::PKey::EC
8
+ # Generate an OpenSSL::X509::SPKI structure for this public key.
9
+ #
10
+ # @param format [Symbol] whether to return the SPKI containing the compressed
11
+ # or uncompressed form of the curve point which represents the public key.
12
+ # Note that from a functional perspective, the two forms are identical, but
13
+ # they will produce completely different key and SPKI fingerprints, which
14
+ # may be important.
15
+ #
16
+ # @return [OpenSSL::X509::SPKI]
17
+ #
18
+ def to_spki(format = :uncompressed)
19
+ unless self.public_key?
20
+ raise OpenSSL::PKey::ECError,
21
+ "Cannot convert non-public-key to SPKI"
22
+ end
23
+ OpenSSL::X509::SPKI.new("id-ecPublicKey", OpenSSL::ASN1::ObjectId.new(self.public_key.group.curve_name), self.public_key.to_octet_string(format))
24
+ end
25
+ end
@@ -0,0 +1,17 @@
1
+ require "openssl"
2
+
3
+ require_relative "../x509/spki"
4
+
5
+ # Additional helper methods for RSA keys.
6
+ #
7
+ class OpenSSL::PKey::RSA
8
+ # Generate an OpenSSL::X509::SPKI structure for this public key.
9
+ #
10
+ # @param _format [NilClass] unused by this class.
11
+ #
12
+ # @return [OpenSSL::X509::SPKI]
13
+ #
14
+ def to_spki(_format = nil)
15
+ OpenSSL::X509::SPKI.new(self.public_key.to_der)
16
+ end
17
+ end
@@ -0,0 +1,17 @@
1
+ require "openssl"
2
+
3
+ require_relative "./spki"
4
+
5
+ # Additional helper methods for certificates.
6
+ #
7
+ class OpenSSL::X509::Certificate
8
+ # Generate an OpenSSL::X509::SPKI structure for the public key in the cert.
9
+ #
10
+ # @param _format [NilClass] Unused.
11
+ #
12
+ # @return [OpenSSL::X509::SPKI]
13
+ #
14
+ def to_spki(_format = nil)
15
+ OpenSSL::X509::SPKI.new(self.public_key.to_der)
16
+ end
17
+ end
@@ -0,0 +1,17 @@
1
+ require "openssl"
2
+
3
+ require_relative "./spki"
4
+
5
+ # Additional helper methods for CSRs.
6
+ #
7
+ class OpenSSL::X509::Request
8
+ # Generate an OpenSSL::X509::SPKI structure for the public key in the CSR.
9
+ #
10
+ # @param _format [NilClass] Unused.
11
+ #
12
+ # @return [OpenSSL::X509::SPKI]
13
+ #
14
+ def to_spki(_format = nil)
15
+ OpenSSL::X509::SPKI.new(self.public_key.to_der)
16
+ end
17
+ end
@@ -0,0 +1,179 @@
1
+ require "openssl"
2
+
3
+ # Additional classes for X509 data structures.
4
+ #
5
+ module OpenSSL::X509
6
+ # Error raised when something goes awry in an SPKI object.
7
+ #
8
+ class SPKIError < OpenSSL::OpenSSLError; end
9
+
10
+ # `subjectPublicKeyInfo` for everyone.
11
+ #
12
+ # A standardised representation of the `SubjectPublicKeyInfo` X.509
13
+ # structure, along with helper methods to construct, deconstruct,
14
+ # and derive useful results from such a structure.
15
+ #
16
+ class SPKI
17
+ # Create a new SPKI object.
18
+ #
19
+ # This method can be called in one of a few different ways:
20
+ #
21
+ # * `SPKI.new(String)` -- the provided string is interpreted as an ASN.1
22
+ # DER data stream representing a `SubjectPublicKeyInfo` structure. If
23
+ #
24
+ #
25
+ # * `SPKI.new(OpenSSL::ASN::Sequence) -- an already-decoded
26
+ # `SubjectPublicKeyInfo` structure, ready for inspection and manipulation.
27
+ #
28
+ # * `SPKI.new(String, Object, String) -- create a new SPKI from its
29
+ # component parts. The first `String` is the OID of the
30
+ # `algorithm.algorithm` field, while the second string is the content of
31
+ # the `subjectPublicKey` field. These will be converted into their ASN.1
32
+ # equivalents (ObjectID and BitString, respectively). The second
33
+ # argument, the `Object`, is an arbitrary ASN.1 object representing
34
+ # whatever should go in the `algorithm.parameters` field. If this
35
+ # field should be **absent**, this argument should be set to `nil`.
36
+ #
37
+ # * `SPKI.new(OpenSSL::ASN1::ObjectId, Object, OpenSSL::ASN1::BitString)` --
38
+ # this is equivalent to the above three-argument form, but the arguments
39
+ # are already in their ASN.1 object form, and won't be converted. The
40
+ # `Object` argument has the same semantics as above.
41
+ #
42
+ # @raise [OpenSSL::X509::SPKIError] if the parameters passed don't meet
43
+ # validation requirements. The exception message will provide more
44
+ # details as to what was unacceptable.
45
+ #
46
+ def initialize(*args)
47
+ @spki = if args.length == 1
48
+ if args.first.is_a?(String)
49
+ OpenSSL::ASN1.decode(args.first)
50
+ elsif args.first.is_a?(OpenSSL::ASN1::Sequence)
51
+ args.first
52
+ else
53
+ raise SPKIError,
54
+ "Must pass String or OpenSSL::ASN1::Sequence (you gave me an instance of #{args.first.class})"
55
+ end
56
+ elsif args.length == 3
57
+ alg_id, params, key_data = args
58
+ alg_id = alg_id.is_a?(String) ? OpenSSL::ASN1::ObjectId.new(alg_id) : alg_id
59
+ key_data = key_data.is_a?(String) ? OpenSSL::ASN1::BitString.new(key_data) : key_data
60
+
61
+ alg_info = [alg_id, params].compact
62
+
63
+ OpenSSL::ASN1::Sequence.new([
64
+ OpenSSL::ASN1::Sequence.new(alg_info),
65
+ key_data
66
+ ])
67
+ else
68
+ raise SPKIError,
69
+ "SPKI.new takes either one or three arguments only"
70
+ end
71
+
72
+ validate_spki
73
+ end
74
+
75
+ # Return the DER-encoded SPKI structure.
76
+ #
77
+ # @return [String]
78
+ #
79
+ def to_der
80
+ @spki.to_der
81
+ end
82
+
83
+ # Return an OpenSSL key.
84
+ #
85
+ # @return [OpenSSL::PKey::PKey]
86
+ #
87
+ def to_key
88
+ OpenSSL::PKey.read(self.to_der)
89
+ end
90
+
91
+ # Return a digest object for the *public key* data.
92
+ #
93
+ # Some specifications (such as RFC5280's subjectKeyId) want a fingerprint
94
+ # of only the key data, rather than a fingerprint of the entire SPKI
95
+ # structure. If so, this is the method for you.
96
+ #
97
+ # Because different things want their fingerprints in different formats,
98
+ # this method returns a *digest object*, rather than a string, on which
99
+ # you can call whatever output format method you like (`#digest`, `#hexdigest`,
100
+ # or `#base64digest`, as appropriate).
101
+ #
102
+ # @param type [OpenSSL::Digest] override the default hash function used
103
+ # to calculate the digest. The default, SHA1, is in line with the most
104
+ # common use of the key fingerprint, which is RFC5280 subjectKeyId
105
+ # calculation, however if you wish to use a different hash function
106
+ # you can pass an alternate digest class to use.
107
+ #
108
+ # @return [OpenSSL::Digest]
109
+ #
110
+ def key_fingerprint(type = OpenSSL::Digest::SHA1)
111
+ type.new(@spki.value.last.value)
112
+ end
113
+
114
+ # Return a digest object for the entire DER-encoded SPKI structure.
115
+ #
116
+ # Some specifications (such as RFC7469 public key pins, and pwnedkeys.com
117
+ # key IDs) require a hash of the entire DER-encoded SPKI structure.
118
+ # If that's what you want, you're in the right place.
119
+ #
120
+ # Because different things want their fingerprints in different formats,
121
+ # this method returns a *digest object*, rather than a string, on which
122
+ # you can call whatever output format method you like (`#digest`, `#hexdigest`,
123
+ # or `#base64digest`, as appropriate).
124
+ #
125
+ # @param type [OpenSSL::Digest] override the default hash function used
126
+ # to calculate the digest. The default, SHA256, is in line with the most
127
+ # common uses of the SPKI fingerprint, however if you wish to use a
128
+ # different hash function you can pass an alternate digest class to use.
129
+ #
130
+ # @return [OpenSSL::Digest]
131
+ #
132
+ def spki_fingerprint(type = OpenSSL::Digest::SHA256)
133
+ type.new(@spki.to_der)
134
+ end
135
+
136
+ private
137
+
138
+ # Make sure that the SPKI data we were passed is legit.
139
+ #
140
+ def validate_spki
141
+ unless @spki.is_a?(OpenSSL::ASN1::Sequence)
142
+ raise SPKIError,
143
+ "SPKI data is not an ASN1 sequence (got a #{@spki.class})"
144
+ end
145
+
146
+ if @spki.value.length != 2
147
+ raise SPKIError,
148
+ "SPKI top-level sequence must have two elements (length is #{@spki.value.length})"
149
+ end
150
+
151
+ alg_id, key_data = @spki.value
152
+
153
+ unless alg_id.is_a?(OpenSSL::ASN1::Sequence)
154
+ raise SPKIError,
155
+ "SPKI algorithm_identifier must be a sequence (got a #{alg_id.class})"
156
+ end
157
+
158
+ unless (1..2) === alg_id.value.length
159
+ raise SPKIError,
160
+ "SPKI algorithm sequence must have one or two elements (got #{alg_id.value.length} elements)"
161
+ end
162
+
163
+ unless alg_id.value.first.is_a?(OpenSSL::ASN1::ObjectId)
164
+ raise SPKIError,
165
+ "SPKI algorithm identifier does not contain an object ID (got #{alg_id.value.first.class})"
166
+ end
167
+
168
+ unless key_data.is_a?(OpenSSL::ASN1::BitString)
169
+ raise SPKIError,
170
+ "SPKI publicKeyInfo field must be a BitString (got a #{@spki.value.last.class})"
171
+ end
172
+ end
173
+ end
174
+ end
175
+
176
+ require_relative "../pkey/rsa"
177
+ require_relative "../pkey/ec"
178
+ require_relative "./request"
179
+ require_relative "./certificate"