기술 문서

Delphi에서 FDF Differences로 서명된 폼 리비전 이어붙이기

HotPDF Component는 THPDFHeadlessDocument.SaveFDFDifferencesToFile를 통해 Delphi에서 FDF /Differences 스트림을 적용합니다. 이 메서드는 정확한 바이트 길이와 SHA-256으로 식별한 기준 리비전과 적재된 PDF가 여전히 일치하는지 검사하고, 누적 리비전 바이트를 정확히 한 번 이어붙이며, 결과를 암호화 권한과 DocMDP/FieldMDP 승인에 통과시키고, 원자적으로 발행합니다. 같은 제출을 반복하면 바이트 단위 no-op입니다

이 기능이 존재하는 워크플로는 화려하지 않지만 흔합니다. 계약서나 검사 양식을 PDF로 보내면 누군가 자기 뷰어에서 작성하고 서명하고, 돌아오는 것은 PDF가 아니라 FDF 파일입니다. 필드 값은 /Fields에 실리지만, 서명된 리비전 자체, 즉 CMS 서명이 실제로 커버하는 바이트는 /Differences에 실립니다. 병합을 잘못하면 서명을 깨거나, 더 나쁘게는 어떤 서명도 커버하지 않는 필드 값을 받아들이게 됩니다

FDF /Differences 스트림은 실제로 무엇을 실어 나를까

FDF /Differences 스트림은 대상 PDF의 raw 증분 업데이트 바이트를 실지, FDF 수준의 이력이 아닙니다. ISO 32000-1 §12.7.7.3, 표 243은 FDF 딕셔너리의 Differences 항목을 열었던 이후 원본 PDF에 가해진 증분 변경으로 정의하며, §7.5.6의 PDF 증분 업데이트와 같은 형태입니다. 새 객체와 교체된 객체, 크로스 레퍼런스 섹션, /Prev가 기본 파일을 가리키는 트레일러입니다

FDF 컨테이너 자체는 정반대입니다. §12.7.7.1은 FDF 파일에 대한 증분 업데이트를 금지하므로, FDF는 단일 트레일러, 세대 0 객체만, 유일한 객체 번호만 갖습니다. HotPDF Component는 FDF 파서에서 정확히 이것을 강제하고 /Differences를 편하게 만들려고 느슨해지지 않습니다. 증분 데이터는 엄격히 비증분 컨테이너의 한 스트림 안에서 살고, 이 두 층을 분리해 두는 것이 일의 절반입니다

Delphi용 HotPDF FDF /Differences 해부도: 단일 트레일러와 세대 0 객체를 가진 엄격히 비증분 FDF 컨테이너가 raw 증분 업데이트 바이트의 누적 /Differences 스트림 하나를 실고, /Fields와 /Annots는 서명되지도 병합되지도 않은 채 남음
서명된 리비전은 /Fields가 아니라 /Differences 스트림에 실립니다. 컨테이너 자체는 증분 업데이트를 금지하며, CMS 서명이 커버하는 바이트 페이로드만 신뢰할 수 있습니다

스트림의 두 성질이 이후 모든 설계 결정을 좌우합니다:

  • 스트림은 누적입니다. 대상 PDF가 열린 이후 추가된 모든 바이트를 담으므로, 같은 세션의 두 번째 제출은 이미 커밋한 리비전을 반복하고 그 뒤에 새 것을 더합니다
  • 같은 FDF의 바깥 /Fields와 /Annots 항목이 /Differences와 일치한다는 보장이 없으므로, 쉽게 읽을 수 있는 필드 값은 서명이 커버하는 데이터가 아닙니다

두 번째 항목은 뼈 있게 말할 가치가 있습니다. 임포트 코드가 /Fields에서 /V를 읽고 같은 파일에 서명이 들어 있었다는 이유로 그 결과를 서명된 것으로 치부한다면, 위조 채널을 만든 겁니다. HotPDF Component는 경로를 의도적으로 분리해 둡니다. FDF 어노테이션 임포터는 /Differences를 즉시 거부하고, 전용 Differences 전송만 이를 다룹니다

이어붙이기를 명시적 기준 리비전에 묶기

SaveFDFDifferencesToFile는 FDF가 어느 리비전에 속하는지 추측하기를 거부합니다. 호출자는 양식을 연 완전한 PDF 리비전의 정확한 길이인 BaseBytes와 64자리 16진수 SHA-256인 BaseSHA256을 넘깁니다. 메서드는 적재된 문서의 그 접두부를 다시 해싱해 불일치면 "The loaded PDF does not match the explicitly selected base"로 거부하고, 접두부가 startxref가 실제 크로스 레퍼런스 데이터를 가리키는 진짜 리비전 끝점에서 끝나는지 검사합니다

실용 규칙은 양식을 보낼 때 두 숫자를 기록하는 것이지 FDF가 돌아올 때가 아닙니다. HPDFCrypt의 SHA256Init / SHA256Update / SHA256Final 루틴 위의 작은 헬퍼면 충분합니다:

uses
  SysUtils, Classes, HPDFCrypt;

function PrefixSHA256Hex(const FileName: string; PrefixBytes: Int64): AnsiString;
var
  Stream: TFileStream;
  Ctx: TSHA256Context;
  Digest: TSHA256Digest;
  Buffer: array[0..65535] of Byte;
  Remaining: Int64;
  Count, I: Integer;
begin
  Stream := TFileStream.Create(FileName, fmOpenRead or fmShareDenyWrite);
  try
    if (PrefixBytes <= 0) or (PrefixBytes > Stream.Size) then
      raise EArgumentException.Create('Base length is outside the file');
    SHA256Init(Ctx);
    Remaining := PrefixBytes;
    while Remaining > 0 do
    begin
      Count := SizeOf(Buffer);
      if Remaining < Count then
        Count := Integer(Remaining);
      Stream.ReadBuffer(Buffer, Count);
      SHA256Update(Ctx, Buffer, Count);
      Dec(Remaining, Count);
    end;
    SHA256Final(Ctx, Digest);
  finally
    Stream.Free;
  end;
  Result := '';
  for I := 0 to High(Digest) do
    Result := Result + AnsiString(LowerCase(IntToHex(Digest[I], 2)));
end;

// 전송 시점: 나가는 파일 전체가 기준 리비전입니다
// BaseLength := 'out\contract-0042.pdf'의 바이트 크기
// BaseHash   := PrefixSHA256Hex('out\contract-0042.pdf', BaseLength);

적재된 문서가 정확히 기본일 필요는 없습니다. 기본에 이전 제출에서 이미 커밋한 리비전을 더한 것일 수 있으며, 누적 스트림이 다룰 만하게 유지되는 방식이 이것입니다. BaseBytes는 계속 원래 접두부를 가리키고, 그 뒤는 전부 이전 이어붙이기 바이트로 취급됩니다

Delphi용 HotPDF FDF Differences 바이트 레이아웃: 적재된 PDF는 BaseBytes 길이와 BaseSHA256으로 검증되는 기준 리비전과 이미 커밋된 이전 이어붙이기 바이트로 갈라지고, 누적 /Differences 페이로드는 새 접미부를 더하기 전에 그 바이트들을 반복해야 함
기준 접두부는 길이와 SHA-256으로 고정되고, 커밋된 바이트는 추가 전용 계약처럼 동작하며, 새 것은 뒤따르는 접미부뿐입니다. 반복 제출은 그러면 바이트 단위 no-op로 착지합니다

SaveFDFDifferencesToFile로 Differences 적용하기

THPDFHeadlessDocument.SaveFDFDifferencesToFile는 출력 파일 이름, FDF 바이트, 기준 길이와 해시, 선택적인 내장 FDF 선택, THPDFFDFPackageOptions 한도 레코드를 받고 THPDFHeadlessFDFDifferencesResult를 반환합니다. 내부에서는 FDF를 THPDFFDFPackage로 열어 DecodeDifferences를 직접 호출하므로, 미리 디코딩된 스트림이 아니라 FDF 파일을 넘기면 됩니다. 빈 Selection은 최상위 FDF를 뜻하고, 비어 있지 않으면 먼저 내장된 자식 FDF로 들어갑니다

uses
  SysUtils, IOUtils, HPDFHeadlessDocument, HPDFHeadlessCMS, HPDFFDFPackage;

procedure CommitReturnedForm(const CurrentPdf, NextPdf, FdfFile: string;
  BaseLength: Int64; const BaseHash: AnsiString);
var
  Doc: THPDFHeadlessDocument;
  Policy: THPDFHeadlessCMSOptions;
  Selection: THPDFFDFChildSelections;
  Applied: THPDFHeadlessFDFDifferencesResult;
begin
  Doc := THPDFHeadlessDocument.Create;
  try
    Policy := THPDFHeadlessCMSOptions.Default;
    Policy.TrustedCAFile := 'trust\issuing-ca.pem';
    Doc.SetSignaturePolicyOptions(Policy);
    Doc.LoadFromFile(CurrentPdf);

    Selection := nil;  // 최상위 FDF, 내장 자식 없음
    Applied := Doc.SaveFDFDifferencesToFile(NextPdf,
      TFile.ReadAllBytes(FdfFile), BaseLength, BaseHash,
      Selection, THPDFFDFPackageOptions.Default);

    Writeln(Format('base %d, prior %d, total %d, added %d, no-op %s',
      [Applied.BaseBytes, Applied.PriorAppendBytes, Applied.TotalAppendBytes,
       Applied.AddedBytes, BoolToStr(Applied.NoOp, True)]));
  finally
    Doc.Free;
  end;
end;

검사 순서가 호출 자체보다 중요합니다. 기본 해시가 통과한 뒤, 메서드는 이미 커밋된 이어붙이기 바이트, 즉 BaseBytes부터 적재된 파일 끝까지를 디코딩된 페이로드의 시작과 비교합니다. 이미 커밋한 것보다 짧은 제출은 "A cumulative submission cannot remove committed append bytes"로 실패하고, 어긋나는 제출은 "The cumulative Differences conflict with the committed revision"으로 실패합니다. 남는 접미부만 새 것이고, 그 접미부만 AddedBytes에 들어갑니다

Delphi에서 HotPDF SaveFDFDifferencesToFile 검사 파이프라인: 기준 길이와 SHA-256, 누적 접두부 비교, 임시 후보 검증, /ID와 서명 아이덴티티, 권한 비트와 DocMDP 및 FieldMDP 승인, 그리고 이어붙인 리비전의 원자적 발행
여섯 개의 실패 시 폐쇄 게이트가 고정된 순서로 돌아간 뒤에야 이어붙인 리비전의 어떤 바이트도 발행됩니다. 형태가 잘못된 FDF나 허용되지 않는 변경은 적재된 문서와 기존 출력을 그대로 둡니다

조립된 후보는 대상 디렉터리의 배타적 임시 파일에 기록되고, 플러시되고, 독립적인 리비전 원천으로 다시 열려, 출력을 교체하기 전에 검증됩니다. 파싱, 취소, 정책, 예산, 발행 실패는 적재된 문서와 기존 출력 파일을 모두 그대로 둡니다. 두 전제 조건이 사람들을 일찍 걸러 줍니다. 출력이 적재된 소스를 별칭으로 삼아서는 안 되고, 문서에 저장되지 않은 편집이 있어서는 안 됩니다. 불투명한 이어붙이기는 그 편집들을 조용히 버리기 때문입니다("Differences를 적용하기 전에 대기 중인 문서 편집을 발행하십시오")

반복 적용이 왜 바이트 단위 no-op일까

같은 누적 FDF의 반복 적용이 no-op인 이유는 페이로드의 모든 바이트가 이미 파일 안에 있기 때문입니다. 페이로드가 이전 이어붙이기 바이트와 정확히 같으면 SaveFDFDifferencesToFile는 NoOp = True와 AddedBytes = 0을 보고하고, 발행하는 출력은 현재 PDF와 동일합니다. 이것은 "추가 전용"보다 강한 보장이며, 네트워크 타임아웃 뒤의 재시도나 죽은 워커를 안전하게 만드는 것이 바로 이것입니다

// Rev 1은 이미 contract-0042-r1.pdf에 커밋됨, 같은 FDF가 두 번 도착
// (Doc, Again, Fdf, Selection, BaseLength, BaseHash는 위에서 선언)
Doc := THPDFHeadlessDocument.Create;
try
  Doc.LoadFromFile('store\contract-0042-r1.pdf');
  Again := Doc.SaveFDFDifferencesToFile('store\contract-0042-r1-retry.pdf',
    Fdf, BaseLength, BaseHash, Selection, THPDFFDFPackageOptions.Default);
  // Again.NoOp = True, Again.AddedBytes = 0,
  // 그리고 재시도 파일은 contract-0042-r1.pdf와 바이트 단위로 동일
finally
  Doc.Free;
end;

수용 테스트도 그에 맞게 쓰십시오. 재시도 출력과 현재 리비전 사이의 전체 바이트 동일성을 단언하십시오. 파일이 줄어들지 않았다는 것만이 아니라. 추가 전용 동작만 확인하는 테스트는 같은 리비전을 두 번 이어붙이는 구현도 쾌히 통과시키는데, 그 결과는 나중에 DocMDP 감사가 설명해야 하는 중복 업데이트 섹션을 가진, 그럴듯해 보이는 PDF입니다

리비전이 발행되기 전에 어떤 승인 검사가 도는가

발행 전에 후보 PDF는 아이덴티티, 서명, 권한 검사를 통과해야 하며, 어느 것도 바깥 FDF 필드를 신뢰하지 않습니다. 트레일러 /ID의 첫 원소는 변하지 않고 살아남아야 하고, 적재된 문서에 이미 있는 모든 서명 필드는 동일한 서명 딕셔너리와 같은 서명 값 아이덴티티로 여전히 존재해야 합니다. 기존 서명을 빼버리거나 교체하거나 다시 쓰는 Differences 페이로드는 hdSignatureModificationUnsupported로 실패합니다

다음은 권한 부여 층입니다. 암호화된 문서에서 비어 있지 않은 이어붙이기의 경우 변경된 객체는 역할별로 분류되고(카탈로그와 페이지, 어노테이션, AcroForm 필드, appearance 스트림) 인증된 사용자 권한 비트와 대조됩니다. 어노테이션을 편집하는 리비전에는 어노테이션 권한이 필요한 식입니다. 그다음 후보는 HotPDF Component가 다른 곳에서 쓰는 것과 같은 인증과 필드 잠금 집행을 거칩니다. DocMDP 인증 서명(ISO 32000-1 §12.8.2.2)은 이후 리비전이 어떤 종류의 변경을 할 수 있는지 제한하고, FieldMDP 잠금(§12.8.2.4)은 이름이 지정된 필드를 보호합니다. HotPDF의 DocMDP 및 FieldMDP 리비전 분석이 그 권한들이 리비전별로 어떻게 분류되는지 다룹니다

오류 처리는 "이 FDF는 형태가 잘못됐다"와 "이 리비전은 허용되지 않는다"를 나눠야 합니다. 두 번째 사례는 보통 사람이 필요하니까요:

try
  Applied := Doc.SaveFDFDifferencesToFile(NextPdf, Fdf, BaseLength, BaseHash,
    Selection, THPDFFDFPackageOptions.Default);
except
  on E: EHPDFFDFPackageError do
    QuarantineFdf(FdfFile, 'No usable Differences stream: ' + E.Message);
  on E: EHPDFHeadlessDocumentError do
    case E.Code of
      hdPermissionDenied, hdSignatureModificationUnsupported:
        EscalateForReview(FdfFile, E.Message);
      hdLimitExceeded:
        QuarantineFdf(FdfFile, 'Over budget: ' + E.Message);
    else
      QuarantineFdf(FdfFile, E.Message);  // 베이스 불일치, 충돌, 잘못된 엔드포인트
    end;
end;

이 연산이 절대 하지 않는 것도 검사하는 것만큼 중요합니다. SaveFDFDifferencesToFile는 FDF /F 파일 명세를 가져오지 않으므로, FDF 안에 이름이 적힌 PDF가 병합을 디스크나 네트워크의 다른 파일로 돌릴 수 없고, JavaScript나 액션을 실행하지도 않습니다. 모든 입력은 명시적으로 도착합니다. 적재된 기본, FDF 바이트, 여러분이 구성한 신뢰 자료입니다

Windows 트랜잭션과 C ABI fdf.differences.apply

같은 전송을 헤드리스 코드를 직접 쓰지 않고도 쓸 수 있습니다. THPDFWindowsFormDataTransaction.ApplyFDFDifferences는 FDF 바이트, 기준 길이, 기준 SHA-256, 선택, 패키지 옵션, 비밀번호를 받아 SaveFDFDifferencesToFile에 위임하고, 트랜잭션의 바이트 회계, 롤백, 취소, 같은 핸들 재시도 동작을 더합니다. Delphi가 아닌 호출자에게는 C ABI가 JSON 연산 fdf.differences.apply를 노출하며, base가 길이와 해시를 실립니다:

{ "schemaVersion": 1, "type": "fdf.differences.apply", "format": "fdf",
  "base": { "bytes": 673, "sha256": "..." },
  "signaturePolicy": { "trustedCAPEM": "..." } }

JSON 응답은 baseBytes, priorAppendBytes, totalAppendBytes, addedBytes, noop을 보고하며, THPDFHeadlessFDFDifferencesResult와 같은 필드입니다. 이 전송의 발표된 수용 게이트는 Linux FPC 3.3.1, Windows Delphi 32비트와 64비트, Windows FPC 32비트와 64비트에서 돌고, 각 보안 타깃은 1,244개의 네이티브 검사와 382개의 콜백 명령으로 21개 사례를 커버합니다

알아 둘 가치가 있는 한계와 픽스처 함정

Differences 전송은 의도적으로 좁으며, 몇몇 가장자리는 실제 테스트 스위트를 물어 뜯은 적이 있습니다:

  • 기본은 완전한 PDF 리비전이어야 합니다. 파일 중간에 걸리는 길이나 startxref가 실제 크로스 레퍼런스 데이터를 가리키지 않는 길이는 바이트 하나 복사되기 전에 거부됩니다
  • 서명 픽스처에는 간접 /Lock이 필요합니다. Windows 적재 문서 권한 리더는 /Lock이 인라인 딕셔너리인 서명 필드를 "Signature field /Lock must be an indirect reference"로 거부합니다. 일부 서명 경로는 인라인 형태를 용인하지만, 잠금 딕셔너리는 자기 객체로 쓰십시오
  • 벌거벗은 openssl req -x509로 만든 테스트 CA에는 CA:TRUE와 keyCertSign이 없어서, 체인 검증이 라이브러리 버그처럼 보이지만 아닌 인증서 검증 오류로 실패합니다
  • 바깥 /Fields 값은 이 호출이 병합하지 않습니다. 문서에 그 값들도 원한다면 정보성인지 명시적으로 정하고, 서명된 것으로 제시하지 마십시오
  • FDF 자체는 비증분으로 남습니다. 트레일러가 여럿이거나 세대가 0이 아니거나 객체 번호가 중복되는 FDF 파일은 더 풍부한 FDF가 아니라 형태가 잘못된 FDF입니다

서명된 리비전이 그림에 없는 FDF와 XFDF의 평범한 값 쪽은, HotPDF의 다중 선택 FDF 및 XFDF 왕복과 Delphi에서의 XFDF 어노테이션 임포트와 익스포트가 더 나은 출발점입니다

FAQ

FDF 파일 대신 디코딩된 Differences 스트림을 넘겨도 되나요?

아니요. SaveFDFDifferencesToFile는 넘겨진 FDF를 THPDFFDFPackage로 파싱하고, 선택적인 내장 선택을 적용하며, DecodeDifferences를 내부적으로 호출합니다. raw 리비전 바이트를 들여다보거나 보관하고 싶으면 THPDFFDFPackage.DecodeDifferences를 단독으로 쓰되, 적용 호출에는 FDF 파일을 넘기십시오

클라이언트가 두 번째, 더 큰 누적 FDF를 보내면 어떻게 되나요?

첫 커밋된 리비전이 이미 들어 있는 PDF를 적재하고 같은 BaseBytes와 BaseSHA256을 유지하십시오. 메서드는 새 페이로드가 이미 커밋된 바이트로 정확히 시작하는지 검증하고, 새 접미부만 이어붙여 AddedBytes로 보고한 뒤, 결과에 전체 권한과 DocMDP/FieldMDP 승인을 돌립니다

적용 성공은 /Fields의 필드 값이 서명됐다는 뜻인가요?

아니요. ISO 32000-1은 하나의 FDF 안에서 /Fields, /Annots, /Differences가 일치할 것을 요구하지 않고, HotPDF Component도 이를 조정하지 않습니다. 서명된 것은 이어붙인 리비전 안의 CMS 서명들이 커버하는 것이므로, 서명된 값은 FDF 래퍼가 아니라 병합된 PDF에서 읽으십시오

FDF Differences 전송, 헤드리스 서명 정책 엔진, Windows 폼 데이터 트랜잭션은 모두 HotPDF Component에 들어 있으며 Delphi, C++Builder, Free Pascal을 지원합니다