もなかアイスの試食品

「とりあえずやってみたい」そんな気持ちが先走りすぎて挫折が多い私のメモ書きみたいなものです.

SpringBootで暗号化・復号処理の実装

はじめに

DBにデータを保存する際に、セキュリティ向上のため暗号化してDBに保存することにした。

目次

参考サイト

qiita.com

blog.hide92795.org

実装

設定値の追加

application.ymlに暗号化に使用するキーの設定値を追加した

my_constants:
  security:
    key: (英数大文字小文字・数字・記号を使った16文字)

キーは16文字で、正直決めるのが面倒くさかったので、以下のサイトでランダム生成した

www.luft.co.jp

Beanの追加

暗号化・復号処理で使うインスタンスをBeanで用意する

@Configuration
public class CommonConfig {

    @Bean
    public SecretKeySpec secretKeySpec(@Value("${my-constants.security.key}") String key) {
        return new SecretKeySpec(key.getBytes(), "AES");
    }

    @Bean
    public Cipher cipher()
        throws NoSuchPaddingException, NoSuchAlgorithmException {
        return Cipher.getInstance("AES/CBC/PKCS5Padding");
    }
}

ログ出力

ログ出力にデフォルト実装を使った

/**
 * ログを出力する機能を実装する
 *
 * ログを出力したいクラスで、このインターフェースを実装する<br/>
 *
 * <pre>
 *     class Foo implements Loggable {
 *         public void bar() {
 *             logger().debug(&quot;Hello world!!!&quot;);
 *         }
 *     }
 * </pre>
 */
public interface Loggable {

    /**
     * デフォルト実装
     *
     * @return ロガー
     */
    default Logger logger() {
        return LoggerFactory.getLogger(this.getClass());
    }

}

暗号化・復号処理クラスの作成

実際の暗号化・復号化処理は以下の通り

@Component
public class Crypt implements Loggable {

    private final SecretKeySpec secretKeySpec;

    private final Cipher cipher;

    // 暗号化・復号処理が同時に呼び出されたときに、Cipher#initを実行しないようにするためのロック
    // 実際に同時にCipher#initした時にどうなるかは未確認。良くないことが起きそう気がするのでロックしている
    // cipherを暗号化用/復号用でインスタンスを分けるのもアリかも
    private final Object lockObj = new Object();

    @Autowired
    public Crypt(SecretKeySpec secretKeySpec, Cipher cipher) {
        this.secretKeySpec = secretKeySpec;
        this.cipher = cipher;
    }

    /**
     * 暗号化する
     *
     * @param str 暗号化する文字列
     * @return 暗号化した文字列
     */
    public String encrypt(String str) {
        synchronized (this.lockObj) {
            this.setEncryptMode();
            byte[] encodedBytes;
            try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
                // 最初に初期ベクトルを出力
                outputStream.write(this.cipher.getIV());
                // ByteArrayOutputStream#toByteArray実行前に、CipherOutputStreamを先にcloseしないと復号化に失敗する
                try (CipherOutputStream cipherOutputStream = new CipherOutputStream(outputStream, this.cipher)) {
                    cipherOutputStream.write(str.getBytes());
                }
                encodedBytes = outputStream.toByteArray();
            } catch (IOException e) {
                this.logger().error("failed cipher.");
                throw new CryptException(e);
            }

            byte[] base64Bytes = Base64.getEncoder().encode(encodedBytes);
            return new String(base64Bytes);
        }
    }

    /**
     * 復号する
     *
     * @param str 復号する文字列
     * @return 復号した文字列
     */
    public String decrypt(String str) {
        synchronized (this.lockObj) {
            byte[] base64Bytes = Base64.getDecoder().decode(str);
            try (ByteArrayInputStream inputStream = new ByteArrayInputStream(base64Bytes)) {
                byte[] iv = getIVForDecode(inputStream);
                this.setDecryptMode(iv);
                return this.decryptCipherStream(inputStream);
            } catch (IOException e) {
                this.logger().error("failed decode.");
                throw new CryptException(e);
            }
        }
    }

    /**
     * 暗号化モードに設定する
     */
    private void setEncryptMode() {
        try {
            this.cipher.init(Cipher.ENCRYPT_MODE, this.secretKeySpec);
        } catch (InvalidKeyException e) {
            this.logger().error("failed initialize cipher.");
            throw new CryptException(e);
        }
    }

    /**
     * 復号モードに設定する
     */
    private void setDecryptMode(byte[] iv) {
        IvParameterSpec ivParameterSpec = new IvParameterSpec(iv);
        try {
            this.cipher.init(Cipher.DECRYPT_MODE, this.secretKeySpec, ivParameterSpec);
        } catch (InvalidKeyException | InvalidAlgorithmParameterException e) {
            this.logger().error("failed initialize cipher.");
            throw new CryptException(e);
        }
    }

    /**
     * 暗号化されたストリームを復号する
     *
     * @param inputStream ストリーム
     * @return 復号されたデータ
     */
    private String decryptCipherStream(ByteArrayInputStream inputStream) {
        try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
            try (CipherInputStream cipherInputStream = new CipherInputStream(inputStream, this.cipher)) {
                byte[] buffer = new byte[1024];
                int length;
                while ((length = cipherInputStream.read(buffer)) != -1) {
                    outputStream.write(buffer, 0, length);
                }
            }
            return outputStream.toString();
        } catch (IOException e) {
            this.logger().error("failed decode.");
            throw new CryptException(e);
        }
    }

    /**
     * 暗号化されたデータから初期ベクトルを取得する。
     *
     * @param inputStream 暗号化データのストリーム
     * @return 初期ベクトル
     */
    private static byte[] getIVForDecode(ByteArrayInputStream inputStream) {
        byte[] iv = new byte[16];
        for (int i = 0; i < iv.length; ++i) {
            iv[i] = (byte)inputStream.read();
        }
        return iv;
    }

}

例外クラス

暗号化・復号失敗時の例外クラス

@ResponseStatus(code = HttpStatus.INTERNAL_SERVER_ERROR)
public class CryptException extends RuntimeException {

    public CryptException() {
    }

    public CryptException(String message) {
        super(message);
    }

    public CryptException(String message, Throwable cause) {
        super(message, cause);
    }

    public CryptException(Throwable cause) {
        super(cause);
    }

    public CryptException(String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace) {
        super(message, cause, enableSuppression, writableStackTrace);
    }
}

テスト

適当にテストコードを書いてみた。

10万回ほどランダムな文字列を作成して、暗号化→復号して文字列が一致するかテスト

何回か実行して大丈夫だった。

@RunWith(SpringRunner.class)
@SpringBootTest
public class CryptTests implements Loggable {

    @Autowired
    private Crypt testTarget;

    @Test
    public void cryptTest() {
        IntStream.rangeClosed(1, 100_000)
            .forEach(i -> {
                int randomNumber = Integer.parseInt(RandomStringUtils.randomNumeric(1, 5));
                String rawText = RandomStringUtils.randomAlphanumeric(randomNumber);
                this.logger().info(String.format("rawText text:%s", rawText));

                String encodedText = this.testTarget.encrypt(rawText);
                this.logger().info(String.format("encoded text:%s", encodedText));

                String decodedText = this.testTarget.decrypt(encodedText);
                assertEquals(rawText, decodedText);
            });
    }

}

おわりに

取り敢えず暗号化する処理はできた。

暗号化するとデータが増加するので、暗号化前のデータを圧縮した方が良いと何処かで見かけたので、ZipOutputStreamとかGZIPOutputStreamを使って見たけど、上手く行かなかった・・・