~/blog
~/blog$ render faiz.blog.demystifying-the-most-popular-factor-of-mfa:-otp

Demystifying the most popular factor of MFA: OTP

Sunday, 01 September 2024 03:45:54 WIB | tags: security, python | 194 hits | 0 comment(s)

Demystifying the most popular factor of MFA: OTP

Imagine you’re trying to log in to your mobile app. You enter your password, but instead of being let in right away, you get a message saying, “Please enter the code we just sent to your phone number.” That code is an example of a One-Time Password, or OTP. It’s like a secret handshake that only you and your mobile app know at that moment, making sure it’s really you trying to access your account. In a world where security is more important than ever, OTPs have become the go-to method for keeping our digital lives safe, one code at a time.

In my bachelor’s thesis, I researched virtual passwords which will generate a random password by taking the user’s initial password as its seed, converting each character into 1-2 random alphanumeric characters, and using the converted password to validate the user’s login password. This converted password will be re-randomised again on each login attempt and thus generates a dynamic password during the login process. While the concepts of my past research are similar, I want to demystify the world’s most popular factor in MFA: OTP. In the process, I also fixed a minor bug in a Python library: PyOTP, which allows users to use a non-proper hashing function that will trigger IndexError on OTP generation.

Contents

What is OTP
What is HOTP
What is TOTP
Python Library: PyOTP

What is OTP

An OTP, or One-Time Password, is a dynamically generated code used to authenticate a user for a single session or transaction. Unlike traditional static passwords, OTPs offer enhanced security because they are valid for only a short period and can’t be reused. This reduces the risk of unauthorised access, even if an attacker manages to intercept the code. OTPs are commonly used in two-factor authentication (2FA) systems, adding an extra layer of protection to online accounts and sensitive transactions by requiring not just something the user knows (like a password) but also something they have, such as a phone or token generator. The general requirements of OTP are defined in RFC 2289.

What is HOTP

HOTP is HMAC-Based OTP, basically an OTP, with HMAC-Based seed 😝. There are several requirements defined in the RFC 4226, which divided into:

  • Algorithm requirements [Section 4]

  • Security Requirements [Section 7]

Understanding HMAC

Now, how does the HOTP work? By default, HOTP uses SHA1 as the hash function in HMAC. Now now, how does HMAC work? Let’s say you and I want to always be confident that every time we communicate, I trust that the message is from you and not modified by a third party, and vice-versa. To achieve this, we define some ground rules:

  • Have a shared secret that we will use to create some sort of signature

    • If the shared secret’s length is lower than hash function’s block size, then fill the gap with 0s

    • If the shared secret’s length is greater than hash function’s block size, then hash it, then the secret’s length will be lower than the block size. After that, fill gap with 0s.

  • Use an agreed hash function that we use to map our secret and message into some sort of random words with a fixed-length

  • Add inner padding (ipad) = x36, repeatedly until filled the hash function’s block byte length

  • Add outer padding (opad) = x5c, repeatedly until filled the hash function’s block byte length

HMAC can be written like the formula below:

HMAC = hash( (K XOR opad) + hash( ( (K XOR ipad) + message) ) )
 

We have the pre-requisite, now let's do a scenario:

  • The shared secret is secret

  • The hash function is sha1

  • The message is FaizIsAwesome

How HMAC works

The steps to generate the HMAC digest are:

  • Define the shared secret which we have done in pre-requisite, in this case, our secret’s length is 6 bytes (48 bit), lower than our hash function’s (sha-1) block size which is 64 bytes (512 bit), so we need to fill the gap with additional 464 of 0s

  • XOR our shared secret with the ipad

  • APPEND the ipad result with the message

  • HASH ipad message result

  • XOR our shared secret with the opad

  • APPEND the hashed ipad message with opad result

  • hash the results

x676ee29a7cde021fd0531ef77619bd533c9a4d2a

Now we know how the HMAC works, let’s continue to the HOTP.

Back to HOTP

Once we get the signature generated by HMAC, which is 160 bits, we will need to do several steps to generate the OTP based on the signature. By default, HOTP uses HMAC-SHA-1 as the hashing algorithm.

HOTP(K,C) = Truncate(HMAC-SHA-1(K,C))

The steps to generate the OTP:

  • Get the HMAC signature => x676ee29a7cde021fd0531ef77619bd533c9a4d2a

  • Get the last 4 bits (last character in our case, since the signature is a hexadecimal and each character formed from a four bits) from the signature as an offset => xa => 10

  • Get the next 4 bytes starting from the offset (signature[offset]) => x1ef77619

signature x67 6e e2 9a 7c de 02 1f d0 53 1e f7 76 19 bd 53 3c 9a 4d 2a
byte number  00 01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19
  • AND the first byte with x7F
x1e_
x7f&
x1e
  • Now we get the 4 bytes
1e f7 76 19
  • Convert the hexadecimal into decimal (this should always be in the range of 0 ... 2^{31}-1. Let’s call this decimal result

519534105
  • Define the length of OTP, and calculate 10^{length}. In this case, let’s use 6 as the length.

1000000
  • Modulo the decimal result with the 10^{length}
519534105_
001000000%
    534105 

We finally get the HOTP value! It is 534105! While the example uses FaizIsAwesome as the HMAC message, HOTP will use an incremental counter-value as the message where it will increment for each OTP use.

What is TOTP

Phew what a journey was that, but we haven’t met the finish line yet! TOTP is a Time-Based OTP which is an extension of HOTP. It is described in RFC 6238 with the requirements:

  • The user and the service provider must know or be able to derive the current Unix time for OTP generation

  • The user and service provider must either share the same secret or the knowledge of a secret transformation to generate a shared secret

  • The algorithm must use HOTP as a key building block

  • The user and service provider must use the same time-step value X

  • There must be a unique secret (key) for each prover

  • The keys should be randomly generated or derived using key derivation algorithms.

  • The keys may be stored in a tamper-resistant device and should be protected against unauthorized access and usage

Basically, TOTP uses a similar process to the HOTP which will generate an HMAC signature and get 4 bytes from it. The main difference is TOTP use a Unix timestamp as the HMAC message, and allows a time-window where the TOTP will be valid. This time-window by default is 30 seconds. Furthermore, the verifier also needs to ensure that it will compare the user’s OTP for T & T-1 value to accommodate the delay in the network when the user sends their OTP. Confused? Let’s do some scenarios. Just like the HOTP, we’ll need to define some pre-requisites.

  • Define shared secret => secret

  • Time-step => 30 seconds

  • Digits => 6

Scenario

Scenario User's Time User's OTP Service Provider's Time (when OTP is received) Service Provider's OTP OTP Status
1 1 September 2024 - 02:30:25 887792 1 September 2024 - 02:30:29 887792 Passed
2 1 September 2024 - 02:30:25 887792 1 September 2024 - 02:30:42 819867 Passed (because of synchronisation requirement)
3 1 September 2024 - 02:30:25 887792 1 September 2024 - 02:31:04 306358 Not Passed
4 1 September 2024 - 02:30:25 887792 1 September 2024 - 02:29:59 941130 Not Passed

Python Library: PyOTP

PyOTP is a Python library that allows users to generate and validate One-Time Passwords (OTPs). It supports both Time-based One-Time Passwords (TOTP) and HMAC-based One-Time Passwords (HOTP), which are widely used in two-factor authentication (2FA) systems. With pyotp, developers can easily integrate OTP functionality into their applications.

Apart from reading the RFCs, I read the code on OTP generation in this library. Until I feel that something is not right. In the HOTP section, we learned that during the OTP generation, it will get 4 bytes based on offset, where the offset itself is determined by HMAC signature’s last 4 bits. What if the last 4 bits are 1111 (binary)? Does the hash function’s digest size accommodate that? 1111 = x15 and x15 = 120 bits. HOTP requires the last 4 bytes, thus additional 24 bits are required.

120 + 24 = 144

PyOTP allows user to define their own hash function on digest parameters. While the default function is SHA1, the existing code will allow user to use other hash functions such as MD5 and SHAKE-128 due to their availability in the hashlib. These hash functions only has digest size of 128 bits and prone to index error if the HOTP get the offset on x15. Thus, I forked the repo and made some changes.

otp.py

...
        if digest in [
            hashlib.md5,
            hashlib.shake_128
        ]:
            raise ValueError("selected digest function must generate digest size greater than or equals to 18 bytes")
...

In the first part of the code above, I added a digest validation on whether a user uses md5 or shake-128 and raised a ValueError. This way, the error will be raised in the init phase.
I put another validation on the second part of the code above, where it will check if the hasher’s digest size is lower than 18 (18 bytes = 144 bits) and raise a ValueError if it is.

hotp.py & totp.py

...
        elif digest in [
            hashlib.md5,
            hashlib.shake_128
        ]:
            raise ValueError("selected digest function must generate digest size greater than or equals to 18 bytes")
...

Just like on the otp.py, I validate whether the user uses md5 or shake-128.

test.py

...
class DigestFunctionTest(unittest.TestCase):
    def test_md5(self):
        with self.assertRaises(ValueError) as cm:
            pyotp.OTP(s="secret", digest=hashlib.md5)
        self.assertEqual("selected digest function must generate digest size greater than or equals to 18 bytes", str(cm.exception))

    def test_shake128(self):
        with self.assertRaises(ValueError) as cm:
            pyotp.OTP(s="secret", digest=hashlib.shake_128)
        self.assertEqual("selected digest function must generate digest size greater than or equals to 18 bytes", str(cm.exception))
...

I also added a unit test with negative cases, where it will check if the code raises a ValueError as per the defined error message if a user uses md5 or shake-128.

Comments

Be the first to comment!

Give Comments









* required fields

Sending comment...

~/blog$ shortcuts: > Notes and > Faiz?