PDF-encryptiesleutelmateriaal genereren met de Random-functie van de runtimebibliotheek is een valkuil op Free Pascal: tenzij ergens Randomize wordt aangeroepen, produceert elk proces dezelfde bytereeks, dus Linux- en macOS-builds zouden bij run na run identieke bestandsversleutelingssleutels, salts, CBC-IV's en AES-GCM-nonceprefixen wegschrijven. PDFiumPas leest op niet-Windows-doelen in plaats daarvan /dev/urandom en gooit een exceptie als dat niet kan
De valkuil zelf past in een lus van vier regels. De nuttigere les is waarom een encryptietestsuite, AESV3 en AESV4, met en zonder PDF MAC, er groen doorheen kan blijven. Willekeur die per proces constant is, is onzichtbaar voor elke test die binnen één proces draait, en precies zo zijn encryptietestsuites meestal ingericht
Waar heeft PDFiumPas willekeurige bytes nodig?
Elke willekeurige byte in de PDFiumPas-encryptiestapel komt uit één procedure, AesGenerateRandomBytes in de unit FPdfAes, dus één slechte bron verontreinigt alles tegelijk. De standaard security handler in ISO 32000-2 §7.6.4 en de AESV4-uitbreiding in ISO/TS 32003 verbruiken die bytes op deze plekken:
- De bestandsversleutelingssleutel van 32 byte, door DeriveEncryptionKeys per document vers aangemaakt en daarna onder wachtwoord-afgeleide sleutels gewikkeld in /UE en /OE
- Twee salts van 16 byte, één opgeslagen in de laatste 16 bytes van /U en één in de laatste 16 bytes van /O, elk gesplitst in een validatiesalt van 8 byte en een keysalt van 8 byte
- Bytes 12 tot 15 van de plaintext achter /Perms, die ISO 32000-2 met willekeurige data vult voordat het blok onder de bestandssleutel wordt versleuteld
- Een CBC-IV van 16 byte die aan elke versleutelde string en stream in een AESV3-document wordt voorgehangen
- Een nonceprefix van 8 byte voor AESV4-documenten, gevolgd door een per-object teller van 4 byte die op nul begint
- De /KDFSalt van 32 byte en de MAC-sleutel wanneer EnableIntegrityProtection is gezet
Waarom zou elk proces dezelfde sleutel produceren?
De pseudo-randomgenerator van de RTL begint bij RandSeed = 0 tenzij het programma Randomize aanroept, dus een niet-Windows-tak die de sleutelbuffer eruit vult krijgt in elk proces dezelfde reeks. Een commentaarregel die zegt dat de generator uit GetTickCount64 wordt geseed, helpt niet als geen enkele regel code dat werkelijk doet; het commentaar wordt de enige plek waar de seed bestaat:
// De valkuil: een niet-geseede RTL-generator als sleutelbron
// (een commentaarregel die een GetTickCount64-seed belooft, past er geen toe)
P := PByte(Buffer);
for I := 0 to Count - 1 do
P[I] := Byte(Random(256));
De reeks herstart bij elk proces en schuift binnen het proces op, dus het eerste document dat een proces versleutelt deelt zijn bestandssleutel met het eerste document van elk ander proces dat dezelfde build draait, het tweede met het tweede, enzovoort. De bestandssleutel in R5, R6 en R7 hangt helemaal niet van het wachtwoord af, want het wachtwoord wikkelt hem slechts in, wat betekent dat iedereen die de reeks kan reproduceren de sleutel in handen heeft zonder een wachtwoord te kennen. AESV4 voegt een tweede faling toe: dezelfde sleutel met hetzelfde prefix van 8 byte en een teller die op nul herstart herhaalt GCM-nonces, wat NIST SP 800-38D §8 ronduit verbiedt. Een herhaalde GCM-nonce onder één sleutel onthult de XOR van de twee plaintexts en legt de authenticatie-subkey bloot, dus de tags waarop AESV4-GCM-versleuteling en het PDF MAC-token steunen, betekenen niets meer. Vertrouwelijkheid en integriteit gaan tegelijk verloren
Het bereik is smaller dan die alinea misschien doet vermoeden. Op Windows ontstaat de vraag niet, want de Windows-tak roept CryptGenRandom aan via advapi32 met CRYPT_VERIFYCONTEXT en gooit een exceptie als dat faalt. De valkuil is specifiek voor niet-Windows-doelen, wat in de praktijk betekent: Lazarus- en Free Pascal-applicaties op Linux en macOS, weer één entry voor de lijst met Delphi-versus-FPC-valkuilen in PDFium-builds
Waarom Randomize het antwoord niet is
Randomize aanroepen verbergt het symptoom zonder de bron te verhelpen, want RandSeed is een 32-bits waarde en Randomize leidt die af van de klok. Dat beperkt het aantal mogelijke sleutelreeksen tot 2^32, en grofweg weten wanneer een bestand is geschreven verlaagt de zoekruimte ver onder dat aantal, verwaarloosbaar naast een AES-sleutel van 256 bit. Sleutelmateriaal moet uit de kernel-entropiepool komen, dus leest AesGenerateRandomBytes /dev/urandom, loopt over korte leesbeurten heen en gooit een exceptie als de pool niet elke gevraagde byte kan leveren:
Handle := FileOpen('/dev/urandom', fmOpenRead or fmShareDenyNone);
if Handle <> THandle(-1) then
try
Remaining := Count;
while Remaining > 0 do
begin
Got := FileRead(Handle, P^, Remaining);
if Got <= 0 then
Break; // mislukking of onverwacht einde van de stream
Inc(P, Got);
Dec(Remaining, Got);
end;
if Remaining = 0 then
Exit;
finally
FileClose(Handle);
end;
raise Exception.Create('FPdfAes: /dev/urandom unavailable; refusing to emit predictable key/IV');
Weigeren is bewust, en het sluit aan bij wat de Windows-tak doet wanneer CryptGenRandom onbeschikbaar is. Een mislukte versleutelde save is een incident dat je dezelfde dag merkt; een geslaagde save met voorspelbare sleutels is er een waarvan je van iemand anders hoort. Twee praktische gevolgen. Een minimaal container- of chroot-omgeving zonder gevulde /dev faalt nu bij versleuteling in plaats van stilletjes te degraderen, dus mount hem. En omdat de exceptie uit TPdf.SaveAsEncrypted propageert nadat het doelfile met fmCreate was geopend, blijft een leeg uitvoerbestand achter om door je foutafhandeling te worden verwijderd
Waarom round-trip-tests dit niet vangen?
Een round-trip-test kan constante willekeur niet zien, omdat ontsleutelen welke bestandssleutel de versleuteling ook koos, terugwint. De test versleutelt een document, opent het opnieuw met het wachtwoord, wikkelt de sleutel uit /UE uit en ontsleutelt elk object; een voorspelbare sleutel pakt en ontsleutelt precies even goed als een willekeurige, en GCM-tags verifiëren omdat ze met diezelfde sleutel zijn berekend. Zelfs een test die twee keer versleutelt en controleert of de twee uitvoeren verschillen, slaagt, want de tweede aanroep in hetzelfde proces trekt de volgende bytes uit de reeks. De eigenschap die telt, een andere sleutel in elk proces, is alleen waarneembaar door uitvoer tussen processen te vergelijken. Waar dezelfde code een waarde zowel produceert als verbruikt, zijn de tests blind voor hele klassen van defecten, en willekeur is het zuiverste voorbeeld
Hoe test u sleutelwillekeur over processen heen?
Draai een kleine probe twee keer als aparte processen op het doelplatform en vergelijk de uitvoer. De probe hieronder roept DeriveEncryptionKeys aan en drukt het salt af dat in bytes 32 tot 47 van de /U-invoer staat. Die waarde wordt open en bloot in elk versleuteld bestand geschreven, dus het afdrukken in CI-logboeken onthult niets, maar hij komt uit dezelfde generator als de bestandssleutel:
program SaltProbe;
{$mode delphi}
uses
SysUtils, FPdfEncrypt;
var
Opts: TPdfEncryptOptions;
Keys: TPdfEncryptionKeys;
I: Integer;
Hex: string;
begin
Opts := TPdfEncryptOptions.Default;
Opts.UserPassword := 'probe';
Opts.Revision := erR6;
DeriveEncryptionKeys(Opts, Keys);
Hex := '';
for I := 32 to 47 do // /U = hash van 32 byte + salt van 16 byte
Hex := Hex + IntToHex(Keys.UEntry[I], 2);
WriteLn(Hex); // moet bij elke run verschillen
end.
Koppel de probe aan de build voor elk niet-Windows-doel: voer hem tweemaal uit en laat de taak vallen als de twee regels gelijk zijn. Dezelfde vergelijking werkt op bestanden die al in omloop zijn. Neem twee versleutelde PDF-bestanden geschreven door verschillende runs van dezelfde applicatie, lees de /U-strings uit hun Encrypt-dictionaries en vergelijk de laatste 16 bytes; identieke salts betekenen dat de sleutelbron voorspelbaar is, en die documenten moeten opnieuw worden versleuteld vanuit hun plaintext zodat elk een verse bestandssleutel krijgt. De algemene gewoonte is niet-Windows-codepaden op het platform zelf uit te oefenen in plaats van de Windows-run te vertrouwen, dezelfde redenering achter de libcurl-timestamp-backend voor niet-Windows-builds
PDFiumPas is een Delphi- en Lazarus-PDF-component gebouwd op de PDFium-engine, met AES-256, AES-GCM en het PDF MAC-token natively geïmplementeerd in Pascal en sleutelmateriaal dat op elk platform uit de generator van het besturingssysteem komt. Details en downloads staan op de PDFium Delphi componentpagina