У сервиса, который хранит ключи шифрования, есть клиент, которому он обязан верить. Это бэкенд приложения: он подписывает каждый запрос своим ключом, и подпись честно сходится.
Теперь предположим, что бэкенд захвачен. Атакующий получает вместе с ним и ключ подписи, а значит выписывает себе совершенно валидные разрешения на любой документ. Все десять проверок при выдаче ключа проходят. Ни одна не заметит ничего необычного, потому что необычного и нет: пришёл доверенный клиент и попросил то, что имеет право просить.
Эта статья про то, что делать в такой ситуации. Она вторая: в первой я разбирал, почему второй фактор, живущий в присланной сервером странице, защищает не то, что кажется. В комментариях там справедливо спросили: если захват одного сервера рушит всю схему, зачем вообще было городить разделение. Вот механизм, который на этот вопрос отвечает.
Рамка: что именно решаем
Чтобы дальше читалось честно, сразу очерчу границы: от кого защищаемся, чего у нас нет и о чём вообще пойдёт речь.
Противник в модели — сломанный или захваченный бэкенд, а также украденный служебный токен и повтор старого разрешения. Против него уже работают лимиты и аварийная блокировка по порогу.
Чего у нас нет — ответа на вопрос «происходит ли прямо сейчас то, чего быть не должно». Лимиты ограничивают темп: сто двадцать выдач на пользователя в окно, шестьдесят на документ. Они срежут вал, но не отличат вора, который выкачивает ключи медленно и аккуратно, от обычной рабочей нагрузки.
Речь дальше пойдёт не про защиту, а про обнаружение. Это разные вещи, и подменять одно другим проще всего именно в разговорах о безопасности, где хочется звучать увереннее, чем есть.
Идея: приманка, которую трогает только вор
Заводим фальшивого пользователя и фальшивый документ. В нормальной жизни к ним не обращается никто и никогда: их нет в интерфейсе, на них не ссылается ни один сценарий, живой человек до них не доберётся.
Значит любое обращение к ним — сигнал. Не подозрительный признак, который надо взвешивать вместе с другими, а событие с почти нулевой вероятностью ложной тревоги.
Идея старая и называется honeytoken. Интересна не она сама, а то, что происходит, когда начинаешь её честно применять.
Главное ограничение, из которого выведено всё остальное
Первая версия, которая приходит в голову: пусть бэкенд помечает приманки флагом в разрешении, скажем is_canary: true.
Достаточно произнести это вслух, чтобы увидеть дыру. Мы защищаемся от захваченного бэкенда, а флаг ставит он же. Захваченный просто не поставит.
Отсюда правило, которое я считаю главным во всей этой истории: сигнал тревоги не может исходить от того, за кем ты следишь. Всё дальнейшее устройство — следствие.
Поэтому набор приманок живёт в сервисе ключей, и бэкенд о нём не знает. Он не может ни обойти приманки, ни узнать, какие идентификаторы фальшивые.
Почему приманки хранятся хэшами
Наивно было бы держать список фальшивых идентификаторов открытым текстом в конфигурации. Проблема в том, что идентификаторы пользователей и документов почти всегда угадываемы: почта, идентификатор из базы, UUID. Утёкший список простых хэшей перебирается на ноутбуке за вечер.
Поэтому приманки хранятся как ключевые хэши:
def canary_digest(key: bytes, value: str) -> str: """Keyed HMAC-SHA256 hex digest of an identifier.""" return hmac.new(key, value.encode("utf-8"), hashlib.sha256).hexdigest()
Ключ HMAC живёт только в сервисе ключей, отдельным секретом. Без него список хэшей бесполезен: перебрать нельзя, потому что перебирать надо не только идентификатор, но и ключ.
Сам распознаватель занимает полтора десятка строк:
class CanarySet: """Secret honeytoken set. `hits()` reports which kind of canary a request touched ('user' / 'document'), or None. Empty or keyless => disabled (always None).""" def __init__(self, hmac_key, user_hashes, document_hashes): self._key = hmac_key self._users = {h.strip().lower() for h in user_hashes if h.strip()} self._docs = {h.strip().lower() for h in document_hashes if h.strip()} def hits(self, *, user_id: str, document_id: str) -> str | None: if self._key is None: return None if self._users and canary_digest(self._key, user_id) in self._users: return "user" if self._docs and canary_digest(self._key, document_id) in self._docs: return "document" return None
Два множества, два вычисления HMAC на запрос, никакой магии. Отдельно отмечу мелочь, которая экономит вечер отладки: если ключа нет, распознаватель молча выключен и всегда возвращает None. Забыть смонтировать секрет и не заметить этого — сценарий вероятнее любой атаки, поэтому состояние «выключено» сделано явным, а не аварийным.
Настраивается всё четырьмя переменными окружения:
Переменная | Что задаёт |
|---|---|
| путь к файлу с секретным ключом (форма с файлом выигрывает у переменной) |
| JSON-массив отпечатков приманок-пользователей |
| то же для приманок-документов |
| запирать сервис при срабатывании или только поднимать тревогу |
Отпечаток для конфигурации считает та же программа, тем же ключом, что и сервис, — чтобы значения гарантированно совпали:
python -m key_service.cli canary-hash --document doc-2026-0042
Наборов два: приманки-пользователи и приманки-документы. Зачем два, скажу ниже, там есть разница.
Как приманка попадает в систему
Тут прячется самая практичная часть, которую легко пропустить: приманка обязана быть неотличима от настоящей записи, иначе тот, кто добрался до хранилища, увидит её раньше, чем на неё наступит.
Значит недостаточно вписать выдуманный идентификатор в конфигурацию. Нужна полноценная запись в данных: правдоподобное имя, настоящий завёрнутый ключ, обычное место в таблице. Для этого в репозитории лежит отдельная программа-сеятель, и работает она так:
придумывает правдоподобный идентификатор документа (в вашей системе он должен выглядеть как остальные — это единственное место, которое обязательно надо адаптировать);
вызывает обычный
POST /wrapи получает настоящий завёрнутый ключ, ничем не отличающийся от ключей живых документов;считает отпечаток идентификатора тем же ключевым хэшем.
На выходе получаются две вещи, и разъезжаются они по разным местам. JSON-массив отпечатков идёт в конфигурацию сервиса ключей — это единственное, что сервис узнаёт о приманках. А пары «идентификатор, завёрнутый ключ» оператор кладёт в свою базу как обычные записи, без единого видимого признака, что они фальшивые.
Отсюда же следует, что секретный ключ приманок обязан жить где угодно, только не на бэкенде: если он окажется там, захвативший бэкенд вычислит отпечатки сам и обойдёт все ловушки за минуту.
Где именно стоит проверка
Это оказалось важнее, чем сам факт наличия ловушки. Одна и та же проверка, поставленная в разные места цепочки, даёт три разных результата, и два из них плохие.
Проверка стоит после проверки подписи, окна жизни, повтора, разрешённой цели и совпадения документа — но до расшифровки конверта. Два довода, и оба практические.
Довод первый: тревога должна быть настоящей. Раз проверка идёт после подписи, сработать она может только на валидно подписанном разрешении от доверенного издателя. То есть это либо захват, либо серьёзная ошибка в бэкенде — в любом случае событие, ради которого стоит поднимать людей ночью. Поставь проверку раньше подписи, и любой прохожий сможет слать запросы с выдуманными идентификаторами, а мы будем на них реагировать.
Довод второй: не давать рычага для отказа в обслуживании. Раз реакция на приманку — аварийная блокировка сервиса, то возможность вызвать её без подписи означала бы, что любой желающий может нас погасить. С проверкой после подписи такой возможности нет.
До расшифровки её ставят по скучной причине: одно вычисление HMAC и просмотр множества стоят дешевле, чем криптография над конвертом.
Целиком проверка выглядит так:
canary_hit = app.state.canary.hits(user_id=claims.user_id, document_id=cc.document_id) if canary_hit: alarm("canary_tripped", field=canary_hit, iss=claims.iss, key_version=cc.key_version) try: app.state.audit_store.append(AuditEvent( timestamp=datetime.now(timezone.utc).isoformat(), operation="canary_trip", decision="deny", reason_code="canary", request_id=req.request_id, document_id=cc.document_id, key_version=cc.key_version, grant_jti=claims.jti, grant_issuer=claims.iss, backend_identity=backend_identity)) except AuditWriteError: pass if s.canary_autolock_enabled: _auto_emergency_lock(app, req.request_id) return _deny(app, ReasonCode.LOCKED, req.request_id, **audit_ids) return _deny(app, ReasonCode.CONTEXT_MISMATCH, req.request_id, document_id=cc.document_id, **audit_ids)
Обратите внимание на except AuditWriteError: pass. Запись улики обёрнута так намеренно: если журнал в этот момент недоступен, тревога всё равно уйдёт, а сервис всё равно запрётся. Ловушка не должна ломаться из-за того, что сломался журнал.
Тихий отказ
Дальше самое интересное, и это то, что я бы вынес в заголовок, будь он подлиннее. Речь про ответ, который получает тот, кто наступил на приманку.
Когда приманка сработала, ответ наружу не отличается от обычной ошибки. Тот же код, что при несовпадении контекста, или locked, если сервис успел запереться. Никакого отдельного кода «вы попали в ловушку».
Причина простая. Отличимый ответ превращает ловушку в индикатор для атакующего: он видит, что этот идентификатор особенный, отступает, чистит следы и начинает аккуратнее. Ловушка, которая сообщает о своём срабатывании, работает один раз и на стороне противника.
Поэтому снаружи видно ровно то же, что при любой другой неудаче. А внутри в этот момент происходит совсем другое.
Что происходит внутри
Внутри происходят три вещи, и порядок между ними важен.
Пишется отдельное событие аудита — не «отказ», а именно срабатывание приманки, с указанием, какого вида приманка сработала. Это будущая улика.
Поднимается тревога во внешнюю систему мониторинга, предварительно вычищенная от чувствительных данных: наружу уходит факт и вид приманки, а не идентификаторы.
И, если включена автоблокировка (по умолчанию включена), сервис аварийно запирается. Выглядит это буквально так:
def _auto_emergency_lock(app, request_id: str) -> None: app.state.master_keys = {} app.state.active_key_version = None app.state.lock_manager.lock( changed_by="auto-emergency", reason="global emergency threshold exceeded", emergency=True, )
Мастер-ключи стираются из памяти процесса, активная версия обнуляется, состояние переходит в «заперт по тревоге». Дальше сервис не выдаст ни одного ключа никому, включая легитимный бэкенд, пока человек руками не введёт парольную фразу по локальному сокету. Автоматического восстановления нет и не планируется: если ловушка сработала, кто-то должен посмотреть на это глазами.
Последнее есть сознательный размен доступности на радиус поражения. Если кто-то дошёл до наших приманок, лучше остановить всё и разбираться, чем продолжать выдавать ключи.
Живой прогон
Механизм проверен не в тестах, а на развёрнутом экземпляре. Порядок был такой: сначала контрольный запрос к обычному документу, потом запрос к приманке — оба с валидными подписанными разрешениями.
Шаг | Ожидали | Получили |
|---|---|---|
Контроль: обычный документ | 200, обычный путь цел | 200 OK |
Приманка: запрос к фальшивому документу | тихий отказ и блокировка | 409 |
Состояние после срабатывания | заперт, ключ выброшен | заперт по тревоге, активная версия ключа |
След в аудите | запись о срабатывании | есть, цепочка ниже |
Цепочка аудита, от старой записи к новой:
wrap | allow unwrap | allow (контроль, обычный документ) canary_trip | deny | canary (обнаружение) emergency_lock | allow (аварийная блокировка) unwrap | deny | locked (тихий ответ вызывающему)
В этих пяти строках видно ровно то, ради чего всё делалось: снаружи обычный отказ, внутри зафиксированное срабатывание с указанием причины. Разница между этими двумя картинами и есть весь механизм. Отдельно проверил доставку: обе тревоги дошли во внешний мониторинг и превратились в письмо.
Два вида приманок, и зачем оба
Приманка-документ ловит массовое выкачивание: кто-то идёт по хранилищу и просит ключи ко всему подряд.
Приманка-пользователь ловит другое: подделку разрешений и перебор пользователей. Разрешение, выписанное на человека, который никогда не заходит, странно само по себе, даже если запрошенный документ настоящий.
И вот честный предел: обе ловят массовое и перебор. Точечную выемку документов одного реального пользователя не поймает ни одна — вор просто не наткнётся на приманку. Это ограничение записано в решении, а не обнаружилось потом.
Чего этот механизм не делает
Раз уж говорим честно, соберу ограничения в одном месте, а не буду разбрасывать по тексту.
Это обнаружение, а не предотвращение. К моменту срабатывания ключ от документа-приманки уже выдан. Ценность не в том, что мы кого-то остановили, а в том, что узнали на несколько часов раньше и ограничили радиус.
Он не ловит целенаправленную атаку на конкретного человека, о чём сказано выше: вор, который идёт за документами одного клиента, на приманку просто не наступит.
Он покрывает только слой ключей. Полноценная схема требует таких же ловушек в хранилище и в самом бэкенде, плюс защищённого способа передать сигнал тревоги между сервисами. Этого у меня пока нет.
Автоблокировка тоже рычаг отказа в обслуживании, только направленный внутрь. Смягчено тем, что набор приманок секретный: тронуть его может только тот, у кого уже есть доступ к данным.
Возражения
«Это security through obscurity». Нет. Секрет здесь — конкретные значения приманок и ключ HMAC, а не алгоритм: алгоритм опубликован, репозиторий открыт. Ровно так же работает любой ключ шифрования.
«У вас уже есть лимиты, зачем ещё и это». Лимиты про темп, приманка про факт. Медленный вор в лимиты укладывается, в приманку — нет.
«Ложные срабатывания положат вам сервис». Приманки не встречаются в нормальной работе по построению: их нет в интерфейсе и ни в одном сценарии. За время эксплуатации ни одного ложного срабатывания не было, а реакция настраивается: можно оставить только тревогу без блокировки.
«Проще смотреть в логи». Логи отвечают на вопрос «что было», когда ты уже знаешь, что случилось нехорошее. Приманка отвечает на вопрос «случилось ли», и делает это сама.
Что я из этого вынес
Сигнал тревоги не может исходить от того, за кем следишь. Любой признак, который проставляет наблюдаемая сторона, бесполезен ровно тогда, когда он нужен.
Ловушка обязана молчать. Отличимый код ответа превращает её в подсказку атакующему и обесценивает после первого срабатывания.
Место проверки в цепочке — часть конструкции, а не деталь реализации: слишком рано даёт посторонним рычаг гасить сервис, слишком поздно стоит дороже без выигрыша.
Списки идентификаторов хранят ключевыми хэшами, потому что сами идентификаторы угадываемы.
Обнаружение стоит называть обнаружением. Первый ключ уже уехал, и честная ценность механизма — время, а не спасение.
Самое ценное в этой конструкции — не приманка. Приманку придумать легко. Ценно то, что сервис, поймав вора, отвечает ему той же скучной ошибкой, что и всегда, и ничем себя не выдаёт.
Код открыт: github.com/TimurTsedik/key-service, Python, MIT.

