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