記事一覧へ
12/15/2023

RubyでTOTPのクライアントを自前実装してみた

RubyでTOTPのクライアントを自前実装してみた

このエントリーは Akatsuki Games Advent Calendar 2023 の15日目の記事です。

昨日は宮川さんの『which-keyはいいぞ』でした。宮川さんお手製のシェル用which-key、とても便利そうですよね!ぜひzshプラグインにしてほしいです。

この記事では、普段多くの人が使っているけど何をしているのかイマイチ分かりにくい Time-based One Time Password(TOTP) について、自前でクライアントを実装しながら理解していきたいと思います。

記事を読む上でのお願い

この記事で紹介する実装は、あくまでTOTPの仕組みの理解を目的として作成されたものであり、秘密情報の機密性や通信経路の安全性の評価は行っていません。

実際にTOTPクライアントを利用する際は、Google Authenticatorなど、しっかりと安全性が評価された製品を使用してください。

Time-based One Time Password(TOTP)とは?

TOTPとは、多要素認証(MFA)の一種であり、一定時間ごとに切り替わるワンタイムパスワード(OTP)を用いて認証する方式です。

利用開始時にGoogle Authenticatorをはじめとするスマホアプリなどのクライアントをセットアップすることで、いつでも6桁の数字のOTPを取得できます。

TOTPクライアントのイメージ

↑こんなやつです(セキュリティ上スクショが撮れない仕様のようだったので、Figmaで再現しましたw)

現在も多くのサービスでMFAの手段として利用できるようになっています。

TOTPの仕組み

TOTPの仕様は、RFC6238に規定されています。

TOTPの仕組み

TOTPを実現するには、OTPの検証を行うサーバーと、ユーザーがOTPを得るために用いるクライアントが必要となります。 サーバーが発行する秘密鍵をQRコードや文字列によってクライアントに取り込むことで、クライアントはサーバーで認証を行うためのOTPを生成できるようになります。

サーバーとクライアントはお互いに秘密鍵と経過時間を知っているため、その数字が正しいかを検証することができます。

Rubyでクライアントを作ってみる

ではクライアントを自前実装し、実際のOTPを生成方法を確認してみましょう。

今回はRubyを用いてCLIベースのクライアントを実装してみます。

シークレットの文字列はファイルシステムに保存し、Rubyスクリプト起動時にファイルシステムからシークレットを取得して、30秒ごとにTOTPを出し続けるように実装します。

ソースコード

require 'openssl'
require 'base32'

class TOTPGenerator
  attr_reader :key, :hash_algorithm, :t0, :time_step

  def initialize(args)
    @key = args[:key]
    @hash_algorithm = args[:hash_algorithm] || 'sha1'
    @t0 = args[:t0] || 0
    @time_step = args[:time_step] || 30
  end

  def totp()
    counter = (Time.now.to_i - t0) / time_step
    hash = OpenSSL::HMAC.digest(hash_algorithm, key, int_to_bytes(counter)).bytes
    offset = hash.last & 0x0f
    bin_code = ((hash[offset] & 0x7f) << 24) |
               ((hash[offset + 1] & 0xff) << 16) |
               ((hash[offset + 2] & 0xff) << 8) |
               (hash[offset + 3] & 0xff)
    (bin_code % 1_000_000).to_s.rjust(6, '0')
  end

  def generate_every_time_step
    loop do
      puts totp
      sleep(time_step - Time.now.to_i % time_step)
    end
  end

  private
    def int_to_bytes(int)
      result = []
      until int == 0
        result << (int & 0xFF).chr
        int >>= 8
      end
      result.reverse.join.rjust(8, 0.chr)
    end
end

secret_str = open('totp_secret', 'r') do |f|
  f.read()
end
secret = Base32.decode(secret_str.chomp)
TOTPGenerator.new(key: secret).generate_every_time_step

かなり短く簡単に実装できるので、驚く方もいるかもしれません。

TOTPGenerator#totpメソッドが6ケタのTOTPを生成する箇所です。詳しく読んでみましょう。

まず、基準の時刻(デフォルトはUNIX時刻の0)から現在時刻までの秒数を、ステップ秒数で割ります。(これによって、Google Authenticatorのグルグルが実現されます。)

counter = (Time.now.to_i - t0) / time_step

次に、HMAC-SHA-1ダイジェストを得て、その最後の4ビットの数字を得ます。

hash = OpenSSL::HMAC.digest(hash_algorithm, key, int_to_bytes(counter)).bytes
offset = hash.last & 0x0f

SHA-1と聞くとちょっと不安になりますが、RFCには、

HMAC is not a hash function. It is a message authentication code (MAC) that uses a hash function internally. A MAC depends on a secret key, while hash functions don't. What one needs to worry about with a MAC is forgery, not collisions.

とあります。雑に翻訳すると、HMACはハッシュ関数ではなくメッセージ認証コードを作るアルゴリズムで、衝突するかどうかはセキュリティには関係ないようです。

最後に、ダイジェストのバイト列のうち、offset番目から4バイトの後ろ31ビットを取得し、それを1000000で割った余りを出します。これで6ケタの数字の完成です。

bin_code = ((hash[offset] & 0x7f) << 24) |
           ((hash[offset + 1] & 0xff) << 16) |
           ((hash[offset + 2] & 0xff) << 8) |
           (hash[offset + 3] & 0xff)
(bin_code % 1_000_000).to_s.rjust(6, '0')

動作確認

アルゴリズムが正しければTwitterのMFAを通過できるはずです。先程のクライアントを使ってTwitterのMFAをセットアップできるか試してみましょう。

新しくTwitterアカウントを作り、二段階認証を設定してみます。

設定を開始すると、QRコードがでてきます。

TwitterのTOTP設定用QRコード

これを読み取ってみると、下のような文字列がでてきます。(アカウントIDとシークレットは伏せました)

otpauth://totp/Twitter:@*********?secret=***************&issuer=Twitter

secretはBase32でエンコードされた秘密鍵なので。これを先程のスクリプトに読ませれば動くはずです。

secrettotp_secretというファイル名で保存し、先程のスクリプトを起動してみました。

$ ruby totp.rb
472000
890427
701872

無事TOTPが出力されたので、実際にTwitterに入力してみたところ、認証をパスできました。

TwitterのTOTP設定が完了した

TOTPを安全に運用するには?

なんとなく時間と秘密鍵をゴニョゴニョすれば安全なOTPが生成されることは分かりましたが、これは安全なのでしょうか?

TOTPを安全に運用するためには、いくつか条件があります。

  1. 秘密鍵は予測不能でなくてはならない
  2. 秘密鍵はセキュアチャネルでやり取りされなくてはならない
  3. 検証者(サーバー)は秘密鍵の機密性を担保しなければならない

ざっくりまとめると、秘密鍵が漏れたらTOTPの安全性は崩れてしまいます。特にサーバー側でも秘密鍵を保持しなくてはならないため、サーバーでの秘密鍵の管理や通信経路には十分に気を遣う必要があります。

TOTPをサーバーに実装する上で、上のような条件を満たすには、具体的に下記のような方法が考えられます。

  1. 各環境から利用できるCSPRNG(暗号学的にセキュアな乱数生成機)を使う
  2. TOTPの秘密鍵をQRコードや文字列で表示する際、必ずSSL/TLS接続になるようにする
  3. サーバー側で秘密鍵を保存する際は、秘密鍵を暗号化し、耐タンパ性(外部にデータが漏れない仕様)のあるハードウェアに保存する。

まとめ

今回Rubyを用いてTOTPクライアントを実装し、Google Authenticatorがどのように6桁の数字を生成しているのかを説明しました。

また、TOTPを安全に運用するには条件があり、それをどのように達成できるのかについても取り上げました。

現在多くのWebアプリケーションフレームワークでも、TOTPの認証を行う仕組みが用意されており、MFAの中でもエンジニアにとってとてもお手軽に実装できるものになっています。

皆さんが日々開発しているWebサービスでも取り入れてみてはいかがでしょうか?

最後に

アカツキゲームスでは一緒に働くエンジニアを募集しています。

カジュアル面談もやっていますので、気軽にご応募ください。

応募はここから:https://herp.careers/v1/aktskgames/requisition-groups/47f46396-e08a-4b2f-8f9b-b2fc79e63b83

明日は山納さんが、グラフィックス関連について何かを書いてくれるみたいです。お楽しみに!

参考


書いた人

木瓜丸

Webエンジニア。2022年に「木瓜丸屋」を開業し、個人開発をしています。

その他プロフィールをチェック