Technical Article

PDF Crypt Filters in Delphi: StmF, StrF, EFF Policies

The HotPDF Delphi PDF component implements the ISO 32000-1 §7.6.5 crypt filter model as three independent policies rather than one switch: ConfigureCryptFilterDefaults assigns the string filter /StrF, the stream filter /StmF and the embedded-file filter /EFF separately, SetStreamCryptFilter overrides a single stream, and GetLoadedCryptFilterInfo reports what an incoming file declares. Most encrypted-PDF interop bugs live in the gaps between those three

Here is the failure that sends people into this layer. A team ships a document where the page content must stay readable by a downstream tool but the attached payload must not, so they set /EFF /StdCF and leave /StmF /Identity. Acrobat opens it fine. A conforming third-party reader hands back the attachment as ciphertext garbage, because /EFF is a producer-side policy about which filter applies to embedded files and a general reader still resolves an unmarked stream through /StmF. The fix is not a different /EFF value. The fix is an explicit /Crypt filter on the embedded-file stream itself

What the crypt filter layer actually controls

Crypt filters sit between the encryption algorithm and the object graph, and they decide which objects the algorithm touches, not how it works. The /CF dictionary inside the encryption dictionary maps names to filter definitions, each carrying a /CFM method, an optional /Length and an /AuthEvent. The three top-level entries /StrF, /StmF and /EFF then select which of those named filters applies to strings, to streams without an explicit filter, and to embedded files. HotPDF deliberately restricts what its built-in handlers will write. ConfigureCryptFilterDefaults accepts only the reserved names for the active handler: the Standard security handler emits /StdCF or /Identity, the public-key handler emits /DefaultCryptFilter or /Identity, and anything else raises EArgumentException at the call site. Filters written by external producers under other names are still preserved on the load, inspection and compatibility-rewrite paths, so HotPDF is conservative as a writer and permissive as a reader. Two further guards apply: the call raises EInvalidOpException once document serialization has begun, and again if the document is in an incremental update, because encryption policy cannot change between revisions of the same file

var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.AutoLaunch := False;
    Pdf.FileName := 'wrapper.pdf';
    Pdf.OwnerPassword := 'owner-secret';
    Pdf.UserPassword := 'open-secret';
    Pdf.CryptKeyLength := aes128;
    // strings encrypted, page streams plaintext, attachments encrypted
    Pdf.ConfigureCryptFilterDefaults('StdCF', 'Identity', 'StdCF');
    Pdf.ActivateProtection := True;
    Pdf.BeginDoc;
    Pdf.CurrentPage.SetFont('Arial', [], 12);
    Pdf.CurrentPage.TextOut(50, 50, 0, 'Visible stream operators');
    Pdf.AddDocumentAttachment('payload.bin', 'Encrypted payload');
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

One constraint is worth stating up front, because it is checked late and surprises people. Named crypt filters in HotPDF require aes128, aes256 or aesgcm document encryption. Configure a filter policy on top of RC4 k40 or k128 and the validation pass that runs when encryption is enabled raises rather than quietly promoting the key type. That is the same design stance as the rest of the AES-256 PDF encryption path in Delphi: refuse the ambiguous configuration instead of guessing what the caller meant

Why does the /Length entry mean two different things?

Because the spec defines it in two different units depending on the security handler, and HotPDF has to honor both. In a crypt filter dictionary whose /CFM is /V2, the /Length entry is expressed in bytes under the Standard security handler and in bits under the public-key handler. The encryption-dictionary /Length that sits alongside /V (ISO 32000-1 §7.6.2) is always in bits. Read a filter dictionary carrying /Length 16 and you have a 128-bit key in a Standard-handler file and a rejected file in a public-key one. HotPDF normalizes this when it captures the loaded configuration. It multiplies a /V2 filter /Length by eight only when the file is not public-key encrypted, falls back to the document-level /Length when the filter omits its own, and stores the result in THPDFCryptFilterInfo.KeyLengthBits. AESV2 is pinned to 128 bits and AESV3 and AESV4 to 256, since those methods have no negotiable key size. The strict part comes next: only 40-bit and 128-bit /V2 are accepted. A filter that resolves to any other length is reported as unavailable and the operation fails, rather than being rounded to 128 on the theory that most producers meant 128 anyway. Silently normalizing a key length is how you ship a file that decrypts on your machine and nowhere else

var
  Reader: THotPDF;
  Info: THPDFCryptFilterInfo;
  I: Integer;
begin
  Reader := THotPDF.Create(nil);
  try
    Reader.AutoLaunch := False;
    if Reader.LoadFromFile('incoming.pdf', 'open-secret') <> 1 then
      Exit;
    // /StrF and /StmF default to Identity; /EFF defaults to /StmF
    WriteLn(Reader.LoadedStringCryptFilterName);        // StdCF
    WriteLn(Reader.LoadedStreamCryptFilterName);        // Identity
    WriteLn(Reader.LoadedEmbeddedFileCryptFilterName);  // StdCF
    for I := 0 to Reader.GetLoadedCryptFilterCount - 1 do
      if Reader.GetLoadedCryptFilterInfo(I, Info) then
        if (Info.Method = hcfmV2) and
           not (Info.KeyLengthBits in [40, 128]) then
          raise Exception.CreateFmt(
            'crypt filter /%s: unsupported V2 key length %d',
            [String(Info.Name), Info.KeyLengthBits]);
  finally
    Reader.Free;
  end;
end;

What does /CFM /None guarantee, and how does /Identity differ?

They reach the same outcome by different routes, and conflating them breaks lookups. A named filter whose /CFM is /None, and a named filter that omits /CFM entirely, both mean that this filter performs no encryption or decryption — HotPDF maps the missing entry to None before resolving, so both land on hcfmNone with a recorded key length of zero. /Identity is different in kind: it is the reserved name that bypasses the /CF lookup altogether, so a document may reference /Identity without defining it anywhere in /CF. PDF names are case-sensitive, which makes one further implementation detail non-negotiable: no crypt filter lookup may be case-insensitive. HotPDF resolves /CF sub-dictionary names, the filter /Length entry and the stream /Type check through case-sensitive dictionary lookups. A file that defines /stdcf while /StmF points at /StdCF is malformed, and treating the two as the same key would turn a detectable authoring bug into a wrong key applied silently to every stream in the document

Making /EFF stick on embedded-file streams

When /EFF differs from /StmF, the embedded-file stream needs an explicit leading /Crypt entry in its /Filter and a matching /DecodeParms dictionary carrying /Name at the same array position. HotPDF works this out per stream at save time: it detects /Type /EmbeddedFile, inherits the configured embedded-file filter, and emits the explicit /Crypt marker only when that inherited name differs from the effective stream default. When /EFF and /StmF agree, no marker is written, because a reader would resolve the same filter anyway. The array position then matters as much as the name. When HotPDF reads a stream back, it scans /Filter for the /Crypt entry, records its index, and then looks up that same index in the /DecodeParms array to find the /Name. A /Crypt at index 0 paired with parameters at index 1 resolves to /Identity, not to your filter. That is also why the writer pads the parameter array with a null when the stream previously had a /Filter but no /DecodeParms: the positions have to stay aligned

There is a sharper trap underneath. If the existing /Filter or /DecodeParms is an indirect object — common in files from generators that share one filter array across many streams — inserting /Crypt in place would mutate a shared filter graph and corrupt every other stream pointing at it. HotPDF resolves the indirect object and clones it into a stream-private direct object first, clearing the object and generation numbers so the original indirect root is never embedded inside the new array. For a stream that already used ASCIIHexDecode the serialized result is /Filter [ /Crypt /ASCIIHexDecode ] with /DecodeParms [ << /Type /CryptFilterDecodeParms /Name /StdCF >> ... ]. The same positional discipline governs every other filter chain, including the ones you walk when extracting images from a loaded PDF through their decode filters

// Editor already holds a loaded document, and ContentStream is a
// THPDFStreamObject whose /Filter is an indirect /ASCIIHexDecode name
Editor.OwnerPassword := 'owner-secret';
Editor.UserPassword := 'open-secret';
Editor.CryptKeyLength := aes128;
Editor.ConfigureCryptFilterDefaults('StdCF', 'Identity');
Editor.SetStreamCryptFilter(ContentStream, 'StdCF');
Editor.ActivateProtection := True;
Editor.SaveLoadedDocument('out.pdf');

// An empty name clears the override and strips the stale /Crypt
// entry together with its decode parameters on the next save
Editor.SetStreamCryptFilter(ContentStream, '');
Editor.SaveLoadedDocument('cleared.pdf');

Do object streams inherit the document /Encrypt policy?

No, and assuming they do is a reliable way to produce garbage. An object stream must follow the actual /StmF policy or its own explicit /Crypt marker: the mere presence of an /Encrypt dictionary does not make every /ObjStm container ciphertext. A document with /StmF /Identity has plaintext object streams even though its strings are fully encrypted, and a decoder that decrypts them anyway feeds the inflate stage input that was never deflate output

The consequence for member objects is the part worth reading twice. Per ISO 32000-1 §7.5.7, strings inside an encrypted object stream are already plaintext once the container itself is decrypted, so decrypting them again would be a double-decrypt. HotPDF guards that by querying whether each type-2 object's container was encrypted and skipping the object when it was, counting the skips into XRefProbeDecryptObjStmSkips as direct evidence that the guard fired. When the container was plaintext, the member strings were never covered by anything, so HotPDF materializes those members and applies /StrF to each one individually — keyed, as the implementation actually does it, by the member object number and generation, not by the containing /ObjStm object number. Reverse that on a mixed-policy file and every string in every compressed object decodes to noise. The container-level rules around this are covered further in the notes on PDF object streams and incremental updates

Where HotPDF refuses to guess

Crypt filter semantics do not exist below /V 4, so HotPDF rejects any per-stream override on such a file with an explicit error rather than writing a /Crypt marker that no conforming reader would honor. The same holds on the read side: an encryption dictionary with /V below 4 clears all three loaded filter names, because there is nothing there to report. Three further boundaries are enforced deliberately:

  • A non-Identity per-stream filter on a public-key encrypted document is refused, because a stream-specific policy under the public-key handler needs a stream-specific recipient envelope that HotPDF does not yet emit
  • Public-key encrypted embedded files whose /EFF differs from the effective /StmF are refused for the same reason, rather than written in a shape that decrypts for nobody
  • The AES-256 direct-file fast path applies only when strings, streams and embedded files all resolve to the same crypt filter method and no object in the file carries an explicit /Crypt; a mixed policy or plaintext metadata forces a fallback to the full object-graph path

None of these are performance decisions. They mark the places where a wrong guess yields a PDF that opens in one viewer, fails in another, and gives the developer no signal at all until a customer reports it. A refusal at ConfigureCryptFilterDefaults or at save time costs one exception; a silently mis-keyed embedded file costs a support cycle. If you build Delphi or C++Builder software that produces or consumes encrypted PDF — selectively plaintext page content with encrypted attachments, PDF 2.0 encrypted payload wrappers, or interop with files whose crypt filter policies you did not choose — the crypt filter API described here ships in the current HotPDF Delphi PDF component, alongside the encryption, object stream and incremental update paths it builds on