기술 문서

HotPDF CSC 원격 서명: Delphi 클라우드 PDF 서명

원격 서명 서비스에 보내는 signatures/signHash 요청은 HSM이 이미 서명을 만들어 놓은 뒤에 타임아웃될 수 있고, 클라이언트는 자기가 그 선의 어느 쪽에 착지했는지 알 수 없습니다. 무작정 재시도하면 하나의 승인이 같은 계약에 서명 두 개를 낳을 수 있습니다. 재시도를 거부하면 매일 밤의 배치가 503 하나에 죽습니다. 송장 마흔 장을 해시 하나씩 서명하면 서명자는 일회용 비밀번호 마흔 개를 타이핑합니다

Cloud Signature Consortium(CSC) 통합은 이 둘을 모두 풀어야 합니다. qualified 키를 발급하는 trust service provider는 PFX 파일이나 USB 토큰 대신 CSC API 엔드포인트와 OAuth 클라이언트를 주는 쪽으로 기울고 있어서, CNG와 CAPI를 통한 Windows 인증서 저장소 서명처럼 로컬 저장소에 넣어 둘 것이 없습니다. 키는 제공자의 HSM에 머물고 모든 서명은 HTTPS 왕복입니다

HotPDF는 HTTP를 애플리케이션에 맡기는 이유는?

HotPDF는 THPDFCSCSignatureProvider로 원격 CSC 서비스를 통해 서명합니다. 이 서명 제공자는 CSC v2 프로토콜(credential info, authorization, signatures/signHash, 폴링)을 수행하되 HTTP 전송과 OAuth 토큰은 여러분 Delphi 코드가 소유합니다. 프록시와 TLS pinning, 클라이언트 인증서, 토크 볼트, 요청 로깅은 전부 HTTP 레이어에 살고, 배포마다 달라집니다

모든 요청마다 제공자는 THPDFCSCTransportRequest를 만들어 여러분의 THPDFCSCTransport 함수를 부릅니다. 레코드는 전체 URL, 준비된 Authorization bearer 헤더, JSON Body, IdempotencyKey, OperationIdentifier, Attempt 번호를 실어 나릅니다. 여러분 함수는 StatusCode, Body, RetryAfterMS로 THPDFCSCTransportResponse를 채운 뒤 ctsSuccess, ctsTemporaryFailure, ctsPermanentFailure, ctsCancelled 중 하나를 돌려줍니다

HotPDF CSC 전송 경계 다이어그램: THPDFCSCSignatureProvider가 프로토콜을 지휘해 당신 코드에 POST 메서드, 전체 URL, 준비된 Authorization bearer 헤더, JSON body, IdempotencyKey, 시도 번호를 담은 THPDFCSCTransportRequest를 건네주고, 당신은 StatusCode, Body, RetryAfterMS와 네 cts 상태 값 중 하나를 반환하며, 키는 HSM을 절대 떠나지 않습니다
제공자가 상태 코드를 직접 분류하므로, 응답받은 503을 영구 실패로 바꾸는 전송은 재시도 로직을 조용히 무력화하고, 프록시와 TLS 정책은 당신이 소유한 코드에 남습니다

RTL의 THTTPClient 위에 만든 전송은 40줄이면 충분합니다. body는 raw 바이트로 나가므로 JSON은 코드 페이지 변환을 한 번도 거치지 않습니다

uses
  System.SysUtils, System.Classes, 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, Reply: TMemoryStream;
      Headers: TNetHeaders;
      HttpResp: IHTTPResponse;
    begin
      Response := Default(THPDFCSCTransportResponse);
      Body := TMemoryStream.Create;
      Reply := TMemoryStream.Create;
      try
        if Request.Body <> '' then
          Body.WriteBuffer(Request.Body[1], Length(Request.Body));
        Body.Position := 0;
        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, TLS, 소켓 문제: 재시도 가능
        end;
        Response.StatusCode := HttpResp.StatusCode; // 429와 503은 손대지 않고 통과
        SetLength(Response.Body, Reply.Size);
        if Reply.Size > 0 then
          Move(Reply.Memory^, Response.Body[1], Reply.Size);
        // 초 단위 Retry-After, HTTP-date 값은 여기서 0으로 폴백
        Response.RetryAfterMS := StrToIntDef(HttpResp.HeaderValue['Retry-After'], 0) * 1000;
        Result := ctsSuccess;
      finally
        Reply.Free;
        Body.Free;
      end;
    end;
end;

외워 둘 규칙 하나: 서버가 답했으면 503이라도 ctsSuccess를 돌려주세요. 상태 코드 분류는 제공자가 자기 손으로 하고, 429를 ctsPermanentFailure로 바꾸는 전송은 아래에서 설명할 재시도 로직을 꺼버립니다. MaxResponseBytes(기본 2 MB)보다 큰 body는 전송이 뭐라 돌려주든 spsMalformed으로 거부됩니다

생성자는 반대 방향으로 엄격합니다. 전송이 없거나, ServiceBaseURL이나 CredentialID가 비었거나, AccessToken과 토큰 콜백 어느 쪽도 없거나, 예산이 범위를 벗었거나, URL이 HTTPS가 아니면 EHPDFCSCSignatureProviderError를 던집니다. 평범한 http://는 AllowInsecureHTTP를 세팅했을 때만 통하는데, 그 플래그는 로컬 목 서비스를 가리키는 테스트 리그의 소유물입니다

SAD란 무엇이고 왜 일회용인가?

THPDFCSCSignatureProvider는 Signature Activation Data(SAD)를 일회용으로 다룹니다. signatures/signHash가 수락되는 순간 제공자 상태에서 지워지며, 서명 자체가 비동기 폴링으로 나중에 도착해도 마찬가지입니다. SAD는 서명자가 이 특정 해시들을 승인했다는 서비스의 증거입니다. 메모리에 남아 있는 것은 엉뚱한 문서에 쓰이기를 기다리는 인가입니다

THPDFCSCOptions.Default의 기본값(RequireSAD와 AutoAuthorize가 모두 True)으로 제공자는 credentials/info를 한 번 로드하고, credential의 auth 블록이 요구하는 authData 값, 보통 OTP나 PIN을 여러분 THPDFCSCAuthenticationCallback에게 물어 credentials/authorize를 보냅니다. 200 응답은 SAD를 곧장 실어 옵니다. 202는 핸들을 실어 오고, 서비스가 Retry-After를 보내지 않는 한 제공자는 credentials/authorizeCheck를 MaxPollAttempts(60)회, PollIntervalMS(250 ms) 간격으로 폴링합니다. 콜백은 최대 32개 값을 돌려줄 수 있고 각각은 256바이트 이하의 빈 요소 없는 ID와 4,096바이트 이하 값입니다

지우는 일은 수락 뒤에 일어납니다. 네트워크가 signHash 수락 전에 끊기면 자동으로 얻은 SAD는 제자리에 남아 같은 배치에 묶여 있으므로, 재시도는 두 번째 OTP 프롬프트 없이 나갑니다. 서명자는 한 번 승인했고, 제공자는 그 승인을 한 번 씁니다

HotPDF SAD 생애주기 다이어그램: RequireSAD와 AutoAuthorize 상태에서 제공자는 credentials/info를 한 번 로드하고, 인증 콜백에 OTP나 PIN 값을 요청하고, credentials/authorize를 보내며, 응답이 202면 250 ms 간격으로 최대 60번 credentials/authorizeCheck를 폴링하고, signatures/signHash가 수락되는 순간 Signature Activation Data를 지우되, 수락 전에 네트워크가 끊겼으면 얻은 SAD를 보관합니다
메모리에 남아 있는 SAD는 잘못된 문서에 쓰이기를 기다리는 인가이며, 옵션으로 넘긴 사전 설정 SAD는 단일 해시 요청에만 쓰입니다
var
  Options: THPDFCSCOptions;
  Provider: THPDFCSCSignatureProvider;
begin
  Options := THPDFCSCOptions.Default;   // RequireSAD, AutoAuthorize, async mode, 2 retries
  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
      // TokenVault는 여러분의 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);

토큰 콜백은 캐시된 토큰이 없거나 여러분이 보고한 만료 시점의 TokenRefreshSkewSeconds(30) 안에 들어왔을 때 불립니다. 0인 ExpiresAtUTC는 401이 갱신을 강제할 때까지 토큰이 재사용된다는 뜻입니다

여러분이 Options.SAD로 직접 넘기는 SAD는 의도적으로 다르게 굴러갑니다. 그 SAD가 어느 해시를 커버하는지 제공자에게 알려 주는 것이 없으므로, 사전 설정 SAD는 단일 해시 요청에만 쓰입니다. AutoAuthorize가 켜진 다중 해시 배치에서는 사전 설정 값을 버리고 배치를 인가하고, AutoAuthorize가 꺼져 있으면 추측하는 대신 "CSC SAD is not pinned to the requested hash batch"로 실패합니다. OAuth 스코프만으로 인가하고 SAD를 발급하지 않는 서비스는 RequireSAD를 False로 놓으면 동작하며, 그 경우 SAD 멤버는 signHash body에서 빠집니다

OTP 프롬프트 한 번으로 송장 마흔 장 서명하기

SignHashBatch는 최대 MaxBatchSignatures(64)개 다이제스트에 credentials/authorize 한 번과 signatures/signHash 한 번을 보내며, 두 body를 같은 배열에서 만들어 numSignatures와 hashes 순서, hashAlgorithmOID가 두 호출 사이에서 일치하게 합니다. 단일 해시 Sign 메서드를 마흔 번 돌리면 인가 마흔 개가 됩니다. 어긋나는 authorize와 signHash를 보내면 서비스가 SAD를 엉뚱한 배치에 소모할 수 있습니다

검증은 네트워크 트래픽 전에 돕니다. 모든 요청은 다이제스트 OID를 달고 1부터 1,024바이트인 다이제스트(sikDigest)여야 하고, 모든 요청은 하나의 서명 알고리즘, 하나의 다이제스트 OID, RSASSA-PSS라면 하나의 salt 길이를 공유해야 합니다. 다중 해시 배치는 또한 credentials/info를 로드해 credential의 multisign 값이 배치 크기보다 작으면 spsUnsupported를 돌려줍니다. 이어서 인가는 요청 목록의 순서 민감 SHA-256 지문에 묶이므로, 해시 둘을 바꾸면 새 승인이 필요한 다른 배치가 됩니다

var
  Requests: THPDFCSCSignatureRequests;
  Signatures: THPDFCSCSignatures;
  Status: THPDFSignatureProviderStatus;
  OpId: AnsiString;
  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;

  // ID는 실행 기록에서 유도, 호출마다 새 GUID에서 절대 유도하지 않음
  OpId := AnsiString('invoices-' + RunRecord.BatchNumber);
  Status := Provider.SignHashBatch(Requests, OpId, Signatures);
  if Status = spsProviderError then
    // 같은 OpId: 완료된 배치는 캐시에서 돌아오고, 대기 중인
    // async 잡은 폴링을 재개하며, 미전송 요청은 같은 키를 재사용합니다
    Status := Provider.SignHashBatch(Requests, OpId, 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의 경우 제공자는 해시 알고리즘과 MGF1, salt 길이를 담은 base64 DER RSASSA-PSS-params 구조인 signAlgoParams를 더합니다. X.690 §8.19.4는 첫 두 OID 아크를 하나의 값(40 × 첫째 + 둘째)으로 접는데, 2 루트 아래에서 39를 넘는 둘째 아크는 그 값을 127을 넘게 만들어 base-128 다중 바이트 형식이 필요해집니다. SHA-2 OID는 그 경로를 지나지 않습니다. 2.16은 96으로 접히므로, 인코더는 모든 SHA-2 테스트를 통과하면서도 틀릴 수 있습니다. 제공자는 다중 바이트 형식을 기록하고 malformed OID를 EConvertError 대신 자기 EHPDFCSCSignatureProviderError로 보고합니다

재시도된 요청은 왜 두 번째 시그니처를 만들지 않을까?

THPDFCSCSignatureProvider는 재시도 가능한 모든 호출에 결정론적 idempotency 키를 달고 완료된 결과를 캐시하므로, 잃어버린 응답 뒤의 재시도는 HSM에 새 서명을 청하는 대신 원본 서명들을 돌려받습니다. 키는 csc- 뒤에 연산 식별자와 프로토콜 단계의 hex SHA-256이 붙는 형태이고, 인가와 signHash에서는 단계에 배치 지문이 포함됩니다. 잘라내는 대신 해싱하면 키가 고정 길이를 유지하고 공유 접두어를 가진 긴 연산 ID 둘이 충돌하지 않습니다

공유 요청 경로의 재시도 정책은 좁습니다

  • HTTP 401은 access-token 콜백을 통해 정확히 한 번의 토큰 갱신을 강제하고 요청은 한 번 반복됩니다. 두 번째 401은 최종이고, 토큰 콜백이 없으면 첫 번째가 최종입니다
  • 그 외 4xx 응답과 ctsPermanentFailure는 호출을 spsProviderError로 끝내고, 서비스의 error_description(혹은 error)이 LastError에 착지합니다
  • 408, 429, 5xx, ctsTemporaryFailure는 RetryLimit(기본 2)까지 재시도되며, Retry-After나 시도마다 두 배가 되는 RetryBaseDelayMS(100 ms)를 기다리고 MaxRetryAfterMS(5,000 ms)로 갈립니다
  • 대기는 Cancel을 확인하는 25 ms 조각으로 돌아 사용자가 중단했을 때 5초 백오프를 통째로 견디지 않습니다
  • signHash는 EnableIdempotency가 켜져 있을 때만 재시도됩니다. 끄면 제출 뒤의 타임아웃이 최종인데, 해시가 이미 서명됐는지 아무도 알 수 없기 때문입니다
HotPDF 재시도 정책 다이어그램: 재시도 가능한 모든 호출은 연산 식별자와 단계에서 해시한 결정론적 csc- idempotency 키를 갖고, HTTP 401은 정확히 한 번 토큰 갱신을 강제하며, 그 외 4xx 응답은 spsProviderError로 끝나고, 408, 429, 5xx 또는 일시적 전송 실패는 Retry-After나 5,000 ms로 제한된 지수 백오프를 기다리며 RetryLimit 2까지 재시도합니다
완료된 배치는 연산 식별자와 credential, 지문으로 캐시되며, 비동기 모드에서는 저장된 responseID 덕분에 반복 호출이 해시를 다시 제출하는 대신 폴링을 재개합니다

비동기 서명(operationMode "A", 기본값, AsyncValidityPeriodMS인 60,000 ms의 validity_period)은 가드를 하나 더 얹습니다. responseID는 제공자가 signatures/signPolling 폴링을 시작하기 전에 저장되므로, 같은 연산 식별자로 반복된 호출은 해시를 다시 제출하는 대신 대기 잡을 잡아 올립니다. 완료된 배치는 제공자 인스턴스별로 최대 MaxOperationCacheEntries(128)까지 캐시되고 재시작 뒤에는 사라집니다. idempotency 키는 살아 남는데, 유도된 것이지 무작위가 아니기 때문입니다. 연산 식별자를 재사용하는 재시작된 프로세스는 같은 키를 보내고, 서비스가 그것으로 중복 제거하는지 여부는 서비스 계약의 일부입니다

이 모든 것은 연산 식별자에 걸려 있는데, 파라미터는 비워 두기 쉽습니다. SignHashBatch에 ''를 넘기면 제공자는 요청들의 OperationIdentifier로 폴백하고, 그것도 없으면 새 GUID로 갑니다. 호출마다 새 GUID는 여러분 재시도 루프가 매번 새 연산을 시작한다는 뜻입니다. 새 캐시 슬롯, 새 idempotency 키, 어쩌면 두 번째 OTP 프롬프트입니다. 또한 Provider.Cancel이 겨냥할 것이 없어집니다. 여러분 코드는 GUID를 본 적이 없으니까요. 식별자는 배치 번호나 문서 ID처럼 이미 영속화하는 것에서 유도하고, EnableIdempotency는 기본 True에 두세요. 서비스가 헤더를 거부한다면 옵션을 끄는 대신 전송에서 그 헤더를 빼세요

CSC 서명을 PDF에 심기

제공자를 GetCertificateChain의 엔드 엔티티 인증서와 함께 HPDFCMSSignPDFStreamWithProvider에 넘기면, HotPDF가 CMS SignedData를 만들고 제공자가 서명 속성의 다이제스트에 서명합니다. 입력 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;

플레이스홀더 크기 잡기에서, EstimateSignatureSize는 설정했다면 EstimatedSignatureBytes를 돌려주고 그렇지 않으면 credentials/info의 키 길이에서 유도한 RSA 모듈러스 크기를 돌려줍니다. ECDSA 키에는 spsUnsupported로 답하고 플레이스홀더 추정기가 인증서로 폴백하므로, EstimatedSignatureBytes를 직접 세팅하면 예측 가능한 결과가 나옵니다. HPDFCMSSignPDFStreamWithProviderAutoSize는 플레이스홀더가 너무 작은 걸로 드러나면 재빌드하고 재서명하는데, spcSafeSignRetry를 광고하는 제공자에만 그렇게 합니다. THPDFCSCSignatureProvider는 EnableIdempotency가 켜져 있는 동안 그것을 광고하니, 그 기본값을 그대로 둘 또 하나의 이유입니다

PAdES-B-T에서는 TimestampDigest가 signatures/timestamp로 같은 서비스에 RFC 3161 토큰을 청하는데, 선택적 nonce와 MaxTimestampBytes(1 MB) 크기 상한이 붙습니다

여러분 코드에 남는 정책 결정들

제공자는 다이제스트에 서명합니다. 순수 모드의 Ed25519와 Ed448은 서명 제공자에게 서명 속성 메시지 전체(sikMessage)를 넘기는데, 배치 검증기는 그것을 malformed로 거부합니다. signHash는 정의상 해시 기반이니까요. Free Pascal 아래에서 제공자 유닛은 익명 메서드 대신 평범한 함수 타입으로 컴파일되므로, SignHashBatch와 TimestampDigest는 거기서 호출 가능합니다. 결과를 HPDFCMSSignPDFStreamWithProvider로 PDF에 심는 것은 Delphi 경로입니다

제공자는 정책도 여러분에게 남깁니다. CredentialInfo는 KeyStatus, CertificateStatus, AuthorizationMode, SCAL, MultiSignatures를 보고하지만, 비활성 키나 SCAL1 credential을 거부할지는 여러분 판단입니다. RefreshCredentialInfo 직후, 서명자가 OTP 프롬프트를 보기 전에 그 필드들을 확인하세요. 서명자가 코드를 타이핑한 뒤에 오는 서비스의 거부는 미리 깔끔하게 나온 오류보다 나쁜 경험입니다

하나의 제공자 인스턴스는 한 번에 한 배치를 돌립니다. SignHashBatch와 RefreshCredentialInfo, TimestampDigest가 하나의 락을 공유해 두 스레드가 SAD 하나를 두고 경주할 수 없기 때문입니다. 처리량은 credential의 multisign 한도까지 더 큰 배치에서 나옵니다. 결과 서명이 qualified로 치세는지는 trust service와 그것이 발급한 credential이 결정합니다

CSC 제공자는 HotPDF Delphi PDF component에서 CMS와 PAdES 빌더, 로컬과 PKCS#11 서명 제공자와 함께 실려 나가므로, 원격과 토큰 기반, 파일 기반 서명이 모두 같은 PDF 서명 호출로 흘러갑니다