«Мы знаем, что вы вчера в 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-слоя — пишите, сделаю отдельную статью.