Блог / Технологии
Технологии

Как мы сделали сквозное шифрование в планировщике задач

15 октября 2026 · 15 мин чтения

Большинство таск-трекеров устроены одинаково: вы пишете задачу, она уходит на сервер, сервер её хранит и показывает коллегам. Сервер видит всё — текст, сроки, кто кому что поручил, файлы, переписку. Обычно это никого не волнует, пока в трекере лежит «поправить отступы на главной».

Но люди хранят в трекерах всё подряд. Юрист — фамилии доверителей и сроки по делам. Врач — заметки о пациентах. Семья — пароль от домофона и номер карты для оплаты кружка. Предприниматель — переписку с клиентами и суммы.

Мы делаем 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 слов, как у криптокошельков. Через месяц убрали: обычные люди теряют бумажку быстрее телефона.

Сейчас работает восстановление через людей:

  1. Пользователь заранее выбирает 2–3 «хранителей» — людей, которым доверяет.
  2. Телефон создаёт случайный ключ восстановления K и шифрует им набор всех ключей пользователя. Этот пакет лежит на сервере — бесполезный без K.
  3. K делится по схеме Шамира: на столько частей, сколько хранителей; собрать K можно из большинства частей (2 из 3, 3 из 5). Каждая часть запечатывается открытым ключом своего хранителя.
  4. Потерял телефон: новый телефон входит по паролю и просит хранителей подтвердить. Каждый хранитель на своём телефоне подтверждает ПИН-кодом или отпечатком, расшифровывает свою часть и перезапечатывает её на ключ нового телефона.
  5. Когда подтвердило большинство — сервер сразу выдаёт части новому телефону. Новый телефон собирает K и открывает пакет; старые устройства получают уведомление и выходят из аккаунта.

Про задержку мы спорили дольше всего. Сценарий атаки: злоумышленник украл телефон и узнал пароль, потом убедил двух хранителей «это я, новый номер». Окно перед выдачей частей даёт настоящему владельцу шанс увидеть запрос и отменить его. Сначала окно было 5 минут, после аудита — 24 часа. На практике сутки ожидания били по тому, ради чего восстановление и нужно: человек потерял телефон и не может работать. Сейчас окна нет — части выдаются сразу после кворума. Цена этого решения честная: защита держится на хранителях. Поэтому приложение прямо говорит хранителю «отмены нет, подтверждайте только после разговора голосом», а один хранитель по-прежнему ничего не решает.

Что важно: один хранитель ничего не может. Хранители не видят данных пользователя — у них только часть ключа, которая сама по себе ничего не открывает. Сервер не видит K ни в один момент.

Цена решения — честно говоря, в UX. Пользователь должен заранее назначить хранителей, а хранители должны иметь аккаунт и не потерять свои телефоны одновременно с ним. Мы напоминаем об этом настойчиво: «нет хранителей — потерянный телефон означает потерянные данные». Если все хранители недоступны и старого телефона нет — данные не вернуть. Нам тоже.

Слой 5. Сервисы: ключ на каждую запись

Отдельная история — пространство «Сервис»: салон, мастерская, кондитерская принимают клиентов. Здесь общий ключ пространства не подходит: клиент не должен получать ключ ко всем записям салона.

Поэтому у каждой записи и заказа — свой ключ. Он раздаётся клиенту и назначенному персоналу (мастер, операторы). Пожелания клиента («аллергия на…»), состав заказа, комментарии к записи — под этим ключом. Новый мастер, принятый в салон, не получит доступ к прошлым записям коллег. Уволенный мастер не прочитает новые.

Что сервер всё-таки видит (метаданные)

Здесь мы стараемся быть честнее, чем принято в рекламе мессенджеров.

Сервер знает: - кто зарегистрирован и в каких пространствах состоит; - название и цвет пространства (нужно, чтобы приглашение показало «вас зовут в „Студию дизайна“»); - когда создана и изменена запись; - кому назначена задача (assignee_ids).

Последнее — компромисс. Мы хотели, чтобы сервер проверял права: «закрыть задачу может автор, руководитель или исполнитель». Если исполнитель внутри шифра, сервер этого не знает, и клиент может отправить что угодно. Мы выбрали серверную проверку прав ценой одного поля метаданных. Альтернатива — полностью клиентская проверка прав — означала бы, что модифицированный клиент может закрыть чужую задачу, и сервер это примет.

Второй компромисс — уведомления. Сервер должен знать, кому отправить «вам назначена задача». Текста задачи в уведомлении нет: телефон получает «событие», сам расшифровывает задачу и сам формирует текст уведомления.

Поиск, когда сервер слеп

Если сервер не видит текста, он не может по нему искать. Поиск по задачам, подзадачам, комментариям и тегам идёт на устройстве: приложение расшифровывает и ищет у себя. Это медленнее серверного поиска, но на объёмах малой команды — тысячи задач — терпимо.

Сервер ищет только то, что открыто: людей по ФИО (если человек разрешил), организации и сервисы по названию.

Уход участника и смена ключа

Если сотрудник уволился, у него остаётся ключ пространства на телефоне — всё, что он видел, он мог сохранить. С этим ничего не сделать ни в одной системе. Но можно не дать ему читать новое. Руководитель меняет ключ пространства: приложение создаёт новый ключ, перешифровывает записи и раздаёт новый ключ всем, кроме ушедшего.

Перешифровка — одна операция на телефоне руководителя: старые конверты на сервере после неё удаляются, а новые получают только оставшиеся участники. Подробности и подводные камни — ниже, в разделе «Смена ключа: подробнее».

Защита канала

Шифрование содержимого не отменяет обычной защиты канала: - HTTPS с HSTS; - проверка сертификата сервера в приложении (pinning): если в сети подменили сертификат, запрос не уходит, а пользователь видит «возможно, вы в чужой сети»; - сервер слушает только локальный порт, наружу — nginx; - строгие заголовки безопасности на всех страницах.

Pinning — палка о двух концах: при смене сертификата старые версии приложения перестанут подключаться. Мы держим в приложении запасной пин и обновляем список заранее.

Подписи: когда нужно доказать, а не спрятать

Шифрование отвечает на вопрос «кто может прочитать». Но в TaskBoss есть данные, которые должны быть публичными и проверяемыми: награды сотрудникам, рекомендации коллег, подтверждённая организацией история работы в резюме. Здесь задача обратная — не спрятать, а доказать, что запись не подделана.

Для этого у организации есть ключ подписи Ed25519. Он создаётся на телефоне руководителя, который первым вручает награду, и на сервер не уходит. Награда — это небольшая структура (кому, за что, когда, какая организация), подписанная этим ключом. Публичная страница резюме проверяет подпись прямо в браузере: если кто-то изменит запись в базе, подпись не сойдётся, и страница покажет «подпись не подтверждена».

Рекомендации коллег подписываются личным ключом автора. Так получатель не может сам себе «написать» рекомендацию от имени другого человека, а сервер не может её подделать.

Сложность — перенос ключа подписи. Если руководитель сменил телефон, ключ должен переехать вместе с ним, иначе новые награды будут подписаны новым ключом и старые проверки станут путаными. Ключ подписи переезжает тем же «кодом со старого телефона», а в веб — по отдельному QR. В браузер он попадает только зашифрованным и снимок экрана с этим кодом запрещён системно.

Смена ключа: подробнее

Звучит просто — «создай новый ключ и перешифруй». На практике это самая хрупкая операция в системе.

Что происходит, когда руководитель нажимает «Сменить ключ»:

  1. Телефон руководителя создаёт новый ключ K₂.
  2. Скачивает все записи пространства и расшифровывает их старым ключом K₁.
  3. Шифрует их ключом K₂.
  4. Для каждого оставшегося участника запечатывает K₂ его открытым ключом.
  5. Отправляет всё одной пачкой. Сервер подменяет записи и старые конверты атомарно.

Подводные камни, на которые мы наступили:

Как мы тестируем криптографию

Криптографический код нельзя проверить «глазами». Что мы делаем:

Всего у клиента сейчас около 590 автоматических тестов, у сервера — около 320. Это не гарантия отсутствия ошибок, но это гарантия, что известные ошибки не вернутся.

Выгрузка: ваши данные — ваши

Если сервер не может прочитать данные, логичный вопрос: а как я их заберу, если захочу уйти?

В приложении есть «Экспорт всех данных»: телефон расшифровывает всё, к чему у вас есть доступ, и собирает ZIP — задачи, проекты и направления в JSON и CSV, вложения, записи резюме с подписями. Мы честно предупреждаем, что внутри архива данные уже расшифрованы и дальше их защищаете вы.

Для отдельных списков — PDF, таблица и текст: задачи, заметки, отчёты. Шрифты для PDF встроены в приложение, а не скачиваются у сторонних сервисов: иначе выгрузка зависела бы от доступности чужого сервера.

Чего стоило всё это

Честный список того, что мы отдали за то, что сервер ничего не читает:

  1. Нет серверного поиска по содержимому. Только на устройстве.
  2. Нет «восстановления по почте». Только хранители или старый телефон. Забытый пароль восстанавливается только на телефоне, где вы уже вошли.
  3. Нет «умных функций» на сервере: подсказок, автокатегорий, аналитики по тексту. Мы считаем это скорее плюсом.
  4. Сложнее отладка. Пользователь пишет «у меня пропала задача», а мы видим только шифр. Пришлось сделать отчёты о сбоях, в которые уходят только тип ошибки и строки кода — без текста ошибки, потому что в нём мог оказаться расшифрованный фрагмент.
  5. Медленнее первый запуск на слабых телефонах: расшифровка сотен записей — это работа процессора.

Что дальше

Если вы делаете что-то похожее — пишите в комментариях, особенно если видите дыру. Нам это важнее, чем лайки.

Попробуйте TaskBoss

Задачи, семья, команда и запись клиентов. Всё, что вы пишете, шифруется на телефоне. Сейчас бесплатно, без рекламы.

Android и веб-версия. Приложения для iPhone пока нет — на iPhone откройте веб-версию.

Письма о TaskBoss

Редко и по делу: новые возможности, советы, статьи. Отписаться — одной ссылкой в любом письме.

Нажимая «Подписаться», вы соглашаетесь на обработку почты для рассылки по политике конфиденциальности.