sh1’s diary

プログラミング、読んだ本、資格試験、ゲームとか私を記録するところ

Unity パスワードを固定長(64 文字)のハッシュ値に変換する

f:id:shikaku_sh:20200214142400p:plain
SQLCipher のパスワード

前回までの記事で、SQLCipher を使って暗号化した SQLite の使い方について説明をしました。

SQCipher のデータベースにアクセスするときには、必ずパスワードを入力・設定する必要があります。

というわけで、今回はこのパスワードの管理について、(個人的に)まとめた記事です。

SQLCipher にオススメのパスワード形式

SQLCipher の「Setting the Key」に説明がありました。

SQLCipher にわたすパスワード(パスフレーズ)を、どこまで SQLCipher まかせにするかといったところで、アクセス速度に差がでます。

パスワードは、そのテキストのまま利用しているわけではなくて、1から3の方向にハッシュ化したり、ソルトを付与したり、ストレッチング(イテレーション)を与えて、最終的なパスワード(ハッシュ値)を割り出しにくくしています。

  1. abcdef
    自由なテキストで表現されたパスワード
  2. 2DD29CA851E7B56EB0E1F...(64 文字)
    16 進数で表現された 64 文字のテキストのパスワード
  3. 2DD29CA851E7B56EB0E1F...(64 文字) + 101010...(32 文字)
    16 進数で表現された 64 文字と、sait として利用する 2 進数で表現された 32 文字のテキストの組み合わせのパスワード

この流れからわかるとおり、1ほど SQLCipher が内部でパスワードを処理するコストが重く、2、3ほどコストが小さくなります。ここから先はケース・バイ・ケースとなりますが、個人的には「2」がベターな選択じゃないかと考えました。

なので、2を想定したパスワードのハッシュ化の方法についても記載しています。

1は、SQLCipher のおまかせ管理。でも、DB アクセスのコストが高く(ハッシュ化のストレッチングが 64,000 回)、生パスワードの管理が求められるのも違和感があります。

2は、アクセスコストが改善するし、パスワードのハッシュ化を(自分で)やる。チート対策には具合がいいように思います。一応、どの程度が適切なのかは、要件(ゲームがどの程度の速度・セキュリティを必要とするか)と、実例から判断するとよいと思います。(CRYPTREC 暗号リストなども)

ハッシュ化する仕組みの例

今回は、以下のような要件を満たすパスワードハッシュ化の仕組みを用意することにしました。素早いハッシュ化の要件を満たすために、ストレッチングの回数を調整する感じです。(実行環境にも依るところなので、参考値程度に)

  1. ハッシュ化アルゴリズムは PBKDF2 を採用する(※NIST が認めているもの)
  2. salt を付与すること
  3. ストレッチングはするけど、負荷を考慮して軽い回数 (OWASP は 10,000 回以上を推奨してるけど1)
  4. ハッシュ化に要する時間は 50ms 以下

salt の生成

salt もパスワードと同じようにプログラムが管理します。OWASP では 32/64 byte のサイズが推奨みたいですので、64 文字の salt の値を生成しておきます。

こんな感じで 64 byte の雑な salt を用意することにしました。

public static class Program {
    public static void Main() {
        
        var salt = "Y7tl32r32we9X1FkwamTttjWsK6gPgjbEJX0mdWNwjW825JgAOo5FLNB22JqGQpr";
        
        Console.WriteLine(System.Text.Encoding.UTF8.GetBytes(salt).Length);
    }
}

f:id:shikaku_sh:20200217191504p:plain:w400
64 バイト。これで問題なさそう

ハッシュ化するためのクラス(サンプル)

ハッシュ化するための部分は「Rfc2898DeriveBytes」クラスにおまかせ。

sing System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Threading.Tasks;

/// <summary>
/// <see cref="Hash" /> クラスは、任意のテキスト値から固定長のハッシュ値に変換するクラスです。
/// <para>
/// salt 値は 8 バイト以上、演算の反復処理回数は 1 回以上を指定する必要があります。
/// </para>
/// </summary>
public class Hash
{
    #region Fields

    private int _Iteration = 1024;

    #endregion

    #region Initializes

    /// <summary>
    /// <see cref="Hash"/> クラスの新しいインスタンスを初期化します。
    /// </summary>
    public Hash() { }

    #endregion

    #region Public Methods

    /// <summary>
    /// PBKDF2 に基づくハッシュ値を取得します。
    /// </summary>
    /// <param name="password">ハッシュ値を得るパスワード。</param>
    /// <param name="salt">ハッシュ値を派生させるために使用する salt の値。</param>
    /// <param name="size">生成するハッシュ値のバイト数</param>
    /// <returns>ハッシュ値を格納したバイト配列。</returns>
    /// <exception cref="ArgumentNullException">指定したパスワードは null または空白です。</exception>
    /// <exception cref="ArgumentNullException">指定した salt は null または空白です。</exception>
    /// <exception cref="ArgumentException">指定された salt のサイズが 8 バイト未満です。</exception>
    /// <exception cref="InvalidOperationException">反復処理回数は 1 回以上を指定してください。</exception>
    public byte[] GetPbkdf2(string password, string salt, int size = 32)
    {
        if (string.IsNullOrEmpty(password))
        {
            throw new ArgumentNullException("指定したパスワードは null または空白です。");
        }

        if (string.IsNullOrEmpty(password) || string.IsNullOrEmpty(salt))
        {
            throw new ArgumentNullException("指定した salt は null または空白です。");
        }

        var saltBytes = Encoding.UTF8.GetBytes(salt);

        if ((saltBytes?.Length ?? 0) <= 8)
        {
            throw new ArgumentException("指定された salt のサイズが 8 バイト未満です。");
        }

        if (_Iteration <= 0)
        {
            throw new InvalidOperationException("反復処理回数は 1 回以上を指定してください。");
        }

        return new Rfc2898DeriveBytes(password, saltBytes, _Iteration)?.GetBytes(size);
    }

    /// <summary>
    /// 演算の反復処理回数を変更します。
    /// </summary>
    /// <param name="iteration">演算の反復処理回数。</param>
    /// <exception cref="ArgumentOutOfRangeException">反復処理回数の回数は 1 回以上を指定してください。</exception>
    public void ChangeIteration(int iteration)
    {
        if (iteration <= 0)
        {
            throw new ArgumentOutOfRangeException("反復処理回数の回数は 1 回以上を指定してください。");
        }

        _Iteration = iteration;
    }

    #endregion

}

ただ、これだけだと使いづらいので、期待するハッシュテキストにアダプトしてくれるような生成用クラスを作成します。

/// <summary>
/// <see cref="HashGenerator"/> クラスは、パスワードのハッシュ値を生成するクラスです。
/// </summary>
public class HashGenerator
{
    #region Fields

    private static string _Salt = "qqWsf2yqy7vuZ9BDdwv0mlLnbhoTS6vw9Pr0NU5tD1aneMyydEv82lIxykeepfZ8";
    private static Hash _Hash = new Hash();

    #endregion

    #region Initializes

    /// <summary>
    /// <see cref="HashGenerator"/> クラスの新しいインスタンスを初期化します。
    /// </summary>
    public HashGenerator() { }

    #endregion

    #region Public Methods

    /// <summary>
    /// ハッシュ値を派生させるために使用する salt 値を変更します。
    /// <para>
    /// salt 値は 64 文字以上を設定する必要があります。
    /// </para>
    /// </summary>
    /// <param name="salt">変更する salt 値。</param>
    /// <exception cref="ArgumentException">salt の文字数は 64 文字以上を入力してください。</exception>
    public static void ChangeSalt(string salt)
    {
        if (salt.Length < 64)
        {
            throw new ArgumentException("salt の文字数は 64 文字以上を入力してください。");
        }

        _Salt = salt;
    }

    /// <summary>
    /// 指定したパスワードのハッシュ値を表すテキストを生成します。
    /// </summary>
    /// <param name="password">パスワードを表すテキスト。</param>
    /// <param name="length">ハッシュ値を表すテキストの文字数。</param>
    /// <returns>ハッシュ値を表すテキスト。</returns>
    /// <exception cref="ArgumentNullException">パスワードのテキストが空白です。</exception>
    /// <exception cref="ArgumentOutOfRangeException">ハッシュ値の長さは 1 以上を指定する必要があります。</exception>
    /// <exception cref="InvalidOperationException">ハッシュ値の生成に失敗しました。</exception>
    public static string Generate(string password, int length = 64)
    {
        if (string.IsNullOrEmpty(password))
        {
            throw new ArgumentNullException("パスワードのテキストが空白です。");
        }

        if (length <= 0)
        {
            throw new ArgumentOutOfRangeException("ハッシュ値の長さは 1 以上を指定する必要があります。");
        }
        
        var hash = _Hash.GetPbkdf2(password, _Salt, length / 2);

        if (hash == null || hash.Length != (int)(length / 2))
        {
            throw new InvalidOperationException("ハッシュ値の生成に失敗しました。");
        }

        var hashText = string.Join("", hash.Select(p => Convert.ToByte(p).ToString("x2")));

        // 奇数の場合は末尾に既定の文字 0 を追加する
        if (length % 2 != 0)
        {
            hashText += "0";
        }

        return hashText;
    }

    #endregion
}

実際つかうときは、こう。らくちんですね。

private void Run()
{
    var hashedPassword = HashGenerator.Generate("password");
    Console.WriteLine(hashedPassword);
}

テスト

最後に、要件をきちんと満たしているかどうかチェックするテストを用意しておきます。たとえばこんなのです。

[TestCase("wdDsJwK_uDdCi@")]
[TestCase("rance_sill_athena")]
[TestCase("password")]
[Timeout(50)]
public void Test_ハッシュテキストの文字数チェック(string password)
{
    var hash = HashGenerator.Generate(password);

    // 64 文字であることをチェック
    Assert.AreEqual(hash.Length, 64);
}

問題なし、想定外の使い方をしたら例外を投げるのもチェック。

f:id:shikaku_sh:20200217191251p:plain:w400
テストは作っておくほうがいいと思う

Unity でもコードを動かせるかチェック。問題なさそうです。

f:id:shikaku_sh:20200217191337p:plain:w400
sqlite3_key で開いたデータです

サンプル

今回のプログラムを GitHub に公開しています。

Samples のリポジトリーは、過去に作成したサンプルプログラムをまとめて公開しています。今回のプログラムは「EncryptHashSample」です。

参考

Pythonで学ぶアルゴリズムとデータ構造 (データサイエンス入門シリーズ)

Pythonで学ぶアルゴリズムとデータ構造 (データサイエンス入門シリーズ)

  • 作者:辻 真吾
  • 発売日: 2019/11/28
  • メディア: 単行本(ソフトカバー)
暗号技術のすべて

暗号技術のすべて

  • 作者:IPUSIRON
  • 発売日: 2017/08/03
  • メディア: 単行本(ソフトカバー)