기술 문서

Delphi PDF 암호화 필터: StmF, StrF, EFF 정책

HotPDF Delphi PDF component는 ISO 32000-1 §7.6.5 crypt filter model을 하나의 switch가 아니라 세 가지 독립 policy로 구현합니다. ConfigureCryptFilterDefaults는 string filter /StrF, stream filter /StmF, embedded-file filter /EFF를 따로 지정하고 SetStreamCryptFilter는 단일 stream을 override하며 GetLoadedCryptFilterInfo는 incoming file이 선언한 내용을 보고합니다. encrypted-PDF interop bug 대부분은 이 세 policy 사이의 틈에서 발생합니다

사람들이 이 계층을 찾게 되는 실패는 다음과 같습니다. downstream tool이 page content는 읽을 수 있어야 하지만 attached payload는 읽으면 안 되는 문서를 팀이 배포하며 /EFF /StdCF를 설정하고 /StmF /Identity는 그대로 둡니다. Acrobat에서는 잘 열립니다. 그러나 conforming third-party reader는 attachment를 ciphertext garbage로 돌려줍니다. /EFF는 embedded file에 어떤 filter를 적용할지 정하는 producer-side policy이고 일반 reader는 표시되지 않은 stream을 여전히 /StmF로 resolve하기 때문입니다. 수정은 다른 /EFF value가 아닙니다. embedded-file stream 자체에 명시적인 /Crypt filter를 두는 것입니다

crypt filter 계층이 실제로 제어하는 것

Crypt filter는 encryption algorithm과 object graph 사이에 놓여 algorithm이 어떻게 작동하는지가 아니라 어떤 object를 건드릴지를 결정합니다. encryption dictionary 안의 /CF dictionary는 이름을 filter definition에 매핑하며 각 definition은 /CFM method, optional /Length/AuthEvent를 가집니다. 상위의 /StrF, /StmF, /EFF 세 entry는 그 이름 있는 filter 중 어떤 것이 string, explicit filter가 없는 stream과 embedded file에 적용되는지 선택합니다. HotPDF는 built-in handler가 write하는 범위를 의도적으로 제한합니다. ConfigureCryptFilterDefaults는 active handler에 예약된 이름만 받습니다. Standard security handler는 /StdCF 또는 /Identity를 내보내고 public-key handler는 /DefaultCryptFilter 또는 /Identity를 내보내며 그 외에는 call site에서 EArgumentException을 발생시킵니다. 외부 producer가 다른 이름으로 작성한 filter는 load, inspection과 compatibility-rewrite path에서 여전히 보존되므로 HotPDF는 writer로서는 보수적이고 reader로서는 관대합니다. guard가 두 가지 더 있습니다. document serialization이 시작된 뒤 호출하면 EInvalidOpException을 발생시키고 document가 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;

앞에서 한 가지 제약을 먼저 말해 두는 것이 좋습니다. HotPDF의 named crypt filter는 aes128, aes256 또는 aesgcm document encryption을 요구합니다. RC4 k40 또는 k128 위에 filter policy를 설정하면 encryption이 enable될 때 실행되는 validation pass가 조용히 key type을 올리지 않고 exception을 발생시킵니다. 이는 Delphi의 AES-256 PDF encryption path를 포함한 나머지 설계와 같은 태도입니다. caller가 무엇을 의도했는지 추측하지 않고 모호한 configuration을 거부합니다

/Length entry가 두 가지 의미를 갖는 이유

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가 아닌 경우에만 /V2 filter /Length에 8을 곱하고 filter에 자체 값이 없으면 document-level /Length로 fallback한 뒤 결과를 THPDFCryptFilterInfo.KeyLengthBits에 저장합니다. AESV2는 128 bit, AESV3AESV4는 256 bit로 고정됩니다. 이 method에는 협상 가능한 key size가 없기 때문입니다. 엄격한 부분은 그 다음입니다. 40-bit와 128-bit /V2만 accept합니다. 다른 length로 resolve되는 filter는 128로 round하지 않고 unavailable로 보고하며 operation을 실패시킵니다. 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으로 매핑하므로 둘 다 key length 0이 기록된 hcfmNone이 됩니다. /Identity는 종류가 다릅니다. /CF lookup을 완전히 우회하는 예약된 이름이라 document가 /CF 어디에도 정의하지 않고 /Identity를 참조할 수 있습니다. PDF name은 case-sensitive이므로 구현 세부 사항 하나는 양보할 수 없습니다. crypt filter lookup은 case-insensitive여서는 안 됩니다. HotPDF는 /CF sub-dictionary name, filter /Length entry와 stream /Type check 모두 case-sensitive dictionary lookup으로 resolve합니다. /stdcf를 정의하면서 /StmF/StdCF를 가리키는 file은 malformed이며 둘을 같은 key로 처리하면 검출 가능한 authoring bug가 document의 모든 stream에 잘못된 key를 조용히 적용하는 문제로 바뀝니다

embedded-file stream에 /EFF 적용하기

/EFF/StmF와 다르면 embedded-file stream은 /Filter에 명시적인 선행 /Crypt entry를 필요로 하며 같은 array position에 /Name을 담은 일치하는 /DecodeParms dictionary도 필요합니다. HotPDF는 save 시 stream마다 이를 계산합니다. /Type /EmbeddedFile을 감지하고 설정된 embedded-file filter를 상속하며, 상속된 이름이 effective stream default와 다를 때만 explicit /Crypt marker를 내보냅니다. /EFF/StmF가 같으면 reader가 같은 filter를 resolve하므로 marker를 쓰지 않습니다. array position은 name만큼 중요합니다. HotPDF가 stream을 다시 읽을 때 /Filter에서 /Crypt entry를 scan해 index를 기록하고 그 동일한 index를 /DecodeParms array에서 찾아 /Name을 얻습니다. index 0의 /Crypt를 index 1의 parameter와 짝지으면 여러분의 filter가 아니라 /Identity로 resolve됩니다. 그래서 stream에 이전부터 /Filter는 있었지만 /DecodeParms가 없었으면 writer가 parameter array에 null을 채우기도 합니다. position이 서로 맞아야 하기 때문입니다

더 날카로운 trap도 있습니다. 기존 /Filter/DecodeParms가 indirect object인 경우가 있습니다. 여러 stream이 하나의 filter array를 공유하는 generator가 만든 file에서 흔합니다. 그 자리에 /Crypt를 삽입하면 shared filter graph를 mutate해 그것을 가리키는 다른 모든 stream을 손상시킵니다. HotPDF는 indirect object를 resolve한 뒤 먼저 stream-private direct object로 clone하고 object와 generation number를 clear하므로 원래 indirect root가 새 array 안에 들어가지 않습니다. 이미 ASCIIHexDecode를 사용한 stream의 serialized result는 /Filter [ /Crypt /ASCIIHexDecode ]이고 /DecodeParms [ << /Type /CryptFilterDecodeParms /Name /StdCF >> ... ]입니다. 같은 positional discipline은 loaded PDF에서 decode filter를 통해 image를 추출할 때 다루는 다른 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를 지우고 다음 save에서 stale /Crypt
// entry와 decode parameter를 함께 제거합니다
Editor.SetStreamCryptFilter(ContentStream, '');
Editor.SaveLoadedDocument('cleared.pdf');

object stream이 document /Encrypt policy를 상속하는가

아닙니다. 그렇게 가정하면 확실히 garbage를 만들게 됩니다. object stream은 실제 /StmF policy 또는 자체 explicit /Crypt marker를 따라야 합니다. /Encrypt dictionary가 있다는 사실만으로 모든 /ObjStm container가 ciphertext가 되는 것은 아닙니다. /StmF /Identity인 document는 string이 완전히 encrypted여도 plaintext object stream을 가지며 이를 decrypt하는 decoder는 deflate output이 아닌 input을 inflate 단계에 넣게 됩니다

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하고 encrypted였다면 object를 건너뛰어 이를 방지하며, XRefProbeDecryptObjStmSkips에 skip 수를 세어 guard가 발동했다는 직접 evidence로 남깁니다. container가 plaintext였다면 member string은 어떤 것에도 덮이지 않았으므로 HotPDF는 member를 materialize하고 /StrF를 각 string에 개별 적용합니다. 실제 구현처럼 key는 포함하는 /ObjStm object number가 아니라 member object number와 generation으로 잡습니다. mixed-policy file에서 이를 거꾸로 하면 모든 compressed 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는 보고할 대상이 없으므로 loaded filter name 세 가지를 모두 clear합니다. 그 밖에도 세 가지 경계를 의도적으로 강제합니다:

  • public-key encrypted document에서 non-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에도 explicit /Crypt가 없을 때만 적용됩니다. mixed policy 또는 plaintext metadata는 full object-graph path로 fallback합니다

이 중 어느 것도 performance decision이 아닙니다. 잘못된 추측이 한 viewer에서는 열리고 다른 viewer에서는 실패하며 customer가 보고할 때까지 developer에게 신호를 주지 않는 PDF를 만드는 지점을 표시합니다. ConfigureCryptFilterDefaults나 save 시점의 refusal은 exception 한 번의 비용이지만 잘못 key된 embedded file은 support cycle의 비용을 부릅니다. Delphi나 C++Builder software에서 encrypted PDF를 생성하거나 소비한다면 선택적으로 plaintext인 page content와 encrypted attachment, PDF 2.0 encrypted payload wrapper 또는 직접 고르지 않은 crypt filter policy를 가진 file과의 interop까지 이 글에서 설명한 API는 HotPDF Delphi PDF component의 current release에 포함되어 있으며 encryption, object stream과 incremental update path도 함께 제공합니다