기술 문서

Delphi PDFium으로 서명 후 PDF 변경 분석하기

PDF가 서명된 뒤 무엇이 바뀌었는지 찾으려면, Delphi와 Lazarus용 PDFium Component가 제공하는 TPdf.AnalyzeSignatureRevisions를 쓰면 됩니다. 서명 후 리비전 변경 분석기로서 원본 파일 바이트에서 모든 증분 리비전을 다시 만들고, 이후 객체 변경 각각을 그 서명의 DocMDP와 FieldMDP 규칙에 대고 판정하며, 섀도 객체 정의를 별도 위험으로 보고합니다. 겨냥하는 상황은 계약서를 다뤄 본 사람이라면 익숙합니다. 인증된 양식이 나갔다가 증분 저장 두 번을 얹어 돌아오는데 모든 서명은 여전히 검증을 통과합니다. 예상된 일입니다. 서명은 자기 리비전의 바이트만 커버하기 때문입니다. 진짜 질문은 그 이후 저장이 허용된 것이었는지고, 서명의 초록 체크마크는 그 답을 주지 않습니다

PDFium 서명 API는 서명 후 무엇이 바뀌었는지 왜 못 보여 줄까요?

PDFium 서명 API는 서명 후 변경을 보여 주지 못합니다. 시그니처 딕셔너리, 즉 /Contents, /ByteRange, /SubFilter, DocMDP 권한 값만 읽기 때문입니다. PDFium에는 증분 리비전 그래프가 없고, FieldMDP 트랜스폼 파라미터를 파싱하지 않으며, 리비전 간 객체 수준 diff도 제공하지 않습니다. 그래서 FPdfPades.pas의 분석기는 날 바이트로 직접 동작합니다. 설계에 반영해야 할 실무적 귀결이 하나 있습니다. TPdf.AnalyzeSignatureRevisions는 문서를 로드할 때 보관한 바이트를 읽으며, SaveAs가 만든 사본은 절대 읽지 않습니다. 다시 쓴 파일은 분석 대상 리비전 구조 자체를 이미 잃었기 때문입니다. 문서가 다운로드가 끝나지 않은 progressive 소스에서 왔다면, 보고서는 잘린 파일을 분석하는 대신 SourceStatus = pvssIncomplete와 Status = prasIndeterminate를 반환합니다

startxref, xref 스트림, /Prev에서 리비전 경계 재구성하기

분석기는 모든 startxref를 따라 클래식 xref 테이블과 크로스 레퍼런스 스트림, 하이브리드 레퍼런스 /XRefStm 항목, /Prev 체인을 거슬러 올라가 리비전 경계를 다시 만듭니다. ISO 32000-1 §7.5.6과 §7.5.8이 증분 업데이트를 위해 정의한 구조입니다. 각 서명의 커버 길이는 두 번째 ByteRange 스팬의 끝이고, 분석기는 그 길이가 어느 리비전의 xref 섹션 안에 떨어지는지 대응시킵니다. 부합하는 리비전이 없으면 서명은 prrCoveredRevisionNotFound와 Indeterminate 상태를 받습니다. 그다음 모든 객체의 상태를 커버 리비전까지 재생하고, 이후의 각 xref 항목을 그 상태와 비교합니다. 들리는 것보다 중요한 대목입니다. 어떤 라이터는 증분 저장마다 완전한 xref 테이블을 다시 적는데, 여전히 같은 안 바뀐 객체를 가리키는 항목은 수정으로 보고하는 대신 건너뜁니다. 그 비교가 없으면 완전히 합법적인 양식 작성이 수백 개의 가짜 변경에 잠겼을 것입니다

AnalyzeSignatureRevisions가 Delphi에서 날 PDF 바이트로 증분 리비전을 재구성하는 방식: 서명 ByteRange는 커버 리비전 안에서 끝나고, xref Prev 체인은 이후 모든 저장을 거슬러 올라가며, 객체 상태는 커버 리비전까지 재생되고, 안 바뀐 재진술 항목은 변경으로 보고되는 대신 건너뜁니다
서명은 자기 리비전의 바이트만 커버하므로, 분석기는 두 번째 ByteRange 스팬을 리비전에 대응시키고 이후의 각 xref 항목을 재생된 객체 상태와 대조해 판정합니다

섀도 정의가 가장 많은 주의를 요하는 사례입니다. 이후 리비전의 바이트 범위 안에 나타나면서 그 리비전의 xref가 참조하지 않는 객체 본문은 일반 뷰어에는 보이지 않지만, 바로 섀도 공격이 의지하는 종류의 준비 작업입니다. 숨은 콘텐츠를 서명 전이나 후에 심어 두고 나중에 참조 하나를 뒤집어 활성화하는 것이죠. AnalyzePadesSignatureRevisionsBytes는 그런 객체를 IsAuthoritative = False인 비 권위 변경으로 기록하고, 권한 수준과 무관하게 prdSuspicious로 판정하며, 위험 집합에 prrUnreferencedObjectDefinition을 더합니다. 관련된 위험 둘이 다른 구조적 술책을 커버합니다. 하나의 xref 섹션이 같은 객체를 두 번 이상 나열하면 prrDuplicateObjectDefinition이 발화하고, 이후 리비전이 기존 서명 객체를 재정의하면 prrSignatureObjectRedefined가 발화합니다

이후 PDF 리비전 바이트 범위 안의 섀도 객체 정의: 객체 본문은 존재하지만 참조하는 xref 항목이 없어 뷰어는 절대 보여 주지 않고, PDFium Component의 AnalyzeSignatureRevisions는 그것을 비 권위로 기록하고 prdSuspicious로 판정하며, 중복과 재정의된 서명 위험과 함께 prrUnreferencedObjectDefinition을 올립니다
숨은 콘텐츠는 서명 전이나 후에 심어졌다가 나중에 참조를 뒤집어 활성화되므로, 참조되지 않는 본문은 DocMDP 권한 수준과 무관하게 suspicious로 판정됩니다
uses
  SysUtils, TypInfo, PDFium, FPdfPades;

const
  ShadowTag: array[Boolean] of string = ('', ' (shadow)');
var
  Pdf: TPdf;
  Report: TPadesRevisionAnalysisReport;
  i, j: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'contract-returned.pdf';
    Pdf.Active := True;
    Report := Pdf.AnalyzeSignatureRevisions;
    Writeln('Revisions: ', Report.RevisionCount,
      '  Signatures: ', Report.SignatureCount,
      '  Overall: ', GetEnumName(TypeInfo(TPadesRevisionAnalysisStatus),
        Ord(Report.Status)));
    for i := 0 to High(Report.Signatures) do
      with Report.Signatures[i] do
      begin
        Writeln(Format('Signature %d covers revision %d, %d later, P=%d, FieldMDP=%s',
          [SignatureIndex, CoveredRevisionIndex, LaterRevisionCount,
           DocMdpPermission,
           GetEnumName(TypeInfo(TPadesFieldMdpAction), Ord(FieldMdpAction))]));
        for j := 0 to High(Changes) do
          Writeln(Format('  rev %d  obj %d  %s -> %s%s',
            [Changes[j].RevisionIndex, Changes[j].ObjectNumber,
             GetEnumName(TypeInfo(TPadesRevisionChangeKind), Ord(Changes[j].Kind)),
             GetEnumName(TypeInfo(TPadesRevisionDecision), Ord(Changes[j].Decision)),
             ShadowTag[not Changes[j].IsAuthoritative]]));
      end;
  finally
    Pdf.Free;
  end;
end;

DocMDP와 FieldMDP는 서명마다 어떻게 집행될까요?

DocMDP와 FieldMDP는 서명마다, 그 서명 자체의 커버 리비전에서 따로 집행됩니다. 같은 파일의 인증 서명과 이후 승인 서명이 같은 편집에 대해 서로 다른 판정에 도달할 수 있는 이유입니다. 이후의 모든 객체는 먼저 /Type, /Subtype, /FT 항목과 페이지, 폼, 주석, DSS 그래프에서 맡는 역할로 TPadesRevisionChangeKind로 분류됩니다. /JavaScript, /JS, /Launch, /OpenAction, /AA, /RichMedia, /EmbeddedFile을 지닌 것은 무엇이든 prckActiveContent가 됩니다. 판정은 이어서 ISO 32000-1 §12.8.2.2를 따릅니다. P=1에서는 크로스 레퍼런스 데이터와 검증 재료를 빼면 전부 금지되고, P=2는 양식 작성과 추가 서명을 허용하되 주석 변경은 거부하며, P=3은 주석도 허용합니다. 페이지 콘텐츠, 문서 구조, 메타데이터, 액티브 콘텐츠, 삭제된 객체는 어떤 DocMDP 수준에서도 금지되고, 서명이 DocMDP를 전혀 지니지 않으면 prdSuspicious로 판정됩니다. 승인 서명은 형식상 아무것도 금지하지 않지만 독자가 더 이상 서명된 것을 보지 못하기 때문입니다

ISO 32000-1 §12.8.2.4에서 온 FieldMDP는 폼 필드 판정을 더 좁힙니다. pfmaAll은 모든 필드를 잠그고, pfmaInclude는 나열된 필드만, pfmaExclude는 나열된 필드를 빼고 전부 잠급니다. Include나 Exclude를 적용하려면 분석기가 변경된 각 필드를 /Parent 체인을 통해 완전 수식 이름으로 해석해 잠금 목록과 정확 일치로 비교하므로, 부모 이름이 자식을 커버하리라 기대하기보다 터미널 필드 이름을 나열하세요. 이름을 해석할 수 없거나 트랜스폼이 파서가 모르는 액션을 쓰면 변경은 prdIndeterminate가 되고 prrFieldMdpUnresolved가 올라옵니다. 변경별 판정은 이어서 worst-first로 롤업됩니다. Suspicious가 Disallowed 위에, Disallowed가 Indeterminate 위에, Indeterminate가 Allowed 위에 섭니다. 섀도 객체 하나가 정당한 필드 업데이트 아무리 많아도 이기는 이유입니다

AnalyzeSignatureRevisions가 Delphi에서 서명 후 변경 각각에 적용하는 판정 파이프라인: Type과 Subtype 항목에서 나온 TPadesRevisionChangeKind, 커버 리비전에서 P=1부터 P=3까지의 DocMDP 판정, 완전 수식 필드 이름에 대한 FieldMDP 잠금 검사, 그리고 prdSuspicious부터 prdAllowed까지의 worst-first 롤업
섀도 객체 하나가 정당한 필드 업데이트 아무리 많아도 이깁니다. Suspicious는 Disallowed와 Indeterminate, Allowed 위에 서고, 일부 위험은 상태를 격하하지 않고 상태 옆에 기록되기 때문입니다

어떤 변경은 안전한 대신 왜 Indeterminate로 돌아올까요?

분석기가 변경이 허용된다는 걸 증명하지 못하면 변경은 Indeterminate로 돌아옵니다. 서명 검사에서 모르는 것은 허용으로 보고해서는 절대 안 되기 때문입니다. 한 가지 흔한 사례는 대신 정밀하게 처리됩니다. 장기 검증이 /DSS를 더하고 카탈로그를 다시 쓰는데, 그대로라면 P=1 아래에서 구조 변경으로 셉니다. 분석기는 오래된 카탈로그 딕셔너리와 새 딕셔너리에서 /DSS와 /Extensions를 떼어내고 나머지를 비교합니다. 다른 게 하나도 다르지 않으면 재작성은 검증 재료 업데이트로 취급되어 허용되고, 그래서 B-LT와 B-LTA 증강이 인증 서명을 깨지 않습니다. 다른 구멍은 의도적으로 열어 둡니다. 크로스 레퍼런스 스트림의 Type-2 항목은 압축된 객체 스트림을 가리키는데, 분석기는 이 보안 경계 안에서 객체 스트림을 확장하지 않으므로 그 변경은 prrCompressedObjectUnresolved를 동반한 prckCompressedObject로 드러나고, P=1 아래에서는 금지되며 그 외에는 Indeterminate입니다. 1024개 리비전, 1,000,000개 객체 번호, 2,000,000개 보고 변경의 하드 예산은 prrResourceLimitExceeded를 내고, 깨진 xref 체인은 prrMalformedRevisionChain을 냅니다. 둘 다 Indeterminate로 끝나지 통과로 끝나지는 않습니다

const
  StructuralRisks: TPadesRevisionRisks = [prrMalformedRevisionChain,
    prrDuplicateObjectDefinition, prrUnreferencedObjectDefinition,
    prrSignatureObjectRedefined, prrResourceLimitExceeded];

function RevisionVerdict(const R: TPadesRevisionAnalysisReport): string;
begin
  // 일부 위험은 Status를 바꾸지 않고 기록되므로 먼저 검사할 것
  if R.Risks * StructuralRisks <> [] then
    Exit('review: structural risk in the revision chain');
  case R.Status of
    prasNoLaterChanges: Result := 'accept: nothing was added after signing';
    prasAllowed:        Result := 'accept: every later change is permitted';
    prasDisallowed:     Result := 'reject: a change violates DocMDP or FieldMDP';
    prasSuspicious:     Result := 'reject: shadow or unconstrained content change';
    prasIndeterminate:  Result := 'review: the analyzer could not decide';
  else
    Result := 'not checked: no signatures or no original bytes';
  end;
end;

그 게이트의 순서는 의도적입니다. prrDuplicateObjectDefinition은 자체적으로는 Status를 격하하지 않고 위험 집합에 더어지고, 파싱할 수 없는 FieldMDP 트랜스폼은 폼 필드가 실제로 바뀌었을 때만 상태에 영향을 줍니다. Status만 보는 게이트는 보고서가 이미 지닌 증거를 놓칠 수 있습니다. 보고서가 주장하지 않는 것도 기억하세요. TPadesRevisionAnalysisReport는 CMS 서명이 암호학적으로 유효한지, 서명자 인증서가 여러분이 신뢰하는 루트로 체인되는지에 대해 아무 말도 하지 않습니다. 리비전 분석은 서명 후 무슨 일이 있었는지에 답하며, 구조 및 신뢰 검증 옆에 있어야 하지 그것을 대신하지 않습니다

서명 시점에 시드 값과 MDP 잠금 쓰기

같은 규칙은 서명할 때 TPadesSignatureFieldOptions로 작성할 수도 있습니다. TPadesSignOptions와 TPadesRemoteSignOptions 양쪽의 FieldOptions 멤버입니다. PDFium은 위젯을 만들 수는 있어도 /SV, /Lock, FieldMDP나 DocMDP 트랜스폼, 카탈로그 /Perms 딕셔너리를 쓰지 못하므로, 컴포넌트 자체의 증분 PAdES 라이터가 서명과 같은 xref 업데이트 안에서 이 객체들을 만듭니다. FieldName은 루트 필드 이름을 정하고, RequiredSeedValues는 ISO 32000-1 §12.7.4.5에서 기술하는 시드 값 딕셔너리의 /Ff 비트가 되며, Reasons, LegalAttestations, AcceptableCertificates는 이후 서명자가 고를 수 있는 것을 제한하고, LockAction과 LockFields는 간접 /SigFieldLock을 쓰며, 1부터 3까지의 CertificationPermission은 서명을 인증 서명으로 만듭니다. DocMDP와 FieldMDP 트랜스폼은 둘 다 서명 값의 /Reference 배열 하나로 들어가고, 각각 /Data가 카탈로그를 가리킵니다

var
  Options: TPadesSignOptions;
begin
  Options := TPadesSignOptions.Default;
  Options.CertificateThumbprint := 'A1B2C3D4E5F60718293A4B5C6D7E8F9012345678';
  Options.Reason := 'Approved for release';
  Options.FieldOptions.FieldName := 'Certification';
  Options.FieldOptions.CertificationPermission := 2;   // 양식 작성과 서명만 허용
  Options.FieldOptions.RequiredSeedValues := [psvcSubFilter, psvcDigestMethod];
  Options.FieldOptions.LockAction := pfmaInclude;      // 이 필드들만 잠금
  SetLength(Options.FieldOptions.LockFields, 2);
  Options.FieldOptions.LockFields[0] := 'Total';
  Options.FieldOptions.LockFields[1] := 'IBAN';
  if not Pdf.SignPades('contract-certified.pdf', Options) then
    Writeln('Signing failed');
end;

이걸 손으로 굴리면 틀리기 쉬운 디테일이 몇 개 있습니다. 카탈로그 /Perms /DocMDP는 위젯 주석이 아니라 서명 값 딕셔너리를 참조해야 하고, 그래서 라이터는 서명 값을 자기만의 간접 객체로 유지합니다. 기존 /Perms 딕셔너리에는 이미 /UR3 사용 권한이 들어 있을 수 있으므로, 라이터는 그것을 교체하는 대신 복사하고 /DocMDP를 삽입합니다. ISO 32000-1 §12.8.4의 권한 딕셔너리를 따르는 것입니다. 이미 /DocMDP를 지닌 문서는 두 번째 인증 서명을 EPadesCrypto로 거부하고, 어긋나는 옵션도 마찬가지입니다. 필드 이름 없는 Include나 Exclude 잠금, 필드 목록을 지닌 All 잠금, 인증 서명이 아닌 곳의 legal attestation, 루트 필드 이름 속의 점이 그것입니다. 원격 서명은 규칙을 하나 더 얹습니다. PreparePadesRemoteSignature가 돌 때는 서명 인증서를 모르기 때문에, 거기서 CertificateRequired를 설정하면 명시적인 AcceptableCertificates 목록을 요구합니다. 로컬 서명은 해석된 서명자 인증서로 폴백할 수 있지만요

리비전 분석은 서명 도구상자를 대체하지 않고 완성합니다. 딕셔너리와 베이스라인 수준을 읽으려면 PDFium Component로 PDF 서명과 PAdES 수준 들여다보기부터 시작하고, 리비전 질문에 앞서 오는 구조적 실패는 검증기가 PAdES 서명을 거부하는 이유를 보고, 판정은 JavaScript와 임베디드 파일 검사와 나란히 더 넓은 PDF 보안 위험 감사로 접어 넣으세요. 이 글에서 보여 준 TPdf.AnalyzeSignatureRevisions, TPadesSignatureFieldOptions, 증분 PAdES 라이터는 Delphi, C++Builder, Lazarus용 PDFium Component에 실려 있습니다