ssh_data 1.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/LICENSE.md +21 -0
- data/lib/ssh_data.rb +36 -0
- data/lib/ssh_data/certificate.rb +240 -0
- data/lib/ssh_data/encoding.rb +666 -0
- data/lib/ssh_data/error.rb +7 -0
- data/lib/ssh_data/private_key.rb +73 -0
- data/lib/ssh_data/private_key/base.rb +39 -0
- data/lib/ssh_data/private_key/dsa.rb +75 -0
- data/lib/ssh_data/private_key/ecdsa.rb +95 -0
- data/lib/ssh_data/private_key/ed25519.rb +68 -0
- data/lib/ssh_data/private_key/rsa.rb +106 -0
- data/lib/ssh_data/public_key.rb +78 -0
- data/lib/ssh_data/public_key/base.rb +71 -0
- data/lib/ssh_data/public_key/dsa.rb +122 -0
- data/lib/ssh_data/public_key/ecdsa.rb +151 -0
- data/lib/ssh_data/public_key/ed25519.rb +74 -0
- data/lib/ssh_data/public_key/rsa.rb +79 -0
- data/lib/ssh_data/version.rb +3 -0
- metadata +116 -0
@@ -0,0 +1,78 @@
|
|
1
|
+
module SSHData
|
2
|
+
module PublicKey
|
3
|
+
# Public key algorithm identifiers
|
4
|
+
ALGO_RSA = "ssh-rsa"
|
5
|
+
ALGO_DSA = "ssh-dss"
|
6
|
+
ALGO_ECDSA256 = "ecdsa-sha2-nistp256"
|
7
|
+
ALGO_ECDSA384 = "ecdsa-sha2-nistp384"
|
8
|
+
ALGO_ECDSA521 = "ecdsa-sha2-nistp521"
|
9
|
+
ALGO_ED25519 = "ssh-ed25519"
|
10
|
+
|
11
|
+
# RSA SHA2 *signature* algorithms used with ALGO_RSA keys.
|
12
|
+
# https://tools.ietf.org/html/draft-rsa-dsa-sha2-256-02
|
13
|
+
ALGO_RSA_SHA2_256 = "rsa-sha2-256"
|
14
|
+
ALGO_RSA_SHA2_512 = "rsa-sha2-512"
|
15
|
+
|
16
|
+
ALGOS = [
|
17
|
+
ALGO_RSA, ALGO_DSA, ALGO_ECDSA256, ALGO_ECDSA384, ALGO_ECDSA521,
|
18
|
+
ALGO_ED25519
|
19
|
+
]
|
20
|
+
|
21
|
+
# Parse an OpenSSH public key in authorized_keys format (see sshd(8) manual
|
22
|
+
# page).
|
23
|
+
#
|
24
|
+
# key - An OpenSSH formatted public key, including algo, base64 encoded key
|
25
|
+
# and optional comment.
|
26
|
+
#
|
27
|
+
# Returns a PublicKey::Base subclass instance.
|
28
|
+
def self.parse_openssh(key)
|
29
|
+
algo, raw, _ = SSHData.key_parts(key)
|
30
|
+
parsed = parse_rfc4253(raw)
|
31
|
+
|
32
|
+
if parsed.algo != algo
|
33
|
+
raise DecodeError, "algo mismatch: #{parsed.algo.inspect}!=#{algo.inspect}"
|
34
|
+
end
|
35
|
+
|
36
|
+
parsed
|
37
|
+
end
|
38
|
+
|
39
|
+
# Deprecated
|
40
|
+
singleton_class.send(:alias_method, :parse, :parse_openssh)
|
41
|
+
|
42
|
+
# Parse an RFC 4253 binary SSH public key.
|
43
|
+
#
|
44
|
+
# key - A RFC 4253 binary public key String.
|
45
|
+
#
|
46
|
+
# Returns a PublicKey::Base subclass instance.
|
47
|
+
def self.parse_rfc4253(raw)
|
48
|
+
data, read = Encoding.decode_public_key(raw)
|
49
|
+
|
50
|
+
if read != raw.bytesize
|
51
|
+
raise DecodeError, "unexpected trailing data"
|
52
|
+
end
|
53
|
+
|
54
|
+
from_data(data)
|
55
|
+
end
|
56
|
+
|
57
|
+
def self.from_data(data)
|
58
|
+
case data[:algo]
|
59
|
+
when ALGO_RSA
|
60
|
+
RSA.new(**data)
|
61
|
+
when ALGO_DSA
|
62
|
+
DSA.new(**data)
|
63
|
+
when ALGO_ECDSA256, ALGO_ECDSA384, ALGO_ECDSA521
|
64
|
+
ECDSA.new(**data)
|
65
|
+
when ALGO_ED25519
|
66
|
+
ED25519.new(**data)
|
67
|
+
else
|
68
|
+
raise DecodeError, "unkown algo: #{data[:algo].inspect}"
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
require "ssh_data/public_key/base"
|
75
|
+
require "ssh_data/public_key/rsa"
|
76
|
+
require "ssh_data/public_key/dsa"
|
77
|
+
require "ssh_data/public_key/ecdsa"
|
78
|
+
require "ssh_data/public_key/ed25519"
|
@@ -0,0 +1,71 @@
|
|
1
|
+
module SSHData
|
2
|
+
module PublicKey
|
3
|
+
class Base
|
4
|
+
attr_reader :algo
|
5
|
+
|
6
|
+
def initialize(**kwargs)
|
7
|
+
@algo = kwargs[:algo]
|
8
|
+
end
|
9
|
+
|
10
|
+
# Calculate the fingerprint of this public key.
|
11
|
+
#
|
12
|
+
# md5: - Bool of whether to generate an MD5 fingerprint instead of the
|
13
|
+
# default SHA256.
|
14
|
+
#
|
15
|
+
# Returns a String fingerprint.
|
16
|
+
def fingerprint(md5: false)
|
17
|
+
if md5
|
18
|
+
# colon separated, hex encoded md5 digest
|
19
|
+
OpenSSL::Digest::MD5.digest(rfc4253).unpack("H2" * 16).join(":")
|
20
|
+
else
|
21
|
+
# base64 encoded sha256 digest with b64 padding stripped
|
22
|
+
Base64.strict_encode64(OpenSSL::Digest::SHA256.digest(rfc4253))[0...-1]
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
# Make an SSH signature.
|
27
|
+
#
|
28
|
+
# signed_data - The String message over which to calculated the signature.
|
29
|
+
#
|
30
|
+
# Returns a binary String signature.
|
31
|
+
def sign(signed_data)
|
32
|
+
raise "implement me"
|
33
|
+
end
|
34
|
+
|
35
|
+
# Verify an SSH signature.
|
36
|
+
#
|
37
|
+
# signed_data - The String message that the signature was calculated over.
|
38
|
+
# signature - The binary String signature with SSH encoding.
|
39
|
+
#
|
40
|
+
# Returns boolean.
|
41
|
+
def verify(signed_data, signature)
|
42
|
+
raise "implement me"
|
43
|
+
end
|
44
|
+
|
45
|
+
# RFC4253 binary encoding of the public key.
|
46
|
+
#
|
47
|
+
# Returns a binary String.
|
48
|
+
def rfc4253
|
49
|
+
raise "implement me"
|
50
|
+
end
|
51
|
+
|
52
|
+
# OpenSSH public key in authorized_keys format (see sshd(8) manual page).
|
53
|
+
#
|
54
|
+
# comment - Optional String comment to append.
|
55
|
+
#
|
56
|
+
# Returns a String key.
|
57
|
+
def openssh(comment: nil)
|
58
|
+
[algo, Base64.strict_encode64(rfc4253), comment].compact.join(" ")
|
59
|
+
end
|
60
|
+
|
61
|
+
# Is this public key equal to another public key?
|
62
|
+
#
|
63
|
+
# other - Another SSHData::PublicKey::Base instance to compare with.
|
64
|
+
#
|
65
|
+
# Returns boolean.
|
66
|
+
def ==(other)
|
67
|
+
other.class == self.class
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
@@ -0,0 +1,122 @@
|
|
1
|
+
module SSHData
|
2
|
+
module PublicKey
|
3
|
+
class DSA < Base
|
4
|
+
attr_reader :p, :q, :g, :y, :openssl
|
5
|
+
|
6
|
+
# Convert an SSH encoded DSA signature to DER encoding for verification with
|
7
|
+
# OpenSSL.
|
8
|
+
#
|
9
|
+
# sig - A binary String signature from an SSH packet.
|
10
|
+
#
|
11
|
+
# Returns a binary String signature, as expected by OpenSSL.
|
12
|
+
def self.openssl_signature(sig)
|
13
|
+
if sig.bytesize != 40
|
14
|
+
raise DecodeError, "bad DSA signature size"
|
15
|
+
end
|
16
|
+
|
17
|
+
r = OpenSSL::BN.new(sig.byteslice(0, 20), 2)
|
18
|
+
s = OpenSSL::BN.new(sig.byteslice(20, 20), 2)
|
19
|
+
|
20
|
+
OpenSSL::ASN1::Sequence.new([
|
21
|
+
OpenSSL::ASN1::Integer.new(r),
|
22
|
+
OpenSSL::ASN1::Integer.new(s)
|
23
|
+
]).to_der
|
24
|
+
end
|
25
|
+
|
26
|
+
# Convert an DER encoded DSA signature, as generated by OpenSSL to SSH
|
27
|
+
# encoding.
|
28
|
+
#
|
29
|
+
# sig - A binary String signature, as generated by OpenSSL.
|
30
|
+
#
|
31
|
+
# Returns a binary String signature, as found in an SSH packet.
|
32
|
+
def self.ssh_signature(sig)
|
33
|
+
a1 = OpenSSL::ASN1.decode(sig)
|
34
|
+
if a1.tag_class != :UNIVERSAL || a1.tag != OpenSSL::ASN1::SEQUENCE || a1.value.count != 2
|
35
|
+
raise DecodeError, "bad asn1 signature"
|
36
|
+
end
|
37
|
+
|
38
|
+
r, s = a1.value
|
39
|
+
if r.tag_class != :UNIVERSAL || r.tag != OpenSSL::ASN1::INTEGER || s.tag_class != :UNIVERSAL || s.tag != OpenSSL::ASN1::INTEGER
|
40
|
+
raise DecodeError, "bad asn1 signature"
|
41
|
+
end
|
42
|
+
|
43
|
+
# left pad big endian representations to 20 bytes and concatenate
|
44
|
+
[
|
45
|
+
"\x00" * (20 - r.value.num_bytes),
|
46
|
+
r.value.to_s(2),
|
47
|
+
"\x00" * (20 - s.value.num_bytes),
|
48
|
+
s.value.to_s(2)
|
49
|
+
].join
|
50
|
+
end
|
51
|
+
|
52
|
+
def initialize(algo:, p:, q:, g:, y:)
|
53
|
+
unless algo == ALGO_DSA
|
54
|
+
raise DecodeError, "bad algorithm: #{algo.inspect}"
|
55
|
+
end
|
56
|
+
|
57
|
+
@p = p
|
58
|
+
@q = q
|
59
|
+
@g = g
|
60
|
+
@y = y
|
61
|
+
|
62
|
+
@openssl = OpenSSL::PKey::DSA.new(asn1.to_der)
|
63
|
+
|
64
|
+
super(algo: algo)
|
65
|
+
end
|
66
|
+
|
67
|
+
# Verify an SSH signature.
|
68
|
+
#
|
69
|
+
# signed_data - The String message that the signature was calculated over.
|
70
|
+
# signature - The binarty String signature with SSH encoding.
|
71
|
+
#
|
72
|
+
# Returns boolean.
|
73
|
+
def verify(signed_data, signature)
|
74
|
+
sig_algo, ssh_sig, _ = Encoding.decode_signature(signature)
|
75
|
+
if sig_algo != ALGO_DSA
|
76
|
+
raise DecodeError, "bad signature algorithm: #{sig_algo.inspect}"
|
77
|
+
end
|
78
|
+
|
79
|
+
openssl_sig = self.class.openssl_signature(ssh_sig)
|
80
|
+
openssl.verify(OpenSSL::Digest::SHA1.new, openssl_sig, signed_data)
|
81
|
+
end
|
82
|
+
|
83
|
+
# RFC4253 binary encoding of the public key.
|
84
|
+
#
|
85
|
+
# Returns a binary String.
|
86
|
+
def rfc4253
|
87
|
+
Encoding.encode_fields(
|
88
|
+
[:string, algo],
|
89
|
+
[:mpint, p],
|
90
|
+
[:mpint, q],
|
91
|
+
[:mpint, g],
|
92
|
+
[:mpint, y],
|
93
|
+
)
|
94
|
+
end
|
95
|
+
|
96
|
+
# Is this public key equal to another public key?
|
97
|
+
#
|
98
|
+
# other - Another SSHData::PublicKey::Base instance to compare with.
|
99
|
+
#
|
100
|
+
# Returns boolean.
|
101
|
+
def ==(other)
|
102
|
+
super && other.p == p && other.q == q && other.g == g && other.y == y
|
103
|
+
end
|
104
|
+
|
105
|
+
private
|
106
|
+
|
107
|
+
def asn1
|
108
|
+
OpenSSL::ASN1::Sequence.new([
|
109
|
+
OpenSSL::ASN1::Sequence.new([
|
110
|
+
OpenSSL::ASN1::ObjectId.new("DSA"),
|
111
|
+
OpenSSL::ASN1::Sequence.new([
|
112
|
+
OpenSSL::ASN1::Integer.new(p),
|
113
|
+
OpenSSL::ASN1::Integer.new(q),
|
114
|
+
OpenSSL::ASN1::Integer.new(g),
|
115
|
+
]),
|
116
|
+
]),
|
117
|
+
OpenSSL::ASN1::BitString.new(OpenSSL::ASN1::Integer.new(y).to_der),
|
118
|
+
])
|
119
|
+
end
|
120
|
+
end
|
121
|
+
end
|
122
|
+
end
|
@@ -0,0 +1,151 @@
|
|
1
|
+
module SSHData
|
2
|
+
module PublicKey
|
3
|
+
class ECDSA < Base
|
4
|
+
attr_reader :curve, :public_key_bytes, :openssl
|
5
|
+
|
6
|
+
NISTP256 = "nistp256"
|
7
|
+
NISTP384 = "nistp384"
|
8
|
+
NISTP521 = "nistp521"
|
9
|
+
|
10
|
+
OPENSSL_CURVE_NAME_FOR_CURVE = {
|
11
|
+
NISTP256 => "prime256v1",
|
12
|
+
NISTP384 => "secp384r1",
|
13
|
+
NISTP521 => "secp521r1",
|
14
|
+
}
|
15
|
+
|
16
|
+
CURVE_FOR_OPENSSL_CURVE_NAME = {
|
17
|
+
"prime256v1" => NISTP256,
|
18
|
+
"secp384r1" => NISTP384,
|
19
|
+
"secp521r1" => NISTP521,
|
20
|
+
}
|
21
|
+
|
22
|
+
DIGEST_FOR_CURVE = {
|
23
|
+
NISTP256 => OpenSSL::Digest::SHA256,
|
24
|
+
NISTP384 => OpenSSL::Digest::SHA384,
|
25
|
+
NISTP521 => OpenSSL::Digest::SHA512,
|
26
|
+
}
|
27
|
+
|
28
|
+
# Convert an SSH encoded ECDSA signature to DER encoding for verification with
|
29
|
+
# OpenSSL.
|
30
|
+
#
|
31
|
+
# sig - A binary String signature from an SSH packet.
|
32
|
+
#
|
33
|
+
# Returns a binary String signature, as expected by OpenSSL.
|
34
|
+
def self.openssl_signature(sig)
|
35
|
+
r, rlen = Encoding.decode_mpint(sig, 0)
|
36
|
+
s, slen = Encoding.decode_mpint(sig, rlen)
|
37
|
+
|
38
|
+
if rlen + slen != sig.bytesize
|
39
|
+
raise DecodeError, "unexpected trailing data"
|
40
|
+
end
|
41
|
+
|
42
|
+
OpenSSL::ASN1::Sequence.new([
|
43
|
+
OpenSSL::ASN1::Integer.new(r),
|
44
|
+
OpenSSL::ASN1::Integer.new(s)
|
45
|
+
]).to_der
|
46
|
+
end
|
47
|
+
|
48
|
+
# Convert an DER encoded ECDSA signature, as generated by OpenSSL to SSH
|
49
|
+
# encoding.
|
50
|
+
#
|
51
|
+
# sig - A binary String signature, as generated by OpenSSL.
|
52
|
+
#
|
53
|
+
# Returns a binary String signature, as found in an SSH packet.
|
54
|
+
def self.ssh_signature(sig)
|
55
|
+
a1 = OpenSSL::ASN1.decode(sig)
|
56
|
+
if a1.tag_class != :UNIVERSAL || a1.tag != OpenSSL::ASN1::SEQUENCE || a1.value.count != 2
|
57
|
+
raise DecodeError, "bad asn1 signature"
|
58
|
+
end
|
59
|
+
|
60
|
+
r, s = a1.value
|
61
|
+
if r.tag_class != :UNIVERSAL || r.tag != OpenSSL::ASN1::INTEGER || s.tag_class != :UNIVERSAL || s.tag != OpenSSL::ASN1::INTEGER
|
62
|
+
raise DecodeError, "bad asn1 signature"
|
63
|
+
end
|
64
|
+
|
65
|
+
[Encoding.encode_mpint(r.value), Encoding.encode_mpint(s.value)].join
|
66
|
+
end
|
67
|
+
|
68
|
+
def initialize(algo:, curve:, public_key:)
|
69
|
+
unless [ALGO_ECDSA256, ALGO_ECDSA384, ALGO_ECDSA521].include?(algo)
|
70
|
+
raise DecodeError, "bad algorithm: #{algo.inspect}"
|
71
|
+
end
|
72
|
+
|
73
|
+
unless algo == "ecdsa-sha2-#{curve}"
|
74
|
+
raise DecodeError, "bad curve: #{curve.inspect}"
|
75
|
+
end
|
76
|
+
|
77
|
+
@curve = curve
|
78
|
+
@public_key_bytes = public_key
|
79
|
+
|
80
|
+
@openssl = begin
|
81
|
+
OpenSSL::PKey::EC.new(asn1.to_der)
|
82
|
+
rescue ArgumentError
|
83
|
+
raise DecodeError, "bad key data"
|
84
|
+
end
|
85
|
+
|
86
|
+
super(algo: algo)
|
87
|
+
end
|
88
|
+
|
89
|
+
# Verify an SSH signature.
|
90
|
+
#
|
91
|
+
# signed_data - The String message that the signature was calculated over.
|
92
|
+
# signature - The binarty String signature with SSH encoding.
|
93
|
+
#
|
94
|
+
# Returns boolean.
|
95
|
+
def verify(signed_data, signature)
|
96
|
+
sig_algo, ssh_sig, _ = Encoding.decode_signature(signature)
|
97
|
+
if sig_algo != "ecdsa-sha2-#{curve}"
|
98
|
+
raise DecodeError, "bad signature algorithm: #{sig_algo.inspect}"
|
99
|
+
end
|
100
|
+
|
101
|
+
openssl_sig = self.class.openssl_signature(ssh_sig)
|
102
|
+
digest = DIGEST_FOR_CURVE[curve]
|
103
|
+
|
104
|
+
openssl.verify(digest.new, openssl_sig, signed_data)
|
105
|
+
end
|
106
|
+
|
107
|
+
# RFC4253 binary encoding of the public key.
|
108
|
+
#
|
109
|
+
# Returns a binary String.
|
110
|
+
def rfc4253
|
111
|
+
Encoding.encode_fields(
|
112
|
+
[:string, algo],
|
113
|
+
[:string, curve],
|
114
|
+
[:string, public_key_bytes],
|
115
|
+
)
|
116
|
+
end
|
117
|
+
|
118
|
+
# Is this public key equal to another public key?
|
119
|
+
#
|
120
|
+
# other - Another SSHData::PublicKey::Base instance to compare with.
|
121
|
+
#
|
122
|
+
# Returns boolean.
|
123
|
+
def ==(other)
|
124
|
+
super && other.curve == curve && other.public_key_bytes == public_key_bytes
|
125
|
+
end
|
126
|
+
|
127
|
+
# The digest algorithm to use with this key's curve.
|
128
|
+
#
|
129
|
+
# Returns an OpenSSL::Digest.
|
130
|
+
def digest
|
131
|
+
DIGEST_FOR_CURVE[curve]
|
132
|
+
end
|
133
|
+
|
134
|
+
private
|
135
|
+
|
136
|
+
def asn1
|
137
|
+
unless name = OPENSSL_CURVE_NAME_FOR_CURVE[curve]
|
138
|
+
raise DecodeError, "unknown curve: #{curve.inspect}"
|
139
|
+
end
|
140
|
+
|
141
|
+
OpenSSL::ASN1::Sequence.new([
|
142
|
+
OpenSSL::ASN1::Sequence.new([
|
143
|
+
OpenSSL::ASN1::ObjectId.new("id-ecPublicKey"),
|
144
|
+
OpenSSL::ASN1::ObjectId.new(name),
|
145
|
+
]),
|
146
|
+
OpenSSL::ASN1::BitString.new(public_key_bytes),
|
147
|
+
])
|
148
|
+
end
|
149
|
+
end
|
150
|
+
end
|
151
|
+
end
|
@@ -0,0 +1,74 @@
|
|
1
|
+
module SSHData
|
2
|
+
module PublicKey
|
3
|
+
class ED25519 < Base
|
4
|
+
attr_reader :pk, :ed25519_key
|
5
|
+
|
6
|
+
# ed25519 isn't a hard requirement for using this Gem. We only do actual
|
7
|
+
# validation with the key if the ed25519 Gem has been loaded.
|
8
|
+
def self.enabled?
|
9
|
+
Object.const_defined?(:Ed25519)
|
10
|
+
end
|
11
|
+
|
12
|
+
# Assert that the ed25519 gem has been loaded.
|
13
|
+
#
|
14
|
+
# Returns nothing, raises AlgorithmError.
|
15
|
+
def self.ed25519_gem_required!
|
16
|
+
raise AlgorithmError, "the ed25519 gem is not loaded" unless enabled?
|
17
|
+
end
|
18
|
+
|
19
|
+
def initialize(algo:, pk:)
|
20
|
+
unless algo == ALGO_ED25519
|
21
|
+
raise DecodeError, "bad algorithm: #{algo.inspect}"
|
22
|
+
end
|
23
|
+
|
24
|
+
@pk = pk
|
25
|
+
|
26
|
+
if self.class.enabled?
|
27
|
+
@ed25519_key = Ed25519::VerifyKey.new(pk)
|
28
|
+
end
|
29
|
+
|
30
|
+
super(algo: algo)
|
31
|
+
end
|
32
|
+
|
33
|
+
# Verify an SSH signature.
|
34
|
+
#
|
35
|
+
# signed_data - The String message that the signature was calculated over.
|
36
|
+
# signature - The binarty String signature with SSH encoding.
|
37
|
+
#
|
38
|
+
# Returns boolean.
|
39
|
+
def verify(signed_data, signature)
|
40
|
+
self.class.ed25519_gem_required!
|
41
|
+
|
42
|
+
sig_algo, raw_sig, _ = Encoding.decode_signature(signature)
|
43
|
+
if sig_algo != ALGO_ED25519
|
44
|
+
raise DecodeError, "bad signature algorithm: #{sig_algo.inspect}"
|
45
|
+
end
|
46
|
+
|
47
|
+
begin
|
48
|
+
ed25519_key.verify(raw_sig, signed_data)
|
49
|
+
rescue Ed25519::VerifyError
|
50
|
+
false
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
# RFC4253 binary encoding of the public key.
|
55
|
+
#
|
56
|
+
# Returns a binary String.
|
57
|
+
def rfc4253
|
58
|
+
Encoding.encode_fields(
|
59
|
+
[:string, algo],
|
60
|
+
[:string, pk],
|
61
|
+
)
|
62
|
+
end
|
63
|
+
|
64
|
+
# Is this public key equal to another public key?
|
65
|
+
#
|
66
|
+
# other - Another SSHData::PublicKey::Base instance to compare with.
|
67
|
+
#
|
68
|
+
# Returns boolean.
|
69
|
+
def ==(other)
|
70
|
+
super && other.pk == pk
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|