PDFium VCL은 CertGetCertificateChain의 폐기 패스에 CERT_CHAIN_REVOCATION_CHECK_CACHE_ONLY를 더해 Windows에서 PDF 서명 폐기를 오프라인으로 검사합니다. 체인 빌드에 쓰던 cache-only 플래그는 CRL이나 OCSP 검색을 전혀 커버하지 않기 때문입니다. v3.119.1부터 오프라인 ValidatePadesTrust 호출은 네트워크를 건드리지 않고, 깨끗한 결과에는 인증서별 폐기 증거가 실제로 필요합니다. 이 글의 나머지는 그 문장의 두 절반이 왜 고쳐져야 했는지에 관한 이야기입니다
문제를 드러내는 구성은 평범합니다. 검증 서비스가 잠긴 Windows 호스트에서 돌고, TPadesTrustValidationOptions.NetworkPolicy는 ptnpOffline(기본값이기도 합니다)이며, 운영자는 모든 답이 로컬 인증서 캐시에서 나오리라 기대합니다. 그런데 누군가 방화벽 로그에서 CA 배포 지점으로 나가는 요청을 발견하거나, 모든 서명마다 UrlRetrievalTimeoutMs의 15000ms를 꽉 채워 멈추는 배치 작업을 알아차립니다. 코드 어디에도 네트워크를 요구한 곳은 없습니다. Windows가 알아서 갔을 뿐입니다
오프라인 체인 빌드는 Windows에서 왜 여전히 CRL을 가져올까요?
CERT_CHAIN_CACHE_ONLY_URL_RETRIEVAL은 체인 빌드가 하는 URL 검색, 즉 AIA 발급자 가져오기와 루트 및 CTL 업데이트만 제한하기 때문입니다. CertGetCertificateChain의 Microsoft 문서는 그 플래그가 폐기 검사에는 적용되지 않는다고 명시합니다. 폐기에는 자체 스위치 CERT_CHAIN_REVOCATION_CHECK_CACHE_ONLY($80000000)가 있고, 그것이 없으면 둘러싼 호출이 오프라인처럼 보여도 폐기 공급자는 CRL을 내려받거나 OCSP 요청을 보내도 됩니다. PDFium VCL은 이제 OnlineRetrieval이 False일 때마다 그 플래그를 체인 플래그 위에, CERT_CHAIN_REVOCATION_CHECK_CHAIN_EXCLUDE_ROOT와 CERT_CHAIN_REVOCATION_ACCUMULATIVE_TIMEOUT에 얹어 폐기 패스에 OR 합니다. 지연 시간 이상으로 중요한 대목입니다. OCSP 요청은 응답자에게 여러분이 어떤 인증서를 보고 있는지 알려 주는데, air-gapped 검증기가 피해야 할 것이 정확히 그것입니다
uses
PDFium, FPdfCrypto, FPdfPades;
var
Options: TPadesTrustValidationOptions;
Report: TPadesValidationResult;
begin
Options := TPadesTrustValidationOptions.Default; // ptnpOffline, 15000 ms
Options.CheckRevocation := True; // 기본값은 False
Options.CheckTimeStamps := True;
// 이제 오프라인은 폐기에도 오프라인을 뜻함: 캐시된 CRL과 OCSP
// 응답만 사용, pcvstOnlineRetrieval 체크포인트는 올라오지 않음
Report := Pdf.ValidatePadesTrust(Options);
end;
두 번의 체인 빌드, 두 개의 오류 필드
Windows 백엔드는 체인을 두 번 빌드하고, 이제 각 빌드는 자기 오류 슬롯을 소유합니다. 첫 CertGetCertificateChain 호출은 폐기 플래그 없이 돌고 base 정책으로 CertVerifyCertificateChainPolicy에 먹여져 TrustStatus와 TrustError를 냅니다. 두 번째 호출은 폐기 플래그를 더합니다. v3.119.1 이전에는 두 번째 호출이 실패하면 GetLastError가 TrustError에 적혀서, 방금 신뢰됨으로 검증된 체인이 폐기 공급자가 한번 사레들렸다는 이유만으로 신뢰할 수 없어 보이며 돌아올 수 있었습니다. 수정은 GetLastError를 즉시 읽어 TPdfCmsVerifyResult.RevocationError에 저장하고 첫 패스 판정은 그대로 둡니다. 그리고 두 번째 호출의 True 반환도 성공으로 취급되지 않습니다. Windows가 들여다볼 가치가 있는 체인 컨텍스트를 돌려줬다는 뜻일 뿐입니다
영 trust error 마스크는 실제로 무엇을 증명할까요?
그것만으로는 아무것도 증명하지 못합니다. 폐기 패스 뒤 집계된 TrustStatus.dwErrorStatus가 0이라는 것은 오류 비트가 하나도 안 올랐다는 말이고, 폐기 정보를 지닌 요소가 전혀 없는 체인도 정확히 그런 결과를 낼 수 있습니다. 이전 코드는 "폐기 비트 없음, unknown 비트 없음, 오프라인 비트 없음"을 곧바로 valid로 매핑했는데, 검증기가 검사 안 한 인증서를 깨끗한 것으로 보고하는 고전적 방식입니다. 새 ReadWinRevocationEvidence 루틴은 모든 simple chain과 모든 요소를 훑고, cbSize가 안전하게 읽기엔 너무 작은 구조는 거부하며, 최소한 하나의 비 루트 요소가 존재하고 그런 모든 요소가 dwRevocationResult가 0인 CERT_REVOCATION_INFO를 지닐 때에만 성공을 보고합니다
// 증거 순회에서 축약: 폐기 공급자가 실제로 응답했을 때만
// 요소가 셈에 들어간다
for J := 0 to ElementCount - 1 do
begin
Element := Elements[J];
ExcludedRoot := (J = ElementCount - 1) and
((Element^.TrustStatus.dwInfoStatus and
(CERT_TRUST_IS_SELF_SIGNED or CERT_TRUST_IS_CA_TRUSTED)) <> 0);
InfoPresent := (Element^.pRevocationInfo <> nil) and
(Element^.pRevocationInfo^.cbSize >= SizeOf(TCERT_REVOCATION_INFO));
if not ExcludedRoot then
begin
Inc(RequiredCount);
if not InfoPresent or
(Element^.pRevocationInfo^.dwRevocationResult <> 0) then
Complete := False;
end;
end;
Complete := Complete and (RequiredCount > 0); // 루트만 있는 체인은 아무것도 증명하지 못한다
공급자 결과는 날 것으로 유지됩니다. RevocationError는 공급자가 돌려준 그대로의 dwRevocationResult DWORD를 담고, 폐기된 요소가 있으면 그 오류를 우선합니다(CRYPT_E_REVOKED는 $80092010). trust 비트마스크가 네이티브 오류 코드로 꾸며지는 일은 없습니다. TPdfCmsRevocationReason으로의 매핑은 의도적으로 거칩니다. 명시적 폐기에는 pcvsInvalid를 곁들인 pcrrCertificateRevoked, 폐기와 무관한 이유로 체인이 실패하면 pcrrChainUntrusted, 나머지는 전부 pcrrUnknown입니다. Windows는 CRL 대신 OCSP를 시도했을 수도 있으므로, 오프라인이나 unknown 결과는 pcrrCrlExpired로 번역되지 않습니다. OpenSSL CMS 검증 백엔드는 여러분이 넘긴 CRL만 평가하므로 그런 CRL별 구분을 할 수 있고, macOS SecTrust 백엔드는 필드를 pcrrNone과 0으로 두는데, 이는 "상세 진단 없음"이지 "통과"가 아닙니다
루트 제외가 멈추는 지점
CERT_CHAIN_REVOCATION_CHECK_CHAIN_EXCLUDE_ROOT은 앵커를 건너뛰는 게 정당합니다. 자기 자신을 폐기하는 루트용 CRL을 발행하는 곳은 없으니까요. 함정은 어느 요소가 루트인지 판단하는 일에 있습니다. PDFium VCL은 simple chain의 마지막 요소를 dwInfoStatus가 self-signed($00000008)나 명시적 CA-trusted($00004000)로 표시할 때에만 제외합니다. 오프라인 호스트는 빠진 발급자를 가져올 수 없는 경우가 많아 체인이 중간 CA에서 끝나는데, 그 부분 체인의 마지막 요소를 루트로 취급하면 캐시에 폐기 상태가 없을 가능성이 가장 높은 바로 그 인증서를 조용히 떨어뜨리게 됩니다. 그 요소는 요구 집합에 남고 공급자 응답이 없으며 결과는 pcvsIndeterminate로 유지됩니다
서명과 타임스탬프 폐기 결과는 어떻게 분리될까요?
서로를 절대 덮어쓰지 않는 별개 필드입니다. PAdES 검증기는 문서 서명의 분리형 CMS와 RFC 3161 타임스탬프 토큰의 첨부형 CMS를 두 번의 독립 호출로 검증하고, v3.119.0이 TPadesSignatureValidation에 각자 진단을 부여했습니다. 서명자에게는 RevocationReason과 NativeRevocationError, TSA에게는 TimeStampRevocationReason과 NativeTimeStampRevocationError입니다. 폐기된 TSA 인증서가 폐기된 서명자로 둔갑할 수 없고, 타임스탬프 실패가 이미 확립된 무결성 결과를 지우지도 않습니다. CheckRevocation이 False이거나 검증이 그 단계에 도달하지 못했으면 필드는 pcrrNone과 0으로 남으므로, 항상 RevocationStatus와 TimeStampRevocationStatus 옆에서 읽으세요
for I := 0 to High(Report.Signatures) do
begin
S := Report.Signatures[I];
case S.RevocationStatus of
pcsInvalid:
Log(Format('sig %d: signer revoked, provider 0x%.8x',
[I, S.NativeRevocationError]));
pcsIndeterminate:
Log(Format('sig %d: revocation unknown, reason %d, provider 0x%.8x',
[I, Ord(S.RevocationReason), S.NativeRevocationError]));
pcsNotChecked:
Log(Format('sig %d: revocation not checked', [I]));
end;
if S.TimeStampRevocationStatus = pcsIndeterminate then
Log(Format('sig %d: TSA revocation unknown, provider 0x%.8x',
[I, S.NativeTimeStampRevocationError]));
end;
증거 보고서도 같은 규칙을 따릅니다. CSV 내보내기는 기존 컬럼 순서 끝에 revocationReason, nativeRevocationError, 그리고 nativeTimeStampRevocationError까지 타임스탬프 컬럼을 덧붙이므로 오래된 파서도 계속 동작하고, JSON 내보내기는 오래된 필드의 의미를 바꾸지 않은 채 대응 필드를 더합니다. 오프라인 검증이 계속 indeterminate로 돌아온다면 근본적인 해결은 상류에 있습니다. 검증기 기계에 따뜻한 캐시가 있기를 바라는 대신, RFC 3161 타임스탬프와 DSS를 곁들인 장기 PDF 서명에서 기술하듯 서명 시점에 검증 재료를 모으세요
테스트 매트릭스가 증명하는 것과 증명하지 않는 것
Windows 검증 매트릭스는 각 Delphi와 FPC, Win32와 Win64 대상에서 통제된 체인 API 시나리오 30개와 실제 오프라인 CMS 스모크 하나를 통과했습니다. 실제 스모크는 신뢰할 수 없는 사설 CA 아래의 유효한 서명을 검증하고, 깨끗한 결과와 명시적 폐기 결과는 설치된 신뢰 앵커나 실제 검색이 아니라 스텁된 CertGetCertificateChain 응답에서 나옵니다. 말해 둘 가치가 있는 정직한 경계입니다. 플래그 처리와 오류 격리, 증거 순회는 못박혔지만, 특정 기계의 폐기 캐시가 어느 날 무엇을 담고 있을지는 여전히 Windows의 영역이며, 빈 캐시는 이제 네트워크 요청이나 거짓 "valid" 대신 올바르게 "unknown"을 냅니다
오프라인 폐기 처리와 필드별 진단, 증거 내보내기는 Delphi 및 C++Builder용 PDFium VCL의 PDF 서명 검증 API 일부이며, 크로스 플랫폼 배포를 위한 OpenSSL과 macOS 백엔드가 함께 있습니다