Как мы добавили E2E шифрование поверх local-first архитектуры
Как мы совместили local-first архитектуру, SQLite, PowerSync и E2E шифрование в финансовом приложении Finsight.
- E2E шифрование
- local-first
- privacy
- PowerSync
- SQLite
Когда-то я пользовался одним финансовым трекером — складывал туда зарплату, кредиты, всякие мелкие переводы. В какой-то момент стало любопытно: а команда, которая его делает, вообще видит эти суммы у себя в базе? Я написал им и спросил. Не ответили.
С тех пор это сидит где-то в голове. Когда мы начинали делать Finsight, мне не хотелось, чтобы наш пользователь оказался в такой же ситуации — гадать, что там с его данными. Поэтому приватность мы закладывали в архитектуру с самого начала, а не дописывали её сверху перед запуском.
Хочется, чтобы данные были недоступны не потому, что команда обещает их не смотреть, а потому, что технически команда не может их посмотреть, даже если очень захочет.
HTTPS — это не end-to-end
HTTPS защищает только трубу между телефоном и сервером. Когда запрос долетел, данные на сервере лежат в открытом виде. Если пришла сумма `53 000`, сервер её видит как «53 000».
Шифрование базы на сервере тоже не помогает: оно спасает на случай украденного диска или бэкапа, но во время обычной работы сервер расшифровывает данные на лету и читает как обычный plaintext.
Получается, в какой-то момент сумму, баланс и заметки всё равно сервер видит. Нам хотелось, чтобы этого момента не было.
Где мы провели границу
В прошлой статье я рассказывал, как мы переехали на local-first. Если коротко: у каждого пользователя на устройстве своя SQLite — маленькая встроенная база. Интерфейс пишет и читает напрямую из неё, а PowerSync, в фоне следит, чтобы локальная и серверная базы были в одном состоянии.
Это решило вопрос со скоростью. Но появился другой: данные постоянно ходят между устройством и сервером — и кто-то на этом пути может их прочитать.
Мы решили так: чувствительные поля шифруются прямо на устройстве, ещё до того, как PowerSync что-то отправит. На сервер уезжает уже зашифрованный мусор. Расшифровать его обратно может только клиент, у которого есть ключ. Сервер ключа не знает.
ввод → plaintext в локальную SQLite
→ шифрование на клиенте
→ ciphertext в синхронизируемую таблицу
→ PowerSync везёт ciphertext на сервер
→ на другом устройстве клиент расшифровывает обратноДля PowerSync эти строки — просто строки. Ему не нужно понимать, что внутри.
Две таблицы вместо одной
Если шифровать данные перед отправкой, возникает неудобство: интерфейсу они нужны в нормальном виде. Список транзакций нельзя нарисовать из непонятных строк, фильтр «расходы за месяц» по ним не построишь.
Поэтому для каждой чувствительной сущности у нас живут две таблицы.
Одна локальная — с обычными данными, с ней работает интерфейс. В PowerSync такие таблицы помечаются как `localOnly` и никогда не покидают устройство. Вторая — синхронизируемая, в ней те же записи, но чувствительные поля уже зашифрованы. Именно её PowerSync и гоняет между устройством и сервером.
На примере счёта это выглядит так:
// Локальная таблица — обычные значения, не покидает устройство
export const accounts = new Table(
{
organization_id: column.text,
name: column.text, // plaintext
balance: column.real, // обычное число
currency_id: column.text,
// ...
},
{ localOnly: true }
);
// Синхронизируемая — те же поля, но name и balance уже ciphertext
export const accountsEncrypt = new Table({
organization_id: column.text,
name: column.text, // зашифровано
balance: column.text, // зашифровано
currency_id: column.text,
// ...
});Такие пары есть для всех чувствительных сущностей:
accounts → accounts_encrypt
transactions → transactions_encrypt
debts → debts_encrypt
loans → loans_encrypt
loan_payments → loan_payments_encryptШифруем не всю запись целиком, а только конкретные поля: суммы, балансы, заметки, имена кредиторов, ставки. А вот дата, идентификаторы пользователя и организации, связи между записями — остаются видимыми. Без них сервер просто не сможет правильно доставить строки другим устройствам.
Это компромисс, и про него стоит сказать вслух чуть ниже.
Как это устроено в коде
Когда пользователь создаёт счёт, интерфейс просто пишет запись в обычную таблицу `accounts`. На этом этапе ещё нет ни шифрования, ни синхронизации — это самая обычная вставка строки в локальную базу.
Сама база — это `PowerSyncDatabase`, который при инициализации получает схему со всеми таблицами и имя файла, куда SQLite сохранит данные на устройстве:
import { PowerSyncDatabase } from '@powersync/web';
const db = new PowerSyncDatabase({
database: { dbFilename: 'finsight.db' },
schema: AppSchema, // описание всех таблиц — и локальных, и синхронизируемых
});Дальше начинается интересное. У PowerSync есть подписка на изменения: он умеет говорить «вот эти таблицы только что поменялись». Мы её используем как триггер, чтобы запустить криптомодуль:
// Запускается каждый раз, когда какая-то из таблиц изменилась
db.onChange({
onChange: async ({ changedTables }) => {
for (const table of changedTables) {
await handleTableChange(table);
}
}
});Сама функция `handleTableChange` — это маленький маршрутизатор. По имени таблицы он понимает, в какую сторону работать: шифровать только что записанные данные перед отправкой или, наоборот, расшифровывать пришедшие с сервера.
async function handleTableChange(table) {
// Пользователь что-то изменил локально → шифруем и кладём в синхронизируемую копию
if (table === 'accounts') return encryptAccounts();
if (table === 'transactions') return encryptTransactions();
// С сервера прилетела зашифрованная строка → расшифровываем для интерфейса
if (table === 'accounts_encrypt') return decryptAccounts();
if (table === 'transactions_encrypt') return decryptTransactions();
// ...долги, кредиты, платежи — по тому же шаблону
}Звучит как две одинаковые штуки, но они нужны именно вместе. Первая — чтобы то, что пользователь только что вписал в приложение, успело зашифроваться до того, как PowerSync соберётся это отправить. Вторая — чтобы то, что прилетело с другого устройства, превратилось в нормальные числа и строки и интерфейс мог их сразу показать.
После этого PowerSync смотрит уже только на зашифрованные таблицы и батчем отправляет накопившиеся изменения на сервер. На бэкенде они мапятся на обычные Django-модели. Поле `Transaction.amount`, например, объявлено как `TextField`, а не как число — потому что внутри лежит ciphertext, а не сумма. Сервер хранит эту строку, отдаёт её по запросу, но прочитать содержимое не может.
Что с ключами
Любое шифрование держится на ключе. Если ключ известен серверу — никакого e2e больше нет.
У нас двухуровневая схема. У каждой организации есть основной ключ — DEK (*data encryption key*), которым шифруются финансовые поля. DEK — это случайный набор байт, его никто не вводит и не запоминает. Сам DEK тоже зашифрован, другим ключом — KEK (*key encryption key*). А вот KEK уже выводится из секретного ключа пользователя — отдельной фразы, которую он задаёт именно для шифрования данных (не путать с паролем от аккаунта). Преобразование делается через Argon2id из libsodium — это алгоритм, специально сделанный, чтобы такие ключи было дорого подбирать перебором.
секретный ключ + соль → KEK
случайный DEK → зашифрованный DEK (через KEK)
DEK → зашифрованные финансовые поляНа сервере лежит только зашифрованный DEK и метаданные к нему. Самого DEK, KEK и секретного ключа у сервера нет.
Хорошая сторона — смена секретного ключа становится дешёвой. Не нужно перешифровывать все транзакции и счета: достаточно взять DEK, снять с него старую обёртку и завернуть в новую, выведенную из нового ключа.
Плохая сторона — если пользователь потерял и секретный ключ, и резервную копию, данные потеряны. У сервера нет открытой копии, и взять её неоткуда. Никакая поддержка не сможет восстановить то, к чему у неё в принципе нет ключа.
Что становится сложнее
E2e в продакшене — это не сама функция `encrypt`, она пишется в три строчки. Сложность вокруг.
Валидация. Раньше большая часть проверок жила на сервере: пришла сумма выполнили валидацию, что не противоречит балансу. Теперь сервер видит вместо суммы строку вроде `eyJhbGciOiJBMjU2R0NN...` и сказать про неё ничего по существу невозможно. Часть проверок переехала на клиент; на сервере остались права доступа, целостность связей и структура — но не «эта сумма миллион или сто».
Отладка. Если приходит баг «у меня не сходится», раньше можно было залезть в PostgreSQL и за две минуты понять, что произошло. Теперь там base64, и без пользовательского ключа из него ничего не вытащишь. Воспроизводить приходится на клиенте — смотреть локальную SQLite, очередь на отправку, состояние синхронизации.
Логи. На сервере plaintext не должен появляться никогда. Но на клиенте он есть всегда — до шифрования и после расшифровки. Значит клиентское логирование надо проектировать отдельно, чтобы случайно не утащить сумму или заметку в лог.
Нагрузка на устройство. Когда пользователь заходит со свежего устройства, ему первым делом нужно подтянуть всю историю — а это могут быть тысячи строк по транзакциям, платежам и долгам. И каждую такую строку клиент должен расшифровать сам, сервер тут не помощник. Поначалу мы ловили на этом нетривиальные баги: данные прилетают пачками, расшифровка идёт параллельно, и в этом потоке легко было поймать гонку, обрывы и просто подтормаживания интерфейса, пока всё не доехало.
Что сервер всё равно видит
Если говорить про приватность без приукрашивания — даже с e2e сервер видит не «ничего». Шифруется содержимое чувствительных полей, но всё, что вокруг, остаётся видимым: кто пользователь, к какой организации он принадлежит, сколько у него вообще есть транзакции, их даты, связи между ними.
По одним только метаданным можно сделать неприятно много выводов. Сервер может не знать сумму операции, но видеть, что в этом месяце транзакций стало в три раза больше прошлого.
Поэтому я не описываю e2e как «полную невидимость данных». Самые тяжёлые финансовые значения — суммы, балансы, ставки, заметки, категории — действительно не уходят на сервер в открытом виде. Но контур, в котором они существуют, серверу виден. Это и есть та граница, которую мы реально провели.
---
E2e в продакшене — не задача на выходные. Это сдвиг о том, как смотришь на бэкенд, клиент, валидацию и отладку. Бэкенд становится тупее — в хорошем смысле. Клиент получает больше ответственности.
Если приложение не работает с чувствительными данными, шифрование, скорее всего, не нужно. Но если речь про финансовое приложение, в котором лежит кусок жизни конкретного человека — становится понятно, это важно. Именно так мы это сделали в Finsight.