ADR-034: La firma XAdES se declara como se aplica — XMLDSig conforme, autoverificación y entrada del ArchiveTimeStamp según EN 319 132-1¶
Estado: Aceptado Fecha: 2026-10-11 Autores: Giampiero (mantenedor principal); triage B7 Relacionados: ADR-016 (firma electrónica; enmendado por este ADR), ADR-030 (constancia; sus huellas no cambian), ETSI EN 319 132-1 §5.5.2, W3C XML Signature Syntax and Processing §4.4.3.2
Contexto¶
La verificación en vivo de B7 sometió lo que firmamos a EU DSS (el validador de referencia de la UE) y todo dio
TOTAL_FAILED / HASH_FAILURE: el sello del índice, las constancias, las actas y las firmas personales. Nuestro propio
/verify decía valida=true.
Causa (comprobada en el código de signxml 5.1.0, XAdESSigner._add_reference_to_signed_info): las referencias a
xades:SignedProperties y a ds:KeyInfo se crean sin ds:Transforms pero su digest se calcula con la canonicalización del
firmante (exclusiva). XMLDSig §4.4.3.2 dice que una referencia same-document sin Transforms se convierte a octetos con
C14N 1.0 inclusiva: un verificador conforme recalcula otro digest. El defecto era nuestro —declarábamos una cosa y
aplicábamos otra—, no «un bug de signxml», como decía el docstring de local.py. Y lo escondíamos: verify_xades pasaba un
expect_config con default_reference_c14n_method=EXCLUSIVE, que le decía a nuestro propio verificador «asume exclusiva», de modo
que lo firmado solo lo verificaba OrpycaMCP.
Una segunda divergencia, la misma familia: la entrada que sellábamos en el xadesv141:ArchiveTimeStamp
(_archive_ts_covered_bytes) era una concatenación propia (SignedInfo ‖ SignatureValue ‖ KeyInfo ‖ SignedProperties ‖ sellos ‖ …),
sin los octetos de las referencias y con SignedProperties donde la norma no lo pone. Ningún artefacto del corpus llevaba
ArchiveTimeStamp (CA de LT sin montar), así que DSS nunca lo evaluó; medido ahora, su MESSAGE_IMPRINT daba no íntegro.
Decisión¶
1. Declarar los Transforms que se aplican y firmar el SignedInfo final¶
Alternativa A (cambiar a C14N 1.0 inclusiva): descartada. Cambia los digests, obliga a tocar tres exclusive=True fijos
(sello de tiempo, ArchiveTimeStamp, split de firma personal), el contrato de digest con auth-service, y la inclusiva arrastra los
namespaces del ancestro al digest: incrustar el XML firmado en otro (paquete AIP/SIP) rompe la firma. La exclusiva está pensada
para sobrevivir al reempaquetado.
Alternativa B (elegida), en services/signature-service/app/providers/local.py:
XAdESSigner.sign(...)(sello: con su clave; firma personal: clave efímera + certificado real, como antes)._declarar_transforms: cadads:Referencesinds:Transformsrecibe, como primer hijo,Transform exc-c14n. Los digests no cambian (ya eran exclusivos): se declara lo que se aplicó._firmar_signed_info, un solo camino para los dos firmantes: canonicalizads:SignedInfocon el algoritmo que ESE elemento declara (no unexclusive=Truefijo) y firma después del parcheo. Sello:PKCS1v15/SHA-256con la clave en proceso; personal:remote_sign(SHA-256(octetos)). El digest que recibe auth-service sale delSignedInfofinal; el contrato con auth no cambia (recibe un digest opaco), solo cambia el orden._autoverificar: la firma se verifica conXAdESVerifier()en su configuración por defecto antes de sellar el tiempo o persistir nada. Si falla,XmldsigNoConformeError→503 firma_no_conforme_xmldsig: nunca se persiste una firma que un tercero no podría comprobar. Sin salvedades: elverification_timees el por defecto (ahora). Una versión anterior de este ADR lo acotaba a la vigencia del certificado para que un sello caducado «firmara y se rechazara al verificar»; eso fue un error de diseño (el sistema emitía y archivaba en WORM firmas que nacían inverificables) y se retiró: la vigencia se exige ANTES de firmar (punto 4).
TSA, CertificateValues, RevocationValues, OCSP y ArchiveTimeStamp son propiedades no firmadas (fuera de SignedInfo) y
se añaden después, como antes.
Dónde cae la autoverificación en la ruta personal (SEG-B2/ARC-B1) y cómo no se quema el TOTP (B10 §7).
XmldsigNoConformeError se traduce a 503 firma_no_conforme_xmldsig también en sign_personal_xml (la comparten /sign-personal y
la cadena), no solo en /sign-xml: antes era un 500. La autoverificación necesita el SignatureValue, que solo produce auth-service
al verificar y consumir el TOTP (remote_sign). La versión anterior de este ADR declaraba la consecuencia («el factor ya está
consumido») y descartaba adelantarla. B10 la reduce con tres barreras, dos antes del TOTP y una dentro de su transacción:
- Ensayo previo, sin TOTP (
_ensayar_xmldsig, solo cuando hayremote_sign): la MISMA ruta design(XAdESSigner.sign→_declarar_transforms→ firma delSignedInfofinal →_autoverificar) con un par y un certificado efímeros. Valida la parte de la conformidad que depende del DOCUMENTO (c14n,Transforms, digests de lasReference: la familia delHASH_FAILURE) antes de llamar aremote_sign. Si falla,503 firma_no_conforme_xmldsigcon el TOTP intacto. - Huella del certificado, antes del TOTP.
InternalSignDigestRequestllevacert_fingerprint(obligatorio, SHA-256 del DER); auth lo contrasta con el certificado vigente y responde409 certificado_cambiadosin tocar el factor (rotación o re-emisión entre el/statusy la firma). - Auth verifica su propia firma DENTRO de la transacción del TOTP. Tras
sign_digest, verifica con la clave pública del certificado; si no verifica,FirmaDigestNoVerificaErrorsale delasync with conn.transaction()y el rollback devuelve también el consumo del código (503 firma_digest_no_verifica, asiento best-effortsigning_key.digest_no_verificaen su propia tx). Es seguro: no se entregó ninguna firma. La alternativa de reservar el TOTP y confirmarlo después (dos fases) se descartó: abre una ventana de replay y rompe «TOTP verificado-y-consumido atómicamente»; mover el XMLDSig a auth se descartó porque auth no debe cargar consignxmlni ver documentos.
Residuo declarado. El _autoverificar final se mantiene: lo único que aún puede fallar tras remote_sign es un defecto al
incrustar el SignatureValue REAL, determinista y que ya pasó por el ensayo. Si ocurre, el 503 lleva totp_posiblemente_consumido y
PersonalSigningService deja el asiento firma.autoverificacion_fallida_tras_totp para que la persona sepa que su código se usó. Costo
real: esperar el siguiente intervalo de 30 s; no suma intentos fallidos ni bloquea. La ruta de la cadena (CadenaFirmaService) lleva la
misma marca en el 503 y deja el MISMO asiento firma.autoverificacion_fallida_tras_totp (cadena_service.py, fuera de la transacción
ambiente): no queda deuda de traza en ninguna de las dos rutas.
4. No se firma con un certificado fuera de vigencia (ARC-M2, SEG-B3)¶
LocalSigner.sign exige la vigencia del certificado antes de hacer nada (sin TSA, sin remote_sign):
- Sello institucional caducado o aún no vigente →
SelloInstitucionalVencidoError→503 sello_institucional_vencido(/sign-xml,/internal/sign-indicey todo lo que firma con el sello: índices, actas, constancias, revocaciones). El llamador best-effort deja el artefactopendiente_firma; no se archiva nada inverificable. - Firma personal →
CertificadoPersonalVencidoError→422 certificado_personal_vencido, antes deremote_sign: auth-service no verifica ni consume el TOTP ni toca el contador anti-replay.
Antes, firmar con un sello caducado funcionaba y solo /verify lo rechazaba después, con el artefacto ya en WORM.
5. /verify de un XAdES-LTA cuyo certificado caducó después de firmar (ARC-M1)¶
Regla: el certificado firmante se juzga a «ahora». Única excepción: si ya caducó y la firma lleva un ArchiveTimeStamp que
valida (nivel verificado XAdES-LTA), se juzga a la fecha genTime del SignatureTimeStamp —que tsa.verify_timestamp_token
ya exige dentro de la vigencia del certificado (AREA-04-08)—, y la respuesta lleva la advertencia
certificado_vencido_vigencia_juzgada_al_sello_de_firma. Las CA de la cadena se juzgan a la misma fecha en esa pasada.
Mecánica: una segunda pasada de verify_xades (_vigencia_diferida) que solo se acepta si el nivel verificado es LTA.
- Sin LTA válido (B, T, LT, o un
ArchiveTimeStampque no valida) se mantienecertificado_fuera_de_vigencia. - Un documento alterado sigue siendo inválido: la comprobación criptográfica corre igual.
- El
ArchiveTimeStampse renueva (B10 §8). Antes de que caduque el certificado de su TSA, el jobapp/jobs/renovacion_lta.pyverifica la firma entera y, si verifica, añade unxadesv141:TimeStampValidationData(la cadena de la TSA del sello anterior) y unArchiveTimeStampnuevo cuya entrada incluye TODO lo previo (referencias,SignedInfo,SignatureValue,KeyInfo, propiedades no firmadas, ATS y TSVD anteriores; EN 319 132-1 §5.5.2 y §5.5.3). El XML renovado es un objeto NUEVO en storage (el original está bajo COMPLIANCE y es evidencia;version_ides el ancla de su fila WORM): heredaretain_until/modo/legal hold porPOST /api/v1/preservacion/renovar-artefacto-internal, que deja el evento PREMISRENOVACION. La cadena se registra enfirma_sello_archivo(migración 026; ordinal 0 = el original).verify_xadesvalida cada ATS sobre sus propios hermanos previos: el primero se ancla a la TSA de la firma, los renovados a la de la firma o a una registrada al renovar, y cada sello debe haberse renovado antes de que caducara el certificado de su TSA (archive_timestamp_cadena_rota)._vigencia_diferidasigue juzgando con elgenTimedelSignatureTimeStamp, condicionado a que toda la cadena valide. No se renueva una firma que no verifica ni con una TSA que no dé más plazo que la anterior (+lta_renovacion_margen_dias). Correcciones de la auditoría de B10: - El último sello se juzga a la fecha ACTUAL (EN 319 102-1 §5.6.2). Si el certificado de su TSA ya caducó y no hay un sello
posterior, la cadena ya no prueba nada:
/verifydevuelvelta_vigente=false, baja el nivel verificado aXAdES-LT, añade la advertenciaarchive_timestamp_caducadoy no aplica_vigencia_diferida(un firmante caducado no se rescata con un sello que ya no prueba; vuelvecertificado_fuera_de_vigencia). El veredicto criptográfico no cambia. - No se renueva sobre una TSA caducada. Un sello nuevo con
genTimeposterior al vencimiento de la TSA anterior rompería la cadena (archive_timestamp_cadena_rota) y el artefacto, ya bajo COMPLIANCE, se invalidaría a sí mismo. Sinow() >= tsa_cert_not_afterdel último eslabón, el job no renueva: el eslabón pasa acaducado(sigue siendo el artefacto que se sirve) y se asientafirma.lta_cadena_caducada. Es una pérdida de prueba que solo se evita renovando antes: por eso el job recorre también las entidades suspendidas (la conservación no depende de la suspensión) y el margen es de 90 días. TimeStampValidationDatacon la revocación de la TSA anterior (EN 319 132-1 §5.5.1): si la CA que emitió el certificado de la TSA publica una CRL (punto de distribuciónhttp(s)), se incrusta comoRevocationValues; si no se puede obtener (red, CRL vencida o de otro emisor, sin punto de distribución) no se renueva y se asientafirma.lta_renovacion_sin_revocacion_tsa. Limitación declarada: un certificado de TSA autofirmado (la TSA local de un despliegue) es su propia ancla de confianza, no tiene CA ni CRL, y su TSVD va sinRevocationValues; no se ha probado contra una TSA con OCSP únicamente (solo CRL).- La CRL se evalúa, no solo se incrusta (R2-3).
tsa.revocacion_de_tsaverifica la firma de la CRL contra la CA de la TSA (la del token o una deTSA_CA_PEM_PATHS; sin CA no se acepta una CRL sin verificar) y consulta el número de serie de la TSA anterior: si figura revocada no se renueva (firma.lta_renovacion_tsa_revocada); si la CA no está o la CRL no se obtiene o no verifica, tampoco (firma.lta_renovacion_sin_revocacion_tsa)./verifyNO evalúa elTimeStampValidationData: lo valida un tercero (p. ej. EU DSS); nosotros solo lo construimos con la revocación disponible. POST /expedientes/{id}/indice/verify(archive) verifica el artefacto vigente del índice (el XML renovado), resuelto igual que el AIP; si signature no lo resuelve cae al original y avisaartefacto_vigente_no_resuelto.- Los asientos de rechazo, TSA por vencer y sin revocación se escriben una vez por motivo y eslabón (
ultimo_aviso); las pasadas siguientes dejan un WARNING en el log. El paso avigentedeja el asientofirma.lta_sello_vigente. - El vencimiento del eslabón 0 sale del certificado elegido por huella (
archive_tsa_cert_fingerprint), no decertificates[0](unSET OFno tiene orden); un token sin certificados no tumba la firma (no hay eslabón y quedafirma.lta_sin_eslabon). - La retención se extiende en cascada: renovar la retención de un artefacto de conservación total (storage,
renueva_a) extiende también los XML renovados, y una renovación nueva hereda el mayorretain_untilde la cadena.
6. La verificación aplica la canonicalización que el XML declara (ARC-B2)¶
El imprint del SignatureTimeStamp y la entrada del ArchiveTimeStamp se recalculan con el ds:CanonicalizationMethod que cada
sello declara, no con un exc-c14n fijo (_c14n_declarado, _archive_ts_covered_bytes(root, algoritmo)). Sin método declarado
o con uno no soportado el sello se rechaza (fail-closed). Hoy el firmante siempre declara exc-c14n, así que el comportamiento
observable no cambia; lo que cambia es que declarar y aplicar dejan de poder divergir en silencio.
2. verify_xades verifica solo conforme¶
Se elimina _VERIFY_CONFIG y el atajo. verify_xades llama a XAdESVerifier().verify(root, x509_cert=…) por defecto.
No hay rama «legado», ni columna perfil_xmldsig, ni backfill, ni constancia de verificación: todo lo anterior es corpus de
prueba (decisión del usuario, 2026-10-11). Las firmas anteriores a este ADR (referencias sin Transforms) dejan de
verificar —firma_invalida— y eso es lo correcto: nunca las habría verificado un tercero. Se regeneran si hace falta.
3. El ArchiveTimeStamp se calcula como dice EN 319 132-1 §5.5.2 (o no se declara LTA)¶
La opción «dejar de declarar LTA» era el plan B. No hizo falta: se probaron contra DSS ocho composiciones y exactamente una da
MESSAGE_IMPRINT íntegro, la de la norma. _archive_ts_covered_bytes concatena ahora:
- los octetos de cada
ds:ReferencedeSignedInfo, en su orden (documento sin la firma,SignedProperties,KeyInfo), tras susTransforms(_octetos_de_referencia); - C14N de
ds:SignedInfo,ds:SignatureValueyds:KeyInfo; - C14N de cada propiedad no firmada previa al
ArchiveTimeStamp, en orden de documento (SignatureTimeStamp,CertificateValues,RevocationValues, conOCSPValuesdentro).
Build y verify comparten la misma función. Un sello construido con la composición antigua ya no valida como LTA
(archive_timestamp_invalido).
Evidencia (EU DSS 6.5, en vivo contra la pila)¶
| Artefacto | Resultado |
|---|---|
| Índice de expediente sellado (XAdES-T, pila real) | SignatureIntact=true; referencias documento, SignedProperties y KeyInfo íntegras; sello de tiempo íntegro. INDETERMINATE / NO_CERTIFICATE_CHAIN_FOUND |
| Firma personal (JEFE, XAdES-T, pila real) | ídem |
| Sello XAdES-LTA (CA de pruebas local) | ídem, y el ARCHIVE_TIMESTAMP íntegro (antes no) |
| Firma anterior al arreglo (control negativo) | TOTAL_FAILED / HASH_FAILURE |
INDETERMINATE / NO_CERTIFICATE_CHAIN_FOUND es el resultado esperado: la CA de desarrollo no está en ninguna lista de confianza.
DSS no dice que la firma sea cualificada ni acreditada, y OrpycaMCP tampoco (acreditado=false, RF-FIR-15). Lo que cambia es que
la criptografía pasa en un validador independiente.
Consecuencias¶
Positivas: lo que firmamos lo verifica un tercero con herramientas estándar (con la salvedad de la confianza del certificado);
/verify ya no esconde defectos de construcción; una regresión del parcheo o del orden (digest antes de parchear) hace fallar la
firma al emitirla, no al verificarla meses después.
Negativas / límites:
- Las firmas anteriores del corpus de prueba no verifican. En una instalación con datos reales haría falta una política de legado (rama de verificación rotulada «solo OrpycaMCP», constancia de verificación institucional): no se implementó porque no hay tal instalación (ver «Decisiones descartadas»).
- ADR-016 y ADR-030 afirmaban que la firma era verificable por terceros. Hasta este ADR no lo era; ambos llevan ahora la nota.
- El humo contra DSS (
tests/test_dss_smoke.py) no forma parte del gate: requiereDSS_URL. - El export no incluye el XML renovado (no lleva el XML firmado; sin cambio). El AIP sí embebe el XML vigente del índice, pero un
AIP empaquetado antes de una renovación no se rehace: reempaquetarlo queda abierto (ver punto 5). Y en la ruta personal queda el residuo del punto 1: un defecto al
incrustar el
SignatureValuereal aún puede gastar un TOTP (se asienta y se avisa). - La confianza (cadena hasta una raíz de una lista de confianza, ONAC) sigue siendo un asunto aparte.
Decisiones descartadas¶
Columna firma.perfil_xmldsig, backfill, rama «legado» en verify_xades, verificable_por_terceros en VerifyResponse y
constancia de verificación por cada firma anterior: descartadas por el usuario al ser todo corpus de prueba. Si algún día
existiera una instalación productiva con firmas previas, el diseño original (rama de verificación rotulada y constancia
institucional sellada, nunca re-firma) está en el historial de la tanda B7.