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] =?UTF-8?q?feat(identity):=20el=20Almac=C3=A9n=20de=20macO?= =?UTF-8?q?S=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