Skip to content

TOTP Algorithm#

I was using Microsoft Authenticator app and got surprised that it was working offline! At this moment, I got curious how time-based OTP works and later found out that the same algorithm works for other authenticator apps such as Google Authenticator and Duo Mobile and more.

Learning the algorithm with Node.js and JavaScript requires you to have at least prior knowledge on the following: Base 32 encoding, Binary Numbers, Bitwise Operators and Node.js Buffer.

Quickstart#

What is TOTP?

Time-based one-time password (TOTP) is a computer algorithm that generates a one-time password (OTP) using the current time as a source of uniqueness. As an extension of the HMAC-based one-time password algorithm (HOTP), it has been adopted as Internet Engineering Task Force (IETF) standard RFC 6238. 1

Below is a code snippet that follows the RFC 6238 standard. 2

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
import { Buffer } from 'node:buffer';
import { createHmac } from 'node:crypto';

type Options = {
  /** The total digits produced for TOTP @default 6 */
  digits?: number;
  /** The time interval in seconds @default 30 */
  timeStep?: number;
  /** The algorithm for hmac @default 'sha1' */
  algorithm?: 'sha1' | 'sha256' | 'sha512';
};

/**
 * Follows the `RFC 6238`
 * @param key The shared key (Recommended: Base 32 decoded)
 * @returns TOTP with pad start zeroes
 */
function generateTOTP(key: string | Uint8Array, opts: Options = {}): string {
  const { digits = 6, timeStep = 30, algorithm = 'sha1' } = opts;

  // Step 1: Convert "key" to binary representation
  const keyBin = Buffer.from(key);

  // Step 2: Calculate how many "30-seconds" intervals have passed since the Unix epoch began.
  const time = Math.floor(Date.now() / (timeStep * 1000));

  // Step 3: Allocate 8-bytes for the counter value which is the moving factor (RFC 4226)
  const counterBin = Buffer.alloc(8);

  // Step 4: Write "time" into "counterBin" in binary representation
  counterBin.writeBigInt64BE(BigInt(time), 0);

  // Step 5: Create HMAC with the "keyBin"
  const hmac = createHmac(algorithm, keyBin);

  // Step 6: Update with counter
  hmac.update(counterBin);

  // Step 7: Calculate HMAC result
  const hash = hmac.digest();

  // Step 8: Calculate offset by getting the last byte and last 4-bit
  const offset = hash[hash.length - 1] & 0x0f;

  // Step 9: Identify the four 1-byte from the offset and perform bitmask (bitwise operation using "&")
  let byte1 = hash[offset + 0] & 0x7f; // applying a bitmask to ensure only the lower 7 bits are (01111111) considered
  let byte2 = hash[offset + 1] & 0xff; // applying a bitmask to ensure all 8 bits are considered (11111111)
  let byte3 = hash[offset + 2] & 0xff;
  let byte4 = hash[offset + 3] & 0xff;

  // Step 10: Align the four 1-byte so that they won't collide when we merge them
  byte1 <<= 24;
  byte2 <<= 16;
  byte3 <<= 8;

  // Step 11: Merge all bytes forming a binary of 32-bits or 4-bytes
  const bytes = byte1 | byte2 | byte3 | byte4; // This is also the decimal in which TOTP is sliced

  // Step 12: Return the number of digits to the power of 10th (pad starting zeroes)
  return (bytes % Math.pow(10, digits)).toString().padStart(digits, '0');
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
/**
 * Follows the `RFC 6238`
 * @param key {string | Uint8Array} The shared key (Recommended: Base 32 decoded)
 * @param opts {Object}
 * @param opts.digits {number} The total digits produced for TOTP (Default: 6)
 * @param opts.timeStep {number} The time interval in seconds (Default: 30)
 * @param opts.algorithm {'sha-1' | 'sha-256' | 'sha-512'} The algorithm for hmac (Default: 'sha-1')
 * @returns TOTP with pad start zeroes
 */
async function generateTOTP(key, opts = {}) {
  const { digits = 6, timeStep = 30, algorithm = 'sha-1' } = opts;

  const subtle = window.crypto.subtle;

  // Step 1: Convert "key" to binary representation
  const keyBin =
    typeof key === 'string'
      ? new TextEncoder().encode(key) // Convert string to Uint8Array
      : key; // This must be a Uint8Array

  // Step 2: Calculate how many "30-seconds" intervals have passed since the Unix epoch began.
  const time = Math.floor(Date.now() / (timeStep * 1000));

  // Step 3: Allocate 8-bytes for the counter value which is the moving factor (RFC 4226)
  const counterBin = new ArrayBuffer(8);

  // Step 4: Write "time" into "counterBin" in binary representation
  new DataView(counterBin).setBigInt64(0, BigInt(time));

  // Step 5: Create CryptoKey for HMAC with the "keyBin"

  /** @type {HmacImportParams} */
  const cryptoKeyAlgorithm = {
    name: 'hmac',
    hash: algorithm,
  };

  const cryptoKey = await subtle.importKey(
    'raw',
    keyBin,
    cryptoKeyAlgorithm,
    false,
    ['sign'],
  );

  // Step 6: Sign key using HMAC with "counterBin"
  const hmac = await subtle.sign(
    cryptoKeyAlgorithm.name,
    cryptoKey,
    counterBin,
  );

  // Step 7: Calculate HMAC result
  const hash = new Uint8Array(hmac);

  // Step 8: Calculate offset by getting the last byte and last 4-bit
  const offset = hash[hash.length - 1] & 0x0f;

  // Step 9: Identify the four 1-byte from the offset and perform bitmask (bitwise operation using "&")
  let byte1 = hash[offset + 0] & 0x7f; // applying a bitmask to ensure only the lower 7 bits are (01111111) considered
  let byte2 = hash[offset + 1] & 0xff; // applying a bitmask to ensure all 8 bits are considered (11111111)
  let byte3 = hash[offset + 2] & 0xff;
  let byte4 = hash[offset + 3] & 0xff;

  // Step 10: Align the four 1-byte so that they won't collide when we merge them
  byte1 <<= 24;
  byte2 <<= 16;
  byte3 <<= 8;

  // Step 11: Merge all bytes forming a binary of 32-bits or 4-bytes
  const bytes = byte1 | byte2 | byte3 | byte4; // This is also the decimal in which TOTP is sliced

  // Step 12: Return the number of digits to the power of 10th (pad starting zeroes)
  return (bytes % Math.pow(10, digits)).toString().padStart(digits, '0');
}

Breakdown#

The following content describes the steps as provided from above snippet.

Given shared key (Base 32 encoded): 12345678901234567890

Format Value
Buffer <Buffer 31 32 33 34 35 36 37 38 39 30 31 32 33 34 35 36 37 38 39 30>
Uint8Array [ 49, 50, 51, 52, 53, 54, 55, 56, 57, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 48]

Note

Uint8Array is the closest equivalent for Buffer when dealing with binary data.

Step 1: Convert "key" to binary representation#

22
const keyBin = Buffer.from(key);

Output: <Buffer 31 32 33 34 35 36 37 38 39 30 31 32 33 34 35 36 37 38 39 30>

16
17
18
19
const keyBin =
  typeof key === 'string'
    ? new TextEncoder().encode(key) // Convert string to Uint8Array
    : key; // This must be a Uint8Array

Output: Uint8Array(20) [ 49, 50, 51, 52, 53, 54, 55, 56, 57, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 48]

Step 2: Calculate time#

The default timeStep is 30 seconds.

This is the duration on how our OTP would last.

// Step 2: Calculate how many "30-seconds" intervals have passed since the Unix epoch began.
const time = Math.floor(Date.now() / (timeStep * 1000));

Tip

For testing purposes, we need to freeze time

// const time = Math.floor(Date.now() / (timeStep * 1000));
const time = Math.floor(59 / timeStep)

Equivalent: 1970-01-01 00:00:59

Only "1" 30-sec interval has passed. Hence, time = 1

Output: time = 1

Step 3: Create counter#

We must allocate 8-bytes for the counter (moving factor). (RFC 4226) 3

27
28
// Step 3: Allocate 8-bytes for the counter value which is the moving factor (RFC 4226)
const counterBin = Buffer.alloc(8);

Output for counterBin

<Buffer 00 00 00 00 00 00 00 00>
24
25
// Step 3: Allocate 8-bytes for the counter value which is the moving factor (RFC 4226)
const counterBin = new ArrayBuffer(8);

Output for counterBin

ArrayBuffer {
  [Uint8Contents]: <00 00 00 00 00 00 00 00>,
  byteLength: 8
}

Step 4: Write "time" into "counterBin" as BigInt#

30
31
// Step 4: Write "time" into "counterBin" in binary representation
counterBin.writeBigInt64BE(BigInt(time), 0);

Output for counterBin

<Buffer 00 00 00 00 00 00 00 01>
27
28
// Step 4: Write "time" into "counterBin" in binary representation
new DataView(counterBin).setBigInt64(0, BigInt(time));

Output for counterBin

ArrayBuffer {
  [Uint8Contents]: <00 00 00 00 00 00 00 01>,
  byteLength: 8
}

Notice that there is the 1 added

Step 5-6: Crypto HMAC#

33
34
35
36
37
// Step 5: Create HMAC with the "keyBin"
const hmac = createHmac(algorithm, keyBin); // algorithm = 'sha1' (without dash)

// Step 6: Update with counter
hmac.update(counterBin);
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
// Step 5: Create CryptoKey for HMAC with the "keyBin"

/** @type {HmacImportParams} */
const cryptoKeyAlgorithm = {
  name: 'hmac',
  hash: algorithm, // algorithm 'sha-1' (with the dash)
};
/** subtle <- window.crypto.subtle */
const cryptoKey = await subtle.importKey(
  'raw',
  keyBin,
  cryptoKeyAlgorithm,
  false,
  ['sign'],
);

// Step 6: Sign key using HMAC with "counterBin"
const hmac = await subtle.sign(
  cryptoKeyAlgorithm.name,
  cryptoKey,
  counterBin,
);

Warning

This would only work in browsers since we are using window.crypto.subtle

Step 7: Get hash from HMAC#

39
40
// Step 7: Calculate HMAC result
const hash = hmac.digest();

Outputs a 20-bytes hash with 'sha1' algorithm:

<Buffer 75 a4 8a 19 d4 cb e1 00 64 4e 8a c1 39 7e ea 74 7a 2d 33 ab>
53
54
// Step 7: Calculate HMAC result
const hash = new Uint8Array(hmac);

Outputs a 20-bytes hash with 'sha-1' algorithm:

Uint8Array(20) [117, 164, 138, 25, 212, 203, 225, 0, 100, 78, 138, 193, 57, 126, 234, 116, 122, 45, 51, 171]

HMAC Results (hash)#

Equivalent between Uint8Array and Buffer

The values within Uint8Array is the equivalent to Buffer when each items are converted to hexadecimal.

The values within Buffer is the equivalent to Uint8Array when each items are convert to decimal

Format Value
Buffer <Buffer 75 a4 8a 19 d4 cb e1 00 64 4e 8a c1 39 7e ea 74 7a 2d 33 ab>
Uint8Array [117, 164, 138, 25, 212, 203, 225, 0, 100, 78, 138, 193, 57, 126, 234, 116, 122, 45, 51, 171]

Info

Beyond this point, we will focus on the Buffer hash output.
The algorithm beyond Step 7 are equally the same between JavaScript/Browser/Node.js

Step 8: Calculate offset from last 4-bit#

Warning

Beyond this point, you should have prior knowledge about binary numbers and bitwise operators

// Step 8: Calculate offset by getting the last byte and last 4-bit
const offset = hash[hash.length - 1] & 0x0f;
hash
<Buffer 75 a4 8a 19 d4 cb e1 00 64 4e 8a c1 39 7e ea 74 7a 2d 33 ab>

The last byte is 0xab. One byte consists of 8 bits.

Hex Binary Decimal
0xab 1010 1011 171
0x0f 0000 1111 15

We only need the last 4 bits to calculate the offset, hence, 0000 1111 = 0x0f

To get the last 4 bits, we will use the & bitwise operator. Below is a refresher:

console.log((0xab).toString(2).padStart(8, '0')); // 10101011
console.log((0x0f).toString(2).padStart(8, '0')); // 00001111
console.log((0xab).toString(10)); // 171
console.log((0x0f).toString(10)); //  15
console.log(171 & 15); // 11
console.log(0xab & 0x0f); // 11
171 15 171 & 15
1 0 0
0 0 0
1 0 0
0 0 0
1 1 1
0 1 0
1 1 1
1 1 1

1 × 23 + 1 × 21 + 1 × 20 = 11

console.log(0b1011); // 11
const offset = hash[hash.length - 1] & 0x0f; // 11

Step 9: Identify the 4 bytes#

hash
<Buffer 75 a4 8a 19 d4 cb e1 00 64 4e 8a c1 39 7e ea 74 7a 2d 33 ab>

Let us identify the four 1-byte from offset = 11

// Step 9: Identify the four 1-byte from the offset and perform bitmask (bitwise operation using "&")
let byte1 = hash[offset + 0] & 0x7f; // applying a bitmask to ensure only the lower 7 bits are (01111111) considered
let byte2 = hash[offset + 1] & 0xff; // applying a bitmask to ensure all 8 bits are considered (11111111)
let byte3 = hash[offset + 2] & 0xff;
let byte4 = hash[offset + 3] & 0xff;
hash[11].toString(16); // c1
hash[12].toString(16); // 39 
hash[13].toString(16); // 7e 
hash[14].toString(16); // ea

// Pick only the lower 7 bits for the first offset (01111111 = 0x7f)
console.log((0xc1 & 0x7f).toString(2).padStart(8, '0')); // 01000001
console.log((0x39 & 0xff).toString(2).padStart(8, '0')); // 00111001
console.log((0x7e & 0xff).toString(2).padStart(8, '0')); // 01111110
console.log((0xea & 0xff).toString(2).padStart(8, '0')); // 11101010

Spoiler Tip

Since they are already string, we can just concatenate all four bytes

01000001 + 00111001 + 01111110 + 11101010 to form the same output for Step 11

Or we can do it in the binary approach (Step 10-11).

Step 10: Align the bytes#

We need to use the << bitwise operator to shift and align the binaries.

// Step 10: Align the four 1-byte so that they won't collide when we merge them
byte1 <<= 24;
byte2 <<= 16;
byte3 <<= 8;
// byte4 is already placed in the correct position

Below is a visual representation on byte1 ... byte4 should be placed.

Before shift

                           01000001
                           00111001
                           01111110
                           11101010

After shift

01000001 00000000 00000000 00000000
         00111001 00000000 00000000
                  01111110 00000000
                           11101010

Step 11: Merge all the bytes#

To merge the bytes, we'll perform the | bitwise operator.

// Step 11: Merge all bytes forming a binary of 32-bits or 4-bytes
const bytes = byte1 | byte2 | byte3 | byte4; // This is also the decimal in which TOTP is sliced

After merged

01000001 00111001 01111110 11101010
Disclaimer

This is only a visual representation to easily explain and understand the process. There's actually NO spaces in between bytes, and there are NO zeroes at the left-side.

Decimal output#

console.log(0b01000001001110010111111011101010); // 1094287082
console.log(bytes); // 1094287082

Output: 1094287082

Step 12: Get TOTP#

We would just slice the decimal output 1094287082 to get TOTP.

// digits = 6
return (bytes % Math.pow(10, digits)).toString().padStart(digits, '0');
// Output: 287082 (if digits is 6)
// Output: 94287082 (if digits is 8)

We now have our TOTP:

  • 6-digit 287 082.
  • 8-digit 9428 7082

Test Vectors#

Reference: https://datatracker.ietf.org/doc/html/rfc6238#appendix-B

Shared Key Algorithm Size
12345678901234567890 SHA1 20 bytes
12345678901234567890123456789012 SHA256 32 bytes
1234567890123456789012345678901234567890123456789012345678901234 SHA512 64 bytes
Time (sec) UTC Time Value of T (hex) TOTP Mode
59 1970-01-01 0000000000000001 94287082 SHA1
00:00:59
59 1970-01-01 0000000000000001 46119246 SHA256
00:00:59
59 1970-01-01 0000000000000001 90693936 SHA512
00:00:59
1111111109 2005-03-18 00000000023523EC 07081804 SHA1
01:58:29
1111111109 2005-03-18 00000000023523EC 68084774 SHA256
01:58:29
1111111109 2005-03-18 00000000023523EC 25091201 SHA512
01:58:29
1111111111 2005-03-18 00000000023523ED 14050471 SHA1
01:58:31
1111111111 2005-03-18 00000000023523ED 67062674 SHA256
01:58:31
1111111111 2005-03-18 00000000023523ED 99943326 SHA512
01:58:31
1234567890 2009-02-13 000000000273EF07 89005924 SHA1
23:31:30
1234567890 2009-02-13 000000000273EF07 91819424 SHA256
23:31:30
1234567890 2009-02-13 000000000273EF07 93441116 SHA512
23:31:30
2000000000 2033-05-18 0000000003F940AA 69279037 SHA1
03:33:20
2000000000 2033-05-18 0000000003F940AA 90698825 SHA256
03:33:20
2000000000 2033-05-18 0000000003F940AA 38618901 SHA512
03:33:20
20000000000 2603-10-11 0000000027BC86AA 65353130 SHA1
11:33:20
20000000000 2603-10-11 0000000027BC86AA 77737706 SHA256
11:33:20
20000000000 2603-10-11 0000000027BC86AA 47863826 SHA512
11:33:20

Live Demo#

Scan this QR code using your favorite authenticator app (i.e. Microsoft Authenticator, Google Authenticator, Duo Mobile) and watch it matches the correct TOTP below:

qr code

Conclusion#

🔥 With my burning curiosity to discover on how the TOTP algorithm works, it pushed me to learn more about binary numbers, bitwise operations, cryptography (i.e. hmac / sha1 / sha256 / sha512), and base 32 encoding/decoding.

Since it is actually an algorithm that is time-based, hence, it works offline! And I know have a better understanding that Authenticator apps have the same algorithm as it follows the RFC 6238.

Comments