Mobile application allows you to register your account with Microsoft / Google or any other TOTP authenticator application (via a specially generated QR code). After successful registration, the authenticator application will generate a new code every 30 seconds which could be used to implement MFA based sign-in. To make it a complete MFA, a PIN is added as a prefix to the application generated code. The sign-in password or some call it Passcode will be PIN+Code.
Introduction
A time-based, One-time Password Algorithm (RFC-6238, TOTP - HMAC-based One-time Password Algorithm) based token, implemented by e.g. Microsoft or Google Authenticator mobile applications. Mobile application allows you to register your account with Microsoft / Google or any other TOTP authenticator application (via a specially generated QR code). After successful registration, the authenticator application will generate a new code every 30 seconds which could be used to implement MFA based sign-in. To make it a complete MFA, a PIN is added as a prefix to the application generated code. The sign-in password or some call it Passcode will be the PIN + Code.
GitHub Repository
Test Deploment
Background
To secure access to any C#, Java or C++ (Windows or Linux) web or normal application, MFA is a best and easy option without creating a custom mobile application of your own. It completes the scenario, that something you know and something you have. Here something you know is your PIN, and something you have is your mobile app and your bio-matric features forced by the authenticator mobile applications like Microsoft Authenticator.
Registration of the QR Code
The authenticator application (Microsoft and Google) follows a standard. Though, only Google defines the URI and parameters required to register an account with the Authenticator Application.
The first step logically is the ability to generate the QR code to register the required user with the authenticator application. The magic ingredient here is the TOTP seed, Company / Web Application user belongs to and User's UPN or email address.
The code below generates a seed using GUID (I use GUID because there is 1 in 2 billion chance that same GUID will ever be regenerated):
public static Byte[] HexToByte(string hexStr)
{
byte[] bArray = new byte[hexStr.Length / 2];
for (int i = 0; i < (hexStr.Length / 2); i++)
{
byte firstNibble = Byte.Parse(hexStr.Substring((2 * i), 1), System.Globalization.NumberStyles.HexNumber);
byte secondNibble = Byte.Parse(hexStr.Substring((2 * i) + 1, 1), System.Globalization.NumberStyles.HexNumber);
int finalByte = (secondNibble) | (firstNibble << 4);
bArray[i] = (byte)finalByte;
}
return bArray;
}
public static string getNewId()
{
string sR = Guid.NewGuid().ToString().ToUpper();
sR = sR.Replace("{", "");
sR = sR.Replace("}", "");
return sR;
}
private void generateQRCode()
{
string seed = getNewId() + getNewId();
seed = seed.Replace("-", "");
seed = seed.Substring(0, 40);
byte[] byteSeed = HexToByte(seed);
var KeyString = Base32.ToBase32String(byteSeed);
string orgDomain = "elogic.synology.me";
string orgName = "eLogic Builders Inc.";
string userUPN = "Kashif" + '@' + orgDomain;
const string AuthenticatorUriFormat = "otpauth://totp/{0}:{1}?secret={2}&issuer={0}&algorithm=SHA1&digits=6&period=30";
string tokenURI = string.Format(
AuthenticatorUriFormat,
HttpUtility.UrlEncode(orgDomain),
HttpUtility.UrlEncode(userUPN),
KeyString);
var qr = QrCode.EncodeText(tokenURI, QrCode.Ecc.High);
string base64EncodedImage = Convert.ToBase64String(Encoding.UTF8.GetBytes(qr.ToSvgString(4)));
string imageSrc = "data:image/svg+xml;base64," + base64EncodedImage;
}
The KeyString
(TOTP seed) must be saved and linked to the user being authenticated. The same seed will be used to authenticate user's entered TOTP. To generate the QR code, I used Net.Codecrete.QrCodeGenerator nuget.org package. This is good to generate QR code on Windows and Linux (using Mono framework). You can use other implementations which suits your application.
Below is the example of a registration link I used to send for registration:
When user follows the link, the QR code generation and registration sequence starts. Here is what is presented to the user:
User scans the QR code with the Microsoft or Google, or with any other RFC-6238 compliant TOTP authenticator application. The application should register the seed and user's UPN and should start generating the TOTPs:
Using the RFC-6238 compliant class below, you could validate the generated TOTP (it is little modified Microsoft code sample version):
using System;
using System.Diagnostics;
using System.Net;
using System.Security.Cryptography;
using System.Text;
class SecurityToken
{
private readonly byte[] _data;
public SecurityToken(byte[] data)
{
_data = (byte[])data.Clone();
}
internal byte[] GetDataNoClone()
{
return _data;
}
}
public static class Rfc6238AuthenticationService
{
private static readonly DateTime _unixEpoch = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc);
private static readonly TimeSpan _timestep = TimeSpan.FromMinutes(3);
private static readonly Encoding _encoding = new UTF8Encoding(false, true);
public static int ComputeTotp(HashAlgorithm hashAlgorithm, ulong timestepNumber, string modifier)
{
const int mod = 1000000;
var timestepAsBytes = BitConverter.GetBytes(IPAddress.HostToNetworkOrder((long)timestepNumber));
var hash = hashAlgorithm.ComputeHash(ApplyModifier(timestepAsBytes, modifier));
var offset = hash[hash.Length - 1] & 0xf;
Debug.Assert(offset + 4 < hash.Length);
var binaryCode = (hash[offset] & 0x7f) << 24
| (hash[offset + 1] & 0xff) << 16
| (hash[offset + 2] & 0xff) << 8
| (hash[offset + 3] & 0xff);
return binaryCode % mod;
}
private static byte[] ApplyModifier(byte[] input, string modifier)
{
if (String.IsNullOrEmpty(modifier))
{
return input;
}
var modifierBytes = _encoding.GetBytes(modifier);
var combined = new byte[checked(input.Length + modifierBytes.Length)];
Buffer.BlockCopy(input, 0, combined, 0, input.Length);
Buffer.BlockCopy(modifierBytes, 0, combined, input.Length, modifierBytes.Length);
return combined;
}
private static ulong GetCurrentTimeStepNumber()
{
var delta = DateTime.UtcNow - _unixEpoch;
return (ulong)(delta.Ticks / _timestep.Ticks);
}
private static int GenerateCode(SecurityToken securityToken, string modifier = null)
{
if (securityToken == null)
{
throw new ArgumentNullException("securityToken");
}
var currentTimeStep = GetCurrentTimeStepNumber();
using (var hashAlgorithm = new HMACSHA1(securityToken.GetDataNoClone()))
{
return ComputeTotp(hashAlgorithm, currentTimeStep, modifier);
}
}
private static bool ValidateCode(SecurityToken securityToken, int code, string modifier = null)
{
if (securityToken == null)
{
throw new ArgumentNullException("securityToken");
}
var currentTimeStep = GetCurrentTimeStepNumber();
using (var hashAlgorithm = new HMACSHA1(securityToken.GetDataNoClone()))
{
for (var i = -2; i <= 2; i++)
{
var computedTotp = ComputeTotp(hashAlgorithm, (ulong)((long)currentTimeStep + i), modifier);
if (computedTotp == code)
{
return true;
}
}
}
return false;
}
}
Here is the function you can use to validate the generated TOTP:
public bool CheckTimeBasedOTP_Rfc6238(byte[] byteSeed, string incomingOTP)
{
bool bR = false;
int IntIncomingCode = int.Parse(incomingOTP);
var hash = new HMACSHA1(byteSeed);
var unixTimestamp = Convert.ToInt64(Math.Round((DateTime.UtcNow - new DateTime(1970, 1, 1, 0, 0, 0)).TotalSeconds));
var timestep = Convert.ToInt64(unixTimestamp / 30);
for (long i = -2; i <= 2; i++)
{
var expectedCode = Rfc6238AuthenticationService.ComputeTotp(hash, (ulong)(timestep + i), modifier: null);
if (expectedCode == IntIncomingCode)
{
bR = true;
break;
}
}
return bR;
}
The byteSeed
is a byte array you can convert from Base32 encoded and saved seed.
Base32 Encoder / Decoder:
using System;
using System.Collections.Generic;
using System.Text;
using System.Text.RegularExpressions;
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));
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 "";
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();
}
}
History
1st Version: 08 July 2024