Рабочий демо-проект: 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 в цикле» до «надо что-то реально знать». Для мира, где простота обычно побеждает безопасность, сдвиг более чем осязаемый.
savostin
Насколько невозможно подобрать функцию зная чистый и публичный ids?