summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJohn McCrae <john.mccrae@progress.com>2022-04-11 13:53:59 -0700
committerJohn McCrae <john.mccrae@progress.com>2022-04-11 13:53:59 -0700
commitd51271ae363eb5119bb5a4158b569a9176fa0d79 (patch)
tree425cd7087049975226a20f7cb850d21a5dce3f84
parent4f1723722cdac9d27fbb46c3df304ed7f7a6ce75 (diff)
downloadchef-d51271ae363eb5119bb5a4158b569a9176fa0d79.tar.gz
Adding additional support for migrating pem to certstore
Signed-off-by: John McCrae <john.mccrae@progress.com>
-rw-r--r--Gemfile.lock16
-rw-r--r--cspell.json1
-rw-r--r--lib/chef/client.rb152
-rw-r--r--lib/chef/http/authenticator.rb158
-rw-r--r--spec/unit/client_spec.rb56
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"