기술 문서

PDF 리비전은 어디서 끝날까? 서명 뒤의 위조 업데이트

계약서에 서명을 하고 나서 업데이트가 하나 더 붙으면, 감사 스크립트가 그 업데이트를 서명된 리비전에 접어 넣거나 재작성된 페이지를 검증 자료로 통과시킵니다. 리비전이 startxref가 /Prev 체인이 도달한 섹션을 이름지는 자리에서 끝나고 재작성된 객체가 자기 이전 클래스를 유지하기만 하면 둘 다 일어나지 않습니다 — PDFlibPas 리비전 맵 뒤에 있는 두 규칙입니다

파일을 자르는 자리로서 %%EOF가 틀린 이유

모든 증분 업데이트는 자기 파일 끝 마커로 종료됩니다(ISO 32000-1 §7.5.6). 그래서 각 %%EOF마다 파일을 자르는 것이 리비전을 찾는 뻔한 방법처럼 보입니다. 그러나 마커는 다섯 바이트일 뿐이고, 그 바이트들이 content stream이나 임베디드 파일, 폰트 프로그램 안에 등장하는 것을 막을 이는 아무것도 없습니다. 리니어라이즈된 파일은 두 번째 문제를 얹습니다. 부록 F(§F.3.4)는 첫 페이지 트레일러가 파일 앞쪽, 주 크로스 레퍼런스 테이블보다 한참 앞에서 자기 startxref와 %%EOF로 닫히는 것을 허용합니다. 모든 마커에서 자르면 어떤 크로스 레퍼런스 섹션도 뒷받침하지 않는 리비전을 발명합니다. 어느 마커가 가짜인지 짐작하는 것은 더 나쁩니다 — 그 짐작이야말로 위조자가 원하는 대로 빚어낼 수 있는 것입니다. PDFlibPas 리비전 맵은 반대 방향으로 동작합니다. /Prev 체인을 따라가고, 체인이 실제로 읽은 모든 크로스 레퍼런스 섹션의 바이트 오프셋을 기록한 다음에야 startxref N … %%EOF 시퀀스를 찾아 돌며, N이 그 기록된 오프셋 중 하나일 때만 경계를 유지합니다. 스트림 안의 길 잃은 마커가 무슨 의미를 지니려면 앞에 실제 섹션을 이름지는 startxref가 필요합니다

Delphi의 PDFlibPas 리비전 맵: /Prev 체인을 따라가고 startxref 오프셋을 맞춰 증분 업데이트 리비전 경계를 찾으며, 스트림 안의 길 잃은 %%EOF 마커와 리니어라이즈된 첫 페이지 트레일러를 무시합니다
/Prev 체인이 실제로 읽은 startxref 오프셋만이 리비전 끝을 표시하며, PDFlibPas가 파일을 어디서 자를지에 대해 스트림 안의 길 잃은 %%EOF 바이트는 발언권이 없습니다

그 세 토큰 사이의 바이트들은 §7.5.5가 암시하는 것보다 덜 단정합니다. 표준은 startxref, 오프셋, 마커를 잇따른 줄에 그리지만, 줄 끝 마커와 §7.2.2(Table 1)의 다른 공백 문자들은 반복될 수 있고 그 수를 제한하는 규칙은 없습니다. 패딩된 마커 하나를 놓치면 두 리비전이 하나로 합쳐집니다. 서명된 것은 이제 아무도 서명하지 않은 바이트들 뒤에서 끝나는 것처럼 보입니다. PDFlibPas 리비전 맵은 키워드에서 피연산자로, 마커로 이어지는 시퀀스를 그들을 가르는 공백이 아무리 많아도, 그 패딩이 파일 어디에 떨어져 있어도 따라가며 각 키워드를 한 번씩 셉니다. 트레일러 키워드에도 비슷한 느슨한 이음새가 있습니다. <<는 구분자(§7.2.2, Table 2)이고 §7.5.5는 trailer 뒤의 줄바꿈을 요구하지 않으므로, 한 줄의 trailer<<는 표준에 맞는 문법입니다. 줄 단위로 읽으면 그 줄은 크로스 레퍼런스 서브섹션 헤더처럼 보입니다 — 그리고 체인은 한가운데서 멈춥니다

PDFlibPas로 리비전 맵 걷기

리비전 접근자들은 TPDFlib에 선언되고 Small Business 에디션에서 구현됩니다. PDFlibSmallBusiness 유닛에서 TPDFlibSmallBusiness를 만드세요(Enterprise가 이를 상속하며 같은 호출을 지닙니다). 평범한 TPDFlib에서는 실패 값(0, 7 또는 -1)을 반환하고 LastErrorCode를 PDFLIB_ERROR_SMALLBUSINESS_REQUIRED로 set합니다. 리비전은 원본 문서인 0부터 번호가 매겨집니다. 각 호출은 파일 이름과 암호를 받고 디스크의 바이트에서 맵을 만드므로, 답은 저장된 그대로의 파일을 기술하며 우연히 로드해서 편집 중인 문서를 결코 기술하지 않습니다. GetDocumentRevisionKind는 리비전에서 가장 멀리 미치는 변경을 보고합니다. OCSP 응답을 추가하면서 페이지도 재작성한 업데이트는 페이지 재작성으로 보고됩니다:

종류의미전형적인 출처
0잡무만: reader가 보는 것을 하나도 바꾸지 않는 객체들크로스 레퍼런스 스트림, 새 오브젝트 스트림 컨테이너
1검증 자료문서 보안 저장소, VRI, 인증서, CRL, OCSP 응답
2문서 타임스탬프/DocTimeStamp 서명과 그것을 나르는 필드
3서명값을 받는 기존 서명 필드
4폼 필드필드 값 또는 그 appearance
5주석위젯이 아닌 모든 주석
6문서 내용문서가 보여 주는 것을 바꿀 수 있는 모든 것
7판별 불가리비전 안의 무언가를 읽을 수 없음, 안전하지 않은 것으로 취급
uses
  System.SysUtils, PDFlibSmallBusiness;

procedure ListRevisions(const FileName, Password: WideString);
const
  KindNames: array[0..7] of string = ('housekeeping', 'validation material',
    'document time stamp', 'signature', 'form field', 'annotation',
    'content', 'undetermined');
var
  Pdf: TPDFlibSmallBusiness;
  Count, I, J, Kind, Changes: Integer;
begin
  Pdf := TPDFlibSmallBusiness.Create;
  try
    Count := Pdf.GetDocumentRevisionCount(FileName, Password);
    if Count = 0 then
      raise Exception.Create('the cross-reference chain could not be followed');
    if Pdf.GetDocumentRevisionsComplete(FileName, Password) <> 1 then
      Writeln('WARNING: history incomplete, every figure below is a lower bound');
    for I := 0 to Count - 1 do
    begin
      Kind := Pdf.GetDocumentRevisionKind(FileName, Password, I);
      if (Kind < 0) or (Kind > 7) then
        Kind := 7;
      Writeln(Format('revision %d: bytes %d..%d, %s', [I,
        Pdf.GetDocumentRevisionStart(FileName, Password, I),
        Pdf.GetDocumentRevisionEnd(FileName, Password, I),
        KindNames[Kind]]));
      // 객체마다 'objnum,change,class,detail' 줄 하나
      Changes := Pdf.GetDocumentRevisionChanges(FileName, Password, I);
      if Changes <> 0 then
      try
        for J := 1 to Pdf.GetStringListCount(Changes) do  // 항목은 1부터 셈
          Writeln('    ' + Pdf.GetStringListItem(Changes, J));
      finally
        Pdf.ReleaseStringList(Changes);
      end;
    end;
  finally
    Pdf.Free;
  end;
end;

GetDocumentRevisionsComplete는 나머지 무엇을 믿기 전에 읽을 호출입니다. 체인이 원본 섹션까지 끝까지 따라갔을 때만 1을 반환합니다. 끊어졌거나 순환하는 /Prev, 혹은 256 링크보다 깊은 체인은 가장 오래된 업데이트들을 보이지 않게 두며, 그때 모든 개수와 인덱스는 하한이 됩니다. 256의 깊이는 실제 파일의 저장-서명 이력에는 충분한 여유이고, 자기 자신을 가리키는 체인은 거의 즉시 끝납니다. 루프의 인덱싱을 주의하세요. GetStringListItem은 1부터 세는 반면 리비전은 0부터 세며, 안쪽 루프를 0에서 시작하면 모든 리비전의 첫 변경 객체가 오류 하나 없이 떨어져 나갑니다

위조 업데이트는 어떻게 검증 자료로 둔갑할까?

PAdES 장기 검증은 마지막 서명 뒤에 정당하게 업데이트를 덧붙입니다. 문서 보안 저장소(/Type /DSS), 서명별 /VRI 사전, 인증서와 폐기 스트림, 문서 타임스탬프가 그것입니다. 인증 레벨은 이들을 통과시키고, 이 때문에 검증 자료는 업데이트가 달 수 있는 가장 값진 꼬리표입니다. 동시에 가장 값싸게 가짜를 만들 수 있는 것이기도 합니다. /Type은 업데이트가 쓰기로 고른 대로입니다. 페이지를 그리는 뷰어는 content stream이나 리소스 사전, 폰트의 /Type을 결코 보지 않으므로, 업데이트는 3페이지의 content stream을 재작성하고 자기 사전에 /Type /DSS를 붙일 수 있고, /Type을 믿는 분류기가 잡무를 보는 동안 모든 렌더러는 새 텍스트를 그립니다. /VRI, /Sig, /DocTimeStamp도 똑같이 작동합니다

PDFlibPas 리비전 맵은 업데이트가 자기가 재작성한 객체에 써 붙이는 꼬리표를 무시합니다. 재작성된 객체가 검증 자료, 타임스탬프 또는 서명으로 셈되는 것은 그 객체의 이전 버전이 이미 그랬을 때뿐입니다. 인증서, CRL, OCSP 응답 스트림은 자체 타입이 없으므로, 그들을 가리키는 문서 보안 저장소로 인식합니다 — 그리고 판단 대상 리비전이나 더 오래된 리비전에서 쓰인 저장소만 셈됩니다. 나중에 추가된 저장소는 뒤돌아가 서명된 리비전이 쓴 객체를 보증할 수 없습니다. 문서 구조에 대한 변경은 reader가 무엇을 resolve할지 바꾸는 한 언제나 콘텐츠로 셈됩니다:

PDFlibPas GetDocumentRevisionKind: 페이지 content stream을 재작성한 위조 /Type /DSS 업데이트를 문서 내용으로 보고합니다. 리비전 맵은 재작성된 객체를 그 이전 버전으로 분류하기 때문입니다
재작성된 페이지 content stream에 /Type /DSS 꼬리표를 붙여도 렌더러에는 아무 변화도 없고, 객체가 이전에는 페이지 콘텐츠였으므로 PDFlibPas는 그 업데이트를 여전히 문서 내용으로 보고합니다
  • 기존 오브젝트 스트림을 교체하는 것. 그 안의 압축된 객체 전부가 이제 교체본으로 resolve되기 때문입니다(새로 만든 컨테이너는 잡무로 남습니다. 각 멤버가 자기 항목을 받고 그 자리에서 판단되기 때문입니다)
  • 카탈로그나 페이지를 추가하는 것
  • 트레일러의 /Root가 다른 카탈로그를 이름지는 것. 루트를 맞바꾸면 서명된 객체 하나를 건드리지 않고 문서 전체를 교체하므로, 업데이트가 서명이 덮는 것을 하나도 재정의하지 않았을 때도 그렇습니다
  • 사전이 이전과 바이트까지 같은 재작성 스트림. 간접 /Length나 같은 크기로 패딩된 교체 본문은 그리는 내용이 바뀌는 동안 사전을 그대로 두므로, 그 증거로는 스트림 본문이 없는 객체만 변경 없음으로 취급됩니다

오브젝트 스트림 멤버에는 벗길 헤더가 없습니다

PDF 1.5부터 업데이트는 재작성된 객체들을 오브젝트 스트림 안에 저장할 수 있고, §7.5.7은 형식에 대해 뚜렷합니다. 저장되는 것은 객체 값뿐이며 obj와 endobj 키워드는 쓰지 말아야 합니다. 객체를 그 위치에서 읽고 obj 문자열을 찾아 헤더를 벗기는 코드는 따라서 멤버의 앞머리에서 아무것도 찾지 못하고 검색을 본문 속으로 계속합니다. 서명 뒤에 재작성된 페이지 사전에 (obj<</Type/DSS>>) 같은 무해해 보이는 문자열을 심어 두면 그 검색은 문자열 안에서 멈춥니다. 디코더가 받는 것은 <</Type/DSS>> — 리비전이 결코 쓰지 않은 저장소 사전입니다. PDFlibPas 리비전 맵은 압축되지 않은 객체의 앞머리에서 N G obj를 파싱하고, 그 숫자들이 기대한 객체를 이름지는지 검사하며, 없거나 어긋난 헤더를 더 찾아 나서는 대신 읽을 수 없음으로 취급합니다

크로스 레퍼런스 스트림에는 자기만의 필드 수준 모호성이 있습니다. §7.5.8.3(Table 18) 아래에서는 타입 1 항목만 바이트 오프셋을 지닙니다. 타입 2 항목의 둘째 필드는 그 객체를 품은 오브젝트 스트림의 객체 번호이고 셋째는 그 안의 인덱스이며, 타입 0 항목의 둘째 필드는 다음 빈 객체 번호입니다. 그 객체 번호를 오프셋으로 읽으면 압축된 항목이 그 작은 바이트 위치들을 덮는 리비전으로 떨어집니다 — 거의 언제나 원본 문서이고, 이는 재작성을 서명된 바이트 안에 숨깁니다. 리비전 맵은 모든 항목을 그것을 선언한 크로스 레퍼런스 섹션으로 배치합니다. 같은 조항은 0, 1, 2가 아닌 항목 타입을 null 객체에 대한 참조로 말하므로, 그런 항목은 아무것도 더하거나 비우지 않고 맵 밖에 머뭅니다

서명 감사는 언제 답하기를 거부해야 할까?

파일이 자기 이력의 일부를 감추고 있을 때마다입니다. 리비전 맵은 이를 세 가지로 보고합니다. GetDocumentRevisionsComplete가 0을 반환하거나, GetDocumentRevisionKind가 서명 뒤의 어떤 리비전에서 7을 반환하거나, GetSignatureRevisionIndex가 -1을 반환하는 것입니다. 마지막 값은 리비전 경계에서 끝나지 않는 ByteRange — 서명이 덮는다고 주장하는 범위 안에 바이트들이 앉는 경우 — 를 다루고, 마찬가지로 존재하지 않거나 서명 값을 지니지 않은 필드, 읽을 수 없었던 파일에 있는 필드도 다룹니다. 셋 모두 리비전 인덱스와 GetDocumentRevisionChanges 줄을 붙여 수동 검토로 보내세요. 파일을 여는 사람이 객체 번호에서 출발하도록 말입니다

// 한 서명 뒤의 업데이트들을 DocMDP식 정책과 비교해 검사합니다.
// Kind는 심각도 순으로 배열되므로 정책 레벨 각각은 상한이 됩니다:
// P 1은 잡무, 검증 자료, 타임스탬프(0..2)를 용인하며,
// P 2는 서명과 폼 채우기(..4), P 3는 주석(..5)을 더 용인합니다.
function UpdatesWithinPolicy(Pdf: TPDFlibSmallBusiness;
  const FileName, Password, FieldName: WideString; Level: Integer): Boolean;
const
  Ceiling: array[1..3] of Integer = (2, 4, 5);
var
  Signed, Count, I, Kind: Integer;
begin
  Result := False;
  if (Level < 1) or (Level > 3) then
    Exit;
  if Pdf.GetDocumentRevisionsComplete(FileName, Password) <> 1 then
    Exit;                        // 이력의 일부는 한 번도 보이지 않았음
  Signed := Pdf.GetSignatureRevisionIndex(FileName, Password, FieldName);
  if Signed < 0 then
    Exit;                        // 리비전 경계 밖, 그런 서명된 필드가 없음,
                                 // 또는 파일을 읽을 수 없었음
  Count := Pdf.GetDocumentRevisionCount(FileName, Password);
  for I := Signed + 1 to Count - 1 do
  begin
    Kind := Pdf.GetDocumentRevisionKind(FileName, Password, I);
    if (Kind = 7) or (Kind > Ceiling[Level]) then
      Exit;                      // 7은 모든 상한 위; reader를 위해 명시
  end;
  Result := True;
end;

상한들은 ISO 32000-2 §12.8.2.2(Table 257)를 따릅니다. P 1은 어떤 변경도 허용하지 않고, 2는 폼 채우기와 서명을 허용하며, 3은 주석을 더하고, DSS 자료나 문서 타임스탬프만 더하는 증분 업데이트는 모든 레벨에서 변경으로 셈되지 않습니다. ISO 32000-1 §12.8.2.2는 그 면제 없이 같은 세 값을 정의합니다. 정책이 검증자의 것이 아니라 당신의 것일 때는 루프를 직접 쓰세요. 예컨대 파일이 어떤 P를 지녀도 주석 달린 계약서를 거부하는 접수 규칙이 그렇습니다. 정책이 파일에서 나와야 할 때는 같은 클래스의 GetSignatureModificationVerdict가 인증 레벨을 읽고, FieldMDP 잠금 폼 필드의 편집 탐지에서 기술한 필드별 잠금을 적용하며, 경계 밖 사례에 5를 반환합니다. 둘 다 간직해 둔 문서 사본에 대비해 변경을 분류하기와는 다른 질문에 답합니다. 리비전 맵은 눈앞의 파일 말고는 아무것도 필요로 하지 않습니다

Delphi의 PDFlibPas DocMDP식 서명 감사: P 1, P 2, P 3에 GetDocumentRevisionKind 상한을 쓰며 GetDocumentRevisionsComplete와 GetSignatureRevisionIndex를 먼저 검사합니다
각 DocMDP 레벨은 리비전 종류에 대한 상한이 됩니다. P 1은 종류 0부터 2까지, P 2는 4까지, P 3는 5까지 용인하며, 판별 불가 리비전은 모든 레벨에서 실패합니다

덧붙이기는 같은 체인을 따릅니다. 증분 업데이트의 트레일러는 이전 크로스 레퍼런스 섹션의 위치를 주는 /Prev 항목을 지녀야 하고(§7.5.6), 전체 스캔에서 크로스 레퍼런스 테이블을 재구축해야만 열리는 문서에는 그 줄 위치가 없습니다. AppendToFile은 그런 문서에 0을 반환하고 대상 파일을 그대로 둡니다. 통째로 저장하고 깨끗한 사본에 서명하세요 — 전체 재작성은 기존 서명을 물려받지 않습니다

서명된 계약서 아카이브라면 새로 무언가가 덧붙기 전에 서명 필드마다 UpdatesWithinPolicy를 한 번 돌리세요. 나중 리비전이 종류 7로 읽히는 파일은, 읽을 수 없는 리비전을 또 하나의 업데이트 아래 묻어 버리는 PDFlibPas 서명이나 장기 검증 단계보다 먼저 사람에게 도달해야 합니다