Last active
January 3, 2025 09:06
-
-
Save erdesigns-eu/cea47189b096eeccfb74f35820ab0395 to your computer and use it in GitHub Desktop.
Time-based one-time password in Typescript
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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