net-ssh 7.3.2 → 8.0.0.beta3
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 +4 -4
- checksums.yaml.gz.sig +0 -0
- data/.dockerignore +3 -0
- data/.github/workflows/ci-with-docker.yml +0 -3
- data/.github/workflows/ci.yml +2 -17
- data/.rubocop.yml +1 -1
- data/.rubocop_todo.yml +4 -1
- data/CHANGES.txt +13 -0
- data/Dockerfile +1 -1
- data/Dockerfile.openssl3 +1 -1
- data/Dockerfile.zlib_ng +1 -1
- data/README.md +10 -13
- data/appveyor.yml +4 -16
- data/docker-compose.yml +1 -7
- data/lib/net/ssh/authentication/agent.rb +4 -4
- data/lib/net/ssh/authentication/ed25519.rb +134 -36
- data/lib/net/ssh/authentication/ed25519_loader.rb +14 -9
- data/lib/net/ssh/authentication/key_manager.rb +21 -6
- data/lib/net/ssh/config.rb +2 -1
- data/lib/net/ssh/known_hosts.rb +44 -0
- data/lib/net/ssh/service/forward.rb +3 -3
- data/lib/net/ssh/transport/chacha20_poly1305_cipher.rb +89 -16
- data/lib/net/ssh/transport/chacha20_poly1305_cipher_loader.rb +20 -1
- data/lib/net/ssh/transport/cipher_factory.rb +23 -1
- data/lib/net/ssh/transport/gcm_cipher.rb +27 -6
- data/lib/net/ssh/transport/kex/curve25519_sha256.rb +41 -9
- data/lib/net/ssh/transport/kex/curve25519_sha256_loader.rb +14 -6
- data/lib/net/ssh/verifiers/always.rb +43 -8
- data/lib/net/ssh/version.rb +4 -4
- data/lib/net/ssh.rb +0 -4
- data/net-ssh.gemspec +3 -7
- data.tar.gz.sig +0 -0
- metadata +12 -42
- metadata.gz.sig +0 -0
- data/Gemfile.noed25519 +0 -12
- data/Gemfile.norbnacl +0 -12
|
@@ -72,12 +72,16 @@ module Net
|
|
|
72
72
|
def add(key_file)
|
|
73
73
|
key_files.push(File.expand_path(key_file)).uniq!
|
|
74
74
|
self
|
|
75
|
+
rescue ArgumentError
|
|
76
|
+
self
|
|
75
77
|
end
|
|
76
78
|
|
|
77
79
|
# Add the given keycert_file to the list of keycert files that will be used.
|
|
78
80
|
def add_keycert(keycert_file)
|
|
79
81
|
keycert_files.push(File.expand_path(keycert_file)).uniq!
|
|
80
82
|
self
|
|
83
|
+
rescue ArgumentError
|
|
84
|
+
self
|
|
81
85
|
end
|
|
82
86
|
|
|
83
87
|
# Add the given keycert_data to the list of keycerts that will be used.
|
|
@@ -172,7 +176,11 @@ module Net
|
|
|
172
176
|
def sign(identity, data, sig_alg = nil)
|
|
173
177
|
info = known_identities[identity] or raise KeyManagerError, "the given identity is unknown to the key manager"
|
|
174
178
|
|
|
175
|
-
if info[:
|
|
179
|
+
if info[:from] == :pubkey_file_only
|
|
180
|
+
raise KeyManagerError, "the given identity is a public key only and cannot be used for signing without an agent"
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
if info[:key].nil? && (info[:from] == :file || info[:from] == :pubkey_file)
|
|
176
184
|
begin
|
|
177
185
|
info[:key] = KeyFactory.load_private_key(info[:file], options[:passphrase], !options[:non_interactive], options[:password_prompt])
|
|
178
186
|
rescue OpenSSL::OpenSSLError, Exception => e
|
|
@@ -248,10 +256,14 @@ module Net
|
|
|
248
256
|
def prepare_identities_from_files
|
|
249
257
|
key_files.map do |file|
|
|
250
258
|
if readable_file?(file)
|
|
251
|
-
identity = {}
|
|
259
|
+
identity = { privkey_file: file }
|
|
252
260
|
cert_file = file + "-cert.pub"
|
|
253
261
|
public_key_file = file + ".pub"
|
|
254
|
-
if
|
|
262
|
+
if file.end_with?(".pub")
|
|
263
|
+
identity[:load_from] = :pubkey_file_only
|
|
264
|
+
identity[:pubkey_file] = file
|
|
265
|
+
identity.delete(:privkey_file)
|
|
266
|
+
elsif readable_file?(cert_file)
|
|
255
267
|
identity[:load_from] = :pubkey_file
|
|
256
268
|
identity[:pubkey_file] = cert_file
|
|
257
269
|
elsif readable_file?(public_key_file)
|
|
@@ -260,7 +272,7 @@ module Net
|
|
|
260
272
|
else
|
|
261
273
|
identity[:load_from] = :privkey_file
|
|
262
274
|
end
|
|
263
|
-
identity
|
|
275
|
+
identity
|
|
264
276
|
end
|
|
265
277
|
end.compact
|
|
266
278
|
end
|
|
@@ -282,7 +294,10 @@ module Net
|
|
|
282
294
|
case identity[:load_from]
|
|
283
295
|
when :pubkey_file
|
|
284
296
|
key = KeyFactory.load_public_key(identity[:pubkey_file])
|
|
285
|
-
{ public_key: key, from: :
|
|
297
|
+
{ public_key: key, from: :pubkey_file, file: identity[:privkey_file] }
|
|
298
|
+
when :pubkey_file_only
|
|
299
|
+
key = KeyFactory.load_public_key(identity[:pubkey_file])
|
|
300
|
+
{ public_key: key, from: :pubkey_file_only, file: identity[:privkey_file] }
|
|
286
301
|
when :privkey_file
|
|
287
302
|
private_key = KeyFactory.load_private_key(
|
|
288
303
|
identity[:privkey_file], options[:passphrase], ask_passphrase, options[:password_prompt]
|
|
@@ -313,7 +328,7 @@ module Net
|
|
|
313
328
|
|
|
314
329
|
def process_identity_loading_error(identity, e)
|
|
315
330
|
case identity[:load_from]
|
|
316
|
-
when :pubkey_file
|
|
331
|
+
when :pubkey_file, :pubkey_file_only
|
|
317
332
|
error { "could not load public key file `#{identity[:pubkey_file]}': #{e.class} (#{e.message})" }
|
|
318
333
|
when :privkey_file
|
|
319
334
|
error { "could not load private key file `#{identity[:privkey_file]}': #{e.class} (#{e.message})" }
|
data/lib/net/ssh/config.rb
CHANGED
|
@@ -229,7 +229,6 @@ module Net
|
|
|
229
229
|
fingerprinthash: :fingerprint_hash,
|
|
230
230
|
port: :port,
|
|
231
231
|
user: :user,
|
|
232
|
-
userknownhostsfile: :user_known_hosts_file,
|
|
233
232
|
checkhostip: :check_host_ip
|
|
234
233
|
}.freeze
|
|
235
234
|
def translate_config_key(hash, key, value, settings)
|
|
@@ -293,6 +292,8 @@ module Net
|
|
|
293
292
|
hash[:set_env] = Shellwords.split(value.to_s).map { |e| e.split '=', 2 }.to_h
|
|
294
293
|
when :numberofpasswordprompts
|
|
295
294
|
hash[:number_of_password_prompts] = value.to_i
|
|
295
|
+
when :userknownhostsfile
|
|
296
|
+
hash[:user_known_hosts_file] = value.split(/\s+/)
|
|
296
297
|
when *TRANSLATE_CONFIG_KEY_RENAME_MAP.keys
|
|
297
298
|
hash[TRANSLATE_CONFIG_KEY_RENAME_MAP[key]] = value
|
|
298
299
|
end
|
data/lib/net/ssh/known_hosts.rb
CHANGED
|
@@ -61,6 +61,37 @@ module Net
|
|
|
61
61
|
false
|
|
62
62
|
end
|
|
63
63
|
end
|
|
64
|
+
|
|
65
|
+
# Returns true if the certificate lists no principals (unrestricted) or
|
|
66
|
+
# if the given hostname matches a listed principal. Host-certificate
|
|
67
|
+
# principals are shell-glob patterns, matching OpenSSH, whose
|
|
68
|
+
# sshkey_cert_check_host passes wildcard_pattern=1 so principals like
|
|
69
|
+
# "*.example.com" are matched with match_pattern rather than strcmp.
|
|
70
|
+
def matches_principal?(server_key, hostname)
|
|
71
|
+
server_key.valid_principals.empty? ||
|
|
72
|
+
server_key.valid_principals.any? { |principal| File.fnmatch(principal, hostname) }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Returns true if the certificate's validity window covers the current time.
|
|
76
|
+
def matches_validity?(server_key)
|
|
77
|
+
return false if server_key.valid_after && server_key.valid_after > Time.now
|
|
78
|
+
return false if server_key.valid_before && server_key.valid_before < Time.now
|
|
79
|
+
|
|
80
|
+
true
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Returns true if the certificate is a host certificate. A user
|
|
84
|
+
# certificate (type :user) must never authenticate a host.
|
|
85
|
+
def matches_type?(server_key)
|
|
86
|
+
server_key.type == :host
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Returns true if every critical option in the certificate is one we can
|
|
90
|
+
# enforce. net-ssh enforces none, so any critical option is unsupported
|
|
91
|
+
# and, as OpenSSH does, must cause the certificate to be rejected.
|
|
92
|
+
def critical_options_supported?(server_key)
|
|
93
|
+
server_key.critical_options.empty?
|
|
94
|
+
end
|
|
64
95
|
end
|
|
65
96
|
end
|
|
66
97
|
|
|
@@ -89,6 +120,17 @@ module Net
|
|
|
89
120
|
def empty?
|
|
90
121
|
@host_keys.empty?
|
|
91
122
|
end
|
|
123
|
+
|
|
124
|
+
# Returns the bare hostname, stripping port and IP portions from the
|
|
125
|
+
# comma-separated host string used internally.
|
|
126
|
+
#
|
|
127
|
+
# Examples:
|
|
128
|
+
# "server.example.com" => "server.example.com"
|
|
129
|
+
# "server.example.com,1.2.3.4" => "server.example.com"
|
|
130
|
+
# "[server.example.com]:2222" => "server.example.com"
|
|
131
|
+
def hostname
|
|
132
|
+
@host.split(",").first.gsub(/\[|\]:\d+/, "")
|
|
133
|
+
end
|
|
92
134
|
end
|
|
93
135
|
|
|
94
136
|
# Searches an OpenSSH-style known-host file for a given host, and returns all
|
|
@@ -160,6 +202,8 @@ module Net
|
|
|
160
202
|
# file. The path is expanded file File.expand_path.
|
|
161
203
|
def initialize(source)
|
|
162
204
|
@source = File.expand_path(source)
|
|
205
|
+
rescue ArgumentError
|
|
206
|
+
@source = source
|
|
163
207
|
end
|
|
164
208
|
|
|
165
209
|
# Returns an array of all keys that are known to be associatd with the
|
|
@@ -48,9 +48,9 @@ module Net
|
|
|
48
48
|
# If three arguments are given, it is as if the local bind address is
|
|
49
49
|
# "127.0.0.1", and the rest are applied as above.
|
|
50
50
|
#
|
|
51
|
-
# To request an ephemeral port on the
|
|
52
|
-
# the port number. In all cases, this method will return the port
|
|
53
|
-
# has been assigned.
|
|
51
|
+
# To request an ephemeral port on the local server, provide 0 (zero) for
|
|
52
|
+
# the local port number. In all cases, this method will return the port
|
|
53
|
+
# that has been assigned.
|
|
54
54
|
#
|
|
55
55
|
# ssh.forward.local(1234, "www.capify.org", 80)
|
|
56
56
|
# assigned_port = ssh.forward.local("0.0.0.0", 0, "www.capify.org", 80)
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
require '
|
|
1
|
+
require 'openssl'
|
|
2
2
|
require 'net/ssh/loggable'
|
|
3
3
|
|
|
4
4
|
module Net
|
|
@@ -8,6 +8,15 @@ module Net
|
|
|
8
8
|
class ChaCha20Poly1305Cipher
|
|
9
9
|
include Net::SSH::Loggable
|
|
10
10
|
|
|
11
|
+
POLY1305_ALGORITHM = "POLY1305"
|
|
12
|
+
POLY1305_KEY_BYTES = 32
|
|
13
|
+
POLY1305_TAG_BYTES = 16
|
|
14
|
+
NAME = "chacha20-poly1305@openssh.com"
|
|
15
|
+
ZERO_BLOCK = "\x00".b * POLY1305_KEY_BYTES
|
|
16
|
+
ZERO_IV = "\x00".b * 16
|
|
17
|
+
|
|
18
|
+
class UnsupportedError < StandardError; end
|
|
19
|
+
|
|
11
20
|
# Implicit HMAC, no need to do anything
|
|
12
21
|
class ImplicitHMac
|
|
13
22
|
def etm
|
|
@@ -24,7 +33,6 @@ module Net
|
|
|
24
33
|
@chacha_hdr = OpenSSL::Cipher.new("chacha20")
|
|
25
34
|
key_len = @chacha_hdr.key_len
|
|
26
35
|
@chacha_main = OpenSSL::Cipher.new("chacha20")
|
|
27
|
-
@poly = RbNaCl::OneTimeAuths::Poly1305
|
|
28
36
|
if key.size < key_len * 2
|
|
29
37
|
error { "chacha20_poly1305: keylength doesn't match" }
|
|
30
38
|
raise "chacha20_poly1305: keylength doesn't match"
|
|
@@ -43,9 +51,9 @@ module Net
|
|
|
43
51
|
end
|
|
44
52
|
|
|
45
53
|
def update_cipher_mac(payload, sequence_number)
|
|
46
|
-
iv_data =
|
|
54
|
+
iv_data = packet_iv(sequence_number)
|
|
47
55
|
@chacha_main.iv = iv_data
|
|
48
|
-
poly_key = @chacha_main.update(
|
|
56
|
+
poly_key = @chacha_main.update(ZERO_BLOCK)
|
|
49
57
|
|
|
50
58
|
packet_length = payload.size
|
|
51
59
|
length_data = [packet_length].pack("N")
|
|
@@ -57,35 +65,36 @@ module Net
|
|
|
57
65
|
unencrypted_data = payload
|
|
58
66
|
packet += @chacha_main.update(unencrypted_data)
|
|
59
67
|
|
|
60
|
-
packet +=
|
|
68
|
+
packet += self.class.poly1305_auth(poly_key, packet)
|
|
61
69
|
return packet
|
|
62
70
|
end
|
|
63
71
|
|
|
64
72
|
def read_length(data, sequence_number)
|
|
65
|
-
iv_data =
|
|
73
|
+
iv_data = packet_iv(sequence_number)
|
|
66
74
|
@chacha_hdr.iv = iv_data
|
|
67
75
|
@chacha_hdr.update(data).unpack1("N")
|
|
68
76
|
end
|
|
69
77
|
|
|
70
78
|
def read_and_mac(data, mac, sequence_number)
|
|
71
|
-
iv_data =
|
|
79
|
+
iv_data = packet_iv(sequence_number)
|
|
72
80
|
@chacha_main.iv = iv_data
|
|
73
|
-
poly_key = @chacha_main.update(
|
|
81
|
+
poly_key = @chacha_main.update(ZERO_BLOCK)
|
|
74
82
|
|
|
75
83
|
iv_data[0] = 1.chr
|
|
76
84
|
@chacha_main.iv = iv_data
|
|
77
85
|
unencrypted_data = @chacha_main.update(data[4..])
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
86
|
+
|
|
87
|
+
expected_mac = self.class.poly1305_auth(poly_key, data[0..])
|
|
88
|
+
valid_mac = mac.respond_to?(:bytesize) &&
|
|
89
|
+
mac.bytesize == POLY1305_TAG_BYTES &&
|
|
90
|
+
OpenSSL.fixed_length_secure_compare(expected_mac, mac)
|
|
91
|
+
raise Net::SSH::Exception, "corrupted hmac detected #{name}" unless valid_mac
|
|
92
|
+
|
|
84
93
|
return unencrypted_data
|
|
85
94
|
end
|
|
86
95
|
|
|
87
96
|
def mac_length
|
|
88
|
-
|
|
97
|
+
POLY1305_TAG_BYTES
|
|
89
98
|
end
|
|
90
99
|
|
|
91
100
|
def block_size
|
|
@@ -93,7 +102,7 @@ module Net
|
|
|
93
102
|
end
|
|
94
103
|
|
|
95
104
|
def name
|
|
96
|
-
|
|
105
|
+
NAME
|
|
97
106
|
end
|
|
98
107
|
|
|
99
108
|
def implicit_mac?
|
|
@@ -111,6 +120,70 @@ module Net
|
|
|
111
120
|
def self.key_length
|
|
112
121
|
64
|
|
113
122
|
end
|
|
123
|
+
|
|
124
|
+
def self.iv_len
|
|
125
|
+
0
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def self.auth_length
|
|
129
|
+
POLY1305_TAG_BYTES
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def self.decrypt_private_key(ciphertext, auth_tag, key, _initialization_vector)
|
|
133
|
+
raise ArgumentError, "chacha20_poly1305: keylength doesn't match" unless
|
|
134
|
+
key.respond_to?(:bytesize) && key.bytesize == key_length
|
|
135
|
+
|
|
136
|
+
ciphertext = binary_string(ciphertext)
|
|
137
|
+
chacha = OpenSSL::Cipher.new("chacha20")
|
|
138
|
+
chacha.decrypt
|
|
139
|
+
chacha.key = binary_string(key[0...POLY1305_KEY_BYTES])
|
|
140
|
+
|
|
141
|
+
iv_data = ZERO_IV.dup
|
|
142
|
+
chacha.iv = iv_data
|
|
143
|
+
poly_key = chacha.update(ZERO_BLOCK)
|
|
144
|
+
|
|
145
|
+
valid_mac = OpenSSL.fixed_length_secure_compare(poly1305_auth(poly_key, ciphertext), auth_tag)
|
|
146
|
+
raise Net::SSH::Exception, "corrupted hmac detected #{NAME}" unless valid_mac
|
|
147
|
+
|
|
148
|
+
iv_data.setbyte(0, 1)
|
|
149
|
+
chacha.iv = iv_data
|
|
150
|
+
chacha.update(ciphertext)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def self.ensure_supported!
|
|
154
|
+
raise UnsupportedError, "OpenSSL::PKey raw private key APIs are unavailable" unless OpenSSL::PKey.respond_to?(:new_raw_private_key)
|
|
155
|
+
|
|
156
|
+
OpenSSL::Cipher.new("chacha20")
|
|
157
|
+
|
|
158
|
+
tag = poly1305_auth("\x00" * POLY1305_KEY_BYTES, "")
|
|
159
|
+
raise UnsupportedError, "OpenSSL Poly1305 authentication failed" unless tag.bytesize == POLY1305_TAG_BYTES
|
|
160
|
+
rescue OpenSSL::Cipher::CipherError, OpenSSL::PKey::PKeyError => e
|
|
161
|
+
raise UnsupportedError, e.message
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def self.poly1305_auth(poly_key, data)
|
|
165
|
+
validate_poly1305_key!(poly_key)
|
|
166
|
+
OpenSSL::PKey.new_raw_private_key(POLY1305_ALGORITHM, binary_string(poly_key)).sign(nil, binary_string(data))
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def self.validate_poly1305_key!(poly_key)
|
|
170
|
+
raise ArgumentError, "invalid Poly1305 key" unless poly_key.respond_to?(:bytesize) && poly_key.bytesize == POLY1305_KEY_BYTES
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def self.binary_string(string)
|
|
174
|
+
string.dup.force_encoding('BINARY')
|
|
175
|
+
end
|
|
176
|
+
private_class_method :binary_string
|
|
177
|
+
|
|
178
|
+
def packet_iv(sequence_number)
|
|
179
|
+
iv_data = ZERO_IV.dup
|
|
180
|
+
iv_data.setbyte(12, (sequence_number >> 24) & 0xff)
|
|
181
|
+
iv_data.setbyte(13, (sequence_number >> 16) & 0xff)
|
|
182
|
+
iv_data.setbyte(14, (sequence_number >> 8) & 0xff)
|
|
183
|
+
iv_data.setbyte(15, sequence_number & 0xff)
|
|
184
|
+
iv_data
|
|
185
|
+
end
|
|
186
|
+
private :packet_iv
|
|
114
187
|
end
|
|
115
188
|
end
|
|
116
189
|
end
|
|
@@ -1,15 +1,34 @@
|
|
|
1
1
|
module Net
|
|
2
2
|
module SSH
|
|
3
3
|
module Transport
|
|
4
|
-
# Loads chacha20
|
|
4
|
+
# Loads chacha20-poly1305 support backed by Ruby OpenSSL APIs.
|
|
5
5
|
module ChaCha20Poly1305CipherLoader
|
|
6
6
|
begin
|
|
7
7
|
require 'net/ssh/transport/chacha20_poly1305_cipher'
|
|
8
|
+
Net::SSH::Transport::ChaCha20Poly1305Cipher.ensure_supported!
|
|
8
9
|
LOADED = true
|
|
9
10
|
ERROR = nil
|
|
10
11
|
rescue LoadError => e
|
|
11
12
|
ERROR = e
|
|
12
13
|
LOADED = false
|
|
14
|
+
rescue StandardError => e
|
|
15
|
+
if defined?(Net::SSH::Transport::ChaCha20Poly1305Cipher::UnsupportedError) &&
|
|
16
|
+
e.is_a?(Net::SSH::Transport::ChaCha20Poly1305Cipher::UnsupportedError)
|
|
17
|
+
ERROR = e
|
|
18
|
+
LOADED = false
|
|
19
|
+
else
|
|
20
|
+
raise
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.raiseUnlessLoaded(message)
|
|
25
|
+
description = LOADED ? '' : chacha20_poly1305_support_required
|
|
26
|
+
description += "#{ERROR.class} : \"#{ERROR.message}\"\n" if ERROR
|
|
27
|
+
raise NotImplementedError, "#{message}\n#{description}" unless LOADED
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.chacha20_poly1305_support_required
|
|
31
|
+
"net-ssh requires openssl >= 3.2.0 and a crypto backend with ChaCha20 and Poly1305 enabled for chacha20-poly1305@openssh.com support.\n"
|
|
13
32
|
end
|
|
14
33
|
end
|
|
15
34
|
end
|
|
@@ -100,7 +100,11 @@ module Net
|
|
|
100
100
|
# if :iv_len option is supplied the third return value will be ivlen
|
|
101
101
|
def self.get_lengths(name, options = {})
|
|
102
102
|
klass = SSH_TO_CLASS[name]
|
|
103
|
-
|
|
103
|
+
unless klass.nil?
|
|
104
|
+
result = [klass.key_length, klass.block_size]
|
|
105
|
+
result << klass.iv_len if options[:iv_len]
|
|
106
|
+
return result
|
|
107
|
+
end
|
|
104
108
|
|
|
105
109
|
ossl_name = SSH_TO_OSSL[name]
|
|
106
110
|
if ossl_name.nil? || ossl_name == "none"
|
|
@@ -124,6 +128,24 @@ module Net
|
|
|
124
128
|
end
|
|
125
129
|
result
|
|
126
130
|
end
|
|
131
|
+
|
|
132
|
+
def self.auth_length(name)
|
|
133
|
+
klass = SSH_TO_CLASS[name]
|
|
134
|
+
return klass.auth_length if klass.respond_to?(:auth_length)
|
|
135
|
+
|
|
136
|
+
0
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def self.decrypt_private_key(name, ciphertext, auth_tag, key, initialization_vector)
|
|
140
|
+
klass = SSH_TO_CLASS[name]
|
|
141
|
+
raise Net::SSH::Exception, "#{name} does not support private key decryption" unless
|
|
142
|
+
klass.respond_to?(:decrypt_private_key)
|
|
143
|
+
|
|
144
|
+
raise Net::SSH::Exception, "incorrect auth_tag length" unless
|
|
145
|
+
auth_tag.respond_to?(:bytesize) && auth_tag.bytesize == klass.auth_length
|
|
146
|
+
|
|
147
|
+
klass.decrypt_private_key(ciphertext, auth_tag, key, initialization_vector)
|
|
148
|
+
end
|
|
127
149
|
end
|
|
128
150
|
end
|
|
129
151
|
end
|
|
@@ -5,7 +5,11 @@ module Net
|
|
|
5
5
|
module Transport
|
|
6
6
|
## Extension module for aes(128|256)gcm ciphers
|
|
7
7
|
module GCMCipher
|
|
8
|
-
|
|
8
|
+
BLOCK_SIZE = 16
|
|
9
|
+
IV_BYTES = 12
|
|
10
|
+
AUTH_TAG_BYTES = 16
|
|
11
|
+
|
|
12
|
+
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
9
13
|
def self.extended(orig)
|
|
10
14
|
# rubocop:disable Metrics/BlockLength
|
|
11
15
|
orig.class_eval do
|
|
@@ -100,15 +104,32 @@ module Net
|
|
|
100
104
|
end
|
|
101
105
|
|
|
102
106
|
def mac_length
|
|
103
|
-
|
|
107
|
+
GCMCipher::AUTH_TAG_BYTES
|
|
104
108
|
end
|
|
105
109
|
|
|
106
110
|
def block_size
|
|
107
|
-
|
|
111
|
+
GCMCipher::BLOCK_SIZE
|
|
108
112
|
end
|
|
109
113
|
|
|
110
114
|
def self.block_size
|
|
111
|
-
|
|
115
|
+
GCMCipher::BLOCK_SIZE
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def self.iv_len
|
|
119
|
+
GCMCipher::IV_BYTES
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def self.auth_length
|
|
123
|
+
GCMCipher::AUTH_TAG_BYTES
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def self.decrypt_private_key(ciphertext, auth_tag, key, initialization_vector)
|
|
127
|
+
cipher = new(encrypt: false, key: key)
|
|
128
|
+
|
|
129
|
+
cipher.nonce = initialization_vector
|
|
130
|
+
cipher.cipher.auth_tag = auth_tag.to_s
|
|
131
|
+
cipher.cipher.auth_data = ''
|
|
132
|
+
cipher.cipher.update(ciphertext.to_s) << cipher.cipher.final
|
|
112
133
|
end
|
|
113
134
|
|
|
114
135
|
#
|
|
@@ -117,7 +138,7 @@ module Net
|
|
|
117
138
|
# N_MAX maximum nonce (IV) length 12 octets
|
|
118
139
|
#
|
|
119
140
|
def iv_len
|
|
120
|
-
|
|
141
|
+
GCMCipher::IV_BYTES
|
|
121
142
|
end
|
|
122
143
|
|
|
123
144
|
#
|
|
@@ -201,7 +222,7 @@ module Net
|
|
|
201
222
|
end
|
|
202
223
|
# rubocop:enable Metrics/BlockLength
|
|
203
224
|
end
|
|
204
|
-
# rubocop:enable Metrics/AbcSize
|
|
225
|
+
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
|
|
205
226
|
end
|
|
206
227
|
end
|
|
207
228
|
end
|
|
@@ -1,7 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
require 'x25519'
|
|
4
|
-
|
|
1
|
+
require 'openssl'
|
|
5
2
|
require 'net/ssh/transport/constants'
|
|
6
3
|
require 'net/ssh/transport/kex/abstract5656'
|
|
7
4
|
|
|
@@ -12,25 +9,60 @@ module Net
|
|
|
12
9
|
# A key-exchange service implementing the "curve25519-sha256@libssh.org"
|
|
13
10
|
# key-exchange algorithm. (defined in https://tools.ietf.org/html/draft-ietf-curdle-ssh-curves-06)
|
|
14
11
|
class Curve25519Sha256 < Abstract5656
|
|
12
|
+
ALGORITHM = "X25519"
|
|
13
|
+
KEY_BYTES = 32
|
|
14
|
+
|
|
15
|
+
class UnsupportedError < StandardError; end
|
|
16
|
+
|
|
17
|
+
def self.ensure_supported!
|
|
18
|
+
unless OpenSSL::PKey.respond_to?(:generate_key) && OpenSSL::PKey.respond_to?(:new_raw_public_key)
|
|
19
|
+
raise UnsupportedError, "OpenSSL::PKey raw public key APIs are unavailable"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
key = generate_x25519_key
|
|
23
|
+
public_key = new_raw_public_key(key.raw_public_key)
|
|
24
|
+
validate_key_bytes!(key.derive(public_key), "shared secret", KEY_BYTES)
|
|
25
|
+
rescue OpenSSL::PKey::PKeyError => e
|
|
26
|
+
raise UnsupportedError, e.message
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.generate_x25519_key
|
|
30
|
+
OpenSSL::PKey.generate_key(ALGORITHM)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.new_raw_public_key(key)
|
|
34
|
+
validate_key_bytes!(key, "public key", KEY_BYTES)
|
|
35
|
+
OpenSSL::PKey.new_raw_public_key(ALGORITHM, binary_string(key))
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.validate_key_bytes!(key, label, expected_bytes)
|
|
39
|
+
raise ArgumentError, "invalid X25519 #{label}" unless key.respond_to?(:bytesize) && key.bytesize == expected_bytes
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def self.binary_string(string)
|
|
43
|
+
string.dup.force_encoding('BINARY')
|
|
44
|
+
end
|
|
45
|
+
private_class_method :binary_string
|
|
46
|
+
|
|
15
47
|
def digester
|
|
16
48
|
OpenSSL::Digest::SHA256
|
|
17
49
|
end
|
|
18
50
|
|
|
19
51
|
private
|
|
20
52
|
|
|
21
|
-
def generate_key
|
|
22
|
-
|
|
53
|
+
def generate_key
|
|
54
|
+
self.class.generate_x25519_key
|
|
23
55
|
end
|
|
24
56
|
|
|
25
57
|
## string Q_C, client's ephemeral public key octet string
|
|
26
58
|
def ecdh_public_key_bytes
|
|
27
|
-
ecdh.
|
|
59
|
+
ecdh.raw_public_key
|
|
28
60
|
end
|
|
29
61
|
|
|
30
62
|
# compute shared secret from server's public key and client's private key
|
|
31
63
|
def compute_shared_secret(server_ecdh_pubkey)
|
|
32
|
-
pk =
|
|
33
|
-
OpenSSL::BN.new(ecdh.
|
|
64
|
+
pk = self.class.new_raw_public_key(server_ecdh_pubkey)
|
|
65
|
+
OpenSSL::BN.new(ecdh.derive(pk), 2)
|
|
34
66
|
end
|
|
35
67
|
end
|
|
36
68
|
end
|
|
@@ -2,26 +2,34 @@ module Net
|
|
|
2
2
|
module SSH
|
|
3
3
|
module Transport
|
|
4
4
|
module Kex
|
|
5
|
-
# Loads Curve25519Sha256 support
|
|
5
|
+
# Loads Curve25519Sha256 support backed by Ruby OpenSSL X25519 APIs.
|
|
6
6
|
module Curve25519Sha256Loader
|
|
7
7
|
begin
|
|
8
8
|
require 'net/ssh/transport/kex/curve25519_sha256'
|
|
9
|
+
Net::SSH::Transport::Kex::Curve25519Sha256.ensure_supported!
|
|
9
10
|
LOADED = true
|
|
10
11
|
ERROR = nil
|
|
11
12
|
rescue LoadError => e
|
|
12
13
|
ERROR = e
|
|
13
14
|
LOADED = false
|
|
15
|
+
rescue StandardError => e
|
|
16
|
+
if defined?(Net::SSH::Transport::Kex::Curve25519Sha256::UnsupportedError) &&
|
|
17
|
+
e.is_a?(Net::SSH::Transport::Kex::Curve25519Sha256::UnsupportedError)
|
|
18
|
+
ERROR = e
|
|
19
|
+
LOADED = false
|
|
20
|
+
else
|
|
21
|
+
raise
|
|
22
|
+
end
|
|
14
23
|
end
|
|
15
24
|
|
|
16
25
|
def self.raiseUnlessLoaded(message)
|
|
17
|
-
description =
|
|
18
|
-
description
|
|
26
|
+
description = LOADED ? '' : x25519SupportRequired
|
|
27
|
+
description += "#{ERROR.class} : \"#{ERROR.message}\"\n" if ERROR
|
|
19
28
|
raise NotImplementedError, "#{message}\n#{description}" unless LOADED
|
|
20
29
|
end
|
|
21
30
|
|
|
22
|
-
def self.
|
|
23
|
-
|
|
24
|
-
result << " * x25519\n"
|
|
31
|
+
def self.x25519SupportRequired
|
|
32
|
+
"net-ssh requires openssl >= 3.2.0 and a crypto backend with X25519 enabled for curve25519-sha256 support.\n"
|
|
25
33
|
end
|
|
26
34
|
end
|
|
27
35
|
end
|
|
@@ -19,10 +19,8 @@ module Net
|
|
|
19
19
|
# We've never seen this host before, so raise an exception.
|
|
20
20
|
process_cache_miss(host_keys, arguments, HostKeyUnknown, "is unknown") if host_keys.empty?
|
|
21
21
|
|
|
22
|
-
#
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
found = host_keys.any? do |key|
|
|
22
|
+
# Find the known-hosts entry whose key matches the presented certificate.
|
|
23
|
+
found_key = host_keys.find do |key|
|
|
26
24
|
if key.respond_to?(:matches_key?)
|
|
27
25
|
key.matches_key?(arguments[:key])
|
|
28
26
|
else
|
|
@@ -30,11 +28,17 @@ module Net
|
|
|
30
28
|
end
|
|
31
29
|
end
|
|
32
30
|
|
|
33
|
-
#
|
|
34
|
-
|
|
35
|
-
|
|
31
|
+
# No matching entry found — key is not recognized.
|
|
32
|
+
process_cache_miss(host_keys, arguments, HostKeyMismatch, "does not match") unless found_key
|
|
33
|
+
|
|
34
|
+
# For @cert-authority entries, enforce the certificate constraints
|
|
35
|
+
# OpenSSH checks in sshkey_cert_check_authority. A certificate signed by
|
|
36
|
+
# the trusted CA but failing any of these is rejected with
|
|
37
|
+
# HostKeyMismatch (not HostKeyUnknown) so AcceptNew does not swallow the
|
|
38
|
+
# failure and remember the rejected certificate.
|
|
39
|
+
verify_certificate!(found_key, host_keys, arguments)
|
|
36
40
|
|
|
37
|
-
|
|
41
|
+
true
|
|
38
42
|
end
|
|
39
43
|
|
|
40
44
|
def verify_signature(&block)
|
|
@@ -43,6 +47,37 @@ module Net
|
|
|
43
47
|
|
|
44
48
|
private
|
|
45
49
|
|
|
50
|
+
# Enforces the @cert-authority certificate constraints for entries that
|
|
51
|
+
# implement them (Net::SSH::HostKeyEntries::CertAuthority). Non-certificate
|
|
52
|
+
# entries do not respond to these predicates and are left untouched.
|
|
53
|
+
def verify_certificate!(entry, host_keys, arguments)
|
|
54
|
+
cert = arguments[:key]
|
|
55
|
+
|
|
56
|
+
if entry.respond_to?(:matches_validity?) && !entry.matches_validity?(cert)
|
|
57
|
+
reason = if cert.valid_before && cert.valid_before < Time.now
|
|
58
|
+
"Certificate has expired"
|
|
59
|
+
else
|
|
60
|
+
"Certificate is not yet valid"
|
|
61
|
+
end
|
|
62
|
+
process_cache_miss(host_keys, arguments, HostKeyMismatch, reason)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
if entry.respond_to?(:matches_principal?) && !entry.matches_principal?(cert, host_keys.hostname)
|
|
66
|
+
process_cache_miss(host_keys, arguments, HostKeyMismatch,
|
|
67
|
+
"Certificate invalid: name is not a listed principal")
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
if entry.respond_to?(:matches_type?) && !entry.matches_type?(cert)
|
|
71
|
+
process_cache_miss(host_keys, arguments, HostKeyMismatch,
|
|
72
|
+
"Certificate invalid: not a host certificate")
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
return unless entry.respond_to?(:critical_options_supported?) && !entry.critical_options_supported?(cert)
|
|
76
|
+
|
|
77
|
+
process_cache_miss(host_keys, arguments, HostKeyMismatch,
|
|
78
|
+
"Certificate invalid: unsupported critical option")
|
|
79
|
+
end
|
|
80
|
+
|
|
46
81
|
def process_cache_miss(host_keys, args, exc_class, message)
|
|
47
82
|
exception = exc_class.new("fingerprint #{args[:fingerprint]} " +
|
|
48
83
|
"#{message} for #{host_keys.host.inspect}")
|