アップロードせずに大きなインストーラーに署名する

· コンポーネント
アップロードせずに大きなインストーラーに署名する

署名サーバーは秘密鍵を一箇所に保管します。それこそが署名サーバーを持つ意味です。その代わりに支払う通常のコストはファイルそのものです。ビルドエージェントがインストーラーをアップロードし、サーバーがそれに署名して、ファイル全体を送り返します。数ギガバイトのインストーラーの場合、これはインストーラーが2回もネットワークを渡ることを意味し、追加されるのは数キロバイトの署名だけです。

しかし、そうである必要はありません。Authenticode が署名するのは決してファイルそのものではなく、ファイルのダイジェストであり、そのダイジェストはファイルがすでに存在する場所で計算できます。sgcSign 2026.10 から、sgcSign Server は EXE に対してすでに行っていたのと同じように、Windows Installer パッケージや MSIX、APPX パッケージにも、そのダイジェストだけから署名します。

sgcSign Server を通じて1GBのインストーラーを2通りの方法で署名し、一度はアップロードして、もう一度はそのハッシュから署名する様子を、回線上のすべてのバイトを数えながら比較しています。YouTubeでも公開中

実際に署名されるもの

Authenticode 署名が対象とするのはダイジェストであり、各フォーマットはそれぞれ独自のダイジェストを定義しています。EXE の場合はチェックサムと証明書テーブルを除いた PE イメージハッシュです。MSI の場合はコンパウンドファイルのストリームを固定の順序でまとめたダイジェストです。MSIX の場合はパッケージの各パーツに対する SHA-256 ダイジェストの小さなブロックです。サーバーが必要とするのはこの値だけであり、保持している鍵でダイジェストに署名し、クライアントがその署名をファイルに書き込みます。

コマンドラインから

コマンドラインツールは、すでに --format authenticode で行っていたのと同様に、--format msi--format appx でもデフォルトでハッシュルートを使用し、--upload を指定すると代わりにファイル全体を送信します。デモでは、同じ1GBのパッケージを同一マシン上のサーバーに対して両方の方法で署名し、ツールが送受信するすべてのバイトを数える小さなリレーを介して計測します。パッケージには1GiBのランダムデータが含まれているため、途中で圧縮されることはありません。

> sgcsign sign --format msi --upload --server http://127.0.0.1:18481 --apikey $k `
    --provider demo --tsa http://timestamp.digicert.com --verbose `
    --out BigApp-signed-upload.msi BigApp.msi
Signed: BigApp-signed-upload.msi | Signer: O=eSeGeCe Demo, CN=sgcSign Demo Code Signing | Duration: 3015 ms

> Get-Content .\relay.log -Tail 1
RUN 4: connections=1 bytes_up(client->server)=1075356521 bytes_down(server->client)=1075364108 wall=14.087s

> sgcsign sign --format msi --server http://127.0.0.1:18481 --apikey $k `
    --provider demo --tsa http://timestamp.digicert.com --verbose `
    --out BigApp-signed-hash.msi BigApp.msi
[prehash] alg=sha256 hash=9151f2ee34b18f80a20d4b2cec515ac344066bbed846c7efbc98d7c5e5a0cddd

> Get-Content .\relay.log -Tail 1
RUN 5: connections=1 bytes_up(client->server)=477 bytes_down(server->client)=11044 wall=0.178s

どちらのファイルも署名済みで返され、同じダイジェストを持っています。signtool verify /v はどちらでも Hash of file (sha256): 9151F2EE…5A0CDDD を表示し、これは [prehash] 行の値と一致し、デモ署名者と DigiCert のタイムスタンプも含まれています。

数値で見る

アップロードルートハッシュルート
パッケージ1,075,355,648 bytes1,075,355,648 bytes
サーバーへの送信量1,075,356,521 bytes477 bytes
サーバーからの受信量1,075,364,108 bytes11,044 bytes
通信にかかった時間14.1 s0.18 s
コマンド全体16.3 s4.8 s

これはループバック経由で実行されており、バイトを移動させるコストはほぼゼロですが、それでもアップロードルートは14秒を要しました。実際の回線では転送そのものがコストのすべてです。100 Mbit/s では、アップロードルートの2.15 GBは約3分かかりますが、ハッシュルートは相変わらず477バイトしか送信しません。ハッシュルートの4.8秒の残りは、ビルドエージェント上で1GBのパッケージを準備してハッシュ化する処理であり、アップロードルートも同じ処理を行いますが、それはサーバー側で行われます。

Delphi での同じ処理

Delphi のビルドツールでは、この流れは4つのステップになります。パッケージを準備し、準備済みパッケージをハッシュ化し、ハッシュを送信し、返ってきた署名を埋め込みます。HTTP呼び出しには、プロジェクトですでに使用しているクライアントを使えます。この例では RTL の THTTPClientSystem.JSON を使用しています。

program HashSignMSI;

{$APPTYPE CONSOLE}

uses
  System.SysUtils, System.Classes, System.JSON, System.Net.HttpClient,
  sgcSign_MSI, sgcSign_Base64, sgcSign_Authenticode;

const
  CS_SERVER = 'http://127.0.0.1:18480';

var
  vPrepared, vSigned, vHex, vBody: string;
  vHash, vPKCS7: TBytes;
  vI: Integer;
  oHTTP: THTTPClient;
  oFields: TStringList;
  oResponse: IHTTPResponse;
  oJSON: TJSONValue;
begin
  try
    vPrepared := ChangeFileExt(ParamStr(1), '.prepared.msi');
    vSigned := ChangeFileExt(ParamStr(1), '.signed.msi');
    // 1. Prepare: this is the package the digest covers
    sgcMSIPrepareForSigning(ParamStr(1), vPrepared);
    // 2. Hash the PREPARED package on this machine
    vHash := sgcMSIComputeHash(vPrepared, ahSHA256);
    vHex := '';
    for vI := 0 to Length(vHash) - 1 do
      vHex := vHex + LowerCase(IntToHex(vHash[vI], 2));
    Writeln('SHA-256 : ', vHex);
    // 3. Only the digest leaves the machine
    oHTTP := THTTPClient.Create;
    oFields := TStringList.Create;
    try
      oHTTP.CustomHeaders['X-API-Key'] := GetEnvironmentVariable('SGCSIGN_APIKEY');
      oFields.Add('hash=' + vHex);
      oFields.Add('alg=sha256');
      oFields.Add('provider=demo');
      oFields.Add('tsa_url=http://timestamp.digicert.com');
      oFields.Add('level=t');
      oResponse := oHTTP.Post(CS_SERVER + '/api/v1/sign/msi/hash', oFields);
      vBody := oResponse.ContentAsString(TEncoding.UTF8);
      if oResponse.StatusCode <> 200 then
        raise Exception.CreateFmt('HTTP %d: %s', [oResponse.StatusCode, vBody]);
    finally
      oFields.Free;
      oHTTP.Free;
    end;
    // 4. Base64-decode the PKCS#7 and embed it into the SAME prepared package
    oJSON := TJSONObject.ParseJSONValue(vBody);
    try
      vPKCS7 := TsgcBase64.Decode(oJSON.GetValue<string>('signature'));
    finally
      oJSON.Free;
    end;
    sgcMSIEmbedSignature(vPrepared, vSigned, vPKCS7);
    Writeln('PKCS#7  : ', Length(vPKCS7), ' bytes');
    Writeln('Signed  : ', vSigned);
  except
    on E: Exception do
    begin
      Writeln(ErrOutput, 'error: ', E.Message);
      ExitCode := 1;
    end;
  end;
end.

同じサーバーに対して、応答として7,941バイトのPKCS#7が返され、signtool は上記の2つのファイルとまったく同じようにその結果を読み取ります。

まず準備し、それからハッシュ化する

インストーラーパッケージは、出荷される形のまま署名されるため、ハッシュ化する前にその形にしておく必要があります。sgcMSIPrepareForSigningsgcAppxPrepareForSigning がこれを行い、ルールは単純です。prepare が返したものをハッシュ化し、その署名は元のパッケージではなく、その同じ準備済みパッケージに埋め込みます。代わりに元のパッケージをハッシュ化すると、署名はもはや存在しないバイトを対象にしてしまいます。

MSIX: publisher は自分で確認する

ハッシュルートでは、どちらの側も Windows が厳格にチェックする両方の要素を同時に見ることができません。サーバーは証明書を保持していますがマニフェストを見ることはなく、クライアントはマニフェストを保持していますが証明書を見ることはありません。そのため、すべてのハッシュ応答には署名した証明書の subject である signer_subject が含まれます。署名を埋め込む前に、これをパッケージの PublishersgcAppxReadPublisher および sgcAppxDNMatches で比較してください。コマンドラインツールはこれを自動的に行い、両者が一致しない場合はパッケージの書き込みを拒否します。

それでもアップロードすべき場合

フルファイルルートは引き続き利用できます。クライアント自身がダイジェストを計算できない場合や、署名に承認が必要な場合に使用してください。承認ワークフローはフルファイルアップロードでのみ機能するためです。--upload は、1回の実行に限りコマンドラインツールをそのルートに戻します。

提供状況

インストーラーパッケージ向けのハッシュルートである /api/v1/sign/msi/hash/api/v1/sign/appx/hash、コマンドラインツールのハッシュ優先のデフォルト動作、そして準備・ハッシュ化・埋め込みの各関数は、Delphi および C++Builder 向けの sgcSign 2026.10 で提供されます。これらのルートはsgcSign オンラインヘルプに記載されています。

ビルドファームから大きなパッケージを署名していますか。お問い合わせください。コードを書いた本人から返信が届きます。