PDFium VCL ya puede completar una cadena de firma PDF y comprobar la revocación por red en su backend OpenSSL: cuando OnlineRetrieval está activado, ConfigureSslCmsVerifier instala un verificador que descarga los certificados intermedios ausentes de las URLs AIA caIssuers y las CRLs de los CRL distribution points, dentro de un presupuesto fijo de tiempo, peticiones y bytes por llamada de verificación. Los certificados descargados son solo material de cadena. La confianza sigue viniendo exclusivamente del almacén del sistema y de las anclas que configures
El hueco que esto cierra aparece la primera vez que validas PDFs reales en un servidor Linux. Gran parte de los firmantes incrustan en el CMS solo su propio certificado leaf, así que OpenSSL no llega a una raíz, el TrustStatus vuelve inválido y la revocación nunca corre porque la cadena nunca llegó a ser fiable. Antes de v3.121.0 el backend OpenSSL descrito en verificar firmas PDF con OpenSSL en PDFium VCL era estrictamente sin conexión y el OnlineRetrieval no tenía efecto sobre él. Una cosa conviene dejarla clara de entrada: el propio motor PDFium no hace verificación de CMS alguna, así que cada regla de abajo vive en la capa PAdES del componente y en su binding OpenSSL, donde puedes leerla
¿En qué orden verifica, descarga y comprueba el backend OpenSSL?
Primero integridad, luego confianza, luego revocación, y la red solo se toca entre los pasos que la necesitan. VerifyCmsWithSsl comprueba la firma del CMS y los signed attributes (RFC 5652) con la evaluación de cadena suprimida, y si eso falla vuelve de inmediato, antes de que exista siquiera una sesión de descarga, así que un documento con bytes rotos no dispara ninguna petición saliente. Solo si la cadena falla después y el OnlineRetrieval está activo sigue los enlaces AIA y verifica otra vez. Los CRL distribution points se descargan solo una vez que la cadena es de fiar, porque una CRL colgada de un camino que no es de fiar no prueba nada. Los tres veredictos se mantienen separados de principio a fin: una firma válida con una cadena incompleta se sigue reportando como firma válida
uses
PDFium, FPdfCrypto, FPdfCryptoSsl, FPdfPades;
var
Pdf: TPdf;
Probe: TPdfCmsVerifyOptions;
Diags: TPdfSslVerifyDiagnostics;
Trust: TPadesTrustValidationOptions;
Verdict: TPadesValidationResult;
I: Integer;
begin
ConfigureSslTrustAnchors(LoadCorporateRoots); // DER; la única confianza extra
ConfigureSslCmsVerifier;
Probe := TPdfCmsVerifyOptions.Default;
Probe.OnlineRetrieval := True;
Probe.CheckRevocation := True;
Diags := SslVerifyOptionsDiagnostics(Probe);
if psvdOnlineRetrievalIgnored in Diags then
Log('no HTTP transport or CMS_add1_cert: validation stays offline');
Trust := TPadesTrustValidationOptions.Default; // ptnpOffline por defecto
Trust.NetworkPolicy := ptnpOnline;
Trust.CheckRevocation := True;
Trust.UrlRetrievalTimeoutMs := 10000; // por llamada de verificación
Pdf := TPdf.Create(nil);
try
Pdf.FileName := 'signed-contract.pdf';
Pdf.Active := True;
Verdict := Pdf.ValidatePadesTrust(Trust);
for I := 0 to High(Verdict.Signatures) do
Log(Format('#%d trust=%d revocation=%d', [I,
Ord(Verdict.Signatures[I].CertificateTrustStatus),
Ord(Verdict.Signatures[I].RevocationStatus)]));
finally
Pdf.Free;
end;
end;
¿Por qué los certificados descargados nunca entran en el trust store?
Porque las URLs vienen del certificado que se está validando, y las escogió el firmante. La entrada authorityInfoAccess caIssuers (RFC 5280 §4.2.2.1) es una pista sobre dónde vive el emisor, nada más. Si lo que responda a esa URL entrara en el almacén de anclas de confianza, cualquiera podría firmar con una clave hecha en casa, apuntar el AIA a su propio servidor y recibir un veredicto verde. RetrieveIntermediates pasa por tanto cada certificado parseado a CMS_add1_cert, que lo coloca en el conjunto no fiable de esta única estructura CMS, y OpenSSL todavía tiene que construir un camino desde él hasta una ancla que hayas configurado o que ya tenga el almacén del sistema. Hay también una razón más silenciosa: el argumento de certificados de CMS_verify no es un sustituto directo de los certificados incrustados en el CMS, así que añadir al propio CMS es el camino fiable
El bucle de recuperación es deliberadamente estrecho. RetrieveIntermediates corre como máximo 4 rondas, cada una recolectando las URLs caIssuers de cada certificado ya presente en el CMS, y se detiene en cuanto una ronda no añade nada o se agota el presupuesto de tiempo. Una respuesta debe decodificar con d2i_X509 como un único certificado DER que consuma el cuerpo entero; los bytes de más se rechazan, y un paquete PKCS#7 de solo certificados servido desde una URL .p7c se salta en lugar de desempaquetarse. El método de acceso OCSP de la misma extensión AIA se ignora, porque este backend no habla OCSP. En el lado de revocación, RetrieveCrls lee solo las URIs fullName de cada DistributionPoint (RFC 5280 §4.2.1.13) de los certificados del CMS y de las anclas configuradas, y las CRLs descargadas van a un segundo X509_STORE independiente con comprobación de CRL de toda la cadena, así que una CRL ausente o caducada cambia el RevocationStatus sin tocar jamás el TrustStatus
// Resumido de VerifyCmsWithSsl (FPdfCryptoSsl.pas); montaje de BIO omitido.
// Cada llamada a _CMS_verify recibe un content BIO fresco
if _CMS_verify(Cms, nil, nil, Bio, nil,
CMS_NO_SIGNER_CERT_VERIFY or CMS_BINARY) <> 1 then
Exit; // firma rota: nada de red
if Options.OnlineRetrieval and SslCapabilities.OnlineRetrieval then
FetchSession := TPdfCryptoFetchSession.Create(Options.UrlRetrievalTimeoutMs);
Store := BuildStore(False, nil, RevocationChecked, CrlsMalformed);
if (_CMS_verify(Cms, nil, Store, Bio, nil, CMS_BINARY) <> 1) and
(FetchSession <> nil) then
begin
RetrieveIntermediates(Cms, FetchSession); // CMS_add1_cert, solo no fiables
// verificar la cadena otra vez contra el mismo almacén de anclas
end;
if Options.CheckRevocation and (FetchSession <> nil) and
(Result.TrustStatus = pcvsValid) then
RetrievedCrls := RetrieveCrls(Cms, FetchSession);
// segundo almacén separado: CRLs configuradas más las recuperadas
Store := BuildStore(True, RetrievedCrls, RevocationChecked, CrlsMalformed);
¿Cuánto cuesta como máximo una llamada de verificación?
Un techo fijo, aplicado por un TPdfCryptoFetchSession que los pasos AIA y CRL de una misma llamada de verificación comparten. Los límites son constantes en FPdfCryptoHttp, no sugerencias:
- Tiempo:
UrlRetrievalTimeoutMs, que vale 15000 por defecto tanto enTPdfCmsVerifyOptions.Defaultcomo enTPadesTrustValidationOptions.Default; una sesión creada con 0 cae a 30000, y el reloj arranca una vez que la firma ha pasado, cubriendo toda petición posterior - Peticiones: como máximo 8 por sesión, contadas antes de intentar el transporte, así que un host muerto también consume un cupo
- Bytes: 1 MiB por respuesta y 4 MiB en total, con URLs de más de 2048 caracteres rechazadas antes de cualquier conexión
La contabilidad es más estricta de lo que parece a primera vista. Los bytes recibidos de una respuesta fallida también cuentan contra el total, así que un servidor que responde 404 con una página grande no drena el presupuesto gratis. La lectura que cruza el límite por respuesta aborta la descarga en vez de pasarle un cuerpo truncado al parser ASN.1, y un HTTP 200 con cuerpo vacío se rechaza sin más, porque el camino del AIA indexaría Data[0] de un array vacío. Solo pasan URLs http:// y https:// planas, sin redirecciones, cookies, credenciales ni descubrimiento automático de proxy, mientras que HTTPS conserva sus comprobaciones normales de certificado y hostname. La deduplicación de URLs queda acotada a una llamada a propósito: la siguiente validación debe poder ver una CRL recién publicada. El presupuesto es además por llamada, no por documento, y ValidatePadesTrust verifica cada firma y cada token de timestamp por separado, así que el peor caso crece con el número de firmas
¿Por qué una petición WinHTTP con timeout puede escribir todavía en tu memoria?
Porque volver por timeout no cancela los callbacks ya en vuelo. El transporte de Windows maneja WinHTTP de forma asíncrona y espera sobre un evento con el tiempo restante de la sesión, y cuando esa espera se rinde, la petición todavía puede completar una lectura y señalizar después. Apunta la lectura asíncrona a un buffer de la pila y esa finalización tardía escribe en un frame que para entonces pertenece a otra función cualquiera. El fix es de propiedad, no de tiempos: el evento y el buffer de lectura de 16 KB viven en un record del heap con dos referencias, una la sostiene el invocador y la otra solo la suelta el callback final HANDLE_CLOSING, así que el lado que acabe el último libera la memoria
type
PHttpState = ^THttpState;
THttpState = record
References: LongInt; // invocador + callback final HANDLE_CLOSING
Event: THandle;
Status, Count: DWORD;
Buffer: array[0..16383] of Byte; // las lecturas asíncronas aterrizan aquí, nunca en la pila
end;
procedure ReleaseState(State: PHttpState);
begin
if InterlockedDecrement(State.References) = 0 then
begin
CloseHandle(State.Event);
Dispose(State);
end;
end;
// En el callback de estado: HANDLE_CLOSING es la última notificación que WinHTTP
// envía para la petición, así que suelta la segunda referencia
if Status = HttpHandleClosing then
begin
ReleaseState(State);
Exit;
end;
Qué debe aportar libcurl en FPC Unix
Un resolver asíncrono y una build thread-safe, o la recuperación en línea se queda apagada. En FPC Unix el transporte pasa por libcurl, la misma dependencia detrás del backend de timestamps libcurl para destinos no Windows, y el binding rechaza cualquier biblioteca cuya máscara de features no tenga CURL_VERSION_ASYNCHDNS o CURL_VERSION_THREADSAFE. La razón es que CURLOPT_NOSIGNAL, que una biblioteca dentro del proceso de otro debe activar, combinado con un resolver síncrono significa que una consulta DNS simplemente puede sobrevivir al timeout. La segunda trampa es el apagado: curl_global_cleanup no espera a los hilos de DNS asíncronos, así que una vez inicializado libcurl el módulo sigue mapeado hasta que el proceso termina en vez de dejar que un hilo en segundo plano corra hacia código descargado. Cuando cualquiera de los dos requisitos falla, SslCapabilities.OnlineRetrieval es False y SslVerifyOptionsDiagnostics reporta psvdOnlineRetrievalIgnored en lugar de fingir que se consultó la red
Qué garantiza el resultado y qué no
Un RevocationStatus válido de este backend significa que se encontraron, configuraron o descargaron CRLs actuales que cubrían toda la cadena, y que ninguna listaba un certificado de ella; nada más. No hay OCSP, así que una CA que publica revocación solo por OCSP deja el resultado como no soportado, y un fallo de red se ve exactamente igual que una CA que no publica nada. Fíjate también en que psvdNoCrlsConfigured describe solo las CRLs que configuraste tú, así que con recuperación en línea es una pista, no un pronóstico de fallo. Cuando una pista de auditoría debe ser reproducible sin acceso a red, deja NetworkPolicy en su default ptnpOffline: no se crea ninguna sesión de descarga y el backend jamás abre una conexión, lo que encaja con el contrato sin conexión del lado CryptoAPI descrito en las comprobaciones de revocación sin conexión de firmas PDF en Windows
El código de recuperación, los presupuestos y los bindings de transporte se envían como fuente con el componente PDFium para Delphi, así que puedes confirmar exactamente qué URLs puede contactar una validación y cuánto puede descargar antes de activar ptnpOnline en un servidor que procesa documentos que no son de fiar