Мы делаем мессенджер RCQ (rcq.app, исходники клиентов на github.com/rcq-messenger). В нём есть штука, которую в разных приложениях называют по-разному: паничный PIN, duress PIN, подставной код. Смысл один. У вас просят разблокировать телефон, вы вводите второй PIN, и человек напротив видит приложение, в котором ничего интересного нет.
В августе мы сели проверять какие у нас остались проблемы с этой фичей. Проверка началась как формальность перед внешним аудитом: пройти по коду, сверить с тем, что написано в модели угроз, поправить формулировки. Закончилась она тем, что на Android подставной PIN оказался не границей, а ключом от всей переписки.
Как это устроено на телефоне
Обе платформы держат локальную базу сообщений зашифрованной. Ключ базы (дальше dataKey) лежит не в открытом виде: он запечатан в маленьком файле, который открывается ключом, выведенным из PIN. PBKDF2-HMAC-SHA256, 400 000 раундов, соль лежит рядом с файлом, к соли примешивается pepper из защищённого хранилища платформы. Сам PIN нигде не хранится, и проверка это попытка расшифровать слот: не расшифровалось, значит код не тот, никакого сравнения хэшей.
Слотов несколько. Настоящий, подставной, стирающий. Каждый несёт свою полезную нагрузку, и вот в этой полезной нагрузке была ошибка.
На Android при установке подставного PIN в его слот клался тот же самый dataKey, что и в настоящий. Скрытие второго аккаунта делалось фильтром списка в памяти: файлы .db оставались на диске нетронутыми, ключ от них выдавался по подставному коду, а человек в этот момент считал, что он только что защитился и теперь можно спокойно отдавать телефон.
Это хуже, чем не иметь фичи вовсе. Без неё вы просто не разблокируете телефон и это ваш единственный ход. С ней вы своими руками вводите код, который открывает всё, и уходите с ощущением, что сработали грамотно.
Локауты, кстати, работали ровно как заявлено: пять неверных попыток дают 30 секунд, шестая минуту, седьмая пять минут, восьмая пятнадцать, дальше час. Задержка, а не стирание, потому что самый вероятный человек, промахнувшийся пять раз подряд, это владелец телефона, у которого к примеру замёрзли пальцы.
Нашли мы это не сканером и не статическим анализом. Просто открыли файл и прочитали его сверху вниз, потому что перед аудитом надо было убедиться, что описание в документации совпадает с кодом. Комментарий, объясняющий, что ключ общий, стоял на месте несколько месяцев и, судя по всему, ни разу не был прочитан после того, как его написали :'(
Почему так получилось
Фича изначально задумывалась как «второй настоящий аккаунт». Логика была такая: пусть подставной режим показывает не пустоту, а живой аккаунт с перепиской, тогда легенда выглядит убедительно. Раз это настоящий аккаунт на том же устройстве, он работает с той же базой, и ключ у базы один. Так и появился общий dataKey, причём каждый отдельный шаг в этой цепочке выглядел разумным.
Мы довольно долго обсуждали, как сделать второй аккаунт правдоподобнее: завести ему свой список контактов, отдельные настройки, свою аватарку, подключить ИИ к генерации, чтобы у пользователей были разные контакты и переписки. Потом заметили, что вся ветка тупиковая, и не из-за криптографии.
Все аккаунты одного устройства регистрируют один push-эндпоинт, ходят с одного IP и с одним device id на один остров. Если у проверяющей стороны есть доступ к серверу (или сервер это мы, и нас попросили), связь «подставной аккаунт и настоящий живут на одном телефоне» вскрывается одним запросом к базе. Легенда, которая держится ровно до первого такого запроса, легендой не является, и достраивать ей контакты бессмысленно.
На iOS модель с самого начала была другой: у подставного слота свой случайный dataKey, настоящее хранилище остаётся закрытым, ключ от него в подставной сессии просто не существует. Но там была своя беда. Вход в подставной режим активно чистил контакты и группы, и человек, отдавший код, показывал пустой экран. Пустой мессенджер на телефоне живого человека это ровно та улика, ради которой всё затевалось, только теперь она указывает на то, что вы что-то прячете.
Итого одна платформа была криптографически честной и бесполезной по легенде, вторая убедительной и опасной.
Что по итогу сделали
Взяли модель iOS и достроили ей то, чего не хватало.
Подставной слот несёт собственный случайный ключ, который открывает ровно один файл: подставное хранилище. Ни настоящая база, ни базы других аккаунтов этим ключом не открываются, потому что там другие ключи и никакой связи между ними нет. Дальше подставной режим ничего не чистит, он заполняется: локально генерируется набор контактов и переписка (пользователь сам выбирает какие переписки можно взять из основого аккаунта) между ними, всё это пишется в подставную базу, и человек видит обжитое приложение с историей за несколько месяцев.
Генерация оказалась отдельной маленькой задачей. Переписка, где все сообщения пришли в одну минуту, читается как подделка с первого взгляда, поэтому даты разложены по неделям, длина сообщений разная, у части диалогов последняя активность старая. Это та часть работы, которую невозможно сделать «правильно», можно только сделать не откровенно фальшиво.
Подставной режим офлайновый. Из него нельзя отправить сообщение по-настоящему, отправка имитируется локально. Это цена, и мы её платим сознательно: любой реальный сетевой запрос из подставного режима это ниточка к настоящему аккаунту на сервере, а весь смысл был в том, чтобы ниточек не оставалось.
Старые слоты, созданные до переделки, помечаются как legacy и ведут себя по-старому до перевыпуска. Мигрировать их молча нельзя: чтобы переписать содержимое слота, надо его открыть, а открывается он только тем PIN, которого у приложения нет. Единственный честный путь это попросить владельца задать подставной код заново, и приложение теперь об этом говорит.
Отдельная история это уведомления. Пуш от настоящего контакта приходит и показывается поверх подставного экрана, с именем отправителя и куском текста. На Android пуш резолвил аккаунт по нефильтрованному списку, на iOS расширение уведомлений про PIN вообще ничего не знает, потому что живёт в отдельном процессе со своим состоянием. До сих пор не понимаю, почему мы не заметили этого раньше: сценарий «телефон в руках у постороннего» это ровно тот сценарий, где всплывающее уведомление решает всё.
Десктоп: другой корпус, другие правила
На телефоне у приложения есть свой контейнер, к которому чужой процесс без root не подберётся. В браузере такого места нет вообще: любой скрипт в том же origin читает и localStorage, и IndexedDB, включая наш собственный код, если в него что-то влезло. Поэтому в веб-версии RCQ PIN'а нет и не будет, и на экране «что лежит в этом браузере» мы прямо пишем, что защиты уровня PIN здесь не существует.
У десктопного приложения (Tauri) есть свой каталог на диске, который сама страница не читает. Туда мы и положили хранилище: vault.json, Argon2id (64 МБ памяти, 3 прохода) выводит ключ из PIN, AES-256-GCM запечатывает содержимое. Неверный PIN не расшифровывает мусор, он не проходит проверку подлинности, то есть страница не получает правдоподобной ерунды и не строит на ней интерфейс.
Первая версия закрывала только аккаунт: ключи, фразу восстановления, сессию. История переписки оставалась на диске в открытом виде, и мы честно писали об этом в настройках, потому что значок замка обещает больше, чем делает. Через сутки закрыли и её: внутри того же запечатанного JSON лежит ещё один случайный ключ, которым шифруются полученная история, все исходящие логи и кэш картинок.
Одна деталь оттуда, которая стоила нам времени. Исходящие сообщения читаются синхронно, экран чата собирает состояние прямо в рендере, а AES-GCM в браузере асинхронный. Пришлось расшифровывать их один раз за экраном блокировки и держать расшифрованными в памяти окна, а на диск писать только шифротекст. Приём не новый, мы его уже применяли для одного маленького списка, но там это была одна структура, а тут все логи всех тредов разом, и порядок инициализации пришлось разбирать заново.
К этому же корпусу относится автоблокировка. PIN, который спрашивают только при запуске, защищает машину, которую выключают. Ноутбук, оставленный открытым, это другой случай, поэтому приложение умеет запираться само через 5, 15 или 60 минут без ввода. По умолчанию выключено: блокировка, которую никто не просил, учит людей отключать всю функцию целиком.
Чего это не решает
Короткий PIN брутфорсится офлайн. Если файл вынули с разблокированного или рутованного устройства, четыре цифры это десять тысяч вариантов. 400 000 раундов PBKDF2 делают перебор медленным, но не невозможным: считайте десятки минут на нормальном железе, а не годы. Единственное, что реально двигает эту границу, длина кода, и шесть цифр лучше четырёх на два порядка.
Pepper не секрет в строгом смысле. Он лежит в защищённом хранилище платформы, и на устройстве с root или jailbreak достаётся вместе со всем остальным. Он поднимает стоимость атаки «унесли файл, ковыряем на своём ноутбуке», и на этом его роль заканчивается.
Подставной режим не защищает от того, кто знает, что он есть. Если у проверяющего есть время, желание и понимание, что в приложении бывает второй код, он спросит второй код, и криптография на это не отвечает вообще ничем.
Стирающий PIN асимметричен. Приложение шлёт запрос на удаление аккаунта на сервере (при включенной опции) и чистит локально только активный аккаунт.
Числа
Телефоны: PBKDF2-HMAC-SHA256, 400 000 раундов, AES-256-GCM для слота, база под SQLCipher. Локауты 30 с, 60 с, 5 мин, 15 мин, час.
Десктоп: Argon2id, 64 МБ, 3 прохода, AES-256-GCM. Пять неверных попыток запускают откат, удваивающийся до пяти минут. В памяти процесса живёт выведенный ключ, а не PIN: что-то держать приходится, иначе каждое обновление токена спрашивало бы код заново, и пользователя бы стало это раздражать.
Веб: ничего. Мы это решили и написали на экране «что лежит в этом браузере».
Что дальше
Правим документацию. Наша собственная статья про PIN, вышедшая раньше, сейчас врёт минимум в десяти местах: соль в ней в keychain (на самом деле в файле), pepper объявлен константой (он случайный, то есть код оказался сильнее статьи), таблица лбокаутов мимо на порядок, decoy описан с посевом контактов, которого тогда не было. Модель угроз в репозитории уже поправлена.
Если вам интересно ковыряться в таком, клиенты открыты: Android и iOS лежат на GitHub целиком, включая всё описанное выше. Замечания по коду принимаем в issues, а если нашли дыру, пишите на legal@rcq.app с пометкой SECURITY.

