Merge pull request #13327 from chrisroberts/ssh-ecdsa

Add support for ecdsa ssh keys
This commit is contained in:
Chris Roberts 2024-01-10 16:09:59 -08:00 committed by GitHub
commit 588d7ecd6e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 411 additions and 47 deletions

View File

@ -5,6 +5,7 @@ require "log4r"
# Add patches to log4r to support trace level
require "vagrant/patches/log4r"
require "vagrant/patches/net-ssh"
# Set our log levels and include trace
require 'log4r/configurator'
Log4r::Configurator.custom_levels(*(["TRACE"] + Log4r::Log4rConfig::LogLevels))

View File

@ -863,6 +863,10 @@ module Vagrant
error_key(:ssh_key_type_not_supported)
end
class SSHKeyTypeNotSupportedByServer < VagrantError
error_key(:ssh_key_type_not_supported_by_server)
end
class SSHNoExitStatus < VagrantError
error_key(:ssh_no_exit_status)
end

View File

@ -0,0 +1,76 @@
# Copyright (c) HashiCorp, Inc.
# SPDX-License-Identifier: BUSL-1.1
require "net/ssh"
require "net/ssh/buffer"
# Set the version requirement for when net-ssh should be patched
NET_SSH_PATCH_REQUIREMENT = Gem::Requirement.new(">= 7.0.0", "< 7.2.2")
# This patch provides support for properly loading ECDSA private keys
if NET_SSH_PATCH_REQUIREMENT.satisfied_by?(Gem::Version.new(Net::SSH::Version::STRING))
Net::SSH::Buffer.class_eval do
def vagrant_read_private_keyblob(type)
case type
when /^ecdsa\-sha2\-(\w*)$/
curve_name_in_type = $1
curve_name_in_key = read_string
unless curve_name_in_type == curve_name_in_key
raise Net::SSH::Exception, "curve name mismatched (`#{curve_name_in_key}' with `#{curve_name_in_type}')"
end
public_key_oct = read_string
priv_key_bignum = read_bignum
begin
curvename = OpenSSL::PKey::EC::CurveNameAlias[curve_name_in_key]
group = OpenSSL::PKey::EC::Group.new(curvename)
point = OpenSSL::PKey::EC::Point.new(group, OpenSSL::BN.new(public_key_oct, 2))
priv_bn = OpenSSL::BN.new(priv_key_bignum, 2)
asn1 = OpenSSL::ASN1::Sequence(
[
OpenSSL::ASN1::Integer.new(OpenSSL::BN.new(0)),
OpenSSL::ASN1::Sequence.new(
[
OpenSSL::ASN1::ObjectId("id-ecPublicKey"),
OpenSSL::ASN1::ObjectId(curvename)
]
),
OpenSSL::ASN1::OctetString.new(
OpenSSL::ASN1::Sequence.new(
[
OpenSSL::ASN1::Integer.new(OpenSSL::BN.new(1)),
OpenSSL::ASN1::OctetString.new(priv_bn.to_s(2)),
OpenSSL::ASN1::ASN1Data.new(
[
OpenSSL::ASN1::BitString.new(point.to_octet_string(:uncompressed)),
], 1, :CONTEXT_SPECIFIC,
)
]
).to_der
)
]
)
key = OpenSSL::PKey::EC.new(asn1.to_der)
return key
rescue OpenSSL::PKey::ECError
raise NotImplementedError, "unsupported key type `#{type}'"
end
else
netssh_read_private_keyblob(type)
end
end
alias_method :netssh_read_private_keyblob, :read_private_keyblob
alias_method :read_private_keyblob, :vagrant_read_private_keyblob
end
OpenSSL::PKey::EC::Point.class_eval do
include Net::SSH::Authentication::PubKeyFingerprint
def to_pem
"#{ssh_type} #{self.to_bn.to_s(2)}"
end
end
end

View File

@ -36,7 +36,7 @@ module Vagrant
autoload :IO, 'vagrant/util/io'
autoload :IPV4Interfaces, 'vagrant/util/ipv4_interfaces'
autoload :IsPortOpen, 'vagrant/util/is_port_open'
autoload :KeyPair, 'vagrant/util/key_pair'
autoload :Keypair, 'vagrant/util/keypair'
autoload :LineBuffer, 'vagrant/util/line_buffer'
autoload :LineEndingHelpers, 'vagrant/util/line_ending_helpers'
autoload :LoggingFormatter, 'vagrant/util/logging_formatter'

View File

@ -10,15 +10,43 @@ require "vagrant/util/retryable"
module Vagrant
module Util
class Keypair
# Magic string header
AUTH_MAGIC = "openssh-key-v1".freeze
# Header of private key file content
PRIVATE_KEY_START = "-----BEGIN OPENSSH PRIVATE KEY-----\n".freeze
# Footer of private key file content
PRIVATE_KEY_END = "-----END OPENSSH PRIVATE KEY-----\n".freeze
# Check if provided key is a supported key type
#
# @param [Symbol] key Key type to check
# @return [Boolean] key type is supported
def self.valid_type?(key)
VALID_TYPES.keys.include?(key)
end
# @return [Array<Symbol>] list of supported key types
def self.available_types
PREFER_KEY_TYPES.values
end
# Create a new keypair
#
# @param [String] password Password for the key or nil for no password (only supported for rsa type)
# @param [Symbol] type Key type to generate
# @return [Array<String, String, String>] Public key, openssh private key, openssh public key with comment
def self.create(password=nil, type: :rsa)
if !VALID_TYPES.key?(type)
raise ArgumentError,
"Invalid key type requested (supported types: #{available_types.map(&:inspect).join(", ")})"
end
VALID_TYPES[type].create(password)
end
class Ed25519
# Magic string header
AUTH_MAGIC = "openssh-key-v1".freeze
# Key type identifier
KEY_TYPE = "ssh-ed25519".freeze
# Header of private key file content
PRIVATE_KEY_START = "-----BEGIN OPENSSH PRIVATE KEY-----\n".freeze
# Footer of private key file content
PRIVATE_KEY_END = "-----END OPENSSH PRIVATE KEY-----\n".freeze
# Encodes given string
#
@ -95,6 +123,9 @@ module Vagrant
class Rsa
extend Retryable
# Key type identifier
KEY_TYPE = "ssh-rsa"
# Creates an SSH keypair and returns it.
#
# @param [String] password Password for the key, or nil for no password.
@ -140,19 +171,123 @@ module Vagrant
end
end
# Supported key types.
VALID_TYPES = {ed25519: Ed25519, rsa: Rsa}.freeze
# Ordered mapping of openssh key type name to lookup name
PREFER_KEY_TYPES = {"ssh-ed25519".freeze => :ed25519, "ssh-rsa".freeze => :rsa}.freeze
def self.create(password=nil, type: :rsa)
if !VALID_TYPES.key?(type)
raise ArgumentError,
"Invalid key type requested (supported types: #{VALID_TYPES.keys.map(&:inspect)})"
# Base class for Ecdsa type keys to subclass
class Ecdsa
# Encodes given string
#
# @param [String] s String to encode
# @return [String]
def self.string(s)
[s.length].pack("N") + s
end
VALID_TYPES[type].create(password)
# Encodes given string with padding to block size
#
# @param [String] s String to encode
# @param [Integer] blocksize Defined block size
# @return [String]
def self.padded_string(s, blocksize)
pad = blocksize - (s.length % blocksize)
string(s + Array(1..pad).pack("c*"))
end
# Creates an ed25519 SSH key pair
# @return [Array<String, String, String>] Public key, openssh private key, openssh public key with comment
# @note Password support was not included as it's not actively used anywhere. If it ends up being
# something that's needed, it can be revisited
def self.create(password=nil)
if password
raise NotImplementedError,
"Ecdsa key pair generation does not support passwords"
end
# Generate the key
base_key = OpenSSL::PKey::EC.generate(self.const_get(:OPENSSL_CURVE))
# Define the comment used for the key
comment = "vagrant"
# Grab the raw public key
public_key = base_key.public_key.to_bn.to_s(2)
# Encode the public key for use building the openssh private key
encoded_public_key = string(self.const_get(:KEY_TYPE)) + string(self.const_get(:OPENSSH_CURVE)) + string(public_key)
# Format the public key into the openssh public key format for writing
openssh_public_key = "#{self.const_get(:KEY_TYPE)} #{Base64.encode64(encoded_public_key).gsub("\n", "")} #{comment}"
pk_value = base_key.private_key.to_s(2)
# Pad the start of the key if required
if pk_value.length % 8 == 0
pk_value = "\0#{pk_value}"
end
# Agent encoded private key is used when building the openssh private key
# (https://datatracker.ietf.org/doc/html/draft-miller-ssh-agent#section-4.2.3)
# (https://dnaeon.github.io/openssh-private-key-binary-format/)
agent_private_key = [
([SecureRandom.random_number((2**32)-1)] * 2).pack("NN"), # checkint, random uint32 value, twice (used for encryption verification)
encoded_public_key, # includes the key type and public key
string(pk_value), # private key
string(comment), # comment for the key
].join
# Build openssh private key data (https://github.com/openssh/openssh-portable/blob/master/PROTOCOL.key)
private_key = [
AUTH_MAGIC + "\0", # Magic string
string("none"), # cipher name, no encryption, so none
string("none"), # kdf name, no encryption, so none
string(""), # kdf options/data, no encryption, so empty string
[1].pack("N"), # Number of keys (just one)
string(encoded_public_key), # The public key
padded_string(agent_private_key, 8) # Private key encoded with agent rules, padded for 8 byte block size
].join
# Create the openssh private key content
openssh_private_key = [
PRIVATE_KEY_START,
Base64.encode64(private_key),
PRIVATE_KEY_END,
].join
return [public_key, openssh_private_key, openssh_public_key]
end
end
class Ecdsa256 < Ecdsa
KEY_TYPE = "ecdsa-sha2-nistp256".freeze
OPENSSH_CURVE = "nistp256".freeze
OPENSSL_CURVE = "prime256v1".freeze
end
class Ecdsa384 < Ecdsa
KEY_TYPE = "ecdsa-sha2-nistp384".freeze
OPENSSH_CURVE = "nistp384".freeze
OPENSSL_CURVE = "secp384r1".freeze
end
class Ecdsa521 < Ecdsa
KEY_TYPE = "ecdsa-sha2-nistp521".freeze
OPENSSH_CURVE = "nistp521".freeze
OPENSSL_CURVE = "secp521r1".freeze
end
# Supported key types.
VALID_TYPES = {
ecdsa256: Ecdsa256,
ecdsa384: Ecdsa384,
ecdsa521: Ecdsa521,
ed25519: Ed25519,
rsa: Rsa
}.freeze
# Ordered mapping of openssh key type name to lookup name. The
# order defined here is based on preference. Note that ecdsa
# ordering is based on performance
PREFER_KEY_TYPES = {
Ed25519::KEY_TYPE => :ed25519,
Ecdsa256::KEY_TYPE => :ecdsa256,
Ecdsa521::KEY_TYPE => :ecdsa521,
Ecdsa384::KEY_TYPE => :ecdsa384,
Rsa::KEY_TYPE => :rsa,
}.freeze
end
end
end

View File

@ -113,6 +113,8 @@ module VagrantPlugins
raise
rescue Vagrant::Errors::SSHKeyTypeNotSupported
raise
rescue Vagrant::Errors::SSHKeyTypeNotSupportedByServer
raise
rescue Vagrant::Errors::SSHKeyBadOwner
raise
rescue Vagrant::Errors::SSHKeyBadPermissions
@ -188,25 +190,56 @@ module VagrantPlugins
@machine.guest.capability?(:remove_public_key)
raise Vagrant::Errors::SSHInsertKeyUnsupported if !cap
# Check for supported key type
key_type = catch(:key_type) do
begin
Vagrant::Util::Keypair::PREFER_KEY_TYPES.each do |type_name, type|
throw :key_type, type if supports_key_type?(type_name)
key_type = machine_config_ssh.key_type
begin
# If the key type is set to `:auto` check for supported type. Otherwise
# ensure that the key type is supported by the guest
if key_type == :auto
key_type = catch(:key_type) do
begin
Vagrant::Util::Keypair::PREFER_KEY_TYPES.each do |type_name, type|
throw :key_type, type if supports_key_type?(type_name)
end
nil
rescue => err
@logger.warn("Failed to check key types server supports: #{err}")
nil
end
end
@logger.debug("Detected key type for new private key: #{key_type}")
# If no key type was discovered, default to rsa
if key_type.nil?
@logger.debug("Failed to detect supported key type in: #{supported_key_types.join(", ")}")
available_types = supported_key_types.map { |t|
next if !Vagrant::Util::Keypair::PREFER_KEY_TYPES.key?(t)
"#{t} (#{Vagrant::Util::Keypair::PREFER_KEY_TYPES[t]})"
}.compact.join(", ")
raise Vagrant::Errors::SSHKeyTypeNotSupportedByServer,
requested_key_type: ":auto",
available_key_types: available_types
end
else
type_name = Vagrant::Util::Keypair::PREFER_KEY_TYPES.key(key_type)
if !supports_key_type?(type_name)
available_types = supported_key_types.map { |t|
next if !Vagrant::Util::Keypair::PREFER_KEY_TYPES.key?(t)
"#{t} (#{Vagrant::Util::Keypair::PREFER_KEY_TYPES[t]})"
}.compact.join(", ")
raise Vagrant::Errors::SSHKeyTypeNotSupportedByServer,
requested_key_type: "#{type_name} (#{key_type})",
available_key_types: available_types
end
nil
rescue => err
@logger.warn("Failed to check key types server supports: #{err}")
nil
end
end
@logger.debug("Detected key type for new private key: #{key_type}")
# If no key type was discovered, default to rsa
if key_type.nil?
@logger.debug("Failed to detect supported key type, defaulting to rsa")
key_type = :rsa
rescue ServerDataError
@logger.warn("failed to load server data for key type check")
if key_type.nil? || key_type == :auto
@logger.warn("defaulting key type to :rsa due to failed server data loading")
key_type = :rsa
end
end
@logger.info("Creating new ssh keypair (type: #{key_type.inspect})")
@ -788,6 +821,8 @@ module VagrantPlugins
protected
class ServerDataError < StandardError; end
# Check if server supports given key type
#
# @param [String, Symbol] type Key type
@ -798,21 +833,31 @@ module VagrantPlugins
if @connection.nil?
raise Vagrant::Errors::SSHNotReady
end
supported_key_types.include?(type.to_s)
end
def supported_key_types
if @connection.nil?
raise Vagrant::Errors::SSHNotReady
end
server_data = @connection.
transport&.
algorithms&.
instance_variable_get(:@server_data)
if server_data.nil?
@logger.warn("No server data available for key type support check")
return false
raise ServerDataError, "no data available"
end
if !server_data.is_a?(Hash)
@logger.warn("Server data is not expected type (expecting Hash, got #{server_data.class})")
return false
raise ServerDataError, "unexpected type encountered (expecting Hash, got #{server_data.class})"
end
@logger.debug("server data used for host key support check: #{server_data.inspect}")
server_data[:host_key].include?(type.to_s)
@logger.debug("server supported key type list: #{server_data[:host_key]}")
server_data[:host_key]
end
end
end

View File

@ -15,6 +15,7 @@ module VagrantPlugins
attr_accessor :password
attr_accessor :insert_key
attr_accessor :keys_only
attr_accessor :key_type
attr_accessor :paranoid
attr_accessor :verify_host_key
attr_accessor :compression
@ -33,6 +34,7 @@ module VagrantPlugins
@password = UNSET_VALUE
@insert_key = UNSET_VALUE
@keys_only = UNSET_VALUE
@key_type = UNSET_VALUE
@paranoid = UNSET_VALUE
@verify_host_key = UNSET_VALUE
@compression = UNSET_VALUE
@ -50,6 +52,7 @@ module VagrantPlugins
@password = nil if @password == UNSET_VALUE
@insert_key = true if @insert_key == UNSET_VALUE
@keys_only = true if @keys_only == UNSET_VALUE
@key_type = :auto if @key_type == UNSET_VALUE
@paranoid = false if @paranoid == UNSET_VALUE
@verify_host_key = :never if @verify_host_key == UNSET_VALUE
@compression = true if @compression == UNSET_VALUE
@ -96,6 +99,10 @@ module VagrantPlugins
rescue
# ignore
end
if @key_type
@key_type = @key_type.to_sym
end
end
# NOTE: This is _not_ a valid config validation method, since it
@ -140,6 +147,14 @@ module VagrantPlugins
given: @connect_timeout.to_s)
end
if @key_type != :auto && !Vagrant::Util::Keypair.valid_type?(@key_type)
errors << I18n.t(
"vagrant.config.ssh.connect_invalid_key_type",
given: @key_type.to_s,
supported: Vagrant::Util::Keypair.available_types.join(", ")
)
end
errors
end
end

View File

@ -1621,6 +1621,14 @@ en:
sometimes keys in your ssh-agent can interfere with this as well,
so verify the keys are valid there in addition to standard
file paths.
ssh_key_type_not_supported_by_server: |-
The private key you are attempting to generate is not supported by
the guest SSH server. Please use one of the available key types defined
below that is supported by the guest SSH server.
Requested: %{requested_key_type}
Available: %{available_key_types}
ssh_not_ready: |-
The provider for this Vagrant-managed machine is reporting that it
is not yet ready for SSH. Depending on your provider this can carry
@ -2065,6 +2073,8 @@ en:
`%{given}` type which cannot be converted to an Integer type.
connect_timeout_invalid_value: |-
The `connect_timeout` key only accepts values greater than 1 (received `%{given}`)
connect_invalid_key_type: |-
Invalid SSH key type set ('%{given}'). Supported types: %{supported}
triggers:
bad_trigger_type: |-
The type '%{type}' defined for trigger '%{trigger}' is not valid. Must be one of the following types: '%{types}'

View File

@ -13,6 +13,7 @@ describe VagrantPlugins::CommunicatorSSH::Communicator do
# SSH configuration information mock
let(:ssh) do
double("ssh",
key_type: :auto,
timeout: 1,
host: nil,
port: 5986,
@ -264,46 +265,48 @@ describe VagrantPlugins::CommunicatorSSH::Communicator do
allow(guest).to receive(:capability).with(:remove_public_key)
allow(connection).to receive(:transport).and_return(transport)
allow(algorithms).to receive(:instance_variable_get).with(:@server_data).and_return(server_data)
allow(communicator).to receive(:supported_key_types).and_raise(described_class.const_get(:ServerDataError))
end
after{ communicator.ready? }
it "should create a new key pair" do
expect(Vagrant::Util::Keypair).to receive(:create).
and_return([new_public_key, new_private_key, openssh])
communicator.ready?
end
it "should call the insert_public_key guest capability" do
expect(guest).to receive(:capability).with(:insert_public_key, openssh)
communicator.ready?
end
it "should write the new private key" do
expect(private_key_file).to receive(:write).with(new_private_key)
communicator.ready?
end
it "should call the set_ssh_key_permissions host capability" do
expect(host).to receive(:capability?).with(:set_ssh_key_permissions).and_return(true)
expect(host).to receive(:capability).with(:set_ssh_key_permissions, private_key_file)
communicator.ready?
end
it "should remove the default public key" do
expect(guest).to receive(:capability).with(:remove_public_key, any_args)
communicator.ready?
end
context "with server algorithm support data" do
context "when no key type matches are found" do
it "should default to rsa type" do
expect(Vagrant::Util::Keypair).to receive(:create).
with(type: :rsa).and_call_original
end
before do
allow(communicator).to receive(:supported_key_types).and_call_original
end
context "when rsa is the only match" do
let(:valid_key_types) { ["ssh-edsca", "ssh-rsa"] }
let(:valid_key_types) { ["ssh-ecdsa", "ssh-rsa"] }
it "should use rsa type" do
expect(Vagrant::Util::Keypair).to receive(:create).
with(type: :rsa).and_call_original
communicator.ready?
end
end
@ -313,27 +316,69 @@ describe VagrantPlugins::CommunicatorSSH::Communicator do
it "should use ed25519 type" do
expect(Vagrant::Util::Keypair).to receive(:create).
with(type: :ed25519).and_call_original
communicator.ready?
end
end
context "when ed25519 is the only match" do
let(:valid_key_types) { ["ssh-edsca", "ssh-ed25519"] }
let(:valid_key_types) { ["ssh-ecdsa", "ssh-ed25519"] }
it "should use ed25519 type" do
expect(Vagrant::Util::Keypair).to receive(:create).
with(type: :ed25519).and_call_original
communicator.ready?
end
end
context "with key_type set as :auto in configuration" do
let(:valid_key_types) { ["ssh-ed25519", "ssh-rsa"] }
before { allow(ssh).to receive(:key_type).and_return(:auto) }
it "should use the preferred ed25519 key type" do
expect(Vagrant::Util::Keypair).to receive(:create).
with(type: :ed25519).and_call_original
communicator.ready?
end
context "when no supported key type is detected" do
let(:valid_key_types) { ["fake-type", "other-fake-type"] }
it "should raise an error" do
expect { communicator.ready? }.to raise_error(Vagrant::Errors::SSHKeyTypeNotSupportedByServer)
end
end
end
context "with key_type set as :ecdsa521 in configuration" do
let(:valid_key_types) { ["ssh-ed25519", "ssh-rsa", "ecdsa-sha2-nistp521", "ecdsa-sha2-nistp256"] }
before { allow(ssh).to receive(:key_type).and_return(:ecdsa521) }
it "should use the requested key type" do
expect(Vagrant::Util::Keypair).to receive(:create).
with(type: :ecdsa521).and_call_original
communicator.ready?
end
context "when requested key type is not supported" do
let(:valid_key_types) { ["ssh-ed25519", "ssh-rsa", "ecdsa-sha2-nistp256"] }
it "should raise an error" do
expect { communicator.ready? }.to raise_error(Vagrant::Errors::SSHKeyTypeNotSupportedByServer)
end
end
end
end
context "when an error is encountered getting server data" do
before do
expect(communicator).to receive(:supported_key_types).and_call_original
expect(connection).to receive(:transport).and_raise(StandardError)
end
it "should default to rsa key" do
expect(Vagrant::Util::Keypair).to receive(:create).
with(type: :rsa).and_call_original
communicator.ready?
end
end
end

View File

@ -44,6 +44,33 @@ describe VagrantPlugins::Kernel_V2::SSHConnectConfig do
end
end
describe "#key_type" do
it "defaults to :auto" do
subject.finalize!
expect(subject.key_type).to eq(:auto)
end
it "should allow supported key type" do
subject.key_type = :ed25519
subject.finalize!
errors = subject.validate(machine)
expect(errors).to be_empty
end
it "should not allow unsupported key type" do
subject.key_type = :unknown_type
subject.finalize!
errors = subject.validate(machine)
expect(errors).not_to be_empty
end
it "should convert string values to symbol" do
subject.key_type = "ecdsa521"
subject.finalize!
expect(subject.key_type).to eq(:ecdsa521)
end
end
describe "#config" do
let(:config_file) { "/path/to/config" }

View File

@ -90,6 +90,12 @@ defaults are typically fine, but you can fine tune whatever you would like.
- `config.ssh.keys_only` (boolean) - Only use Vagrant-provided SSH private keys (do not use
any keys stored in ssh-agent). The default value is `true`.
- `config.ssh.key_type` (string, symbol) - The SSH key type that should be used when generating
a new key to replace the default insecure key. Supported values are: `:ed25519`, `:ecdsa256`,
`:ecdsa384`, `:ecdsa521`, `:rsa`, and `:auto`. When the value is set to `:auto`, Vagrant will
automatically pick a type based on what is supported by the guest SSH server. The default
value is `:auto`.
- `config.ssh.paranoid` (boolean) - Perform strict host-key verification. The default value is
`false`.