기술 문서

Delphi FieldMDP: 잠긴 폼 필드 변경 감지

PDFlibPas는 TPDFlib.GetSignatureModificationVerdict로 FieldMDP 질문에 답합니다. 서명된 PDF와 비밀번호, 서명 필드 이름을 넘기면 이 메서드는 그 서명 뒤에 추가된 모든 증분 업데이트를 훑으면서 해당 업데이트들이 건드린 폼 필드의 전체 정규화 이름(fully qualified name)을 해석하고, FieldMDP로 잠긴 필드가 변경되었으면 3을, 잠기지 않은 필드만 바뀌었으면 2를 반환합니다. GetSignatureModificationDetail은 이어서 리비전과 필드 이름을 알려 줍니다

이 기능이 필요한 워크플로는 다중 결재 양식입니다. 요청자가 구매 요청서에 Order.Customer와 Order.Total을 채워 넣고 Approval.Requester로 서명하며 두 필드를 잠급니다. 관리자가 코스트 센터를 추가하고 Approval.Manager로 서명합니다. 두 주 뒤 누군가 데스크톱 에디터에서 파일을 열어 세 번째 증분 업데이트로 Order.Total을 몰래 올립니다. 모든 서명은 여전히 검증을 통과합니다. 각 서명이 자기 바이트 범위만 커버하기 때문입니다. 감사자가 실제로 묻는 질문은 어느 필드가 어느 서명 뒤에 바뀌었는지, 그리고 그 변경이 허용된 것이었는지입니다

DocMDP만으로는 잠긴 필드 편집을 못 잡는 이유

DocMDP는 변경의 종류를 분류할 뿐 개별 필드를 보지 않으므로 이 편집을 잡지 못합니다. ISO 32000-1 §12.8.2.2의 인증 권한 레벨은 이후 업데이트가 양식 작성과 서명까지만 허용되는지(P 2) 주석 달기까지 허용되는지(P 3)를 정하며, 이 권한은 문서의 모든 필드에 똑같이 적용됩니다. Order.Total을 고치는 것은 양식 작성에 불과하므로, 요청자가 금지하려 했던 바로 그 편집이 P 2 인증을 만족시켜 버립니다. PDFlibPas의 문서 수준 수정 분류가 그 층위를 담당하고, 이 글은 한 단계 더 잘게 쪼갠 이야기입니다

FieldMDP는 필드 단위의 층위입니다. 서명 필드는 /Lock 딕셔너리를 가질 수 있고(ISO 32000-1 §12.7.4.5), 그 서명이 담고 있는 서명 참조에 FieldMDP 트랜스폼을 넣을 수도 있습니다(ISO 32000-1 §12.8.2.4). 둘은 같은 어휘를 씁니다. /Action /All은 모든 필드를 잠그고, /Action /Include는 /Fields에 나열된 이름을 잠그며, /Action /Exclude는 /Fields의 이름을 제외한 나머지 전부를 잠급니다. v3.586.0 이전에는 GetSignatureModificationVerdict가 이후 리비전에서 폼 필드가 바뀌었다는 사실은 알았어도 어느 필드인지는 몰랐으므로, 필드 잠금을 담고 있고 이후 업데이트가 검증 자료 이상으로 나아간 문서는 전부 4, 미확정으로 돌아왔습니다. 이제 리비전 맵이 분류하는 모든 필드 관련 객체에 해석된 필드 이름을 기록하므로, 그 4가 진짜 2나 3으로 바뀝니다

결재자가 서명할 때 필드 잠금 지정하기

TPDFlib.SetSignProcessFieldLock은 서명 프로세스가 만드는 다음 서명에 잠금을 지정합니다. 액션 1은 All, 2는 Include, 3은 Exclude이고 0은 잠금을 해제합니다. Include와 Exclude는 세미콜론으로 구분된 전체 정규화 필드 이름 목록을 받습니다. 서명된 필드에는 /Lock 딕셔너리와 그에 맞는 FieldMDP 트랜스폼이 함께 기록되는데, 엄격한 검증기가 기대하는 짝이 바로 이것입니다

Delphi FieldMDP 서명용 PDFlibPas SetSignProcessFieldLock 액션 코드: 액션 1 All은 이후 서명 필드까지 포함한 모든 폼 필드를 잠그고, 액션 2 Include는 이름이 지정된 데이터 필드를 잠그며, 액션 3 Exclude는 목록을 제외한 전부를 잠그고, 액션 0은 잠금을 해제
각 결재자가 소유한 데이터 필드는 Include로, 이후 서명 필드를 나열한 Exclude로 선택하고, 거부된 호출은 0을 반환하며 기존 잠금이 그대로 남으므로 반환값은 항상 확인합니다
var
  Pdf: TPDFlib;
  SignId: Integer;
begin
  Pdf := TPDFlib.Create;
  try
    SignId := Pdf.NewSignProcessFromFile('purchase-request.pdf', '');
    if SignId = 0 then
      raise Exception.Create('cannot open the form');
    try
      Pdf.SetSignProcessField(SignId, 'Approval.Requester');
      Pdf.SetSignProcessPFXFromFile(SignId, 'requester.pfx', PfxPassword);
      // 2 = Include: 이 서명이 얼리는 것은 이 필드들뿐입니다
      if Pdf.SetSignProcessFieldLock(SignId, 2,
        'Order.Customer;Order.Total') <> 1 then
        raise Exception.Create('field lock rejected');
      Pdf.EndSignProcessToFile(SignId, 'purchase-request-signed.pdf');
      if Pdf.GetSignProcessResult(SignId) <> 1 then
        raise Exception.CreateFmt('signing failed, code %d',
          [Pdf.GetSignProcessResult(SignId)]);
    finally
      Pdf.ReleaseSignProcess(SignId);
    end;
  finally
    Pdf.Free;
  end;
end;

SetSignProcessFieldLock은 이름 없이 Include나 Exclude를 호출하면 0을 반환하고, 'Order.Customer;;Order.Total'처럼 빈 항목이 섞인 목록에서도 0을 반환합니다. 거부된 호출은 이전에 설정된 잠금을 그대로 두므로, 마지막 호출이 통과했으리라 가정하지 말고 반환값을 확인하십시오. 다이제스트 선택, /Contents 예약, 결과 코드 등 서명 설정의 나머지는 PDFlibPas용 PAdES 서명 및 검증 가이드가 다룹니다. 결재 체인에서만 생기는 설계 포인트가 하나 있습니다. 요청자 서명에 /All 잠금을 걸면 Approval.Manager까지 얼어 버립니다. 서명 필드도 폼 필드의 일종이기 때문입니다. 각 결재자가 소유한 데이터 필드는 Include로 잠그거나, 이후 서명 필드들을 나열한 Exclude를 쓰십시오

결재 체인 전체에 판정 돌리기

GetSignatureModificationVerdict는 한 번에 한 서명만 판정하므로, 결재 체인 검사는 서명 필드마다 한 번씩 호출하는 방식으로 진행합니다. 반환값은 작고 닫힌 집합이며, 검토자에게 각 값이 뜻하는 바는 다음과 같습니다:

  • 0: 파일이나 지정된 서명 필드를 읽지 못했습니다
  • 1: 서명 뒤에 아무것도 추가되지 않았습니다
  • 2: 유효 권한이 허용하는 변경만 추가되었고 잠긴 필드는 건드리지 않았습니다
  • 3: 이후 리비전이 허용되지 않는 변경을 만들었습니다. FieldMDP가 잠근 필드의 편집도 여기에 포함됩니다
  • 4: 답을 확정하지 못했습니다. 승인으로 읽어서는 절대 안 됩니다
  • 5: 서명이 리비전 경계에서 끝나지 않아, 자신이 커버한다고 주장하는 범위 안에 바이트가 걸려 있습니다
const
  Approvals: array[0..2] of WideString =
    ('Approval.Requester', 'Approval.Manager', 'Approval.Finance');
var
  Pdf: TPDFlib;
  I, Verdict: Integer;
begin
  Pdf := TPDFlib.Create;
  try
    for I := Low(Approvals) to High(Approvals) do
    begin
      Verdict := Pdf.GetSignatureModificationVerdict(
        'purchase-request-final.pdf', FormPassword, Approvals[I]);
      // 두 게터는 방금 호출한 결과를 기술하므로 다음 호출 전에 읽어 둡니다
      Log.Add(Format('%s: verdict %d, MDP level %d, %s',
        [Approvals[I], Verdict, Pdf.GetSignatureEffectiveMDPLevel,
         Pdf.GetSignatureModificationDetail]));
      case Verdict of
        1, 2: ;                                 // 수용
        3: RejectForm(Approvals[I]);            // detail에 필드와 리비전이 기록됨
      else
        QueueForManualReview(Approvals[I]);     // 0, 4, 5
      end;
    end;
  finally
    Pdf.Free;
  end;
end;

변조된 구매 요청서를 예로 들어 보면, 리비전 0은 빈 양식이고 리비전 1과 2는 두 서명이며 리비전 3이 그 편집입니다. 잠금 정의는 서명된 모든 필드에서 모아 오므로, Approval.Manager의 판정에는 이미 요청자의 잠금이 집행됩니다. 그리고 관리자 서명 이후의 유일한 업데이트는 그 편집 자체입니다. 그 업데이트가 필드 딕셔너리만 다시 썼다면 판정은 3이고 detail은 Revision 3 changed locked form field "Order.Total", which FieldMDP does not allow.을 출력합니다. 이 한 줄이 감사 질문 두 개에 동시에 답합니다. Approval.Requester의 판정은 관리자 서명 리비전을 먼저 넘어야 하고, 아래 제한 사항 절의 이유로 4가 자주 나오므로, 가장 최근 서명이 보통 가장 날카로운 답을 줍니다. GetSignatureEffectiveMDPLevel은 서명들 가운데 발견된 가장 낮은 DocMDP P 값을 보고하며 아무도 문서를 인증하지 않았으면 3을 내고, 두 게터 모두 다음 판정 호출까지 유효합니다

PDFlibPas GetSignatureModificationVerdict가 읽어 들인 변조된 Delphi PDF 양식의 증분 업데이트 타임라인: 리비전 1은 Order.Customer와 Order.Total에 Include 잠금, 리비전 2는 Approval.Manager 서명, 리비전 3의 잠긴 Order.Total 필드 재작성으로 관리자 서명에는 3, 요청자 서명에는 4 판정
잠금은 서명된 모든 필드에서 모아 오므로 Approval.Manager 판정에는 요청자의 잠금이 이미 반영되고, detail 문자열이 위반 필드와 리비전을 짚어 주지만 가장 이른 서명은 4에 머무는 경우가 많습니다

PDFlibPas가 리비전이 바꾼 필드를 알아내는 방법

PDFlibPas는 각 리비전의 객체 그래프에서 필드 이름을 재구성합니다. 변경된 객체가 전체 이름을 갖고 있는 경우는 드물기 때문입니다. /FT나 /V를 가진 필드 딕셔너리, 그리고 위젯 어노테이션은 자기 부분 이름만 /T에 담습니다. ISO 32000-1 §12.7.3.2가 기술하는 전체 정규화 이름은 루트에서부터의 부분 이름 사슬을 마침표로 이은 것이므로, 리비전 맵은 /Parent 링크를 따라 올라가며 발견하는 /T를 앞에 붙입니다. 각 부모는 분류 중인 리비전 시점 또는 그 이전의 가장 최신 정의에서 읽으므로, 부모 노드의 나중 이름 변경이 이전 편집의 역사를 다시 쓰는 일은 없습니다

Delphi용 PDFlibPas의 FieldMDP 전체 정규화 이름 해석: 리비전 맵이 변경된 위젯에서 /Parent 링크를 따라 올라가며 /T 부분 이름을 마침표로 이어 Order.Total을 만들고, /Fields 항목과 완전 일치 또는 마침표 접두어 규칙으로 대조해 Order에 걸린 잠금이 모든 자손을 커버
변경된 객체가 전체 이름을 지니는 경우는 드뭅니다. 부모는 분류 중인 리비전 시점 이전의 최신 정의에서 읽고, 이름 비교는 대소문자를 구분하며, 올라가기는 여덟 단계에서 멈춥니다

해석된 이름과 잠금을 대조하는 규칙은 의도적으로 단순합니다. 이름은 /Fields 항목과 완전히 같거나 항목 뒤에 마침표가 붙는 형태로 시작할 때 해당 항목과 일치하므로, Order를 잠그면 Order.Total과 다른 모든 자손도 함께 잠깁니다. All은 모든 이름을 잠그고, Include는 일치하는 이름을, Exclude는 일치하지 않는 모든 이름을 잠급니다. 잠금 정의는 파일 안의 서명된 모든 서명 필드에서 가져오며, 필드가 /Lock 딕셔너리와 FieldMDP 트랜스폼을 둘 다 가지면 /Lock 값을 씁니다. 각 서명이 정확히 무엇을 잠갔는지 보려면, 판정이 읽는 권한 보고서를 하위 수준의 TPDFlibSignDoc 클래스가 노출합니다. PDFlibDigitalSign 유닛에 들어 있습니다:

uses
  PDFlibDigitalSign;

var
  SignDoc: TPDFlibSignDoc;
  Report: TPDFlibSignaturePermissionReport;
  Names: TStringList;
  I, J: Integer;
  Line: string;
begin
  SignDoc := TPDFlibSignDoc.Create;
  Names := TStringList.Create;
  try
    if not SignDoc.Open('purchase-request-final.pdf', FormPassword, False) then
      Exit;
    try
      SignDoc.GetSignatureFieldNames(Names);
      for I := 0 to Names.Count - 1 do
      begin
        SignDoc.GetSignaturePermissionInfoByName(Names[I], Report);
        if not (Report.HasFieldLock or Report.HasFieldMDP) then
          Continue;
        Line := Names[I] + ': ' + string(Report.FieldLockAction);
        for J := 0 to High(Report.FieldNames) do
          Line := Line + ' ' + string(Report.FieldNames[J]);
        if Report.HasFieldLock and Report.HasFieldMDP and
          not Report.FieldMDPMatches then
          Line := Line + ' (lock and FieldMDP transform disagree)';
        Log.Add(Line);
      end;
    finally
      SignDoc.Close;
    end;
  finally
    Names.Free;
    SignDoc.Free;
  end;
end;

암호화된 양식: 비밀번호를 넘겨야 필드 이름을 읽을 수 있음

암호화된 양식은 필드 이름을 해석하기 전에 복호화되어야 하므로, GetSignatureModificationVerdict에 Password 인자가 필요한 이유는 파일을 여는 것만이 아닙니다. /T 같은 문자열은 객체별로 암호화되어 있고, 압축 객체 스트림에 기록된 필드는 담고 있는 스트림이 복호화되고 풀리기 전까지는 전혀 읽을 수 없습니다. v3.586.0부터 내부 리비전 맵은 비밀번호를 받아서 객체 스트림과 리비전 딕셔너리를 파싱하는 동안 복호화 컨텍스트를 붙이고, 디코딩하는 모든 객체를 복호화합니다. 비교 대상인 이전 버전과 올라가며 읽는 부모들도 포함됩니다

리비전 맵의 객체별 복호화는 Password가 빈 값이 아닐 때만 켜집니다. 따라서 암호화된 파일을 빈 비밀번호로 계산한 판정은 문서 자체가 프롬프트 없이 열렸더라도 검증되지 않은 것으로 취급해야 합니다. 양식 비밀번호는 워크플로 기록 옆에 보관하고 모든 호출에 넘기십시오

FieldMDP 판정이 커버하지 못하는 것들

필드 검사는 좁은 질문 하나에 대한 구조적 답이며, 여러 사례가 그 밖에 있습니다. 이를 미리 알아 두면 4가 지원 에스컬레이션으로 번지는 것을 막을 수 있습니다:

  • 필드 비교는 문서 수준 검사가 깨끗하게 통과하고 최소한 하나의 이후 리비전이 검증 자료나 타임스탬프 이상을 변경했을 때만 실행됩니다. DocMDP 위반은 필드를 보기 전에 3으로 보고됩니다
  • 서명 값 딕셔너리, 다시 쓴 AcroForm 딕셔너리, /AcroForm이나 /Perms에 국한된 카탈로그 변경처럼 서명 관련으로 분류되지만 필드 이름이 없는 객체는 추측 대신 4를 반환하게 만듭니다. 활성 잠금 아래에서 이후 결재 서명을 추가하는 리비전이 4에 자주 머무는 이유입니다
  • 필드 딕셔너리와 위젯 어노테이션만 필드 이름을 받습니다. 리비전 맵에는 appearance 스트림용 전용 클래스가 없으므로, 값과 함께 외형을 재생성하는 에디터는 필드 검사가 실행되기 전에 문서 수준 검사가 콘텐츠 변경으로 막을 수 있고, detail 문자열이 두 사례를 구분해 줍니다
  • 스캔은 처음 만난 잠긴 필드나 이름 없는 항목에서 멈춥니다. 3은 위반 필드 하나를 짚을 뿐 전부를 나열하지 않습니다
  • 잠금은 시간 순서를 정하지 않은 채 서명된 모든 서명 필드에서 모아 오므로, 나중 서명자가 쓴 잠금이 그보다 앞선 업데이트에도 적용됩니다. 결과를 3 쪽으로만 민다는 뜻이며 2 쪽으로는 절대 밀지 않습니다
  • 이름은 정확히, 대소문자를 구분해 비교하며 /Parent 올라가기는 여덟 단계 후 멈춥니다. 비정상적으로 깊은 계층은 잘린 이름으로 해석됩니다
  • 구조가 검증되지 않고 액션을 읽을 수 없는 FieldMDP 항목은 판정을 4로 만듭니다

판정이 4로 돌아오면 GetSignatureRevisionIndex가 서명이 끝나는 리비전을 알려 주고 GetDocumentRevisionChanges가 각 이후 리비전이 추가, 수정, 해제한 모든 객체를 변경 클래스와 함께 나열합니다. 사람이 1분 안에 판단하기에 충분한 정보입니다. 그 리비전들이 추가되는 역학은 PDFlibPas용 증분 업데이트 가이드가 다룹니다

FAQ

판정 2는 서명이 유효하다는 뜻인가요?

아니요. GetSignatureModificationVerdict는 서명 뒤에 추가된 내용을 검사할 뿐 CMS 서명 값, 인증서 체인, 폐기 상태는 전혀 확인하지 않습니다. 2는 이후 업데이트가 권한 안에 머물렀다는 뜻입니다. 암호학적 검증은 별개 단계이며 둘 다 필요합니다

두 번째 결재자의 서명이 2가 아니라 4가 되는 이유는요?

서명은 서명 값 딕셔너리를 추가하고 보통 AcroForm 딕셔너리 같은 폼 부기 정보를 다시 씁니다. 이 객체들은 이름 있는 필드에 속하지 않으므로, 필드 잠금이 활성인 동안 판정은 필드로 귀속할 수 없는 변경을 수용하기를 거부합니다. GetDocumentRevisionChanges로 해당 리비전이 예상된 서명 객체만 담고 있는지 확인하십시오

이름 하나로 필드 그룹 전체를 잠을 수 있나요?

네. 잠금 항목은 지정된 필드와, 전체 정규화 이름이 그 이름 뒤에 마침표를 붙인 형태로 시작하는 모든 자손에 일치합니다. Include 잠금에 Order를 나열하면 Order.Customer, Order.Total과 그 부모 아래의 다른 모든 필드가 얼어 붙습니다

FieldMDP 판정, 필드 잠금 서명, 리비전 검사는 모두 PDFlibPas Delphi PDF Library에 들어 있으며, 위 결재 워크플로가 의존하는 DocMDP 인증과 PAdES 장기 검증 기능도 함께 제공됩니다