PDFium Component localiza el inicio de una estructura CMS anidada a partir de la longitud de su contenido, nunca recorriendo los octetos de longitud hacia atrás, porque el byte justo anterior al contenido es el último octeto de longitud y no dice nada sobre cuántos lo preceden. CmsHeaderStart, en FPdfCms.pas, deriva en su lugar la longitud de cabecera desde ContentLen, que DER hace exacta, y eso es lo que evita que AddSignatureTimestampToCms corrompa todo CMS cuyo conjunto de certificados pase de 127 bytes
El escenario es la mejora PAdES B-T. Un atributo signature-time-stamp, el que ETSI EN 319 122-1 cláusula 5.3 define bajo el OID 1.2.840.113549.1.9.16.2.14, tiene que acabar en los unsignedAttrs del SignerInfo que describe RFC 5652 cláusula 5.3, y por definición solo se puede añadir después de que exista el valor de la firma, porque el token de sello de tiempo se calcula sobre ese valor. Así que el CMS ya está construido y ya está firmado cuando llega el token. Añadir un atributo cambia la longitud del SignerInfo, lo que cambia la longitud del SET signerInfos, luego la del SignedData, luego la del envoltorio EXPLICIT [0] y luego la del ContentInfo exterior. Toda cabecera que los contiene hay que reemitirla, y todo lo que no está en ese camino hay que trasladarlo byte a byte. El recorrido por B-LT y B-LTA cubre qué te compra el token; este artículo va de los cuatro bytes que hay delante del conjunto de certificados y que la reconstrucción no dejaba de calcular mal
¿Por qué añadir un sello de tiempo necesita el offset de etiqueta de un hermano?
Porque la reconstrucción reutiliza tal cual cuatro hermanos del SET signerInfos, y el reader informa de dónde está su contenido, no de dónde está su etiqueta. TDerReader.ReadTlv devuelve el byte de etiqueta, el offset del contenido, la longitud del contenido y el offset del siguiente TLV. Esa es la superficie correcta para descender por una estructura, pero para copiar un elemento entero necesitas el octeto donde está su etiqueta, y lo único que tiene quien llama es ContentOffs. CmsSliceTlv existe para salvar esa distancia: dado un offset y una longitud de contenido, devuelve etiqueta, octetos de longitud y contenido en un solo búfer, y AddSignatureTimestampToCms lo llama para el OID contentType, el INTEGER version, el SET digestAlgorithms, el SEQUENCE encapContentInfo y, cuando está, el conjunto certificates [0]
// Dentro de AddSignatureTimestampToCms: desciende y corta los hermanos tal cual
if not R.ReadTlv(Tag, CO, CL, CN) or (Tag<> Byte(asnSequence)) then
raise Exception.Create('CMS: encapContentInfo SEQUENCE expected');
SdEncapTlv:= CmsSliceTlv(CmsDer, CO, CL);
R.Position:= CN;
// certificados [0] opcionales
HasCerts:= (R.Position< SdEnd) and (CmsDer[R.Position]= $A0);
if HasCerts then
begin
if not R.ReadTlv(Tag, CO, CL, CN) or (Tag<> $A0) then
raise Exception.Create('CMS: certificates [0] malformed');
SdCertsTlv:= CmsSliceTlv(CmsDer, CO, CL); // etiqueta + octetos de longitud + contenido
R.Position:= CN;
end;
De esos cinco cortes, cuatro son diminutos: un OID de once bytes, un INTEGER de tres, un conjunto de algoritmos de digest de diecisiete y un encapContentInfo separado de trece. El conjunto de certificados es el que lleva el certificado del firmante y su cadena, y un certificado X.509 real ocupa como mínimo varios cientos de bytes. El conjunto de certificados es, por tanto, el único corte cuyos octetos de longitud están alguna vez en forma larga, y es justo el corte que el helper antiguo no sabía localizar
¿Por qué no se pueden recorrer los octetos de longitud DER hacia atrás?
Porque el número de octetos de longitud está guardado en el primero de ellos, y leyendo desde el contenido hacia atrás te encuentras antes 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: un octeto inicial con el bit 8 puesto cuyos bits 7 a 1 dan el número de octetos posteriores, seguidos de esos octetos que llevan la longitud como entero sin signo big-endian. Nada en la regla marca un octeto posterior como posterior. Su bit 8 es un bit de magnitud como cualquier otro, así que un recorrido hacia atrás que prueba el bit alto de Buf[ContentOffs- 1] está probando un bit de datos y luego leyendo sus siete bits bajos como si fueran una cuenta
// El helper antiguo, al que solo se le da el offset del contenido
function CmsHeaderStart(const Buf: TBytes; ContentOffs: Integer): Integer;
var
P, LenByte, LongLen: Integer;
begin
P:= ContentOffs- 1; // cae en el ÚLTIMO octeto de longitud
if P< 0 then
Exit(ContentOffs);
LenByte:= Buf[P];
if (LenByte and $80)= 0 then // solo tiene sentido para el PRIMERO
Result:= P- 1
else
begin
LongLen:= LenByte and $7F;
Result:= P- LongLen- 1;
end;
end;
// Cabecera de un conjunto de certificados de 1500 bytes: A0 82 05 DC
// Buf[ContentOffs- 1]= $DC -> bit 8 puesto, $DC and $7F= 92
// Result= ContentOffs- 94 (la etiqueta está en ContentOffs- 4)
Toma la cabecera de un conjunto de certificados que contiene 1500 bytes de certificados, A0 82 05 DC. El recorrido cae en DC, ve el bit alto puesto, extrae 92 de los siete bits bajos e informa de que la etiqueta está 94 bytes antes del contenido, cuando está 4 bytes antes. En un SignedData construido por BuildSignedData, el contenido del conjunto de certificados está a solo unas decenas de bytes del inicio del CMS, así que el offset calculado no era solo temprano, era negativo, y el código antiguo protegía ContentOffs- 1 contra bajar de cero, no su resultado final. CmsSliceTlv tomaba entonces un corte de noventa y tantos bytes más que el elemento, empezando antes del búfer, y el SignedData reconstruido llevaba ese corte donde debería haber estado su conjunto de certificados. Una longitud de tres octetos cuyo último octeto cayera por debajo de $80, por ejemplo A0 82 05 10, fallaba al revés: el recorrido lo tomaba por un octeto de forma corta y empezaba el corte en 05, dos bytes tarde y dentro de los octetos de longitud, sin etiqueta ninguna. El resultado estaba mal en ambos casos, solo variaba la dirección
¿Qué garantiza DER para que la derivación hacia delante sea exacta?
DER garantiza que la codificación de la longitud es 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 rellenar una longitud en forma larga con octetos cero a la izquierda. Bajo esa regla, una longitud de contenido por debajo de 128 tiene exactamente un octeto de longitud, y cualquier otra longitud tiene un octeto inicial más exactamente tantos octetos posteriores como bytes significativos necesite la longitud. Quien llama a CmsHeaderStart ya tiene ContentLen, porque ReadTlv acaba de devolverlo, así que la longitud de cabecera se puede calcular sin mirar un solo byte del búfer
// El helper que se publica: deriva la cabecera de la longitud del contenido.
// Bajo X.690 10.1 los octetos de longitud son función de ContentLen
function CmsHeaderStart(const Buf: TBytes; ContentOffs, ContentLen: Integer): Integer;
var
LengthOctets, Remaining: Integer;
begin
if ContentLen< 128 then
LengthOctets:= 1 // forma corta, X.690 8.1.3.4
else
begin
LengthOctets:= 1; // el octeto inicial, X.690 8.1.3.5
Remaining:= ContentLen;
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:= ContentOffs;
end;
Dos detalles hacen que esto sea seguro y no solo plausible. Primero, la suposición de que la entrada es DER se hace cumplir aguas arriba: TDerReader.TryReadTlvAt, sobre el que se construye ReadTlv, rechaza la forma indefinida, rechaza una longitud en forma larga cuyo primer octeto posterior sea cero y rechaza un único octeto posterior por debajo de $80. Un TLV que llega a CmsSliceTlv ya ha pasado esas comprobaciones, así que una longitud no mínima de estilo BER no puede llegar a la derivación y hacerla mentir. Segundo, el respaldo para un resultado negativo ahora protege la respuesta real, no un valor intermedio. Merece la pena decir que el reader conocía el offset de la etiqueta desde el principio: TDerTlv lleva tanto Offset como HeaderLength, y solo la superficie de ReadTlv con cuatro parámetros de salida los descarta. Devolverlos sería la interfaz más limpia a largo plazo; el arreglo publicado mantiene esa superficie intacta y hace que el helper sea correcto en sus propios términos
¿Por qué pasaban los tests de sello de tiempo con el bug dentro?
Porque todos los certificados de los fixtures eran lo bastante cortos para usar la forma corta, y el recorrido hacia atrás es correcto exactamente en ese caso. Tests.PadesTimestamp.pas construye su certificado de firmante con SetLength(SignerCertDer, 32) en un test y 64 en otro, relleno con una rampa de bytes. Un conjunto de certificados de 32 bytes se codifica como A0 20 y uno de 64 como A0 40, un solo octeto de longitud en cada caso. Recorrer hacia atrás desde el contenido cae en ese único octeto, su bit alto está a cero porque es el primero y único octeto de longitud, y el helper responde bien por el motivo equivocado. La batería de 1414 casos estaba en verde, el CMS con sello de tiempo se parseaba, el validador de etapa 1 informaba de B-T, y todas esas comprobaciones se ejecutaban contra un conjunto de certificados que ningún documento real ha contenido jamás
La regla general es la parte útil. Siempre que un camino de código dependa de cómo se codifica una longitud, el fixture tiene que cruzar la frontera de codificación, y en DER eso significa contenido de más de 127 bytes, que fuerza la forma larga, y mejor aún de más de 255 bytes, que fuerza un segundo octeto posterior. La misma disciplina se aplica al otro caso de aquella revisión en el que la autoverificación no veía una desviación DER: el SET OF sin ordenar en signedAttrs era invisible a una ida y vuelta del mismo origen por un motivo estructuralmente idéntico, el test solo ejercitaba entradas en las que el código equivocado y el correcto coinciden. El esbozo de abajo llama directamente al helper de corte, lo que implica exportarlo desde FPdfCms.pas para la build de test; la misma frontera es alcanzable a través de la superficie pública pasándole a BuildSignedData un certificado de cadena de cada tamaño y volviendo a parsear el resultado con sello de tiempo
// Fija la frontera: un corte a través de una cabecera larga debe empezar en la etiqueta
const
Lens: array[0..6] of Integer= (127, 128, 255, 256, 1500, 65535, 65536);
procedure TCmsSliceTests.HeaderStart_LongFormLengths;
var
W: TDerWriter;
Content, Tlv: TBytes;
I, Len: Integer;
begin
W:= TDerWriter.Create;
try
for I:= Low(Lens) to High(Lens) do
begin
Len:= Lens[I];
SetLength(Content, Len);
Tlv:= W.Wrap($A0, Content); // A0 7F / A0 81 80 / A0 82 05 DC ...
W.Clear;
// el contenido empieza justo tras la cabecera; el corte debe ser el TLV entero
Assert.AreEqual(Length(Tlv),
Length(CmsSliceTlv(Tlv, Length(Tlv)- Len, Len)),
'slice through header of a '+ IntToStr(Len)+ '-byte content');
end;
finally
W.Free;
end;
end;
Dónde sigue poniendo límites la reconstrucción
AddSignatureTimestampToCms está escrito para el CMS que emite BuildSignedData, y sus límites salen de ahí. El recorrido espera un único SignerInfo y reemite solo ese, así que un CMS ajeno con varios firmantes volvería con un solo firmante; reconoce un conjunto opcional certificates [0] pero no un conjunto crls [1], y un CMS que lleve uno falla a voces con la excepción signerInfos SET expected en vez de cortar mal en silencio. Los nuevos unsignedAttrs llevan un solo atributo, así que la regla de ordenación del SET OF de X.690 cláusula 11.6 se cumple de forma trivial y no necesita ordenación. Y la parte firmada queda intacta por construcción: el prefijo del SignerInfo hasta el OCTET STRING de la firma se copia tal cual, y por eso un validador que vuelve a calcular el digest de signedAttrs ve los mismos bytes antes y después de añadir el sello de tiempo. Cuando alguno aun así rechaza el documento, las causas suelen estar en otro sitio y merecen su propia lista de comprobación
El lector DER, el escritor, el constructor de CMS y esta inyección de sello de tiempo se distribuyen como código Pascal junto con el componente PDFium para Delphi, y un bug de esta forma es el argumento a favor de eso: cuando un SignedData reconstruido sale noventa bytes demasiado largo, quieres leer el helper que hizo el corte y la cláusula de X.690 que leyó mal, no un stack trace de una caja negra