PDFium Component ubica 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 antes del contenido es el último octeto de longitud y no dice nada sobre cuántos lo preceden. CmsHeaderStart, en FPdfCms.pas, deriva en cambio la longitud de la cabecera de ContentLen, que DER vuelve exacta, y eso es lo que impide 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 la cláusula 5.3 de ETSI EN 319 122-1 define bajo el OID 1.2.840.113549.1.9.16.2.14, tiene que quedar en el unsignedAttrs del SignerInfo que describe la cláusula 5.3 de RFC 5652, y por definición solo se puede añadir después de que exista el valor de 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, después la de SignedData, después la del envoltorio EXPLICIT [0] y después la del ContentInfo externo. Cada cabecera contenedora tiene que reemitirse, y todo lo que no está en ese camino tiene que trasladarse byte por byte. El recorrido de B-LT y B-LTA cubre lo que le compra el token; este artículo va de los cuatro bytes delante del conjunto de certificados que la reconstrucción seguía escribiendo 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 dónde está su contenido, no 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 interfaz correcta para descender en una estructura, pero para copiar un elemento entero usted necesita el octeto donde está su etiqueta, y lo único que tiene quien llama es ContentOffs. CmsSliceTlv existe para tender ese puente: dado un offset de contenido y una longitud, devuelve la etiqueta, los octetos de longitud y el contenido como un solo búfer, y AddSignatureTimestampToCms lo llama para el OID contentType, el INTEGER version, el SET digestAlgorithms, la SEQUENCE encapContentInfo y, cuando está presente, el conjunto certificates [0]
// Dentro de AddSignatureTimestampToCms: descender y copiar 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;
// certificates [0] opcional
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 esas cinco copias, cuatro son diminutas: 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 mide varios cientos de bytes como mínimo. El conjunto de certificados es por lo tanto la única copia cuyos octetos de longitud están alguna vez en forma larga, y es la copia que el helper viejo no podía ubicar
¿Por qué los octetos de longitud DER no se pueden recorrer hacia atrás?
Porque la cantidad de octetos de longitud está guardada en el primero de ellos, y leyendo desde el contenido hacia atrás uno se topa primero con el último. La cláusula 8.1.3.4 de X.690 define la forma corta: un octeto con el bit 8 en cero y los 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 en uno cuyos bits 7 a 1 dan la cantidad de octetos siguientes, seguidos de esos octetos que llevan la longitud como entero sin signo big-endian. Nada en la regla marca a un octeto siguiente como siguiente. 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 después lee sus siete bits bajos como una cantidad
// El helper viejo, 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 en uno, $DC and $7F= 92
// Result= ContentOffs- 94 (la etiqueta está en ContentOffs- 4)
Tome la cabecera de un conjunto de certificados que tiene 1500 bytes de certificados, A0 82 05 DC. El recorrido cae en DC, ve un bit alto en uno, extrae 92 de los siete bits bajos e informa la etiqueta 94 bytes antes del contenido, cuando está 4 bytes antes. En un SignedData construido por BuildSignedData, el contenido del conjunto de certificados está apenas unas docenas de bytes dentro del CMS, así que el offset calculado no solo quedaba corto sino negativo, y el código viejo protegía a ContentOffs- 1 de caer bajo cero, no a su resultado final. CmsSliceTlv tomaba entonces una copia noventa y pico bytes más larga que el elemento, empezando antes del búfer, y el SignedData reconstruido llevaba esa copia donde debería haber estado su conjunto de certificados. Una longitud de tres octetos cuyo último octeto cayera por debajo de $80, digamos A0 82 05 10, fallaba al revés: el recorrido la tomaba por un octeto de forma corta y arrancaba la copia en 05, dos bytes tarde y dentro de los octetos de longitud, sin etiqueta alguna. El resultado estaba mal de cualquiera de las dos formas; solo variaba la dirección
¿Qué garantiza DER que vuelve exacta la derivación hacia adelante?
DER garantiza que la codificación de la longitud es una función pura de la longitud. La cláusula 10.1 de X.690 restringe DER a la forma definida y exige la cantidad mínima de octetos, lo que elimina las dos libertades que permite BER: la forma indefinida y rellenar una longitud de forma larga con octetos cero a la izquierda. Bajo esa regla, una longitud de contenido menor que 128 tiene exactamente un octeto de longitud, y cualquier otra longitud tiene un octeto inicial más exactamente tantos octetos siguientes como bytes significativos necesite la longitud. Quien llama a CmsHeaderStart ya tiene ContentLen, porque ReadTlv acaba de devolverla, así que la longitud de la cabecera se puede calcular sin mirar un solo byte del búfer
// El helper publicado: derivar 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 de forma larga cuyo primer octeto siguiente sea cero y rechaza un único octeto siguiente por debajo de $80. Un TLV que llega a CmsSliceTlv ya pasó esas comprobaciones, así que una longitud no mínima 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. Vale 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 interfaz de ReadTlv con cuatro parámetros de salida los descarta. Devolverlos sería la interfaz más limpia a largo plazo; el fix publicado mantiene esa interfaz intacta y hace correcto al helper en sus propios términos
¿Por qué pasaban las pruebas del sello de tiempo con el bug presente?
Porque todos los certificados de los fixtures eran lo bastante cortos para usar la forma corta, y el recorrido hacia atrás es correcto justo en ese caso. Tests.PadesTimestamp.pas arma su certificado de firmante con SetLength(SignerCertDer, 32) en una prueba y 64 en otra, rellenados 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 cada uno. Recorrer hacia atrás desde el contenido cae en ese único octeto, su bit alto está en cero porque es el primero y el único octeto de longitud, y el helper responde bien por el motivo equivocado. La suite de 1414 casos estaba verde, el CMS con sello de tiempo se parseaba, el validador de etapa 1 reportaba B-T, y cada una de esas comprobaciones corría contra un conjunto de certificados que ningún documento real ha tenido 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 para DER eso significa contenido de más de 127 bytes, lo que fuerza la forma larga, y de ser posible también de más de 255 bytes, lo que fuerza un segundo octeto siguiente. La misma disciplina aplica al otro caso de esa revisión donde la autoverificación no podía ver una desviación de DER: el SET OF sin ordenar en signedAttrs era invisible para un round-trip de mismo origen por un motivo estructuralmente idéntico, la prueba solo ejercitaba entradas en las que el código equivocado y el correcto coinciden. El esbozo de abajo llama directamente al helper de copia, lo que implica exportarlo desde FPdfCms.pas para la compilación de pruebas; la misma frontera es alcanzable a través de la superficie pública si usted le entrega a BuildSignedData un certificado de cadena de cada tamaño y vuelve a parsear el resultado con sello de tiempo
// Fijar la frontera: una copia a través de una cabecera de forma 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 después de la cabecera; la copia debe ser el TLV completo
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 trazando sus límites la reconstrucción
AddSignatureTimestampToCms está escrito para el CMS que emite BuildSignedData, y sus límites se siguen de eso. El recorrido espera un solo SignerInfo y reemite solo ese, así que un CMS ajeno con varios firmantes volvería con un solo firmante; reconoce un conjunto certificates [0] opcional pero no un conjunto crls [1], y un CMS que lleve uno falla a gritos con la excepción signerInfos SET expected en vez de cortar mal la copia en silencio. El nuevo unsignedAttrs tiene un solo atributo, así que la regla de orden del SET OF de la cláusula 11.6 de X.690 se cumple de forma trivial y no necesita ordenamiento. Y la porción firmada queda intacta por construcción: el prefijo de SignerInfo hasta el OCTET STRING de la firma se copia tal cual, y por eso un validador que recalcula el digest de signedAttrs ve los mismos bytes antes y después de añadir el sello de tiempo. Cuando aun así rechaza el documento, las causas suelen estar en otro lado y merecen su propia lista de verificación
El reader de DER, el writer, el constructor de CMS y esta inyección de sello de tiempo vienen todos como código fuente Pascal con el componente PDFium Delphi, y un bug de esta forma es el argumento a favor de eso: cuando un SignedData reconstruido sale con noventa bytes de más, usted quiere leer el helper que cortó la copia y la cláusula de X.690 que malinterpretó, no un stack trace de una caja negra