一部の値が暗号化されたプロパティファイルのBeanを丸ごと復号する

# 状況
・**最新には程遠いJavaVM（6以降）**で開発をしている。
・プロパティファイル内で、「パスワード」などの**一部の項目値がOpenSSL(Base64)で暗号化**されている。
・復号されるべき対象項目は**増減**するかもしれない。
・復号対象の項目を一つ一つ**ちまちま復号するのが嫌**だ。

# 目的
復号に手間をかけたくない。
あたかも平文で最初から定義されていたかのようにBeanを操作したい。
つまり、Beanごとドーンとやったらパーンと使いたい。（語彙）

# 参考にさせて頂いた記事
[OpenSSL互換の暗号化をJava/PHPで実現する](https://qiita.com/kazuhidet/items/509bee2c3a109ff6ea61)

# Javaのバージョン
6以降。
本稿ではとりあえず6に準拠します。

# 使用ライブラリ
Base64を使用するので[ApacheCommonsCodec](https://commons.apache.org/proper/commons-codec/)を使用します。
※今回はJava6で使用可能な「[1.11](http://archive.apache.org/dist/commons/codec/binaries/)」を使用します。
※なおJava8以降であれば「java.util.Base64」で代替できるので、当該ライブラリは不要です。

# 登場人物（用意するもの）
#### プロパティファイル
まずはJAXBで変換したいのでXML形式のプロパティファイルです。

#### Encryptedアノテーション
Bean用のカスタムアノテーションです。

#### PropertyBeanクラス
プロパティファイルの値たちが入るBeanです。

#### EncDecConverterクラス
掲題の通り、ドーンとやったらパーンとしてくれるクラスです。

Javaで使用するリソースは以上です。
別途、暗号化・復号に使用するマスターパスワードを脳内で準備しておいてください。
また、暗号化方式は「128bit鍵AES CBCモード」とします。


# 実践

###### １．プロパティファイルを作ります。
`xxUser`と`xxPass`を復号対象とします。
クラスパスが通っているディレクトリに置いておきます。

```xml:property.xml
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<root>
	<!--復号対象「xxUser」-->
	<!--平文「hogeyama」をパスワード「yakisoba」で暗号化した文字列です-->
	<xxUser>U2FsdGVkX1/k+6dPcXrci4AbyQ0TNtytubkVFCxzcF4=</xxUser>
	<!--復号対象「xxPass」-->
	<!--平文「hogeyamanopassword」をパスワード「yakisoba」で暗号化した文字列です-->
	<xxPass>U2FsdGVkX19XLVe01kx2ahoKVKnSXLhBQ2aiRrdUlbjgtKu1IXD3EuYDSADab5vA</xxPass>
	<!--平文-->
	<miscItem1>hoge</miscItem1>
	<!--平文-->
	<miscItem2>fuga</miscItem2>
</root>
```

実際の暗号化文字列はopensslコマンド（下記）、もしくは上述した[参考URL](https://qiita.com/kazuhidet/items/509bee2c3a109ff6ea61)で作成しておいてください。

```sh:bash
$echo 【平文】 | openssl enc -e -aes-128-cbc -base64 -k 【マスターパスワード】
```

###### ２．Encryptedアノテーションを作ります。

```Java:Encryptedアノテーション
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface Encrypted {}
```

###### ３．PropertyBeanを作ります。
この時、暗号化値が入るフィールドにEncryptedアノテーションをつけておきます。

```Java:PropertyBean

public class PropertyBean {
	@Encrypted
	private String xxUser = null;
	@Encrypted
	private String xxPass= null;
	private String miscItem1 = null;
	private String miscItem2 = null;

	public String getXxUser() {
		return xxUser;
	}
	public void setXxUser(String xxUser) {
		this.xxUser = xxUser;
	}
	public String getXxPass() {
		return xxPass;
	}
	public void setXxPass(String xxPass) {
		this.xxPass = xxPass;
	}
	public String getMiscItem1() {
		return miscItem1;
	}
	public void setMiscItem1(String miscItem1) {
		this.miscItem1 = miscItem1;
	}
	public String getMiscItem2() {
		return miscItem2;
	}
	public void setMiscItem2(String miscItem2) {
		this.miscItem2 = miscItem2;
	}

}
```

###### ４．EncDecConverterクラスは以下の通りです。

```Java:EncDecConverterクラス
import java.lang.reflect.Field;
import java.nio.ByteBuffer;
import java.nio.charset.Charset;
import java.security.InvalidAlgorithmParameterException;
import java.security.InvalidKeyException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;

import javax.crypto.BadPaddingException;
import javax.crypto.Cipher;
import javax.crypto.IllegalBlockSizeException;
import javax.crypto.NoSuchPaddingException;
import javax.crypto.SecretKey;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;

import org.apache.commons.codec.binary.Base64;


public class EncDecConverter<T> {
	private T t = null;
	private String password = null;
	private Charset charset = null;

	@SuppressWarnings("unused")
	private EncDecConverter() {}

	public EncDecConverter(T t,String password){
	    this.t = t;
	    this.password = password;
	    charset = Charset.defaultCharset();
	}

	public EncDecConverter(T t,String password,String charsetName){
	    this.t = t;
	    this.password = password;
	    charset = Charset.forName(charsetName);
	}

	public boolean decrypt(){
		return convert(true);
	}

	public boolean encrypt(){
		return convert(false);
	}

	private boolean convert(boolean processDecrypt){
	    if(t == null || password == null){
	        return false;
	    }

	    Field[] fs = t.getClass().getDeclaredFields();
	    String value = "";
	    try {
		    for(Field f : fs){
		        f.setAccessible(true);
		        if(f.getAnnotation(Encrypted.class) != null){
		        	value = (processDecrypt?decrypt((String)f.get(t),password):encrypt((String)f.get(t),password));
		            f.set(t,removeLineSeparator(value));
		        }
		    }
	    }catch(Throwable e) {
	    	e.printStackTrace();
	    	return false;
	    }
	    return true;
	}

	private String removeLineSeparator(String s) {
		if(s == null) {
			return "";
		}
		return s.replaceAll("[\r]*[\n]*$", "");
	}

    private boolean getKeyAndGenerateIv(String password, byte[] salt, byte[] key_bytes, byte[] iv_bytes) {
        byte[] password_bytes = password.getBytes(charset);
        int length = password_bytes.length + salt.length;
        ByteBuffer byte_buffer = ByteBuffer.allocate(length);
        byte_buffer.put(password_bytes);
        byte_buffer.put(salt);
        byte_buffer.rewind();
        byte[] byte_array = new byte[length];
        byte_buffer.get(byte_array);
        try {
        	System.arraycopy(MessageDigest.getInstance("MD5").digest(byte_array), 0, key_bytes, 0, key_bytes.length);
        }catch (NoSuchAlgorithmException e ) {
        	e.printStackTrace();
        	return false;
        }
        length = password_bytes.length + salt.length + key_bytes.length;
        byte_buffer = ByteBuffer.allocate(length);
        byte_buffer.put(key_bytes);
        byte_buffer.put(password_bytes);
        byte_buffer.put(salt);
        byte_buffer.rewind();
        byte_array = new byte[length];
        byte_buffer.get(byte_array);
        try {
        	System.arraycopy(MessageDigest.getInstance("MD5").digest(byte_array), 0, iv_bytes, 0, iv_bytes.length);
        }catch (NoSuchAlgorithmException e ) {
        	e.printStackTrace();
        	return false;
        }

        return true;
    }

    private String encrypt(String plaintext, String password) throws Throwable{
        // Generate random salt.
        byte[] random_bytes = new byte[8];
        new SecureRandom().nextBytes(random_bytes);

        byte[] key_bytes = new byte[16];
        byte[] iv_bytes = new byte[16];
        getKeyAndGenerateIv(password, random_bytes, key_bytes, iv_bytes);

        SecretKey secret = new SecretKeySpec(key_bytes, "AES");
        IvParameterSpec ivspec = new IvParameterSpec(iv_bytes);
        Cipher cipher = null;
        try {
        	cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
        }catch(NoSuchPaddingException e) {
        	throw e;
        }catch(NoSuchAlgorithmException e) {
        	throw e;
        }

        try {
        	cipher.init(Cipher.ENCRYPT_MODE, secret, ivspec);
        }catch(InvalidKeyException e) {
        	throw e;
        }catch(InvalidAlgorithmParameterException e) {
        	throw e;
        }

        byte[] encrypted_bytes;
        try {
        	encrypted_bytes = cipher.doFinal(plaintext.getBytes(charset));
	    }catch(IllegalBlockSizeException e) {
	    	throw e;
	    }catch(BadPaddingException e) {
	    	throw e;
	    }

        final String header_string = "Salted__";
        byte[] header_bytes = header_string.getBytes(charset);
        int length = header_bytes.length + random_bytes.length + encrypted_bytes.length;
        ByteBuffer byte_buffer = ByteBuffer.allocate(length);
        byte_buffer.put(header_bytes);
        byte_buffer.put(random_bytes);
        byte_buffer.put(encrypted_bytes);
        byte_buffer.rewind();
        byte[] byte_array = new byte[length];
        byte_buffer.get(byte_array);

        return new String(Base64.encodeBase64(byte_array));
        //Java8以降の場合は以下の通り記載可能です。
        //return new String(Base64.getEncoder().encodeToString(byte_array));
    }

    private String decrypt(String payload, String password) throws Throwable{
    	byte[] payload_bytes = Base64.decodeBase64(payload.getBytes(charset));
        //Java8以降の場合は以下の通り記載可能です。
        //byte[] payload_bytes = Base64.getDecoder().decode(payload.getBytes(StandardCharsets.UTF_8));
        byte[] header_bytes = new byte[8];
        byte[] salt_bytes = new byte[8];
        int length = payload_bytes.length;
        ByteBuffer byte_buffer = ByteBuffer.allocate(length);
        byte_buffer.put(payload_bytes);
        byte_buffer.rewind();
        byte_buffer.get(header_bytes);
        byte_buffer.get(salt_bytes);
        length = payload_bytes.length - header_bytes.length - salt_bytes.length;
        byte[] data_bytes = new byte[length];
        byte_buffer.get(data_bytes);

        byte[] key_byte = new byte[16];
        byte[] iv_bytes = new byte[16];
        getKeyAndGenerateIv(password, salt_bytes, key_byte, iv_bytes);

        SecretKey secret = new SecretKeySpec(key_byte, "AES");
        IvParameterSpec ivspec = new IvParameterSpec(iv_bytes);
        Cipher cipher = null;
        try {
        	cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
        }catch(NoSuchPaddingException e) {
        	throw e;
        }catch(NoSuchAlgorithmException e) {
        	throw e;
        }

        try {
        	cipher.init(Cipher.DECRYPT_MODE, secret, ivspec);
        }catch(InvalidKeyException e) {
        	throw e;
        }catch(InvalidAlgorithmParameterException e) {
        	throw e;
        }

        byte[] decrypted;
        try {
        	decrypted = cipher.doFinal(data_bytes);
        }catch(IllegalBlockSizeException e) {
        	throw e;
        }catch(BadPaddingException e) {
        	throw e;
        }

        return new String(decrypted);
    }
}
```

###### ５．テストクラスを作成して動作確認します。

```Java:EncDecTest
import java.io.InputStream;

import javax.xml.bind.JAXB;

public class EncDecTest {
	public static void main(String[] args) {
		EncDecTest t = new EncDecTest();
		t.execute();
	}

	public void execute() {
		InputStream is = this.getClass().getClassLoader().getResourceAsStream("property.xml");
		PropertyBean prop = JAXB.unmarshal(is, PropertyBean.class);
		EncDecConverter<PropertyBean> c = new EncDecConverter<PropertyBean>(prop,"yakisoba","UTF-8");
		if(!c.decrypt()) {
			System.err.println("error.");
			System.exit(1);
		}
		System.out.println(prop.getXxUser()); //hogeyamaを出力
		System.out.println(prop.getXxPass()); //hogeyamanopasswordを出力
		System.out.println(prop.getMiscItem1()); //hogeを出力
		System.out.println(prop.getMiscItem2()); //fugaを出力
		System.exit(0);
	}
}
```

decryptメソッドの結果がtrueであれば、
引数として渡したPropertyBeanインスタンスにおいて
**復号対象としたフィールド**が復号されています。
あとは普通にBeanからgetして使うだけです。

なお、上記の通り、EncDecConverterコンストラクタへの引数として
マスターパスワードを平文で渡す必要がありますが、
この前段として**マスターパスワードをどのように管理しておきどうやってI/Fしていくべきか？**
については本稿では触れません。

# まとめ
「テキスト丸ごと」ではなく部分的にOpenSSL暗号化文字列を運用していくパターンもあると思うんですよね。（それが正解かどうかは置いておいて）
なのでそんなTipsをまとめてみました。
あと、リフレクションを下手に多用するのはよくないと思いつつ、便利だから使っちゃう。