기술 문서

RFC 5280이 PDF 서명 경로 검증자에게 요구하는 것

서명된 송장이 바이트 단위로 검증에 통과하지만 서명자는 지난달 폐지됐고, 가져온 CRL은 그 일련번호를 절대 나열하지 않습니다. 그 CRL은 다른 파티션을 대변합니다. HotPDF의 경로 검증은 이런 사례를 중심으로 설계됐습니다. RFC 5280 범위와 앵커 규칙, UTC 평가 시계, 그리고 TSA 체인이 신뢰될 때만 시각이 힘을 얻는 RFC 3161 타임스탬프

평가 시계는 UTC로 돌아갑니다

경로 검증자가 비교하는 모든 시각은 UTC에 저장됩니다. RFC 5280 §4.1.2.5는 인증서 유효 기간 날짜가 그리니치 표준시의 UTCTime이나 GeneralizedTime으로 인코딩되기를 요구하고, CRL의 thisUpdate와 nextUpdate도 같은 인코딩을 쓰며, RFC 6960 OCSP 응답은 producedAt, thisUpdate, nextUpdate를 UTC의 GeneralizedTime으로 나릅니다. Delphi의 TDateTime에는 시간대가 없고 Now는 로컬 벽시계 시각을 반환하므로, if Now > NotAfter로 쓴 검사는 컴파일러 경고 하나 없이 두 시계를 뒤섞습니다. 오류는 지리로 갈라집니다. 그리니치 서쪽에서는 이르게 돌아갑니다. 뉴욕 서버에서 현지 09:00, 세계시가 14:00Z일 때 13:59Z에 생성된 OCSP 응답은 순진한 시계의 미래 다섯 시간에 앉아 HotPDF 기본값 OCSPAllowedClockSkewMinutes 같은 5분 오차 허용치에서 한참 벗어나 아직 유효하지 않음으로 거부됩니다. 상하이에서는 부호가 뒤집힙니다. 현지 22:00, 즉 14:00Z일 때 notAfter가 20:00Z인 서명 인증서는 이미 만료된 것으로 보이며, 여덟 시간 일찍입니다. 두 실패 모두 UTC로 맞춘 빌드 에이전트 — 대부분의 호스팅 CI 러너 기본값 — 에서는 나타나지 않습니다

Delphi의 HotPDF VerifyLoadedSignatureWithTrust: THPDFCertificateValidationOptions.ValidationTime을 UTC로 평가해 로컬 Now가 OCSP producedAt과 인증서 notAfter에 대해 만들어내는 뉴욕과 상하이의 시계 오류를 피합니다
로컬 Delphi Now를 UTC 유효 시각과 비교하면 그리니치 서쪽에서는 새 OCSP 응답이 거부되고 동쪽에서는 인증서가 이르게 만료되는데, 이는 UTC 빌드 에이전트에서는 결코 보이지 않는 두 실패입니다

HotPDF는 THPDFCertificateValidationOptions.ValidationTime을 UTC로 취급합니다. 기본값 0으로 두면 평가 시각은 현재 UTC 시각이 됩니다. 신뢰된 타임스탬프가 서명 시각을 공급하는 경우는 예외입니다(아래에서 다룹니다), 실제로 쓰인 순간은 Info.CertificateValidation.EvaluationTime으로 돌아옵니다. VerifyLoadedSignatureWithTrust는 이 모든 것에 앞서 svValid 뒤의 바이트 범위 검증을 돌린 다음 경로를 빚습니다. 감사인이 분기 마감 시점에 송장 서명이 멀쩡했는지 묻는 식으로 과거 순간을 직접 고정할 때는 변환이 당신의 몫입니다. System.DateUtils의 TTimeZone.Local.ToUniversalTime이 그 일을 합니다. 검증 호출이 아니라 타임스탬프가 시스템에 들어오는 자리에 적용하세요. 서머타임 시간대에서는 매년 가을에 로컬 한 시간이 반복되고 봄에는 또 다른 한 시간이 존재하지 않으므로, 로컬 타임스탬프의 데이터베이스 열은 모든 순간을 왕복시킬 수 없습니다

uses
  System.SysUtils, System.DateUtils, System.IOUtils,
  HPDFDoc, HPDFSignVerify;

var
  Pdf: THotPDF;
  Options: THPDFCertificateValidationOptions;
  Info: THPDFSignatureInfo;
  Status: THPDFCertificateValidationStatus;
  ClosingLocal: TDateTime;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.LoadFromFile('invoices-2026-q1.pdf');
    Options := THPDFCertificateValidationOptions.Default;
    SetLength(Options.TrustAnchors, 1);
    Options.TrustAnchors[0] := TFile.ReadAllBytes('issuing-root.cer');
    Options.RevocationMode := rvmRequire;
    // ERP가 기록한 분기 마감, 서버의 로컬 시간대로
    ClosingLocal := EncodeDateTime(2026, 3, 31, 23, 59, 59, 0);
    // 인증서, CRL, OCSP 응답은 모두 UTC 시각을 나릅니다
    Options.ValidationTime := TTimeZone.Local.ToUniversalTime(ClosingLocal);
    Status := Pdf.VerifyLoadedSignatureWithTrust(0, Options, Info);
    Writeln('Evaluated at (UTC): ',
      DateTimeToStr(Info.CertificateValidation.EvaluationTime));
    case Status of
      cvsTrusted:
        Writeln('Trusted via ',
          string(Info.CertificateValidation.TrustAnchorSubjectDN));
      cvsRevoked:
        Writeln('Revoked at (UTC): ',
          DateTimeToStr(Info.CertificateValidation.RevokedAt));
    else
      Writeln('Not trusted: ', string(Info.CertificateValidation.Issue));
    end;
  finally
    Pdf.Free;
  end;
end;

CRL은 실제로 어느 인증서를 덮을까?

CRL은 자기 범위 안의 인증서에만 대변하고, 범위는 RFC 5280 §5.2.5의 Issuing Distribution Point 확장에서 선언됩니다. 폐지 데이터를 파티션으로 나누는 CA는 여러 CRL을 발행합니다. 모두 같은 키로 서명되고 모두 신선하지만, 그중 하나에 인증서가 없다는 것은 그 CRL이 자기가 덮는다고 주장하지 않는 한 아무 의미도 없습니다. 범위를 좁히는 필드는 이것들입니다:

  • distributionPoint: CRL은 CRL Distribution Points 확장이 이 지점을 이름지는 인증서만 덮습니다
  • onlyContainsUserCerts와 onlyContainsCACerts: end-entity 인증서만, 혹은 CA 인증서만
  • onlySomeReasons: keyCompromise 같은 사유 코드 일부에 대한 폐지
  • onlyContainsAttributeCerts: CRL은 속성 인증서에 관한 것이며 공개키 인증서에 대해서는 아무 말도 하지 않습니다
  • indirectCRL: 항목들이 CRL 서명자가 아닌 발행자에 속할 수 있습니다

순진한 조회는 CRL 서명을 검증하고, thisUpdate와 nextUpdate를 검사하고, 일련번호를 찾아보고, 없으면 인증서를 멀쩡하다고 부릅니다. 마지막 단계는 RFC 5280 §6.3.3 (b)(2)의 범위 검사를 통과한 뒤에만 성립합니다. 파티션 7의 CRL은 파티션 3의 인증서를 결코 나열하지 않습니다. CA 인증서로 한정된 CRL은 end-entity 서명자를 결코 나열하지 않습니다. 둘 다 폐지된 인증서에 "not found"로 답합니다. HotPDF는 CRL이 속성 인증서로도, 특정 사유로도 한정되지 않고, user 또는 CA 제한이 인증서와 맞으며, 이름지는 배포 지점이 인증서 자신의 CRL Distribution Points 확장의 URI 중 하나와 대소문자를 무시하고 일치할 때 — CRL 발행자에 상대적인 이름으로 주어진 지점은 아무것과도 일치하지 않습니다 — 만 인증서에 기본 CRL을 참조합니다. 사유로 한정된 CRL은 아예 참조되지 않으므로 그것이 나열하는 폐지도 셈되지 않습니다. §6.3.3은 사유 파티션에서 전체 커버리지를 조립하는 것을 허용합니다. 모든 사유가 셈될 때까지 사유 마스크를 추적하는 식이지만, 문서 서명에는 OCSP 응답이나 전체 범위 CRL이 더 간단한 증거입니다. 그중 하나를 모으세요. 범위 안 증거가 더 없으면 폐지 상태는 unknown이고 rvmRequire 아래에서 호출은 cvsRevocationUnknown을 반환합니다

Delphi의 PDF 서명 폐지 검사를 위한 HotPDF CRL 범위 테스트: Issuing Distribution Point 필드가 기본 CRL 참조 여부를 정하며, 범위 안 증거가 없으면 RFC 5280 아래 rvmRequire는 cvsRevocationUnknown을 반환합니다
일련번호를 찾기 전에 CRL의 Issuing Distribution Point가 인증서를 덮어야 합니다. 그렇지 않으면 없는 항목은 폐지에 대해 아무것도 증명하지 못합니다

경로를 닫는 트러스트 앵커는 CA여야 합니다

RFC 5280 §6.1.1은 경로 검증 알고리즘에 트러스트 앵커를 단순한 정보로, 발행자 이름과 공개키로 넘깁니다. 알고리즘은 앵커 자신의 인증서가 무언가를 발행해도 되는지 결코 묻지 않으며, §6.1.4의 basicConstraints 검사는 경로의 중간 인증서들에 돌아갑니다. 앵커가 엄선된 저장소의 루트 CA일 때는 문제없습니다. 문서 워크플로는 이 가정을 깹니다. 사람들이 end-entity 인증서를 고정하기 때문입니다. 세 곳의 알려진 공급업체에서 계약서를 받는 조달 시스템은 흔히 공급업체 각자의 서명 인증서를 곧장 신뢰합니다. 고정은 한 인증서짜리 경로, 서명자와 앵커가 같은 인증서인 경우에는 잘 작동합니다. 위험한 것은 두 인증서짜리 경로입니다. 공급업체의 개인키는 어떤 제목이든 지닌 새 인증서에 서명할 수 있습니다. 가령 당신 CFO의 이름입니다. 모든 앵커 키를 발행 키로 취급하는 검증자는 그 위조물에서 고정된 리프까지 말끔한 경로를 빚어 냅니다

HotPDF는 경로가 한 인증서보다 길 때마다 앵커에 RFC 5280 발행자 요구를 적용합니다. 그러면 앵커는 cA=TRUE를 지닌 basicConstraints(§4.2.1.9)가 필요하고, keyUsage 확장을 지닌다면 keyCertSign 비트가 켜져 있어야 합니다(§4.2.1.3). basicConstraints 확장이 없는 루트는 self-issued일 때, 즉 제목과 발행자 이름이 같을 때만 발행자로 셈되며, 확장보다 앞선 옛 버전 1 루트들이 계속 작동합니다. 고정도 계속 작동합니다. 고정된 end-entity 인증서는 자기 서명을 여전히 검증하고, 그것이 서명한 인증서는 경로를 발행한 앵커가 인증 기관이 아니라는 Issue 문자열과 함께 cvsPathValidationFailed로 돌아옵니다

Delphi의 HotPDF RFC 5280 인증서 경로 검증: TrustAnchors에 고정된 end-entity 인증서는 자기 서명을 검증하지만, basicConstraints cA=TRUE와 keyCertSign이 없으면 그것이 발행한 경로는 cvsPathValidationFailed로 실패합니다
공급업체의 서명 인증서를 고정하는 것은 그 공급업체의 서명에 여전히 작동하는 반면, 그 키가 서명한 어떤 인증서든 앵커가 인증 기관이 아니라는 이유로 거부됩니다

어느 타임스탬프가 평가 시각을 정할 수 있을까?

신뢰된 것들만입니다. CMS에 박힌 RFC 3161 토큰은 서명 값이 genTime에 존재했다고 주장하며, 검증자는 그 순간의 서명자 인증서를 판정합니다 — 서명이 자기 인증서보다 오래 사는 방법이 이것입니다. 토큰은 또한 그냥 또 하나의 CMS 서명입니다. 그것을 그것이 지닌 인증서로 검증하는 것은 내부 일관성만 증명할 뿐 누가 만들었는지는 아무것도 증명하지 않습니다. 만료되었거나 폐지된 서명 키를 쥔 누구든 자기만의 타임스탬프 서비스를 돌리고, 작년의 genTime으로 새 서명에 찍어 인증서 유효 기간 안으로 되걸어 들어갈 수 있습니다. RFC 3161 §2.3은 TSA 인증서가 id-kp-timeStamping 확장 키 용도를 유일한 목적으로, critical로 표시해 지니기를 요구합니다. HotPDF는 토큰 인증서가 당신의 앵커에 대해, 현재 UTC 시각으로, 타임스탬프 목적으로 검증을 통과한 뒤에야 Info.Timestamp.GenerationTime을 평가 시각으로 채택합니다. /SubFilter ETSI.RFC3161의 문서 타임스탬프는 다른 사례입니다. 거기서는 토큰이 검증 대상 서명 자신이며 그 genTime은 자기 경로에 적용됩니다

토큰이 당신의 옵션으로 검사되므로 TSA 루트는 서명자 루트와 같은 TrustAnchors 목록에 앉아야 하고, rvmRequire 아래에서 TSA 체인은 자기만의 폐지 증거가 필요합니다. 하나라도 빠지면 평가는 현재 시각으로 폴백합니다. TSA 인증서의 수명이 또 하나의 한계입니다. 토큰은 현재 시각에 판정되므로 그 인증서가 만료하는 순간 서명 시각 평가도 함께 가고, 아카이브는 그 지점 전에 문서 타임스탬프를 추가합니다. PAdES LTV 증거와 B-LTA 타임스탬프 갱신이 다루는 단계입니다. 원본 CMS 바이트를 쥔 코드는 HPDFCMSTimestampTimeIsTrusted(CMSDER, Options)로 같은 질문을 할 수 있고, ValidateLoadedSignatureBatch는 배치의 모든 서명에 적용합니다. 아래 함수는 어느 시계 위에서 판정이 내려졌는지 보고합니다

// 예상: SignerRoot만 있으면 2025에 만료된 인증서의 서명은 현재 UTC
// 시각으로 평가되어 타임스탬프 토큰이 검증되더라도 cvsTrusted로
// 돌아오지 않습니다. TsaRoot를 더하면 같은 호출이 genTime으로
// 평가됩니다
function DescribeVerdictClock(Pdf: THotPDF; SigIndex: Integer;
  const SignerRoot, TsaRoot: TBytes): string;
var
  Options: THPDFCertificateValidationOptions;
  Info: THPDFSignatureInfo;
  Status: THPDFCertificateValidationStatus;
  Clock: string;
begin
  Options := THPDFCertificateValidationOptions.Default;
  // TSA 체인은 서명자와 같은 앵커 목록으로 판정됩니다
  SetLength(Options.TrustAnchors, 2);
  Options.TrustAnchors[0] := SignerRoot;
  Options.TrustAnchors[1] := TsaRoot;
  Options.RevocationMode := rvmBestEffort;
  Options.ValidationTime := 0; // 신뢰된 타임스탬프가 시각을 공급할 수 있음
  Status := Pdf.VerifyLoadedSignatureWithTrust(SigIndex, Options, Info);
  if Info.Timestamp.Kind <> tskSignature then
    Clock := 'no signature timestamp, current UTC time'
  else if (Info.Timestamp.Status = svValid) and
    (Info.CertificateValidation.EvaluationTime =
     Info.Timestamp.GenerationTime) then
    Clock := 'trusted timestamp genTime'
  else
    Clock := 'timestamp not trusted, current UTC time';
  Result := Format('%s at %s (UTC), status %d', [Clock,
    DateTimeToStr(Info.CertificateValidation.EvaluationTime), Ord(Status)]);
end;

첫 신뢰 결정 전의 적대적 입력

일부 검증 작업은 아무것도 신뢰되기 전에, 공격자가 완전히 통제하는 데이터 위에서 돌아갑니다. 빠진 중간 인증서는 RFC 5280 §4.2.2.1의 Authority Information Access caIssuers URL로 가져오고, 가져온 각 중간 인증서는 자기 발행자의 URL을 또 광고할 수 있으므로 작업 목록은 걸어 가는 동안 불어납니다. Delphi에서 이는 for 루프를 규격 밖으로 만듭니다. for 문의 최종값은 루프가 시작하기 전에 한 번 평가됩니다 — 걷는 동안 덧붙은 URL은 결코 방문되지 않고, 두 단계가 빠진 체인은 미완성으로 남습니다. 대신 while 루프로 매 순회마다 Count를 다시 읽으세요. HotPDF에서 HPDFFetchAIAIntermediates의 MaxFetch는 한 번의 걷기가 내려받는 인증서 수를 제한하고, FetchMissingIntermediates가 경로 검증이 스스로 가져오게 할 때는 MaxFetchedIntermediates가 같은 상한을 정합니다

Retry-After 힌트도 낯선 이가 고르는 값입니다. 그 서버는 검증되지 않은 인증서가 이름지었고 모든 요청에 한 시간의 멈춤을 요구할 수 있습니다. 상한을 씌우세요. THPDFSignatureEvidenceRetryPolicy.MaxRetryAfterMS가 HotPDF의 증거와 AIA 가져오기에 그 천장을 정하며 기본값은 30,000 ms입니다. 아래 루틴은 오프라인 서명 계층의 중간 인증서 캐시를 미워밍하는 식으로 AIA를 직접 기어 다니는 팀을 위해 두 규칙을 함께 적용합니다

uses
  System.SysUtils, System.Generics.Collections;

type
  // 당신의 전송 계층: caIssuers 대상 하나를 내려받고 받은 인증서가
  // 광고한 caIssuers URL들을 반환합니다
  TFetchIssuer = reference to function(const URL: string;
    out NextURLs: TArray<string>; out RetryAfterMS: Cardinal): Boolean;

function WalkCAIssuers(const SeedURLs: TArray<string>;
  const Fetch: TFetchIssuer; MaxFetch: Integer;
  MaxRetryAfterMS: Cardinal): Integer;
var
  Queue: TList<string>;
  Seen: TDictionary<string, Boolean>;
  NextURLs: TArray<string>;
  URL: string;
  I, Attempt: Integer;
  RetryAfter: Cardinal;
  Ok: Boolean;

  procedure Enqueue(const AURL: string);
  begin
    if not Seen.ContainsKey(LowerCase(AURL)) then
    begin
      Seen.Add(LowerCase(AURL), True);
      Queue.Add(AURL);
    end;
  end;

begin
  Result := 0;
  Queue := TList<string>.Create;
  Seen := TDictionary<string, Boolean>.Create;
  try
    for URL in SeedURLs do
      Enqueue(URL);
    I := 0;
    // for 루프는 들어갈 때 Queue.Count를 못 박아 아래에서 내려받은
    // 인증서가 추가하는 URL에는 결코 도달하지 못합니다
    while (I < Queue.Count) and (Result < MaxFetch) do
    begin
      Ok := False;
      for Attempt := 1 to 3 do
      begin
        NextURLs := nil;
        RetryAfter := 0;
        Ok := Fetch(Queue[I], NextURLs, RetryAfter);
        if Ok or (Attempt = 3) then
          Break;
        // 지연은 서버의 소원이며, 서버를 고른 것은 검증되지 않은
        // 인증서입니다
        if RetryAfter > MaxRetryAfterMS then
          RetryAfter := MaxRetryAfterMS;
        Sleep(RetryAfter);
      end;
      Inc(I);
      Inc(Result);
      if Ok then
        for URL in NextURLs do
          Enqueue(URL);
    end;
  finally
    Seen.Free;
    Queue.Free;
  end;
end;

CMS 서명 검사 자체는 경로 검증 전에 돌아갑니다. 검증되지 않는 서명을 위해 경로를 빚는 것은 헛수고이고, 검사는 서명에 박힌 인증서의 공개키를 씁니다. 이 순서는 가장 비싼 산술이 도는 순간에 공격자가 고른 키 크기를 주인 자리에 앉힙니다. 아직 신뢰된 것은 하나도 없습니다. RSA 검증 비용은 모듈러스 길이와 지수 길이 둘 다에 비례하므로, 엄청난 모듈러스와 그만큼 긴 지수를 지닌 인증서는 업로드된 파일을 검증하는 서버에서 스레드 하나를 모듈러 거듭제곱으로 붙들어 둘 수 있습니다. HotPDF는 16,384비트를 넘는 RSA 모듈러스를 거부하고, 모듈러스보다 긴 지수를 거부하며, 3,072비트를 넘으면 최대 64비트 지수만 받습니다. 실제 서명 키는 e = 65537, 17비트 지수로 2,048에서 4,096비트 사이에 앉으므로 천장이 그들을 건드릴 일은 없습니다. 바닥은 별개의 설정 가능한 정책인 AlgorithmPolicy.MinimumRSAKeyBits이며 기본값은 2,048입니다

같은 서명이 타임스탬프의 genTime에서는 cvsTrusted이고 오늘 날짜에서는 실패할 수 있습니다. HotPDF Delphi PDF 컴포넌트가 반환하는 모든 판정 옆에 Info.CertificateValidation.EvaluationTime과 TrustAnchorSubjectDN을 기록하세요. 나중에 기록을 읽는 감사인이 답이 어느 시계와 어느 앵커 위에 서는지 볼 수 있습니다