Как сервер может хранить и синхронизировать наши секреты, не имея ключей для их чтения? Разберём по порядку, как Confidex создаёт ключи, защищает их замком, шифрует данные и проверяет изменения.
Замок
Только Избранный может открыть эту дверь
Матрица: Перезагрузка
Представим, что мы создаём виртуальный сейф (vault), чтобы хранить в нём секретные данные. Он должен сохранять данные на ноутбуке после закрытия приложения и позволять открывать их на другом устройстве. Для этого у сейфа будут собственные ключи. Их нужно сохранить так, чтобы копирование файлов не давало постороннему права читать содержимое или менять его.
Ключи защищает замок (lock) — небольшой зашифрованный файл. Для его открытия нужны мастер-пароль и секретный код secret_key; ни того ни другого в самом файле нет. Замок можно целиком напечатать в утренней газете: читатели получат его копию, но сама публикация не раскроет приватные ключи.
Допустим, посторонний получил этот файл и даже узнал мастер-пароль. Теперь он может повторить все вычисления, для которых нужен только пароль, но ключ открытия замка из них не получится: в его создании участвует ещё и secret_key. В файле нет отдельной проверки правильности одного пароля. Для открытия всё равно придётся получить неизвестный секретный код.
Этот код получается из криптографически случайных данных; человек его не придумывает. Пока secret_key неизвестен, даже правильно угаданный мастер-пароль не открывает замок: атакующему всё ещё недостаёт случайного секрета. Поэтому слабость пароля сама по себе не превращает серверную копию в доступную задачу словарного перебора.
Если же secret_key тоже окажется у злоумышленника, он сможет проверять догадки о мастер-пароле офлайн, это будет непросто, но итоговая сложность перебора зависит от стойкости пароля.
Ключи
Господа, для чего нужны ключи? Ключи... отпирают замки?
Пираты Карибского моря: Сундук мертвеца
Приложение получает от криптографического генератора операционной системы 32 случайных байта и подготавливает их для работы с ключами. Эти подготовленные байты и есть приватный мастер-ключ (master_key).
Из master_key приложение каждый раз получает одни и те же ключи: повторение тех же вычислений над теми же байтами даёт тот же результат. Поэтому ноутбук и телефон смогут восстановить одинаковые ключи. Чтобы получить master_key на новом устройстве, приложение сначала должно открыть замок мастер-паролем и secret_key.
Первая пара — для шифрования
Я - Ключник.
Охотники за привидениями
Чтобы сохранять данные в зашифрованном виде, приложение вычисляет из master_key публичный ключ — secret_key. С его помощью можно зашифровать данные для сейфа, а для расшифровки нужен master_key.
Слово «публичный» описывает роль этого ключа в алгоритме, а не обязанность раздать его всем. В Confidex он остаётся у пользователя. Он и есть второй секрет для открытия замка, поэтому никто не должен его получать по сети, он остается локально. Сам по себе secret_key позволяет шифровать данные для сейфа, но не расшифровывать их и не подписывать изменения от его имени.
Алгоритм X25519 это и есть то, что вычислит из master_key - secret_key. Обратно восстановить master_key по secret_key ключу — практически неосуществима задача. Для классического метода перебора на условном суперкомпьютере, выполняющим миллиард миллиардов операций на кривой в секунду, потребовалось бы в среднем около 2,3 триллиона лет (Оценка из SafeCurves).
Вторая пара — для подписи
Единственное, что здесь подлинное, - подпись
Поймай меня, если сможешь
Первая пара поможет скрыть содержимое данных. Теперь нужен способ подтвердить, что изменение разрешено владельцем сейфа. Проверять подписи должен в том числе сервер, поэтому публичный ключ проверки нужно ему передать. У первой пары публичная часть — это secret_key, который сервер получать не должен. Поэтому для подписи создадим вторую, постоянную пару ключей.
Чтобы не хранить ещё один независимый секрет, выведем приватный ключ подписи из master_key и назовём его signing_key. Им приложение будет подписывать данные и замок, а его публичной частью другие устройства и сервер смогут проверять подпись, не читая содержимое.
Пару можно заново восстановить из того же master_key. Для этого приложение получает из него заготовку — 32 байта, которые однозначно задают ключ подписи.
Чтобы для разных задач получать разные ключи, при выводе добавляют постоянную метку — здесь confidex/vault/ed25519/v1. С тем же master_key и той же меткой приложение всегда получает прежний ключ.
Мастер-пароль в этих вычислениях пока не участвует. Обе пары определяются случайными байтами master_key, а не придуманной человеком строкой. Это разделение пригодится дальше: пароль будет защищать доступ к уже существующим ключам, поэтому его можно будет менять, сохраняя сами ключи.
Для вывода используется функция получения ключей на основе хеширования — HKDF, HMAC-based Extract-and-Expand Key Derivation Function. В Confidex она работает с SHA-256, отсюда название HKDF-SHA256. Полученные 32 байта — seed для пары Ed25519. Публичная часть signing_key не раскрывает secret_key, поэтому её можно передавать серверу для проверки подписей.
Что же такое файл замка и какими свойствами обладает?
Открой двери шлюзового отсека, Хэл.
Космическая одиссея
Прости, Дэйв. Боюсь, я не могу этого сделать.
Замок хранит master_key в зашифрованном виде. Для открытия нужны мастер-пароль и secret_key. Открытый заголовок содержит версию формата, идентификатор сейфа, публичную часть signing_key, соль и параметры обработки пароля. Вместе с master_key зашифрована версия внутреннего формата.
При создании замка master_key уже есть у приложения, поэтому secret_key вычисляется до шифрования. При открытии пользователь предоставляет secret_key отдельно: в публичной копии замка его нет.
После расшифровки приложение вычисляет из master_key его публичную часть через X25519 и требует совпадения с secret_key. Затем восстанавливает signing_key и сверяет его публичную часть с ключом в заголовке. Таким образом, замок проверяет сам себя.
Чтобы замедлить перебор мастер-пароля, каждая попытка должна требовать времени и памяти. В Confidex для этого используется Argon2id с 64 МиБ памяти и тремя проходами. При обработке пароля также используется случайная соль. Соль не является секретом: она позволяет получать разные результаты для одинаковых паролей в разных замках.
Если secret_key уже известен, длина пароля резко меняет масштаб перебора. Для 5 случайных строчных латинских букв существует около 12 млн вариантов, для 7 — около 8 млрд, для 12 — около 95 квадриллионов. Переход с 7 на 12 букв увеличивает перебор почти в 12 млн раз. Если на одной машине средний перебор 7 букв занял бы сутки, то 5 букв потребовали бы около двух минут, а 12 — около 33 тысяч лет.
Чтобы получить один ключ из результата Argon2id и secret_key, используется уже описанная функция вывода ключей HKDF-SHA256 с меткой confidex/lock/wrap/v1. Результат — wrapping_key, ключ шифрования замка.
master_key нужно скрыть и защитить от подмены. Для этого используется XChaCha20-Poly1305: он шифрует его ключом wrapping_key и добавляет тег аутентификации. Тег связывает шифротекст с открытым заголовком: неверный пароль или изменение защищённых байтов приводят к отказу при открытии. Вместе с шифротекстом сохраняются тег и случайные одноразовые данные – nonce.
При каждом создании или повторном шифровании приложение получает свежие соль и nonce. Зашифрованное содержимое включает один байт версии и 32 байта master_key; рядом сохраняются 24-байтовый nonce и 16-байтовый тег. Подробности конструкции описаны в документации алгоритма.
Несовпадение восстановленных ключей может быть следствием намеренного формирования некорректного замка или ошибки создавшей его программы. Случайное повреждение байтов обычно обнаруживается раньше, при проверке тега.
У сервера нет секретов, необходимых для проверки этого тега. Поэтому для передачи замка приложение добавляет подпись, созданную signing_key, которая покрывает готовый файл целиком, включая открытый заголовок и зашифрованную часть. Сервер проверяет подпись, а при первой загрузке закрепляет полную публичную часть signing_key и внутренний идентификатор сейфа. В дальнейшем он проверяет изменения именно этим ключом. Замок и зашифрованные данные подписаны одним signing_key, восстановленным из master_key.
Сейф
Теперь можно выложить публично ключ, и только тот кто знает secret_key и master password сможет прочитать master_key, но при этом кто угодно может взять публичную часть ключа подписи из файла-замка чтобы проверить любые данные, при этом не увидев ни содержимого замка ни данных.
Примером данных может быть пароль — SuperPa55worD. Пока нас интересует только его сохранение: после закрытия приложения, передать файл ключа и шифротекст с паролем куда угодно, и никто не должен увидеть SuperPa55worD, но подтвердить наше авторство. Разумеется шифр должен быть обратим, при наличии secret_key + master password. Для этого подойдёт симметричный шифр, который превращает текст в шифротекст одним и тем же ключом, но так как ключ никто не хранит, его всегда должно быть можно вычислить из замка. Ключ назовём message_key.
Раз никто их не хранит, то для каждого нового шифрования будем получать отдельный message_key. Остаётся устроить его восстановление: если положить ключ открыто рядом с шифротекстом, всё содержимое прочитает любой получатель копии.
Из новых случайных байтов приложение создаёт временную пару X25519 только для этого шифрования — назовём её record_key. Затем берёт приватную часть record_key и постоянный публичный ключ сейфа, то есть secret_key. Из них оно вычисляет общий секрет. Из общего секрета отдельная функция получает message_key, которым и шифруется наш пароль.
При чтении приложение пойдёт другим путём. Оно возьмёт master_key и публичную часть record_key. Эти два ключа дадут тот же общий секрет. Математически оба пути сводятся к двум умножениям одной исходной точки на кривой на два приватных числа, выполненным в разном порядке. Порядок здесь не меняет результата. Поэтому ключ для чтения можно восстановить, не сохраняя приватную часть record_key.
Из двух публичных ключей получить этот секрет практически невозможно: для одного из путей нужен соответствующий приватный ключ. Публичные точки не позволяют эффективно восстановить приватные числа, из которых они получены. Этот математический трюк основа безопасности всего сейфа. Но он не защищает от того чтобы нам подсунули чужой сейф, с нашим приделанным замком.
Теперь можно сохранить шифротекст вместе с публичной частью record_key. Приватная часть record_key и использованный message_key больше не нужны для будущего чтения. Позже приложение восстановит ключ расшифровки из master_key и сохранённой публичной части record_key. Так один постоянный секрет сейфа позволяет читать множество отдельно зашифрованных значений, а message_key каждого из них восстанавливается при чтении.
Одного сокрытия текста недостаточно: при чтении нужно обнаружить и его подмену. Поэтому шифрование добавляет тег аутентификации — короткое значение, которое проверяется вместе с зашифрованными данными. Если проверка не пройдёт, приложение отвергнет результат. Оно не должно показывать случайный или изменённый текст как успешно прочитанный пароль.
Мы описали схему, в которой асимметричная пара помогает получить общий секрет, а message_key шифрует само содержимое. Такая конструкция называется Hybrid Public Key Encryption (HPKE), или гибридное шифрование с открытым ключом, и описана в RFC 9180. В Confidex используются X25519 для согласования секрета, HKDF-SHA256 для получения ключей и ChaCha20-Poly1305 для шифрования с проверкой целостности. Здесь входом для HKDF служит общий секрет, согласованный с помощью record_key и пары master_key; это отдельная задача от получения seed Ed25519. Устройство HKDF описано в RFC 5869.
Для следующего шифрования приложение создаст новый record_key и новый контекст HPKE. Это важно: повторное использование одной комбинации message_key и одноразового параметра nonce нарушает защиту шифра. В Confidex ключ и nonce выводятся внутри HPKE, используются для одного сообщения, после чего контекст отбрасывается.
Сам ChaCha20 использует 256-битный message_key. Чтобы оценить масштаб его прямого перебора, представим выдуманный суперкомпьютер, проверяющий миллиард миллиардов вариантов в секунду. Поиск среди 2²⁵⁶ ключей занял бы у него около 1,8 × 10⁵¹ лет. Алгоритм и его анализ описаны в RFC 8439.
Запись
Ну что, мы довольны?
Криминальное чтиво
Пароль мы сохранили, но для входа на сайт обычно нужны ещё имя пользователя и адрес. Иногда рядом должна лежать заметка. Если хранить эти значения по отдельности, приложению придётся как-то помнить, что они относятся друг к другу. Положим эти "бумаги" в конверт. Объединим поля в один набор и назовём это запись (record).
Состав записи может быть произвольным. Приложение собирает её поля в один пакет, сжимает его и шифрует целиком по уже разобранной схеме. Под защитой оказываются и значения, и имена полей, и их типы, и теги. Если изменить адрес сайта, даже сохранив прежний пароль, приложение подготовит новую версию всей записи и зашифрует её с новым record_key.
Hash-chain
НУЖЕН ЛИ ВАШЕМУ ПРОЕКТУ БЛОКЧЕЙН? НЕТ
XKCD 2267
Зная secret_key, посторонний может подготовить зашифрованную запись для нашего сейфа. Чтобы не принять её за нашу, приложение подписывает каждую версию записи signing_key. Без этого ключа посторонний не сможет создать версию с подписью, которую примут сервер и другие устройства.
Рядом с зашифрованной версией нужны служебные сведения: к какому сейфу и какой записи она относится, когда изменена и какие версии ей предшествовали. Эти сведения образуют открытый заголовок. Они не содержат сам пароль, но тоже требуют защиты: иначе можно было бы менять принадлежность и связи объектов, не трогая шифротекст. Приложение подписывает точные байты заголовка вместе с шифротекстом. Изменение подписанных данных нарушит проверку подписи.
Сервер здесь похож на склад запечатанных посылок. Он видит заголовок, зашифрованное содержимое и подпись, но не видит пароль внутри, и знает что такой адресат есть в городе. По закреплённой публичной части signing_key он проверяет подпись и решает, допустимо ли принять новую версию. Для этой проверки ему не нужны ни мастер-пароль, ни secret_key, ни приватные ключи. Клиент при чтении проверит ту же подпись, а затем при расшифровке ещё раз проверит связь содержимого с заголовком через тег аутентификации HPKE.
Связь версий также защищена подписью. От точных байтов заголовка и шифротекста вычисляется хеш SHA-256 — идентификатор версии. В заголовке следующей версии указывается идентификатор предыдущей. Когда мы поменяем пароль от сайта, приложение создаст и подпишет новый объект с этой ссылкой. Старый объект и его подпись останутся прежними. Поэтому подменить предшественника, сохранив подпись новой версии, не получится. Если два устройства сделали независимые изменения, сохранятся две ветви; при их объединении новая версия сможет сослаться на обе.
Помните блокчейн, вот он :-).
Открытый заголовок позволяет проверять связи без расшифровки, но часть сведений остаётся видимой. Сервер знает идентификаторы, связи версий, переданное клиентом время изменения, число полей и закодированный размер значения каждого поля. Шифрование скрывает содержимое записи, а не эти служебные сведения.
Синхронизация
«Не утечка данных, а бесплатный распределенный бекап».
Internet мем.
Для работы на нескольких устройствах нужно передавать между ними замок и историю записей. Формат объектов при этом не меняется: передаются те же зашифрованные байты и подписи, которые хранятся локально.
Для ручного переноса приложение собирает ZIP-архив: замок, все локально сохранённые зашифрованные версии записей и подписанный список файлов. Пароль и secret_key в архив не входят. На другом устройстве приложение сначала открывает замок, затем проверяет подписи, расшифровывает записи и восстанавливает локальную копию сейфа. Импорт предназначен для устройства, где этого сейфа ещё нет; с существующей копией он историю не объединяет.
Чтобы обмениваться изменениями между работающими копиями, используется сервер синхронизации. При первой загрузке он получает замок с подписью, проверяет её и вычисляет публичный адрес сейфа из публичной части signing_key. Затем запоминает этот ключ и идентификатор сейфа из заголовка: так сейф регистрируется на сервере. Последующие изменения должны соответствовать этому идентификатору и иметь подпись того же ключа.
Новому устройству нужны адрес сейфа и secret_key, поэтому приложение объединяет их в код переноса с контрольной суммой. Пользователь переносит этот код на устройство и отдельно вводит мастер-пароль; серверу отправляется только публичный адрес.
Открыв полученный замок, приложение сверяет публичные части восстановленных ключей с secret_key и заголовком, а вычисленный адрес — с кодом переноса. После этого оно сохраняет проверенную публичную часть signing_key для проверки записей.
При синхронизации приложение получает недостающие версии и отправляет свои изменения. Сервер проверяет подписи и связи с предыдущими версиями, сохраняет принятые объекты и передаёт их другим устройствам. Проверка полученных данных, расшифровка и разрешение конфликтов выполняются на устройствах пользователя.
При регистрации протокол не запрашивает имя, почту или отдельный пароль. Сервер видит публичный адрес, ключ проверки подписи, открытые заголовки, размеры объектов и связи версий. Ему также видны IP-адреса и время запросов. Мастер-пароль, secret_key, приватные ключи и открытое содержимое записей остаются на устройствах пользователя. Любой, кому известен публичный адрес, может скачать зашифрованные объекты, но для их чтения нужны секреты пользователя.
Эти проверки обнаруживают подделку присланных объектов, но не доказывают, что сервер показал всю историю и её последнее состояние. Он может скрыть неизвестную клиенту ветвь или вернуть согласованный старый набор данных. Подпись подтверждает подлинность конкретного объекта, а не полноту ответа сервера. Пользователь может заметить отсутствие изменений на другом устройстве, но согласованная старая история пройдёт проверку hash-chain.
Что происходит при смене мастер-пароля
Итак, не соблаговолит ли ваше святейшество сменить пароль?
Хакеры (1995)
При смене мастер-пароля приложение перешифровывает только замок; master_key внутри остаётся прежним. Поэтому записи, их подписи, secret_key и адрес сейфа не меняются. Новый замок подписывается тем же signing_key.
Старая копия замка по-прежнему открывается старым паролем и прежним secret_key; смена пароля не отзывает уже раскрытые ключи.
Общая схема
Замок защищает master_key, message_key шифрует записи, а signing_key создаёт подписи. В ZIP и на сервер передаются зашифрованные объекты с подписями.