Artículo técnico

Firmas CMS en Delphi: límites DER y arcos de OID

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

PLTrimCMSPadding de PDFlibPas lee el primer TLV del hex string reservado de /Contents, exige el tag $30 y corta el padding de ceros en la longitud que declara el SEQUENCE externo, y devuelve un resultado vacío cuando el buffer no arranca con un SEQUENCE bien formado
Los bytes sobrantes solo son legales en el nivel superior, donde el slot reservado debe quedar fijo para /ByteRange — las lecturas más profundas reciben en cambio la regla de acotación por el padre

¿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

PDFlibPas acota cada lectura DER anidada por su elemento padre: un issuer Name de 60 bytes dentro de un issuerAndSerialNumber de 40 bytes es aceptado por el viejo DERReadTLV acotado por el buffer, que luego lee el número de serie desde digestAlgorithm, mientras que ReadTLVWithin rechaza todo elemento que termine más allá de ParentEnd
Dentro del buffer es memory safety, dentro del padre es corrección — PDFlibPas hace pasar el offset de fin del padre por cada nivel del CMS para que una longitud hostil no pueda pedirle prestados los bytes del vecino
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.
DER_OID de PDFlibPas combina los primeros dos arcos del OID como 40 * arc1 + arc2 y codifica la suma en base-128 dentro de un UInt64, así que 2.999.3 se vuelve 06 03 88 37 03, mientras que el viejo cast a Byte conservaba 55 y decodificaba el identificador en silencio como 1.15.3
La mayoría de los arcos de PKI nunca llega a la frontera, y por eso el bug sobrevivió — los arcos joint-iso-itu-t desde 2.48 para arriba necesitan dos bytes, y el test conserva 2.47 y 2.48 a cada lado

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