Подписанная запись что-то доказывает, только если её может проверить другой человек, кодом, который он получил не от вас. Заметки о втором верификаторе для открытой спецификации аттестации и о двух открытых issue в моём собственном проекте, который этот тест не проходит.
Подписать запись легко. Ключ, хеш, несколько десятков строк кода.
Потом идёт фраза. «Проверить может кто угодно».
Может. Он запустит верификатор, который написали вы, на формате, как вы его поняли. Это одно мнение, и проверил его автор.
В README моего собственного проекта эта фраза есть. Верификатор там один, и лежит он в том же пакете, который пишет квитанции.
Мнение превращают в доказательство две вещи: фиксированные тестовые векторы и второй верификатор, написанный по документу, без импорта первого. Этот пост о них.
почему формат с одной реализацией говорит то, что делает эта реализация
три места, где два честных верификатора разошлись на одних и тех же подписанных байтах
из чего состоит полезный набор тестовых векторов
два открытых issue в моём собственном проекте, который сегодня этот тест не проходит
1. Верификатор, который согласен сам с собой
Типичная система подписанных свидетельств поставляет три вещи: описание формата, код, который пишет записи, и код, который их проверяет. Последние два живут в одном пакете и делят вспомогательные функции. Тесты подписывают запись и проверяют её. Проходят.
Они прошли бы и в том случае, если бы описание и код расходились. Обе стороны теста берут одну и ту же функцию канонизации, один и тот же декодер base64, одно и то же представление о том, что такое целое число. Ошибку в общей функции не видно: она применяется с обеих сторон.
Получателю записи хуже. У него есть описание и вердикт. Узнать, согласуются ли они, ему нечем, и фиксированных входов, на которых можно попробовать свой код, у него нет.
2. Что нашла вторая реализация
Этой осенью я вносил правки в TRACE, открытую спецификацию аттестации для агентных систем. Десять моих пул-реквестов приняли. Один из них — второй верификатор, на TypeScript. Он написан по тексту спецификации и сверяется с эталонным верификатором на Python дифференциальным стендом: в оба уходят одни и те же записи, вердикты сравниваются.
Сейчас в прогоне 1 646 случаев. 1 645 совпадают. Оставшийся занесён в реестр расхождений вместе с причиной.
Ранние прогоны были полезнее. Верификаторы расходились. С тех пор из реестра ушло двенадцать записей, по мере того как приходили исправления. Три примера.
Одно целое число, три написания.1785000000, 1785000000.0 и 1.785e9 — одно и то же число JSON. Каноническая форма записывает все три одинаково, поэтому подпись у них одна. Схема принимала все три как целое. Проверка свежести в верификаторе на Python смотрела на тип, видела во втором и третьем написании float и отклоняла правильно подписанную запись. Верификатор на JavaScript эти написания вообще не различает: его парсер возвращает одно число. Две реализации, одни подписанные байты, разные вердикты, и к подписи разница отношения не имела. В спецификации теперь есть абзац: целое определяется по значению, а не по написанию.
Четыре лишних бита. 64-байтовая подпись в base64url без дополнения занимает 86 символов. Это 516 бит на 512 бит подписи. Последний символ несёт четыре бита, которых не производит ни один алгоритм подписи. Строгий декодер требует, чтобы они были нулевыми. Мягкий их игнорирует. Значит, одни и те же 64 байта можно записать несколькими способами, и два верификатора могут не сойтись в том, одна ли это запись. Исправление — одно предложение в спецификации и один шаблон в схеме: последним символом должен быть A, Q, g или w. В том же пул-реквесте был скрипт, который проходит по опубликованным подписям и считает, сколько из них перестанут проходить при новом правиле.
Порядок проверок. Эталон сначала декодирует подпись, потом проверяет схему. Мой верификатор делал наоборот. Запись, в которой неверны две вещи, тогда отклоняется по разным причинам. Ни один порядок не ошибочен. Одна запись реестра закрылась, когда я перенёс декодирование туда, где оно стоит в эталоне.
Ни один из этих случаев не криптографическая ошибка. С Ed25519 всё было в порядке. Все три живут в слое под подписью: числа, кодировки, порядок. В этом слое описание формата обычно занимает одно предложение, и одна реализация тихо решает за всех.
3. Зачем нужны векторы
Второму верификатору нужно на что-то опереться. Это набор векторов: фиксированные входы с ожидаемыми вердиктами, опубликованные рядом с форматом.
Полезный набор в основном отрицательный. Валидная запись доказывает мало. Важны случаи, которые обязаны провалиться, каждый ровно по одной причине:
изменённое содержимое под нетронутой подписью
изменённое поле в подписанном теле
разорванная ссылка на предыдущую запись
не тот открытый ключ
цепочка, в которой две записи поменяны местами
У каждого вектора есть ожидаемый вердикт и код причины. Верификатор, который отклонил нужную запись по неверной причине, тест не прошёл.
Потом одно правило делает набор обязательным: векторы привязаны к строке версии схемы. Если формат изменился, а строка нет, CI падает. Без этого правила векторы остаются примерами. С ним они становятся эталоном.
4. Мой собственный проект этот тест не проходит
Brand impersonation detector — конвейер, который разворачивают у себя. Я его поддерживаю. Каждое решение по кандидату записывается как подписанная квитанция, и квитанции образуют цепочку. В README сказано, что любой, у кого есть открытый ключ, может проверить их офлайн.
Это правда, и это ситуация из раздела 1. Верификатор один. Он лежит в пакете, который пишет квитанции.
Об этом открыты два issue.
Issue #4 просит каталог vectors/: валидная квитанция, валидная цепочка, изменённые байты ассета, изменённое поле манифеста, разорванное звено цепочки, не тот ключ, переставленная цепочка. К каждому вектору прилагаются сохранённые ассеты, открытый ключ и expected.json. И второй верификатор, написанный без импорта пакета, который прогоняется по векторам в CI. Хватит скрипта в один файл на другом языке. Задача считается выполненной, когда оба верификатора сходятся на каждом векторе, а изменение формата без новой строки схемы делает CI красным.
Issue #5 меньше и неприятнее. Для головы цепочки можно получить метку времени у центра RFC 3161, чтобы обрезанная цепочка была заметна. Код проверяет, что токен привязан к точным подписанным байтам. Цепочку сертификатов центра он не проверяет. Правильно оформленный токен от любого подписанта принимается. Документ с описанием формата говорит об этом прямо, и докстринг модуля тоже. Issue предлагает хранилище доверенных сертификатов и три раздельных исхода: токена нет; токен привязан, подписант не проверен; токен привязан, подписант проверен. Проверка отзыва сертификатов остаётся за рамками, как и любые утверждения о юридическом весе метки времени.
Оба issue открыты.
5. Что я из этого вынес
Подпись говорит, что байты не менялись. Она не говорит, что два человека понимают под словом «проверить» одно и то же.
Для этого нужны фиксированные входы, вердикты, записанные заранее, и второй читатель, у которого был только документ. Если второй читатель не согласен, значит, документ был неясен. Исправить предложение, добавить вектор, прогнать ещё раз.
Это медленнее, чем выпустить один верификатор. Я выпустил один.
Источники
Источники
trace-spec #376: верификатор на TypeScript и дифференциальный стенд