golang

DNSSEC validation на Go: написал свой validator и не до конца сошёл с ума

  • понедельник, 1 июня 2026 г. в 00:00:16
https://habr.com/ru/articles/1035656/

ну и снова привет, Хабр!

Я пилю VantageDNS, privacy-focused recursive DNS-резолвер с фильтрацией. Edge-фронт на Go, 10 нод по миру, миекговский miekg/dns под капотом. На каком-то этапе у меня закончились отговорки, и пришлось писать DNSSEC validator. Своими руками. Ночью. Под кофе восьмой кружки.

Ниже расскажу, как устроен trust chain, что есть в стандартной библиотеке, какие грабли разложены по дороге, и почему алгоритм 14 я до сих пор обхожу как кота во дворе. В конце ссылки на open-source реализацию, можно поковырять.

Зачем вообще DNSSEC

Если коротко, DNSSEC решает две задачи. Первая, защита от cache poisoning. Атака Каминского, помните? Ей в 2026 году исполнилось 17 лет, а сюрпризов меньше не стало: каждый раз, когда в новостях очередное «BGP-инцидент перенаправил трафик», где-то рядом обязательно лежит резолвер без валидации. Вторая задача — проверка origin: вы убеждаетесь, что ответ действительно подписан владельцем зоны, а не дядей в кафе с openwrt и злыми намерениями.

На практике, по моим замерам на edge-нодах, около 95% доменов в TLD .com/.net до сих пор без RRSIG. Подписаны в основном крупные сервисы, банки, госуслуги и параноики. Но для критичных сценариев DNSSEC реально нужен. И если вы делаете резолвер всерьёз, надо валидировать.

Я сначала пытался его игнорировать. Реальность, как водится, побила меня палкой по голове.

Trust chain в одну страницу

Объясню коротко, чтобы дальше код был понятен.

DNSSEC строится как иерархия подписей от запрошенной записи до root-зоны. На каждом уровне есть три ключевые сущности.

DNSKEY — публичные ключи зоны. Их два типа: KSK (Key Signing Key, флаг 257) подписывает только DNSKEY rrset. ZSK (Zone Signing Key, флаг 256) подписывает остальные RRSet’ы.

RRSIG — собственно подпись. Каждый RRSet в подписанной зоне имеет свой RRSIG.

DS (Delegation Signer) лежит у родителя. Это hash от KSK ребёнка. Родитель говорит: «вот этот ключ у моего ребёнка настоящий».

Валидация = идти снизу вверх:

  1. Получили A example.com и RRSIG A example.com.

  2. Запросили DNSKEY example.com, нашли ZSK с нужным KeyTag, проверили подпись.

  3. Запросили DNSKEY example.com целиком как rrset, проверили его подпись KSK.

  4. Запросили DS example.com у .com, проверили, что hash KSK совпадает.

  5. Дальше валидируем RRSIG на DS у .com через DNSKEY у .com, и так до root.

  6. Корневой DNSKEY проверяется против trust anchor, публичного ключа IANA, который вы зашили в код или подтянули из файла.

Trust anchor — единственная точка, где доверие приходит «снаружи». Всё остальное криптография.

Что есть в miekg/dns

Библиотека github.com/miekg/dns это фундамент. Она парсит wire-format, считает KeyTag, реализует RRSIG.Verify() для всех живых алгоритмов. Я её обожаю, как обожают старого кота: за то, что есть и не требует объяснений.

Но самого алгоритма валидации в ней нет. И это правильно. Security-critical логика должна быть в вашем коде, чтобы вы понимали, где она ошибается, а не молились на чёрный ящик. miekg/dns даёт примитивы, собирать chain надо самому.

Алгоритм по шагам

Сначала валидация одного RRSet. Это база, на которой строится всё остальное.

func (r *Resolver) ValidateRRSet(rrset []dns.RR, rrsigs []*dns.RRSIG, keyset []*dns.DNSKEY) error { if len(rrsigs) == 0 { return ErrNoSignature } now := time.Now().Unix()

for _, rrsig := range rrsigs {
    if int64(rrsig.Inception) > now || int64(rrsig.Expiration) < now {
        continue
    }
    for _, k := range keyset {
        if k.KeyTag() != rrsig.KeyTag {
            continue
        }
        if k.Algorithm != rrsig.Algorithm {
            continue
        }
        if err := rrsig.Verify(k, rrset); err == nil {
            return nil
        }
    }
}
return ErrNoValidSig

}

Дальше рекурсивный подъём по цепочке. Идея простая: для зоны Z проверяем DNSKEY rrset (KSK подписывает DNSKEY, ZSK подписывает всё остальное), потом запрашиваем DS у родителя и убеждаемся, что KSK ребёнка соответствует DS.

Полная функция ValidateChain (упрощённый вариант)

func (r *Resolver) ValidateChain(ctx context.Context, name string, qtype uint16) error { rrset, rrsigs, err := r.fetchSigned(ctx, name, qtype) if err != nil { return err }

zone := signerName(rrsigs)
if zone == "" {
    return ErrNoSigner
}

keyset, keysigs, err := r.fetchSigned(ctx, zone, dns.TypeDNSKEY)
if err != nil {
    return err
}
keys := toDNSKEY(keyset)

if err := r.ValidateRRSet(rrset, rrsigs, keys); err != nil {
    return fmt.Errorf("rrset %s: %w", name, err)
}
if err := r.ValidateRRSet(keyset, keysigs, keys); err != nil {
    return fmt.Errorf("dnskey %s: %w", zone, err)
}

if zone == "." {
    return r.matchTrustAnchor(keys)
}

parent := dns.Fqdn(strings.SplitN(zone, ".", 2)[1])
dsset, dssigs, err := r.fetchSigned(ctx, zone, dns.TypeDS)
if err != nil {
    return err
}

if err := r.matchDS(keys, dsset); err != nil {
    return err
}

parentKeys, _, err := r.cachedKeys(ctx, parent)
if err != nil {
    return err
}
if err := r.ValidateRRSet(dsset, dssigs, parentKeys); err != nil {
    return fmt.Errorf("ds %s: %w", zone, err)
}

return r.ValidateChain(ctx, parent, dns.TypeDNSKEY)

}

Реальная версия в репо длиннее: там кэширование промежуточных DNSKEY/DS, обработка CNAME (сменилась зона, chain идёт по новой ветке), backoff на сетевые ошибки. Но костяк такой.

Корневой DS я держу как захардкоженную структуру с возможностью override через файл. RFC 5011 пока упрощённо, об этом дальше.

Грабли, которые я собрал

Алгоритмы

На 2026 год живые алгоритмы для DNSSEC такие.

Алгоритм 8, RSASHA256. Старый workhorse, до сих пор большинство подписанных зон.

Алгоритм 13, ECDSA P-256 с SHA-256. Современный default, root его уже использует.

Алгоритм 15, Ed25519. Растёт, но медленно.

Алгоритм 14 (ECDSA P-384) в живой природе встречается реже, чем падающий метеорит. Я его поддерживаю формально, но если у вас нет времени, спокойно начинайте без него. RSAMD5 и RSASHA1 надо отвергать как insecure.

Time skew

RRSIG имеет поля Inception и Expiration в Unix-секундах. Если у edge-ноды NTP уехал больше чем на пару минут, валидация лопается, причём тихо: вы видите ErrNoValidSig и думаете, что зона сломана.

Реальная история: одна моя нода с broken systemd-timesyncd неделю валила DNSSEC у части юзеров. Заметил, когда пришёл тикет «у меня банк не открывается, у соседа открывается». Пошёл смотреть, timedatectl показывает «System clock synchronized: no», и часы на 12 минут вперёд.

С тех пор у меня в healthcheck’е проверка skew против двух независимых NTP-источников, и метрика dnssec_validation_clock_skew_seconds в Prometheus. Без флагов тестирую на себе, что довольно унизительно, но работает.

NSEC и NSEC3

Отдельная история, отрицательные ответы. «Домен не существует» тоже надо доказать криптографически, иначе атакующий просто скажет «нет такой записи» и обойдёт валидацию.

NSEC — связанный список имён в зоне, отсортированных canonically. Зона подписывает «между aaa.example.com и ccc.example.com ничего нет», и вы убеждаетесь, что bbb.example.com действительно попадает в этот промежуток.

NSEC3 устроен так же, но с хэшированными именами, чтобы нельзя было zone walking’ом перечислить все домены в зоне. Реализация прыжков по hash-space, отдельный квест на пару вечеров. Я честно отложил полную NSEC3-валидацию на v2, в первой версии валидирую NSEC и для NSEC3 fallback в insecure.

Loose validators и сломанные цепи

В реальности куча зон подписана криво. Самый частый случай: основная зона подписана, у неё есть CNAME на чужой домен, и там никаких подписей нет. Формально по RFC надо отдавать SERVFAIL.

На практике, если ты резолвер для конечных юзеров, SERVFAIL это «у меня интернет не работает», и юзер уходит на 8.8.8.8. Поэтому я сделал режим permissive: при broken chain логирую и отдаю insecure-ответ с пометкой в query log. Параноикам остаётся strict-режим в профиле.

Trust anchor rotation

ICANN ротировал root KSK в 2018 году, и тогда упало много резолверов, потому что их операторы забыли обновить trust anchor. С тех пор есть RFC 5011, automated rollover: резолвер сам подхватывает новый KSK, если он подписан старым, и через hold-down период доверяет новому.

В первой версии я RFC 5011 полностью не делал, у меня просто файл trust-anchors.xml (тот самый, что публикует IANA), который подтягивается при апдейте edge-бинарника. Это работает, пока вы выпускаете релизы чаще, чем ICANN ротирует ключи. То есть всегда. Но для serious deployment 5011 надо доделать, и я доделаю, как только закроется текущий спринт.

Чего я не сделал в первой версии и пожалел

Кэш negative validations. Каждый NXDOMAIN валидировал заново, все NSEC/NSEC3 заново парсил, все подписи заново проверял. На потоке трафика типичной edge-ноды это съедало ощутимо CPU и добавляло latency на «несуществующие» домены, которых, как известно, в дикой природе очень много (всякий malware-DGA, опечатки, телеметрия отвалившихся сервисов).

Лечится отдельным negative-cache, где ключом служит (qname, qtype, NSEC-proof-hash). Сделал во второй итерации, latency на NXDOMAIN упала в разы.

Вторая ошибка, не оптимизировал крипто. Один RRSet может иметь 5-10 RRSIG (зона использует несколько ключей или меняет алгоритм). Я перебирал их подряд, проверяя все. На большом DNSKEY rrset с RSA это заметно. Решение: сначала фильтр по KeyTag/Algorithm, потом по времени, и только потом дорогая verify.

Включать ли по умолчанию

У меня DNSSEC валидация opt-in: профиль dnssec_validation: true. На free-плане выключена по умолчанию по двум причинам.

Первая, большинство юзеров на free просто не парятся. Им нужно «реклама не показывается, и слава богу». Если что-то начнёт SERVFAIL’ить, пойдёт ругань в саппорт, разбираться с broken chain у них нет времени и желания.

Вторая, у меня самого до сих пор edge-cases вылезают раз в неделю. Кто-то жалуется, что какой-то региональный сайт не резолвится. Идёшь смотреть, у них KSK с алгоритмом 14 и expiration ушёл вчера. Оператор зоны спит. Ты ничего не можешь.

На paid-плане планирую сделать default-on через 6 месяцев, когда все эти кейсы каталогизирую и научусь либо обходить, либо аргументированно объяснять «у вас сломан DNSSEC, идите к админу».

Как тестировать

Есть несколько публичных тест-зон:

  • dnssec-failed.org, специально с broken signature, должен SERVFAIL’ить.

  • dnssec-tools.org, корректно подписан, должен резолвиться.

  • internetsociety.org, большой реальный сайт с DNSSEC, хорош для smoke-теста.

dig +dnssec @dns.vantagedns.com/ dnssec-failed.org dig +dnssec @dns.vantagedns.com/ dnssec-tools.org A

В первом случае ожидаем status: SERVFAIL. Во втором, status: NOERROR и флаг ad (Authenticated Data) в ответе. Если флага ad нет, валидация не сработала, идите смотреть логи.

В юнит-тестах я использую захардкоженные wire-format ответы из testdata/, генерил их через dig +dnssec +noall +answer и сохранял. Так тесты не зависят от внешней сети и не падают, когда у тестовой зоны истекли подписи.

Где код

Свой DNSSEC-модуль я готовлю к открытию, но репозиторий пока приватный — рядом лежат blocklist-engine и shipper, которые ещё не разнесены по чистым границам, в них есть операционная специфика конкретно моих 10 нод. Если хочется посмотреть на конкретный фрагмент (validator, trust-anchor loader, NSEC3 walk) для своей системы — напишите на ms@vantagedns.com, пришлю тарбол с тестами. Особенно благодарен буду за замечания по NSEC3 и алгоритму 14, я честно признался, что это слабые места.

Что я понял

DNSSEC сложнее, чем кажется по википедии, и проще, чем пугают на конференциях. Если делаете свой резолвер, не игнорируйте, но и не пишите валидатор в первый месяц. Сначала нормальный кэш, негативные ответы, ECS, фильтрация. Когда базовая часть работает и не падает под нагрузкой, тогда DNSSEC. К этому моменту у вас уже будет инфраструктура для тестов, метрик и алёртов, без которой validator превратится в чёрный ящик с настроением.

И поставьте, пожалуйста, нормальный NTP на все ноды. Я серьёзно.

Ссылки