Мы храним зашифрованные данные, и рядом с ними лежат ключи, которые к этим данным не подходят. Выглядит как расстройство для взломщика.

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

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

Защищаем:

  1. Холодную кражу — диск, бэкап или дамп базы, снятые при запертом сервисе.

  2. Постороннего без подписанного разрешения.

  3. Ошибающийся или частично захваченный сервер приложения: он ограничен лимитами, одноразовыми разрешениями со сроком жизни в две минуты и аварийной блокировкой.

  4. Повтор разрешения — одноразовый идентификатор.

  5. Правку файла мастер-ключа на диске — служебные поля вшиты в проверяемые данные шифра.

Не защищаем, и это записано ровно так же прямо:

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

  2. Многопроцессный запуск: кэш повторов и счётчики лимитов живут внутри процесса.

  3. Аудит — вообще не защита, а запись для разбора после.

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

Откуда вообще задача

У меня есть телеграм-бот, мой второй мозг: кидаю ему всё, что попалось интересного за день, он раскладывает по местам. Пока это бот на моём собственном хозяйстве, вопросов нет. Захотелось сделать из него мобильное приложение, и вопрос появился сразу: заметки уедут на сервер. А заметки у человека это не список покупок. Это идеи, планы, иногда откровения, которые он не сказал бы вслух.

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

Отсюда пошло решение: пусть ключами занимается вообще другой сервис на другом сервере. Основной бэкенд шифрует пользовательские данные своим ключом, а сам ключ отправляет в сервис ключей. Тот заворачивает его в конверт и возвращает обратно. Хранится этот конверт рядом с данными, и открыть его основной сервер не может, потому что ключа от конверта у него нет.

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

Что из этого получилось, лежит открыто: github.com/TimurTsedik/key-service, Python, MIT. Дальше по тексту я буду показывать куски оттуда, потому что половина решений в такой работе видна только в коде.

Схема в трёх строках

документ ──шифруется случайным FileKey──▶ шифротекст лежит у бэкенда
FileKey  ──заворачивается под MasterKey──▶ конверт лежит рядом с документом
MasterKey ──зашифрован на диске ключом из парольной фразы (Argon2id)──▶ файл мастер-ключа

Сервис ключей никогда не видит документов. Он умеет ровно две вещи: завернуть присланный ключ и, если попросить как следует, развернуть обратно.

«Как следует» означает подписанное разрешение: бэкенд подписывает своим ключом Ed25519 запрос на конкретный документ, с временем жизни в пару минут и одноразовым идентификатором. Использовали разрешение один раз, второй раз оно не сработает. Заворачиваю на AES-256-GCM-SIV, это вариант, устойчивый к повторному использованию одноразового числа; для сервиса, который живёт годами и переживает перезапуски, свойство нелишнее.

Запускается сервис запертым. Мастер-ключ появляется в памяти только после того, как ему по локальному сокету с правами 0600 скажут парольную фразу. По HTTP отпереть его нельзя вообще, такого маршрута нет. Ключ из фразы выводится Argon2id, и параметры лежат прямо в коде, а не в чьей-то голове:

DEFAULT_KDF_PARAMS = KdfParams(time_cost=3, memory_cost=65536, parallelism=4)

Шестьдесят четыре мегабайта памяти на попытку — это то, что превращает перебор фразы из скучной работы для видеокарты в дорогое удовольствие.

Файл мастер-ключа лежит на диске зашифрованным, и вот тут есть деталь, которую я сначала сделал неправильно. Служебные поля файла — версия формата, номер версии ключа, номер активной версии — сначала лежали просто рядом с шифротекстом. То есть их можно было отредактировать в текстовом редакторе, и сервис бы их послушно прочитал. Теперь все три вшиты в проверяемые данные конверта:

def _master_aad(format_version: int, key_version: int, active_key_version: int) -> bytes:
    """AAD binding the envelope format_version, THIS key's version, AND the file's
    active_key_version. Tampering with any of the three on disk makes the entry's
    decrypt fail."""
    return b"key-service-master-key-v1|fv=%d|kv=%d|akv=%d" % (
        format_version, key_version, active_key_version)

Правка любого из трёх чисел на диске означает, что расшифровка не сойдётся и сервис не отопрётся. Не «предупредит», а просто не отопрётся.

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

Зачем понадобился второй уровень

Дальше начинается интересное. Против кражи диска схема работает. А против самого сервера?

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

Мне хотелось, чтобы у человека была возможность закрыться и от меня. Схема простая: пользователь задаёт свой артефакт, парольную фразу или, скажем, четверостишие, которое знает только он. Из неё на его устройстве выводится ключ, и конверт заворачивается ещё раз, уже под этим ключом. На сервере такого ключа нет никогда. Тогда сервер физически не может открыть запись, что бы с ним ни делали.

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

Первая версия второго уровня, которая мне очень нравилась

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

Логика была такая. Ключ невозможно вытащить с устройства даже вредоносным кодом. Значит, даже полностью захваченный сервер не сможет открыть документ, потому что подпись делается там, куда он не дотягивается. Это единственная штука, которая честно закрывает активно скомпрометированный бэкенд, а не изображает такую защиту.

Потом я сел разбирать схему на враждебном сценарии: предположим, бэкенд уже захвачен и злонамерен. Что он может.

Четыре неприятности

Оказалось, что может он практически всё, и вот почему. Ни одна из четырёх неприятностей не про криптографию: все четыре про то, кто кому что присылает.

Задачу для подписи готовит код, который прислал бэкенд. В вебе страницу с её JavaScript отдаёт сервер приложения. Он же формирует то, что уйдёт на подпись устройству. Человек видит на экране «подтвердите доступ к документу», нажимает палец, а подписывается ровно то, что положил туда бэкенд. Привязки к документу, который человек имел в виду, нет никакой.

Свежесть подтверждает та же недоверенная сторона. Мой сервис ключей своих задач не выдавал, он принимал готовое доказательство. То есть уникальность и свежесть заявлял клиент, которому мы по условию не верим. Захваченный бэкенд может заранее собрать пачку валидных доказательств и держать их про запас, а потом использовать, когда пользователь давно закрыл ноутбук.

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

И добивающее: регистрация нового устройства шла через бэкенд. Человек покупает новый телефон и добавляет его как второе устройство. Кто удостоверяет, что это его телефон? Бэкенд. Тот самый, от которого мы защищаемся. Он спокойно регистрирует собственное устройство как «второй телефон пользователя» и после этого открывает документы совершенно законно, ничего не взламывая.

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

Общий вывод из всего этого получился короткий. Если страницу, в которой живёт твой второй фактор, отдаёт тот, от кого ты защищаешься, независимым фактором он не является. Никакое железо этого не чинит, потому что железо честно подписывает то, что ему подсунули.

Развилка

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

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

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

Второй фактор, который отсекает половину пользователей, защищает уже не данные. Он защищает от пользователей.

Чем заменил

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

Свойство сохранилось главное: сервер в одиночку документ второго уровня не откроет. Артефакт пользователя обязателен.

А теперь честная часть, ради которой всё и затевалось. Замена не закрывает то, что закрывал WebAuthn. Если сервер захвачен и активно вредит, он подсунет браузеру такой скрипт, который украдёт парольную фразу в момент ввода. Никакой Argon2id от этого не спасает, потому что фразу перехватывают до него.

Это записано прямым текстом в решении по проекту: угроза названа, не закрыта, вынесена за границу модели и адресована отдельному аудиту сервера. Не «мы полностью защищены», а «вот это мы не защищаем, знайте».

Правило, которое я из этого вынес

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

Работает и за пределами криптографии. Прежде чем гордиться механизмом, стоит спросить: от кого именно он защищает и оказывается ли этот кто-то внутри моей модели угроз. Если механизм красив, но закрывает то, чего в модели нет, он не бесплатный: он стоит времени, сложности и, что хуже, создаёт ощущение защищённости.

Обратная сторона правила ещё полезнее. Правильная реакция на «мы не закрываем вот это» — записать. Не докрутить наспех, не переформулировать так, чтобы звучало прикрыто, а написать в документации, что не закрыто.

Что осталось в рабочей версии

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

Отдельная возня была с кодами отказов. По ответу нельзя понять, существует ли чужой документ, есть ли такой пользователь и какие версии ключей живы. Запрос на давно удалённую версию ключа получает не «такой версии нет», а тот же ответ про несовпадение контекста, что и любая другая ошибка привязки:

    # check 9: look up the key version in the retained map. Unknown versions collapse
    # to context_mismatch (C3: do not reveal which versions exist).
    master_key = app.state.master_keys.get(cc.key_version)
    if master_key is None:
        # C3: do not reveal which versions exist; same code as an AAD mismatch.
        return _deny(app, ReasonCode.CONTEXT_MISMATCH, req.request_id, ...)

Самое честное свидетельство того, что это решение принималось осознанно, осталось в перечислении кодов. Подходящий код там есть, он написан, у него есть имя, и он намеренно не используется:

class ReasonCode(str, Enum):
    """Safe, non-revealing reason codes."""
    ...
    UNSUPPORTED_KEY_VERSION = "unsupported_key_version"  # reserved; /unwrap uses context_mismatch

Иначе перебором версий можно было бы составить карту хранилища, ничего не расшифровав.

Криптография заперта в одном пакете. Остальной код библиотеку шифрования не импортирует вообще, это проверяется. Скучное решение, зато при следующем разборе смотреть надо в одно место, а не по всему проекту.

Чему научил остальной разбор

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

Журнал писался после дела. При отпирании сервис сначала менял состояние, а потом записывал событие. Если запись падала, получалось лучшее из возможных: сервис отперт, мастер-ключ в памяти, а в журнале про это ни строчки. Теперь сначала запись, и если она не удалась, операция не выполняется.

Цепочка целостности журнала ловила не всё. Записи связаны хэшами, каждая ссылается на предыдущую, подделать середину нельзя. А вот отрезать хвост можно было спокойно: удаляешь последние события, проверка целостности по-прежнему говорит, что всё в порядке. Цепочка, связывающая только назад, не замечает, что у неё укоротили конец.

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

Возражения, которые я слышу заранее

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

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

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

Что я вынес

  1. Прежде чем называть фактор вторым, посмотрите, кто прислал код, внутри которого он работает.

  2. Технология, отсекающая часть пользователей по возрасту их железа, защищает уже не данные.

  3. Правильный ответ на «мы этого не закрываем» — строчка в документации, а не спешная докрутка.

  4. Цепочка целостности, связывающая записи только назад, не заметит, что журнал укоротили с конца.

  5. Журнал пишется до действия и падает закрыто, иначе это не журнал, а пожелание.

  6. Временный ключ, заведённый на пять минут для пробы, живёт ровно столько, сколько живёт проект.

Самая полезная страница в документации к любому средству защиты — не список того, что оно умеет. Это список того, чего оно не делает, написанный самим автором.

Код целиком здесь: github.com/TimurTsedik/key-service. Python, около двух тысяч строк, двадцать четыре файла тестов, лицензия MIT. Список «чего не защищаем» лежит в README вторым разделом, до описания возможностей.

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


  1. Sap_ru
    19.08.2026 10:58

    Что-то здесь не так и чувствуется какая-то проблема в логике. Решали одну задачу, решили другую, при это проблем/рисков то ли столько же стало, то ли больше, первоначальная проблема усугубилась, и всё это под рассказы про безопасность.
    Могу ошибаться, но прежде всего видна путаница в терминологии. Мне кажется, что под словом "клиент" у вас в разных местах разные сущности и разные риски. Где-то там пошли проблемы с логикой в расуждениях.

    Во-вторых, вы много говорите о безопасности и угрозах, но у вас нет списка и приоритетов угроз - от чего именно и с какими приоритетами вы защищаетесь? Вы не можете защититься от всего - нужно выбирать, какие угрозы более приоритетны, какие менее. И дальше обязательно действовать в рамках это модели: каждый раз, когда у вас возникает вопрос о том, какое решение более безопасное/правильное, вы должны смотреть на свою модель и действовать в соответствии с ней. Если вы меняете модель, то пересматриваете все решения. А у вас вероятности и опасности угроз меняются прямо на ходу. И результат, честно говоря, очень сомнительный. Вы в какой-то момент начали защищаться от потери контроля на бекэндом, перелопатили совершенно всё, но в результате от заявленной угрозы не защитились и даже сделали хуже. Но зачем вы тогда это всё делали?

    И хуже всего то, что, судя по выводам, вы из всей этой истории вынесли крайне отрицательный и вредный опыт по работе с безопасностью. Вместо последовательной работы по с оценкой угроз вы скатились в придумывание и переоценку этих угроз прямо на ходу. При этом вы неоднократно повышаете статус какой-то угрозы, на которой сконцентрированы прямо сейчас, но при это этом не пересматриваете статусы всех остальных угроз. В результате у вас то одна угроза в приоритете, то другая, то третья, но при этом непосредственно-связанные с ними угрозы вы не пересматриваете. Обратите внимание, что результат по защите у вас получился случайный и зависит от того, в каком порядке вы рассматривали угрозы. Так нельзя: вы составляете список угроз с приоритетами, и при необходимости правите всю модель, пересматривая все решения, конечный результат должен соответствовать модели.

    Кроме того:

    "Захваченный бэкенд может заранее собрать пачку валидных доказательств и держать их про запас, а потом использовать, когда пользователь давно закрыл ноутбук."
    Это может быть очень серьёзная и очевидной угроза, но от этого можно защититься. Делайте одноразовые токены, делайте обмен токенов, делайте привязку ко времени - что-то из этого в зависимости от приоритетов ваших угроз.

    "Общий вывод из всего этого получился короткий. Если страницу, в которой живёт твой второй фактор, отдаёт тот, от кого ты защищаешься, независимым фактором он не является."
    Это очевидно-неверный вывод. Сам второй фактор при этом может быть защищен так, что контроль над страницей ничего не даст атакующему. Это ещё один признак того, что вы рассматривали угрозы фрагментарно и по ходу разработки, что-то там сами себе на ходу постулировали, без взгляда на всю ситуацию в целом. Даже просто прочитайте вот эту свою фразу - вы точно уверены, что она верная? Вы точно не можете придумать второго фактора, который не может быть скомпрометирован контролем над отдающей страницей? А если nouce, подписи и челендж со стороны клиента добавить? А потому подумайте, сколько неверных решений вы приняли из-за того, что в какой-то момент почему приняли вот эту фразу за постулат.

    А ещё вы из-хз отсутствия модели угроз не рассмотрели случай, когда можно иметь ещё один сервер, находящийся в своём собственном изолированном контуре безопасности, и выполняющих какую-то очень простую изолированную задачу. Это решило бы практически все ваши проблемы. Но для этого нужно точно знать от каких именно угроз вы защищаетесь. От потери контроля над одним сервером? Без проблем. От потери контроля вообще над всем серверами? Так и вы сейчас от этого не защитились и нужно более тщательно сравнивать эти два варианта (ваш скорее всего проиграет). У вас сейчас потеря контроля над одним сервером/сервисом полностью рушить всю безопасность - зачем тогда вы всё это городили?


    1. Timur555 Автор
      19.08.2026 10:58

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

      Модель угроз. Вы правы: её не было в тексте, и без неё рассказ читается как импровизация. Добавил врезкой в начало статьи — пять пунктов «защищаем» по приоритету и три «не защищаем», ровно в том виде, в каком они лежат в README проекта. Там же теперь сказано главное: решение считается правильным, если оно закрывает пункт из первого списка и не притворяется, что закрывает пункт из второго.

      Терминология. Согласен полностью. В статье «клиент» — это браузерная страница, «бэкенд» — сервер приложения, который эту страницу отдаёт и хранит документы, а key-service — отдельный сервис на отдельной машине, который хранит только ключи и документов не видит никогда. Три роли с разными правами, и названы они так, что их легко слить в одну.

      Вывод про второй фактор. Здесь вы правы, и это не мелочь. Моя фраза шире того, что следует из фактов. WebAuthn с challenge от независимой стороны даёт вполне реальные вещи: заготовить подписи впрок нельзя, использовать фактор без физического присутствия человека нельзя. Чего он не даёт в вебе — привязки к намерению: устройство не показывает, что именно подписывает, а текст на экране рисует тот же бэкенд. Он покажет «доступ к документу А», а на подпись отправит challenge для документа Б.

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

      Одноразовые токены и привязка ко времени — они есть. Разрешение на выдачу ключа: подпись Ed25519, одноразовый идентификатор, срок жизни не больше двух минут, привязка к конкретному документу, а контекст вшит в проверяемые данные шифра, так что подменить его нельзя. В статье про это сказано вскользь, отсюда и впечатление. Ваше замечание точно бьёт в WebAuthn-доказательство старой схемы, где challenge выдавал не key-service, — и это ровно то, что предписал разбор.

      Отдельный сервис в изолированном контуре — это и есть моя архитектура. Раз из статьи это не считалось, написана она плохо. Что происходит при захвате каждого из двух:

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

      Key-service: документов там нет вовсе, мастер-ключ появляется в памяти только после ручного отпирания по локальному сокету.

      Оба сразу: первый уровень падает, второй — нет, пока у атакующего нет парольной фразы, которой на серверах не существует.

      То есть разделение поднимает цену атаки с «одна машина» до «две машины плюс активная малварь на устройстве пользователя». Не полная защита, но и не ноль.

      И про то, зачем тогда убрали WebAuthn. Не из-за объёма работы. Я не мог поручиться, что у людей, для которых это делается, есть подходящее устройство, и не хотел, чтобы человеку со старым ноутбуком отказали в доступе к собственным записям. Это размен доступности на защиту от активной компрометации, и в решении он записан вместе с тем, что мы теряем. В статье я подал его как вывод, а не как размен, — и звучит хуже, чем есть.


  1. Timur555 Автор
    19.08.2026 10:58

    qwe