diff --git a/rust/agama-lib/share/examples/storage/model.json b/rust/agama-lib/share/examples/storage/model.json index 689f502d9d..7f2155246e 100644 --- a/rust/agama-lib/share/examples/storage/model.json +++ b/rust/agama-lib/share/examples/storage/model.json @@ -1,4 +1,8 @@ { + "encryption": { + "method": "luks1", + "password": "12345" + }, "boot": { "configure": true, "device": { diff --git a/rust/agama-lib/share/storage.model.schema.json b/rust/agama-lib/share/storage.model.schema.json index 6f6bed82aa..6a6bfb6b8f 100644 --- a/rust/agama-lib/share/storage.model.schema.json +++ b/rust/agama-lib/share/storage.model.schema.json @@ -5,6 +5,7 @@ "additionalProperties": false, "properties": { "boot": { "$ref": "#/$defs/boot" }, + "encryption": { "$ref": "#/$defs/encryption" }, "drives": { "type": "array", "items": { "$ref": "#/$defs/drive" } @@ -29,6 +30,22 @@ "name": { "type": "string" } } }, + "encryption": { + "type": "object", + "additionalProperties": false, + "required": ["method"], + "properties": { + "method": { "$ref": "#/$defs/encryptionMethod" }, + "password": { "type": "string" } + } + }, + "encryptionMethod": { + "enum": [ + "luks1", + "luks2", + "tpmFde" + ] + }, "drive": { "type": "object", "additionalProperties": false, diff --git a/rust/package/agama.changes b/rust/package/agama.changes index 094116cd86..e162e83458 100644 --- a/rust/package/agama.changes +++ b/rust/package/agama.changes @@ -1,3 +1,9 @@ +------------------------------------------------------------------- +Fri Feb 21 14:00:47 UTC 2025 - José Iván López González + +- Extend storage model schema to support global encryption + (gh#agama-project/agama#2031). + ------------------------------------------------------------------- Thu Feb 20 12:58:09 UTC 2025 - Ancor Gonzalez Sosa diff --git a/service/lib/agama/storage/config_conversions/from_model_conversions.rb b/service/lib/agama/storage/config_conversions/from_model_conversions.rb index 30a80ea9a1..5e86901b9d 100644 --- a/service/lib/agama/storage/config_conversions/from_model_conversions.rb +++ b/service/lib/agama/storage/config_conversions/from_model_conversions.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2024] SUSE LLC +# Copyright (c) [2024-2025] SUSE LLC # # All Rights Reserved. # @@ -23,6 +23,7 @@ require "agama/storage/config_conversions/from_model_conversions/boot_device" require "agama/storage/config_conversions/from_model_conversions/config" require "agama/storage/config_conversions/from_model_conversions/drive" +require "agama/storage/config_conversions/from_model_conversions/encryption" require "agama/storage/config_conversions/from_model_conversions/filesystem" require "agama/storage/config_conversions/from_model_conversions/filesystem_type" require "agama/storage/config_conversions/from_model_conversions/partition" diff --git a/service/lib/agama/storage/config_conversions/from_model_conversions/config.rb b/service/lib/agama/storage/config_conversions/from_model_conversions/config.rb index 382f49528d..336ea261c9 100644 --- a/service/lib/agama/storage/config_conversions/from_model_conversions/config.rb +++ b/service/lib/agama/storage/config_conversions/from_model_conversions/config.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2024] SUSE LLC +# Copyright (c) [2024-2025] SUSE LLC # # All Rights Reserved. # @@ -79,7 +79,6 @@ def convert_boot FromModelConversions::Boot.new(boot_model).convert end - # @return [Array, nil] def convert_drives return unless drive_models @@ -89,7 +88,9 @@ def convert_drives # @param drive_model [Hash] # @return [Configs::Drive] def convert_drive(drive_model) - FromModelConversions::Drive.new(drive_model, product_config).convert + FromModelConversions::Drive + .new(drive_model, product_config, model_json[:encryption]) + .convert end # Conversion for the boot device alias. diff --git a/service/lib/agama/storage/config_conversions/from_model_conversions/drive.rb b/service/lib/agama/storage/config_conversions/from_model_conversions/drive.rb index ab55e01243..461ee6d9cb 100644 --- a/service/lib/agama/storage/config_conversions/from_model_conversions/drive.rb +++ b/service/lib/agama/storage/config_conversions/from_model_conversions/drive.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2024] SUSE LLC +# Copyright (c) [2024-2025] SUSE LLC # # All Rights Reserved. # @@ -39,17 +39,22 @@ class Drive < Base # @param model_json [Hash] # @param product_config [Agama::Config] - def initialize(model_json, product_config) + # @param encryption_model [Hash, nil] + def initialize(model_json, product_config, encryption_model = nil) super(model_json) @product_config = product_config + @encryption_model = encryption_model end private + alias_method :drive_model, :model_json + # @return [Agama::Config] attr_reader :product_config - alias_method :drive_model, :model_json + # @return [Hash, nil] + attr_reader :encryption_model # @see Base # @return [Configs::Drive] @@ -65,7 +70,7 @@ def conversions alias: drive_model[:alias], filesystem: convert_filesystem, ptable_type: convert_ptable_type, - partitions: convert_partitions + partitions: convert_partitions(encryption_model) } end end diff --git a/service/lib/agama/storage/config_conversions/from_model_conversions/encryption.rb b/service/lib/agama/storage/config_conversions/from_model_conversions/encryption.rb new file mode 100644 index 0000000000..a327efbe61 --- /dev/null +++ b/service/lib/agama/storage/config_conversions/from_model_conversions/encryption.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +# Copyright (c) [2025] 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 "agama/storage/config_conversions/from_model_conversions/base" +require "y2storage/encryption_method" + +module Agama + module Storage + module ConfigConversions + module FromModelConversions + # Encryption conversion from model according to the JSON schema. + class Encryption < Base + private + + # @see Base + # @return [Configs::Encryption] + def default_config + Configs::Encryption.new + end + + # @see Base#conversions + # @return [Hash] + def conversions + { + method: convert_method, + password: model_json[:password] + } + end + + # @return [Y2Storage::EncryptionMethod::Base] + def convert_method + method_conversions = { + "luks1" => Y2Storage::EncryptionMethod::LUKS1, + "luks2" => Y2Storage::EncryptionMethod::LUKS2, + "tpmFde" => Y2Storage::EncryptionMethod::TPM_FDE + } + + method_conversions[model_json[:method]] + end + end + end + end + end +end diff --git a/service/lib/agama/storage/config_conversions/from_model_conversions/partition.rb b/service/lib/agama/storage/config_conversions/from_model_conversions/partition.rb index 39314623a3..577dc02345 100644 --- a/service/lib/agama/storage/config_conversions/from_model_conversions/partition.rb +++ b/service/lib/agama/storage/config_conversions/from_model_conversions/partition.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2024] SUSE LLC +# Copyright (c) [2024-2025] SUSE LLC # # All Rights Reserved. # @@ -20,6 +20,7 @@ # find current contact information at www.suse.com. require "agama/storage/config_conversions/from_model_conversions/base" +require "agama/storage/config_conversions/from_model_conversions/with_encryption" require "agama/storage/config_conversions/from_model_conversions/with_filesystem" require "agama/storage/config_conversions/from_model_conversions/with_search" require "agama/storage/config_conversions/from_model_conversions/with_size" @@ -32,26 +33,38 @@ module ConfigConversions module FromModelConversions # Partition conversion from model according to the JSON schema. class Partition < Base - private - include WithSearch + include WithEncryption include WithFilesystem include WithSize + # @param model_json [Hash] + # @param encryption_model [Hash, nil] + def initialize(model_json, encryption_model = nil) + super(model_json) + @encryption_model = encryption_model + end + + private + + alias_method :partition_model, :model_json + + # @return [Hash, nil] + attr_reader :encryption_model + # @see Base # @return [Configs::Partition] def default_config Configs::Partition.new end - alias_method :partition_model, :model_json - # @see Base#conversions # @return [Hash] def conversions { search: convert_search, alias: partition_model[:alias], + encryption: convert_encryption, filesystem: convert_filesystem, size: convert_size, id: convert_id, diff --git a/service/lib/agama/storage/config_conversions/from_model_conversions/with_encryption.rb b/service/lib/agama/storage/config_conversions/from_model_conversions/with_encryption.rb new file mode 100644 index 0000000000..1ff08b3bb7 --- /dev/null +++ b/service/lib/agama/storage/config_conversions/from_model_conversions/with_encryption.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +# Copyright (c) [2025] 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 "agama/storage/config_conversions/from_model_conversions/encryption" + +module Agama + module Storage + module ConfigConversions + module FromModelConversions + # Mixin for encryption conversion. + module WithEncryption + # @return [Configs::Encryption, nil] + def convert_encryption + # Do not encrypt reused partitions. + return if model_json[:name] + + return if encryption_model.nil? + + FromModelConversions::Encryption.new(encryption_model).convert + end + end + end + end + end +end diff --git a/service/lib/agama/storage/config_conversions/from_model_conversions/with_partitions.rb b/service/lib/agama/storage/config_conversions/from_model_conversions/with_partitions.rb index d099004776..ec8e68fc34 100644 --- a/service/lib/agama/storage/config_conversions/from_model_conversions/with_partitions.rb +++ b/service/lib/agama/storage/config_conversions/from_model_conversions/with_partitions.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2024] SUSE LLC +# Copyright (c) [2024-2025] SUSE LLC # # All Rights Reserved. # @@ -28,40 +28,45 @@ module ConfigConversions module FromModelConversions # Mixin for partitions conversion. module WithPartitions + # @param encryption_model [Hash, nil] # @return [Array] - def convert_partitions + def convert_partitions(encryption_model = nil) # If the model does not indicate a space policy, then the space policy defined by the # product is applied. space_policy = model_json[:spacePolicy] || product_config.space_policy case space_policy when "keep" - used_partition_configs + used_partition_configs(encryption_model) when "delete" - [used_partition_configs, delete_all_partition_config].flatten + [used_partition_configs(encryption_model), delete_all_partition_config].flatten when "resize" - [used_partition_configs, resize_all_partition_config].flatten + [used_partition_configs(encryption_model), resize_all_partition_config].flatten else - partition_configs + partition_configs(encryption_model) end end # @param partition_model [Hash] + # @param encryption_model [Hash, nil] + # # @return [Configs::Partition] - def convert_partition(partition_model) - FromModelConversions::Partition.new(partition_model).convert + def convert_partition(partition_model, encryption_model = nil) + FromModelConversions::Partition.new(partition_model, encryption_model).convert end # @return [Array] - def partition_configs - partitions.map { |p| convert_partition(p) } + # @param encryption_model [Hash, nil] + def partition_configs(encryption_model = nil) + partitions.map { |p| convert_partition(p, encryption_model) } end # Partitions with any usage (format, mount, etc). + # @param encryption_model [Hash, nil] # # @return [Array] - def used_partition_configs - used_partitions.map { |p| convert_partition(p) } + def used_partition_configs(encryption_model = nil) + used_partitions.map { |p| convert_partition(p, encryption_model) } end # @return [Array] diff --git a/service/lib/agama/storage/config_conversions/to_model_conversions.rb b/service/lib/agama/storage/config_conversions/to_model_conversions.rb index 153a2d9146..1d2c252603 100644 --- a/service/lib/agama/storage/config_conversions/to_model_conversions.rb +++ b/service/lib/agama/storage/config_conversions/to_model_conversions.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2024] SUSE LLC +# Copyright (c) [2024-2025] SUSE LLC # # All Rights Reserved. # @@ -24,6 +24,7 @@ require "agama/storage/config_conversions/to_model_conversions/boot_device" require "agama/storage/config_conversions/to_model_conversions/config" require "agama/storage/config_conversions/to_model_conversions/drive" +require "agama/storage/config_conversions/to_model_conversions/encryption" require "agama/storage/config_conversions/to_model_conversions/filesystem" require "agama/storage/config_conversions/to_model_conversions/partition" require "agama/storage/config_conversions/to_model_conversions/size" diff --git a/service/lib/agama/storage/config_conversions/to_model_conversions/config.rb b/service/lib/agama/storage/config_conversions/to_model_conversions/config.rb index b0ba09e169..2634fbe1b4 100644 --- a/service/lib/agama/storage/config_conversions/to_model_conversions/config.rb +++ b/service/lib/agama/storage/config_conversions/to_model_conversions/config.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2024] SUSE LLC +# Copyright (c) [2024-2025] SUSE LLC # # All Rights Reserved. # @@ -21,6 +21,7 @@ require "agama/storage/config_conversions/to_model_conversions/base" require "agama/storage/config_conversions/to_model_conversions/boot" +require "agama/storage/config_conversions/to_model_conversions/encryption" require "agama/storage/config_conversions/to_model_conversions/drive" module Agama @@ -40,8 +41,9 @@ def initialize(config) # @see Base#conversions def conversions { - boot: convert_boot, - drives: convert_drives + boot: convert_boot, + encryption: convert_encryption, + drives: convert_drives } end @@ -50,6 +52,14 @@ def convert_boot ToModelConversions::Boot.new(config).convert end + # @return [Hash] + def convert_encryption + encryption = base_encryption + return unless encryption + + ToModelConversions::Encryption.new(encryption).convert + end + # @return [Array] def convert_drives valid_drives.map { |d| ToModelConversions::Drive.new(d).convert } @@ -59,6 +69,32 @@ def convert_drives def valid_drives config.drives.select(&:found_device) end + + # TODO: proper support for a base encryption. + # The current implementation is a temporary solution which assumes that all the + # partitions are encrypted in the very same way (encryption method, password, etc). + # + # Detects the base encryption. + # + # @return [Configs::Encryption, nil] nil if there is no encrypted partition. + def base_encryption + root_encryption || first_encryption + end + + # Encryption from root partition. + # + # @return [Configs::Encryption, nil] nil if there is no encryption for root partition. + def root_encryption + root_partition = config.partitions.find { |p| p.filesystem&.root? } + root_partition&.encryption + end + + # Encryption from the first encrypted partition. + # + # @return [Configs::Encryption, nil] nil if there is no encrypted partition. + def first_encryption + config.partitions.find(&:encryption)&.encryption + end end end end diff --git a/service/lib/agama/storage/config_conversions/to_model_conversions/encryption.rb b/service/lib/agama/storage/config_conversions/to_model_conversions/encryption.rb new file mode 100644 index 0000000000..ab13e3248e --- /dev/null +++ b/service/lib/agama/storage/config_conversions/to_model_conversions/encryption.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +# Copyright (c) [2025] 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 "agama/storage/config_conversions/to_model_conversions/base" +require "y2storage/encryption_method" + +module Agama + module Storage + module ConfigConversions + module ToModelConversions + # Encryption config conversion to model according to the JSON schema. + class Encryption < Base + # @param config [Configs::Encryption] + def initialize(config) + super() + @config = config + end + + private + + # @see Base#conversions + def conversions + { + method: convert_method, + password: config.password + } + end + + # @return [string] + def convert_method + method_conversions = { + Y2Storage::EncryptionMethod::LUKS1.id => "luks1", + Y2Storage::EncryptionMethod::LUKS2.id => "luks2", + Y2Storage::EncryptionMethod::TPM_FDE.id => "tpmFde" + } + + method_conversions[config.method.id] || "luks2" + end + end + end + end + end +end diff --git a/service/lib/agama/storage/proposal.rb b/service/lib/agama/storage/proposal.rb index 44c07d9438..05f5f7593d 100644 --- a/service/lib/agama/storage/proposal.rb +++ b/service/lib/agama/storage/proposal.rb @@ -298,8 +298,7 @@ def model_supported?(config) ].flatten encryptable_configs = [ - config.drives, - config.partitions + config.drives ].flatten unsupported_configs.empty? && encryptable_configs.none?(&:encryption) diff --git a/service/package/rubygem-agama-yast.changes b/service/package/rubygem-agama-yast.changes index 3d60989c25..570eb795a3 100644 --- a/service/package/rubygem-agama-yast.changes +++ b/service/package/rubygem-agama-yast.changes @@ -1,9 +1,15 @@ +------------------------------------------------------------------- +Fri Feb 21 13:59:30 UTC 2025 - José Iván López González + +- Initial support for global encryption in the storage model + (gh#agama-project/agama#2031). + ------------------------------------------------------------------- Thu Feb 20 17:22:09 UTC 2025 - Imobach Gonzalez Sosa - Add libzypp callbacks to handle more checksum and signatures problems (gh#agama-project/agama#2030). - + ------------------------------------------------------------------- Thu Feb 20 14:23:49 UTC 2025 - Ancor Gonzalez Sosa @@ -137,7 +143,7 @@ Mon Jan 20 10:35:35 UTC 2025 - Imobach Gonzalez Sosa - Add support for specifying a license for each product (jsc#PED-11987). -------------------------------------------------------------------- +------------------------------------------------------------------- Fri Jan 17 20:42:58 UTC 2025 - Josef Reidinger - Relax gems version in the gemspec file @@ -169,7 +175,7 @@ Fri Jan 10 15:44:30 UTC 2025 - José Iván López González ------------------------------------------------------------------- Thu Jan 9 12:21:40 UTC 2025 - Knut Anderssen -- Activate multipath in case it is forced by the user +- Activate multipath in case it is forced by the user (gh#agama-project/agama#1875). ------------------------------------------------------------------- diff --git a/service/test/agama/storage/config_conversions/from_model_test.rb b/service/test/agama/storage/config_conversions/from_model_test.rb index 19acc047ac..d6a0cfe58d 100644 --- a/service/test/agama/storage/config_conversions/from_model_test.rb +++ b/service/test/agama/storage/config_conversions/from_model_test.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2024] SUSE LLC +# Copyright (c) [2024-2025] SUSE LLC # # All Rights Reserved. # @@ -1044,6 +1044,37 @@ end end + context "with a JSON specifying 'encryption'" do + let(:model_json) do + { + encryption: { + method: "luks1", + password: "12345" + }, + drives: [ + { + name: "/dev/vda", + partitions: [ + { name: "/dev/vda1" }, + {} + ] + } + ] + } + end + + it "sets #encryption to the new partitions" do + config = subject.convert + partitions = config.drives.first.partitions + new_partition = partitions.find { |p| p.search.nil? } + reused_partition = partitions.find { |p| p.search&.name == "/dev/vda1" } + + expect(new_partition.encryption.method.id).to eq(:luks1) + expect(new_partition.encryption.password).to eq("12345") + expect(reused_partition.encryption).to be_nil + end + end + context "with a JSON specifying 'drives'" do let(:model_json) do { drives: drives } diff --git a/service/test/agama/storage/config_conversions/to_model_test.rb b/service/test/agama/storage/config_conversions/to_model_test.rb index 5cd8af9e33..adf2b45e9a 100644 --- a/service/test/agama/storage/config_conversions/to_model_test.rb +++ b/service/test/agama/storage/config_conversions/to_model_test.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2024] SUSE LLC +# Copyright (c) [2024-2025] SUSE LLC # # All Rights Reserved. # @@ -722,6 +722,90 @@ end end + context "if the root partition is encrypted" do + let(:config_json) do + { + drives: [ + { + partitions: [ + { + filesystem: { path: "/" }, + encryption: { + luks1: { password: "12345" } + } + } + ] + } + ] + } + end + + it "generates the expected JSON for 'encryption'" do + encryption_model = subject.convert[:encryption] + + expect(encryption_model).to eq( + { + method: "luks1", + password: "12345" + } + ) + end + end + + context "if the root partition is not encrypted but other partition is encrypted" do + let(:config_json) do + { + drives: [ + { + partitions: [ + { + filesystem: { path: "/" } + }, + { + encryption: { + luks1: { password: "12345" } + } + } + ] + } + ] + } + end + + it "generates the expected JSON for 'encryption'" do + encryption_model = subject.convert[:encryption] + + expect(encryption_model).to eq( + { + method: "luks1", + password: "12345" + } + ) + end + end + + context "if there is not an encrypted partition" do + let(:config_json) do + { + drives: [ + { + partitions: [ + { + filesystem: { path: "/" } + } + ] + } + ] + } + end + + it "generates the expected JSON for 'encryption'" do + encryption_model = subject.convert[:encryption] + + expect(encryption_model).to be_nil + end + end + context "if #drives is configured" do let(:config_json) do { drives: drives } diff --git a/service/test/agama/storage/proposal_test.rb b/service/test/agama/storage/proposal_test.rb index 99f0878bf8..dfe6246804 100644 --- a/service/test/agama/storage/proposal_test.rb +++ b/service/test/agama/storage/proposal_test.rb @@ -378,30 +378,6 @@ def drive(partitions) end end - context "and the config contains an encrypted partition" do - let(:config_json) do - { - storage: { - drives: [ - { - partitions: [ - { - encryption: { - luks1: { password: "12345" } - } - } - ] - } - ] - } - } - end - - it "returns nil" do - expect(subject.model_json).to be_nil - end - end - context "and the config contains volume groups" do let(:config_json) do { @@ -457,7 +433,10 @@ def drive(partitions) alias: "root", partitions: [ { - filesystem: { path: "/" } + filesystem: { path: "/" }, + encryption: { + luks1: { password: "12345" } + } } ] } @@ -469,14 +448,18 @@ def drive(partitions) it "returns the config model" do expect(subject.model_json).to eq( { - boot: { + boot: { configure: true, device: { default: true, name: "/dev/sda" } }, - drives: [ + encryption: { + method: "luks1", + password: "12345" + }, + drives: [ { name: "/dev/sda", alias: "root", diff --git a/web/package/agama-web-ui.changes b/web/package/agama-web-ui.changes index 86a79ceae6..b379fe7778 100644 --- a/web/package/agama-web-ui.changes +++ b/web/package/agama-web-ui.changes @@ -1,3 +1,9 @@ +------------------------------------------------------------------- +Fri Feb 21 13:56:52 UTC 2025 - José Iván López González + +- Bring encryption section back to the proposal page + (gh#agama-project/agama#2031). + ------------------------------------------------------------------- Thu Feb 20 12:46:04 UTC 2025 - Ancor Gonzalez Sosa diff --git a/web/src/api/storage/types/config-model.ts b/web/src/api/storage/types/config-model.ts index 61a143416a..fff51257ca 100644 --- a/web/src/api/storage/types/config-model.ts +++ b/web/src/api/storage/types/config-model.ts @@ -4,6 +4,7 @@ * and run json-schema-to-typescript to regenerate this file. */ +export type EncryptionMethod = "luks1" | "luks2" | "tpmFde"; /** * Alias used to reference a device. */ @@ -34,6 +35,7 @@ export type PartitionId = "linux" | "swap" | "lvm" | "raid" | "esp" | "prep" | " */ export interface Config { boot?: Boot; + encryption?: Encryption; drives?: Drive[]; } export interface Boot { @@ -44,6 +46,10 @@ export interface BootDevice { default: boolean; name?: string; } +export interface Encryption { + method: EncryptionMethod; + password?: string; +} export interface Drive { name: string; alias?: Alias; diff --git a/web/src/assets/styles/index.scss b/web/src/assets/styles/index.scss index 97872b940f..cfd32779b6 100644 --- a/web/src/assets/styles/index.scss +++ b/web/src/assets/styles/index.scss @@ -263,6 +263,10 @@ color: inherit; } +label.pf-m-disabled + .pf-v6-c-check__description { + color: var(--pf-v6-c-check__label--disabled--Color); +} + // Do not change the default cursor for labels forms because it is confusing // // See: diff --git a/web/src/components/core/PasswordAndConfirmationInput.tsx b/web/src/components/core/PasswordAndConfirmationInput.tsx index 79dabac839..6b06e07244 100644 --- a/web/src/components/core/PasswordAndConfirmationInput.tsx +++ b/web/src/components/core/PasswordAndConfirmationInput.tsx @@ -32,6 +32,7 @@ import { _ } from "~/i18n"; type PasswordAndConfirmationInputProps = { inputRef?: React.RefObject; + initialValue?: string; value?: string; showErrors?: boolean; isDisabled?: boolean; @@ -42,6 +43,7 @@ type PasswordAndConfirmationInputProps = { const PasswordAndConfirmationInput = ({ inputRef, showErrors = true, + initialValue, value, onChange, onValidation, @@ -56,6 +58,11 @@ const PasswordAndConfirmationInput = ({ if (isDisabled) setError(""); }, [isDisabled]); + useEffect(() => { + setPassword(initialValue); + setConfirmation(initialValue); + }, [initialValue]); + const validate = (password: string, passwordConfirmation: string) => { let newError = ""; showErrors && setError(newError); diff --git a/web/src/components/storage/EncryptionField.test.tsx b/web/src/components/storage/EncryptionField.test.tsx deleted file mode 100644 index 148a6d7945..0000000000 --- a/web/src/components/storage/EncryptionField.test.tsx +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Copyright (c) [2024-2025] SUSE LLC - * - * All Rights Reserved. - * - * This program is free software; you can redistribute it and/or modify it - * under the terms of the GNU General Public License as published by the Free - * Software Foundation; either version 2 of the License, or (at your option) - * any later version. - * - * 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. - */ - -import React from "react"; -import { screen, within } from "@testing-library/react"; -import { plainRender } from "~/test-utils"; -import { EncryptionMethods } from "~/types/storage"; -import EncryptionField from "~/components/storage/EncryptionField"; - -describe("EncryptionField", () => { - it("renders proper value depending on encryption status", () => { - // No encryption set - const { rerender } = plainRender(); - screen.getByText("Disabled"); - - // Encryption set with LUKS2 - rerender(); - screen.getByText("Enabled"); - - // Encryption set with TPM - rerender(); - screen.getByText("Using TPM unlocking"); - }); - - it("allows opening the encryption settings dialog", async () => { - const { user } = plainRender(); - const button = screen.getByRole("button", { name: /Enable/ }); - await user.click(button); - const dialog = await screen.findByRole("dialog"); - within(dialog).getByRole("heading", { name: "Encryption" }); - }); -}); diff --git a/web/src/components/storage/EncryptionField.tsx b/web/src/components/storage/EncryptionField.tsx deleted file mode 100644 index ed760b608a..0000000000 --- a/web/src/components/storage/EncryptionField.tsx +++ /dev/null @@ -1,128 +0,0 @@ -/* - * Copyright (c) [2024-2025] SUSE LLC - * - * All Rights Reserved. - * - * This program is free software; you can redistribute it and/or modify it - * under the terms of the GNU General Public License as published by the Free - * Software Foundation; either version 2 of the License, or (at your option) - * any later version. - * - * 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. - */ - -import React, { useCallback, useEffect, useState } from "react"; -import { Button, Content, Skeleton } from "@patternfly/react-core"; -import { Page } from "~/components/core"; -import EncryptionSettingsDialog, { - EncryptionSetting, -} from "~/components/storage/EncryptionSettingsDialog"; -import { EncryptionMethods } from "~/types/storage"; -import { _ } from "~/i18n"; -import { noop } from "~/utils"; - -const encryptionMethods = () => ({ - disabled: _("Disabled"), - [EncryptionMethods.LUKS2]: _("Enabled"), - [EncryptionMethods.TPM]: _("Using TPM unlocking"), -}); - -const Value = ({ isLoading, isEnabled, method }) => { - const values = encryptionMethods(); - if (isLoading) return ; - if (isEnabled) return values[method]; - - return values.disabled; -}; - -const Action = ({ isEnabled, isLoading, onClick }) => { - if (isLoading) return ; - - const variant = isEnabled ? "secondary" : "primary"; - const label = isEnabled ? _("Modify") : _("Enable"); - - return ( - - ); -}; - -export type EncryptionConfig = { - password: string; - method?: string; -}; - -export type EncryptionFieldProps = { - password?: string; - method?: string; - methods?: string[]; - isLoading?: boolean; - onChange?: (config: EncryptionConfig) => void; -}; - -/** - * Allows to define encryption - * @component - */ -export default function EncryptionField({ - password = "", - method = "", - // FIXME: should be available methods actually a prop? - methods = [], - isLoading = false, - onChange = noop, -}: EncryptionFieldProps) { - const validPassword = useCallback(() => password?.length > 0, [password]); - const [isEnabled, setIsEnabled] = useState(validPassword()); - const [isDialogOpen, setIsDialogOpen] = useState(false); - - useEffect(() => { - setIsEnabled(validPassword()); - }, [password, validPassword]); - - const openDialog = () => setIsDialogOpen(true); - - const closeDialog = () => setIsDialogOpen(false); - - const onAccept = (encryptionSetting: EncryptionSetting) => { - closeDialog(); - onChange(encryptionSetting); - }; - - return ( - } - > - - - - {isDialogOpen && ( - - )} - - ); -} diff --git a/web/src/components/storage/EncryptionSection.test.tsx b/web/src/components/storage/EncryptionSection.test.tsx new file mode 100644 index 0000000000..a56a6c6c03 --- /dev/null +++ b/web/src/components/storage/EncryptionSection.test.tsx @@ -0,0 +1,84 @@ +/* + * Copyright (c) [2024-2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * 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. + */ + +import React from "react"; +import { screen } from "@testing-library/react"; +import { plainRender } from "~/test-utils"; +import EncryptionSection from "./EncryptionSection"; +import { STORAGE } from "~/routes/paths"; + +const mockUseEncryption = jest.fn(); +jest.mock("~/queries/storage/config-model", () => ({ + ...jest.requireActual("~/queries/storage/config-model"), + useEncryption: () => mockUseEncryption(), +})); + +describe("EncryptionSection", () => { + describe("if encryption is enabled", () => { + beforeEach(() => { + mockUseEncryption.mockReturnValue({ + encryption: { + method: "luks2", + password: "12345", + }, + }); + }); + + it("renders encryption as enabled", () => { + plainRender(); + screen.getByText(/is enabled/); + }); + + describe("and uses TPM", () => { + beforeEach(() => { + mockUseEncryption.mockReturnValue({ + encryption: { + method: "tpmFde", + password: "12345", + }, + }); + }); + + it("renders encryption as TPM enabled", () => { + plainRender(); + screen.getByText(/using TPM/); + }); + }); + }); + + describe("if encryption is disabled", () => { + beforeEach(() => { + mockUseEncryption.mockReturnValue({}); + }); + + it("renders encryption as disabled", () => { + plainRender(); + screen.getByText(/is disabled/); + }); + }); + + it("renders a link for navigating to encryption settings", () => { + plainRender(); + const editLink = screen.getByRole("link", { name: "Edit" }); + expect(editLink).toHaveAttribute("href", STORAGE.encryption); + }); +}); diff --git a/web/src/components/storage/EncryptionSection.tsx b/web/src/components/storage/EncryptionSection.tsx new file mode 100644 index 0000000000..b4aa77f20e --- /dev/null +++ b/web/src/components/storage/EncryptionSection.tsx @@ -0,0 +1,59 @@ +/* + * Copyright (c) [2024-2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * 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. + */ + +import React from "react"; +import { Card, CardBody, Content } from "@patternfly/react-core"; +import { Link, Page } from "~/components/core"; +import { useEncryption } from "~/queries/storage/config-model"; +import { EncryptionMethod } from "~/api/storage/types/config-model"; +import { STORAGE } from "~/routes/paths"; +import { _ } from "~/i18n"; + +function encryptionLabel(method?: EncryptionMethod) { + if (!method) return _("Encryption is disabled"); + if (method === "tpmFde") return _("Encryption is enabled using TPM unlocking"); + + return _("Encryption is enabled"); +} + +export default function EncryptionSection() { + const { encryption } = useEncryption(); + const method = encryption?.method; + + return ( + {_("Edit")}} + > + + + {encryptionLabel(method)} + + + + ); +} diff --git a/web/src/components/storage/EncryptionSettingsDialog.test.tsx b/web/src/components/storage/EncryptionSettingsDialog.test.tsx deleted file mode 100644 index 62f06d3d9a..0000000000 --- a/web/src/components/storage/EncryptionSettingsDialog.test.tsx +++ /dev/null @@ -1,178 +0,0 @@ -/* - * Copyright (c) [2024-2025] SUSE LLC - * - * All Rights Reserved. - * - * This program is free software; you can redistribute it and/or modify it - * under the terms of the GNU General Public License as published by the Free - * Software Foundation; either version 2 of the License, or (at your option) - * any later version. - * - * 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. - */ - -// @ts-check - -import React from "react"; -import { screen } from "@testing-library/react"; -import { plainRender } from "~/test-utils"; -import { EncryptionMethods } from "~/types/storage"; -import EncryptionSettingsDialog, { - EncryptionSettingsDialogProps, -} from "~/components/storage/EncryptionSettingsDialog"; - -let props: EncryptionSettingsDialogProps; -const onCancelFn = jest.fn(); -const onAcceptFn = jest.fn(); - -describe("EncryptionSettingsDialog", () => { - beforeEach(() => { - props = { - password: "1234", - method: EncryptionMethods.LUKS2, - methods: Object.values(EncryptionMethods), - isOpen: true, - onCancel: onCancelFn, - onAccept: onAcceptFn, - }; - }); - - describe("when password is not set", () => { - beforeEach(() => { - props.password = ""; - }); - - it("allows settings the encryption", async () => { - const { user } = plainRender(); - const checkbox = screen.getByRole("switch", { name: "Encrypt the system" }); - const passwordInput = screen.getByLabelText("Password"); - const confirmationInput = screen.getByLabelText("Password confirmation"); - const tpmCheckbox = screen.getByRole("checkbox", { name: /Use.*TPM/ }); - const acceptButton = screen.getByRole("button", { name: "Accept" }); - - expect(checkbox).not.toBeChecked(); - expect(passwordInput).toBeDisabled(); - expect(passwordInput).toBeDisabled(); - expect(tpmCheckbox).toBeDisabled(); - - await user.click(checkbox); - - expect(checkbox).toBeChecked(); - expect(passwordInput).toBeEnabled(); - expect(passwordInput).toBeEnabled(); - expect(tpmCheckbox).toBeEnabled(); - - await user.type(passwordInput, "2345"); - await user.type(confirmationInput, "2345"); - await user.click(acceptButton); - - expect(props.onAccept).toHaveBeenCalledWith(expect.objectContaining({ password: "2345" })); - }); - }); - - describe("when password is set", () => { - it("allows changing the encryption", async () => { - const { user } = plainRender(); - const passwordInput = screen.getByLabelText("Password"); - const confirmationInput = screen.getByLabelText("Password confirmation"); - const acceptButton = screen.getByRole("button", { name: "Accept" }); - const tpmCheckbox = screen.getByRole("checkbox", { name: /Use.*TPM/ }); - - await user.clear(passwordInput); - await user.type(passwordInput, "9876"); - await user.clear(confirmationInput); - await user.type(confirmationInput, "9876"); - await user.click(tpmCheckbox); - await user.click(acceptButton); - - expect(props.onAccept).toHaveBeenCalledWith({ - password: "9876", - method: EncryptionMethods.TPM, - }); - }); - - it("allows unsetting the encryption", async () => { - const { user } = plainRender(); - const checkbox = screen.getByRole("switch", { name: "Encrypt the system" }); - const acceptButton = screen.getByRole("button", { name: "Accept" }); - expect(checkbox).toBeChecked(); - await user.click(checkbox); - expect(checkbox).not.toBeChecked(); - await user.click(acceptButton); - expect(props.onAccept).toHaveBeenCalledWith({ password: "" }); - }); - }); - - describe("when using TPM", () => { - beforeEach(() => { - props.method = EncryptionMethods.TPM; - }); - - it("allows to stop using it", async () => { - const { user } = plainRender(); - const tpmCheckbox = screen.getByRole("checkbox", { name: /Use.*TPM/ }); - const acceptButton = screen.getByRole("button", { name: "Accept" }); - expect(tpmCheckbox).toBeChecked(); - await user.click(tpmCheckbox); - expect(tpmCheckbox).not.toBeChecked(); - await user.click(acceptButton); - expect(props.onAccept).toHaveBeenCalledWith( - expect.not.objectContaining({ method: EncryptionMethods.TPM }), - ); - }); - }); - - describe("when TPM is not included in given methods", () => { - beforeEach(() => { - props.methods = [EncryptionMethods.LUKS2]; - }); - - it("does not render the TPM checkbox", () => { - plainRender(); - expect(screen.queryByRole("checkbox", { name: /Use.*TPM/ })).toBeNull(); - }); - }); - - it("does not allow sending not valid settings", async () => { - const { user } = plainRender(); - const checkbox = screen.getByRole("switch", { name: "Encrypt the system" }); - const passwordInput = screen.getByLabelText("Password"); - const confirmationInput = screen.getByLabelText("Password confirmation"); - const acceptButton = screen.getByRole("button", { name: "Accept" }); - - expect(acceptButton).toBeEnabled(); - await user.clear(confirmationInput); - // Now password and passwordConfirmation do not match - expect(acceptButton).toBeDisabled(); - await user.click(checkbox); - // But now the user is trying to unset the encryption - expect(acceptButton).toBeEnabled(); - await user.click(checkbox); - // Back to a not valid settings state - expect(acceptButton).toBeDisabled(); - await user.clear(passwordInput); - await user.clear(confirmationInput); - // Passwords match... but are empty - expect(acceptButton).toBeDisabled(); - await user.type(passwordInput, "valid"); - await user.type(confirmationInput, "valid"); - // Not empty passwords matching! - expect(acceptButton).toBeEnabled(); - }); - - it("triggers onCancel callback when dialog is discarded", async () => { - const { user } = plainRender(); - const cancelButton = screen.getByRole("button", { name: "Cancel" }); - await user.click(cancelButton); - expect(props.onCancel).toHaveBeenCalled(); - }); -}); diff --git a/web/src/components/storage/EncryptionSettingsDialog.tsx b/web/src/components/storage/EncryptionSettingsDialog.tsx deleted file mode 100644 index 3095e35ab7..0000000000 --- a/web/src/components/storage/EncryptionSettingsDialog.tsx +++ /dev/null @@ -1,160 +0,0 @@ -/* - * Copyright (c) [2024] SUSE LLC - * - * All Rights Reserved. - * - * This program is free software; you can redistribute it and/or modify it - * under the terms of the GNU General Public License as published by the Free - * Software Foundation; either version 2 of the License, or (at your option) - * any later version. - * - * 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. - */ - -// @ts-check - -import React, { useEffect, useState, useRef } from "react"; -import { Checkbox, Form, Switch, Stack } from "@patternfly/react-core"; -import { _ } from "~/i18n"; -import { PasswordAndConfirmationInput, Popup } from "~/components/core"; -import { EncryptionMethods } from "~/types/storage"; - -export type EncryptionSetting = { - password: string; - method?: string; -}; - -export type EncryptionSettingsDialogProps = { - password: string; - method: string; - methods: string[]; - isOpen?: boolean; - isLoading?: boolean; - onCancel: () => void; - onAccept: (settings: EncryptionSetting) => void; -}; - -/** - * Renders a dialog that allows the user change encryption settings - * @component - */ -export default function EncryptionSettingsDialog({ - password: passwordProp, - method: methodProp, - methods, - isOpen = false, - isLoading = false, - onCancel, - onAccept, -}: EncryptionSettingsDialogProps) { - const [isEnabled, setIsEnabled] = useState(passwordProp?.length > 0); - const [password, setPassword] = useState(passwordProp); - const [method, setMethod] = useState(methodProp); - const [passwordsMatch, setPasswordsMatch] = useState(true); - const [validSettings, setValidSettings] = useState(true); - const [wasLoading, setWasLoading] = useState(isLoading); - const passwordRef = useRef(); - const formId = "encryptionSettingsForm"; - - // reset the settings only after loading is finished - if (isLoading && !wasLoading) { - setWasLoading(true); - } - if (!isLoading && wasLoading) { - setWasLoading(false); - // refresh the state when the real values are loaded - if (method !== methodProp) { - setMethod(methodProp); - } - if (password !== passwordProp) { - setPassword(passwordProp); - setIsEnabled(passwordProp?.length > 0); - } - } - - useEffect(() => { - setValidSettings(!isEnabled || (password.length > 0 && passwordsMatch)); - }, [isEnabled, password, passwordsMatch]); - - const changePassword = (_, v) => setPassword(v); - const changeMethod = (_, useTPM) => - setMethod(useTPM ? EncryptionMethods.TPM : EncryptionMethods.LUKS2); - - const submitSettings = (e) => { - e.preventDefault(); - - if (isEnabled) { - onAccept({ password, method }); - } else { - onAccept({ password: "" }); - } - }; - - // TRANSLATORS: "Trusted Platform Module" is the name of the technology and TPM its abbreviation - const tpm_label = _( - "Use the Trusted Platform Module (TPM) to decrypt automatically on each boot", - ); - // TRANSLATORS: The word 'directly' is key here. For example, booting to the installer media and then choosing - // 'Boot from Hard Disk' from there will not work. Keep it sort (this is a hint in a form) but keep it clear. - const tpm_explanation = _( - "The password will not be needed to boot and access the data if the \ -TPM can verify the integrity of the system. TPM sealing requires the new system to be booted \ -directly on its first run.", - ); - - const tpmAvailable = methods.includes(EncryptionMethods.TPM); - - return ( - - - setIsEnabled(!isEnabled)} - /> -
- - {tpmAvailable && ( - - )} - -
- - - {_("Accept")} - - - -
- ); -} diff --git a/web/src/components/storage/EncryptionSettingsPage.test.tsx b/web/src/components/storage/EncryptionSettingsPage.test.tsx new file mode 100644 index 0000000000..38fa6dc462 --- /dev/null +++ b/web/src/components/storage/EncryptionSettingsPage.test.tsx @@ -0,0 +1,148 @@ +/* + * Copyright (c) [2024-2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * 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. + */ + +import React from "react"; +import { screen, fireEvent } from "@testing-library/react"; +import { installerRender } from "~/test-utils"; +import EncryptionSettingsPage from "./EncryptionSettingsPage"; +import { EncryptionHook } from "~/queries/storage/config-model"; + +const mockLuks2Encryption: EncryptionHook = { + encryption: { + method: "luks2", + password: "12345", + }, + enable: jest.fn(), + disable: jest.fn(), +}; + +const mockTpmEncryption: EncryptionHook = { + encryption: { + method: "tpmFde", + password: "12345", + }, + enable: jest.fn(), + disable: jest.fn(), +}; + +const mockNoEncryption: EncryptionHook = { + encryption: undefined, + enable: jest.fn(), + disable: jest.fn(), +}; + +jest.mock("~/components/product/ProductRegistrationAlert", () => () => ( +
registration alert
+)); + +const mockUseEncryptionMethods = jest.fn(); +jest.mock("~/queries/storage", () => ({ + ...jest.requireActual("~/queries/storage"), + useEncryptionMethods: () => mockUseEncryptionMethods(), +})); + +const mockUseEncryption = jest.fn(); +jest.mock("~/queries/storage/config-model", () => ({ + ...jest.requireActual("~/queries/storage/config-model"), + useEncryption: () => mockUseEncryption(), +})); + +describe("EncryptionSettingsPage", () => { + beforeEach(() => { + mockUseEncryptionMethods.mockReturnValue(["luks2", "tpmFde"]); + }); + + describe("when encryption is not enabled", () => { + beforeEach(() => { + mockUseEncryption.mockReturnValue(mockNoEncryption); + }); + + it("allows enabling the encryption", async () => { + const { user } = installerRender(); + const toggle = screen.getByRole("switch", { name: "Encrypt the system" }); + expect(toggle).not.toBeChecked(); + await user.click(toggle); + const passwordInput = screen.getByLabelText("Password"); + const passwordConfirmationInput = screen.getByLabelText("Password confirmation"); + fireEvent.change(passwordInput, { target: { value: "12345" } }); + fireEvent.change(passwordConfirmationInput, { target: { value: "12345" } }); + const acceptButton = screen.getByRole("button", { name: "Accept" }); + await user.click(acceptButton); + expect(mockNoEncryption.enable).toHaveBeenCalledWith("luks2", "12345"); + }); + }); + + describe("when encryption is enabled", () => { + beforeEach(() => { + mockUseEncryption.mockReturnValue(mockLuks2Encryption); + }); + + describe("and user chooses to not use encryption", () => { + it("allows disabling the encryption", async () => { + const { user } = installerRender(); + const toggle = screen.getByRole("switch", { name: "Encrypt the system" }); + expect(toggle).toBeChecked(); + await user.click(toggle); + const passwordInput = screen.getByLabelText("Password"); + const passwordConfirmationInput = screen.getByLabelText("Password confirmation"); + const tpmCheckbox = screen.getByRole("checkbox", { name: /Use.*TPM/ }); + + expect(passwordInput).toBeDisabled(); + expect(passwordConfirmationInput).toBeDisabled(); + expect(tpmCheckbox).toBeDisabled(); + + const acceptButton = screen.getByRole("button", { name: "Accept" }); + await user.click(acceptButton); + + expect(mockLuks2Encryption.disable).toHaveBeenCalled(); + }); + }); + }); + + describe("when using TPM", () => { + beforeEach(() => { + mockUseEncryption.mockReturnValue(mockTpmEncryption); + }); + + it("allows disabling TPM", async () => { + const { user } = installerRender(); + const tpmCheckbox = screen.getByRole("checkbox", { name: /Use.*TPM/ }); + const acceptButton = screen.getByRole("button", { name: "Accept" }); + expect(tpmCheckbox).toBeChecked(); + await user.click(tpmCheckbox); + expect(tpmCheckbox).not.toBeChecked(); + await user.click(acceptButton); + expect(mockTpmEncryption.enable).toHaveBeenCalledWith("luks2", "12345"); + }); + }); + + describe("when TPM is not available", () => { + beforeEach(() => { + mockUseEncryptionMethods.mockReturnValue(["luks1", "luks2"]); + }); + + it("does not offer TPM", () => { + installerRender(); + expect(screen.queryByRole("checkbox", { name: /Use.*TPM/ })).not.toBeInTheDocument(); + }); + }); +}); diff --git a/web/src/components/storage/EncryptionSettingsPage.tsx b/web/src/components/storage/EncryptionSettingsPage.tsx new file mode 100644 index 0000000000..09b0c2d864 --- /dev/null +++ b/web/src/components/storage/EncryptionSettingsPage.tsx @@ -0,0 +1,158 @@ +/* + * Copyright (c) [2024-2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * 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. + */ + +import React, { useState, useRef } from "react"; +import { useNavigate } from "react-router-dom"; +import { ActionGroup, Alert, Checkbox, Content, Form, Stack, Switch } from "@patternfly/react-core"; +import { Page, PasswordAndConfirmationInput } from "~/components/core"; +import { useEncryptionMethods } from "~/queries/storage"; +import { useEncryption } from "~/queries/storage/config-model"; +import { EncryptionMethod } from "~/api/storage/types/config-model"; +import sizingStyles from "@patternfly/react-styles/css/utilities/Sizing/sizing"; +import { isEmpty } from "~/utils"; +import { _ } from "~/i18n"; + +/** + * Renders a form that allows the user change encryption settings + */ +export default function EncryptionSettingsPage() { + const navigate = useNavigate(); + const { encryption: encryptionConfig, enable, disable } = useEncryption(); + const methods = useEncryptionMethods(); + + const [errors, setErrors] = useState([]); + const [isEnabled, setIsEnabled] = useState(false); + const [password, setPassword] = useState(""); + const [method, setMethod] = useState("luks2"); + + const passwordRef = useRef(); + const formId = "encryptionSettingsForm"; + + React.useEffect(() => { + if (encryptionConfig) { + setIsEnabled(true); + setMethod(encryptionConfig.method); + setPassword(encryptionConfig.password || ""); + } + }, [encryptionConfig]); + + const changePassword = (_, v) => setPassword(v); + + const changeMethod = (_, useTPM) => { + const method = useTPM ? "tpmFde" : "luks2"; + setMethod(method); + }; + + const onSubmit = (e) => { + e.preventDefault(); + + const nextErrors = []; + setErrors([]); + + const passwordInput = passwordRef.current; + + if (isEnabled) { + isEmpty(password) && nextErrors.push(_("Password is empty.")); + !passwordInput?.validity.valid && nextErrors.push(passwordInput?.validationMessage); + } + + if (nextErrors.length > 0) { + setErrors(nextErrors); + return; + } + + const commit = () => (isEnabled ? enable(method, password) : disable()); + + commit(); + navigate(".."); + }; + + // TRANSLATORS: "Trusted Platform Module" is the name of the technology and TPM its abbreviation + const tpm_label = _( + "Use the Trusted Platform Module (TPM) to decrypt automatically on each boot", + ); + // TRANSLATORS: The word 'directly' is key here. For example, booting to the installer media and then choosing + // 'Boot from Hard Disk' from there will not work. Keep it sort (this is a hint in a form) but keep it clear. + const tpm_explanation = _( + "The password will not be needed to boot and access the data if the \ +TPM can verify the integrity of the system. TPM sealing requires the new system to be booted \ +directly on its first run.", + ); + + const isTpmAvailable = methods.includes("tpmFde"); + + return ( + + + {_("Encryption settings")} + + {_( + "Full Disk Encryption (FDE) allows to protect the information stored \ +at the new file systems, including data, programs, and system files.", + )} + + + + +
+ {errors.length > 0 && ( + + {errors.map((e, i) => ( +

{e}

+ ))} +
+ )} + setIsEnabled(!isEnabled)} + /> + + + + {isTpmAvailable && ( + + )} + + + + + +
+
+ ); +} diff --git a/web/src/components/storage/ProposalPage.tsx b/web/src/components/storage/ProposalPage.tsx index ae189925e9..9dfcae5f74 100644 --- a/web/src/components/storage/ProposalPage.tsx +++ b/web/src/components/storage/ProposalPage.tsx @@ -56,6 +56,7 @@ import { useDASDSupported } from "~/queries/storage/dasd"; import { useSystemErrors, useConfigErrors } from "~/queries/issues"; import { STORAGE as PATHS } from "~/routes/paths"; import { _, n_ } from "~/i18n"; +import EncryptionSection from "./EncryptionSection"; function InvalidConfigEmptyState(): React.ReactNode { const errors = useConfigErrors("storage"); @@ -185,26 +186,31 @@ function ProposalSections(): React.ReactNode { {model && ( - - - - - - - - - - } - > - - - + <> + + + + + + + + + + } + > + + + + + + + )} {hasResult && } diff --git a/web/src/queries/software.ts b/web/src/queries/software.ts index 3117bca5f4..e0ead01fcc 100644 --- a/web/src/queries/software.ts +++ b/web/src/queries/software.ts @@ -138,6 +138,7 @@ const useConfigMutation = () => { queryClient.invalidateQueries({ queryKey: ["software/proposal"] }); if (config.product) { queryClient.invalidateQueries({ queryKey: ["software/product"] }); + queryClient.invalidateQueries({ queryKey: ["storage"] }); startProbing(); } }, @@ -158,6 +159,7 @@ const useRegisterMutation = () => { mutationFn: register, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["software/registration"] }); + queryClient.invalidateQueries({ queryKey: ["storage", "productParams"] }); startProbing(); }, }; diff --git a/web/src/queries/storage.ts b/web/src/queries/storage.ts index 32570b0525..59c8ba3094 100644 --- a/web/src/queries/storage.ts +++ b/web/src/queries/storage.ts @@ -35,6 +35,7 @@ import { import { fetchDevices, fetchDevicesDirty } from "~/api/storage/devices"; import { useInstallerClient } from "~/context/installer"; import { config, ProductParams, Volume } from "~/api/storage/types"; +import { EncryptionMethod } from "~/api/storage/types/config-model"; import { Action, StorageDevice } from "~/types/storage"; import { QueryHookOptions } from "~/types/queries"; @@ -133,7 +134,7 @@ const useAvailableDevices = (): StorageDevice[] => { }; const productParamsQuery = { - queryKey: ["storage", "encryptionMethods"], + queryKey: ["storage", "productParams"], queryFn: fetchProductParams, staleTime: Infinity, }; @@ -147,6 +148,33 @@ const useProductParams = (options?: QueryHookOptions): ProductParams => { return data; }; +/** + * Hook that returns the available encryption methods. + * + * @note The ids of the encryption methods reported by product params are different to the + * EncryptionMethod values. This should be fixed at the bakcend size. + */ +const useEncryptionMethods = (options?: QueryHookOptions): EncryptionMethod[] => { + const productParams = useProductParams(options); + + const encryptionMethods = React.useMemo((): EncryptionMethod[] => { + const conversions = { + luks1: "luks1", + luks2: "luks2", + pervasive_encryption: "pervasiveEncryption", + tpm_fde: "tpmFde", + protected_swap: "protectedSwap", + secure_swap: "secureSwap", + random_swap: "randomSwap", + }; + + const apiMethods = productParams?.encryptionMethods || []; + return apiMethods.map((v) => conversions[v] || "luks2"); + }, [productParams]); + + return encryptionMethods; +}; + const volumesQuery = (mountPaths: string[]) => ({ queryKey: ["storage", "volumes"], queryFn: () => fetchVolumes(mountPaths), @@ -264,7 +292,7 @@ export { useResetConfigMutation, useDevices, useAvailableDevices, - useProductParams, + useEncryptionMethods, useVolumes, useVolume, useVolumeDevices, diff --git a/web/src/queries/storage/config-model.ts b/web/src/queries/storage/config-model.ts index abfd9e5326..22dc392a2b 100644 --- a/web/src/queries/storage/config-model.ts +++ b/web/src/queries/storage/config-model.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) [2024] SUSE LLC + * Copyright (c) [2024-2025] SUSE LLC * * All Rights Reserved. * @@ -23,6 +23,7 @@ import { useMutation, useQuery, useQueryClient, useSuspenseQuery } from "@tanstack/react-query"; import { fetchConfigModel, setConfigModel, solveConfigModel } from "~/api/storage"; import { configModel, Volume } from "~/api/storage/types"; +import { EncryptionMethod } from "~/api/storage/types/config-model"; import { QueryHookOptions } from "~/types/queries"; import { SpacePolicyAction } from "~/types/storage"; import { useVolumes } from "~/queries/storage"; @@ -135,6 +136,22 @@ function disableBoot(originalModel: configModel.Config): configModel.Config { return setBoot(originalModel, { configure: false }); } +function setEncryption( + originalModel: configModel.Config, + method: EncryptionMethod, + password: string, +): configModel.Config { + const model = copyModel(originalModel); + model.encryption = { method, password }; + return model; +} + +function disableEncryption(originalModel: configModel.Config): configModel.Config { + const model = copyModel(originalModel); + model.encryption = null; + return model; +} + function deletePartition( originalModel: configModel.Config, driveName: string, @@ -373,6 +390,24 @@ export function useBoot(): BootHook { }; } +export type EncryptionHook = { + encryption?: configModel.Encryption; + enable: (method: EncryptionMethod, password: string) => void; + disable: () => void; +}; + +export function useEncryption(): EncryptionHook { + const model = useConfigModel(); + const { mutate } = useConfigModelMutation(); + + return { + encryption: model?.encryption, + enable: (method: EncryptionMethod, password: string) => + mutate(setEncryption(model, method, password)), + disable: () => mutate(disableEncryption(model)), + }; +} + export type DriveHook = { isBoot: boolean; isExplicitBoot: boolean; diff --git a/web/src/routes/paths.ts b/web/src/routes/paths.ts index 59637166eb..b50f3f904b 100644 --- a/web/src/routes/paths.ts +++ b/web/src/routes/paths.ts @@ -72,6 +72,7 @@ const SOFTWARE = { const STORAGE = { root: "/storage", bootDevice: "/storage/select-boot-device", + encryption: "/storage/encryption", addPartition: "/storage/:id/add-partition", editPartition: "/storage/:id/edit-partition/:partitionId", findSpace: "/storage/:id/find-space", diff --git a/web/src/routes/storage.tsx b/web/src/routes/storage.tsx index 0a010b9d47..e49708c7ba 100644 --- a/web/src/routes/storage.tsx +++ b/web/src/routes/storage.tsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2024] SUSE LLC + * Copyright (c) [2024-2025] SUSE LLC * * All Rights Reserved. * @@ -25,6 +25,7 @@ import { redirect } from "react-router-dom"; import { N_ } from "~/i18n"; import { Route } from "~/types/routes"; import BootSelection from "~/components/storage/BootSelection"; +import EncryptionSettingsPage from "~/components/storage/EncryptionSettingsPage"; import SpacePolicySelection from "~/components/storage/SpacePolicySelection"; import ProposalPage from "~/components/storage/ProposalPage"; import ISCSIPage from "~/components/storage/ISCSIPage"; @@ -48,6 +49,10 @@ const routes = (): Route => ({ path: PATHS.bootDevice, element: , }, + { + path: PATHS.encryption, + element: , + }, { path: PATHS.findSpace, element: , diff --git a/web/src/types/storage.ts b/web/src/types/storage.ts index 6167e4591a..2f62aef190 100644 --- a/web/src/types/storage.ts +++ b/web/src/types/storage.ts @@ -97,17 +97,6 @@ type SpacePolicyAction = { value: "delete" | "resizeIfNeeded"; }; -/** - * Enum for the encryption method values - * - * @readonly - * @enum { string } - */ -const EncryptionMethods = Object.freeze({ - LUKS2: "luks2", - TPM: "tpm_fde", -}); - type ISCSIInitiator = { name: string; ibft: boolean; @@ -137,5 +126,3 @@ export type { SpacePolicyAction, StorageDevice, }; - -export { EncryptionMethods };