HotPDF는 원격 Cloud Signature Consortium(CSC) 서비스가 쥔 개인 키로 PDF 문서에 서명합니다. THPDFCSCSignatureProvider라는 시그니처 제공자가 CSC API를 구동합니다. credential info, 인가, signatures/signHash, 폴링까지요. HTTP 전송과 OAuth 액세스 토큰은 당신의 Delphi 애플리케이션이 공급합니다. 키는 서비스의 HSM을 절대 떠나지 않습니다
적격 서명 키를 아예 손에 넣는 길이 갈수록 이것뿐입니다. 신뢰 서비스 제공자는 PFX 파일이나 USB 토큰이 아니라 CSC 엔드포인트와 OAuth 클라이언트를 나눠 주므로, CNG와 CAPI로 하는 Windows 인증서 저장소 서명처럼 로컬 인증서 저장소에 적재할 것이 없습니다. 순진한 통합은 예측 가능하게 실패합니다. signHash 호출이 시간 초과되고 재시도가 같은 계약서에 두 번 서명한다든가, 해시마다 개별 인가를 받는 바람에 청구서 마흔 장이 일회용 비밀번호 마흔 개를 유발한다든가. 제공자가 하는 일의 대부분은 그 두 실패를 막는 방어입니다
HotPDF는 HTTP를 애플리케이션에 맡기는 이유는?
전송 계층이야말로 배포마다 달라지는 지점이기 때문입니다. 프록시, TLS 피닝, 클라이언트 인증서, 기업 OAuth 볼트, 로깅 정책이 모두 HTTP 계층에 사므로, THPDFCSCSignatureProvider는 프로토콜 상태를 지휘하고 요청마다 THPDFCSCTransport 함수를 호출합니다. 제공자는 Method(늘 POST), ServiceBaseURL에 엔드포인트 경로를 붙여 만든 전체 URL, 준비된 Authorization bearer 헤더, ContentType, JSON Body, IdempotencyKey, Attempt 번호, MaxResponseBytes를 담은 THPDFCSCTransportRequest를 건네줍니다. 당신은 StatusCode, Body, RetryAfterMS로 THPDFCSCTransportResponse를 채우고 ctsSuccess, ctsTemporaryFailure, ctsPermanentFailure, ctsCancelled 중 하나를 반환합니다
uses
System.Net.HttpClient, System.Net.URLClient, HPDFSignatureProvider,
HPDFCSCSignatureProvider;
function MakeCSCTransport(Client: THTTPClient): THPDFCSCTransport;
begin
Result :=
function(const Request: THPDFCSCTransportRequest;
out Response: THPDFCSCTransportResponse): THPDFCSCTransportStatus
var
Body: TStringStream;
Reply: TMemoryStream;
Headers: TNetHeaders;
HttpResp: IHTTPResponse;
begin
Response := Default(THPDFCSCTransportResponse);
Body := TStringStream.Create(string(Request.Body), TEncoding.UTF8);
Reply := TMemoryStream.Create;
try
Headers := [TNameValuePair.Create('Authorization', string(Request.Authorization)),
TNameValuePair.Create('Content-Type', string(Request.ContentType))];
if Request.IdempotencyKey <> '' then // 헤더 이름은 서비스가 문서화한 대로
Headers := Headers + [TNameValuePair.Create('Idempotency-Key',
string(Request.IdempotencyKey))];
try
HttpResp := Client.Post(Request.URL, Body, Reply, Headers);
except
on ENetHTTPClientException do
Exit(ctsTemporaryFailure); // 소켓이나 DNS 문제: 재시도 가능
end;
Response.StatusCode := HttpResp.StatusCode; // 503은 있는 그대로 보고, 분류하지 않음
SetLength(Response.Body, Reply.Size);
if Reply.Size > 0 then
Move(Reply.Memory^, Response.Body[1], Reply.Size);
Response.RetryAfterMS := StrToIntDef(HttpResp.HeaderValue['Retry-After'], 0) * 1000;
Result := ctsSuccess;
finally
Reply.Free;
Body.Free;
end;
end;
end;
외울 만한 규칙 하나. 서버가 실제로 답했으면 503이어도 ctsSuccess를 반환하세요. 제공자가 상태 코드를 직접 분류하고, 429를 ctsPermanentFailure로 바꾸는 전송은 아래에서 설명할 재시도 로직을 조용히 무력화합니다. 생성자는 반대 방향으로 엄격합니다. 전송이 없거나, CredentialID가 비었거나, AccessToken도 토큰 콜백도 공급되지 않았거나, 예산이 범위를 벗어났거나, ServiceBaseURL이 HTTPS가 아니면 EHPDFCSCSignatureProviderError를 일으킵니다. 맨 http://는 AllowInsecureHTTP와 함께일 때만 받아지는데, 이건 테스트 리그에나 어울리고 다른 곳엔 어울리지 않습니다
SAD란 무엇이고 HotPDF는 왜 한 번 쓰고 버릴까?
THPDFCSCSignatureProvider는 Signature Activation Data(SAD)를 일회용으로 취급합니다. signatures/signHash가 수락되는 순간 제공자 상태에서 지워지며, 시그니처 자체가 비동기 폴링으로 나중에 도착할 때도 마찬가지입니다. SAD는 서명자가 이 특정 해시들을 승인했다는 서비스의 증명이고, 메모리에 남아 있는 SAD는 잘못된 문서에 쓰이기를 기다리는 인가입니다
THPDFCSCOptions.Default의 기본값, 그러니까 RequireSAD와 AutoAuthorize가 모두 True인 상태에서 제공자는 credentials/info를 한 번 로드하고, authData 값(OTP, PIN, credential의 auth 블록이 요구하는 무엇이든)을 THPDFCSCAuthenticationCallback에 요청한 다음 credentials/authorize를 보냅니다. 200은 SAD를 곧장 실어 오고, 202는 credentials/authorizeCheck로 MaxPollAttempts(60)번, PollIntervalMS(250 ms) 간격으로 폴링하는 핸들을 실어 옵니다. 콜백은 최대 32개 값을 반환할 수 있고, 각각은 256바이트까지의 비어 있지 않은 ID와 4,096바이트까지의 값입니다. signHash가 수락되기 전에 네트워크가 끊기면 자동으로 얻은 SAD를 보관해 서명자에게 다시 묻지 않고 같은 배치를 재시도할 수 있습니다
var
Options: THPDFCSCOptions;
Provider: THPDFCSCSignatureProvider;
begin
Options := THPDFCSCOptions.Default; // RequireSAD, AutoAuthorize, 비동기 모드, 재시도 2
Options.ServiceBaseURL := 'https://csc.example.com/csc/v2';
Options.CredentialID := 'contracts-signing-01';
Options.ClientData := 'invoice-run-2026-09';
Provider := THPDFCSCSignatureProvider.Create(Options, MakeCSCTransport(HttpClient),
function(ForceRefresh: Boolean; const OperationIdentifier: AnsiString;
out AccessToken: AnsiString; out ExpiresAtUTC: TDateTime): THPDFSignatureProviderStatus
begin
// 당신의 OAuth 클라이언트, 서비스가 401로 답한 뒤엔 ForceRefresh가 True
if not TokenVault.Acquire(ForceRefresh, AccessToken, ExpiresAtUTC) then
Exit(spsProviderError);
Result := spsValid;
end,
function(const CredentialID, CredentialInfoJSON, OperationIdentifier: AnsiString;
out Values: THPDFCSCAuthenticationValues): THPDFSignatureProviderStatus
var
Otp: string;
begin
if not AskSignerForOtp(Otp) then // 당신의 UI
Exit(spsCancelled);
SetLength(Values, 1);
Values[0].ID := 'otp';
Values[0].Value := AnsiString(Otp);
Result := spsValid;
end);
Options.SAD로 직접 넘긴 SAD는 다르게, 그리고 의도적으로 다르게 동작합니다. HotPDF는 그것이 어떤 해시를 위해 발급됐는지 알 수 없으므로, 제공자는 사전 설정 SAD를 단일 해시 요청에만 씁니다. AutoAuthorize를 끈 배치에서는 추측하는 대신 "CSC SAD is not pinned to the requested hash batch"로 실패합니다
SignHashBatch는 하나의 인가로 여러 문서에 어떻게 서명할까?
SignHashBatch는 MaxBatchSignatures(64)개까지의 다이제스트에 대해 credentials/authorize 하나와 signatures/signHash 하나를 보내고, 양쪽 body를 같은 배열로 만들어 numSignatures, hashes 순서, hashAlgorithmOID가 두 호출에서 동일하게 합니다. 그 일치가 바로 CSC multisign 모델이 요구하는 것입니다. 단일 해시 Sign 메서드를 마흔 번 돌리면 인가 마흔 개를 얻고, 서로 어긋나는 authorize와 signHash를 보내면 서비스가 SAD를 잘못된 배치에 소비할 수 있습니다
네트워크 트래픽 전에 제공자는 배치를 검증합니다. 모든 요청은 다이제스트 OID를 가진 1부터 1,024바이트의 다이제스트(sikDigest)여야 하고, 모든 요청은 하나의 시그니처 알고리즘 OID, 하나의 다이제스트 OID, 그리고 RSASSA-PSS라면 하나의 salt 길이를 공유해야 합니다. 다중 해시 배치는 credentials/info도 로드하고, credential의 multisign 값이 배치보다 작으면 spsUnsupported를 반환합니다. SAD는 이어서 배치 지문에 고정됩니다. 버전 레이블과 개수, 그리고 요청마다 알고리즘 OID, 다이제스트 OID, 알고리즘, salt 길이, 다이제스트 바이트를 각각 길이 접두어 붙여 SHA-256한 것입니다. 해시 둘을 바꾸면 그것은 새 인가가 필요한 다른 배치입니다
var
Requests: THPDFCSCSignatureRequests;
Signatures: THPDFCSCSignatures;
Status: THPDFSignatureProviderStatus;
I: Integer;
begin
SetLength(Requests, Length(Digests)); // Digests: 당신이 계산한 SHA-256 값
for I := 0 to High(Digests) do
begin
Requests[I] := Default(THPDFSignatureProviderRequest);
Requests[I].Algorithm := hsaRSAPKCS1v15; // AlgorithmOID가 비면 signAlgo는 파생됨
Requests[I].DigestAlgorithmOID := '2.16.840.1.101.3.4.2.1';
Requests[I].InputKind := sikDigest;
Requests[I].Input := Digests[I];
end;
Status := Provider.SignHashBatch(Requests, 'invoices-2026-09-25-a', Signatures);
if Status <> spsValid then
raise Exception.CreateFmt('CSC batch failed (HTTP %d): %s',
[Provider.LastHTTPStatus, Provider.LastError]);
// Signatures[I]는 Digests[I]에 속합니다. 개수는 요청과 대조해 검증됐습니다
end;
RSASSA-PSS에서는 제공자가 signAlgoParams도 보냅니다. 해시 알고리즘, MGF1, salt 길이를 담은 base64 DER RSASSA-PSS-params 구조입니다. 이걸 만드는 건 OID 인코딩인데, v2.748.5가 그 구석 하나를 고쳤습니다. X.690 §8.19.4는 처음 두 arc를 하나의 값으로 접고(40 × 첫 arc + 둘째 arc), 2 루트 아래에서 둘째 arc가 39를 넘으면 그 값이 127을 넘어, 이전 빌드가 적용하지 않던 base-128 다중 바이트 형태가 필요해집니다. SHA-2 OID는 아무것도 영향받지 않습니다. 2.16은 96으로 접히니까요. 그래도 잘못된 형태의 OID는 이제 EConvertError 대신 제공자 자신의 오류를 일으킵니다
재시도된 요청은 왜 두 번째 시그니처를 만들지 않을까?
THPDFCSCSignatureProvider는 재시도 가능한 모든 호출이 결정론적 idempotency 키를 갖게 하고 완료된 결과를 캐시하므로, 응답을 잃은 뒤의 재시도는 HSM에 새 서명을 청구하는 대신 원래 시그니처를 돌려줍니다. 키는 csc- 뒤에 연산 식별자와 단계의 hex SHA-256을 붙인 것이고, 단계는 인가와 signHash 양쪽에 배치 지문을 심어 둡니다. 잘라내는 대신 해시하는 게 중요합니다. 접두어를 공유하는 두 긴 연산 ID는 잘라내기 아래에서 충돌하지만, 고정 길이 콘텐츠 주소 키는 시도를 거듭해도 고유하고 안정적입니다
공유 요청 경로의 재시도 정책은 의도적으로 좁습니다:
- HTTP 401은 액세스 토큰 콜백을 통해 정확히 한 번 토큰 갱신을 강제하고, 액세스 토큰 콜백이 할당돼 있으면 요청을 한 번 반복합니다. 두 번째 401은 최종입니다
- 그 외 4xx 응답과
ctsPermanentFailure는 호출을spsProviderError로 끝내고, 서비스의error_description이LastError에 담깁니다 - 408, 429, 5xx,
ctsTemporaryFailure는RetryLimit(기본 2)까지 재시도하며,Retry-After나RetryBaseDelayMS× 2attempt(기본 100 ms)를 기다리고MaxRetryAfterMS(5,000 ms)로 제한됩니다 - 대기는
Cancel을 검사하는 25 ms 조각으로 돌므로, 중단한 사용자가 5초 백오프를 견뎌야 하지 않습니다 signHash는EnableIdempotency가 켜져 있을 때만 재시도됩니다. 끄면 제출 뒤의 시간 초과는 최종인데, 키가 이미 쓰였는지 아무도 알 수 없기 때문입니다
비동기 서명(operationMode "A", 기본값)은 가드를 하나 더 얹습니다. responseID가 signatures/signPolling 폴링 전에 저장되므로, 같은 연산 식별자의 반복 호출은 다시 제출하는 대신 폴링을 재개합니다. 완료된 배치는 연산 식별자, credential, 지문으로 키잉된 캐시에 머물며, MaxOperationCacheEntries(128)로 제한되고 깊은 사본으로 반환됩니다. 그 캐시는 제공자 인스턴스 안에 살고 재시작을 버티지 못합니다. idempotency 키는 버팁니다. 파생된 것이지 무작위가 아니기 때문입니다. 그래서 연산 식별자를 재사용하는 재시작된 프로세스는 같은 키를 보내며, 서비스가 그걸로 중복을 걸러 주는지는 서비스의 약속이지 HotPDF의 약속이 아닙니다
CSC 시그니처를 PDF에 넣는 방법은?
제공자를 GetCertificateChain의 엔드 엔티티 인증서와 함께 HPDFCMSSignPDFStreamWithProvider에 넘기세요. HotPDF가 CMS SignedData를 만들고 제공자가 signed attributes의 다이제스트에 서명합니다. 입력 PDF는 THPDFPage.AddSignedSignatureField가 기록하는 /ByteRange와 /Contents 플레이스홀더가 필요하며, HotPDF의 PAdES 서명 워크플로와 정확히 같습니다. 제공자 모델도 ML-DSA와 EdDSA를 위한 HotPDF 플러그형 시그니처 제공자에서 다룬 것과 같습니다
var
Chain: THPDFCSCCertificateChain;
SignOpts: THPDFCMSSignOptions;
Src, Dst: TFileStream;
begin
if Provider.RefreshCredentialInfo <> spsValid then
raise Exception.Create(Provider.LastError);
Chain := Provider.GetCertificateChain; // CSC는 엔드 엔티티 인증서를 먼저 나열합니다
if Length(Chain) = 0 then
raise Exception.Create('Credential returned no certificate');
SignOpts := HPDFCMSDefaultOptions(palBaseline_B_B);
SignOpts.DigestAlgorithm := cmsdaSHA256;
SignOpts.SignatureScheme := cmsRSAPKCS1v15;
Src := TFileStream.Create('contract-unsigned.pdf', fmOpenRead or fmShareDenyWrite);
Dst := TFileStream.Create('contract-signed.pdf', fmCreate);
try
if not HPDFCMSSignPDFStreamWithProvider(Src, Dst, Chain[0], Provider, '', SignOpts) then
raise Exception.Create('PDF signing failed');
finally
Dst.Free;
Src.Free;
end;
end;
/Contents 플레이스홀더 크기 잡기는 EstimateSignatureSize를 거칩니다. 설정했다면 EstimatedSignatureBytes를 돌려주고, 아니면 credential의 키 길이로부터 RSA 모듈러스 크기를 돌려줍니다. ECDSA라면 EstimatedSignatureBytes를 직접 설정하거나, 추정은 spsUnsupported를 보고합니다. 자동 크기 서명 변형은 플레이스홀더가 너무 작음이 드러나면 다시 서명하는데, spcSafeSignRetry를 광고하는 제공자에게만 그렇게 합니다. THPDFCSCSignatureProvider는 EnableIdempotency가 켜져 있을 때만 그렇습니다. PAdES-B-T 워크플로에서는 TimestampDigest가 같은 서비스에서 signatures/timestamp로 타임스탬프 토큰을 요청하며, MaxTimestampBytes(1 MB)로 제한됩니다
CSC 제공자가 하지 않는 것은?
메시지는 서명하지 않고 다이제스트만 합니다. 순수 모드의 Ed25519와 Ed448은 제공자에게 signed-attributes 메시지 전체(sikMessage)를 넘기는데, 배치 검증기는 그것을 malformed로 거부합니다. signHash는 정의상 해시 기반이기 때문입니다. 제공자 유닛은 익명 메서드 대신 plain function type으로 Free Pascal에서 컴파일되지만, 제공자 기반 CMS 빌더는 현재 FPC에서 예외를 일으키므로, CSC 시그니처를 PDF에 심는 것은 Delphi 경로입니다
정책도 결정하지 않습니다. CredentialInfo는 키 상태, 인증서 상태, 인가 모드, SCAL 수준, multisign 한도를 보고하지만, 제공자가 스스로 비활성화된 키나 SCAL1 credential을 거부하지는 않습니다. 서명자에게 OTP 프롬프트를 보여 주기 전에 그것들을 확인하세요. 그리고 제공자 인스턴스 하나는 한 번에 배치 하나에 서명합니다. SignHashBatch는 내부에서 직렬화되어 두 스레드가 하나의 SAD를 두고 경쟁할 수 없으며, 이는 처리량이 배칭에서 나오지 워커 스레드에 걸쳐 제공자를 공유하는 데서 나오지 않는다는 뜻입니다. 결과 시그니처가 적격인지는 그 해시를 나른 신뢰 서비스와 그 credential이 결정하지, 그걸 운반한 라이브러리가 결정하지 않습니다
CSC 제공자, CMS와 PAdES 빌더, 로컬 및 PKCS#11 제공자는 모두 HotPDF Delphi PDF component에 들어 있습니다