From c8c7a50ddea99e14b9d7d9d7f1e698276304a6bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Tue, 13 Jan 2026 08:25:08 +0000 Subject: [PATCH 01/20] Do not insist on registering the system when it failed --- rust/agama-software/src/zypp_server.rs | 58 +++++++++++++++++++++----- 1 file changed, 48 insertions(+), 10 deletions(-) diff --git a/rust/agama-software/src/zypp_server.rs b/rust/agama-software/src/zypp_server.rs index 67b63fe0fc..c61e1bca3d 100644 --- a/rust/agama-software/src/zypp_server.rs +++ b/rust/agama-software/src/zypp_server.rs @@ -39,7 +39,10 @@ use zypp_agama::{errors::ZyppResult, ZyppError}; use crate::{ callbacks, - model::state::{self, SoftwareState}, + model::{ + registration::RegistrationError, + state::{self, SoftwareState}, + }, state::{Addon, RegistrationState, ResolvableSelection}, Registration, ResolvableType, }; @@ -100,10 +103,19 @@ pub enum SoftwareAction { }, } +/// Registration status. +#[derive(Default)] +pub enum RegistrationStatus { + #[default] + NotRegistered, + Registered(Registration), + Failed(RegistrationError), +} + /// Software service server. pub struct ZyppServer { receiver: mpsc::UnboundedReceiver, - registration: Option, + registration: RegistrationStatus, root_dir: Utf8PathBuf, } @@ -119,7 +131,7 @@ impl ZyppServer { let server = Self { receiver, root_dir: root_dir.as_ref().to_path_buf(), - registration: None, + registration: Default::default(), }; // drop the returned JoinHandle: the thread will be detached @@ -273,7 +285,6 @@ impl ZyppServer { // TODO: add information about the current registration state let old_state = self.read(zypp)?; - // how to check whether the system is registered if let Some(registration_config) = &state.registration { self.update_registration(registration_config, &zypp, &mut issues); } @@ -517,7 +528,13 @@ impl ZyppServer { ) -> Result<(), ZyppDispatchError> { let patterns = self.patterns(&product, zypp)?; let repositories = self.repositories(zypp)?; - let registration = self.registration.as_ref().map(|r| r.to_registration_info()); + // let registration = self.registration.as_ref().map(|r| r.to_registration_info()); + let registration = match &self.registration { + RegistrationStatus::Registered(registration) => { + Some(registration.to_registration_info()) + } + _ => None, + }; let system_info = SystemInfo { patterns, @@ -679,15 +696,35 @@ impl ZyppServer { .map_err(|e| e.into()) } + /// Update the registration status. + /// + /// Register the system and the add-ons. If it was not possible to register the system + /// on a previous call to this function, do not try again. Otherwise, it might fail + /// again. + /// + /// - `state`: wanted registration state. + /// - `issues`: list of issues to update. fn update_registration( &mut self, state: &RegistrationState, zypp: &zypp_agama::Zypp, issues: &mut Vec, ) { - if self.registration.is_none() { - self.register_base_system(state, zypp, issues); - } + match &self.registration { + RegistrationStatus::Failed(error) => { + issues.push( + Issue::new( + "software.register_system", + &gettext("Failed to register the system"), + ) + .with_details(&error.to_string()), + ); + } + RegistrationStatus::NotRegistered => { + self.register_base_system(state, zypp, issues); + } + RegistrationStatus::Registered(registration) => {} + }; if !state.addons.is_empty() { self.register_addons(&state.addons, zypp, issues); @@ -714,13 +751,14 @@ impl ZyppServer { match registration.register(&zypp) { Ok(registration) => { - self.registration = Some(registration); + self.registration = RegistrationStatus::Registered(registration); } Err(error) => { issues.push( Issue::new("software.register_system", "Failed to register the system") .with_details(&error.to_string()), ); + self.registration = RegistrationStatus::Failed(error); } } } @@ -731,7 +769,7 @@ impl ZyppServer { zypp: &zypp_agama::Zypp, issues: &mut Vec, ) { - let Some(registration) = &mut self.registration else { + let RegistrationStatus::Registered(registration) = &mut self.registration else { tracing::error!("Could not register addons because the base system is not registered"); return; }; From 026b95ab14683e97cee682548e865f68724f06fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Mon, 12 Jan 2026 13:32:59 +0000 Subject: [PATCH 02/20] RegistrationBuilder::register does not consume the builder --- rust/agama-software/src/model/registration.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/rust/agama-software/src/model/registration.rs b/rust/agama-software/src/model/registration.rs index d3106ad4d3..5c43e9240b 100644 --- a/rust/agama-software/src/model/registration.rs +++ b/rust/agama-software/src/model/registration.rs @@ -289,7 +289,7 @@ impl RegistrationBuilder { /// It announces the system, gets the credentials and registers the base product. /// /// * `zypp`: zypp instance. - pub fn register(self, zypp: &zypp_agama::Zypp) -> RegistrationResult { + pub fn register(&self, zypp: &zypp_agama::Zypp) -> RegistrationResult { let params = suseconnect_agama::ConnectParams { token: self.code.clone(), email: self.email.clone(), @@ -314,7 +314,7 @@ impl RegistrationBuilder { )?; let mut registration = Registration { - root_dir: self.root_dir, + root_dir: self.root_dir.clone(), connect_params: params, product: self.product.clone(), version: self.version.clone(), From 9d2290beb49f943adbc08601db3739ebf2a6e20f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Mon, 12 Jan 2026 14:14:01 +0000 Subject: [PATCH 03/20] Handle SSL problems when registering a system --- rust/Cargo.lock | 9 +- rust/agama-software/Cargo.toml | 1 + rust/agama-software/src/zypp_server.rs | 157 +++++++++++++++++++++++-- rust/agama-utils/src/api/question.rs | 5 + 4 files changed, 160 insertions(+), 12 deletions(-) diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 7d48f7fca2..a3c7e4d513 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -302,6 +302,7 @@ dependencies = [ "gettext-rs", "glob", "i18n-format", + "openssl", "regex", "serde", "serde_with", @@ -3197,9 +3198,9 @@ checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" [[package]] name = "openssl" -version = "0.10.72" +version = "0.10.75" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fedfea7d58a1f73118430a55da6a286e7b044961736ce96a16a17068ea25e5da" +checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" dependencies = [ "bitflags 2.9.0", "cfg-if", @@ -3229,9 +3230,9 @@ checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" [[package]] name = "openssl-sys" -version = "0.9.107" +version = "0.9.111" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8288979acd84749c744a9014b4382d42b8f7b2592847b5afb2ed29e5d16ede07" +checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" dependencies = [ "cc", "libc", diff --git a/rust/agama-software/Cargo.toml b/rust/agama-software/Cargo.toml index 4621ccaf70..00707cb9d1 100644 --- a/rust/agama-software/Cargo.toml +++ b/rust/agama-software/Cargo.toml @@ -25,6 +25,7 @@ url = "2.5.7" utoipa = { version = "5.2.0", features = ["axum_extras", "uuid"] } suseconnect-agama = { path = "../suseconnect-agama" } zypp-agama = { path = "../zypp-agama" } +openssl = "0.10.75" [dev-dependencies] serde_yaml = "0.9.34" diff --git a/rust/agama-software/src/zypp_server.rs b/rust/agama-software/src/zypp_server.rs index c61e1bca3d..5804cc649a 100644 --- a/rust/agama-software/src/zypp_server.rs +++ b/rust/agama-software/src/zypp_server.rs @@ -22,15 +22,22 @@ use agama_utils::{ actor::Handler, api::{ self, + question::QuestionSpec, software::{Pattern, SelectedBy, SoftwareProposal, SystemInfo}, Issue, Scope, }, products::ProductSpec, - progress, question, + progress, + question::{self, ask_question}, }; use camino::{Utf8Path, Utf8PathBuf}; use gettextrs::gettext; -use std::collections::HashMap; +use openssl::{ + hash::MessageDigest, + nid::Nid, + x509::{X509NameRef, X509}, +}; +use std::{collections::HashMap, fs::File, io::Write, process::Command}; use tokio::sync::{ mpsc::{self, UnboundedSender}, oneshot, @@ -79,6 +86,9 @@ pub enum ZyppServerError { #[error("Could not find a mount point to calculate the used space")] MissingMountPoint, + + #[error("SSL error: {0}")] + SSL(#[from] openssl::error::ErrorStack), } pub type ZyppServerResult = Result; @@ -186,8 +196,8 @@ impl ZyppServer { question, tx, } => { - let mut security_callback = callbacks::Security::new(question); - self.write(state, progress, &mut security_callback, tx, zypp)?; + let mut security_callback = callbacks::Security::new(question.clone()); + self.write(state, progress, question, &mut security_callback, tx, zypp)?; } SoftwareAction::GetSystemInfo(product_spec, tx) => { self.system_info(product_spec, tx, zypp)?; @@ -263,6 +273,7 @@ impl ZyppServer { &mut self, state: SoftwareState, progress: Handler, + questions: Handler, security: &mut callbacks::Security, tx: oneshot::Sender>>, zypp: &zypp_agama::Zypp, @@ -286,7 +297,7 @@ impl ZyppServer { let old_state = self.read(zypp)?; if let Some(registration_config) = &state.registration { - self.update_registration(registration_config, &zypp, &mut issues); + self.update_registration(registration_config, &zypp, &questions, &mut issues); } progress.cast(progress::message::Next::new(Scope::Software))?; @@ -708,6 +719,7 @@ impl ZyppServer { &mut self, state: &RegistrationState, zypp: &zypp_agama::Zypp, + questions: &Handler, issues: &mut Vec, ) { match &self.registration { @@ -721,7 +733,7 @@ impl ZyppServer { ); } RegistrationStatus::NotRegistered => { - self.register_base_system(state, zypp, issues); + self.register_base_system(state, zypp, questions, issues); } RegistrationStatus::Registered(registration) => {} }; @@ -731,10 +743,11 @@ impl ZyppServer { } } - fn register_base_system( + async fn register_base_system( &mut self, state: &RegistrationState, zypp: &zypp_agama::Zypp, + questions: &Handler, issues: &mut Vec, ) { let mut registration = @@ -749,7 +762,8 @@ impl ZyppServer { registration = registration.with_url(url); } - match registration.register(&zypp) { + let result = handle_registration_error(|| registration.register(&zypp), &questions).await; + match result { Ok(registration) => { self.registration = RegistrationStatus::Registered(registration); } @@ -787,3 +801,130 @@ impl ZyppServer { } } } + +/// Ancillary function to handle registration errors. +/// +/// Runs the given function and handles potential SSL errors. If there is an SSL +/// error and it can be solved by importing the certificate, it asks the user and, +/// if wanted, imports the certificate and runs the function again. +/// +/// It returns the result if the given function runs successfully or return any +/// other kind of error. +async fn handle_registration_error( + func: F, + questions: &Handler, +) -> Result +where + F: Fn() -> Result, +{ + loop { + let result = func(); + + if let Err(RegistrationError::Registration(ref inner)) = result { + if let suseconnect_agama::Error::SSL { + code, + message: _, + current_certificate, + } = inner + { + if code.is_fixable_by_import() { + let certificate = X509::from_pem(¤t_certificate.as_bytes()).unwrap(); + if should_trust_certificate(&certificate, questions).await { + if let Err(error) = certs::import_certificate(&certificate) { + tracing::error!("Could not import the certificate: {error}"); + } + continue; + } + } + } + } + return result; + } +} + +/// Asks the user whether it should trust the certificate. +/// +/// * `certificate`: certificate to ask for. +/// * `questions`: handler to ask questions. +async fn should_trust_certificate(cert: &X509, questions: &Handler) -> bool { + let labels = [gettext("Trust"), gettext("Reject")]; + let msg = gettext("Trying to import a self-signed certificate. Do you want to trust it and register the product?"); + let mut data = HashMap::from([ + ("Not before".to_string(), cert.not_before().to_string()), + ("Not after".to_string(), cert.not_after().to_string()), + ]); + + if let Ok(digest) = cert.digest(MessageDigest::sha1()) { + let sha1 = digest + .iter() + .map(|b| format!("{:02x}", b)) + .collect::>() + .join(""); + data.insert("SHA1 fingerprint".to_string(), sha1); + } + + if let Ok(digest) = cert.digest(MessageDigest::sha256()) { + let sha256 = digest + .iter() + .map(|b| format!("{:02x}", b)) + .collect::>() + .join(""); + data.insert("SHA256 fingerprint".to_string(), sha256); + } + + let issuer = cert.issuer_name(); + if let Some(name) = certs::extract_entry(issuer, Nid::COMMONNAME) { + data.insert("Issuer".to_string(), name); + } + + let question = QuestionSpec::new(&msg, "registration.certificate") + .with_owned_data(data) + .with_actions(&[ + ("Trust", labels[0].as_str()), + ("Reject", labels[1].as_str()), + ]) + .with_default_action("Trust"); + + let Ok(answer) = ask_question(questions, question).await else { + return false; + }; + + answer.action == "Trust".to_string() +} + +/// Ancillary functions to work with certificates. +mod certs { + use std::{fs::File, io::Write, process::Command}; + + use openssl::{ + nid::Nid, + x509::{X509NameRef, X509}, + }; + + const INSTSYS_CERT_FILE: &str = "/etc/pki/trust/anchors/registration_server.pem"; + + /// Imports the certificate. + /// + /// * `certificate`: certificate to import. + pub fn import_certificate(certificate: &X509) -> std::io::Result<()> { + let mut file = File::create(INSTSYS_CERT_FILE)?; + let pem = certificate.to_pem().unwrap(); + file.write_all(&pem)?; + Command::new("update-ca-certificates").output()?; + Ok(()) + } + + /// Extract an entry from the X509 names. + /// + /// It only extracts the first value. + /// + /// * `name`: X509 names. + /// * `nid`: entry identifier. + pub fn extract_entry(name: &X509NameRef, nid: Nid) -> Option { + let Some(entry) = name.entries_by_nid(nid).next() else { + return None; + }; + + entry.data().as_utf8().map(|l| l.to_string()).ok() + } +} diff --git a/rust/agama-utils/src/api/question.rs b/rust/agama-utils/src/api/question.rs index 0f3ead470d..fc22c7f1e7 100644 --- a/rust/agama-utils/src/api/question.rs +++ b/rust/agama-utils/src/api/question.rs @@ -293,6 +293,11 @@ impl QuestionSpec { .collect::>(); self } + + pub fn with_owned_data(mut self, data: HashMap) -> Self { + self.data = data; + self + } } /// Question field. From 0f26a224b663902187a9ce21241b9e6b50a7488a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Mon, 12 Jan 2026 14:19:07 +0000 Subject: [PATCH 04/20] Add ca-certificates as dependency * It is needed to import the registration certificates. --- rust/package/agama.spec | 2 ++ 1 file changed, 2 insertions(+) diff --git a/rust/package/agama.spec b/rust/package/agama.spec index a560af5ed6..cc5608249f 100644 --- a/rust/package/agama.spec +++ b/rust/package/agama.spec @@ -74,6 +74,8 @@ Requires: python-langtable-data # dependency on the YaST part of Agama Requires: agama-yast Requires: agama-common +# required for importing SSL certificates +Requires: ca-certificates %description Agama is a service-based Linux installer. It is composed of an HTTP-based API, From 91b12cf58318029c84a96e69b5136693adf5e969 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Mon, 12 Jan 2026 21:53:15 +0000 Subject: [PATCH 05/20] Do not use async/await in the zypp_server module --- rust/agama-software/src/callbacks.rs | 2 +- rust/agama-software/src/zypp_server.rs | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/rust/agama-software/src/callbacks.rs b/rust/agama-software/src/callbacks.rs index 8fb99a4c28..7178e0fbc2 100644 --- a/rust/agama-software/src/callbacks.rs +++ b/rust/agama-software/src/callbacks.rs @@ -10,7 +10,7 @@ pub use security::Security; mod install; pub use install::Install; -fn ask_software_question( +pub fn ask_software_question( handler: &Handler, question: QuestionSpec, ) -> Result { diff --git a/rust/agama-software/src/zypp_server.rs b/rust/agama-software/src/zypp_server.rs index 5804cc649a..ce617ddde9 100644 --- a/rust/agama-software/src/zypp_server.rs +++ b/rust/agama-software/src/zypp_server.rs @@ -45,7 +45,7 @@ use tokio::sync::{ use zypp_agama::{errors::ZyppResult, ZyppError}; use crate::{ - callbacks, + callbacks::{self, ask_software_question}, model::{ registration::RegistrationError, state::{self, SoftwareState}, @@ -743,7 +743,7 @@ impl ZyppServer { } } - async fn register_base_system( + fn register_base_system( &mut self, state: &RegistrationState, zypp: &zypp_agama::Zypp, @@ -762,7 +762,7 @@ impl ZyppServer { registration = registration.with_url(url); } - let result = handle_registration_error(|| registration.register(&zypp), &questions).await; + let result = handle_registration_error(|| registration.register(&zypp), &questions); match result { Ok(registration) => { self.registration = RegistrationStatus::Registered(registration); @@ -810,7 +810,7 @@ impl ZyppServer { /// /// It returns the result if the given function runs successfully or return any /// other kind of error. -async fn handle_registration_error( +fn handle_registration_error( func: F, questions: &Handler, ) -> Result @@ -829,7 +829,7 @@ where { if code.is_fixable_by_import() { let certificate = X509::from_pem(¤t_certificate.as_bytes()).unwrap(); - if should_trust_certificate(&certificate, questions).await { + if should_trust_certificate(&certificate, questions) { if let Err(error) = certs::import_certificate(&certificate) { tracing::error!("Could not import the certificate: {error}"); } @@ -846,7 +846,7 @@ where /// /// * `certificate`: certificate to ask for. /// * `questions`: handler to ask questions. -async fn should_trust_certificate(cert: &X509, questions: &Handler) -> bool { +fn should_trust_certificate(cert: &X509, questions: &Handler) -> bool { let labels = [gettext("Trust"), gettext("Reject")]; let msg = gettext("Trying to import a self-signed certificate. Do you want to trust it and register the product?"); let mut data = HashMap::from([ @@ -885,7 +885,7 @@ async fn should_trust_certificate(cert: &X509, questions: &Handler Date: Mon, 12 Jan 2026 22:10:26 +0000 Subject: [PATCH 06/20] Reload certificates after importing them --- rust/agama-software/src/zypp_server.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/rust/agama-software/src/zypp_server.rs b/rust/agama-software/src/zypp_server.rs index ce617ddde9..c9b71a6d41 100644 --- a/rust/agama-software/src/zypp_server.rs +++ b/rust/agama-software/src/zypp_server.rs @@ -833,6 +833,9 @@ where if let Err(error) = certs::import_certificate(&certificate) { tracing::error!("Could not import the certificate: {error}"); } + if let Err(error) = suseconnect_agama::reload_certificates() { + tracing::error!("Could not reload the certificates: {error}"); + } continue; } } From ddb6b836869f5fcf3b9913b2dc5513ce58877e0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Fri, 16 Jan 2026 09:57:00 +0000 Subject: [PATCH 07/20] Add a new agama-security package --- rust/Cargo.toml | 1 + rust/agama-security/Cargo.toml | 7 +++++++ rust/agama-security/src/lib.rs | 14 ++++++++++++++ 3 files changed, 22 insertions(+) create mode 100644 rust/agama-security/Cargo.toml create mode 100644 rust/agama-security/src/lib.rs diff --git a/rust/Cargo.toml b/rust/Cargo.toml index b51c5698f8..6bf3e5c8af 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -9,6 +9,7 @@ members = [ "agama-locale-data", "agama-manager", "agama-network", + "agama-security", "agama-server", "agama-software", "agama-storage", diff --git a/rust/agama-security/Cargo.toml b/rust/agama-security/Cargo.toml new file mode 100644 index 0000000000..3db048d227 --- /dev/null +++ b/rust/agama-security/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "agama-security" +version = "0.1.0" +rust-version.workspace = true +edition.workspace = true + +[dependencies] diff --git a/rust/agama-security/src/lib.rs b/rust/agama-security/src/lib.rs new file mode 100644 index 0000000000..b93cf3ffd9 --- /dev/null +++ b/rust/agama-security/src/lib.rs @@ -0,0 +1,14 @@ +pub fn add(left: u64, right: u64) -> u64 { + left + right +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn it_works() { + let result = add(2, 2); + assert_eq!(result, 4); + } +} From c0dc10bdfb53ec4345ca703282c887c497422a48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Fri, 16 Jan 2026 16:54:20 +0000 Subject: [PATCH 08/20] Add a minimal security service to keep the fingerprints --- rust/Cargo.lock | 12 +++ rust/agama-manager/Cargo.toml | 1 + rust/agama-manager/src/lib.rs | 1 + rust/agama-manager/src/service.rs | 25 ++++- rust/agama-security/Cargo.toml | 5 + rust/agama-security/src/lib.rs | 43 +++++--- rust/agama-security/src/message.rs | 42 ++++++++ rust/agama-security/src/service.rs | 103 ++++++++++++++++++++ rust/agama-security/src/test_utils.rs | 30 ++++++ rust/agama-utils/src/api.rs | 1 + rust/agama-utils/src/api/config.rs | 4 +- rust/agama-utils/src/api/security.rs | 72 ++++++++++++++ rust/agama-utils/src/api/security/config.rs | 0 13 files changed, 323 insertions(+), 16 deletions(-) create mode 100644 rust/agama-security/src/message.rs create mode 100644 rust/agama-security/src/service.rs create mode 100644 rust/agama-security/src/test_utils.rs create mode 100644 rust/agama-utils/src/api/security.rs create mode 100644 rust/agama-utils/src/api/security/config.rs diff --git a/rust/Cargo.lock b/rust/Cargo.lock index a3c7e4d513..0213ee39f9 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -186,6 +186,7 @@ dependencies = [ "agama-hostname", "agama-l10n", "agama-network", + "agama-security", "agama-software", "agama-storage", "agama-users", @@ -231,6 +232,17 @@ dependencies = [ "zbus", ] +[[package]] +name = "agama-security" +version = "0.1.0" +dependencies = [ + "agama-utils", + "async-trait", + "gettext-rs", + "openssl", + "thiserror 2.0.17", +] + [[package]] name = "agama-server" version = "0.1.0" diff --git a/rust/agama-manager/Cargo.toml b/rust/agama-manager/Cargo.toml index cd2c18bdf1..2235259b98 100644 --- a/rust/agama-manager/Cargo.toml +++ b/rust/agama-manager/Cargo.toml @@ -10,6 +10,7 @@ agama-files = { path = "../agama-files" } agama-hostname = { path = "../agama-hostname" } agama-l10n = { path = "../agama-l10n" } agama-network = { path = "../agama-network" } +agama-security = { path = "../agama-security" } agama-software = { path = "../agama-software" } agama-storage = { path = "../agama-storage" } agama-utils = { path = "../agama-utils" } diff --git a/rust/agama-manager/src/lib.rs b/rust/agama-manager/src/lib.rs index 692dada137..ed36ebbfe6 100644 --- a/rust/agama-manager/src/lib.rs +++ b/rust/agama-manager/src/lib.rs @@ -30,6 +30,7 @@ pub use agama_files as files; pub use agama_hostname as hostname; pub use agama_l10n as l10n; pub use agama_network as network; +pub use agama_security as security; pub use agama_software as software; pub use agama_storage as storage; pub use agama_users as users; diff --git a/rust/agama-manager/src/service.rs b/rust/agama-manager/src/service.rs index 87daa82837..b7f1115b9d 100644 --- a/rust/agama-manager/src/service.rs +++ b/rust/agama-manager/src/service.rs @@ -19,7 +19,8 @@ // find current contact information at www.suse.com. use crate::{ - bootloader, files, hardware, hostname, l10n, message, network, software, storage, users, + bootloader, files, hardware, hostname, l10n, message, network, security, software, storage, + users, }; use agama_utils::{ actor::{self, Actor, Handler, MessageHandler}, @@ -57,6 +58,8 @@ pub enum Error { #[error(transparent)] L10n(#[from] l10n::service::Error), #[error(transparent)] + Security(#[from] security::service::Error), + #[error(transparent)] Software(#[from] software::service::Error), #[error(transparent)] Storage(#[from] storage::service::Error), @@ -94,6 +97,7 @@ pub struct Starter { hostname: Option>, l10n: Option>, network: Option, + security: Option>, software: Option>, storage: Option>, files: Option>, @@ -117,6 +121,7 @@ impl Starter { hostname: None, l10n: None, network: None, + security: None, software: None, storage: None, files: None, @@ -140,6 +145,11 @@ impl Starter { self } + pub fn with_security(mut self, security: Handler) -> Self { + self.security = Some(security); + self + } + pub fn with_software(mut self, software: Handler) -> Self { self.software = Some(software); self @@ -217,6 +227,11 @@ impl Starter { } }; + let security = match self.security { + Some(security) => security, + None => security::Service::starter(self.questions.clone()).start()?, + }; + let software = match self.software { Some(software) => software, None => { @@ -285,6 +300,7 @@ impl Starter { hostname, l10n, network, + security, software, storage, files, @@ -306,6 +322,7 @@ pub struct Service { bootloader: Handler, hostname: Handler, l10n: Handler, + security: Handler, software: Handler, network: NetworkSystemClient, storage: Handler, @@ -374,6 +391,10 @@ impl Service { return Err(Error::MissingProduct); }; + self.security + .call(security::message::SetConfig::new(config.security.clone())) + .await?; + self.hostname .call(hostname::message::SetConfig::new(config.hostname.clone())) .await?; @@ -551,6 +572,7 @@ impl MessageHandler for Service { .to_option(); let hostname = self.hostname.call(hostname::message::GetConfig).await?; let l10n = self.l10n.call(l10n::message::GetConfig).await?; + let security = self.security.call(security::message::GetConfig).await?; let questions = self.questions.call(question::message::GetConfig).await?; let network = self.network.get_config().await?; let storage = self.storage.call(storage::message::GetConfig).await?; @@ -570,6 +592,7 @@ impl MessageHandler for Service { l10n: Some(l10n), questions, network: Some(network), + security: Some(security), software, storage, files: None, diff --git a/rust/agama-security/Cargo.toml b/rust/agama-security/Cargo.toml index 3db048d227..12c7720616 100644 --- a/rust/agama-security/Cargo.toml +++ b/rust/agama-security/Cargo.toml @@ -5,3 +5,8 @@ rust-version.workspace = true edition.workspace = true [dependencies] +agama-utils = { path = "../agama-utils" } +async-trait = "0.1.89" +gettext-rs = { version = "0.7.1", features = ["gettext-system"] } +openssl = "0.10.75" +thiserror = "2.0.17" diff --git a/rust/agama-security/src/lib.rs b/rust/agama-security/src/lib.rs index b93cf3ffd9..b2b0d2e1b2 100644 --- a/rust/agama-security/src/lib.rs +++ b/rust/agama-security/src/lib.rs @@ -1,14 +1,29 @@ -pub fn add(left: u64, right: u64) -> u64 { - left + right -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn it_works() { - let result = add(2, 2); - assert_eq!(result, 4); - } -} +// Copyright (c) [2026] 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. + +//! This crate implements the support for handling security settings, +//! including certificates management. + +pub mod service; +pub use service::{Service, Starter}; + +pub mod message; + +pub mod test_utils; diff --git a/rust/agama-security/src/message.rs b/rust/agama-security/src/message.rs new file mode 100644 index 0000000000..3e05f6610f --- /dev/null +++ b/rust/agama-security/src/message.rs @@ -0,0 +1,42 @@ +// Copyright (c) [2026] 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. + +use agama_utils::{actor::Message, api::security::Config}; + +#[derive(Clone)] +pub struct GetConfig; + +impl Message for GetConfig { + type Reply = Config; +} + +pub struct SetConfig { + pub config: Option, +} + +impl Message for SetConfig { + type Reply = (); +} + +impl SetConfig { + pub fn new(config: Option) -> Self { + Self { config } + } +} diff --git a/rust/agama-security/src/service.rs b/rust/agama-security/src/service.rs new file mode 100644 index 0000000000..eadddb650d --- /dev/null +++ b/rust/agama-security/src/service.rs @@ -0,0 +1,103 @@ +// Copyright (c) [2026] 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. + +use agama_utils::{ + actor::{self, Actor, Handler, MessageHandler}, + api::{self, security::SSLFingerprint}, + question, +}; +use async_trait::async_trait; + +use crate::message; + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error(transparent)] + Actor(#[from] actor::Error), +} + +pub struct Starter { + questions: Handler, +} + +impl Starter { + pub fn new(questions: Handler) -> Starter { + Self { questions } + } + + pub fn start(self) -> Result, Error> { + let service = Service { + questions: self.questions, + state: State::default(), + }; + let handler = actor::spawn(service); + Ok(handler) + } +} + +#[derive(Default)] +struct State { + fingerprints: Vec, +} + +pub struct Service { + questions: Handler, + state: State, +} + +impl Service { + pub fn starter(questions: Handler) -> Starter { + Starter::new(questions) + } +} + +impl Actor for Service { + type Error = Error; +} + +#[async_trait] +impl MessageHandler> for Service { + async fn handle( + &mut self, + message: message::SetConfig, + ) -> Result<(), Error> { + match message.config { + Some(config) => { + self.state.fingerprints = config.ssl_certificates.unwrap_or_default(); + } + None => { + self.state = State::default(); + } + } + Ok(()) + } +} + +#[async_trait] +impl MessageHandler for Service { + async fn handle( + &mut self, + _message: message::GetConfig, + ) -> Result { + Ok(api::security::Config { + ssl_certificates: Some(self.state.fingerprints.clone()), + }) + } +} diff --git a/rust/agama-security/src/test_utils.rs b/rust/agama-security/src/test_utils.rs new file mode 100644 index 0000000000..a628c2967d --- /dev/null +++ b/rust/agama-security/src/test_utils.rs @@ -0,0 +1,30 @@ +// Copyright (c) [2026] 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. + +use agama_utils::{actor::Handler, question}; + +use crate::{Service, Starter}; + +/// Starts a testing security service. +pub async fn start_service(questions: Handler) -> Handler { + Starter::new(questions) + .start() + .expect("Could not spawn a testing security service") +} diff --git a/rust/agama-utils/src/api.rs b/rust/agama-utils/src/api.rs index 24762c9009..8d1f004200 100644 --- a/rust/agama-utils/src/api.rs +++ b/rust/agama-utils/src/api.rs @@ -59,6 +59,7 @@ pub mod manager; pub mod network; pub mod query; pub mod question; +pub mod security; pub mod software; pub mod storage; pub mod users; diff --git a/rust/agama-utils/src/api/config.rs b/rust/agama-utils/src/api/config.rs index 2b0ed46a5f..cbe812c296 100644 --- a/rust/agama-utils/src/api/config.rs +++ b/rust/agama-utils/src/api/config.rs @@ -19,7 +19,7 @@ // find current contact information at www.suse.com. use crate::api::{ - bootloader, files, hostname, l10n, network, question, + bootloader, files, hostname, l10n, network, question, security, software::{self, ProductConfig}, storage, users, }; @@ -38,6 +38,8 @@ pub struct Config { #[serde(alias = "localization")] pub l10n: Option, #[serde(flatten, skip_serializing_if = "Option::is_none")] + pub security: Option, + #[serde(flatten, skip_serializing_if = "Option::is_none")] pub software: Option, #[serde(skip_serializing_if = "Option::is_none")] pub network: Option, diff --git a/rust/agama-utils/src/api/security.rs b/rust/agama-utils/src/api/security.rs new file mode 100644 index 0000000000..fa2aeadb83 --- /dev/null +++ b/rust/agama-utils/src/api/security.rs @@ -0,0 +1,72 @@ +// Copyright (c) [2026] 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. +//! Implements a data model for Bootloader configuration. + +use merge::Merge; +use serde::{Deserialize, Serialize}; + +/// Security settings for installation +#[derive(Clone, Debug, Default, Serialize, Deserialize, Merge, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct Config { + /// List of user selected patterns to install. + #[serde(skip_serializing_if = "Option::is_none")] + // when we add support for remote URL here it should be vector of SSL + // certificates which will include flatten fingerprint + #[merge(strategy = merge::option::overwrite_none)] + pub ssl_certificates: Option>, +} + +#[derive( + Default, + Clone, + Copy, + Debug, + strum::IntoStaticStr, + strum::EnumString, + Serialize, + Deserialize, + utoipa::ToSchema, +)] +#[strum(ascii_case_insensitive)] +// #[strum( +// parse_err_fn = alg_not_found_err, +// parse_err_ty = ServiceError, +// )] +pub enum SSLFingerprintAlgorithm { + SHA1, + #[default] + SHA256, +} + +#[derive(Clone, Debug, Serialize, Deserialize, utoipa::ToSchema)] +pub struct SSLFingerprint { + /// The string value for SSL certificate fingerprint. + /// Example value is "F6:7A:ED:BB:BC:94:CF:55:9D:B3:BA:74:7A:87:05:EF:67:4E:C2:DB" + pub fingerprint: String, + /// Algorithm used to compute SSL certificate fingerprint. + /// Supported options are "SHA1" and "SHA256" + #[serde(default)] + pub algorithm: SSLFingerprintAlgorithm, +} + +// fn alg_not_found_err(s: &str) -> ServiceError { +// ServiceError::UnsupportedSSLFingerprintAlgorithm(s.to_string()) +// } diff --git a/rust/agama-utils/src/api/security/config.rs b/rust/agama-utils/src/api/security/config.rs new file mode 100644 index 0000000000..e69de29bb2 From a330c627d7da11ce762971dd6818fcd82452d1c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Mon, 19 Jan 2026 12:52:46 +0000 Subject: [PATCH 09/20] Extend the security service to check certificates --- rust/agama-security/src/certificate.rs | 143 +++++++++++++++++++++++++ rust/agama-security/src/lib.rs | 2 + rust/agama-security/src/message.rs | 15 +++ rust/agama-security/src/service.rs | 75 ++++++++++++- rust/agama-utils/src/api/security.rs | 1 + rust/test/share/test.key | 28 +++++ rust/test/share/test.pem | 24 +++++ 7 files changed, 285 insertions(+), 3 deletions(-) create mode 100644 rust/agama-security/src/certificate.rs create mode 100644 rust/test/share/test.key create mode 100644 rust/test/share/test.pem diff --git a/rust/agama-security/src/certificate.rs b/rust/agama-security/src/certificate.rs new file mode 100644 index 0000000000..e67380c46a --- /dev/null +++ b/rust/agama-security/src/certificate.rs @@ -0,0 +1,143 @@ +// Copyright (c) [2026] 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. + +use std::{collections::HashMap, fs::File, io::Write, process::Command}; + +use openssl::{ + hash::MessageDigest, + nid::Nid, + x509::{X509NameRef, X509}, +}; + +const INSTSYS_CERT_FILE: &str = "/etc/pki/trust/anchors/registration_server.pem"; + +/// Wrapper around a X509 certificate. +/// +/// It extracts the relevant information from a certificate (fingerprint, issuer name, etc.). +pub struct Certificate { + x509: X509, +} + +impl Certificate { + pub fn new(x509: X509) -> Self { + Self { x509 } + } + + pub fn issuer(&self) -> Option { + Self::extract_entry(self.x509.issuer_name(), Nid::COMMONNAME) + } + + pub fn not_before(&self) -> String { + self.x509.not_before().to_string() + } + + pub fn not_after(&self) -> String { + self.x509.not_after().to_string() + } + + pub fn sha1(&self) -> Option { + match self.x509.digest(MessageDigest::sha1()) { + Ok(digest) => digest + .iter() + .map(|b| format!("{:02x}", b)) + .collect::>() + .join("") + .into(), + Err(_) => None, + } + } + + pub fn sha256(&self) -> Option { + match self.x509.digest(MessageDigest::sha1()) { + Ok(digest) => digest + .iter() + .map(|b| format!("{:02x}", b)) + .collect::>() + .join("") + .into(), + Err(_) => None, + } + } + + pub fn import(&self) -> std::io::Result<()> { + let mut file = File::create(INSTSYS_CERT_FILE)?; + let pem = self.x509.to_pem().unwrap(); + file.write_all(&pem)?; + Command::new("update-ca-certificates").output()?; + Ok(()) + } + + pub fn to_data(&self) -> HashMap { + let mut data = HashMap::from([ + ("issueDate".to_string(), self.not_before()), + ("expirationDate".to_string(), self.not_after()), + ]); + + if let Some(sha1) = self.sha1() { + data.insert("sha1Fingerprint".to_string(), sha1); + } + + if let Some(sha256) = self.sha256() { + data.insert("sha256Fingerprint".to_string(), sha256); + } + + if let Some(issuer) = self.issuer() { + data.insert("issuer".to_string(), issuer); + } + + data + } + + /// Extract an entry from the X509 names. + /// + /// It only extracts the first value. + /// + /// * `name`: X509 names. + /// * `nid`: entry identifier. + fn extract_entry(name: &X509NameRef, nid: Nid) -> Option { + let Some(entry) = name.entries_by_nid(nid).next() else { + return None; + }; + + entry.data().as_utf8().map(|l| l.to_string()).ok() + } +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use openssl::x509::X509; + + use crate::certificate::Certificate; + + #[test] + fn test_read_certificate() { + let fixtures = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../test/share"); + let path = fixtures.join("test.pem"); + let content = std::fs::read_to_string(path).unwrap(); + + let x509 = X509::from_pem(content.as_bytes()).unwrap(); + let certificate = Certificate::new(x509); + + let issuer = certificate.issuer().unwrap(); + dbg!(issuer); + } +} diff --git a/rust/agama-security/src/lib.rs b/rust/agama-security/src/lib.rs index b2b0d2e1b2..83f4081476 100644 --- a/rust/agama-security/src/lib.rs +++ b/rust/agama-security/src/lib.rs @@ -27,3 +27,5 @@ pub use service::{Service, Starter}; pub mod message; pub mod test_utils; + +mod certificate; diff --git a/rust/agama-security/src/message.rs b/rust/agama-security/src/message.rs index 3e05f6610f..59c7f33aad 100644 --- a/rust/agama-security/src/message.rs +++ b/rust/agama-security/src/message.rs @@ -19,6 +19,7 @@ // find current contact information at www.suse.com. use agama_utils::{actor::Message, api::security::Config}; +use openssl::x509::X509; #[derive(Clone)] pub struct GetConfig; @@ -40,3 +41,17 @@ impl SetConfig { Self { config } } } + +pub struct CheckCertificate { + pub certificate: X509, +} + +impl CheckCertificate { + pub fn new(certificate: X509) -> Self { + Self { certificate } + } +} + +impl Message for CheckCertificate { + type Reply = bool; +} diff --git a/rust/agama-security/src/service.rs b/rust/agama-security/src/service.rs index eadddb650d..985b81f54b 100644 --- a/rust/agama-security/src/service.rs +++ b/rust/agama-security/src/service.rs @@ -20,12 +20,18 @@ use agama_utils::{ actor::{self, Actor, Handler, MessageHandler}, - api::{self, security::SSLFingerprint}, - question, + api::{ + self, + question::QuestionSpec, + security::{SSLFingerprint, SSLFingerprintAlgorithm}, + }, + question::{self, ask_question}, }; use async_trait::async_trait; +use gettextrs::gettext; +use openssl::x509; -use crate::message; +use crate::{certificate::Certificate, message}; #[derive(thiserror::Error, Debug)] pub enum Error { @@ -66,6 +72,24 @@ impl Service { pub fn starter(questions: Handler) -> Starter { Starter::new(questions) } + + pub async fn should_trust_certificate(&self, certificate: &Certificate) -> bool { + let labels = [gettext("Trust"), gettext("Reject")]; + let msg = gettext("Trying to import a self-signed certificate. Do you want to trust it and register the product?"); + + let question = QuestionSpec::new(&msg, "registration.certificate") + .with_owned_data(certificate.to_data()) + .with_actions(&[ + ("Trust", labels[0].as_str()), + ("Reject", labels[1].as_str()), + ]) + .with_default_action("Trust"); + + if let Ok(answer) = ask_question(&self.questions, question).await { + return answer.action == "Trust"; + } + false + } } impl Actor for Service { @@ -101,3 +125,48 @@ impl MessageHandler for Service { }) } } + +#[async_trait] +impl MessageHandler for Service { + // check whether the certificate is trusted. + // if it is not trusted, ask the user + // if the user rejects, adds it to a list of not trusted + // if the user accepts, adds it to the list of fingerprints + // and import the certificate. + async fn handle(&mut self, message: message::CheckCertificate) -> Result { + let certificate = Certificate::new(message.certificate); + + if let Some(sha256) = certificate.sha256() { + if self + .state + .fingerprints + .iter() + .find(|f| f.algorithm == SSLFingerprintAlgorithm::SHA256 && f.fingerprint == sha256) + .is_some() + { + certificate.import().unwrap(); + return Ok(true); + }; + } + + if let Some(sha1) = certificate.sha1() { + if self + .state + .fingerprints + .iter() + .find(|f| f.algorithm == SSLFingerprintAlgorithm::SHA1 && f.fingerprint == sha1) + .is_some() + { + certificate.import().unwrap(); + return Ok(true); + } + } + + if self.should_trust_certificate(&certificate).await { + certificate.import().unwrap(); + Ok(true) + } else { + Ok(false) + } + } +} diff --git a/rust/agama-utils/src/api/security.rs b/rust/agama-utils/src/api/security.rs index fa2aeadb83..b7f79ad0a5 100644 --- a/rust/agama-utils/src/api/security.rs +++ b/rust/agama-utils/src/api/security.rs @@ -43,6 +43,7 @@ pub struct Config { strum::EnumString, Serialize, Deserialize, + PartialEq, utoipa::ToSchema, )] #[strum(ascii_case_insensitive)] diff --git a/rust/test/share/test.key b/rust/test/share/test.key new file mode 100644 index 0000000000..8e332ab867 --- /dev/null +++ b/rust/test/share/test.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCz+RUdK23Rk1bq +Zz17BlG1wv9/0vmXrSiuwIhBonjP1e4DvHtKLkRDeya+cazMLT/LRLUq58GzGMFw +QK3gxKU+lfWI+TKF/b+LWFDeoMW09yaFvUoYXOQ+MMKQ45PeRCiJWd/1M71g0vg/ +pKGFo7wQ2jIsj1NOd7dpXwOIY2ohVvI3a12KSgly6boNkjxXKpED02ivGHJz4JB8 +W1NVUz61GeXniu4m4tkOb5IXsIDxIgNSmZN7rPrLpwUtio3ppQkVpYPlKTe0JCSL +8vMann47hiUYKpa8dTfFRVxxnIgGrkGOBW4Fl2YkMszxhFFIrEWskKiuAPp/kH1q +zWbdYBSRAgMBAAECggEAECItHV9OSfJOYVb98PBhFM01QE02aL1yHzBNRqz87Yy8 ++ILF/qFlJzN8BqiEGA2aYWt4Xi9GcvquJJT3wWV/drvUtgG44MAdkq1JGpwI/S8G +ugh9AvAY2eZfGsP79nnftGhlJkMMIAjpjjMA88z4x/33b30tt6QNwhltZKWc1v4g +1tXd58jc7j8VTy5gTa0dCSBJH+VOdl5J1FvySuxa4odSPQnttxePeO1If03oIT5K +yZjgBkSKI9x8+Jv/cD4kg9WBtM1kAmQ1/LmwkAewAsQago/RvyGdLA6Ln7BlnqZw +tpNUPFtMsXUnVVZmRwPDtmTmfWXKsiUh5Da0EtTOwQKBgQDfWSKFB+17wni3BP90 +fgmt20CWZ9XJxN37WEAmkLNiwxFSJVLEeQ+AW6YxqdNGXbGJH1URolK62ROk5vnc +2njDTTQOQWfPcwiuzPYsd75qqTkJx/S+/h9zDenDyjDRaG9tLakx1caUmcX/eT2s +wtGxUQsufdTvBrMSVc1fFMZq6QKBgQDOSJ57Gcr7zBEq2VozK3dO3TghyzqJ2No5 +WFFwtNKARRPUXSm08S4AFxsPcTOpYRer1h6+oRLFF/qRVhQ9qeFsFgE7UtVfX+ei +JrVulZ4lgSC8YQ8twvtWkwdsojDf1mkJlIRgGxgw+rkcUXZ17HhJk6mEuF+iBlCr +0ZCCxWGDaQKBgHxFizh75b6TwTkMVdsKefY7ZV+KnOCsYlsEioUqxDUyloOfcsMR +HPfthrZhaMXhQfQq89lg+SkvuYdqYSJqiQEaBtnbuJgryGwCbQLnCZMtXyg7Esnm +ebc5yZT//lO2CTG1U0wAR4LNYOa6Hf/vUl+X2WHf78ejcaXFCgVaeOBpAoGBAJph +QLISKJZvL73ospevBPgxmSu5Y9L8Rr05+qElwpAaom+BVZBEG1AX+rmA19AAiO2i +LpouA9UbT/vq9vT7KWhxw3Q5VtSs+ragz5G2SYf57pzs6qYt0VoGaT0E2Rz74to3 +myYtwNoPGfA2izhPw+oUp35bWb7xiPg9uzATNhpZAoGAHUOj15scuXL5RcCSiBkP +5jMd/CTMduj9BsJgI+bUwww3MGHWuWqhwzPKR5JYz30Xw8a3B5qS+NAOrcA512kI +JctxA+wKQrQdMz7A1LGZb5FA/zwuWNk+H0KrYOyGvH4i+qKENGUJuyogmr8Vwimz +G4Wq1anZkgO257QYeSbDBFQ= +-----END PRIVATE KEY----- diff --git a/rust/test/share/test.pem b/rust/test/share/test.pem new file mode 100644 index 0000000000..29b79a321d --- /dev/null +++ b/rust/test/share/test.pem @@ -0,0 +1,24 @@ +-----BEGIN CERTIFICATE----- +MIID+zCCAuOgAwIBAgIUePrgQsvZZA6/g5/o1WyMU+ttNtAwDQYJKoZIhvcNAQEL +BQAwgYwxCzAJBgNVBAYTAkVTMQ8wDQYDVQQIDAZNYWRyaWQxDzANBgNVBAcMBk1h +ZHJpZDEcMBoGA1UECgwTRXhhbXBsZSBDb21wYW55IEx0ZDEYMBYGA1UEAwwPd3d3 +LmV4YW1wbGUubmV0MSMwIQYJKoZIhvcNAQkBFhRqYW5lLmRvZUBleGFtcGxlLm5l +dDAeFw0yNjAxMTkxMDQ5NTdaFw0yNzAxMTkxMDQ5NTdaMIGMMQswCQYDVQQGEwJF +UzEPMA0GA1UECAwGTWFkcmlkMQ8wDQYDVQQHDAZNYWRyaWQxHDAaBgNVBAoME0V4 +YW1wbGUgQ29tcGFueSBMdGQxGDAWBgNVBAMMD3d3dy5leGFtcGxlLm5ldDEjMCEG +CSqGSIb3DQEJARYUamFuZS5kb2VAZXhhbXBsZS5uZXQwggEiMA0GCSqGSIb3DQEB +AQUAA4IBDwAwggEKAoIBAQCz+RUdK23Rk1bqZz17BlG1wv9/0vmXrSiuwIhBonjP +1e4DvHtKLkRDeya+cazMLT/LRLUq58GzGMFwQK3gxKU+lfWI+TKF/b+LWFDeoMW0 +9yaFvUoYXOQ+MMKQ45PeRCiJWd/1M71g0vg/pKGFo7wQ2jIsj1NOd7dpXwOIY2oh +VvI3a12KSgly6boNkjxXKpED02ivGHJz4JB8W1NVUz61GeXniu4m4tkOb5IXsIDx +IgNSmZN7rPrLpwUtio3ppQkVpYPlKTe0JCSL8vMann47hiUYKpa8dTfFRVxxnIgG +rkGOBW4Fl2YkMszxhFFIrEWskKiuAPp/kH1qzWbdYBSRAgMBAAGjUzBRMB0GA1Ud +DgQWBBRF5ZQjh0I7WTM5zSD+baT8xRjjBDAfBgNVHSMEGDAWgBRF5ZQjh0I7WTM5 +zSD+baT8xRjjBDAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQA4 +MfSJZrhiXjxCbPhigjnzCqGer3KYNUOgZ1FdqUuqV1RYujG0JQVrTRTgS8pUgeTg +qCNEtAUPIpPMwJamZxkuD8PRQHlChE+kUoJiHjlM8aSIc2CsxI+BMxzmdW9cBoyU +k3HQhCLy3iihNDgneWkMEtcfbnUFTYu13k8u1WCloaRaEM7IBTE6eo6yDdRasF5t +FGPM/fXQQSqggUGTdrfpRA5Eff2GqsIZ5bQBmsGd9dcM/PQ06e50xR6eArLWRfX+ +8FApQdCeEfUiimb1drhRpR2IWHokcWrkqxfjBtY35R5EjRnMTRgSFI7hFfwtXUPi +6t1yBHndvLYQehu4wNdC +-----END CERTIFICATE----- From 510327899019582c67fab27e6bf1440e292ca45e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Mon, 19 Jan 2026 12:54:39 +0000 Subject: [PATCH 10/20] Delegate registration certificate checks to the security service --- rust/Cargo.lock | 10 +- rust/agama-manager/src/service.rs | 1 + rust/agama-manager/src/test_utils.rs | 3 + rust/agama-security/Cargo.toml | 1 + rust/agama-security/src/certificate.rs | 95 ++++++---- rust/agama-security/src/service.rs | 148 ++++++++++----- rust/agama-software/Cargo.toml | 1 + rust/agama-software/src/model.rs | 5 + rust/agama-software/src/model/registration.rs | 81 ++++++++- rust/agama-software/src/service.rs | 8 +- rust/agama-software/src/test_utils.rs | 4 +- rust/agama-software/src/zypp_server.rs | 169 +++--------------- rust/agama-software/tests/zypp_server.rs | 6 + rust/agama-utils/src/api/security.rs | 45 ++++- 14 files changed, 347 insertions(+), 230 deletions(-) diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 0213ee39f9..731a7620a9 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -241,6 +241,7 @@ dependencies = [ "gettext-rs", "openssl", "thiserror 2.0.17", + "tracing", ] [[package]] @@ -308,6 +309,7 @@ version = "0.1.0" dependencies = [ "agama-l10n", "agama-locale-data", + "agama-security", "agama-utils", "async-trait", "camino", @@ -4864,9 +4866,9 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] name = "tracing" -version = "0.1.43" +version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d15d90a0b5c19378952d479dc858407149d7bb45a14de0142f6c534b16fc647" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "log", "pin-project-lite", @@ -4887,9 +4889,9 @@ dependencies = [ [[package]] name = "tracing-core" -version = "0.1.35" +version = "0.1.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a04e24fab5c89c6a36eb8558c9656f30d81de51dfa4d3b45f26b21d61fa0a6c" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", "valuable", diff --git a/rust/agama-manager/src/service.rs b/rust/agama-manager/src/service.rs index b7f1115b9d..3be84f361e 100644 --- a/rust/agama-manager/src/service.rs +++ b/rust/agama-manager/src/service.rs @@ -240,6 +240,7 @@ impl Starter { issues.clone(), progress.clone(), self.questions.clone(), + security.clone(), ) .start() .await? diff --git a/rust/agama-manager/src/test_utils.rs b/rust/agama-manager/src/test_utils.rs index 6b5b24362f..fa9db49ef1 100644 --- a/rust/agama-manager/src/test_utils.rs +++ b/rust/agama-manager/src/test_utils.rs @@ -26,6 +26,7 @@ use agama_bootloader::test_utils::start_service as start_bootloader_service; use agama_hostname::test_utils::start_service as start_hostname_service; use agama_l10n::test_utils::start_service as start_l10n_service; use agama_network::test_utils::start_service as start_network_service; +use agama_security::test_utils::start_service as start_security_service; use agama_software::test_utils::start_service as start_software_service; use agama_storage::test_utils::start_service as start_storage_service; use agama_utils::{actor::Handler, api::event, issue, progress, question}; @@ -38,6 +39,7 @@ pub async fn start_service(events: event::Sender, dbus: zbus::Connection) -> Han let issues = issue::Service::starter(events.clone()).start(); let questions = question::start(events.clone()).await.unwrap(); let progress = progress::Service::starter(events.clone()).start(); + let security = start_security_service(questions.clone()).await; Service::starter(questions.clone(), events.clone(), dbus.clone()) .with_hostname(start_hostname_service(events.clone(), issues.clone()).await) @@ -53,6 +55,7 @@ pub async fn start_service(events: event::Sender, dbus: zbus::Connection) -> Han ) .with_bootloader(start_bootloader_service(issues.clone(), dbus.clone()).await) .with_network(start_network_service(events.clone(), progress.clone()).await) + .with_security(security.clone()) .with_software(start_software_service(events, issues, progress, questions).await) .with_hardware(hardware::Registry::new_from_file( fixtures.join("lshw.json"), diff --git a/rust/agama-security/Cargo.toml b/rust/agama-security/Cargo.toml index 12c7720616..829c1b6a6e 100644 --- a/rust/agama-security/Cargo.toml +++ b/rust/agama-security/Cargo.toml @@ -10,3 +10,4 @@ async-trait = "0.1.89" gettext-rs = { version = "0.7.1", features = ["gettext-system"] } openssl = "0.10.75" thiserror = "2.0.17" +tracing = "0.1.44" diff --git a/rust/agama-security/src/certificate.rs b/rust/agama-security/src/certificate.rs index e67380c46a..ba3b079c98 100644 --- a/rust/agama-security/src/certificate.rs +++ b/rust/agama-security/src/certificate.rs @@ -18,16 +18,15 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use std::{collections::HashMap, fs::File, io::Write, process::Command}; +use std::{collections::HashMap, fs::File, io::Write, path::Path, process::Command}; +use agama_utils::api::security::SSLFingerprint; use openssl::{ hash::MessageDigest, nid::Nid, x509::{X509NameRef, X509}, }; -const INSTSYS_CERT_FILE: &str = "/etc/pki/trust/anchors/registration_server.pem"; - /// Wrapper around a X509 certificate. /// /// It extracts the relevant information from a certificate (fingerprint, issuer name, etc.). @@ -40,10 +39,6 @@ impl Certificate { Self { x509 } } - pub fn issuer(&self) -> Option { - Self::extract_entry(self.x509.issuer_name(), Nid::COMMONNAME) - } - pub fn not_before(&self) -> String { self.x509.not_before().to_string() } @@ -52,32 +47,40 @@ impl Certificate { self.x509.not_after().to_string() } - pub fn sha1(&self) -> Option { + pub fn fingerprint(&self) -> Option { + self.sha256().or_else(|| self.sha1()) + } + + pub fn sha1(&self) -> Option { match self.x509.digest(MessageDigest::sha1()) { - Ok(digest) => digest - .iter() - .map(|b| format!("{:02x}", b)) - .collect::>() - .join("") - .into(), + Ok(digest) => { + let fingerprint = digest + .iter() + .map(|b| format!("{:02x}", b)) + .collect::>() + .join(":"); + SSLFingerprint::sha1(&fingerprint).into() + } Err(_) => None, } } - pub fn sha256(&self) -> Option { - match self.x509.digest(MessageDigest::sha1()) { - Ok(digest) => digest - .iter() - .map(|b| format!("{:02x}", b)) - .collect::>() - .join("") - .into(), + pub fn sha256(&self) -> Option { + match self.x509.digest(MessageDigest::sha256()) { + Ok(digest) => { + let fingerprint = digest + .iter() + .map(|b| format!("{:02x}", b)) + .collect::>() + .join(":"); + SSLFingerprint::sha256(&fingerprint).into() + } Err(_) => None, } } - pub fn import(&self) -> std::io::Result<()> { - let mut file = File::create(INSTSYS_CERT_FILE)?; + pub fn import>(&self, path: P) -> std::io::Result<()> { + let mut file = File::create(path)?; let pem = self.x509.to_pem().unwrap(); file.write_all(&pem)?; Command::new("update-ca-certificates").output()?; @@ -85,21 +88,30 @@ impl Certificate { } pub fn to_data(&self) -> HashMap { + let issuer_name = self.x509.issuer_name(); let mut data = HashMap::from([ ("issueDate".to_string(), self.not_before()), ("expirationDate".to_string(), self.not_after()), ]); - if let Some(sha1) = self.sha1() { - data.insert("sha1Fingerprint".to_string(), sha1); + if let Some(common_name) = Self::extract_entry(issuer_name, Nid::COMMONNAME) { + data.insert("issuer".to_string(), common_name); } - if let Some(sha256) = self.sha256() { - data.insert("sha256Fingerprint".to_string(), sha256); + if let Some(ou) = Self::extract_entry(issuer_name, Nid::ORGANIZATIONALUNITNAME) { + data.insert("organizationalUnit".to_string(), ou); + } + + if let Some(o) = Self::extract_entry(issuer_name, Nid::ORGANIZATIONNAME) { + data.insert("organization".to_string(), o); } - if let Some(issuer) = self.issuer() { - data.insert("issuer".to_string(), issuer); + if let Some(sha1) = self.sha1() { + data.insert("sha1".to_string(), sha1.to_string()); + } + + if let Some(sha256) = self.sha256() { + data.insert("sha256".to_string(), sha256.to_string()); } data @@ -137,7 +149,26 @@ mod tests { let x509 = X509::from_pem(content.as_bytes()).unwrap(); let certificate = Certificate::new(x509); - let issuer = certificate.issuer().unwrap(); - dbg!(issuer); + let data = certificate.to_data(); + assert_eq!( + data.get("organization").unwrap(), + &"Example Company Ltd".to_string() + ); + assert_eq!( + data.get("issueDate").unwrap(), + &"Jan 19 10:49:57 2026 GMT".to_string() + ); + assert_eq!( + data.get("expirationDate").unwrap(), + &"Jan 19 10:49:57 2027 GMT".to_string() + ); + assert_eq!( + data.get("sha1").unwrap(), + &"5d:f7:68:ce:de:96:4c:dc:ea:84:e0:35:09:7a:9d:5f:af:b3:25:f4".to_string() + ); + assert_eq!( + data.get("sha256").unwrap(), + &"18:c0:d9:dc:9d:a9:93:6b:52:79:39:62:39:49:17:9f:0b:9f:ad:95:83:a5:d6:5b:02:16:62:f4:4b:18:1a:79".to_string() + ); } } diff --git a/rust/agama-security/src/service.rs b/rust/agama-security/src/service.rs index 985b81f54b..f712c34e72 100644 --- a/rust/agama-security/src/service.rs +++ b/rust/agama-security/src/service.rs @@ -18,40 +18,50 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. +use std::path::PathBuf; + use agama_utils::{ actor::{self, Actor, Handler, MessageHandler}, - api::{ - self, - question::QuestionSpec, - security::{SSLFingerprint, SSLFingerprintAlgorithm}, - }, + api::{self, question::QuestionSpec, security::SSLFingerprint}, question::{self, ask_question}, }; use async_trait::async_trait; use gettextrs::gettext; -use openssl::x509; use crate::{certificate::Certificate, message}; +const DEFAULT_WORKDIR: &str = "/etc/pki/trust/anchors"; + #[derive(thiserror::Error, Debug)] pub enum Error { #[error(transparent)] Actor(#[from] actor::Error), + #[error("Could not write the certificate: {0}")] + CertificateIO(#[source] std::io::Error), } pub struct Starter { questions: Handler, + workdir: PathBuf, } impl Starter { pub fn new(questions: Handler) -> Starter { - Self { questions } + Self { + questions, + workdir: PathBuf::from(DEFAULT_WORKDIR), + } + } + + pub fn with_workdir(mut self, workdir: &PathBuf) -> Self { + self.workdir = workdir.clone(); + self } pub fn start(self) -> Result, Error> { let service = Service { questions: self.questions, - state: State::default(), + state: State::new(self.workdir), }; let handler = actor::spawn(service); Ok(handler) @@ -60,7 +70,67 @@ impl Starter { #[derive(Default)] struct State { - fingerprints: Vec, + trusted: Vec, + rejected: Vec, + imported: Vec, + workdir: PathBuf, +} + +impl State { + pub fn new(workdir: PathBuf) -> Self { + Self { + workdir, + ..Default::default() + } + } + pub fn trust(&mut self, certificate: &Certificate) { + match certificate.fingerprint() { + Some(fingerprint) => self.trusted.push(fingerprint), + None => tracing::warn!("Failed to get the certificate fingerprint"), + } + } + + pub fn reject(&mut self, certificate: &Certificate) { + match certificate.fingerprint() { + Some(fingerprint) => self.rejected.push(fingerprint), + None => tracing::warn!("Failed to get the certificate fingerprint"), + } + } + + pub fn import(&mut self, certificate: &Certificate) -> Result<(), Error> { + let path = self.workdir.join("registration_server.pem"); + certificate + .import(&path) + .map_err(|e| Error::CertificateIO(e))?; + self.imported.push(path); + Ok(()) + } + + /// Determines whether the certificate is trusted. + pub fn is_trusted(&self, certificate: &Certificate) -> bool { + Self::contains(&self.trusted, certificate) + } + + /// Determines whether the certificate was rejected. + pub fn is_rejected(&self, certificate: &Certificate) -> bool { + Self::contains(&self.rejected, certificate) + } + + fn contains(list: &[SSLFingerprint], certificate: &Certificate) -> bool { + if let Some(sha256) = certificate.sha256() { + if list.contains(&sha256) { + return true; + } + } + + if let Some(sha1) = certificate.sha1() { + if list.contains(&sha1) { + return true; + } + } + + false + } } pub struct Service { @@ -104,7 +174,7 @@ impl MessageHandler> for Service { ) -> Result<(), Error> { match message.config { Some(config) => { - self.state.fingerprints = config.ssl_certificates.unwrap_or_default(); + self.state.trusted = config.ssl_certificates.unwrap_or_default(); } None => { self.state = State::default(); @@ -121,52 +191,48 @@ impl MessageHandler for Service { _message: message::GetConfig, ) -> Result { Ok(api::security::Config { - ssl_certificates: Some(self.state.fingerprints.clone()), + ssl_certificates: Some(self.state.trusted.clone()), }) } } #[async_trait] impl MessageHandler for Service { - // check whether the certificate is trusted. - // if it is not trusted, ask the user - // if the user rejects, adds it to a list of not trusted - // if the user accepts, adds it to the list of fingerprints - // and import the certificate. async fn handle(&mut self, message: message::CheckCertificate) -> Result { let certificate = Certificate::new(message.certificate); + let fingerprint = certificate + .fingerprint() + .as_ref() + .map(ToString::to_string) + .unwrap_or("unknown".to_string()); - if let Some(sha256) = certificate.sha256() { - if self - .state - .fingerprints - .iter() - .find(|f| f.algorithm == SSLFingerprintAlgorithm::SHA256 && f.fingerprint == sha256) - .is_some() - { - certificate.import().unwrap(); - return Ok(true); - }; + let trusted = self.state.is_trusted(&certificate); + let rejected = self.state.is_rejected(&certificate); + + tracing::info!( + "Certificate fingerprint={fingerprint} trusted={trusted} rejected={rejected}" + ); + + if rejected { + return Ok(false); } - if let Some(sha1) = certificate.sha1() { - if self - .state - .fingerprints - .iter() - .find(|f| f.algorithm == SSLFingerprintAlgorithm::SHA1 && f.fingerprint == sha1) - .is_some() - { - certificate.import().unwrap(); - return Ok(true); - } + if trusted { + // import in case it was not previously imported + tracing::info!("Importing already trusted certificate {fingerprint}"); + self.state.import(&certificate)?; + return Ok(true); } if self.should_trust_certificate(&certificate).await { - certificate.import().unwrap(); - Ok(true) + tracing::info!("The user trusts certificate {fingerprint}"); + self.state.trust(&certificate); + self.state.import(&certificate)?; + return Ok(true); } else { - Ok(false) + tracing::info!("The user rejects the certificate {fingerprint}"); + self.state.reject(&certificate); + return Ok(false); } } } diff --git a/rust/agama-software/Cargo.toml b/rust/agama-software/Cargo.toml index 00707cb9d1..6b023f2fca 100644 --- a/rust/agama-software/Cargo.toml +++ b/rust/agama-software/Cargo.toml @@ -8,6 +8,7 @@ edition.workspace = true agama-l10n = { path = "../agama-l10n" } agama-locale-data = { path = "../agama-locale-data" } agama-utils = { path = "../agama-utils" } +agama-security = { path = "../agama-security" } async-trait = "0.1.89" camino = "1.2.1" gettext-rs = { version = "0.7.1", features = ["gettext-system"] } diff --git a/rust/agama-software/src/model.rs b/rust/agama-software/src/model.rs index 6f4e5992cd..82504b8334 100644 --- a/rust/agama-software/src/model.rs +++ b/rust/agama-software/src/model.rs @@ -18,6 +18,7 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. +use agama_security as security; use agama_utils::{ actor::Handler, api::{ @@ -82,6 +83,7 @@ pub struct Model { selected_product: Option, progress: Handler, question: Handler, + security: Handler, /// Predefined repositories (from the off-line media and Driver Update Disks). /// They cannot be altered through user configuration. predefined_repositories: Vec, @@ -94,12 +96,14 @@ impl Model { predefined_repositories: Vec, progress: Handler, question: Handler, + security: Handler, ) -> Result { Ok(Self { zypp_sender, selected_product: None, progress, question, + security, predefined_repositories, }) } @@ -120,6 +124,7 @@ impl ModelAdapter for Model { self.zypp_sender.send(SoftwareAction::Write { state: software, progress, + security: self.security.clone(), question: self.question.clone(), tx, })?; diff --git a/rust/agama-software/src/model/registration.rs b/rust/agama-software/src/model/registration.rs index 5c43e9240b..586ebee1cb 100644 --- a/rust/agama-software/src/model/registration.rs +++ b/rust/agama-software/src/model/registration.rs @@ -24,11 +24,14 @@ //! the system and its add-ons and with libzypp (through [zypp_agama]) to add the //! corresponding services to `libzypp`. +use agama_security as security; use agama_utils::{ + actor::Handler, api::software::{AddonInfo, AddonStatus, RegistrationInfo}, arch::Arch, }; use camino::Utf8PathBuf; +use openssl::x509::X509; use suseconnect_agama::{self, ConnectParams, Credentials}; use url::Url; @@ -42,6 +45,8 @@ pub enum RegistrationError { AddService(String, #[source] zypp_agama::ZyppError), #[error("Failed to refresh the service {0}: {1}")] RefreshService(String, #[source] zypp_agama::ZyppError), + #[error(transparent)] + Security(#[from] security::service::Error), } type RegistrationResult = Result; @@ -289,7 +294,11 @@ impl RegistrationBuilder { /// It announces the system, gets the credentials and registers the base product. /// /// * `zypp`: zypp instance. - pub fn register(&self, zypp: &zypp_agama::Zypp) -> RegistrationResult { + pub fn register( + &self, + zypp: &zypp_agama::Zypp, + security_srv: &Handler, + ) -> RegistrationResult { let params = suseconnect_agama::ConnectParams { token: self.code.clone(), email: self.email.clone(), @@ -301,7 +310,12 @@ impl RegistrationBuilder { let version = self.version.split(".").next().unwrap_or("1"); let target_distro = format!("{}-{}-{}", &self.product, version, std::env::consts::ARCH); tracing::debug!("Announcing system {target_distro}"); - let creds = suseconnect_agama::announce_system(params.clone(), &target_distro)?; + let creds = handle_registration_error( + || suseconnect_agama::announce_system(params.clone(), &target_distro), + security_srv, + )?; + + // suseconnect_agama::announce_system(params.clone(), &target_distro)?; tracing::debug!( "Creating the base credentials file at {}", @@ -327,3 +341,66 @@ impl RegistrationBuilder { Ok(registration) } } + +/// Ancillary function to handle registration errors. +/// +/// Runs the given function and handles potential SSL errors. If there is an SSL +/// error and it can be solved by importing the certificate, it asks the security +/// service whether to trust the certificate. +/// +/// It returns the result if the given function runs successfully or return any +/// other kind of error. +fn handle_registration_error( + func: F, + security_srv: &Handler, +) -> Result +where + F: Fn() -> Result, +{ + loop { + let result = func(); + + if let Err(suseconnect_agama::Error::SSL { + code, + message: _, + current_certificate, + }) = &result + { + if code.is_fixable_by_import() { + let x509 = X509::from_pem(¤t_certificate.as_bytes()).unwrap(); + match should_trust_certificate(&x509, security_srv) { + Ok(true) => { + if let Err(error) = suseconnect_agama::reload_certificates() { + tracing::error!("Could not reload the certificates: {error}"); + } + continue; + } + Ok(false) => tracing::warn!("Do not trust the certificate"), + Err(error) => tracing::error!("Error processing the certificate: {error}"), + } + } + } + + return Ok(result?); + } +} + +pub fn should_trust_certificate( + certificate: &X509, + security_srv: &Handler, +) -> Result { + // unwrap OK: unwrap is fine because, if we eat all I/O resources, there is not solution + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + + let security_srv = security_srv.clone(); + let certificate = certificate.clone(); + let handle = rt.spawn(async move { + security_srv + .call(security::message::CheckCertificate::new(certificate)) + .await + }); + rt.block_on(handle).unwrap() +} diff --git a/rust/agama-software/src/service.rs b/rust/agama-software/src/service.rs index bde10f21bb..5a2e81ad06 100644 --- a/rust/agama-software/src/service.rs +++ b/rust/agama-software/src/service.rs @@ -24,6 +24,7 @@ use crate::{ zypp_server::{self, SoftwareAction, ZyppServer}, Model, }; +use agama_security as security; use agama_utils::{ actor::{self, Actor, Handler, MessageHandler}, api::{ @@ -71,6 +72,7 @@ pub struct Starter { issues: Handler, progress: Handler, questions: Handler, + security: Handler, } impl Starter { @@ -79,6 +81,7 @@ impl Starter { issues: Handler, progress: Handler, questions: Handler, + security: Handler, ) -> Self { Self { model: None, @@ -86,6 +89,7 @@ impl Starter { issues, progress, questions, + security, } } @@ -113,6 +117,7 @@ impl Starter { find_mandatory_repositories("/"), self.progress.clone(), self.questions.clone(), + self.security.clone(), )?)) } }; @@ -162,8 +167,9 @@ impl Service { issues: Handler, progress: Handler, questions: Handler, + security: Handler, ) -> Starter { - Starter::new(events, issues, progress, questions) + Starter::new(events, issues, progress, questions, security) } pub async fn setup(&mut self) -> Result<(), Error> { diff --git a/rust/agama-software/src/test_utils.rs b/rust/agama-software/src/test_utils.rs index 62dffe1b82..13634e1925 100644 --- a/rust/agama-software/src/test_utils.rs +++ b/rust/agama-software/src/test_utils.rs @@ -18,6 +18,7 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. +use agama_security::test_utils::start_service as start_security_service; use agama_utils::{ actor::Handler, api::{ @@ -89,7 +90,8 @@ pub async fn start_service( progress: Handler, questions: Handler, ) -> Handler { - Service::starter(events, issues, progress, questions) + let security = start_security_service(questions.clone()).await; + Service::starter(events, issues, progress, questions, security) .with_model(TestModel {}) .start() .await diff --git a/rust/agama-software/src/zypp_server.rs b/rust/agama-software/src/zypp_server.rs index c9b71a6d41..42f32922e4 100644 --- a/rust/agama-software/src/zypp_server.rs +++ b/rust/agama-software/src/zypp_server.rs @@ -18,26 +18,20 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. +use agama_security as security; use agama_utils::{ actor::Handler, api::{ self, - question::QuestionSpec, software::{Pattern, SelectedBy, SoftwareProposal, SystemInfo}, Issue, Scope, }, products::ProductSpec, - progress, - question::{self, ask_question}, + progress, question, }; use camino::{Utf8Path, Utf8PathBuf}; use gettextrs::gettext; -use openssl::{ - hash::MessageDigest, - nid::Nid, - x509::{X509NameRef, X509}, -}; -use std::{collections::HashMap, fs::File, io::Write, process::Command}; +use std::collections::HashMap; use tokio::sync::{ mpsc::{self, UnboundedSender}, oneshot, @@ -45,7 +39,7 @@ use tokio::sync::{ use zypp_agama::{errors::ZyppResult, ZyppError}; use crate::{ - callbacks::{self, ask_software_question}, + callbacks, model::{ registration::RegistrationError, state::{self, SoftwareState}, @@ -109,6 +103,7 @@ pub enum SoftwareAction { state: SoftwareState, progress: Handler, question: Handler, + security: Handler, tx: oneshot::Sender>>, }, } @@ -194,10 +189,19 @@ impl ZyppServer { state, progress, question, + security: security_srv, tx, } => { let mut security_callback = callbacks::Security::new(question.clone()); - self.write(state, progress, question, &mut security_callback, tx, zypp)?; + self.write( + state, + progress, + question, + security_srv, + &mut security_callback, + tx, + zypp, + )?; } SoftwareAction::GetSystemInfo(product_spec, tx) => { self.system_info(product_spec, tx, zypp)?; @@ -274,6 +278,7 @@ impl ZyppServer { state: SoftwareState, progress: Handler, questions: Handler, + security_srv: Handler, security: &mut callbacks::Security, tx: oneshot::Sender>>, zypp: &zypp_agama::Zypp, @@ -297,7 +302,7 @@ impl ZyppServer { let old_state = self.read(zypp)?; if let Some(registration_config) = &state.registration { - self.update_registration(registration_config, &zypp, &questions, &mut issues); + self.update_registration(registration_config, &zypp, &security_srv, &mut issues); } progress.cast(progress::message::Next::new(Scope::Software))?; @@ -714,12 +719,13 @@ impl ZyppServer { /// again. /// /// - `state`: wanted registration state. + /// - `zypp`: zypp instance. /// - `issues`: list of issues to update. fn update_registration( &mut self, state: &RegistrationState, zypp: &zypp_agama::Zypp, - questions: &Handler, + security_srv: &Handler, issues: &mut Vec, ) { match &self.registration { @@ -733,7 +739,7 @@ impl ZyppServer { ); } RegistrationStatus::NotRegistered => { - self.register_base_system(state, zypp, questions, issues); + self.register_base_system(state, zypp, security_srv, issues); } RegistrationStatus::Registered(registration) => {} }; @@ -747,7 +753,7 @@ impl ZyppServer { &mut self, state: &RegistrationState, zypp: &zypp_agama::Zypp, - questions: &Handler, + security_srv: &Handler, issues: &mut Vec, ) { let mut registration = @@ -762,8 +768,7 @@ impl ZyppServer { registration = registration.with_url(url); } - let result = handle_registration_error(|| registration.register(&zypp), &questions); - match result { + match registration.register(&zypp, security_srv) { Ok(registration) => { self.registration = RegistrationStatus::Registered(registration); } @@ -801,133 +806,3 @@ impl ZyppServer { } } } - -/// Ancillary function to handle registration errors. -/// -/// Runs the given function and handles potential SSL errors. If there is an SSL -/// error and it can be solved by importing the certificate, it asks the user and, -/// if wanted, imports the certificate and runs the function again. -/// -/// It returns the result if the given function runs successfully or return any -/// other kind of error. -fn handle_registration_error( - func: F, - questions: &Handler, -) -> Result -where - F: Fn() -> Result, -{ - loop { - let result = func(); - - if let Err(RegistrationError::Registration(ref inner)) = result { - if let suseconnect_agama::Error::SSL { - code, - message: _, - current_certificate, - } = inner - { - if code.is_fixable_by_import() { - let certificate = X509::from_pem(¤t_certificate.as_bytes()).unwrap(); - if should_trust_certificate(&certificate, questions) { - if let Err(error) = certs::import_certificate(&certificate) { - tracing::error!("Could not import the certificate: {error}"); - } - if let Err(error) = suseconnect_agama::reload_certificates() { - tracing::error!("Could not reload the certificates: {error}"); - } - continue; - } - } - } - } - return result; - } -} - -/// Asks the user whether it should trust the certificate. -/// -/// * `certificate`: certificate to ask for. -/// * `questions`: handler to ask questions. -fn should_trust_certificate(cert: &X509, questions: &Handler) -> bool { - let labels = [gettext("Trust"), gettext("Reject")]; - let msg = gettext("Trying to import a self-signed certificate. Do you want to trust it and register the product?"); - let mut data = HashMap::from([ - ("Not before".to_string(), cert.not_before().to_string()), - ("Not after".to_string(), cert.not_after().to_string()), - ]); - - if let Ok(digest) = cert.digest(MessageDigest::sha1()) { - let sha1 = digest - .iter() - .map(|b| format!("{:02x}", b)) - .collect::>() - .join(""); - data.insert("SHA1 fingerprint".to_string(), sha1); - } - - if let Ok(digest) = cert.digest(MessageDigest::sha256()) { - let sha256 = digest - .iter() - .map(|b| format!("{:02x}", b)) - .collect::>() - .join(""); - data.insert("SHA256 fingerprint".to_string(), sha256); - } - - let issuer = cert.issuer_name(); - if let Some(name) = certs::extract_entry(issuer, Nid::COMMONNAME) { - data.insert("Issuer".to_string(), name); - } - - let question = QuestionSpec::new(&msg, "registration.certificate") - .with_owned_data(data) - .with_actions(&[ - ("Trust", labels[0].as_str()), - ("Reject", labels[1].as_str()), - ]) - .with_default_action("Trust"); - - let Ok(answer) = ask_software_question(questions, question) else { - return false; - }; - - answer.action == "Trust".to_string() -} - -/// Ancillary functions to work with certificates. -mod certs { - use std::{fs::File, io::Write, process::Command}; - - use openssl::{ - nid::Nid, - x509::{X509NameRef, X509}, - }; - - const INSTSYS_CERT_FILE: &str = "/etc/pki/trust/anchors/registration_server.pem"; - - /// Imports the certificate. - /// - /// * `certificate`: certificate to import. - pub fn import_certificate(certificate: &X509) -> std::io::Result<()> { - let mut file = File::create(INSTSYS_CERT_FILE)?; - let pem = certificate.to_pem().unwrap(); - file.write_all(&pem)?; - Command::new("update-ca-certificates").output()?; - Ok(()) - } - - /// Extract an entry from the X509 names. - /// - /// It only extracts the first value. - /// - /// * `name`: X509 names. - /// * `nid`: entry identifier. - pub fn extract_entry(name: &X509NameRef, nid: Nid) -> Option { - let Some(entry) = name.entries_by_nid(nid).next() else { - return None; - }; - - entry.data().as_utf8().map(|l| l.to_string()).ok() - } -} diff --git a/rust/agama-software/tests/zypp_server.rs b/rust/agama-software/tests/zypp_server.rs index 92f1f321ce..f2aca5746f 100644 --- a/rust/agama-software/tests/zypp_server.rs +++ b/rust/agama-software/tests/zypp_server.rs @@ -18,6 +18,7 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. +use agama_security as security; use agama_software::state::{Repository as StateRepository, SoftwareState}; use agama_software::zypp_server::{SoftwareAction, ZyppServer, ZyppServerResult}; use agama_utils::{ @@ -84,6 +85,10 @@ async fn test_start_zypp_server() { let question_service = question::service::Service::new(event_tx.clone()); let question_handler = actor::spawn(question_service); + // Spawn the security service + let security_service_starter = security::service::Starter::new(question_handler.clone()); + let security_handler = security_service_starter.start().unwrap(); + // Pre-configure the answer to the GPG key question let answer = Answer { action: "Trust".to_string(), @@ -121,6 +126,7 @@ async fn test_start_zypp_server() { state: software_state, progress: progress_handler, question: question_handler.clone(), + security: security_handler, tx, }) .expect("Failed to send SoftwareAction::Write"); diff --git a/rust/agama-utils/src/api/security.rs b/rust/agama-utils/src/api/security.rs index b7f79ad0a5..b0853741e8 100644 --- a/rust/agama-utils/src/api/security.rs +++ b/rust/agama-utils/src/api/security.rs @@ -20,7 +20,8 @@ //! Implements a data model for Bootloader configuration. use merge::Merge; -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize}; +use serde_with::serde_as; /// Security settings for installation #[derive(Clone, Debug, Default, Serialize, Deserialize, Merge, utoipa::ToSchema)] @@ -57,10 +58,12 @@ pub enum SSLFingerprintAlgorithm { SHA256, } -#[derive(Clone, Debug, Serialize, Deserialize, utoipa::ToSchema)] +#[serde_as] +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, utoipa::ToSchema)] pub struct SSLFingerprint { /// The string value for SSL certificate fingerprint. /// Example value is "F6:7A:ED:BB:BC:94:CF:55:9D:B3:BA:74:7A:87:05:EF:67:4E:C2:DB" + #[serde_with(serialize_fingerprint)] pub fingerprint: String, /// Algorithm used to compute SSL certificate fingerprint. /// Supported options are "SHA1" and "SHA256" @@ -71,3 +74,41 @@ pub struct SSLFingerprint { // fn alg_not_found_err(s: &str) -> ServiceError { // ServiceError::UnsupportedSSLFingerprintAlgorithm(s.to_string()) // } + +impl SSLFingerprint { + pub fn sha1(fingerprint: &str) -> Self { + Self { + fingerprint: normalize_fingerprint(fingerprint), + algorithm: SSLFingerprintAlgorithm::SHA1, + } + } + + pub fn sha256(fingerprint: &str) -> Self { + Self { + fingerprint: normalize_fingerprint(fingerprint), + algorithm: SSLFingerprintAlgorithm::SHA256, + } + } +} + +impl ToString for SSLFingerprint { + fn to_string(&self) -> String { + self.fingerprint.clone() + } +} + +fn serialize_fingerprint<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + // 1. Deserialize the value into a temporary String + let s: String = Deserialize::deserialize(deserializer)?; + + // 2. Preprocess: remove spaces and convert to lowercase + // We use .replace(" ", "") to remove all spaces, or .trim() for just ends + Ok(s.replace(' ', "").to_lowercase()) +} + +fn normalize_fingerprint(fingerprint: &str) -> String { + fingerprint.replace(' ', "").to_lowercase() +} From 3a02accb04f82d6575c1112b6eef24ba74482905 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Tue, 20 Jan 2026 20:59:27 +0000 Subject: [PATCH 11/20] Add tests for the agama-security --- rust/Cargo.lock | 3 + rust/agama-security/Cargo.toml | 5 + rust/agama-security/src/lib.rs | 251 ++++++++++++++++++++++++++ rust/share/bin/update-ca-certificates | 2 + 4 files changed, 261 insertions(+) create mode 100755 rust/share/bin/update-ca-certificates diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 731a7620a9..9fdd5356e7 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -240,7 +240,10 @@ dependencies = [ "async-trait", "gettext-rs", "openssl", + "tempfile", + "test-context", "thiserror 2.0.17", + "tokio", "tracing", ] diff --git a/rust/agama-security/Cargo.toml b/rust/agama-security/Cargo.toml index 829c1b6a6e..671e9fa39c 100644 --- a/rust/agama-security/Cargo.toml +++ b/rust/agama-security/Cargo.toml @@ -11,3 +11,8 @@ gettext-rs = { version = "0.7.1", features = ["gettext-system"] } openssl = "0.10.75" thiserror = "2.0.17" tracing = "0.1.44" + +[dev-dependencies] +test-context = "0.4.1" +tempfile = "3.10.1" +tokio = { version = "1.47.1", features = ["macros"] } diff --git a/rust/agama-security/src/lib.rs b/rust/agama-security/src/lib.rs index 83f4081476..df15edeb46 100644 --- a/rust/agama-security/src/lib.rs +++ b/rust/agama-security/src/lib.rs @@ -29,3 +29,254 @@ pub mod message; pub mod test_utils; mod certificate; + +#[cfg(test)] +mod tests { + use crate::{message, service::Service}; + use agama_utils::{ + actor::Handler, + api::{self, event::Event, question::Answer}, + question, + }; + use openssl::{hash::MessageDigest, x509::X509}; + use std::{ + env, + fs::{self}, + path::PathBuf, + }; + use tempfile::TempDir; + use test_context::{test_context, AsyncTestContext}; + use tokio::sync::broadcast; + + struct Context { + handler: Handler, + questions: Handler, + _tmp_dir: TempDir, + _events_rx: broadcast::Receiver, + } + + impl AsyncTestContext for Context { + async fn setup() -> Context { + let tmp_dir = TempDir::new().expect("Could not create temp dir"); + let workdir = tmp_dir.path().to_path_buf(); + + let bin_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("Could not find parent dir") + .join("share/bin") + .canonicalize() + .expect("Could not find share/bin"); + + // Update PATH to include the bin dir + let path = env::var("PATH").unwrap_or_default(); + let new_path = format!("{}:{}", bin_dir.display(), path); + env::set_var("PATH", new_path); + + let (events_tx, events_rx) = broadcast::channel::(16); + let questions = question::start(events_tx) + .await + .expect("Could not start question service"); + + let handler = Service::starter(questions.clone()) + .with_workdir(&workdir) + .start() + .expect("Could not start the security service"); + + Self { + handler, + questions, + _tmp_dir: tmp_dir, + _events_rx: events_rx, + } + } + } + + fn load_test_certificate() -> X509 { + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../test/share/test.pem"); + let content = fs::read(path).expect("failed to read test certificate"); + X509::from_pem(&content).expect("failed to parse test certificate") + } + + fn get_fingerprint(cert: &X509) -> String { + let digest = cert.digest(MessageDigest::sha256()).unwrap(); + digest + .iter() + .map(|b| format!("{:02x}", b)) + .collect::>() + .join(":") + } + + #[test_context(Context)] + #[tokio::test] + async fn test_check_certificate_pre_approved( + ctx: &mut Context, + ) -> Result<(), Box> { + let cert = load_test_certificate(); + let fingerprint = get_fingerprint(&cert); + + // Set the certificate as pre-approved + let config = api::security::Config { + ssl_certificates: Some(vec![api::security::SSLFingerprint::sha256(&fingerprint)]), + }; + ctx.handler + .call(message::SetConfig::new(Some(config))) + .await?; + + // Check the certificate + let valid = ctx + .handler + .call(message::CheckCertificate::new(cert)) + .await?; + + assert!(valid); + + Ok(()) + } + + #[test_context(Context)] + #[tokio::test] + async fn test_check_certificate_user_trusted( + ctx: &mut Context, + ) -> Result<(), Box> { + let cert = load_test_certificate(); + + // Clear config + ctx.handler.call(message::SetConfig::new(None)).await?; + + let questions = ctx.questions.clone(); + tokio::spawn(async move { + loop { + // Poll for the question + let pending = questions.call(question::message::Get).await.unwrap(); + if let Some(q) = pending + .iter() + .find(|q| q.spec.class == "registration.certificate") + { + questions + .call(question::message::Answer { + id: q.id, + answer: Answer { + action: "Trust".to_string(), + value: Default::default(), + }, + }) + .await + .unwrap(); + break; + } + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } + }); + + // Check the certificate + let valid = ctx + .handler + .call(message::CheckCertificate::new(cert)) + .await?; + + assert!(valid); + + Ok(()) + } + + #[test_context(Context)] + #[tokio::test] + async fn test_check_certificate_user_rejected( + ctx: &mut Context, + ) -> Result<(), Box> { + let cert = load_test_certificate(); + + // Clear config + ctx.handler.call(message::SetConfig::new(None)).await?; + + let questions = ctx.questions.clone(); + tokio::spawn(async move { + loop { + // Poll for the question + let pending = questions.call(question::message::Get).await.unwrap(); + if let Some(q) = pending + .iter() + .find(|q| q.spec.class == "registration.certificate") + { + questions + .call(question::message::Answer { + id: q.id, + answer: Answer { + action: "Reject".to_string(), + value: Default::default(), + }, + }) + .await + .unwrap(); + break; + } + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } + }); + + // Check the certificate + let valid = ctx + .handler + .call(message::CheckCertificate::new(cert)) + .await?; + + assert!(!valid); + + Ok(()) + } + + #[test_context(Context)] + #[tokio::test] + async fn test_check_certificate_remembers_trust( + ctx: &mut Context, + ) -> Result<(), Box> { + let cert = load_test_certificate(); + + // Clear config + ctx.handler.call(message::SetConfig::new(None)).await?; + + let questions = ctx.questions.clone(); + tokio::spawn(async move { + loop { + // Poll for the question + let pending = questions.call(question::message::Get).await.unwrap(); + if let Some(q) = pending + .iter() + .find(|q| q.spec.class == "registration.certificate") + { + questions + .call(question::message::Answer { + id: q.id, + answer: Answer { + action: "Trust".to_string(), + value: Default::default(), + }, + }) + .await + .unwrap(); + break; + } + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } + }); + + // Check the certificate (first time) + let valid = ctx + .handler + .call(message::CheckCertificate::new(cert.clone())) + .await?; + + assert!(valid); + + // Check the certificate again (should be remembered) + let valid_again = ctx + .handler + .call(message::CheckCertificate::new(cert)) + .await?; + + assert!(valid_again); + + Ok(()) + } +} + diff --git a/rust/share/bin/update-ca-certificates b/rust/share/bin/update-ca-certificates new file mode 100755 index 0000000000..039e4d0069 --- /dev/null +++ b/rust/share/bin/update-ca-certificates @@ -0,0 +1,2 @@ +#!/bin/sh +exit 0 From 5d58d1bc643eef5a3e1b92598658545edf720647 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Tue, 20 Jan 2026 21:22:59 +0000 Subject: [PATCH 12/20] Reset the list of trusted certificates --- rust/agama-security/src/service.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/rust/agama-security/src/service.rs b/rust/agama-security/src/service.rs index f712c34e72..afc61066b4 100644 --- a/rust/agama-security/src/service.rs +++ b/rust/agama-security/src/service.rs @@ -116,6 +116,10 @@ impl State { Self::contains(&self.rejected, certificate) } + pub fn reset(&mut self) { + self.trusted.clear(); + } + fn contains(list: &[SSLFingerprint], certificate: &Certificate) -> bool { if let Some(sha256) = certificate.sha256() { if list.contains(&sha256) { @@ -177,7 +181,7 @@ impl MessageHandler> for Service { self.state.trusted = config.ssl_certificates.unwrap_or_default(); } None => { - self.state = State::default(); + self.state.reset(); } } Ok(()) From ea07ac5d62e45932271f2eef8440361d94db9425 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Wed, 21 Jan 2026 06:45:24 +0000 Subject: [PATCH 13/20] Fix deserializing of fingerprints --- rust/agama-utils/src/api/security.rs | 58 ++++++++++++++-------------- 1 file changed, 28 insertions(+), 30 deletions(-) diff --git a/rust/agama-utils/src/api/security.rs b/rust/agama-utils/src/api/security.rs index b0853741e8..66793af822 100644 --- a/rust/agama-utils/src/api/security.rs +++ b/rust/agama-utils/src/api/security.rs @@ -21,7 +21,6 @@ use merge::Merge; use serde::{Deserialize, Deserializer, Serialize}; -use serde_with::serde_as; /// Security settings for installation #[derive(Clone, Debug, Default, Serialize, Deserialize, Merge, utoipa::ToSchema)] @@ -35,35 +34,20 @@ pub struct Config { pub ssl_certificates: Option>, } -#[derive( - Default, - Clone, - Copy, - Debug, - strum::IntoStaticStr, - strum::EnumString, - Serialize, - Deserialize, - PartialEq, - utoipa::ToSchema, -)] -#[strum(ascii_case_insensitive)] -// #[strum( -// parse_err_fn = alg_not_found_err, -// parse_err_ty = ServiceError, -// )] +#[derive(Default, Clone, Copy, Debug, Serialize, Deserialize, PartialEq, utoipa::ToSchema)] pub enum SSLFingerprintAlgorithm { + #[serde(alias = "sha1", alias = "SHA1")] SHA1, + #[serde(alias = "sha256", alias = "SHA256")] #[default] SHA256, } -#[serde_as] #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, utoipa::ToSchema)] pub struct SSLFingerprint { /// The string value for SSL certificate fingerprint. /// Example value is "F6:7A:ED:BB:BC:94:CF:55:9D:B3:BA:74:7A:87:05:EF:67:4E:C2:DB" - #[serde_with(serialize_fingerprint)] + #[serde(deserialize_with = "serialize_fingerprint")] pub fingerprint: String, /// Algorithm used to compute SSL certificate fingerprint. /// Supported options are "SHA1" and "SHA256" @@ -71,11 +55,8 @@ pub struct SSLFingerprint { pub algorithm: SSLFingerprintAlgorithm, } -// fn alg_not_found_err(s: &str) -> ServiceError { -// ServiceError::UnsupportedSSLFingerprintAlgorithm(s.to_string()) -// } - impl SSLFingerprint { + /// Helper function to creaate a SHA1 fingerprint. pub fn sha1(fingerprint: &str) -> Self { Self { fingerprint: normalize_fingerprint(fingerprint), @@ -83,6 +64,7 @@ impl SSLFingerprint { } } + /// Helper function to creaate a SHA256 fingerprint. pub fn sha256(fingerprint: &str) -> Self { Self { fingerprint: normalize_fingerprint(fingerprint), @@ -101,14 +83,30 @@ fn serialize_fingerprint<'de, D>(deserializer: D) -> Result where D: Deserializer<'de>, { - // 1. Deserialize the value into a temporary String let s: String = Deserialize::deserialize(deserializer)?; - - // 2. Preprocess: remove spaces and convert to lowercase - // We use .replace(" ", "") to remove all spaces, or .trim() for just ends - Ok(s.replace(' ', "").to_lowercase()) + Ok(normalize_fingerprint(s.as_str())) } +/// Remove spaces and convert to uppercase fn normalize_fingerprint(fingerprint: &str) -> String { - fingerprint.replace(' ', "").to_lowercase() + fingerprint.replace(' ', "").to_uppercase() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_deserialize_fingerprint() { + let json = r#" + { "fingerprint": "f6:7a:ED:BB:BC:94:CF:55:9D:B3:BA:74:7A:87:05:EF:67:4E:C2:DB", "algorithm": "sha256" } + "#; + + let fingerprint: SSLFingerprint = serde_json::from_str(json).unwrap(); + assert_eq!( + &fingerprint.fingerprint, + "F6:7A:ED:BB:BC:94:CF:55:9D:B3:BA:74:7A:87:05:EF:67:4E:C2:DB" + ); + assert_eq!(fingerprint.algorithm, SSLFingerprintAlgorithm::SHA256); + } } From e08bbf20d5ab751c54a91381e292107da3f92a73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Wed, 21 Jan 2026 06:47:10 +0000 Subject: [PATCH 14/20] Make sure fingerprint is always normalized --- rust/agama-security/src/certificate.rs | 4 ++-- rust/agama-utils/src/api/security.rs | 17 +++++++++-------- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/rust/agama-security/src/certificate.rs b/rust/agama-security/src/certificate.rs index ba3b079c98..5ea385e843 100644 --- a/rust/agama-security/src/certificate.rs +++ b/rust/agama-security/src/certificate.rs @@ -164,11 +164,11 @@ mod tests { ); assert_eq!( data.get("sha1").unwrap(), - &"5d:f7:68:ce:de:96:4c:dc:ea:84:e0:35:09:7a:9d:5f:af:b3:25:f4".to_string() + &"5D:F7:68:CE:DE:96:4C:DC:EA:84:E0:35:09:7A:9D:5F:AF:B3:25:F4".to_string() ); assert_eq!( data.get("sha256").unwrap(), - &"18:c0:d9:dc:9d:a9:93:6b:52:79:39:62:39:49:17:9f:0b:9f:ad:95:83:a5:d6:5b:02:16:62:f4:4b:18:1a:79".to_string() + &"18:C0:D9:DC:9D:A9:93:6B:52:79:39:62:39:49:17:9F:0B:9F:AD:95:83:A5:D6:5B:02:16:62:F4:4B:18:1A:79".to_string() ); } } diff --git a/rust/agama-utils/src/api/security.rs b/rust/agama-utils/src/api/security.rs index 66793af822..a94039c26b 100644 --- a/rust/agama-utils/src/api/security.rs +++ b/rust/agama-utils/src/api/security.rs @@ -48,7 +48,7 @@ pub struct SSLFingerprint { /// The string value for SSL certificate fingerprint. /// Example value is "F6:7A:ED:BB:BC:94:CF:55:9D:B3:BA:74:7A:87:05:EF:67:4E:C2:DB" #[serde(deserialize_with = "serialize_fingerprint")] - pub fingerprint: String, + fingerprint: String, /// Algorithm used to compute SSL certificate fingerprint. /// Supported options are "SHA1" and "SHA256" #[serde(default)] @@ -56,20 +56,21 @@ pub struct SSLFingerprint { } impl SSLFingerprint { - /// Helper function to creaate a SHA1 fingerprint. - pub fn sha1(fingerprint: &str) -> Self { + pub fn new(fingerprint: &str, algorithm: SSLFingerprintAlgorithm) -> Self { Self { fingerprint: normalize_fingerprint(fingerprint), - algorithm: SSLFingerprintAlgorithm::SHA1, + algorithm, } } + /// Helper function to creaate a SHA1 fingerprint. + pub fn sha1(fingerprint: &str) -> Self { + new(fingerprint, SSLFingerprintAlgorithm::SHA1) + } + /// Helper function to creaate a SHA256 fingerprint. pub fn sha256(fingerprint: &str) -> Self { - Self { - fingerprint: normalize_fingerprint(fingerprint), - algorithm: SSLFingerprintAlgorithm::SHA256, - } + new(fingerprint, SSLFingerprintAlgorithm::SHA256) } } From 5f7b4d5515a34656a55e01a623d08f3a12c3633d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Wed, 21 Jan 2026 07:54:44 +0000 Subject: [PATCH 15/20] Add a Finish action to the security service * It copies the certificates to the target system. --- rust/agama-security/src/lib.rs | 27 ++++++++--- rust/agama-security/src/message.rs | 22 ++++++++- rust/agama-security/src/service.rs | 47 ++++++++++++++++--- rust/agama-software/src/model/registration.rs | 5 +- rust/agama-utils/src/api/security.rs | 4 +- 5 files changed, 86 insertions(+), 19 deletions(-) diff --git a/rust/agama-security/src/lib.rs b/rust/agama-security/src/lib.rs index df15edeb46..426b86f980 100644 --- a/rust/agama-security/src/lib.rs +++ b/rust/agama-security/src/lib.rs @@ -52,13 +52,18 @@ mod tests { handler: Handler, questions: Handler, _tmp_dir: TempDir, + workdir: PathBuf, + install_dir: PathBuf, _events_rx: broadcast::Receiver, } impl AsyncTestContext for Context { async fn setup() -> Context { let tmp_dir = TempDir::new().expect("Could not create temp dir"); - let workdir = tmp_dir.path().to_path_buf(); + let workdir = tmp_dir.path().join("etc/pki/trust/anchors").to_path_buf(); + std::fs::create_dir_all(&workdir).unwrap(); + let install_dir = tmp_dir.path().join("mnt").to_path_buf(); + std::fs::create_dir_all(&install_dir).unwrap(); let bin_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")) .parent() @@ -79,6 +84,7 @@ mod tests { let handler = Service::starter(questions.clone()) .with_workdir(&workdir) + .with_install_dir(&install_dir) .start() .expect("Could not start the security service"); @@ -86,6 +92,8 @@ mod tests { handler, questions, _tmp_dir: tmp_dir, + install_dir, + workdir, _events_rx: events_rx, } } @@ -125,11 +133,17 @@ mod tests { // Check the certificate let valid = ctx .handler - .call(message::CheckCertificate::new(cert)) + .call(message::CheckCertificate::new(cert, "registration")) .await?; assert!(valid); + // Check that the file is copied at the end of the installation. + ctx.handler.call(message::Finish).await?; + assert!( + std::fs::exists(ctx.install_dir.join(ctx.workdir.join("registration.pem"))).unwrap() + ); + Ok(()) } @@ -171,7 +185,7 @@ mod tests { // Check the certificate let valid = ctx .handler - .call(message::CheckCertificate::new(cert)) + .call(message::CheckCertificate::new(cert, "registration")) .await?; assert!(valid); @@ -217,7 +231,7 @@ mod tests { // Check the certificate let valid = ctx .handler - .call(message::CheckCertificate::new(cert)) + .call(message::CheckCertificate::new(cert, "registration")) .await?; assert!(!valid); @@ -263,7 +277,7 @@ mod tests { // Check the certificate (first time) let valid = ctx .handler - .call(message::CheckCertificate::new(cert.clone())) + .call(message::CheckCertificate::new(cert.clone(), "registration")) .await?; assert!(valid); @@ -271,7 +285,7 @@ mod tests { // Check the certificate again (should be remembered) let valid_again = ctx .handler - .call(message::CheckCertificate::new(cert)) + .call(message::CheckCertificate::new(cert, "registration")) .await?; assert!(valid_again); @@ -279,4 +293,3 @@ mod tests { Ok(()) } } - diff --git a/rust/agama-security/src/message.rs b/rust/agama-security/src/message.rs index 59c7f33aad..bbc75ac29b 100644 --- a/rust/agama-security/src/message.rs +++ b/rust/agama-security/src/message.rs @@ -42,16 +42,34 @@ impl SetConfig { } } +/// Message to check an SSL certificate. pub struct CheckCertificate { pub certificate: X509, + pub name: String, } impl CheckCertificate { - pub fn new(certificate: X509) -> Self { - Self { certificate } + /// Creates the message. + /// + /// * `certificate`: X509 certificate. + /// * `name`: certificate name. It is used as the filename (without the extension) when + /// importing the certificate. + pub fn new(certificate: X509, name: &str) -> Self { + Self { + certificate, + name: name.to_string(), + } } } impl Message for CheckCertificate { type Reply = bool; } + +/// Execute actions at the end of the installation. +#[derive(Clone)] +pub struct Finish; + +impl Message for Finish { + type Reply = (); +} diff --git a/rust/agama-security/src/service.rs b/rust/agama-security/src/service.rs index afc61066b4..fd8b4fca54 100644 --- a/rust/agama-security/src/service.rs +++ b/rust/agama-security/src/service.rs @@ -18,7 +18,7 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use agama_utils::{ actor::{self, Actor, Handler, MessageHandler}, @@ -31,6 +31,7 @@ use gettextrs::gettext; use crate::{certificate::Certificate, message}; const DEFAULT_WORKDIR: &str = "/etc/pki/trust/anchors"; +const DEFAULT_INSTALL_DIR: &str = "/mnt"; #[derive(thiserror::Error, Debug)] pub enum Error { @@ -43,6 +44,7 @@ pub enum Error { pub struct Starter { questions: Handler, workdir: PathBuf, + install_dir: PathBuf, } impl Starter { @@ -50,9 +52,15 @@ impl Starter { Self { questions, workdir: PathBuf::from(DEFAULT_WORKDIR), + install_dir: PathBuf::from(DEFAULT_INSTALL_DIR), } } + pub fn with_install_dir>(mut self, install_dir: P) -> Self { + self.install_dir = PathBuf::from(install_dir.as_ref()); + self + } + pub fn with_workdir(mut self, workdir: &PathBuf) -> Self { self.workdir = workdir.clone(); self @@ -62,6 +70,7 @@ impl Starter { let service = Service { questions: self.questions, state: State::new(self.workdir), + install_dir: self.install_dir.clone(), }; let handler = actor::spawn(service); Ok(handler) @@ -72,7 +81,7 @@ impl Starter { struct State { trusted: Vec, rejected: Vec, - imported: Vec, + imported: Vec, workdir: PathBuf, } @@ -97,12 +106,12 @@ impl State { } } - pub fn import(&mut self, certificate: &Certificate) -> Result<(), Error> { - let path = self.workdir.join("registration_server.pem"); + pub fn import(&mut self, certificate: &Certificate, name: &str) -> Result<(), Error> { + let path = self.workdir.join(format!("{name}.pem")); certificate .import(&path) .map_err(|e| Error::CertificateIO(e))?; - self.imported.push(path); + self.imported.push(name.to_string()); Ok(()) } @@ -120,6 +129,21 @@ impl State { self.trusted.clear(); } + pub fn copy_certificates(&self, directory: &Path) { + let workdir = self.workdir.strip_prefix("/").unwrap_or(&self.workdir); + let target_directory = directory.join(workdir); + for name in &self.imported { + let filename = format!("{name}.pem"); + let source = self.workdir.join(&filename); + let destination = target_directory.join(&filename); + + println!("COPYING {} {}", source.display(), destination.display()); + if let Err(error) = std::fs::copy(source, destination) { + tracing::warn!("Failed to write the certificate to {filename}: {error}",); + } + } + } + fn contains(list: &[SSLFingerprint], certificate: &Certificate) -> bool { if let Some(sha256) = certificate.sha256() { if list.contains(&sha256) { @@ -140,6 +164,7 @@ impl State { pub struct Service { questions: Handler, state: State, + install_dir: PathBuf, } impl Service { @@ -224,14 +249,14 @@ impl MessageHandler for Service { if trusted { // import in case it was not previously imported tracing::info!("Importing already trusted certificate {fingerprint}"); - self.state.import(&certificate)?; + self.state.import(&certificate, &message.name)?; return Ok(true); } if self.should_trust_certificate(&certificate).await { tracing::info!("The user trusts certificate {fingerprint}"); self.state.trust(&certificate); - self.state.import(&certificate)?; + self.state.import(&certificate, &message.name)?; return Ok(true); } else { tracing::info!("The user rejects the certificate {fingerprint}"); @@ -240,3 +265,11 @@ impl MessageHandler for Service { } } } + +#[async_trait] +impl MessageHandler for Service { + async fn handle(&mut self, _message: message::Finish) -> Result<(), Error> { + self.state.copy_certificates(&self.install_dir); + Ok(()) + } +} diff --git a/rust/agama-software/src/model/registration.rs b/rust/agama-software/src/model/registration.rs index 586ebee1cb..2f16bf61b3 100644 --- a/rust/agama-software/src/model/registration.rs +++ b/rust/agama-software/src/model/registration.rs @@ -399,7 +399,10 @@ pub fn should_trust_certificate( let certificate = certificate.clone(); let handle = rt.spawn(async move { security_srv - .call(security::message::CheckCertificate::new(certificate)) + .call(security::message::CheckCertificate::new( + certificate, + "registration_server", + )) .await }); rt.block_on(handle).unwrap() diff --git a/rust/agama-utils/src/api/security.rs b/rust/agama-utils/src/api/security.rs index a94039c26b..960e276a36 100644 --- a/rust/agama-utils/src/api/security.rs +++ b/rust/agama-utils/src/api/security.rs @@ -65,12 +65,12 @@ impl SSLFingerprint { /// Helper function to creaate a SHA1 fingerprint. pub fn sha1(fingerprint: &str) -> Self { - new(fingerprint, SSLFingerprintAlgorithm::SHA1) + Self::new(fingerprint, SSLFingerprintAlgorithm::SHA1) } /// Helper function to creaate a SHA256 fingerprint. pub fn sha256(fingerprint: &str) -> Self { - new(fingerprint, SSLFingerprintAlgorithm::SHA256) + Self::new(fingerprint, SSLFingerprintAlgorithm::SHA256) } } From baf2a423f2840eb06ebba347323eff8ac554416a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Wed, 21 Jan 2026 10:06:41 +0000 Subject: [PATCH 16/20] Adapt the registration certificate question --- rust/agama-security/src/certificate.rs | 4 ---- .../RegistrationCertificateQuestion.tsx | 17 ++++++++--------- 2 files changed, 8 insertions(+), 13 deletions(-) diff --git a/rust/agama-security/src/certificate.rs b/rust/agama-security/src/certificate.rs index 5ea385e843..22e9d2c5f2 100644 --- a/rust/agama-security/src/certificate.rs +++ b/rust/agama-security/src/certificate.rs @@ -98,10 +98,6 @@ impl Certificate { data.insert("issuer".to_string(), common_name); } - if let Some(ou) = Self::extract_entry(issuer_name, Nid::ORGANIZATIONALUNITNAME) { - data.insert("organizationalUnit".to_string(), ou); - } - if let Some(o) = Self::extract_entry(issuer_name, Nid::ORGANIZATIONNAME) { data.insert("organization".to_string(), o); } diff --git a/web/src/components/questions/RegistrationCertificateQuestion.tsx b/web/src/components/questions/RegistrationCertificateQuestion.tsx index 63976e3f1a..45a88426cd 100644 --- a/web/src/components/questions/RegistrationCertificateQuestion.tsx +++ b/web/src/components/questions/RegistrationCertificateQuestion.tsx @@ -80,15 +80,14 @@ export default function RegistrationCertificateQuestion({ - - - - - - + + {question.data.organization && ( + + )} + + + + From 6c28a4e0df27f3f172eb0c98469bd673ca8917f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Wed, 21 Jan 2026 10:18:07 +0000 Subject: [PATCH 17/20] Add some documentation --- rust/agama-security/src/service.rs | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/rust/agama-security/src/service.rs b/rust/agama-security/src/service.rs index fd8b4fca54..64fc028f67 100644 --- a/rust/agama-security/src/service.rs +++ b/rust/agama-security/src/service.rs @@ -92,6 +92,10 @@ impl State { ..Default::default() } } + + /// Trust the given certificate. + /// + /// * `certificate`: certificate to trust. pub fn trust(&mut self, certificate: &Certificate) { match certificate.fingerprint() { Some(fingerprint) => self.trusted.push(fingerprint), @@ -99,6 +103,9 @@ impl State { } } + /// Reject the given certificate. + /// + /// * `certificate`: certificate to import. pub fn reject(&mut self, certificate: &Certificate) { match certificate.fingerprint() { Some(fingerprint) => self.rejected.push(fingerprint), @@ -106,6 +113,12 @@ impl State { } } + /// Import the given certificate. + /// + /// It will be copied to the running system using the given name. + /// + /// * `certificate`: certificate to import. + /// * `name`: certificate name (e.g., "registration_server") pub fn import(&mut self, certificate: &Certificate, name: &str) -> Result<(), Error> { let path = self.workdir.join(format!("{name}.pem")); certificate @@ -116,19 +129,33 @@ impl State { } /// Determines whether the certificate is trusted. + /// + /// It checks whether its SHA1 or SHA256 fingerprint are included in the list of trusted + /// certificates. + /// + /// * `certificate`: certificate to check. pub fn is_trusted(&self, certificate: &Certificate) -> bool { Self::contains(&self.trusted, certificate) } /// Determines whether the certificate was rejected. + /// + /// It checks whether its SHA1 or SHA256 fingerprint are included in the list of rejected + /// certificates. pub fn is_rejected(&self, certificate: &Certificate) -> bool { Self::contains(&self.rejected, certificate) } + /// Reset the list of trusted certificates. + /// + /// Beware that it does not remove the already imported certificates. pub fn reset(&mut self) { self.trusted.clear(); } + /// Copy the certificates to the given directory. + /// + /// * `directory`: directory to copy the certificates. pub fn copy_certificates(&self, directory: &Path) { let workdir = self.workdir.strip_prefix("/").unwrap_or(&self.workdir); let target_directory = directory.join(workdir); @@ -172,6 +199,9 @@ impl Service { Starter::new(questions) } + /// Asks the user whether to trust the certificate. + /// + /// * `certificate`: certificate to check. pub async fn should_trust_certificate(&self, certificate: &Certificate) -> bool { let labels = [gettext("Trust"), gettext("Reject")]; let msg = gettext("Trying to import a self-signed certificate. Do you want to trust it and register the product?"); From 7b64a61ea95e9d6c0d708711570bd4d80a5d62c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Wed, 21 Jan 2026 10:46:52 +0000 Subject: [PATCH 18/20] Fix RegistrationCertificateQuestion test --- .../RegistrationCertificateQuestion.test.tsx | 22 +++++++++---------- 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/web/src/components/questions/RegistrationCertificateQuestion.test.tsx b/web/src/components/questions/RegistrationCertificateQuestion.test.tsx index 2296046e7f..05e8794270 100644 --- a/web/src/components/questions/RegistrationCertificateQuestion.test.tsx +++ b/web/src/components/questions/RegistrationCertificateQuestion.test.tsx @@ -37,12 +37,11 @@ const question: Question = { ], defaultAction: "yes", data: { - url: "https://test.com", - issuer_name: "test", - issue_date: "01-01-2025", - expiration_date: "01-01-2030", - sha1_fingerprint: "AA:BB:CC", - sha256_fingerprint: "11:22:33:44:55", + organization: "test", + issueDate: "01-01-2025", + expirationDate: "01-01-2030", + sha1: "AA:BB:CC", + sha256: "11:22:33:44:55", }, }; @@ -61,10 +60,9 @@ it("renders the certificate data", async () => { renderQuestion(); const data = question.data; - await screen.findByText(data.url); - await screen.findByText(data.issuer_name); - await screen.findByText(data.issue_date); - await screen.findByText(data.expiration_date); - await screen.findByText(data.sha1_fingerprint); - await screen.findByText(data.sha256_fingerprint); + await screen.findByText(data.organization); + await screen.findByText(data.issueDate); + await screen.findByText(data.expirationDate); + await screen.findByText(data.sha1); + await screen.findByText(data.sha256); }); From e1f8346e51e3e9735738b01c1cad4ea36215565a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Wed, 21 Jan 2026 10:49:26 +0000 Subject: [PATCH 19/20] Update changes files --- rust/package/agama.changes | 6 ++++++ web/package/agama-web-ui.changes | 8 +++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/rust/package/agama.changes b/rust/package/agama.changes index cefaaca190..cf5db6ac87 100644 --- a/rust/package/agama.changes +++ b/rust/package/agama.changes @@ -1,3 +1,9 @@ +------------------------------------------------------------------- +Wed Jan 21 10:47:24 UTC 2026 - Imobach Gonzalez Sosa + +- Allow importing SSL certificates from registration servers + (gh#agama-project/agama#3055). + ------------------------------------------------------------------- Mon Jan 19 20:31:26 UTC 2026 - Josef Reidinger diff --git a/web/package/agama-web-ui.changes b/web/package/agama-web-ui.changes index d4da2cffe0..d281b76886 100644 --- a/web/package/agama-web-ui.changes +++ b/web/package/agama-web-ui.changes @@ -1,3 +1,9 @@ +------------------------------------------------------------------- +Wed Jan 21 10:48:33 UTC 2026 - Imobach Gonzalez Sosa + +- Adapt to the changes in the question about trusting + certificates from the registration (gh#agama-project/agama#3055). + ------------------------------------------------------------------- Tue Jan 20 03:52:59 UTC 2026 - David Diaz @@ -10,7 +16,7 @@ Tue Jan 20 00:53:06 UTC 2026 - David Diaz - Avoid flicker on the product selection page by deferring the display of current product information (gh#agama-project/agama#3049). - + ------------------------------------------------------------------- Mon Jan 19 23:48:20 UTC 2026 - David Diaz From 9041b71458edf05d9d761e20180b7523214a4dcb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Wed, 21 Jan 2026 12:31:42 +0000 Subject: [PATCH 20/20] Run update-ca-certificates at the end of the installation --- rust/agama-security/src/service.rs | 32 ++++++++++++++++++++++++++---- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/rust/agama-security/src/service.rs b/rust/agama-security/src/service.rs index 64fc028f67..c16345bf4b 100644 --- a/rust/agama-security/src/service.rs +++ b/rust/agama-security/src/service.rs @@ -18,7 +18,10 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use std::path::{Path, PathBuf}; +use std::{ + path::{Path, PathBuf}, + process, +}; use agama_utils::{ actor::{self, Actor, Handler, MessageHandler}, @@ -39,6 +42,10 @@ pub enum Error { Actor(#[from] actor::Error), #[error("Could not write the certificate: {0}")] CertificateIO(#[source] std::io::Error), + #[error("Could not update the certificates database: {0}")] + CaCertificates(String), + #[error(transparent)] + IO(#[from] std::io::Error), } pub struct Starter { @@ -156,7 +163,7 @@ impl State { /// Copy the certificates to the given directory. /// /// * `directory`: directory to copy the certificates. - pub fn copy_certificates(&self, directory: &Path) { + pub fn copy_certificates(&self, directory: &Path) -> Result<(), Error> { let workdir = self.workdir.strip_prefix("/").unwrap_or(&self.workdir); let target_directory = directory.join(workdir); for name in &self.imported { @@ -164,11 +171,26 @@ impl State { let source = self.workdir.join(&filename); let destination = target_directory.join(&filename); - println!("COPYING {} {}", source.display(), destination.display()); if let Err(error) = std::fs::copy(source, destination) { tracing::warn!("Failed to write the certificate to {filename}: {error}",); } } + + let output = process::Command::new("update-ca-certificates") + .arg("--root") + .arg(&directory) + .output()?; + + if !output.status.success() { + tracing::warn!( + "Failed to update the certificates database at {}", + &directory.display() + ); + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(Error::CaCertificates(stderr.to_string())); + } + + Ok(()) } fn contains(list: &[SSLFingerprint], certificate: &Certificate) -> bool { @@ -299,7 +321,9 @@ impl MessageHandler for Service { #[async_trait] impl MessageHandler for Service { async fn handle(&mut self, _message: message::Finish) -> Result<(), Error> { - self.state.copy_certificates(&self.install_dir); + if let Err(error) = self.state.copy_certificates(&self.install_dir) { + tracing::error!("Failed to update the certificates on the target system: {error}"); + } Ok(()) } }