Blog

Movable Type の2段階認証はDB を移しても動くのか

Movable Type(以下 MT)の管理画面に、MFA プラグインで2段階認証を入れる機会がありました。そこでふと気になったのが、「MFA を設定した MT を、バックアップから復元したら、2段階認証はそのまま使えるのだろうか?」ということです。

正直なところ、2段階認証の確認コードがどうやって決まっているのか、私自身よくわかっていませんでした。そこで、そもそもの仕組みから調べ直し、MT のソースコードで確かめることにしました。この記事は、そのときの調査記録です。

この記事でわかること

  • 認証アプリの確認コード(TOTP)が、どうやって決まっているのか
  • MT の MFA / MFA-TOTP プラグインが、DB に何を保存しているのか
  • バックアップから復元しても2段階認証が通る理由と、逆にログインできなくなる条件
  • 復元先でログインするための対策

調べた環境

  • Movable Type 8.8.6
  • MFA 1.1.1 / MFA-TOTP 1.2.1(MT 8.8.6 に同梱のもの)
  • 仕様の確認には RFC 6238(TOTP)を参照しました

そもそも2段階認証はどう動いているのか

MT の MFA-TOTP は、Google Authenticator などの認証アプリに表示される6桁のコードを使う方式です。この方式は TOTP(Time-Based One-Time Password)と呼ばれていて、MT 独自のものではなく、RFC 6238 で決められた共通の仕様です。

ログインのたびにアプリを開いてコードを入れていると、スマホとサーバーが裏で通信して確かめ合っているように思えます。ですが、実際には両者は一切通信していません。仕組みは次のとおりです。

  • 登録のときに読み取る QR コードには、確認コードではなく秘密鍵が入っている
  • サーバーと認証アプリは、同じ秘密鍵をそれぞれ1つずつ持っている
  • ログインのたびに、両者が「秘密鍵+現在時刻」から同じ計算をして、結果が一致するかを見ている

計算式は次のとおりです(RFC 6238 4.2 節)。

T    = floor( (現在の Unix 時刻 - T0) / X )   ※ T0 = 0、X = 30 秒が既定値
TOTP = HOTP(K, T)                              ※ K = 秘密鍵

入力は、秘密鍵 K と、30秒ごとに1つずつ増える番号 T の2つだけです。この2つが同じなら、どこで計算しても同じコードになります。

ちなみに、認証アプリにはサービス名やユーザー名も表示されますが、これは QR コードに一緒に入っていた表示用の名札のようなものです。計算には使われていません。

ここまでで、「秘密鍵と時刻さえ同じなら、復元した後もコードは通るはず」という見当がつきました。次は、MT がその秘密鍵をどこに持っているのかを見ていきます。

MT の MFA プラグインは DB に何を保存しているのか

プラグイン 役割
MFA 多要素認証の枠組み。ログイン処理に割り込んで、確認コードの検証を呼び出す。「設定の必須化」とユーザーのリセット機能を持つ
MFA-TOTP TOTP 方式の実装。秘密鍵とリカバリーコードを管理する

ソースを追っていくと、MFA に関係するデータは次の場所に入っていました。DB をバックアップから復元すると、これらはすべてバックアップを取った時点の値に戻ります。

データ テーブル 中身
TOTP の秘密鍵 mt_author_meta(type = mfa_totp_base32_secret) ユーザーごとの秘密鍵
リカバリーコード mt_author_meta(type = mfa_totp_recovery_codes) 未使用のリカバリーコードの配列。使うと1つずつ消える
プラグイン設定 mt_plugindata 設定の必須化(mfa_enforcement)、桁数、許容幅、発行者名など
プラグインの有効/無効 mt_config(PluginSwitch) 管理画面のプラグイン一覧で切り替えた結果
ロックアウト状態 mt_author(locked_out)、mt_failedlogin ログインの失敗回数とロックアウト

秘密鍵がユーザーの meta として定義されていることは、MFA-TOTP の config.yaml で確認できます。

出典: mt-plugin-MFA-TOTP v1.2.1 plugins/MFA-TOTP/config.yaml L75-78

object_types:
  author:
    mfa_totp_base32_secret: string(255) meta
    mfa_totp_recovery_codes: array meta

前の節で見たとおり、確認コードの計算に使うのは秘密鍵と時刻だけです。その秘密鍵は DB に入っていて、サイトの URL やドメイン、mt-config.cgi の値、サーバー固有の鍵といったものは計算に関わりません。これで最初の疑問には答えが出ました。DB をバックアップから復元しても、たとえ復元元とは別のサーバーに復元したとしても、いま使っている認証アプリのエントリで、そのままログインできます。

ログインできなくなるケース

ただ、読み進めていくと、ログインできなくなるケースも見えてきました。TOTP の仕組みを思い出すと、コードが通らなくなるのは「秘密鍵」か「時刻」がずれたときです。バックアップからの復元と組み合わせると、次の2つが考えられます。

1. バックアップを取った後に、認証デバイスを登録し直していた

スマホの機種変更などで「認証デバイスの設定」をやり直すと、新しい秘密鍵が DB に保存され、認証アプリにも新しいエントリが増えます。ところが、それより前に取ったバックアップから復元すると、DB の秘密鍵は登録し直す前のものに戻ってしまいます。そのため、登録し直した後のエントリのコードは通らなくなります。

逆に言えば、古いエントリを認証アプリに残していれば、そちらのコードでログインできます。

2. 復元先のサーバーの時計がずれている

MFA-TOTP には、時刻のずれをどこまで許すかを決める totp_tolerance という設定があり、既定値は1です。

出典: mt-plugin-MFA-TOTP v1.2.1 plugins/MFA-TOTP/config.yaml L53-55

totp_tolerance:
  scope: system
  default: 1

素直に読むと「前後1ステップ(30秒)までは許容する」という意味に見えます。ところが、MFA-TOTP に同梱されている Authen::TOTP 0.0.7(Perl と同じライセンスのフリーソフトウェア)は、この値から1を引いた数を、許容するステップ数として使っています。

出典: mt-plugin-MFA-TOTP v1.2.1 plugins/MFA-TOTP/extlib/Authen/TOTP.pm L104-115

sub valid_tolerance {
	my $self = shift;
	my $tolerance = shift;

	if ($tolerance && $tolerance =~ m|^\d+$| && $tolerance > 0) {
		$self->{tolerance} = ($tolerance-1);
	}
	elsif (!defined($self->{tolerance}) || $self->{tolerance} !~ m|^\d+$|) {
		$self->{tolerance} = 0;
	}
	1;
}

照合する時刻の一覧は、次のように作られます。

出典: 同 L293-297

my @tests = ( $self->{when} );
for my $i (1..$self->{tolerance}) {
	push @tests, ($self->{when} - ($self->{period} * $i) );
	push @tests, ($self->{when} + ($self->{period} * $i) );
}

設定値が1だと内部の tolerance は0になるので、このループは1回も回りません。つまり、既定の設定では、いまの30秒の時間枠1つとしか照合していないことになります。RFC 6238 では、時刻のずれをどこまで許すかは検証する側が決めてよいとされているので(6章)、仕様どおりの動きです。

そのため、復元先のサーバーと認証アプリの時計が30秒以上ずれていると、正しいコードを入れても通りません。30秒未満のずれでも、時間枠の切り替わり付近では通らないことがあります。復元元とは別に用意したサーバーに復元するときは、特に気をつけたいところです。

対策

状況に合わせて、次の3つを組み合わせて使えます。

A. 復元元と同じ認証アプリのエントリでログインする

手間がいちばん少ない方法です。秘密鍵は DB と一緒に戻るので、復元先でも、復元元で使っていた認証アプリのエントリがそのまま使えます。

  • 認証デバイスを登録し直した場合は、復元に使うバックアップを取った時点のエントリを使う。そのために、古いエントリは認証アプリからすぐには消さないでおく
  • 復元先のサーバーの時刻を NTP で合わせる。Docker などのコンテナで動かす場合は、ホストの時計に合わせる

B. 復元先で、一時的に MFA を無効にする

復元先の mt-config.cgi に、次の2行を書いておく方法です。復元直後の動作確認で、まず管理画面に入りたいときに使えます。

mt-config.cgi

PluginSwitch MFA=0
PluginSwitch MFA-TOTP=0

管理画面で切り替えた有効/無効は DB(mt_config)に入るので、復元するとバックアップを取った時点の状態に戻ってしまいます(lib/MT/CMS/Plugin.pm L147-148)。その点、mt-config.cgi に書いた値は DB の値より優先されるので、復元の影響を受けません。確認が終わったら、2行を消して MFA を元に戻します。

この方法は、ソースコードを読んだうえでの判断で、実機ではまだ確かめていません。手順に組み込む前に、一度検証用の環境で動作を確認してください。

C. 復元した後に、ユーザーの MFA 設定をリセットする

確認コードが通らないユーザーの MFA の設定を、復元した後にリセットする方法です。秘密鍵がなくなったユーザーは、確認コードなしでログインできるようになります。MFA プラグインに同梱されているツール(mt-plugin-MFA v1.1.1 tools/MFA/reset-settings)を、MT のディレクトリで実行します。

perl tools/MFA/reset-settings --username=example-user

--username のほかに、--email でメールアドレスを指定したり、--all で全ユーザーをまとめてリセットしたりもできます。リセットしたユーザーは、ログインした後に認証デバイスを登録し直してください。

まとめ

  • MT の MFA-TOTP の秘密鍵は、mt_author_meta に保存されている。サーバー固有の値とは結びついていないので、DB をバックアップから復元しても、復元元とは別のサーバーに復元しても、同じ確認コードが通る
  • ログインできなくなるのは、「秘密鍵」か「時刻」がずれたとき。バックアップを取った後に認証デバイスを登録し直していたか、復元先のサーバーの時計がずれているかのどちらかが多い
  • totp_tolerance の既定値1は、実際には「前後の時間枠を許容しない」設定。復元先のサーバーの時計には気をつけたい
  • 復元先でも、復元元と同じ認証アプリのエントリでログインできる。それでもログインできなければ、mt-config.cgi の PluginSwitch か reset-settings を使う

最初は「バックアップから戻しても使えるのかな?」という素朴な疑問から調べ始めたのですが、TOTP の仕組みから順にたどっていくと、使える理由も、使えなくなる条件も、すっきり説明がつきました。

「たぶん大丈夫」で済ませずに、公開されているソースコードで根拠を確かめられたのはよかったと思っています。復元の手順を見直す際の参考になればうれしいです。

参考資料

 
↑