Technical Article

Open Encrypted PDFs with a Raw File Key in Delphi

PDF Library for Delphi can open an encrypted PDF from its raw file encryption key instead of a password. DAOpenFileWithEncryptionKey accepts the key as hexadecimal text, checks it against the verifier already stored in the encryption dictionary, and returns a read-only Direct Access handle; DAOpenFromStreamWithEncryptionKey does the same for a caller-owned TStream. Both arrived in v3.496.0

The scenario is narrow and it is real. A forensics engagement hands you a key recovered from a memory image and no password. A bulk archival run has ten thousand documents whose file keys sit in an escrow database because the originating DRM system stopped issuing passwords years ago. A migration off a retired rights-management product has key material and nothing else. In every one of those cases the credential you hold is the output of key derivation, not the input, and no password parameter anywhere in the API will take it

Why is a file encryption key not a password?

A password and a file encryption key sit on opposite sides of key derivation in the PDF standard security handler (ISO 32000-1 §7.6.3). The handler takes a password, mixes it with /O, /P, the file ID and a revision-specific hash, and produces the file key. Feed a file key into the password slot and you get nonsense hashed into different nonsense, which is why this needs its own entry point rather than a flag on DAOpenFile

Where the raw key can be injected is fixed by the revision. Revisions 2 through 4 still derive a distinct per-object key from the file key, the object number, the generation number and, for AESV2, the AES salt, so holding the file key does not let you skip object-level derivation at all. Revisions 5 through 7 use the 32-byte file key directly for AES-256, with no per-object step. The one layer common to both is the file key itself, so that is the only place PDF Library for Delphi accepts an externally supplied key. If you do still have the password, stay on the ordinary path instead and let the password retry lifecycle handle a wrong first attempt, because the raw-key entry deliberately gives up several conveniences that the password path keeps

What input does DAOpenFileWithEncryptionKey accept?

Only unprefixed, whitespace-free, even-length ASCII hex, and the decoded byte count must match the encryption revision exactly. For revisions 2 through 4 the expected length comes from /Length in the encryption dictionary: a multiple of 8 bits that lands between 5 and 16 bytes, defaulting to 40 bits when /Length is absent. For revisions 5 through 7 it is exactly 32 bytes, with no negotiation. A 0x prefix, an odd digit count, more than 64 hex characters, an unknown bit in Options, or a document that is not encrypted at all all produce the same outcome: a handle of 0 and LastErrorCode set to PDFLIB_ERROR_RAW_ENCRYPTION_KEY_INVALID, which is 425. The strictness is the point. A lenient parser that trims whitespace and zero-pads short input will happily turn a truncated clipboard paste into a credential and then fail somewhere far less legible

Var
  Lib: TPDFlib;
  FileHandle, PageRef: Integer;
Begin
  Lib:= TPDFlib.Create;
  Try
    // KeyHex is 32 hex characters for AES-128 R4, 64 for AES-256 R6
    FileHandle:= Lib.DAOpenFileWithEncryptionKey(CaseFile, KeyHex);
    If FileHandle= 0 Then
      Raise Exception.CreateFmt('raw key refused (error %d)', [Lib.LastErrorCode]);
    Try
      PageRef:= Lib.DAFindPage(FileHandle, 1);
      WriteLn(Lib.DAExtractPageText(FileHandle, PageRef, 0));
    Finally
      Lib.DACloseFile(FileHandle);
    End;
  Finally
    Lib.Free;
  End;
End;

The other failure codes stay distinct so a batch job can tell operator error from evidence problems: 411 when the file does not exist, 401 when it cannot be opened for reading, 409 when the cross-reference structure is broken. Everything key-related collapses into 425, on purpose, because a raw-key entry point that reports which part of the key was wrong is an oracle

What does verification actually prove?

PDF Library for Delphi proves that the supplied key belongs to this document, using the verifier the encryption dictionary already carries, and the check differs by revision. Revision 2 recomputes the RC4 encryption of the 32-byte standard padding string and compares all 32 bytes against /U. Revisions 3 and 4 hash the padding together with the file ID, run the RC4 pass plus the 19 XOR-derived rounds, and compare the first 16 bytes of /U. Revisions 5 through 7 decrypt the 16-byte /Perms string with a zero IV and check four independent things at once: the little-endian permission word against /P, the four FF bytes at positions 5 through 8, the encrypt-metadata flag as T or F, and the adb marker at positions 10 through 12

When a verifier exists but does not match, the open is refused unconditionally. That is worth stating plainly because it is the guarantee the whole feature rests on. Note also what verification is not: it says the key decrypts this file, not that anyone authorised you to use it. The permission word recovered from /Perms is evidence about the key, not a grant, and if you want to know what the document actually claims to allow, that is a separate job for an encryption and permissions audit pass. Password-side normalisation problems, such as SASLprep handling of non-ASCII AES-256 passwords, simply do not arise here, since no string ever reaches a hash

When does PDF_RAW_KEY_ALLOW_UNVERIFIED apply?

PDF_RAW_KEY_ALLOW_UNVERIFIED covers exactly one situation: the document carries no usable verifier, because /Perms is missing or not 16 bytes, or /U is too short to compare. It cannot override failing evidence. Corrupt one hex digit inside /Perms and pass the correct key with the option set, and PDF Library for Delphi still returns 0 and 425. Pass a key of 32 zero bytes against an intact verifier with the option set, and the answer is the same. The option relaxes the absence of proof, never a contradiction of it. Because a recovery open and a verified open are different epistemic states, they are also reported separately rather than folded into the return value, and DAGetEncryptionKeyValidation takes the open handle and answers with one of three constants:

  • PDF_RAW_KEY_VALIDATION_VERIFIED (1) means a verifier was present and matched
  • PDF_RAW_KEY_VALIDATION_UNVERIFIED (2) means the key was accepted only because no verifier could be evaluated and the caller asked for that policy explicitly
  • PDF_RAW_KEY_VALIDATION_NONE (0) is what an ordinary password-opened handle reports
FileHandle:= Lib.DAOpenFileWithEncryptionKey(CaseFile, KeyHex);
If (FileHandle= 0)And
   (Lib.LastErrorCode= PDFLIB_ERROR_RAW_ENCRYPTION_KEY_INVALID) Then
  // no verifier in this file? retry under an explicit recovery policy
  FileHandle:= Lib.DAOpenFileWithEncryptionKey(CaseFile, KeyHex,
    PDF_RAW_KEY_ALLOW_UNVERIFIED);
If FileHandle<> 0 Then
Begin
  Case Lib.DAGetEncryptionKeyValidation(FileHandle) Of
    PDF_RAW_KEY_VALIDATION_VERIFIED:
      Chain.Note('key verified against the encryption dictionary');
    PDF_RAW_KEY_VALIDATION_UNVERIFIED:
      Chain.Note('no verifier available: extraction is unattested');
  End;
End;

Read-only by construction, and who owns the stream

The raw-key file entry always opens the source with fmOpenRead or fmShareDenyWrite and marks the whole Direct Access chain read-only, so DAAppendFile refuses in-place writing and returns 2 rather than attempting an incremental update. That is not a policy you can talk the handle out of; it is set in the constructor before the file is even parsed. For evidence work the property you want is that the source bytes are byte-identical after the handle closes, and the regression suite asserts exactly that on both an AES-128 revision 4 fixture and an AES-256 revision 6 one. The stream entry behaves the same way on writes and adds one more rule: DAOpenFromStreamWithEncryptionKey never takes ownership, so DACloseFile leaves your TStream alive and you free it yourself

Source:= TMemoryStream.Create;
Try
  Source.LoadFromFile(ArchivePath);
  Source.Position:= 0;
  FileHandle:= Lib.DAOpenFromStreamWithEncryptionKey(Source, KeyHex);
  If FileHandle<> 0 Then
  Try
    // 2 = read-only handle: export elsewhere, never append to the evidence
    Assert(Lib.DAAppendFile(FileHandle)= 2);
    Harvest(Lib, FileHandle);
  Finally
    Lib.DACloseFile(FileHandle);
  End;
Finally
  Source.Free;   // the handle never owned this stream
End;

Key hygiene and what the DLL exports

Closing the chain overwrites the file key, the password cache and the derived object keys, and the decoded key is wiped in the Finally block of the entry point itself whether the open succeeded or not. There is a subtlety behind that which cost real debugging time: any copy that has to survive is cloned explicitly with SetLength plus Move rather than assigned. Assign one AnsiString to another in Delphi and both names share one buffer under copy-on-write, so wiping the caller side would zero the key the crypt handler is still using, and the document would decrypt to garbage for reasons no stack trace would explain. Only the file-based entry points cross the DLL boundary, in wide and ANSI forms, together with the validation status accessor; the TStream variant stays Delphi-only, because it depends on Delphi object lifetime and reference semantics that have no honest representation in a flat C ABI. If your recovery tooling is a DLL client, plan on staging to a temporary file and deleting it under the same controls you apply to the key

Treat the hex key as credential material with the handling rules you would give a document password, and keep the validation status in whatever log your chain of custody produces, so a later reader can tell a verified extraction from an unattested one. If you are evaluating a Delphi PDF component for forensics, bulk archival or a DRM migration, the raw-key entry points, the read-only guarantee and the encryption audit surface are all part of the same library, and you can read the full feature list on the PDF Library for Delphi product page