技術記事

Delphi から SHA-3 で PDF に署名できるのか

Delphi の署名ルーチンを SHA3-512 に切り替えると OpenSSL が結果を拒否します。CMS は SHA3-512 と宣言しているのに、その messageDigest 属性は 32 バイトしか運んでいません。あのアルゴリズムなら 64 バイト作るところです。できます。Delphi のコードは SHA-3 で署名できるのです。PDFium Component の SignPadesForDigest は SHA3-256 と SHA3-384、SHA3-512 を受け入れ、ISO/TS 32001 が期待するとおり、その 1 つの選択に対してダイジェスト依存の CMS フィールドをすべて書き込みます

ISO/TS 32001 は PDF 2.0 署名に何を足すのか

ISO/TS 32001 は ISO 32000-2 への短い拡張です。PDF 署名が宣言してよいハッシュ関数の集合を広げます。基本のリストは ISO 32000-2 §12.8.3.1 の Table 260 にあり、CMS 系のサブフィルタでは SHA-256 と SHA-384、SHA-512、RIPEMD-160 で止まり、SHA-1 は非推奨です。この技術仕様は FIPS 202 由来の SHA-3 ファミリーを加えます。SHA-3 はスポンジ構造で、SHA-2 と内部構造を何ひとつ共有しません。SHA-2 がいつか弱まったときに備えて 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 では選択全体が 1 つの列挙型、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 はデフォルトで SHA3-256 を得る(PDFium Component)
SHA-3 ファミリーが 2 つ目のハッシュファミリーとして Table 260 に加わり、EC 曲線は合う幅と組み合わり、SHA3-256 はすでに RSA-2048 鍵を追い越します

ダイジェストと一緒に変わる 5 つの CMS フィールド

ハッシュ関数の差し替えは仕事の最小部分です。detached な CAdES 署名はダイジェストを 5 か所で名指しし、厳密な検証器はそれらを突き合わせます。どれか 1 つがまだ SHA-256 と言っていると、RSA や ECDSA の演算が正しくても署名は失敗します

  1. SignerInfo の digestAlgorithm、SignedData の digestAlgorithms 集合に映るものがダイジェスト OID を運びます。SHA3-384 なら例えば 2.16.840.1.101.3.4.2.9 です
  2. 署名属性の messageDigest(RFC 5652)が 2 つの /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 ハッシュとしての 2 回繰り返し、saltLength をダイジェスト長に設定します。省くと検証器は SHA-1 と SHA-1 の MGF1、20 バイトのソルトを仮定します。別のアルゴリズムです。RFC 4055 の RSASSA-PSS-params タグ付けルールもここで効いてきます
  5. 署名属性の CMSAlgorithmProtection(RFC 6211)がダイジェストと署名アルゴリズム識別子を署名属性の中へコピーし、誰にも気づかれずにすり替えられない場所へ置きます。フィールド 1 と 3 にバイト単位で一致する必要があります

まったく変わらないフィールドが 1 つあります。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 拒否はこれです。同じ過ちはもう 1 層深いところにも住んでいます。OpenSSL の EVP_PKEY_sign はダイジェスト長を tbslen として受け取り、コンテキストに設定されたメッセージダイジェストと突き合わせるので、常に 32 を渡す署名器は、SHA-3 に一度も触れない長さエラーを受け取ります。PDFium Component は文書ダイジェストを、解決された署名器が宣言するアルゴリズムから計算し、属性と PSS パラメータ、アルゴリズム保護属性をその同じ宣言から導くので、どのフィールドも他から漂うことはありません

Delphi からの PAdES 署名で SHA-3 ダイジェストとともに変わる 5 つの CMS フィールド。PDFium Component が書くとおり、厳密な検証器が突き合わせる
digestAlgorithm と messageDigest、signatureAlgorithm、PSS パラメータ、CMSAlgorithmProtection は 1 つのダイジェストを名指しし、ESSCertIDv2 だけは専用の SHA-256 を保ちます

SignPadesForDigest でローカル PDF に署名する

TPdf コンポーネントの SHA-3 入り口は SignPadesForDigest(const FileName: string; const Options: TPadesSignOptions; DigestAlgorithm: TCmsDigestAlgorithm): Boolean と、そのストリーム版 SignPadesToStreamForDigest です。どちらも SignPades と同じ TPadesSignOptions レコードを取り、cdaSha256 は既存のオーバーロードと同じ署名を作るので、1 つのコードパスが両ファミリーに仕えます。Windows では SHA-1 サムプリントが Current User の MY ストアから証明書を選びます。他のプラットフォームの OpenSSL バックエンドは ConfigureSslSigningCredentials に渡された鍵と証明書で署名し、サムプリントは拒否します。文書バージョンも確認してください。ISO/TS 32001 は PDF 2.0 を拡張するもので、署名は元のヘッダーを保つ増分更新として入るので、PDF 2.0 のルールで検証する relying party は、署名の前に 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 を求めたのであり、たいていは relying party のポリシー文書です。代わりに作られた 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 のプロバイダーを 1 度開いて答えをキャッシュします。プロセスが動いている間に変わるはずがないからです。それ以外の場所では 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 には 2 つのメソッドがあります。FPdfCryptoSsl の ResolveSslSignerForDigest は、宣言されたダイジェストと署名アルゴリズム、ダイジェスト長、PSS ソルトのすべてが要求アルゴリズムに従う署名器を返し、プロバイダーが設定されていれば ResolvePadesSignerForDigest が、返された署名器が呼び出しが求めたダイジェストを宣言するか確認し、宣言していなければ EPadesCrypto を上げます。SHA3-256 向けに作られたプロバイダーは SHA3-384 の要求に判を押せません。ただしプロバイダーはプロセス全体で共有されるので、並行して署名するサーバーは、このスケッチのように呼び出しごとではなく起動時に 1 度設定すべきです。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 の署名器はダイジェストをどう知るのか

リモート署名は仕事を 2 つに分けます。PreparePadesRemoteSignature が空の /Contents スロット付きの PDF と TPadesRemoteSigningRequest を書き出し、鍵の保有者が detached CMS を後で返します。多くの場合それは別のプロセスやマシンからで、HSM とクラウド鍵に使われる準備と完了のセッションを通ります。準備側では TPadesRemoteSignOptions.DigestAlgorithm を、たとえば cdaSha3_384 に設定すると、要求はその選択を 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 がそれを出せないホストのほうです。だからこそ利用可能性のチェックは、PDFium Component for Delphi を配備するどの環境でも、アプリケーションの起動時に置くべきなのです