A Closer Look at 2FA with Rust


Crafting One-Time Passwords

/images/2024/01-30/header.jpg

The view from Mt. Karamatsu, the 93rd highest mountain in Japan, as photographed by me in Sep. 2022.

Lately, at Acompany, we started using IDaaS, and steadily making use of One-Time Passwords (OTPs) has become a regular part of my daily grind. Motivated by this, I decided to dive in and get my hands dirty, exploring the nitty-gritty of generating OTPs. I’ll share the insightful stuff I gained after falling down the rabbit hole.

This article is the 16th entry in the #AcompanyValentine’sDayAdventCalendar2024 series.

The Time-based One-Time Passwords (TOTPs) algorithm, which is based on HMAC-based One-Time Passwords (HOTPs) as defined in RFC4226, generates typically a 6-8-digit numeric code that changes every 30-60 seconds. In practice, this acts as a disposable password, valid for only several tens of seconds usually. This mechanism adds an essential layer of security to multi-factor authentication (MFA) systems. You’ve probably noticed that MFA popping up more and more on different platforms.

RFC 6238

Now that we can see the part of implementation in Java outlined in RFC 6238, TOTP is generated using an integer derived from an HMAC-Hash, which combines Unix time and a secret:

private static byte[] hexStr2Bytes(String hex){
    // Adding one byte to get the right conversion
    // Values starting with "0" can be converted
    byte[] bArray = new BigInteger("10" + hex,16).toByteArray();

    // Copy all the REAL bytes, not the "first"
    byte[] ret = new byte[bArray.length - 1];
    for (int i = 0; i < ret.length; i++)
        ret[i] = bArray[i+1];
    return ret;
}

public static String generateTOTP(String key,
        String time,
        String returnDigits,
        String crypto){
    int codeDigits = Integer.decode(returnDigits).intValue();
    String result = null;

    // Using the counter
    // First 8 bytes are for the movingFactor
    // Compliant with base RFC 4226 (HOTP)
    while (time.length() < 16 )
        time = "0" + time;

    // Get the HEX in a Byte[]
    byte[] msg = hexStr2Bytes(time);
    byte[] k = hexStr2Bytes(key);
    byte[] hash = hmac_sha(crypto, k, msg);

    // put selected bytes into result int
    int offset = hash[hash.length - 1] & 0xf;

    int binary =
        ((hash[offset] & 0x7f) << 24) |
        ((hash[offset + 1] & 0xff) << 16) |
        ((hash[offset + 2] & 0xff) << 8) |
        (hash[offset + 3] & 0xff);

    int otp = binary % DIGITS_POWER[codeDigits];

    result = Integer.toString(otp);
    while (result.length() < codeDigits) {
        result = "0" + result;
    }
    return result;
}

Note that while RFC 6238 doesn’t specify a particular endianness, it is based on HOTP as outlined in RFC 4226, which states:

The Key (K), the Counter (C), and Data values are hashed high-order byte first. The HOTP values generated by the HOTP generator are treated as big-endian.

When implementing methods like generateTOTP() as shown in the example above, on little-endian CPU architectures, adhering closely to the specification document is pivotal. In this case, integers used as messages in hashing, such as counter values, must be explicitly converted to big-endian format in the code to ensure consistent OTP generation across different systems. This could present a common pitfall. Indeed, I’ve fallen into that pitfall before.

Understanding endianness is not exactly rocket science. Take, for instance, a 4-byte integer 0x12345678. In little-endian format, it’s represented in memory as [78, 56, 34, 12]. However, if interpreted in big-endian, the same byte array represents a different integer, 0x78563412. Converting it to big-endian requires simply reversing the array to [12, 34, 56, 78] from [78, 56, 34, 12].

On systems like my Mac, which uses little-endian:

% python3
>>> import sys
>>> print(sys.byteorder)
little

When it comes to strings, such as secrets, there’s no need to worry. Typically, the strings are stored as byte arrays in the same order on both big-endian and little-endian systems, unaffected by endianness.

Java implementations generally don’t require special handling for endianness due to the JVM’s toByteArray() operating in big-endian byte order. Accordingly, eliminating the need for a manual converting process in such scenarios.

In C#, you can manage endianness like this:

byte[] counterByte = BitConverter.GetBytes(counter);

if (BitConverter.IsLittleEndian)
{
    Array.Reverse(counterByte);
}

In Python, you’d handle it as follows:

msg = (counter).to_bytes(8, byteorder="big")
signature = hmac.new(key.encode("utf-8"), msg, hashlib.sha1).hexdigest()

Rust Implementation

This is my implementation of an OTP generation program in Rust:

use std::time::SystemTime;
use {
    byteorder::{BigEndian, ByteOrder},
    hmac_sha1::hmac_sha1,
};

// Number of digits to return. This value defaults to 6.
const DEFAULT_DIGITS: u32 = 6;

// T = (Current Unix time - T0) / TIME_STEP_SEC
const TIME_STEP_SEC: u64 = 30;

struct Digits(u32);

impl Default for Digits {
    fn default() -> Self {
        Self(DEFAULT_DIGITS)
    }
}

impl From<u32> for Digits {
    fn from(digits: u32) -> Self {
        Self(digits)
    }
}

/// Computes a OTP.
fn new<D>(secret: &[u8], counter: u64, digits: D) -> Result<u32, ()>
where
    D: Into<Digits>,
{
    let mut unpacked_counter: [u8; 8] = Default::default();

    BigEndian::write_u64(&mut unpacked_counter, counter);

    let hash: [u8; 20] = hash(secret, &unpacked_counter).unwrap();
    let snum = truncate(hash).unwrap();
    let digits: Digits = digits.into();

    Ok(snum % (10_u32.pow(digits.0)))
}

/// step 1: Generates an HMAC-SHA-1 value from the secret and the message.
fn hash(secret: &[u8], message: &[u8]) -> Result<[u8; 20], ()> {
    let hash = hmac_sha1(secret, message);

    Ok(hash)
}

/// step 2: Truncates a HMAC-SHA-1 hash to a 4-byte value.
fn truncate(hs: [u8; 20]) -> Result<u32, ()> {
    let offset = (&hs[hs.len() - 1] & 0xf) as usize;

    let mut dst: [u8; 4] = Default::default();
    dst.clone_from_slice(&hs[offset..(offset + 4)]);

    let snum = u32::from(dst[0] & 0x7f) << 24
        | u32::from(dst[1] & 0xff) << 16
        | u32::from(dst[2] & 0xff) << 8
        | u32::from(dst[3] & 0xff);

    Ok(snum)
}

/// Calculates the current counter value
/// based on Unix time is used as part of the OTP generation process.
fn counter() -> u64 {
    let current_unix_time: u64 = current_unix_time_as_secs();
    let offset = 0;
    let step = TIME_STEP_SEC;

    let counter: u64 = (current_unix_time - offset) / step;

    counter
}

fn current_unix_time_as_secs() -> u64 {
    let current = unix_time_utc_now()
        .duration_since(SystemTime::UNIX_EPOCH)
        .expect("Back to the future.");

    current.as_secs()
}

fn unix_time_utc_now() -> SystemTime {
    SystemTime::now()
}

fn main() {
    let secret = b"foobarbaz";
    let counter = counter();

    println!("{}", new(secret, counter, Digits::default()).unwrap());
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_8_digits() {
        // https://datatracker.ietf.org/doc/html/rfc6238#appendix-B

        // The 20-byte secret key used for generating OTPs.
        let secret = b"12345678901234567890";

        let counters = vec![
            0x0000000000000001,
            0x00000000023523EC,
            0x00000000023523ED,
            0x000000000273EF07,
            0x0000000003F940AA,
            0x0000000027BC86AA,
        ];
        let expected = vec![94287082, 07081804, 14050471, 89005924, 69279037, 65353130];

        let result: Vec<_> = expected
            .iter()
            .enumerate()
            .map(|(i, _)| new(secret, counters[i], 8).unwrap())
            .collect();

        assert_eq!(result, expected);
    }

    #[test]
    fn test_hash() {
        let secret = b"This is the secret.";
        let mut unpacked_counter: [u8; 8] = Default::default();
        BigEndian::write_u64(&mut unpacked_counter, 0xff);
        let hash = hash(secret, &unpacked_counter).unwrap();

        let expected: [u8; 20] = [
            190, 110, 100, 238, 57, 93, 42, 48, 207, 194, 16, 16, 49, 106, 213, 213, 89, 180, 240,
            104,
        ];

        assert_eq!(hash, expected);
    }

    #[test]
    fn test_counter() {
        let result = counter();
        let expected = (current_unix_time_as_secs() - 0) / 30;

        assert!(result == expected);
    }
}

In this instalment, we’re utilizing specific Rust crates. To get started, add these to your Cargo.toml:

[dependencies]
byteorder = "1.5.0"
hmac-sha1 = "0.2.2"

Upon execution, you’ll observe the following:

% cargo run
    Finished dev [unoptimized + debuginfo] target(s) in 0.02s
     Running `target/debug/totp`
266164

This output 266164, is an example of an OTP, refreshing every 30 seconds. Please give it a try running cargo run for yourself to see how it works firsthand.

Next time, we’re going to dive into generating QR codes, especially in the context of setting up OTP apps such as Authy and Microsoft Authenticator on smartphones. I’m sure you’ll find this exploration interesting. Here’s to a splendid 2024.


We’re currently on the lookout for innovative minds ready to impact privacy tech. Click here to discover the exciting roles we’re offering.

See also