今さらTOTPクライアントを実装する

前々から二要素認証に用いられているTOTPについて確認しておきたかったので、この土曜を使ってTOTPによるワンタイムパスワード生成をGoで実装した。このnoteはその過程です。

ちなみにTOTPクライアントについては他にも実装されている方がいて、解説や用いられている語句等はそちらの方が仕様に対して厳密だと思います。

そもそもTOTPによるワンタイムパスワードって?

二要素認証のために多くの方がスマホにインストールしている(よね)Google Authenticatorに表示される6桁のアレ。時間が経過すると勝手に更新されていくアレです。アレはTOTPによって生成されていて、その仕組みを知りたいというのがモチベーションになっています。 

仕様

RFC 6238 がTOTPの仕様です。ただこれ単体じゃなくて、TOTPが前提としているHOTPという仕様があって、これが RFC 4226 になります。TOTPはHOTPに毛が生えたようなものなので、ワンタイムパスワード生成のためには大体HOTPの仕様を見ることになります。

HOTPの仕様

なのでまずはHOTPによるワンタイムパスワード生成の仕様を見るわけです。HOTPでは、事前に何らかの方法でHOTPを用いて認証を行うサーバーと秘密鍵を交換していることが前提になります。恐らく、ほとんどのWebサービスではこれをQRコードの読み取りによって行っているじゃないかと思います。

また、秘密鍵に加えてcounterと呼ばれるサーバーと同期された値も必要になります。このcounterを時刻を元に生成するようにしたのがTOTPですが、今は置いておきます。で、この2つの値を元にワンタイムパスワードを生成するのがHOTPです。生成の流れは以下の通り。

1. 秘密鍵と、counterをメッセージとしてHMAC-SHA1を計算し20byteのMACを得る
2. MACの末尾4bitoffsetと呼ばれる数値として扱う
3. MAC中のoffsetから連続する4バイトをbin_codeと呼ばれる数値として扱う
4. binCodeと1,000,000の剰余演算を行い、結果をワンタイムパスワードとする

以上です。結構チョロそうじゃないですか?各ステップにおいてはもうちょっと考慮するべき細かい点がありますが、それは実装と共に見ていきましょう。

MACの計算

Goで書くと以下のような感じになります。

func hmacSha1(key []byte, counter uint64) []byte {
	counterBytes := make([]byte, 8)
	binary.BigEndian.PutUint64(counterBytes, counter)

	hm := hmac.New(sha1.New, key)
	hm.Write(counterBytes)
	return hm.Sum(nil)
}

counterの型をuint64としたのは、HOTPの仕様で8バイトの整数として扱うことが明記されているためです。また、メッセージとして扱う際のエンディアンはビッグエンディアンであるためbinary.BigEndian.PutUint64を用いています。当然のように計算されたMACを返します。

ワンタイムパスワードの生成

HOTPの仕様で、MACからワンタイムパスワードを生成する処理がTruncateと呼ばれているので以下の実装でも関数名をそれに合わせています。

func truncate(hmacResult []byte) uint32 {
	offset := hmacResult[len(hmacResult)-1] & 0x0F
	binCode := binary.BigEndian.Uint32(hmacResult[offset:offset+4]) & 0x7FFFFFFF
	return binCode % 1000000
}

上述の通り、MACの末尾4bitがoffsetで、MAC中のoffsetから連続する4バイトがbin_codeです。bin_codeはもう少し細かい仕様があり、これを整数として扱う際の最上位ビットは除くこととなっているため0x7FFFFFFFでマスクしています。なお整数として扱う際のエンディアンはビッグエンディアンです。
あとはbin_codeと1,000,000の剰余の結果を計算すれば、それがワンタイムパスワードとなります。

HOTPによるワンタイムパスワード生成

というわけで、hmacSha1関数とtruncate関数を合わせてHOTPによるワンタイムパスワード生成は以下のように記述できます。

func hotp(key []byte, counter uint64) uint32 {
	return truncate(hmacSha1(key, counter))
}

HOTPが計算できればTOTPも出来たようなもんですよ。

TOTPによるワンタイムパスワード生成

TOTPは、HOTPでcounterと呼ばれていた値を時刻を元に生成するようにしただけです。TOTPにおけるこの値Tは以下の要素から、

T0: UNIXタイムを計測し始めた始点の時間。通常は0秒
X: TOTPが生成したワンタイムパスワードが更新されるまでの1サイクルの時間。ほとんどのサービスで30秒

次の式で計算されます。

T = (Current Unix time - T0) / X

除算の余りは切り捨てて良いことになっています。
このTを単にHOTPのcounterとして使えば良いだけなので、TOTPは次のように記述できます。

func totp(key []byte) uint32 {
	return hotp(key, uint64(time.Now().Unix()/30))
}

T0は0であることを前提に省略し、Xも30秒を前提としてハードコードしています。これでTOTPによるワンタイムパスワード生成も完成です。

Google Authenticatorと比べる

せっかくTOTPによるワンタイムパスワードを生成できるようになったので、Google Authenticatorのそれと比べたいところです。これまでの内容を用いて以下のような簡単なmain関数を記述し、

func main() {
	key, err := base32.StdEncoding.DecodeString(os.Args[1])
	if err != nil {
		fmt.Fprintf(os.Stderr, "invalid secret: %s", err.Error())
		return
	}
	fmt.Printf("One time digit: %d\n", totp(key))
}

実行時の引数に秘密鍵を渡せば良いです。サービスにもよりますが、二要素認証設定時に表示されるQRコードをQRコードリーダーで読み取れば秘密鍵入りのURIが読み取れるはずです。このURIの仕様についてはGoogle Authenticatorの仕様がデファクトスタンダードなようです。秘密鍵はBASE32でエンコードされている点に注意。

というわけでTwitterの二要素認証設定画面で表示されるQRコードで試してみたところ、無事ワンタイムパスワードの値が一致しました。

チョロかった。このnote書く方が時間かかりました。

この記事が気に入ったらサポートをしてみませんか?