diff options
author | John McCrae <john.mccrae@progress.com> | 2022-04-11 13:53:59 -0700 |
---|---|---|
committer | John McCrae <john.mccrae@progress.com> | 2022-04-11 13:53:59 -0700 |
commit | d51271ae363eb5119bb5a4158b569a9176fa0d79 (patch) | |
tree | 425cd7087049975226a20f7cb850d21a5dce3f84 | |
parent | 4f1723722cdac9d27fbb46c3df304ed7f7a6ce75 (diff) | |
download | chef-d51271ae363eb5119bb5a4158b569a9176fa0d79.tar.gz |
Adding additional support for migrating pem to certstore
Signed-off-by: John McCrae <john.mccrae@progress.com>
-rw-r--r-- | Gemfile.lock | 16 | ||||
-rw-r--r-- | cspell.json | 1 | ||||
-rw-r--r-- | lib/chef/client.rb | 152 | ||||
-rw-r--r-- | lib/chef/http/authenticator.rb | 158 | ||||
-rw-r--r-- | spec/unit/client_spec.rb | 56 |
5 files changed, 370 insertions, 13 deletions
diff --git a/Gemfile.lock b/Gemfile.lock index 1d52fe3a23..e5f9e664ae 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -143,7 +143,7 @@ GEM mixlib-shellout (>= 2.0, < 4.0) ast (2.4.2) aws-eventstream (1.2.0) - aws-partitions (1.574.0) + aws-partitions (1.575.0) aws-sdk-core (3.130.0) aws-eventstream (~> 1, >= 1.0.2) aws-partitions (~> 1, >= 1.525.0) @@ -279,9 +279,9 @@ GEM mixlib-config (3.0.9) tomlrb mixlib-log (3.0.9) - mixlib-shellout (3.2.5) + mixlib-shellout (3.2.7) chef-utils - mixlib-shellout (3.2.5-universal-mingw32) + mixlib-shellout (3.2.7-universal-mingw32) chef-utils ffi-win32-extensions (~> 1.0.3) win32-process (~> 0.9) @@ -316,7 +316,7 @@ GEM rainbow (3.1.1) rake (13.0.6) rb-readline (0.5.5) - regexp_parser (2.2.1) + regexp_parser (2.3.0) rexml (3.2.5) rspec (3.11.0) rspec-core (~> 3.11.0) @@ -330,7 +330,7 @@ GEM rspec-its (1.3.0) rspec-core (>= 3.0.0) rspec-expectations (>= 3.0.0) - rspec-mocks (3.11.0) + rspec-mocks (3.11.1) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.11.0) rspec-support (3.11.0) @@ -343,7 +343,7 @@ GEM rubocop-ast (>= 1.15.1, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 1.4.0, < 3.0) - rubocop-ast (1.16.0) + rubocop-ast (1.17.0) parser (>= 3.1.1.0) ruby-progressbar (1.11.0) ruby2_keywords (0.0.5) @@ -360,7 +360,7 @@ GEM syslog-logger (1.6.8) thor (1.2.1) tomlrb (1.3.0) - train-core (3.8.9) + train-core (3.9.2) addressable (~> 2.5) ffi (!= 1.13.0) json (>= 1.8, < 3.0) @@ -440,7 +440,7 @@ GEM rubyzip (~> 2.0) winrm (~> 2.0) wisper (2.0.1) - wmi-lite (1.0.5) + wmi-lite (1.0.7) PLATFORMS arm64-darwin-21 diff --git a/cspell.json b/cspell.json index baafe6cbeb..8ca09f978f 100644 --- a/cspell.json +++ b/cspell.json @@ -580,6 +580,7 @@ "keyivgen", "KEYNAME", "keyname", + "keypair", "keyscan", "KEYTAB", "keytab", diff --git a/lib/chef/client.rb b/lib/chef/client.rb index 7f184d7db4..95c8c784ab 100644 --- a/lib/chef/client.rb +++ b/lib/chef/client.rb @@ -64,6 +64,10 @@ class Chef # The main object in a Chef run. Preps a Chef::Node and Chef::RunContext, # syncs cookbooks if necessary, and triggers convergence. class Client + CRYPT_EXPORTABLE = 0x00000001 + + attr_reader :local_context + extend Chef::Mixin::Deprecation extend Forwardable @@ -640,6 +644,19 @@ class Chef if !config[:client_key] events.skipping_registration(client_name, config) logger.trace("Client key is unspecified - skipping registration") + elsif ::Chef::Config[:migrate_key_to_keystore] == true && ChefUtils.windows? + cert_name = "chef-#{client_name}" + result = check_certstore_for_key(cert_name) + if result.rassoc("#{cert_name}") + logger.trace("Client key #{config[:client_key]} is present in Certificate Store - skipping registration") + elsif File.exists?(config[:client_key]) # client.pem is on disk, client is already registered, moving the + update_key_and_register(cert_name) + logger.trace("Existing client keys migrated to the Certificate Store - skipping registration") + else + create_new_key_and_register(cert_name) + logger.trace("New client keys created in the Certificate Store - skipping registration") + end + events.skipping_registration(client_name, config) elsif File.exists?(config[:client_key]) events.skipping_registration(client_name, config) logger.trace("Client key #{config[:client_key]} is present - skipping registration") @@ -658,6 +675,140 @@ class Chef raise end + # In the brave new world of No Certs On Disk, we want to put the pem file into Keychain or the Certstore + # But is it already there? + def check_certstore_for_key(cert_name) + require "win32-certstore" + win32certstore = ::Win32::Certstore.open("MY") + win32certstore.search("#{cert_name}") + end + + def generate_pfx_package(cert_name, date) + self.class.generate_pfx_package(cert_name, date) + end + + def self.generate_pfx_package(cert_name, date) + require "openssl" unless defined?(OpenSSL) + + key = OpenSSL::PKey::RSA.new(2048) + public_key = key.public_key + + subject = "CN=#{cert_name}" + + cert = OpenSSL::X509::Certificate.new + cert.subject = cert.issuer = OpenSSL::X509::Name.parse(subject) + cert.not_before = Time.now + cert.not_after = Time.parse(date) + cert.public_key = public_key + cert.serial = 0x0 + cert.version = 2 + + ef = OpenSSL::X509::ExtensionFactory.new + ef.subject_certificate = cert + ef.issuer_certificate = cert + cert.extensions = [ + ef.create_extension("subjectKeyIdentifier", "hash"), + ef.create_extension("keyUsage", "digitalSignature,keyEncipherment", true), + ] + cert.add_extension(ef.create_ext_from_string("extendedKeyUsage=critical,serverAuth,clientAuth")) + + cert.sign key, OpenSSL::Digest.new("SHA256") + password = ::Chef::HTTP::Authenticator.get_cert_password + pfx = OpenSSL::PKCS12.create(password, subject, key, cert) + pfx + end + + def update_key_and_register(cert_name) + # Chef client and node objects exist on Chef Server already + # Create a new public/private keypair in secure storage + # and register the new public cert with Chef Server + require "time" unless defined?(Time) + autoload :URI, "uri" + + node = Chef::Config[:node_name] + d = Time.now + if d.month == 10 || d.month == 11 || d.month == 12 + end_date = Time.new(d.year + 1, d.month - 9, d.day, d.hour, d.min, d.sec).utc.iso8601 + else + end_date = Time.new(d.year, d.month + 3, d.day, d.hour, d.min, d.sec).utc.iso8601 + end + + new_cert_name = Time.now.iso8601 + payload = { + name: new_cert_name, + clientname: node, + public_key: "", + expiration_date: end_date, + } + + new_pfx = generate_pfx_package(cert_name, end_date) + payload[:public_key] = new_pfx.certificate.public_key.to_pem + base_url = "#{Chef::Config[:chef_server_url]}" + client = Chef::ServerAPI.new(base_url, client_name: Chef::Config[:node_name], signing_key_filename: Chef::Config[:client_key]) + cert_list = client.get(base_url + "/clients/#{node}/keys") + client.post(base_url + "/clients/#{node_name}/keys", payload) + + # We want to remove the old key for various reasons + # In the case where more than 1 certificate is returned we assume + # there is some special condition applied to the client so we won't delete the old + # certificates + if cert_list.count < 2 + cert_hash = cert_list.reduce({}, :merge!) + old_cert_name = cert_hash["name"] + new_key = new_pfx.key.to_pem + file_path = File.join(Chef::Config["file_cache_path"], "temp.pem") + File.open(file_path, "w") { |f| f.write new_key } + client = Chef::ServerAPI.new(base_url, client_name: Chef::Config[:node_name], signing_key_filename: file_path) + client.delete(base_url + "/clients/#{node}/keys/#{old_cert_name}") + File.delete(file_path) + end + + import_pfx_to_store(new_pfx) + end + + def create_new_key_and_register(cert_name) + require "time" unless defined?(Time) + autoload :URI, "uri" + + node = Chef::Config[:node_name] + d = Time.now + if d.month == 10 || d.month == 11 || d.month == 12 + end_date = Time.new(d.year + 1, d.month - 9, d.day, d.hour, d.min, d.sec).utc.iso8601 + else + end_date = Time.new(d.year, d.month + 3, d.day, d.hour, d.min, d.sec).utc.iso8601 + end + + payload = { + name: node, + clientname: node, + public_key: "", + expiration_date: end_date, + } + + new_pfx = generate_pfx_package(cert_name, end_date) + payload[:public_key] = new_pfx.certificate.public_key.to_pem + base_url = "#{Chef::Config[:chef_server_url]}" + client = Chef::ServerAPI.new(base_url, client_name: Chef::Config[:validation_client_name], signing_key_filename: Chef::Config[:validation_key]) + client.post(base_url + "/clients", payload) + Chef::Log.trace("Updated client data: #{client.inspect}") + import_pfx_to_store(new_pfx) + end + + def import_pfx_to_store(new_pfx) + self.class.import_pfx_to_store(new_pfx) + end + + def self.import_pfx_to_store(new_pfx) + password = ::Chef::HTTP::Authenticator.get_cert_password + require "win32-certstore" + tempfile = Tempfile.new("#{Chef::Config[:node_name]}.pfx") + File.open(tempfile, "wb") { |f| f.print new_pfx.to_der } + + store = ::Win32::Certstore.open("MY") + store.add_pfx(tempfile, password, CRYPT_EXPORTABLE) + tempfile.unlink + end + # # Converges all compiled resources. # @@ -922,3 +1073,4 @@ end require_relative "cookbook_loader" require_relative "cookbook_version" require_relative "cookbook/synchronizer" + diff --git a/lib/chef/http/authenticator.rb b/lib/chef/http/authenticator.rb index 80b32be750..3b9d49f42a 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,13 +88,69 @@ 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 + + def get_cert_password + self.class.get_cert_password + end + + def encrypt_pfx_pass + self.class.encrypt_pfx_pass + end + + def decrypt_pfx_pass + self.class.decrypt_pfx_pass + 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 "chef-#{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 !!results + @raw_key = results + elsif key_file == nil? && raw_key == nil? + puts "\nNo key detected\n" + elsif !!key_file @raw_key = IO.read(key_file).strip elsif !!raw_key @raw_key = raw_key.strip else - return nil + return end # Pass in '' as the passphrase to avoid OpenSSL prompting on the TTY if # given an encrypted key. This also helps if using a single file for @@ -104,6 +165,97 @@ class Chef raise Chef::Exceptions::InvalidPrivateKey, msg end + 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" + password = decrypt_pfx_pass(secret[:data]) + return password + end + end + + 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 values not present in registry, creating them now" + new_path = "HKEY_LOCAL_MACHINE\\Software\\Progress\\Authentication" + unless @win32registry.key_exists?(new_path) + @win32registry.create_key(new_path, true) + end + size = 14 + password = SOME_CHARS.sample(size).join + encrypted_pass = encrypt_pfx_pass(password) + values = { name: "PfxPass", type: :string, data: encrypted_pass } + @win32registry.set_value(new_path, values) + password + end + + def self.encrypt_pfx_pass(password) + powershell_code = <<~CODE + $encrypted_string = ConvertTo-SecureString "#{password}" -AsPlainText -Force + $secure_string = ConvertFrom-SecureString $encrypted_string + return $secure_string + CODE + powershell_exec!(powershell_code).result + end + + def self.decrypt_pfx_pass(password) + powershell_code = <<~CODE + $secure_string = "#{password}" | ConvertTo-SecureString + $string = [Runtime.InteropServices.Marshal]::PtrToStringAuto([Runtime.InteropServices.Marshal]::SecureStringToBSTR((($secure_string)))) + return $string + CODE + powershell_exec!(powershell_code).result + 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) + ps_blob = powershell_exec!(get_the_key_ps(client_name, password)).result + file_path = ps_blob["PSPath"].split("::")[1] + pkcs = OpenSSL::PKCS12.new(File.binread(file_path), password) + + # We test the pfx we just extracted the private key from + # if that cert is expiring in 7 days or less we generate a new pfx/p12 object + # then we post the new public key from that to the client endpoint on + # chef server. + # is_certificate_expiring(pkcs) + File.delete(file_path) + + return pkcs.key.private_to_pem + end + end + + false + end + + def self.get_the_key_ps(client_name, password) + powershell_code = <<~CODE + Try { + $my_pwd = ConvertTo-SecureString -String "#{password}" -Force -AsPlainText; + $cert = Get-ChildItem -path cert:\\LocalMachine\\My -Recurse | Where-Object { $_.Subject -match "chef-#{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 + end + def authentication_headers(method, url, json_body = nil, headers = nil) request_params = { http_method: method, diff --git a/spec/unit/client_spec.rb b/spec/unit/client_spec.rb index b6a321c8e8..2428004c7b 100644 --- a/spec/unit/client_spec.rb +++ b/spec/unit/client_spec.rb @@ -23,6 +23,11 @@ require "chef/run_context" require "chef/server_api" require "rbconfig" +begin + require "chef-powershell" +rescue LoadError +end + class FooError < RuntimeError end @@ -113,6 +118,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 +207,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 +267,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. @@ -282,6 +287,53 @@ shared_examples "a failed run" do end end +describe Chef::Client, :windows_only do + let(:hostname) { "test" } + let(:my_client) { Chef::Client.new } + let(:cert_name) { "chef-#{hostname}" } + let(:node_name) { "#{hostname}" } + let(:end_date) do + d = Time.now + if d.month == 10 || d.month == 11 || d.month == 12 + end_date = Time.new(d.year + 1, d.month - 9, d.day, d.hour, d.min, d.sec).utc.iso8601 + else + end_date = Time.new(d.year, d.month + 3, d.day, d.hour, d.min, d.sec).utc.iso8601 + end + end + + # include_context "client" + before(:each) do + Chef::Config[:migrate_key_to_keystore] = true + end + + after(:each) do + delete_certificate(cert_name) + end + + context "when the client intially boots the first time" do + it "verfies that a certificate was correctly created and exists in the Cert Store" do + new_pfx = my_client.generate_pfx_package(cert_name, end_date) + my_client.import_pfx_to_store(new_pfx) + expect(my_client.check_certstore_for_key(cert_name)).not_to be false + end + + it "correctly returns a new Publc Key" do + new_pfx = my_client.generate_pfx_package(cert_name, end_date) + cert_object = new_pfx.certificate.public_key.to_pem + expect(cert_object.to_s).to match(/PUBLIC KEY/) + end + end + + def delete_certificate(cert_name) + require "chef/mixin/powershell_exec" + extend Chef::Mixin::PowershellExec + powershell_code = <<~CODE + Get-ChildItem -path cert:\\LocalMachine\\My -Recurse -Force | Where-Object { $_.Subject -Match "#{cert_name}" } | Remove-item + CODE + powershell_exec!(powershell_code) + end +end + describe Chef::Client do include_context "client" |