LoginSignup
24
12

More than 3 years have passed since last update.

ハッシュ値を出力する湯婆婆をC#で実装してみる(PBKDF2他対応)

Last updated at Posted at 2020-11-14

はじめに

先日、.NET 5.0がリリースされました。業務でがっつりC#を使うわけではありませんが、でも、せっかくなので触ってみたいと思ったのと、加えて、私が情報処理安全確保支援士(SC)の学習をしているのもあり、このような記事になりました。

でも、他の記事と同じように、これもネタ記事です。
(勉強も兼ねていますので、指摘は歓迎です)

なお、本記事では.NET 5.0(C# 9.0)で実装していますが、基本的なコードの部分に関しては、以下の記事を参考にさせて頂きました(ありがとうございます)。

更新履歴
2020/11/14 記事公開
2020/11/15 パスワード保存に適したハッシュ関数(PBKDF2)を追記
2020/11/16 BCrypt、Argon2を追記

基本的なコード

最低限のコードは以下のような感じでしょうか。
C# 9.0から可能になったトップレベルステートメントで記述しています。
アルゴリズムにはSHA256を使用。

using System;
using System.Security.Cryptography;
using System.Text;

Console.WriteLine("契約書だよ。そこに名前を書きな。");
var name = Console.ReadLine();
Console.WriteLine($"フン。{name}というのかい。贅沢な名だねぇ。");

//ハッシュ値を算出
SHA256 Hash = SHA256.Create();
byte[] namebyte = Encoding.UTF8.GetBytes(name);
byte[] hashbyte = Hash.ComputeHash(namebyte);
string hashstring = BitConverter.ToString(hashbyte);

Console.WriteLine($"今からお前の名前は{hashstring}だ。いいかい、{hashstring}だよ。分かったら返事をするんだ、{hashstring}!!");

実行結果は、こうなります。
ハッシュ値の基となる入力データは「千尋」です。

契約書だよ。そこに名前を書きな。
千尋
フン。千尋というのかい。贅沢な名だねぇ。
今からお前の名前は05-FA-F2-ED-C0-4D-95-28-79-2E-AB-C5-EE-80-33-A5-52-24-73-09-92-54-06-70-A3-E9-B9-A5-90-D7-22-6Bだ。いいかい、05-FA-F2-ED-C0-4D-95-28-79-2E-AB-C5-EE-80-33-A5-52-24-73-09-92-54-06-70-A3-E9-B9-A5-90-D7-22-6Bだよ。分かったら返事をするんだ、05-FA-F2-ED-C0-4D-95-28-79-2E-AB-C5-EE-80-33-A5-52-24-73-09-92-54-06-70-A3-E9-B9-A5-90-D7-22-6B!!

解説

以下はハッシュ関数のアルゴリズムとしてよく知られているものだと思いますが、.NETでも標準で使用可能です。

  • SHA256
  • SHA384
  • SHA512

実は、SHA1とMD5も使用できますが、この2つは現在では安全ではないとされており、使用は推奨されていません。というのも、この2つは現在では現実的な時間で同一ハッシュ値(もしくは元データ)を得ることが可能であることが分かっているためです。ちなみに、元データが異なるのにハッシュ値が同一になることを「衝突」と呼びますが、ハッシュ関数には衝突への耐性が求められます(強衝突耐性)。

で、上の例ではSHA256を使用しています。「電子政府における調達のために参照すべき暗号のリスト(CRYPTREC暗号リスト)」にもあるもので、安全であると見なされています(SHA384、SHA512についても同様)。

ComputeHashに渡す値はbyte配列ですので、文字列から変換しています。
また、得られたハッシュ値もbyte配列なので、BitConverterで16進数の文字列に変換しています。

「そもそも、ハッシュ値って何?」という方のために

まぁ、ここで説明するよりググって頂いたほうが詳しい説明が見つかりますが、念のため書きますと、パスワード認証の実装で使用したり、改ざんの検知にも使用されるもので、ある元となる値ないしデータ(上の例で言えば「千尋」)をある関数(ハッシュ関数)に通すことで得られる、不可逆な固定長の値のことを指します。SHAの後ろの数字は、それぞれ出力されるビットの長さを表します。

ちなみに、このようなハッシュ関数を暗号学的ハッシュ関数と呼び、ハッシュ値はメッセージダイジェストとも呼ばれることもあります。

パスワード認証の場面だと、DB内にパスワードをプレーンテキストで保管するのを避けるため、ハッシュ値に変換するという使い方をします。ですので、衝突の発見が容易だったり、ブルートフォースで元データが現実的な時間で特定可能だったりすると、認証突破の危険性を持つことになります。

また、改ざんの検知では、ダウンロードしたファイルが改ざんされていないかを検知するのに利用されます。サイトのダウンロードページなどにハッシュ値が掲載されていることがあるので、たまに見るかな…と。
あと、Webサイト(アプリ)制作の際、CDNリソースを参照する際にintegrity属性を付けることがありますが、ここで指定する値もハッシュ値です。SRI(サブリソース完全性)で検索すると出てくると思います。

.NETだとパスワード認証を実装する必要がある時は、この機能は使用するかもしれませんね。

アルゴリズムを選べるようにしてみる

上の例だとSHA256でのみ出力するので、選べるようにしてみましょう。
(※追記:条件分岐の箇所はタプルとC# 8.0から追加されたswitch式で、実はもう少しすっきり書けます。具体的には、コメント欄をご覧ください)

using System;
using System.Security.Cryptography;
using System.Text;

Console.WriteLine("契約書だよ。そこに名前を書いてハッシュアルゴリズムを選びな。");

//名前(ハッシュコード計算の基となる値)
Console.Write("名前:");
var name = Console.ReadLine();

//ハッシュアルゴリズム選択
Console.Write("アルゴリズム[MD5/SHA1/SHA256/SHA384/SHA512]:");
var algorism = Console.ReadLine();
algorism = algorism.ToUpper();

HashAlgorithm Hash;
switch (algorism)
{
    case "MD5":
        Hash = MD5.Create();
        break;
    case "SHA1":
        Hash = SHA1.Create();
        break;
    case "SHA256":
        Hash = SHA256.Create();
        break;
    case "SHA384":
        Hash = SHA384.Create();
        break;
    case "SHA512":
        Hash = SHA512.Create();
        break;
    default:
        Console.WriteLine("アルゴリズムの選択間違ってるよ!");
        Hash = SHA256.Create(); //dummy
        Environment.Exit(0);
        break;
}

Console.WriteLine($"フン。{name}というのかい。贅沢な名だねぇ。");

//ハッシュ値を算出
byte[] namebyte = Encoding.UTF8.GetBytes(name);
byte[] hashbyte = Hash.ComputeHash(namebyte);
string hashstring = BitConverter.ToString(hashbyte);

Console.WriteLine($"今からお前の名前は{hashstring}だ。いいかい、{hashstring}だよ。分かったら返事をするんだ、{hashstring}!!");

行がちょっと長いですが、実行すると以下のようになります。

契約書だよ。そこに名前を書いてハッシュアルゴリズムを選びな。
名前:千尋    
アルゴリズム[MD5/SHA1/SHA256/SHA384/SHA512]:SHA512
フン。千尋というのかい。贅沢な名だねぇ。
今からお前の名前は68-C3-EC-FC-8C-B7-F2-C9-42-DF-AC-B0-5F-DA-EC-2F-C3-81-0A-89-87-21-F1-55-D1-F5-CE-AC-DF-23-46-1D-25-29-D8-50-75-3D-F5-60-55-F6-FA-D1-CD-EB-14-DD-E0-3F-C0-09-E0-9D-99-A5-24-83-7D-8A-46-25-8B-A2だ。いいかい、68-C3-EC-FC-8C-B7-F2-C9-42-DF-AC-B0-5F-DA-EC-2F-C3-81-0A-89-87-21-F1-55-D1-F5-CE-AC-DF-23-46-1D-25-29-D8-50-75-3D-F5-60-55-F6-FA-D1-CD-EB-14-DD-E0-3F-C0-09-E0-9D-99-A5-24-83-7D-8A-46-25-8B-A2だよ。分かったら返事をするんだ、68-C3-EC-FC-8C-B7-F2-C9-42-DF-AC-B0-5F-DA-EC-2F-C3-81-0A-89-87-21-F1-55-D1-F5-CE-AC-DF-23-46-1D-25-29-D8-50-75-3D-F5-60-55-F6-FA-D1-CD-EB-14-DD-E0-3F-C0-09-E0-9D-99-A5-24-83-7D-8A-46-25-8B-A2!!

MD1、SHA1、SHA256、SHA384、SHA512は、いずれも基底クラスがHashAlgorithmです。どれも、Createメソッドでインスタンスを作成し、ComputeHashでハッシュ値を生成します。

アルゴリズムの指定を間違うと、怒られて終了します。

以上、C#で実装する「セキュアな湯婆婆」でした。

ソルトとストレッチングを実装して、より安全な湯婆婆にする(PBKDF2対応)

以下、2020/11/15追記分です。

ハッシュ値から元のパスワードを解析する手法もないわけではない

MD5とSHA1は現在では安全性が担保されていないことは上でも書きましたが、他にも、レインボーテーブルを使用した攻撃とか、攻撃対象のシステムにアカウントを作成して何らかの方法(SQLインジェクションとか)でハッシュ値を盗み出し、同一ハッシュ値を発見することでパスワードを特定するという方法があり得ます。この辺りは、↓の書籍が詳しいのでご興味がある方はどうぞ。

  • 徳丸浩著 『体系的に学ぶ安全なWebアプリケーションの作り方 脆弱性が生まれる原理と対策の実践』 SBクリエイティブ、2018

特に後者の方法をされてしまうと、上で説明した、単にSHA256でハッシュ値を求めただけの方法だと心許ない気がしますね。

で、ハッシュ解読を困難にする対策としては、以下のソルトを付加することと、ストレッチングがあります。

ソルト(salt)

ハッシュ値の元データ(パスワードなど)に付加する文字列です。レインボーテーブルを使用した攻撃の対策としてはある程度の長さのパスワードが求められるので、元の文字列にソルトを付けて長さを確保します。加えて、ソルトをユーザー毎に異なるものにすることで、別々のユーザーが作成したパスワードが仮に同一だった場合でも、同じハッシュ値を生成させないようにできます。

ストレッチング(stretching)

ハッシュ計算を繰り返し実行することで計算をより困難にさせる、という方法です。

これらは、わざわざ自分でゼロから実装する必要はありません。
OWASPのサイト(Password Storage Cheat Sheet - Custom Algorithms)で独自にアルゴリズムを組むことはやめるべきと書いてありますが(わざわざreally hardとかDo not do thisと太字で書いてある)、完成されたものがあるなら、わざわざ作るのもリスクになる気がします。

ソルトやストレッチングの仕組みを備えたパスワード保管により適したハッシュ関数は既に考案されていて、BCrypt、Argon2、PBKDF2があります。.NETでは最初からPBKDF2 (Password-Based Key Derivation Function 2)が使用可能です。ちなみに、BCryptとArgon2はNugetから入手できます。

湯婆婆にもっと安全なハッシュ値を出力させてみる

前置きが長くなりましたが、PFKDF2を実装した湯婆婆が以下になります。
Rfc2898DeriveBytesというクラスを使用します。

using System;
using System.Security.Cryptography;

Console.WriteLine("契約書だよ。そこに名前を書きな。");
var name = Console.ReadLine();
Console.WriteLine($"フン。{name}というのかい。贅沢な名だねぇ。");

//ソルトを作成(最低8byte必要)
byte[] salt1 = new byte[8];
using (RNGCryptoServiceProvider rngCsp = new RNGCryptoServiceProvider())
{
    rngCsp.GetBytes(salt1);
}

//ハッシュ値を算出
string hashstring = string.Empty;
//ストレッチングの反復処理回数1000、アルゴリズムにSHA256
using (Rfc2898DeriveBytes pbkdf2 = new Rfc2898DeriveBytes(name, salt1, 1000, HashAlgorithmName.SHA256))
{
    byte[] hashbyte = pbkdf2.GetBytes(32);
    hashstring = BitConverter.ToString(hashbyte);
}

//出力
Console.WriteLine($"今からお前の名前は{hashstring}だ。いいかい、{hashstring}だよ。分かったら返事をするんだ、{hashstring}!!");
Console.WriteLine($"そして、これがソルトだよ!:{BitConverter.ToString(salt1)}");

ちなみに、ソルトをRNGCryptoServiceProviderクラスを使用して取得していますが、Rfc2898DeriveBytesのインスタンス作成時の第2引数(上の例で言えばsalt1)を整数にすることで、自動でソルトを作成させることも可能です。その場合、pbkdf2.Saltでソルトのbyte配列を取得可能です。文字列で取り出すなら、BitConverter.ToString(pbkdf2.Salt)ですね。

で、出力はこんな風です。

契約書だよ。そこに名前を書きな。
千尋
フン。千尋というのかい。贅沢な名だねぇ。
今からお前の名前は19-A3-4B-9E-D8-25-4E-A7-12-02-86-D7-AB-6D-D7-7B-8D-C6-CB-B9-0C-C8-70-18-44-64-6F-DF-2D-63-AF-C6だ。いいかい、19-A3-4B-9E-D8-25-4E-A7-12-02-86-D7-AB-6D-D7-7B-8D-C6-CB-B9-0C-C8-70-18-44-64-6F-DF-2D-63-AF-C6だよ。分かったら返事をするんだ、19-A3-4B-9E-D8-25-4E-A7-12-02-86-D7-AB-6D-D7-7B-8D-C6-CB-B9-0C-C8-70-18-44-64-6F-DF-2D-63-AF-C6!!
そして、これがソルトだよ!:AF-70-37-73-C6-E3-22-A9

せっかくなので、ハッシュ値を突合させる処理も書いてみました。
色々とハードコーディングしていますが、例なので、ご容赦下さい。

using System;
using System.Security.Cryptography;

string name = "千尋";
//前回決めたソルト
string salt = "AF-70-37-73-C6-E3-22-A9";
//前回求めたハッシュ値
string prevhash = "19-A3-4B-9E-D8-25-4E-A7-12-02-86-D7-AB-6D-D7-7B-8D-C6-CB-B9-0C-C8-70-18-44-64-6F-DF-2D-63-AF-C6";

//ソルトをbyte配列に変換
string[] saltstrings = salt.Split("-");
byte[] saltbytes = new byte[saltstrings.Length];
for (int i=0; i < saltstrings.Length; i++)
{
    saltbytes[i] = Convert.ToByte(saltstrings[i], 16);
}

//ハッシュ値を算出
string hashstring = string.Empty;
using (Rfc2898DeriveBytes pbkdf2 = new Rfc2898DeriveBytes(name, saltbytes, 1000, HashAlgorithmName.SHA256))
{
    byte[] hashbyte = pbkdf2.GetBytes(32);
    hashstring = BitConverter.ToString(hashbyte);
}

//結果判定を出力
if (prevhash.Equals(hashstring))
{
    Console.WriteLine("OK");
} else
{
    Console.WriteLine("NG");
}

以上です。
よりパスワードクラッキングされにくい、安全な湯婆婆ができあがりました。

BCryptにも対応させてみる

以下、2020/11/16追記分です。

せっかくなので、パスワード保管に適しているとされる他のハッシュ関数も実装してみます。.NETに標準で使用可能なのは上に書いたPBKDF2ですが、BCryptについてはNugetから入手します。

いくつかあるようですが、今回は↓を使用します(Nugetからのインストールの仕方については…お手数ですが、ググってください)。

早速、コードです。
すっきりしていますね。
cost = 11はストレッチングの回数ですが、例の場合、$2^{11}$回数分処理します。最大31まで指定可能ですが、あまり大きくすると負荷が高くなります(未指定の場合はデフォルトの11)。

using System;
using Bcrypt = BCrypt.Net.BCrypt;

Console.WriteLine("契約書だよ。そこに名前を書きな。");
var name = Console.ReadLine();
Console.WriteLine($"フン。{name}というのかい。贅沢な名だねぇ。");

//ハッシュ値を算出
int cost = 11; //ストレッチングの回数 (2^11回)
string hashstring = Bcrypt.EnhancedHashPassword(name, cost);

//出力
Console.WriteLine($"今からお前の名前は{hashstring}だ。いいかい、{hashstring}だよ。分かったら返事をするんだ、{hashstring}!!");

出力結果は以下のようになります。


千尋
フン。千尋というのかい。贅沢な名だねぇ。
今からお前の名前は$2a$11$Z0h6Wc9xrSyRVntye6WecuIdurJzvNP7UvfY4yvZlKBBZPIjOA9W2だ。いいかい、$2a$11$Z0h6Wc9xrSyRVntye6WecuIdurJzvNP7UvfY4yvZlKBBZPIjOA9W2だよ。分かったら返事をするんだ、$2a$11$Z0h6Wc9xrSyRVntye6WecuIdurJzvNP7UvfY4yvZlKBBZPIjOA9W2!!

詳しくはググって頂きたいですが、出力した文字列の中にcost、ソルト、ハッシュ値が全て含まれています(ソルトは自動生成)。DBへの格納が簡単ですみます。

ちなみに、検証は以下のようにします(trueが返されたら、OK)。

bool validate = Bcrypt.EnhancedVerify(name, hashstring);
Console.WriteLine(validate);

さらに、Argon2を実装してみる

なんでも、Password Hashing Competitionなる競技会で優勝したものなのだとか。

使用にはパラメータの操作が必要です(実行時間、使用メモリ、並列処理数)。上のOWASPサイトでは、パラメータ設定のやり方が分からない場合、Bcryptがよりよい方法かもしれないと説明されています。

参考までに、PHPのArgon2のデフォルトパラメータは以下のようになっています。

memory_cost = 1024 KiB
time_cost = 2
threads = 2

どのパラメータが最適かについては複雑そうなので、今は触れないことにします(また分かったら追記します)。

今回は、以下のライブラリを使用してみます。

パラメータはArgon2Configクラスをインスタンス化して値をセットし、Argon2のコンストラクタの引数として与えてやりますが、一応デフォルト値がセットされているようです(今回は何にしません)。

using System;
using Isopoh.Cryptography.Argon2;

Console.WriteLine("契約書だよ。そこに名前を書きな。");
var name = Console.ReadLine();
Console.WriteLine($"フン。{name}というのかい。贅沢な名だねぇ。");

//ハッシュ値を算出
string hashstring = Argon2.Hash(name);

//出力
Console.WriteLine($"今からお前の名前は{hashstring}だ。いいかい、{hashstring}だよ。分かったら返事をするんだ、{hashstring}!!");

結果は以下の通りです。

契約書だよ。そこに名前を書きな。
千尋
フン。千尋というのかい。贅沢な名だねぇ。
今からお前の名前は$argon2id$v=19$m=65536,t=3,p=1$h0XiPFdMvZzkWt5YePRd8Q$+GPj6ztOfXuaKsjbLED4dy0sYmOgzE1SCnDADzUe0Ooだ。いいかい、$argon2id$v=19$m=65536,t=3,p=1$h0XiPFdMvZzkWt5YePRd8Q$+GPj6ztOfXuaKsjbLED4dy0sYmOgzE1SCnDADzUe0Ooだよ。分かったら返事をするんだ、$argon2id$v=19$m=65536,t=3,p=1$h0XiPFdMvZzkWt5YePRd8Q$+GPj6ztOfXuaKsjbLED4dy0sYmOgzE1SCnDADzUe0Oo!!

BCryptと同様、1つの文字列にパラメータ値やハッシュ値がまとめられているようです。

検証は、以下のようにします。
trueが返されたら、OK。

bool validate = Argon2.Verify(hashstring, name);
Console.WriteLine(validate);

以上です。
調子に乗って、結構、長い記事になってしまいました。

24
12
8

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
24
12