C#で公開鍵暗号RSAで「ファイル」を暗号化する

探してみると、サンプルも含め意外にないので、Visual Studio 2019 の、C# から .NET Framework の RSA を使って「ファイル」を暗号化した結果を記事にしてみました。

## 結論：RSA で「ファイル」を暗号化するには向いていない

もう結論から言ってしまうと、実用的ではありませんでした。

暗号化はある程度、速いですが、復号は死ぬほど遅いです。もちろん、知ってはいたのですが、ここまで遅いとは思いませんでした。。。

かの暗号の大家である、<a href="https://www.schneier.com/" target="_blank" rel="noreferer noopener">ブルース・シュナイアー氏</a>も『暗号技術大全』（日本語版は絶版）で、公開鍵暗号だけではなく、ハイブリッド暗号にするべきと言いつつ、こう書いておられます。

> 現実世界においては、公開鍵アルゴリズムは対称アルゴリズムに代わるものではない。メッセージを暗号化するのにではなく、鍵を暗号化するのに使われているのだ。

そして、これには理由は２つあるとも述べています。

> 1. 公開鍵アルゴリズムは遅い（対称鍵暗号に比べて1000倍は遅い）
> 2. 公開鍵暗号システムは選択平文攻撃に弱い

2.については、ありったけの平文を用意して、暗号化したものを比較していけば良いということです。その一方で、対称暗号（共通鍵暗号）システムは、暗号分析家が未知の鍵で試しに暗号化してみる、ということはできないため、この攻撃で破られることはないとも書いています。

ということから、メッセージ（ファイル）などの大きなサイズは、おとなしく共通鍵暗号で暗号化しましょう。

とはいえ、せっかく実験してみたので、ソースコードだけでも残しておこうかと思います。

## 公開鍵暗号とは？

そこから説明する？と思われた方は読み飛ばしてください。一応、図を使って簡単に説明します。

### 共通鍵暗号（対称暗号）

公開鍵暗号を説明する前に、対称暗号（共通鍵暗号）の説明から簡単に。

これは皆さんも馴染みがあり、これが暗号システムだと思われている方も多いのではないでしょうか。簡単にいえば、１つの鍵（パスワード）で復号する、元に戻すことができるシステムのことを言います。

![共通鍵暗号.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/30945/c25c3c7a-966e-f0b5-4675-884a4c0bde3b.png)

### 公開鍵暗号

次に、公開鍵暗号ですが、こちらも簡単に言い切ってしまえば、鍵が２つあります。

下の図で言えば、錠前と鍵に分かれていて、錠前は「暗号専用」、鍵は「復号専用」になっているというイメージです。

![公開暗号鍵はペア.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/30945/69f08995-28a5-73ef-e6d4-fdc89eea331f.png)

これの何が嬉しいかと言うと、いわゆる「鍵配送問題」を解決してくれます。今話題のパスワードZIPをメール添付して、その後に「これが先ほどのパスワードです」っていうメールをもう一通送ってしまうという問題を解消できます。

なぜなら、配送される（公開される）のは、「暗号化しかできない」錠前だけだからです。復号する鍵は手元に置いたまま、相手に送ることができますし、公に広めても良いわけです。

![公開鍵を配送.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/30945/bb6870eb-c14e-0e1d-8ca8-955acb6576e5.png)

![秘密鍵の秘匿.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/30945/002b18cd-7ace-3f7d-f358-44b1f43ad860.png)

この仕組みをどうやって実現しているかというと、先に述べた処理速度が遅い、につながるのですが、ざっくり言えば２つの巨大な素数の剰余を使って鍵を生成しています。詳しくお知りになりたい方は、いろいろウェブサイトを当たっていただければと思います。

私は参考書籍から、C++で実装してみたことがあるのですが、巨大な素数を収める型自体が存在しないので、まず多倍長整数を収めるためのクラスからつくるという、なんともフルスクラッチ感のあることもやりました。また、その巨大な数字が本当に「素数」なのかを確認するためのアルゴリズムとか、そもそも巨大素数を生成するという時間のかかる作業にも辟易しました。。。

しかし、.NET Framework には、そのような苦労をせずとも簡単にそれらを（たぶん裏で）扱って計算を肩代わりしてくれます。

## ２つの鍵（公開鍵、秘密鍵）をつくってみる

では、さっそく２つの鍵を生成してみましょう。

```csharp
using System;
using System.IO;
using System.Security.Cryptography;
using System.Text;

//RSACryptoServiceProviderオブジェクトの作成
RSACryptoServiceProvider rsa = new RSACryptoServiceProvider(2048);

//公開鍵をXML形式で取得
String publicKey = rsa.ToXmlString(false);
//秘密鍵をXML形式で取得
String privateKey = rsa.ToXmlString(true);

byte[] bytesPublicKey = Encoding.UTF8.GetBytes(publicKey);
byte[] bytesPrivateKey = Encoding.UTF8.GetBytes(privateKey);

//公開鍵を保存
FileStream outfs = new FileStream("PublicKey.txt", FileMode.Create, FileAccess.Write);
outfs.Write(bytesPublicKey, 0, bytesPublicKey.Length);
outfs.Close();

//秘密鍵を保存
FileStream outfs1 = new FileStream("PrivateKey.txt", FileMode.Create, FileAccess.Write);
outfs1.Write(bytesPrivateKey, 0, bytesPrivateKey.Length);
outfs1.Close();

rsa.Clear();
```

超簡単です。

生成された、`PublicKey.txt` が公開鍵（前章でいう錠前）で、`PrivateKey.txt` が秘密鍵（前章でいう錠前に対する鍵）になります。

暗号化するのには、`PublicKey.txt` が必要になり、復号するのには、`PrivateKey.txt` が必要になるため、大切に保管しておきます。特に秘密鍵の方は、たとえローカルから出ないとはいえ、別の対称暗号か何かで暗号化しておくのが実装としてはベターだと思います。

## ファイルを暗号化する

関数にしてみました。引数に、それぞれ元ファイルパス `FilePath` を指定し、出力される暗号化ファイルパスには、`OutFilePath` を指定します。また、先に生成した `PublicKey.txt` を読み込むところがあります。

ポイントは、復号した際に、ファイルサイズ情報が失われるので、暗号化ファイルの先頭に 8byte (Int64) の数値を格納しています。ただ、おそらくそれほどデカいファイルを扱うこともないでしょうから、ここは通常の int (4byte) でも十分かもしれません。

```csharp
// RSAでファイルを暗号化する
private void RsaEncrypt(string FilePath, string OutFilePath)
{
  StreamReader sr = new StreamReader(@"PublicKey.txt", Encoding.UTF8);
  string PublicKey = sr.ReadToEnd();
  sr.Close();

  //RSACryptoServiceProviderオブジェクトの作成
  RSACryptoServiceProvider rsa = new RSACryptoServiceProvider(2048);
  rsa.FromXmlString(PublicKey); //公開鍵を指定

  using (FileStream fs = new FileStream(FilePath, FileMode.Open, FileAccess.Read))
  {
    using (FileStream outfs = new FileStream(OutFilePath, FileMode.Create, FileAccess.Write))
    {
      byte[] byteArray;
      byte[] outbuffer = new byte[214];  // 剰余サイズ(256bytes) -2 -2 * hLen(SHA-1) = 214 Max 

      int len = 0;

      // ファイルサイズを求めて書き込む
      FileInfo fi = new FileInfo(FilePath);
      Int64 FileSize = Convert.ToInt64(fi.Length);
      byteArray = BitConverter.GetBytes(FileSize);
      outfs.Write(byteArray, 0, 8);

      while ((len = fs.Read(outbuffer, 0, outbuffer.Length)) > 0)
      {
        byte[] encryptedData = rsa.Encrypt(outbuffer, RSAEncryptionPadding.OaepSHA1); //OAEPパディング=trueでRSA復号
        outfs.Write(encryptedData, 0, encryptedData.Length);
      }
    }

  }
}
```

ちなみに、暗号化するためのバッファは、256byteですが、暗号に必要な情報分（42byte）だけ差し引いたデータ量になります。

## 暗号化ファイルを復号する

復号処理を開始する前に、暗号化ファイルの先頭から元ファイルのサイズ（8byte）を取り出していることに注意してください。

```csharp
// RSAで暗号化ファイルを復号する
private void RsaDecrypt(string FilePath, string OutFilePath)
{
  StreamReader sr = new StreamReader(@"PrivateKey.txt", Encoding.UTF8);
  string PrivateKey = sr.ReadToEnd();
  sr.Close();

  //RSACryptoServiceProviderオブジェクトの作成
  RSACryptoServiceProvider rsa = new RSACryptoServiceProvider(2048);
  rsa.FromXmlString(PrivateKey);  //秘密鍵を指定

  using (FileStream fs = new FileStream(FilePath, FileMode.Open, FileAccess.Read))
  {
    using (FileStream outfs = new FileStream(OutFilePath, FileMode.Create, FileAccess.Write))
    {
      byte[] byteArray;
      byte[] outbuffer = new byte[256];

      // ファイルサイズを取り出す
      byteArray = new byte[8];
      fs.Read(byteArray, 0, 8);
      Int64 FileSize = BitConverter.ToInt64(byteArray, 0);

      int len = 0;
      Int64 TotalSize = 0;
      while ((len = fs.Read(outbuffer, 0, outbuffer.Length)) > 0)
      {
        byte[] decryptedData = rsa.Decrypt(outbuffer, RSAEncryptionPadding.OaepSHA1);
        if (TotalSize + decryptedData.Length > FileSize)
        {
          outfs.Write(decryptedData, 0, (int)(FileSize - TotalSize));
        }
        else
        {
          outfs.Write(decryptedData, 0, decryptedData.Length);
        }
        TotalSize += decryptedData.Length;
      }
    }
  }
}
```

復号では、バッファサイズを 256byteを指定しています。

## 復号が死ぬほど遅い

つくってから実測してみたら分かりました・・・

ほんの数百バイトなら気にはならなかったのですが、1MB のファイルを暗号化して、復号した結果は、以下の通りです。

おおよそ10回ほど暗号化・復号を繰り返した結果です。ほとんど差異はなかったので、100回などする必要はありませんでした。あしからず。

| 暗号化  |復号  |
|---|---|
| 1.535s | 42.832s |

MacBook Pro M1 上で Parallels を走らせた状態からの Windows10 Pro + Visual Studio 2019 で実行した結果です。

ちなみに、同じ要領で対称暗号の代表格でもある AES で暗号化・復号を行った結果は以下の通りです。

| 暗号化  |復号  |
|---|---|
| 0.125s | 0.104s |

爆速すぎて、1MB くらいで正確にはベンチマークは無理ですね。先の「1000倍」は言い過ぎですが、.NET Framework 上での復号においては、400倍以上の速度差があります。

## 再び結論：RSAでファイルの暗号化・復号をやってはダメ

これでウェブサイトを回ってもサンプルコードがない理由が分かりました。実用に耐えないため、誰も使っていないというのが実情なのでしょう。素直に、対称暗号（共通鍵暗号）を使ったハイブリッド暗号にするのが実用的です。

ちなみに、復号が遅い原因ですが、前述でもしたとおり、公開鍵暗号の仕組み自体にあり、これは避けられないことのようです。

参考になったのは以下のサイトです。概ねなぜ遅いかの理由が書かれています。RSA暗号をある程度理解した上で、参考になさると良いでしょう。

Why is RSA decryption slow?
<https://security.stackexchange.com/questions/57205/why-is-rsa-decryption-slow>

## 蛇足：AES のサンプルコード

蛇足ですが、上記のベンチマークで使った AES のファイル暗号化・復号処理のコードも上げておきます。いずれも関数化されているので、使い回しが利くかと思います。

なお、AES（正確には、Rijndael）を使った暗号方法については、別記事で「共通鍵暗号（対称暗号）」の仕組みと、お作法も絡めながら、詳細に書いていますので、興味のある方は以下も参照になさってください。

Visual Studio C#でファイルを暗号化してみる
<https://qiita.com/hibara/items/c9096376b1d7b5c8e2ae>

### AES で「ファイル」を暗号化する

RSA サンプルと同様に、関数の引数には、暗号化したい元ファイルパス `FilePath` と、出力される暗号化ファイルのフルパス `OutFilePath` を指定します。

```csharp
private void AesEncrypt(string FilePath, string OutFilePath)
{
  // パスワードは、単純な「abc」ですが、鍵空間を広げる
  Rfc2898DeriveBytes deriveBytes = new Rfc2898DeriveBytes("abc", 8, 1000);
  // Rfc2898DeriveBytes オブジェクトからキーと IV を導出する
  byte[] salt = deriveBytes.Salt;
  byte[] key = deriveBytes.GetBytes(32);  // キーサイズは、256bit
  byte[] iv = deriveBytes.GetBytes(16);   // ブロックサイズは、128bit

  using (FileStream fs = new FileStream(FilePath, FileMode.Open, FileAccess.Read))
  {
    using (FileStream outfs = new FileStream(OutFilePath, FileMode.Create, FileAccess.Write))
    {
      // salt だけ先頭に書き込む
      outfs.Write(salt, 0, 8);
      // AESオブジェクト
      AesManaged aes = new AesManaged();
      aes.Mode = CipherMode.CBC;       // デフォルト（書かなくてもO.K.）
      aes.Padding = PaddingMode.PKCS7; // デフォルト（書かなくてもO.K.）
      aes.KeySize = 256;
      aes.BlockSize = 128;
      aes.Key = key;
      aes.IV = iv;
      // 対称暗号化オブジェクト
      using (ICryptoTransform encryptor = aes.CreateEncryptor())
      {
        // 書き出すための暗号化ストリーム
        using (CryptoStream cs = new CryptoStream(outfs, encryptor, CryptoStreamMode.Write))
        {
          //暗号化されたデータを書き出す
          byte[] buffer = new byte[1024];
          int len;
          while ((len = fs.Read(buffer, 0, buffer.Length)) > 0)
          {
            cs.Write(buffer, 0, len);
          }
        }
      }
    }
  }
}
```

### AES で暗号化ファイルを復号する

暗号化のときと同様ですが、引数の `FilePath` には、復号する暗号化ファイルのフルパスを入れ、元に戻したファイル（元ファイルに上書きしてしまってもO.K.）パスに、`OutFilePath` を指定します。紛らわしいですが、暗号化と復号で引数に指定するファイルが異なるのでご注意ください。

```csharp
private void AesDecrypt(string FilePath, string OutFilePath)
{
  using (FileStream fs = new FileStream(FilePath, FileMode.Open, FileAccess.Read))
  {
    byte[] salt = new byte[8];
    fs.Read(salt, 0, 8); // 先頭の salt を先に読み込む
    Rfc2898DeriveBytes deriveBytes = new Rfc2898DeriveBytes("abc", salt, 1000);

    byte[] key = deriveBytes.GetBytes(32);
    byte[] iv = deriveBytes.GetBytes(16);

    using (FileStream outfs = new FileStream(OutFilePath, FileMode.Create, FileAccess.Write))
    {
      // AESオブジェクト
      AesManaged aes = new AesManaged();
      aes.Mode = CipherMode.CBC;       // デフォルト
      aes.Padding = PaddingMode.PKCS7; // デフォルト
      aes.KeySize = 256;
      aes.BlockSize = 128;

      aes.Key = key;
      aes.IV = iv;

      // 対称復号化オブジェクト
      using (ICryptoTransform decryptor = aes.CreateDecryptor())
      {
        // 暗号化されたデータを読み込むための復号ストリーム
        using (CryptoStream cs = new CryptoStream(fs, decryptor, CryptoStreamMode.Read))
        {
          byte[] buffer = new byte[1024];
          int len;
          while ((len = cs.Read(buffer, 0, buffer.Length)) > 0)
          {
            outfs.Write(buffer, 0, len);
          }
        }

      }
    }
  }
}
```

以上です。