Перейти к содержанию

Nullifier в ZK-приложении: защита от повторного действия

Доказательство можно показать ещё раз

Предыдущая глава связала proof с конкретным получателем и действием. Но одно и то же право всё ещё можно пытаться использовать повторно. Сама проверка математического утверждения не обязана помнить, что оно уже принято. Для памяти приложения нужен nullifier — публичный идентификатор использования, связанный со скрытым секретом и областью действия.

В модели библиотеки один участник группы получает одно право на специальный материал курса 17. Мы не хотим раскрывать, какое обязательство принадлежит участнику, но хотим отклонить повторное использование. Собственный AccessClaimRegistry хранит журнал принятых nullifier. Это продолжение выбранной view-проверки из прошлого урока, а не обещание, что внешний verifier сам управляет бизнес-правами.

Область определяет, что считается повтором

В Semaphore nullifier связан с секретом идентичности и scope. Руководство по proofs объясняет выбор области. Для библиотеки зададим scope права: версия политики, идентификатор приложения, курс 17 и тип claim. Если каждое издание разрешает отдельное получение, в scope включаем edition. Если право выдаётся один раз на весь курс, edition туда не включается.

Эти два решения нельзя смешать. В первом случае участник может получить выпуск 1 и выпуск 2 по одному разу. Во втором — смена выпуска не даёт новое право. Количество разрешённых действий устанавливает библиотека, поэтому пользователь не может сам менять scope на произвольное значение, чтобы получить новый nullifier.

Точные способы хеширования и представления выбираются вместе с протоколом. Концептуальная запись N = H(secret, scope) объясняет связь, но не является инструкцией написать собственную криптографическую функцию. Нельзя попросить браузер передать любой N и проверить лишь его отсутствие в журнале: тогда каждая случайная строка давала бы новое право.

Nullifier должен быть связан доказательством

В математическом отношении присутствует связь между скрытым секретом, областью и публичным nullifier. Проверяющий подтверждает именно её. Если приложение принимает отдельный параметр nullifier, не сравнивая его с проверенной публичной частью, злоумышленник сохранит прежний proof и подставит новый номер.

Для учебной модели записи выглядят так:

claim(course=17, scope=COURSE_RIGHT_V1, nullifier=N1, beneficiary=A)
  proof связывает N1 с секретом и объявленным scope
  журнал библиотеки: N1 ещё не использован
  результат: право A; N1 отмечен использованным

повтор того же права:
  proof может быть математически корректным
  журнал библиотеки: N1 уже использован
  результат: второго права нет

N1 и COURSE_RIGHT_V1 — смысловые обозначения, не вычисленные значения. Доказательство не генерировалось. Пример показывает два независимых решения: корректность отношения и допустимость повторного эффекта. View-проверка может снова вернуть успешный результат, но журнал бизнес-права блокирует новое использование.

Смена корня не сбрасывает историю

Пусть Анна использовала N1 при корне R1. Администратор добавил нового участника, и появился R2. Если scope права остаётся прежним, Анна не получает новое право просто от изменения состава группы. Журнал N1 сохраняется независимо от того, какой допустимый корень использовался для доказательства членства.

Поэтому не включаем текущий root в scope автоматически, когда цель — одно право на курс. Это превратило бы каждое обновление дерева в новую область. Такой подход допустим для некоторых отдельных голосований, но изменяет смысл нашего продукта. Мы выбираем стабильную область действия и отдельную политику допустимости корней.

Если участника удалили и позже снова включили с той же идентичностью, N1 в стабильной области остаётся использованным. Если администратор выдал новую идентичность, она может создавать другой nullifier. Это вопрос процедуры регистрации и защиты от множества идентичностей; ZK не доказывает, что два секрета принадлежат одному человеку.

Где хранится отметка

AccessClaimRegistry содержит свой журнал, связанный с определённым экземпляром и политикой. Проверка proof, проверка журнала, отметка и выдача права выполняются атомарно. В первой модели результат — запись beneficiary, поэтому не требуются внешние выплаты. Если позже появятся внешние вызовы, необходимо отдельно сохранить защиту от повторного входа и порядок изменения состояния.

Не опираемся на чужой вызов validateProof как на условие библиотечного получения. Исходник Semaphore различает проверку и запись nullifier в группе. Наше решение использует view-проверку и собственную память. Вызов внешнего потребления не выдаёт право библиотеки и не должен сам блокировать наш первый корректный claim.

При миграции приложения журнал требуется сохранить или явно определить новый договор. Развёртывание пустого нового экземпляра не обеспечивает прежнюю однократность автоматически. Нужен способ переноса состояния либо политика, которая продолжает проверять старые использования. Само изменение адреса в message защищает контекст, но не восстанавливает историю прав.

Цена связываемости

Одинаковый nullifier в одной области позволяет распознать повтор. Это намеренное свойство. Но если использовать один scope для несвязанных действий разных курсов, наблюдатели могут связать их по N. Поэтому область выбирается достаточно узкой для правила однократности и достаточно стабильной для защиты от обхода.

Разделение scope не скрывает всё остальное. Публичный beneficiary, время и способ отправки могут связывать действия независимо от N. Nullifier решает конкретную задачу повторного использования, а не заменяет анализ приватности маршрута. Если требуется анонимное получение файла, обычная выдача права известному адресу может противоречить цели интерфейса.

Изменённые сценарии

Сначала принимаем N1 для курса 17. Повтор N1 отклоняем по журналу. Затем создаём допустимое доказательство для нового корня, сохранив scope: право всё ещё использовано. Потом меняем courseId на 18, но оставляем message и scope курса 17: адаптер отклоняет несогласованный контекст. Только разрешённая новая область может дать самостоятельное право.

Последний случай — два вызова пришли почти одновременно. На одной цепи транзакции применяются последовательно к состоянию. Первый успешный claim записывает N1, второй видит использование. Если первый целиком откатился, журнал не должен оставаться занят без выданного результата.

Это ожидаемые результаты без фактических proof и транзакций. В следующей главе сведём их с уже знакомыми отказами мостов, оракулов и хранения: каждый модуль хранит своё подтверждение, а общий интерфейс не должен смешивать математическую корректность, доставку данных и завершённое пользовательское действие.

Оглавление серии