In this post, I will provide a rough overview of the main process from implementing HOTP to TOTP. I won’t delve into the detailed explanations of each line of code; instead, the primary focus will be on extracting the implementation steps and highlighting some points I encountered during the development process that require attention.
If you wish to refer to detailed processes, principles, and information about HOTP and TOTP, please visit the following website:
- RFC 4226 - HOTP: An HMAC-Based One-Time Password Algorithm
- RFC 6238 - TOTP: Time-Based One-Time Password Algorithm
- Wikipedia - HMAC-based one-time password
- Wikipedia - Time-based one-time password
- Medium - Implementing Your Own Time-Based OTP Generator
To implement RFC 6238 (TOTP), it has been stated in the standard's Abstract section that TOTP is an extension of HOTP (RFC 4226), thus requiring the implementation of RFC 4226 (HOTP).
This document describes an extension of the One-Time Password (OTP) algorithm, namely the HMAC-based One-Time Password (HOTP) algorithm, as defined in RFC 4226, to support the time-based moving factor.
RFC 4226 (HOTP) Implementation
Implementing HOTP requires three essential parameters that we will be using today:
- Key
- Counter
- Digit
Symbol Represents
-------------------------------------------------------------------
C 8-byte counter value, the moving factor. This counter
MUST be synchronized between the HOTP generator (client)
and the HOTP validator (server).
K shared secret between client and server; each HOTP
generator has a different and unique secret K.
T throttling parameter: the server will refuse connections
from a user after T unsuccessful authentication attempts.
s resynchronization parameter: the server will attempt to
verify a received authenticator across s consecutive
counter values.
Digit number of digits in an HOTP value; system parameter.
Step 1
Using the HMAC-SHA1 algorithm, generate the hash value hs
. The key is the aforementioned K
(Key), and the message is C
(Counter in 8 bytes).
Step 1: Generate an HMAC-SHA-1 value Let HS = HMAC-SHA-1(K,C) // HS is a 20-byte string
var hmac = new HMACSHA1(Secret);
var hs = hmac.ComputeHash(Counter);
The code above is written in C#, keeping only the main parts as an example.
Step 2
This step requires using a method called Dynamic Truncation
to convert the 20-byte HMAC hash result into a shorter fixed code, which serves as a one-time password.
Step 2: Generate a 4-byte string (Dynamic Truncation)
Let Sbits = DT(HS) // DT, defined below,
// returns a 31-bit string
Since the hmac.ComputeHash
method in .NET will return a byte[]
type, I will be directly working with data of byte[]
type.
private int DynamicTruncation(byte[] p)
{
var offset = p.Last() & 0x0F; // Offset value
return ((p[offset] & 0x7F) << 24) |
((p[offset + 1] & 0xFF) << 16) |
((p[offset + 2] & 0xFF) << 8) |
(p[offset + 3] & 0xFF); // Return the Last 31 bits of p
}
This step has completed the entire second step, and the return type is an integer, as we will use it for numerical operations in the third step.
Step 3
Step 3: Compute an HOTP value
Let Snum = StToNum(Sbits) // Convert S to a number in
0...2^{31}-1
Return D = Snum mod 10^Digit // D is a number in the range
0...10^{Digit}-1
In this step, we need to perform a modulo operation on the number we obtained. The algorithm is num % 10^Digit
.
var code = (DynamicTruncation(hs) % (int)Math.Pow(10, Digit)) // hs is defined in Step 1.
At this point, the one-time password generation for HOTP is complete, and we will convert it to a string type.
var code = (DynamicTruncation(hs) % (int)Math.Pow(10, Digit)).ToString().PadLeft(Digit, '0');
Since we are working with numerical data when calculating Dynamic Truncation and performing modulo operations on numbers, if a one-time password includes a 0
in the highest order of magnitude, the mathematical 0
will be discarded. Therefore, when we convert this to a string (or any data type in any language that represents arbitrary text), we need to apply the corresponding PadLeft
operation. In this case, you can use the string.PadLeft()
method directly in C#.
To validate its algorithm correctness, test cases are provided for developers in the standard Appendix D.
Appendix D - HOTP Algorithm: Test Values
The following test data uses the ASCII string
"12345678901234567890" for the secret:
Secret = 0x3132333435363738393031323334353637383930
Table 1 details for each count, the intermediate HMAC value.
Count Hexadecimal HMAC-SHA-1(secret, count)
0 cc93cf18508d94934c64b65d8ba7667fb7cde4b0
1 75a48a19d4cbe100644e8ac1397eea747a2d33ab
2 0bacb7fa082fef30782211938bc1c5e70416ff44
3 66c28227d03a2d5529262ff016a1e6ef76557ece
4 a904c900a64b35909874b33e61c5938a8e15ed1c
5 a37e783d7b7233c083d4f62926c7a25f238d0316
6 bc9cd28561042c83f219324d3c607256c03272ae
7 a4fb960c0bc06e1eabb804e5b397cdc4b45596fa
8 1b3c89f65e6c9e883012052823443f048b4332db
9 1637409809a679dc698207310c8c7fc07290d9e5
Table 2 details for each count the truncated values (both in
hexadecimal and decimal) and then the HOTP value.
Truncated
Count Hexadecimal Decimal HOTP
0 4c93cf18 1284755224 755224
1 41397eea 1094287082 287082
2 82fef30 137359152 359152
3 66ef7655 1726969429 969429
4 61c5938a 1640338314 338314
5 33c083d4 868254676 254676
6 7256c032 1918287922 287922
7 4e5b397 82162583 162583
8 2823443f 673399871 399871
9 2679dc69 645520489 520489
class HOTP
{
private byte[] Secret { get; set; }
private byte[] Counter { get; set; }
private int Digit { get; set; }
public HOTP(byte[] secret, int counter, int digit)
{
Secret = secret;
Digit = digit;
var bytes = new byte[8];
for (var i = 7; i >= 0; i--)
{
bytes[i] = (byte)(counter & 0xFF);
counter >>= 8;
}
Counter = bytes;
}
public string Code()
{
var hmac = new HMACSHA1(Secret);
var hs = hmac.ComputeHash(Counter);
var code = (DynamicTruncation(hs) % (int)Math.Pow(10, Digit)).ToString().PadLeft(Digit, '0');
return code;
}
private int DynamicTruncation(byte[] p)
{
var offset = p.Last() & 0x0F;
return ((p[offset] & 0x7F) << 24) |
((p[offset + 1] & 0xFF) << 16) |
((p[offset + 2] & 0xFF) << 8) |
(p[offset + 3] & 0xFF);
}
}
class Program
{
public static void Main()
{
for (int i = 0; i != 10; i++)
{
var hotp = new HOTP(Encoding.ASCII.GetBytes("12345678901234567890"), i, 6);
Console.WriteLine(hotp.Code());
}
}
}
755224
287082
359152
969429
338314
254676
287922
162583
399871
520489
C:\Users\xyfbs\source\repos\ConsoleTotp\ConsoleTotp\bin\Debug\net7.0\ConsoleTotp.exe (process 26588) exited with code 0.
To automatically close the console when debugging stops, enable Tools->Options->Debugging->Automatically close the console when debugging stops.
Press any key to close this window . . .
RFC 6238 (TOTP) Implementation
With the completion of the implementation of HOTP, we have already accomplished more than half of the entire project. Since TOTP is an extension of HOTP, our workload will be significantly reduced.
Navigating to the standard's Section 4 is the core description of the entire TOTP algorithm.
Step 1
o X represents the time step in seconds (default value X =
30 seconds) and is a system parameter.
o T0 is the Unix time to start counting time steps (default value is
0, i.e., the Unix epoch) and is also a system parameter.
Basically, we define TOTP as TOTP = HOTP(K, T), where T is an integer
and represents the number of time steps between the initial counter
time T0 and the current Unix time.
More specifically, T = (Current Unix time - T0) / X, where the
default floor function is used in the computation.
For example, with T0 = 0 and Time Step X = 30, T = 1 if the current
Unix time is 59 seconds, and T = 2 if the current Unix time is
60 seconds.
According to the description above, we need to obtain the time step X
and the timestamp parameter T
(usually in UTC timestamp format, measured in seconds). In general, the parameter T0
is 0, so subtraction calculation can be omitted.
var x = 30; // Time step in seconds
var timeestamp = DateTime.UtcNow.Subtract(new DateTime(1970, 1, 1)).TotalSeconds; // Current UTC timestamp
var t = (int)Math.Floor(unixTimestamp / x);
Step 2
Finally, by using the parameter T
as the Counter
parameter for HOTP, you can obtain the one-time password (OTP) for TOTP.
var totp = new HOTP(SECRET, t, DIGIT); // This is pseudocode, please refer to the complete code example provided at the end of the article.
var code = totp.Code(); // Also pseudocode...
Step 3
In this step, we will discuss the correctness after the implementation of the TOTP algorithm is completed... First of all, here is the complete code for the entire project.
class HOTP
{
private byte[] Secret { get; set; }
private byte[] Counter { get; set; }
private int Digit { get; set; }
public HOTP(byte[] secret, int counter, int digit)
{
Secret = secret;
Digit = digit;
var bytes = new byte[8];
for (var i = 7; i >= 0; i--)
{
bytes[i] = (byte)(counter & 0xFF);
counter >>= 8;
}
Counter = bytes;
}
public string Code()
{
var hmac = new HMACSHA1(Secret);
var hs = hmac.ComputeHash(Counter);
var code = (DynamicTruncation(hs) % (int)Math.Pow(10, Digit)).ToString().PadLeft(Digit, '0');
return code;
}
private int DynamicTruncation(byte[] p)
{
var offset = p.Last() & 0x0F;
return ((p[offset] & 0x7F) << 24) |
((p[offset + 1] & 0xFF) << 16) |
((p[offset + 2] & 0xFF) << 8) |
(p[offset + 3] & 0xFF);
}
}
public class TOTP
{
private byte[] Secret;
private int Timestep = 30;
private int Digit = 6;
public TOTP(byte[] secret)
{
Secret = secret;
}
public TOTP(byte[] secret, int timestep)
{
Secret = secret;
Timestep = timestep;
}
public TOTP(byte[] secret, int timestep, int digit)
{
Secret = secret;
Timestep = timestep;
Digit = digit;
}
public string Code()
{
var unixTimestamp = DateTime.UtcNow.Subtract(new DateTime(1970, 1, 1)).TotalSeconds;
var t = (int)Math.Floor(unixTimestamp / Timestep);
var hotp = new HOTP(Secret, t, Digit);
var code = hotp.Code();
return code;
}
}
In the standard, Appendix B, there are also test cases similar to HOTP. However, please note that the Digit
parameter in the test cases consists of 8 digits, not 6 digits.
The test token shared secret uses the ASCII string value
"12345678901234567890". With Time Step X = 30, and the Unix epoch as
the initial value to count time steps, where T0 = 0, the TOTP
algorithm will display the following values for specified modes and
timestamps.
+-------------+--------------+------------------+----------+--------+
| 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 | | | |
+-------------+--------------+------------------+----------+--------+
To test the correctness of this standard implementation, it is necessary to compute the same TOTP result based on the timestamps and keys provided in the test cases. Therefore, we need to make slight modifications and update the logic related to the T
parameter of TOTP as follows:
+-------------+--------------+------------------+----------+--------+
| Time (sec) | UTC Time | Value of T (hex) | TOTP | Mode |
+-------------+--------------+------------------+----------+--------+
| 1234567890 | 2009-02-13 | 000000000273EF07 | 89005924 | SHA1 |
+-------------+--------------+------------------+----------+--------+
public string Code()
{
var unixTimestamp = DateTime.UtcNow.Subtract(new DateTime(1970, 1, 1)).TotalSeconds;
var testTimestamp = 1234567890;
var t = (int)Math.Floor((double)testTimestamp / Timestep);
var hotp = new HOTP(Secret, t, Digit);
var code = hotp.Code();
return code;
}
class Program
{
public static void Main()
{
var totp = new TOTP(Encoding.ASCII.GetBytes("12345678901234567890"), 30, 8);
Console.WriteLine(totp.Code());
}
}
89005924
C:\Users\xyfbs\source\repos\ConsoleTotp\ConsoleTotp\bin\Debug\net7.0\ConsoleTotp.exe (process 45364) exited with code 0.
To automatically close the console when debugging stops, enable Tools->Options->Debugging->Automatically close the console when debugging stops.
Press any key to close this window . . .
Look, we are able to calculate the correct results given by the test cases in the standard.
Appendix
Base32
In typical real-world usage, keys are often encoded using Base32
. To obtain the correct results, developers usually need to decode the key using Base32
before proceeding. As a result, I will provide the implementation I've used for Base32
decoding in this article.
/*
* Derived from https://github.com/google/google-authenticator-android/blob/master/AuthenticatorApp/src/main/java/com/google/android/apps/authenticator/Base32String.java
*
* Copyright (C) 2016 BravoTango86
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
using System;
using System.Text;
public static class Base32
{
private static readonly char[] _digits = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567".ToCharArray();
private const int _mask = 31;
private const int _shift = 5;
private static int CharToInt(char c)
{
switch (c)
{
case 'A': return 0;
case 'B': return 1;
case 'C': return 2;
case 'D': return 3;
case 'E': return 4;
case 'F': return 5;
case 'G': return 6;
case 'H': return 7;
case 'I': return 8;
case 'J': return 9;
case 'K': return 10;
case 'L': return 11;
case 'M': return 12;
case 'N': return 13;
case 'O': return 14;
case 'P': return 15;
case 'Q': return 16;
case 'R': return 17;
case 'S': return 18;
case 'T': return 19;
case 'U': return 20;
case 'V': return 21;
case 'W': return 22;
case 'X': return 23;
case 'Y': return 24;
case 'Z': return 25;
case '2': return 26;
case '3': return 27;
case '4': return 28;
case '5': return 29;
case '6': return 30;
case '7': return 31;
}
return -1;
}
public static byte[] FromBase32String(string encoded)
{
if (encoded == null)
throw new ArgumentNullException(nameof(encoded));
// Remove whitespace and padding. Note: the padding is used as hint
// to determine how many bits to decode from the last incomplete chunk
// Also, canonicalize to all upper case
encoded = encoded.Trim().TrimEnd('=').ToUpper();
if (encoded.Length == 0)
return new byte[0];
var outLength = encoded.Length * _shift / 8;
var result = new byte[outLength];
var buffer = 0;
var next = 0;
var bitsLeft = 0;
var charValue = 0;
foreach (var c in encoded)
{
charValue = CharToInt(c);
if (charValue < 0)
throw new FormatException("Illegal character: `" + c + "`");
buffer <<= _shift;
buffer |= charValue & _mask;
bitsLeft += _shift;
if (bitsLeft >= 8)
{
result[next++] = (byte)(buffer >> (bitsLeft - 8));
bitsLeft -= 8;
}
}
return result;
}
public static string ToBase32String(byte[] data, bool padOutput = false)
{
return ToBase32String(data, 0, data.Length, padOutput);
}
public static string ToBase32String(byte[] data, int offset, int length, bool padOutput = false)
{
if (data == null)
throw new ArgumentNullException(nameof(data));
if (offset < 0)
throw new ArgumentOutOfRangeException(nameof(offset));
if (length < 0)
throw new ArgumentOutOfRangeException(nameof(length));
if ((offset + length) > data.Length)
throw new ArgumentOutOfRangeException();
if (length == 0)
return "";
// SHIFT is the number of bits per output character, so the length of the
// output is the length of the input multiplied by 8/SHIFT, rounded up.
// The computation below will fail, so don't do it.
if (length >= (1 << 28))
throw new ArgumentOutOfRangeException(nameof(data));
var outputLength = (length * 8 + _shift - 1) / _shift;
var result = new StringBuilder(outputLength);
var last = offset + length;
int buffer = data[offset++];
var bitsLeft = 8;
while (bitsLeft > 0 || offset < last)
{
if (bitsLeft < _shift)
{
if (offset < last)
{
buffer <<= 8;
buffer |= (data[offset++] & 0xff);
bitsLeft += 8;
}
else
{
int pad = _shift - bitsLeft;
buffer <<= pad;
bitsLeft += pad;
}
}
int index = _mask & (buffer >> (bitsLeft - _shift));
bitsLeft -= _shift;
result.Append(_digits[index]);
}
if (padOutput)
{
int padding = 8 - (result.Length % 8);
if (padding > 0) result.Append('=', padding == 8 ? 0 : padding);
}
return result.ToString();
}
}
To test the usability of the TOTP code with real keys, I recommend using the TOTP testing page provided by Authentication Test as a test case. The key is typically I65VU7K5ZQL7WB4E
(but please refer to the key provided on the website). The time step X
parameter and the Digit
parameter are usually set to common values of 30 and 6, respectively.
class Program
{
public static void Main()
{
var totp = new TOTP(Base32.FromBase32String("I65VU7K5ZQL7WB4E"), 30, 6);
Console.WriteLine(totp.Code()); // You will obtain a usable TOTP one-time password result.
}
}
Once you have the result, enter the TOTP password, click on "Login," and observe whether you can successfully log in.