기술 문서

DocMDP와 FieldMDP: Delphi에서 PDF 리비전 감사하기

서명 후에 변경된 서명된 PDF가 자동으로 깨진 것은 아닙니다. ISO 32000-1은 서명 위에 증분 업데이트를 허용하며, 그중 일부만이 서명자가 설정한 정책을 위반합니다. Delphi와 C++Builder용 HotPDF Component는 AnalyzeLoadedSignatureRevisions로 이 질문에 답합니다. 이 메서드는 서명 이후의 모든 리비전을 분류하고 DocMDP와 FieldMDP에 대해 채점합니다. 이 시나리오는 계약 소프트웨어를 다루는 누구에게나 익숙합니다. 고객이 구매 계약서에 서명해서 발송했는데, 부록 페이지가 첨부된 채로 돌아옵니다. 리더는 서명은 온전하지만 서명 이후 문서가 변경되었다는 노란색 배너를 보여주고, 그 자리에 있는 누구도 그것이 정상적인 부서명 워크플로인지 아니면 누군가 조용히 서명된 계약서를 편집한 것인지 말할 수 없습니다

서명 후 어떤 변경이 합법인가

어떤 변경이든 그 의미론적 범주가 인증 서명이 선언한 허가 범위 안에 있으면 합법입니다. ISO 32000-1 §12.8.2.2는 /P 값 1, 2, 3을 가진 DocMDP 변환을 정의합니다. 1은 어떠한 변경도 허용하지 않고, 2는 양식 채우기와 서명을 허용하고, 3은 양식 채우기, 서명, 주석을 허용합니다. HotPDF는 이를 THPDFDocMDPPermission 값인 dmpNoChanges, dmpFormFillAndSign, dmpFormFillSignAndAnnotate로 노출하며, dmpNone은 DocMDP 변환을 전혀 갖지 않는 검사 결과를 위해 예약되어 있습니다

이 범주들은 순서를 가지며, 그 순서가 이 검사 전체의 엔진입니다. THPDFRevisionModificationLevelrmlNone, rmlLongTermValidation, rmlFormFillAndSign, rmlAnnotations, rmlOther를 가지며, 더 큰 서수가 절대 덜 제한적이지 않도록 일부러 배치되어 있습니다. 문서 전체는 서명 이후 모든 리비전에서 관찰된 최대 레벨로 환원되고, DocMDP 비교는 단일한 정수 검사가 됩니다. 초반에 중요한 뉘앙스가 하나 있습니다. dmpNoChanges에서도 분석은 여전히 rmlLongTermValidation을 받아들입니다. 인증된 파일에 DSS와 VRI 검증 자료나 문서 타임스탬프를 추가하는 것은 서명 유지 관리이지 문서 수정이 아니며, 이를 위반으로 취급한다면 현존하는 모든 장기 아카이브 워크플로가 깨질 것입니다

HotPDF는 리비전 체인을 어떻게 재구성하는가

휴리스틱이 아니라 구조적으로 재구성합니다. ISO 32000-1 §7.5.6에 따르면 증분 업데이트는 이전 섹션을 가리키는 /Prev를 가진 새 상호 참조 섹션을 덧붙이므로, HotPDF는 파일 끝에서 startxref를 읽고 거기서 섹션을 파싱한 다음 /Prev를 따라 거꾸로 이동하며 반복해서, 가장 오래된 것부터 순서대로 섹션을 반환합니다. 이 루프에는 두 가지 안전 한계가 있으며, 실패하는 파일을 조사할 때 둘 다 알아둘 가치가 있습니다. 이미 방문한 오프셋을 가리키는 /Prev는 계속 도는 대신 명시적인 순환 진단과 함께 순회를 종료하며, 천 개를 넘는 리비전 체인은 즉시 거부됩니다. 둘 다 Analysis.Issue에 나타나고 함수는 False를 반환하며, 둘 다 그냥 넘겨서는 안 됩니다. 순환하는 /Prev는 특이한 파일이 아니라 손상되었거나 악의적인 파일이기 때문입니다

실제 문서에서 나타나는 네 가지 역사적 형태는 모두 처리됩니다. 줄 단위로 파싱하는 전통적 xref 테이블, /W/Index 필드를 통해 압축 해제되고 디코드되는 상호 참조 스트림, 전통적 트레일러가 같은 리비전으로 파싱되어 병합되는 /XRefStm 키를 가진 하이브리드 참조 파일(Office 생성기 케이스로, 하이브리드 상호 참조 스트림 글에서 다룹니다), 그리고 ObjStm 컨테이너 안에 사는 객체입니다. 마지막 항목이 중요한 이유는 최신 업데이트가 보통 변경된 딕셔너리를 직접 쓰는 대신 압축된 스트림 안에 넣기 때문이며, 이는 객체 스트림과 증분 업데이트에 관한 글에서 설명합니다. 서명이 그 분기점을 고정합니다. /ByteRange[2] + /ByteRange[3]SignedRevisionLength가 되고, 그 오프셋 이상에 있는 모든 섹션은 서명 이후입니다. 바이트 범위가 여전히 올바르게 해시되는지는 별개의 질문이며, VerifyLoadedSignature가 답하고 PDF 디지털 서명 검증에 관한 글에서 다룹니다

변경된 각 객체는 어떻게 분류되는가

분류는 객체 단위로 실행된 뒤 참조를 따라 전파됩니다. 서명 이후 섹션이 건드린 모든 객체 번호에 대해, HotPDF는 새 본문과 서명된 스냅샷 당시의 본문을 읽습니다. 동일한 본문은 rmlNone이 되는데, 생성기가 내용을 바꾸지 않고도 객체를 다시 쓰는 경우가 흔하기 때문입니다. 인식기는 일부러 좁게 만들어졌습니다. /Type /DocTimeStamp 객체이거나 /SubFilterETSI.RFC3161인 객체는 rmlLongTermValidation이며, 카탈로그의 /DSS 트리에서 도달 가능한 것도 마찬가지입니다. /Type /Sig 딕셔너리는 rmlFormFillAndSign입니다. 컨테이너의 경우 검사 기준은 객체가 무엇이냐가 아니라 어떤 키가 움직였느냐입니다. 카탈로그는 /DSS, /Extensions, /AcroForm만 얻거나 변경할 수 있고, AcroForm 딕셔너리는 /Fields, /SigFlags, /NeedAppearances, /DR, /DA, /Q만, 페이지는 /Annots만, 필드나 위젯은 /V, /AP, /AS, /M만 가능합니다. 이 집합 바깥의 무엇이든 rmlOther로 떨어지며, 이것이 바로 첨부된 부록 페이지가 잡히는 방식입니다. 페이지를 추가하면 화이트리스트가 커버하지 못하는 방식으로 페이지 트리가 재배치되고, 정당한 양식 채우기는 그 어떤 것도 이와 비슷하지 않습니다

그다음 레벨들은 각 컨테이너가 자신이 가리키는 변경된 자식들의 최대 레벨을 물려받으며, 할당이 안정될 때까지 반복하는 방식으로 전파됩니다. 이것이 외관 스트림(appearance stream)을 작동하게 만드는 핵심입니다. 채워진 텍스트 필드는 /V를 다시 쓰고 새로운 /AP 스트림을 가리키는데, 그 스트림 자체는 인식할 타입이 없는 익명의 콘텐츠 연산자 덩어리입니다. 그 스트림을 소유한 필드가 rmlFormFillAndSign이므로, 스트림은 rmlOther로 떨어지는 대신 같은 레벨을 물려받습니다. 같은 전파가 DSS 컨텍스트를 인증서와 폐기 스트림에 실어 나르는데, 그러지 않으면 이들은 분류 불가능할 것입니다

읽을 수 없는 객체는 왜 위반으로 계산되는가

대안은 이해하지 못하는 무언가를 씀으로써 무력화되는 검증기이기 때문입니다. HotPDF에서 이의 없이 rmlOther로 끝나는 세 가지 상황이 있습니다. 본문을 리비전에서 읽을 수 없었던 객체, 리비전이 해제된 것으로 표시한 객체, 위의 인식기 중 어느 것과도 일치하지 않는 객체입니다. 각각 리비전 Issue 필드에 구체적인 진단을 기록하므로, 운영자는 어느 객체 번호가 그 판정을 냈는지 볼 수 있습니다

해제(freeing)는 셋 중 가장 예리합니다. 서명 이후 리비전이 이전에 정의된 객체를 해제로 표시했다면, 서명된 문서에서 콘텐츠를 삭제한 것이며, §12.8.2.2 아래 그 어떤 허가 레벨도 이를 허용하지 않습니다. 해당 객체 번호는 FreedObjectNumbers에 담기고 리비전은 rmlOther로 올라갑니다. 읽을 수 없는 객체도 다른 이유로 같은 논리를 따릅니다. 객체를 파싱할 수 없는 검증기는 그것을 무해하다고 부를 근거가 없으며, 그에 대한 정직한 반응은 침묵이 아닙니다. 흔치 않지만 무해한 구조를 위반으로 보고하면 사람의 검토 비용이 들지만, 반대의 실수는 눈에 띄지 않는 편집이 담긴 서명된 계약서를 그대로 내보냅니다

Delphi에서 판정 결과 읽기

호출은 짧습니다. 문서를 로드하고, 서명 인덱스를 고르고, 레코드를 읽으면 됩니다. 매개변수 없는 오버로드는 문서가 로드된 파일을 다시 열고, TStream 오버로드는 호출자가 제공한 바이트를 받아 반환 전에 스트림 위치를 복원합니다. PolicyCompliant는 대부분의 호출자가 원하는 단일 Boolean으로, 세 가지 독립적인 판단, 즉 허가 딕셔너리의 구조적 유효성, DocMDPCompliant, FieldMDPCompliant를 결합합니다. 이 구성 요소들을 UI에서 뭉뚱그리지 말고 그대로 보이게 유지하십시오. 그리고 DocMDP 변환이 없는 문서는 DocMDPCompliantTrue로 남는다는 점에 유의하십시오. 일반 승인 서명은 위반할 정책을 아예 선언하지 않으므로, 이 경우 종합된 ModificationLevel은 판정이 아니라 서술적인 값이 됩니다

var
  Pdf: THotPDF;
  Analysis: THPDFSignatureRevisionAnalysis;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('contract-countersigned.pdf') > 0 then
    begin
      if Pdf.AnalyzeLoadedSignatureRevisions(0, Analysis) then
      begin
        if Analysis.PolicyCompliant then
          Writeln('Post-signature changes stay inside the signing policy')
        else
          Writeln('Policy violation: ', string(Analysis.Issue));
      end
      else
        Writeln('Analysis could not run: ', string(Analysis.Issue));
    end;
  finally
    Pdf.Free;
  end;
end;

선별 작업에서는 보통 요약 대신 리비전별 세부 내역을 원하게 되는데, 문서 이력에서 언제 문제가 생겼는지 알려주기 때문입니다. Analysis.Revisions의 각 항목은 체인 내 인덱스, 그것이 쓰인 상호 참조 오프셋, 자신의 수정 레벨, 관련된 객체 번호를 담습니다

const
  LevelNames: array[THPDFRevisionModificationLevel] of string =
    ('none', 'long-term validation', 'form fill and sign',
     'annotations', 'other');
var
  I: Integer;
begin
  Writeln(Format('%d revisions in chain, signature sits at index %d',
    [Analysis.TotalRevisionCount, Analysis.SignedRevisionIndex]));
  for I := 0 to High(Analysis.Revisions) do
    Writeln(Format('  rev %d at offset %d: %s (%d changed, %d freed) %s',
      [Analysis.Revisions[I].RevisionIndex,
       Analysis.Revisions[I].XRefOffset,
       LevelNames[Analysis.Revisions[I].ModificationLevel],
       Length(Analysis.Revisions[I].ChangedObjectNumbers),
       Length(Analysis.Revisions[I].FreedObjectNumbers),
       string(Analysis.Revisions[I].Issue)]));
end;

FieldMDP는 별도로 판정되며, 이는 의도적입니다

문서는 DocMDP를 만족하면서도 여전히 정당하지 않을 수 있습니다. 그래서 FieldMDPCompliant는 레벨 비교에 접혀 들어가는 대신 독립된 Boolean으로 존재합니다. ISO 32000-1 §12.8.2.4는 FieldMDP 변환을, §12.7.5.5는 관련된 /SigFieldLock 항목을 정의합니다. 문서 전체가 여전히 양식 채우기를 허용하는 상황에서도, 서명 시점에 지정된 양식 필드를 고정합니다. 필드를 채우는 것은 레벨 2 동작이지만, 서명자가 잠근 필드를 채우는 것은 레벨과 무관하게 위반입니다. HotPDF는 그 범위를 THPDFFieldLockAction으로 읽어 flaAll, flaInclude, flaExclude로 표현하며, 잠금 정책이 없는 결과에는 flaNone을 사용합니다. 이름들은 Permissions.FieldNames에 담깁니다. flaAll은 모든 것을 잠그고, flaInclude는 나열된 이름들을 잠그며, flaExclude는 나열된 것을 제외한 모든 것을 잠급니다. 결과를 읽을 때 중요한 세부 사항 하나는, 서명된 스냅샷에 이미 존재하던 필드만 ChangedFieldNames에 보고된다는 점입니다. 서명 이후에 완전히 새로 만들어진 필드는 모순될 서명된 상태가 없으므로 대신 DocMDP 경로에서 잡힙니다

var
  Source: TFileStream;
  Analysis: THPDFSignatureRevisionAnalysis;
  I: Integer;
begin
  Source := TFileStream.Create('contract.pdf', fmOpenRead or fmShareDenyWrite);
  try
    if Pdf.AnalyzeLoadedSignatureRevisions(0, Source, Analysis) then
      if Analysis.Permissions.HasFieldMDP and (not Analysis.FieldMDPCompliant) then
        for I := 0 to High(Analysis.ChangedFieldNames) do
          Writeln('modified after locking: ',
            string(Analysis.ChangedFieldNames[I]));
  finally
    Source.Free;  // stream position was restored before the call returned
  end;
end;

이 분석이 말해주지 않는 것

이것은 서명을 검증하지 않습니다. AnalyzeLoadedSignatureRevisions는 구조와 허가에 대해서만 추론합니다. 서명된 바이트 범위가 여전히 CMS 블롭 안의 값으로 해시되는지, 서명자 인증서가 여러분이 신뢰하는 무언가로 체인되는지는 VerifyLoadedSignatureVerifyLoadedSignatureWithTrust가 답합니다. 파일은 정책적으로는 완벽하게 준수하면서도 암호학적으로는 무가치할 수 있으므로, 이 두 검사는 실제 수락 게이트라면 나란히 있어야 합니다. 이 분석은 콘텐츠 스트림 안의 의도도 읽지 않습니다. 콘텐츠 스트림이 통째로 교체된 페이지는 화이트리스트 바깥의 변경으로 잡히지만, 그 교체가 결제 금액을 바꿔치기했다는 사실은 알려주지 않습니다. rmlOther 판정은 사람이 봐야 한다는 뜻이지 사기가 발생했다는 뜻이 아니며, 준수 판정은 변경이 허용된 범주에 들어맞는다는 뜻이지 그 변경이 의도된 것이었다는 뜻이 아닙니다. 리비전 순회 없이 서명자가 선언한 내용만 필요할 때는 GetLoadedSignaturePermissions가 정책 딕셔너리만 단독으로 반환합니다

여기서 설명한 모든 것은 외부 서명 서비스 없이 Delphi와 C++Builder에서 네이티브로 실행되며, 이 덕분에 이미 의심되던 문서뿐 아니라 모든 수신 문서에 대해 이를 실행하는 것이 실용적입니다. 허가 및 검증 메서드와 짝을 이루는 전체 서명·리비전 API는 Delphi와 C++Builder용 HotPDF Component의 일부입니다