Практикум: примеры с 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/

Похожие записки:



Далее - мнения и дискуссии

(Сообщения ниже добавляются читателями сайта, через форму, расположенную в конце страницы.)

Написать комментарий

Ваш комментарий:

Введите ключевое слово "QGRU9" латиницей СПРАВА НАЛЕВО (<--) без кавычек: (это необходимо для защиты от спама).

Если видите "капчу", то решите её. Это необходимо для отправки комментария ("капча" не применяется для зарегистрированных пользователей). Обычно, комментарии поступают на премодерацию, которая нередко занимает продолжительное время.