Delphi PDF 컴포넌트인 HotPDF는 이제 서명 래핑을 거부합니다. v2.759.0부터 VerifyLoadedSignatureEx와 배치 검증기 둘 다 두 /ByteRange 세그먼트 사이의 갭이 구분자를 포함해 정확히 /Contents hex 문자열일 것을 요구하고, v2.761.0은 AddLoadedSignedSignatureField를 더해 이미 서명된 PDF에 두 번째 서명을 깨끗한 증분 리비전으로 덧붙일 수 있게 합니다. 두 변경은 한 세트입니다. 올바른 두 번째 서명이 바로 더 엄격해진 검증기가 기대하는 배치이기 때문입니다
문제를 드러낸 상황은 평범합니다. 계약서가 벤더에 의해 서명된 뒤, 첫 서명을 건드리지 않고 반드시 반서명해야 하는 승인자에게 흘러갑니다. 두 번째 리비전은 첫 번째 뒤에 덧붙여지고, 자기 /ByteRange는 자라난 파일 전체를 덮으며, 두 서명 모두 검증되어야 합니다. 손으로 거기까지 가는 것은 증분 섹션을 직접 쓴다는 뜻이었고, 정확히 그렇게 한 테스트 픽스처는 예전 검증기가 흔쾌히 받아들이던 교과서적인 서명 래핑 구조로 밝혀졌습니다. 검증 API를 본 적이 없다면 HotPDF로 PDF 전자 서명 검증하기 가이드가 이 글이 쌓는 기본을 다룹니다
ByteRange 갭에 정확히 무엇이 들어가야 할까?
갭은 완전한 /Contents 값, 그것만을 담아야 합니다. ISO 32000-1 §12.8.3.3은 <와 > 구분자를 갖춘 hex 문자열이 두 바이트 범위 사이의 공간에 정확히 맞는다고 말하고, ISO 32000-2 §12.8.1이 같은 규칙을 이어갑니다. Table 252와 PAdES 문서들은 다이제스트가 Contents 값을 제외한다고만 말하는데, hex 숫자만 제외한다는 식으로 읽기 쉽습니다. 예전 HotPDF 릴리스가 그렇게 읽었습니다. PreparePDFForSigning과 스트리밍 CMS 준비는 각괄호까지 해시했고, 소스 주석은 괄호가 반드시 덮여야 한다고 우겼습니다. 갭을 서명 값과 비교하는 검증기들은 그 배치를 무효한 byte range로 표시하므로, v2.759.0은 두 구분자를 서명된 범위 밖으로 옮깁니다. 서명된 파일에 대한 빠른 독립 검사는 바이트 두 개를 보는 것입니다. 오프셋 ByteRange[1]의 바이트는 <여야 하고 오프셋 ByteRange[2] - 1의 바이트는 >여야 합니다
비지 않은 갭 검사는 왜 서명 래핑을 놓칠까?
비지 않은 갭 검사는 뭔가가 다이제스트에서 빠졌다는 것만 증명하지, 무엇이 빠졌는지는 증명하지 않습니다. 그리고 그것이 공격면 전부입니다. /Contents 플레이스홀더는 수천 개의 0 숫자로 예약되는 반면, 실제 CMS 컨테이너는 그것을 채우는 일이 드뭅니다. 공격자는 그 0 패딩 안에서 >로 hex 문자열을 일찍 닫고, 남은 예약 공간에 새 오브젝트나 위조된 리비전을 쓰고, 바이트 범위는 건드리지 않을 수 있습니다. CMS 서명은 여전히 검증됩니다. 서명된 모든 바이트가 그대로이고, 범위는 여전히 0에서 시작해 파일 크기에서 끝나기 때문입니다. 그리고 예전 HotPDF 검증기는 CoversWholeDocument를 True로 세팅한 svValid를 보고했습니다. 한편 PDF 리더는 그 서명 안 된 구멍에 앉아 있는 무엇이든 파싱합니다
HotPDF는 이제 갭을 바이트 단위로 검증할 데이터로 다룹니다. 검증기는 갭을 읽고, 구분자를 벗기고, hex 숫자와 PDF 공백(탭, 라인 피드, 폼 피드, 캐리지 리턴, 공백)만 받아들이며, 숫자를 디코딩하고 결과가 서명 딕셔너리 /Contents와 정확히 같을 것을 요구합니다. 그 외의 것은 결과를 svInvalidByteRange로 격하합니다. 검사는 단일 서명 경로와 ValidateLoadedSignatureBatch 양쪽에서 돕니다. 후자는 자기 커버리지 로직을 갖고 있어 같은 수정이 필요했습니다. v2.759.0 이전 HotPDF가 만든 파일, 그러니까 괄호가 범위 안쪽에 딱 붙어 갭에 숫자만 담긴 파일은 여전히 검증되므로, 보관된 문서가 갑자기 빨갛게 변하지는 않습니다
var
Pdf: THotPDF;
Info: THPDFSignatureInfo;
Status: THPDFSignatureVerifyStatus;
I: Integer;
begin
Pdf := THotPDF.Create(nil);
try
Pdf.LoadFromFile('SignedTwice.pdf');
for I := 0 to Pdf.GetLoadedSignatureFieldCount - 1 do
begin
Status := Pdf.VerifyLoadedSignatureEx(I, Info);
case Status of
svValid:
if Info.CoversWholeDocument then
Writeln(Info.FieldName, ': valid, covers the whole file')
else
Writeln(Info.FieldName, ': valid, ',
Info.UnsignedTrailingBytes, ' bytes appended later');
svInvalidByteRange:
Writeln(Info.FieldName, ': ByteRange gap rejected (wrapping?)');
else
Writeln(Info.FieldName, ': failed, status ', Ord(Status));
end;
end;
finally
Pdf.Free;
end;
end;
이미 서명된 PDF에 두 번째 서명은 어떻게 추가할까?
서명된 파일을 BeginIncrementalUpdate로 열고, AddLoadedSignedSignatureField를 부르고, SaveIncrementalUpdate로 저장한 다음, 클래스 함수 THotPDF.SignPDFWithPFX로 준비된 파일에 서명합니다. v2.761.0 전에는 BeginIncrementalUpdate 뒤에 THPDFPage.AddSignedSignatureField를 부른다는 문서화된 레시피가 동작할 수 없었습니다. 증분 모드에서 CurrentPage는 nil이어서 불러온 문서의 필드에 /V 플레이스홀더를 붙일 방법이 없었기 때문입니다. 새 메서드는 불러온 페이지 위에 위젯을 만들고 새 문서 경로가 쓰는 같은 플레이스홀더 딕셔너리를 /V 아래에 매닙니다. 그래서 두 서명 경로가 하나의 직렬화를 공유합니다. 첫 서명 그 자체에 대해서는 Delphi에서 PAdES 전자 서명 만들기 글이 PFX 파이프라인을 안내합니다
var
Pdf: THotPDF;
FieldIndex: Integer;
begin
Pdf := THotPDF.Create(nil);
try
Pdf.BeginIncrementalUpdate('Signed.pdf');
// 페이지 0, 포인트 단위 위젯 사각형, CMS에 8192바이트 예약
FieldIndex := Pdf.AddLoadedSignedSignatureField(0, 320, 60, 520, 120,
'ApproverSignature', 8192);
if FieldIndex < 0 then
raise Exception.Create('Page index out of range');
Pdf.SaveIncrementalUpdate('Prepared.pdf');
finally
Pdf.Free;
end;
if not THotPDF.SignPDFWithPFX('Prepared.pdf', 'SignedTwice.pdf',
'approver.pfx', 'pfx-password') then
raise Exception.Create('Second signature failed');
end;
AddLoadedSignedSignatureField는 형제들보다 일부러 조용합니다. 다른 AddLoaded* 필드 생성자들은 AcroForm에 /NeedAppearances true를 세팅해서 뷰어에게 필드 외양 재생성을 지시하는데, 서명된 문서에서 그 재생성은 서명된 콘텐츠를 다시 쓸 수 있으므로, 새 메서드는 소스가 이미 그 플래그를 갖고 있지 않다면 다시 제거합니다. /SigFlags는 원래 값 OR 3(SignaturesExist 더하기 AppendOnly, ISO 32000-1 Table 219)을 유지합니다. 페이지에 MarkDirty를 부를 필요도 없습니다. /Annots와 /Fields에 추가하는 것이 dirty 플래그를 소유 간접 오브젝트로 전파하고, 명시적인 페이지 마크는 바뀌지 않은 페이지 딕셔너리를 새 리비전으로 끌어들일 뿐이라 리비전 분석이 페이지 수정으로 보고합니다. 마지막으로 플레이스홀더는 /Contents 전에 /ByteRange를 씁니다. 패처가 sentinel /ByteRange를 먼저 찾고 앞으로 검색해 맞는 hex 문자열을 찾기 때문입니다
외부 서명자나 HSM이 CMS를 만들면 무엇이 바뀔까?
워크플로는 바뀌지 않지만, 오프셋이 이제 스펙이 말하는 것을 뜻합니다. PreparePDFForSigning은 갭이 /Contents 문자열 전체인 0 기반 두 범위를 반환하고, ContentsHexStart는 AnsiString에서 첫 hex 숫자의 1 기반 인덱스입니다. 더 짧은 CMS는 닫는 > 앞, 끝에서 0으로 패딩됩니다. PreparePDFForSigning은 찾은 첫 미패치 sentinel을 패치하므로, 리비전마다 정확히 플레이스홀더 하나를 준비하고, 파일에 이전 서명이 이미 있을 때는 검색 기반 InsertSignatureHex 대신 반환된 오프셋으로 InsertSignatureHexAt을 선호하세요
var
Bytes, ToSign, CmsHex: AnsiString;
R1Start, R1Len, R2Start, R2Len, HexStart, HexLen: Integer;
begin
Bytes := LoadFileAsAnsiString('Prepared.pdf'); // 여러분의 헬퍼
if not THotPDF.PreparePDFForSigning(Bytes, R1Start, R1Len,
R2Start, R2Len, HexStart, HexLen) then
raise Exception.Create('No signature placeholder found');
// 갭은 hex 문자열 전체: '<'가 범위 1을 끝내고 '>'가 범위 2 앞에 옴
Assert(Bytes[R1Start + R1Len + 1] = '<');
Assert(Bytes[R2Start] = '>');
ToSign := Copy(Bytes, R1Start + 1, R1Len) + Copy(Bytes, R2Start + 1, R2Len);
CmsHex := SignDetachedWithHsm(ToSign); // 여러분의 CMS 서명자, hex DER
if not THotPDF.InsertSignatureHexAt(Bytes, HexStart, HexLen, CmsHex) then
raise Exception.Create('CMS does not fit the reserved space');
SaveAnsiStringToFile(Bytes, 'SignedTwice.pdf'); // 여러분의 헬퍼
end;
새 검사들의 한계는 어디에 있을까?
갭 검사는 하나의 특정 구멍을 닫을 뿐이며 과대 판매해서는 안 됩니다. svValid는 여전히 바이트 무결성에 내장된 인증서와 맞는 키까지를 뜻합니다. 그 인증서에 대한 신뢰는 별개의 결정입니다. 갭은 검증기가 소스 바이트를 가졌을 때만 검증됩니다. VerifyLoadedSignatureEx는 불러온 파일에서 읽고 TStream 오버로드는 여러분에게서 받습니다. 반서명된 파일의 첫 서명은 CoversWholeDocument가 올바르게 False이며, 덧붙여진 리비전이 서명만 추가했는지 페이지까지 바꿨는지는 HotPDF의 DocMDP, FieldMDP, 리비전 분석의 문제입니다. 덧붙이자면 첨부된 PDF MAC 검사는 오프셋을 <와 > 위치와 비교하므로 오래된 배치와 새 배치를 모두 받아들입니다. pre-v2.759.0 오프셋을 하드코딩한 여러분 자신의 도구는 갓 서명된 파일을 만나는 순간 먼저 실패할 것입니다
여러분의 Delphi나 C++Builder 애플리케이션이 PDF를 서명하고, 반서명하고, 감사한다면 가장 안전한 길은 하나의 라이브러리에 같은 배치의 생산과 검증을 맡기는 것입니다. 네이티브 Delphi PDF 컴포넌트인 HotPDF는 위에서 보여 준 더 엄격한 갭 검증, 증분 두 번째 서명, 외부 서명자 훅을 하나의 컴포넌트로 실어 나갑니다