HTTPSレコード(RFC 9460)とECH(RFC 9849)を読む|主要30ドメインでHTTPSレコードは6件、ECHは0件。でも自分のサイトには付いていた

Security

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件のテストがオフラインで通ります。

github.com/ac5net/httpsrr

関連記事

タイトルとURLをコピーしました