Хук
В 2024-2025 годах в открытый доступ попали базы данных «ВкусВилла», «Спортмастера», СДЭК, «Магнита» — и это только то, что попало в новости. Каждая из этих утечек открыла электронные адреса, имена, истории заказов и в некоторых случаях — гео-координаты пользователей. Каждая стоила бизнесу штрафов, репутации и юридических расследований.
Когда делаешь приложение, в котором пользователи хранят список «купить молоко, забрать ребёнка из садика, заплатить за ипотеку» — оказаться в этом списке утечек не хочется. Не из страха перед Роскомнадзором (хотя и из него тоже — после поправок 2025 года штрафы за утечку ПДн стали кусаться), а потому что список покупок одной семьи в чужих руках — это уже почти доступ к её быту.
В keepware полтора года развиваем todo-приложение todo.keepware.ru — Spring Boot на сервере, Compose на Android, для семей и микрокоманд. В этой статье — честный разбор, как в проекте устроено шифрование персональных данных: где оно настоящее, где компромисс, и почему мы не утверждаем, что у нас end-to-end (хотя так писать в магазине приложений — заманчиво и продаваемо).
Что мы шифруем (и зачем)
Данные в todolist делятся на три категории по чувствительности.
Категория 1 — критические: email пользователей (используется для логина и инвайтов в списки), имена пользователей, тексты задач (то самое «забрать ребёнка из садика»).
Категория 2 — функциональные, нужны в открытом виде: хеш пароля (bcrypt — собственное шифрование), JWT-refresh-токены (хранятся как хеш), идентификаторы списков, роли участников, временные метки.
Категория 3 — не ПДн: счётчики, агрегаты, журналы изменений (без user-content).
Шифровать всё подряд — соблазнительно, но дорого: каждый зашифрованный столбец ломает сортировку, индексы и фильтры. Поэтому реальный вопрос дизайна — не «шифровать или нет», а какие именно поля и какие операции над ними нужны.
В todolist это решается так:
| Поле | Шифруется? | Поиск? | Решение |
|---|---|---|---|
users.email | Да (AES-256-GCM) | Да, точное совпадение | + blind index HMAC-SHA256 |
users.name | Да (AES-256-GCM) | Нет | Только AES |
tasks.title, tasks.description | Да (AES-256-GCM) | Нет (пока) | Только AES |
users.password_hash | Нет — bcrypt | Нет | Стандартный bcrypt |
lists.id, tasks.id, roles | Нет | Да, фильтры/joins | Открытый текст |
Часть 1. Server-side AES-256-GCM в Spring Boot
Почему именно GCM, а не CBC
GCM (Galois/Counter Mode) — режим работы AES, который даёт аутентификацию вместе с шифрованием (AEAD — Authenticated Encryption with Associated Data). На практике это значит:
- Если злоумышленник получит зашифрованный столбец и попробует подменить там байты — расшифровка вернёт ошибку, а не «странную, но валидную» строку.
- Не нужно отдельно ставить HMAC поверх ciphertext — GCM-tag это уже включает.
- Скорость на современных x86/ARM — нативная (инструкции AES-NI), overhead на JVM измеряется единицами микросекунд на запись.
Альтернатива — AES-CBC + HMAC-SHA256 в режиме encrypt-then-MAC. Работает, но это две операции, два ключа и больше шансов ошибиться при сборке. GCM — стандарт по умолчанию для нового кода с 2015 года (NIST SP 800-38D).
Один важный подвох GCM: никогда не использовать одинаковую (key, nonce) пару дважды. Если повторить nonce при том же ключе — теоретически можно восстановить XOR двух открытых текстов, что катастрофа. Решается тем, что nonce генерируется случайно (12 байт = 96 бит = 2⁹⁶ вариантов; вероятность коллизии при ~2⁴⁸ записях остаётся ничтожной — это парадокс дней рождения, но в обратную сторону).
Сервис шифрования
| |
Несколько неочевидных моментов.
Длина GCM-tag — 128 бит. Это максимум, заданный стандартом. Не 96 и не 64 — это сэкономит 4-8 байт на запись, но снизит безопасность до неприемлемого уровня для случая, когда злоумышленник может попробовать миллионы вариантов подделки.
Nonce 12 байт. Это рекомендуемая длина GCM. Можно 16, но тогда GCM внутри сначала хеширует nonce, что добавляет операцию.
Формат хранения nonce || ciphertext+tag. В одной строке. Не нужно держать nonce в отдельной колонке — это всё равно случайные данные, они должны храниться рядом с ciphertext. Просто префиксуем.
Cipher не thread-safe. В коде выше Cipher.getInstance() создаётся на каждый вызов — для read-heavy/write-light нагрузки это нормально, в hot path можно использовать ThreadLocal<Cipher> или пул.
JPA @Converter — прозрачное шифрование на entity-уровне
Главная архитектурная ошибка, которую делают начинающие в этой области — раскидывать encryptionService.encrypt(...) по сервисам и DAO. Через полгода никто не знает, где зашифровано, а где нет, и любая забытая точка превращается в утечку.
Правильный путь — AttributeConverter:
| |
И на entity:
| |
С этого момента в коде работаешь со строками email и name как с обычными — конвертер прозрачно шифрует на запись и расшифровывает на чтение. Все JPA-операции (save, findById, findAll, query-методы) — работают.
Одна тонкость со Spring DI: конвертер по умолчанию инстанцируется JPA через no-arg constructor, и @Autowired в нём не сработает. Решений два:
| |
В проекте используется вариант 2 — более чистый, хоть и требует один лишний bean. В тестах это даёт изолируемость: можно подменить EncryptionService на стаб с предсказуемым выводом.
Поиск по зашифрованному полю — blind index HMAC
Возвращаемся к таблице полей выше: users.email нужно искать по точному совпадению (для логина и проверки уникальности). Если в базе лежит ciphertext, то наивный WHERE email_encrypted = ? — не сработает: каждое шифрование одной строки даёт разный ciphertext (разный nonce → разный результат). Это, кстати, фича GCM, а не баг.
Решение, применённое в проекте — blind index на HMAC-SHA256:
| |
И в entity:
| |
Теперь WHERE email_blind_index = ? работает как обычный B-tree-индекс.
Что мы выигрываем: поиск по точному совпадению, проверка уникальности email, фильтры — всё работает на скорости обычного индекса.
Что мы платим: blind index — это, фактически, детерминированный хеш. Если злоумышленник получит дамп базы и список из 100 миллионов известных email, он сможет посчитать HMAC для каждого и сравнить — это даёт атаку «известного открытого текста». Снижается она тем, что ключ HMAC должен быть отдельным от ключа AES, и хранится с теми же ограничениями (env, KMS — никогда не в коде или в той же таблице).
Чего blind index не даёт: поиска по подстроке (LIKE '%...%'), сортировки, range-запросов. Для todolist это не нужно — задачи показываются в порядке создания, поиск по ним пока не реализован. Когда понадобится — будем смотреть в сторону ORE (Order-Revealing Encryption) или client-side full-text index, но это уже отдельная статья.
Где живёт ключ AES — главная честность
Тот самый app.encryption.key из application.yml. В проекте он подгружается из переменной окружения сервера (ENCRYPTION_KEY=...), которая прописана в systemd unit-файле и недоступна другим процессам пользователя.
Это не Hardware Security Module, не AWS KMS и не Yandex KMS. Это просто переменная окружения на VPS.
Уровень защиты, который это даёт:
- ✅ От утечки бэкапа базы (бэкап содержит только ciphertext, ключа в нём нет — без ключа дамп бесполезен)
- ✅ От разработчика, который случайно увидел SQL-вывод в логах
- ✅ От ошибочно открытого pgAdmin/DBeaver, направленного в прод
- ❌ От взлома сервера с получением root (в этом случае ключ читается из
/procили/etc/systemd/system/...env) - ❌ От злонамеренного администратора с физическим доступом к VPS
- ❌ От правоохранительных органов с ордером на доступ к серверу
То есть это защита data-at-rest, не защита от инсайдера. Это важно проговорить честно, потому что в карточках приложений соблазн написать «end-to-end encryption» очень велик — и эту фразу часто пишут без права на это. End-to-end означает, что ключ доступен только клиенту. Если ключ есть на сервере — это не E2E.
Когда нужен следующий уровень защиты:
- Если приложение хранит банковские/медицинские данные — обязательно KMS с rotation.
- Если есть требование сертификации (PCI DSS, ISO 27001) — KMS не опционален.
- Если приложение претендует на E2E — ключ должен быть только у клиента, и это меняет всю архитектуру (нужен KDF из пароля пользователя, нужна модель восстановления при забытом пароле, нужна синхронизация ключа между устройствами одного пользователя).
Для todo-приложения уровня «семейные списки покупок» переход на KMS — overengineering. Но если/когда в приложении появится функция хранения паролей (как в Bitwarden), архитектура должна полностью измениться. Это нужно проговорить явно, потому что эта граница — не очевидна для пользователя.
Часть 2. Client-side SQLCipher на Android
Серверное шифрование защищает базу. Но на устройстве пользователя живёт локальная база Room — она нужна для оффлайн-режима (создавать и редактировать задачи без сети). И эта база, по умолчанию, лежит в открытом виде в /data/data/ru.mngerasimenko.todolist/databases/....
Если телефон рутован — любое приложение его может прочитать. Если телефон украдут и снимут с него данные через ADB-форму или восстановление — задачи и имена улетят как есть.
Решение — net.zetetic:android-database-sqlcipher (актуальная версия 4.5.4). Это форк SQLite с прозрачным шифрованием БД ключом, который можно подать при открытии.
| |
Что здесь происходит:
- При первом запуске приложение генерирует случайный 32-байтный (256-битный) ключ.
- Этот ключ шифруется и сохраняется в
EncryptedSharedPreferences— это обёртка над SharedPreferences, которая шифрует и ключи, и значения через AES-256-GCM/SIV. Корневой ключ для этого шифрования живёт в Android Keystore — аппаратном изолированном хранилище (на устройствах с TEE/StrongBox — внутри отдельного защищённого процессора, недоступного даже корневому ОС). - SQLCipher открывает базу с этим ключом и шифрует все записи AES-256-CBC + HMAC-SHA512 (да, тут именно CBC, а не GCM — это решение SQLCipher из соображений совместимости со старыми устройствами).
Уровень защиты, который это даёт:
- ✅ Если телефон украден и блокирован экраном — данные нельзя прочитать без разблокировки (ключ в Keystore требует unlocked screen на API 28+, если задана политика
setUserAuthenticationRequired) - ✅ От попытки прочитать
/data/data/.../databases/todo.dbчерез ADB-снимок (база зашифрована) - ✅ От malicious-приложения на устройстве (Android sandbox + Keystore-изоляция)
- ⚠️ От рутованного устройства с разблокированным экраном — частично (ключ можно вытянуть из Keystore только если StrongBox недоступен; на современных Pixel/Samsung StrongBox есть)
- ❌ От forensic-расшифровки RAM на работающем устройстве (ключ в памяти процесса)
Цена внедрения SQLCipher — измеряется. На Snapdragon 7 Gen 2 (Honor 90 — типичное устройство в нашей пользовательской базе) средний overhead на чтение мелкой записи — 0.3-0.5 мс, на запись — 0.5-1.0 мс, для нашего use-case (десятки операций в минуту максимум) это незаметно. Памяти — +5-7 MB в процесс.
Один сюрприз с миграциями. Если переносить существующую базу на SQLCipher (в проекте — с версии 1.0 база была не шифрована), нужна миграция через PRAGMA rekey или экспорт через sqlcipher_export. У нас это сделано через одноразовую копию базы при старте, после успеха — удаление старой. Тут есть что обсудить отдельно — может быть, в следующей статье.
Часть 3. Что мы НЕ сделали (и что писать в карточке приложения)
Вот честный список того, что в проекте не реализовано — и почему это важно проговорить, особенно при размещении приложения в магазинах.
Не E2E. Серверное шифрование защищает базу при утечке дампа — но не защищает от команды разработки или от законного запроса в адрес сервера. Если у пользователя в задачах что-то по-настоящему чувствительное — журналистский источник, медицинская информация, юридическая консультация — todolist не подходит. Существуют Standard Notes, Bitwarden и подобные с реальным E2E.
Нет защиты метаданных. Названия списков, ID списков, факты доступа, временные метки изменений — не шифруются. Из них можно понять «эта семья создаёт список покупок каждое воскресенье в 18:00 и активность падает летом». Это известная проблема даже в E2E-системах (Signal сделал PIR — Private Information Retrieval — для частичной защиты метаданных, но это сложная задача).
Bcrypt вместо argon2id. Хеширование паролей — на bcrypt с cost=12. Argon2id современнее и устойчивее к ASIC-атакам. Перейдём, когда будет повод сделать большую миграцию пользователей, но это не критическое отставание (bcrypt с cost=12 в 2026 году всё ещё OK для типичной угрозы соло-приложения).
Нет логирования доступа разработчика. В проекте не реализован access log, который бы фиксировал, какие записи команда (как админы) посмотрела в БД через psql/DBeaver. То есть «техническая возможность подсмотреть» есть, и пользователь не имеет способа узнать, что этого не делалось. Мы этого не делаем — но доказать это не можем. Для соло-приложения с публичным GitHub-репозиторием это компромисс; для бизнеса с инсайдер-риском надо ставить аудит.
Что написано в карточке RuStore/AppGallery:
| |
Не «end-to-end». Не «никто не сможет прочитать». Не «военный уровень защиты». Просто фактическое описание того, что сделано — где это даёт защиту и где не даёт. По наблюдениям — этот текст конвертит хуже, чем «военное шифрование», но никто не подставится под разнос журналистами, если те найдут несоответствие.
Часть 4. Что в этом для тебя
Если ты делаешь B2C-приложение в РФ в 2026 году:
- AES-256-GCM на сервере + JPA @Converter — это 1-2 дня работы и снимает ~80% риска утечки бэкапа. Если у тебя ещё нет — стоит сделать.
- Blind index на HMAC-SHA256 — добавляет 0.5 дня работы и закрывает поиск по точному совпадению. Без него шифрование email превращается в кошмар.
- SQLCipher на Android — 1 день интеграции для нового проекта, 2-3 дня с миграцией. Защищает от кражи устройства.
- End-to-end — это не про библиотеку, это про архитектуру. Если планируешь — закладывай с самого начала, включая модель восстановления и синхронизации ключа.
И главное — не ври в карточке приложения. Если у тебя нет E2E, не пиши «end-to-end encryption». Это и юридический риск (Роскомнадзор за заведомо ложные сведения о безопасности может привлечь), и репутационный (если всплывёт — доверие пользователей не вернётся).
В комментариях полезно обсудить детали — особенно если у вас есть опыт миграции с открытой Room на SQLCipher на проде или с argon2id на проде в Spring.
Код примеров — упрощённый. Реальные имплементации могут чуть отличаться (logging, error handling, метрики), но архитектурно — это рабочая схема.
Статья опубликована в блоге keepware как canonical. Кросспост — vc.ru / Habr — с указанием канонической ссылки.
Приложение, в котором это применено: todo.keepware.ru, исходники backend — пока приватный репо, готовится выделение библиотеки шифрования в open-source.
