From 67d2585bb97cfe99dc696c56449e02ddd12dd50d Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Fri, 11 Apr 2025 21:58:37 +0200 Subject: [PATCH 01/23] Implement initial question to import certificate --- service/lib/agama/registration.rb | 170 ++++++++--- service/lib/agama/ssl/certificate.rb | 256 ++++++++++++++++ service/lib/agama/ssl/certificate_details.rb | 59 ++++ service/lib/agama/ssl/errors.rb | 53 ++++ service/lib/agama/ssl/fingerprint.rb | 23 ++ service/run_tests_in_container.sh | 2 +- service/test/agama/registration_test.rb | 40 ++- .../agama/ssl/certificate_details_spec.rb | 64 ++++ service/test/agama/ssl/certificate_spec.rb | 282 ++++++++++++++++++ service/test/agama/ssl/fingerprint_spec.rb | 66 ++++ .../test/fixtures/anchors/openssl/8820a2e8.0 | 1 + .../test/fixtures/anchors/openssl/8f13f82e.0 | 1 + .../fixtures/anchors/openssl/YaST_Team.pem | 23 ++ service/test/fixtures/anchors/pem/8820a2e8.0 | 1 + service/test/fixtures/anchors/pem/8f13f82e.0 | 1 + .../test/fixtures/anchors/pem/YaST_Team.pem | 23 ++ service/test/fixtures/test.pem | 20 ++ 17 files changed, 1040 insertions(+), 45 deletions(-) create mode 100644 service/lib/agama/ssl/certificate.rb create mode 100644 service/lib/agama/ssl/certificate_details.rb create mode 100644 service/lib/agama/ssl/errors.rb create mode 100644 service/lib/agama/ssl/fingerprint.rb create mode 100755 service/test/agama/ssl/certificate_details_spec.rb create mode 100755 service/test/agama/ssl/certificate_spec.rb create mode 100755 service/test/agama/ssl/fingerprint_spec.rb create mode 120000 service/test/fixtures/anchors/openssl/8820a2e8.0 create mode 120000 service/test/fixtures/anchors/openssl/8f13f82e.0 create mode 100644 service/test/fixtures/anchors/openssl/YaST_Team.pem create mode 120000 service/test/fixtures/anchors/pem/8820a2e8.0 create mode 120000 service/test/fixtures/anchors/pem/8f13f82e.0 create mode 100644 service/test/fixtures/anchors/pem/YaST_Team.pem create mode 100644 service/test/fixtures/test.pem diff --git a/service/lib/agama/registration.rb b/service/lib/agama/registration.rb index 58fce1e108..dc94db2cb5 100644 --- a/service/lib/agama/registration.rb +++ b/service/lib/agama/registration.rb @@ -29,6 +29,9 @@ require "agama/cmdline_args" require "agama/errors" require "agama/registered_addon" +require "agama/ssl/certificate" +require "agama/ssl/certificate_details" +require "agama/ssl/errors" Yast.import "Arch" Yast.import "Pkg" @@ -36,6 +39,8 @@ module Agama # Handles everything related to registration of system to SCC, RMT or similar. class Registration + include Yast::I18n + # NOTE: identical and keep in sync with Software::Manager::TARGET_DIR TARGET_DIR = "/run/agama/zypp" private_constant :TARGET_DIR @@ -84,54 +89,58 @@ def initialize(software_manager, logger) def register(code, email: "") return if product.nil? || reg_code - reg_params = connect_params(token: code, email: email) + catch_registration_errors do + reg_params = connect_params(token: code, email: email) - @login, @password = SUSE::Connect::YaST.announce_system(reg_params, target_distro) - # write the global credentials - # TODO: check if we can do it in memory for libzypp - SUSE::Connect::YaST.create_credentials_file(@login, @password, GLOBAL_CREDENTIALS_PATH) + @login, @password = SUSE::Connect::YaST.announce_system(reg_params, target_distro) + # write the global credentials + # TODO: check if we can do it in memory for libzypp + SUSE::Connect::YaST.create_credentials_file(@login, @password, GLOBAL_CREDENTIALS_PATH) - activate_params = {} - service = SUSE::Connect::YaST.activate_product(base_target_product, activate_params, email) - process_service(service) + activate_params = {} + service = SUSE::Connect::YaST.activate_product(base_target_product, activate_params, email) + process_service(service) - @reg_code = code - @email = email - run_on_change_callbacks + @reg_code = code + @email = email + run_on_change_callbacks + end end def register_addon(name, version, code) - register_version = if version.empty? - # version is not specified, find it automatically - find_addon_version(name) - else - # use the explicitly required version - version + catch_registration_errors do + register_version = if version.empty? + # version is not specified, find it automatically + find_addon_version(name) + else + # use the explicitly required version + version + end + + if @registered_addons.any? { |a| a.name == name && a.version == register_version } + @logger.info "Addon #{name}-#{register_version} already registered, skipping registration" + return + end + + @logger.info "Registering addon #{name}-#{register_version}" + # do not log the code, but at least log if it is empty + @logger.info "Using empty registration code" if code.empty? + + target_product = OpenStruct.new( + arch: Yast::Arch.rpm_arch, + identifier: name, + version: register_version + ) + activate_params = { token: code } + service = SUSE::Connect::YaST.activate_product(target_product, activate_params, @email) + process_service(service) + + @registered_addons << RegisteredAddon.new(name, register_version, !version.empty?, code) + # select the products to install + @software.addon_products(find_addon_products) + + run_on_change_callbacks end - - if @registered_addons.any? { |a| a.name == name && a.version == register_version } - @logger.info "Addon #{name}-#{register_version} already registered, skipping registration" - return - end - - @logger.info "Registering addon #{name}-#{register_version}" - # do not log the code, but at least log if it is empty - @logger.info "Using empty registration code" if code.empty? - - target_product = OpenStruct.new( - arch: Yast::Arch.rpm_arch, - identifier: name, - version: register_version - ) - activate_params = { token: code } - service = SUSE::Connect::YaST.activate_product(target_product, activate_params, @email) - process_service(service) - - @registered_addons << RegisteredAddon.new(name, register_version, !version.empty?, code) - # select the products to install - @software.addon_products(find_addon_products) - - run_on_change_callbacks end # Deregisters the selected product. @@ -252,9 +261,88 @@ def credentials_path(file) def connect_params(params = {}) default_params = {} default_params[:url] = registration_url if registration_url + default_params[:verify_callback] = verify_callback default_params.merge(params) end + # returns SSL verify callback + def verify_callback + lambda do |verify_ok, context| + + # we cannot raise an exception with details here (all exceptions in + # verify_callback are caught and ignored), we need to store the error + # details in a global instance + store_ssl_error(context) unless verify_ok + + verify_ok + rescue StandardError => e + @logger.error "Exception in SSL verify callback: #{e.class}: #{e.message} : #{e.backtrace}" + # the exception will be ignored, but reraise anyway... + raise e + + end + end + + def store_ssl_error(context) + @logger.error "SSL verification failed: #{context.error}: #{context.error_string}" + SSL::Errors.instance.ssl_error_code = context.error + SSL::Errors.instance.ssl_error_msg = context.error_string + SSL::Errors.instance.ssl_failed_cert = + context.current_cert ? SSL::Certificate.load(context.current_cert) : nil + end + + def catch_registration_errors(&block) + # import the SSL certificate just once to avoid an infinite loop + certificate_imported = false + begin + # reset the previous SSL errors + Agama::SSL::Errors.instance.reset + + block.call + + true + rescue OpenSSL::SSL::SSLError => e + @logger.error "OpenSSL error: #{e}" + should_retry = handle_ssl_error(e, certificate_imported) + if should_retry + certificate_imported = true + SSL::Errors.instance.ssl_failed_cert.import + retry + end + false + end + end + + def handle_ssl_error(_error, certificate_imported) + return false if certificate_imported + + cert = SSL::Errors.instance.ssl_failed_cert + error_code = SSL::Errors.instance.ssl_error_code + + puts "cert #{cert} code #{error_code}" + if cert && SSL::ErrorCodes::IMPORT_ERROR_CODES.include?(error_code) + message = format( + _("Secure Connection Error for %{url}: %{error}. Certificate details %{details}."), + url: registration_url || "https://scc.suse.com", + error: SSL::ErrorCodes::OPENSSL_ERROR_MESSAGES[error_code], + details: SSL::CertificateDetails.new(cert).summary + ) + question = Agama::Question.new( + qclass: "registration.certificate", + text: message, + options: [:Import, :Abort], + default_option: :Abort + ) + + questions_client = Agama::DBus::Clients::Questions.new(logger: @logger) + questions_client.ask(question) do |question_client| + return question_client.answer == :Import + end + end + + false + end + # Returns the URL of the registration server # # At this point, it just checks the kernel's command-line. diff --git a/service/lib/agama/ssl/certificate.rb b/service/lib/agama/ssl/certificate.rb new file mode 100644 index 0000000000..80a2907a40 --- /dev/null +++ b/service/lib/agama/ssl/certificate.rb @@ -0,0 +1,256 @@ +require "openssl" +require "suse/connect" +require "yast2/execute" +require "agama/ssl/fingerprint" +require "agama/ssl/certificate_details" + +module Agama + module SSL + # class handling SSL certificate + class Certificate + include Yast::Logger + + Yast.import "Stage" + Yast.import "Installation" + + # Path to the registration certificate in the instsys + INSTSYS_CERT_DIR = "/etc/pki/trust/anchors".freeze + INSTSYS_SERVER_CERT_FILE = File.join(INSTSYS_CERT_DIR, "registration_server.pem").freeze + # Path to system CA certificates + CA_CERTS_DIR = "/var/lib/ca-certificates".freeze + + # all used certificate paths, this is used during upgrade to import + # the old certificate into the inst-sys, put the older paths at the end + # so the newer paths are checked first + PATHS = [ + # the YaST (SUSEConnect) current default path + # /etc/pki/trust/anchors/registration_server.pem + SUSE::Connect::YaST::SERVER_CERT_FILE, + # old location of the certificate (before moved to /etc) + # https://bugzilla.suse.com/show_bug.cgi?id=1130864 + "/usr/share/pki/trust/anchors/registration_server.pem", + # RMT certificate + # https://github.com/SUSE/rmt/blob/b240ce577bd1637cfb57548f2741a1925cf3e4ee/public/tools/rmt-client-setup#L214 + "/etc/pki/trust/anchors/rmt-server.pem", + # SMT certificate + # https://github.com/SUSE/smt/blob/SMT12/script/clientSetup4SMT.sh#L245 + "/etc/pki/trust/anchors/registration-server.pem", + # the SLE11 path (for both YaST and the clientSetup4SMT.sh script) + # https://github.com/yast/yast-registration/blob/Code-11-SP3/src/modules/Register.ycp#L296-L297 + "/etc/ssl/certs/registration-server.pem" + ].freeze + + attr_reader :x509_cert + + # Path to store the certificate of the registration server + # + # @return [String] Path to store the certificate + def self.default_certificate_path + INSTSYS_SERVER_CERT_FILE + end + + def initialize(x509_cert) + @x509_cert = x509_cert + end + + def self.load_file(file) + load(File.read(file)) + end + + def self.load(data) + cert = OpenSSL::X509::Certificate.new(data) + Certificate.new(cert) + end + + def self.download(url, insecure: false) + # TODO + #result = Downloader.download(url, insecure: insecure) + #load(result) + end + + # Path to temporal CA certificates (to be used only in instsys) + TMP_CA_CERTS_DIR = "/var/lib/YaST2/ca-certificates".freeze + + # Update instys CA certificates + # + # update-ca-certificates script cannot be used in inst-sys. + # See bsc#981428 and bsc#989787. + # + # @return [Boolean] true if update was successful; false otherwise. + # + # @see CA_CERTS_DIR + # @see TMP_CA_CERTS_DIR + def self.update_instsys_ca + FileUtils.mkdir_p(TMP_CA_CERTS_DIR) + # Extract system certs in openssl and pem formats + Yast::Execute.locally("trust", "extract", "--format=openssl-directory", + "--filter=ca-anchors", "--overwrite", File.join(TMP_CA_CERTS_DIR, "openssl")) + Yast::Execute.locally("trust", "extract", "--format=pem-directory-hash", + "--filter=ca-anchors", "--overwrite", File.join(TMP_CA_CERTS_DIR, "pem")) + + # Copy certificates/links + new_files = [] + ["pem", "openssl"].each do |subdir| + files = Dir[File.join(TMP_CA_CERTS_DIR, subdir, "*")] + next if files.empty? + + subdir = File.join(CA_CERTS_DIR, subdir) + FileUtils.mkdir_p(subdir) unless Dir.exist?(subdir) + files.each do |file| + # FileUtils.cp does not seem to allow copying the links without dereferencing them. + Yast::Execute.locally("cp", "--no-dereference", "--preserve=links", file, subdir) + new_files << File.join(subdir, File.basename(file)) + end + end + + # Cleanup + FileUtils.rm_rf(TMP_CA_CERTS_DIR) + + return false if new_files.empty? + + # Reload SUSEConnect internal cert pool (suseconnect-ng only) + SUSE::Connect::SSLCertificate.reload if SUSE::Connect::SSLCertificate.respond_to?(:reload) + + # Check that last file was copied to return true or false + File.exist?(new_files.last) + end + + # certificate serial number (in HEX format, e.g. AB:CD:42:FF...) + def serial + x509_cert.serial.to_s(16).scan(/../).join(":") + end + + def issued_on + x509_cert.not_before.localtime.strftime("%F") + end + + def valid_yet? + Time.now > x509_cert.not_before + end + + def expires_on + x509_cert.not_after.localtime.strftime("%F") + end + + def expired? + Time.now > x509_cert.not_after + end + + def subject_name + find_subject_attribute("CN") + end + + def subject_organization + find_subject_attribute("O") + end + + def subject_organization_unit + find_subject_attribute("OU") + end + + def issuer_name + find_issuer_attribute("CN") + end + + def issuer_organization + find_issuer_attribute("O") + end + + def issuer_organization_unit + find_issuer_attribute("OU") + end + + def fingerprint(sum) + case sum.upcase + when Fingerprint::SHA1 + sha1_fingerprint + when Fingerprint::SHA256 + sha256_fingerprint + else + raise "Unsupported checksum type '#{sum}'" + end + end + + # Import the certificate + # + # Depending if running in installation or in a installed system, + # it will rely on #import_to_instsys or #import_to_system methods. + # + # @return [true] true if import was successful + # + # @raise Connect::SystemCallError + # @raise Cheetah::ExecutionFailed + + # @see #import_to_instsys + def import + import_to_instsys + end + + # Import the certificate to the installation system + # + # This method exists because the procedure to import certificates + # to installation system is slightly different to the one followed + # to import certificates to a installed system. + # + # @param target_path [String] where the imported certificate will be saved, + # the path should contain the INSTSYS_CERT_DIR prefix otherwise it might + # not work correctly. + # @return [Boolean] true if import was successful; false otherwise. + # + # @see update_instsys_ca + def import_to_instsys(target_path = self.class.default_certificate_path) + # Copy certificate + File.write(target_path, x509_cert.to_pem) + + # Update database + self.class.update_instsys_ca + end + + # Log the certificate details + def log_details + require "registration/ssl_certificate_details" + # log also the dates + log.info("#{CertificateDetails.new(self).summary}\n" \ + "Issued on: #{issued_on}\nExpires on: #{expires_on}") + + # log a warning for expired certificate + expires = x509_cert.not_after.localtime + log.warn("The certificate has EXPIRED! (#{expires})") if expires < Time.now + end + + private + + # @param x509_name [OpenSSL::X509::Name] name object + # @param attribute [String] requested attribute name. e.g. "CN" + # @return attribut value or nil if not defined + def find_name_attribute(x509_name, attribute) + # to_a returns an attribute list, e.g.: + # [["CN", "linux", 19], ["emailAddress", "root@...", 22], ["O", "YaST", 19], ...] + _attr, value, _code = x509_name.to_a.find { |a| a.first == attribute } + value + end + + def find_issuer_attribute(attribute) + find_name_attribute(x509_cert.issuer, attribute) + end + + def find_subject_attribute(attribute) + find_name_attribute(x509_cert.subject, attribute) + end + + def sha1_fingerprint + Fingerprint.new( + Fingerprint::SHA1, + ::SUSE::Connect::YaST.cert_sha1_fingerprint(x509_cert) + ) + end + + def sha256_fingerprint + Fingerprint.new( + Fingerprint::SHA256, + ::SUSE::Connect::YaST.cert_sha256_fingerprint(x509_cert) + ) + end + end + end +end diff --git a/service/lib/agama/ssl/certificate_details.rb b/service/lib/agama/ssl/certificate_details.rb new file mode 100644 index 0000000000..25c130ad9c --- /dev/null +++ b/service/lib/agama/ssl/certificate_details.rb @@ -0,0 +1,59 @@ +require "agama/ssl/fingerprint" + +module Agama + # class handling SSL certificate + module SSL + class CertificateDetails + include Yast::I18n + + # indent size used in summary text + INDENT = " " * 3 + + def initialize(certificate) + textdomain "registration" + @certificate = certificate + end + + def subject + identity_details(certificate.subject_name, certificate.subject_organization, + certificate.subject_organization_unit) + end + + def issuer + identity_details(certificate.issuer_name, certificate.issuer_organization, + certificate.issuer_organization_unit) + end + + def summary(small_space: false) + summary = _("Certificate:") + "\n" + _("Issued To") + "\n" + subject + + "\n" + _("Issued By") + "\n" + issuer + "\n" + _("SHA1 Fingerprint: ") + + "\n" + INDENT + certificate.fingerprint(Fingerprint::SHA1).value + "\n" + + _("SHA256 Fingerprint: ") + "\n" + + sha256 = certificate.fingerprint(Fingerprint::SHA256).value + summary += if small_space + # split the long SHA256 digest to two lines in small text mode UI + INDENT + sha256[0..59] + "\n" + INDENT + sha256[60..-1] + else + INDENT + sha256 + end + + summary + end + + private + + attr_reader :certificate + + def identity_details(cn, o, ou) + # label followed by the SSL certificate identification + _("Common Name (CN): ") + (cn || "") + "\n" + + # label followed by the SSL certificate identification + _("Organization (O): ") + (o || "") + "\n" + + # label followed by the SSL certificate identification + _("Organization Unit (OU): ") + (ou || "") + "\n" + end + end + end +end + diff --git a/service/lib/agama/ssl/errors.rb b/service/lib/agama/ssl/errors.rb new file mode 100644 index 0000000000..ac47ffe4ba --- /dev/null +++ b/service/lib/agama/ssl/errors.rb @@ -0,0 +1,53 @@ +require "yast" + +module Agama + module SSL + # remember the details about SSL verification failure + # the attributes are read from the SSL error context + class Errors < Struct.new(:ssl_error_code, :ssl_error_msg, :ssl_failed_cert) + include Singleton + + def reset + self.ssl_error_code = nil + self.ssl_error_msg = nil + self.ssl_failed_cert = nil + end + end + + module ErrorCodes + extend Yast::I18n + textdomain "registration" + + # "certificate has expired" + EXPIRED = 10 + # "self signed certificate" + SELF_SIGNED_CERT = 18 + # "self signed certificate in certificate chain" + SELF_SIGNED_CERT_IN_CHAIN = 19 + # "unable to get local issuer certificate" + NO_LOCAL_ISSUER_CERTIFICATE = 20 + + # openSSL error codes for which the import SSL certificate dialog is shown, + # for the other error codes just the error message is displayed + # (importing the certificate would not help) + IMPORT_ERROR_CODES = [ + SELF_SIGNED_CERT, + SELF_SIGNED_CERT_IN_CHAIN + ].freeze + + # error code => translatable error message + # @note the text messages need to be translated at runtime via _() call + # @note we do not translate every possible OpenSSL error message, just the most common ones + OPENSSL_ERROR_MESSAGES = { + # TRANSLATORS: SSL error message + EXPIRED => N_("Certificate has expired"), + # TRANSLATORS: SSL error message + SELF_SIGNED_CERT => N_("Self signed certificate"), + # TRANSLATORS: SSL error message + SELF_SIGNED_CERT_IN_CHAIN => N_("Self signed certificate in certificate chain"), + # TRANSLATORS: SSL error message + NO_LOCAL_ISSUER_CERTIFICATE => N_("Unable to get local issuer certificate") + }.freeze + end + end +end diff --git a/service/lib/agama/ssl/fingerprint.rb b/service/lib/agama/ssl/fingerprint.rb new file mode 100644 index 0000000000..1b3080e9fa --- /dev/null +++ b/service/lib/agama/ssl/fingerprint.rb @@ -0,0 +1,23 @@ +module Agama + module SSL + class Fingerprint + attr_reader :sum, :value + + SHA1 = "SHA1".freeze + SHA256 = "SHA256".freeze + + def initialize(sum, value) + @sum = sum + @value = value + end + + def ==(other) + return false if other.nil? + + # case insensitive compare of the fingerprint value + # (ignore optional colon separators) + sum.casecmp(other.sum) == 0 && value.tr(":", "").casecmp(other.value.tr(":", "")) == 0 + end + end + end +end diff --git a/service/run_tests_in_container.sh b/service/run_tests_in_container.sh index c298048703..a718ebadd2 100644 --- a/service/run_tests_in_container.sh +++ b/service/run_tests_in_container.sh @@ -4,7 +4,7 @@ set -ex SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd ) podman create -ti --rm --entrypoint '["sh", "-c"]' --name agama_ruby_tests -v $SCRIPT_DIR/..:/checkout registry.opensuse.org/yast/head/containers_tumbleweed/yast-ruby sh podman start agama_ruby_tests -podman exec agama_ruby_tests zypper --non-interactive install yast2-iscsi-client yast2-bootloader ruby3.3-rubygem-eventmachine +podman exec agama_ruby_tests zypper --non-interactive install yast2-iscsi-client yast2-bootloader ruby3.4-rubygem-eventmachine if podman exec --workdir /checkout/service agama_ruby_tests rake test:unit; then if [ "$KEEP_RUNNING" != "1" ]; then podman stop agama_ruby_tests diff --git a/service/test/agama/registration_test.rb b/service/test/agama/registration_test.rb index 92baaf5367..52edf6c21d 100644 --- a/service/test/agama/registration_test.rb +++ b/service/test/agama/registration_test.rb @@ -56,6 +56,16 @@ let(:service) { OpenStruct.new(name: "test-service", url: nil) } let(:cmdline_args) { Agama::CmdlineArgs.new({}) } + describe "#verify_callback" do + it "stores to SSL::Error ssl error details" do + error = double(error: 20, error_string: "Error", current_cert: nil) + + subject.send(:verify_callback).call(false, error) + expect(Agama::SSL::Errors.instance.ssl_error_code).to eq 20 + expect(Agama::SSL::Errors.instance.ssl_error_msg).to eq "Error" + end + end + describe "#register" do context "if there is no product selected yet" do let(:product) { nil } @@ -85,7 +95,7 @@ context "and the product is not registered yet" do it "announces the system" do expect(SUSE::Connect::YaST).to receive(:announce_system).with( - { token: "11112222", email: "test@test.com" }, + { token: "11112222", email: "test@test.com", verify_callback: anything }, "test-5-x86_64" ) @@ -99,7 +109,8 @@ it "registers using the given URL" do expect(SUSE::Connect::YaST).to receive(:announce_system).with( - { token: "11112222", email: "test@test.com", url: "http://scc.example.net" }, + { token: "11112222", email: "test@test.com", url: "http://scc.example.net", + verify_callback: anything }, "test-5-x86_64" ) @@ -223,6 +234,29 @@ expect(subject.email).to be_nil end end + + context "if the registration server has self-signed certificate" do + before do + Agama::SSL::Errors.instance.ssl_error_code = Agama::SSL::ErrorCodes::SELF_SIGNED_CERT + Agama::SSL::Errors.instance.ssl_error_msg = "test error" + Agama::SSL::Errors.instance.ssl_failed_cert = Agama::SSL::Certificate.new("test") + # mock reset to avoid previous setup + allow(Agama::SSL::Errors.instance).to receive(:reset) + + allow(SUSE::Connect::YaST).to receive(:activate_product).and_raise(OpenSSL::SSL::SSLError.new("test")) + end + + it "opens question" do + expect(Agama::Question).to receive(:new) + q_client = double + expect(q_client).to receive(:ask).and_yield(q_client) + expect(q_client).to receive(:answer).and_return(:Abort) + expect(Agama::DBus::Clients::Questions).to receive(:new) + .and_return(q_client) + + subject.register("11112222", email: "test@test.com") + end + end end end end @@ -362,7 +396,7 @@ it "deactivates the system" do expect(SUSE::Connect::YaST).to receive(:deactivate_system).with( - { token: "11112222", email: "test@test.com" } + { token: "11112222", email: "test@test.com", verify_callback: anything } ) subject.deregister diff --git a/service/test/agama/ssl/certificate_details_spec.rb b/service/test/agama/ssl/certificate_details_spec.rb new file mode 100755 index 0000000000..c9f502dcf3 --- /dev/null +++ b/service/test/agama/ssl/certificate_details_spec.rb @@ -0,0 +1,64 @@ +#! /usr/bin/env rspec + +require_relative "../../test_helper" + +require "agama/ssl/certificate" +require "agama/ssl/certificate_details" + +describe "Agama::SSL::CertificateDetails" do + subject do + Agama::SSL::CertificateDetails.new( + Agama::SSL::Certificate.load_file(File.join(FIXTURES_PATH, "test.pem")) + ) + end + + let(:identity) do + <<~IDENTITY + Common Name (CN): linux-1hyn + Organization (O): WebYaST + Organization Unit (OU): WebYaST + IDENTITY + end + + let(:sha256sum) do + "2A:02:DA:EC:A9:FF:4C:B4:A6:C0:57:08:F6:1C:8B:B0:94:FA:" \ + "F4:60:96:5E:18:48:CA:84:81:48:60:F3:CB:BF" + end + let(:sha1sum) { "A8:DE:08:B1:57:52:FE:70:DF:D5:31:EA:E3:53:BB:39:EE:01:FF:B9" } + + describe ".#subject" do + it "returns textual summary of the certificate subject" do + expect(subject.subject).to eq(identity) + end + end + + describe "#issuer" do + it "return textual summary of the certificate issuer" do + expect(subject.issuer).to eq(identity) + end + end + + describe "#summary" do + it "returns textual summary of the whole certificate" do + # rubocop:disable Layout/TrailingWhitespace + expect(subject.summary).to eq(<<~CERT.chomp + Certificate: + Issued To + #{identity} + Issued By + #{identity} + SHA1 Fingerprint: + #{sha1sum} + SHA256 Fingerprint: + #{sha256sum} + CERT + ) + # rubocop:enable Layout/TrailingWhitespace + end + + it "can optionaly limit line lenght to fit terminal width" do + # the longest line still fits 80 chars wide terminal + expect(subject.summary(small_space: true).split("\n").map(&:size).max).to be < 80 + end + end +end diff --git a/service/test/agama/ssl/certificate_spec.rb b/service/test/agama/ssl/certificate_spec.rb new file mode 100755 index 0000000000..c51e6bebe8 --- /dev/null +++ b/service/test/agama/ssl/certificate_spec.rb @@ -0,0 +1,282 @@ +#! /usr/bin/env rspec + +require_relative "../../test_helper" + +require "agama/ssl/certificate" + +require "pathname" +require "tmpdir" + +describe Agama::SSL::Certificate do + subject { Agama::SSL::Certificate.load_file(File.join(FIXTURES_PATH, "test.pem")) } + # use "openssl x509 -in test.pem -noout -serial -fingerprint" to get serial and SHA1 + # use "openssl x509 -outform der -in test.pem -out test.der" and then + # "sha224sum test.der" and "sha256sum test.der" to get SHA224 and SHA256 + let(:serial) { "B8:AB:F1:73:E4:1F:10:4D" } + let(:sha1) { "A8:DE:08:B1:57:52:FE:70:DF:D5:31:EA:E3:53:BB:39:EE:01:FF:B9" } + let(:sha256) do + "2A:02:DA:EC:A9:FF:4C:B4:A6:C0:57:08:F6:1C:8B:B0:94:FA:F4:60:96:5E:" \ + "18:48:CA:84:81:48:60:F3:CB:BF" + end + let(:initial) { false } + + before do + allow(Yast::Stage).to receive(:initial).and_return(initial) + end + + describe ".load_file" do + it "loads SSL certificate from a file" do + expect(subject).to be_a(Agama::SSL::Certificate) + end + end + + describe ".load" do + it "loads SSL certificate from data" do + expect(Agama::SSL::Certificate.load(File.read(File.join(FIXTURES_PATH, "test.pem")))).to \ + be_a(Agama::SSL::Certificate) + end + end + + xdescribe ".download" do + it "downloads a SSL certificate from server" do + expect(Agama::SSL::Downloader).to receive(:download)\ + .and_return(File.read(File.join(FIXTURES_PATH, "test.pem"))) + + expect(Agama::SSL::Certificate.download("http://example.com/smt.crt")).to \ + be_a(Agama::SSL::Certificate) + end + end + + describe ".update_instsys_ca" do + CERT_NAME = "YaST_Team.pem".freeze + # Names are asigned by "trust" and related to certificate content + CERT_LINKS = ["8820a2e8.0", "8f13f82e.0"].freeze + + let(:ca_dir) { Pathname.new(Dir.mktmpdir) } + let(:tmp_ca_dir) { File.join(FIXTURES_PATH, "anchors") } + + before do + stub_const("Agama::SSL::Certificate::TMP_CA_CERTS_DIR", tmp_ca_dir.to_s) + stub_const("Agama::SSL::Certificate::CA_CERTS_DIR", ca_dir.to_s) + allow(Yast::Execute).to receive(:locally).and_call_original + allow(FileUtils).to receive(:rm_rf).and_call_original + ["openssl", "pem"].each do |d| + FileUtils.mkdir_p(File.join(tmp_ca_dir, d)) + CERT_LINKS.each do |l| + FileUtils.ln_sf(File.join(tmp_ca_dir, d, CERT_NAME), File.join(tmp_ca_dir, d, l)) + end + end + end + + after do + FileUtils.rm_rf(ca_dir.to_s) + cert_links = Dir[File.join(tmp_ca_dir, "*")].select { |f| File.symlink?(f)} + FileUtils.rm(cert_links) unless cert_links.empty? + end + + it "adds new certs under anchors to system CA certificates" do + expect(Yast::Execute).to receive(:locally).with("trust", "extract", + "--format=openssl-directory", "--filter=ca-anchors", "--overwrite", + File.join(tmp_ca_dir, "openssl")) + .and_return(true) + expect(Yast::Execute).to receive(:locally).with("trust", "extract", + "--format=pem-directory-hash", "--filter=ca-anchors", "--overwrite", + File.join(tmp_ca_dir, "pem")) + .and_return(true) + expect(FileUtils).to receive(:rm_rf).with(tmp_ca_dir) + .and_return(Dir[File.join(tmp_ca_dir, "*")]) + + expect(described_class.update_instsys_ca).to eq(true) + + # Check that certificates and symlink exists + targets = ["pem", "openssl"].map { |d| File.join(ca_dir, d) } + targets.each do |subdir| + expect(File.file?(File.join(subdir, CERT_NAME))).to eq true + CERT_LINKS.each { |l| expect(File.symlink?(File.join(subdir, l))).to eq true } + end + end + + context "when updating the system CA certificate fails" do + before do + allow(Cheetah).to receive(:run) + allow(FileUtils).to receive(:rm_rf) + end + + it "returns false" do + expect(described_class.update_instsys_ca).to eq(false) + end + end + end + + describe ".default_certificate_path" do + it "returns the path defined by INSTSYS_SERVER_CERT_FILE" do + expect(described_class.default_certificate_path) + .to eq(Agama::SSL::Certificate::INSTSYS_SERVER_CERT_FILE) + end + end + + describe "#fingerprint" do + it "returns SHA1 fingerprint in HEX format" do + expect(subject.fingerprint(Agama::SSL::Fingerprint::SHA1).value).to eq(sha1) + end + + it "returns SHA256 fingerprint in HEX format" do + expect(subject.fingerprint(Agama::SSL::Fingerprint::SHA256).value).to eq(sha256) + end + + it "raises an exception when unsupported sum is requested" do + expect { subject.fingerprint("SHA224") }.to raise_error(/Unsupported checksum type/) + end + end + + describe "#serial" do + it "returns serial number in HEX format" do + expect(subject.serial).to eq(serial) + end + end + + describe "#issued_on" do + it "returns date of issue in human readable form" do + expect(subject.issued_on).to eq("2014-04-24") + end + end + + describe "#expires_on" do + it "returns date of issue in human readable form" do + expect(subject.expires_on).to eq("2017-04-23") + end + end + + context "current date in the past" do + before do + expect(Time).to receive(:now).and_return(Time.new(2010, 1, 1)) + end + + describe "#expired?" do + it "returns false" do + expect(subject.expired?).to eq(false) + end + end + + describe "#valid_yet?" do + it "returns false" do + expect(subject.valid_yet?).to eq(false) + end + end + end + + context "current date in the future" do + before do + expect(Time).to receive(:now).and_return(Time.new(2020, 1, 1)) + end + + describe "#expired?" do + it "returns true" do + expect(subject.expired?).to eq(true) + end + end + + describe "#valid_yet?" do + it "returns true" do + expect(subject.valid_yet?).to eq(true) + end + end + end + + context "current date in valid range" do + before do + expect(Time).to receive(:now).and_return(Time.new(2015, 1, 1)) + end + + describe "#expired?" do + it "returns false" do + expect(subject.expired?).to eq(false) + end + end + + describe "#valid_yet?" do + it "returns true" do + expect(subject.valid_yet?).to eq(true) + end + end + end + + describe "#subject_name" do + it "returns subject name" do + expect(subject.subject_name).to eq("linux-1hyn") + end + end + + describe "#subject_organization" do + it "returns subject organization name" do + expect(subject.subject_organization).to eq("WebYaST") + end + end + + describe "#subject_organization_unit" do + it "returns subject organization unit name" do + expect(subject.subject_organization_unit).to eq("WebYaST") + end + end + + describe "#issuer_name" do + it "returns issuer name" do + expect(subject.issuer_name).to eq("linux-1hyn") + end + end + + describe "#issuer_organization" do + it "returns issuer organization name" do + expect(subject.issuer_organization).to eq("WebYaST") + end + end + + describe "#issuer_organization_unit" do + it "returns issuer organization unit name" do + expect(subject.issuer_organization_unit).to eq("WebYaST") + end + end + + describe "#import_to_instsys" do + before do + allow(subject.x509_cert).to receive(:to_pem).and_return("CERTIFICATE") + end + + it "copies the certificate to the default instsys path" do + allow(described_class).to receive(:update_instsys_ca) + expect(File).to receive(:write) + .with(described_class.default_certificate_path, "CERTIFICATE") + + subject.import_to_instsys + end + + context "when successfully updates system CA certificates" do + before do + allow(File).to receive(:write) + expect(described_class).to receive(:update_instsys_ca).and_return(true) + end + + it "returns true" do + expect(subject.import_to_instsys).to eq(true) + end + end + + context "when fails to update system CA certificates" do + before do + allow(File).to receive(:write) + expect(described_class).to receive(:update_instsys_ca).and_return(false) + end + + it "returns false" do + expect(subject.import_to_instsys).to eq(false) + end + end + end + + describe "#import" do + it "installs the certificate in the instsys" do + expect(subject).to receive(:import_to_instsys) + subject.import + end + end +end diff --git a/service/test/agama/ssl/fingerprint_spec.rb b/service/test/agama/ssl/fingerprint_spec.rb new file mode 100755 index 0000000000..3fce51238d --- /dev/null +++ b/service/test/agama/ssl/fingerprint_spec.rb @@ -0,0 +1,66 @@ +#! /usr/bin/env rspec + +require_relative "../../test_helper" + +require "agama/ssl/fingerprint" + +describe Agama::SSL::Fingerprint do + # checksum examples + let(:sha1) { "A8:DE:08:B1:57:52:FE:70:DF:D5:31:EA:E3:53:BB:39:EE:01:FF:B9" } + let(:sha256) do + "2A:02:DA:EC:A9:FF:4C:B4:A6:C0:57:08:F6:1C:8B:B0:94:FA:F4:" \ + "60:96:5E:18:48:CA:84:81:48:60:F3:CB:BF" + end + + describe "== operator" do + it "returns true when comparing self" do + fp1 = Agama::SSL::Fingerprint.new(Agama::SSL::Fingerprint::SHA1, sha1) + fp2 = fp1 + expect(fp1).to eq(fp2) + end + + it "returns true when comparing to the identical fingerprint" do + fp1 = Agama::SSL::Fingerprint.new(Agama::SSL::Fingerprint::SHA1, sha1) + fp2 = Agama::SSL::Fingerprint.new(Agama::SSL::Fingerprint::SHA1, sha1) + expect(fp1 == fp2).to eq(true) + end + + it "returns false when sum type does not match" do + fp1 = Agama::SSL::Fingerprint.new(Agama::SSL::Fingerprint::SHA1, sha1) + fp2 = Agama::SSL::Fingerprint.new(Agama::SSL::Fingerprint::SHA256, sha1) + expect(fp1 == fp2).to eq(false) + end + + it "returns false when fingerprint value does not match" do + fp1 = Agama::SSL::Fingerprint.new(Agama::SSL::Fingerprint::SHA1, sha1) + fp2 = Agama::SSL::Fingerprint.new(Agama::SSL::Fingerprint::SHA1, sha256) + expect(fp1 == fp2).to eq(false) + end + + it "compares the fingerprints case insensitively" do + fp1 = Agama::SSL::Fingerprint.new(Agama::SSL::Fingerprint::SHA1, sha1.downcase) + fp2 = Agama::SSL::Fingerprint.new(Agama::SSL::Fingerprint::SHA1, sha1.upcase) + expect(fp1.value).to_not eq(fp2.value) + expect(fp1 == fp2).to eq(true) + end + + it "compares the sum types case insensitively" do + fp1 = Agama::SSL::Fingerprint.new(Agama::SSL::Fingerprint::SHA1.downcase, sha1) + fp2 = Agama::SSL::Fingerprint.new(Agama::SSL::Fingerprint::SHA1.upcase, sha1) + expect(fp1.sum).to_not eq(fp2.sum) + expect(fp1 == fp2).to eq(true) + end + + it "ignores optional colon separator in fingerprints" do + fp1 = Agama::SSL::Fingerprint.new(Agama::SSL::Fingerprint::SHA1, sha1.tr(":", "")) + fp2 = Agama::SSL::Fingerprint.new(Agama::SSL::Fingerprint::SHA1, sha1) + expect(fp1.value).to_not eq(fp2.value) + expect(fp1 == fp2).to eq(true) + end + + it "returns false when compared to nil" do + fp1 = Agama::SSL::Fingerprint.new(Agama::SSL::Fingerprint::SHA1, sha1) + expect(fp1).to_not eq(nil) + end + end +end diff --git a/service/test/fixtures/anchors/openssl/8820a2e8.0 b/service/test/fixtures/anchors/openssl/8820a2e8.0 new file mode 120000 index 0000000000..46267d6c34 --- /dev/null +++ b/service/test/fixtures/anchors/openssl/8820a2e8.0 @@ -0,0 +1 @@ +/checkout/service/test/fixtures/anchors/openssl/YaST_Team.pem \ No newline at end of file diff --git a/service/test/fixtures/anchors/openssl/8f13f82e.0 b/service/test/fixtures/anchors/openssl/8f13f82e.0 new file mode 120000 index 0000000000..46267d6c34 --- /dev/null +++ b/service/test/fixtures/anchors/openssl/8f13f82e.0 @@ -0,0 +1 @@ +/checkout/service/test/fixtures/anchors/openssl/YaST_Team.pem \ No newline at end of file diff --git a/service/test/fixtures/anchors/openssl/YaST_Team.pem b/service/test/fixtures/anchors/openssl/YaST_Team.pem new file mode 100644 index 0000000000..82de302854 --- /dev/null +++ b/service/test/fixtures/anchors/openssl/YaST_Team.pem @@ -0,0 +1,23 @@ +-----BEGIN TRUSTED CERTIFICATE----- +MIIDvzCCAqegAwIBAgIJAJNA85YnEsdeMA0GCSqGSIb3DQEBBQUAMHYxCzAJBgNV +BAYTAkVTMRMwEQYDVQQIDApMYXMgUGFsbWFzMSMwIQYDVQQHDBpMYXMgUGFsbWFz +IGRlIEdyYW4gQ2FuYXJpYTEZMBcGA1UECgwQU1VTRSBMSU5VWCwgR21iSDESMBAG +A1UECwwJWWFTVCBUZWFtMB4XDTE2MDcyOTA3NDIyNloXDTE2MDgyODA3NDIyNlow +djELMAkGA1UEBhMCRVMxEzARBgNVBAgMCkxhcyBQYWxtYXMxIzAhBgNVBAcMGkxh +cyBQYWxtYXMgZGUgR3JhbiBDYW5hcmlhMRkwFwYDVQQKDBBTVVNFIExJTlVYLCBH +bWJIMRIwEAYDVQQLDAlZYVNUIFRlYW0wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAw +ggEKAoIBAQC/1CmF0hSYw6lQhmR26fT4iW5mOueoRHkuCOqp5zFCB3b0/8kgNUQm +/BXBrrBgYML9CvRXXNFsUj7BQuEE78eJBvcLnQdpoJZOjcZa5QC/cmzRbouDvfaV +dIJGBhvG1QlExnmXf2eHArtwq3xIkjAUUuhiL2uhOsH5TV0USHrJK5mhcdHB1ZsF +USW9joptWUC1LtcSt95X2B1PUn3UnSKVeU4V16w3Z/TRGjUxBl6iXnDVMNVXCMFN +MTRMDnY1BYv++XXy9jhxXgX5wqQ99sRx3b6LSXeNAF1ek/6nuHyfj5qXhPigE7TX +2nnVGQP9ZoDzfdQSU57TwamG/LdU5L6vAgMBAAGjUDBOMB0GA1UdDgQWBBRrNGbo +10cn76hsFWokfWxEFvXWLTAfBgNVHSMEGDAWgBRrNGbo10cn76hsFWokfWxEFvXW +LTAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBBQUAA4IBAQAGIGnaWbrIWdNvxZWI +Gc/knvytBB7zwhk7zyijQMKiZY4LRqv/B4B1BSWmoYs/VBWYULLzMRcX1QttOVpr +6zP/SdxSFmRTa8ZhrHWOfxHVS1Jp9IWe9s5xmt5tr34L+i2mGd6wTwUmaWZFqICO +N6mhaOvpeMZEowdQWsoqklbXJrQ1COHm0ogpcODWDKwr2plJa29FMwecZEmfaZeX +X6yUCim0ASV9Eh7rQi6iNLFyGnqAXtRnm83Sf/xKinIjHUFXvjjRvZS1wOSElSYS +WjG2xs4u+6+lrCJ1H5ZgII8EFfxPf53e1f8zl058RVvAMA+SGYjN0fhl3KBcIJqu +62cNMAsMCVlhU1QgVGVhbQ== +-----END TRUSTED CERTIFICATE----- diff --git a/service/test/fixtures/anchors/pem/8820a2e8.0 b/service/test/fixtures/anchors/pem/8820a2e8.0 new file mode 120000 index 0000000000..09151dc5e0 --- /dev/null +++ b/service/test/fixtures/anchors/pem/8820a2e8.0 @@ -0,0 +1 @@ +/checkout/service/test/fixtures/anchors/pem/YaST_Team.pem \ No newline at end of file diff --git a/service/test/fixtures/anchors/pem/8f13f82e.0 b/service/test/fixtures/anchors/pem/8f13f82e.0 new file mode 120000 index 0000000000..09151dc5e0 --- /dev/null +++ b/service/test/fixtures/anchors/pem/8f13f82e.0 @@ -0,0 +1 @@ +/checkout/service/test/fixtures/anchors/pem/YaST_Team.pem \ No newline at end of file diff --git a/service/test/fixtures/anchors/pem/YaST_Team.pem b/service/test/fixtures/anchors/pem/YaST_Team.pem new file mode 100644 index 0000000000..c8cc9e1da0 --- /dev/null +++ b/service/test/fixtures/anchors/pem/YaST_Team.pem @@ -0,0 +1,23 @@ +-----BEGIN CERTIFICATE----- +MIIDvzCCAqegAwIBAgIJAJNA85YnEsdeMA0GCSqGSIb3DQEBBQUAMHYxCzAJBgNV +BAYTAkVTMRMwEQYDVQQIDApMYXMgUGFsbWFzMSMwIQYDVQQHDBpMYXMgUGFsbWFz +IGRlIEdyYW4gQ2FuYXJpYTEZMBcGA1UECgwQU1VTRSBMSU5VWCwgR21iSDESMBAG +A1UECwwJWWFTVCBUZWFtMB4XDTE2MDcyOTA3NDIyNloXDTE2MDgyODA3NDIyNlow +djELMAkGA1UEBhMCRVMxEzARBgNVBAgMCkxhcyBQYWxtYXMxIzAhBgNVBAcMGkxh +cyBQYWxtYXMgZGUgR3JhbiBDYW5hcmlhMRkwFwYDVQQKDBBTVVNFIExJTlVYLCBH +bWJIMRIwEAYDVQQLDAlZYVNUIFRlYW0wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAw +ggEKAoIBAQC/1CmF0hSYw6lQhmR26fT4iW5mOueoRHkuCOqp5zFCB3b0/8kgNUQm +/BXBrrBgYML9CvRXXNFsUj7BQuEE78eJBvcLnQdpoJZOjcZa5QC/cmzRbouDvfaV +dIJGBhvG1QlExnmXf2eHArtwq3xIkjAUUuhiL2uhOsH5TV0USHrJK5mhcdHB1ZsF +USW9joptWUC1LtcSt95X2B1PUn3UnSKVeU4V16w3Z/TRGjUxBl6iXnDVMNVXCMFN +MTRMDnY1BYv++XXy9jhxXgX5wqQ99sRx3b6LSXeNAF1ek/6nuHyfj5qXhPigE7TX +2nnVGQP9ZoDzfdQSU57TwamG/LdU5L6vAgMBAAGjUDBOMB0GA1UdDgQWBBRrNGbo +10cn76hsFWokfWxEFvXWLTAfBgNVHSMEGDAWgBRrNGbo10cn76hsFWokfWxEFvXW +LTAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBBQUAA4IBAQAGIGnaWbrIWdNvxZWI +Gc/knvytBB7zwhk7zyijQMKiZY4LRqv/B4B1BSWmoYs/VBWYULLzMRcX1QttOVpr +6zP/SdxSFmRTa8ZhrHWOfxHVS1Jp9IWe9s5xmt5tr34L+i2mGd6wTwUmaWZFqICO +N6mhaOvpeMZEowdQWsoqklbXJrQ1COHm0ogpcODWDKwr2plJa29FMwecZEmfaZeX +X6yUCim0ASV9Eh7rQi6iNLFyGnqAXtRnm83Sf/xKinIjHUFXvjjRvZS1wOSElSYS +WjG2xs4u+6+lrCJ1H5ZgII8EFfxPf53e1f8zl058RVvAMA+SGYjN0fhl3KBcIJqu +62cN +-----END CERTIFICATE----- diff --git a/service/test/fixtures/test.pem b/service/test/fixtures/test.pem new file mode 100644 index 0000000000..f1bd4e82c0 --- /dev/null +++ b/service/test/fixtures/test.pem @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDLjCCAhYCCQC4q/Fz5B8QTTANBgkqhkiG9w0BAQUFADBZMRMwEQYDVQQDEwps +aW51eC0xaHluMR4wHAYJKoZIhvcNAQkBFg9yb290QGxpbnV4LTFoeW4xEDAOBgNV +BAoTB1dlYllhU1QxEDAOBgNVBAsTB1dlYllhU1QwHhcNMTQwNDI0MDgxNjMxWhcN +MTcwNDIzMDgxNjMxWjBZMRMwEQYDVQQDEwpsaW51eC0xaHluMR4wHAYJKoZIhvcN +AQkBFg9yb290QGxpbnV4LTFoeW4xEDAOBgNVBAoTB1dlYllhU1QxEDAOBgNVBAsT +B1dlYllhU1QwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCsSh+SznSW +2fqdJyn8iE/IaLFKnMUxViOxsVYhCAywVNqANUQagLVCBhhPXtyFenXAvnllmewl +XQiD6O+xntWfqy14fZHZ7dg3kNQYjTKuCPdVWCkXrrMvne5of3xh/AJwU9fU9iCg +PubY3fNlqPq+bMCf5LssgdqniQnUPkvX+WdojXAszYZACYPxt2fP3+btjtD1ueRr +JW/pML864sQk11Mm2JT8ShWGSfn6o9qTzgROItv+Y5p2TWEoW6pp70cYpMOfHgrJ +7a1ZexLKHhYqQdMFDzsJwafGGM4sbLXw26Elw+1x1nkzSjuToAGzGzjd3jPYmXhu +dQlqE1VKaL9RAgMBAAEwDQYJKoZIhvcNAQEFBQADggEBACqRrWDJbnqq805gkF/E +mpuZBvTvO6db/Nng5v8Ob1+j26U4hi8/vmn7hufG4Uv7CIsLkEdkqrC/r6Gjw43j +iIfWF0mjN3OK0C919uA8N+cszvwc1XZVfIRkY7KAk5UQcYMMlrxii5WFJfLxFbZk +NgeIEOKDdrQBaKuMCS9KKU5zuujzJ6wDEO2xlbt24Fes4M+ZDsFw7D0IK63R1Ol6 +wddW/Y0WoWWPO0KE5PGNC7Avg66y5QJDTpwtjqQTcl22xBAGWImw4iFG0kwDDLvu +oaa/jexkWza+2CKAoN/vDMXmdRuxRY+61RcC0Xp1hDorZ5Cc4Qv0inFdC5pKto/p +RNU= +-----END CERTIFICATE----- From 72873ffde8944a49e97158498a05c85fe0cb6f77 Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Wed, 16 Apr 2025 09:50:32 +0200 Subject: [PATCH 02/23] experiment with paragraphs --- service/lib/agama/registration.rb | 12 +++++++++--- service/test/agama/registration_test.rb | 2 +- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/service/lib/agama/registration.rb b/service/lib/agama/registration.rb index 61404d004e..ad7e843505 100644 --- a/service/lib/agama/registration.rb +++ b/service/lib/agama/registration.rb @@ -323,7 +323,7 @@ def catch_registration_errors(&block) SSL::Errors.instance.ssl_failed_cert.import retry end - false + raise e end end @@ -335,12 +335,18 @@ def handle_ssl_error(_error, certificate_imported) puts "cert #{cert} code #{error_code}" if cert && SSL::ErrorCodes::IMPORT_ERROR_CODES.include?(error_code) - message = format( - _("Secure Connection Error for %{url}: %{error}. Certificate details %{details}."), + error_msg = format( + _("Secure Connection Error for %{url}: %{error}."), url: registration_url || "https://scc.suse.com", error: SSL::ErrorCodes::OPENSSL_ERROR_MESSAGES[error_code], + ) + cert_details = format( + _("Certificate details %{details}."), details: SSL::CertificateDetails.new(cert).summary ) + + message = "

#{error_msg}

#{cert_details}

" + question = Agama::Question.new( qclass: "registration.certificate", text: message, diff --git a/service/test/agama/registration_test.rb b/service/test/agama/registration_test.rb index 52edf6c21d..4ef8854c8d 100644 --- a/service/test/agama/registration_test.rb +++ b/service/test/agama/registration_test.rb @@ -239,7 +239,7 @@ before do Agama::SSL::Errors.instance.ssl_error_code = Agama::SSL::ErrorCodes::SELF_SIGNED_CERT Agama::SSL::Errors.instance.ssl_error_msg = "test error" - Agama::SSL::Errors.instance.ssl_failed_cert = Agama::SSL::Certificate.new("test") + Agama::SSL::Errors.instance.ssl_failed_cert = Agama::SSL::Certificate.load(File.read(File.join(FIXTURES_PATH, "test.pem"))) # mock reset to avoid previous setup allow(Agama::SSL::Errors.instance).to receive(:reset) From abf9ff09faf8e700ee18d9a8b481d5c92e61d7eb Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Wed, 16 Apr 2025 13:25:36 +0200 Subject: [PATCH 03/23] add fingerprint storage and use it in registration --- service/lib/agama/registration.rb | 10 +++++++- service/lib/agama/ssl/certificate.rb | 4 +++ service/lib/agama/ssl/storage.rb | 16 ++++++++++++ service/test/agama/registration_test.rb | 33 ++++++++++++++++++++++--- 4 files changed, 58 insertions(+), 5 deletions(-) create mode 100644 service/lib/agama/ssl/storage.rb diff --git a/service/lib/agama/registration.rb b/service/lib/agama/registration.rb index ad7e843505..85091f822a 100644 --- a/service/lib/agama/registration.rb +++ b/service/lib/agama/registration.rb @@ -32,6 +32,7 @@ require "agama/ssl/certificate" require "agama/ssl/certificate_details" require "agama/ssl/errors" +require "agama/ssl/storage" Yast.import "Arch" Yast.import "Pkg" @@ -318,6 +319,7 @@ def catch_registration_errors(&block) rescue OpenSSL::SSL::SSLError => e @logger.error "OpenSSL error: #{e}" should_retry = handle_ssl_error(e, certificate_imported) + puts "handle ssl error #{should_retry}" if should_retry certificate_imported = true SSL::Errors.instance.ssl_failed_cert.import @@ -333,7 +335,13 @@ def handle_ssl_error(_error, certificate_imported) cert = SSL::Errors.instance.ssl_failed_cert error_code = SSL::Errors.instance.ssl_error_code - puts "cert #{cert} code #{error_code}" + if cert + SSL::Storage.instance.fingerprints.each do |fp| + # import certificate if it match predefined fingerprint + return true if cert.match_fingerprint?(fp) + end + end + if cert && SSL::ErrorCodes::IMPORT_ERROR_CODES.include?(error_code) error_msg = format( _("Secure Connection Error for %{url}: %{error}."), diff --git a/service/lib/agama/ssl/certificate.rb b/service/lib/agama/ssl/certificate.rb index 80a2907a40..54ce6883d5 100644 --- a/service/lib/agama/ssl/certificate.rb +++ b/service/lib/agama/ssl/certificate.rb @@ -160,6 +160,10 @@ def issuer_organization_unit find_issuer_attribute("OU") end + def match_fingerprint?(fp) + fp == fingerprint(fp.sum) + end + def fingerprint(sum) case sum.upcase when Fingerprint::SHA1 diff --git a/service/lib/agama/ssl/storage.rb b/service/lib/agama/ssl/storage.rb new file mode 100644 index 0000000000..5cc9082371 --- /dev/null +++ b/service/lib/agama/ssl/storage.rb @@ -0,0 +1,16 @@ +require "singleton" + +module Agama + module SSL + class Storage + include Singleton + + # @return [Array] + attr_reader :fingerprints + + def initialize + @fingerprints = [] + end + end + end +end diff --git a/service/test/agama/registration_test.rb b/service/test/agama/registration_test.rb index 4ef8854c8d..52a8a5f6ab 100644 --- a/service/test/agama/registration_test.rb +++ b/service/test/agama/registration_test.rb @@ -236,14 +236,37 @@ end context "if the registration server has self-signed certificate" do + let (:certificate) { Agama::SSL::Certificate.load(File.read(File.join(FIXTURES_PATH, "test.pem"))) } before do Agama::SSL::Errors.instance.ssl_error_code = Agama::SSL::ErrorCodes::SELF_SIGNED_CERT Agama::SSL::Errors.instance.ssl_error_msg = "test error" - Agama::SSL::Errors.instance.ssl_failed_cert = Agama::SSL::Certificate.load(File.read(File.join(FIXTURES_PATH, "test.pem"))) - # mock reset to avoid previous setup + Agama::SSL::Errors.instance.ssl_failed_cert = certificate + # mock reset to avoid deleting of previous setup allow(Agama::SSL::Errors.instance).to receive(:reset) - allow(SUSE::Connect::YaST).to receive(:activate_product).and_raise(OpenSSL::SSL::SSLError.new("test")) + @called = 0 + allow(SUSE::Connect::YaST).to receive(:activate_product) do + @called += 1 + raise(OpenSSL::SSL::SSLError.new("test")) if @called == 1 + + service + end + end + + context "and certificate fingerprint is in storage" do + before do + Agama::SSL::Storage.instance.fingerprints.replace([certificate.send(:sha256_fingerprint)]) + end + + it "tries to import certificate" do + expect(certificate).to receive(:import) + + subject.register("11112222", email: "test@test.com") + end + + after do + Agama::SSL::Storage.instance.fingerprints.clear + end end it "opens question" do @@ -254,7 +277,9 @@ expect(Agama::DBus::Clients::Questions).to receive(:new) .and_return(q_client) - subject.register("11112222", email: "test@test.com") + expect { subject.register("11112222", email: "test@test.com") }.to( + raise_error(OpenSSL::SSL::SSLError) + ) end end end From 65ceb8d1599a4e07a092edb2c841623974a2570c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Wed, 16 Apr 2025 14:41:45 +0100 Subject: [PATCH 04/23] service: add data to certificate question --- service/lib/agama/registration.rb | 57 ++++++++++++++----------------- 1 file changed, 25 insertions(+), 32 deletions(-) diff --git a/service/lib/agama/registration.rb b/service/lib/agama/registration.rb index 85091f822a..e28f64455e 100644 --- a/service/lib/agama/registration.rb +++ b/service/lib/agama/registration.rb @@ -333,42 +333,35 @@ def handle_ssl_error(_error, certificate_imported) return false if certificate_imported cert = SSL::Errors.instance.ssl_failed_cert - error_code = SSL::Errors.instance.ssl_error_code - - if cert - SSL::Storage.instance.fingerprints.each do |fp| - # import certificate if it match predefined fingerprint - return true if cert.match_fingerprint?(fp) - end - end - - if cert && SSL::ErrorCodes::IMPORT_ERROR_CODES.include?(error_code) - error_msg = format( - _("Secure Connection Error for %{url}: %{error}."), - url: registration_url || "https://scc.suse.com", - error: SSL::ErrorCodes::OPENSSL_ERROR_MESSAGES[error_code], - ) - cert_details = format( - _("Certificate details %{details}."), - details: SSL::CertificateDetails.new(cert).summary - ) + return false unless cert - message = "

#{error_msg}

#{cert_details}

" + # Import certificate if it matches predefined fingerprint. + return true if SSL::Storage.instance.fingerprints.any? { |f| cert.match_fingerprint?(f) } - question = Agama::Question.new( - qclass: "registration.certificate", - text: message, - options: [:Import, :Abort], - default_option: :Abort - ) + error_code = SSL::Errors.instance.ssl_error_code + return false unless SSL::ErrorCodes::IMPORT_ERROR_CODES.include?(error_code) + + details = SSL::CertificateDetails.new(cert) + + question = Agama::Question.new( + qclass: "registration.certificate", + text: _("Secure connection error. Import certificate?"), + options: [:Import, :Abort], + default_option: :Abort, + data: { + url: registration_url || "https://scc.suse.com", + error: SSL::ErrorCodes::OPENSSL_ERROR_MESSAGES[error_code], + subject: details.subject, + issuer: details.issuer, + summary: details.summary, + fingerprints: SSL::Storage.instance.fingerprints + } + ) - questions_client = Agama::DBus::Clients::Questions.new(logger: @logger) - questions_client.ask(question) do |question_client| - return question_client.answer == :Import - end + questions_client = Agama::DBus::Clients::Questions.new(logger: @logger) + questions_client.ask(question) do |question_client| + return question_client.answer == :Import end - - false end # Returns the URL of the registration server From 5946bbb04818a6a203df1b7e49a49b961faa4383 Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Wed, 16 Apr 2025 21:35:56 +0200 Subject: [PATCH 05/23] allow registration url be redefined and also add ssl fingerprints --- service/lib/agama/dbus/software/manager.rb | 20 ++++++++++++++++++++ service/lib/agama/dbus/software/product.rb | 11 +++++++++++ service/lib/agama/registration.rb | 8 +++++++- 3 files changed, 38 insertions(+), 1 deletion(-) diff --git a/service/lib/agama/dbus/software/manager.rb b/service/lib/agama/dbus/software/manager.rb index 9ef26becf7..9f638940e4 100644 --- a/service/lib/agama/dbus/software/manager.rb +++ b/service/lib/agama/dbus/software/manager.rb @@ -162,8 +162,28 @@ def locale=(locale) end end + def ssl_fingerprints + ssl_storage.fingerprints.map { |f| [f.sum, f.value] } + end + + def ssl_fingerprints=(new_fps) + fps = new_fps.map { |f| SSL::Fingerprint.new(f[0], f[1]) } + ssl_storage.fingerprints.replace(fps) + end + + SECURITY_INTERFACE = "org.opensuse.Agama.Security" + private_constant :SECURITY_INTERFACE + + dbus_interface SOFTWARE_INTERFACE do + # List of SSL fingerprints serialized into type and its value + dbus_accessor :ssl_fingerprints, "a(ss)" + end + private + def ssl_storage + SSL::Storage.instance + end # @return [Agama::Software] attr_reader :backend diff --git a/service/lib/agama/dbus/software/product.rb b/service/lib/agama/dbus/software/product.rb index b44eb5cb7e..4c5c27f6fd 100644 --- a/service/lib/agama/dbus/software/product.rb +++ b/service/lib/agama/dbus/software/product.rb @@ -136,6 +136,15 @@ def email backend.registration.email || "" end + def url + backend.registration.registration_url || "" + end + + def url=(url) + # dbus has problem with nils, so empty string is only for dbus nil + backend.registration.registration_url = url.empty? ? nil : url + end + # list of already registered addons # # @return [Array>] each list contains three items: addon id, version and @@ -294,6 +303,8 @@ def deregister dbus_reader(:email, "s") + dbus_accessor(:url, "s") + dbus_reader(:registered_addons, "a(sss)") dbus_reader(:available_addons, "aa{sv}") diff --git a/service/lib/agama/registration.rb b/service/lib/agama/registration.rb index e28f64455e..c507ea1965 100644 --- a/service/lib/agama/registration.rb +++ b/service/lib/agama/registration.rb @@ -67,6 +67,11 @@ class Registration # @return [Array] attr_reader :registered_addons + # Overwriten default registration URL + # + # @return [String, nil] + attr_accessor :registration_url + # @param software_manager [Agama::Software::Manager] # @param logger [Logger] def initialize(software_manager, logger) @@ -75,6 +80,7 @@ def initialize(software_manager, logger) @services = [] @credentials_files = [] @registered_addons = [] + @registration_url = registration_url_from_cmdline end # Registers the selected product. @@ -369,7 +375,7 @@ def handle_ssl_error(_error, certificate_imported) # At this point, it just checks the kernel's command-line. # # @return [String, nil] - def registration_url + def registration_url_from_cmdline cmdline_args = CmdlineArgs.read cmdline_args.data["register_url"] end From 930f816d700fb1428d5c72e8bc4fd9239c2cfb36 Mon Sep 17 00:00:00 2001 From: Josef Reidinger Date: Thu, 17 Apr 2025 10:24:37 +0200 Subject: [PATCH 06/23] update dbus doc and fix typo --- doc/dbus/bus/org.opensuse.Agama.Software1.Product.bus.xml | 1 + doc/dbus/bus/org.opensuse.Agama.Software1.bus.xml | 3 +++ doc/dbus/org.opensuse.Agama.Software1.doc.xml | 3 +++ doc/dbus/org.opensuse.Agama1.Registration.doc.xml | 4 ++++ service/lib/agama/dbus/software/manager.rb | 2 +- 5 files changed, 12 insertions(+), 1 deletion(-) diff --git a/doc/dbus/bus/org.opensuse.Agama.Software1.Product.bus.xml b/doc/dbus/bus/org.opensuse.Agama.Software1.Product.bus.xml index 7bd3ab733f..360c277ef0 100644 --- a/doc/dbus/bus/org.opensuse.Agama.Software1.Product.bus.xml +++ b/doc/dbus/bus/org.opensuse.Agama.Software1.Product.bus.xml @@ -48,6 +48,7 @@ + diff --git a/doc/dbus/bus/org.opensuse.Agama.Software1.bus.xml b/doc/dbus/bus/org.opensuse.Agama.Software1.bus.xml index b2c21475f8..965d06450c 100644 --- a/doc/dbus/bus/org.opensuse.Agama.Software1.bus.xml +++ b/doc/dbus/bus/org.opensuse.Agama.Software1.bus.xml @@ -82,4 +82,7 @@ + + + diff --git a/doc/dbus/org.opensuse.Agama.Software1.doc.xml b/doc/dbus/org.opensuse.Agama.Software1.doc.xml index 0080f876ff..aab14669af 100644 --- a/doc/dbus/org.opensuse.Agama.Software1.doc.xml +++ b/doc/dbus/org.opensuse.Agama.Software1.doc.xml @@ -98,4 +98,7 @@ + + + diff --git a/doc/dbus/org.opensuse.Agama1.Registration.doc.xml b/doc/dbus/org.opensuse.Agama1.Registration.doc.xml index fa510b0eaa..750c11ee7d 100644 --- a/doc/dbus/org.opensuse.Agama1.Registration.doc.xml +++ b/doc/dbus/org.opensuse.Agama1.Registration.doc.xml @@ -63,6 +63,10 @@ Email used for the registration. Empty if the current product is not registered yet. --> + +