1. hibara

    No comment

    hibara
Changes in body
Source | HTML | Preview
@@ -1,483 +1,470 @@
<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;
-
-//初期化ベクトル
-byte[] iv = new byte[16];
-RNGCryptoServiceProvider rng = new RNGCryptoServiceProvider();
-rng.GetNonZeroBytes(iv);
-aes.IV = iv;
+aes.GenerateIV();
//Encryption interface.
ICryptoTransform encryptor = aes.CreateEncryptor(aes.Key, aes.IV);
```
-初期化ベクトルをつくるときに、さらっと「RNGCryptoServiceProvider」を使っていますが、これは、乱数でも、質の高いものを生成するためのクラスです。
-
-RNG(Random Number Generator)、すなわち「暗号乱数ジェネレータ」を使うことで、IVが攻撃者によって推測され、同じものを発生させにくくしています。
-
-これも本来なら、自前で実装する必要がありますが、.NET Frameworkですでに用意されています。便利。
-
-これで暗号化の準備は整いました。
-
## 暗号化パスワードについて
コメント欄より、[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, salt, 1000);
// 生成した擬似乱数から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>の著書『暗号技術大全』にも、
> データ圧縮アルゴリズムを暗号アルゴリズムと一緒に使うことは、2つの点で有意義だ:
>
> - 暗号分析は、平文の冗長さを利用する。暗号化の前に圧縮しておけば、冗長さが減らすことができる。
> - 暗号化は時間のかかる作業だ。暗号化の前に圧縮しておけば、作業時間を短縮できる。
また、僕は前の記事で、「たいていは、暗号アルゴリズムの処理負荷を下げます」と書きましたが、実際に計測してみました。
それぞれ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);
}
}
}
}
```
## サンプルコード
一応、そのまま貼り付けて使えるように、以下に、関数化したサンプルソースコードも載せておきましょう。
まずは、暗号化から。↓
```csharp
private bool FileEncrypt(string FilePath, string Password)
{
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".
byte[] salt = new byte[16]; // 16バイトのランダムなソルトを生成
RNGCryptoServiceProvider rng = new RNGCryptoServiceProvider();
rng.GetNonZeroBytes(salt);
//入力されたパスワードをベースに擬似乱数を新たに生成
Rfc2898DeriveBytes deriveBytes = new Rfc2898DeriveBytes(Password, salt, 1000);
// 生成した擬似乱数から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(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);
}
}
}
}
}
}
//結果を表示する
long resultTime = sw.ElapsedMilliseconds;
//Encryption succeed.
textBox1.AppendText("暗号化成功: " + Path.GetFileName(OutFilePath) + Environment.NewLine);
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".
// 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, iv);
byte[] bufferKey = deriveBytes.GetBytes(16); // 16バイト切り出してパスワードにする
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ライセンスです。ご自由にどうぞ。