1. はじめに
コード署名とは、実行ファイルやスクリプトに発行者の電子署名を付与する仕組みです。WindowsではAuthenticodeという形式を使います。
署名があることで、「誰が作ったか」「配布後に改ざんされていないか」を利用側で確認できます。
署名には秘密鍵が必要で、ビルドやリリースのたびに同じ鍵で署名を行います。
そのため一般的には、鍵を持つ署名専用のサーバやサービスを用意し、ビルドパイプライン等から署名リクエストを送る構成を取ります。
このとき設計が必要になるのが、以下の3点です。
- 鍵をどこに・どう保護して置くか
- 誰が署名を要求できるか
- 「誰が・いつ署名したか」を後から追えるか
開発者のPCや、署名サーバのディスクに置くだけでは、この3点を満たすことは難しいです。
今回は、この署名サーバの役割をHashiCorp VaultのTransitシークレットエンジンに担わせる構成を試しました。Transitは、秘密鍵をVaultの外に出さずに、署名・暗号化の演算だけをリクエストできる機能です。
鍵を誰が持っているかではなく、誰が署名リクエストを送れるかでアクセス制御でき、リクエストはVaultの監査ログに残ります。上記3点を満たせる構成です(ただし本検証はrootトークンで署名しているため、「誰が」を識別するには本番でトークンを分ける必要があります。4.1.3節で説明します)。
Authenticode署名には、秘密鍵による署名演算のほかに、署名者を示す証明書も必要です。証明書は署名演算そのものには使いません。署名済みファイルに証明書を一緒に埋め込んでおき、検証側(WindowsやPowerShellなど)が「誰の鍵で署名されたか」を表示したり、信頼できるルートCAまでチェーンを遡って検証したり、証明書に入っている公開鍵で署名値そのものを検証したりするために使います。
この証明書はVaultのPKIシークレットエンジン(CSRから証明書を発行するCA機能)で発行します。
PKIは証明書を1回発行するところまでが役割で、同じ鍵で繰り返し署名するAPIは持っていません。
そのため今回の構成では、PKIが証明書を発行し、Transitが鍵を保持して署名する、という役割分担にしています(3章で両方使います)。
ここまでの役割分担を図にすると、以下のようになります(処理の流れは2.3節、設定と実行の手順は3章・4章で扱います)。
(図は役割分担を示すために簡略化しています。証明書の実際の受け渡し方は3.2節で説明します)
図中の3つの要素の意味
- 証明書: 「この公開鍵の持ち主は誰か」を、CA(ここではVaultのPKI)が保証した電子的な身分証明書です。署名済みファイルに埋め込んでおくと、ファイルを受け取った側は証明書の公開鍵で署名を検証できます。これにより「誰が署名したか」と「署名後に改ざんされていないか」を確かめられます。
- 署名: Transitが秘密鍵で作る値です。秘密鍵を持つ本人にしか作れないので、証明書の公開鍵で検証できれば本人が署名したことになります。
- タイムスタンプ: TSAが「この署名はこの日時に存在した」と証明するデータです。証明書には有効期限があります。タイムスタンプがあれば、証明書の期限が切れた後も「署名した時点では有効だった」と判断され、署名は有効なまま扱われます。
署名クライアントにはjsignを使います。jsignは、Windowsの実行ファイルやPowerShellスクリプトにAuthenticode署名を付けるJava製のOSSツールです。署名したいファイルを指定して実行すると、jsignが内部で「ファイルのダイジェストを計算→鍵ストアに署名を依頼→返ってきた署名をファイルに埋め込む」という処理を行います。鍵ストアはローカルのPKCS#12ファイルだけでなく、AWS KMSやAzure Key Vault、Vault Transitも指定できます。
jsignの公式ドキュメントは、Vaultでの署名を次のように説明しています。
HashiCorp Vault exposes keys through secrets engines. Jsign supports the Google Cloud KMS and Transit secrets engines. The keystore parameter references the endpoint of the secrets engine, which is typically the Vault server URL and the API version v1 followed by the secrets engine path. The certificate must be provided separately using the certfile parameter. The alias parameter specifies the name of the key in Vault.
公式ドキュメントのコード例は以下です(Google Cloud KMSシークレットエンジンの例ですが、storetype・keystore・storepass・alias・certfileの渡し方はTransitでも同じです)。
jsign --storetype HASHICORPVAULT \
--keystore https://vault.example.com/v1/gcpkms \
--storepass <vault-token> \
--alias test:1 \
--certfile full-chain.pem application.exe
keystoreにシークレットエンジンのエンドポイント、certfileに証明書(PKIが発行したもの)、aliasに鍵名(Transitの鍵名)を渡す、という対応関係が分かります。Transitシークレットエンジンを使う場合は、keystoreの末尾を/v1/gcpkmsではなく/v1/transitに変えるだけです(実際に使ったコマンドは4.1節で示します)。このコマンドでjsignは、秘密鍵そのものを受け取ることなく、Vaultに「このファイルのダイジェストに署名してほしい」とリクエストを送るだけで署名を完了できます。
ここまでが、設計どおりに動けば3つの設計ポイントを満たすはずの構成です。ところが、Transitが保持する署名用の秘密鍵をどこに置くかによって、成立する・成立しないが分かれました。試した3パターンです。
| # | ケース | 鍵の置き方 | jsignでの結果 |
|---|---|---|---|
| 1 | Case A | SoftHSMなし。Vault Transitのソフトウェア鍵(鍵はVaultのストレージ内) | 成功 |
| 2 | Case B | SoftHSMをPKCS#11バックエンドにしたVault Managed Key | 失敗(クラッシュ) |
| 3 | Case C | Case Bと同じ鍵構成に、jsignとVaultの間に自作のPKCS#11ラッパー(PoCとして実装)を挟んだ構成 | 成功 |
各ケースの挙動は4章で説明します。
Case Cの自作ラッパーは、jsignを改造せずにManaged Keyで署名できるかを確かめるための、PoC(概念実証)としての実装です。本番で同じ実装を使う場合の考慮点は4.3.5節にまとめています。
用語集(本記事で登場する専門用語の一覧です。既に分かる場合は読み飛ばしてください)
| # | 用語 | 説明 |
|---|---|---|
| 1 | コード署名 / Authenticode | 実行ファイル・スクリプトに発行者の電子署名を付与する仕組み。Windowsの署名形式はAuthenticodeと呼ぶ |
| 2 | HashiCorp Vault | 秘密情報・鍵・証明書を集中管理するソフトウェア |
| 3 | Transit(シークレットエンジン) | 秘密鍵をVaultの外に出さずに署名・暗号化などの演算を行う機能 |
| 4 | PKI(シークレットエンジン) | 証明書発行局(CA)としてCSRから証明書を発行するVaultの機能 |
| 5 | Managed Keys | VaultがHSM等の外部鍵管理システムの鍵を参照して使うための機能。Transit側はtype=managed_keyで参照する |
| 6 | SoftHSM | HSMをソフトウェアで模倣したOSS実装。実HSMの代替にはならない点に注意 |
| 7 | PKCS#11 | HSMなどの暗号デバイスを操作するための標準API規格(OASIS策定) |
| 8 | jsign | AuthenticodeをJavaから行うOSSの署名ツール(本記事の署名クライアント) |
| 9 | TSA(タイムスタンプ局) | 署名に対して「いつ署名されたか」を証明するRFC 3161準拠のサービス。証明書の有効期限が切れた後も署名を有効に保つために使う |
| 10 | RFC 3161タイムスタンプ | TSAへのリクエスト・レスポンス形式と、発行されるタイムスタンプトークンの構造を定めたIETFの標準規格。jsignはこの形式でTSAと通信する |
| 11 | OpenSSL | 証明書・鍵・署名を扱うOSSの暗号ツールキット。本記事ではVaultの署名結果や証明書を、Vaultの返り値を信用せず独立に検証する際に使う |
| 12 | CSR(証明書署名要求) | 「この公開鍵とこの名前で証明書を発行してほしい」という申請データ。対応する秘密鍵で署名して、CAに提出する |
| 13 | CA / Root CA / 中間CA | CA(認証局)は証明書を発行する主体。Root CAは信頼の起点となるCAで、中間CA(Issuing CA)はRoot CAに認められて日々の証明書発行を担当するCA |
| 14 | 証明書チェーン | リーフ証明書(署名者の証明書)から中間CA、Root CAまでを順につないだもの。検証する側はこれをたどって信頼できるか判断する |
| 15 | ダイジェスト(ハッシュ値) | ハッシュ関数で計算したデータの「指紋」。SHA-256なら元のデータの大きさに関係なく32バイトになる |
| 16 | HMAC | 鍵を使って計算するハッシュ値。Vaultの監査ログは、既定で文字列の値をHMAC化して記録し、元の値を読めないようにする |
| 17 | EKU / OID | EKU(拡張鍵用途)は証明書の用途(コード署名・TLSサーバなど)を示す拡張。OIDは用途などを表す番号(例: コード署名は1.3.6.1.5.5.7.3.3) |
| 18 | PEM / DER | 証明書の保存形式。DERはバイナリ形式、PEMはDERをbase64にして-----BEGIN ...-----で囲んだテキスト形式 |
| 19 | PKCS#1 v1.5 / PSS | RSA署名のパディング方式(署名前にデータを整える方法)の2種類。方式が違うと検証に失敗する。本記事の検証では、jsignはPKCS#1 v1.5で署名を要求していた(4.1節) |
| 20 | SunPKCS11 | Java標準のPKCS#11プロバイダ。jsignの--storetype PKCS11は、これを経由してPKCS#11ライブラリを呼び出す |
扱う内容
- Vault Transitでのコード署名鍵の作り方(証明書チェーンの紐付けまで)
- jsignからVault Transitを呼ぶときに踏む落とし穴
- SoftHSMをVaultのManaged Keyとして使う手順
- jsignがVault Transitの
managed_keyで落ちる原因と、PKCS#11ラッパーで回避する方法 - 3ケースそれぞれのVault監査ログの実例
- PowerShellスクリプトへのAuthenticode署名
扱わない内容
- Vault TransitのキーをPKCS#11経由で直結し、Vaultの署名APIを一切経由しない方式(別方式として検証はしていますが、本記事では扱いません)
- 実HSM(AWS CloudHSMやThales Luna等)での検証。SoftHSMはソフトウェア実装であり、実HSMの代替にはなりません
-
.exe/PEファイルでの署名検証。Authenticodeの実務ではPE署名が主流ですが、テスト用の.exeファイルを用意しなかったため、本記事はPowerShellスクリプトのみで検証しています - 本番運用のRBAC設計・鍵ローテーション運用
使用技術: HashiCorp Vault(Transit / PKI / Managed Keys)、jsign、SoftHSM、OpenSSL、RFC 3161タイムスタンプ
2. 検証環境の概要
2.1 使用バージョン
以下は、各ケースで使用したバージョンの一覧です。値は検証時の記録からそのまま転記しており、記録が無いものは書いていません。
| # | 項目 | 値 |
|---|---|---|
| 1 | Vault(Case A) | v2.1.0+ent.fips1403 |
| 2 | Vault(Case B / C) |
v2.1.0+ent.hsm.fips1403(PKCS#11 Managed KeyはHSMビルドのみ対応) |
| 3 | jsign | 7.5 |
| 4 | SoftHSM | 2.6.1 |
| 5 | OpenSC(pkcs11-tool) |
0.22.0-1ubuntu2.1 |
| 6 | Java | OpenJDK 17.0.20
|
| 7 | OS | Ubuntu 22.04(WSL2, kernel 6.6.87.2-microsoft-standard-WSL2) |
| 8 | Windows(検証端末) | WSL2ホストのWindows側PowerShell |
2.2 3ケースの違い
3ケースの違いを一言で言うと、秘密鍵の保管場所と、jsignのアクセス方法の2点です。
| # | 項目 | Case A | Case B | Case C |
|---|---|---|---|---|
| 1 | 秘密鍵の保管場所 | Vaultのストレージ内(ソフトウェア鍵) | SoftHSM(Managed Key経由でVaultが参照) | 同左 |
| 2 | jsignのアクセス方法(--storetype) |
HASHICORPVAULT(Transit鍵を直接指定) |
同左 |
PKCS11(自作ラッパー経由。jsign自体は無改造) |
| 3 | 結果 | 成功 | 失敗(jsignがクラッシュ) | 成功 |
| 4 | 失敗の原因/回避方法 | - | jsignがtype=managed_keyという文字列を解析できない(5.2節) |
jsignを改造せず、PKCS#11ラッパーで回避(4.3節) |
| 5 | 追加開発の要否 | 不要 | -(失敗のため評価対象外) | 必要(自作PKCS#11ラッパー、C実装 約840行) |
| 6 | タイムスタンプ(TSA) ※ | 自前のローカルTSA | -(署名まで到達しないため使用せず) | 実在の外部公開TSA(DigiCert) |
上記の表のとおり、Case Bは「Case Aと同じアクセス方法×Case Cと同じ鍵」の組み合わせです。
※ TSAの種類(自前か外部公開か)は、鍵の保管場所・jsignのアクセス方法という比較軸とは無関係です。Case Aは検証用の自前ローカルTSA、Case Cは実在の外部公開TSA(DigiCert)を使っていますが、検証時に手元にあったものをそのまま使った結果であり、結論には影響しません。
各ケースの詳細な手順と検証結果は、Case Aが4.1節、Case Bが4.2節、Case Cが4.3節にあります。
2.3 構成図・シーケンス図
Case Aのシーケンスです。jsignがVault Transitに直接アクセスし、ローカルTSAでタイムスタンプを取得します。
図中の「32Bダイジェスト」「署名(384B)」とは
- ダイジェスト(32B): ハッシュ関数(ここではSHA-256)にデータを入れて得られる、データの「指紋」です。元のデータが何バイトでも結果は必ず32バイト(256ビット)になります。元データが1文字でも変わると、まったく別の値になります。ダイジェストから元のデータを復元することはできません。
- 何のダイジェストか: jsignはまずスクリプトのダイジェストを計算します。次に、そのダイジェストを、データの種類などの情報と一緒に「署名属性」というデータにまとめます(署名日時は署名属性には入らず、TSAのタイムスタンプ側に入ります)。Vaultに送るのは、この署名属性のダイジェストです。スクリプトのダイジェストは署名属性の中に入っているので、間接的にスクリプトの中身を保証できます。
- なぜダイジェストだけを送るのか: スクリプト本体をVaultに送らずに済むからです。ファイルが大きくても、Vaultに送る署名対象のデータは32バイトで済み、スクリプトの中身はVaultにも監査ログにも渡りません。
-
署名(384B): Vaultが秘密鍵でダイジェストに署名した結果です。RSA署名の長さは鍵の長さで決まります。今回の鍵は3072ビット(
rsa-3072)なので、3072 ÷ 8 = 384バイトになります。検証する側は、証明書に入っている公開鍵でこの384バイトを検証し、「32バイトのダイジェストに、この鍵の持ち主が署名した」ことを確かめます。
Case Bのシーケンスです。jsignは鍵情報の取得までは成功しますが、typeの文字列を解析する箇所で例外を起こし、transit/signに到達する前に終了します。
Case Cのシーケンスです。jsignは無改造のまま--storetype PKCS11で動き、自作のPKCS#11ラッパー(共有ライブラリ)がVault Transitへの変換を行います。鍵はSoftHSMに物理的に留まったままです。①〜⑫はすべて1回のjsignコマンドの実行中に起きます。ラッパーは、jsignの実行直後に読み込まれた時点(②)でVaultから証明書チェーンを取得します。
3. Vaultの設定
3.1 Transitエンジンと監査ログの設定
この節では、署名鍵を持つTransitエンジンと、署名操作を記録する監査ログを設定します。手順は次の2段階です。
- Transitエンジンを有効化し、取り出せない設定のコード署名用RSA鍵を作る
- 監査デバイス(
file)を有効化し、署名の検証に必要なフィールドだけを平文で記録する
完成すると、Vaultの中は以下の構成になります。
まずtransitシークレットエンジンを有効化し、コード署名用のRSA鍵を作ります。
# Transitシークレットエンジンを有効化する(transit/ にマウントされる)
$ vault secrets enable transit
Success! Enabled the transit secrets engine at: transit/
# コード署名用のRSA 3072ビット鍵を作る(エクスポート不可・平文バックアップ不可)
$ vault write transit/keys/codesign type=rsa-3072 exportable=false allow_plaintext_backup=false
ここで指定している2つのパラメータの意味です。
-
exportable:transit/export/signing-key等のエクスポート系APIで、鍵materialをVaultの外に取り出せるかどうかを制御します -
allow_plaintext_backup:transit/backup(鍵のバックアップ・別クラスタへの移行用エンドポイント)で、鍵を平文のまま取り出せるかどうかを制御します
いずれも既定値はfalseなので、今回明示しているのは既定値のままです。それでもあえて明示しているのは、両方とも一度trueにすると二度とfalseに戻せない片方向のスイッチだからです。「秘密鍵をVaultの外に一切出さない」という今回の設計を証明したいので、既定値に無言で頼るのではなく、鍵作成コマンドそのものにこの意図を残す形にしています。
exportable=false・allow_plaintext_backup=falseにしているので、root権限のトークンであっても秘密鍵を取り出すことはできません。実際に試すと以下のように拒否されます。
# 秘密鍵をエクスポートAPIで取り出そうとする(exportable=falseで拒否されるはず)
$ vault read transit/export/signing-key/codesign
Code: 400. Errors:
* private key material is not exportable
# 鍵を平文バックアップとして取り出そうとする(allow_plaintext_backup=falseで拒否されるはず)
$ vault read transit/backup/codesign
Code: 500. Errors:
* 1 error occurred:
* exporting is disallowed on the policy
監査ログはfileデバイスを使います。Vaultは、監査デバイスを1つ以上有効にしないと監査ログを記録しません。今回の環境では、Vaultクラスタの構築時に以下のコマンドで有効化しており、vault audit listで有効になっていることを確認しています。
# 監査デバイス(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へのすべてのリクエストとレスポンスが記録されます。既定(audit_non_hmac_request_keys・audit_non_hmac_response_keysはいずれも空配列)では、リクエスト・レスポンスに含まれる文字列の値はHMAC化されます(数値や真偽値はそのまま記録されます)。HMAC化は監査ログに書き込む値だけに対する処理で、署名などの処理そのものには影響しません。HMAC化されたままだと、ログから「何かに署名した」という事実は分かっても、ダイジェストや署名値そのものは読めず、第三者が独立に検証することができません。監査ログだけで第三者が署名を独立検証できるようにするため、署名リクエストに関わる一部のフィールドだけを可読化する設定(audit-non-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 \
-audit-non-hmac-response-keys=error \
transit
Success! Tuned the secrets engine at: transit/
指定した各オプションの意味と、設定したときの挙動です。
| # | オプション | 対象 | 意味・設定時の挙動 |
|---|---|---|---|
| 1 | input |
request | 署名対象のダイジェスト(base64)。設定すると平文で記録され、何に対して署名したかをログから確認できる |
| 2 | prehashed |
request |
inputが既にハッシュ済みかを示すフラグ。真偽値なので既定でも平文で記録されるが、意図を明示するために指定している |
| 3 | hash_algorithm |
request | ハッシュアルゴリズム名(例: sha2-256)。設定すると、検証時にどのハッシュ関数を使うべきか分かる |
| 4 | signature_algorithm |
request | 署名のパディング方式(pkcs1v15/pss等)。設定すると、検証時にどちらで検証すべきか分かる |
| 5 | marshaling_algorithm |
request | 署名値のエンコード形式。設定すると署名値の解釈方法が分かる |
| 6 | signature |
response | Vaultが返した署名値そのもの。設定すると平文で記録され、opensslで直接検証できる |
| 7 | key_version |
response | 署名に使われたTransit鍵のバージョン番号。どの公開鍵で検証すべきかが分かる。レスポンスでは数値なので既定でも平文で記録されるが、意図を明示するために指定している |
| 8 | error |
response | 失敗時のエラーメッセージ。設定すると、ログだけで失敗原因を確認できる(暗号検証とは別の運用上の目的) |
ここで注意したいのが、フラグをカンマ区切りの1文字列にせず、1キーずつ繰り返し指定することです。読み戻すと、別々の配列要素として登録されていることが確認できます。
# 設定したキーが1つずつ配列として登録されたかを読み戻して確認する
$ vault read sys/mounts/transit/tune
Key Value
--- -----
audit_non_hmac_request_keys [input prehashed hash_algorithm signature_algorithm marshaling_algorithm]
audit_non_hmac_response_keys [signature key_version error]
ここで-audit-non-hmac-response-keysにtypeやkeysを指定していないため、監査ログ上のtransit/keys/codesignのレスポンスではtype・name・public_keyなどがHMAC化されたまま残ります。4章の監査ログの実例でこの点を実際に確認します。
3.2 コード署名用証明書の準備
この節では、Transit鍵(transit/keys/codesign)に対応するコード署名用証明書を、Vault PKIで発行します。手順は次の3段階です。
- Transit鍵のCSR(証明書署名要求)を作る
- プライベートCA(自前のRoot CA・Issuing CA)を作り、CSRに署名して証明書を発行する
- 発行した証明書チェーンをTransit鍵に紐付ける
完成すると、Vaultの中は以下の構成になります(Vaultの枠の中の各枠は、パス名のマウントを表します)。
3.2.1 Transit鍵のCSRを作る
証明書を発行してもらうには、CA(認証局)にCSRを提出します。CSRは「この公開鍵とこの名前(Subject)で証明書を発行してほしい」という申請書です。この申請書には、対応する秘密鍵で署名を付ける必要があります。秘密鍵の持ち主本人が申請していることを、CAが確かめるためです。
Transit鍵の秘密鍵はVaultの外に出せないので、opensslなどの手元のツールではTransit鍵のCSRを作れません。そのためにTransitに用意されているのが、transit/keys/<鍵名>/csrエンドポイント(Sign CSR)です。Vault公式のAPIドキュメントでは、このエンドポイントを次のように説明しています。
This endpoint signs a CSR with the
:namekey, allowing the key material never to leave Transit. If no template CSR is specified, an empty CSR is signed, otherwise, a copy of the specified CSR with key material replaced with this key material is signed.
csr(string: "") - Optional PEM-encoded CSR template to use as a basis for the new CSR signed by this key. If not set, an empty CSR is used.(出典: Transit Secrets Engine API「Sign CSR」、2026-10-06参照)
使い方は、Subject(CN=...やO=...)を書いたCSRを「テンプレート」として渡す、という公式の仕様です。このエンドポイントのパラメータは鍵名・鍵バージョン・テンプレートCSR(csr)の3つだけで、Subjectを個別に渡すパラメータはありません。テンプレートを渡さないと、Subjectが空のCSRが返ってきます(実際に試して確認しています)。
テンプレートCSRも普通のCSRなので、作るときに何かの鍵が必要です。そこで、その場限りの捨て鍵でテンプレートCSRを作ります。Transitは、テンプレートの公開鍵をTransit鍵のものに差し替え、Transit鍵の秘密鍵で署名し直した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鍵に差し替わったCSRが返る
$ vault write transit/keys/codesign/csr csr=@template.csr
Key Value
--- -----
csr -----BEGIN CERTIFICATE REQUEST-----
(... 中略 ...)
-----END CERTIFICATE REQUEST-----
(返ってきたCSRを csr-template.pem として保存)
公開鍵のSHA-256フィンガープリントを比べると、返ってきたCSRの公開鍵は捨て鍵ではなく、Transit鍵のものに差し替わっていることが分かります。
transit pub.pem : bad55b5350fff2ee43cb939140ba08589d80b336c9fe3492f537f8dda36b94e8
CSR(template) pubkey : bad55b5350fff2ee43cb939140ba08589d80b336c9fe3492f537f8dda36b94e8
捨て鍵 template.csr : 6aff6f591f05ace9260689ca209bbddda27439ff7a263fe0932eb65bcbc36062
3.2.2 プライベートCA(自前のRoot CA・Issuing CA)を作り、証明書を発行する
次に、CSRに署名して証明書を発行するCAを用意します。今回は、次の2段構成のCAを新しく作りました。
- Root CA: 信頼の起点です。検証する側(Windows)は、このRoot CAの証明書を信頼ストアに入れて信頼します。Root CAが署名するのは、Issuing CAの証明書1枚だけです。
- Issuing CA(中間CA): 日々の証明書発行を担当します。Root CAの署名を受けて「Root CAに認められたCA」になります。
Root CAとIssuing CAを別々のマウント(pki_codesign_root/とpki_codesign/)に分けた理由は3つあります。
-
Root CAの鍵を日常の発行作業から切り離すため: Vaultのアクセス権限(ポリシー)はパス単位で付与します。マウントが分かれていれば、「
pki_codesign/sign/codesignだけを呼べる」権限を作り、Root CA側(pki_codesign_root/)には一切触れさせない、という設定がしやすくなります。Root CAの鍵は、中間CAの証明書を発行するときにしか使いません。 -
有効期間の上限をCAごとに設定するため: 証明書の有効期間の上限(
-max-lease-ttl)はマウント単位で設定します。Root CAは20年、Issuing CAは10年と、別々の上限を設定しています。 -
既存のPKIと混ぜないため: このVaultには、以前の検証で作ったテスト用のPKI(
pki/)が既にあります。コード署名用のCAを別マウントで新しく作り、既存のpki/には一切手を加えていません。
VaultのTLS(HTTPS)のサーバ証明書は、Vaultの外でファイルとして作ったCA(<vault-poc>/pki/root-ca.pem)が発行しています。本節で作るコード署名用のRoot CAとは別物です。4.1.4節の異常系3では、この2つのRoot CAを取り違えて検証に失敗しています。
PoCなので、本記事ではRoot CAも同じVaultの中に作っています。本番では、Root CAをオフライン環境に置き、Vaultには中間CAだけを置く構成が一般的です。
各コマンドが何をしているかは、コメントで示しています。
# Root CA用のPKIマウントを作る(有効期間の上限: 20年)
$ vault secrets enable -path=pki_codesign_root -max-lease-ttl=175200h pki
# Issuing CA用のPKIマウントを作る(有効期間の上限: 10年)
$ vault secrets enable -path=pki_codesign -max-lease-ttl=87600h pki
# Root CAを作る(自己署名。秘密鍵はVault内で生成され、外に出ない)
# (応答の certificate を codesign-root.pem として保存)
$ vault write pki_codesign_root/root/generate/internal \
common_name="PoC Code Signing Root CA" issuer_name="codesign-root" \
key_type=rsa key_bits=4096 ttl=175200h
# Issuing CAの鍵ペアをVault内で作り、そのCSRを出力する(→ inter.csr として保存)
$ vault write pki_codesign/intermediate/generate/internal \
common_name="PoC Code Signing Issuing CA" key_type=rsa key_bits=3072
# Root CAがIssuing CAのCSRに署名し、中間CA証明書を発行する(→ codesign-inter.pem として保存)
$ vault write pki_codesign_root/root/sign-intermediate csr=@inter.csr \
common_name="PoC Code Signing Issuing CA" ttl=87600h format=pem_bundle
# 発行された中間CA証明書をIssuing CAに取り込む(これでIssuing CAが発行できる状態になる)
$ vault write pki_codesign/intermediate/set-signed certificate=@codesign-inter.pem
# 発行ルール(ロール)を定義する: コード署名用途だけ・RSA 3072・有効期間1年
$ vault write pki_codesign/roles/codesign \
allow_any_name=true enforce_hostnames=false allow_bare_domains=true \
server_flag=false client_flag=false code_signing_flag=true \
key_usage="DigitalSignature" ext_key_usage="CodeSigning" \
key_type=rsa key_bits=3072 use_csr_common_name=true \
country="JP" organization="PoC" ou="Code Signing" \
ttl=8760h max_ttl=8760h
# 3.2.1で作ったTransit鍵のCSRをロールに通して、リーフ証明書を発行する
# (応答の certificate を leaf.pem、issuing_ca を issuing_ca.pem として保存)
$ vault write pki_codesign/sign/codesign csr=@csr-template.pem ttl=8760h
ロールの主なパラメータは以下のとおりです。
| # | パラメータ | 意味 |
|---|---|---|
| 1 |
code_signing_flag=true / ext_key_usage="CodeSigning"
|
証明書の用途(EKU)を「コード署名」にする |
| 2 |
server_flag=false / client_flag=false
|
TLSのサーバ・クライアント用途を付けない。コード署名以外に使えない証明書にする |
| 3 | key_usage="DigitalSignature" |
鍵の用途をデジタル署名に限定する |
| 4 | use_csr_common_name=true |
CN(Common Name)にCSRの値(Code Signing Demo)を使う |
| 5 |
allow_any_name=true / enforce_hostnames=false
|
CNをホスト名の形式に限定しない(既定ではホスト名の形式でないと発行できない) |
| 6 |
ttl / max_ttl
|
証明書の有効期間を1年にする |
発行された証明書を検査し、EKUにCode Signingだけが入っていること、Microsoft独自のLifetime Signing OID(1.3.6.1.4.1.311.10.3.13)が入っていないことを確認しています。
Lifetime Signing OIDを入れない理由
- 通常、Windowsはタイムスタンプ付きの署名を、証明書の期限が切れた後も有効として扱います。タイムスタンプが「署名した時点では証明書が有効だった」ことを証明するからです。
- 証明書にLifetime Signing OIDが入っていると、この扱いがなくなります。タイムスタンプがあっても、証明書の期限切れと同時に署名も無効になります。
- 本記事のリーフ証明書の有効期間は1年です。このOIDが入っていると、1年後に署名済みスクリプトがすべて「署名無効」になり、TSAでタイムスタンプを付けた意味がなくなります。
- 署名はこのOIDがあっても作成できます。問題になるのは、証明書の期限が切れた後の検証です。
まず、opensslで鍵用途(Key Usage)と拡張鍵用途(EKU)を表示します。
# 証明書の鍵用途(Key Usage)と拡張鍵用途(EKU)を表示する
$ openssl x509 -in leaf.pem -noout -ext extendedKeyUsage,keyUsage
X509v3 Key Usage: critical
Digital Signature
X509v3 Extended Key Usage:
Code Signing
ただし、この表示はopensslが名前を知っているOIDを人間向けの名前に置き換えたものです。表示に出てこないことだけでは、OIDが証明書に「入っていない」ことの証明になりません。そこで、証明書をDER(バイナリ形式)に変換し、各OIDのDERエンコード(06 <長さ> <値>のバイト列)が含まれているかを直接バイト検索しました。
import subprocess, sys
# 証明書をDER(バイナリ)に変換する
der = subprocess.run(["openssl", "x509", "-in", sys.argv[1], "-outform", "DER"],
capture_output=True, check=True).stdout
def oid_to_der(oid):
"""OID文字列を DER の OBJECT IDENTIFIER(タグ0x06+長さ+値)に変換する"""
p = [int(x) for x in oid.split(".")]
body = bytes([40 * p[0] + p[1]])
for n in p[2:]:
s = [n & 0x7f]; n >>= 7
while n:
s.append(0x80 | (n & 0x7f)); n >>= 7
body += bytes(reversed(s))
return bytes([0x06, len(body)]) + body
for name, oid in [("Code Signing", "1.3.6.1.5.5.7.3.3"),
("Lifetime Signing", "1.3.6.1.4.1.311.10.3.13"),
("Server Auth", "1.3.6.1.5.5.7.3.1"),
("Client Auth", "1.3.6.1.5.5.7.3.2")]:
print(f"{name:<19} {oid:<24} DER present: {oid_to_der(oid) in der}")
# 証明書のDERに、各EKUのOIDが含まれているかを検索する
$ python3 check_eku_oid.py leaf.pem
Code Signing 1.3.6.1.5.5.7.3.3 DER present: True
Lifetime Signing 1.3.6.1.4.1.311.10.3.13 DER present: False
Server Auth 1.3.6.1.5.5.7.3.1 DER present: False
Client Auth 1.3.6.1.5.5.7.3.2 DER present: False
3.2.3 証明書チェーンをTransit鍵に紐付ける
Authenticode署名には秘密鍵(署名に使う)と証明書(ファイルに埋め込む)の2つが必要です。秘密鍵はTransitの中にあり、証明書は3.2.2でPKIが発行しました。この2つを、Transit鍵1つにまとめて保存します。
この構成で実現していることは3つです。
- PKIは証明書を発行するだけ。署名には関わりません。
- Transit鍵に「秘密鍵+その証明書」をセットで持たせる。鍵のバージョンごとに証明書が対応づきます。
-
署名はTransit鍵から受け取り、証明書もTransit鍵に保存したものを使う。jsign(Case A/B)は、事前にファイルへ書き出したものを
--certfileで受け取ります。Case Cの自作ラッパーは、jsignの実行時(ラッパーが読み込まれた時点)にTransit鍵から直接読み取ります。
# リーフ → 中間CA → Root CA の順に1つのファイルへつなぐ(リーフが先頭)
$ cat leaf.pem issuing_ca.pem codesign-root.pem > fullchain.pem
# つないだ証明書チェーンをTransit鍵に保存する
$ vault write transit/keys/codesign/set-certificate certificate_chain=@fullchain.pem
上のコマンドで証明書を3枚(リーフ・中間CA・Root CA)つなぐ理由
- Windowsが署名を信頼するのは、リーフ証明書から信頼ストアのRoot CAまで、証明書を順にたどれたときです。
- 実測では、jsignが署名済みファイルに埋め込んだのはリーフと中間CAの2枚でした。Root CAは埋め込まれず、検証する端末の信頼ストアにあるものが使われます。
- それでもTransit鍵にRoot CAまで保存しているのは、チェーン全体を1か所で管理するためです。
set-certificateは、公式のAPIドキュメントで「鍵材をTransitの中に留めたまま、証明書を1つのマウントで管理できるようにする」ためのエンドポイントと説明されています(Transit Secrets Engine API「Set Certificate Chain」、2026-10-06参照)。
紐付けは、署名済みファイルに埋め込む証明書を、Vaultの中で鍵と一緒に保管しておくための操作です。署名クライアントは、署名するときに証明書を手元に持っている必要があります。その証明書をどこから取るかが署名クライアントによって違うため、紐付けが必須かどうかも分かれます。
| # | ケース | 証明書の取り先 | 紐付けが無い場合 | 紐付け |
|---|---|---|---|---|
| 1 | Case A / B(jsign) |
--certfileで渡すファイル。jsignはVaultから鍵の情報だけを読み、証明書は取らない(1章で引用した公式ドキュメントのとおり) |
影響なし。PKIが発行した証明書ファイルを直接渡しても署名できる | 必須ではない |
| 2 | Case C(自作ラッパー) | ラッパーが、jsign実行時にtransit/keys/codesignから読み取る |
ラッパーの初期化でエラーになり、署名できない | 必須 |
Case Cで紐付けが必須になる理由
Case Cでも--certfileは渡していますが、それだけでは署名できません。--certfileは「署名済みファイルに埋め込む証明書チェーン」を渡すためのもので、jsignが署名に使う鍵を探す処理には使われないからです。
jsign(正確にはJavaのSunPKCS11)は、署名の前にHSMへ次のように問い合わせます。PKCS#11では、HSMは中身を「オブジェクト」(秘密鍵1つ、証明書1つ、という単位)として答えます。
- 「秘密鍵はありますか?」→「あります(ID = X、名前 =
vault-transit-codesign)」 - 「IDがXの証明書はありますか?」→「あります」
- SunPKCS11は、秘密鍵と証明書が同じIDでそろったときだけ、それを「使える鍵」としてjsignに渡す
- jsignが、その鍵で署名を依頼する
2で「ありません」と答えると、秘密鍵があってもjsignには「鍵が無い」ように見えます。実際に、ラッパーを使わずjsignをSoftHSMに直接つないだ検証では、SoftHSMに証明書を入れていない状態だと、--certfileを付けてもNo certificate found in the keystoreなどのエラーで署名に失敗しました。リーフ証明書を鍵と同じIDでSoftHSMに書き込むと、署名できました。
Case Cでは、この問い合わせに答えるのがラッパーです。ラッパーは、1には「秘密鍵の番号」(実体は持たない。署名を頼まれたらVaultのtransit/signを呼ぶ)を、2には「jsign実行時にTransit鍵から読み取ったリーフ証明書」を返します。そのため、Transit鍵に証明書が紐付いていないと2に答えられず、署名できません。
なお、ラッパーが返す証明書はリーフ1枚だけです。中間CAを署名済みファイルに埋め込むために、--certfileでチェーン全体を渡しています。
Case A / Bでは、本検証はtransit/export/certificate-chain/codesignで取り出したチェーンを--certfileに渡しました。証明書を1か所で管理するための運用上の選択で、jsignの動作に必要なわけではありません。
3.3 SoftHSMとの連携
この節は、Case B・Cに共通の前提です。Case B・Cでは、SoftHSMをVaultのkms_libraryとして登録します。
まずSoftHSMをインストールします。pkcs11-tool(SoftHSMの中身を確認するためのツール)を使うために、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のトークンを初期化します。Vaultと同じOSユーザーで動かすため、既定のトークン置き場(root:softhsmグループ)ではなく専用のtokendirを用意しました。
# SoftHSMのトークン(鍵の保存場所)用のディレクトリを作る
$ mkdir -p <vault-poc>/softhsm/tokens
# SoftHSMの設定ファイルを作る(トークン置き場を上のディレクトリにする)
$ cat > <vault-poc>/softhsm/softhsm2.conf <<'EOF'
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>
PIN値はデモ用です。本番では秘密管理下の値を使ってください。
Vaultサーバ設定(.hcl)にkms_libraryスタンザを追記します。ラベルを"pkcs11"(型名)にして、名前はname属性で与える形です。
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がSoftHSMと通信するために読み込む、PKCS#11ライブラリ(共有ライブラリ .so)のパス。前の手順のreadlink -fで確認したSoftHSMのライブラリを指定している。実HSMを使う場合は、HSMベンダーが提供するPKCS#11ライブラリのパスに置き換える |
Case Aで使ったtransit/・pki_codesign/・pki_codesign_root/の各マウントは、Case Bの前にいったん削除し、Case B・C用に作り直しています。作り直したtransit/には、3.1節の監査ログの設定(vault secrets tune)を同じ内容で再度適用しています。
検証に使ったVaultは、3ケースとも同じ3ノードのraftクラスタ(各ノードのポートは8210/8220/8230)です。Case Aの後に、Vaultのバイナリを+ent.fips1403から+ent.hsm.fips1403に差し替えています(2.1節)。4章のコマンドでは、Case A・Cが8220、Case Bが8230のノードに接続していますが、同じクラスタの別ノードです。Case B・Cでは、3ノードとも同じSoftHSMのトークンを参照しています。リーダーではないノード(8220)に署名を依頼したときも、そのノード自身がSoftHSMのPKCS#11ライブラリを読み込んで署名し、リーダーに依頼したときとバイト単位で同じ署名が返ることを確認しています。
Managed Keyを登録し、Transitから使えるようにします。署名方式としてmechanism=0x0001(CKM_RSA_PKCS)を指定しています。
mechanism=0x0001(CKM_RSA_PKCS)を指定する理由
- RSA署名の2つの方式: RSA署名には、署名する前にデータを整える方法(パディング方式)が2種類あります。昔からある「PKCS#1 v1.5」と、より新しい「PSS」です。同じ鍵・同じデータでも、方式が違えば別の署名になります。検証する側は、署名した側と同じ方式で検証しないと失敗します。
-
mechanismとは: SoftHSMなどのPKCS#11デバイスに「どの方式で署名するか」を伝える番号です。
0x0001(CKM_RSA_PKCS)がPKCS#1 v1.5、0x000D(CKM_RSA_PKCS_PSS)がPSSです。 -
jsignが求める方式: jsignはPKCS#1 v1.5の署名を求めます(4.1節の監査ログでは
signature_algorithm=pkcs1v15)。Windowsも、この署名をPKCS#1 v1.5として検証します。4.1節では、同じ署名をPSSとして検証すると失敗することも確認しています。 -
PSSにしてはいけない理由: PKCS#11バックエンドのManaged Keyでは、署名の方式はjsignの指定ではなく、この
mechanismで決まります(5.3節)。ここを0x000D(PSS)にすると、jsignはPKCS#1 v1.5を求めているのに、PSSの署名が返ってきます。その結果、Windowsで署名を検証すると失敗するはずです(PSSを指定した場合の実測はしていません)。
# 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
# (Case Aで使った transit/ は事前に削除済み)Managed Keyの利用を許可したTransitエンジンを有効化する
$ vault secrets enable -allowed-managed-keys=hsm-codesign transit
# Managed Keyを参照するTransit鍵を作る(鍵の実体はSoftHSMの中)
$ vault write transit/keys/codesign type=managed_key managed_key_name=hsm-codesign
鍵は作成直後はまだHSM内に存在せず(遅延生成)、test/signエンドポイントを呼ぶとトリガされます。
# Managed Keyの署名テストを実行する(このときSoftHSM内に鍵が生成される)
$ vault write -f sys/managed-keys/pkcs11/hsm-codesign/test/sign
pkcs11-toolでSoftHSM内を見ると、鍵が実際に生成されていることが分かります。
# SoftHSMのトークン内のオブジェクト(鍵)を一覧表示する
$ pkcs11-tool --module /usr/lib/softhsm/libsofthsm2.so --token-label vault-hsm-demo \
--login --pin <USER-PIN> --list-objects
Private Key Object; RSA
label: hsm-codesign-rsa3072
Usage: decrypt, sign, unwrap
Access: sensitive, always sensitive, never extractable, local
Access: ... never extractable, localに注目してください。localはHSM内で生成された(外部からインポートされていない)ことを、never extractableは秘密鍵を取り出せないことを示しています。
次に、Transit鍵の公開鍵とSoftHSM内の公開鍵が同じ鍵かを確認しました。2つの公開鍵を同じDER形式(バイナリ形式)にそろえてからSHA-256のハッシュ(フィンガープリント)を計算し、値が一致するかを比べます。
# Transit鍵の公開鍵(PEM形式)を取り出し、transit-pub.pem として保存する
$ vault read -format=json transit/keys/codesign \
| python3 -c "import json,sys;print(json.load(sys.stdin)['data']['keys']['1']['public_key'],end='')" > transit-pub.pem
# SoftHSMのトークンから公開鍵を取り出し、hsm-pub.der として保存する(DER形式で出力される)
$ pkcs11-tool --module /usr/lib/softhsm/libsofthsm2.so --token-label vault-hsm-demo \
--login --pin <USER-PIN> --read-object --type pubkey \
--label hsm-codesign-rsa3072 --output-file hsm-pub.der
# 比較しやすいように、SoftHSMの公開鍵をPEM形式に変換する
$ openssl rsa -pubin -inform DER -in hsm-pub.der -pubout -outform PEM -out hsm-pub.pem
writing RSA key
# Transit鍵の公開鍵をDER形式にして、SHA-256のハッシュを計算する
$ openssl pkey -pubin -in transit-pub.pem -pubout -outform DER | sha256sum
5f20628740c4b15dab77573c80600c6c25942cf3159d21d12bbaf4afde6f52a6 -
# SoftHSMの公開鍵も同じ手順でハッシュを計算する(上と一致すれば同一の鍵)
$ openssl pkey -pubin -in hsm-pub.pem -pubout -outform DER | sha256sum
5f20628740c4b15dab77573c80600c6c25942cf3159d21d12bbaf4afde6f52a6 -
2つのハッシュが一致したため、Transit鍵codesignの公開鍵は、SoftHSM内の鍵hsm-codesign-rsa3072の公開鍵と同一です。
公開鍵が一致しても、確かめられたのは「同じ鍵ペアを指している」ことだけです。Vaultが署名するときに、本当にSoftHSMの中の秘密鍵を使っているかまでは分かりません。そこで、同じデータを「Vault経由」と「SoftHSMに直接」の2通りで署名させ、結果が同じになるかを比べました。
この比較が成り立つのは、今回の署名方式(RSA PKCS#1 v1.5)では、同じ秘密鍵で同じデータに署名すると、毎回まったく同じ署名になるからです。2つの署名がバイト単位で一致すれば、同じ秘密鍵で作られたことになります。
ただし、Vaultとpkcs11-toolでは、署名するデータの渡し方が違います。
| # | 署名させる方法 | 渡すデータ |
|---|---|---|
| 1 | Vault(transit/sign) |
32バイトのダイジェストだけ。hash_algorithm=sha2-256を指定すると、Vaultが「これはSHA-256のダイジェストです」という目印(19バイト)を自動で先頭に付けてから署名する |
| 2 |
pkcs11-tool(--mechanism RSA-PKCS) |
目印は自動で付かない。目印19バイト+ダイジェスト32バイト=51バイトを、自分で組み立てて渡す |
この「目印+ダイジェスト」の51バイトをDigestInfoと呼びます。RSA PKCS#1 v1.5の署名は、このDigestInfoに対して作られます。目印を付け忘れると、Vault経由の署名とは別の値になり、比較できません。
下のコマンドのprintfで書き込む「目印(19バイト)」の意味
下のコマンドの【SoftHSMに直接】の部分では、printf '\x30\x31\x30\x0d…\x04\x20'で19バイトの目印をファイルに書き込み、その後ろにダイジェスト(32バイト)をつなげています。この19バイト(30 31 30 0d 06 09 60 86 48 01 65 03 04 02 01 05 00 04 20)は、「SHA-256のダイジェストが32バイト続く」ことを表すバイト列で、規格(RFC 8017)で値が決まっています。
-
06 09の後ろの9バイト(60 86 48 … 02 01): SHA-256を表す識別番号(OID2.16.840.1.101.3.4.2.1) - 末尾の
04 20: 「この後に32バイト(0x20)のデータが続く」という意味
# 署名に使うテスト用のデータ(文字列)をファイルに書き込む
$ printf 'phase15-hsm-managed-key-demo' > msg.bin
# テスト用データのSHA-256ダイジェスト(32バイト)を計算し、digest.bin に保存する
$ openssl dgst -sha256 -binary msg.bin > digest.bin
# 【Vault経由】ダイジェスト(base64)をTransitに渡して署名させる
# (prehashed=true: 渡す値はダイジェスト計算済み、hash_algorithm=sha2-256: SHA-256の目印を付ける)
# (応答の signature から先頭の vault:v1: を除いてbase64デコードし、sig-pkcs1v15.bin として保存)
$ vault write -format=json transit/sign/codesign \
input="t1M26XHh7xKxexB80q1v3K4hzYq8/s0tGECQqOikR/4=" \
prehashed=true hash_algorithm=sha2-256 signature_algorithm=pkcs1v15
# 【SoftHSMに直接】SHA-256の目印(19バイト)をファイルに書き込む
$ printf '\x30\x31\x30\x0d\x06\x09\x60\x86\x48\x01\x65\x03\x04\x02\x01\x05\x00\x04\x20' > digestinfo.der
# 目印の後ろにダイジェスト(32バイト)をつなげ、51バイトのDigestInfoにする
$ cat digest.bin >> digestinfo.der
# Vaultを経由せず、pkcs11-toolでSoftHSMにDigestInfoへ直接署名させる
$ 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 digestinfo.der --output-file pkcs11-direct-sig.bin
Using signature algorithm RSA-PKCS
# 2つの署名ファイルのハッシュを比べ、同一かどうかを確認する
$ sha256sum sig-pkcs1v15.bin pkcs11-direct-sig.bin
7cf9ef1741df3869b7a23491821a80c72af682cb7e1263f7b9d2fcdb9becc30c sig-pkcs1v15.bin
7cf9ef1741df3869b7a23491821a80c72af682cb7e1263f7b9d2fcdb9becc30c pkcs11-direct-sig.bin
2つの署名はバイト単位で一致しました。Vault Transitが返した署名は、SoftHSM内の秘密鍵で作られたものです。
4. 動作確認
3つのケースそれぞれについて、次の4段階で確認します。
| # | 段階 | 内容 |
|---|---|---|
| 1 | 準備 | 署名クライアント側のインストール・設定 |
| 2 | 署名の実行 | jsignで.ps1に署名する。正常系(成功する手順)と異常系(失敗する条件)を分けて記載 |
| 3 | 署名結果の確認(Linux) | 署名が正しく作られたか、Vaultに何が送られたかを、Linux上で確かめる |
| 4 | Windowsでの有効性確認 | Windows自身が、その署名を「有効な署名」と判定するかを確かめる |
段階3と4は確かめていることが違います。段階3では、署名の計算が正しいか(正しい鍵で、正しいデータに署名されているか)を確かめます。段階4では、実際にスクリプトを使うWindowsが、証明書チェーンとタイムスタンプまで含めて署名を信頼するかを確かめます。段階3で正しくても、Windowsが信頼するRoot CAを持っていなければ署名は有効になりません。そのため、両方を確認しています。
4.1 Case A — SoftHSMなし(ソフトウェア鍵)
4.1.1 準備
まず、jsignの実行に必要なJavaと、jsign本体をインストールします。jsignはaptの公式リポジトリに無いため、GitHub Releasesの.debパッケージを使います。
# jsignの実行に必要なJava(OpenJDK 17)をインストールする
$ sudo apt-get install -y openjdk-17-jre-headless
# インストールされたJavaのバージョンを確認する
$ java -version
openjdk version "17.0.20" 2026-07-21
# jsign 7.5 の .deb パッケージをGitHub Releasesから取得する
$ curl -sSL -o jsign_7.5_all.deb \
https://github.com/ebourg/jsign/releases/download/7.5/jsign_7.5_all.deb
# 取得したファイルのハッシュを確認する(検証時の値)
$ sha256sum jsign_7.5_all.deb
7b4a01ba81e9ee866f09a5e45d40c928707eb5286f4e20d4042c67a141ae5e62 jsign_7.5_all.deb
# 取得したパッケージをインストールする
$ sudo apt-get install -y ./jsign_7.5_all.deb
Setting up jsign (7.5) ...
# インストールされたjsignのバージョンを確認する
$ jsign --version
Jsign 7.5
次に、--certfileで渡す証明書チェーンを用意します。3.2節でTransit鍵に紐付けたチェーンを取り出し、chain.pemとして保存します。
# Transit鍵に紐付けた証明書チェーンをJSONで取り出す
$ vault read -format=json transit/export/certificate-chain/codesign > chain-export.json
# JSONから鍵バージョン1のチェーン(PEM)を取り出し、chain.pem として保存する
$ python3 -c "import json;d=json.load(open('chain-export.json'))['data'];open('chain.pem','w').write(d['keys']['1'])"
# chain.pem に証明書が3枚(リーフ → 中間CA → Root CA)入っていることを確認する
$ grep -c "BEGIN CERTIFICATE" chain.pem
3
最後に、jsign(Java)がVaultにHTTPSで接続できるようにします。Vaultのサーバ証明書はプライベートCA(自前で構築したCA)の発行なので、JavaのデフォルトのトラストストアはVaultを信頼していません。VAULT_CACERTはVault CLI用の環境変数で、JVMには一切効きません。(設定しなかった場合の失敗は4.1.2節の異常系に載せています。)
対処は次の2段階です。
-
Vaultを信頼させるための証明書ファイル(トラストストア)を作る: Javaは、HTTPSの接続先を信頼してよいかを「信頼するCA証明書の一覧」で判断します。この一覧を入れたファイルをトラストストアと呼びます。OSやVault CLIの設定は見ません。Javaに最初から入っている一覧には、今回のプライベートCAが入っていません。そこで、VaultのTLS用Root CA証明書だけを入れたトラストストア(
vault-truststore.p12)を新しく作ります。 -
jsignのJavaに、そのトラストストアを使わせる: どのトラストストアを使うかは、Javaの起動オプション(
-Djavax.net.ssl.trustStore=…)で指定します。jsignコマンドの実体はシェルスクリプト(/usr/bin/jsign)で、中でjava $JSIGN_OPTS …のようにJavaを起動しています。そのため、環境変数JSIGN_OPTSに起動オプションを入れておくと、jsignが起動するJavaにそのまま渡ります。
# VaultのTLS用Root CA証明書を、jsign専用のトラストストア(PKCS12形式)に取り込む
$ keytool -importcert -noprompt -trustcacerts \
-alias poc-root-ca \
-file <vault-poc>/pki/root-ca.pem \
-keystore vault-truststore.p12 \
-storetype PKCS12 \
-storepass changeit
# jsign(Java)がこのトラストストアを使うように、JSIGN_OPTSでJVMオプションを渡す
$ export JSIGN_OPTS="-Djavax.net.ssl.trustStore=vault-truststore.p12 \
-Djavax.net.ssl.trustStoreType=PKCS12 \
-Djavax.net.ssl.trustStorePassword=changeit"
他の方法を使わなかった理由
-
Java標準のトラストストア(
cacerts)に追加する方法: 同じサーバで動く、すべてのJavaアプリがこのCAを信頼するようになってしまいます。 -
環境変数
JAVA_TOOL_OPTIONSで渡す方法: Javaの起動時に毎回余計なメッセージが表示されます。 -
JSIGN_OPTSなら、影響はjsignだけにとどまります。また、TLSの証明書検証そのものは無効にせず、プライベートCAを明示的に信頼させる形にしています。
4.1.2 署名の実行
正常系
Transit鍵で署名し、ローカルTSAのタイムスタンプを付けます。
# Transit鍵で署名し、ローカルTSAのタイムスタンプを付ける
$ jsign --storetype HASHICORPVAULT \
--keystore https://127.0.0.1:8220/v1/transit \
--storepass <REDACTED> \
--alias codesign:1 \
--certfile chain.pem \
--tsmode RFC3161 \
--tsaurl http://127.0.0.1:8318/ \
t2-signed.ps1
Adding Authenticode signature to t2-signed.ps1
# 署名前の hello.ps1 と、署名後の t2-signed.ps1 のサイズを比べる(t2-signed.ps1 の本文56バイトは hello.ps1 とバイト単位で一致)
$ ls -l hello.ps1 t2-signed.ps1
-rw-r--r-- 1 demo demo 56 Sep 15 22:09 hello.ps1
-rw-r--r-- 1 demo demo 9494 Sep 15 22:13 t2-signed.ps1
56バイトの.ps1が、署名ブロックの追加で9494バイトになりました。
異常系
(1) トラストストアを設定せずに実行した場合
4.1.1節のトラストストアを設定する前に実行すると、次のように失敗します。
# jsignで署名する(この時点ではJavaがVaultのTLS証明書を信頼していないため失敗する)
$ jsign --storetype HASHICORPVAULT \
--keystore https://127.0.0.1:8220/v1/transit \
--storepass <REDACTED> \
--alias codesign:1 \
--certfile chain.pem \
--tsmode RFC3161 \
--tsaurl http://127.0.0.1:8318/ \
t1-notruststore.ps1
jsign: Failed to retrieve the private key from the keystore
java.security.UnrecoverableKeyException: Unable to fetch HashiCorp Vault private key 'codesign:1'
Caused by: javax.net.ssl.SSLHandshakeException: (certificate_unknown) PKIX path building failed
エラーが「秘密鍵が取れない」という表層メッセージで出るため、Caused by:を最後まで読まないとTLSの問題だと気づけません。
(2) オプションの指定を誤った場合
| # | 誤り | 結果 |
|---|---|---|
| 1 |
--alias codesign(バージョンなし) |
UnrecoverableKeyException: ... (missing key version)で失敗する。--aliasは**鍵名:バージョンのコロン形式が必須** |
| 2 |
--tsmode RFC3161を省略 |
jsignは既定のAuthenticodeモード(Microsoft独自のタイムスタンプ要求)でTSAに要求する。ローカルTSA(RFC 3161実装)はこれを解釈できず、3回リトライして失敗する |
(2)の2番目のように、タイムスタンプの取得で失敗した場合でも、Vault側の署名操作自体は既に完了しています。監査ログ上は「署名回数 ≠ 成果物の数」になり得ます。
4.1.3 署名結果の確認(Linux)
jsignにはverifyサブコマンドが存在しません(sign / timestamp / extract / remove / tagのみ)。そこで、署名済みファイルを検証するスクリプトverify_ps1_sig.pyを作り、Linux上で確認しました。スクリプトは次の4つを順に行います。
-
署名ブロックの切り出し: 署名済みファイルの
# SIG # Begin signature block〜# SIG # End signature blockの各行から先頭の#を除いてつなぎ、base64からDER(署名データのバイナリ形式)に戻す - 本文の確認: 署名ブロックを除いた本文からダイジェストを計算し直し、署名に入っているダイジェストと比べる(本文が改ざんされていないか)。署名データの中で本文のダイジェストを入れている部分をSpcIndirectDataContentと呼びます
-
署名属性の確認: 2.3節で説明した「署名属性」は、署名データの中ではauthenticatedAttributesという名前で入っています。その中の
messageDigest(SpcIndirectDataContentのダイジェスト)が、実際のSpcIndirectDataContentから計算した値と一致するか -
署名値の検証: 署名データから署名属性と署名値(384バイト)を切り出し、署名に埋め込まれた証明書の公開鍵を使って
openssl dgst -sha256 -verify(RSA PKCS#1 v1.5)で検証する
verify_ps1_sig.py(検証に使ったスクリプト全文)
#!/usr/bin/env python3
"""署名済み PowerShell スクリプトの Authenticode 署名を Linux 側だけで独立検証する。
jsign 7.5 に verify サブコマンドが無いため、検証は自作する。
手順:
1. `# SIG # Begin/End signature block` から base64 を取り出し DER (PKCS#7 SignedData) にする
2. 署名ブロックを除いた本文を UTF-16LE に変換して SHA-256 → SpcIndirectDataContent 内の
digest と一致するか(= 本文が改変されていないか)
3. signerInfo の messageDigest 属性 == SHA-256(SpcIndirectDataContent の中身) か
4. authenticatedAttributes を SET に再タグして SHA-256 → 署名者証明書の公開鍵で
RSA PKCS#1 v1.5 検証(openssl 相当を pure python で行わず、外部 openssl に渡す)
"""
import base64, hashlib, subprocess, sys, tempfile, os
BEGIN = "# SIG # Begin signature block"
END = "# SIG # End signature block"
def der_read(b, p):
tag = b[p]; p += 1
l = b[p]; p += 1
if l & 0x80:
n = l & 0x7f; l = int.from_bytes(b[p:p+n], "big"); p += n
return tag, p, l
def der_children(b, p, end):
out = []
while p < end:
t, cs, cl = der_read(b, p)
out.append((t, cs, cl, p))
p = cs + cl
return out
def main(path):
ok = True
raw = open(path, "rb").read()
# jsign は署名ブロックを CRLF で追記する。本文は BEGIN 行直前の改行までで終わる
i = raw.find(BEGIN.encode())
if i < 0:
print("FAIL: signature block not found"); return 1
j = raw.find(END.encode())
body_bytes = raw[:i]
for sep in (b"\r\n", b"\n"):
if body_bytes.endswith(sep):
body_bytes = body_bytes[:-len(sep)]
break
body = body_bytes.decode("utf-8")
b64lines = [l.strip() for l in raw[i+len(BEGIN):j].replace(b"\r\n", b"\n").split(b"\n")]
b64 = b"".join(l[2:] for l in b64lines if l.startswith(b"# "))
der = base64.b64decode(b64)
print("signature block: %d base64 lines, DER %d bytes"
% (len([l for l in b64lines if l.startswith(b"# ")]), len(der)))
t, cs, cl = der_read(der, 0)
ci = der_children(der, cs, cs+cl)
t2, cs2, cl2 = der_read(der, ci[1][1])
sd = der_children(der, cs2, cs2+cl2)
enc = [x for x in sd if x[0] == 0x30][0]
ec = der_children(der, enc[1], enc[1]+enc[2])
a0 = [x for x in ec if x[0] == 0xa0][0]
_, ics, icl = der_read(der, a0[1])
spc_inner = der[ics:ics+icl] # SpcIndirectDataContent の中身
# SpcIndirectDataContent の messageDigest(末尾 32 バイトの OCTET STRING)
file_digest = spc_inner[-32:]
calc = hashlib.sha256(body.encode("utf-16-le")).digest()
print("content digest in signature : %s" % file_digest.hex())
print("recomputed (UTF-16LE of body): %s" % calc.hex())
if calc == file_digest:
print(" [OK] script body matches the signed digest")
else:
print(" [FAIL] script body has been modified"); ok = False
si_set = [x for x in sd if x[0] == 0x31][-1]
si = der_children(der, si_set[1], si_set[1]+si_set[2])[0]
sif = der_children(der, si[1], si[1]+si[2])
aa = [x for x in sif if x[0] == 0xa0][0]
aa_blob = der[aa[3]:aa[1]+aa[2]]
signed_attrs = b"\x31" + aa_blob[1:]
md_expect = hashlib.sha256(spc_inner).digest()
if md_expect.hex() in signed_attrs.hex():
print(" [OK] messageDigest attribute == SHA-256(SpcIndirectDataContent)")
else:
print(" [FAIL] messageDigest attribute mismatch"); ok = False
encdig = [x for x in sif if x[0] == 0x04][-1]
sigbytes = der[encdig[1]:encdig[1]+encdig[2]]
certs = subprocess.run(["openssl", "pkcs7", "-inform", "DER", "-print_certs"],
input=der, capture_output=True).stdout
with tempfile.TemporaryDirectory() as tmp:
cf = os.path.join(tmp, "certs.pem"); open(cf, "wb").write(certs)
# 署名者証明書 = EKU codeSigning を持つもの(このチェーンでは leaf 1 枚)
leaf = subprocess.run(["openssl", "x509", "-in", cf], capture_output=True).stdout
# -print_certs の 1 枚目は中間 CA の場合があるので両方試す
pub = os.path.join(tmp, "pub.pem"); sf = os.path.join(tmp, "sig.bin")
af = os.path.join(tmp, "aa.der")
open(sf, "wb").write(sigbytes); open(af, "wb").write(signed_attrs)
verified = False
blocks = certs.decode().split("-----END CERTIFICATE-----")
for blk in blocks:
if "BEGIN CERTIFICATE" not in blk: continue
pem = (blk[blk.index("-----BEGIN CERTIFICATE-----"):]
+ "-----END CERTIFICATE-----\n").encode()
p = subprocess.run(["openssl", "x509", "-noout", "-pubkey"],
input=pem, capture_output=True)
open(pub, "wb").write(p.stdout)
r = subprocess.run(["openssl", "dgst", "-sha256", "-verify", pub,
"-signature", sf, af], capture_output=True)
if r.returncode == 0:
subj = subprocess.run(["openssl", "x509", "-noout", "-subject"],
input=pem, capture_output=True).stdout.decode().strip()
print(" [OK] RSA PKCS#1 v1.5 signature verified with %s" % subj)
verified = True
break
if not verified:
print(" [FAIL] signature does not verify with any embedded certificate")
ok = False
print("RESULT: %s" % ("PASS" if ok else "FAIL"))
return 0 if ok else 1
if __name__ == "__main__":
sys.exit(main(sys.argv[1]))
正常系
# 署名済みファイルを検証スクリプトで検証する
$ python3 verify_ps1_sig.py t2-signed.ps1
signature block: 138 base64 lines, DER 6618 bytes
content digest in signature : 85d89eecb8347e5c1b6c3d53a48dda8b6a82a75809f4d8654befc33fb37ff2e9
recomputed (UTF-16LE of body): 85d89eecb8347e5c1b6c3d53a48dda8b6a82a75809f4d8654befc33fb37ff2e9
[OK] script body matches the signed digest
[OK] messageDigest attribute == SHA-256(SpcIndirectDataContent)
[OK] RSA PKCS#1 v1.5 signature verified with subject=C = JP, O = PoC, OU = Code Signing, CN = Code Signing Demo
RESULT: PASS
手順2〜4の3つの確認がすべて[OK]になりました(手順1は署名ブロックの切り出しだけで、判定はありません)。
さらに、スクリプトの手順4で切り出す2つのデータ(署名値encdigest.binと署名属性authattrs-set.der)をファイルに書き出し、opensslで直接検証しました。
# 3.2節で発行したリーフ証明書から公開鍵を取り出す
$ openssl x509 -in leaf.pem -noout -pubkey > leaf-pub.pem
# 署名属性に対する署名値を、RSA PKCS#1 v1.5 として検証する
$ openssl dgst -sha256 -verify leaf-pub.pem -signature encdigest.bin authattrs-set.der
Verified OK
# 同じ署名値をPSSとして検証すると失敗する(PKCS#1 v1.5で署名されていることの確認)
$ openssl dgst -sha256 -verify leaf-pub.pem -sigopt rsa_padding_mode:pss \
-sigopt rsa_pss_saltlen:-1 -signature encdigest.bin authattrs-set.der
Verification failure
encdigest.binとauthattrs-set.derを書き出したときのコマンドは、検証記録に残っていませんでした。ただしencdigest.binのSHA-256(b148c459…)は、t2-signed.ps1に入っているタイムスタンプが証明している値(タイムスタンプは署名値のSHA-256に対して発行されます)と一致しています。このため、t2-signed.ps1の署名値そのものであることは確認できています。
異常系
(1) 署名方式を変えて検証した場合: 上の最後のコマンドのとおり、同じ署名値をPSSとして検証するとVerification failureになります。署名がPKCS#1 v1.5で作られていることの確認です。
(2) 本文を1文字書き換えた場合: 署名済みファイルをコピーし、本文のHelloをHalloに1文字だけ書き換えたファイル(t7-tampered.ps1)を検証しました。
# 署名済みファイルをコピーする(このコピーの本文を Hello → Hallo に1文字だけ書き換える。ファイルサイズは9494バイトのまま)
$ cp t2-signed.ps1 t7-tampered.ps1
# 書き換え前後の1行目を比べ、1文字だけ違うことを確認する
$ diff <(head -1 t2-signed.ps1) <(head -1 t7-tampered.ps1)
1c1
< Write-Output "Hello World from Vault-signed PowerShell"
---
> Write-Output "Hallo World from Vault-signed PowerShell"
# 書き換えたファイルを検証スクリプトで検証する
$ python3 verify_ps1_sig.py t7-tampered.ps1
signature block: 138 base64 lines, DER 6618 bytes
content digest in signature : 85d89eecb8347e5c1b6c3d53a48dda8b6a82a75809f4d8654befc33fb37ff2e9
recomputed (UTF-16LE of body): ee785c9debfd6878d026477d4b2ac295ac74015cebb7f5f8bf0773ed51b016d1
[FAIL] script body has been modified
[OK] messageDigest attribute == SHA-256(SpcIndirectDataContent)
[OK] RSA PKCS#1 v1.5 signature verified with subject=C = JP, O = PoC, OU = Code Signing, CN = Code Signing Demo
RESULT: FAIL
本文から計算し直したダイジェストが署名の中の値と一致しなくなり、[FAIL] script body has been modifiedで改ざんを検出しました。署名属性と署名値は書き換えていないため、残り2つは[OK]のままです。「署名そのものは本物だが、署名した後に本文が変わった」と正しく切り分けられています。
Vaultの監査ログ
監査ログです。jsignによる1回の署名操作で、Vault APIは「鍵メタデータの取得(read)」「署名(update)」の計2回だけ呼ばれています。
| # | time | type | operation | path | user-agent |
|---|---|---|---|---|---|
| 1 | 2026-09-15T13:13:10.196915698Z | request | read | transit/keys/codesign | Jsign |
| 2 | 2026-09-15T13:13:10.197233278Z | response | read | transit/keys/codesign | Jsign |
| 3 | 2026-09-15T13:13:10.269874489Z | request | update | transit/sign/codesign | Jsign |
| 4 | 2026-09-15T13:13:10.272198598Z | response | update | transit/sign/codesign | Jsign |
代表1組(transit/sign/codesignのrequest / response)の全文です(識別子はHMAC値を短縮表示、署名値は先頭のみ表示)。
{
"auth": {
"accessor": "hmac-sha256:49f2d0cd…",
"client_token": "hmac-sha256:163a9457…",
"display_name": "root",
"policies": ["root"],
"token_policies": ["root"],
"token_issue_time": "2026-08-29T06:38:51+09:00",
"token_type": "service"
},
"request": {
"client_id": "0DHqvq2D…",
"client_token": "hmac-sha256:163a9457…",
"client_token_accessor": "hmac-sha256:49f2d0cd…",
"data": {
"hash_algorithm": "sha2-256",
"input": "b6R6xBJsQHM4qx4borUWsEulytfCsdAAzd6RhMGr34k=",
"key_version": "hmac-sha256:a8c28fab1ec66ddacab12ad5cf5e5f6c8afe2eeef99ba49abb5ed8d0c367703d",
"prehashed": true,
"signature_algorithm": "pkcs1v15"
},
"headers": { "user-agent": ["Jsign (https://ebourg.github.io/jsign/)"] },
"id": "5d5ee3d6…",
"mount_point": "transit/",
"mount_type": "transit",
"operation": "update",
"path": "transit/sign/codesign",
"remote_address": "127.0.0.1",
"remote_port": 50396
},
"time": "2026-09-15T13:13:10.269874489Z",
"type": "request"
}
{
"auth": { "...": "(requestと同一)" },
"request": { "...": "(requestと同一)" },
"response": {
"data": {
"key_version": 1,
"signature": "vault:v1:godCbh+xZr0AQc/…(省略)"
},
"mount_point": "transit/",
"mount_type": "transit"
},
"time": "2026-09-15T13:13:10.272198598Z",
"type": "response"
}
inputはbase64デコードすると厳密に32バイトで、署名済みファイルの署名ブロックから取り出した署名属性(authenticatedAttributes)のSHA-256ダイジェストと一致します(2.3節の解説参照)。署名対象の本文(スクリプト文字列)は監査ログのどこにも現れません。 本記事に貼っているJSONはすべて抽出用のスクリプトで実ログから取り出し、トークンなどをマスクしたもので、手で書き換えていません。
本検証では、署名リクエストにrootトークンを使っています(監査ログのdisplay_name: root・policies: ["root"])。そのため監査ログからは「rootトークンで署名した」ことしか分かりません。本番では、署名する人やパイプラインごとに、署名だけを許可したポリシーのトークンを発行し、監査ログで「誰が」署名したかを識別できるようにしてください(本記事ではRBAC設計は扱いません)。
4.1.4 Windowsでの有効性確認
4.1.3節では、署名の計算が正しいことを確かめました。ここでは、実際にスクリプトを実行するWindowsが、この署名を有効と判定するかを確かめます。Windowsは署名の計算に加えて、次の2点も確認します。
- 署名者の証明書から、信頼ストアにあるRoot CAまで証明書をたどれるか
- タイムスタンプが、信頼できるTSAによるものか
確認には、PowerShellのGet-AuthenticodeSignature(ファイルの署名状態を表示するコマンド)を使いました。Windows実機は、WSL2を動かしているホスト側のWindowsです。署名済みファイルは、WSLのファイルを\\wsl.localhost\Ubuntu-22.04\...のパスで直接参照しています(ファイルのコピーは不要)。
準備
本検証のCAはプライベートCAなので、Windowsは最初は信頼していません。コード署名用のRoot CA証明書と、ローカルTSAのRoot CA証明書を、現在のユーザーの信頼ストア(Cert:\CurrentUser\Root)に登録します。
# コード署名用のRoot CA証明書を、現在のユーザーの信頼ストアに登録する
> Import-Certificate -FilePath '\\wsl.localhost\Ubuntu-22.04\...\codesign-root.pem' -CertStoreLocation Cert:\CurrentUser\Root
# ローカルTSAのRoot CA証明書を、現在のユーザーの信頼ストアに登録する
> Import-Certificate -FilePath '\\wsl.localhost\Ubuntu-22.04\...\tsa-ca.crt' -CertStoreLocation Cert:\CurrentUser\Root
正常系
# 署名済みファイルを置いたWSL側フォルダのパスを変数に入れる
> $p = '\\wsl.localhost\Ubuntu-22.04\...\'
# 署名済みスクリプトの署名状態と、タイムスタンプ証明書を表示する
> Get-AuthenticodeSignature ($p + 't2-signed.ps1') | Format-List Status,StatusMessage,TimeStamperCertificate
Status : Valid
StatusMessage : 署名を確認しました。
TimeStamperCertificate : [Subject]
CN=Vault PoC Demo TSA Responder, O=Vault PoC Demo, C=JP
Status: Validになり、Windowsが署名を有効と判定しました。TimeStamperCertificateにローカルTSAの証明書が表示されているので、タイムスタンプも認識されています。
異常系
| # | 条件 | 結果 | 分かること |
|---|---|---|---|
| 1 | 本文を書き換えたファイル(4.1.3節のt7-tampered.ps1) |
Status: HashMismatch |
Windowsが本文の中身まで検証している |
| 2 | Root CAを登録していない状態 |
Status: UnknownError(「証明書チェーンを、信頼されたルート機関として構築できませんでした。」) |
署名が正しくても、Root CAを信頼していなければ有効にならない |
| 3 | 別のRoot CA(VaultのTLS用CA)を誤って登録した状態 |
Status: UnknownError(2と同じメッセージ) |
コード署名用のRoot CAでなければ通らない。メッセージだけでは2と区別できない |
# 本文を書き換えたファイルの署名状態を表示する
> Get-AuthenticodeSignature ($p + 't7-tampered.ps1') | Format-List Status,StatusMessage
Status : HashMismatch
StatusMessage : ファイル ...\t7-tampered.ps1 の内容は、承認されていないユーザーまたはプロセスによって変更された
可能性があります。ファイルのハッシュが、デジタル署名に保存されているハッシュと一致しません。
このスクリプトは指定されたシステムでは実行できません。
異常系2は、準備のRoot CA登録をする前に、正常系と同じコマンドを実行した結果です。
# Root CAを登録する前に、署名済みスクリプトの署名情報をすべて表示する
> Get-AuthenticodeSignature '\\wsl.localhost\Ubuntu-22.04\...\t2-signed.ps1' | Format-List *
(SignerCertificate・TimeStamperCertificate の表示は省略)
Status : UnknownError
StatusMessage : 証明書チェーンを、信頼されたルート機関として構築できませんでした。
SignatureType : Authenticode
IsOSBinary : False
SignatureType: Authenticodeなので、Windowsは署名ブロックそのものは読み取れています。信頼できるRoot CAまでチェーンをたどれないために、Validにならない状態です。
異常系3は、コード署名用のRoot CAではなく、VaultのTLS用Root CA(3.2節の注記)を登録してしまった状態です。
# 誤って、VaultのTLS用Root CA証明書を信頼ストアに登録する
> Import-Certificate -FilePath '\\wsl.localhost\Ubuntu-22.04\...\root-ca.pem' -CertStoreLocation Cert:\CurrentUser\Root
(出力は省略)
# 署名済みスクリプトの署名状態と、タイムスタンプ証明書を表示する
> Get-AuthenticodeSignature ($p + 't2-signed.ps1') | Format-List Status,StatusMessage,TimeStamperCertificate
Status : UnknownError
StatusMessage : 証明書チェーンを、信頼されたルート機関として構築できませんでした。
TimeStamperCertificate : [Subject] CN=Vault PoC Demo TSA Responder, O=Vault PoC Demo, C=JP
[Serial Number] 1AD164889A0135930BB94EB2933EE1ED6F8FBEFF
このとき、ローカルTSAのRoot CA(tsa-ca.crt)は正しく登録していました。登録した証明書のSubjectがCN=PoC Root CAで、署名のチェーンのRoot(CN=PoC Code Signing Root CA)と違います。メッセージは異常系2と同じなので、どちらの状態かはメッセージでは区別できません。登録した証明書のSubjectやThumbprintで確かめる必要があります。
正常系のValidと異常系1のHashMismatchを対で確認しました。これにより、「何か署名が付いている」だけでなく、「Windowsが中身まで検証した上でValidと判定した」と言えます。
Case Aではsigntool verifyは実施していません(Get-AuthenticodeSignatureのみ)。また.exe/PEファイルは一切作成・署名していません。.ps1とPEはダイジェストの算出方法が異なるため、本結果をPEに外挿することはできません。
4.2 Case B — SoftHSM使用(Managed Key)
4.2.1 準備
jsign・Java・トラストストアは、Case Aと同じもの(4.1.1節)を使います。
Case B・Cの鍵は、Case Aとは別の鍵(SoftHSMの中で生成した鍵)です。そのため、3.2節と同様の手順(有効期間や鍵長など一部の値は異なります)でこの鍵の証明書を発行し、3枚のチェーン(chain.pem。4.1節のCase A用のchain.pemとは別のファイル)をTransit鍵に紐付けてあります。--certfileに渡すcertfile.pemは、このチェーンの後ろにコード署名用のRoot CA証明書(root-ca.pem)をつなげたファイルです。
ここでのroot-ca.pemは、Case B・C用に作り直したコード署名用のRoot CA(CN=PoC Code Signing Root CA)の証明書です。3.2節・4.1.1節に出てくる<vault-poc>/pki/root-ca.pem(VaultのTLS用Root CA、CN=PoC Root CA)とはファイル名が同じですが、別の証明書です。4.3.4節でWindowsに登録するroot-ca.crtも、コード署名用のRoot CAです。
# Transit鍵に紐付けたチェーン(3枚)の後ろにコード署名用のRoot CA証明書をつなげ、--certfile 用のファイルを作る
$ cat chain.pem root-ca.pem > certfile.pem
certfile.pemを作ったときのコマンドは、検証記録に残っていませんでした。上のコマンドは、検証で使ったcertfile.pemが「chain.pem+root-ca.pem」とバイト単位で一致することを確認したうえで載せています。このためRoot CA証明書が重複した4枚の構成になっていますが、Case Cでも同じファイル(ハッシュが一致)を使い、WindowsでValidと検証されています。重複の無いchain.pemだけを渡しても署名できると考えられますが、本検証では試していません。
4.2.2 署名の実行(異常系)
3章で用意したSoftHSM裏付けのManaged Key(Transit鍵type=managed_key)に対して、Case Aと同じコマンドでjsignを実行します。
# Case Aと同じコマンドで、Managed Key(SoftHSM裏付け)のTransit鍵に対してjsignを実行する
$ jsign --storetype HASHICORPVAULT \
--keystore https://127.0.0.1:8230/v1/transit \
--storepass <REDACTED> \
--alias codesign:1 \
--certfile certfile.pem \
--tsmode RFC3161 \
--tsaurl http://127.0.0.1:8318/ \
signed.ps1
jsign: Failed to retrieve the private key from the keystore
java.lang.StringIndexOutOfBoundsException: begin 0, end -1, length 11
at java.base/java.lang.String.checkBoundsBeginEnd(String.java:4606)
at java.base/java.lang.String.substring(String.java:2709)
at net.jsign.jca.HashiCorpVaultSigningService.getPrivateKey(HashiCorpVaultSigningService.java:129)
...
クラッシュしました。ファイルサイズを見ると署名されていないことが分かります(変化なし)。
jsign 7.5のソース(HashiCorpVaultSigningService.java)を見ると、原因は鍵タイプ文字列の決め打ちパースでした。
} else if (data.containsKey("type")) {
engine = VaultEngine.TRANSIT;
// Transit key type format : 'rsa-<BITS>'
algorithm = data.get("type");
algorithm = algorithm.substring(0, algorithm.indexOf("-")).toUpperCase();
}
Transit鍵のtypeは通常rsa-3072のように-を含む文字列を想定していますが、type=managed_keyには-がありません。indexOf("-")が-1を返し、substring(0, -1)で例外になります。例外メッセージのlength 11は"managed_key".length()そのものです。
この問題は執筆時点(2026年10月)でも上流で未修正です。jsignの最新リリースは7.5(2026-07-16公開)で、GitHubのmasterブランチの最新コミット(2026-09-03時点)でも該当箇所は変わっていません。該当ファイル自体の最後の機能変更は2024年6月で、managed_keyという文字列への対応は入っていません。GitHub Issuesをmanaged_keyで検索しても、関連するIssueは1件も見つかりませんでした(2026-10-05時点の確認。jsign Releases、Issues検索)。
jsignはtype=managed_keyを無条件に「対応している署名インターフェース」として扱えません。「Vault TransitでHSM鍵署名は可能。ただしjsign 7.5のHASHICORPVAULT storetypeはmanaged_key非対応」というのが、現時点での正確な状態です。
4.2.3 署名結果の確認(監査ログ)
監査ログです。jsignは鍵メタデータの取得(read)には成功しますが、クラッシュがgetPrivateKeyの中(=transit/signを呼ぶ前)で起きるため、transit/sign/codesignには一度も到達しません。
| # | time | type | operation | path | user-agent |
|---|---|---|---|---|---|
| 1 | 2026-09-16T08:44:27.375367251Z | request | read | transit/keys/codesign | Jsign |
| 2 | 2026-09-16T08:44:27.375676355Z | response | read | transit/keys/codesign | Jsign |
readの代表1組の全文です。
{
"auth": {
"accessor": "hmac-sha256:49f2d0cd…",
"client_token": "hmac-sha256:163a9457…",
"display_name": "root",
"policies": ["root"]
},
"request": {
"client_id": "0DHqvq2D…",
"client_token": "hmac-sha256:163a9457…",
"client_token_accessor": "hmac-sha256:49f2d0cd…",
"headers": { "user-agent": ["Jsign (https://ebourg.github.io/jsign/)"] },
"id": "d46e9d75…",
"mount_point": "transit/",
"mount_type": "transit",
"operation": "read",
"path": "transit/keys/codesign",
"remote_address": "127.0.0.1",
"remote_port": 38938
},
"time": "2026-09-16T08:44:27.375367251Z",
"type": "request"
}
{
"auth": { "...": "(requestと同一)" },
"request": { "...": "(requestと同一)" },
"response": {
"data": {
"allow_plaintext_backup": false,
"deletion_allowed": false,
"derived": false,
"exportable": false,
"imported_key": false,
"keys": {
"1": {
"certificate_chain": "hmac-sha256:7efc991bc1e77834c6ca3d9c98c288062a9f7601597ab3e50c7116e4d23cbcb6",
"creation_time": "2026-09-16T17:30:06.642265014+09:00",
"hybrid_public_key": "hmac-sha256:4c76492fc8c82e5e17474f6f2ba93338e7f474e6e479483e3a44cdc7eedfd918",
"name": "hmac-sha256:4c76492fc8c82e5e17474f6f2ba93338e7f474e6e479483e3a44cdc7eedfd918",
"public_key": "hmac-sha256:8d60543e99f727421cb2fa0523dfb331c4e1a85a2e319c31de022045eac4bccf"
}
},
"latest_version": 1,
"supports_decryption": true,
"supports_derivation": false,
"supports_encryption": true,
"supports_signing": true,
"name": "hmac-sha256:6b864bc89b4662bdc9498d5412ded4c074b2d6949234527afd81954f3754e946",
"type": "hmac-sha256:1280c9595ca613af9bc33ff2ea7ccb33c2690ef626505f6afb4c9a7885edb8b2"
},
"mount_point": "transit/",
"mount_type": "transit"
},
"time": "2026-09-16T08:44:27.375676355Z",
"type": "response"
}
ここで気づいたのが、レスポンスのtypeフィールドがHMAC化されたままになっている点です。3.1節で-audit-non-hmac-response-keysにsignature・key_version・errorは指定しましたが、transit/keysのレスポンスに含まれるtype・name・public_keyは指定していないため、既定のHMAC化が適用されています。sys/audit-hash/fileに平文を渡すとHMAC値を再計算できるので、"managed_key"のHMACがログ中の値と一致することは別途確認済みです(この照合によって「レスポンスにtype=managed_keyが記録されている」という事実そのものは検証できます)。
このケースの監査ログだけからは、「jsignが鍵情報を読みに来たが、その後の署名リクエストが続いていない」という事実しか分かりません。鍵のtypeは読み取れても内容はHMAC化されているため、「managed_keyだから失敗した」という原因の特定には、監査ログだけでなくjsignのスタックトレースと上流ソースの確認が必要でした。
なお、Managed Key自体の署名能力そのものは別途確認できています。 signature_algorithmを明示的にpkcs1v15に指定してvault write transit/sign/codesignを直接呼ぶと、PKCS#11バックエンド経由でSoftHSM内の秘密鍵が正しく署名を返します。問題はjsign側のtype文字列パース(5.2節)だけで、Vault側の配線自体は健全です。Vault経由の署名とSoftHSMへの直接署名がバイト単位で一致することは3.3節で、Managed Keyへの署名リクエストの挙動は5.3節で扱います。
4.3 Case C — SoftHSM使用+自作PKCS#11ラッパー
4.3.1 準備
Case Cでは、jsignとVaultの間に挟む自作のPKCS#11ラッパーを準備します。
回避策が必要な理由
Case Bの失敗は、jsignのHASHICORPVAULTモードの中で起きています。このモードは、Vaultから取得した鍵のtype(例: rsa-3072)を-で区切って鍵の種類を判定します。managed_keyには-が無いため、この処理で例外になります(4.2.2節)。原因はjsignのコードの中にあるので、Vault側の設定を変えても回避できません。jsignを改造する方法もありますが、改造版を自分で保守し続けることになるため、jsignは無改造のまま使う方針にしました。
回避策の方針
jsignのHASHICORPVAULTモードを使わず、jsignが標準で持つ別のモード--storetype PKCS11を使います。
-
PKCS11モードは、HSMを使うための汎用の方式です。jsignは、Java標準のSunPKCS11(JavaからPKCS#11ライブラリを呼ぶための仕組み)を経由して、指定されたPKCS#11ライブラリ(.soファイル)を呼び出します。 - このモードでは、jsignはVaultのAPIを直接呼びません。そのため、
typeを解析する処理(HashiCorpVaultSigningService)は一度も実行されません。 - そこで、jsignからはHSMのPKCS#11ライブラリに見え、中ではVault TransitのAPIを呼ぶライブラリ(
vault_pkcs11.so)を自作しました。
typeの代わりに何を返しているか
ラッパーは、Vaultが返すtypeを使いません。managed_keyを別の文字列に置き換えて返しているわけでもありません。PKCS#11では、jsign(SunPKCS11)は鍵の情報を「属性」として1つずつ問い合わせます。ラッパーは、Vaultに登録された実際の証明書から、その答えを作ります。
| # | jsign(SunPKCS11)が知りたいこと | PKCS#11での問い合わせ | ラッパーの返し方 |
|---|---|---|---|
| 1 | 証明書 |
C_FindObjects・C_GetAttributeValue
|
transit/keys/codesignから取得した証明書チェーンの先頭(リーフ証明書) |
| 2 | 鍵の種類 | 属性CKA_KEY_TYPE
|
CKK_RSA(RSA)。本ラッパーはRSA鍵専用なので固定 |
| 3 | 公開鍵・鍵の長さ | 属性CKA_MODULUS・CKA_PUBLIC_EXPONENT・CKA_MODULUS_BITS
|
リーフ証明書の公開鍵から取り出した値(今回は3072ビット) |
| 4 | 署名 |
C_SignInit→C_Sign(またはC_SignUpdate→C_SignFinal) |
下記の流れでVaultに署名させた結果 |
鍵の長さなどは証明書から取り出すので、鍵を作り直して証明書が変わっても、ラッパー側の修正は不要です。
署名は次の流れで行います。秘密鍵を使う計算はVault(その先のSoftHSM)が行い、ラッパーの中では行いません。
- jsignが、署名したいデータ(署名属性)を
C_Sign(またはC_SignUpdate)で渡す - ラッパーが、そのデータのSHA-256ダイジェスト(32バイト)をOpenSSLで計算する
- ラッパーが、
PUT transit/sign/codesignを呼ぶ(prehashed=true・hash_algorithm=sha2-256・signature_algorithm=pkcs1v15)。VaultのAPIは、書き込み系の操作をPOSTとPUTのどちらでも受け付けます(監査ログ上はどちらもoperation: update)。ソース中のコメントにはPOSTと書かれている箇所がありますが、実際に送っているのはPUTです - Vaultが返した署名(384バイト)を、
C_Sign(またはC_SignFinal)の結果としてjsignに返す
ソースとビルド
ソース全文です。PKCS#11モジュール本体(582行)とVault Transit RESTクライアント(255行)の計約840行のCで、依存はlibcurl・json-c・libsslです。
src/vault_pkcs11.c(PKCS#11モジュール本体、582行)
/* vault_pkcs11: a minimal PKCS#11 v2.40 module exposing exactly one RSA
* private key + one X.509 certificate, backed by a Vault Transit managed
* key. Signing never touches a real HSM locally -- every signature
* is produced by a single POST transit/sign/<key> call. Hashing (required
* because jsign/SunPKCS11 drives this module via CKM_SHA256_RSA_PKCS, which
* hashes on the token side) is done locally with OpenSSL; the RSA private
* key operation itself is always delegated to Vault.
*
* Scope is intentionally narrow: one slot, one token, one session at a time,
* one key pair. Everything outside that is a safe ABI stub.
*/
#include "../include/pkcs11.h"
#include "vault_transit_client.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <openssl/evp.h>
#include <openssl/x509.h>
#define OBJ_PRIVKEY 1
#define OBJ_CERT 2
#define FIXED_ID_BYTE 0x01
#define MAX_SESSIONS 8
static int g_initialized = 0;
static int g_logged_in = 0;
/* SunPKCS11 keeps separate pooled sessions for object enumeration and for
* crypto operations and opens more than one concurrently; advertising (and
* actually supporting) only a single session causes it to fail client-side
* with "Could not obtain session" without ever calling C_OpenSession again. */
static int g_session_open[MAX_SESSIONS + 1]; /* index 1..MAX_SESSIONS; 0 unused */
static int g_session_count = 0;
static int session_valid(CK_SESSION_HANDLE h) {
return h >= 1 && h <= MAX_SESSIONS && g_session_open[h];
}
static vtc_ctx g_vtc;
static unsigned char *g_cert_der = NULL; static size_t g_cert_der_len = 0;
static unsigned char *g_modulus = NULL; static size_t g_modulus_len = 0;
static unsigned char *g_pub_exp = NULL; static size_t g_pub_exp_len = 0;
/* DER-encoded subject Name of g_cert_der, used so C_FindObjectsInit can honor
* a CKA_SUBJECT filter. Without this, SunPKCS11's certificate-chain walk
* (matching a cert's issuer DN against another cert's subject DN) always
* "finds" our one-and-only certificate again regardless of which DN it
* asked for, and loops forever trying to walk past the chain's true root. */
static unsigned char *g_cert_subject_der = NULL; static size_t g_cert_subject_der_len = 0;
/* Active sign operation state (single active op, matches jsign's usage pattern). */
static int g_sign_active = 0;
static CK_MECHANISM_TYPE g_sign_mech = 0;
static EVP_MD_CTX *g_sign_mdctx = NULL;
static const CK_BYTE FIXED_ID[1] = { FIXED_ID_BYTE };
static const CK_UTF8CHAR FIXED_LABEL[] = "vault-transit-codesign";
static char *getenv_dup(const char *name) {
const char *v = getenv(name);
return v ? strdup(v) : NULL;
}
/* ---- C_Initialize / C_Finalize ------------------------------------------------ */
CK_RV C_Initialize(CK_VOID_PTR pInitArgs) {
(void)pInitArgs;
if (g_initialized) return CKR_CRYPTOKI_ALREADY_INITIALIZED;
g_vtc.vault_addr = getenv_dup("VAULT_ADDR");
g_vtc.vault_token = getenv_dup("VAULT_TOKEN");
g_vtc.key_name = getenv_dup("VAULT_TRANSIT_KEY");
g_vtc.ca_cert_path = getenv_dup("VAULT_CACERT");
if (!g_vtc.vault_addr || !g_vtc.vault_token || !g_vtc.key_name) {
fprintf(stderr, "vault_pkcs11: VAULT_ADDR, VAULT_TOKEN and VAULT_TRANSIT_KEY must be set\n");
return CKR_GENERAL_ERROR;
}
if (vtc_get_key_info(&g_vtc, &g_cert_der, &g_cert_der_len,
&g_modulus, &g_modulus_len, &g_pub_exp, &g_pub_exp_len) != 0) {
fprintf(stderr, "vault_pkcs11: C_Initialize: %s\n", vtc_last_error());
return CKR_GENERAL_ERROR;
}
{
const unsigned char *p = g_cert_der;
X509 *x = d2i_X509(NULL, &p, (long)g_cert_der_len);
if (x) {
unsigned char *buf = NULL;
int len = i2d_X509_NAME(X509_get_subject_name(x), &buf);
if (len > 0) {
g_cert_subject_der = malloc((size_t)len);
memcpy(g_cert_subject_der, buf, (size_t)len);
g_cert_subject_der_len = (size_t)len;
}
OPENSSL_free(buf);
X509_free(x);
}
}
g_initialized = 1;
return CKR_OK;
}
CK_RV C_Finalize(CK_VOID_PTR pReserved) {
(void)pReserved;
if (!g_initialized) return CKR_CRYPTOKI_NOT_INITIALIZED;
free(g_cert_der); g_cert_der = NULL;
free(g_modulus); g_modulus = NULL;
free(g_pub_exp); g_pub_exp = NULL;
free(g_cert_subject_der); g_cert_subject_der = NULL; g_cert_subject_der_len = 0;
free(g_vtc.vault_addr); free(g_vtc.vault_token);
free(g_vtc.key_name); free(g_vtc.ca_cert_path);
memset(&g_vtc, 0, sizeof(g_vtc));
if (g_sign_mdctx) { EVP_MD_CTX_free(g_sign_mdctx); g_sign_mdctx = NULL; }
g_initialized = 0;
g_logged_in = 0;
memset(g_session_open, 0, sizeof(g_session_open));
g_session_count = 0;
return CKR_OK;
}
CK_RV C_GetInfo(CK_INFO_PTR pInfo) {
if (!pInfo) return CKR_ARGUMENTS_BAD;
memset(pInfo, 0, sizeof(*pInfo));
pInfo->cryptokiVersion.major = 2;
pInfo->cryptokiVersion.minor = 40;
memcpy(pInfo->manufacturerID, "vault-pkcs11 PoC ", 32);
memcpy(pInfo->libraryDescription, "Vault Transit PKCS11 wrapper ", 32);
pInfo->libraryVersion.major = 0;
pInfo->libraryVersion.minor = 1;
return CKR_OK;
}
/* ---- Slot / token info --------------------------------------------------------- */
CK_RV C_GetSlotList(CK_BBOOL tokenPresent, CK_SLOT_ID_PTR pSlotList, CK_ULONG_PTR pulCount) {
(void)tokenPresent;
if (!pulCount) return CKR_ARGUMENTS_BAD;
if (pSlotList) {
if (*pulCount < 1) return CKR_BUFFER_TOO_SMALL;
pSlotList[0] = 0;
}
*pulCount = 1;
return CKR_OK;
}
CK_RV C_GetSlotInfo(CK_SLOT_ID slotID, CK_SLOT_INFO_PTR pInfo) {
if (slotID != 0 || !pInfo) return CKR_ARGUMENTS_BAD;
memset(pInfo, 0, sizeof(*pInfo));
memcpy(pInfo->slotDescription, "Vault Transit virtual slot ", 64);
memcpy(pInfo->manufacturerID, "vault-pkcs11 PoC ", 32);
pInfo->flags = CKF_TOKEN_PRESENT;
return CKR_OK;
}
CK_RV C_GetTokenInfo(CK_SLOT_ID slotID, CK_TOKEN_INFO_PTR pInfo) {
if (slotID != 0 || !pInfo) return CKR_ARGUMENTS_BAD;
memset(pInfo, 0, sizeof(*pInfo));
memcpy(pInfo->label, "vault-transit-token ", 32);
memcpy(pInfo->manufacturerID, "vault-pkcs11 PoC ", 32);
memcpy(pInfo->model, "vault-transit ", 16);
memcpy(pInfo->serialNumber, "0000000000000001", 16);
pInfo->flags = CKF_TOKEN_INITIALIZED;
pInfo->ulMaxSessionCount = MAX_SESSIONS;
pInfo->ulSessionCount = (CK_ULONG)g_session_count;
pInfo->ulMaxRwSessionCount = MAX_SESSIONS;
pInfo->ulRwSessionCount = (CK_ULONG)g_session_count;
pInfo->ulMaxPinLen = 255;
pInfo->ulMinPinLen = 0;
return CKR_OK;
}
CK_RV C_GetMechanismList(CK_SLOT_ID slotID, CK_MECHANISM_TYPE_PTR pMechanismList, CK_ULONG_PTR pulCount) {
if (slotID != 0 || !pulCount) return CKR_ARGUMENTS_BAD;
if (pMechanismList) {
if (*pulCount < 2) return CKR_BUFFER_TOO_SMALL;
pMechanismList[0] = CKM_RSA_PKCS;
pMechanismList[1] = CKM_SHA256_RSA_PKCS;
}
*pulCount = 2;
return CKR_OK;
}
CK_RV C_GetMechanismInfo(CK_SLOT_ID slotID, CK_MECHANISM_TYPE type, CK_MECHANISM_INFO_PTR pInfo) {
if (slotID != 0 || !pInfo) return CKR_ARGUMENTS_BAD;
if (type != CKM_RSA_PKCS && type != CKM_SHA256_RSA_PKCS) return CKR_MECHANISM_INVALID;
pInfo->ulMinKeySize = 3072;
pInfo->ulMaxKeySize = 3072;
pInfo->flags = CKF_SIGN;
return CKR_OK;
}
/* ---- Sessions / login ----------------------------------------------------------- */
CK_RV C_OpenSession(CK_SLOT_ID slotID, CK_FLAGS flags, CK_VOID_PTR pApplication,
CK_NOTIFY Notify, CK_SESSION_HANDLE_PTR phSession) {
(void)pApplication; (void)Notify;
if (slotID != 0 || !phSession) return CKR_ARGUMENTS_BAD;
if (!(flags & CKF_SERIAL_SESSION)) return CKR_ARGUMENTS_BAD;
for (int i = 1; i <= MAX_SESSIONS; i++) {
if (!g_session_open[i]) {
g_session_open[i] = 1;
g_session_count++;
*phSession = (CK_SESSION_HANDLE)i;
return CKR_OK;
}
}
return CKR_DEVICE_ERROR; /* session pool exhausted */
}
CK_RV C_CloseSession(CK_SESSION_HANDLE hSession) {
if (!session_valid(hSession)) return CKR_SESSION_HANDLE_INVALID;
g_session_open[hSession] = 0;
g_session_count--;
g_logged_in = 0;
return CKR_OK;
}
CK_RV C_CloseAllSessions(CK_SLOT_ID slotID) {
(void)slotID;
memset(g_session_open, 0, sizeof(g_session_open));
g_session_count = 0;
g_logged_in = 0;
return CKR_OK;
}
CK_RV C_GetSessionInfo(CK_SESSION_HANDLE hSession, CK_SESSION_INFO_PTR pInfo) {
if (!session_valid(hSession) || !pInfo) return CKR_SESSION_HANDLE_INVALID;
pInfo->slotID = 0;
pInfo->state = g_logged_in ? CKS_RO_USER_FUNCTIONS : CKS_RO_PUBLIC_SESSION;
pInfo->flags = CKF_SERIAL_SESSION;
pInfo->ulDeviceError = 0;
return CKR_OK;
}
CK_RV C_Login(CK_SESSION_HANDLE hSession, CK_USER_TYPE userType, CK_UTF8CHAR_PTR pPin, CK_ULONG ulPinLen) {
(void)pPin; (void)ulPinLen;
if (!session_valid(hSession)) return CKR_SESSION_HANDLE_INVALID;
if (userType != CKU_USER) return CKR_USER_TYPE_INVALID;
/* The real credential is VAULT_TOKEN (checked at C_Initialize); the PIN
* presented by SunPKCS11 is accepted but not otherwise used. */
g_logged_in = 1;
return CKR_OK;
}
CK_RV C_Logout(CK_SESSION_HANDLE hSession) {
if (!session_valid(hSession)) return CKR_SESSION_HANDLE_INVALID;
g_logged_in = 0;
return CKR_OK;
}
/* ---- Objects / attributes -------------------------------------------------------- */
typedef struct { CK_ATTRIBUTE_TYPE type; const void *value; size_t len; } static_attr;
static CK_RV find_object_attrs(CK_OBJECT_HANDLE hObject, const static_attr **attrs, size_t *nattrs);
static CK_BBOOL TRUE_VAL = CK_TRUE;
static CK_BBOOL FALSE_VAL = CK_FALSE;
static CK_OBJECT_CLASS CLASS_PRIVKEY = CKO_PRIVATE_KEY;
static CK_OBJECT_CLASS CLASS_CERT = CKO_CERTIFICATE;
static CK_KEY_TYPE KEYTYPE_RSA = CKK_RSA;
static CK_CERTIFICATE_TYPE CERTTYPE_X509 = CKC_X_509;
static CK_RV find_object_attrs(CK_OBJECT_HANDLE hObject, const static_attr **attrs, size_t *nattrs) {
static static_attr privkey_attrs[12];
static static_attr cert_attrs[5];
static int built = 0;
if (!built) {
privkey_attrs[0] = (static_attr){ CKA_CLASS, &CLASS_PRIVKEY, sizeof(CLASS_PRIVKEY) };
privkey_attrs[1] = (static_attr){ CKA_TOKEN, &TRUE_VAL, sizeof(TRUE_VAL) };
privkey_attrs[2] = (static_attr){ CKA_PRIVATE, &TRUE_VAL, sizeof(TRUE_VAL) };
privkey_attrs[3] = (static_attr){ CKA_LABEL, FIXED_LABEL, sizeof(FIXED_LABEL) - 1 };
privkey_attrs[4] = (static_attr){ CKA_ID, FIXED_ID, sizeof(FIXED_ID) };
privkey_attrs[5] = (static_attr){ CKA_KEY_TYPE, &KEYTYPE_RSA, sizeof(KEYTYPE_RSA) };
privkey_attrs[6] = (static_attr){ CKA_SIGN, &TRUE_VAL, sizeof(TRUE_VAL) };
privkey_attrs[7] = (static_attr){ CKA_MODULUS_BITS, NULL, 0 }; /* filled lazily below */
/* SunPKCS11's P11Key.getAttributes() batches these when loading a
* private key (net.jsign -> KeyStore.getKey() -> P11KeyStore.loadPkey);
* without them it aborts the whole batch with CKR_ATTRIBUTE_TYPE_INVALID. */
privkey_attrs[8] = (static_attr){ CKA_SENSITIVE, &TRUE_VAL, sizeof(TRUE_VAL) };
privkey_attrs[9] = (static_attr){ CKA_EXTRACTABLE, &FALSE_VAL, sizeof(FALSE_VAL) };
privkey_attrs[10] = (static_attr){ CKA_ALWAYS_SENSITIVE, &TRUE_VAL, sizeof(TRUE_VAL) };
privkey_attrs[11] = (static_attr){ CKA_NEVER_EXTRACTABLE, &TRUE_VAL, sizeof(TRUE_VAL) };
cert_attrs[0] = (static_attr){ CKA_CLASS, &CLASS_CERT, sizeof(CLASS_CERT) };
cert_attrs[1] = (static_attr){ CKA_TOKEN, &TRUE_VAL, sizeof(TRUE_VAL) };
cert_attrs[2] = (static_attr){ CKA_LABEL, FIXED_LABEL, sizeof(FIXED_LABEL) - 1 };
cert_attrs[3] = (static_attr){ CKA_ID, FIXED_ID, sizeof(FIXED_ID) };
cert_attrs[4] = (static_attr){ CKA_CERTIFICATE_TYPE, &CERTTYPE_X509, sizeof(CERTTYPE_X509) };
built = 1;
}
if (hObject == OBJ_PRIVKEY) { *attrs = privkey_attrs; *nattrs = 12; return CKR_OK; }
if (hObject == OBJ_CERT) { *attrs = cert_attrs; *nattrs = 5; return CKR_OK; }
return CKR_OBJECT_HANDLE_INVALID;
}
/* Find-objects state: template filtering is limited to CKA_CLASS, which is
* all SunPKCS11 actually filters on when enumerating keys vs. certificates. */
static CK_OBJECT_HANDLE g_find_results[2];
static CK_ULONG g_find_count = 0;
static CK_ULONG g_find_pos = 0;
CK_RV C_FindObjectsInit(CK_SESSION_HANDLE hSession, CK_ATTRIBUTE_PTR pTemplate, CK_ULONG ulCount) {
if (!session_valid(hSession)) return CKR_SESSION_HANDLE_INVALID;
CK_OBJECT_CLASS wanted_class = (CK_OBJECT_CLASS)-1;
CK_ATTRIBUTE_PTR subj_attr = NULL;
for (CK_ULONG i = 0; i < ulCount; i++) {
if (pTemplate[i].type == CKA_CLASS && pTemplate[i].pValue) {
wanted_class = *(CK_OBJECT_CLASS *)pTemplate[i].pValue;
} else if (pTemplate[i].type == CKA_SUBJECT && pTemplate[i].pValue) {
subj_attr = &pTemplate[i];
}
}
/* Our lone certificate only "is" a match when no CKA_SUBJECT filter is
* given, or it's given and equals this cert's actual subject DN -- this
* is what stops chain-walking lookups for a different (e.g. issuer) DN
* from matching our cert and looping forever. */
int cert_matches = 1;
if (subj_attr) {
cert_matches = g_cert_subject_der &&
subj_attr->ulValueLen == g_cert_subject_der_len &&
memcmp(subj_attr->pValue, g_cert_subject_der, g_cert_subject_der_len) == 0;
}
g_find_count = 0;
if (wanted_class == (CK_OBJECT_CLASS)-1) {
g_find_results[g_find_count++] = OBJ_PRIVKEY;
if (cert_matches) g_find_results[g_find_count++] = OBJ_CERT;
} else if (wanted_class == CKO_PRIVATE_KEY) {
g_find_results[g_find_count++] = OBJ_PRIVKEY;
} else if (wanted_class == CKO_CERTIFICATE) {
if (cert_matches) g_find_results[g_find_count++] = OBJ_CERT;
}
g_find_pos = 0;
return CKR_OK;
}
CK_RV C_FindObjects(CK_SESSION_HANDLE hSession, CK_OBJECT_HANDLE_PTR phObject,
CK_ULONG ulMaxObjectCount, CK_ULONG_PTR pulObjectCount) {
if (!session_valid(hSession)) return CKR_SESSION_HANDLE_INVALID;
CK_ULONG n = 0;
while (n < ulMaxObjectCount && g_find_pos < g_find_count) {
phObject[n++] = g_find_results[g_find_pos++];
}
*pulObjectCount = n;
return CKR_OK;
}
CK_RV C_FindObjectsFinal(CK_SESSION_HANDLE hSession) {
if (!session_valid(hSession)) return CKR_SESSION_HANDLE_INVALID;
g_find_count = 0;
g_find_pos = 0;
return CKR_OK;
}
CK_RV C_GetAttributeValue(CK_SESSION_HANDLE hSession, CK_OBJECT_HANDLE hObject,
CK_ATTRIBUTE_PTR pTemplate, CK_ULONG ulCount) {
if (!session_valid(hSession)) return CKR_SESSION_HANDLE_INVALID;
const static_attr *attrs;
size_t nattrs;
CK_RV rv = find_object_attrs(hObject, &attrs, &nattrs);
if (rv != CKR_OK) return rv;
CK_RV overall = CKR_OK;
for (CK_ULONG i = 0; i < ulCount; i++) {
CK_ATTRIBUTE *want = &pTemplate[i];
const void *value = NULL;
size_t len = 0;
int found = 0;
if (hObject == OBJ_CERT && want->type == CKA_VALUE) {
value = g_cert_der; len = g_cert_der_len; found = 1;
} else if (hObject == OBJ_PRIVKEY && want->type == CKA_MODULUS) {
value = g_modulus; len = g_modulus_len; found = 1;
} else if (hObject == OBJ_PRIVKEY && want->type == CKA_PUBLIC_EXPONENT) {
value = g_pub_exp; len = g_pub_exp_len; found = 1;
} else if (hObject == OBJ_PRIVKEY && want->type == CKA_MODULUS_BITS) {
static CK_ULONG bits;
bits = (CK_ULONG)(g_modulus_len * 8);
value = &bits; len = sizeof(bits); found = 1;
} else {
for (size_t j = 0; j < nattrs; j++) {
if (attrs[j].type == want->type && attrs[j].value) {
value = attrs[j].value; len = attrs[j].len; found = 1;
break;
}
}
}
if (!found) {
want->ulValueLen = CK_UNAVAILABLE_INFORMATION;
overall = CKR_ATTRIBUTE_TYPE_INVALID;
continue;
}
if (!want->pValue) {
want->ulValueLen = (CK_ULONG)len;
} else if (want->ulValueLen < len) {
want->ulValueLen = CK_UNAVAILABLE_INFORMATION;
overall = CKR_BUFFER_TOO_SMALL;
} else {
memcpy(want->pValue, value, len);
want->ulValueLen = (CK_ULONG)len;
}
}
return overall;
}
/* ---- Signing --------------------------------------------------------------------- */
CK_RV C_SignInit(CK_SESSION_HANDLE hSession, CK_MECHANISM_PTR pMechanism, CK_OBJECT_HANDLE hKey) {
if (!session_valid(hSession)) return CKR_SESSION_HANDLE_INVALID;
if (hKey != OBJ_PRIVKEY) return CKR_KEY_HANDLE_INVALID;
if (!pMechanism) return CKR_ARGUMENTS_BAD;
if (pMechanism->mechanism != CKM_RSA_PKCS && pMechanism->mechanism != CKM_SHA256_RSA_PKCS) {
return CKR_MECHANISM_INVALID;
}
if (g_sign_active) return CKR_OPERATION_ACTIVE;
g_sign_mech = pMechanism->mechanism;
if (g_sign_mech == CKM_SHA256_RSA_PKCS) {
g_sign_mdctx = EVP_MD_CTX_new();
EVP_DigestInit_ex(g_sign_mdctx, EVP_sha256(), NULL);
}
g_sign_active = 1;
return CKR_OK;
}
static CK_RV do_vault_sign(const unsigned char *digest, size_t digest_len,
CK_BYTE_PTR pSignature, CK_ULONG_PTR pulSignatureLen) {
unsigned char *sig = NULL;
size_t sig_len = 0;
if (vtc_sign_digest(&g_vtc, digest, digest_len, &sig, &sig_len) != 0) {
fprintf(stderr, "vault_pkcs11: sign failed: %s\n", vtc_last_error());
return CKR_FUNCTION_FAILED;
}
CK_RV rv = CKR_OK;
if (!pSignature) {
*pulSignatureLen = (CK_ULONG)sig_len;
} else if (*pulSignatureLen < sig_len) {
*pulSignatureLen = (CK_ULONG)sig_len;
rv = CKR_BUFFER_TOO_SMALL;
} else {
memcpy(pSignature, sig, sig_len);
*pulSignatureLen = (CK_ULONG)sig_len;
}
free(sig);
return rv;
}
CK_RV C_Sign(CK_SESSION_HANDLE hSession, CK_BYTE_PTR pData, CK_ULONG ulDataLen,
CK_BYTE_PTR pSignature, CK_ULONG_PTR pulSignatureLen) {
if (!session_valid(hSession)) return CKR_SESSION_HANDLE_INVALID;
if (!g_sign_active) return CKR_OPERATION_NOT_INITIALIZED;
if (!pulSignatureLen) return CKR_ARGUMENTS_BAD;
unsigned char digest[32];
if (g_sign_mech == CKM_RSA_PKCS) {
if (ulDataLen != 32) { g_sign_active = 0; return CKR_DATA_LEN_RANGE; }
memcpy(digest, pData, 32);
} else {
EVP_DigestUpdate(g_sign_mdctx, pData, ulDataLen);
unsigned int dlen = 0;
EVP_DigestFinal_ex(g_sign_mdctx, digest, &dlen);
EVP_MD_CTX_free(g_sign_mdctx); g_sign_mdctx = NULL;
}
CK_RV rv = do_vault_sign(digest, 32, pSignature, pulSignatureLen);
if (rv != CKR_BUFFER_TOO_SMALL) g_sign_active = 0; /* allow retry on too-small buffer */
return rv;
}
CK_RV C_SignUpdate(CK_SESSION_HANDLE hSession, CK_BYTE_PTR pPart, CK_ULONG ulPartLen) {
if (!session_valid(hSession)) return CKR_SESSION_HANDLE_INVALID;
if (!g_sign_active || g_sign_mech != CKM_SHA256_RSA_PKCS) return CKR_OPERATION_NOT_INITIALIZED;
EVP_DigestUpdate(g_sign_mdctx, pPart, ulPartLen);
return CKR_OK;
}
CK_RV C_SignFinal(CK_SESSION_HANDLE hSession, CK_BYTE_PTR pSignature, CK_ULONG_PTR pulSignatureLen) {
if (!session_valid(hSession)) return CKR_SESSION_HANDLE_INVALID;
if (!g_sign_active || g_sign_mech != CKM_SHA256_RSA_PKCS) return CKR_OPERATION_NOT_INITIALIZED;
if (!pulSignatureLen) return CKR_ARGUMENTS_BAD;
unsigned char digest[32];
unsigned int dlen = 0;
EVP_DigestFinal_ex(g_sign_mdctx, digest, &dlen);
CK_RV rv = do_vault_sign(digest, 32, pSignature, pulSignatureLen);
if (rv != CKR_BUFFER_TOO_SMALL) {
EVP_MD_CTX_free(g_sign_mdctx); g_sign_mdctx = NULL;
g_sign_active = 0;
}
return rv;
}
/* ---- Unsupported functions: safe stubs ------------------------------------------- */
CK_RV C_InitToken(CK_SLOT_ID s, CK_UTF8CHAR_PTR p, CK_ULONG l, CK_UTF8CHAR_PTR lb) { (void)s;(void)p;(void)l;(void)lb; return CKR_FUNCTION_NOT_SUPPORTED; }
CK_RV C_InitPIN(CK_SESSION_HANDLE h, CK_UTF8CHAR_PTR p, CK_ULONG l) { (void)h;(void)p;(void)l; return CKR_FUNCTION_NOT_SUPPORTED; }
CK_RV C_SetPIN(CK_SESSION_HANDLE h, CK_UTF8CHAR_PTR o, CK_ULONG ol, CK_UTF8CHAR_PTR n, CK_ULONG nl) { (void)h;(void)o;(void)ol;(void)n;(void)nl; return CKR_FUNCTION_NOT_SUPPORTED; }
CK_RV C_GetOperationState(CK_SESSION_HANDLE h, CK_BYTE_PTR p, CK_ULONG_PTR l) { (void)h;(void)p;(void)l; return CKR_FUNCTION_NOT_SUPPORTED; }
CK_RV C_SetOperationState(CK_SESSION_HANDLE h, CK_BYTE_PTR p, CK_ULONG l, CK_OBJECT_HANDLE a, CK_OBJECT_HANDLE b) { (void)h;(void)p;(void)l;(void)a;(void)b; return CKR_FUNCTION_NOT_SUPPORTED; }
CK_RV C_CreateObject(CK_SESSION_HANDLE h, CK_ATTRIBUTE_PTR t, CK_ULONG c, CK_OBJECT_HANDLE_PTR o) { (void)h;(void)t;(void)c;(void)o; return CKR_FUNCTION_NOT_SUPPORTED; }
CK_RV C_CopyObject(CK_SESSION_HANDLE h, CK_OBJECT_HANDLE o, CK_ATTRIBUTE_PTR t, CK_ULONG c, CK_OBJECT_HANDLE_PTR n) { (void)h;(void)o;(void)t;(void)c;(void)n; return CKR_FUNCTION_NOT_SUPPORTED; }
CK_RV C_DestroyObject(CK_SESSION_HANDLE h, CK_OBJECT_HANDLE o) { (void)h;(void)o; return CKR_FUNCTION_NOT_SUPPORTED; }
CK_RV C_GetObjectSize(CK_SESSION_HANDLE h, CK_OBJECT_HANDLE o, CK_ULONG_PTR s) { (void)h;(void)o;(void)s; return CKR_FUNCTION_NOT_SUPPORTED; }
CK_RV C_SetAttributeValue(CK_SESSION_HANDLE h, CK_OBJECT_HANDLE o, CK_ATTRIBUTE_PTR t, CK_ULONG c) { (void)h;(void)o;(void)t;(void)c; return CKR_FUNCTION_NOT_SUPPORTED; }
CK_RV C_EncryptInit(CK_SESSION_HANDLE h, CK_MECHANISM_PTR m, CK_OBJECT_HANDLE k) { (void)h;(void)m;(void)k; return CKR_FUNCTION_NOT_SUPPORTED; }
CK_RV C_Encrypt(CK_SESSION_HANDLE h, CK_BYTE_PTR d, CK_ULONG dl, CK_BYTE_PTR e, CK_ULONG_PTR el) { (void)h;(void)d;(void)dl;(void)e;(void)el; return CKR_FUNCTION_NOT_SUPPORTED; }
CK_RV C_EncryptUpdate(CK_SESSION_HANDLE h, CK_BYTE_PTR p, CK_ULONG pl, CK_BYTE_PTR e, CK_ULONG_PTR el) { (void)h;(void)p;(void)pl;(void)e;(void)el; return CKR_FUNCTION_NOT_SUPPORTED; }
CK_RV C_EncryptFinal(CK_SESSION_HANDLE h, CK_BYTE_PTR e, CK_ULONG_PTR el) { (void)h;(void)e;(void)el; return CKR_FUNCTION_NOT_SUPPORTED; }
CK_RV C_DecryptInit(CK_SESSION_HANDLE h, CK_MECHANISM_PTR m, CK_OBJECT_HANDLE k) { (void)h;(void)m;(void)k; return CKR_FUNCTION_NOT_SUPPORTED; }
CK_RV C_Decrypt(CK_SESSION_HANDLE h, CK_BYTE_PTR e, CK_ULONG el, CK_BYTE_PTR d, CK_ULONG_PTR dl) { (void)h;(void)e;(void)el;(void)d;(void)dl; return CKR_FUNCTION_NOT_SUPPORTED; }
CK_RV C_DecryptUpdate(CK_SESSION_HANDLE h, CK_BYTE_PTR e, CK_ULONG el, CK_BYTE_PTR p, CK_ULONG_PTR pl) { (void)h;(void)e;(void)el;(void)p;(void)pl; return CKR_FUNCTION_NOT_SUPPORTED; }
CK_RV C_DecryptFinal(CK_SESSION_HANDLE h, CK_BYTE_PTR p, CK_ULONG_PTR pl) { (void)h;(void)p;(void)pl; return CKR_FUNCTION_NOT_SUPPORTED; }
CK_RV C_DigestInit(CK_SESSION_HANDLE h, CK_MECHANISM_PTR m) { (void)h;(void)m; return CKR_FUNCTION_NOT_SUPPORTED; }
CK_RV C_Digest(CK_SESSION_HANDLE h, CK_BYTE_PTR d, CK_ULONG dl, CK_BYTE_PTR o, CK_ULONG_PTR ol) { (void)h;(void)d;(void)dl;(void)o;(void)ol; return CKR_FUNCTION_NOT_SUPPORTED; }
CK_RV C_DigestUpdate(CK_SESSION_HANDLE h, CK_BYTE_PTR p, CK_ULONG pl) { (void)h;(void)p;(void)pl; return CKR_FUNCTION_NOT_SUPPORTED; }
CK_RV C_DigestKey(CK_SESSION_HANDLE h, CK_OBJECT_HANDLE k) { (void)h;(void)k; return CKR_FUNCTION_NOT_SUPPORTED; }
CK_RV C_DigestFinal(CK_SESSION_HANDLE h, CK_BYTE_PTR d, CK_ULONG_PTR dl) { (void)h;(void)d;(void)dl; return CKR_FUNCTION_NOT_SUPPORTED; }
CK_RV C_SignRecoverInit(CK_SESSION_HANDLE h, CK_MECHANISM_PTR m, CK_OBJECT_HANDLE k) { (void)h;(void)m;(void)k; return CKR_FUNCTION_NOT_SUPPORTED; }
CK_RV C_SignRecover(CK_SESSION_HANDLE h, CK_BYTE_PTR d, CK_ULONG dl, CK_BYTE_PTR s, CK_ULONG_PTR sl) { (void)h;(void)d;(void)dl;(void)s;(void)sl; return CKR_FUNCTION_NOT_SUPPORTED; }
CK_RV C_VerifyInit(CK_SESSION_HANDLE h, CK_MECHANISM_PTR m, CK_OBJECT_HANDLE k) { (void)h;(void)m;(void)k; return CKR_FUNCTION_NOT_SUPPORTED; }
CK_RV C_Verify(CK_SESSION_HANDLE h, CK_BYTE_PTR d, CK_ULONG dl, CK_BYTE_PTR s, CK_ULONG sl) { (void)h;(void)d;(void)dl;(void)s;(void)sl; return CKR_FUNCTION_NOT_SUPPORTED; }
CK_RV C_VerifyUpdate(CK_SESSION_HANDLE h, CK_BYTE_PTR p, CK_ULONG pl) { (void)h;(void)p;(void)pl; return CKR_FUNCTION_NOT_SUPPORTED; }
CK_RV C_VerifyFinal(CK_SESSION_HANDLE h, CK_BYTE_PTR s, CK_ULONG sl) { (void)h;(void)s;(void)sl; return CKR_FUNCTION_NOT_SUPPORTED; }
CK_RV C_VerifyRecoverInit(CK_SESSION_HANDLE h, CK_MECHANISM_PTR m, CK_OBJECT_HANDLE k) { (void)h;(void)m;(void)k; return CKR_FUNCTION_NOT_SUPPORTED; }
CK_RV C_VerifyRecover(CK_SESSION_HANDLE h, CK_BYTE_PTR s, CK_ULONG sl, CK_BYTE_PTR d, CK_ULONG_PTR dl) { (void)h;(void)s;(void)sl;(void)d;(void)dl; return CKR_FUNCTION_NOT_SUPPORTED; }
CK_RV C_DigestEncryptUpdate(CK_SESSION_HANDLE h, CK_BYTE_PTR p, CK_ULONG pl, CK_BYTE_PTR e, CK_ULONG_PTR el) { (void)h;(void)p;(void)pl;(void)e;(void)el; return CKR_FUNCTION_NOT_SUPPORTED; }
CK_RV C_DecryptDigestUpdate(CK_SESSION_HANDLE h, CK_BYTE_PTR e, CK_ULONG el, CK_BYTE_PTR p, CK_ULONG_PTR pl) { (void)h;(void)e;(void)el;(void)p;(void)pl; return CKR_FUNCTION_NOT_SUPPORTED; }
CK_RV C_SignEncryptUpdate(CK_SESSION_HANDLE h, CK_BYTE_PTR p, CK_ULONG pl, CK_BYTE_PTR e, CK_ULONG_PTR el) { (void)h;(void)p;(void)pl;(void)e;(void)el; return CKR_FUNCTION_NOT_SUPPORTED; }
CK_RV C_DecryptVerifyUpdate(CK_SESSION_HANDLE h, CK_BYTE_PTR e, CK_ULONG el, CK_BYTE_PTR p, CK_ULONG_PTR pl) { (void)h;(void)e;(void)el;(void)p;(void)pl; return CKR_FUNCTION_NOT_SUPPORTED; }
CK_RV C_GenerateKey(CK_SESSION_HANDLE h, CK_MECHANISM_PTR m, CK_ATTRIBUTE_PTR t, CK_ULONG c, CK_OBJECT_HANDLE_PTR k) { (void)h;(void)m;(void)t;(void)c;(void)k; return CKR_FUNCTION_NOT_SUPPORTED; }
CK_RV C_GenerateKeyPair(CK_SESSION_HANDLE h, CK_MECHANISM_PTR m, CK_ATTRIBUTE_PTR pt, CK_ULONG pc, CK_ATTRIBUTE_PTR kt, CK_ULONG kc, CK_OBJECT_HANDLE_PTR pk, CK_OBJECT_HANDLE_PTR prk) { (void)h;(void)m;(void)pt;(void)pc;(void)kt;(void)kc;(void)pk;(void)prk; return CKR_FUNCTION_NOT_SUPPORTED; }
CK_RV C_WrapKey(CK_SESSION_HANDLE h, CK_MECHANISM_PTR m, CK_OBJECT_HANDLE wk, CK_OBJECT_HANDLE k, CK_BYTE_PTR w, CK_ULONG_PTR wl) { (void)h;(void)m;(void)wk;(void)k;(void)w;(void)wl; return CKR_FUNCTION_NOT_SUPPORTED; }
CK_RV C_UnwrapKey(CK_SESSION_HANDLE h, CK_MECHANISM_PTR m, CK_OBJECT_HANDLE uk, CK_BYTE_PTR w, CK_ULONG wl, CK_ATTRIBUTE_PTR t, CK_ULONG c, CK_OBJECT_HANDLE_PTR k) { (void)h;(void)m;(void)uk;(void)w;(void)wl;(void)t;(void)c;(void)k; return CKR_FUNCTION_NOT_SUPPORTED; }
CK_RV C_DeriveKey(CK_SESSION_HANDLE h, CK_MECHANISM_PTR m, CK_OBJECT_HANDLE bk, CK_ATTRIBUTE_PTR t, CK_ULONG c, CK_OBJECT_HANDLE_PTR k) { (void)h;(void)m;(void)bk;(void)t;(void)c;(void)k; return CKR_FUNCTION_NOT_SUPPORTED; }
CK_RV C_SeedRandom(CK_SESSION_HANDLE h, CK_BYTE_PTR s, CK_ULONG sl) { (void)h;(void)s;(void)sl; return CKR_FUNCTION_NOT_SUPPORTED; }
CK_RV C_GenerateRandom(CK_SESSION_HANDLE h, CK_BYTE_PTR r, CK_ULONG rl) { (void)h;(void)r;(void)rl; return CKR_FUNCTION_NOT_SUPPORTED; }
CK_RV C_GetFunctionStatus(CK_SESSION_HANDLE h) { (void)h; return CKR_FUNCTION_NOT_SUPPORTED; }
CK_RV C_CancelFunction(CK_SESSION_HANDLE h) { (void)h; return CKR_FUNCTION_NOT_SUPPORTED; }
CK_RV C_WaitForSlotEvent(CK_FLAGS f, CK_SLOT_ID_PTR s, CK_VOID_PTR r) { (void)f;(void)s;(void)r; return CKR_FUNCTION_NOT_SUPPORTED; }
/* ---- Function list / entry point ------------------------------------------------- */
static CK_FUNCTION_LIST g_function_list = {
{ 2, 40 },
C_Initialize, C_Finalize, C_GetInfo, NULL /* C_GetFunctionList set below */,
C_GetSlotList, C_GetSlotInfo, C_GetTokenInfo, C_GetMechanismList, C_GetMechanismInfo,
C_InitToken, C_InitPIN, C_SetPIN,
C_OpenSession, C_CloseSession, C_CloseAllSessions, C_GetSessionInfo,
C_GetOperationState, C_SetOperationState, C_Login, C_Logout,
C_CreateObject, C_CopyObject, C_DestroyObject, C_GetObjectSize,
C_GetAttributeValue, C_SetAttributeValue,
C_FindObjectsInit, C_FindObjects, C_FindObjectsFinal,
C_EncryptInit, C_Encrypt, C_EncryptUpdate, C_EncryptFinal,
C_DecryptInit, C_Decrypt, C_DecryptUpdate, C_DecryptFinal,
C_DigestInit, C_Digest, C_DigestUpdate, C_DigestKey, C_DigestFinal,
C_SignInit, C_Sign, C_SignUpdate, C_SignFinal,
C_SignRecoverInit, C_SignRecover,
C_VerifyInit, C_Verify, C_VerifyUpdate, C_VerifyFinal,
C_VerifyRecoverInit, C_VerifyRecover,
C_DigestEncryptUpdate, C_DecryptDigestUpdate, C_SignEncryptUpdate, C_DecryptVerifyUpdate,
C_GenerateKey, C_GenerateKeyPair, C_WrapKey, C_UnwrapKey, C_DeriveKey,
C_SeedRandom, C_GenerateRandom,
C_GetFunctionStatus, C_CancelFunction, C_WaitForSlotEvent
};
CK_RV C_GetFunctionList(CK_FUNCTION_LIST_PTR_PTR ppFunctionList) {
if (!ppFunctionList) return CKR_ARGUMENTS_BAD;
g_function_list.C_GetFunctionList = C_GetFunctionList;
*ppFunctionList = &g_function_list;
return CKR_OK;
}
src/vault_transit_client.h(Vault Transit REST クライアント、ヘッダ)
/* Minimal Vault Transit REST client used by the vault_pkcs11 module. */
#ifndef VAULT_TRANSIT_CLIENT_H
#define VAULT_TRANSIT_CLIENT_H
#include <stddef.h>
typedef struct {
char *vault_addr; /* e.g. https://127.0.0.1:8220 */
char *vault_token;
char *key_name; /* transit key name, e.g. "codesign" */
char *ca_cert_path; /* may be NULL -> disables TLS verify */
} vtc_ctx;
/* Fetches transit/keys/<key_name>. On success fills *cert_der / *cert_der_len
* with the DER bytes of the current signing certificate (parsed out of the
* PEM certificate_chain), and *modulus/*modulus_len, *pub_exp/*pub_exp_len
* with the RSA public key components (from the certificate's SubjectPublicKeyInfo).
* Caller frees all output buffers with free(). Returns 0 on success, -1 on error
* (message left in vtc_last_error()). */
int vtc_get_key_info(vtc_ctx *ctx,
unsigned char **cert_der, size_t *cert_der_len,
unsigned char **modulus, size_t *modulus_len,
unsigned char **pub_exp, size_t *pub_exp_len);
/* Signs a pre-hashed SHA-256 digest (must be exactly 32 bytes) via
* POST transit/sign/<key_name>. On success *sig/*sig_len holds the raw
* (non-base64, non-"vault:v1:"-prefixed) signature bytes. Returns 0 on
* success, -1 on error. */
int vtc_sign_digest(vtc_ctx *ctx, const unsigned char *digest, size_t digest_len,
unsigned char **sig, size_t *sig_len);
const char *vtc_last_error(void);
#endif
src/vault_transit_client.c(Vault Transit REST クライアント、実装 255行)
#include "vault_transit_client.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdarg.h>
#include <curl/curl.h>
#include <json-c/json.h>
#include <openssl/pem.h>
#include <openssl/x509.h>
#include <openssl/rsa.h>
#include <openssl/bn.h>
#include <openssl/evp.h>
static char g_last_error[512] = "";
static void set_error(const char *fmt, ...) {
va_list ap;
va_start(ap, fmt);
vsnprintf(g_last_error, sizeof(g_last_error), fmt, ap);
va_end(ap);
}
const char *vtc_last_error(void) { return g_last_error; }
struct membuf {
char *data;
size_t len;
};
static size_t write_cb(void *contents, size_t size, size_t nmemb, void *userp) {
size_t realsize = size * nmemb;
struct membuf *mb = (struct membuf *)userp;
char *ptr = realloc(mb->data, mb->len + realsize + 1);
if (!ptr) return 0;
mb->data = ptr;
memcpy(&(mb->data[mb->len]), contents, realsize);
mb->len += realsize;
mb->data[mb->len] = 0;
return realsize;
}
/* Performs an HTTP request against Vault. method = "GET" or "PUT"/"POST".
* body may be NULL for GET. On success returns 0 and *out_body/out_len hold
* the response body (caller must free with free()); on failure returns -1. */
static int vault_http(vtc_ctx *ctx, const char *method, const char *path,
const char *body, char **out_body, size_t *out_len) {
CURL *curl = curl_easy_init();
if (!curl) { set_error("curl_easy_init failed"); return -1; }
char url[1024];
snprintf(url, sizeof(url), "%s/v1/%s", ctx->vault_addr, path);
struct membuf mb = {0};
struct curl_slist *headers = NULL;
char token_hdr[600];
snprintf(token_hdr, sizeof(token_hdr), "X-Vault-Token: %s", ctx->vault_token);
headers = curl_slist_append(headers, token_hdr);
headers = curl_slist_append(headers, "Content-Type: application/json");
curl_easy_setopt(curl, CURLOPT_URL, url);
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers);
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_cb);
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &mb);
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 15L);
if (ctx->ca_cert_path && ctx->ca_cert_path[0]) {
curl_easy_setopt(curl, CURLOPT_CAINFO, ctx->ca_cert_path);
} else {
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 0L);
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 0L);
}
if (strcmp(method, "GET") == 0) {
curl_easy_setopt(curl, CURLOPT_HTTPGET, 1L);
} else {
curl_easy_setopt(curl, CURLOPT_CUSTOMREQUEST, method);
curl_easy_setopt(curl, CURLOPT_POSTFIELDS, body ? body : "");
}
CURLcode rc = curl_easy_perform(curl);
long http_code = 0;
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code);
curl_slist_free_all(headers);
curl_easy_cleanup(curl);
if (rc != CURLE_OK) {
set_error("curl error: %s", curl_easy_strerror(rc));
free(mb.data);
return -1;
}
if (http_code < 200 || http_code >= 300) {
set_error("vault HTTP %ld: %.400s", http_code, mb.data ? mb.data : "");
free(mb.data);
return -1;
}
*out_body = mb.data;
*out_len = mb.len;
return 0;
}
static int b64_decode(const char *in, unsigned char **out, size_t *out_len) {
size_t inlen = strlen(in);
unsigned char *buf = malloc(inlen); /* decoded is always <= input length */
if (!buf) return -1;
int len = EVP_DecodeBlock(buf, (const unsigned char *)in, (int)inlen);
if (len < 0) { free(buf); return -1; }
/* EVP_DecodeBlock does not account for padding chars in its return value
* correctly for our purposes; strip trailing '=' manually. */
size_t pad = 0;
if (inlen >= 1 && in[inlen - 1] == '=') pad++;
if (inlen >= 2 && in[inlen - 2] == '=') pad++;
*out = buf;
*out_len = (size_t)len - pad;
return 0;
}
int vtc_get_key_info(vtc_ctx *ctx,
unsigned char **cert_der, size_t *cert_der_len,
unsigned char **modulus, size_t *modulus_len,
unsigned char **pub_exp, size_t *pub_exp_len) {
char path[256];
snprintf(path, sizeof(path), "transit/keys/%s", ctx->key_name);
char *body = NULL;
size_t body_len = 0;
if (vault_http(ctx, "GET", path, NULL, &body, &body_len) != 0) return -1;
struct json_object *root = json_tokener_parse(body);
free(body);
if (!root) { set_error("invalid JSON from transit/keys"); return -1; }
struct json_object *data, *keys, *latest_version;
if (!json_object_object_get_ex(root, "data", &data) ||
!json_object_object_get_ex(data, "keys", &keys) ||
!json_object_object_get_ex(data, "latest_version", &latest_version)) {
set_error("unexpected transit/keys response shape");
json_object_put(root);
return -1;
}
char ver_str[32];
snprintf(ver_str, sizeof(ver_str), "%d", json_object_get_int(latest_version));
struct json_object *key_entry, *cert_pem_obj;
if (!json_object_object_get_ex(keys, ver_str, &key_entry) ||
!json_object_object_get_ex(key_entry, "certificate_chain", &cert_pem_obj)) {
set_error("no certificate_chain for key version %s (has set-certificate been run?)", ver_str);
json_object_put(root);
return -1;
}
const char *cert_pem = json_object_get_string(cert_pem_obj);
BIO *bio = BIO_new_mem_buf(cert_pem, -1);
X509 *x509 = PEM_read_bio_X509(bio, NULL, NULL, NULL);
BIO_free(bio);
json_object_put(root);
if (!x509) { set_error("failed to parse certificate_chain PEM"); return -1; }
unsigned char *der = NULL;
int der_len = i2d_X509(x509, &der);
if (der_len < 0) { set_error("i2d_X509 failed"); X509_free(x509); return -1; }
EVP_PKEY *pkey = X509_get_pubkey(x509);
if (!pkey) { set_error("no public key in certificate"); OPENSSL_free(der); X509_free(x509); return -1; }
BIGNUM *n = NULL, *e = NULL;
if (!EVP_PKEY_get_bn_param(pkey, "n", &n) || !EVP_PKEY_get_bn_param(pkey, "e", &e)) {
set_error("failed to extract RSA modulus/exponent");
EVP_PKEY_free(pkey);
OPENSSL_free(der);
X509_free(x509);
return -1;
}
*modulus_len = (size_t)BN_num_bytes(n);
*modulus = malloc(*modulus_len);
BN_bn2bin(n, *modulus);
*pub_exp_len = (size_t)BN_num_bytes(e);
*pub_exp = malloc(*pub_exp_len);
BN_bn2bin(e, *pub_exp);
*cert_der_len = (size_t)der_len;
*cert_der = malloc(der_len);
memcpy(*cert_der, der, der_len);
BN_free(n);
BN_free(e);
OPENSSL_free(der);
EVP_PKEY_free(pkey);
X509_free(x509);
return 0;
}
int vtc_sign_digest(vtc_ctx *ctx, const unsigned char *digest, size_t digest_len,
unsigned char **sig, size_t *sig_len) {
if (digest_len != 32) {
set_error("vtc_sign_digest: expected 32-byte SHA-256 digest, got %zu", digest_len);
return -1;
}
int b64_len = 4 * ((digest_len + 2) / 3) + 1;
char *digest_b64 = malloc(b64_len);
EVP_EncodeBlock((unsigned char *)digest_b64, digest, (int)digest_len);
char path[256];
snprintf(path, sizeof(path), "transit/sign/%s", ctx->key_name);
struct json_object *req = json_object_new_object();
json_object_object_add(req, "input", json_object_new_string(digest_b64));
json_object_object_add(req, "prehashed", json_object_new_boolean(1));
json_object_object_add(req, "hash_algorithm", json_object_new_string("sha2-256"));
json_object_object_add(req, "signature_algorithm", json_object_new_string("pkcs1v15"));
const char *req_str = json_object_to_json_string(req);
free(digest_b64);
char *resp_body = NULL;
size_t resp_len = 0;
int rc = vault_http(ctx, "PUT", path, req_str, &resp_body, &resp_len);
json_object_put(req);
if (rc != 0) return -1;
struct json_object *root = json_tokener_parse(resp_body);
free(resp_body);
if (!root) { set_error("invalid JSON from transit/sign"); return -1; }
struct json_object *data, *sig_obj;
if (!json_object_object_get_ex(root, "data", &data) ||
!json_object_object_get_ex(data, "signature", &sig_obj)) {
set_error("unexpected transit/sign response shape");
json_object_put(root);
return -1;
}
const char *sig_str = json_object_get_string(sig_obj);
/* Format: "vault:v<N>:<base64>" -- skip past the second colon. */
const char *b64_part = strchr(sig_str, ':');
if (b64_part) b64_part = strchr(b64_part + 1, ':');
if (!b64_part) { set_error("malformed vault signature marker: %s", sig_str); json_object_put(root); return -1; }
b64_part++;
int drc = b64_decode(b64_part, sig, sig_len);
json_object_put(root);
if (drc != 0) { set_error("failed to base64-decode signature"); return -1; }
return 0;
}
test/pkcs11_sign_selftest.c(PKCS#11を介さない単体確認用、63行)
/* Standalone sanity test for vault_transit_client.c, independent of PKCS#11.
* Signs a fixed digest via the client library and prints the signature as hex
* so it can be diffed against a direct `vault write transit/sign/...` call. */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <openssl/evp.h>
#include "../src/vault_transit_client.h"
static void print_hex(const unsigned char *buf, size_t len) {
for (size_t i = 0; i < len; i++) printf("%02x", buf[i]);
printf("\n");
}
int main(int argc, char **argv) {
if (argc < 4) {
fprintf(stderr, "usage: %s <vault_addr> <vault_token> <key_name> [input_string]\n", argv[0]);
return 1;
}
vtc_ctx ctx = {0};
ctx.vault_addr = argv[1];
ctx.vault_token = argv[2];
ctx.key_name = argv[3];
ctx.ca_cert_path = NULL;
const char *input = argc > 4 ? argv[4] : "test\n";
unsigned char digest[32];
unsigned int digest_len = 0;
EVP_MD_CTX *mdctx = EVP_MD_CTX_new();
EVP_DigestInit_ex(mdctx, EVP_sha256(), NULL);
EVP_DigestUpdate(mdctx, input, strlen(input));
EVP_DigestFinal_ex(mdctx, digest, &digest_len);
EVP_MD_CTX_free(mdctx);
printf("digest (sha256 of %d-byte input): ", (int)strlen(input));
print_hex(digest, digest_len);
unsigned char *cert_der = NULL, *modulus = NULL, *pub_exp = NULL;
size_t cert_der_len = 0, modulus_len = 0, pub_exp_len = 0;
if (vtc_get_key_info(&ctx, &cert_der, &cert_der_len, &modulus, &modulus_len, &pub_exp, &pub_exp_len) != 0) {
fprintf(stderr, "vtc_get_key_info failed: %s\n", vtc_last_error());
return 1;
}
printf("cert DER len: %zu, modulus len: %zu bytes, pub_exp len: %zu bytes\n",
cert_der_len, modulus_len, pub_exp_len);
unsigned char *sig = NULL;
size_t sig_len = 0;
if (vtc_sign_digest(&ctx, digest, digest_len, &sig, &sig_len) != 0) {
fprintf(stderr, "vtc_sign_digest failed: %s\n", vtc_last_error());
return 1;
}
printf("signature (%zu bytes): ", sig_len);
print_hex(sig, sig_len);
free(cert_der);
free(modulus);
free(pub_exp);
free(sig);
return 0;
}
build.sh / pkcs11.cfg.example / env.sh.example
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
gcc -shared -fPIC -Wall -Wno-comment -O2 \
-o vault_pkcs11.so \
src/vault_pkcs11.c src/vault_transit_client.c \
-lcurl -ljson-c -lssl -lcrypto
echo "built: $(pwd)/vault_pkcs11.so"
name = VaultTransit
library = /path/to/scripts/vault-pkcs11/vault_pkcs11.so
slotListIndex = 0
showInfo = true
#!/usr/bin/env bash
# Copy to env.sh (untracked) and fill in real values; then `source env.sh`
# before running build.sh's output, pkcs11-tool, keytool, or jsign.
export VAULT_ADDR="https://<vault-host>:<port>"
export VAULT_TOKEN="<token with sign+read on transit/keys/<key>>"
export VAULT_CACERT="/path/to/ca.pem" # unset/empty to skip TLS verification (lab only)
export VAULT_TRANSIT_KEY="codesign" # transit key name (managed_key-backed)
本ラッパーは、VAULT_CACERTが未設定または空のとき、Vaultへの接続でTLSのサーバ証明書検証(CURLOPT_SSL_VERIFYPEER・CURLOPT_SSL_VERIFYHOST)を無効にします。単体確認用のpkcs11_sign_selftest.cもca_cert_path = NULLで、常に検証なしで接続します。検証環境での手間を減らすための実装です。この状態で使うと、Vaultトークンと署名対象のダイジェストが中間者に渡る恐れがあります。検証環境以外では必ずVAULT_CACERTを設定してください。流用する場合は、未設定時にエラーで終了する実装に変えることを推奨します。
公開ヘッダinclude/pkcs11.h・include/pkcs11f.hはPKCS#11規格(OASIS)が定義する公開ヘッダそのものなので、本記事には掲載しません。OASIS PKCS#11仕様から入手できます。
ビルドと設定は次のとおりです(gcc 11.4.0)。
# ラッパーをビルドし、vault_pkcs11.so を作る(libcurl・json-c・OpenSSL の開発用パッケージが必要)
$ ./build.sh
# Vaultの接続先・トークン・Transit鍵名を環境変数に設定する(ラッパーが C_Initialize で読み込む)
$ source env.sh
ビルド時の出力と、開発用パッケージのインストールコマンドは、検証記録に残っていません。pkcs11.cfgは、上のpkcs11.cfg.exampleのlibraryにvault_pkcs11.soの場所を書いたものです。--certfileに渡すcertfile.pemは、Case Bと同じファイル(4.2.1節)を使っています。
4.3.2 署名の実行(正常系)
--storepassは指定していません。本ラッパーのC_Login(PKCS#11のPINによる認証)はPINを検証せずに受け入れるだけで、認可をVaultトークン(VAULT_TOKEN環境変数)に一本化しているためです。鍵の使用許可はPKCS#11レイヤーではなく、Vault Transit側のポリシー・トークン権限で判定されます。
# jsignをPKCS#11モードで実行し、自作ラッパー経由でVault Transitに署名させる
# (pkcs11.cfg の中身は、4.3.1節のソース一式にある pkcs11.cfg.example と同じ形式)
$ jsign --storetype PKCS11 --keystore pkcs11.cfg --alias vault-transit-codesign \
--certfile certfile.pem --tsmode RFC3161 --tsaurl http://timestamp.digicert.com hello2.ps1
Adding Authenticode signature to hello2.ps1
4.3.3 署名結果の確認(Linux)
Case Cでは、本文を書き換えた場合の確認(異常系)をWindows側だけで行っています(4.3.4節)。Linux側での異常系の記録はありません。
正常系
Case Aと同じ検証スクリプト(4.1.3節)で検証しました。
# 署名済みファイルを検証スクリプトで検証する
$ python3 verify_ps1_sig.py hello2.ps1
content digest in signature : d11e989a41f6adf5f5a43d779d7fd0416be87437217034aab91130fad4c21ac0
recomputed (UTF-16LE of body): d11e989a41f6adf5f5a43d779d7fd0416be87437217034aab91130fad4c21ac0
[OK] script body matches the signed digest
[OK] messageDigest attribute == SHA-256(SpcIndirectDataContent)
[OK] RSA PKCS#1 v1.5 signature verified with subject=C = JP, O = PoC, OU = Code Signing, CN = Code Signing Demo
RESULT: PASS
さらに、.ps1に埋め込まれた署名値(384バイト)と、同じダイジェストをvault write transit/sign/codesignで直接署名させた結果を比較すると、バイト単位で完全一致しました。署名が確かにVault Transit(=SoftHSM)で行われていることの決定的な証拠です。
embedded signature (384 bytes) = 9f72dfb758d88a8f3293938e5ca9cb04...
vault direct signature (384 bytes) = 9f72dfb758d88a8f3293938e5ca9cb04...
RESULT: PASS (byte-identical)
Vaultの監査ログ
監査ログです。署名前後での行数の増分(delta)は4行で、「鍵情報の読み取り1回+署名1回」のrequest/responseペアがそのまま記録されています。
| # | time | type | operation | path | user-agent |
|---|---|---|---|---|---|
| 1 | 2026-09-18T05:49:49.226382769Z | request | read | transit/keys/codesign | (none) |
| 2 | 2026-09-18T05:49:49.226720162Z | response | read | transit/keys/codesign | (none) |
| 3 | 2026-09-18T05:49:50.144706143Z | request | update | transit/sign/codesign | (none) |
| 4 | 2026-09-18T05:49:50.147609847Z | response | update | transit/sign/codesign | (none) |
user-agentが(none)になっているのは、自作ラッパーが使っているlibcurlが既定でUser-Agentヘッダを送らないためです。jsignやVault CLIのようなJsign・Go-http-clientといった識別子は付きません。
transit/sign/codesignの代表1組の全文です。
{
"auth": {
"accessor": "hmac-sha256:49f2d0cd…",
"client_token": "hmac-sha256:163a9457…",
"display_name": "root",
"policies": ["root"]
},
"request": {
"client_id": "0DHqvq2D…",
"client_token": "hmac-sha256:163a9457…",
"client_token_accessor": "hmac-sha256:49f2d0cd…",
"data": {
"hash_algorithm": "sha2-256",
"input": "Va+9NT/9kubEVXW6ZcWrOA0vzOH1y8rSNSM0d5cBS2Y=",
"prehashed": true,
"signature_algorithm": "pkcs1v15"
},
"id": "f8a8700a…",
"mount_point": "transit/",
"mount_type": "transit",
"operation": "update",
"path": "transit/sign/codesign",
"remote_address": "127.0.0.1",
"remote_port": 44164
},
"time": "2026-09-18T05:49:50.144706143Z",
"type": "request"
}
{
"auth": { "...": "(requestと同一)" },
"request": { "...": "(requestと同一)" },
"response": {
"data": {
"key_version": 1,
"signature": "vault:v1:n3Lft1jYio8yk5O…(省略)"
},
"mount_point": "transit/",
"mount_type": "transit"
},
"time": "2026-09-18T05:49:50.147609847Z",
"type": "response"
}
jsignのプロセスに読み込まれたラッパーが、Vaultのリスナーに実際にTCP接続していることもstraceで確認しました(jsign自体はVaultのAPIを呼ばず、接続しているのはラッパーです)。
# jsign実行中(ラッパーを含むプロセス全体)のTCP接続(connect)を記録し、Vaultへ接続していることを確認する
$ strace -f -e trace=connect -o strace-connect.log -- jsign ... hello2.ps1
connect(10, {sa_family=AF_INET, sin_port=htons(8220), sin_addr=inet_addr("127.0.0.1")}, 16) = -1 EINPROGRESS
Case Cの接続先8220は、Case Bで使った8230と同じクラスタの別ノードです(3.3節)。
4.3.4 Windowsでの有効性確認
Case Aと同じく、Windowsが署名を有効と判定するかを確かめます(4.1.4節)。Case AではGet-AuthenticodeSignatureだけで確認し、Microsoftの署名ツールsigntoolでの検証は未確認として残していました。そこでCase Cでは、signtool verifyでの検証も追加しました。signtoolは、Windowsが署名者とタイムスタンプの証明書チェーンをどうたどったかを詳しく表示します。
準備
コード署名用のRoot CA証明書と中間CA証明書を、現在のユーザーの証明書ストアに登録します。Case CのTSAは実在の外部公開TSA(DigiCert)で、そのRoot CAはWindowsに標準で入っているため、TSAの証明書の登録は不要です。
# コード署名用のRoot CA証明書を、信頼されたルート証明機関(Root)に登録する
> Import-Certificate -FilePath '\\wsl.localhost\Ubuntu-22.04\...\root-ca.crt' -CertStoreLocation Cert:\CurrentUser\Root
# コード署名用の中間CA証明書を、中間証明機関(CA)に登録する
> Import-Certificate -FilePath '\\wsl.localhost\Ubuntu-22.04\...\issuing-ca.crt' -CertStoreLocation Cert:\CurrentUser\CA
signtoolはWindows SDKに含まれるツールで、Windowsには標準で入っていません(Visual Studio Codeにも含まれません)。次の手順でWindows SDKを追加しました。
- Windows SDKのダウンロードページからインストーラーをダウンロードして実行する
- インストールする機能の選択画面で、署名ツール(Signing Tools)だけにチェックを入れてインストールする(
signtoolを使うだけなら、他の機能は不要) - インストール後、
signtool.exeの場所を確認する。本検証ではC:\Program Files (x86)\Windows Kits\10\bin\10.0.28000.0\x64\signtool.exeを使った
signtool.exeは通常PATHに入らないため、上記のフルパスで実行するか、そのフォルダをPATHに追加してください。パスの10.0.28000.0の部分は、インストールしたSDKのバージョンによって変わります。
正常系
以下は、2026-09-18に署名したhello2.ps1を、2026-10-07に同じWindows環境で検証し直したときの出力です(結果は、2026-09-18に検証したときの記録と一致しています)。
# 署名済みファイルを置いたWSL側フォルダのパスを変数に入れる
> $p = '\\wsl.localhost\Ubuntu-22.04\...\'
# 署名状態・署名者の証明書・タイムスタンプ証明書を表示する
> Get-AuthenticodeSignature ($p + 'hello2.ps1') | 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
[Issuer]
CN=DigiCert Trusted G4 TimeStamping RSA4096 SHA256 2025 CA1, O="DigiCert, Inc.", C=US
[Serial Number]
084FDC334F7E454EDBC30F8FF9921835
[Not Before]
2026/08/05 9:00:00
[Not After]
2037/11/05 8:59:59
[Thumbprint]
51D9ABDA034973D84F4266ACA48248E6B369C439
Status: Validになり、署名者がVaultのPKIで発行した証明書(CN=Code Signing Demo)、タイムスタンプがDigiCertのものであることが分かります。
# signtoolで、Authenticodeの既定ポリシー(/pa)により、詳細表示(/v)で検証する
> & 'C:\Program Files (x86)\Windows Kits\10\bin\10.0.28000.0\x64\signtool.exe' verify /pa /v ($p + 'hello2.ps1')
Verifying: \\wsl.localhost\Ubuntu-22.04\...\hello2.ps1
Signature Index: 0 (Primary Signature)
Hash of file (sha256): D11E989A41F6ADF5F5A43D779D7FD0416BE87437217034AAB91130FAD4C21AC0
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 Sep 18 14:49:56 2026
Timestamp Verified by:
Issued to: DigiCert Assured ID Root CA
Issued by: DigiCert Assured ID Root CA
Expires: Mon Nov 10 09:00:00 2031
SHA1 hash: 0563B8630D62D75ABBC8AB1E4BDFB5A899B24D43
Issued to: DigiCert Trusted Root G4
Issued by: DigiCert Assured ID Root CA
Expires: Mon Nov 10 08:59:59 2031
SHA1 hash: A99D5B79E9F1CDA59CDAB6373169D5353F5874C6
Issued to: DigiCert Trusted G4 TimeStamping RSA4096 SHA256 2025 CA1
Issued by: DigiCert Trusted Root G4
Expires: Fri Jan 15 08:59:59 2038
SHA1 hash: 07894D00FC194A17DB273AEB5CF8FACEF14423A4
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: \\wsl.localhost\Ubuntu-22.04\...\hello2.ps1
Number of files successfully Verified: 1
Number of warnings: 0
Number of errors: 0
signtoolの出力から、次のことが分かります。
-
Signing Certificate Chain: 署名者の証明書(
Code Signing Demo)から中間CA、Root CAまでのチェーンを、Windowsが組み立てられた -
Hash of file:
D11E989A…は、4.3.3節の検証スクリプトが出力した本文のダイジェスト(d11e989a…)と同じ値。LinuxとWindowsが同じダイジェストで検証している - The signature is timestamped: タイムスタンプの日時は署名した時刻(2026-09-18 14:49:56)。DigiCertのRoot CAまでのタイムスタンプのチェーンも検証できた
- 最後に
Successfully verified、警告0件・エラー0件
Get-AuthenticodeSignatureとsigntool verify /paの違い
-
判定の仕組みはほぼ同じです。 どちらも、WindowsのAuthenticodeの検証ルール(Authenticodeポリシー)で署名を判定します。
-
signtoolの/paは、「既定のAuthenticode検証ポリシーを使う」という指定です。/paを付けないと、ドライバ用の検証ポリシーで判定されます(SignTool公式ドキュメント)。 - PowerShell 7のソースでも、署名の検証にWindowsの
WinVerifyTrust(署名を検証するAPI)を、Authenticodeポリシー(WINTRUST_ACTION_GENERIC_VERIFY_V2)で呼んでいます(PowerShellのソースAuthenticode.cs)。ソースが公開されていないWindows PowerShell 5.1については確認していません。
-
-
違うのは、結果の見せ方と使い方です。
-
Get-AuthenticodeSignatureは、結果をStatus(Validなど)として返します。PowerShellの標準コマンドで、追加のインストールは不要です。 -
signtoolは、署名者とタイムスタンプのそれぞれについて、証明書チェーンを1段ずつ表示します。結果を終了コード(0=成功、1=失敗、2=警告あり)でも返すので、ビルドやリリースの自動化に組み込みやすいツールです。
-
- 本検証で
signtoolを追加して新たに分かったのは、チェーンを1段ずつ確認できたことです。判定の結果は、Get-AuthenticodeSignatureのValidと同じです。
異常系
署名ブロックより前の本文を書き換えたファイル(hello2-tampered.ps1)で確認しました。
# 署名済みファイルをコピーする(このコピーの、署名ブロックより前の本文を書き換える)
> Copy-Item ($p + 'hello2.ps1') ($p + 'hello2-tampered.ps1')
# 書き換えたファイルの署名状態を表示する
> Get-AuthenticodeSignature ($p + 'hello2-tampered.ps1') | Format-List Status,StatusMessage
結果はStatus: HashMismatch(StatusMessageは「ファイルの内容は、承認されていないユーザーまたはプロセスによって変更された可能性があります」)で、改ざんを検出しました。
最初はAdd-Contentで署名ブロックの後に1行追記してしまい、PowerShellが署名ブロック自体を認識できずNotSignedになりました。署名ブロックより前の本文を書き換える手順に直して再実行し、HashMismatchを確認しています。直した後の書き換えコマンドは、検証記録に残っていません。
4.3.5 本番で使う場合の考慮点
Case Cのラッパーは、「jsignを改造せずに、Vault TransitのManaged Keyで署名できるか」を確かめるためのPoC(概念実証)として作ったものです。動作はWindows実機まで確認できましたが、同じ実装をそのまま本番で使う場合は、次の点を考慮する必要があります。
| # | 考慮点 | PoCでの実装 | 本番で検討すること |
|---|---|---|---|
| 1 | 保守・レビュー | 約840行のCを自作。SunPKCS11(Java側)の呼び出し方に合わせた作り込みが必要だった | 自社でコードの保守とセキュリティレビューを続ける体制。Javaやjsignの更新で、SunPKCS11の呼び出し方が変わったときの追従 |
| 2 | 認証・トークン管理 | PKCS#11のPIN認証(C_Login)はPINを検証せずに受け入れ、環境変数VAULT_TOKENのVaultトークンで認可 |
トークンの保管方法と有効期限。署名する人やパイプラインごとに、署名だけを許可した短命のトークンを発行する仕組み(4.1.3節の注意も参照) |
| 3 | TLSの検証 |
VAULT_CACERTが未設定だと、TLSのサーバ証明書検証を無効にする |
未設定ならエラーで終了する実装に変える(4.3.1節の注意を参照) |
| 4 | 対応範囲 | 鍵は1つだけ。Transit鍵の最新バージョンと、そのチェーンの先頭のリーフ証明書1枚だけを扱う。RSA・SHA-256のみ | 複数の鍵や証明書の使い分け、鍵のローテーション時の切り替え手順、他のアルゴリズムが必要かどうか |
| 5 | 可用性 | Vaultに接続できないと署名できない(署名のたびにVaultを呼ぶ) | 署名の時間帯にVaultを止めない運用と、障害時の再実行手順 |
本番では、ラッパーを自作・保守する以外の方法も比較する価値があります。たとえば、jsign本体のtype=managed_keyの扱いを修正する(上流に修正を提案する)方法や、Microsoftのsigntoolが持つ「ダイジェストだけを外部で署名する」機能(/dg・/di・/dlib、SignTool公式ドキュメント)を使ってVaultに署名させる方法です。いずれも本検証では試していません。
5. 落とし穴と実測で分かった挙動
本文で触れたものも含めて、検証中につまずいた点と、実測して初めて分かった挙動をまとめます。
5.1 jsignのaliasは「鍵名:バージョン」の形式で指定する
--alias codesign(バージョンなし)はUnrecoverableKeyException: ... (missing key version)で失敗します。--alias codesign:1のようにコロンとバージョン番号を明示する必要があります(4.1節)。
5.2 jsign 7.5はtype=managed_keyをパースできない
HashiCorpVaultSigningService.getPrivateKeyがtypeの値を-区切りで解析する実装になっており、rsa-3072のような形式しか想定していません。managed_keyには-が無いためStringIndexOutOfBoundsExceptionで落ちます。2026年10月時点の最新リリース(7.5)・masterブランチの両方で未修正です(4.2節)。
5.3 PKCS#11バックエンドのManaged Keyではsignature_algorithmが無視される
type=managed_key(PKCS#11バックエンド)の鍵に対してtransit/signを呼ぶとき、signature_algorithmにpkcs1v15・pss・省略のいずれを指定しても、まったく同一の署名が返ってきます。
# signature_algorithmを変えて取得した3つの署名のハッシュを比べる
$ sha256sum sig-pkcs1v15.bin sig-default.bin sig-pss.bin
7cf9ef1741df3869b7a23491821a80c72af682cb7e1263f7b9d2fcdb9becc30c sig-pkcs1v15.bin
7cf9ef1741df3869b7a23491821a80c72af682cb7e1263f7b9d2fcdb9becc30c sig-default.bin
7cf9ef1741df3869b7a23491821a80c72af682cb7e1263f7b9d2fcdb9becc30c sig-pss.bin
パディング方式はManaged Key側のmechanism(この場合CKM_RSA_PKCS)で決まり、Transit側のsignature_algorithmは効きません。しかもこの挙動は無警告です。存在しない値(例: totally-bogus)を渡してもエラーにならず署名が返ってくるため、設定ミスがあっても気づけません。
managed_key : signature_algorithm=totally-bogus → 署名が返る(エラーも警告も無し)
rsa-3072 : signature_algorithm=totally-bogus → エラー「unsupported rsa signature algorithm」
本検証で実測したのはPKCS#11バックエンドだけです。他のバックエンドについて、Vaultの公式ドキュメントから分かることは次のとおりです(Managed Keys API、Transit Secrets Engine API、2026-10-08参照)。
| # | バックエンド | Managed Keyの設定で、パディング方式に関わるパラメータ |
|---|---|---|
| 1 | PKCS#11 |
mechanism(0x0001 CKM_RSA_PKCS/0x000D CKM_RSA_PKCS_PSS など) |
| 2 | Google Cloud KMS |
algorithm(「The signature algorithm to be used with the key.」。PSS・PKCS#1 v1.5の各方式から選ぶ) |
| 3 | AWS KMS | 無し(key_type・key_bits・curveなど鍵の種類と長さだけ) |
| 4 | Azure Key Vault | 無し(key_type・key_bitsなど鍵の種類と長さだけ) |
PKCS#11とGoogle Cloud KMSは、Managed Keyの設定でパディング方式を決めています。AWS KMSとAzure Key Vaultの設定には、パディング方式を決めるパラメータがありません。そのため、Transit側のsignature_algorithmで決まる可能性があります。ただし、Transit APIのsignature_algorithmの説明にはManaged Keyの扱いが書かれておらず、ドキュメントからは判断できません。本検証でも試していません。
いずれにしても、signature_algorithm=pkcs1v15は明示しておくべきです。バックエンドや鍵の種類を切り替えたときに、署名の方式が意図せず変わらないようにするためです。
5.4 改ざん検知テストは署名ブロックより前を書き換える
PowerShellのAuthenticode署名は、スクリプト本文の末尾に# SIG # Begin signature block以降として付与されます。改ざん検知テストのために署名ブロックの後に追記すると、PowerShellは署名ブロック自体を認識できずStatus: NotSignedになります。改ざんは署名ブロックより前の本文に対して行う必要があります(4.3節)。
6. まとめ
今回は、HashiCorp Vault TransitとjsignによるPowerShellのAuthenticode署名を、SoftHSMなし・SoftHSM+Managed Key・SoftHSM+自作PKCS#11ラッパーの3パターンで試しました。確認できたことは以下の通りです。
-
ソフトウェア鍵なら、jsignはそのままVault Transitを署名プロバイダとして使えます。TLSトラストストアの設定と
--alias 鍵名:バージョン・--tsmode RFC3161の明示が必要ですが、追加実装は不要です(4.1節)。 -
SoftHSMをVaultのManaged Keyとして登録し、PKCS#11経由でHSM内の鍵に署名させる配線自体は成立します。
pkcs11-toolによる直接署名とVault経由の署名がバイト単位で一致することを確認しました(3.3・5.3節)。 -
ただしjsign 7.5は、そのManaged Key(
type=managed_key)を直接扱えません。鍵タイプ文字列のパース処理に起因するクラッシュで、2026年10月時点でも上流は未修正です(4.2・5.2節)。 -
jsignを改造せず、Vault Transitの監査証跡を保ったままManaged Keyで署名する方法として、自作のPKCS#11ラッパーが機能しました。Windows実機で
Get-AuthenticodeSignature・signtool verify /pa /vの両方で検証済みの署名を得られています(4.3節)。ただしラッパーはPoCとしての実装で、本番で同じ実装を使うには、保守・トークン管理・TLS検証・対応範囲などの考慮点があります(4.3.5節)。 -
監査ログだけで、署名対象の本文を含めずに「誰が・いつ・どのダイジェストに署名したか」を第三者が検証できます。署名まで到達したCase A・Cでは、
inputは厳密に32バイトのSHA-256ダイジェストのみで、署名対象の本文(スクリプト文字列)はログに現れないことを確認しました(Case Bはtransit/signに到達しないため、transit/keysのreadだけが記録されます)。署名値の検証には、証明書の公開鍵も使います。なお、本検証はrootトークンで署名しているため、「誰が」を識別するには、本番で署名用のトークンを分ける必要があります(4.1節)。 -
PKCS#11バックエンドのManaged Keyでは、Transit側の
signature_algorithmが無警告で無視されます。パディング方式はManaged Keyのmechanismで決まります(5.3節)。
本検証の制約: SoftHSMはソフトウェア実装であり、実HSMの代替にはなりません。また、署名・検証したのはPowerShellスクリプト(.ps1)のみで、PEファイル(.exe)は検証していません。