diff options
author | John McCrae <john.mccrae@progress.com> | 2022-02-01 11:09:06 -0800 |
---|---|---|
committer | GitHub <noreply@github.com> | 2022-02-01 11:09:06 -0800 |
commit | cdfc851fa0a5ca2c2ce598a403d41e17d13189b0 (patch) | |
tree | 6e25bc62810d6b4936635f9edde7c998de07beaa | |
parent | 6fb40bcfbe2be4b23581493e697756a52be84e70 (diff) | |
parent | 0baa4e8bfbae9d17465fd4fa1d23652e58bcef2f (diff) | |
download | chef-cdfc851fa0a5ca2c2ce598a403d41e17d13189b0.tar.gz |
Merge pull request #12426 from chef/jfm/win_certs_take2
-rw-r--r-- | Gemfile.lock | 7 | ||||
-rw-r--r-- | Rakefile | 19 | ||||
-rw-r--r-- | chef-universal-mingw32.gemspec | 1 | ||||
-rw-r--r-- | lib/chef/client.rb | 11 | ||||
-rw-r--r-- | lib/chef/http/authenticator.rb | 120 | ||||
-rw-r--r-- | lib/chef/mixin/powershell_exec.rb | 33 | ||||
-rw-r--r-- | lib/chef/win32/registry.rb | 6 | ||||
-rw-r--r-- | spec/functional/resource/dsc_script_spec.rb | 2 | ||||
-rw-r--r-- | spec/integration/client/client_spec.rb | 73 | ||||
-rw-r--r-- | spec/unit/client_spec.rb | 4 | ||||
-rw-r--r-- | spec/unit/mixin/powershell_exec_spec.rb | 10 | ||||
-rw-r--r-- | spec/unit/platform/query_helpers_spec.rb | 23 | ||||
-rw-r--r-- | spec/unit/util/dsc/local_configuration_manager_spec.rb | 2 |
13 files changed, 231 insertions, 80 deletions
diff --git a/Gemfile.lock b/Gemfile.lock index a7b2cc37ee..752d7a9ebb 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -71,6 +71,7 @@ PATH aws-sdk-s3 (~> 1.91) aws-sdk-secretsmanager (~> 1.46) chef-config (= 18.0.25) + chef-powershell (~> 1.0.12) chef-utils (= 18.0.25) chef-vault chef-zero (>= 14.0.11) @@ -164,6 +165,9 @@ GEM debug_inspector (>= 0.0.1) builder (3.2.4) byebug (11.1.3) + chef-powershell (1.0.13) + ffi (~> 1.15) + ffi-yajl (~> 2.4) chef-telemetry (1.1.1) chef-config concurrent-ruby (~> 1.0) @@ -438,7 +442,6 @@ GEM wmi-lite (1.0.5) PLATFORMS - arm64-darwin-20 ruby x64-mingw32 x86-mingw32 @@ -466,4 +469,4 @@ DEPENDENCIES webmock BUNDLED WITH - 2.2.32 + 2.3.5 @@ -99,25 +99,6 @@ task :register_eventlog do end end -desc "Copies powershell_exec related binaries from the latest built Habitat Packages" -task :update_chef_exec_dll do - raise "This task must be run on Windows since we are installing a Windows targeted package!" unless Gem.win_platform? - - require "mkmf" - raise "Unable to locate Habitat cli. Please install Habitat cli before invoking this task!" unless find_executable "hab" - - sh("hab pkg install chef/chef-powershell-shim") - sh("hab pkg install chef/chef-powershell-shim-x86") - x64 = `hab pkg path chef/chef-powershell-shim`.chomp.tr("\\", "/") - x86 = `hab pkg path chef/chef-powershell-shim-x86`.chomp.tr("\\", "/") - FileUtils.rm_rf(Dir["distro/ruby_bin_folder/AMD64/*"]) - FileUtils.rm_rf(Dir["distro/ruby_bin_folder/x86/*"]) - puts "Copying #{x64}/bin/* to distro/ruby_bin_folder/AMD64" - FileUtils.cp_r(Dir["#{x64}/bin/*"], "distro/ruby_bin_folder/AMD64") - puts "Copying #{x86}/bin/* to distro/ruby_bin_folder/x86" - FileUtils.cp_r(Dir["#{x86}/bin/*"], "distro/ruby_bin_folder/x86") -end - begin require "chefstyle" require "rubocop/rake_task" diff --git a/chef-universal-mingw32.gemspec b/chef-universal-mingw32.gemspec index 329b4f9d6b..bf7446ed02 100644 --- a/chef-universal-mingw32.gemspec +++ b/chef-universal-mingw32.gemspec @@ -15,6 +15,7 @@ gemspec.add_dependency "wmi-lite", "~> 1.0" gemspec.add_dependency "win32-taskscheduler", "~> 2.0" gemspec.add_dependency "iso8601", ">= 0.12.1", "< 0.14" # validate 0.14 when it comes out gemspec.add_dependency "win32-certstore", "~> 0.6.2" # 0.5+ required for specifying user vs. system store +gemspec.add_dependency "chef-powershell", "~> 1.0.12" # The guts of the powershell_exec code have been moved to its own gem, chef-powershell. It's part of the chef-powershell-shim repo. gemspec.extensions << "ext/win32-eventlog/Rakefile" gemspec.files += Dir.glob("{distro,ext}/**/*") diff --git a/lib/chef/client.rb b/lib/chef/client.rb index 7f184d7db4..5ec15fb582 100644 --- a/lib/chef/client.rb +++ b/lib/chef/client.rb @@ -229,7 +229,7 @@ class Chef start_profiling runlock = RunLock.new(Chef::Config.lockfile) - # TODO feels like acquire should have its own block arg for this + # TODO: feels like acquire should have its own block arg for this runlock.acquire # don't add code that may fail before entering this section to be sure to release lock begin @@ -637,7 +637,14 @@ class Chef # @api private # def register(client_name = node_name, config = Chef::Config) - if !config[:client_key] + if Chef::HTTP::Authenticator.detect_certificate_key(client_name) + if File.exists?(config[:client_key]) + logger.warn("WARNING - Client key #{client_name} is present on disk, ignoring that in favor of key stored in CertStore") + end + events.skipping_registration(client_name, config) + logger.trace("Client key #{client_name} is present in certificate repository - skipping registration") + config[:client_key] = "Cert:\\LocalMachine\\My\\chef-#{client_name}" + elsif !config[:client_key] events.skipping_registration(client_name, config) logger.trace("Client key is unspecified - skipping registration") elsif File.exists?(config[:client_key]) diff --git a/lib/chef/http/authenticator.rb b/lib/chef/http/authenticator.rb index 80b32be750..26413c283b 100644 --- a/lib/chef/http/authenticator.rb +++ b/lib/chef/http/authenticator.rb @@ -16,15 +16,20 @@ # limitations under the License. # +require "chef/mixin/powershell_exec" require_relative "auth_credentials" require_relative "../exceptions" +require_relative "../win32/registry" autoload :OpenSSL, "openssl" class Chef class HTTP class Authenticator - DEFAULT_SERVER_API_VERSION = "2".freeze + # cspell:disable-next-line + SOME_CHARS = "~!@#%^&*_-+=`|\\(){}[<]:;'>,.?/0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz".each_char.to_a + + extend Chef::Mixin::PowershellExec attr_reader :signing_key_filename attr_reader :raw_key @@ -83,8 +88,53 @@ class Chef @auth_credentials.client_name end + def detect_certificate_key(client_name) + self.class.detect_certificate_key(client_name) + end + + def check_certstore_for_key(client_name) + self.class.check_certstore_for_key(client_name) + end + + def retrieve_certificate_key(client_name) + self.class.retrieve_certificate_key(client_name) + end + + # Detects if a private key exists in a certificate repository like Keychain (macOS) or Certificate Store (Windows) + # + # @param client_name - we're using the node name to store and retrieve any keys + # Returns true if a key is found, false if not. False will trigger a registration event which will lead to a certificate based key being created + # + def self.detect_certificate_key(client_name) + if ChefUtils.windows? + check_certstore_for_key(client_name) + else # generic return for Mac and LInux clients + false + end + end + + def self.check_certstore_for_key(client_name) + powershell_code = <<~CODE + $cert = Get-ChildItem -path cert:\\LocalMachine\\My -Recurse -Force | Where-Object { $_.Subject -Match "#{client_name}" } -ErrorAction Stop + if (($cert.HasPrivateKey -eq $true) -and ($cert.PrivateKey.Key.ExportPolicy -ne "NonExportable")) { + return $true + } + else{ + return $false + } + CODE + powershell_exec!(powershell_code).result + end + def load_signing_key(key_file, raw_key = nil) - if !!key_file + results = retrieve_certificate_key(Chef::Config[:node_name]) + + if key_file == nil? && raw_key == nil? + puts "\nNo key detected\n" + elsif !!results + # results variable can be 1 of 2 values - "False" or the contents of a key. + @raw_key = results + elsif !!key_file @raw_key = IO.read(key_file).strip elsif !!raw_key @raw_key = raw_key.strip @@ -104,6 +154,72 @@ class Chef raise Chef::Exceptions::InvalidPrivateKey, msg end + # takes no parameters. Checks for the password in the registry and returns it if there, otherwise returns false + def self.get_cert_password + @win32registry = Chef::Win32::Registry.new + path = "HKEY_LOCAL_MACHINE\\Software\\Progress\\Authentication" + # does the registry key even exist? + present = @win32registry.get_values(path) + if present.nil? || present.empty? + raise Chef::Exceptions::Win32RegKeyMissing + end + + present.each do |secret| + if secret[:name] == "PfxPass" + return secret[:data] + end + end + + # if we make it this far, that means there is no valid password in the Registry. Fail out to correct that. + raise Chef::Exceptions::Win32RegKeyMissing + + rescue Chef::Exceptions::Win32RegKeyMissing + # if we don't have a password, log that and generate one + Chef::Log.warn "Authentication Hive and value not present in registry, creating it now" + new_path = "HKEY_LOCAL_MACHINE\\Software\\Progress\\Authentication" + unless @win32registry.key_exists?(new_path) + @win32registry.create_key(new_path, true) + end + password = SOME_CHARS.sample(1 + rand(SOME_CHARS.count)).join[0...14] + values = { name: "PfxPass", type: :string, data: password } + @win32registry.set_value(new_path, values) + password + end + + def self.retrieve_certificate_key(client_name) + require "openssl" unless defined?(OpenSSL) + + if ChefUtils.windows? + + password = get_cert_password + + return false unless password + + if check_certstore_for_key(client_name) + powershell_code = <<~CODE + Try { + $my_pwd = ConvertTo-SecureString -String "#{password}" -Force -AsPlainText; + $cert = Get-ChildItem -path cert:\\LocalMachine\\My -Recurse | Where-Object { $_.Subject -match "#{client_name}" } -ErrorAction Stop; + $tempfile = [System.IO.Path]::GetTempPath() + "export_pfx.pfx"; + Export-PfxCertificate -Cert $cert -Password $my_pwd -FilePath $tempfile; + } + Catch { + return $false + } + CODE + my_result = powershell_exec!(powershell_code).result + + if !!my_result + pkcs = OpenSSL::PKCS12.new(File.binread(my_result["PSPath"].split("::")[1]), password) + ::File.delete(my_result["PSPath"].split("::")[1]) + return OpenSSL::PKey::RSA.new(pkcs.key.to_pem) + end + end + end + + false + end + def authentication_headers(method, url, json_body = nil, headers = nil) request_params = { http_method: method, diff --git a/lib/chef/mixin/powershell_exec.rb b/lib/chef/mixin/powershell_exec.rb index 75114220cb..8b11529957 100644 --- a/lib/chef/mixin/powershell_exec.rb +++ b/lib/chef/mixin/powershell_exec.rb @@ -15,9 +15,6 @@ # See the License for the specific language governing permissions and # limitations under the License. -require_relative "../powershell" -require_relative "../pwsh" - # The powershell_exec mixin provides in-process access to the PowerShell engine. # # powershell_exec is initialized with a string that should be set to the script @@ -94,35 +91,15 @@ require_relative "../pwsh" # - It is not possible to impersonate another user running powershell, the # credentials of the user running Chef Client are used. # +if ChefUtils.windows? + require "chef-powershell" +end class Chef module Mixin module PowershellExec - # Run a command under PowerShell via a managed (.NET) API. - # - # Requires: .NET Framework 4.0 or higher on the target machine. - # - # @param script [String] script to run - # @param interpreter [Symbol] the interpreter type, `:powershell` or `:pwsh` - # @param timeout [Integer, nil] timeout in seconds. - # @return [Chef::PowerShell] output - def powershell_exec(script, interpreter = :powershell, timeout: -1) - case interpreter - when :powershell - Chef::PowerShell.new(script, timeout: timeout) - when :pwsh - Chef::Pwsh.new(script, timeout: timeout) - else - raise ArgumentError, "Expected interpreter of :powershell or :pwsh" - end - end - - # The same as the #powershell_exec method except this will raise - # Chef::PowerShell::CommandFailed if the command fails - def powershell_exec!(script, interpreter = :powershell, **options) - cmd = powershell_exec(script, interpreter, **options) - cmd.error! - cmd + if ChefUtils.windows? + include ChefPowerShell::ChefPowerShellModule::PowerShellExec end end end diff --git a/lib/chef/win32/registry.rb b/lib/chef/win32/registry.rb index 4f7f2b2d52..ec7783815d 100644 --- a/lib/chef/win32/registry.rb +++ b/lib/chef/win32/registry.rb @@ -293,7 +293,11 @@ class Chef end def machine_architecture - node[:kernel][:machine].to_sym + if node.nil? + ::RbConfig::CONFIG["target_cpu"] == "x64" ? :x86_64 : :i386 + else + node[:kernel][:machine].to_sym + end end def assert_architecture! diff --git a/spec/functional/resource/dsc_script_spec.rb b/spec/functional/resource/dsc_script_spec.rb index 6aee29133f..3831486547 100644 --- a/spec/functional/resource/dsc_script_spec.rb +++ b/spec/functional/resource/dsc_script_spec.rb @@ -263,7 +263,7 @@ describe Chef::Resource::DscScript, :windows_powershell_dsc_only, :ruby64_only d dsc_test_resource.cwd(dsc_environment_fail_etc_directory) expect { dsc_test_resource.run_action(:run) - }.to raise_error(Chef::PowerShell::CommandFailed, /#{exception_message_signature}/) + }.to raise_error(ChefPowerShell::PowerShellExceptions::PowerShellCommandFailed, /#{exception_message_signature}/) end end end diff --git a/spec/integration/client/client_spec.rb b/spec/integration/client/client_spec.rb index af35eadb74..bec58eb682 100644 --- a/spec/integration/client/client_spec.rb +++ b/spec/integration/client/client_spec.rb @@ -4,6 +4,10 @@ require "chef/mixin/shell_out" require "tiny_server" require "tmpdir" require "chef-utils/dist" +require "chef/mixin/powershell_exec" + +# cspell:disable-next-line +SOME_CHARS = "~!@#%^&*_-+=`|\\(){}[<]:;'>,.?/0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz".each_char.to_a.freeze describe "chef-client" do @@ -31,8 +35,48 @@ describe "chef-client" do @server = @api = nil end + def install_certificate_in_store + if ChefUtils.windows? + powershell_exec!("New-SelfSignedCertificate -certstorelocation cert:\\localmachine\\my -Subject #{client_name} -FriendlyName #{client_name} -KeyExportPolicy Exportable") + end + end + + def create_registry_key + @win32registry = Chef::Win32::Registry.new + path = "HKEY_LOCAL_MACHINE\\Software\\Progress\\Authentication" + unless @win32registry.key_exists?(path) + @win32registry.create_key(path, true) + end + password = SOME_CHARS.sample(1 + rand(SOME_CHARS.count)).join[0...14] + values = { name: "PfxPass", type: :string, data: password } + @win32registry.set_value(path, values) + end + + def remove_certificate_from_store + powershell_exec! <<~EOH + Get-ChildItem -path cert:\\LocalMachine\\My -Recurse -Force | Where-Object { $_.Subject -Match "#{client_name}" } -ErrorAction Stop | Remove-Item + EOH + end + + def remove_registry_key + powershell_exec!("Remove-Item -Path HKLM:\\SOFTWARE\\Progress -Recurse") + end + + def verify_export_password_exists + powershell_exec! <<~EOH + Try { + $response = Get-ItemPropertyValue -Path "HKLM:\\Software\\Progress\\Authentication" -Name "PfxPass" -ErrorAction Stop + if ($response) {return $true} + } + Catch { + return $false + } + EOH + end + include IntegrationSupport include Chef::Mixin::ShellOut + include Chef::Mixin::PowershellExec let(:chef_dir) { File.join(__dir__, "..", "..", "..") } @@ -47,6 +91,7 @@ describe "chef-client" do # cf. CHEF-4914 let(:chef_client) { "bundle exec #{ChefUtils::Dist::Infra::CLIENT} --minimal-ohai" } let(:chef_solo) { "bundle exec #{ChefUtils::Dist::Solo::EXEC} --legacy-mode --minimal-ohai" } + let(:client_name) { "chef-973334" } context "when validation.pem in current Directory" do let(:validation_path) { "" } @@ -146,6 +191,34 @@ describe "chef-client" do result.error! end + if ChefUtils.windows? + context "and the private key is in the Windows CertStore" do + before do + # install the p12/pfx and make sure the key and password are stored in the registry + install_certificate_in_store + create_registry_key + end + + after do + # remove the p12/pfx and remove the registry key + remove_certificate_from_store + remove_registry_key + end + + it "should verify that the cert is loaded in the LocalMachine\\My" do + expect(Chef::HTTP::Authenticator.check_certstore_for_key(client_name)).to eq(true) + end + + it "should verify that the export password for the pfx is loaded in the Registry" do + expect(verify_export_password_exists.result).to eq(true) + end + + it "should verify that a private key is returned to me" do + expect(Chef::HTTP::Authenticator.retrieve_certificate_key(client_name)).not_to be_falsey + end + end + end + context "and a private key" do before do file "mykey.pem", <<~EOM diff --git a/spec/unit/client_spec.rb b/spec/unit/client_spec.rb index b6a321c8e8..1e835ae398 100644 --- a/spec/unit/client_spec.rb +++ b/spec/unit/client_spec.rb @@ -113,6 +113,7 @@ shared_context "a client run" do # --Client.register # Make sure Client#register thinks the client key doesn't # exist, so it tries to register and create one. + allow(Chef::HTTP::Authenticator).to receive(:detect_certificate_key).with(fqdn).and_return(false) allow(File).to receive(:exists?).and_call_original expect(File).to receive(:exists?) .with(Chef::Config[:client_key]) @@ -201,7 +202,6 @@ shared_context "a client run" do # Post conditions: check that node has been filled in correctly expect(client).to receive(:run_started) - stub_for_run end end @@ -262,7 +262,7 @@ end # requires platform and platform_version be defined shared_examples "a completed run" do - include_context "run completed" + include_context "run completed" # should receive run_completed_successfully it "runs ohai, sets up authentication, loads node state, synchronizes policy, converges" do # This is what we're testing. diff --git a/spec/unit/mixin/powershell_exec_spec.rb b/spec/unit/mixin/powershell_exec_spec.rb index 92e92dc2a1..b7e7f2a160 100644 --- a/spec/unit/mixin/powershell_exec_spec.rb +++ b/spec/unit/mixin/powershell_exec_spec.rb @@ -26,7 +26,7 @@ describe Chef::Mixin::PowershellExec, :windows_only do describe "#powershell_exec" do context "not specifying an interpreter" do it "runs a basic command and returns a Chef::PowerShell object" do - expect(object.powershell_exec("$PSVersionTable")).to be_kind_of(Chef::PowerShell) + expect(object.powershell_exec("$PSVersionTable")).to be_kind_of(ChefPowerShell::PowerShell) end it "uses less than version 6" do @@ -37,7 +37,7 @@ describe Chef::Mixin::PowershellExec, :windows_only do context "using pwsh interpreter" do it "runs a basic command and returns a Chef::PowerShell object" do - expect(object.powershell_exec("$PSVersionTable", :pwsh)).to be_kind_of(Chef::Pwsh) + expect(object.powershell_exec("$PSVersionTable", :pwsh)).to be_kind_of(ChefPowerShell::Pwsh) end it "uses greater than version 6" do @@ -48,7 +48,7 @@ describe Chef::Mixin::PowershellExec, :windows_only do context "using powershell interpreter" do it "runs a basic command and returns a Chef::PowerShell object" do - expect(object.powershell_exec("$PSVersionTable", :powershell)).to be_kind_of(Chef::PowerShell) + expect(object.powershell_exec("$PSVersionTable", :powershell)).to be_kind_of(ChefPowerShell::PowerShell) end it "uses less than version 6" do @@ -76,11 +76,11 @@ describe Chef::Mixin::PowershellExec, :windows_only do describe "#powershell_exec!" do it "runs a basic command and returns a Chef::PowerShell object" do - expect(object.powershell_exec!("$PSVersionTable")).to be_kind_of(Chef::PowerShell) + expect(object.powershell_exec!("$PSVersionTable")).to be_kind_of(ChefPowerShell::PowerShell) end it "raises an error if the command fails" do - expect { object.powershell_exec!("this-should-error") }.to raise_error(Chef::PowerShell::CommandFailed) + expect { object.powershell_exec!("this-should-error") }.to raise_error(ChefPowerShell::PowerShellExceptions::PowerShellCommandFailed) end it "raises an error if the interpreter is invalid" do diff --git a/spec/unit/platform/query_helpers_spec.rb b/spec/unit/platform/query_helpers_spec.rb index 0b4169810e..424bd6348b 100644 --- a/spec/unit/platform/query_helpers_spec.rb +++ b/spec/unit/platform/query_helpers_spec.rb @@ -18,7 +18,7 @@ require "spec_helper" -describe "Chef::Platform#supports_dsc_invoke_resource?" do +describe "Chef::Platform#supports_dsc_invoke_resource?", :windows_only do it "returns false if powershell is not present" do node = Chef::Node.new expect(Chef::Platform.supports_dsc_invoke_resource?(node)).to be_falsey @@ -39,25 +39,14 @@ describe "Chef::Platform#supports_dsc_invoke_resource?" do end end -describe "Chef::Platform#dsc_refresh_mode_disabled?" do +describe "Chef::Platform#dsc_refresh_mode_disabled?", :windows_only do let(:node) { instance_double("Chef::Node") } - let(:powershell) { instance_double("Chef::PowerShell") } + let(:powershell) { Class.new { include ChefPowerShell::ChefPowerShellModule::PowerShellExec } } + subject(:object) { powershell.new } it "returns true when RefreshMode is Disabled" do - expect(Chef::PowerShell).to receive(:new) - .with("Get-DscLocalConfigurationManager") - .and_return(powershell) - expect(powershell).to receive(:error!) - expect(powershell).to receive(:result).and_return({ "RefreshMode" => "Disabled" }) - expect(Chef::Platform.dsc_refresh_mode_disabled?(node)).to be true - end - - it "returns false when RefreshMode is not Disabled" do - expect(Chef::PowerShell).to receive(:new) - .with("Get-DscLocalConfigurationManager") - .and_return(powershell) - expect(powershell).to receive(:error!) - expect(powershell).to receive(:result).and_return({ "RefreshMode" => "LaLaLa" }) + execution = object.powershell_exec("Get-DscLocalConfigurationManager", :powershell, timeout: -1) + expect(execution.result["RefreshMode"]).to eq "PUSH" expect(Chef::Platform.dsc_refresh_mode_disabled?(node)).to be false end end diff --git a/spec/unit/util/dsc/local_configuration_manager_spec.rb b/spec/unit/util/dsc/local_configuration_manager_spec.rb index 8adf778949..510038db73 100644 --- a/spec/unit/util/dsc/local_configuration_manager_spec.rb +++ b/spec/unit/util/dsc/local_configuration_manager_spec.rb @@ -185,7 +185,7 @@ describe Chef::Util::DSC::LocalConfigurationManager do context "when invalid dsc script is given" do it "raises exception" do configuration_document = "invalid-config" - expect { lcm.send(:run_configuration_cmdlet, configuration_document, true) }.to raise_error(Chef::PowerShell::CommandFailed) + expect { lcm.send(:run_configuration_cmdlet, configuration_document, true) }.to raise_error(ChefPowerShell::PowerShellExceptions::PowerShellCommandFailed) end end end |