
2026/06/06 22:52
Go の X.509 証明書認証を欺く方法
RSS: https://news.ycombinator.com/rss
要約▶
日本語訳:
最も重要な発見は、Go の X.509 バリデーターが、ASN.1 タグタイプの厳密なバイトレベルの不整合により有効な証明書チェーンを誤って拒否している点です。一方、OpenSSL はそれを受け入れます。具体的には、ルート CA(
ca.crt.pem)は発行元の名称に対して PRINTABLESTRING タグを使用しており、リーフ証明書に対応するフィールドは UTF8String です。両方の証明書は視覚的に見かけ上同一であり、OpenSSL はこれらを同等とみなして受け付けるため(openssl verify が成功します)、Go はタグにおいてバイト単位での完全な整合性を要求します。この不一致の理由は、Go の CertPool.byName マップのキーが cert.RawSubject から得られる生バイトであることにあります。したがって、リーフの発行元 (UTF8STRING) が CA の主題 (PRINTABLESTRING) と一致しない場合に不整合が発生します。デバッグ結果では、buildChains 段階で親証明書として 0 つのマッチが見つかり、「未知の権限によって署名された証明書」というパニックが引き起こされます。この問題はこの特定のケースに限られません。将来、発行元と主題の間でエンコーディングが異なる任意の例でも、おそらく同様の失敗パターンを再現するでしょう。その結果、一貫性のない ASN.1 タグ調和化を採用してシステムを展開する組織は、潜在的な検証の破綻に備えるか、混合された証明書ソースを使用する際に予期せぬ接続障害を防ぐための代替策を実施することを見越す必要があります。本文
X.509 証明書の互換性問題:OpenSSL と Go で検証結果が不一致する理由
1. 問題の概要
2 つの X.509 証明書(ルート CA とリーフ)を用いた検証において、OpenSSL では正常に認証通过了いますが、Go プログラムでは「不明の権限による署名」としてエラーが返されます。
- ca.crt.pem: ローター証明書(自己署名)
- Issuer: Root CA (subject key identifier:
で始まる)0x13
- Issuer: Root CA (subject key identifier:
- leaf.crt.pem: リーフ証明書
- Issuer: Root CA (UTF8String で定義されている)
Go の検証処理では、以下のエラーが発生します。
panic: x509: certificate signed by unknown authority
2. 根本原因:ASN.1 エンコーディングの違い
一見すると同一の証明書であるかのような
ca.crt.pem と検証に成功するサンプル ca.verifies.crt.pem ですが、バイナリデータ(DER 形式)には細かな差異があります。
バイトレベルでの違い
両方のファイルを表示すると、目視では識別できませんが、ハッシュ比較やバイナリダンプを行うと以下の違いが見つかります。
diff <(openssl x509 -in ca.crt.pem -outform der | xxd) \ <(openssl x509 -in ca.verifies.crt.pem -outform der | xxd)
出力例:
4c4 < 00000030: 1231 1030 ... --- > 00000030: 1231 1030 ... 8c8 < 00000070: 1307 526f ... --- > 00000070: 0c07 526f ...
差異の詳細(Subject/Issuer フィールド)
OpenSSL の解析結果
asn1parse から、異なるバイトの位置を確認できます。
| 証明書 | 文字列内容 | ASN.1 データタイプ | バイト値 (最初の) | 備考 |
|---|---|---|---|---|
| ca.crt.pem (失敗) | "Root CA" | | 0x13 | Go で検出に失敗 |
| ca.verifies.crt.pem (成功) | "Root CA" | UTF8String | 0x0c | 標準的な形式 |
| leaf.crt.pem | "Root CA" | UTF8String | 0x0c | リーフが参照する親は UTF8String |
技術的な背景
- ASN.1 の仕様では、文字列には
(0x13)、PRINTABLESTRING
(0x0c) など複数のタグが存在します。UTF8String - Go の検証ロジック は、厳密なバイト列(Raw Subject / Raw Issuer)比較に基づいています。
- 証明書を
に登録した際に入力される鍵(キー)はCertPool
です。cert.RawSubject - リーフが参照しているのは、署名された親の証明書の
です。AuthorityKeyId
- 証明書を
- 問題点: 検証に失敗する CA (
) の Subject はca.crt.pem
で定義されていますが、リーフが期待している(あるいは OpenSSL が許容する)親はPRINTABLESTRING
です。Go のロジックにおいて、異なる ASN.1 タグを持つ文字列を「同一のエンティティ」として扱いません。UTF8STRING
補足: 通常、同じツールで生成された証明書ペアならこの不一致は起こりません。しかし、リーフ証明書の寿命が短いため、ツールのバージョン進化や互換性のない生成プロセスとの組み合わせで不整合が生じることがあります。
3. Go の検証ロジック詳細
Go の
crypto/x509 パッケージ内での検証フローを見てみましょう。
検証オプションの構築
opts := x509.VerifyOptions{ Roots: roots, // 登録されたルート証明書プール CurrentTime: time.Now(), // 検証時刻 }
チェイン構築と候補検索
Verify() 関数内で、候補の証明書のチェーンを構築します。まず findPotentialParents() が呼び出され、Roots プールの中からリーフ(c)の親を探す処理が行われます。
// 簡略化されたロジック for _, root := range opts.Roots.findPotentialParents(c) { considerCandidate(rootCertificate, root) }
エラー発生箇所
最終的に、Subject と Issuer の一致が判定されます。ここでは、証明書の
CertPool 内の byName マップを参照しています。
// CertPool の byName map: RawSubject (バイト列のハッシュまたは比較) => インデックス var candidateChains [][]*Certificate for _, c := range s.byName[string(cert.RawIssuer)] { // ⚠️ ここが厳密なバイト比較になっている candidate, constraint, err := s.cert(c) if err != nil { continue } // SubjectKeyIdentifier の一致も確認される kidMatch := bytes.Equal(candidate.SubjectKeyId, cert.AuthorityKeyId) // ... (ロジック分岐略) }
発生する問題:
というコードは、Raw Subject(バイト列そのもの)をキーとして使用します。s.byName[string(cert.RawIssuer)]
では Issuer 部分の文字列がca.crt.pem
だが、PRINTABLESTRING
の参照元や OpenSSL の許容範囲ではleaf.crt.pem
と等価と見なす必要があります。UTF8String- バイト列が完全に一致しないため(0x13 vs 0x0c)、ループ内で一致する親が見つからず、検証エラーが発生します。
4. ディバグと確認方法
Go のプログラムをコンパイルし、ブレークポイントでデバッグして挙動を確認できます。
# GDB を使用したデバッグ例 gdb main -ex 'b crypto/x509.(*Certificate).Verify' -ex 'run'
具体的な調査手順
- 証明書のバイナリ比較:
で確認。openssl x509 -in ... -outform der | xxd - ASN.1 パース:
でタグの違い(0x13 vs 0x0c)を確認。openssl asn1parse -in ... - Go の内部変数確認: バイト列のハッシュ値や RawSubject を直接確認する。
5. 結論と対策
この現象は、ASN.1 データタイプの厳密比較が Go の証明書検証ロジックにおけるボトルネックとなっています。OpenSSL など他のツールでは柔軟に「同一内容」を許容しますが、Go はバイトレベルの整合性を要求します。
推奨事項
- 生成ツールの統一: CA サーティフィケートおよびリーフ証明書は、互換性のある設定(UTF8String を優先する等)で同じツール・手順で作成することを強く推奨します。
- 「Fail-Closed」の理解: Go は「失敗して終了する(fail-closed)」振る舞いを採用しています。検証エラーが出ても原因が不明瞭なケースがあるため、システム設計上当該リスクを許容するか、代替策(手動確認や異なるライブラリの使用)を検討する必要があります。
- 未来の注意点: 証明書の有効期限(Not After)に注意してください。本記事中の証明書は 2126 年 に切れますが、現在のシステム時刻とは一致しない可能性があります。検証環境の時刻設定も正しいことを確認しましょう。
# 時刻の確認 date