net-ssh 7.3.9 → 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.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/.github/workflows/ci-with-docker.yml +0 -3
  4. data/.github/workflows/ci.yml +2 -17
  5. data/.rubocop.yml +1 -1
  6. data/.rubocop_todo.yml +1 -0
  7. data/CHANGES.txt +2 -27
  8. data/Dockerfile +1 -4
  9. data/Dockerfile.openssl3 +1 -1
  10. data/Dockerfile.zlib_ng +1 -1
  11. data/README.md +10 -13
  12. data/Rakefile +12 -20
  13. data/appveyor.yml +4 -16
  14. data/docker-compose.yml +1 -7
  15. data/lib/net/ssh/authentication/agent.rb +4 -4
  16. data/lib/net/ssh/authentication/ed25519.rb +134 -46
  17. data/lib/net/ssh/authentication/ed25519_loader.rb +14 -9
  18. data/lib/net/ssh/errors.rb +0 -10
  19. data/lib/net/ssh/key_factory.rb +3 -4
  20. data/lib/net/ssh/known_hosts.rb +30 -85
  21. data/lib/net/ssh/proxy/command.rb +3 -28
  22. data/lib/net/ssh/proxy/jump.rb +7 -38
  23. data/lib/net/ssh/service/forward.rb +0 -6
  24. data/lib/net/ssh/transport/chacha20_poly1305_cipher.rb +89 -16
  25. data/lib/net/ssh/transport/chacha20_poly1305_cipher_loader.rb +20 -1
  26. data/lib/net/ssh/transport/cipher_factory.rb +23 -1
  27. data/lib/net/ssh/transport/gcm_cipher.rb +27 -6
  28. data/lib/net/ssh/transport/kex/curve25519_sha256.rb +41 -9
  29. data/lib/net/ssh/transport/kex/curve25519_sha256_loader.rb +14 -6
  30. data/lib/net/ssh/transport/packet_stream.rb +0 -4
  31. data/lib/net/ssh/transport/server_version.rb +13 -33
  32. data/lib/net/ssh/transport/session.rb +1 -19
  33. data/lib/net/ssh/verifiers/accept_new_or_local_tunnel.rb +1 -3
  34. data/lib/net/ssh/verifiers/always.rb +1 -19
  35. data/lib/net/ssh/verifiers/never.rb +1 -2
  36. data/lib/net/ssh/version.rb +4 -4
  37. data/net-ssh.gemspec +3 -9
  38. data.tar.gz.sig +0 -0
  39. metadata +8 -52
  40. metadata.gz.sig +0 -0
  41. data/Gemfile.noed25519 +0 -12
  42. data/Gemfile.norbnacl +0 -12
@@ -1,29 +1,34 @@
1
1
  module Net
2
2
  module SSH
3
3
  module Authentication
4
- # Loads ED25519 support which requires optinal dependecies like
5
- # ed25519, bcrypt_pbkdf
4
+ # Loads ED25519 support backed by Ruby OpenSSL raw key APIs.
6
5
  module ED25519Loader
7
6
  begin
8
7
  require 'net/ssh/authentication/ed25519'
8
+ Net::SSH::Authentication::ED25519.ensure_supported!
9
9
  LOADED = true
10
10
  ERROR = nil
11
11
  rescue LoadError => e
12
12
  ERROR = e
13
13
  LOADED = false
14
+ rescue StandardError => e
15
+ if defined?(Net::SSH::Authentication::ED25519::UnsupportedError) &&
16
+ e.is_a?(Net::SSH::Authentication::ED25519::UnsupportedError)
17
+ ERROR = e
18
+ LOADED = false
19
+ else
20
+ raise
21
+ end
14
22
  end
15
23
 
16
24
  def self.raiseUnlessLoaded(message)
17
- description = ERROR.is_a?(LoadError) ? dependenciesRequiredForED25519 : +''
18
- description << "#{ERROR.class} : \"#{ERROR.message}\"\n" if ERROR
25
+ description = LOADED ? '' : ed25519SupportRequired
26
+ description += "#{ERROR.class} : \"#{ERROR.message}\"\n" if ERROR
19
27
  raise NotImplementedError, "#{message}\n#{description}" unless LOADED
20
28
  end
21
29
 
22
- def self.dependenciesRequiredForED25519
23
- result = +"net-ssh requires the following gems for ed25519 support:\n"
24
- result << " * ed25519 (>= 1.2, < 2.0)\n"
25
- result << " * bcrypt_pbkdf (>= 1.0, < 2.0)\n" unless RUBY_PLATFORM == "java"
26
- result << "See https://github.com/net-ssh/net-ssh/issues/565 for more information\n"
30
+ def self.ed25519SupportRequired
31
+ "net-ssh requires openssl >= 3.2.0 and a crypto backend with Ed25519 enabled for ed25519 support.\n"
27
32
  end
28
33
  end
29
34
  end
@@ -102,15 +102,5 @@ module Net
102
102
  # you want to proceed anyway, simply call the remember_host! method on the
103
103
  # exception, and then retry.
104
104
  class HostKeyUnknown < HostKeyError; end
105
-
106
- # Raised when the key given by the host, or the CA that signed its
107
- # certificate, is marked @revoked in a known hosts file. A revoked key must
108
- # never be accepted, so unlike the other host key errors this one can't be
109
- # overridden: #remember_host! just raises it again.
110
- class HostKeyRevoked < HostKeyError
111
- def remember_host!
112
- raise self
113
- end
114
- end
115
105
  end
116
106
  end
@@ -55,7 +55,7 @@ module Net
55
55
  prompter = nil
56
56
  result =
57
57
  begin
58
- key_type.read(data, passphrase)
58
+ key_type.read(data, passphrase || 'invalid')
59
59
  rescue *key_type.error_classes => e
60
60
  encrypted_key = !!key_type.encrypted_key?(data, e) if encrypted_key.nil?
61
61
  if encrypted_key && ask_passphrase
@@ -138,9 +138,8 @@ module Net
138
138
  raise Exception, "TODO: subclasses should implement"
139
139
  end
140
140
 
141
- # A nil passphrase would make OpenSSL prompt on the terminal itself.
142
141
  def self.read(key_data, passphrase)
143
- open_ssl_class.new(key_data, passphrase || 'invalid')
142
+ open_ssl_class.new(key_data, passphrase)
144
143
  end
145
144
 
146
145
  def self.encrypted_key?(key_data, error)
@@ -150,7 +149,7 @@ module Net
150
149
 
151
150
  class OpenSSLPKeyType < OpenSSLKeyTypeBase
152
151
  def self.read(key_data, passphrase)
153
- open_ssl_class.read(key_data, passphrase || 'invalid')
152
+ open_ssl_class.read(key_data, passphrase)
154
153
  end
155
154
 
156
155
  def self.open_ssl_class
@@ -93,22 +93,6 @@ module Net
93
93
  server_key.critical_options.empty?
94
94
  end
95
95
  end
96
-
97
- # @revoked entry
98
- class Revoked
99
- def initialize(key, comment: nil)
100
- @key = key
101
- @comment = comment
102
- end
103
-
104
- # Returns true for the revoked key itself and for any certificate that
105
- # wraps it or is signed by it, matching OpenSSH's check_key_not_revoked.
106
- def matches_key?(server_key)
107
- keys = [server_key]
108
- keys += [server_key.key, server_key.signature_key] if server_key.respond_to?(:signature_key)
109
- keys.any? { |key| key.ssh_type == @key.ssh_type && key.to_blob == @key.to_blob }
110
- end
111
- end
112
96
  end
113
97
 
114
98
  # Represents the result of a search in known hosts
@@ -117,17 +101,11 @@ module Net
117
101
  include Enumerable
118
102
  attr_reader :host
119
103
 
120
- def initialize(host_keys, host, known_hosts, options = {}, revoked_keys = [])
104
+ def initialize(host_keys, host, known_hosts, options = {})
121
105
  @host_keys = host_keys
122
106
  @host = host
123
107
  @known_hosts = known_hosts
124
108
  @options = options
125
- @revoked_keys = revoked_keys
126
- end
127
-
128
- # Returns true if a @revoked known-hosts entry for this host matches +key+.
129
- def revoked?(key)
130
- @revoked_keys.any? { |entry| entry.matches_key?(key) }
131
109
  end
132
110
 
133
111
  def add_host_key(key)
@@ -173,14 +151,11 @@ module Net
173
151
  # Searches all known host files (see KnownHosts.hostfiles) for all keys
174
152
  # of the given host. Returns an enumerable of keys found.
175
153
  def search_for(host, options = {})
176
- files = hostfiles(options)
177
- revoked_keys = files.flat_map { |file| KnownHosts.new(file).revoked_keys_for(host) }
178
- HostKeys.new(search_in(files, host, options), host, self, options, revoked_keys)
154
+ HostKeys.new(search_in(hostfiles(options), host, options), host, self, options)
179
155
  end
180
156
 
181
157
  # Search for all known keys for the given host, in every file given in
182
- # the +files+ array. Returns the list of keys. @revoked entries are not
183
- # included.
158
+ # the +files+ array. Returns the list of keys.
184
159
  def search_in(files, host, options = {})
185
160
  files.flat_map { |file| KnownHosts.new(file).keys_for(host, options) }
186
161
  end
@@ -244,50 +219,46 @@ module Net
244
219
  # "[net.ssh.test]:5555"
245
220
  # "[1,2,3,4]:5555"
246
221
  # "[net.ssh.test]:5555,[1.2.3.4]:5555
247
- #
248
- # @revoked entries are not included, see revoked_keys_for.
249
222
  def keys_for(host, options = {})
250
223
  keys = []
224
+ return keys unless File.readable?(source)
225
+
251
226
  entries = host.split(/,/)
252
227
  host_name = entries[0]
253
228
  host_ip = entries[1]
254
229
 
255
- each_entry do |marker, hostlist, key_content, comment|
256
- next if marker == "@revoked"
257
-
258
- found = hostlist.any? { |pattern| match(host_name, pattern) } || known_host_hash?(hostlist, entries)
259
- next unless found
260
-
261
- found = hostlist.include?(host_ip) if options[:check_host_ip] && entries.size > 1 && hostlist.size > 1
262
- next unless found
263
-
264
- raw_key = read_key(key_content)
265
-
266
- keys <<
267
- if marker == "@cert-authority"
268
- HostKeyEntries::CertAuthority.new(raw_key, comment: comment)
230
+ File.open(source) do |file|
231
+ file.each_line do |line|
232
+ if line.start_with?('@')
233
+ marker, hosts, type, key_content, comment = line.split(' ')
269
234
  else
270
- HostKeyEntries::PubKey.new(raw_key, comment: comment)
235
+ marker = nil
236
+ hosts, type, key_content, comment = line.split(' ')
271
237
  end
272
- end
273
238
 
274
- keys
275
- end
239
+ # Skip empty line or one that is commented
240
+ next if hosts.nil? || hosts.start_with?('#')
276
241
 
277
- # Returns the @revoked entries for the given host, which takes the same
278
- # formats as keys_for. An entry matching any of the comma-separated names
279
- # counts, so :check_host_ip can't narrow a revocation.
280
- def revoked_keys_for(host)
281
- keys = []
282
- entries = host.split(/,/)
242
+ hostlist = hosts.split(',')
243
+
244
+ next unless SUPPORTED_TYPE.include?(type)
245
+
246
+ found = hostlist.any? { |pattern| match(host_name, pattern) } || known_host_hash?(hostlist, entries)
247
+ next unless found
283
248
 
284
- each_entry do |marker, hostlist, key_content, comment|
285
- next unless marker == "@revoked"
249
+ found = hostlist.include?(host_ip) if options[:check_host_ip] && entries.size > 1 && hostlist.size > 1
250
+ next unless found
286
251
 
287
- found = entries.any? { |entry| hostlist.any? { |pattern| match(entry, pattern) } }
288
- next unless found || known_host_hash?(hostlist, entries)
252
+ blob = key_content.unpack("m*").first
253
+ raw_key = Net::SSH::Buffer.new(blob).read_key
289
254
 
290
- keys << HostKeyEntries::Revoked.new(read_key(key_content), comment: comment)
255
+ keys <<
256
+ if marker == "@cert-authority"
257
+ HostKeyEntries::CertAuthority.new(raw_key, comment: comment)
258
+ else
259
+ HostKeyEntries::PubKey.new(raw_key, comment: comment)
260
+ end
261
+ end
291
262
  end
292
263
 
293
264
  keys
@@ -332,32 +303,6 @@ module Net
332
303
  file.puts "#{host} #{key.ssh_type} #{blob}"
333
304
  end
334
305
  end
335
-
336
- private
337
-
338
- # Yields the marker, host patterns, key and comment of each usable line.
339
- def each_entry
340
- return unless File.readable?(source)
341
-
342
- File.open(source) do |file|
343
- file.each_line do |line|
344
- fields = line.split(' ')
345
- marker = fields.shift if fields.first&.start_with?('@')
346
- hosts, type, key_content, comment = fields
347
-
348
- # Skip empty line or one that is commented
349
- next if hosts.nil? || hosts.start_with?('#')
350
-
351
- next unless SUPPORTED_TYPE.include?(type)
352
-
353
- yield marker, hosts.split(','), key_content, comment
354
- end
355
- end
356
- end
357
-
358
- def read_key(key_content)
359
- Net::SSH::Buffer.new(key_content.unpack("m*").first).read_key
360
- end
361
306
  end
362
307
  end
363
308
  end
@@ -16,11 +16,6 @@ module Net
16
16
  # ...
17
17
  # end
18
18
  class Command
19
- # The command line is run by the shell, so like OpenSSH 9.6 (CVE-2023-51385) we refuse
20
- # host and user names containing shell metacharacters rather than substituting them.
21
- INVALID_HOST_NAME = /\A-|[[:space:][:cntrl:]'`"$\\;&<>|(){},]/.freeze
22
- INVALID_USER_NAME = /\A-|[[:cntrl:]'`";&<>|(){}]|[[:space:]]-|\\\z/.freeze
23
-
24
19
  # The command line template
25
20
  attr_reader :command_line_template
26
21
 
@@ -46,13 +41,13 @@ module Net
46
41
  command_line = @command_line_template.gsub(/%(.)/) {
47
42
  case $1
48
43
  when 'h'
49
- validate_host_name!(host)
44
+ host
50
45
  when 'p'
51
- validate_port!(port)
46
+ port.to_s
52
47
  when 'r'
53
48
  remote_user = connection_options && connection_options[:remote_user]
54
49
  if remote_user
55
- validate_user_name!(remote_user)
50
+ remote_user
56
51
  else
57
52
  raise ArgumentError, "remote user name not available"
58
53
  end
@@ -122,26 +117,6 @@ module Net
122
117
  Process.kill('TERM', io.pid)
123
118
  Thread.new { io.close }
124
119
  end
125
-
126
- private
127
-
128
- def validate_host_name!(host)
129
- raise ArgumentError, "host name contains invalid characters: #{host.inspect}" if host.to_s.match?(INVALID_HOST_NAME)
130
-
131
- host
132
- end
133
-
134
- def validate_user_name!(user)
135
- raise ArgumentError, "user name contains invalid characters: #{user.inspect}" if user.to_s.match?(INVALID_USER_NAME)
136
-
137
- user
138
- end
139
-
140
- def validate_port!(port)
141
- raise ArgumentError, "port is not a number: #{port.inspect}" unless port.to_s.match?(/\A\d+\z/)
142
-
143
- port.to_s
144
- end
145
120
  end
146
121
  end
147
122
  end
@@ -1,4 +1,3 @@
1
- require 'shellwords'
2
1
  require 'uri'
3
2
  require 'net/ssh/proxy/command'
4
3
 
@@ -35,50 +34,20 @@ module Net
35
34
  # We cannot build the ProxyCommand template until we know if the :config
36
35
  # option was specified during `Net::SSH.start`.
37
36
  def build_proxy_command_equivalent(connection_options = nil)
38
- raise ArgumentError, "no jump host given" if jump_proxies.empty?
39
-
37
+ first_jump, extra_jumps = jump_proxies.split(",", 2)
40
38
  config = connection_options && connection_options[:config]
41
- uri, *extra_jumps = jump_proxies.split(",", -1).map { |jump| validate_jump!(jump) }
39
+ uri = URI.parse("ssh://#{first_jump}")
42
40
 
43
41
  template = "ssh".dup
44
- template << " -l #{escape(uri.user)}" if uri.user
45
- template << " -p #{uri.port}" if uri.port
46
- template << " -J #{escape(extra_jumps.map { |jump| jump_spec(jump) }.join(','))}" unless extra_jumps.empty?
47
- template << " -F #{Shellwords.escape(config)}" if config != true && config
42
+ template << " -l #{uri.user}" if uri.user
43
+ template << " -p #{uri.port}" if uri.port
44
+ template << " -J #{extra_jumps}" if extra_jumps
45
+ template << " -F #{config}" if config != true && config
48
46
  template << " -W %h:%p "
49
- template << escape(uri.host)
47
+ template << uri.host
50
48
 
51
49
  @command_line_template = template
52
50
  end
53
-
54
- private
55
-
56
- # Escape for the shell, and double any % so it survives the %h/%p
57
- # substitution done on the template in Command#open.
58
- def escape(value)
59
- Shellwords.escape(value).gsub("%", "%%")
60
- end
61
-
62
- # Only [user@]host[:port] is checked, so anything else URI accepts (a path,
63
- # query, fragment or password) must be refused before it reaches the shell.
64
- def validate_jump!(jump)
65
- uri = URI.parse("ssh://#{jump}")
66
- raise URI::InvalidURIError if uri.host.to_s.empty? || jump_spec(uri) != jump
67
-
68
- validate_user_name!(uri.user) if uri.user
69
- validate_host_name!(uri.host)
70
- validate_port!(uri.port) if uri.port
71
- uri
72
- rescue URI::InvalidURIError
73
- raise ArgumentError, "jump host is not [user@]host[:port]: #{jump.inspect}"
74
- end
75
-
76
- def jump_spec(uri)
77
- spec = uri.host.dup
78
- spec.prepend("#{uri.user}@") if uri.user
79
- spec << ":#{uri.port}" if uri.port
80
- spec
81
- end
82
51
  end
83
52
  end
84
53
  end
@@ -405,12 +405,6 @@ module Net
405
405
 
406
406
  # The callback used when an auth-agent channel is requested by the server.
407
407
  def auth_agent_channel(session, channel, packet)
408
- # Like OpenSSH, only relay the local agent when forwarding was requested (GHSA-qpg5-fx6p-8cq3).
409
- unless session.options[:forward_agent] || @agent_forwarded
410
- error { "server tried agent forwarding, which was not requested; refusing" }
411
- raise Net::SSH::ChannelOpenFailed.new(1, "agent forwarding was not requested")
412
- end
413
-
414
408
  info { "opening auth-agent channel" }
415
409
  channel[:invisible] = true
416
410
 
@@ -1,4 +1,4 @@
1
- require 'rbnacl'
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 = [0, 0, 0, sequence_number].pack("NNNN")
54
+ iv_data = packet_iv(sequence_number)
47
55
  @chacha_main.iv = iv_data
48
- poly_key = @chacha_main.update(([0] * 32).pack('C32'))
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 += @poly.auth(poly_key, 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 = [0, 0, 0, sequence_number].pack("NNNN")
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 = [0, 0, 0, sequence_number].pack("NNNN")
79
+ iv_data = packet_iv(sequence_number)
72
80
  @chacha_main.iv = iv_data
73
- poly_key = @chacha_main.update(([0] * 32).pack('C32'))
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
- begin
79
- ok = @poly.verify(poly_key, mac, data[0..])
80
- raise Net::SSH::Exception, "corrupted hmac detected #{name}" unless ok
81
- rescue RbNaCl::BadAuthenticatorError
82
- raise Net::SSH::Exception, "corrupted hmac detected #{name}"
83
- end
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
- 16
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
- "chacha20-poly1305@openssh.com"
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 poly1305 support which requires optinal dependency rbnacl
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
- return [klass.key_length, klass.block_size] unless klass.nil?
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