Artículo técnico

Recuperación AIA y CRL de OpenSSL para firmas PDF en Delphi

PDFium VCL ahora puede completar una cadena de firma PDF y chequear revocación por la red en su backend OpenSSL: cuando OnlineRetrieval está habilitado, ConfigureSslCmsVerifier instala un verificador que descarga los certificados intermedios faltantes desde las URLs AIA caIssuers y las CRLs desde los CRL distribution points, dentro de un presupuesto fijo de tiempo, requests y bytes por llamada de verificación. Los certificados descargados son material de cadena y nada más. La confianza sigue viniendo exclusivamente del store del sistema y de las anclas que usted configure

La brecha que esto cierra aparece la primera vez que valida PDFs reales en un servidor Linux. Una buena parte de los firmantes incrusta en el CMS solo su propio certificado hoja, así que OpenSSL no puede llegar a una raíz, TrustStatus vuelve inválido, y la revocación nunca corre porque la cadena nunca llegó a ser confiable. Antes de la v3.121.0 el backend OpenSSL descrito en verificar firmas PDF con OpenSSL en PDFium VCL era estrictamente offline y OnlineRetrieval no tenía efecto sobre él. Una cosa vale declararla de entrada: el motor PDFium en sí no hace verificación CMS alguna, así que cada regla de abajo vive en la capa PAdES del componente y en su binding OpenSSL, donde usted puede leerla

¿En qué orden verifica, descarga y chequea el backend OpenSSL?

Primero integridad, después confianza, después revocación, y la red se toca solo entre los pasos que la necesitan. VerifyCmsWithSsl chequea la firma 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 ningún request de salida. Solo si la cadena entonces falla y OnlineRetrieval está activo sigue los enlaces AIA y verifica otra vez. Los CRL distribution points se descargan únicamente cuando la cadena ya es confiable, porque una CRL colgada de un camino no confiable no prueba nada. Los tres veredictos se mantienen separados de punta a punta: una firma válida con cadena incompleta sigue reportándose como firma válida

Orden de VerifyCmsWithSsl en el backend OpenSSL del PDFium Component: el chequeo de firma CMS corre con la evaluación de cadena suprimida, así que bytes rotos jamás tocan la red; RetrieveIntermediates sigue las URLs AIA caIssuers solo tras un fallo de cadena con OnlineRetrieval activo, y RetrieveCrls descarga las CRLs de distribution points a un store separado una vez que la cadena es confiable
Integridad, después confianza, después revocación: la red se toca solo entre los pasos que la necesitan, y una CRL colgada de un camino no confiable no prueba nada
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 jamás entran al trust store?

Porque las URLs vienen del certificado que se está validando, y quien las eligió fue 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 al store de trust anchors, cualquiera podría firmar con una llave hecha en casa, apuntar el AIA a su propio servidor y recibir un veredicto verde. RetrieveIntermediates por eso le pasa cada certificado parseado a CMS_add1_cert, que lo coloca en el set no confiable de esta estructura CMS puntual, y OpenSSL todavía tiene que construir un camino desde él hasta una ancla que usted configuró o que el store del sistema ya tenga. Hay una razón más silenciosa también: el argumento de certificados de CMS_verify no es un sustituto directo de los certificados incrustados en el CMS, así que agregar al propio CMS es el camino confiable

El bucle de recuperación es deliberadamente angosto. RetrieveIntermediates corre a lo más 4 rondas, cada una colectando las URLs caIssuers de todo certificado que ya esté en el CMS, y para apenas una ronda no agrega nada o el presupuesto de tiempo se agota. Una respuesta debe decodificar con d2i_X509 como un único certificado DER que consuma el cuerpo entero; los bytes de sobra se rechazan, y un bundle PKCS#7 solo-certificados servido desde una URL .p7c se salta en lugar de desempacarse. El método de acceso OCSP de la misma extensión AIA se ignora, porque este backend no habla OCSP. Del lado de la 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 chequeo de CRL de cadena completa, así que una CRL faltante o vencida cambia el RevocationStatus sin tocar jamás el TrustStatus

// Condensado de VerifyCmsWithSsl (FPdfCryptoSsl.pas); setup 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 confiable
  // verificar la cadena otra vez contra el mismo store de anclas
end;

if Options.CheckRevocation and (FetchSession <> nil) and
   (Result.TrustStatus = pcvsValid) then
  RetrievedCrls := RetrieveCrls(Cms, FetchSession);
// segundo store, 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, impuesto por un solo 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 por defecto es 15000 tanto en TPdfCmsVerifyOptions.Default como en TPadesTrustValidationOptions.Default; una sesión creada con 0 cae a 30000, y el reloj arranca una vez que la firma pasó, cubriendo todo request posterior
  • Requests: a lo más 8 por sesión, contados antes de intentar el transporte, así que un host muerto igual 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
Los techos duros de un TPdfCryptoFetchSession compartido por los pasos AIA y CRL de una llamada de verificación en PDFium Component: UrlRetrievalTimeoutMs por defecto 15000 ms con fallback de 30000 en cero, a lo más 8 requests por sesión, 1 MiB por respuesta y 4 MiB en total con las respuestas fallidas contando igual, y URLs de más de 2048 caracteres rechazadas
Los límites son constantes, no sugerencias: los bytes de una respuesta fallida igual drenan el presupuesto, y como cada firma y cada timestamp verifica por separado el peor caso crece con el conteo de firmas

La contabilidad es más estricta de lo que parece a primera vista. Los bytes recibidos de una respuesta fallida cuentan contra el total, así que un servidor que responde 404 con una página grande no puede drenar el presupuesto gratis. La lectura que cruza el límite por respuesta aborta la descarga en lugar de entregarle un cuerpo truncado al parser ASN.1, y un HTTP 200 con cuerpo vacío se rechaza de plano, porque el camino AIA indexaría Data[0] de un array vacío. Solo pasan las URLs http:// y https:// peladas, sin redirects, cookies, credenciales ni descubrimiento automático de proxy, mientras HTTPS mantiene sus chequeos 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 además es 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é un request WinHTTP con timeout aún puede escribir en su memoria?

Porque volver por timeout no cancela los callbacks que ya están en vuelo. El transporte Windows maneja WinHTTP en forma asíncrona y espera sobre un evento con el tiempo restante de la sesión, y cuando esa espera se rinde, el request todavía puede completar una lectura y señalizar después. Apunte la lectura asíncrona a un buffer del stack y esa finalización tardía escribe en un frame que para entonces pertenece a alguna función ajena. El fix es propiedad, no timing: el evento y el buffer de lectura de 16 KB viven en un record del heap con dos referencias, una del llamador y otra que solo libera el callback final HANDLE_CLOSING, así que el lado que termine último libera la memoria

Por qué un request WinHTTP con timeout todavía puede escribir en memoria: volver por timeout deja callbacks en vuelo, así que el PDFium Component apunta la lectura asíncrona a un record THttpState en el heap cuyo buffer de 16 KB y dos referencias, una del llamador y otra liberada por el callback final HANDLE_CLOSING, se liberan solo cuando el último lado termina
Una finalización tardía puede terminar su lectura después de que su espera se rindió; la propiedad en heap con dos referencias significa que esa escritura aterriza en memoria que sigue viva
type
  PHttpState = ^THttpState;
  THttpState = record
    References: LongInt;               // llamador + callback final HANDLE_CLOSING
    Event: THandle;
    Status, Count: DWORD;
    Buffer: array[0..16383] of Byte;   // las lecturas async aterrizan aquí, nunca en el stack
  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 del request, así que suelta la segunda referencia
if Status = HttpHandleClosing then
begin
  ReleaseState(State);
  Exit;
end;

Qué debe proveer libcurl en FPC Unix

Un resolver asíncrono y un build thread-safe, o la recuperación online queda apagada. En FPC Unix el transporte va por libcurl, la misma dependencia detrás del backend de timestamps libcurl para targets no Windows, y el binding rechaza cualquier librería cuya máscara de features carezca de CURL_VERSION_ASYNCHDNS o de CURL_VERSION_THREADSAFE. La razón es que CURLOPT_NOSIGNAL, que una librería dentro del proceso de otro debe fijar, combinado con un resolver síncrono significa que una lookup DNS puede simplemente sobrevivir al timeout. La segunda trampa es el apagado: curl_global_cleanup no espera a los threads DNS asíncronos, así que una vez que libcurl se inicializó el módulo queda mapeado hasta que el proceso termina, en lugar de dejar que un thread de fondo corra dentro de 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

Lo que el resultado garantiza y lo que no

Un RevocationStatus válido de este backend significa que se encontraron, configuraron o descargaron CRLs vigentes cubriendo toda la cadena, y que ninguna listó 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 una falla de red se ve exactamente igual que una CA que no publica nada. Note además que psvdNoCrlsConfigured describe solo las CRLs que usted configuró, así que con recuperación online es una pista, no un pronóstico de falla. Cuando una pista de auditoría debe ser reproducible sin acceso a red, deje NetworkPolicy en su default ptnpOffline: no se crea ninguna sesión de descarga y el backend jamás abre una conexión, lo que cuadra con el contrato offline del lado CryptoAPI descrito en chequeos de revocación de firmas PDF offline en Windows

El código de recuperación, los presupuestos y los bindings de transporte vienen como fuente con el PDFium Delphi component, así que puede confirmar exactamente qué URLs puede contactar una validación y cuánto puede descargar antes de habilitar ptnpOnline en un servidor que procesa documentos no confiables