Visual Studio C#でファイルを暗号化してみる

<a href="http://hibara.org/blog/2011/02/20/c-sharp-net-framework-aes/" target="_blank">自分のブログ</a>でも書いたので、Qiitaでの再掲ともなりますが、そのときよりも少しだけ発展させ、かつ詳説を加えてみたいと思います。

`2015/08/22: コメント欄からのご指摘がいくつかあり、検証や追記、ソースコードの修正を行いました。`
`2015/10/08: コメント欄から、さらなるご指摘がいくつかあり、説明やソースコードの記述の修正を行いました。`


まず「暗号化」するための常識、というか、ある程度の定石について解説します。

次に実際にソースコードを交えてファイルを暗号化してみます。

.NET Framework 4.0 には、標準で暗号化に必要なクラスがそろっていますので、今回は、それらを駆使して、自分のコードは必要最低限で済ませてみましょう。


## 暗号化には定石がある

意外とこのポイントを知らずに、ただ暗号化クラスを使っている方も多いと思います。

「定石」とは、暗号アルゴリズムは何を使うか？といったレベルではなく、もっと上のレイヤー、「暗号化とは？」に近い、基礎的な手法を指します。

それを「暗号化モード（Block cipher modes of operation）」と言います。


## 暗号化モードとは？

基本的に、どの暗号アルゴリズムを使うかに限らず、それが「ブロック暗号方式」であるのなら、まず「暗号化モード」は、何を使うのか？を検討しましょう。

よくわからず、暗号化でやってしまいがちなのは、「ECBモード」でしょうか。

![ecb_mode.png](https://qiita-image-store.s3.amazonaws.com/0/30945/4bcd15b9-c247-430a-79b8-6b2bd0c3e874.png)

これの何がダメなのでしょうか？

このモードでは、同じデータ、同じパスワードだと、毎回同じ暗号化データが生成されてしまうことが問題になります。

「毎回同じデータが生成される」ということは、別のデータと比較することで、悪意ある攻撃者に、少なからず「ヒント」を与えることになります。

たとえば、必ず同じブロックが生成されるなら、それが生成されるまでパスワードの総当り攻撃がしやすくなります。つまり、ファイル全体ではなく、小さなブロックデータなら、解析が容易になるということです。

ただ、毎回同じパスワードと同じデータなど、そんなにあるわけない、と思われるかもしれません。

しかし、日付など同じヘッダ情報が付加されるデータや、同じく終端データなどはどうでしょうか。

ユーザーや開発者が意図しなくとも、知らずに同じデータが入ってしまうこともあるのです。


## CBCモードを使うのがベター

逆に言えば、同じデータ、同じパスワードでも、毎回暗号化するたびに中身が変われば、攻撃者にヒントを与えにくくするということになります。

そこで出てくるのが、別の暗号化モードです。

実は、暗号化モードには、いろいろな種類があり、.NET Framework にも、ほぼ主要なものはそろっています。

<strong>CipherMode 列挙体</strong>
http://msdn.microsoft.com/ja-jp/library/system.security.cryptography.ciphermode(v=vs.110).aspx

とはいえ、どのモードにも、メリット、デメリットがあります。

その中で、暗号強度、コストパフォーマンスのバランスから、<strong>CBCモード</strong>を選択するのがベターでしょう。

暗号化の前に、初期化ベクトル（IV）を与えることで、縄のように、データを交互にねじり合せていくイメージです。

![cbc_mode.png](https://qiita-image-store.s3.amazonaws.com/0/30945/38b2af20-0e91-789f-52a6-623455330387.png)

暗号化する度にランダムのデータ列が与えられることで、ファイル全体が、毎回ちがう値になることが保証されます。

このモードは、最初に与えるIVの質さえ考慮していれば良く、安全に暗号化を行うことができます。

ちなみに、暗号の大家である<a href="https://www.schneier.com/" target="_blank">ブルース・シュナイアー氏</a>がその著書『暗号技術大全』（日本語訳版は絶版・・・）の中でも、

> ファイルを暗号化するのであれば、CBCモードがベストだろう。このモードを使えば、セキュリティは大きく向上するし、保存したデータに多少エラーが発生しても、同期エラーが発生することはまずない。アプリケーションが（ハードウェアではなく）ソフトウェアベースであれば、CBCがほぼ確実にいちばんいい。

と書いています。


## ブロック暗号における「パディング」とは？

これも、ブロック暗号を行うときに避けては通れないポイントです。

AESでは、128bit（16バイト）と決められたデータサイズで暗号化されます。

ですので、たとえば16バイトで割り切れないデータサイズのファイルを暗号化するときには、「余り」ができてしまうことがあります（もちろんピッタリの場合もありますが）。

暗号化するときには、特に大きな問題は起きませんが、実は、復号するときに問題となります。

なぜなら元のデータと、暗号化データの境界線がわからなくなるからです（元ファイルのサイズがわからなくなる、とも言えます）。

そこで、ブロック暗号には、「暗号化モード」とは別に、「パディングモード」というものも指定する必要があります。

このパディングモードも、.NET Frameworkでは、いろいろ用意されおり、自前で実装する必要がありません。

<strong>PaddingMode 列挙体</strong>
http://msdn.microsoft.com/ja-jp/library/vstudio/system.security.cryptography.paddingmode(v=vs.100).aspx

今回は、PaddingMode.PKCS7 を使うことにしましょう。

たとえば、以下の例ですと、データ長が8バイトで、実際のデータ列が9バイトあれば、残りの7バイトは、以下のように埋められます。

![padding_mode.png](https://qiita-image-store.s3.amazonaws.com/0/30945/2c842534-306f-5617-f4b3-0e2320d76eb6.png)

つまり「余り」に埋められた合計サイズが、数値として埋められるというわけです。

これにより、復号時に、データ境界線をプログラムで判別できるようになります。


## AESとは？

さて、本題の「AES」についても少し説明をしておきましょう。

<strong>A</strong>dvanced <strong>E</strong>ncryption <strong>S</strong>tandard の略です。

2000年、アメリカ国立標準技術研究所（<a title="NIST" href="http://www.nist.gov/index.html" target="_blank">NIST</a>）による、厳しい選定審査によって選ばれた、オープンな暗号アルゴリズムです。

正式名は、Rijndael（ラインダール）と言いますが、≒ AESです。完全にイコールでない理由は、後述します。

米国産とはいえ、言葉どおり、世界のスタンダードになりつつあります。今のところ、大きな脆弱性もなく、世界各国、いろいろな場所での採用が進んでいます。

共通鍵暗号方式を採用するときは、ほぼ「AES」の一択でしょう。

どうして他のアルゴリズムじゃダメなのか？　あるいは、独自暗号じゃダメなのか？という議論もあるでしょう。しかし、原則的には推奨できません。

AESは、アルゴリズムが完全にオープンにされ、多くの数学者や暗号研究家たちの検証に晒された上で耐え、最終選定されています。

それは数学的にも、アルゴリズム的にも、脆弱性が「今のところない」と、公に証明されていることになります。保証されている、とも言い換えられます。

独自アルゴリズムだと、それがないため、未知の脆弱性が多く潜んでいる可能性を否定できません。

また、たとえ万が一、AESに脆弱性が発見されても（そんな事態になったら世界的な危機ですが・・・）、知名度の高さから、ニュースとしてすぐに報じられ、対応策の情報も即座に得やすいでしょう。

と、前置きが長くなりましたが、実際にC#のコードにしてみましょう。

```csharp

AesManaged aes = new AesManaged();
aes.BlockSize = 128;              // BlockSize = 16bytes
aes.KeySize = 128;                // KeySize = 16bytes
aes.Mode = CipherMode.CBC;        // CBC mode
aes.Padding = PaddingMode.PKCS7;  // Padding mode is "PKCS7".

// パスワード文字列が大きい場合は、切り詰め、16バイトに満たない場合は0で埋めます
byte[] bufferKey = new byte[16];
byte[] bufferPassword = Encoding.UTF8.GetBytes(Password);
for (i = 0; i < bufferKey.Length; i++)
{
  if (i < bufferPassword.Length)
  {
    bufferKey[i] = bufferPassword[i];
  }
  else
  {
    bufferKey[i] = 0;
  }
}
aes.Key = bufferKey;
aes.GenerateIV();

//Encryption interface.
ICryptoTransform encryptor = aes.CreateEncryptor(aes.Key, aes.IV);

```

## 暗号化パスワードについて

コメント欄より、[suzukis](http://qiita.com/suzukis)さんからのご指摘があり、以下の書き方だと、「鍵空間が小さくなるのではないか」とのご指摘をいただきました。

```csharp
// パスワード文字列が大きい場合は、切り詰め、16バイトに満たない場合は0で埋めます
byte[] bufferKey = new byte[16];
byte[] bufferPassword = Encoding.UTF8.GetBytes(Password);
for (i = 0; i < bufferKey.Length; i++)
{
  if (i < bufferPassword.Length)
  {
    bufferKey[i] = bufferPassword[i];
  }
  else
  {
    bufferKey[i] = 0;
  }
}
aes.Key = bufferKey;
```

たしかに、この書き方ですと、たとえば16文字までパスワード文字列を指定できるのに、「a」と一文字だけにしてしまうと、15文字は空いてしまうということになります。

![key_space.png](https://qiita-image-store.s3.amazonaws.com/0/30945/f5c16176-93d5-4e1e-c916-0b2d1ed61bd0.png)

そこで、入力されたパスワードからランダムな文字列を生成して、きちんと16バイト埋めた状態で暗号化した方がより安全ではないか、ということでした。

それは以下のように、` Rfc2898DeriveBytes` クラスを使うことで簡単に実現することができます。

```csharp
//入力されたパスワードをベースに擬似乱数を新たに生成
Rfc2898DeriveBytes deriveBytes = new Rfc2898DeriveBytes(Password, 16); // saltは内部的に生成される
// 生成した擬似乱数から16バイト切り出したデータをパスワードにする
byte[] bufferKey = deriveBytes.GetBytes(16);
```

ただ、この場合、ランダムな文字列を生成した際の、「salt」をファイルに保持しておかないと（書き込んでおかないと）、復号できなくなるので、その点は注意が必要です。


## AESの厳密な定義

先ほど、チラリと言及しましたが、NISTでは、Rijndaelアルゴリズムで、ブロックサイズが、<strong>128bit</strong>で、キー長が、<strong>128、192、256bit</strong> を「AES」と定義しています。

p.5 - 1. Introduction
http://csrc.nist.gov/publications/fips/fips197/fips-197.pdf

こちらも、[suzukis](http://qiita.com/suzukis)さんからのコメントでのご指摘があり、前の記事にあった「ブロックサイズ、キー長ともに128bitである」という記述を修正しました。

ですので、キー長とブロックサイズに、これら以外の値を設定すると、AESではないので、このクラスではエラーを吐きます。

どうしても強度面で、両方とも128bit以上、たとえば256bitで運用したいという場合は、

System.Security.Cryptography.RijndaelManaged クラスを使いましょう。

そのときは、"AesManaged"の部分を置換するだけです。

```csharp

RijndaelManaged aes = new RijndaelManaged();
aes.BlockSize = 256;
aes.KeySize = 256;

```

ただ、この場合、厳密には「AESを使っている」とは言えなくなります。「Rijndaelを使っている」となります。
> ※これは定義的な問題で、中身に問題があるわけではありません。たとえば開発依頼された顧客に対して、暗号化内容を説明するときに注意が必要ということです。


## 暗号化前に圧縮をかけるのも定石

実は、暗号化前に圧縮をかけるというも、定石とされています。

・・・と、前の記事では書いていたのですが、こちらも[suzukis](http://qiita.com/suzukis)さんからのコメントでのご指摘で、ちゃんと調べてみました。

何度も引用して恐縮ですが、<a href="https://www.schneier.com/" target="_blank">ブルース・シュナイアー氏</a>の著書『暗号技術大全』にも、

> データ圧縮アルゴリズムを暗号アルゴリズムと一緒に使うことは、２つの点で有意義だ：
> 
> - 暗号分析は、平文の冗長さを利用する。暗号化の前に圧縮しておけば、冗長さが減らすことができる。
> - 暗号化は時間のかかる作業だ。暗号化の前に圧縮しておけば、作業時間を短縮できる。

また、僕は前の記事で、「たいていは、暗号アルゴリズムの処理負荷を下げます」と書きましたが、実際に計測してみました。

それぞれ10MB, 100MB, 1000MBデータで、圧縮有無で、10回ずつ試行し、その平均値を求めてみました。

|            | 10MBのデータ| 100MBのデータ|1000MBのデータ|
|:-----------|------------:|-------------:|-------------:|
| 圧縮有り   |    592.7 ms |     6100.5ms |    58709.8ms |
| 圧縮無し   |    253.4 ms |     2592.4ms |    25675.3ms |

結果は圧倒的に「圧縮無し」の勝利。。。

というわけで、[suzukis](http://qiita.com/suzukis)さんのご指摘通りでした。ありがとうございます！

とはいえ、処理時間の短縮としては、期待できませんが、平文の冗長さを無くしておくという意義からも、圧縮をやっておきましょう。

ここでは、.NET Framework に標準である System.IO.Compression.DeflateStream クラスを使います。

すべてStream派生クラスですので、親和性が高く、合わせて使えば、ほとんどプログラマーはするべきことがありません。ホント、.NET Framework様々ですね(笑)。

```csharp

using (CryptoStream cse = new CryptoStream(outfs, encryptor, CryptoStreamMode.Write))
{  // IV
  outfs.Write(iv, 0, 16);  //ファイル先頭に埋め込む
  using (DeflateStream ds = new DeflateStream(cse, CompressionMode.Compress))  //圧縮
  {
    using (FileStream fs = new FileStream(FilePath, FileMode.Open, FileAccess.Read))
    {
      while ((len = fs.Read(buffer, 0, 4096)) > 0)
      {
        ds.Write(buffer, 0, len);
      }
    }
  }
}

```

## サンプルコード

一応、そのまま貼り付けて使えるように、以下に、関数化したサンプルソースコードも載せておきましょう。

計測のため、前のソースコードからStopwatch が加えられていますが、先頭から末尾までの時間を計測するだけです。

まずは、暗号化から。↓

```csharp
private bool FileEncrypt(string FilePath, string Password)
{
	//Stopwatchオブジェクトを作成する
	System.Diagnostics.Stopwatch sw = new System.Diagnostics.Stopwatch();
	//ストップウォッチを開始する
	sw.Start();

	int i, len;
	byte[] buffer = new byte[4096];

	//Output file path.
	string OutFilePath = Path.Combine(Path.GetDirectoryName(FilePath), Path.GetFileNameWithoutExtension(FilePath)) + ".enc";

	using (FileStream outfs = new FileStream(OutFilePath, FileMode.Create, FileAccess.Write))
	{
		using (AesManaged aes = new AesManaged())
		{
			aes.BlockSize = 128;              // BlockSize = 16bytes
			aes.KeySize = 128;                // KeySize = 16bytes
			aes.Mode = CipherMode.CBC;        // CBC mode
			aes.Padding = PaddingMode.PKCS7;	// Padding mode is "PKCS7".

			//入力されたパスワードをベースに擬似乱数を新たに生成
			Rfc2898DeriveBytes deriveBytes = new Rfc2898DeriveBytes(Password, 16);
			byte[] salt = new byte[16];	// Rfc2898DeriveBytesが内部生成したなソルトを取得
			salt = deriveBytes.Salt;
			// 生成した擬似乱数から16バイト切り出したデータをパスワードにする
			byte[] bufferKey = deriveBytes.GetBytes(16);

			/*
			// パスワード文字列が大きい場合は、切り詰め、16バイトに満たない場合は0で埋めます
			byte[] bufferKey = new byte[16];
			byte[] bufferPassword = Encoding.UTF8.GetBytes(Password);
			for (i = 0; i < bufferKey.Length; i++)
			{
				if (i < bufferPassword.Length)
				{
					bufferKey[i] = bufferPassword[i];
				}
				else
				{
					bufferKey[i] = 0;
				}
			*/

			aes.Key = bufferKey;
			// IV ( Initilization Vector ) は、AesManagedにつくらせる
			aes.GenerateIV();

			//Encryption interface.
			ICryptoTransform encryptor = aes.CreateEncryptor(aes.Key, aes.IV);

			using (CryptoStream cse = new CryptoStream(outfs, encryptor, CryptoStreamMode.Write))
			{
				outfs.Write(salt, 0, 16);	  // salt をファイル先頭に埋め込む
				outfs.Write(aes.IV, 0, 16);	// 次にIVもファイルに埋め込む
				using (DeflateStream ds = new DeflateStream(cse, CompressionMode.Compress))	//圧縮
				{
					using (FileStream fs = new FileStream(FilePath, FileMode.Open, FileAccess.Read))
					{
						while ((len = fs.Read(buffer, 0, 4096)) > 0)
						{
							ds.Write(buffer, 0, len);
						}
					}
				}

			}

		}
	}
	//ストップウォッチを止める
	sw.Stop();

	//結果を表示する
	long resultTime = sw.ElapsedMilliseconds;

	//Encryption succeed.
	textBox1.AppendText("暗号化成功: " + Path.GetFileName(OutFilePath) + Environment.NewLine);
	textBox1.AppendText("実行時間: " + resultTime.ToString() + "ms");

	return (true);
}

```

次に復号する場合ですが、暗号化とまったく逆のことをすれば良いだけです。

暗号化では、<strong>先に</strong>圧縮していたので、<strong>復号後</strong>に、解凍処理という順番となっています。


```csharp
private bool FileDecrypt(string FilePath, string Password)
{
	int i, len;
	byte[] buffer = new byte[4096];

	if (String.Compare(Path.GetExtension(FilePath), ".enc", true) != 0)
	{
		//The file are not encrypted file! Decryption failed
		MessageBox.Show("暗号化されたファイルではありません！" + Environment.NewLine + "復号に失敗しました。", 
			"Error", MessageBoxButtons.OK, MessageBoxIcon.Exclamation);
		return (false); ;
	}
	
	//Output file path.
	string OutFilePath = Path.Combine(Path.GetDirectoryName(FilePath), Path.GetFileNameWithoutExtension(FilePath)) + ".txt";

	using (FileStream outfs = new FileStream(OutFilePath, FileMode.Create, FileAccess.Write))
	{
		using (FileStream fs = new FileStream(FilePath, FileMode.Open, FileAccess.Read))
		{
			using (AesManaged aes = new AesManaged())
			{
				aes.BlockSize = 128;              // BlockSize = 16bytes
				aes.KeySize = 128;                // KeySize = 16bytes
				aes.Mode = CipherMode.CBC;        // CBC mode
				aes.Padding = PaddingMode.PKCS7;	// Padding mode is "PKCS7".
										
				// salt
				byte[] salt = new byte[16];
				fs.Read(salt, 0, 16);

				// Initilization Vector
				byte[] iv = new byte[16];
				fs.Read(iv, 0, 16);
				aes.IV = iv;

				/*
				// パスワード文字列が大きい場合は、切り詰め、16バイトに満たない場合は0で埋めます
				byte[] bufferKey = new byte[16];
				byte[] bufferPassword = Encoding.UTF8.GetBytes(Password);
				for (i = 0; i < bufferKey.Length; i++)
				{
					if (i < bufferPassword.Length)
					{
						bufferKey[i] = bufferPassword[i];
					}
					else
					{
						bufferKey[i] = 0;
					}
				*/

				// ivをsaltにしてパスワードを擬似乱数に変換
				Rfc2898DeriveBytes deriveBytes = new Rfc2898DeriveBytes(Password, salt);
				byte[] bufferKey = deriveBytes.GetBytes(16);	// 16バイトのsaltを切り出してパスワードに変換
				aes.Key = bufferKey;

				//Decryption interface.
				ICryptoTransform decryptor = aes.CreateDecryptor(aes.Key, aes.IV);

				using (CryptoStream cse = new CryptoStream(fs, decryptor, CryptoStreamMode.Read))
				{
					using (DeflateStream ds = new DeflateStream(cse, CompressionMode.Decompress))	//解凍
					{
						while ((len = ds.Read(buffer, 0, 4096)) > 0)
						{
							outfs.Write(buffer, 0, len);
						}
					}
				}
			}
		}
	}
	//Decryption succeed.
	textBox1.AppendText("復号成功: " + Path.GetFileName(OutFilePath) + Environment.NewLine);
	return (true);
}
	
```

ここで注意していただきたいのは、一度暗号化してしまうと、ファイルを復号しても、ファイル情報が失われてしまうことです。

ファイル情報とは、ファイル名、属性、タイムスタンプなどです。

もしそうした情報も正常に戻せるようにするには、暗号化ファイルにそのデータも含めるなど、少々複雑な処理が必要になってくるでしょう。

また、パスワードが合っているのか、間違っているのかの判定もしていません。

このソースコードでは、誤ったパスワードで復号すると、さらに変なデータ配列に混ぜられてしまいます（おそらく元のデータに戻せなくなります）。

これについても、復号が成功したか、ヘッダに何かしらの印を埋め込んでおいて、それでパスワードの成否を判定する必要があります。

今回は、「ファイルを暗号化する」という部分に焦点を合わせて書きましたので、そういった「完全版」は、また別の機会に取り上げたいと思います。

一応、GitHubにも今回のプロジェクトファイルごと上げました。↓

https://github.com/hibara/FileEncryptSample

Visual Studio C# 2010 Express、または、VS Express 2013 for Desktopでの動作、ビルドを確認済みです。

ちなみに、MITライセンスです。ご自由にどうぞ。