다른 도구에서 서명한 AES 암호화 계약서를 열면 서명 값이 복호화해도 무작위 바이트로 나오거나, AES-GCM에서는 서명 사전 자체가 복호화되지 않습니다. 트레일러 /ID와 암호화 사전을 제외하면 ISO 32000-2 §7.6.2는 서명 /Contents만 암호화하지 않고 두며, PDFlibPas는 읽을 때와 쓸 때 이를 평문으로 유지하는 한편 /Reason과 /M은 암호문으로 둡니다
ISO 32000-2가 암호화 규칙에 추가한 예외
ISO 32000-2 §7.6.2는 암호화가 파일의 모든 문자열과 스트림을 덮는다고 하되 네 가지 예외를 둡니다:
- 트레일러
/ID항목의 값들 /Encrypt사전 안의 문자열들- 스트림 안의 문자열들. 스트림은 통째로 암호화됩니다
- 서명 사전에서
/Contents값인 모든 16진수 문자열
처음 셋은 예전 것입니다. ISO 32000-1 §7.6.1은 이 셋만 열거하므로 2008년 판본을 기준으로 쓰인 복호기는 서명 사전의 무엇을 건너뛰라는 지시를 받지 못합니다. 그럼에도 서명 도구들은 이 값을 평문으로 썼습니다. 이유는 기계적입니다 — 서명자는 16진수 플레이스홀더를 예약하고, 파일 전체를 쓰고, 플레이스홀더를 뺀 나머지를 해시하고, CMS를 만들어 그 16진수를 간격에 덧씌웁니다. 그 다이제스트는 디스크에 놓인 그대로의 바이트, 암호문까지 덮습니다. 서명 값을 암호화하면 지켜 주는 것도 없거니와, 패치 단계가 파일 키를 알아야 하고 해시가 존재하기 전에 크기가 정해진 플레이스홀더 안에 IV와 패딩까지 맞춰 넣어야 합니다. 사전의 나머지는 평범한 규칙을 따릅니다:
| 서명 사전 항목 | PDF 타입 | 암호화해 저장? |
|---|---|---|
/Contents | 16진수 문자열 | 아니요, ISO 32000-2 §7.6.2의 예외 |
/Reason, /Location, /ContactInfo, /Name | 텍스트 문자열 | 예 |
/M | 날짜 문자열 | 예 |
/ByteRange | 정수 배열 | 아니요, 정수는 결코 암호화되지 않음 |
/Type, /Filter, /SubFilter | 이름 | 아니요, 이름은 결코 암호화되지 않음 |
이 예외를 놓친 reader는 평문 DER 위에 암호를 돌립니다. 무엇이 나오는지는 알고리즘이 정합니다. RC4는 스트림 암호라 출력이 길이를 유지한 채 잡음으로 변하고, CMS 파서는 더 이상 0x30이 아닌 첫 바이트를 보고 형태가 틀린 서명이라 보고합니다. AES-CBC는 첫 16바이트를 초기화 벡터로, 나머지를 암호문으로 취급하며, 패딩 오류로 끝나거나 더 짧은 쓰레기 덩어리가 나옵니다. ISO/TS 32003이 PDF용으로 정의한 AES-GCM이 가장 가혹합니다. 암호화된 문자열마다 자체 nonce와 인증 태그를 지니고, 평문 값은 태그 검사를 통과할 수 없습니다. 태그 하나의 실패를 사전 전체의 실패로 치는 복호기는 서명 값과 함께 /Reason과 /M까지 잃습니다
서명 /Contents를 다른 /Contents와 어떻게 구별할까?
키 이름으로 맞춰 보는 것이 뻔한 첫 시도입니다. 양방향에서 다 실패합니다. 주석 사전에도 /Contents 항목이 있습니다 — 검토자가 적어 넣은 코멘트를 담는 텍스트 문자열이고, 이 문자열은 암호화되어야 합니다. 건너뛰면 기밀 파일의 포스트잇 전부가 암호 없이 읽힙니다. /Type /Sig로 맞추면 반대 방향으로 깨집니다. ISO 32000-2 Table 255는 서명 사전에서 /Type을 선택으로 두고 Sig를 기본값으로 삼으며 /DocTimeStamp에서만 요구하므로, 표준을 따르는 producer는 이를 빼고 쓸 수 있습니다. 그래서 PDFlibPas는 같은 사전이 /ByteRange 배열을 지니고, /Type이 Sig나 DocTimeStamp이거나 /Type이 아예 없는 자리에 /Filter 이름이 있을 때만 /Contents 문자열을 서명 값으로 취급합니다. 모든 서명 사전에는 /Filter가 필요하고 서명 필드에 속하는 서명에는 /ByteRange가 필요하며, 주석에는 둘 다 없습니다. 라이브러리는 저장 시 암호화할 때와 로드 시 복호화할 때 같은 검사를 돌리므로, 로드가 끝난 /Contents는 producer가 쓴 바이트를 그대로 담고 나옵니다
먼저 암호화하고 그다음 PDFlibPas로 서명하기
서명은 마지막에 하세요. 서명 다이제스트는 저장된 바이트를 덮으므로, 서명 뒤에 파일을 다시 쓰는 어떤 조작도 그 안의 모든 서명을 깹니다. 서명된 문서에 암호화를 적용하면 문자열과 스트림 하나하나가 다시 암호화되고, 암호화를 제거하는 것도 똑같이 철저히 다시 씁니다. 먹히는 순서는 암호화, 저장, 그리고 암호화된 파일에 서명입니다. 서명 프로세스는 증분 업데이트를 덧붙이는데, 그 새 문자열들은 기존 파일 키로 암호화되고 /Contents 플레이스홀더만 평문으로 남습니다. 서명 프로세스로 파일을 열 때는 소유자 암호를 쓰세요. 사용자 암호로 충분한 경우는 권한에 ISO 32000-2 Table 22의 비트 9가 포함될 때뿐이고, 이 비트는 서명 필드를 명시적으로 이름지습니다. Encrypt와 서명 프로세스는 모든 PDFlibPas 에디션에서 쓸 수 있습니다
uses
SysUtils, PDFlibrary;
const
OwnerPwd = 'owner-secret';
UserPwd = 'reader-secret';
procedure EncryptCopy(const Source, Target: string);
var
Lib: TPDFlib;
Perms: Integer;
begin
Lib := TPDFlib.Create;
try
if Lib.LoadFromFile(Source, '') <> 1 then
raise Exception.CreateFmt('Load failed (%d)', [Lib.LastErrorCode]);
// 인쇄, 필드 채우기(비트 9가 서명 필드를 포함), 접근성 복사, 고해상도 인쇄
Perms := Lib.EncodePermissions(1, 0, 0, 0, 1, 1, 0, 1);
// Strength 4 = AES-256 리비전 6: 파일 키를 직접 사용, /ID는 관여하지 않음
if Lib.Encrypt(OwnerPwd, UserPwd, 4, Perms) <> 1 then
raise Exception.CreateFmt('Encrypt failed (%d)', [Lib.LastErrorCode]);
if Lib.SaveToFile(Target) <> 1 then
raise Exception.CreateFmt('Save failed (%d)', [Lib.LastErrorCode]);
finally
Lib.Free;
end;
end;
procedure SignField(Lib: TPDFlib; const Source, Target, FieldName,
PFXFile, PFXPassword: string);
var
SP, R: Integer;
begin
SP := Lib.NewSignProcessFromFile(Source, OwnerPwd);
if SP = 0 then
raise Exception.CreateFmt('Input not found: %s', [Source]);
try
Lib.SetSignProcessField(SP, FieldName); // 없으면 보이지 않게 생성
Lib.SetSignProcessPFXFromFile(SP, PFXFile, PFXPassword);
Lib.SetSignProcessInfo(SP, 'Approved for release', 'Head office', '');
// 나중에 붙일 서명 타임스탬프를 위한 플레이스홀더 여유 공간
Lib.SetSignProcessReserveContentsBytes(SP, 16384);
Lib.EndSignProcessToFile(SP, Target); // 항상 0을 반환
R := Lib.GetSignProcessResult(SP);
if R <> 1 then
raise Exception.CreateFmt('Signing %s failed (result %d)', [FieldName, R]);
finally
Lib.ReleaseSignProcess(SP);
end;
end;
Strength 4는 AES-256 리비전 6을 고르며, 이 선택이 실패 양상 둘을 없앱니다. RC4와 AES-128에서는 Algorithm 1(ISO 32000-1 §7.6.2)이 객체 번호와 세대 번호에서 객체마다 별도의 키를 끌어냅니다. Algorithm 2(ISO 32000-1 §7.6.3.3, 단계 e)는 첫 /ID 요소를 파일 키에 섞기도 합니다. 서명 업데이트는 AcroForm 사전과 페이지의 /Annots 배열을 다시 쓰므로, 다시 쓰인 객체마다 원래 세대 번호를 유지해야 하고 업데이트의 트레일러는 같은 첫 /ID 요소를 지녀야 합니다. 세대가 틀리면 그 객체는 더 이상 복호화되지 않고, /ID가 틀리면 아무것도 복호화되지 않습니다. 리비전 6은 파일 키를 직접 쓰고(ISO 32000-2 §7.6.3.3, Algorithm 1.A) /ID를 참조하지 않고 키를 되찾습니다(§7.6.4.3.3, Algorithm 2.A). PDFlibPas는 모든 강도에서 다시 쓴 객체의 세대 번호를 유지하고 저장된 그대로의 /ID에서 키를 끌어내며, 잘못 만들어진 파일이 ISO 32000-2 Table 15가 요구하는 직접 배열 대신 /ID 7 0 R을 지녀도 처리합니다. 빈 /ID []는 암호 검사에 실패합니다. 암호에서 키로 가는 경로는 원본 파일 키로 암호화된 PDF 열기에서 더 깊이 추적합니다
결과는 텍스트 에디터면 충분히 볼 수 있습니다. 서명 사전은 자기를 추가한 업데이트 안에서 압축 없이 앉아 있습니다. 서명자가 /ByteRange와 /Contents를 파일 오프셋으로 패치하기 때문입니다. /ByteRange를 검색하면 바로 그자리로 떨어집니다. /Contents 16진수는 대개 3082, 즉 CMS SignedData SEQUENCE의 DER 헤더로 열리고, 플레이스홀더가 서명보다 컸던 자리를 긴 0의 연속으로 닫습니다. /Reason은 텍스트가 같아도 서명을 돌릴 때마다 달라지는 암호문입니다. AES가 문자열마다 새 IV를 뽑기 때문입니다. 최근 qpdf에서 예상되는 것: qpdf --password=owner-secret --show-object=14 signed.pdf는 변하지 않은 /Contents 곁에 복호화된 /Reason을 인쇄합니다. 이 검사에는 strength 4 파일을 쓰세요. 원본 파일에서 예상되는 모양이며, 오프셋과 바이트는 달라집니다:
14 0 obj
<</Type /Sig /Filter /Adobe.PPKLite
/ByteRange [0 48211 81093 2304]
/Contents <308240A206092A864886F70D010702A0...000000000000>
/Reason <7F21C9E04B...>
/M <D35A0F98...>
...>>
endobj
타임스탬프를 끼워 넣으면 어떤 서명이 깨질까?
타임스탬프가 붙은 것 뒤에 추가된 모든 서명입니다. PAdES 서명 타임스탬프는 서명자의 unsigned 속성(RFC 5652 §5.3, unsignedAttrs)으로 저장된 RFC 3161 토큰이라 /Contents의 CMS 안에 삽니다. 하나를 붙인다는 말은 그 16진수를 제자리에서 다시 쓴다는 뜻입니다. 암호화된 파일에서 이 재작성이 먹히는 것은 /Contents가 평문이기 때문입니다. 재암호화도, 새 객체도 없습니다. 토큰은 서명 시점에 예약해 둔 공간에 들어가기만 하면 됩니다. 그 서명을 소유한 서명은 /ByteRange가 그 바이트들을 건너뛰기에 살아남습니다. 나중 서명은 그런 예외가 없습니다. ISO 32000-2 §12.8.1은 각 서명의 바이트 범위가 %PDF- 헤더에서 자기를 추가한 증분 업데이트의 끝까지 달리게 정합니다. 따라서 두 번째 서명이나 문서 타임스탬프는 첫 서명의 /Contents까지 나머지와 함께 해시합니다. 그 간격은 하나의 서명만의 것입니다. 두 번째 서명자가 서명한 뒤에 패치하면 두 번째 서명의 다이제스트가 더는 맞지 않습니다. 토큰 요청 자체는 Delphi에서 PAdES 서명과 검증에 속하고, 이 절은 어떤 바이트가 움직이는지만 다룹니다
PDFlibPas는 아무것도 해시하기 전에 겹침을 검사합니다. 서명 타임스탬프 프로세스는 문서 안의 다른 모든 서명의 /ByteRange를 읽고 각 스팬 쌍을 대상의 /Contents 간격과 비교합니다. 겹침이 하나라도 있으면 준비가 프로세스 결과 17로 멈추고 출력 파일은 쓰이지 않습니다. 이 프로세스는 Enterprise 에디션에 속하며, PDFlibEnterprise 유닛의 TPDFlibEnterprise로 인스턴스화합니다. 다른 에디션도 메서드는 지니고 있지만 0을 반환하고 LastErrorCode를 900으로 set합니다. 로그에서 17을 주의하세요. GetPAdESSignatureTimeStampProcessResult에서는 나중 서명이 이 서명을 덮었다는 뜻이고, GetSignProcessResult에서는 인증 레벨이나 필드 잠금이 서명을 금지한다는 뜻입니다. 둘을 따로 매핑하세요. 아래 헬퍼는 앞 목록의 SignField와 OwnerPwd를 씁니다
uses
SysUtils, PDFlibrary, PDFlibEnterprise;
const
STS_COVERED_BY_LATER_SIGNATURE = 17;
function StampSignature(Lib: TPDFlibEnterprise; const Source, Target,
FieldName: string): Integer;
var
STS: Integer;
begin
Result := 0;
STS := Lib.NewPAdESSignatureTimeStampProcessFromFile(Source, OwnerPwd);
if STS = 0 then
Exit;
try
Lib.SetPAdESSignatureTimeStampField(STS, FieldName);
if Lib.ApplyPAdESSignatureTimeStamp(STS, 0) <> 1 then
begin
Result := Lib.GetPAdESSignatureTimeStampProcessResult(STS);
if Result = 1 then
Result := -1; // 값은 해시됐고 TSA 교환은 실패: GetTimeStampError 참조
Exit;
end;
Result := Lib.EndPAdESSignatureTimeStampProcessToFile(STS, Target);
finally
Lib.ReleasePAdESSignatureTimeStampProcess(STS);
end;
end;
procedure SignAndStampInOrder(const EncryptedFile: string);
var
Lib: TPDFlibEnterprise;
R: Integer;
begin
Lib := TPDFlibEnterprise.Create;
try
Lib.SetTimeStampServer('https://tsa.example.com/tsa', '', '');
SignField(Lib, EncryptedFile, 'step1.pdf', 'Author', 'author.pfx', 'pfx-1');
R := StampSignature(Lib, 'step1.pdf', 'step1-ts.pdf', 'Author');
if R <> 1 then
raise Exception.CreateFmt('Author time stamp failed (%d)', [R]);
SignField(Lib, 'step1-ts.pdf', 'step2.pdf', 'Approver', 'approver.pfx', 'pfx-2');
R := StampSignature(Lib, 'step2.pdf', 'final.pdf', 'Approver');
if R <> 1 then
raise Exception.CreateFmt('Approver time stamp failed (%d)', [R]);
// 예상: 17. step2.pdf에서 Approver의 바이트 범위가 Author의 /Contents를
// 덮으므로 프로세스가 거부하고 restamped.pdf는 결코 쓰이지 않음
R := StampSignature(Lib, 'step2.pdf', 'restamped.pdf', 'Author');
if R <> STS_COVERED_BY_LATER_SIGNATURE then
raise Exception.CreateFmt('Unexpected result %d', [R]);
finally
Lib.Free;
end;
end;
다음 서명자가 파일을 만지기 전에 각 서명에 타임스탬프를 붙이세요. 서명자마다 TSA 왕복 한 번이 들지만 모든 서명이 자기만의 신뢰 시간을 얻습니다. 여러 서명이 이미 들어 있는 파일은 이야기가 다릅니다. 앞선 것들은 이제 서명 타임스탬프를 붙일 수 없습니다. 대신 문서 타임스탬프를 덧붙이세요 — 새 리비전에서 /Type /DocTimeStamp 서명에 /SubFilter /ETSI.RFC3161을 단 것으로, 그 바이트 범위가 앞선 모든 서명을 덮고 그들이 그 순간에 존재했음을 증명합니다. ApplyPAdESLongTermArchiveValidation은 검증 자료를 붙인 뒤 하나를 추가합니다. 서명할 때는 첫 목록처럼 SetSignProcessReserveContentsBytes로 서명 타임스탬프 방을 예약하세요. TSA 인증서 체인을 품는 토큰은 몇 킬로바이트에 이르기 쉽고, 여유가 없는 플레이스홀더는 나중 증강이 결과 13으로 실패하게 만듭니다
서명된 파일 복호화와 검증자가 해시하는 것
암호화된 파일의 서명 범위는 암호문입니다. 검증자는 저장된 암호문의 다이제스트와 CMS 안의 다이제스트를 비교합니다. /ByteRange는 정수고 /Contents는 평문이라 무결성 검사에는 키가 필요 없습니다. 키가 필요한 것은 필드 이름과 /Reason뿐입니다. 같은 사실이, 눈에 보이는 픽셀 하나도 바뀌지 않았는데도 서명된 파일을 복호화해 평문으로 저장하면 그 안의 모든 서명이 무효가 되는 이유를 설명합니다. 암호화·서명된 원본을 기록 사본으로 보관하세요. 크로스 레퍼런스 스트림도 결코 암호화되지 않습니다(ISO 32000-2 §7.5.8.2). xref 스트림으로 쓰인 업데이트는 어떤 키도 끌어내기 전까지 오프셋을 읽을 수 있게 유지합니다. 이 배치는 Delphi에서의 오브젝트 스트림과 크로스 레퍼런스 스트림에 기술되어 있습니다. 서명 범위가 커서 비교를 위해 메모리에 읽을 수 없을 때 PDFlibPas는 무결성 검사를 invalid 대신 unknown으로 보고합니다. 한 번도 비교되지 않은 바이트가 다르다고 나타난 적은 없으니까요
암호화된 파일의 서명 값은 다른 서명이나 문서 타임스탬프가 덮기 전까지 서명 뒤에도 계속 쓸 수 있습니다. 타임스탬프 단계를 그 창 안에 넣으면 첫 목록의 암호화 후 서명 순서가 Delphi용 PDF Library의 다중 서명자 파일에도 그대로 이어집니다