Articolo tecnico

Parsing delle firme CMS in Delphi: limiti DER e archi OID

PDF Library for Delphi (PDFlibPas) estrae i certificati dentro una firma PDF con un percorso DER puro sul CMS SignedData memorizzato in /Contents, senza toccare CryptoAPI. Dalla v3.539.10 ogni lettura annidata è limitata dal suo elemento padre, il padding di zeri dopo il CMS viene tagliato alla lunghezza che il CMS stesso dichiara, e gli object identifier codificano il loro primo subidentifier combinato in base-128. La regola sui limiti e la correzione OID hanno entrambe sostituito codice che produceva risposte sbagliate senza sollevare alcun errore, e la regola sul padding evita che il lettore più severo rigetti firme reali

Il lato lettura conta più di quanto sembri. Gli strumenti di long-term validation devono estrarre il certificato del firmatario e i suoi issuer da una firma esistente prima di poter recuperare i dati di revoca, un report di audit deve dire chi ha firmato, e una build Lazarus su Linux non ha funzioni di messaggio Windows a cui appoggiarsi. Un parser in quella posizione va raramente in crash su input difettosi. La modalità di fallimento che fa male è un conteggio certificati che include byte di un vicino, un match del firmatario fatto contro il campo sbagliato, o un OID che diventa silenziosamente un OID diverso. Una pipeline di firme costruita sopra tutto questo riporta nonsensi con piena sicurezza

Estrarre i certificati del firmatario da un PDF firmato

Cinque metodi di TPDFlib coprono il lato lettura, e tutti prendono InputFile, Password, FieldName: ogni chiamata apre il file in sola lettura, risponde e lo richiude. GetSignatureEmbeddedCertificateCount e GetSignatureEmbeddedCertificateDER enumerano i certificati nell'ordine di codifica, GetSignatureSignerCertificateDER restituisce il certificato che ha prodotto un dato SignerInfo, e GetSignatureCertificateChainLength / GetSignatureCertificateChainDER camminano da quel firmatario verso l'issuer più lontano che la firma stessa trasporta. Gli indici partono da zero. Conserva i risultati in AnsiString, ed è per questo che la libreria li restituisce così: un blob DER instradato attraverso string o un TStrings passa per una conversione di set di caratteri e torna corrotto

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;

Due cose in quell'output richiedono attenzione. Un conteggio di 0 non è una diagnosi: un campo mancante, una password sbagliata, un blob che non è DER e un SignedData che semplicemente omette l'insieme opzionale dei certificati tornano tutti come 0 o stringa vuota, quindi logga il nome del campo accanto al numero. E una catena che finisce prima di un certificato auto-rilasciato non è nemmeno un errore. Il costruttore di catena usa solo i certificati inclusi nella firma, quindi gli issuer rimanenti vanno recuperati attraverso gli indirizzi che GetCertificateIssuerURLs riporta

Quanta parte di /Contents è davvero il CMS?

Solo il prefisso che il SEQUENCE esterno dichiara appartiene al CMS, e PLTrimCMSPadding taglia tutto ciò che viene dopo. Un firmatario riserva la stringa esadecimale di /Contents prima che il CMS esista, perché il /ByteRange descritto in ISO 32000-1 §12.8.1 va fissato prima, quindi lo slot è dimensionato con generosità e la coda inutilizzata è fatta di zeri. PLTrimCMSPadding legge il primo TLV, richiede il tag $30 e restituisce i byte fino alla fine di quell'elemento; qualsiasi cosa che non inizi con un SEQUENCE ben formato torna vuota. Quel livello più alto è l'unico posto dove i byte finali sono legali, e la distinzione conta per la sezione successiva: una regola severa del tipo «l'elemento deve consumare tutto il buffer» rigetterebbe ogni firma reale, mentre una regola permissiva applicata a ogni profondità lascia i campi annidati leggere byte che non possiedono

PDFlibPas PLTrimCMSPadding legge il primo TLV della stringa esadecimale di /Contents riservata, richiede il tag $30 e taglia il padding di zeri alla lunghezza che il SEQUENCE esterno dichiara, e restituisce un risultato vuoto quando il buffer non inizia con un SEQUENCE ben formato
I byte finali sono legali solo al livello più alto, dove lo slot riservato deve restare fisso per il /ByteRange — le letture più profonde ricevono invece la regola del limite dato dal padre

Perché un lettore DER ha bisogno dell'offset di fine del padre?

Un elemento annidato è valido solo se finisce dentro il suo padre, e il controllo contro la fine del buffer non lo dimostra. La DERReadTLV di basso livello in PDFlibASN1 limita ogni elemento rispetto all'intera stringa, che è il controllo giusto per l'oggetto più esterno e quello sbagliato per tutto ciò che sta sotto. Immagina un SignerInfo il cui issuerAndSerialNumber dichiara 40 byte mentre la issuer Name al suo interno ne rivendica 60. Ogni byte è ancora nel buffer, quindi un lettore limitato al buffer accetta la Name, legge il numero di seriale dall'algoritmo di digest che segue, e poi confronta quella coppia con i certificati inclusi. Prima della v3.539.10 il walker del CMS leggeva esattamente così. La correzione è un piccolo wrapper che porta la posizione di fine del padre in ogni lettura

PDFlibPas limita ogni lettura DER annidata al suo elemento padre: una issuer Name da 60 byte dentro un issuerAndSerialNumber da 40 byte viene accettata dalla vecchia DERReadTLV limitata al buffer, che poi legge il numero di seriale dal digestAlgorithm, mentre ReadTLVWithin rifiuta qualsiasi elemento che finisce oltre ParentEnd
Dentro il buffer è memory safety, dentro il padre è correttezza — PDFlibPas fa passare l'offset di fine del padre attraverso ogni livello del CMS così una lunghezza ostile non può prendere in prestito i byte di un vicino
function ReadTLVWithin(const Data: AnsiString; ParentEnd: Integer;
  var Offset: Integer; out Tag: Byte; out Start, Len: Integer): Boolean;
begin
  Result := False;
  // niente più spazio dentro il padre: rifiuta di avviare una lettura
  if (Offset < 1) or (Offset >= ParentEnd) then
    Exit;
  if not DERReadTLV(Data, Offset, Tag, Start, Len) then
    Exit;
  // Offset ora sta uno oltre l'elemento; non deve superare il padre
  Result := Offset <= ParentEnd;
end;

// ogni livello registra la propria fine e la passa giù:
//   OuterEnd    := fine di ContentInfo         (RFC 5652 sezione 3)
//   ExplicitEnd := fine del contenuto [0] EXPLICIT
//   ContentEnd  := fine di SignedData          (RFC 5652 sezione 5.1)
//   SignerEnd / InnerEnd per SignerInfo e issuerAndSerialNumber

L'unità PDFlibCMSRead ora fa passare quelle fine attraverso ContentInfo, il wrapper [0] EXPLICIT, i campi SignedData fino a signerInfos, il SignerIdentifier nelle sue due forme issuerAndSerialNumber e [0] subjectKeyIdentifier (RFC 5652 §5.3), e i campi tbsCertificate letti da ogni certificato incluso quando si confronta il firmatario. Dentro l'insieme dei certificati e l'insieme signerInfos, un elemento che supera la fine dell'insieme ferma il ciclo: PLExtractCMSCertificates restituisce i certificati che aveva già accettato e non incolla mai i byte seguenti di crls o signerInfos sull'ultimo. Il match issuer-e-seriale richiede inoltre entrambe le metà, dato che un numero di seriale è univoco solo dentro un issuer

Perché 2.999.3 usciva come 1.15.3?

I primi due archi di un OID si combinano in un solo subidentifier, non in un byte, e quel subidentifier è codificato in base-128 come ogni altro arco. X.690 §8.19.4 lo definisce come 40 * arc1 + arc2; il vecchio DER_OID scriveva quel valore con Byte(...), corretto solo fino a 127, cioè il valore di 2.47. Per 2.999 la somma è 1079, il cast a byte conserva 55, e 55 si decodifica come 1.15, quindi l'identificatore nomina silenziosamente un ramo diverso dell'albero. I valori da 128 a 255 falliscono diversamente, emettendo un byte con il bit di continuazione impostato che ingoia l'arco successivo. La maggior parte degli identificatori PKI (1.2.840..., 2.5.29..., 0.4.0...) non raggiunge mai il confine, ed è per questo che il bug è sopravvissuto; gli archi joint-iso-itu-t da 2.48 in su sì. DER_OID serve sia l'encoder per i signed attributes sia il matcher in DERFindExtensionByOID e nel controllo del content-type del SignedData, così una codifica sbagliata rompeva scrittura e ricerca allo stesso modo

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.
PDFlibPas DER_OID combina i primi due archi OID come 40 * arc1 + arc2 e codifica in base-128 la somma in un UInt64, così 2.999.3 diventa 06 03 88 37 03, mentre il vecchio cast a Byte conservava 55 e decodificava silenziosamente l'identificatore come 1.15.3
La maggior parte degli archi PKI non raggiunge mai il confine, ed è per questo che il bug è sopravvissuto — gli archi joint-iso-itu-t da 2.48 in su richiedono due byte, e il test tiene 2.47 e 2.48 ai due lati

Il valore combinato è tenuto in un UInt64 di proposito. DER_OID parsa gli archi in Int64, quindi un secondo arco legale può arrivare fino a Int64.MaxValue, e aggiungere 80 per arc1 = 2 fa overflow su un intero a 64 bit con segno. Un UInt64 porta Int64.MaxValue + 80 senza fare wrapping, e il buffer di lavoro da dieci byte contiene i dieci gruppi a 7 bit di cui ha bisogno un valore a 64 bit. I vettori di test che vale la pena conservare sono quelli ai due lati del confine: 2.47 deve restare un byte, e 2.48 deve diventare due

Che cosa garantisce il walker CMS lato lettura?

PDFlibCMSRead garantisce la struttura e nient'altro: restituisce i byte che stanno dove RFC 5652 dice che devono stare e non verifica né firma, né digest, né periodo di validità. Il walker accetta solo DER, quindi DERReadTLV rifiuta lunghezze indefinite e numeri di tag multi-byte, e un CMS codificato BER da un firmatario non conforme riporta zero certificati invece di un'ipotesi parziale. Gli attribute certificate e le altre alternative di CertificateChoices vengono saltate perché nulla a valle può usarle. La verifica crittografica resta al codice che la possiede, e inizia con i controlli di copertura dei byte descritti in firma PAdES e validazione del ByteRange in Delphi e continua con la classificazione di ciò che cambia dopo la firma di un PDF

La lezione più generale vale per qualsiasi formato binario: «dentro il buffer» è una proprietà di memory safety, «dentro il padre» è una proprietà di correttezza, e un parser ha bisogno di entrambe. Lo stesso ragionamento sulle lunghezze ostili attraversa l'hardening di un parser PDF Pascal contro file ostili. L'estrazione dei certificati, la costruzione della catena e le API di long-term validation discusse qui sono incluse in losLab PDF Library for Delphi, per Delphi, C++Builder e Lazarus