Provided you use a cryptographic hash of a concatenation of high-entropy randomly-generated server-side-secret, a timestamp known to the client, along with a username, I don't see a flaw in that method. Granted I would not use an 8-digit number (e.g., 1 in 100 million chance of randomly bypassing) or 8-digit hexadecimal number (1 in ~4 billion chance), but would take the cryptographic hash and convert it to base-36 (lowercase letters plus numbers) (1 in 2 trillion) or base-64 (1 in 100 trillion) and take the first 8 characters (though preferably more characters). Note for base64 it may make sense for UX purposes to sanitize some characters used that are commonly confused; e.g., don't make a user differentiate between a I
or l
or 1
, O
or 0
(in a seemingly random string) and possibly strip +
, /
from base64. You also must make sure that at the screen where the user inputs the OTP that the time_str and user_name are passed back.
Also make sure you compare the time_str in a constant time fashion (otherwise by timing attacks can figure out the token by trial and error) and expire the OTP password if its not in a valid time period. Note, if you worry that a random chance of 1 in 100 trillion to break in is not safe enough, you could additionally track bad login attempts from IP addresses and block them/require CAPTCHAs/rate limit them after about 5 to 10 bad attempts.
Here's some sample python code:
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