C#でRSA暗号を使って署名や暗号化する

# はじめに

OpenSSLで生成した鍵（PEM）ファイルをC#で読み込んで、署名とか暗号化とかする方法の話です。RSA暗号のアルゴリズムの話はしません。

なお、この記事中のコード断片は、エラー処理が省略してありますので、コピペ使用したい人は、適当にエラー処理を追加してください。また、プログラムのコピペ利用は自由にして構いませんが、自己責任でお願いします。

# 処理の手順

.NETの`System.Security.Cryptography`には、`PublicKey`や`PrivateKey`といった公開鍵や秘密鍵を直接扱うクラスやインターフェイスがありません。代わりに`RSACryptoServiceProvider`を使うのですが、このクラスには鍵をファイルから読み込むメソッドが、~~FromXmlString(String)というくっそ使えないメソッドしか~~ あまり一般的ではない方法しかありません。そのためPEMから読み込むには、ちょっと面倒な手順になります。

1. OpenSSLでRSA鍵を生成する。
2. PEMファイルから `System.Security.Cryptography.RSAParameters` の各パラメータを読み込み、設定する。
3. `System.Security.Cryptography.RSACryptoServiceProvider` にパラメータを読み込み、暗号化とか署名とかする。

<table>
<tr><td>
:information_source: PEMとDERとは？<br>
<ul>
<li>DER：バイナリーデータ</li>
<li>PEM：DERをBase64エンコードして、ヘッダーとフッターをつけたもの</li>
</ul>
DERとPEMは書式が違うだけで、中身は同じものです。そのため、両者は変換できます。<br>
<br>
DER → PEM<br>
<pre>
$ openssl x509 -in [input_file] -inform DER -out [output_file] -outform PEM
</pre>
PEM → DER<br>
<pre>
$ openssl x509 -in [input_file] -inform PEM -out [output_file] -outform DER
</pre>
</td></tr>
</table>

# 秘密鍵で署名と復号をする

## OpenSSLで秘密鍵を生成する

パスフレーズは外しておきます。

```sh
$ openssl genrsa -out private.key 2048
$ openssl rsa -in private.key -out private.key
```

できたファイル（private.key）は次のようなPEM形式になっています。

```
-----BEGIN RSA PRIVATE KEY-----
(Base64エンコード)
-----END RSA PRIVATE KEY-----
```

## 秘密鍵を読み込み、各鍵パラメータをRSAParametersに設定する

PEMファイルを読み込み、ヘッダーとフッターを取って、Base64デコードして、DER(バイナリー形式)に変換します。（DERに変換しているなら、最初からOpenSSLでDERで出力しておけば？　と思うかもしれませんが、その通りです。しかし大抵の場合、ファイルの形式はPEMであることが多いので、PEMから読み込めるようにしておいたほうが便利だと思います）

```csharp
public void Load(string filename)
{
    byte[] der = null;
    using (var stream = new FileStream(filename, FileMode.Open))
    {
        // Base64デコードして、DER(バイナリー形式)にする
        var encoded = pem.
            Replace(@"-----BEGIN RSA PRIVATE KEY-----", string.Empty).
            Replace(@"-----END RSA PRIVATE KEY-----", string.Empty);
        encoded = new Regex(@"\r?\n").Replace(encoded, string.Empty);
        der = Convert.FromBase64String(encoded);
    }

    this.parameters = CreateParameter(der);
}
```

DER(バイナリー形式)にできたら、RSAの各鍵パラメータを読み込んで、`RSAParameters`に値を設定します。

```csharp
private RSAParameters CreateParameter(byte[] der)
{
    byte[] sequence = null;
    using (var reader = new BinaryReader(new MemoryStream(der))
    {
        sequence = Read(reader);
    }

    var parameters = new RSAParameters();
    using (var reader = new BinaryReader(new MemoryStream(sequence))
    {
        Read(reader); // version
        parameters.Modulus = Read(reader);
        parameters.Exponent = Read(reader);
        parameters.D = Read(reader);
        parameters.P = Read(reader);
        parameters.Q = Read(reader);
        parameters.DP = Read(reader);
        parameters.DQ = Read(reader);
        parameters.InverseQ = Read(reader);
    }
    return parameters;
}

private byte[] Read(BinaryReader reader)
{
    // tag
    reader.ReadByte();

    // length
    int length = 0;
    byte b = reader.ReadByte();
    if ((b & 0x80) == 0x80) // length が128 octet以上
    {
        int n = b & 0x7F;
        byte[] buf = new byte[] {0x00, 0x00, 0x00, 0x00};
        for (var i = n-1; i>=0; --i)
            buf[i] = reader.ReadByte();
        length = BitConverter.ToInt32(buf, 0);
    } else // length が 127 octet以下
    {
        length = b;
    }

    // value
    if (length == 0)
        return new byte[0];
    byte first = reader.ReadByte();
    if (first == 0x00) length -= 1; // 最上位byteが0x00の場合は、除いておく
    else reader.BaseStream.Seek(-1, SeekOrigin.Current); // 1byte 読んじゃったので、streamの位置を戻しておく
    return reader.ReadBytes(length);
}
```

注意点としては、この処理はRSA秘密鍵限定となっていることです。

`Read()`メソッドの中の処理が意味不明だと思いますが、処理の内容に興味がない人は、こういうことするんだと思ってください。興味ある人は、[ASN.1バイナリー変換規則](http://www.geocities.co.jp/SiliconValley-SanJose/3377/)でルール（仕様）を見てください。本当は、汎用的な`ASN1Value`のようなクラスを作るほうが良いのですが、真面目に作ると結構大変なので（Qiita記事１回分になるので）、今回はRSA秘密鍵限定処理にさせてもらいました。

RSA秘密鍵は数値が並んでいるだけなので、各パラメータを順に`RSAParameters`に設定していくだけです。

## 署名と復号をする

`RSAParameters`が作れれば、あとは`RSACryptoServiceProvider#ImportParameters()`でパラメータを読み込んで、署名や復号をするだけです。

- 署名

```csharp
/// <summary>署名</summary>
/// <param name="data">署名元のデータ</param>
/// <param name="hash">ハッシュアルゴリズム名</param>
public byte[] Sign(byte[] data, string hash)
{
    var provider = new RSACryptoServiceProvider();
    provider.ImportParameters(this.parameters);
    return provider.SignData(data, hash);
}
```

- 復号

```csharp
/// <summary>復号</summary>
/// <param name="encrypt">暗号化されたデータ</param>
public byte[] Decrypt(byte[] encrypt)
{
    var provider = new RSACryptoServiceProvider();
    provider.ImportParameters(this.parameters);
    return provider.Decrypt(encrypt, false);
}
```

# 公開鍵で署名の検証と暗号化する

## OpenSSLで公開鍵を生成する

実は、公開鍵だけ生成する、ということは出来ず、秘密鍵から取り出します…　と言うより、秘密鍵は鍵データ全部であり、公開鍵は秘密鍵の一部のデータです。

```sh
$ openssl genrsa -out private.key 2048
$ openssl rsa -in private.key -pubout -out public.pem
```

## 公開鍵を読み込み、各鍵パラメータをRSAParametersに設定する

鍵のパラメータ数が少ないだけで、秘密鍵とやることは一緒です。

```csharp
public void Load(string filename)
{
    byte[] der = null;
    using (var stream = new FileStream(filename, FileMode.Open))
    {
        // Base64デコードして、DER(バイナリー形式)にする
        var encoded = pem.
            Replace(@"-----BEGIN PUBLIC KEY-----", string.Empty).
            Replace(@"-----END PUBLIC KEY-----", string.Empty);
        encoded = new Regex(@"\r?\n").Replace(encoded, string.Empty);
        der = Convert.FromBase64String(encoded);
    }

    this.parameters = CreateParameter(der);
}

private RSAParameters CreateParameter(byte[] der)
{
    byte[] sequence1 = null;
    using (var reader = new BinaryReader(new MemoryStream(der))
    {
        sequence1 = Read(reader);
    }

    byte[] sequence2 = null;
    using (var reader = new BinaryReader(new MemoryStream(sequence1))
    {
        Read(reader); // sequence
        sequence2 = Read(reader); // bit string
    }

    byte[] sequence3 = null;
    using (var reader = new BinaryReader(new MemoryStream(sequence2))
    {
        sequence3 = Read(reader); // sequence
    }

    var parameters = new RSAParameters();
    using (var reader = new BinaryReader(new MemoryStream(sequence3))
    {
        parameters.Modulus = Read(reader); // モジュラス
        parameters.Exponent = Read(reader); // 公開指数
    }
    
    return parameters;
}

private byte[] Read(BinaryReader reader)
{
    // tag
    reader.ReadByte();

    // length
    int length = 0;
    byte b = reader.ReadByte();
    if ((b & 0x80) == 0x80) // length が128 octet以上
    {
        int n = b & 0x7F;
        byte[] buf = new byte[] {0x00, 0x00, 0x00, 0x00};
        for (var i = n-1; i>=0; --i)
            buf[i] = reader.ReadByte();
        length = BitConverter.ToInt32(buf, 0);
    } else // length が 127 octet以下
    {
        length = b;
    }

    // value
    if (length == 0)
        return new byte[0];
    byte first = reader.ReadByte();
    if (first == 0x00) length -= 1; // 最上位byteが0x00の場合は、除いておく
    else reader.BaseStream.Seek(-1, SeekOrigin.Current); // 1byte 読んじゃったので、streamの位置を戻しておく
    return reader.ReadBytes(length);
}
```

`Read()`メソッドの中は秘密鍵と同じです。秘密鍵と同じように、この処理はRSA公開鍵限定です。

RSA公開鍵のパラメータはモジュラス(N)と公開指数(E)だけなので、この２つを`RSAParameters`に設定します。

## うまく読めませんよ？

読み込みでエラーになる場合、PEMの中身が想定しているものと違っているかもしれません。

今回読み込むPEMの中身は、こんな風になっていると想定しています。

```
-----BEGIN PUBLIC KEY-----
(Base64エンコード)
-----END PUBLIC KEY-----
```

エラーになっているのはこんなファイルでしょうか？

```
-----BEGIN RSA PUBLIC KEY-----
(Base64エンコード)
-----END RSA PUBLIC KEY-----
```

確かにこれはRSA公開鍵で正しいのですが、OpenSSLで公開鍵を生成すると、PKCS#1という汎用の公開鍵の形式になります。汎用、といっても鍵の種類と鍵自体が入っているだけですが。

RSA公開鍵（ヘッダーが`BEGIN RSA PUBLIC KEY`となっている）を扱いたい場合は、`CreateParameter()`メソッド内の`sequence3`がRSA公開鍵に相当するので、そこから処理すれば良いです。

```csharp
public void Load(string filename)
{
    byte[] der = null;
    using (var stream = new FileStream(filename, FileMode.Open))
    {
        // Base64デコードして、DER(バイナリー形式)にする
        var encoded = pem.
            Replace(@"-----BEGIN RSA PUBLIC KEY-----", string.Empty).
            Replace(@"-----END RSA PUBLIC KEY-----", string.Empty);
        encoded = new Regex(@"\r?\n").Replace(encoded, string.Empty);
        der = Convert.FromBase64String(encoded);
    }

    this.parameters = CreateParameter(der);
}

private RSAParameters CreateParameter(byte[] der)
{
    byte[] sequence3 = null;
    using (var reader = new BinaryReader(new MemoryStream(der))
    {
        sequence3 = Read(reader); // sequence
    }

    var parameters = new RSAParameters();
    using (var reader = new BinaryReader(new MemoryStream(sequence3))
    {
        parameters.Modulus = Read(reader); // モジュラス
        parameters.Exponent = Read(reader); // 公開指数
    }
    return parameters;
}

(...Read()は同じ...)
```

## 署名の検証と暗号化をする

秘密鍵と同様、`RSAParameters`が作れれば、あとは`RSACryptoServiceProvider#ImportParameters()`でパラメータを読み込んで、署名の検証や暗号化をするだけです。

- 署名の検証

```csharp
/// <summary>署名の検証をする</summary>
/// <param name="data">検証元データ</param>
/// <param name="hash">ハッシュアルゴリズム名</param>
/// <param name="signature">署名</param>
public bool Verify(byte[] data, string hash, byte[] signature)
{
    var provider = new RSACryptoServiceProvider();
    provider.ImportParameters(parameters);
    return provider.VerifyHash(data, hash, signature);
}
```

- 暗号化

```csharp
/// <summary>暗号化</summary>
/// <param name="data">暗号元データ</param>
public byte[] Encrypt(byte[] data)
{
    var provider = new RSACryptoServiceProvider();
    provider.ImportParameters(parameters);
    return provider.Encrypt(data, false);
}
```

# おまけ：JWKから鍵を読み込む

## JWKを１行で説明すると

鍵のデータをJSON形式で表す仕様です。

## JWKからRSAParametersに設定する

JWKであっても、RSA鍵に必要なパラメータを読み込んで`RSAParameters`に設定し、`RSACryptoServiceProvider#ImportParamerts()`で取り込めばOKです。

例えば、次のJWKからRSA公開鍵を取り込んでみます。

```json
{
    "keys": [
        {
            "kid": "5zAqwCTCDarYXW1aicEc6T-p_NzlcyvqN-XFPh8Isnw",
            "kty": "RSA",
            "alg": "RS256",
            "use": "sig",
            "n": "jUCauOWjlPbhCiRQbYaq9Pm5tFl-VSAGF2soNOxWO4t_34niZiHUpCN9OQT0TkmG3UcFJveFBlHj6MGapFU-y2MxGchJFQLgKRk2ictmmgUYj55TUR0_82QrgBcEYL-OPl_4QQ_XNpTqoTGLEPPZ-AUmJd6NGOrlkSFiOvxFYNcwTmJ0oWlOUbJmL0CphadBPl-SpU7zLU_cXsnt6YzdLIbEA_zuxpA_1ZW85-KIHpsH6b98MoYfguxJjxz-MmStWWjTMkffa60FvPvOjcNXA30Nqsq8rHHAusDtNuJ4XSllS1xTuuVED_z5u-9wsvrKRxlzC-FocGzhKTghXU6NHQ",
            "e": "AQAB"
        }
    ]
}
```

JSONのパーズには`Newtonsoft.Json`を使います。使用する鍵は、今回の例ではアルゴリズム(kty)がRSA、鍵用途(use)が署名検証用(sig)とします。（もしOpenID ConnectでIDトークンの署名検証用の公開鍵を取得するなら、IDトークンのJWTのヘッダーから、鍵ID(kid)が一致、アルゴリズム(alg)が一致、用途(use)が"sig"に一致、が鍵の選択条件になります）

```csharp
private RSAParameters CreateParameter(string json)
{
    var keys= JsonConvert.DeserializeObject<IDictionary<string, IList<IDictionary<string, string>>>>(json)[@"keys"];
    var key = keys.First(k => k[@"use"] == @"sig" && k[@"kty"] == @"RSA");

    var parameters = new RSAParameters();
    parameters.Modulus = Base64.Decode(key[@"n"]); // モジュラス
    parameters.Exponent = Base64.Decode(key[@"e"]); // 公開指数
    return parameters;
}
```

鍵パラメータは、`n`(モジュラス)と`e`(公開指数)の値がBase64エンコードされているので、デコードして`byte[]`をそのまま`RSAParameters`に設定すればよいです。ただ少し面倒なのは、JWKはBase64のパディング文字(`=`)がstripされており、.NETの`Convert.FromBase64String()`は厳密にパディングを要求するという気が利かない実装になっているので、ちょっと処理を追加してやる必要があります。

```csharp
public class Base64
{
    public static byte[] Decode(string encoded)
    {
        if (encoded.Length % 4 != 0) encoded += "====".Substring(encoded.Length % 4);
        return Convert.FromBase64String(encoded);
    }
}
```

`RSAParameters`が生成できてしまえば、後は公開鍵でやったのと同じように`RSACryptoServiceProvider#ImportParamerts()`で取り込んで、署名の検証と暗号化をすればOKです。

# まとめ

本当は楕円曲線暗号の扱い方にしたかったのですが、調べ切れませんでした。次回から本気出す。

# 参考

- [ASN.1バイナリー変換規則](http://www.geocities.co.jp/SiliconValley-SanJose/3377/)

日本語で読める数少ないページです。まぁ、あまりASN.1を知りたいという需要はないでしょうが。

- [JWKのRFC](https://tools.ietf.org/html/rfc7517)

JWKはすべての鍵（公開鍵と秘密鍵、共通鍵）をJSONで表す仕様のため、秘密鍵や共通鍵など普通は外部に公開しない鍵の仕様で定められています。

- [RSA公開鍵/秘密鍵のフォーマット](http://bearmini.hatenablog.com/entry/2014/02/05/143510)

RSA鍵自体のフォーマットが知りたい場合。ただ後半はASN.1を知らないと、よく分からないかも。

- [ASN.1のパーズと編集ができるGUIツール](https://www.vector.co.jp/soft/winnt/prog/se420138.html)
 (Vectorです)

ASN.1マニアにしか需要があるとしか思えませんが。