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 meter CryptoAPI de por medio. Desde la v3.539.10 toda lectura anidada queda acotada por su elemento padre, el padding de ceros después del CMS se corta en la longitud que el propio CMS declara, y los object identifiers codifican su primer subidentificador combinado en base-128. La regla de límites y el fix de OID reemplazaron 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. El tooling de validación a largo plazo tiene que sacar el certificado del firmante y sus issuers de una firma existente antes de poder buscar data de revocación, un reporte de auditoría tiene que decir quién firmó, y un build Lazarus sobre Linux no tiene funciones de mensajería de Windows de las cuales valerse. Un parser en esa posición rara vez crashea con entrada mala. El modo de falla que duele es un conteo de certificados que incluye bytes de un vecino, un match del firmante hecho contra el campo equivocado, o un OID que se convierte en silencio en otro OID. Un pipeline de firmas construido encima de eso reporta disparates con total confianza
Sacar 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 los certificados del set en orden de codificación, GetSignatureSignerCertificateDER devuelve el certificado que produjo un SignerInfo dado, y GetSignatureCertificateChainLength / GetSignatureCertificateChainDER caminan desde ese firmante hacia el issuer más lejano que la firma misma cargue. Los índices son base 0. Guarde los resultados en AnsiString, y es que la librería los devuelve así por algo: un blob DER que pasa por string o por un TStrings atraviesa una conversión de juego de caracteres y regresa 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;
Dos cosas de esa salida piden cuidado. Un conteo de 0 no es un diagnóstico: un campo inexistente, un password equivocado, un blob que no es DER y un SignedData que simplemente omite el set opcional de certificados regresan todos como 0 o como string vacío, así que registre el nombre del campo junto al número. Y una cadena que termina antes de llegar a un certificado auto-emitido tampoco es un error. El constructor de cadenas solo usa certificados embebidos en la firma, así que los issuers restantes hay que ir a buscarlos a las direcciones que reporta GetCertificateIssuerURLs
¿Cuánto de /Contents es en realidad el CMS?
Solo el prefijo que el SEQUENCE externo declara pertenece al CMS, y PLTrimCMSPadding corta todo lo que viene 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 fijo primero, así que el slot 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; lo que no arranque con un SEQUENCE bien formado regresa vacío. Ese nivel superior es el único lugar donde los bytes sobrantes son legales, y la distinción importa para la siguiente sección: una regla estricta de «el elemento debe consumir todo el buffer» rechazaría todas las firmas del mundo real, mientras que una regla laxa aplicada a cada profundidad deja que los campos anidados lean bytes que no les pertenecen
¿Por qué un lector DER necesita el offset de fin del padre?
Un elemento anidado solo es válido si termina dentro de su padre, y chequear contra el final del buffer no demuestra eso. El DERReadTLV de bajo nivel en PDFlibASN1 acota cada elemento contra el string completo, que es el chequeo correcto para el objeto más externo y el equivocado para todo lo que vive debajo. Imagine un SignerInfo cuyo issuerAndSerialNumber declara 40 bytes mientras el issuer Name que lleva adentro reclama 60. Todos los bytes siguen dentro del buffer, así que un lector acotado por el buffer acepta el Name, lee el número de serie del digest algorithm que viene después, y luego compara ese par contra los certificados embebidos. Antes de la v3.539.10 el walker del CMS leía exactamente así. El fix es un wrapper chiquito que lleva la posición de fin 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: rechace arrancar una lectura
if (Offset < 1) or (Offset >= ParentEnd) then
Exit;
if not DERReadTLV(Data, Offset, Tag, Start, Len) then
Exit;
// Offset queda un byte después del elemento; no debe pasar del padre
Result := Offset <= ParentEnd;
end;
// cada nivel registra su propio fin y lo hereda hacia abajo:
// OuterEnd := fin de ContentInfo (RFC 5652 sección 3)
// ExplicitEnd := fin del content [0] EXPLICIT
// ContentEnd := fin de SignedData (RFC 5652 sección 5.1)
// SignerEnd / InnerEnd para SignerInfo e issuerAndSerialNumber
La unidad, PDFlibCMSRead, ahora hace pasar esos fines a través del ContentInfo, el wrapper [0] EXPLICIT, los campos de SignedData hasta signerInfos, el SignerIdentifier en sus dos formas, issuerAndSerialNumber y [0] subjectKeyIdentifier (RFC 5652 §5.3), y los campos de tbsCertificate leídos de cada certificado embebido al momento de matchear al firmante. Dentro del set de certificados y del set de signerInfos, un elemento que se pasa del fin del set corta el bucle: PLExtractCMSCertificates devuelve los certificados que ya había aceptado y nunca pega los bytes siguientes de crls o signerInfos al último. El match issuer-and-serial exige además las dos mitades, porque un número de serie solo es único dentro de un issuer
¿Por qué 2.999.3 salía como 1.15.3?
Los primeros dos arcos de un OID se combinan en un subidentificador, no en un byte, y ese subidentificador se codifica en base-128 igual que cualquier otro arco. X.690 §8.19.4 lo define como 40 * arc1 + arc2; el viejo DER_OID escribía ese valor con Byte(...), que solo es correcto hasta 127, el valor de 2.47. Para 2.999 la suma es 1079, el cast a byte conserva 55, y 55 se decodifica como 1.15, o sea que el identificador pasa a nombrar 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 prendido que se traga el siguiente arco. La mayoría de los identificadores de PKI (1.2.840..., 2.5.29..., 0.4.0...) nunca llegan a la frontera, y por eso el bug sobrevivió; los arcos joint-iso-itu-t desde 2.48 para arriba sí llegan. DER_OID sirve tanto al encoder de signed attributes como al matcher en DERFindExtensionByOID y al chequeo del content-type de SignedData, así que una codificación equivocada rompía la escritura y la 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 de 64 bits con signo. Un UInt64 carga Int64.MaxValue + 80 sin dar la vuelta, y el buffer de trabajo de diez bytes aloja los diez grupos de 7 bits que un valor de 64 bits necesita. Los vectores de test que valen la pena conservar son los que están a cada lado de la frontera: 2.47 debe quedarse en un byte, y 2.48 debe volverse dos
¿Qué garantiza el walker del CMS del lado de lectura?
PDFlibCMSRead garantiza estructura y nada más: devuelve los bytes que están donde RFC 5652 dice que deben estar y no verifica ninguna firma, digest ni período de validez. El walker acepta solo DER, así que DERReadTLV rechaza longitudes indefinidas y números de tag multi-byte, y un CMS codificado en BER que venga de un firmante fuera de estándar reporta cero certificados en lugar de una adivinanza 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 con el código que la posee, y eso arranca con los chequeos de cobertura de bytes descritos en firma PAdES y validación de ByteRange en Delphi y sigue con clasificar qué cambió después de que se firmó un PDF
La lección más general aplica a 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 atraviesa endurecer un parser de PDF en Pascal contra archivos maliciosos. La extracción de certificados, la construcción de cadenas y las APIs de validación a largo plazo que discutimos aquí vienen con losLab PDF Library for Delphi, para Delphi, C++Builder y Lazarus