Javaで任意のファイルを画像ファイルに偽装（暗号化）

# 1. はじめに

みなさんは隠したいファイルとか無いですか？
~~Dドライブにあるファイルとか~~

ここでは簡単に画像ファイルに偽装（暗号化）する方法について説明します。

お遊び的なテーマです。
アプリケーションで一番の処理がデータ（情報）を操作する事がですので、
皆さんが良く目にするデータである画像ファイルを題材としました。

ちなみに、暗号化された情報は画像ファイルなんで、
ぱっとみ暗号化されたものなのか解らない。

# 2. 画像ファイルの構成

今回はJPGファイルを対象とします。
JPGのデータ構成の詳細は以下の記事を参考にして下さい。
（全体の構成は本質ではない為）
>https://www.setsuki.com/hsp/ext/jpg.htm

ここで重要なマーカーは
開始マーカーのFFF8
終了マーカーのFFF9
そして**コメント**の**FFFE**です。

コメントであれば、何を書き込んでも影響が無いので、
ここに別のファイル情報を書き込んで偽装してみましょうか。

# 3. ファイル情報の埋め込み方

JPEGファイルのデータ構成は以下になっています。

```
FFF8
～画像データ～
FFF9
```

これを

```
FFF8
FFFE<バイト長><埋め込みデータ>
～画像データ～
FFF9
```

のようにします。
**コメントマーカーの後にはコメントのバイト長を2byteで指定します。**


# 4. 埋め込みデータ形式

## 4.1. 基本フォーマット

FFFEはjpgフォーマットのコメントマーカーなので、
そのままデータ部として使うと既存のコメントと区別がつかない。
従って、情報を付与して他のコメントと区別を付ける必要がある。

従って、

```
FFFE<バイト長><識別子><埋め込みデータ>
```

のようにコメントマーカー直後に、
識別子を入れて他のコメントと区別を付ける。

識別子と既存のコメントについて偶然の一致は発生する可能性があるため、
ある程度**アリエナイ**識別子にして回避する。

徹底的に回避しようとすると、

1. 既存コメントに対して識別子と同一データがあればエスケープする。

2. 全コメントを先に見て識別子を動的に変える。

とかあるのですが、
今回の主題ではないので解りやすくする。
~~めんどうだからとかじゃないよ~~

## 4.2. 埋め込みデータのヘッダ部

埋め込むファイルのメタデータとして色々あるのですが、
とりあえずファイル名だけを保存します。

```
FFFE<バイト長>"HDFN"<ファイル名>
例:FFFE000C"HDFNtest.txt"
※文字列は16進数ではなくダブルクォートでくくって表現ます。
※ファイル名が8文字（test.txt）+識別子（HDFN）でバイト長は16進数で000C
```

## 4.3. 埋め込みデータのデータ部

バイト長が2バイトでの表現なので65536が最大バイト数になります。
このままだと容量の大きなファイルが埋め込めない。

従って、以下のように複数のコメントマーカーで
データを細切れにしてコメントを分ける。

```
FFFE<バイト長>"BDDT"<埋め込みデータ>
FFFE<バイト長>"BDDT"<埋め込みデータ>
FFFE<バイト長>"BDDT"<埋め込みデータ>
FFFE<バイト長>"BDDT"<埋め込みデータ>
...
```

あと、普通に埋め込んでも面白くないので、
以下の利点を考え圧縮して埋め込もうと思う。

1. 容量の増加量が減りより偽装が増す。（元が圧縮ファイルなら減らないけど。。。）
2. 圧縮することにより、平文にくらべ看破されずらくなる。

なので圧縮してみましょう、
**GZIPOutputStream**を挟むだけです。


# 5. Let's コーディング

ここまでくれば後はコーディングするだけ。

大したコードでもないので、１Classファイルで表現します。
ライブラリも標準ライブラリのみを使用です。
Java11で書きました。

## 5.1. 暗号化

**メソッド構成は以下**
execute：画像ファイルを出力
　⇒writeHeader：埋め込み情報のヘッダ部（ファイル名）出力
　⇒writeBody：埋め込み情報のデータ部（圧縮バイト配列）出力


```Java
package test;

import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.util.zip.GZIPOutputStream;

/**
 * 暗号化
 */
public class Encord {

	/** jpgの開始マーカー */
	private static byte[] PIC_START = new byte[] { (byte) 0xFF, (byte) 0xD8 };
	/** jpegのコメントマーカー */
	private static byte[] COM_START = new byte[] { (byte) 0xFF, (byte) 0xFE };
	/** 埋め込み情報のヘッダ部として使用するコメント後に付与する文字列 */
	private static byte[] HEADER_START = new byte[] { 'H', 'D', 'F', 'N' };
	/** 埋め込み情報のデータ部として使用するコメント後に付与する文字列 */
	private static byte[] DATA_START = new byte[] { 'B', 'D', 'D', 'T' };
	/** 埋め込み時に使用するバッファサイズ */
	private static final int BUFFER_SIZE = 0x2000;

	/**
	 * 実行。
	 *
	 * 埋め込み後は元ファイルに.jpgを付与して出力する。
	 *
	 * @param implFilePath 埋め込みファイル
	 * @param picPath 画像ファイル
	 */
	public void execute(final Path implFilePath, final Path picFilePath) {
		var fileName = implFilePath.getFileName().toString();
		var outfile = new File(implFilePath.getFileName() + ".jpg");
		try (var picIs = new FileInputStream(picFilePath.toFile());
				var implIs = new FileInputStream(implFilePath.toFile());
				var os = new FileOutputStream(outfile);) {
			for (var c : PIC_START) {
				if ((byte) picIs.read() != c) {
					throw new IllegalArgumentException("画像ファイル不正");
				}
			}
			os.write(PIC_START);
			writeHeader(fileName, os);
			writeBody(implIs, os);

			var buffer = new byte[BUFFER_SIZE];
			int len;
			while ((len = picIs.read(buffer)) >= 0) {
				os.write(buffer, 0, len);
			}
		} catch (Exception e) {
			throw new RuntimeException(e);
		}

	}

	/**
	 * ヘッダ部出力。
	 *
	 * @param fileName
	 * @param os
	 * @throws IOException
	 */
	private void writeHeader(final String fileName, final FileOutputStream os) throws IOException {
		var fileNameBytes = fileName.getBytes(StandardCharsets.UTF_8);
		var headerSize = getComSize(fileNameBytes.length, HEADER_START);
		os.write(COM_START);
		os.write(getComSizeData(headerSize));
		os.write(HEADER_START);
		os.write(fileNameBytes);
	}

	/**
	 * データ部出力。
	 *
	 * @param implIs 埋め込みファイル
	 * @param os 出力ファイルOS
	 * @throws IOException
	 */
	private void writeBody(final FileInputStream implIs, final FileOutputStream os) throws IOException {
		var buffer = new byte[BUFFER_SIZE];
		var gaos = new ByteArrayOutputStream(); // 圧縮データ取得用OS
		var gzipos = new GZIPOutputStream(gaos); // 圧縮OS

		while (true) {
			var len = implIs.read(buffer);
			if (len < 0) {
				gzipos.close();
				writeData(os, gaos.toByteArray());
				break;
			}
			gzipos.write(buffer, 0, len);
			writeData(os, gaos.toByteArray());
			gaos.reset();
		}
	}

	/**
	 * データ部出力（分割単位）。
	 *
	 * @param os 出力ファイルOS
	 * @param compressBytes 圧縮したバイト配列
	 * @throws IOException
	 */
	private void writeData(final OutputStream os, final byte[] compressBytes) throws IOException {
		if (compressBytes.length > 0) {
			var size = getComSize(compressBytes.length, DATA_START);
			var sizeData = getComSizeData(size);
			os.write(COM_START);
			os.write(sizeData);
			os.write(DATA_START);
			os.write(compressBytes);
		}
	}

	/**
	 * 付与情報を含めたデータサイズを算出する。
	 *
	 * @param len 対象データの文字列
	 * @param bytes 付与情報
	 * @return データサイズ
	 */
	private static final int getComSize(final int len, final byte[] bytes) {
		return len + bytes.length + COM_START.length;
	}

	/**
	 * データサイズをバイト配列にする
	 *
	 * @param size データサイズ
	 * @return バイト配列
	 */
	private static final byte[] getComSizeData(final int size) {
		return new byte[] { (byte) (size / 0x100), (byte) (size % 0x100) };
	}
}
```

## 5.2. 複合化

**メソッド構成は以下**

GSInputStreamというデータが埋め込まれた画像ファイルからデータ部のみを抽出する、
InputStreamを作成する。

execute：GSInputStreamで画像情報からデータ部のみを抽出し複合化してファイルに出力する

って単純なんだけど、処理をGSInputStreamに寄せています。
GZIPのバイト配列を取得するISを作ればシンプルになるなと考え、
GSInputStreamというデータ部のみ抽出するISを作成しました。

```Java
package test;

import java.io.BufferedOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.nio.file.FileAlreadyExistsException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.ArrayDeque;
import java.util.Queue;
import java.util.UUID;
import java.util.zip.GZIPInputStream;

/**
 * 複合化
 *
 */
public class Decord {

	/** jpgのコメントマーカー */
	private static byte[] COM_START = new byte[] { (byte) 0xFF, (byte) 0xFE };
	/** 埋め込み情報のヘッダ部として使用するコメント後に付与する文字列 */
	private static byte[] HEADER_START  = new byte[] {'H', 'D', 'F', 'N'};
	/** 埋め込み情報のデータ部として使用するコメント後に付与する文字列 */
	private static byte[] DATA_START  = new byte[] {'B', 'D', 'D', 'T'};
	/** READバッファサイズ */
	private static final int BUFFER_SIZE = 0x2000;
	/** WRITEバッファサイズ */
	private static final int SIZE_8M = 1024 * 1024 * 8;

	/**
	 * 実行。
	 *
	 * @param implFilePath 埋め込まれた画像ファイル
	 */
	public void execute(final Path implFilePath) {
		/* 一時的に出力するテンポラリファイル */
		var tmpFile = new File("TMP_" + UUID.randomUUID() + ".tmp");
		try (var implIs = new FileInputStream(implFilePath.toFile());
				var gsis = new GSInputStream(implIs); // 画像ファイルからGZIP圧縮領域のみを取得する自作IS Class
				var gzipis = new GZIPInputStream(gsis);
				var os = new BufferedOutputStream( new FileOutputStream(tmpFile), SIZE_8M);) {
			var buffer = new byte[BUFFER_SIZE];
			int len;
			/* 解答してテンポラリファイルに出力 */
			while ((len = gzipis.read(buffer)) >= 0) {
				os.write(buffer, 0, len);
			}
			os.close();
			var outFileName = gsis.getFileName();
			/* テンポラリファイルを取得したファイル名にMOVE */
			copyToDecodeFile(tmpFile, outFileName);
		} catch (IOException e) {
			/* 一応お掃除 */
			if (tmpFile.exists()) {
				try {
					Files.delete(tmpFile.toPath());
				} catch (IOException e1) {
					// NP
				}
			}
			throw new RuntimeException(e);
		}
	}

	/**
	 * テンポラリファイルをコピーして複合ファイルを作成する。
	 * @param tmpFile テンポラリファイル
	 * @param outFileName 複合ファイル
	 */
	private void copyToDecodeFile(final File tmpFile, final String outFileName) {
		try {
			if (outFileName != null) {
				var outFile = new File(outFileName);
				if (outFile.exists()) {
					throw new FileAlreadyExistsException(outFileName);
				}
				Files.move(tmpFile.toPath(), outFile.toPath(), StandardCopyOption.REPLACE_EXISTING);
			} else {
				Files.delete(tmpFile.toPath());
			}
		} catch (IOException ex) {
			throw new RuntimeException(ex);
		}
	}



	/**
	 * 画像データからGZIP圧縮領域だけ取得するIS。
	 *
	 * データ領域をバッファリングしてReadで取得できるようにしているだけ。
	 *
	 */
	private class GSInputStream extends InputStream {

		/** バッファキュー */
		private final Queue<Byte> queueBuffer;
		/** 画像ファイルIS */
		private InputStream is;
		/** 出力ファイル名 */
		private String outFileName = null;

		/** 埋め込みデータのバイト数 */
		private int impleDataCnt = 0;
		/** 埋め込みファイル名のバイト数 */
		private int nameCnt = 0;
		/** バッファサイズ */
		private byte[] buffer = new byte[BUFFER_SIZE];
		/** 識別子保存領域 */
		private Byte[] identifier = new Byte[8];
		/** ファイル名格納領域 */
		private ByteBuffer bb = ByteBuffer.allocate(255);

		/**
		 * コンストラクタ
		 * @param is 画像ファイルIS
		 */
		public GSInputStream(final InputStream is) {
			this.queueBuffer = new ArrayDeque<>();
			this.is = is;
		}

		/**
		 * データロード
		 *
		 * @return ロード結果文字数
		 * @throws IOException
		 */
		private int readData() throws IOException {
			var len = is.read(buffer);
			for (int i = 0; i < len; i++) {
				if (nameCnt > 0) {
					readFileName(i);
				} else if (impleDataCnt > 0) {
					readBody(i);
				} else {
					if (outFileName == null) {
						if (checkIdentifier(buffer[i], HEADER_START)) {
							nameCnt = (identifier[2] & 0xFF) * 0x100 + (identifier[3] & 0xFF) - HEADER_START.length - COM_START.length;
						}
					} else {
						if (checkIdentifier(buffer[i], DATA_START)) {
							impleDataCnt = (identifier[2] & 0xFF) * 0x100 + (identifier[3] & 0xFF) - DATA_START.length - COM_START.length;
						}
					}
				}
			}
			return len;
		}

		/**
		 * データ領域取得
		 * @param i 埋め込みファイルバイトデータ
		 */
		private void readBody(int i) {
			queueBuffer.add(buffer[i]);
			if (--impleDataCnt == 0) {
				clearIdentifier();
			}
		}

		/**
		 * ヘッダファイル名領域取得
		 * @param i ヘッダファイル名のバイトデータ
		 */
		private void readFileName(int i) {
			bb.put(buffer[i]);
			if (--nameCnt == 0) {
				bb.flip();
				outFileName = StandardCharsets.UTF_8.decode(bb).toString();
				clearIdentifier();
			}
		}

		@Override
		public int read() throws IOException {
			while (queueBuffer.size() == 0) {
				var len = readData();
				if (len < 0) {
					return -1;
				}
			}
			return queueBuffer.poll() & 0xFF;
		}

		/**
		 * ファイル名取得
		 * @return ファイル名
		 */
		public String getFileName() {
			return outFileName;
		}

		/**
		 * データ領域マーカー判定
		 * @param b バイトデータ
		 * @param bytes 識別子チェック用配列
		 * @return
		 */
		private final boolean checkIdentifier(final byte b, final byte[] bytes) {
			for (int i = 0; i < identifier.length; i++) {
				if (identifier[i] == null) {
					final boolean result;
					switch (i) {
					case 0:
					case 1:
						result = b == COM_START[i];
						break;
					case 2:
					case 3:
						result = true;
						break;
					case 4:
					case 5:
					case 6:
					case 7:
						result = (b == bytes[i - 4]);
						break;
					default:
						throw new IllegalArgumentException();
					}
					if (result) {
						identifier[i] = b;
						if (i == identifier.length - 1) {
							return true;
						}
					} else {
						clearIdentifier();
					}
					break;
				}
			}
			return false;
		}

		/**
		 * 識別子保存一時領域クリア
		 */
		private void clearIdentifier() {
			for (int j = 0; j < identifier.length; j++) {
				identifier[j] = null;
			}
		}
	}
}
```

# 6. 実行結果

### 6.1. 「test.txt」を「適当な画像ファイル.jpg」に埋め込みます。
![pic001.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/101761/c976c4f4-3e3d-7e60-e059-ef54560903a4.png)

### 6.2. 「test.txt」はこんなふうに1～0を大量に並べたテキストファイル。
![pic002.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/101761/c3785013-27da-8783-c773-7d8aa5d9044a.png)

### 6.3. 埋め込んだ結果の画像ファイルはちゃんと開けます。
※容量は58KBで圧縮が効いている事を確認。
![pic003.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/101761/e8146bd1-ffc5-9ef9-a9c9-a3c033b000a6.png)

### 6.4. 複合もきちんとできました。
![pic004.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/101761/2473aee8-f69d-2270-14d9-a3e1f67b646c.png)

### 6.5. ファイルの中身も変わりありません。
![pic005.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/101761/e8679fa5-625e-56d7-53bf-86e42e57aae2.png)

### 6.6. 次に、さっきのテキストをzip圧縮してから埋め込みます。
![pic006.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/101761/4810fa08-b77d-4af8-b07c-d5cae3413b01.png)

### 6.7. ZIP ⇒ GZIP と二回実施した事で、更に容量が減った。。。。。
※ちなみに圧縮ファイル（XLSXも圧縮ファイル）の場合はほぼ加算されます。
※バイナリも正常に扱える（解答）できることも確認済み。
![pic007.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/101761/10853a2b-9aa8-5b9c-4699-0b3f7382b6eb.png)

# 7. まとめ

今回データを画像ファイルに暗号化する方法をやってみた。
このロジックを知らない人にとっては複合化できない、
秘密鍵暗号の一種だと思います。

課題としては、
1. 画像ファイルにあるコメントの構成が一致している場合は複合化できなくなる。
　⇒対応例：ファイル内で絶対使われない文字列や構成を使用する（処理前に、画像ファイルの全バイトを検査する）
2. ファイル名は平文で登録されていてテキストとして覗ける。
　⇒対応例：ファイル名を暗号化する。（1bit ADDするとか、ボディと同じように圧縮するとか）
3. ロジックを知っていたら複合化できる。
　⇒対応例：パスワード機能を実装する。（ZipOutputStream使えば楽）
etc...

「etc...」ってのは
手遊びで作ったので他にも絶対あるだろうなぁと。。。

データ扱うって本当に面白いですよね。

それでは、
さよなら、さよなら、さよなら