이미 AES-256 암호화가 걸린 인보이스 PDF를 가져와, 전체 재작성이 아니라 증분 업데이트를 통해 Delphi와 C++Builder용 PDFium 컴포넌트(PDFiumPas)에게 아카이브 보관을 위해 PDF/A 스탬프를 찍거나 PAdES로 서명하라고 요청한다고 하자. 이 라이브러리는 암호화된 바이트를 직접 패치하는 방식으로는 그곳에 도달하지 않는다: 여섯 개의 적합성 마커 주입기는 기존 /Encrypt 항목을 감지하면 소스를 바이트 단위로 변경 없이 통과시키고, PAdES 서명기는 어떤 검증기도 받아들이지 않을 서명을 만들어내는 대신 예외를 일으킨다
이는 여러분이 만들지 않은 PDF를 숨겨진 위험을 찾기 위해 감사하는 것과는 다른 질문이며, 그것은 그 자체로 읽기 전용 작업이다. 이 글은 같은 신뢰 경계의 쓰기 쪽에 관한 것이다: 이미 다른 누군가의 비밀번호 뒤에 잠긴 바이트를 가진 파일에, 여러분의 코드가 나중에 무언가를 추가하려 할 때 무엇을 해도 되는가에 대한 것이다
암호화된 PDF를 업데이트할 때 ISO 32000-1은 무엇을 요구하는가?
ISO 32000-1 §7.5.6은 증분 업데이트의 트레일러가 /Prev를 제외한 이전 트레일러의 모든 항목을 반복해야 한다고 요구하며, Table 15는 트레일러가 담을 수 있는 항목 중에 /Encrypt를 나열한다. 이를 새 트레일러에서 빠뜨리면 표준을 준수하는 리더는 그 누락을 의심할 이유가 없다: 가장 최신 트레일러가 권위 있는 것이므로, 거기서 /Encrypt를 찾지 못한 리더는 파일 전체가 암호화되지 않았다고 판단하고 더 오래되고 여전히 암호화된 본문을 평범한 바이트로 파싱하려 한다. 새 트레일러에 /Encrypt는 유지하되 업데이트 자체의 객체를 평문으로 쓰면, 실패는 한 단계 뒤로 옮겨질 뿐이다: 리더는 암호화를 올바르게 감지하고, 자신이 건드리는 모든 객체를 파일의 암호를 통해 실행하는데, 애초에 전혀 암호화된 적 없는 새 객체들도 포함된다. 그리고 복호화가 손대기 전까지는 완벽하게 읽을 수 있었던 콘텐츠에 대해 잡음을 돌려받는다. 어느 실수든 바이트 수준에서는 표준을 준수하는 리더가 열기 전까지 정상적이고 형식이 올바른 증분 업데이트처럼 보이는 파일을 만들어낸다
여섯 개의 마커 주입기, 하나의 v2.14.2 암호화 관문
PDFiumPas는 자신이 표시할 수 있는 각 ISO PDF 하위 집합마다 하나씩, 여섯 개의 바이트 수준 마커 주입기를 제공한다: PDF/A(ISO 19005), PDF/X(ISO 15930), PDF/UA(ISO 14289-1), PDF/E-1(ISO 24517-1), PDF/R-1(ISO 23504-1), PDF/VT-1(ISO 16612-2)이다. 각각은 PDFium 자체의 FPDF_SaveAsCopy가 이미 작성한 바이트를 받아 그 위에 두 번째의 더 작은 증분 업데이트를 얹는다: 새 XMP 메타데이터 스트림, 그것을 가리키는 카탈로그 딕셔너리 편집, 그리고 인쇄 지향 하위 집합의 경우 OutputIntent와 ICC 프로필이다. v2.14.2 기준으로, InjectPdfAMarkers, InjectPdfXMarkers, InjectPdfUaMarkers, InjectPdfEMarkers, InjectPdfRMarkers, InjectPdfVTMarkers 모두 먼저 소스 트레일러를 읽고, 기존 /Encrypt 항목이 보고되면 소스를 대상 스트림으로 변경 없이 복사한 뒤 즉시 반환한다. XMP도, OutputIntent도, 카탈로그 편집도 없다 — 호출자는 원본 파일을 바이트 단위로 그대로 돌려받는다
var
Src, Dst: TFileStream;
Opts: TPdfXSaveOptions;
begin
Src := TFileStream.Create('signed-encrypted-proof.pdf', fmOpenRead);
Dst := TFileStream.Create('pdfx-attempt.pdf', fmCreate);
try
Opts.Conformance := pxc4;
InjectPdfXMarkers(Src, Dst, Opts);
// pdfx-attempt.pdf is byte-identical to the source: still encrypted,
// no /GTS_PDFXVersion, no OutputIntent. Nothing was written, and
// nothing was corrupted either
finally
Dst.Free;
Src.Free;
end;
end;
암호화가 허용됨과 주입해도 안전함은 다르다
PDF/E-1과 PDF/R-1은 둘 다 스펙 수준에서 호스트 문서가 암호화될 수 있음을 명시적으로 허용하는데, 실제로 디스크에서 무엇이 일어나야 하는지 들여다보기 전까지는 이것이 예외처럼 읽힌다. ISO 24517-1 §6.3은 PDF/E-1에 대해 암호화를 허용하고, ISO 23504-1 §6.2.3은 헤더가 %PDF-2.0을 선언한다는 조건 하에 PDF/R-1에 대해 이를 허용한다. 어느 조항도 바이트 수준 후처리기가 그 암호화된 컨테이너에 평문 객체를 안전하게 추가할 수 있는지에 대해서는 아무 말도 하지 않으며, 다른 모든 하위 집합에 적용되는 것과 같은 §7.5.6의 이유로 그것은 안전하지 않다. 이 두 프로필을 위한 PDFiumPas 자체의 적합성 검증기인 ValidatePdfECompliance와 ValidatePdfRCompliance는 /Encrypt의 존재를 의도적으로 결함으로 표시하지 않고 기록하는데, 이는 바이트를 전혀 쓰지 않는 읽기 전용 검증기로서는 옳다. 그러나 이는 또한 형제 주입기가 별도의 방어 장치를 필요로 하지 않는다고 대충 넘겨짚기 쉬운 패턴이기도 하다. 실제로 이 쌍에서 진짜로 거부해야 하는 함수는 그 주입기 쪽이다
SaveAsPdfX는 문서를 조용히 복호화하는가?
주입기를 직접 호출하는 대신 공개 편의 메서드를 거치면 그렇다. TPdf.SaveAsPdfA, SaveAsPdfX, SaveAsPdfUa, SaveAsPdfE, SaveAsPdfR, SaveAsPdfVT 모두는 그 바이트를 짝을 이루는 주입기에 넘기기 전에 SaveAs(Tmp, saRemoveSecurity)로 현재 문서를 임시 스트림에 렌더링한다. saRemoveSecurity는 PDFium 자체의 FPDF_REMOVE_SECURITY 플래그에 매핑되므로, 주입기가 받는 임시 사본은 애초에 암호화된 적이 없으며, 주입기의 /Encrypt 방어는 촉발될 이유가 전혀 없다. 출력물은 PDF/A, PDF/X, PDF/UA, PDF/E-1, PDF/R-1, PDF/VT-1 마커를 담고 있지만, 소스를 열었던 그 비밀번호로 더 이상 보호되지 않는다
이 절충은 다운스트림의 누군가가 비밀번호 없이 그 "보호된" 아카이브 사본을 열어보고 그냥 잘 열린다는 것을 알아차리기 전까지는 보이지 않는다. 이 수정은 다른 메서드 호출이 아니다; PDFiumPas에는 saRemoveSecurity와 짝을 이룰 saAddSecurity가 없는데, 밑에 있는 PDFium 엔진이 애초에 새 암호화를 쓰도록 만들어진 적이 없고 그것을 제거하도록만 만들어졌기 때문이다. 한 파일에 대해 두 속성 모두가 중요하다면, 암호화는 적합성 마커 이후에 적용되는, 같은 SaveAsPdfA 호출 안에 접혀 들어가는 것이 아니라 여러분이 소유하는 별도의 단계여야 한다
var
Pdf: TPdf;
begin
Pdf := TPdf.Create(nil);
try
Pdf.Password := 'open-secret'; // needed to open the source at all
Pdf.FileName := 'signed-encrypted-invoice.pdf';
Pdf.LoadDocument;
Pdf.SaveAsPdfA('invoice-pdfa.pdf', pac2b);
// invoice-pdfa.pdf now declares PDF/A-2b, but SaveAs(saRemoveSecurity)
// ran first inside SaveAsPdfA: the output opens without a password
finally
Pdf.Free;
end;
end;
암호화된 PDF에 PAdES로 서명하면 어떻게 되는가?
PDFiumPas는 마커 주입기처럼 요청을 조용히 무시하는 대신 완전히 거부한다. TPdf.SignPades와 SignPadesToStream 둘 다 내부의 SignPadesBytes를 거치며, 소스 트레일러를 파싱한 뒤 가장 먼저 하는 일이 /Encrypt를 확인하는 것이다. 그 항목이 존재하면, 더 이상 진행하는 대신 "SignPadesBytes: the source document is encrypted; remove encryption before signing"라는 메시지와 함께 EPadesCrypto를 일으킨다. 장기 검증을 위해 인증서, OCSP 응답, CRL을 임베드하는 함수인 InjectPadesDssMarkers도 동일한 이유로 동일한 검사를 적용하며, 자신만의 메시지를 갖는다: "InjectPadesDssMarkers: the source document is encrypted; remove encryption before embedding DSS validation material"
여기서의 논리는 마커 주입기의 통과 방식보다 더 엄격하며, 이는 의도적이다. 조용한 통과는 PDF/A 스탬프에는 안전한데, 이를 건너뛰어도 여러분은 시작했던 것과 같은 유효한 PDF를 여전히 갖게 되며 그저 라벨이 붙지 않았을 뿐이기 때문이다. 서명은 그렇게 조용히 실패할 수 없다: 조용히 전혀 추가되지 않은 서명은, 불리언 결과만 확인하는 어떤 호출 코드에게도 성공적으로 추가된 서명과 똑같아 보인다. EPadesCrypto는 평범한 Exception 클래스에서 파생되므로, 이를 잡는 것은 여러분이 따로 배워야 할 특별한 제어 흐름 관례가 아니라 일반적인 예외 처리다
var
Pdf: TPdf;
begin
Pdf := TPdf.Create(nil);
try
Pdf.Password := 'open-secret';
Pdf.FileName := 'encrypted-contract.pdf';
Pdf.LoadDocument;
try
Pdf.SignPades('encrypted-contract-signed.pdf', 'A1B2C3D4E5F6...');
except
on E: EPadesCrypto do
// E.Message: 'SignPadesBytes: the source document is encrypted;
// remove encryption before signing'
raise Exception.Create('Decrypt the contract before signing: '+ E.Message);
end;
finally
Pdf.Free;
end;
end;
적합성 스탬프, 서명, 암호화의 순서 정하기
실무적인 해법은 다른 라이브러리가 아니라 순서다. PDF/A, PDF/X, PDF/UA, PDF/E-1, PDF/R-1, PDF/VT-1 마커를 먼저 적용하고, 그다음 PAdES 서명을 추가하고, 그 뒤에야 파이프라인 안에서 실제로 암호화를 담당하는 단계 — 전용 PDF 라이터든, 서명 어플라이언스든, 여러분 자신의 AES 구현이든 — 를 실행하라. PDFiumPas의 증분 업데이트 계층은 이 순서의 중간에 자연스럽게 들어맞는다. 그 외에는 완성된 파일 위에 작고 목표가 명확한 객체를 덧붙이는 것이며, 암호화가 끝에 자리하는 이유는 정확히 그것이 이 사슬 안에서 PDFiumPas 자체가 수행하거나 되돌릴 수 없는 유일한 작업이기 때문이다
이 중 어느 것도 모든 증분 업데이트가 의존하는 트레일러와 크로스 레퍼런스 데이터를 PDFiumPas가 읽는 방식을 바꾸지 않는다. xref 스트림이 등장하면 그것 자체가 미묘함의 원천이 되는데, PDF의 객체 스트림과 xref 스트림 검증하기가 그 같은 트레일러 읽기 경로가 PDF 1.5 이상의 압축된 구조를 어떻게 다루는지 다룬다. 그리고 문서가 적합성 스탬프보다 더 강력한 무언가를 받을 준비가 되면, Delphi에서 PAdES B-B 서명으로 PDF에 서명하기가 이 글이 멈춘 바로 그 지점부터 SignPades가 이어받는 곳이다
여기서 설명한 마커 주입기와 SignPades 메서드는 PDFium이 네이티브로 제공하는 렌더링 및 읽기 전용 검사와 함께 Delphi와 C++Builder용 PDFium 컴포넌트의 일부로 제공된다