目次

2026年6月、犬アプリのAndroid実機対応を始めた日、flutter build apk が通らなくなった(このアプリを最後に凍結した経緯は以前の記事に書いた。今回はその中身ではなく、ビルド環境の話だ)。Gradleが何かを取りに行くたびに、PKIX——証明書パス検証のエラーで落ちる。Gradle本体の取得も、pluginも、依存アーティファクトも、HTTPSで取ってくるものが軒並み失敗する。

コードは1行も悪くない。ネットワークは生きているし、ブラウザなら同じホストに普通にアクセスできる。それなのに、ビルドのために動くJavaから出て行く通信だけが、全部証明書で止まっていた。

証明書チェーンの中に、知らない名前がいた

当時の記録には、診断がこう残っている。NortonのTLS傍受(Norton Web/Mail Shield Root)がJBR(JDK21)のcacertsに無いため、Gradleの取得がPKIXで失敗する——。

構造を説明するとこうなる。セキュリティソフトの多くは、暗号化された通信を検査する機能を持っている。HTTPSを一度ほどいて中身を検査し、自前のルート証明書で署名し直して届ける方式だ。実際、当時もブラウザでは同じHTTPS先に普通にアクセスできていた。ところが、GradleをAndroid Studio同梱のJBR(JetBrains Runtime、中身はOpenJDK 21)で動かすと、既定ではJBR側のcacertsという信頼リストが参照される(Javaは指定すれば別のtruststoreも使えるが、当時はそういう設定は何もしていない)。そしてこの環境では、Windows側で信頼されているNortonのルートが、そのcacertsには入っていなかった。信頼できる発行元まで証明書の鎖をたどれない——それがPKIXエラーの意味で、だからこの環境ではJVM経由の通信だけが全滅していた。

これはNortonの不具合という話ではない。暗号化通信の検査は、Nortonを含む同種のセキュリティ製品が公式仕様として持っている機能で、現在の公式サポート情報でも、メール保護について「Nortonの証明書をクライアント側にインポートする」手順が案内されているくらいだ。仕様と仕様の相性が、開発ツールの一番深いところで悪かった。

ローカル環境と戦わないことにした

このときの判断は、記録に短く残っている。「ローカル証明書環境に手を入れず先へ進む」。ローカルのJavaに証明書を教え込む代わりに、ビルドする場所を変えた。private GitHubリポジトリを作り、GitHub Actionsのubuntu-latest上でAPKをビルドする。クラウドのランナーにNortonはいない。

ここに面白い事実がある。それまで犬アプリはgit管理されていなかった。最初のcommit(bc7994d、6月22日21時37分)のメッセージはこうだ。

chore: initialize dog app repository with android debug workflow

リポジトリの最初のcommitに、Android debug APKをビルドするGitHub Actionsのworkflowが最初から入っている。犬アプリはGitHub生まれ——というより、CIでビルドするために生まれたリポジトリだった。正確に言えば、初commit自体にNortonのことは書かれておらず、迂回の動機が明文化されたのは翌日のdocsでのことだ。ただ、その翌日の記録がはっきり動機を書き残していて、初commitにはworkflowが同梱されている。この並びだけで、当時何から逃げたのかは十分に読み取れる。

ちなみに、リポジトリ史上最初のCI runは、初commitの約12分後にfailureで終わっている。原因はNortonではなく、Gitが空ディレクトリを追跡しないせいで、pubspecが参照するassets/images/がCI上に存在しなかったこと。.gitkeepを置いた30分後の2回目でようやくグリーンになった。クラウドへ逃げれば終わり、ではなかった。

迂回策が、別の問題を連れてきた

翌23日、もっと大きい請求書が届く。CIで新しくビルドしたAPKで、それまで成功していたGoogleログインがDEVELOPER_ERROR系で失敗するようになった。認証まわりのコードは一切触っていない。

原因はCIの構造だった。ubuntu-latestのランナーは使い捨てで、APKの署名に使うdebug keystoreが実行のたびに新規生成される。署名が変わればSHA-1指紋も毎ビルド変わる。Google OAuth側に登録してあったのは「最初にログインが成功したときの、その1回分のSHA」だけ。だから2回目以降のビルドはすべて署名不一致で弾かれる。

対処は、GitHub Secretに固定のdebug keystoreを置き、ビルド前に復元するstepをworkflowへ足すこと(a528011)。以後は署名が固定され、OAuth登録も一度で済むようになった。CIに逃げれば必ず起きる、という話ではない。ただ、迂回策は迂回策で独自の制約を持ち込む、ということの実例ではあった。

NortonをOFFにしたら、通った

同じ23日の記録に、少し拍子抜けする一文が残っている。「NortonのHTTPSスキャンOFF時に、ローカルビルドのPKIX問題を回避できた」。

つまり、僕の環境ではNorton側の検査を止めればローカルビルド自体は普通に通った。これはNortonのTLS検査が原因だったことの、いちばん分かりやすい傍証でもある。以後の実機ビルド手順には「直前にNorton HTTPSスキャンOFFを確認」という定型が入り、犬アプリはCIビルドとこの運用の二本立てで凍結まで走った。当時の記録も、これを恒久解とは書いていない。cacertsへCAを取り込む恒久対処は「残課題」と書かれたまま、最後まで実施されなかった。もちろん、セキュリティソフトを切って開発しようという話でもない。

2か月後、別のプロジェクトで

8月8日。今度は決め手ログという比較投票アプリで、Pixel 9の実機検証のためにexpo run:androidを実行した。このプロジェクトでは初めてのネイティブビルドだ。そして、Gradleの依存取得が全滅した。PKIX。

このときの記録には、観測がはっきり書かれている。提示された証明書チェーンがCN=Norton Web/Mail Shield Rootに再署名されており、JBR(Android Studio同梱JDK21)のcacertsに無い——。2か月前と同じ名前が、別のプロジェクトの記録に現れた。

今度は、truststore側から解いた

このセッションでの切り分けは、まずjavax.net.ssl.trustStoreType=Windows-ROOT——JVMにWindowsの証明書ストアをそのまま信頼リストとして使わせる指定——を試している。OracleのWindows向けJDKにはSunMSCAPIというプロバイダがあり、Windows-ROOTはそのKeyStore型として公式に存在する。だが当時のこの環境のJBRでは初期化できなかった(プロバイダが無い)。現在の一次情報を確認すると、JBRに該当プロバイダ(SunMSCAPI)が同梱されずWindows-ROOTが認識されない、という既知のissueがJetBrains側にあり、観測と整合する。

そこで方針を変えて、信頼リストの側にNortonを足した。Windowsの証明書ストアからNortonのルート証明書をexportし、JBRのcacertsを作業ディレクトリへコピーして、そのコピーにkeytool -importcertで取り込む。あとはGradleを動かすJVMに、コピーした方のtruststoreを使わせるだけだ。

GRADLE_OPTS: ... -Djavax.net.ssl.trustStore=<cacertsのコピー> -Djavax.net.ssl.trustStorePassword=changeit

システムのJBR cacerts自体には触らない。リポジトリにも何も保存しない。そのセッション限りの対処として、記録にも「システム・リポジトリ無変更、セッション限定」と明記されている。ビルドは通り、同じ日のうちにGoogleログイン・LINEログインの実機検証まで完了した。手順は検証用のrunbookに「既知の落とし穴」として書き残された。

同じ原因だったのか

厳密には断定できない。どちらの記録にも、生のエラーログや証明書チェーンの実データは保存されていないし、Norton側の設定やバージョンの記録もない。

ただ、独立した2つの記録が同じNorton Web/Mail Shield Rootという名前を指している。環境はどちらも同じWindows機のJBR(JDK21)。そして犬アプリ側では「Nortonの検査を切ると通り」、決め手ログ側では「そのルートを信頼すると通った」。逆方向の2つの介入がどちらも同じ仮説で説明できる。同じ種類の問題だった、とまでは言っていいと思っている。

経験を活かした、とは書けない

きれいな物語ならこうなるはずだ。「6月に一度経験していたから、8月は迷わず正面から解けた」。

でも、決め手ログのリポジトリをすべて調べても——docsも、セッション記録も、commitメッセージも——犬アプリや過去のNorton経験への参照は1件もなかった。8月の記録は、初見の問題をその場で切り分けた記録として書かれている。後から2つを並べると、学習して成長したように見える。でも、その2つをつなぐ記録は何も残っていない。だからこの記事も、「同じ種類の問題を、2か月後にもう一度踏んだ」とまでしか書かない。

手元に残ったのは、この問題との3通りの付き合い方だ。ビルドする場所を変えて環境と戦わない迂回(ただし別の制約を連れてくる)。検査を一時的に止める回避(その場は通るが、恒久解ではない)。そしてJVMの信頼リストに検査者を加える直接の対処(システムに触らずコピーでやる方法があった)。どれかが正解でどれかを勧める、という話ではなく、そのときの目的——先へ進むこと、切り分けること、環境を汚さないこと——で選び方が変わった、ということなのだと思う。

技術的に持ち帰るなら2つ。PKIXエラーを見たら、コードやネットワークを疑う前に、提示されている証明書チェーンの中身を見る価値がある。そして、ブラウザが信じているものとJVMが信じているものは、同じマシンの上でも別のリストだ。