기술 문서

PDF Library for Delphi: Delphi에서 compliance and signing workbench

컴플라이언스 검증과 디지털 서명을 하나로 연결하는 워크벤치는 정확히 이 순서로 네 단계를 조율해야 하며, 그 과정 내내 하나의 바이트 집합에 묶여 있어야 한다. 먼저 PDF/A 또는 PDF/UA 프리플라이트를 실행한다. 검사 결과가 요구하는 수정을 적용하고 보정된 리비전을 저장한다. 바로 그 리비전에 서명한다. 그런 다음 서명된 파일을 다시 읽어 서명이 실제로 그 내용을 포괄하는지 확인한다. 이 순서는 장식이 아니다. 재검토 단계를 건너뛰면 자신의 쓰기 경로를 그냥 믿는 셈이 되고, 프리플라이트를 잘못된 리비전에 대해 실행하면 컴플라이언스 리포트는 실제로 배포한 적 없는 파일을 설명하게 된다

자체 구축한 파이프라인들이 가장 자주 틀리는 지점은 검증과 서명 사이의 이음매다. 검증과 서명을 두 개의 개별 도구로 나누고 그 사이에 교정 단계를 두면, 파일의 서로 다른 리비전이 최소 세 개 생겨나고 각각 고유한 바이트를 갖게 된다. 감사자에게 건네는 프리플라이트 리포트는 그중 하나를 설명한다. 서명은 또 다른 하나를 고정한다. 파일 어디에도 이들이 같은 리비전이라는 증거는 없으며, 실제로 같지 않은 경우가 많다. Delphi와 C++Builder용 losLab PDF Developer Library인 PDF Library for Delphi는 프리플라이트와 PAdES 서명을 하나의 파사드 클래스 뒤에 배치해, 전체 시퀀스가 어떤 바이트를 다루고 있는지 놓치지 않는 단일 프로세스 안에서 진행되도록 한다. 아래의 모든 호출은 현재 라이브러리에 실제로 존재하며, 그 옆에 적어 둔 모든 함정 역시 마찬가지다

Delphi 컴플라이언스 및 서명 워크벤치 다이어그램: preflight, remediation, PAdES 서명, ByteRange 감사 단계가 각자 다루는 정확한 리비전에 대해 SHA-256을 기록
매 저장마다 기록된 해시는 프리플라이트 보고서, PAdES 서명, 감사를 하나의 동일 리비전으로 묶습니다

하나의 문서에 존재하는 세 개의 리비전, 그리고 간극이 벌어지는 과정

저장 횟수를 세어 보자. 원본은 업스트림에서 도착한다. 교정 단계는 그것을 불러와 컴플라이언스 모드를 켜고 보정된 리비전을 기록한다. 서명 단계는 증분 업데이트 형태로 서명을 덧붙이는데, 이것이 세 번째 쓰기다. 세 번의 저장, 세 가지 바이트 레이아웃이 생기며, 프리플라이트 리포트는 셋 중 어느 것을 다루는지 명시하지 않는 한 아무 의미가 없다. 파일의 SHA-256을 모든 프리플라이트 실행과 모든 서명 옆에 기록해 두는 것이, 검증한 리비전과 서명한 리비전이 같다는 사실을 저렴하게 증명해 주는 고정점이다

라이브러리의 한 가지 동작은 이 원칙을 한층 더 강제한다. SetPDFAModeSetPDFUAMode를 통해 요청한 컴플라이언스 수정은 호출 시점에는 적용되지 않는다. 이 수정은 저장 시점에 적용된다. 주석 인쇄 플래그를 강제하거나 PDF/UA 탭 순서를 지정하는 등의 자동 복구는 출력 파일에만 반영되며 그 외에는 어디에도 반영되지 않으므로, 메모리에서 방금 "수정"한 문서를 대상으로 검사를 돌려도 서명자에게 넘어갈 실제 바이트에 대해서는 아무것도 알려 주지 않는다. 먼저 저장한 다음, 저장된 파일을 프리플라이트하라. 메모리 상태는 초안일 뿐이며 디스크에 있는 파일만이 실체다

디스크에서 실행하는 프리플라이트, 그리고 두 가지를 동시에 의미하는 0

플랫 API 수준의 프리플라이트 진입점은 CheckFileCompliance(FileName, Password, ComplianceTest, Options)이다. 테스트 값 1은 PDF/A(ISO 19005)를, 2는 PDF/UA(ISO 14289)를 선택한다. 이 함수는 라이브러리의 스트리밍 리더를 통해 파일을 열기 때문에 먼저 LoadFromFile을 호출할 필요가 없으며, 항목마다 검사 결과 하나씩을 담은 문자열 목록 핸들을 반환한다:

var
  PDF: TPDFlib;
  ListID, I: Integer;
begin
  PDF := TPDFlib.Create;
  try
    ListID := PDF.CheckFileCompliance('invoice-fixed.pdf', '', 1, 0);  // 1은 PDF/A
    if ListID = 0 then
    begin
      if PDF.LastErrorCode <> 0 then
        raise Exception.Create('Preflight could not read the file')
      else
        Writeln('No PDF/A findings');
    end
    else
    begin
      for I := 0 to PDF.GetStringListCount(ListID) - 1 do
        Writeln(PDF.GetStringListItem(ListID, I));
      PDF.ReleaseStringList(ListID);
    end;
  finally
    PDF.Free;
  end;
end;

함정은 반환값에 있으며, 이는 모든 해피 패스 테스트를 통과해 버리는 종류의 함정이다. 0은 "검사 결과 없음"을 의미한다. 그런데 0은 동시에 "파일을 열 수 없었음"도 의미하는데, 결과 목록이 비어 있을 경우 구현이 항상 0을 반환하기 때문이며 읽기 실패도 여기에 포함된다. 0을 청신호로 해석하는 워크벤치는 다른 프로세스가 잠가 둔 파일도 태연히 승인해 버릴 것이다. 위 예제처럼 이 호출을 LastErrorCode와 함께 짝지어야 두 경우를 구분할 수 있다. 검사기는 또한 쓰기 거부 공유 모드로 파일을 여는데, 교정 단계가 아직 쓰기 핸들을 쥐고 있다면 프리플라이트는 컴플라이언스와는 무관하게, 해제를 잊은 스트림 때문에 실패한다

Delphi PDF preflight에서 LastErrorCode가 CheckFileCompliance의 0 반환에 담긴 두 가지 의미를 구분하는 방법을 보여주는 결정 다이어그램
LastErrorCode가 빈 발견 목록과 라이브러리가 열지 못한 파일을 구분해 주기 전까지는 CheckFileCompliance의 0 반환은 아무 의미가 없습니다

파이프라인이 아니라 사람이 검사 결과를 읽어야 할 때는, CreatePreflightReport가 이를 읽기 쉬운 리포트로 렌더링해 준다. ComparePreflightReports는 두 번의 실행 결과를 비교해 주는데, 교정 작업이 기존 검사 결과를 해소하면서 새로운 문제를 조용히 끌어들이지 않았음을 깔끔하게 보여주는 방법이다

SignProcess로 검증된 리비전에 서명하기

저장된 리비전이 프리플라이트를 통과하고 그 해시가 기록되고 나면, 바로 그 파일에만 서명하고 다른 파일에는 서명하지 않는다. SignProcess API는 빌더 패턴처럼 읽힌다. 프로세스 핸들을 열고, 한 줄씩 설정하고, 커밋한 다음, 결과 코드를 읽어 온다

ProcessID := PDF.NewSignProcessFromFile('invoice-fixed.pdf', '');
if ProcessID = 0 then
  raise Exception.Create('Cannot open source for signing');
PDF.SetSignProcessField(ProcessID, 'ApprovalSig');
PDF.SetSignProcessPFXFromFile(ProcessID, 'company.pfx', PfxPassword);
PDF.SetSignProcessInfo(ProcessID, 'Invoice approval', 'Berlin', 'billing@example.com');
PDF.SetSignProcessCustomSubFilter(ProcessID, 'ETSI.CAdES.detached');  // PAdES 베이스라인
PDF.SetSignProcessDigestAlgorithm(ProcessID, 2);                      // SHA-256
PDF.SetSignProcessReserveContentsBytes(ProcessID, 8192);              // 나중에 타임스탬프를 위한 여유 공간
PDF.EndSignProcessToFile(ProcessID, 'invoice-signed.pdf');
if PDF.GetSignProcessResult(ProcessID) <> 1 then
  Writeln('Sign failed, code ', PDF.GetSignProcessResult(ProcessID));
PDF.ReleaseSignProcess(ProcessID);

이 시퀀스에서 두 줄은 겉보기보다 훨씬 무게가 있다. ETSI.CAdES.detached를 지정한 SetSignProcessCustomSubFilter는 레거시 adbe.pkcs7.detached 계열 대신 ETSI EN 319 142-1에 프로파일링된 PAdES 서명을 선택하는데, 이는 유럽 검증기가 받아들이는 서명과 플래그를 세우는 서명의 차이를 만든다. SetSignProcessReserveContentsBytes는 /Contents 플레이스홀더를 채우며, 여기서 선택하는 크기는 미래에 대한 결정이다. 나중에 서명 타임스탬프가 뒤따를 예정이라면, 커진 CMS는 지금 예약해 둔 공간 안에 들어가야 한다. 플레이스홀더는 전체를 다시 서명하지 않고서는 나중에 커질 수 없기 때문이다. 넉넉하게 예약하면 몇 킬로바이트를 낭비하는 정도로 끝난다. 너무 빠듯하게 예약하면 몇 달 뒤 타임스탬프 단계에서 오버플로가 발생하고, 이 한 줄과 그 문제를 연결짓는 데 애를 먹게 된다

GetSignProcessResult는 불리언이 아니라 코드로 답하며, 이 코드들은 남겨 둘 가치가 있다. 1은 성공이다. 4는 잘못된 PDF 비밀번호, 7은 잘못된 인증서 비밀번호, 9는 개인 키가 없는 PFX, 11은 서명 적용 중 실패를 의미한다. 이를 참/거짓으로 뭉개 버리면, 비밀번호 오류 문의와 개인 키 없는 인증서 문제를 구분해 줄 유일한 정보를 잃게 된다. 정수 값을 그대로 로그에 남겨라

재검토: 방금 만든 파일을 감사하기

어떤 워크벤치도 지금 인증하려는 파일을 실제로 써낸 경로 자체를 그대로 신뢰해서는 안 된다. 감사용 클래스인 TPDFlibSignDoc은 서명된 출력을 다시 열어 서명 딕셔너리 항목을 디스크에서 직접 읽어 들인다:

var
  Doc: TPDFlibSignDoc;
  Names: TStringList;
  FS: TFileStream;
  I: Integer;
  SourceSize, RangeStart, GapStart, TailStart, TailLen: Int64;
begin
  // Open 이전에 크기를 미리 캡처: 감사 객체가 파일에 대해 공유 잠금을 유지하기 때문
  FS := TFileStream.Create('invoice-signed.pdf', fmOpenRead or fmShareDenyNone);
  SourceSize := FS.Size;
  FS.Free;
  Doc := TPDFlibSignDoc.Create;
  Names := TStringList.Create;
  try
    if not Doc.Open('invoice-signed.pdf', '', False) then Exit;
    Doc.GetSignatureFieldNames(Names);
    for I := 0 to Names.Count - 1 do
      if Doc.GetSignatureValueObjNum(Names[I]) > 0 then  // > 0이면 해당 필드가 서명되었다는 의미
      begin
        RangeStart := StrToInt64(string(Doc.GetSignatureValueByName(Names[I], 11)));
        GapStart   := StrToInt64(string(Doc.GetSignatureValueByName(Names[I], 12)));
        TailStart  := StrToInt64(string(Doc.GetSignatureValueByName(Names[I], 13)));
        TailLen    := StrToInt64(string(Doc.GetSignatureValueByName(Names[I], 14)));
        if (RangeStart = 0) and (TailStart + TailLen = SourceSize) then
          Writeln(Names[I], ': signature covers the file to EOF')
        else
          Writeln(Names[I], ': earlier revision, or unusual ByteRange layout');
      end;
    Doc.Close;
  finally
    Names.Free;
    Doc.Free;
  end;
end;

ValueKey 인자는 딕셔너리 항목에 대응한다. 키 0은 /Contents의 원본 CMS를 반환하고, 키 2와 3은 /Filter와 /SubFilter 이름을, 11부터 14까지는 ByteRange의 네 숫자를 반환한다. 텍스트 값은 대신 GetSignatureTextValueByName을 통해 돌아온다: 키 0은 서명자가 주장하는 서명 시각이고, 키 5는 일반 SigDocTimeStamp를 구분해 주는데, 문서가 둘 다 담고 있을 때 이 구분이 중요해진다

위 예제 맨 위에서 파일 크기를 미리 캡처해 두는 것은 단순한 정리 작업이 아니라 실제로 결과를 좌우하는 부분이다. TPDFlibSignDoc.Open은 객체가 살아 있는 동안 파일에 제한적인 공유 잠금을 계속 걸어 두므로, 원본 바이트가 필요한 작업(서명된 범위의 해시 계산, CMS 다이제스트 재계산 등)은 반드시 Open을 호출하기 전에 파일을 읽어 두어야 한다. 라이브러리 자체의 SigningWorkbench 데모도 바로 이 이유 때문에 파일 전체를 먼저 메모리로 읽어 들이며, 이 순서를 무시하는 워크벤치는 경쟁 조건에서 패배하는 특정 머신에서만 이따금 실패한다

적용 범위를 증명하는 ByteRange 산술

정상적인 단일 서명 파일은 [0 a b c] 형태의 ByteRange를 갖는다: 적용 범위는 오프셋 0에서 시작해 a와 b 사이의 16진수 /Contents 플레이스홀더를 건너뛴 다음 b+c 바이트까지 다시 이어진다. b+c가 파일 크기와 같으면 서명이 파일 끝까지 모두 포괄한다는 뜻이며, 이것이 바로 원하는 결과다. 이에 미치지 못한다면 서명이 기록된 이후 누군가 증분 업데이트를 덧붙였다는 뜻이다. 이는 ISO 32000-1§12.8 아래에서 완전히 정당한 동작인데, 이후의 양식 작성, 두 번째 서명, DSS 딕셔너리가 모두 정확히 이런 방식으로 도착하기 때문이다. 그리고 이것이야말로 분쟁이 벌어졌을 때 압박 속에서 재구성할 것이 아니라 서명 시점에 감사 기록으로 남겨 두어야 할 사실이다

PDF Library for Delphi: 서명된 PDF의 ByteRange 해부도로 Contents 플레이스홀더 갭과 전체 커버 사례, 어펜드된 증분 업데이트 사례를 보여줌
0 a b c 형태의 ByteRange는 b + c가 파일 끝에 도달할 때만 파일 전체를 커버하므로, 감사 로그는 서명 후 추가된 증분 업데이트를 기록합니다

이 산술을 계산할 때는 정수 폭에 주의해야 한다. 플랫 API의 GetSignProcessByteRange는 32비트 Integer를 반환하지만 실제 값은 Int64이므로, 2GB를 넘는 파일에서는 이 플랫 접근자가 조용히 값을 잘라 버린다. Int64를 반환하는 클래스 계층의 TPDFlibSigner.GetByteRange를 사용하거나, 위의 감사 코드처럼 GetSignatureValueByName에서 직접 값을 파싱하라

라이브러리가 직접 처리해 주지 않는 부분

두 가지 경계는 최종 스프린트가 아니라 설계 단계에서 미리 파악해 두는 편이 낫다. 플랫 TPDFlib API에는 서명 검증 래퍼가 전혀 없다. 암호학적 검증은 한 계층 아래인 TPDFlibSignatureVerifier에 있으며, 이곳의 VerifySignature는 유효, 무효, 알 수 없음 중 하나로 답한다. RFC 3161 타임스탬프 기관을 위한 내장 HTTP 클라이언트도 존재하지 않는다. 라이브러리는 제출할 해시를 계산하고 토큰이 돌아오면 확장된 CMS를 다시 삽입해 주지만, TSA로의 네트워크 왕복은 직접 작성해야 한다. 두 가지 모두 감싸기는 어렵지 않지만 출시 일주일 전에 빠져 있는 것을 발견하면 정말 곤란하므로, 최초 설계 단계부터 포함시켜 두어야 한다

컴플라이언스에 관한 한 가지 질문은 명확히 짚고 넘어갈 가치가 있는데, 이것이 마지막 게이트를 어디에 둘지를 결정하기 때문이다: 서명을 추가하면 PDF/A가 깨지는가? 그 자체만으로는 아니다. 서명은 증분 업데이트 형태로 도착하며, ISO 19005-2 이후 버전은 서명된 문서를 명시적으로 허용한다. 문제가 되는 것은 서명 외관인데, 이는 다른 모든 페이지 콘텐츠와 동일한 규칙을 따라야 하며 여기에는 폰트 임베딩과 디바이스 종속 색상 금지도 포함된다. 따라서 워크벤치의 마지막 게이트는 이번에는 서명된 출력물을 대상으로 한 프리플라이트를 한 번 더 실행하는 것이다. CheckFileCompliance는 파이프라인 내부의 빠른 검사로 취급하고, 릴리스 후보는 veraPDF 같은 독립적인 도구로도 여전히 검증하라. 검증기마다 구현하는 규칙 집합이 겹치기는 하지만 완전히 같지는 않기 때문이며, 둘이 서로 다른 결과를 낼 때는 검사 결과 텍스트가 대개 참조해야 할 조항을 알려 준다

이 모든 것에서 한 가지 순서상의 요점이 도출된다. 서명과 타임스탬프는 한 번에 끝나는 작업이 아니다: 먼저 베이스라인 서명이 기록되고, 그다음 별도의 타임스탬프 프로세스가 예약해 둔 /Contents 공간 안에서 CMS를 확장한다. 앞서 예약 바이트 관련 줄이 그토록 중요했던 이유가 바로 여기에 있다. 이 워크벤치 위에 구축되는 타임스탬프 및 장기 검증 계층에 대해서는 PAdES 서명 및 검증 안내서가 서명을 베이스라인에서 B-LT까지 다루며, 프리플라이트 부분은 PDF/A 및 PDF/UA 프리플라이트 가이드에서 더 깊이 다룬다. 전체 API 문서와 체험판 다운로드는 PDF Library for Delphi 제품 페이지에서 확인할 수 있다