diff --git a/docs/adr/0040-macos-en-apple-silicon-como-tercera-plataforma.md b/docs/adr/0040-macos-en-apple-silicon-como-tercera-plataforma.md index 1fb9ae15..6ea257cc 100644 --- a/docs/adr/0040-macos-en-apple-silicon-como-tercera-plataforma.md +++ b/docs/adr/0040-macos-en-apple-silicon-como-tercera-plataforma.md @@ -17,7 +17,6 @@ como fallaría Linux sin el servicio y dice en su nombre qué falta. | Cerrojos (Firefox, carpeta de paso) | Los de Unix, sin cambios. | | Llavero `oo7` (ADR-0034) | `PendingMacosKeychain`: responde que no hay llavero. | | Manejadores de `afirma://` por GIO | `PendingMacosLaunchServices`: la lista no está disponible y elegir falla. | -| CA local en los almacenes NSS (ADR-0005) | `PendingMacosKeychainTrust`, sin perfiles: la CA consta como no instalada. | | Diálogo GTK de fallo de arranque | El texto sale por la salida de error. | La firma trifásica (ADR-0001) funciona de extremo a extremo con una clave en memoria: PAdES, @@ -150,5 +149,5 @@ dependencia en todas las plataformas para algo que en Linux y Windows ya resuelv el `Info.plist` embebido, y llamarla dos veces falla al enlazar. - Los códigos con que macOS rechaza un certificado de sede no reconocido (ADR-0039) se añaden a los de OpenSSL y Schannel, sin condicional de sistema. -- Sin llavero ni CA local, el navegador no confía en el canal local de un trámite de sede en - macOS sin instalar la CA a mano. +- La CA local (ADR-0005) se confía en el dominio de la persona usuaria, con el diálogo de + contraseña de macOS; Safari, Chrome y Firefox 120 o posterior la leen de ahí, sin perfiles NSS. diff --git a/rfirma-app/src-tauri/src/desktop/adapters/tauri.rs b/rfirma-app/src-tauri/src/desktop/adapters/tauri.rs index b9e2b020..f38e96f0 100644 --- a/rfirma-app/src-tauri/src/desktop/adapters/tauri.rs +++ b/rfirma-app/src-tauri/src/desktop/adapters/tauri.rs @@ -122,6 +122,9 @@ fn brand_of( if crate::site::adapters::is_the_system_root_store(profile) { return StoreBrand::Windows; } + if crate::site::adapters::is_the_macos_user_trust(profile) { + return StoreBrand::Macos; + } brand_of_class(StoreClass::of_nss_owner(owner)) } @@ -148,12 +151,23 @@ fn measured_local_ca_certificate_signal( site: &SiteRoot, restart_firefox_notice: bool, ) -> crate::desktop::domain::status::SignalRow { - let detail = site - .measure_local_ca_trust() - .unwrap_or_default() + local_ca_signal_of( + site.measure_local_ca_trust().unwrap_or_default(), + |profile| site.trust.owner_of(profile), + restart_firefox_notice, + ) +} + +/// La señal del certificado de rFirma con lo medido en cada almacén, rotulado con su marca. +fn local_ca_signal_of( + readings: Vec, + owner_of: impl Fn(&std::path::Path) -> Option, + restart_firefox_notice: bool, +) -> crate::desktop::domain::status::SignalRow { + let detail = readings .into_iter() .map(|reading| StoreDetail { - brand: brand_of(&reading.profile, site.trust.owner_of(&reading.profile)), + brand: brand_of(&reading.profile, owner_of(&reading.profile)), trusted: reading.trusted, }) .collect(); diff --git a/rfirma-app/src-tauri/src/desktop/adapters/tauri/tests.rs b/rfirma-app/src-tauri/src/desktop/adapters/tauri/tests.rs index 8ee30103..4bb686df 100644 --- a/rfirma-app/src-tauri/src/desktop/adapters/tauri/tests.rs +++ b/rfirma-app/src-tauri/src/desktop/adapters/tauri/tests.rs @@ -1,5 +1,7 @@ use super::*; +mod macos_trust; + #[test] fn a_trust_profile_takes_its_brand_from_its_discovered_owner_and_not_from_its_path() { use crate::identity::domain::browsers::{browser_named, BrowserChannel, NssOwner}; @@ -32,6 +34,17 @@ fn a_trust_profile_takes_its_brand_from_its_discovered_owner_and_not_from_its_pa ); } +#[test] +fn the_macos_user_trust_is_the_macos_brand() { + assert_eq!( + brand_of( + std::path::Path::new(crate::site::adapters::MACOS_USER_TRUST), + None + ), + StoreBrand::Macos + ); +} + #[test] fn a_card_and_an_installed_p12_are_each_their_own_brand() { assert_eq!(brand_of_class(StoreClass::Card), StoreBrand::Card); diff --git a/rfirma-app/src-tauri/src/desktop/adapters/tauri/tests/macos_trust.rs b/rfirma-app/src-tauri/src/desktop/adapters/tauri/tests/macos_trust.rs new file mode 100644 index 00000000..7e6fd1df --- /dev/null +++ b/rfirma-app/src-tauri/src/desktop/adapters/tauri/tests/macos_trust.rs @@ -0,0 +1,136 @@ +use std::path::{Path, PathBuf}; +use std::sync::Mutex; + +use super::super::local_ca_signal_of; +use crate::desktop::domain::status::{ + ActionKind, SignalDetail, SignalRow, StoreBrand, StoreDetail, Verdict, +}; +use crate::site::adapters::MACOS_USER_TRUST; +use crate::site::application::tests::InMemoryCaSlots; +use crate::site::application::trust::{measure_local_ca_trust, refresh_local_ca_trust}; +use crate::site::domain::local_ca::{ChannelMark, LocalCa}; +use crate::site::domain::trust::{Moment, TRUSTED_SSL_CA}; +use crate::site::domain::trust_error::{Situation, TrustError}; +use crate::site::ports::{LocalCaSlots, TrustStores}; + +/// La confianza de la persona usuaria de macOS, con su diálogo de contraseña aceptado o cancelado. +struct UserTrust { + password_given: bool, + trusted: Mutex>>, +} + +impl UserTrust { + fn accepting() -> Self { + Self { + password_given: true, + trusted: Mutex::new(Vec::new()), + } + } + + fn cancelling() -> Self { + Self { + password_given: false, + ..Self::accepting() + } + } +} + +impl TrustStores for UserTrust { + fn install(&self, _profile: &Path, der: &[u8], _nickname: &str) -> Result<(), TrustError> { + if !self.password_given { + return Err(TrustError::new( + Situation::TrustNotWritten, + "se ha cancelado el diálogo de contraseña de macOS", + )); + } + crate::lock(&self.trusted).push(der.to_vec()); + Ok(()) + } + + fn trust_of(&self, _profile: &Path, der: &[u8]) -> Result, TrustError> { + Ok(crate::lock(&self.trusted) + .iter() + .any(|trusted| trusted == der) + .then_some(TRUSTED_SSL_CA)) + } + + fn withdraw(&self, _profile: &Path, der: &[u8]) -> Result<(), TrustError> { + crate::lock(&self.trusted).retain(|trusted| trusted != der); + Ok(()) + } + + fn local_cas(&self, _profile: &Path) -> Result>, TrustError> { + Ok(crate::lock(&self.trusted).clone()) + } +} + +fn the_macos_user_trust() -> [PathBuf; 1] { + [PathBuf::from(MACOS_USER_TRUST)] +} + +fn installed_then_measured(slots: &InMemoryCaSlots, trust: &UserTrust) -> SignalRow { + let profiles = the_macos_user_trust(); + let _ = refresh_local_ca_trust( + slots, + &profiles, + trust, + Moment::Startup, + ChannelMark::Native, + ); + let readings = + measure_local_ca_trust(slots, &profiles, trust).expect("las ranuras en memoria se leen"); + local_ca_signal_of(readings, |_| None, false) +} + +fn the_macos_store(trusted: bool) -> Option { + Some(SignalDetail::Trust { + stores: vec![StoreDetail { + brand: StoreBrand::Macos, + trusted, + }], + }) +} + +#[test] +fn installing_with_the_password_given_shows_the_macos_store_trusting_the_local_ca() { + let signal = installed_then_measured(&InMemoryCaSlots::default(), &UserTrust::accepting()); + + assert_eq!(signal.detail, the_macos_store(true)); + assert_eq!(signal.verdict, Verdict::Correct); + assert_eq!(signal.action, None); +} + +#[test] +fn cancelling_the_password_dialog_leaves_the_local_ca_not_installed_and_offers_to_install_it() { + let signal = installed_then_measured(&InMemoryCaSlots::default(), &UserTrust::cancelling()); + + assert_eq!(signal.detail, the_macos_store(false)); + assert_eq!(signal.verdict, Verdict::Incorrect); + assert_eq!( + signal.action.map(|action| action.kind), + Some(ActionKind::Repair) + ); +} + +#[test] +fn an_expired_local_ca_is_replaced_and_the_macos_store_trusts_the_new_one() { + let slots = InMemoryCaSlots::default(); + let trust = UserTrust::accepting(); + let expired = LocalCa::expired_for_test().expect("se fabrica una CA caducada"); + slots + .write_serving(&expired) + .expect("las ranuras en memoria se escriben"); + + let signal = installed_then_measured(&slots, &trust); + + let serving = slots + .serving() + .expect("se lee") + .expect("hay una CA vigente"); + assert_eq!(signal.detail, the_macos_store(true)); + assert_ne!(der_of(&serving), der_of(&expired)); +} + +fn der_of(ca: &LocalCa) -> Vec { + ca.certificate().to_der().expect("sale en DER") +} diff --git a/rfirma-app/src-tauri/src/desktop/domain/status.rs b/rfirma-app/src-tauri/src/desktop/domain/status.rs index 241d0a0a..bed91cbe 100644 --- a/rfirma-app/src-tauri/src/desktop/domain/status.rs +++ b/rfirma-app/src-tauri/src/desktop/domain/status.rs @@ -105,7 +105,7 @@ pub enum StoreBrand { Installed, /// Almacén raíz del usuario de Windows. Windows, - /// Los certificados con clave del llavero de macOS. + /// El Almacén de macOS: los certificados con clave del llavero y la confianza de la persona usuaria. Macos, } diff --git a/rfirma-app/src-tauri/src/site/adapters/macos_trust.rs b/rfirma-app/src-tauri/src/site/adapters/macos_trust.rs new file mode 100644 index 00000000..079cd072 --- /dev/null +++ b/rfirma-app/src-tauri/src/site/adapters/macos_trust.rs @@ -0,0 +1,200 @@ +//! `MacosUserTrust`, la CA local en la confianza del dominio de la persona usuaria de macOS con Security.framework, detrás del puerto `TrustStores`; solo en macOS (ADR-0040). + +use std::path::{Path, PathBuf}; +use std::ptr; + +use core_foundation::array::{CFArray, CFArrayRef}; +use core_foundation::base::{CFType, TCFType}; +use security_framework::certificate::SecCertificate; +use security_framework::item::{ItemClass, ItemSearchOptions, Limit, Reference, SearchResult}; +use security_framework::trust_settings::{Domain, TrustSettings, TrustSettingsForCertificate}; +use security_framework_sys::base::{ + errSecDuplicateItem, errSecItemNotFound, errSecSuccess, SecCertificateRef, +}; +use security_framework_sys::trust_settings::{ + kSecTrustSettingsDomainUser, SecTrustSettingsCopyTrustSettings, SecTrustSettingsDomain, +}; + +use super::{is_the_macos_user_trust, MACOS_USER_TRUST}; +use crate::site::domain::local_ca::{has_the_local_ca_subject, COMMON_NAME}; +use crate::site::domain::trust::TRUSTED_SSL_CA; +use crate::site::domain::trust_error::{Situation, TrustError}; +use crate::site::ports::TrustStores; + +const USER_CANCELED: i32 = -128; +const AUTHORIZATION_CANCELED: i32 = -60006; + +#[link(name = "Security", kind = "framework")] +extern "C" { + fn SecTrustSettingsRemoveTrustSettings( + certificate: SecCertificateRef, + domain: SecTrustSettingsDomain, + ) -> i32; +} + +/// La confianza del dominio de la persona usuaria: cambiarla pide su contraseña con el diálogo de macOS. +#[derive(Clone, Copy, Debug, Default)] +pub struct MacosUserTrust; + +/// El único almacén donde se confía la CA local: Safari y Chrome lo leen, y Firefox 120 o posterior lo importa. +pub fn trust_profiles() -> Vec { + vec![PathBuf::from(MACOS_USER_TRUST)] +} + +impl TrustStores for MacosUserTrust { + fn install( + &self, + profile: &Path, + certificate_der: &[u8], + _nickname: &str, + ) -> Result<(), TrustError> { + let certificate = certificate_in(profile, certificate_der)?; + match certificate.add_to_keychain(None) { + Err(error) if error.code() != errSecDuplicateItem => { + return Err(refused_or_failed( + Situation::TrustNotWritten, + "SecCertificateAddToKeychain", + error.code(), + )) + } + _ => {} + } + TrustSettings::new(Domain::User) + .set_trust_settings_always(&certificate) + .map_err(|error| { + refused_or_failed( + Situation::TrustNotWritten, + "SecTrustSettingsSetTrustSettings", + error.code(), + ) + }) + } + + fn trust_of(&self, profile: &Path, certificate_der: &[u8]) -> Result, TrustError> { + let certificate = certificate_in(profile, certificate_der)?; + match TrustSettings::new(Domain::User).tls_trust_settings_for_certificate(&certificate) { + Err(error) if error.code() == errSecItemNotFound => Ok(None), + Err(error) => Err(refused_or_failed( + Situation::StoreUnreachable, + "SecTrustSettingsCopyTrustSettings", + error.code(), + )), + Ok(Some( + TrustSettingsForCertificate::TrustRoot | TrustSettingsForCertificate::TrustAsRoot, + )) => Ok(Some(TRUSTED_SSL_CA)), + Ok(Some(_)) => Ok(None), + Ok(None) => Ok(always_trusted(&certificate).then_some(TRUSTED_SSL_CA)), + } + } + + fn withdraw(&self, profile: &Path, certificate_der: &[u8]) -> Result<(), TrustError> { + let certificate = certificate_in(profile, certificate_der)?; + let status = unsafe { + SecTrustSettingsRemoveTrustSettings( + certificate.as_concrete_TypeRef(), + kSecTrustSettingsDomainUser, + ) + }; + if status != errSecSuccess && status != errSecItemNotFound { + return Err(refused_or_failed( + Situation::TrustNotWithdrawn, + "SecTrustSettingsRemoveTrustSettings", + status, + )); + } + for copy in local_cas_in_the_keychain()? + .into_iter() + .filter(|copy| copy.to_der() == certificate_der) + { + copy.delete().map_err(|error| { + refused_or_failed(Situation::TrustNotWithdrawn, "SecItemDelete", error.code()) + })?; + } + Ok(()) + } + + fn local_cas(&self, profile: &Path) -> Result>, TrustError> { + the_user_trust(profile)?; + Ok(local_cas_in_the_keychain()? + .iter() + .map(SecCertificate::to_der) + .filter(|der| has_the_local_ca_subject(der)) + .collect()) + } +} + +fn the_user_trust(profile: &Path) -> Result<(), TrustError> { + if is_the_macos_user_trust(profile) { + return Ok(()); + } + Err(TrustError::new( + Situation::StoreUnreachable, + format!("«{}» no es la confianza de macOS", profile.display()), + )) +} + +fn certificate_in(profile: &Path, der: &[u8]) -> Result { + the_user_trust(profile)?; + SecCertificate::from_der(der).map_err(|_| { + TrustError::new( + Situation::StoreUnreachable, + "el certificado de la CA local no es DER", + ) + }) +} + +/// Una configuración de confianza vacía es «confiar siempre», que el agregado por política no cuenta. +fn always_trusted(certificate: &SecCertificate) -> bool { + let mut settings: CFArrayRef = ptr::null(); + let status = unsafe { + SecTrustSettingsCopyTrustSettings( + certificate.as_concrete_TypeRef(), + kSecTrustSettingsDomainUser, + &mut settings, + ) + }; + if status != errSecSuccess || settings.is_null() { + return false; + } + unsafe { CFArray::::wrap_under_create_rule(settings) }.is_empty() +} + +fn local_cas_in_the_keychain() -> Result, TrustError> { + let found = ItemSearchOptions::new() + .class(ItemClass::certificate()) + .label(COMMON_NAME) + .load_refs(true) + .limit(Limit::All) + .search(); + match found { + Err(error) if error.code() == errSecItemNotFound => Ok(Vec::new()), + Err(error) => Err(refused_or_failed( + Situation::StoreUnreachable, + "SecItemCopyMatching", + error.code(), + )), + Ok(results) => Ok(results + .into_iter() + .filter_map(|result| match result { + SearchResult::Ref(Reference::Certificate(certificate)) => Some(certificate), + _ => None, + }) + .collect()), + } +} + +fn refused_or_failed(situation: Situation, step: &str, code: i32) -> TrustError { + if code == USER_CANCELED || code == AUTHORIZATION_CANCELED { + return TrustError::new( + situation, + "se ha cancelado el diálogo de contraseña de macOS", + ); + } + TrustError::new( + situation, + format!("Security.framework ha fallado en {step} (código {code})"), + ) +} + +#[cfg(test)] +mod tests; diff --git a/rfirma-app/src-tauri/src/site/adapters/macos_trust/tests.rs b/rfirma-app/src-tauri/src/site/adapters/macos_trust/tests.rs new file mode 100644 index 00000000..610856e1 --- /dev/null +++ b/rfirma-app/src-tauri/src/site/adapters/macos_trust/tests.rs @@ -0,0 +1,50 @@ +use std::path::PathBuf; + +use super::*; +use crate::site::domain::local_ca::{ChannelMark, LocalCa}; + +fn a_local_ca() -> Vec { + LocalCa::generate(ChannelMark::Native) + .expect("una CA local") + .certificate() + .to_der() + .expect("en DER") +} + +#[test] +fn the_trust_profile_is_the_user_trust() { + assert_eq!(trust_profiles(), vec![PathBuf::from(MACOS_USER_TRUST)]); +} + +#[test] +fn a_path_that_is_not_the_user_trust_is_unreachable_without_asking_for_the_password() { + let error = MacosUserTrust + .install(Path::new("/perfiles/firefox"), &a_local_ca(), "rFirma") + .expect_err("solo existe la confianza de la persona usuaria"); + + assert_eq!(error.situation(), Situation::StoreUnreachable); +} + +#[test] +fn a_local_ca_never_installed_is_not_trusted() { + let reading = MacosUserTrust + .trust_of(Path::new(MACOS_USER_TRUST), &a_local_ca()) + .expect("leer la confianza no pide contraseña"); + + assert_eq!(reading, None); +} + +#[test] +fn a_cancelled_password_dialog_is_told_apart_from_a_failure() { + let cancelled = refused_or_failed(Situation::TrustNotWritten, "paso", AUTHORIZATION_CANCELED); + let failed = refused_or_failed(Situation::TrustNotWritten, "paso", -25300); + + assert_eq!( + cancelled.detail(), + "se ha cancelado el diálogo de contraseña de macOS" + ); + assert_eq!( + failed.detail(), + "Security.framework ha fallado en paso (código -25300)" + ); +} diff --git a/rfirma-app/src-tauri/src/site/adapters/mod.rs b/rfirma-app/src-tauri/src/site/adapters/mod.rs index 1f62dc1f..7d782c6a 100644 --- a/rfirma-app/src-tauri/src/site/adapters/mod.rs +++ b/rfirma-app/src-tauri/src/site/adapters/mod.rs @@ -1,4 +1,4 @@ -//! Adaptadores de `site`, todo lo que toca el mundo con las órdenes y vistas de Tauri, y qué almacenes de confianza usa cada plataforma: NSS en Linux, `CurrentUser\Root` en Windows y el llavero, pendiente, en macOS (ADR-0035, ADR-0040). +//! Adaptadores de `site`, todo lo que toca el mundo con las órdenes y vistas de Tauri, y qué almacenes de confianza usa cada plataforma: NSS en Linux, `CurrentUser\Root` en Windows y la confianza de la persona usuaria en macOS (ADR-0035, ADR-0040). pub mod batch_services; pub mod channel; @@ -14,6 +14,8 @@ pub mod frontier; mod header_probe; pub mod invoice_scheme; pub mod invoice_view; +#[cfg(target_os = "macos")] +pub mod macos_trust; pub mod nss; pub mod readers; pub mod relay; @@ -34,8 +36,6 @@ pub mod xslt; use std::path::Path; use crate::identity::domain::browsers::NssOwner; -#[cfg(target_os = "macos")] -use crate::site::domain::trust_error::{Situation, TrustError}; use crate::site::ports::TrustStores; /// El almacén raíz del usuario de Windows, visto como un perfil más de los almacenes de confianza. @@ -46,6 +46,14 @@ pub fn is_the_system_root_store(profile: &Path) -> bool { profile.as_os_str() == SYSTEM_ROOT_STORE } +/// La confianza del dominio de la persona usuaria de macOS, vista como un perfil más. +pub const MACOS_USER_TRUST: &str = "trust-settings:user"; + +/// Si el perfil es la confianza de la persona usuaria de macOS y no un perfil NSS. +pub fn is_the_macos_user_trust(profile: &Path) -> bool { + profile.as_os_str() == MACOS_USER_TRUST +} + /// Si la CA local vigente es de confianza en cada perfil, en el orden dado; la medición del panel de estado. pub fn local_ca_trusted_in_each( store: &tls::LocalCaStore, @@ -106,7 +114,7 @@ pub fn trust_profile_owners() -> Vec<(std::path::PathBuf, NssOwner)> { Vec::new() } -/// Ningún perfil NSS de navegador: en macOS, la confianza vivirá en el llavero. +/// Ningún perfil NSS de navegador: en macOS, la confianza vive en la de la persona usuaria. #[cfg(target_os = "macos")] pub fn trust_profile_owners() -> Vec<(std::path::PathBuf, NssOwner)> { Vec::new() @@ -116,56 +124,14 @@ pub fn trust_profile_owners() -> Vec<(std::path::PathBuf, NssOwner)> { #[cfg(windows)] pub use windows_root::trust_profiles; -/// Los almacenes de confianza de esta plataforma. -#[cfg(target_os = "macos")] -pub fn desktop_trust_stores() -> Box { - Box::new(PendingMacosKeychainTrust) -} - -/// Ningún almacén de confianza: sin llavero de macOS no hay dónde instalar la CA local. -#[cfg(target_os = "macos")] -pub fn trust_profiles() -> Vec { - Vec::new() -} - -/// La confianza en la CA local sobre el llavero de macOS, que aún no existe: toda escritura falla. -#[cfg(target_os = "macos")] -pub struct PendingMacosKeychainTrust; - +/// Los almacenes de confianza de esta persona. #[cfg(target_os = "macos")] -impl TrustStores for PendingMacosKeychainTrust { - fn install( - &self, - _profile: &Path, - _certificate_der: &[u8], - _nickname: &str, - ) -> Result<(), TrustError> { - Err(pending_macos_keychain_trust(Situation::TrustNotWritten)) - } - - fn trust_of( - &self, - _profile: &Path, - _certificate_der: &[u8], - ) -> Result, TrustError> { - Ok(None) - } - - fn withdraw(&self, _profile: &Path, _certificate_der: &[u8]) -> Result<(), TrustError> { - Err(pending_macos_keychain_trust(Situation::TrustNotWithdrawn)) - } - - fn local_cas(&self, _profile: &Path) -> Result>, TrustError> { - Ok(Vec::new()) - } -} +pub use macos_trust::trust_profiles; +/// Los almacenes de confianza de esta plataforma. #[cfg(target_os = "macos")] -fn pending_macos_keychain_trust(situation: Situation) -> TrustError { - TrustError::new( - situation, - "el llavero de macOS aún no está disponible para la CA local", - ) +pub fn desktop_trust_stores() -> Box { + Box::new(macos_trust::MacosUserTrust) } const CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); diff --git a/rfirma-app/src-tauri/src/site/mod.rs b/rfirma-app/src-tauri/src/site/mod.rs index 4ef5f8c9..33768afd 100644 --- a/rfirma-app/src-tauri/src/site/mod.rs +++ b/rfirma-app/src-tauri/src/site/mod.rs @@ -16,7 +16,8 @@ use application::errand::ErrandDesk; pub use application::errand::LiveErrand; use application::site::CodecTable; use application::startup::{HeldChannel, LocalCaTrust}; -use application::trust::{ProfileTrust, TrustOutcome, WithdrawOutcome}; +pub use application::trust::ProfileTrust; +use application::trust::{TrustOutcome, WithdrawOutcome}; use domain::tls_error::TlsError; use domain::trust::Moment;