diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 7d48f7fca2..9fdd5356e7 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,21 @@ dependencies = [ "zbus", ] +[[package]] +name = "agama-security" +version = "0.1.0" +dependencies = [ + "agama-utils", + "async-trait", + "gettext-rs", + "openssl", + "tempfile", + "test-context", + "thiserror 2.0.17", + "tokio", + "tracing", +] + [[package]] name = "agama-server" version = "0.1.0" @@ -296,12 +312,14 @@ version = "0.1.0" dependencies = [ "agama-l10n", "agama-locale-data", + "agama-security", "agama-utils", "async-trait", "camino", "gettext-rs", "glob", "i18n-format", + "openssl", "regex", "serde", "serde_with", @@ -3197,9 +3215,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 +3247,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", @@ -4851,9 +4869,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", @@ -4874,9 +4892,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/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-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..3be84f361e 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 => { @@ -225,6 +240,7 @@ impl Starter { issues.clone(), progress.clone(), self.questions.clone(), + security.clone(), ) .start() .await? @@ -285,6 +301,7 @@ impl Starter { hostname, l10n, network, + security, software, storage, files, @@ -306,6 +323,7 @@ pub struct Service { bootloader: Handler, hostname: Handler, l10n: Handler, + security: Handler, software: Handler, network: NetworkSystemClient, storage: Handler, @@ -374,6 +392,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 +573,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 +593,7 @@ impl MessageHandler for Service { l10n: Some(l10n), questions, network: Some(network), + security: Some(security), software, storage, files: None, 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 new file mode 100644 index 0000000000..671e9fa39c --- /dev/null +++ b/rust/agama-security/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "agama-security" +version = "0.1.0" +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" +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/certificate.rs b/rust/agama-security/src/certificate.rs new file mode 100644 index 0000000000..22e9d2c5f2 --- /dev/null +++ b/rust/agama-security/src/certificate.rs @@ -0,0 +1,170 @@ +// 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, path::Path, process::Command}; + +use agama_utils::api::security::SSLFingerprint; +use openssl::{ + hash::MessageDigest, + nid::Nid, + x509::{X509NameRef, X509}, +}; + +/// 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 not_before(&self) -> String { + self.x509.not_before().to_string() + } + + pub fn not_after(&self) -> String { + self.x509.not_after().to_string() + } + + pub fn fingerprint(&self) -> Option { + self.sha256().or_else(|| self.sha1()) + } + + pub fn sha1(&self) -> Option { + match self.x509.digest(MessageDigest::sha1()) { + 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::sha256()) { + Ok(digest) => { + let fingerprint = digest + .iter() + .map(|b| format!("{:02x}", b)) + .collect::>() + .join(":"); + SSLFingerprint::sha256(&fingerprint).into() + } + Err(_) => None, + } + } + + 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()?; + Ok(()) + } + + 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(common_name) = Self::extract_entry(issuer_name, Nid::COMMONNAME) { + data.insert("issuer".to_string(), common_name); + } + + if let Some(o) = Self::extract_entry(issuer_name, Nid::ORGANIZATIONNAME) { + data.insert("organization".to_string(), o); + } + + 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 + } + + /// 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 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/lib.rs b/rust/agama-security/src/lib.rs new file mode 100644 index 0000000000..426b86f980 --- /dev/null +++ b/rust/agama-security/src/lib.rs @@ -0,0 +1,295 @@ +// 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; + +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, + 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().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() + .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) + .with_install_dir(&install_dir) + .start() + .expect("Could not start the security service"); + + Self { + handler, + questions, + _tmp_dir: tmp_dir, + install_dir, + workdir, + _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, "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(()) + } + + #[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, "registration")) + .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, "registration")) + .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(), "registration")) + .await?; + + assert!(valid); + + // Check the certificate again (should be remembered) + let valid_again = ctx + .handler + .call(message::CheckCertificate::new(cert, "registration")) + .await?; + + assert!(valid_again); + + Ok(()) + } +} diff --git a/rust/agama-security/src/message.rs b/rust/agama-security/src/message.rs new file mode 100644 index 0000000000..bbc75ac29b --- /dev/null +++ b/rust/agama-security/src/message.rs @@ -0,0 +1,75 @@ +// 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}; +use openssl::x509::X509; + +#[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 } + } +} + +/// Message to check an SSL certificate. +pub struct CheckCertificate { + pub certificate: X509, + pub name: String, +} + +impl CheckCertificate { + /// 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 new file mode 100644 index 0000000000..c16345bf4b --- /dev/null +++ b/rust/agama-security/src/service.rs @@ -0,0 +1,329 @@ +// 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::{ + path::{Path, PathBuf}, + process, +}; + +use agama_utils::{ + actor::{self, Actor, Handler, MessageHandler}, + api::{self, question::QuestionSpec, security::SSLFingerprint}, + question::{self, ask_question}, +}; +use async_trait::async_trait; +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 { + #[error(transparent)] + 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 { + questions: Handler, + workdir: PathBuf, + install_dir: PathBuf, +} + +impl Starter { + pub fn new(questions: Handler) -> 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 + } + + pub fn start(self) -> Result, Error> { + let service = Service { + questions: self.questions, + state: State::new(self.workdir), + install_dir: self.install_dir.clone(), + }; + let handler = actor::spawn(service); + Ok(handler) + } +} + +#[derive(Default)] +struct State { + trusted: Vec, + rejected: Vec, + imported: Vec, + workdir: PathBuf, +} + +impl State { + pub fn new(workdir: PathBuf) -> Self { + Self { + workdir, + ..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), + None => tracing::warn!("Failed to get the certificate fingerprint"), + } + } + + /// Reject the given certificate. + /// + /// * `certificate`: certificate to import. + 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"), + } + } + + /// 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 + .import(&path) + .map_err(|e| Error::CertificateIO(e))?; + self.imported.push(name.to_string()); + Ok(()) + } + + /// 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) -> Result<(), Error> { + 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); + + 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 { + 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 { + questions: Handler, + state: State, + install_dir: PathBuf, +} + +impl Service { + pub fn starter(questions: Handler) -> Starter { + 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?"); + + 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 { + 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.trusted = config.ssl_certificates.unwrap_or_default(); + } + None => { + self.state.reset(); + } + } + 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.trusted.clone()), + }) + } +} + +#[async_trait] +impl MessageHandler for Service { + 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()); + + 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 trusted { + // import in case it was not previously imported + tracing::info!("Importing already trusted certificate {fingerprint}"); + 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, &message.name)?; + return Ok(true); + } else { + tracing::info!("The user rejects the certificate {fingerprint}"); + self.state.reject(&certificate); + return Ok(false); + } + } +} + +#[async_trait] +impl MessageHandler for Service { + async fn handle(&mut self, _message: message::Finish) -> Result<(), Error> { + if let Err(error) = self.state.copy_certificates(&self.install_dir) { + tracing::error!("Failed to update the certificates on the target system: {error}"); + } + Ok(()) + } +} 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-software/Cargo.toml b/rust/agama-software/Cargo.toml index 4621ccaf70..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"] } @@ -25,6 +26,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/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/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 d3106ad4d3..2f16bf61b3 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 {}", @@ -314,7 +328,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(), @@ -327,3 +341,69 @@ 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, + "registration_server", + )) + .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 67b63fe0fc..42f32922e4 100644 --- a/rust/agama-software/src/zypp_server.rs +++ b/rust/agama-software/src/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_utils::{ actor::Handler, api::{ @@ -39,7 +40,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, }; @@ -76,6 +80,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; @@ -96,14 +103,24 @@ pub enum SoftwareAction { state: SoftwareState, progress: Handler, question: Handler, + security: Handler, tx: oneshot::Sender>>, }, } +/// 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 +136,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 @@ -172,10 +189,19 @@ impl ZyppServer { state, progress, question, + security: security_srv, 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, + security_srv, + &mut security_callback, + tx, + zypp, + )?; } SoftwareAction::GetSystemInfo(product_spec, tx) => { self.system_info(product_spec, tx, zypp)?; @@ -251,6 +277,8 @@ impl ZyppServer { &mut self, state: SoftwareState, progress: Handler, + questions: Handler, + security_srv: Handler, security: &mut callbacks::Security, tx: oneshot::Sender>>, zypp: &zypp_agama::Zypp, @@ -273,9 +301,8 @@ 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); + self.update_registration(registration_config, &zypp, &security_srv, &mut issues); } progress.cast(progress::message::Next::new(Scope::Software))?; @@ -517,7 +544,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 +712,37 @@ 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. + /// - `zypp`: zypp instance. + /// - `issues`: list of issues to update. fn update_registration( &mut self, state: &RegistrationState, zypp: &zypp_agama::Zypp, + security_srv: &Handler, 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, security_srv, issues); + } + RegistrationStatus::Registered(registration) => {} + }; if !state.addons.is_empty() { self.register_addons(&state.addons, zypp, issues); @@ -698,6 +753,7 @@ impl ZyppServer { &mut self, state: &RegistrationState, zypp: &zypp_agama::Zypp, + security_srv: &Handler, issues: &mut Vec, ) { let mut registration = @@ -712,15 +768,16 @@ impl ZyppServer { registration = registration.with_url(url); } - match registration.register(&zypp) { + match registration.register(&zypp, security_srv) { 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 +788,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; }; 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.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/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. diff --git a/rust/agama-utils/src/api/security.rs b/rust/agama-utils/src/api/security.rs new file mode 100644 index 0000000000..960e276a36 --- /dev/null +++ b/rust/agama-utils/src/api/security.rs @@ -0,0 +1,113 @@ +// 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, Deserializer, 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, Serialize, Deserialize, PartialEq, utoipa::ToSchema)] +pub enum SSLFingerprintAlgorithm { + #[serde(alias = "sha1", alias = "SHA1")] + SHA1, + #[serde(alias = "sha256", alias = "SHA256")] + #[default] + SHA256, +} + +#[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(deserialize_with = "serialize_fingerprint")] + fingerprint: String, + /// Algorithm used to compute SSL certificate fingerprint. + /// Supported options are "SHA1" and "SHA256" + #[serde(default)] + pub algorithm: SSLFingerprintAlgorithm, +} + +impl SSLFingerprint { + pub fn new(fingerprint: &str, algorithm: SSLFingerprintAlgorithm) -> Self { + Self { + fingerprint: normalize_fingerprint(fingerprint), + algorithm, + } + } + + /// Helper function to creaate a SHA1 fingerprint. + pub fn sha1(fingerprint: &str) -> Self { + Self::new(fingerprint, SSLFingerprintAlgorithm::SHA1) + } + + /// Helper function to creaate a SHA256 fingerprint. + pub fn sha256(fingerprint: &str) -> Self { + Self::new(fingerprint, 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>, +{ + let s: String = Deserialize::deserialize(deserializer)?; + Ok(normalize_fingerprint(s.as_str())) +} + +/// Remove spaces and convert to uppercase +fn normalize_fingerprint(fingerprint: &str) -> String { + 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); + } +} 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 diff --git a/rust/package/agama.changes b/rust/package/agama.changes index ac4d4c9bb9..4695ed6454 100644 --- a/rust/package/agama.changes +++ b/rust/package/agama.changes @@ -1,3 +1,9 @@ +------------------------------------------------------------------- +Wed Jan 21 11:07:34 UTC 2026 - Imobach Gonzalez Sosa + +- Allow importing SSL certificates from registration servers + (gh#agama-project/agama#3055). + ------------------------------------------------------------------- Wed Jan 21 10:59:15 UTC 2026 - Knut Anderssen 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, 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 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----- 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 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); }); 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 && ( + + )} + + + +