Artículo técnico

No recorras los octetos de longitud DER hacia atrás

Los cuatro bytes A0 82 05 DC abren un elemento [0] context-specific que lleva 1500 bytes de contenido. Leídos de izquierda a derecha son inequívocos: tag A0, un octeto de longitud inicial 82 que anuncia dos más, luego 05 DC. Leídos de derecha a izquierda, empezando en el primer byte de contenido, el primer octeto que te encuentras es DC. Tiene el bit alto a uno y sus siete bits bajos dicen 92, así que un helper que se fía de él reporta el tag 94 bytes antes del contenido. La distancia real es 4

Nadie camina hacia atrás a propósito. Pasa porque la mayoría de lectores DER devuelven el offset de contenido y la longitud de cada elemento, que es justo lo que necesitas para descender en él, mientras que copiar un elemento hermano sin cambios necesita el offset de su tag. Restar desde el offset de contenido parece un trabajo de dos líneas, y se mantiene correcto mientras toda longitud quepa en un solo octeto. La longitud de un conjunto de certificados real nunca cabe en uno

Qué le hace un sello de tiempo de firma a un CMS firmado

Añadir un sello de tiempo PAdES B-T obliga a re-codificar cada cabecera envolvente del CMS mientras cinco elementos hermanos tienen que pasar byte a byte. El atributo signature-time-stamp que define ETSI EN 319 122-1, cláusula 5.3, bajo el OID 1.2.840.113549.1.9.16.2.14 vive en el unsignedAttrs del SignerInfo de la cláusula 5.3 del RFC 5652, y el token RFC 3161 que lleva dentro se calcula sobre el valor de la firma. Así que el CMS ya está construido y ya está firmado cuando el token llega de la TSA. Un atributo extra alarga el SignerInfo, que alarga el SET signerInfos, luego SignedData, luego el envoltorio [0] EXPLICIT, luego el ContentInfo exterior

En PDFium Component esa secuencia son tres llamadas exportadas en FPdfCms.pas y FPdfTsa.pas. SignPadesBytes las ejecuta por ti cuando TPadesSignOptions.Level es plB_T o superior y TsaUrl está fijado, y lanza EPadesCrypto cuando se pide un nivel B-T con un TsaUrl vacío en lugar de escribir una firma B-B plana. Si construyes el CMS tú mismo con BuildSignedData, la cadena se ve así

// CmsDer guarda el ContentInfo que devolvió BuildSignedData.
// SignPadesBytes ejecuta los mismos tres pasos cuando Options.Level >= plB_T
var
  SigValue: TBytes;
  Ts: TPadesTimestamp;
begin
  if not ExtractSignatureValue(CmsDer, SigValue) then
    raise Exception.Create('CMS carries no signature value');
  // El messageImprint de la TSA se calcula sobre los octetos del valor de firma
  Ts:= RequestTimestamp(TsaUrl, SigValue, Nonce);
  // Re-codifica SignerInfo hasta ContentInfo, copia los hermanos sin tocar
  AddSignatureTimestampToCms(CmsDer, Ts.TokenDer);
end;

AddSignatureTimestampToCms hace pasar cinco elementos sin tocar: el OID contentType, el INTEGER de versión de SignedData, el SET digestAlgorithms, el SEQUENCE encapContentInfo y, cuando está presente, el conjunto certificates [0]. En el CMS que BuildSignedData emite con SHA-256, cuatro de ellos son diminutos: 11 bytes para el OID, 3 para la versión, 17 para el conjunto de algoritmos de digest, 13 para el encapContentInfo desacoplado. El conjunto de certificados lleva el certificado del firmante seguido de cada certificado de cadena que pasaras. Un certificado X.509 real llega a varios cientos de bytes. Esa es la única rebanada cuyos octetos de longitud usan siempre la forma larga

Qué reconstruye añadir un sello de tiempo PAdES B-T en un CMS: AddSignatureTimestampToCms copia contentType, version, digestAlgorithms, encapContentInfo y el conjunto de certificados byte a byte, el conjunto de certificados es la única rebanada suficientemente larga para salir de la forma de longitud corta, y cada cabecera envolvente desde SignerInfo hasta ContentInfo se vuelve a emitir
La porción firmada queda intacta por construcción porque el prefijo del SignerInfo hasta el OCTET STRING de la firma se copia verbatim, así que un validador que re-digesta signedAttrs ve bytes idénticos antes y después de que el sello aterrice

¿Por qué los octetos de longitud DER no se pueden recorrer hacia atrás?

El conteo de octetos de longitud está guardado en el primero de ellos, y un recorrido hacia atrás se encuentra primero con el último. X.690 cláusula 8.1.3.4 define la forma corta: un octeto, bit 8 a cero, bits 7 a 1 con una longitud de 0 a 127. La cláusula 8.1.3.5 define la forma larga. Su octeto inicial tiene el bit 8 a uno, y los bits 7 a 1 cuentan los octetos siguientes que guardan la longitud como entero unsigned big-endian. Nada marca un octeto posterior como posterior. Su bit 8 es un bit de magnitud como los otros siete. Así que un helper que comprueba el bit alto de Buf[ContentOffs- 1] está comprobando datos y después leyendo datos como un conteo

// El helper tentador, dado solo el offset de contenido. No lo expedas
function NaiveHeaderStart(const Buf: TBytes; ContentOffs: Integer): Integer;
var
  P, LenByte: Integer;
begin
  P:= ContentOffs- 1;            // aterriza en el ÚLTIMO octeto de longitud
  if P< 0 then
    Exit(ContentOffs);
  LenByte:= Buf[P];
  if (LenByte and $80)= 0 then   // solo significativo para el PRIMERO
    Result:= P- 1
  else
    Result:= P- (LenByte and $7F)- 1;
end;

// A0 82 05 DC (1500 bytes): Buf[ContentOffs- 1]= $DC, $DC and $7F= 92
//   Result= ContentOffs- 94, el tag está en ContentOffs- 4
// A0 82 05 10 (1296 bytes): $10 tiene el bit 8 a cero, se lee como forma corta
//   Result= ContentOffs- 2, que apunta al 05 dentro de los octetos de longitud

Ambas direcciones de fallo aparecen con tamaños corrientes. Un conjunto de certificados de 1500 bytes acaba su cabecera en DC, y el helper arranca la rebanada 90 bytes antes de tiempo. Un conjunto de 1296 bytes acaba en 10, el helper lo toma por una longitud de forma corta, y la rebanada arranca dos bytes tarde y sin tag alguno. Que una cadena dada caiga en el primer o el segundo caso no depende de más que del byte bajo de su longitud total

El caso temprano puede además salirse por delante del buffer. Para un CMS dispuesto como lo dispone BuildSignedData, con toda longitud envolvente ocupando dos octetos, el contenido del conjunto de certificados empieza 60 bytes dentro: 4 por la cabecera del ContentInfo, 11 por el OID, 4 cada una por las cabeceras del [0] y del SignedData, luego 3, 17 y 13 por los hermanos pequeños y 4 por la cabecera del conjunto de certificados. Resta 94 a 60 y el helper devuelve -34. Su único guard está en ContentOffs- 1, así que el resultado negativo va directo a la copia

Por qué los octetos de longitud DER no se pueden recorrer hacia atrás: leer A0 82 05 DC desde el final cae en el último octeto DC, cuyo bit alto puesto da una cuenta falsa de 92 y coloca la etiqueta 94 octetos antes, mientras que A0 82 05 10 falla al revés y empieza el corte dos octetos tarde dentro de los octetos de longitud
El antiguo CmsHeaderStart protegía la resta intermedia en vez de su resultado final, así que un corte podía incluso empezar antes del búfer, y el SignedData reconstruido llevaba ese corte donde pertenecía su conjunto de certificados

Lo que fija la cláusula 10.1 de X.690

DER convierte la codificación de longitud en una función pura de la longitud. X.690 cláusula 10.1 restringe DER a la forma definida y exige el número mínimo de octetos, lo que elimina las dos libertades que permite BER: la forma indefinida, y una longitud de forma larga rellenada con octetos cero delante. Bajo esa regla una longitud de contenido por debajo de 128 toma siempre exactamente un octeto de longitud. Cualquier otra longitud toma un octeto inicial más exactamente tantos octetos posteriores como bytes significativos tenga el valor. Eso da 81 80 para 128. En 256 la cabecera crece a 82 01 00, y 65536 necesita 83 01 00 00. El código que ya tiene la longitud de contenido puede computar el tamaño de cabecera sin leer ni un byte del buffer

La derivación hacia delante que garantiza DER: una longitud de contenido de 127 se codifica como A0 7F, 128 como A0 81 80, 255 como A0 81 FF, 256 como A0 82 01 00 y 1500 como A0 82 05 DC, así que la longitud de cabecera sale de ContentLen sin más y TryReadTlvAt ya ha rechazado toda forma BER no mínima
Los fixtures construidos con certificados de 32 y 64 bytes se quedaban dentro de la forma corta, donde el recorrido hacia atrás responde bien por el motivo equivocado, y por eso la batería de fronteras ahora cruza 127, 128, 255 y 256 bytes

La derivación vale lo que valga la afirmación de que la entrada es de verdad DER. TDerReader.TryReadTlvAt hace cumplir esa afirmación antes de que ninguna rebanada ocurra. Rechaza un octeto de longitud inicial de $80 (la forma indefinida), una longitud de forma larga cuyo primer octeto posterior sea cero, un único octeto posterior por debajo de $80 que debería haber sido una forma corta, y más de cuatro octetos posteriores. ReadTlv está construido sobre TryReadTlvAt, así que cada elemento que AddSignatureTimestampToCms recorre ya pasó esas comprobaciones. Una longitud rellenada estilo BER jamás llega a la aritmética

Dentro de AddSignatureTimestampToCms el offset de tag de cada hermano se deriva de su offset de contenido y longitud de contenido de esta manera. Cuando el inicio derivado cae antes del principio del buffer, señal de que los offsets no pueden ir juntos, el procedimiento lanza una excepción con el mensaje CMS: TLV header precedes the buffer start y no produce CMS alguno. Una reconstrucción malformada que aún parsea es mucho más difícil de rastrear que una excepción en el punto donde los offsets dejaron de concordar

La derivación lleva una suposición más: el identificador ocupa un solo octeto. X.690 permite identificadores multi-octeto para números de tag de 31 en adelante, y TryReadTlvAt los parsea. El SignedData de CMS nunca usa uno, ya que sus tags son SEQUENCE, SET, INTEGER y OID universales más el [0] y el [1] context-specific, así que la suposición se sostiene para cada elemento que la reconstrucción del sello toca

Copiar elementos hermanos en tu propio código DER

Si escribes código contra TDerReader, sáltate la derivación entera. PeekTlv y ReadNextTlv rellenan un record TDerTlv cuyo Offset ya apunta al tag y cuyo HeaderLength es el tamaño medido de tag más octetos de longitud, y EncodedBytes devuelve el elemento de Offset a NextOffset. Esa vía también se las apaña con identificadores multi-octeto, que ninguna aritmética de longitudes puede recuperar. El ReadTlv de cuatro parámetros de salida es cómodo para descender. Solo reporta el lado de contenido, eso sí, así que úsalo cuando necesites contenido y la forma de record cuando necesites el elemento entero

// Cada hijo de SignedData como un TLV completo: tag, octetos de longitud, contenido.
// TDerTlv.Offset apunta al tag, así que no interviene aritmética de cabecera
function SignedDataChildren(const CmsDer: TBytes): TDerByteArrays;
var
  Root, Ci, Explicit0, Sd: TDerReader;
  Tlv: TDerTlv;
begin
  Result:= nil;
  Root.Initialize(CmsDer);
  if not Root.ReadNextTlv(Tlv) or not Root.EnterConstructed(Tlv, Ci) then
    raise Exception.Create('ContentInfo SEQUENCE expected');
  if not Ci.SkipTlv then                            // contentType OID
    raise Exception.Create('contentType OID expected');
  if not Ci.ReadNextTlv(Tlv) or not Ci.EnterConstructed(Tlv, Explicit0) then
    raise Exception.Create('[0] EXPLICIT content expected');
  if not Explicit0.ReadNextTlv(Tlv) or not Explicit0.EnterConstructed(Tlv, Sd) then
    raise Exception.Create('SignedData SEQUENCE expected');
  while not Sd.AtEnd do
  begin
    if not Sd.ReadNextTlv(Tlv) then
      raise Exception.Create('SignedData child malformed');
    SetLength(Result, Length(Result)+ 1);
    Result[High(Result)]:= Sd.EncodedBytes(Tlv);   // e.g. A0 82 05 DC ...
  end;
end;

Para los casos en que alguna otra API no te da nada más que un offset de contenido y una longitud, la derivación hacia delante es corta. DerHeaderStart, mostrado con su comprobación de fronteras en la sección siguiente, es código tuyo; refleja la regla de la cláusula 10.1 y devuelve -1 para offsets que no pueden ser consistentes, así que quien llama tiene un valor que puede rechazar en lugar de un byte de inicio de aspecto plausible

¿Por qué los certificados de test cortos esconden el error?

Un certificado marcador pequeño mantiene el conjunto de certificados en la forma corta, y el recorrido hacia atrás es correcto exactamente para ese caso. Un conjunto de 32 bytes se codifica como A0 20 y uno de 64 como A0 40. Andando hacia atrás desde el contenido aterrizas en ese único octeto de longitud, y su bit alto está a cero porque es el primero y único. Respuesta correcta, motivo equivocado. Un test de sello de tiempo construido sobre certificados así de pequeños puede re-parsear el resultado, encontrar el atributo, reportar B-T, y jamás empujar un conjunto de certificados de tamaño realista por la rebanada

Toda ruta de código que dependa de cómo esté codificada una longitud necesita entrada de test que cruce la frontera de codificación. Para DER significa contenido de 128 bytes o más, que fuerza la forma larga, y de 256 bytes o más, que fuerza un segundo octeto posterior. La comprobación de abajo compara DerHeaderStart contra la cabecera que el lector midió al parsear, en ambas fronteras y en el caso de 1500 bytes de la apertura. Un SET OF sin ordenar en signedAttrs se cuela en los round trips de mismo origen por la misma razón estructural: el test solo alimenta entradas en las que el código equivocado y el correcto concuerdan

// Tu propio helper para APIs que solo reportan offset de contenido y longitud.
// Asume un identificador de un octeto, cierto para cada tag de CMS SignedData
function DerHeaderStart(ContentOffs, ContentLen: Integer): Integer;
var
  LengthOctets, Remaining: Integer;
begin
  LengthOctets:= 1;                  // forma corta, X.690 8.1.3.4
  if ContentLen>= 128 then
  begin
    Remaining:= ContentLen;          // forma larga, X.690 8.1.3.5
    while Remaining> 0 do
    begin
      Inc(LengthOctets);             // uno por byte significativo
      Remaining:= Remaining shr 8;
    end;
  end;
  Result:= ContentOffs- LengthOctets- 1;
  if Result< 0 then
    Result:= -1;                     // offsets inconsistentes: deja que el llamador lance
end;

// Comprobación de fronteras, escrita como tu propio test: el offset de tag derivado
// debe igualar al offset que TDerReader midió al parsear la cabecera
procedure CheckHeaderDerivation;
const
  Lens: array[0..6] of Integer= (127, 128, 255, 256, 1500, 65535, 65536);
var
  W: TDerWriter;
  R: TDerReader;
  Tlv: TDerTlv;
  Content: TBytes;
  I: Integer;
begin
  W:= TDerWriter.Create;
  try
    for I:= Low(Lens) to High(Lens) do
    begin
      SetLength(Content, Lens[I]);
      R.Initialize(W.Wrap($A0, Content));   // A0 7F / A0 81 80 / A0 82 05 DC ...
      W.Clear;
      if not R.PeekTlv(Tlv) then
        raise Exception.CreateFmt('unreadable TLV for %d bytes', [Lens[I]]);
      if DerHeaderStart(Tlv.ContentOffset, Tlv.ContentLength)<> Tlv.Offset then
        raise Exception.CreateFmt('header start wrong for %d bytes', [Lens[I]]);
    end;
  finally
    W.Free;
  end;
end;

La misma frontera es alcanzable de punta a punta a través de las funciones exportadas. Dale a BuildSignedData certificados de cadena cuyo tamaño combinado deje el conjunto de certificados justo por debajo y justo por encima de 128 y 256 bytes, ejecuta AddSignatureTimestampToCms sobre cada resultado, después recorre la salida con SignedDataChildren y compara el elemento del conjunto de certificados byte a byte con el del CMS de antes de añadir el sello

Qué acepta AddSignatureTimestampToCms

AddSignatureTimestampToCms está escrito para el CMS que emite BuildSignedData: un SignerInfo, un conjunto certificates [0] opcional, nada de crls [1] y aún nada de unsignedAttrs. Aliméntalo con un SET signerInfos que lleve más de un SignerInfo y lanza en lugar de quedarse con el primer firmante y soltar el resto. Un CMS que lleva un conjunto crls [1] se para en la excepción CMS: signerInfos SET expected, porque el elemento detrás de los certificados no es el SET que el recorrido busca. Ambos rechazos ocurren antes de escribir nada de vuelta en CmsDer

El unsignedAttrs nuevo guarda exactamente un atributo, así que la regla de ordenación SET OF de la cláusula 11.6 de X.690 se satisface sin ordenar. La parte firmada del SignerInfo, desde su versión hasta el OCTET STRING de la firma, se copia como un bloque, lo que significa que un validador que re-digesta signedAttrs ve los mismos bytes antes y después de añadir el token. Cuando aun así un validador rechaza el documento, las causas suelen sentarse en otro sitio y están cubiertas en esta checklist de motivos de rechazo PAdES. Lo que el token te compra con el tiempo, y cómo B-LT y B-LTA se apoyan en él, está en el recorrido de sellos RFC 3161 y el DSS

El lector y escritor DER de FPdfAsn1.pas, el constructor CMS y el paso de sello de tiempo en FPdfCms.pas son todo código Pascal que viene con el PDFium Component for Delphi. Cuando un SignedData reconstruido se niega a parsear en un validador, puedes recorrer paso a paso el código que cortó cada elemento y comprobarlo contra la cláusula X.690 que cita