If you care about your security on the web, you probably use a Two-Factor authentication (2FA) method to protect your accounts. There are various 2FA methods available out there, a combination of password + fingerprint, for example, is one of them. However, since not so many people have a fingerprint reader available all the time, one of the most popular 2FA methods today is to use an authenticator app on your cellphone to generate a temporary password that expires within a minute or even less. But, how does this temporary password, called Time-Based One-Time Password (TOTP) works, and how can I implement that on my own service?
An abstract view
This kind of authentication is not hard. It consists basically of issuing a secret key on your server and reading it on your phone, or any other device (generally using a QR code), then using this secret key to generate the passwords. That's why it works even when your phone is offline, because the secret key is stored in your phone, and therefore it is perfectly capable of generating a TOTP for you.
Generating the shared secret key
The TOTP algorithm is defined on the IETF RFC 6238, where it says the shared key "should be chosen at random or using a cryptographically strong pseudorandom generator properly seeded with a random value". This key must be encrypted to be securely stored and should be decrypted only on two occasions: when validating a password that comes in and when exposing itself to be copied by another device, that should keep it encrypted too. To generate it, we can use Python's secrets.
import secrets
def generate_shared_secret() -> str:
return secrets.token_hex(16)
# >> e8fb1a2faf331bfffe8670ca20447fae
Note that this secret should be unique for every user on your database, this is what will guarantee that one user cannot generate a TOTP for another.
Generating and validating a one-time password
Now that we have a shared secret, we can generate and validate an OTP. The formula is simple: TOTP = HOTP(K, T), where K is the secret key we just generated and T is a time step. In other words, we will encrypt the timestamp with our shared secret, but a raw timestamp wouldn't work, because the timeframe for the user to read and input the password would be zero. For this reason, we use a "step" factor, so the user gets more time. RFC 6238 recommends a step of 30 seconds, that may be sufficient for usability and security constraints.
import hashlib
import hmac
import math
import time
def generate_totp(shared_key: str, length: int = 6) -> str:
now_in_seconds = math.floor(time.time())
step_in_seconds = 30
t = math.floor(now_in_seconds / step_in_seconds)
hash = hmac.new(
bytes(shared_key, encoding="utf-8"),
t.to_bytes(length=8, byteorder="big"),
hashlib.sha256,
)
return dynamic_truncation(hash, length)
We could just return the HMAC hash, but the output is way too long for the user to type (even more when there are only 30 seconds to do this). For this reason, we use the dynamic truncation algorithm to get a sample of it, usually of six digits. It was developed for the predecessor of TOTP, at RFC 4226 and consists of four steps:
def dynamic_truncation(raw_key: hmac.HMAC, length: int) -> str:
bitstring = bin(int(raw_key.hexdigest(), base=16))
# >> 11010100000110011101010100010001100100011111001010111010001010110110000010111101000101011110111111010111101100011101001111100001011111101100001011110111100111001111100000100010011001010110101111100010111001001000010000011000000010010111100110101100011
last_four_bits = bitstring[-4:]
# >> 0011
offset = int(last_four_bits, base=2)
# >> 3
chosen_32_bits = bitstring[offset * 8 : offset * 8 + 32]
# >> 01000100011001000111110010101110
full_totp = str(int(chosen_32_bits, base=2))
# >> 1147436206
return full_totp[-length:]
# >> 436206
For the example above, 436206 is the temporary password the user would use. Now, how to validate a password on the backend? It is exactly the same. We generate the TOTP on the server using the shared key and check if it matches the input.
def validate_totp(totp: str, shared_key: str) -> bool:
return totp == generate_totp(shared_key)
Conclusion
Implementing 2FA is not hard, but must be taken seriously to avoid breaches. No security protocol is perfect, there is no silver bullet, but why not make invaders lives harder? Do a favor for you and your users: Don't implement this by hand. Instead, prefer using a library that is constantly updated with the best security practices. This article is to understand what is going on behind the scenes, hope you've enjoyed.
The code snippets are available at my GitHub.