diff --git a/.github/ISSUE_TEMPLATE/dnie-macos.md b/.github/ISSUE_TEMPLATE/dnie-macos.md new file mode 100644 index 000000000..f8700822f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/dnie-macos.md @@ -0,0 +1,24 @@ +--- +name: El DNIe en macOS +about: El DNIe no aparece o no firma en macOS +title: '' +labels: needs-triage +assignees: '' +--- + +**No pegues certificados ni datos personales** (nombre, número del documento, número de soporte). Lo que sale de `--debug-info` no los lleva; revisa que lo que pegues tampoco. + +## Qué hacías + +## Qué pasó + +## Entorno + +- Versión de rFirma: +- Versión de macOS: +- Lector (marca y modelo): + +## Salida de `--debug-info` + +``` +``` diff --git a/docs/design/dialogo-pin.md b/docs/design/dialogo-pin.md index ab044a2df..4bf15e3e8 100644 --- a/docs/design/dialogo-pin.md +++ b/docs/design/dialogo-pin.md @@ -121,28 +121,31 @@ intento, así que no hay salto que evitar. - **Secreto incorrecto**: el campo toma la clase `error` del tema —el borde que el escritorio use para eso, sin glifo— y bajo el campo, en negrita, el mensaje de fallo de la palabra del almacén. Nada más. +- **Intentos restantes**: la tarjeta da el número exacto, como el DNIe por + rdnie en macOS (ADR-0047). Sobre el campo, siempre: «Te quedan N intentos.»; + con uno, «Te queda 1 intento: si el PIN no es correcto, la tarjeta se + bloqueará.». Con uno o dos va en negrita, como el último intento. Con cero + no hay diálogo: termina con el mensaje de PIN bloqueado. - **Último intento**: la tarjeta declara `CKF_USER_PIN_FINAL_TRY`. Sobre el campo y en negrita: «Último intento: si el PIN no es correcto, la tarjeta se - bloqueará.» Con el DNIe esa señal solo existe tras un fallo, así que llega en - el diálogo de reintento, junto al mensaje de fallo. + bloqueará.» Con el DNIe por PKCS#11 esa señal solo existe tras un fallo, así + que llega en el diálogo de reintento, junto al mensaje de fallo. - **Algún intento fallido**: la tarjeta declara `CKF_USER_PIN_COUNT_LOW`. Sobre el campo, sin negrita: «Ya ha habido algún intento fallido con esta tarjeta.» -Los dos avisos son solo del PIN de una tarjeta; una contraseña no los lleva. Las -señales se vuelven a leer antes de cada diálogo, y una tarjeta que se declara -bloqueada no abre el diálogo: termina con el mensaje de PIN bloqueado -(ADR-0047). La terminal de la línea de órdenes da los mismos avisos antes de -pedir el PIN. - -**No hay contador de reintentos, y no es un hueco por rellenar: es estructural** -(ID-191, ADR-0047). La información de token de PKCS#11 no trae los intentos -restantes, ni con una tarjeta real: solo las dos señales de arriba, y los avisos -no enseñan ningún número. Hasta la v0.3 esta ficha prometía enseñar los intentos que -quedaban antes de que la tarjeta se bloquease; el argumento era bueno y el dato -no existe, así que se retiran los dos. Los avisos no lo sustituyen: dicen lo que -la tarjeta declara y no cuentan nada. - -Tampoco cruza: `SecretView` no lleva contador, y el ciclo vuelve a pedir el +Los avisos son solo del PIN de una tarjeta; una contraseña no los lleva. Se +vuelven a leer antes de cada diálogo, y una tarjeta que se declara bloqueada no +abre el diálogo: termina con el mensaje de PIN bloqueado (ADR-0047). La +terminal de la línea de órdenes da los mismos avisos antes de pedir el PIN. + +**El número solo sale cuando el origen lo da** (ADR-0047). La información de +token de PKCS#11 no trae los intentos restantes, ni con una tarjeta real: solo +las dos señales de arriba, que no enseñan ningún número, y el Almacén de +Windows no deja leer nada. Un número calculado a partir de lo que rFirma ha +visto sería falso en cuanto la tarjeta se usara con otro programa, así que +fuera del DNIe por rdnie no se da. + +Tampoco cruza: `SecretView` no lleva el número, y el ciclo vuelve a pedir el secreto hasta que se acierta, se cancela o la tarjeta se bloquea. Un secreto rechazado no se reenvía nunca: cada intento es un tecleo nuevo. diff --git a/docs/design/panel-de-firma.md b/docs/design/panel-de-firma.md index 2bd42536a..cee3b4bcc 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`, `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 | +| 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 en Linux; en macOS, solo el mensaje genérico. 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/packaging/flatpak/sources.lock b/packaging/flatpak/sources.lock index 7a4b0b52a..de01e3bdd 100644 --- a/packaging/flatpak/sources.lock +++ b/packaging/flatpak/sources.lock @@ -1 +1 @@ -ceb2c8bd61e3bee5d942efee22001fbf6e2c4f853cc8ff2567b47f5ba216ae7e rfirma-app/src-tauri/Cargo.lock +5e12a3d773853fbb1a645685ca06c1b757007d1c3509837c70650271fdd86cb3 rfirma-app/src-tauri/Cargo.lock diff --git a/packaging/repo/site/src/content/docs/manual/firmar-un-pdf.md b/packaging/repo/site/src/content/docs/manual/firmar-un-pdf.md index 07c35f4bc..f624284c5 100644 --- a/packaging/repo/site/src/content/docs/manual/firmar-un-pdf.md +++ b/packaging/repo/site/src/content/docs/manual/firmar-un-pdf.md @@ -82,11 +82,29 @@ no sale del almacén. - Con un `.p12`, la contraseña se pide al pulsar **Firmar**. - Si el almacén no necesita sesión, no se pide nada. - Si te equivocas, el campo se marca como erróneo y la ventana vuelve a pedirlo. - rFirma no cuenta los intentos que quedan: no los conoce. + Solo con el DNIe en macOS rFirma sabe cuántos intentos quedan y te lo dice antes de + que escribas el PIN. -rFirma no admite todavía las tarjetas criptográficas, entre ellas el DNIe: hoy se firma -con los certificados de los almacenes del sistema, los de los navegadores, los de un -módulo PKCS#11 que ya tengas configurado y los `.p12`. +### El DNIe en macOS + +En macOS, rFirma lee el DNIe 3.0 y 4.0 **por contacto**, en un lector de tarjetas, +sin instalar ningún controlador ni programa más. Al meter el DNIe, sus certificados +aparecen en la lista. No funciona por NFC ni con el DNIe 1.0 o 2.0. + +El diálogo del PIN dice cuántos intentos te quedan. Si no te queda ninguno, rFirma no +pide el PIN y avisa de que el DNIe está bloqueado: se desbloquea en un punto de +actualización del DNIe. + +### Otras tarjetas en macOS + +Para firmar con otra tarjeta, instala el módulo PKCS#11 de su fabricante y nómbralo +en la variable de entorno `RFIRMA_PKCS11_MODULE`, con la ruta completa del módulo: + +```bash +RFIRMA_PKCS11_MODULE=/ruta/al/modulo-pkcs11.dylib open -a rfirma +``` + +Sin ella, rFirma solo avisa de que no puede leer esa tarjeta. ## 5. Firmar y encontrar el documento diff --git a/packaging/repo/site/src/i18n/ca.ts b/packaging/repo/site/src/i18n/ca.ts index f9333855b..3ad3f0a0c 100644 --- a/packaging/repo/site/src/i18n/ca.ts +++ b/packaging/repo/site/src/i18n/ca.ts @@ -103,7 +103,7 @@ export const ca: Dictionary = { "comparison.pin.rfirma": "Diàleg del sistema i memòria protegida", "comparison.dnie.label": "Signatura amb DNIe", "comparison.dnie.autofirma": "Sí, amb jMulticard", - "comparison.dnie.rfirma": "Sí, el detecta en inserir-lo", + "comparison.dnie.rfirma": "Sí, el detecta en inserir-lo, també a macOS", "comparison.store.label": "Certificats .p12", "comparison.store.autofirma": "Tries el fitxer en un diàleg", "comparison.store.rfirma": "Magatzem xifrat propi", diff --git a/packaging/repo/site/src/i18n/en.ts b/packaging/repo/site/src/i18n/en.ts index bfc8ff7e4..ccab11f1e 100644 --- a/packaging/repo/site/src/i18n/en.ts +++ b/packaging/repo/site/src/i18n/en.ts @@ -104,7 +104,7 @@ export const en: Dictionary = { "comparison.pin.rfirma": "Native dialogue and protected memory", "comparison.dnie.label": "Signing with your DNIe", "comparison.dnie.autofirma": "Yes, with jMulticard", - "comparison.dnie.rfirma": "Yes, detected as soon as you insert it", + "comparison.dnie.rfirma": "Yes, detected as soon as you insert it, on macOS too", "comparison.store.label": ".p12 certificates", "comparison.store.autofirma": "You pick the file in a dialogue", "comparison.store.rfirma": "Its own encrypted store", diff --git a/packaging/repo/site/src/i18n/es.ts b/packaging/repo/site/src/i18n/es.ts index 4094ddf32..a55ed49cc 100644 --- a/packaging/repo/site/src/i18n/es.ts +++ b/packaging/repo/site/src/i18n/es.ts @@ -102,7 +102,7 @@ export const es = { "comparison.pin.rfirma": "Diálogo nativo y memoria protegida", "comparison.dnie.label": "Firma con DNIe", "comparison.dnie.autofirma": "Sí, con jMulticard", - "comparison.dnie.rfirma": "Sí, con autodetección en caliente", + "comparison.dnie.rfirma": "Sí, con autodetección en caliente, también en macOS", "comparison.store.label": "Certificados .p12", "comparison.store.autofirma": "Eliges el fichero en un diálogo", "comparison.store.rfirma": "Almacén cifrado propio", diff --git a/packaging/repo/site/src/i18n/eu.ts b/packaging/repo/site/src/i18n/eu.ts index 235efea07..ef4abd0dc 100644 --- a/packaging/repo/site/src/i18n/eu.ts +++ b/packaging/repo/site/src/i18n/eu.ts @@ -105,7 +105,7 @@ export const eu: Dictionary = { "comparison.pin.rfirma": "Sistemaren leihoa eta memoria babestua", "comparison.dnie.label": "DNIe-arekin sinatzea", "comparison.dnie.autofirma": "Bai, jMulticard bidez", - "comparison.dnie.rfirma": "Bai, txartela sartu bezain laster antzematen du", + "comparison.dnie.rfirma": "Bai, txartela sartu bezain laster antzematen du, macOS-en ere", "comparison.store.label": ".p12 ziurtagiriak", "comparison.store.autofirma": "Fitxategia leiho batean aukeratzen duzu", "comparison.store.rfirma": "Biltegi zifratu propioa", diff --git a/packaging/repo/site/src/i18n/gl.ts b/packaging/repo/site/src/i18n/gl.ts index d85ccbe91..c307f1e6c 100644 --- a/packaging/repo/site/src/i18n/gl.ts +++ b/packaging/repo/site/src/i18n/gl.ts @@ -103,7 +103,7 @@ export const gl: Dictionary = { "comparison.pin.rfirma": "Diálogo nativo e memoria protexida", "comparison.dnie.label": "Sinatura co DNIe", "comparison.dnie.autofirma": "Si, con jMulticard", - "comparison.dnie.rfirma": "Si, con detección automática en quente", + "comparison.dnie.rfirma": "Si, con detección automática en quente, tamén en macOS", "comparison.store.label": "Certificados en ficheiro .p12", "comparison.store.autofirma": "Eliges o ficheiro nun diálogo", "comparison.store.rfirma": "Almacén propio e cifrado", diff --git a/rfirma-app/po/ca.po b/rfirma-app/po/ca.po index 4ce9c35dd..b51258b4d 100644 --- a/rfirma-app/po/ca.po +++ b/rfirma-app/po/ca.po @@ -533,9 +533,6 @@ 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 cb91dc21a..b045a3f83 100644 --- a/rfirma-app/po/en.po +++ b/rfirma-app/po/en.po @@ -533,9 +533,6 @@ 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 67ecdc03b..887a98eab 100644 --- a/rfirma-app/po/es.po +++ b/rfirma-app/po/es.po @@ -533,9 +533,6 @@ 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 b7d80fb74..00a4e1885 100644 --- a/rfirma-app/po/eu.po +++ b/rfirma-app/po/eu.po @@ -534,9 +534,6 @@ 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 eab79cbbd..a080228ce 100644 --- a/rfirma-app/po/gl.po +++ b/rfirma-app/po/gl.po @@ -531,9 +531,6 @@ 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 196de416b..934330c37 100644 --- a/rfirma-app/po/messages.pot +++ b/rfirma-app/po/messages.pot @@ -521,9 +521,6 @@ 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-tauri/Cargo.lock b/rfirma-app/src-tauri/Cargo.lock index aba1afccc..3288cac14 100644 --- a/rfirma-app/src-tauri/Cargo.lock +++ b/rfirma-app/src-tauri/Cargo.lock @@ -544,6 +544,15 @@ dependencies = [ "cipher 0.4.4", ] +[[package]] +name = "cbc" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2dc9ee5f88d11e0beb842c88b33c8a5cf0d1329c4b19494af42b07dbfe8896" +dependencies = [ + "cipher 0.5.2", +] + [[package]] name = "cc" version = "1.4.4" @@ -648,6 +657,12 @@ dependencies = [ "inout 0.2.2", ] +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + [[package]] name = "combine" version = "4.6.8" @@ -911,6 +926,15 @@ version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "914a755b7c2d4af2bdcff7ce1739e2db9a1b81a9b07123d8015786ae03c0980d" +[[package]] +name = "ctutils" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03bb0e1cc970d482d121d9a1744999169b69a07470b3d644a7894e53fcaf4574" +dependencies = [ + "cmov", +] + [[package]] name = "darling" version = "0.23.0" @@ -1034,6 +1058,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ "const-oid 0.9.6", + "der_derive 0.7.3", + "flagset", "pem-rfc7468 0.7.0", "zeroize", ] @@ -1045,12 +1071,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a69dedd701da44b0536442edf09c81a64b0ab97a7a4a5e3d1971f00027cbc63d" dependencies = [ "const-oid 0.10.2", - "der_derive", + "der_derive 0.8.0", "flagset", "pem-rfc7468 1.0.0", "zeroize", ] +[[package]] +name = "der_derive" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "der_derive" version = "0.8.0" @@ -1123,6 +1160,7 @@ dependencies = [ "block-buffer 0.12.1", "const-oid 0.10.2", "crypto-common 0.2.2", + "ctutils", ] [[package]] @@ -3300,7 +3338,7 @@ checksum = "e3299dd401feaf1d45afd8fd1c0586f10fcfb22f244bb9afa942cec73503b89d" dependencies = [ "aes", "ashpd", - "cbc", + "cbc 0.1.2", "cipher 0.4.4", "digest 0.10.7", "endi", @@ -3314,7 +3352,7 @@ dependencies = [ "pbkdf2", "rand 0.9.5", "serde", - "sha2", + "sha2 0.10.9", "subtle", "tokio", "zbus", @@ -4020,6 +4058,44 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" +[[package]] +name = "rdnie" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c5efd3ee9067fe46a85544069d10881bf1222e8d6ad3620b0cf178a817a95bd" +dependencies = [ + "cbc 0.2.1", + "der 0.7.10", + "des", + "flate2", + "pcsc", + "rand_core 0.6.4", + "retail-mac", + "rsa", + "sha1 0.11.0", + "sha2 0.11.1", + "thiserror 2.0.20", + "x509-cert 0.2.5", + "zeroize", +] + +[[package]] +name = "rdnie-emulator" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7167ae9868b70aa884c95922432539176d19804f41edd88ffbef2760986fcb4d" +dependencies = [ + "cbc 0.2.1", + "des", + "flate2", + "rand_core 0.6.4", + "rdnie", + "retail-mac", + "rsa", + "sha1 0.11.0", + "x509-cert 0.2.5", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -4148,6 +4224,16 @@ dependencies = [ "web-sys", ] +[[package]] +name = "retail-mac" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "398138ca42c8d308207c1beed9473447be86805b72efd1f7e56b5c196664e64b" +dependencies = [ + "cipher 0.5.2", + "digest 0.11.3", +] + [[package]] name = "rfd" version = "0.16.0" @@ -4205,13 +4291,15 @@ dependencies = [ "pcsc", "quick-xml", "ratatui", + "rdnie", + "rdnie-emulator", "reqwest", "rsa", "security-framework", "security-framework-sys", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "sys-locale", "tauri", "tauri-build", @@ -4226,7 +4314,7 @@ dependencies = [ "tokio-tungstenite", "url", "windows-sys 0.61.2", - "x509-cert", + "x509-cert 0.3.0", "zeroize", "zip", ] @@ -4259,6 +4347,7 @@ dependencies = [ "pkcs1", "pkcs8", "rand_core 0.6.4", + "sha2 0.10.9", "signature", "spki 0.7.3", "subtle", @@ -4646,6 +4735,17 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + [[package]] name = "sha1" version = "0.11.0" @@ -4668,6 +4768,17 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "sha2" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", +] + [[package]] name = "shlex" version = "2.0.1" @@ -5134,7 +5245,7 @@ dependencies = [ "semver", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "syn 2.0.119", "tauri-utils", "thiserror 2.0.20", @@ -5813,7 +5924,7 @@ dependencies = [ "log", "native-tls", "rand 0.10.2", - "sha1", + "sha1 0.11.0", "thiserror 2.0.20", ] @@ -6752,7 +6863,7 @@ dependencies = [ "once_cell", "percent-encoding", "raw-window-handle", - "sha2", + "sha2 0.10.9", "soup3", "tao-macros", "thiserror 2.0.20", @@ -6787,6 +6898,20 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "x509-cert" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1301e935010a701ae5f8655edc0ad17c44bad3ac5ce8c39185f75453b720ae94" +dependencies = [ + "const-oid 0.9.6", + "der 0.7.10", + "sha1 0.10.7", + "signature", + "spki 0.7.3", + "tls_codec", +] + [[package]] name = "x509-cert" version = "0.3.0" diff --git a/rfirma-app/src-tauri/Cargo.toml b/rfirma-app/src-tauri/Cargo.toml index 73f7f9d1d..e9c7a57e7 100644 --- a/rfirma-app/src-tauri/Cargo.toml +++ b/rfirma-app/src-tauri/Cargo.toml @@ -228,7 +228,7 @@ 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", "libc", "NSAlert", "NSApplication", "NSButton", "NSControl", "NSResponder", "NSRunningApplication", "NSSecureTextField", "NSTextField", "NSView", "NSWindow", "NSWorkspace"] } +objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "block2", "libc", "NSAlert", "NSApplication", "NSButton", "NSControl", "NSFont", "NSLayoutConstraint", "NSResponder", "NSRunningApplication", "NSSecureTextField", "NSTextField", "NSView", "NSWindow", "NSWorkspace", "objc2-core-foundation"] } objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSArray", "NSError", "NSFileManager", "NSGeometry", "NSString", "NSURL"] } # El Almacén de macOS: las identidades del llavero por Security.framework (ADR-0040). security-framework = "3.7" @@ -236,6 +236,13 @@ 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"] } +# El DNIe por PC/SC, sin controlador del sistema; versión fija (ADR-0040). +rdnie = "=0.1.1" + +[target.'cfg(target_os = "macos")'.dev-dependencies] +# El DNIe emulado de las pruebas, que activa la feature `emulator` de rdnie; `faults` queda fuera de +# semver, así que la versión es exacta y se revisa al actualizarla. +rdnie-emulator = { version = "=0.1.0", features = ["faults"] } [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/build.rs b/rfirma-app/src-tauri/build.rs index 2b286f61f..c88936595 100644 --- a/rfirma-app/src-tauri/build.rs +++ b/rfirma-app/src-tauri/build.rs @@ -10,6 +10,7 @@ const PO_LANGUAGES: [&str; 5] = ["es", "ca", "eu", "gl", "en"]; fn main() { write_the_catalog(); record_the_commit(); + record_the_rdnie_version(); let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); link_libxslt(&target_os); if target_os != "windows" { @@ -111,3 +112,19 @@ fn record_the_commit() { println!("cargo:rustc-env=RFIRMA_GIT_COMMIT={commit}"); } } + +fn record_the_rdnie_version() { + let lock = crate_dir().join("Cargo.lock"); + println!("cargo:rerun-if-changed={}", lock.display()); + let text = std::fs::read_to_string(lock).unwrap_or_default(); + let version = text + .split("[[package]]") + .find(|package| package.contains("\nname = \"rdnie\"\n")) + .and_then(|package| { + package + .lines() + .find_map(|line| line.strip_prefix("version = ")) + }) + .map_or("desconocida", |quoted| quoted.trim_matches('"')); + println!("cargo:rustc-env=RFIRMA_RDNIE_VERSION={version}"); +} 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 59254c2c1..f46ef2dc2 100644 --- a/rfirma-app/src-tauri/src/desktop/adapters/debug_info.rs +++ b/rfirma-app/src-tauri/src/desktop/adapters/debug_info.rs @@ -285,6 +285,8 @@ 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(), + dnie: identity_adapters::this_dnie(), + rdnie_version: identity_adapters::RDNIE_VERSION.to_owned(), }) } diff --git a/rfirma-app/src-tauri/src/desktop/adapters/terminal.rs b/rfirma-app/src-tauri/src/desktop/adapters/terminal.rs index c38c8c1db..26b5cbc5e 100644 --- a/rfirma-app/src-tauri/src/desktop/adapters/terminal.rs +++ b/rfirma-app/src-tauri/src/desktop/adapters/terminal.rs @@ -20,7 +20,7 @@ use crate::desktop::ports::{ use crate::documents::domain::document::Document; use crate::identity::adapters::failures::situation_name; use crate::identity::adapters::pkcs11::stores::discovered_module_named; -use crate::identity::adapters::{desktop_stores, DesktopToken}; +use crate::identity::adapters::{desktop_stores, desktop_token}; use crate::identity::domain::certificate::{CertificateRef, TokenCertificate}; use crate::identity::domain::error::{Situation, TokenError}; use crate::identity::domain::holder::prompted_holder_of; @@ -64,7 +64,7 @@ impl SeenStores { impl CertificateStores for SeenStores { fn certificates(&self) -> Result, TokenError> { - DesktopToken.list_across(&self.stores) + desktop_token().list_across(&self.stores) } fn discovered_module(&self, library: &str) -> Option { @@ -118,11 +118,19 @@ fn prompt_for(asked: &AskedSecret<'_>) -> String { "" }; let warning = match asked.warning { - PinWarning::Quiet => "", - PinWarning::CountLow => "rfirma: ya ha habido algún intento fallido con esta tarjeta.\n", + PinWarning::Quiet => String::new(), + PinWarning::CountLow => { + "rfirma: ya ha habido algún intento fallido con esta tarjeta.\n".to_owned() + } PinWarning::FinalTry => { "rfirma: último intento: si el PIN no es correcto, la tarjeta se bloqueará.\n" + .to_owned() + } + PinWarning::AttemptsLeft(1) => { + "rfirma: te queda 1 intento: si el PIN no es correcto, la tarjeta se bloqueará.\n" + .to_owned() } + PinWarning::AttemptsLeft(left) => format!("rfirma: te quedan {left} intentos.\n"), }; format!("{again}{warning}{name} de «{}»: ", asked.alias) } @@ -215,6 +223,7 @@ impl RootsSigner<'_> { loop { asked.warning = signer .pin_warning(reference) + .and_then(PinWarning::unless_locked) .map_err(|error| Failure::from(error).detail)?; let typed = request.terminal.secret(&asked)?; let Err(failure) = self.signing.sign_on_token(signer, &typed) else { diff --git a/rfirma-app/src-tauri/src/desktop/adapters/terminal/tests.rs b/rfirma-app/src-tauri/src/desktop/adapters/terminal/tests.rs index 62f87ed33..9d8377fa3 100644 --- a/rfirma-app/src-tauri/src/desktop/adapters/terminal/tests.rs +++ b/rfirma-app/src-tauri/src/desktop/adapters/terminal/tests.rs @@ -37,6 +37,19 @@ fn the_terminal_warns_softly_of_earlier_failed_tries() { ); } +#[test] +fn the_terminal_says_the_exact_attempts_left_before_asking_for_the_pin() { + assert_eq!( + prompt_for(&asked(PinWarning::AttemptsLeft(3))), + "rfirma: te quedan 3 intentos.\nPIN de «CertFirmaDigital»: " + ); + assert_eq!( + prompt_for(&asked(PinWarning::AttemptsLeft(1))), + "rfirma: te queda 1 intento: si el PIN no es correcto, la tarjeta se bloqueará.\n\ + PIN de «CertFirmaDigital»: " + ); +} + #[test] fn after_a_wrong_pin_the_terminal_says_so_before_the_warning() { let mut retry = asked(PinWarning::FinalTry); 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 bd7b5a8fb..a061239e0 100644 --- a/rfirma-app/src-tauri/src/desktop/application/debug_report.rs +++ b/rfirma-app/src-tauri/src/desktop/application/debug_report.rs @@ -5,8 +5,10 @@ use std::path::{Path, PathBuf}; use crate::identity::domain::browsers::NssOwner; use crate::identity::domain::readers::Reader; +pub use crate::identity::domain::seen_dnie::{DnieInTheReader, SeenDnie}; use pkcs11::module_lines; pub use pkcs11::{DiscardReason, ModuleStatus, Pkcs11Module, Pkcs11Modules}; +pub use stores::{MacosStores, WindowsStores}; /// Qué se sabe de la librería nativa tras intentar cargarla de verdad. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -75,24 +77,6 @@ pub struct NssStores { pub rfirma_store_installed: bool, } -/// Lo que solo existe en Windows: el almacén del sistema y los minidrivers de tarjeta. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct WindowsStores { - /// Si el almacén raíz del usuario confía en el canal local; nunca es un perfil ignorado. - pub local_channel: NssProfileState, - /// Tipos de tarjeta que Windows tiene dados de alta. - 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 { 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 dea338786..baf8c1b84 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,28 @@ //! 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, MacosStores, NssProfileState, NssStores, ReportOwner, WindowsStores}; +use super::{section, DnieInTheReader, NssProfileState, NssStores, ReportOwner}; + +/// Lo que solo existe en Windows: el almacén del sistema y los minidrivers de tarjeta. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct WindowsStores { + /// Si el almacén raíz del usuario confía en el canal local; nunca es un perfil ignorado. + pub local_channel: NssProfileState, + /// Tipos de tarjeta que Windows tiene dados de alta. + 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, + /// Lo que rFirma ve en el lector. + pub dnie: DnieInTheReader, + /// Versión de rdnie con la que se compiló. + pub rdnie_version: String, +} pub(super) fn windows_sections(stores: Option<&WindowsStores>) -> [Option; 2] { let Some(stores) = stores else { @@ -22,9 +44,9 @@ pub(super) fn windows_sections(stores: Option<&WindowsStores>) -> [Option) -> [Option; 2] { +pub(super) fn macos_sections(stores: Option<&MacosStores>) -> [Option; 3] { let Some(stores) = stores else { - return [None, None]; + return [None, None, None]; }; let trust = format!( "Confianza del usuario: {}", @@ -42,9 +64,25 @@ pub(super) fn macos_sections(stores: Option<&MacosStores>) -> [Option; 2 [ section("Almacén de macOS", &[trust]), section("Controladores de tarjeta", &drivers), + section("DNIe", &dnie_lines(stores)), ] } +fn dnie_lines(stores: &MacosStores) -> Vec { + let card = match &stores.dnie { + DnieInTheReader::Seen(dnie) => vec![ + format!("ATR: {}", dnie.atr), + format!("versión: {}", dnie.version), + format!("juego de claves: {}", dnie.key_set), + ], + DnieInTheReader::Absent => vec!["tarjeta: no hay DNIe".to_owned()], + DnieInTheReader::Unopened(error) => vec![format!("tarjeta: rdnie no la abre · {error}")], + }; + card.into_iter() + .chain(std::iter::once(format!("rdnie: {}", stores.rdnie_version))) + .collect() +} + 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 eb7a72f3e..9db6bd3eb 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 @@ -150,6 +150,8 @@ fn report_with_macos(trust: NssProfileState, drivers: Vec<&str>) -> String { report.macos_stores = Some(MacosStores { local_channel: trust, card_drivers: drivers.into_iter().map(str::to_owned).collect(), + dnie: DnieInTheReader::Absent, + rdnie_version: "0.1.1".to_owned(), }); debug_report_text(&report, &ana()) } @@ -164,13 +166,13 @@ fn macos_replaces_the_nss_stores_with_the_macos_store_and_card_drivers() { ); assert!( - text.ends_with( + text.contains( "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" + ifd-gempc: instalado\n" ), "{text}" ); @@ -192,7 +194,59 @@ 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.contains("Controladores de tarjeta\n tarjetas: ninguno instalado\n"), + "{text}" + ); +} + +fn report_with_dnie(dnie: DnieInTheReader) -> String { + let mut report = a_flatpak_report(); + report.pcsc = None; + report.macos_stores = Some(MacosStores { + local_channel: NssProfileState::DoesNotTrustLocalChannel, + card_drivers: Vec::new(), + dnie, + rdnie_version: "0.1.1".to_owned(), + }); + debug_report_text(&report, &ana()) +} + +#[test] +fn macos_says_the_dnie_it_sees_with_nothing_of_the_holder() { + let text = report_with_dnie(DnieInTheReader::Seen(SeenDnie { + atr: "3B7F96000000".to_owned(), + version: "4.0".to_owned(), + key_set: "segundo".to_owned(), + })); + + assert!( + text.ends_with( + "DNIe\n \ + ATR: 3B7F96000000\n \ + versión: 4.0\n \ + juego de claves: segundo\n \ + rdnie: 0.1.1" + ), + "{text}" + ); +} + +#[test] +fn macos_without_a_dnie_says_none_and_still_the_rdnie_version() { + let text = report_with_dnie(DnieInTheReader::Absent); + + assert!( + text.ends_with("DNIe\n tarjeta: no hay DNIe\n rdnie: 0.1.1"), + "{text}" + ); +} + +#[test] +fn macos_says_the_rdnie_error_of_a_card_it_cannot_open() { + let text = report_with_dnie(DnieInTheReader::Unopened("card answered 6A82".to_owned())); + + assert!( + text.ends_with("DNIe\n tarjeta: rdnie no la abre · card answered 6A82\n rdnie: 0.1.1"), "{text}" ); } 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 e44cb4596..753a3128d 100644 --- a/rfirma-app/src-tauri/src/identity/adapters/macos_store.rs +++ b/rfirma-app/src-tauri/src/identity/adapters/macos_store.rs @@ -1,9 +1,13 @@ //! `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 dnie; mod installs; mod keychain; use std::path::Path; +use std::sync::Arc; + +pub use dnie::{dnie_store, seen_dnie, CardOpener, DnieToken, PcscCard, RDNIE_VERSION}; use super::pkcs11::RealToken; use crate::identity::domain::algorithm::SignatureAlgorithm; @@ -30,7 +34,7 @@ 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. +/// Los almacenes de esta máquina: el llavero y los PKCS#11 descubiertos, o solo el módulo nombrado, y el DNIe. pub fn from_environment() -> Vec { stores_besides( super::pkcs11::stores::from_environment(), @@ -39,29 +43,55 @@ pub fn from_environment() -> Vec { } fn stores_besides(pkcs11: Vec, module_named: bool) -> Vec { - if module_named { - return pkcs11; + let keychain = (!module_named).then(user_store); + keychain + .into_iter() + .chain(pkcs11) + .chain(std::iter::once(dnie_store())) + .collect() +} + +/// El adaptador de [`Token`] en macOS: el llavero para el Almacén de macOS, rdnie para el almacén del DNIe y [`RealToken`] para los demás. +#[derive(Clone)] +pub struct MacosToken { + dnie: Arc, +} + +impl Default for MacosToken { + fn default() -> Self { + Self::with_the_dnie(DnieToken::new(PcscCard)) } - std::iter::once(user_store()).chain(pkcs11).collect() } -/// 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 MacosToken { + /// El de siempre, con el DNIe que abre otro. + pub fn with_the_dnie(dnie: impl Token + Send + Sync + 'static) -> Self { + Self { + dnie: Arc::new(dnie), + } + } + + fn beyond_the_keychain(&self, store: &Store) -> &dyn Token { + if store.is_the_dnie() { + return self.dnie.as_ref(); + } + &RealToken + } +} impl Token for MacosToken { fn list(&self, store: &Store) -> Result, TokenError> { if is_a_keychain(store) { return keychain::signable_certificates(store); } - RealToken.list(store) + self.beyond_the_keychain(store).list(store) } fn every_certificate(&self, store: &Store) -> Result, TokenError> { if is_a_keychain(store) { return keychain::every_certificate(store); } - RealToken.every_certificate(store) + self.beyond_the_keychain(store).every_certificate(store) } fn list_authenticated( @@ -72,21 +102,24 @@ impl Token for MacosToken { if is_a_keychain(store) { return keychain::signable_certificates(store); } - RealToken.list_authenticated(store, pin) + self.beyond_the_keychain(store) + .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) + self.beyond_the_keychain(&reference.store()) + .secret_of(reference) } fn pin_warning(&self, reference: &CertificateRef) -> Result { if is_a_keychain(&reference.store()) { return Ok(PinWarning::Quiet); } - RealToken.pin_warning(reference) + self.beyond_the_keychain(&reference.store()) + .pin_warning(reference) } fn offers( @@ -97,7 +130,8 @@ impl Token for MacosToken { if is_a_keychain(&reference.store()) { return keychain::offers(reference, algorithm); } - RealToken.offers(reference, algorithm) + self.beyond_the_keychain(&reference.store()) + .offers(reference, algorithm) } fn accepts_the_secret( @@ -108,7 +142,8 @@ impl Token for MacosToken { if is_a_keychain(&reference.store()) { return Ok(()); } - RealToken.accepts_the_secret(reference, secret) + self.beyond_the_keychain(&reference.store()) + .accepts_the_secret(reference, secret) } fn sign_with_secret( @@ -121,15 +156,18 @@ impl Token for MacosToken { if is_a_keychain(&reference.store()) { return keychain::sign(reference, algorithm, data); } - RealToken.sign_with_secret(reference, secret, algorithm, data) + self.beyond_the_keychain(&reference.store()) + .sign_with_secret(reference, secret, algorithm, data) } fn hold_one_login(&self, reference: &CertificateRef) { - RealToken.hold_one_login(reference); + self.beyond_the_keychain(&reference.store()) + .hold_one_login(reference); } fn release_the_login(&self, reference: &CertificateRef) { - RealToken.release_the_login(reference); + self.beyond_the_keychain(&reference.store()) + .release_the_login(reference); } fn installs_in_the_system(&self) -> bool { diff --git a/rfirma-app/src-tauri/src/identity/adapters/macos_store/dnie.rs b/rfirma-app/src-tauri/src/identity/adapters/macos_store/dnie.rs new file mode 100644 index 000000000..96e2d0d41 --- /dev/null +++ b/rfirma-app/src-tauri/src/identity/adapters/macos_store/dnie.rs @@ -0,0 +1,381 @@ +//! `DnieToken`, el DNIe leído con rdnie detrás del puerto `Token`: lista sin PIN, da los intentos restantes y firma el resumen, abriendo la tarjeta en cada operación y nunca entre ellas (ADR-0040, ADR-0047). + +use std::path::Path; +use std::sync::{Mutex, MutexGuard}; + +use rdnie::{HashAlgorithm, PcscTransport, Pin, Session, Transport, INTERMEDIATE_CA_LABEL}; + +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::protected_secret::ProtectedSecret; +use crate::identity::domain::secret::{PinWarning, StoreSecret}; +use crate::identity::domain::seen_dnie::{DnieInTheReader, SeenDnie}; +use crate::identity::domain::store::{Store, DNIE_STORE_PREFIX}; +use crate::identity::ports::Token; + +const TOKEN_LABEL: &str = "DNIe"; +const PIN_CHECK: &[u8] = b"rfirma: comprobacion del PIN"; + +/// El almacén del DNIe: de clase tarjeta, sin módulo PKCS#11 detrás. +pub fn dnie_store() -> Store { + Store::module(format!("{DNIE_STORE_PREFIX}pcsc")) +} + +/// Quien abre la tarjeta para cada operación: el lector en producción, el emulador en las pruebas. +pub trait CardOpener: Send + Sync { + /// Por dónde viajan las órdenes a la tarjeta. + type Card: Transport; + + /// Una sesión nueva con el DNIe. + fn open(&self) -> Result, rdnie::Error>; +} + +/// El primer lector PC/SC con un DNIe, con la sesión normal de rdnie. +#[derive(Clone, Copy, Debug, Default)] +pub struct PcscCard; + +impl CardOpener for PcscCard { + type Card = PcscTransport; + + fn open(&self) -> Result, rdnie::Error> { + Session::open(PcscTransport::connect_first_dnie()?) + } +} + +/// El adaptador de [`Token`] para el almacén del DNIe. +#[derive(Debug, Default)] +pub struct DnieToken { + opener: O, + login: Mutex, +} + +/// Lo que el adaptador recuerda entre llamadas, sin retener la tarjeta. +#[derive(Debug, Default)] +struct Login { + shown_attempts: Option, + depth: usize, + cut: Option, +} + +impl DnieToken { + /// El DNIe que abre `opener`. + pub fn new(opener: O) -> Self { + Self { + opener, + login: Mutex::new(Login::default()), + } + } + + fn login(&self) -> MutexGuard<'_, Login> { + self.login + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + } + + fn session(&self) -> Result, TokenError> { + self.opener.open().map_err(|error| token_error(&error)) + } + + fn attempts_left(&self) -> Result { + match self.session()?.pin_retries() { + Err(rdnie::Error::PinBlocked) => Ok(0), + read => read.map_err(|error| token_error(&error)), + } + } + + /// rdnie no expone un VERIFY suelto: el PIN se comprueba con una firma que nunca sale de aquí (ADR-0047). + fn verified_by_a_discarded_signature( + &self, + reference: &CertificateRef, + secret: &ProtectedSecret, + shown: u8, + ) -> Result<(), TokenError> { + self.signed( + reference, + secret, + SignatureAlgorithm::Sha256Rsa, + PIN_CHECK, + Some(shown), + ) + .map(|_| ()) + } + + fn signed( + &self, + reference: &CertificateRef, + secret: &ProtectedSecret, + algorithm: SignatureAlgorithm, + data: &[u8], + shown: Option, + ) -> Result, TokenError> { + let hash = hash_of(algorithm)?; + let digest = ecdsa::digest(algorithm, data)?; + let pin = pin_of(secret)?; + let mut session = self.session()?; + let key = session + .certificates() + .map_err(|error| token_error(&error))? + .into_iter() + .find(|certificate| certificate.label == reference.label()) + .ok_or_else(|| { + TokenError::new( + Situation::CertificateNotFound, + format!("el DNIe no tiene la clave de {}", reference.label()), + ) + })? + .key; + let left = session.pin_retries().map_err(|error| token_error(&error))?; + unchanged_since_shown(left, shown)?; + session + .sign(&digest, hash, &key, pin) + .map_err(|error| token_error(&error)) + } + + fn read( + &self, + store: &Store, + with_the_intermediate: bool, + ) -> Result, TokenError> { + let mut session = match self.opener.open() { + Ok(session) => session, + Err(error) if is_no_readable_dnie(&error) => return Ok(Vec::new()), + Err(error) => return Err(token_error(&error)), + }; + let mut found: Vec = session + .certificates() + .map_err(|error| token_error(&error))? + .into_iter() + .map(|certificate| in_the_store(store, &certificate.label, certificate.der)) + .collect(); + if with_the_intermediate { + let intermediate = session + .certificate(INTERMEDIATE_CA_LABEL) + .map_err(|error| token_error(&error))?; + found.extend(intermediate.map(|der| in_the_store(store, INTERMEDIATE_CA_LABEL, der))); + } + Ok(found) + } +} + +/// La versión de rdnie que fija `Cargo.lock`. +pub const RDNIE_VERSION: &str = env!("RFIRMA_RDNIE_VERSION"); + +const DNIE_4_CHIP: [u8; 2] = [0x4C, 0x34]; +const CHIP_TYPE: std::ops::Range = 13..15; + +/// Lo que hay en el lector de `opener`: el DNIe que se ve, ninguno, o el error de rdnie al abrirlo. +pub fn seen_dnie(opener: &O) -> DnieInTheReader { + match described(opener) { + Ok(seen) => DnieInTheReader::Seen(seen), + Err(rdnie::Error::NoReader | rdnie::Error::NoCard | rdnie::Error::NotDnie) => { + DnieInTheReader::Absent + } + Err(error) => DnieInTheReader::Unopened(error.to_string()), + } +} + +fn described(opener: &O) -> Result { + let session = opener.open()?; + let key_set = match session.key_generation() { + rdnie::KeyGeneration::First => "primero", + _ => "segundo", + }; + let atr = session.into_transport().reset()?; + let version = if atr.get(CHIP_TYPE) == Some(&DNIE_4_CHIP[..]) { + "4.0" + } else { + "3.0" + }; + Ok(SeenDnie { + atr: atr.iter().map(|byte| format!("{byte:02X}")).collect(), + version: version.to_owned(), + key_set: key_set.to_owned(), + }) +} + +fn in_the_store(store: &Store, label: &str, der: Vec) -> TokenCertificate { + TokenCertificate::new(CertificateRef::new(store, TOKEN_LABEL, label, None), der) +} + +fn is_no_readable_dnie(error: &rdnie::Error) -> bool { + matches!( + error, + rdnie::Error::NoReader + | rdnie::Error::NoCard + | rdnie::Error::NotDnie + | rdnie::Error::LegacyDnie + | rdnie::Error::BurnedDnie + ) +} + +fn token_error(error: &rdnie::Error) -> TokenError { + let situation = match error { + rdnie::Error::WrongPin { retries_left } => { + return TokenError::new(Situation::IncorrectPin, error.to_string()) + .with_attempts_left(u32::from(*retries_left)); + } + rdnie::Error::InvalidPinFormat => Situation::IncorrectPin, + rdnie::Error::PinBlocked => Situation::PinLocked, + rdnie::Error::CardRemoved + | rdnie::Error::CardReset + | rdnie::Error::NoCard + | rdnie::Error::NoReader => Situation::TokenAbsent, + _ => Situation::Unknown, + }; + TokenError::new(situation, error.to_string()) +} + +fn pin_of(secret: &ProtectedSecret) -> Result { + secret + .expose_secret() + .map(|pin| Pin::new(pin.to_owned())) + .map_err(|_| TokenError::new(Situation::IncorrectPin, "el PIN no es texto")) +} + +fn hash_of(algorithm: SignatureAlgorithm) -> Result { + match algorithm { + SignatureAlgorithm::Sha256Rsa => Ok(HashAlgorithm::Sha256), + SignatureAlgorithm::Sha384Rsa => Ok(HashAlgorithm::Sha384), + SignatureAlgorithm::Sha512Rsa => Ok(HashAlgorithm::Sha512), + other => Err(TokenError::new( + Situation::MechanismNotOffered, + format!("el DNIe por rdnie no firma con {}", other.name()), + )), + } +} + +fn unchanged_since_shown(left: u8, shown: Option) -> Result<(), TokenError> { + match shown { + Some(shown) if shown != left => Err(TokenError::new( + Situation::Unknown, + format!("los intentos han pasado de {shown} a {left} tras leerlos: el PIN no se envia"), + )), + _ => Ok(()), + } +} + +fn has_a_length_the_dnie_admits(secret: &ProtectedSecret) -> bool { + secret + .expose_secret() + .is_ok_and(|pin| (8..=16).contains(&pin.encode_utf16().count())) +} + +impl Token for DnieToken { + fn list(&self, store: &Store) -> Result, TokenError> { + self.read(store, false) + } + + fn every_certificate(&self, store: &Store) -> Result, TokenError> { + self.read(store, true) + } + + fn list_authenticated( + &self, + store: &Store, + _pin: &ProtectedSecret, + ) -> Result, TokenError> { + self.list(store) + } + + fn secret_of(&self, _reference: &CertificateRef) -> Result { + Ok(StoreSecret::TypedOnScreen) + } + + fn pin_warning(&self, _reference: &CertificateRef) -> Result { + let left = self.attempts_left()?; + self.login().shown_attempts = Some(left); + Ok(PinWarning::AttemptsLeft(u32::from(left))) + } + + fn offers( + &self, + _reference: &CertificateRef, + algorithm: SignatureAlgorithm, + ) -> Result<(), TokenError> { + hash_of(algorithm).map(|_| ()) + } + + fn accepts_the_secret( + &self, + reference: &CertificateRef, + secret: &ProtectedSecret, + ) -> Result<(), TokenError> { + if let Some(cut) = self.login().cut.clone() { + return Err(cut); + } + if !has_a_length_the_dnie_admits(secret) { + return Err(token_error(&rdnie::Error::InvalidPinFormat)); + } + let left = self.attempts_left()?; + let shown = self.login().shown_attempts.take(); + unchanged_since_shown(left, shown)?; + PinWarning::AttemptsLeft(u32::from(left)).unless_locked()?; + self.verified_by_a_discarded_signature(reference, secret, left) + } + + fn sign_with_secret( + &self, + reference: &CertificateRef, + secret: &ProtectedSecret, + algorithm: SignatureAlgorithm, + data: &[u8], + ) -> Result, TokenError> { + let shown = { + let mut login = self.login(); + if let Some(cut) = login.cut.clone() { + return Err(cut); + } + login.shown_attempts.take() + }; + let signed = self.signed(reference, secret, algorithm, data, shown); + if let Err(failure) = &signed { + let mut login = self.login(); + if login.depth > 0 { + login.cut = Some(failure.clone()); + } + } + signed + } + + fn hold_one_login(&self, _reference: &CertificateRef) { + self.login().depth += 1; + } + + fn release_the_login(&self, _reference: &CertificateRef) { + let mut login = self.login(); + login.depth = login.depth.saturating_sub(1); + if login.depth == 0 { + login.cut = None; + } + } + + fn import_pkcs12( + &self, + _directory: &Path, + _pkcs12: &[u8], + _password: &str, + _pin: &ProtectedSecret, + ) -> Result { + Err(TokenError::new( + Situation::Unknown, + "un .p12 no se instala en el DNIe", + )) + } + + fn remove_certificate( + &self, + _directory: &Path, + _reference: &CertificateRef, + _pin: &ProtectedSecret, + ) -> Result<(), TokenError> { + Err(TokenError::new( + Situation::Unknown, + "del DNIe no se quita ningun certificado", + )) + } +} + +#[cfg(test)] +mod tests; diff --git a/rfirma-app/src-tauri/src/identity/adapters/macos_store/dnie/tests.rs b/rfirma-app/src-tauri/src/identity/adapters/macos_store/dnie/tests.rs new file mode 100644 index 000000000..6a220fde4 --- /dev/null +++ b/rfirma-app/src-tauri/src/identity/adapters/macos_store/dnie/tests.rs @@ -0,0 +1,349 @@ +use openssl::asn1::Asn1Time; +use openssl::bn::BigNum; +use openssl::hash::MessageDigest; +use openssl::pkey::PKey; +use openssl::rsa::Rsa; +use openssl::x509::extension::BasicConstraints; +use openssl::x509::{X509Name, X509}; +use rdnie::{Session, INTERMEDIATE_CA_LABEL, SIGNATURE_LABEL}; +use rdnie_emulator::{EmulatedDnie, HolderCertificate, Step, DNIE_3_ATR, DNIE_4_ATR}; +use rsa::traits::PublicKeyParts; + +use super::{dnie_store, seen_dnie, CardOpener, DnieToken}; +use crate::identity::adapters::macos_store::MacosToken; +use crate::identity::application::certificates::{certificates_with_their_chains, on_the_desktop}; +use crate::identity::application::tests::a_dnie_der; +use crate::identity::domain::certificate::TokenCertificate; +use crate::identity::domain::seen_dnie::DnieInTheReader; +use crate::identity::domain::store::StoreClass; +use crate::identity::ports::Token; + +const AUTHENTICATION_LABEL: &str = "CertAutenticacion"; + +/// El emulador de rdnie como tarjeta: una nueva en cada apertura, con la sesión que confía en su raíz. +struct Emulated(Box EmulatedDnie + Send + Sync>); + +impl CardOpener for Emulated { + type Card = EmulatedDnie; + + fn open(&self) -> Result, rdnie::Error> { + let card = (self.0)(); + let root = card.component_root(); + Session::open_trusting(card, root) + } +} + +fn the_dnie(card: impl Fn() -> EmulatedDnie + Send + Sync + 'static) -> DnieToken { + DnieToken::new(Emulated(Box::new(card))) +} + +type Version = (&'static str, fn() -> EmulatedDnie); + +fn the_two_versions() -> [Version; 2] { + [ + ("DNIe 3.0", EmulatedDnie::dnie_3), + ("DNIe 4.0", EmulatedDnie::dnie_4), + ] +} + +fn labels_of(found: &[TokenCertificate]) -> Vec { + let mut labels: Vec = found + .iter() + .map(|certificate| certificate.reference().label().to_owned()) + .collect(); + labels.sort(); + labels +} + +fn modulus_of(der: &[u8]) -> Vec { + X509::from_der(der) + .and_then(|certificate| certificate.public_key()) + .and_then(|key| key.rsa()) + .expect("el certificado deberia traer una clave RSA") + .n() + .to_vec() +} + +fn listed_by(token: &dyn Token) -> Vec { + token.list(&dnie_store()).expect("el DNIe deberia listarse") +} + +#[test] +fn the_dnie_store_is_a_card() { + assert_eq!(dnie_store().class(), StoreClass::Card); +} + +#[test] +fn lists_the_signing_and_the_authentication_certificates_without_a_pin() { + for (version, card) in the_two_versions() { + let emulated = card(); + let signing_key = emulated.signing_public_key().n().to_bytes_be(); + let authentication_key = emulated.authentication_public_key().n().to_bytes_be(); + + let found = listed_by(&the_dnie(card)); + + assert_eq!( + labels_of(&found), + vec![AUTHENTICATION_LABEL, SIGNATURE_LABEL], + "{version}" + ); + let key_of = |label: &str| { + found + .iter() + .find(|certificate| certificate.reference().label() == label) + .map(|certificate| modulus_of(certificate.der())) + }; + assert_eq!(key_of(SIGNATURE_LABEL), Some(signing_key), "{version}"); + assert_eq!( + key_of(AUTHENTICATION_LABEL), + Some(authentication_key), + "{version}" + ); + assert!( + found + .iter() + .all(|certificate| certificate.reference().store() == dnie_store()), + "{version}" + ); + } +} + +#[test] +fn another_card_in_the_reader_lists_nothing() { + const ANOTHER_CARD: [u8; 11] = [ + 0x3B, 0x8F, 0x80, 0x01, 0x80, 0x4F, 0x0C, 0xA0, 0x00, 0x00, 0x03, + ]; + + let found = listed_by(&the_dnie(|| EmulatedDnie::new(&ANOTHER_CARD))); + + assert!(found.is_empty()); +} + +#[test] +fn a_card_taken_out_while_listing_lists_nothing_and_says_the_card_is_gone() { + use rdnie_emulator::Step; + + let failure = the_dnie(|| EmulatedDnie::dnie_3().removing_card_at(Step::Certificates)) + .list(&dnie_store()) + .expect_err("la tarjeta se ha ido"); + + assert_eq!( + failure.situation(), + crate::identity::domain::error::Situation::TokenAbsent + ); +} + +/// Los certificados de un DNIe de la Dirección General de la Policía, con la CA intermedia que los emite. +#[derive(Clone)] +struct OfThePolice { + authentication: Vec, + signing: Vec, + intermediate: Vec, +} + +impl OfThePolice { + fn new() -> Self { + Self { + authentication: a_dnie_der("ADA LOVELACE", false), + signing: a_dnie_der("ADA LOVELACE", true), + intermediate: the_dnie_intermediate_der(), + } + } + + fn on(&self, card: EmulatedDnie) -> EmulatedDnie { + let mut authentication = HolderCertificate::holder_authentication(1); + authentication.der = self.authentication.clone(); + let mut signing = HolderCertificate::holder_signing(2); + signing.der = self.signing.clone(); + let mut intermediate = HolderCertificate::new(3, 0) + .labelled(INTERMEDIATE_CA_LABEL) + .without_key(); + intermediate.der = self.intermediate.clone(); + card.with_certificates(&[authentication, signing, intermediate]) + } + + fn in_the(self, card: fn() -> EmulatedDnie) -> DnieToken { + the_dnie(move || self.on(card())) + } +} + +/// La CA que `a_dnie_der` nombra como emisora, emitida a su vez por una raíz. +fn the_dnie_intermediate_der() -> Vec { + let key = PKey::from_rsa(Rsa::generate(2048).expect("deberia generarse la clave")) + .expect("deberia envolverse la clave"); + let mut subject = X509Name::builder().expect("deberia construirse un nombre"); + for (field, value) in [ + ("C", "ES"), + ("O", "DIRECCION GENERAL DE LA POLICIA"), + ("OU", "DNIE"), + ("CN", "AC DNIE 004"), + ] { + subject + .append_entry_by_text(field, value) + .expect("el atributo deberia entrar"); + } + let mut issuer = X509Name::builder().expect("deberia construirse un nombre"); + issuer + .append_entry_by_text("CN", "AC RAIZ DNIE 2") + .expect("el atributo deberia entrar"); + let mut builder = X509::builder().expect("deberia construirse un certificado"); + builder.set_version(2).expect("la version deberia ponerse"); + builder + .set_serial_number( + &BigNum::from_u32(4) + .and_then(|serial| serial.to_asn1_integer()) + .expect("el serie deberia construirse"), + ) + .expect("el serie deberia ponerse"); + builder + .set_subject_name(&subject.build()) + .expect("el titular deberia ponerse"); + builder + .set_issuer_name(&issuer.build()) + .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"); + builder + .append_extension( + BasicConstraints::new() + .critical() + .ca() + .build() + .expect("deberia construirse la extension"), + ) + .expect("la extension deberia ponerse"); + builder + .sign(&key, MessageDigest::sha256()) + .expect("el certificado deberia firmarse"); + builder + .build() + .to_der() + .expect("el certificado deberia salir en DER") +} + +#[test] +fn the_intermediate_ca_completes_the_chain_of_both_certificates_for_a_site() { + for (version, card) in the_two_versions() { + let police = OfThePolice::new(); + let dnie = police.clone().in_the(card); + + let offered = certificates_with_their_chains(&dnie, &[dnie_store()]) + .expect("el DNIe deberia listarse"); + + assert_eq!( + labels_of(&offered), + vec![AUTHENTICATION_LABEL, SIGNATURE_LABEL], + "{version}" + ); + for certificate in &offered { + assert_eq!( + certificate.chain(), + vec![certificate.der().to_vec(), police.intermediate.clone()], + "{version}" + ); + } + } +} + +#[test] +fn the_signing_panel_offers_only_the_signing_certificate_of_the_dnie() { + for (version, card) in the_two_versions() { + let dnie = OfThePolice::new().in_the(card); + + let offered = on_the_desktop(listed_by(&dnie)); + + assert_eq!(labels_of(&offered), vec![SIGNATURE_LABEL], "{version}"); + assert!(offered[0].is_from_a_dnie(), "{version}"); + } +} + +#[test] +fn the_macos_token_hands_the_dnie_store_to_the_dnie() { + let token = MacosToken::with_the_dnie(the_dnie(EmulatedDnie::dnie_4)); + + assert_eq!( + labels_of(&listed_by(&token)), + vec![AUTHENTICATION_LABEL, SIGNATURE_LABEL] + ); +} + +fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|byte| format!("{byte:02X}")).collect() +} + +type SeenCase = ( + &'static str, + fn() -> EmulatedDnie, + &'static [u8], + &'static str, +); + +#[test] +fn describes_the_dnie_it_sees_by_its_atr_version_and_key_set() { + use rdnie::KeyGeneration; + + let cases: [SeenCase; 2] = [ + ("3.0", EmulatedDnie::dnie_3, &DNIE_3_ATR, "segundo"), + ("4.0", EmulatedDnie::dnie_4, &DNIE_4_ATR, "segundo"), + ]; + for (version, card, atr, key_set) in cases { + let DnieInTheReader::Seen(seen) = seen_dnie(&Emulated(Box::new(card))) else { + panic!("{version}: hay un DNIe"); + }; + + assert_eq!(seen.atr, hex(atr), "{version}"); + assert_eq!(seen.version, version); + assert_eq!(seen.key_set, key_set, "{version}"); + } + let DnieInTheReader::Seen(first) = seen_dnie(&Emulated(Box::new(|| { + EmulatedDnie::dnie_3().with_key_generation(KeyGeneration::First) + }))) else { + panic!("hay un DNIe"); + }; + assert_eq!(first.key_set, "primero"); +} + +#[test] +fn sees_no_dnie_in_another_card() { + const ANOTHER_CARD: [u8; 11] = [ + 0x3B, 0x8F, 0x80, 0x01, 0x80, 0x4F, 0x0C, 0xA0, 0x00, 0x00, 0x03, + ]; + + assert_eq!( + seen_dnie(&Emulated(Box::new(|| EmulatedDnie::new(&ANOTHER_CARD)))), + DnieInTheReader::Absent + ); +} + +#[test] +fn says_the_rdnie_error_of_a_dnie_that_fails_while_opening() { + for (version, card) in the_two_versions() { + let seen = seen_dnie(&Emulated(Box::new(move || { + card().removing_card_at(Step::Opening) + }))); + + assert_eq!( + seen, + DnieInTheReader::Unopened("the card was removed".to_owned()), + "{version}" + ); + } +} + +#[test] +fn the_rdnie_version_is_the_one_in_the_lockfile() { + assert!( + super::RDNIE_VERSION.starts_with("0."), + "{}", + super::RDNIE_VERSION + ); +} +mod failures; +mod in_the_reader; +mod prompting; +mod signing; diff --git a/rfirma-app/src-tauri/src/identity/adapters/macos_store/dnie/tests/failures.rs b/rfirma-app/src-tauri/src/identity/adapters/macos_store/dnie/tests/failures.rs new file mode 100644 index 000000000..16696e32c --- /dev/null +++ b/rfirma-app/src-tauri/src/identity/adapters/macos_store/dnie/tests/failures.rs @@ -0,0 +1,116 @@ +use rdnie_emulator::Step; + +use super::in_the_reader::{a_wrong_pin, the_pin, the_signing_certificate, InTheReader}; +use super::the_two_versions; +use crate::identity::domain::algorithm::SignatureAlgorithm; +use crate::identity::domain::error::{Situation, TokenError}; +use crate::identity::domain::protected_secret::ProtectedSecret; +use crate::identity::domain::secret::PinWarning; +use crate::identity::ports::Token; + +fn signed_by(dnie: &dyn Token, pin: &ProtectedSecret) -> Result, TokenError> { + dnie.sign_with_secret( + &the_signing_certificate(), + pin, + SignatureAlgorithm::Sha256Rsa, + b"datos", + ) +} + +#[test] +fn a_wrong_pin_is_incorrect_with_one_attempt_fewer() { + for (version, card) in the_two_versions() { + let reader = InTheReader::new(card()); + + let refused = signed_by(&reader.token(), &a_wrong_pin()).expect_err("el PIN es otro"); + + assert_eq!(refused.situation(), Situation::IncorrectPin, "{version}"); + assert_eq!(refused.attempts_left(), Some(2), "{version}"); + assert_eq!( + reader.token().pin_warning(&the_signing_certificate()), + Ok(PinWarning::AttemptsLeft(2)), + "{version}" + ); + } +} + +#[test] +fn a_wrong_pin_on_the_last_attempt_ends_as_pin_locked() { + for (version, card) in the_two_versions() { + let reader = InTheReader::new(card().with_pin_retries(1)); + + let refused = signed_by(&reader.token(), &a_wrong_pin()).expect_err("el PIN es otro"); + + assert_eq!(refused.situation(), Situation::PinLocked, "{version}"); + } +} + +#[test] +fn with_no_attempts_left_no_pin_is_sent_and_it_ends_as_pin_locked() { + for (version, card) in the_two_versions() { + let reader = InTheReader::new(card().with_pin_retries(0)); + + let refused = signed_by(&reader.token(), &the_pin()).expect_err("el PIN esta bloqueado"); + + assert_eq!(refused.situation(), Situation::PinLocked, "{version}"); + assert_eq!(reader.pins_sent(), 0, "{version}"); + } +} + +#[test] +fn a_pin_of_a_length_the_dnie_does_not_admit_is_incorrect_without_spending_an_attempt() { + for (version, card) in the_two_versions() { + for impossible in ["1234", "un PIN de diecisiete"] { + let reader = InTheReader::new(card()); + let pin = ProtectedSecret::from_str(impossible); + + let signing = signed_by(&reader.token(), &pin).expect_err("longitud imposible"); + let accepting = reader + .token() + .accepts_the_secret(&the_signing_certificate(), &pin) + .expect_err("longitud imposible"); + + assert_eq!(signing.situation(), Situation::IncorrectPin, "{version}"); + assert_eq!(accepting.situation(), Situation::IncorrectPin, "{version}"); + assert_eq!(reader.pins_sent(), 0, "{version}"); + assert_eq!( + reader.token().pin_warning(&the_signing_certificate()), + Ok(PinWarning::AttemptsLeft(3)), + "{version}" + ); + } + } +} + +#[test] +fn the_card_taken_out_or_reset_at_any_step_ends_as_a_card_that_is_gone() { + let steps = [ + Step::Opening, + Step::Certificates, + Step::PinRetries, + Step::Verify, + Step::Signing, + ]; + for (version, card) in the_two_versions() { + for step in steps { + for (strike, struck) in [ + ("retirada", card().removing_card_at(step)), + ("reiniciada", card().resetting_card_at(step)), + ] { + let reader = InTheReader::new(struck); + let dnie = reader.token(); + + let cycle = dnie + .pin_warning(&the_signing_certificate()) + .and_then(|_| signed_by(&dnie, &the_pin())) + .expect_err("la tarjeta ya no esta"); + + assert_eq!( + cycle.situation(), + Situation::TokenAbsent, + "{version}, {strike} en {step:?}" + ); + } + } + } +} diff --git a/rfirma-app/src-tauri/src/identity/adapters/macos_store/dnie/tests/in_the_reader.rs b/rfirma-app/src-tauri/src/identity/adapters/macos_store/dnie/tests/in_the_reader.rs new file mode 100644 index 000000000..cf27cfa81 --- /dev/null +++ b/rfirma-app/src-tauri/src/identity/adapters/macos_store/dnie/tests/in_the_reader.rs @@ -0,0 +1,96 @@ +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex, MutexGuard}; + +use rdnie::{Session, Transport, SIGNATURE_LABEL}; +use rdnie_emulator::{EmulatedDnie, DEFAULT_PIN}; + +use super::super::{dnie_store, CardOpener, DnieToken}; +use crate::identity::domain::certificate::CertificateRef; +use crate::identity::domain::protected_secret::ProtectedSecret; + +/// Un solo DNIe emulado que sigue en el lector entre aperturas, y cuántas sesiones lo tienen abierto. +#[derive(Clone)] +pub(super) struct InTheReader { + card: Arc>, + sessions: Arc, +} + +impl InTheReader { + pub(super) fn new(card: EmulatedDnie) -> Self { + Self { + card: Arc::new(Mutex::new(card)), + sessions: Arc::new(AtomicUsize::new(0)), + } + } + + pub(super) fn card(&self) -> MutexGuard<'_, EmulatedDnie> { + self.card + .lock() + .expect("la tarjeta no deberia quedar envenenada") + } + + pub(super) fn token(&self) -> DnieToken { + DnieToken::new(self.clone()) + } + + pub(super) fn open_sessions(&self) -> usize { + self.sessions.load(Ordering::SeqCst) + } + + pub(super) fn pins_sent(&self) -> usize { + self.card() + .pin_queries() + .iter() + .filter(|query| query.with_pin) + .count() + } +} + +/// Una sesión con la tarjeta de [`InTheReader`], que se cuenta mientras vive. +pub(super) struct Connection(InTheReader); + +impl Drop for Connection { + fn drop(&mut self) { + self.0.sessions.fetch_sub(1, Ordering::SeqCst); + } +} + +impl Transport for Connection { + fn transmit_raw(&mut self, apdu: &[u8]) -> Result, rdnie::Error> { + self.0.card().transmit_raw(apdu) + } + + fn reset(&mut self) -> Result, rdnie::Error> { + self.0.card().reset() + } + + fn transaction( + &mut self, + body: impl FnOnce(&mut dyn Transport) -> Result, + ) -> Result { + self.0.card().transaction(body) + } +} + +impl CardOpener for InTheReader { + type Card = Connection; + + fn open(&self) -> Result, rdnie::Error> { + self.sessions.fetch_add(1, Ordering::SeqCst); + let root = self.card().component_root(); + Session::open_trusting(Connection(self.clone()), root) + } +} + +pub(super) fn the_pin() -> ProtectedSecret { + ProtectedSecret::from_str(DEFAULT_PIN) +} + +pub(super) fn a_wrong_pin() -> ProtectedSecret { + ProtectedSecret::from_str("OtroPin9") +} + +/// El certificado de firma del DNIe, nombrado sin leer la tarjeta. +pub(super) fn the_signing_certificate() -> CertificateRef { + CertificateRef::new(dnie_store(), "DNIe", SIGNATURE_LABEL, None) +} diff --git a/rfirma-app/src-tauri/src/identity/adapters/macos_store/dnie/tests/prompting.rs b/rfirma-app/src-tauri/src/identity/adapters/macos_store/dnie/tests/prompting.rs new file mode 100644 index 000000000..f469fd4c2 --- /dev/null +++ b/rfirma-app/src-tauri/src/identity/adapters/macos_store/dnie/tests/prompting.rs @@ -0,0 +1,282 @@ +use std::sync::Mutex; + +use super::in_the_reader::{a_wrong_pin, the_pin, the_signing_certificate, InTheReader}; +use super::the_two_versions; +use crate::crossing::Failure; +use crate::identity::domain::algorithm::SignatureAlgorithm; +use crate::identity::domain::certificate::TokenCertificate; +use crate::identity::domain::error::{Situation, TokenError}; +use crate::identity::domain::protected_secret::ProtectedSecret; +use crate::identity::domain::secret::{PinWarning, SecretName}; +use crate::identity::ports::{ + prompted_until_accepted, PromptedError, SecretPromptError, SecretPromptRequest, SecretPrompter, + Token, +}; +use crate::signing::adapters::prompted_secret::batch_signed_with_one_secret; +use crate::signing::domain::Language; + +/// Lo que vio el diálogo al abrirse: el aviso, si era un reintento y cuántas sesiones tenían la tarjeta. +#[derive(Debug, PartialEq, Eq)] +struct Seen { + warning: PinWarning, + retry: bool, + open_sessions: usize, +} + +/// Quien teclea, en orden, los PIN que se le dan, y apunta lo que ve cada diálogo. +struct Typist { + reader: InTheReader, + typed: Mutex>, + seen: Mutex>, +} + +impl Typist { + fn typing(reader: &InTheReader, typed: Vec) -> Self { + Self { + reader: reader.clone(), + typed: Mutex::new(typed), + seen: Mutex::new(Vec::new()), + } + } + + fn seen(&self) -> Vec { + std::mem::take(&mut *self.seen.lock().expect("sin envenenar")) + } +} + +impl SecretPrompter for Typist { + fn prompt_secret( + &self, + request: &SecretPromptRequest, + ) -> Result { + self.seen.lock().expect("sin envenenar").push(Seen { + warning: request.pin_warning, + retry: request.incorrect_secret, + open_sessions: self.reader.open_sessions(), + }); + let mut typed = self.typed.lock().expect("sin envenenar"); + if typed.is_empty() { + return Err(SecretPromptError::Cancelled); + } + Ok(typed.remove(0)) + } +} + +fn a_request() -> SecretPromptRequest { + SecretPromptRequest { + secret: SecretName::Pin, + holder: None, + language: Language::Spanish, + incorrect_secret: false, + pin_warning: PinWarning::Quiet, + origin_window: None, + } +} + +fn is_incorrect(error: &TokenError) -> bool { + error.situation() == Situation::IncorrectPin +} + +fn sign(dnie: &dyn Token, pin: &ProtectedSecret) -> Result, TokenError> { + dnie.sign_with_secret( + &the_signing_certificate(), + pin, + SignatureAlgorithm::Sha256Rsa, + b"datos", + ) +} + +/// Cada elemento de un lote firma en su ciclo, que abre su propio login dentro del del lote. +fn signed_in_its_own_cycle(dnie: &dyn Token, pin: &ProtectedSecret) -> Result, TokenError> { + dnie.hold_one_login(&the_signing_certificate()); + let signed = sign(dnie, pin); + dnie.release_the_login(&the_signing_certificate()); + signed +} + +fn signed_after_the_dialog( + dnie: &dyn Token, + typist: &Typist, +) -> Result<(ProtectedSecret, Vec), PromptedError> { + prompted_until_accepted( + typist, + a_request(), + || dnie.pin_warning(&the_signing_certificate()), + |pin| signed_in_its_own_cycle(dnie, pin), + is_incorrect, + ) +} + +fn accepted_after_the_dialog( + dnie: &dyn Token, + typist: &Typist, +) -> Result<(ProtectedSecret, ()), PromptedError> { + prompted_until_accepted( + typist, + a_request(), + || dnie.pin_warning(&the_signing_certificate()), + |pin| dnie.accepts_the_secret(&the_signing_certificate(), pin), + is_incorrect, + ) +} + +#[test] +fn the_dialog_says_the_attempts_left_and_a_wrong_pin_is_reread_before_the_next_one() { + for (version, card) in the_two_versions() { + let reader = InTheReader::new(card()); + let typist = Typist::typing(&reader, vec![a_wrong_pin(), the_pin()]); + + let signed = signed_after_the_dialog(&reader.token(), &typist); + + assert!(signed.is_ok(), "{version}"); + assert_eq!( + typist.seen(), + vec![ + Seen { + warning: PinWarning::AttemptsLeft(3), + retry: false, + open_sessions: 0 + }, + Seen { + warning: PinWarning::AttemptsLeft(2), + retry: true, + open_sessions: 0 + }, + ], + "{version}" + ); + } +} + +#[test] +fn with_no_attempts_left_there_is_no_dialog_and_it_ends_as_pin_locked() { + for (version, card) in the_two_versions() { + let reader = InTheReader::new(card().with_pin_retries(0)); + let typist = Typist::typing(&reader, vec![the_pin()]); + + let ended = signed_after_the_dialog(&reader.token(), &typist); + + assert!( + matches!(ended, Err(PromptedError::Attempt(ref error)) if error.situation() == Situation::PinLocked), + "{version}: {ended:?}" + ); + assert!(typist.seen().is_empty(), "{version}"); + assert_eq!(reader.pins_sent(), 0, "{version}"); + } +} + +#[test] +fn attempts_that_change_between_the_dialog_and_the_send_cut_the_cycle_without_sending_the_pin() { + for (version, card) in the_two_versions() { + let reader = InTheReader::new(card().losing_a_retry_between_phases()); + let typist = Typist::typing(&reader, vec![the_pin(), the_pin()]); + + let ended = signed_after_the_dialog(&reader.token(), &typist); + + assert!( + matches!(ended, Err(PromptedError::Attempt(_))), + "{version}: {ended:?}" + ); + assert_eq!(typist.seen().len(), 1, "{version}"); + assert_eq!(reader.pins_sent(), 0, "{version}"); + } +} + +#[test] +fn a_batch_asks_for_the_pin_once_and_every_signature_checks_it_on_the_card() { + for (version, card) in the_two_versions() { + let reader = InTheReader::new(card()); + let dnie = reader.token(); + let typist = Typist::typing(&reader, vec![the_pin()]); + + dnie.hold_one_login(&the_signing_certificate()); + let (pin, ()) = accepted_after_the_dialog(&dnie, &typist).expect("PIN aceptado"); + let signed: Vec<_> = (0..3).map(|_| sign(&dnie, &pin)).collect(); + dnie.release_the_login(&the_signing_certificate()); + + assert!(signed.iter().all(Result::is_ok), "{version}: {signed:?}"); + assert_eq!(typist.seen().len(), 1, "{version}"); + assert_eq!(reader.pins_sent(), 4, "{version}"); + } +} + +#[test] +fn a_batch_asks_again_after_a_wrong_pin_with_the_attempts_left_and_signs_with_the_right_one() { + for (version, card) in the_two_versions() { + let reader = InTheReader::new(card()); + let dnie = reader.token(); + let typist = Typist::typing(&reader, vec![a_wrong_pin(), the_pin()]); + let certificate = TokenCertificate::new(the_signing_certificate(), Vec::new()); + + let signed = batch_signed_with_one_secret( + &dnie, + &certificate, + &typist, + Language::Spanish, + &ProtectedSecret::new(b""), + |pin| { + for _ in 0..3 { + signed_in_its_own_cycle(&dnie, pin).map_err(Failure::from)?; + } + Ok(()) + }, + ); + + assert!(signed.is_ok(), "{version}: {signed:?}"); + let warnings: Vec<_> = typist + .seen() + .into_iter() + .map(|seen| (seen.warning, seen.retry)) + .collect(); + assert_eq!( + warnings, + vec![ + (PinWarning::AttemptsLeft(3), false), + (PinWarning::AttemptsLeft(2), true) + ], + "{version}" + ); + } +} + +#[test] +fn a_batch_stops_at_the_first_failure_without_spending_more_attempts() { + for (version, card) in the_two_versions() { + let reader = InTheReader::new(card()); + let dnie = reader.token(); + + dnie.hold_one_login(&the_signing_certificate()); + let signed: Vec<_> = (0..3) + .map(|_| signed_in_its_own_cycle(&dnie, &a_wrong_pin())) + .collect(); + dnie.release_the_login(&the_signing_certificate()); + + assert!( + signed + .iter() + .all(|done| done.as_ref().is_err_and(is_incorrect)), + "{version}: {signed:?}" + ); + assert_eq!(reader.pins_sent(), 1, "{version}"); + assert_eq!( + dnie.pin_warning(&the_signing_certificate()), + Ok(PinWarning::AttemptsLeft(2)), + "{version}" + ); + } +} + +#[test] +fn a_batch_asks_again_for_a_pin_of_a_length_the_dnie_does_not_admit() { + let reader = InTheReader::new(rdnie_emulator::EmulatedDnie::dnie_3()); + let dnie = reader.token(); + let typist = Typist::typing(&reader, vec![ProtectedSecret::from_str("1234"), the_pin()]); + + dnie.hold_one_login(&the_signing_certificate()); + let accepted = accepted_after_the_dialog(&dnie, &typist); + dnie.release_the_login(&the_signing_certificate()); + + assert!(accepted.is_ok()); + assert_eq!(typist.seen().len(), 2); + assert_eq!(reader.pins_sent(), 1); +} diff --git a/rfirma-app/src-tauri/src/identity/adapters/macos_store/dnie/tests/signing.rs b/rfirma-app/src-tauri/src/identity/adapters/macos_store/dnie/tests/signing.rs new file mode 100644 index 000000000..e9c8d6c93 --- /dev/null +++ b/rfirma-app/src-tauri/src/identity/adapters/macos_store/dnie/tests/signing.rs @@ -0,0 +1,131 @@ +use rdnie_emulator::EmulatedDnie; +use rsa::{Pkcs1v15Sign, RsaPublicKey}; +use sha2::{Digest, Sha256, Sha384, Sha512}; + +use super::in_the_reader::{the_pin, the_signing_certificate, InTheReader}; +use super::{the_dnie, the_two_versions}; +use crate::identity::adapters::macos_store::MacosToken; +use crate::identity::domain::algorithm::SignatureAlgorithm; +use crate::identity::domain::error::Situation; +use crate::identity::domain::secret::PinWarning; +use crate::identity::ports::Token; + +const SIGNED: &[u8] = b"los atributos firmados de un documento"; + +fn verifies(key: &RsaPublicKey, algorithm: SignatureAlgorithm, signature: &[u8]) -> bool { + let (scheme, digest) = match algorithm { + SignatureAlgorithm::Sha256Rsa => ( + Pkcs1v15Sign::new::(), + Sha256::digest(SIGNED).to_vec(), + ), + SignatureAlgorithm::Sha384Rsa => ( + Pkcs1v15Sign::new::(), + Sha384::digest(SIGNED).to_vec(), + ), + SignatureAlgorithm::Sha512Rsa => ( + Pkcs1v15Sign::new::(), + Sha512::digest(SIGNED).to_vec(), + ), + other => panic!("el DNIe no firma con {other:?}"), + }; + key.verify(scheme, &digest, signature).is_ok() +} + +#[test] +fn signs_with_sha256_sha384_and_sha512_and_the_signature_verifies_with_the_card_key() { + for (version, card) in the_two_versions() { + for algorithm in [ + SignatureAlgorithm::Sha256Rsa, + SignatureAlgorithm::Sha384Rsa, + SignatureAlgorithm::Sha512Rsa, + ] { + let reader = InTheReader::new(card()); + let key = reader.card().signing_public_key(); + + let signature = reader + .token() + .sign_with_secret(&the_signing_certificate(), &the_pin(), algorithm, SIGNED) + .unwrap_or_else(|error| panic!("{version} {algorithm:?}: {error}")); + + assert!( + verifies(&key, algorithm, &signature), + "{version} {algorithm:?}" + ); + } + } +} + +#[test] +fn the_macos_token_signs_the_dnie_copy_through_rdnie_with_its_attempts_left() { + let reader = InTheReader::new(EmulatedDnie::dnie_4().with_pin_retries(2)); + let key = reader.card().signing_public_key(); + let token = MacosToken::with_the_dnie(reader.token()); + + let warning = token.pin_warning(&the_signing_certificate()); + let signature = token + .sign_with_secret( + &the_signing_certificate(), + &the_pin(), + SignatureAlgorithm::Sha256Rsa, + SIGNED, + ) + .expect("el DNIe deberia firmar"); + + assert_eq!(warning, Ok(PinWarning::AttemptsLeft(2))); + assert!(verifies(&key, SignatureAlgorithm::Sha256Rsa, &signature)); +} + +#[test] +fn the_pin_warning_gives_the_exact_attempts_left_without_spending_any() { + for (version, card) in the_two_versions() { + for left in [3, 2, 1, 0] { + let reader = InTheReader::new(card().with_pin_retries(left)); + let dnie = reader.token(); + + let warning = dnie.pin_warning(&the_signing_certificate()); + + assert_eq!( + warning, + Ok(PinWarning::AttemptsLeft(u32::from(left))), + "{version}" + ); + assert_eq!(reader.pins_sent(), 0, "{version}"); + assert_eq!( + dnie.pin_warning(&the_signing_certificate()), + Ok(PinWarning::AttemptsLeft(u32::from(left))), + "{version}" + ); + } + } +} + +#[test] +fn offers_rsa_pkcs1_with_sha256_sha384_and_sha512_and_nothing_else() { + let dnie = the_dnie(EmulatedDnie::dnie_3); + let signing = the_signing_certificate(); + + let offered: Vec = SignatureAlgorithm::ALL + .into_iter() + .filter(|algorithm| dnie.offers(&signing, *algorithm).is_ok()) + .collect(); + + assert_eq!( + offered, + vec![ + SignatureAlgorithm::Sha256Rsa, + SignatureAlgorithm::Sha384Rsa, + SignatureAlgorithm::Sha512Rsa + ] + ); + for refused in [ + SignatureAlgorithm::Sha1Rsa, + SignatureAlgorithm::Sha256RsaPss, + ] { + assert_eq!( + dnie.offers(&signing, refused) + .expect_err("el DNIe no ofrece ese algoritmo") + .situation(), + Situation::MechanismNotOffered + ); + } +} 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 589b0cb2b..8bffd8c8a 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::{installs, keychain_store, stores_besides, user_store, MacosToken}; +use super::{dnie_store, 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}; @@ -60,7 +60,7 @@ impl TemporaryKeychain { } fn listed(&self) -> Vec { - MacosToken + MacosToken::default() .list(&keychain_store(&self.path)) .expect("el llavero deberia listarse") } @@ -74,7 +74,7 @@ impl TemporaryKeychain { } fn every(&self) -> Vec { - MacosToken + MacosToken::default() .every_certificate(&self.store()) .expect("el llavero deberia listarse") } @@ -204,14 +204,17 @@ fn the_user_store_is_the_macos_store_and_not_a_card() { } #[test] -fn the_macos_store_comes_before_the_pkcs11_stores_unless_a_module_is_named() { +fn the_macos_store_comes_before_the_pkcs11_stores_unless_a_module_is_named_and_the_dnie_after() { let card = Store::module("/opt/homebrew/lib/opensc-pkcs11.so"); assert_eq!( stores_besides(vec![card.clone()], false), - vec![user_store(), card.clone()] + vec![user_store(), card.clone(), dnie_store()] + ); + assert_eq!( + stores_besides(vec![card.clone()], true), + vec![card, dnie_store()] ); - assert_eq!(stores_besides(vec![card.clone()], true), vec![card]); } #[test] @@ -222,8 +225,14 @@ fn macos_asks_for_the_secret_and_never_warns_about_the_pin() { 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)); + assert_eq!( + MacosToken::default().secret_of(reference), + Ok(StoreSecret::NotNeeded) + ); + assert_eq!( + MacosToken::default().pin_warning(reference), + Ok(PinWarning::Quiet) + ); } #[test] @@ -288,8 +297,9 @@ fn the_chain_of_a_keychain_certificate_is_completed_with_its_issuer_from_the_key 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"); + let offered = + certificates_with_their_chains(&MacosToken::default(), &[keychain_store(&keychain.path)]) + .expect("el llavero deberia listarse"); assert_eq!(offered.len(), 1); let chain: Vec = offered[0] @@ -316,7 +326,7 @@ fn common_name_of(der: &[u8]) -> String { const DATA: &[u8] = b"lo que Java manda firmar"; fn signed(certificate: &TokenCertificate, algorithm: SignatureAlgorithm) -> Vec { - MacosToken + MacosToken::default() .sign_with_secret( certificate.reference(), &ProtectedSecret::new(b""), @@ -404,11 +414,11 @@ 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), + MacosToken::default().offers(certificate.reference(), SignatureAlgorithm::Sha256RsaPss), Ok(()) ); assert_eq!( - MacosToken + MacosToken::default() .offers(certificate.reference(), SignatureAlgorithm::Sha256Ecdsa) .map_err(|error| error.situation()), Err(Situation::MechanismNotOffered) @@ -432,7 +442,7 @@ fn a_certificate_gone_from_the_keychain_is_not_found_when_signing() { drop(keychain); assert_eq!( - MacosToken + MacosToken::default() .sign_with_secret( &moved, &ProtectedSecret::new(b""), @@ -580,9 +590,9 @@ fn removing_an_installed_certificate_deletes_it_and_its_key_from_the_keychain() 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())); + assert!(MacosToken::default().installed_in_the_system(home.path(), gone.reference())); - MacosToken + MacosToken::default() .remove_certificate(home.path(), gone.reference(), &ProtectedSecret::new(b"")) .expect("deberia quitarse"); @@ -592,7 +602,7 @@ 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())); + assert!(!MacosToken::default().installed_in_the_system(home.path(), gone.reference())); } #[test] @@ -604,10 +614,10 @@ fn a_keychain_certificate_rfirma_did_not_install_is_not_installed_in_the_system( let listed = keychain.listed(); - assert!(!MacosToken.installed_in_the_system(home.path(), listed[0].reference())); + assert!(!MacosToken::default().installed_in_the_system(home.path(), listed[0].reference())); } #[test] fn macos_installs_pkcs12_in_the_system() { - assert!(MacosToken.installs_in_the_system()); + assert!(MacosToken::default().installs_in_the_system()); } diff --git a/rfirma-app/src-tauri/src/identity/adapters/mod.rs b/rfirma-app/src-tauri/src/identity/adapters/mod.rs index 4563a11ed..60b190224 100644 --- a/rfirma-app/src-tauri/src/identity/adapters/mod.rs +++ b/rfirma-app/src-tauri/src/identity/adapters/mod.rs @@ -57,6 +57,17 @@ pub use pkcs11::RealToken as DesktopToken; #[cfg(windows)] pub use windows_store::WindowsToken as DesktopToken; +/// El token de esta plataforma, con el lector del DNIe en macOS. +#[cfg(target_os = "macos")] +pub fn desktop_token() -> DesktopToken { + DesktopToken::default() +} +/// El token de esta plataforma. +#[cfg(any(target_os = "linux", windows))] +pub fn desktop_token() -> DesktopToken { + DesktopToken +} + /// Los almacenes de certificados de esta plataforma. #[cfg(target_os = "macos")] pub use macos_store::from_environment as desktop_stores; @@ -66,3 +77,21 @@ pub use pkcs11::stores::from_environment as desktop_stores; /// Los almacenes de certificados de esta plataforma. #[cfg(windows)] pub use windows_store::from_environment as desktop_stores; + +/// El DNIe que ve el lector de esta plataforma; solo macOS lo lee con rdnie. +#[cfg(target_os = "macos")] +pub fn this_dnie() -> crate::identity::domain::seen_dnie::DnieInTheReader { + macos_store::seen_dnie(&macos_store::PcscCard) +} +/// El DNIe que ve el lector de esta plataforma; solo macOS lo lee con rdnie. +#[cfg(not(target_os = "macos"))] +pub fn this_dnie() -> crate::identity::domain::seen_dnie::DnieInTheReader { + crate::identity::domain::seen_dnie::DnieInTheReader::Absent +} + +/// La versión de rdnie compilada; sin ella fuera de macOS. +#[cfg(target_os = "macos")] +pub const RDNIE_VERSION: &str = macos_store::RDNIE_VERSION; +/// La versión de rdnie compilada; sin ella fuera de macOS. +#[cfg(not(target_os = "macos"))] +pub const RDNIE_VERSION: &str = "no incluido"; diff --git a/rfirma-app/src-tauri/src/identity/application/certificates/tests/copies.rs b/rfirma-app/src-tauri/src/identity/application/certificates/tests/copies.rs index ee2587389..620c888a7 100644 --- a/rfirma-app/src-tauri/src/identity/application/certificates/tests/copies.rs +++ b/rfirma-app/src-tauri/src/identity/application/certificates/tests/copies.rs @@ -227,6 +227,27 @@ fn the_card_copy_is_preferred_to_a_windows_copy_that_does_not_say_where_its_key_ assert_eq!(rows[0].stores, vec![StoreClass::Card, StoreClass::Windows]); } +#[test] +fn the_dnie_copy_is_preferred_to_a_pkcs11_module_copy_and_the_row_is_listed_once() { + let dnie = Store::module("dnie:pcsc"); + let module = Store::module(CARD); + + assert_eq!(the_copy_signed_with(&[&module, &dnie]), 1); + assert_eq!(the_copy_signed_with(&[&dnie, &module]), 0); + + let home = tempfile::tempdir().expect("deberia haber directorio temporal"); + let der = TestAuthority::root("EIDAS CERTIFICADO PRUEBAS - 99999999R").der(); + let rows = rows_of( + copies_of(&der, &[&module, &dnie]), + &home.path().join("certificates"), + &ListedCertificates::new(), + &ListedCertificates::new(), + &a_memory(home.path()), + ); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].store, StoreClass::Card); +} + #[test] fn the_installed_store_is_preferred_to_the_browsers() { let home = tempfile::tempdir().expect("deberia haber directorio temporal"); diff --git a/rfirma-app/src-tauri/src/identity/application/readers.rs b/rfirma-app/src-tauri/src/identity/application/readers.rs index f8dee499b..2f314b6a1 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::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 31e6b5d81..5c2955f43 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_and_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::MacOs)); } const CARD_KSP: &str = "Microsoft Smart Card Key Storage Provider"; @@ -477,3 +477,76 @@ fn a_change_in_the_readers_lists_the_macos_store_and_the_card_modules_again() { assert!(asked.contains(&the_macos_store())); assert!(asked.contains(&Store::module(CARD))); } + +const AUTHENTICATING_WITH_THE_DNIE: &str = "AUTENTICACION DEL DNIE"; + +fn the_dnie_store() -> Store { + Store::module(format!( + "{}pcsc", + crate::identity::domain::store::DNIE_STORE_PREFIX + )) +} + +fn in_the_dnie(label: &str, der: Vec) -> TokenCertificate { + TokenCertificate::new( + CertificateRef::new(the_dnie_store(), "DNIe", label, None), + der, + ) +} + +/// macOS con un certificado en el llavero y el almacén del DNIe, que enseña lo que lee del lector. +fn macos_with_a_reader(on_the_card: Vec) -> (WindowsWithACard, Arranged) { + let (card, _) = WindowsWithACard::of(Vec::new()); + let card_in = std::sync::Arc::clone(&card.card_in); + let in_the_keychain = in_macos( + IN_SOFTWARE, + TestAuthority::root("Firmante en el disco").der(), + ); + let token = Counting::showing(move |store| { + if *store != the_dnie_store() { + return vec![in_the_keychain.clone()]; + } + if card_in.load(std::sync::atomic::Ordering::SeqCst) { + on_the_card.clone() + } else { + Vec::new() + } + }); + let arranged = Arranged::over(token, vec![the_macos_store(), the_dnie_store()]); + (card, arranged) +} + +#[test] +fn putting_the_dnie_in_on_macos_makes_it_ready_and_adds_only_its_signing_certificate() { + let dnie = crate::identity::application::tests::a_dnie_der; + let (card, arranged) = macos_with_a_reader(vec![ + in_the_dnie(ON_THE_DNIE, dnie("ADA LOVELACE", true)), + in_the_dnie(AUTHENTICATING_WITH_THE_DNIE, dnie("ADA LOVELACE", false)), + ]); + + let heard = followed(&arranged, &card, &[false, true, false]); + + assert_eq!( + heard.iter().map(|news| news.reader).collect::>(), + vec![ + ReaderStatus::NoCard, + ReaderStatus::Reading, + ReaderStatus::Ready(ReadyCard::Dnie), + ReaderStatus::NoCard, + ] + ); + assert_eq!(labels_in(&heard[2]), vec![ON_THE_DNIE, IN_SOFTWARE]); + assert_eq!(labels_in(&heard[3]), vec![IN_SOFTWARE]); +} + +#[test] +fn another_card_on_macos_that_no_named_module_reads_is_unreadable() { + let (card, arranged) = macos_with_a_reader(Vec::new()); + + let heard = followed(&arranged, &card, &[true]); + + assert_eq!( + heard.iter().map(|news| news.reader).collect::>(), + vec![ReaderStatus::Reading, ReaderStatus::Unreadable] + ); +} diff --git a/rfirma-app/src-tauri/src/identity/domain/copies.rs b/rfirma-app/src-tauri/src/identity/domain/copies.rs index ea103a072..41af32c34 100644 --- a/rfirma-app/src-tauri/src/identity/domain/copies.rs +++ b/rfirma-app/src-tauri/src/identity/domain/copies.rs @@ -37,7 +37,7 @@ pub struct ChosenCopy { } impl ChosenCopy { - /// La recordada si está entre las copias; si no, la primera por preferencia de almacén y, dentro, de dueño. + /// La recordada si está entre las copias; si no, la primera por preferencia de almacén y, dentro, la del DNIe y la de dueño. pub fn among( mut copies: Vec, class_of: impl Fn(&CertificateRef) -> StoreClass, @@ -62,6 +62,7 @@ impl ChosenCopy { .min_by_key(|©| { ( classes[copy].preference(), + !copies[copy].reference().store().is_the_dnie(), copies[copy].reference().owner().map(NssOwner::preference), ) }) diff --git a/rfirma-app/src-tauri/src/identity/domain/error.rs b/rfirma-app/src-tauri/src/identity/domain/error.rs index d89da55ed..516e1ec47 100644 --- a/rfirma-app/src-tauri/src/identity/domain/error.rs +++ b/rfirma-app/src-tauri/src/identity/domain/error.rs @@ -46,7 +46,7 @@ pub struct TokenError { situation: Situation, ckr: Option, detail: String, - final_try: bool, + attempts_left: Option, } impl TokenError { @@ -56,24 +56,28 @@ impl TokenError { situation, ckr: None, detail: detail.into(), - final_try: false, + attempts_left: None, } } /// El mismo fallo, dejando la tarjeta en su último intento. pub fn on_the_final_try(self) -> Self { + self.with_attempts_left(1) + } + + /// El mismo fallo, con los intentos restantes exactos que da la tarjeta (ADR-0047). + pub fn with_attempts_left(self, attempts_left: u32) -> Self { Self { - final_try: true, + attempts_left: Some(attempts_left), ..self } } - /// Los intentos que se saben: ninguno con el PIN bloqueado, uno en el último intento (ADR-0047). + /// Los intentos que se saben: ninguno con el PIN bloqueado; si no, los que dio la tarjeta (ADR-0047). pub fn attempts_left(&self) -> Option { match self.situation { Situation::PinLocked => Some(0), - _ if self.final_try => Some(1), - _ => None, + _ => self.attempts_left, } } @@ -110,7 +114,7 @@ impl From for TokenError { situation: classify(rv), detail: format!("{ckr} ({})", function_name(function)), ckr: Some(ckr), - final_try: false, + attempts_left: None, } } Error::LibraryLoading(e) => Self::new(Situation::ModuleNotFound, e.to_string()), diff --git a/rfirma-app/src-tauri/src/identity/domain/error/tests.rs b/rfirma-app/src-tauri/src/identity/domain/error/tests.rs index 03cebf7fb..fdf72914e 100644 --- a/rfirma-app/src-tauri/src/identity/domain/error/tests.rs +++ b/rfirma-app/src-tauri/src/identity/domain/error/tests.rs @@ -88,3 +88,23 @@ fn a_missing_keyring_and_a_missing_pin_map_to_distinct_situations() { Situation::KeyringPinMissing ); } + +#[test] +fn a_wrong_pin_carries_the_exact_attempts_left_when_the_card_says_them() { + let refused = TokenError::new(Situation::IncorrectPin, "PIN incorrecto").with_attempts_left(2); + + assert_eq!(refused.attempts_left(), Some(2)); + assert_eq!(refused.situation(), Situation::IncorrectPin); +} + +#[test] +fn the_final_try_is_one_attempt_left_and_a_locked_pin_none() { + let refused = TokenError::new(Situation::IncorrectPin, "PIN incorrecto"); + + assert_eq!(refused.attempts_left(), None); + assert_eq!(refused.clone().on_the_final_try().attempts_left(), Some(1)); + assert_eq!( + TokenError::new(Situation::PinLocked, "bloqueado").attempts_left(), + Some(0) + ); +} diff --git a/rfirma-app/src-tauri/src/identity/domain/mod.rs b/rfirma-app/src-tauri/src/identity/domain/mod.rs index c7556ff77..360bf6ad7 100644 --- a/rfirma-app/src-tauri/src/identity/domain/mod.rs +++ b/rfirma-app/src-tauri/src/identity/domain/mod.rs @@ -12,4 +12,5 @@ pub mod keyring; pub mod protected_secret; pub mod readers; pub mod secret; +pub mod seen_dnie; pub mod store; diff --git a/rfirma-app/src-tauri/src/identity/domain/secret.rs b/rfirma-app/src-tauri/src/identity/domain/secret.rs index 4eb00168d..ef8304250 100644 --- a/rfirma-app/src-tauri/src/identity/domain/secret.rs +++ b/rfirma-app/src-tauri/src/identity/domain/secret.rs @@ -2,6 +2,7 @@ use std::fmt; +use crate::identity::domain::error::{Situation, TokenError}; use crate::identity::domain::store::StoreClass; /// Cómo se llama el secreto en los términos de quien firma: el PIN de un módulo o la contraseña de un fichero. @@ -93,6 +94,8 @@ pub enum PinWarning { CountLow, /// Un PIN incorrecto bloquea la tarjeta (`CKF_USER_PIN_FINAL_TRY`). FinalTry, + /// Los intentos restantes exactos, cuando la tarjeta los da. + AttemptsLeft(u32), } impl PinWarning { @@ -104,6 +107,22 @@ impl PinWarning { (false, false) => Self::Quiet, } } + + /// Si el aviso se resalta: el último intento, o quedan uno o dos. + pub fn stands_out(self) -> bool { + matches!(self, Self::FinalTry | Self::AttemptsLeft(1 | 2)) + } + + /// El aviso, salvo que no queden intentos: entonces el PIN está bloqueado y no se pide (ADR-0047). + pub fn unless_locked(self) -> Result { + match self { + Self::AttemptsLeft(0) => Err(TokenError::new( + Situation::PinLocked, + "la tarjeta no tiene intentos restantes", + )), + open => Ok(open), + } + } } #[cfg(test)] diff --git a/rfirma-app/src-tauri/src/identity/domain/secret/tests.rs b/rfirma-app/src-tauri/src/identity/domain/secret/tests.rs index 5d09f31ae..10496b897 100644 --- a/rfirma-app/src-tauri/src/identity/domain/secret/tests.rs +++ b/rfirma-app/src-tauri/src/identity/domain/secret/tests.rs @@ -1,5 +1,33 @@ use super::*; use crate::identity::domain::browsers::NssOwner; +use crate::identity::domain::error::Situation; + +#[test] +fn one_or_two_attempts_left_stand_out_like_the_final_try_and_more_do_not() { + assert!(PinWarning::FinalTry.stands_out()); + assert!(PinWarning::AttemptsLeft(1).stands_out()); + assert!(PinWarning::AttemptsLeft(2).stands_out()); + assert!(!PinWarning::AttemptsLeft(3).stands_out()); + assert!(!PinWarning::CountLow.stands_out()); + assert!(!PinWarning::Quiet.stands_out()); +} + +#[test] +fn no_attempts_left_is_a_locked_pin_and_any_other_warning_lets_the_dialog_open() { + let locked = PinWarning::AttemptsLeft(0) + .unless_locked() + .expect_err("con cero no hay diálogo"); + assert_eq!(locked.situation(), Situation::PinLocked); + assert_eq!(locked.attempts_left(), Some(0)); + + for open in [ + PinWarning::AttemptsLeft(3), + PinWarning::FinalTry, + PinWarning::Quiet, + ] { + assert_eq!(open.unless_locked(), Ok(open)); + } +} #[test] fn a_store_that_asks_for_no_session_needs_no_secret() { diff --git a/rfirma-app/src-tauri/src/identity/domain/seen_dnie.rs b/rfirma-app/src-tauri/src/identity/domain/seen_dnie.rs new file mode 100644 index 000000000..e2436debf --- /dev/null +++ b/rfirma-app/src-tauri/src/identity/domain/seen_dnie.rs @@ -0,0 +1,23 @@ +//! Lo que se ve de un DNIe sin leer nada de su titular. + +/// ATR, versión y juego de claves de un DNIe. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SeenDnie { + /// ATR en hexadecimal. + pub atr: String, + /// «3.0» o «4.0», según el chip. + pub version: String, + /// «primero» o «segundo», el juego de claves del canal seguro. + pub key_set: String, +} + +/// Lo que hay en el lector: ningún DNIe, uno que se ve o una tarjeta que rdnie no abre. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum DnieInTheReader { + /// Sin lector, sin tarjeta o con una tarjeta que no es un DNIe. + Absent, + /// Un DNIe legible. + Seen(SeenDnie), + /// El error de rdnie al abrirla, que no lleva datos del titular. + Unopened(String), +} diff --git a/rfirma-app/src-tauri/src/identity/domain/store.rs b/rfirma-app/src-tauri/src/identity/domain/store.rs index ad72a0758..897d0ab53 100644 --- a/rfirma-app/src-tauri/src/identity/domain/store.rs +++ b/rfirma-app/src-tauri/src/identity/domain/store.rs @@ -10,6 +10,9 @@ 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:"; +/// Prefijo con el que se nombra el almacén del DNIe leído con rdnie: ningún módulo PKCS#11 se llama así. +pub const DNIE_STORE_PREFIX: &str = "dnie:"; + /// 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", @@ -167,6 +170,11 @@ impl Store { self.is_named(WINDOWS_STORE_PREFIX) } + /// Si es el almacén del DNIe leído con rdnie y no un módulo PKCS#11. + pub fn is_the_dnie(&self) -> bool { + self.is_named(DNIE_STORE_PREFIX) + } + fn is_named(&self, prefix: &str) -> bool { self.module .to_str() diff --git a/rfirma-app/src-tauri/src/identity/ports.rs b/rfirma-app/src-tauri/src/identity/ports.rs index c3aa89e0a..57a594384 100644 --- a/rfirma-app/src-tauri/src/identity/ports.rs +++ b/rfirma-app/src-tauri/src/identity/ports.rs @@ -262,8 +262,8 @@ pub enum PromptedError { Attempt(E), } -/// Pide el secreto hasta que `attempt` lo acepta, releyendo antes de cada diálogo el aviso del PIN; un secreto rechazado no se reenvía (ADR-0001, ADR-0047). -pub fn prompted_until_accepted( +/// Pide el secreto hasta que `attempt` lo acepta, releyendo antes de cada diálogo el aviso del PIN; sin intentos restantes no hay diálogo, y un secreto rechazado no se reenvía (ADR-0001, ADR-0047). +pub fn prompted_until_accepted>( prompter: &dyn SecretPrompter, mut request: SecretPromptRequest, mut warning: impl FnMut() -> Result, @@ -271,7 +271,9 @@ pub fn prompted_until_accepted( rejected: impl Fn(&E) -> bool, ) -> Result<(ProtectedSecret, T), PromptedError> { loop { - request.pin_warning = warning().map_err(PromptedError::Attempt)?; + request.pin_warning = warning() + .and_then(|read| read.unless_locked().map_err(E::from)) + .map_err(PromptedError::Attempt)?; let secret = prompter .prompt_secret(&request) .map_err(PromptedError::Prompt)?; diff --git a/rfirma-app/src-tauri/src/identity/ports/tests.rs b/rfirma-app/src-tauri/src/identity/ports/tests.rs index 234a4dfb3..af3d8954b 100644 --- a/rfirma-app/src-tauri/src/identity/ports/tests.rs +++ b/rfirma-app/src-tauri/src/identity/ports/tests.rs @@ -5,6 +5,7 @@ use super::{ prompted_until_accepted, Keyring, PromptedError, SecretName, SecretPromptError, SecretPromptRequest, SecretPrompter, }; +use crate::identity::domain::error::{Situation, TokenError}; use crate::identity::domain::keyring::{generate_pin, KeyringError}; use crate::identity::domain::protected_secret::ProtectedSecret; use crate::identity::domain::secret::PinWarning; @@ -46,6 +47,18 @@ fn a_request() -> SecretPromptRequest { } } +fn rejected() -> TokenError { + TokenError::new(Situation::IncorrectPin, "rechazado") +} + +fn unrelated() -> TokenError { + TokenError::new(Situation::TokenAbsent, "otro fallo") +} + +fn is_rejected(error: &TokenError) -> bool { + error.situation() == Situation::IncorrectPin +} + #[test] fn accepts_the_secret_on_the_first_try() { let prompter = AScriptedPrompter::with(vec![Ok(ProtectedSecret::from_str("1234"))]); @@ -54,7 +67,7 @@ fn accepts_the_secret_on_the_first_try() { &prompter, a_request(), || Ok(PinWarning::Quiet), - |secret| -> Result<(), &'static str> { + |secret| -> Result<(), TokenError> { assert_eq!(secret.expose_secret(), Ok("1234")); Ok(()) }, @@ -82,10 +95,10 @@ fn retries_only_when_the_attempt_says_the_secret_was_rejected() { if secret.expose_secret() == Ok("right") { Ok(()) } else { - Err("rejected") + Err(rejected()) } }, - |error| *error == "rejected", + is_rejected, ) .expect("el segundo intento acierta"); @@ -107,12 +120,12 @@ fn stops_without_retrying_when_the_attempt_is_not_a_rejection() { &prompter, a_request(), || Ok(PinWarning::Quiet), - |_| -> Result<(), &'static str> { Err("unrelated failure") }, - |error| *error == "rejected", + |_| -> Result<(), TokenError> { Err(unrelated()) }, + is_rejected, ) .expect_err("un rechazo que no es de secreto no se reintenta"); - assert!(matches!(error, PromptedError::Attempt("unrelated failure"))); + assert!(matches!(error, PromptedError::Attempt(failure) if failure == unrelated())); assert_eq!( prompter.recorded.lock().unwrap().len(), 1, @@ -128,7 +141,7 @@ fn a_cancelled_prompt_stops_the_loop_without_attempting() { &prompter, a_request(), || Ok(PinWarning::Quiet), - |_| -> Result<(), &'static str> { unreachable!("cancelar no llega a intentarlo") }, + |_| -> Result<(), TokenError> { unreachable!("cancelar no llega a intentarlo") }, |_| true, ) .expect_err("cancelar el dialogo aborta"); @@ -139,6 +152,29 @@ fn a_cancelled_prompt_stops_the_loop_without_attempting() { )); } +#[test] +fn a_card_with_no_attempts_left_ends_as_a_locked_pin_without_a_dialog() { + let prompter = AScriptedPrompter::with(vec![]); + + let error = prompted_until_accepted( + &prompter, + a_request(), + || Ok(PinWarning::AttemptsLeft(0)), + |_| -> Result<(), TokenError> { unreachable!("sin intentos no se envía el PIN") }, + is_rejected, + ) + .expect_err("sin intentos, el PIN está bloqueado"); + + assert!(matches!( + error, + PromptedError::Attempt(failure) if failure.situation() == Situation::PinLocked + )); + assert!( + prompter.recorded.lock().unwrap().is_empty(), + "no hay diálogo" + ); +} + /// Un llavero en memoria: sin llavero, con llavero sin PIN, o con un PIN ya guardado. struct AMemoryKeyring { state: RefCell>>, diff --git a/rfirma-app/src-tauri/src/lib.rs b/rfirma-app/src-tauri/src/lib.rs index a78a4dc84..d1b0ade08 100644 --- a/rfirma-app/src-tauri/src/lib.rs +++ b/rfirma-app/src-tauri/src/lib.rs @@ -78,7 +78,7 @@ fn composed_roots(paths: desktop::adapters::paths::Paths, invocation: Option gtk::Label { let label = gtk::Label::new(None); let escaped = glib::markup_escape_text(text); - match warning { - PinWarning::FinalTry => label.set_markup(&format!("{escaped}")), - PinWarning::CountLow | PinWarning::Quiet => label.set_markup(&escaped), + if warning.stands_out() { + label.set_markup(&format!("{escaped}")); + } else { + label.set_markup(&escaped); } label.set_halign(gtk::Align::Start); label.set_xalign(0.0); 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 index e9f3671be..422931bae 100644 --- a/rfirma-app/src-tauri/src/signing/adapters/gtk_prompter/appkit.rs +++ b/rfirma-app/src-tauri/src/signing/adapters/gtk_prompter/appkit.rs @@ -7,7 +7,8 @@ use block2::RcBlock; use objc2::rc::{autoreleasepool, Retained}; use objc2::MainThreadMarker; use objc2_app_kit::{ - NSAlert, NSAlertFirstButtonReturn, NSModalResponse, NSSecureTextField, NSWindow, + NSAlert, NSAlertFirstButtonReturn, NSFont, NSModalResponse, NSSecureTextField, NSTextField, + NSView, NSWindow, }; use objc2_foundation::{NSPoint, NSRect, NSSize, NSString}; @@ -22,6 +23,7 @@ use crate::identity::ports::{ pub struct AlertTexts { pub message: String, pub informative: String, + pub highlighted: Option, pub accept: &'static str, pub cancel: &'static str, } @@ -67,19 +69,20 @@ impl SecretPrompter for SecureAlertPrompter { 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"); + let (quiet_warning, highlighted) = if request.pin_warning.stands_out() { + (None, i18n.pin_warning) + } else { + (i18n.pin_warning, None) + }; + let informative = [i18n.holder_name, i18n.id_number, incorrect, quiet_warning] + .into_iter() + .flatten() + .collect::>() + .join("\n"); AlertTexts { message: i18n.title, informative, + highlighted, accept: i18n.accept, cancel: i18n.cancel, } @@ -159,14 +162,42 @@ fn alert_with(mtm: MainThreadMarker, texts: &AlertTexts) -> AlertParts { 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)); + let field = NSSecureTextField::initWithFrame(mtm.alloc(), at(0.0, FIELD_HEIGHT)); + match &texts.highlighted { + Some(warning) => alert.setAccessoryView(Some(&in_bold_above(mtm, warning, &field))), + None => alert.setAccessoryView(Some(&field)), + } alert.layout(); alert.window().setInitialFirstResponder(Some(&field)); AlertParts { alert, field } } +const ACCESSORY_WIDTH: f64 = 240.0; +const FIELD_HEIGHT: f64 = 24.0; +const GAP: f64 = 8.0; + +fn at(y: f64, height: f64) -> NSRect { + NSRect::new(NSPoint::new(0.0, y), NSSize::new(ACCESSORY_WIDTH, height)) +} + +fn in_bold_above( + mtm: MainThreadMarker, + warning: &str, + field: &NSSecureTextField, +) -> Retained { + let label = NSTextField::wrappingLabelWithString(&NSString::from_str(warning), mtm); + label.setFont(Some( + &NSFont::boldSystemFontOfSize(NSFont::systemFontSize()), + )); + label.setPreferredMaxLayoutWidth(ACCESSORY_WIDTH); + let label_height = label.fittingSize().height; + label.setFrame(at(FIELD_HEIGHT + GAP, label_height)); + let accessory = NSView::initWithFrame(mtm.alloc(), at(0.0, FIELD_HEIGHT + GAP + label_height)); + accessory.addSubview(&label); + accessory.addSubview(field); + accessory +} + fn run_app_modal(parts: &AlertParts) -> AlertAnswer { answer_of(parts.alert.runModal(), &parts.field) } 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 index 512064cf9..d720c1211 100644 --- 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 @@ -118,9 +118,30 @@ fn a_card_on_its_final_try_is_warned_and_a_wrong_pin_is_said_again() { let _ = prompter.prompt_secret(&request); let (texts, _) = prompter.alert.shown(); + assert_eq!(texts.informative, "PIN incorrecto. Vuelve a intentarlo."); assert_eq!( - texts.informative, - "PIN incorrecto. Vuelve a intentarlo.\n\ - Último intento: si el PIN no es correcto, la tarjeta se bloqueará." + texts.highlighted.as_deref(), + Some("Último intento: si el PIN no es correcto, la tarjeta se bloqueará.") ); } + +#[test] +fn one_or_two_attempts_left_stand_out_beside_the_field_and_three_stay_in_the_text() { + for (left, stands_out) in [(1, true), (2, true), (3, false)] { + let alert = ScriptedAlert::answering(AlertAnswer::Dismissed); + let prompter = SecureAlertPrompter::over(alert); + let mut request = a_pin_request(); + request.holder = None; + request.pin_warning = PinWarning::AttemptsLeft(left); + + let _ = prompter.prompt_secret(&request); + let (texts, _) = prompter.alert.shown(); + + assert_eq!(texts.highlighted.is_some(), stands_out, "{left}: {texts:?}"); + assert_eq!( + texts.informative.is_empty(), + stands_out, + "{left}: {texts:?}" + ); + } +} diff --git a/rfirma-app/src-tauri/src/signing/adapters/gtk_prompter/tests.rs b/rfirma-app/src-tauri/src/signing/adapters/gtk_prompter/tests.rs index 4e85a2595..bfdfa7526 100644 --- a/rfirma-app/src-tauri/src/signing/adapters/gtk_prompter/tests.rs +++ b/rfirma-app/src-tauri/src/signing/adapters/gtk_prompter/tests.rs @@ -159,6 +159,48 @@ fn the_pin_warnings_are_in_all_five_languages_and_count_nothing() { } } +#[test] +fn a_card_that_says_its_attempts_left_has_them_said_before_typing() { + let mut request = a_request(None, Language::Spanish); + + request.pin_warning = PinWarning::AttemptsLeft(3); + assert_eq!( + localize(&request).pin_warning.as_deref(), + Some("Te quedan 3 intentos.") + ); + + request.pin_warning = PinWarning::AttemptsLeft(2); + assert_eq!( + localize(&request).pin_warning.as_deref(), + Some("Te quedan 2 intentos.") + ); + + request.pin_warning = PinWarning::AttemptsLeft(1); + assert_eq!( + localize(&request).pin_warning.as_deref(), + Some("Te queda 1 intento: si el PIN no es correcto, la tarjeta se bloqueará.") + ); +} + +#[test] +fn the_attempts_left_are_said_in_all_five_languages() { + for lang in Language::ALL { + let mut request = a_request(None, lang); + let said: Vec = [1, 2, 3] + .into_iter() + .map(|left| { + request.pin_warning = PinWarning::AttemptsLeft(left); + localize(&request).pin_warning.expect("aviso con número") + }) + .collect(); + + for (left, warning) in [1, 2, 3].into_iter().zip(&said) { + assert!(warning.contains(&left.to_string()), "{lang:?}: {warning}"); + } + assert_ne!(said[0], said[1], "{lang:?}"); + } +} + #[test] fn a_password_is_never_asked_with_a_card_warning() { let mut request = a_request(None, Language::Spanish); diff --git a/rfirma-app/src-tauri/src/signing/adapters/gtk_prompter/texts.rs b/rfirma-app/src-tauri/src/signing/adapters/gtk_prompter/texts.rs index cd15717eb..f88d497f3 100644 --- a/rfirma-app/src-tauri/src/signing/adapters/gtk_prompter/texts.rs +++ b/rfirma-app/src-tauri/src/signing/adapters/gtk_prompter/texts.rs @@ -118,9 +118,36 @@ fn incorrect_secret_text(lang: Language, secret: &SecretName) -> String { } } -fn pin_warning_text(lang: Language, warning: PinWarning) -> Option<&'static str> { +fn attempts_left_text(lang: Language, left: u32) -> String { + match (lang, left) { + (Language::Spanish, 1) => { + "Te queda 1 intento: si el PIN no es correcto, la tarjeta se bloqueará.".to_string() + } + (Language::Spanish, _) => format!("Te quedan {left} intentos."), + (Language::Catalan, 1) => { + "Et queda 1 intent: si el PIN no és correcte, la targeta es bloquejarà.".to_string() + } + (Language::Catalan, _) => format!("Et queden {left} intents."), + (Language::Basque, 1) => { + "Saiakera 1 geratzen zaizu: PINa zuzena ez bada, txartela blokeatu egingo da." + .to_string() + } + (Language::Basque, _) => format!("{left} saiakera geratzen zaizkizu."), + (Language::Galician, 1) => { + "Quédache 1 intento: se o PIN non é correcto, a tarxeta bloquearase.".to_string() + } + (Language::Galician, _) => format!("Quédanche {left} intentos."), + (Language::English, 1) => { + "1 try left: if the PIN is wrong, the card will be locked.".to_string() + } + (Language::English, _) => format!("{left} tries left."), + } +} + +fn pin_warning_text(lang: Language, warning: PinWarning) -> Option { let text = match (lang, warning) { (_, PinWarning::Quiet) => return None, + (_, PinWarning::AttemptsLeft(left)) => return Some(attempts_left_text(lang, left)), (Language::Spanish, PinWarning::FinalTry) => { "Último intento: si el PIN no es correcto, la tarjeta se bloqueará." } @@ -152,7 +179,7 @@ fn pin_warning_text(lang: Language, warning: PinWarning) -> Option<&'static str> "There has already been a failed try with this card." } }; - Some(text) + Some(text.to_owned()) } /// Traduce los textos del diálogo a uno de los 5 idiomas oficiales de rFirma (ADR-0009). @@ -170,8 +197,7 @@ pub fn localize(request: &SecretPromptRequest) -> DialogI18n { incorrect_secret: incorrect_secret_text(request.language, &request.secret), pin_warning: (request.secret == SecretName::Pin) .then(|| pin_warning_text(request.language, request.pin_warning)) - .flatten() - .map(str::to_owned), + .flatten(), accept, cancel, } diff --git a/rfirma-app/src-tauri/src/signing/adapters/prompted_secret/tests.rs b/rfirma-app/src-tauri/src/signing/adapters/prompted_secret/tests.rs index 0421984a8..43a931ba9 100644 --- a/rfirma-app/src-tauri/src/signing/adapters/prompted_secret/tests.rs +++ b/rfirma-app/src-tauri/src/signing/adapters/prompted_secret/tests.rs @@ -496,3 +496,150 @@ fn the_main_window_dialog_after_a_failure_carries_the_warning_read_again() { .collect(); assert_eq!(warnings, vec![PinWarning::Quiet, PinWarning::FinalTry]); } + +/// Un token que da sus intentos restantes exactos, solo acepta `1234` y gasta uno con cada PIN incorrecto. +struct ATokenThatCountsItsAttempts { + left: RefCell, +} + +impl ATokenThatCountsItsAttempts { + fn with(left: u32) -> Self { + Self { + left: RefCell::new(left), + } + } + + fn tried(&self, secret: &ProtectedSecret) -> Result<(), TokenError> { + if secret.as_bytes() == b"1234" { + return Ok(()); + } + *self.left.borrow_mut() -= 1; + Err(TokenError::new(Situation::IncorrectPin, "PIN incorrecto") + .with_attempts_left(*self.left.borrow())) + } +} + +impl Signer for ATokenThatCountsItsAttempts { + fn secret_of(&self, _reference: &CertificateRef) -> Result { + Ok(StoreSecret::TypedOnScreen) + } + + fn pin_warning(&self, _reference: &CertificateRef) -> Result { + Ok(PinWarning::AttemptsLeft(*self.left.borrow())) + } + + fn offers( + &self, + _reference: &CertificateRef, + _algorithm: SignatureAlgorithm, + ) -> Result<(), TokenError> { + Ok(()) + } + + fn sign_with_secret( + &self, + _reference: &CertificateRef, + secret: &ProtectedSecret, + _algorithm: SignatureAlgorithm, + data: &[u8], + ) -> Result, TokenError> { + self.tried(secret).map(|()| data.to_vec()) + } + + fn accepts_the_secret( + &self, + _reference: &CertificateRef, + secret: &ProtectedSecret, + ) -> Result<(), TokenError> { + self.tried(secret) + } +} + +#[test] +fn a_card_with_no_attempts_left_ends_the_signature_as_a_locked_pin_without_a_dialog() { + let certificate = a_certificate("FIRMA", b"der"); + let signer = ATokenThatCountsItsAttempts::with(0); + let session = a_session_with_open_cycle(&signer, &certificate); + let prompter = MockSecretPrompter::with_secrets(&[]); + + let failure = sign_on_token_with_prompter( + &signer, + &session, + &prompter, + Language::Spanish, + OriginWindow::Main, + ) + .expect_err("sin intentos no se firma"); + + assert_eq!(failure.situation, "pinLocked"); + assert_eq!(failure.attempts_left, Some(0)); + assert!(prompter.recorded_requests().is_empty(), "no hay diálogo"); +} + +#[test] +fn a_batch_on_a_card_with_no_attempts_left_ends_as_a_locked_pin_without_a_dialog() { + let prompter = MockSecretPrompter::with_secrets(&[]); + + let failure = secret_for_the_batch( + &ATokenThatCountsItsAttempts::with(0), + &a_certificate("FIRMA", b"der"), + &prompter, + Language::Spanish, + &ProtectedSecret::from_str(""), + ) + .expect_err("sin intentos no hay secreto del lote"); + + assert_eq!(failure.situation, "pinLocked"); + assert!(prompter.recorded_requests().is_empty(), "no hay diálogo"); +} + +#[test] +fn each_dialog_carries_the_exact_attempts_left_read_again_after_a_wrong_pin() { + let certificate = a_certificate("FIRMA", b"der"); + let signer = ATokenThatCountsItsAttempts::with(3); + let session = a_session_with_open_cycle(&signer, &certificate); + let prompter = MockSecretPrompter::with_secrets(&["0000", "1234"]); + + sign_on_token_with_prompter( + &signer, + &session, + &prompter, + Language::Spanish, + OriginWindow::Main, + ) + .expect("firma tras corregir el PIN"); + + let warnings: Vec = prompter + .recorded_requests() + .iter() + .map(|request| request.pin_warning) + .collect(); + assert_eq!( + warnings, + vec![PinWarning::AttemptsLeft(3), PinWarning::AttemptsLeft(2)] + ); +} + +#[test] +fn spending_the_last_attempt_ends_as_a_locked_pin_without_asking_again() { + let certificate = a_certificate("FIRMA", b"der"); + let signer = ATokenThatCountsItsAttempts::with(1); + let session = a_session_with_open_cycle(&signer, &certificate); + let prompter = MockSecretPrompter::with_secrets(&["0000"]); + + let failure = sign_on_token_with_prompter( + &signer, + &session, + &prompter, + Language::Spanish, + OriginWindow::Main, + ) + .expect_err("el último intento fallido bloquea"); + + assert_eq!(failure.situation, "pinLocked"); + assert_eq!( + prompter.recorded_requests().len(), + 1, + "no se vuelve a pedir" + ); +} diff --git a/rfirma-app/src/signing/ReaderLine.test.tsx b/rfirma-app/src/signing/ReaderLine.test.tsx index d1e64d84e..8fee21f21 100644 --- a/rfirma-app/src/signing/ReaderLine.test.tsx +++ b/rfirma-app/src/signing/ReaderLine.test.tsx @@ -32,11 +32,12 @@ describe("the reader line under the certificate selector", () => { expect(screen.getByRole("status")).toHaveTextContent(/OpenSC/); }); - it("points to OpenSC and the DGP page on macOS", () => { + it("offers no help on macOS", () => { renderWithCatalog(); - expect(screen.getByRole("status")).toHaveTextContent(/OpenSC/); - expect(screen.getByRole("status")).toHaveTextContent(/DGP/); + expect(screen.getByRole("status")).toHaveTextContent( + /^rFirma no puede leer la tarjeta del lector$/, + ); }); it("offers nothing on a platform it has no advice for", () => { diff --git a/rfirma-app/src/signing/ReaderLine.tsx b/rfirma-app/src/signing/ReaderLine.tsx index 22c792780..c313c786e 100644 --- a/rfirma-app/src/signing/ReaderLine.tsx +++ b/rfirma-app/src/signing/ReaderLine.tsx @@ -69,16 +69,14 @@ function readerText( function CardHelp({ platform }: { platform: CardHelpPlatform }) { const { t } = useTranslation(); - if (platform === "other") return null; + if (platform === "other" || platform === "macos") return null; return {cardHelpText(platform, t)}; } -function cardHelpText(platform: Exclude, t: TFunction): string { +function cardHelpText(platform: "windows" | "linux", 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"); }