web-dev-qa-db-ja.com

SMS認証:ランダムOTPまたは暗号化OTP

SMSを使用して2要素認証を追加して、既存のログインプロセスを強化しています。物理トークンを使用していないので、何が最も安全であると考えられているのか疑問に思いました。

ある種の時間ベースのワンタイムパスワード(TOTP)アルゴリズムを使用して生成されたランダムな8文字の文字列または8桁のハッシュを送信する。

私自身の考慮事項は次のとおりです。TOTPを使用して生成されたハッシュは、ユーザーが正しく入力したかどうかを確認するために保存する必要はありません。ランダムに生成されたトークンは、保存してユーザーアカウントとタイムスタンプにリンクする必要があります。

それでも、ランダムに文字列を生成するプロセスはより簡単に見え、危険にさらされる可能性のある脆弱なスポットが少ないように見えます。

7
Entrecote

適切に行われた場合、時間ベースのワンタイムパスワードはかなり安全になります。これは、通常想定されるよりも大きな「if」です。

うまくいくのは次のようになります:

  1. 時間の粒度を決定します。 5分。ここで考慮されるすべての日付は、その粒度の倍数になります(つまり、8:05:00、17:25:00 ... 16:34:00ではありません)。

  2. 適切なサイズの秘密対称鍵[〜#〜] k [〜#〜]を生成します(_/dev/urandom_、CryptGenRandom()、_Java.security.SecureRandom_から直接16バイト)またはあなたの宗教に応じて_System.Security.Cryptography.RNGCryptoServiceProvider_)。

  3. ワンタイムパスワードを生成する場合[〜#〜] p [〜#〜] for user [〜#〜] u [〜#〜] at time [〜#〜] t [〜#〜]、計算:

    [〜#〜] p [〜#〜] = encode(HMAC/SHA-256([〜#〜] u [〜#〜] || [〜#〜] t [〜#〜][〜#〜] k [〜#〜]))

    つまり、ユーザー名と現在の日付を「連結」して、カスタム構造(XML、ASN.1、text-with-separator ...が確定的で曖昧でない限り、請求書に適合するもの)に [〜#〜] mac [〜#〜] を計算します。具体的には [〜#〜] hmac [〜#〜] を使用して、基になるハッシュとしてSHA-256を使用します関数、および[〜#〜] k [〜#〜]をキーとして使用します。 encode()関数は、HMACの出力を切り捨て、電話に表示してユーザーが入力できるものにエンコードするものです。

  4. [〜#〜] p [〜#〜]をユーザーに送信します。時間T '(おそらく[〜#〜] t [〜#〜]の後でないと思われる)に、ユーザーが戻ってきてパスワードを入力するP'。次に、recompute[〜#〜] p [〜#〜]の値T 'および時間についてT'-5min、同じユーザーについて[〜#〜] u [〜#〜]。どちらかの値がP 'に一致する場合、認証は成功し、それ以外の場合は失敗します。

トリッキーなポイント:

  • 「多すぎ」を切り詰めてはいけません。攻撃者は自分の運を試してランダムなパスワードを送信したい場合があります。可能なパスワードのスペースは、そのような成功がほとんどあり得ないほど十分に広くなければなりません。

  • 確定的でほとんど統一されている限り、任意の「エンコーディング」を使用できます。たとえば、HMAC値(256ビット文字列)を( ..2256-1範囲)、その整数の100000000による除算の余りを取得します。次に、その値を10進数で表す(必要に応じて左にゼロを埋め込む)ことで、ニースの8桁のパスワードを取得できます。残りの操作は「切り捨て」であり、この状況では十分に偏りがありません。 8文字を使用する場合は、26で割ります8 残りを表すために基数26を使用します。

  • 粒度はセキュリティに関連しています。検証時に2つのパスワードを試すことに注意してください(ユーザーがSMSを11:34に取得し、11:36に再入力した場合でもパスワードは有効でなければならないため) 。これは、攻撃者にとっての難易度を2で割ります。パスワードが8桁のシーケンスである場合、100000000の可能なパスワードがありますが、攻撃者は1/50000000の成功の確率を持っています。5分の粒度は、ワンタイムパスワードが5〜10分間有効です。この有効期間をより正確にしたい場合(たとえば、5〜6分)、粒度を下げ(たとえば、1分まで)、より多くのパスワードを受け入れる必要があります(T 'の場合)。 =、T'-1T'-2 ... to T'-5)これにより、攻撃者の成功率が上がります(ここ1/16666667)ここにはトレードオフがあります:精度が高いほどセキュリティが低くなることを意味します。私の意見では、セキュリティを高く維持し、パスワードの有効期間を5〜10分の範囲で自由に変化させることを許容するのが最善です。

    この質問の細分性は、時間ベースのパスワードのセキュリティが、ランダムに選択されたパスワードのセキュリティと異なる点であることに注意してください。上記で説明したように、[〜#〜] n [〜#〜]一部の[~~~~ n [〜#〜](at少なくとも2)、そして攻撃者が成功する可能性は[〜#〜] n [〜#〜]が同じサイズのランダムなパスワードに対して持つ可能性のある倍です。

  • マルチフロントエンドシステムの場合、最初のリクエストを受信して​​パスワードを生成するサーバーは、サーバーと必ずしも同じではないため、キー[〜#〜] k [〜#〜]を共有する必要があります。 2番目の要求を受け取り、パスワードを確認する必要があります。ただし、どちらも同じキーを使用する必要があります。また、すべてのフロントエンドは同じ時間の概念を持っている必要があります( [〜#〜] ntp [〜#〜] を使用)。

  • キー[〜#〜] k [〜#〜]を永続的に保存する必要はありません。生成されたパスワードはとにかく有効期間が短いため、再起動後も存続しないことは許容されます。サーバーの起動時に[〜#〜] k [〜#〜]を生成できます(これは、SMSによって送信されたパスワードが再起動前に受け入れられないことを意味します)ただし、一時的なキーはマルチフロントエンドシステムでは面倒な場合があります(フロントエンドは何らかの方法で同じキーを使用する必要があります)。

  • 破壊的な攻撃を回避するためにいくつかのガードメカニズムを追加する必要があります。特定のユーザーに100万回の認証を要求する人[〜#〜] u [〜#〜]。ユーザー[〜#〜] u [〜#〜] 100万の偽のSMSを受信したくない。そしてyo 100万の送信に対して支払いたくないSMS(これらは無料ではありません)。可能な対策の1つは、SMSベースの2番目の認証手順としてのみパスワードafterユーザーのプリンシパル(移動しない)パスワード(または2要素システムでの最初の認証要素は何でも)を検証した。

9
Thomas Pornin

ランダムな値を使用することをお勧めします。これが最も簡単なアプローチです。安全上、シンプルは良いです。

TOTPを使用することもできますが、なぜ面倒なのでしょうか。 (データベースに保存する必要がないとおっしゃっていますが、どうですか?データベースがあります。なぜそれを使用しないのですか?)TOTPソリューションは、適切に実装すれば安全ですが、より複雑であり、間違いをする機会が増える。 @Thomas Porninの答えの長さを見てください。

結論:KISS。ランダムな値を使用します。

3
D.W.

高エントロピーのランダムに生成されたサーバー側シークレットの連結の暗号化ハッシュ、クライアントに既知のタイムスタンプ、およびユーザー名を使用する場合、その方法に欠陥はありません。私が8桁の数字(たとえば、ランダムにバイパスする1億分の1の確率)や8桁の16進数(1〜40億の確率で1)を使用しないことは承知していますが、暗号化ハッシュを取得してbase-36に変換します(小文字と数字)(2兆分の1)またはbase-64(100兆分の1)で、最初の8文字(できればそれ以上の文字)を使用します。 base64の場合、UXの目的で、一般的に混乱している使用されている一部の文字をサニタイズすることは意味があることに注意してください。たとえば、ユーザーにIまたはlまたは1Oまたは0(ランダムに見える文字列内)を区別させないでくださいおそらく+/をbase64から削除します。また、ユーザーがOTPを入力する画面で、time_strとuser_nameが返されることを確認する必要もあります。

また、time_strを一定時間で比較し(そうでない場合は、タイミング攻撃によって試行錯誤でトークンを特定できる)、有効期間内にない場合はOTPパスワードを期限切れにしてください。 100兆分の1のランダムチャンスが侵入するのが十分に安全でないと心配する場合は、IPアドレスからの不正なログイン試行をさらに追跡し、それらをブロック/ CAPTCHAを要求/約5〜10回の不正試行後にレート制限することができます。

ここにいくつかのサンプルがありますpythonコード:

import hashlib
import time
import base64

secret_str = 'pcA2Sh1e2ovxzjcih4OUiGKHBzytB8FaVScTo0iQ'
time_str = str(time.time())
user_name = 'drjimbob'

def get_hash_from_time_username(time_str, user_name):
    hash = hashlib.sha512(secret_str+time_str+user_name).digest()
    b64_hash = base64.b64_encode(hash) 
    # starts as 86 characters long (excluding `=` at end)
    for ch in ['0','O','1','I','l','+','/', '=']:
        b64_hash = b64_hash.replace(ch,'')
    b64_hash = b64_hash[:8] 
    # overwhelming odds roughly ~1 in 10^60 it will be shorter than 8 chars
    if len(b64_hash) < 8: # in very rare exception repeat the hash.
        b64_hash = (b64_hash + b64_hash)[:8]
    return b64_hash

def check_from_time_username(input_one_time_pass, time_str, user_name):
    cur_time = time.time()
    if cur_time < int(time_str): # time_str in future; user changed time_str
        return False
    if cur_time - 3600 > int(time_str): # time_str is more than an hour old
        return False
    if len(input_one_time_pass) < 8:
        return False
    b64_hash = get_hash_from_time_username(time_str, user_name)
    is_ok = 0 
    for i in range(8):
        is_ok += ord(b64_hash[i]) ^ ord(input_one_time_pass[i])
        # bitwise compare to have constant time string comparison
    return is_ok == 0
3
dr jimbob