기술 문서

Delphi PDF 인증서 암호화: RSA-OAEP와 ECDH

HotPDF는 ISO 32000 공개키 security handler를 통해 특정 인증서 보유자를 위해 PDF를 암호화합니다. EnablePubKeyEncryption이 20바이트 무작위 seed를 받고, 각 수신자는 자기 CMS 엔벨로프를 받습니다. RSA 키는 AddPubKeyRecipientCertificate가(RSA-OAEP key transport), 타원곡선 키는 AddPubKeyAgreementRecipientWithSecret이(P-256, P-384, P-521, X25519, X448 위의 ECDH) 만들어 줍니다. 아무도 비밀번호를 공유하지 않습니다. 맞는 개인 키를 쥔 사람이 파일을 엽니다

유스 케이스는 늘 같은 이야기의 변주입니다. 분기 감사 패키지가 외부 리뷰어 셋에게 가는데, 법무는 세 사람 모두 읽기를 원하고 한 명만 인쇄가 허용되며, 첨부 파일 옆 이메일 스레드에 비밀번호가 누워 있는 걸 아무도 원하지 않습니다. 비밀번호 암호화로는 이걸 표현할 수 없습니다. 인증서 암호화는 됩니다. 각 수신자가 이미 쥐고 있는 키로 문서를 열고, 각 수신자의 엔벨로프 안에 서로 다른 권한 세트를 담을 수 있기 때문입니다

인증서 기반 PDF 암호화는 비밀번호와 무엇이 다를까?

공개키 암호화 PDF는 사람이 입력하는 어떤 것도 아니라 무작위 seed 더하기 모든 수신자 엔벨로프의 정확한 바이트로 파일 키를 파생합니다. 핸들러는 ISO 32000-1 §7.6.4(ISO 32000-2는 §7.6.5)에 기술되고, 엔벨로프는 RFC 5652가 정의하는 CMS EnvelopedData 구조입니다. HotPDF는 /SubFilter /adbe.pkcs7.s5와 함께 /Filter /Adobe.PubSec을 씁니다. AES-256이라면 /V 5, /CF 아래 /CFM /AESV3인 /DefaultCryptFilter 엔트리를 뜻하고, /Recipients 배열은 그 crypt filter 안에 삽니다. 각 엔벨로프는 24바이트를 암호화합니다. 20바이트 seed 뒤에 그 수신자의 32비트 권한 워드가 따라옵니다. 암호화 딕셔너리의 /P 값은 자리표시자일 뿐입니다. 진짜 권한은 각 엔벨로프 안을 여행하기 때문입니다. 로드 시점에 리더는 엔벨로프 하나를 풀어 seed를 회복하고, seed를 /Recipients 순서의 모든 엔벨로프와 함께 해시해서(AES-256은 SHA-256, 구형 암호는 SHA-1) 파일 키를 다시 만듭니다. 이 모델과 평범한 비밀번호 사이에서 아직 고민 중이라면 AES-256 비밀번호 암호화와 권한 플래그 가이드가 그 트레이드오프의 반대편을 다룹니다

HotPDF 공개키 암호화 다이어그램: EnablePubKeyEncryption이 20바이트 seed를 고정하고, 각 CMS EnvelopedData 엔벨로프는 /Filter /Adobe.PubSec과 /SubFilter /adbe.pkcs7.s5, /CFM /AESV3 안에서 그 20바이트 더하기 32비트 권한 워드 하나를 암호화하며, 리더는 엔벨로프 하나를 풀어 seed를 회복해 배열 순서의 모든 /Recipients 엔트리와 함께 해시해 파일 키를 재구성합니다
암호화 딕셔너리의 /P 값은 자리표시자일 뿐입니다. 진짜 권한은 각 엔벨로프 안을 여행하고, 다이제스트가 도는 배열을 하류에서 재정렬하거나 재인코딩해서는 안 됩니다

EnablePubKeyEncryption으로 RSA 수신자 쓰기

RSA 인증서라면 aes256으로 EnablePubKeyEncryption을 호출한 다음, BeginDoc 전에 DER 인코딩된 인증서마다 AddPubKeyRecipientCertificate를 한 번씩 호출합니다. 헬퍼는 OAEP 다이제스트와 MGF1 다이제스트용 THPDFRSAOAEPHash 값(rohSHA256, rohSHA384, rohSHA512)으로 프로세스 안에서 RSAES-OAEP 엔벨로프를 만들고, 엔벨로프 콘텐츠를 AES-256-CBC로 암호화합니다

uses
  System.SysUtils, System.IOUtils, HPDFDoc, HPDFCrypt, HPDFRSA;

procedure WriteAuditPack(const OutFile: string);
var
  Pdf: THotPDF;
  Seed: AnsiString;
begin
  SetLength(Seed, 20);                      // AES-256이라도 정확히 20바이트
  AESGenerateRandomBytes(@Seed[1], Length(Seed));
  Pdf := THotPDF.Create(nil);
  try
    Pdf.AutoLaunch := False;
    Pdf.FileName := OutFile;
    Pdf.EnablePubKeyEncryption(Seed, aes256, True);   // 기본 키 타입은 aes128
    // 리뷰어 A는 인쇄 가능, 리뷰어 B는 읽기와 추출만 가능
    Pdf.AddPubKeyRecipientCertificate(TFile.ReadAllBytes('reviewer-a.cer'),
      [prPrint, prPrint12bit, prExtractContent], rohSHA256, rohSHA256);
    Pdf.AddPubKeyRecipientCertificate(TFile.ReadAllBytes('reviewer-b.cer'),
      [prExtractContent]);
    Pdf.BeginDoc;
    Pdf.CurrentPage.SetFont('Arial', [], 11);
    Pdf.CurrentPage.TextOut(72, 720, 0, 'Q3 audit pack');
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

그 목록의 세 가지 디테일이 하중을 겁니다. 첫째, seed 길이는 AES-256을 포함한 모든 키 타입에서 20바이트로 고정입니다. 다른 길이면 EnablePubKeyEncryption이 예외를 일으킵니다. 둘째, EnablePubKeyEncryption의 기본값은 aes128이고 두 인증서 헬퍼 모두 키 타입이 aes256이 아니면 실행을 거부하므로, 두 번째 인자를 잊으면 "certificate envelopes require aes256" 예외를 받습니다. 구형 암호(k40, k128, aes128)도 여전히 동작하지만, 다른 곳에서 만든 엔벨로프를 AddPubKeyRecipient로 넘길 때뿐입니다. 셋째, AES-256 공개키 암호화는 PDF 2.0 기능이므로 HotPDF는 문서 버전을 자동으로 2.0으로 올립니다. 더 낮은 버전에 StrictVersionLock이 걸려 있으면 EnablePubKeyEncryption은 아무것도 활성화하지 않은 채 반환하고, 실패는 다음 줄에 "call EnablePubKeyEncryption first"로 나타날 뿐입니다. 증분 업데이트 도중 암호화를 바꾸면 EInvalidOpException이 곧장 납니다

ECDH 수신자 추가: P-256, P-384, P-521, X25519, X448

타원곡선 인증서에는 AddPubKeyAgreementRecipientWithSecret이 CMS key-agreement 수신자(RFC 5753의 KARI 구조인 KeyAgreeRecipientInfo, X25519와 X448은 RFC 8418 프로파일)를 기록하고 프로세스 안에서 ECDH 공유 시크릿을 계산합니다. 곡선은 THPDFPubKeyAgreementScheme 값으로 고릅니다. pkasECDHP256, pkasECDHP384, pkasECDHP521, pkasX25519, pkasX448입니다. 스킴은 인증서의 키와 맞아야 하고, 아니면 호출이 "Certificate key does not match the requested agreement scheme"를 일으킵니다. 내부적으로는 각 엔벨로프가 새 무작위 32바이트 UKM과 stdDH KDF로 파생한 key-encryption key(P-256과 X25519는 SHA-256, P-384는 SHA-384, P-521과 X448은 SHA-512), 그리고 RFC 3394에 정의된 AES-256 key wrap을 받습니다. 공유 시크릿 자체는 플랫폼 crypto provider를 전혀 거치지 않는 순수 Pascal 곡선 코드에서 나옵니다. 그 계층을 어떻게 만들고 검증했는지는 순수 Pascal NIST 곡선 연산 기사가 설명합니다. Montgomery 곡선이라면 에페머럴 키 쌍 전체를 로컬에서 만들 수 있습니다:

uses
  System.SysUtils, System.IOUtils, HPDFDoc, HPDFCrypt, HPDFPubSec,
  HPDFKeyAgreement;

procedure AddLegalRecipient(Pdf: THotPDF);
var
  Scalar, OriginatorPublic: TBytes;
begin
  // 엔벨로프마다 새 에페머럴 스칼라, 클램핑은 ladder 안에서 일어남
  SetLength(Scalar, 32);
  AESGenerateRandomBytes(@Scalar[0], Length(Scalar));
  try
    OriginatorPublic := HPDFX25519PublicFromScalar(Scalar);
    Pdf.AddPubKeyAgreementRecipientWithSecret(
      TFile.ReadAllBytes('legal-x25519.cer'),
      [prPrint, prExtractContent], pkasX25519,
      OriginatorPublic, Scalar,
      []);   // OwnPublicPoint: NIST 곡선에서만 의미 있음
  finally
    HPDFSecureClearBytes(Scalar);
  end;
end;

NIST 곡선은 호출자에게 더 요구합니다. HotPDF가 공개키 헬퍼를 제공하는 건 X25519와 X448뿐입니다(HPDFX25519PublicFromScalar, HPDFX448PublicFromScalar). 그래서 P-256, P-384, P-521은 자체 도구로 에페머럴 키 쌍을 만들고, 필드 크기와 정확히 같은 빅엔디안 스칼라(32, 48, 66바이트)와 그에 맞는 비압축 0x04||X||Y 점을 OriginatorPublicKey로 넘겨야 합니다. HotPDF는 수신자 점을 곡선 방정식으로 검증하지만, originator 공개키가 실제로 스칼라에 속하는지까지는 확인할 수 없습니다. 어긋난 반쪽끼리도 완벽하게 잘 만들어진, 그러나 아무 수신자도 열지 못하는 엔벨로프가 나옵니다. 그래서 왕복 로드는 파일 크기 검사만이 아니라 테스트 스위트에 들어가야 합니다

HotPDF ECDH 합의 다이어그램: AddPubKeyAgreementRecipientWithSecret이 순수 Pascal 곡선 코드로 공유 시크릿을 파생하고, 새 32바이트 UKM을 stdDH KDF로 섞습니다. P-256과 X25519는 SHA-256, P-384는 SHA-384, P-521과 X448은 SHA-512이며, 이어서 RFC 3394 AES-256 key wrap으로 콘텐츠 키를 감싸 KeyAgreeRecipientInfo 엔벨로프를 만듭니다
pkasECDHP256부터 pkasX448까지의 스킴 값은 인증서 키와 맞아야 하고, 어긋난 스칼라와 공개 점 반쪽도 잘 만들어졌지만 아무도 열지 못하는 엔벨로프를 만들어 냅니다

/Recipients의 순서는 왜 중요할까?

/Recipients의 순서가 중요한 이유는 파일 키가 seed와 배열 순서대로의 모든 엔벨로프에 대한 다이제스트이기 때문입니다. 작성자와 리더가 같은 바이트를 같은 순서로 해시해야 합니다. HotPDF는 엔벨로프를 추가한 순서 그대로 유지하고 그대로 쓰므로, 수신자는 어떤 순서로 추가해도 되지만 그 배열을 하류에서 재정렬하거나 재인코딩하거나 "정리"해서는 안 됩니다. 이 영역의 실제 버그 대부분은 그 테마의 변주였습니다. 양쪽이 살짝 다른 바이트를 해시한 케이스들입니다:

  • Add로 TList에 동적 배열을 저장하면 raw 포인터만 남고 참조 카운트는 지역 변수에 남습니다. 다음 SetLength가 버퍼를 해제하고 재사용할 수 있으므로 모든 슬롯이 마지막 엔벨로프를 별칭으로 가리키게 됐고, 다수 수신자 파일이 잘못된 키를 파생했습니다. 수정은 List.Add(Pointer(System.Copy(Bytes)))로 소유 사본을 저장하는 것입니다
  • 엔벨로프 언랩은 DER을 제자리에서 파싱하고, 키 복구 패스는 원래 그 살아 있는 배열들을 그대로 해시했습니다. 리더는 이제 어떤 언랩이 손대기 전에 모든 엔벨로프의 깨끗한 사본을 스냅숏으로 뜨고, 다이제스트는 스냅숏 위를 돕니다
  • 유니코드 TStringList를 거친 바이너리 DER은 $80 이상의 바이트가 코드 페이지로 재인코딩되므로, HotPDF는 엔벨로프를 내부적으로 hex 텍스트로 저장합니다
  • 암호화된 문자열과 바이너리 문자열은 hex string으로 기록해야 합니다. literal string은 줄 끝 정규화의 대상이어서 CR, LF, CRLF가 모두 단일 LF가 되는데(ISO 32000-1 §7.3.4.2), 그게 조용히 암호문을 다시 씁니다. HotPDF는 모든 /Recipients 엔트리를 hex string으로 내보내고 문자열 암호화에서 면제하는데, 모든 리더는 어떤 키를 쥐기 전에 엔벨로프를 먼저 필요로 하기 때문입니다
  • DER BIT STRING의 첫 바이트는 미사용 비트 수를 세며, 바이트 정렬 키에서는 0이어야 합니다. SetLength 뒤에 초기화하지 않으면 스택에 있던 무엇이든 쓰였고, 엄격한 언래퍼는 originator 키를 거부해서, 파일이 자신을 위해 쓰인 바로 그 키로 간헐적으로 열리지 않을 수 있었습니다
  • 같은 키로도 여전히 복호화가 안 되면 계층별로 비교하세요. 파일 키, 그다음 암호문 접두어(IV), 그다음 오브젝트 키, 그다음 평문입니다. 버그는 처음으로 어긋난 계층 바로 다음에 삽니다

개인 키로 인증서 암호화 PDF를 여는 방법은?

인증서 암호화 PDF를 열려면 LoadFromFile 호출 전에 개인 키 자료를 등록하세요. HotPDF는 구조 패스 동안 파일 키를 회복합니다. HPDFParsePFX로 파싱한 RSA나 EC 키를 PubSecKeyMaterial에 대입하고, 추가 RSA 키는 AddPubSecKeyMaterial로 더하며, raw ECDH 스칼라는 AddPubSecAgreementKeyMaterial(CurveOID, PrivateScalar, OwnPublicPoint)으로 등록합니다. 상수는 HPDFOIDX25519, HPDFOIDX448, HPDFOIDECP256, HPDFOIDECP384, HPDFOIDECP521입니다. NIST 곡선은 수신자 자신의 비압축 공개 점을 요구하고, Montgomery 곡선은 무시합니다

uses
  System.SysUtils, System.IOUtils, HPDFDoc, HPDFPFX, HPDFKeyAgreement;

procedure OpenAuditPack(const LegalScalar: TBytes);
var
  Reader: THotPDF;
begin
  Reader := THotPDF.Create(nil);
  try
    Reader.AutoLaunch := False;
    Reader.PubSecKeyMaterial :=
      HPDFParsePFX(TFile.ReadAllBytes('reviewer-a.pfx'), 'pfx-password');
    Reader.AddPubSecAgreementKeyMaterial(HPDFOIDX25519, LegalScalar, nil);
    // 선택: 전부 시도하는 대신 엔벨로프를 직접 고릅니다
    Reader.PubSecRecipientQuery :=
      function(Context: Pointer; RecipientCount: Integer): Integer
      begin
        Result := -1;   // -1 = 모든 엔벨로프를 순서대로 시도
      end;
    Reader.LoadFromFile('audit-pack.pdf', '');
    Writeln('Pages: ', Reader.GetLoadedPageCount);
  finally
    Reader.Free;
  end;
end;

콜백이 없으면 HotPDF는 등록된 모든 키로 모든 엔벨로프를 시도합니다. 기본 키 먼저, 그다음 추가 RSA 키들, 그다음 EC 자료입니다. PubSecRecipientQuery는 엔벨로프 수를 받아 0 기반 인덱스나 -1을 반환하며, 배열 밖의 인덱스는 클램프되는 대신 예외를 일으킵니다. AddPubSecKeyMaterial은 RSA 자료만 받습니다(모듈러스와 개인 지수를 고집합니다). 그러니 EC 키는 PubSecKeyMaterial이나 AddPubSecAgreementKeyMaterial 자리입니다. 어떤 키도 엔벨로프를 못 풀면 복구 단계는 예외를 일으키는 대신 파일 키 없이 반환하므로, 로드 호출이 반환됐다는 사실을 믿기보다 기대한 콘텐츠가 실제로 복호화됐는지 확인하세요

HotPDF 개인 키 로딩 다이어그램: PubSecKeyMaterial이 HPDFParsePFX로 파싱한 기본 RSA나 EC 키를 나르고, AddPubSecKeyMaterial은 RSA 키만 더하며, AddPubSecAgreementKeyMaterial은 HPDFOIDX25519부터 HPDFOIDP521까지 곡선 OID 아래 raw ECDH 스칼라를 등록하고, LoadFromFile 시점에 제공자가 기본 키, 추가 RSA 키들, EC 자료 순서로 모든 엔벨로프에 시도합니다
어떤 키도 엔벨로프를 못 풀면 복구 단계는 예외 대신 파일 키 없이 반환하므로, 콘텐츠가 실제로 복호화됐는지 확인하거나 PubSecRecipientQuery로 엔벨로프를 고정하세요

HotPDF가 보장하지 않는 것

HotPDF는 자기 작성기와 리더가 바이트 단위로 일치하는 것은 보장하고, 위에서 인용한 CMS 구조를 따르는 엔벨로프를 만듭니다. 하지만 모든 PDF 뷰어가 모든 조합을 연다는 보장은 아닙니다. RSA-OAEP key transport와 X25519나 X448 수신자 지원은 리더와 버전마다 갈리고, 그 조합에 대한 호환성 결과는 공개하지 않았습니다. 문서가 특정 뷰어에서 반드시 열려야 한다면 같은 키 타입의 테스트 인증서로 테스트 파일을 암호화해 스킴을 확정하기 전에 그 뷰어에서 열어 보세요. 엔벨로프가 나르는 권한은 컨포밍 소프트웨어가 지키는 정책으로 남고, 비밀번호 암호화에서와 정확히 같습니다. seed 품질도 당신의 몫입니다. AESGenerateRandomBytes가 그 일을 위한 도구이고, HotPDF는 파일 키가 파생되면 자기 seed 사본을 지웁니다. string이나 stream, 첨부에 다른 crypt filter를 써야 한다면 StmF, StrF, EFF crypt filter 정책 가이드가 공개키 핸들러가 받는 필터 이름을 보여 줍니다

인증서 암호화, RSA-OAEP와 ECDH 수신자 엔벨로프, 개인 키 로딩은 모두 HotPDF Delphi PDF component에 들어가며, 비밀번호 암호화, 전자서명, Delphi와 C++Builder를 위한 ISO 32000 툴셋의 나머지와 함께 제공됩니다