TLSで接続先のホスト名(SNI)を暗号化するEncrypted Client Hello(ECH)が、2026年3月にRFC 9849として発行されました。同時に、ECHの鍵をDNSで配る方法を定めたRFC 9848も出ています。ECHの設定はDNSのHTTPSレコード(RFC 9460)のechパラメータに載るので、HTTPSレコードを読めればECHの導入状況が分かります。
そこで、CAA・DNSSEC・DMARCの記事と同じ主要30ドメインについて、apexとwwwのHTTPSレコードを引き、echがあればRFC 9849の構造で中身まで解いてみました。結果は、HTTPSレコードがあるのは30ドメイン中6件、echを広告しているのは0件。ところが参照用に加えた自分のサイト(ac-5.net)には、Cloudflareが付けたechが載っていたというものでした。仕様の確認、実測、スクリプト、落とし穴の順にまとめます。
RFC で確認した要点
HTTPSレコードの形式はRFC 9460(2023年11月)、echパラメータはRFC 9848(2026年3月)、ECHの設定構造はRFC 9849(同月)で確認しました。
HTTPSレコードの形式(RFC 9460 2.1、2.2)
提示形式はSvcPriority TargetName SvcParamsの3つです。SvcParamsはkey=valueの空白区切りで、同じキーは1回しか書けません。提示形式では順序は自由ですが、ワイヤ形式ではキー番号の昇順に並び、昇順でなければ不正です。そして2.2節には厳しい規定があります。
If any RRs are malformed, the client MUST reject the entire RRset and fall back to non-SVCB connection establishment.
RRsetに1本でも不正なレコードがあると、クライアントはHTTPSレコードを全部捨てて従来の接続に戻る、ということです。初期のキーと番号は次のとおりです。
番号 キー名 意味 出典
0 mandatory このRRで必須のキー RFC 9460 8
1 alpn 対応プロトコル(ALPN ID の列) RFC 9460 7.1
2 no-default-alpn 既定プロトコルに非対応 RFC 9460 7.1
3 port 接続先ポート RFC 9460 7.2
4 ipv4hint IPv4 アドレスのヒント RFC 9460 7.3
5 ech TLS Encrypted ClientHello Config RFC 9848(9460 では RESERVED)
6 ipv6hint IPv6 アドレスのヒント RFC 9460 7.3
ワイヤ形式(2.2): SvcPriority(2) | TargetName(圧縮なし、長さ付きラベル列)| (キー番号(2) 長さ(2) 値)* ← キー番号の昇順
ech(番号5)は、RFC 9460の時点では「RESERVED (held for Encrypted ClientHello)」と予約されているだけで、RFC 9848が2年あまり後に中身を定義しました。
AliasModeとServiceMode、「.」の意味(2.4、2.5)
SvcPriorityが0ならAliasMode。TargetNameへ別名を張る。apexではCNAMEが使えないので、その代わりになる。AliasModeのレコードがあれば同じRRsetのServiceModeは無視。SvcParamsが付いていても無視- 0以外ならServiceMode。数字が小さいほど優先。同じ優先度のものはクライアントが無作為に並べ替える
- ServiceModeで
TargetNameが「.」なら所有者名そのものが接続先。CNAMEを辿った後なら、辿った先の名前 - AliasModeで「.」なら「このサービスは存在しない」の表明(助言的で、無視してもよい)
- AliasModeの連鎖にはクライアントが上限を設けなければならない(MUST)。上限値は実装任せだが0は不可
HTTPSに固有の規則(9、9.1)
- ポート443のhttpsなら、問い合わせ名はホスト名そのもの(SVCBのような
_httpsプレフィックスを付けない)。だからCDN側のCNAME先に置いたHTTPSレコードが、そのまま顧客ドメインのレコードとして効く - 既定のALPNは
http/1.1。alpn=h2,h3と書いてあっても、no-default-alpnが無ければhttp/1.1も集合に含まれる portとno-default-alpnは「自動的にmandatory」。これらを理解しないクライアントはそのレコードを無視しなければならない- HTTPSレコードが存在すること自体が「httpsで接続せよ」の合図になる(9.5。HTTPからのアップグレード)
ech パラメータ(RFC 9848 3〜5)
- ワイヤ形式の値はECHConfigList(RFC 9849 4節)をそのまま。「冗長な長さプレフィックスを含めて」。提示形式はそのBase64で、エスケープは使えない(MUST NOT)
- 公開する側の義務:
TargetNameのすべてのIPアドレスのサーバが、対応する秘密鍵を持つか、public_nameに対して権威を持つこと(MUST)。でなければ接続が丸ごと失敗する - クライアントの義務:SVCBの解決が終わるまでClientHelloを送ってはならない(MUST NOT)。すべての接続先に
echがあれば、HTTPSレコードを使わない接続へのフォールバックを捨てる(SVCB-reliant)。フォールバックを許すとECHの意味が無くなるため echのあるRRと無いRRが混在すると、ECH側だけ遮断されてECH無しへ落とされる。NOT RECOMMENDED
ECHConfigの構造と public_name(RFC 9849 4、4.1、6.1.7)
struct {
uint16 version; // 0xfe0d
uint16 length; // 次のフィールドの長さ(未知 version の読み飛ばし用)
select (ECHConfig.version) {
case 0xfe0d: ECHConfigContents contents;
}
} ECHConfig;
struct {
HpkeKeyConfig key_config; // config_id(1) kem_id(2) public_key<1..2^16-1> cipher_suites<4..2^16-4>
uint8 maximum_name_length; // 分かればバックエンド名の最大長。不明なら 0
opaque public_name<1..255>; // client-facing server の DNS 名
ECHConfigExtension extensions<0..2^16-1>;
} ECHConfigContents;
ECHConfig ECHConfigList<4..2^16-1>; // 優先順に 1 個以上
(RFC 9849 4 節より。cipher_suites の各要素は kdf_id(2) aead_id(2))
versionは0xfe0d。クライアントは未対応のversionを無視しなければならない(MUST)。lengthはそのための読み飛ばし用config_idは1バイト。クライアントがClientHelloで「どの鍵で暗号化したか」を示す。サーバは古い設定もしばらく持ち続けるべき(SHOULD)。クライアントがDNSをTTLまでキャッシュするため(4.1)public_nameはclient-facing serverのDNS名。ECHが拒否されたとき、クライアントはこの名前に対する証明書で接続を検証し、サーバから渡される新しい設定で再試行する(6.1.6〜6.1.7、8.1.1)。外側のClientHelloのSNIにはこの名前を入れる(SHOULD。8.1.2)public_nameは6.1.7の規則で検査する:LDHラベルのドット区切り、先頭・末尾のドット不可、各ラベル63オクテット以下、最終ラベルが全部数字、または0xで始まる16進ならIPv4リテラルと紛れるので無視(SHOULD)- 10.2節:ECHの設定は認証されていないDNSで配られる。DNSを改ざんできる攻撃者には対抗できないが、それはどのみちIPアドレスを差し替えられる相手なので「状況を大きく悪くはしない」。一方、DNSの問い合わせが平文ならSNIを隠しても意味が薄いので、暗号化DNSとの併用が前提
36ドメインの実測(2026年11月7日)
主要30ドメイン(海外10、日本20)のapexとwww、計60件に、参照としてac-5.net、ietf.org、crypto.cloudflare.com、defo.ie、cloudflare-ech.com、tls-ech.devの6ドメイン12件を加えました。DoH(dns.google)でtype 65を引き、応答のCNAMEも含めて記録しています。
主要30ドメインの HTTPS レコード(2026-11-07、dns.google、apex と www)
あり 6 / 30
google.com apex・www とも自前 1 . alpn=h2,h3
cloudflare.com apex・www とも自前 1 . alpn=h3,h2 ipv4hint=… ipv6hint=…
cybozu.com www → cloudfront.net 配下の CNAME 先に 1 . alpn=h2
so-net.ne.jp www → cloudfront.net 配下の CNAME 先に 1 . alpn=h2
line.me www → cloudfront.net 配下(CNAME 2 段) 1 . alpn=h2,h3
mercari.com www → cdn.cloudflare.net 配下の CNAME 先に 1 . alpn=h2 ipv4hint=…
なし 24 / 30
www が akamaiedge.net 配下の CNAME(12): microsoft.com, apple.com, amazon.com, salesforce.com,
softbank.jp, mufg.jp, smbc.co.jp, ana.co.jp, jal.co.jp, rakuten.co.jp, freee.co.jp, ntt.com
www が他の CNAME(5): github.com(→github.com), okta.com(→cdn.cloudflare.net), yahoo.co.jp(→yimg.jp),
sakura.ad.jp(→sakura.ne.jp), japanpost.jp(→incapdns.net)
www も apex も CNAME なし(7): zoom.us, slack.com, biglobe.ne.jp, ocn.ne.jp, kddi.com, moneyforward.com, nifty.com
ech あり 0 / 30
apex の AliasMode 0 / 30
no-default-alpn 0 / 30、port 0 / 30
参照 6 ドメイン
ac-5.net 1 . alpn=h3,h2 ipv4hint=… ech=… ipv6hint=… ← ech あり(public_name cloudflare-ech.com)
ietf.org 1 . alpn=h3,h2 ipv4hint=… ipv6hint=…(ech なし。DNSSEC AD)
crypto.cloudflare.com ech あり(public_name cloudflare-ech.com)
cloudflare-ech.com ech あり(自分自身が public_name)。www は NXDOMAIN
defo.ie ech あり:ECHConfig 3 個(public_name cover.defo.ie)。DNSSEC AD
tls-ech.dev ech あり:暗号スイート 2 組、maximum_name_length 64(public_name public.tls-ech.dev)
読み取れることをいくつか。
- 主要30でHTTPSレコードがあるのは6件。apex自身に置いているのはgoogle.comとcloudflare.comの2件だけで、残り4件は
wwwのCNAME先(cloudfront.net配下が3件、cdn.cloudflare.net配下が1件)に載っているものを、CNAME経由で受け取っている形です。9.1節が想定した「CDN側に置けば顧客ドメインに効く」構造がそのまま現れています - CNAME先がakamaiedge.net配下の12件(海外4、日本8)にはHTTPSレコードがありませんでした。同じcdn.cloudflare.net配下でも、www.mercari.comにはあってwww.okta.comには無い、という違いもあります。どちらも観測事実で、事業者側の設定理由までは分かりません
- apexでAliasMode(
HTTPS 0)を使っている例は0件。CDNを使う日本のドメインはapexにA/AAAAを直接置いています echを広告しているのは30件中0件。主要サービスのドメインでECHを有効にしているところは、この30件に限ればまだありません- HTTPSレコードがあった6件のALPNは
h2またはh2,h3。既定のhttp/1.1を外すno-default-alpnは0件、portも0件
自分のサイトには ech が付いていた
一方で、Cloudflareのプロキシ(オレンジ雲)越しに配信しているac-5.netには、こちらが何も設定していないのにechが付いていました。Base64を外してRFC 9849の構造で読むと次のようになります。
ech=AEX+DQBBbQAgACDPl6q/TzSEOl/lSZrnK3iyyWRYuSir/EOPKwFMwLKBcwAEAAEAAQASY2xvdWRmbGFyZS1lY2guY29tAAA=
Base64 を外した 71 バイト(16進)
00 45 ECHConfigList の長さ 69
fe 0d version 0xfe0d
00 41 ECHConfig の長さ 65
6d config_id = 109
00 20 kem_id = 0x0020 DHKEM(X25519, HKDF-SHA256)
00 20 cf 97 … 81 73 public_key(32 バイト)
00 04 00 01 00 01 cipher_suites: HKDF-SHA256 + AES-128-GCM の 1 組
00 maximum_name_length = 0
12 "cloudflare-ech.com" public_name(18 バイト)
00 00 extensions なし
public_nameはcloudflare-ech.com。つまりECH対応のクライアントは、外側のClientHelloではcloudflare-ech.comに接続するふりをして、暗号化した内側にac-5.netを入れて送ります。KEMはX25519、暗号スイートはHKDF-SHA256とAES-128-GCMの1組、maximum_name_lengthは0(不明)、拡張なしです。
面白いのは、同じ日に引いたapexとwwwでconfig_idが違ったこと(109と23。公開鍵も別)です。Cloudflareが鍵をローテーションしていて、dns.googleのキャッシュに2世代が混在していたと読めます。これはまさに4.1節が「古い設定も持ち続けるべき」と言っている状況で、サーバ側が両方の秘密鍵を保持していれば問題は起きません。crypto.cloudflare.comとcloudflare-ech.com自身も同じpublic_nameのechを持ちますが、cloudflare.com自身のHTTPSレコードにはechがありませんでした。
参照で加えたdefo.ieはECHConfigを3個(config_id 177、187、211、public_nameはいずれもcover.defo.ie)持ち、tls-ech.devは暗号スイートを2組(AES-128-GCMとChaCha20-Poly1305)、maximum_name_lengthに64を指定していました。4節の「ECHConfigListは優先順に複数持てる」「cipher_suitesはリスト」が実データで見える例です。
検証スクリプト
Python 3の標準ライブラリだけで動きます。HTTPSレコードをCNAMEとAliasModeを辿って取り、提示形式をワイヤ形式に組み立て直しながら検査し、echをECHConfigListとして解きます。--fixturesでテキストの応答を渡せばネットワークに出ません。
#!/usr/bin/env python3
"""
httpsrr.py - HTTPS レコード(RFC 9460)を読み、ech(RFC 9848)の中身を RFC 9849 の構造で解く
使い方:
./httpsrr.py <ホスト名> [--fixtures <txt>] [--json]
例: ./httpsrr.py ac-5.net
./httpsrr.py www.cybozu.com
やること:
1. HTTPS(type 65)を DoH(dns.google)で引く。応答の CNAME を辿って最終名の HTTPS RRset を取る
2. 各 RR の提示形式「SvcPriority TargetName SvcParams」を RFC 9460 2.1 / Appendix A の規則で解析し、
2.2 のワイヤ形式に組み立て直す(キーの重複・mandatory の整合・値の形式は、このとき検査する)
3. SvcPriority 0(AliasMode)なら TargetName を辿る(3 節。回数上限あり)
4. ech があれば Base64 を外し、RFC 9849 4 節の ECHConfigList として解く
(version / config_id / KEM / 公開鍵 / 暗号スイート / maximum_name_length / public_name / extensions)
public_name は 6.1.7 の規則で検査する
Python 標準ライブラリだけで動きます。--fixtures を渡すとネットワークに出ません。
"""
import argparse
import base64
import ipaddress
import json
import re
import sys
import urllib.parse
import urllib.request
DOH = "https://dns.google/resolve"
TYPE_CNAME = 5
TYPE_HTTPS = 65
ALIAS_LIMIT = 4 # RFC 9460 2.4.2: 上限は実装任せ。0 は禁止
# RFC 9460 14.3.2 + RFC 9848 9(ech は 9460 では「予約」、9848 で定義)
KEYS = {"mandatory": 0, "alpn": 1, "no-default-alpn": 2, "port": 3, "ipv4hint": 4, "ech": 5, "ipv6hint": 6}
KEY_NAMES = {v: k for k, v in KEYS.items()}
AUTO_MANDATORY_HTTPS = {"port", "no-default-alpn"} # RFC 9460 9
DEFAULT_ALPN_HTTPS = ["http/1.1"] # RFC 9460 9
# IANA HPKE レジストリ(2026-11-07 閲覧)から、ECH で見かける範囲
KEM = {0x0010: "DHKEM(P-256, HKDF-SHA256)", 0x0011: "DHKEM(P-384, HKDF-SHA384)", 0x0012: "DHKEM(P-521, HKDF-SHA512)",
0x0020: "DHKEM(X25519, HKDF-SHA256)", 0x0021: "DHKEM(X448, HKDF-SHA512)",
0x0041: "ML-KEM-768", 0x0050: "MLKEM768-P256", 0x647A: "X-Wing"}
KDF = {0x0001: "HKDF-SHA256", 0x0002: "HKDF-SHA384", 0x0003: "HKDF-SHA512"}
AEAD = {0x0001: "AES-128-GCM", 0x0002: "AES-256-GCM", 0x0003: "ChaCha20Poly1305"}
ECH_VERSION = 0xFE0D
class Malformed(ValueError):
"""RFC 9460 2.2 の意味で不正(クライアントは RRset 全体を捨てて非 SVCB 接続に戻る)"""
# ---------------------------------------------------------------- DNS 取得
def doh_https(name):
"""dns.google に HTTPS を問い合わせ、(status, ad, [(type, owner, rdata)]) を返す(CNAME も含む)"""
url = DOH + "?" + urllib.parse.urlencode({"name": name, "type": TYPE_HTTPS})
req = urllib.request.Request(url, headers={"Accept": "application/dns-json"})
with urllib.request.urlopen(req, timeout=10) as r:
j = json.load(r)
ans = [(a["type"], a["name"].rstrip(".").lower(), a["data"]) for a in j.get("Answer", [])]
return j.get("Status", -1), bool(j.get("AD")), ans
class FixtureLookup:
"""'名前 | Status | AD | type owner rdata || ...' 形式のテキストで応答を再生する"""
def __init__(self, path):
self.data, self.missing = {}, []
with open(path, encoding="utf-8") as f:
for line in f:
line = line.rstrip("\n")
if not line or line.startswith("#"):
continue
name, st, ad, rest = [x.strip() for x in line.split("|", 3)]
ans = []
for rec in [x.strip() for x in rest.split("||") if x.strip()]:
t, owner, rdata = rec.split(" ", 2)
ans.append((int(t), owner.rstrip(".").lower(), rdata))
self.data[name.lower()] = (int(st), ad == "1", ans)
def __call__(self, name):
key = name.lower().rstrip(".")
if key not in self.data:
self.missing.append(key)
return 3, False, []
return self.data[key]
# ---------------------------------------------------------------- 提示形式の解読(RFC 9460 2.1、Appendix A)
def _tokenize(rdata):
"""空白区切り。ただし二重引用符の中の空白は区切らない(Appendix A の quoted)"""
toks, cur, inq, i = [], [], False, 0
while i < len(rdata):
c = rdata[i]
if c == '"':
inq = not inq
cur.append(c)
elif c == "\\" and i + 1 < len(rdata):
cur.append(c + rdata[i + 1])
i += 1
elif c in " \t" and not inq:
if cur:
toks.append("".join(cur))
cur = []
else:
cur.append(c)
i += 1
if inq:
raise Malformed("二重引用符が閉じていない")
if cur:
toks.append("".join(cur))
return toks
def charstring_decode(s):
"""Appendix A: 引用符を外し、\\X と \\DDD(10進3桁)を1オクテットに戻す。戻り値は bytes"""
if len(s) >= 2 and s[0] == '"' and s[-1] == '"':
s = s[1:-1]
out, i = bytearray(), 0
while i < len(s):
c = s[i]
if c == "\\":
if i + 1 >= len(s):
raise Malformed("末尾に単独の \\")
if s[i + 1].isdigit(): # \DDD は 10 進 3 桁(Appendix A: dec-octet)
if not (i + 3 < len(s) and s[i + 1:i + 4].isdigit()):
raise Malformed("10進エスケープは 3 桁必要: %r" % s[i:i + 4])
n = int(s[i + 1:i + 4])
if n > 255:
raise Malformed("10進エスケープが 255 を超える: \\%03d" % n)
out.append(n)
i += 4
continue
out.append(ord(s[i + 1]))
i += 2
continue
out.append(ord(c))
i += 1
return bytes(out)
def comma_list_decode(value):
"""Appendix A.1: 文字列デコード後の値を "," で分割。"\\," と "\\\\" はエスケープ。空の項目は不可"""
items, cur, i = [], bytearray(), 0
while i < len(value):
b = value[i]
if b == 0x5C and i + 1 < len(value) and value[i + 1] in (0x2C, 0x5C): # "\," or "\\"
cur.append(value[i + 1])
i += 2
continue
if b == 0x2C: # ","
items.append(bytes(cur))
cur = bytearray()
i += 1
continue
cur.append(b)
i += 1
items.append(bytes(cur))
if any(len(x) == 0 for x in items):
raise Malformed("カンマ区切りリストに空の項目がある(A.1)")
return items
def parse_rdata(rdata):
"""'SvcPriority TargetName SvcParams' → (priority, target, [(key, value_bytes or None), ...])"""
toks = _tokenize(rdata.strip())
if len(toks) < 2:
raise Malformed("SvcPriority と TargetName が必要")
try:
prio = int(toks[0])
except ValueError:
raise Malformed("SvcPriority が整数でない: %r" % toks[0])
if not 0 <= prio <= 65535:
raise Malformed("SvcPriority の範囲外")
target = toks[1]
params = []
for tok in toks[2:]:
if "=" in tok:
k, v = tok.split("=", 1)
params.append((k, charstring_decode(v)))
else:
params.append((tok, None)) # 値なし(no-default-alpn など)
return prio, target, params
# ---------------------------------------------------------------- ワイヤ形式(RFC 9460 2.2)
def name_to_wire(name):
if name == ".":
return b"\x00"
out = b""
for label in name.rstrip(".").split("."):
b = label.encode("ascii")
if not 0 < len(b) < 64:
raise Malformed("不正なラベル: %r" % label)
out += bytes([len(b)]) + b
return out + b"\x00"
def key_number(name):
if name in KEYS:
return KEYS[name]
m = re.fullmatch(r"key(\d+)", name)
if m and (m.group(1) == "0" or not m.group(1).startswith("0")):
n = int(m.group(1))
if n <= 65535:
return n
raise Malformed("未知の SvcParamKey 名: %r(keyNNNNN 形式でなければワイヤ形式に変換できない)" % name)
def encode_param(key, value):
"""提示形式の値(bytes or None)→ ワイヤ形式の SvcParamValue。キーごとの検査もここで行う"""
if key == "mandatory":
if not value:
raise Malformed("mandatory の値が空")
names = [x.decode("ascii") for x in comma_list_decode(value)]
nums = [key_number(n) for n in names]
if 0 in nums:
raise Malformed("mandatory 自身を mandatory に含めてはならない(8 節)")
if len(set(nums)) != len(nums):
raise Malformed("mandatory のリストにキーが重複")
return b"".join(n.to_bytes(2, "big") for n in sorted(nums))
if key == "alpn":
if not value:
raise Malformed("alpn の値が空")
out = b""
for item in comma_list_decode(value):
if len(item) > 255:
raise Malformed("alpn-id が 255 オクテットを超える")
out += bytes([len(item)]) + item
return out
if key == "no-default-alpn":
if value:
raise Malformed("no-default-alpn の値は空でなければならない(7.1.1)")
return b""
if key == "port":
if not value:
raise Malformed("port の値が空")
s = value.decode("ascii", "replace")
if not s.isdigit() or not 0 <= int(s) <= 65535:
raise Malformed("port は 0〜65535 の10進整数: %r" % s)
return int(s).to_bytes(2, "big")
if key in ("ipv4hint", "ipv6hint"):
if not value:
raise Malformed("%s の値が空" % key)
out = b""
for item in comma_list_decode(value):
try:
addr = ipaddress.ip_address(item.decode("ascii"))
except ValueError:
raise Malformed("%s に不正なアドレス: %r" % (key, item))
if (key == "ipv4hint") != (addr.version == 4):
raise Malformed("%s にアドレス族の違うアドレス: %s" % (key, addr))
out += addr.packed
return out
if key == "ech":
if not value:
raise Malformed("ech の値が空")
if b"\\" in value:
raise Malformed("ech にエスケープは使えない(RFC 9848 3)")
try:
raw = base64.b64decode(value, validate=True)
except Exception:
raise Malformed("ech が Base64 として読めない")
parse_echconfiglist(raw) # 形式検査(例外はそのまま上げる)
return raw
# keyNNNNN: デコード済みの値をそのままワイヤ形式に(2.1)
return value if value is not None else b""
def to_wire(prio, target, params):
"""RFC 9460 2.2。戻り値 (wire_bytes, findings)。findings は警告の列"""
findings = []
seen = set()
encoded = []
for k, v in params:
n = key_number(k)
if n in seen:
raise Malformed("SvcParamKey %s が重複(2.1 / 2.2)" % k)
seen.add(n)
encoded.append((n, k, encode_param(KEY_NAMES.get(n, k), v)))
names = {KEY_NAMES.get(n, k) for n, k, _ in encoded}
if "no-default-alpn" in names and "alpn" not in names:
raise Malformed("no-default-alpn には alpn が必要(self-consistent でない。7.1.1)")
for n, k, w in encoded:
if n == 0:
for i in range(0, len(w), 2):
m = int.from_bytes(w[i:i + 2], "big")
if m not in seen:
raise Malformed("mandatory に挙げたキー %s がレコードに無い(8 節)" % KEY_NAMES.get(m, "key%d" % m))
if KEY_NAMES.get(m) in AUTO_MANDATORY_HTTPS:
findings.append(("INFO", "%s は HTTPS では自動的に mandatory。列挙は無駄(8 節)" % KEY_NAMES[m]))
if prio == 0 and params:
findings.append(("WARN", "AliasMode なのに SvcParams がある。受信側は無視する(2.4.2)"))
wire = prio.to_bytes(2, "big") + name_to_wire(target)
if prio != 0:
for n, k, w in sorted(encoded): # ワイヤ形式はキー番号の昇順(2.2)
wire += n.to_bytes(2, "big") + len(w).to_bytes(2, "big") + w
return wire, findings
# ---------------------------------------------------------------- ECHConfigList(RFC 9849 4 節)
def _u8(b, i):
if i + 1 > len(b):
raise Malformed("ECHConfig が途中で切れている")
return b[i], i + 1
def _u16(b, i):
if i + 2 > len(b):
raise Malformed("ECHConfig が途中で切れている")
return int.from_bytes(b[i:i + 2], "big"), i + 2
def _vec(b, i, lenbytes):
n, i = (_u8 if lenbytes == 1 else _u16)(b, i)
if i + n > len(b):
raise Malformed("ECHConfig の可変長フィールドが本体より長い")
return b[i:i + n], i + n
def check_public_name(name):
"""RFC 9849 6.1.7: LDH ラベルのドット区切り。先頭末尾のドット不可、各ラベル 63 以下、
最終ラベルが全部数字 or 0x+16進 なら無視(IPv4 と誤解されるため)。戻り値は None(OK)か理由"""
if not name or name.startswith(".") or name.endswith("."):
return "先頭または末尾がドット"
labels = name.split(".")
for lb in labels:
if not lb or len(lb) > 63:
return "ラベル長が不正"
if not re.fullmatch(r"[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?", lb):
return "LDH ラベルでない: %r" % lb
last = labels[-1]
if last.isdigit() or re.fullmatch(r"0[xX][0-9A-Fa-f]*", last):
return "最終ラベルが IPv4 リテラルと解釈され得る"
return None
def parse_echconfiglist(raw):
"""ECHConfigList(長さプレフィックス付き)→ [config dict]。未知 version は skipped に入れる"""
total, i = _u16(raw, 0)
if total != len(raw) - 2:
raise Malformed("ECHConfigList の長さプレフィックス(%d)と実体(%d)が合わない" % (total, len(raw) - 2))
if total < 4:
raise Malformed("ECHConfigList が短すぎる(<4..2^16-1>)")
configs = []
while i < len(raw):
version, i = _u16(raw, i)
body, i = _vec(raw, i, 2)
if version != ECH_VERSION:
configs.append({"version": version, "supported": False, "length": len(body)})
continue
j = 0
config_id, j = _u8(body, j)
kem_id, j = _u16(body, j)
public_key, j = _vec(body, j, 2)
suites_raw, j = _vec(body, j, 2)
if len(suites_raw) < 4 or len(suites_raw) % 4:
raise Malformed("cipher_suites の長さが 4 の倍数でない(<4..2^16-4>)")
suites = [(int.from_bytes(suites_raw[k:k + 2], "big"), int.from_bytes(suites_raw[k + 2:k + 4], "big"))
for k in range(0, len(suites_raw), 4)]
max_name_len, j = _u8(body, j)
pn_raw, j = _vec(body, j, 1)
if not pn_raw:
raise Malformed("public_name が空(<1..255>)")
ext_raw, j = _vec(body, j, 2)
if j != len(body):
raise Malformed("ECHConfigContents の後に余分なバイト")
exts, k, seen_types = [], 0, set()
while k < len(ext_raw):
et, k = _u16(ext_raw, k)
ed, k = _vec(ext_raw, k, 2)
if et in seen_types:
raise Malformed("同じ型の ECHConfig 拡張が 2 つある(4.2)")
seen_types.add(et)
exts.append({"type": et, "mandatory": bool(et & 0x8000), "len": len(ed)})
public_name = pn_raw.decode("ascii", "replace")
cfg = {"version": version, "supported": True, "config_id": config_id,
"kem_id": kem_id, "kem": KEM.get(kem_id, "未知(クライアントは無視する)"),
"public_key_len": len(public_key),
"cipher_suites": [{"kdf": KDF.get(a, "0x%04x" % a), "aead": AEAD.get(b, "0x%04x" % b)} for a, b in suites],
"maximum_name_length": max_name_len, "public_name": public_name,
"extensions": exts, "ignore_reason": None}
bad = check_public_name(public_name)
if bad:
cfg["ignore_reason"] = "public_name が不正(6.1.7): " + bad
elif kem_id not in KEM:
cfg["ignore_reason"] = "未知の KEM(4 節: MUST ignore)"
elif any(e["mandatory"] for e in exts):
cfg["ignore_reason"] = "未対応の mandatory 拡張(4.2: MUST ignore)"
configs.append(cfg)
return configs
# ---------------------------------------------------------------- 解決(RFC 9460 3 節)
def resolve(name, lookup=doh_https):
"""name の HTTPS RRset を CNAME と AliasMode を辿って求める。
戻り値: {'name', 'chain':[...], 'final', 'ad', 'records':[{...}], 'findings':[...], 'state'}"""
out = {"name": name, "chain": [], "final": None, "ad": False, "records": [], "findings": [], "state": None}
qname = name.lower().rstrip(".")
for hop in range(ALIAS_LIMIT + 1):
st, ad, ans = lookup(qname)
out["ad"] = out["ad"] or ad
if st not in (0, 3):
raise RuntimeError("DNS 応答が異常: status=%d" % st)
# CNAME を辿る(2.3: 各 RR は自分の所有者名で返る)
cur = qname
cnames = {o: d.rstrip(".").lower() for t, o, d in ans if t == TYPE_CNAME}
seen = set()
while cur in cnames and cur not in seen:
seen.add(cur)
out["chain"].append(("CNAME", cur, cnames[cur]))
cur = cnames[cur]
rrs = [d for t, o, d in ans if t == TYPE_HTTPS and o == cur]
out["final"] = cur
if not rrs:
out["state"] = "none" if st == 0 else "nxdomain"
out["findings"].append(("INFO", "%s に HTTPS レコード無し(status=%d)。通常の A/AAAA 接続" % (cur, st)))
return out
parsed = []
try:
for rd in rrs:
prio, target, params = parse_rdata(rd)
wire, fnd = to_wire(prio, target, params)
out["findings"] += fnd
parsed.append({"owner": cur, "rdata": rd, "priority": prio, "target": target,
"params": params, "wire_hex": wire.hex()})
except Malformed as e:
out["state"] = "malformed"
out["findings"].append(("ERROR", "RRset が不正: %s。RRset 全体を捨てて非 SVCB 接続に戻る(2.2)" % e))
return out
aliases = [p for p in parsed if p["priority"] == 0]
if aliases:
if len(aliases) > 1:
out["findings"].append(("WARN", "AliasMode が複数ある。1 つを無作為に選ぶ(2.4.2)"))
if len(parsed) > len(aliases):
out["findings"].append(("WARN", "AliasMode と ServiceMode が混在。ServiceMode は無視(2.4.1)"))
if aliases[0]["target"] == ".": # rstrip(".") の前に見る。"." は rstrip で空になる
tgt = "."
else:
tgt = aliases[0]["target"].rstrip(".").lower()
if tgt == ".":
out["state"] = "alias-dot"
out["findings"].append(("INFO", "AliasMode の TargetName が「.」= サービス無しの表明(2.5.1)"))
return out
out["chain"].append(("HTTPS 0", cur, tgt))
qname = tgt
continue
# ServiceMode
for p in parsed:
p["effective_target"] = cur if p["target"] == "." else p["target"].rstrip(".").lower()
d = {k: v for k, v in p["params"]}
names = [k for k, _ in p["params"]]
alpn = [x.decode("ascii", "replace") for x in comma_list_decode(d["alpn"])] if "alpn" in d else []
p["alpn_set"] = alpn + ([] if "no-default-alpn" in names else DEFAULT_ALPN_HTTPS)
p["ech"] = None
if "ech" in d:
p["ech"] = parse_echconfiglist(base64.b64decode(d["ech"]))
usable = [c for c in p["ech"] if c.get("supported") and not c.get("ignore_reason")]
if not usable:
out["findings"].append(("WARN", "ech はあるが使える ECHConfig が無い"))
if p["target"] == "." and ("ipv4hint" in d or "ipv6hint" in d):
out["findings"].append(("INFO", "TargetName が「.」なのに IP ヒントがある。性能上の利点は無く SHOULD NOT(7.3)"))
parsed.sort(key=lambda p: p["priority"])
out["records"] = parsed
out["state"] = "service"
return out
out["state"] = "alias-limit"
out["findings"].append(("WARN", "AliasMode の連鎖が上限 %d を超えた。元の名前で接続(3.1)" % ALIAS_LIMIT))
return out
def fmt(r):
L = ["対象 : %s%s" % (r["name"], "(DNSSEC AD)" if r["ad"] else "")]
for kind, a, b in r["chain"]:
L.append(" %-7s %s → %s" % (kind, a, b))
if r["state"] in ("none", "nxdomain"):
L.append("HTTPS : なし(%s)" % r["final"])
elif r["state"] == "service":
for p in r["records"]:
L.append("HTTPS : %s" % p["rdata"])
L.append(" 優先度 %d / 接続先 %s / ALPN %s" % (p["priority"], p["effective_target"], ",".join(p["alpn_set"])))
if p["ech"]:
for c in p["ech"]:
if not c["supported"]:
L.append(" ech: version 0x%04x は未対応。読み飛ばす" % c["version"])
continue
L.append(" ech: config_id=%d KEM=%s 公開鍵 %dB スイート %s max_name_len=%d public_name=%s%s" % (
c["config_id"], c["kem"], c["public_key_len"],
"/".join("%s+%s" % (s["kdf"], s["aead"]) for s in c["cipher_suites"]),
c["maximum_name_length"], c["public_name"],
(" [無視: %s]" % c["ignore_reason"]) if c["ignore_reason"] else ""))
else:
L.append("状態 : %s" % r["state"])
for lv, m in r["findings"]:
L.append(" [%s] %s" % (lv, m))
return "\n".join(L)
def main(argv=None):
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("name")
ap.add_argument("--fixtures", help="テキスト形式の DNS 応答で動く(ネットワークに出ない)")
ap.add_argument("--json", action="store_true")
a = ap.parse_args(argv)
lookup = FixtureLookup(a.fixtures) if a.fixtures else doh_https
r = resolve(a.name, lookup)
if a.json:
print(json.dumps(r, ensure_ascii=False, indent=2, default=lambda o: o.hex() if isinstance(o, bytes) else str(o)))
else:
print(fmt(r))
return 1 if any(lv == "ERROR" for lv, _ in r["findings"]) else 0
if __name__ == "__main__":
sys.exit(main())
実行例
ac-5.net。echの中身と、7.3節の「TargetNameが「.」ならIPヒントはSHOULD NOT」に触れている点を指摘しています(Cloudflareは付けています)。
対象 : ac-5.net
HTTPS : 1 . alpn=h3,h2 ipv4hint=104.21.37.142,172.67.209.42 ech=AEX+DQBBbQAgACDPl6q/TzSEOl/lSZrnK3iyyWRYuSir/EOPKwFMwLKBcwAEAAEAAQASY2xvdWRmbGFyZS1lY2guY29tAAA= ipv6hint=2606:4700:3034::6815:258e,2606:4700:3036::ac43:d12a
優先度 1 / 接続先 ac-5.net / ALPN h3,h2,http/1.1
ech: config_id=109 KEM=DHKEM(X25519, HKDF-SHA256) 公開鍵 32B スイート HKDF-SHA256+AES-128-GCM max_name_len=0 public_name=cloudflare-ech.com
[INFO] TargetName が「.」なのに IP ヒントがある。性能上の利点は無く SHOULD NOT(7.3)
(終了コード 0)
www.line.me。CNAMEを2段辿った先のcloudfront.netのレコードを読み、既定のALPN http/1.1を足しています。
対象 : www.line.me
CNAME www.line.me → www-line-me-flava-cloud-com.fcdn.flava-cloud.com
CNAME www-line-me-flava-cloud-com.fcdn.flava-cloud.com → dj6jito9qvx8o.cloudfront.net
HTTPS : 1 . alpn=h2,h3
優先度 1 / 接続先 dj6jito9qvx8o.cloudfront.net / ALPN h2,h3,http/1.1
(終了コード 0)
defo.ie。ECHConfigが3個。
対象 : defo.ie(DNSSEC AD)
HTTPS : 1 . ipv4hint=213.108.108.101 ech=AMD+DQA8sQAgACDvExg/nM5JchtB7qkHkeXo57shfveDYR921ijBEPrScwAEAAEAAQANY292ZXIuZGVmby5pZQAA/g0APLsAIAAgllnAoC1OrasFLaKwkB6zUaZwxQotX/0fajyWHz6W6HIABAABAAEADWNvdmVyLmRlZm8uaWUAAP4NADzTACAAIPBCrheTHHqlPJ5WZ3Ml6B9LVxe5fA9oR527AH6WTkl/AAQAAQABAA1jb3Zlci5kZWZvLmllAAA= ipv6hint=2a00:c6c0:0:116:5::10
優先度 1 / 接続先 defo.ie / ALPN http/1.1
ech: config_id=177 KEM=DHKEM(X25519, HKDF-SHA256) 公開鍵 32B スイート HKDF-SHA256+AES-128-GCM max_name_len=0 public_name=cover.defo.ie
ech: config_id=187 KEM=DHKEM(X25519, HKDF-SHA256) 公開鍵 32B スイート HKDF-SHA256+AES-128-GCM max_name_len=0 public_name=cover.defo.ie
ech: config_id=211 KEM=DHKEM(X25519, HKDF-SHA256) 公開鍵 32B スイート HKDF-SHA256+AES-128-GCM max_name_len=0 public_name=cover.defo.ie
[INFO] TargetName が「.」なのに IP ヒントがある。性能上の利点は無く SHOULD NOT(7.3)
(終了コード 0)
www.microsoft.com。CNAMEの先(akamaiedge.net)にHTTPSレコードは無く、通常のA/AAAA接続になります。
対象 : www.microsoft.com
CNAME www.microsoft.com → www.microsoft.com-c-3.edgekey.net
CNAME www.microsoft.com-c-3.edgekey.net → e13678.dscb.akamaiedge.net
HTTPS : なし(e13678.dscb.akamaiedge.net)
[INFO] e13678.dscb.akamaiedge.net に HTTPS レコード無し(status=0)。通常の A/AAAA 接続
(終了コード 0)
実データと RFC のテストベクタで踏んだ落とし穴
RFC 9460のAppendix Dには、提示形式とワイヤ形式の対応がテストベクタとして9例、不正な例が6例載っています。これを全部テストに入れて、実データ36件と合わせて直したのが次の点です。
1. AliasModeの「.」を rstrip で消していた
ドメイン名の末尾ドットをrstrip(".")で落としてから比較していたため、TargetNameが「.」のときに空文字列になり、2.5.1節の「サービス無しの表明」を検出できずに空の名前を問い合わせに行っていました。「.」かどうかはrstripの前に見る必要があります。ServiceModeの「.」(所有者名を使う)は別経路で処理していたので無事でしたが、同じ関数の2行上で同じ落とし穴を踏んでいたわけです。
2. 「冗長な長さプレフィックス」の意味
RFC 9848は、echのワイヤ形式を「ECHConfigList、冗長な長さプレフィックスを含む」と書いています。SvcParamValueには2.2節の長さフィールドがあるので、その中にさらにECHConfigList自身の長さが入る、という二重構造です。RFC 9848 Figure 1の例(public_nameがech-sites.example.net)をワイヤ形式にすると、0005 004a 0048 fe0d 0044 ...と、キー5、SvcParamの長さ0x4a(74)、ECHConfigListの長さ0x48(72)、version、ECHConfigの長さ0x44と続きます。最初、テストの期待値を「SvcParamの長さ=0x48」と書いて落ちました。4バイトのうち2バイトは冗長、を読み飛ばしていました。
3. ECHConfigのフィールド位置
テストで「KEMを書き換えたら表示が変わるか」を確かめるために特定のバイトを書き換えたところ、ずれた場所を書き換えていました。構造は、リスト長(2)、version(2)、length(2)、config_id(1)、kem_id(2)、公開鍵長(2)、公開鍵、スイート長(2)、スイート(4×n)、maximum_name_length(1)、public_name長(1)、public_name、拡張長(2)、拡張、の順です。RFC 9848 Figure 1を手で読むと、00 48 | fe 0d | 00 44 | 01 | 00 20 | 00 20 | …となり、config_idが1、KEMが0x0020(X25519)、公開鍵が32バイトだと分かります。スクリプトの解析は最初から合っていて、テストの方が間違っていた例です。
4. 10進エスケープは3桁必要
Appendix Aの文字列デコードでは、\の後に数字が来たら必ず3桁の10進(\210など)です。最初の実装は「3桁揃っていなければ1文字エスケープとして読む」という寛容な動きをしていて、\21xのような不正入力を黙って通していました。ABNFはescaped = "\" ( non-digit / dec-octet )なので、数字で始まって3桁でないものは不正として弾くようにしました。Figure 6の"hello\210qoo"(0xd2を含む)とFigure 10のalpn=f\\\092oo\092,bar,h2(引用符版と同じワイヤ形式になる)は、修正前後とも通っています。
自分のドメインでは
ac-5.netのHTTPSレコードとechはCloudflareが自動で管理しているもので、こちらの操作はありません。ECH対応のクライアントが暗号化DNS経由でHTTPSレコードを引けば、SNIをcloudflare-ech.comに隠して接続してきます。鍵の世代が複数見えるのも正常です。確認したいときはpython3 httpsrr.py ac-5.netで、その時点のconfig_idとpublic_nameが読めます。
まとめ
- ECHはRFC 9849、DNSでの鍵配布はRFC 9848として2026年3月に発行。HTTPSレコード(RFC 9460)の
echは、それまで予約番号だった - HTTPSレコードは1本でも不正ならRRset全体が捨てられる。ワイヤ形式はキー番号の昇順、提示形式は自由
- HTTPSの問い合わせ名はホスト名そのもの。CDN側のCNAME先に置いたレコードが顧客ドメインに効く。実測でもHTTPSレコードありの6件中4件はこの形
- 主要30ドメインでHTTPSレコードは6件、
echは0件、apexのAliasModeは0件 - Cloudflareのプロキシ配下のac-5.netには
echが自動で付いていた。public_nameはcloudflare-ech.com、鍵は複数世代が並行 echのワイヤ形式は長さが二重に入る。ECHConfigListは複数のECHConfigを優先順に持て、スイートもリスト
参考:RFC 9460(Service Binding and Parameter Specification via the DNS)2.1〜2.5節・3節・7〜9節・14.3.2節・Appendix A・Appendix D/RFC 9848(Bootstrapping TLS Encrypted ClientHello with DNS Service Bindings)3〜5節・8節/RFC 9849(TLS Encrypted Client Hello)3節・4節・4.1節・6.1.7節・8.1.1節・10.2節/IANA Hybrid Public Key Encryption (HPKE) レジストリ(2026年11月7日閲覧)
コードとテスト
検証スクリプト、72件のDNS応答を収めたフィクスチャ、RFC 9460 Appendix Dのテストベクタ(Figure 2〜16)とRFC 9848 Figure 1のechをそのまま突き合わせたテストはGitHubに置いています。python3 test_httpsrr.py で133件のテストがオフラインで通ります。
