Skip to content

Commit

Permalink
Merge pull request #13327 from chrisroberts/ssh-ecdsa
Browse files Browse the repository at this point in the history
Add support for ecdsa ssh keys
  • Loading branch information
chrisroberts authored Jan 11, 2024
2 parents 673f42b + 2d5c9c0 commit 588d7ec
Show file tree
Hide file tree
Showing 11 changed files with 409 additions and 45 deletions.
1 change: 1 addition & 0 deletions lib/vagrant.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

# 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))
Expand Down
4 changes: 4 additions & 0 deletions lib/vagrant/errors.rb
Original file line number Diff line number Diff line change
Expand Up @@ -863,6 +863,10 @@ class SSHKeyTypeNotSupported < VagrantError
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
Expand Down
76 changes: 76 additions & 0 deletions lib/vagrant/patches/net-ssh.rb
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion lib/vagrant/util.rb
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ module Remote
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'
Expand Down
165 changes: 150 additions & 15 deletions lib/vagrant/util/keypair.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,43 @@
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
#
Expand Down Expand Up @@ -95,6 +123,9 @@ def self.create(password=nil)
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.
Expand Down Expand Up @@ -140,19 +171,123 @@ def self.create(password=nil)
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
# 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

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)})"
# 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

VALID_TYPES[type].create(password)
# 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
Loading

0 comments on commit 588d7ec

Please sign in to comment.