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

  • 164
    いいね
  • 10
    コメント
この記事は最終更新日から1年以上が経過しています。

自分のブログでも書いたので、Qiitaでの再掲ともなりますが、そのときよりも少しだけ発展させ、かつ詳説を加えてみたいと思います。

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

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

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

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

暗号化には定石がある

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

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

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

暗号化モードとは?

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

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

ecb_mode.png

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

cbc_mode.png

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

このモードは、最初に与える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バイトは、以下のように埋められます。

padding_mode.png

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

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

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文字は空いてしまうということになります。

key_space.png

そこで、入力されたパスワードからランダムな文字列を生成して、きちんと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にも今回のプロジェクトファイルごと上げました。↓

https://github.com/hibara/FileEncryptSample

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

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