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 6d7392bd..93aab5d0 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 cb9838d3..6e29409a 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 0ec5f8d6..1bafb5d3 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 f924313c..78a8623a 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); +}