Доказать, не раскрывая: от теории к практике

Доказать, не раскрывая: от теории к практике

В посте Доказать, не раскрывая: как работает одна из самых красивых идей в криптографии я рассказывал про здание с двумя выходами и секретной дверью. Алиса заходит внутрь, Боб просит выйти через случайный вход, и после двадцати успешных попыток Боб уверен: Алиса знает код от секретной двери. Сам код он так и не узнал.

Метафора красивая, но у неё есть проблема. Несколько человек написали мне: «Со зданием понятно. Но как это применить к паролю? К проверке возраста? К номеру паспорта? Что конкретно происходит с данными?»

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

Важная оговорка: примеры в посте упрощены, чтобы показать принцип. Реальные ZKP-системы проектируют профессиональные криптографы. Между учебным примером на Python и боевой реализацией — пропасть. Почему именно пропасть, а не просто расстояние, — расскажу в конце. Но понять, как устроена идея, можно и на упрощённых примерах.

Но сначала поговорим об инструменте, без которого ничего не получится.

Однонаправленная функция

Чтобы доказать секрет, не раскрывая его, нужна математическая операция с особым свойством: легко вычислить в одну сторону и практически невозможно — в обратную. Такая операция называется однонаправленной функцией.

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

Хеш-функция — одна из самых распространённых однонаправленных функций. Она берёт любой текст и превращает его в строку фиксированной длины — «отпечаток». Одинаковый вход всегда даёт одинаковый выход. Но по выходу восстановить вход нельзя.

from hashlib import sha256

print(sha256("мой_пароль".encode()).hexdigest())
# → "a3f5c8..." — всегда одинаковый результат для этого входа
# Но по "a3f5c8..." вычислить "мой_пароль" невозможно

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

# Легко: вычислить 5^7 mod 23
# pow с тремя аргументами — возведение в степень и остаток от деления:
# pow(основание, степень, модуль) = основание^степень % модуль
result = pow(5, 7, 23)  # = 78125 % 23 = 17

# Практически невозможно: зная 17, основание 5 и модуль 23,
# найти степень 7.
# При маленьких числах можно перебрать, но при числах
# длиной в сотни цифр перебор нереалистичен.

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

Теперь — к задачам.

Задача 1. Пароль: доказать, что знаешь, не называя

Как это работает сейчас

Вы вводите пароль на сайте. Браузер отправляет его на сервер. Сервер проверяет пароль и пускает вас внутрь.

Как именно проверяет — зависит от качества инженерии. В 2009 году сервис RockYou хранил 32 миллиона паролей в открытом виде — без какого-либо преобразования. Следующий шаг — хранить хеш пароля вместо самого пароля. Но и это недостаточно: если два пользователя выбрали одинаковый пароль, их хеши совпадут, и злоумышленник с украденной базой может заранее вычислить хеши для миллионов популярных паролей (радужные таблицы) и найти совпадения за секунды.

Современный стандарт — соль и медленное хеширование. К каждому паролю перед хешированием добавляется случайная строка — соль (salt). Одинаковые пароли с разными солями дают разные хеши, и радужные таблицы становятся бесполезными. А алгоритмы вроде Argon2, bcrypt или scrypt намеренно работают медленно и потребляют много памяти, чтобы перебор стал дорогим даже на специализированном оборудовании.

Но при всём этом остаётся фундаментальная проблема: в момент проверки сервер видит ваш пароль. Да, соединение защищено HTTPS, но на стороне сервера пароль расшифровывается и существует в памяти в открытом виде. Если сервер скомпрометирован, злоумышленник перехватит пароль прямо в момент входа. Соль и Argon2 защищают базу данных, но не защищают сам момент логина.

Как это работает с нулевым разглашением

Идея: вы доказываете серверу, что знаете пароль, но сам пароль никогда не покидает ваш компьютер. Даже взломанный сервер не увидит ни одного символа.

Для этого используется протокол, основанный на возведении в степень по модулю. Ниже — упрощённая версия протокола Шнорра (назван в честь немецкого криптографа Клауса-Петера Шнорра, предложившего его в 1989 году).

Регистрация (один раз):

  1. Вы и сервер заранее договорились о двух публичных числах: простое число p = 23 и генератор g = 5. Эти параметры знают все, в них нет секрета.
  2. Вы выбираете пароль — число 7.
  3. Вы вычисляете public_key = 5^7 mod 23 = 17 и отправляете серверу.
  4. Сервер сохраняет public_key = 17. Сам пароль 7 он не знает и никогда не видел. Восстановить 7 из 17 — это задача дискретного логарифма, та самая необратимая операция.

Вход (каждый раз):

p = 23       # простое число (публичный параметр)
g = 5        # генератор (публичный параметр)
order = 22   # порядок группы (p − 1), нужен для арифметики

# Сервер хранит:
public_key = 17   # то есть g^password mod p

# === Шаг 1: вы создаёте «маску» ===
password = 7              # ваш секрет — не покидает компьютер
r = 3                     # случайное число, новое каждый раз
t = pow(g, r, p)          # 5^3 mod 23 = 10
# → вы отправляете серверу t = 10

# === Шаг 2: сервер бросает «вызов» ===
c = 4                     # случайное число, выбранное сервером

# === Шаг 3: вы вычисляете ответ ===
s = (r + c * password) % order   # (3 + 4×7) % 22 = 31 % 22 = 9
# → вы отправляете серверу s = 9

# === Шаг 4: сервер проверяет ===
left  = pow(g, s, p)                           # 5^9 mod 23 = 11
right = (t * pow(public_key, c, p)) % p        # (10 × 17^4 mod 23) % 23
# 17^4 mod 23 = 8, значит right = (10 × 8) % 23 = 80 % 23 = 11

print(left == right)   # True → пароль верный

Что здесь произошло? Сервер получил три числа: t = 10, c = 4 (он сам его выбрал) и s = 9. Ни одно из них — не пароль. Но математика гарантирует: равенство g^s ≡ t × public_key^c выполняется только если вы подставили правильный пароль при вычислении s. Подобрать s, не зная пароля, — значит решить задачу дискретного логарифма. А это и есть та самая необратимая операция.

Случайное число r — одноразовая маска. Оно прячет пароль внутри ответа s: даже перехватив s, извлечь из него пароль нельзя. При следующем входе r будет другим, и все числа изменятся.

Связь со зданием

Помните здание с двумя выходами из первого поста? Здесь та же структура. Публичный ключ — это здание, которое Боб видит снаружи. Пароль — код от внутренней двери. Случайный вызов сервера — это Боб, который говорит «выйди через парадный вход». Ваш математический ответ — Алиса, выходящая через нужную дверь. А проверка равенства — Боб, который видит: да, она вышла откуда нужно.

Разница в том, что в здании нужно повторить эксперимент 20 раз, чтобы набрать уверенность. В протоколе Шнорра математика даёт уверенность за один раунд: подделать ответ без знания пароля вычислительно невозможно.

Задача 2. Возраст: подтвердить «мне есть 18», не показывая дату рождения

Как это работает сейчас

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

Как это работает с нулевым разглашением

Здесь задача другого типа. С паролем вы доказывали: «я знаю конкретное значение». С возрастом нужно доказать: «моё значение больше порога». Это называется доказательство диапазона (range proof) — математическое доказательство того, что число попадает в определённый интервал, без раскрытия самого числа.

В протоколе участвуют три стороны:

  1. Издатель — государство, которое достоверно знает вашу дату рождения.
  2. Вы — храните подписанные государством данные на своём устройстве.
  3. Проверяющий — сайт, которому нужен только ответ «да» или «нет».

Подготовка (один раз):

  1. Государство выдаёт вам цифровой документ — верифицируемое удостоверение (verifiable credential). Этот документ содержит дату рождения и подписан криптографической подписью государства. Подпись — это как печать нотариуса, только математическая: подделать её невозможно, а проверить может любой, кто знает открытый ключ государства.
  2. Документ хранится на вашем телефоне, в специальном приложении-кошельке.

Проверка (каждый раз):

  1. Сайт запрашивает: «Докажи, что тебе есть 18».
  2. Ваш кошелёк берёт подписанный документ и создаёт доказательство с нулевым разглашением: «Я владею документом с валидной подписью государства, и дата рождения в нём такова, что мне сейчас не менее 18 лет».
  3. Сайт получает доказательство и проверяет: (а) подпись государства валидна, (б) математическое доказательство корректно.
  4. Сайт не получает: дату рождения, имя, номер документа — ничего, кроме ответа «да, 18 есть».

Что происходит внутри доказательства

Ключевой приём — обязательство (commitment). Вы «запечатываете» свою дату рождения в математический конверт: вычисляете значение, которое однозначно привязано к вашей дате, но не раскрывает её.

from hashlib import sha256
import secrets

birth_year = 2000
# Случайный «ослепляющий множитель» — делает конверт уникальным
blinding = secrets.token_hex(32)

# Обязательство: однозначно привязано к birth_year,
# но без знания blinding восстановить birth_year нельзя
commitment = sha256(f"{birth_year}:{blinding}".encode()).hexdigest()
# → "7f3a1b..." — это «запечатанный конверт»

Получив такой конверт, проверяющий видит строку 7f3a1b..., но не знает, что внутри: 2000, 1995 или 1980. Теперь нужно доказать, что число внутри конверта не больше 2008 (чтобы в 2026 году владельцу было не менее 18).

Для этого используются арифметические схемы — цепочки математических операций (сложение, умножение), которые можно выполнить над зашифрованными данными. Проверяющий убеждается, что результат вычислений корректен, не видя самих данных. Подробнее о том, как устроены арифметические схемы, — в разделе про ИНН.

Именно такой подход закрепляет Европейский регламент eIDAS 2.0 (2024): к декабрю 2026 года все 27 стран ЕС обязаны выдать гражданам цифровые кошельки с поддержкой доказательств нулевого разглашения. А Privado ID (бывший Polygon ID) уже реализовал этот сценарий: пользователь хранит удостоверения на смартфоне и подтверждает факты о себе сканированием QR-кода.

Задача 3. ИНН: доказать, что номер настоящий, не показывая его

Зачем это нужно

ИНН (идентификационный номер налогоплательщика) — чувствительная информация. По нему можно определить регион регистрации (первые четыре цифры — код налогового органа), а в сочетании с другими данными — идентифицировать конкретного человека. Но иногда нужно подтвердить одно: у вас есть настоящий ИНН. Например, при регистрации на госуслугах или при заключении договора.

Контрольные разряды: необходимо, но недостаточно

ИНН — не произвольный набор цифр. У него есть внутренняя структура: последние цифры вычисляются по формуле из остальных. Такие цифры называются контрольными разрядами, и они позволяют проверить, что номер не содержит случайной опечатки.

Вот как устроена проверка для 12-значного ИНН физического лица:

def is_valid_inn_12(inn: str) -> bool:
    """Проверка контрольных разрядов 12-значного ИНН."""
    if len(inn) != 12 or not inn.isdigit():
        return False

    digits = [int(c) for c in inn]

    # Коэффициенты для 11-й цифры (предпоследней)
    weights_11 = [7, 2, 4, 10, 3, 5, 9, 4, 6, 8]
    check_11 = sum(d * w for d, w in zip(digits[:10], weights_11)) % 11 % 10

    # Коэффициенты для 12-й цифры (последней)
    weights_12 = [3, 7, 2, 4, 10, 3, 5, 9, 4, 6, 8]
    check_12 = sum(d * w for d, w in zip(digits[:11], weights_12)) % 11 % 10

    return digits[10] == check_11 and digits[11] == check_12

Алгоритм прост: умножить каждую цифру на коэффициент, сложить результаты, взять остаток от деления на 11, затем на 10. Если вычисленные контрольные разряды совпадают с фактическими — формат ИНН корректен.

Но вот проблема: контрольные разряды защищают от опечатки, а не от подделки. Написать генератор 12-значных чисел с правильной контрольной суммой — задача на несколько строк кода. Поэтому ZK-доказательство «я знаю число с правильными контрольными разрядами» само по себе ничего не стоит. Нужно доказать большее: что ваш ИНН действительно выдан налоговой службой.

Как это решается: подписанное удостоверение

Дерево Меркла со всеми выданными ИНН (подробнее о деревьях — в разделе про паспорт) решает проблему генерации, но создаёт новую: оно доказывает «такой ИНН существует», а не «этот ИНН принадлежит мне». Ничто не мешает подставить чужой номер.

Поэтому в реальном сценарии используется тот же подход, что и с возрастом: подписанное удостоверение. ФНС выдаёт вам цифровой документ, который связывает ваш ИНН с вашим криптографическим ключом — уникальным идентификатором, которым владеете только вы. Документ подписан ключом ФНС, и подделать эту подпись невозможно.

Тогда ZK-доказательство объединяет три проверки:

  1. «У меня есть документ с валидной подписью ФНС» — подтверждение подлинности.
  2. «ИНН в этом документе имеет корректные контрольные разряды» — проверка формата.
  3. «Я владею криптографическим ключом, к которому привязан этот документ» — подтверждение, что ИНН мой, а не чужой.

Все три проверки выполняются внутри одного доказательства. Проверяющий не видит ни сам номер, ни подпись, ни ключ — только результат: «да, у этого человека есть настоящий ИНН, выданный ФНС».

Можно пойти ещё дальше. Доказать, что ваш ИНН зарегистрирован в конкретном регионе (первые четыре цифры попадают в определённый диапазон), не раскрывая остальные восемь цифр. Или доказать, что ИНН принадлежит физическому лицу (12 цифр), а не организации (10 цифр), не показывая сам номер.

Арифметические схемы: как компьютер проверяет зашифрованные данные

Все проверки — контрольных разрядов, подписи, владения ключом — записываются в виде арифметической схемы (arithmetic circuit). Это алгоритм, разложенный на цепочку элементарных операций: сложений и умножений. Почему только они? Потому что ZKP-системы умеют эффективно работать именно с этими двумя операциями над зашифрованными данными. Любой алгоритм — хоть проверку ИНН, хоть вычисление хеша — можно разложить на такую цепочку.

Проверка контрольных разрядов ИНН для этого подходит как учебный пример: умножение цифры на коэффициент — это умножение; суммирование — сложение; взятие остатка от деления — тоже раскладывается на базовые операции. Всё это компилируется в компактную схему, внутри которой проверяющий видит только результат: «корректно» или «некорректно».

Для написания таких схем существуют специализированные языки: Circom, Noir, Halo2. Они позволяют описывать проверки на языке, похожем на обычный код, а затем компилируют описание в арифметическую схему, готовую для генерации ZK-доказательств.

Задача 4. Паспорт: доказать, что документ настоящий, не показывая его

Самая сложная из четырёх задач

С паролем мы доказывали знание конкретного значения. С возрастом — что число больше порога. С ИНН — что номер выдан налоговой и принадлежит нам. С паспортом задача другая: нужно доказать, что ваш документ входит в список действительных документов, не раскрывая, какой именно он. Это называется доказательство принадлежности множеству (set membership proof).

Дерево Меркла: структура, которая делает это возможным

Для этой задачи используется дерево Меркла (Merkle tree) — структура данных, предложенная криптографом Ральфом Мерклом в 1979 году. Принцип: берём список всех валидных паспортов, хешируем каждый номер и строим из хешей двоичное дерево.

Представьте перевёрнутое дерево. В нижнем ряду — хеши отдельных паспортных номеров (листья). Каждый узел выше — хеш двух дочерних узлов. На самом верху — один-единственный хеш, корень, который «сжимает» информацию обо всём списке.

              [корень]
             /        \
          [AB]        [CD]
          /   \       /   \
        [A]   [B]   [C]   [D]   ← хеши паспортов

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

Как работает доказательство

from hashlib import sha256

def merkle_hash(a: str, b: str) -> str:
    """Хеш пары узлов дерева Меркла."""
    return sha256((a + b).encode()).hexdigest()

# Государство строит дерево из четырёх паспортов
passports = ["1234 567890", "2345 678901", "3456 789012", "4567 890123"]
leaves = [sha256(p.encode()).hexdigest() for p in passports]

# Попарно хешируем листья
node_ab = merkle_hash(leaves[0], leaves[1])
node_cd = merkle_hash(leaves[2], leaves[3])

# Корень дерева
root = merkle_hash(node_ab, node_cd)
# Государство публикует только root

Допустим, у вас паспорт 3456 789012 — третий в списке. Государство выдаёт вам путь в дереве: цепочку хешей, нужных для подъёма от вашего листа до корня. Для дерева из четырёх элементов путь — это два хеша. Для дерева из миллиона элементов — всего 20 хешей (потому что log₂(1 000 000) ≈ 20).

# Ваш путь от листа к корню:
my_leaf = sha256("3456 789012".encode()).hexdigest()
step_1 = merkle_hash(my_leaf, leaves[3])    # хешируем с соседом → node_cd
step_2 = merkle_hash(node_ab, step_1)       # хешируем с node_ab → корень

print(step_2 == root)   # True → паспорт есть в дереве

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

ZK-доказательство решает эту проблему. Вы выполняете весь обход дерева внутри доказательства: предъявляете свой номер, путь и получаете корень — но проверяющий видит только результат: «корень совпал» или «не совпал». Ни хеш вашего листа, ни путь, ни сам номер паспорта — ничего не утекает наружу.

Что это даёт на практике

Государство обновляет дерево (добавляет новые паспорта, удаляет недействительные) и публикует свежий корень. Ни один номер паспорта не утекает. Гражданин подтверждает действительность документа на любом сервисе — не доверяя этому сервису свои данные.

По этому принципу работает Semaphore — открытый протокол анонимной групповой идентификации, разработанный командой Privacy & Scaling Explorations при Ethereum Foundation. Пользователь доказывает, что он член группы, не раскрывая, кто именно он.

Общая картина

Четыре задачи — четыре типа доказательств:

ЗадачаЧто доказываемТип
Пароль«Я знаю конкретное значение»Доказательство знания (proof of knowledge)
Возраст«Моё значение ≥ порога»Доказательство диапазона (range proof)
ИНН«У меня есть настоящий ИНН, и он мой»Подписанное удостоверение + проверка формата
Паспорт«Моё значение входит в список»Доказательство принадлежности множеству (set membership proof)

Во всех четырёх случаях проверяющий получает уверенность, а секрет остаётся у владельца. Меняется только тип вопроса — и математический аппарат за ним.

Почему мы до сих пор вводим пароли и загружаем фото паспортов

Если всё так хорошо работает, почему эти подходы не применяются повсеместно? Три причины.

Вычислительная стоимость. Генерация ZK-доказательства — ресурсоёмкая операция. Для простых случаев вроде пароля она занимает миллисекунды, но для сложных схем (дерево Меркла с миллионами записей, проверка документов) может потребовать секунд и значительной памяти. Маломощные устройства справляются с этим не всегда.

Инфраструктура. Для проверки возраста нужно, чтобы государство выдавало цифровые удостоверения и поддерживало криптографическую инфраструктуру. Для проверки паспорта — чтобы публиковало и обновляло дерево Меркла. Это требует стандартов, политической воли и координации между ведомствами. Европейский eIDAS 2.0 — первый регламент, который обязывает страны это сделать. Дедлайн — декабрь 2026 года.

Сложность разработки. Написать арифметическую схему для проверки ИНН — задача для специалиста по криптографии, а не для обычного бэкенд-разработчика. Ошибка в схеме может создать уязвимость, невидимую при стандартном тестировании. Инструменты становятся доступнее (Circom, Noir, Halo2 позволяют писать схемы на языках, похожих на обычный код), но порог входа остаётся высоким.

Вывод

Здание с двумя выходами — хорошая метафора, но метафора не отвечает на вопрос «как именно?». Ответ — в однонаправленных функциях: операциях, которые легко выполнить и невозможно обратить. Хеш-функции, возведение в степень по модулю, деревья Меркла — конкретные инструменты, которые превращают абстрактную идею «доказать, не раскрывая» в работающий код.

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

Данные, которые не были переданы, невозможно украсть.

Предыдущий
Наверх