기술 문서

HotPDF 인증서 저장소 PDF 서명: CNG와 CAPI의 바이트 순서 차이

HotPDF는 다이제스트를 Windows에 직접 전달하는 방식으로 Windows 인증서 저장소에 이미 등록된 인증서를 이용해 PDF에 서명하며, Windows는 이 요청을 두 가지 개인키 백엔드 중 하나를 통해 처리한다: RSA 서명을 빅엔디언으로 반환하는 CNG, 또는 리틀엔디언으로 반환하는 레거시 CryptoAPI CSP다. 이 둘을 혼동하면 HotPDF가 삽입하는 CMS 서명은 실제로 응답한 백엔드 기준으로 바이트 순서가 뒤집힌 상태가 되어, 문서 바이트 자체는 전혀 손대지 않았음에도 표준을 준수하는 검증기가 서명을 유효하지 않다고 판단하게 된다

이 한 문장 뒤에는 서로 무관한 두 가지 문제가 숨어 있으며, HotPDF의 시스템 인증서 서명기는 어떤 서명이든 수행하기 전에 이 둘을 모두 해결해야 한다. 바이트 순서 불일치는 조용히 발생한다: 서명 호출은 여전히 True를 반환하고, PDF도 여전히 열리며, 뷰어가 CMS 구조를 파싱해 거부할 때가 되어서야 실패가 드러난다. 두 번째 문제는 요란하고 C++Builder에 특화되어 있다: crypt32의 함수 여섯 개가 링크를 거부하는데, RAD Studio가 제공하는 임포트 라이브러리가 이들을 export하지 않기 때문이다. PFX 파일로만 서명한다면 이 두 문제 중 어느 것도 발생하지 않는데, 이 때문에 PFX 기반 원콜 서명에서 IT 부서가 사용자 프로필에 이미 설치해 둔 인증서로 전환하는 개발자들이 흔히 걸려 넘어진다

저장소에서 인증서 선택하기

HotPDF는 이 경로를 HPDFSignPDFStreamWithSystemCertificateHPDFSignPDFFileWithSystemCertificate로 노출하며, 둘 다 THPDFCertificateStoreSelector 레코드로 제어된다: Location(cslCurrentUser 또는 cslLocalMachine), StoreName(기본값은 개인 저장소 'MY'), SHA-1 Thumbprint, 그리고 AllowUI 플래그다. Thumbprint는 내부적으로 정규화되므로, 인증서 관리자 UI에서 그대로 복사해 온 하이픈이나 공백은 비교 전에 제거된다

var
  Selector: THPDFCertificateStoreSelector;
  Options: THPDFCMSSignOptions;
begin
  Selector := THPDFCertificateStoreSelector.Default;  // cslCurrentUser, store 'MY'
  Selector.Thumbprint := 'A1B2C3D4E5F6A7B8C9D0E1F2A3B4C5D6E7F8A9B0';
  Selector.AllowUI := False;

  Options := HPDFCMSDefaultOptions(palBaseline_B_B);
  if not HPDFSignPDFFileWithSystemCertificate('invoice.pdf',
    'invoice-signed.pdf', Selector, Options) then
    raise Exception.Create('Certificate-store signing failed');
end;

AllowUI = False는 겉보기보다 훨씬 중요한데, 이 값이 CRYPT_ACQUIRE_SILENT_FLAG로 그대로 매핑되고 Windows가 이를 문자 그대로 준수하기 때문이다: 일치하는 인증서의 개인키가 스마트카드나 토큰에 있고 Windows가 아직 캐시하지 않은 PIN 입력이 필요한 경우, CryptAcquireCertificatePrivateKey는 서비스 프로세스일 수도 있는 상황에서 대화상자를 띄우는 대신 그냥 실패한다. 이 실패는 EHPDFCMSError로 즉시 나타나 눈에 띄지만, 실제 원인이 아무도 입력하지 않을 PIN을 기다리며 대기 중인 토큰인데도 "인증서를 찾을 수 없음"으로 오독하기 쉽다

CNG와 CAPI는 왜 바이트 순서가 다른가?

어느 백엔드가 응답했는지는 추측이 아니다: CryptAcquireCertificatePrivateKeyKeySpec 출력 매개변수를 통해 직접 알려주며, HotPDF의 서명기는 이 값 하나로 분기한다. CNG Key Storage Provider 키는 KeySpec이 센티널 값인 CERT_NCRYPT_KEY_SPEC($FFFFFFFF)로 반환되고, 그 외에는 전통적인 CryptoAPI CSP 키다. 현재 버전 Windows에서 발급되거나 임포트된 대부분의 개인 인증서는 호환성을 위한 레거시 CSP 셰임이 여전히 존재함에도 CNG로 귀결되는데, 이 때문에 HotPDF는 어느 값이 반환되었는지 확인하기 전에 CRYPT_ACQUIRE_ALLOW_NCRYPT_KEY_FLAGCRYPT_ACQUIRE_PREFER_NCRYPT_KEY_FLAG를 함께 요청한다

두 백엔드는 단순히 다른 함수(CNG 키는 NCryptSignHash, CSP 키는 CryptSignHashA)를 호출하는 데 그치지 않고, 원시 RSA 서명을 서로 반대의 바이트 순서로 돌려준다. CNG의 출력은 이미 PKCS#1이 요구하는 형태와 일치한다: 최상위 바이트부터 시작하는 빅엔디언 옥텟 문자열로, RFC 8017의 I2OSP 변환이 만들어내는 것이자 ISO 32000-1 §12.8.3에 따라 CMS SignerInfo(RFC 5652)의 signature 필드가 필요로 하는 형태 그 자체다. 반면 CryptoAPI의 CryptSignHash는 서명을 리틀엔디언으로 반환하는데, 이는 클래식 CSP가 내부적으로 큰 수를 표현하던 방식에서 비롯된 문서화된 특이 동작이다. CAPI 경로에서 바이트 반전을 건너뛰면 서명의 모든 바이트가 잘못된 위치에 놓이게 된다. RSA 연산 자체는 여전히 올바르지만, 검증기가 읽는 옥텟 문자열은 PKCS#1이 정의하는 것과 다르다

// CryptSignHashA returns the RSA signature least-significant byte first;
// CMS/PKCS#7 (ISO 32000-1 Section 12.8.3) needs it most-significant byte first.
for I := 0 to (Length(Signature) div 2) - 1 do
begin
  Temp := Signature[I];
  Signature[I] := Signature[High(Signature) - I];
  Signature[High(Signature) - I] := Temp;
end;

커스텀 서명 콜백은 어떤가?

HotPDF 내장 인증서 저장소 서명기를 우회하는 경우에도 동일한 바이트 순서 규칙이 그대로 적용된다. HPDFCMSSignPDFStreamWithExternalSignerreference to function(const SignedAttributesSHA256: TBytes): TBytes 타입의 클로저인 THPDFCMSSignDigestCallback을 받아, HSM이나 스마트카드 미들웨어 스택 등 Windows 저장소가 키 핸들을 내주지 못하는 대상을 통한 서명을 지원한다. 이 콜백 뒤에 어떤 백엔드가 있든, 반환하는 바이트는 HotPDF가 이를 CMS 구조에 접어넣기 전에 반드시 빅엔디언 순서여야 한다

Signer :=
  function(const SignedAttributesSHA256: TBytes): TBytes
  begin
    if UsesCngKeyStorageProvider then
      Result := SignWithMyCngKey(SignedAttributesSHA256)       // already big-endian
    else
      Result := ReverseBytes(SignWithMyLegacyToken(SignedAttributesSHA256));
  end;
HPDFCMSSignPDFStreamWithExternalSigner(InputStream, OutputStream,
  CertificateDER, Signer, Options);

여기서 한 가지 경계는 명확히 짚어둘 가치가 있다: HotPDF의 두 내장 서명 경로, 즉 NCryptSignHash를 통한 PKCS#1 패딩 방식의 CNG와 CryptSignHashA를 통한 CAPI 모두 32바이트 SHA-256 다이제스트에 서명하는 RSA 키를 대상으로 한다. 어느 쪽도 ECDSA 서명 형식은 처리하지 않는다. 개인키가 EC 기반인 인증서는 HPDFCMSSignPDFStreamWithExternalSigner를 직접 구현한 서명기가 필요하며, 고정 길이 RSA 바이트 문자열을 가정하는 대신 CMS가 기대하는 방식으로 ECDSA 서명을 인코딩해야 한다. 따라서 EC 인증서가 프로비저닝된 토큰에 대해 내장 인증서 저장소 서명기가 알아서 처리해 줄 것이라 기대해서는 안 된다

C++Builder는 왜 CertOpenStore 링크에 실패하는가?

RAD Studio 기본 C++Builder 임포트 라이브러리인 import32.libCertOpenStore를 비롯한 다섯 개의 이웃 함수, 즉 CertEnumCertificatesInStore, CertGetCertificateContextProperty, CertFreeCertificateContext, CertCloseStore, CryptAcquireCertificatePrivateKey를 export하지 않기 때문이다. Delphi 빌드에서는 이 문제가 전혀 나타나지 않는데, dcc32/dcc64가 정적 external 'crypt32.dll' 임포트를 PE 임포트 테이블로 곧바로 해석하기 때문이다. C++Builder는 다르다: Delphi 컴파일러가 패키지 빌드를 위해 OMF .obj를 만들어내고 ilink32가 이를 링크하는데, 이 시점에서 동일한 external 선언은 명령줄에 임포트 라이브러리가 지정되기를 기다리는 미해결 심볼일 뿐이다. 여섯 심볼 모두를 export하는 완전한 crypt32.lib가 있는 Windows SDK의 psdk 디렉터리로 링커를 가리켜도 해결되지 않는다: ilink32는 명령줄에 실제로 이름을 올린 임포트 라이브러리(기본값 import32.lib cp32mt.lib)만 링크하며, 검색 경로를 추가한다고 해서 그 경로에서 추가로 무언가를 끌어오지는 않는다. import32.libtdump를 실행해 보면 이 격차가 바로 확인된다: CertOpenStore에 대한 히트는 0건인 반면, SDK의 crypt32.lib에는 정확히 6건이 있다

HotPDF는 라이브러리 내 다른 곳에서 인증서 열거를 처리할 때와 동일한 방식으로 이 문제를 해결한다: 링커에게 이 심볼들을 요청하는 대신 런타임에 로드한다. 내부 THPDFCryptoProcs 레코드는 crypt32.dll 핸들 하나, advapi32.dll 핸들 하나, 그리고 11개의 함수 포인터 필드를 갖는다. LoadCryptoProcsHPDFSignPDFStreamWithSystemCertificate 시작 시점에 두 DLL을 로드하고 GetProcAddress로 모든 엔트리 포인트를 정확히 한 번씩 해석하며, 무언가 빠졌다면 서명 흐름 깊숙한 곳에서 액세스 위반으로 나중에 실패하는 대신 즉시 EHPDFCMSError를 발생시킨다

type
  TCertOpenStoreFn = function(lpszStoreProvider: Pointer; dwEncodingType: DWORD;
    hCryptProv: NativeUInt; dwFlags: DWORD; pvPara: Pointer): HCERTSTORE; stdcall;
var
  Crypt32Handle: HMODULE;
  CertOpenStore: TCertOpenStoreFn;
begin
  Crypt32Handle := LoadLibrary('crypt32.dll');
  if Crypt32Handle = 0 then
    raise Exception.Create('crypt32.dll could not be loaded');
  @CertOpenStore := GetProcAddress(Crypt32Handle, 'CertOpenStore');
  // ... use CertOpenStore, then FreeLibrary(Crypt32Handle) when signing returns
end;

로딩은 각 헬퍼 내부에서 지연 방식으로 이루어지는 대신 호출당 한 번만 일어나는데, CNG와 CAPI 사이를 선택하는 클로저가 로드된 함수 테이블을 값으로 캡처해 HPDFCMSSignPDFStreamWithExternalSigner로의 콜백을 포함한 전체 서명 흐름 동안 살아 있어야 하기 때문이다. 두 DLL 핸들 모두 서명이 끝나거나 예외를 발생시킨 시점에 가장 바깥쪽 finally 블록에서 해제된다. 이 어느 것도 공개 표면을 건드리지 않는다: HPDFSignPDFStreamWithSystemCertificate, HPDFSignPDFFileWithSystemCertificate, THPDFCertificateStoreSelector는 이전과 정확히 동일한 시그니처를 유지하므로, 이 수정을 적용하는 것은 기존 호출자 입장에서 코드 변경이 아니라 재빌드만으로 충분하다

이 글이 다루지 않는 것

바이트 순서와 C++Builder 링크 문제를 바로잡으면 검증기가 파싱할 수 있고 산술적으로 확인 가능한 서명을 가진 CMS SignerInfo가 만들어지지만, 그 검증기가 그 뒤에 있는 인증서를 신뢰해야 하는지에 대해서는 아무것도 말해주지 않는다. 체인 구성, 폐기 확인, 타임스탬프 정책은 CMS 옵션을 통해 그 위에 얹히는 별개의 관심사이지, 바이트 순서가 올바르다고 해서 공짜로 얻어지는 것이 아니기 때문이다. 암호화 자체만큼 중요한 두 가지 관리 세부사항이 있다: 인증서 조회로 반환된 PCCERT_CONTEXT는 저장소가 닫히기 전에 반드시 CertFreeCertificateContext로 해제해야 하며, 획득한 CNG 또는 CSP 키 핸들은 API가 호출자에게 소유권이 있다고 알려주는 경우 항상 그 백엔드 고유의 호출을 통해 해제해야 하고 다른 쪽 호출로 해제해서는 안 된다. 이 모든 과정을 거친 뒤 얻은 svValid 결과가 예상보다 좁은 의미로 보인다면, PDF 서명 검증에 관한 글에서 이 플래그가 정확히 무엇을 보장하고 무엇을 보장하지 않는지를 다룬다. 인증서가 이 과정 내내 Windows의 관리하에 남아 있기 때문에, 인증서 저장소 서명은 공격 표면 전체를 우회한다: PKCS#12 파일을 파싱할 필요도, ASN.1을 직접 순회할 필요도 없다. 이 문제는 대신 PFX 파일 서명 경로를 위해 HotPDF의 PKCS#12 및 ASN.1 강화가 다룬다

인증서 저장소 서명, PFX 서명, 외부 서명 콜백은 Delphi와 C++Builder용 HotPDF PDF 컴포넌트 내부의 동일한 CMS/PKCS#7 파이프라인으로 들어가는 세 개의 문이며, 어느 문을 고를지는 대체로 개인키를 누가 쥐고 있느냐, 즉 자신의 프로세스인지, PFX 파일인지, Windows 자체인지에 달려 있다