Привет! Меня зовут Алексей, я — разработчик. Здесь я расскажу о недавнем кейсе, в рамках которого мы реализовали нетипичное решение для обеспечения безопасной обработки данных. А также, сколько времени ушло на выбор оптимального архитектурного решения, и почему мы остановились на сложном в реализации методе шифрования.
Утечка данных 150 тыс. клиентов Совкомбанка, слив исходного кода Госуслуг, потеря данных более 8 млн. пользователей сервисов доставки еды — мне продолжать? Кажется, всё это — достаточно веские причины, чтобы задуматься о безопасности. Говоря о приложениях для финансового учета, вопрос сохранности данных стоит ребром.
Немного о самом приложении — это система учета финансов для малого и среднего бизнеса. Она позволяет управлять продажами, расходами, планировать будущие платежи и анализировать с помощью дашбордов финансовое состояние компании.
Перед моей командой стояла задача сделать доступ к данным безопасным, максимально защитить и минимизировать потери при утечке информации. При проектировании приложения был сделан большой упор на безопасность данных.
Внешнее воздействие — проблема безопасности, которой занимается не только бэкенд, но и фронтенд. Утечка может произойти на разных уровнях: передача данных, бэкенд, фронтенд, база. Базовое решение со стороны фронта — аутентификация, введение двухфакторной защиты и дополнительные усложнения. Как вы поняли из начала статьи, на нем мы не остановились.
Сразу скажу, что это решение подходит не только для финансового сектора. Где применять его — решать вам.
Как могут воздействовать на данные пользователя извне?
Наша задача сделать так, чтобы гипотетическая утечка данных никак не воздействовала на пользователя — не принесла ему убытков и не подвергла опасности. Для этого нам надо понимать, как на систему могут воздействовать. Здесь перечислю только основные уязвимости:
Межсайтовый скриптинг, XSS;
Межсетевая подделка запроса, CSRF;
Bruteforce attack.
Уязвимости эволюционируют — слишком много вещей, от которых мы хотим защититься. Каждый год OWASP публикует топ-10 уязвимостей, поэтому советую актуализировать информацию и знать, что или как может воздействовать на систему.
От всего спрятаться нельзя, но мы можем попытаться свести к нулю последствия.
Считается, что данные безопасны тогда, когда затраты злоумышленника на их получение не оправдывают потенциального заработка. Поэтому наша цель — не защитить данные от утечки на 100%, а максимально усложнить доступ к ним.
Как обеспечить безопасность данных пользователя?
И тут мы подходим к шифрованию, чтобы даже при возможной утечке базы, данные пользователя остались в безопасности, так как они зашифрованы.
Есть два типа шифрования: симметричное и асимметричное. В симметричном у нас общий ключ для шифрования и расшифрования, а в асимметричном — разные, то есть публичный и приватный.
I вариант. Храним данные в базе в зашифрованном виде
Мы закрываем данные на уровне базы. Если произойдет кража базы, к данным без ключей шифрования доступ не получить.
Если данные нужны пользователю, мы подготавливаем (расшифровываем) их на сервере и в готовом виде передаем для отображения пользователю. Все необходимые для финансового учета расчеты и анализы производятся на сервере.
Таким образом мы используем server-side шифрование, то есть зашифровываем и расшифровываем данные каждый раз с помощью ключа, который хранится на сервере.
Не подходит.
Проблему утечки не решает. В отдельных узлах нашего контура данные открыты. Кроме того, если у пользователя крадут сессию, то у него могут украсть ключи шифрования. Кража ключей шифрования = кража данных. Получается, что мы защитились от утечки целой базы, а от воздействия злоумышленника на конкретного пользователя — нет.
II вариант. Храним данные в базе в зашифрованном виде и закрываем доступ на уровне сервера
Еще больше закрываем доступ к данным. Теперь мы не агрегируем их на бэкенде, а делаем все на фронте. Мы хотим закрыть контур на фронте, то есть утечки сервера не так страшны.
Спрячемся за спины крутых безопасников. Закроем систему настолько, насколько можем, прикрутим кучу анализаторов и будем стараться душить любую проблему в зародыше. Инициализируем ключи шифрования на стороне фронта, данные закрываем. Ключ мы также зашифровываем.
Получается, что мы используем client-side шифрование, то есть, предотвращаем утечку данных на закрытом контуре.
Хорошая новость — хакер теперь задохнётся в нашей системе. Плохая — пользователь задохнётся вместе с ним.
Не подходит.
Проблему утечки решает. Но мы говорим о приложении для финансового учета: большие данные, вычисления внутри. Отдать все вычисления на сторону фронта в корне неправильно с точки зрения клиент-серверной архитектуры. К тому же, мы увеличим время обработки и загрузки данных пользователю.
Можно попробовать выбрать самый эффективный алгоритм подсчёта, но его всё равно не хватит для вычислений. Кроме того, устройства (телефоны, планшеты и т.д.) обладают разной мощностью.
III вариант. Хранение, передача и вычисления в зашифрованном виде
Мы полностью закрываем данные от системы. Доступ к открытым данным есть только у пользователя. Все вычисления происходят на сервере. Мы можем использовать математические методы поверх зашифрованных данных — гомоморфное шифрование.
Гомоморфное шифрование — форма асимметричного шифрования, которая позволяет выполнять математические операции над данными в зашифрованном виде. Конкретная реализация этого метода — Paillier cryptosystem.
Подходит.
Проблему утечки решает. Потери в скорости вычислений сводятся к минимуму за счет того, что вычисления оставляем на стороне сервера.
Переходим к конкретной реализации
Архитектура приложения:
React/Redux, TypeScript;
Взаимодействие с шифрованием через RxJS;
Шифрование — асимметричное для строк и гомоморфное для чисел.
Процесс обработки и передачи данных в приложении:
Создадим приватную область. Приватной областью будем считать личный кабинет организации, для доступа к которому необходимы авторизация и ключи шифрования.
import { generateRandomKeysSync } from 'paillier-bigint';
import { pkcs5, pki, md, util, cipher } from 'node-forge';
const {
publicKey: publicKeyPailler,
privateKey: privateKeyPailler,
} = generateRandomKeysSync(PAILLER_SIZE_BYTES);
const {
privateKey: privateKeyStr,
publicKey: publicKeyStr,
} = pki.rsa.generateKeyPair(RSA_SIZE_BYTES);
Для работы нужны два ключа — один для гомоморфного, другой для асимметричного шифрования RSA. При инициализации этой области пользователь вводит пароль, который является паролем суперюзера.
Для хранения на сервере RSA private key и Pailler private key объединятся в общий зашифрованный ключ и зашифруются паролем пользователя/организации.
/**
* объединяем приватные части ключей
*/
const privateKeyParts = [privateKeyStrPem, privateKeyInt].join(
CRYPTO_KEY_DELIMITER,
);
/**
* шифруем объединные приватные ключи паролем
*/
const cryptoKey = this.encryptCryptoKey(privateKeyParts, password);
/**
* данные для сохранения на сервер:
* зашифрованные приватные части ключей шифрования и открытые публичные
*/
const cipherData: CipherKeys = {
cryptoKey,
publicKeyInt,
publicKeyStr: publicKeyStrPem,
};
import { generateRandomKeysSync } from 'paillier-bigint';
import { pkcs5, pki, md, util, cipher } from 'node-forge';
/**
* @description шифрование cryptoKey, используя AES
* @param cryptoKey объединенные разделителем приватные ключи для RSA и HE
* @param password - пароль, которым шифруется cryptoKey
*/
private encryptCryptoKey(cryptoKey: string, password: string): string {
const salt = util.decode64(AES_SALT);
const iv = util.decode64(AES_INITIALIZATION_VECTOR);
const aesCipherKey = pkcs5.pbkdf2(password, salt, AES_ITERATIONS, 16);
const aesCipher = cipher.createCipher('AES-CBC', aesCipherKey);
aesCipher.start({ iv });
aesCipher.update(util.createBuffer(cryptoKey, 'utf8'));
aesCipher.finish();
const encryptedCryptoKey = util.encode64(aesCipher.output.getBytes());
return encryptedCryptoKey;
}
Симметричное шифрование последовательно собирает данные. Изначально мы получаем суммированный криптоключ, зашифровываем его с помощью симметричного алгоритма шифрования. Нам нужен хэш от пароля, который существует. Хэш мы получим с помощью Password Based Key Derivation Function – это более сложно реализуемое хэширование, чтобы хэш найти перебором было почти нереально.
/**
* AES salt: n bytes - base64 encoded
*/
const salt = util.decode64(AES_SALT);
/**
* AES initialization vector: m bytes - base64 encoded
*/
const iv = util.decode64(AES_INITIALIZATION_VECTOR);
/**
* формируем хэш для шифрования на основе пароля
*/
const aesCipherKey = pkcs5.pbkdf2(password, salt, AES_ITERATIONS, 16);
Шифруем private keys. После получения хэша мы шифруем приватные части ключей шифрования симметричным шифрованием, чтобы можно было сохранить на сервере. Используем Cipher Block Chaining: Мы дробим приватный ключ на пакеты одного размера. Каждый пакет данных зашифровывается с помощью хэш-пароля. Первый байт начинается с вектора инициализации. В дальнейшем каждый блок данных будет связан между собой и проходить некоторую связную математическую операцию, зависящую от предыдущего вычисления.
const aesCipher = cipher.createCipher('AES-CBC', aesCipherKey);
aesCipher.start({ iv });
aesCipher.update(util.createBuffer(cryptoKey, 'utf8'));
aesCipher.finish();
const encryptedCryptoKey = util.encode64(aesCipher.output.getBytes());
Отправка зашифрованных данных. Пользователь отправляет приватные части публичных ключей в зашифрованном виде и публичные части в открытом. Так как публичные части лишь зашифровывают, то их утрата для безопасности не страшна. Что с ними происходит дальше? Пользователь обладает приватным ключом, который зашит в Execution Context в JS. При отправке каких-либо данных зашифровываем их на стороне клиента, если данные небольшие. Большие данные можем посылать потоком на сервер и зашифровывать на сервере. Так мы почти не будем влиять на main thread и производительность.
const encryptSalesHandler = (saleRequest: ICreateSaleData): ICreateSaleData => {
const total = cipherClient.encryptNumber(saleRequest.total) || 0;
const comment = cipherClient.encryptString(saleRequest.comment || '');
return {
...saleRequest,
total,
comment,
};
};
public encryptNumber(num: number | string): string | undefined {
const encryptedNumber = this.publicKeyPaillier?.encrypt(
BigInt(Number(num)),
);
if (!encryptedNumber) {
throw Error(NumberEncryptionError);
}
return encryptedNumber.toString();
}
Мы постарались максимально изолировать сервис и клиента от утраты данных, а если это все-таки происходит, то данные все равно остаются зашифрованными. Система достаточно сложна для взлома всех слоев безопасности.
С какими проблемами мы столкнулись при реализации?
Проверка уникальных значений. Названия счетов и словарей хранятся зашифрованными. Мы не можем сравнить два зашифрованных значения, потому что будут разные результаты.
Решение: для уникальных значений храним hash и сравниваем по хэшам.
public encryptString(str: string, params?: { withHash?: boolean }): string {
if (!this.publicKeyRSA) {
throw Error(EmptyPublicRSAError);
}
const encryptedStr = this.publicKeyRSA.encrypt(
util.encodeUtf8(str),
'RSA-OAEP',
{
md: md.sha1.create(),
mgf1: {
md: md.sha1.create(),
},
},
);
const encodedStr = util.encode64(encryptedStr);
if (params && params.withHash) {
const hashString = this.hashString(str);
return `${encodedStr}${CRYPTO_HASH_STRING_DELIMITER}${hashString}`;
}
return encodedStr;
}
Шифрование апеллирует целыми числами. В нашей реализации гомоморфного шифрования значений используется BigInt, который принимает только целочисленные значения.
Решение: дробные доли приводятся к целым — определяется разряд, до которого происходит округление. Например, учет идет до второго числа после запятой. Чтобы сохранить возможность оперирования дробными частями, мы перед шифрованием умножаем все числа на 10 в степени n. Степень 10 равна max разряду дробного числа. После дешифровки делим на это же число.
export const prepareNumberToCipher = (value: number, power: number) => {
return value * (10 * power);
};
export const prepareNumberToDecipher = (value: number, power: number) => {
return value * (10 * power);
};
Что учитывали при работе с шифрованием?
1.Как агрегировать и получать большие объемы данных? Алгоритм гомоморфного шифрования позволяет эти данные агрегировать. Получаем массив данных, который можем просуммировать. Эта сумма для нас будет того вида, которого мы хотим. Однако если нам необходимо получить большой объем данных, то клиент будет испытывать проблемы с быстрой расшифровкой. Пользователь почувствует разницу.
Чтобы не блокировался основной поток процессами шифрования, мы подняли Web Worker — мультитрединг на уровне JS. Расшифрование происходит в отдельном потоке. Оно не влияет на другие данные и взаимодействие между ними.
2.1. Как предоставить пользователю удобный доступ к зашифрованным данным? При обновлении страницы пользователю придётся каждый раз вводить пароль.
Необходимо хранить ключи между сессиями. Хранить в открытом виде их не можем, поэтому создадим временный пароль на сервере, который отдаем на клиент и используем как пароль, которым шифруем ключи. Клиент будет кэшировать уже зашифрованные ключи. При обновлениях сессии регулярно обновляем временный пароль и перешифровываем ключ.
Проблемы безопасности кэширования.
Стандарт для хранения чувствительных данных — cookies, но в нашей концепции они будут нарушать политику безопасности, т.к. сервер будет обладать временным паролем и приватным ключом, зашифрованным этим паролем – очевидная дыра в безопасности;
local storage – уязвимость к XSS.
Т.к. концептуально мы не можем полностью защититься, мы будем хранить всё в Local Storage. Для каждой сессии мы делаем связку из accessToken, refreshToken, temporary password. Когда пользователь будет обновлять сессию, мы сможем определить предыдущий временный пароль, расшифровать приватные ключи из кэша и зашифровать их с помощью нового временного пароля.
Чтобы каждый раз не входить в организацию с началом новой сессии, приватные ключи шифрования сохраняются в закрытом виде в кэше. При обновлении сессии достаем получаем доступ к приватным ключам из кэша без необходимости ввода пароля организации. .
2.2. Что делать, когда ключ утрачен? При создании организациимы назначаем суперюзера. Он может управлять пользователями — добавлять, удалять, выдавать пароли. При необходимости, суперюзер может перешифровать данные, обновить ключ для пользователя.
3. Как загрузить на сервер большой объем данных? Например, пользователь хочет загрузить большой excel-файл, где находилась его «база данных», в нашу систему, где эта информация будет в безопасности. Сервер обладает публичными ключами, можно зашифровать большой объем данных на серверной стороне.
Итоги
Вернёмся к изначальной цели: обеспечить максимально безопасное хранение данных в приложении для финансового учёта для малого и среднего бизнеса.
Концептуально полностью защитить данные от утечки нельзя, потому что уязвимостей слишком много, и они непрерывно эволюционируют. Так, наша задача трансформировалась из того, чтобы уберечь данные от утечки на 100% в то, чтобы сделать гипотетическую утечку безвредной для пользователя. Так, если данные украдут, то они будут надёжно зашифрованы.
Мы постарались максимально изолировать слой сервера и клиента от утраты данных. А если данные всё-таки утрачены, то они напрямую недоступны. Мы можем считать, что система достаточно сложна для того, чтобы пройти все слои безопасности. Так, хакеру сложно и затратно реализовать взлом этой системы.
Если мы используем концепции гомоморфного шифрования, то достаточно сложно найти удобную и поддерживаемую реализацию и использовать её. Придётся искать самостоятельно решение, исследовать сам алгоритм и следить за поддержкой самим. При расшифровке данных производительность немного снизилась. Мы попробовали вытащить данные в Web Worker, но скорость расшифровки данных большого объёма значительно не ускоряется.
Для создания подобной схемы шифрования мы использовали следующие алгоритмы:
RSA — криптографический алгоритм с открытым ключом, основывающийся на вычислительной сложности задачи факторизации больших целых чисел;
AES — это симметричный алгоритм блочного шифрования, который оперирует блоками по 128 бит;
Paillier cryposystem — аддитивная гомоморфная криптосистема, то есть зная только открытый ключ и шифротексты, соответствующие открытым текстам m1 и m2, мы можем вычислить шифротекст открытого текста m1+m2 .
Для организации архитектуры — RxJS, Web Worker.
ermouth
Добавлю конкретных цифр во сколько раз это удовольствие замедляет операции:
Читать надо так: сложение в системе Пэйе примерно в 30000 раз медленнее обычного. По моему личному опыту, это очень, очень оптимистичная оценка, будет ещё медленнее.