1. はじめに
コード署名とは、実行ファイルやスクリプトに発行者の電子署名を付ける仕組みです。Windows では Authenticode という形式を使います。署名があると、利用する側で「誰が作ったか」「配布後に改ざんされていないか」を確かめられます。
署名には秘密鍵が必要です。今回は、次の2つを両方満たす構成を試しました。
- 署名用の秘密鍵を HSM の外に出さない
- 署名を1回するたびに、HashiCorp Vault の監査ログに記録を残す
HSM(Hardware Security Module)は、秘密鍵を外に出さずに中で署名や暗号化の演算をする専用の装置です。HashiCorp Vault の Transit シークレットエンジンは、署名や暗号化の演算を API で受け付ける機能です。Transit の鍵の実体を HSM に置く機能(Managed Key)を使うと、「鍵は HSM の中」「署名の依頼は Vault が受け付けて監査ログに残す」という分担にできます。
前回の記事「HashiCorp Vault Transit と jsign で PowerShell にコード署名する - ソフトウェア鍵・SoftHSM(Managed Key)・ 自作PKCS#11ラッパーの3パターン検証」では、Java 製の署名ツール jsign を使ってこの構成を試しました。ただ、jsign 7.5 の Vault 連携モード(HASHICORPVAULT)は、鍵の実体が HSM にある Transit 鍵(type=managed_key)を扱えませんでした。そこで、jsign と Vault の間に自作の PKCS#11 ラッパー(約840行の C コード)を入れて回避しました。
今回は、Microsoft の署名ツール signtool のダイジェスト署名という機能を使います。ダイジェスト署名は、署名を3つの段階に分けて実行する機能です。
- signtool が、署名すべきデータのハッシュ(ダイジェスト)だけをファイルに書き出す
- そのダイジェストに、外部の仕組みで署名する(今回は Vault の Transit)
- signtool が、外部で作った署名を読み込んで、ファイルに埋め込む
1は signtool sign /dg、3は signtool sign /di というオプションで行います。オプションと、やり取りするファイル(.dig など)は、2.4節で説明します。
2の部分をファイルの受け渡しでつなぐので、PKCS#11 ラッパー(C コード)を作らずに、Vault を通して署名できます。署名の対象は Windows の実行ファイル(.exe)です。.exe は PE(Portable Executable)という形式のファイルで、.dll なども同じ形式です。今回は .exe で確かめました。
全体の流れは次のとおりです。
①〜③がダイジェスト署名の3つの段階です。④のタイムスタンプは、署名が終わった後に付ける別の手順です(4.5節)。
今回は HSM の代わりに SoftHSM(ソフトウェアで HSM の動きを再現するもの)を使っています。SoftHSM の鍵の実体はディスク上のファイルで、実際の HSM のような物理的な保護はありません。実際の HSM で同じ手順が動くかは確かめていません。
1.1 用語集
この記事で使う主な用語です。
署名の仕組み
| # | 用語 | 意味 |
|---|---|---|
| 1 | コード署名 | 実行ファイルやスクリプトに、発行者の電子署名を付けること。利用する側は「誰が作ったか」「配布後に改ざんされていないか」を確かめられる |
| 2 | Authenticode | Windows のコード署名の形式。署名は、ファイルの中に PKCS#7 の形で埋め込まれる |
| 3 | ハッシュ関数 | データから、決まった長さの値を計算する関数。同じデータからは必ず同じ値になり、1バイトでも違えばまったく別の値になる。値から元のデータには戻せない。この記事では SHA-256(結果は32バイト)を使う |
| 4 | ダイジェスト | ハッシュ関数で計算した値(ハッシュ値)。データの「指紋」として使う。署名では、データそのものではなくダイジェストに署名する。Authenticode では、ハッシュを3段階で計算する(5.1節の表)。Vault に送るのは、最後の段階の署名属性のダイジェスト(32バイト)だけで、exe の中身は送らない |
| 5 | 署名属性(signedAttrs) | 署名データの中で、実際に署名の対象になる部分。exe のダイジェストを含むデータのダイジェスト(messageDigest)などが入っている。この記事の .dig は、署名属性のダイジェスト(4.3節) |
| 6 | PKCS#7 | 署名値・証明書・署名属性などをまとめる、署名データの形式 |
| 7 | RSA PKCS#1 v1.5 | RSA の署名の方式の1つ。今回の Authenticode 署名ではこの方式を使った。同じ鍵で同じデータに署名すると、毎回同じ署名になる(6.2節で利用) |
| 8 | DigestInfo | PKCS#1 v1.5 で署名する直前のデータ。「SHA-256 のダイジェストである」という19バイトの目印と、ダイジェスト32バイトをつないだ51バイト(6.2節) |
| 9 | ダイジェスト署名 | signtool の機能。署名を「ダイジェストの書き出し(/dg)」「外部での署名」「署名の取り込み(/di)」の3段階に分ける(2.4節) |
| 10 | PE(Portable Executable) | Windows の実行ファイルの形式。.exe や .dll など |
鍵と Vault
| # | 用語 | 意味 |
|---|---|---|
| 11 | HSM(Hardware Security Module) | 秘密鍵を外に出さずに、中で署名や暗号化をする専用の装置 |
| 12 | SoftHSM | HSM の動きをソフトウェアで再現するもの。この記事では HSM の代わりに使う。鍵の実体はディスク上のファイル |
| 13 | PKCS#11 | HSM を操作するための標準の API。Vault は、HSM のメーカーが配る PKCS#11 のライブラリを読み込んで HSM を使う |
| 14 | HashiCorp Vault | 秘密情報や鍵を管理するソフトウェア。この記事では、署名の依頼を受け付けて監査ログに残す役割 |
| 15 | Transit | Vault の機能(シークレットエンジン)の1つ。署名や暗号化を API で受け付ける。鍵は Vault の外に出さない |
| 16 | Managed Key | Vault の外(HSM やクラウドの鍵管理サービス)にある鍵を、Vault から使えるように登録する機能。Transit の鍵を type=managed_key で作ると、署名の演算はその HSM で行われる |
| 17 | 監査ログ | Vault が受けたリクエストとレスポンスの記録。既定では、文字列の値を HMAC 化して記録する。HMAC は鍵を使って計算するハッシュで、鍵は監査デバイスごとに Vault が持つ |
| 18 | ポリシー | Vault で、どの API を呼んでよいかを決める設定。HCL という形式のファイルに書く |
| 19 | unseal | 起動した直後の Vault は、データを暗号化したまま読めない状態(sealed)。unseal 鍵を入力して、使える状態に戻す操作 |
| 20 | raft クラスタ | Vault の複数のノードがデータを複製し合い、1つの Vault として動く構成 |
証明書とタイムスタンプ
| # | 用語 | 意味 |
|---|---|---|
| 21 | 証明書 | 公開鍵と、その持ち主の名前(Subject)を、CA が署名して保証したもの。検証する側は、証明書の公開鍵で署名を検証する |
| 22 | CA(認証局) | 証明書を発行するもの。この記事では、Vault の PKI で Root CA と Issuing CA(証明書を実際に発行する CA)を作った |
| 23 | リーフ証明書 | 証明書チェーンの末端の証明書。この記事では、署名者の証明書(CN=Code Signing Demo) |
| 24 | 証明書チェーン | リーフ証明書から Root CA までのつながり。Windows はこれをたどって、信頼できる署名かを判断する |
| 25 | Thumbprint/フィンガープリント | 証明書全体のハッシュ値(SHA-1 や SHA-256)。証明書が同じものかを比べるのに使う |
| 26 | PEM/DER | 証明書などのファイルの書き方。DER はバイナリ、PEM は DER を base64 にして -----BEGIN …----- で囲んだもの |
| 27 | base64 | バイナリのデータを、英数字と記号だけの文字列にする書き方。.dig や Vault の署名値はこの形 |
| 28 | タイムスタンプ/TSA | 「この署名がこの時刻に存在した」ことを、第三者の TSA(タイムスタンプ局)に証明してもらう仕組み。付けておくと、証明書の有効期限が切れた後も署名を有効と判定できる。この記事では DigiCert の TSA を使う |
「トークン」という言葉は、この記事で3つの意味で出てきます。
- Vault のトークン:Vault の API を呼ぶときの認証情報
- SoftHSM のトークン:鍵をしまう入れ物
- タイムスタンプトークン:TSA が返すタイムスタンプのデータ
2. 検証環境
2.1 使用バージョン
値は検証時の記録から転記しています。
| # | 項目 | 値 |
|---|---|---|
| 1 | Vault |
v2.1.0+ent.hsm.fips1403(3ノードの raft クラスタ。PKCS#11 の Managed Key を使うため、HSM 対応ビルドを使用) |
| 2 | SoftHSM | 2.6.1 |
| 3 | OpenSC(pkcs11-tool) |
0.22.0-1ubuntu2.1 |
| 4 | mingw-w64(Linux で Windows 用の exe をビルドするコンパイラ) |
x86_64-w64-mingw32-gcc (GCC) 10-win32 20220113(パッケージ gcc-mingw-w64 10.3.0-14ubuntu1+24.3) |
| 5 | osslsigncode(Linux で Authenticode 署名を検証するツール) |
2.2.0(OpenSSL 3.0.2) |
| 6 | signtool | Windows SDK 10.0.28000.0 の x64\signtool.exe
|
| 7 | PowerShell | Windows PowerShell 5.1.26100.9444
|
| 8 | OS | Ubuntu 22.04(WSL2, kernel 6.6.87.2-microsoft-standard-WSL2)と、そのホストの Windows(Microsoft Windows NT 10.0.26200.0) |
raft クラスタは、Vault の複数のノードがデータを複製し合って1つの Vault として動く構成です。今回は3ノード(ポート 8210/8220/8230)です。4章以降は、そのときのリーダーだった 8220 に接続しています。3章の作業をしたときのリーダーは 8230 だったため、3.5節の証明書の URL には 8230 が入っています。
2.2 ファイルの受け渡し
この記事では、Windows と Linux(WSL2)の2つを使っています。署名そのものに Linux は必須ではありません。今回は、Windows に exe をビルドするツールなどをインストールしないようにするため、一部の処理を Linux で行いました。Linux で行ったのは次の処理です。
| # | 処理 | Linux で使ったツール |
|---|---|---|
| 1 | 署名する exe のビルド(4.1節) | mingw-w64 |
| 2 | Vault への署名の依頼(4.4節) |
vault・jq・openssl
|
| 3 | 署名結果の確認(5.1節) | osslsigncode |
Windows で行ったのは、signtool を使う①・③・④と、Windows での署名の確認(5.2節)です。signtool は、Windows SDK に入っているものを使いました。
Windows と Linux(WSL2)の間は、ファイルで受け渡します。Windows の C:\work_vault\phase18-pe\ は、WSL2 からは /mnt/c/work_vault/phase18-pe/ として見えます。フォルダ名やトークン名などに出てくる phase18 は、今回の検証の作業名です。
この構成では、署名の手順で、Windows から Vault への通信は要りません。Vault に接続するのは Linux 側のスクリプトだけです。ただし、④のタイムスタンプでは、Windows からインターネット上の DigiCert の TSA(http://timestamp.digicert.com)に接続します。
Vault が受け取るのは32バイトのダイジェストだけで、それが何のファイルのダイジェストかを Vault は判断できません。署名用のトークンを持つ人は、どのファイルのダイジェストにも署名させられます。そのため、守るべきものは、署名用のトークンとそのポリシー、そして HSM の PIN と HSM へのアクセスです(Vault を通さない署名については 6.2節)。今回は、署名1回ごとに使い捨てのトークンを発行する方法を試しました(6.3節の方法 B)。署名の前に承認を求めたり、リクエストの中身で許可・拒否を決めたりする仕組み(Vault Enterprise の Control Groups・Sentinel)は試していません(8章)。
一方で、exe の中身は Vault に送られないので、Vault の側に署名するファイルを置く必要はありません。
記事の Linux のコマンドは、作業ディレクトリからの相対パス(work/・scripts/)で書いています。証明書のファイル(certfile.pem・root-ca.crt・issuing-ca.crt)は、3.5節の最後で説明します。
コマンドと出力は、記録から転記しています。記録で ; や && でつないで1行で実行したコマンドは、次のいずれかで載せています。
- コマンドごとに分けて、それぞれにコメントを付けた。この場合は、「実際には1行で実行しました」と書いている
- 記録どおり1行のまま載せて、まとめて1つのコメントを付けた。長い行は、
\で改行して表示している(記録では1行) - 一部のコマンドだけを載せた。この場合は、コメントに「記録では、…もつないでいる。その部分は省略」と書いている
2.3 シーケンス図
2.4 signtool のダイジェスト署名で使うオプションとファイル
この記事で使う signtool のオプションと、ダイジェスト署名でやり取りするファイルをまとめます。オプションの説明は、Microsoft の公式ドキュメント(SignTool)によるものです。
オプション
| # | コマンドとオプション | 公式ドキュメントの説明(要約) | この記事での使い方 |
|---|---|---|---|
| 1 | sign /dg <フォルダ> |
署名すべきダイジェスト(digest)と、署名していない PKCS#7 のファイルを作る(generate)。出力は <フォルダ>\<ファイル名>.dig と <フォルダ>\<ファイル名>.p7u
|
①。署名すべきダイジェストを書き出す(4.2節) |
| 2 | sign /di <フォルダ> |
署名済みのダイジェストを、署名していない PKCS#7 に取り込んで(ingest)署名を作る。入力は <フォルダ>\<ファイル名>.dig.signed と <フォルダ>\<ファイル名>.p7u
|
③。Vault の署名を exe に埋め込む(4.5節) |
| 3 | sign /fd <アルゴリズム> |
ファイルのダイジェストに使うハッシュのアルゴリズム。署名するときに指定しないとエラーになる | /fd sha256 |
| 4 | sign /f <証明書のファイル> |
署名者の証明書のファイル。秘密鍵が入っていなくてもよい |
/f codesign.cer。秘密鍵の無い証明書だけを渡した |
| 5 | timestamp /tr <URL> |
RFC 3161 のタイムスタンプサーバー(TSA)の URL | ④。DigiCert の TSA |
| 6 | timestamp /td <アルゴリズム> |
/tr と一緒に使い、TSA に求めるハッシュのアルゴリズムを指定する |
/td sha256 |
| 7 | verify /pa |
既定の Authenticode の検証方針(Default Authentication Verification Policy)で検証する。指定しないと、Windows のドライバー用の方針で検証する | 5.2節の検証 |
| 8 |
/v(全コマンド共通) |
詳しい出力を表示する |
sign・timestamp・verify のすべて |
ダイジェスト署名には、この記事で使わなかったオプションもあります。
-
sign /ds:/dgが作ったダイジェストに、signtool 自身が署名する(/dgと/diの間の段階を signtool で行う)。 -
sign /dlib <DLL>:AuthenticodeDigestSignという関数を持つ DLL を指定する。公式ドキュメントには「/dg・/ds・/diを1回の操作で行うのと同じ」と書かれている。②の Vault への依頼を DLL にすれば、1回のコマンドで署名できる(この記事では試していない)。
ダイジェスト署名でやり取りするファイル
| # | ファイル | 作るもの | 中身 |
|---|---|---|---|
| 1 | <ファイル名>.dig |
/dg |
署名すべきダイジェスト。中身の形式は公式ドキュメントに書かれていない。調べたところ、署名属性の SHA-256 を base64 にした値だった(4.3節) |
| 2 | <ファイル名>.p7u |
/dg |
署名していない PKCS#7(unsigned)。署名者の証明書と署名属性が入っていて、署名値の欄にだけ仮の値が入っている(4.3節) |
| 3 | <ファイル名>.dig.signed |
自分で作る(この記事では②の sign_dig.sh) |
.dig に署名した結果。形式は公式ドキュメントに書かれていない。RSA の署名値(384バイト)を base64 にしたものを、/di が受け付けた(4.5節) |
/di は .p7u の仮の署名値を .dig.signed の署名値に入れ替えて、exe に埋め込みます。そのため、/dg と /di には同じフォルダを指定し、.p7u を消したり書き換えたりしないでください。
3. Vault 側の準備
この章では、署名用の鍵を SoftHSM の中に作り、Vault の Transit から使えるようにします。あわせて、コード署名用の証明書を Vault の PKI で発行します。
3.1〜3.5節の作業は、前回の記事までの検証で行ったものです(コマンドと出力は、そのときの記録から転記しています)。今回は、その環境をそのまま使いました。3.5節の証明書の発行日(2026年9月16日)が、署名した日(10月9日)より前なのはそのためです。ただし、3.4節の設定の読み戻しと、3.5節の最後のファイルの比較は、今回行いました。今回新しく作ったのは、3.6節のポリシーとトークンです。
3.1 監査ログを有効にする
Vault は、監査デバイスを1つ以上有効にしないと監査ログを記録しません。今回の環境では、Vault クラスタを作ったときに file 型の監査デバイスを有効にしています。
# 監査デバイス(file)を有効化し、監査ログをファイルに書き出す
$ vault audit enable file file_path=<vault-poc>/audit/vault-audit.log
Success! Enabled the file audit device at: file/
# 有効になっている監査デバイスを確認する
$ vault audit list -detailed
Path Type Description Replication Options
---- ---- ----------- ----------- -------
file/ file n/a replicated file_path=<vault-poc>/audit/vault-audit.log
<vault-poc> は、検証用の Vault の作業ディレクトリです(以降も同じ)。
3.2 SoftHSM を入れて、Vault から使えるようにする
SoftHSM は、HSM と同じ PKCS#11 という標準の API で操作できます。Vault は PKCS#11 のライブラリを読み込んで、HSM(今回は SoftHSM)に署名を依頼します。
まず SoftHSM と、SoftHSM の中身を確認するツール pkcs11-tool(OpenSC に含まれる)を入れます。
# SoftHSM(PKCS#11 ライブラリと管理ツール softhsm2-util)と OpenSC(pkcs11-tool)をインストールする
$ sudo apt-get install -y softhsm2 opensc
Setting up softhsm2-common (2.6.1-2ubuntu1) ...
Creating config file /etc/softhsm/softhsm2.conf with new version
(中略)
Setting up libsofthsm2 (2.6.1-2ubuntu1) ...
Setting up softhsm2 (2.6.1-2ubuntu1) ...
Setting up opensc (0.22.0-1ubuntu2.1) ...
# インストールされたバージョンを確認する
$ softhsm2-util --version
2.6.1
# Vault に指定する PKCS#11 ライブラリの実体を確認する
$ readlink -f /usr/lib/softhsm/libsofthsm2.so
/usr/lib/x86_64-linux-gnu/softhsm/libsofthsm2.so
次に、SoftHSM のトークン(鍵をしまう入れ物。HSM の「スロットに挿したカード」にあたる)を作ります。既定のトークン置き場(/var/lib/softhsm/tokens)は root:softhsm の権限で、Vault を動かしているユーザーからは読めませんでした。そこで、Vault と同じユーザーが持つ専用のディレクトリを用意しました。
# SoftHSM のトークン用のディレクトリを作る
$ mkdir -p <vault-poc>/softhsm/tokens
# SoftHSM の設定ファイルを作る(トークン置き場を上のディレクトリにする)
$ cat > <vault-poc>/softhsm/softhsm2.conf <<'EOF'
# SoftHSM v2 configuration for Vault PoC (demo)
directories.tokendir = <vault-poc>/softhsm/tokens
objectstore.backend = file
log.level = INFO
slots.removable = false
slots.mechanisms = ALL
library.reset_on_fork = false
EOF
# SoftHSM のツールとライブラリがこの設定ファイルを読むように、環境変数で指定する
$ export SOFTHSM2_CONF=<vault-poc>/softhsm/softhsm2.conf
# トークンを初期化する(ラベル vault-hsm-demo、管理者用 PIN と利用者用 PIN を設定)
$ softhsm2-util --init-token --free --label vault-hsm-demo --so-pin <SO-PIN> --pin <USER-PIN>
Slot 0 has a free/uninitialized token.
The token has been initialized and is reassigned to slot 1837329727
スロット番号(1837329727)は、初期化のたびに変わります。そのため、後の Managed Key の設定では、スロット番号ではなくトークンのラベル(vault-hsm-demo)で指定します。PIN はデモ用の値を使いました。本番では秘密として管理してください。
Vault のサーバ設定ファイル(.hcl)に、PKCS#11 ライブラリを登録する kms_library の設定を追記します。3ノードすべてに同じ内容を追記しました。
kms_library "pkcs11" {
name = "softhsm"
library = "/usr/lib/softhsm/libsofthsm2.so"
}
| # | 項目 | 意味 |
|---|---|---|
| 1 | kms_library "pkcs11" |
外部の鍵管理の仕組みを PKCS#11 で使う、という宣言。"pkcs11" は種類を表す |
| 2 | name |
この設定の名前。Managed Key を登録するときに library=softhsm として参照する |
| 3 | library |
Vault が読み込む PKCS#11 ライブラリのパス。実際の HSM では、HSM ベンダーのライブラリのパスにする |
SoftHSM のライブラリは Vault のプロセスの中で読み込まれます。そのため、SOFTHSM2_CONF は Vault サーバのプロセスの環境変数として渡す必要があります。環境変数を渡すために、3ノードを一度止めてから、次のように起動し直しました。
# 3ノードの Vault を、SOFTHSM2_CONF を環境変数に入れて起動する
$ for n in 1 2 3; do
nohup env SOFTHSM2_CONF=<vault-poc>/softhsm/softhsm2.conf \
/usr/local/bin/vault server -config=<vault-poc>/node$n/config/vault-$n.hcl \
>> <vault-poc>/node$n/vault-hsm.log 2>&1 &
done
起動した直後の Vault は、データを暗号化したまま読めない状態(sealed)です。unseal は、Vault を初期化したときに分割して受け取った鍵(unseal 鍵)を必要な数だけ入力して、使える状態に戻す操作です。今回は鍵の値を画面やログに出さないように、変数に入れてから各ノードで2回ずつ入力しました。
unseal の手順(3ノード)
# 初期化時の出力から unseal 鍵を2つ変数に読み込む(値は表示しない)
$ K0=$(python3 -c "import json;print(json.load(open('<vault-poc>/init-output.json'))['unseal_keys_b64'][0])")
$ K1=$(python3 -c "import json;print(json.load(open('<vault-poc>/init-output.json'))['unseal_keys_b64'][1])")
# 3ノードそれぞれで unseal し、Sealed の状態を表示する
$ for p in 8210 8220 8230; do
export VAULT_ADDR=https://127.0.0.1:$p
vault operator unseal "$K0" >/dev/null 2>&1
vault operator unseal "$K1" >/dev/null 2>&1
echo -n ":$p "; vault status | grep -E '^Sealed'
done
:8210 Sealed false
:8220 Sealed false
:8230 Sealed false
# 鍵を入れた変数を消す
$ unset K0 K1
unseal 鍵を初期化時の出力ファイル(init-output.json)から読むのは、検証用の簡略化です。本番では、unseal 鍵をファイルに置かないでください。
3.3 Managed Key と、managed_key 型の Transit 鍵を作る
Managed Key は、Vault の外(HSM やクラウドの鍵管理サービス)にある鍵を、Vault から使えるように登録する機能です。
# SoftHSM の鍵を、Vault の Managed Key として登録する
$ vault write sys/managed-keys/pkcs11/hsm-codesign \
library=softhsm \
token_label=vault-hsm-demo \
pin=<USER-PIN> \
key_label=hsm-codesign-rsa3072 \
mechanism=0x0001 \
key_bits=3072 \
allow_generate_key=true \
usages=sign,verify
Success! Data written to: sys/managed-keys/pkcs11/hsm-codesign
| # | パラメータ | 値 | 意味 |
|---|---|---|---|
| 1 | library |
softhsm |
3.2節の kms_library の name。ライブラリのパスではない |
| 2 | token_label |
vault-hsm-demo |
使うトークンのラベル |
| 3 | key_label |
hsm-codesign-rsa3072 |
SoftHSM の中での鍵の名前 |
| 4 | mechanism |
0x0001 |
署名の方式。0x0001(CKM_RSA_PKCS)は RSA PKCS#1 v1.5。今回の Authenticode 署名ではこの方式を使った |
| 5 | key_bits |
3072 |
RSA の鍵長 |
| 6 | allow_generate_key |
true |
鍵が無ければ、Vault が SoftHSM の中に生成してよい |
| 7 | usages |
sign,verify |
この鍵を署名と検証だけに使う |
登録した直後は、SoftHSM の中にまだ鍵がありません。鍵は、初めて使うときに作られます。test/sign を呼んで、鍵を作らせました。
# Managed Key の署名テストを実行する(このとき SoftHSM の中に鍵が生成される)
$ vault write -f sys/managed-keys/pkcs11/hsm-codesign/test/sign
Success! Data written to: sys/managed-keys/pkcs11/hsm-codesign/test/sign
# SoftHSM のトークンの中のオブジェクト(鍵)を一覧表示する
$ pkcs11-tool --module /usr/lib/softhsm/libsofthsm2.so --token-label vault-hsm-demo \
--login --pin <USER-PIN> --list-objects
Public Key Object; RSA 3072 bits
label: hsm-codesign-rsa3072
ID: 31383338393030303237
Usage: encrypt, verify, wrap
Access: local
Private Key Object; RSA
label: hsm-codesign-rsa3072
ID: 31383338393030303237
Usage: decrypt, sign, unwrap
Access: sensitive, always sensitive, never extractable, local
秘密鍵の Access に注目してください。local は SoftHSM の中で生成されたこと、never extractable は秘密鍵を取り出せないことを表します(どちらも SoftHSM 自身が示す属性です)。
次に、Transit を有効にして、この Managed Key を使う Transit 鍵 codesign を作ります。
# Managed Key hsm-codesign の利用を許可して、Transit を有効化する
$ vault secrets enable -allowed-managed-keys=hsm-codesign transit
Success! Enabled the transit secrets engine at: transit/
# Managed Key を使う Transit 鍵を作る(鍵の実体は SoftHSM の中)
$ vault write transit/keys/codesign type=managed_key managed_key_name=hsm-codesign
Key Value
--- -----
allow_plaintext_backup false
(中略)
exportable false
(中略)
latest_version 1
(中略)
name codesign
(中略)
supports_signing true
type managed_key
type が managed_key になっています。exportable と allow_plaintext_backup はどちらも false で、Vault からも鍵を取り出せません。
3.4 監査ログに署名の中身を残す設定
Vault の監査ログは、既定ではリクエストとレスポンスの文字列の値を HMAC 化して記録します(値そのものではなく、値から計算したハッシュを書く)。そのままだと「何かに署名した」ことは分かっても、何に署名したか、どんな署名を返したかは読めません。
そこで、署名に関係する一部のキーだけを、HMAC 化せずに平文で記録する設定を入れます。
# transit/ の監査ログで、署名の確認に必要なキーだけを平文で記録する
$ vault secrets tune \
-audit-non-hmac-request-keys=input \
-audit-non-hmac-request-keys=prehashed \
-audit-non-hmac-request-keys=hash_algorithm \
-audit-non-hmac-request-keys=signature_algorithm \
-audit-non-hmac-request-keys=marshaling_algorithm \
-audit-non-hmac-response-keys=signature \
-audit-non-hmac-response-keys=key_version \
transit
Success! Tuned the secrets engine at: transit/
| # | キー | 対象 | 平文で残す理由 |
|---|---|---|---|
| 1 | input |
リクエスト | 署名したダイジェスト。6.1節で、exe から監査ログの記録を探すのに使う |
| 2 |
prehashed・hash_algorithm・signature_algorithm・marshaling_algorithm
|
リクエスト | どの方式で署名したか |
| 3 | signature |
レスポンス | Vault が返した署名値 |
| 4 | key_version |
レスポンス | 署名に使った鍵のバージョン |
フラグは、カンマでつながずに1キーずつ指定します。設定が入っていることは、4章の署名の後(6.1節の確認をしたとき)に読み戻して確認しました。
# transit/ の監査ログ設定を読み戻す
$ vault read -format=json sys/mounts/transit/tune | jq -c '.data | {audit_non_hmac_request_keys,audit_non_hmac_response_keys}'
{"audit_non_hmac_request_keys":["input","prehashed","hash_algorithm","signature_algorithm","marshaling_algorithm"],"audit_non_hmac_response_keys":["signature","key_version"]}
平文で残すのは、署名するダイジェストと署名値だけです。署名するファイルの中身は、そもそも Vault に送られません(Vault に送るのはダイジェストだけです)。
3.5 コード署名用の証明書を Vault の PKI で発行する
Authenticode の署名には、署名者を示す証明書も埋め込みます。検証する側(Windows など)は、証明書を見て「誰の署名か」を表示し、信頼できる CA までのつながり(証明書チェーン)をたどり、証明書の公開鍵で署名値を検証します。
証明書は、Vault の PKI シークレットエンジン(CA の機能)で発行しました。自前の Root CA と、その下の Issuing CA(実際に証明書を発行する CA)を作り、Issuing CA が署名者の証明書(リーフ証明書。チェーンの末端の証明書)を発行します。
この節のコマンドは、応答(JSON)から証明書や CSR を取り出してファイルに保存しています(root-ca.pem・inter.csr・inter-ca.pem・codesign.csr・leaf.pem・chain.pem)。ただし、保存に使ったコマンド(jq での取り出しなど)は記録に残っていません。コメントに保存先のファイル名だけを書いています。また、出力を載せていないコマンドは、出力が記録に残っていないものです。
# Root CA 用の PKI を有効化する(有効期間の上限は 10年)
$ vault secrets enable -path=pki_codesign_root -max-lease-ttl=87600h pki
Success! Enabled the pki secrets engine at: pki_codesign_root/
# Issuing CA 用の PKI を有効化する(有効期間の上限は 5年)
$ vault secrets enable -path=pki_codesign -max-lease-ttl=43800h pki
Success! Enabled the pki secrets engine at: pki_codesign/
# Root CA を作る(自己署名。CA の秘密鍵は Vault の中で生成される。応答の certificate を root-ca.pem に保存した)
$ vault write -format=json pki_codesign_root/root/generate/internal \
common_name="PoC Code Signing Root CA" issuer_name="poc-codesign-root" \
key_type=rsa key_bits=4096 ttl=87600h
# Issuing CA の鍵を Vault の中で作り、その CSR(証明書の発行依頼)を inter.csr に保存した
$ vault write -format=json pki_codesign/intermediate/generate/internal \
common_name="PoC Code Signing Issuing CA" key_type=rsa key_bits=4096
# Root CA が Issuing CA の CSR に署名する(応答の証明書を inter-ca.pem に保存した)
$ vault write -format=json pki_codesign_root/root/sign-intermediate \
issuer_ref="poc-codesign-root" csr=@inter.csr format=pem_bundle ttl=43800h
# 署名された Issuing CA の証明書を取り込む(これで Issuing CA が証明書を発行できる)
$ vault write -format=json pki_codesign/intermediate/set-signed certificate=@inter-ca.pem
# 発行する証明書に入れる、CA 証明書と失効リスト(CRL)の URL を設定する
$ vault write pki_codesign/config/urls \
issuing_certificates="https://127.0.0.1:8230/v1/pki_codesign/ca" \
crl_distribution_points="https://127.0.0.1:8230/v1/pki_codesign/crl"
次に、Transit 鍵 codesign の CSR を作ります。Transit の CSR の API は、渡した CSR の Subject(証明書の持ち主の名前)を引き継ぎ、公開鍵だけを Transit 鍵のものに差し替えて返します。そこで、Subject を伝えるためだけの CSR を、使い捨ての鍵で作って渡しました。
# Subject を伝えるためのテンプレートの CSR を作る(鍵はその場限りの使い捨て)
$ openssl req -new -newkey rsa:2048 -nodes -keyout throwaway.key -out template.csr \
-subj "/C=JP/O=PoC/OU=Code Signing/CN=Code Signing Demo"
# Transit に渡すと、Subject はそのまま・公開鍵は Transit 鍵(SoftHSM の鍵)に差し替わった CSR が返る(codesign.csr に保存した)
$ vault write -format=json transit/keys/codesign/csr csr=@template.csr
# 返ってきた CSR の Subject と、CSR 自体の署名を確かめる
$ openssl req -in codesign.csr -noout -subject -verify
Certificate request self-signature verify OK
subject=C = JP, O = PoC, OU = Code Signing, CN = Code Signing Demo
self-signature verify OK は、CSR の中の公開鍵と対になる秘密鍵で、CSR が署名されていることを表します。その公開鍵が SoftHSM の鍵の公開鍵と同じであることは、公開鍵の SHA-256 を比べて確かめました(記録には比べた結果だけが残っています)。
5f20628740c4b15dab77573c80600c6c25942cf3159d21d12bbaf4afde6f52a6 <- transit CSR の公開鍵
5f20628740c4b15dab77573c80600c6c25942cf3159d21d12bbaf4afde6f52a6 <- SoftHSM 内の公開鍵
d833b10f2fa83019d69e03638f42cb5648827743d98e6dae7801a7f09fcd107d <- テンプレート(捨て鍵)の公開鍵
Subject はテンプレートから引き継がれ、公開鍵は SoftHSM の鍵に差し替わっています。テンプレートを作ったときの使い捨ての鍵(throwaway.key)は、ディスクに残ります。証明書には使われないので、使い終わったら消してかまいません。
続いて、コード署名用の証明書を発行するルール(ロール)を作り、CSR に署名して証明書を発行します。
# コード署名用のロールを作る(用途はコード署名だけ、RSA 3072、有効期間1年、C/O/OU はロールで指定)
$ vault write pki_codesign/roles/codesign \
allow_any_name=true enforce_hostnames=false allow_bare_domains=true \
cn_validations=disabled \
key_type=rsa key_bits=3072 \
ext_key_usage="CodeSigning" key_usage="DigitalSignature" \
server_flag=false client_flag=false code_signing_flag=true \
use_csr_common_name=true use_csr_sans=false require_cn=false \
country="JP" organization="PoC" ou="Code Signing" \
ttl=8760h max_ttl=8760h
# Transit 鍵の CSR に署名して、証明書を発行する(応答の証明書を leaf.pem に保存した)
$ vault write -format=json pki_codesign/sign/codesign csr=@codesign.csr ttl=8760h
# 発行された証明書の持ち主・発行者・有効期間・シリアル番号を表示する
$ openssl x509 -in leaf.pem -noout -subject -issuer -dates -serial
subject=C = JP, O = PoC, OU = Code Signing, CN = Code Signing Demo
issuer=CN = PoC Code Signing Issuing CA
notBefore=Sep 16 08:35:23 2026 GMT
notAfter=Sep 16 08:35:53 2027 GMT
serial=4793C7425E934EED5524FD55EA2B3114DD069B19
use_csr_common_name=true で CSR から引き継がれるのは CN だけです。C・O・OU はロールの country・organization・ou で決まります。最初はロールに C・O・OU を入れずに発行したため、Subject が CN = Code Signing Demo だけになりました。また、cn_validations=disabled が無いと、ホスト名でもメールアドレスでもない CN(Code Signing Demo)が受け付けられないことがあります。
発行した証明書を、Root CA までの証明書チェーンとして Transit 鍵に紐付けておきます。
# リーフ → Issuing CA → Root CA の順につないだ chain.pem の中身を確かめる
$ openssl crl2pkcs7 -nocrl -certfile chain.pem | openssl pkcs7 -print_certs -noout
subject=C = JP, O = PoC, OU = Code Signing, CN = Code Signing Demo
issuer=CN = PoC Code Signing Issuing CA
subject=CN = PoC Code Signing Issuing CA
issuer=CN = PoC Code Signing Root CA
subject=CN = PoC Code Signing Root CA
issuer=CN = PoC Code Signing Root CA
# 証明書チェーンを Transit 鍵に保存する
$ vault write transit/keys/codesign/set-certificate certificate_chain=@chain.pem
4章では、この節の証明書を次のファイル名で使います。どれも前回の記事の作業で、この節の証明書から作ったファイルです。作ったときのコマンドは記録に残っていないため、ハッシュとフィンガープリントを比べて、中身が同じであることを確かめました(手順は表の下の折りたたみ)。
| # | 4章で使うファイル | 中身 |
|---|---|---|
| 1 | certfile.pem |
chain.pem(3枚)の後ろに Root CA の証明書をもう1枚つないだ4枚。先頭がリーフ証明書 |
| 2 | root-ca.crt |
Root CA の証明書(root-ca.pem と同じ証明書) |
| 3 | issuing-ca.crt |
Issuing CA の証明書(inter-ca.pem と同じ証明書) |
ファイルの中身が同じことの確認(ハッシュとフィンガープリント)
下のコマンドのディレクトリは次のとおりです。
-
<prev>:前回の作業ディレクトリ -
<pki>:この節の作業ディレクトリ(<vault-poc>/pkiとは別)
# certfile.pem と、chain.pem に root-ca.pem をつないだもののハッシュを比べる(1行目と4行目が同じなら同じ中身。記録では、この後ろに 3.5節の各ファイルのハッシュの表示もつないでいる。その部分は省略)
$ sha256sum <prev>/certfile.pem <prev>/root-ca.crt <prev>/issuing-ca.crt; cat <pki>/chain.pem <pki>/root-ca.pem | sha256sum
52e87d0a175a82e7cd0fbae3aa6f1b982b13f7338bd718df11331c336c4333ee <prev>/certfile.pem
67c49d82283cc37f814b5f527529f0a51127327c6d8345ab5a2fbf787e8aa3a8 <prev>/root-ca.crt
9dabb3d5ac0b933e8a0375dbb9e69aee4f26cf0f43600868048432fcd3bc0758 <prev>/issuing-ca.crt
52e87d0a175a82e7cd0fbae3aa6f1b982b13f7338bd718df11331c336c4333ee -
# CA 証明書の SHA-1 フィンガープリントを比べる(記録では、この後ろにファイルどうしの diff と file コマンドもつないでいる。その部分は省略)
$ for f in <prev>/root-ca.crt <pki>/root-ca.pem <prev>/issuing-ca.crt <pki>/inter-ca.pem; do echo "$f: $(openssl x509 -in $f -noout -fingerprint -sha1)"; done
<prev>/root-ca.crt: sha1 Fingerprint=7A:AE:DA:C5:5E:02:EC:8D:53:02:BA:21:E5:DF:B2:25:2F:90:77:05
<pki>/root-ca.pem: sha1 Fingerprint=7A:AE:DA:C5:5E:02:EC:8D:53:02:BA:21:E5:DF:B2:25:2F:90:77:05
<prev>/issuing-ca.crt: sha1 Fingerprint=E1:89:E2:48:AB:FE:53:74:CF:90:DF:85:6F:47:83:66:E6:1F:C4:B2
<pki>/inter-ca.pem: sha1 Fingerprint=E1:89:E2:48:AB:FE:53:74:CF:90:DF:85:6F:47:83:66:E6:1F:C4:B2
root-ca.crt と root-ca.pem はファイルとしてはバイト単位で同じではありませんが、フィンガープリントが一致するので同じ証明書です(issuing-ca.crt と inter-ca.pem も同じ)。
3.6 署名用と、拒否を確かめる用のトークンを作る
Vault では、API を呼ぶときにトークン(Vault の認証情報)を使います。トークンに付けるポリシーで、どの API を呼べるかを決めます。今回は、次の2つを用意しました。
-
phase18-sign:署名(transit/sign/codesign)と、鍵の情報の読み取りができる。鍵の情報の読み取りは、4.1節で Transit 鍵の公開鍵を取り出すのに使う -
phase18-nosign:鍵の情報を読めるだけで、署名はできない(5.3節で、署名が拒否されることを確かめる)。読み取りだけを許可したのは、同じ鍵でも、署名の path だけを止められることを示すため
ポリシーは、HCL 形式のファイルに書きます。2つのファイルの中身は次のとおりです(実際のファイルは、それぞれの path を1行で書いています)。
# 署名用:署名(transit/sign/codesign の update)と、鍵の情報の読み取りを許可する
path "transit/sign/codesign" {
capabilities = ["update"]
}
path "transit/keys/codesign" {
capabilities = ["read"]
}
# 拒否を確かめる用:鍵の情報の読み取りだけを許可する(署名の path が無いので、署名は拒否される)
path "transit/keys/codesign" {
capabilities = ["read"]
}
capabilities の update は、transit/sign に署名を依頼する(データを送って結果を受け取る)操作の許可です。read は、鍵の情報(公開鍵など)を読む許可です。
この2つのファイルを、Vault にポリシーとして登録します。
# 署名用のポリシーを Vault に登録する
$ vault policy write phase18-sign work/phase18-sign.hcl
Success! Uploaded policy: phase18-sign
# 拒否を確かめる用のポリシーを Vault に登録する
$ vault policy write phase18-nosign work/phase18-nosign.hcl
Success! Uploaded policy: phase18-nosign
実際には、ファイルの中身を表示する cat と、この2つのコマンドを ; でつないで1行で実行しました。それぞれのポリシーで、有効期間8時間のトークンを発行しました(トークンの値はファイル work/token-sign・work/token-nosign に保存し、記事には載せません)。
# 署名用のトークンを発行する(値はファイルに保存する)
$ vault token create -policy=phase18-sign -ttl=8h -display-name=phase18-sign -field=token > work/token-sign
# 拒否を確かめる用のトークンを発行する
$ vault token create -policy=phase18-nosign -ttl=8h -display-name=phase18-nosign -field=token > work/token-nosign
# 発行したトークンの名前・ポリシー・残り時間を確かめる
$ for f in work/token-sign work/token-nosign; do VAULT_TOKEN=$(cat $f) vault token lookup -format=json | jq -c "{display_name:.data.display_name,policies:.data.policies,ttl:.data.ttl}"; done
{"display_name":"token-phase18-sign","policies":["default","phase18-sign"],"ttl":28799}
{"display_name":"token-phase18-nosign","policies":["default","phase18-nosign"],"ttl":28800}
display_name は、監査ログに記録されるトークンの名前です。-display-name に指定した値の前に token- が付きます(発行したコマンドは記録に要約しか残っていないため、-display-name を付けたことは、この結果から補いました)。
Linux 側のスクリプトは、Vault の接続先を work/vaultenv.sh から読み込みます。
VAULT_CACERT は、Vault のサーバ証明書(TLS)を検証するための CA 証明書です。コード署名用の Root CA(3.5節の <pki>/root-ca.pem、4章の root-ca.crt)とは別の証明書です。3.5節の <pki>/root-ca.pem とファイル名が同じなので、取り違えないでください。4章以降の Linux の vault コマンドは、このファイルを source してから実行しています(4.1節の公開鍵の比較だけは、同じ値をコマンドの中で直接指定しました)。
export VAULT_ADDR=https://127.0.0.1:8220
export VAULT_CACERT=<vault-poc>/pki/root-ca.pem
4. 署名の実行
4.1 準備
署名するファイルを作る
署名するファイルとして、メッセージを1行表示するだけの exe を作りました。Linux で、Windows 用の exe をビルドできるコンパイラ(mingw-w64)を使います。
# Windows 用の exe をビルドするコンパイラ(mingw-w64)と、Linux で署名を検証するツール(osslsigncode)を入れる
$ sudo apt-get install -y gcc-mingw-w64 osslsigncode
# インストールされたバージョンを確認する
$ x86_64-w64-mingw32-gcc --version | head -1; osslsigncode --version 2>&1 | head -2
x86_64-w64-mingw32-gcc (GCC) 10-win32 20220113
osslsigncode 2.2.0, using:
OpenSSL 3.0.1 14 Dec 2021 (Library: OpenSSL 3.0.2 15 Mar 2022)
実際には、次の5つのコマンドを ; と && でつないで1行で実行しました。
# ソースを表示する
$ cat work/hello.c
#include <stdio.h>
int main(void) { printf("Hello from phase18 Case D (signed via Vault transit/sign)\n"); return 0; }
# Windows 用の exe(test.exe)にビルドする
$ x86_64-w64-mingw32-gcc -O2 -o work/test.exe work/hello.c
# できたファイルの種類を確かめる
$ file work/test.exe
work/test.exe: PE32+ executable (console) x86-64, for MS Windows
# 署名前の控え(test_unsigned.exe)を残す
$ cp work/test.exe work/test_unsigned.exe
# 2つのファイルのハッシュを表示する
$ sha256sum work/test.exe work/test_unsigned.exe
d1b7f7c7358a460ab0225e5d862b8a3747e535f41b157663abde4ea052a21edf work/test.exe
d1b7f7c7358a460ab0225e5d862b8a3747e535f41b157663abde4ea052a21edf work/test_unsigned.exe
PE32+ executable ... for MS Windows は、64ビット Windows 用の exe(PE 形式)であることを表します。
signtool に渡す証明書を用意する
signtool には、署名者の証明書(秘密鍵は含まない)だけを渡します。3.5節で発行した証明書を、Windows で扱いやすい DER 形式(バイナリ形式)の codesign.cer にしました。certfile.pem は、先頭がリーフ証明書のファイルです(3.5節の最後の表)。openssl x509 は先頭の1枚だけを読みます。実際には、次の4つのコマンドを && でつないで1行で実行しました。
# 先頭のリーフ証明書を DER 形式で codesign.cer に書き出す
$ openssl x509 -in certfile.pem -outform DER -out work/codesign.cer
# codesign.cer の持ち主・発行者・SHA-256 フィンガープリントを表示する
$ openssl x509 -inform DER -in work/codesign.cer -noout -subject -issuer -fingerprint -sha256
subject=C = JP, O = PoC, OU = Code Signing, CN = Code Signing Demo
issuer=CN = PoC Code Signing Issuing CA
sha256 Fingerprint=94:AB:4F:5F:9D:11:FE:B1:6B:C6:11:F3:03:6D:56:09:AD:12:01:78:73:99:5D:4A:53:37:D3:00:08:8E:67:D5
# CA 証明書を作業ディレクトリにコピーする
$ cp root-ca.crt issuing-ca.crt work/
# 証明書から公開鍵を取り出し、codesign-pub.pem に保存する(4.4節のスクリプトが署名の検証に使う)
$ openssl x509 -in work/codesign.cer -inform DER -pubkey -noout > work/codesign-pub.pem
証明書の公開鍵が、Vault の Transit 鍵 codesign の公開鍵と同じかを確かめます。2つの公開鍵を同じ DER 形式にしてから SHA-256 を計算して比べ、一致したらメッセージを表示します。
# Vault から Transit 鍵 v1 の公開鍵を取り出し、証明書の公開鍵とハッシュを比べる
$ VAULT_TOKEN=$(cat work/token-sign) VAULT_ADDR=https://127.0.0.1:8220 VAULT_CACERT=<vault-poc>/pki/root-ca.pem \
vault read -format=json transit/keys/codesign | jq -r '.data.keys."1".public_key' > work/vault-pub.pem; \
diff <(openssl pkey -pubin -in work/vault-pub.pem -outform DER | sha256sum) \
<(openssl pkey -pubin -in work/codesign-pub.pem -outform DER | sha256sum) \
&& echo 'PUBKEY MATCH: 証明書の公開鍵 = Vault transit codesign v1 の公開鍵'
PUBKEY MATCH: 証明書の公開鍵 = Vault transit codesign v1 の公開鍵
作ったファイルを、Windows の作業フォルダにコピーします。
# exe・証明書・CA 証明書を Windows の作業フォルダにコピーする
$ cp work/test.exe work/test_unsigned.exe work/codesign.cer work/root-ca.crt work/issuing-ca.crt /mnt/c/work_vault/phase18-pe/
Windows で CA を信頼しているか確かめる
Windows が署名を有効と判定するには、Root CA と Issuing CA の証明書が Windows の証明書ストアに入っている必要があります。今回の Windows には、前回の記事の検証のときに登録済みでした。そのときの手順書に書いたコマンドは次のとおりです(Root CA は「信頼されたルート証明機関」、Issuing CA は「中間証明機関」のストアに入れます)。実行したときの出力は記録に残っていません。
# Root CA の証明書を、現在のユーザーの「信頼されたルート証明機関」に登録する
Import-Certificate -FilePath '<証明書の場所>\root-ca.crt' -CertStoreLocation Cert:\CurrentUser\Root
# Issuing CA の証明書を、現在のユーザーの「中間証明機関」に登録する
Import-Certificate -FilePath '<証明書の場所>\issuing-ca.crt' -CertStoreLocation Cert:\CurrentUser\CA
今回は、登録されていることだけを確かめます。
Windows での作業は、PowerShell のトランスクリプト(画面の入出力を記録する機能)で記録しました。以降の Windows のコマンドは、どれも次の変数と作業フォルダを使います。Start-Transcript の行はトランスクリプトに残らないため、記録からではなく手順として載せています(記録の先頭には、出力ファイルのパスが残っています)。
# 画面の入出力をファイルに記録し始める
Start-Transcript -Path C:\work_vault\phase18-pe\logs\02-dg.txt -Append
# signtool のパスを変数に入れる
$st = 'C:\Program Files (x86)\Windows Kits\10\bin\10.0.28000.0\x64\signtool.exe'
# 作業フォルダに移動する
cd C:\work_vault\phase18-pe
# 現在のユーザーの「信頼されたルート」と「中間 CA」のストアから、PoC の CA 証明書を探す
PS C:\work_vault\phase18-pe> Get-ChildItem Cert:\CurrentUser\Root, Cert:\CurrentUser\CA | Where-Object { $_.Subject -match 'PoC Code Signing' } | Format-List Subject, Thumbprint, PSParentPath
Subject : CN=PoC Code Signing Root CA
Thumbprint : 7AAEDAC55E02EC8D5302BA21E5DFB2252F907705
PSParentPath : Microsoft.PowerShell.Security\Certificate::CurrentUser\Root
Subject : CN=PoC Code Signing Issuing CA
Thumbprint : E189E248ABFE5374CF90DF856F478366E61FC4B2
PSParentPath : Microsoft.PowerShell.Security\Certificate::CurrentUser\CA
Thumbprint は、証明書の SHA-1 ハッシュです。Linux 側の CA 証明書の SHA-1 フィンガープリントと比べて、どちらも一致しました。
Linux 側の CA 証明書のフィンガープリント
# Root CA と Issuing CA の証明書の SHA-1 フィンガープリントを表示する
$ openssl x509 -in work/root-ca.crt -noout -fingerprint -sha1; openssl x509 -in work/issuing-ca.crt -noout -fingerprint -sha1
sha1 Fingerprint=7A:AE:DA:C5:5E:02:EC:8D:53:02:BA:21:E5:DF:B2:25:2F:90:77:05
sha1 Fingerprint=E1:89:E2:48:AB:FE:53:74:CF:90:DF:85:6F:47:83:66:E6:1F:C4:B2
4.2 signtool でダイジェストを書き出す(/dg)
signtool sign /dg <フォルダ> は、署名に必要な情報を集めて、署名する直前の状態で止めるオプションです(オプションの一覧は2.4節)。実際の署名はせず、次の2つのファイルを書き出します。
| # | ファイル | 中身 |
|---|---|---|
| 1 | <ファイル名>.dig |
署名すべきダイジェスト(4.3節で中身を確かめる) |
| 2 | <ファイル名>.p7u |
署名データのひな形。署名値の欄には仮の値が入っている(4.3節)。PKCS#7 は、署名値・証明書・署名属性などをまとめる署名データの形式 |
# 署名すべきダイジェストを dig フォルダに書き出す(/fd sha256: ハッシュは SHA-256、/f: 署名者の証明書)
PS C:\work_vault\phase18-pe> & $st sign /v /dg C:\work_vault\phase18-pe\dig /fd sha256 /f C:\work_vault\phase18-pe\codesign.cer C:\work_vault\phase18-pe\test.exe
Done Adding Additional Store
Successfully signed: C:\work_vault\phase18-pe\test.exe
Number of files successfully Signed: 1
Number of warnings: 0
Number of errors: 0
PS C:\work_vault\phase18-pe> "exit=$LASTEXITCODE"
exit=0
# 書き出されたファイルを確かめる
PS C:\work_vault\phase18-pe> Get-ChildItem C:\work_vault\phase18-pe\dig | Format-Table Name, Length, LastWriteTime
Name Length LastWriteTime
---- ------ -------------
test.exe.dig 44 2026/10/09 10:11:28
test.exe.p7u 3143 2026/10/09 10:11:28
/f には、秘密鍵を含まない証明書(.cer)だけを渡しましたが、問題なく動きました。
/dg は Successfully signed と表示し、test.exe 自体も書き換えます。署名値の欄に仮の値が入った署名が埋め込まれ、ファイルサイズは 498,849 バイトから 502,008 バイトに増えました。この状態のファイルを検証すると、署名値の長さが正しくないため失敗します。
# /dg の後の test.exe を、Linux で検証する(抜粋)
$ osslsigncode verify -CAfile work/root-ca.crt -untrusted work/issuing-ca.crt -in /mnt/c/work_vault/phase18-pe/test.exe 2>&1 | tail -12
(中略)
8009DA5C587D0000:error:02000077:rsa routines:ossl_rsa_verify:wrong signature length:../crypto/rsa/rsa_sign.c:340:
(中略)
Signature verification: failed
(以下略)
仮の署名は、4.5節の /di で本物の署名に置き換わります。/dg から /di までの間に、exe を作り直したり差し替えたりしないでください。
4.3 .dig の中身を確かめる
signtool の公式ドキュメントには、.dig に何がどの形式で入っているかが書かれていません。そこで、実際のファイルを調べました。
結論は、**.dig は「署名属性の SHA-256 を base64 にした値」**です。これは、Vault の Transit に「ハッシュ計算済みの値(prehashed=true)」として渡す値そのものです。
Authenticode の署名では、ファイル全体ではなく、署名属性(signedAttrs)に署名します。署名属性には messageDigest という値が入っています。これは、exe のハッシュを含むデータ(SpcIndirectDataContent)のハッシュです。exe のハッシュそのものではありません(3つのハッシュの関係は 5.1節の表)。
.p7u の中から署名属性を取り出して SHA-256 を計算し、.dig をデコードした値と比べた結果です。
# .p7u の署名属性の SHA-256 を計算し、.dig と比べる
$ python3 -I scripts/check_dig.py /mnt/c/work_vault/phase18-pe/dig/test.exe.p7u /mnt/c/work_vault/phase18-pe/dig/test.exe.dig 2968 126
sha256(signedAttrs as SET) = 9002ab462516e8bb8da2127156a1d0a3f106ea0b9a6d7802ac49ddd27186b6f2
base64-decoded .dig = 9002ab462516e8bb8da2127156a1d0a3f106ea0b9a6d7802ac49ddd27186b6f2
MATCH
MATCH は、2つの値が一致したことを表します。使ったスクリプトと、署名属性の位置(2968、126)の調べ方は、次の折りたたみに入れています。
調べた手順(.dig のバイト列、.p7u の ASN.1、check_dig.py)
実際には、次の3つのコマンドを ; でつないで1行で実行しました。
# .dig の中身をそのまま表示する
$ xxd /mnt/c/work_vault/phase18-pe/dig/test.exe.dig
00000000: 6b41 4b72 5269 5557 364c 754e 6f68 4a78 kAKrRiUW6LuNohJx
00000010: 5671 4851 6f2f 4547 3667 7561 6258 6743 VqHQo/EG6guabXgC
00000020: 7245 6e64 306e 4747 7476 493d rEnd0nGGtvI=
# base64 としてデコードした中身を表示する
$ base64 -d /mnt/c/work_vault/phase18-pe/dig/test.exe.dig | xxd
00000000: 9002 ab46 2516 e8bb 8da2 1271 56a1 d0a3 ...F%......qV...
00000010: f106 ea0b 9a6d 7802 ac49 ddd2 7186 b6f2 .....mx..I..q...
# デコードした長さ(バイト数)を表示する
$ base64 -d /mnt/c/work_vault/phase18-pe/dig/test.exe.dig | wc -c
32
.dig は base64 の文字列(44文字)で、デコードすると32バイトでした。32バイトは SHA-256 のハッシュの長さです。
署名データの構造は ASN.1 という規格で表されます。ASN.1 では、各要素の先頭に種類を表すタグ(今回の要素では1バイト)と長さがあり、その後ろに中身が続きます。署名属性は、.p7u の中では先頭のタグが [0](0xA0)になっています。署名するときは、このタグを SET(0x31)に戻したものに対してハッシュを計算するという決まりがあります(RFC 5652)。次のスクリプトは、その計算をして .dig と比べます。
# .p7u の signedAttrs([0] IMPLICIT)を SET(0x31) に戻して SHA-256 を取り、.dig と比べる
import sys, hashlib, base64
p7u, dig, off, length = sys.argv[1], sys.argv[2], int(sys.argv[3]), int(sys.argv[4])
d = open(p7u, 'rb').read()
attrs = bytearray(d[off:off+length]); assert attrs[0] == 0xA0; attrs[0] = 0x31
h = hashlib.sha256(attrs).digest()
dg = base64.b64decode(open(dig, 'rb').read())
print('sha256(signedAttrs as SET) =', h.hex()); print('base64-decoded .dig =', dg.hex()); print('MATCH' if h == dg else 'MISMATCH')
署名属性の位置(先頭から2968バイトの位置から126バイト。位置は0から数える)は、openssl asn1parse で .p7u を表示して調べました。openssl asn1parse は、ASN.1 の構造を1行ずつ表示します。各行の先頭の数字が、その要素の位置(0から数える)です。
# .p7u を ASN.1 として表示し、署名属性(cont [ 0 ])の位置と長さを探す(抜粋)
$ openssl asn1parse -inform DER -in /mnt/c/work_vault/phase18-pe/dig/test.exe.p7u -i | tail -40
(中略)
2968:d=5 hl=2 l= 124 cons: cont [ 0 ]
2970:d=6 hl=2 l= 16 cons: SEQUENCE
2972:d=7 hl=2 l= 10 prim: OBJECT :1.3.6.1.4.1.311.2.1.12
(中略)
3045:d=6 hl=2 l= 47 cons: SEQUENCE
3047:d=7 hl=2 l= 9 prim: OBJECT :messageDigest
3058:d=7 hl=2 l= 34 cons: SET
3060:d=8 hl=2 l= 32 prim: OCTET STRING [HEX DUMP]:3E15891F419F9437E5FEF10E12AD0B452BA226D5EF84503BB37E997426C6B35C
3094:d=5 hl=2 l= 13 cons: SEQUENCE
3096:d=6 hl=2 l= 9 prim: OBJECT :rsaEncryption
3107:d=6 hl=2 l= 0 prim: NULL
3109:d=5 hl=2 l= 32 prim: OCTET STRING [HEX DUMP]:9002AB462516E8BB8DA2127156A1D0A3F106EA0B9A6D7802AC49DDD27186B6F2
hl=2 l= 124 は、ヘッダー2バイトと中身124バイトで、合わせて126バイトという意味です。最後の行(位置 3109)は、署名値が入る場所です。ここには今、.dig と同じ32バイトの値が仮に入っています。これが4.2節の「仮の署名」です。
4.4 Vault で署名する
.dig を読んで Vault に署名を依頼し、返ってきた署名値を .dig.signed に書き出すスクリプトです。署名値を書き出す前に、証明書の公開鍵で署名を検証して、正しい署名であることを確かめます。
#!/usr/bin/env bash
# usage: sign_dig.sh <file.dig> <token-file> [out.dig.signed]
# .dig(base64 の SHA-256)を Vault transit/sign/codesign(managed_key→SoftHSM)で署名し、
# 署名値(生の RSA 署名を base64)を .dig.signed に書く。書く前に証明書の公開鍵でローカル検証する。
set -euo pipefail
dig="$1"; tokfile="$2"; out="${3:-$1.signed}"
here="$(cd "$(dirname "$0")/.." && pwd)"
source "$here/work/vaultenv.sh"
export VAULT_TOKEN="$(cat "$tokfile")"
input="$(tr -d '\r\n' < "$dig")"
echo "input(.dig base64) = $input"
resp="$(vault write -format=json transit/sign/codesign \
input="$input" prehashed=true hash_algorithm=sha2-256 signature_algorithm=pkcs1v15)"
sig="$(jq -r .data.signature <<<"$resp")"
echo "vault signature prefix = ${sig%%:v*}:v$(cut -d: -f2 <<<"$sig" | tr -d v) (key_version=$(jq -r .data.key_version <<<"$resp"))"
b64="${sig#vault:v*:}"
tmp="$(mktemp -d)"; trap 'rm -rf "$tmp"' EXIT
base64 -d <<<"$input" > "$tmp/digest.bin"
base64 -d <<<"$b64" > "$tmp/sig.bin"
echo "signature length = $(wc -c < "$tmp/sig.bin") bytes"
openssl pkeyutl -verify -pubin -inkey "$here/work/codesign-pub.pem" -pkeyopt digest:sha256 \
-in "$tmp/digest.bin" -sigfile "$tmp/sig.bin"
printf '%s' "$b64" > "$out"
echo "wrote $out ($(wc -c < "$out") bytes, base64)"
スクリプトの処理の流れは次のとおりです。
- Vault の接続先(
work/vaultenv.sh)と、トークンのファイルを読む -
.digの中身をinputにして、transit/sign/codesignを呼ぶ - 返ってきた署名値から、先頭の
vault:v1:を取り除く - 証明書の公開鍵で、RSA PKCS#1 v1.5(SHA-256)の署名として検証する(
openssl pkeyutl -verify。-pkeyopt digest:sha256で、入力が SHA-256 のダイジェストであることを指定する) - 検証できたら、
.dig.signedに書く
4の検証は、Vault の鍵と証明書が合っていないときなどに、検証に通らない .dig.signed を Windows に渡さないためです。検証に失敗すると、set -e によってスクリプトはそこで止まり、.dig.signed は書かれません。
transit/sign に渡すパラメータの意味です。
| # | パラメータ | 値 | 意味 |
|---|---|---|---|
| 1 | input |
.dig の中身 |
署名するデータ(base64) |
| 2 | prehashed |
true |
input はハッシュ計算済みの値なので、Vault ではハッシュを計算しない。false(既定値)にすると、Vault は input をもう一度 SHA-256 にかけてから署名する。その場合、署名属性のハッシュとは別の値に署名することになり、検証に通らない署名になる(理屈からの説明で、試してはいない) |
| 3 | hash_algorithm |
sha2-256 |
input が SHA-256 のハッシュであることを伝える。Vault は、SHA-256 を表す19バイトの目印(DigestInfo の先頭部分)を付けてから署名する |
| 4 | signature_algorithm |
pkcs1v15 |
RSA PKCS#1 v1.5 で署名する |
Vault は、署名値を vault:v1:<base64> の形で返します。v1 は鍵のバージョンです。
署名の前後で監査ログの行数を数え、増えた行から、主な項目だけを取り出して表示しました。
# 署名の前に、監査ログの行数を記録する
$ wc -l <vault-poc>/audit/vault-audit.log | tee work/audit.before.sign
1221 <vault-poc>/audit/vault-audit.log
# 署名用のトークンで .dig に署名し、.dig.signed を書き出す
$ scripts/sign_dig.sh /mnt/c/work_vault/phase18-pe/dig/test.exe.dig work/token-sign; echo exit=$?
input(.dig base64) = kAKrRiUW6LuNohJxVqHQo/EG6guabXgCrEnd0nGGtvI=
vault signature prefix = vault:v1 (key_version=1)
signature length = 384 bytes
Signature Verified Successfully
wrote /mnt/c/work_vault/phase18-pe/dig/test.exe.dig.signed (512 bytes, base64)
exit=0
# 増えた監査ログの行から、時刻・種類・操作・パス・トークン名・ポリシー・エラーだけを表示する
$ wc -l <vault-poc>/audit/vault-audit.log; tail -n +$(($(cut -d' ' -f1 work/audit.before.sign)+1)) <vault-poc>/audit/vault-audit.log | jq -c '{time,type,op:.request.operation,path:.request.path,display_name:.auth.display_name,policies:.auth.policies,error}'
1223 <vault-poc>/audit/vault-audit.log
{"time":"2026-10-09T01:13:31.89270673Z","type":"request","op":"update","path":"transit/sign/codesign","display_name":"token-phase18-sign","policies":["default","phase18-sign"],"error":null}
{"time":"2026-10-09T01:13:31.898195681Z","type":"response","op":"update","path":"transit/sign/codesign","display_name":"token-phase18-sign","policies":["default","phase18-sign"],"error":null}
結果の意味は次のとおりです。
-
signature length = 384 bytes:RSA 3072ビットの署名の長さ(3072 ÷ 8 = 384)です。 -
Signature Verified Successfully:Vault が返した署名値を、証明書の公開鍵で検証できました。 -
512 bytes, base64:384バイトの署名値を base64 にすると、512文字になります。 - 監査ログは2行増えました。1回の署名につき、リクエスト(
request)とレスポンス(response)の1組です。どちらにも、署名したトークンの名前(token-phase18-sign)が記録されています。
4.5 署名を埋め込み、タイムスタンプを付ける(/di)
signtool sign /di <フォルダ> は、フォルダの中の .dig.signed と .p7u を読んで、署名を exe に埋め込みます。
続けて、タイムスタンプを付けます。タイムスタンプは、「この署名はこの時刻に存在した」ことを、第三者の TSA(タイムスタンプ局)に証明してもらう仕組みです。付けておくと、証明書の有効期限が切れた後も、署名を有効と判定できます。今回は、DigiCert が公開している TSA を使いました。
# 画面の入出力の記録を始める(変数と作業フォルダは 4.1節と同じ)
Start-Transcript -Path C:\work_vault\phase18-pe\logs\04-di-ts.txt -Append
# Vault の署名(dig\test.exe.dig.signed)を test.exe に埋め込む
PS C:\work_vault\phase18-pe> & $st sign /v /di C:\work_vault\phase18-pe\dig C:\work_vault\phase18-pe\test.exe
PS C:\work_vault\phase18-pe> "exit=$LASTEXITCODE"
exit=0
# DigiCert の TSA でタイムスタンプを付ける(/tr: TSA の URL、/td sha256: タイムスタンプのハッシュは SHA-256)
PS C:\work_vault\phase18-pe> & $st timestamp /v /tr http://timestamp.digicert.com /td sha256 C:\work_vault\phase18-pe\test.exe
PS C:\work_vault\phase18-pe> "exit=$LASTEXITCODE"
exit=0
どちらも exit=0(成功)で終わりました。
このときの signtool の画面出力は、記録に残っていません。トランスクリプトには、PowerShell のコマンドの出力(exit=0 など)は残りましたが、signtool のような外部のプログラム(.exe)の出力は残りませんでした。5.2節では、コマンドの後ろに 2>&1 | Out-Host を付けて、出力を記録しました。
signtool は、署名値(RSA の署名そのもの、384バイト)を base64 にした .dig.signed を、エラーなしで受け付けました。署名が正しく埋め込まれたかは、5章で確かめます(5章のとおり、正しく埋め込まれていました)。Vault が返す vault:v1: の後ろの base64 を、変換せずにそのまま使えます。
5. 署名結果の確認
5.1 Linux での確認(osslsigncode)
正常系
Linux で、署名済みの test.exe の署名を検証します。確かめることは次の3つです。
- exe のハッシュが、署名に記録されたハッシュと一致するか
- 署名値が、証明書の公開鍵で検証できるか
- タイムスタンプが、TSA の証明書で検証できるか
# 署名済みの test.exe を検証する(-CAfile: 信頼する Root CA、-untrusted: 中間の Issuing CA、-TSA-CAfile: TSA の検証に使う CA)
$ osslsigncode verify -CAfile work/root-ca.crt -untrusted work/issuing-ca.crt -TSA-CAfile /etc/ssl/certs/ca-certificates.crt -in /mnt/c/work_vault/phase18-pe/test.exe; echo exit=$?
Current PE checksum : 0008047A
Calculated PE checksum: 0008047A
Signature Index: 0 (Primary Signature)
Message digest algorithm : SHA256
Current message digest : A82900B414438B4C49D2A4A8DA9001DD2AF8D291C49507759AB048DE36F2DDC2
Calculated message digest : A82900B414438B4C49D2A4A8DA9001DD2AF8D291C49507759AB048DE36F2DDC2
Signer's certificate:
Signer #0:
Subject: /C=JP/O=PoC/OU=Code Signing/CN=Code Signing Demo
Issuer : /CN=PoC Code Signing Issuing CA
Serial : 4793C7425E934EED5524FD55EA2B3114DD069B19
(中略)
Authenticated attributes:
Message digest algorithm: SHA256
Message digest: 3E15891F419F9437E5FEF10E12AD0B452BA226D5EF84503BB37E997426C6B35C
Signing time: N/A
Microsoft Individual Code Signing purpose
The signature is timestamped: Oct 8 16:14:44 2026 GMT
Hash Algorithm: sha256
Timestamp Verified by:
Issuer : /C=US/O=DigiCert, Inc./CN=DigiCert Trusted G4 TimeStamping RSA4096 SHA256 2025 CA1
Serial : 084FDC334F7E454EDBC30F8FF9921835
(中略)
Timestamp Server Signature verification: ok
Signature verification: ok
80C956F8AE7B0000:error:1700006B:CMS routines:cms_get_enveloped_type:content type not enveloped data:../crypto/cms/cms_env.c:41:
Number of verified signatures: 1
Succeeded
exit=0
結果の意味は次のとおりです。
-
Current message digestとCalculated message digestが一致:exe は、署名した後に変更されていません。 -
Signature verification: ok:署名値は、証明書(CN=Code Signing Demo)の公開鍵で検証できました。 -
Timestamp Server Signature verification: ok:DigiCert のタイムスタンプも有効です。 -
Message digest: 3E15…B35Cは、4.3節で.p7uの中に見えたmessageDigestと同じ値です。 -
Signing time: N/A:署名属性に、署名した時刻が入っていないことを表します。時刻は、TSA のタイムスタンプに入っています(6.4節)。 -
Microsoft Individual Code Signing purpose:署名属性の1つ(SpcStatementType、OID1.3.6.1.4.1.311.2.1.11)の値で、個人向け(Individual)のコード署名であることを表します。
途中の cms_get_enveloped_type の行と、タイムスタンプの時刻については、7章で説明します。
さらに、exe に埋め込まれた署名値が、Vault が返した署名値と同じかを確かめました。osslsigncode で exe から署名データを取り出し、その中に .dig.signed の384バイトがそのまま入っているかを探すスクリプトです。
# test.exe から取り出した署名データ(PKCS#7)の中に、Vault が返した署名値(.dig.signed、384バイト)がそのまま入っているか
import sys, base64
p7, signed = sys.argv[1], sys.argv[2]
raw = open(p7, 'rb').read(); vault = base64.b64decode(open(signed).read())
pos = raw.find(vault)
print(f'vault signature {len(vault)} bytes; found in embedded PKCS#7 at offset {pos}' if pos >= 0 else 'NOT FOUND')
print('occurrences:', raw.count(vault))
# exe から署名データを取り出し、work/test.exe.sig.der に保存する
$ osslsigncode extract-signature -in /mnt/c/work_vault/phase18-pe/test.exe -out work/test.exe.sig.der >/dev/null
# 取り出した署名データの中に、Vault の署名値が入っているかを探す
$ python3 -I scripts/sig_identity.py work/test.exe.sig.der /mnt/c/work_vault/phase18-pe/dig/test.exe.dig.signed
vault signature 384 bytes; found in embedded PKCS#7 at offset 3121
occurrences: 1
Vault が返した署名値が、exe の署名データの中に1か所だけ、そのまま入っていました。
3つのハッシュの関係
この記事には、「ダイジェスト」と呼ぶ値が3つ出てきます。Authenticode では、次の順にハッシュを計算し、最後の値に署名します。
| # | 値 | 何のハッシュか | 記事に出てくる場所 |
|---|---|---|---|
| 1 | A829…DDC2 |
exe の Authenticode ハッシュ(PE ハッシュ)。exe のうち、署名を埋め込むと変わる部分を除いて計算する | 5.1節の Current message digest、5.2節の Hash of file (sha256)
|
| 2 | 3E15…B35C |
1を入れたデータ(SpcIndirectDataContent)の SHA-256。署名属性の messageDigest に入る |
4.3節の messageDigest、5.1節の Message digest
|
| 3 | 9002…B6F2 |
2を入れた署名属性の SHA-256。これが .dig の中身で、Vault に送る値 |
4.3節 |
PE の仕様(PE Format の Certificate Data)には、1の計算から除くものの例として、ヘッダーのチェックサム(CheckSum)と、署名の場所を示す項目(Certificate Table のエントリ)が挙げられています。署名データ自体も、含めると署名を埋め込んだときに値が変わってしまうので、計算に入りません。そのため1は、ファイル全体の SHA-256(e337a17e…)とは別の値です。
2と3の関係は、4.3節の check_dig.py で確かめました。1と2の関係(PE ハッシュが SpcIndirectDataContent の中に入っていて、その中身の SHA-256 が messageDigest になる)は、記事を書く段階で chain_check.py で確かめました(手順は折りたたみ)。
1と2の関係の確認(chain_check.py)
この確認は、6.4節までの確認の後に行いました。使ったのは、上で取り出した work/test.exe.sig.der から、先頭8バイト(WIN_CERTIFICATE。6.1節)を除いた work/test.exe.p7.der です(作ったコマンドは 6.4節)。次のスクリプトは、SpcIndirectDataContent に1が入っていること、その SHA-256 が2になることを確かめます。
# test.exe の署名データ(PKCS#7)で、ハッシュの連鎖を確かめる
# SpcIndirectDataContent(位置 59 の SEQUENCE、hl=2 l=76)に PE ハッシュが入り、その SHA-256 が messageDigest になるか
import sys, hashlib
p7 = open(sys.argv[1], 'rb').read()
off, hl, ln = 59, 2, 76
whole = p7[off:off+hl+ln]; body = p7[off+hl:off+hl+ln]
print('PE hash in SpcIndirectDataContent =', p7[105:137].hex())
print('sha256(SpcIndirectDataContent, tag+len included) =', hashlib.sha256(whole).hexdigest())
print('sha256(SpcIndirectDataContent, content only) =', hashlib.sha256(body).hexdigest())
実際には、次の3つのコマンドを ; でつないで1行で実行しました。
# 署名データの先頭部分を ASN.1 として表示する(抜粋。位置 59 の SEQUENCE が SpcIndirectDataContent)
$ openssl asn1parse -inform DER -in work/test.exe.p7.der | sed -n '10,26p'
(前略)
45:d=4 hl=2 l= 10 prim: OBJECT :1.3.6.1.4.1.311.2.1.4
57:d=4 hl=2 l= 78 cons: cont [ 0 ]
59:d=5 hl=2 l= 76 cons: SEQUENCE
(中略)
103:d=7 hl=2 l= 32 prim: OCTET STRING [HEX DUMP]:A82900B414438B4C49D2A4A8DA9001DD2AF8D291C49507759AB048DE36F2DDC2
(以下略)
# SpcIndirectDataContent の中の PE ハッシュと、SpcIndirectDataContent の SHA-256 を表示する
$ python3 -I scripts/chain_check.py work/test.exe.p7.der
PE hash in SpcIndirectDataContent = a82900b414438b4c49d2a4a8da9001dd2af8d291c49507759ab048de36f2ddc2
sha256(SpcIndirectDataContent, tag+len included) = 4bf030c7f0117b155aa8a3f3b7e27ecd10b74b1c7da640e494176391b025b64b
sha256(SpcIndirectDataContent, content only) = 3e15891f419f9437e5fef10e12ad0b452ba226d5ef84503bb37e997426c6b35c
# 署名済みの test.exe のファイル全体の SHA-256 を表示する
$ sha256sum /mnt/c/work_vault/phase18-pe/test.exe
e337a17ecc59f3faafec3dc4c37b68f3861225de13437a013134b336b35d45bb /mnt/c/work_vault/phase18-pe/test.exe
1.3.6.1.4.1.311.2.1.4 は SpcIndirectDataContent を表す OID です。PE ハッシュ(A829…)が SpcIndirectDataContent の中に入っていて、その SHA-256 が messageDigest(3E15…)と一致しました。ハッシュの対象は、SpcIndirectDataContent の先頭のタグと長さ(2バイト)を除いた中身です(含めると別の値になります)。
異常系
署名した後に exe を1バイトでも変えると、検証が失敗することを確かめます。署名済みの test.exe のコピーで、表示する文字列 Hello の H(0x48)を J(0x4a)に書き換えました。
# 署名済み exe のコピーで、メッセージ文字列 "Hello" の 'H' を 'J' に 1 バイトだけ書き換える
import sys
src, dst = sys.argv[1], sys.argv[2]
b = bytearray(open(src,'rb').read()); i = b.find(b'Hello from phase18'); assert i > 0
print(f'offset {i}: {b[i]:#04x} -> 0x4a'); b[i] = 0x4a; open(dst,'wb').write(b)
# 署名済みの exe のコピーを1バイト書き換え、元のファイルとの違いを表示する
$ python3 -I scripts/tamper1.py /mnt/c/work_vault/phase18-pe/test.exe /mnt/c/work_vault/phase18-pe/test_tampered.exe && cmp -l /mnt/c/work_vault/phase18-pe/test.exe /mnt/c/work_vault/phase18-pe/test_tampered.exe
offset 29696: 0x48 -> 0x4a
29697 110 112
# 書き換えた exe を検証する(結果に関係する行だけを表示する)
$ osslsigncode verify -CAfile work/root-ca.crt -untrusted work/issuing-ca.crt -TSA-CAfile /etc/ssl/certs/ca-certificates.crt -in /mnt/c/work_vault/phase18-pe/test_tampered.exe 2>&1 | grep -E 'message digest|MISMATCH|Signature verification|Succeeded|Failed'; echo exit=${PIPESTATUS[0]}
Calculated PE checksum: 0008047C MISMATCH!!!
Current message digest : A82900B414438B4C49D2A4A8DA9001DD2AF8D291C49507759AB048DE36F2DDC2
Calculated message digest : 255BA3BA126A98FA26D596F8FA3A1DC136D2D66AAB054A38CEF3DC8987C9E6E3 MISMATCH!!!
Signature verification: failed
Failed
exit=1
cmp -l の出力は、1バイトだけ(8進数の110=0x48 が 112=0x4a に)変わったことを表します。exe から計算したハッシュ(Calculated message digest)が署名に記録されたハッシュと合わなくなり、検証は失敗しました(1行目の PE checksum は、署名とは別の簡易なチェックサムです)。
5.2 Windows での確認
正常系
Windows 自身が、署名を有効と判定するかを確かめます。signtool の検証、PowerShell の Get-AuthenticodeSignature、exe の実行の3つです。
# 4.5節と同じトランスクリプト(04-di-ts.txt)の続き:PowerShell で署名の状態を表示する
PS C:\work_vault\phase18-pe> Get-AuthenticodeSignature C:\work_vault\phase18-pe\test.exe | Format-List Status, StatusMessage, SignerCertificate, TimeStamperCertificate
Status : Valid
StatusMessage : 署名を確認しました。
SignerCertificate : [Subject]
CN=Code Signing Demo, OU=Code Signing, O=PoC, C=JP
[Issuer]
CN=PoC Code Signing Issuing CA
[Serial Number]
4793C7425E934EED5524FD55EA2B3114DD069B19
[Not Before]
2026/09/16 17:35:23
[Not After]
2027/09/16 17:35:53
[Thumbprint]
D2F5F69933F544732CF1F04D38E92DD23C646239
TimeStamperCertificate : [Subject]
CN=DigiCert SHA256 RSA4096 Timestamp Responder 2026 1, O="DigiCert, Inc.", C=US
(以下略)
Status : Valid は、Windows が署名を有効と判定したことを表します。署名者の証明書の Thumbprint(D2F5…6239)は、codesign.cer の SHA-1 フィンガープリントと一致しました。
# codesign.cer の SHA-1 フィンガープリントを表示する(記録では、ls -l と sha256sum もつないでいる。その部分は省略)
$ openssl x509 -inform DER -in work/codesign.cer -noout -fingerprint -sha1
sha1 Fingerprint=D2:F5:F6:99:33:F5:44:73:2C:F1:F0:4D:38:E9:2D:D2:3C:64:62:39
signtool の検証と exe の実行は、出力を記録するために 2>&1 | Out-Host を付けて実行しました(4.5節の注意を参照)。
# 画面の入出力の記録を始める(変数と作業フォルダは 4.1節と同じ)
Start-Transcript -Path C:\work_vault\phase18-pe\logs\05-06-verify.txt -Append
# signtool で署名を検証する(/pa: 通常の Authenticode の検証方針を使う、/v: 詳しく表示する)
PS C:\work_vault\phase18-pe> & $st verify /pa /v C:\work_vault\phase18-pe\test.exe 2>&1 | Out-Host
Verifying: C:\work_vault\phase18-pe\test.exe
Signature Index: 0 (Primary Signature)
Hash of file (sha256): A82900B414438B4C49D2A4A8DA9001DD2AF8D291C49507759AB048DE36F2DDC2
Signing Certificate Chain:
Issued to: PoC Code Signing Root CA
Issued by: PoC Code Signing Root CA
Expires: Sat Sep 13 17:33:30 2036
SHA1 hash: 7AAEDAC55E02EC8D5302BA21E5DFB2252F907705
Issued to: PoC Code Signing Issuing CA
Issued by: PoC Code Signing Root CA
Expires: Mon Sep 15 17:33:46 2031
SHA1 hash: E189E248ABFE5374CF90DF856F478366E61FC4B2
Issued to: Code Signing Demo
Issued by: PoC Code Signing Issuing CA
Expires: Thu Sep 16 17:35:53 2027
SHA1 hash: D2F5F69933F544732CF1F04D38E92DD23C646239
The signature is timestamped: Fri Oct 09 10:14:44 2026
Timestamp Verified by:
Issued to: DigiCert Assured ID Root CA
(中略)
Issued to: DigiCert SHA256 RSA4096 Timestamp Responder 2026 1
Issued by: DigiCert Trusted G4 TimeStamping RSA4096 SHA256 2025 CA1
Expires: Thu Nov 05 08:59:59 2037
SHA1 hash: 51D9ABDA034973D84F4266ACA48248E6B369C439
Successfully verified: C:\work_vault\phase18-pe\test.exe
Number of files successfully Verified: 1
Number of warnings: 0
Number of errors: 0
PS C:\work_vault\phase18-pe> "exit=$LASTEXITCODE"
exit=0
# 署名済みの exe を実行する
PS C:\work_vault\phase18-pe> C:\work_vault\phase18-pe\test.exe 2>&1 | Out-Host
Hello from phase18 Case D (signed via Vault transit/sign)
結果の意味は次のとおりです。
-
Signing Certificate Chain:署名者の証明書から Root CA までのチェーンを、Windows がたどれました。 -
Hash of file (sha256):5.1節の osslsigncode のCurrent message digestと同じ値です。 -
Successfully verified、警告0件、エラー0件:Windows も署名を有効と判定しました。 - 署名した exe は、そのまま実行できました。
異常系
5.1節で1バイト書き換えた test_tampered.exe を、Windows でも検証します。
# 書き換えた exe の署名の状態を表示する
PS C:\work_vault\phase18-pe> Get-AuthenticodeSignature C:\work_vault\phase18-pe\test_tampered.exe | Format-List Status, StatusMessage
Status : HashMismatch
StatusMessage : ファイル C:\work_vault\phase18-pe\test_tampered.exe の内容は、承認されていないユーザーまたはプロセスに
よって変更された可能性があります。ファイルのハッシュが、デジタル署名に保存されているハッシュと一致しま
せん。このスクリプトは指定されたシステムでは実行できません。詳細については、Get-Help about_Signing を実
行してください。
# 書き換えた exe を signtool で検証する(抜粋)
PS C:\work_vault\phase18-pe> & $st verify /pa /v C:\work_vault\phase18-pe\test_tampered.exe 2>&1 | Out-Host
Verifying: C:\work_vault\phase18-pe\test_tampered.exe
Signature Index: 0 (Primary Signature)
Hash of file (sha256): A82900B414438B4C49D2A4A8DA9001DD2AF8D291C49507759AB048DE36F2DDC2
(中略)
Number of files successfully Verified: 0
Number of warnings: 0
Number of errors: 1
signtool.exe : SignTool Error: WinVerifyTrust returned error: 0x80096010
(中略)
PS C:\work_vault\phase18-pe> "exit=$LASTEXITCODE"
exit=1
Get-AuthenticodeSignature は HashMismatch(ハッシュが一致しない)と判定しました。異常系では、書き換えた exe は実行していません(実行できるかどうかは、署名の検証とは別の話になるためです)。signtool はエラー 0x80096010 で失敗しました。このコードは TRUST_E_BAD_DIGEST(デジタル署名が無効)を表します。
なお、signtool の Hash of file (sha256) の行には、署名に記録されたハッシュ(正常系と同じ値)が表示されています。書き換えた後の exe のハッシュではありません。
5.3 Vault での確認(署名の権限が無い場合)
Vault の正常系(署名できること、監査ログに残ること)は、4.4節で確かめました。ここでは異常系として、Vault のポリシーで署名を許可していないトークンでは、署名できないことを確かめます。3.6節の phase18-nosign のトークンで、同じ sign_dig.sh を実行しました。.dig は work/nosign-test.dig にコピーして使いました(コピーのコマンドは記録に残っていません)。
# 署名の前に、監査ログの行数を記録する
$ wc -l <vault-poc>/audit/vault-audit.log | tee work/audit.before.nosign
1223 <vault-poc>/audit/vault-audit.log
# 署名の権限が無いトークンで、.dig(コピー)に署名を依頼する
$ scripts/sign_dig.sh work/nosign-test.dig work/token-nosign work/nosign-test.dig.signed; echo exit=$?; ls work/nosign-test.dig.signed 2>&1
input(.dig base64) = kAKrRiUW6LuNohJxVqHQo/EG6guabXgCrEnd0nGGtvI=
Error writing data to transit/sign/codesign: Error making API request.
URL: PUT https://127.0.0.1:8220/v1/transit/sign/codesign
Code: 403. Errors:
* 1 error occurred:
* permission denied
exit=2
ls: cannot access 'work/nosign-test.dig.signed': No such file or directory
# 増えた監査ログの行から、主な項目だけを表示する
$ tail -n +$(($(cut -d' ' -f1 work/audit.before.nosign)+1)) <vault-poc>/audit/vault-audit.log | jq -c '{time,type,op:.request.operation,path:.request.path,display_name:.auth.display_name,policies:.auth.policies,error}'
{"time":"2026-10-09T01:30:50.073317256Z","type":"request","op":"update","path":"transit/sign/codesign","display_name":"token-phase18-nosign","policies":["default","phase18-nosign"],"error":"1 error occurred:\n\t* permission denied\n\n"}
{"time":"2026-10-09T01:30:50.073832681Z","type":"response","op":"update","path":"transit/sign/codesign","display_name":"token-phase18-nosign","policies":["default","phase18-nosign"],"error":"1 error occurred:\n\t* permission denied\n\n"}
Vault は 403 permission denied で署名を拒否し、.dig.signed は作られませんでした。拒否されたことも、トークンの名前(token-phase18-nosign)つきで監査ログに残っています。
6. 監査ログと鍵で、署名の出どころを確かめる
6.1 exe から、監査ログの署名の記録を探す
配布された exe を1つ手に入れたとき、その署名が「いつ」「どのトークンで」Vault に依頼されたものかを、監査ログから探せるかを確かめます。この節の確認は、6.3節の確認より前に行いました。
4.3節で分かったとおり、Vault に送った input は、exe の署名データにある署名属性の SHA-256 です。そして 3.4節の設定で、input は監査ログに平文で残っています。そこで、署名済みの exe から署名属性を取り出して SHA-256 を計算し直し、監査ログの input と同じ値の記録を探すスクリプトを作りました。
# 署名済み exe から signedAttrs の SHA-256 を計算し直し(= /dg が出す .dig の値)、
# Vault 監査ログの transit/sign/codesign の input と照合する。exe → 監査ログの1行 を逆にたどれるかの確認。
import sys, subprocess, hashlib, base64, json, tempfile, os
exe, audit = sys.argv[1], sys.argv[2]
tmp = tempfile.mkdtemp(); sig = os.path.join(tmp, 'sig.bin')
subprocess.run(['osslsigncode', 'extract-signature', '-in', exe, '-out', sig], check=True, capture_output=True)
raw = open(sig, 'rb').read()
# extract-signature の出力は先頭8バイトが WIN_CERTIFICATE ヘッダ(dwLength, wRevision, wCertificateType)
hdr_len = int.from_bytes(raw[0:4], 'little'); print(f'WIN_CERTIFICATE dwLength={hdr_len} rev={raw[4:6].hex()} type={raw[6:8].hex()}')
p7 = raw[8:]
p7f = os.path.join(tmp, 'p7.der'); open(p7f, 'wb').write(p7)
lines = subprocess.run(['openssl', 'asn1parse', '-inform', 'DER', '-in', p7f], capture_output=True, text=True).stdout.splitlines()
# signerInfos(d=3 の SET のうち最後のもの)の直後に出てくる d=5 の cont [ 0 ] = signedAttrs
si = max(i for i, l in enumerate(lines) if ':d=3 ' in l and 'SET' in l)
sa = next(l for l in lines[si:] if ':d=5 ' in l and 'cont [ 0 ]' in l)
off = int(sa.split(':')[0]); hl = int(sa.split('hl=')[1].split()[0]); ln = int(sa.split('l=')[2].split()[0])
attrs = bytearray(p7[off:off+hl+ln]); attrs[0] = 0x31
dig = base64.b64encode(hashlib.sha256(attrs).digest()).decode()
print(f'signedAttrs offset={off} len={hl+ln} -> sha256(base64) = {dig}')
hits = []
for line in open(audit):
e = json.loads(line); r = e.get('request', {})
if r.get('path') == 'transit/sign/codesign' and (r.get('data') or {}).get('input') == dig:
hits.append({'time': e['time'], 'type': e['type'], 'display_name': e.get('auth', {}).get('display_name'),
'policies': e.get('auth', {}).get('policies'), 'remote': r.get('remote_address'), 'error': e.get('error'),
'x-correlation-id': (r.get('headers') or {}).get('x-correlation-id'), 'token_metadata': (e.get('auth') or {}).get('metadata')})
print(f'audit log entries with this input: {len(hits)}')
for h in hits: print(json.dumps(h, ensure_ascii=False))
trace_audit.py は、6.3節の確認の後で、表示する項目(x-correlation-id・token_metadata)を足しています。下の出力は、足す前のものです。
# 署名済みの test.exe から署名属性の SHA-256 を計算し、監査ログで同じ input の記録を探す
$ python3 -I scripts/trace_audit.py /mnt/c/work_vault/phase18-pe/test.exe <vault-poc>/audit/vault-audit.log
WIN_CERTIFICATE dwLength=9520 rev=0002 type=0200
signedAttrs offset=2968 len=126 -> sha256(base64) = kAKrRiUW6LuNohJxVqHQo/EG6guabXgCrEnd0nGGtvI=
audit log entries with this input: 4
{"time": "2026-10-09T01:13:31.89270673Z", "type": "request", "display_name": "token-phase18-sign", "policies": ["default", "phase18-sign"], "remote": "127.0.0.1", "error": null}
{"time": "2026-10-09T01:13:31.898195681Z", "type": "response", "display_name": "token-phase18-sign", "policies": ["default", "phase18-sign"], "remote": "127.0.0.1", "error": null}
{"time": "2026-10-09T01:30:50.073317256Z", "type": "request", "display_name": "token-phase18-nosign", "policies": ["default", "phase18-nosign"], "remote": "127.0.0.1", "error": "1 error occurred:\n\t* permission denied\n\n"}
{"time": "2026-10-09T01:30:50.073832681Z", "type": "response", "display_name": "token-phase18-nosign", "policies": ["default", "phase18-nosign"], "remote": "127.0.0.1", "error": "1 error occurred:\n\t* permission denied\n\n"}
結果の意味は次のとおりです。
- 1行目の
WIN_CERTIFICATEは、exe の中で署名データを包んでいる入れ物(先頭8バイト)です。スクリプトはこの8バイトを飛ばしてから署名データを読みます。rev=0002 type=0200は、ファイルの中のバイトの並び(リトルエンディアン)のまま表示しています。数値に直すとwRevision=0x0200(WIN_CERT_REVISION_2_0)とwCertificateType=0x0002(WIN_CERT_TYPE_PKCS_SIGNED_DATA。中身が PKCS#7 の署名データ)で、どちらも PE Format に書かれている値です。 - exe から計算した値は、4.4節で Vault に送った
input(kAKr…tvI=)と同じでした。 - 同じ
inputの記録は4行(2組)でした。1組目は 4.4節の署名(token-phase18-sign)、2組目は 5.3節の拒否された依頼(token-phase18-nosign)です。成功した署名の記録は1組だけで、時刻とトークンの名前が分かります。
監査ログのレスポンスに残った署名値も、exe に埋め込まれた署名値と同じでした。この時点では、token-phase18-sign の署名の記録は1組だけでした。
# 監査ログのレスポンスから token-phase18-sign の署名値を取り出し、.dig.signed と比べる
$ jq -r 'select(.type=="response" and .request.path=="transit/sign/codesign" and .auth.display_name=="token-phase18-sign") | .response.data.signature' \
<vault-poc>/audit/vault-audit.log | sed 's/^vault:v1://' > work/audit-response-sig.b64; \
cmp <(tr -d '\n' < work/audit-response-sig.b64) /mnt/c/work_vault/phase18-pe/dig/test.exe.dig.signed \
&& echo '監査ログ response の signature = .dig.signed(= exe に埋め込まれた署名値)'
監査ログ response の signature = .dig.signed(= exe に埋め込まれた署名値)
このコマンドは、6.3節で同じトークンを使ってもう一度署名する前に実行しました。6.3節の後に実行すると、署名値が2つ取り出されてつながるため、cmp は失敗します。同じダイジェストに何度も署名した場合は、時刻で記録を絞ってください(6.4節)。
この照合ができるのは、3.4節で input と signature を平文で残す設定を入れているからです。既定の設定(HMAC 化)のままの場合は、Vault の sys/audit-hash API で値を HMAC 化してから照合する方法になります(この方法は試していません)。
6.2 署名が SoftHSM の鍵で行われたことを確かめる
4.4節の署名が、本当に SoftHSM の中の鍵で作られたかを確かめます。同じデータを、Vault を通さずに SoftHSM に直接署名させ、Vault の署名と比べます。
この比べ方が成り立つのは、RSA PKCS#1 v1.5 では、同じ鍵で同じデータに署名すると、毎回まったく同じ署名になるからです。
ただし、渡すデータの形が違います。Vault には32バイトのダイジェストだけを渡し、SHA-256 を表す19バイトの目印は Vault が付けました(4.4節の表)。pkcs11-tool の RSA-PKCS は目印を付けないので、目印19バイトとダイジェスト32バイトをつないだ51バイト(DigestInfo)を自分で作って渡します。19バイトの目印は、規格(RFC 8017)で決まっている値です。
手順と出力は、次の折りたたみに入れています。
DigestInfo を作り、SoftHSM に直接署名させて比べた手順
# SHA-256 の目印(19バイト)を書き、その後ろに .dig のダイジェスト(32バイト)をつなげて DigestInfo を作る
$ printf '\x30\x31\x30\x0d\x06\x09\x60\x86\x48\x01\x65\x03\x04\x02\x01\x05\x00\x04\x20' > work/hsm/digestinfo.der; \
base64 -d /mnt/c/work_vault/phase18-pe/dig/test.exe.dig >> work/hsm/digestinfo.der; \
echo "DigestInfo 長: $(wc -c < work/hsm/digestinfo.der) バイト"; xxd -p work/hsm/digestinfo.der | tr -d '\n'; echo
DigestInfo 長: 51 バイト
3031300d0609608648016503040201050004209002ab462516e8bb8da2127156a1d0a3f106ea0b9a6d7802ac49ddd27186b6f2
# Vault を通さず、pkcs11-tool で SoftHSM に DigestInfo へ直接署名させる
$ SOFTHSM2_CONF=<vault-poc>/softhsm/softhsm2.conf pkcs11-tool --module /usr/lib/softhsm/libsofthsm2.so --token-label vault-hsm-demo --login --pin <USER-PIN> --sign --mechanism RSA-PKCS --label hsm-codesign-rsa3072 --input-file work/hsm/digestinfo.der --output-file work/hsm/pkcs11-direct-sig.bin
Using signature algorithm RSA-PKCS
# Vault の署名(.dig.signed をデコードしたもの)と、SoftHSM に直接署名させた結果を比べる
$ base64 -d /mnt/c/work_vault/phase18-pe/dig/test.exe.dig.signed > work/hsm/vault-sig.bin; \
sha256sum work/hsm/vault-sig.bin work/hsm/pkcs11-direct-sig.bin; \
cmp work/hsm/vault-sig.bin work/hsm/pkcs11-direct-sig.bin && echo 'BYTE IDENTICAL: Vault の署名 = SoftHSM で直接署名した結果'
eb4975c78cb26024aa607c37b733c64472c3cde7f1b4591fbca16699ded807f7 work/hsm/vault-sig.bin
eb4975c78cb26024aa607c37b733c64472c3cde7f1b4591fbca16699ded807f7 work/hsm/pkcs11-direct-sig.bin
BYTE IDENTICAL: Vault の署名 = SoftHSM で直接署名した結果
# SoftHSM の中の秘密鍵の属性を表示する
$ SOFTHSM2_CONF=<vault-poc>/softhsm/softhsm2.conf pkcs11-tool --module /usr/lib/softhsm/libsofthsm2.so --token-label vault-hsm-demo --login --pin <USER-PIN> --list-objects --type privkey 2>&1 | grep -E 'Private Key|label|Usage|Access'
Private Key Object; RSA
label: hsm-codesign-rsa3072
Usage: decrypt, sign, unwrap
Access: sensitive, always sensitive, never extractable, local
2つの署名はバイト単位で一致しました。exe に埋め込まれた署名は、SoftHSM の中の鍵 hsm-codesign-rsa3072 で作られたものです。この鍵は、取り出せない属性(never extractable)を持っています。
never extractable などの属性は、SoftHSM 自身が示している値です。SoftHSM の鍵の実体はディスク上のファイルなので、実際の HSM と同じ意味での「取り出せない」ではありません。
また、今回は確かめるために Vault を通さずに直接署名しました。これは、PIN と鍵(SoftHSM のトークン)に触れられる人なら、**Vault を通さずに署名できる(監査ログに残らない)**ということでもあります。本番では、PIN の管理と HSM 側のアクセス制御(Vault 以外から HSM の鍵を使えないようにする)で、署名するには Vault を通るしかない状態にする必要があります(今回は確かめていません)。
6.3 署名したファイルの名前を監査ログに残す
6.1節で、exe から監査ログの記録をたどれることを確かめました。ただし、Vault が受け取るのはダイジェストだけなので、どのファイルに署名したか(ファイル名やビルド番号)は、そのままでは監査ログに残りません。
そこで、ファイル名を Vault の監査ログに残す方法を2つ試しました。どちらも、4.2節の .dig のコピー(work/metaA.dig・work/metaB.dig。コピーのコマンドは記録に残っていません)にもう一度署名しています。exe は作り直していません。
方法 A:リクエストヘッダーに書く
Vault は、sys/config/auditing/request-headers に登録した HTTP のヘッダーを、監査ログに記録します。今回の Vault には、X-Correlation-ID などのヘッダーが、HMAC 化せずに記録する設定(hmac=false)で登録されていました(登録したときのコマンドは記録に残っていません)。この設定は、方法 A を試す前にも読み取っています(監査ログに読み取りの記録があります)。下の出力は、方法 A と方法 B を試した後に読み取ったものです。今回、この設定は変えていません。
# 監査ログに記録する HTTP ヘッダーの設定を表示する
$ vault read -format=json sys/config/auditing/request-headers | jq -c .data
{"headers":{"correlation-id":{"hmac":false},"user-agent":{"hmac":false},"x-correlation-id":{"hmac":false}}}
sign_dig.sh と同じ処理を curl で行い、X-Correlation-ID ヘッダーにファイル名・exe の SHA-256(先頭16文字)・ビルド番号を書いて送るスクリプトを作りました。exe の SHA-256 は、署名した後の test.exe のハッシュ(e337a17e…)です。5.1節の osslsigncode の検証と同じときに計算しました(5.1節の「3つのハッシュの関係」でも、もう一度計算しています)。
# 署名した後の test.exe のファイル全体の SHA-256 を表示する(記録では、ls -l と openssl x509 も `;` でつないでいる。その部分は省略)
$ sha256sum /mnt/c/work_vault/phase18-pe/test.exe
e337a17ecc59f3faafec3dc4c37b68f3861225de13437a013134b336b35d45bb /mnt/c/work_vault/phase18-pe/test.exe
実際の運用では署名の前に送るので、署名前の exe のハッシュか .dig の値を入れることになります(この形は試していません)。
#!/usr/bin/env bash
# usage: sign_dig_meta.sh <file.dig> <token-file> <correlation-id> [out.dig.signed]
# sign_dig.sh と同じ処理。違いは、transit/sign を curl で呼び、X-Correlation-ID ヘッダーにファイル名などを載せること。
set -euo pipefail
dig="$1"; tokfile="$2"; corr="$3"; out="${4:-$1.signed}"
here="$(cd "$(dirname "$0")/.." && pwd)"
source "$here/work/vaultenv.sh"
input="$(tr -d '\r\n' < "$dig")"
echo "input(.dig base64) = $input"; echo "X-Correlation-ID = $corr"
resp="$(curl -sS --cacert "$VAULT_CACERT" -H "X-Vault-Token: $(cat "$tokfile")" -H "X-Correlation-ID: $corr" \
-X POST "$VAULT_ADDR/v1/transit/sign/codesign" \
-d "$(jq -nc --arg i "$input" '{input:$i,prehashed:true,hash_algorithm:"sha2-256",signature_algorithm:"pkcs1v15"}')")"
sig="$(jq -r '.data.signature // empty' <<<"$resp")"
[ -n "$sig" ] || { echo "vault error: $(jq -c .errors <<<"$resp")"; exit 2; }
b64="${sig#vault:v*:}"
tmp="$(mktemp -d)"; trap 'rm -rf "$tmp"' EXIT
base64 -d <<<"$input" > "$tmp/digest.bin"; base64 -d <<<"$b64" > "$tmp/sig.bin"
openssl pkeyutl -verify -pubin -inkey "$here/work/codesign-pub.pem" -pkeyopt digest:sha256 -in "$tmp/digest.bin" -sigfile "$tmp/sig.bin"
printf '%s' "$b64" > "$out"; echo "wrote $out"
# 署名の前に、監査ログの行数を記録する
$ wc -l <vault-poc>/audit/vault-audit.log | tee work/audit.before.meta
1229 <vault-poc>/audit/vault-audit.log
# X-Correlation-ID にファイル名などを書いて、.dig(コピー)に署名する
$ scripts/sign_dig_meta.sh work/metaA.dig work/token-sign 'file=test.exe;sha256=e337a17ecc59f3fa;build=phase18-001'; echo exit=$?
input(.dig base64) = kAKrRiUW6LuNohJxVqHQo/EG6guabXgCrEnd0nGGtvI=
X-Correlation-ID = file=test.exe;sha256=e337a17ecc59f3fa;build=phase18-001
Signature Verified Successfully
wrote work/metaA.dig.signed
exit=0
# 署名値が 4.4節の .dig.signed と同じかを比べる
$ cmp work/metaA.dig.signed /mnt/c/work_vault/phase18-pe/dig/test.exe.dig.signed && echo '方法A の署名値 = 既存の .dig.signed'
方法A の署名値 = 既存の .dig.signed
# 増えた監査ログの行から、ヘッダーとトークンのメタデータを表示する
$ tail -n +$(($(cut -d' ' -f1 work/audit.before.meta)+1)) <vault-poc>/audit/vault-audit.log | jq -c '{time,type,path:.request.path,display_name:.auth.display_name,headers:.request.headers,metadata:.auth.metadata}'
{"time":"2026-10-09T01:46:02.811735939Z","type":"request","path":"transit/sign/codesign","display_name":"token-phase18-sign","headers":{"user-agent":["curl/7.81.0"],"x-correlation-id":["file=test.exe;sha256=e337a17ecc59f3fa;build=phase18-001"]},"metadata":null}
{"time":"2026-10-09T01:46:02.815257902Z","type":"response","path":"transit/sign/codesign","display_name":"token-phase18-sign","headers":{"user-agent":["curl/7.81.0"],"x-correlation-id":["file=test.exe;sha256=e337a17ecc59f3fa;build=phase18-001"]},"metadata":null}
X-Correlation-ID に書いた値が、監査ログのリクエストとレスポンスの両方に平文で残りました。
方法 B:署名1回ごとに、ファイル名を書いたトークンを発行する
Vault のトークンには、メタデータ(任意のキーと値)を付けられます。メタデータは、そのトークンを使ったリクエストの監査ログに記録されます。そこで、署名1回ごとに、ファイル名などをメタデータに書いた使い捨てのトークンを発行しました。-use-limit=1 で1回しか使えないようにし、有効期間も5分にしています。
# ファイル名などをメタデータに書いた、1回だけ使えるトークンを発行する(値はファイルに保存する)
$ vault token create -policy=phase18-sign -use-limit=1 -ttl=5m -display-name=phase18-onetime -metadata file=test.exe -metadata sha256=e337a17ecc59f3fa -metadata build=phase18-002 -metadata requested_by=ci-pipeline-demo -field=token > work/token-onetime
# 署名の前に、監査ログの行数を記録する
$ wc -l <vault-poc>/audit/vault-audit.log | tee work/audit.before.metaB
1233 <vault-poc>/audit/vault-audit.log
# 使い捨てのトークンで、.dig(コピー)に署名する
$ scripts/sign_dig.sh work/metaB.dig work/token-onetime; echo exit=$?
input(.dig base64) = kAKrRiUW6LuNohJxVqHQo/EG6guabXgCrEnd0nGGtvI=
vault signature prefix = vault:v1 (key_version=1)
signature length = 384 bytes
Signature Verified Successfully
wrote work/metaB.dig.signed (512 bytes, base64)
exit=0
# 署名値が 4.4節の .dig.signed と同じかを比べる
$ cmp work/metaB.dig.signed /mnt/c/work_vault/phase18-pe/dig/test.exe.dig.signed && echo '方法B の署名値 = 既存の .dig.signed'
方法B の署名値 = 既存の .dig.signed
# 同じトークンで、もう一度署名を依頼する(1回しか使えないので拒否されるはず)
$ scripts/sign_dig.sh work/metaB.dig work/token-onetime work/metaB-2nd.dig.signed; echo exit=$?
input(.dig base64) = kAKrRiUW6LuNohJxVqHQo/EG6guabXgCrEnd0nGGtvI=
Error writing data to transit/sign/codesign: Error making API request.
URL: PUT https://127.0.0.1:8220/v1/transit/sign/codesign
Code: 403. Errors:
* 2 errors occurred:
* permission denied
* invalid token
exit=2
# 増えた監査ログの行から、トークンのメタデータと残りの使用回数を表示する
$ tail -n +$(($(cut -d' ' -f1 work/audit.before.metaB)+1)) <vault-poc>/audit/vault-audit.log | jq -c '{time,type,path:.request.path,display_name:.auth.display_name,metadata:.auth.metadata,remaining_uses:.auth.remaining_uses,error}'
{"time":"2026-10-09T01:46:21.701742472Z","type":"request","path":"transit/sign/codesign","display_name":"token-phase18-onetime","metadata":{"build":"phase18-002","file":"test.exe","requested_by":"ci-pipeline-demo","sha256":"e337a17ecc59f3fa"},"remaining_uses":1,"error":null}
{"time":"2026-10-09T01:46:21.708956719Z","type":"response","path":"transit/sign/codesign","display_name":"token-phase18-onetime","metadata":{"build":"phase18-002","file":"test.exe","requested_by":"ci-pipeline-demo","sha256":"e337a17ecc59f3fa"},"remaining_uses":1,"error":null}
{"time":"2026-10-09T01:46:21.820235115Z","type":"request","path":"transit/sign/codesign","display_name":null,"metadata":null,"remaining_uses":null,"error":"2 errors occurred:\n\t* permission denied\n\t* invalid token\n\n"}
{"time":"2026-10-09T01:46:21.820482685Z","type":"response","path":"transit/sign/codesign","display_name":null,"metadata":null,"remaining_uses":null,"error":"2 errors occurred:\n\t* permission denied\n\t* invalid token\n\n"}
メタデータに書いたファイル名などが、監査ログのリクエストとレスポンスの両方に平文で残りました。同じトークンでの2回目の依頼は 403 invalid token で拒否されました。2回目の記録に display_name と metadata が入っていないのは、トークンがもう無効になっていたためです。
監査ログの行数の内訳(記録の確認)
方法 A の署名の前の行数(1229)は、5.3節の後(1225)より4行、方法 B の署名の前の行数(1233)は、方法 A の後(1231)より2行増えています。増えた行の中身は、記事を書く段階で、該当の行を表示して確かめました。
# 監査ログの 1224〜1233 行目の、種類・パス・操作だけを表示する
$ sed -n 1224,1233p <vault-poc>/audit/vault-audit.log | jq -c '{type,path:.request.path,op:.request.operation}'
{"type":"request","path":"transit/sign/codesign","op":"update"}
{"type":"response","path":"transit/sign/codesign","op":"update"}
{"type":"request","path":"sys/mounts/transit/tune","op":"read"}
{"type":"response","path":"sys/mounts/transit/tune","op":"read"}
{"type":"request","path":"sys/config/auditing/request-headers","op":"read"}
{"type":"response","path":"sys/config/auditing/request-headers","op":"read"}
{"type":"request","path":"transit/sign/codesign","op":"update"}
{"type":"response","path":"transit/sign/codesign","op":"update"}
{"type":"request","path":"auth/token/create","op":"update"}
{"type":"response","path":"auth/token/create","op":"update"}
1224〜1225行目は 5.3節の拒否された依頼、1226〜1229行目は tune と request-headers の読み取り、1230〜1231行目は方法 A の署名、1232〜1233行目はトークンの発行です。
2つの方法の違い
| # | 項目 | A. リクエストヘッダー | B. トークンのメタデータ |
|---|---|---|---|
| 1 | ファイル名を書くのは誰か | 署名を依頼する側。トークンを持っていれば、何でも書ける |
トークンを発行する側(例:ビルドのパイプライン、承認者)。発行した後のトークンのメタデータを依頼する側が変えられるかは、試していない。依頼する側に、使い回せる署名用のトークン(3.6節の phase18-sign など)を渡さないことが前提になる |
| 2 | 事前の設定 | ヘッダーを sys/config/auditing/request-headers に登録する |
要らない |
| 3 | 運用の手間 | ヘッダーを付けるだけ | 署名1回ごとにトークンを発行する仕組みが要る |
| 4 | 向いている使い方 | ビルド番号やジョブ ID など、補助の情報を残す | 署名1回分の権限と、記録に残すラベルを、発行する側が決める |
どちらの方法でも、書かれたファイル名が本当に署名したファイルと同じかを、Vault は確かめません。Vault が受け取るのはダイジェストだけだからです。ファイル名と中身の対応を後から確かめられるように、今回はメタデータに exe の SHA-256 も入れました(先頭16文字。署名した後の exe のハッシュ)。署名の前に Vault の側でこの対応を確かめる方法(Vault Enterprise の Sentinel。リクエストの中身を条件にして許可・拒否を決めるポリシーの仕組み)は、試していません。
6.4 同じダイジェストに何度も署名した場合の記録の絞り込み
6.3節で同じ .dig に2回署名し直したため、6.1節と同じ方法で探すと、同じ input の記録が増えます。trace_audit.py に、ヘッダーとメタデータを表示する項目を足してから実行しました。
# 署名済みの test.exe から、同じ input の記録を探す(request の行だけを表示する)
$ python3 -I scripts/trace_audit.py /mnt/c/work_vault/phase18-pe/test.exe <vault-poc>/audit/vault-audit.log | grep -E 'sha256\(base64\)|entries|"type": "request"'
signedAttrs offset=2968 len=126 -> sha256(base64) = kAKrRiUW6LuNohJxVqHQo/EG6guabXgCrEnd0nGGtvI=
audit log entries with this input: 10
{"time": "2026-10-09T01:13:31.89270673Z", "type": "request", "display_name": "token-phase18-sign", "policies": ["default", "phase18-sign"], "remote": "127.0.0.1", "error": null, "x-correlation-id": null, "token_metadata": null}
{"time": "2026-10-09T01:30:50.073317256Z", "type": "request", "display_name": "token-phase18-nosign", "policies": ["default", "phase18-nosign"], "remote": "127.0.0.1", "error": "1 error occurred:\n\t* permission denied\n\n", "x-correlation-id": null, "token_metadata": null}
{"time": "2026-10-09T01:46:02.811735939Z", "type": "request", "display_name": "token-phase18-sign", "policies": ["default", "phase18-sign"], "remote": "127.0.0.1", "error": null, "x-correlation-id": ["file=test.exe;sha256=e337a17ecc59f3fa;build=phase18-001"], "token_metadata": null}
{"time": "2026-10-09T01:46:21.701742472Z", "type": "request", "display_name": "token-phase18-onetime", "policies": ["default", "phase18-sign"], "remote": "127.0.0.1", "error": null, "x-correlation-id": null, "token_metadata": {"build": "phase18-002", "file": "test.exe", "requested_by": "ci-pipeline-demo", "sha256": "e337a17ecc59f3fa"}}
{"time": "2026-10-09T01:46:21.820235115Z", "type": "request", "display_name": null, "policies": null, "remote": "127.0.0.1", "error": "2 errors occurred:\n\t* permission denied\n\t* invalid token\n\n", "x-correlation-id": null, "token_metadata": null}
| # | 時刻(UTC) | トークン | 内容 |
|---|---|---|---|
| 1 | 01:13:31 | token-phase18-sign |
4.4節の署名 |
| 2 | 01:30:50 | token-phase18-nosign |
5.3節の、拒否された依頼 |
| 3 | 01:46:02 | token-phase18-sign |
方法 A で、同じ .dig にもう一度署名したもの(ヘッダーつき) |
| 4 | 01:46:21(.70) | token-phase18-onetime |
方法 B で、同じ .dig にもう一度署名したもの(メタデータつき) |
| 5 | 01:46:21(.82) | (記録なし) | 方法 B で、使い終わったトークンで依頼して拒否されたもの |
1番・3番・4番は、同じ鍵で同じ input に署名したので、署名値も同じです(RSA PKCS#1 v1.5 は同じ入力に同じ署名を返します)。そのため、exe の署名値だけでは、3つのうちどの依頼の署名かを区別できません。
区別する手がかりは、タイムスタンプです。exe のタイムスタンプには、TSA が時刻(genTime)と、タイムスタンプを付けた署名値のハッシュ(messageImprint)を入れています。TSTInfo(タイムスタンプの中身)を asn1parse で取り出し、Vault の署名値の SHA-256 と比べました(手順は折りたたみ)。
TSTInfo から messageImprint と genTime を取り出した手順
最後の2つのコマンド(-strparse 3581 の asn1parse と sha256sum)は、実際には ; でつないで1行で実行しました。
# 署名データの先頭8バイト(WIN_CERTIFICATE)を除いて、PKCS#7 の部分だけを work/test.exe.p7.der に保存する(記録では、この後ろに別の asn1parse(要素の位置の一覧)もつないでいる。その部分は省略)
$ tail -c +9 work/test.exe.sig.der > work/test.exe.p7.der
# タイムスタンプの中身(TSTInfo)がどの位置にあるかを探す(TSTInfo の OID の後ろの OCTET STRING が中身)
$ openssl asn1parse -inform DER -in work/test.exe.p7.der 2>/dev/null | grep -A3 'id-smime-ct-TSTInfo' | head -4 | cut -c1-110
3566:d=12 hl=2 l= 11 prim: OBJECT :id-smime-ct-TSTInfo
3579:d=12 hl=2 l= 105 cons: cont [ 0 ]
3581:d=13 hl=2 l= 103 prim: OCTET STRING [HEX DUMP]:306502010106096086480186FD6C07013031300D060960864801
3686:d=11 hl=4 l=4922 cons: cont [ 0 ]
# 位置 3581 の TSTInfo を分解して、messageImprint と genTime を表示する
$ openssl asn1parse -inform DER -in work/test.exe.p7.der -strparse 3581 -i | grep -E 'OBJECT|OCTET STRING|GENERALIZEDTIME'
5:d=1 hl=2 l= 9 prim: OBJECT :2.16.840.1.114412.7.1
20:d=3 hl=2 l= 9 prim: OBJECT :sha256
33:d=2 hl=2 l= 32 prim: OCTET STRING [HEX DUMP]:EB4975C78CB26024AA607C37B733C64472C3CDE7F1B4591FBCA16699DED807F7
86:d=1 hl=2 l= 15 prim: GENERALIZEDTIME :20261009011444Z
# Vault の署名値の SHA-256 を表示する
$ sha256sum work/hsm/vault-sig.bin
eb4975c78cb26024aa607c37b733c64472c3cde7f1b4591fbca16699ded807f7 work/hsm/vault-sig.bin
messageImprint(EB4975…07F7)は、Vault の署名値の SHA-256 と同じです。このタイムスタンプは、この署名値に対して 2026-10-09 01:14:44 UTC に付けられました。3番と4番の依頼(01:46)はそれより後なので、exe に入っている署名は、それより前の1番の依頼(01:13:31)で作られたものと判断できます。
同じ exe に署名をやり直すと、同じダイジェストになる可能性があります。署名属性に、署名した時刻が入っていないためです(5.1節の Signing time: N/A。/dg を2回実行して比べてはいません)。その場合は、このように時刻で記録を絞り込みます。
7. 実際に試して分かった注意点
| # | 注意点 | 内容 |
|---|---|---|
| 1 |
/dg は対象のファイルを書き換える |
/dg の後の exe には仮の署名が入っている(4.2節)。/dg から /di までの間に、exe を作り直したり差し替えたりしない |
| 2 | Windows PowerShell 5.1 のトランスクリプトに、signtool の出力が残らないことがある | 外部のプログラムの出力を記録するには 2>&1 | Out-Host を付ける(4.5節) |
| 3 | osslsigncode 2.2.0 のタイムスタンプの時刻表示が、9時間ずれる | 5.1節では Oct 8 16:14:44 2026 GMT と、実際より9時間早い時刻(前日の 16:14:44)を GMT として表示した。タイムスタンプの中の時刻(TSA が返した genTime)は、6.4節のとおり 20261009011444Z(2026-10-09 01:14:44 UTC、日本時間 10:14:44)で、Windows の表示(Fri Oct 09 10:14:44 2026)と合っている。表示だけの問題で、検証の結果には影響しない |
| 4 | osslsigncode の出力に cms_get_enveloped_type ... content type not enveloped data が出る |
結果は Succeeded。原因は調べていない |
8. まとめ
signtool のダイジェスト署名と Vault Transit を組み合わせて、PKCS#11 ラッパー(C コード)を作らずに、Vault を通して exe に Authenticode 署名できました。
| # | 確かめたこと | 結果 | 節 |
|---|---|---|---|
| 1 |
.dig の中身 |
署名属性の SHA-256 を base64 にした値。Vault に prehashed=true でそのまま渡せる |
4.3 |
| 2 |
.dig.signed の形式 |
署名値を base64 にした値。Vault の vault:v1: の後ろをそのまま使える |
4.5 |
| 3 | 署名の有効性 | Linux(osslsigncode)と Windows(signtool、Get-AuthenticodeSignature)の両方で有効。exe も実行できた | 5.1、5.2 |
| 4 | 改ざんの検知 | 1バイト書き換えると、Linux でも Windows でも検証が失敗した | 5.1、5.2 |
| 5 | 署名の権限 | 署名を許可していないトークンは、Vault が 403 で拒否した。拒否も監査ログに残った | 5.3 |
| 6 | 監査ログ | 署名1回ごとに、トークンの名前つきで記録が残った。exe から、同じダイジェストへの署名依頼の記録を探せた。同じダイジェストに複数回署名した場合は、タイムスタンプの時刻で絞れた | 4.4、6.1、6.4 |
| 7 | 鍵の場所 | Vault の署名と、SoftHSM に直接署名させた結果がバイト単位で一致した | 6.2 |
| 8 | ファイル名の記録 | リクエストヘッダー、またはトークンのメタデータで、監査ログに残せた | 6.3 |
確かめていないことは次のとおりです。
- 実際の HSM での動作(今回は SoftHSM)
- signtool の
/dlib(外部の署名処理を DLL として組み込む方法)を使って、1回のコマンドで署名する方法 - CI(継続的インテグレーション)への組み込み、複数ファイルの一括署名
- Vault Enterprise の Sentinel(リクエストの中身を条件に許可・拒否を決めるポリシー)や Control Groups(別の人の承認が無いと実行できないようにする仕組み)と組み合わせる方法
- MSI など、exe 以外の形式
本番で前提が変わる点として、次のことも確かめていません。
- ビルドする Windows と署名を依頼する Linux が別のマシンのとき(今回は同じ PC の Windows と WSL2)の、
.digと.dig.signedの受け渡しの方法 - Vault の各ノードが別のホストにあるとき、各ノードから同じ HSM の鍵を使える構成(今回は3ノードが同じホストで、1つの SoftHSM のトークンを共有)
- Transit 鍵のローテーション(
rotate)と、証明書の更新の関係(Transit の API ドキュメントでは、transit/signはkey_versionを指定しないと最新の鍵のバージョンで署名する。managed_key 型の鍵でローテーションしたときに何が起きるかは確かめていない) - 証明書の失効の確認(証明書の CRL と CA 証明書の URL は、Vault の
https://127.0.0.1:8230を指している。そのため、検証する側から Vault に接続する可能性がある。5.2節の検証で、失効の確認がどう扱われたかは調べていない) - PIN の管理と HSM 側のアクセス制御で、Vault を通らない署名(6.2節)を防ぐ方法