Технічна стаття

PDF crypt filters у Delphi: політики StmF, StrF, EFF

HotPDF Delphi PDF component реалізує модель crypt filter ISO 32000-1 §7.6.5 як три незалежні policies, а не один switch: ConfigureCryptFilterDefaults окремо призначає string filter /StrF, stream filter /StmF і embedded-file filter /EFF, SetStreamCryptFilter перевизначає один stream, а GetLoadedCryptFilterInfo повідомляє, що оголошує incoming file. Більшість interop bugs у encrypted PDF живе в проміжках між цими трьома рівнями

Ось failure, який приводить людей до цього layer. Команда постачає document, де page content має залишатися читабельним для downstream tool, а attached payload — ні, тому встановлює /EFF /StdCF і залишає /StmF /Identity. Acrobat відкриває його без проблем. Conforming third-party reader повертає attachment як ciphertext garbage, бо /EFF — producer-side policy про те, який filter застосовується до embedded files, а general reader усе ще розв’язує unmarked stream через /StmF. Виправлення — не інше значення /EFF. Виправлення — явний /Crypt filter на самому embedded-file stream

Що насправді контролює crypt filter layer

Crypt filters розташовані між encryption algorithm та object graph і вирішують, які objects торкається algorithm, а не як він працює. /CF dictionary всередині encryption dictionary зіставляє names із filter definitions, кожне з яких має /CFM method, optional /Length і /AuthEvent. Три top-level entries /StrF, /StmF і /EFF потім вибирають, який із named filters діє на strings, streams без explicit filter і embedded files. HotPDF навмисно обмежує те, що його built-in handlers записують. ConfigureCryptFilterDefaults приймає лише reserved names активного handler: Standard security handler видає /StdCF або /Identity, public-key handler — /DefaultCryptFilter або /Identity, а все інше піднімає EArgumentException у call site. Filters, записані зовнішніми producers під іншими names, усе одно зберігаються на load, inspection і compatibility-rewrite paths, тому HotPDF як writer консервативний, а як reader — permissive. Є ще два guards: call піднімає EInvalidOpException, коли serialization document уже почалася, і знову, якщо document перебуває в incremental update, бо encryption policy не може змінюватися між revisions одного 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 зашифровані, page streams залишаються plaintext, attachments зашифровані
    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;

Одне обмеження варто назвати на початку, бо воно перевіряється пізно й дивує. Named crypt filters у HotPDF потребують encryption document типу aes128, aes256 або aesgcm. Налаштуйте filter policy поверх RC4 k40 або k128 — і validation pass, який запускається під час увімкнення encryption, підніме exception замість тихого підвищення key type. Це та сама design stance, що й в іншій частині AES-256 PDF encryption path у Delphi: відхилити неоднозначну configuration замість вгадувати намір caller

Чому entry /Length означає дві різні речі?

Тому що spec визначає його в різних units залежно від security handler, і HotPDF має дотримуватися обох трактувань. У crypt filter dictionary, де /CFM дорівнює /V2, entry /Length виражено в bytes у Standard security handler і в bits у public-key handler. /Length в encryption dictionary поруч із /V (ISO 32000-1 §7.6.2) завжди виражений у bits. Прочитати filter dictionary з /Length 16 означає отримати 128-bit key у Standard-handler file і rejected file у public-key file. HotPDF нормалізує це під час захоплення loaded configuration. Він множить /V2 filter /Length на eight лише коли file не зашифрований public-key, бере document-level /Length, якщо filter не має власного, і зберігає result у THPDFCryptFilterInfo.KeyLengthBits. AESV2 зафіксований на 128 bits, а AESV3 і AESV4 — на 256, бо ці methods не мають negotiable key size. Далі стає суворо: приймаються лише 40-bit і 128-bit /V2. Filter, який розв’язується в будь-яку іншу length, повідомляється як unavailable, і operation провалюється, а не округлюється до 128 за теорією, що більшість producers мали на увазі 128. Саме тихе нормалізування key length дозволяє випустити file, який decrypt-иться на вашій machine і більше ніде

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 і /StmF типово мають Identity; /EFF типово наслідує /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;

Що гарантує /CFM /None і чим /Identity відрізняється?

Вони приводять до одного результату різними шляхами, і змішування цих понять ламає lookups. Named filter із /CFM /None і named filter, у якого /CFM повністю відсутній, обидва означають, що filter не виконує encryption або decryption — HotPDF перед resolving відображає missing entry на None, тому обидва приводять до hcfmNone із recorded key length zero. /Identity відрізняється за природою: це reserved name, який обходить /CF lookup повністю, тому document може посилатися на /Identity, не визначаючи його ніде в /CF. PDF names case-sensitive, і це робить ще одну implementation detail обов’язковою: жоден crypt filter lookup не може бути case-insensitive. HotPDF розв’язує names у /CF sub-dictionary, entry /Length filter і check stream /Type через case-sensitive dictionary lookups. File, який визначає /stdcf, тоді як /StmF вказує на /StdCF, є malformed, і вважати ці keys однаковими означало б перетворити помилку authoring, яку можна виявити, на тихо застосований неправильний key до кожного stream document

Як зробити /EFF чинним для embedded-file streams

Коли /EFF відрізняється від /StmF, embedded-file stream потребує explicit leading /Crypt entry у своєму /Filter і matching /DecodeParms dictionary з /Name на тій самій array position. HotPDF визначає це per stream під час save: знаходить /Type /EmbeddedFile, наслідує configured embedded-file filter і видає explicit /Crypt marker лише тоді, коли inherited name відрізняється від effective stream default. Коли /EFF і /StmF збігаються, marker не записується, бо reader усе одно розв’язав би той самий filter. Важлива не лише name, а й array position. Під час read stream назад HotPDF сканує /Filter у пошуку /Crypt, записує його index, а потім шукає в /DecodeParms на тому самому index, щоб знайти /Name. /Crypt на index 0, поєднаний із parameters на index 1, розв’язується в /Identity, а не у ваш filter. Ось чому writer доповнює parameter array null, якщо stream раніше мав /Filter, але не мав /DecodeParms: positions мають залишатися aligned

Під цим є ще гостріша пастка. Якщо existing /Filter або /DecodeParms є indirect object — це поширено у files від generators, які ділять один filter array між багатьма streams, — вставка /Crypt in place змінила б shared filter graph і пошкодила кожен інший stream, що на нього посилається. HotPDF розв’язує indirect object і спочатку clone-ить його в stream-private direct object, очищаючи object і generation numbers, щоб original indirect root ніколи не опинився всередині нового array. Для stream, який уже використовував ASCIIHexDecode, serialized result — /Filter [ /Crypt /ASCIIHexDecode ] із /DecodeParms [ << /Type /CryptFilterDecodeParms /Name /StdCF >> ... ]. Та сама positional discipline керує всіма іншими filter chains, зокрема тими, що проходяться під час витягування images із loaded PDF через їхні decode filters

// Editor уже тримає loaded document, а ContentStream —
// THPDFStreamObject, чий /Filter є 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');

// Порожнє name очищає override і під час наступного save прибирає stale /Crypt
// entry разом із його decode parameters
Editor.SetStreamCryptFilter(ContentStream, '');
Editor.SaveLoadedDocument('cleared.pdf');

Чи наслідують object streams document /Encrypt policy?

Ні, і припущення протилежного — надійний спосіб отримати garbage. Object stream має дотримуватися фактичної /StmF policy або власного explicit /Crypt marker: сама наявність /Encrypt dictionary не робить кожен /ObjStm container ciphertext. Document із /StmF /Identity має plaintext object streams, хоча його strings повністю encrypted, а decoder, який усе одно decrypt-ить їх, передає inflate stage input, який ніколи не був deflate output

Наслідок для member objects варто перечитати двічі. За ISO 32000-1 §7.5.7 strings усередині encrypted object stream уже plaintext після decryption самого container, тому повторна decryption була б double-decrypt. HotPDF захищає це, перевіряючи, чи був container кожного type-2 object encrypted, і пропускаючи object, коли був, а skips рахує в XRefProbeDecryptObjStmSkips як пряме evidence, що guard спрацював. Коли container був plaintext, member strings ніколи нічим не накривалися, тому HotPDF materialize-ить ці members і застосовує /StrF до кожного окремо — з keying, яке реалізація справді використовує, за member object number і generation, а не за object number containing /ObjStm. Переверніть це на mixed-policy file — і кожен string у кожному compressed object декодується в noise. Правила container-level навколо цього детальніше описані в нотатках про PDF object streams та incremental updates

Де HotPDF відмовляється вгадувати

Crypt filter semantics не існує нижче /V 4, тому HotPDF відхиляє будь-який per-stream override на такому file з explicit error, а не записує /Crypt marker, якого не визнав би жоден conforming reader. Те саме на read side: encryption dictionary із /V нижче 4 очищає всі три loaded filter names, бо там нічого повідомляти. Ще три boundaries enforced навмисно:

  • Non-Identity per-stream filter у public-key encrypted document відхиляється, бо stream-specific policy під public-key handler потребує stream-specific recipient envelope, який HotPDF поки не генерує
  • Public-key encrypted embedded files, у яких /EFF відрізняється від effective /StmF, відхиляються з тієї самої причини, замість запису shape, який не decrypt-иться ні для кого
  • AES-256 direct-file fast path застосовується лише коли strings, streams і embedded files розв’язуються в один crypt filter method і жоден object у file не має explicit /Crypt; mixed policy або plaintext metadata примушує fallback до full object-graph path

Жодне з цього не є performance decision. Це межі, де wrong guess дає PDF, який відкривається в одному viewer, ламається в іншому і не дає developer-у жодного сигналу, доки customer не повідомить про проблему. Refusal у ConfigureCryptFilterDefaults або під час save коштує одного exception; silently mis-keyed embedded file коштує support cycle. Якщо ви створюєте Delphi або C++Builder software, яке produce або consume encrypted PDF — selectively plaintext page content із encrypted attachments, PDF 2.0 encrypted payload wrappers або interop із files, чиї crypt filter policies вибирали не ви, — описаний тут crypt filter API входить до поточного HotPDF Delphi PDF component разом з encryption, object stream та incremental update paths, на яких він побудований