ну и снова привет, Хабр!
Я пилю 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 ребёнка. Родитель говорит: «вот этот ключ у моего ребёнка настоящий».
Валидация = идти снизу вверх:
Получили
A example.comиRRSIG A example.com.Запросили
DNSKEY example.com, нашли ZSK с нужным KeyTag, проверили подпись.Запросили
DNSKEY example.comцеликом как rrset, проверили его подпись KSK.Запросили
DS example.comу.com, проверили, что hash KSK совпадает.Дальше валидируем RRSIG на DS у
.comчерез DNSKEY у.com, и так до root.Корневой 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 на все ноды. Я серьёзно.