Большинство таск-трекеров устроены одинаково: вы пишете задачу, она уходит на сервер, сервер её хранит и показывает коллегам. Сервер видит всё — текст, сроки, кто кому что поручил, файлы, переписку. Обычно это никого не волнует, пока в трекере лежит «поправить отступы на главной».
Но люди хранят в трекерах всё подряд. Юрист — фамилии доверителей и сроки по делам. Врач — заметки о пациентах. Семья — пароль от домофона и номер карты для оплаты кружка. Предприниматель — переписку с клиентами и суммы.
Мы делаем TaskBoss — приложение для задач, семьи, команды и записи клиентов, — и с самого начала решили, что сервер не должен уметь читать содержимое. Ни у нас, ни у того, кто когда-нибудь украдёт базу. Ниже — как это устроено, какие решения мы приняли и что пришлось отдать взамен. Без маркетинга: с компромиссами и местами, где мы ошибались.
Модель угроз
Сначала договоримся, от чего защищаемся.
Защищаемся от: - утечки базы данных (украли дамп, получили доступ к бэкапам); - любопытного администратора хостинга; - нас самих — разработчиков и владельцев сервера; - запроса «покажите содержимое задач пользователя X»: показать мы можем только шифр.
Не защищаемся (честно): - от вредоносного кода на телефоне пользователя: если телефон заражён, он видит то же, что и пользователь; - от участника пространства, который сам скопировал данные: у него есть ключ, и это нормально; - от анализа метаданных в полном объёме: сервер знает, кто в каком пространстве, когда создана запись и кому она назначена. Об этом — отдельный раздел.
Эта граница важна. «Абсолютная защита» — фраза из рекламы. Мы описываем, что именно защищено и чем за это платим.
Архитектура в двух абзацах
Клиент — Flutter (Android и веб). Сервер — Python, FastAPI, SQLite: отдельная база на каждое пространство и общая база аккаунтов. Сервер — в России, домен .ru, ничего не проксируется через зарубежные сервисы. Push-уведомления сделаны опросом, без Firebase: мы не хотели, чтобы даже тексты уведомлений проходили через Google.
Пользователь создаёт пространства: Личное, Общество (семья, друзья), Организацию (отделы, роли), Сервис (запись клиентов). Каждое пространство — это отдельный ключ шифрования. Участник пространства — тот, у кого есть его ключ.
Слой 1. Ключ пространства и AES-256-GCM
Всё содержимое пространства — задачи, проекты, заметки, профили участников, настройки — шифруется на устройстве симметричным ключом пространства. Алгоритм — AES-256-GCM: аутентифицированное шифрование, которое не только прячет текст, но и ловит подмену (если кто-то изменит хоть один байт шифра, расшифровка упадёт, а не вернёт мусор).
Ключ — 32 случайных байта, создаётся на телефоне создателя пространства. На сервер в открытом виде он не уходит никогда.
Запись на сервере выглядит так:
id | 7f3c…
created_at | 2026-10-12T09:14:03
created_by | u_81a…
assignee_ids | ["u_81a…", "u_c02…"]
encrypted | mK3x9Qp+vL2…7fA== ← nonce + шифр + тег
Всё, что пользователь вводит руками, — внутри encrypted. Название задачи, описание, срок, статус, подзадачи,
комментарии, теги — шифр.
Обратите внимание на assignee_ids. Это сознательная уступка, о ней ниже.
Расшифровка пачкой
Первая реализация расшифровывала задачи по одной в главном потоке. На списке в 500 задач интерфейс подвисал на секунду.
Сейчас расшифровка идёт пачкой в отдельном изоляте (Flutter compute), а интерфейс показывает «скелет» списка. Мелочь,
но без неё шифрование ощущалось как тормоза.
Что делать с записями, которые не расшифровались
Раньше клиент молча выбрасывал записи, которые не удалось расшифровать (чужой ключ, повреждённый блок). Пользователь видел «пропала задача» и писал в поддержку. Сейчас приложение считает такие записи и показывает предупреждение: «часть записей открыть не удалось — возможно, ключ устарел». Молчаливая потеря данных хуже честной ошибки.
Слой 2. Как ключ попадает к другим людям: X25519 и обёртки
Общий ключ пространства надо как-то передать участникам, не показав серверу.
У каждого аккаунта есть пара ключей X25519. Открытая часть лежит на сервере, закрытая — только на устройствах владельца аккаунта. Когда руководитель добавляет человека в организацию, его телефон «запечатывает» ключ пространства открытым ключом нового участника. На сервер уходит конверт, который может открыть только получатель.
В прежних версиях этот же механизм использовался для чатов: у каждого был свой ключ, файлы и голосовые шифровались до загрузки. В TaskBoss 10.0 чатов и личной переписки больше нет. Их история доступна только для чтения. Рабочие вопросы теперь обсуждаются в комментариях к задаче или записи.
Ошибка, которая стоила нам недели: «одна пара на устройство»
Первая версия создавала пару X25519 на каждом устройстве. Пользователь вошёл с нового телефона — телефон создал свою пару и опубликовал открытую часть поверх старой. Результат: все конверты, которые другие участники потом запечатывали «на этого человека», открывались только на новом телефоне. Тогда в приложении ещё были чаты: старый телефон и веб-версия переставали получать их новые ключи. Выглядело как «сообщения пропадают на одном из устройств».
Исправление: одна пара на аккаунт. Устройство публикует открытый ключ, только если на сервере ключа ещё нет или он совпадает. Если не совпадает — устройство «не связано» и должно получить пару от уже связанного устройства (об этом следующий слой). Мораль простая: в E2E-системе любое «ну пусть каждый создаст себе» нужно проверять на сценарии с несколькими устройствами.
Слой 3. Свои устройства: «ящик ключей»
У человека телефон, веб-версия, иногда второй телефон. Ключи пространств должны быть на всех, а сервер их видеть не должен.
Решение — «ящик ключей»: зашифрованное хранилище на сервере, куда каждое устройство кладёт ключи пространств, запечатанные на ключ самого аккаунта. Любое связанное устройство раз в несколько минут заглядывает в ящик и забирает новое. Создали организацию на телефоне — через пару минут она открывается в браузере. Сервер видит только, что в ящике лежат какие-то конверты.
Новый телефон
Новое устройство связывается со старым кодом: старый телефон показывает QR, новый сканирует. Код содержит одноразовый ключ, которым запечатан пакет со всеми доступами. Код работает один раз, ограничен по времени и бесполезен без входа в тот же аккаунт.
Веб входит похожим образом — «Войти по QR с телефона»: браузер показывает код, телефон его сканирует и отправляет в браузер запечатанный пакет. Пароль в браузере вводить не нужно вообще.
Ключи в браузере
Браузер — самое слабое место: localStorage читается любым скриптом страницы. Поэтому ключи в браузере хранятся зашифрованными ПИН-кодом: PBKDF2-SHA256 (600 000 итераций) превращает ПИН в ключ, а им AES-256-GCM шифрует сейф. Пока вкладка закрыта, в хранилище лежит только шифр. Отказаться от ПИН-кода можно — тогда ключи живут только в памяти вкладки и пропадают при закрытии.
Вторая часть защиты браузера — строгий CSP: страница не загружает ни одного скрипта не с нашего домена, встроенных
скриптов нет, eval запрещён (разрешена только компиляция WebAssembly — без неё не запустится графический движок).
Движок Flutter (CanvasKit) и шрифты мы отдаём со своего сервера, а не с CDN Google: иначе каждый запуск веб-версии
сообщал бы Google, что вы открыли TaskBoss.
Слой 4. Потерял телефон: хранители и схема Шамира
Самый трудный вопрос любой E2E-системы: что делать, когда пользователь потерял единственное устройство с ключами?
Сервер помочь не может — у него нет ключей. Пароль помочь не может — он не участвует в шифровании (и это правильно: пароли слабые, и сервер, знающий пароль, мог бы перебирать). Сначала мы сделали фразу из 12 слов, как у криптокошельков. Через месяц убрали: обычные люди теряют бумажку быстрее телефона.
Сейчас работает восстановление через людей:
- Пользователь заранее выбирает 2–3 «хранителей» — людей, которым доверяет.
- Телефон создаёт случайный ключ восстановления K и шифрует им набор всех ключей пользователя. Этот пакет лежит на сервере — бесполезный без K.
- K делится по схеме Шамира: на столько частей, сколько хранителей; собрать K можно из большинства частей (2 из 3, 3 из 5). Каждая часть запечатывается открытым ключом своего хранителя.
- Потерял телефон: новый телефон входит по паролю и просит хранителей подтвердить. Каждый хранитель на своём телефоне подтверждает ПИН-кодом или отпечатком, расшифровывает свою часть и перезапечатывает её на ключ нового телефона.
- Когда подтвердило большинство — сервер сразу выдаёт части новому телефону. Новый телефон собирает K и открывает пакет; старые устройства получают уведомление и выходят из аккаунта.
Про задержку мы спорили дольше всего. Сценарий атаки: злоумышленник украл телефон и узнал пароль, потом убедил двух хранителей «это я, новый номер». Окно перед выдачей частей даёт настоящему владельцу шанс увидеть запрос и отменить его. Сначала окно было 5 минут, после аудита — 24 часа. На практике сутки ожидания били по тому, ради чего восстановление и нужно: человек потерял телефон и не может работать. Сейчас окна нет — части выдаются сразу после кворума. Цена этого решения честная: защита держится на хранителях. Поэтому приложение прямо говорит хранителю «отмены нет, подтверждайте только после разговора голосом», а один хранитель по-прежнему ничего не решает.
Что важно: один хранитель ничего не может. Хранители не видят данных пользователя — у них только часть ключа, которая сама по себе ничего не открывает. Сервер не видит K ни в один момент.
Цена решения — честно говоря, в UX. Пользователь должен заранее назначить хранителей, а хранители должны иметь аккаунт и не потерять свои телефоны одновременно с ним. Мы напоминаем об этом настойчиво: «нет хранителей — потерянный телефон означает потерянные данные». Если все хранители недоступны и старого телефона нет — данные не вернуть. Нам тоже.
Слой 5. Сервисы: ключ на каждую запись
Отдельная история — пространство «Сервис»: салон, мастерская, кондитерская принимают клиентов. Здесь общий ключ пространства не подходит: клиент не должен получать ключ ко всем записям салона.
Поэтому у каждой записи и заказа — свой ключ. Он раздаётся клиенту и назначенному персоналу (мастер, операторы). Пожелания клиента («аллергия на…»), состав заказа, комментарии к записи — под этим ключом. Новый мастер, принятый в салон, не получит доступ к прошлым записям коллег. Уволенный мастер не прочитает новые.
Что сервер всё-таки видит (метаданные)
Здесь мы стараемся быть честнее, чем принято в рекламе мессенджеров.
Сервер знает:
- кто зарегистрирован и в каких пространствах состоит;
- название и цвет пространства (нужно, чтобы приглашение показало «вас зовут в „Студию дизайна“»);
- когда создана и изменена запись;
- кому назначена задача (assignee_ids).
Последнее — компромисс. Мы хотели, чтобы сервер проверял права: «закрыть задачу может автор, руководитель или исполнитель». Если исполнитель внутри шифра, сервер этого не знает, и клиент может отправить что угодно. Мы выбрали серверную проверку прав ценой одного поля метаданных. Альтернатива — полностью клиентская проверка прав — означала бы, что модифицированный клиент может закрыть чужую задачу, и сервер это примет.
Второй компромисс — уведомления. Сервер должен знать, кому отправить «вам назначена задача». Текста задачи в уведомлении нет: телефон получает «событие», сам расшифровывает задачу и сам формирует текст уведомления.
Поиск, когда сервер слеп
Если сервер не видит текста, он не может по нему искать. Поиск по задачам, подзадачам, комментариям и тегам идёт на устройстве: приложение расшифровывает и ищет у себя. Это медленнее серверного поиска, но на объёмах малой команды — тысячи задач — терпимо.
Сервер ищет только то, что открыто: людей по ФИО (если человек разрешил), организации и сервисы по названию.
Уход участника и смена ключа
Если сотрудник уволился, у него остаётся ключ пространства на телефоне — всё, что он видел, он мог сохранить. С этим ничего не сделать ни в одной системе. Но можно не дать ему читать новое. Руководитель меняет ключ пространства: приложение создаёт новый ключ, перешифровывает записи и раздаёт новый ключ всем, кроме ушедшего.
Перешифровка — одна операция на телефоне руководителя: старые конверты на сервере после неё удаляются, а новые получают только оставшиеся участники. Подробности и подводные камни — ниже, в разделе «Смена ключа: подробнее».
Защита канала
Шифрование содержимого не отменяет обычной защиты канала: - HTTPS с HSTS; - проверка сертификата сервера в приложении (pinning): если в сети подменили сертификат, запрос не уходит, а пользователь видит «возможно, вы в чужой сети»; - сервер слушает только локальный порт, наружу — nginx; - строгие заголовки безопасности на всех страницах.
Pinning — палка о двух концах: при смене сертификата старые версии приложения перестанут подключаться. Мы держим в приложении запасной пин и обновляем список заранее.
Подписи: когда нужно доказать, а не спрятать
Шифрование отвечает на вопрос «кто может прочитать». Но в TaskBoss есть данные, которые должны быть публичными и проверяемыми: награды сотрудникам, рекомендации коллег, подтверждённая организацией история работы в резюме. Здесь задача обратная — не спрятать, а доказать, что запись не подделана.
Для этого у организации есть ключ подписи Ed25519. Он создаётся на телефоне руководителя, который первым вручает награду, и на сервер не уходит. Награда — это небольшая структура (кому, за что, когда, какая организация), подписанная этим ключом. Публичная страница резюме проверяет подпись прямо в браузере: если кто-то изменит запись в базе, подпись не сойдётся, и страница покажет «подпись не подтверждена».
Рекомендации коллег подписываются личным ключом автора. Так получатель не может сам себе «написать» рекомендацию от имени другого человека, а сервер не может её подделать.
Сложность — перенос ключа подписи. Если руководитель сменил телефон, ключ должен переехать вместе с ним, иначе новые награды будут подписаны новым ключом и старые проверки станут путаными. Ключ подписи переезжает тем же «кодом со старого телефона», а в веб — по отдельному QR. В браузер он попадает только зашифрованным и снимок экрана с этим кодом запрещён системно.
Смена ключа: подробнее
Звучит просто — «создай новый ключ и перешифруй». На практике это самая хрупкая операция в системе.
Что происходит, когда руководитель нажимает «Сменить ключ»:
- Телефон руководителя создаёт новый ключ K₂.
- Скачивает все записи пространства и расшифровывает их старым ключом K₁.
- Шифрует их ключом K₂.
- Для каждого оставшегося участника запечатывает K₂ его открытым ключом.
- Отправляет всё одной пачкой. Сервер подменяет записи и старые конверты атомарно.
Подводные камни, на которые мы наступили:
- Участник, который ни разу не открывал новую версию приложения. У него нет открытого ключа X25519 на сервере, и запечатать K₂ для него нельзя. Если продолжить, он потеряет доступ. Поэтому приложение сначала проверяет всех участников и предупреждает: «эти люди не смогут прочитать данные после смены ключа — всё равно сменить?».
- Новые и удалённые записи во время смены. Пока руководитель перешифровывает, кто-то из команды создаёт задачу. Сервер сравнивает набор записей с тем, что было выгружено: если появилась или исчезла хоть одна — отвечает 409, и операция повторяется. Версия ключа тоже сверяется: две смены одновременно не пройдут.
- Изменение существующей записи в те же секунды. Это честное ограничение: если коллега отредактировал задачу, пока шла перешифровка, его правка может быть перезаписана перешифрованной старой версией. Перешифровка занимает секунды, и мы советуем менять ключ, когда команда не работает. Сравнение версий отдельных записей — в планах.
- Прерванная операция. Если связь пропала посередине, пространство не должно остаться наполовину в старом ключе, наполовину в новом. Поэтому замена записей, обёрток и версии ключа — одна транзакция, а до неё на сервере ничего не меняется.
Как мы тестируем криптографию
Криптографический код нельзя проверить «глазами». Что мы делаем:
- Только стандартные примитивы из проверенной библиотеки. Никаких собственных шифров. AES-GCM, X25519, Ed25519,
PBKDF2 — из пакета
cryptographyдля Dart. - Тесты на круговой путь: зашифровали — расшифровали — сравнили, в том числе на кириллице, эмодзи и пустых строках.
- Тесты на подмену: изменили один байт шифра — расшифровка обязана упасть, а не вернуть мусор.
- Тесты на чужой ключ: запись, зашифрованная ключом пространства A, не открывается ключом B, а интерфейс показывает предупреждение, а не пустоту.
- Тесты схемы восстановления: части ключа от двух из трёх хранителей собирают ключ, от одного — нет; сервер не выдаёт части до кворума и до конца суточного окна; посторонний не может подтвердить чужой запрос.
- Серверные тесты на права: ни один конечный пункт не отдаёт чужие конверты, даже если угадать идентификатор.
Всего у клиента сейчас около 590 автоматических тестов, у сервера — около 320. Это не гарантия отсутствия ошибок, но это гарантия, что известные ошибки не вернутся.
Выгрузка: ваши данные — ваши
Если сервер не может прочитать данные, логичный вопрос: а как я их заберу, если захочу уйти?
В приложении есть «Экспорт всех данных»: телефон расшифровывает всё, к чему у вас есть доступ, и собирает ZIP — задачи, проекты и направления в JSON и CSV, вложения, записи резюме с подписями. Мы честно предупреждаем, что внутри архива данные уже расшифрованы и дальше их защищаете вы.
Для отдельных списков — PDF, таблица и текст: задачи, заметки, отчёты. Шрифты для PDF встроены в приложение, а не скачиваются у сторонних сервисов: иначе выгрузка зависела бы от доступности чужого сервера.
Чего стоило всё это
Честный список того, что мы отдали за то, что сервер ничего не читает:
- Нет серверного поиска по содержимому. Только на устройстве.
- Нет «восстановления по почте». Только хранители или старый телефон. Забытый пароль восстанавливается только на телефоне, где вы уже вошли.
- Нет «умных функций» на сервере: подсказок, автокатегорий, аналитики по тексту. Мы считаем это скорее плюсом.
- Сложнее отладка. Пользователь пишет «у меня пропала задача», а мы видим только шифр. Пришлось сделать отчёты о сбоях, в которые уходят только тип ошибки и строки кода — без текста ошибки, потому что в нём мог оказаться расшифрованный фрагмент.
- Медленнее первый запуск на слабых телефонах: расшифровка сотен записей — это работа процессора.
Что дальше
- Вынести оставшиеся встроенные скрипты из публичных страниц сервера, чтобы CSP стал строгим везде.
- Сделать независимую проверку схемы восстановления — это место, где ошибка стоит дороже всего.
- Опубликовать формат хранения, чтобы пользователь мог расшифровать свой экспорт без нашего приложения.
Если вы делаете что-то похожее — пишите в комментариях, особенно если видите дыру. Нам это важнее, чем лайки.