Before version 3.114.8, PDFiumPas generated PDF encryption key material on non-Windows targets with the runtime library Random function, and because nothing called Randomize, every process produced the same byte sequence. Free Pascal builds on Linux and macOS therefore wrote identical file encryption keys, salts, CBC IVs and AES-GCM nonce prefixes run after run. Version 3.114.8 reads /dev/urandom instead and raises an exception when it cannot
The defect itself is a four-line loop. The more useful lesson is why a test suite that encrypts and decrypts hundreds of documents, with AESV3 and AESV4, with and without a PDF MAC, stayed green the whole time. Randomness that is constant per process is invisible to every test that runs inside one process, and that is exactly how encryption test suites are usually organised
Where does PDFiumPas need random bytes?
Every random byte in the PDFiumPas encryption stack comes from a single procedure, AesGenerateRandomBytes in the FPdfAes unit, so one bad source contaminates all of it. The standard security handler in ISO 32000-2 §7.6.4 and the AESV4 extension in ISO/TS 32003 consume those bytes in these places:
- The 32-byte file encryption key, generated fresh by DeriveEncryptionKeys for each document and then wrapped into /UE and /OE under password-derived keys
- Two 16-byte salts, one stored in the last 16 bytes of /U and one in the last 16 bytes of /O, each split into an 8-byte validation salt and an 8-byte key salt
- Bytes 12 to 15 of the plaintext behind /Perms, which ISO 32000-2 fills with random data before the block is encrypted under the file key
- A 16-byte CBC IV prepended to every encrypted string and stream in an AESV3 document
- An 8-byte nonce prefix for AESV4 documents, followed by a 4-byte per-object counter that starts at zero
- The 32-byte /KDFSalt and the MAC key when EnableIntegrityProtection is set
Why did every process produce the same key?
AesGenerateRandomBytes used the operating system generator only on Windows; everywhere else it filled the buffer from the RTL pseudo-random generator, and that generator starts from RandSeed = 0 unless the program calls Randomize. The comment above the loop said the generator was seeded from GetTickCount64. No line of code ever did that, which made the comment the only place the seed existed:
// Non-Windows branch of AesGenerateRandomBytes before 3.114.8
// (the comment above it promised a GetTickCount64 seed that was never applied)
P := PByte(Buffer);
for I := 0 to Count - 1 do
P[I] := Byte(Random(256));
The sequence restarts with each process and advances within it, so the first document any process encrypts shares its file key with the first document of every other process running the same build, the second with the second, and so on. The file key in R5, R6 and R7 does not depend on the password at all, since the password only wraps it, which means anyone able to reproduce the sequence holds the key without knowing a password. AESV4 adds a second failure: the same key with the same 8-byte prefix and a counter restarting at zero repeats GCM nonces, which NIST SP 800-38D §8 forbids outright. A repeated GCM nonce under one key reveals the XOR of the two plaintexts and exposes the authentication subkey, so the tags that AESV4-GCM encryption and the PDF MAC token rely on stop meaning anything. Confidentiality and integrity go at the same time
The scope is narrower than that paragraph might suggest. Windows builds were never affected, because the Windows branch has always called CryptGenRandom through advapi32 with CRYPT_VERIFYCONTEXT and raised when that failed. What was exposed was output from non-Windows builds earlier than 3.114.8, which in practice means Lazarus and Free Pascal applications on Linux and macOS, one more entry for the list of Delphi versus FPC pitfalls in PDFium builds
Why was Randomize never the right fix?
Calling Randomize would have hidden the symptom without fixing the source, because RandSeed is a 32-bit value and Randomize derives it from the clock. That caps the number of possible key streams at 2^32, and knowing roughly when a file was written cuts the search far below that, which is nothing next to a 256-bit AES key. Key material must come from the kernel entropy pool, so AesGenerateRandomBytes in 3.114.8 reads /dev/urandom, loops over short reads, and raises if the pool cannot deliver every requested byte:
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; // failure or unexpected end of 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');
Refusing is deliberate, and it matches what the Windows branch has always done when CryptGenRandom is unavailable. A failed encrypted save is an incident you notice the same day; a successful save with predictable keys is one you learn about from someone else. Two practical consequences follow. A minimal container or chroot without a populated /dev now fails encryption instead of quietly degrading, so mount it. And because the exception propagates out of TPdf.SaveAsEncrypted after the target file was opened with fmCreate, an empty output file is left behind for your error handler to delete
Why did round-trip tests never catch it?
A round-trip test cannot see constant randomness, because decryption recovers whatever file key encryption chose. The test encrypts a document, opens it again with the password, unwraps the key from /UE, and decrypts every object; a predictable key unwraps and decrypts exactly as well as a random one, and GCM tags verify because they were computed with that same key. Even a test that encrypts twice and asserts the two outputs differ passes, since the second call in the same process draws the next bytes of the sequence. The property that matters, a different key in every process, is only observable by comparing output across processes. Whenever the same code both produces and consumes a value, the tests are blind to whole classes of defect, and randomness is the purest example
How can you test key randomness across processes?
Run a small probe twice as separate processes on the target platform and compare the output. The probe below calls DeriveEncryptionKeys and prints the salt stored in bytes 32 to 47 of the /U entry. That value is written in plain sight into every encrypted file, so printing it in CI logs discloses nothing, yet it comes from the same generator as the file key:
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 = 32-byte hash + 16-byte salt
Hex := Hex + IntToHex(Keys.UEntry[I], 2);
WriteLn(Hex); // must differ on every run
end.
Wire the probe into the build for every non-Windows target: run it twice, fail the job if the two lines match. The same comparison works on files already in circulation. Take two encrypted PDFs written by different runs of the same application, read the /U strings from their Encrypt dictionaries, and compare the last 16 bytes; identical salts identify an affected build, and the documents should be encrypted again from their plaintext with 3.114.8 or later so that each one receives a fresh file key. The general habit is to exercise non-Windows code paths on the platform itself rather than trusting the Windows run, the same reasoning behind the libcurl timestamp backend for non-Windows builds
PDFiumPas is a Delphi and Lazarus PDF component built on the PDFium engine, with AES-256, AES-GCM and the PDF MAC token implemented natively in Pascal and key material drawn from the operating system generator on every platform. Details and downloads are on the PDFium Delphi component page