Articolo tecnico

Mai percorrere a ritroso gli octet di lunghezza DER

PDFium Component individua l'inizio di una struttura CMS annidata dalla lunghezza del contenuto, mai percorrendo a ritroso gli octet di lunghezza, perché il byte immediatamente prima del contenuto è l'ultimo octet di lunghezza e non dice nulla su quanti lo precedano. CmsHeaderStart in FPdfCms.pas ricava invece la lunghezza dell'header da ContentLen, cosa che DER rende esatta, ed è questo a impedire che AddSignatureTimestampToCms corrompa ogni CMS il cui insieme di certificati superi i 127 byte

Lo scenario è l'upgrade PAdES B-T. Un attributo signature-time-stamp, quello che la clausola 5.3 di ETSI EN 319 122-1 definisce sotto l'OID 1.2.840.113549.1.9.16.2.14, deve finire negli unsignedAttrs del SignerInfo descritto nella clausola 5.3 di RFC 5652, e per definizione può essere aggiunto solo dopo che il valore della firma esiste, perché il token di timestamp è calcolato su quel valore. Quindi il CMS è già costruito e già firmato quando arriva il token. Aggiungere un attributo cambia la lunghezza del SignerInfo, che cambia quella del SET signerInfos, poi di SignedData, poi del wrapper EXPLICIT [0], poi del ContentInfo esterno. Ogni header che li racchiude va riemesso, e tutto ciò che non sta su quel percorso va portato attraverso byte per byte. La panoramica su B-LT e B-LTA racconta che cosa ti dà il token; questo articolo parla dei quattro byte davanti all'insieme dei certificati che la ricostruzione continuava a sbagliare

Perché aggiungere un timestamp richiede l'offset del tag di un fratello?

Perché la ricostruzione riusa alla lettera quattro fratelli del SET signerInfos, e il reader riporta dove sta il loro contenuto, non dove sta il loro tag. TDerReader.ReadTlv restituisce il byte del tag, l'offset del contenuto, la lunghezza del contenuto e l'offset del TLV successivo. È la superficie giusta per scendere dentro una struttura, ma per copiare un elemento intero serve l'octet in cui sta il suo tag, e l'unica cosa che un chiamante ha in mano è ContentOffs. CmsSliceTlv esiste per colmare quel divario: dato un offset e una lunghezza di contenuto restituisce tag, octet di lunghezza e contenuto in un unico buffer, e AddSignatureTimestampToCms lo chiama per l'OID contentType, l'INTEGER version, il SET digestAlgorithms, la SEQUENCE encapContentInfo e, quando presente, l'insieme certificates [0]

// Dentro AddSignatureTimestampToCms: scendi, ritaglia i fratelli alla lettera
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;
// certificati opzionali [0]
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);   // tag + octet di lunghezza + contenuto
  R.Position:= CN;
end;

Di quei cinque ritagli, quattro sono minuscoli: un OID da undici byte, un INTEGER da tre, un insieme di algoritmi di digest da diciassette, un encapContentInfo detached da tredici. L'insieme dei certificati è quello che porta il certificato del firmatario e la sua catena, e un vero certificato X.509 arriva come minimo a diverse centinaia di byte. L'insieme dei certificati è quindi l'unico ritaglio i cui octet di lunghezza stiano mai in forma lunga, ed è il ritaglio che il vecchio helper non riusciva a individuare

Che cosa ricostruisce l'aggiunta di un timestamp PAdES B-T in un CMS: CmsSliceTlv copia alla lettera contentType, version, digestAlgorithms, encapContentInfo e l'insieme dei certificati, l'insieme dei certificati è l'unico ritaglio abbastanza lungo da uscire dalla forma breve della lunghezza, e ogni header che li racchiude dal SignerInfo fino al ContentInfo viene riemesso
La parte firmata resta intatta per costruzione perché il prefisso del SignerInfo fino all'OCTET STRING della firma viene copiato alla lettera, quindi un validatore che ricalcola il digest dei signedAttrs vede byte identici prima e dopo l'inserimento del timestamp

Perché gli octet di lunghezza DER non si possono percorrere a ritroso?

Perché il numero di octet di lunghezza è memorizzato nel primo di essi, e leggendo dal contenuto all'indietro si incontra per primo l'ultimo. La clausola 8.1.3.4 di X.690 definisce la forma breve: un octet, bit 8 a zero, bit da 7 a 1 che contengono una lunghezza da 0 a 127. La clausola 8.1.3.5 definisce la forma lunga: un octet iniziale con il bit 8 a uno i cui bit da 7 a 1 danno il numero di octet successivi, seguiti da quegli octet che portano la lunghezza come intero unsigned big-endian. Nulla nella regola marca un octet successivo come successivo. Il suo bit 8 è un bit di grandezza come tutti gli altri, quindi un percorso a ritroso che testa il bit alto di Buf[ContentOffs- 1] sta testando un bit di dato e poi legge i suoi sette bit bassi come se fossero un conteggio

// Il vecchio helper, a cui veniva dato solo l'offset del contenuto
function CmsHeaderStart(const Buf: TBytes; ContentOffs: Integer): Integer;
var
  P, LenByte, LongLen: Integer;
begin
  P:= ContentOffs- 1;            // finisce sull'ULTIMO octet di lunghezza
  if P< 0 then
    Exit(ContentOffs);
  LenByte:= Buf[P];
  if (LenByte and $80)= 0 then   // ha senso solo per il PRIMO
    Result:= P- 1
  else
  begin
    LongLen:= LenByte and $7F;
    Result:= P- LongLen- 1;
  end;
end;

// Header di un insieme di certificati da 1500 byte:  A0 82 05 DC
//   Buf[ContentOffs- 1]= $DC  -> bit 8 a uno, $DC and $7F= 92
//   Result= ContentOffs- 94     (il tag sta a ContentOffs- 4)

Prendi l'header di un insieme di certificati che contiene 1500 byte, A0 82 05 DC. Il percorso finisce su DC, vede il bit alto a uno, estrae 92 dai sette bit bassi e segnala il tag 94 byte prima del contenuto, quando invece sta 4 byte prima. In un SignedData costruito da BuildSignedData, il contenuto dell'insieme dei certificati sta a poche decine di byte dall'inizio del CMS, quindi l'offset calcolato non era solo anticipato ma negativo, e il vecchio codice proteggeva ContentOffs- 1 dal finire sotto zero, non il suo risultato finale. CmsSliceTlv prendeva poi un ritaglio lungo una novantina di byte in più dell'elemento, che iniziava prima del buffer, e il SignedData ricostruito portava quel ritaglio dove avrebbe dovuto avere il suo insieme di certificati. Una lunghezza su tre octet il cui ultimo octet cadeva sotto $80, per esempio A0 82 05 10, falliva dall'altra parte: il percorso lo prendeva per un octet di forma breve e faceva iniziare il ritaglio da 05, due byte in ritardo e dentro gli octet di lunghezza, senza alcun tag. L'esito era sbagliato in entrambi i casi, cambiava solo la direzione

Perché gli octet di lunghezza DER non si possono percorrere a ritroso: leggere A0 82 05 DC dalla fine finisce sull'ultimo octet DC, il cui bit alto a uno produce un conteggio fasullo di 92 e colloca il tag 94 octet troppo presto, mentre A0 82 05 10 fallisce dall'altra parte e fa iniziare il ritaglio due octet in ritardo dentro gli octet di lunghezza
Il vecchio CmsHeaderStart proteggeva la sottrazione intermedia invece del suo risultato finale, quindi un ritaglio poteva persino iniziare prima del buffer, e il SignedData ricostruito portava quel ritaglio dove apparteneva il suo insieme di certificati

Che cosa garantisce DER da rendere esatta la derivazione in avanti?

DER garantisce che la codifica della lunghezza sia una funzione pura della lunghezza. La clausola 10.1 di X.690 limita DER alla forma definita e richiede il numero minimo di octet, il che elimina le due libertà che BER concede: la forma indefinita e il riempimento di una lunghezza in forma lunga con octet zero iniziali. Con quella regola una lunghezza di contenuto sotto 128 ha esattamente un octet di lunghezza, e qualsiasi altra lunghezza ha un octet iniziale più tanti octet successivi quanti sono i byte significativi che la lunghezza richiede. Il chiamante di CmsHeaderStart ha già ContentLen, perché ReadTlv l'ha appena restituito, quindi la lunghezza dell'header è calcolabile senza guardare un solo byte del buffer

La derivazione in avanti che DER garantisce: una lunghezza di contenuto di 127 si codifica A0 7F, 128 come A0 81 80, 255 come A0 81 FF, 256 come A0 82 01 00 e 1500 come A0 82 05 DC, quindi la lunghezza dell'header discende dal solo ContentLen e TryReadTlvAt ha già rifiutato ogni forma BER non minima
Le fixture costruite con certificati da 32 e 64 byte restavano dentro la forma breve, dove il percorso a ritroso risponde correttamente per il motivo sbagliato, ed è per questo che la suite sui confini ora attraversa 127, 128, 255 e 256 byte
// L'helper rilasciato: deriva l'header dalla lunghezza del contenuto.
// Con X.690 10.1 gli octet di lunghezza sono funzione di ContentLen
function CmsHeaderStart(const Buf: TBytes; ContentOffs, ContentLen: Integer): Integer;
var
  LengthOctets, Remaining: Integer;
begin
  if ContentLen< 128 then
    LengthOctets:= 1                 // forma breve, X.690 8.1.3.4
  else
  begin
    LengthOctets:= 1;                // l'octet iniziale, X.690 8.1.3.5
    Remaining:= ContentLen;
    while Remaining> 0 do
    begin
      Inc(LengthOctets);             // uno per byte significativo
      Remaining:= Remaining shr 8;
    end;
  end;
  Result:= ContentOffs- LengthOctets- 1;
  if Result< 0 then
    Result:= ContentOffs;
end;

Due dettagli rendono tutto questo sicuro e non solo plausibile. Primo, il presupposto che l'input sia DER è imposto a monte: TDerReader.TryReadTlvAt, su cui è costruito ReadTlv, rifiuta la forma indefinita, rifiuta una lunghezza in forma lunga il cui primo octet successivo sia zero e rifiuta un singolo octet successivo sotto $80. Un TLV che arriva a CmsSliceTlv ha già superato quei controlli, quindi una lunghezza non minima in stile BER non può raggiungere la derivazione e farla mentire. Secondo, il fallback per un risultato negativo ora protegge la risposta vera, non un valore intermedio. Vale la pena dire che il reader conosceva l'offset del tag fin dall'inizio: TDerTlv porta sia Offset sia HeaderLength, ed è solo la superficie di ReadTlv con quattro parametri di output a perderli. Restituirli sarebbe l'interfaccia più pulita nel lungo periodo; la correzione rilasciata lascia quella superficie intatta e rende l'helper corretto alle sue condizioni

Perché i test sul timestamp passavano con il bug in piedi?

Perché ogni certificato delle fixture era abbastanza corto da usare la forma breve, e il percorso a ritroso è corretto esattamente in quel caso. Tests.PadesTimestamp.pas costruisce il suo certificato di firma con SetLength(SignerCertDer, 32) in un test e 64 in un altro, riempiti con una rampa di byte. Un insieme di certificati da 32 byte si codifica A0 20 e uno da 64 byte A0 40, un solo octet di lunghezza ciascuno. Percorrere a ritroso dal contenuto finisce su quell'unico octet, il suo bit alto è a zero perché è il primo e unico octet di lunghezza, e l'helper risponde correttamente per il motivo sbagliato. La suite di 1414 casi era verde, il CMS con timestamp veniva analizzato, il validatore di stadio 1 riportava B-T, e ognuno di quei controlli girava su un insieme di certificati che nessun documento reale ha mai contenuto

La regola generale è la parte utile. Ogni volta che un percorso di codice dipende da come è codificata una lunghezza, la fixture deve attraversare il confine di codifica, e per DER questo significa contenuti più lunghi di 127 byte, che forzano la forma lunga, e idealmente anche più lunghi di 255 byte, che forzano un secondo octet successivo. La stessa disciplina vale per l'altro caso di quella revisione in cui l'autoverifica non riusciva a vedere una deviazione da DER: il SET OF non ordinato in signedAttrs era invisibile a un round trip sulla stessa origine per un motivo strutturalmente identico, il test esercitava solo input su cui il codice sbagliato e quello giusto concordano. Lo sketch qui sotto chiama direttamente l'helper di ritaglio, il che significa esportarlo da FPdfCms.pas per la build di test; lo stesso confine è raggiungibile dalla superficie pubblica passando a BuildSignedData un certificato di catena di ogni dimensione e rianalizzando il risultato con il timestamp

// Fissa il confine: un ritaglio attraverso un header in forma lunga deve partire dal tag
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;
      // il contenuto inizia subito dopo l'header; il ritaglio deve essere l'intero TLV
      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;

Dove la ricostruzione traccia ancora i suoi confini

AddSignatureTimestampToCms è scritto per il CMS che BuildSignedData emette, e i suoi limiti derivano da questo. Il percorso si aspetta un solo SignerInfo e riemette solo quello, quindi un CMS estraneo con più firmatari tornerebbe con un solo firmatario; riconosce un insieme opzionale certificates [0] ma non un insieme crls [1], e un CMS che ne porti uno fallisce in modo fragoroso con l'eccezione signerInfos SET expected invece di ritagliare in silenzio nel posto sbagliato. I nuovi unsignedAttrs contengono un solo attributo, quindi la regola di ordinamento SET OF della clausola 11.6 di X.690 è banalmente soddisfatta e non serve alcun sort. E la parte firmata è intatta per costruzione: il prefisso del SignerInfo fino all'OCTET STRING della firma viene copiato alla lettera, ed è per questo che un validatore che ricalcola il digest dei signedAttrs vede gli stessi byte prima e dopo l'aggiunta del timestamp. Quando uno rifiuta comunque il documento, le cause di solito stanno altrove e meritano una checklist a parte

Il reader DER, il writer, il costruttore CMS e questa iniezione del timestamp sono tutti distribuiti come sorgente Pascal con il componente PDFium per Delphi, ed è proprio un bug di questa forma l'argomento a favore: quando un SignedData ricostruito esce lungo novanta byte di troppo, vuoi leggere l'helper che ha tagliato il ritaglio e la clausola di X.690 che ha letto male, non uno stack trace da una scatola nera