yast/yast-registration

View on GitHub
src/lib/registration/ssl_certificate.rb

Summary

Maintainability
B
4 hrs
Test Coverage

require "openssl"
require "suse/connect"
require "registration/downloader"
require "registration/fingerprint"
require "yast2/execute"

module Registration
  # class handling SSL certificate
  # TODO: move it to yast2 to share it?
  class SslCertificate
    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
    #
    # During installation, the certificate should be written to a read-write
    # directory. On an installed system, the method relies in SUSEConnect.
    #
    # @return [String] Path to store the certificate
    def self.default_certificate_path
      Yast::Stage.initial ? INSTSYS_SERVER_CERT_FILE : SUSE::Connect::YaST::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)
      SslCertificate.new(cert)
    end

    def self.download(url, insecure: false)
      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_system
    # @see #import_to_instsys
    def import
      Yast::Stage.initial ? import_to_instsys : import_to_system
    end

    # Import a certificate to the installed system
    #
    # @return [Boolean] true if import was successful; false otherwise.
    def import_to_system
      ::SUSE::Connect::YaST.import_certificate(x509_cert)
      true
    rescue ::SUSE::Connect::SystemCallError => e
      log.error("Error updating system CA certificates: #{e.message}")
      false
    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

    # Import the old SSL certificate if present. Tries all known locations.
    # Uses Installation.destdir as the root system.
    def self.import_from_system
      prefix = Yast::Installation.destdir

      SslCertificate::PATHS.each do |file|
        cert_file = File.join(prefix, file)
        if File.exist?(cert_file)
          log.info("Importing the SSL certificate from other system: (#{prefix})#{file} ...")
          cert = SslCertificate.load_file(cert_file)
          cert.log_details
          if Yast::Stage.initial
            target_path = File.join(SslCertificate::INSTSYS_CERT_DIR, File.basename(cert_file))
            cert.import_to_instsys(target_path)
          else
            cert.import_to_system
          end
        else
          log.debug("SSL certificate (#{prefix})#{file} not found in the system")
        end
      end
    end

    # Log the certificate details
    def log_details
      require "registration/ssl_certificate_details"
      # log also the dates
      log.info("#{SslCertificateDetails.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