diff options
author | Miklós Fazekas <mfazekas@szemafor.com> | 2021-08-10 10:04:46 +0200 |
---|---|---|
committer | GitHub <noreply@github.com> | 2021-08-10 10:04:46 +0200 |
commit | 355670ee82932608817c2e686ed7d10b1fca9d1c (patch) | |
tree | 394dce856b86a737ba5b2e4aa4e8432d35a32629 | |
parent | ba3a689713a4635c16eb9a35c5c62be34be60e8b (diff) | |
parent | 344cc90c83eda9d2e1f4d5d272b1c325717db633 (diff) | |
download | net-ssh-355670ee82932608817c2e686ed7d10b1fca9d1c.tar.gz |
Merge pull request #833 from net-ssh/mfazekas/cert-base-auth
Cert based host auth
-rw-r--r-- | .rubocop.yml | 7 | ||||
-rw-r--r-- | CHANGES.txt | 5 | ||||
-rw-r--r-- | lib/net/ssh.rb | 2 | ||||
-rw-r--r-- | lib/net/ssh/known_hosts.rb | 81 | ||||
-rw-r--r-- | lib/net/ssh/transport/algorithms.rb | 2 | ||||
-rw-r--r-- | lib/net/ssh/verifiers/always.rb | 8 | ||||
-rw-r--r-- | test/integration/common.rb | 21 | ||||
-rw-r--r-- | test/integration/playbook.yml | 10 | ||||
-rw-r--r-- | test/integration/test_cert_host_auth.rb | 94 | ||||
-rw-r--r-- | test/integration/test_channel.rb | 2 | ||||
-rw-r--r-- | test/test_known_hosts.rb | 38 | ||||
-rw-r--r-- | test/transport/test_algorithms.rb | 12 |
12 files changed, 257 insertions, 25 deletions
diff --git a/.rubocop.yml b/.rubocop.yml index 973c53d..6ac7e73 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,3 +1,10 @@ +AllCops: + Exclude: + - 'tryout/**/*' + - "vendor/**/.*" + - "vendor/**/*" + NewCops: enable + inherit_from: .rubocop_todo.yml Style/DoubleNegation: diff --git a/CHANGES.txt b/CHANGES.txt index d511bf9..8fb56bf 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,3 +1,8 @@ +=== 6.3.0 beta1 + + * Support cert based host key auth, fix asterisk in known_hosts [#833] + * Support kex dh-group14-sha256 [#795] + === 6.2.0 rc1 === 6.2.0 beta1 diff --git a/lib/net/ssh.rb b/lib/net/ssh.rb index 8b8b7b9..bbae909 100644 --- a/lib/net/ssh.rb +++ b/lib/net/ssh.rb @@ -121,7 +121,7 @@ module Net # * :forward_agent => set to true if you want the SSH agent connection to # be forwarded # * :known_hosts => a custom object holding known hosts records. - # It must implement #search_for and add in a similiar manner as KnownHosts. + # It must implement #search_for and `add` in a similiar manner as KnownHosts. # * :global_known_hosts_file => the location of the global known hosts # file. Set to an array if you want to specify multiple global known # hosts files. Defaults to %w(/etc/ssh/ssh_known_hosts /etc/ssh/ssh_known_hosts2). diff --git a/lib/net/ssh/known_hosts.rb b/lib/net/ssh/known_hosts.rb index f3d773a..eb7d7a7 100644 --- a/lib/net/ssh/known_hosts.rb +++ b/lib/net/ssh/known_hosts.rb @@ -6,6 +6,64 @@ require 'net/ssh/authentication/ed25519_loader' module Net module SSH + module HostKeyEntries + # regular public key entry + class PubKey < Delegator + def initialize(key, comment: nil) # rubocop:disable Lint/MissingSuper + @key = key + @comment = comment + end + + def ssh_type + @key.ssh_type + end + + def ssh_types + [ssh_type] + end + + def to_blob + @key.to_blob + end + + def __getobj__ + Kernel.warn("Calling Net::SSH::Buffer methods on HostKeyEntries PubKey is deprecated") + @key + end + + def matches_key?(server_key) + @key.ssh_type == server_key.ssh_type && @key.to_blob == server_key.to_blob + end + end + + # @cert-authority entry + class CertAuthority + def ssh_types + %w[ + ecdsa-sha2-nistp256-cert-v01@openssh.com + ecdsa-sha2-nistp384-cert-v01@openssh.com + ecdsa-sha2-nistp521-cert-v01@openssh.com + ssh-ed25519-cert-v01@openssh.com + ssh-rsa-cert-v01@openssh.com + ssh-rsa-cert-v00@openssh.com + ] + end + + def initialize(key, comment: nil) + @key = key + @comment = comment + end + + def matches_key?(server_key) + if ssh_types.include?(server_key.ssh_type) + server_key.signature_valid? && (server_key.signature_key.to_blob == @key.to_blob) + else + false + end + end + end + end + # Represents the result of a search in known hosts # see search_for class HostKeys @@ -127,7 +185,13 @@ module Net File.open(source) do |file| file.each_line do |line| - hosts, type, key_content = line.split(' ') + if line.start_with?('@') + marker, hosts, type, key_content, comment = line.split(' ') + else + marker = nil + hosts, type, key_content, comment = line.split(' ') + end + # Skip empty line or one that is commented next if hosts.nil? || hosts.start_with?('#') @@ -142,7 +206,14 @@ module Net next unless found blob = key_content.unpack("m*").first - keys << Net::SSH::Buffer.new(blob).read_key + raw_key = Net::SSH::Buffer.new(blob).read_key + + keys << + if marker == "@cert-authority" + HostKeyEntries::CertAuthority.new(raw_key, comment: comment) + else + HostKeyEntries::PubKey.new(raw_key, comment: comment) + end end end @@ -152,11 +223,11 @@ module Net def match(host, pattern) if pattern.include?('*') || pattern.include?('?') # see man 8 sshd for pattern details - pattern_regexp = pattern.split('*').map do |x| - x.split('?').map do |y| + pattern_regexp = pattern.split('*', -1).map do |x| + x.split('?', -1).map do |y| Regexp.escape(y) end.join('.') - end.join('[^.]*') + end.join('.*') host =~ Regexp.new("\\A#{pattern_regexp}\\z") else diff --git a/lib/net/ssh/transport/algorithms.rb b/lib/net/ssh/transport/algorithms.rb index 7408d40..f0d8d15 100644 --- a/lib/net/ssh/transport/algorithms.rb +++ b/lib/net/ssh/transport/algorithms.rb @@ -278,7 +278,7 @@ module Net # existing known key for the host has preference. existing_keys = session.host_keys - host_keys = existing_keys.map { |key| key.ssh_type }.uniq + host_keys = existing_keys.flat_map { |key| key.respond_to?(:ssh_types) ? key.ssh_types : [key.ssh_type] }.uniq algorithms[:host_key].each do |name| host_keys << name unless host_keys.include?(name) end diff --git a/lib/net/ssh/verifiers/always.rb b/lib/net/ssh/verifiers/always.rb index 0f52a29..0c86589 100644 --- a/lib/net/ssh/verifiers/always.rb +++ b/lib/net/ssh/verifiers/always.rb @@ -21,9 +21,13 @@ module Net # If we found any matches, check to see that the key type and # blob also match. + found = host_keys.any? do |key| - key.ssh_type == arguments[:key].ssh_type && - key.to_blob == arguments[:key].to_blob + if key.respond_to?(:matches_key?) + key.matches_key?(arguments[:key]) + else + key.ssh_type == arguments[:key].ssh_type && key.to_blob == arguments[:key].to_blob + end end # If a match was found, return true. Otherwise, raise an exception diff --git a/test/integration/common.rb b/test/integration/common.rb index 73eadf8..b897ece 100644 --- a/test/integration/common.rb +++ b/test/integration/common.rb @@ -87,16 +87,17 @@ module IntegrationTestHelpers end end - def with_lines_as_tempfile(lines = [], add_pid = true, &block) + def with_lines_as_tempfile(lines = [], add_pid: true, debug: false, &block) Tempfile.open('sshd_config') do |f| - f.write(lines) + f.write(lines.join("\n")) pidpath = nil if add_pid pidpath = f.path + '.pid' - f.write("\nPIDFILE #{pidpath}") + f.write("\nPidFile #{pidpath}\n") end - # f.write("\nLogLevel DEBUG3") + f.write("\nLogLevel DEBUG3\n") if debug f.close + puts "CONFIG: #{f.path} PID: #{pidpath}" if debug yield(f.path, pidpath) end end @@ -106,20 +107,22 @@ module IntegrationTestHelpers end # @yield [pid, port] - def start_sshd_7_or_later(port = '2200', config: nil) + def start_sshd_7_or_later(port = '2200', config: nil, debug: false) pid = nil sshpidfile = nil if config - with_lines_as_tempfile(config) do |path, pidpath| - # puts "DEBUG - SSH LOG: #{path}-log.txt" + with_lines_as_tempfile(config, debug: debug) do |path, pidpath| + puts "DEBUG - SSH LOG: #{path}-log.txt config: #{path}" if debug raise "A leftover sshd is already running" if port_open?(port) - pid = spawn('sudo', '/opt/net-ssh-openssh/sbin/sshd', '-D', '-f', path, '-p', port) # '-E', "#{path}-log.txt") + extra_params = [] + extra_params = ['-E', "#{path}-log.txt"] if debug + pid = spawn('sudo', '/opt/net-ssh-openssh/sbin/sshd', '-D', '-f', path, '-p', port, *extra_params) sshpidfile = pidpath yield pid, port end else - with_lines_as_tempfile('') do |path, pidpath| + with_lines_as_tempfile(['']) do |path, pidpath| pid = spawn('sudo', '/opt/net-ssh-openssh/sbin/sshd', '-D', '-f', path, '-p', port) sshpidfile = pidpath yield pid, port diff --git a/test/integration/playbook.yml b/test/integration/playbook.yml index 75a1ee2..828dda2 100644 --- a/test/integration/playbook.yml +++ b/test/integration/playbook.yml @@ -92,16 +92,22 @@ lineinfile: dest='/etc/ssh/sshd_config' line='X11Forwarding no' regexp=X11Forwarding notify: restart sshd - name: sshd allow forward - lineinfile: dest='/etc/ssh/sshd_config' line='PasswordAuthentication=yes' regexp=PasswordAuthentication + lineinfile: dest='/etc/ssh/sshd_config' line='#PasswordAuthentication no' regexp='#?PasswordAuthentication.+no' + notify: restart sshd + - name: sshd allow forward + lineinfile: dest='/etc/ssh/sshd_config' line='PasswordAuthentication yes' regexp=PasswordAuthentication notify: restart sshd - name: put NET_SSH_RUN_INTEGRATION_TESTS=YES environment lineinfile: dest='/etc/environment' line='NET_SSH_RUN_INTEGRATION_TESTS=YES' - name: change dir in bashrc lineinfile: dest="{{homedir}}/.bashrc" owner="{{myuser}}" mode=0644 regexp='^cd ' line='cd /net-ssh' - - name: add host aliases + - name: add host aliases1 lineinfile: dest='/etc/hosts' owner='root' group='root' mode=0644 regexp='^127\.0\.0\.1\s+gateway.netssh' line='127.0.0.1 gateway.netssh' + - name: add host aliases2 + lineinfile: dest='/etc/hosts' owner='root' group='root' mode=0644 + regexp='^127\.0\.0\.1\s+one.hosts.netssh' line='127.0.0.1 one.hosts.netssh' - name: Update APT Cache apt: update_cache: yes diff --git a/test/integration/test_cert_host_auth.rb b/test/integration/test_cert_host_auth.rb new file mode 100644 index 0000000..fee5b2b --- /dev/null +++ b/test/integration/test_cert_host_auth.rb @@ -0,0 +1,94 @@ +require_relative 'common' +require 'fileutils' +require 'tmpdir' +require 'net/ssh' + +require 'timeout' + +# see Vagrantfile,playbook for env. +# we're running as net_ssh_1 user password foo +# and usually connecting to net_ssh_2 user password foo2pwd +class TestCertHostAuth < NetSSHTest + include IntegrationTestHelpers + + def setup_ssh_env(&block) + tmpdir do |dir| + cert_type = "rsa" + # cert_type = "ssh-ed25519" + host_key_type = "ecdsa" + # host_key_type = "ed25519" + + # create a cert, and sign the host key + @cert = "#{dir}/ca" + sh "rm -rf #{@cert} #{@cert}.pub" + sh "ssh-keygen -t #{cert_type} -N '' -C 'ca@hosts.netssh' -f #{@cert} #{debug ? '' : '-q'}" + FileUtils.cp "/etc/ssh/ssh_host_#{host_key_type}_key.pub", "#{dir}/one.hosts.netssh.pub" + Dir.chdir(dir) do + sh "ssh-keygen -s #{@cert} -h -I one.hosts.netssh -n one.hosts.netssh #{debug ? '' : '-q'} #{dir}/one.hosts.netssh.pub" + sh "ssh-keygen -L -f one.hosts.netssh-cert.pub" if debug + end + signed_host_key = "/etc/ssh/ssh_host_#{host_key_type}_key-cert.pub" + sh "sudo cp -f #{dir}/one.hosts.netssh-cert.pub #{signed_host_key}" + + # we don't use this for signing the cert + @badcert = "#{dir}/badca" + sh "rm -rf #{@badcert} #{@badcert}.pub" + sh "ssh-keygen -t #{cert_type} -N '' -C 'ca@hosts.netssh' -f #{@badcert} #{debug ? '' : '-q'}" + yield(cert_pub: "#{@cert}.pub", badcert_pub: "#{@badcert}.pub", signed_host_key: signed_host_key) + end + end + + def debug + false + end + + def test_host_should_match_when_host_key_was_signed_by_key + Tempfile.open('cert_kh') do |f| + setup_ssh_env do |params| + data = File.read(params[:cert_pub]) + f.write("@cert-authority [*.hosts.netssh]:2200 #{data}") + f.close + + config_lines = ["HostCertificate #{params[:signed_host_key]}"] + start_sshd_7_or_later(config: config_lines) do |_pid, port| + Timeout.timeout(500) do + # sleep 0.2 + # sh "ssh -v -i ~/.ssh/id_ed25519 one.hosts.netssh -o UserKnownHostsFile=#{f.path} -p 2200" + ret = Net::SSH.start("one.hosts.netssh", "net_ssh_1", password: 'foopwd', port: port, verify_host_key: :always, user_known_hosts_file: [f.path]) do |ssh| + ssh.exec! "echo 'foo'" + end + assert_equal "foo\n", ret + rescue SocketError, Errno::ECONNREFUSED, Errno::EHOSTUNREACH + sleep 0.25 + retry + end + end + end + end + end + + def test_with_other_pub_key_host_key_should_not_match + Tempfile.open('cert_kh') do |f| + setup_ssh_env do |params| + data = File.read(params[:badcert_pub]) + f.write("@cert-authority [*.hosts.netssh]:2200 #{data}") + f.close + + config_lines = ["HostCertificate #{params[:signed_host_key]}"] + start_sshd_7_or_later(config: config_lines) do |_pid, port| + Timeout.timeout(100) do + sleep 0.2 + assert_raises(Net::SSH::HostKeyMismatch) do + Net::SSH.start("one.hosts.netssh", "net_ssh_1", password: 'foopwd', port: port, verify_host_key: :always, user_known_hosts_file: [f.path]) do |ssh| + ssh.exec! "echo 'foo'" + end + end + rescue SocketError, Errno::ECONNREFUSED, Errno::EHOSTUNREACH + sleep 0.25 + retry + end + end + end + end + end +end diff --git a/test/integration/test_channel.rb b/test/integration/test_channel.rb index 46f5fd5..4d55b75 100644 --- a/test/integration/test_channel.rb +++ b/test/integration/test_channel.rb @@ -108,7 +108,7 @@ class TestChannel < NetSSHTest def test_channel_should_set_environment_variables_on_remote setup_ssh_env do - start_sshd_7_or_later(config: 'AcceptEnv foo baz') do |_pid, port| + start_sshd_7_or_later(config: ['AcceptEnv foo baz']) do |_pid, port| Timeout.timeout(20) do # We have our own sshd, give it a chance to come up before # listening. diff --git a/test/test_known_hosts.rb b/test/test_known_hosts.rb index fcd64fa..5bd7668 100644 --- a/test/test_known_hosts.rb +++ b/test/test_known_hosts.rb @@ -1,4 +1,4 @@ -require 'common' +require_relative './common' class TestKnownHosts < NetSSHTest def perform_test(source) @@ -109,7 +109,29 @@ class TestKnownHosts < NetSSHTest def test_search_for_with_hostname_not_matching_pattern_3 options = { user_known_hosts_file: path("known_hosts/misc") } keys = Net::SSH::KnownHosts.search_for('subsubdomain.subdomain.gitfoo.com',options) - assert_equal(0, keys.count) + assert_equal(1, keys.count) + end + + def test_asterisk_matches_multiple_dots + with_config_file(lines: ["*.git???.com #{sample_key}"]) do |path| + options = { user_known_hosts_file: path } + keys = Net::SSH::KnownHosts.search_for('subsubdomain.subdomain.gitfoo.com',options) + assert_equal(1, keys.count) + + keys = Net::SSH::KnownHosts.search_for('subsubdomain.subdomain.gitfoo2.com',options) + assert_equal(0, keys.count) + end + end + + def test_asterisk_matches_everything + with_config_file(lines: ["* #{sample_key}"]) do |path| + options = { user_known_hosts_file: path } + keys = Net::SSH::KnownHosts.search_for('subsubdomain.subdomain.gitfoo.com',options) + assert_equal(1, keys.count) + + keys = Net::SSH::KnownHosts.search_for('subsubdomain.subdomain.gitfoo2.com',options) + assert_equal(1, keys.count) + end end def test_search_for_then_add @@ -131,6 +153,18 @@ class TestKnownHosts < NetSSHTest File.join(File.dirname(__FILE__), relative_path) end + def sample_key + "ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEAq2A7hRGmdnm9tUDbO9IDSwBK6TbQa+PXYPCPy6rbTrTtw7PHkccKrpp0yVhp5HdEIcKr6pLlVDBfOLX9QUsyCOV0wzfjIJNlGEYsdlLJizHhbn2mUjvSAHQqZETYP81eFzLQNnPHt4EVVUh7VfDESU84KezmD5QlWpXLmvU31/yMf+Se8xhHTvKSCZIFImWwoG6mbUoWf9nzpIoaSjB+weqqUUmpaaasXVal72J+UX2B+2RPW3RcT0eOzQgqlJL3RKrTJvdsjE3JEAvGq3lGHSZXy28G3skua2SmVi/w4yCE6gbODqnTWlg7+wC604ydGXA8VJiS5ap43JXiUFFAaQ==" + end + + def with_config_file(lines: [], &block) + Tempfile.open('known_hosts') do |f| + f.write(lines.join("\n")) + f.close + yield(f.path) + end + end + def rsa_key key = OpenSSL::PKey::RSA.new if key.respond_to?(:set_key) diff --git a/test/transport/test_algorithms.rb b/test/transport/test_algorithms.rb index d79d446..cef1b19 100644 --- a/test/transport/test_algorithms.rb +++ b/test/transport/test_algorithms.rb @@ -46,7 +46,10 @@ module Transport end def test_constructor_with_known_hosts_reporting_known_host_key_should_use_that_host_key_type - Net::SSH::KnownHosts.expects(:search_for).with("net.ssh.test,127.0.0.1", {}).returns([stub("key", ssh_type: "ssh-dss")]) + Net::SSH::KnownHosts.expects(:search_for).with( + "net.ssh.test,127.0.0.1", + { user_known_hosts_file: "/dev/null", global_known_hosts_file: "/dev/null" } + ).returns([stub("key", ssh_type: "ssh-dss")]) assert_equal %w[ssh-dss] + ed_ec_host_keys + %w[ssh-rsa-cert-v01@openssh.com ssh-rsa-cert-v00@openssh.com ssh-rsa rsa-sha2-256 rsa-sha2-512], algorithms[:host_key] end @@ -438,7 +441,12 @@ module Transport end def transport(transport_options={}) - @transport ||= MockTransport.new(transport_options) + @transport ||= MockTransport.new( + { + user_known_hosts_file: '/dev/null', + global_known_hosts_file: '/dev/null' + }.merge(transport_options) + ) end end end |