Help us understand the problem. What is going on with this article?

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

More than 3 years have passed since last update.

自分のブログでも書いたので、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ライセンスです。ご自由にどうぞ。

Why do not you register as a user and use Qiita more conveniently?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away