Java 標準API でパスワードベースの暗号化・複合化


はじめに

パスワードで暗号化/複合化のサンプルをメモ。

package org.example;

import javax.crypto.Cipher;
import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.PBEKeySpec;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.Key;
import java.security.SecureRandom;
import java.util.Arrays;
import java.util.Base64;

public class App {

    private static final String ENCRYPTION_ALGORITHM = "AES/CBC/PKCS5Padding";
    private static final int SALT_LEN = 16;
    private static final int IV_LEN   = 16;

    private static Key buildSecretKey(byte[] salt) throws Exception {

        char[] password = new char[] { 'p', 'a', 's', 's' };
        PBEKeySpec spec = new PBEKeySpec(password, salt, /* iterationCount */1000, /* keyLength */256);
        Arrays.fill(password, ' ');

        SecretKeyFactory factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256");
        return factory.generateSecret(spec);
    }

    public static String encrypt(String plainText) throws Exception {

        SecureRandom random = new SecureRandom();
        byte[] salt = new byte[SALT_LEN];
        byte[] iv   = new byte[IV_LEN];
        random.nextBytes(salt);
        random.nextBytes(iv);

        Cipher cipher = Cipher.getInstance(ENCRYPTION_ALGORITHM);
        Key key = new SecretKeySpec(buildSecretKey(salt).getEncoded(), "AES");
        cipher.init(Cipher.ENCRYPT_MODE, key, new IvParameterSpec(iv));

        byte[] encrypted = cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8));

        byte[] combined = new byte[salt.length + iv.length + encrypted.length];
        System.arraycopy(salt, 0, combined, 0, salt.length);
        System.arraycopy(iv, 0, combined, salt.length, iv.length);
        System.arraycopy(encrypted, 0, combined, salt.length + iv.length, encrypted.length);

        return Base64.getEncoder().encodeToString(combined);
    }

    public static String decrypt(String encryptedText) throws Exception {

        byte[] combined = Base64.getDecoder().decode(encryptedText);

        byte[] salt = new byte[SALT_LEN];
        byte[] iv   = new byte[IV_LEN];
        byte[] encrypted = new byte[combined.length - salt.length - iv.length];
        System.arraycopy(combined, 0, salt, 0, salt.length);
        System.arraycopy(combined, salt.length, iv, 0, iv.length);
        System.arraycopy(combined, salt.length + iv.length, encrypted, 0, encrypted.length);

        Cipher cipher = Cipher.getInstance(ENCRYPTION_ALGORITHM);
        Key key = new SecretKeySpec(buildSecretKey(salt).getEncoded(), "AES");
        cipher.init(Cipher.DECRYPT_MODE, key, new IvParameterSpec(iv));
        byte[] decrypted = cipher.doFinal(encrypted);

        return new String(decrypted, StandardCharsets.UTF_8);

    }

    public static void main(String[] args) throws Exception {
        System.out.println(decrypt(encrypt("test")));  // test
    }

}


秘密鍵の生成

パスワードによる暗号化・複合化には、PBEKeySpec (Password Based Encryption) を使う。

人間が扱うパスワードは、鍵として脆弱なので、パスワードとランダム値であるソルトから秘密鍵を作成する。ソルトを使用することでレインボーテーブル攻撃(辞書総当たりを効率良く行う攻撃手法)。

ソルトは、予測不可能性を持った疑似乱数生成器である SecureRandom から生成する。

SecureRandom random = new SecureRandom(); 
byte[] salt = new byte[SALT_LEN];
random.nextBytes(salt); 

パスワード(ここではpass)とソルトから秘密鍵の仕様を作成する。

char[] password = new char[] { 'p', 'a', 's', 's' };
PBEKeySpec spec = new PBEKeySpec(password, salt, 1000, 256);
Arrays.fill(password, ' ');

ここでは、イテレーション回数を1,000、鍵長を256という鍵仕様としている。イテレーション回数は1,000以上、鍵長は128以上、さらに言えばソルトは64ビット(もしくは128ビット)が推奨されているようだ。

パスワードは早めにクリアしておきたいため、使用後に空文字で埋めている(Stringfinal でクリアできないため、char[] として扱われる)


ファクトリに秘密鍵の仕様を渡すことで、秘密鍵を生成する。

SecretKeyFactory factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256");
return factory.generateSecret(spec);

PBKDF2 (Password-Based Key Derivation Function 2) は PBKDF1 の後継の鍵導出関数で、擬似乱数関数を用いて鍵を生成するアルゴリズム

HmacSHA256 は Hash-based Message Authentication Code(ハッシュベースメッセージ認証コード)のMAC 値の計算にハッシュ関数 SHA-256 を利用する指定


暗号化

暗号機能は、Cipher に "algorithm/mode/padding" を指定してプロバイダを取得する。

Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");

AES Advanced Encryption Standard はDESの後継の、共通鍵暗号アルゴリズム

CBC Cipher Block Chaining(暗号ブロック連鎖モード)を指定

PKCS5Padding はデータの長さがブロックの長さに足りない部分をパディングする方式を指定

ブロック暗号(DES、AESなど)は、データを決まった長さの「ブロック」単位で処理する。 CBC は、ブロック毎の暗号化で、今回のブロックに前のブロックを連鎖(XOR)させて暗号化するモード。最初のブロックでは、前のブロックが存在しないので、初期化ベクトル(Initialization Vector)を使う。


秘密鍵を "AES" を指定した SecretKeySpec としてインスタンス化。

Key key = new SecretKeySpec(buildSecretKey(salt).getEncoded(), "AES");

暗号化は cipher を、モードと秘密鍵、初期化ベクトル(Initialization Vector)を指定して doFinal() するだけ。

cipher.init(Cipher.ENCRYPT_MODE, key, new IvParameterSpec(iv));
byte[] encryptedBytes = cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8));


ここでは、暗号化されたバイト配列は、ソルトとIVと共に Base64 エンコードしている。

byte[] combined = new byte[salt.length + iv.length + encrypted.length];
System.arraycopy(salt, 0, combined, 0, salt.length);
System.arraycopy(iv, 0, combined, salt.length, iv.length);
System.arraycopy(encrypted, 0, combined, salt.length + iv.length, encrypted.length);

return Base64.getEncoder().encodeToString(combined);


複合化

Base64 デコードしてソルトとIV、暗号文を取り出し、

byte[] combined = Base64.getDecoder().decode(encryptedText);

byte[] salt = new byte[SALT_LEN];
byte[] iv   = new byte[IV_LEN];
byte[] encrypted = new byte[combined.length - salt.length - iv.length];
System.arraycopy(combined, 0, salt, 0, salt.length);
System.arraycopy(combined, salt.length, iv, 0, iv.length);
System.arraycopy(combined, salt.length + iv.length, encrypted, 0, encrypted.length);

暗号化と同じで、Cipher を使い、モードとして複合 Cipher.DECRYPT_MODE を使うだけ。

Cipher cipher = Cipher.getInstance(ENCRYPTION_ALGORITHM);
Key key = new SecretKeySpec(buildSecretKey(salt).getEncoded(), "AES");
cipher.init(Cipher.DECRYPT_MODE, key, new IvParameterSpec(iv));
byte[] decrypted = cipher.doFinal(encrypted);


まとめ

こんなん誰が覚えてられるん?