@epic-web/totp
Version:
Create and verify cryptographically secure Time-based One-time Passwords (TOTP) using the HMAC-based One-time Password (HOTP) algorithm.
295 lines (277 loc) • 10.2 kB
JavaScript
/**
* This was copy/paste/modified/tested from https://npm.im/notp (MIT)
*/
import base32Encode from 'base32-encode'
import base32Decode from 'base32-decode'
/**
* @typedef {'SHA-1' | 'SHA-256' | 'SHA-386' | 'SHA-512' | string & {}} HashAlgorithm
*
* For all available algorithms, refer to the following:
* https://developer.mozilla.org/en-US/docs/Web/API/HmacImportParams#hash
*/
// SHA-1 is not secure, but in the context of TOTPs, it's unrealistic to expect
// security issues. Also, it's the default for compatibility with OTP apps.
// That said, if you're acting the role of both client and server and your TOTP
// is longer lived, you can definitely use a more secure algorithm like SHA-256.
// Learn more: https://www.rfc-editor.org/rfc/rfc4226#page-25 (B.1. SHA-1 Status)
const DEFAULT_ALGORITHM = 'SHA-1'
const DEFAULT_CHAR_SET = '0123456789'
const DEFAULT_DIGITS = 6
const DEFAULT_WINDOW = 1
const DEFAULT_PERIOD = 30
/**
* Generates a HMAC-based One Time Password (HOTP) using the provided secret and
* configuration options.
*
* @param {ArrayBuffer} secret - The secret used to generate the HOTP.
* @param {Object} options - The configuration options for the HOTP.
* @param {number} [options.counter=0] - The counter value to use for the HOTP.
* Defaults to 0.
* @param {number} [options.digits=6] - The number of digits to use for the
* HOTP. Defaults to 6.
* @param {HashAlgorithm} [options.algorithm='SHA-1'] - The algorithm to use for the
* HOTP. Defaults to 'SHA-1'.
* @param {string} [options.charSet='0123456789'] - The character set to use, defaults to the numbers 0-9.
* @returns {Promise<string>} The generated HOTP.
*/
async function generateHOTP(
secret,
{
counter = 0,
digits = DEFAULT_DIGITS,
algorithm = DEFAULT_ALGORITHM,
charSet = DEFAULT_CHAR_SET,
} = {}
) {
const byteCounter = intToBytes(counter)
const key = await crypto.subtle.importKey(
'raw',
secret,
{ name: 'HMAC', hash: algorithm },
false,
['sign']
)
const signature = await crypto.subtle.sign('HMAC', key, byteCounter)
const hashBytes = new Uint8Array(signature)
// offset is always the last 4 bits of the signature; its value: 0-15
const offset = hashBytes[hashBytes.length - 1] & 0xf
let hotpVal = 0n
// the original specification allows any amount of digits between 4 and 10,
// so stay on the 32bit number if the digits are less then or equal to 10.
if (digits <= 10) {
// stay compatible with the authenticator apps and only use the bottom 32 bits of BigInt
hotpVal =
0n |
(BigInt(hashBytes[offset] & 0x7f) << 24n) |
(BigInt(hashBytes[offset + 1]) << 16n) |
(BigInt(hashBytes[offset + 2]) << 8n) |
BigInt(hashBytes[offset + 3])
} else {
// otherwise create a 64bit value from the hashBytes
hotpVal =
0n |
(BigInt(hashBytes[offset] & 0x7f) << 56n) |
(BigInt(hashBytes[offset + 1]) << 48n) |
(BigInt(hashBytes[offset + 2]) << 40n) |
(BigInt(hashBytes[offset + 3]) << 32n) |
(BigInt(hashBytes[offset + 4]) << 24n) |
// we have only 20 hashBytes; if offset is 15 these indexes are out of the hashBytes
// fallback to the bytes at the start of the hashBytes
(BigInt(hashBytes[(offset + 5) % 20]) << 16n) |
(BigInt(hashBytes[(offset + 6) % 20]) << 8n) |
BigInt(hashBytes[(offset + 7) % 20])
}
let hotp = ''
const charSetLength = BigInt(charSet.length)
for (let i = 0; i < digits; i++) {
hotp = charSet.charAt(Number(hotpVal % charSetLength)) + hotp
// Ensures hotpVal decreases at a fixed rate, independent of charSet length.
// 10n is compatible with the original TOTP algorithm used in the authenticator apps.
hotpVal = hotpVal / 10n
}
return hotp
}
/**
* Verifies a HMAC-based One Time Password (HOTP) using the provided OTP and
* configuration options.
*
* @param {string} otp - The OTP to verify.
* @param {ArrayBuffer} secret - The secret used to generate the HOTP.
* @param {Object} options - The configuration options for the HOTP.
* @param {number} [options.counter=0] - The counter value to use for the HOTP.
* Defaults to 0.
* @param {number} [options.digits=6] - The number of digits to use for the
* HOTP. Defaults to 6.
* @param {HashAlgorithm} [options.algorithm='SHA-1'] - The algorithm to use for the
* HOTP. Defaults to 'SHA-1'.
* @param {string} [options.charSet='0123456789'] - The character set to use, defaults to the numbers 0-9.
* @param {number} [options.window=1] - The number of counter values to check
* before and after the current counter value. Defaults to 1.
* @returns {Promise<{delta: number}|null>} An object with the `delta` property
* indicating the number of counter values between the current counter value and
* the verified counter value, or `null` if the OTP could not be verified.
*/
async function verifyHOTP(
otp,
secret,
{
counter = 0,
digits = DEFAULT_DIGITS,
algorithm = DEFAULT_ALGORITHM,
charSet = DEFAULT_CHAR_SET,
window = DEFAULT_WINDOW,
} = {}
) {
for (let i = counter - window; i <= counter + window; ++i) {
if (
(await generateHOTP(secret, {
counter: i,
digits,
algorithm,
charSet,
})) === otp
) {
return { delta: i - counter }
}
}
return null
}
/**
* Creates a time-based one-time password (TOTP). This handles creating a random
* secret (base32 encoded), and generating a TOTP for the current time. As a
* convenience, it also returns the config options used to generate the TOTP.
*
* @param {Object} [options] Configuration options for the TOTP.
* @param {number} [options.period=30] The number of seconds for the OTP to be
* valid. Defaults to 30.
* @param {number} [options.digits=6] The length of the OTP. Defaults to 6.
* @param {HashAlgorithm} [options.algorithm='SHA-1'] The algorithm to use. Defaults to
* SHA-1.
* @param {string} [options.charSet='0123456789'] - The character set to use, defaults to the numbers 0-9.
* @param {string} [options.secret] The secret to use for the TOTP. It should be
* base32 encoded (you can use https://npm.im/thirty-two). Defaults to a random
* secret: base32Encode(crypto.getRandomValues(new Uint8Array(10)), 'RFC4648').
* @returns {Promise<{otp: string, secret: string, period: number, digits: number, algorithm: string, charSet: string}>}
* The OTP, secret, and config options used to generate the OTP.
*/
export async function generateTOTP({
period = DEFAULT_PERIOD,
digits = DEFAULT_DIGITS,
algorithm = DEFAULT_ALGORITHM,
secret = base32Encode(crypto.getRandomValues(new Uint8Array(10)), 'RFC4648'),
charSet = DEFAULT_CHAR_SET,
} = {}) {
const otp = await generateHOTP(base32Decode(secret, 'RFC4648'), {
counter: getCounter(Number(period)),
digits: Number(digits),
algorithm,
charSet,
})
return { otp, secret, period, digits, algorithm, charSet }
}
/**
* Generates a otpauth:// URI which you can use to generate a QR code or users
* can manually enter into their password manager.
*
* @param {Object} options Configuration options for the TOTP Auth URI.
* @param {number} options.period The number of seconds for the OTP to be valid.
* @param {number} options.digits The length of the OTP.
* @param {HashAlgorithm} options.algorithm The algorithm to use. (Note, we
* automatically remove the dashes from the algorithm name because the otpauth
* URI spec requires it.)
* @param {string} options.secret The secret to use for the TOTP Auth URI.
* @param {string} options.accountName A way to uniquely identify this Auth URI
* (in case they have multiple of these).
* @param {string} options.issuer The issuer to use for the TOTP Auth URI.
*
* @returns {string} The OTP Auth URI
*/
export function getTOTPAuthUri({
period,
digits,
algorithm,
secret,
accountName,
issuer,
}) {
const params = new URLSearchParams({
secret,
issuer,
algorithm: algorithm.replaceAll('-', ''),
digits: digits.toString(),
period: period.toString(),
})
const escapedIssuer = encodeURIComponent(issuer)
const escapedAccountName = encodeURIComponent(accountName)
const label = `${escapedIssuer}:${escapedAccountName}`
return `otpauth://totp/${label}?${params.toString()}`
}
/**
* Verifies a time-based one-time password (TOTP). This handles decoding the
* secret (base32 encoded), and verifying the OTP for the current time.
*
* @param {Object} options The otp, secret, and configuration options for the
* TOTP.
* @param {string} options.otp The OTP to verify.
* @param {string} options.secret The secret to use for the TOTP.
* @param {number} [options.period] The number of seconds for the OTP to be valid.
* @param {number} [options.digits] The length of the OTP.
* @param {HashAlgorithm} [options.algorithm] The algorithm to use.
* @param {string} [options.charSet] - The character set to use, defaults to the numbers 0-9.
* @param {number} [options.window] The number of OTPs to check before and after
* the current OTP. Defaults to 1.
*
* @returns {Promise<{delta: number}|null>} an object with "delta" which is the delta
* between the current OTP and the OTP that was verified, or null if the OTP is
* invalid.
*/
export async function verifyTOTP({
otp,
secret,
period,
digits,
algorithm,
charSet,
window = DEFAULT_WINDOW,
}) {
let decodedSecret
try {
decodedSecret = base32Decode(secret, 'RFC4648')
} catch (error) {
// If the secret is invalid, return null
return null
}
return verifyHOTP(otp, new Uint8Array(decodedSecret), {
counter: getCounter(period),
digits,
window,
algorithm,
charSet,
})
}
/**
* Converts a number to a byte array.
*
* @param {number} num The number to convert to a byte array.
* @returns {Uint8Array} The byte array representation of the number.
*/
function intToBytes(num) {
const arr = new Uint8Array(8)
for (let i = 7; i >= 0; i--) {
arr[i] = num & 0xff
num = num >> 8
}
return arr
}
/**
* Calculates the current counter value for the TOTP based on the current time
* and the specified period.
*
* @param {number} [period=30] The number of seconds for the OTP to be valid.
* @returns {number} The current counter value for the TOTP.
*/
function getCounter(period = DEFAULT_PERIOD) {
const now = new Date().getTime()
const counter = Math.floor(now / 1000 / period)
return counter
}