기술 문서

HotPDF로 Delphi에서 양자내성과 EdDSA PDF 서명하기

ML-DSA-65 서명을 지닌 PDF는 만들어 낸 toolchain의 검사는 통과하면서도, 파일이 자기 알고리즘을 정의하는 extension을 선언하라고 강제하는 verifier에게는 지원하지 않는 알고리즘으로 돌아올 수 있습니다. 공개 키는 1,952바이트, 그 parameter set의 FIPS 204 크기 그대로입니다. 서명 바이트는 온전하고 byte range는 파일 전체를 덮습니다. 그런 verifier가 찾는 것은 문서 Catalog의 항목 하나, 즉 그 extension을 이름 대는 항목입니다. ISO 32000-2는 ML-DSA를 전혀 실지 않으니까요

서명 쪽에는 다른 벽이 있습니다. 하드웨어 토큰, 클라우드 HSM, 국가 eID 카드는 요청받으면 서명을 계산해 주지만 개인 키를 애플리케이션에 넘겨주지는 않습니다. PFX 파일을 읽어 in-process로 서명하리라 기대하는 서명 코드는 그냥 읽을 것이 없는 거죠. 두 문제 모두 결정이 어디에 사는지의 문제입니다. 알고리즘 선언은 파일 안에 살고, 키는 Delphi 코드가 닿을 수 없는 어딘가에 삽니다

유효한 ML-DSA 서명이 왜 unsupported로 돌아오는가

HotPDF는 로드된 문서가 Catalog에 HotPDF developer extension을 선언했을 때만 ML-DSA-44, ML-DSA-65, ML-DSA-87 CMS 서명을 검증합니다. 선언이 없으면 암호학이 아무리 튼튼해도 verifier는 격자 서명에 svUnsupportedAlgorithm을 반환합니다. 근거는 재현성입니다. 기본 표준이 이름 대지 않는 알고리즘을 쓰면서 어느 extension에 의존하는지 말하지 않는 파일은, 다른 구현이 다시 세울 근거가 없는 판정을 만들어 냅니다

만드는 쪽에서는 THotPDF.EnsureMLDSAExtensions가 Catalog에 /Extensions << /HotPDF << /BaseVersion /2.0 /ExtensionLevel 1 >> >>을 기록하고, 이전 버전을 겨냥하고 있었다면 문서를 PDF 2.0으로 끌어올립니다. 읽는 쪽에서는 LoadedDocumentDeclaresMLDSAExtension이 그 딕셔너리가 /BaseVersion 2.0과 /ExtensionLevel 1 이상으로 존재할 때만 True를 반환합니다. VerifyLoadedSignatureWithOptions는 둘을 묶습니다. 호출자의 AllowMLDSA 플래그를 받아 검증이 시작되기 전에 선언 검사와 AND하므로, 선언되지 않은 파일에 플래그를 설정해도 아무것도 바뀌지 않습니다

HotPDF는 ML-DSA PDF 검증을 문서 저장 전 EnsureMLDSAExtensions이 쓴 명시적 Catalog 확장 선언 뒤에 두고, AllowMLDSA가 설정돼 있어도 선언되지 않은 파일의 격자 알고리즘은 거부
HotPDF는 Catalog가 여전히 확장을 선언할 때만 ML-DSA를 허용해 포스트 퀀텀 판정을 재현 가능하게 유지하며, EndDoc 전에 EnsureMLDSAExtensions를 호출하면 선언이 서명된 바이트 범위 뒤가 아니라 안에 들어갑니다
var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.FileName := 'contract-pq.pdf';
    Pdf.StrictVersionLock := False;   // 기본값. 이 호출이 파일을 PDF 2.0으로 끌어올립니다
    Pdf.BeginDoc;
    Pdf.CurrentPage.SetFont('Arial', [], 11);
    Pdf.CurrentPage.TextOut(50, 720, 0, 'Supply agreement 2026-114');
    Pdf.EnsureMLDSAExtensions;        // 서명이 Catalog를 덮기 전에 선언합니다
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

이 스니펫에는 무게 있는 세부가 둘 있습니다. 호출은 문서가 저장되고 서명되기 전에 이루어져야 합니다. Catalog가 서명된 byte range 안에 있기 때문입니다. 사후에 패치하면 서명된 파일에 대한 서명되지 않은 변경이 되거나, 어느 validator도 수정으로 기록할 새 incremental revision이 됩니다. 두 번째 세부는 StrictVersionLock입니다. 잠금이 켜지고 문서가 PDF 2.0 아래로 고정되어 있으면 EnsureMLDSAExtensions는 아무것도 쓰지 않고 돌아옵니다. PDF 2.0 extension은 1.7 파일에서 선언될 수 없기 때문입니다. generator가 보존 사유로 버전을 고정한다면 선언을 믿기 전에 Version = pdf20을 확인하세요

EdDSA와 ML-DSA를 위한 검증 호출 하나

VerifyLoadedSignatureWithOptions는 서명 인덱스, 소스 스트림, THPDFCMSVerifyOptions 레코드, out THPDFSignatureInfo를 받습니다. 레코드는 THPDFCMSVerifyOptions.Default에서 출발하는데, 이 기본값은 AllowMLDSA를 끄고, CMS 컨테이너를 256 signer로 제한하며, countersignature 중첩을 8로 제한합니다. 최신 알고리즘에는 필드 셋이 중요합니다. SignatureProvider는 여러분의 verifier가 먼저 답하게 하고, OpenSSLLibraryPath는 순수 모드 Ed25519·Ed448 검증을 수행할 OpenSSL 3 libcrypto를 지명하며, AllowMLDSA는 위의 Catalog 검사를 조건으로 격자 알고리즘에 옵트인합니다. 레코드는 서명 정책 고정 필드(RequireSignaturePolicy, ExpectedSignaturePolicyOID, ExpectedSignaturePolicyHash)도 지니는데, 모든 알고리즘 계열에 적용됩니다

빌트인 경로는 알고리즘별로 갈라집니다. Ed25519와 Ed448은 OpenSSL 3을 거치는데, 경로를 주지 않으면 플랫폼 provider가 Win64의 통상 DLL 검색 순서로 libcrypto-3-x64.dll, libcrypto-3.dll, libcrypto.dll을 시도합니다. ML-DSA는 Windows CNG의 ML-DSA algorithm provider를 거치고, 그것을 제공하지 않는 Windows 빌드에서는 결과가 unsupported입니다. OpenSSLLibraryPath는 실행 파일 곁에 배포하는 복사본으로 지정하세요. 비워 두면 판정이 검색 경로에 다른 installer가 우연히 흘려둔 libcrypto에 좌우되는데, 이건 감사에서 설명하기 어려운 종류의 일입니다

여러분의 SignatureProvider가 먼저 물어집니다. spsUnsupported를 반환하면 빌트인 플랫폼 provider가 같은 요청을 받으므로, ML-DSA만 아는 provider가 기본 EdDSA 처리를 다시 구현하지 않고도 그 앞에 앉을 수 있습니다

var
  Pdf: THotPDF;
  PQ: THPDFCallbackSignatureProvider;
  Opts: THPDFCMSVerifyOptions;
  Info: THPDFSignatureInfo;
  Status: THPDFSignatureVerifyStatus;
  Src: TFileStream;
begin
  PQ := THPDFCallbackSignatureProvider.Create(
    function(const Req: THPDFSignatureProviderRequest): THPDFSignatureProviderStatus
    begin
      if not (Req.Algorithm in [hsaMLDSA44, hsaMLDSA65, hsaMLDSA87]) then
        Exit(spsUnsupported);            // EdDSA는 플랫폼 프로바이더에 넘깁니다
      // MyFips204Verify는 ML-DSA 라이브러리를 감싼 직접 작성한 래퍼입니다.
      // Req.Input은 DER 인코딩된 signed attributes를 담습니다(순수 모드)
      Result := MyFips204Verify(Req.AlgorithmOID, Req.PublicKey,
        Req.Input, Req.Signature);
    end);
  Pdf := THotPDF.Create(nil);
  Src := TFileStream.Create('contract-pq.pdf', fmOpenRead or fmShareDenyNone);
  try
    Pdf.LoadFromFile('contract-pq.pdf');
    Opts := THPDFCMSVerifyOptions.Default;
    Opts.SignatureProvider := PQ;
    Opts.OpenSSLLibraryPath := ExtractFilePath(ParamStr(0)) + 'libcrypto-3-x64.dll';
    Opts.AllowMLDSA := True;             // 여전히 Catalog 선언과 AND됩니다
    Status := Pdf.VerifyLoadedSignatureWithOptions(0, Src, Opts, Info);
    if Status = svValid then
      Memo1.Lines.Add(string(Info.SignatureAlgorithm) + ' / ' +
        string(Info.SignatureAlgorithmOID));
  finally
    Src.Free;
    Pdf.Free;
    PQ.Free;                             // 옵션 레코드는 이것을 소유하지 않습니다
  end;
end;

설정이 예상한 알고리즘이 아니라 Info.SignatureAlgorithmOID를 기록하세요. ML-DSA-65라면 2.16.840.1.101.3.4.3.18입니다. 파일에서 발견된 OID를 기록하는 audit trail은 나중에 설정값으로는 답할 수 없는 질문에 답합니다. 커스텀 provider가 아예 필요 없다면 더 짧은 VerifyLoadedSignature(SigIndex, Source, Info) 오버로드가 기본 옵션을 만들고 Catalog 선언에서 AllowMLDSA를 채워 줍니다

외부에서 만든 ML-DSA·EdDSA CMS를 넘어뜨리는 인코딩 규칙

두 알고리즘 계열 모두 완전한 DER 인코딩 signed attributes 위에서 순수 모드로 검증되고, signed attributes가 없는 SignerInfo는 unsupported로 보고됩니다. EdDSA는 AlgorithmIdentifier parameter가 아예 없어야 합니다. 손으로 만든 CMS의 흔한 실수는 signed attributes를 먼저 SHA-512나 SHAKE256으로 해시하고 그 다이제스트에 서명하는 것인데, 이는 PureEdDSA를 CMS 어디에도 선언되지 않은 prehash 변종으로 조용히 바꿔 버립니다. ML-DSA는 parameter set과 message digest의 짝도 검사합니다. ML-DSA-44는 SHA-256, SHA-384, SHA-512, SHAKE256을 받고, ML-DSA-65는 SHA-384 이상, ML-DSA-87은 SHA-512나 SHAKE256이 필요합니다. 어긋난 짝은 Info.DigestConsistencyIssue에 나타나는데, 다른 toolchain의 파일이 실패할 때 가장 먼저 읽을 필드입니다

EdDSA에는 자기 등록 이야기가 있습니다. ISO/TS 32002는 Ed25519와 Ed448을 PDF 2.0 서명용으로 등록하는 extension이고, EnsureISO32002Extensions는 ExtensionLevel 32002를 지닌 맞는 ISO_ 항목을 Catalog에 추가합니다. 검증은 그 항목 없이도 EdDSA를 받아들이지만, 여러분이 만드는 파일은 ML-DSA 선언과 같은 재현성 이유로 그 항목을 지니고 있어야 합니다

서명 provider에게 요구되는 일

서명 provider는 THPDFSignatureProviderRequest 하나를 받고 THPDFSignatureProviderStatus를, 성공 시 서명 바이트를 반환합니다. 요청은 알고리즘과 그 OID, 다이제스트 알고리즘 OID, PSS 솔트 길이, Input이 메시지(sikMessage)인지 미리 계산된 다이제스트(sikDigest)인지, 입력 바이트, signer 인증서, 키 식별자, 이 호출에 고유한 연산 식별자를 담습니다. 토큰 드라이버나 서명 서비스가 이미 말하는 어휘이고, 어느 것도 키가 여러분 프로세스로 넘어올 것을 요구하지 않습니다

provider는 각자 spcSign, spcCancel, spcHardwareKey, spcRemoteKey 같은 capability 집합도 광고합니다. 라이브러리에는 구현 여러 개가 들어 있습니다:

  • THPDFCallbackSignatureProvider는 검증, 서명, 취소를 익명 메서드로 감싸서 기존 사내 서명 루틴에서 PDF 서명까지 가는 가장 짧은 경로가 됩니다
  • THPDFRemoteSignatureProvider는 재시도 한도, 취소 레지스트리, 입력·서명 크기 상한을 지닌 전송 함수를 감싸므로, 잘못 동작하는 원격 서비스가 CMS 인코더에 과대 응답을 밀어 넣을 수 없습니다
  • THPDFPKCS11SignatureProvider는 여러분 코드가 이미 열고 로그인한 세션과 개인 키 핸들을 빌려 씁니다. HotPDF는 PIN을 보지 않고 토큰 세션을 관리하지 않습니다. C_Sign 호출을 직렬화하고, RSA PKCS#1 v1.5용 DigestInfo를 앞에 붙이고, mechanism parameter로 RSA-PSS에 실제 솔트 길이를 넘기고, raw ECDSA r||s 결과를 CMS가 기대하는 DER 형태로 바꿉니다
  • THPDFWindowsKeyStorageSignatureProvider는 Windows CNG 키 저장소를 통해 서명합니다. 스마트 카드와 TPM 백업 키를 커버하고 하드웨어 백업 키를 요구할 수도 있습니다

PKCS#11 provider는 다이제스트 입력을 받으므로 RSA PKCS#1 v1.5, RSA-PSS, ECDSA를 다룹니다. Ed25519와 Ed448은 완전한 signed-attributes 메시지에 서명하므로 callback이나 remote provider를 거치는데, 여러분 코드가 Req.Input을 메시지로 토큰이나 서비스에 넘깁니다

HotPDF 플러그형 서명 프로바이더는 하나의 평문 요청 레코드를 주고받고 여섯 값 상태를 반환합니다. 콜백, 원격, PKCS#11 구현에서 보여지며, 개인 키는 끝까지 Delphi 프로세스에 들어오지 않습니다
콜백, 원격, PKCS#11 provider는 같은 요청 레코드로 말하고 같은 여섯 값 상태로 답하며, 이것이 하드웨어 토큰, 원격 HSM 서비스, 국가 eID 카드가 키를 익스포트하는 대신 계속 쥐고 있게 해 줍니다
var
  Provider: THPDFRemoteSignatureProvider;
  Opts: THPDFCMSSignOptions;
  CertDER: TBytes;
  InStream, OutStream: TFileStream;
begin
  CertDER := TFile.ReadAllBytes('signer.cer');   // System.IOUtils. 원격 키의 DER 인증서
  Provider := THPDFRemoteSignatureProvider.Create(
    function(const Req: THPDFSignatureProviderRequest; Attempt: Integer;
      out Signature: TBytes): THPDFSignatureProviderStatus
    begin
      // PostToSigningService는 직접 작성한 HTTP 클라이언트입니다. 타임아웃과 5xx는
      // spsProviderError로, 거절된 승인은 spsCancelled로, 모르는 키는 spsUnsupported로
      Result := PostToSigningService(Req.KeyIdentifier, Req.AlgorithmOID,
        Req.Input, Signature);
    end,
    3,          // RetryLimit: 총 네 번까지 시도합니다
    1048576,    // MaxInputBytes
    65536);     // MaxSignatureBytes
  Opts := HPDFCMSDefaultOptions(palBaseline_B_B);
  Opts.SignatureScheme := cmsRSAPSS;
  Opts.DigestAlgorithm := cmsdaSHA256;
  Opts.PSSSaltLength := 32;
  // contract-unsigned.pdf는 AddSignedSignatureField의 플레이스홀더로 저장되었습니다
  InStream := TFileStream.Create('contract-unsigned.pdf', fmOpenRead or fmShareDenyWrite);
  OutStream := TFileStream.Create('contract-signed.pdf', fmCreate);
  try
    HPDFCMSSignPDFStreamWithProvider(InStream, OutStream, CertDER,
      Provider, 'contracts-2026', Opts);
  finally
    OutStream.Free;
    InStream.Free;
    Provider.Free;
  end;
end;

여섯 상태, 그리고 각각이 촉발해야 할 대응

THPDFSignatureProviderStatus가 여섯 값을 지닌 이유는 올바른 대응이 제각각이기 때문입니다. spsInvalid는 암호학적 실패로 보안 로그에 기록할 사안입니다. spsUnsupported는 provider가 그 알고리즘을 구현하지 않았다는 뜻으로 배포 문제입니다. spsMalformed는 요청이나 응답의 모양이 틀렸다는 뜻입니다. spsProviderError는 재시도하면 성공할 수도 있는 전송·장치 고장입니다. spsCancelled는 사람이 토큰 프롬프트를 거절했다는 뜻이고, 재시도는 그 사람만 짜증 나게 합니다

remote provider는 그 정책을 안에 심어 둡니다. spsProviderError에서만 재시도하고, 매 시도 전에 취소 레지스트리를 확인하며, 전송을 부르기 전에 비었거나 과대한 입력을 spsMalformed로 거부하고, 비었거나 과대한 서명을 지닌 "valid" 응답은 spsMalformed로 격하합니다. 검증 쪽에서는 provider의 응답이 verify 상태로 매핑됩니다. spsInvalid는 svSignatureInvalid로, spsMalformed는 svMalformed로, spsProviderError와 spsCancelled은 모두 svCryptoProviderError로, spsUnsupported는 플랫폼 provider까지 요청을 거절한 뒤 svUnsupportedAlgorithm이 됩니다

provider를 직접 쓴다면 모든 예상 밖 상황을 spsInvalid로 매핑하고 싶은 유혹을 거부하세요. 없는 DLL이나 만료된 서비스 토큰이 "고객의 서명이 위조되었다"는 보고로 바뀌고, 그걸 고객에게 설명할 사람이 필요해집니다. spsInvalid는 수학이 실제로 실패했을 때만 반환하세요. 서명용으로는 비어 있지 않은 서명과 함께만 spsValid를 반환합니다. provider가 바이트 없이 성공을 반환하면 두 진입점 모두 EHPDFCMSError를 일으킵니다

provider의 서명이 파일에 내려앉는 곳

두 함수가 provider를 실제 PDF 바이트에 연결합니다. HPDFCMSBuildSignedDataWithProvider는 여러분이 다른 곳에서 계산한 문서 다이제스트로 detached CMS를 만듭니다. 다이제스트 길이는 Options.DigestAlgorithm과 맞아야 하므로, SHA-384 설정은 48바이트를 기대하며 32바이트 SHA-256 다이제스트는 예외로 거부합니다. HPDFCMSSignPDFStreamWithProvider는 AddSignedSignatureField가 배치한 플레이스홀더에 서명하고, 두 /ByteRange 창을 해시하고, provider에게 signed attributes에 대한 서명을 청해 16진 결과를 /Contents에 패치합니다. CMS가 플레이스홀더보다 커지면 호출은 예외를 일으키고 아무것도 잘라내지 않은 채 출력 스트림의 서명되지 않은 플레이스홀더를 복원합니다

레이아웃은 PFX 기반 서명 경로가 쓰는 것과 같습니다. 16진 서명 창을 건너뛰는 두 범위입니다. verifier는 모든 파일에 이 모양을 요구합니다. 두 범위 사이의 간극에 정확히 이 서명의 /Contents 문자열만 있는지 검사하고, 그렇지 않으면 svInvalidByteRange를 보고합니다. 따라서 PKCS#11 토큰으로 서명된 문서는 PFX 파일에서 서명된 문서와 같은 서명 검증 코드로 검증됩니다. 알고리즘 선택 위에 놓인 프로필 규칙은 Delphi의 PAdES 베이스라인 서명에서, PKCS#11 변환이 다루는 ECDSA 인코딩 함정은 ECDSA CMS 검증과 P1363 서명 형식에서 다룹니다

두 HotPDF provider 서명 진입점: 외부 계산 다이제스트용 HPDFCMSBuildSignedDataWithProvider와 배치된 플레이스홀더용 HPDFCMSSignPDFStreamWithProvider가, 검증기가 먼저 확인하는 /ByteRange 구간을 보존하는 하나의 CMS 레이아웃으로 수렴
어느 진입점이든 16진수 창 주위에 두 커버 범위의 동일한 서명 형태를 쓰므로, provider가 서명한 문서는 PFX 파일에서 서명한 것과 같은 byte layout을 지닙니다

독자를 고립시키지 않는 포스트 퀀텀 서명 배포

ML-DSA를 구현하지 않은 PDF reader는 그런 서명을 검증할 수 없다고 보고하고, 문서를 보는 사람은 초록 체크 대신 경고를 봅니다. 그래서 마이그레이션은 일정 조율 문제가 됩니다. 오늘의 validator가 판정할 서명으로 RSA나 ECDSA를 유지하세요. 정책이 양자내성 증거를 요구하는 곳에서는 EnsureMLDSAExtensions로 확장을 선언하고 클래식 서명 곁에 ML-DSA 지원 signer가 만든 두 번째 서명을 추가합니다. 그 서명은 여러분의 FIPS 204 signer에서 나옵니다. HotPDF CMS builder는 ML-DSA 서명을 만들지 않고, 라이브러리의 몫은 그것을 검증하는 것입니다. 주 서명은 문서를 소비하는 시스템들이 검증할 수 있게 되었을 때만 옮기세요

그 계획에서 HotPDF는 Delphi에서 통제하는 양 끝을 커버합니다. CMS builder는 위의 어느 provider로든 RSA PKCS#1 v1.5, RSA-PSS, ECDSA, Ed25519, Ed448로 서명하고, verifier는 한 호출로 ML-DSA, EdDSA, 클래식 알고리즘을 검사하며 실제로 발견된 OID를 결과에 기록합니다. HotPDF는 외부 PDF 런타임이 필요 없는 Delphi와 C++Builder용 native VCL PDF 컴포넌트라서 서명·검증 코드가 여러분 실행 파일 안으로 컴파일됩니다. 에디션, 서명 기능 목록, 평가판 다운로드는 HotPDF Delphi PDF 컴포넌트 페이지에 있습니다