Статьи

Apache Shiro Part 3 — Криптография

Помимо защиты веб-страниц и управления правами доступа Apache Shiro также выполняет базовые задачи криптографии. Фреймворк способен:

  • шифровать и дешифровать данные,
  • хеш-данные,
  • генерировать случайные числа.

Широ не реализует никаких алгоритмов шифрования. Все вычисления делегируются API расширения криптографии Java (JCE). Основным преимуществом использования Shiro вместо того, что уже присутствует в Java, является простота использования и безопасные настройки по умолчанию. Крипто-модуль Shiro написан на более высоком уровне абстракции и по умолчанию реализует все известные лучшие практики.

Это третья часть серии, посвященная Apache Shiro. Первая часть показала, как защитить веб-приложение и добавить функциональность входа / выхода. Во второй части показано, как хранить учетные записи пользователей в базе данных и дать пользователям возможность аутентифицировать себя с помощью сертификатов PGP.

Этот пост начинается с краткого обзора Shiro и JCE и продолжается описанием нескольких полезных утилит преобразования . В следующих главах объясняется генерация случайных чисел , хеширование и способы шифрования и дешифрования данных. В последней главе показано, как настроить шифр и как создать новый.

обзор

Модуль шифрования Shiro находится в пакете org.apache.shiro.crypto . В нем нет руководства, но, к счастью, все криптографические классы тяжелы для Javadoc. Javadoc содержит все, что будет написано в руководстве.

Широ в значительной степени опирается на расширение криптографии Java. Вам не нужно понимать JCE, чтобы использовать Широ. Тем не менее, вам нужны основы JCE, чтобы настроить его или добавить новые функции. Если вы не заинтересованы в JCE, перейдите к следующей главе.

JCE — это набор настраиваемых API и их реализация по умолчанию. Это на основе провайдера. Если реализация по умолчанию не имеет того, что вам нужно, вы можете легко установить нового поставщика.

Каждый шифр, опция шифрования, алгоритм хеширования или любая другая функция JCE имеет имя. JCE определяет два набора стандартных имен для алгоритмов и режимов алгоритмов. Они доступны с любым JDK. Любой провайдер, например Bouncy Castle , может расширять наборы имен новыми алгоритмами и опциями.

Имена состоят из так называемых строк преобразования, которые используются для поиска необходимых объектов. Например, Cipher.getInstance('DES/ECB/PKCS5Padding') возвращает шифр DES в режиме ECB с заполнением PKCS # 5. Возвращенный шифр обычно требует дальнейшей инициализации, может не использовать безопасные значения по умолчанию и не является потокобезопасным.

Apache Shiro составляет строки преобразования, настраивает полученные объекты и повышает безопасность потоков. Самое главное, он имеет простой в использовании API и добавляет лучшие практики высокого уровня, которые должны быть реализованы в любом случае.

Кодирование, декодирование и ByteSource

Crypto пакет шифрует, дешифрует и хэширует байтовые массивы ( byte[] ). Если вам нужно зашифровать или хэшировать строку, вы должны сначала преобразовать ее в байтовый массив. И наоборот, если вам нужно сохранить хешированное или зашифрованное значение в текстовом файле или столбце строковой базы данных, вы должны преобразовать его в строку.

Текст в байтовый массив

Статический класс CodecSupport способен конвертировать текст в байтовый массив и обратно. Метод byte[] toBytes(String source) преобразует строку в байтовый массив, а метод String toString(byte[] bytes) преобразует ее обратно.

пример

Используйте поддержку кодека для преобразования между текстом и байтовым массивом:

1
2
3
4
5
6
7
8
9
@Test
 public void textToByteArray() {
  String encodeMe = 'Hello, I'm a text.';
 
  byte[] bytes = CodecSupport.toBytes(encodeMe);
  String decoded = CodecSupport.toString(bytes);
 
  assertEquals(encodeMe, decoded);
 }

Кодировать и декодировать байтовые массивы

Преобразование из байтового массива в строку называется кодированием. Обратный процесс называется декодированием. Широ предоставляет два разных алгоритма:

  • Base64 реализован в классе Base64 ,
  • Шестнадцатеричное реализовано в классе Hex .

Оба класса являются статическими, и оба имеют доступные методы encodeToString и encodeToString .

Примеры

Кодируем случайный массив в его шестнадцатеричное представление, декодируем его и проверяем результат:

01
02
03
04
05
06
07
08
09
10
@Test
 public void testStaticHexadecimal() {
  byte[] encodeMe = {2, 4, 6, 8, 10, 12, 14, 16, 18, 20};
   
  String hexadecimal = Hex.encodeToString(encodeMe);
  assertEquals('020406080a0c0e101214', hexadecimal);
   
  byte[] decoded = Hex.decode(hexadecimal);
  assertArrayEquals(encodeMe, decoded);
 }

Кодируем случайный массив в его представление Byte64 , декодируем его и проверяем результат:

01
02
03
04
05
06
07
08
09
10
@Test
 public void testStaticBase64() {
  byte[] encodeMe = {2, 4, 6, 8, 10, 12, 14, 16, 18, 20};
   
  String base64 = Base64.encodeToString(encodeMe);
  assertEquals('AgQGCAoMDhASFA==', base64);
   
  byte[] decoded = Base64.decode(base64);
  assertArrayEquals(encodeMe, decoded);
 }

ByteSource

Пакет криптографии часто возвращает экземпляр интерфейса ByteSource вместо байтового массива. Его реализация SimpleByteSource представляет собой простую обертку вокруг байтового массива с доступными дополнительными методами кодирования:

  • String toHex() — возвращает представление шестнадцатеричного байтового массива,
  • String toBase64() — возвращает представление байтового массива Base64,
  • byte[] getBytes() — возвращает упакованный байтовый массив.

Примеры

Тест использует ByteSource для кодирования массива в его шестнадцатеричное представление. Затем он декодирует его и проверяет результат:

01
02
03
04
05
06
07
08
09
10
11
@Test
 public void testByteSourceHexadecimal() {
  byte[] encodeMe = {2, 4, 6, 8, 10, 12, 14, 16, 18, 20};
   
  ByteSource byteSource = ByteSource.Util.bytes(encodeMe);
  String hexadecimal = byteSource.toHex();
  assertEquals('020406080a0c0e101214', hexadecimal);
   
  byte[] decoded = Hex.decode(hexadecimal);
  assertArrayEquals(encodeMe, decoded);
 }

Используйте Bytesource для кодирования массива в его представление Base64 . Расшифруйте его и проверьте результат:

01
02
03
04
05
06
07
08
09
10
11
@Test
 public void testByteSourceBase64() {
  byte[] encodeMe = {2, 4, 6, 8, 10, 12, 14, 16, 18, 20};
   
  ByteSource byteSource = ByteSource.Util.bytes(encodeMe);
  String base64 = byteSource.toBase64();
  assertEquals('AgQGCAoMDhASFA==', base64);
   
  byte[] decoded = Base64.decode(base64);
  assertArrayEquals(encodeMe, decoded);
 }

Генератор случайных чисел

Генератор случайных чисел состоит из интерфейса RandomNumberGenerator и его реализации по умолчанию SecureRandomNumberGenerator .

Интерфейс довольно прост, у него есть только два метода:

  • ByteSource nextBytes() — генерирует случайный источник байтов фиксированной длины,
  • ByteSource nextBytes(int numBytes) — генерирует случайный источник байтов указанной длины.

Реализация по умолчанию реализует эти два метода и предоставляет некоторые дополнительные настройки:

  • setSeed(byte[] bytes) — пользовательская setSeed(byte[] bytes) конфигурация,
  • setDefaultNextBytesSize(int defaultNextBytesSize) — длина nextBytes() .

Начальное число — это число (фактически, байтовый массив), которое инициализирует генератор случайных чисел. Это позволяет генерировать «предсказуемые случайные числа». Два экземпляра одного и того же генератора случайных чисел, инициализированного одним и тем же начальным числом, всегда генерируют одинаковую последовательность случайных чисел. Это полезно для отладки, но будьте очень осторожны с этим.

Если вы можете, не указывайте пользовательское начальное число для криптографии. Используйте по умолчанию. Если вы действительно не знаете, что делаете, злоумышленник может угадать пользовательский вариант. Это побьет все соображения безопасности случайных чисел.

Изнутри: SecureRandomNumberGenerator делегирует генерацию случайных чисел реализации JCE SecureRandom.

Примеры

Первый пример создает два генератора случайных чисел и проверяет, генерируют ли они две разные вещи:

01
02
03
04
05
06
07
08
09
10
11
12
13
@Test
 public void testRandomWithoutSeed() {
  //create random generators
  RandomNumberGenerator firstGenerator = new SecureRandomNumberGenerator();
  RandomNumberGenerator secondGenerator = new SecureRandomNumberGenerator();
   
  //generate random bytes
  ByteSource firstRandomBytes = firstGenerator.nextBytes();
  ByteSource secondRandomBytes = secondGenerator.nextBytes();
  
  //compare random bytes
  assertByteSourcesNotSame(firstRandomBytes, secondRandomBytes);
 }

Второй пример создает два генератора случайных чисел, инициализирует их одним и тем же начальным числом и проверяет, генерируют ли они одинаковый ожидаемый случайный массив длиной 20 байт:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
@Test
 public void testRandomWithSeed() {
  byte[] seed = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
   
  //create and initialize first random generator
  SecureRandomNumberGenerator firstGenerator = new SecureRandomNumberGenerator();
  firstGenerator.setSeed(seed);
  firstGenerator.setDefaultNextBytesSize(20);
 
  //create and initialize second random generator
  SecureRandomNumberGenerator secondGenerator = new SecureRandomNumberGenerator();
  secondGenerator.setSeed(seed);
  secondGenerator.setDefaultNextBytesSize(20);
 
  //generate random bytes
  ByteSource firstRandomBytes = firstGenerator.nextBytes();
  ByteSource secondRandomBytes = secondGenerator.nextBytes();
  
  //compare random arrays
  assertByteSourcesEquals(firstRandomBytes, secondRandomBytes);
 
  //following nextBytes are also the same
  ByteSource firstNext = firstGenerator.nextBytes();
  ByteSource secondNext = secondGenerator.nextBytes();
 
  //compare random arrays
  assertByteSourcesEquals(firstRandomBytes, secondRandomBytes);
 
  //compare against expected values
  byte[] expectedRandom = {-116, -31, 67, 27, 13, -26, -38, 96, 122, 31, -67, 73, -52, -4, -22, 26, 18, 22, -124, -24};
  assertArrayEquals(expectedRandom, firstNext.getBytes());
 }

Хэш

Хеш- функция принимает произвольные длинные данные в качестве входных данных и преобразует их в данные меньшей фиксированной длины. Результат хеш-функции называется хеш. Хеширование — это односторонняя операция. Невозможно преобразовать хэш обратно в исходные данные.

Важно помнить: всегда храните хэш паролей вместо самого пароля. Никогда не храните его напрямую.

Shiro предоставляет два интерфейса, связанных с хэшем, оба поддерживают две концепции, необходимые для безопасного хэширования паролей: подсчет итераций:

  • Hash — представляет алгоритм хеширования.
  • Hasher — используйте это для хеширования паролей.

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

Укажите количество хеш-итераций, чтобы замедлить хеш-операцию. Чем медленнее операция, тем сложнее взломать сохраненные пароли. Используйте много итераций.

гашиш

Реализации хеш-интерфейса вычисляют хеш-функции. Shiro реализует шесть стандартных хеш-функций: Md2 , Md5 , Sha1 , Sha256 , Sha384 и Sha512 .

Каждая реализация хеша происходит от ByteSource . Конструктор принимает входные данные, соль и количество необходимых итераций. Соль и количество итераций не являются обязательными.

Методы интерфейса ByteSource возвращают:

  • byte[] getBytes() — хеш,
  • String toBase64() — хеш в представлении Base64,
  • String toHex() — хеш в шестнадцатеричном представлении.

Следующий код вычисляет хэш Md5 текста «Hello Md5» без соли:

01
02
03
04
05
06
07
08
09
10
11
@Test
 public void testMd5Hash() {
  Hash hash = new Md5Hash('Hello Md5');
   
  byte[] expectedHash = {-7, 64, 38, 26, 91, 99, 33, 9, 37, 50, -22, -112, -99, 57, 115, -64};
  assertArrayEquals(expectedHash, hash.getBytes());
  assertEquals('f940261a5b6321092532ea909d3973c0', hash.toHex());
  assertEquals('+UAmGltjIQklMuqQnTlzwA==', hash.toBase64());
 
  print(hash, 'Md5 with no salt iterations of 'Hello Md5': ');
 }

Следующий фрагмент вычисляет 10 итераций Sha256 с солью:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
@Test
 public void testIterationsSha256Hash() {
  byte[] salt = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
 
  Hash hash = new Sha256Hash('Hello Sha256', salt, 10);
   
  byte[] expectedHash = {24, 4, -97, -61, 70, 28, -29, 85, 110, 0, -107, -8, -12, -93, -121, 99, -5, 23, 60, 46, -23, 92, 67, -51, 65, 95, 84, 87, 49, -35, -78, -115};
  String expectedHex = '18049fc3461ce3556e0095f8f4a38763fb173c2ee95c43cd415f545731ddb28d';
  String expectedBase64 = 'GASfw0Yc41VuAJX49KOHY/sXPC7pXEPNQV9UVzHdso0=';
   
  assertArrayEquals(expectedHash, hash.getBytes());
  assertEquals(expectedHex, hash.toHex());
  assertEquals(expectedBase64, hash.toBase64());
 
  print(hash, 'Sha256 with salt and 10 iterations of 'Hello Sha256': ');
 }

Сравните итерации, рассчитанные платформой и клиентским кодом:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
@Test
 public void testIterationsDemo() {
  byte[] salt = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
   
  //iterations computed by the framework
  Hash shiroIteratedHash = new Sha256Hash('Hello Sha256', salt, 10);
 
  //iterations computed by the client code
  Hash clientIteratedHash = new Sha256Hash('Hello Sha256', salt);
  for (int i = 1; i < 10; i++) {
   clientIteratedHash = new Sha256Hash(clientIteratedHash.getBytes());
  }
   
  //compare results
  assertByteSourcesEquals(shiroIteratedHash, clientIteratedHash);
 }

Под капотом: все конкретные хеш-классы простираются от SimpleHash который делегирует хеш-вычисления до реализации JCE MessageDigest. Если вы хотите расширить Shiro другой хэш-функцией, скопируйте ее напрямую. Конструктор принимает в качестве параметра имя алгоритма дайджеста (хеша) сообщения JCE.

мясорубка

Хешер работает поверх хеш-функций и внедряет лучшие практики, связанные с засолкой. Интерфейс имеет только один метод:

  • HashResponse computeHash(HashRequest request)

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

Любая реализация хэша может генерировать свою собственную случайную соль. Реализация по умолчанию делает это, только если запрос содержит null соль. Кроме того, использованная соль может состоять из «основной соли» и «поваренной соли». «Общественная соль» возвращается в ответе хэша.

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

Следовательно, «общественная соль» хранится в том же месте, что и пароль, а «базовая соль» хранится в другом месте. Затем атакующему необходимо получить доступ к двум различным местам.

Стандартный хэш настраивается. Вы можете указать базовую соль, количество итераций и алгоритм хеширования. Используйте имя алгоритма хеширования из любой реализации хеширования Широ. Он также всегда возвращает публичную соль из хеш-запроса. Смотрите демо :

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
@Test
 public void fullyConfiguredHasher() {
  ByteSource originalPassword = ByteSource.Util.bytes('Secret');
 
  byte[] baseSalt = {1, 1, 1, 2, 2, 2, 3, 3, 3};
  int iterations = 10;
  DefaultHasher hasher = new DefaultHasher();
  hasher.setBaseSalt(baseSalt);
  hasher.setHashIterations(iterations);
  hasher.setHashAlgorithmName(Sha256Hash.ALGORITHM_NAME);
   
  //custom public salt
  byte[] publicSalt = {1, 3, 5, 7, 9};
  ByteSource salt = ByteSource.Util.bytes(publicSalt);
   
  //use hasher to compute password hash
  HashRequest request = new SimpleHashRequest(originalPassword, salt);
  HashResponse response = hasher.computeHash(request);
   
  byte[] expectedHash = {55, 9, -41, -9, 82, -24, 101, 54, 116, 16, 2, 68, -89, 56, -41, 107, -33, -66, -23, 43, 63, -61, 6, 115, 74, 96, 10, -56, -38, -83, -17, 57};
  assertArrayEquals(expectedHash, response.getHash().getBytes());
 }

Если вам нужно сравнить пароли или контрольные суммы данных, предоставьте «общую соль» обратно к тому же хешу. Это воспроизведет операцию хеширования. В примере используется реализация Shiro DefaultHasher :

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
@Test
 public void hasherDemo() {
  ByteSource originalPassword = ByteSource.Util.bytes('Secret');
  ByteSource suppliedPassword = originalPassword;
  Hasher hasher = new DefaultHasher();
   
  //use hasher to compute password hash
  HashRequest originalRequest = new SimpleHashRequest(originalPassword);
  HashResponse originalResponse = hasher.computeHash(originalRequest);
   
  //Use salt from originalResponse to compare stored password with user supplied password. We assume that user supplied correct password.
  HashRequest suppliedRequest = new SimpleHashRequest(suppliedPassword, originalResponse.getSalt());
  HashResponse suppliedResponse = hasher.computeHash(suppliedRequest);
  assertEquals(originalResponse.getHash(), suppliedResponse.getHash());
   
  //important: the same request hashed twice may lead to different results
  HashResponse anotherResponse = hasher.computeHash(originalRequest);
  assertNotSame(originalResponse.getHash(), anotherResponse.getHash());
 }

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

Шифрование / Дешифрование

Шифр шифрует данные в зашифрованный текст, которые невозможно прочитать без секретного ключа. Шифры делятся на две группы: симметричные и асимметричные. Симметричный шифр использует один и тот же ключ для шифрования и дешифрования. Асимметричный шифр использует два разных ключа, один используется для шифрования, а другой для дешифрования.

Apache Shiro содержит два симметричных шифра: AES и Blowfish . Оба являются безгосударственными и, следовательно, потокобезопасными. Асимметричные шифры не поддерживаются.

Оба шифра способны генерировать случайный ключ шифрования, и оба реализуют интерфейс CipherService . Интерфейс определяет два метода шифрования и два метода дешифрования. Первая группа служит для шифрования / дешифрования байтовых массивов:

  • ByteSource encrypt(byte[] raw, byte[] encryptionKey) ,
  • ByteSource decrypt(byte[] encrypted, byte[] decryptionKey) .

Вторая группа шифрует / дешифрует потоки:

  • encrypt(InputStream in, OutputStream out, byte[] encryptionKey) ,
  • decrypt(InputStream in, OutputStream out, byte[] decryptionKey) .

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

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
@Test
 public void encryptStringMessage() {
  String secret = 'Tell nobody!';
  AesCipherService cipher = new AesCipherService();
   
  //generate key with default 128 bits size
  Key key = cipher.generateNewKey();
  byte[] keyBytes = key.getEncoded();
   
  //encrypt the secret
  byte[] secretBytes = CodecSupport.toBytes(secret);
  ByteSource encrypted = cipher.encrypt(secretBytes, keyBytes);
   
  //decrypt the secret
  byte[] encryptedBytes = encrypted.getBytes();
  ByteSource decrypted = cipher.decrypt(encryptedBytes, keyBytes);
  String secret2 = CodecSupport.toString(decrypted.getBytes());
   
  //verify correctness
  assertEquals(secret, secret2);
 }

Другой отрывок показывает, как шифровать / дешифровать потоки с помощью Blowfish. Шифры Широ не закрывают и не сбрасывают ни входной, ни выходной поток. Вы должны сделать это самостоятельно:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
@Test
 public void encryptStream() {
  InputStream secret = openSecretInputStream();
  BlowfishCipherService cipher = new BlowfishCipherService();
 
  // generate key with default 128 bits size
  Key key = cipher.generateNewKey();
  byte[] keyBytes = key.getEncoded();
 
  // encrypt the secret
  OutputStream encrypted = openSecretOutputStream();
  try {
   cipher.encrypt(secret, encrypted, keyBytes);
  } finally {
   // The cipher does not flush neither close streams.
   closeStreams(secret, encrypted);
  }
 
  // decrypt the secret
  InputStream encryptedInput = convertToInputStream(encrypted);
  OutputStream decrypted = openSecretOutputStream();
  try {
   cipher.decrypt(encryptedInput, decrypted, keyBytes);
  } finally {
   // The cipher does not flush neither close streams.
   closeStreams(secret, encrypted);
  }
 
  // verify correctness
  assertStreamsEquals(secret, decrypted);
 }

Если вы дважды зашифруете один и тот же текст одним и тем же ключом, вы получите два разных зашифрованных текста:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
@Test
 public void unpredictableEncryptionProof() {
  String secret = 'Tell nobody!';
  AesCipherService cipher = new AesCipherService();
 
  // generate key with default 128 bits size
  Key key = cipher.generateNewKey();
  byte[] keyBytes = key.getEncoded();
 
  // encrypt two times
  byte[] secretBytes = CodecSupport.toBytes(secret);
  ByteSource encrypted1 = cipher.encrypt(secretBytes, keyBytes);
  ByteSource encrypted2 = cipher.encrypt(secretBytes, keyBytes);
 
  // verify correctness
  assertArrayNotSame(encrypted1.getBytes(), encrypted2.getBytes());
 }

В обоих предыдущих примерах использовался метод Key generateNewKey() для генерации ключей. Используйте метод setKeySize(int keySize) чтобы переопределить размер ключа по умолчанию (128 бит). Альтернативно, параметр keyBitSize метода Key generateNewKey(int keyBitSize) указывает размер ключа в битах.

Некоторые шифры поддерживают только некоторые размеры ключей. Например , AES поддерживает только 128, 192 и 256-битные ключи журнала:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
@Test(expected=RuntimeException.class)
 public void aesWrongKeySize() {
  AesCipherService cipher = new AesCipherService();
   
  //The call throws an exception. Aes supports only keys of 128, 192, and 256 bits.
  cipher.generateNewKey(200);
 }
 
 @Test
 public void aesGoodKeySize() {
  AesCipherService cipher = new AesCipherService();
  //aes supports only keys of 128, 192, and 256 bits
  cipher.generateNewKey(128);
  cipher.generateNewKey(192);
  cipher.generateNewKey(256);
 }

Что касается основ, вот и все. Вам не нужно больше для шифрования и дешифрования конфиденциальных данных в ваших приложениях.

Обновление: я был чрезмерно оптимистичен здесь. Узнать больше всегда полезно, особенно если вы работаете с конфиденциальными данными. Этот метод в основном, но не совсем безопасный. И проблема, и решение описаны в моем другом посте .

Шифрование / дешифрование — Advanced

Предыдущая глава показала, как зашифровать и расшифровать некоторые данные. В этой главе рассказывается о том, как работает шифрование Shiro и как его настроить. Также показано, как легко добавить новый шифр, если стандартные два не подходят для вас.

Вектор инициализации

Вектор инициализации — это случайно сгенерированный байтовый массив, используемый во время шифрования. Шифр, который использует вектор инициализации, менее предсказуем, и, следовательно, его сложнее расшифровать для атакующего.

Широ автоматически генерирует вектор инициализации и использует его для шифрования данных. Затем вектор объединяется с зашифрованными данными и возвращается в код клиента. Вы можете отключить его, вызвав setGenerateInitializationVectors(false) на шифре. Метод определен в классе JcaCipherService . Оба класса шифрования по умолчанию расширяют его.

Размер вектора инициализации зависит от алгоритма шифрования. Если размер по умолчанию (128 бит) не работает, используйте метод setInitializationVectorSize чтобы настроить его.

Генератор случайных чисел

Отключение вектора инициализации не обязательно означает, что шифр становится предсказуемым. И у Blowfish, и у AES есть элемент случайности.

Следующий пример отключает вектор инициализации, но зашифрованные тексты по-прежнему отличаются:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
@Test
 public void unpredictableEncryptionNoIVProof() {
  String secret = 'Tell nobody!';
  AesCipherService cipher = new AesCipherService();
  cipher.setGenerateInitializationVectors(false);
 
  // generate key with default 128 bits size
  Key key = cipher.generateNewKey();
  byte[] keyBytes = key.getEncoded();
 
  // encrypt two times
  byte[] secretBytes = CodecSupport.toBytes(secret);
  ByteSource encrypted1 = cipher.encrypt(secretBytes, keyBytes);
  ByteSource encrypted2 = cipher.encrypt(secretBytes, keyBytes);
 
  // verify correctness
  assertArrayNotSame(encrypted1.getBytes(), encrypted2.getBytes());
 }

Можно настроить или отключить случайность. Однако никогда не делайте этого в рабочем коде. Случайность является абсолютной необходимостью для безопасного шифрования данных.

Оба алгоритма шифрования Shiro выходят из класса JcaCipherService . Класс имеет setSecureRandom(SecureRandom secureRandom) . Безопасный случай — это стандартный генератор случайных чисел java JCE. Расширьте его, чтобы создать собственную реализацию, и передайте его шифру.

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

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
@Test
 public void predictableEncryption() {
  String secret = 'Tell nobody!';
  AesCipherService cipher = new AesCipherService();
  cipher.setSecureRandom(new ConstantSecureRandom());
  cipher.setGenerateInitializationVectors(false);
 
  // define the key
  byte[] keyBytes = {5, -112, 36, 113, 80, -3, -114, 77, 38, 127, -1, -75, 65, -102, -13, -47};
 
  // encrypt first time
  byte[] secretBytes = CodecSupport.toBytes(secret);
  ByteSource encrypted = cipher.encrypt(secretBytes, keyBytes);
 
  // verify correctness, the result is always the same
  byte[] expectedBytes = {76, 69, -49, -110, -121, 97, -125, -111, -11, -61, 61, 11, -40, 26, -68, -58};
  assertArrayEquals(expectedBytes, encrypted.getBytes());
 }

Постоянная безопасная случайная реализация длинна и неинтересна. Это доступно на Github .

Пользовательский Шифр

Изначально Shiro предоставляет только методы шифрования Blowfish и AES. Фреймворк не реализует свои собственные алгоритмы. Вместо этого он делегирует шифрование классам JCE.

Shiro предоставляет только безопасные настройки по умолчанию и более простой API. Такая конструкция позволяет расширить Shiro любым блочным шифром JCE.

Блочные шифры шифруют сообщения на блоки. Все блоки имеют одинаковый фиксированный размер. Если последний блок слишком короткий, добавляется отступ, чтобы сделать его того же размера, что и все остальные блоки. Каждый блок шифруется и объединяется с ранее зашифрованными блоками.

Поэтому вы должны настроить:

Метод шифрования

Пользовательский шифр расширяет класс DefaultBlockCipherService . Класс имеет только один конструктор с одним параметром: имя алгоритма. Вы можете указать любое имя, совместимое с JCE .

Например, это исходный код шифра AES Shiro:

1
2
3
4
5
6
7
8
9
public class AesCipherService extends DefaultBlockCipherService {
 
    private static final String ALGORITHM_NAME = 'AES';
 
    public AesCipherService() {
        super(ALGORITHM_NAME);
    }
 
}

AES не нужно указывать никаких других параметров шифрования (размер блока, заполнение, метод шифрования). Значения по умолчанию достаточно хороши для AES.

Размер блока

Служба блочного шифрования по умолчанию имеет два метода для настройки размера блока. Метод setBlockSize(int blockSize) работает только для кодирования и декодирования байтового массива. Метод setStreamingBlockSize(int streamingBlockSize) работает только для кодирования и декодирования потока.

Значение 0 означает, что будет использоваться определенный размер блока по умолчанию для алгоритма. Это значение по умолчанию.

Размер блочного шифра зависит от алгоритма. Выбранный алгоритм шифрования может не работать с произвольным размером блока :

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
@Test(expected=CryptoException.class)
 public void aesWrongBlockSize() {
  String secret = 'Tell nobody!';
  AesCipherService cipher = new AesCipherService();
  // set wrong block size
  cipher.setBlockSize(200);
 
  // generate key with default 128 bits size
  Key key = cipher.generateNewKey();
  byte[] keyBytes = key.getEncoded();
 
  // encrypt the secret
  byte[] secretBytes = CodecSupport.toBytes(secret);
  cipher.encrypt(secretBytes, keyBytes);
 }

набивка

Используйте метод setPaddingScheme(PaddingScheme paddingScheme) чтобы указать шифрование байтового массива и расшифровку дополнений. Метод setStreamingPaddingScheme( PaddingScheme paddingScheme) определяет потоковое шифрование и дополнение расшифровки.

Перечисление PaddingScheme представляет все типичные схемы заполнения. Не все из них доступны по умолчанию, для их использования может потребоваться установка собственного поставщика JCE.

Значение null означает, что будет использоваться специфический для алгоритма заполнения по умолчанию. Это значение по умолчанию.

Если вам нужен отступ, не включенный в перечисление PaddingScheme , используйте методы setPaddingSchemeName или setStreamingPaddingSchemeName . Эти методы принимают строку с именем схемы заполнения в качестве параметра. Они менее безопасны по типу, но более гибкие, чем вышеуказанные.

Заполнение очень специфично для алгоритма. Выбранный алгоритм шифрования может не работать с произвольным заполнением :

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
@Test(expected=CryptoException.class)
 public void aesWrongPadding() {
  String secret = 'Tell nobody!';
  BlowfishCipherService cipher = new BlowfishCipherService();
  // set wrong block size
  cipher.setPaddingScheme(PaddingScheme.PKCS1);
 
  // generate key with default 128 bits size
  Key key = cipher.generateNewKey();
  byte[] keyBytes = key.getEncoded();
 
  // encrypt the secret
  byte[] secretBytes = CodecSupport.toBytes(secret);
  cipher.encrypt(secretBytes, keyBytes);
 }

Режим работы

Режим работы определяет, как блоки объединяются (объединяются). Как и в случае схемы заполнения, вы можете использовать перечисление OperationMode или строку для их предоставления.

Будьте осторожны, не каждый режим работы может быть доступен. Кроме того, они не рождаются равными. Некоторые режимы цепочки менее безопасны, чем другие. Стандартный режим работы Cipher Feedback является безопасным и доступен во всех средах JDK.

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

  • setMode(OperationMode mode)
  • setModeName(String modeName)

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

  • setStreamingMode(OperationMode mode)
  • setStreamingModeName(String modeName)

Упражнение — Расшифруйте Openssl

Предположим, что приложение отправляет данные, зашифрованные командой Linux openssl . Мы знаем как шестнадцатеричное представление ключа, так и команду, используемую для шифрования данных:

  • Ключ: B9FAB84B65870109A6E8707BC95151C245BF18204C028A6A .
  • Команда: openssl des3 -base64 -p -K <secret key> -iv <initialization vector> .

Каждое сообщение содержит как шестнадцатеричное представление вектора инициализации, так и зашифрованное сообщение в кодировке base64.

Пример сообщения:

  • Вектор инициализации: F758CEEB7CA7E188 .
  • Сообщение: GmfvxhbYJbVFT8Ad1Xc+Gh38OBmhzXOV .

Создать образец с OpenSSL

Образец сообщения был зашифрован командой:

1
2
#encrypt 'yeahh, that worked!'
echo yeahh, that worked! | openssl des3 -base64 -p -K B9FAB84B65870109A6E8707BC95151C245BF18204C028A6A -iv F758CEEB7CA7E188

Используйте опцию OpenSSL -P для генерации секретного ключа или случайного начального вектора.

Решение

Сначала мы должны выяснить имя алгоритма, отступы и режим работы. К счастью, все три доступны в документации OpenSSL. Des3 — это псевдоним алгоритма тройного шифрования DES в режиме CBC, а OpenSSL использует заполнение PKCS # 5.

Цепочка блоков шифрования (CBC) требует вектор инициализации того же размера, что и размер блока. Triple DES требует блоков длиной 64 бита. Java JCE использует имя алгоритма DESede для Triple DES.

Наш собственный шифр расширяет и настраивает DefaultBlockCipherService :

01
02
03
04
05
06
07
08
09
10
public class OpensslDes3CipherService extends DefaultBlockCipherService {
 
  public OpensslDes3CipherService() {
   super('DESede');
   setMode(OperationMode.CBC);
   setPaddingScheme(PaddingScheme.PKCS5);
   setInitializationVectorSize(64);
  }
   
 }

Метод decrypt шифра Широ ожидает два входных байтовых массива, шифрованный текст и ключ. Зашифрованный текст должен содержать как вектор инициализации, так и зашифрованный зашифрованный текст. Поэтому мы должны объединить их вместе, прежде чем пытаться расшифровать сообщение. Метод combine объединяет два массива в один:

1
2
3
4
5
6
7
8
private byte[] combine(byte[] iniVector, byte[] ciphertext) {
  byte[] ivCiphertext = new byte[iniVector.length + ciphertext.length];
 
  System.arraycopy(iniVector, 0, ivCiphertext, 0, iniVector.length);
  System.arraycopy(ciphertext, 0, ivCiphertext, iniVector.length, ciphertext.length);
 
  return ivCiphertext;
 }

Фактическая расшифровка выглядит как обычно :

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Test
 public void opensslDes3Decryption() {
  String hexInitializationVector = 'F758CEEB7CA7E188';
  String base64Ciphertext = 'GmfvxhbYJbVFT8Ad1Xc+Gh38OBmhzXOV';
  String hexSecretKey = 'B9FAB84B65870109A6E8707BC95151C245BF18204C028A6A';
 
  //decode secret message and initialization vector
  byte[] iniVector = Hex.decode(hexInitializationVector);
  byte[] ciphertext = Base64.decode(base64Ciphertext);
 
  //combine initialization vector and ciphertext together
  byte[] ivCiphertext = combine(iniVector, ciphertext);
   
  //decode secret key
  byte[] keyBytes = Hex.decode(hexSecretKey);
 
  //initialize cipher and decrypt the message
  OpensslDes3CipherService cipher = new OpensslDes3CipherService();
  ByteSource decrypted = cipher.decrypt(ivCiphertext, keyBytes);
   
  //verify result
  String theMessage = CodecSupport.toString(decrypted.getBytes());
  assertEquals('yeahh, that worked!\n', theMessage);
 }

Конец

В этой части руководства Apache Shiro рассматриваются функции криптографии, доступные в версии 1.2. Все используемые примеры доступны на Github .

Ссылка: Apache Shiro Part 3 — Криптография от нашего партнера JCG Марии Юрковичовой в блоге This Is Stuff .