Implementing TOTP in Node.js
In this post, we’ll explore how to enhance your application’s security using Time-based One-Time Passwords (TOTP) in Node.js. We’ll dive into practical steps for implementing TOTP, incorporating cryptography and QR code generation to strengthen your digital security.
Understanding TOTP
TOTP, a cornerstone in two-factor authentication (2FA), generates a transient password using a shared secret key and the current time. This password refreshes periodically, typically every 30 seconds, ensuring a dynamic and secure authentication process.
Prerequisites
Before diving in, ensure Node.js is installed on your system. Then, install the necessary packages using the following command:
npm install qrcode qrcode-terminal
Detailed Code Walkthrough - Full code is at the end of the blog
Converting Base32 to Hex
TOTP keys, often in Base32 format, require conversion to hexadecimal for processing:
function base32ToHex(base32: string): string {
const base32chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
let bits = "";
let hex = "";
for (let i = 0; i < base32.length; i++) {
const val = base32chars.indexOf(base32.charAt(i).toUpperCase());
bits += leftPad(val.toString(2), 5, "0");
}
for (let i = 0; i + 4 <= bits.length; i += 4) {
const chunk = bits.substring(i, i + 4);
hex += parseInt(chunk, 2).toString(16);
}
return hex;
}
The leftPad Function
The leftPad function plays an important role in ensuring our TOTP generation works correctly. Its purpose is to pad a given string with a specified character to a certain length. This is crucial in cryptographic operations where fixed-length strings are often required.
function leftPad(str: string, len: number, ch: string): string {
len = len - str.length + 1;
return Array(len).join(ch) + str;
}
Generating the TOTP
A key component of our TOTP implementation in Node.js is the generateTOTP function. This function is responsible for generating a one-time password based on the shared secret and the current time. Let’s break down how it works.
function generateTOTP(
secret: string,
algorithm = "sha1",
digits = 6,
period = 30
): string {
const timeCounter = Math.floor(Date.now() / 1000 / period);
const hexCounter = leftPad(timeCounter.toString(16), 16, "0");
const decodedSecret = Buffer.from(base32ToHex(secret), "hex");
const hmac = createHmac(algorithm, decodedSecret)
.update(Buffer.from(hexCounter, "hex"))
.digest();
const offset = hmac[hmac.length - 1] & 0xf;
const binaryCode =
((hmac[offset] & 0x7f) << 24) |
((hmac[offset + 1] & 0xff) << 16) |
((hmac[offset + 2] & 0xff) << 8) |
(hmac[offset + 3] & 0xff);
const otp = binaryCode % Math.pow(10, digits);
return leftPad(otp.toString(), digits, "0");
}
secret: string
: The shared secret key in Base32 format.algorithm = "sha1"
: The hashing algorithm used, defaulting to SHA-1.digits = 6
: The length of the TOTP, typically 6 or 8 digits.period = 30
: The time step in seconds, commonly 30 seconds.
Step-by-Step Breakdown
Time Counter Calculation:
const timeCounter = Math.floor(Date.now() / 1000 / period);
This line calculates the number of time steps that have elapsed since the Unix epoch. It divides the current time in milliseconds by the period (in seconds), rounding down to the nearest whole number.
Hex Counter Preparation:
const hexCounter = leftPad(timeCounter.toString(16), 16, "0");
Converts the time counter to a hexadecimal string and pads it to ensure it’s 16 characters long, a requirement for the HMAC calculation.
Decoding the Secret and HMAC Generation:
const decodedSecret = Buffer.from(base32ToHex(secret), "hex"); const hmac = createHmac(algorithm, decodedSecret).update(Buffer.from(hexCounter, "hex")).digest();
The shared secret is converted from Base32 to hex, then used with the hex counter to create an HMAC (Hash-based Message Authentication Code).
Dynamic Truncation:
const offset = hmac[hmac.length - 1] & 0xf; const binaryCode = // Bitwise operations
A dynamic truncation technique is applied to extract a 4-byte string from the HMAC, which ensures that each TOTP is unpredictable even if previous ones are known.
Generating the OTP:
const otp = binaryCode % Math.pow(10, digits);
The extracted 4-byte string is reduced to the desired number of digits (default is 6).
Final OTP Formatting:
return leftPad(otp.toString(), digits, "0");
The OTP is converted to a string and padded with zeros if necessary to ensure it’s always the specified length.
Token Validation
This function validates the one-time password provided by the user against the expected token. Let’s understand how it ensures security and accommodates potential time discrepancies.
function confirm(
userToken: string,
secret: string,
tolerance = 1,
algorithm = "sha1",
digits = 6,
period = 30
): boolean {
const currentToken = generateTOTP(secret, algorithm, digits, period);
// If userToken matches the currentToken, return true
if (userToken === currentToken) return true;
// If a tolerance is set (for clock drift or slight time mismatches),
// generate tokens for the previous and next intervals.
for (let i = 1; i <= tolerance; i++) {
if (
userToken ===
generateTOTPForTimeOffset(secret, i, algorithm, digits, period) ||
userToken ===
generateTOTPForTimeOffset(secret, -i, algorithm, digits, period)
) {
return true;
}
}
return false;
}
function generateTOTPForTimeOffset(
secret: string,
offset: number,
algorithm = "sha1",
digits = 6,
period = 30
): string {
const timeCounter = Math.floor(Date.now() / 1000 / period) + offset;
const hexCounter = leftPad(timeCounter.toString(16), 16, "0");
const decodedSecret = Buffer.from(base32ToHex(secret), "hex");
const hmac = createHmac(algorithm, decodedSecret)
.update(Buffer.from(hexCounter, "hex"))
.digest();
const offsetByte = hmac[hmac.length - 1] & 0xf;
const binaryCode =
((hmac[offsetByte] & 0x7f) << 24) |
((hmac[offsetByte + 1] & 0xff) << 16) |
((hmac[offsetByte + 2] & 0xff) << 8) |
(hmac[offsetByte + 3] & 0xff);
const otp = binaryCode % Math.pow(10, digits);
return leftPad(otp.toString(), digits, "0");
}
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
The confirm
Function Explained
The confirm
function is designed to verify the user-provided token (userToken
) against the expected token generated by the server.
Parameters
userToken
: The token entered by the user.secret
: The shared secret key.tolerance = 1
: Allows a margin for time discrepancies.algorithm = "sha1"
: The hashing algorithm (default SHA-1).digits = 6
: The length of the TOTP.period = 30
: The time step in seconds.
Function Logic
Generate the Current Token: The current valid token is generated using the
generateTOTP
function.Direct Comparison: The function first checks if the
userToken
directly matches thecurrentToken
. If it does, the token is valid.Handling Time Discrepancies: Given the possibility of slight time differences between the server and the client’s device, the function allows a tolerance level. This tolerance level (default 1) lets the function check not only the current time step but also the adjacent ones.
- Time Offset Tokens: The
generateTOTPForTimeOffset
function generates tokens for these adjacent time steps, considering both past and future intervals based on the tolerance level.
- Time Offset Tokens: The
Validating with Tolerance: The function iterates over the time steps within the tolerance range. If the
userToken
matches any of these tokens, it is considered valid.
QR Code Generation
For user convenience, we generate a QR code for easy scanning with TOTP applications:
function generateQRCode(secret: string): void {
const label = encodeURIComponent("[email protected]"); // Replace with appropriate label
const issuer = encodeURIComponent("New"); // Replace with your app's name
const uri = `otpauth://totp/${label}?secret=${secret}&issuer=${issuer}`;
QRCode.toDataURL(uri, (err: any, url: any) => {
if (err) {
console.error("Failed to generate QR Code:", err);
return;
}
// console.log("Scan this QR Code with your authenticator app:", url);
});
}
generateQRCode(secret);
function generateQRCodeInTerminal(secret: string): void {
const label = encodeURIComponent(randomUUID()); // Replace with appropriate label
const issuer = encodeURIComponent("New Name"); // Replace with your app's name
const uri = `otpauth://totp/${label}?secret=${secret}&issuer=${issuer}`;
QRCodeTerminal.generate(uri, { small: true }, function(qr) {
console.log(qr);
});
}
// Usage
generateQRCodeInTerminal(secret);
rl.question("Your Token?", (name: string) => {
// Usage
const userProvidedToken = name;
const isTokenValid = confirm(userProvidedToken, secret);
console.log(isTokenValid ? "Valid token!" : "Invalid token!");
rl.close();
});
Caution - Before copying and pasting any code from online sources, it’s advisable to consult with your security architect alternatively you can use open source library from otpauth. Full code at the end of blog
// Usage
// Unique Secret for Each User
// In a real-world scenario, this secret should be unique for each user.
// It must be securely encrypted and stored in a database or secure storage.
const userUniqueSecret = "NB2W45DFOIZA"; // Example secret, replace with actual user-specific secret
// Generating the TOTP
// This token will be valid for a short period (e.g., 30 seconds).
const currentToken = generateTOTP(userUniqueSecret);
console.log("Your Generated Token is:", currentToken);
// QR Code Generation
// Generate a QR code for easy scanning and setup in a TOTP application like Google Authenticator.
// The QR code should be displayed to the user securely, ideally during the setup process.
generateQRCode(userUniqueSecret);
// QR Code in Terminal (Optional)
// Additionally, for command-line interfaces or debugging, generate a QR code in the terminal.
generateQRCodeInTerminal(userUniqueSecret);
// Note: In a production environment, ensure that all sensitive data, including the TOTP secret,
// is handled securely and in compliance with relevant data protection standards and regulations.
Outline of the Code’s Functionality
- Base32 to Hex Conversion: Transforms Base32 formatted keys into hexadecimal format, suitable for cryptographic operations.
- TOTP Generation: Generates a time-based one-time password using the shared secret key and current time.
- Token Validation: Checks the validity of the user-provided token against the generated TOTP.
- QR Code Generation: Creates QR codes for both web browsers and terminal applications, facilitating the easy addition of the TOTP account to authentication apps.
Full code
import { createHmac, randomUUID } from "crypto";
import * as QRCodeTerminal from "qrcode-terminal";
import * as QRCode from "qrcode";
import * as readline from "readline";
function base32ToHex(base32: string): string {
const base32chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
let bits = "";
let hex = "";
for (let i = 0; i < base32.length; i++) {
const val = base32chars.indexOf(base32.charAt(i).toUpperCase());
bits += leftPad(val.toString(2), 5, "0");
}
for (let i = 0; i + 4 <= bits.length; i += 4) {
const chunk = bits.substring(i, i + 4);
hex += parseInt(chunk, 2).toString(16);
}
return hex;
}
function leftPad(str: string, len: number, ch: string): string {
len = len - str.length + 1;
return Array(len).join(ch) + str;
}
function generateTOTP(
secret: string,
algorithm = "sha1",
digits = 6,
period = 30
): string {
const timeCounter = Math.floor(Date.now() / 1000 / period);
const hexCounter = leftPad(timeCounter.toString(16), 16, "0");
const decodedSecret = Buffer.from(base32ToHex(secret), "hex");
const hmac = createHmac(algorithm, decodedSecret)
.update(Buffer.from(hexCounter, "hex"))
.digest();
const offset = hmac[hmac.length - 1] & 0xf;
const binaryCode =
((hmac[offset] & 0x7f) << 24) |
((hmac[offset + 1] & 0xff) << 16) |
((hmac[offset + 2] & 0xff) << 8) |
(hmac[offset + 3] & 0xff);
const otp = binaryCode % Math.pow(10, digits);
return leftPad(otp.toString(), digits, "0");
}
// Usage
const secret = "NB2W45DFOIZA";
const token = generateTOTP(secret);
console.log("Your Generated Token is:", token);
function confirm(
userToken: string,
secret: string,
tolerance = 1,
algorithm = "sha1",
digits = 6,
period = 30
): boolean {
const currentToken = generateTOTP(secret, algorithm, digits, period);
// If userToken matches the currentToken, return true
if (userToken === currentToken) return true;
// If a tolerance is set (for clock drift or slight time mismatches),
// generate tokens for the previous and next intervals.
for (let i = 1; i <= tolerance; i++) {
if (
userToken ===
generateTOTPForTimeOffset(secret, i, algorithm, digits, period) ||
userToken ===
generateTOTPForTimeOffset(secret, -i, algorithm, digits, period)
) {
return true;
}
}
return false;
}
function generateTOTPForTimeOffset(
secret: string,
offset: number,
algorithm = "sha1",
digits = 6,
period = 30
): string {
const timeCounter = Math.floor(Date.now() / 1000 / period) + offset;
const hexCounter = leftPad(timeCounter.toString(16), 16, "0");
const decodedSecret = Buffer.from(base32ToHex(secret), "hex");
const hmac = createHmac(algorithm, decodedSecret)
.update(Buffer.from(hexCounter, "hex"))
.digest();
const offsetByte = hmac[hmac.length - 1] & 0xf;
const binaryCode =
((hmac[offsetByte] & 0x7f) << 24) |
((hmac[offsetByte + 1] & 0xff) << 16) |
((hmac[offsetByte + 2] & 0xff) << 8) |
(hmac[offsetByte + 3] & 0xff);
const otp = binaryCode % Math.pow(10, digits);
return leftPad(otp.toString(), digits, "0");
}
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
function generateQRCode(secret: string): void {
const label = encodeURIComponent("[email protected]"); // Replace with appropriate label
const issuer = encodeURIComponent("New"); // Replace with your app's name
const uri = `otpauth://totp/${label}?secret=${secret}&issuer=${issuer}`;
QRCode.toDataURL(uri, (err: any, url: any) => {
if (err) {
console.error("Failed to generate QR Code:", err);
return;
}
// console.log("Scan this QR Code with your authenticator app:", url);
});
}
generateQRCode(secret);
function generateQRCodeInTerminal(secret: string): void {
const label = encodeURIComponent(randomUUID()); // Replace with appropriate label
const issuer = encodeURIComponent("New Name"); // Replace with your app's name
const uri = `otpauth://totp/${label}?secret=${secret}&issuer=${issuer}`;
QRCodeTerminal.generate(uri, { small: true }, function(qr) {
console.log(qr);
});
}
// Usage
generateQRCodeInTerminal(secret);
rl.question("Your Token?", (name: string) => {
// Usage
const userProvidedToken = name;
const isTokenValid = confirm(userProvidedToken, secret);
console.log(isTokenValid ? "Valid token!" : "Invalid token!");
rl.close();
});