Цель состоит в том, чтобы гарантировать, что конфиденциальные данные, хранящиеся в базе данных, зашифрованы, и только у пользователя есть ключ для их расшифровки.

Что, если бы вы могли зашифровать части данных, которые хранятся в базе данных, и только владелец мог бы их расшифровать?

Никакая база данных в облаке не является стопроцентно защищенной от взлома, независимо от используемых новейших технологий. Предположим, что можно достичь 100% -ного решения, защищенного от взлома, существуют и другие формы угроз, такие как внутренняя угроза, от которых трудно защититься.

Я признаю, что есть плюсы и минусы, однако в моем случае плюсы перевешивают минусы, но могут не быть одинаковыми для всех.

Осторожность! 🚨

Следует отметить, что Я не являюсь экспертом в области криптографии, пожалуйста, внимательно изучите информацию и не полагайтесь исключительно на содержание этой статьи.

Я выбрал эту тему потому, что недавно работал над подобной проблемой. Я хотел поделиться своими выводами и получить отзывы (если есть).

Стек технологий

Бэкэнд - это веб-служба RESTful, разработанная с использованием структур Java, Spring, Hibernate, защищенных стандартом OAuth2 и JWT. База данных является реляционной, а интерфейс разработан на Vue.js.

Но почему?

Я признаю, что это кажется неправильным, но это требование. Это также можно рассматривать как дополнительный уровень безопасности к существующим мерам безопасности.

На этапе регистрации пользователя

Цель состоит в том, чтобы сгенерировать DEK (ключ дешифрования) и множество других необходимых byte[] значений, используемых для шифрования / дешифрования данных пользователей.

Мы выполним следующие шаги, когда пользователь зарегистрируется

  1. После регистрации пользователя мы сгенерируем набор CSPRNG в кодировке Base64 определенной длины и назовем их dek (ключ дешифрования), saltForDek (Salt для dek), ivForDek (IV для dek), salForKek и, наконец, ivForKek используется в этой статье для генерации SecretKey с использованием алгоритма PBKDF2 и выполнения encryption/decryption с использованием алгоритма AES
  2. Затем мы воспользуемся алгоритмом PBKDF2 для генерации KEK (ключа шифрования ключа) с использованием пароля пользователя и saltForKek (из # 1) и назовем его kekBasedOnUserPassword
  3. Теперь, когда у нас есть KEK SecretKey, мы воспользуемся алгоритмом шифрования AES, чтобы зашифровать dek (From # 1), используя KEK SecretKey & ivForKek
  4. Теперь, когда у нас encryptedDek используется для encryption/decryption данных, мы можем безопасно хранить их в базе данных для каждого пользователя как cryptographicEntry
{
   “iv”: “cjwe7Q/ZSUIzf64MOKvJbA==”, 
   “dek”:       “iULqm0Cv5vj6yxBQQuHkoOPoNVcxXTiLm2vPWpbaA86VW8Q4rTuCyUs9KU+cJdDs”,  “salt”: “ksSva39weXQdRMQhHQ17BwCJa0s1yBH”, 
“kekIV”: “Oss1I1eOFZuc6mQ33ssWSA==”, 
“kekSalt”: “A+KZGwOsSo4ivsw4xDpN0oF75WfBu8JU”
}

На этапе входа в U ser

Задача состоит в том, чтобы захватить пароль пользователей во время входа в систему, загрузить их cryptographicEntry из базы данных для дешифрования encryptedDek и сгенерировать DEK SecretKey, который будет помещен в память для использования в течение срока действия JWT токена для каждого запроса на шифрование входящих данных и расшифровку исходящих данные.

Я использую OAuth2 для аутентификации и авторизации и выдаю пользователям токен JWT

Вот как выглядит образец JWT

{
  "user_name": "[email protected]",
  "scope": [
    "read",
    "write",
    "trust"
  ],
  "exp": 1530983861,
  "userId": 22,
  "authorities": [
    "STANDARD_USER"
  ],
  "jti": "20991ab2-a55f-4a13-b983-f680050fadd2",
  "client_id": "some_client_id"
}

Без дополнительной оплаты на этапе входа в систему выполняются следующие шаги.

  1. Запрос перехватывается top-level фильтром, который ищет URL входа /oauth/token до того, как запрос достигнет Spring Security layer
  2. Используйте username в базе данных запросов и поиска для User, если найдено, затем перейдите к следующему шагу, если не найден, делегируйте запрос вниз по цепочке фильтров.
  3. Загрузка User от username возвращает cryptographicEntry пользователя
  4. Используйте password пользователя с kekSalt из cryptographicEntry, чтобы сгенерировать kekBasedOnUserPassword (аналогично # 2 этапа регистрации) с использованием алгоритмаPBKDF2 и сохранить его (использовался ConcurrentHashMap с ключом username) в памяти как неполный UserAuthEntry
  5. Делегируйте запрос по цепочке фильтров. Если аутентификация успешна, то Spring OAuth запускает переопределенный CustomTokenEnhancer который extends JwtAccessTokenConvertor где JWT расширяется
  6. Наконец, в CustomTokenEnhancer мы уверены, что аутентификация прошла успешно и у нас есть доступ к JWT ID (jti), JWT токенам expiry date, и для завершения процесса нам нужно получить неполный UserAuthEntry из памяти в decrypt encryptedDek (от пользователя cryptographicEntry), затем скормить decryptedDek saltForDek PBKDF2, чтобы получить DEK SecretKey, и теперь у нас есть все поля, необходимые для создания полного UserAuthEntry для шифрования / дешифрования.

Во время нормальной фазы обслуживания

Цель состоит в том, чтобы encrypt/decrypt во время CRUD операций обрабатывать личную информацию беспрепятственно, чтобы пользователь не заметил никакой разницы в производительности.

Основываясь на моих тестах, генерация CSPRNG (для dek, iv и salt), а также SecretKey занимает примерно полсекунды (~ 500 мс), и, что удивительно, шифрование / дешифрование AES занимает менее ~1ms, что означает

  • Пользователь будет испытывать немного более длительную задержку во время фазы Registration и Login (генерация всех этих CSPRNG и BCrypt хеширования).
  • Все остальные операции, отличные от Registration и Login, не затрагиваются.

Это очень хорошо.

Запрос - Создать клиента (HTTP POST)

Давайте посмотрим, что происходит, когда пользователь создает запись Клиент (имя, фамилия, адрес, номер телефона и т. Д.)

  1. Фильтр top-level перехватывает запрос и проверяет, существует ли пользователь. Если пользователь не найден, делегируйте запрос дальше по цепочке else следующий шаг
  2. Если срок действия токена JWT истек, делегируйте запрос else следующим шагом
  3. Извлеките user_name из токена JWT и убедитесь, что память (т.е. ConcurrentHashMap) содержит запись. Если не найден, мы облажались и останавливаем запрос (т.е. происходит, когда память переполняется из-за перезапуска сервера или по любой другой причине) else делегировать запрос вниз по цепочке
  4. Запрос сопоставляется с CustomerController, создается Customer, затем Customer используется для создания CustomerOutDTO объекта (с полем идентификатора null), затем происходит encryption и, наконец, передается частично зашифрованный объект на уровень службы и DAO для persistence и заполняется возвращено поле id (скажем 42) вoutDTO

После шага 4 база данных содержит частично зашифрованную информацию о клиенте, и пользователь может видеть unencrypted данные о клиенте.

Запрос - получение клиента по идентификатору (HTTP GET)

Допустим, пользователь хочет получить Customer с идентификатором 42 и отправить HTTP GET запрос. Вышеупомянутый шаг 1–3 происходит нормально, и на шаге 4 запрос сопоставляется с CustomerController, и контроллер извлекает объект типа Customer с идентификатором 42, а затем выполняет decryption операцию с объектом. как показано на схеме ниже.

Вам может быть интересно, как происходит encryption/decryption на шаге 4?

Следующим образом

  • CustomerController имеет доступ к объекту HttpServletRequest, следовательно, он извлекает токен JWT из запроса, используя request.getParameter('Authorization'), а затем использует org.springframework.security.jwt.JwtHelper для создания объекта JsonNode.
  • Служебный класс EncryptionDecryptionUtil использует переданный JsonNode для извлечения user_name и использования его для запроса хранилища памяти (т. Е. ConcurrentHashMap) для UserAuthEntry, хранящегося на этапе входа (прокрутите вверх)
  • UserAuthEntry содержит dekSecretKey, а также ivForDek, и это части информации, необходимые для AES шифрования / дешифрования.

Фрагменты кода

Давайте посмотрим на фрагменты кода, используемые на шагах выше для выполнения различных задач.

Если вы пропустили Caution часть этого сообщения, прокрутите вверх, чтобы прочитать

Хранилище для записей аутентификации

Я использую базовый ConcurrentHashMap для хранения сгенерированного JWT токена и UserAuthEntry на этапе входа в систему

@Component
public final class BasicUserLoginStore {
 private ConcurrentHashMap<String, UserAuthEntry> authEntries; 
 private ConcurrentHashMap<String, NotExpiredInvalidJwtEntry> jwtEntries;
 @PostConstruct
 private void init() {
   this.authEntries = new ConcurrentHashMap<>(); 
   this.jwtEntries = new ConcurrentHashMap<>();
 }
 //getters and setters
}

Этот метод не будет работать в мультитенантной / распределенной среде, и есть другие ограничения, поэтому выберите хранилище, которое лучше всего соответствует вашим потребностям.

Фильтр запросов верхнего уровня

Запрос отображается здесь перед тем, как спускаться вниз по Spring Framework слоям, здесь мы будем выполнять необходимую индивидуальную работу. Если мы не сделаем здесь эту работу, у нас не будет доступа к паролю пользователя в цепочке фильтров.

AuthenticationRequestInterceptor имеет doFilter метод, внутри которого у нас есть if else условия, показанные ниже.

//Store the request-url
HttpServletRequest requestTemp = (HttpServletRequest) request; 
String url = requestTemp.getRequestURI(); 

Для всех условий, не относящихся к типу login, register and logout, выполнить

if(!url.contains("/oauth/token") && !url.contains("/logout") &&
   !url.contains("/register")) {
  // 1. Check if token is expired then doFilter(request, response)
  // 2. Check if store.containsLoggedOutJWT ask to login again
  // 3. Check if store.doesNotContainAuthEntry return apology msg
  
   chain.doFilter(request, response);  //otherwise
}

Когда требуется login выполнить следующее else if

else if(url.contains("/oauth/token")) {
  //1. Generate SecretKey using password, and salfForKek
  //2. Store the KEK SecretKey in store mapped to username
  SecretKey secret = cryptoUtil.generateEncryptionKey(password,
     kekSalt);
 
  //Incomplete userAuthEntry
  store.addAuthEntry(user.getUsername(), new UserAuthEntry(secret));
  chain.doFilter(request, response);
 }

Для других значений url запрос передается вниз по цепочке фильтров. Уровень Spring Security берет на себя и аутентифицирует пользователя. Если аутентификация прошла успешно, он создаст JWT и вызовет наш CustomTokenEnhancer, и именно здесь мы будем использовать частичный UserAuthEntry из магазина и преобразовать его в полную запись, вызвав

encryptionDecryptionUtil.populateUserAuthEntry(null, username, jwtId, jwtExpiry);

Причина, по которой у нас есть запись partial в первую очередь (см. Выше), заключается в том, что у нас не было доступа к jwtId и jwtExpiry на уровне фильтра. Вышеупомянутый вызов выполнит следующие фрагменты кода

//cryptoConfig aka cryptographicEntry
CryptoConfig cryptoConfig = user.getCryptoConfig();
UserAuthEntry intialEntry = store.getAuthEntry(username);
//I should use byte[] instead of String, safer
String decryptedDek = cryptoUtil.decrypt(intialEntry.getSecretKey(), cryptoConfig.getKekIV().getBytes(), cryptoConfig.getDek());
SecretKey secretKey = cryptoUtil.generateEncryptionKey(decryptedDek, cryptoConfig.getSalt());
UserAuthEntry finalEntry = new UserAuthEntry(secretKey, cryptoConfig.getIv(), new Timestamp(jwtExpiry),username, jwtId);
store.removeAuthEntry(username);
store.addAuthEntry(username, finalEntry);

Теперь у нас есть SecretKey, основанный на decryptedDek, и он находится в памяти, сопоставленной с username как ключ. Для каждого запроса мы будем извлекать username из JWT и искать userAuthEntry для SecretKey

Служебный класс криптографии и фрагменты кода

Импорт следующий

import java.io.UnsupportedEncodingException;
import java.security.InvalidAlgorithmParameterException;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.security.spec.AlgorithmParameterSpec;
import java.security.spec.InvalidKeySpecException;
import java.security.spec.KeySpec;
import java.util.Base64;
import javax.annotation.PostConstruct;
import javax.crypto.BadPaddingException;
import javax.crypto.Cipher;
import javax.crypto.IllegalBlockSizeException;
import javax.crypto.NoSuchPaddingException;
import javax.crypto.SecretKey;
import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.PBEKeySpec;
import javax.crypto.spec.SecretKeySpec;

Поля sharedstatic final следующие (возможно, ITERATIONS слишком мало, настройте следующее, чтобы удовлетворить ваши потребности)

 private static final String PKBDF2_ALGO = "PBKDF2WithHmacSHA256";
 private static final int ITERATIONS = 10000;
 private static final int HASHBYTES = 256; 
 private static final int SALT_BYTES = 24; 
 private static final int CSPRNG_BYTES = 24; 
 private Cipher cipher = null; 
 private SecretKeyFactory factory = null;

Значения cipher и factory выше инициализируются в @PostConstruct этого служебного класса, а не для каждого запроса (для производительности).

 @PostConstruct
 public void initializeCipher() {
  try {
     cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
     factory = SecretKeyFactory.getInstance(PKBDF2_ALGO);
  } catch (NoSuchAlgorithmException | NoSuchPaddingException e) {
     throw new EncryptionDecryptionFatalException("...", e);
  }
 }

Создайте DEK (ключ дешифрования) с длиной CSPRNG_BYTES и SALT с длиной SALT_BYTES, как показано ниже (по одному для каждого KEK и DEK)

public byte[] generateSecureRandom(int length) {
  SecureRandom random = new SecureRandom();
  byte[] bytes = new byte[length];
  random.nextBytes(bytes);
  return Base64.getEncoder().encode(bytes); 
}

Сгенерируйте IV или вектор инициализации по одному для каждогоKEK и DEK

 public byte[] generateSecureIV() {
  byte[] iv = new byte[cipher.getBlockSize()];
  new SecureRandom().nextBytes(iv);
  return Base64.getEncoder().encode(iv); 
 }

Следующий метод используется для генерации KEK из пароля пользователя, затем KEK используется для encrypt/decrypt DEK, который использовался для encrypt/decrypt данных.

 /*
  * Generate an encryption key using PBKDF2 with given 
  * salt, iterations and hash bytes. 
  */
 public SecretKey generateEncryptionKey(String str, Byte[] salt) {
   char[] strChars = str.toCharArray();
   KeySpec spec = new PBEKeySpec(strChars, salt, 
                                   ITERATIONS, HASHBYTES);
  
   SecretKey key;
   try {
       key = factory.generateSecret(spec);
       return new SecretKeySpec(key.getEncoded(), "AES");
   } catch (InvalidKeySpecException e) {
       e.printStackTrace();
      throw new EncryptionDecryptionFatalException("...", e);
   }  
 }

И, наконец, метод, выполняющий шифрование

 /*
  * Encrypt given toBeEncrypted with passed SecretKey and IV.
  */
 public String encrypt(SecretKey secret, byte[] encodedIV, 
          String toBeEncryped) {
   byte[] iv = Base64.getDecoder().decode(encodedIV); 
   AlgorithmParameterSpec ivspec = new IvParameterSpec(iv);
   byte[] encrypedValue = null; 
  
   try {
     cipher.init(Cipher.ENCRYPT_MODE, secret, ivspec);
     byte[] ciphertext = cipher.doFinal(
                 toBeEncryped.getBytes("UTF-8"));
     encrypedValue = Base64.getEncoder().encode(ciphertext); 
   } catch (InvalidKeyException | 
           InvalidAlgorithmParameterException |
           IllegalBlockSizeException | 
           BadPaddingException | 
           UnsupportedEncodingException e) {
    e.printStackTrace();
    throw new EncryptionDecryptionFatalException("...", e); 
   }
   return new String(encrypedValue);
 }

Возможно, вы могли бы обработать это исключение отдельно, либо сделать что-нибудь для восстановления, либо выдать соответствующие сообщения об ошибках.

Метод decrypt следующий

 /*
  * Decrypt toBeDecrypted using the secret and passed iv. 
  */
  public String decrypt(SecretKey secret, byte[] encodedIV, 
          String toBeDecrypted) {
    byte[] iv = Base64.getDecoder().decode(encodedIV); 
    AlgorithmParameterSpec ivspec = new IvParameterSpec(iv);
    byte[] decryptedValue = null; 
    try {
       cipher.init(Cipher.DECRYPT_MODE, secret, ivspec);
       byte[] decodedValue =  Base64.getDecoder()
                                 .decode(toBeDecrypted.getBytes());
       decryptedValue = cipher.doFinal(decodedValue);
    } catch (InvalidKeyException | 
           InvalidAlgorithmParameterException |   
           IllegalBlockSizeException | 
           BadPaddingException e) {
       e.printStackTrace();
       throw new EncryptionDecryptionFatalException("...", e); 
    }
    return new String(decryptedValue);
 }

Теперь перетащите все это в singleton служебный класс и используйте его по мере необходимости.

Заключение

Вот мои последние мысли, опасения и…

Шифрование / дешифрование на уровне базы данных - Временные надежды 😏

Сначала я изучил встроенные решения, которые Relational Database предоставляют в виде строк, столбцов, таблиц и файлов, и ни одно из них, похоже, не соответствовало цели handing шифрования key для пользователя (владельца) данных.

Только если бы я мог просто сделать это на стороне клиента 🤷‍

Исключив возможность решения на основе базы данных, я решил зашифровать и расшифровать информацию во внешнем интерфейсе, прежде чем отправлять ее на сервер, однако подавляющее большинство статей и ресурсов, которые я читал, советовали не использовать криптографию на стороне клиента (особенно браузер) из-за различных проблем безопасности и отсутствия cryptographically-secure sources of randomness

Свет в конце туннеля - запрос по обмену стеком 🤓

Что ж, я не был готов отказаться или проигнорировать совет против криптографии на стороне клиента, и именно тогда я задал вопрос на Security Stack Exchange, и ответ на мой вопрос дал мне какое-то направление

Какая комбинация значений (итерация, алгоритм, хэш-байты, заполнение и т. Д.) Для криптографии является правильным сочетанием? 😟

Чтобы заставить работать алгоритмы PBKDF2 и AES, возник ряд проблем. Были проблемы с генерацией ключа шифрования с использованием PBKDF2, и мне пришлось настроить ввод, чтобы он заработал. Мне нужно будет больше узнать о том, как эти алгоритмы работают внутри компании.

Как насчет производительности реляционной базы данных со всеми этими частично бессмысленными данными? 📉

Из любопытства, как хранение частично gibberish данных в реляционной и нереляционной базе данных повлияет на производительность базы данных в целом, выполнение запросов и т. Д.? Предполагая, что вы шифруете только поля, которые не играют роли в ссылочной целостности (только реляционной), индексировании, объединениях и т. Д.

Все зашифрованные поля относятся к типу text, и мне интересно, будет ли какая-то разница, объединяя все поля, которые должны быть зашифрованы, в jsonb?

Длина и сложность пароля? забыл пароль? Социальный вход? 😭

Как определить, является ли пароль длинным и достаточно сложным, чтобы его можно было защитить? Особенно, когда он используется в качестве ключа шифрования. Сложный и слишком длинный пароль трудно запомнить, и 123456 слишком часто.

Если пользователь забыл свой пароль, его зашифрованные данные будут useless. Им придется обновить все записи. Интересно, можно ли / идеально подобрать код восстановления? Я еще не исследовал этот путь.

Что касается social login, единственный вариант, который я могу придумать, - это создать dummy password для пользователя и разработать отдельный рабочий процесс для создания UserAuthEntry

Ручное шифрование / дешифрование? АОП? Джексон? 🤦‍

Пусть Jackson обрабатывает шифрование / дешифрование с помощью аннотаций customEncrypt и Decrypt, но я мало знал, что во время фазы сериализации и десериализации у меня нет доступа к HttpServletRequest, особенно в веб-службе Restful и стране Singleton

Исключив Jackson из списка, я решил пойти по пути ручного on-demand шифрования / дешифрования данных на уровне сети / контроллера. Я еще не изучал варианты, можно ли использовать AOP или нет.

Обмен и обратная связь 🤝

Я решил написать эту статью, как только закончу реализацию решения и убедится, что оно работает должным образом. Если у вас есть какие-либо отзывы в виде беспокойства, совета, предложения, пожалуйста, поделитесь им, так как это поможет мне, а также читателям.

Спасибо за чтение.