TLS 1.3 (RFC 8446) 0-RTT再生攻撃耐性と DNSSEC / DANE / MTA-STS 暗号検証パイプライン
著者:加藤 明生(株式会社オープンナレッジ 創業者・代表取締役CTO / ネットワークスペシャリスト / 登録情報処理安全確保支援士(第007617号))
この記事の要点と4つの章のご案内
| 章構成 | 主なテーマ | 要点と確認ポイント |
|---|---|---|
| 1. 基本と全体像 | 要旨と脅威モデル:BGPハイジャック・StripTLS・0-RTT再生攻撃の構造的解剖 | WebブラウザやOSのルート証明書ストアには数百の商用・公的認証局(CA)が登録されており、原則としてどのCAでも任意のドメインに対する証明書を発行できる構造(Weakest-Link Model)になっています。万一、BGP経路ハイジャックによってドメイン制御検証(DV)通信が傍受された場合、攻撃者は正規CAから有効な証明書を即時取得できてしまいます。DNSSEC署名とDANE TLSA(RFC 6698)およびCAAレコード(RFC 8659)を組み合わせることで、信頼の起点(Trust Anchor)を特定ドメイン所有者の公開鍵へ暗号学的に束縛できます。 |
| 2. 現場での進め方と対策 | Curve25519 (X25519) 楕円曲線鍵共有と HKDF-Expand-Label 鍵スケジュール数理 | SMTPのSTARTTLS拡張(RFC 3207)は本来「日和見暗号化(Opportunistic TLS)」であるため、中間者(MitM)が平文のEHLO応答から「250-STARTTLS」の1行を削除するだけで、送信側メールサーバーは平文のままメール本文を送信してしまいます。これに対し、DNSSEC上で公開鍵ハッシュを宣言するDANE(RFC 7672)と、HTTPS経由で暗号化強制ポリシーを配布するMTA-STS(RFC 8461)を併用することで、TLS 1.3接続と証明書検証が成功しない限りメール配送をフェイルクローズで遮断します。 |
| 3. 関連する制度とルール | DNSSEC署名連鎖と DANE TLSA (3 1 1) SPKIピンニング・MTA-STS の二重防壁 | TLS 1.3の0-RTTモードは初回のClientHelloと同時にアプリケーションデータを送信できる反面、前方秘匿性(Forward Secrecy)が完全ではなく、ネットワーク上の盗聴者が同じ0-RTTパケットをそのまま再送する再生攻撃(Replay Attack: RFC 8446 Section 8)が成立します。当社の実装では、状態変更を伴うリクエストでの0-RTTを拒否するとともに、ClientHelloのobfuscated_ticket_age時計ずれ検査と二層スライディング・ブルームフィルタ(Strike Register)により同一Early Dataの二重実行を決定論的に排除しています。 |
| 4. 詳しい技術仕様としくみ | なぜ従来のWeb PKI(商用認証局による証明書発行)だけでは、高度な経路ハイジャックや偽証明書発行を防ぎきれないのですか? | 著者:加藤 明生(株式会社オープンナレッジ 創業者・代表取締役CTO / ネットワークスペシャリスト / 登録情報処理安全確保支援士(第007617号)) |
1. 要旨と脅威モデル:BGPハイジャック・StripTLS・0-RTT再生攻撃の構造的解剖
トランスポート層セキュリティ(TLS)および電子メール配送基盤(SMTP)の安全性は、しばしば「TLS 1.3が有効化されているか」という単一の指標のみで語られます。しかし、実世界のインターネット経路(BGP: Border Gateway Protocol)および名前解決基盤(DNS)が攻撃者の介入下に置かれた場合、単なるTLS有効化だけでは防ぎきれない3つの重大な暗号学的攻撃面が露呈します。
第1の脅威は、BGP Sub-prefix Hijacking を悪用した不正なドメイン検証(DV)証明書の発行です。攻撃者が対象のIPプレフィックスより長いサブネットマスク(例:/24)を不正広告してトラフィックを局所的に引き込み、ACME HTTP-01 / DNS-01 チャレンジに応答すると、公開CAから正規のX.509証明書が発行されてしまいます。第2の脅威は、SMTP通信における STARTTLS ダウングレード攻撃(StripTLS)と不正MX誘導です。平文のDNS MX応答が改ざんされるか、またはSMTPセッションの 250-STARTTLS 応答が中間者に削ぎ落とされた場合、従来のメールサーバーは暗号化を諦めて平文で機密メールを送出してしまいます。第3の脅威は、TLS 1.3 の 0-RTT(Early Data)における再生攻撃(Replay Attack)です。
本論文では、当社エッジ基盤(ap01.ok.st)および3重冗長メールクラスタ(mx1..mx3.ok.st)に実装されている、(1) Curve25519上のX25519一時鍵共有とHKDF鍵スケジュール、(2) スライディング・ブルームフィルタによる0-RTT再生防御、(3) DNSSEC RRSIG連鎖とDANE TLSA(3 1 1)SPKIピンニング、および (4) MTA-STS(RFC 8461)の統合検証アーキテクチャを定式化します。
| ATTACK VECTOR | 脆弱な標準構成(Web PKI単体 / Opportunistic STARTTLS) | 当社暗号検証パイプライン(TLS 1.3 + DNSSEC/DANE + MTA-STS) |
|---|---|---|
| Rogue CA / BGP DV Hijack | 任意のパブリックCAが発行した証明書を無条件に信頼 | DNS CAA (RFC 8659) 制限 + DANE TLSA (3 1 1) SPKI SHA-256 厳密一致 |
| SMTP StripTLS Downgrade | 250-STARTTLS 削除時に平文配送へフォールバックし本文漏洩 | DANE + MTA-STS (mode: enforce) により TLS 検証失敗時は即時配信拒否 |
| TLS 1.3 0-RTT Replay | Early Data パケットの再送によりAPI更新処理が複数回実行される | 非べき等メソッドの HTTP 425 (Too Early) 拒否 + Strike Register 二重排除 |
| Static RSA Key Compromise | 過去の録画済みパケットが秘密鍵漏洩時に遡及復号される | TLS 1.3 Ephemeral X25519 / P-256 のみ許可(完全前方秘匿性 PFS 保証) |
2. Curve25519 (X25519) 楕円曲線鍵共有と HKDF-Expand-Label 鍵スケジュール数理
TLS 1.3(RFC 8446)では、静的RSA鍵交換が完全に廃止され、すべてのハンドシェイクで一時楕円曲線ディフィー・ヘルマン鍵共有(ECDHE)が強制されます。当社のエッジノード ap01.ok.st では、素数体 𝔽p(ただし p = 2255 − 19)上のモンゴメリ曲線 Curve25519(RFC 7748 X25519) を第一優先鍵共有グループ(NamedGroup 0x001d)として採用しています。定数時間モンゴメリ・ラダー演算により、キャッシュタイミング攻撃(サイドチャネル漏洩)をハードウェア命令レベルで排除します。
共有秘密 Zecdhe および事前共有鍵(PSK / セッション再開チケット)から、RFC 5869 の HMAC-based Extract-and-Expand Key Derivation Function(HKDF)を用いて3段階のシークレット(Early Secret, Handshake Secret, Master Secret)を順次導出します。各段階では、ハンドシェイクメッセージ全体のトランスクリプトハッシュ Transcript-Hash(M1 ∥ … ∥ Mn) が HKDF-Expand-Label に入力されるため、ハンドシェイク途中のビット改ざんやダウングレード試行は Finished メッセージの検証時点で100%検知されます。
0-RTT Early Data 再生攻撃の数理と二層ブルームフィルタ防御
上式が示す通り、client_early_traffic_secret は一時鍵共有値 Zecdhe(ServerHelloで確定)に依存せず、PSKと ClientHello のみから決定されます。したがって、攻撃者がネットワーク経路上で 0-RTT パケットをキャプチャして再送した場合、サーバーが状態を記憶していない限り同一の復号結果が得られてしまいます。これを防ぐため、RFC 8446 Section 8 に基づき以下の3重防御を適用します。
3. DNSSEC署名連鎖と DANE TLSA (3 1 1) SPKIピンニング・MTA-STS の二重防壁
DANE(DNS-Based Authentication of Named Entities: RFC 6698 / RFC 7672)は、TLSサーバーのX.509証明書またはSubjectPublicKeyInfo(SPKI)のハッシュ値を、DNSSEC署名された TLSA リソースレコード(RRType = 52)として権威DNSゾーンに直接束縛する規格です。
当社では、証明書の自動更新(Let's Encrypt等の90日ローテーション)時に毎回DNSゾーンのTLSAレコードを書き換える運用負荷と伝播遅延(TTL問題)を排除するため、Selector = 1(SubjectPublicKeyInfoのみを対象)かつ Usage = 3(DANE-EE:Domain-Issued Certificate)である 3 1 1 構成と、現用鍵(Current Key)+次期予備鍵(Rollover Backup Key)のデュアルTLSAレコード公開方式を採用しています。これにより、X.509証明書のシリアル番号や有効期限が更新されても、SPKI公開鍵または予約済み次期鍵のSHA-256ダイジェストが一致する限り、無停止でゼロダウンタイム鍵ローテーションが完了します。
| FIELD / RFC | PRODUCTION VALUE | 暗号学的意味と選択理由(Engineering Rationale) |
|---|---|---|
| TLSA Certificate Usage | 3 (DANE-EE) / 2 (DANE-TA) | 外部CAの誤発行に影響されず、ドメイン管理者が指定したエンドエンティティ鍵(3)または専用CA鍵(2)のみを信頼 |
| TLSA Selector | 1 (SubjectPublicKeyInfo) | 証明書全体(0)ではなく公開鍵構造体 DER(SPKI) のみを対象とすることで、ACME自動更新時のTLSA不一致障害を防止 |
| TLSA Matching Type | 1 (SHA-256 / 32 Bytes) | DNS UDP/EDNS0 パケットサイズ肥大化を抑えつつ、2128 衝突耐性と 2256 第二原像計算困難性を確保 |
| MTA-STS Policy (RFC 8461) | mode: enforce, max_age: 604800 | DNSSEC未対応のリゾルバ経由でも、HTTPS Web PKI経由でMXホスト名パターンとTLS必須ポリシーを強制キャッシュ |
| SMTP TLS-RPT (RFC 8460) | _smtp._tls.ok.st TXT rua=... | 配送先MTAとのTLSネゴシエーション失敗・DANE不一致・STSポリシー違反のJSON統計テレメトリを日次自動収集 |
DANE/MTA-STSによる通信路暗号化の上位で動作するメッセージ署名・アライメント仕様。
TLSチャネル確立後の認証層において中間者フィッシング(AiTM)を排除する設計。
DNS/TLS/HTTPの複合設定不備をグラフ探索で継続検証するRust 2024エンジン。
DANE TLSA (3 1 1) SPKI ピン検証器・MTA-STS ポリシー評価器・0-RTT Strike Register の Rust 2024 実装
#![forbid(unsafe_code)]
/// Constant-time 32-byte SHA-256 digest comparison to prevent timing side-channels.
#[inline]
pub fn ct_eq_sha256(a: &[u8; 32], b: &[u8; 32]) -> bool {
let mut diff: u8 = 0;
let mut i = 0;
while i < 32 {
diff |= a[i] ^ b[i];
i += 1;
}
diff == 0
}
/// RFC 6698 DANE TLSA Record parameters.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct TlsaRecord {
/// 3 = DANE-EE (Domain-Issued Certificate), 2 = DANE-TA (Trust Anchor)
pub cert_usage: u8,
/// 1 = SubjectPublicKeyInfo (SPKI DER), 0 = Full Certificate
pub selector: u8,
/// 1 = SHA-256 (32 bytes)
pub matching_type: u8,
pub spki_sha256: [u8; 32],
pub dnssec_ad_flag: bool,
}
/// RFC 8461 MTA-STS Policy representation.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum MtaStsMode {
Enforce,
Testing,
None,
}
#[derive(Clone, Debug)]
pub struct MtaStsPolicy {
pub mode: MtaStsMode,
pub max_age_seconds: u64,
pub allowed_mx_patterns: Vec<&'static str>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TransportVerifyError {
DnssecBogusOrUnsigned,
UnsupportedTlsaProfile,
TlsaSpkiPinMismatch,
MtaStsMxHostForbidden,
MtaStsWeakTlsVersion,
ZeroRttClockSkewExceeded,
ZeroRttReplayDetected,
ZeroRttUnsafeMethodRejected,
}
impl TlsaRecord {
/// Verifies peer certificate SPKI SHA-256 digest against DNSSEC-authenticated TLSA (3 1 1).
pub fn verify_peer_spki(
&self,
peer_spki_sha256: &[u8; 32],
) -> Result<(), TransportVerifyError> {
if !self.dnssec_ad_flag {
return Err(TransportVerifyError::DnssecBogusOrUnsigned);
}
if (self.cert_usage != 3 && self.cert_usage != 2)
|| self.selector != 1
|| self.matching_type != 1
{
return Err(TransportVerifyError::UnsupportedTlsaProfile);
}
if !ct_eq_sha256(&self.spki_sha256, peer_spki_sha256) {
return Err(TransportVerifyError::TlsaSpkiPinMismatch);
}
Ok(())
}
}
impl MtaStsPolicy {
/// Validates MX hostname against RFC 8461 wildcard/exact policy and enforces TLS 1.3.
pub fn verify_mx_session(
&self,
candidate_mx: &str,
negotiated_tls_version: u16,
) -> Result<(), TransportVerifyError> {
// Require TLS 1.2 (0x0303) minimum; enforce TLS 1.3 (0x0304) on strict profile
if negotiated_tls_version < 0x0304 {
return Err(TransportVerifyError::MtaStsWeakTlsVersion);
}
let matched = self.allowed_mx_patterns.iter().any(|pattern| {
if let Some(suffix) = pattern.strip_prefix("*.") {
candidate_mx.ends_with(suffix)
&& candidate_mx.len() > suffix.len()
&& candidate_mx.as_bytes()[candidate_mx.len() - suffix.len() - 1] == b'.'
} else {
candidate_mx.eq_ignore_ascii_case(pattern)
}
});
if !matched && self.mode == MtaStsMode::Enforce {
return Err(TransportVerifyError::MtaStsMxHostForbidden);
}
Ok(())
}
}
/// RFC 8446 Section 8: Dual-Epoch Sliding Bloom Filter (Strike Register) for 0-RTT Anti-Replay.
pub struct ZeroRttStrikeRegister {
active_bits: [u64; 64],
previous_bits: [u64; 64],
epoch_start_ms: u64,
freshness_window_ms: u64,
}
impl ZeroRttStrikeRegister {
pub const fn new(epoch_start_ms: u64, freshness_window_ms: u64) -> Self {
Self {
active_bits: [0u64; 64],
previous_bits: [0u64; 64],
epoch_start_ms,
freshness_window_ms,
}
}
#[inline]
fn bloom_indices(client_random: &[u8; 32], psk_binder: &[u8; 32]) -> [usize; 4] {
let mut h0: u64 = 0x243F_6A88_85A3_08D3;
let mut h1: u64 = 0x1319_8A2E_0370_7344;
let mut i = 0;
while i < 32 {
h0 = (h0 ^ (client_random[i] as u64)).wrapping_mul(0x1000_0000_01B3);
h1 = (h1 ^ (psk_binder[i] as u64)).wrapping_mul(0x1000_0000_01B3);
i += 1;
}
[
(h0 as usize) & 4095,
((h0 >> 16) as usize) & 4095,
(h1 as usize) & 4095,
((h1 >> 16) as usize) & 4095,
]
}
/// Validates TLS 1.3 0-RTT Early Data against method safety, ticket age skew, and replay cache.
pub fn validate_and_record_0rtt(
&mut self,
is_safe_http_method: bool,
obfuscated_ticket_age_ms: u32,
ticket_age_add_ms: u32,
ticket_issued_at_ms: u64,
server_now_ms: u64,
client_random: &[u8; 32],
psk_binder: &[u8; 32],
) -> Result<(), TransportVerifyError> {
if !is_safe_http_method {
return Err(TransportVerifyError::ZeroRttUnsafeMethodRejected);
}
// Rotate bloom filter epoch when freshness window elapses
if server_now_ms.saturating_sub(self.epoch_start_ms) >= self.freshness_window_ms {
self.previous_bits = self.active_bits;
self.active_bits = [0u64; 64];
self.epoch_start_ms = server_now_ms;
}
let claimed_age = obfuscated_ticket_age_ms.wrapping_sub(ticket_age_add_ms) as u64;
let actual_age = server_now_ms.saturating_sub(ticket_issued_at_ms);
let skew = claimed_age.abs_diff(actual_age);
if skew > self.freshness_window_ms {
return Err(TransportVerifyError::ZeroRttClockSkewExceeded);
}
let indices = Self::bloom_indices(client_random, psk_binder);
let mut seen_in_active = true;
let mut seen_in_prev = true;
for &idx in &indices {
let word = idx >> 6;
let mask = 1u64 << (idx & 63);
if (self.active_bits[word] & mask) == 0 {
seen_in_active = false;
}
if (self.previous_bits[word] & mask) == 0 {
seen_in_prev = false;
}
}
if seen_in_active || seen_in_prev {
return Err(TransportVerifyError::ZeroRttReplayDetected);
}
for &idx in &indices {
let word = idx >> 6;
let mask = 1u64 << (idx & 63);
self.active_bits[word] |= mask;
}
Ok(())
}
}