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