prsm/packages/otp/src/index.ts
2024-08-27 18:16:34 -04:00

187 lines
7.8 KiB
TypeScript

import * as base32 from "hi-base32";
import * as crypto from "node:crypto";
export class InvalidOtpLengthError extends Error {}
export class InvalidSecretError extends Error {}
export class InvalidHashFunctionError extends Error {}
export class InvalidSecretStrengthError extends Error {}
class Otp {
static OTP_LENGTH_MIN = 6;
static OTP_LENGTH_MAX = 8;
static OTP_LENGTH_DEFAULT = 6;
static INTERVAL_LENGTH_DEFAULT = 30;
static EPOCH_DEFAULT = 0;
static HASH_FUNCTION_SHA_1 = 1;
static HASH_FUNCTION_SHA_256 = 2;
static HASH_FUNCTION_SHA_512 = 3;
static HASH_FUNCTION_DEFAULT = Otp.HASH_FUNCTION_SHA_1;
static SHARED_SECRET_STRENGTH_LOW = 1;
static SHARED_SECRET_STRENGTH_MODERATE = 2;
static SHARED_SECRET_STRENGTH_HIGH = 3;
/**
* Generates a shared secret using a specified strength.
*
* @param {number} strength - The strength of the shared secret, defaulting to Otp.SHARED_SECRET_STRENGTH_HIGH.
* This determines the number of bits used to generate the secret.
* @returns {string} - A base32 encoded string representing the generated shared secret.
* @throws {Error} - If the strength parameter is invalid or if there is an issue generating random bytes.
*/
static createSecret(strength: number = Otp.SHARED_SECRET_STRENGTH_HIGH): string {
const bits = this.determineBitsForSharedSecretStrength(strength);
const bytes = Math.ceil(bits / 8);
const buffer = crypto.randomBytes(bytes);
return base32.encode(buffer).replace(/=+$/, "");
}
/**
* Generates a TOTP (Time-based One-Time Password) Key URI for use in QR code generation.
* This URI can be scanned by authenticator apps like Google Authenticator or Authy.
*
* @param {string} issuer - The name of the service or organization issuing the OTP.
* @param {string} accountName - The account name or email address associated with the OTP.
* @param {string} secret - The shared secret key used for generating the OTP.
* @returns {string} - A URI formatted according to the otpauth URI scheme.
* @throws {Error} - Throws an error if any of the parameters are invalid or missing.
*/
static createTotpKeyUriForQrCode(issuer: string, accountName: string, secret: string): string {
return `otpauth://totp/${issuer}:${encodeURIComponent(accountName)}?secret=${secret}&issuer=${issuer}`;
}
/**
* Generates a Time-based One-Time Password (TOTP) using the provided secret and parameters.
*
* @param {string} secret - The shared secret key used for generating the TOTP. Must be at least 16 characters long.
* @param {number} [t=Math.floor(Date.now() / 1000)] - The current Unix time in seconds. Defaults to the current time.
* @param {number} [otpLength=Otp.OTP_LENGTH_DEFAULT] - The desired length of the OTP. Must be between Otp.OTP_LENGTH_MIN and Otp.OTP_LENGTH_MAX.
* @param {number} [t_x=Otp.INTERVAL_LENGTH_DEFAULT] - The time step in seconds. Defaults to Otp.INTERVAL_LENGTH_DEFAULT.
* @param {number} [t_0=Otp.EPOCH_DEFAULT] - The Unix time to start counting time steps. Defaults to Otp.EPOCH_DEFAULT.
* @param {number} [hashFunction=Otp.HASH_FUNCTION_DEFAULT] - The hash function to use (e.g., Otp.HASH_FUNCTION_SHA_1, Otp.HASH_FUNCTION_SHA_256, Otp.HASH_FUNCTION_SHA_512). Defaults to Otp.HASH_FUNCTION_DEFAULT.
* @returns {string} - The generated TOTP as a string of digits, padded to the specified length.
* @throws {InvalidOtpLengthError} - If the specified OTP length is not within the valid range.
* @throws {InvalidSecretError} - If the provided secret is less than 16 characters long.
* @throws {InvalidHashFunctionError} - If the specified hash function is not supported.
*/
static generateTotp(
secret: string,
t: number = Math.floor(Date.now() / 1000),
otpLength: number = Otp.OTP_LENGTH_DEFAULT,
t_x: number = Otp.INTERVAL_LENGTH_DEFAULT,
t_0: number = Otp.EPOCH_DEFAULT,
hashFunction: number = Otp.HASH_FUNCTION_DEFAULT,
): string {
if (otpLength < Otp.OTP_LENGTH_MIN || otpLength > Otp.OTP_LENGTH_MAX) {
throw new InvalidOtpLengthError();
}
secret = secret ? secret : "";
t = t ? t : Math.floor(Date.now() / 1000);
t_x = t_x ? t_x : Otp.INTERVAL_LENGTH_DEFAULT;
t_0 = t_0 ? t_0 : Otp.EPOCH_DEFAULT;
const c_t = Math.max(0, Math.floor((t - t_0) / t_x)); // Ensure c_t is non-negative
secret = secret.replace(/[^A-Za-z2-7]/g, "").toUpperCase();
if (secret.length < 16) {
throw new InvalidSecretError();
}
const k = base32.decode.asBytes(secret);
const counter64BitBigEndian = Buffer.alloc(8);
counter64BitBigEndian.writeUInt32BE(Math.floor(c_t / Math.pow(2, 32)), 0);
counter64BitBigEndian.writeUInt32BE(c_t % Math.pow(2, 32), 4);
let hashFunctionNameForHmac: string;
switch (hashFunction) {
case Otp.HASH_FUNCTION_SHA_1:
hashFunctionNameForHmac = "sha1";
break;
case Otp.HASH_FUNCTION_SHA_256:
hashFunctionNameForHmac = "sha256";
break;
case Otp.HASH_FUNCTION_SHA_512:
hashFunctionNameForHmac = "sha512";
break;
default:
throw new InvalidHashFunctionError();
}
const hmac = crypto.createHmac(hashFunctionNameForHmac, Buffer.from(k));
hmac.update(counter64BitBigEndian);
const mac = hmac.digest();
const offset = mac[mac.length - 1] & 0x0f;
const macSubstring4Bytes = mac.slice(offset, offset + 4);
const integer32Bit = macSubstring4Bytes.readUInt32BE(0) & 0x7fffffff;
const hotp = integer32Bit % Math.pow(10, otpLength);
return hotp.toString().padStart(otpLength, "0");
}
/**
* Verifies a Time-based One-Time Password (TOTP) against a given secret.
*
* @param {string} secret - The shared secret key used to generate the TOTP.
* @param {string} otpValue - The TOTP value to be verified.
* @param {number} [lookBehindSteps=2] - The number of time steps to look behind for a valid TOTP.
* @param {number} [lookAheadSteps=2] - The number of time steps to look ahead for a valid TOTP.
* @param {number} [t=Math.floor(Date.now() / 1000)] - The current Unix time in seconds.
* @param {number} [otpLength=Otp.OTP_LENGTH_DEFAULT] - The expected length of the TOTP.
* @param {number} [t_x=Otp.INTERVAL_LENGTH_DEFAULT] - The time step interval in seconds.
* @param {number} [t_0=Otp.EPOCH_DEFAULT] - The Unix epoch to start counting time steps from.
* @param {number} [hashFunction=Otp.HASH_FUNCTION_DEFAULT] - The hash function to use for generating the TOTP.
* @returns {boolean} - Returns true if the TOTP is valid, false otherwise.
* @throws {Error} - Throws an error if the OTP value length is not within the valid range.
*/
static verifyTotp(
secret: string,
otpValue: string,
lookBehindSteps: number = 2,
lookAheadSteps: number = 2,
t: number = Math.floor(Date.now() / 1000),
otpLength: number = Otp.OTP_LENGTH_DEFAULT,
t_x: number = Otp.INTERVAL_LENGTH_DEFAULT,
t_0: number = Otp.EPOCH_DEFAULT,
hashFunction: number = Otp.HASH_FUNCTION_DEFAULT,
): boolean {
otpValue = otpValue.replace(/[^0-9]/g, "");
if (otpValue.length < Otp.OTP_LENGTH_MIN || otpValue.length > Otp.OTP_LENGTH_MAX) {
return false;
}
if (otpValue.length !== otpLength) {
return false;
}
for (let s = -lookBehindSteps; s <= lookAheadSteps; s++) {
const expectedOtpValue = this.generateTotp(secret, t + t_x * s, otpLength, t_x, t_0, hashFunction);
if (crypto.timingSafeEqual(Buffer.from(expectedOtpValue), Buffer.from(otpValue))) {
return true;
}
}
return false;
}
private static determineBitsForSharedSecretStrength(strength: number): number {
switch (strength) {
case 1:
return 80;
case 2:
return 128;
case 3:
return 160;
default:
throw new InvalidSecretStrengthError();
}
}
}
export default Otp;