«Мы знаем, что вы вчера в 23:47 переписывались с Алексеем 14 минут. О содержании разговора нам неизвестно.» — Так выглядит мир, где сообщения зашифрованы, а метаданные — нет.

Привет, Хабр! Я занимаюсь разработкой open-source мессенджера (проект Xipher, C++/Android), и один из компонентов, который пришлось проектировать с нуля — защита метаданных. Не содержимого сообщений (E2EE сейчас есть у всех), а информации о самом факте общения: кто с кем, когда, сколько раз.

В этой статье я подробно разберу инженерные решения, к которым пришёл, — от криптографических примитивов до С++ кода и SQL-схемы. Все примеры — из реального работающего кода. В конце честно расскажу, где подход имеет ограничения и чем отличается от того, что делают Signal и Tor.

��сходники проекта открыты — ссылка на GitHub в конце статьи, если захотите покопаться или раскритиковать.

Содержание

  1. Зачем вообще шифровать метаданные?

  2. Модель угроз: что знает сервер

  3. Архитектура: четыре уровня скрытности

  4. HMAC-SHA256 Anonymous Pairs — ядро системы

  5. Отравление собственных логов

  6. Push-уведомления как канал утечки

  7. Timing Obfuscation: случайные задержки против корреляционного анализа

  8. Мердж настроек: дипломатия приватности

  9. Хранение и production-реалии

  10. Полная картина: что видит атакующий с root-доступом к серверу

  11. Сравнение с аналогами: Signal, Tor, Matrix

  12. Честные ограничения и что не работает

  13. Заключение и ссылки


1. Зачем вообще шифровать метаданные?

Большинство «защищённых» мессенджеров гордо заявляют: «Мы используем E2EE!». И это правда — AES-256-GCM + X25519 + HKDF делают содержимое сообщений нечитаемым для сервера. Вот только сервер всё равно знает:

Метаданные

Что из них можно извлечь

Кто с кем общ��ется (sender_id → receiver_id)

Социальный граф пользователя

Когда (timestamp отправки/доставки)

Режим дня, часовой пояс, срочность

Как часто (кол-во сообщений за период)

Интенсивность отношений

Статус «онлайн»

Привычки, реальное местоположение

Typing indicators

Что человек собирался написать, но передумал

Read receipts

Скорость реакции, приоритетность собеседника

Push-уведомления (payload в FCM/RuStore)

Google/VK знают receiver_id + preview

User-Agent

Модель устройства, версия ОС, fingerprint

Исследование «Metadata: Piecing Together a Privacy Solution» от EFF наглядно показало: метаданные телефонных звонков позволяют восстановить круг общения, рабочий график и политические предпочтения человека — без единого слова из содержания разговоров.

Бывший директор АНБ Майкл Хейден прямо сказал: «We kill people based on metadata.»

Вывод: E2EE без защиты метаданных — это бронированная дверь с прозрачными стенами.


2. Модель угроз: что знает сервер

Прежде чем писать код, стоит определить модель угроз по методологии STRIDE. Adversary — кто-то с полным root-доступом к серверу: может читать БД, логи, память процесса и перехватывать сетевой трафик на уровне ОС.

Это не параноидальный сценарий. Это реальность для:

  • Администратора хостинга (Hetzner, VK Cloud, AWS — у них есть физический доступ к железу)

  • Государственных органов с ордером на выемку сервера

  • Атакующего, эксплуатирующего RCE-уязвимость в серверном коде

Вопрос: что он должен НЕ мочь узнать?


Цель: минимизировать знание сервера о паттернах коммуникации (traffic analysis resistance).

Задача разбивается на слои — от «скрыть превью в push» до «сервер вообще не знает, кто с кем общается». В итоге получилось 4 уровня защиты.


3. Архитектура: четыре уровня скрытности


Level 0 — Off        Обычный режим, вся метадата хранится нормально
Level 1 — Basic      Анонимизация push, отключение activity/typing/read/online
Level 2 — Enhanced   + редакция WS-логов, удаление User-Agent из сессий
Level 3 — Maximum    + анонимные пары (HMAC-SHA256), timing jitter, cover traffic

Каждый уровень включает все предыдущие. Вот как это выглядит в C++:


enum class StealthLevel {    Off         = 0,    Basic       = 1,    Enhanced    = 2,    Maximum     = 3
};
struct StealthSettings {
StealthLevel level = StealthLevel::Off;
bool hide_push_content       = false;  // Не отправлять preview в push
bool hide_push_sender        = false;  // Не отправлять имя отправителя
bool anonymize_activity      = false;  // Не обновлять last_activity
bool disable_typing_events   = false;  // Подавить "печатает..."
bool disable_read_receipts   = false;  // Подавить "прочитано"
bool disable_online_status   = false;  // Не транслировать онлайн/оффлайн
bool strip_user_agent        = false;  // Не сохранять User-Agent
bool anonymize_ws_logs       = false;  // Редактировать WS-логи
bool use_anonymous_pairs     = false;  // Хранить сообщения под хеш-парой
bool dummy_traffic           = false;  // Генерировать фиктивный трафик

};

Преобразование уровня в набор флагов — тривиальная функция:


StealthSettings MetadataGuard::levelToSettings(StealthLevel level) {    StealthSettings s;    s.level = level;
switch (level) {    case StealthLevel::Basic:        s.hide_push_content = s.hide_push_sender = true;        s.anonymize_activity = s.disable_typing_events = true;        s.disable_read_receipts = s.disable_online_status = true;        break;    case StealthLevel::Enhanced:        // ... всё от Basic +        s.strip_user_agent = s.anonymize_ws_logs = true;        break;    case StealthLevel::Maximum:        // ... всё от Enhanced +        s.use_anonymous_pairs = s.dummy_traffic = true;        break;
}
return s;

}

Почему не один bool stealth_enabled?

Потому что гранулярность критична. Защита метаданных — это всегда компромисс с UX. Пользователь на Level 1 жертвует удобством (нет typing, нет read receipts), но сохраняет push-уведомления. Level 3 жертвует всем (включая push-доставку через FCM), но получает максимальную устойчивость к traffic analysis.

Этот паттерн — graduated privacy — хорошо известен в академической литературе. Например, в работе Tyagi et al. Stadium: A Distributed Metadata-Private Messaging System (SOSP '17) проанализированы компромиссы между уровнем приватности метаданных и пропускной способностью системы.


4. HMAC-SHA256 Anonymous Pairs — ядро системы

Это самая интересная часть. На уровне Maximum вместо хранения (sender_id, receiver_id) в базе данных мы заменяем идентификаторы пользователей криптографическими хешами.

4.1. Проблема

В обычном мессенджере таблица сообщений выглядит так:


-- Обычный мессенджер
INSERT INTO messages (sender_id, receiver_id, encrypted_content, ...)
VALUES ('alice-uuid', 'bob-uuid', 'encrypted-blob', ...);

Даже если encrypted_content невозможно прочитать, поля sender_id и receiver_id — это прямой социальный граф в открытом виде. Атакующий с доступом к БД мгновенно восстановит, кто с кем общается.

4.2. Решение: HMAC-SHA256 с глобальной солью

Мы заменяем пару (alice, bob) на детерминированный, но необратимый хеш:


pair_hash  = HMAC-SHA256(global_salt, min(alice, bob) || "||" || max(alice, bob))
sender_tag = HMAC-SHA256(pair_hash, sender_id)[0:16]

Вот как это выглядит в C++:


std::string MetadataGuard::computePairHash(    const std::string& user_a, const std::string& user_b
) {    ensureSalt();
// Детерминированный порядок: всегда min||max
std::string ordered;
if (user_a < user_b) {    ordered = user_a + "||" + user_b;
} else {    ordered = user_b + "||" + user_a;
}
return hmacSha256(pair_hmac_salt_, ordered);

}
std::string MetadataGuard::computeSenderTag(
const std::string& sender_id, const std::string& pair_hash
) {
std::string full_tag = hmacSha256(pair_hash, sender_id);
// Первые 16 hex-символов (8 байт) — короткий тег
return full_tag.substr(0, 16);
}

Критические свойства:

Свойство

Гарантия

Детерминированность

pair_hash(Alice->Bob) == pair_hash(Bob->Alice) — сортировка ID перед хешированием

Необратимость

Без global_salt невозможно восстановить реальные ID

Уникальность тегов

sender_tag различает, кто из пары отправил сообщение, но не раскрывает кто

Collision resistance

HMAC-SHA256 даёт 256 бит — вероятность коллизии порядка 2^(-128)

4.3. Глобальная соль: линия жизни системы

global_salt — это 32-байтный случайный ключ, без которого вся схема разваливается. Если атакующий получит соль, он сможет перебрать все пары пользователей и раскрыть граф.


void MetadataGuard::ensureSalt() {    std::lock_guard lock(salt_mutex_);    if (!pair_hmac_salt_.empty()) return;
// 1. Попробовать загрузить из БД
if (db_ && initialized_) {    std::string stored = db_->getAdminSetting("metadata_guard_salt");    if (!stored.empty()) {        pair_hmac_salt_ = stored;        return;    }
}
// 2. Сгенерировать новую (CSPRNG)
unsigned char salt_bytes[32];
if (RAND_bytes(salt_bytes, 32) != 1) {    // Fallback: time-based seed (только если OpenSSL CSPRNG недоступен)    std::random_device rd;    std::mt19937_64 gen(rd());    for (int i = 0; i < 32; ++i)        salt_bytes[i] = static_cast<unsigned char>(gen() & 0xFF);
}
// 3. Сохранить в admin_settings
pair_hmac_salt_ = toHex(salt_bytes, 32);
db_->upsertAdminSetting("metadata_guard_salt", pair_hmac_salt_);

}

Критическое замечание: хранение соли в той же БД, что и хеши — осознанный компромисс. Если атакующий имеет доступ к anonymous_messages, он, вероятно, имеет доступ и к admin_settings. В production правильнее хранить соль в HSM (HashiCorp Vault, AWS KMS, YC KMS) — так, чтобы компрометация БД не давала возможности раскрутить хеши. Это known limitation текущей реализации (подробнее в разделе «Ограничения»).

4.4. Как выглядит БД с анонимными парами

Вместо обычной таблицы messages создаётся отдельная anonymous_messages:


CREATE TABLE anonymous_messages (    id          BIGSERIAL PRIMARY KEY,    pair_hash   VARCHAR(64) NOT NULL,       -- HMAC-SHA256 хеш пары    sender_tag  VARCHAR(16) NOT NULL,       -- Кто из пары отправил (8 байт hex)    encrypted_content TEXT NOT NULL,         -- E2EE зашифрованный контент    nonce       VARCHAR(32) NOT NULL,        -- Nonce для AES-256-GCM    ephemeral_key VARCHAR(64),              -- Эфемерный ключ (если нужен)    content_type VARCHAR(20) DEFAULT 'text', -- Тип контента    created_at  TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX idx_anon_msg_pair ON anonymous_messages(pair_hash, created_at DESC);

Запрос на вставку:


INSERT INTO anonymous_messages     (pair_hash, sender_tag, encrypted_content, nonce, ephemeral_key, content_type)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, created_at;

Что видит атакующий с root-доступом:


pair_hash:   "a7f3b2c1e4d6..."  -- Какая-то пара общается
sender_tag:  "3e8f1a2b"         -- Один из двоих что-то написал
content:     "U2FsdGVkX1..."    -- Зашифрованный блоб

Он не может определить: это Alice->Bob или Carol->Dave? Сколько вообще уникальных людей стоит за этими хешами? Без global_salt — невозможно.

4.5. Получение сообщений: серверная сторона

Получатель запрашивает сообщения, предоставляя свой токен и peer_id. Сервер вычисляет pair_hash заново (потому что знает оба ID в момент запроса) и фильтрует по нему:


std::string RequestHandler::handleStealthGetMessages(const std::string& body) {    // ... парсинг token, peer_id ...
auto& guard = MetadataGuard::getInstance();
std::string pair_hash = guard.computePairHash(user_id, peer_id);
std::string my_sender_tag = guard.computeSenderTag(user_id, pair_hash);
const char* params[2] = {pair_hash.c_str(), limit_s.c_str()};
std::string sql =    "SELECT id, sender_tag, encrypted_content, nonce, ... "    "FROM anonymous_messages "    "WHERE pair_hash = $1 "    "ORDER BY created_at DESC LIMIT $2::int";
// Для каждого сообщения: is_mine = (sender_tag == my_sender_tag)

}

Обратите внимание: сервер вычисляет pair_hash только на лету, в момент конкретного запроса. В базе хранятся только хеши — mapping user_id -> pair_hash нигде не персистируется.


5. Отравление собственных логов

На уровне Enhanced (Level 2+) WebSocket-сообщения в серверных логах редактируются перед записью:


std::string MetadataGuard::sanitizeLogMessage(    const std::string& raw_message, const StealthSettings& settings
) {    if (!settings.anonymize_ws_logs) {        return raw_message;  // Level 0-1: логируем как есть    }
try {    auto data = JsonParser::parse(raw_message);    std::string type = data.count("type") ? data["type"] : "unknown";    // Оставляем только тип и размер    std::ostringstream oss;    oss << "{\"type\":\"" << type         << "\",\"size\":" << raw_message.size()         << ",\"redacted\":true}";    return oss.str();
} catch (...) {    // Если парсинг упал — всё равно редактируем    return "{\"redacted\":true,\"size\":"            + std::to_string(raw_message.size()) + "}";
}

}

Было:


{"type":"message","content":"Привет!","sender_id":"abc-123","receiver_id":"def-456"}

Стало:


{"type":"message","size":82,"redacted":true}

Зачем тип? Для диагностики. Если сервер падает, нам нужно знать какой тип сообщения вызвал проблему (message, typing, ack и т.д.), но не содержание.


6. Push-уведомления как канал утечки

Э��о неочевидный, но критический вектор. Когда сервер отправляет push через Firebase Cloud Messaging (Google) или RuStore Push, payload проходит через серверы третьей стороны. Если push содержит sender_id и preview — Google/VK знают, кто кому пишет.

6.1. Санитизация payload (Level 1+)


std::map MetadataGuard::sanitizePushPayload(    const std::map& original_payload,    const StealthSettings& settings
) {    if (settings.level == StealthLevel::Off) return original_payload;
std::map<std::string, std::string> sanitized;
// Всегда оставляем type для маршрутизации на клиенте
sanitized["type"] = original_payload.at("type");
if (settings.hide_push_sender) {    sanitized["sender_id"] = "anonymous";  // Google видит "anonymous"    sanitized["chat_title"] = "";    sanitized["title"] = "";
}
if (settings.hide_push_content) {    sanitized["body"] = "";            // Preview стёрт    sanitized["message_type"] = "hidden";
}
return sanitized;

}

Поле

Обычный режим

hide_push_sender

hide_push_content

sender_id

"alice-uuid"

"anonymous"

title

"Алиса"

""

body

"Привет, как дела?"

""

6.2. Level 3: полный отказ от push через FCM/RuStore

На максимальном уровне push-уведомления полностью отключены:


// request_handler_stealth.cpp — handleStealthSendAnonymous()
// Push notifications intentionally omitted for anonymous messages.
// Sending through FCM/RuStore would leak receiver_id to Google/VK,
// defeating the purpose of metadata-hidden communication.

Получатель узнаёт о новых сообщениях через WebSocket (если подключён) или polling. Да, это жертва UX ради приватности. На Level 3 пользователь осознанно принимает это решение.


7. Timing Obfuscation: случайные задержки

Даже если атакующий не знает sender_id и receiver_id, он может коррелировать по времени: «пользователь A отправил что-то в 14:32:05.123, пользователь B получил что-то в 14:32:05.456 — вероятно, это одна пара».

На уровне Maximum мы добавляем случайную задержку 0–2000 мс:


int MetadataGuard::getTimingJitter(const std::string& user_id) {    if (getStealthLevel(user_id) < StealthLevel::Maximum) {        return 0;    }
// Thread-local MT19937 с аппаратным seed
static thread_local std::mt19937 rng(std::random_device{}());
std::uniform_int_distribution<int> dist(0, 2000);
return dist(rng);

}

Перед доставкой сообщения сервер ждёт getTimingJitter() миллисекунд. Пара (14:32:05.123 отправил) -> (14:32:06.847 получил) уже не поддаётся тривиальной корреляции.

Почему 0–2000 мс?

  • Меньше — недостаточно шума для маскировки

  • Больше — заметная задержка в чате, ухудшение UX

  • Равномерное распределение — каждая задержка равновероятна

Дополнительно на Level 3 включается Cover Traffic (Dummy Traffic) — генерация фиктивных пакетов, чтобы наблюдатель не мог отличить реальное сообщение от шума по объёму трафика.


8. Мердж настроек: дипломатия приватности

Вот элегантная проблема: Alice на Level 2, Bob на Level 0. Какой уровень защиты применять к их диалогу?

Ответ: максимальный из двух. Каждый флаг мержится через OR:


StealthSettings MetadataGuard::getConversationStealthSettings(    const std::string& user_a, const std::string& user_b
) {    auto sa = getStealthSettings(user_a);    auto sb = getStealthSettings(user_b);
StealthSettings merged;
merged.level = (sa.level > sb.level) ? sa.level : sb.level;
merged.hide_push_content     = sa.hide_push_content     || sb.hide_push_content;
merged.hide_push_sender      = sa.hide_push_sender      || sb.hide_push_sender;
merged.anonymize_activity    = sa.anonymize_activity    || sb.anonymize_activity;
merged.disable_typing_events = sa.disable_typing_events || sb.disable_typing_events;
merged.disable_read_receipts = sa.disable_read_receipts || sb.disable_read_receipts;
merged.disable_online_status = sa.disable_online_status || sb.disable_online_status;
// ... и так для всех флагов
return merged;

}

Философия: если хотя бы один участник хочет приватности — приватность применяется ко всему диалогу. Bob не может «заставить» Alice раскрыть метаданные.

Это аналогично Strict-Transport-Security в HTTPS — если сервер запросил HSTS, клиент не может откатиться на HTTP.


9. Хранение и production-реалии

9.1. Кеширование настроек

Запрос к PostgreSQL на каждое сообщение — накладно. Поэтому MetadataGuard кеширует настройки в unordered_map с мьютексом:


class MetadataGuard {    std::unordered_map settings_cache_;    mutable std::mutex cache_mutex_;
StealthSettings getStealthSettings(const std::string& user_id) {    {        std::lock_guard<std::mutex> lock(cache_mutex_);        auto it = settings_cache_.find(user_id);        if (it != settings_cache_.end()) return it->second;    }        StealthSettings settings = loadFromDb(user_id);        {        std::lock_guard<std::mutex> lock(cache_mutex_);        settings_cache_[user_id] = settings;    }    return settings;
}

};

9.2. Singleton и потокобезопасность

MetadataGuard — singleton (Meyer's singleton через static local). Все мутабельные поля защищены мьютексами. getTimingJitter использует thread_local RNG — без блокировок на горячем пути.

9.3. Подавление побочных эффектов

Каждый модуль сервера перед выполнением действия проверяет stealth-настройки:


// Перед обновлением активности:
if (!MetadataGuard::getInstance().shouldSkipActivityUpdate(user_id)) {    db_manager_.updateLastActivity(user_id);
}
// Перед отправкой typing indicator:
if (!MetadataGuard::getInstance().shouldSuppressTyping(user_id)) {
ws_sender_(peer_id, typing_payload);
}
// Перед записью User-Agent в сессию:
if (!MetadataGuard::getInstance().shouldStripUserAgent(user_id)) {
session.user_agent = request.headers["User-Agent"];
}

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


10. Полная картина: что видит атакующий с root-доступом

Уровень

Что знает атакующий

Что НЕ знает

Level 0

Всё: кто, с кем, когда, метаданные

Содержание (E2EE)

Level 1

Кто с кем (из БД), когда (из БД)

Preview в push, typing, read receipts, online

Level 2

Кто с кем (из БД), когда (из БД)

+ содержание WS-логов, User-Agent

Level 3

Хеши пар (без salt — нераскрываемые), приблизительное время

Кто с кем, точное время, все индикаторы, push-получатель

На Level 3 атакующий видит:


anonymous_messages:  pair_hash = "9a2f...c4d1"    -- Какая-то пара (которая из 10000?)  sender_tag = "e7b3a1f2"      -- Один из двоих  encrypted_content = "..."    -- Нечитаемо (E2EE)  created_at = 14:32:06.847    -- Время +/- jitter
stealth_settings:
user_id =              -- Этот user на Level 3
Нет в anonymous_messages: sender_id, receiver_id, username
Нет в WS-логах: содержание, идентификаторы
Нет в push: ничего (push отключён на L3)

11. Сравнение с аналогами: Signal, Tor, Matrix

Было бы нечестно рассказывать про свой подход, не сравнив его с тем, что делают другие. Вот объективное сравнение:

Signal — Sealed Sender

Signal решает проблему защиты отправителя с помощью Sealed Sender: сообщение помещается во «внешний конверт», зашифрованный ключом сервера, но без поля sender_id. Сервер знает только получателя, не отправителя.

Преимущества Signal по сравнению с Metadata Guard:

  • Sealed Sender прошёл формальный аудит безопасности

  • Защита отправителя работает без потери UX (push-уведомления сохраняются)

  • Реализация значительно проще и элегантнее

Преимущества Metadata Guard:

  • На Level 3 скрыт не только отправитель, но и получатель (pair_hash скрывает обоих)

  • Graduated privacy: пользователь выбирает точку компромисса, а не бинарный вкл/выкл

  • Защита не только «кто отправил», но и timing, activity, typing, push payload

Tor / Mixnet-подход

Tor и mix-сети (например, Nym) решают задачу на сетевом уровне: сообщения проходят через цепочку прокси-серверов, каждый из которых снимает один слой шифрования. Наблюдатель не может связать входящий и исходящий трафик.

Преимущества Tor/Mixnet:

  • Математически доказуемая анонимность (при достаточном числе узлов)

  • Защита от глобального наблюдателя (network-level adversary)

  • Не требует доверия к серверу приложения

Почему я не использовал этот подход:

  • Высокая задержка (Tor: 1-5 сек, mixnet: до 30 сек) — неприемлемо для мессенджера

  • Сложность реализации для мобильных клиентов

  • Зависимость от инфраструктуры третьей стороны

Matrix / Element

Matrix Protocol использует E2EE (Megolm), но метаданные (sender, receiver, room_id, timestamp) хранятся в открытом виде на homeserver. Проект Matrix Sliding Sync улучшает UX, но не метаданные.

Итоговая таблица

Критерий

Signal (Sealed Sender)

Tor/Mixnet

Metadata Guard

Скрыт отправитель

Да

Да

Да (Level 3)

Скрыт получатель

Нет

Да

Да (Level 3, pair_hash)

Защита тайминга

Нет

Да (onion routing)

Частичная (jitter 0-2с)

Push-уведомления

Сохранены

N/A

Отключены на L3

Задержка

~0 мс

1000-5000 мс

0-2000 мс

Формальный аудит

Да

Да

Нет

Гранулярность

Бинарная

Бинарная

4 уровня

Честный вывод: Signal Sealed Sender — более зрелое решение с формальным аудитом. Tor-подход — теоретически сильнее. Metadata Guard пытается занять нишу «сильнее Signal по метаданным, практичнее Tor по задержке», но пока не прошла независимый аудит.


12. Честные ограничения и что не работает

Ни одна система безопасности не идеальна. Вот чего Metadata Guard не решает и где есть известные слабости:

12.1. Соль в одной БД с хешами

Как упоминалось выше, global_salt хранится в admin_settings — в той же PostgreSQL, что и anonymous_messages. Атакующий с полным дампом БД получит и соль, и хеши, и сможет перебрать все пары пользователей (если количество пользователей невелико).

Mitigation: перенос соли в HSM/Vault. В идеале — использовать разные мастер-ключи для разных компонентов с ротацией.

12.2. Размер множества анонимности

При 100 пользователях и Level 3 атакующий знает, что pair_hash — это одна из C(100, 2) = 4950 возможных пар. При 1000 пользователях — одна из примерно 500 000. Для по-настоящему сильной анонимности нужна большая пользовательская база.

Это фундаментальное ограничение любой системы, основанной на k-anonymity: чем меньше пользователей, тем слабее анонимность.

12.3. Сервер знает оба ID в момент API-запроса

Когда Alice вызывает GET /api/stealth/get-messages?peer_id=bob, сервер в runtime знает оба ID (из токена и из запроса). pair_hash защищает данные at rest (в БД, бэкапах, логах), но не in transit (в памяти процесса).

Для защиты in-transit нужен подход Signal (Sealed Sender) или Tor. Это принципиальное архитектурное ограничение клиент-серверной модели.

12.4. Timing jitter — не панацея

Равномерный jitter 0-2000 мс ломает тривиальную корреляцию, но не защищает от статистического анализа на больших объёмах. Если атакующий наблюдает 10 000 сообщений между двумя парами, он может восстановить паттерн даже с jitter.

Для серьёзной timing protection нужен constant-rate padding (как в Vuvuzela) — но это кратно увеличивает нагрузку на сервер.

12.5. Cover Traffic масштабируется плохо

Dummy traffic на Level 3 увеличивает нагрузку на WebSocket-соединения и сетевой трафик пользователя. На мобильных устройствах с ограниченным трафиком это может быть проблемой.

12.6. Нет независимого аудита

Код открыт, но формального аудита от независимой security-компании (Cure53, Trail of Bits, NCC Group) пока не проводилось. Для любого security-sensitive проекта это критический пробел.


Как это вписывается в общую архитектуру шифрования

Для контекста: Metadata Guard — один из трёх криптографических слоёв мессенджера:


+----------------------------------------------------------+
|  Слой 1: E2EE (содержание)                                |
|  X25519 key exchange -> HKDF-SHA256 -> AES-256-GCM        |
|  + Message Ratchet (forward secrecy)                      |
+----------------------------------------------------------+
|  Слой 2: Metadata Guard (метаданные)        <-- эта статья|
|  HMAC-SHA256 anonymous pairs + push sanitization          |
|  + timing obfuscation + WS log redaction                  |
+----------------------------------------------------------+
|  Слой 3: Provable Chat (доказуемость)                     |
|  Ed25519 подписи + SHA-256 hash chain                     |
|  Неотрекаемость: блокчейн для переписки                   |
+----------------------------------------------------------+

E2EE и Metadata Guard ортогональны: E2EE скрывает что сказано, Metadata Guard скрывает кто, когда, с кем и сколько. Provable Chat, наоборот, делает переписку доказуемой — каждое сообщение криптографически подписано и связано в цепочку. Если интересно, могу написать отдельный разбор каждого слоя — дайте знать в комментариях.


13. Заключение

Защита метаданных — это не бинарный переключатель, а спектр компромиссов между удобством и приватностью. Подход с 4 уровнями позволяет пользователю самому выбрать точку на этом спектре:

  • Level 0: «Мне нечего скрывать» — полный функционал

  • Level 1: «Скройте preview в push» — минимальная жертва UX

  • Level 2: «Почистите логи» — серьёзнее относящиеся к приватности

  • Level 3: «Сервер не должен знать, с кем я говорю» — максимальная защита, осознанная жертва UX

Что можно забрать в свой проект, даже если вы не пишете мессенджер:

  1. HMAC-SHA256 для деперсонализации связей — подходит для любой системы, где нужно хранить связи между сущностями, не раскрывая их (медицинские данные, HR-системы, аналитика)

  2. Мердж настроек по OR — паттерн «приватность одного защищает обоих» применим к любым двусторонним взаимодействиям

  3. Послойная санитизация — архитектурный принцип: каждая точка выхода данных (push, лог, API-ответ) гейтится через единый policy-объект

  4. Graduated privacy — вместо «вкл/выкл» дайте пользователю шкалу


Источники и дальнейшее чтение

Академические работы:

Индустрия:

Стандарты:

Исходный код проекта: github.com/w78flezeex/Xipher.pro (copyleft-лицензия)


Буду рад конструктивной критике в комментариях — особенно от тех, кто работал с traffic analysis или проводил аудиты мессенджеров. Если есть вопросы по конкретным решениям или хотите увидеть разбор E2EE-слоя — пишите, сделаю отдельную статью.