2020.04.02

ワンタイムパスワードの実装・導入は難しいのか?

こんにちは。次世代システム研究室のM.Mです。

現在担当しているサービスで、ログイン時にID、パスワードの他に、Google Authenticatorなどで表示された30秒間隔で変わる6桁の数字のワンタイムパスワード(TOTP: Time-Based One-Time Password Algorithm)を入力してログインする仕組みのものがあります。

そのワンタイムパスワードの機能はアカウント数に応じて課金される有償の認証系サービスを使って実現しているのですが、コストもかかるので、自分で実装、導入してコスト削減できればなと考えておりました。

すでにTOTPに関する資料は多く存在しており、サンプルコードやライブラリも公開されていましたが、さすがに認証系機能なので、理解も深めておきたいということで、実際にTOTPをPHPで実装して動かしてみることにしました。

QRコードの表示はライブラリ(chillerlan/php-qrcode)を使わせてもらいました。
https://github.com/chillerlan/php-qrcode

今回はその実装内容の紹介をしたいと思います。

目次

  1. 3つの機能を作るだけで実現可能!
  2. それぞれの実装について
  3. まとめ

3つの機能を作るだけで実現可能!

1.Google Authenticatorに読み込ませる秘密鍵を作成する機能

ユーザー単位にサーバー側とクライアント側(Google Authenticator側)とで同じ秘密鍵を持つことで、TOTPが実現されています。
クライアント側(Google Authenticator側)に秘密鍵を渡すことになるのですが、単純な文字列ではなくBase32でエンコードする必要があるので、秘密鍵をBase32する機能を作ります。

2.Google Authenticatorに読み込ませるQRコードを表示する機能

Google Authenticatorに秘密鍵などの情報を読み込ませるのはQRコードでなくても、手入力でも可能ですが、QRコードで読み込ませるのが一般的なので、上記1.にて作成したBase32された秘密鍵などを含んだ情報をQRコードで表示する機能を作ります。

3.ワンタイムパスワードをチェックする機能

上記、1、2を行いGoogle Authenticatorで読み込むと、30秒間隔で変わる6桁の数字(ワンタイムパスワード)が表示されます。
実際にはその表示された6桁の数字をID/パスワードと共にログイン画面で入力してログインすることになります。
その入力された6桁の数字が正しいかサーバー側でチェックする機能を作ります。

それぞれの実装について

1. Google Authenticatorに読み込ませる秘密鍵を作成する機能

Google Authenticatorに読み込ませる秘密鍵について調べると、以下の通り、パディングが不要でBase32でエンコードされた値とあります。
https://github.com/google/google-authenticator/wiki/Key-Uri-Format
Parameters/Secretの箇所に

REQUIRED: The secret parameter is an arbitrary key value encoded in Base32 according to RFC 3548. The padding specified in RFC 3548 section 2.2 is not required and should be omitted.

また、Base32を調べると、以下のPHPのマニュアルにBase32でエンコード、デコードできるBase32というクラスの記載がありました。
https://www.php.net/manual/ja/function.base-convert.php#102232
今回はエンコードしか使わないので、その部分だけ切り抜いて関数にしました。
ソースコードは以下になります。それほど難しいことをしている訳ではなさそうです。

<?php 

// 秘密鍵
$secret = 'fsofe8fe#3d3';


echo base32_encode($secret, false);

function base32_encode($input, $padding = true)
{
   $map = array(
        'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', //  7
        'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', // 15
        'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', // 23
        'Y', 'Z', '2', '3', '4', '5', '6', '7', // 31
        '='  // padding char
    );
    
    if(empty($input)) return "";
    $input = str_split($input);
    $binaryString = "";
    for($i = 0; $i < count($input); $i++) {
        $binaryString .= str_pad(base_convert(ord($input[$i]), 10, 2), 8, '0', STR_PAD_LEFT);
    }
    $fiveBitBinaryArray = str_split($binaryString, 5);
    $base32 = "";
    $i=0;
    while($i < count($fiveBitBinaryArray)) {   
        $base32 .= $map[base_convert(str_pad($fiveBitBinaryArray[$i], 5,'0'), 2, 10)];
        $i++;
    }
    if($padding && ($x = strlen($binaryString) % 40) != 0) {
        if($x == 8) $base32 .= str_repeat($map[32], 6);
        else if($x == 16) $base32 .= str_repeat($map[32], 4);
        else if($x == 24) $base32 .= str_repeat($map[32], 3);
        else if($x == 32) $base32 .= $map[32];
    }
    return $base32;
}

2.Google Authenticatorに読み込ませるQRコードを表示する機能

フォーマットにあるExamplesの記載をみると、最低限以下のフォーマットになっていればよさそうです。

otpauth://totp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&issuer=Example

Labelの部分はProvider1:Alice%20Smithといったフォーマットでもよいみたいなので、

Label: TestLogin:M%20M
issuer: TestLogin
secret: MZZW6ZTFHBTGKIZTMQZQ ・・・上記1のスクリプトで秘密鍵をBase32した値

としてみます。

他にもオプションパラメータがありますが、今回はデフォルトを使います。
QRコードの表示はライブラリ(chillerlan/php-qrcode)を使ったので、以下のソースコードのみです。

<?php
require_once('/home/vagrant/totp/vendor/autoload.php');

use chillerlan\QRCode\QRCode;
use chillerlan\QRCode\QROptions;

$data = 'otpauth://totp/TestLogin:M%20M?secret=MZZW6ZTFHBTGKIZTMQZQ&issuer=TestLogin';

echo '<img src="'.(new QRCode)->render($data).'" alt="QR Code" />';
画面に表示されたQRコードをGoogle Authenticatorで読み込むと、以下のように30秒ごとに変わる6桁の数字が表示されるようになります。

3. ワンタイムパスワードをチェックする機能

TOTPで調べるとHOTPというキーワードと一緒にでてくることが多く、

TOTPのRFCには、

TOTP = HOTP(K, T)

T = (Current Unix time – T0) / X

とありました。どうやらTOTPの元になっているのはHOTPだと分かりました。

HOTPのRFCには、

HOTP(K,C) = Truncate(HMAC-SHA-1(K,C))
K shared secret between client and server; each HOTP generator has a different and unique secret K.
C 8-byte counter value, the moving factor.

とありました。

TOTPはHOTP(K,C)のCのところにTを設定したものだということが分かりました。
HMAC-SHA-1はPHPの標準関数のhash_hmacがあるので、Truncateが何をしているかを確認できればと思います。
(今回、秘密鍵は長さなど気にせず適当な値にしているので、ちゃんとHMACも知っておくべきではあるのですが・・)

実装したソースコードは以下になります。

<?php 

// 秘密鍵
$k = 'fsofe8fe#3d3';

$t = floor(microtime(true) / 30);

$c = pack('N*', 0) . pack('N*', $t);
$hs = hash_hmac('sha1', $c, $k, true);

$offset = ord($hs[19]) & 0xf;

$snum = ((ord($hs[$offset+0]) & 0x7f) << 24 )
    | ((ord($hs[$offset+1]) & 0xff) << 16 )
    | ((ord($hs[$offset+2]) & 0xff) << 8 )
    | (ord($hs[$offset+3]) & 0xff);

$d = $snum % pow(10, 6);
echo str_pad($d, 6, '0', STR_PAD_LEFT);
非常に短いです。内容の確認をしていきたいと思います。

■6行目
$t = floor(microtime(true) / 30);
30で割って小数点以下を切り捨てることで、30秒間は同じ値が生成される。
少数点切り捨てなので30秒単位で割り切れる数が増えていくイメージでしょうか。

この30秒ごとに値が変わる仕組みを利用してTOTPを実現している模様。

■8~9行目
$c = pack(‘N*’, 0) . pack(‘N*’, $t);
$hs = hash_hmac(‘sha1’, $c, $k, true);

8行目はHOTPの資料に以下の記載があったように8バイトのカウンターにする必要があるので、8バイトになるようしてバイナリ文字列にpackしています。

C 8-byte counter value, the moving factor.

8バイトにしないで試したところTOTPが一致しませんでした。

9行目で秘密鍵と30秒ごとに変わる数値を使ってハッシュ値を取得しています。(30秒間は同じハッシュ値になるということ)

私が実行したタイミングでは取得したハッシュ値をbin2hexで16進表現に変換すると以下のような値になりました。
c6d3f3100c2665de9cdff8a441c336c9b74cb2f7

■11行目
$offset = ord($hs[19]) & 0xf;

この後の処理で、ハッシュ値から4バイト分の値を取得するのですが、ここではその4バイト分を取得する際の開始位置(配列のindex)を求めています。
$hs[19]で20バイト目の値を取得(indexは0-19なので値的には19)。以下の*の箇所になります。

| 0| 1| 2| 3| 4| 5| 6| 7| 8| 9|10|11|12|13|14|15|16|17|18|19|
|c6|d3|f3|10|0c|26|65|de|9c|df|f8|a4|41|c3|36|c9|b7|4c|b2|f7|
|  |  |  |  |  |  |  |  |  |  |  |  |  |  |  |  |  |  |  | *|
*の箇所をみると16進数のf7になっています。
0xf7は2進数にすると
11110111
0xfは2進数にすると
00001111
となり&でAND演算することで、
11110111
00001111
----------------
00000111
となり$offsetの値は10進数の7となります。
0xfをAND演算することで15より大きくならないようにしています。
私が実行したタイミングでは
$offset = 7
ということになります。

■13~16行目
$snum = ((ord($hs[$offset+0]) & 0x7f) << 24 )
| ((ord($hs[$offset+1]) & 0xff) << 16 )
| ((ord($hs[$offset+2]) & 0xff) << 8 )
| (ord($hs[$offset+3]) & 0xff);

$offsetの値7を使って、4バイト分、$hs[$offset+0]、$hs[$offset+1]、$hs[$offset+2]、$hs[$offset+3]の値を利用します。
$hs[7]、$hs[8]、$hs[9]、$hs[10]の4バイト分を利用するということになります。
($offsetは15より大きくならないので、$hs[0-19]のindexから外れないようになっています。)

$offsetの値は7なので以下の*の部分になります。
| 0| 1| 2| 3| 4| 5| 6| 7| 8| 9|10|11|12|13|14|15|16|17|18|19|
|c6|d3|f3|10|0c|26|65|de|9c|df|f8|a4|41|c3|36|c9|b7|4c|b2|f7|
|  |  |  |  |  |  |  | *| *| *| *|  |  |  |  |  |  |  |  |  |
13行目 ((ord($hs[$offset+0]) & 0x7f) << 24 ) について

$hs[$offset+0] = $hs[7] = de
0x7fでAND演算すると
11011110 (0xde)
01111111 (0x7f) 0x7fにしているのは最上位ビットを0にして必ずunsignedにするため
------------------
01011110
<< 24でシフト演算すると
01011110000000000000000000000000 ・・・①

次に、14行目 ((ord($hs[$offset+1]) & 0xff) << 16 ) について
$hs[$offset+1] = $hs[8] = 9c
0xffでAND演算すると
10011100 (0x9c)
11111111 (0xff)
------------------
10011100
<< 16でシフト演算すると
00000000100111000000000000000000 ・・・②

次に、15行目 ((ord($hs[$offset+2]) & 0xff) << 8 ) について
$hs[$offset+2] = $hs[9] = df
0xffでAND演算すると
11011111 (0xdf)
11111111 (0xff)
------------------
11011111
<< 8でシフト演算すると
00000000000000001101111100000000 ・・・③

最後に、16行目 (ord($hs[$offset+3]) & 0xff) について
$hs[$offset+3] = $hs[10] = f8
0xffでAND演算すると
11111000 (0xf8)
11111111 (0xff)
------------------
11111000
これはシフト演算しないので以下になります。
00000000000000000000000011111000 ・・・④

上記のように4つの値をAND演算、シフト演算にてそれぞれ値を求め、最後にOR演算します。
01011110000000000000000000000000 ・・・①
00000000100111000000000000000000 ・・・②
00000000000000001101111100000000 ・・・③
00000000000000000000000011111000 ・・・④
-------------------------------------------
01011110100111001101111111111000
$snum = 01011110100111001101111111111000
結果、10進数にすると1587339256となります。

■18~19行目
$d = $snum % pow(10, 6);
echo str_pad($d, 6, ‘0’, STR_PAD_LEFT);

10の6乗で割った余りがTOTPとなります。
10の6乗で割った余りというのは単純に下6桁取っているだけです。

$d = 1587339256 % 10^6 = 339256
私が実行したタイミングのTOTPは339256ということになります。
よって、ログイン画面にて入力されたワンタイムパスワードが339256と一致していればOKということになります。

まとめ

実際にTOTPの値を算出すること自体が複雑で実装が難しいということではなく、導入に向けてTOTPを使ったログインにて起こりえる問題を考え、運用設計することのほうが重要になると感じました。
例えば、
・本来ユーザー毎に生成される秘密鍵をどのように管理するのか?暗号化して保存は必要だろう。
・数回間違えたらアカウントロックする機能は必要だろう。
・30秒間は同じTOTPになる。利用できるのは1回だけにする。
・QRコードはどのような条件で表示させるのか?
・クライアント側(Google Authenticator側)に管理されている秘密鍵の扱いの検討も必要。機種変更されたら?
・クライアントとサーバー側の時間のずれやTOTPでログインできなくなった場合の検討。
など、いろいろと考えないといけないことがありそうです。

最後に、次世代システム研究室では、グループ全体のインテグレーションを支援してくれるアーキテクトを募集しています。アプリケーション開発の方、次世代システム研究室にご興味を持って頂ける方がいらっしゃいましたら、ぜひ募集職種一覧からご応募をお願いします。

皆さんのご応募をお待ちしています。

Pocket