HotPDF valida un PDF MAC ISO/TS 32004 per revisione anziché per file. THotPDF.ValidatePDFMACChain percorre ogni aggiornamento incrementale dall'ancora della catena in avanti e verifica ogni MAC contro uno stream prefix di sola lettura che termina al proprio startxref e %%EOF di quella revisione. Un MAC valido sull'ultima revisione non prova nulla sulle revisioni sotto di essa
Ecco lo scenario che motiva tutto ciò. Spedisci un PDF cifrato AES-256 con un PDF MAC sopra. Qualcuno apre il file in un editor esadecimale, ribalta un byte dentro la prima revisione protetta da MAC, poi appende una revisione completamente nuova che porta un MAC perfettamente valido del suo. Ogni viewer apre il file senza proteste, e un controllore ingenuo che hasha l'attuale intervallo di byte contro il MAC nel trailer attivo riporta successo — perché quel MAC è davvero corretto per i byte che copre. Il danno sta due revisioni più giù, in una regione che nessuno ha ricontrollato
Perché un MAC valido al livello più alto non prova che il file sia intatto?
Perché un PDF MAC copre un prefisso, non un documento. L'aggiornamento incrementale è una parte di prima classe del formato: ogni salvataggio appende un nuovo body, una nuova sezione cross-reference e un nuovo trailer, mentre i byte più vecchi restano esattamente dove erano. ISO/TS 32004 cavalca quel modello, quindi ogni revisione porta il proprio dizionario /AuthCode che autentica il file com'era in quel momento, e verificare solo l'ultima lascia ogni revisione precedente senza esame. HotPDF espone quindi le due domande come due chiamate, e la differenza tra loro è il punto di tutto l'articolo. ValidatePDFMAC risponde a "la revisione attuale è autentica", riempiendo un record THPDFPDFMACValidationInfo; ValidatePDFMACChain risponde a "ogni revisione protetta da MAC in questo file è autentica", riempiendo THPDFPDFMACChainValidationInfo con un array per revisione più un motivo di fallimento leggibile dalla macchina. Sul file sopra manomesso e poi ri-MACato, la prima chiamata ritorna True e la seconda ritorna False sulla revisione indice 1
var
Pdf: THotPDF;
Chain: THPDFPDFMACChainValidationInfo;
I: Integer;
begin
Pdf := THotPDF.Create(nil);
try
if not Pdf.ValidatePDFMACChain('incoming.pdf', 'user', Chain) then
begin
// Il fallimento è uno di pmcfRevisionBoundary, pmcfNoPDFMAC,
// pmcfRequiredRevisionMissing, pmcfRevisionInvalid,
// pmcfKDFSaltChanged, pmcfDigestDowngrade, pmcfPermissionDowngrade
Writeln('chain rejected: ', Chain.Message);
Writeln('revision ', Chain.FailureRevisionIndex,
' at xref offset ', Chain.FailureXRefOffset);
Exit;
end;
for I := 0 to High(Chain.Revisions) do
Writeln(I, ' len=', Chain.Revisions[I].RevisionLength,
' mac=', Chain.Revisions[I].HasPDFMAC,
' perms=', Chain.Revisions[I].PermissionsAuthenticated);
finally
Pdf.Free;
end;
end;
Ogni MAC si verifica sul proprio stream prefix, mai sulla lunghezza finale del file
Il bug più costoso in quest'area è usare la dimensione finale del file come limite superiore quando si ri-hasha una revisione più vecchia, cosa che piega i byte finali dentro ogni digest tranne il più recente e riporta manomissioni su un file sano. HotPDF invece ricostruisce, per ogni revisione, uno stream di sola lettura delimitato che termina al valore startxref di quella revisione seguito dal suo %%EOF, e hasha solo quello. Locare il confine è più laborioso di quanto sembri: la letterale %%EOF può apparire dentro un content stream o una stringa, quindi un candidato viene accettato solo quando il startxref immediatamente precedente si parsa in un numero uguale all'offset cross-reference della sezione in via di validazione, con nulla se non spazi bianchi tra loro. La revisione assorbe poi esattamente una sequenza di fine riga dopo il marcatore — un CR singolo, un LF singolo, o una coppia CRLF — e niente di più. Quell'ultima regola morde in pratica, perché un writer che emette una riga vuota extra tra due revisioni ha prodotto byte appartenenti alla revisione successiva, e ingoiare tutto lo spazio bianco finale nella precedente cambia silenziosamente entrambi i digest. L'enumerazione delle sezioni segue la stessa disciplina: HotPDF percorre le sezioni cross-reference dalla più vecchia alla più nuova esattamente una volta, ripetendo le voci free, direct e object-stream così le sezioni successive sovrascrivono lo stato precedente, che è l'opposto della semantica first-seen-wins che un parser di xref attivi applica
Dove si ancora la catena, e che cosa la rompe?
La prima revisione che porta un /AuthCode valido è l'ancora, e FirstMACRevisionIndex riporta dove inizia la protezione; qualsiasi cosa prima è non protetta per costruzione, il che è normale. Tutto ciò che viene dopo deve essere protetto da MAC, quindi appendere un solo aggiornamento incrementale semplice a un file protetto da MAC fallisce con pmcfRequiredRevisionMissing e l'indice della revisione colpevole — tollerare un varco lascerebbe un attaccante togliere la protezione semplicemente salvando ancora una volta. Tre invarianti ulteriori valgono lungo la catena, ognuna con il proprio codice di fallimento
pmcfKDFSaltChanged— il/KDFSaltdeve restare stabile dall'ancora in avanti, dato che un sale rotante lascerebbe un falsario ri-derivare le chiavi sotto parametri di propria sceltapmcfDigestDowngrade— la forza del digest è confrontata con l'ultimo MAC verificato anziché con la revisione immediatamente precedente, così una catena che parte sotto il profilo Modern a SHA-384 non può continuare furtivamente con SHA-256pmcfPermissionDowngrade— una revisione non può azzerare un requisito PDF MAC che una revisione precedente aveva autenticato
La conseguenza che vale la pena interiorizzare è che i MAC storici vengono verificati indipendentemente anche quando non sono più il trailer attivo. Ecco perché l'attacco modifica-una-revisione-vecchia-e-appendi-un-MAC-fresco dell'apertura non sopravvive: il MAC più recente è a posto da sé, ValidatePDFMAC è contento, e la catena cade comunque sulla revisione 1 con pmcfRevisionInvalid
Ordine della firma: chiavi del trailer prima, signatureDigest per ultimo
Quando il MAC è attaccato a una firma CMS anziché stare da solo, l'ordine di scrittura smette di essere una questione stilistica. HotPDF richiede che /AuthCode, /KDFSalt, l'estensione sviluppatore ISO 32004 e /SigObjRef siano scritti nella stessa revisione prima che il /ByteRange della firma sia calcolato; appendine uno qualsiasi dopo e quei byte atterrano fuori dall'intervallo che la firma copre, producendo un file la cui firma verifica mentre il binding del MAC è non firmato. I due digest poi corrono nell'altro senso, che a prima vista sembra circolare e non lo è. Il signatureDigest del PDF MAC lega gli ottetti di contenuto grezzi dell'OCTET STRING CMS SignerInfo.signature — non l'intero DER CMS, e non gli attributi firmati — quindi è costruito dopo che il valore della firma grezza esiste e iniettato come attributo non firmato id-attr-pdfMacData. Siccome /Contents è escluso dal ByteRange della firma e gli attributi non firmati non alimentano mai il calcolo della firma, la sequenza produrre-firma, costruire-MAC, avvolgere-CMS si chiude pulita senza loop crittografico. Due corollari seguono: il sentinella /ByteRange e il segnaposto /Contents devono restare in chiaro e fuori dagli object stream anche in un file cifrato, o il patcher a larghezza fissa non li trova; e quando il digest del MAC è anch'esso SHA-256 il digest di firma viene riusato del tutto, altrimenti entrambi i contesti di digest sono aggiornati in un solo passaggio sullo stream di output
var
Pdf: THotPDF;
Options: THPDFPDFMACOptions;
Info: THPDFPDFMACValidationInfo;
begin
Pdf := THotPDF.Create(nil);
try
Pdf.AutoLaunch := False;
Pdf.FileName := 'unsigned.pdf';
Pdf.ActivateProtection := True;
Pdf.CryptKeyLength := aesgcm;
Pdf.OwnerPassword := 'owner';
Pdf.UserPassword := 'user';
Pdf.ProtectOptions := [prPrint, prExtractContent];
Pdf.BeginDoc;
Pdf.CurrentPage.SetFont('Arial', [], 12);
Pdf.CurrentPage.AddSignedSignatureField('Approval',
Rect(72, 120, 280, 160), 16384);
Pdf.EndDoc;
Options := THPDFPDFMACOptions.Modern; // digest del documento SHA-384
if THotPDF.SignPDFWithPFXAndAttachedPDFMAC('unsigned.pdf',
'signed.pdf', 'signer.pfx', 'pfx-secret', 'user', Options) then
if Pdf.ValidatePDFMAC('signed.pdf', 'user', Options, Info) then
begin
// Location = pmlAttachedToSignature, e i due digest
// sono riportati separatamente
Writeln('signature object : ', Info.SignatureObjectNumber);
Writeln('signature digest : ', Info.SignatureDigestMatched);
Writeln('full file coverage: ', Info.FullFileCoverage);
Writeln('perms authentic : ', Info.PermissionsAuthenticated);
end;
finally
Pdf.Free;
end;
end;
La validazione ripercorre lo stesso percorso dall'altra estremità: leggi il /AuthCode diretto dal trailer cross-reference classico attualmente attivo, segui il /SigObjRef indiretto consapevole delle generazioni, conferma che lega il /V dell'unico campo firma, e riporta un fallimento del digest del documento separatamente da un fallimento del digest della firma. Sono diagnosi diverse, e collassarle in un solo booleano scarta l'unica informazione che dice se il contenuto della pagina o il valore della firma è stato toccato. Se fai già lavoro CMS, questo si affianca all'articolo sulla firma PAdES e alla guida alla verifica delle firme nei documenti caricati
Non fidarti mai di /P: decifra prima il /Perms a 16 byte
ISO/TS 32004 segnala "questo documento richiede un PDF MAC" attraverso il bit di permesso 13, e il modo ovvio di leggerlo è quello sbagliato, perché l'intero /P nel dizionario di cifratura è in chiaro e non autenticato — chiunque può ribaltare quel bit in un editor di testo e abbassare il requisito. ISO 32000-2 §7.6 fornisce la risposta nella voce /Perms, e HotPDF la usa: decifra la stringa /Perms a 16 byte con la chiave di cifratura del file sotto AES-256 CBC, IV zero, senza padding, poi controlla ogni campo del testo in chiaro prima di credere a qualsiasi cosa. I byte 1 a 4 contengono il valore di permesso in ordine little-endian e devono eguagliare esattamente l'intero /P; i byte 5 a 8 sono 0xFF; il byte 9 è il flag di cifratura metadati T o F; i byte 10 a 12 sono il marcatore letterale adb. Solo quando tutto ciò regge PermissionsAuthenticated diventa True e il bit 13 viene letto — e fai attenzione alla sua polarità, dato che il requisito MAC è asserito quando il bit 0x1000 è azzerrato. Una discrepanza tra /P e i permessi decifrati non è un avviso da registrare e superare; è un insieme di permessi contraffatto, e la risposta giusta è fallire in sicurezza
L'agilità degli algoritmi si ferma al digest
ISO/TS 32004 ti lascia scegliere il digest del documento, e solo il digest del documento. HotPDF tiene HMAC-SHA-256 per l'autenticazione, HKDF-SHA-256 per RFC 5869 per la derivazione delle chiavi e AES-256 key wrap per RFC 3394 fissati sotto un THPDFPDFMACDigestAlgorithm variabile che copre da pmdaSHA256 a pmdaSHA3_512, perché l'errore naturale è trattare un "profilo SHA3-512" come licenza di cambiare anche l'HMAC, cosa che produce un file che non è più un PDF MAC in nessun senso interoperabile. Un dettaglio di implementazione merita di essere copiato se scrivi il tuo verificatore: leggi l'OID del digest dal AuthenticatedData CMS prima di hashare l'intervallo di byte, dato che cablare SHA-256 e riconciliare dopo trasforma l'agilità in un'etichetta e lascia a un file ostile farti streamare l'intero documento prima di scoprire che l'algoritmo non è mai stato supportato. CMSAlgorithmProtection, l'algoritmo di digest del AuthenticatedData, il messageDigest dell'integrity-info e il digest dell'intervallo di byte devono tutti nominare un algoritmo solo, e qualsiasi disaccordo fallisce in sicurezza
var
Options: THPDFPDFMACOptions;
begin
Options := THPDFPDFMACOptions.Compatibility; // SHA-256, accetta tutti e sei
Options := THPDFPDFMACOptions.Modern; // SHA-384, respinge i 256 bit
Options := THPDFPDFMACOptions.HighAssurance; // solo SHA3-512, AES-GCM
// Un profilo personalizzato è legale, ma l'algoritmo con cui genera
// deve anche comparire nella allowlist di validazione, o la
// configurazione viene respinta prima che un solo byte sia scritto
Options.Profile := pmppCustom;
Options.DigestAlgorithm := pmdaSHA512;
Options.AllowedDigestAlgorithms := [pmdaSHA512, pmdaSHA3_512];
Options.RequireAESGCM := True;
end;
Cosa prova e cosa non prova un PDF MAC
Una catena PDF MAC verificata prova che ogni revisione protetta è byte-identica a ciò che è stato scritto da qualcuno in possesso della chiave di cifratura del file, che nessuna revisione protetta è stata rimossa o riordinata, e che nessuna revisione non protetta è stata appesa dopo l'ancora — esattamente la classe di attacco che la semplice cifratura AES-256 lascia aperta, dato che la riservatezza non dice nulla sull'integrità e un PDF cifrato con una revisione innestata si decifra tanto contento quanto uno intatto. Ciò che non prova è l'autorialità. La chiave del MAC deriva dalla chiave di cifratura del file, così chiunque possa aprire il documento può anche produrre un MAC valido su una versione modificata, ogni destinatario legittimo incluso; è una primitiva simmetrica, e le primitive simmetriche non attribuiscono. Se devi sapere chi ha cambiato qualcosa ti serve una firma digitale con un certificato dietro, e il PDF MAC allora la completa proteggendo la struttura incrementale che la firma da sola non copre. Trattali come strati e lascia che i due verdetti siano riportati indipendentemente anziché collassati in una sola icona di stato
I punti di ingresso PDF MAC descritti qui — AddStandalonePDFMAC, SignPDFWithPFXAndAttachedPDFMAC, ValidatePDFMAC e ValidatePDFMACChain — vengono spediti con la standard HotPDF Delphi Component per Delphi e C++Builder, dove la pagina prodotto porta il riferimento completo per il record delle opzioni, le enumerazioni di stato e l'array di validazione per revisione