自分のブログでも書いたので、Qiitaでの再掲ともなりますが、そのときよりも少しだけ発展させ、かつ詳説を加えてみたいと思います。
2015/08/22: コメント欄からのご指摘がいくつかあり、検証や追記、ソースコードの修正を行いました。
2015/10/08: コメント欄から、さらなるご指摘がいくつかあり、説明やソースコードの記述の修正を行いました。
まず「暗号化」するための常識、というか、ある程度の定石について解説します。
次に実際にソースコードを交えてファイルを暗号化してみます。
.NET Framework 4.0 には、標準で暗号化に必要なクラスがそろっていますので、今回は、それらを駆使して、自分のコードは必要最低限で済ませてみましょう。
暗号化には定石がある
意外とこのポイントを知らずに、ただ暗号化クラスを使っている方も多いと思います。
「定石」とは、暗号アルゴリズムは何を使うか?といったレベルではなく、もっと上のレイヤー、「暗号化とは?」に近い、基礎的な手法を指します。
それを「暗号化モード(Block cipher modes of operation)」と言います。
暗号化モードとは?
基本的に、どの暗号アルゴリズムを使うかに限らず、それが「ブロック暗号方式」であるのなら、まず「暗号化モード」は、何を使うのか?を検討しましょう。
よくわからず、暗号化でやってしまいがちなのは、「ECBモード」でしょうか。
これの何がダメなのでしょうか?
このモードでは、同じデータ、同じパスワードだと、毎回同じ暗号化データが生成されてしまうことが問題になります。
「毎回同じデータが生成される」ということは、別のデータと比較することで、悪意ある攻撃者に、少なからず「ヒント」を与えることになります。
たとえば、必ず同じブロックが生成されるなら、それが生成されるまでパスワードの総当り攻撃がしやすくなります。つまり、ファイル全体ではなく、小さなブロックデータなら、解析が容易になるということです。
ただ、毎回同じパスワードと同じデータなど、そんなにあるわけない、と思われるかもしれません。
しかし、日付など同じヘッダ情報が付加されるデータや、同じく終端データなどはどうでしょうか。
ユーザーや開発者が意図しなくとも、知らずに同じデータが入ってしまうこともあるのです。
CBCモードを使うのがベター
逆に言えば、同じデータ、同じパスワードでも、毎回暗号化するたびに中身が変われば、攻撃者にヒントを与えにくくするということになります。
そこで出てくるのが、別の暗号化モードです。
実は、暗号化モードには、いろいろな種類があり、.NET Framework にも、ほぼ主要なものはそろっています。
CipherMode 列挙体
http://msdn.microsoft.com/ja-jp/library/system.security.cryptography.ciphermode(v=vs.110).aspx
とはいえ、どのモードにも、メリット、デメリットがあります。
その中で、暗号強度、コストパフォーマンスのバランスから、CBCモードを選択するのがベターでしょう。
暗号化の前に、初期化ベクトル(IV)を与えることで、縄のように、データを交互にねじり合せていくイメージです。
暗号化する度にランダムのデータ列が与えられることで、ファイル全体が、毎回ちがう値になることが保証されます。
このモードは、最初に与えるIVの質さえ考慮していれば良く、安全に暗号化を行うことができます。
ちなみに、暗号の大家であるブルース・シュナイアー氏がその著書『暗号技術大全』(日本語訳版は絶版・・・)の中でも、
ファイルを暗号化するのであれば、CBCモードがベストだろう。このモードを使えば、セキュリティは大きく向上するし、保存したデータに多少エラーが発生しても、同期エラーが発生することはまずない。アプリケーションが(ハードウェアではなく)ソフトウェアベースであれば、CBCがほぼ確実にいちばんいい。
と書いています。
ブロック暗号における「パディング」とは?
これも、ブロック暗号を行うときに避けては通れないポイントです。
AESでは、128bit(16バイト)と決められたデータサイズで暗号化されます。
ですので、たとえば16バイトで割り切れないデータサイズのファイルを暗号化するときには、「余り」ができてしまうことがあります(もちろんピッタリの場合もありますが)。
暗号化するときには、特に大きな問題は起きませんが、実は、復号するときに問題となります。
なぜなら元のデータと、暗号化データの境界線がわからなくなるからです(元ファイルのサイズがわからなくなる、とも言えます)。
そこで、ブロック暗号には、「暗号化モード」とは別に、「パディングモード」というものも指定する必要があります。
このパディングモードも、.NET Frameworkでは、いろいろ用意されおり、自前で実装する必要がありません。
PaddingMode 列挙体
http://msdn.microsoft.com/ja-jp/library/vstudio/system.security.cryptography.paddingmode(v=vs.100).aspx
今回は、PaddingMode.PKCS7 を使うことにしましょう。
たとえば、以下の例ですと、データ長が8バイトで、実際のデータ列が9バイトあれば、残りの7バイトは、以下のように埋められます。
つまり「余り」に埋められた合計サイズが、数値として埋められるというわけです。
これにより、復号時に、データ境界線をプログラムで判別できるようになります。
AESとは?
さて、本題の「AES」についても少し説明をしておきましょう。
Advanced Encryption Standard の略です。
2000年、アメリカ国立標準技術研究所(NIST)による、厳しい選定審査によって選ばれた、オープンな暗号アルゴリズムです。
正式名は、Rijndael(ラインダール)と言いますが、≒ AESです。完全にイコールでない理由は、後述します。
米国産とはいえ、言葉どおり、世界のスタンダードになりつつあります。今のところ、大きな脆弱性もなく、世界各国、いろいろな場所での採用が進んでいます。
共通鍵暗号方式を採用するときは、ほぼ「AES」の一択でしょう。
どうして他のアルゴリズムじゃダメなのか? あるいは、独自暗号じゃダメなのか?という議論もあるでしょう。しかし、原則的には推奨できません。
AESは、アルゴリズムが完全にオープンにされ、多くの数学者や暗号研究家たちの検証に晒された上で耐え、最終選定されています。
それは数学的にも、アルゴリズム的にも、脆弱性が「今のところない」と、公に証明されていることになります。保証されている、とも言い換えられます。
独自アルゴリズムだと、それがないため、未知の脆弱性が多く潜んでいる可能性を否定できません。
また、たとえ万が一、AESに脆弱性が発見されても(そんな事態になったら世界的な危機ですが・・・)、知名度の高さから、ニュースとしてすぐに報じられ、対応策の情報も即座に得やすいでしょう。
と、前置きが長くなりましたが、実際にC#のコードにしてみましょう。
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さんからのご指摘があり、以下の書き方だと、「鍵空間が小さくなるのではないか」とのご指摘をいただきました。
// パスワード文字列が大きい場合は、切り詰め、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文字は空いてしまうということになります。
そこで、入力されたパスワードからランダムな文字列を生成して、きちんと16バイト埋めた状態で暗号化した方がより安全ではないか、ということでした。
それは以下のように、 Rfc2898DeriveBytes
クラスを使うことで簡単に実現することができます。
//入力されたパスワードをベースに擬似乱数を新たに生成
Rfc2898DeriveBytes deriveBytes = new Rfc2898DeriveBytes(Password, 16); // saltは内部的に生成される
// 生成した擬似乱数から16バイト切り出したデータをパスワードにする
byte[] bufferKey = deriveBytes.GetBytes(16);
ただ、この場合、ランダムな文字列を生成した際の、「salt」をファイルに保持しておかないと(書き込んでおかないと)、復号できなくなるので、その点は注意が必要です。
AESの厳密な定義
先ほど、チラリと言及しましたが、NISTでは、Rijndaelアルゴリズムで、ブロックサイズが、128bitで、キー長が、128、192、256bit を「AES」と定義しています。
p.5 - 1. Introduction
http://csrc.nist.gov/publications/fips/fips197/fips-197.pdf
こちらも、suzukisさんからのコメントでのご指摘があり、前の記事にあった「ブロックサイズ、キー長ともに128bitである」という記述を修正しました。
ですので、キー長とブロックサイズに、これら以外の値を設定すると、AESではないので、このクラスではエラーを吐きます。
どうしても強度面で、両方とも128bit以上、たとえば256bitで運用したいという場合は、
System.Security.Cryptography.RijndaelManaged クラスを使いましょう。
そのときは、"AesManaged"の部分を置換するだけです。
RijndaelManaged aes = new RijndaelManaged();
aes.BlockSize = 256;
aes.KeySize = 256;
ただ、この場合、厳密には「AESを使っている」とは言えなくなります。「Rijndaelを使っている」となります。
※これは定義的な問題で、中身に問題があるわけではありません。たとえば開発依頼された顧客に対して、暗号化内容を説明するときに注意が必要ということです。
暗号化前に圧縮をかけるのも定石
実は、暗号化前に圧縮をかけるというも、定石とされています。
・・・と、前の記事では書いていたのですが、こちらもsuzukisさんからのコメントでのご指摘で、ちゃんと調べてみました。
何度も引用して恐縮ですが、ブルース・シュナイアー氏の著書『暗号技術大全』にも、
データ圧縮アルゴリズムを暗号アルゴリズムと一緒に使うことは、2つの点で有意義だ:
- 暗号分析は、平文の冗長さを利用する。暗号化の前に圧縮しておけば、冗長さが減らすことができる。
- 暗号化は時間のかかる作業だ。暗号化の前に圧縮しておけば、作業時間を短縮できる。
また、僕は前の記事で、「たいていは、暗号アルゴリズムの処理負荷を下げます」と書きましたが、実際に計測してみました。
それぞれ10MB, 100MB, 1000MBデータで、圧縮有無で、10回ずつ試行し、その平均値を求めてみました。
10MBのデータ | 100MBのデータ | 1000MBのデータ | |
---|---|---|---|
圧縮有り | 592.7 ms | 6100.5ms | 58709.8ms |
圧縮無し | 253.4 ms | 2592.4ms | 25675.3ms |
結果は圧倒的に「圧縮無し」の勝利。。。
というわけで、suzukisさんのご指摘通りでした。ありがとうございます!
とはいえ、処理時間の短縮としては、期待できませんが、平文の冗長さを無くしておくという意義からも、圧縮をやっておきましょう。
ここでは、.NET Framework に標準である System.IO.Compression.DeflateStream クラスを使います。
すべてStream派生クラスですので、親和性が高く、合わせて使えば、ほとんどプログラマーはするべきことがありません。ホント、.NET Framework様々ですね(笑)。
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 が加えられていますが、先頭から末尾までの時間を計測するだけです。
まずは、暗号化から。↓
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);
}
次に復号する場合ですが、暗号化とまったく逆のことをすれば良いだけです。
暗号化では、先に圧縮していたので、復号後に、解凍処理という順番となっています。
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にも今回のプロジェクトファイルごと上げました。↓
Visual Studio C# 2010 Express、または、VS Express 2013 for Desktopでの動作、ビルドを確認済みです。
ちなみに、MITライセンスです。ご自由にどうぞ。