PEMから動くスタックへ — 第2部:Javaが読もうとしなかったキーストア
シリーズ第2部。第1部で証明書フォーマットを整理した。今度はそれらからPKCS#12トラストストアを構築する——技術的に正しいファイルでも引っかかる二つのハードルに直面しながら。
Keycloak(Javaベースの認証サーバー)はPKCS#12トラストストアを要求していた:サービスが信頼すべき公開CA証明書を保持するファイルだ。トラストストアには意図的に秘密鍵を含まない——CAの公開証明書だけだ。それによってサービスに「このCAが署名したものは信頼してよい」と伝える。
きれいなCA証明書は手元にあった。当然のコマンド:
openssl pkcs12 -export -nokeys -in ca.crt -out root.p12 -passout pass:<TRUSTSTORE_PW>
トラストストアには鍵が不要なので-nokeys。うまくいくはずだった。そして——表面上は——うまくいった。
ファイルは作成され、正しいパスワードを持ち、OpenSSLで開くことができた。しかしJavaはトラストストアが空だと報告した。Javaが提供するツールkeytoolによる証明:
keytool -list -keystore root.p12 -storetype PKCS12 -storepass <TRUSTSTORE_PW> # -> Your keystore contains 0 entries
証拠: ゼロエントリー——OpenSSLはファイル作成時に文句を言わず、検査時にはCertificate bagを律儀に表示したにもかかわらず。
原因は微妙だ:openssl pkcs12 -export -nokeysで秘密鍵なしに素の証明書をパッケージすると、コンテナ内にエイリアス(「friendlyName」とも呼ばれる)なしのいわゆるbagとして格納される。そしてJava/keytoolはエイリアスのないエントリーを使用可能なエントリーとして単純にリストしない。Javaから見れば:空だ。
修正方法はJava自身のツールでトラストストアを構築することだ。keytool -importcertはまさにこのため——信頼された証明書をエイリアス付きでインポートするために作られている:
keytool -importcert -noprompt \ -alias corp-ca \ -file ca.crt \ -keystore root.p12 \ -storetype PKCS12 \ -storepass <TRUSTSTORE_PW>
これでkeytool -listはtrustedCertEntryとともに1 entryを律儀に表示する。Javaが証明書を認識する。
結論: Javaのトラストストアにはkeytoolが正しい選択であり、OpenSSLのエクスポートではない。OpenSSLが正しいとみなすものが、Javaの世界で使えるとは限らない。
空のキーストアを解決したとたん、次の古典的エラーが来た:
ERROR: keystore password was incorrect javax.crypto.BadPaddingException: Given final block not properly padded
厄介なのは:まったく同じファイルをまったく同じパスワードでOpenSSLでは問題なく開けた:
openssl pkcs12 -info -in root.p12 -passin pass:<TRUSTSTORE_PW> -noout # runs through cleanly
証拠: OpenSSLがパスワードでファイルを開けるのにJavaが「パスワードが違う」と言う場合、問題はパスワードではない——暗号化アルゴリズムだ。
背景:OpenSSL 3.xはPKCS#12ファイルをデフォルトでモダンなPBES2 / AES-256 / SHA-256でパッケージする。多くのコンテナイメージのJDK 17のような古いJavaバージョンはこのアルゴリズムを復号化時に理解できない——そして誤解を招くことに「パスワードが違う」と報告する、パスワードは完全に正しいのに。
モダンフォーマットはOpenSSLの出力から判別できる:
openssl pkcs12 -info -in root.p12 -passin pass:<TRUSTSTORE_PW> -noout # PKCS7 Encrypted data: PBES2, PBKDF2, AES-256-CBC ... <- Java doesn't like this # MAC: sha256
Javaはその代わり旧来のレガシーフォーマットを求める:
PKCS7 Encrypted data: pbeWithSHA1And3-KeyTripleDES-CBC ... <- Java understands this MAC: sha1
結論: OpenSSLがファイルを問題なく開けるのにJavaから「パスワードが違う」が来たら、それはほぼ常にPKCS#12暗号化アルゴリズムの問題であって、パスワードの問題ではない。
ジレンマ——そしてその解決
プロジェクト全体を貫くトラップにはまった:
- OpenSSLでレガシーフォーマットにエクスポート→ Javaで読めるが、空のキーストア(エイリアスなし)。
- keytoolでインポート→ エイリアス付きの中身のあるキーストアだが、Javaが拒否するモダンなAESフォーマット。
両方同時に必要だった:エイリアス付きのエントリーかつレガシーフォーマット。
解決策は構築にkeytoolを使い(エイリアス問題を解決)、同時にJDKのシステムプロパティでレガシーフォーマットを強制することだった:
keytool -importcert -noprompt -alias corp-ca \ -file ca.crt \ -keystore root.p12 -storetype PKCS12 -storepass <TRUSTSTORE_PW> \ -J-Dkeystore.pkcs12.certProtectionAlgorithm=PBEWithSHA1AndDESede \ -J-Dkeystore.pkcs12.certPbeIterationCount=2048 \ -J-Dkeystore.pkcs12.macAlgorithm=HmacPBESHA1 \ -J-Dkeystore.pkcs12.macIterationCount=2048
四つの-J-Dオプションは「旧来の3DES/SHA1フォーマットを使え」という指示をkeytoolを実行するJVMに渡す。結果:エイリアス付きのエントリーがJavaで読めるフォーマットで。一度で両条件を満たした。
コンテナ内でファイルを構築しなければならない場合
脇道にあった実践的な落とし穴:どのkeytoolを使うかは無関係ではない。最も確実な方法は、後でそれを読むJavaとまったく同じJavaでキーストアを構築すること——つまりコンテナ内で。そうすれば生成されたフォーマットとの互換性が保証される。
コンテナ内の証明書ディレクトリはしばしば読み取り専用(read-only)でマウントされているため、/tmpでファイルを構築してストリームとして出力する:
docker compose run --rm -v /host/certs:/out:ro --entrypoint sh keycloak -c '
keytool -importcert -noprompt -alias corp-ca \
-file /out/ca.crt \
-keystore /tmp/root.p12 -storetype PKCS12 -storepass <TRUSTSTORE_PW> \
-J-Dkeystore.pkcs12.certProtectionAlgorithm=PBEWithSHA1AndDESede \
-J-Dkeystore.pkcs12.macAlgorithm=HmacPBESHA1 && cat /tmp/root.p12
' > /host/certs/root.p12
トリック:keytoolは(常に書き込み可能な)/tmpに書き込み、catで結果を標準出力にダンプし、ホスト側のシェルが>でターゲットファイルにリダイレクトする——コンテナではなくあなたが書き込み権限を持つ場所に。
第2部で学んだこと
- 作成成功なのに「0 entries」= 証明書にエイリアスがない。
-alias付きのkeytool -importcertを使え。 - パスワードが正しいのに「パスワードが違う」= 実際はアルゴリズムの問題。OpenSSL 3はAES-256で書くが、古いJavaは読めない。
- Javaはレガシーフォーマット
pbeWithSHA1And3-KeyTripleDES-CBC/MAC: sha1を必要とする。 keytoolと-J-Dオプションで中身ありかつレガシーを一度で実現。- 常に
keytoolで検証せよ、OpenSSLだけでなく——Javaの視点を見せてくれるのはそちらだけだ。 - キーストアはターゲットコンテナ内で構築せよ、そうすればフォーマットが確実に合う。
次のパートでは、組織的に厄介になる:複数のサービスが同じファイルに対して異なるパスワードを期待する場合どうなるか——そして三種類の異なるパスワード変数を区別し続ける方法。