기술 문서

Delphi와 CryptoAPI로 만드는 자체 서명 테스트 인증서

PDFlibPas의 PLCreateSelfSignedCertificate 함수는 자체 서명 RSA/SHA-256 인증서를 만들어, 모든 Windows 컴퓨터에 이미 설치된 Win32 CryptoAPI만 사용해 개인키를 포함한 채로 비밀번호로 보호된 PFX 파일에 곧바로 내보낸다. 외부 도구도, 인증 기관도, 수동 makecert나 OpenSSL 단계도 필요 없다: 함수 호출 한 번으로 서명 테스트를 구동하기에 충분한 인증서 하나를 얻는다

이 함수를 가질 가치가 있게 만드는 시나리오는 거의 항상 CI 파이프라인이다. 서명 스모크 테스트는 그 뒤에 진짜 개인키가 있는 진짜 PFX가 필요하며, 이를 저장소에 커밋하는 것은 그 자체로 보안 문제다. 커밋된 개인키는 그 커밋이 도착하는 순간부터 유출된 개인키이기 때문이다. 빌드 스크립트에서 makecert.exe나 OpenSSL 호출로 넘기는 것도 통하긴 하지만, 그러면 파이프라인은 설치되어야 하고 PATH에서 찾을 수 있어야 하며 모든 빌드 에이전트에서 버전이 일관되게 유지되어야 하는 도구에 의존하게 된다. 테스트를 실행하는 것과 같은 프로세스 안에서 Windows가 이미 제공하는 같은 Win32 CryptoAPI 호출로 인증서를 생성하면 그 의존성이 완전히 사라진다

PLCreateSelfSignedCertificate는 실제로 무엇을 만들어내는가?

PLCreateSelfSignedCertificate는 자체 서명 RSA 인증서와 그 개인키를 담은, sha256RSA로 서명된 비밀번호 보호 PFX 파일을 만든다. 다섯 개의 매개변수 SubjectName, PFXFileName, PFXPassword, ValidDays, KeyBits로 구동되며, 순수한 Boolean 성공 플래그를 반환한다. SubjectName'CN=Alice, O=Example' 같은 완전한 X.500 문자열을 받아들이며, = 기호가 전혀 없는 단순한 이름은 자동으로 CN=이 앞에 붙는다. ValidDays가 1 미만이면 365로 대체되고, KeyBits가 1024에서 16384 범위 밖이면 2048로 대체된다. PDFlibPas는 v3.224.0부터 이 함수를 제공해 왔으며, Delphi 유닛뿐 아니라 DLL과 ActiveX 표면을 통해서도 도달할 수 있다. 그리고 그 자체 문서 주석은 이것이 어디서 쓸모없어지는지 솔직하게 밝힌다: 누군가 명시적으로 설치하지 않는 한 모든 주류 뷰어는 자체 서명 인증서를 신뢰할 수 없다고 표시하므로, 이것이 만들어내는 것을 코드 경로를 실행해 보기 위한 인증서로 취급하되, 팀 밖의 누군가가 신뢰하도록 요구받아서는 안 되는 서명으로 취급하지 말라

var
  Success: Boolean;
begin
  Success := PLCreateSelfSignedCertificate(
    'CN=PDFlibPas CI Test, O=Example Corp',
    'ci-test-signer.pfx',
    'a-strong-throwaway-password',
    365,     // ValidDays
    2048);   // KeyBits
  if not Success then
    raise Exception.Create('Self-signed certificate generation failed');
end;

CryptGenKey는 왜 키 길이를 flags 매개변수에 인코딩하는가?

CryptGenKey는 서로 무관한 두 설정을 하나의 dwFlags 매개변수에 채워 넣는다. 하위 워드는 CRYPT_EXPORTABLE을 포함한 동작 플래그를 담고, 상위 워드는 RSA 키 교환 키의 경우 비트 단위로 요청된 키 길이를 담는다. 2048을 그저 또 다른 플래그인 것처럼 넘기면 이는 대신 하위 워드에 떨어지는데, 거기서는 CryptoAPI가 정의하는 어떤 동작 플래그와도 일치하지 않으므로, 이 호출은 호출자가 요청했다고 생각한 길이가 아니라 프로바이더가 폴백하는 기본 길이로 키를 생성한다. 실제 2048비트 RSA 키를 얻으려면 먼저 그 숫자를 상위 워드로 시프트해야 한다

// Key length lives in the upper 16 bits of the CryptGenKey flags;
// the low word carries behavior flags such as CRYPT_EXPORTABLE.
if not CryptGenKey(hProv, AT_KEYEXCHANGE,
    (Cardinal(KeyBits) shl 16) or CRYPT_EXPORTABLE, hKey) then
  Exit;

CRYPT_EXPORTABLE을 빠뜨리면 어떻게 되는가?

같은 플래그 값에서 CRYPT_EXPORTABLE을 빼도 CryptGenKey는 여전히 성공하지만, CSP 수준에서 생성된 개인키를 내보낼 수 없는 것으로 표시한다. 다운스트림의 모든 것도 계속 성공을 보고한다: CertCreateSelfSignCertificate는 유효한 인증서 컨텍스트를 반환하고, EXPORT_PRIVATE_KEYS와 함께 호출된 PFXExportCertStoreEx조차 어쨌든 성공해 열리고 파싱되며 완전히 정상으로 보이는 PFX 파일을 작성한다. 그 안에 담기지 않는 것은 개인키인데, CSP가 그것을 키 컨테이너 밖으로 내보내는 것을 거부했기 때문이며, PFXExportCertStoreEx는 그 거부를 전체 내보내기를 실패시킬 이유로 절대 취급하지 않는다

이 실패는 나중에야, 그리고 완전히 다른 곳에서야 드러난다: 서명 호출이 그 PFX를 열어, 개인키가 붙어 있지 않은 인증서를 발견하고, 상류에서 세 계층 위에 있는 빠진 플래그가 아니라 손상되거나 잘못된 PFX에서 얻을 법한 오류를 정확히 그대로 보고한다. 서명 쪽에서만 디버깅하는 사람은 실제 버그가 완전히 다른 함수 호출 안의, 어쩌면 완전히 다른 빌드 스크립트 안의 키 생성 시점에 빠진 비트 하나라는 것을 깨닫기 전에 잘못된 파일에 오후 시간을 통째로 태울 수 있다

CryptAcquireContextW와 인증서 사이에서 ProvType은 왜 일치해야 하는가?

ProvType이 일치해야 하는 이유는 CertCreateSelfSignCertificate가 새 인증서의 개인키를 CRYPT_KEY_PROV_INFO 레코드를 통해 해석하기 때문이다. 그 레코드 안의 필드 하나인 ProvType은 키 컨테이너가 열렸을 때 CryptAcquireContextW에 넘겨진 것과 정확히 같은 CSP 타입 값 — PDFlibPas의 구현에서는 숫자로 24인 PROV_RSA_AES — 을 명시해야 한다. ProvType을 0으로, 또는 컨테이너가 실제로 속한 것이 아닌 다른 어떤 프로바이더 상수로 설정해도 인증서는 여전히 만들어질 수 있지만, 개인키로 되돌아가는 그 기록된 링크는 더 이상 그것을 담고 있는 컨테이너로 해석되지 않으며, 이는 나중에 인증서의 실제 암호학적 콘텐츠와는 아무 관련 없는 서명이나 내보내기 실패로 드러난다

// The provider type used to open the key container must match the
// provider type recorded in the certificate's key-provider info.
CryptAcquireContextW(hProv, PWideChar(Container), nil,
  PROV_RSA_AES, CRYPT_NEWKEYSET);
// ... generate the key, build the subject name blob, then:
KeyProvInfo.ProvType := PROV_RSA_AES;   // same constant, both call sites

모두 합치기: GUID 컨테이너에서 비밀번호로 보호된 PFX까지

PLCreateSelfSignedCertificate 내부의 호출 체인은 하나의 직선을 따른다: 동시에 실행되는 CI 실행들이 컨테이너 이름을 두고 절대 충돌하지 않도록 새로 생성된 GUID의 이름을 딴 새 키 컨테이너를 열고, 위에서 다룬 두 플래그로 그 안에 RSA 키 쌍을 생성하고, CertStrToNameW를 통해 SubjectName을 X.500 이름 블롭으로 인코딩하고, ValidDays로부터 계산되어 순수한 SYSTEMTIME 형태의 구조체로 전달되는 유효 기간과 함께 CertCreateSelfSignCertificate를 호출한다. 결과로 나온 인증서 컨텍스트는 CertOpenStoreCERT_STORE_PROV_MEMORY로 열린 메모리 내 인증서 저장소로 들어가는데, 이는 오로지 PFXExportCertStoreEx가 내보낼 저장소를 갖도록 하기 위함이다. 그 API는 순수한 인증서 컨텍스트가 아니라 저장소 핸들에 대해 동작하기 때문이다

// Each call opens a throwaway container named after a fresh GUID:
CryptAcquireContextW(hProv, PWideChar(Container), nil,
  PROV_RSA_AES, CRYPT_NEWKEYSET);
// ... generate the key, self-sign the certificate, export the PFX ...
// then delete the container once the PFX holds its own copy of the key:
CryptAcquireContextW(hProv, PWideChar(Container), nil,
  PROV_RSA_AES, CRYPT_DELETEKEYSET);

PFXExportCertStoreEx 자체는 일반적인 Win32 2단계 관례를 따른다: 길이 0짜리 버퍼로 한 번 호출해 PFX가 몇 바이트를 필요로 하는지 알아낸 다음, 그만큼 할당하고, 버퍼를 채우기 위해 다시 호출한다. 바이트가 디스크에 저장되면, PDFlibPas는 임시 키 컨테이너를 그대로 남겨두는 대신 CRYPT_DELETEKEYSET으로 삭제하는데, PFX가 이미 그 컨테이너가 담고 있던 키 자재의 모든 바이트를 자체 사본으로 가지고 있기 때문이다. 이 정리를 건너뛰면 PLCreateSelfSignedCertificate를 호출할 때마다 호출한 사용자의 프로필에 고아가 된 GUID 이름의 키 컨테이너가 남으며, 이는 정확히 모든 빌드마다 이 함수를 실행하는 CI 에이전트가 아무도 알아차리기 전에 몇 달에 걸쳐 쌓아 올릴 종류의 누수다

자체 서명 인증서를 프로덕션 서명에 써도 안전한가?

아니다: 자체 서명 인증서는 서명 코드 경로를 실행해 보는 데는 안전하지만, 팀 밖의 누군가가 신뢰하리라 기대되는 서명에는 안전하지 않다. 신뢰 당사자의 소프트웨어가 이미 신뢰하는 루트로 이를 되돌려 연결해 줄 것이 아무것도 없기 때문이다. 이런 PFX의 자연스러운 다음 단계는 실제 서명 호출이며, 이는 Delphi에서 PDFlibPas로 컴플라이언스 및 서명 워크벤치 만들기에서 다루는데, 여기서 이런 방식으로 만들어진 PFX는 PDF/A 프리플라이트와 ByteRange 감사도 함께 실행하는 파이프라인의 서명 절반을 구동한다. 하지만 서명은 인증서 주위에 있는 것의 절반일 뿐이고, 나머지 절반은 정확히 자체 서명된 리프가 실패하도록 되어 있는 지점이다: Delphi에서 PDFlibPas로 하는 PAdES 서명과 검증은 적합성 검증기가 실행하는 신뢰 체인 검사를 다루며, 그 체인을 신뢰할 수 있는 루트까지 거슬러 올라가는 검증기는 이 함수가 5분 전에 아무것도 없는 데서 지어낸 인증서를 신뢰할 이유가 전혀 없다

PLCreateSelfSignedCertificate는 Delphi와 C++Builder용 PDFlibPas PDF 라이브러리의 인증서 및 서명 API 중 하나이며, 정확히 여기서 설명한 공백을 위해 존재한다: 뒤에 진짜 키 쌍이 필요하고 그것을 생성할 외부의 아무것도 필요로 하지 않는 서명 테스트를 위해서다