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

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

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

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

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

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

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

Что из этого получилось, лежит открыто: 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 вторым разделом, до описания возможностей.