C# で TOTP を生成する方法

TOTP とは?

TOTP は Time-based One-Time Password の略です。RFC6238 で定義されており、主に2段階認証を行うためのワンタイムパスワードに使われます。

メールやSMSを利用するのと異なり、コードは一切ネットワーク上を行き来しないために安全性が高まります。

TOTP は HOTP を基にされていて、HOTP のカウンターとして「タイムカウンター」を使用します。

タイムカウンターは Unix 時間から計算され、30秒区切りのカウンターなら Unix 時間を 30 で割った商が「タイムカウンター」になります。

このタイムカウンターと、前のページで実装方法を説明したシークレットキーから TOTP が算出できます。

Google 認証システムにおける TOTP の実装

TOTP は RFC6238 に記載があります。詳細についてはRFCを参考にしてください。

Google 認証システムで使うことを考え、 Base32 でエンコードしたシークレットキーから TOTP を生成します。もっとも、サーバー側ではバイナリで持っていても良いのでしょうが、クライアントには Base32 のエンコードを渡すのでスタート地点はバイト列でなく文字列とします。

大まかな流れとしては次のようになっています。

  1. Unix 時間から現在のタイムカウンターを計算する
  2. Base32 のシークレットキーをバイト列に戻す
  3. タイムカウンター C を8バイトのバッファに格納する (8バイトは HOTP で決められた値)。さらにネットワークバイトオーダーに変換 (後述)
  4. キーとカウンター値から HMAC-SHA1 値を計算 (これは常に20バイト長)
  5. 最後のバイトの4ビットから「オフセット」を取得 (4ビットなので0から15のオフセット)
  6. HMAC-SHA1 値のバイト列の先頭から上記オフセット値の場所から4バイト取り出して「コード」を作成
  7. 6桁の数値とするため 1,000,000 で割った余り (要は下6桁) を TOTP とする

これを念頭においていれば、下記のコードは簡単にわかると思います。

C# による TOTP の実装方法

上記の TOTP の生成の流れを実装すると、下記のようになります。

using System;
using System.Security.Cryptography;

namespace GenerateTOTP
{
  class Program
  {
    /*
    otpauth://totp/mytest:user1?secret=B2RHV2XOFG4ECO7TWRNBWMB6J3DSH2BR
    */
    static void Main(string[] args)
    {
      // サンプルのシークレットキー
      const string S = "B2RHV2XOFG4ECO7TWRNBWMB6J3DSH2BR";
      Console.WriteLine(GenerateTOTP(S));
      Console.WriteLine(GenerateTOTP(S, -1));
    }

    static long GetUnixEpoch()
    {
      var span = DateTime.UtcNow - new DateTime(1970, 1, 1);
      return (long)span.TotalSeconds;
    }

    static int GenerateTOTP(string S, int window = 0)
    {
      var unixEpoch = GetUnixEpoch();
      var C = (long)unixEpoch / 30;
      return GenerateHOTP(S, C + window);
    }

    static int GenerateHOTP(string S, long C)
    {
      var secret = Base32.FromBase32String(S);

      using (var sha1Hmac = new HMACSHA1(secret))
      {
        var counter = BitConverter.GetBytes(C);
        Array.Reverse(counter, 0, counter.Length);

        var hs = sha1Hmac.ComputeHash(counter);
        var offset = hs[hs.Length - 1] & 0xf;
        var code = ((hs[offset] & 0x7f) << 24)
          | ((hs[offset + 1] & 0xff) << 16)
          | ((hs[offset + 2] & 0xff) << 8)
          | ((hs[offset + 3] & 0xff));

        return code % 1000000;
      }
    }
  }
}

カウンターのバイトオーダー

HMACでカウンター値はネットワークバイトオーダーとすることになっています。上記コードでは、リトルエンディアンの環境を想定しているのでいきなりバイト列を Reverse しています。(39行目)

ポータブルなコードにするなら、恐らくその点は実行環境のバイトオーダーをチェックし、必要なら Reverse するという方が恐らく良いのでしょう。手元に環境がないので試していませんが。

        var counter = BitConverter.GetBytes(C);
        if (BitConverter.IsLittleEndian)
        {
          Array.Reverse(counter, 0, counter.Length);
        }

TOTP の有効期間について

TOTP はクライアントとサーバーの双方で、共有されたシークレットキーとタイムカウンターを用いて、現時点の TOTP を計算することによって認証を行います。

タイムカウンターは Unix 時間を使います。もし、クライアントとサーバーで時計が大きくズレていたら、タイムカウンターも異なることになるので認証は成功しません。

また、Google 認証システムでは TOTP の有効期間はデフォルトで30秒です。

仮にクライアントとサーバーの時計が正確に合っていたとしても、30秒の間にユーザーは Google 認証システムのアプリで TOTP を確認して、それを入力し、サーバーへの送信を全て完了しないといけないことになります。

この制限を緩和するには、現在のタイムカウンターからひとつ、あるいはふたつ遡り、TOTP をチェックすることで、認証の許容範囲を広げることができます。

上記コードの window-1 を渡すことで、タイムカウンターが現在よりも一つ少ないときの TOTP が計算できます。 このようにすることで、サーバー側で認証可能 (受け入れ可能な) な TOTP を増やすことができます。

もちろん、あまりにワンタイムパスワードの有効期限を長くすることはセキュリティの低下となるので、運用には充分注意しましょう。

ここまでお読みいただき、誠にありがとうございます。SNS 等でこの記事をシェアしていただけますと、大変励みになります。どうぞよろしくお願いします。

© 2024 C# 入門