Skip to content

Instantly share code, notes, and snippets.

@erdesigns-eu
Last active January 3, 2025 09:06
Show Gist options
  • Save erdesigns-eu/cea47189b096eeccfb74f35820ab0395 to your computer and use it in GitHub Desktop.
Save erdesigns-eu/cea47189b096eeccfb74f35820ab0395 to your computer and use it in GitHub Desktop.
Time-based one-time password in Typescript
import { randomBytes, createHmac, timingSafeEqual } from 'crypto';
/**
* Charset for base32 encoding
* @see https://en.wikipedia.org/wiki/Base32
* @see https://tools.ietf.org/html/rfc4648
*/
const base32Charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
/**
* Number of digits in TOTP token
*/
const DIGITS = 6;
/**
* Modulo for TOTP token
*/
const TOKEN_MODULO = 10 ** DIGITS;
/**
* Type for TOTP algorithm
* @property {string} SHA1 - HMAC-SHA1 algorithm
* @property {string} SHA256 - HMAC-SHA256 algorithm
* @property {string} SHA512 - HMAC-SHA512 algorithm
*/
type TotpAlgorithm = 'SHA1' | 'SHA256' | 'SHA512';
/**
* Convert base32 to hex string
* @param base32 - Base32 encoded string
* @returns Hexadecimal string
*/
function base32ToHex(base32: string): string {
let bits = 0; // Accumulated bits
let bitCount = 0; // Number of accumulated bits
let hex = ''; // Resulting hex string
for (const char of base32) {
// Find value of base32 character
const value = base32Charset.indexOf(char);
// Check for invalid characters
if (value === -1) {
throw new Error(`Invalid base32 character: ${char}`);
}
// Accumulate bits
bits = (bits << 5) | value;
// Update bit count
bitCount += 5;
while (bitCount >= 4) {
// Extract chunks of 4 bits
const hexValue = (bits >> (bitCount - 4)) & 0xf;
// Append hex value to result
hex += hexValue.toString(16);
// Update bit count
bitCount -= 4;
}
}
return hex;
}
/**
* Validate base32 encoded string
* @param base32 - Base32 encoded string
*/
function validateBase32(base32: string): void {
const regex = /^[A-Z2-7]+$/;
if (!regex.test(base32)) {
throw new Error('Invalid base32 string. It must contain only A-Z and 2-7.');
}
}
/**
* Validate HMAC algorithm
* @param algorithm - HMAC algorithm
*/
function validateAlgorithm(algorithm: string): void {
const validAlgorithms = ['SHA1', 'SHA256', 'SHA512'];
if (!validAlgorithms.includes(algorithm)) {
throw new Error(`Invalid algorithm. It must be one of ${validAlgorithms.join(', ')}.`);
}
}
/**
* Generate a random secret key for TOTP
* @param length - Length of the secret key (default: 24)
* @returns Random base32 encoded secret key
*/
function generateSecret(length = 24): string {
return randomBytes(length)
.map((value: number) =>
base32Charset.charCodeAt(Math.floor((value * base32Charset.length) / 256))
)
.toString('utf8');
}
/**
* Parameters for token generation
* @property {string} secret - Base32 encoded secret
* @property {number} [timestamp] - Timestamp for deterministic tests (default: current time)
* @property {number} [interval] - Time interval in seconds (default: 30)
* @property {TotpAlgorithm} [algorithm] - HMAC algorithm (default: SHA1)
* @see https://tools.ietf.org/html/rfc6238#section-4
*/
type GenerateTokenParams = {
secret: string;
timestamp?: number;
interval?: number;
algorithm?: TotpAlgorithm;
}
/**
* Generate a TOTP token using HMAC-SHA1
* @param params - Parameters for token generation
* @returns 6-digit TOTP token
*/
function generateToken(params: GenerateTokenParams): string {
// Destructure parameters with default values
const {
secret,
timestamp = Date.now(),
interval = 30,
algorithm = 'SHA1',
} = params;
// Validate base32 secret key
validateBase32(secret);
// Validate HMAC algorithm
validateAlgorithm(algorithm);
// Calculate time slice
const time = Math.floor(timestamp / (interval * 1000));
// Allocate a buffer for 8 bytes
const message = Buffer.alloc(8);
// Write time as a 64-bit integer
message.writeBigUInt64BE(BigInt(time));
// Decode the secret from base32
const key = Buffer.from(base32ToHex(secret.toUpperCase()), 'hex');
// Generate HMAC digest
const hmac = createHmac(algorithm.toLowerCase(), key).update(message).digest();
// Extract offset from the last byte
const offset = hmac[hmac.length - 1] & 0xf;
// Extract 31-bit code
const code = ((hmac[offset] & 0x7f) << 24) | ((hmac[offset + 1] & 0xff) << 16) | ((hmac[offset + 2] & 0xff) << 8) | (hmac[offset + 3] & 0xff);
// Return 6-digit token
return (code % TOKEN_MODULO).toString().padStart(DIGITS, '0');
}
/**
* Parameters for token validation
* @property {string} secret - Base32 encoded secret
* @property {string} token - TOTP token to validate
* @property {number} [interval] - Time interval in seconds (default: 30)
* @property {number} [threshold] - Number of valid time periods (default: 1)
* @property {number} [timestamp] - Timestamp for deterministic tests (default: current time)
* @property {TotpAlgorithm} [algorithm] - HMAC algorithm (default: SHA1)
*/
type ValidateTokenParams = {
secret: string;
token: string;
interval?: number;
threshold?: number;
timestamp?: number;
algorithm?: TotpAlgorithm;
}
/**
* Validate a TOTP token using HMAC-SHA1
* @param params - Parameters for token validation
* @returns True if the token is valid, false otherwise
*/
function validateToken(params: ValidateTokenParams): boolean {
// Destructure parameters with default values
const {
secret,
token,
interval = 30,
threshold = 1,
timestamp = Date.now(),
algorithm = 'SHA1',
} = params;
if (threshold < 1) {
throw new Error('Threshold value must be greater than or equal to 1.');
}
if (threshold > 5) {
throw new Error('Threshold value must be less than or equal to 5 due to performance reasons.');
}
// Validate base32 secret key
validateBase32(secret);
// Validate HMAC algorithm
validateAlgorithm(algorithm);
// Convert token to buffer for constant-time comparison
const tokenBuffer = Buffer.from(token);
// Generate tokens for a range of timestamps
for (let i = -threshold; i <= threshold; i++) {
// Generate token for the current timestamp
const generatedToken = generateToken({ secret, timestamp: timestamp + i * interval * 1000, interval, algorithm });
// Convert generated token to buffer for constant-time comparison
const generatedTokenBuffer = Buffer.from(generatedToken);
// Compare the generated token with the provided token
if (
tokenBuffer.length === generatedTokenBuffer.length &&
timingSafeEqual(tokenBuffer, generatedTokenBuffer)
) {
return true;
}
}
// Token is invalid
return false;
}
/**
* Parameters for URI generation
* @property {string} label - Label for the account (e.g., app name)
* @property {string} username - Username for the account (e.g., email)
* @property {string} secret - Base32 encoded secret
* @property {string} issuer - Issuer for the account (e.g., app name)
* @property {number} [interval] - Time interval in seconds (default: 30)
* @property {TotpAlgorithm} [algorithm] - HMAC algorithm (default: SHA1)
*/
type GenerateUriParams = {
label: string;
username: string;
secret: string;
issuer: string;
interval?: number;
algorithm?: TotpAlgorithm;
}
/**
* Generate an otpauth URI for TOTP
* @param params - Parameters for URI generation
* @returns URI string
* @note Older versions of Google Authenticator do not support the 'algorithm' parameter
* and always use HMAC-SHA1 for TOTP. If you want to support these versions,
* you should omit the 'algorithm' parameter.
*/
function generateUri(params: GenerateUriParams): string {
// Destructure parameters with default values
const {
label,
username,
secret,
issuer,
interval = 30,
algorithm = 'SHA1',
} = params;
if (!label) {
throw new Error('Label is required for generating URI.');
}
if (!username) {
throw new Error('Username is required for generating URI.');
}
if (!issuer) {
throw new Error('Issuer is required for generating URI.');
}
// Validate base32 secret key
validateBase32(secret);
// Validate HMAC algorithm
validateAlgorithm(algorithm);
// Encode label (e.g., app name)
const encodedLabel = encodeURIComponent(label);
// Encode username (e.g., email)
const encodedUsername = encodeURIComponent(username);
// Encode secret (base32)
const encodedSecret = encodeURIComponent(secret);
// Encode issuer (e.g., app name)
const encodedIssuer = encodeURIComponent(issuer);
// Generate an otpauth URI
return `otpauth://totp/${encodedLabel}:${encodedUsername}?secret=${encodedSecret}&issuer=${encodedIssuer}&algorithm=${algorithm}&digits=${DIGITS}&period=${interval}`;
}
export {
generateSecret,
generateToken,
validateToken,
generateUri
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment