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