openssl-additions 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/CODE_OF_CONDUCT.md +49 -0
- data/CONTRIBUTING.md +14 -0
- data/LICENCE +674 -0
- data/README.md +90 -0
- data/lib/openssl/pkey.rb +100 -0
- data/lib/openssl/pkey/ec.rb +25 -0
- data/lib/openssl/pkey/rsa.rb +17 -0
- data/lib/openssl/x509/certificate.rb +17 -0
- data/lib/openssl/x509/request.rb +17 -0
- data/lib/openssl/x509/spki.rb +179 -0
- data/openssl-additions.gemspec +40 -0
- metadata +197 -0
data/README.md
ADDED
@@ -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.
|
data/lib/openssl/pkey.rb
ADDED
@@ -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"
|