Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
name: CI
on:
pull_request:
push: { branches: master }
push:
branches:
- master
jobs:
test:
runs-on: ubuntu-22.04
Expand Down Expand Up @@ -42,8 +44,8 @@ jobs:
gem install bundler ${{ (startsWith(matrix.ruby-version, '2.6.') || startsWith(matrix.ruby-version, '2.7.')) && '-v 2.4.22' || '' }}
bundle config set path 'vendor/bundle'
bundle config set --local path 'vendor/bundle'
bundle install --jobs 4 --retry 3 --path vendor/bundle
BUNDLE_GEMFILE=./Gemfile.noed25519 bundle install --jobs 4 --retry 3 --path vendor/bundle
bundle install --jobs 4 --retry 3
BUNDLE_GEMFILE=./Gemfile.noed25519 bundle install --jobs 4 --retry 3
env:
BUNDLE_PATH: vendor/bundle

Expand Down
53 changes: 38 additions & 15 deletions lib/net/ssh/authentication/ed25519.rb
Original file line number Diff line number Diff line change
Expand Up @@ -64,25 +64,48 @@ def self.read(datafull, password)

len = buffer.read_long

keylen, blocksize, ivlen = CipherFactory.get_lengths(ciphername, iv_len: true)
raise ArgumentError.new("Private key len:#{len} is not a multiple of #{blocksize}") if
((len < blocksize) || ((blocksize > 0) && (len % blocksize) != 0))

if kdfname == 'bcrypt'
salt = kdfopts.read_string
rounds = kdfopts.read_long

raise "BCryptPbkdf is not implemented for jruby" if RUBY_PLATFORM == "java"

key = BCryptPbkdf::key(password, salt, keylen + ivlen, rounds)
raise DecryptError.new("BCyryptPbkdf failed", encrypted_key: true) unless key
if ciphername == 'none'
cipher = Transport::IdentityCipher
else
key = '\x00' * (keylen + ivlen)
cipher = OpenSSL::Cipher.new(CipherFactory::SSH_TO_OSSL[ciphername])
keylen = cipher.key_len
ivlen = cipher.iv_len
blocksize = cipher.block_size

raise ArgumentError.new("Private key len:#{len} is not a multiple of #{blocksize}") if
((len < blocksize) || ((blocksize > 0) && (len % blocksize) != 0))

if kdfname == 'bcrypt'
salt = kdfopts.read_string
rounds = kdfopts.read_long

raise "BCryptPbkdf is not implemented for jruby" if RUBY_PLATFORM == "java"

key = BCryptPbkdf::key(password, salt, keylen + ivlen, rounds)
raise DecryptError.new("BCryptPbkdf failed", encrypted_key: true) unless key
else
key = '\x00' * (keylen + ivlen)
end

cipher.decrypt
cipher.key = key[0...keylen]
cipher.iv = key[keylen...keylen + ivlen]
cipher.padding = 0
end
Comment on lines 67 to 94
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tested manually with AES-CBC, AES-GCM, and unencrypted keys. This doesn't yet work with ChaCha20-Poly1305; it looks like the corresponding class defines key_length to be 64, which is the bytes of key material required to maintain two ciphers.

While that may be necessary for transport, it's not for decrypting a private key. We may be better off interrogating the OpenSSL::Cipher#key_len (and iv_len) directly instead of using CipherFactory.get_lengths above.


cipher = CipherFactory.get(ciphername, key: key[0...keylen], iv: key[keylen...keylen + ivlen], decrypt: true)
encrypted_data = buffer.remainder_as_buffer.to_s

# TODO: test with chacha poly
decoded = if cipher.authenticated?
ciphertext = encrypted_data[0...-16]
auth_tag = encrypted_data[-16..]
cipher.auth_tag = auth_tag
cipher.auth_data = ''
cipher.update(ciphertext)
else
cipher.update(encrypted_data)
end

decoded = cipher.update(buffer.remainder_as_buffer.to_s)
decoded << cipher.final

decoded = Net::SSH::Buffer.new(decoded)
Expand Down
4 changes: 4 additions & 0 deletions lib/net/ssh/transport/chacha20_poly1305_cipher.rb
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,10 @@ def self.block_size
def self.key_length
64
end

def self.iv_len
12
end
end
end
end
Expand Down
10 changes: 9 additions & 1 deletion lib/net/ssh/transport/cipher_factory.rb
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ class CipherFactory
"aes128-ctr" => ::OpenSSL::Cipher.ciphers.include?("aes-128-ctr") ? "aes-128-ctr" : "aes-128-ecb",
'cast128-ctr' => 'cast5-ecb',

'aes128-gcm@openssh.com' => 'aes-128-gcm',
'aes256-gcm@openssh.com' => 'aes-256-gcm',
'chacha20-poly1305@openssh.com' => 'chacha20-poly1305',

'none' => 'none'
}

Expand Down Expand Up @@ -100,7 +104,11 @@ def self.get(name, options = {})
# if :iv_len option is supplied the third return value will be ivlen
def self.get_lengths(name, options = {})
klass = SSH_TO_CLASS[name]
return [klass.key_length, klass.block_size] unless klass.nil?
unless klass.nil?
result = [klass.key_length, klass.block_size]
result << klass.iv_len if options[:iv_len]
return result
end

ossl_name = SSH_TO_OSSL[name]
if ossl_name.nil? || ossl_name == "none"
Expand Down
2 changes: 1 addition & 1 deletion lib/net/ssh/transport/gcm_cipher.rb
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ def self.block_size
# N_MIN minimum nonce (IV) length 12 octets
# N_MAX maximum nonce (IV) length 12 octets
#
def iv_len
def self.iv_len
12
end

Expand Down
4 changes: 4 additions & 0 deletions lib/net/ssh/transport/identity_cipher.rb
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@ def reset
def implicit_mac?
false
end

def authenticated?
false
end
end
end
end
Expand Down
68 changes: 68 additions & 0 deletions test/authentication/test_open_ssh_private_key_loader.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
unless ENV['NET_SSH_NO_ED25519']

require_relative '../common'
require 'net/ssh/authentication/ed25519_loader'
require 'net/ssh/key_factory'
require 'base64'

module Authentication
class TestOpenSSHPrivateKeyLoader < NetSSHTest
def setup
raise "No ED25519 set NET_SSH_NO_ED25519 to ignore this test" unless Net::SSH::Authentication::ED25519Loader::LOADED
end

def test_aes_cbc_key
key = <<~PRIVATEKEY
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAACmFlczI1Ni1jYmMAAAAGYmNyeXB0AAAAGAAAABAkbe7i7M
lqGCSgPgr+ohv1AAAAGAAAAAEAAAAzAAAAC3NzaC1lZDI1NTE5AAAAIHl0AyaA/DuN/ZYd
7gbSDA1uCALeRaz2j/72tKkJQ6mrAAAAkIDyuOAcJhooRGOI1Vo3EWCaVWgQI7yT+Up2D8
AOZ/xcOlcdrnSoVAPjYbeNGePuhShnSvzt3/ffzk8OAMkmVIfOukzQ1xRlySeFASWzXpZR
gJ4xdOyaURZ2zGeie29WOWJfeNCf/sKrtE8GonVW85iLVBBM1tDga9ta2Dq872OGFS7/qi
VCs5bN8YByGKbuxA==
-----END OPENSSH PRIVATE KEY-----
PRIVATEKEY
pwd = 'test'

privkey = Net::SSH::Authentication::ED25519::OpenSSHPrivateKeyLoader.read(key, pwd)
assert_kind_of(Net::SSH::Authentication::ED25519::PrivKey, privkey)
end

def test_aes256_gcm_key
key = <<~PRIVATEKEY
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAAFmFlczI1Ni1nY21Ab3BlbnNzaC5jb20AAAAGYmNyeXB0AA
AAGAAAABCCj7iTVdMrx15cLyopqOpcAAAAGAAAAAEAAAAzAAAAC3NzaC1lZDI1NTE5AAAA
IPDBX9jvz2qS9ZFU+F9x+vL+ef7rH8VPwCn6IJc4CaGhAAAAkEyIM5bCRB50LPxP9D4rtD
EdReGh+wsN6/84u4Efw1QjRm/O6LJzJEDl3LV8ntDMWBqBN0q6OJL3eqICWYjAjOdPHLkl
qmKYhrT+eWDqS/1KihyO46HkfKOWSM2fOQPcjqVsjSTJ3CV5oVkptuLB6ak8e/mmzplywv
MjSA8aFdrtUGDJhpT7z46xURMJarvDkqvJZbw4PkmYWlfNRu3dXAI=
-----END OPENSSH PRIVATE KEY-----
PRIVATEKEY
pwd = 'test'

privkey = Net::SSH::Authentication::ED25519::OpenSSHPrivateKeyLoader.read(key, pwd)
assert_kind_of(Net::SSH::Authentication::ED25519::PrivKey, privkey)
end

def test_chacha20_poly1305_key
key = <<~PRIVATEKEY
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAAHWNoYWNoYTIwLXBvbHkxMzA1QG9wZW5zc2guY29tAAAABm
JjcnlwdAAAABgAAAAQgMsN42jlw2C+pMgTPx+suAAAABgAAAABAAAAMwAAAAtzc2gtZWQy
NTUxOQAAACCHThbU/SJU7ntvbok6ANB0ob4Q36gXQxUj40PDGJGw4AAAAJADmcQtG5SDxI
srhPwRMOUvwK3niQ6R/vxuHrAXiCt9oMymG2ALOmt08no/MVgxeQwKGGFgSzVjFaq6Nyzg
yWA5df/AxUK72z7cqUaGzyMWQ+N4pC1q5pOINIiDxtjUTgo2Nv3ZbNV8EBGeDYX95iTN5G
YHeAFEd6hZKLOSMUDcKdj1vkZClWTHZBNJtIg4a4ZlQ8/mSJCf7TBv9z1ibaOh
-----END OPENSSH PRIVATE KEY-----
PRIVATEKEY
pwd = 'test'

privkey = Net::SSH::Authentication::ED25519::OpenSSHPrivateKeyLoader.read(key, pwd)
assert_kind_of(Net::SSH::Authentication::ED25519::PrivKey, privkey)
end

def test_unencrypted_key; end
end
end
end
13 changes: 11 additions & 2 deletions test/integration/common.rb
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,18 @@ def sshd_8_or_later?
!!(`sshd -v 2>&1 |grep 'OpenSSH_'` =~ /OpenSSH_8./)
end

def ssh_keygen(file, type = 'rsa', password = '')
def ssh_keygen(file, type = 'rsa', password = '', cipher = nil)
sh "rm -rf #{file} #{file}.pub"
sh "ssh-keygen #{ssh_keygen_format} -q -f #{file} -t #{type} -N '#{password}'"
cmd_words = [
'ssh-keygen',
ssh_keygen_format,
'-q',
'-f', file,
'-t', type,
'-N', "'#{password}'"
]
cmd_words += ['-Z', cipher] if cipher
sh cmd_words.join(' ')
end

def ssh_keygen_format
Expand Down
17 changes: 16 additions & 1 deletion test/integration/test_ed25519_pkeys.rb
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,22 @@ def test_ssh_agent

def test_in_file_with_password
Dir.mktmpdir do |dir|
ssh_keygen "#{dir}/id_rsa_ed25519", "ed25519"
ssh_keygen "#{dir}/id_rsa_ed25519", "ed25519", "pwd"
set_authorized_key('net_ssh_1', "#{dir}/id_rsa_ed25519.pub")

# TODO: fix bug in net ssh which reads public key even if private key is there
sh "mv #{dir}/id_rsa_ed25519.pub #{dir}/id_rsa_ed25519.pub.hidden"

ret = Net::SSH.start("localhost", "net_ssh_1", { keys: "#{dir}/id_rsa_ed25519", passphrase: 'pwd' }) do |ssh|
ssh.exec! 'echo "hello from:$USER"'
end
assert_equal "hello from:net_ssh_1\n", ret
end
end

def test_in_file_with_password
Dir.mktmpdir do |dir|
ssh_keygen "#{dir}/id_rsa_ed25519", "ed25519", "pwd", "aes256-gcm@openssh.com"
set_authorized_key('net_ssh_1', "#{dir}/id_rsa_ed25519.pub")

# TODO: fix bug in net ssh which reads public key even if private key is there
Expand Down
33 changes: 33 additions & 0 deletions test/transport/test_cipher_factory.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,65 +11,98 @@ def self.if_supported?(name)

def test_lengths_for_none
assert_equal [0, 0], factory.get_lengths("none")
assert_equal [0, 0, 0], factory.get_lengths("none", iv_len: true)
assert_equal [0, 0], factory.get_lengths("bogus")
assert_equal [0, 0, 0], factory.get_lengths("bogus", iv_len: true)
end

def test_lengths_for_blowfish_cbc
assert_equal [16, 8], factory.get_lengths("blowfish-cbc")
assert_equal [16, 8, 8], factory.get_lengths("blowfish-cbc", iv_len: true)
end

if_supported?("idea-cbc") do
def test_lengths_for_idea_cbc
assert_equal [16, 8], factory.get_lengths("idea-cbc")
assert_equal [16, 8, 8], factory.get_lengths("idea-cbc", iv_len: true)
end
end

def test_lengths_for_rijndael_cbc
assert_equal [32, 16], factory.get_lengths("rijndael-cbc@lysator.liu.se")
assert_equal [32, 16, 16], factory.get_lengths("rijndael-cbc@lysator.liu.se", iv_len: true)
end

def test_lengths_for_cast128_cbc
assert_equal [16, 8], factory.get_lengths("cast128-cbc")
assert_equal [16, 8, 8], factory.get_lengths("cast128-cbc", iv_len: true)
end

def test_lengths_for_3des_cbc
assert_equal [24, 8], factory.get_lengths("3des-cbc")
assert_equal [24, 8, 8], factory.get_lengths("3des-cbc", iv_len: true)
end

def test_lengths_for_aes128_cbc
assert_equal [16, 16], factory.get_lengths("aes128-cbc")
assert_equal [16, 16, 16], factory.get_lengths("aes128-cbc", iv_len: true)
end

def test_lengths_for_aes192_cbc
assert_equal [24, 16], factory.get_lengths("aes192-cbc")
assert_equal [24, 16, 16], factory.get_lengths("aes192-cbc", iv_len: true)
end

def test_lengths_for_aes256_cbc
assert_equal [32, 16], factory.get_lengths("aes256-cbc")
assert_equal [32, 16, 16], factory.get_lengths("aes256-cbc", iv_len: true)
end

def test_lengths_for_3des_ctr
assert_equal [24, 8], factory.get_lengths("3des-ctr")
assert_equal [24, 8, 0], factory.get_lengths("3des-ctr", iv_len: true)
end

def test_lengths_for_aes128_ctr
assert_equal [16, 16], factory.get_lengths("aes128-ctr")
assert_equal [16, 16, 16], factory.get_lengths("aes128-ctr", iv_len: true)
end

def test_lengths_for_aes192_ctr
assert_equal [24, 16], factory.get_lengths("aes192-ctr")
assert_equal [24, 16, 16], factory.get_lengths("aes192-ctr", iv_len: true)
end

def test_lengths_for_aes256_ctr
assert_equal [32, 16], factory.get_lengths("aes256-ctr")
assert_equal [32, 16, 16], factory.get_lengths("aes256-ctr", iv_len: true)
end

def test_lengths_for_blowfish_ctr
assert_equal [16, 8], factory.get_lengths("blowfish-ctr")
assert_equal [16, 8, 0], factory.get_lengths("blowfish-ctr", iv_len: true)
end

def test_lengths_for_cast128_ctr
assert_equal [16, 8], factory.get_lengths("cast128-ctr")
assert_equal [16, 8, 0], factory.get_lengths("cast128-ctr", iv_len: true)
end

def test_lengths_for_aes128_gcm
assert_equal [16, 16], factory.get_lengths("aes128-gcm@openssh.com")
assert_equal [16, 16, 12], factory.get_lengths("aes128-gcm@openssh.com", iv_len: true)
end

def test_lengths_for_aes256_gcm
assert_equal [32, 16], factory.get_lengths("aes256-gcm@openssh.com")
assert_equal [32, 16, 12], factory.get_lengths("aes256-gcm@openssh.com", iv_len: true)
end

def test_lengths_for_chacha20_poly1305
skip "chacha20-poly1305 not loaded" unless Net::SSH::Transport::ChaCha20Poly1305CipherLoader::LOADED

assert_equal [16, 64], factory.get_lengths("chacha20-poly1305@openssh.com")
assert_equal [16, 64, 12], factory.get_lengths("chacha20-poly1305@openssh.com", iv_len: true)
end

BLOWFISH_CBC = "\210\021\200\315\240_\026$\352\204g\233\244\242x\332e\370\001\327\224Nv@9_\323\037\252kb\037\036\237\375]\343/y\037\237\312Q\f7]\347Y\005\275%\377\0010$G\272\250B\265Nd\375\342\372\025r6}+Y\213y\n\237\267\\\374^\346BdJ$\353\220Ik\023<\236&H\277=\225"
Expand Down
Loading