PDF Library for Delphi (PDFlibPas) extrae los certificados dentro de una firma PDF con un recorrido DER puro sobre el CMS SignedData guardado en /Contents, sin que CryptoAPI intervenga en ningún momento. Desde v3.539.10, cada lectura anidada queda acotada por su elemento padre, el padding de ceros tras el CMS se recorta en la longitud que el propio CMS declara, y los OID codifican su primer subidentificador combinado en base-128. La regla de límites y el fix de OIDs sustituyeron ambos a código que daba respuestas equivocadas sin levantar ningún error, y la regla del padding evita que el lector más estricto rechace firmas reales
El lado de lectura importa más de lo que parece. Las herramientas de long-term validation tienen que sacar el certificado del firmante y sus emisores de una firma existente antes de poder consultar los datos de revocación, un informe de auditoría tiene que decir quién firmó, y una build con Lazarus en Linux no cuenta con las message functions de Windows para apoyarse. Un parser en esa posición rara vez revienta con entrada malformada. El modo de fallo que duele es otro: un conteo de certificados que incluye bytes de un elemento vecino, un emparejamiento del firmante contra el campo equivocado, o un OID que se convierte en silencio en otro OID distinto. Un pipeline de firmas montado encima de eso reporta disparates con total seguridad
Leer los certificados del firmante de un PDF firmado
Cinco métodos de TPDFlib cubren el lado de lectura, y todos reciben InputFile, Password, FieldName: cada llamada abre el archivo en modo solo lectura, responde, y lo vuelve a cerrar. GetSignatureEmbeddedCertificateCount y GetSignatureEmbeddedCertificateDER enumeran el conjunto de certificados en orden de codificación, GetSignatureSignerCertificateDER devuelve el certificado que produjo un SignerInfo concreto, y GetSignatureCertificateChainLength / GetSignatureCertificateChainDER caminan desde ese firmante hasta el emisor más lejano que la firma misma lleve incrustado. Los índices son zero-based. Guarde los resultados en AnsiString, que es justo como los devuelve la library: un blob DER que pase por string o por un TStrings atraviesa una conversión de juego de caracteres y vuelve corrompido
uses
SysUtils, Classes, PDFlibrary;
procedure SaveDer(const FileName: string; const Der: AnsiString);
var
Fs: TFileStream;
begin
Fs := TFileStream.Create(FileName, fmCreate);
try
if Der <> '' then
Fs.WriteBuffer(Der[1], Length(Der));
finally
Fs.Free;
end;
end;
const
Src = 'contract-signed.pdf';
Field = 'Signature1';
var
Pdf: TPDFlib;
ChainLen, I: Integer;
SignerDer, LastDer: AnsiString;
begin
Pdf := TPDFlib.Create;
try
WriteLn('Certificates in the CMS: ',
Pdf.GetSignatureEmbeddedCertificateCount(Src, '', Field));
SignerDer := Pdf.GetSignatureSignerCertificateDER(Src, '', Field, 0);
if SignerDer = '' then
raise Exception.Create('signer certificate missing or not matched');
SaveDer('signer.cer', SignerDer);
ChainLen := Pdf.GetSignatureCertificateChainLength(Src, '', Field, 0);
for I := 0 to ChainLen - 1 do
SaveDer(Format('chain-%d.cer', [I]),
Pdf.GetSignatureCertificateChainDER(Src, '', Field, 0, I));
if ChainLen > 0 then
begin
LastDer := Pdf.GetSignatureCertificateChainDER(Src, '', Field, 0, ChainLen - 1);
WriteLn('Next issuer, if any: ', Pdf.GetCertificateIssuerURLs(LastDer));
end;
finally
Pdf.Free;
end;
end;
Ese resultado pide dos precauciones. Un conteo de 0 no es un diagnóstico: un campo que no existe, una contraseña equivocada, un blob que no es DER y un SignedData que sencillamente omite el conjunto opcional de certificados devuelven todos 0 o una cadena vacía, así que registre el nombre del campo junto al número. Y una cadena que se corta antes de llegar a un certificado autoemitido tampoco es un error. El constructor de cadenas solo usa certificados incrustados en la firma, así que los emisores restantes hay que ir a buscarlos a las direcciones que reporta GetCertificateIssuerURLs
¿Cuánto de /Contents es realmente el CMS?
Solo el prefijo que declara el SEQUENCE exterior pertenece al CMS, y PLTrimCMSPadding corta todo lo que venga después. El firmante reserva el hex string de /Contents antes de que el CMS exista, porque el /ByteRange descrito en ISO 32000-1 §12.8.1 tiene que quedar fijado primero, así que el hueco se dimensiona con generosidad y la cola sin usar son ceros. PLTrimCMSPadding lee el primer TLV, exige el tag $30 y devuelve los bytes hasta el final de ese elemento; cualquier cosa que no empiece con un SEQUENCE bien formado vuelve vacía. Ese nivel superior es el único sitio donde los bytes finales son legales, y la distinción importa para la sección siguiente: una regla estricta de «el elemento debe consumir el buffer entero» rechazaría todas las firmas reales del mundo, mientras que una regla laxa aplicada a cualquier profundidad deja que los campos anidados lean bytes que no les pertenecen
¿Por qué un lector DER necesita el offset final del padre?
Un elemento anidado solo es válido si termina dentro de su padre, y comprobar contra el final del buffer no demuestra eso. El DERReadTLV de bajo nivel de PDFlibASN1 acota cada elemento contra la cadena completa, que es la comprobación correcta para el objeto más exterior y la equivocada para todo lo que hay por debajo. Imagine un SignerInfo cuyo issuerAndSerialNumber declara 40 bytes mientras el Name del emisor que lleva dentro reclama 60. Todos los bytes siguen en el buffer, así que un lector acotado al buffer acepta el Name, lee el número de serie del algoritmo de digest que viene detrás, y luego compara ese par contra los certificados incrustados. Antes de v3.539.10 el walker del CMS leía exactamente así. El fix es un pequeño wrapper que lleva la posición final del padre a cada lectura
function ReadTLVWithin(const Data: AnsiString; ParentEnd: Integer;
var Offset: Integer; out Tag: Byte; out Start, Len: Integer): Boolean;
begin
Result := False;
// no queda nada dentro del padre: rehusa iniciar la lectura
if (Offset < 1) or (Offset >= ParentEnd) then
Exit;
if not DERReadTLV(Data, Offset, Tag, Start, Len) then
Exit;
// Offset queda ahora uno más allá del elemento; no debe pasar del padre
Result := Offset <= ParentEnd;
end;
// cada nivel anota su propio final y lo pasa hacia abajo:
// OuterEnd := final de ContentInfo (RFC 5652 sección 3)
// ExplicitEnd := final del contenido [0] EXPLICIT
// ContentEnd := final de SignedData (RFC 5652 sección 5.1)
// SignerEnd / InnerEnd para SignerInfo e issuerAndSerialNumber
La unidad, PDFlibCMSRead, hace ahora pasar esos finales a través de ContentInfo, del wrapper [0] EXPLICIT, de los campos de SignedData hasta signerInfos, del SignerIdentifier en sus dos formas, issuerAndSerialNumber y [0] subjectKeyIdentifier (RFC 5652 §5.3), y de los campos tbsCertificate leídos de cada certificado incrustado al emparejar al firmante. Dentro del conjunto de certificados y del conjunto signerInfos, un elemento que se pasa del final del conjunto corta el bucle: PLExtractCMSCertificates devuelve los certificados que ya había aceptado y nunca pega a la última pieza los bytes de crls o signerInfos que vienen detrás. El emparejamiento emisor-y-número-de-serie exige además las dos mitades, porque un número de serie solo es único dentro de un emisor
¿Por qué 2.999.3 salía como 1.15.3?
Los dos primeros arcos de un OID se combinan en un solo subidentificador, no en un byte, y ese subidentificador se codifica en base-128 como cualquier otro arco. X.690 §8.19.4 lo define como 40 * arc1 + arc2; el DER_OID anterior escribía ese valor con un cast a Byte(...), correcto solo hasta 127, que es el valor de 2.47. Para 2.999 la suma es 1079, el cast a byte se queda con 55, y 55 se decodifica como 1.15, así que el identificador nombra en silencio otra rama del árbol. Los valores de 128 a 255 fallan de otra manera: emiten un byte con el bit de continuación activado que se traga el siguiente arco. La mayoría de los identificadores PKI (1.2.840..., 2.5.29..., 0.4.0...) nunca llegan al límite, y por eso el bug sobrevivió; los arcos joint-iso-itu-t desde 2.48 hacia arriba sí lo hacen. DER_OID sirve tanto al encoder de los signed attributes como al matcher de DERFindExtensionByOID y a la comprobación del content-type de SignedData, así que una codificación equivocada rompía escritura y búsqueda por igual
uses
SysUtils, PDFlibASN1;
function Hex(const S: AnsiString): string;
var
I: Integer;
begin
Result := '';
for I := 1 to Length(S) do
Result := Result + IntToHex(Byte(S[I]), 2) + ' ';
Result := Trim(Result);
end;
begin
WriteLn(Hex(DER_OID('2.999.3'))); // 06 03 88 37 03
WriteLn(Hex(DER_OID('2.47.1'))); // 06 02 7F 01
WriteLn(Hex(DER_OID('2.48.1'))); // 06 03 81 00 01
WriteLn(Hex(DER_OID('2.5.29.14'))); // 06 03 55 1D 0E
WriteLn(Hex(DER_OID('1.2.840.113549.1.7.2'))); // 06 09 2A 86 48 86 F7 0D 01 07 02
end.
El valor combinado se guarda en un UInt64 a propósito. DER_OID parsea los arcos en Int64, así que un segundo arco legal puede ser tan grande como Int64.MaxValue, y sumar 80 para arc1 = 2 desborda un entero con signo de 64 bits. Un UInt64 carga Int64.MaxValue + 80 sin dar la vuelta, y el buffer de trabajo de diez bytes guarda los diez grupos de 7 bits que necesita un valor de 64 bits. Los vectores de test que valen la pena conservar son los de ambos lados del límite: 2.47 debe seguir siendo un byte, y 2.48 debe convertirse en dos
¿Qué garantiza el walker CMS del lado de lectura?
PDFlibCMSRead garantiza estructura y nada más: devuelve bytes que están donde RFC 5652 dice que deben estar y no verifica ninguna firma, digest ni periodo de validez. El walker acepta solo DER, de modo que DERReadTLV rechaza longitudes indefinidas y números de tag multibyte, y un CMS codificado en BER de un firmante no conforme reporta cero certificados en lugar de una suposición parcial. Los attribute certificates y las demás alternativas de CertificateChoices se saltan porque nada aguas abajo puede usarlos. La verificación criptográfica se queda en el código que la posee, que empieza con las comprobaciones de cobertura de bytes descritas en firma PAdES y validación de ByteRange en Delphi y sigue con clasificar qué cambió después de firmar un PDF
La lección más general sirve para cualquier formato binario: «dentro del buffer» es una propiedad de memory safety, «dentro del padre» es una propiedad de corrección, y un parser necesita ambas. El mismo razonamiento sobre longitudes hostiles recorre el endurecimiento de un parser PDF en Pascal contra archivos maliciosos. La extracción de certificados, la construcción de cadenas y las APIs de long-term validation que se tratan aquí vienen con losLab PDF Library for Delphi, para Delphi, C++Builder y Lazarus