技術記事

DelphiのPDF crypt filter:StmF、StrF、EFF policy

HotPDF Delphi PDF componentは、ISO 32000-1 §7.6.5のcrypt filter modelを1つのswitchではなく、3つの独立したpolicyとして実装しています。ConfigureCryptFilterDefaultsはstring filter /StrF、stream filter /StmF、embedded-file filter /EFFを個別に割り当て、SetStreamCryptFilterは単一streamをoverrideし、GetLoadedCryptFilterInfoはincoming fileが宣言する内容を報告します。encrypted PDFのinterop bugの多くは、この3つの隙間に潜んでいます

この層へ人を送り込むfailureは次のようなものです。teamが、page contentはdownstream toolから読めるままにし、attached payloadだけは読めないdocumentを出荷したため、/EFF /StdCFを設定して/StmF /Identityを残します。Acrobatは問題なく開きます。conforming third-party readerはattachmentをciphertext garbageとして返します。/EFFはembedded fileにどのfilterを適用するかというproducer-side policyであり、一般的なreaderはmarkされていないstreamを/StmFで解決するためです。修正は別の/EFF valueではありません。embedded-file stream自身に明示的な/Crypt filterを置きます

crypt filter layerが実際に制御するもの

crypt filterはencryption algorithmとobject graphの間にあり、algorithmの動作ではなく、どのobjectにalgorithmを適用するかを決めます。encryption dictionary内の/CF dictionaryはnameをfilter definitionへmapし、それぞれが/CFM method、optionalな/Length/AuthEventを持ちます。top-levelの/StrF/StmF/EFFは、named filterのどれをstring、explicit filterを持たないstream、embedded fileに適用するかを選びます。HotPDFはbuilt-in handlerがwriteする内容を意図的に制限します。ConfigureCryptFilterDefaultsが受け入れるのはactive handler用のreserved nameだけです。Standard security handlerは/StdCFまたは/Identityを出力し、public-key handlerは/DefaultCryptFilterまたは/Identityを出力し、それ以外はcall siteでEArgumentExceptionをraiseします。外部producerが別名で書いたfilterはload、inspection、compatibility-rewrite pathでは保持されるため、HotPDFはwriterとしてはconservativeで、readerとしてはpermissiveです。さらに2つのguardがあります。document serializationが始まった後のcallはEInvalidOpExceptionをraiseし、incremental update中にも同じことをします。同じfileのrevision間でencryption policyを変更できないためです

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、page streamは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;

先に述べるべき制約があります。lateにcheckされるため、驚く人がいます。HotPDFのnamed crypt filterにはaes128aes256aesgcmのdocument encryptionが必要です。RC4 k40またはk128の上にfilter policyを設定すると、encryption enable時に実行されるvalidation passがkey typeを黙ってpromoteせずraiseします。これはDelphiのAES-256 PDF encryption pathと同じ設計姿勢です。callerが意味したものを推測するのではなく、曖昧なconfigurationを拒否します

/Length entryが2つの意味を持つ理由

security handlerによってspecが異なるunitで定義しているためで、HotPDFは両方を守らなければなりません。/CFM/V2のcrypt filter dictionaryでは、/Length entryはStandard security handlerではbyte、public-key handlerではbitで表します。/Vと並ぶencryption-dictionaryの/Length(ISO 32000-1 §7.6.2)は常にbitです。/Length 16を持つfilter dictionaryを読むと、Standard-handler fileでは128-bit keyとなり、public-key fileではrejected fileになります。HotPDFはloaded configurationをcaptureするときにこれをnormalizeします。public-key encryptedでないfileの場合だけ/V2 filterの/Lengthを8倍し、filterが自身の値を省略すればdocument-level /Lengthへfallbackし、結果をTHPDFCryptFilterInfo.KeyLengthBitsへ保存します。AESV2は128 bit、AESV3AESV4は256 bitに固定されます。これらのmethodにはnegotiableなkey sizeがないからです。さらにstrictな部分があります。受け入れる/V2は40-bitと128-bitだけです。それ以外のlengthへ解決されるfilterはunavailableとして報告され、operationはfailureになります。「多くのproducerは128を意図したはず」と考えて128へroundすることはありません。key lengthを黙ってnormalizeすると、自分のmachineではdecryptでき、他ではできないfileを出荷することになります

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のdefaultはIdentity、/EFFのdefaultは/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との差

同じ結果へ別の経路で到達するため、混同するとlookupが壊れます。/CFM/Noneのnamed filterと、/CFM自体を完全に省略したnamed filterは、どちらもこのfilterがencryptionもdecryptionも行わないことを意味します。HotPDFはresolve前にmissing entryをNoneへmapするため、両方ともkey length 0を記録したhcfmNoneになります。/Identityは種類が異なります。/CF lookupを完全にbypassするreserved nameなので、documentは/CFのどこにも定義せずに/Identityを参照できます。PDF nameはcase-sensitiveです。そのため、もう1つの実装細部も譲れません。crypt filter lookupをcase-insensitiveにしてはいけません。HotPDFは/CF sub-dictionaryのname、filterの/Length entry、streamの/Type checkをcase-sensitive dictionary lookupで解決します。/stdcfを定義しながら/StmF/StdCFを指すfileはmalformedです。2つを同じkeyとして扱うと、検出可能なauthoring bugが、document内すべてのstreamへ誤ったkeyを黙って適用する問題へ変わります

embedded-file streamで/ EFFを確実に効かせる

/EFF/StmFと異なるとき、embedded-file streamは/Filterの先頭に明示的な/Crypt entryを必要とし、同じarray positionに/Nameを持つmatching /DecodeParms dictionaryも必要です。HotPDFはsave時にstreamごとにこれを解決します。/Type /EmbeddedFileを検出し、configured embedded-file filterをinheritし、inheritしたnameがeffective stream defaultと異なる場合だけ明示的な/Crypt markerを出力します。/EFF/StmFが一致するならmarkerは書きません。readerが同じfilterをresolveするためです。ここではarray positionがnameと同じくらい重要です。HotPDFがstreamを読み戻すときは/Filterから/Crypt entryをscanし、その同じindexを/DecodeParms arrayでlookupして/Nameを見つけます。index 0の/Cryptとindex 1のparameterを組み合わせると、あなたのfilterではなく/Identityへ解決されます。streamに以前/Filterはあったのに/DecodeParmsがなかったとき、writerがparameter arrayをnullでpadするのも同じ理由です。positionをalignmentしたままにする必要があります

さらに鋭いtrapがあります。既存の/Filter/DecodeParmsがindirect objectの場合、generatorが複数streamで1つのfilter arrayを共有するfileではよくありますが、in placeで/Cryptを挿入するとshared filter graphが変更され、そのarrayを指すすべてのstreamが壊れます。HotPDFはindirect objectをresolveし、まずstream-privateなdirect objectへcloneします。objectとgeneration numberをclearするので、original indirect rootが新しいarray内へ埋め込まれることもありません。すでにASCIIHexDecodeを使っていたstreamでは、serialized resultは/Filter [ /Crypt /ASCIIHexDecode ]/DecodeParms [ << /Type /CryptFilterDecodeParms /Name /StdCF >> ... ]になります。同じpositional disciplineは、loaded PDFからdecode filterを通じてimageをextractする処理を含む他のfilter chainにも適用されます

// Editorはloaded documentを保持し、ContentStreamは
// /Filterがindirectな/ASCIIHexDecode nameであるTHPDFStreamObject
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をclearし、次のsaveでstaleな/Cryptと
// そのdecode parameterを取り除く
Editor.SetStreamCryptFilter(ContentStream, '');
Editor.SaveLoadedDocument('cleared.pdf');

object streamはdocument /Encrypt policyをinheritするのか

しません。そう思うと、確実にgarbageを作ります。object streamは実際の/StmF policy、または自身の明示的な/Crypt markerに従います。/Encrypt dictionaryが存在するだけで、すべての/ObjStm containerがciphertextになるわけではありません。/StmF /Identityのdocumentは、stringが完全にencryptedでもplaintext object streamを持ちます。それをdecryptするdecoderは、deflate outputでなかったinputをinflate stageへ渡すことになります

member objectについては、もう一度読む価値のある部分です。ISO 32000-1 §7.5.7によれば、encrypted object stream内のstringはcontainer自身がdecryptされた時点ですでにplaintextです。そのため再びdecryptするとdouble-decryptになります。HotPDFは各type-2 objectについてcontainerがencryptedだったかをqueryし、そうならobjectをskipします。guardが発火した直接の証拠として、skipをXRefProbeDecryptObjStmSkipsへcountします。containerがplaintextならmember stringは何にも覆われていないため、HotPDFはmemberをmaterializeし、各stringへ個別に/StrFを適用します。実装が実際に使うkeyは、含んでいる/ObjStm object numberではなくmember object numberとgenerationです。mixed-policy fileでこれを逆にすると、圧縮object内のすべてのstringがnoiseへdecodeされます。この周辺のcontainer-level ruleはPDF object streamとincremental updateのnotesでも詳しく扱っています

HotPDFが推測を拒否する場所

crypt filter semanticsは/V 4未満には存在しないため、HotPDFはそのようなfileへのper-stream overrideを明示的なerrorで拒否します。conforming readerが尊重しない/Crypt markerを書くことはありません。read sideも同じです。/Vが4未満のencryption dictionaryでは、報告すべきものがないため、3つのloaded filter nameをすべてclearします。さらに3つの境界を意図的に強制します

  • public-key encrypted documentでIdentity以外のper-stream filterは拒否します。public-key handler下のstream-specific policyには、HotPDFがまだ出力していないstream-specific recipient envelopeが必要だからです
  • /EFFがeffective /StmFと異なるpublic-key encrypted embedded fileは、同じ理由で拒否します。誰もdecryptできない形で書くことはありません
  • AES-256 direct-file fast pathは、string、stream、embedded fileがすべて同じcrypt filter methodへresolveし、file内のどのobjectにも明示的な/Cryptがない場合だけ適用します。mixed policyまたはplaintext metadataはfull object-graph pathへのfallbackを強制します

これらはperformance decisionではありません。誤った推測が、1つのviewerでは開き別のviewerでは失敗し、customerから報告されるまでdeveloperへsignalを何も返さない場所を示しています。ConfigureCryptFilterDefaultsまたはsave時の拒否ならexception 1つで済みます。mis-keyされたembedded fileを黙って出すとsupport cycleが1つ必要になります。DelphiまたはC++Builderでencrypted PDFを生成またはconsumeするsoftwareを作るなら、page contentはselectively plaintext、attachmentはencrypted、PDF 2.0 encrypted payload wrapper、または自分で選ばなかったcrypt filter policyを持つfileとのinteropなど、ここで説明したcrypt filter APIは現在のHotPDF Delphi PDF componentに含まれています。encryption、object stream、incremental updateの各pathも同じcomponentが支えています