diff --git a/src/client/Client.js b/src/client/Client.js index 8af6eee..8b65e4e 100644 --- a/src/client/Client.js +++ b/src/client/Client.js @@ -35,6 +35,7 @@ const DataResolver = require('../util/DataResolver'); const Intents = require('../util/Intents'); const DiscordAuthWebsocket = require('../util/RemoteAuth'); const Sweepers = require('../util/Sweepers'); +const TOTP = require('../util/Totp'); /** * The main hub for interacting with the Discord API, and the starting point for any bot. @@ -281,14 +282,13 @@ class Client extends BaseClient { * Logs the client in, establishing a WebSocket connection to Discord. * @param {string} email The email associated with the account * @param {string} password The password assicated with the account - * @param {string|number} [mfaCode = null] The mfa code if you have it enabled * @returns {string | null} Token of the account used * * @example * client.passLogin("test@gmail.com", "SuperSecretPa$$word", 1234) * @deprecated This method will not be updated until I find the most convenient way to implement MFA. */ - async passLogin(email, password, mfaCode = null) { + async passLogin(email, password) { const initial = await this.api.auth.login.post({ auth: false, versioned: true, @@ -298,10 +298,12 @@ class Client extends BaseClient { if ('token' in initial) { return this.login(initial.token); } else if ('ticket' in initial) { + if (!this.options.TOTPKey) throw new Error('TOTPKEY_MISSING'); + const { otp } = await TOTP.generate(this.options.TOTPKey); const totp = await this.api.auth.mfa.totp.post({ auth: false, versioned: true, - data: { gift_code_sku_id: null, login_source: null, code: mfaCode, ticket: initial.ticket }, + data: { gift_code_sku_id: null, login_source: null, code: otp, ticket: initial.ticket }, }); if ('token' in totp) { return this.login(totp.token); diff --git a/src/errors/Messages.js b/src/errors/Messages.js index 62568ed..3cff48b 100644 --- a/src/errors/Messages.js +++ b/src/errors/Messages.js @@ -10,6 +10,7 @@ const Messages = { TOKEN_INVALID: 'An invalid token was provided.', TOKEN_MISSING: 'Request to use token, but token was unavailable to the client.', + TOTPKEY_MISSING: 'Request to use mfa, but TOTPKey was not set in client options.', WS_CLOSE_REQUESTED: 'WebSocket closed due to user request.', WS_CONNECTION_EXISTS: 'There is already an existing WebSocket connection.', diff --git a/src/util/Totp.js b/src/util/Totp.js new file mode 100644 index 00000000..f569765 --- /dev/null +++ b/src/util/Totp.js @@ -0,0 +1,194 @@ +'use strict'; + +/** @typedef {"SHA-1" | "SHA-256" | "SHA-384" | "SHA-512"} TOTPAlgorithm */ +/** @typedef {"hex" | "ascii"} TOTPEncoding */ +/** + * @description Options for TOTP generation + * @typedef {Object} generateOptions + * @property {number} [digits=6] - The number of digits in the OTP. + * @property {TOTPAlgorithm} [algorithm="SHA-1"] - Algorithm used for hashing. + * @property {TOTPEncoding} [encoding="hex"] - Encoding used for the OTP. + * @property {number} [period=30] - The time period for OTP validity in seconds. + * @property {number} [timestamp=Date.now()] - The current timestamp. + */ + +class TOTP { + // eslint-disable-next-line valid-jsdoc + /** + * Generates a Time-based One-Time Password (TOTP) + * @public + * @param {string} key - The secret key for TOTP + * @param {generateOptions} options - Optional parameters for TOTP + * @returns {Promise<{ otp: string, expires: number }>} + */ + static async generate(key, options = {}) { + const _options = { + digits: 6, + algorithm: 'SHA-1', + encoding: 'hex', + period: 30, + timestamp: Date.now(), + ...options, + }; + const epochSeconds = Math.floor(_options.timestam / 1000); + const timeHex = this.dec2hex(Math.floor(epochSeconds / _options.period)).padStart(16, '0'); + + const keyBuffer = _options.encoding === 'hex' ? this.base32ToBuffer(key) : this.asciiToBuffer(key); + + const hmacKey = await this.crypto.importKey( + 'raw', + keyBuffer, + { name: 'HMAC', hash: { name: _options.algorithm } }, + false, + ['sign'], + ); + const signature = await this.crypto.sign('HMAC', hmacKey, this.hex2buf(timeHex)); + + const signatureHex = this.buf2hex(signature); + const offset = this.hex2dec(signatureHex.slice(-1)) * 2; + const masked = this.hex2dec(signatureHex.slice(offset, offset + 8)) & 0x7fffffff; + const otp = masked.toString().slice(-_options.digits); + + const period = _options.period * 1000; + const expires = Math.ceil((_options.timestamp + 1) / period) * period; + + return { otp, expires }; + } + + /** + * Converta a hexadecimal string into a decimal number + * @private + * @param {string} hex - The hexadecimal string + * @returns {number} The decimal representation + */ + static hex2dec(hex) { + return parseInt(hex, 16); + } + + /** + * Converts a decimal number into a hexadeciamal string + * @private + * @param {number} dec - The decimal number + * @returns {string} The hex representation + */ + static dec2hex(dec) { + return (dec < 15.5 ? '0' : '') + Math.round(dec).toString(16); + } + + /** + * Converts a base32 encoded string to an ArrayBuffer + * @private + * @param {string} str - The base32 encoded string to convert. + * @returns {ArrayBuffer} The ArrayBuffer representation of the base32 encoded string + */ + static base32ToBuffer(str) { + str = str.toUpperCase(); + let length = str.length; + while (str.charCodeAt(length - 1) === 61) length--; // Remove padding + + const bufferSize = (length * 5) / 8; + const buffer = new Uint8Array(bufferSize); + let value = 0, + bits = 0, + index = 0; + + for (let i = 0; i < length; i++) { + const charCode = this.base32[str.charCodeAt(i)]; + if (charCode === undefined) throw new Error('Invalid base32 character in key'); + value = (value << 5) | charCode; + bits += 5; + + if (bits >= 8) buffer[index++] = value >>> (bits -= 8); + } + + return buffer.buffer; + } + + /** + * Converts an ASCII string to an ArrayBuffer + * @private + * @param {string} str - The ASCII string to convert + * @returns {ArrayBuffer} The ArrayBuffer representation of the string + */ + static asciiToBuffer(str) { + const buffer = new Uint8Array(str.length); + for (let i = 0; i < str.length; i++) { + buffer[i] = str.charCodeAt(i); + } + return buffer.buffer; + } + + /** + * Converts a hexadecimal string to an ArrayBuffer + * @private + * @param {string} hex - The hexadecimal string to convert + * @returns {ArrayBuffer} The ArrayBuffer representation of the string + */ + static hex2buf(hex) { + const buffer = new Uint8Array(hex.length / 2); + + for (let i = 0, j = 0; i < hex.length; i += 2, j++) buffer[j] = this.hex2dec(hex.slice(i, i + 2)); + + return buffer.buffer; + } + + /** + * Converts an ArrayBuffer to a hexadecimal string + * @private + * @param {ArrayBuffer} buffer - The ArrayBuffer to convert + * @returns {string} The hexadecimal string representation of the buffer + */ + static buf2hex(buffer) { + return [...new Uint8Array(buffer)].map(x => x.toString(16).padStart(2, '0')).join(''); + } + + /** + * A precalculated mapping from base32 character codes to their corresponding index values for performance optimization + * This mapping is used in the base32ToBuffer method to convert base32 encoded strings to their binary representation + * @private + * @readonly + */ + static base32 = { + 50: 26, + 51: 27, + 52: 28, + 53: 29, + 54: 30, + 55: 31, + 65: 0, + 66: 1, + 67: 2, + 68: 3, + 69: 4, + 70: 5, + 71: 6, + 72: 7, + 73: 8, + 74: 9, + 75: 10, + 76: 11, + 77: 12, + 78: 13, + 79: 14, + 80: 15, + 81: 16, + 82: 17, + 83: 18, + 84: 19, + 85: 20, + 86: 21, + 87: 22, + 88: 23, + 89: 24, + 90: 25, + }; + + /** + * @private + * @static + * @readonly + */ + static crypto = (globalThis.crypto || require('crypto').webcrypto).subtle; +} + +module.exports = TOTP; diff --git a/typings/index.d.ts b/typings/index.d.ts index 6e63dae..d26c19d 100644 --- a/typings/index.d.ts +++ b/typings/index.d.ts @@ -782,7 +782,7 @@ export class Client extends BaseClient { public sleep(timeout: number): Promise; public login(token?: string): Promise; /** @deprecated This method will not be updated until I find the most convenient way to implement MFA. */ - public passLogin(email: string, password: string, mfaCode?: string | number): Promise; + public passLogin(email: string, password: string): Promise; public QRLogin(): Promise; public logout(): Promise; public isReady(): this is Client; @@ -3428,6 +3428,29 @@ export class ThreadMemberFlags extends BitField { public static resolve(bit?: BitFieldResolvable): number; } +export class TOTP { + private static hex2dec(hex: string): number; + private static dec2hex(dec: number): string; + private static base32ToBuffer(str: string): ArrayBuffer; + private static asciiToBuffer(str: string): ArrayBuffer; + private static hex2buf(hex: string): ArrayBuffer; + private static buf2hex(buf: ArrayBuffer): string; + private static readonly base32: { [key: number]: number }; + private static readonly crypto: SubtleCrypto; + public static generate(key: string, options?: generateOptions): Promise<{ otp: string; expires: number}>; +} + +export type TOTPAlgorithm = "SHA-1" | "SHA-256" | "SHA-384" | "SHA-512"; +export type TOTPEncoding = "hex" | "ascii"; + +export interface generateOptions { + digits: number; + algorithm: TOTPAlgorithm; + encoding: TOTPEncoding; + period: number; + timestamp: number; +} + export class Typing extends Base { private constructor(channel: TextBasedChannel, user: PartialUser, data?: RawTypingData); public channel: TextBasedChannel; @@ -5711,6 +5734,7 @@ export interface ClientOptions { ws?: WebSocketOptions; http?: HTTPOptions; rejectOnRateLimit?: string[] | ((data: RateLimitData) => boolean | Promise); + TOTPKey?: string; } export type ClientPresenceStatus = 'online' | 'idle' | 'dnd';