DNSSECのDSとDNSKEYをRFC 4034の式で検算する|主要30ドメインで署名ありは5件、SHA-1は2025年11月にMUST NOTになった

Security

DNSSECの説明で必ず出てくるのが「親ゾーンのDSレコードが、子ゾーンのDNSKEYレコードを指している」という図です。ではDSの中身は具体的に何で、どう計算すれば「指している」と確かめられるのでしょうか。CAAの記事で主要30ドメインを調べたとき、DNSSEC検証済み(ADフラグ)だったのは5ドメインでした。今回はその5ドメインを含む36ドメインについて、DSとDNSKEYを取得し、RFC 4034の計算式をそのままPythonで書いて自分で検算しました。

結果は、主要30ドメインで署名しているのは5件、参照用に加えた6件を含む署名あり10件はすべてDSとDNSKEYが一致というものでした。もう1つ、調べる過程で分かったのが、2025年11月のRFC 9905でRSASHA1とSHA-1ダイジェストが「MUST NOT」になり、RFC 9904でアルゴリズム推奨の正本がRFCからIANAレジストリへ移ったことです。仕様の確認、実測、スクリプト、実データで踏んだ落とし穴の順にまとめます。

RFC で確認した要点

DNSKEYとDSの形式はRFC 4034(2005年)、DSのSHA-256はRFC 4509(2006年)、アルゴリズムの推奨は2025年のRFC 9904/9905とIANAレジストリで確認しました。

DNSKEY(RFC 4034 2節):Flags・Protocol・Algorithm・公開鍵

DNSKEYのRDATAは、2オクテットのFlags、1オクテットのProtocol、1オクテットのAlgorithm、そして公開鍵です。提示形式では257 3 13 mdsswUyr3D...のように並びます。

  • Flags:bit 7がZone Key(値256)。これが立っていないDNSKEYはRRSIGの検証に使ってはならない(MUST NOT)。bit 15がSecure Entry Point(値1、合わせて257)で、これは「KSKとして使う鍵ですよ」というヒントにすぎず、検証器は動作を変えてはならない(2.1.1)
  • Protocol:常に3。3以外なら無効(2.1.2)
  • Algorithm:IANAの番号。13がECDSAP256SHA256、8がRSASHA256、15がEd25519

つまり「256がZSK、257がKSK」という言い方は慣習であって、プロトコル上の区別はZone Keyビットの有無だけです。

DS(RFC 4034 5節):Key Tag・Algorithm・Digest Type・Digest

DSは親ゾーンに置かれ、子ゾーンのDNSKEYを1つ指します。2371 13 2 32996839A6D8...のように、Key Tag、アルゴリズム番号、ダイジェスト種別、ダイジェストの順です。ダイジェストの計算式は5.1.4節に1行で書かれています。

digest = digest_algorithm( DNSKEY owner name | DNSKEY RDATA )
DNSKEY RDATA = Flags | Protocol | Algorithm | Public Key

所有者名は canonical form(小文字・ワイヤ形式)
  "cloudflare.com." → 0x0a "cloudflare" 0x03 "com" 0x00

ここで重要なのは、ハッシュの対象に所有者名が含まれることです。しかも「canonical form」、つまり小文字化したワイヤ形式(各ラベルの前に長さ、末尾にルートラベルの0x00)です。文字列のcloudflare.comをそのまま連結すると違う値になります。この点は後述の実データで確かめられました。

もう1つ、5.2節に「DSが指すDNSKEYはZone Keyビットが立っていなければならず、立っていなければそのDSもDNSKEYも検証に使ってはならない」とあります。ダイジェストが一致していても、です。

Key Tag(RFC 4034 Appendix B):16ビットごとの和

Key TagはDNSKEYを効率よく選ぶための16ビットの値で、RDATAを2オクテットずつ足し、桁上がりを畳み込んで下位16ビットを取るだけです(アルゴリズム1のRSA/MD5だけはB.1の別計算)。Appendix Bは「Key Tagは一意な識別子ではない。同じ所有者名・同じアルゴリズム・同じKey Tagの別の鍵は理論上あり得る」と念を押しています。だから検証はDSのダイジェスト照合まで行う必要があります。

RFC 4509:SHA-256のDSと、複数DSの扱い

RFC 4034の時点ではダイジェストはSHA-1しかありませんでした。RFC 4509がダイジェスト種別2(SHA-256)を追加し、同時に2つの運用規定を置いています。

  • 検証器は、SHA-256のDSがあればSHA-1のDSは無視すべき(SHOULD)(3節)。両方置いた場合に弱い方へ格下げされる攻撃を防ぐため
  • 検証器が対応していないダイジェスト種別のDSしか無い場合は、「DSが無い」のと同じに扱う(4節)。bogusではなくinsecureになる

RFC 9904/9905:推奨の正本はIANAレジストリへ、SHA-1はMUST NOT

アルゴリズムの実装要件は長らくRFC 8624(2019年)が持っていましたが、2025年11月のRFC 9904がこれを廃止し、正本をIANAの「DNS Security Algorithm Numbers」と「Digest Algorithms」レジストリに移しました。レジストリには「Use for DNSSEC Signing」「Use for DNSSEC Validation」などの列が追加され、RFCを改訂しなくても推奨を更新できるようになっています。

その最初の適用が同月のRFC 9905で、RSASHA1(5)とRSASHA1-NSEC3-SHA1(7)は署名に「MUST NOT」、DSのSHA-1ダイジェスト(1)は委任に「MUST NOT」になりました。検証側はまだ「MUST」で受け入れますが、新規に使ってはいけないということです。2026年10月3日時点のレジストリから、署名に使える主なアルゴリズムの推奨列を抜き出すと次のとおりです。

番号  ニーモニック          署名(Use)        検証(Use)     根拠
 5    RSASHA1               MUST NOT         RECOMMENDED   RFC 9905
 7    RSASHA1-NSEC3-SHA1    MUST NOT         RECOMMENDED   RFC 9905
 8    RSASHA256             RECOMMENDED      RECOMMENDED   RFC 5702
10    RSASHA512             NOT RECOMMENDED  RECOMMENDED   RFC 5702
13    ECDSAP256SHA256       RECOMMENDED      RECOMMENDED   RFC 6605
14    ECDSAP384SHA384       MAY              RECOMMENDED   RFC 6605
15    ED25519               RECOMMENDED      RECOMMENDED   RFC 8080
16    ED448                 MAY              RECOMMENDED   RFC 8080

DS ダイジェスト種別       委任(Use)        検証(Use)
 1    SHA-1                 MUST NOT         RECOMMENDED   RFC 9905
 2    SHA-256               RECOMMENDED      RECOMMENDED   RFC 4509
 4    SHA-384               MAY              RECOMMENDED   RFC 6605

出典: IANA "DNS Security Algorithm Numbers"(2026-08-10 更新)
      IANA "Digest Algorithms"(2026-01-13 更新)

この表はスクリプトに埋め込んであり、実測したDNSKEY/DSを照らして警告を出します。

36ドメインの実測(2026年10月3日)

CAAの記事と同じ主要30ドメイン(海外10、日本20)に、参照としてietf.org、iana.org、jprs.jp、nic.ad.jp、soumu.go.jp、digital.go.jpの6件を加えました。取得はDoH(dns.google)で、DNSKEY(type 48)とDS(type 43)、およびADフラグを記録しています。

主要30ドメイン:DNSSEC 署名あり(DS と DNSKEY の両方が存在) 5 / 30
  海外系 10件中 3件  cloudflare.com, salesforce.com, slack.com
  日本系 20件中 2件  mufg.jp, smbc.co.jp
  なし(海外): google.com, microsoft.com, apple.com, amazon.com,
                github.com, zoom.us, okta.com
  なし(日本): cybozu.com, yahoo.co.jp, softbank.jp, ana.co.jp, jal.co.jp,
                rakuten.co.jp, freee.co.jp, sakura.ad.jp, biglobe.ne.jp,
                ocn.ne.jp, so-net.ne.jp, line.me, japanpost.jp, kddi.com,
                mercari.com, moneyforward.com, nifty.com, ntt.com

参照6ドメイン:署名あり 5 / 6(digital.go.jp のみ署名なし)

署名あり 10 件の内訳(KSK のアルゴリズム/鍵長、DS のダイジェスト種別)
  cloudflare.com   ECDSAP256SHA256(13)  KSK 2371   / ZSK 34505          DS SHA-256  一致
  salesforce.com   ECDSAP256SHA256(13)  KSK 61000  / ZSK 2317           DS SHA-256  一致
  slack.com        ECDSAP256SHA256(13)  KSK 296    / ZSK 23496, 19277   DS SHA-256  一致
  mufg.jp          ECDSAP256SHA256(13)  KSK 61189  / ZSK 62017          DS SHA-256  一致
  smbc.co.jp       ECDSAP256SHA256(13)  KSK 26743  / ZSK 35510          DS SHA-256  一致
  ietf.org         ECDSAP256SHA256(13)  KSK 2371   / ZSK 34505          DS SHA-256  一致
  iana.org         ECDSAP256SHA256(13)  KSK 2234   / ZSK 16626, 47334   DS SHA-256  一致
  jprs.jp          RSASHA256(8)         KSK 7240 (2048bit) / ZSK 21474 (1024bit)        DS SHA-256  一致
  nic.ad.jp        RSASHA256(8)         KSK 10306 (4096bit) / ZSK 62646, 7338 (2048bit) DS SHA-256  一致
  soumu.go.jp      ECDSAP256SHA256(13)  KSK 17224  / ZSK 15117          DS SHA-256  一致

リゾルバ(dns.google)の AD フラグ: 署名あり 10 件すべて true、署名なし 26 件すべて false
RSASHA1 / RSASHA1-NSEC3-SHA1 / SHA-1 ダイジェスト: 0 件

読み取れることをいくつか。

  • 署名している10件は全件、DSとDNSKEYが一致(secure)。DSのダイジェストはすべてSHA-256で、SHA-1のDSは1件もありませんでした
  • 主要30ドメインの署名率は5/30。google.com、microsoft.com、apple.com、amazon.com、github.comは署名していません。日本の20ドメインで署名しているのはmufg.jpとsmbc.co.jpの2件で、どちらもメガバンクです
  • アルゴリズムは10件中8件がECDSAP256SHA256(13)。RSASHA256(8)はjprs.jpとnic.ad.jpだけで、nic.ad.jpのKSKはRSA 4096ビット、jprs.jpのZSKはRSA 1024ビットでした
  • RFC 9905でMUST NOTになったRSASHA1は0件。少なくともこの36件では、新しい規定に抵触するゾーンはありませんでした
  • デジタル庁のdigital.go.jpは署名なし、総務省のsoumu.go.jpは署名あり。省庁でも足並みは揃っていません

同じ鍵、違うDS:cloudflare.com と ietf.org

一番面白かったのがこれです。cloudflare.comとietf.orgのDNSKEY RRsetは完全に同一で、KSKのKey Tagも同じ2371でした(同じ事業者の権威DNSで同じ鍵が使われていると推測できますが、事業者間の契約までは分かりません)。ところが、DSのダイジェストは異なります。

cloudflare.com.  DNSKEY 257 3 13 mdsswUyr3DPW132mOi8V9xESWE8jTo0dxCjjnopKl+GqJxpVXckHAeF+KkxLbxILfDLUT0rAK9iUzy1L53eKGQ==
ietf.org.        DNSKEY 257 3 13 mdsswUyr3DPW132mOi8V9xESWE8jTo0dxCjjnopKl+GqJxpVXckHAeF+KkxLbxILfDLUT0rAK9iUzy1L53eKGQ==

cloudflare.com.  DS 2371 13 2 32996839A6D808AFE3EB4A795A0E6A7A39A76FC52FF228B22B76F6D63826F2B9
ietf.org.        DS 2371 13 2 B1AE88AFF068DDEC3F7FF662F47D6599C74134425C67106E6C203942D6227EA4

SHA-256( 0x0a"cloudflare"0x03"com"0x00 | RDATA ) = 32996839...
SHA-256( 0x04"ietf"0x03"org"0x00       | RDATA ) = B1AE88AF...

5.1.4節の式に所有者名が含まれるので、同じ鍵でもゾーン名が違えばDSは違う。これを実データで確認できました。逆に言えば、スクリプトの所有者名の扱いを間違えると、この2件のどちらか(あるいは両方)で一致しなくなります。

go.jpはゾーンではない

soumu.go.jpのDSはどこにあるのか。go.jpのNSやSOAを引くと、権威セクションにjp.のSOAが返ります。つまりgo.jpは委任されたゾーンではなく、jpゾーンの中の名前で、soumu.go.jpのDSもsmbc.co.jpのDSもjpゾーンに直接置かれています。属性型JPドメインの構造を知っていれば当然ですが、「親ゾーン=1つ上のラベル」と思い込んでいると引く場所を間違えます。今回のスクリプトはDSを子の名前でそのまま問い合わせてリゾルバに任せているので影響しませんでした。

検算スクリプト

Python 3の標準ライブラリだけで動きます。DNSKEYとDSを取り、Key Tagとダイジェストを計算し、IANAの推奨列と照らします。--fixturesでJSONの応答を渡せばネットワークに出ません。

#!/usr/bin/env python3
"""
dscheck.py - DS と DNSKEY の整合を RFC 4034 / 4509 どおりに自分で計算して確かめる

使い方:
    ./dscheck.py <ゾーン名> [--fixtures <json>] [--json]
    例: ./dscheck.py cloudflare.com

やること:
  1. DNSKEY RRset(type 48)と DS RRset(type 43)を DoH(dns.google)で取る
  2. 各 DNSKEY の Key Tag を RFC 4034 Appendix B で計算する
  3. 各 DS について、同じアルゴリズム・同じ Key Tag の DNSKEY を探し、
     digest = H(所有者名の正規ワイヤ形式 | DNSKEY RDATA) を計算して突き合わせる
     (RFC 4034 5.1.4、RFC 4509 2.1)
  4. アルゴリズムと DS ダイジェスト種別を IANA レジストリの推奨列
     (RFC 9904 以降は IANA が正本。RFC 9905 で SHA-1 が MUST NOT)と照らす
  5. RSA 鍵は RFC 3110 の形式を読んで鍵長(bit)を出す

Python 標準ライブラリだけで動きます。--fixtures を渡すとネットワークに出ません。
"""
import argparse
import base64
import hashlib
import json
import sys
import urllib.parse
import urllib.request

DOH = "https://dns.google/resolve"
TYPE_DNSKEY = 48
TYPE_DS = 43
TYPE_A = 1

# IANA "DNS Security Algorithm Numbers"(2026-08-10 更新時点)
# 値: (ニーモニック, Use for DNSSEC Signing, Use for DNSSEC Validation)
ALG = {
    1:  ("RSAMD5",             "MUST NOT",        "MUST NOT"),
    3:  ("DSA",                "MUST NOT",        "MUST NOT"),
    5:  ("RSASHA1",            "MUST NOT",        "RECOMMENDED"),   # RFC 9905
    6:  ("DSA-NSEC3-SHA1",     "MUST NOT",        "MUST NOT"),
    7:  ("RSASHA1-NSEC3-SHA1", "MUST NOT",        "RECOMMENDED"),   # RFC 9905
    8:  ("RSASHA256",          "RECOMMENDED",     "RECOMMENDED"),
    10: ("RSASHA512",          "NOT RECOMMENDED", "RECOMMENDED"),
    12: ("ECC-GOST",           "MUST NOT",        "MUST NOT"),      # RFC 9906
    13: ("ECDSAP256SHA256",    "RECOMMENDED",     "RECOMMENDED"),
    14: ("ECDSAP384SHA384",    "MAY",             "RECOMMENDED"),
    15: ("ED25519",            "RECOMMENDED",     "RECOMMENDED"),
    16: ("ED448",              "MAY",             "RECOMMENDED"),
    17: ("SM2SM3",             "MAY",             "MAY"),
    18: ("MLDSA44",            "MAY",             "MAY"),
    23: ("ECC-GOST12",         "MAY",             "MAY"),
}
# IANA "Digest Algorithms"(2026-01-13 更新時点)
# 値: (名前, hashlib 名 or None, Use for DNSSEC Delegation)
DIGEST = {
    1: ("SHA-1",   "sha1",   "MUST NOT"),      # RFC 9905
    2: ("SHA-256", "sha256", "RECOMMENDED"),
    3: ("GOST R 34.11-94", None, "MUST NOT"),
    4: ("SHA-384", "sha384", "MAY"),
    5: ("GOST R 34.11-2012", None, "MAY"),
    6: ("SM3",     None,     "MAY"),
}
RSA_ALGS = {1, 5, 7, 8, 10}


# ---------------------------------------------------------------- DNS 取得
def doh(name, rtype):
    """dns.google に JSON で問い合わせ、(status, ad, [data文字列]) を返す"""
    url = DOH + "?" + urllib.parse.urlencode({"name": name, "type": rtype})
    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["data"] for a in j.get("Answer", []) if a.get("type") == rtype]
    return j.get("Status", -1), bool(j.get("AD")), ans


def make_fixture_lookup(path):
    with open(path, encoding="utf-8") as f:
        fx = json.load(f)

    def lookup(name, rtype):
        key = name.rstrip(".").lower()
        ent = fx.get(key)
        if ent is None:
            return 3, False, []          # NXDOMAIN 相当
        if rtype == TYPE_DNSKEY:
            return 0, bool(ent.get("ad")), list(ent.get("dnskey", []))
        if rtype == TYPE_DS:
            return 0, bool(ent.get("ad")), list(ent.get("ds", []))
        return 0, bool(ent.get("ad")), []
    return lookup


# ---------------------------------------------------------------- 変換
def owner_wire(name):
    """所有者名を正規ワイヤ形式に(小文字化・末尾にルートラベル)。RFC 4034 6.2"""
    name = name.rstrip(".").lower()
    out = b""
    if name:
        for label in name.split("."):
            b = label.encode("ascii")
            if not 0 < len(b) < 64:
                raise ValueError("bad label: %r" % label)
            out += bytes([len(b)]) + b
    return out + b"\x00"


def _tokens(pres):
    """提示形式を分割。';' 以降のコメントと、複数行用の '(' ')' は捨てる
    (RFC 4034 2.3 の例や dig +multiline の出力をそのまま貼れるように)"""
    pres = pres.split(";", 1)[0]
    return [t for t in pres.replace("(", " ").replace(")", " ").split() if t]


def parse_dnskey(pres):
    """'flags proto alg base64...' → dict。base64 は空白で分割されていてもよい"""
    parts = _tokens(pres)
    if len(parts) < 4:
        raise ValueError("DNSKEY の項目が足りない: %r" % pres)
    flags, proto, alg = int(parts[0]), int(parts[1]), int(parts[2])
    pub = base64.b64decode("".join(parts[3:]), validate=True)
    rdata = flags.to_bytes(2, "big") + bytes([proto, alg]) + pub
    return {
        "flags": flags, "proto": proto, "alg": alg, "pub": pub, "rdata": rdata,
        "zone_key": bool(flags & 0x0100),   # bit 7 (RFC 4034 2.1.1)
        "sep": bool(flags & 0x0001),        # bit 15
        "revoke": bool(flags & 0x0080),     # bit 8 (RFC 5011)
        "keytag": key_tag(rdata, alg),
        "bits": key_bits(alg, pub),
        "pres": pres,
    }


def parse_ds(pres):
    """'keytag alg dtype hex...' → dict。16進は空白で分割されていてもよい"""
    parts = _tokens(pres)
    if len(parts) < 4:
        raise ValueError("DS の項目が足りない: %r" % pres)
    return {
        "keytag": int(parts[0]), "alg": int(parts[1]), "dtype": int(parts[2]),
        "digest": "".join(parts[3:]).lower(), "pres": pres,
    }


def key_tag(rdata, alg):
    """RFC 4034 Appendix B。アルゴリズム 1 だけ B.1 の別計算"""
    if alg == 1:
        # 公開鍵の末尾から 3 オクテット目と 2 オクテット目
        return int.from_bytes(rdata[-3:-1], "big")
    ac = 0
    for i, b in enumerate(rdata):
        ac += b if (i & 1) else (b << 8)
    ac += (ac >> 16) & 0xFFFF
    return ac & 0xFFFF


def key_bits(alg, pub):
    """鍵長(bit)。RSA は RFC 3110 の形式(指数長 | 指数 | 法)を読む"""
    if alg in RSA_ALGS:
        if not pub:
            return None
        elen = pub[0]
        off = 1
        if elen == 0:                       # 指数長が 256 以上なら 2 オクテット
            elen = int.from_bytes(pub[1:3], "big")
            off = 3
        mod = pub[off + elen:]
        return len(mod) * 8 if mod else None
    return {13: 256, 14: 384, 15: 256, 16: 456}.get(alg)


def ds_digest(owner, dnskey, dtype):
    """digest = H(owner | DNSKEY RDATA)。未対応のダイジェスト種別なら None"""
    h = DIGEST.get(dtype, (None, None, None))[1]
    if h is None:
        return None
    return hashlib.new(h, owner_wire(owner) + dnskey["rdata"]).hexdigest()


# ---------------------------------------------------------------- 判定
def check(zone, lookup=doh):
    zone = zone.rstrip(".").lower()
    st_k, ad_k, keys_pres = lookup(zone, TYPE_DNSKEY)
    st_d, ad_d, ds_pres = lookup(zone, TYPE_DS)
    if st_k not in (0, 3) or st_d not in (0, 3):
        raise RuntimeError("DNS 応答が異常: DNSKEY status=%s DS status=%s" % (st_k, st_d))

    keys = [parse_dnskey(p) for p in keys_pres]
    dss = [parse_ds(p) for p in ds_pres]
    findings = []
    matches = []

    # RFC 4509 3: SHA-256 の DS があれば SHA-1 の DS は無視してよい
    has_sha256 = any(d["dtype"] == 2 for d in dss)

    for d in dss:
        cand = [k for k in keys if k["alg"] == d["alg"] and k["keytag"] == d["keytag"]]
        ok_key = None
        computable = DIGEST.get(d["dtype"], (None, None, None))[1] is not None
        for k in cand:
            calc = ds_digest(zone, k, d["dtype"])
            if calc is not None and calc == d["digest"]:
                ok_key = k
                break
        usable = ok_key is not None and ok_key["zone_key"]   # RFC 4034 5.2
        rec = {"ds": d["pres"], "matched": usable, "computable": computable,
               "candidates": len(cand), "ignored_sha1": (d["dtype"] == 1 and has_sha256)}
        if ok_key is not None:
            rec["dnskey_flags"] = ok_key["flags"]
            if not ok_key["zone_key"]:
                findings.append(("ERROR", "DS %d が指す DNSKEY に Zone Key ビット(256)が無い。ダイジェストは一致するが検証に使ってはならない(RFC 4034 5.2)" % d["keytag"]))
            if not ok_key["sep"]:
                findings.append(("INFO", "DS %d が指す DNSKEY は SEP ビット(257)無し。動作には影響しない(RFC 4034 2.1.1)" % d["keytag"]))
        else:
            if not computable:
                findings.append(("WARN", "DS %d はダイジェスト種別 %d。このツールでは計算できない(対応しない検証器は DS 無しと同じ扱い:RFC 4509 4)" % (d["keytag"], d["dtype"])))
            elif cand:
                findings.append(("ERROR", "DS %d と同じ alg/keytag の DNSKEY はあるがダイジェスト不一致。検証失敗(bogus)になる" % d["keytag"]))
            else:
                findings.append(("ERROR", "DS %d (alg %d) に対応する DNSKEY が無い。他に一致する DS が無ければ bogus" % (d["keytag"], d["alg"])))
        # 推奨状態
        name_a, use_sign, _ = ALG.get(d["alg"], ("alg%d" % d["alg"], "不明", "不明"))
        name_d, _, use_del = DIGEST.get(d["dtype"], ("digest%d" % d["dtype"], None, "不明"))
        if use_del == "MUST NOT":
            findings.append(("WARN", "DS %d のダイジェスト %s は委任に MUST NOT(IANA / RFC 9905)" % (d["keytag"], name_d)))
        if use_sign == "MUST NOT":
            findings.append(("WARN", "DS %d のアルゴリズム %s は署名に MUST NOT(IANA / RFC 9905)" % (d["keytag"], name_a)))
        elif use_sign == "NOT RECOMMENDED":
            findings.append(("INFO", "DS %d のアルゴリズム %s は署名に NOT RECOMMENDED" % (d["keytag"], name_a)))
        matches.append(rec)

    for k in keys:
        name_a, use_sign, _ = ALG.get(k["alg"], ("alg%d" % k["alg"], "不明", "不明"))
        if use_sign == "MUST NOT":
            findings.append(("WARN", "DNSKEY %d のアルゴリズム %s は署名に MUST NOT" % (k["keytag"], name_a)))
        if k["alg"] in RSA_ALGS and k["bits"] and k["bits"] < 2048:
            findings.append(("INFO", "DNSKEY %d は RSA %d bit" % (k["keytag"], k["bits"])))
        if k["proto"] != 3:
            findings.append(("ERROR", "DNSKEY %d の Protocol が 3 でない(RFC 4034 2.1.2)" % k["keytag"]))
        if k["revoke"]:
            findings.append(("INFO", "DNSKEY %d は REVOKE ビット付き(RFC 5011)" % k["keytag"]))

    # 全体の状態
    if not dss and not keys:
        state = "unsigned"
    elif dss and not keys:
        state = "broken: DS はあるが DNSKEY が無い(検証リゾルバで SERVFAIL)"
    elif keys and not dss:
        state = "signed-but-no-DS: 親に DS が無く、信頼の連鎖が切れている(insecure 扱い)"
    elif all(m["matched"] for m in matches):
        state = "secure: 全ての DS が DNSKEY と一致"
    elif any(m["matched"] for m in matches):
        state = "secure-partial: 一致する DS が少なくとも1つある(不一致の DS は無視される)"
    elif not any(m["computable"] for m in matches):
        state = "insecure: 計算できないダイジェスト種別の DS しか無い(この検証器では DS 無しと同じ)"
    else:
        state = "bogus: どの DS も DNSKEY と一致しない"

    # 同じ指摘は1回だけ(DS が複数あると同じ鍵の指摘が重複する)
    seen = set()
    findings = [f for f in findings if not (f in seen or seen.add(f))]

    return {
        "zone": zone, "ad_from_resolver": ad_k or ad_d, "state": state,
        "dnskey": [{"flags": k["flags"], "alg": k["alg"],
                    "alg_name": ALG.get(k["alg"], ("alg%d" % k["alg"],))[0],
                    "keytag": k["keytag"], "bits": k["bits"]} for k in keys],
        "ds": matches, "findings": findings,
    }


def fmt(r):
    lines = ["対象        : %s" % r["zone"],
             "リゾルバAD  : %s" % ("yes" if r["ad_from_resolver"] else "no"),
             "状態        : %s" % r["state"]]
    for k in r["dnskey"]:
        role = "KSK" if k["flags"] & 1 else "ZSK"
        lines.append("  DNSKEY %-5d %s(%d) %s flags=%d bits=%s" %
                     (k["keytag"], k["alg_name"], k["alg"], role, k["flags"], k["bits"]))
    for m in r["ds"]:
        tag = "一致" if m["matched"] else "不一致"
        if m["ignored_sha1"]:
            tag += "(SHA-256 があるので無視対象)"
        lines.append("  DS     %s  → %s" % (m["ds"], tag))
    for lv, msg in r["findings"]:
        lines.append("  [%s] %s" % (lv, msg))
    return "\n".join(lines)


def main(argv=None):
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("zone")
    ap.add_argument("--fixtures", help="JSON 応答で動く(ネットワークに出ない)")
    ap.add_argument("--json", action="store_true", help="JSON で出力")
    a = ap.parse_args(argv)
    lookup = make_fixture_lookup(a.fixtures) if a.fixtures else doh
    r = check(a.zone, lookup)
    print(json.dumps(r, ensure_ascii=False, indent=2) if a.json else fmt(r))
    return 0 if r["state"].startswith(("secure", "unsigned")) else 1


if __name__ == "__main__":
    sys.exit(main())

実行例

cloudflare.com。DS 2371がKSKと一致しています。

対象        : cloudflare.com
リゾルバAD  : yes
状態        : secure: 全ての DS が DNSKEY と一致
  DNSKEY 34505 ECDSAP256SHA256(13) ZSK flags=256 bits=256
  DNSKEY 2371  ECDSAP256SHA256(13) KSK flags=257 bits=256
  DS     2371 13 2 32996839A6D808AFE3EB4A795A0E6A7A39A76FC52FF228B22B76F6D63826F2B9  → 一致
(終了コード 0)

ietf.org。DNSKEYは上と同一ですが、DSのダイジェストが違い、それでも一致します。

対象        : ietf.org
リゾルバAD  : yes
状態        : secure: 全ての DS が DNSKEY と一致
  DNSKEY 2371  ECDSAP256SHA256(13) KSK flags=257 bits=256
  DNSKEY 34505 ECDSAP256SHA256(13) ZSK flags=256 bits=256
  DS     2371 13 2 B1AE88AFF068DDEC3F7FF662F47D6599C74134425C67106E6C203942D6227EA4  → 一致
(終了コード 0)

RFC 4034 5.4節とRFC 4509 2.3節の例(同じDNSKEYに対するSHA-1のDSとSHA-256のDS)。Key Tag 60485がRFCの「key id = 60485」と一致し、両方のダイジェストが一致します。同時に、RSASHA1とSHA-1に対する警告が出ます。

対象        : dskey.example.com
リゾルバAD  : no
状態        : secure: 全ての DS が DNSKEY と一致
  DNSKEY 60485 RSASHA1(5) ZSK flags=256 bits=1024
  DS     60485 5 1 2BB183AF5F22588179A53B0A98631FAD1A292118  → 一致(SHA-256 があるので無視対象)
  DS     60485 5 2 D4B7D520E7BB5F0F67674A0CCEB1E3E0614B93C4F9E99B8383F6A1E4469DA50A  → 一致
  [INFO] DS 60485 が指す DNSKEY は SEP ビット(257)無し。動作には影響しない(RFC 4034 2.1.1)
  [WARN] DS 60485 のダイジェスト SHA-1 は委任に MUST NOT(IANA / RFC 9905)
  [WARN] DS 60485 のアルゴリズム RSASHA1 は署名に MUST NOT(IANA / RFC 9905)
  [WARN] DNSKEY 60485 のアルゴリズム RSASHA1 は署名に MUST NOT
  [INFO] DNSKEY 60485 は RSA 1024 bit
(終了コード 0)

jprs.jp。RSASHA256で、KSK 2048ビット、ZSK 1024ビットです。

対象        : jprs.jp
リゾルバAD  : yes
状態        : secure: 全ての DS が DNSKEY と一致
  DNSKEY 7240  RSASHA256(8) KSK flags=257 bits=2048
  DNSKEY 21474 RSASHA256(8) ZSK flags=256 bits=1024
  DS     7240 8 2 E147A85589E24FE0DBB5980C73501B5DD656BE5550714F150BE574AE8777B77D  → 一致
  [INFO] DNSKEY 21474 は RSA 1024 bit
(終了コード 0)

実データで踏んだ落とし穴

最初の実行でRFCの例と実データ10件は一致しました。しかしテストを書いて境界を突いたら、5か所直すことになりました。

1. 所有者名はテキストではなくワイヤ形式

これは実装前にRFCで分かっていたことですが、実データで確かめられたのが収穫でした。hashlib.sha256(b"cloudflare.com" + rdata)は9bcb4e5c...、正しいワイヤ形式では32996839...。大文字小文字と末尾ドットの有無で結果が変わらないことも、Cloudflare.COM.で確認しています。

2. RFCの提示形式をそのまま貼ると落ちる

RFC 4034 2.3節やdigの+multiline出力は、公開鍵を( ... )で囲み、末尾に; key id = 60485というコメントを付けます。これをそのまま渡すとbase64.b64decode(validate=True)が「Non-base64 digit found」で例外になりました。;以降を捨て、括弧を空白に置き換えてから分割するようにしました。

3. 同じ指摘が2回出る

RFCの例はDSが2本(SHA-1とSHA-256)あります。DSごとにループして「この鍵はRSASHA1なのでMUST NOT」と指摘していたので、同じ鍵の指摘が2回出ました。指摘を順序を保ったまま重複排除するようにしました。地味ですが、警告が倍に見えると読む側が混乱します。

4. Zone Keyビットが無い鍵を「一致」にしていた

Flagsが1(SEPのみ、Zone Keyなし)のDNSKEYを作り、それに合わせたDSを渡すと、ダイジェストは当然一致します。最初の実装はこれをsecureと判定し、別途ERRORを出すだけでした。RFC 4034 5.2節は「使ってはならない」なので、一致の条件にZone Keyビットを含め、状態もsecureにしないようにしました。

5. 計算できないダイジェストを「bogus」と言っていた

ダイジェスト種別6(SM3)のDSだけがある状況を作ると、最初の実装は「どのDSも一致しない=bogus」と判定しました。RFC 4509 4節によれば、対応していない種別しか無い場合はDSが無いのと同じ(insecure)です。bogusなら検証リゾルバはSERVFAILを返しますが、insecureなら名前解決はできる。意味が全く違うので、状態を分けました。

自分のドメインでは

ac-5.netはDNSSECを有効にしていません(DNSKEYもDSもなし)。権威DNSはCloudflareで、ダッシュボードからDNSSECを有効化すると表示されるDSをレジストラ側に登録する、という流れになります。レジストラ側のDS登録を伴うので、今回は実測と検算までにとどめ、有効化は別の記事で手順と結果を書くつもりです。有効化後は、この記事のスクリプトでpython3 dscheck.py ac-5.netを実行すれば、DSとDNSKEYが一致しているかをその場で検算できます。

まとめ

  • DSのダイジェストはH(所有者名のワイヤ形式 | DNSKEY RDATA)。所有者名が入るので、同じ鍵でもゾーンが違えばDSは違う(cloudflare.comとietf.orgで確認)
  • Key Tagは16ビットごとの和。一意ではないので、照合はダイジェストまで行う
  • DSが指すDNSKEYにはZone Keyビット(256)が必須。SEPビット(257)はヒント
  • SHA-256のDSがあればSHA-1のDSは無視。対応しない種別しか無ければbogusではなくinsecure
  • RFC 9904(2025年11月)でアルゴリズム推奨の正本はIANAレジストリへ。RFC 9905でRSASHA1とSHA-1ダイジェストは署名・委任にMUST NOT
  • 主要30ドメインで署名ありは5件、参照6件を含む署名あり10件は全件一致。8件がECDSAP256SHA256、RSASHA1は0件

参考:RFC 4034(Resource Records for the DNS Security Extensions)2節・5節・6.2節・Appendix B/RFC 4509(Use of SHA-256 in DNSSEC Delegation Signer (DS) Resource Records)2〜4節/RFC 3110(RSA/SHA-1 SIGs and RSA KEYs in the DNS)2節/RFC 9904/RFC 9905/IANA DNS Security Algorithm Numbers・Digest Algorithms(2026年10月3日閲覧)

関連記事

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