yast/yast-storage-ng

View on GitHub
src/lib/y2storage/encryption_processes/secure_key.rb

Summary

Maintainability
A
0 mins
Test Coverage
# Copyright (c) [2019-2020] SUSE LLC
#
# All Rights Reserved.
#
# This program is free software; you can redistribute it and/or modify it
# under the terms of version 2 of the GNU General Public License as published
# by the Free Software Foundation.
#
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along
# with this program; if not, contact SUSE LLC.
#
# To contact SUSE LLC about this file by physical or electronic mail, you may
# find current contact information at www.suse.com.

require "fileutils"
require "yast2/execute"
require "y2storage/encryption_processes/apqn"
require "y2storage/encryption_processes/secure_key_volume"
require "yast"

module Y2Storage
  module EncryptionProcesses
    # Class representing the secure AES keys managed by the Crypto Express CCA
    # coprocessors found in the system.
    #
    # For more information, see
    # https://www.ibm.com/support/knowledgecenter/linuxonibm/com.ibm.linux.z.lxdc/lxdc_zkey_reference.html
    class SecureKey
      include Yast::Logger

      # Location of the zkey command
      ZKEY = "/usr/bin/zkey".freeze
      private_constant :ZKEY

      # Default location of the zkey repository
      DEFAULT_REPO_DIR = File.join("/", "etc", "zkey", "repository")
      private_constant :DEFAULT_REPO_DIR

      # @return [String] name of the secure key
      attr_reader :name

      # @return [Integer, nil] sector size in bytes
      attr_reader :sector_size

      # Constructor
      #
      # @note: creating a SecureKey object does not generate a new record for it
      # in the keys database. See {#generate}.
      #
      # @param name [String] see {#name}
      # @param sector_size [Integer, nil] see {#sector_size}
      # @param apqns [Array<Apqn>] APQNs to use for generating the secure key
      def initialize(name, sector_size: nil, apqns: [])
        @name = name
        @volume_entries = []
        @sector_size = sector_size
        @apqns = apqns
      end

      # Whether the key contains an entry in its list of volumes referencing the
      # given device
      #
      # @param device [BlkDevice, Encryption] it can be the plain device being
      #   encrypted or the resulting encryption device
      # @return [Boolean]
      def for_device?(device)
        !!volume_entry(device)
      end

      # DeviceMapper name registered in this key for the given device
      #
      # @param device [BlkDevice, Encryption] it can be the plain device being
      #   encrypted or the resulting encryption device
      # @return [String, nil] nil if the current key contain no information
      #   about the device or whether it does not specify a DeviceMapper name
      #   for it
      def dm_name(device)
        volume_entry(device)&.dm_name
      end

      # For the given device, name with which the plain device is registered
      # in this key
      #
      # @param device [BlkDevice, Encryption] it can be the plain device being
      #   encrypted or the resulting encryption device
      # @return [String, nil] nil if the current key contain no information
      #   about the device
      def plain_name(device)
        volume_entry(device)&.plain_name
      end

      # Adds the given device to the list of volumes registered for this key
      #
      # @note This only modifies the current object in memory, it does not imply
      #   saving the volume entry in the keys database.
      #
      # @param device [Encryption]
      # @return [SecureKeyVolume] the newly added SecureKeyVolume
      def add_device(device)
        @volume_entries << SecureKeyVolume.new_from_encryption(device)
        @volume_entries.last
      end

      # Adds the given device to the list of volumes registered for this key
      #
      # @param device [Encryption]
      # @return [SecureKeyVolume] the newly added SecureKeyVolume
      def add_device_and_write(device)
        secure_key_volume = add_device(device)

        Yast::Execute.locally(ZKEY, "change", "--name", name, "--volumes",
          "+#{secure_key_volume}")

        secure_key_volume
      end

      # Registers the key in the keys database by invoking "zkey generate"
      #
      # The generated key will have the name and the list of volumes from this
      # object. The rest of attributes will be set at the convenient values for
      # pervasive LUKS2 encryption, see {#generate_args}.
      def generate
        Yast::Execute.locally(ZKEY, "generate", *generate_args)
      end

      # Registers the key in the keys database by invoking "zkey generate"
      #
      # @see #generate
      #
      # @raise [Cheetah::ExecutionFailed] when the generation fails
      def generate!
        Yast::Execute.locally!(ZKEY, "generate", *generate_args)
      end

      # Removes a key from the keys database by invoking "zkey remove"
      def remove
        Yast::Execute.locally!(ZKEY, "remove", "--force", "--name", name)
      rescue Cheetah::ExecutionFailed => e
        log.error("Error removing the key - #{e.message}")
      end

      # Parses the representation of a secure key, in the format used by
      # "zkey list", and adds the corresponding volume entries to the list of
      # volumes registered for this key
      #
      # @note This only modifies the current object in memory, it does not imply
      #   saving the volume entries in the keys database.
      #
      # @param string [String] portion of the output of "zkey list" that
      #   represents a concrete secure key
      def add_zkey_volumes(string)
        # TODO: likely this method could be better implemented with
        # StringScanner

        vol_pattern = "\s+\/[^\s]*\s*\n"
        match_data = /\s* Volumes\s+:((#{vol_pattern})+)/.match(string)
        return [] unless match_data

        volumes_str = match_data[1]
        volumes = volumes_str.split("\n").map(&:strip)

        @volume_entries += volumes.map { |str| SecureKeyVolume.new_from_str(str) }
      end

      # Full filename of the secure key file.
      #
      # @return [String]
      def filename
        File.join(repo_dir, name + ".skey")
      end

      # Copies the files of this key from the current keys repository to the
      # repository of a target system
      #
      # @param base_dir [String] base directory where the target system is
      #   mounted, typically Yast::Installation.destdir
      def copy_to_repository(base_dir)
        target = repository_path(base_dir)
        return unless File.exist?(target)

        log.info "Copying files of key #{name} to #{target}"
        FileUtils.cp_r(Dir.glob("#{repository_path}/#{name}.*"), target, preserve: true)
        target_stat = File.stat(target)
        FileUtils.chown_R(target_stat.uid, target_stat.gid, Dir.glob("#{target}/#{name}.*"))
      rescue StandardError => e
        log.error "Error copying the key - #{e.message}"
      end

      private

      # @return [Array<SecureKeyVolume>] entries in the "volumes" section of
      #   this key
      attr_accessor :volume_entries

      # @return [Array<Apqn>]
      attr_reader :apqns

      # Volume entry associated to the given device
      #
      # @param device [BlkDevice, Encryption] it can be the plain device being
      #   encrypted or the resulting encryption device
      # @return [SecureKeyVolume, nil] nil if this key is not associated to the
      #   device
      def volume_entry(device)
        volume_entries.find { |vol| vol.match_device?(device) }
      end

      # Full path of the zkey repository for the system mounted at the given
      # location
      #
      # @param base_dir [String] mount point of the system, "/" means the
      #   currently running system
      # @return [String]
      def repository_path(base_dir = "/")
        (base_dir == "/") ? repo_dir : File.join(base_dir, DEFAULT_REPO_DIR)
      end

      # Full path of the current zkey repository
      #
      # @return [String]
      def repo_dir
        ENV["ZKEY_REPOSITORY"] || DEFAULT_REPO_DIR
      end

      # Arguments to be used with the "zkey generate" command
      #
      # @return [Array<String>]
      def generate_args
        args = [
          "-V",
          "--name", name,
          "--xts",
          "--keybits", "256",
          "--volume-type", "LUKS2"
        ]

        args += ["--sector-size", sector_size.to_s] if sector_size

        args += ["--volumes", volume_entries.map(&:to_s).join(",")] if volume_entries.any?

        args += ["--apqns", apqns.map(&:name).join(",")] if apqns.any?

        args
      end

      class << self
        # Whether it's possible to use secure AES keys in this system
        #
        # @return [Boolean]
        def available?
          Apqn.online.any?
        end

        # Registers a new secure key in the system's key database
        #
        # The name of the resulting key may be different (a numbered suffix is
        # added) if the given name is already taken.
        #
        # @param name [String] tentative name for the new key
        # @param sector_size [Integer,nil] sector size to set in the register.
        #   Use the nil to use the system's default.
        # @param volumes [Array<Encryption>] encryption devices to register in
        #   the "volumes" section of the new key
        # @param apqns [Array<Apqn>] APQNs to use
        #
        # @return [SecureKey] an object representing the new key
        def generate(name, sector_size: nil, volumes: [], apqns: [])
          key = new_for_generate(name, sector_size: sector_size, volumes: volumes, apqns: apqns)

          key.generate

          key
        end

        # Registers a new secure key in the system's key database
        #
        # @see #generate
        #
        # @raise [Cheetah::ExecutionFailed] when the generation fails
        def generate!(name, sector_size: nil, volumes: [], apqns: [])
          key = new_for_generate(name, sector_size: sector_size, volumes: volumes, apqns: apqns)

          key.generate!

          key
        end

        # Finds an existing secure key that references the given device in
        # one of its "volumes" entries
        #
        # @param device [BlkDevice] Block device to search the secure key for
        # @return [SecureKey, nil] nil if no key is found for the device
        def for_device(device)
          all.find { |key| key.for_device?(device) }
        end

        # Parses the representation of a secure key, in the format used by
        # "zkey list", and returns a SecureKey object representing it
        #
        # @param string [String] portion of the output of "zkey list" that
        #   represents a concrete secure key
        def new_from_zkey(string)
          lines = string.lines
          attrs = lines.map { |l| l.split(":", 2) }.each_with_object({}) do |parts, all|
            next if parts.size != 2

            all[parts[0].strip] = parts[1].strip
          end
          sector_size = attrs["Sector size"].start_with?(/\d/) ? attrs["Sector size"].to_i : nil
          key = new(attrs["Key"], sector_size: sector_size)
          key.add_zkey_volumes(string)
          key
        end

        private

        # All secure keys registered in the system
        #
        # @return [Array<SecureKey>]
        def all
          output = Yast::Execute.locally(ZKEY, "list", stdout: :capture)
          return [] if output&.empty?

          entries = output&.split("\n\n") || []
          entries.map { |entry| new_from_zkey(entry) }
        end

        # Creates a new secure key ready to be generated in the system (i.e., with an exclusive name and
        # with the associated volumes).
        #
        # @return [SecureKey]
        def new_for_generate(name, sector_size: nil, volumes: [], apqns: [])
          name = exclusive_name(name)
          key = new(name, sector_size: sector_size, apqns: apqns)
          volumes.each { |v| key.add_device(v) }

          key
        end

        # Returns the name that is available for a new key taking original_name
        # as a base. If the name is already taken by an existing key in the
        # system, the returned name will have a number appended.
        #
        # @param original_name [String]
        # @return [String]
        def exclusive_name(original_name)
          existing_names = all.map(&:name)
          return original_name unless existing_names.include?(original_name)

          suffix = 0
          name = "#{original_name}_#{suffix}"
          while existing_names.include?(name)
            suffix += 1
            name = "#{original_name}_#{suffix}"
          end
          name
        end
      end
    end
  end
end