diff --git a/CHANGELOG.md b/CHANGELOG.md index dbc4645..1863a3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ # kitchen-oci CHANGELOG +# 2.1.0 +- feat: add support for `ED25519` ssh keys. + # 2.0.0 - feat: set default value for `are_legacy_imds_endpoints_disabled` to `true` > BREAKING CHANGE: This overrides the default value to `true` in accordance with latest [OCI secuirty guidelines](https://docs.oracle.com/en-us/iaas/Content/Compute/Tasks/gettingmetadata.htm) diff --git a/README.md b/README.md index 6db6e1a..b5b450d 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,7 @@ The following driver parameters are common to both instance types, but are not r - `oci_config`, Hash of additional `OCI::Config` settings. Allows you to test without an oci config file [[more](#use-without-oci-config-file)] - `ssh_keypath`, SSH public key (default: `~/.ssh/id_rsa.pub`) - `ssh_keygen`, Automatically generate the rsa key pair for an instance (default: `false`) [[more](#ssh-keygen)] + - `ssh_keytype`, Specify the type of ssh key to generate (valid values: `rsa`, `ed25519`) (default: `rsa`) [[more](#ssh-keygen)] - `post_create_script`, run a script on an instance after deployment [[more](#post-create-script)] - `post_create_reboot`, reboot the instance after instance creation (default: `false`) - `proxy_url`, Connect via the specified proxy URL [[more](#proxy-support)] @@ -309,7 +310,8 @@ These scripts are executed by the user specified as the transport username (most ## SSH Keygen The driver can generate an ssh key pair for an instance during creation. In order to turn this feature on, add the `ssh_keygen` property to the `driver` and set the value to `true`. This can be set in the `driver` section on a -per-platform or per-suite basis, but can also be enabled globally for the entire kitchen.yml in the top-level `driver` section. +per-platform or per-suite basis, but can also be enabled globally for the entire kitchen.yml in the top-level `driver` section. By default, the driver will create a 4096 bit RSA key. For additional security, the driver can also create +a `ED25519` OpenSSH key. In order to enable this, utilize the `ssh_keytype: ed25519` parameter (see example below). Ensure that the `transport` section does not contain a path to a private key (the `ssh_key` property). If the `transport` has a value in `ssh_key` property, this will mismatch with the key pair that the driver will create causing your instance creation to be stuck in an endless loop waiting for `transport` to receive a confirmed ssh connection. @@ -321,6 +323,7 @@ Upon instance termination (`kitchen destroy`), the generated key pair will be re ```yml driver: ssh_keygen: true + ssh_keytype: ed25519 transport: username: opc diff --git a/kitchen-oci.gemspec b/kitchen-oci.gemspec index d4dcd1a..16ef24a 100644 --- a/kitchen-oci.gemspec +++ b/kitchen-oci.gemspec @@ -41,6 +41,7 @@ Gem::Specification.new do |spec| # rubocop: disable Metrics/BlockLength } spec.add_dependency "oci", "~> 2.18" spec.add_dependency "test-kitchen" + spec.add_dependency "ed25519" spec.add_development_dependency "bundler" spec.add_development_dependency "chefstyle" spec.add_development_dependency "pry" diff --git a/lib/kitchen/driver/oci.rb b/lib/kitchen/driver/oci.rb index b7bf3ea..3a8a335 100644 --- a/lib/kitchen/driver/oci.rb +++ b/lib/kitchen/driver/oci.rb @@ -34,10 +34,15 @@ module Driver # @author Stephen Pearson class Oci < Kitchen::Driver::Base require_relative "oci_version" + require_relative "oci/validations" require_relative "oci/mixin/actions" require_relative "oci/mixin/models" require_relative "oci/mixin/volumes" + include Kitchen::Driver::Oci::Mixin::Actions + include Kitchen::Driver::Oci::Mixin::Models + include Kitchen::Driver::Oci::Mixin::Volumes + plugin_version Kitchen::Driver::OCI_VERSION kitchen_driver_api_version 2 @@ -66,6 +71,7 @@ class Oci < Kitchen::Driver::Base default_keypath = File.expand_path(File.join(%w{~ .ssh id_rsa.pub})) default_config :ssh_keypath, default_keypath default_config :ssh_keygen, false + default_config :ssh_keytype, "rsa" default_config :post_create_script, nil default_config :proxy_url, nil default_config :user_data, nil @@ -95,28 +101,6 @@ class Oci < Kitchen::Driver::Base # dbaas configs default_config :dbaas, {} - validations[:instance_type] = lambda do |attr, val, driver| - validation_error("[:#{attr}] #{val} is not a valid instance_type. must be either compute or dbaas.", driver) unless %w{compute dbaas}.include?(val.downcase) - end - - validations[:nsg_ids] = lambda do |attr, val, driver| - unless val.nil? - validation_error("[:#{attr}] list cannot be longer than 5 items", driver) if val.length > 5 - end - end - - validations[:volumes] = lambda do |attr, val, driver| - val.each do |vol_attr| - unless ["iscsi", "paravirtual", nil].include?(vol_attr[:type]) - validation_error("[:#{attr}][:type] #{vol_attr[:type]} is not a valid volume type for #{vol_attr[:name]}", driver) - end - end - end - - def self.validation_error(message, driver) - raise UserError, "#{driver.class}<#{driver.instance.name}>#config#{message}" - end - # Creates an instance. # (see Kitchen::Driver::Base#create) # @@ -145,23 +129,6 @@ def destroy(state) detatch_and_delete_volumes(state, oci, api) terminate(state, inst) end - - private - - include Kitchen::Driver::Oci::Mixin::Actions - include Kitchen::Driver::Oci::Mixin::Models - include Kitchen::Driver::Oci::Mixin::Volumes - - # Creates the OCI config and API clients. - # - # @param action [Symbol] the name of the method that called this method. - # @return [Oci::Config, Oci::Api] - def auth(action) - oci = Oci::Config.new(config) - api = Oci::Api.new(oci.config, config) - oci.compartment if action == :create - [oci, api] - end end end end diff --git a/lib/kitchen/driver/oci/instance.rb b/lib/kitchen/driver/oci/instance.rb index cd584f6..7dc49ab 100644 --- a/lib/kitchen/driver/oci/instance.rb +++ b/lib/kitchen/driver/oci/instance.rb @@ -28,8 +28,10 @@ class Instance < Oci # rubocop:disable Metrics/ClassLength require_relative "models/compute" require_relative "models/dbaas" require_relative "instance/common" + require_relative "mixin/ssh_keys" include CommonLaunchDetails + include Mixin::SshKeys def initialize(opts = {}) super() @@ -97,59 +99,6 @@ def public_ip_allowed? !subnet.prohibit_public_ip_on_vnic end - # Returns the location of the public ssh key. - # - # @return [String] - def public_key_file - if config[:ssh_keygen] - "#{config[:kitchen_root]}/.kitchen/.ssh/#{config[:instance_name]}_rsa.pub" - else - config[:ssh_keypath] - end - end - - # Returns the name of the private key file. - # - # @return [String] - def private_key_file - public_key_file.gsub(".pub", "") - end - - # Generates an RSA key pair to be used to SSH to the instance and updates the state with the full path to the private key. - def gen_key_pair - FileUtils.mkdir_p("#{config[:kitchen_root]}/.kitchen/.ssh") - rsa_key = OpenSSL::PKey::RSA.new(4096) - write_private_key(rsa_key) - write_public_key(rsa_key) - state.store(:ssh_key, private_key_file) - end - - # Writes the private key. - # - # @param rsa_key [OpenSSL::PKey::RSA] the generated RSA key. - def write_private_key(rsa_key) - File.open(private_key_file, "wb") { |k| k.write(rsa_key.to_pem) } - File.chmod(0600, private_key_file) - end - - # Writes the encoded private key as a public key. - # - # @param rsa_key [OpenSSL::PKey::RSA] the generated RSA key. - def write_public_key(rsa_key) - File.open(public_key_file, "wb") { |k| k.write("ssh-rsa #{encode_private_key(rsa_key)} #{config[:instance_name]}") } - File.chmod(0600, public_key_file) - end - - # Encodes the private key. - # - # @param rsa_key [OpenSSL::PKey::RSA] the generated RSA key. - def encode_private_key(rsa_key) - prefix = "#{[7].pack("N")}ssh-rsa" - exponent = rsa_key.e.to_s(0) - modulus = rsa_key.n.to_s(0) - ["#{prefix}#{exponent}#{modulus}"].pack("m0") - end - # Generates a random password. # # @param special_chars [Array] an array of special characters to include in the random password. diff --git a/lib/kitchen/driver/oci/instance/dbaas.rb b/lib/kitchen/driver/oci/instance/dbaas.rb index 1263f8d..fe036ca 100644 --- a/lib/kitchen/driver/oci/instance/dbaas.rb +++ b/lib/kitchen/driver/oci/instance/dbaas.rb @@ -69,7 +69,7 @@ def node_count end # Adds the ssh_public_keys property to the launch_details. - def pubkey + def ssh_public_keys result = [] result << read_public_key launch_details.ssh_public_keys = result diff --git a/lib/kitchen/driver/oci/mixin/actions.rb b/lib/kitchen/driver/oci/mixin/actions.rb index 1d8498f..921fba3 100644 --- a/lib/kitchen/driver/oci/mixin/actions.rb +++ b/lib/kitchen/driver/oci/mixin/actions.rb @@ -25,6 +25,17 @@ module Mixin # # @author Justin Steele module Actions + # Creates the OCI config and API clients. + # + # @param action [Symbol] the name of the method that called this method. + # @return [Oci::Config, Oci::Api] + def auth(action) + oci = Oci::Config.new(config) + api = Oci::Api.new(oci.config, config) + oci.compartment if action == :create + [oci, api] + end + # Launches an instance. # # @param state [Hash] (see Kitchen::StateFile) diff --git a/lib/kitchen/driver/oci/mixin/ssh_keys.rb b/lib/kitchen/driver/oci/mixin/ssh_keys.rb new file mode 100644 index 0000000..8019797 --- /dev/null +++ b/lib/kitchen/driver/oci/mixin/ssh_keys.rb @@ -0,0 +1,212 @@ +# frozen_string_literal: true + +# +# Author:: Justin Steele () +# +# Copyright (C) 2025, Stephen Pearson +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +module Kitchen + module Driver + class Oci + module Mixin + # SSH key generation mixins. + # + # @author Justin Steele + module SshKeys + # Read in the public ssh key. + # + # @return [String] + def read_public_key + if config[:ssh_keygen] + logger.info("Generating public/private #{config[:ssh_keytype]} key pair") + generate_keys + end + File.readlines(public_key_file).first.chomp + end + + # The location of the private ssh key. + # + # @return [String] + def private_key_file + public_key_file.gsub(".pub", "") + end + + # The location of the public ssh key. + # + # @return [String] + def public_key_file + if config[:ssh_keygen] + "#{config[:kitchen_root]}/.kitchen/.ssh/#{config[:instance_name]}_#{config[:ssh_keytype]}.pub" + else + config[:ssh_keypath] + end + end + + # Algorithm used when encoding the private and public keys. + # + # @return [String] + def algorithm + "ssh-#{config[:ssh_keytype]}" + end + + # Generates the public/private key pair in the format specified in the config. + def generate_keys + FileUtils.mkdir_p("#{config[:kitchen_root]}/.kitchen/.ssh") + extend SshKeys.const_get(config[:ssh_keytype].upcase) + generate_key_pair + end + + # Mixins required to generate a RSA key pair. + # + # @author Justin Steele + module RSA + # Generates an RSA key pair to be used to SSH to the instance and updates the state with the full path to the private key. + def generate_key_pair + rsa_key = OpenSSL::PKey::RSA.new(4096) + write_private_key(rsa_key) + write_public_key(rsa_key) + state.store(:ssh_key, private_key_file) + end + + # Writes the private key. + # + # @param rsa_key [OpenSSL::PKey::RSA] the generated RSA key. + def write_private_key(rsa_key) + File.open(private_key_file, "wb") { |k| k.write(rsa_key.to_pem) } + File.chmod(0600, private_key_file) + end + + # Writes the encoded private key as a public key. + # + # @param rsa_key [OpenSSL::PKey::RSA] the generated RSA key. + def write_public_key(rsa_key) + public_key = ["#{[7].pack("N")}#{algorithm}#{rsa_key.e.to_s(0)}#{rsa_key.n.to_s(0)}"].pack("m0") + File.open(public_key_file, "wb") { |k| k.write("#{algorithm} #{public_key} #{config[:instance_name]}") } + File.chmod(0600, public_key_file) + end + end + + # Mixins required to generate a ED25519 key pair. + # + # @author Justin Steele + module ED25519 + require "ed25519" + require "securerandom" unless defined?(SecureRandom) + + # Generates an ED25519 key pair to be used to SSH to the instance and updates the state with the full path to the private key. + def generate_key_pair + signing_key = Ed25519::SigningKey.generate + private_seed = signing_key.to_bytes + public_key = signing_key.verify_key.to_bytes + write_private_key(public_key, private_seed) + write_public_key(public_key) + state.store(:ssh_key, private_key_file) + end + + # Packs a string as SSH “string” (4-byte len + bytes). + # + # @param str [String] the portion of the key being packed. + # @return [String] + def pack_string(str) + [str.bytesize].pack("N") + str + end + + # Writes the encoded private key. + # + # @param public_key [String] the byte representation of the Ed25519::VerifyKey. + # @param private_seed [String] the byte representation of the Ed25519::SigningKey. + def write_private_key(public_key, private_seed) + private_key = encode_private_key(public_key, private_seed) + File.open(private_key_file, "w") { |f| f.write(private_key) } + File.chmod(0600, private_key_file) + end + + # Writes the encoded public key. + # + # @param public_key [String] the byte representation of the Ed25519::VerifyKey. + def write_public_key(public_key) + pub_key = encode_public_key(public_key) + File.open(public_key_file, "w") { |f| f.write(pub_key) } + File.chmod(0600, public_key_file) + end + + # Encodes the private key. + # + # @param public_key [String] the byte representation of the Ed25519::VerifyKey. + # @param private_seed [String] the byte representation of the Ed25519::SigningKey. + # @return [String] + def encode_private_key(public_key, private_seed) + buf = header(public_key) + priv = private_section(public_key, private_seed) + padlen = (-priv.bytesize) & 7 + priv << (1..padlen).to_a.pack("C*") + buf << pack_string(priv) + b64 = Base64.strict_encode64(buf).scan(/.{1,70}/).join("\n") + "-----BEGIN OPENSSH PRIVATE KEY-----\n#{b64}\n-----END OPENSSH PRIVATE KEY-----\n" + end + + # "openssh-key-v1" header: magic, cipher/kdf, nkeys, and the public key blob(s). + # + # @param public_key [String] the byte representation of the Ed25519::VerifyKey. + # @return [String] + def header(public_key) + [ + "openssh-key-v1\0", + pack_string("none"), # ciphername + pack_string("none"), # kdfname + pack_string(""), # kdfoptions + [1].pack("N"), # nkeys + pack_string(pub_blob(public_key)), + ].join + end + + # Correct private section: checkints, key fields, comment, padding + # + # @param public_key [String] the byte representation of the Ed25519::VerifyKey. + # @param private_seed [String] the byte representation of the Ed25519::SigningKey. + # @return [String] + def private_section(public_key, private_seed) + checkint = SecureRandom.random_number(2**32) + [ + [checkint, checkint].pack("N*"), + pack_string(algorithm), + pack_string(public_key), + pack_string(private_seed + public_key), + pack_string(config[:instance_name] || ""), + ].join + end + + # Encodes the public key. + # + # @param public_key [String] the byte representation of the Ed25519::VerifyKey. + # @return [String] + def encode_public_key(public_key) + blob = [algorithm.bytesize].pack("N") + algorithm + [public_key.bytesize].pack("N") + public_key + [algorithm, Base64.strict_encode64(blob), config[:instance_name]].compact.join(" ") + end + + # SSH public key blob: string keytype + string key (32 bytes). + # + # @param public_key [String] the byte representation of the Ed25519::VerifyKey. + # @return [String] + def pub_blob(public_key) + pack_string(algorithm) + pack_string(public_key) + end + end + end + end + end + end +end diff --git a/lib/kitchen/driver/oci/models/compute.rb b/lib/kitchen/driver/oci/models/compute.rb index d4e0b6f..97822f5 100644 --- a/lib/kitchen/driver/oci/models/compute.rb +++ b/lib/kitchen/driver/oci/models/compute.rb @@ -210,23 +210,12 @@ def create_vnic_details(name) ) end - # Read in the public ssh key. - # - # @return [String] - def pubkey - if config[:ssh_keygen] - logger.info("Generating public/private rsa key pair") - gen_key_pair - end - File.readlines(public_key_file).first.chomp - end - # Add our special sauce to the instance metadata to be executed by cloud-init. def metadata md = {} inject_powershell config[:custom_metadata]&.each { |k, v| md.store(k, v) } - md.store("ssh_authorized_keys", pubkey) unless config[:setup_winrm] + md.store("ssh_authorized_keys", read_public_key) unless config[:setup_winrm] md.store("user_data", user_data) if user_data? md end diff --git a/lib/kitchen/driver/oci/models/dbaas.rb b/lib/kitchen/driver/oci/models/dbaas.rb index be1a189..f6d6149 100644 --- a/lib/kitchen/driver/oci/models/dbaas.rb +++ b/lib/kitchen/driver/oci/models/dbaas.rb @@ -111,17 +111,6 @@ def hostname_prefix def long_hostname_suffix [random_string(25 - hostname_prefix.length), random_string(3)].compact.join("-") end - - # Read in the public ssh key. - # - # @return [String] - def read_public_key - if config[:ssh_keygen] - logger.info("Generating public/private rsa key pair") - gen_key_pair - end - File.readlines(public_key_file).first.chomp - end end end end diff --git a/lib/kitchen/driver/oci/validations.rb b/lib/kitchen/driver/oci/validations.rb new file mode 100644 index 0000000..71f2097 --- /dev/null +++ b/lib/kitchen/driver/oci/validations.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +# +# Author:: Justin Steele () +# +# Copyright (C) 2025, Stephen Pearson +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +module Kitchen + module Driver + class Oci + # Execute the defined config validations + # + # @param message [String] the message to be output to explain the validation. + # @param driver [Kitchen::Driver] the instance of the driver. + # @raise [UserError] + def self.validation_error(message, driver) + raise UserError, "#{driver.class}<#{driver.instance.name}>#config#{message}" + end + + # Coerces config values to standardized formats. + # + # @param instance [Kitchen::Instance] + def finalize_config!(instance) + super + %i{instance_type ssh_keytype}.each do |k| + config[k] = config[k].downcase + end + end + + validations[:instance_type] = lambda do |attr, val, driver| + validation_error("[:#{attr}] #{val} is not a valid instance_type. must be either compute or dbaas.", driver) unless %w{compute dbaas}.include?(val.downcase) + end + + validations[:nsg_ids] = lambda do |attr, val, driver| + unless val.nil? + validation_error("[:#{attr}] list cannot be longer than 5 items", driver) if val.length > 5 + end + end + + validations[:volumes] = lambda do |attr, val, driver| + val.each do |vol_attr| + unless ["iscsi", "paravirtual", nil].include?(vol_attr[:type]) + validation_error("[:#{attr}][:type] #{vol_attr[:type]} is not a valid volume type for #{vol_attr[:name]}", driver) + end + end + end + + validations[:ssh_keytype] = lambda do |attr, val, driver| + validation_error("[:#{attr}] #{val} is not a supported ssh key type.", driver) unless %w{rsa ed25519}.include?(val.downcase) + end + end + end +end diff --git a/lib/kitchen/driver/oci_version.rb b/lib/kitchen/driver/oci_version.rb index aead3aa..9a50b30 100644 --- a/lib/kitchen/driver/oci_version.rb +++ b/lib/kitchen/driver/oci_version.rb @@ -22,6 +22,6 @@ module Driver # Version string for Oracle OCI Kitchen driver # # @author Stephen Pearson () - OCI_VERSION = "2.0.0" + OCI_VERSION = "2.1.0" end end