Рабочий демо-проект: github.com/mlivirov/fiestel-cipher-demo

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

Куча веб-приложений светит в URL целочисленные первичные ключи:

GET /invoices/1043
GET /invoices/1044
GET /invoices/1045

Один валидный URL плюс цикл for — и у тебя на руках вся таблица. Ровно на этом в апреле 2025 года погорела APCOA, утёкшая парковочными счетами сразу по пяти странам. И ровно это каждый год занимает первую строчку в OWASP под названием Broken Access Control / IDOR. Встречается всюду: внутренние утилиты, B2B-порталы, админки, к которым три года не подходили, MVP, который однажды тихо стал продакшеном.

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

Сеть Фейстеля делает ровно это, примерно в сорок строк. Ни новых зависимостей, ни миграций, ни изменений в текущих запросах. В этом весь смысл.

Идея одним абзацем

Сеть Фейстеля берёт любую функцию с ключом F(data, key) — хоть плохонькую — и превращает её в обратимую перестановку над блоком фиксированного размера. Делишь вход пополам, прогоняешь несколько раундов: одна половина XOR-ится с F(другая_половина, key), половины меняются местами. На выходе — шифртекст. Чтобы расшифровать, гоняешь те же раунды в обратном порядке. Всё. Функция F может быть хоть необратимой — обратимость даёт сама структура.

На этом построен DES. На этом построен Blowfish. Здесь же она используется для задачи куда скромнее: сделать /persons/1 неугадываемым.

Как это выглядит в репозитории

В базе по-прежнему скучный bigint с автоинкрементом. Наружу API отдаёт только зашифрованное значение.

public class PersonIdValueConverter(byte[] key) : ValueConverter<PersonId, long>(
    id => FiestelId.Decode(key, id.Value),      // entity -> БД: снимаем шифр
    value => new PersonId(FiestelId.Encode(key, value))) // БД -> entity: накладываем шифр
{ }

Конвертер живёт на границе EF Core. Бизнес-код, DTO и роуты про сырой PK не знают в принципе. База, в свою очередь, не знает про зашифрованное представление. Случайно протечь одно в другое неоткуда — граница зафиксирована типом.

Сам шифр (Domain/FiestelId.cs) — классика из учебника:

for (var i = 0; i < Rounds; i++)
{
    var roundIndex = reverse ? Rounds - 1 - i : i;
    var f = RoundFunction(right, key, roundIndex);
    var newRight = left ^ f;
    left = right;
    right = newRight;
}

RoundFunction — это просто SHA-256(key || data || round), обрезанный до 32 бит. Восемь раундов. Никаких S-боксов, никаких таблиц подстановок, никакого расписания подключей. Весь файл — 58 строк.

Что получается на практике: подряд идущие id из базы (1, 2, 3) превращаются наружу во что-то вроде 5823901847263…, 9174625038192…, 2847193650471…. Прибавил единицу к своему id — не узнал ничего ни о чьих других.

Что это даёт

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

Нулевые изменения в схеме. Внутренние PK остаются последовательными. Индексы — плотные, вставки — в хвост, внешние ключи — целыми числами. Путь записи не трогается вообще.

Биекция, а не хеш. Каждый внутренний ID — ровно в один внешний и обратно. Никаких коллизий, никакой таблицы соответствий, никакого кэша «id → slug», который надо поддерживать в синхроне.

Копейки в рантайме. Восемь вызовов SHA-256 на кодирование — доли микросекунды на любой современной машине. Можно спокойно гонять на каждой строке в горячем чтении, в бюджет впишется.

Стабильные URL. В отличие от UUID, которые рандомятся в момент вставки, зашифрованный ID — чистая функция от PK. Подняли таблицу из бэкапа, перепрогнали события, восстановились из снапшота — те же строки получили ровно те же публичные ID. Ключ не менялся — ссылки не сломались.

Чего это не даёт

Это не авторизация. Это стоит выписать маркером на мониторе. Непрозрачный ID останавливает того, кто перебирает цикл for, но не того, у кого уже есть один валидный ID — из лога, из Referer, из расшаренного скрина, из тикета в саппорт — и кто теперь хочет узнать, отдаст ли ему сервер эту строку. На каждом запросе сервер всё ещё обязан отвечать на вопрос «а этому вызывающему вообще можно эту запись?». Feistel-ID — это defense in depth, не более того.

Ключ — single point of failure. Утёк секрет — перебор снова работает. У кого ключ, тот шифрует 1, 2, 3, … и идёт по таблице подряд. Хранить соответственно: переменные окружения или менеджер секретов, в репозиторий — никогда, при подозрении на компрометацию — ротация. Заглушка в appsettings.json здесь именно что заглушка, а не пример «как надо».

Ротация — больно. Зашифрованный ID — чистая функция ключа. Сменили ключ — поменялись вообще все публичные ID в системе. Сломались старые URL, кэшированные ссылки, QR-коды на парковочных талонах. Схемы, где идентификатор и токен целостности разделены (скажем, HMAC-подписанные ID, где сам ID стабилен, а ротируется только подпись), этого избегают — но за счёт лишних байт на проводе.

Это не сертифицированный шифр. Конструкция использует SHA-256 как раундовую функцию и самодельное расписание ключа. Для неперебираемости ID — задачи хорошо изученной, ставки низкие — этого вполне хватает. Для конфиденциальности чувствительных данных — нет. Хотите шифровать данные — берите AES-GCM из System.Security.Cryptography, а не этот файл.

64-битный блок, без nonce. Одна и та же пара (ключ, открытый текст) всегда даёт один и тот же шифртекст. Для публичных ID это как раз то, что надо — ссылки стабильны. Но значит, что, увидев два зашифрованных ID, атакующий поймёт, одна это строка в БД или разные. Нормально. А вот по любому количеству зашифрованных ID нельзя сказать ни сколько строк в таблице, ни какие у них номера, ни в каком порядке они вставлялись — пока ключ цел.

Когда это действительно к месту

Feistel-ID имеет смысл брать, если одновременно:

  • API сейчас светит наружу последовательные первичные ключи, и это нужно прекратить.

  • Переехать на UUID в роли PK либо невозможно, либо не хочется — из-за раздутых индексов, джойнов или просто стоимости миграции.

  • Нормальный слой авторизации либо уже есть, либо вот-вот появится, и нужна простая обратимая вторая линия обороны на ключе, не лезущая в схему БД.

Если хотя бы один пункт не про вас — лучше взять другой инструмент. Под новую схему — UUIDv7. Когда ротация ключа реально маячит в эксплуатации — HMAC-подписанные ID. Когда защищаете данные, а не идентификаторы — нормальное шифрование.

Одной строкой

Сеть Фейстеля — самый дешёвый способ сделать публичные ID неугадываемыми, не трогая базу. И самый дешёвый способ перепутать неугадываемость с контролем доступа. Первое — брать. Второе — не делать.


Если ты лид или CTO и смотришь на кодовую базу, которую собирали бегом и теперь надо хотя бы остановить кровь — это работа на час, первая в очереди. Архитектуру она не чинит, но стоимость самой массовой, самой автоматизированной и самой стыдной категории атак поднимает с «curl в цикле» до «надо что-то реально знать». Для мира, где простота обычно побеждает безопасность, сдвиг более чем осязаемый.


Комментарии (1)


  1. savostin
    21.04.2026 06:22

    Насколько невозможно подобрать функцию зная чистый и публичный ids?