مقاله فنی

Crypt filter در Delphi: policyهای StmF، StrF و EFF در PDF

HotPDF Delphi PDF component مدل crypt filter در ISO 32000-1 §7.6.5 را به‌جای یک switch، به‌صورت سه policy مستقل پیاده می‌کند: ConfigureCryptFilterDefaults، string filter یعنی /StrF، stream filter یعنی /StmF و embedded-file filter یعنی /EFF را جداگانه assign می‌کند؛ SetStreamCryptFilter یک stream را override می‌کند و GetLoadedCryptFilterInfo آنچه یک file ورودی اعلام کرده report می‌دهد. بیشتر bugهای interop در PDF رمزگذاری‌شده، در فاصله بین همین سه مورد زندگی می‌کنند

این failure است که افراد را به این لایه می‌فرستد. یک team documentی تولید می‌کند که محتوای page باید برای ابزار downstream قابل‌خواندن بماند اما payload پیوست نباید خوانده شود؛ پس /EFF /StdCF را تنظیم می‌کند و /StmF /Identity را باقی می‌گذارد. Acrobat آن را خوب باز می‌کند. یک reader ثالث منطبق با استاندارد، attachment را به‌صورت ciphertext garbage تحویل می‌دهد، چون /EFF در سمت producer policy مربوط به filterی است که روی embedded file اعمال می‌شود و reader عمومی، stream بدون marker را همچنان با /StmF resolve می‌کند. راه‌حل value متفاوتی برای /EFF نیست؛ راه‌حل، یک filter صریح /Crypt روی خود embedded-file stream است

لایه crypt filter واقعاً چه چیزی را کنترل می‌کند؟

crypt filterها بین encryption algorithm و object graph قرار می‌گیرند و تعیین می‌کنند algorithm به کدام objectها دست بزند، نه اینکه چگونه کار کند. dictionary مربوط به /CF داخل encryption dictionary، nameها را به تعریف filterها map می‌کند و هر تعریف شامل methodای از نوع /CFM، یک /Length اختیاری و یک /AuthEvent است. سپس سه entry سطح بالا یعنی /StrF، /StmF و /EFF مشخص می‌کنند کدام filter نام‌گذاری‌شده برای stringها، streamهای بدون filter صریح و embedded fileها اعمال شود. HotPDF عمداً چیزهایی را که handlerهای built-in می‌توانند write کنند محدود می‌کند. ConfigureCryptFilterDefaults فقط nameهای رزروشده handler فعال را می‌پذیرد: Standard security handler، /StdCF یا /Identity صادر می‌کند، public-key handler، /DefaultCryptFilter یا /Identity و هر چیز دیگر در محل call، EArgumentException ایجاد می‌کند. filterهایی که producerهای خارجی با nameهای دیگر نوشته‌اند، در مسیرهای load، inspection و compatibility-rewrite همچنان حفظ می‌شوند؛ بنابراین HotPDF به‌عنوان writer محافظه‌کار و به‌عنوان reader پذیراست. دو guard دیگر هم وجود دارد: call پس از شروع document serialization با EInvalidOpException fail می‌شود و اگر document در incremental update باشد نیز همین اتفاق رخ می‌دهد، چون policy رمزگذاری نمی‌تواند بین revisionهای یک 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;
    // stringها encrypted، streamهای page plaintext و attachmentها 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;

یک constraint را باید از ابتدا گفت، چون دیر check می‌شود و افراد را غافلگیر می‌کند. crypt filterهای نام‌گذاری‌شده در HotPDF به encryption document از نوع aes128، aes256 یا aesgcm نیاز دارند. policy filter را روی RC4 از نوع k40 یا k128 تنظیم کنید؛ pass اعتبارسنجی که هنگام فعال شدن encryption اجرا می‌شود exception می‌دهد و به‌طور خاموش key type را promote نمی‌کند. این همان موضع طراحی در باقی مسیر رمزگذاری PDF با AES-256 در Delphi است: configuration مبهم را رد کن، به‌جای اینکه حدس بزنی caller چه منظوری داشته است

چرا entry مربوط به /Length دو معنای متفاوت دارد؟

چون spec آن را بر اساس security handler در دو واحد متفاوت تعریف می‌کند و HotPDF باید هر دو را رعایت کند. در dictionary مربوط به crypt filter که /CFM آن /V2 است، entry مربوط به /Length در Standard security handler برحسب byte و در public-key handler برحسب bit بیان می‌شود. /Length در encryption dictionary که کنار /V قرار دارد (ISO 32000-1 §7.6.2)، همیشه برحسب bit است. dictionary filter با /Length 16 را بخوانید: در file مربوط به Standard handler یک key صدوبیست‌وهشت‌بیتی دارید و در file مربوط به public-key با file ردشده روبه‌رو می‌شوید. HotPDF هنگام capture کردن configuration بارگذاری‌شده این تفاوت را normalize می‌کند. مقدار /Length filter نوع /V2 را فقط وقتی در 8 ضرب می‌کند که file با public key encrypt نشده باشد، اگر filter مقدار خودش را نداشته باشد به /Length سطح document برمی‌گردد و نتیجه را در THPDFCryptFilterInfo.KeyLengthBits ذخیره می‌کند. AESV2 روی 128 bit و AESV3 و AESV4 روی 256 ثابت شده‌اند، چون این methodها اندازه key قابل مذاکره ندارند. بخش سخت‌گیرانه بعد می‌آید: فقط /V2 با 40 bit و 128 bit پذیرفته می‌شود. filterی که به هر length دیگری resolve شود unavailable گزارش می‌شود و operation fail می‌کند، نه اینکه با این نظریه که بیشتر producerها حتماً 128 را منظور داشته‌اند به 128 round شود. normalize کردن خاموش key length همان راهی است که fileی می‌سازید که روی machine شما decrypt می‌شود و جای دیگری نه

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 چه تفاوتی دارد؟

هر دو به outcome یکسان می‌رسند اما از مسیر متفاوت و اشتباه گرفتن آن‌ها lookupها را خراب می‌کند. filter نام‌گذاری‌شده‌ای که /CFM آن /None است و filter نام‌گذاری‌شده‌ای که اصلاً /CFM ندارد، هر دو یعنی این filter هیچ encryption یا decryptionی انجام نمی‌دهد؛ HotPDF entry غایب را پیش از resolve به None map می‌کند، پس هر دو به hcfmNone با key length ثبت‌شده صفر می‌رسند. /Identity از اساس متفاوت است: نام رزروشده‌ای است که lookup مربوط به /CF را به‌طور کامل bypass می‌کند، بنابراین document می‌تواند به /Identity اشاره کند بدون اینکه آن را جایی داخل /CF تعریف کرده باشد. PDF nameها case-sensitive هستند و همین موضوع یک جزئیات implementation غیرقابل‌مذاکره دیگر ایجاد می‌کند: هیچ crypt filter lookupی نباید case-insensitive باشد. HotPDF نام‌های sub-dictionary مربوط به /CF، entry /Length filter و check مربوط به stream /Type را با lookupهای case-sensitive resolve می‌کند. fileی که /stdcf را تعریف می‌کند در حالی که /StmF به /StdCF اشاره دارد malformed است و یکسان گرفتن این دو key، bug قابل‌تشخیص authoring را به key اشتباهی تبدیل می‌کند که بی‌سروصدا روی همه streamهای document اعمال شده است

وادار کردن /EFF به ماندن روی embedded-file streamها

وقتی /EFF با /StmF متفاوت است، embedded-file stream به entry صریح و ابتدایی /Crypt در /Filter و dictionary متناظر /DecodeParms نیاز دارد که /Name را در همان array position حمل کند. HotPDF هنگام save این را برای هر stream محاسبه می‌کند: /Type /EmbeddedFile را تشخیص می‌دهد، embedded-file filter تنظیم‌شده را به ارث می‌برد و فقط وقتی marker صریح /Crypt را emit می‌کند که name به‌ارث‌رسیده با stream default مؤثر فرق داشته باشد. وقتی /EFF و /StmF یکی هستند، marker نوشته نمی‌شود، چون reader در هر حال همان filter را resolve می‌کند. پس array position به اندازه name مهم است. HotPDF هنگام read کردن stream، /Filter را برای entry از نوع /Crypt scan می‌کند، index آن را ثبت می‌کند و سپس برای پیدا کردن /Name همان index را در array مربوط به /DecodeParms lookup می‌کند. /Crypt در index 0 که با parameterهای index 1 جفت شده باشد به /Identity resolve می‌شود، نه filter شما. به همین دلیل writer وقتی stream قبلاً /Filter داشته اما /DecodeParms نداشته، array parameter را با null پر می‌کند: positionها باید aligned بمانند

یک دام جدی‌تر زیر این رفتار وجود دارد. اگر /Filter یا /DecodeParms موجود یک indirect object باشد، چیزی که در fileهای generatorهایی که یک filter array را بین streamهای متعدد share می‌کنند رایج است، insert کردن /Crypt درجا، shared filter graph را mutate می‌کند و همه streamهای دیگری را که به آن اشاره دارند corrupt می‌کند. HotPDF indirect object را resolve می‌کند و ابتدا آن را در یک direct object خصوصی stream clone می‌کند و object و generation number را clear می‌کند تا root indirect اصلی هرگز داخل array جدید embed نشود. برای streamی که از قبل ASCIIHexDecode استفاده می‌کرده، نتیجه serialize‌شده /Filter [ /Crypt /ASCIIHexDecode ] با /DecodeParms [ << /Type /CryptFilterDecodeParms /Name /StdCF >> ... ] است. همین discipline مربوط به position بر هر filter chain دیگری هم حاکم است، از جمله chainهایی که هنگام استخراج image از PDF بارگذاری‌شده از مسیر decode filterهای آن طی می‌کنید

// Editor از قبل document بارگذاری‌شده را نگه داشته و ContentStream یک
// THPDFStreamObject است که /Filter آن نام indirect /ASCIIHexDecode است
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');

// نام خالی override را clear می‌کند و در save بعدی، entry قدیمی /Crypt
// را همراه parameterهای decode آن strip می‌کند
Editor.SetStreamCryptFilter(ContentStream, '');
Editor.SaveLoadedDocument('cleared.pdf');

آیا object streamها policy مربوط به /Encrypt document را به ارث می‌برند؟

خیر، و فرض کردن این موضوع راه مطمئنی برای تولید garbage است. یک object stream باید از policy واقعی /StmF یا marker صریح /Crypt خودش پیروی کند؛ صرف وجود یک dictionary از نوع /Encrypt همه containerهای /ObjStm را ciphertext نمی‌کند. documentی با /StmF /Identity، object streamهای plaintext دارد، حتی اگر stringهایش کاملاً encrypted باشند و decoderی که آن‌ها را دوباره decrypt کند، inputی را به مرحله inflate می‌دهد که هرگز خروجی deflate نبوده است

پیامد مربوط به member objectها ارزش دوباره خواندن دارد. طبق ISO 32000-1 §7.5.7، stringهای داخل یک object stream encrypted، وقتی خود container decrypt شد، از قبل plaintext هستند؛ بنابراین decrypt کردن دوباره آن‌ها double-decrypt است. HotPDF با query کردن اینکه container هر object نوع 2 encrypted بوده یا نه، از این وضعیت guard می‌کند و وقتی encrypted بوده object را skip می‌کند؛ تعداد skipها نیز در XRefProbeDecryptObjStmSkips ثبت می‌شود تا evidence مستقیم اجرای guard باشد. وقتی container plaintext بوده، stringهای member هرگز تحت پوشش چیزی نبوده‌اند، بنابراین HotPDF آن memberها را materialize می‌کند و /StrF را روی هرکدام جداگانه اعمال می‌کند؛ keying دقیقاً طبق implementation با member object number و generation انجام می‌شود، نه object number مربوط به /ObjStm دربرگیرنده. این ترتیب را در file دارای policy مختلط برعکس کنید و هر string در هر object فشرده به noise decode می‌شود. ruleهای سطح container در این زمینه در یادداشت مربوط به PDF object streamها و incremental updateها بیشتر پوشش داده شده‌اند

HotPDF کجا از حدس زدن خودداری می‌کند؟

semanticهای crypt filter پایین‌تر از /V 4 وجود ندارند، بنابراین HotPDF هر override مربوط به هر stream را در چنین fileی با error صریح رد می‌کند، نه اینکه marker از نوع /Cryptی بنویسد که هیچ reader منطبقی آن را رعایت نمی‌کند. در سمت read نیز همین‌طور است: encryption dictionary با /V کمتر از 4 هر سه نام filter بارگذاری‌شده را clear می‌کند، چون چیزی برای report کردن وجود ندارد. سه مرز دیگر نیز عمداً enforce می‌شوند:

  • filter مربوط به هر stream که Identity نباشد روی document رمزگذاری‌شده با public key رد می‌شود، چون policy مخصوص stream در public-key handler به recipient envelope مخصوص stream نیاز دارد و HotPDF هنوز آن را emit نمی‌کند
  • embedded fileهای رمزگذاری‌شده با public key که /EFF آن‌ها با /StmF مؤثر فرق داشته باشد، به همان دلیل رد می‌شوند، نه اینکه در شکلی نوشته شوند که برای هیچ‌کس decrypt نشود
  • مسیر fast مربوط به direct-file در AES-256 فقط وقتی اعمال می‌شود که string، stream و embedded file همگی به یک method از crypt filter resolve شوند و هیچ objectی در file، /Crypt صریح نداشته باشد؛ policy مختلط یا metadata plaintext، fallback به مسیر کامل object graph را اجباری می‌کند

هیچ‌کدام از این‌ها تصمیم performance نیستند. آن‌ها جاهایی را مشخص می‌کنند که حدس اشتباه PDFی می‌سازد که در یک viewer باز می‌شود، در دیگری fail می‌کند و تا زمانی که customer گزارش ندهد هیچ signalی به developer نمی‌دهد. refusal در ConfigureCryptFilterDefaults یا زمان save هزینه یک exception دارد؛ embedded fileی که بی‌سروصدا با key اشتباه نوشته شده یک چرخه support هزینه دارد. اگر نرم‌افزار Delphi یا C++Builder می‌سازید که PDF رمزگذاری‌شده تولید یا مصرف می‌کند، از محتوای page که عمداً plaintext و attachmentهای رمزگذاری‌شده دارد تا wrapperهای payload رمزگذاری‌شده PDF 2.0 یا interop با fileهایی که policy crypt filter آن‌ها را شما انتخاب نکرده‌اید، API مربوط به crypt filter در HotPDF Delphi PDF component فعلی ارائه می‌شود و کنار مسیرهای encryption، object stream و incremental update قرار دارد که بر آن‌ها تکیه می‌کند