기술 문서

PAdES 서명에 SHA-3 더하기: ISO/TS 32001 CMS 필드 5곳

Delphi 서명 루틴을 SHA3-512로 바꾸자 OpenSSL이 결과를 거부합니다. CMS는 SHA3-512를 선언하는데 그 messageDigest 속성에는 그 알고리즘이 64바이트를 내놓아야 할 자리에 32바이트가 들어 있다는 것입니다. 그렇습니다, Delphi 코드는 SHA-3로 서명할 수 있습니다. PDFium Component의 SignPadesForDigest는 SHA3-256, SHA3-384, SHA3-512를 받아들이고 그 한 선택에 맞춰 다이제스트 의존 CMS 필드 전부를 써 넣습니다. ISO/TS 32001이 기대하는 대로입니다

ISO/TS 32001이 PDF 2.0 서명에 더하는 것

ISO/TS 32001은 ISO 32000-2에 대한 짧은 확장입니다. PDF 서명이 선언할 수 있는 해시 함수 집합을 넓힙니다. 기본 목록은 ISO 32000-2 §12.8.3.1의 표 260에 있고, CMS 기반 서브필터에 대해서는 SHA-256, SHA-384, SHA-512, RIPEMD-160에서 멈추며 SHA-1은 폐기 예정입니다. 기술 사양은 FIPS 202의 SHA-3 계열을 더합니다. SHA-3는 SHA-2와 내부 구조를 아무것도 나누지 않는 스펀지 구조인데, SHA-2가 언젠가 흔들릴 경우를 대비해 두 번째 해시 계열을 곁에 두려는 서명 정책에 그것이 바로 매력입니다. CMS에서 각 변형은 NIST 아크 아래의 객체 식별자입니다. 조합 서명 알고리즘 식별자들이 바로 곁에 앉아 있습니다:

TCmsDigestAlgorithm다이제스트 OID출력RSA PKCS#1 v1.5ECDSA
cdaSha2562.16.840.1.101.3.4.2.132바이트1.2.840.113549.1.1.111.2.840.10045.4.3.2
cdaSha3_2562.16.840.1.101.3.4.2.832바이트2.16.840.1.101.3.4.3.142.16.840.1.101.3.4.3.10
cdaSha3_3842.16.840.1.101.3.4.2.948바이트2.16.840.1.101.3.4.3.152.16.840.1.101.3.4.3.11
cdaSha3_5122.16.840.1.101.3.4.2.1064바이트2.16.840.1.101.3.4.3.162.16.840.1.101.3.4.3.12

PDFium Component에서 그 선택 전체는 FPdfCms 유닛의 열거형 하나, TCmsDigestAlgorithm입니다. 구성원은 키가 고르게 두십시오. NIST 곡선은 자기 폭의 다이제스트를 기대하므로 P-256은 SHA3-256과, P-384는 SHA3-384와, P-521은 SHA3-512와 짝짓고, ISO/TS 32002 곡선과 다이제스트 정책을 적용하는 검증기는 그 외의 짝짓기를 무효로 표시합니다. RSA에는 그런 규칙이 없습니다. 강도 논거는 어차피 짧습니다. RSA-2048 키는 대략 112비트의 보안을, RSA-3072는 128비트를 내주니 SHA3-256이 이미 키를 앞서고, RSA-2048 키에 SHA3-512는 CMS에 32바이트를 더할 뿐 보안에는 아무것도 더하지 않습니다. 문서화된 정책이 더 큰 변형을 명명하지 않는 한 RSA에는 SHA3-256을 권합니다

Delphi에서 PDF 서명을 위한 ISO/TS 32001의 SHA-3 추가: EC 키는 같은 폭의 다이제스트와 짝짓고 RSA는 PDFium Component에서 기본으로 SHA3-256을 받습니다
SHA-3 계열은 두 번째 해시 계열로 표 260에 합류하고, EC 곡선은 맞는 폭과 짝지으며, SHA3-256은 이미 RSA-2048 키를 앞섭니다

다이제스트와 함께 바뀌는 CMS 필드 다섯

해시 함수를 바꾸는 것은 이 작업의 가장 작은 부분입니다. 분리형(detached) CAdES 서명은 자기 다이제스트를 다섯 곳에 명명하고, 엄격한 검증기는 그것들을 교차 검사합니다. 그중 하나라도 여전히 SHA-256이라고 말하면 RSA나 ECDSA 산술이 옳아도 서명은 실패합니다:

  1. digestAlgorithm는 SignerInfo 안에서 SignedData의 digestAlgorithms 집합에도 거울처럼 비치며 다이제스트 OID를 실습니다. SHA3-384라면 예컨대 2.16.840.1.101.3.4.2.9입니다
  2. messageDigest 서명 속성(RFC 5652)은 두 /ByteRange 구간의 해시를 실으므로 그 OCTET STRING은 32, 48 또는 64바이트입니다
  3. signatureAlgorithm은 위 표의 조합 방식을 명명합니다. RSASSA-PSS가 예외입니다. OID는 id-RSASSA-PSS(1.2.840.113549.1.1.10)에 머물고 다이제스트는 매개변수로 옮겨 갑니다
  4. RSASSA-PSS 매개변수는 다이제스트를 두 번 반복합니다. hashAlgorithm으로 한 번, MGF1 해시로 한 번이고, saltLength를 다이제스트 길이로 놓습니다. 이들을 빼먹으면 검증기는 SHA-1과 SHA-1을 쓰는 MGF1, 20바이트 솔트를 가정하는데, 그것은 다른 알고리즘입니다. RFC 4055의 RSASSA-PSS-params 태깅 규칙도 여기서 문제가 됩니다
  5. CMSAlgorithmProtection 서명 속성(RFC 6211)은 다이제스트와 서명 알고리즘 식별자를 서명 속성 안으로 복사해 누구도 알아채지 못하게 바꿀 수 없게 합니다. 필드 1과 3과 바이트 단위로 일치해야 합니다

한 필드는 전혀 바뀌지 않습니다. signing-certificate-v2 속성(RFC 5035) 안의 ESSCertIDv2는 서명자 인증서를 제 hashAlgorithm으로 해시하는데, 기본은 SHA-256이고 SignedData 다이제스트와 무관하므로 PDFium Component는 기본 인코딩에서 그것을 SHA-256에 둡니다. 손으로 쓴 CMS 코드에서 가장 자주 드러나는 실수는 messageDigest 길이입니다. SHA-256용으로 쓴 코드는 해시를 고정 32바이트 레코드에 담고 그 레코드를 속성에 복사하는 경향이 있습니다. SHA3-512 결과를 밀어 넣으면 속성은 첫 32바이트를 받는 동시에 digestAlgorithm은 .10을 말합니다. 첫머리의 그 OpenSSL 거부입니다. 같은 실수는 한 층 아래에도 삽니다. OpenSSL의 EVP_PKEY_sign은 다이제스트 길이를 tbslen으로 받아 콘텍스트에 설정된 메시지 다이제스트와 검사하므로, 늘 32를 넘기는 서명자는 SHA-3를 한 번도 언급하지 않는 길이 오류를 받습니다. PDFium Component는 해석된 서명자가 선언한 알고리즘이 무엇이든 그것으로 문서 다이제스트를 계산하고 속성과 PSS 매개변수, 알고리즘 보호 속성을 같은 선언에서 도출하므로, 어떤 필드도 나머지와 어긋날 수 없습니다

Delphi의 PAdES 서명에서 SHA-3 다이제스트와 함께 바뀌는 CMS 필드 다섯 곳과, PDFium Component가 써 넣는 대로 엄격한 검증기가 교차 검사하는 모습
digestAlgorithm과 messageDigest, signatureAlgorithm, PSS 매개변수, CMSAlgorithmProtection은 하나의 다이제스트를 명명해야 하고, ESSCertIDv2는 제 SHA-256을 지킵니다

SignPadesForDigest로 로컬 PDF 서명하기

TPdf 컴포넌트에서 SHA-3 진입점은 SignPadesForDigest(const FileName: string; const Options: TPadesSignOptions; DigestAlgorithm: TCmsDigestAlgorithm): Boolean과 그 스트림 자매인 SignPadesToStreamForDigest입니다. 둘 다 SignPades와 같은 TPadesSignOptions 레코드를 받고, cdaSha256은 기존 오버로드와 같은 서명을 내놓으니 한 코드 경로가 두 계열을 모두 섬깁니다. Windows에서는 SHA-1 지문이 Current User MY 저장소의 인증서를 고릅니다. 다른 플랫폼의 OpenSSL 백엔드는 ConfigureSslSigningCredentials에 준 키와 인증서로 서명하고 지문은 거부합니다. 문서 버전도 확인하십시오. ISO/TS 32001은 PDF 2.0을 확장하고 서명은 원래 헤더를 지키는 증분 갱신으로 들어가니, PDF 2.0 규칙으로 검증하는 신뢰 당사자에게는 서명하기 전에 2.0 파일이 필요합니다

uses
  System.SysUtils, System.Classes, System.IOUtils,
  PDFium, FPdfCms, FPdfCrypto, FPdfPades
  {$IFDEF MSWINDOWS}, FPdfCryptoWin{$ELSE}, FPdfCryptoSsl{$ENDIF};

function Sha3SigningAvailable: Boolean;
begin
{$IFDEF MSWINDOWS}
  Result := WinSha3DigestAvailable;   // CNG SHA-3 공급자, Windows 11 24H2+
{$ELSE}
  Result := SslSha3SigningAvailable;  // 자격 증명이 설정되기 전까지는 False
{$ENDIF}
end;

procedure SignWithSha3(const SourceFile, SignedFile, Thumbprint: string;
  const KeyPassword: AnsiString; const Log: TProc<string>);
var
  Pdf: TPdf;
  Opts: TPadesSignOptions;
  Signed: TMemoryStream;
begin
{$IFNDEF MSWINDOWS}
  // OpenSSL은 명시적으로 설정된 인증 자료로 서명하며, 이 스케치에는 체인이 없습니다
  ConfigureSslSigningCredentials(
    TFile.ReadAllBytes('/etc/signing/signer-key.pem'),
    TFile.ReadAllBytes('/etc/signing/signer.der'), nil, KeyPassword);
{$ENDIF}
  if not Sha3SigningAvailable then
    raise Exception.Create('No SHA-3 capable signing backend on this machine');

  Opts := TPadesSignOptions.Default;          // B-B, RSASSA-PSS
{$IFDEF MSWINDOWS}
  Opts.CertificateThumbprint := Thumbprint;   // Current User MY의 인증서
{$ENDIF}
  Opts.Reason := 'Approved for release';

  Pdf := TPdf.Create(nil);
  Signed := TMemoryStream.Create;
  try
    Pdf.FileName := SourceFile;
    Pdf.Active := True;
    if Pdf.PdfVersion <> pv20 then
      Log(SourceFile + ' is not a PDF 2.0 file');
    try
      if Pdf.SignPadesToStreamForDigest(Signed, Opts, cdaSha3_256) then
        Signed.SaveToFile(SignedFile);
    except
      on E: EPadesCryptoUnavailable do
        Log('SHA-3 signing unavailable: ' + E.Message);
    end;
  finally
    Signed.Free;
    Pdf.Free;
  end;
end;

백엔드가 SHA-3를 만들어 낼 수 없을 때 호출은 SHA-256으로 후퇴하는 대신 EPadesCryptoUnavailable을 던집니다. 그것이 바로 원하는 동작입니다. 누군가 SHA3-256을 요구한 데는 이유가 있습니다. 보통 신뢰 당사자의 정책 문서입니다. 그 자리에서 만들어진 SHA-256 서명은 여러분 쪽 검사는 모두 통과하고 상대 쪽에서 몇 주 뒤에 되돌려질 것입니다. 팀의 누구도 어느 머신이 서명했는지 기억하지 못할 때 말입니다. 예외는 아직 운영자에게 어느 호스트에 그 알고리즘이 없는지 말해 줄 수 있는 자리에서 잡으십시오

SHA-3가 있는 곳: Windows CNG와 OpenSSL

가용성은 머신에 속합니다. Windows에서 기본 백엔드는 CNG이고 CNG는 Windows 11 24H2부터 SHA-3 알고리즘 공급자를 실어 나릅니다. 그 이전 빌드에서는 "SHA3-256"에 대한 BCryptOpenAlgorithmProvider가 STATUS_NOT_FOUND로 실패합니다. WinSha3DigestAvailable은 SHA3-256, SHA3-384, SHA3-512 공급자를 한 번 열고 답을 캐시합니다. 프로세스가 도는 동안에는 바뀔 수 없기 때문입니다. 그 밖의 모든 곳에서는 OpenSSL 백엔드가 서명하며, 무엇을 할 수 있는지는 찾아낸 libcrypto에 달려 있습니다:

  • OpenSSL 3.x는 EVP_sha3_256, EVP_sha3_384, EVP_sha3_512를 언제나 내보내므로, 백엔드가 적재되는 모든 플랫폼에서 SHA-3 서명이 동작합니다
  • 로더는 1.1 계열 라이브러리(libcrypto-1_1.dll, libcrypto.so.1.1)도 받아들입니다. SHA-3는 OpenSSL 1.1.1에 왔고 1.1.0에는 없으니, 오래된 Linux 이미지에서는 SslSha3SigningAvailable이 판가름합니다
  • SslSha3SigningAvailable은 설정된 서명 자격 증명도 요구합니다. ConfigureSslSigningCredentials 전에 물으면 SslLibraryVersion이 완전한 3.x 라이브러리를 보고하고 있어도 False로 답합니다. 백엔드가 없어 보입니다. 순서 문제일 뿐입니다
  • 32비트 실행 파일은 libcrypto-3-x64.dll을 적재하지 못합니다. 이 백엔드를 올리던 때 x64 DLL을 가리킨 Win32 빌드는 SslAvailable = False를 보고하는 동시에 SslMissingSymbols는 라이브러리 경로 외에는 아무것도 울지 않았습니다. 심볼 바인딩이 깨진 것처럼 읽히는 증상입니다. 원인은 비트 수였고, 실행 파일에 맞는 DLL이 해결했습니다

24H2 이전 장비가 아직 섞여 있는 Windows 환경은 SHA-3가 있는 곳에서는 CNG를 지키고 나머지는 서명자 공급자를 통해 OpenSSL로 돌립니다. IPadesSignerProvider에는 메서드가 둘 있습니다. FPdfCryptoSsl의 ResolveSslSignerForDigest는 선언된 다이제스트와 서명 알고리즘, 다이제스트 길이, PSS 솔트가 모두 요청된 알고리즘을 따르는 서명자를 돌려주고, 공급자가 설정되어 있으면 ResolvePadesSignerForDigest는 돌아온 서명자가 호출이 요청한 다이제스트를 선언하는지 검사하며, 그러지 않으면 EPadesCrypto를 던집니다. SHA3-256용으로 지어진 공급자는 SHA3-384 요청을 도장 찍을 수 없습니다. 다만 공급자는 프로세스 전역이므로 동시에 서명하는 서버는 이 스케치처럼 호출마다가 아니라 시작 시 한 번 설정해야 합니다. DLL이 검색 경로에 없을 때는 ConfigureSslLibraryPath가 로더를 libcrypto로 향하게 합니다

Delphi에서 SHA-3 서명 가용성 흐름: 24H2부터의 Windows CNG 공급자, OpenSSL 버전과 비트 가드, 그리고 PDFium Component의 EPadesCryptoUnavailable 실패 경로
가용성은 머신에 속하므로 검사는 시작 시에: SHA-3가 있는 곳은 CNG, 나머지는 OpenSSL, SHA-256으로의 조용한 후퇴는 없습니다
uses
  System.SysUtils, System.Classes, System.IOUtils,
  PDFium, FPdfCms, FPdfCrypto, FPdfCryptoWin, FPdfCryptoSsl, FPdfPades;

type
  TOpenSslSha3Provider = class(TInterfacedObject, IPadesSignerProvider)
  private
    FDigest: TCmsDigestAlgorithm;
  public
    constructor Create(ADigest: TCmsDigestAlgorithm);
    function BackendName: string;
    function Resolve(const Options: TPadesSignerOptions): TPadesResolvedSigner;
  end;

constructor TOpenSslSha3Provider.Create(ADigest: TCmsDigestAlgorithm);
begin
  inherited Create;
  FDigest := ADigest;
end;

function TOpenSslSha3Provider.BackendName: string;
begin
  Result := 'OpenSSL';
end;

function TOpenSslSha3Provider.Resolve(
  const Options: TPadesSignerOptions): TPadesResolvedSigner;
begin
  Result := ResolveSslSignerForDigest(Options, FDigest);
end;

procedure SignSha3OnAnyWindows(Pdf: TPdf; Dest: TStream;
  const Opts: TPadesSignOptions; const KeyPassword: AnsiString);
var
  Provider: IPadesSignerProvider;
  Chain: TPadesChainCertArray;
  SslOpts: TPadesSignOptions;
begin
  if WinSha3DigestAvailable then
  begin
    Pdf.SignPadesToStreamForDigest(Dest, Opts, cdaSha3_256);   // CNG 경로
    Exit;
  end;

  Chain := nil;
  ConfigureSslSigningCredentials(
    TFile.ReadAllBytes('C:\Signing\signer-key.pem'),
    TFile.ReadAllBytes('C:\Signing\signer.der'), Chain, KeyPassword);
  if not SslSha3SigningAvailable then
    raise Exception.Create('No SHA-3 in loaded OpenSSL ' + SslLibraryVersion);

  SslOpts := Opts;
  SslOpts.CertificateThumbprint := '';   // OpenSSL은 설정된 인증서로 서명합니다
  Provider := TOpenSslSha3Provider.Create(cdaSha3_256);
  ConfigurePadesSignerProvider(Provider);
  try
    Pdf.SignPadesToStreamForDigest(Dest, SslOpts, cdaSha3_256);
  finally
    ConfigurePadesSignerProvider(nil);
  end;
end;

원격이나 HSM 서명자는 다이제스트를 어떻게 알까?

원격 서명은 일을 둘로 가릅니다. PreparePadesRemoteSignature는 빈 /Contents 슬롯을 실은 PDF와 TPadesRemoteSigningRequest를 쓰고, 키 보유자는 나중에 분리형 CMS를 돌려줍니다. 흔히 다른 프로세스나 머신에서, HSM과 클라우드 키에 쓰는 준비-완료 세션을 통해 말입니다. 준비 쪽에서 TPadesRemoteSignOptions.DigestAlgorithm를, 예컨대 cdaSha3_384로 놓으면 요청은 그 선택을 DocumentDigest 곁의 TPadesRemoteSigningRequest.DigestAlgorithm에 기록합니다. CMS messageDigest가 실어야 할 값이 DocumentDigest입니다. 그 시점부터 요청이 권위입니다. CompletePadesRemoteSignature는 요청에 저장된 알고리즘으로 /ByteRange 다이제스트를 다시 계산하고 옵션은 다시 물어보지 않습니다. 그렇지 않으면 준비와 완료 사이의 설정 변화가 오래된 요청을 새 알고리즘으로 판결할 테니까요. 컴포넌트 위에 지어진 서명 서비스도 알고리즘은 같은 자리에서 가져가야 합니다:

uses
  System.SysUtils, System.Classes,
  FPdfCms, FPdfCrypto, FPdfCryptoSsl, FPdfPades,
  FPdfPadesRemote;   // Enterprise 구현을 연결합니다

function SignRemoteRequest(const RequestFile: string;
  Padding: TPadesPaddingScheme): TBytes;
var
  Stream: TFileStream;
  Request: TPadesRemoteSigningRequest;
  SignerOpts: TPadesSignerOptions;
  Resolved: TPadesResolvedSigner;
begin
  Stream := TFileStream.Create(RequestFile, fmOpenRead or fmShareDenyWrite);
  try
    Request := LoadPadesRemoteSigningRequest(Stream);
  finally
    Stream.Free;
  end;

  SignerOpts := TPadesSignerOptions.Default;   // OpenSSL용 빈 지문
  SignerOpts.PaddingScheme := Padding;
  // 다이제스트는 요청이 정합니다, 이 서비스의 설정이 아니라
  Resolved := ResolveSslSignerForDigest(SignerOpts, Request.DigestAlgorithm);
  Result := BuildSignedDataEx(Request.DocumentDigest, Resolved.Signer,
    Resolved.SignerCertDer, Resolved.ChainCertDer, TCmsBuildOptions.Default);
end;

요청 안에서 다이제스트 길이는 그 알고리즘의 유일한 증거이자 지문을 겸합니다. 어떤 스트림도 읽기 전에 CompletePadesRemoteSignature는 Length(DocumentDigest)를 CmsDigestByteLength(DigestAlgorithm)와 비교하므로, 잘린 다이제스트든 이름만 바뀐 알고리즘이든 그 지점에서 실패하고 목적지 스트림은 건드리지 않습니다. 길이만으로는 SHA3-256과 SHA-256을 구별할 수 없습니다. 둘 다 32바이트이기 때문입니다. 그래서 다이제스트 값은 돌아온 CMS 안의 messageDigest와도 비교됩니다. 반면 준비된 PDF는 SHA-256과 SHA3-384가 바이트 단위로 같습니다. 자리표시자는 /ByteRange 숫자만 실고 다이제스트는 없으니까요. 같은 파일은 설정이 무시된 것으로 잘못 읽기 쉽습니다. 원격 서명은 Enterprise 판에서 FPdfPadesRemote 유닛으로 실려 나옵니다

변형 선택이 어려운 판단인 경우는 드뭅니다. RSA 키라면 보통 cdaSha3_256이고, 실전에서 실패하는 것은 CNG나 libcrypto가 그것을 만들어 낼 수 없는 호스트입니다. 그래서 가용성 검사는 Delphi용 PDFium Component의 어떤 배포에서든 애플리케이션 시작 시점에 둘 자리입니다