From a202faf7a4bf34e0c787f14dd4fdd945d68fee27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20G=C3=B3mez=20Bachiller?= Date: Mon, 19 Oct 2026 15:16:00 +0200 Subject: [PATCH 01/22] =?UTF-8?q?docs(macos):=20la=20regla=20=C2=ABen=20ma?= =?UTF-8?q?cOS,=20la=20API=20de=20macOS,=20salvo=20el=20DNIe=C2=BB=20y=20e?= =?UTF-8?q?l=20Almac=C3=A9n=20de=20macOS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements #2001: El ADR-0040 recoge la regla «en macOS, la API de macOS, salvo el DNIe» - ADR-0040 reescrito en su sitio; glosario con el Almacén de macOS y el nuevo orden --- CONTEXT.md | 10 +++- ...n-apple-silicon-como-tercera-plataforma.md | 46 ++++++++++++++++++- 2 files changed, 52 insertions(+), 4 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index d8cff3b2d..2ca35c173 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -205,8 +205,8 @@ y se abren por separado, así que uno que no cargue no deja sin certificados a los demás. El mismo certificado —mismo emisor y número de serie— en varios almacenes se muestra una vez, con la lista de almacenes donde está; se firma con la copia recordada o, si no la hay, con la del primer almacén por este orden: -tarjeta, Almacén de Windows, Almacén de rFirma, NSS del sistema, familia Firefox, -familia Chromium; dentro de cada familia, nativo, snap y flatpak. +tarjeta, Almacén de Windows o Almacén de macOS, Almacén de rFirma, NSS del sistema, +familia Firefox, familia Chromium; dentro de cada familia, nativo, snap y flatpak. No hay **almacén activo**: la persona elige un certificado, nunca un almacén. _Avoid_: keystore, repositorio de certificados, llavero, «el conjunto de certificados de la máquina», almacén activo @@ -217,6 +217,12 @@ una tarjeta criptográfica que Windows copia ahí no son suyos, sino de la tarjeta. Windows pide su contraseña o su PIN con su propio diálogo. _Avoid_: almacén del sistema, almacén CNG, almacén MY +**Almacén de macOS** (Rust `StoreClass::MacOs`): +Los certificados con clave privada del llavero de la persona usuaria en macOS. Los de una +tarjeta que macOS lee con su controlador son de la tarjeta, no suyos. macOS pide su contraseña o +su PIN con su propio diálogo. «Llavero» sigue reservado para lo que guarda secretos. +_Avoid_: almacén del sistema, llavero, Keychain + **Almacén NSS** (Rust `StoreClass::Nssdb`): El almacén de un navegador —el perfil de Firefox, la base de datos de Chrome—, que es a la vez de donde salen certificados para firmar y **donde la aplicación 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 fac485697..dffbe75e0 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 @@ -26,6 +26,30 @@ CAdES y XAdES pasan por el puente y el validador de AutoFirma da por válidas la falta para firmar con una tarjeta es el diálogo del PIN; para el almacén del sistema, un adaptador del llavero de macOS como el de CNG en Windows. +## En macOS, la API de macOS siempre que se pueda, salvo el DNIe + +Como en Windows (ADR-0035), para certificados, PIN, tarjetas y confianza rFirma usa lo que macOS +ofrece (Security.framework, CryptoTokenKit a través del llavero, LaunchServices): es lo que la +persona ya conoce y evita mantener un camino propio. De la regla salen: + +- **Los certificados con clave privada del llavero** salen como «Almacén de macOS» y se firman por + la API del sistema: Rust calcula el resumen y macOS lo firma (ADR-0001). La contraseña o el PIN + los pide macOS con su diálogo. +- **Las tarjetas que macOS ya sabe leer** (PIV o con controlador del sistema) entran por ese mismo + camino y se tratan como tarjeta. +- **«Instalar certificado» importa el `.p12` al llavero de inicio de sesión** (ADR-0034); en macOS + no hay Almacén de rFirma. +- **La CA local va a la confianza de la persona usuaria** (ADR-0005), con el diálogo de contraseña + del sistema, y `afirma://` se gestiona por LaunchServices. + +**La excepción es el DNIe**, que va por PKCS#11: macOS no trae controlador del sistema para él y +la DGP solo publica un módulo PKCS#11 para Mac. Solo para él, rFirma carga el módulo de OpenSC o el +de la DGP y pide el PIN con un diálogo propio nativo, que lee el estado del PIN antes de pedirlo +(ADR-0047). Nadie debe meter PKCS#11 para todo ni quitar la excepción sin conocer este motivo. + +El orden de almacenes pasa a ser: tarjeta, Almacén de macOS (o de Windows), Almacén de rFirma, +NSS del sistema, familia Firefox, familia Chromium. + ## `afirma://` llega como evento, no como argumento LaunchServices no pasa la URL en la línea de órdenes: abre la aplicación y se la entrega con un @@ -64,6 +88,24 @@ equipo, porque todo Mac con Apple Silicon puede actualizar a ella. El `.dmg` pasa por la misma puerta del contenido que los demás paquetes (`packaging/verifica-contenido.sh`): una sola `librfirma_crypto.dylib` y ningún auxiliar de AWT. +## Sin cuenta de Apple Developer: tap de Homebrew y actualizador propio + +No hay cuenta de Apple Developer: el paquete no se firma con Developer ID ni se notariza, y +mantiene la firma ad hoc y el *hardened runtime* apagado, que además deja cargar módulos PKCS#11 +de terceros. Con firma ad hoc, cada versión es otra identidad de código para macOS, y el +«Permitir siempre» del llavero puede volver a pedirse tras cada actualización. + +- **Distribución:** un tap de Homebrew propio del proyecto, con un cask que instala el `.app` + publicado en la Release; se actualiza en cada versión. Las instrucciones de instalación, + incluido `xattr -c /Applications/rFirma.app` para abrir la aplicación ante el aviso de + Gatekeeper la primera vez, van en la documentación del tap y en la de rFirma. +- **Actualización:** rFirma se actualiza sola con el canal de Windows: la entrega publica el + paquete de actualización firmado con minisign y su entrada en `latest.json`. El cask declara + `auto_updates: true`, así que `brew upgrade` no lo toca, y rFirma no trata una instalación por + Homebrew como gestionada por paquete. La descarga del actualizador no lleva la marca de + cuarentena, así que el aviso de Gatekeeper solo sale en la primera instalación; está por + verificar con la firma ad hoc. + ## GraalVM: la 25.0.2 en el CI, la 25.0.1 en un Mac Intel El CI construye con la 25.0.2 fijada (ADR-0035) en `macos-14`, que es Apple Silicon. La 25.0.2 no @@ -82,8 +124,8 @@ GraalVM se busca en `GRAALVM_HOME`, después en `JAVA_HOME` y por último con 25.0.1 en un runner Intel. Los Mac Intel son una plataforma que Apple ya no vende. **Notarizar con un Developer ID**: quita el aviso de Gatekeeper, pero exige una cuenta de pago de -Apple y secretos de firma en el CI. Queda para cuando haya quien la mantenga; el instalador de -Windows tampoco está firmado. +Apple y secretos de firma en el CI. No hay cuenta de Apple Developer; el instalador de Windows tampoco +está firmado. **Instalar la `.dylib` en `Contents/Resources`**: funciona igual para `dlopen`, pero `Frameworks` es donde macOS espera el código cargable y donde `codesign` lo firma al sellar el From 6ac3fde40d17a55593b5077059e852d7ead16e0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20G=C3=B3mez=20Bachiller?= Date: Mon, 19 Oct 2026 15:24:27 +0200 Subject: [PATCH 02/22] =?UTF-8?q?feat(macos):=20el=20PIN=20de=20una=20tarj?= =?UTF-8?q?eta=20por=20PKCS#11=20se=20pide=20con=20un=20di=C3=A1logo=20nat?= =?UTF-8?q?ivo=20de=20macOS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements #2011: El PIN de una tarjeta por PKCS#11 se pide con un diálogo nativo de macOS - Alerta de AppKit con NSSecureTextField: hoja sobre la ventana de origen, modal de la app sin ella - El puerto se prueba con una alerta doble tras la costura SecureAlert; cerrar es cancelar - Sale PendingMacosPinDialog y su fila de la tabla del ADR-0040 --- ...n-apple-silicon-como-tercera-plataforma.md | 12 +- packaging/flatpak/sources.lock | 2 +- rfirma-app/src-tauri/Cargo.lock | 4 + rfirma-app/src-tauri/Cargo.toml | 4 + .../src/signing/adapters/gtk_prompter.rs | 34 +-- .../signing/adapters/gtk_prompter/appkit.rs | 202 ++++++++++++++++++ .../adapters/gtk_prompter/appkit/tests.rs | 126 +++++++++++ 7 files changed, 347 insertions(+), 37 deletions(-) create mode 100644 rfirma-app/src-tauri/src/signing/adapters/gtk_prompter/appkit.rs create mode 100644 rfirma-app/src-tauri/src/signing/adapters/gtk_prompter/appkit/tests.rs 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 fac485697..a2a65e22c 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 @@ -15,16 +15,16 @@ como fallaría Linux sin el servicio y dice en su nombre qué falta. | Token PKCS#11 y sus almacenes | El mismo `RealToken` de Linux; solo encuentra módulo si `RFIRMA_PKCS11_MODULE` lo nombra. | | `mlock` del secreto | `mlock`; `MADV_DONTDUMP` es solo de Linux. | | Cerrojos (Firefox, carpeta de paso) | Los de Unix, sin cambios. | -| Diálogo GTK del PIN | `PendingMacosPinDialog`: falla con un mensaje en castellano. | | 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, -CAdES y XAdES pasan por el puente y el validador de AutoFirma da por válidas las PAdES. Lo que -falta para firmar con una tarjeta es el diálogo del PIN; para el almacén del sistema, un adaptador -del llavero de macOS como el de CNG en Windows. +CAdES y XAdES pasan por el puente y el validador de AutoFirma da por válidas las PAdES. El PIN +de una tarjeta por PKCS#11 lo pide una alerta de AppKit con campo seguro, modal sobre la ventana +que lo pidió (ADR-0047); para el almacén del sistema falta un adaptador del llavero de macOS como +el de CNG en Windows. ## `afirma://` llega como evento, no como argumento @@ -108,5 +108,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 diálogo del PIN, llavero ni CA local, un trámite de sede en macOS no puede todavía firmar - con tarjeta ni el navegador confía en el canal local sin instalar la CA a mano. +- 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. diff --git a/packaging/flatpak/sources.lock b/packaging/flatpak/sources.lock index 2f5bc089e..fe00eeddc 100644 --- a/packaging/flatpak/sources.lock +++ b/packaging/flatpak/sources.lock @@ -1 +1 @@ -dd9acf8e7da3e17ce767cd7430561d6277abce093f7003f29ca5fab6e360134b rfirma-app/src-tauri/Cargo.lock +0c70a1048823f3a8f468a30da28e9695b6d316b64c4f03e900e3af9a463d8138 rfirma-app/src-tauri/Cargo.lock diff --git a/rfirma-app/src-tauri/Cargo.lock b/rfirma-app/src-tauri/Cargo.lock index e0c779109..d5f4e03ed 100644 --- a/rfirma-app/src-tauri/Cargo.lock +++ b/rfirma-app/src-tauri/Cargo.lock @@ -4176,6 +4176,7 @@ name = "rfirma" version = "0.15.0" dependencies = [ "base64 0.22.1", + "block2", "chrono", "cipher 0.5.2", "cryptoki", @@ -4194,6 +4195,9 @@ dependencies = [ "libc", "libloading 0.8.9", "native-tls", + "objc2", + "objc2-app-kit", + "objc2-foundation", "oo7", "openssl", "pcsc", diff --git a/rfirma-app/src-tauri/Cargo.toml b/rfirma-app/src-tauri/Cargo.toml index 9e5b0f5f0..c4a65cc14 100644 --- a/rfirma-app/src-tauri/Cargo.toml +++ b/rfirma-app/src-tauri/Cargo.toml @@ -226,6 +226,10 @@ openssl = { version = "0.10", features = ["vendored"] } # El saludo TLS del canal local: Secure Transport tampoco importa la clave de # curva elíptica de la CA local en PKCS#8 (ADR-0036). tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "tls12"] } +block2 = "0.6" +objc2 = "0.6" +objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "block2", "NSAlert", "NSApplication", "NSButton", "NSControl", "NSResponder", "NSSecureTextField", "NSTextField", "NSView", "NSWindow"] } +objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSGeometry", "NSString"] } [target.'cfg(not(windows))'.dev-dependencies] # La tarjeta falsa de las pruebas: no se compila en Windows y nunca llega a un paquete (ADR-0004). diff --git a/rfirma-app/src-tauri/src/signing/adapters/gtk_prompter.rs b/rfirma-app/src-tauri/src/signing/adapters/gtk_prompter.rs index 1545eb2ee..2b18c9f4a 100644 --- a/rfirma-app/src-tauri/src/signing/adapters/gtk_prompter.rs +++ b/rfirma-app/src-tauri/src/signing/adapters/gtk_prompter.rs @@ -1,4 +1,4 @@ -//! Adaptadores del puerto `SecretPrompter`: diálogo nativo GTK3, sus pendientes de Windows y macOS y los de pruebas (ADR-0001, ADR-0014, ADR-0035, ADR-0040). +//! Adaptadores del puerto `SecretPrompter`: diálogo nativo GTK3, alerta de AppKit, el pendiente de Windows y los de pruebas (ADR-0001, ADR-0014, ADR-0035, ADR-0040). use std::sync::Mutex; #[cfg(target_os = "linux")] @@ -11,6 +11,8 @@ use crate::identity::domain::secret::PinWarning; use crate::identity::ports::OriginWindow; use crate::identity::ports::{SecretPromptError, SecretPromptRequest, SecretPrompter}; +#[cfg(target_os = "macos")] +mod appkit; mod texts; pub use texts::{localize, DialogI18n}; @@ -292,34 +294,6 @@ impl SecretPrompter for PendingWindowsPinDialog { } } -/// El diálogo nativo del PIN en macOS, que aún no existe: toda petición falla. -#[cfg(target_os = "macos")] -#[derive(Clone, Default)] -pub struct PendingMacosPinDialog; - -#[cfg(target_os = "macos")] -impl PendingMacosPinDialog { - /// Crea el adaptador pendiente. - pub fn new() -> Self { - Self - } - - /// No vincula nada: el diálogo de macOS no tiene ventana a la que atarse todavía. - pub fn attach(&self, _app: tauri::AppHandle) {} -} - -#[cfg(target_os = "macos")] -impl SecretPrompter for PendingMacosPinDialog { - fn prompt_secret( - &self, - _request: &SecretPromptRequest, - ) -> Result { - Err(SecretPromptError::Failed( - "la solicitud del PIN aún no está disponible en macOS".to_owned(), - )) - } -} - /// El diálogo nativo del PIN de esta plataforma. #[cfg(target_os = "linux")] pub type NativePinDialog = GtkSecretPrompter; @@ -328,7 +302,7 @@ pub type NativePinDialog = GtkSecretPrompter; pub type NativePinDialog = PendingWindowsPinDialog; /// El diálogo nativo del PIN de esta plataforma. #[cfg(target_os = "macos")] -pub type NativePinDialog = PendingMacosPinDialog; +pub type NativePinDialog = appkit::AppKitPinDialog; /// Adaptador de pruebas que suministra un secreto fijo preconfigurado. pub struct PreconfiguredSecretPrompter { diff --git a/rfirma-app/src-tauri/src/signing/adapters/gtk_prompter/appkit.rs b/rfirma-app/src-tauri/src/signing/adapters/gtk_prompter/appkit.rs new file mode 100644 index 000000000..e9f3671be --- /dev/null +++ b/rfirma-app/src-tauri/src/signing/adapters/gtk_prompter/appkit.rs @@ -0,0 +1,202 @@ +//! El diálogo nativo del PIN en macOS: una alerta de AppKit con campo seguro, modal sobre la ventana que lo pidió (ADR-0040, ADR-0047). + +use std::ffi::CStr; +use std::sync::{mpsc, Arc, OnceLock}; + +use block2::RcBlock; +use objc2::rc::{autoreleasepool, Retained}; +use objc2::MainThreadMarker; +use objc2_app_kit::{ + NSAlert, NSAlertFirstButtonReturn, NSModalResponse, NSSecureTextField, NSWindow, +}; +use objc2_foundation::{NSPoint, NSRect, NSSize, NSString}; + +use super::localize; +use crate::identity::domain::protected_secret::ProtectedSecret; +use crate::identity::ports::{ + OriginWindow, SecretPromptError, SecretPromptRequest, SecretPrompter, +}; + +/// Lo que se lee en la alerta del PIN. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AlertTexts { + pub message: String, + pub informative: String, + pub accept: &'static str, + pub cancel: &'static str, +} + +/// Cómo se cerró la alerta. +pub enum AlertAnswer { + Typed(ProtectedSecret), + Dismissed, + Unavailable(String), +} + +/// La ventana nativa que pide el secreto. +pub trait SecureAlert { + /// Muestra la alerta, modal sobre `origin` si sigue montada, y espera a que se cierre. + fn ask(&self, texts: AlertTexts, origin: Option) -> AlertAnswer; +} + +/// Adaptador del puerto `SecretPrompter` sobre una alerta segura. +pub struct SecureAlertPrompter { + alert: A, +} + +impl SecureAlertPrompter { + /// Pide los secretos con `alert`. + pub fn over(alert: A) -> Self { + Self { alert } + } +} + +impl SecretPrompter for SecureAlertPrompter { + fn prompt_secret( + &self, + request: &SecretPromptRequest, + ) -> Result { + match self.alert.ask(texts_of(request), request.origin_window) { + AlertAnswer::Typed(secret) => Ok(secret), + AlertAnswer::Dismissed => Err(SecretPromptError::Cancelled), + AlertAnswer::Unavailable(reason) => Err(SecretPromptError::Failed(reason)), + } + } +} + +fn texts_of(request: &SecretPromptRequest) -> AlertTexts { + let i18n = localize(request); + let incorrect = request.incorrect_secret.then_some(i18n.incorrect_secret); + let informative = [ + i18n.holder_name, + i18n.id_number, + incorrect, + i18n.pin_warning, + ] + .into_iter() + .flatten() + .collect::>() + .join("\n"); + AlertTexts { + message: i18n.title, + informative, + accept: i18n.accept, + cancel: i18n.cancel, + } +} + +/// El diálogo nativo del PIN en macOS. +pub type AppKitPinDialog = SecureAlertPrompter; + +impl AppKitPinDialog { + /// Crea el diálogo pendiente de vincular al manejador de Tauri. + pub fn new() -> Self { + Self::over(AppKitAlert::default()) + } + + /// Vincula el manejador de la aplicación, tras montarse la ventana (ADR-0024). + pub fn attach(&self, app: tauri::AppHandle) { + let _ = self.alert.app.set(app); + } +} + +/// La alerta de AppKit, que corre en el hilo principal: hoja sobre la ventana de origen o, sin ella, modal de la aplicación. +#[derive(Clone, Default)] +pub struct AppKitAlert { + app: Arc>, +} + +impl AppKitAlert { + fn window_to_be_modal_over(&self, origin: Option) -> Option> { + use tauri::Manager as _; + + let window = self.app.get()?.get_webview_window(origin?.label())?; + let pointer = window.ns_window().ok()?.cast::(); + // SAFETY: Tauri entrega el `NSWindow` vivo de la ventana montada. + unsafe { Retained::retain(pointer) } + } +} + +impl SecureAlert for AppKitAlert { + fn ask(&self, texts: AlertTexts, origin: Option) -> AlertAnswer { + if let Some(mtm) = MainThreadMarker::new() { + return run_app_modal(&alert_with(mtm, &texts)); + } + let Some(app) = self.app.get() else { + return AlertAnswer::Unavailable("la ventana de rFirma aún no está montada".to_owned()); + }; + let (sender, receiver) = mpsc::channel(); + let this = self.clone(); + let scheduled = app.run_on_main_thread(move || { + let Some(mtm) = MainThreadMarker::new() else { + return; + }; + let parts = alert_with(mtm, &texts); + match this.window_to_be_modal_over(origin) { + Some(parent) => begin_sheet(parts, &parent, sender), + None => { + let _ = sender.send(run_app_modal(&parts)); + } + } + }); + if let Err(error) = scheduled { + return AlertAnswer::Unavailable(error.to_string()); + } + receiver.recv().unwrap_or(AlertAnswer::Dismissed) + } +} + +struct AlertParts { + alert: Retained, + field: Retained, +} + +fn alert_with(mtm: MainThreadMarker, texts: &AlertTexts) -> AlertParts { + let alert = NSAlert::new(mtm); + alert.setMessageText(&NSString::from_str(&texts.message)); + alert.setInformativeText(&NSString::from_str(&texts.informative)); + alert.addButtonWithTitle(&NSString::from_str(texts.accept)); + let cancel = alert.addButtonWithTitle(&NSString::from_str(texts.cancel)); + cancel.setKeyEquivalent(&NSString::from_str("\u{1b}")); + + let frame = NSRect::new(NSPoint::new(0.0, 0.0), NSSize::new(240.0, 24.0)); + let field = NSSecureTextField::initWithFrame(mtm.alloc(), frame); + alert.setAccessoryView(Some(&field)); + alert.layout(); + alert.window().setInitialFirstResponder(Some(&field)); + AlertParts { alert, field } +} + +fn run_app_modal(parts: &AlertParts) -> AlertAnswer { + answer_of(parts.alert.runModal(), &parts.field) +} + +fn begin_sheet(parts: AlertParts, parent: &NSWindow, sender: mpsc::Sender) { + let AlertParts { alert, field } = parts; + let handler = RcBlock::new(move |response: NSModalResponse| { + let _ = sender.send(answer_of(response, &field)); + }); + alert.beginSheetModalForWindow_completionHandler(parent, Some(&handler)); +} + +fn answer_of(response: NSModalResponse, field: &NSSecureTextField) -> AlertAnswer { + let typed = emptied(field); + if response == NSAlertFirstButtonReturn { + AlertAnswer::Typed(typed) + } else { + AlertAnswer::Dismissed + } +} + +fn emptied(field: &NSSecureTextField) -> ProtectedSecret { + let secret = autoreleasepool(|_| { + let typed = field.stringValue(); + // SAFETY: `UTF8String` vive mientras `typed` y el pool que lo envuelve. + ProtectedSecret::new(unsafe { CStr::from_ptr(typed.UTF8String()) }.to_bytes()) + }); + field.setStringValue(&NSString::from_str("")); + secret +} + +#[cfg(test)] +mod tests; diff --git a/rfirma-app/src-tauri/src/signing/adapters/gtk_prompter/appkit/tests.rs b/rfirma-app/src-tauri/src/signing/adapters/gtk_prompter/appkit/tests.rs new file mode 100644 index 000000000..512064cf9 --- /dev/null +++ b/rfirma-app/src-tauri/src/signing/adapters/gtk_prompter/appkit/tests.rs @@ -0,0 +1,126 @@ +use std::sync::Mutex; + +use super::{AlertAnswer, AlertTexts, SecureAlert, SecureAlertPrompter}; +use crate::identity::domain::holder::PromptedHolder; +use crate::identity::domain::protected_secret::ProtectedSecret; +use crate::identity::domain::secret::{PinWarning, SecretName}; +use crate::identity::ports::{ + OriginWindow, SecretPromptError, SecretPromptRequest, SecretPrompter, +}; +use crate::signing::domain::Language; + +struct ScriptedAlert { + answer: Mutex>, + shown: Mutex)>>, +} + +impl ScriptedAlert { + fn answering(answer: AlertAnswer) -> Self { + Self { + answer: Mutex::new(Some(answer)), + shown: Mutex::new(Vec::new()), + } + } + + fn shown(&self) -> (AlertTexts, Option) { + self.shown + .lock() + .unwrap() + .pop() + .expect("deberia haberse mostrado") + } +} + +impl SecureAlert for ScriptedAlert { + fn ask(&self, texts: AlertTexts, origin: Option) -> AlertAnswer { + self.shown.lock().unwrap().push((texts, origin)); + self.answer + .lock() + .unwrap() + .take() + .expect("una sola respuesta") + } +} + +fn a_pin_request() -> SecretPromptRequest { + SecretPromptRequest { + secret: SecretName::Pin, + holder: Some(PromptedHolder { + name: "JUAN PEREZ".to_string(), + id_number: "IDCES-12345678Z".to_string(), + }), + language: Language::Spanish, + incorrect_secret: false, + pin_warning: PinWarning::Quiet, + origin_window: Some(OriginWindow::Site), + } +} + +#[test] +fn a_dismissed_alert_is_a_cancellation_not_a_failure() { + let prompter = SecureAlertPrompter::over(ScriptedAlert::answering(AlertAnswer::Dismissed)); + + let answer = prompter.prompt_secret(&a_pin_request()); + + assert_eq!(answer, Err(SecretPromptError::Cancelled)); +} + +#[test] +fn the_typed_pin_comes_back_as_the_protected_secret() { + let typed = ProtectedSecret::from_str("1234"); + let prompter = SecureAlertPrompter::over(ScriptedAlert::answering(AlertAnswer::Typed(typed))); + + let secret = prompter + .prompt_secret(&a_pin_request()) + .expect("PIN escrito"); + + assert_eq!(secret.as_str(), Ok("1234")); +} + +#[test] +fn an_alert_that_cannot_open_fails_with_its_reason() { + let prompter = SecureAlertPrompter::over(ScriptedAlert::answering(AlertAnswer::Unavailable( + "sin ventana".to_string(), + ))); + + let answer = prompter.prompt_secret(&a_pin_request()); + + assert_eq!( + answer, + Err(SecretPromptError::Failed("sin ventana".to_string())) + ); +} + +#[test] +fn the_alert_asks_for_the_pin_of_the_holder_over_the_window_that_asked() { + let alert = ScriptedAlert::answering(AlertAnswer::Dismissed); + let prompter = SecureAlertPrompter::over(alert); + + let _ = prompter.prompt_secret(&a_pin_request()); + let (texts, origin) = prompter.alert.shown(); + + assert_eq!(texts.message, "Introduce el PIN"); + assert_eq!(texts.informative, "JUAN PEREZ\nIDCES-12345678Z"); + assert_eq!(texts.accept, "Aceptar"); + assert_eq!(texts.cancel, "Cancelar"); + assert_eq!(origin, Some(OriginWindow::Site)); +} + +#[test] +fn a_card_on_its_final_try_is_warned_and_a_wrong_pin_is_said_again() { + let alert = ScriptedAlert::answering(AlertAnswer::Dismissed); + let prompter = SecureAlertPrompter::over(alert); + let mut request = a_pin_request(); + request.holder = None; + request.incorrect_secret = true; + request.pin_warning = PinWarning::FinalTry; + + let _ = prompter.prompt_secret(&request); + let (texts, _) = prompter.alert.shown(); + + assert_eq!( + texts.informative, + "PIN incorrecto. Vuelve a intentarlo.\n\ + Último intento: si el PIN no es correcto, la tarjeta se bloqueará." + ); +} From 908de121400160b36e56b884521aa1a14025dee0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20G=C3=B3mez=20Bachiller?= Date: Mon, 19 Oct 2026 15:59:52 +0200 Subject: [PATCH 03/22] =?UTF-8?q?feat(identity):=20el=20Almac=C3=A9n=20de?= =?UTF-8?q?=20macOS=20lista=20los=20certificados=20del=20llavero?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements #2004: El Almacén de macOS lista los certificados del llavero - `MacosToken` lista las identidades del llavero por Security.framework sin pedir nada y apunta en `key_provider` el token del sistema de la clave, si lo hay. - `StoreClass::Macos` comparte el puesto del Almacén de Windows en el orden de copias. - Firmar con el llavero queda para #2005: por ahora el adaptador lo rechaza. --- packaging/flatpak/sources.lock | 2 +- rfirma-app/src-tauri/Cargo.lock | 3 + rfirma-app/src-tauri/Cargo.toml | 4 + .../command-line/listaliases.schema.json | 4 +- .../src-tauri/src/desktop/adapters/views.rs | 1 + .../application/command_line/certtui.rs | 1 + .../application/command_line/json_output.rs | 1 + .../src/desktop/application/status.rs | 1 + .../src-tauri/src/desktop/domain/status.rs | 2 + rfirma-app/src-tauri/src/identity/AGENTS.md | 2 +- .../src/identity/adapters/macos_store.rs | 162 ++++++++++ .../identity/adapters/macos_store/keychain.rs | 136 +++++++++ .../identity/adapters/macos_store/tests.rs | 285 ++++++++++++++++++ .../src-tauri/src/identity/adapters/mod.rs | 12 +- .../src/identity/adapters/pkcs11/mod.rs | 2 +- .../src-tauri/src/identity/adapters/views.rs | 2 + .../certificates/tests/macos_store.rs | 123 ++++++++ .../application/certificates/tests/mod.rs | 1 + .../src-tauri/src/identity/domain/secret.rs | 5 +- .../src-tauri/src/identity/domain/store.rs | 18 +- rfirma-app/src-tauri/tests/contract.snapshot | 2 + 21 files changed, 758 insertions(+), 11 deletions(-) create mode 100644 rfirma-app/src-tauri/src/identity/adapters/macos_store.rs create mode 100644 rfirma-app/src-tauri/src/identity/adapters/macos_store/keychain.rs create mode 100644 rfirma-app/src-tauri/src/identity/adapters/macos_store/tests.rs create mode 100644 rfirma-app/src-tauri/src/identity/application/certificates/tests/macos_store.rs diff --git a/packaging/flatpak/sources.lock b/packaging/flatpak/sources.lock index 2f5bc089e..27fa7d9cd 100644 --- a/packaging/flatpak/sources.lock +++ b/packaging/flatpak/sources.lock @@ -1 +1 @@ -dd9acf8e7da3e17ce767cd7430561d6277abce093f7003f29ca5fab6e360134b rfirma-app/src-tauri/Cargo.lock +7fabb2a39e5f78ee53261322ed25f35bb976da9bf0e77779acb1c968fd6b2fcc rfirma-app/src-tauri/Cargo.lock diff --git a/rfirma-app/src-tauri/Cargo.lock b/rfirma-app/src-tauri/Cargo.lock index e0c779109..f6f3a47a3 100644 --- a/rfirma-app/src-tauri/Cargo.lock +++ b/rfirma-app/src-tauri/Cargo.lock @@ -4178,6 +4178,7 @@ dependencies = [ "base64 0.22.1", "chrono", "cipher 0.5.2", + "core-foundation", "cryptoki", "des", "ecb", @@ -4201,6 +4202,8 @@ dependencies = [ "ratatui", "reqwest", "rsa", + "security-framework", + "security-framework-sys", "serde", "serde_json", "sha2", diff --git a/rfirma-app/src-tauri/Cargo.toml b/rfirma-app/src-tauri/Cargo.toml index 9e5b0f5f0..6376bb3fd 100644 --- a/rfirma-app/src-tauri/Cargo.toml +++ b/rfirma-app/src-tauri/Cargo.toml @@ -226,6 +226,10 @@ openssl = { version = "0.10", features = ["vendored"] } # El saludo TLS del canal local: Secure Transport tampoco importa la clave de # curva elíptica de la CA local en PKCS#8 (ADR-0036). tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "tls12"] } +# El Almacén de macOS: las identidades del llavero por Security.framework (ADR-0040). +security-framework = "3.7" +security-framework-sys = "2.17" +core-foundation = "0.10" [target.'cfg(not(windows))'.dev-dependencies] # La tarjeta falsa de las pruebas: no se compila en Windows y nunca llega a un paquete (ADR-0004). diff --git a/rfirma-app/src-tauri/schemas/command-line/listaliases.schema.json b/rfirma-app/src-tauri/schemas/command-line/listaliases.schema.json index cde804cba..155a58b6a 100644 --- a/rfirma-app/src-tauri/schemas/command-line/listaliases.schema.json +++ b/rfirma-app/src-tauri/schemas/command-line/listaliases.schema.json @@ -16,9 +16,9 @@ "type": "string" }, "store": { - "description": "El almacén, con el nombre que acepta --store: pkcs11:, mozilla para la familia NSS, o windows.", + "description": "El almacén, con el nombre que acepta --store: pkcs11:, mozilla para la familia NSS, windows o apple.", "type": "string", - "pattern": "^(pkcs11:.+|mozilla|windows)$" + "pattern": "^(pkcs11:.+|mozilla|windows|apple)$" } }, "required": ["alias", "store"], diff --git a/rfirma-app/src-tauri/src/desktop/adapters/views.rs b/rfirma-app/src-tauri/src/desktop/adapters/views.rs index cdd1acb89..19e6b8fb9 100644 --- a/rfirma-app/src-tauri/src/desktop/adapters/views.rs +++ b/rfirma-app/src-tauri/src/desktop/adapters/views.rs @@ -200,6 +200,7 @@ crossing! { Card, Installed, Windows, + Macos, } } diff --git a/rfirma-app/src-tauri/src/desktop/application/command_line/certtui.rs b/rfirma-app/src-tauri/src/desktop/application/command_line/certtui.rs index 784568551..2515829cb 100644 --- a/rfirma-app/src-tauri/src/desktop/application/command_line/certtui.rs +++ b/rfirma-app/src-tauri/src/desktop/application/command_line/certtui.rs @@ -173,5 +173,6 @@ pub(super) fn store_of(class: StoreClass, reference: &CertificateRef) -> String StoreClass::Browser(owner) => owner.label(), StoreClass::Nssdb => "NSS del sistema".to_owned(), StoreClass::Windows => "Windows".to_owned(), + StoreClass::Macos => "Almacén de macOS".to_owned(), } } diff --git a/rfirma-app/src-tauri/src/desktop/application/command_line/json_output.rs b/rfirma-app/src-tauri/src/desktop/application/command_line/json_output.rs index 6c03fa24d..315941964 100644 --- a/rfirma-app/src-tauri/src/desktop/application/command_line/json_output.rs +++ b/rfirma-app/src-tauri/src/desktop/application/command_line/json_output.rs @@ -152,6 +152,7 @@ fn store_name_of(certificate: &TokenCertificate) -> String { match reference.store().class() { StoreClass::Card => format!("pkcs11:{}", reference.module().display()), StoreClass::Windows => "windows".to_owned(), + StoreClass::Macos => "apple".to_owned(), StoreClass::Browser(_) | StoreClass::Nssdb | StoreClass::Installed => "mozilla".to_owned(), } } diff --git a/rfirma-app/src-tauri/src/desktop/application/status.rs b/rfirma-app/src-tauri/src/desktop/application/status.rs index b0d18a5e5..8a7b0c7e8 100644 --- a/rfirma-app/src-tauri/src/desktop/application/status.rs +++ b/rfirma-app/src-tauri/src/desktop/application/status.rs @@ -185,6 +185,7 @@ pub fn brand_of_class(class: StoreClass) -> StoreBrand { StoreClass::Card => StoreBrand::Card, StoreClass::Installed => StoreBrand::Installed, StoreClass::Windows => StoreBrand::Windows, + StoreClass::Macos => StoreBrand::Macos, StoreClass::Nssdb => StoreBrand::Nssdb, StoreClass::Browser(owner) => match owner.browser_name() { Some(name) => StoreBrand::Browser { diff --git a/rfirma-app/src-tauri/src/desktop/domain/status.rs b/rfirma-app/src-tauri/src/desktop/domain/status.rs index 6370ab148..241d0a0ab 100644 --- a/rfirma-app/src-tauri/src/desktop/domain/status.rs +++ b/rfirma-app/src-tauri/src/desktop/domain/status.rs @@ -105,6 +105,8 @@ pub enum StoreBrand { Installed, /// Almacén raíz del usuario de Windows. Windows, + /// Los certificados con clave del llavero de macOS. + Macos, } /// Un almacén, con su marca y si la señal es de confianza en él. diff --git a/rfirma-app/src-tauri/src/identity/AGENTS.md b/rfirma-app/src-tauri/src/identity/AGENTS.md index fc5df35c6..2acbdb0a3 100644 --- a/rfirma-app/src-tauri/src/identity/AGENTS.md +++ b/rfirma-app/src-tauri/src/identity/AGENTS.md @@ -3,7 +3,7 @@ El contexto de la **identidad**: qué certificados hay, cuál se usa y cómo entra o sale un `.p12`. Aquí no se firma ningún documento, solo se dice quién puede. Su adaptador de verdad es `adapters/pkcs11/`, la única parte del backend que -habla con el token; en Windows, también `adapters/windows_store/`. Rutas relativas a `src/identity/`. +habla con el token; en Windows, también `adapters/windows_store/`, y en macOS, `adapters/macos_store/`. Rutas relativas a `src/identity/`. ## Dónde vive qué diff --git a/rfirma-app/src-tauri/src/identity/adapters/macos_store.rs b/rfirma-app/src-tauri/src/identity/adapters/macos_store.rs new file mode 100644 index 000000000..cb9838d3e --- /dev/null +++ b/rfirma-app/src-tauri/src/identity/adapters/macos_store.rs @@ -0,0 +1,162 @@ +//! `MacosToken`, el Almacén de macOS detrás del puerto `Token`, por Security.framework; los módulos PKCS#11 siguen en `RealToken` (ADR-0040). + +mod keychain; + +use std::path::Path; + +use super::pkcs11::RealToken; +use crate::identity::domain::algorithm::SignatureAlgorithm; +use crate::identity::domain::certificate::{CertificateRef, TokenCertificate}; +use crate::identity::domain::error::{Situation, TokenError}; +use crate::identity::domain::protected_secret::ProtectedSecret; +use crate::identity::domain::secret::{PinWarning, StoreSecret}; +use crate::identity::domain::store::{Store, StoreClass, MACOS_STORE_PREFIX}; +use crate::identity::ports::Token; + +const SEARCH_LIST: &str = "search-list"; + +/// El llavero de la persona usuaria: su lista de búsqueda entera. +pub fn user_store() -> Store { + Store::module(format!("{MACOS_STORE_PREFIX}{SEARCH_LIST}")) +} + +/// Un llavero suelto, por su fichero. +pub fn keychain_store(path: &Path) -> Store { + Store::module(format!("{MACOS_STORE_PREFIX}{}", path.display())) +} + +fn is_a_keychain(store: &Store) -> bool { + store.class() == StoreClass::Macos +} + +/// Los almacenes de esta máquina: el llavero y los PKCS#11 descubiertos, o solo el módulo nombrado. +pub fn from_environment() -> Vec { + stores_besides( + super::pkcs11::stores::from_environment(), + std::env::var_os(crate::PKCS11_MODULE_VARIABLE).is_some(), + ) +} + +fn stores_besides(pkcs11: Vec, module_named: bool) -> Vec { + if module_named { + return pkcs11; + } + std::iter::once(user_store()).chain(pkcs11).collect() +} + +fn not_signing_yet() -> TokenError { + TokenError::new( + Situation::MechanismNotOffered, + "el Almacén de macOS todavía no firma", + ) +} + +/// El adaptador de [`Token`] en macOS: el llavero para el Almacén de macOS y [`RealToken`] para los demás. +#[derive(Clone, Copy, Debug, Default)] +pub struct MacosToken; + +impl Token for MacosToken { + fn list(&self, store: &Store) -> Result, TokenError> { + if is_a_keychain(store) { + return keychain::signable_certificates(store); + } + RealToken.list(store) + } + + fn every_certificate(&self, store: &Store) -> Result, TokenError> { + if is_a_keychain(store) { + return keychain::every_certificate(store); + } + RealToken.every_certificate(store) + } + + fn list_authenticated( + &self, + store: &Store, + pin: &ProtectedSecret, + ) -> Result, TokenError> { + if is_a_keychain(store) { + return keychain::signable_certificates(store); + } + RealToken.list_authenticated(store, pin) + } + + fn secret_of(&self, reference: &CertificateRef) -> Result { + if is_a_keychain(&reference.store()) { + return Ok(StoreSecret::NotNeeded); + } + RealToken.secret_of(reference) + } + + fn pin_warning(&self, reference: &CertificateRef) -> Result { + if is_a_keychain(&reference.store()) { + return Ok(PinWarning::Quiet); + } + RealToken.pin_warning(reference) + } + + fn offers( + &self, + reference: &CertificateRef, + algorithm: SignatureAlgorithm, + ) -> Result<(), TokenError> { + if is_a_keychain(&reference.store()) { + return Err(not_signing_yet()); + } + RealToken.offers(reference, algorithm) + } + + fn accepts_the_secret( + &self, + reference: &CertificateRef, + secret: &ProtectedSecret, + ) -> Result<(), TokenError> { + if is_a_keychain(&reference.store()) { + return Ok(()); + } + RealToken.accepts_the_secret(reference, secret) + } + + fn sign_with_secret( + &self, + reference: &CertificateRef, + secret: &ProtectedSecret, + algorithm: SignatureAlgorithm, + data: &[u8], + ) -> Result, TokenError> { + if is_a_keychain(&reference.store()) { + return Err(not_signing_yet()); + } + RealToken.sign_with_secret(reference, secret, algorithm, data) + } + + fn hold_one_login(&self, reference: &CertificateRef) { + RealToken.hold_one_login(reference); + } + + fn release_the_login(&self, reference: &CertificateRef) { + RealToken.release_the_login(reference); + } + + fn import_pkcs12( + &self, + directory: &Path, + pkcs12: &[u8], + password: &str, + pin: &ProtectedSecret, + ) -> Result { + RealToken.import_pkcs12(directory, pkcs12, password, pin) + } + + fn remove_certificate( + &self, + directory: &Path, + reference: &CertificateRef, + pin: &ProtectedSecret, + ) -> Result<(), TokenError> { + RealToken.remove_certificate(directory, reference, pin) + } +} + +#[cfg(test)] +mod tests; diff --git a/rfirma-app/src-tauri/src/identity/adapters/macos_store/keychain.rs b/rfirma-app/src-tauri/src/identity/adapters/macos_store/keychain.rs new file mode 100644 index 000000000..0ec5f8d64 --- /dev/null +++ b/rfirma-app/src-tauri/src/identity/adapters/macos_store/keychain.rs @@ -0,0 +1,136 @@ +//! El llavero de macOS leído con Security.framework: las identidades con su certificado y el token de su clave, sin abrirla (ADR-0001). + +use core_foundation::base::{CFType, TCFType}; +use core_foundation::string::CFString; +use openssl::hash::{hash, MessageDigest}; +use security_framework::certificate::SecCertificate; +use security_framework::identity::SecIdentity; +use security_framework::item::{ItemClass, ItemSearchOptions, Limit, Reference, SearchResult}; +use security_framework::key::SecKey; +use security_framework::os::macos::keychain::SecKeychain; +use security_framework_sys::item::kSecAttrTokenID; + +use super::SEARCH_LIST; +use crate::identity::domain::certificate::{CertificateRef, TokenCertificate}; +use crate::identity::domain::error::{Situation, TokenError}; +use crate::identity::domain::store::{Store, MACOS_STORE_PREFIX}; + +/// La etiqueta de «token» de los certificados del llavero. +pub const TOKEN_LABEL: &str = "Keychain"; + +const SYSTEM_ROOTS: &str = "/System/Library/Keychains/SystemRootCertificates.keychain"; + +const ITEM_NOT_FOUND: i32 = -25300; + +/// Las identidades del llavero: los certificados con su clave privada. +pub fn signable_certificates(store: &Store) -> Result, TokenError> { + let _no_prompt = SecKeychain::disable_user_interaction().map_err(|error| failure(&error))?; + Ok(found(ItemClass::identity(), &scope_of(store)?)? + .into_iter() + .filter_map(|reference| match reference { + Reference::Identity(identity) => certificate_of_identity(store, &identity), + _ => None, + }) + .collect()) +} + +/// Todos los certificados del llavero y las raíces del sistema: con ellos se completa la cadena. +pub fn every_certificate(store: &Store) -> Result, TokenError> { + let _no_prompt = SecKeychain::disable_user_interaction().map_err(|error| failure(&error))?; + let scope = scope_of(store)?; + let mut certificates = found(ItemClass::certificate(), &scope)?; + if scope.is_none() { + if let Ok(roots) = SecKeychain::open(SYSTEM_ROOTS) { + certificates + .extend(found(ItemClass::certificate(), &Some(vec![roots])).unwrap_or_default()); + } + } + Ok(certificates + .into_iter() + .filter_map(|reference| match reference { + Reference::Certificate(certificate) => { + Some(certificate_in(store, &certificate.to_der(), None)) + } + _ => None, + }) + .collect()) +} + +/// Los llaveros donde se busca: ninguno en concreto es la lista de búsqueda de la persona usuaria. +fn scope_of(store: &Store) -> Result>, TokenError> { + let named = store + .path() + .to_str() + .and_then(|path| path.strip_prefix(MACOS_STORE_PREFIX)) + .unwrap_or(SEARCH_LIST); + if named == SEARCH_LIST { + return Ok(None); + } + SecKeychain::open(named) + .map(|keychain| Some(vec![keychain])) + .map_err(|error| failure(&error)) +} + +fn found(class: ItemClass, scope: &Option>) -> Result, TokenError> { + let mut search = ItemSearchOptions::new(); + search.class(class).load_refs(true).limit(Limit::All); + if let Some(keychains) = scope { + search.keychains(keychains); + } + match search.search() { + Ok(results) => Ok(results + .into_iter() + .filter_map(|result| match result { + SearchResult::Ref(reference) => Some(reference), + _ => None, + }) + .collect()), + Err(error) if error.code() == ITEM_NOT_FOUND => Ok(Vec::new()), + Err(error) => Err(failure(&error)), + } +} + +fn certificate_of_identity(store: &Store, identity: &SecIdentity) -> Option { + let certificate = identity.certificate().ok()?; + let token = identity + .private_key() + .ok() + .and_then(|key| token_holding(&key)); + Some(certificate_in(store, &certificate.to_der(), token)) +} + +fn certificate_in(store: &Store, der: &[u8], token: Option) -> TokenCertificate { + let thumbprint = hash(MessageDigest::sha1(), der) + .map(|digest| digest.to_vec()) + .unwrap_or_default(); + let label = SecCertificate::from_der(der) + .map(|certificate| certificate.subject_summary()) + .ok() + .filter(|summary| !summary.is_empty()) + .unwrap_or_else(|| hex(&thumbprint)); + let reference = CertificateRef::new(store.clone(), TOKEN_LABEL, label, thumbprint); + let reference = match token { + Some(token) => reference.with_key_provider(token), + None => reference, + }; + TokenCertificate::new(reference, der.to_vec()) +} + +/// El token del sistema que guarda la clave, leído de sus atributos; una clave de software no tiene. +fn token_holding(key: &SecKey) -> Option { + let attributes = key.attributes(); + let value = attributes.find(unsafe { kSecAttrTokenID }.cast())?; + let value = unsafe { CFType::wrap_under_get_rule(*value) }; + value.downcast::().map(|token| token.to_string()) +} + +fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|byte| format!("{byte:02X}")).collect() +} + +fn failure(error: &security_framework::base::Error) -> TokenError { + TokenError::new( + Situation::Unknown, + format!("el llavero de macOS ha fallado ({}): {error}", error.code()), + ) +} diff --git a/rfirma-app/src-tauri/src/identity/adapters/macos_store/tests.rs b/rfirma-app/src-tauri/src/identity/adapters/macos_store/tests.rs new file mode 100644 index 000000000..f924313ce --- /dev/null +++ b/rfirma-app/src-tauri/src/identity/adapters/macos_store/tests.rs @@ -0,0 +1,285 @@ +use std::path::{Path, PathBuf}; + +use core_foundation::base::TCFType; +use openssl::asn1::Asn1Time; +use openssl::bn::BigNum; +use openssl::ec::{EcGroup, EcKey}; +use openssl::hash::MessageDigest; +use openssl::nid::Nid; +use openssl::pkcs12::Pkcs12; +use openssl::pkey::{PKey, Private}; +use openssl::rsa::Rsa; +use openssl::x509::extension::BasicConstraints; +use openssl::x509::{X509Name, X509}; +use security_framework::certificate::SecCertificate; +use security_framework::import_export::Pkcs12ImportOptions; +use security_framework::os::macos::keychain::{CreateOptions, SecKeychain}; + +use super::{keychain_store, stores_besides, user_store, MacosToken}; +use crate::identity::application::certificates::certificates_with_their_chains; +use crate::identity::domain::certificate::TokenCertificate; +use crate::identity::domain::secret::{PinWarning, StoreSecret}; +use crate::identity::domain::store::{Store, StoreClass}; +use crate::identity::ports::Token; + +const PASSWORD: &str = "rfirma-test"; + +#[link(name = "Security", kind = "framework")] +extern "C" { + fn SecKeychainDelete(keychain: *mut std::ffi::c_void) -> i32; +} + +/// Un llavero propio de la prueba, fuera de la lista de búsqueda de quien la corre, que se borra al acabar. +struct TemporaryKeychain { + _directory: tempfile::TempDir, + path: PathBuf, + keychain: SecKeychain, +} + +impl TemporaryKeychain { + fn create() -> Self { + let directory = tempfile::tempdir().expect("deberia haber directorio temporal"); + let path = directory.path().join("rfirma-test.keychain-db"); + let mut keychain = CreateOptions::new() + .password(PASSWORD) + .create(&path) + .expect("deberia crearse el llavero de pruebas"); + keychain + .unlock(Some(PASSWORD)) + .expect("deberia desbloquearse el llavero de pruebas"); + Self { + _directory: directory, + path, + keychain, + } + } + + fn listed(&self) -> Vec { + MacosToken + .list(&keychain_store(&self.path)) + .expect("el llavero deberia listarse") + } + + fn holds_an_identity(&self, certificate: &X509, key: &PKey) { + let pkcs12 = Pkcs12::builder() + .name("rfirma-test") + .pkey(key) + .cert(certificate) + .key_algorithm(Nid::PBE_WITHSHA1AND3_KEY_TRIPLEDES_CBC) + .cert_algorithm(Nid::PBE_WITHSHA1AND3_KEY_TRIPLEDES_CBC) + .mac_md(MessageDigest::sha1()) + .build2(PASSWORD) + .and_then(|pkcs12| pkcs12.to_der()) + .expect("deberia construirse el .p12 de pruebas"); + Pkcs12ImportOptions::new() + .passphrase(PASSWORD) + .keychain(self.keychain.clone()) + .import(&pkcs12) + .expect("el .p12 deberia entrar en el llavero de pruebas"); + } + + fn holds_a_certificate(&self, certificate: &X509) { + SecCertificate::from_der(&der_of(certificate)) + .and_then(|certificate| certificate.add_to_keychain(Some(self.keychain.clone()))) + .expect("el certificado deberia entrar en el llavero de pruebas"); + } +} + +impl Drop for TemporaryKeychain { + fn drop(&mut self) { + unsafe { + SecKeychainDelete(self.keychain.as_concrete_TypeRef().cast()); + } + } +} + +fn an_rsa_key() -> PKey { + PKey::from_rsa(Rsa::generate(2048).expect("deberia generarse la clave RSA")) + .expect("deberia envolverse la clave RSA") +} + +fn an_elliptic_curve_key() -> PKey { + let group = EcGroup::from_curve_name(Nid::X9_62_PRIME256V1).expect("deberia estar P-256"); + PKey::from_ec_key(EcKey::generate(&group).expect("deberia generarse la clave EC")) + .expect("deberia envolverse la clave EC") +} + +/// Un certificado de `common_name` para `key`, emitido por `issuer` o autofirmado. +fn a_certificate( + common_name: &str, + key: &PKey, + issuer: Option<(&X509, &PKey)>, + authority: bool, +) -> X509 { + let mut name = X509Name::builder().expect("deberia construirse un nombre"); + name.append_entry_by_nid(Nid::COMMONNAME, common_name) + .expect("el nombre comun deberia entrar"); + let name = name.build(); + let mut builder = X509::builder().expect("deberia construirse un certificado"); + builder.set_version(2).expect("la version deberia ponerse"); + let serial = BigNum::from_u32(rand_serial()) + .and_then(|serial| serial.to_asn1_integer()) + .expect("el serie deberia construirse"); + builder + .set_serial_number(&serial) + .expect("el serie deberia ponerse"); + builder + .set_subject_name(&name) + .expect("el titular deberia ponerse"); + builder + .set_issuer_name(issuer.map_or(&name, |(certificate, _)| certificate.subject_name())) + .expect("el emisor deberia ponerse"); + builder.set_pubkey(key).expect("la clave deberia ponerse"); + builder + .set_not_before(&Asn1Time::days_from_now(0).expect("deberia haber fecha")) + .expect("el inicio deberia ponerse"); + builder + .set_not_after(&Asn1Time::days_from_now(30).expect("deberia haber fecha")) + .expect("el fin deberia ponerse"); + if authority { + builder + .append_extension( + BasicConstraints::new() + .critical() + .ca() + .build() + .expect("deberia construirse la extension"), + ) + .expect("la extension deberia ponerse"); + } + builder + .sign(issuer.map_or(key, |(_, key)| key), MessageDigest::sha256()) + .expect("el certificado deberia firmarse"); + builder.build() +} + +fn rand_serial() -> u32 { + let mut bytes = [0u8; 4]; + openssl::rand::rand_bytes(&mut bytes).expect("deberia haber azar"); + u32::from_be_bytes(bytes) >> 1 +} + +fn der_of(certificate: &X509) -> Vec { + certificate + .to_der() + .expect("el certificado deberia salir en DER") +} + +#[test] +fn the_user_store_is_the_macos_store_and_not_a_card() { + assert_eq!(user_store().class(), StoreClass::Macos); + assert_eq!( + keychain_store(Path::new("/tmp/otro.keychain-db")).class(), + StoreClass::Macos + ); +} + +#[test] +fn the_macos_store_comes_before_the_pkcs11_stores_unless_a_module_is_named() { + let card = Store::module("/opt/homebrew/lib/opensc-pkcs11.so"); + + assert_eq!( + stores_besides(vec![card.clone()], false), + vec![user_store(), card.clone()] + ); + assert_eq!(stores_besides(vec![card.clone()], true), vec![card]); +} + +#[test] +fn macos_asks_for_the_secret_and_never_warns_about_the_pin() { + let keychain = TemporaryKeychain::create(); + let key = an_rsa_key(); + keychain.holds_an_identity(&a_certificate("FIRMA RSA", &key, None, false), &key); + let listed = keychain.listed(); + let reference = listed[0].reference(); + + assert_eq!(MacosToken.secret_of(reference), Ok(StoreSecret::NotNeeded)); + assert_eq!(MacosToken.pin_warning(reference), Ok(PinWarning::Quiet)); +} + +#[test] +fn lists_an_rsa_and_an_elliptic_curve_certificate_and_leaves_out_one_without_its_key() { + let keychain = TemporaryKeychain::create(); + let rsa = an_rsa_key(); + let rsa_certificate = a_certificate("FIRMA RSA", &rsa, None, false); + let ec = an_elliptic_curve_key(); + let ec_certificate = a_certificate("FIRMA CURVA ELIPTICA", &ec, None, false); + let keyless = a_certificate("SIN CLAVE", &an_rsa_key(), None, false); + keychain.holds_an_identity(&rsa_certificate, &rsa); + keychain.holds_an_identity(&ec_certificate, &ec); + keychain.holds_a_certificate(&keyless); + + let mut listed: Vec = keychain + .listed() + .iter() + .map(|certificate| certificate.reference().label().to_owned()) + .collect(); + listed.sort(); + + assert_eq!(listed, vec!["FIRMA CURVA ELIPTICA", "FIRMA RSA"]); +} + +#[test] +fn a_certificate_with_its_key_in_software_stays_in_the_macos_store() { + let keychain = TemporaryKeychain::create(); + let key = an_rsa_key(); + keychain.holds_an_identity(&a_certificate("FIRMA RSA", &key, None, false), &key); + + let listed = keychain.listed(); + + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].reference().key_provider(), None); + assert_eq!( + listed[0].reference().class_under(Path::new("/nowhere")), + StoreClass::Macos + ); + assert_eq!(listed[0].reference().label(), "FIRMA RSA"); +} + +#[test] +fn the_chain_of_a_keychain_certificate_is_completed_with_its_issuer_from_the_keychain() { + let keychain = TemporaryKeychain::create(); + let root_key = an_rsa_key(); + let root = a_certificate("RAIZ DE PRUEBAS", &root_key, None, true); + let intermediate_key = an_rsa_key(); + let intermediate = a_certificate( + "INTERMEDIA DE PRUEBAS", + &intermediate_key, + Some((&root, &root_key)), + true, + ); + let key = an_rsa_key(); + let signer = a_certificate( + "FIRMANTE", + &key, + Some((&intermediate, &intermediate_key)), + false, + ); + keychain.holds_a_certificate(&root); + keychain.holds_a_certificate(&intermediate); + keychain.holds_an_identity(&signer, &key); + + let offered = certificates_with_their_chains(&MacosToken, &[keychain_store(&keychain.path)]) + .expect("el llavero deberia listarse"); + + assert_eq!(offered.len(), 1); + let chain: Vec = offered[0] + .chain() + .iter() + .map(|der| common_name_of(der)) + .collect(); + assert_eq!(chain, vec!["FIRMANTE", "INTERMEDIA DE PRUEBAS"]); +} + +fn common_name_of(der: &[u8]) -> String { + let certificate = X509::from_der(der).expect("deberia leerse el certificado"); + let entry = certificate + .subject_name() + .entries_by_nid(Nid::COMMONNAME) + .next() + .expect("deberia tener nombre comun"); + entry + .data() + .to_string() + .expect("el nombre comun deberia leerse") +} diff --git a/rfirma-app/src-tauri/src/identity/adapters/mod.rs b/rfirma-app/src-tauri/src/identity/adapters/mod.rs index c307d6f2b..5bd62158b 100644 --- a/rfirma-app/src-tauri/src/identity/adapters/mod.rs +++ b/rfirma-app/src-tauri/src/identity/adapters/mod.rs @@ -4,6 +4,8 @@ pub mod failures; pub mod folder; #[cfg(target_os = "linux")] pub mod keyring; +#[cfg(target_os = "macos")] +pub mod macos_store; #[cfg(any(target_os = "linux", windows))] pub mod pcsc; #[cfg(target_os = "macos")] @@ -46,14 +48,20 @@ pub const SPEAKS_PCSC: bool = cfg!(any(target_os = "linux", windows)); pub use readers::UnavailableReaderWatch as DesktopReaderWatch; /// El token de esta plataforma. -#[cfg(unix)] +#[cfg(target_os = "macos")] +pub use macos_store::MacosToken as DesktopToken; +/// El token de esta plataforma. +#[cfg(target_os = "linux")] pub use pkcs11::RealToken as DesktopToken; /// El token de esta plataforma. #[cfg(windows)] pub use windows_store::WindowsToken as DesktopToken; /// Los almacenes de certificados de esta plataforma. -#[cfg(unix)] +#[cfg(target_os = "macos")] +pub use macos_store::from_environment as desktop_stores; +/// Los almacenes de certificados de esta plataforma. +#[cfg(target_os = "linux")] pub use pkcs11::stores::from_environment as desktop_stores; /// Los almacenes de certificados de esta plataforma. #[cfg(windows)] diff --git a/rfirma-app/src-tauri/src/identity/adapters/pkcs11/mod.rs b/rfirma-app/src-tauri/src/identity/adapters/pkcs11/mod.rs index c2256ed5f..65a03e68d 100644 --- a/rfirma-app/src-tauri/src/identity/adapters/pkcs11/mod.rs +++ b/rfirma-app/src-tauri/src/identity/adapters/pkcs11/mod.rs @@ -1,4 +1,4 @@ -//! Capa de acceso a tokens criptográficos y firma nativa PKCS#11: `RealToken`, el adaptador del puerto `Token` en Linux y macOS, y el que usa `WindowsToken` para los módulos PKCS#11 (ADR-0001). +//! Capa de acceso a tokens criptográficos y firma nativa PKCS#11: `RealToken`, el adaptador del puerto `Token` en Linux, y el que usan `WindowsToken` y `MacosToken` para los módulos PKCS#11 (ADR-0001). mod listing; mod mechanism; diff --git a/rfirma-app/src-tauri/src/identity/adapters/views.rs b/rfirma-app/src-tauri/src/identity/adapters/views.rs index adc6b1317..25bc0c4bd 100644 --- a/rfirma-app/src-tauri/src/identity/adapters/views.rs +++ b/rfirma-app/src-tauri/src/identity/adapters/views.rs @@ -79,6 +79,7 @@ crossing! { Installed, Nssdb, Windows, + Macos, /// El almacén NSS que comparten los navegadores Chromium nativos. SharedChromium, /// El almacén NSS de un navegador, con el canal por el que llegó si no es nativo. @@ -97,6 +98,7 @@ impl StoreMarkView { StoreClass::Installed => Self::Installed, StoreClass::Nssdb => Self::Nssdb, StoreClass::Windows => Self::Windows, + StoreClass::Macos => Self::Macos, StoreClass::Browser(owner) => match owner.browser_name() { Some(name) => Self::Browser { name: name.to_owned(), diff --git a/rfirma-app/src-tauri/src/identity/application/certificates/tests/macos_store.rs b/rfirma-app/src-tauri/src/identity/application/certificates/tests/macos_store.rs new file mode 100644 index 000000000..6a0bd7127 --- /dev/null +++ b/rfirma-app/src-tauri/src/identity/application/certificates/tests/macos_store.rs @@ -0,0 +1,123 @@ +use std::path::Path; + +use super::super::{listed_rows, rows_of, ListedCertificates}; +use super::{StoresWith, CARD, INSTALLED, SOFTOKEN}; +use crate::identity::application::readers::LastListing; +use crate::identity::application::tests::{ + a_firefox_class, a_firefox_profile_store, NoMemory, TestAuthority, +}; +use crate::identity::domain::certificate::{CertificateRef, ListedCertificate, TokenCertificate}; +use crate::identity::domain::store::{Store, StoreClass}; + +fn the_macos_store() -> Store { + Store::module("keychain:search-list") +} + +fn in_the_keychain(label: &str, der: &[u8]) -> TokenCertificate { + TokenCertificate::new( + CertificateRef::new( + the_macos_store(), + "Keychain", + label, + label.as_bytes().to_vec(), + ), + der.to_vec(), + ) +} + +fn a_copy_in(store: &Store, label: &str, der: &[u8]) -> TokenCertificate { + TokenCertificate::new( + CertificateRef::new(store.clone(), "rfirma-test", label, vec![0x01]), + der.to_vec(), + ) +} + +/// El Almacén de rFirma en `directory`, con su base ya creada. +fn the_installed_store(directory: &Path) -> Store { + std::fs::create_dir_all(directory).expect("deberia crearse el almacen"); + std::fs::write(directory.join("cert9.db"), b"").expect("deberia crearse la base"); + Store::installed_nss(INSTALLED, directory) +} + +fn listed_on_the_mac( + found: &[TokenCertificate], + stores: &[Store], + home: &Path, +) -> Vec { + listed_rows( + &StoresWith::holding(found.to_vec(), found.to_vec()), + stores, + &home.join("certificates"), + &ListedCertificates::new(), + &ListedCertificates::new(), + &NoMemory, + &LastListing::default(), + ) + .expect("los almacenes deberian listarse") +} + +#[test] +fn a_keychain_certificate_with_its_key_in_software_is_in_the_macos_store() { + let home = tempfile::tempdir().expect("deberia haber directorio temporal"); + let found = [in_the_keychain( + "EN-SOFTWARE", + &TestAuthority::root("EN SOFTWARE").der(), + )]; + + let rows = listed_on_the_mac(&found, &[the_macos_store()], home.path()); + + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].store, StoreClass::Macos); +} + +#[test] +fn the_macos_store_comes_after_the_card_and_before_the_rfirma_store_and_the_nss_ones() { + let home = tempfile::tempdir().expect("deberia haber directorio temporal"); + let der = TestAuthority::root("EIDAS CERTIFICADO PRUEBAS - 99999999R").der(); + let firefox = a_firefox_profile_store(SOFTOKEN, "/Users/ada/Library/Firefox/perfil"); + let system_nss = Store::nss(SOFTOKEN, Path::new("/Users/ada/.pki/otro")); + let installed = the_installed_store(&home.path().join("certificates")); + let card = Store::module(CARD); + let found = [ + a_copy_in(&firefox, "COPIA-FIREFOX", &der), + a_copy_in(&system_nss, "COPIA-NSS", &der), + a_copy_in(&installed, "COPIA-RFIRMA", &der), + in_the_keychain("COPIA-LLAVERO", &der), + a_copy_in(&card, "COPIA-TARJETA", &der), + ]; + + let rows = rows_of( + found.to_vec(), + &home.path().join("certificates"), + &ListedCertificates::new(), + &ListedCertificates::new(), + &NoMemory, + ); + + assert_eq!( + rows[0].stores, + vec![ + StoreClass::Card, + StoreClass::Macos, + StoreClass::Installed, + StoreClass::Nssdb, + a_firefox_class(), + ] + ); +} + +#[test] +fn without_a_card_the_macos_copy_is_the_one_behind_the_row() { + let home = tempfile::tempdir().expect("deberia haber directorio temporal"); + let der = TestAuthority::root("EIDAS CERTIFICADO PRUEBAS - 99999999R").der(); + let firefox = a_firefox_profile_store(SOFTOKEN, "/Users/ada/Library/Firefox/perfil"); + let found = [ + a_copy_in(&firefox, "COPIA-FIREFOX", &der), + in_the_keychain("COPIA-LLAVERO", &der), + ]; + + let rows = listed_on_the_mac(&found, &[firefox, the_macos_store()], home.path()); + + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].store, StoreClass::Macos); +} diff --git a/rfirma-app/src-tauri/src/identity/application/certificates/tests/mod.rs b/rfirma-app/src-tauri/src/identity/application/certificates/tests/mod.rs index c3cdc0746..4b48e19cb 100644 --- a/rfirma-app/src-tauri/src/identity/application/certificates/tests/mod.rs +++ b/rfirma-app/src-tauri/src/identity/application/certificates/tests/mod.rs @@ -606,5 +606,6 @@ fn an_entity_certificate_without_a_natural_person_holder_carries_no_entity_name( } mod copies; +mod macos_store; mod removal; mod windows_store; diff --git a/rfirma-app/src-tauri/src/identity/domain/secret.rs b/rfirma-app/src-tauri/src/identity/domain/secret.rs index 5b33add9f..4eb00168d 100644 --- a/rfirma-app/src-tauri/src/identity/domain/secret.rs +++ b/rfirma-app/src-tauri/src/identity/domain/secret.rs @@ -23,7 +23,10 @@ impl SecretName { match store { // En Windows el PIN lo pide el propio sistema, pero si hay que nombrarlo es el de la tarjeta. StoreClass::Card | StoreClass::Windows => Self::Pin, - StoreClass::Browser(_) | StoreClass::Nssdb | StoreClass::Installed => Self::Password, + StoreClass::Browser(_) + | StoreClass::Nssdb + | StoreClass::Installed + | StoreClass::Macos => Self::Password, } } } diff --git a/rfirma-app/src-tauri/src/identity/domain/store.rs b/rfirma-app/src-tauri/src/identity/domain/store.rs index 9c0f26473..ad72a0758 100644 --- a/rfirma-app/src-tauri/src/identity/domain/store.rs +++ b/rfirma-app/src-tauri/src/identity/domain/store.rs @@ -7,6 +7,9 @@ use crate::identity::domain::browsers::NssOwner; /// Prefijo con el que se nombran los almacenes de Windows: ningún módulo PKCS#11 se llama así. pub const WINDOWS_STORE_PREFIX: &str = "cng:"; +/// Prefijo con el que se nombran los llaveros de macOS: ningún módulo PKCS#11 se llama así. +pub const MACOS_STORE_PREFIX: &str = "keychain:"; + /// Los proveedores de Windows que guardan la clave en una tarjeta: el KSP de los minidrivers y su CSP. const CARD_KEY_PROVIDERS: [&str; 2] = [ "Microsoft Smart Card Key Storage Provider", @@ -33,6 +36,8 @@ pub enum StoreClass { Installed, /// Almacén de certificados personales de Windows, servido por CNG (ADR-0035). Windows, + /// Los certificados con clave privada del llavero de macOS (ADR-0040). + Macos, } impl StoreClass { @@ -44,13 +49,13 @@ impl StoreClass { } } - /// Qué copia de un mismo certificado se prefiere: tarjeta, Windows, Almacén de rFirma, NSS del sistema, Firefox, Chrome. + /// Qué copia de un mismo certificado se prefiere: tarjeta, Windows o macOS, Almacén de rFirma, NSS del sistema, Firefox, Chrome. pub fn preference(self) -> u8 { match self { Self::Browser(owner) if owner.is_firefox_family() => 4, Self::Browser(_) => 5, Self::Card => 0, - Self::Windows => 1, + Self::Windows | Self::Macos => 1, Self::Installed => 2, Self::Nssdb => 3, } @@ -148,6 +153,9 @@ impl Store { if self.is_windows() { return StoreClass::Windows; } + if self.is_named(MACOS_STORE_PREFIX) { + return StoreClass::Macos; + } if self.profile().is_none() { return StoreClass::Card; } @@ -156,9 +164,13 @@ impl Store { /// Si es un almacén de Windows y no un módulo PKCS#11. fn is_windows(&self) -> bool { + self.is_named(WINDOWS_STORE_PREFIX) + } + + fn is_named(&self, prefix: &str) -> bool { self.module .to_str() - .is_some_and(|module| module.starts_with(WINDOWS_STORE_PREFIX)) + .is_some_and(|module| module.starts_with(prefix)) } /// Directorio del perfil NSS si está configurado. diff --git a/rfirma-app/src-tauri/tests/contract.snapshot b/rfirma-app/src-tauri/tests/contract.snapshot index 416088743..f35e31d92 100644 --- a/rfirma-app/src-tauri/tests/contract.snapshot +++ b/rfirma-app/src-tauri/tests/contract.snapshot @@ -251,6 +251,7 @@ TIPOS QUE CRUZAN (/adapters/views*.rs y order Installed Nssdb Windows + Macos SharedChromium Browser { name: String, channel: Option } @@ -533,6 +534,7 @@ TIPOS QUE CRUZAN (/adapters/views*.rs y order Card Installed Windows + Macos pub enum Theme [signing/application/configuration_memory.rs] System From 164272799b8cec703458d0c51eba6428b48f936e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20G=C3=B3mez=20Bachiller?= Date: Mon, 19 Oct 2026 16:05:55 +0200 Subject: [PATCH 04/22] feat(macos): la CA local se instala en la confianza de la persona usuaria de macOS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements #2014: La CA local se instala en la confianza de la persona usuaria de macOS - MacosUserTrust sustituye a PendingMacosKeychainTrust: Security.framework en el dominio de la persona usuaria, con el diálogo de contraseña del sistema. - La confianza de macOS es un perfil más (MACOS_USER_TRUST) con la marca StoreBrand::Macos. - Cancelar el diálogo se distingue de un fallo y deja la CA como no instalada. --- ...n-apple-silicon-como-tercera-plataforma.md | 5 +- packaging/flatpak/sources.lock | 2 +- rfirma-app/src-tauri/Cargo.lock | 3 + rfirma-app/src-tauri/Cargo.toml | 4 + .../src-tauri/src/desktop/adapters/tauri.rs | 22 +- .../src/desktop/adapters/tauri/tests.rs | 13 ++ .../adapters/tauri/tests/macos_trust.rs | 136 ++++++++++++ .../src-tauri/src/desktop/adapters/views.rs | 1 + .../src-tauri/src/desktop/domain/status.rs | 2 + .../src/site/adapters/macos_trust.rs | 200 ++++++++++++++++++ .../src/site/adapters/macos_trust/tests.rs | 50 +++++ rfirma-app/src-tauri/src/site/adapters/mod.rs | 68 ++---- rfirma-app/src-tauri/src/site/mod.rs | 3 +- rfirma-app/src-tauri/tests/contract.snapshot | 1 + 14 files changed, 450 insertions(+), 60 deletions(-) create mode 100644 rfirma-app/src-tauri/src/desktop/adapters/tauri/tests/macos_trust.rs create mode 100644 rfirma-app/src-tauri/src/site/adapters/macos_trust.rs create mode 100644 rfirma-app/src-tauri/src/site/adapters/macos_trust/tests.rs 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 a2a65e22c..174018fb4 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, @@ -108,5 +107,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/packaging/flatpak/sources.lock b/packaging/flatpak/sources.lock index fe00eeddc..e376bfe0f 100644 --- a/packaging/flatpak/sources.lock +++ b/packaging/flatpak/sources.lock @@ -1 +1 @@ -0c70a1048823f3a8f468a30da28e9695b6d316b64c4f03e900e3af9a463d8138 rfirma-app/src-tauri/Cargo.lock +9520c54cf8146264ea9d8112d3a7cd6cef9d43e9255e24bee8cedd7993e83e11 rfirma-app/src-tauri/Cargo.lock diff --git a/rfirma-app/src-tauri/Cargo.lock b/rfirma-app/src-tauri/Cargo.lock index d5f4e03ed..599669b83 100644 --- a/rfirma-app/src-tauri/Cargo.lock +++ b/rfirma-app/src-tauri/Cargo.lock @@ -4179,6 +4179,7 @@ dependencies = [ "block2", "chrono", "cipher 0.5.2", + "core-foundation", "cryptoki", "des", "ecb", @@ -4205,6 +4206,8 @@ dependencies = [ "ratatui", "reqwest", "rsa", + "security-framework", + "security-framework-sys", "serde", "serde_json", "sha2", diff --git a/rfirma-app/src-tauri/Cargo.toml b/rfirma-app/src-tauri/Cargo.toml index c4a65cc14..2507273c6 100644 --- a/rfirma-app/src-tauri/Cargo.toml +++ b/rfirma-app/src-tauri/Cargo.toml @@ -230,6 +230,10 @@ block2 = "0.6" objc2 = "0.6" objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "block2", "NSAlert", "NSApplication", "NSButton", "NSControl", "NSResponder", "NSSecureTextField", "NSTextField", "NSView", "NSWindow"] } objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSGeometry", "NSString"] } +# La CA local en la confianza de la persona usuaria (ADR-0040). +core-foundation = "0.10" +security-framework = "3" +security-framework-sys = "2" [target.'cfg(not(windows))'.dev-dependencies] # La tarjeta falsa de las pruebas: no se compila en Windows y nunca llega a un paquete (ADR-0004). diff --git a/rfirma-app/src-tauri/src/desktop/adapters/tauri.rs b/rfirma-app/src-tauri/src/desktop/adapters/tauri.rs index b9e2b020d..f38e96f0e 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 8ee301032..4bb686df2 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 000000000..7e6fd1df6 --- /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/adapters/views.rs b/rfirma-app/src-tauri/src/desktop/adapters/views.rs index cdd1acb89..19e6b8fb9 100644 --- a/rfirma-app/src-tauri/src/desktop/adapters/views.rs +++ b/rfirma-app/src-tauri/src/desktop/adapters/views.rs @@ -200,6 +200,7 @@ crossing! { Card, Installed, Windows, + Macos, } } diff --git a/rfirma-app/src-tauri/src/desktop/domain/status.rs b/rfirma-app/src-tauri/src/desktop/domain/status.rs index 6370ab148..184de9994 100644 --- a/rfirma-app/src-tauri/src/desktop/domain/status.rs +++ b/rfirma-app/src-tauri/src/desktop/domain/status.rs @@ -105,6 +105,8 @@ pub enum StoreBrand { Installed, /// Almacén raíz del usuario de Windows. Windows, + /// Confianza del dominio de la persona usuaria de macOS. + Macos, } /// Un almacén, con su marca y si la señal es de confianza en él. 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 000000000..079cd0726 --- /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 000000000..610856e17 --- /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 1f62dc1fc..7d782c6ad 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 4ef5f8c9a..33768afdf 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; diff --git a/rfirma-app/src-tauri/tests/contract.snapshot b/rfirma-app/src-tauri/tests/contract.snapshot index 416088743..6a68ad52f 100644 --- a/rfirma-app/src-tauri/tests/contract.snapshot +++ b/rfirma-app/src-tauri/tests/contract.snapshot @@ -533,6 +533,7 @@ TIPOS QUE CRUZAN (/adapters/views*.rs y order Card Installed Windows + Macos pub enum Theme [signing/application/configuration_memory.rs] System From d77a6f793873f68210a5325af5a23de78803b212 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20G=C3=B3mez=20Bachiller?= Date: Mon, 19 Oct 2026 16:06:14 +0200 Subject: [PATCH 05/22] feat(macos): el aviso de arranque es una alerta nativa MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements #2019: El aviso de arranque de macOS es una alerta nativa - NSAlert crítica modal, sin ventana de Tauri; sin hilo principal queda el stderr --- rfirma-app/src-tauri/src/startup_dialog.rs | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/rfirma-app/src-tauri/src/startup_dialog.rs b/rfirma-app/src-tauri/src/startup_dialog.rs index d0764c7a0..0fc045ab9 100644 --- a/rfirma-app/src-tauri/src/startup_dialog.rs +++ b/rfirma-app/src-tauri/src/startup_dialog.rs @@ -1,4 +1,4 @@ -//! Diálogo nativo que enseña un fallo de arranque y sale del proceso (GTK en Linux, `MessageBoxW` en Windows, pendiente en macOS); capa fina, sin pruebas (ADR-0035, ADR-0040). +//! Diálogo nativo que enseña un fallo de arranque y sale del proceso (GTK en Linux, `MessageBoxW` en Windows, `NSAlert` en macOS); capa fina, sin pruebas (ADR-0035, ADR-0040). use crate::startup_failure::StartupFailure; use crate::startup_failure::REPOSITORY_ADDRESS; @@ -50,14 +50,25 @@ pub fn report_and_exit(failure: &StartupFailure) -> ! { #[cfg(windows)] show_windows_dialog(failure); #[cfg(target_os = "macos")] - pending_macos_dialog(failure); + show_macos_alert(failure); std::process::exit(1); } -/// El diálogo de macOS aún no existe: el detalle y la dirección del repositorio van a `stderr`. #[cfg(target_os = "macos")] -fn pending_macos_dialog(failure: &StartupFailure) { - eprintln!("{}", detail_text(failure)); +fn show_macos_alert(failure: &StartupFailure) { + use objc2::MainThreadMarker; + use objc2_app_kit::{NSAlert, NSAlertStyle, NSApplication}; + use objc2_foundation::NSString; + + let Some(mtm) = MainThreadMarker::new() else { + return; + }; + let _ = NSApplication::sharedApplication(mtm); + let alert = NSAlert::new(mtm); + alert.setAlertStyle(NSAlertStyle::Critical); + alert.setMessageText(&NSString::from_str(failure.phrase())); + alert.setInformativeText(&NSString::from_str(&detail_text(failure))); + alert.runModal(); } #[cfg(windows)] From ec83b92c0826c5fb7179e6861d183b7bd7c91087 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20G=C3=B3mez=20Bachiller?= Date: Mon, 19 Oct 2026 16:09:46 +0200 Subject: [PATCH 06/22] ci(macos): la entrega vuelve a publicar el paquete de macOS Implements #2021: La entrega vuelve a publicar el paquete de macOS - Revierte f785fe37; firma ad hoc y hardened runtime desactivado (ID-548) - El calentador semanal recupera la entrada de macos-14 --- .github/workflows/build.yml | 40 ++++++++++++++++++- .github/workflows/warm-release-cache.yml | 2 +- ...n-apple-silicon-como-tercera-plataforma.md | 3 +- 3 files changed, 41 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 30635fb90..5e2d2047c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -242,9 +242,40 @@ jobs: if-no-files-found: error retention-days: 1 + # El .dmg de Apple Silicon compila su propia .dylib, como el instalador de Windows (ADR-0040). + macos: + name: DMG de macOS + runs-on: macos-14 + timeout-minutes: 90 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ inputs.ref }} + + - uses: ./.github/actions/setup-runner + with: + rust: release + node: true + java: graalvm + save-cache: false + + - name: Libreria nativa + run: just native + + - name: Empaqueta la .app y el .dmg + run: just bundle + + - name: Sube el .dmg + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: bundle-macos + path: rfirma-app/src-tauri/target/aarch64-apple-darwin/release/bundle/dmg/*.dmg + if-no-files-found: error + retention-days: 1 + collect-packages: name: Reune y verifica paquetes - needs: [native-bridge, test-native, flatpak, bundle, windows] + needs: [native-bridge, test-native, flatpak, bundle, windows, macos] if: | always() && !cancelled() && @@ -252,6 +283,7 @@ jobs: needs.test-native.result == 'success' && needs.flatpak.result == 'success' && needs.windows.result == 'success' && + needs.macos.result == 'success' && (needs.bundle.result == 'success' || needs.bundle.result == 'skipped') runs-on: ubuntu-latest timeout-minutes: 10 @@ -285,6 +317,12 @@ jobs: name: bundle-windows path: ${{ runner.temp }}/paquetes + - name: Descarga el .dmg de macOS + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: bundle-macos + path: ${{ runner.temp }}/paquetes + - name: Descarga bundles nativos (.deb y .rpm) if: needs.native-bridge.outputs.native-packages == 'true' uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 diff --git a/.github/workflows/warm-release-cache.yml b/.github/workflows/warm-release-cache.yml index d358adc1b..1ceca735a 100644 --- a/.github/workflows/warm-release-cache.yml +++ b/.github/workflows/warm-release-cache.yml @@ -55,7 +55,7 @@ jobs: strategy: fail-fast: false matrix: - include: ${{ fromJSON(github.event.schedule == '37 2 * * *' && '[{"runner":"ubuntu-24.04","timeout":60,"apt":"tauri","java":"none","native":false,"recipe":"build-rust"}]' || github.event_name == 'schedule' && '[{"runner":"windows-latest","timeout":90,"apt":"none","java":"graalvm","native":true,"recipe":"bundle"}]' || '[{"runner":"ubuntu-24.04","timeout":60,"apt":"tauri","java":"none","native":false,"recipe":"build-rust"},{"runner":"windows-latest","timeout":90,"apt":"none","java":"graalvm","native":true,"recipe":"bundle"}]') }} + include: ${{ fromJSON(github.event.schedule == '37 2 * * *' && '[{"runner":"ubuntu-24.04","timeout":60,"apt":"tauri","java":"none","native":false,"recipe":"build-rust"}]' || github.event_name == 'schedule' && '[{"runner":"windows-latest","timeout":90,"apt":"none","java":"graalvm","native":true,"recipe":"bundle"},{"runner":"macos-14","timeout":90,"apt":"none","java":"graalvm","native":true,"recipe":"bundle"}]' || '[{"runner":"ubuntu-24.04","timeout":60,"apt":"tauri","java":"none","native":false,"recipe":"build-rust"},{"runner":"windows-latest","timeout":90,"apt":"none","java":"graalvm","native":true,"recipe":"bundle"},{"runner":"macos-14","timeout":90,"apt":"none","java":"graalvm","native":true,"recipe":"bundle"}]') }} runs-on: ${{ matrix.runner }} timeout-minutes: ${{ matrix.timeout }} defaults: 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 1fb9ae159..6d35e3606 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 @@ -144,8 +144,7 @@ dependencia en todas las plataformas para algo que en Linux y Windows ya resuelv - El CI tiene un job `macos` en `platforms.yml` que compila la `.dylib`, pasa `rustfmt`, `clippy` y `just test-macos`. Corre en un PR solo si toca algo que solo compila macOS o que cambia cómo se compila allí, o lleva `ci-macos` o `ci-full`, y en `main` en la nocturna (ADR-0014). -- El `.dmg` no se publica en la entrega: `build.yml` no lo construye mientras los adaptadores de - firma estén pendientes, porque un paquete que no puede firmar no sirve a quien lo descarga. + La entrega (`build.yml`) construye el `.dmg` con firma ad hoc y el *hardened runtime* desactivado. - `lib.rs` genera el contexto de Tauri una sola vez en `run` y `event_loop.rs` lo construye: en macOS `generate_context!()` define 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 From 48bc41d3401ab71dc3bd4a03841d9f68abe5943a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20G=C3=B3mez=20Bachiller?= Date: Mon, 19 Oct 2026 16:10:15 +0200 Subject: [PATCH 07/22] =?UTF-8?q?feat(macos):=20rFirma=20sabe=20qui=C3=A9n?= =?UTF-8?q?=20abre=20afirma://=20en=20macOS=20y=20deja=20elegirse?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements #2017: rFirma sabe quién abre afirma:// en macOS y deja elegirse - LaunchServices tras la costura Workspace; NSWorkspace la implementa y se prueba a mano - Leer nunca escribe; retirar rFirma devuelve el esquema a otra aplicación que lo declare - Sale PendingMacosLaunchServices y su fila de la tabla del ADR-0040 --- ...n-apple-silicon-como-tercera-plataforma.md | 1 - rfirma-app/src-tauri/Cargo.toml | 4 +- .../src/desktop/adapters/registry.rs | 43 +---- .../adapters/registry/launch_services.rs | 145 +++++++++++++++ .../registry/launch_services/ns_workspace.rs | 73 ++++++++ .../registry/launch_services/tests.rs | 166 ++++++++++++++++++ 6 files changed, 393 insertions(+), 39 deletions(-) create mode 100644 rfirma-app/src-tauri/src/desktop/adapters/registry/launch_services.rs create mode 100644 rfirma-app/src-tauri/src/desktop/adapters/registry/launch_services/ns_workspace.rs create mode 100644 rfirma-app/src-tauri/src/desktop/adapters/registry/launch_services/tests.rs 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 57d353fbb..ad53ab701 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 @@ -16,7 +16,6 @@ como fallaría Linux sin el servicio y dice en su nombre qué falta. | `mlock` del secreto | `mlock`; `MADV_DONTDUMP` es solo de Linux. | | 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. | diff --git a/rfirma-app/src-tauri/Cargo.toml b/rfirma-app/src-tauri/Cargo.toml index c4a65cc14..9e3699fd0 100644 --- a/rfirma-app/src-tauri/Cargo.toml +++ b/rfirma-app/src-tauri/Cargo.toml @@ -228,8 +228,8 @@ openssl = { version = "0.10", features = ["vendored"] } tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "tls12"] } block2 = "0.6" objc2 = "0.6" -objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "block2", "NSAlert", "NSApplication", "NSButton", "NSControl", "NSResponder", "NSSecureTextField", "NSTextField", "NSView", "NSWindow"] } -objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSGeometry", "NSString"] } +objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "block2", "NSAlert", "NSApplication", "NSButton", "NSControl", "NSResponder", "NSSecureTextField", "NSTextField", "NSView", "NSWindow", "NSWorkspace"] } +objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSArray", "NSError", "NSFileManager", "NSGeometry", "NSString", "NSURL"] } [target.'cfg(not(windows))'.dev-dependencies] # La tarjeta falsa de las pruebas: no se compila en Windows y nunca llega a un paquete (ADR-0004). diff --git a/rfirma-app/src-tauri/src/desktop/adapters/registry.rs b/rfirma-app/src-tauri/src/desktop/adapters/registry.rs index 9f8eafa43..6da4282e1 100644 --- a/rfirma-app/src-tauri/src/desktop/adapters/registry.rs +++ b/rfirma-app/src-tauri/src/desktop/adapters/registry.rs @@ -1,7 +1,9 @@ -//! `DesktopRegistry`, el registro de manejadores detrás del puerto `HandlerRegistry`: canal, GIO y `mimeapps.list` en Linux, el registro en Windows y Launch Services, pendiente, en macOS. +//! `DesktopRegistry`, el registro de manejadores detrás del puerto `HandlerRegistry`: canal, GIO y `mimeapps.list` en Linux, el registro en Windows y LaunchServices en macOS. use std::path::PathBuf; +#[cfg(target_os = "macos")] +pub mod launch_services; #[cfg(windows)] pub mod windows_classes; @@ -12,7 +14,7 @@ use crate::desktop::adapters::choice::{ choose_handler_for_scheme, current_default_for_scheme, remove_handler_for_scheme, }; use crate::desktop::domain::error::DesktopError; -#[cfg(not(windows))] +#[cfg(target_os = "linux")] use crate::desktop::domain::error::Situation; use crate::desktop::domain::handlers::UrlHandler; use crate::desktop::ports::HandlerRegistry; @@ -88,45 +90,14 @@ pub fn this_desktop_to_write() -> Result, DesktopError> Ok(this_desktop()) } -/// Quién abre `afirma://` en este escritorio, para leerlo: Launch Services, aún pendiente. +/// Quién abre `afirma://` en este escritorio, para leerlo: LaunchServices. #[cfg(target_os = "macos")] pub fn this_desktop() -> Box { - Box::new(PendingMacosLaunchServices) + Box::new(launch_services::of_this_app()) } -/// Quién abre `afirma://` en este escritorio, para escribirlo: Launch Services, aún pendiente. +/// Quién abre `afirma://` en este escritorio, para escribirlo: LaunchServices. #[cfg(target_os = "macos")] pub fn this_desktop_to_write() -> Result, DesktopError> { Ok(this_desktop()) } - -/// El registro de manejadores sobre Launch Services de macOS, que aún no existe: ni se consulta ni se escribe. -#[cfg(target_os = "macos")] -pub struct PendingMacosLaunchServices; - -#[cfg(target_os = "macos")] -impl HandlerRegistry for PendingMacosLaunchServices { - fn registered_for(&self, _scheme: &str) -> Option> { - None - } - - fn current_default_for(&self, _scheme: &str) -> Option { - None - } - - fn choose_for(&self, _scheme: &str, _handler: &str) -> Result<(), DesktopError> { - Err(pending_macos_launch_services()) - } - - fn remove_for(&self, _scheme: &str) -> Result<(), DesktopError> { - Err(pending_macos_launch_services()) - } -} - -#[cfg(target_os = "macos")] -fn pending_macos_launch_services() -> DesktopError { - DesktopError::new( - Situation::TheListIsNotWritable, - "el registro de manejadores de macOS aún no está disponible", - ) -} diff --git a/rfirma-app/src-tauri/src/desktop/adapters/registry/launch_services.rs b/rfirma-app/src-tauri/src/desktop/adapters/registry/launch_services.rs new file mode 100644 index 000000000..77777ffd4 --- /dev/null +++ b/rfirma-app/src-tauri/src/desktop/adapters/registry/launch_services.rs @@ -0,0 +1,145 @@ +//! Quién abre un esquema de URL en macOS, según LaunchServices: lo lee siempre y solo lo escribe cuando la persona elige (ADR-0040). + +pub mod ns_workspace; + +use std::path::{Path, PathBuf}; + +use crate::desktop::domain::error::{DesktopError, Situation}; +use crate::desktop::domain::handlers::{UrlHandler, OUR_DESKTOP_FILE}; +use crate::desktop::ports::HandlerRegistry; + +/// Una aplicación que declara el esquema. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct InstalledApp { + pub path: PathBuf, + pub name: String, +} + +/// Lo que LaunchServices sabe de un esquema y cómo se le cambia la aplicación. +pub trait Workspace { + /// Las aplicaciones que declaran el esquema. + fn applications_for(&self, scheme: &str) -> Vec; + + /// La aplicación que lo abre hoy. + fn default_for(&self, scheme: &str) -> Option; + + /// Deja esa aplicación como la que lo abre. + fn set_default_for(&self, scheme: &str, app: &Path) -> Result<(), String>; +} + +/// El registro de manejadores sobre LaunchServices, con el `.app` que es rFirma. +pub struct LaunchServices { + workspace: W, + ours: PathBuf, +} + +impl LaunchServices { + /// El registro sobre `workspace`, con `ours` como rFirma. + pub fn new(workspace: W, ours: PathBuf) -> Self { + Self { workspace, ours } + } + + fn is_ours(&self, app: &Path) -> bool { + same_app(app, &self.ours) + } + + fn id_of(&self, app: &Path) -> String { + if self.is_ours(app) { + return OUR_DESKTOP_FILE.to_owned(); + } + app.display().to_string() + } + + fn app_of(&self, scheme: &str, handler: &str) -> Option { + if handler == OUR_DESKTOP_FILE { + return Some(self.ours.clone()); + } + self.workspace + .applications_for(scheme) + .into_iter() + .map(|app| app.path) + .find(|path| self.id_of(path) == handler) + } + + fn set_default(&self, scheme: &str, app: &Path) -> Result<(), DesktopError> { + self.workspace + .set_default_for(scheme, app) + .map_err(|reason| DesktopError::new(Situation::TheListIsNotWritable, reason)) + } +} + +impl HandlerRegistry for LaunchServices { + fn registered_for(&self, scheme: &str) -> Option> { + let mut handlers: Vec = Vec::new(); + for app in self.workspace.applications_for(scheme) { + let id = self.id_of(&app.path); + if !handlers.iter().any(|already| already.id == id) { + handlers.push(UrlHandler { id, name: app.name }); + } + } + Some(handlers) + } + + fn current_default_for(&self, scheme: &str) -> Option { + self.workspace + .default_for(scheme) + .map(|app| self.id_of(&app)) + } + + fn choose_for(&self, scheme: &str, handler: &str) -> Result<(), DesktopError> { + let app = self.app_of(scheme, handler).ok_or_else(|| { + DesktopError::new( + Situation::TheListIsNotWritable, + format!("«{handler}» no declara {scheme}://"), + ) + })?; + self.set_default(scheme, &app) + } + + /// LaunchServices no deja un esquema sin aplicación: si lo abre rFirma, pasa a la siguiente que lo declare. + fn remove_for(&self, scheme: &str) -> Result<(), DesktopError> { + if !self + .workspace + .default_for(scheme) + .is_some_and(|app| self.is_ours(&app)) + { + return Ok(()); + } + match self + .workspace + .applications_for(scheme) + .into_iter() + .find(|app| !self.is_ours(&app.path)) + { + Some(other) => self.set_default(scheme, &other.path), + None => Ok(()), + } + } +} + +fn same_app(app: &Path, ours: &Path) -> bool { + let canonical = |path: &Path| path.canonicalize().unwrap_or_else(|_| path.to_path_buf()); + !ours.as_os_str().is_empty() && canonical(app) == canonical(ours) +} + +/// El LaunchServices de la sesión, con el `.app` que está corriendo como rFirma. +pub fn of_this_app() -> LaunchServices { + let executable = std::env::current_exe().unwrap_or_default(); + LaunchServices::new(ns_workspace::SystemWorkspace, bundle_of(&executable)) +} + +/// El `.app` que contiene el ejecutable o, fuera de uno, el ejecutable. +pub fn bundle_of(executable: &Path) -> PathBuf { + executable + .ancestors() + .find(|folder| { + folder + .extension() + .is_some_and(|extension| extension == "app") + }) + .unwrap_or(executable) + .to_path_buf() +} + +#[cfg(test)] +mod tests; diff --git a/rfirma-app/src-tauri/src/desktop/adapters/registry/launch_services/ns_workspace.rs b/rfirma-app/src-tauri/src/desktop/adapters/registry/launch_services/ns_workspace.rs new file mode 100644 index 000000000..8062bb253 --- /dev/null +++ b/rfirma-app/src-tauri/src/desktop/adapters/registry/launch_services/ns_workspace.rs @@ -0,0 +1,73 @@ +//! `SystemWorkspace`, el `NSWorkspace` de AppKit detrás de `Workspace`: toca LaunchServices de verdad y se prueba a mano. + +use std::path::{Path, PathBuf}; +use std::sync::mpsc; +use std::time::Duration; + +use block2::RcBlock; +use objc2::rc::{autoreleasepool, Retained}; +use objc2_app_kit::NSWorkspace; +use objc2_foundation::{NSError, NSFileManager, NSString, NSURL}; + +use super::{InstalledApp, Workspace}; + +const THE_PERSON_CONFIRMS_WITHIN: Duration = Duration::from_secs(120); + +/// El LaunchServices de la sesión de la persona. +pub struct SystemWorkspace; + +impl Workspace for SystemWorkspace { + fn applications_for(&self, scheme: &str) -> Vec { + autoreleasepool(|_| { + let Some(url) = url_of(scheme) else { + return Vec::new(); + }; + let files = NSFileManager::defaultManager(); + NSWorkspace::sharedWorkspace() + .URLsForApplicationsToOpenURL(&url) + .iter() + .filter_map(|app| { + let path = app.path()?; + Some(InstalledApp { + name: files.displayNameAtPath(&path).to_string(), + path: PathBuf::from(path.to_string()), + }) + }) + .collect() + }) + } + + fn default_for(&self, scheme: &str) -> Option { + autoreleasepool(|_| { + let url = url_of(scheme)?; + let app = NSWorkspace::sharedWorkspace().URLForApplicationToOpenURL(&url)?; + Some(PathBuf::from(app.path()?.to_string())) + }) + } + + fn set_default_for(&self, scheme: &str, app: &Path) -> Result<(), String> { + let (sender, answer) = mpsc::channel(); + let done = RcBlock::new(move |error: *mut NSError| { + let outcome = unsafe { error.as_ref() }.map_or(Ok(()), |error| { + Err(error.localizedDescription().to_string()) + }); + let _ = sender.send(outcome); + }); + autoreleasepool(|_| { + let app = NSURL::fileURLWithPath(&NSString::from_str(&app.to_string_lossy())); + NSWorkspace::sharedWorkspace() + .setDefaultApplicationAtURL_toOpenURLsWithScheme_completionHandler( + &app, + &NSString::from_str(scheme), + Some(&done), + ); + }); + answer + .recv_timeout(THE_PERSON_CONFIRMS_WITHIN) + .map_err(|_| format!("macOS no ha confirmado quién abre {scheme}://"))? + } +} + +fn url_of(scheme: &str) -> Option> { + NSURL::URLWithString(&NSString::from_str(&format!("{scheme}://"))) +} diff --git a/rfirma-app/src-tauri/src/desktop/adapters/registry/launch_services/tests.rs b/rfirma-app/src-tauri/src/desktop/adapters/registry/launch_services/tests.rs new file mode 100644 index 000000000..0ea4f0502 --- /dev/null +++ b/rfirma-app/src-tauri/src/desktop/adapters/registry/launch_services/tests.rs @@ -0,0 +1,166 @@ +use std::cell::RefCell; +use std::path::{Path, PathBuf}; + +use super::*; +use crate::desktop::application::handlers::{chosen, who_handles, withdrawn, SCHEME}; +use crate::desktop::domain::handlers::{UrlHandler, OUR_DESKTOP_FILE}; +use crate::desktop::domain::withdrawal::Withdrawal; + +const RFIRMA: &str = "/Applications/rFirma.app"; +const AUTOFIRMA: &str = "/Applications/AutoFirma.app"; + +struct FakeWorkspace { + apps: Vec, + default: RefCell>, + written: RefCell>, +} + +impl FakeWorkspace { + fn with_both_installed_and_chosen(chosen: &str) -> Self { + Self { + apps: vec![an_app(RFIRMA, "rFirma"), an_app(AUTOFIRMA, "AutoFirma")], + default: RefCell::new(Some(PathBuf::from(chosen))), + written: RefCell::new(Vec::new()), + } + } + + fn written(&self) -> Vec { + self.written.borrow().clone() + } +} + +impl Workspace for &FakeWorkspace { + fn applications_for(&self, scheme: &str) -> Vec { + assert_eq!(scheme, SCHEME); + self.apps.clone() + } + + fn default_for(&self, scheme: &str) -> Option { + assert_eq!(scheme, SCHEME); + self.default.borrow().clone() + } + + fn set_default_for(&self, scheme: &str, app: &Path) -> Result<(), String> { + assert_eq!(scheme, SCHEME); + self.written.borrow_mut().push(app.to_path_buf()); + *self.default.borrow_mut() = Some(app.to_path_buf()); + Ok(()) + } +} + +fn an_app(path: &str, name: &str) -> InstalledApp { + InstalledApp { + path: PathBuf::from(path), + name: name.to_owned(), + } +} + +fn launch_services(workspace: &FakeWorkspace) -> LaunchServices<&FakeWorkspace> { + LaunchServices::new(workspace, PathBuf::from(RFIRMA)) +} + +#[test] +fn rfirma_is_the_bundle_that_holds_the_running_executable() { + assert_eq!( + bundle_of(Path::new("/Applications/rFirma.app/Contents/MacOS/rfirma")), + PathBuf::from(RFIRMA) + ); +} + +#[test] +fn outside_a_bundle_rfirma_is_the_executable_itself() { + let executable = Path::new("/Users/someone/rfirma/target/debug/rfirma"); + + assert_eq!(bundle_of(executable), executable); +} + +#[test] +fn with_autofirma_chosen_both_are_listed_and_autofirma_is_current() { + let workspace = FakeWorkspace::with_both_installed_and_chosen(AUTOFIRMA); + + let view = who_handles(&launch_services(&workspace)); + + assert!(view.available); + assert_eq!( + view.handlers, + vec![ + UrlHandler { + id: OUR_DESKTOP_FILE.to_owned(), + name: "rFirma".to_owned(), + }, + UrlHandler { + id: AUTOFIRMA.to_owned(), + name: "AutoFirma".to_owned(), + }, + ] + ); + assert_eq!(view.current.as_deref(), Some(AUTOFIRMA)); +} + +#[test] +fn reading_who_handles_the_scheme_never_takes_it() { + let workspace = FakeWorkspace::with_both_installed_and_chosen(AUTOFIRMA); + + who_handles(&launch_services(&workspace)); + + assert!(workspace.written().is_empty()); +} + +#[test] +fn choosing_rfirma_makes_it_the_one_that_opens_the_scheme() { + let workspace = FakeWorkspace::with_both_installed_and_chosen(AUTOFIRMA); + + chosen(&launch_services(&workspace), OUR_DESKTOP_FILE).expect("debería elegirse"); + + assert_eq!(workspace.written(), vec![PathBuf::from(RFIRMA)]); + assert_eq!( + who_handles(&launch_services(&workspace)).current.as_deref(), + Some(OUR_DESKTOP_FILE) + ); +} + +#[test] +fn choosing_autofirma_again_gives_the_scheme_back_to_it() { + let workspace = FakeWorkspace::with_both_installed_and_chosen(RFIRMA); + + chosen(&launch_services(&workspace), AUTOFIRMA).expect("debería elegirse"); + + assert_eq!( + who_handles(&launch_services(&workspace)).current.as_deref(), + Some(AUTOFIRMA) + ); +} + +#[test] +fn choosing_an_application_that_does_not_declare_the_scheme_fails_without_writing() { + let workspace = FakeWorkspace::with_both_installed_and_chosen(AUTOFIRMA); + + let failure = chosen(&launch_services(&workspace), "/Applications/Otra.app") + .expect_err("no declara el esquema"); + + assert!(!failure.detail().is_empty()); + assert!(workspace.written().is_empty()); +} + +#[test] +fn withdrawing_while_autofirma_is_chosen_leaves_it_untouched() { + let workspace = FakeWorkspace::with_both_installed_and_chosen(AUTOFIRMA); + + let outcome = withdrawn(&launch_services(&workspace)); + + assert_eq!(outcome, Withdrawal::WasNotThere); + assert!(workspace.written().is_empty()); +} + +#[test] +fn withdrawing_rfirma_hands_the_scheme_back_to_autofirma() { + let workspace = FakeWorkspace::with_both_installed_and_chosen(RFIRMA); + + let outcome = withdrawn(&launch_services(&workspace)); + + assert_eq!(outcome, Withdrawal::Withdrawn); + assert_eq!( + who_handles(&launch_services(&workspace)).current.as_deref(), + Some(AUTOFIRMA) + ); +} From 68f9af8aeddc2aa7fe29938c13833fd1c33334fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20G=C3=B3mez=20Bachiller?= Date: Mon, 19 Oct 2026 16:14:47 +0200 Subject: [PATCH 08/22] =?UTF-8?q?feat(identity):=20firmar=20con=20un=20cer?= =?UTF-8?q?tificado=20del=20Almac=C3=A9n=20de=20macOS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements #2005: Firmar con un certificado del Almacén de macOS - Rust resume y `SecKeyCreateSignature` firma el resumen: PKCS#1 v1.5, PSS y ECDSA en DER, con SHA-1 como rama explícita de la tabla de algoritmos. - Los errores de Security.framework, CryptoTokenKit y PC/SC se traducen por dominio y código. --- ...n-apple-silicon-como-tercera-plataforma.md | 2 +- .../src/identity/adapters/macos_store.rs | 13 +- .../identity/adapters/macos_store/keychain.rs | 121 +++++++++++- .../identity/adapters/macos_store/tests.rs | 184 +++++++++++++++++- 4 files changed, 305 insertions(+), 15 deletions(-) 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 6ea257ccf..43a9abd63 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 @@ -12,7 +12,7 @@ como fallaría Linux sin el servicio y dice en su nombre qué falta. | --- | --- | | OpenSSL del sistema | `openssl` con `vendored`, como en Windows: el binario no depende del de Homebrew y compila en cruzado. | | Canal TLS local con `native-tls` | rustls, como en Windows (ADR-0036). | -| Token PKCS#11 y sus almacenes | `MacosToken`: el llavero por Security.framework para el Almacén de macOS, que todavía no firma, y el `RealToken` de Linux para los módulos, que solo encuentra si `RFIRMA_PKCS11_MODULE` lo nombra. | +| Token PKCS#11 y sus almacenes | `MacosToken`: el llavero por Security.framework para el Almacén de macOS, que firma el resumen con la clave del llavero, y el `RealToken` de Linux para los módulos, que solo encuentra si `RFIRMA_PKCS11_MODULE` lo nombra. | | `mlock` del secreto | `mlock`; `MADV_DONTDUMP` es solo de Linux. | | Cerrojos (Firefox, carpeta de paso) | Los de Unix, sin cambios. | | Llavero `oo7` (ADR-0034) | `PendingMacosKeychain`: responde que no hay llavero. | diff --git a/rfirma-app/src-tauri/src/identity/adapters/macos_store.rs b/rfirma-app/src-tauri/src/identity/adapters/macos_store.rs index cb9838d3e..6e29409aa 100644 --- a/rfirma-app/src-tauri/src/identity/adapters/macos_store.rs +++ b/rfirma-app/src-tauri/src/identity/adapters/macos_store.rs @@ -7,7 +7,7 @@ use std::path::Path; use super::pkcs11::RealToken; use crate::identity::domain::algorithm::SignatureAlgorithm; use crate::identity::domain::certificate::{CertificateRef, TokenCertificate}; -use crate::identity::domain::error::{Situation, TokenError}; +use crate::identity::domain::error::TokenError; use crate::identity::domain::protected_secret::ProtectedSecret; use crate::identity::domain::secret::{PinWarning, StoreSecret}; use crate::identity::domain::store::{Store, StoreClass, MACOS_STORE_PREFIX}; @@ -44,13 +44,6 @@ fn stores_besides(pkcs11: Vec, module_named: bool) -> Vec { std::iter::once(user_store()).chain(pkcs11).collect() } -fn not_signing_yet() -> TokenError { - TokenError::new( - Situation::MechanismNotOffered, - "el Almacén de macOS todavía no firma", - ) -} - /// El adaptador de [`Token`] en macOS: el llavero para el Almacén de macOS y [`RealToken`] para los demás. #[derive(Clone, Copy, Debug, Default)] pub struct MacosToken; @@ -101,7 +94,7 @@ impl Token for MacosToken { algorithm: SignatureAlgorithm, ) -> Result<(), TokenError> { if is_a_keychain(&reference.store()) { - return Err(not_signing_yet()); + return keychain::offers(reference, algorithm); } RealToken.offers(reference, algorithm) } @@ -125,7 +118,7 @@ impl Token for MacosToken { data: &[u8], ) -> Result, TokenError> { if is_a_keychain(&reference.store()) { - return Err(not_signing_yet()); + return keychain::sign(reference, algorithm, data); } RealToken.sign_with_secret(reference, secret, algorithm, data) } diff --git a/rfirma-app/src-tauri/src/identity/adapters/macos_store/keychain.rs b/rfirma-app/src-tauri/src/identity/adapters/macos_store/keychain.rs index 0ec5f8d64..1bafb5d39 100644 --- a/rfirma-app/src-tauri/src/identity/adapters/macos_store/keychain.rs +++ b/rfirma-app/src-tauri/src/identity/adapters/macos_store/keychain.rs @@ -1,17 +1,20 @@ -//! El llavero de macOS leído con Security.framework: las identidades con su certificado y el token de su clave, sin abrirla (ADR-0001). +//! El llavero de macOS por Security.framework: las identidades se listan sin abrir su clave, y el resumen lo firma el sistema (ADR-0001). use core_foundation::base::{CFType, TCFType}; +use core_foundation::error::CFError; use core_foundation::string::CFString; use openssl::hash::{hash, MessageDigest}; use security_framework::certificate::SecCertificate; use security_framework::identity::SecIdentity; use security_framework::item::{ItemClass, ItemSearchOptions, Limit, Reference, SearchResult}; -use security_framework::key::SecKey; +use security_framework::key::{Algorithm, SecKey}; use security_framework::os::macos::keychain::SecKeychain; use security_framework_sys::item::kSecAttrTokenID; use super::SEARCH_LIST; +use crate::identity::domain::algorithm::SignatureAlgorithm; use crate::identity::domain::certificate::{CertificateRef, TokenCertificate}; +use crate::identity::domain::ecdsa; use crate::identity::domain::error::{Situation, TokenError}; use crate::identity::domain::store::{Store, MACOS_STORE_PREFIX}; @@ -56,6 +59,120 @@ pub fn every_certificate(store: &Store) -> Result, TokenEr .collect()) } +/// Comprueba que la clave del certificado es de la clase que exige el algoritmo. +pub fn offers(reference: &CertificateRef, algorithm: SignatureAlgorithm) -> Result<(), TokenError> { + let (certificate, _) = identity_of(reference)?; + if certificate.key_kind() == Some(algorithm.key_kind()) { + return Ok(()); + } + Err(TokenError::new( + Situation::MechanismNotOffered, + format!( + "la clave de {} no sirve para {}", + reference.label(), + algorithm.name() + ), + )) +} + +/// Resume `data` y firma el resumen con la clave del certificado; macOS pide permiso o el PIN si hace falta. +pub fn sign( + reference: &CertificateRef, + algorithm: SignatureAlgorithm, + data: &[u8], +) -> Result, TokenError> { + let (_, identity) = identity_of(reference)?; + let key = identity.private_key().map_err(|error| failure(&error))?; + let digest = ecdsa::digest(algorithm, data)?; + key.create_signature(over_a_digest(algorithm), &digest) + .map_err(|error| refused(&error)) +} + +/// La situación del catálogo que corresponde a un error de Security.framework, CryptoTokenKit o PC/SC. +pub fn situation_of(domain: &str, code: i64) -> Situation { + match (domain, code as i32) { + (OS_STATUS_DOMAIN, -128) | (TOKEN_DOMAIN, -4) => Situation::PinEntryCancelled, + (OS_STATUS_DOMAIN, -25293 | WRONG_CHV) | (TOKEN_DOMAIN, -5) => Situation::IncorrectPin, + (OS_STATUS_DOMAIN, CHV_BLOCKED) => Situation::PinLocked, + (OS_STATUS_DOMAIN, NO_SMARTCARD | REMOVED_CARD) | (TOKEN_DOMAIN, -7) => { + Situation::TokenAbsent + } + (OS_STATUS_DOMAIN, ITEM_NOT_FOUND) | (TOKEN_DOMAIN, -6) => Situation::CertificateNotFound, + _ => Situation::Unknown, + } +} + +const OS_STATUS_DOMAIN: &str = "NSOSStatusErrorDomain"; +const TOKEN_DOMAIN: &str = "CryptoTokenKit"; +const WRONG_CHV: i32 = 0x8010_006B_u32 as i32; +const CHV_BLOCKED: i32 = 0x8010_006C_u32 as i32; +const NO_SMARTCARD: i32 = 0x8010_000C_u32 as i32; +const REMOVED_CARD: i32 = 0x8010_0069_u32 as i32; + +fn refused(error: &CFError) -> TokenError { + let domain = error.domain().to_string(); + let situation = situation_of(&domain, error.code() as i64); + let detail = if situation == Situation::PinEntryCancelled { + format!( + "has cancelado la petición de macOS ({domain} {})", + error.code() + ) + } else { + format!( + "macOS no ha firmado ({domain} {}): {}", + error.code(), + error.description() + ) + }; + TokenError::new(situation, detail) +} + +/// El algoritmo de Security.framework que firma un resumen ya calculado. +fn over_a_digest(algorithm: SignatureAlgorithm) -> Algorithm { + match algorithm { + SignatureAlgorithm::Sha1Rsa => Algorithm::RSASignatureDigestPKCS1v15SHA1, + SignatureAlgorithm::Sha256Rsa => Algorithm::RSASignatureDigestPKCS1v15SHA256, + SignatureAlgorithm::Sha384Rsa => Algorithm::RSASignatureDigestPKCS1v15SHA384, + SignatureAlgorithm::Sha512Rsa => Algorithm::RSASignatureDigestPKCS1v15SHA512, + SignatureAlgorithm::Sha256RsaPss => Algorithm::RSASignatureDigestPSSSHA256, + SignatureAlgorithm::Sha384RsaPss => Algorithm::RSASignatureDigestPSSSHA384, + SignatureAlgorithm::Sha512RsaPss => Algorithm::RSASignatureDigestPSSSHA512, + SignatureAlgorithm::Sha1Ecdsa => Algorithm::ECDSASignatureDigestX962SHA1, + SignatureAlgorithm::Sha256Ecdsa => Algorithm::ECDSASignatureDigestX962SHA256, + SignatureAlgorithm::Sha384Ecdsa => Algorithm::ECDSASignatureDigestX962SHA384, + SignatureAlgorithm::Sha512Ecdsa => Algorithm::ECDSASignatureDigestX962SHA512, + } +} + +/// La identidad del llavero cuyo certificado tiene la huella de `reference`. +fn identity_of(reference: &CertificateRef) -> Result<(TokenCertificate, SecIdentity), TokenError> { + let not_there = || { + TokenError::new( + Situation::CertificateNotFound, + format!("el llavero ya no tiene {}", reference.label()), + ) + }; + let thumbprint = reference.cka_id().ok_or_else(not_there)?; + let store = reference.store(); + let found = { + let _no_prompt = + SecKeychain::disable_user_interaction().map_err(|error| failure(&error))?; + found(ItemClass::identity(), &scope_of(&store)?)? + }; + found + .into_iter() + .find_map(|reference| match reference { + Reference::Identity(identity) => { + let der = identity.certificate().ok()?.to_der(); + let certificate = certificate_in(&store, &der, None); + (certificate.reference().cka_id() == Some(thumbprint)) + .then_some((certificate, identity)) + } + _ => None, + }) + .ok_or_else(not_there) +} + /// Los llaveros donde se busca: ninguno en concreto es la lista de búsqueda de la persona usuaria. fn scope_of(store: &Store) -> Result>, TokenError> { let named = store diff --git a/rfirma-app/src-tauri/src/identity/adapters/macos_store/tests.rs b/rfirma-app/src-tauri/src/identity/adapters/macos_store/tests.rs index f924313ce..78a8623a0 100644 --- a/rfirma-app/src-tauri/src/identity/adapters/macos_store/tests.rs +++ b/rfirma-app/src-tauri/src/identity/adapters/macos_store/tests.rs @@ -8,16 +8,21 @@ use openssl::hash::MessageDigest; use openssl::nid::Nid; use openssl::pkcs12::Pkcs12; use openssl::pkey::{PKey, Private}; -use openssl::rsa::Rsa; +use openssl::rsa::{Padding as RsaPadding, Rsa}; +use openssl::sign::{RsaPssSaltlen, Verifier}; use openssl::x509::extension::BasicConstraints; use openssl::x509::{X509Name, X509}; use security_framework::certificate::SecCertificate; use security_framework::import_export::Pkcs12ImportOptions; use security_framework::os::macos::keychain::{CreateOptions, SecKeychain}; +use super::keychain::situation_of; use super::{keychain_store, stores_besides, user_store, MacosToken}; use crate::identity::application::certificates::certificates_with_their_chains; -use crate::identity::domain::certificate::TokenCertificate; +use crate::identity::domain::algorithm::{KeyKind, SignatureAlgorithm}; +use crate::identity::domain::certificate::{CertificateRef, TokenCertificate}; +use crate::identity::domain::error::Situation; +use crate::identity::domain::protected_secret::ProtectedSecret; use crate::identity::domain::secret::{PinWarning, StoreSecret}; use crate::identity::domain::store::{Store, StoreClass}; use crate::identity::ports::Token; @@ -78,6 +83,13 @@ impl TemporaryKeychain { .expect("el .p12 deberia entrar en el llavero de pruebas"); } + fn holding(key: &PKey) -> (Self, TokenCertificate) { + let keychain = Self::create(); + keychain.holds_an_identity(&a_certificate("FIRMANTE", key, None, false), key); + let listed = keychain.listed().remove(0); + (keychain, listed) + } + fn holds_a_certificate(&self, certificate: &X509) { SecCertificate::from_der(&der_of(certificate)) .and_then(|certificate| certificate.add_to_keychain(Some(self.keychain.clone()))) @@ -283,3 +295,171 @@ fn common_name_of(der: &[u8]) -> String { .to_string() .expect("el nombre comun deberia leerse") } + +const DATA: &[u8] = b"lo que Java manda firmar"; + +fn signed(certificate: &TokenCertificate, algorithm: SignatureAlgorithm) -> Vec { + MacosToken + .sign_with_secret( + certificate.reference(), + &ProtectedSecret::new(b""), + algorithm, + DATA, + ) + .expect("el llavero deberia firmar") +} + +fn verifies( + certificate: &TokenCertificate, + algorithm: SignatureAlgorithm, + signature: &[u8], +) -> bool { + let key = X509::from_der(certificate.der()) + .and_then(|certificate| certificate.public_key()) + .expect("el certificado deberia traer su clave publica"); + let digest = match algorithm { + SignatureAlgorithm::Sha1Rsa | SignatureAlgorithm::Sha1Ecdsa => MessageDigest::sha1(), + SignatureAlgorithm::Sha384Rsa + | SignatureAlgorithm::Sha384RsaPss + | SignatureAlgorithm::Sha384Ecdsa => MessageDigest::sha384(), + SignatureAlgorithm::Sha512Rsa + | SignatureAlgorithm::Sha512RsaPss + | SignatureAlgorithm::Sha512Ecdsa => MessageDigest::sha512(), + _ => MessageDigest::sha256(), + }; + let mut verifier = Verifier::new(digest, &key).expect("verificador"); + if algorithm.name().ends_with("MGF1") { + verifier.set_rsa_padding(RsaPadding::PKCS1_PSS).unwrap(); + verifier + .set_rsa_pss_saltlen(RsaPssSaltlen::DIGEST_LENGTH) + .unwrap(); + verifier.set_rsa_mgf1_md(digest).unwrap(); + } + verifier.verify_oneshot(signature, DATA).unwrap_or(false) +} + +fn algorithms_for(kind: KeyKind) -> Vec { + SignatureAlgorithm::ALL + .into_iter() + .filter(|algorithm| algorithm.key_kind() == kind) + .collect() +} + +#[test] +fn signs_with_rsa_pkcs1_and_pss_through_the_keychain_with_every_hash_sha1_included() { + let (_keychain, certificate) = TemporaryKeychain::holding(&an_rsa_key()); + + for algorithm in algorithms_for(KeyKind::Rsa) { + let signature = signed(&certificate, algorithm); + + assert!( + verifies(&certificate, algorithm, &signature), + "{algorithm:?}" + ); + } +} + +#[test] +fn signs_with_ecdsa_through_the_keychain_in_der_with_every_hash_sha1_included() { + let (_keychain, certificate) = TemporaryKeychain::holding(&an_elliptic_curve_key()); + + for algorithm in algorithms_for(KeyKind::Ec) { + let signature = signed(&certificate, algorithm); + + assert_eq!(signature[0], 0x30, "ECDSA viaja en DER, no en r||s"); + assert!( + verifies(&certificate, algorithm, &signature), + "{algorithm:?}" + ); + } +} + +#[test] +fn a_keychain_key_offers_only_the_algorithms_of_its_kind() { + let (_keychain, certificate) = TemporaryKeychain::holding(&an_rsa_key()); + + assert_eq!( + MacosToken.offers(certificate.reference(), SignatureAlgorithm::Sha256RsaPss), + Ok(()) + ); + assert_eq!( + MacosToken + .offers(certificate.reference(), SignatureAlgorithm::Sha256Ecdsa) + .map_err(|error| error.situation()), + Err(Situation::MechanismNotOffered) + ); +} + +#[test] +fn a_certificate_gone_from_the_keychain_is_not_found_when_signing() { + let (keychain, certificate) = TemporaryKeychain::holding(&an_rsa_key()); + let other = TemporaryKeychain::create(); + let moved = CertificateRef::new( + keychain_store(&other.path), + certificate.reference().token_label(), + certificate.reference().label(), + certificate + .reference() + .cka_id() + .unwrap_or_default() + .to_vec(), + ); + drop(keychain); + + assert_eq!( + MacosToken + .sign_with_secret( + &moved, + &ProtectedSecret::new(b""), + SignatureAlgorithm::Sha256Rsa, + DATA + ) + .map_err(|error| error.situation()), + Err(Situation::CertificateNotFound) + ); +} + +const OS_STATUS: &str = "NSOSStatusErrorDomain"; +const CRYPTO_TOKEN_KIT: &str = "CryptoTokenKit"; + +#[test] +fn denying_the_macos_dialog_is_the_person_cancelling() { + assert_eq!(situation_of(OS_STATUS, -128), Situation::PinEntryCancelled); + assert_eq!( + situation_of(CRYPTO_TOKEN_KIT, -4), + Situation::PinEntryCancelled + ); +} + +#[test] +fn system_errors_map_to_their_situations() { + assert_eq!(situation_of(OS_STATUS, -25293), Situation::IncorrectPin); + assert_eq!(situation_of(CRYPTO_TOKEN_KIT, -5), Situation::IncorrectPin); + assert_eq!( + situation_of(OS_STATUS, 0x8010_006B_u32 as i64), + Situation::IncorrectPin + ); + assert_eq!( + situation_of(OS_STATUS, 0x8010_006C_u32 as i64), + Situation::PinLocked + ); + assert_eq!(situation_of(CRYPTO_TOKEN_KIT, -7), Situation::TokenAbsent); + assert_eq!( + situation_of(OS_STATUS, 0x8010_0069_u32 as i64), + Situation::TokenAbsent + ); + assert_eq!( + situation_of(OS_STATUS, 0x8010_000C_u32 as i64), + Situation::TokenAbsent + ); + assert_eq!( + situation_of(OS_STATUS, -25300), + Situation::CertificateNotFound + ); + assert_eq!( + situation_of(CRYPTO_TOKEN_KIT, -6), + Situation::CertificateNotFound + ); + assert_eq!(situation_of(OS_STATUS, -4), Situation::Unknown); + assert_eq!(situation_of("NSCocoaErrorDomain", -128), Situation::Unknown); +} From 1c9023d904a5f395c9981e196413c4975cbbe379 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20G=C3=B3mez=20Bachiller?= Date: Mon, 19 Oct 2026 16:16:31 +0200 Subject: [PATCH 09/22] =?UTF-8?q?fix(macos):=20el=20bloque=20inseguro=20de?= =?UTF-8?q?l=20aviso=20de=20LaunchServices=20dice=20por=20qu=C3=A9=20es=20?= =?UTF-8?q?seguro?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../desktop/adapters/registry/launch_services/ns_workspace.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/rfirma-app/src-tauri/src/desktop/adapters/registry/launch_services/ns_workspace.rs b/rfirma-app/src-tauri/src/desktop/adapters/registry/launch_services/ns_workspace.rs index 8062bb253..e39d2c78f 100644 --- a/rfirma-app/src-tauri/src/desktop/adapters/registry/launch_services/ns_workspace.rs +++ b/rfirma-app/src-tauri/src/desktop/adapters/registry/launch_services/ns_workspace.rs @@ -48,6 +48,7 @@ impl Workspace for SystemWorkspace { fn set_default_for(&self, scheme: &str, app: &Path) -> Result<(), String> { let (sender, answer) = mpsc::channel(); let done = RcBlock::new(move |error: *mut NSError| { + // SAFETY: AppKit pasa un `NSError` vivo mientras dura la llamada, o nulo si fue bien. let outcome = unsafe { error.as_ref() }.map_or(Ok(()), |error| { Err(error.localizedDescription().to_string()) }); From 1bd243e0b07481682ee5f4eb8c04130ba82d948a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20G=C3=B3mez=20Bachiller?= Date: Mon, 19 Oct 2026 16:22:58 +0200 Subject: [PATCH 10/22] =?UTF-8?q?feat(sede):=20APPLE=20y=20-store=20apple?= =?UTF-8?q?=20acotan=20al=20Almac=C3=A9n=20de=20macOS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements #2006: APPLE y -store apple acotan al Almacén de macOS - Solo en macOS; en Linux y Windows siguen respondiendo SAF_08 y almacén desconocido --- .../command_line/tests/platform.rs | 48 +++++++++++++++---- .../src/desktop/application/store_scope.rs | 4 ++ .../src/desktop/domain/store_scope.rs | 4 ++ .../src/desktop/domain/store_scope/tests.rs | 22 +++++++++ .../src-tauri/src/site/adapters/codec.rs | 30 ++++++++++-- .../src/site/adapters/codec/tests.rs | 26 ++++++++++ .../src-tauri/src/site/adapters/codec_v3.rs | 7 ++- .../src/site/application/filtering.rs | 4 ++ .../src/site/application/filtering/tests.rs | 31 ++++++++++++ .../src/site/domain/protocol/key_store.rs | 26 +++++++++- .../site/domain/protocol/key_store/tests.rs | 42 ++++++++++++++-- .../src-tauri/src/site/domain/protocol/mod.rs | 2 +- 12 files changed, 227 insertions(+), 19 deletions(-) diff --git a/rfirma-app/src-tauri/src/desktop/application/command_line/tests/platform.rs b/rfirma-app/src-tauri/src/desktop/application/command_line/tests/platform.rs index 28a2ebc8a..19108d874 100644 --- a/rfirma-app/src-tauri/src/desktop/application/command_line/tests/platform.rs +++ b/rfirma-app/src-tauri/src/desktop/application/command_line/tests/platform.rs @@ -15,15 +15,19 @@ impl CardAndWindows { impl CertificateStores for CardAndWindows { fn certificates(&self) -> Result, TokenError> { self.opened.set(true); - Ok([("cng:CurrentUser", "WIN"), ("/modulo.so", "TARJETA")] - .into_iter() - .map(|(module, label)| { - TokenCertificate::new( - CertificateRef::new(Store::module(module), "token", label, None), - Vec::new(), - ) - }) - .collect()) + Ok([ + ("cng:CurrentUser", "WIN"), + ("/modulo.so", "TARJETA"), + ("keychain:login", "MAC"), + ] + .into_iter() + .map(|(module, label)| { + TokenCertificate::new( + CertificateRef::new(Store::module(module), "token", label, None), + Vec::new(), + ) + }) + .collect()) } fn discovered_module(&self, library: &str) -> Option { @@ -178,3 +182,29 @@ fn the_password_is_refused_in_any_position_with_a_message_naming_password_fd() { assert!(!said(&outcome).contains("1234"), "nunca repite el secreto"); } } + +#[test] +fn on_macos_store_apple_narrows_to_the_macos_store() { + let outcome = listed_on( + Platform::MacOs, + &["listaliases", "-store", "apple"], + &CardAndWindows::new(), + ); + + assert_eq!(outcome.exit_code, SUCCEEDED); + assert_eq!(outcome.stdout, b"MAC\n"); +} + +#[test] +fn on_linux_store_apple_is_refused_without_opening_any_store() { + let stores = CardAndWindows::new(); + + let outcome = listed_on( + Platform::Linux, + &["listaliases", "-store", "apple"], + &stores, + ); + + assert_eq!(outcome.exit_code, REFUSED); + assert!(!stores.opened.get()); +} diff --git a/rfirma-app/src-tauri/src/desktop/application/store_scope.rs b/rfirma-app/src-tauri/src/desktop/application/store_scope.rs index 15a51fb53..17071e9c9 100644 --- a/rfirma-app/src-tauri/src/desktop/application/store_scope.rs +++ b/rfirma-app/src-tauri/src/desktop/application/store_scope.rs @@ -28,6 +28,10 @@ pub fn within_the_scope( .into_iter() .filter(|certificate| certificate.reference().store().class() == StoreClass::Windows) .collect(), + (StoreScope::Macos, _) => certificates + .into_iter() + .filter(|certificate| certificate.reference().store().class() == StoreClass::Macos) + .collect(), (_, Some(module)) => certificates .into_iter() .filter(|certificate| certificate.reference().module() == module) diff --git a/rfirma-app/src-tauri/src/desktop/domain/store_scope.rs b/rfirma-app/src-tauri/src/desktop/domain/store_scope.rs index df69d2318..5ef96ae3a 100644 --- a/rfirma-app/src-tauri/src/desktop/domain/store_scope.rs +++ b/rfirma-app/src-tauri/src/desktop/domain/store_scope.rs @@ -10,6 +10,7 @@ const AUTO: &str = "auto"; const MOZILLA: &str = "mozilla"; const PKCS11: &str = "pkcs11"; const WINDOWS: &str = "windows"; +const APPLE: &str = "apple"; const NOT_SUPPORTED: [&str; 5] = ["pkcs12", "dni", "dnie", WINDOWS, "mac"]; const NOT_SUPPORTED_ON_WINDOWS: [&str; 4] = ["pkcs12", "dni", "dnie", "mac"]; @@ -23,6 +24,8 @@ pub enum StoreScope { Nss, /// Solo en el almacén de Windows. Windows, + /// Solo en el Almacén de macOS. + Macos, /// Solo en este módulo PKCS#11, si es uno ya descubierto. Module(String), } @@ -82,6 +85,7 @@ fn scope_of(value: &str, platform: Platform) -> Result let lowered = name.trim().to_ascii_lowercase(); let on_windows = platform == Platform::Windows; match (lowered.as_str(), library) { + (APPLE, None) if platform == Platform::MacOs => Ok(StoreScope::Macos), (AUTO | WINDOWS, None) if on_windows => Ok(StoreScope::Windows), (AUTO | MOZILLA, None) if !on_windows => Ok(StoreScope::Nss), (PKCS11, Some(library)) if !library.is_empty() => { diff --git a/rfirma-app/src-tauri/src/desktop/domain/store_scope/tests.rs b/rfirma-app/src-tauri/src/desktop/domain/store_scope/tests.rs index 5c7f88c79..fd0585698 100644 --- a/rfirma-app/src-tauri/src/desktop/domain/store_scope/tests.rs +++ b/rfirma-app/src-tauri/src/desktop/domain/store_scope/tests.rs @@ -118,3 +118,25 @@ fn store_without_a_value_is_refused() { Err(StoreRefusal::Missing) ); } + +#[test] +fn on_macos_apple_narrows_to_the_macos_store_in_any_case() { + for name in ["apple", "Apple", "APPLE"] { + assert_eq!( + scope_of_words_on(Platform::MacOs, &["listaliases", "-store", name]), + Ok(StoreScope::Macos), + "{name}" + ); + } +} + +#[test] +fn outside_macos_apple_is_still_an_unknown_store() { + for platform in [Platform::Linux, Platform::Windows] { + assert_eq!( + scope_of_words_on(platform, &["listaliases", "-store", "apple"]), + Err(StoreRefusal::Unknown("apple".to_owned())), + "{platform:?}" + ); + } +} diff --git a/rfirma-app/src-tauri/src/site/adapters/codec.rs b/rfirma-app/src-tauri/src/site/adapters/codec.rs index 0f2528418..dfa3cccf0 100644 --- a/rfirma-app/src-tauri/src/site/adapters/codec.rs +++ b/rfirma-app/src-tauri/src/site/adapters/codec.rs @@ -3,10 +3,11 @@ use base64::engine::general_purpose::STANDARD; use base64::Engine as _; +use crate::desktop::domain::platform::Platform; use crate::site::domain::batch::parse_local_batch; use crate::site::domain::protocol::{ - read_operation, AfirmaUrl, BatchRequest, Refusal, SiteOperation, WireAnswer, - THIRD_PROTOCOL_VERSION, + read_operation, refuse_the_macos_store, AfirmaUrl, BatchRequest, Refusal, SiteOperation, + WireAnswer, THIRD_PROTOCOL_VERSION, }; use crate::site::adapters::data_download::HttpDataSource; @@ -24,7 +25,10 @@ pub struct V4Codec; impl ProtocolCodec for V4Codec { fn decode(&self, message: &AfirmaUrl) -> SiteRequest { - request_of(read_operation(message, &HttpDataSource)) + request_of(on_this_host( + message, + read_operation(message, &HttpDataSource), + )) } fn encode(&self, outcome: &SiteOutcome) -> String { @@ -72,6 +76,26 @@ impl ProtocolCodec for V4Codec { } /// La petición que el trámite atiende a partir de la operación leída. +/// La lectura de la operación, con `APPLE` rechazado donde no hay Almacén de macOS. +pub(super) fn on_this_host( + url: &AfirmaUrl, + read: Result, +) -> Result { + on_host(url, read, Platform::CURRENT == Platform::MacOs) +} + +fn on_host( + url: &AfirmaUrl, + read: Result, + on_macos: bool, +) -> Result { + let operation = read?; + if !on_macos && operation.chooses_a_certificate() { + refuse_the_macos_store(url)?; + } + Ok(operation) +} + pub(super) fn request_of(read: Result) -> SiteRequest { match read { Ok(SiteOperation::SelectCertificate(request)) => SiteRequest::SelectCertificate(request), diff --git a/rfirma-app/src-tauri/src/site/adapters/codec/tests.rs b/rfirma-app/src-tauri/src/site/adapters/codec/tests.rs index 798822b32..67488d5b9 100644 --- a/rfirma-app/src-tauri/src/site/adapters/codec/tests.rs +++ b/rfirma-app/src-tauri/src/site/adapters/codec/tests.rs @@ -207,3 +207,29 @@ fn the_cancellation_and_the_refusals_go_out_as_the_catalogue_writes_them() { "el detalle no sale" ); } + +fn apple_selection() -> AfirmaUrl { + an_operation(&format!( + "afirma://selectcert?op=selectcert&idsession={CREDENTIAL}&keystore=APPLE" + )) +} + +#[test] +fn off_macos_a_site_that_names_apple_is_answered_with_saf_08() { + let url = apple_selection(); + + let refusal = on_host(&url, read_operation(&url, &HttpDataSource), false) + .expect_err("no hay Almacen de macOS"); + + assert!(refusal.code().as_str() == "SAF_08"); +} + +#[test] +fn on_macos_a_site_that_names_apple_is_attended_over_the_macos_store() { + let url = apple_selection(); + + let operation = on_host(&url, read_operation(&url, &HttpDataSource), true) + .expect("el Almacen de macOS lo abre rFirma"); + + assert!(matches!(operation, SiteOperation::SelectCertificate(_))); +} diff --git a/rfirma-app/src-tauri/src/site/adapters/codec_v3.rs b/rfirma-app/src-tauri/src/site/adapters/codec_v3.rs index 93e47729c..9e4b87d6b 100644 --- a/rfirma-app/src-tauri/src/site/adapters/codec_v3.rs +++ b/rfirma-app/src-tauri/src/site/adapters/codec_v3.rs @@ -5,7 +5,7 @@ use crate::site::domain::protocol::{read_operation_within_the_protocol, AfirmaUr use crate::site::adapters::data_download::HttpDataSource; use crate::site::application::errand::{ProtocolCodec, SiteOutcome, SiteRequest}; -use super::codec::{request_of, V4Codec}; +use super::codec::{on_this_host, request_of, V4Codec}; /// Códec de la versión 3 del protocolo de comunicación con la sede: el catálogo y la forma /// de respuesta de la versión 4, pero cada operación se somete a la versión que declara. @@ -14,7 +14,10 @@ pub struct V3Codec; impl ProtocolCodec for V3Codec { fn decode(&self, message: &AfirmaUrl) -> SiteRequest { - request_of(read_operation_within_the_protocol(message, &HttpDataSource)) + request_of(on_this_host( + message, + read_operation_within_the_protocol(message, &HttpDataSource), + )) } fn encode(&self, outcome: &SiteOutcome) -> String { diff --git a/rfirma-app/src-tauri/src/site/application/filtering.rs b/rfirma-app/src-tauri/src/site/application/filtering.rs index 8f44c78e1..1b4b3968d 100644 --- a/rfirma-app/src-tauri/src/site/application/filtering.rs +++ b/rfirma-app/src-tauri/src/site/application/filtering.rs @@ -96,6 +96,10 @@ fn within_the_scope( .into_iter() .filter(|certificate| certificate.reference().store().class() != StoreClass::Card) .collect()), + StoreScope::Macos => Ok(certificates + .into_iter() + .filter(|certificate| certificate.reference().store().class() == StoreClass::Macos) + .collect()), StoreScope::Module(named) => { let module = directory .discovered_module(named) diff --git a/rfirma-app/src-tauri/src/site/application/filtering/tests.rs b/rfirma-app/src-tauri/src/site/application/filtering/tests.rs index c48660b82..3d132eccb 100644 --- a/rfirma-app/src-tauri/src/site/application/filtering/tests.rs +++ b/rfirma-app/src-tauri/src/site/application/filtering/tests.rs @@ -82,6 +82,13 @@ fn an_nss_certificate(label: &str, der: &[u8]) -> TokenCertificate { ) } +fn a_macos_certificate(label: &str, der: &[u8]) -> TokenCertificate { + TokenCertificate::new( + CertificateRef::new("keychain:login", "llavero", label, vec![0x04]), + der.to_vec(), + ) +} + #[test] fn the_expression_and_the_listing_reach_the_engine_untouched() { let engine = AnEngine::answering(&[0]); @@ -420,3 +427,27 @@ fn a_certificate_inside_the_module_the_site_names_is_usable() { assert_eq!(chosen.reference().label(), "TARJETA"); } + +#[test] +fn a_site_that_names_apple_only_sees_the_certificates_of_the_macos_store() { + let engine = AnEngine::answering(&[0]); + let certificates = vec![ + a_certificate("TOKEN", &[0x01]), + an_nss_certificate("NSS", &[0x03]), + a_card_certificate("TARJETA", &[0x02]), + a_macos_certificate("LLAVERO", &[0x04]), + ]; + let listed = ListedCertificates::new(); + + let kept = keep_what_the_site_accepts( + &engine, + &SiteFilter::default().within(StoreScope::Macos), + certificates.clone(), + &a_directory(&certificates, &listed), + ) + .expect("el Almacen de macOS lo abre rFirma"); + + assert_eq!(kept.len(), 1); + assert_eq!(kept[0].reference().label(), "LLAVERO"); + assert_eq!(engine.asked.borrow()[0].1, "BA=="); +} diff --git a/rfirma-app/src-tauri/src/site/domain/protocol/key_store.rs b/rfirma-app/src-tauri/src/site/domain/protocol/key_store.rs index 3bea947b9..521074110 100644 --- a/rfirma-app/src-tauri/src/site/domain/protocol/key_store.rs +++ b/rfirma-app/src-tauri/src/site/domain/protocol/key_store.rs @@ -14,6 +14,9 @@ const THE_NSS_STORES: [KeyStoreOfTheOriginal; 2] = [ ("Mozilla / Firefox (unificado)", "MOZ_UNI"), ]; +/// Los dos nombres del Almacén de macOS, que solo en macOS rFirma abre. +const THE_MACOS_STORE: [KeyStoreOfTheOriginal; 1] = [("Llavero de Mac", "APPLE")]; + /// Los dos nombres del almacén PKCS#11, el único que admite biblioteca, y solo como acotación. const THE_PKCS11_STORE: [KeyStoreOfTheOriginal; 1] = [("PKCS#11", "PKCS11")]; @@ -98,6 +101,8 @@ pub enum StoreScope { Everywhere, /// Solo los de la familia NSS. Nss, + /// Solo el Almacén de macOS. + Macos, /// Solo el módulo PKCS#11 de esta biblioteca, tal y como vino. Module(String), } @@ -110,6 +115,7 @@ pub fn scope_named_by(url: &AfirmaUrl) -> StoreScope { match named.library { Some(library) if named_among(&named.name, &THE_PKCS11_STORE) => StoreScope::Module(library), None if named_among(&named.name, &THE_NSS_STORES) => StoreScope::Nss, + None if named_among(&named.name, &THE_MACOS_STORE) => StoreScope::Macos, _ => StoreScope::Everywhere, } } @@ -146,10 +152,28 @@ fn refused(blame: Parameter, detail: String) -> Refusal { .because(RefusalSituation::UnsupportedKeyStore) } +/// El `SAF_08` de `APPLE` fuera de macOS, donde no hay Almacén de macOS que abrir. +pub fn refuse_the_macos_store(url: &AfirmaUrl) -> Result<(), Refusal> { + match key_store_named_by(url) { + Some(named) if named.library().is_none() && named_among(named.name(), &THE_MACOS_STORE) => { + Err(refused( + named.declared_in(), + format!( + "rFirma no abre el almacen '{}' que nombra la sede", + named.name() + ), + )) + } + _ => Ok(()), + } +} + /// Un nombre del original que no es el de la familia NSS; uno que el original tampoco reconoce /// no lo es, porque allí acaba en el almacén por omisión del sistema. fn is_a_store_rfirma_does_not_open(name: &str) -> bool { - named_among(name, &THE_STORES_OF_THE_ORIGINAL) && !named_among(name, &THE_NSS_STORES) + named_among(name, &THE_STORES_OF_THE_ORIGINAL) + && !named_among(name, &THE_NSS_STORES) + && !named_among(name, &THE_MACOS_STORE) } /// El original mira antes el nombre visible, y ya recortado (ADR-0022). diff --git a/rfirma-app/src-tauri/src/site/domain/protocol/key_store/tests.rs b/rfirma-app/src-tauri/src/site/domain/protocol/key_store/tests.rs index 4a88984be..19ecce660 100644 --- a/rfirma-app/src-tauri/src/site/domain/protocol/key_store/tests.rs +++ b/rfirma-app/src-tauri/src/site/domain/protocol/key_store/tests.rs @@ -1,5 +1,6 @@ use super::{ - key_store_named_by, refuse_a_key_store_rfirma_does_not_open, scope_named_by, StoreScope, + key_store_named_by, refuse_a_key_store_rfirma_does_not_open, refuse_the_macos_store, + scope_named_by, StoreScope, }; use crate::site::domain::protocol::codes::{Parameter, SafCode}; use crate::site::domain::protocol::refusal::RefusalSituation; @@ -101,7 +102,6 @@ fn a_pkcs12_named_by_the_site_is_refused_with_saf_08() { fn the_stores_of_the_original_that_rfirma_does_not_open_are_refused_one_by_one() { for store in [ "WINDOWS", - "APPLE", "PKCS12", "JAVA", "PKCS11", @@ -146,7 +146,6 @@ fn the_visible_name_of_a_store_names_it_as_the_constant_does() { for store in [ "PKCS#12 / PFX", "pkcs#12 / pfx", - "Llavero de Mac", "DNIe y tarjetas FNMT-TIF", "Tipo desconocido", ] { @@ -228,3 +227,40 @@ fn the_legacy_nss_scopes_the_listing_over_a_module_in_base64() { fn a_store_the_original_does_not_recognise_scopes_nothing() { assert_eq!(scope_named_by(&ksb64("INVENTADO")), StoreScope::Everywhere); } + +#[test] +fn apple_scopes_the_listing_to_the_macos_store_in_any_spelling() { + for named in ["APPLE", "apple", "Llavero de Mac"] { + assert_eq!(scope_named_by(&ksb64(named)), StoreScope::Macos, "{named}"); + } +} + +#[test] +fn apple_is_not_refused_as_a_store_rfirma_does_not_open() { + assert!(refuse_a_key_store_rfirma_does_not_open(&ksb64("APPLE")).is_ok()); +} + +#[test] +fn apple_with_a_library_is_still_refused() { + let refusal = refuse_a_key_store_rfirma_does_not_open(&ksb64("APPLE:/usr/lib/x.dylib")) + .expect_err("la biblioteca no es de un almacen del sistema"); + + assert_eq!(refusal.code(), SafCode::CannotAccessKeystore); +} + +#[test] +fn off_macos_apple_is_refused_with_saf_08_naming_its_parameter() { + for named in ["APPLE", "Llavero de Mac"] { + let refusal = refuse_the_macos_store(&ksb64(named)).expect_err("no hay Almacen de macOS"); + + assert_eq!(refusal.code(), SafCode::CannotAccessKeystore, "{named}"); + } +} + +#[test] +fn off_macos_the_other_stores_are_left_to_their_own_refusal() { + for named in ["NSS", "PKCS12", "INVENTADO"] { + assert!(refuse_the_macos_store(&ksb64(named)).is_ok(), "{named}"); + } + assert!(refuse_the_macos_store(&a_selection("")).is_ok()); +} diff --git a/rfirma-app/src-tauri/src/site/domain/protocol/mod.rs b/rfirma-app/src-tauri/src/site/domain/protocol/mod.rs index 92ef838d6..45944384a 100644 --- a/rfirma-app/src-tauri/src/site/domain/protocol/mod.rs +++ b/rfirma-app/src-tauri/src/site/domain/protocol/mod.rs @@ -67,7 +67,7 @@ pub use framing::{ credential_matches, http_response, read_request, request_credential, split_response, FragmentBuffer, FramedRequest, RefusedOrder, MORE_DATA_NEED, RESPONSE_MAX_SIZE, }; -pub use key_store::{refuse_a_key_store_rfirma_does_not_open, StoreScope}; +pub use key_store::{refuse_a_key_store_rfirma_does_not_open, refuse_the_macos_store, StoreScope}; pub use launch::{ asks_for_active_wait, drawn_ports, location_for_a_refusal, warns_of_an_old_web_client, ChannelCredential, LaunchRequest, NegotiatedCredential, RelayChannelInfo, RelayRequest, From 29ddafc3fa2978fd63396b6e69d58afbb5e148ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20G=C3=B3mez=20Bachiller?= Date: Mon, 19 Oct 2026 16:29:28 +0200 Subject: [PATCH 11/22] =?UTF-8?q?feat(identity):=20la=20interfaz=20rotula?= =?UTF-8?q?=20el=20Almac=C3=A9n=20de=20macOS=20en=20la=20lista,=20el=20asi?= =?UTF-8?q?stente=20y=20el=20estado?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements #2015: La interfaz rotula el Almacén de macOS en la lista, el asistente y el estado - Marca macos en el selector, el asistente y el estado, con su clave de i18n y traducciones - Historias de macOS en CertificateSelect, SetupWizard y StatusView --- rfirma-app/po/ca.po | 3 +++ rfirma-app/po/en.po | 3 +++ rfirma-app/po/es.po | 3 +++ rfirma-app/po/eu.po | 3 +++ rfirma-app/po/gl.po | 3 +++ rfirma-app/po/messages.pot | 3 +++ .../setup/SetupWizard.presentation.test.tsx | 12 ++++++++++++ rfirma-app/src/setup/SetupWizard.stories.tsx | 19 ++++++++++++++++++- rfirma-app/src/setup/testing/fixtures.ts | 6 ++++++ rfirma-app/src/signing/CertificateCard.tsx | 2 ++ .../src/signing/CertificateSelect.stories.tsx | 9 +++++++++ .../src/signing/CertificateSelect.test.tsx | 10 ++++++++++ rfirma-app/src/signing/certificate.ts | 1 + .../src/signing/testing/storyReports.ts | 9 +++++++++ .../status/StatusView.presentation.test.tsx | 5 +++++ rfirma-app/src/status/StatusView.stories.tsx | 5 +++++ rfirma-app/src/status/status.ts | 5 ++++- rfirma-app/src/status/testing/fixtures.ts | 11 +++++++++++ 18 files changed, 110 insertions(+), 2 deletions(-) diff --git a/rfirma-app/po/ca.po b/rfirma-app/po/ca.po index 00223e58f..b51258b4d 100644 --- a/rfirma-app/po/ca.po +++ b/rfirma-app/po/ca.po @@ -1052,6 +1052,9 @@ msgstr "Fitxer instal·lat" msgid "status.storeBrands.windows" msgstr "Magatzem de Windows" +msgid "status.storeBrands.macos" +msgstr "Magatzem de macOS" + msgid "status.notices.restartFirefox" msgstr "Reinicia el Firefox perquè tingui efecte." diff --git a/rfirma-app/po/en.po b/rfirma-app/po/en.po index 28a455aa4..b045a3f83 100644 --- a/rfirma-app/po/en.po +++ b/rfirma-app/po/en.po @@ -1052,6 +1052,9 @@ msgstr "Installed file" msgid "status.storeBrands.windows" msgstr "Windows store" +msgid "status.storeBrands.macos" +msgstr "macOS store" + msgid "status.notices.restartFirefox" msgstr "Restart Firefox for this to take effect." diff --git a/rfirma-app/po/es.po b/rfirma-app/po/es.po index ebf068e7d..887a98eab 100644 --- a/rfirma-app/po/es.po +++ b/rfirma-app/po/es.po @@ -1052,6 +1052,9 @@ msgstr "Fichero instalado" msgid "status.storeBrands.windows" msgstr "Almacén de Windows" +msgid "status.storeBrands.macos" +msgstr "Almacén de macOS" + msgid "status.notices.restartFirefox" msgstr "Reinicia Firefox para que surta efecto." diff --git a/rfirma-app/po/eu.po b/rfirma-app/po/eu.po index 87e6be35e..00a4e1885 100644 --- a/rfirma-app/po/eu.po +++ b/rfirma-app/po/eu.po @@ -1053,6 +1053,9 @@ msgstr "Instalatutako fitxategia" msgid "status.storeBrands.windows" msgstr "Windows biltegia" +msgid "status.storeBrands.macos" +msgstr "macOS biltegia" + msgid "status.notices.restartFirefox" msgstr "Berrabiarazi Firefox eragina izan dezan." diff --git a/rfirma-app/po/gl.po b/rfirma-app/po/gl.po index 9faa93a9e..a080228ce 100644 --- a/rfirma-app/po/gl.po +++ b/rfirma-app/po/gl.po @@ -1050,6 +1050,9 @@ msgstr "Ficheiro instalado" msgid "status.storeBrands.windows" msgstr "Almacén de Windows" +msgid "status.storeBrands.macos" +msgstr "Almacén de macOS" + msgid "status.notices.restartFirefox" msgstr "Reinicia Firefox para que xurda efecto." diff --git a/rfirma-app/po/messages.pot b/rfirma-app/po/messages.pot index a2eae7abf..934330c37 100644 --- a/rfirma-app/po/messages.pot +++ b/rfirma-app/po/messages.pot @@ -1037,6 +1037,9 @@ msgstr "" msgid "status.storeBrands.windows" msgstr "" +msgid "status.storeBrands.macos" +msgstr "" + msgid "status.notices.restartFirefox" msgstr "" diff --git a/rfirma-app/src/setup/SetupWizard.presentation.test.tsx b/rfirma-app/src/setup/SetupWizard.presentation.test.tsx index cf51d47f3..33f328e6d 100644 --- a/rfirma-app/src/setup/SetupWizard.presentation.test.tsx +++ b/rfirma-app/src/setup/SetupWizard.presentation.test.tsx @@ -1,5 +1,6 @@ import { composeStories } from "@storybook/react-vite"; import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { describe, expect, it } from "vitest"; import { renderWithCatalog } from "../testing/render"; import * as stories from "./SetupWizard.stories"; @@ -13,6 +14,7 @@ const { CertificateDoneHandlerPending, WithoutAutoFirma, ProtectionOff, + CertificateFailedOnMacos, } = composeStories(stories); const stepButtons = ["Instalar", "Usar rFirma", "Ahora no"]; @@ -99,3 +101,13 @@ describe("the protection against accidental signing", () => { expect(await screen.findByRole("switch")).not.toBeChecked(); }); }); + +describe("the certificate step on macOS", () => { + it("names the macOS store among the places where the install failed", async () => { + renderWithCatalog(); + + await userEvent.click(await screen.findByRole("button", { name: "Instalar" })); + + expect(await screen.findByText("Almacén de macOS")).toBeInTheDocument(); + }); +}); diff --git a/rfirma-app/src/setup/SetupWizard.stories.tsx b/rfirma-app/src/setup/SetupWizard.stories.tsx index 69b0a3f41..c490dba31 100644 --- a/rfirma-app/src/setup/SetupWizard.stories.tsx +++ b/rfirma-app/src/setup/SetupWizard.stories.tsx @@ -1,13 +1,14 @@ //! Las historias del asistente del primer arranque: la bienvenida y la configuración con cada estado de sus dos pasos. import type { Meta, StoryObj } from "@storybook/react-vite"; -import { fn } from "storybook/test"; +import { expect, fn, userEvent, within } from "storybook/test"; import { inMemoryPreferences } from "../preferences/preferences"; import { defaults } from "../preferences/testing/fixtures"; import { memoryStatus } from "../status/status"; import { SetupWizard } from "./SetupWizard"; import { aVersionRow, + certificateFailedOnMacos, certificateInstalled, certificateNotInstalled, handlerNotOurs, @@ -85,6 +86,22 @@ export const WithoutAutoFirma: Story = { }, }; +export const CertificateFailedOnMacos: Story = { + name: "2 · Configuración, certificado sin instalar en macOS", + args: { + initialStep: 2, + statusPort: { + ...memoryStatus([aVersionRow, certificateNotInstalled, handlerNotOurs]), + installLocalCaCertificate: async () => certificateFailedOnMacos, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(await canvas.findByRole("button", { name: "Instalar" })); + await expect(await canvas.findByText("Almacén de macOS")).toBeVisible(); + }, +}; + export const ProtectionOff: Story = { name: "2 · Configuración, sin la espera de la sede", args: { diff --git a/rfirma-app/src/setup/testing/fixtures.ts b/rfirma-app/src/setup/testing/fixtures.ts index 568c30f5b..a24c28486 100644 --- a/rfirma-app/src/setup/testing/fixtures.ts +++ b/rfirma-app/src/setup/testing/fixtures.ts @@ -28,6 +28,12 @@ export const certificateInstalled: SignalRow = { action: null, }; +export const certificateFailedOnMacos: SignalRow = { + ...certificateNotInstalled, + verdict: "incorrect", + detail: { kind: "trust", stores: [{ brand: "macos", trusted: false }] }, +}; + export const handlerNotOurs: SignalRow = { signal: "siteSignature", value: "AutoFirma", diff --git a/rfirma-app/src/signing/CertificateCard.tsx b/rfirma-app/src/signing/CertificateCard.tsx index 4b201392f..59c2d359a 100644 --- a/rfirma-app/src/signing/CertificateCard.tsx +++ b/rfirma-app/src/signing/CertificateCard.tsx @@ -67,6 +67,8 @@ export function storeLabel(store: Store, t: TFunction): string { return t("panel.certificate.stores.installed"); case "windows": return t("status.storeBrands.windows"); + case "macos": + return t("status.storeBrands.macos"); } } diff --git a/rfirma-app/src/signing/CertificateSelect.stories.tsx b/rfirma-app/src/signing/CertificateSelect.stories.tsx index 9dfc2a793..86791228d 100644 --- a/rfirma-app/src/signing/CertificateSelect.stories.tsx +++ b/rfirma-app/src/signing/CertificateSelect.stories.tsx @@ -5,6 +5,7 @@ import { fn } from "storybook/test"; import { CertificateSelect } from "./CertificateSelect"; import { ENTITY_CERTIFICATE, + MACOS_CERTIFICATE, PERSONAL_CERTIFICATE, STORY_CERTIFICATES, } from "./testing/storyReports"; @@ -53,4 +54,12 @@ export const Searching: Story = { args: { certificates: [], chosen: null, search export const Disabled: Story = { args: { disabled: true } }; +export const OpenWithMacosStore: Story = { + args: { + certificates: [MACOS_CERTIFICATE, PERSONAL_CERTIFICATE], + chosen: MACOS_CERTIFICATE, + defaultOpen: true, + }, +}; + export const Open: Story = { args: { defaultOpen: true } }; diff --git a/rfirma-app/src/signing/CertificateSelect.test.tsx b/rfirma-app/src/signing/CertificateSelect.test.tsx index eaa55da98..2478cbc42 100644 --- a/rfirma-app/src/signing/CertificateSelect.test.tsx +++ b/rfirma-app/src/signing/CertificateSelect.test.tsx @@ -247,6 +247,16 @@ describe("CertificateSelect", () => { expect(within(row(0)).getByText("Tarjeta")).toBeVisible(); }); + it("names the macOS personal store as the macOS store", async () => { + renderSelect({ + certificates: [aCertificate({ id: "macos", stores: ["macos"] })], + }); + + await userEvent.click(box()); + + expect(within(row(0)).getByText("Almacén de macOS")).toBeVisible(); + }); + it("tags a DNIe certificate as «DNIe» and a certificate on any other card as «Tarjeta»", async () => { renderSelect({ certificates: [ diff --git a/rfirma-app/src/signing/certificate.ts b/rfirma-app/src/signing/certificate.ts index 1ad27b8c9..af911cd47 100644 --- a/rfirma-app/src/signing/certificate.ts +++ b/rfirma-app/src/signing/certificate.ts @@ -55,6 +55,7 @@ type CertificateStoreClass = | "nssdb" | "installed" | "windows" + | "macos" | BrowserStoreMark; /** Un certificado elegible, con lo justo para pintarlo y para firmar con él. */ diff --git a/rfirma-app/src/signing/testing/storyReports.ts b/rfirma-app/src/signing/testing/storyReports.ts index 99fbdb331..6afc7236b 100644 --- a/rfirma-app/src/signing/testing/storyReports.ts +++ b/rfirma-app/src/signing/testing/storyReports.ts @@ -181,6 +181,15 @@ const NOT_YET_VALID_CERTIFICATE = aCertificate({ status: { kind: "notYetValid", notBefore: IN_2099 }, }); +export const MACOS_CERTIFICATE = aCertificate({ + id: "macos", + holderName: "BABBAGE CHARLES", + givenName: "Charles", + surname: "Babbage", + idNumber: "IDCES-00000002W", + stores: ["macos"], +}); + export const STORY_CERTIFICATES: readonly Certificate[] = [ PERSONAL_CERTIFICATE, ENTITY_CERTIFICATE, diff --git a/rfirma-app/src/status/StatusView.presentation.test.tsx b/rfirma-app/src/status/StatusView.presentation.test.tsx index 2c0ddd3e6..9d6b8a6d6 100644 --- a/rfirma-app/src/status/StatusView.presentation.test.tsx +++ b/rfirma-app/src/status/StatusView.presentation.test.tsx @@ -118,6 +118,11 @@ const table: Record = { buttons: [], absent: ["No aplica"], }, + UserCertificatesOnMacos: { + texts: ["Almacén de macOS", "Tarjeta"], + buttons: ["Ver dónde"], + absent: ["Almacén de Windows"], + }, UserCertificatesDetailExpanded: { texts: [ "Almacén de Windows", diff --git a/rfirma-app/src/status/StatusView.stories.tsx b/rfirma-app/src/status/StatusView.stories.tsx index 5a2d7d5d1..a764bdda2 100644 --- a/rfirma-app/src/status/StatusView.stories.tsx +++ b/rfirma-app/src/status/StatusView.stories.tsx @@ -21,6 +21,7 @@ import { sitesUnavailableOutsideFlatpak, sitesWithTwoCandidates, someUserCertificates, + someUserCertificatesOnMacos, versionChecking, versionOutdated, versionUpToDate, @@ -122,6 +123,10 @@ export const CardReaderMissing = panel([cardReaderMissing]); export const CardReaderNotSupported = panel([cardReaderNotSupported]); +export const UserCertificatesOnMacos = panel([someUserCertificatesOnMacos], { + initiallyExpanded: ["userCertificates"], +}); + export const UserCertificatesDetailExpanded = panel([someUserCertificates], { initiallyExpanded: ["userCertificates"], }); diff --git a/rfirma-app/src/status/status.ts b/rfirma-app/src/status/status.ts index 703770a94..bcc5aa34d 100644 --- a/rfirma-app/src/status/status.ts +++ b/rfirma-app/src/status/status.ts @@ -21,7 +21,8 @@ export type StoreBrand = | "nssdb" | "card" | "installed" - | "windows"; + | "windows" + | "macos"; export interface StoreDetail { brand: StoreBrand; @@ -44,6 +45,8 @@ export function storeBrandLabel(t: TFunction, brand: StoreBrand): string { return t("status.storeBrands.installed"); case "windows": return t("status.storeBrands.windows"); + case "macos": + return t("status.storeBrands.macos"); } } diff --git a/rfirma-app/src/status/testing/fixtures.ts b/rfirma-app/src/status/testing/fixtures.ts index 917c7c4aa..46e1e0f30 100644 --- a/rfirma-app/src/status/testing/fixtures.ts +++ b/rfirma-app/src/status/testing/fixtures.ts @@ -148,6 +148,17 @@ export const someUserCertificates: SignalRow = { }, }; +export const someUserCertificatesOnMacos: SignalRow = { + ...someUserCertificates, + detail: { + kind: "certificates", + stores: [ + { brand: "macos", certificates: 2 }, + { brand: "card", certificates: 1 }, + ], + }, +}; + export const cardReaderDetected = cardReaderRow({ kind: "noCard" }); export const cardReaderMissing = cardReaderRow({ kind: "noReader" }); From 7ffcbd756d78595042208a8a7b0e6ef6882818f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20G=C3=B3mez=20Bachiller?= Date: Mon, 19 Oct 2026 16:29:34 +0200 Subject: [PATCH 12/22] =?UTF-8?q?feat(identity):=20=C2=ABInstalar=20certif?= =?UTF-8?q?icado=C2=BB=20lleva=20el=20.p12=20al=20llavero=20de=20inicio=20?= =?UTF-8?q?de=20sesi=C3=B3n=20en=20macOS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements #2007: «Instalar certificado» lleva el `.p12` al llavero de inicio de sesión en macOS - el puerto Token dice si instala en el sistema: entonces no hay carpeta ni PIN del llavero de rFirma - el .p12 se prueba antes en un llavero desechable: contraseña incorrecta o sin clave no dejan nada - Preferencias lista y quita los certificados del Almacén de macOS --- rfirma-app/po/ca.po | 3 + rfirma-app/po/en.po | 3 + rfirma-app/po/es.po | 3 + rfirma-app/po/eu.po | 3 + rfirma-app/po/gl.po | 3 + rfirma-app/po/messages.pot | 3 + .../src/identity/adapters/macos_store.rs | 18 +- .../identity/adapters/macos_store/keychain.rs | 122 +++++++++- .../identity/adapters/macos_store/tests.rs | 125 +++++++++- .../src-tauri/src/identity/adapters/tauri.rs | 30 ++- .../src/identity/application/certificates.rs | 28 ++- .../application/certificates/tests/mod.rs | 1 + .../certificates/tests/system_install.rs | 218 ++++++++++++++++++ rfirma-app/src-tauri/src/identity/ports.rs | 5 + rfirma-app/src/signing/CertificateCard.tsx | 2 + .../src/signing/CertificateSelect.test.tsx | 10 + rfirma-app/src/signing/certificate.test.ts | 8 + rfirma-app/src/signing/certificate.ts | 9 +- 18 files changed, 560 insertions(+), 34 deletions(-) create mode 100644 rfirma-app/src-tauri/src/identity/application/certificates/tests/system_install.rs diff --git a/rfirma-app/po/ca.po b/rfirma-app/po/ca.po index 00223e58f..b51258b4d 100644 --- a/rfirma-app/po/ca.po +++ b/rfirma-app/po/ca.po @@ -1052,6 +1052,9 @@ msgstr "Fitxer instal·lat" msgid "status.storeBrands.windows" msgstr "Magatzem de Windows" +msgid "status.storeBrands.macos" +msgstr "Magatzem de macOS" + msgid "status.notices.restartFirefox" msgstr "Reinicia el Firefox perquè tingui efecte." diff --git a/rfirma-app/po/en.po b/rfirma-app/po/en.po index 28a455aa4..b045a3f83 100644 --- a/rfirma-app/po/en.po +++ b/rfirma-app/po/en.po @@ -1052,6 +1052,9 @@ msgstr "Installed file" msgid "status.storeBrands.windows" msgstr "Windows store" +msgid "status.storeBrands.macos" +msgstr "macOS store" + msgid "status.notices.restartFirefox" msgstr "Restart Firefox for this to take effect." diff --git a/rfirma-app/po/es.po b/rfirma-app/po/es.po index ebf068e7d..887a98eab 100644 --- a/rfirma-app/po/es.po +++ b/rfirma-app/po/es.po @@ -1052,6 +1052,9 @@ msgstr "Fichero instalado" msgid "status.storeBrands.windows" msgstr "Almacén de Windows" +msgid "status.storeBrands.macos" +msgstr "Almacén de macOS" + msgid "status.notices.restartFirefox" msgstr "Reinicia Firefox para que surta efecto." diff --git a/rfirma-app/po/eu.po b/rfirma-app/po/eu.po index 87e6be35e..00a4e1885 100644 --- a/rfirma-app/po/eu.po +++ b/rfirma-app/po/eu.po @@ -1053,6 +1053,9 @@ msgstr "Instalatutako fitxategia" msgid "status.storeBrands.windows" msgstr "Windows biltegia" +msgid "status.storeBrands.macos" +msgstr "macOS biltegia" + msgid "status.notices.restartFirefox" msgstr "Berrabiarazi Firefox eragina izan dezan." diff --git a/rfirma-app/po/gl.po b/rfirma-app/po/gl.po index 9faa93a9e..a080228ce 100644 --- a/rfirma-app/po/gl.po +++ b/rfirma-app/po/gl.po @@ -1050,6 +1050,9 @@ msgstr "Ficheiro instalado" msgid "status.storeBrands.windows" msgstr "Almacén de Windows" +msgid "status.storeBrands.macos" +msgstr "Almacén de macOS" + msgid "status.notices.restartFirefox" msgstr "Reinicia Firefox para que xurda efecto." diff --git a/rfirma-app/po/messages.pot b/rfirma-app/po/messages.pot index a2eae7abf..934330c37 100644 --- a/rfirma-app/po/messages.pot +++ b/rfirma-app/po/messages.pot @@ -1037,6 +1037,9 @@ msgstr "" msgid "status.storeBrands.windows" msgstr "" +msgid "status.storeBrands.macos" +msgstr "" + msgid "status.notices.restartFirefox" msgstr "" diff --git a/rfirma-app/src-tauri/src/identity/adapters/macos_store.rs b/rfirma-app/src-tauri/src/identity/adapters/macos_store.rs index 6e29409aa..e39bdffb9 100644 --- a/rfirma-app/src-tauri/src/identity/adapters/macos_store.rs +++ b/rfirma-app/src-tauri/src/identity/adapters/macos_store.rs @@ -131,23 +131,29 @@ impl Token for MacosToken { RealToken.release_the_login(reference); } + fn installs_in_the_system(&self) -> bool { + true + } + fn import_pkcs12( &self, - directory: &Path, + _directory: &Path, pkcs12: &[u8], password: &str, - pin: &ProtectedSecret, + _pin: &ProtectedSecret, ) -> Result { - RealToken.import_pkcs12(directory, pkcs12, password, pin) + let store = user_store(); + keychain::import_pkcs12(&store, pkcs12, password)?; + Ok(store) } fn remove_certificate( &self, - directory: &Path, + _directory: &Path, reference: &CertificateRef, - pin: &ProtectedSecret, + _pin: &ProtectedSecret, ) -> Result<(), TokenError> { - RealToken.remove_certificate(directory, reference, pin) + keychain::remove(reference) } } diff --git a/rfirma-app/src-tauri/src/identity/adapters/macos_store/keychain.rs b/rfirma-app/src-tauri/src/identity/adapters/macos_store/keychain.rs index 1bafb5d39..9622181e7 100644 --- a/rfirma-app/src-tauri/src/identity/adapters/macos_store/keychain.rs +++ b/rfirma-app/src-tauri/src/identity/adapters/macos_store/keychain.rs @@ -6,9 +6,10 @@ use core_foundation::string::CFString; use openssl::hash::{hash, MessageDigest}; use security_framework::certificate::SecCertificate; use security_framework::identity::SecIdentity; +use security_framework::import_export::Pkcs12ImportOptions; use security_framework::item::{ItemClass, ItemSearchOptions, Limit, Reference, SearchResult}; use security_framework::key::{Algorithm, SecKey}; -use security_framework::os::macos::keychain::SecKeychain; +use security_framework::os::macos::keychain::{CreateOptions, SecKeychain}; use security_framework_sys::item::kSecAttrTokenID; use super::SEARCH_LIST; @@ -88,6 +89,125 @@ pub fn sign( .map_err(|error| refused(&error)) } +/// Mete el `.p12` en el llavero de `store`, o en el de inicio de sesión si es la lista de búsqueda; antes lo prueba aparte y, si no sirve, no deja nada. +pub fn import_pkcs12(store: &Store, pkcs12: &[u8], password: &str) -> Result<(), TokenError> { + only_if_it_brings_a_usable_identity(pkcs12, password)?; + let mut options = Pkcs12ImportOptions::new(); + options.passphrase(password); + if let Some(mut keychains) = scope_of(store)? { + options.keychain(keychains.remove(0)); + } + options + .import(pkcs12) + .map(|_| ()) + .map_err(|error| rejected_pkcs12(&error)) +} + +/// Quita del llavero el certificado de `reference` y su clave privada. +pub fn remove(reference: &CertificateRef) -> Result<(), TokenError> { + let (_, identity) = identity_of(reference)?; + identity.delete().map_err(|error| failure(&error)) +} + +fn only_if_it_brings_a_usable_identity(pkcs12: &[u8], password: &str) -> Result<(), TokenError> { + let scratch = ScratchKeychain::create()?; + let imported = Pkcs12ImportOptions::new() + .passphrase(password) + .keychain(scratch.keychain.clone()) + .import(pkcs12) + .map_err(|error| rejected_pkcs12(&error))?; + let mut brought_a_key = false; + for identity in imported.iter().filter_map(|item| item.identity.as_ref()) { + brought_a_key = true; + let certificate = identity.certificate().map_err(|error| failure(&error))?; + let der = certificate.to_der(); + let candidate = certificate_in(&Store::module(""), &der, None); + if candidate.key_kind().is_none() { + return Err(TokenError::new( + Situation::KeyKindUnsupported, + format!( + "{}: la clave no es RSA ni de curva eliptica", + candidate.reference().label() + ), + )); + } + } + if brought_a_key { + return Ok(()); + } + Err(TokenError::new( + Situation::Pkcs12NoPrivateKey, + "el fichero no ha dejado ningun certificado con clave privada dentro", + )) +} + +fn rejected_pkcs12(error: &security_framework::base::Error) -> TokenError { + match error.code() { + WRONG_PKCS12_PASSWORD | AUTH_FAILED => TokenError::new( + Situation::IncorrectPkcs12Password, + format!( + "la contrasena del .p12 no es la correcta ({})", + error.code() + ), + ), + _ => failure(error), + } +} + +const WRONG_PKCS12_PASSWORD: i32 = -25264; +const AUTH_FAILED: i32 = -25293; + +/// Un llavero desechable donde probar un `.p12` sin tocar el de la persona. +struct ScratchKeychain { + directory: std::path::PathBuf, + keychain: SecKeychain, +} + +impl ScratchKeychain { + fn create() -> Result { + let unique = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |elapsed| elapsed.as_nanos()); + let directory = + std::env::temp_dir().join(format!("rfirma-p12-{}-{unique}", std::process::id())); + std::fs::create_dir(&directory).map_err(|error| { + TokenError::new( + Situation::Unknown, + format!("no se ha podido preparar un llavero temporal: {error}"), + ) + })?; + let created = CreateOptions::new() + .password(SCRATCH_PASSWORD) + .create(directory.join("scratch.keychain-db")); + match created { + Ok(keychain) => Ok(Self { + directory, + keychain, + }), + Err(error) => { + let _ = std::fs::remove_dir_all(&directory); + Err(failure(&error)) + } + } + } +} + +impl Drop for ScratchKeychain { + fn drop(&mut self) { + unsafe { + SecKeychainDelete(self.keychain.as_concrete_TypeRef().cast()); + } + let _ = std::fs::remove_dir_all(&self.directory); + } +} + +const SCRATCH_PASSWORD: &str = "rfirma-scratch"; + +#[link(name = "Security", kind = "framework")] +extern "C" { + fn SecKeychainDelete(keychain: *mut std::ffi::c_void) -> i32; +} + /// La situación del catálogo que corresponde a un error de Security.framework, CryptoTokenKit o PC/SC. pub fn situation_of(domain: &str, code: i64) -> Situation { match (domain, code as i32) { diff --git a/rfirma-app/src-tauri/src/identity/adapters/macos_store/tests.rs b/rfirma-app/src-tauri/src/identity/adapters/macos_store/tests.rs index 78a8623a0..fac5eaf4e 100644 --- a/rfirma-app/src-tauri/src/identity/adapters/macos_store/tests.rs +++ b/rfirma-app/src-tauri/src/identity/adapters/macos_store/tests.rs @@ -16,7 +16,7 @@ use security_framework::certificate::SecCertificate; use security_framework::import_export::Pkcs12ImportOptions; use security_framework::os::macos::keychain::{CreateOptions, SecKeychain}; -use super::keychain::situation_of; +use super::keychain::{import_pkcs12 as install, situation_of}; use super::{keychain_store, stores_besides, user_store, MacosToken}; use crate::identity::application::certificates::certificates_with_their_chains; use crate::identity::domain::algorithm::{KeyKind, SignatureAlgorithm}; @@ -66,23 +66,23 @@ impl TemporaryKeychain { } fn holds_an_identity(&self, certificate: &X509, key: &PKey) { - let pkcs12 = Pkcs12::builder() - .name("rfirma-test") - .pkey(key) - .cert(certificate) - .key_algorithm(Nid::PBE_WITHSHA1AND3_KEY_TRIPLEDES_CBC) - .cert_algorithm(Nid::PBE_WITHSHA1AND3_KEY_TRIPLEDES_CBC) - .mac_md(MessageDigest::sha1()) - .build2(PASSWORD) - .and_then(|pkcs12| pkcs12.to_der()) - .expect("deberia construirse el .p12 de pruebas"); Pkcs12ImportOptions::new() .passphrase(PASSWORD) .keychain(self.keychain.clone()) - .import(&pkcs12) + .import(&pkcs12_of(certificate, Some(key), PASSWORD)) .expect("el .p12 deberia entrar en el llavero de pruebas"); } + fn every(&self) -> Vec { + MacosToken + .every_certificate(&self.store()) + .expect("el llavero deberia listarse") + } + + fn store(&self) -> Store { + keychain_store(&self.path) + } + fn holding(key: &PKey) -> (Self, TokenCertificate) { let keychain = Self::create(); keychain.holds_an_identity(&a_certificate("FIRMANTE", key, None, false), key); @@ -105,6 +105,23 @@ impl Drop for TemporaryKeychain { } } +fn pkcs12_of(certificate: &X509, key: Option<&PKey>, password: &str) -> Vec { + let mut builder = Pkcs12::builder(); + builder + .name("rfirma-test") + .cert(certificate) + .key_algorithm(Nid::PBE_WITHSHA1AND3_KEY_TRIPLEDES_CBC) + .cert_algorithm(Nid::PBE_WITHSHA1AND3_KEY_TRIPLEDES_CBC) + .mac_md(MessageDigest::sha1()); + if let Some(key) = key { + builder.pkey(key); + } + builder + .build2(password) + .and_then(|pkcs12| pkcs12.to_der()) + .expect("deberia construirse el .p12 de pruebas") +} + fn an_rsa_key() -> PKey { PKey::from_rsa(Rsa::generate(2048).expect("deberia generarse la clave RSA")) .expect("deberia envolverse la clave RSA") @@ -463,3 +480,87 @@ fn system_errors_map_to_their_situations() { assert_eq!(situation_of(OS_STATUS, -4), Situation::Unknown); assert_eq!(situation_of("NSCocoaErrorDomain", -128), Situation::Unknown); } + +#[test] +fn installing_a_pkcs12_puts_its_identity_in_the_keychain_for_rsa_and_elliptic_curve() { + let keychain = TemporaryKeychain::create(); + let rsa = an_rsa_key(); + let ec = an_elliptic_curve_key(); + + for (name, key) in [("INSTALADO RSA", &rsa), ("INSTALADO CURVA", &ec)] { + let pkcs12 = pkcs12_of(&a_certificate(name, key, None, false), Some(key), "clave"); + install(&keychain.store(), &pkcs12, "clave").expect("deberia instalarse"); + } + + let mut labels: Vec = keychain + .listed() + .iter() + .map(|certificate| certificate.reference().label().to_owned()) + .collect(); + labels.sort(); + assert_eq!(labels, vec!["INSTALADO CURVA", "INSTALADO RSA"]); +} + +#[test] +fn a_wrong_pkcs12_password_installs_nothing() { + let keychain = TemporaryKeychain::create(); + let key = an_rsa_key(); + let pkcs12 = pkcs12_of( + &a_certificate("FIRMA", &key, None, false), + Some(&key), + "clave", + ); + + let failure = install(&keychain.store(), &pkcs12, "otra").expect_err("la clave es otra"); + + assert_eq!(failure.situation(), Situation::IncorrectPkcs12Password); + assert!(keychain.listed().is_empty()); +} + +#[test] +fn a_pkcs12_without_a_private_key_installs_nothing() { + let keychain = TemporaryKeychain::create(); + let key = an_rsa_key(); + let pkcs12 = pkcs12_of( + &a_certificate("SIN CLAVE", &key, None, false), + None, + "clave", + ); + + let failure = install(&keychain.store(), &pkcs12, "clave").expect_err("no trae clave"); + + assert_eq!(failure.situation(), Situation::Pkcs12NoPrivateKey); + assert!(keychain.every().is_empty()); +} + +#[test] +fn removing_an_installed_certificate_deletes_it_and_its_key_from_the_keychain() { + let keychain = TemporaryKeychain::create(); + let (kept_key, gone_key) = (an_rsa_key(), an_rsa_key()); + keychain.holds_an_identity( + &a_certificate("SE QUEDA", &kept_key, None, false), + &kept_key, + ); + keychain.holds_an_identity(&a_certificate("SE VA", &gone_key, None, false), &gone_key); + let gone = keychain + .listed() + .into_iter() + .find(|certificate| certificate.reference().label() == "SE VA") + .expect("deberia estar en el llavero"); + + MacosToken + .remove_certificate(Path::new(""), gone.reference(), &ProtectedSecret::new(b"")) + .expect("deberia quitarse"); + + let labels: Vec = keychain + .listed() + .iter() + .map(|certificate| certificate.reference().label().to_owned()) + .collect(); + assert_eq!(labels, vec!["SE QUEDA"]); +} + +#[test] +fn macos_installs_pkcs12_in_the_system() { + assert!(MacosToken.installs_in_the_system()); +} diff --git a/rfirma-app/src-tauri/src/identity/adapters/tauri.rs b/rfirma-app/src-tauri/src/identity/adapters/tauri.rs index 830bb9188..df94039f3 100644 --- a/rfirma-app/src-tauri/src/identity/adapters/tauri.rs +++ b/rfirma-app/src-tauri/src/identity/adapters/tauri.rs @@ -8,7 +8,31 @@ use crate::signing::SigningRoot; use super::views::{CertificateView, ReaderStatusView}; use crate::crossing::Failure; use crate::identity::application::certificates::PasswordPrompt; -use crate::identity::ports::OriginWindow; +use crate::identity::domain::keyring::KeyringError; +use crate::identity::domain::protected_secret::ProtectedSecret; +use crate::identity::ports::{Keyring, OriginWindow}; + +/// El llavero que no se usa cuando instalar y quitar es cosa del sistema. +struct SystemManaged; + +impl Keyring for SystemManaged { + fn pin(&self) -> Result { + Err(KeyringError::NoKeyring) + } + + fn create_pin(&self) -> Result { + Err(KeyringError::NoKeyring) + } +} + +fn keyring_for_installing( + identity: &IdentityRoot, +) -> Result, KeyringError> { + if identity.token.installs_in_the_system() { + return Ok(Box::new(SystemManaged)); + } + (identity.keyring)() +} /// Certificados de los tokens conectados. #[tauri::command] @@ -62,7 +86,7 @@ pub fn install_certificate_over( return Ok(false); }; let (file_name, pkcs12) = read_the_file(chosen)?; - let keyring = (identity.keyring)()?; + let keyring = keyring_for_installing(identity)?; super::failures::installed_unless_cancelled( crate::identity::application::certificates::install_pkcs12_asking_its_password( @@ -84,7 +108,7 @@ pub fn install_certificate_over( /// Desinstala un certificado PKCS#12 previamente instalado. #[tauri::command(async)] pub fn remove_certificate(id: String, identity: State<'_, IdentityRoot>) -> Result<(), Failure> { - let keyring = (identity.keyring)()?; + let keyring = keyring_for_installing(&identity)?; Ok( crate::identity::application::certificates::remove_installed( identity.token.as_ref(), diff --git a/rfirma-app/src-tauri/src/identity/application/certificates.rs b/rfirma-app/src-tauri/src/identity/application/certificates.rs index d141453f1..f86c83008 100644 --- a/rfirma-app/src-tauri/src/identity/application/certificates.rs +++ b/rfirma-app/src-tauri/src/identity/application/certificates.rs @@ -248,6 +248,10 @@ pub fn install_pkcs12( pkcs12: &[u8], password: &str, ) -> Result<(), InstallError> { + if token.installs_in_the_system() { + token.import_pkcs12(installed_dir, pkcs12, password, &ProtectedSecret::new(b""))?; + return Ok(()); + } let already_existed = installed_dir.join("cert9.db").is_file(); let pin = pin_for_installing(keyring, already_existed)?; validate_pkcs12_alone(token, folder, pkcs12, password)?; @@ -398,16 +402,20 @@ pub fn remove_installed( Some(installed) => installed, None => listed.get(handle).ok_or_else(not_from_the_last_listing)?, }; - reference - .store() - .installed_directory_under(installed_dir) - .ok_or_else(|| { - TokenError::new( - Situation::CertificateNotFound, - "ese certificado no viene de un .p12 instalado", - ) - })?; - let pin = keyring.pin()?; + let pin = if token.installs_in_the_system() { + ProtectedSecret::new(b"") + } else { + reference + .store() + .installed_directory_under(installed_dir) + .ok_or_else(|| { + TokenError::new( + Situation::CertificateNotFound, + "ese certificado no viene de un .p12 instalado", + ) + })?; + keyring.pin()? + }; token.remove_certificate(installed_dir, &reference, &pin)?; if memory.remembered_certificate().as_ref() == Some(&reference) { let _ = memory.forget_the_certificate(); diff --git a/rfirma-app/src-tauri/src/identity/application/certificates/tests/mod.rs b/rfirma-app/src-tauri/src/identity/application/certificates/tests/mod.rs index 4b48e19cb..f05f7e0ed 100644 --- a/rfirma-app/src-tauri/src/identity/application/certificates/tests/mod.rs +++ b/rfirma-app/src-tauri/src/identity/application/certificates/tests/mod.rs @@ -608,4 +608,5 @@ fn an_entity_certificate_without_a_natural_person_holder_carries_no_entity_name( mod copies; mod macos_store; mod removal; +mod system_install; mod windows_store; diff --git a/rfirma-app/src-tauri/src/identity/application/certificates/tests/system_install.rs b/rfirma-app/src-tauri/src/identity/application/certificates/tests/system_install.rs new file mode 100644 index 000000000..cc6b3b7ac --- /dev/null +++ b/rfirma-app/src-tauri/src/identity/application/certificates/tests/system_install.rs @@ -0,0 +1,218 @@ +use std::cell::RefCell; +use std::path::{Path, PathBuf}; + +use super::super::{install_pkcs12, remove_installed, InstallError, ListedCertificates}; +use crate::identity::application::tests::NoToken; +use crate::identity::domain::algorithm::SignatureAlgorithm; +use crate::identity::domain::certificate::{CertificateRef, TokenCertificate}; +use crate::identity::domain::error::{Situation, TokenError}; +use crate::identity::domain::keyring::KeyringError; +use crate::identity::domain::protected_secret::ProtectedSecret; +use crate::identity::domain::secret::StoreSecret; +use crate::identity::domain::store::Store; +use crate::identity::ports::{CertificateMemory as _, InstalledFolder, Keyring, Token}; +use crate::signing::application::tests::a_memory; + +/// Un token que instala en el sistema y apunta lo que le piden. +struct SystemToken { + imported: RefCell>, + removed: RefCell>, + outcome: Result<(), TokenError>, +} + +impl SystemToken { + fn answering(outcome: Result<(), TokenError>) -> Self { + Self { + imported: RefCell::new(Vec::new()), + removed: RefCell::new(Vec::new()), + outcome, + } + } +} + +impl Token for SystemToken { + fn installs_in_the_system(&self) -> bool { + true + } + + fn list(&self, store: &Store) -> Result, TokenError> { + NoToken.list(store) + } + + fn every_certificate(&self, store: &Store) -> Result, TokenError> { + NoToken.every_certificate(store) + } + + fn list_authenticated( + &self, + store: &Store, + pin: &ProtectedSecret, + ) -> Result, TokenError> { + NoToken.list_authenticated(store, pin) + } + + fn secret_of(&self, reference: &CertificateRef) -> Result { + NoToken.secret_of(reference) + } + + fn offers( + &self, + reference: &CertificateRef, + algorithm: SignatureAlgorithm, + ) -> Result<(), TokenError> { + NoToken.offers(reference, algorithm) + } + + fn accepts_the_secret( + &self, + reference: &CertificateRef, + secret: &ProtectedSecret, + ) -> Result<(), TokenError> { + NoToken.accepts_the_secret(reference, secret) + } + + fn sign_with_secret( + &self, + reference: &CertificateRef, + secret: &ProtectedSecret, + algorithm: SignatureAlgorithm, + data: &[u8], + ) -> Result, TokenError> { + NoToken.sign_with_secret(reference, secret, algorithm, data) + } + + fn import_pkcs12( + &self, + _directory: &Path, + _pkcs12: &[u8], + password: &str, + _pin: &ProtectedSecret, + ) -> Result { + self.imported.borrow_mut().push(password.to_owned()); + self.outcome + .clone() + .map(|()| Store::module("keychain:search-list")) + } + + fn remove_certificate( + &self, + _directory: &Path, + reference: &CertificateRef, + _pin: &ProtectedSecret, + ) -> Result<(), TokenError> { + self.removed.borrow_mut().push(reference.clone()); + self.outcome.clone() + } +} + +/// Un llavero que no existe: en macOS el PIN del Almacén de rFirma no se usa. +struct NoKeyring; + +impl Keyring for NoKeyring { + fn pin(&self) -> Result { + Err(KeyringError::NoKeyring) + } + + fn create_pin(&self) -> Result { + Err(KeyringError::NoKeyring) + } +} + +/// Una carpeta a la que instalar en el sistema no debe llegar. +struct UntouchedFolder; + +impl InstalledFolder for UntouchedFolder { + fn make(&self, _directory: &Path) -> Result<(), String> { + panic!("instalar en el sistema no crea carpetas") + } + + fn restrict_to_owner(&self, _path: &Path) { + panic!("instalar en el sistema no toca permisos") + } + + fn remove(&self, _directory: &Path) -> Result<(), String> { + panic!("instalar en el sistema no borra carpetas") + } + + fn remove_file(&self, _path: &Path) { + panic!("instalar en el sistema no borra ficheros") + } + + fn staging_directory(&self) -> PathBuf { + panic!("instalar en el sistema no prepara un directorio de prueba") + } +} + +fn in_the_macos_store() -> CertificateRef { + CertificateRef::new( + Store::module("keychain:search-list"), + "Keychain", + "FIRMA", + vec![0x01], + ) +} + +#[test] +fn installing_in_the_system_needs_neither_the_rfirma_keyring_nor_its_folder() { + let token = SystemToken::answering(Ok(())); + + let installed = install_pkcs12( + &token, + &UntouchedFolder, + &NoKeyring, + Path::new("/nada/certificates"), + b"p12", + "clave", + ); + + assert_eq!(installed, Ok(())); + assert_eq!(*token.imported.borrow(), vec!["clave".to_owned()]); +} + +#[test] +fn a_wrong_password_installing_in_the_system_is_the_tokens_refusal() { + let token = SystemToken::answering(Err(TokenError::new( + Situation::IncorrectPkcs12Password, + "contrasena", + ))); + + let failure = install_pkcs12( + &token, + &UntouchedFolder, + &NoKeyring, + Path::new("/nada/certificates"), + b"p12", + "mala", + ) + .expect_err("la contrasena es incorrecta"); + + assert!(matches!( + failure, + InstallError::Token(token) if token.situation() == Situation::IncorrectPkcs12Password + )); +} + +#[test] +fn removing_a_macos_store_certificate_asks_the_system_and_forgets_it() { + let home = tempfile::tempdir().expect("deberia haber directorio temporal"); + let memory = a_memory(home.path()); + let reference = in_the_macos_store(); + super::super::remember_the_certificate(&memory, &reference); + let listed = ListedCertificates::new(); + let handle = listed.mint(reference.clone()); + let token = SystemToken::answering(Ok(())); + + let removed = remove_installed( + &token, + &NoKeyring, + &memory, + &home.path().join("certificates"), + &handle, + &listed, + &ListedCertificates::new(), + ); + + assert_eq!(removed, Ok(())); + assert_eq!(*token.removed.borrow(), vec![reference]); + assert!(memory.remembered_certificate().is_none()); +} diff --git a/rfirma-app/src-tauri/src/identity/ports.rs b/rfirma-app/src-tauri/src/identity/ports.rs index 1fc358e91..495cd4878 100644 --- a/rfirma-app/src-tauri/src/identity/ports.rs +++ b/rfirma-app/src-tauri/src/identity/ports.rs @@ -68,6 +68,11 @@ pub trait Token { /// Cierra el login que compartían las firmas del certificado. fn release_the_login(&self, _reference: &CertificateRef) {} + /// Si instalar y quitar un `.p12` es cosa del sistema y no del Almacén de rFirma: sin carpeta, sin PIN del llavero. + fn installs_in_the_system(&self) -> bool { + false + } + /// Importa un `.p12` al Almacén de rFirma en ese directorio, cifrado con `pin`, y lo devuelve. fn import_pkcs12( &self, diff --git a/rfirma-app/src/signing/CertificateCard.tsx b/rfirma-app/src/signing/CertificateCard.tsx index 4b201392f..59c2d359a 100644 --- a/rfirma-app/src/signing/CertificateCard.tsx +++ b/rfirma-app/src/signing/CertificateCard.tsx @@ -67,6 +67,8 @@ export function storeLabel(store: Store, t: TFunction): string { return t("panel.certificate.stores.installed"); case "windows": return t("status.storeBrands.windows"); + case "macos": + return t("status.storeBrands.macos"); } } diff --git a/rfirma-app/src/signing/CertificateSelect.test.tsx b/rfirma-app/src/signing/CertificateSelect.test.tsx index eaa55da98..f01383645 100644 --- a/rfirma-app/src/signing/CertificateSelect.test.tsx +++ b/rfirma-app/src/signing/CertificateSelect.test.tsx @@ -247,6 +247,16 @@ describe("CertificateSelect", () => { expect(within(row(0)).getByText("Tarjeta")).toBeVisible(); }); + it("names the keychain certificates as the macOS store", async () => { + renderSelect({ + certificates: [aCertificate({ id: "keychain", stores: ["macos"] })], + }); + + await userEvent.click(box()); + + expect(within(row(0)).getByText("Almacén de macOS")).toBeVisible(); + }); + it("tags a DNIe certificate as «DNIe» and a certificate on any other card as «Tarjeta»", async () => { renderSelect({ certificates: [ diff --git a/rfirma-app/src/signing/certificate.test.ts b/rfirma-app/src/signing/certificate.test.ts index 64702389b..fb216e16f 100644 --- a/rfirma-app/src/signing/certificate.test.ts +++ b/rfirma-app/src/signing/certificate.test.ts @@ -243,6 +243,14 @@ describe("installedCertificates", () => { expect(installedCertificates([profile, installed, shared])).toEqual([installed]); }); + /** En macOS el `.p12` instalado vive en el llavero, y desde Preferencias se quita de allí. */ + it("keeps the ones in the macOS store, where a .p12 is installed on a Mac", () => { + const keychain = aCertificate({ id: "keychain", stores: ["macos"] }); + const windows = aCertificate({ id: "windows", stores: ["windows"] }); + + expect(installedCertificates([windows, keychain])).toEqual([keychain]); + }); + /** Un caducado se queda: que desaparezca no le explica nada a quien lo instaló. */ it("keeps an expired one, unlike the dropdown's available group", () => { const expired = aCertificate({ diff --git a/rfirma-app/src/signing/certificate.ts b/rfirma-app/src/signing/certificate.ts index 1ad27b8c9..277e0f7c4 100644 --- a/rfirma-app/src/signing/certificate.ts +++ b/rfirma-app/src/signing/certificate.ts @@ -55,6 +55,7 @@ type CertificateStoreClass = | "nssdb" | "installed" | "windows" + | "macos" | BrowserStoreMark; /** Un certificado elegible, con lo justo para pintarlo y para firmar con él. */ @@ -297,7 +298,8 @@ export function emptyCertificateStore(): CertificateStore { /** * Los que se instalaron en rFirma desde un `.p12`, que son los únicos que - * Preferencias enseña y los únicos que se pueden quitar. + * Preferencias enseña y los únicos que se pueden quitar. En macOS el `.p12` + * se instala en el llavero, así que son los del Almacén de macOS. * * El orden es el mismo del desplegable —alfabético por primera línea— para * que la misma persona salga en el mismo sitio en las dos pantallas, y **los @@ -307,7 +309,10 @@ export function installedCertificates( certificates: readonly Certificate[], ): readonly Certificate[] { return certificates - .filter((certificate) => certificate.stores.includes("installed")) + .filter( + (certificate) => + certificate.stores.includes("installed") || certificate.stores.includes("macos"), + ) .sort(byHeadlineThenStore); } From 019803e066c537a35607fdf535fb75b2f7f59e04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20G=C3=B3mez=20Bachiller?= Date: Mon, 19 Oct 2026 16:30:08 +0200 Subject: [PATCH 13/22] feat(identity): el vigilante de lectores corre en macOS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements #2009: El vigilante de lectores corre en macOS - el adaptador PC/SC se compila en macOS y la plataforma vigila los lectores - al cambiar un lector se vuelve a listar el Almacén de macOS junto a los módulos PKCS#11 --- rfirma-app/src-tauri/Cargo.toml | 6 +- .../src-tauri/src/identity/adapters/mod.rs | 12 ++-- .../src-tauri/src/identity/adapters/pcsc.rs | 4 +- .../src/identity/application/readers.rs | 6 +- .../src/identity/application/readers/tests.rs | 60 ++++++++++++++++++- 5 files changed, 72 insertions(+), 16 deletions(-) diff --git a/rfirma-app/src-tauri/Cargo.toml b/rfirma-app/src-tauri/Cargo.toml index a8384d7ed..f68c53b2b 100644 --- a/rfirma-app/src-tauri/Cargo.toml +++ b/rfirma-app/src-tauri/Cargo.toml @@ -193,9 +193,9 @@ glib = "0.18" # secretos dentro del flatpak, Secret Service por D-Bus fuera de él (ADR-0034). oo7 = "0.5" -# El vigilante de lectores y tarjetas: `libpcsclite` en Linux y `winscard.dll` -# en Windows (ADR-0048). -[target.'cfg(any(target_os = "linux", windows))'.dependencies] +# El vigilante de lectores y tarjetas: `libpcsclite` en Linux, `winscard.dll` +# en Windows y `PCSC.framework` en macOS (ADR-0048). +[target.'cfg(any(target_os = "linux", windows, target_os = "macos"))'.dependencies] pcsc = "2.9" # Windows no trae OpenSSL: se compila desde las fuentes, y solo alli (ADR-0035). diff --git a/rfirma-app/src-tauri/src/identity/adapters/mod.rs b/rfirma-app/src-tauri/src/identity/adapters/mod.rs index 5bd62158b..4563a11ed 100644 --- a/rfirma-app/src-tauri/src/identity/adapters/mod.rs +++ b/rfirma-app/src-tauri/src/identity/adapters/mod.rs @@ -6,7 +6,7 @@ pub mod folder; pub mod keyring; #[cfg(target_os = "macos")] pub mod macos_store; -#[cfg(any(target_os = "linux", windows))] +#[cfg(any(target_os = "linux", windows, target_os = "macos"))] pub mod pcsc; #[cfg(target_os = "macos")] pub mod pending_macos_keychain; @@ -30,21 +30,21 @@ pub use pending_macos_keychain::PendingMacosKeychain as DesktopKeyring; pub use windows_credential_manager::WindowsCredentialManager as DesktopKeyring; /// Una consulta puntual a los lectores; `None`, si PC/SC no responde. -#[cfg(any(target_os = "linux", windows))] +#[cfg(any(target_os = "linux", windows, target_os = "macos"))] pub use pcsc::survey_readers; /// El vigilante de lectores de esta plataforma. -#[cfg(any(target_os = "linux", windows))] +#[cfg(any(target_os = "linux", windows, target_os = "macos"))] pub use pcsc::PcscReaderWatch as DesktopReaderWatch; /// Una consulta puntual a los lectores; sin PC/SC en esta plataforma, nunca hay respuesta. -#[cfg(not(any(target_os = "linux", windows)))] +#[cfg(not(any(target_os = "linux", windows, target_os = "macos")))] pub fn survey_readers() -> Option> { None } /// Si esta plataforma habla con PC/SC. -pub const SPEAKS_PCSC: bool = cfg!(any(target_os = "linux", windows)); +pub const SPEAKS_PCSC: bool = cfg!(any(target_os = "linux", windows, target_os = "macos")); /// El vigilante de lectores de esta plataforma. -#[cfg(not(any(target_os = "linux", windows)))] +#[cfg(not(any(target_os = "linux", windows, target_os = "macos")))] pub use readers::UnavailableReaderWatch as DesktopReaderWatch; /// El token de esta plataforma. diff --git a/rfirma-app/src-tauri/src/identity/adapters/pcsc.rs b/rfirma-app/src-tauri/src/identity/adapters/pcsc.rs index 89564b8e9..e0f9288f6 100644 --- a/rfirma-app/src-tauri/src/identity/adapters/pcsc.rs +++ b/rfirma-app/src-tauri/src/identity/adapters/pcsc.rs @@ -1,4 +1,4 @@ -//! El vigilante PC/SC de Linux y Windows: espera a `SCardGetStatusChange` sobre todos los lectores y las notificaciones PnP (ADR-0048). +//! El vigilante PC/SC de Linux, Windows y macOS: espera a `SCardGetStatusChange` sobre todos los lectores y las notificaciones PnP (ADR-0048). use std::ffi::CString; use std::time::Duration; @@ -70,7 +70,7 @@ impl ReaderWatch for StatusWatch { } } -/// La fuente real: `libpcsclite` en Linux, `winscard.dll` en Windows. +/// La fuente real: `libpcsclite` en Linux, `winscard.dll` en Windows, `PCSC.framework` en macOS. #[derive(Default)] pub struct PcscSource { context: Option, diff --git a/rfirma-app/src-tauri/src/identity/application/readers.rs b/rfirma-app/src-tauri/src/identity/application/readers.rs index 62cd3fce6..0f5723509 100644 --- a/rfirma-app/src-tauri/src/identity/application/readers.rs +++ b/rfirma-app/src-tauri/src/identity/application/readers.rs @@ -45,7 +45,7 @@ pub fn watches_the_readers(platform: Platform, channel: Channel) -> bool { match platform { Platform::Linux => matches!(channel, Channel::Native | Channel::Flatpak), Platform::Windows => channel == Channel::Windows, - Platform::MacOs => false, + Platform::MacOs => channel == Channel::Native, } } @@ -190,11 +190,11 @@ fn listed_in(token: &dyn Token, stores: &[Store]) -> Vec { token.list_across(stores).unwrap_or_default() } -/// Si el almacén se vuelve a listar al cambiar los lectores: uno de tarjeta, o el de Windows, que copia las suyas. +/// Si el almacén se vuelve a listar al cambiar los lectores: uno de tarjeta, o el de Windows o macOS, que copian las suyas. fn shows_cards(store: &Store, installed_dir: &Path) -> bool { matches!( store.class_under(installed_dir), - StoreClass::Card | StoreClass::Windows + StoreClass::Card | StoreClass::Windows | StoreClass::Macos ) } diff --git a/rfirma-app/src-tauri/src/identity/application/readers/tests.rs b/rfirma-app/src-tauri/src/identity/application/readers/tests.rs index c177b6680..fff987248 100644 --- a/rfirma-app/src-tauri/src/identity/application/readers/tests.rs +++ b/rfirma-app/src-tauri/src/identity/application/readers/tests.rs @@ -229,14 +229,14 @@ fn with_no_full_listing_yet_every_store_is_opened() { } #[test] -fn the_readers_are_watched_on_linux_and_windows_but_not_on_macos() { +fn the_readers_are_watched_on_linux_windows_and_macos() { use crate::desktop::domain::channel::Channel; use crate::desktop::domain::platform::Platform; assert!(watches_the_readers(Platform::Linux, Channel::Native)); assert!(watches_the_readers(Platform::Linux, Channel::Flatpak)); assert!(watches_the_readers(Platform::Windows, Channel::Windows)); - assert!(!watches_the_readers(Platform::MacOs, Channel::Native)); + assert!(watches_the_readers(Platform::MacOs, Channel::Native)); } const CARD_KSP: &str = "Microsoft Smart Card Key Storage Provider"; @@ -421,3 +421,59 @@ fn without_the_smart_card_service_there_is_no_reader_and_the_listing_goes_on() { assert_eq!(listed.len(), 1); assert_eq!(listed[0].label, IN_SOFTWARE); } + +fn the_macos_store() -> Store { + Store::module(format!( + "{}search-list", + crate::identity::domain::store::MACOS_STORE_PREFIX + )) +} + +fn in_macos(label: &str, der: Vec) -> TokenCertificate { + TokenCertificate::new( + CertificateRef::new( + the_macos_store(), + "login.keychain-db", + label, + label.as_bytes().to_vec(), + ), + der, + ) +} + +#[test] +fn a_card_in_the_reader_with_no_card_class_certificate_in_macos_is_unreadable() { + let token = Counting::showing(|_store| { + vec![in_macos( + IN_SOFTWARE, + TestAuthority::root("Firmante en el disco").der(), + )] + }); + let arranged = Arranged::over(token, vec![the_macos_store()]); + let steps = [true]; + let (card, _) = WindowsWithACard::of(Vec::new()); + + let heard = followed(&arranged, &card, &steps); + + assert_eq!( + heard.iter().map(|news| news.reader).collect::>(), + vec![ReaderStatus::Reading, ReaderStatus::Unreadable] + ); + assert_eq!(labels_in(&heard[1]), vec![IN_SOFTWARE]); +} + +#[test] +fn a_change_in_the_readers_lists_the_macos_store_and_the_card_modules_again() { + let token = Counting::new(); + let arranged = Arranged::over(token, vec![the_macos_store(), Store::module(CARD)]); + arranged.listed_in_full(); + arranged.token.asked(); + let (card, _) = WindowsWithACard::of(Vec::new()); + + followed(&arranged, &card, &[false, true]); + + let mut asked = arranged.token.asked(); + asked.dedup(); + assert!(asked.contains(&the_macos_store())); + assert!(asked.contains(&Store::module(CARD))); +} From 3c401375ab990393ca54f1edf32ccf3ca210b017 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20G=C3=B3mez=20Bachiller?= Date: Mon, 19 Oct 2026 16:32:10 +0200 Subject: [PATCH 14/22] feat(dev): just dev-handler funciona en macOS Implements #2018: `just dev-handler` funciona en macOS - registra un .app de desarrollo por LaunchServices; off restaura el manejador previo --- scripts/dev-handler.sh | 96 +++++++++++++++++++++++++++++++++++++----- 1 file changed, 86 insertions(+), 10 deletions(-) diff --git a/scripts/dev-handler.sh b/scripts/dev-handler.sh index 1f5c2d095..b42e81289 100755 --- a/scripts/dev-handler.sh +++ b/scripts/dev-handler.sh @@ -3,10 +3,88 @@ set -euo pipefail binario="${CARGO_TARGET_DIR:?}/debug/rfirma" lib_dir="${RFIRMA_LIB_DIR:?}" -destino="$HOME/.local/share/applications" -fichero="$destino/rfirma-dev.desktop" +sistema="$(uname -s)" + +if [ "$sistema" = "Darwin" ]; then + destino="$HOME/.local/share/rfirma-dev" + app="$destino/rfirma-dev.app" + bundle_id="es.rfirma.dev-handler" + lsregister="/System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/LaunchServices.framework/Versions/A/Support/lsregister" +else + destino="$HOME/.local/share/applications" + fichero="$destino/rfirma-dev.desktop" +fi previo="$destino/.rfirma-dev-handler-previo" +aviso_sin_binario() { + if [ ! -x "$binario" ]; then + echo + echo "AVISO: todavia no existe $binario." >&2 + echo "Arranca 'just dev' antes de pulsar el enlace de la sede." >&2 + fi +} + +# JXA es lo único de serie en macOS que llama a LaunchServices sin compilar nada. +macos_handler() { + osascript -l JavaScript -e " + ObjC.import('CoreServices'); + function run() { + try { return ObjC.unwrap(ObjC.castRefToObject(\$.LSCopyDefaultHandlerForURLScheme(\$('afirma')))); } + catch (e) { return ''; } + } + " +} + +macos_set_handler() { + osascript -l JavaScript -e " + ObjC.import('CoreServices'); + function run() { \$.LSSetDefaultHandlerForURLScheme(\$('afirma'), \$('$1')); return ''; } + " > /dev/null +} + +macos_on() { + mkdir -p "$app/Contents/MacOS" + if [ ! -f "$previo" ]; then + macos_handler > "$previo" + fi + # El ejecutable es el binario mismo y no un envoltorio: Tauri recibe afirma:// por evento de Apple. + ln -sf "$binario" "$app/Contents/MacOS/rfirma" + cat > "$app/Contents/Info.plist" < + + + + CFBundleIdentifier$bundle_id + CFBundleNamerFirma (desarrollo) + CFBundleExecutablerfirma + CFBundlePackageTypeAPPL + LSEnvironmentRFIRMA_LIB_DIR$lib_dir + CFBundleURLTypes + + CFBundleURLNameafirma + CFBundleURLSchemesafirma + + + +PLIST + "$lsregister" -f "$app" + macos_set_handler "$bundle_id" + echo + echo "manejador de afirma://: $(macos_handler)" + echo " -> $app" + aviso_sin_binario +} + +macos_off() { + "$lsregister" -u "$app" || true + rm -rf "$app" + if [ -s "$previo" ] && [ "$(cat "$previo")" != "$bundle_id" ]; then + macos_set_handler "$(cat "$previo")" || true + fi + rm -f "$previo" + echo "manejador de afirma://: $(macos_handler)" +} + on() { mkdir -p "$destino" # `off` le devuelve el esquema a quien lo tenía: con AutoFirma al lado, a su lanzador. @@ -29,11 +107,7 @@ on() { echo echo "manejador de afirma://: $(xdg-mime query default x-scheme-handler/afirma)" echo " -> $fichero" - if [ ! -x "$binario" ]; then - echo - echo "AVISO: todavia no existe $binario." >&2 - echo "Arranca 'just dev' antes de pulsar el enlace de la sede." >&2 - fi + aviso_sin_binario } off() { @@ -46,8 +120,10 @@ off() { echo "manejador de afirma://: $(xdg-mime query default x-scheme-handler/afirma || echo 'ninguno')" } -case "${1:-}" in - on) on ;; - off) off ;; +case "${1:-}:$sistema" in + on:Darwin) macos_on ;; + off:Darwin) macos_off ;; + on:*) on ;; + off:*) off ;; *) echo "uso: dev-handler.sh on|off" >&2; exit 1 ;; esac From 63274a64d5cd2690ab540faffbc6a436a145edc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20G=C3=B3mez=20Bachiller?= Date: Mon, 19 Oct 2026 16:32:21 +0200 Subject: [PATCH 15/22] =?UTF-8?q?docs(scripts):=20el=20mapa=20dice=20c?= =?UTF-8?q?=C3=B3mo=20registra=20dev-handler=20en=20macOS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/AGENTS.md b/scripts/AGENTS.md index 9b0517290..5c2fedcde 100644 --- a/scripts/AGENTS.md +++ b/scripts/AGENTS.md @@ -12,7 +12,7 @@ Los arneses que llaman las recetas del `justfile` (ADR-0013). | `pinned-version.sh` | Imprime una clave de `versions.env` para los arranques que no pasan por `just`. | | `check-versions.sh` | La guarda de `versions.env`: ningún valor fijado escrito fuera de él y los pom con la misma AutoFirma. | | `app_version.py` | La versión de la aplicación con tres verbos: `bump` la sube en todos sus sitios, `check` comprueba que cuadran y `changelog` imprime la sección de una versión. No es la guarda de las herramientas fijadas. | -| `dev-handler.sh` | Registra o quita el binario de desarrollo como manejador de `afirma://`. | +| `dev-handler.sh` | Registra o quita el binario de desarrollo como manejador de `afirma://`: por `xdg-mime` en Linux y, en macOS, por LaunchServices con un `.app` de desarrollo. | | `isolated-store.sh` | Monta, para un cliente de la suite de conformidad y un almacén (`rsa`, `ec`, `token`, `token_apart`, `ed25519`, `several`, `expired` o `dnie`), su perfil de usar y tirar con su envoltorio y su raíz de confianza, sin lanzar el cliente. Lo llama la consola web de la suite al resolver el cliente, no una receta. | | `check-glibc.sh` | Comprueba el suelo de glibc de la librería nativa. | | `flatpak-sources.sh` | Regenera las fuentes de cargo vendorizadas del manifiesto flatpak y el sello de `Cargo.lock`. | From 2e1e08304faddd43eba9c541c7d3acfd4cb01cc5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20G=C3=B3mez=20Bachiller?= Date: Mon, 19 Oct 2026 16:48:06 +0200 Subject: [PATCH 16/22] feat(macos): rFirma se actualiza sola en macOS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements #2024: rFirma se actualiza sola en macOS - Canal propio `Channel::MacOs`: lee /macos/latest.json y exige su entrada darwin-aarch64 - El plugin updater también en macOS, con la misma pública minisign que Windows - La entrega empaqueta, firma y sirve el .app.tar.gz en /macos/ con su latest.json --- .github/workflows/build.yml | 8 +- .github/workflows/release.yml | 2 +- docs/adr/0015-canal-de-distribucion-propio.md | 3 +- justfile | 5 + packaging/macos/tauri.macos.json | 6 ++ packaging/repo/Caddyfile | 4 +- packaging/repo/README.md | 21 ++-- packaging/repo/build-tree.sh | 53 +++++++++- packaging/repo/build-tree.test.sh | 96 +++++++++++++++++++ packaging/windows/sign-updater.sh | 52 ++++++---- packaging/windows/sign-updater.test.sh | 39 ++++++-- rfirma-app/src-tauri/Cargo.toml | 4 +- .../src-tauri/src/desktop/adapters/channel.rs | 17 ++-- .../src/desktop/adapters/channel/tests.rs | 37 ++++++- .../src/desktop/adapters/installation.rs | 2 +- .../src/desktop/adapters/installer.rs | 23 +++-- .../src/desktop/adapters/releases.rs | 6 +- .../src/desktop/adapters/releases/tests.rs | 41 +++++++- .../src/desktop/application/status.rs | 2 +- .../src/desktop/application/status/tests.rs | 1 + .../src/desktop/application/version.rs | 13 ++- .../src/desktop/application/version/tests.rs | 56 +++++++++++ .../src-tauri/src/desktop/domain/channel.rs | 14 ++- .../src-tauri/src/site/application/trust.rs | 2 +- .../site/application/trust/tests/unmarked.rs | 5 + scripts/packages-manifest.sh | 4 +- scripts/tests/packages_manifest_test.sh | 20 +++- 27 files changed, 458 insertions(+), 78 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5e2d2047c..b602895cc 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -265,11 +265,13 @@ jobs: - name: Empaqueta la .app y el .dmg run: just bundle - - name: Sube el .dmg + - name: Sube el .dmg y la actualizacion uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: bundle-macos - path: rfirma-app/src-tauri/target/aarch64-apple-darwin/release/bundle/dmg/*.dmg + path: | + rfirma-app/src-tauri/target/aarch64-apple-darwin/release/bundle/dmg/*.dmg + rfirma-app/src-tauri/target/aarch64-apple-darwin/release/bundle/dmg/*.app.tar.gz if-no-files-found: error retention-days: 1 @@ -317,7 +319,7 @@ jobs: name: bundle-windows path: ${{ runner.temp }}/paquetes - - name: Descarga el .dmg de macOS + - name: Descarga el .dmg y la actualizacion de macOS uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: bundle-macos diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9fc63b6c2..d176d9219 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -115,7 +115,7 @@ jobs: packaging/verify-packages.sh paquetes --against "${{ runner.temp }}/SHA256SUMS.construccion" # Despues de comparar con la construccion y antes del `SHA256SUMS`, que cubre el `.sig` (ADR-0015). - - name: Firma de actualizacion de cada -setup.exe + - name: Firma de actualizacion de cada -setup.exe y .app.tar.gz if: ${{ env.IS_PRERELEASE != 'true' }} env: TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} diff --git a/docs/adr/0015-canal-de-distribucion-propio.md b/docs/adr/0015-canal-de-distribucion-propio.md index 5d75b2680..6aa8ee542 100644 --- a/docs/adr/0015-canal-de-distribucion-propio.md +++ b/docs/adr/0015-canal-de-distribucion-propio.md @@ -74,9 +74,10 @@ nunca. | `/apt/` | con `dists/stable/main/binary-amd64/` | | `/rpm/` | con `repodata/` | | `/windows/` | el `-setup.exe` y su `.sig` de cada versión, y `latest.json` | +| `/macos/` | el `.app.tar.gz` y su `.sig` de cada versión que lo trae, y `latest.json` | Estas rutas van dentro del `.flatpakref` y del `.flatpakrepo`, de las órdenes de alta publicadas y del *endpoint* -del *updater* embebido en cada instalación de Windows, así que se fijan aquí. +del *updater* embebido en cada instalación de Windows y de macOS, así que se fijan aquí. **apt con una sola suite**, no repositorio plano: el plano es más barato y **no admite `Suites:`/`Components:` en un fichero `.sources` deb822**, que es el formato obligado para diff --git a/justfile b/justfile index e2440a02f..8186f563d 100644 --- a/justfile +++ b/justfile @@ -608,6 +608,11 @@ bundle: check-native build-ts exit 1 fi echo "dmg: $dmg ($(du -h "$dmg" | cut -f1))" + # La actualización que baja el updater: la .app sola, sin firmar aquí (ADR-0015). + rm -f "$salida"/dmg/*.app.tar.gz + archivo="$salida/dmg/$(basename "$dmg" .dmg).app.tar.gz" + COPYFILE_DISABLE=1 tar -czf "$archivo" -C "$salida/macos" rfirma.app + echo "actualizacion: $archivo ($(du -h "$archivo" | cut -f1))" # Regenera cargo-sources.json y el sello de Cargo.lock. [group('release')] diff --git a/packaging/macos/tauri.macos.json b/packaging/macos/tauri.macos.json index 66e9cf7a9..793d95af0 100644 --- a/packaging/macos/tauri.macos.json +++ b/packaging/macos/tauri.macos.json @@ -10,5 +10,11 @@ "signingIdentity": "-", "hardenedRuntime": false } + }, + "plugins": { + "updater": { + "endpoints": ["https://rfirma.sgomez.me/macos/latest.json"], + "pubkey":"dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDQ0RjFGMDhEMEQyMUY4RjkKUldUNStDRU5qZkR4Ukx5VlNSZWR2aGl2VVJlSzhUSEMvZGVrVDBIc09Va01lYzN3Um1sVGdHYnMK" + } } } diff --git a/packaging/repo/Caddyfile b/packaging/repo/Caddyfile index da9253796..9524394fb 100644 --- a/packaging/repo/Caddyfile +++ b/packaging/repo/Caddyfile @@ -53,7 +53,7 @@ # `.flatpakref` y de las órdenes de alta publicadas—, así que se enumeran # en vez de servir el montaje entero: lo que no esté en esta lista no se # sirve aunque alguien lo deje ahí. - @repositorio path /flatpak/* /apt/* /rpm/* /rfirma.asc /rfirma.flatpakref /rfirma.flatpakrepo /windows/* + @repositorio path /flatpak/* /apt/* /rpm/* /rfirma.asc /rfirma.flatpakref /rfirma.flatpakrepo /windows/* /macos/* handle @repositorio { root * /srv/rfirma-repo/actual header /rfirma.flatpakrepo Content-Type application/vnd.flatpak.repo @@ -79,7 +79,7 @@ # quien las pide es apt, dnf o flatpak, no una persona. handle_errors 404 { import cabeceras - @web not path /flatpak/* /apt/* /rpm/* /rfirma.asc /rfirma.flatpakref /rfirma.flatpakrepo /windows/* + @web not path /flatpak/* /apt/* /rpm/* /rfirma.asc /rfirma.flatpakref /rfirma.flatpakrepo /windows/* /macos/* handle @web { root * /srv rewrite * /404.html diff --git a/packaging/repo/README.md b/packaging/repo/README.md index 09b0a13ff..c062a42c2 100644 --- a/packaging/repo/README.md +++ b/packaging/repo/README.md @@ -265,17 +265,19 @@ Si el `grep` no encuentra nada, el fichero lleva la maestra: no se sube. Al term ## La clave de actualizaciones de Windows -Un par minisign propio, distinto de la GPG (ADR-0015): firma el `-setup.exe` de cada versión -estable para el *updater* de Tauri. La pública se versiona en un solo sitio, -`plugins.updater.pubkey` de `packaging/windows/tauri.windows.json`, la configuración que se -pasa con `--config` al bundler: es en la que confía la versión que se instala. +Un par minisign propio, distinto de la GPG (ADR-0015): firma el `-setup.exe` y el +`.app.tar.gz` de macOS de cada versión estable para el *updater* de Tauri. La pública se +versiona en `plugins.updater.pubkey` de `packaging/windows/tauri.windows.json` y de +`packaging/macos/tauri.macos.json`, las configuraciones que se pasan con `--config` al +bundler: es en la que confía la versión que se instala. Las dos llevan la misma, y una +prueba de `desktop/adapters/releases` lo comprueba. La privada y su contraseña son los secretos `TAURI_SIGNING_PRIVATE_KEY` y `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` del entorno `release`, y no aparecen en ningún otro workflow: `.github/check-workflows.sh` lo comprueba. -`release.yml` firma con `packaging/windows/sign-updater.sh`, que deja el `.sig` al lado del -instalador y falla si falta la clave o si la firma no verifica con la pública embebida en la +`release.yml` firma con `packaging/windows/sign-updater.sh`, que deja el `.sig` al lado de +cada paquete y falla si falta la clave o si la firma no verifica con la pública embebida en la **última etiqueta estable**, que es la que aceptan las instalaciones que ya existen; si todavía no hay ninguna estable con *updater*, con la embebida actual. Todo lo de abajo se hace en un equipo propio, nunca en el CI, desde la raíz del repositorio y con las dependencias de @@ -296,9 +298,10 @@ ficheros, la privada `rfirma-actualizaciones.key` y la pública - **Versionar la pública**, tal cual, por PR a `main`: ```bash - CONF=packaging/windows/tauri.windows.json - jq --arg k "$(cat "$DIR/rfirma-actualizaciones.key.pub")" '.plugins.updater.pubkey = $k' \ - "$CONF" > "$DIR/conf.json" && mv "$DIR/conf.json" "$CONF" + for CONF in packaging/windows/tauri.windows.json packaging/macos/tauri.macos.json; do + jq --arg k "$(cat "$DIR/rfirma-actualizaciones.key.pub")" '.plugins.updater.pubkey = $k' \ + "$CONF" > "$DIR/conf.json" && mv "$DIR/conf.json" "$CONF" + done ``` - **Custodiar la privada**: copia `rfirma-actualizaciones.key` fuera de línea, con copia de diff --git a/packaging/repo/build-tree.sh b/packaging/repo/build-tree.sh index a23101fcc..02e3bb2a7 100755 --- a/packaging/repo/build-tree.sh +++ b/packaging/repo/build-tree.sh @@ -42,6 +42,8 @@ # /apt/ con dists/stable/main/binary-amd64/ # /rpm/ con repodata/ # /windows/ el `-setup.exe` y el `.sig` de cada version, y `latest.json` +# /macos/ el `.app.tar.gz` y el `.sig` de cada version que lo trae, y +# `latest.json` # # APT CON SUITE `stable` Y NO REPOSITORIO PLANO (ID-175): el plano es mas # barato y NO admite `Suites:`/`Components:` en un `.sources` deb822, que es el @@ -99,7 +101,7 @@ exe_de_la_version() { return 1 fi mapfile -t exes < <("$manifiesto" files "$dir" nsis) - mapfile -t sigs < <("$manifiesto" files "$dir" minisign) + mapfile -t sigs < <("$manifiesto" files "$dir" minisign | grep -v '\.app\.tar\.gz\.sig$' || true) if [ "${#exes[@]}" -ne 1 ] || [ "${#sigs[@]}" -ne 1 ] || [ "${sigs[0]}" != "${exes[0]}.sig" ]; then echo "a la version $(basename "$dir") le falta el .exe de Windows o su .sig" >&2 return 1 @@ -111,6 +113,25 @@ exe_de_la_version() { echo "${exes[0]}" } +# La actualizacion de macOS no la traen las versiones de antes de macOS: sin ella, nada. +app_de_la_version() { + local dir="$1" apps sigs + mapfile -t apps < <("$manifiesto" files "$dir" updater) + if [ "${#apps[@]}" -eq 0 ]; then + return 0 + fi + mapfile -t sigs < <("$manifiesto" files "$dir" minisign | grep '\.app\.tar\.gz\.sig$' || true) + if [ "${#apps[@]}" -ne 1 ] || [ "${#sigs[@]}" -ne 1 ] || [ "${sigs[0]}" != "${apps[0]}.sig" ]; then + echo "a la version $(basename "$dir") le falta la actualizacion de macOS o su .sig" >&2 + return 1 + fi + if [ ! -f "$dir/${apps[0]}" ] || [ ! -f "$dir/${sigs[0]}" ]; then + echo "el manifiesto de $(basename "$dir") nombra ${apps[0]} y ${sigs[0]} y no estan en la Release" >&2 + return 1 + fi + echo "${apps[0]}" +} + # Las versiones de la serie, en orden de version y no de listado: el orden en # que se importan los bundles es la historia del repositorio ostree, asi que # tiene que ser el mismo en cada reconstruccion. @@ -168,6 +189,10 @@ exe_de=() for version in "${versiones[@]}"; do exe_de+=("$(exe_de_la_version "$serie/$version")") || exit 1 done +app_de=() +for version in "${versiones[@]}"; do + app_de+=("$(app_de_la_version "$serie/$version")") || exit 1 +done # Las herramientas, TODAS de golpe y por su nombre de paquete: que la # publicacion se caiga a la mitad porque falta `createrepo_c` deja el arbol a @@ -235,7 +260,7 @@ ceba_el_agente() { } rm -rf "$arbol" -mkdir -p "$arbol/flatpak" "$arbol/apt" "$arbol/rpm" "$arbol/windows" +mkdir -p "$arbol/flatpak" "$arbol/apt" "$arbol/rpm" "$arbol/windows" "$arbol/macos" # La clave publica no la genera nada: es la misma que firma las Releases, y # aqui se copia tal cual para que apt y dnf la encuentren en la ruta que dicen @@ -439,4 +464,28 @@ jq -n \ platforms: {"windows-x86_64": {signature: ($firma | rtrimstr("\n")), url: $url}}}' \ > "$arbol/windows/latest.json" +# ----------------------------------------------------------------- 5. macos -- +# Como `windows/`, con las versiones estables que traen la actualizacion de macOS. +echo " macos: colocando las actualizaciones y latest.json" +ultima_app="" +for i in "${!versiones[@]}"; do + [[ "${versiones[$i]}" == *-* ]] && continue + [ -n "${app_de[$i]}" ] || continue + cp "$serie/${versiones[$i]}/${app_de[$i]}" "$serie/${versiones[$i]}/${app_de[$i]}.sig" "$arbol/macos/" + ultima_app="$i" +done +if [ -z "$ultima_app" ]; then + echo " macos: ninguna version estable trae la actualizacion de macOS, no hay latest.json" +else + app_ultima="${app_de[$ultima_app]}" + jq -n \ + --arg version "${versiones[$ultima_app]#v}" \ + --arg fecha "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + --arg url "https://rfirma.sgomez.me/macos/$(jq -rn --arg f "$app_ultima" '$f | @uri')" \ + --rawfile firma "$arbol/macos/$app_ultima.sig" \ + '{version: $version, notes: "", pub_date: $fecha, + platforms: {"darwin-aarch64": {signature: ($firma | rtrimstr("\n")), url: $url}}}' \ + > "$arbol/macos/latest.json" +fi + echo "OK arbol construido en $arbol con ${#versiones[@]} version(es): ${versiones[*]}" diff --git a/packaging/repo/build-tree.test.sh b/packaging/repo/build-tree.test.sh index 06896e5a5..792519e89 100755 --- a/packaging/repo/build-tree.test.sh +++ b/packaging/repo/build-tree.test.sh @@ -77,6 +77,20 @@ instalador_de_pega() { "$raiz/scripts/packages-manifest.sh" write "$dir" } +actualizacion_de_macos_de_pega() { + local dir="$1" version="$2" + echo "app $version" > "$dir/rFirma_${version}_aarch64.app.tar.gz" + printf 'firma mac de %s\n' "$version" > "$dir/rFirma_${version}_aarch64.app.tar.gz.sig" + "$raiz/scripts/packages-manifest.sh" write "$dir" +} + +sin_actualizacion_de_macos() { + local dir="$1" + rm -f "$dir"/*.app.tar.gz "$dir"/*.app.tar.gz.sig + jq 'map(select(.platform != "macos"))' "$dir/paquetes.json" > "$dir/paquetes.json.nuevo" + mv "$dir/paquetes.json.nuevo" "$dir/paquetes.json" +} + # an_incomplete_series_stops_the_publication incompleta="$tmp/incompleta" paquetes_de_pega "$incompleta/v0.4.0" "0.4.0" @@ -151,6 +165,20 @@ else ok "una clave publica que no existe detiene la publicacion" fi +# a_macos_update_without_its_signature_stops_the_publication +mac_sin_sig="$tmp/mac-sin-sig" +paquetes_de_pega "$mac_sin_sig/v0.4.0" "0.4.0" +actualizacion_de_macos_de_pega "$mac_sin_sig/v0.4.0" "0.4.0" +rm "$mac_sin_sig/v0.4.0"/*.app.tar.gz.sig +"$raiz/scripts/packages-manifest.sh" write "$mac_sin_sig/v0.4.0" +if salida="$("$construye" "$mac_sin_sig" "$tmp/arbol-malo" "$tmp/rfirma.asc" "$SIN_FIRMA" 2>&1)"; then + fail "una actualizacion de macOS sin su .sig no detiene la publicacion" +elif grep -q "actualizacion de macOS" <<< "$salida"; then + ok "una actualizacion de macOS sin su .sig detiene la publicacion" +else + fail "una actualizacion de macOS sin su .sig se para por otro motivo: $salida" +fi + # --------------------------------------------------------------------------- # Las ordenes de alta de la landing: no necesitan construir nada y son la # unica atadura entre lo que se sirve y lo que la gente copia. @@ -251,12 +279,16 @@ for version in "${versiones[@]}"; do deb_de_prueba "$version" "$dir/rfirma_${version}_amd64.deb" rpm_de_prueba "$version" "$dir/rfirma-$version.x86_64.rpm" instalador_de_pega "$dir" "$version" + # La primera version es de antes de macOS: no trae su actualizacion. + [ "$version" = 0.4.0 ] || actualizacion_de_macos_de_pega "$dir" "$version" done # Assets subidos a mano a la Release DESPUES del manifiesto: no estan en el. echo "de mas" > "$serie/v0.4.10/rFirma_9.9.9_x64-setup.exe" echo "de mas" > "$serie/v0.4.10/rFirma_9.9.9_x64-setup.exe.sig" echo "de mas" > "$serie/v0.4.10/subido-a-mano.exe" +echo "de mas" > "$serie/v0.4.10/rFirma_9.9.9_aarch64.app.tar.gz" +echo "de mas" > "$serie/v0.4.10/rFirma_9.9.9_aarch64.app.tar.gz.sig" # --------------------------------------------------------------------------- # El arbol, dos veces y en dos directorios distintos @@ -471,6 +503,62 @@ else fail "una candidata aparece en windows/ o en latest.json" fi +# --------------------------------------------------------------------------- +# /macos/: la actualizacion y latest.json +# --------------------------------------------------------------------------- +# macos_has_the_update_and_signature_of_every_version_that_brings_it +faltan_de_macos=0 +for version in 0.4.2 0.4.10; do + for fichero in "rFirma_${version}_aarch64.app.tar.gz" "rFirma_${version}_aarch64.app.tar.gz.sig"; do + [ -f "$tmp/arbol/macos/$fichero" ] || { faltan_de_macos=1; echo " falta $fichero" >&2; } + done +done +if [ "$faltan_de_macos" -eq 0 ] && [ -z "$(find "$tmp/arbol/macos" -name '*0.4.0*' -print -quit)" ] \ + && [ ! -e "$tmp/arbol/macos/rFirma_9.9.9_aarch64.app.tar.gz" ]; then + ok "macos/ trae la actualizacion y su .sig de cada version que la trae, y nada fuera del manifiesto" +else + fail "macos/ no trae lo que dicen los manifiestos de la serie" +fi + +# macos_latest_json_announces_the_darwin_aarch64_entry_of_the_highest_version +latest_mac="$tmp/arbol/macos/latest.json" +app_alta="rFirma_0.4.10_aarch64.app.tar.gz" +if [ -f "$latest_mac" ] \ + && [ "$(jq -r .version "$latest_mac")" = "0.4.10" ] \ + && [ "$(jq -r '.platforms | keys | join(",")' "$latest_mac")" = "darwin-aarch64" ] \ + && [ "$(jq -r '.platforms["darwin-aarch64"].url' "$latest_mac")" = "https://rfirma.sgomez.me/macos/$app_alta" ] \ + && [ "$(jq -r '.platforms["darwin-aarch64"].signature' "$latest_mac")" = "$(cat "$tmp/arbol/macos/$app_alta.sig")" ] \ + && jq -e '.pub_date != null and .notes != null' "$latest_mac" > /dev/null; then + ok "el latest.json de macos anuncia la entrada darwin-aarch64 de la version mas alta" +else + fail "el latest.json de macos no cumple el contrato del updater: $(cat "$latest_mac" 2>&1)" +fi + +# a_candidate_never_appears_in_macos +actualizacion_de_macos_de_pega "$con_candidata/v0.4.11-rc.1" "0.4.11-rc.1" +"$construye" "$con_candidata" "$tmp/arbol-rc-mac" "$tmp/rfirma.asc" "$SIN_FIRMA" > /dev/null 2>&1 \ + || fail "la construccion con una candidata de macOS en la serie falla" +if [ "$(jq -r .version "$tmp/arbol-rc-mac/macos/latest.json")" = "0.4.10" ] \ + && [ -z "$(find "$tmp/arbol-rc-mac/macos" -name '*rc*' -print -quit)" ]; then + ok "una candidata no aparece en macos/ ni en su latest.json" +else + fail "una candidata aparece en macos/ o en su latest.json" +fi + +# a_series_without_any_macos_update_still_builds_without_announcing_one +sin_mac="$tmp/sin-mac" +cp -r "$serie" "$sin_mac" +for version in "${versiones[@]}"; do + sin_actualizacion_de_macos "$sin_mac/v$version" +done +if "$construye" "$sin_mac" "$tmp/arbol-sin-mac" "$tmp/rfirma.asc" "$SIN_FIRMA" > /dev/null 2>&1 \ + && [ ! -e "$tmp/arbol-sin-mac/macos/latest.json" ] \ + && [ -f "$tmp/arbol-sin-mac/windows/latest.json" ]; then + ok "una serie sin actualizacion de macOS se construye y no anuncia ninguna" +else + fail "una serie sin actualizacion de macOS no se construye o anuncia una" +fi + # the_caddyfile_serves_windows_from_the_current_link if grep -E '^\s*@repositorio path .*/windows/\*' "$raiz/packaging/repo/Caddyfile" > /dev/null; then ok "el Caddyfile sirve /windows/* desde actual" @@ -478,6 +566,14 @@ else fail "el Caddyfile no sirve /windows/*" fi +# the_caddyfile_serves_macos_from_the_current_link +if grep -E '^\s*@repositorio path .*/macos/\*' "$raiz/packaging/repo/Caddyfile" > /dev/null \ + && grep -E '^\s*@web not path .*/macos/\*' "$raiz/packaging/repo/Caddyfile" > /dev/null; then + ok "el Caddyfile sirve /macos/* desde actual" +else + fail "el Caddyfile no sirve /macos/*" +fi + # --------------------------------------------------------------------------- # Lo demas # --------------------------------------------------------------------------- diff --git a/packaging/windows/sign-updater.sh b/packaging/windows/sign-updater.sh index 9c43ca15d..5cc64f949 100755 --- a/packaging/windows/sign-updater.sh +++ b/packaging/windows/sign-updater.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Firma de actualización (minisign) de cada instalador de Windows de una entrega, comprobada contra la pública embebida en la última etiqueta estable (ADR-0015). +# Firma de actualización (minisign) de cada instalador de Windows y de cada `.app.tar.gz` de macOS de una entrega, comprobada contra la pública embebida en la última etiqueta estable de su plataforma (ADR-0015). # # Uso: packaging/windows/sign-updater.sh # Entorno: TAURI_SIGNING_PRIVATE_KEY y TAURI_SIGNING_PRIVATE_KEY_PASSWORD. @@ -13,11 +13,13 @@ fi root="$(cd "$(dirname "$0")/../.." && pwd)" manifest="$root/scripts/packages-manifest.sh" -config=packaging/windows/tauri.windows.json +windows_config=packaging/windows/tauri.windows.json +macos_config=packaging/macos/tauri.macos.json mapfile -t installers < <("$manifest" files "$dir" nsis) -if [ "${#installers[@]}" -eq 0 ]; then - echo "sin instalador de Windows que firmar" +mapfile -t archives < <("$manifest" files "$dir" updater) +if [ "${#installers[@]}" -eq 0 ] && [ "${#archives[@]}" -eq 0 ]; then + echo "sin instalador de Windows ni actualizacion de macOS que firmar" exit 0 fi @@ -27,7 +29,7 @@ embedded_pubkey() { # No la actual: en la versión puente de una rotación ya embebe la nueva (ADR-0015). trusted_pubkey() { - local tag pubkey + local config="$1" tag pubkey while read -r tag; do [[ "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || continue pubkey="$(git -C "$root" show "$tag:$config" 2> /dev/null | embedded_pubkey)" || pubkey="" @@ -39,12 +41,19 @@ trusted_pubkey() { echo "HEAD $(embedded_pubkey < "$root/$config")" } -read -r source pubkey < <(trusted_pubkey) -if [ -z "${pubkey-}" ]; then - echo "falta plugins.updater.pubkey en $config ($source)" >&2 - echo "Se crea como dice «La clave de actualizaciones de Windows» en packaging/repo/README.md." >&2 - exit 1 -fi +declare -A source_of=() pubkey_of=() +for config in "$windows_config" "$macos_config"; do + if [ "$config" = "$windows_config" ] && [ "${#installers[@]}" -eq 0 ]; then continue; fi + if [ "$config" = "$macos_config" ] && [ "${#archives[@]}" -eq 0 ]; then continue; fi + read -r source pubkey < <(trusted_pubkey "$config") + if [ -z "${pubkey-}" ]; then + echo "falta plugins.updater.pubkey en $config ($source)" >&2 + echo "Se crea como dice «La clave de actualizaciones de Windows» en packaging/repo/README.md." >&2 + exit 1 + fi + source_of["$config"]="$source" + pubkey_of["$config"]="$pubkey" +done if [ -z "${TAURI_SIGNING_PRIVATE_KEY:-}" ] || [ -z "${TAURI_SIGNING_PRIVATE_KEY_PASSWORD:-}" ]; then echo "faltan TAURI_SIGNING_PRIVATE_KEY o TAURI_SIGNING_PRIVATE_KEY_PASSWORD en el entorno" >&2 exit 1 @@ -59,18 +68,25 @@ done cli_version="$(jq -r '.devDependencies["@tauri-apps/cli"]' "$root/rfirma-app/package.json")" work="$(mktemp -d)" trap 'rm -rf "$work"' EXIT -printf '%s' "$pubkey" | base64 -d > "$work/updater.pub" -for name in "${installers[@]}"; do - installer="$dir/$name" - rm -f "$installer.sig" - npx --yes "@tauri-apps/cli@$cli_version" signer sign "$installer" < /dev/null - base64 -d "$installer.sig" > "$work/updater.minisig" - if ! minisign -V -q -p "$work/updater.pub" -m "$installer" -x "$work/updater.minisig"; then +sign_against() { + local config="$1" name="$2" package="$dir/$2" source="${source_of[$1]}" + printf '%s' "${pubkey_of[$config]}" | base64 -d > "$work/updater.pub" + rm -f "$package.sig" + npx --yes "@tauri-apps/cli@$cli_version" signer sign "$package" < /dev/null + base64 -d "$package.sig" > "$work/updater.minisig" + if ! minisign -V -q -p "$work/updater.pub" -m "$package" -x "$work/updater.minisig"; then echo "$name.sig no verifica con la clave publica embebida en $config de $source" >&2 exit 1 fi echo "OK $name.sig verifica con la clave publica embebida en $config de $source" +} + +for name in "${installers[@]}"; do + sign_against "$windows_config" "$name" +done +for name in "${archives[@]}"; do + sign_against "$macos_config" "$name" done "$manifest" write "$dir" diff --git a/packaging/windows/sign-updater.test.sh b/packaging/windows/sign-updater.test.sh index b25898ba7..4b8427625 100755 --- a/packaging/windows/sign-updater.test.sh +++ b/packaging/windows/sign-updater.test.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Prueba la firma de actualización del instalador de Windows: sin clave no firma, y la firma verifica contra la pública embebida en la última estable y falla contra otra. +# Prueba la firma de actualización del instalador de Windows y del `.app.tar.gz` de macOS: sin clave no firma, y la firma verifica contra la pública embebida en la última estable y falla contra otra. set -euo pipefail root="$(cd "$(dirname "$0")/../.." && pwd)" @@ -19,20 +19,27 @@ git_in() { -c tag.gpgsign=false "$@" > /dev/null } -embed() { - local repo="$1" pubkey="$2" config="$1/packaging/windows/tauri.windows.json" +embed_in() { + local config="$1" original="$2" pubkey="$3" if [ -n "$pubkey" ]; then - jq --arg k "$pubkey" '.plugins.updater.pubkey = $k' "$root/packaging/windows/tauri.windows.json" > "$config" + jq --arg k "$pubkey" '.plugins.updater.pubkey = $k' "$original" > "$config" else - jq 'del(.plugins.updater)' "$root/packaging/windows/tauri.windows.json" > "$config" + jq 'del(.plugins.updater)' "$original" > "$config" fi +} + +embed() { + local repo="$1" pubkey="$2" platform + for platform in windows macos; do + embed_in "$repo/packaging/$platform/tauri.$platform.json" "$root/packaging/$platform/tauri.$platform.json" "$pubkey" + done git_in "$repo" add -A git_in "$repo" commit -q --allow-empty -m "embebe $pubkey" } repo_embedding() { local repo="$1" - mkdir -p "$repo/packaging/windows" "$repo/scripts" "$repo/rfirma-app" + mkdir -p "$repo/packaging/windows" "$repo/packaging/macos" "$repo/scripts" "$repo/rfirma-app" cp "$root/packaging/windows/sign-updater.sh" "$repo/packaging/windows/" cp "$manifest" "$repo/scripts/" cp "$root/rfirma-app/package.json" "$repo/rfirma-app/" @@ -44,12 +51,13 @@ delivery() { mkdir "$1" echo flatpak > "$1/me.sgomez.rfirma.flatpak" [ "${2-}" = sin-instalador ] || echo instalador > "$1/rfirma_1.0.0_x64-setup.exe" + [ "${2-}" != con-macos ] || echo app > "$1/rfirma_1.0.0_aarch64.app.tar.gz" "$manifest" write "$1" } signs() { local repo="$1" key="$2" password="$3" name="$4" - delivery "$tmp/$name" + delivery "$tmp/$name" "${5-}" TAURI_SIGNING_PRIVATE_KEY="$(cat "$key")" TAURI_SIGNING_PRIVATE_KEY_PASSWORD="$password" \ "$repo/packaging/windows/sign-updater.sh" "$tmp/$name" > /dev/null 2>&1 } @@ -67,6 +75,18 @@ if TAURI_SIGNING_PRIVATE_KEY=k TAURI_SIGNING_PRIVATE_KEY_PASSWORD=p \ fi [ ! -e "$tmp/sin-publica-entrega/rfirma_1.0.0_x64-setup.exe.sig" ] || fail "sin clave publica no se firma" +repo_embedding "$tmp/macos-sin-publica" cHVibGljYQ== +embed_in "$tmp/macos-sin-publica/packaging/macos/tauri.macos.json" "$root/packaging/macos/tauri.macos.json" "" +git_in "$tmp/macos-sin-publica" commit -q -am "macos sin updater" +delivery "$tmp/macos-sin-publica-entrega" con-macos +if salida="$(TAURI_SIGNING_PRIVATE_KEY=k TAURI_SIGNING_PRIVATE_KEY_PASSWORD=p \ + "$tmp/macos-sin-publica/packaging/windows/sign-updater.sh" "$tmp/macos-sin-publica-entrega" 2>&1)"; then + fail "sin clave publica embebida en la configuracion de macOS: debia fallar" +fi +grep -qF packaging/macos/tauri.macos.json <<< "$salida" || fail "sin clave publica de macOS: dice que configuracion" +[ ! -e "$tmp/macos-sin-publica-entrega/rfirma_1.0.0_x64-setup.exe.sig" ] \ + || fail "sin clave publica de macOS no se firma tampoco el instalador de Windows" + repo_embedding "$tmp/con-publica" cHVibGljYQ== delivery "$tmp/sin-privada" if env -u TAURI_SIGNING_PRIVATE_KEY TAURI_SIGNING_PRIVATE_KEY_PASSWORD=p \ @@ -103,6 +123,11 @@ signs "$repo" "$tmp/vieja.key" vieja sin-estable-vieja || fail "sin estable, ver if signs "$repo" "$tmp/nueva.key" nueva sin-estable-nueva; then fail "sin estable, una firma que no verifica con la embebida actual: debia fallar" fi +signs "$repo" "$tmp/vieja.key" vieja con-macos con-macos || fail "firma tambien la actualizacion de macOS" +[ "$("$manifest" files "$tmp/con-macos" minisign | sort | tr '\n' ' ')" \ + = "rfirma_1.0.0_aarch64.app.tar.gz.sig rfirma_1.0.0_x64-setup.exe.sig " ] \ + || fail "el .sig de la actualizacion de macOS es una fila del manifiesto" +[ "$(cat "$tmp/con-macos/rfirma_1.0.0_aarch64.app.tar.gz")" = app ] || fail "firmar no toca el .app.tar.gz" repo="$tmp/estable-sin-updater" repo_embedding "$repo" "" diff --git a/rfirma-app/src-tauri/Cargo.toml b/rfirma-app/src-tauri/Cargo.toml index a8384d7ed..94c52b956 100644 --- a/rfirma-app/src-tauri/Cargo.toml +++ b/rfirma-app/src-tauri/Cargo.toml @@ -216,7 +216,7 @@ windows-sys = { version = "0.61", features = [ # de la CA local sin persistirla en el almacén de claves del usuario (ADR-0036). tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "tls12"] } # Instalar desde la aplicación la versión que anuncia `latest.json`, verificada con minisign. -# Solo en Windows: en Linux instala el gestor de paquetes (ADR-0035). +# Solo en Windows y macOS: en Linux instala el gestor de paquetes (ADR-0035). tauri-plugin-updater = { version = "2", default-features = false, features = ["native-tls"] } # macOS no trae OpenSSL: se compila desde las fuentes, sin depender del de @@ -234,6 +234,8 @@ objc2-foundation = { version = "0.3", default-features = false, features = ["std security-framework = "3.7" security-framework-sys = "2.17" core-foundation = "0.10" +# Como en Windows: el `.app` se actualiza sola, también la que instaló Homebrew (ADR-0040). +tauri-plugin-updater = { version = "2", default-features = false, features = ["native-tls"] } [target.'cfg(not(windows))'.dev-dependencies] # La tarjeta falsa de las pruebas: no se compila en Windows y nunca llega a un paquete (ADR-0004). diff --git a/rfirma-app/src-tauri/src/desktop/adapters/channel.rs b/rfirma-app/src-tauri/src/desktop/adapters/channel.rs index 1a59d31bd..4a218b3f1 100644 --- a/rfirma-app/src-tauri/src/desktop/adapters/channel.rs +++ b/rfirma-app/src-tauri/src/desktop/adapters/channel.rs @@ -6,19 +6,20 @@ use std::path::Path; const SANDBOX_MARKER: &str = "/.flatpak-info"; pub use crate::desktop::domain::channel::Channel; +use crate::desktop::domain::platform::Platform; impl Channel { /// Detecta el canal examinando la presencia del testigo de sandbox. pub fn detected() -> Self { - Self::on_platform(cfg!(windows), Path::new(SANDBOX_MARKER)) + Self::on_platform(Platform::CURRENT, Path::new(SANDBOX_MARKER)) } - /// Windows es su propio canal; en el resto manda el testigo de sandbox. - fn on_platform(windows: bool, marker: &Path) -> Self { - if windows { - Self::Windows - } else { - Self::over(marker) + /// Windows y macOS son su propio canal; en Linux manda el testigo de sandbox. + fn on_platform(platform: Platform, marker: &Path) -> Self { + match platform { + Platform::Windows => Self::Windows, + Platform::MacOs => Self::MacOs, + Platform::Linux => Self::over(marker), } } @@ -45,7 +46,7 @@ pub enum RegisteredHandlers { pub fn registered_handlers_for_scheme(channel: Channel, scheme: &str) -> RegisteredHandlers { match channel { Channel::Flatpak => RegisteredHandlers::NotAvailableInsideTheSandbox, - Channel::Native | Channel::Windows => { + Channel::Native | Channel::Windows | Channel::MacOs => { #[cfg(target_os = "linux")] let handlers = gio::AppInfo::all_for_type(&content_type_for(scheme)) .iter() diff --git a/rfirma-app/src-tauri/src/desktop/adapters/channel/tests.rs b/rfirma-app/src-tauri/src/desktop/adapters/channel/tests.rs index e8bdc66c1..784721ee9 100644 --- a/rfirma-app/src-tauri/src/desktop/adapters/channel/tests.rs +++ b/rfirma-app/src-tauri/src/desktop/adapters/channel/tests.rs @@ -1,5 +1,6 @@ use super::*; use crate::desktop::domain::handlers::OUR_DESKTOP_FILE; +use crate::desktop::domain::platform::Platform; use std::fs; #[test] @@ -27,13 +28,43 @@ fn windows_is_its_own_channel_whatever_the_marker_says() { let marker = directory.path().join(".flatpak-info"); fs::write(&marker, b"[Application]\n").expect("deberia escribirse"); - assert_eq!(Channel::on_platform(true, &marker), Channel::Windows); - assert_eq!(Channel::on_platform(false, &marker), Channel::Flatpak); + assert_eq!( + Channel::on_platform(Platform::Windows, &marker), + Channel::Windows + ); + assert_eq!( + Channel::on_platform(Platform::Linux, &marker), + Channel::Flatpak + ); +} + +#[test] +fn macos_is_its_own_channel_whatever_the_marker_says() { + let directory = tempfile::tempdir().expect("deberia haber directorio temporal"); + let marker = directory.path().join(".flatpak-info"); + fs::write(&marker, b"[Application]\n").expect("deberia escribirse"); + + assert_eq!( + Channel::on_platform(Platform::MacOs, &marker), + Channel::MacOs + ); +} + +#[test] +fn a_homebrew_installation_does_not_turn_off_the_updater() { + let directory = tempfile::tempdir().expect("deberia haber directorio temporal"); + let caskroom = directory.path().join("opt/homebrew/Caskroom/rfirma"); + fs::create_dir_all(&caskroom).expect("deberia crearse"); + + let channel = Channel::on_platform(Platform::MacOs, &caskroom.join(".flatpak-info")); + + assert!(channel.installs_from_the_app()); } #[test] -fn only_the_windows_channel_installs_from_the_app() { +fn windows_and_macos_install_from_the_app_and_linux_does_not() { assert!(Channel::Windows.installs_from_the_app()); + assert!(Channel::MacOs.installs_from_the_app()); assert!(!Channel::Native.installs_from_the_app()); assert!(!Channel::Flatpak.installs_from_the_app()); } diff --git a/rfirma-app/src-tauri/src/desktop/adapters/installation.rs b/rfirma-app/src-tauri/src/desktop/adapters/installation.rs index 4b26d6e5f..56a1e62c5 100644 --- a/rfirma-app/src-tauri/src/desktop/adapters/installation.rs +++ b/rfirma-app/src-tauri/src/desktop/adapters/installation.rs @@ -16,7 +16,7 @@ pub fn this_installation(channel: Channel) -> Installation { match channel { Channel::Flatpak => Installation::Flatpak(flatpak_installation()), Channel::Windows => Installation::WindowsInstaller(installer_scope()), - Channel::Native if cfg!(target_os = "macos") => Installation::MacOs, + Channel::MacOs => Installation::MacOs, Channel::Native => native_installation(), } } diff --git a/rfirma-app/src-tauri/src/desktop/adapters/installer.rs b/rfirma-app/src-tauri/src/desktop/adapters/installer.rs index 0d997ee17..5f7efeb2e 100644 --- a/rfirma-app/src-tauri/src/desktop/adapters/installer.rs +++ b/rfirma-app/src-tauri/src/desktop/adapters/installer.rs @@ -1,4 +1,4 @@ -//! El instalador de la versión anunciada detrás de `UpdateInstaller`: el plugin updater en Windows, «no disponible» en el resto (ADR-0035). +//! El instalador de la versión anunciada detrás de `UpdateInstaller`: el plugin updater en Windows y macOS, «no disponible» en Linux (ADR-0035). use crate::desktop::domain::installation::InstallFailure; use crate::desktop::ports::UpdateInstaller; @@ -6,7 +6,7 @@ use crate::desktop::ports::UpdateInstaller; /// La clave del plugin en `plugins` de la configuración de Tauri. const UPDATER: &str = "updater"; -/// Registra el plugin updater, solo en Windows y solo si la configuración de Windows lo trae. +/// Registra el plugin updater, solo en Windows y macOS y solo si su configuración de plataforma lo trae. pub fn with_the_updater( builder: tauri::Builder, config: &tauri::Config, @@ -14,14 +14,14 @@ pub fn with_the_updater( if !configured(config) { return builder; } - #[cfg(windows)] + #[cfg(any(windows, target_os = "macos"))] let builder = builder.plugin(tauri_plugin_updater::Builder::new().build()); builder } -/// El instalador de esta máquina: el plugin en Windows con su configuración, y si no, ninguno. +/// El instalador de esta máquina: el plugin en Windows y macOS con su configuración, y si no, ninguno. pub fn this_desktop(app: tauri::AppHandle) -> Box { - #[cfg(windows)] + #[cfg(any(windows, target_os = "macos"))] if configured(app.config()) { return Box::new(plugin::PluginInstaller::new(app)); } @@ -29,7 +29,7 @@ pub fn this_desktop(app: tauri::AppHandle) -> Box { Box::new(NotAvailable) } -/// Sin la configuración de Windows que pasa `--config`, `just dev` y `cargo test` no la traen (ADR-0035). +/// Sin la configuración de plataforma que pasa `--config`, `just dev` y `cargo test` no la traen (ADR-0035). fn configured(config: &tauri::Config) -> bool { config.plugins.0.contains_key(UPDATER) } @@ -47,7 +47,7 @@ impl UpdateInstaller for NotAvailable { } } -#[cfg(windows)] +#[cfg(any(windows, target_os = "macos"))] mod plugin { use std::sync::Mutex; @@ -56,7 +56,7 @@ mod plugin { use crate::desktop::domain::installation::InstallFailure; use crate::desktop::ports::UpdateInstaller; - /// El plugin updater: `latest.json`, la firma minisign y el NSIS en modo `passive`. + /// El plugin updater: `latest.json`, la firma minisign y el NSIS en modo `passive` o el `.app` sustituida. pub struct PluginInstaller { app: tauri::AppHandle, found: Mutex>, @@ -94,7 +94,12 @@ mod plugin { tauri::async_runtime::block_on(async move { update.download_and_install(|_, _| {}, || {}).await }) - }) + })?; + // En Windows el NSIS ya cerró rFirma; en macOS la `.app` nueva solo corre al relanzarla. + if cfg!(target_os = "macos") { + self.app.request_restart(); + } + Ok(()) } } diff --git a/rfirma-app/src-tauri/src/desktop/adapters/releases.rs b/rfirma-app/src-tauri/src/desktop/adapters/releases.rs index c82db6194..6c70e4b1d 100644 --- a/rfirma-app/src-tauri/src/desktop/adapters/releases.rs +++ b/rfirma-app/src-tauri/src/desktop/adapters/releases.rs @@ -1,4 +1,4 @@ -//! Consulta HTTP de la última publicación oficial según el canal (la API de GitHub o el `latest.json` de rfirma.sgomez.me en Windows); no es el instalador, que es `installer.rs` (ADR-0015). +//! Consulta HTTP de la última publicación oficial según el canal (la API de GitHub o el `latest.json` de rfirma.sgomez.me en Windows y macOS); no es el instalador, que es `installer.rs` (ADR-0015). use std::time::Duration; @@ -11,6 +11,9 @@ pub const LATEST_RELEASE_ENDPOINT: &str = /// `latest.json` estable del servidor propio, el feed del canal de Windows. pub const WINDOWS_FEED_ENDPOINT: &str = "https://rfirma.sgomez.me/windows/latest.json"; +/// `latest.json` estable del servidor propio, el feed del canal de macOS. +pub const MACOS_FEED_ENDPOINT: &str = "https://rfirma.sgomez.me/macos/latest.json"; + /// Tiempo máximo de espera para la respuesta del servidor. const TIMEOUT: Duration = Duration::from_secs(4); @@ -24,6 +27,7 @@ pub fn latest_release(channel: Channel) -> Option { fn endpoint_for(channel: Channel) -> &'static str { match channel { Channel::Windows => WINDOWS_FEED_ENDPOINT, + Channel::MacOs => MACOS_FEED_ENDPOINT, Channel::Native | Channel::Flatpak => LATEST_RELEASE_ENDPOINT, } } diff --git a/rfirma-app/src-tauri/src/desktop/adapters/releases/tests.rs b/rfirma-app/src-tauri/src/desktop/adapters/releases/tests.rs index 7cc924d52..67f6d1663 100644 --- a/rfirma-app/src-tauri/src/desktop/adapters/releases/tests.rs +++ b/rfirma-app/src-tauri/src/desktop/adapters/releases/tests.rs @@ -1,15 +1,20 @@ -use super::{endpoint_for, LATEST_RELEASE_ENDPOINT, WINDOWS_FEED_ENDPOINT}; +use super::{endpoint_for, LATEST_RELEASE_ENDPOINT, MACOS_FEED_ENDPOINT, WINDOWS_FEED_ENDPOINT}; use crate::desktop::domain::channel::Channel; #[test] fn each_channel_asks_its_own_feed() { assert_eq!(endpoint_for(Channel::Windows), WINDOWS_FEED_ENDPOINT); + assert_eq!(endpoint_for(Channel::MacOs), MACOS_FEED_ENDPOINT); assert_eq!(endpoint_for(Channel::Native), LATEST_RELEASE_ENDPOINT); assert_eq!(endpoint_for(Channel::Flatpak), LATEST_RELEASE_ENDPOINT); assert_eq!( WINDOWS_FEED_ENDPOINT, "https://rfirma.sgomez.me/windows/latest.json" ); + assert_eq!( + MACOS_FEED_ENDPOINT, + "https://rfirma.sgomez.me/macos/latest.json" + ); } #[test] @@ -19,3 +24,37 @@ fn it_asks_github_for_the_latest_release_and_nobody_else() { "se le pregunta a GitHub, no a rfirma.sgomez.me (ADR-0015)" ); } + +const WINDOWS_CONFIG: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../packaging/windows/tauri.windows.json" +)); +const MACOS_CONFIG: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../packaging/macos/tauri.macos.json" +)); + +fn updater_of(config: &str) -> serde_json::Value { + let config: serde_json::Value = serde_json::from_str(config).expect("es JSON"); + config["plugins"]["updater"].clone() +} + +#[test] +fn the_installer_of_each_platform_asks_the_same_feed_as_the_version_check() { + assert_eq!( + updater_of(WINDOWS_CONFIG)["endpoints"], + serde_json::json!([WINDOWS_FEED_ENDPOINT]) + ); + assert_eq!( + updater_of(MACOS_CONFIG)["endpoints"], + serde_json::json!([MACOS_FEED_ENDPOINT]) + ); +} + +#[test] +fn windows_and_macos_trust_the_same_update_key() { + assert_eq!( + updater_of(MACOS_CONFIG)["pubkey"], + updater_of(WINDOWS_CONFIG)["pubkey"] + ); +} diff --git a/rfirma-app/src-tauri/src/desktop/application/status.rs b/rfirma-app/src-tauri/src/desktop/application/status.rs index 8a7b0c7e8..7b056f17e 100644 --- a/rfirma-app/src-tauri/src/desktop/application/status.rs +++ b/rfirma-app/src-tauri/src/desktop/application/status.rs @@ -22,7 +22,7 @@ const OUR_NAME: &str = "rFirma"; pub fn update_destination_for(channel: Channel) -> &'static str { match channel { Channel::Flatpak => REPOSITORY, - Channel::Native => RELEASES, + Channel::Native | Channel::MacOs => RELEASES, Channel::Windows => WINDOWS, } } diff --git a/rfirma-app/src-tauri/src/desktop/application/status/tests.rs b/rfirma-app/src-tauri/src/desktop/application/status/tests.rs index b818b5bc1..489f471d0 100644 --- a/rfirma-app/src-tauri/src/desktop/application/status/tests.rs +++ b/rfirma-app/src-tauri/src/desktop/application/status/tests.rs @@ -102,6 +102,7 @@ fn checking_version_signal_has_no_value_nor_action() { fn each_channel_updates_from_its_own_destination() { assert_eq!(update_destination_for(Channel::Windows), "windows"); assert_eq!(update_destination_for(Channel::Native), "releases"); + assert_eq!(update_destination_for(Channel::MacOs), "releases"); assert_eq!(update_destination_for(Channel::Flatpak), "repository"); } diff --git a/rfirma-app/src-tauri/src/desktop/application/version.rs b/rfirma-app/src-tauri/src/desktop/application/version.rs index e94adb482..cc7aeeea8 100644 --- a/rfirma-app/src-tauri/src/desktop/application/version.rs +++ b/rfirma-app/src-tauri/src/desktop/application/version.rs @@ -111,13 +111,16 @@ pub(crate) fn ask_and_remember( Some(announced) } -/// Extrae la versión anunciada: `version` del `latest.json` en Windows, `tag_name` en GitHub. +/// Extrae la versión anunciada: `version` del `latest.json` con la entrada del canal, `tag_name` en GitHub. fn announced_version(body: &str, channel: Channel) -> Option { - let field = match channel { - Channel::Windows => "version", - Channel::Native | Channel::Flatpak => "tag_name", - }; let feed: serde_json::Value = serde_json::from_str(body).ok()?; + let field = match channel.updater_target() { + Some(target) => { + feed.get("platforms")?.get(target)?; + "version" + } + None => "tag_name", + }; Version::parse(feed.get(field)?.as_str()?) } diff --git a/rfirma-app/src-tauri/src/desktop/application/version/tests.rs b/rfirma-app/src-tauri/src/desktop/application/version/tests.rs index 8547a2bfa..0d265743a 100644 --- a/rfirma-app/src-tauri/src/desktop/application/version/tests.rs +++ b/rfirma-app/src-tauri/src/desktop/application/version/tests.rs @@ -63,6 +63,62 @@ fn on_windows_the_feed_is_read_as_latest_json_and_the_version_is_installable() { ); } +fn a_macos_latest_json(version: &str) -> String { + format!( + r#"{{"version":"{version}","notes":"","pub_date":"2026-09-30T00:00:00Z","platforms":{{"darwin-aarch64":{{"signature":"sig","url":"https://rfirma.sgomez.me/macos/rfirma.app.tar.gz"}}}}}}"# + ) +} + +#[test] +fn on_macos_the_darwin_aarch64_entry_of_latest_json_is_announced_and_installable() { + let home = tempfile::tempdir().expect("deberia haber directorio temporal"); + let memory = a_memory(home.path()); + + let newer = new_version( + Version::parse("0.3.1").expect("es una version"), + &memory, + &|| Some(a_macos_latest_json("0.4.0")), + Channel::MacOs, + at(1_756_000_000), + ); + + assert_eq!( + newer, + Some(NewVersion { + version: Version::parse("0.4.0").expect("es una version"), + installable: true, + }) + ); +} + +#[test] +fn a_latest_json_without_the_entry_of_this_platform_announces_nothing() { + let home = tempfile::tempdir().expect("deberia haber directorio temporal"); + let memory = a_memory(home.path()); + let running = Version::parse("0.3.1").expect("es una version"); + + assert_eq!( + new_version( + running, + &memory, + &|| Some(a_latest_json("0.4.0")), + Channel::MacOs, + at(1) + ), + None + ); + assert_eq!( + new_version( + running, + &memory, + &|| Some(a_macos_latest_json("0.4.0")), + Channel::Windows, + at(2) + ), + None + ); +} + #[test] fn on_linux_the_version_is_announced_but_not_installable() { let home = tempfile::tempdir().expect("deberia haber directorio temporal"); diff --git a/rfirma-app/src-tauri/src/desktop/domain/channel.rs b/rfirma-app/src-tauri/src/desktop/domain/channel.rs index 4a238d75a..6f2fb78fb 100644 --- a/rfirma-app/src-tauri/src/desktop/domain/channel.rs +++ b/rfirma-app/src-tauri/src/desktop/domain/channel.rs @@ -9,6 +9,8 @@ pub enum Channel { Flatpak, /// Instalación de Windows, servida desde el servidor propio. Windows, + /// Instalación de macOS, del `.dmg` o del tap de Homebrew. + MacOs, } impl Channel { @@ -18,11 +20,21 @@ impl Channel { Self::Native => "nativo", Self::Flatpak => "flatpak", Self::Windows => "windows", + Self::MacOs => "macos", } } /// Si la versión anunciada se puede instalar desde la aplicación. pub fn installs_from_the_app(self) -> bool { - self == Self::Windows + matches!(self, Self::Windows | Self::MacOs) + } + + /// La entrada de `platforms` que el `latest.json` del canal trae para él, si lo sirve el servidor propio. + pub fn updater_target(self) -> Option<&'static str> { + match self { + Self::Windows => Some("windows-x86_64"), + Self::MacOs => Some("darwin-aarch64"), + Self::Native | Self::Flatpak => None, + } } } diff --git a/rfirma-app/src-tauri/src/site/application/trust.rs b/rfirma-app/src-tauri/src/site/application/trust.rs index e0f86c2a1..a9e57d292 100644 --- a/rfirma-app/src-tauri/src/site/application/trust.rs +++ b/rfirma-app/src-tauri/src/site/application/trust.rs @@ -85,7 +85,7 @@ pub fn narrate_startup_outcome(mut outcome: TrustOutcome, profiles: &[PathBuf]) /// La marca que lleva la CA local de cada canal de distribución. pub fn mark_of(channel: Channel) -> ChannelMark { match channel { - Channel::Native => ChannelMark::Native, + Channel::Native | Channel::MacOs => ChannelMark::Native, Channel::Flatpak => ChannelMark::Flatpak, Channel::Windows => ChannelMark::Windows, } diff --git a/rfirma-app/src-tauri/src/site/application/trust/tests/unmarked.rs b/rfirma-app/src-tauri/src/site/application/trust/tests/unmarked.rs index e5c6f0da7..e301b18cf 100644 --- a/rfirma-app/src-tauri/src/site/application/trust/tests/unmarked.rs +++ b/rfirma-app/src-tauri/src/site/application/trust/tests/unmarked.rs @@ -274,3 +274,8 @@ fn each_distribution_channel_marks_its_local_ca_with_its_own_value() { assert_eq!(mark_of(Channel::Flatpak).value(), "flatpak"); assert_eq!(mark_of(Channel::Windows).value(), "windows"); } + +#[test] +fn macos_keeps_marking_its_local_ca_as_native() { + assert_eq!(mark_of(Channel::MacOs).value(), "native"); +} diff --git a/scripts/packages-manifest.sh b/scripts/packages-manifest.sh index da32ed26b..17870d1c9 100755 --- a/scripts/packages-manifest.sh +++ b/scripts/packages-manifest.sh @@ -19,6 +19,8 @@ row_for() { *.rpm) echo "linux|rpm|true|" ;; *-setup.exe) echo "windows|nsis|false|Windows SmartScreen avisará de que el instalador no está firmado: pulsa «Más información» y «Ejecutar de todas formas»." ;; *-setup.exe.sig) echo "windows|minisign|false|" ;; + *.app.tar.gz) echo "macos|updater|false|" ;; + *.app.tar.gz.sig) echo "macos|minisign|false|" ;; *.dmg) echo "macos|dmg|false|Gatekeeper bloqueará la aplicación, que no está notarizada: pulsa «Abrir igualmente» en Ajustes del Sistema › Privacidad y seguridad." ;; *) return 1 ;; esac @@ -95,7 +97,7 @@ case "$command" in ;; packages) require_manifest "$dir" - jq -r '.[] | select(.format != "minisign") | .file' "$dir/$manifest_name" + jq -r '.[] | select(.format != "minisign" and .format != "updater") | .file' "$dir/$manifest_name" ;; signable) require_manifest "$dir" diff --git a/scripts/tests/packages_manifest_test.sh b/scripts/tests/packages_manifest_test.sh index d23883f00..896a11040 100755 --- a/scripts/tests/packages_manifest_test.sh +++ b/scripts/tests/packages_manifest_test.sh @@ -61,11 +61,27 @@ done "$script" write "$signed" [ "$(jq -r '.[] | select(.format == "minisign") | "\(.platform) \(.file) \(.signable)"' "$signed/paquetes.json")" \ = "windows rfirma_1.0.0_x64-setup.exe.sig false" ] || fail "firma de actualizacion: una fila de windows, no firmable" -[ "$("$script" platform-files "$signed" windows | wc -l)" = 2 ] || fail "firma de actualizacion: viaja con el instalador" -[ "$("$script" files "$signed" | wc -l)" = 3 ] || fail "files lista tambien la firma de actualizacion" +[ "$("$script" platform-files "$signed" windows | wc -l | tr -d ' ')" = 2 ] || fail "firma de actualizacion: viaja con el instalador" +[ "$("$script" files "$signed" | wc -l | tr -d ' ')" = 3 ] || fail "files lista tambien la firma de actualizacion" [ "$("$script" packages "$signed" | tr '\n' ' ')" = "me.sgomez.rfirma.flatpak rfirma_1.0.0_x64-setup.exe " ] \ || fail "packages: los paquetes sin la firma de actualizacion" +macos="$tmp/macos" +mkdir "$macos" +for f in rfirma_1.0.0_aarch64.dmg rfirma_1.0.0_aarch64.app.tar.gz rfirma_1.0.0_aarch64.app.tar.gz.sig; do + : > "$macos/$f" +done +"$script" write "$macos" +[ "$("$script" files "$macos" updater)" = rfirma_1.0.0_aarch64.app.tar.gz ] \ + || fail "actualizacion de macos: el .app.tar.gz es su propio formato" +[ "$("$script" files "$macos" minisign)" = rfirma_1.0.0_aarch64.app.tar.gz.sig ] \ + || fail "actualizacion de macos: su .sig es una firma de actualizacion" +[ "$("$script" platform-files "$macos" macos | wc -l | tr -d ' ')" = 3 ] \ + || fail "actualizacion de macos: viaja con el dmg" +[ -z "$("$script" signable "$macos")" ] || fail "actualizacion de macos: nada firmable con gpg" +[ "$("$script" packages "$macos")" = rfirma_1.0.0_aarch64.dmg ] \ + || fail "packages: el dmg, sin lo que solo baja el actualizador" + unknown="$tmp/unknown" mkdir "$unknown" : > "$unknown/me.sgomez.rfirma.flatpak" From 6350a595faab981713ed0e8aad967279d681b740 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20G=C3=B3mez=20Bachiller?= Date: Mon, 19 Oct 2026 17:02:44 +0200 Subject: [PATCH 17/22] =?UTF-8?q?feat(signing):=20la=20l=C3=ADnea=20del=20?= =?UTF-8?q?lector=20ilegible=20en=20macOS=20ofrece=20OpenSC=20y=20la=20DGP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements #2016: La línea del lector «ilegible» en macOS ofrece OpenSC y la DGP - plataforma macos en CardHelpPlatform, con su historia y su ficha de diseño --- docs/design/panel-de-firma.md | 2 +- rfirma-app/po/ca.po | 3 +++ rfirma-app/po/en.po | 3 +++ rfirma-app/po/es.po | 3 +++ rfirma-app/po/eu.po | 3 +++ rfirma-app/po/gl.po | 3 +++ rfirma-app/po/messages.pot | 3 +++ rfirma-app/src/signing/ReaderLine.test.tsx | 7 ++++++ rfirma-app/src/signing/ReaderLine.tsx | 22 ++++++++++++------- .../SigningPanel.presentation.test.tsx | 2 ++ .../src/signing/SigningPanel.stories.tsx | 4 ++++ 11 files changed, 46 insertions(+), 9 deletions(-) diff --git a/docs/design/panel-de-firma.md b/docs/design/panel-de-firma.md index 273005f90..2bd42536a 100644 --- a/docs/design/panel-de-firma.md +++ b/docs/design/panel-de-firma.md @@ -82,7 +82,7 @@ Cada uno tiene su historia en «Flujos/Firma/SigningPanel», salvo que se diga o | Sin certificados | `NoCertificates` | El selector se queda en su sitio, desactivado y diciendo «Sin certificados»; debajo, «Añadir un certificado…» (primario, el ancho que sobra) y «Volver a buscar» (secundario); el pie deja solo «Firmar», desactivado | | Sin certificados, con lector | `NoCertificatesWithReader` | La misma forma, con la línea del lector entre el selector desactivado y las salidas | | Búsqueda fallida | `SearchFailed` | La misma forma, con el error clasificado debajo de las salidas; la caja dice que no se pudo buscar, no que no haya ninguno | -| Lector sin tarjeta, leyendo, tarjeta lista e ilegible | `ReaderWithoutCard`, `ReadingTheCard`, `DnieReady`, `CardReady`, `UnreadableCardOnWindows`, `UnreadableCardOnLinux` | La línea del lector bajo el selector; sin lector (`Ready`), no hay línea. Ilegible, añade la ayuda de la plataforma: Windows Update o la página de la DGP en Windows, OpenSC en Linux. Mientras se lee, los demás certificados se siguen pudiendo elegir | +| Lector sin tarjeta, leyendo, tarjeta lista e ilegible | `ReaderWithoutCard`, `ReadingTheCard`, `DnieReady`, `CardReady`, `UnreadableCardOnWindows`, `UnreadableCardOnMacos`, `UnreadableCardOnLinux` | La línea del lector bajo el selector; sin lector (`Ready`), no hay línea. Ilegible, añade la ayuda de la plataforma: Windows Update o la página de la DGP en Windows, OpenSC o la página de la DGP en macOS, OpenSC en Linux. Mientras se lee, los demás certificados se siguen pudiendo elegir | | Varios certificados | `SeveralCertificates` | El desplegable los lista todos al abrirse | | Certificados abiertos | `Open` de «Dominio/Firma/CertificateSelect» | El buscador en lugar de la caja y la lista flotando | | Firma visible | `VisibleSignature*` | Una página, varias, todas, otra página a la vista y sin colocar | diff --git a/rfirma-app/po/ca.po b/rfirma-app/po/ca.po index b51258b4d..4ce9c35dd 100644 --- a/rfirma-app/po/ca.po +++ b/rfirma-app/po/ca.po @@ -533,6 +533,9 @@ msgstr "" msgid "panel.certificate.reader.unreadableHelp.linux" msgstr "Instal·la OpenSC perquè l'rFirma la pugui llegir." +msgid "panel.certificate.reader.unreadableHelp.macos" +msgstr "Instal·la OpenSC o el mòdul de la DGP perquè l'rFirma la pugui llegir." + #. {{issuer}} es el nombre de la entidad emisora del certificado. msgid "panel.certificate.issuer" msgstr "Emès per {{issuer}}" diff --git a/rfirma-app/po/en.po b/rfirma-app/po/en.po index b045a3f83..cb91dc21a 100644 --- a/rfirma-app/po/en.po +++ b/rfirma-app/po/en.po @@ -533,6 +533,9 @@ msgstr "" msgid "panel.certificate.reader.unreadableHelp.linux" msgstr "Install OpenSC so that rFirma can read it." +msgid "panel.certificate.reader.unreadableHelp.macos" +msgstr "Install OpenSC or the DGP module so that rFirma can read it." + #. {{issuer}} es el nombre de la entidad emisora del certificado. msgid "panel.certificate.issuer" msgstr "Issued by {{issuer}}" diff --git a/rfirma-app/po/es.po b/rfirma-app/po/es.po index 887a98eab..67ecdc03b 100644 --- a/rfirma-app/po/es.po +++ b/rfirma-app/po/es.po @@ -533,6 +533,9 @@ msgstr "" msgid "panel.certificate.reader.unreadableHelp.linux" msgstr "Instala OpenSC para que rFirma pueda leerla." +msgid "panel.certificate.reader.unreadableHelp.macos" +msgstr "Instala OpenSC o el módulo de la DGP para que rFirma pueda leerla." + #. {{issuer}} es el nombre de la entidad emisora del certificado. msgid "panel.certificate.issuer" msgstr "Emitido por {{issuer}}" diff --git a/rfirma-app/po/eu.po b/rfirma-app/po/eu.po index 00a4e1885..b7d80fb74 100644 --- a/rfirma-app/po/eu.po +++ b/rfirma-app/po/eu.po @@ -534,6 +534,9 @@ msgstr "" msgid "panel.certificate.reader.unreadableHelp.linux" msgstr "Instalatu OpenSC rFirmak txartela irakur dezan." +msgid "panel.certificate.reader.unreadableHelp.macos" +msgstr "Instalatu OpenSC edo DGPren modulua rFirmak txartela irakur dezan." + #. {{issuer}} es el nombre de la entidad emisora del certificado. msgid "panel.certificate.issuer" msgstr "Jaulkitzailea: {{issuer}}" diff --git a/rfirma-app/po/gl.po b/rfirma-app/po/gl.po index a080228ce..eab79cbbd 100644 --- a/rfirma-app/po/gl.po +++ b/rfirma-app/po/gl.po @@ -531,6 +531,9 @@ msgstr "" msgid "panel.certificate.reader.unreadableHelp.linux" msgstr "Instala OpenSC para que rFirma poida lela." +msgid "panel.certificate.reader.unreadableHelp.macos" +msgstr "Instala OpenSC ou o módulo da DGP para que rFirma poida lela." + #. {{issuer}} es el nombre de la entidad emisora del certificado. msgid "panel.certificate.issuer" msgstr "Emitido por {{issuer}}" diff --git a/rfirma-app/po/messages.pot b/rfirma-app/po/messages.pot index 934330c37..196de416b 100644 --- a/rfirma-app/po/messages.pot +++ b/rfirma-app/po/messages.pot @@ -521,6 +521,9 @@ msgstr "" msgid "panel.certificate.reader.unreadableHelp.linux" msgstr "" +msgid "panel.certificate.reader.unreadableHelp.macos" +msgstr "" + #. {{issuer}} es el nombre de la entidad emisora del certificado. msgid "panel.certificate.issuer" msgstr "" diff --git a/rfirma-app/src/signing/ReaderLine.test.tsx b/rfirma-app/src/signing/ReaderLine.test.tsx index 10c62db20..d1e64d84e 100644 --- a/rfirma-app/src/signing/ReaderLine.test.tsx +++ b/rfirma-app/src/signing/ReaderLine.test.tsx @@ -32,6 +32,13 @@ describe("the reader line under the certificate selector", () => { expect(screen.getByRole("status")).toHaveTextContent(/OpenSC/); }); + it("points to OpenSC and the DGP page on macOS", () => { + renderWithCatalog(); + + expect(screen.getByRole("status")).toHaveTextContent(/OpenSC/); + expect(screen.getByRole("status")).toHaveTextContent(/DGP/); + }); + it("offers nothing on a platform it has no advice for", () => { renderWithCatalog(); diff --git a/rfirma-app/src/signing/ReaderLine.tsx b/rfirma-app/src/signing/ReaderLine.tsx index eae42cbbd..22c792780 100644 --- a/rfirma-app/src/signing/ReaderLine.tsx +++ b/rfirma-app/src/signing/ReaderLine.tsx @@ -7,11 +7,12 @@ import { hasAReader, type ReaderStatus } from "./certificate"; import "./ReaderLine.css"; /** La plataforma de la que depende la ayuda para una tarjeta ilegible. */ -export type CardHelpPlatform = "windows" | "linux" | "other"; +export type CardHelpPlatform = "windows" | "macos" | "linux" | "other"; /** La plataforma que corresponde al `userAgent` del WebView. */ function cardHelpPlatformFor(userAgent: string): CardHelpPlatform { if (/windows/i.test(userAgent)) return "windows"; + if (/macintosh|mac os x/i.test(userAgent)) return "macos"; if (/linux/i.test(userAgent) && !/android/i.test(userAgent)) return "linux"; return "other"; } @@ -69,11 +70,16 @@ function readerText( function CardHelp({ platform }: { platform: CardHelpPlatform }) { const { t } = useTranslation(); if (platform === "other") return null; - return ( - - {platform === "windows" - ? t("panel.certificate.reader.unreadableHelp.windows") - : t("panel.certificate.reader.unreadableHelp.linux")} - - ); + return {cardHelpText(platform, t)}; +} + +function cardHelpText(platform: Exclude, t: TFunction): string { + switch (platform) { + case "windows": + return t("panel.certificate.reader.unreadableHelp.windows"); + case "macos": + return t("panel.certificate.reader.unreadableHelp.macos"); + case "linux": + return t("panel.certificate.reader.unreadableHelp.linux"); + } } diff --git a/rfirma-app/src/signing/SigningPanel.presentation.test.tsx b/rfirma-app/src/signing/SigningPanel.presentation.test.tsx index 0d6c583f0..4e9ebf611 100644 --- a/rfirma-app/src/signing/SigningPanel.presentation.test.tsx +++ b/rfirma-app/src/signing/SigningPanel.presentation.test.tsx @@ -41,6 +41,7 @@ const { CardReady, UnreadableCardOnLinux, UnreadableCardOnWindows, + UnreadableCardOnMacos, NoCertificatesWithReader, } = composed; @@ -135,6 +136,7 @@ describe("the signing panel, by state", () => { { Story: CardReady, said: "Tarjeta lista" }, { Story: UnreadableCardOnWindows, said: "rFirma no puede leer la tarjeta del lector" }, { Story: UnreadableCardOnLinux, said: "rFirma no puede leer la tarjeta del lector" }, + { Story: UnreadableCardOnMacos, said: "rFirma no puede leer la tarjeta del lector" }, ] as const; for (const { Story, said } of cases) { diff --git a/rfirma-app/src/signing/SigningPanel.stories.tsx b/rfirma-app/src/signing/SigningPanel.stories.tsx index 6389f5a9a..67880b1fc 100644 --- a/rfirma-app/src/signing/SigningPanel.stories.tsx +++ b/rfirma-app/src/signing/SigningPanel.stories.tsx @@ -138,6 +138,10 @@ export const UnreadableCardOnLinux: Story = { args: { certificate: withReader({ kind: "unreadable" }), platform: "linux" }, }; +export const UnreadableCardOnMacos: Story = { + args: { certificate: withReader({ kind: "unreadable" }), platform: "macos" }, +}; + export const NoCertificatesWithReader: Story = { args: { certificate: withReader({ kind: "noCard" }, { kind: "empty" }) }, }; From d1f821bf9a0acaae3b0d196fc40533783259a2d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20G=C3=B3mez?= Date: Mon, 19 Oct 2026 18:57:55 +0200 Subject: [PATCH 18/22] docs(adr): el ADR-0040 deja las tarjetas y el DNIe de macOS pendientes de #2043 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements #2046: El ADR-0040 deja las tarjetas y el DNIe de macOS pendientes de #2043 - la regla sin la excepción del DNIe; el apartado de distribución no cambia --- ...n-apple-silicon-como-tercera-plataforma.md | 25 ++++++++----------- 1 file changed, 11 insertions(+), 14 deletions(-) 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 22e7a45ad..fa61b99f2 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 @@ -24,28 +24,25 @@ de una tarjeta por PKCS#11 lo pide una alerta de AppKit con campo seguro, modal que lo pidió (ADR-0047); el Almacén de macOS ya lista los certificados del llavero, pero aún no firma con ellos como firma el de CNG en Windows. -## En macOS, la API de macOS siempre que se pueda, salvo el DNIe +## En macOS, la API de macOS siempre que se pueda -Como en Windows (ADR-0035), para certificados, PIN, tarjetas y confianza rFirma usa lo que macOS -ofrece (Security.framework, CryptoTokenKit a través del llavero, LaunchServices): es lo que la -persona ya conoce y evita mantener un camino propio. De la regla salen: +Como en Windows (ADR-0035), para certificados, PIN y confianza rFirma usa lo que macOS ofrece +(Security.framework y LaunchServices): es lo que la persona ya conoce y evita mantener un camino +propio. De la regla salen: -- **Los certificados con clave privada del llavero** salen como «Almacén de macOS» y se firman por - la API del sistema: Rust calcula el resumen y macOS lo firma (ADR-0001). La contraseña o el PIN - los pide macOS con su diálogo. -- **Las tarjetas que macOS ya sabe leer** (PIV o con controlador del sistema) entran por ese mismo - camino y se tratan como tarjeta. +- **Los certificados con clave de software del llavero** salen como «Almacén de macOS» y se firman + por la API del sistema: Rust calcula el resumen y macOS lo firma (ADR-0001). La contraseña o el + permiso los pide macOS con su diálogo. Una identidad con la clave en un token del sistema no sale. - **«Instalar certificado» importa el `.p12` al llavero de inicio de sesión** (ADR-0034); en macOS no hay Almacén de rFirma. - **La CA local va a la confianza de la persona usuaria** (ADR-0005), con el diálogo de contraseña del sistema, y `afirma://` se gestiona por LaunchServices. -**La excepción es el DNIe**, que va por PKCS#11: macOS no trae controlador del sistema para él y -la DGP solo publica un módulo PKCS#11 para Mac. Solo para él, rFirma carga el módulo de OpenSC o el -de la DGP y pide el PIN con un diálogo propio nativo, que lee el estado del PIN antes de pedirlo -(ADR-0047). Nadie debe meter PKCS#11 para todo ni quitar la excepción sin conocer este motivo. +Las tarjetas y el DNIe en macOS quedan fuera de la regla: están pendientes de #2043, sin decidir, y +mientras tanto el Almacén de macOS solo lista certificados con clave de software. En macOS no se +vigilan los lectores ni aparece la línea del lector. -El orden de almacenes pasa a ser: tarjeta, Almacén de macOS (o de Windows), Almacén de rFirma, +El orden de almacenes pasa a ser: tarjeta, Almacén de Windows o de macOS, Almacén de rFirma, NSS del sistema, familia Firefox, familia Chromium. ## `afirma://` llega como evento, no como argumento From 7ab5d7069d1d85300cdfaf69da5de4d28de25439 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20G=C3=B3mez?= Date: Mon, 19 Oct 2026 19:03:46 +0200 Subject: [PATCH 19/22] =?UTF-8?q?fix(identity):=20en=20macOS=20no=20se=20v?= =?UTF-8?q?igilan=20los=20lectores=20ni=20aparece=20su=20l=C3=ADnea?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements #2044: En macOS no se muestra la línea del lector mientras no se lean tarjetas - ID-555: watches_the_readers devuelve false en macOS; el vigilante queda para #2043 --- rfirma-app/src-tauri/src/identity/application/readers.rs | 2 +- .../src-tauri/src/identity/application/readers/tests.rs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/rfirma-app/src-tauri/src/identity/application/readers.rs b/rfirma-app/src-tauri/src/identity/application/readers.rs index 0f5723509..f8dee499b 100644 --- a/rfirma-app/src-tauri/src/identity/application/readers.rs +++ b/rfirma-app/src-tauri/src/identity/application/readers.rs @@ -45,7 +45,7 @@ pub fn watches_the_readers(platform: Platform, channel: Channel) -> bool { match platform { Platform::Linux => matches!(channel, Channel::Native | Channel::Flatpak), Platform::Windows => channel == Channel::Windows, - Platform::MacOs => channel == Channel::Native, + Platform::MacOs => false, } } diff --git a/rfirma-app/src-tauri/src/identity/application/readers/tests.rs b/rfirma-app/src-tauri/src/identity/application/readers/tests.rs index fff987248..31e6b5d81 100644 --- a/rfirma-app/src-tauri/src/identity/application/readers/tests.rs +++ b/rfirma-app/src-tauri/src/identity/application/readers/tests.rs @@ -229,14 +229,14 @@ fn with_no_full_listing_yet_every_store_is_opened() { } #[test] -fn the_readers_are_watched_on_linux_windows_and_macos() { +fn the_readers_are_watched_on_linux_and_windows_and_not_on_macos() { use crate::desktop::domain::channel::Channel; use crate::desktop::domain::platform::Platform; assert!(watches_the_readers(Platform::Linux, Channel::Native)); assert!(watches_the_readers(Platform::Linux, Channel::Flatpak)); assert!(watches_the_readers(Platform::Windows, Channel::Windows)); - assert!(watches_the_readers(Platform::MacOs, Channel::Native)); + assert!(!watches_the_readers(Platform::MacOs, Channel::Native)); } const CARD_KSP: &str = "Microsoft Smart Card Key Storage Provider"; From 8103ad3d77c13ed97a0826a4968e9349f040afc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20G=C3=B3mez?= Date: Mon, 19 Oct 2026 19:06:31 +0200 Subject: [PATCH 20/22] =?UTF-8?q?feat(identity):=20el=20Almac=C3=A9n=20de?= =?UTF-8?q?=20macOS=20solo=20lista=20certificados=20con=20clave=20de=20sof?= =?UTF-8?q?tware?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements #2008: El Almacén de macOS no lista las identidades con clave en un token - el filtro está en el listado, no en el adaptador, para probarlo con un doble del puerto Token --- .../src/identity/application/certificates.rs | 13 ++++-- .../certificates/tests/macos_store.rs | 44 +++++++++++++++++++ .../src/identity/domain/certificate.rs | 6 +++ 3 files changed, 59 insertions(+), 4 deletions(-) diff --git a/rfirma-app/src-tauri/src/identity/application/certificates.rs b/rfirma-app/src-tauri/src/identity/application/certificates.rs index f86c83008..c4127676d 100644 --- a/rfirma-app/src-tauri/src/identity/application/certificates.rs +++ b/rfirma-app/src-tauri/src/identity/application/certificates.rs @@ -103,7 +103,9 @@ pub fn on_the_desktop(found: Vec) -> Vec { fn holders_only(found: Vec) -> Vec { found .into_iter() - .filter(|certificate| !certificate.is_an_authority()) + .filter(|certificate| { + !certificate.is_an_authority() && !certificate.is_a_macos_token_identity() + }) .collect() } @@ -115,9 +117,12 @@ pub fn certificates_by_class( ) -> Vec<(StoreClass, usize)> { let mut counted: Vec<(StoreClass, usize)> = Vec::new(); for store in stores { - let found = token - .list(store) - .map_or(0, |certificates| certificates.len()); + let found = token.list(store).map_or(0, |certificates| { + certificates + .iter() + .filter(|certificate| !certificate.is_a_macos_token_identity()) + .count() + }); if found == 0 { continue; } diff --git a/rfirma-app/src-tauri/src/identity/application/certificates/tests/macos_store.rs b/rfirma-app/src-tauri/src/identity/application/certificates/tests/macos_store.rs index 6a0bd7127..55dfa1532 100644 --- a/rfirma-app/src-tauri/src/identity/application/certificates/tests/macos_store.rs +++ b/rfirma-app/src-tauri/src/identity/application/certificates/tests/macos_store.rs @@ -121,3 +121,47 @@ fn without_a_card_the_macos_copy_is_the_one_behind_the_row() { assert_eq!(rows.len(), 1); assert_eq!(rows[0].store, StoreClass::Macos); } + +fn in_the_keychain_on_a_token(label: &str, der: &[u8]) -> TokenCertificate { + let certificate = in_the_keychain(label, der); + TokenCertificate::new( + certificate + .reference() + .clone() + .with_key_provider("com.apple.setoken:Card"), + der.to_vec(), + ) +} + +#[test] +fn a_keychain_certificate_with_its_key_in_a_token_is_not_listed() { + let home = tempfile::tempdir().expect("deberia haber directorio temporal"); + let found = [ + in_the_keychain("EN-SOFTWARE", &TestAuthority::root("EN SOFTWARE").der()), + in_the_keychain_on_a_token("EN-TOKEN", &TestAuthority::root("EN TOKEN").der()), + ]; + + let rows = listed_on_the_mac(&found, &[the_macos_store()], home.path()); + + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].store, StoreClass::Macos); +} + +#[test] +fn a_card_certificate_reaching_the_keychain_on_a_token_is_listed_once_by_pkcs11() { + let home = tempfile::tempdir().expect("deberia haber directorio temporal"); + let der = TestAuthority::root("EIDAS CERTIFICADO PRUEBAS - 99999999R").der(); + let found = [ + a_copy_in(&Store::module(CARD), "COPIA-TARJETA", &der), + in_the_keychain_on_a_token("COPIA-LLAVERO", &der), + ]; + + let rows = listed_on_the_mac( + &found, + &[Store::module(CARD), the_macos_store()], + home.path(), + ); + + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].store, StoreClass::Card); +} diff --git a/rfirma-app/src-tauri/src/identity/domain/certificate.rs b/rfirma-app/src-tauri/src/identity/domain/certificate.rs index 028d3a7ba..6bca90365 100644 --- a/rfirma-app/src-tauri/src/identity/domain/certificate.rs +++ b/rfirma-app/src-tauri/src/identity/domain/certificate.rs @@ -335,6 +335,12 @@ impl TokenCertificate { }) } + /// Si es del Almacén de macOS con la clave en un token del sistema, que se queda para las tarjetas. + pub fn is_a_macos_token_identity(&self) -> bool { + self.reference().store().class() == StoreClass::Macos + && self.reference().key_provider().is_some() + } + /// Si es de una autoridad, con `CA:TRUE`. pub fn is_an_authority(&self) -> bool { Certificate::from_der(&self.der).is_ok_and(|certificate| { From 39d5bcb35badb04804dde4eea4d928f42a3e5e93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20G=C3=B3mez?= Date: Mon, 19 Oct 2026 19:12:05 +0200 Subject: [PATCH 21/22] =?UTF-8?q?feat(debug-info):=20almac=C3=A9n=20de=20m?= =?UTF-8?q?acOS=20y=20controladores=20de=20tarjeta?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements #2020: --debug-info en macOS - Secciones de macOS como las de Windows; Linux y Windows no cambian --- .../src/desktop/adapters/card_drivers.rs | 30 +++++++++++ .../desktop/adapters/card_drivers/tests.rs | 19 +++++++ .../src/desktop/adapters/debug_info.rs | 32 ++++++----- .../src-tauri/src/desktop/adapters/mod.rs | 1 + .../src/desktop/application/debug_report.rs | 12 +++++ .../application/debug_report/stores.rs | 27 +++++++++- .../application/debug_report/stores_tests.rs | 53 +++++++++++++++++++ .../desktop/application/debug_report/tests.rs | 1 + 8 files changed, 161 insertions(+), 14 deletions(-) create mode 100644 rfirma-app/src-tauri/src/desktop/adapters/card_drivers.rs create mode 100644 rfirma-app/src-tauri/src/desktop/adapters/card_drivers/tests.rs diff --git a/rfirma-app/src-tauri/src/desktop/adapters/card_drivers.rs b/rfirma-app/src-tauri/src/desktop/adapters/card_drivers.rs new file mode 100644 index 000000000..0400c503f --- /dev/null +++ b/rfirma-app/src-tauri/src/desktop/adapters/card_drivers.rs @@ -0,0 +1,30 @@ +//! Los controladores de tarjeta (`.bundle`) que macOS trae para el servicio de tarjetas inteligentes. + +use std::path::Path; + +const SYSTEM_DRIVERS: &str = "/usr/libexec/SmartCardServices/drivers"; + +/// Los nombres de los controladores del sistema, ordenados; vacío si no hay ninguno o no se leen. +pub fn system_card_drivers() -> Vec { + card_drivers_in(Path::new(SYSTEM_DRIVERS)) +} + +fn card_drivers_in(directory: &Path) -> Vec { + let Ok(entries) = std::fs::read_dir(directory) else { + return Vec::new(); + }; + let mut names: Vec = entries + .filter_map(Result::ok) + .map(|entry| entry.path()) + .filter(|path| { + path.extension() + .is_some_and(|extension| extension == "bundle") + }) + .filter_map(|path| Some(path.file_stem()?.to_string_lossy().into_owned())) + .collect(); + names.sort(); + names +} + +#[cfg(test)] +mod tests; diff --git a/rfirma-app/src-tauri/src/desktop/adapters/card_drivers/tests.rs b/rfirma-app/src-tauri/src/desktop/adapters/card_drivers/tests.rs new file mode 100644 index 000000000..a9a8cdd66 --- /dev/null +++ b/rfirma-app/src-tauri/src/desktop/adapters/card_drivers/tests.rs @@ -0,0 +1,19 @@ +use super::*; + +#[test] +fn lists_the_bundles_sorted_by_name_and_ignores_other_files() { + let directory = tempfile::tempdir().unwrap(); + for name in ["ifd-gempc.bundle", "ifd-ccid.bundle", "readme.txt"] { + std::fs::create_dir(directory.path().join(name)).unwrap(); + } + + assert_eq!( + card_drivers_in(directory.path()), + vec!["ifd-ccid", "ifd-gempc"] + ); +} + +#[test] +fn a_missing_directory_has_no_drivers() { + assert!(card_drivers_in(Path::new("/nonexistent/drivers")).is_empty()); +} diff --git a/rfirma-app/src-tauri/src/desktop/adapters/debug_info.rs b/rfirma-app/src-tauri/src/desktop/adapters/debug_info.rs index fd92190b4..59254c2c1 100644 --- a/rfirma-app/src-tauri/src/desktop/adapters/debug_info.rs +++ b/rfirma-app/src-tauri/src/desktop/adapters/debug_info.rs @@ -11,9 +11,9 @@ use crate::desktop::adapters::installation::{this_glibc, this_installation, this use crate::desktop::adapters::paths::{Paths, Platform}; use crate::desktop::adapters::registry::this_desktop; use crate::desktop::application::debug_report::{ - DebugReport, DiscardReason, LinuxEnvironment, ModuleStatus, NativeLibrary, NativeLibraryStatus, - NssProfile, NssProfileState, NssStores, OwnerFolder, PcscStatus, Pkcs11Module, Pkcs11Modules, - ProtocolHandlerStatus, ReportOwner, WindowsStores, + DebugReport, DiscardReason, LinuxEnvironment, MacosStores, ModuleStatus, NativeLibrary, + NativeLibraryStatus, NssProfile, NssProfileState, NssStores, OwnerFolder, PcscStatus, + Pkcs11Module, Pkcs11Modules, ProtocolHandlerStatus, ReportOwner, WindowsStores, }; use crate::desktop::domain::channel::Channel; use crate::identity::adapters as identity_adapters; @@ -57,6 +57,7 @@ pub fn this_process_report() -> DebugReport { pkcs11_modules: pkcs11_modules(), nss_stores: nss_stores(), windows_stores: windows_stores(channel), + macos_stores: macos_stores(channel), } } @@ -274,9 +275,20 @@ fn native_library() -> NativeLibrary { } fn windows_stores(channel: Channel) -> Option { - if channel != Channel::Windows { - return None; - } + (channel == Channel::Windows).then(|| WindowsStores { + local_channel: user_trust_state(), + minidrivers: crate::desktop::adapters::minidrivers::registered_minidrivers(), + }) +} + +fn macos_stores(channel: Channel) -> Option { + (channel == Channel::MacOs).then(|| MacosStores { + local_channel: user_trust_state(), + card_drivers: crate::desktop::adapters::card_drivers::system_card_drivers(), + }) +} + +fn user_trust_state() -> NssProfileState { let local_ca = Paths::from_environment() .ok() .map(|paths| LocalCaStore::of(&paths)); @@ -287,17 +299,13 @@ fn windows_stores(channel: Channel) -> Option { .copied() .unwrap_or(false) }); - let local_channel = if trusted { + if trusted { NssProfileState::TrustsLocalChannel { until: local_ca.as_ref().and_then(local_ca_expiry), } } else { NssProfileState::DoesNotTrustLocalChannel - }; - Some(WindowsStores { - local_channel, - minidrivers: crate::desktop::adapters::minidrivers::registered_minidrivers(), - }) + } } fn nss_stores() -> Option { diff --git a/rfirma-app/src-tauri/src/desktop/adapters/mod.rs b/rfirma-app/src-tauri/src/desktop/adapters/mod.rs index d569c29a3..30a6f70ce 100644 --- a/rfirma-app/src-tauri/src/desktop/adapters/mod.rs +++ b/rfirma-app/src-tauri/src/desktop/adapters/mod.rs @@ -1,5 +1,6 @@ //! Adaptadores de `desktop`: todo lo que toca el mundo, incluidas las órdenes y las vistas de Tauri. +pub mod card_drivers; pub mod channel; pub mod choice; pub mod command_line_ports; diff --git a/rfirma-app/src-tauri/src/desktop/application/debug_report.rs b/rfirma-app/src-tauri/src/desktop/application/debug_report.rs index 7881e20f5..bd7b5a8fb 100644 --- a/rfirma-app/src-tauri/src/desktop/application/debug_report.rs +++ b/rfirma-app/src-tauri/src/desktop/application/debug_report.rs @@ -84,6 +84,15 @@ pub struct WindowsStores { pub minidrivers: Vec, } +/// Lo que solo existe en macOS: la confianza del usuario y los controladores de tarjeta del sistema. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct MacosStores { + /// Si la confianza del usuario en el Almacén de macOS acepta el canal local. + pub local_channel: NssProfileState, + /// Controladores de tarjeta que macOS tiene instalados. + pub card_drivers: Vec, +} + /// Dónde y con qué instalador está el flatpak. #[derive(Clone, Debug, PartialEq, Eq)] pub struct FlatpakInstallation { @@ -263,6 +272,8 @@ pub struct DebugReport { pub nss_stores: Option, /// Almacén de Windows y minidrivers; `None` fuera de Windows. pub windows_stores: Option, + /// Almacén de macOS y controladores de tarjeta; `None` fuera de macOS. + pub macos_stores: Option, } /// Una cabecera y una sección por bloque, con el patrón `clave: estado · detalle` y las rutas anonimizadas. @@ -293,6 +304,7 @@ pub fn debug_report_text(report: &DebugReport, owner: &ReportOwner) -> String { .map(|stores| section("Almacenes NSS", &stores::nss_lines(stores, owner))), ) .chain(stores::windows_sections(report.windows_stores.as_ref())) + .chain(stores::macos_sections(report.macos_stores.as_ref())) .flatten() .collect::>() .join("\n\n") diff --git a/rfirma-app/src-tauri/src/desktop/application/debug_report/stores.rs b/rfirma-app/src-tauri/src/desktop/application/debug_report/stores.rs index e2c0875f8..dea338786 100644 --- a/rfirma-app/src-tauri/src/desktop/application/debug_report/stores.rs +++ b/rfirma-app/src-tauri/src/desktop/application/debug_report/stores.rs @@ -1,6 +1,6 @@ -//! Las líneas de los almacenes del informe: los perfiles NSS, y el almacén de Windows con sus minidrivers. +//! Las líneas de los almacenes del informe: los perfiles NSS, y el almacén de Windows con sus minidrivers y el de macOS con sus controladores. -use super::{section, NssProfileState, NssStores, ReportOwner, WindowsStores}; +use super::{section, MacosStores, NssProfileState, NssStores, ReportOwner, WindowsStores}; pub(super) fn windows_sections(stores: Option<&WindowsStores>) -> [Option; 2] { let Some(stores) = stores else { @@ -22,6 +22,29 @@ pub(super) fn windows_sections(stores: Option<&WindowsStores>) -> [Option) -> [Option; 2] { + let Some(stores) = stores else { + return [None, None]; + }; + let trust = format!( + "Confianza del usuario: {}", + profile_state(&stores.local_channel) + ); + let drivers = if stores.card_drivers.is_empty() { + vec!["tarjetas: ninguno instalado".to_owned()] + } else { + stores + .card_drivers + .iter() + .map(|name| format!("{name}: instalado")) + .collect() + }; + [ + section("Almacén de macOS", &[trust]), + section("Controladores de tarjeta", &drivers), + ] +} + pub(super) fn nss_lines(stores: &NssStores, owner: &ReportOwner) -> Vec { let rfirma_store = if stores.rfirma_store_installed { "instalado" diff --git a/rfirma-app/src-tauri/src/desktop/application/debug_report/stores_tests.rs b/rfirma-app/src-tauri/src/desktop/application/debug_report/stores_tests.rs index 1154e6158..eb7a72f3e 100644 --- a/rfirma-app/src-tauri/src/desktop/application/debug_report/stores_tests.rs +++ b/rfirma-app/src-tauri/src/desktop/application/debug_report/stores_tests.rs @@ -144,6 +144,59 @@ fn windows_without_registered_minidrivers_says_none() { ); } +fn report_with_macos(trust: NssProfileState, drivers: Vec<&str>) -> String { + let mut report = a_flatpak_report(); + report.pcsc = None; + report.macos_stores = Some(MacosStores { + local_channel: trust, + card_drivers: drivers.into_iter().map(str::to_owned).collect(), + }); + debug_report_text(&report, &ana()) +} + +#[test] +fn macos_replaces_the_nss_stores_with_the_macos_store_and_card_drivers() { + let text = report_with_macos( + NssProfileState::TrustsLocalChannel { + until: Some("2027-03-14".to_owned()), + }, + vec!["ifd-ccid", "ifd-gempc"], + ); + + assert!( + text.ends_with( + "Almacén de macOS\n \ + Confianza del usuario: confía en el canal local · hasta 2027-03-14\n\ + \n\ + Controladores de tarjeta\n \ + ifd-ccid: instalado\n \ + ifd-gempc: instalado" + ), + "{text}" + ); + assert!(!text.contains("Almacenes NSS"), "{text}"); +} + +#[test] +fn macos_without_trust_in_the_local_channel_says_so() { + let text = report_with_macos(NssProfileState::DoesNotTrustLocalChannel, vec!["ifd-ccid"]); + + assert!( + text.contains("Almacén de macOS\n Confianza del usuario: no confía en el canal local\n"), + "{text}" + ); +} + +#[test] +fn macos_without_card_drivers_says_none() { + let text = report_with_macos(NssProfileState::DoesNotTrustLocalChannel, Vec::new()); + + assert!( + text.ends_with("Controladores de tarjeta\n tarjetas: ninguno instalado"), + "{text}" + ); +} + #[test] fn a_flatpak_store_says_its_browser_and_channel_and_its_full_path() { let section = report_with_nss( diff --git a/rfirma-app/src-tauri/src/desktop/application/debug_report/tests.rs b/rfirma-app/src-tauri/src/desktop/application/debug_report/tests.rs index bfbffd0b8..3ceb31aab 100644 --- a/rfirma-app/src-tauri/src/desktop/application/debug_report/tests.rs +++ b/rfirma-app/src-tauri/src/desktop/application/debug_report/tests.rs @@ -43,6 +43,7 @@ pub(super) fn a_flatpak_report() -> DebugReport { pkcs11_modules: None, nss_stores: None, windows_stores: None, + macos_stores: None, } } From c849330011b0eb71c4db850d23c3e79dbbac991e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20G=C3=B3mez?= Date: Mon, 19 Oct 2026 20:43:41 +0200 Subject: [PATCH 22/22] =?UTF-8?q?fix(pr):=20Preferencias=20solo=20quita=20?= =?UTF-8?q?del=20llavero=20lo=20que=20instal=C3=B3=20rFirma,=20la=20l?= =?UTF-8?q?=C3=ADnea=20de=20=C3=B3rdenes=20deja=20fuera=20las=20identidade?= =?UTF-8?q?s=20de=20un=20token=20y=20la=20puerta=20CRAP=20mide=20macOS=20e?= =?UTF-8?q?n=20su=20carril=20(#2042)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude --- .github/workflows/platforms.yml | 4 +- CONTEXT.md | 6 +- ...n-apple-silicon-como-tercera-plataforma.md | 4 +- justfile | 15 +++- packaging/repo/build-tree.test.sh | 2 +- .../command_line/tests/platform.rs | 34 ++++++++ .../src/desktop/application/store_scope.rs | 12 +-- .../src/identity/adapters/macos_store.rs | 18 ++++- .../identity/adapters/macos_store/installs.rs | 57 ++++++++++++++ .../identity/adapters/macos_store/keychain.rs | 25 +++--- .../identity/adapters/macos_store/tests.rs | 77 +++++++++++++++---- .../src-tauri/src/identity/adapters/views.rs | 3 + .../src/identity/adapters/views/tests.rs | 2 + .../src/identity/application/certificates.rs | 38 ++++++--- .../certificates/tests/macos_store.rs | 37 +++++++++ .../application/certificates/tests/mod.rs | 15 ++++ .../certificates/tests/system_install.rs | 38 ++++++++- .../src/identity/domain/certificate.rs | 2 + rfirma-app/src-tauri/src/identity/ports.rs | 5 ++ .../src/site/adapters/macos_trust.rs | 53 +++++++------ .../site/application/errand/state/tests.rs | 1 + rfirma-app/src-tauri/src/startup_dialog.rs | 1 + rfirma-app/src-tauri/tests/contract.snapshot | 1 + rfirma-app/src/App.chrome.test.tsx | 14 +++- .../src/preferences/testing/fixtures.ts | 1 + .../src/sede/testing/fixtures/sedeWindow.ts | 1 + .../src/sede/testing/fixtures/siteErrands.ts | 1 + .../src/signing/CertificateCard.stories.tsx | 1 + .../src/signing/CertificateSelect.test.tsx | 1 + rfirma-app/src/signing/certificate.test.ts | 16 ++-- rfirma-app/src/signing/certificate.ts | 16 ++-- rfirma-app/src/signing/failure.test.ts | 1 + .../src/signing/sameSignerNotice.test.ts | 1 + rfirma-app/src/signing/testing/harness.tsx | 1 + .../src/signing/testing/storyReports.ts | 1 + .../src/signing/useCertificateListing.test.ts | 4 +- rfirma-app/src/signing/useSigning.test.ts | 1 + rfirma-app/src/testing/harness.tsx | 1 + 38 files changed, 413 insertions(+), 98 deletions(-) create mode 100644 rfirma-app/src-tauri/src/identity/adapters/macos_store/installs.rs diff --git a/.github/workflows/platforms.yml b/.github/workflows/platforms.yml index f31e7ce91..c9801aea2 100644 --- a/.github/workflows/platforms.yml +++ b/.github/workflows/platforms.yml @@ -94,6 +94,7 @@ jobs: rust: lint node: true java: graalvm + tools: llvm-cov crap save-cache: ${{ inputs.save-cache }} - name: Cache de librfirma_crypto.dylib @@ -119,5 +120,6 @@ jobs: - name: rustfmt y clippy run: just lint-rust - - name: Pruebas de --lib y del canal local + # Los adaptadores de macOS solo compilan aqui: Linux los oculta de su puerta CRAP (ADR-0014, ADR-0040). + - name: Pruebas de --lib y del canal local, y CRAP de los adaptadores de macOS run: just test-macos diff --git a/CONTEXT.md b/CONTEXT.md index 2ca35c173..123708a0a 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -217,9 +217,9 @@ una tarjeta criptográfica que Windows copia ahí no son suyos, sino de la tarjeta. Windows pide su contraseña o su PIN con su propio diálogo. _Avoid_: almacén del sistema, almacén CNG, almacén MY -**Almacén de macOS** (Rust `StoreClass::MacOs`): -Los certificados con clave privada del llavero de la persona usuaria en macOS. Los de una -tarjeta que macOS lee con su controlador son de la tarjeta, no suyos. macOS pide su contraseña o +**Almacén de macOS** (Rust `StoreClass::Macos`): +Los certificados con clave privada de software del llavero de la persona usuaria en macOS. Los +que tienen la clave en una tarjeta o un token del sistema no salen. macOS pide su contraseña o su PIN con su propio diálogo. «Llavero» sigue reservado para lo que guarda secretos. _Avoid_: almacén del sistema, llavero, Keychain 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 fa61b99f2..14be91d06 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 @@ -16,13 +16,11 @@ como fallaría Linux sin el servicio y dice en su nombre qué falta. | `mlock` del secreto | `mlock`; `MADV_DONTDUMP` es solo de Linux. | | Cerrojos (Firefox, carpeta de paso) | Los de Unix, sin cambios. | | Llavero `oo7` (ADR-0034) | `PendingMacosKeychain`: responde que no hay llavero. | -| 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, CAdES y XAdES pasan por el puente y el validador de AutoFirma da por válidas las PAdES. El PIN de una tarjeta por PKCS#11 lo pide una alerta de AppKit con campo seguro, modal sobre la ventana -que lo pidió (ADR-0047); el Almacén de macOS ya lista los certificados del llavero, pero aún no -firma con ellos como firma el de CNG en Windows. +que lo pidió (ADR-0047). ## En macOS, la API de macOS siempre que se pueda diff --git a/justfile b/justfile index 8186f563d..9608230df 100644 --- a/justfile +++ b/justfile @@ -94,6 +94,11 @@ ffi_allow := "src/signing/adapters/ffi.rs" # carril de Windows lo mide con `just test-windows` (ADR-0014, ADR-0035). windows_allow := "src/identity/adapters/windows_store/cng.rs" +# Los adaptadores que solo compila macOS: el carril rapido de Linux los oculta y +# el carril de macOS los mide con `just test-macos` (ADR-0014, ADR-0040). +macos_store_allow := "src/identity/adapters/macos_store" +macos_trust_allow := "src/site/adapters/macos_trust.rs" + autoscript_url := "https://raw.githubusercontent.com/ctt-gob-es/clienteafirma/v" + autofirma_version + "/afirma-ui-miniapplet-deploy/src/main/webapp/js/autoscript.js" # Librerias -dev del WebView que necesita Tauri; lista canonica que instala @@ -402,10 +407,13 @@ smoke-console: build-ts cd {{ tauri }} && {{ no_debuginfo }} cargo build --features console --bin rfirma --bin rfirma-console {{ root }}/scripts/console-smoke.sh "$CARGO_TARGET_DIR/debug" -# Pruebas de --lib y del canal local en macOS, sin grada B ni C (ADR-0040). +# Pruebas de --lib y del canal local en macOS, sin grada B ni C, y la puerta CRAP de sus adaptadores (ADR-0040). [group('ci')] test-macos: build-ts - cd {{ tauri }} && cargo test --all-features --lib --test channel_client --test channel_operations --test service_acknowledgement + mkdir -p "{{ coverage_out }}/macos" + cd {{ tauri }} && {{ no_debuginfo }} cargo llvm-cov --all-features --lib --test channel_client --test channel_operations --test service_acknowledgement --lcov --output-path "{{ coverage_out }}/macos/lcov.info" + cd {{ tauri }} && cargo crap --path '{{ macos_store_allow }}' --lcov "{{ coverage_out }}/macos/lcov.info" --threshold 30 --fail-above + cd {{ tauri }} && cargo crap --path '{{ macos_trust_allow }}' --lcov "{{ coverage_out }}/macos/lcov.info" --threshold 30 --fail-above # --------------------------------------------------------------------------- # CRAP: solo en Rust (ADR-0014) @@ -428,7 +436,8 @@ coverage: (certs "install") build-ts llvm-cov-tag [private] crap: coverage cd {{ tauri }} && cargo crap --lcov "{{ coverage_out }}/coverage/lcov.info" --threshold 30 --fail-above \ - --allow '{{ ffi_allow }}' --allow '{{ windows_allow }}' + --allow '{{ ffi_allow }}' --allow '{{ windows_allow }}' \ + --allow '{{ macos_store_allow }}/**' --allow '{{ macos_trust_allow }}' # Cobertura del diff contra origin/main (ADR-0014): reutiliza el lcov.info que # ya dejo `coverage` (dependencia de `check-rust`) en disco, sin volver a diff --git a/packaging/repo/build-tree.test.sh b/packaging/repo/build-tree.test.sh index 792519e89..1433398ef 100755 --- a/packaging/repo/build-tree.test.sh +++ b/packaging/repo/build-tree.test.sh @@ -539,7 +539,7 @@ actualizacion_de_macos_de_pega "$con_candidata/v0.4.11-rc.1" "0.4.11-rc.1" "$construye" "$con_candidata" "$tmp/arbol-rc-mac" "$tmp/rfirma.asc" "$SIN_FIRMA" > /dev/null 2>&1 \ || fail "la construccion con una candidata de macOS en la serie falla" if [ "$(jq -r .version "$tmp/arbol-rc-mac/macos/latest.json")" = "0.4.10" ] \ - && [ -z "$(find "$tmp/arbol-rc-mac/macos" -name '*rc*' -print -quit)" ]; then + && [ -z "$(find "$tmp/arbol-rc-mac/macos" -name '*-rc*' -print -quit)" ]; then ok "una candidata no aparece en macos/ ni en su latest.json" else fail "una candidata aparece en macos/ o en su latest.json" diff --git a/rfirma-app/src-tauri/src/desktop/application/command_line/tests/platform.rs b/rfirma-app/src-tauri/src/desktop/application/command_line/tests/platform.rs index 19108d874..15e86d3b0 100644 --- a/rfirma-app/src-tauri/src/desktop/application/command_line/tests/platform.rs +++ b/rfirma-app/src-tauri/src/desktop/application/command_line/tests/platform.rs @@ -208,3 +208,37 @@ fn on_linux_store_apple_is_refused_without_opening_any_store() { assert_eq!(outcome.exit_code, REFUSED); assert!(!stores.opened.get()); } + +struct SoftwareAndTokenIdentities; + +impl CertificateStores for SoftwareAndTokenIdentities { + fn certificates(&self) -> Result, TokenError> { + let in_the_keychain = |label: &str| { + CertificateRef::new(Store::module("keychain:login"), "token", label, None) + }; + Ok(vec![ + TokenCertificate::new(in_the_keychain("SOFTWARE"), Vec::new()), + TokenCertificate::new( + in_the_keychain("EN_TOKEN").with_key_provider("com.apple.pivtoken"), + Vec::new(), + ), + ]) + } + + fn discovered_module(&self, _library: &str) -> Option { + None + } +} + +#[test] +fn on_macos_the_command_line_leaves_out_the_identities_with_their_key_in_a_system_token() { + for words in [ + &["listaliases", "-store", "apple"][..], + &["listaliases"][..], + ] { + let outcome = listed_on(Platform::MacOs, words, &SoftwareAndTokenIdentities); + + assert_eq!(outcome.exit_code, SUCCEEDED, "{words:?}"); + assert_eq!(outcome.stdout, b"SOFTWARE\n", "{words:?}"); + } +} diff --git a/rfirma-app/src-tauri/src/desktop/application/store_scope.rs b/rfirma-app/src-tauri/src/desktop/application/store_scope.rs index 17071e9c9..178524d0b 100644 --- a/rfirma-app/src-tauri/src/desktop/application/store_scope.rs +++ b/rfirma-app/src-tauri/src/desktop/application/store_scope.rs @@ -18,25 +18,25 @@ pub fn within_the_scope( ), _ => None, }; - let certificates = stores.certificates().map_err(ScopeFailure::Token)?; + let certificates = stores + .certificates() + .map_err(ScopeFailure::Token)? + .into_iter() + .filter(|certificate| !certificate.is_a_macos_token_identity()); Ok(match (scope, module) { (StoreScope::Nss, _) => certificates - .into_iter() .filter(|certificate| certificate.reference().store().class() != StoreClass::Card) .collect(), (StoreScope::Windows, _) => certificates - .into_iter() .filter(|certificate| certificate.reference().store().class() == StoreClass::Windows) .collect(), (StoreScope::Macos, _) => certificates - .into_iter() .filter(|certificate| certificate.reference().store().class() == StoreClass::Macos) .collect(), (_, Some(module)) => certificates - .into_iter() .filter(|certificate| certificate.reference().module() == module) .collect(), - _ => certificates, + _ => certificates.collect(), }) } diff --git a/rfirma-app/src-tauri/src/identity/adapters/macos_store.rs b/rfirma-app/src-tauri/src/identity/adapters/macos_store.rs index e39bdffb9..e44cb4596 100644 --- a/rfirma-app/src-tauri/src/identity/adapters/macos_store.rs +++ b/rfirma-app/src-tauri/src/identity/adapters/macos_store.rs @@ -1,5 +1,6 @@ //! `MacosToken`, el Almacén de macOS detrás del puerto `Token`, por Security.framework; los módulos PKCS#11 siguen en `RealToken` (ADR-0040). +mod installs; mod keychain; use std::path::Path; @@ -135,25 +136,34 @@ impl Token for MacosToken { true } + fn installed_in_the_system(&self, directory: &Path, reference: &CertificateRef) -> bool { + is_a_keychain(&reference.store()) + && reference + .cka_id() + .is_some_and(|thumbprint| installs::holds(directory, thumbprint)) + } + fn import_pkcs12( &self, - _directory: &Path, + directory: &Path, pkcs12: &[u8], password: &str, _pin: &ProtectedSecret, ) -> Result { let store = user_store(); - keychain::import_pkcs12(&store, pkcs12, password)?; + let thumbprints = keychain::import_pkcs12(&store, pkcs12, password)?; + installs::record(directory, &thumbprints)?; Ok(store) } fn remove_certificate( &self, - _directory: &Path, + directory: &Path, reference: &CertificateRef, _pin: &ProtectedSecret, ) -> Result<(), TokenError> { - keychain::remove(reference) + keychain::remove(reference)?; + installs::forget(directory, reference.cka_id().unwrap_or_default()) } } diff --git a/rfirma-app/src-tauri/src/identity/adapters/macos_store/installs.rs b/rfirma-app/src-tauri/src/identity/adapters/macos_store/installs.rs new file mode 100644 index 000000000..d344257b2 --- /dev/null +++ b/rfirma-app/src-tauri/src/identity/adapters/macos_store/installs.rs @@ -0,0 +1,57 @@ +//! Las huellas de los `.p12` que rFirma metió en el llavero de macOS, apuntadas en un fichero de su carpeta: solo esos se pueden quitar. + +use std::path::Path; + +use crate::identity::domain::error::{Situation, TokenError}; + +const RECORD: &str = "keychain-installs"; + +/// Apunta en `directory` las huellas de lo que rFirma acaba de meter en el llavero. +pub fn record(directory: &Path, thumbprints: &[Vec]) -> Result<(), TokenError> { + let mut lines = recorded(directory); + lines.extend(thumbprints.iter().map(|thumbprint| line_of(thumbprint))); + std::fs::create_dir_all(directory) + .and_then(|()| write(directory, &lines)) + .map_err(|error| unrecorded(&error)) +} + +/// Borra de `directory` la huella de lo que rFirma ha quitado del llavero. +pub fn forget(directory: &Path, thumbprint: &[u8]) -> Result<(), TokenError> { + let gone = line_of(thumbprint); + let lines = recorded(directory); + if !lines.contains(&gone) { + return Ok(()); + } + let kept: Vec = lines.into_iter().filter(|line| *line != gone).collect(); + write(directory, &kept).map_err(|error| unrecorded(&error)) +} + +/// Si la huella está apuntada en `directory`. +pub fn holds(directory: &Path, thumbprint: &[u8]) -> bool { + recorded(directory).contains(&line_of(thumbprint)) +} + +fn line_of(thumbprint: &[u8]) -> String { + thumbprint + .iter() + .map(|byte| format!("{byte:02X}")) + .collect() +} + +fn recorded(directory: &Path) -> Vec { + std::fs::read_to_string(directory.join(RECORD)) + .map(|text| text.lines().map(str::to_owned).collect()) + .unwrap_or_default() +} + +fn write(directory: &Path, lines: &[String]) -> std::io::Result<()> { + let text: String = lines.iter().map(|line| format!("{line}\n")).collect(); + std::fs::write(directory.join(RECORD), text) +} + +fn unrecorded(error: &std::io::Error) -> TokenError { + TokenError::new( + Situation::Unknown, + format!("no se ha podido apuntar lo que rFirma ha instalado en el llavero: {error}"), + ) +} diff --git a/rfirma-app/src-tauri/src/identity/adapters/macos_store/keychain.rs b/rfirma-app/src-tauri/src/identity/adapters/macos_store/keychain.rs index 9622181e7..9568a0b9f 100644 --- a/rfirma-app/src-tauri/src/identity/adapters/macos_store/keychain.rs +++ b/rfirma-app/src-tauri/src/identity/adapters/macos_store/keychain.rs @@ -89,9 +89,13 @@ pub fn sign( .map_err(|error| refused(&error)) } -/// Mete el `.p12` en el llavero de `store`, o en el de inicio de sesión si es la lista de búsqueda; antes lo prueba aparte y, si no sirve, no deja nada. -pub fn import_pkcs12(store: &Store, pkcs12: &[u8], password: &str) -> Result<(), TokenError> { - only_if_it_brings_a_usable_identity(pkcs12, password)?; +/// Mete el `.p12` en el llavero de `store`, o en el de inicio de sesión si es la lista de búsqueda, y da las huellas de sus identidades; antes lo prueba aparte y, si no sirve, no deja nada. +pub fn import_pkcs12( + store: &Store, + pkcs12: &[u8], + password: &str, +) -> Result>, TokenError> { + let thumbprints = only_if_it_brings_a_usable_identity(pkcs12, password)?; let mut options = Pkcs12ImportOptions::new(); options.passphrase(password); if let Some(mut keychains) = scope_of(store)? { @@ -99,7 +103,7 @@ pub fn import_pkcs12(store: &Store, pkcs12: &[u8], password: &str) -> Result<(), } options .import(pkcs12) - .map(|_| ()) + .map(|_| thumbprints) .map_err(|error| rejected_pkcs12(&error)) } @@ -109,16 +113,18 @@ pub fn remove(reference: &CertificateRef) -> Result<(), TokenError> { identity.delete().map_err(|error| failure(&error)) } -fn only_if_it_brings_a_usable_identity(pkcs12: &[u8], password: &str) -> Result<(), TokenError> { +fn only_if_it_brings_a_usable_identity( + pkcs12: &[u8], + password: &str, +) -> Result>, TokenError> { let scratch = ScratchKeychain::create()?; let imported = Pkcs12ImportOptions::new() .passphrase(password) .keychain(scratch.keychain.clone()) .import(pkcs12) .map_err(|error| rejected_pkcs12(&error))?; - let mut brought_a_key = false; + let mut thumbprints = Vec::new(); for identity in imported.iter().filter_map(|item| item.identity.as_ref()) { - brought_a_key = true; let certificate = identity.certificate().map_err(|error| failure(&error))?; let der = certificate.to_der(); let candidate = certificate_in(&Store::module(""), &der, None); @@ -131,9 +137,10 @@ fn only_if_it_brings_a_usable_identity(pkcs12: &[u8], password: &str) -> Result< ), )); } + thumbprints.push(candidate.reference().cka_id().unwrap_or_default().to_vec()); } - if brought_a_key { - return Ok(()); + if !thumbprints.is_empty() { + return Ok(thumbprints); } Err(TokenError::new( Situation::Pkcs12NoPrivateKey, diff --git a/rfirma-app/src-tauri/src/identity/adapters/macos_store/tests.rs b/rfirma-app/src-tauri/src/identity/adapters/macos_store/tests.rs index fac5eaf4e..589b0cb2b 100644 --- a/rfirma-app/src-tauri/src/identity/adapters/macos_store/tests.rs +++ b/rfirma-app/src-tauri/src/identity/adapters/macos_store/tests.rs @@ -17,7 +17,7 @@ use security_framework::import_export::Pkcs12ImportOptions; use security_framework::os::macos::keychain::{CreateOptions, SecKeychain}; use super::keychain::{import_pkcs12 as install, situation_of}; -use super::{keychain_store, stores_besides, user_store, MacosToken}; +use super::{installs, keychain_store, stores_besides, user_store, MacosToken}; use crate::identity::application::certificates::certificates_with_their_chains; use crate::identity::domain::algorithm::{KeyKind, SignatureAlgorithm}; use crate::identity::domain::certificate::{CertificateRef, TokenCertificate}; @@ -334,7 +334,16 @@ fn verifies( let key = X509::from_der(certificate.der()) .and_then(|certificate| certificate.public_key()) .expect("el certificado deberia traer su clave publica"); - let digest = match algorithm { + let digest = digest_of(algorithm); + let mut verifier = Verifier::new(digest, &key).expect("verificador"); + if algorithm.name().ends_with("MGF1") { + with_pss_padding(&mut verifier, digest); + } + verifier.verify_oneshot(signature, DATA).unwrap_or(false) +} + +fn digest_of(algorithm: SignatureAlgorithm) -> MessageDigest { + match algorithm { SignatureAlgorithm::Sha1Rsa | SignatureAlgorithm::Sha1Ecdsa => MessageDigest::sha1(), SignatureAlgorithm::Sha384Rsa | SignatureAlgorithm::Sha384RsaPss @@ -343,16 +352,15 @@ fn verifies( | SignatureAlgorithm::Sha512RsaPss | SignatureAlgorithm::Sha512Ecdsa => MessageDigest::sha512(), _ => MessageDigest::sha256(), - }; - let mut verifier = Verifier::new(digest, &key).expect("verificador"); - if algorithm.name().ends_with("MGF1") { - verifier.set_rsa_padding(RsaPadding::PKCS1_PSS).unwrap(); - verifier - .set_rsa_pss_saltlen(RsaPssSaltlen::DIGEST_LENGTH) - .unwrap(); - verifier.set_rsa_mgf1_md(digest).unwrap(); } - verifier.verify_oneshot(signature, DATA).unwrap_or(false) +} + +fn with_pss_padding(verifier: &mut Verifier<'_>, digest: MessageDigest) { + verifier.set_rsa_padding(RsaPadding::PKCS1_PSS).unwrap(); + verifier + .set_rsa_pss_saltlen(RsaPssSaltlen::DIGEST_LENGTH) + .unwrap(); + verifier.set_rsa_mgf1_md(digest).unwrap(); } fn algorithms_for(kind: KeyKind) -> Vec { @@ -487,18 +495,39 @@ fn installing_a_pkcs12_puts_its_identity_in_the_keychain_for_rsa_and_elliptic_cu let rsa = an_rsa_key(); let ec = an_elliptic_curve_key(); + let mut thumbprints = Vec::new(); for (name, key) in [("INSTALADO RSA", &rsa), ("INSTALADO CURVA", &ec)] { let pkcs12 = pkcs12_of(&a_certificate(name, key, None, false), Some(key), "clave"); - install(&keychain.store(), &pkcs12, "clave").expect("deberia instalarse"); + thumbprints + .extend(install(&keychain.store(), &pkcs12, "clave").expect("deberia instalarse")); } - let mut labels: Vec = keychain - .listed() + let listed = keychain.listed(); + let mut labels: Vec = listed .iter() .map(|certificate| certificate.reference().label().to_owned()) .collect(); labels.sort(); assert_eq!(labels, vec!["INSTALADO CURVA", "INSTALADO RSA"]); + let mut listed_thumbprints: Vec> = listed + .iter() + .filter_map(|certificate| certificate.reference().cka_id().map(<[u8]>::to_vec)) + .collect(); + listed_thumbprints.sort(); + thumbprints.sort(); + assert_eq!(thumbprints, listed_thumbprints); +} + +#[test] +fn the_record_of_what_rfirma_installed_keeps_each_thumbprint_until_it_is_removed() { + let home = tempfile::tempdir().expect("deberia haber directorio temporal"); + let directory = home.path().join("certificates"); + + installs::record(&directory, &[vec![0x01], vec![0x02]]).expect("deberia apuntarse"); + installs::forget(&directory, &[0x01]).expect("deberia borrarse"); + + assert!(!installs::holds(&directory, &[0x01])); + assert!(installs::holds(&directory, &[0x02])); } #[test] @@ -548,8 +577,13 @@ fn removing_an_installed_certificate_deletes_it_and_its_key_from_the_keychain() .find(|certificate| certificate.reference().label() == "SE VA") .expect("deberia estar en el llavero"); + let home = tempfile::tempdir().expect("deberia haber directorio temporal"); + let thumbprint = gone.reference().cka_id().expect("lleva huella").to_vec(); + installs::record(home.path(), &[thumbprint]).expect("deberia apuntarse"); + assert!(MacosToken.installed_in_the_system(home.path(), gone.reference())); + MacosToken - .remove_certificate(Path::new(""), gone.reference(), &ProtectedSecret::new(b"")) + .remove_certificate(home.path(), gone.reference(), &ProtectedSecret::new(b"")) .expect("deberia quitarse"); let labels: Vec = keychain @@ -558,6 +592,19 @@ fn removing_an_installed_certificate_deletes_it_and_its_key_from_the_keychain() .map(|certificate| certificate.reference().label().to_owned()) .collect(); assert_eq!(labels, vec!["SE QUEDA"]); + assert!(!MacosToken.installed_in_the_system(home.path(), gone.reference())); +} + +#[test] +fn a_keychain_certificate_rfirma_did_not_install_is_not_installed_in_the_system() { + let keychain = TemporaryKeychain::create(); + let key = an_rsa_key(); + keychain.holds_an_identity(&a_certificate("DE SAFARI", &key, None, false), &key); + let home = tempfile::tempdir().expect("deberia haber directorio temporal"); + + let listed = keychain.listed(); + + assert!(!MacosToken.installed_in_the_system(home.path(), listed[0].reference())); } #[test] diff --git a/rfirma-app/src-tauri/src/identity/adapters/views.rs b/rfirma-app/src-tauri/src/identity/adapters/views.rs index 25bc0c4bd..e7c77a655 100644 --- a/rfirma-app/src-tauri/src/identity/adapters/views.rs +++ b/rfirma-app/src-tauri/src/identity/adapters/views.rs @@ -138,6 +138,8 @@ crossing! { pub status: StatusView, /// Si alguna de sus copias fue la usada en la última firma. pub remembered: bool, + /// Si rFirma lo instaló desde un `.p12`: Preferencias solo enseña y quita estos. + pub installed: bool, } } @@ -162,6 +164,7 @@ impl From for CertificateView { .collect(), status: certificate.status.into(), remembered: certificate.remembered, + installed: certificate.installed, } } } diff --git a/rfirma-app/src-tauri/src/identity/adapters/views/tests.rs b/rfirma-app/src-tauri/src/identity/adapters/views/tests.rs index 6b101b0c6..630db8b84 100644 --- a/rfirma-app/src-tauri/src/identity/adapters/views/tests.rs +++ b/rfirma-app/src-tauri/src/identity/adapters/views/tests.rs @@ -70,6 +70,7 @@ fn a_view() -> CertificateView { not_after: 1_900_000_000, }, remembered: false, + installed: false, } } @@ -91,6 +92,7 @@ fn a_row_in(stores: Vec, from_a_dnie: bool) -> ListedCertificate { from_a_dnie, status: CertificateStatus::Valid { not_after: 0 }, remembered: false, + installed: false, } } diff --git a/rfirma-app/src-tauri/src/identity/application/certificates.rs b/rfirma-app/src-tauri/src/identity/application/certificates.rs index c4127676d..75573f3fb 100644 --- a/rfirma-app/src-tauri/src/identity/application/certificates.rs +++ b/rfirma-app/src-tauri/src/identity/application/certificates.rs @@ -68,7 +68,26 @@ pub fn listed_rows( listed, installed_copies, memory, - )) + ) + .into_iter() + .map(|mut row| { + row.installed = row.installed + || listed + .get(&row.id) + .is_some_and(|reference| token.installed_in_the_system(installed_dir, &reference)); + row + }) + .collect()) +} + +/// Si rFirma instaló `reference` desde un `.p12`: en su Almacén o, donde instala el sistema, apuntado por el token. +fn installed_by_rfirma( + token: &dyn Token, + installed_dir: &Path, + reference: &CertificateRef, +) -> bool { + reference.class_under(installed_dir) == StoreClass::Installed + || token.installed_in_the_system(installed_dir, reference) } /// Los certificados de los almacenes, cada uno con los emisores que su propio almacén aporta. @@ -229,6 +248,7 @@ fn listed_as(row: ChosenCopy, id: String) -> ListedCertificate { from_a_dnie: certificate.is_from_a_dnie(), status: certificate.status(), remembered: row.remembered, + installed: row.installed_reference.is_some(), } } @@ -407,18 +427,16 @@ pub fn remove_installed( Some(installed) => installed, None => listed.get(handle).ok_or_else(not_from_the_last_listing)?, }; + if !installed_by_rfirma(token, installed_dir, &reference) { + return Err(TokenError::new( + Situation::CertificateNotFound, + "ese certificado no viene de un .p12 instalado", + ) + .into()); + } let pin = if token.installs_in_the_system() { ProtectedSecret::new(b"") } else { - reference - .store() - .installed_directory_under(installed_dir) - .ok_or_else(|| { - TokenError::new( - Situation::CertificateNotFound, - "ese certificado no viene de un .p12 instalado", - ) - })?; keyring.pin()? }; token.remove_certificate(installed_dir, &reference, &pin)?; diff --git a/rfirma-app/src-tauri/src/identity/application/certificates/tests/macos_store.rs b/rfirma-app/src-tauri/src/identity/application/certificates/tests/macos_store.rs index 55dfa1532..49834166f 100644 --- a/rfirma-app/src-tauri/src/identity/application/certificates/tests/macos_store.rs +++ b/rfirma-app/src-tauri/src/identity/application/certificates/tests/macos_store.rs @@ -165,3 +165,40 @@ fn a_card_certificate_reaching_the_keychain_on_a_token_is_listed_once_by_pkcs11( assert_eq!(rows.len(), 1); assert_eq!(rows[0].store, StoreClass::Card); } + +#[test] +fn only_what_rfirma_installed_is_marked_as_installed() { + let home = tempfile::tempdir().expect("deberia haber directorio temporal"); + let installed_dir = home.path().join("certificates"); + let installed = the_installed_store(&installed_dir); + let found = vec![ + in_the_keychain("DE-RFIRMA", &TestAuthority::root("DE RFIRMA").der()), + in_the_keychain("DE-SAFARI", &TestAuthority::root("DE SAFARI").der()), + a_copy_in( + &installed, + "DEL-ALMACEN", + &TestAuthority::root("DEL ALMACEN").der(), + ), + ]; + let token = + StoresWith::holding(found.clone(), found).having_installed_in_the_system("DE-RFIRMA"); + + let rows = listed_rows( + &token, + &[the_macos_store(), Store::nss(INSTALLED, &installed_dir)], + &installed_dir, + &ListedCertificates::new(), + &ListedCertificates::new(), + &NoMemory, + &LastListing::default(), + ) + .expect("los almacenes deberian listarse"); + + let mut marked: Vec<&str> = rows + .iter() + .filter(|row| row.installed) + .map(|row| row.label.as_str()) + .collect(); + marked.sort_unstable(); + assert_eq!(marked, vec!["DE-RFIRMA", "DEL-ALMACEN"]); +} diff --git a/rfirma-app/src-tauri/src/identity/application/certificates/tests/mod.rs b/rfirma-app/src-tauri/src/identity/application/certificates/tests/mod.rs index f05f7e0ed..e5ddf2467 100644 --- a/rfirma-app/src-tauri/src/identity/application/certificates/tests/mod.rs +++ b/rfirma-app/src-tauri/src/identity/application/certificates/tests/mod.rs @@ -198,6 +198,7 @@ const SOFTOKEN: &str = "/usr/lib/libsoftokn3.so"; struct StoresWith { signable: Vec, everything: Result, TokenError>, + installed_in_the_system: Vec, } impl StoresWith { @@ -206,6 +207,7 @@ impl StoresWith { Self { signable, everything: Ok(everything), + installed_in_the_system: Vec::new(), } } @@ -216,9 +218,16 @@ impl StoresWith { Situation::ModuleNotFound, "el almacen no se deja abrir", )), + installed_in_the_system: Vec::new(), } } + /// El mismo token, con el certificado de esa etiqueta instalado en el sistema por rFirma. + fn having_installed_in_the_system(mut self, label: &str) -> Self { + self.installed_in_the_system.push(label.to_owned()); + self + } + fn only(&self, store: &Store, certificates: &[TokenCertificate]) -> Vec { certificates .iter() @@ -229,6 +238,12 @@ impl StoresWith { } impl Token for StoresWith { + fn installed_in_the_system(&self, _directory: &Path, reference: &CertificateRef) -> bool { + self.installed_in_the_system + .iter() + .any(|label| label == reference.label()) + } + fn accepts_the_secret( &self, _reference: &crate::identity::domain::certificate::CertificateRef, diff --git a/rfirma-app/src-tauri/src/identity/application/certificates/tests/system_install.rs b/rfirma-app/src-tauri/src/identity/application/certificates/tests/system_install.rs index cc6b3b7ac..237b3dfdb 100644 --- a/rfirma-app/src-tauri/src/identity/application/certificates/tests/system_install.rs +++ b/rfirma-app/src-tauri/src/identity/application/certificates/tests/system_install.rs @@ -18,6 +18,7 @@ struct SystemToken { imported: RefCell>, removed: RefCell>, outcome: Result<(), TokenError>, + installed: Vec, } impl SystemToken { @@ -26,8 +27,14 @@ impl SystemToken { imported: RefCell::new(Vec::new()), removed: RefCell::new(Vec::new()), outcome, + installed: Vec::new(), } } + + fn having_installed(mut self, reference: &CertificateRef) -> Self { + self.installed.push(reference.clone()); + self + } } impl Token for SystemToken { @@ -35,6 +42,10 @@ impl Token for SystemToken { true } + fn installed_in_the_system(&self, _directory: &Path, reference: &CertificateRef) -> bool { + self.installed.contains(reference) + } + fn list(&self, store: &Store) -> Result, TokenError> { NoToken.list(store) } @@ -200,7 +211,7 @@ fn removing_a_macos_store_certificate_asks_the_system_and_forgets_it() { super::super::remember_the_certificate(&memory, &reference); let listed = ListedCertificates::new(); let handle = listed.mint(reference.clone()); - let token = SystemToken::answering(Ok(())); + let token = SystemToken::answering(Ok(())).having_installed(&reference); let removed = remove_installed( &token, @@ -216,3 +227,28 @@ fn removing_a_macos_store_certificate_asks_the_system_and_forgets_it() { assert_eq!(*token.removed.borrow(), vec![reference]); assert!(memory.remembered_certificate().is_none()); } + +#[test] +fn a_macos_store_certificate_rfirma_did_not_install_is_not_removed() { + let home = tempfile::tempdir().expect("deberia haber directorio temporal"); + let listed = ListedCertificates::new(); + let handle = listed.mint(in_the_macos_store()); + let token = SystemToken::answering(Ok(())); + + let failure = remove_installed( + &token, + &NoKeyring, + &a_memory(home.path()), + &home.path().join("certificates"), + &handle, + &listed, + &ListedCertificates::new(), + ) + .expect_err("no lo instalo rFirma"); + + assert!(matches!( + failure, + InstallError::Token(token) if token.situation() == Situation::CertificateNotFound + )); + assert!(token.removed.borrow().is_empty()); +} diff --git a/rfirma-app/src-tauri/src/identity/domain/certificate.rs b/rfirma-app/src-tauri/src/identity/domain/certificate.rs index 6bca90365..7a31acb9c 100644 --- a/rfirma-app/src-tauri/src/identity/domain/certificate.rs +++ b/rfirma-app/src-tauri/src/identity/domain/certificate.rs @@ -418,6 +418,8 @@ pub struct ListedCertificate { pub status: CertificateStatus, /// Si alguna de sus copias fue la usada en la última firma. pub remembered: bool, + /// Si rFirma lo instaló desde un `.p12` y, por tanto, se puede quitar. + pub installed: bool, } #[cfg(test)] diff --git a/rfirma-app/src-tauri/src/identity/ports.rs b/rfirma-app/src-tauri/src/identity/ports.rs index 495cd4878..c3aa89e0a 100644 --- a/rfirma-app/src-tauri/src/identity/ports.rs +++ b/rfirma-app/src-tauri/src/identity/ports.rs @@ -73,6 +73,11 @@ pub trait Token { false } + /// Si `reference` es de un `.p12` que rFirma instaló en el sistema, según lo apuntado en `directory`. + fn installed_in_the_system(&self, _directory: &Path, _reference: &CertificateRef) -> bool { + false + } + /// Importa un `.p12` al Almacén de rFirma en ese directorio, cifrado con `pin`, y lo devuelve. fn import_pkcs12( &self, diff --git a/rfirma-app/src-tauri/src/site/adapters/macos_trust.rs b/rfirma-app/src-tauri/src/site/adapters/macos_trust.rs index 079cd0726..6b62083db 100644 --- a/rfirma-app/src-tauri/src/site/adapters/macos_trust.rs +++ b/rfirma-app/src-tauri/src/site/adapters/macos_trust.rs @@ -89,28 +89,8 @@ impl TrustStores for MacosUserTrust { 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(()) + untrusted(&certificate)?; + delete_the_copies_of(certificate_der) } fn local_cas(&self, profile: &Path) -> Result>, TrustError> { @@ -123,6 +103,35 @@ impl TrustStores for MacosUserTrust { } } +fn untrusted(certificate: &SecCertificate) -> Result<(), TrustError> { + let status = unsafe { + SecTrustSettingsRemoveTrustSettings( + certificate.as_concrete_TypeRef(), + kSecTrustSettingsDomainUser, + ) + }; + if status == errSecSuccess || status == errSecItemNotFound { + return Ok(()); + } + Err(refused_or_failed( + Situation::TrustNotWithdrawn, + "SecTrustSettingsRemoveTrustSettings", + status, + )) +} + +fn delete_the_copies_of(certificate_der: &[u8]) -> Result<(), TrustError> { + 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 the_user_trust(profile: &Path) -> Result<(), TrustError> { if is_the_macos_user_trust(profile) { return Ok(()); diff --git a/rfirma-app/src-tauri/src/site/application/errand/state/tests.rs b/rfirma-app/src-tauri/src/site/application/errand/state/tests.rs index 65d004de9..9a17c2d90 100644 --- a/rfirma-app/src-tauri/src/site/application/errand/state/tests.rs +++ b/rfirma-app/src-tauri/src/site/application/errand/state/tests.rs @@ -37,6 +37,7 @@ fn asking_with(label: &str) -> Moment { from_a_dnie: false, status: CertificateStatus::Valid { not_after: 0 }, remembered: false, + installed: false, }], not_accepted: 0, } diff --git a/rfirma-app/src-tauri/src/startup_dialog.rs b/rfirma-app/src-tauri/src/startup_dialog.rs index 0fc045ab9..1fd2a8301 100644 --- a/rfirma-app/src-tauri/src/startup_dialog.rs +++ b/rfirma-app/src-tauri/src/startup_dialog.rs @@ -61,6 +61,7 @@ fn show_macos_alert(failure: &StartupFailure) { use objc2_foundation::NSString; let Some(mtm) = MainThreadMarker::new() else { + eprintln!("{}", detail_text(failure)); return; }; let _ = NSApplication::sharedApplication(mtm); diff --git a/rfirma-app/src-tauri/tests/contract.snapshot b/rfirma-app/src-tauri/tests/contract.snapshot index f35e31d92..4e473f1be 100644 --- a/rfirma-app/src-tauri/tests/contract.snapshot +++ b/rfirma-app/src-tauri/tests/contract.snapshot @@ -270,6 +270,7 @@ TIPOS QUE CRUZAN (/adapters/views*.rs y order stores: Vec status: StatusView remembered: bool + installed: bool pub enum ReaderStatusView (serde: etiqueta "kind") Unavailable diff --git a/rfirma-app/src/App.chrome.test.tsx b/rfirma-app/src/App.chrome.test.tsx index 5653b3f79..94dc65621 100644 --- a/rfirma-app/src/App.chrome.test.tsx +++ b/rfirma-app/src/App.chrome.test.tsx @@ -116,7 +116,12 @@ describe("App", () => { */ it("lists a just installed certificate in Preferences", async () => { const user = userEvent.setup(); - const p12: Certificate = { ...aCertificate, id: "p12", stores: ["installed"] }; + const p12: Certificate = { + ...aCertificate, + id: "p12", + stores: ["installed"], + installed: true, + }; let found: readonly Certificate[] = []; renderApp({ recents: inMemoryRecents(), @@ -145,7 +150,12 @@ describe("App", () => { it("takes a removed certificate out of the list", async () => { const user = userEvent.setup(); - const p12: Certificate = { ...aCertificate, id: "p12", stores: ["installed"] }; + const p12: Certificate = { + ...aCertificate, + id: "p12", + stores: ["installed"], + installed: true, + }; let found: readonly Certificate[] = [p12]; renderApp({ recents: inMemoryRecents(), diff --git a/rfirma-app/src/preferences/testing/fixtures.ts b/rfirma-app/src/preferences/testing/fixtures.ts index e527659d3..de8f24ecd 100644 --- a/rfirma-app/src/preferences/testing/fixtures.ts +++ b/rfirma-app/src/preferences/testing/fixtures.ts @@ -40,6 +40,7 @@ export function anInstalledCertificate(overrides: Partial = {}): Ce stores: ["installed"], status: { kind: "valid", notAfter: IN_2030 }, remembered: false, + installed: true, ...overrides, }; } diff --git a/rfirma-app/src/sede/testing/fixtures/sedeWindow.ts b/rfirma-app/src/sede/testing/fixtures/sedeWindow.ts index e2c01075e..61cbecd82 100644 --- a/rfirma-app/src/sede/testing/fixtures/sedeWindow.ts +++ b/rfirma-app/src/sede/testing/fixtures/sedeWindow.ts @@ -33,6 +33,7 @@ export function certificate(overrides: Partial = {}): Certificate { stores: ["installed"], status: { kind: "valid", notAfter: 4_102_444_800 }, remembered: false, + installed: false, ...overrides, }; } diff --git a/rfirma-app/src/sede/testing/fixtures/siteErrands.ts b/rfirma-app/src/sede/testing/fixtures/siteErrands.ts index d06d8d039..6a497e2db 100644 --- a/rfirma-app/src/sede/testing/fixtures/siteErrands.ts +++ b/rfirma-app/src/sede/testing/fixtures/siteErrands.ts @@ -27,6 +27,7 @@ export function certificate(overrides: Partial = {}): Certificate { stores: ["installed"], status: { kind: "valid", notAfter: 4_102_444_800 }, remembered: false, + installed: false, ...overrides, }; } diff --git a/rfirma-app/src/signing/CertificateCard.stories.tsx b/rfirma-app/src/signing/CertificateCard.stories.tsx index 993bcd5dc..6e8bc5ddc 100644 --- a/rfirma-app/src/signing/CertificateCard.stories.tsx +++ b/rfirma-app/src/signing/CertificateCard.stories.tsx @@ -25,6 +25,7 @@ function aCertificate(overrides: Partial = {}): Certificate { stores: ["card"], status: { kind: "valid", notAfter: IN_2030 }, remembered: false, + installed: false, ...overrides, }; } diff --git a/rfirma-app/src/signing/CertificateSelect.test.tsx b/rfirma-app/src/signing/CertificateSelect.test.tsx index 2478cbc42..7bed47a87 100644 --- a/rfirma-app/src/signing/CertificateSelect.test.tsx +++ b/rfirma-app/src/signing/CertificateSelect.test.tsx @@ -26,6 +26,7 @@ function aCertificate(overrides: Partial = {}): Certificate { stores: ["card"], status: { kind: "valid", notAfter: Date.UTC(2028, 2, 15, 12) / 1000 }, remembered: false, + installed: false, ...overrides, }; } diff --git a/rfirma-app/src/signing/certificate.test.ts b/rfirma-app/src/signing/certificate.test.ts index fb216e16f..e7ee566fa 100644 --- a/rfirma-app/src/signing/certificate.test.ts +++ b/rfirma-app/src/signing/certificate.test.ts @@ -29,6 +29,7 @@ function aCertificate(overrides: Partial = {}): Certificate { stores: ["card"], status: { kind: "valid", notAfter: 1_894_752_000 }, remembered: false, + installed: false, ...overrides, }; } @@ -236,19 +237,19 @@ describe("installedCertificates", () => { * nadie no es un gesto de esta aplicación (ID-198). */ it("keeps only the ones installed in rFirma", () => { - const installed = aCertificate({ id: "installed", stores: ["installed"] }); + const installed = aCertificate({ id: "installed", stores: ["installed"], installed: true }); const profile = aCertificate({ id: "firefox", stores: [browserStore("Firefox")] }); const shared = aCertificate({ id: "nssdb", stores: ["nssdb"] }); expect(installedCertificates([profile, installed, shared])).toEqual([installed]); }); - /** En macOS el `.p12` instalado vive en el llavero, y desde Preferencias se quita de allí. */ - it("keeps the ones in the macOS store, where a .p12 is installed on a Mac", () => { - const keychain = aCertificate({ id: "keychain", stores: ["macos"] }); - const windows = aCertificate({ id: "windows", stores: ["windows"] }); + /** En macOS el `.p12` instalado vive en el llavero, junto a los que no puso rFirma. */ + it("keeps only the macOS store ones that rFirma installed", () => { + const fromRfirma = aCertificate({ id: "rfirma", stores: ["macos"], installed: true }); + const fromSafari = aCertificate({ id: "safari", stores: ["macos"] }); - expect(installedCertificates([windows, keychain])).toEqual([keychain]); + expect(installedCertificates([fromSafari, fromRfirma])).toEqual([fromRfirma]); }); /** Un caducado se queda: que desaparezca no le explica nada a quien lo instaló. */ @@ -256,6 +257,7 @@ describe("installedCertificates", () => { const expired = aCertificate({ id: "expired", stores: ["installed"], + installed: true, status: { kind: "expired", notAfter: 0 }, }); @@ -266,11 +268,13 @@ describe("installedCertificates", () => { const ada = aCertificate({ id: "ada", stores: ["installed"], + installed: true, holderName: "Ada Lovelace Byron", }); const grace = aCertificate({ id: "grace", stores: ["installed"], + installed: true, holderName: "Grace Hopper Murray", }); diff --git a/rfirma-app/src/signing/certificate.ts b/rfirma-app/src/signing/certificate.ts index 277e0f7c4..d77e19123 100644 --- a/rfirma-app/src/signing/certificate.ts +++ b/rfirma-app/src/signing/certificate.ts @@ -39,9 +39,8 @@ export type CertificateStatus = /** * De qué clase es el almacén de donde salió el certificado. * - * `installed` es el `.p12` que se metió en rFirma desde Preferencias: - * es la única clase que se puede **quitar**, y por eso la lista de esa pantalla - * se queda exactamente con ella. + * `installed` es el Almacén de rFirma, donde va el `.p12` que se instala desde + * Preferencias fuera de macOS. * * Cruza la frontera como **clase en inglés** y nunca como texto ya escrito ni * como ruta: el rótulo lo pone el catálogo de esta ventana, igual que hace con @@ -120,6 +119,8 @@ export interface Certificate { * error, es que no está (ADR-0010). */ remembered: boolean; + /** Si rFirma lo instaló desde un `.p12`: Preferencias solo enseña y quita estos. */ + installed: boolean; } /** @@ -299,7 +300,7 @@ export function emptyCertificateStore(): CertificateStore { /** * Los que se instalaron en rFirma desde un `.p12`, que son los únicos que * Preferencias enseña y los únicos que se pueden quitar. En macOS el `.p12` - * se instala en el llavero, así que son los del Almacén de macOS. + * se instala en el llavero, y el backend marca solo los que puso rFirma. * * El orden es el mismo del desplegable —alfabético por primera línea— para * que la misma persona salga en el mismo sitio en las dos pantallas, y **los @@ -308,12 +309,7 @@ export function emptyCertificateStore(): CertificateStore { export function installedCertificates( certificates: readonly Certificate[], ): readonly Certificate[] { - return certificates - .filter( - (certificate) => - certificate.stores.includes("installed") || certificate.stores.includes("macos"), - ) - .sort(byHeadlineThenStore); + return certificates.filter((certificate) => certificate.installed).sort(byHeadlineThenStore); } /** diff --git a/rfirma-app/src/signing/failure.test.ts b/rfirma-app/src/signing/failure.test.ts index 9a55f8762..4cae45ef1 100644 --- a/rfirma-app/src/signing/failure.test.ts +++ b/rfirma-app/src/signing/failure.test.ts @@ -18,6 +18,7 @@ function withStatus(status: Certificate["status"]): Certificate { stores: ["card"], status, remembered: false, + installed: false, }; } diff --git a/rfirma-app/src/signing/sameSignerNotice.test.ts b/rfirma-app/src/signing/sameSignerNotice.test.ts index d0f2639e3..963ab8f7e 100644 --- a/rfirma-app/src/signing/sameSignerNotice.test.ts +++ b/rfirma-app/src/signing/sameSignerNotice.test.ts @@ -19,6 +19,7 @@ function aCertificate(overrides: Partial = {}): Certificate { stores: ["card"], status: { kind: "valid", notAfter: 1_894_752_000 }, remembered: false, + installed: false, ...overrides, }; } diff --git a/rfirma-app/src/signing/testing/harness.tsx b/rfirma-app/src/signing/testing/harness.tsx index 28693692f..f0d3d14db 100644 --- a/rfirma-app/src/signing/testing/harness.tsx +++ b/rfirma-app/src/signing/testing/harness.tsx @@ -67,6 +67,7 @@ export const certificate: Certificate = { stores: ["card"], status: { kind: "valid", notAfter: 1_894_752_000 }, remembered: false, + installed: false, }; /** Un JPEG de un píxel: lo que devuelve `rubric::normalize`, ya opaco. */ diff --git a/rfirma-app/src/signing/testing/storyReports.ts b/rfirma-app/src/signing/testing/storyReports.ts index 6afc7236b..25761baf8 100644 --- a/rfirma-app/src/signing/testing/storyReports.ts +++ b/rfirma-app/src/signing/testing/storyReports.ts @@ -146,6 +146,7 @@ function aCertificate(overrides: Partial = {}): Certificate { stores: ["card"], status: { kind: "valid", notAfter: IN_2030 }, remembered: false, + installed: false, ...overrides, }; } diff --git a/rfirma-app/src/signing/useCertificateListing.test.ts b/rfirma-app/src/signing/useCertificateListing.test.ts index b4477643f..8dbaac0aa 100644 --- a/rfirma-app/src/signing/useCertificateListing.test.ts +++ b/rfirma-app/src/signing/useCertificateListing.test.ts @@ -6,7 +6,7 @@ import { announcingCertificateStore } from "./testing/readers"; import { useCertificateListing } from "./useCertificateListing"; const installed = anInstalledCertificate({ id: "installed" }); -const onToken = anInstalledCertificate({ id: "token", stores: ["card"] }); +const onToken = anInstalledCertificate({ id: "token", stores: ["card"], installed: false }); function aStore(found: Certificate[], overrides: Partial = {}): CertificateStore { return { @@ -19,7 +19,7 @@ function aStore(found: Certificate[], overrides: Partial = {}) }; } -const dnie = anInstalledCertificate({ id: "dnie", stores: ["dnie"] }); +const dnie = anInstalledCertificate({ id: "dnie", stores: ["dnie"], installed: false }); async function listed(store: CertificateStore) { const hook = renderHook(() => useCertificateListing(store)); diff --git a/rfirma-app/src/signing/useSigning.test.ts b/rfirma-app/src/signing/useSigning.test.ts index 3dc7b4ab4..267490052 100644 --- a/rfirma-app/src/signing/useSigning.test.ts +++ b/rfirma-app/src/signing/useSigning.test.ts @@ -28,6 +28,7 @@ const certificate: Certificate = { stores: ["card"], status: { kind: "valid", notAfter: 1_894_752_000 }, remembered: false, + installed: false, }; const wrongPin: TokenFailure = { diff --git a/rfirma-app/src/testing/harness.tsx b/rfirma-app/src/testing/harness.tsx index 7f55e6347..4d649d3f5 100644 --- a/rfirma-app/src/testing/harness.tsx +++ b/rfirma-app/src/testing/harness.tsx @@ -154,6 +154,7 @@ export const aCertificate: Certificate = { stores: ["card"], status: { kind: "valid", notAfter: 1_894_752_000 }, remembered: false, + installed: false, }; /**