Cadena de confianza de los certificados ajenos¶
Cómo se comprueba que el certificado que firmó un documento que nos llega es de quien dice ser, y qué hay que instalar para que esa comprobación funcione.
El problema¶
Verificar una firma XML-DSig demuestra dos cosas: que el documento no se alteró después de
firmarse, y que la firma cuadra con la clave pública del certificado que viene dentro del propio
documento (el <X509Certificate> del KeyInfo).
Lo que no demuestra: que ese certificado sea de quien dice. Cualquiera puede generarse uno con
openssl en diez segundos, poner el RNC de un tercero en el campo SN y firmar con él. La firma
verificaría perfectamente.
Donde eso importa más no es en la recepción de comprobantes, es en el servicio de autenticación: el token que emitimos ahí es la identidad del llamante para el resto de los servicios, y el RNC con el que se asocia se lee del subject del certificado firmante. Un certificado autofirmado bastaba para obtener un token con el RNC de otro contribuyente.
Lo que convierte la firma en prueba de autoría es que el certificado lo haya emitido una entidad de certificación autorizada —acreditada por INDOTEL— y que estuviera vigente.
Qué se comprueba, y cuándo¶
App\Ecf\CertificateTrust separa dos comprobaciones de coste muy distinto:
| Comprobación | ¿Necesita algo instalado? | ¿Está activa? |
|---|---|---|
| Vigencia del certificado | No | Siempre |
| Cadena hasta una raíz confiable | Sí, las raíces | Sólo si hay raíces instaladas |
Se aplica en los tres puntos por donde entra un documento ajeno:
EcfReceiver— e-CF de otro emisor. Un certificado no confiable se acusa como no recibido con motivo2(error de firma digital): el ARECF no tiene un código propio para esto, y el de firma es el motivo de fondo.CommercialApprovalService::receive()— aprobación/rechazo comercial. Responde 400 con el motivo en texto plano, que es lo único que el estándar define aquí.SeedAuthenticator— semilla firmada. Responde 401 y no emite token.
Sobre la vigencia¶
Se comprueba contra el reloj de ahora, no contra la FechaHoraFirma que declara el documento: esa
fecha la escribe el propio firmante, y quien tenga un certificado caducado puede poner cualquiera. Como
el flujo emisor→receptor es en línea —el e-CF llega a los minutos de emitirse—, la diferencia entre
"vigente al firmar" y "vigente al recibir" es una holgura de reloj, y para eso está
DGII_TRUST_CLOCK_SKEW (5 minutos por defecto).
Qué hay que instalar¶
# Raíces confiables: un fichero PEM, un directorio, o varias rutas separadas por comas.
DGII_TRUST_AUTHORITIES=/etc/ssl/dgii/raices.pem
# CAs intermedias (fichero PEM). Ver más abajo por qué casi seguro hace falta.
DGII_TRUST_INTERMEDIATES=/etc/ssl/dgii/intermedias.pem
# Rechazar lo que no encadene. Se activa en producción, con las raíces ya instaladas.
DGII_TRUST_REQUIRE_CHAIN=true
De dónde salen los ficheros:
- El raíz de la DGII: el paso 8 del set de pruebas ofrece descargarlo desde el portal de certificación.
- Las CAs acreditadas por INDOTEL: son las que emiten los certificados de los contribuyentes que nos van a enviar comprobantes. Sus raíces e intermedias se publican en los sitios de cada CA.
Después, comprobar que quedó bien instalado:
Lista las raíces que el proceso está leyendo de verdad (con su fecha de vencimiento), las intermedias, y verifica que nuestros propios certificados de firma encadenen hasta alguna raíz. Es la comprobación barata: si el nuestro no encadena, el de un tercero tampoco.
Tres trampas¶
1. El KeyInfo del e-CF sólo trae el certificado del firmante. El instructivo de firmado de la
DGII lo fija así: KeyInfo con X509Data/X509Certificate, nada más. No viene la cadena. Si la CA del
firmante es intermedia —lo normal, las CAs comerciales no firman con la raíz— tener la raíz no
basta: hay que tener su intermedia instalada de antemano, o la cadena no cierra y se rechazaría todo
lo legítimo. De ahí DGII_TRUST_INTERMEDIATES.
2. Un directorio de raíces no se usa como uno espera. OpenSSL sólo busca dentro de un directorio
de CAs por nombre de hash (c_rehash), así que dejar los .pem ahí sin rehashear los volvería
invisibles: estarían instalados y no se usarían, sin ningún error. Por eso, si se configura un
directorio, se expande a la lista de ficheros .pem/.crt/.cer que contenga.
3. Sin raíces, la cadena no se comprueba — a propósito. Es el estado normal hasta descargar el raíz
de la DGII, y bloquear la recepción por ello impediría las pruebas de comunicación. Pero con
DGII_TRUST_REQUIRE_CHAIN=true y sin raíces legibles se rechaza todo (fail closed): si se exige
la cadena y no hay nada contra lo que encadenar, el despliegue está mal configurado, y aceptar en
silencio sería peor que rechazar.
Qué NO se comprueba¶
- Revocación (CRL / OCSP). Un certificado revocado antes de su vencimiento pasa. Añadirlo implica consultar en línea al emitir el acuse —con su latencia y su modo de fallo— y la DGII, que es quien conoce a los emisores autorizados, valida por su cuenta el e-CF que le transmite el emisor. Queda anotado como decisión, no como olvido.
- Uso de clave / políticas de certificado más allá de lo que comprueba
X509_PURPOSE_ANY.
Pruebas¶
tests/Feature/CertificateTrustTest.php levanta una PKI de prueba completa (raíz → intermedia →
firmante) y comprueba, entre otros: que un certificado emitido por la raíz confiable pasa; que uno
autofirmado con un RNC en el subject se rechaza; que uno de otra raíz se rechaza; que la cadena con
intermedia no cierra sin la intermedia instalada y sí con ella; que un vencido se rechaza y uno
recién vencido dentro de la holgura no; y el efecto de punta a punta en los tres servicios (ARECF con
motivo 2, 401 sin token, 400 en la aprobación comercial).