この記事は公開から3年以上経過しています。
VSCodeとC#(.NET 5.0)を使い、お馴染みの2段階認証(TOTP)クライアントを実装してみましたので紹介します。実質100行未満のシンプルなコードのため、TOTP/OTPの原理をコードから理解するのにも最適です。
このプログラムは、私がスマホで愛用している二段階認証アプリが万が一使えなくなったときのバックアップ用に作成したものです。ここで紹介するサンプルソースで私が登録している十数個のTOTP認証で正しいワンタイムパスワードを表示していることを確認済ですが、これを参考に実用化や製品化を考えている場合は、参考WEBサイトの関連RFCなどを、しっかりとご確認ください(At your own risk)。
プログラム実行イメージ
プログラムを起動してTOTPのプライベートキー(TOTP登録時にWEBサイトから発行されたキー)を入力すると、30秒間隔でワンタイムパスワードを表示します。時刻を基準にした認証であることから、当該プログラムを実行する端末はNTP等を使いPCの時刻を正確に合わせておく必要があります。
このプログラムを利用した新規TOTP登録は絶対に行わないでください。プログラムが意図せず終了するなど、万が一プライベートキーが分からなってしまった場合、代替のログイン手段(リカバリーキーやメール、SMS認証)がないと、そのサービスには二度とログインできなくなる可能性があります。
サンプルソースコード(C#)
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Cryptography;
using System.Threading;
namespace _2FAS
{
internal static class Program
{
/// <summary>
/// Interval time(sec)
/// </summary>
private const int INTERVAL_SEC = 30;
/// <summary>
/// Digits()
/// </summary>
private const int NUM_OF_DIGITS = 6;
/// <summary>
/// Entry
/// </summary>
/// <param name="_">unused</param>
private static void Main(string[] _)
{
Console.WriteLine("Enter the TOTP private key encoded in base32.");
var privateKey = DecodeBase32(Console.ReadLine()).ToArray();
var isFirstTime = true;
while (true)
{
var remainingSec = (long)TimeSpan.FromTicks(DateTime.UtcNow.Ticks).TotalSeconds % INTERVAL_SEC;
if (isFirstTime || remainingSec == 0)
{
isFirstTime = false;
var totp = GetTOTP(privateKey, NUM_OF_DIGITS, INTERVAL_SEC);
Console.WriteLine(totp);
}
Console.Title = $"TOTP Client: TIME REMAINING:{INTERVAL_SEC - remainingSec,2}";
Thread.Sleep(1000);
}
}
/// <summary>
/// Get TOTP password
/// </summary>
/// <param name="privateKey">Private key</param>
/// <param name="numOfDigits">Number of digits in generated password</param>
/// <param name="interval">Password generation interval(sec)</param>
/// <returns>TOTP password</returns>
private static string GetTOTP(byte[] privateKey, int numOfDigits, int interval)
{
var counter = (long)(DateTime.UtcNow - DateTime.UnixEpoch).TotalSeconds / interval;
return GetOTP(privateKey, counter, numOfDigits);
}
/// <summary>
/// Get OTP password
/// </summary>
/// <param name="privateKey">Private key</param>
/// <param name="iteration">Iteration number</param>
/// <param name="numOfDigits">Number of digits in generated password</param>
/// <returns>OTP</returns>
private static string GetOTP(byte[] privateKey, long iteration, int numOfDigits)
{
var counter = BitConverter.GetBytes(iteration);
if (BitConverter.IsLittleEndian)
Array.Reverse(counter);
var hmacSha1 = new HMACSHA1(privateKey, true);
var hmacResult = hmacSha1.ComputeHash(counter);
var offset = hmacResult[^1] & 0xf;
var binCode = ((hmacResult[offset] & 0x7f) << 24)
| ((hmacResult[offset + 1] & 0xff) << 16)
| ((hmacResult[offset + 2] & 0xff) << 8)
| (hmacResult[offset + 3] & 0xff);
var password = binCode % (int)Math.Pow(10, numOfDigits);
return password.ToString(new string('0', numOfDigits));
}
/// <summary>
/// Decode Base32 string into enumerable byte data
/// </summary>
/// <param name="encodedString">Base32 encorded string</param>
/// <returns>Decoded byte data</returns>
private static IEnumerable<byte> DecodeBase32(string encodedString)
{
var numOfBit = 0;
byte decoded = 0;
foreach (var base32Char in encodedString.ToUpper())
{
var base32Val = 0;
switch (base32Char)
{
case >= 'A' and <= 'Z':
base32Val = base32Char - 65;
break;
case >= '2' and <= '7':
base32Val = base32Char - 24;
break;
}
var bitMask = 0x10;
for (var i = 0; i < 5; ++i)
{
decoded |= (byte)((base32Val & bitMask) != 0 ? 1 : 0);
if (++numOfBit == 8)
{
yield return decoded;
numOfBit = 0;
decoded = 0;
}
decoded <<= 1;
bitMask >>= 1;
}
}
}
}
}
参考ウェブサイトなど
以上です。