E2EE-шифрование: компромиссы о которых никто не говорит
"End-to-end encrypted" — модный лейбл в маркетинговых материалах. Но реализовать его без потери удобства — задача из адских, рассказываем что у нас работает, а что — нет.
Что мы изначально хотели
Чтобы данные пользователя были криптографически недоступны для нас (сервера). Чтобы при компроментации DB или сервера задачи и заметки оставались зашифрованным мусором.
Звучит просто. На практике — куча подводных камней:
Камень 1: Поиск
Если данные зашифрованы — как сервер ищет по ним? Классический ответ: никак, ищем на клиенте после расшифровки. Проблема — для команды из 30 человек с 10K задач это значит синкать ВСЁ на каждое устройство.
Что сделали:
- Encrypted search index на клиенте — обновляется при каждой mutation, хранится локально
- Searchable encryption для server-side queries — Blind Index по заранее заданным полям (title, tags)
- Trade-off: server видит "сколько раз встречается hash(word)", но не само word'о
Это компромисс. Чистый E2EE-пуристы скажут что Blind Index — это утечка metadata. Технически правы. Но без неё фича "найти задачу по слову на сервере" невозможна.
Камень 2: Совместный доступ
В команде из 10 человек все должны иметь доступ к общим задачам. Каждая задача шифруется ключом, ключ зашифрован публичными ключами участников. При добавлении нового члена — нужно пере-зашифровать ключи всех задач его публичным ключом.
На 10K задач это 10K крипто-операций. Делать на клиенте при каждом добавлении — нон-старт. Решение:
// Group-key model
const groupKey = generateAESKey(); // shared symmetric key
const wrappedForUser = [];
for (const member of team) {
wrappedForUser.push({
userId: member.id,
wrappedKey: encrypt(member.publicKey, groupKey)
});
}
// Server stores wrappedForUser. Знает только публичные ключи.
Когда новый юзер добавлен — старая group key остаётся (он не получит доступ к старым задачам), генерируется новая для будущих. Юзер видит "вы добавлены — но только будущие задачи".
Если нужен доступ к старым — админ выгружает старые задачи, расшифровывает, перешифровывает с новой group key. Лагает. Юзер ждёт. Менее идеально.
Камень 3: Восстановление пароля
Что если пользователь забыл пароль? У нас нет мастер-ключа на сервере. Просто "забыл пароль" = потерял доступ ко всему навсегда.
Юзеры это не понимают. В Slack ты восстанавливаешь пароль и логинишься. В Signal — теряешь чаты. FocusWork по дефолту был как Signal. Юзеры возмущались.
Решение: recovery key. При активации E2EE юзер скачивает 24-словную recovery-фразу (типа crypto-wallet seed). Если потерял пароль — вводит фразу, расшифровываются данные, ставится новый пароль.
"Recovery key звучал страшно, пока мы не поняли что это buy-in для юзера. Если он скачал и сохранил seed — он понимает risks и серьёзно относится к шифрованию. Если не скачал — fallback на non-E2EE workspace, никаких сюрпризов."
Камень 4: Push-уведомления
Apple/Google push содержат body (текст). У нас body — шифрованный мусор. Юзер видит "У вас новая задача" вместо названия.
Решение: push содержит только task_id и notification_type. Клиент при получении push'а сам подтягивает шифрованную задачу и расшифровывает локально. Дополнительный round-trip — но превью полноценное.
Что мы НЕ делаем (и почему)
HSM / Hardware Security Module
Не используем. Для нашего масштаба overkill. Ключи в Keychain (iOS/Mac) / Keystore (Android) / IndexedDB-encrypted (web). Достаточно для нашей threat model.
Full forward secrecy
Не реализовали. Forward secrecy требует частой ротации session keys и пере-шифрования. Performance hit + complexity для use case "task manager" не оправданы.
Анонимные аккаунты
Аккаунт по email-у. Без верификации не реальной идентичности, но и не Tor-уровня анонимности. Это compromise для UX.
Уроки
- "E2EE" — не бинарный yes/no. Чёткие границы threat model важнее marketing-лейбла.
- Дать юзеру выбор уровня шифрования (E2EE workspace vs обычный) — повышает adoption обоих.
- UX компромиссы неизбежны. Признавать честно, документировать.
- Готовый recovery flow — most underrated часть шифрования.
— Команда безопасности FocusWork