Статьи

Создание ключей шифрования на основе пароля

В этой статье обсуждается создание ключей PBE на основе пароля.

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

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

Я буду обсуждать использование рабочих ключей в шифровании базы данных в следующей статье.

Генерация ключа шифрования на основе пароля с помощью PBKDF2WithHmacSHA1

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

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

Этот алгоритм принимает четыре параметра. Первая длина ключа — используйте 128 для ключей AES. Другие возможные значения: 192 и 256 бит. Второе число итераций. Ваш Wi-Fi-маршрутизатор использует 4096 итераций, но многие сейчас рекомендуют не менее 10 000 итераций.

Третий параметр — «соль». Маршрутизатор Wi-Fi использует SSID, многие сайты используют небольшой файл, и я расскажу о другом подходе ниже. Соль должна быть достаточно большой, чтобы энтропия превышала длину ключа. Например, если вам нужен 128-битный ключ, вы должны иметь (как минимум) 128 бит случайных двоичных данных или около 22 случайных буквенно-цифровых символов.

Последний параметр — пароль. Опять же энтропия должна быть больше, чем длина ключа. В веб-приложении пароль часто предоставляется сервером приложений через JNDI.

Наконец, нам нужен и ключ шифрования, и IV, а не просто ключ шифрования. Отсутствие IV или использование слабого — одна из самых распространенных ошибок, совершаемых людьми, незнакомыми с криптографией. (См .: Не использовать случайный вектор инициализации в режиме цепочки блоков шифрования [owasp.org].) Один из распространенных подходов — генерировать случайную соль и добавлять ее к зашифрованному тексту для использования во время расшифровки, но я расскажу о другом подходе, который использует пароль и соль.

Теперь код. Сначала мы увидим, как создать ключ шифрования и IV из пароля и соли. (Мы обсудим соль через минуту.)

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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
public class PbkTest {
    private static final Provider bc = new BouncyCastleProvider();
    private static final ResourceBundle BUNDLE = ResourceBundle
            .getBundle(PbkTest.class.getName());
    private SecretKey cipherKey;
    private AlgorithmParameterSpec ivSpec;
 
    /**
     * Create secret key and IV from password.
     *
     * Implementation note: I've believe I've seen other code that can extract
     * the random bits for the IV directly from the PBEKeySpec but I haven't
     * been able to duplicate it. It might have been a BouncyCastle extension.
     *
     * @throws Exception
     */
    public void createKeyAndIv(char[] password) throws SecurityException,
            NoSuchAlgorithmException, InvalidKeySpecException {
        final String algorithm = "PBKDF2WithHmacSHA1";
        final SecretKeyFactory factory = SecretKeyFactory
                .getInstance(algorithm);
        final int derivedKeyLength = 128;
        final int iterations = 10000;
 
        // create salt
        final byte[][] salt = feistelSha1Hash(createSalt(), 1000);
 
        // create cipher key
        final PBEKeySpec cipherSpec = new PBEKeySpec(password, salt[0],
                iterations, derivedKeyLength);
        cipherKey = factory.generateSecret(cipherSpec);
        cipherSpec.clearPassword();
 
        // create IV. This is just one of many approaches. You do
        // not want to use the same salt used in creating the PBEKey.
        try {
            final Cipher cipher = Cipher.getInstance("AES/CBC/NoPadding", bc);
            cipher.init(Cipher.ENCRYPT_MODE, cipherKey, new IvParameterSpec(
                    salt[1], 0, 16));
            ivSpec = new IvParameterSpec(cipher.doFinal(salt[1], 4, 16));
        } catch (NoSuchPaddingException e) {
            throw new SecurityException("unable to create IV", e);
        } catch (InvalidAlgorithmParameterException e) {
            throw new SecurityException("unable to create IV", e);
        } catch (InvalidKeyException e) {
            throw new SecurityException("unable to create IV", e);
        } catch (BadPaddingException e) {
            throw new SecurityException("unable to create IV", e);
        } catch (IllegalBlockSizeException e) {
            throw new SecurityException("unable to create IV", e);
        }
    }
}

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

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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
/**
     * Create salt. Two values are provided to support creation of both a cipher
     * key and IV from a single password.
     *
     * The 'left' salt is pulled from a file outside of the app context. this
     * makes it much harder for a compromised app to obtain or modify this
     * value. You could read it as classloader resource but that's not really
     * different from the properties file used below. Another possibility is to
     * load it from a read-only value in a database, ideally one with a
     * different schema than the rest of the application. (It could even be an
     * in-memory database such as H2 that contains nothing but keying material,
     * again initialized from a file outside of the app context.)
     *
     * The 'right' salt is pulled from a properties file. It is possible to use
     * a base64-encoded value but administration is a lot easier if we just take
     * an arbitrary string and hash it ourselves. At a minimum it should be a
     * random mix-cased string of at least (120/5 = 24) characters.
     *
     * The generated salts are equally strong.
     *
     * Implementation note: since this is for demonstration purposes a static
     * string in used in place of reading an external file.
     */
    public byte[][] createSalt() throws NoSuchAlgorithmException {
        final MessageDigest digest = MessageDigest.getInstance("SHA1");
        final byte[] left = new byte[20]; // fall back to all zeroes
        final byte[] right = new byte[20]; // fall back to all zeroes
 
        // load value from file or database.
        // note: we use fixed value for demonstration purposes.
        final String leftValue = "this string should be read from file or database";
        if (leftValue != null) {
            System.arraycopy(digest.digest(leftValue.getBytes()), 0, left, 0,
                    left.length);
            digest.reset();
        }
 
        // load value from resource bundle.
        final String rightValue = BUNDLE.getString("salt");
        if (rightValue != null) {
            System.arraycopy(digest.digest(rightValue.getBytes()), 0, right, 0,
                    right.length);
            digest.reset();
        }
 
        final byte[][] salt = feistelSha1Hash(new byte[][] { left, right },
                1000);
 
        return salt;
    }

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

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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
/**
     * Create salt. Two values are provided to support creation of both a cipher
     * key and IV from a single password.
     *
     * The 'left' salt is pulled from a file outside of the app context. this
     * makes it much harder for a compromised app to obtain or modify this
     * value. You could read it as classloader resource but that's not really
     * different from the properties file used below. Another possibility is to
     * load it from a read-only value in a database, ideally one with a
     * different schema than the rest of the application. (It could even be an
     * in-memory database such as H2 that contains nothing but keying material,
     * again initialized from a file outside of the app context.)
     *
     * The 'right' salt is pulled from a properties file. It is possible to use
     * a base64-encoded value but administration is a lot easier if we just take
     * an arbitrary string and hash it ourselves. At a minimum it should be a
     * random mix-cased string of at least (120/5 = 24) characters.
     *
     * The generated salts are equally strong.
     *
     * Implementation note: since this is for demonstration purposes a static
     * string in used in place of reading an external file.
     */
    public byte[][] createSalt() throws NoSuchAlgorithmException {
        final MessageDigest digest = MessageDigest.getInstance("SHA1");
        final byte[] left = new byte[20]; // fall back to all zeroes
        final byte[] right = new byte[20]; // fall back to all zeroes
 
        // load value from file or database.
        // note: we use fixed value for demonstration purposes.
        final String leftValue = "this string should be read from file or database";
        if (leftValue != null) {
            System.arraycopy(digest.digest(leftValue.getBytes()), 0, left, 0,
                    left.length);
            digest.reset();
        }
 
        // load value from resource bundle.
        final String rightValue = BUNDLE.getString("salt");
        if (rightValue != null) {
            System.arraycopy(digest.digest(rightValue.getBytes()), 0, right, 0,
                    right.length);
            digest.reset();
        }
 
        final byte[][] salt = feistelSha1Hash(new byte[][] { left, right },
                1000);
 
        return salt;
    }

Наконец, мы можем увидеть это на практике в двух тестовых методах:

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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
/**
     * Obtain password. Architectually we'll want good "separation of concerns"
     * and we should get the cipher key and IV from a separate place than where
     * we use it.
     *
     * This is a unit test so the password is stored in a properties file. In
     * practice we'll want to get it from JNDI from an appserver, or at least a
     * file outside of the appserver's directory.
     *
     * @throws Exception
     */
    @Before
    public void setup() throws Exception {
        createKeyAndIv(BUNDLE.getString("password").toCharArray());
    }
 
    /**
     * Test encryption.
     *
     * @throws Exception
     */
    @Test
    public void testEncryption() throws Exception {
        String plaintext = BUNDLE.getString("plaintext");
 
        Cipher cipher = Cipher.getInstance(BUNDLE.getString("algorithm"), bc);
        cipher.init(Cipher.ENCRYPT_MODE, cipherKey, ivSpec);
        byte[] actual = cipher.doFinal(plaintext.getBytes());
        assertEquals(BUNDLE.getString("ciphertext"),
                new String(Base64.encode(actual), Charset.forName("UTF-8")));
    }
 
    /**
     * Test decryption.
     *
     * @throws Exception
     */
    @Test
    public void testEncryptionAndDecryption() throws Exception {
        String ciphertext = BUNDLE.getString("ciphertext");
 
        Cipher cipher = Cipher.getInstance(BUNDLE.getString("algorithm"), bc);
        cipher.init(Cipher.DECRYPT_MODE, cipherKey, ivSpec);
        byte[] actual = cipher.doFinal(Base64.decode(ciphertext));
 
        assertEquals(BUNDLE.getString("plaintext"),
                new String(actual, Charset.forName("UTF-8")));
    }