本文へスキップ
ecdsa.com
← すべての記事

2026年の証明書ピンニング:使うべき場面と安全な方法

ピンニングは、公開 PKI の柔軟性と引き換えに、まれな障害の 1 つ、つまり信頼された CA が自分のドメイン名の証明書を第三者に発行してしまう事態から身を守る仕組みです。2026年の時点でこの引き換えにどれだけのコストがかかるのかを、実際のチェーンから計算したピンを使い、OkHttp 5.5.0 と macOS 27 上の Apple の App Transport Security でテストした結果とともに解説します。

要点:ブラウザーはヘッダーによるピンニング(HPKP)を廃止しました。Chrome はバージョン 72(2019年1月)で、Firefox はバージョン 72(2020年1月)で HPKP を削除し、HPKP が検出しようとしていた問題の大部分は、現在では Certificate Transparency がカバーしています。ピンニングが今も使われているのは、自分で管理するサーバーと通信するモバイルアプリと API クライアントです。ピンニングするなら、証明書ではなく公開鍵(SPKI)の SHA-256 ハッシュをピンニングしてください。ほとんどの ACME クライアントが更新のたびに差し替えるリーフの鍵ではなく、今後も使い続ける CA の鍵(中間証明書またはルート)をピンニングしてください。別の CA の鍵かオフラインの鍵によるバックアップのピンを少なくとも 1 つ必ず含めて出荷してください。そして、プラットフォームの仕組みを使ってください。Android では有効期限付きのネットワークセキュリティ構成、iOS では NSPinnedDomains です。Google と Apple はどちらも、具体的な理由がない限りピンニングを勧めていません。

ピンとは

通常の証明書検証では、トラストストアにあるいずれかのルートにつながるチェーンなら、どれでも受け入れます。ピンはここに条件を 1 つ追加します。検証済みのチェーンに含まれる鍵のうち、少なくとも 1 つがリストに載っていなければなりません。リストに入るのは SubjectPublicKeyInfo(SPKI)、つまりアルゴリズム識別子と公開鍵を DER エンコードしたもののハッシュです。証明書ではなく鍵をハッシュするので、同じ鍵のまま更新した証明書は引き続き一致し、CA の鍵に対するピンはその CA が発行するすべての証明書に一致します。

ピンは OpenSSL を使って証明書から計算します。2026年10月20日に letsencrypt.org が提供していたチェーンについて計算した結果は次のとおりです(eclipse-temurin:25.0.4.1_1-jdk-noble、OpenSSL 3.0.13)。

サーバーが送信するすべての証明書の SPKI ピン
openssl s_client -connect letsencrypt.org:443 -servername letsencrypt.org -showcerts </dev/null 2>/dev/null \
  | awk '/-----BEGIN CERTIFICATE-----/{n++; f=sprintf("c%d.pem", n); p=1} p{print >> f} /-----END CERTIFICATE-----/{p=0; close(f)}'
pin() { openssl x509 -in "$1" -noout -pubkey | openssl pkey -pubin -outform der | openssl dgst -sha256 -binary | openssl enc -base64; }
for f in c*.pem; do printf '%s  %s\n' "$(pin "$f")" "$(openssl x509 -in "$f" -noout -subject | sed 's/^subject=//')"; done
出力
x8ltqh+zNPYG7RZS+IjFymndoy6YAWLPaSkptqebzfc=  CN = letsencrypt.org
s/tdAOmUzd8syaTuqfgGvFcn6DzA5Cmb+Vby1ST+U3Y=  C = US, O = Let's Encrypt, CN = YE2
sCkq5UWXjg+7mKu9lMhhYF5bGLsy7VI/UNW3tccdR7w=  C = US, O = ISRG, CN = Root YE
diGVwiVYbubAI3RW4hB9xU8e/CH2GnkuvVFZE8zmgzI=  C = US, O = Internet Security Research Group, CN = ISRG Root X2

この 4 つの候補は、時間が経つにつれてまったく異なる振る舞いを見せます。どれを選ぶかが作業の大部分を占めます。SPKI ピン生成ツールは、同じ値をブラウザー内で計算し、設定スニペットを出力します。

ブラウザーが HPKP を断念した理由

HTTP 公開鍵ピンニング(RFC 7469、2015年)では、サイトが鍵のハッシュと max-age を含む Public-Key-Pins ヘッダーを送信でき、ブラウザーはその期間が過ぎるまで、これらの鍵を 1 つも含まないチェーンをすべて拒否しました。HPKP が廃れた原因は、次の 2 つの障害パターンです。

  • 自ら招く障害:鍵の紛失、CA の変更、あるいはタイプミスがあると、再訪したすべての訪問者が max-age の期限が切れるまでサイトに接続できなくなり、サーバー側から元に戻す手段はありませんでした。
  • 悪意のあるピンニング:サイト(またはその証明書の発行)を一時的に掌握した攻撃者は、攻撃者だけが持つ鍵のピンを送信でき、短時間の侵害を長期間のサービス拒否に変えることができました。

Chrome の削除告知は、理由として「導入率が非常に低い」ことと「サービス拒否と悪意のあるピンニングのリスク」を挙げています(chromestatus)。サポートはデスクトップ版と Android 版の Chrome 72 で削除され、Firefox もバージョン 72 でこれに続きました。ブラウザーには今も、ベンダー自身のドメイン向けの小さな組み込みピンリストが含まれていますが、サイトが自分をそこに追加することはできません。

HPKP に代わったのは、防止ではなく検出です。2018年以降、Chrome と Apple のプラットフォームは、Certificate Transparency のログが発行した署名付き証明書タイムスタンプがある場合にのみ、公的に信頼された TLS 証明書を受け入れます。そのため、自分のドメイン名に対するすべての証明書が公開され、監視できます。また、CAA レコードは、どの CA がそのドメインの証明書を発行してよいかを CA に伝えます。この 2 つを組み合わせることで、HPKP が対象としていた誤発行を、接続不能に陥るリスクなしに数時間以内に可視化できます。

何をピンニングするか

ピンの対象影響を受けない変化壊れるケース
リーフの鍵鍵を変えない更新新しい鍵による更新のたび(certbot ではこれがデフォルト)
発行元の中間証明書(YE2)リーフの更新と鍵の変更CA が中間証明書をローテーションしたとき。Let's Encrypt は複数の中間証明書をローテーションしており、バックアップの中間証明書からも「予告なしに、いつでも…発行を開始する可能性がある」
ルート(ISRG Root X2)中間証明書のローテーションCA を変更したとき、または CA が信頼されなくなったとき
オフラインで保管する鍵使われていない限り、あらゆる変化紛失した場合のみ

リーフの行は、デフォルトのままだと陥る罠なので特に強調しておきます。certbot --help all(v5.8.0)は --reuse-key をデフォルトで無効と説明しており、「秘密鍵を再利用しないのが Certbot のデフォルトの動作です」と記載しています。そのため、リーフのピンは次の更新で壊れます。しかも更新の間隔は短くなる一方です。CA/Browser Forum は TLS 証明書の有効期間の上限を、2026年3月以降は 200 日、2027年3月からは 100 日、2029年3月からは 47 日と定めており、Let's Encrypt のデフォルトは 2027年2月に 64 日になります。

中間証明書の行は気付きにくい落とし穴です。letsencrypt.org の証明書は YE2 から発行されていましたが、Let's Encrypt は YE1 と YE2(ECDSA)、YR1 と YR2(RSA)をローテーション中の中間証明書として挙げ、YE3 と YR3 をバックアップとしています(信頼の連鎖)。YE2 だけを含むピンのセットは、YE1 から発行された次の証明書で失敗します。

ルートのピンはこれらすべてに耐え、失敗するのは CA を切り替えたときだけです。これは思ったよりも頻繁に起こります。Chrome が、最も早い SCT が 2024年11月11日より後の Entrust 発行の証明書を信頼しないことにしたとき(Google Security Blog)、Entrust の顧客は全員、数か月以内に別の CA へ移行しなければなりませんでした。バックアップなしで Entrust のルートにピンニングしていたアプリは、その前にアプリ自体を更新する必要があったはずです。

一般的な妥協案は、現在の CA のルート(または両方のルート)に加えて、切り替え先になりうる 2 つ目の CA のルートをピンニングし、必要に応じて、緊急用の証明書のために自分で生成してオフラインで保管している鍵もピンニングすることです。現在の CA を Let's Encrypt、切り替え先を Google Trust Services とした場合、Debian の ca-certificates 20260816 から得られるピンは次のとおりです。

ルートのピン(/etc/ssl/certs/*.pem に同じ openssl パイプラインを適用)
C5+lpZ7tcVwmwQIMcRtPbsQtWLABXhQzejna0wHFr8M=  subject=C=US, O=Internet Security Research Group, CN=ISRG Root X1 notAfter=Jun  4 11:04:38 2035 GMT
diGVwiVYbubAI3RW4hB9xU8e/CH2GnkuvVFZE8zmgzI=  subject=C=US, O=Internet Security Research Group, CN=ISRG Root X2 notAfter=Sep 17 16:00:00 2040 GMT
mEflZT5enoR1FuXLgYYGqnVEoZvmf9c2bVBpiOjYQ0c=  subject=C=US, O=Google Trust Services LLC, CN=GTS Root R4 notAfter=Jun 22 00:00:00 2036 GMT
hxqRlPTu1bMS/0DITB1SSu0vd4u/8l8TjPgfaAp63Gc=  subject=C=US, O=Google Trust Services LLC, CN=GTS Root R1 notAfter=Jun 22 00:00:00 2036 GMT

ISRG Root X2 のピンは、letsencrypt.org が送信する最後の証明書(X1 によってクロス署名された X2)に一致します。トラストストアにある自己署名の X2 と同じ鍵だからです。まだ使っていない別の CA のルートをピンニングしておくのにコストはかからず、それがいざというときの脱出口になります。

Android:ネットワークセキュリティ構成

Android 7.0 以降は、マニフェストの android:networkSecurityConfig で参照される res/xml/network_security_config.xml からピンを読み込みます(ドキュメント)。

res/xml/network_security_config.xml:現在の CA のルートと 2 つ目の CA、有効期限付き
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
    <domain-config>
        <domain includeSubdomains="true">api.example.com</domain>
        <pin-set expiration="2027-10-01">
            <!-- ISRG Root X2 and X1: the current CA, ECDSA and RSA chains -->
            <pin digest="SHA-256">diGVwiVYbubAI3RW4hB9xU8e/CH2GnkuvVFZE8zmgzI=</pin>
            <pin digest="SHA-256">C5+lpZ7tcVwmwQIMcRtPbsQtWLABXhQzejna0wHFr8M=</pin>
            <!-- GTS Root R4 and R1: the CA we would switch to -->
            <pin digest="SHA-256">mEflZT5enoR1FuXLgYYGqnVEoZvmf9c2bVBpiOjYQ0c=</pin>
            <pin digest="SHA-256">hxqRlPTu1bMS/0DITB1SSu0vd4u/8l8TjPgfaAp63Gc=</pin>
        </pin-set>
    </domain-config>
</network-security-config>

ドキュメントに書かれている重要なルールは次のとおりです。

  • チェーンは「ピンニングされた公開鍵のうち少なくとも 1 つを含む」場合に受け入れられます。使えるダイジェストは SHA-256 だけです。
  • expiration(yyyy-MM-dd)を過ぎると、ピンニングは行われなくなります。これにより、存在を忘れてしまった古いインストールも動作し続けます。一方でドキュメントは、更新されていないアプリが入ったデバイスでは、これが「攻撃者にピンニングされた証明書の回避を許す可能性がある」とも警告しています。日付は、次にピンを更新する予定日からリリースサイクル 1 回分ほど後に設定してください。
  • <debug-overrides> 内のトラストアンカーは、アプリがデバッグ可能な場合にピンを迂回します。開発中にトラフィックを調べるときはこの仕組みを使います。<certificates> 要素に overridePins="true" を指定するとリリースビルドでも同じことが起こるため、これを含めたまま出荷すべきではありません。
  • Android 16 では同じファイルに Certificate Transparency のチェック(<certificateTransparency enabled="true"/>)が追加され、Android 17 からは、このバージョンをターゲットとするアプリでデフォルトで有効になります(カスタムのトラストアンカーを使う接続を除く)。多くのアプリにとって、これこそがピンニングに求めていた保護です。

Google 自身のガイダンスは率直です。ピンニングは「Android アプリには推奨されません」とし、行う場合は「完全に自分の管理下にある鍵を少なくとも 1 つ含む複数のバックアップのピンと、十分に短い有効期限」を含めるよう求めています(ネットワークプロトコルのセキュリティ)。

OkHttp

OkHttp の CertificatePinner は、Android でも、ネットワークセキュリティ構成が存在しない JVM でも動作します。このテストでは、まず letsencrypt.org を ISRG Root X2(バックアップとして X1)にピンニングし、次にどの CA も証明書を発行していない鍵にピンニングします。後者は、サーバーがピンニングされていない CA に移行した後にアプリが目にする状態です。

Pin.java(OkHttp 5.5.0、Okio 3.18.1、Kotlin stdlib 2.1.21、Java 25)
// OkHttp CertificatePinner against letsencrypt.org: a correct CA-key pin, a backup pin, and a wrong pin.
import java.io.IOException;
import okhttp3.CertificatePinner;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;

public class Pin {
    static void get(String label, CertificatePinner pinner) {
        OkHttpClient client = new OkHttpClient.Builder().certificatePinner(pinner).build();
        Request request = new Request.Builder().url("https://letsencrypt.org/robots.txt").build();
        try (Response response = client.newCall(request).execute()) {
            System.out.println(label + ": HTTP " + response.code());
        } catch (IOException e) {
            System.out.println(label + ": " + e.getClass().getName() + ": " + e.getMessage());
        }
    }

    public static void main(String[] args) {
        // ISRG Root X2 key, plus a backup pin for ISRG Root X1 (RSA): either satisfies the pin set.
        get("pinned to ISRG Root X2 (+ X1 backup)", new CertificatePinner.Builder()
            .add("letsencrypt.org", "sha256/" + args[0])
            .add("letsencrypt.org", "sha256/" + args[1])
            .build());
        // A key that is not in the chain: what a client sees after the site moves to another CA.
        get("pinned to an unrelated key", new CertificatePinner.Builder()
            .add("letsencrypt.org", "sha256/" + args[2])
            .build());
    }
}
java -Djava.net.preferIPv4Stack=true -cp okhttp-jvm-5.5.0.jar:okio-jvm-3.18.1.jar:kotlin-stdlib-2.1.21.jar Pin.java …
pinned to ISRG Root X2 (+ X1 backup): HTTP 200
pinned to an unrelated key: javax.net.ssl.SSLPeerUnverifiedException: Certificate pinning failure!
  Peer certificate chain:
    sha256/x8ltqh+zNPYG7RZS+IjFymndoy6YAWLPaSkptqebzfc=: CN=letsencrypt.org
    sha256/s/tdAOmUzd8syaTuqfgGvFcn6DzA5Cmb+Vby1ST+U3Y=: CN=YE2, O=Let's Encrypt, C=US
    sha256/sCkq5UWXjg+7mKu9lMhhYF5bGLsy7VI/UNW3tccdR7w=: CN=Root YE, O=ISRG, C=US
    sha256/diGVwiVYbubAI3RW4hB9xU8e/CH2GnkuvVFZE8zmgzI=: CN=ISRG Root X2, O=Internet Security Research Group, C=US
  Pinned certificates for letsencrypt.org:
    sha256/VIJvW/e8yv/LvjcOADWb4C9aNclxEiQeW0tr4SvazsA=

失敗メッセージには、OkHttp が検証したチェーンのピンが、トラストアンカーに至るまで列挙されます。OkHttp 自身のドキュメントも、まさにこれを設定方法として勧めています。信頼できるネットワーク上でわざと壊れたピンから始め、例外から期待される値をコピーするという方法です。ピンは上の OpenSSL の出力と一致します。同じドキュメントコメントには、変更を承認する人に改めて伝えておきたい警告も書かれています。「サーバーの TLS 管理者のお墨付きなしに証明書ピンニングを使ってはいけません!」また、CertificatePinner は通常の検証の後にしか実行されないため、自己署名証明書を受け入れさせることはできません。

デバッグ中に実際にはまった罠もあります。コンテナーには IPv6 の経路がなく、-Djava.net.preferIPv4Stack=true を付けないと、2 回目の呼び出しは java.net.SocketException: Network is unreachable を報告しました。OkHttp 5 は複数のアドレスに並行して接続を試みるため(「ファストフォールバック」)、IPv4 アドレスでのピンニングの失敗は、IPv6 の接続エラーの陰で抑制された例外になっていました。fastFallback(false) を指定すると、ピンニングの失敗が主たる例外になりました。本番環境でピンニングが失敗したときは、getSuppressed() もログに記録してください。

iOS と macOS:NSPinnedDomains

iOS 14 と macOS 11 以降、App Transport Security は Info.plist で宣言された鍵をピンニングします。Apple の記事『Identity Pinning』では、ドメインごとに 2 つのリストが説明されています。NSPinnedCAIdentities(鍵がチェーンの中間証明書またはルートに含まれていなければならない)と NSPinnedLeafIdentities(サーバー自身の鍵)で、各エントリは同じ OpenSSL パイプラインで計算した SPKI-SHA256-BASE64 の値です。

Info.plist(抜粋):letsencrypt.org を ISRG Root X2 の鍵にピンニング
<key>NSAppTransportSecurity</key>
<dict>
    <key>NSPinnedDomains</key>
    <dict>
        <key>letsencrypt.org</key>
        <dict>
            <key>NSPinnedCAIdentities</key>
            <array>
                <dict>
                    <key>SPKI-SHA256-BASE64</key>
                    <string>diGVwiVYbubAI3RW4hB9xU8e/CH2GnkuvVFZE8zmgzI=</string>
                </dict>
            </array>
        </dict>
    </dict>
</dict>

この動作を手元のマシン(macOS 27.0.1、Apple M4 Max、Swift 6.4)で確認しました。URLSession で https://letsencrypt.org/robots.txt を取得するコマンドラインツールを、swiftc main.swift -Xlinker -sectcreate -Xlinker __TEXT -Xlinker __info_plist -Xlinker Info.plist で 3 回ビルドしました。ピンなし、上記の X2 のピンあり、OkHttp のテストで使った無関係な鍵のピンありの 3 通りです。

3 つのビルドの出力
HTTP 200
HTTP 200
error: NSURLErrorDomain -1200: A TLS error caused the secure connection to fail.

アプリが受け取るのは、ピンニング固有のコードではなく汎用的な NSURLErrorSecureConnectionFailed(-1200)です。そのため、アプリの各バージョンがどのピンのセットを含めて出荷されたかを記録しておいてください。記録がないと、サーバーの変更後に起きたピンの不一致は、クラッシュレポート上でほかの TLS の失敗と見分けがつきません。NSIncludesSubdomains がピンを適用するのは 1 階層下のサブドメインまでです(math.example.org には適用されますが、advanced.math.example.org には適用されません)。Apple のアドバイスも Google と同じで、「ほとんどの場合、ピンニングは不要であり、避けるべきです」としたうえで、ピンニングするなら CA の鍵を、それも複数ピンニングするよう勧めています。

誰も締め出さずにピンをローテーションする

ピンのセットは将来についての約束であり、それを持つアプリは 1 年間更新されないこともあります。うまくいくローテーションの手順は次のとおりです。

  1. 必要になる前に新しいピンを出荷する:次の CA のルートまたは次の鍵をリリースでピンのセットに追加し、それを含まないバージョンを使うユーザーが、接続できなくなっても許容できる割合に減るまで待ちます。
  2. その後で初めてサーバーを変更する:CA または鍵を切り替えます。古いバージョンのアプリにある古いピンも、新しいチェーンのいずれかの鍵を含んでいる限り、引き続き一致します。
  3. 後のリリースで使わなくなったピンを削除します。
  4. 緊急時の手段を残しておく:ピンニングしてあるものの一度もデプロイしていないオフラインの鍵があれば、緊急時にどの CA からでもその鍵の証明書を取得できます。署名付きのリモート設定からピンの更新を取得するアプリもあります。その場合、そのファイルには TLS のインフラとは別に保管する専用の署名鍵が必要です。
  5. Android では有効期限を設定し、形式的なものではなく、次のローテーションの期限として扱います。
  6. 失敗をテストする:トラストストアで信頼されるもののピンのセットには含まれない証明書を持つプロキシに対してアプリを実行し、アプリが接続を拒否する側に倒れ(フェイルクローズ)、役に立つメッセージを表示することを確認します。

企業の TLS インスペクションプロキシは、その仕組み上、ピンニングしたアプリを動作させなくします。プロキシの証明書は、ピンニングされていない企業のルートにチェーンするからです。Android では、API レベル 24 以降をターゲットとするアプリは、設定で追加しない限りユーザーがインストールした CA を信頼しません。また、overridePins を設定しない限り、そうした CA にもピンが適用されます。

よくある間違い

  • リーフだけをピンニングする:鍵をローテーションする ACME クライアントでは、次の更新がそのまま障害になります。
  • 中間証明書を 1 つだけピンニングする:複数の中間証明書をローテーションする CA では、CA が別の中間証明書を選ぶまでしか機能しません。
  • バックアップのピンがない、またはバックアップが同じ CA のもの:その CA が信頼されなくなると、両方のピンが同時に失敗します。
  • SPKI ではなく証明書のハッシュをピンニングする:同じ鍵を使っていても、更新のたびに壊れます。
  • 自作のトラストマネージャー:「ピンニングを追加する」ためにプラットフォームの検証を置き換えると、ホスト名やチェーンのチェックを誤って取り除いてしまうことがよくあります。通常の検証に上乗せしてピンニングを行うネットワークセキュリティ構成、CertificatePinner、NSPinnedDomains を使ってください。
  • 侵害されたデバイスからもピンが守ってくれると期待する:root 化や脱獄をしたデバイスでは、ユーザー(またはマルウェア)がピンニングをパッチで無効にできます。ピンニングはネットワークからユーザーを守るものであり、ユーザーから API を守るものではありません。

出典