PDFium VCL은 이제 OpenSSL 백엔드에서 PDF 서명 체인을 완성하고 네트워크로 폐기 상태를 검사할 수 있습니다. OnlineRetrieval을 켜면 ConfigureSslCmsVerifier가 AIA caIssuers URL에서 빠진 중간 인증서를, CRL 배포 지점에서 CRL을 내려받는 검증기를 설치합니다. 검증 호출당 고정된 시간, 요청, 바이트 예산 안에서요. 내려받은 인증서는 어디까지나 체인 재료입니다. 신뢰는 여전히 시스템 스토어와 직접 구성한 앵커에서만 나옵니다
이 수정이 메우는 간극은 Linux 서버에서 실제 PDF를 검증하는 순간 나타납니다. 상당수 서명자는 CMS에 자기 leaf 인증서만 넣으므로 OpenSSL은 루트에 닿지 못하고, TrustStatus는 invalid로 돌아오며, 체인이 신뢰할 수 있게 된 적이 없으니 폐기 검사는 아예 돌지 않습니다. v3.121.0 이전 PDFium VCL에서 OpenSSL로 PDF 서명 검증하기의 OpenSSL 백엔드는 철저히 오프라인이었고 OnlineRetrieval은 아무 효과도 없었습니다. 미리 짚어 둘 것이 하나 있습니다. PDFium 엔진 자신은 CMS 검증을 전혀 하지 않으므로, 아래의 모든 규칙은 컴포넌트의 PAdES 계층과 OpenSSL 바인딩에, 즉 읽을 수 있는 곳에 삽니다
OpenSSL 백엔드는 어떤 순서로 검증하고, 가져오고, 검사할까요?
무결성 먼저, 그다음 신뢰, 그다음 폐기이고, 네트워크는 필요한 단계 사이에서만 닿습니다. VerifyCmsWithSsl은 체인 평가를 끈 채 CMS 서명과 signed attributes(RFC 5652)를 검사하고, 실패하면 fetch 세션이 존재하기도 전에 즉시 반환하므로 바이트가 깨진 문서는 발신 요청을 일으키지 않습니다. 체인이 그다음 실패하고 OnlineRetrieval이 켜져 있을 때만 AIA 링크를 따라가 다시 검증합니다. CRL 배포 지점은 체인이 신뢰된 다음에야 가져옵니다. 신뢰할 수 없는 경로에 매달린 CRL은 아무것도 증명하지 않기 때문입니다. 세 판정은 끝까지 분리되어 있습니다. 불완전한 체인과 함께라도 유효한 서명은 여전히 유효한 서명으로 보고됩니다
uses
PDFium, FPdfCrypto, FPdfCryptoSsl, FPdfPades;
var
Pdf: TPdf;
Probe: TPdfCmsVerifyOptions;
Diags: TPdfSslVerifyDiagnostics;
Trust: TPadesTrustValidationOptions;
Verdict: TPadesValidationResult;
I: Integer;
begin
ConfigureSslTrustAnchors(LoadCorporateRoots); // DER. 유일하게 추가되는 신뢰
ConfigureSslCmsVerifier;
Probe := TPdfCmsVerifyOptions.Default;
Probe.OnlineRetrieval := True;
Probe.CheckRevocation := True;
Diags := SslVerifyOptionsDiagnostics(Probe);
if psvdOnlineRetrievalIgnored in Diags then
Log('no HTTP transport or CMS_add1_cert: validation stays offline');
Trust := TPadesTrustValidationOptions.Default; // 기본값은 ptnpOffline
Trust.NetworkPolicy := ptnpOnline;
Trust.CheckRevocation := True;
Trust.UrlRetrievalTimeoutMs := 10000; // 검증 호출당
Pdf := TPdf.Create(nil);
try
Pdf.FileName := 'signed-contract.pdf';
Pdf.Active := True;
Verdict := Pdf.ValidatePadesTrust(Trust);
for I := 0 to High(Verdict.Signatures) do
Log(Format('#%d trust=%d revocation=%d', [I,
Ord(Verdict.Signatures[I].CertificateTrustStatus),
Ord(Verdict.Signatures[I].RevocationStatus)]));
finally
Pdf.Free;
end;
end;
내려받은 인증서를 트러스트 스토어에 절대 넣지 않는 이유는?
URL이 검증 대상 인증서에서 나오고, 그걸 골라 넣은 건 서명자이기 때문입니다. authorityInfoAccess caIssuers 항목(RFC 5280 §4.2.2.1)은 발행자가 사는 곳에 관한 힌트일 뿐입니다. 그 URL에 응답하는 무엇이든 trust-anchor 스토어에 들어간다면, 누구나 자작 키로 서명하고 AIA를 자기 서버로 가리킨 뒤 초록불 판정을 받을 수 있습니다. 그래서 RetrieveIntermediates는 파싱된 각 인증서를 CMS_add1_cert에 넘겨, 이 CMS 구조체 하나의 untrusted 집합에 놓고, OpenSSL은 여전히 그것에서 직접 구성한 앵커나 시스템 스토어가 이미 보유한 앵커로 가는 경로를 만들어야 합니다. 더 조용한 이유도 있습니다. CMS_verify의 인증서 인수는 CMS에 내장된 인증서의 드롭인 대체재가 아니므로, CMS 자체에 추가하는 것이 확실한 경로입니다
조회 루프는 일부러 좁게 유지됩니다. RetrieveIntermediates는 최대 4라운드를 돌며 각 라운드마다 이제 CMS에 있는 모든 인증서에서 caIssuers URL을 모으고, 어떤 라운드가 아무것도 추가하지 않거나 시간 예산이 바닥나면 곧 멈춥니다. 응답은 몸체 전체를 소비하는 단일 DER 인증서로 d2i_X509로 디코딩되어야 합니다. 꼬리 바이트는 거부되고, .p7c URL에서 서빙되는 PKCS#7 certs-only 번들은 풀어 보지 않고 건너뜁니다. 같은 AIA 확장의 OCSP 접근 방식은 이 백엔드가 OCSP를 말하지 않으므로 무시됩니다. 폐기 쪽에서는 RetrieveCrls가 CMS 인증서와 구성된 앵커에서 각 DistributionPoint(RFC 5280 §4.2.1.13)의 fullName URI만 읽고, 내려받은 CRL은 전체 체인 CRL 검사를 갖춘 두 번째 독립적인 X509_STORE로 들어갑니다. 빠지거나 오래된 CRL은 TrustStatus를 건드리지 않은 채 RevocationStatus만 바꿉니다
// VerifyCmsWithSsl(FPdfCryptoSsl.pas)에서 축약. BIO 셋업은 생략.
// _CMS_verify 호출마다 새 content BIO를 받습니다
if _CMS_verify(Cms, nil, nil, Bio, nil,
CMS_NO_SIGNER_CERT_VERIFY or CMS_BINARY) <> 1 then
Exit; // 깨진 서명: 네트워크는 아예 없음
if Options.OnlineRetrieval and SslCapabilities.OnlineRetrieval then
FetchSession := TPdfCryptoFetchSession.Create(Options.UrlRetrievalTimeoutMs);
Store := BuildStore(False, nil, RevocationChecked, CrlsMalformed);
if (_CMS_verify(Cms, nil, Store, Bio, nil, CMS_BINARY) <> 1) and
(FetchSession <> nil) then
begin
RetrieveIntermediates(Cms, FetchSession); // CMS_add1_cert, untrusted뿐
// 같은 앵커 스토어로 체인을 다시 검증
end;
if Options.CheckRevocation and (FetchSession <> nil) and
(Result.TrustStatus = pcvsValid) then
RetrievedCrls := RetrieveCrls(Cms, FetchSession);
// 두 번째 별도 스토어: 구성된 CRL에 내려받은 CRL을 더함
Store := BuildStore(True, RetrievedCrls, RevocationChecked, CrlsMalformed);
검증 호출 한 번은 최대 얼마를 쓸까요?
고정된 상한이고, 단일 검증 호출의 AIA와 CRL 단계가 공유하는 하나의 TPdfCryptoFetchSession이 강제합니다. 한계는 FPdfCryptoHttp의 상수이지 제안이 아닙니다:
- 시간:
UrlRetrievalTimeoutMs.TPdfCmsVerifyOptions.Default와TPadesTrustValidationOptions.Default양쪽에서 기본값은 15000이고, 0으로 만든 세션은 30000으로 폴백하며, 시계는 서명이 통과한 순간 켜져 이후의 모든 요청을 커버합니다 - 요청: 세션당 최대 8개로, 전송을 시도하기 전에 세므로 죽은 호스트도 슬롯을 하나 소모합니다
- 바이트: 응답당 1 MiB, 총 4 MiB이며, 2048자를 넘는 URL은 연결 전에 거부됩니다
장부는 겉보기보다 엄격합니다. 실패한 응답에서 받은 바이트도 총액에 들어가므로 큰 페이지로 404를 답하는 서버가 공짜로 예산을 태울 수 없습니다. 응답당 한계를 넘는 읽기는 잘린 몸체를 ASN.1 파서에 넘기는 대신 다운로드를 중단하고, 빈 몸체의 HTTP 200은 그대로 거부됩니다. 그렇지 않으면 AIA 경로가 빈 배열의 Data[0]을 인덱스하게 되기 때문입니다. 평범한 http://와 https:// URL만 통과하며, 리다이렉트, 쿠키, 자격 증명, 자동 프록시 탐색은 없고, HTTPS는 평범한 인증서와 호스트명 검사를 유지합니다. URL 중복 제거는 일부러 한 호출에만 적용됩니다. 다음 검증이 새로 발행된 CRL을 볼 수 있어야 하기 때문입니다. 예산은 문서가 아니라 호출당이고, ValidatePadesTrust는 각 서명과 각 타임스탬프 토큰을 따로 검증하므로 최악의 경우는 서명 수에 따라 커집니다
시간 초과된 WinHTTP 요청이 여전히 당신의 메모리에 쓸 수 있는 이유는?
타임아웃으로 반환하는 것은 이미 날아가 있는 콜백을 취소하지 않기 때문입니다. Windows 전송은 WinHTTP를 비동기로 몰고 세션의 남은 시간으로 이벤트를 기다리는데, 그 기다림이 포기해도 요청은 이후에 읽기를 완료하고 신호할 수 있습니다. 비동기 읽기를 스택 버퍼로 향하게 하면 그 늦은 완료가 그 시점에는 무관한 함수의 소유인 프레임에 씁니다. 해법은 타이밍이 아니라 소유권입니다. 이벤트와 16 KB 읽기 버퍼는 참조 두 개를 가진 힙 레코드에 삽니다. 하나는 호출자가 쥐고 하나는 최종 HANDLE_CLOSING 콜백만 해제하므로, 마지막으로 끝나는 쪽이 메모리를 해제합니다
type
PHttpState = ^THttpState;
THttpState = record
References: LongInt; // 호출자 + 최종 HANDLE_CLOSING 콜백
Event: THandle;
Status, Count: DWORD;
Buffer: array[0..16383] of Byte; // 비동기 읽기는 여기에 닿습니다. 스택에는 절대 없음
end;
procedure ReleaseState(State: PHttpState);
begin
if InterlockedDecrement(State.References) = 0 then
begin
CloseHandle(State.Event);
Dispose(State);
end;
end;
// 상태 콜백 안에서: HANDLE_CLOSING은 WinHTTP가 요청에 대해 보내는
// 마지막 알림이므로 두 번째 참조를 내려놓습니다
if Status = HttpHandleClosing then
begin
ReleaseState(State);
Exit;
end;
FPC Unix에서 libcurl이 갖춰야 할 것
비동기 리졸버와 스레드 안전 빌드입니다. 아니면 온라인 조회는 꺼진 채로 남습니다. FPC Unix에서 전송은 libcurl, 즉 비 Windows 타깃용 libcurl 타임스탬프 백엔드의 배후에 있는 같은 의존성을 거칩니다. 그리고 바인딩은 feature 마스크에 CURL_VERSION_ASYNCHDNS나 CURL_VERSION_THREADSAFE 중 하나라도 없는 라이브러리를 거부합니다. 이유는 이렇습니다. 남의 프로세스 안에 사는 라이브러리가 set해야 하는 CURLOPT_NOSIGNAL과 동기 리졸버가 결합하면 DNS 조회가 타임아웃을 단순히 초월해 버릴 수 있습니다. 두 번째 함정은 종료입니다. curl_global_cleanup은 비동기 DNS 스레드를 기다리지 않으므로, libcurl이 초기화된 뒤에는 모듈이 배경 스레드가 언로드된 코드로 달려 들게 두는 대신 프로세스가 끝날 때까지 매핑된 채로 있습니다. 어느 요건이든 실패하면 SslCapabilities.OnlineRetrieval은 False가 되고 SslVerifyOptionsDiagnostics는 네트워크를 접촉한 척하는 대신 psvdOnlineRetrievalIgnored를 보고합니다
결과가 보장하는 것과 하지 않는 것
이 백엔드의 유효한 RevocationStatus는 전체 체인을 커버하는 현행 CRL이 발견되거나 구성되거나 내려받았고, 그중 어떤 것도 체인 안의 인증서를 폐기로 나열하지 않았다는 뜻입니다. 그 이상도 이하도 아닙니다. OCSP는 없으므로 OCSP로만 폐기를 발행하는 CA는 결과를 unsupported로 남기고, 네트워크 실패는 아무것도 발행하지 않는 CA와 똑같이 보입니다. psvdNoCrlsConfigured는 직접 구성한 CRL만 묘사하므로, 온라인 조회가 켜져 있을 때는 실패 예고가 아니라 힌트입니다. 감사 추적이 네트워크 없이 재현 가능해야 한다면 NetworkPolicy를 ptnpOffline 기본값에 두세요. fetch 세션이 만들어지지 않고 백엔드는 연결을 절대 열지 않으며, 이는 Windows에서 오프라인 PDF 서명 폐기 검사가 묘사하는 CryptoAPI 쪽 오프라인 계약과 일치합니다
조회 코드, 예산과 전송 바인딩은 PDFium Delphi 컴포넌트와 함께 소스로 실려 나옵니다. 그래서 신뢰할 수 없는 문서를 다루는 서버에서 ptnpOnline을 켜기 전에, 검증이 어떤 URL에 접촉할 수 있고 얼마나 내려받을 수 있는지 정확히 확인할 수 있습니다