2FA の 6 桁の数字を入力するアレ

ふとしです。

最近2FA には SMS を使うものがありますが危ないのでやめようぜという記事を最近読みました。URL は無くしました。

というわけで一般的に他に使われている 2FA の認証アプリ (Google Authenticator など) を使うやつを調べたのでメモり日記です。

TOTP

Time-based One-Time Password というワンタイムパスワード生成手法を使っています。これは秘密鍵と現在時刻から 6 桁の数値列をパスワードとして生成します。Google Authenticator は時間経過とともに数値が変わるのは現在時刻から算出しているためですね。

(なお数値列は 6 桁でない場合もある)

2FA では、秘密鍵をホストとユーザーが持って数値列を算出し、ユーザーがホストと同じ数値列を提示できれば ok という感じです。

秘密鍵は 2FA 設定画面で出てくる QR コードに含まれています。

QR コードの内容は otpauth URI

2FA 設定画面に出てくる QR コードは otpauth URI というフォーマットに従っています。

otpauth://タイプ/ラベル?パラメーター

パラメーターには秘密鍵などが含まれており、認証アプリはそれを読めば自動的に設定を完了できます。

例えば JS のライブラリ otplib が自動的に書き出してくれる otpauth は以下のようになります。

otpauth://totp/mmmpa-2fa-test?secret=LRVRCGZDORTDSIT2&period=30&digits=6&algorithm=SHA1&issuer=mmmpa-2fa-test

6 桁とか 30 秒更新とかデファクトスタンダード?なのでスパッと省略して、パラメーターは secret のみでも Google Authenticator はちゃんとやってくれるみたいです。

ライブラリ

ライブラリ素振りもしたので残しておきます。

otplib (JS)

最初は speakeasy でさわりはじめたのですが、ブラウザ上でうまく動かないので断念して otplib に変更しました。Google Authentictor 用に調整されたメソッドセットがあるので考えずに使えます。

以下は Storybook で動かしてみたコード。

import { KeyEncodings } from "@otplib/core";
import base32Decode from "base32-decode";
import { useEffect, useState } from "react";
import { authenticator, totp } from "otplib";
import qrcode from "qrcode";

export default {
  title: "2FA",
};

const user = "mmmpa";
const service = "mmmpa-2fa-test";
// ユーザーごとに発行する。
//
// base32 encoded hex secret key
// const secret = authenticator.generateSecret();
const secret = "LRVRCGZDORTDSIT2";

export const ShowSecret = () => {
  const [qrImg, setQrImg] = useState("");
  const [userToken, setUserToken] = useState("");
  const [token, setToken] = useState("");
  const [token2, setTokenByTotp] = useState("");
  const url = authenticator.keyuri(
    encodeURIComponent(user),
    encodeURIComponent(service),
    secret
  );

  function verify(e: React.FormEvent) {
    e.preventDefault();

    // google authenticator は base32 のシークレットを要求するので、
    // 検査関数のたぐいも secret を自動でデコードされる。
    alert(authenticator.verify({ token: userToken, secret }));
  }

  useEffect(() => {
    qrcode.toDataURL(url, function (err, dataUrl) {
      !!err ? console.error(err) : setQrImg(dataUrl);
    });
  });

  useEffect(() => {
    function renewToken() {
      setToken(
        // google authenticator は base32 のシークレットを要求するので、
        // 検査関数のたぐいも secret を自動でデコードされる。
        authenticator.generate(secret)
      );

      // アルゴリズムは同じなので、
      // totp 側でも同じ値を得られることを確認。
      //
      // authenticator の仕様に合わせるために generateSecret では hex かつ base32 でエンコードされているので、
      // それを正しくデコードしなければならない。
      totp.options = { encoding: "hex" as KeyEncodings };
      setTokenByTotp(
        totp.generate(
          Buffer.from(
            // 必ず "RFC4648" でデコードする。
            base32Decode(secret, "RFC4648")
          ).toString("hex")
        )
      );
    }

    renewToken();
    const sid = setInterval(renewToken, 1000 * 10);

    return () => {
      clearInterval(sid);
    };
  });

  return (
    <>
      <h1>otpauth</h1>
      <p>
        <code>otpauth://タイプ/ラベル?パラメータ</code>
      </p>
      <p>
        <code>{url}</code>
      </p>
      <h1>token</h1>
      <p>authenticator.generate: {token}</p>
      <p>totp.generate: {token2}</p>
      <h1>qrcode</h1>
      {qrImg ? <img src={qrImg} alt="" /> : null}
      <h1>verify token</h1>
      <form onSubmit={verify}>
        <div>
          <input
            type="text"
            value={userToken}
            onChange={(e) => setUserToken(e.target.value)}
          />
        </div>
        <div>
          <button type="submit">verify</button>
        </div>
      </form>
    </>
  );
};

rotp (Ruby)

Ruby だと rotp というのが使えるみたいです。こちらもそのまま使った場合は Google Authenticator と互換性があるみたいなのでそのまま使えます。

require 'rotp'

# シークレットを生成。ユーザーごとに保持する。
new_secret = ROTP::Base32.random_base32

# 検査などを行うインスタンスを生成。
new_totp = ROTP::TOTP.new(
  new_secret,
  issuer: "new-service-name"
)

# 現在のトークンを取得
new_token = new_totp.now

puts(
  uri: new_totp.provisioning_uri,
  token: new_token,

  # 成功の時は設定しているインターバル時間、失敗の時は nil が返る。
  success: new_totp.verify(new_token),
  failure: new_totp.verify("000000"),
)