Практикум: примеры с dig для DNSSEC и DNS
Небольшой, – зато самодостаточный, – практикум по “хакерскому исследованию” DNSSEC.
В DNSSEC “цепочка доверия” выстраивается при помощи связки делегирующих записей, ключей и, собственно, цифровой подписи. Делегирующая запись – это DS (Delegation Signer), которая содержит отпечаток ключа из зоны уровнем ниже. Ключи размещаются в записях DNSKEY, а подписи – в RRSIG. Иногда спрашивают, можно ли простым методом посмотреть на детали устройства DNSSEC. Конечно, совсем вручную – будет медленно, но можно воспользоваться “низкоуровневым” режимом утилиты dig, чтобы извлечь байты нужных записей, а “сходимость” записей непосредственно проверить при помощи OpenSSL. Всё в консоли, то есть, можно сказать, что в ручном режиме. Это упражнение и разобрано ниже. Оно помогает понять практическую сторону DNSSEC.
Утилита dig – это инструмент для непосредственной работы с DNS из пакета BIND, входит в стандартный набор для данной технологической области. При помощи dig можно отправлять DNS-запросы в адрес различных интернет-узлов, получать вывод в доступной форме, а при необходимости – получать байтовые дампы, чем и воспользуемся. Общий смысл запросов должен быть очевиден, а заковыристые опции и загадочные термины ниже объясняются по мере их появления. Утилита dig не единственный вариант, но здесь используем именно её. Кроме dig и openssl dgst задействованы типовые для современных линуксов (и unix-подобных систем вообще) консольные средства: xxd, cat и др. Я здесь использую dig версии 9.16.50 под Debian 11, но это не является требованием.
План достаточно простой: получить базовые записи, относящиеся к DNSSEC, из DNS; сравнить ключи и отпечатки, что называется, вручную; убедиться, что всё сходится, сверив, как минимум, одну цифровую подпись “вручную”, то есть, при помощи инструментария openssl.
Утилита dig, при самом простом вызове (dig example.com), запрашивает A-запись у локального (системного) резолвера. По умолчанию dig печатает большое количество сведений и о запросе, и об ответе, и об ответившем сервере. Чтобы не перегружать текст, ниже используются различные дополнительные опции (+short, +noall и др.), позволяющие управлять составом вывода dig.
В качестве источника примеров здесь используется DNS-зона example.com, которая не только предназначена для того, чтобы служить зоной-примером, но ещё и удачно подписана DNSSEC (что в современной глобальной DNS – редкость).
Итак, для начала нужно узнать, какие серверы имён отвечают за зону com.
$ dig -t NS com. +noall +authority @a.root-servers.net com. 172800 IN NS l.gtld-servers.net. com. 172800 IN NS j.gtld-servers.net. com. 172800 IN NS h.gtld-servers.net. com. 172800 IN NS d.gtld-servers.net. com. 172800 IN NS b.gtld-servers.net. com. 172800 IN NS f.gtld-servers.net. com. 172800 IN NS k.gtld-servers.net. com. 172800 IN NS m.gtld-servers.net. com. 172800 IN NS i.gtld-servers.net. com. 172800 IN NS g.gtld-servers.net. com. 172800 IN NS a.gtld-servers.net. com. 172800 IN NS c.gtld-servers.net. com. 172800 IN NS e.gtld-servers.net.
За этим несложным запросом и строчками ответа скрывается большое количество логических элементов DNS. Пока что непосредственно до DNSSEC мы не добрались, но описываемые подготовительные шаги прямо относятся и к устройству DNSSEC.
Расшифровка запроса dig:
-t NS – означает, что запрашивать нужно NS-записи (то есть, записи, содержащие перечень NS-серверов);
com. – это имя зоны, для которой производится поиск: это ключ для поиска, поскольку DNS является распределённой базой данных типа “ключ-значение”;
+noall +authority – это параметры, управляющие отображением результата; в dig вывод разбит на блоки различного назначения, а первый слева параметр (+noall) запрещает вывод всех этих блоков; второй параметр – включает вывод блока “авторитативной части” (Authority – см. ниже); другими словами – чтобы не множить опции, отключая отдельные блоки, сначала отключены все, а потом включен единственный блок, который мы тут хотим видеть – Authority.
@a.root-servers.net. – это DNS-имя одного из корневых серверов (про то, как это имя превращается в IP-адрес – см. ниже); сведения о зоне com. мы спрашиваем сначала у корневых серверов, обслуживающих вершину глобальной DNS.
В DNS каждая доменная зона обслуживается специальными серверами – серверами имён, которые уполномочены отвечать на запросы об именах в данной зоне. Такие серверы называют “авторитативными серверами” (или “авторитетными”). Авторитативными – серверы назначают в результате выполнения процесса делегирования: список прописывается в зоне уровнем выше. Вся глобальная DNS по именам выстроена в иерархию, ведущую к единому (пока что) корню. Эта иерархия имеет ключевое значение и для DNSSEC. “Ключевое” – в прямом смысле. В мире DNS корневой домен настолько важен, что он обозначается пустым множеством, чтобы математически подчеркнуть фундаментальность. Корневой домен отделяется в DNS-именах крайней справа точкой, вот так: habr.com. – это называется FQDN (fully-qualified domain name) и, скорее всего, знакомо многим инженерам DevOps. Поэтому в запросе выше “com.” снабжается правой точкой, как и имена авторитативных серверов для зоны com.
В DNSSEC цепочка доверия выстраивается от корневого ключа, соответствующего корню глобальной DNS. Звенья цепочки – соответствуют делегированию. Корневой ключ должен передаваться на сторону резолвера, проверяющего DNSSEC-записи, не через DNS.
Почему использована опция +authority? Потому, что корневые серверы не являются авторитативными для зоны com. Соответственно, корневой сервер, который получил запрос, отвечает “делегирующими записями”, а именно – перечнем серверов имён, которые могут быть авторитативными. Это и есть основа так называемого рекурсивного опроса. DNS-сообщения разбиты на несколько блоков (секций). Записи, соответствующие “делегирующему ответу”, передаются в специальном блоке (Authority). Если бы корневой сервер был авторитативным для com., то он бы ответил перечнем NS в блоке обычного ответа (Answer). Таким образом мы проверили работу делегирования: сервер вышестоящей зоны отвечает списком NS у которых нужно спрашивать записи для зоны по мнению данного конкретного DNS-сервера. И пусть мы спрашивали именно NS-записи – полученный ответ всё равно здесь не является “окончательным” и авторитативным, несмотря на название блока Authority.
Обратите внимание, что мы спрашивали NS потому, что, во-первых, для изучения DNSSEC нам потребуются NS-записи; во-вторых, мы уже заранее знали, как устроено делегирование зоны com. Но спросить можно было и A-запись, и TXT-запись, и другую запись, и сразу для example.com. – корневой сервер всё равно ответил бы делегирующим списком серверов имён (NS list), потому что не является авторитативным, но является “делегирующим” для com. Например, спросим TXT для example.com.:
$ dig -t TXT example.com. +noall +authority @a.root-servers.net com. 172800 IN NS l.gtld-servers.net. com. 172800 IN NS j.gtld-servers.net. com. 172800 IN NS h.gtld-servers.net. com. 172800 IN NS d.gtld-servers.net. com. 172800 IN NS b.gtld-servers.net. com. 172800 IN NS f.gtld-servers.net. com. 172800 IN NS k.gtld-servers.net. com. 172800 IN NS m.gtld-servers.net. com. 172800 IN NS i.gtld-servers.net. com. 172800 IN NS g.gtld-servers.net. com. 172800 IN NS a.gtld-servers.net. com. 172800 IN NS c.gtld-servers.net. com. 172800 IN NS e.gtld-servers.net.
А блок Answer – пустой:
$ dig -t TXT example.com. +noall +answer @a.root-servers.net $
Для того, чтобы подключиться к корневому серверу a.root-servers.net утилита dig должна была определить его IP-адрес. Так как всякий рекурсивный опрос DNS начинается с корневого домена, а IP-адреса корневых серверов невозможно извлечь при помощи только DNS, то серверы, отвечающие за корневую зону, должны быть заранее известны стороне, выполняющей рекурсивный опрос. Поэтому IP-адреса корневых серверов распространяются без DNS, например, в составе дистрибутива резолвера (исторический файл root hints, например) или дистрибутива операционной системы. Впрочем, этот момент к DNSSEC напрямую не относится – для DNSSEC важен криптографический аналог: корневой ключ DNS.
Тем не менее, так как корень обычно уже настроен, для определения IP-адреса корневого сервера, который мы указали по имени a.root-servers.net., dig может использовать локальный рекурсивный резолвер:
$ dig -t A a.root-servers.net. +short 198.41.0.4
Соответственно, запросы к корневому серверу можно было отправлять по IPv4-адресу 198.41.0.4 (это только один из IP-адресов корневых серверов; конкретные IP-адреса указывают на различные узлы, являющиеся составляющими частями одного логического корневого сервера из тринадцати; в зависимости от сетевой конфигурации в точке подключения к Интернету, отвечать на запросы, отправляемые по одному и тому же IP-адресу корневого севера, могут разные физические узлы).
Итак, мы получили делегирующий ответ со списком NS для зоны com. В DNSSEC делегирующие ответы не подписываются, а связь между зонами, позволяющая строить цепочки доверия, устанавливается по ключам. В корневой зоне для com. присутствует DS-запись, содержащая отпечаток, – значение хеш-функции, – открытого ключа, размещённого в зоне com. Подписывается именно DS-запись (их может быть несколько). Заметьте, что если DS-запись отсутствует, то тем или иным способом обязательно удостоверяется (подписывается) факт отсутствия записи. Для этого служат записи NSEC/NSEC3,которые, впрочем мы здесь не рассматриваем.
Запрашиваем DS-запись:
$ dig -t DS com. +short @a.root-servers.net 19718 13 2 8ACBB0CD28F41250A80A491389424D341522D946B0DA0C0291F2D3D7 71D7805A
Здесь использованы следующие параметры:
-t DS – DS-запись;
+short – краткий вывод (только DS-запись);
@a.root-servers.net – всё у того же корневого сервера: DS-запись для com должна быть опубликована в корневой зоне.
В ответ получили состав DS-записи. Это числа. Что они обозначают? Смотрим слева направо:
19718 13 2 8ACBB0CD28F41250A80A491389424D341522D946B0DA0C0291F2D3D7 71D7805A
Первое число (десятичная запись, 19718) – это 16-битный идентификатор ключа, облегчающий его поиск (на практике – есть оговорки: идентификаторы могут легко совпасть у разных ключей и поэтому не должны использоваться в качестве единственного “указателя” на ключ).
Второе число (десятичная запись, 13) обозначает криптосистему ключа; здесь это ECDSA на кривой P-256, что весьма удобно для наших целей, поскольку запись ключей имеет разумную длину в 64 байта.
Третье число (два) это идентификатор хеш-функции, использованной для получения отпечатка: SHA-256.
Наконец, четвёртое число (шестнадцатеричная запись, в нём много цифр, поэтому оно разбито пробелом на две части) – это сам отпечаток, значение хеш-функции от данных записи с ключом DNSSEC.
Для DS-записи в корневой зоне есть подпись. Подписи DNSSEC публикуются в RRSIG-записях. Проверим (значение RRSIG разбито на строки):
$ dig -t DS com. +dnssec +short @a.root-servers.net 19718 13 2 8ACBB0CD28F41250A80A491389424D341522D946B0DA0C0291F2D3D7 71D7805A DS 8 1 86400 20250315050000 20250302040000 26470 . hYFItZHTilkQr5S2UgyiqboUu7tQ2z QyqGJU++jAGdDMdFLNCAMZ575q sbR+A/B35ARDCeJuzC54mBn9LXrGNO8EQHoGpBX4l8n6s0y4m6bHQha2 6pOBVtB5waWns/glKgVbxStEUrlGf110Q+ShWSodgEMVkHKeOJghmC/6 jRiHyVLsiUQjYqXVUln9o9sr1l hGvWcwrzuxuEeaQh1PRuLpqUO9KL80 pwnOK1MDFKDakmnT8sKhaegMUXKqQN6vKDJefEpFU1fqGk1hP4GV Uy/i UbvbTpLRoJj4HBBmw8phvd1qFRbtQ2pqdCRMpVSNzdESw0fFncMutkiX NwXT8Q==
Здесь добавлена опция +dnssec, которая устанавливает в DNS-запросе флаг, требующий от авторитативного сервера отправки DNSSEC-записей, соответствующих запросу. Мы получили RRSIG. Это RRSIG от ключа подписи корневой зоны (идентификатор 26470). Корневая зона использует RSA-ключи, поэтому такой большой ответ. Чтобы не возиться с байтами устаревшей RSA – мы не станем проверять именно эту RRSIG-запись, а проверим RSSIG с ECDSA когда перейдём непосредственно к example.com. ниже. Логика работы с RRSIG от уровня зоны никак не зависит, и от используемой криптосистемы подписи тоже не зависит. Поля RRSIG тоже рассматриваются детально ниже.
Здесь же проверим, что сходится сама DS-запись, то есть, убедимся, разобрав всё по байтам, что отпечаток соответствует ключу в зоне com.
Для начала такой проверки нужно получить соответствующий ключ. Ключи публикуются в DNSKEY-записях. Запросим ключи у авторитативного сервера зоны com. (то есть, уже не у корневого сервера):
$ dig -t DNSKEY +noall +answer @a.gtld-servers.net. -q com. com. 86400 IN DNSKEY 257 3 13 tx8EZRAd2+K/DJRV0S+hbBzaRPS/G6JVNBitHzqpsGlz8hu E61Ms9ANe 6NSDLKJtiTBqfTJWDAywEp1FCsEINQ== com. 86400 IN DNSKEY 256 3 13 pkypg02poKlakMmicLGweP8+fDKFWyeUuYXlkJ+qhZHwrHZ q+xRSeEm+ /68t287aYn0tn/IRw7PbHl08cnH66A==
Здесь использована новая опция +answer – она означает, что мы хотим вывести основной блок ответа (Answer).
В ответе мы получили два ключа. Посмотрим на параметры записей, слева направо:
com. – имя зоны;
86400 – TTL, время кеширования записи;
IN – класс записи (не рассматриваем, здесь все будут IN);
DNSKEY – запись DNSKEY, которая содержит ключ.
Далее идёт значение флагов, соответствующих ключу (257 и 256). Это весьма важное для DNSSEC поле. В данном случае 257 означает, что установлен бит SEP (Secure Entry Point – точка доверенного входа). Именно ключ с флагом SEP нам и нужен, потому что на него должна показывать DS-запись. Естественно, флаг SEP не является строгим указанием на то, что ключ с этим флагом действительно является ключом в цепочке доверия: нужно проверить соответствие подписей и отпечатков.
Ключи в зоне с DNSSEC делят на KSK и ZSK. Первый тип, KSK (Key Signing Key – ключ подписи ключа), служит для подписывания записей с ключами DNSKEY и является SEP-ключом. Ключ ZSK (Zone Signing Key) служит для подписывания прочих DNS-записей в зоне. На практике, это достаточно условное деление: в зоне может быть только один ключ (KSK, в таком случае), которым подписано всё остальное.
Формально, разделение ключей на KSK и ZSK объясняется необходимостью снизить количество подписей, выполняемых каждым ключом до его замены: например, ZSK обновляется реже, чем другие записи в зоне, которые им подписываются, при этом сам ZSK может быть заменён без необходимости замены KSK, которая требует и замены DS-записи в зоне уровнем выше. ZSK может быть меньшей разрядности, чем KSK, что иногда может повысить производительность, но уменьшит допустимое время использования ключа. И так далее. Всё это верные, но скорее теоретические рассуждения, относящиеся к “сферической в вакууме”, идеальной системе, потому что на практике DNSSEC в корне всё ещё использует RSA, а сама система была запущена даже без проектирования процесса ротации корневых ключей. Конечно, в зоне может быть несколько ключей KSK, несколько ZSK. Ключи должны быть предварительно опубликованы в процессе ротации, также предусмотрен бит статуса, отмечающий отозванные ключи.
Вернёмся к записи DNSKEY (убран пробел и удалены общие поля):
257 3 13 tx8EZRAd2+K/DJRV0S+hbBzaRPS/G6JVNBitHzqpsGlz8huE61Ms9ANe6NSDLKJtiTBqfTJWDAywEp1FCsEINQ==
Следующее за флагами значение 3 – это поле протокола, которое, согласно спецификации, должно быть равно трём для открытых ключей.
Параметр 13 – обозначает криптосистему: ECDSA-P256 (обратите внимание, что уже сходится с параметрами DS-записи выше).
Следом идёт значение ключа, записанное здесь в Base64. Запись в Base64 генерирует dig.
Значение хеш-функции для DS-записи вычисляется от данных DNSKEY-записи. Для сравнения результатов преобразований проделаем несколько шагов.
Шаг 1. Получение байтового представления DNSKEY-записи
Значение хеш-функции для DS-записи вычисляется от конкатенации имени, к которому относится ключ, с DNSKEY-записью. Всё в байтовом представлении. Поэтому нам нужны байты DNSKEY-записи, а не текстовое отображение, которое получено утлитой dig выше. К счастью, всё та же утилита dig может вывести и исходные байты: для этого нужно указать параметр +unknownformat (или просто +unknown), здесь вывод байтовых значений разбит на несколько строк:
$ dig -t DNSKEY com. +noall +answer @a.gtld-servers.net +unknownformat com. 86400 CLASS1 TYPE48 \# 68 0101030DB71F0465101DDBE2BF0C9455D12FA16C1CDA44F4BF1BA255 3418AD1F3AA9B06973F21B84EB532CF4035EE8D4832CA26D89306A7D 32560C0CB0129D450AC10835 com. 86400 CLASS1 TYPE48 \# 68 0100030DA64CA9834DA9A0A95A90C9A270B1B078FF3E7C32855B2794 B985E5909FAA8591F0AC766AFB14527849BEFFAF2DDBCEDA627D2D9F F211C3B3DB1E5D3C7271FAE8
Нам нужен ключ из первого набора (257 == 0x101), а точнее – байты ресурсной записи с этим ключом, поскольку до значения ключа, если смотреть слева направо, указаны ещё флаги и идентификаторы.
Шаг 2. Добавление значения имени
Мы используем HEX-текстовую запись байтов, чтобы вычислить значение хеш-функции утилитой sha256sum. Но для этого к байтам нужно слева присоединить имя зоны. Именем у нас является com. – обязательно указывается в FQDN-формате. Для кодирования DNS-имён в DNS применяется отдельная схема: имена разбиваются на лейблы (это, грубо говоря, то, что идёт “между точками”), каждый лейбл предваряется байтом, содержащим длину записи, после которой идут байты с ASCII-кодами. То есть, никаких точек нет, но зато есть корневой домен, обозначаемый нулевым байтом. У нас для com. получается три байта со значениями букв: 0x63, 0x6F, 0x6D и 0x00 для корневого домена; перед байтами записи имени должен быть байт со значением 0x03 – это длина. Полученную строку присоединяем слева к записи DNSKEY (результат опять разбит на строки):
03636F6D000101030DB71F0465101DDBE2BF0C9455D12FA16C1CDA44F4BF1BA2 553418AD1F3AA9B06973F21B84EB532CF4035EE8D4832CA26D89306A7D32560C 0CB0129D450AC10835
Хорошо, что это “эллиптический” ключ на кривой P-256, а не RSA в 4096 бит.
Шаг 3. Вычисление значения хеш-функции
Теперь строку можно преобразовать в байты, передать в sha256sum и получить результат. Сделаем это так:
$ echo "03636F6D000101030DB71F0465101DDBE2BF0C9455D12FA16C1CDA44F4BF1BA2\ 553418AD1F3AA9B06973F21B84EB532CF4035EE8D4832CA26D89306A7D32560C\ 0CB0129D450AC10835" | xxd -r -p | sha256sum 8acbb0cd28f41250a80a491389424d341522d946b0da0c0291f2d3d771d7805a -
Здесь xxd -r -p преобразует HEX-текст в байты. -r – означает “обратное” преобразование, то есть, из “дампа” в байты, а -p – задаёт тип входной строки: поток HEX-текста.
Полученный результат совпал со значением в DS-записи из корневой зоны (см. выше):
8acbb0cd28f41250a80a491389424d341522d946b0da0c0291f2d3d771d7805a 8ACBB0CD28F41250A80A491389424D341522D946B0DA0C0291F2D3D771D7805A
Дальше – RRSIG
Что ж, раз ключ совпал, можно переходить к следующему упражнению: мы планировали проверить RRSIG для каких-то записей, но уже в зоне example.com. Обратите внимание, что только что обработанный ключ не подходит для example.com, поскольку это был ключ из com. Мы выше определили, на какие авторитативные серверы делегирована зона com. Спросим у них имена NS для example.com:
$ dig -t NS example.com. +noall +authority @a.gtld-servers.net example.com. 172800 IN NS a.iana-servers.net. example.com. 172800 IN NS b.iana-servers.net.
Зона делегирована на a и b в .iana-servers.net. Запросим у сервера b A-записи для example.com (значение RRSIG разбито на несколько строк):
$ dig -t A example.com. +noall +answer +dnssec @b.iana-servers.net. example.com. 300 IN A 23.192.228.80 example.com. 300 IN A 23.192.228.84 example.com. 300 IN A 23.215.0.136 example.com. 300 IN A 23.215.0.138 example.com. 300 IN A 96.7.128.175 example.com. 300 IN A 96.7.128.198 example.com. 300 IN RRSIG A 13 2 300 20250322112746 20250301062039 13517 \ example.com. c9mvE8rEy8qnrjHkTXI2vszUdxJeex0RmmG1G2DTRABeYENPZvl3JNgz \ bWylvUIDptgCaQhVoOFq/mWCR7mXqA==
Рассмотрим запись RRSIG, начиная от названия записи:
A 13 2 300 20250322112746 20250301062039 13517 example.com. c9m...
A – означает, что подпись RRSIG относится к A-записям;
13 – тип криптосистемы подписи (ECDSA-P-256);
2 – количество лейблов в имени; это поле необходимо для того, чтобы можно было отличить “сконструированные” имена, для wildcard-записей (то есть, вида *.example.com), от “прямых” имён; например, в example.com – два лейбла (корневой домен не считается);
300 – исходное значение TTL для подписывания: исходное значение нужно потому, что конкретные значения TTL для A-записей могли уменьшиться, так как ответ мог прийти от кеширующего резолвера; для того, чтобы подпись проверить, нужно знать то значение TTL, котрое было указано в DNS-зоне;
20250322112746 – время окончания действия подписи (2025-03-22 11:27:46);
20250301062039 – время начала действия подписи;
13517 – идентификатор ключа.
Далее идёт значение подписи в Base64. Мы будем проверять подпись при помощи утилиты dgst из OpenSSL. Чтобы проверить подпись, нужно выполнить следующее: определить байтовый состав подписываемого сообщения; найти значение подписи и преобразовать в подходящий для openssl dgst формат; извлечь из DNS ключ; привести ключ в формат, подходящий для использования с openssl dgst; проверить, что подпись сходится, при помощи openssl dgst.
Итак, RRSIG охватывает все A-записи. Естественно, подписывается не текстовый вариант, который выведен ниже, а объединение байтового представления самих записей, как они фигурируют в DNS, вместе с RRSIG (но кроме самого значения подписи). Заметьте, что в состав A-записи входит не только адрес, но и сопутствующие поля. (Всё это вместе называется RDATA.) Получим байтовое представление при помощи +unknownformat (запись RRSIG разбита на строки):
$ dig -t A example.com. +noall +answer +dnssec +unknownformat @b.iana-servers.net. example.com. 300 CLASS1 TYPE1 \# 4 17C0E450 example.com. 300 CLASS1 TYPE1 \# 4 17C0E454 example.com. 300 CLASS1 TYPE1 \# 4 17D70088 example.com. 300 CLASS1 TYPE1 \# 4 17D7008A example.com. 300 CLASS1 TYPE1 \# 4 600780AF example.com. 300 CLASS1 TYPE1 \# 4 600780C6 example.com. 300 CLASS1 TYPE46 \# 95 00010D020000012C67DE9EB267C2A737 34CD076578616D706C6503636F6D0073 D9AF13CAC4CBCAA7AE31E44D7236BECC D477125E7B1D119A61B51B60D344005E 60434F66F97724D8336D6CA5BD4203A6 D802690855A0E16AFE658247B997A8
У нас шесть A-записей. Базовое значение A-записи – это просто IPv4-адрес, записанный в четырёх октетах (байтах): 0x17C0E450 == 23.192.228.80. Чтобы преобразовать одну A-запись в байтовый формат, нужно добавить байты, обозначающие имя, тип, класс записи, а также исходное значение TTL (из RRSIG). В байтах это выглядит так:
076578616D706C65 // "example" - лейбл из DNS-имени example.com. 03636F6D // "com" - следующий лейбл в example.com. 00 // "." - "корневой домен", обозначающий окончание записи имени. 0001 // DNS-класс записи (здесь это единица, IN). 0001 // Тип записи - единица обозначает A-запись. 0000012C // Исходное значение TTL: 300 секунд.
Просто приписываем эти байты в качестве префикса слева к каждому значению A-записи, а результат – сортируем по возрастанию, как числа:
076578616D706C6503636F6D00000100010000012C000417C0E450 // Первая A-запись. 076578616D706C6503636F6D00000100010000012C000417C0E454 // Вторая [...] 076578616D706C6503636F6D00000100010000012C000417D70088 // [...] 076578616D706C6503636F6D00000100010000012C000417D7008A 076578616D706C6503636F6D00000100010000012C0004600780AF 076578616D706C6503636F6D00000100010000012C0004600780C6
Сортировка нужна для того, чтобы определить единственный порядок вхождения значений в подписываемое сообщение: из DNS записи могут возвращаться в произвольном порядке. Мы уже получили первый блок байтов для проверки подписи. Его нужно присоединить справа к данным RRSIG в сетевом представлении (кроме подписи). Байты RRSIG мы получили выше, но основная их часть – это значение подписи, которую нужно удалить. Как отрезать подпись? Очень просто – в префиксе, в известном формате, указаны значения полей RRSIG, в порядке слева направо, как они были рассмотрены выше. Поэтому нетрудно отделить байты, предшествующие подписи:
0001 // A-запись: это RRSIG для A-записей. 0D // 13 - индекс криптосистемы. 02 // 2 - количество лейблов. 0000012C // исходное значение TTL. 67DE9EB2 // таймстемп окончания действия. 67C2A737 // таймстемп начала действия. 34CD // идентификатор ключа. 076578616D706C65 // example 03636F6D // com 00 // .
То есть, чтобы получить подписываемое сообщение – нужно присоединить блок с байтами A-записей к этой части RRSIG. Получится следующий блок, он разбит на несколько строк:
00010D020000012C67DE9EB267C2A73734CD076578616D706C6503636F6D0007 6578616D706C6503636F6D00000100010000012C000417C0E450076578616D70 6C6503636F6D00000100010000012C000417C0E454076578616D706C6503636F 6D00000100010000012C000417D70088076578616D706C6503636F6D00000100 010000012C000417D7008A076578616D706C6503636F6D00000100010000012C 0004600780AF076578616D706C6503636F6D00000100010000012C0004600780 C6
Переходим к извлечению и преобразованию подписи. Все байты, которые идут следом за полями с параметрами RRSIG – байты подписи. В ECDSA подпись предcтавляет собой два целых значения r и s. Так как мы используем ECDSA на кривой P-256 значения будут 256-битными, то есть займут 32 байта (строго 32, поскольку спецификация предписывает в DNSSEC указывать байты полной разрядности). Чтобы подпись успешно прочитал пакет OpenSSL эти значения нужно записать в формате ASN.1/DER, что не так уж сложно сделать руками: нужно задать тип SEQUENCE, внутри которого находятся два элемента с типом INTEGER. ASN.1 здесь – это кодирование с указанием длины блока данных перед самими байтами блока. То есть, тип SEQUENCE – 0x30, длина данных будет (0x20+1+1)*2 == 0x44 (ниже будет понятно почему). Значит, первые два байта: 0x30, 0x44. Внутри SEQUENCE – INTEGER. Тип INTEGER – 0x02, длина данных будет 0x20 (это 32 байта, которые занимает и r, и s). Префикс INTEGER получается 0x02, 0x20. И он должен быть указан перед каждым 32-байтовым элементом: и r, и s. Отсюда и общая длина 0x44. (Тут есть некоторые оговорки, которые конкретно в этих данных не проявились: в ASN.1 тип INTEGER знаковый, знак записывается в старший бит старшего байта, это необходимо учитывать, дописывая нулевой байт слева, если старший бит равен единице; кроме того, длина блоков данных может иметь разный формат записи, а определяется этот формат старшим битом первого байта, следующего за байтом, указывающим тип.)
Дописав байты, получаем такое представление для значения подписи:
3044 // ASN.1/SEQUENCE 0220 // ASN.1/INTEGER 73D9AF13CAC4CBCAA7AE31E44D7236BECCD477125E7B1D119A61B51B60D34400 0220 // ASN.1/INTEGER 5E60434F66F97724D8336D6CA5BD4203A6D802690855A0E16AFE658247B997A8
Подпись в нужном формате одним блоком:
3044022073D9AF13CAC4CBCAA7AE31E44D7236BECCD477125E7B1D119A61B51B 60D3440002205E60434F66F97724D8336D6CA5BD4203A6D802690855A0E16AFE 658247B997A8
Переходим к извлечению ключа (значения опять разбиты на строки, а пробелы – удалены).
$ dig -t DNSKEY example.com +noall +answer +unknownformat @b.iana-servers.net. example.com. 3600 CLASS1 TYPE48 \# 68 0100030D92F8951FD3F87B6DD 6E04B5FF1DA38E293DA1853ECEAB520C1BAD224FA120E04B494E9F28342AC8C55F28C55C7044FA8 0EC06F610C26FA6E4FF38FBB126443FF example.com. 3600 CLASS1 TYPE48 \# 68 0101030D9172A4BD6537BC661 F4C91A5DEA05DE2A8625A9E5A46CED8B64089C43D9DFADECA5EAC1A870C3922026DC494F6C8522D 96081ACF27D7A891153A6309DEA4F4B5
Мы проверяем подпись внутри зоны, поэтому будем использовать ключ без флага SEP. Это ключ, соответствующий записи на 0x0100 (то есть, 256). Открытый ключ ECDSA – это точка на кривой. Здесь используется несжатый формат, так что будут указаны две координаты: X и Y. Каждая по 32 байта, поскольку это P-256. Если отделить поля параметров (0x0100030D), то найти байты ключа нетрудно:
92F8951FD3F87B6DD6E04B5FF1DA38E293DA1853ECEAB520C1BAD224FA120E04 B494E9F28342AC8C55F28C55C7044FA80EC06F610C26FA6E4FF38FBB126443FF
Здесь верхняя строка – X-координата, а нижняя – Y-координата. Осталось преобразовать ключ к формату, который можно обработать openssl dgst. OpenSSL принимает ключи в ASN.1, можно завернуть в PEM. Небольшую трудность, относительно кодирования подписи, представляет указание OID (ObjectID) – то есть, идентификаторов, по которым OpenSSL сможет опредлить, что в файле именно открытый ключ на кривой P-256. Тем не менее, всё кодирование тут точно так же, как и для подписи, сводится к ручному построению байтов префикса (это настоящий хакерский подход, между прочим) и приписыванию этого префикса к байтам ключа. Делается это следующим образом.
Префикс:
3059 // ASN.1/SEQUENCE 3013 // ASN.1/SEQUENCE 0607 // ASN.1/OID 2A8648CE3D0201 // 1.2.840.10045.2.1 ecPublicKey 0608 // ASN.1/OID 2A8648CE3D030107 // 1.2.840.10045.3.1.7 P-256 0342 // ASN.1/BIT STRING 0004...
Ключ кодируется типом ASN.1 BIT STRING, при этом X и Y объединяются, и приписывается идентификатор несжатого представления (0x04). Итоговое значение в байтах:
3059301306072A8648CE3D020106082A8648CE3D0301070342000492F895 1FD3F87B6DD6E04B5FF1DA38E293DA1853ECEAB520C1BAD224FA120E04B4 94E9F28342AC8C55F28C55C7044FA80EC06F610C26FA6E4FF38FBB126443 FF
Ключ в PEM (далее – zsk-1.pem):
-----BEGIN PUBLIC KEY----- MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEkviVH9P4e23W4Etf8do44pPaGFPs6rUgwbrSJPoS DgS0lOnyg0KsjFXyjFXHBE+oDsBvYQwm+m5P84+7EmRD/w== -----END PUBLIC KEY-----
Данные, которые записаны HEX-текстом, нетрудно преобразовать в исходные бинарные файлы при помощи утилиты xxd. Например, если HEX-запись подписываемого сообщения сохранена в файле tbs.hex, то получить бинарный файл можно так:
cat tbs.hex | xxd -r -p > tbs.bin
Аналогично получаем файл подписи – sig.bin
Итак, у нас есть нужный для проверки набор данных, все элементы которого получены из DNS в ручном режиме. А именно: tbs.bin – подписанные данные, A-записи и RRSIG; zsk-1.pem – ключ проверки подписи; sig.bin – подпись. Осталось убедиться, что всё сходится:
$ openssl dgst -sha256 -verify zsk-1.pem -signature sig.bin tbs.bin Verified OK
Проверка завершилась успешно – что и требовалось доказать.
Адрес записки: https://dxdt.ru/2025/03/02/15097/
Похожие записки:
- Общее представление о шифрах и бэкдоры
- Удаление аккаунтов GoDaddy
- Трафик на тестовом сервере TLS 1.3 и ESNI
- Реплика: интернет-названия
- Новые риски: автомобили-роботы в такси
- Квантовые компьютеры и аксиома непрерывности
- Техническое: poison-расширение и SCT-метки в Certificate Transparency
- Странные особенности Golang: комментарии и ассемблер
- Про цепочки, RSA и ECDSA
- Срок действия сертификатов и компрометация ключей
- Техническое: имена в TLS и Nginx
Написать комментарий