CAAレコード(RFC 8659)の読み方|主要30ドメインを調べたら「置いてあるのに効いていない」が2件あった

Security

TLS証明書を取るとき、CAは発行前に必ずCAAレコード(RFC 8659)を確認します。「このドメインの証明書を発行してよいCAはどこか」をDNSで宣言する仕組みで、置いておけば、契約していないCAから勝手に証明書が発行されるリスクを下げられます。

名前は知っていても、実際にどれくらい使われているかの感覚がなかったので、MTA-STSの記事と同じ主要30ドメインでCAAを調べました。結果は、CAAを置いているのは11ドメイン、そのうち2ドメインは置いているのに発行を制限していないというものでした。この「置いてあるのに効いていない」状態がRFCのどこから来るのかを含めて、仕様を一次資料で確認し、判定スクリプトを書き、実データで踏んだ落とし穴をまとめます。

RFC 8659 で確認した要点

RFC 8659は2019年11月にRFC 6844を置き換えた現行仕様です。要点を原文で確認しました。

Relevant RRset(3節):親へ登って最初に見つかったRRsetが効く

CAは要求されたFQDNから始めて、ルート直前まで親へ1ラベルずつ登り、最初に見つかった空でないCAA RRsetを採用します。擬似コードは次のとおりです。

RelevantCAASet(domain):
    while domain is not ".":
        if CAA(domain) is not Empty:
            return CAA(domain)
        domain = Parent(domain)
    return Empty

つまりdeveloper.okta.comにCAAが無ければokta.comのCAAが適用されます。サブドメインを外部サービスに向けていても、発行制限は親ドメインの宣言に従います。

もう1つ重要なのが、Relevant RRsetにissue/issuewildが1つも無ければ、CAAは発行を制限しないという規定です。3節に明記されています。

If the Relevant RRset for an FQDN or Wildcard Domain Name contains no Property Tags that restrict issuance (for instance, if it contains only iodef Property Tags or only Property Tags unrecognized by the CA), CAA does not restrict issuance.

これが後述の「置いてあるのに効いていない」2件の理由です。

RFC 6844との最大の違い:CNAMEの先の木は登らない(7節)

旧RFC 6844は、途中でCNAME/DNAMEに当たるとその先の名前の木も登るアルゴリズムでした。ホスティング事業者が自社ドメインにCAAを置くと、顧客のCNAME経由でその制限が顧客ドメインに波及してしまう問題があり、RFC 8659はこれを廃止しました。現行仕様では、CNAMEの追跡はCAの再帰リゾルバに任せ、CAが自分で登るのは要求されたFQDNの木だけです。

issue/issuewild/iodef(4.2〜4.4節)

  • issue "ca.example" … そのCAに発行を許可。複数あれば加算的。issue ";"(発行者名が空)は「誰にも発行させない」の意思表示
  • 文法に合わない値(例 issue "%%%%%")は空の発行者名と同じに扱わなければならない(MUST)=発行禁止。空と非空が混在すれば非空だけを見ればよい
  • issuewild … ワイルドカード証明書(*.example)専用。非ワイルドカード要求では無視され、ワイルドカード要求で1つでもあればissueはすべて無視される
  • iodef … 違反報告先(mailto:/http/httpsのみ)。発行制限には関与しない
  • パラメータは;区切りのtag=value。意味はCAが決める(例:account=、accounturi=、cansignhttpexchanges=yes)

criticalフラグ(4.5節)

Flagsのbit 0(MSB)がcriticalで、表示形式では128がそれに当たります。criticalが立った未知のタグがRelevant RRsetにあると、そのタグを実装していないCAは誰であっても発行してはならない(MUST NOT)。Flags値1はbit 7なので、criticalではありません。

DNSSEC(5.1節)

CAAの検証にDNSSECを使うことは「強く推奨(strongly RECOMMENDED)」ですが必須ではありません。署名済みの空応答は「CAAが無い」ことの証明になり、署名が無い空応答は「抑止された可能性」を排除できない、という違いがあります。

主要30ドメインの実測(2026年9月26日)

DNS over HTTPS(dns.google)で各ドメインのCAA(type 257)を引きました。応答のADフラグでDNSSEC検証済みかも見ています。

CAAレコードあり : 11 / 30
  海外系 10件中 8件  google.com, microsoft.com, apple.com, cloudflare.com,
                      github.com, zoom.us, slack.com, okta.com
  日本系 20件中 3件  cybozu.com, yahoo.co.jp, softbank.jp

  なし(海外): amazon.com, salesforce.com
  なし(日本): nifty.com, moneyforward.com, mercari.com, ntt.com, kddi.com,
                rakuten.co.jp, biglobe.ne.jp, ocn.ne.jp, so-net.ne.jp,
                sakura.ad.jp, freee.co.jp, line.me, jal.co.jp, ana.co.jp,
                mufg.jp, smbc.co.jp, japanpost.jp

うち issue / issuewild で発行を制限しているもの : 9 / 30
  制限していない2件 : microsoft.com, softbank.jp(contactemail タグのみ)

DNSSEC検証済み(AD=true): 5 / 30
  cloudflare.com, salesforce.com, slack.com, mufg.jp, smbc.co.jp

目を引いた点をいくつか挙げます。

  • microsoft.comとsoftbank.jpはcontactemailタグしか置いていない。contactemailはRFC 8659には定義されておらず、issueでもissuewildでもないため、3節の規定によりCAAは発行を何も制限していません。CAAレコードが「ある」ことと「効いている」ことは別です
  • cloudflare.comは11レコード。issueとissuewildを同じCA群で対にし、パラメータcansignhttpexchanges=yesを付けている
  • slack.comはaccount=とaccounturi=でCAアカウントまで縛っている。同じDigiCertやLet’s Encryptの利用者であっても、別アカウントからは発行できない
  • okta.comはissueにletsencrypt.orgがあるが、issuewildはdigicert.comとglobalsign.comだけ。つまり*.okta.comのワイルドカード証明書はLet’s Encryptからは発行できません(後述の落とし穴3)
  • yahoo.co.jpのdigicert.com;cansignhttpexchanges=yesは;の後に空白が無い。cloudflare.comは空白あり。どちらもABNF上は合法です(後述の落とし穴2)
  • 日本の20ドメインでは、CAAを置いてかつ発行を制限しているのはcybozu.comとyahoo.co.jpの2件だけでした

なお、本サイト(ac-5.net)にもこの記事の執筆時点ではCAAを置いていませんでした。調べる側に回って初めて気づくというのは、よくある話です。

判定スクリプト

Python標準ライブラリだけで動きます。DoHでCAAを引き、RFC 8659の3節どおりに親へ登ってRelevant RRsetを求め、指定したCAが発行できるかを判定します。--fixturesにJSONを渡せばネットワークに出ずに動くので、検証用の応答を固定して試せます。

#!/usr/bin/env python3
"""caacheck.py - CAA (RFC 8659) の Relevant RRset を求め、発行可否を判定する

使い方:
    ./caacheck.py <FQDN> <issuer-domain-name> [--wildcard]
    例: ./caacheck.py developer.okta.com letsencrypt.org
        ./caacheck.py okta.com letsencrypt.org --wildcard

DNSは DoH(dns.google)で引く。--fixtures <json> を渡すと
ネットワークに出ず、JSONに書いた応答で動く(テスト用)。
"""
import sys, json, re, urllib.request

TIMEOUT = 10
UA = 'caacheck/1.0'
RESTRICTIVE_TAGS = ('issue', 'issuewild')   # RFC 8659 3.: これ以外だけなら発行を制限しない


# ----------------------------------------------------------------------
# DNS
# ----------------------------------------------------------------------
def doh_caa(name):
    """dns.google に CAA を問い合わせる。戻り値は (status, ad, [presentation形式の文字列])

    DoH の CAA 応答の data は '0 issue "pki.goog"' のような表示形式で返る。
    Answer が無い応答(NXDOMAIN / NOERROR-NODATA)では 'Answer' キー自体が無い。
    CNAME は dns.google が追ってくれる(RFC 8659 3. の「エイリアスは追う」に相当)。
    """
    url = 'https://dns.google/resolve?name=%s&type=CAA' % name
    req = urllib.request.Request(url, headers={'User-Agent': UA})
    with urllib.request.urlopen(req, timeout=TIMEOUT) as r:
        j = json.load(r)
    caa = [a.get('data', '') for a in j.get('Answer', []) if a.get('type') == 257]
    return j.get('Status'), bool(j.get('AD')), caa


def make_fixture_lookup(path):
    """fixtures JSON: {"name": {"Status":0,"AD":false,"caa":["0 issue \"x\""]}, ...}
    無い名前は NOERROR-NODATA 扱い(空)。"""
    with open(path, encoding='utf-8') as f:
        table = json.load(f)

    def lookup(name):
        e = table.get(name.rstrip('.').lower())
        if not e:
            return 0, False, []
        return e.get('Status', 0), bool(e.get('AD')), list(e.get('caa', []))
    return lookup


# ----------------------------------------------------------------------
# パース
# ----------------------------------------------------------------------
_PRES = re.compile(r'^\s*(\d{1,3})\s+([A-Za-z0-9-]+)\s+"?(.*?)"?\s*$')


def parse_record(pres):
    """'0 issue "pki.goog"' → {'flags':0,'tag':'issue','value':'pki.goog','critical':False}

    RFC 8659 4.1: タグの照合は大文字小文字を区別しない → 小文字に正規化する。
    Flags の bit 0(MSB)が critical。表示形式では 128 がそれに当たる。
    """
    m = _PRES.match(pres)
    if not m:
        raise ValueError('CAA presentation format ではない: %r' % pres)
    flags = int(m.group(1))
    return {'flags': flags, 'tag': m.group(2).lower(), 'value': m.group(3),
            'critical': bool(flags & 0x80)}


_LABEL = r'[A-Za-z0-9](?:-*[A-Za-z0-9])*'
_ISSUER = re.compile(r'^\s*(%s(?:\.%s)*)?\s*(?:;(.*))?$' % (_LABEL, _LABEL))


def parse_issue_value(value):
    """issue / issuewild の値を (issuer_domain_name or None, {param: value}) にする

    RFC 8659 4.2 ABNF:
      issue-value = *WSP [issuer-domain-name *WSP] [";" *WSP [parameters *WSP]]
    ・"digicert.com; account=1"  も  "digicert.com;cansignhttpexchanges=yes" も合法
      → 空白の有無に依存してはいけない。';' で切ってから strip する
    ・issuer-domain-name が空(";" だけ)→ 発行禁止の意思表示
    ・文法に合わない値 → 空の issuer-domain-name と同じに扱う MUST(= 禁止)
    """
    m = _ISSUER.match(value)
    if not m:
        return None, {}          # malformed → 空扱い(禁止)
    issuer = (m.group(1) or '').strip().lower() or None
    params = {}
    rest = m.group(2) or ''
    for p in rest.split(';'):
        p = p.strip()
        if not p:
            continue
        if '=' not in p:
            return None, {}      # parameter は tag=value 必須。崩れていたら malformed
        k, v = p.split('=', 1)
        params[k.strip().lower()] = v.strip()
    return issuer, params


# ----------------------------------------------------------------------
# RFC 8659 3. Relevant RRset
# ----------------------------------------------------------------------
def relevant_rrset(fqdn, lookup=doh_caa):
    """ルート直前まで親へ登り、最初に見つかった空でない CAA RRset を返す。

    CNAME/DNAME の追跡は再帰リゾルバの仕事(RFC 8659 3. および 7.)。
    RFC 6844 のように CNAME の先の木まで自分で登ってはいけない。
    戻り値: (見つかった名前 or None, [record dict], climbed_names, ad)
    """
    name = fqdn.rstrip('.').lower()
    climbed = []
    while name and name != '.':
        climbed.append(name)
        status, ad, recs = lookup(name)
        if status not in (0, 3):
            # SERVFAIL 等。RFC 8659 6.3 は CA が「発行不可」と解釈してよい(MAY)としている
            raise RuntimeError('DNS status %s for %s' % (status, name))
        if recs:
            return name, [parse_record(r) for r in recs], climbed, ad
        if '.' not in name:
            break
        name = name.split('.', 1)[1]
    return None, [], climbed, False


# ----------------------------------------------------------------------
# 判定
# ----------------------------------------------------------------------
def evaluate(rrset, issuer_domain, wildcard=False):
    """Relevant RRset に対して、issuer_domain が発行できるかを判定する。

    戻り値: (allowed: bool, reason: str)
    """
    issuer_domain = issuer_domain.lower().rstrip('.')

    # 4.5 critical フラグ付きの未知タグがあれば、誰も発行できない
    for r in rrset:
        if r['critical'] and r['tag'] not in ('issue', 'issuewild', 'iodef'):
            return False, 'critical な未知タグ %r があるため発行不可(RFC 8659 4.5)' % r['tag']

    # 3. 制限するタグが無ければ CAA は発行を制限しない
    restrictive = [r for r in rrset if r['tag'] in RESTRICTIVE_TAGS]
    if not restrictive:
        others = sorted({r['tag'] for r in rrset})
        return True, 'issue/issuewild が無い(%s のみ)ため CAA は発行を制限しない(RFC 8659 3.)' % (
            ','.join(others) if others else 'なし')

    # 4.3 wildcard 要求で issuewild が1つでもあれば issue は全て無視
    if wildcard:
        wilds = [r for r in rrset if r['tag'] == 'issuewild']
        effective = wilds if wilds else [r for r in rrset if r['tag'] == 'issue']
        basis = 'issuewild' if wilds else 'issue(issuewild が無いので流用)'
    else:
        effective = [r for r in rrset if r['tag'] == 'issue']   # issuewild は非wildcardでは無視
        basis = 'issue'

    if not effective:
        # 例: issuewild だけあって issue が無い場合の非wildcard要求 → 制限なし
        return True, '%s 要求に適用されるタグが無いため制限しない' % ('wildcard' if wildcard else '通常')

    allowed_names = set()
    forbid_only = True
    for r in effective:
        issuer, params = parse_issue_value(r['value'])
        if issuer:
            allowed_names.add(issuer)
            forbid_only = False
    # 4.2 additive: 空の issuer と非空の issuer が混在すれば非空だけを見ればよい
    if forbid_only:
        return False, '%s の値がすべて空または不正のため、どの CA も発行できない(RFC 8659 4.2)' % basis
    if issuer_domain in allowed_names:
        return True, '%s に %s が含まれる' % (basis, issuer_domain)
    return False, '%s に %s が無い(許可: %s)' % (basis, issuer_domain, ', '.join(sorted(allowed_names)))


def check(fqdn, issuer_domain, wildcard=False, lookup=doh_caa):
    found_at, rrset, climbed, ad = relevant_rrset(fqdn, lookup)
    allowed, reason = evaluate(rrset, issuer_domain, wildcard)
    return {'fqdn': fqdn, 'wildcard': wildcard, 'issuer': issuer_domain,
            'relevant_at': found_at, 'climbed': climbed, 'dnssec_ad': ad,
            'rrset': rrset, 'allowed': allowed, 'reason': reason}


def main(argv):
    args = [a for a in argv if not a.startswith('--')]
    if len(args) < 2:
        print(__doc__)
        return 2
    wildcard = '--wildcard' in argv
    lookup = doh_caa
    if '--fixtures' in argv:
        lookup = make_fixture_lookup(argv[argv.index('--fixtures') + 1])
    res = check(args[0], args[1], wildcard, lookup)
    print('対象        : %s%s' % ('*.' if wildcard else '', res['fqdn']))
    print('登った名前  : %s' % ' → '.join(res['climbed']))
    print('Relevant    : %s' % (res['relevant_at'] or '(なし)'))
    print('DNSSEC(AD)  : %s' % ('yes' if res['dnssec_ad'] else 'no'))
    for r in res['rrset']:
        print('  %3d %-12s %s' % (r['flags'], r['tag'], r['value']))
    print('判定        : %s  %s' % ('許可' if res['allowed'] else '不可', res['reason']))
    return 0 if res['allowed'] else 1


if __name__ == '__main__':
    sys.exit(main(sys.argv[1:]))

実行例

取得した実データをフィクスチャにして実行した結果です(メールアドレスは伏せています)。

対象        : developer.okta.com
登った名前  : developer.okta.com → okta.com
Relevant    : okta.com
DNSSEC(AD)  : no
    0 iodef        mailto:(省略)@okta.com
    0 iodef        mailto:(省略)@okta.com
    0 issue        pki.goog
    0 issue        amazonaws.com
    0 issuewild    digicert.com
    0 issue        digicert.com
    0 issuewild    globalsign.com
    0 issue        globalsign.com
    0 issue        letsencrypt.org
判定        : 許可  issue に letsencrypt.org が含まれる
(終了コード 0)

developer.okta.com自体にはCAAが無く、CNAMEでCDNに向いていますが、親のokta.comへ登ってそこで判定しています。

対象        : *.okta.com
登った名前  : okta.com
Relevant    : okta.com
DNSSEC(AD)  : no
    0 iodef        mailto:(省略)@okta.com
    0 iodef        mailto:(省略)@okta.com
    0 issue        pki.goog
    0 issue        amazonaws.com
    0 issuewild    digicert.com
    0 issue        digicert.com
    0 issuewild    globalsign.com
    0 issue        globalsign.com
    0 issue        letsencrypt.org
判定        : 不可  issuewild に letsencrypt.org が無い(許可: digicert.com, globalsign.com)
(終了コード 1)

同じokta.comでも、ワイルドカード要求ではissuewildだけが効くため、Let’s Encryptは不可になります。

対象        : microsoft.com
登った名前  : microsoft.com
Relevant    : microsoft.com
DNSSEC(AD)  : no
    0 contactemail (省略)@microsoft.com
判定        : 許可  issue/issuewild が無い(contactemail のみ)ため CAA は発行を制限しない(RFC 8659 3.)
(終了コード 0)

CAAレコードは存在するのに、issue/issuewildが無いので制限なし、と判定しています。

対象        : www.yahoo.co.jp
登った名前  : www.yahoo.co.jp → yahoo.co.jp
Relevant    : yahoo.co.jp
DNSSEC(AD)  : no
    0 issue        cybertrust.ne.jp
    0 issue        digicert.com;cansignhttpexchanges=yes
    0 iodef        mailto:(省略)@mail.yahoo.co.jp
    0 issue        globalsign.com
判定        : 不可  issue に letsencrypt.org が無い(許可: cybertrust.ne.jp, digicert.com, globalsign.com)
(終了コード 1)

www.yahoo.co.jpはCNAMEでyimg.jp配下に向いていますが、RFC 8659ではその先の木は登らず、yahoo.co.jpのCAAに従います。

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

書いてから、フィクスチャで5つ直しました。どれも例外は出ません。

1. 「CAAがある=制限されている」ではない

最初の実装は「Relevant RRsetが空でなければ、指定CAがissueに無い限り不可」としていました。これだとmicrosoft.com(contactemailのみ)で「Let’s Encryptは発行不可」と誤判定します。RFC 8659の3節どおり、issue/issuewildが1つも無ければ制限なしにする必要があります。

2. パラメータの区切りは空白に依存してはいけない

発行者名を「最初の空白まで」で切る素朴な実装は、yahoo.co.jpのdigicert.com;cansignhttpexchanges=yesで発行者名をdigicert.com;cansignhttpexchanges=yesと読んでしまい、DigiCertを不一致にします。ABNFは*WSPなので空白は「あってもなくてもよい」。;で切ってからstripするのが正しい読み方です。

3. ワイルドカードではissuewildがissueを上書きする

issueとissuewildを合算して「どちらかに含まれていれば許可」とすると、*.okta.comでLet’s Encryptが通ってしまいます。4.3節は「issuewildが1つでもあれば、ワイルドカード要求ではissueをすべて無視する(MUST)」です。逆に、issuewildが無ければissueがワイルドカードにも適用されます。

4. 木登りはするが、CNAMEの先の木は登らない

要求されたFQDNで空だったからといって「CAAなし」で終えると、developer.okta.comのように親で制限しているケースを取りこぼします。一方で、RFC 6844の感覚でCNAMEの先(www.yahoo.co.jpならyimg.jp)の木まで登ると、現行仕様と違う結果になります。CNAMEはリゾルバが追った結果をそのまま受け取り、自分で登るのは要求名の木だけ、が正解です。www.github.comのようにCNAME先(github.com)にCAAがあれば、最初の問い合わせでリゾルバが追った結果が返ってくるので、そこで確定します。

5. タグは大文字小文字を区別しない。criticalは128

ISSUEと書かれていてもissueとして扱う必要があります(4.1節)。Flagsは128がcriticalで、1はbit 7なのでcriticalではありません。表示形式の数字をそのまま「0以外はcritical」と読むと間違えます。

自分のドメインにどう置くか

Let’s Encryptだけを使うサイトなら、最低限これで足ります。

example.jp.  CAA 0 issue "letsencrypt.org"
example.jp.  CAA 0 iodef "mailto:[email protected]"

ワイルドカード証明書も同じCAで取るなら、issuewildを明示しておくと意図が明確になります(issuewildが無ければissueが流用されるので、省略しても動作は同じです)。別のCAに切り替える予定があるなら、切り替え前にレコードを足しておかないと、新しいCAでの発行が止まります。CAAの反映はDNSのTTL次第なので、証明書の更新日直前に変更しないことも大事です。

逆に、複数のサービスにサブドメインを預けている場合は、それぞれのサービスがどのCAで証明書を発行するかを把握してから置く必要があります。親ドメインのCAAはサブドメインにも効くため(3節)、把握していないCAを使っているサービスがあると、そのサービスの証明書更新が失敗します。

まとめ

  • CAAは要求名から親へ登って最初に見つかったRRsetが効く。サブドメインは親の宣言に従う
  • issue/issuewildが無ければ制限しない。CAAが「ある」ことと「効いている」ことは別(主要30ドメイン中2件がこの状態)
  • ワイルドカードではissuewildがissueを上書きする
  • CNAMEの先の木は登らない(RFC 6844からの最大の変更点)
  • パラメータの区切りは;。空白の有無に依存しない
  • 主要30ドメインでCAAを置いているのは11件、日本の20ドメインでは3件。発行を制限しているのはそれぞれ9件と2件

参考:RFC 8659(DNS Certification Authority Authorization (CAA) Resource Record)3節・4節・5.1節・7節

コードとテスト

判定スクリプト、フィクスチャ、RFC 8659の例をそのまま突き合わせたテストはGitHubに置いています。python3 test_bugs.py で38件のテストがオフラインで通ります。

github.com/ac5net/caacheck

関連記事

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