Назад в блог
Инженерия6 мин чтения

Как мы добавили 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.

Связанные продукты

Finsight

Finsight