«Разблокируй телефон.» — Он разблокирует. Открывается мессенджер. Чаты с «Alex», «Mila», «Family». Всё выглядит нормально. Всё — подделка.
Привет, Хабр! Я разрабатываю open-source мессенджер Xipher (C++/Android), и одна из фич, которую пришлось проектировать особенно тщательно — Panic Mode. Это система правдоподобной отрицаемости (plausible deniability): при вводе специального PIN-кода мессенджер показывает полностью фейковую, но убедительную базу данных с поддельными чатами, а параллельно отправляет скрытый SOS-сигнал на сервер.
В статье разберу архитектуру целиком — от криптографического разделения баз до генерации правдоподобных фейков и маскировки panic-алерта под рутинный сетевой запрос. Весь код — из реального проекта.
Исходники открыты — ссылка на GitHub в конце статьи.
Содержание
1. Зачем нужна правдоподобная отрицаемость
E2EE решает проблему перехвата. Исчезающие сообщения решают проблему хранения. Но ни то, ни другое не решает проблему физического принуждения.
Сценарии:
Пограничный контроль: вас просят разблокировать телефон и показать мессенджер. В ряде стран отказ — уголовное правонарушение (UK — Regulation of Investigatory Powers Act, Part III: до 2 лет тюрьмы за отказ предоставить ключ шифрования)
Задержание: устройство изъято, пароль получен под давлением
Корпоративный шпионаж: сотрудника вынуждают показать рабочие чаты
Домашнее насилие: абьюзер требует доступ к переписке
Во всех этих случаях жертва не может солгать — пустой мессенджер или отсутствие приложения вызовут подозрения. А «Удалить чат» оставляет forensic-следы в SQLite (WAL-журнал, UNDELog, свободные страницы).
Правильный ответ — не скрывать, а подменять. Показать убедительную, нормально выглядящую переписку, которая не вызовет вопросов. Именно это делает Panic Mode.
Концепция не нова — VeraCrypt Hidden Volume использует тот же принцип для дисков. Я адаптировал его для мессенджера.
2. Архитектура: две базы, один PIN-экран
Система построена на двух полностью изолированных SQLCipher-базах данных:
+---------------------------------------+ | Android Device | | | | /data/data/com.xipher/.../ | | | | +----------------+ +----------------+| | | real_user.db | | decoy_user.db || | | (SQLCipher) | | (SQLCipher) || | | | | || | | PIN: 1234 | | PIN: 5678 || | | Реальные чаты | | Фейковые чаты || | | 47 диалогов | | 47 диалогов || | | 12K сообщений | | ~180 сообщений || | +----------------+ +----------------+| | | | +------------------------------------+| | | PinUnlockActivity || | | [ _ _ _ _ ] <-- один экран || | | Введите PIN || | +------------------------------------+| +---------------------------------------+
Ключевые принципы:
Один экран ввода — пользователь вводит PIN в одно и то же поле. Нет кнопки «Panic Mode», нет переключателя, нет визуальной разницы
Криптографическое разделение — какая база откроется, определяет PIN через SQLCipher, а не
if/elseв кодеСтруктурная мимикрия — количество чатов в decoy-базе совпадает с реальной, типы чатов (DM/группы/каналы) сохранены
Полная изоляция — в panic mode WebSocket отключён, push-токен не синхронизируется, реальные данные невидимы
3. Криптографическая развилка: SQLCipher решает за вас
Это самая элегантная часть. В коде нигде нет сравнения if (pin == panicPin). Вместо этого:
private void initInternal(Context context, String password, boolean allowDecoyCreate) throws InvalidPasswordException { char[] passphrase = password != null ? password.toCharArray() : new char[0]; try { try { // ▶ Шаг 1: пробуем открыть РЕАЛЬНУЮ базу с введённым PIN DbHolder real = openDatabase(context, REAL_DB_NAME, passphrase, true); setActive(context, real, false); // isPanic = false return; } catch (Exception ignore) { // SQLCipher не смог расшифровать → PIN не от реальной базы } // ▶ Шаг 2: реальная не открылась → пробуем decoy if (!PanicModePrefs.isEnabled(context) && !allowDecoyCreate) { throw new IllegalStateException("Panic mode disabled"); } DbHolder decoy = openDatabase(context, DECOY_DB_NAME, passphrase, allowDecoyCreate); if (allowDecoyCreate && decoy.created) { seedDecoy(decoy.db); } setActive(context, decoy, true); // isPanic = true } catch (Exception e) { throw new InvalidPasswordException("Invalid password", e); } finally { wipeChars(passphrase); // Затираем PIN из памяти } }
Как это работает:
Пользователь вводит PIN
DatabaseManagerпробует расшифроватьreal_user.dbэтим PIN через SQLCipherЕсли расшифровка удалась — это реальный PIN. Открывается настоящая база
Если
real_user.dbне расшифровался — пробуемdecoy_user.dbЕсли decoy расшифровался — это panic PIN. Включается panic mode
Если ни одна база не открылась — «неверный пароль»
Почему это надёжно:
Подход | Уязвимость |
|---|---|
| Panic PIN хранится в открытом виде или как хеш. Forensic-анализ извлечёт его из APK/SharedPreferences |
| Наличие двух хешей — прямое доказательство существования panic mode |
SQLCipher try/catch (наш подход) | Нет сравнения PIN. Нет хранения PIN. SQLCipher — чёрный ящик: либо расшифровал, либо нет. Forensic-аналитик видит два зашифрованных файла и не может доказать, какой «настоящий» |
В setActive() сразу срабатывает цепная реакция:
private void setActive(Context context, DbHolder holder, boolean isPanic) { synchronized (lock) { clearSensitiveStateLocked(); // Затираем предыдущую базу activeDb = holder.db; activePassphrase = holder.passphrase; activeDbName = holder.dbName; panicMode = isPanic; } SecurityContext.get().setPanicMode(isPanic); // ▶ Если panic — немедленно ставим в очередь скрытый SOS if (isPanic) { PanicModeWorker.enqueue(context.getApplicationContext()); } }
Обратите внимание: clearSensitiveStateLocked() затирает байты предыдущего пароля из памяти через Arrays.fill(activePassphrase, (byte) 0). Это не идеальная защита от cold boot атак, но лучше, чем оставлять пароль в куче.
4. Генератор фейков: как обмануть следователя
Фейковая база должна быть убедительной. Пустая база или два чата «Welcome» и «Support» мгновенно выдадут обман. Поэтому DecoyDataGenerator анализирует структуру реальной базы и генерирует правдоподобную копию.
4.1. Зеркалирование структуры
Генератор итерирует по каждому реальному чату и создаёт его «двойника»:
static void generate(Context context, AppDatabase source, AppDatabase target, String selfId, String selfName) { List sourceChats = source != null ? source.chatDao().getAll() : null; if (sourceChats == null || sourceChats.isEmpty()) { seedFallback(target, System.currentTimeMillis()); // Минимальный fallback return; } SecureRandom random = new SecureRandom(); Set<String> usedNames = new HashSet<>(); int index = 0; for (ChatEntity src : sourceChats) { ChatEntity chat = new ChatEntity(); chat.id = "decoy_" + index + "_" + UUID.randomUUID().toString(); // ▶ Сохраняем СТРУКТУРУ: тип чата идентичен реальному chat.isGroup = src.isGroup; chat.isChannel = src.isChannel; chat.isSaved = src.isSaved; chat.isPrivate = src.isPrivate; // ▶ Меняем СОДЕРЖАНИЕ: имя, сообщения, даты — случайные if (src.isGroup) { chat.title = pickUnique(GROUP_NAMES, usedNames, random); } else if (src.isChannel) { chat.title = pickUnique(CHANNEL_NAMES, usedNames, random); } else { chat.title = pickUnique(DIRECT_NAMES, usedNames, random); } // ... } }
Результат: если у вас 15 личных чатов, 8 групп и 3 канала — в decoy-базе будет ровно 15 личных, 8 групп и 3 канала. Но с другими именами и сообщениями.
4.2. Логарифмическое масштабирование сообщений
Наивный подход — «столько же сообщений, сколько в реальном чате» — слишком дорогой и подозрительный (зачем 10 000 скучных сообщений?). Вместо этого:
private static int estimateMessageCount(AppDatabase source, ChatEntity chat, SecureRandom random) { int realCount = source.messageDao().countByChat(chat.id); int count = 2 + (int) Math.round(Math.log(realCount + 1) * 2.0d); if (count < 2) count = 2; if (count > 12) count = 12; return count; }
Логика: 2 + ln(realCount + 1) * 2, ограничено диапазоном [2, 12].
Реальных сообщений | Фейковых сообщений |
|---|---|
0 | 2 |
10 | 7 |
100 | 11 |
1 000 | 12 (cap) |
10 000 | 12 (cap) |
Почему логарифм? Активный чат (1000 сообщений) должен выглядеть «живым» в preview-списке, но генерировать 1000 фейковых сообщений — и долго, и подозрительно, и бессмысленно: следователь не будет скроллить 1000 сообщений «Got it, thanks!». Достаточно 10-12 сообщений, чтобы чат выглядел естественно при беглом просмотре.
4.3. Пулы нейтрального контента
Фейковые сообщения выбираются из специально составленных пулов — однозначно безобидных и невызывающих подозрений:
private static final String[] DIRECT_MESSAGES = { "Hey, are we still on for today?", "Got it, thanks!", "On my way.", "Can you send the file?", "Let's do it tomorrow.", "Sounds good.", "I'll check and get back to you.", "See you at 6.", "All set here.", "Thanks, appreciate it." }; private static final String[] GROUP_MESSAGES = { "Reminder: meeting at 4pm.", "Anyone free this weekend?", "I'll bring snacks.", "Updated the doc.", "Please review the notes.", "Let's sync after lunch.", "Sharing the draft now.", "Heads-up: schedule changed." };
Контент намеренно скучный. Никаких тем, которые могли бы заинтересовать проверяющего. Только бытовая координация — встречи, файлы, обеды.
4.4. Реалистичная хронология
Фейковые сообщения получают правдоподобные временные метки:
long jitter = random.nextInt(60 * 1000); // Случайный сдвиг до 60 секунд long createdAt = baseTime - ((count - 1 - i) * 2L * 60L * 1000L) + jitter;
Интервал между сообщениями — ~2 минуты с jitter до минуты. Это имитирует естественный ритм переписки: не слишком быстро (бот), не слишком медленно (заброшенный чат).
4.5. Уникальность имён и SecureRandom
private static String pickUnique(String[] pool, Set used, SecureRandom random) { for (int i = 0; i < pool.length * 2; i++) { String candidate = pool[random.nextInt(pool.length)]; if (used.add(candidate)) { return candidate; } } // Fallback: имя + число, если пул исчерпан String fallback = pool[random.nextInt(pool.length)] + " " + (used.size() + 1); used.add(fallback); return fallback; }
Два момента:
SecureRandom, а неjava.util.Random— криптографически стойкий ГПСЧ. Атакующий не может предсказать, какие имена будут выбраныДедупликация через HashSet — никогда не будет двух чатов с «Alex». Если пул имён исчерпан (20 имён, а чатов больше), добавляется порядковый номер: «Alex 21»
4.6. Атомарная запись
target.runInTransaction(() -> { target.messageDao().clearAll(); target.chatDao().clear(); target.chatDao().upsertAll(chats); target.messageDao().upsertAll(messages); });
Вся decoy-база заполняется в одной транзакции. Если процесс упадёт посреди генерации — никакого «корраптнутого» промежуточного состояния.
5. Скрытый SOS: AES-GCM под видом metadata_sync
При активации panic mode на сервер должен уйти сигнал «убить все мои реальные сессии». Но этот сигнал сам по себе — улика. Если наблюдатель видит POST /api/panic — игра окончена.
Решение: замаскировать panic-алерт под рутинный сетевой запрос.
5.1. Внешний слой: безобидный JSON
private Request buildRequest() throws Exception { String token = SecurityContext.get().getCurrentSessionToken(); String deviceId = Settings.Secure.getString( getApplicationContext().getContentResolver(), Settings.Secure.ANDROID_ID); String encrypted = encryptPayload(token, deviceId); // ▶ Внешний JSON — выглядит как рутинная синхронизация JsonObject body = new JsonObject(); body.addProperty("type", "metadata_sync"); body.addProperty("ts", System.currentTimeMillis()); body.addProperty("device_id", deviceId != null ? deviceId : "unknown"); body.addProperty("payload", encrypted); // Настоящий сигнал — внутри RequestBody rb = RequestBody.create(body.toString(), JSON); return new Request.Builder() .url(BuildConfig.API_BASE + "/api/v1/auth/panic_alert") .post(rb) .header("X-Auth-Token", token) .build(); }
Наблюдатель, перехватывающий HTTPS-трафик (даже через корпоративный MITM-прокси с установленным корневым сертификатом), видит:
{ "type": "metadata_sync", "ts": 1708701234567, "device_id": "a1b2c3d4e5f6", "payload": "dGhpcyBpcyBub3QgdGhlIHJlYWwgcGF5bG9hZA..." }
Поле type: "metadata_sync" — стандартный тип запроса, который приложение отправляет регулярно. Один лишний запрос неотличим от фонового трафика.
5.2. Внутренний слой: AES-128-GCM
private String encryptPayload(String token, String deviceId) throws Exception { // ▶ Деривация ключа: SHA-256(token + "|" + deviceId), обрезка до 16 байт String keyMaterial = (token != null ? token : "") + "|" + (deviceId != null ? deviceId : ""); byte[] keyBytes = MessageDigest.getInstance("SHA-256") .digest(keyMaterial.getBytes(StandardCharsets.UTF_8)); byte[] aesKey = Arrays.copyOf(keyBytes, 16); // ▶ AES-128-GCM с случайным 12-байт IV byte[] iv = new byte[12]; new SecureRandom().nextBytes(iv); Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(aesKey, "AES"), new GCMParameterSpec(128, iv)); // ▶ Настоящий payload — скрыт внутри JsonObject payload = new JsonObject(); payload.addProperty("event", "metadata_sync"); // Камуфляж даже внутри payload.addProperty("panic", true); // Единственный маркер payload.addProperty("action", "kill_sessions"); // Что сделать payload.addProperty("ts", System.currentTimeMillis()); byte[] ciphertext = cipher.doFinal( payload.toString().getBytes(StandardCharsets.UTF_8)); // IV || ciphertext → Base64 byte[] combined = new byte[iv.length + ciphertext.length]; System.arraycopy(iv, 0, combined, 0, iv.length); System.arraycopy(ciphertext, 0, combined, iv.length, ciphertext.length); return Base64.encodeToString(combined, Base64.NO_WRAP); }
Почему именно так:
Решение | Зачем |
|---|---|
AES-128-GCM | Шифрование + аутентификация. Нельзя подменить payload |
Ключ = SHA-256(token + deviceId) | Только сервер (знающий оба значения) может расшифровать. Не нужен отдельный shared secret |
Случайный IV | Каждый запрос уникален. Нельзя определить panic по повторяющемуся blob |
| Если AES вскрыт, содержимое выглядит рутинным. |
5.3. Гарантия доставки: WorkManager
public static void enqueue(Context context) { Constraints constraints = new Constraints.Builder() .setRequiredNetworkType(NetworkType.CONNECTED) .build(); OneTimeWorkRequest req = new OneTimeWorkRequest.Builder(PanicModeWorker.class) .setConstraints(constraints) .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 10, TimeUnit.SECONDS) .build(); WorkManager.getInstance(context.getApplicationContext()) .enqueueUniqueWork(UNIQUE_NAME, ExistingWorkPolicy.REPLACE, req); }
Почему WorkManager, а не просто OkHttp?
Переживает убийство процесса — если Android убьёт приложение после ввода panic PIN, WorkManager возобновит задачу при следующем запуске
Ожидание сети — если телефон был в авиарежиме, panic-алерт уйдёт сразу при появлении сети
Exponential backoff — при ошибке сети: 10с → 20с → 40с → ... Не бросает попытки
REPLACE policy — повторный вход в panic mode не создаёт дубликаты
Что делает сервер: получив panic-алерт, сервер расшифровывает payload, проверяет "panic": true, и убивает все активные сессии пользователя. Это значит: никто не сможет подключиться к аккаунту с другого устройства, используя украденный токен.
6. Сетевая изоляция: ни один реальный бит не утечёт
Panic mode бесполезен, если сквозь фейковую базу просвечивают реальные данные. Поэтому в panic mode вся реальная сетевая активность отключена.
6.1. WebSocket заблокирован
// SocketManager.java public void connect() { if (SecurityContext.get().isPanicMode()) { Log.w(TAG, "panic mode active; websocket blocked"); Listener l = listener; if (l != null) l.onClosed(); return; } // ... нормальное подключение }
В panic mode WebSocket не открывается. Приложение не получает реальных входящих сообщений и не отправляет исходящих. Для UI это выглядит как «нет интернета» — вполне правдоподобно.
6.2. Push-токен не синхронизируется
// PinUnlockActivity.java private void openMain() { if (!SecurityContext.get().isPanicMode()) { PushTokenManager.syncIfPossible(this); // Только для реального режима } startActivity(new Intent(this, ChatActivity.class)); finish(); }
Push-токен FCM/RuStore не отправляется на сервер. Это значит: сервер не будет слать push-уведомления на это устройство, и новые сообщения за реальных собеседников не просочатся через notification bar.
6.3. Визуальная идентичность
ChatActivity открывается одинаково независимо от режима. Тот же список чатов, тот же UI, те же иконки. Наблюдатель, смотрящий через плечо, не заметит разницы.
7. Полный flow: что происходит посекундно
t=0.000 Пользователь вводит panic PIN [5678] в PinUnlockActivity t=0.001 DatabaseManager.init("5678") вызван t=0.050 SQLCipher пробует: real_user.db + "5678" → FAIL (неверный ключ) t=0.100 SQLCipher пробует: decoy_user.db + "5678" → SUCCESS t=0.110 setActive(decoy, isPanic=true) ├── clearSensitiveStateLocked() // Затирает bytе[] старого пароля ├── SecurityContext.setPanicMode(true) // Глобальный volatile флаг └── PanicModeWorker.enqueue() // SOS в очередь WorkManager t=0.120 PinUnlockActivity.openMain() ├── PushTokenManager.syncIfPossible → SKIP (panic mode) └── startActivity(ChatActivity) t=0.200 ChatActivity отображает decoy-чаты: Alex, Mila, Family, Weekend Plan, City News ... t=0.300 SocketManager.connect() → BLOCKED (panic mode) → UI показывает "connecting..." (выглядит как слабый интернет) t=0.500 PanicModeWorker.startWork() ├── buildRequest(): JSON { type: "metadata_sync", payload: AES(...) } └── OkHttp POST /api/v1/auth/panic_alert t=1.200 Сервер получает запрос ├── Расшифровывает payload: { panic: true, action: "kill_sessions" } └── Инвалидирует все сессии пользователя Итого: 1.2 секунды от ввода PIN до убийства всех сессий.
8. Модель угроз: от кого защищаем
Adversary | Возможности | Panic Mode защищает? |
|---|---|---|
Пограничный контроль | Визуальный осмотр телефона, 5-10 минут | Да — увидят нормально выглядящий мессенджер с безобидными чатами |
Разъярённый партнёр | Требует разблокировать, проверяет чаты | Да — увидят «Alex», «Mila», «Got it, thanks!» |
Forensic-аналитик | Полный дамп файловой системы, инструменты типа Cellebrite | Частично — увидят два зашифрованных файла, но SQLCipher с достаточно длинным PIN устойчив |
Forensic + знание о Xipher | Знает что Xipher имеет panic mode, ищет | Слабо — наличие двух файлов БД может вызвать подозрения (см. раздел «Ограничения») |
Cold boot / RAM dump | Извлечение ключей из оперативной памяти | Нет — |
9. Сравнение с аналогами
VeraCrypt Hidden Volume
VeraCrypt Hidden Volume — прямой идеологический предшественник. Два пароля → два раздела диска. Внешний выглядит нормально, внутренний — настоящий.
Чем Panic Mode отличается:
Адаптирован для мессенджера, а не файловой системы — генерируются чаты, а не файлы
Автоматическая генерация контента (VeraCrypt требует вручную заполнять outer volume)
Серверный компонент (kill sessions) — VeraCrypt чисто локальный
Signal — нет аналога
Signal не предлагает правдоподобной отрицаемости. «Disappearing messages» удаляют контент, но не скрывают факт использования мессенджера. Пустой Signal с нулём чатов — сам по себе подозрителен.
Briar — нет аналога
Briar (Tor-based мессенджер для активистов) шифрует все данные локально, но тоже не имеет decoy-режима. При принуждении — либо отдаёшь PIN, либо отказываешься.
Итоговая таблица
Критерий | VeraCrypt | Signal | Briar | Xipher Panic Mode |
|---|---|---|---|---|
Два пароля → два набора данных | Да | Нет | Нет | Да |
Автогенерация фейков | Нет (вручную) | — | — | Да (DecoyDataGenerator) |
Структурная мимикрия | Зависит от пользователя | — | — | Да (зеркалирует реальные чаты) |
Скрытый серверный алерт | Нет | Нет | Нет | Да (AES-GCM metadata_sync) |
Kill other sessions | N/A | Нет | Нет | Да (panic → kill_sessions) |
Устойчив к forensic | Высоко | Низко | Средне | Средне (см. ограничения) |
10. Честные ограничения
10.1. Два файла базы данных — улика
Forensic-аналитик, знающий о Xipher, обнаружит два файла в /data/data/com.xipher/databases/: real_user.db и decoy_user.db. Само наличие двух баз — косвенное доказательство panic mode.
Mitigation (будущее): использовать единый файл с двумя SQLCipher-«страницами» (аналог VeraCrypt, где outer и hidden volume в одном контейнере). Это сложнее реализовать с Room/SQLCipher, но теоретически возможно.
10.2. Имена файлов говорящие
real_user.db и decoy_user.db — очевидные имена. В production стоит использовать нейтральные имена (user_v1.db, user_v2.db), чтобы не было ясно, какой файл «настоящий».
10.3. JVM не гарантирует затирание памяти
wipeChars(passphrase) вызывает Arrays.fill(passphrase, '\0'), но JIT-компилятор может оптимизировать эту запись (dead store elimination). Garbage Collector может создать копии массива при сборке. Для Java/Android это нерешаемая проблема — единственное полное решение — NDK + mlock + explicit_bzero.
10.4. Английский контент фейков
Текущие пулы сообщений — на английском. Для русскоговорящего пользователя англоязычные чаты подозрительны. Необходима локализация пулов под язык устройства.
10.5. Пустая история после входа
В panic mode WebSocket отключён → новые сообщения не приходят. Если следователь наблюдает за телефоном длительное время, отсутствие входящих вызовет вопросы. Частичное решение — генерировать фейковый «incoming message» по таймеру, но это значительно усложняет систему.
10.6. Rubber-hose cryptanalysis
Никакая техническая мера не защищает от достаточно мотивированного физического насилия. Panic Mode снижает порог «знаю, что скрываешь» до «может быть, скрываешь», но не устраняет его полностью. Это инструмент для покупки времени, а не абсолютная защита.
11. Заключение
Panic Mode — это реализация принципа «не скрывай — подменяй» для мессенджера:
Две SQLCipher базы — криптографическая развилка без
if/elseс PINDecoyDataGenerator — зеркалирует структуру реальных чатов, логарифмически масштабирует сообщения, использует SecureRandom
PanicModeWorker — AES-128-GCM panic-алерт, замаскированный под
metadata_sync, с гарантией доставки через WorkManagerСетевая изоляция — WebSocket, push-токены и real-time данные полностью отключены в panic mode
Это не серебряная пуля — у подхода есть конкретные ограничения. Но для сценария «показать телефон за 5 минут» — это разница между «здесь скрытые чаты» и «обычный мессенджер, вот Alex пишет See you at 6».
Что можно забрать в свой проект:
Криптографический fork вместо условного — пусть шифрование решает, какой путь выбрать, а не ваш код
Структурная мимикрия — фейковые данные должны повторять форму реальных
Камуфляж сетевых запросов — критические сигналы должны быть неотличимы от рутинного трафика
Логарифмическое масштабирование — универсальный приём для «достаточно, чтобы выглядело реально, не слишком, чтобы было дорого»
Источники и дальнейшее чтение
Plausible deniability:
VeraCrypt: Plausible Deniability — идеологическая основа hidden volumes
UK RIPA Part III — закон, обязывающий раскрывать ключи шифрования
Czeskis A. et al. — Defeating Encrypted and Deniable File Systems (USENIX Security '08)
SQLCipher:
SQLCipher Design — 256-bit AES, PBKDF2 key derivation, HMAC-SHA512 page authentication
Mobile forensics:
Anglano C. — Forensic analysis of WhatsApp Messenger on Android smartphones (Digital Investigation, 2014)
Cellebrite UFED — промышленный инструмент извлечения данных из мобильных устройств
Исходный код проекта: github.com/w78flezeex/Xipher.pro (copyleft-лицензия)
Это вторая статья из серии о криптографической архитектуре Xipher. Первая была про защиту метаданных (Metadata Guard). Если интересен разбор E2EE-слоя (X25519 + Double Ratchet) — пишите в комментариях.
