«dap-18» это не dap-18: номер версии протокола не фиксирует формат на проводе
- среда, 15 июля 2026 г. в 00:00:21
В свободное время пишу на чистом Go порт DAP. DAP, Distributed Aggregation Protocol - это протокол распределённой агрегации для приватных измерений, из IETF, рабочая группа PPM (Privacy Preserving Measurement). Идея простая: собрать статистику по куче пользователей так, чтобы ни один сервер по дороге не увидел ни одного отдельного значения. Реализаций DAP несколько (daphne у Cloudflare, divviup-ts), а я целюсь в Janus на Rust, реализацию ISRG - тех же людей, что делают Let’s Encrypt. Взял его потому, что за ним стоит их продакшен-сервис Divvi Up, и на проводе мне надо совпасть именно с ним. Я переписываю протокол на Go с нуля, потому что мне нужна сборка под мобилку и WASM без CGO, а pure-Go узел собирается куда угодно. И главный тест для такого порта ровно один: интероп с Janus. Мой узел должен говорить с ним по проводу и быть от родного неотличим.
Эта статья про один баг совместимости, который меня озадачил. Обе стороны честно объявляют версию протокола «dap-18», а байты на проводе не сходятся. Это не туториал по крипте и не показ репозитория: репозиторий тут декорация. История про то, что строка версии - это не контракт совместимости, и про то, как это чинить, когда наткнулся.
У меня был готовый узел, написанный по структурам опубликованного черновика draft-ietf-ppm-dap-18. Я поднял рядом Janus и попробовал прогнать через них простейший сценарий агрегации. Обе стороны договорились о версии: и там, и там в коде зашито "dap-18". Рукопожатие прошло. А дальше HPKE-открытие на моей стороне упало на проверке auth-тега, и разбор запроса лёг на неизвестном поле. Две реализации, обе называют формат «dap-18», и обе не могут прочитать байты друг друга.
Разбор этой истории занял у меня пару вечеров. Но сначала стоит объяснить, зачем вообще такой протокол существует - без этого расхождение читается как скучный баг сериализации, а оно интереснее.
Представьте, что вы хотите знать, сколько пользователей вашего браузера включили какую-то фичу. Обычная телеметрия решает это в лоб: каждый клиент шлёт «я такой-то, фичу включил», сервер считает. Проблема в том, что теперь у сервера лежит база «кто что включил», и она опасна ровно для тех людей, которых инструмент должен защищать. Достаточно утечки базы или смены владельца компании - и агрегат, который вам был нужен, превращается в досье.
Приватная агрегация ломает эту связку. Вам нужна сумма, а не отдельные записи, значит и хранить отдельные записи никто не должен. Prio3, семейство схем, которые DAP переносит по HTTP, делает так: клиент бьёт своё измерение на секретные доли и рассылает по доле агрегаторам, которых в DAP по построению ровно два. Ни один агрегатор в одиночку не видит исходное значение, у него на руках только бессмысленная доля. Каждый агрегатор складывает свои доли в свой кусок агрегата, а из двух кусков в конце собирается единственное число - сумма по всем. Плюс к этому клиент прикладывает доказательство, что его измерение корректное (скажем, честный ноль или единица, а не число на миллиард, отравляющее статистику), и агрегаторы проверяют доказательство, не раскрывая само значение.
Prio3Count - самый простой представитель семейства: каждое измерение это один бит, ноль или единица, а агрегат это просто счётчик единиц. «Сколько пользователей увидели фичу» без «кто именно её увидел». Это не гипотетика: на схемах семейства Prio так считают часть браузерной телеметрии, ISRG держит для этого сервис Divvi Up, а Janus - его серверная часть.
Ролей в DAP несколько: клиент, который загружает доли, два агрегатора (Leader и Helper), и коллектор, который в конце забирает агрегат. Сам DAP это то, что связывает роли по HTTP. Я делал сторону Helper - одного из двух агрегаторов, - и именно её пытался подружить с Janus.
Прежде чем интеропить протокол, надо иметь корректную крипту под ним, иначе непонятно, где искать расхождение. Prio3Count я собрал на Go с нуля: расширяемую хеш-функцию (TurboSHAKE128), арифметику в конечном поле (Field64), систему доказательств (FLP-слой). Ходить по этим кирпичам я тут не буду, это отдельная большая тема, скажу главное про метод проверки.
Крипто-код нельзя проверять «на глаз» и нельзя проверять только своими тестами: свои тесты закрепляют твоё же понимание, а не корректность. Спасают тестовые вектора - CFRG, крипто-исследовательская группа IRTF, публикует для VDAF байт-точные примеры: вот вход, вот какими должны получиться доли и доказательство до последнего байта. Я прогнал свою реализацию против этих векторов и добился совпадения байт-в-байт. Только после этого можно было сказать, что если интероп ляжет, то виновата обёртка протокола, а не поле под ней. Это ровно тот же приём, что я использую для портов бинарных форматов: пока у тебя нет внешнего эталона, ты доказываешь не корректность, а собственную последовательность в ошибке.
Так что к моменту интеропа крипта была вне подозрений. А байты всё равно не сошлись.
Симптомов было два: HPKE-открытие падало с ошибкой аутентификации и разбор запроса падал на неизвестном поле. Оба указывают в одну сторону - структуры сообщений на проводе разные, хотя версия объявлена одна.
HPKE в DAP шифрует долю клиента с привязкой к дополнительным данным (AAD): расшифровка проходит, только если получатель соберёт ровно тот же AAD, что отправитель запечатал. Провал auth-тега означает ровно одно: мой AAD не совпал с тем, что запечатал Janus. Ключ сходится, и сам плейнтекст расшифровался бы верно, но тег в AEAD считается ещё и над AAD, так что при расхождении он не проходит и HPKE отдаёт ошибку, а не долю. Я вытащил байты AAD с обеих сторон и сравнил напрямую - у меня в структуре было на одно поле больше, чем у Janus.
Дальше я перестал гадать и пошёл в исходники Janus, читать реальные определения сообщений, а не пересказ в доке. Это, кстати, отдельный урок, который у меня уже был на CRDT-портах: свериться надо с тем, что реализация делает на самом деле, а не с тем, что написано в спеке, потому что расходятся именно эти двое. Прочитал определения структур в Janus поле за полем и увидел картину целиком.
Оказалось, что Janus реализует «dap-18» так: крипта и строки доменного разделения у нас совпали, а вот структуры сообщений - с более раннего момента черновика, до нескольких изменений, которые в опубликованный draft-18 вошли позже. И это не «Janus застрял на старой версии». Наоборот: Janus осознанно мигрирует к опубликованному draft-18 и вносит структурные изменения по одному мержу за раз, коммиты помечены тегом [DAP-18]. То есть строка версии всё это время стоит на «dap-18», а форма под ней едет от мержа к мержу. За несколько недель одно и то же имя «dap-18» успело обозначить несколько разных наборов байт. Байт версии эти состояния не различает.
Конкретные расхождения на тот момент (снимок Janus main на коммите c1531764 от 18 июня 2026, определения структур в messages/src/lib.rs, HTTP в http_handlers.rs):
AAD у input share: три поля против четырёх. В опубликованный draft-18 в AAD добавили конфигурацию задачи, у Janus её там нет. Отсюда и провал auth-тега на HPKE-открытии.
Тело запроса на инициализацию агрегации разное. В новой ревизии появился verification_key_id и расширения, а PartialBatchSelector убрали. У Janus наоборот: verification_key_id нет, а PartialBatchSelector на месте.
HTTP-модель разная. Janus создаёт джобу через PUT на конкретный URL ресурса, опубликованный draft-18 - через POST на коллекцию, id выбирает сервер.
Длины списков закодированы по-разному. Janus префиксует некоторые списки сообщений длиной в uint32, черновик оставляет их неявной длиной до конца буфера.
Ничего из этого рукопожатие не ловит: версия совпала, а поймать расхождение можно только на самих байтах, и то если специально сравнивать структуру. И застаёт это формат в движении, а не в покое: verification_key_id, которого на моём снимке у Janus нет, влился в main 24 июня, через неделю после коммита, что я читал. Форма под именем «dap-18» продолжала ехать от мержа к мержу. Классический случай, когда зелёная проверка «версии совпали» не значит ровным счётом ничего про совместимость.
Вариантов было два: подстроиться под Janus и сломать совместимость с опубликованным черновиком, или держать оба. Я выбрал держать оба - dual-mode.
Есть путь строго по опубликованному draft-18, и есть janus-совместимый вариант, а Helper выбирает режим по форме входящего запроса. PUT на URL ресурса это janus-режим, POST на коллекцию это draft-18. От выбранного режима зависит и форма AAD для HPKE, так что расшифровка всегда собирает ровно тот AAD, под которым Leader запечатал долю. Крипта общая для обоих, те же строки доменного разделения, та же кодировка ответов. Отдельным путём идёт только обвязка структур.
Честно говоря, разделять форматы по HTTP-методу - это эвристика, а не настоящий маркер версии на проводе: она работает ровно потому, что живых форм сейчас две и расходятся они как раз по этой оси. Честного маркера режима в проводе нет, и это тот же тезис статьи, просто теперь уже с моей стороны развилки.
Приятная деталь: janus-совместимый вариант это по сути возврат к более ранним формам сообщений, а они у меня лежали в истории гита с тех пор, как я двигался по ревизиям черновика. Так что дописывать пришлось не протокол, а развилку. И развилка эта по своей природе временная: как Janus домигрирует до опубликованного draft-18, путь по черновику заговорит с ним напрямую, а janus-режим можно будет снять.
После этого сценарий поехал. Janus-клиент, его Leader и Collector прогоняют через мой Helper четыре измерения Prio3Count [1, 1, 0, 1], и коллектор на выходе разворачивает правильный агрегат: 3. Байты наконец неотличимы, сумма сходится. Ради этой тройки всё и затевалось.
Строка версии говорит вам, как обе стороны называют формат, а не то, что байты договорятся. Это звучит очевидно, пока не наткнёшься: рукопожатие зелёное, обе реализации крупные и живые, и всё равно они читают разный провод под одним и тем же именем. Номер версии - это ярлык на коробке, а не гарантия содержимого, и особенно легко попасть, когда одна из сторон ещё мигрирует к финальной форме: код честно держит имя «dap-18», а форма под этим именем неделю за неделей другая.
Практический вывод простой: тестировать интероп надо на байтах, а не на согласии о версии. Зелёный чек «версии совпали» проверяет ярлык, а не формат, ровно как с обычным conformance - пока провод не сравнили с эталоном напрямую, вы проверили только, что обе стороны произносят одно слово.
И целиться при интеропе надо не в текст спеки, а в то, что конкретное живое развёртывание реально кладёт на провод: чтобы с ним заговорить, совпасть надо именно с этими байтами. Это не отмена спеки, опубликованный draft-18 остаётся тем, к чему всё едет, просто на моём снимке Janus от него ещё отстаёт, так что интероп идёт со снимком, а не с финальной формой. Поэтому эталон я пиню коммитом, а не версией, и пересматриваю перед тем, как на него полагаться. К моменту, когда вы это читаете, Janus мог уже дойти до опубликованного черновика, и тогда моя janus-развилка становится не нужна.
Сам код обоих режимов и janus-смоук лежат в репозитории dap-go. Сборка коллектора, остальные инстансы Prio3 и полный путь сбора агрегата - это уже за рамками этой истории, там своя очередь. А история здесь простая: две стороны сказали «dap-18», пожали руки и не поняли друг друга. С тех пор согласие о версии я не считаю совместимостью, и вам не советую.