feat: integrate otplib for TOTP authentication and remove deprecated TOTP utility

This commit is contained in:
Elysia
2025-07-10 20:11:13 +07:00
parent d774c97d94
commit a82ba7aad1
7 changed files with 51 additions and 216 deletions

View File

@@ -59,6 +59,7 @@
"discord-api-types": "^0.38.15", "discord-api-types": "^0.38.15",
"fetch-cookie": "^3.1.0", "fetch-cookie": "^3.1.0",
"find-process": "^2.0.0", "find-process": "^2.0.0",
"otplib": "^12.0.1",
"prism-media": "^1.3.5", "prism-media": "^1.3.5",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"tough-cookie": "^5.1.2", "tough-cookie": "^5.1.2",

View File

@@ -5,6 +5,7 @@ const process = require('node:process');
const { setInterval } = require('node:timers'); const { setInterval } = require('node:timers');
const { setTimeout } = require('node:timers'); const { setTimeout } = require('node:timers');
const { Collection } = require('@discordjs/collection'); const { Collection } = require('@discordjs/collection');
const { authenticator } = require('otplib');
const BaseClient = require('./BaseClient'); const BaseClient = require('./BaseClient');
const ActionsManager = require('./actions/ActionsManager'); const ActionsManager = require('./actions/ActionsManager');
const ClientVoiceManager = require('./voice/ClientVoiceManager'); const ClientVoiceManager = require('./voice/ClientVoiceManager');
@@ -36,7 +37,6 @@ const DataResolver = require('../util/DataResolver');
const Intents = require('../util/Intents'); const Intents = require('../util/Intents');
const DiscordAuthWebsocket = require('../util/RemoteAuth'); const DiscordAuthWebsocket = require('../util/RemoteAuth');
const Sweepers = require('../util/Sweepers'); 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. * The main hub for interacting with the Discord API, and the starting point for any bot.
@@ -195,6 +195,18 @@ class Client extends BaseClient {
*/ */
this.readyAt = null; this.readyAt = null;
/**
* The authenticator used for TOTP
* @type {Object}
*/
this.authenticator = authenticator;
this.authenticator.options = {
step: 30,
digits: 6,
algorithm: 'sha1',
};
if (this.options.messageSweepInterval > 0) { if (this.options.messageSweepInterval > 0) {
process.emitWarning( process.emitWarning(
'The message sweeping client options are deprecated, use the global sweepers instead.', 'The message sweeping client options are deprecated, use the global sweepers instead.',
@@ -306,7 +318,7 @@ class Client extends BaseClient {
return this.login(initial.token); return this.login(initial.token);
} else if ('ticket' in initial) { } else if ('ticket' in initial) {
if (!this.options.TOTPKey) throw new Error('TOTPKEY_MISSING'); if (!this.options.TOTPKey) throw new Error('TOTPKEY_MISSING');
const { otp } = await TOTP.generate(this.options.TOTPKey); const otp = this.authenticator.generate(this.options.TOTPKey);
const totp = await this.api.auth.mfa.totp.post({ const totp = await this.api.auth.mfa.totp.post({
auth: false, auth: false,
versioned: true, versioned: true,

View File

@@ -2,15 +2,10 @@
const Buffer = require('node:buffer').Buffer; const Buffer = require('node:buffer').Buffer;
const { setTimeout } = require('node:timers'); const { setTimeout } = require('node:timers');
const makeFetchCookie = require('fetch-cookie'); const { FormData, buildConnector, Client, ProxyAgent } = require('undici');
const { CookieJar } = require('tough-cookie');
const { fetch: fetchOriginal, FormData, buildConnector, Client, ProxyAgent } = require('undici');
const { ciphers } = require('../util/Constants'); const { ciphers } = require('../util/Constants');
const Util = require('../util/Util'); const Util = require('../util/Util');
const cookieJar = new CookieJar();
const fetch = makeFetchCookie.default(fetchOriginal, cookieJar);
let agent = null; let agent = null;
class APIRequest { class APIRequest {
@@ -142,15 +137,17 @@ class APIRequest {
const controller = new AbortController(); const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), this.client.options.restRequestTimeout).unref(); const timeout = setTimeout(() => controller.abort(), this.client.options.restRequestTimeout).unref();
return fetch(url, { return this.rest
method: this.method.toUpperCase(), // Undici doesn't normalize "patch" into "PATCH" (which surprisingly follows the spec). .fetch(url, {
headers, method: this.method.toUpperCase(), // Undici doesn't normalize "patch" into "PATCH" (which surprisingly follows the spec).
body, headers,
signal: controller.signal, body,
redirect: 'follow', signal: controller.signal,
dispatcher: agent, redirect: 'follow',
credentials: 'include', dispatcher: agent,
}).finally(() => clearTimeout(timeout)); credentials: 'include',
})
.finally(() => clearTimeout(timeout));
} }
} }

View File

@@ -2,6 +2,9 @@
const { setInterval } = require('node:timers'); const { setInterval } = require('node:timers');
const { Collection } = require('@discordjs/collection'); const { Collection } = require('@discordjs/collection');
const makeFetchCookie = require('fetch-cookie');
const { CookieJar } = require('tough-cookie');
const { fetch: fetchOriginal } = require('undici');
const APIRequest = require('./APIRequest'); const APIRequest = require('./APIRequest');
const routeBuilder = require('./APIRouter'); const routeBuilder = require('./APIRouter');
const RequestHandler = require('./RequestHandler'); const RequestHandler = require('./RequestHandler');
@@ -17,6 +20,8 @@ class RESTManager {
this.globalRemaining = this.globalLimit; this.globalRemaining = this.globalLimit;
this.globalReset = null; this.globalReset = null;
this.globalDelay = null; this.globalDelay = null;
this.cookieJar = new CookieJar();
this.fetch = makeFetchCookie.default(fetchOriginal, this.cookieJar);
if (client.options.restSweepInterval > 0) { if (client.options.restSweepInterval > 0) {
this.sweepInterval = setInterval(() => { this.sweepInterval = setInterval(() => {
this.handlers.sweep(handler => handler._inactive); this.handlers.sweep(handler => handler._inactive);

View File

@@ -9,7 +9,6 @@ const RateLimitError = require('./RateLimitError');
const { const {
Events: { DEBUG, RATE_LIMIT, INVALID_REQUEST_WARNING, API_RESPONSE, API_REQUEST }, Events: { DEBUG, RATE_LIMIT, INVALID_REQUEST_WARNING, API_RESPONSE, API_REQUEST },
} = require('../util/Constants'); } = require('../util/Constants');
const TOTP = require('../util/Totp');
const captchaMessage = [ const captchaMessage = [
'incorrect-captcha', 'incorrect-captcha',
@@ -396,7 +395,7 @@ class RequestHandler {
request.retries < 1 request.retries < 1
) { ) {
// Get mfa code // Get mfa code
const { otp } = await TOTP.generate(this.manager.client.options.TOTPKey); const otp = this.manager.client.authenticator.generate(this.manager.client.options.TOTPKey);
this.manager.client.emit( this.manager.client.emit(
DEBUG, DEBUG,
`${data.message} `${data.message}

View File

@@ -1,194 +0,0 @@
'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.timestamp / 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;

21
typings/index.d.ts vendored
View File

@@ -20,7 +20,6 @@ import {
underscore, underscore,
userMention, userMention,
} from '@discordjs/builders'; } from '@discordjs/builders';
import { RtpPacket } from 'werift-rtp';
import { Collection } from '@discordjs/collection'; import { Collection } from '@discordjs/collection';
import { import {
APIActionRowComponent, APIActionRowComponent,
@@ -73,6 +72,9 @@ import { AgentOptions } from 'node:https';
import { Response, ProxyAgent } from 'undici'; import { Response, ProxyAgent } from 'undici';
import { Readable, Writable, Stream } from 'node:stream'; import { Readable, Writable, Stream } from 'node:stream';
import { MessagePort, Worker } from 'node:worker_threads'; import { MessagePort, Worker } from 'node:worker_threads';
import { authenticator } from "otplib";
import { CookieJar } from 'tough-cookie';
import { RtpPacket } from 'werift-rtp';
import * as WebSocket from 'ws'; import * as WebSocket from 'ws';
import { import {
ActivityTypes, ActivityTypes,
@@ -460,10 +462,22 @@ export abstract class Base {
public valueOf(): string; public valueOf(): string;
} }
export class RESTManager {
private constructor(client: Client);
public readonly client: Client;
public handlers: Collection<string, unknown>;
public versioned: true;
public cookieJar: CookieJar;
public fetch: typeof globalThis.fetch;
public getAuth(): string;
public readonly api: unknown;
public readonly cdn: unknown;
}
export class BaseClient extends EventEmitter { export class BaseClient extends EventEmitter {
public constructor(options?: ClientOptions | WebhookClientOptions); public constructor(options?: ClientOptions | WebhookClientOptions);
private readonly api: unknown; public readonly api: RESTManager['api'];
private rest: unknown; public rest: RESTManager;
private decrementMaxListeners(): void; private decrementMaxListeners(): void;
private incrementMaxListeners(): void; private incrementMaxListeners(): void;
@@ -769,6 +783,7 @@ export type If<T extends boolean, A, B = null> = T extends true ? A : T extends
export class Client<Ready extends boolean = boolean> extends BaseClient { export class Client<Ready extends boolean = boolean> extends BaseClient {
public constructor(options?: ClientOptions); public constructor(options?: ClientOptions);
private actions: unknown; private actions: unknown;
public authenticator: typeof authenticator;
private presence: ClientPresence; private presence: ClientPresence;
private _eval(script: string): unknown; private _eval(script: string): unknown;
private _validateOptions(options: ClientOptions): void; private _validateOptions(options: ClientOptions): void;