diff --git a/.eslintrc.js b/.eslintrc.js index 3b6a7546..6a3ba8f9 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -181,6 +181,10 @@ module.exports = { ], globals: { Atomics: 'readonly', SharedArrayBuffer: 'readonly' }, parser: '@typescript-eslint/parser', + parserOptions: { + project: true, + tsconfigRootDir: __dirname, + }, plugins: ['@typescript-eslint'], rules: { // https://github.com/typescript-eslint/typescript-eslint/tree/master/packages/eslint-plugin#supported-rules diff --git a/package.json b/package.json index baed19ac..26509876 100644 --- a/package.json +++ b/package.json @@ -27,15 +27,15 @@ "@types/node": "18.16.0", "@types/node-forge": "1.3.2", "@types/ws": "8.5.4", - "@typescript-eslint/eslint-plugin": "5.59.8", - "@typescript-eslint/parser": "5.59.8", + "@typescript-eslint/eslint-plugin": "6.4.0", + "@typescript-eslint/parser": "6.4.0", "chai": "4.3.7", "dotenv-flow": "3.2.0", - "eslint": "8.42.0", + "eslint": "8.47.0", "eslint-config-prettier": "8.8.0", - "eslint-import-resolver-typescript": "3.5.5", + "eslint-import-resolver-typescript": "3.6.0", "eslint-plugin-ascii": "1.0.0", - "eslint-plugin-import": "2.27.5", + "eslint-plugin-import": "2.28.0", "eslint-plugin-simple-import-sort": "10.0.0", "glob": "10.2.6", "husky": "^8.0.3", diff --git a/packages/client/scripts/generate-client.js b/packages/client/scripts/generate-client.js index 1feb9a6f..a51af6bb 100644 --- a/packages/client/scripts/generate-client.js +++ b/packages/client/scripts/generate-client.js @@ -64,8 +64,11 @@ async function addSingleMethod(state, fileName) { if ( !stmt.importClause.namedBindings || - stmt.importClause.namedBindings.kind !== ts.SyntaxKind.NamedImports - ) { throwError(stmt, fileName, 'Only named imports are supported!') } + stmt.importClause.namedBindings.kind !== + ts.SyntaxKind.NamedImports + ) { + throwError(stmt, fileName, 'Only named imports are supported!') + } let module = stmt.moduleSpecifier.text @@ -131,11 +134,7 @@ async function addSingleMethod(state, fileName) { })() if (!isExported && !isPrivate) { - throwError( - stmt, - fileName, - 'Public methods MUST be exported.', - ) + throwError(stmt, fileName, 'Public methods MUST be exported.') } if (isExported && !checkForFlag(stmt, '@internal')) { @@ -182,16 +181,20 @@ async function addSingleMethod(state, fileName) { ) } - const returnsExported = (stmt.body ? - ts.getLeadingCommentRanges(fileFullText, stmt.body.pos + 2) || - (stmt.statements && - stmt.statements.length && - ts.getLeadingCommentRanges( - fileFullText, - stmt.statements[0].pos, - )) || - [] : - [] + const returnsExported = ( + stmt.body ? + ts.getLeadingCommentRanges( + fileFullText, + stmt.body.pos + 2, + ) || + (stmt.statements && + stmt.statements.length && + ts.getLeadingCommentRanges( + fileFullText, + stmt.statements[0].pos, + )) || + [] : + [] ) .map((range) => fileFullText.substring(range.pos, range.end)) .join('\n') @@ -275,7 +278,9 @@ async function addSingleMethod(state, fileName) { } async function main() { - const output = fs.createWriteStream(path.join(__dirname, '../src/client.ts')) + const output = fs.createWriteStream( + path.join(__dirname, '../src/client.ts'), + ) const state = { imports: {}, fields: [], @@ -295,7 +300,8 @@ async function main() { } output.write( - '/* THIS FILE WAS AUTO-GENERATED */\n' + + '/* eslint-disable @typescript-eslint/no-unsafe-declaration-merging, @typescript-eslint/unified-signatures */\n' + + '/* THIS FILE WAS AUTO-GENERATED */\n' + "import { BaseTelegramClient, BaseTelegramClientOptions } from '@mtcute/core'\n" + "import { tl } from '@mtcute/tl'\n", ) @@ -336,7 +342,9 @@ async function main() { * @param name Event name * @param handler ${updates.toSentence(type, 'full')} */ -on(name: '${type.typeName}', handler: ((upd: ${type.updateType}) => void)): this\n`) +on(name: '${type.typeName}', handler: ((upd: ${ + type.updateType +}) => void)): this\n`) }) const printer = ts.createPrinter() @@ -406,7 +414,9 @@ on(name: '${type.typeName}', handler: ((upd: ${type.updateType}) => void)): this it.initializer = undefined const deleteParents = (obj) => { - if (Array.isArray(obj)) { return obj.forEach((it) => deleteParents(it)) } + if (Array.isArray(obj)) { + return obj.forEach((it) => deleteParents(it)) + } if (obj.parent) delete obj.parent @@ -455,7 +465,7 @@ on(name: '${type.typeName}', handler: ((upd: ${type.updateType}) => void)): this for (const name of [origName, ...aliases]) { if (!hasOverloads) { if (!comment.match(/\/\*\*?\s*\*\//)) { - // empty comment, no need to write it + // empty comment, no need to write it output.write(comment + '\n') } @@ -465,18 +475,14 @@ on(name: '${type.typeName}', handler: ((upd: ${type.updateType}) => void)): this } if (!overload) { - classContents.push( - `${name} = ${origName}`, - ) + classContents.push(`${name} = ${origName}`) } } }, ) output.write('}\n') - output.write( - '\nexport class TelegramClient extends BaseTelegramClient {\n', - ) + output.write('\nexport class TelegramClient extends BaseTelegramClient {\n') state.fields.forEach(({ code }) => output.write(`protected ${code}\n`)) @@ -501,10 +507,9 @@ on(name: '${type.typeName}', handler: ((upd: ${type.updateType}) => void)): this await fs.promises.writeFile(targetFile, fullSource) // fix using eslint - require('child_process').execSync( - `pnpm exec eslint --fix ${targetFile}`, - { stdio: 'inherit' }, - ) + require('child_process').execSync(`pnpm exec eslint --fix ${targetFile}`, { + stdio: 'inherit', + }) } main().catch(console.error) diff --git a/packages/client/src/client.ts b/packages/client/src/client.ts index b214cc3e..e127382f 100644 --- a/packages/client/src/client.ts +++ b/packages/client/src/client.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-unsafe-declaration-merging */ /* THIS FILE WAS AUTO-GENERATED */ import { Readable } from 'stream' @@ -8,7 +9,6 @@ import { Deque, MaybeArray, MaybeAsync, - SessionConnection, SortedLinkedList, } from '@mtcute/core' import { ConditionVariable } from '@mtcute/core/src/utils/condition-variable' @@ -131,7 +131,6 @@ import { getMessages } from './methods/messages/get-messages' import { getMessagesUnsafe } from './methods/messages/get-messages-unsafe' import { getReactionUsers } from './methods/messages/get-reaction-users' import { getScheduledMessages } from './methods/messages/get-scheduled-messages' -import { _normalizeInline } from './methods/messages/normalize-inline' import { _parseEntities } from './methods/messages/parse-entities' import { pinMessage } from './methods/messages/pin-message' import { readHistory } from './methods/messages/read-history' @@ -1909,14 +1908,15 @@ export interface TelegramClient extends BaseTelegramClient { /** * Total file size. Automatically inferred for Buffer, File and local files. - * - * When using with streams, if `fileSize` is not passed, the entire file is - * first loaded into memory to determine file size, and used as a Buffer later. - * This might be a major performance bottleneck, so be sure to provide file size - * when using streams and file size is known (which often is the case). */ fileSize?: number + /** + * If the file size is unknown, you can provide an estimate, + * which will be used to determine appropriate part size. + */ + estimatedSize?: number + /** * File MIME type. By default is automatically inferred from magic number * If MIME can't be inferred, it defaults to `application/octet-stream` @@ -1931,11 +1931,16 @@ export interface TelegramClient extends BaseTelegramClient { */ partSize?: number + /** + * Number of parts to be sent in parallel per connection. + */ + requestsPerConnection?: number + /** * Function that will be called after some part has been uploaded. * * @param uploaded Number of bytes already uploaded - * @param total Total file size + * @param total Total file size, if known */ progressCallback?: (uploaded: number, total: number) => void }): Promise @@ -2759,10 +2764,6 @@ export interface TelegramClient extends BaseTelegramClient { messageIds: number[] ): Promise<(Message | null)[]> - _normalizeInline( - id: string | tl.TypeInputBotInlineMessageID - ): Promise<[tl.TypeInputBotInlineMessageID, SessionConnection]> - _parseEntities( text?: string | FormattedString, mode?: string | null, @@ -4024,8 +4025,6 @@ export class TelegramClient extends BaseTelegramClient { protected _selfUsername: string | null protected _pendingConversations: Record protected _hasConversations: boolean - protected _downloadConnections: Record - protected _connectionsForInline: Record protected _parseModes: Record protected _defaultParseMode: string | null protected _updatesLoopActive: boolean @@ -4060,8 +4059,6 @@ export class TelegramClient extends BaseTelegramClient { this.log.prefix = '[USER N/A] ' this._pendingConversations = {} this._hasConversations = false - this._downloadConnections = {} - this._connectionsForInline = {} this._parseModes = {} this._defaultParseMode = null this._updatesLoopActive = false @@ -4213,7 +4210,6 @@ export class TelegramClient extends BaseTelegramClient { getMessages = getMessages getReactionUsers = getReactionUsers getScheduledMessages = getScheduledMessages - _normalizeInline = _normalizeInline _parseEntities = _parseEntities pinMessage = pinMessage readHistory = readHistory diff --git a/packages/client/src/methods/_imports.ts b/packages/client/src/methods/_imports.ts index a67ffb0b..0b2abc89 100644 --- a/packages/client/src/methods/_imports.ts +++ b/packages/client/src/methods/_imports.ts @@ -2,12 +2,7 @@ import { Readable } from 'stream' // @copy -import { - AsyncLock, - MaybeArray, - MaybeAsync, - SessionConnection, -} from '@mtcute/core' +import { AsyncLock, MaybeArray, MaybeAsync } from '@mtcute/core' // @copy import { Logger } from '@mtcute/core/src/utils/logger' // @copy diff --git a/packages/client/src/methods/auth/check-password.ts b/packages/client/src/methods/auth/check-password.ts index 736d2d4e..4c0aeb1a 100644 --- a/packages/client/src/methods/auth/check-password.ts +++ b/packages/client/src/methods/auth/check-password.ts @@ -38,16 +38,18 @@ export async function checkPassword( 'user', ) - this.log.prefix = `[USER ${this._userId}] ` this._userId = res.user.id + this.log.prefix = `[USER ${this._userId}] ` this._isBot = false this._selfChanged = true this._selfUsername = res.user.username ?? null + await this.network.notifyLoggedIn(res) + await this._fetchUpdatesState() await this._saveStorage() // telegram ignores invokeWithoutUpdates for auth methods - if (this._disableUpdates) this.primaryConnection._resetSession() + if (this.network.params.disableUpdates) this.network.resetSessions() else this.startUpdatesLoop() return new User(this, res.user) diff --git a/packages/client/src/methods/auth/send-code.ts b/packages/client/src/methods/auth/send-code.ts index a6eec87e..88548b87 100644 --- a/packages/client/src/methods/auth/send-code.ts +++ b/packages/client/src/methods/auth/send-code.ts @@ -19,7 +19,7 @@ export async function sendCode( const res = await this.call({ _: 'auth.sendCode', phoneNumber: phone, - apiId: this._initConnectionParams.apiId, + apiId: this.network._initConnectionParams.apiId, apiHash: this._apiHash, settings: { _: 'codeSettings' }, }) diff --git a/packages/client/src/methods/auth/sign-in-bot.ts b/packages/client/src/methods/auth/sign-in-bot.ts index 6262215a..a7a49ea2 100644 --- a/packages/client/src/methods/auth/sign-in-bot.ts +++ b/packages/client/src/methods/auth/sign-in-bot.ts @@ -17,7 +17,7 @@ export async function signInBot( const res = await this.call({ _: 'auth.importBotAuthorization', flags: 0, - apiId: this._initConnectionParams.apiId, + apiId: this.network._initConnectionParams.apiId, apiHash: this._apiHash, botAuthToken: token, }) @@ -33,16 +33,19 @@ export async function signInBot( 'user', ) - this.log.prefix = `[USER ${this._userId}] ` this._userId = res.user.id + this.log.prefix = `[USER ${this._userId}] ` this._isBot = true this._selfUsername = res.user.username! this._selfChanged = true + + await this.network.notifyLoggedIn(res) + await this._fetchUpdatesState() await this._saveStorage() // telegram ignores invokeWithoutUpdates for auth methods - if (this._disableUpdates) this.primaryConnection._resetSession() + if (this.network.params.disableUpdates) this.network.resetSessions() else this.startUpdatesLoop() return new User(this, res.user) diff --git a/packages/client/src/methods/auth/sign-in.ts b/packages/client/src/methods/auth/sign-in.ts index a443279e..d37afd6c 100644 --- a/packages/client/src/methods/auth/sign-in.ts +++ b/packages/client/src/methods/auth/sign-in.ts @@ -41,16 +41,18 @@ export async function signIn( assertTypeIs('signIn (@ auth.signIn -> user)', res.user, 'user') - this.log.prefix = `[USER ${this._userId}] ` this._userId = res.user.id + this.log.prefix = `[USER ${this._userId}] ` this._isBot = false this._selfChanged = true this._selfUsername = res.user.username ?? null + await this.network.notifyLoggedIn(res) + await this._fetchUpdatesState() await this._saveStorage() // telegram ignores invokeWithoutUpdates for auth methods - if (this._disableUpdates) this.primaryConnection._resetSession() + if (this.network.params.disableUpdates) this.network.resetSessions() else this.startUpdatesLoop() return new User(this, res.user) diff --git a/packages/client/src/methods/auth/sign-up.ts b/packages/client/src/methods/auth/sign-up.ts index fcff30f8..b9449e2c 100644 --- a/packages/client/src/methods/auth/sign-up.ts +++ b/packages/client/src/methods/auth/sign-up.ts @@ -32,15 +32,18 @@ export async function signUp( assertTypeIs('signUp (@ auth.signUp)', res, 'auth.authorization') assertTypeIs('signUp (@ auth.signUp -> user)', res.user, 'user') - this.log.prefix = `[USER ${this._userId}] ` this._userId = res.user.id + this.log.prefix = `[USER ${this._userId}] ` this._isBot = false this._selfChanged = true + + await this.network.notifyLoggedIn(res) + await this._fetchUpdatesState() await this._saveStorage() // telegram ignores invokeWithoutUpdates for auth methods - if (this._disableUpdates) this.primaryConnection._resetSession() + if (this.network.params.disableUpdates) this.network.resetSessions() else this.startUpdatesLoop() return new User(this, res.user) diff --git a/packages/client/src/methods/auth/start-test.ts b/packages/client/src/methods/auth/start-test.ts index 3d6a3170..9b8e2d42 100644 --- a/packages/client/src/methods/auth/start-test.ts +++ b/packages/client/src/methods/auth/start-test.ts @@ -78,7 +78,7 @@ export async function startTest( if (!availableDcs.find((dc) => dc.id === id)) { throw new MtArgumentError(`${phone} has invalid DC ID (${id})`) } } else { - let dcId = this._primaryDc.id + let dcId = this._defaultDc.id if (params.dcId) { if (!availableDcs.find((dc) => dc.id === params!.dcId)) { throw new MtArgumentError(`DC ID is invalid (${dcId})`) } diff --git a/packages/client/src/methods/auth/start.ts b/packages/client/src/methods/auth/start.ts index 6e8ab4ce..bef94d44 100644 --- a/packages/client/src/methods/auth/start.ts +++ b/packages/client/src/methods/auth/start.ts @@ -155,7 +155,9 @@ export async function start( me.isBot, ) - if (!this._disableUpdates) { + this.network.setIsPremium(me.isPremium) + + if (!this.network.params.disableUpdates) { this._catchUpChannels = Boolean(params.catchUp) if (!params.catchUp) { @@ -175,14 +177,18 @@ export async function start( if (!(e instanceof tl.errors.AuthKeyUnregisteredError)) throw e } - if (!params.phone && !params.botToken) { throw new MtArgumentError('Neither phone nor bot token were provided') } + if (!params.phone && !params.botToken) { + throw new MtArgumentError('Neither phone nor bot token were provided') + } let phone = params.phone ? await resolveMaybeDynamic(params.phone) : null if (phone) { phone = normalizePhoneNumber(phone) - if (!params.code) { throw new MtArgumentError('You must pass `code` to use `phone`') } + if (!params.code) { + throw new MtArgumentError('You must pass `code` to use `phone`') + } } else { const botToken = params.botToken ? await resolveMaybeDynamic(params.botToken) : diff --git a/packages/client/src/methods/bots/get-game-high-scores.ts b/packages/client/src/methods/bots/get-game-high-scores.ts index 5759c5d4..803f2feb 100644 --- a/packages/client/src/methods/bots/get-game-high-scores.ts +++ b/packages/client/src/methods/bots/get-game-high-scores.ts @@ -1,12 +1,8 @@ import { tl } from '@mtcute/tl' import { TelegramClient } from '../../client' -import { - GameHighScore, - InputPeerLike, - MtInvalidPeerTypeError, - PeersIndex, -} from '../../types' +import { GameHighScore, InputPeerLike, PeersIndex } from '../../types' +import { normalizeInlineId } from '../../utils/inline-utils' import { normalizeToInputUser } from '../../utils/peer-utils' /** @@ -57,7 +53,7 @@ export async function getInlineGameHighScores( messageId: string | tl.TypeInputBotInlineMessageID, userId?: InputPeerLike, ): Promise { - const [id, connection] = await this._normalizeInline(messageId) + const id = await normalizeInlineId(messageId) let user: tl.TypeInputUser @@ -73,7 +69,7 @@ export async function getInlineGameHighScores( id, userId: user, }, - { connection }, + { dcId: id.dcId }, ) const peers = PeersIndex.from(res) diff --git a/packages/client/src/methods/bots/set-game-score.ts b/packages/client/src/methods/bots/set-game-score.ts index e95351c0..091ab765 100644 --- a/packages/client/src/methods/bots/set-game-score.ts +++ b/packages/client/src/methods/bots/set-game-score.ts @@ -2,6 +2,7 @@ import { tl } from '@mtcute/tl' import { TelegramClient } from '../../client' import { InputPeerLike, Message, MtInvalidPeerTypeError } from '../../types' +import { normalizeInlineId } from '../../utils/inline-utils' import { normalizeToInputUser } from '../../utils/peer-utils' /** @@ -86,7 +87,7 @@ export async function setInlineGameScore( const user = normalizeToInputUser(await this.resolvePeer(userId), userId) - const [id, connection] = await this._normalizeInline(messageId) + const id = await normalizeInlineId(messageId) await this.call( { @@ -97,6 +98,6 @@ export async function setInlineGameScore( editMessage: !params.noEdit, force: params.force, }, - { connection }, + { dcId: id.dcId }, ) } diff --git a/packages/client/src/methods/files/_initialize.ts b/packages/client/src/methods/files/_initialize.ts deleted file mode 100644 index f01ea4b1..00000000 --- a/packages/client/src/methods/files/_initialize.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { SessionConnection } from '@mtcute/core' - -import { TelegramClient } from '../../client' - -// @extension -interface FilesExtension { - _downloadConnections: Record -} - -// @initialize -function _initializeFiles(this: TelegramClient): void { - this._downloadConnections = {} -} diff --git a/packages/client/src/methods/files/download-iterable.ts b/packages/client/src/methods/files/download-iterable.ts index 35311848..3ff2b5a7 100644 --- a/packages/client/src/methods/files/download-iterable.ts +++ b/packages/client/src/methods/files/download-iterable.ts @@ -1,3 +1,4 @@ +import { ConditionVariable, ConnectionKind } from '@mtcute/core' import { fileIdToInputFileLocation, fileIdToInputWebFileLocation, @@ -14,6 +15,12 @@ import { } from '../../types' import { determinePartSize } from '../../utils/file-utils' +// small files (less than 128 kb) are downloaded using the "downloadSmall" pool +// furthermore, if the file is small and is located on our main DC, it will be downloaded +// using the current main connection +const SMALL_FILE_MAX_SIZE = 131072 +const REQUESTS_PER_CONNECTION = 3 // some arbitrary magic value that seems to work best + /** * Download a file and return it as an iterable, which yields file contents * in chunks of a given size. Order of the chunks is guaranteed to be @@ -26,17 +33,7 @@ export async function* downloadAsIterable( this: TelegramClient, params: FileDownloadParameters, ): AsyncIterableIterator { - const partSizeKb = - params.partSize ?? - (params.fileSize ? determinePartSize(params.fileSize) : 64) - - if (partSizeKb % 4 !== 0) { - throw new MtArgumentError( - `Invalid part size: ${partSizeKb}. Must be divisible by 4.`, - ) - } - - let offset = params.offset ?? 0 + const offset = params.offset ?? 0 if (offset % 4096 !== 0) { throw new MtArgumentError( @@ -76,26 +73,54 @@ export async function* downloadAsIterable( const isWeb = tl.isAnyInputWebFileLocation(location) // we will receive a FileMigrateError in case this is invalid - if (!dcId) dcId = this._primaryDc.id + if (!dcId) dcId = this._defaultDc.id + + const partSizeKb = + params.partSize ?? (fileSize ? determinePartSize(fileSize) : 64) + + if (partSizeKb % 4 !== 0) { + throw new MtArgumentError( + `Invalid part size: ${partSizeKb}. Must be divisible by 4.`, + ) + } const chunkSize = partSizeKb * 1024 - let limit = - params.limit ?? - // derive limit from chunk size, file size and offset - (fileSize ? - ~~((fileSize + chunkSize - offset - 1) / chunkSize) : - // we will receive an error when we have reached the end anyway - Infinity) + let limitBytes = params.limit ?? fileSize ?? Infinity + if (limitBytes === 0) return - let connection = this._downloadConnections[dcId] + let numChunks = + limitBytes === Infinity ? + Infinity : + ~~((limitBytes + chunkSize - offset - 1) / chunkSize) - if (!connection) { - connection = await this.createAdditionalConnection(dcId) - this._downloadConnections[dcId] = connection + let nextChunkIdx = 0 + let nextWorkerChunkIdx = 0 + const nextChunkCv = new ConditionVariable() + const buffer: Record = {} + + const isSmall = fileSize && fileSize <= SMALL_FILE_MAX_SIZE + let connectionKind: ConnectionKind + + if (isSmall) { + connectionKind = + dcId === this.network.getPrimaryDcId() ? 'main' : 'downloadSmall' + } else { + connectionKind = 'download' } + const poolSize = this.network.getPoolSize(connectionKind, dcId) - const requestCurrent = async (): Promise => { + this.log.debug( + 'Downloading file of size %d from dc %d using %s connection pool (pool size: %d)', + limitBytes, + dcId, + connectionKind, + poolSize, + ) + + const downloadChunk = async ( + chunk = nextWorkerChunkIdx++, + ): Promise => { let result: | tl.RpcCallReturn['upload.getFile'] | tl.RpcCallReturn['upload.getWebFile'] @@ -106,22 +131,17 @@ export async function* downloadAsIterable( _: isWeb ? 'upload.getWebFile' : 'upload.getFile', // eslint-disable-next-line @typescript-eslint/no-explicit-any location: location as any, - offset, + offset: chunkSize * chunk, limit: chunkSize, }, - { connection }, + { dcId, kind: connectionKind }, ) - // eslint-disable-next-line @typescript-eslint/no-explicit-any + // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (e: any) { if (e.constructor === tl.errors.FileMigrateXError) { - connection = this._downloadConnections[e.new_dc] + dcId = e.new_dc - if (!connection) { - connection = await this.createAdditionalConnection(e.new_dc) - this._downloadConnections[e.new_dc] = connection - } - - return requestCurrent() + return downloadChunk(chunk) } else if (e.constructor === tl.errors.FilerefUpgradeNeededError) { // todo: implement someday // see: https://github.com/LonamiWebs/Telethon/blob/0e8bd8248cc649637b7c392616887c50986427a0/telethon/client/downloads.py#L99 @@ -141,25 +161,65 @@ export async function* downloadAsIterable( if ( result._ === 'upload.webFile' && result.size && - limit === Infinity + limitBytes === Infinity ) { - limit = result.size + limitBytes = result.size + numChunks = ~~((limitBytes + chunkSize - offset - 1) / chunkSize) } - return result.bytes + buffer[chunk] = result.bytes + + if (chunk === nextChunkIdx) { + nextChunkCv.notify() + } + + if ( + nextWorkerChunkIdx < numChunks && + result.bytes.length === chunkSize + ) { + return downloadChunk() + } } - for (let i = 0; i < limit; i++) { - const buf = await requestCurrent() + let error: unknown = undefined + Promise.all( + Array.from( + { length: Math.min(poolSize * REQUESTS_PER_CONNECTION, numChunks) }, + downloadChunk, + ), + ) + .catch((e) => { + this.log.debug('download workers errored: %s', e.message) + error = e + nextChunkCv.notify() + }) + .then(() => { + this.log.debug('download workers finished') + }) - if (buf.length === 0) { - // we've reached the end - return + let position = offset + + while (position < limitBytes) { + await nextChunkCv.wait() + + if (error) throw error + + while (nextChunkIdx in buffer) { + const buf = buffer[nextChunkIdx] + delete buffer[nextChunkIdx] + + position += buf.length + + params.progressCallback?.(position, limitBytes) + + yield buf + + nextChunkIdx++ + + if (buf.length < chunkSize) { + // we received the last chunk + return + } } - - yield buf - offset += chunkSize - - params.progressCallback?.(offset, limit) } } diff --git a/packages/client/src/methods/files/upload-file.ts b/packages/client/src/methods/files/upload-file.ts index 02a346fc..c969cc3b 100644 --- a/packages/client/src/methods/files/upload-file.ts +++ b/packages/client/src/methods/files/upload-file.ts @@ -3,7 +3,7 @@ import { fromBuffer as fileTypeFromBuffer } from 'file-type' import type { ReadStream } from 'fs' import { Readable } from 'stream' -import { randomLong } from '@mtcute/core' +import { AsyncLock, randomLong } from '@mtcute/core' import { tl } from '@mtcute/tl' import { TelegramClient } from '../../client' @@ -13,7 +13,6 @@ import { bufferToStream, convertWebStreamToNodeReadable, readBytesFromStream, - readStreamUntilEnd, } from '../../utils/stream-utils' let fs: any = null @@ -29,6 +28,14 @@ const OVERRIDE_MIME: Record = { 'audio/opus': 'audio/ogg', } +// small files (less than 128 kb) are uploaded using the current connection and not the "upload" pool +const SMALL_FILE_MAX_SIZE = 131072 +const BIG_FILE_MIN_SIZE = 10485760 // files >10 MB are considered "big" +const DEFAULT_FILE_NAME = 'unnamed' +const REQUESTS_PER_CONNECTION = 3 +const MAX_PART_COUNT = 4000 // 512 kb * 4000 = 2000 MiB +const MAX_PART_COUNT_PREMIUM = 8000 // 512 kb * 8000 = 4000 MiB + /** * Upload a file to Telegram servers, without actually * sending a message anywhere. Useful when an `InputFile` is required. @@ -60,14 +67,15 @@ export async function uploadFile( /** * Total file size. Automatically inferred for Buffer, File and local files. - * - * When using with streams, if `fileSize` is not passed, the entire file is - * first loaded into memory to determine file size, and used as a Buffer later. - * This might be a major performance bottleneck, so be sure to provide file size - * when using streams and file size is known (which often is the case). */ fileSize?: number + /** + * If the file size is unknown, you can provide an estimate, + * which will be used to determine appropriate part size. + */ + estimatedSize?: number + /** * File MIME type. By default is automatically inferred from magic number * If MIME can't be inferred, it defaults to `application/octet-stream` @@ -82,11 +90,16 @@ export async function uploadFile( */ partSize?: number + /** + * Number of parts to be sent in parallel per connection. + */ + requestsPerConnection?: number + /** * Function that will be called after some part has been uploaded. * * @param uploaded Number of bytes already uploaded - * @param total Total file size + * @param total Total file size, if known */ progressCallback?: (uploaded: number, total: number) => void }, @@ -94,7 +107,7 @@ export async function uploadFile( // normalize params let file = params.file let fileSize = -1 // unknown - let fileName = 'unnamed' + let fileName = DEFAULT_FILE_NAME let fileMime = params.fileMime if (Buffer.isBuffer(file)) { @@ -162,12 +175,12 @@ export async function uploadFile( } } - if (fileName === 'unnamed') { + if (fileName === DEFAULT_FILE_NAME) { // try to infer from url const url = new URL(file.url) const name = url.pathname.split('/').pop() - if (name && name.indexOf('.') > -1) { + if (name && name.includes('.')) { fileName = name } } @@ -192,42 +205,88 @@ export async function uploadFile( // set file size if not automatically inferred if (fileSize === -1 && params.fileSize) fileSize = params.fileSize - if (fileSize === -1) { - // load the entire stream into memory - const buffer = await readStreamUntilEnd(file as Readable) - fileSize = buffer.length - file = bufferToStream(buffer) + let partSizeKb = params.partSize + + if (!partSizeKb) { + if (fileSize === -1) { + partSizeKb = params.estimatedSize ? + determinePartSize(params.estimatedSize) : + 64 + } else { + partSizeKb = determinePartSize(fileSize) + } } if (!(file instanceof Readable)) { throw new MtArgumentError('Could not convert input `file` to stream!') } - const partSizeKb = params.partSize ?? determinePartSize(fileSize) - if (partSizeKb > 512) { throw new MtArgumentError(`Invalid part size: ${partSizeKb}KB`) } const partSize = partSizeKb * 1024 - const isBig = fileSize > 10485760 // 10 MB - const hash = this._crypto.createMd5() + let partCount = + fileSize === -1 ? -1 : ~~((fileSize + partSize - 1) / partSize) + const maxPartCount = this.network.params.isPremium ? + MAX_PART_COUNT_PREMIUM : + MAX_PART_COUNT + + if (partCount > maxPartCount) { + throw new MtArgumentError( + `File is too large (max ${maxPartCount} parts, got ${partCount})`, + ) + } + + const isBig = fileSize === -1 || fileSize > BIG_FILE_MIN_SIZE + const isSmall = fileSize !== -1 && fileSize < SMALL_FILE_MAX_SIZE + const connectionKind = isSmall ? 'main' : 'upload' + const connectionPoolSize = Math.min( + this.network.getPoolSize(connectionKind), + partCount, + ) + const requestsPerConnection = + params.requestsPerConnection ?? REQUESTS_PER_CONNECTION - const partCount = ~~((fileSize + partSize - 1) / partSize) this.log.debug( - 'uploading %d bytes file in %d chunks, each %d bytes', + 'uploading %d bytes file in %d chunks, each %d bytes in %s connection pool of size %d', fileSize, partCount, partSize, + connectionKind, + connectionPoolSize, ) // why is the file id generated by the client? // isn't the server supposed to generate it and handle collisions? const fileId = randomLong() - let pos = 0 + const stream = file - for (let idx = 0; idx < partCount; idx++) { - const part = await readBytesFromStream(file, partSize) + let pos = 0 + let idx = 0 + const lock = new AsyncLock() + + const uploadNextPart = async (): Promise => { + const thisIdx = idx++ + + let part + + try { + await lock.acquire() + part = await readBytesFromStream(stream, partSize) + } finally { + lock.release() + } + + if (fileSize === -1 && stream.readableEnded) { + fileSize = pos + (part?.length ?? 0) + partCount = ~~((fileSize + partSize - 1) / partSize) + this.log.debug( + 'readable ended, file size = %d, part count = %d', + fileSize, + partCount, + ) + } if (!part) { throw new MtArgumentError( @@ -236,15 +295,15 @@ export async function uploadFile( } if (!Buffer.isBuffer(part)) { - throw new MtArgumentError(`Part ${idx} was not a Buffer!`) + throw new MtArgumentError(`Part ${thisIdx} was not a Buffer!`) } if (part.length > partSize) { throw new MtArgumentError( - `Part ${idx} had invalid size (expected ${partSize}, got ${part.length})`, + `Part ${thisIdx} had invalid size (expected ${partSize}, got ${part.length})`, ) } - if (idx === 0 && fileMime === undefined) { + if (thisIdx === 0 && fileMime === undefined) { const fileType = await fileTypeFromBuffer(part) fileMime = fileType?.mime @@ -260,37 +319,43 @@ export async function uploadFile( } } - if (!isBig) { - // why md5 only small files? - // big files have more chance of corruption, but whatever - // also isn't integrity guaranteed by mtproto? - await hash.update(part) - } - - pos += part.length - // why const request = isBig ? ({ _: 'upload.saveBigFilePart', fileId, - filePart: idx, + filePart: thisIdx, fileTotalParts: partCount, bytes: part, - } as tl.upload.RawSaveBigFilePartRequest) : + } satisfies tl.upload.RawSaveBigFilePartRequest) : ({ _: 'upload.saveFilePart', fileId, - filePart: idx, + filePart: thisIdx, bytes: part, - } as tl.upload.RawSaveFilePartRequest) + } satisfies tl.upload.RawSaveFilePartRequest) - const result = await this.call(request) + const result = await this.call(request, { kind: connectionKind }) if (!result) throw new Error(`Failed to upload part ${idx}`) + pos += part.length + params.progressCallback?.(pos, fileSize) + + if (idx === partCount) return + + return uploadNextPart() } + await Promise.all( + Array.from( + { + length: connectionPoolSize * requestsPerConnection, + }, + uploadNextPart, + ), + ) + let inputFile: tl.TypeInputFile if (isBig) { @@ -306,7 +371,7 @@ export async function uploadFile( id: fileId, parts: partCount, name: fileName, - md5Checksum: (await hash.digest()).toString('hex'), + md5Checksum: '', // tdlib doesn't do this, why should we? } } diff --git a/packages/client/src/methods/messages/edit-inline-message.ts b/packages/client/src/methods/messages/edit-inline-message.ts index b2691da4..94363b14 100644 --- a/packages/client/src/methods/messages/edit-inline-message.ts +++ b/packages/client/src/methods/messages/edit-inline-message.ts @@ -7,6 +7,7 @@ import { InputMediaLike, ReplyMarkup, } from '../../types' +import { normalizeInlineId } from '../../utils/inline-utils' /** * Edit sent inline message text, media and reply markup. @@ -75,7 +76,7 @@ export async function editInlineMessage( let entities: tl.TypeMessageEntity[] | undefined let media: tl.TypeInputMedia | undefined = undefined - const [id, connection] = await this._normalizeInline(messageId) + const id = await normalizeInlineId(messageId) if (params.media) { media = await this._normalizeInputMedia(params.media, params, true) @@ -111,7 +112,7 @@ export async function editInlineMessage( entities, media, }, - { connection }, + { dcId: id.dcId }, ) return diff --git a/packages/client/src/methods/messages/normalize-inline.ts b/packages/client/src/methods/messages/normalize-inline.ts deleted file mode 100644 index 1fad930f..00000000 --- a/packages/client/src/methods/messages/normalize-inline.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { SessionConnection } from '@mtcute/core' -import { tl } from '@mtcute/tl' - -import { TelegramClient } from '../../client' -import { parseInlineMessageId } from '../../utils/inline-utils' - -// @extension -interface InlineExtension { - _connectionsForInline: Record -} - -// @initialize -function _initializeInline(this: TelegramClient) { - this._connectionsForInline = {} -} - -/** @internal */ -export async function _normalizeInline( - this: TelegramClient, - id: string | tl.TypeInputBotInlineMessageID, -): Promise<[tl.TypeInputBotInlineMessageID, SessionConnection]> { - if (typeof id === 'string') { - id = parseInlineMessageId(id) - } - - let connection = this.primaryConnection - - if (id.dcId !== connection.params.dc.id) { - if (!(id.dcId in this._connectionsForInline)) { - this._connectionsForInline[id.dcId] = - await this.createAdditionalConnection(id.dcId) - } - connection = this._connectionsForInline[id.dcId] - } - - return [id, connection] -} diff --git a/packages/client/src/methods/updates.ts b/packages/client/src/methods/updates.ts index 9b522229..f53ac505 100644 --- a/packages/client/src/methods/updates.ts +++ b/packages/client/src/methods/updates.ts @@ -558,11 +558,15 @@ async function _fetchPeersForShort( if ( msg.replyTo._ === 'messageReplyHeader' && !(await fetchPeer(msg.replyTo.replyToPeerId)) - ) { return null } + ) { + return null + } if ( msg.replyTo._ === 'messageReplyStoryHeader' && !(await fetchPeer(msg.replyTo.userId)) - ) { return null } + ) { + return null + } } if (msg._ !== 'messageService') { @@ -791,7 +795,7 @@ async function _fetchChannelDifference( if (!_pts) _pts = fallbackPts if (!_pts) { - this._updsLog.warn( + this._updsLog.debug( 'fetchChannelDifference failed for channel %d: base pts not available', channelId, ) @@ -956,19 +960,13 @@ async function _fetchDifference( this: TelegramClient, requestedDiff: Record>, ): Promise { - let isFirst = true - for (;;) { - const diff = await this.call( - { - _: 'updates.getDifference', - pts: this._pts!, - date: this._date!, - qts: this._qts!, - }, - // { flush: !isFirst } - ) - isFirst = false + const diff = await this.call({ + _: 'updates.getDifference', + pts: this._pts!, + date: this._date!, + qts: this._qts!, + }) switch (diff._) { case 'updates.differenceEmpty': @@ -1210,16 +1208,21 @@ async function _onUpdate( case 'dummyUpdate': // we just needed to apply new pts values return - case 'updateDcOptions': - if (!this._config) { - this._config = await this.call({ _: 'help.getConfig' }) + case 'updateDcOptions': { + const config = this.network.config.getNow() + + if (config) { + this.network.config.setConfig({ + ...config, + dcOptions: upd.dcOptions, + }) } else { - (this._config as tl.Mutable).dcOptions = - upd.dcOptions + await this.network.config.update(true) } break + } case 'updateConfig': - this._config = await this.call({ _: 'help.getConfig' }) + await this.network.config.update(true) break case 'updateUserName': if (upd.userId === this._userId) { @@ -1753,10 +1756,12 @@ export async function _updatesLoop(this: TelegramClient): Promise { log.debug( 'waiting for %d pending diffs before processing unordered: %j', pendingDiffs.length, - Object.keys(requestedDiff), // fixme + Object.keys(requestedDiff), ) - // this.primaryConnection._flushSendQueue() // fixme + // is this necessary? + // this.primaryConnection._flushSendQueue() + await Promise.all(pendingDiffs) // diff results may as well contain new diffs to be requested @@ -1764,7 +1769,7 @@ export async function _updatesLoop(this: TelegramClient): Promise { log.debug( 'pending diffs awaited, new diffs requested: %d (%j)', pendingDiffs.length, - Object.keys(requestedDiff), // fixme + Object.keys(requestedDiff), ) } @@ -1784,11 +1789,12 @@ export async function _updatesLoop(this: TelegramClient): Promise { log.debug( 'waiting for %d pending diffs after processing unordered: %j', pendingDiffs.length, - Object.keys(requestedDiff), // fixme + Object.keys(requestedDiff), ) - // fixme + // is this necessary? // this.primaryConnection._flushSendQueue() + await Promise.all(pendingDiffs) // diff results may as well contain new diffs to be requested @@ -1796,7 +1802,7 @@ export async function _updatesLoop(this: TelegramClient): Promise { log.debug( 'pending diffs awaited, new diffs requested: %d (%j)', pendingDiffs.length, - Object.keys(requestedDiff), // fixme + Object.keys(requestedDiff), ) } @@ -1815,5 +1821,4 @@ export async function _updatesLoop(this: TelegramClient): Promise { export function _keepAliveAction(this: TelegramClient): void { this._updsLog.debug('no updates for >15 minutes, catching up') this._handleUpdate({ _: 'updatesTooLong' }) - // this.catchUp().catch((err) => this._emitError(err)) } diff --git a/packages/client/src/types/conversation.ts b/packages/client/src/types/conversation.ts index f774a6a0..a3f5d9bd 100644 --- a/packages/client/src/types/conversation.ts +++ b/packages/client/src/types/conversation.ts @@ -133,8 +133,7 @@ export class Conversation { const pending = this.client['_pendingConversations'] - const idx = - pending[this._chatId].indexOf(this) + const idx = pending[this._chatId].indexOf(this) if (idx > -1) { // just in case @@ -143,8 +142,7 @@ export class Conversation { if (!pending[this._chatId].length) { delete pending[this._chatId] } - this.client['_hasConversations'] = - Object.keys(pending).length > 0 + this.client['_hasConversations'] = Object.keys(pending).length > 0 // reset pending status this._queuedNewMessage.clear() @@ -279,6 +277,7 @@ export class Conversation { if (timeout !== null) { timer = setTimeout(() => { + console.log('timed out') promise.reject(new tl.errors.TimeoutError()) this._queuedNewMessage.removeBy((it) => it.promise === promise) }, timeout) @@ -537,7 +536,9 @@ export class Conversation { it.promise.resolve(msg) delete this._pendingEditMessage[msg.id] } - })().catch((e) => this.client['_emitError'](e)) + })().catch((e) => { + this.client['_emitError'](e) + }) } private _onHistoryRead(upd: HistoryReadUpdate) { diff --git a/packages/client/src/types/files/file-location.ts b/packages/client/src/types/files/file-location.ts index c52f21b0..261d4971 100644 --- a/packages/client/src/types/files/file-location.ts +++ b/packages/client/src/types/files/file-location.ts @@ -4,6 +4,7 @@ import { tl } from '@mtcute/tl' import { TelegramClient } from '../../client' import { makeInspectable } from '../utils' +import { FileDownloadParameters } from './utils' /** * Information about file location. @@ -50,48 +51,61 @@ export class FileLocation { * in chunks of a given size. Order of the chunks is guaranteed to be * consecutive. * - * Shorthand for `client.downloadAsIterable({ location: this })` - * + * @param params Download parameters * @link TelegramClient.downloadAsIterable */ - downloadIterable(): AsyncIterableIterator { - return this.client.downloadAsIterable({ location: this }) + downloadIterable( + params?: Partial, + ): AsyncIterableIterator { + return this.client.downloadAsIterable({ + ...params, + location: this, + }) } /** * Download a file and return it as a Node readable stream, * streaming file contents. * - * Shorthand for `client.downloadAsStream({ location: this })` - * * @link TelegramClient.downloadAsStream */ - downloadStream(): Readable { - return this.client.downloadAsStream({ location: this }) + downloadStream(params?: Partial): Readable { + return this.client.downloadAsStream({ + ...params, + location: this, + }) } /** * Download a file and return its contents as a Buffer. * - * Shorthand for `client.downloadAsBuffer({ location: this })` - * + * @param params File download parameters * @link TelegramClient.downloadAsBuffer */ - downloadBuffer(): Promise { - return this.client.downloadAsBuffer({ location: this }) + downloadBuffer(params?: Partial): Promise { + return this.client.downloadAsBuffer({ + ...params, + location: this, + }) } /** * Download a remote file to a local file (only for NodeJS). * Promise will resolve once the download is complete. * - * Shorthand for `client.downloadToFile(filename, { location: this })` - * * @param filename Local file name + * @param params File download parameters * @link TelegramClient.downloadToFile */ - downloadToFile(filename: string): Promise { - return this.client.downloadToFile(filename, { location: this }) + downloadToFile( + filename: string, + params?: Partial, + ): Promise { + return this.client.downloadToFile(filename, { + ...params, + location: this, + fileSize: this.fileSize, + }) } } diff --git a/packages/client/src/types/files/utils.ts b/packages/client/src/types/files/utils.ts index 4132f15d..bd66a1be 100644 --- a/packages/client/src/types/files/utils.ts +++ b/packages/client/src/types/files/utils.ts @@ -97,7 +97,7 @@ export interface FileDownloadParameters { offset?: number /** - * Number of chunks (!) of that given size that will be downloaded. + * Number of bytes to be downloaded. * By default, downloads the entire file */ limit?: number diff --git a/packages/client/src/types/misc/takeout-session.ts b/packages/client/src/types/misc/takeout-session.ts index da73a358..af8f8af6 100644 --- a/packages/client/src/types/misc/takeout-session.ts +++ b/packages/client/src/types/misc/takeout-session.ts @@ -1,4 +1,4 @@ -import { MustEqual } from '@mtcute/core' +import { MustEqual, RpcCallOptions } from '@mtcute/core' import { tl } from '@mtcute/tl' import { TelegramClient } from '../../client' @@ -31,9 +31,7 @@ export class TakeoutSession { */ async call( message: MustEqual, - params?: { - throwFlood: boolean - }, + params?: RpcCallOptions, ): Promise { return this.client.call( { diff --git a/packages/client/src/utils/file-utils.ts b/packages/client/src/utils/file-utils.ts index 94d8f407..f5a8d809 100644 --- a/packages/client/src/utils/file-utils.ts +++ b/packages/client/src/utils/file-utils.ts @@ -5,10 +5,9 @@ import { MtArgumentError } from '../types' * for upload/download operations. */ export function determinePartSize(fileSize: number): number { - if (fileSize <= 104857600) return 128 // 100 MB + if (fileSize <= 262078465) return 128 // 200 MB if (fileSize <= 786432000) return 256 // 750 MB if (fileSize <= 2097152000) return 512 // 2000 MB - if (fileSize <= 4194304000) return 1024 // 4000 MB throw new MtArgumentError('File is too large') } diff --git a/packages/client/src/utils/inline-utils.ts b/packages/client/src/utils/inline-utils.ts index cd3d32c2..297d2f9b 100644 --- a/packages/client/src/utils/inline-utils.ts +++ b/packages/client/src/utils/inline-utils.ts @@ -66,3 +66,11 @@ export function encodeInlineMessageId( return encodeUrlSafeBase64(writer.result()) } + +export function normalizeInlineId(id: string | tl.TypeInputBotInlineMessageID) { + if (typeof id === 'string') { + return parseInlineMessageId(id) + } + + return id +} diff --git a/packages/client/src/utils/stream-utils.ts b/packages/client/src/utils/stream-utils.ts index cdfa020c..db33033c 100644 --- a/packages/client/src/utils/stream-utils.ts +++ b/packages/client/src/utils/stream-utils.ts @@ -35,7 +35,9 @@ class NodeReadable extends Readable { return } if (this.push(res.value)) { - return doRead() + doRead() + + return } this._reading = false this._reader.releaseLock() @@ -49,7 +51,9 @@ class NodeReadable extends Readable { const promise = new Promise((resolve) => { this._doneReading = resolve }) - promise.then(() => this._handleDestroy(err, callback)) + promise.then(() => { + this._handleDestroy(err, callback) + }) } else { this._handleDestroy(err, callback) } @@ -71,26 +75,6 @@ export function convertWebStreamToNodeReadable( return new NodeReadable(webStream, opts) } -export async function readStreamUntilEnd(stream: Readable): Promise { - const chunks = [] - let length = 0 - - while (stream.readable) { - const c = await stream.read() - if (c === null) break - - length += c.length - - if (length > 2097152000) { - throw new Error('File is too big') - } - - chunks.push(c) - } - - return Buffer.concat(chunks) -} - export function bufferToStream(buf: Buffer): Readable { return new Readable({ read() { @@ -109,15 +93,17 @@ export async function readBytesFromStream( let res = stream.read(size) if (!res) { - return new Promise((resolve) => { + return new Promise((resolve, reject) => { stream.on('readable', function handler() { res = stream.read(size) if (res) { stream.off('readable', handler) + stream.off('error', reject) resolve(res) } }) + stream.on('error', reject) }) } diff --git a/packages/client/tests/stream-utils.spec.ts b/packages/client/tests/stream-utils.spec.ts deleted file mode 100644 index 51f10393..00000000 --- a/packages/client/tests/stream-utils.spec.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { expect } from 'chai' -import { describe, it } from 'mocha' -import { Readable } from 'stream' - -import { readStreamUntilEnd } from '../src/utils/stream-utils' - -describe('readStreamUntilEnd', () => { - it('should read stream until end', async () => { - const stream = new Readable({ - read() { - this.push(Buffer.from('aaeeff', 'hex')) - this.push(Buffer.from('ff33ee', 'hex')) - this.push(null) - }, - }) - - expect((await readStreamUntilEnd(stream)).toString('hex')).eq( - 'aaeeffff33ee', - ) - }) -}) diff --git a/packages/core/src/base-client.ts b/packages/core/src/base-client.ts index 90ae5e7f..37c5ab41 100644 --- a/packages/core/src/base-client.ts +++ b/packages/core/src/base-client.ts @@ -8,12 +8,16 @@ import defaultWriterMap from '@mtcute/tl/binary/writer' import { TlReaderMap, TlWriterMap } from '@mtcute/tl-runtime' import { - defaultReconnectionStrategy, - defaultTransportFactory, ReconnectionStrategy, SessionConnection, TransportFactory, } from './network' +import { ConfigManager } from './network/config-manager' +import { + NetworkManager, + NetworkManagerExtraParams, + RpcCallOptions, +} from './network/network-manager' import { PersistentConnectionParams } from './network/persistent-connection' import { ITelegramStorage, MemoryStorage } from './storage' import { MustEqual } from './types' @@ -29,11 +33,10 @@ import { getAllPeersFrom, ICryptoProvider, LogManager, - sleep, + readStringSession, toggleChannelIdMark, + writeStringSession, } from './utils' -import { addPublicKey } from './utils/crypto/keys' -import { readStringSession, writeStringSession } from './utils/string-session' export interface BaseTelegramClientOptions { /** @@ -74,15 +77,15 @@ export interface BaseTelegramClientOptions { * When session already contains primary DC, this parameter is ignored. * Defaults to Production DC 2. */ - primaryDc?: tl.RawDcOption + defaultDc?: tl.RawDcOption /** * Whether to connect to test servers. * - * If passed, {@link primaryDc} defaults to Test DC 2. + * If passed, {@link defaultDc} defaults to Test DC 2. * * **Must** be passed if using test servers, even if - * you passed custom {@link primaryDc} + * you passed custom {@link defaultDc} */ testMode?: boolean @@ -123,7 +126,7 @@ export interface BaseTelegramClientOptions { * * @default 5 */ - rpcRetryCount?: number + maxRetryCount?: number /** * If true, every single API call will be wrapped with `tl.invokeWithoutUpdates`, @@ -152,6 +155,11 @@ export interface BaseTelegramClientOptions { */ niceStacks?: boolean + /** + * Extra parameters for {@link NetworkManager} + */ + network?: NetworkManagerExtraParams + /** * **EXPERT USE ONLY!** * @@ -178,93 +186,52 @@ export interface BaseTelegramClientOptions { export class BaseTelegramClient extends EventEmitter { /** - * `initConnection` params taken from {@link BaseTelegramClient.Options.initConnectionOptions}. - */ - protected readonly _initConnectionParams: tl.RawInitConnectionRequest - - /** - * Crypto provider taken from {@link BaseTelegramClient.Options.crypto} + * Crypto provider taken from {@link BaseTelegramClientOptions.crypto} */ protected readonly _crypto: ICryptoProvider /** - * Transport factory taken from {@link BaseTelegramClient.Options.transport} - */ - protected readonly _transportFactory: TransportFactory - - /** - * Telegram storage taken from {@link BaseTelegramClient.Options.storage} + * Telegram storage taken from {@link BaseTelegramClientOptions.storage} */ readonly storage: ITelegramStorage /** - * API hash taken from {@link BaseTelegramClient.Options.apiHash} + * API hash taken from {@link BaseTelegramClientOptions.apiHash} */ protected readonly _apiHash: string /** - * "Use IPv6" taken from {@link BaseTelegramClient.Options.useIpv6} + * "Use IPv6" taken from {@link BaseTelegramClientOptions.useIpv6} */ protected readonly _useIpv6: boolean /** - * "Test mode" taken from {@link BaseTelegramClient.Options.testMode} + * "Test mode" taken from {@link BaseTelegramClientOptions.testMode} */ protected readonly _testMode: boolean /** - * Reconnection strategy taken from {@link BaseTelegramClient.Options.reconnectionStrategy} - */ - protected readonly _reconnectionStrategy: ReconnectionStrategy - - /** - * Flood sleep threshold taken from {@link BaseTelegramClient.Options.floodSleepThreshold} - */ - protected readonly _floodSleepThreshold: number - - /** - * RPC retry count taken from {@link BaseTelegramClient.Options.rpcRetryCount} - */ - protected readonly _rpcRetryCount: number - - /** - * "Disable updates" taken from {@link BaseTelegramClient.Options.disableUpdates} - */ - protected readonly _disableUpdates: boolean - - /** - * Primary DC taken from {@link BaseTelegramClient.Options.primaryDc}, + * Primary DC taken from {@link BaseTelegramClientOptions.defaultDc}, * loaded from session or changed by other means (like redirecting). */ - protected _primaryDc: tl.RawDcOption + protected _defaultDc: tl.RawDcOption private _niceStacks: boolean readonly _layer: number readonly _readerMap: TlReaderMap readonly _writerMap: TlWriterMap - private _keepAliveInterval?: NodeJS.Timeout protected _lastUpdateTime = 0 - private _floodWaitedRequests: Record = {} - protected _config?: tl.RawConfig - protected _cdnConfig?: tl.RawCdnConfig - - private _additionalConnections: SessionConnection[] = [] + protected _config = new ConfigManager(() => + this.call({ _: 'help.getConfig' }), + ) // not really connected, but rather "connect() was called" private _connected: ControllablePromise | boolean = false private _onError?: (err: unknown, connection?: SessionConnection) => void - /** - * The primary {@link SessionConnection} that is used for - * most of the communication with Telegram. - * - * Methods for downloading/uploading files may create additional connections as needed. - */ - primaryConnection!: SessionConnection - private _importFrom?: string private _importForce?: boolean @@ -278,7 +245,8 @@ export class BaseTelegramClient extends EventEmitter { // eslint-disable-next-line @typescript-eslint/no-unused-vars protected _handleUpdate(update: tl.TypeUpdates): void {} - readonly log = new LogManager() + readonly log = new LogManager('client') + readonly network: NetworkManager constructor(opts: BaseTelegramClientOptions) { super() @@ -290,14 +258,13 @@ export class BaseTelegramClient extends EventEmitter { throw new Error('apiId must be a number or a numeric string!') } - this._transportFactory = opts.transport ?? defaultTransportFactory this._crypto = (opts.crypto ?? defaultCryptoProviderFactory)() this.storage = opts.storage ?? new MemoryStorage() this._apiHash = opts.apiHash this._useIpv6 = Boolean(opts.useIpv6) this._testMode = Boolean(opts.testMode) - let dc = opts.primaryDc + let dc = opts.defaultDc if (!dc) { if (this._testMode) { @@ -309,42 +276,47 @@ export class BaseTelegramClient extends EventEmitter { } } - this._primaryDc = dc - this._reconnectionStrategy = - opts.reconnectionStrategy ?? defaultReconnectionStrategy - this._floodSleepThreshold = opts.floodSleepThreshold ?? 10000 - this._rpcRetryCount = opts.rpcRetryCount ?? 5 - this._disableUpdates = opts.disableUpdates ?? false + this._defaultDc = dc this._niceStacks = opts.niceStacks ?? true this._layer = opts.overrideLayer ?? tl.LAYER this._readerMap = opts.readerMap ?? defaultReaderMap this._writerMap = opts.writerMap ?? defaultWriterMap + this.network = new NetworkManager( + { + apiId, + crypto: this._crypto, + disableUpdates: opts.disableUpdates ?? false, + initConnectionOptions: opts.initConnectionOptions, + layer: this._layer, + log: this.log, + readerMap: this._readerMap, + writerMap: this._writerMap, + reconnectionStrategy: opts.reconnectionStrategy, + storage: this.storage, + testMode: this._testMode, + transport: opts.transport, + _emitError: this._emitError.bind(this), + floodSleepThreshold: opts.floodSleepThreshold ?? 10000, + maxRetryCount: opts.maxRetryCount ?? 5, + isPremium: false, + useIpv6: Boolean(opts.useIpv6), + keepAliveAction: this._keepAliveAction.bind(this), + ...(opts.network ?? {}), + }, + this._config, + ) + this.storage.setup?.(this.log, this._readerMap, this._writerMap) + } - let deviceModel = 'mtcute on ' - if (typeof process !== 'undefined' && typeof require !== 'undefined') { - // eslint-disable-next-line @typescript-eslint/no-var-requires - const os = require('os') - deviceModel += `${os.type()} ${os.arch()} ${os.release()}` - } else if (typeof navigator !== 'undefined') { - deviceModel += navigator.userAgent - } else deviceModel += 'unknown' - - this._initConnectionParams = { - _: 'initConnection', - deviceModel, - systemVersion: '1.0', - appVersion: '1.0.0', - systemLangCode: 'en', - langPack: '', // "langPacks are for official apps only" - langCode: 'en', - ...(opts.initConnectionOptions ?? {}), - apiId, - - query: null as any, - } + protected _keepAliveAction(): void { + // core does not have update handling, so we just use getState so the server knows + // we still do need updates + this.call({ _: 'updates.getState' }).catch((e) => { + this.log.error('failed to send keep-alive: %s', e) + }) } protected async _loadStorage(): Promise { @@ -356,72 +328,6 @@ export class BaseTelegramClient extends EventEmitter { await this.storage.save?.() } - protected _keepAliveAction(): void { - if (this._disableUpdates) return - - // telegram asks to fetch pending updates - // if there are no updates for 15 minutes. - // core does not have update handling, - // so we just use getState so the server knows - // we still do need updates - this.call({ _: 'updates.getState' }).catch((e) => { - if (!(e instanceof tl.errors.RpcError)) { - this.primaryConnection.reconnect() - } - }) - } - - private _cleanupPrimaryConnection(forever = false): void { - if (forever && this.primaryConnection) this.primaryConnection.destroy() - if (this._keepAliveInterval) clearInterval(this._keepAliveInterval) - } - - private _setupPrimaryConnection(): void { - this._cleanupPrimaryConnection(true) - - this.primaryConnection = new SessionConnection( - { - crypto: this._crypto, - initConnection: this._initConnectionParams, - transportFactory: this._transportFactory, - dc: this._primaryDc, - testMode: this._testMode, - reconnectionStrategy: this._reconnectionStrategy, - layer: this._layer, - disableUpdates: this._disableUpdates, - readerMap: this._readerMap, - writerMap: this._writerMap, - }, - this.log.create('connection'), - ) - - this.primaryConnection.on('usable', () => { - this._lastUpdateTime = Date.now() - - if (this._keepAliveInterval) clearInterval(this._keepAliveInterval) - this._keepAliveInterval = setInterval(async () => { - if (Date.now() - this._lastUpdateTime > 900_000) { - this._keepAliveAction() - this._lastUpdateTime = Date.now() - } - }, 60_000) - }) - this.primaryConnection.on('update', (update) => { - this._lastUpdateTime = Date.now() - this._handleUpdate(update) - }) - this.primaryConnection.on('wait', () => - this._cleanupPrimaryConnection(), - ) - this.primaryConnection.on('key-change', async (key) => { - this.storage.setAuthKeyFor(this._primaryDc.id, key) - await this._saveStorage() - }) - this.primaryConnection.on('error', (err) => - this._emitError(err, this.primaryConnection), - ) - } - /** * Initialize the connection to the primary DC. * @@ -430,61 +336,59 @@ export class BaseTelegramClient extends EventEmitter { */ async connect(): Promise { if (this._connected) { + // avoid double-connect await this._connected return } - this._connected = createControllablePromise() + // we cant do this in constructor because we need to support subclassing + this.network.setUpdateHandler(this._handleUpdate.bind(this)) + + const promise = (this._connected = createControllablePromise()) await this._loadStorage() const primaryDc = await this.storage.getDefaultDc() - if (primaryDc !== null) this._primaryDc = primaryDc + if (primaryDc !== null) this._defaultDc = primaryDc - this._setupPrimaryConnection() - - await this.primaryConnection.setupKeys( - await this.storage.getAuthKeyFor(this._primaryDc.id), + const defaultDcAuthKey = await this.storage.getAuthKeyFor( + this._defaultDc.id, ) - if ( - (this._importForce || !this.primaryConnection.getAuthKey()) && - this._importFrom - ) { + if ((this._importForce || !defaultDcAuthKey) && this._importFrom) { const data = readStringSession(this._readerMap, this._importFrom) - if (data.testMode !== !this._testMode) { + if (data.testMode !== this._testMode) { throw new Error( - 'This session string is not for the current backend', + 'This session string is not for the current backend. ' + + `Session is ${ + data.testMode ? 'test' : 'prod' + }, but the client is ${ + this._testMode ? 'test' : 'prod' + }`, ) } - this._primaryDc = this.primaryConnection.params.dc = data.primaryDc + this._defaultDc = data.primaryDc await this.storage.setDefaultDc(data.primaryDc) if (data.self) { await this.storage.setSelf(data.self) } - await this.primaryConnection.setupKeys(data.authKey) + // await this.primaryConnection.setupKeys(data.authKey) await this.storage.setAuthKeyFor(data.primaryDc.id, data.authKey) await this._saveStorage(true) } - this._connected.resolve() - this._connected = true - - this.primaryConnection.connect() - } - - /** - * Wait until this client is usable (i.e. connection is fully ready) - */ - async waitUntilUsable(): Promise { - return new Promise((resolve) => { - this.primaryConnection.once('usable', resolve) - }) + this.network + .connect(this._defaultDc) + .then(() => { + promise.resolve() + this._connected = true + }) + .catch((err) => this._emitError(err)) } /** @@ -499,107 +403,13 @@ export class BaseTelegramClient extends EventEmitter { async close(): Promise { await this._onClose() - this._cleanupPrimaryConnection(true) - // close additional connections - this._additionalConnections.forEach((conn) => conn.destroy()) + this._config.destroy() + this.network.destroy() await this._saveStorage() await this.storage.destroy?.() } - /** - * Utility function to find the DC by its ID. - * - * @param id Datacenter ID - * @param preferMedia Whether to prefer media-only DCs - * @param cdn Whether the needed DC is a CDN DC - */ - async getDcById( - id: number, - preferMedia = false, - cdn = false, - ): Promise { - if (!this._config) { - this._config = await this.call({ _: 'help.getConfig' }) - } - - if (cdn && !this._cdnConfig) { - this._cdnConfig = await this.call({ _: 'help.getCdnConfig' }) - - for (const key of this._cdnConfig.publicKeys) { - await addPublicKey(this._crypto, key.publicKey) - } - } - - if (this._useIpv6) { - // first try to find ipv6 dc - - let found - - if (preferMedia) { - found = this._config.dcOptions.find( - (it) => - it.id === id && - it.mediaOnly && - it.cdn === cdn && - it.ipv6 && - !it.tcpoOnly, - ) - } - - if (!found) { - found = this._config.dcOptions.find( - (it) => - it.id === id && - it.cdn === cdn && - it.ipv6 && - !it.tcpoOnly, - ) - } - - if (found) return found - } - - let found - - if (preferMedia) { - found = this._config.dcOptions.find( - (it) => - it.id === id && - it.mediaOnly && - it.cdn === cdn && - !it.tcpoOnly && - !it.ipv6, - ) - } - if (!found) { - found = this._config.dcOptions.find( - (it) => - it.id === id && it.cdn === cdn && !it.tcpoOnly && !it.ipv6, - ) - } - if (found) return found - - throw new Error(`Could not find${cdn ? ' CDN' : ''} DC ${id}`) - } - - /** - * Change primary DC and write that fact to the storage. - * Will immediately reconnect to another DC. - * - * @param newDc New DC or its ID - */ - async changeDc(newDc: tl.RawDcOption | number): Promise { - if (typeof newDc === 'number') { - newDc = await this.getDcById(newDc) - } - - this._primaryDc = newDc - await this.storage.setDefaultDc(newDc) - await this._saveStorage() - await this.primaryConnection.changeDc(newDc) - } - /** * Make an RPC call to the primary DC. * This method handles DC migration, flood waits and retries automatically. @@ -615,227 +425,18 @@ export class BaseTelegramClient extends EventEmitter { */ async call( message: MustEqual, - params?: { - throwFlood?: boolean - connection?: SessionConnection - timeout?: number - }, + params?: RpcCallOptions, ): Promise { if (this._connected !== true) { await this.connect() } - // do not send requests that are in flood wait - if (message._ in this._floodWaitedRequests) { - const delta = this._floodWaitedRequests[message._] - Date.now() - - if (delta <= 3000) { - // flood waits below 3 seconds are "ignored" - delete this._floodWaitedRequests[message._] - } else if (delta <= this._floodSleepThreshold) { - await sleep(delta) - delete this._floodWaitedRequests[message._] - } else { - throw new tl.errors.FloodWaitXError(delta / 1000) - } - } - - const connection = params?.connection ?? this.primaryConnection - - let lastError: Error | null = null const stack = this._niceStacks ? new Error().stack : undefined - for (let i = 0; i < this._rpcRetryCount; i++) { - try { - const res = await connection.sendRpc( - message, - stack, - params?.timeout, - ) - await this._cachePeersFrom(res) + const res = await this.network.call(message, params, stack) + await this._cachePeersFrom(res) - return res - } catch (e: any) { - lastError = e - - if (e instanceof tl.errors.InternalError) { - this.log.warn('Telegram is having internal issues: %s', e) - - if (e.message === 'WORKER_BUSY_TOO_LONG_RETRY') { - // according to tdlib, "it is dangerous to resend query without timeout, so use 1" - await sleep(1000) - } - continue - } - - if ( - e.constructor === tl.errors.FloodWaitXError || - e.constructor === tl.errors.SlowmodeWaitXError || - e.constructor === tl.errors.FloodTestPhoneWaitXError - ) { - if (e.constructor !== tl.errors.SlowmodeWaitXError) { - // SLOW_MODE_WAIT is chat-specific, not request-specific - this._floodWaitedRequests[message._] = - Date.now() + e.seconds * 1000 - } - - // In test servers, FLOOD_WAIT_0 has been observed, and sleeping for - // such a short amount will cause retries very fast leading to issues - if (e.seconds === 0) { - (e as any).seconds = 1 - } - - if ( - params?.throwFlood !== true && - e.seconds <= this._floodSleepThreshold - ) { - this.log.info('Flood wait for %d seconds', e.seconds) - await sleep(e.seconds * 1000) - continue - } - } - - if (connection.params.dc.id === this._primaryDc.id) { - if ( - e.constructor === tl.errors.PhoneMigrateXError || - e.constructor === tl.errors.UserMigrateXError || - e.constructor === tl.errors.NetworkMigrateXError - ) { - this.log.info('Migrate error, new dc = %d', e.new_dc) - await this.changeDc(e.new_dc) - continue - } - } else if ( - e.constructor === tl.errors.AuthKeyUnregisteredError - ) { - // we can try re-exporting auth from the primary connection - this.log.warn('exported auth key error, re-exporting..') - - const auth = await this.call({ - _: 'auth.exportAuthorization', - dcId: connection.params.dc.id, - }) - - await connection.sendRpc({ - _: 'auth.importAuthorization', - id: auth.id, - bytes: auth.bytes, - }) - - continue - } - - throw e - } - } - - throw lastError - } - - /** - * Creates an additional connection to a given DC. - * This will use auth key for that DC that was already stored - * in the session, or generate a new auth key by exporting - * authorization from primary DC and importing it to the new DC. - * New connection will use the same crypto provider, `initConnection`, - * transport and reconnection strategy as the primary connection - * - * This method is quite low-level and you shouldn't usually care about this - * when using high-level API provided by `@mtcute/client`. - * - * @param dcId DC id, to which the connection will be created - * @param cdn Whether that DC is a CDN DC - * @param inactivityTimeout - * Inactivity timeout for the connection (in ms), after which the transport will be closed. - * Note that connection can still be used normally, it's just the transport which is closed. - * Defaults to 5 min - */ - async createAdditionalConnection( - dcId: number, - params?: { - // todo proper docs - // default = false - media?: boolean - // default = fa;se - cdn?: boolean - // default = 300_000 - inactivityTimeout?: number - // default = false - disableUpdates?: boolean - }, - ): Promise { - const dc = await this.getDcById(dcId, params?.media, params?.cdn) - const connection = new SessionConnection( - { - dc, - testMode: this._testMode, - crypto: this._crypto, - initConnection: this._initConnectionParams, - transportFactory: this._transportFactory, - reconnectionStrategy: this._reconnectionStrategy, - inactivityTimeout: params?.inactivityTimeout ?? 300_000, - layer: this._layer, - disableUpdates: params?.disableUpdates, - readerMap: this._readerMap, - writerMap: this._writerMap, - }, - this.log.create('connection'), - ) - - connection.on('error', (err) => this._emitError(err, connection)) - await connection.setupKeys(await this.storage.getAuthKeyFor(dc.id)) - connection.connect() - - if (!connection.getAuthKey()) { - this.log.info('exporting auth to DC %d', dcId) - const auth = await this.call({ - _: 'auth.exportAuthorization', - dcId, - }) - await connection.sendRpc({ - _: 'auth.importAuthorization', - id: auth.id, - bytes: auth.bytes, - }) - - // connection.authKey was already generated at this point - this.storage.setAuthKeyFor(dc.id, connection.getAuthKey()!) - await this._saveStorage() - } else { - // in case the auth key is invalid - const dcId = dc.id - connection.on('key-change', async (key) => { - // we don't need to export, it will be done by `.call()` - // in case this error is returned - // - // even worse, exporting here will lead to a race condition, - // and may result in redundant re-exports. - - this.storage.setAuthKeyFor(dcId, key) - await this._saveStorage() - }) - } - - this._additionalConnections.push(connection) - - return connection - } - - /** - * Destroy a connection that was previously created using - * {@link BaseTelegramClient.createAdditionalConnection}. - * Passing any other connection will not have any effect. - * - * @param connection Connection created with {@link BaseTelegramClient.createAdditionalConnection} - */ - async destroyAdditionalConnection( - connection: SessionConnection, - ): Promise { - const idx = this._additionalConnections.indexOf(connection) - if (idx === -1) return - - await connection.destroy() - this._additionalConnections.splice(idx, 1) + return res } /** @@ -849,11 +450,7 @@ export class BaseTelegramClient extends EventEmitter { * @param factory New transport factory */ changeTransport(factory: TransportFactory): void { - this.primaryConnection.changeTransport(factory) - - this._additionalConnections.forEach((conn) => - conn.changeTransport(factory), - ) + this.network.changeTransport(factory) } /** @@ -865,7 +462,9 @@ export class BaseTelegramClient extends EventEmitter { * the connection in which the error has occurred, in case * this was connection-related error. */ - onError(handler: typeof this._onError): void { + onError( + handler: (err: unknown, connection?: SessionConnection) => void, + ): void { this._onError = handler } @@ -950,9 +549,8 @@ export class BaseTelegramClient extends EventEmitter { } } - await this.storage.updatePeers(parsedPeers) - if (count > 0) { + await this.storage.updatePeers(parsedPeers) this.log.debug('cached %d peers', count) } @@ -975,16 +573,18 @@ export class BaseTelegramClient extends EventEmitter { * > with [@BotFather](//t.me/botfather) */ async exportSession(): Promise { - if (!this.primaryConnection.getAuthKey()) { - throw new Error('Auth key is not generated yet') - } + const primaryDc = await this.storage.getDefaultDc() + if (!primaryDc) throw new Error('No default DC set') + + const authKey = await this.storage.getAuthKeyFor(primaryDc.id) + if (!authKey) throw new Error('Auth key is not ready yet') return writeStringSession(this._writerMap, { version: 1, self: await this.storage.getSelf(), testMode: this._testMode, - primaryDc: this._primaryDc, - authKey: this.primaryConnection.getAuthKey()!, + primaryDc, + authKey, }) } @@ -996,7 +596,7 @@ export class BaseTelegramClient extends EventEmitter { * * Also note that the session will only be imported in case * the storage is missing authorization (i.e. does not contain - * auth key for the primary DC), otherwise it will be ignored. + * auth key for the primary DC), otherwise it will be ignored (unless `force). * * @param session Session string to import * @param force Whether to overwrite existing session diff --git a/packages/core/src/network/auth-key.ts b/packages/core/src/network/auth-key.ts new file mode 100644 index 00000000..d2e5f690 --- /dev/null +++ b/packages/core/src/network/auth-key.ts @@ -0,0 +1,166 @@ +import Long from 'long' + +import { tl } from '@mtcute/tl' +import { TlBinaryReader, TlReaderMap } from '@mtcute/tl-runtime' + +import { buffersEqual, ICryptoProvider, Logger, randomBytes } from '../utils' +import { createAesIgeForMessage } from '../utils/crypto/mtproto' + +export class AuthKey { + ready = false + + key!: Buffer + id!: Buffer + clientSalt!: Buffer + serverSalt!: Buffer + + constructor( + readonly _crypto: ICryptoProvider, + readonly log: Logger, + readonly _readerMap: TlReaderMap, + ) {} + + match(keyId: Buffer): boolean { + return this.ready && buffersEqual(keyId, this.id) + } + + async setup(authKey?: Buffer | null): Promise { + if (!authKey) return this.reset() + + this.ready = true + this.key = authKey + this.clientSalt = authKey.slice(88, 120) + this.serverSalt = authKey.slice(96, 128) + this.id = (await this._crypto.sha1(authKey)).slice(-8) + + this.log.verbose('auth key set up, id = %h', this.id) + } + + async encryptMessage( + message: Buffer, + serverSalt: Long, + sessionId: Long, + ): Promise { + if (!this.ready) throw new Error('Keys are not set up!') + + let padding = + (16 /* header size */ + message.length + 12) /* min padding */ % 16 + padding = 12 + (padding ? 16 - padding : 0) + + const buf = Buffer.alloc(16 + message.length + padding) + + buf.writeInt32LE(serverSalt.low) + buf.writeInt32LE(serverSalt.high, 4) + buf.writeInt32LE(sessionId.low, 8) + buf.writeInt32LE(sessionId.high, 12) + message.copy(buf, 16) + randomBytes(padding).copy(buf, 16 + message.length) + + const messageKey = ( + await this._crypto.sha256(Buffer.concat([this.clientSalt, buf])) + ).slice(8, 24) + const ige = await createAesIgeForMessage( + this._crypto, + this.key, + messageKey, + true, + ) + const encryptedData = await ige.encrypt(buf) + + return Buffer.concat([this.id, messageKey, encryptedData]) + } + + async decryptMessage( + data: Buffer, + sessionId: Long, + callback: (msgId: tl.Long, seqNo: number, data: TlBinaryReader) => void, + ): Promise { + const messageKey = data.slice(8, 24) + const encryptedData = data.slice(24) + + const ige = await createAesIgeForMessage( + this._crypto, + this.key, + messageKey, + false, + ) + const innerData = await ige.decrypt(encryptedData) + + const expectedMessageKey = ( + await this._crypto.sha256( + Buffer.concat([this.serverSalt, innerData]), + ) + ).slice(8, 24) + + if (!buffersEqual(messageKey, expectedMessageKey)) { + this.log.warn( + '[%h] received message with invalid messageKey = %h (expected %h)', + messageKey, + expectedMessageKey, + ) + + return + } + + const innerReader = new TlBinaryReader(this._readerMap, innerData) + innerReader.seek(8) // skip salt + const sessionId_ = innerReader.long() + const messageId = innerReader.long(true) + + if (sessionId_.neq(sessionId)) { + this.log.warn( + 'ignoring message with invalid sessionId = %h', + sessionId_, + ) + + return + } + + const seqNo = innerReader.uint() + const length = innerReader.uint() + + if (length > innerData.length - 32 /* header size */) { + this.log.warn( + 'ignoring message with invalid length: %d > %d', + length, + innerData.length - 32, + ) + + return + } + + if (length % 4 !== 0) { + this.log.warn( + 'ignoring message with invalid length: %d is not a multiple of 4', + length, + ) + + return + } + + const paddingSize = innerData.length - length - 32 // header size + + if (paddingSize < 12 || paddingSize > 1024) { + this.log.warn( + 'ignoring message with invalid padding size: %d', + paddingSize, + ) + + return + } + + callback(messageId, seqNo, innerReader) + } + + copyFrom(authKey: AuthKey): void { + this.ready = authKey.ready + this.key = authKey.key + this.id = authKey.id + this.serverSalt = authKey.serverSalt + this.clientSalt = authKey.clientSalt + } + + reset(): void { + this.ready = false + } +} diff --git a/packages/core/src/network/authorization.ts b/packages/core/src/network/authorization.ts index 53d4f3af..e3f98613 100644 --- a/packages/core/src/network/authorization.ts +++ b/packages/core/src/network/authorization.ts @@ -9,7 +9,12 @@ import { TlSerializationCounter, } from '@mtcute/tl-runtime' -import { bigIntToBuffer, bufferToBigInt, ICryptoProvider } from '../utils' +import { + bigIntToBuffer, + bufferToBigInt, + ICryptoProvider, + Logger, +} from '../utils' import { buffersEqual, randomBytes, @@ -17,12 +22,123 @@ import { xorBufferInPlace, } from '../utils/buffer-utils' import { findKeyByFingerprints } from '../utils/crypto/keys' +import { millerRabin } from '../utils/crypto/miller-rabin' import { generateKeyAndIvFromNonce } from '../utils/crypto/mtproto' import { SessionConnection } from './session-connection' // Heavily based on code from https://github.com/LonamiWebs/Telethon/blob/master/telethon/network/authenticator.py +// see https://core.telegram.org/mtproto/security_guidelines const DH_SAFETY_RANGE = bigInt[2].pow(2048 - 64) +const KNOWN_DH_PRIME = bigInt( + 'C71CAEB9C6B1C9048E6C522F70F13F73980D40238E3E21C14934D037563D930F48198A0AA7C14058229493D22530F4DBFA336F6E0AC925139543AED44CCE7C3720FD51F69458705AC68CD4FE6B6B13ABDC9746512969328454F18FAF8C595F642477FE96BB2A941D5BCD1D4AC8CC49880708FA9B378E3C4F3A9060BEE67CF9A4A4A695811051907E162753B56B0F6B410DBA74D8A84B2A14B3144E0EF1284754FD17ED950D5965B4B9DD46582DB1178D169C6BC465B0D6FF9CA3928FEF5B9AE4E418FC15E83EBEA0F87FA9FF5EED70050DED2849F47BF959D956850CE929851F0D8115F635B105EE2E4E15D04B2454BF6F4FADF034B10403119CD8E3B92FCC5B', + 16, +) +const TWO_POW_2047 = bigInt[2].pow(2047) +const TWO_POW_2048 = bigInt[2].pow(2048) + +interface CheckedPrime { + prime: bigInt.BigInteger + generators: number[] +} +const checkedPrimesCache: CheckedPrime[] = [] + +function checkDhPrime(log: Logger, dhPrime: bigInt.BigInteger, g: number) { + if (KNOWN_DH_PRIME.eq(dhPrime)) { + log.debug('server is using known dh prime, skipping validation') + + return + } + + let checkedPrime = checkedPrimesCache.find((x) => x.prime.eq(dhPrime)) + + if (!checkedPrime) { + if ( + dhPrime.lesserOrEquals(TWO_POW_2047) || + dhPrime.greaterOrEquals(TWO_POW_2048) + ) { + throw new Error('Step 3: dh_prime is not in the 2048-bit range') + } + + if (!millerRabin(dhPrime)) { + throw new Error('Step 3: dh_prime is not prime') + } + if (!millerRabin(dhPrime.minus(1).divide(2))) { + throw new Error( + 'Step 3: dh_prime is not a safe prime - (dh_prime-1)/2 is not prime', + ) + } + + log.debug('dh_prime is probably prime') + + checkedPrime = { + prime: dhPrime, + generators: [], + } + checkedPrimesCache.push(checkedPrime) + } else { + log.debug('dh_prime is probably prime (cached)') + } + + const generatorChecked = checkedPrime.generators.includes(g) + + if (generatorChecked) { + log.debug('g = %d is already checked for dh_prime', g) + + return + } + + switch (g) { + case 2: + if (dhPrime.mod(8).notEquals(7)) { + throw new Error('Step 3: ivalid g - dh_prime mod 8 != 7') + } + break + case 3: + if (dhPrime.mod(3).notEquals(2)) { + throw new Error('Step 3: ivalid g - dh_prime mod 3 != 2') + } + break + case 4: + break + case 5: { + const mod = dhPrime.mod(5) + + if (mod.notEquals(1) && mod.notEquals(4)) { + throw new Error( + 'Step 3: ivalid g - dh_prime mod 5 != 1 && dh_prime mod 5 != 4', + ) + } + break + } + case 6: { + const mod = dhPrime.mod(24) + + if (mod.notEquals(19) && mod.notEquals(23)) { + throw new Error( + 'Step 3: ivalid g - dh_prime mod 24 != 19 && dh_prime mod 24 != 23', + ) + } + break + } + case 7: { + const mod = dhPrime.mod(7) + + if (mod.notEquals(3) && mod.notEquals(5) && mod.notEquals(6)) { + throw new Error( + 'Step 3: ivalid g - dh_prime mod 7 != 3 && dh_prime mod 7 != 5 && dh_prime mod 7 != 6', + ) + } + break + } + default: + throw new Error(`Step 3: ivalid g - unknown g = ${g}`) + } + + checkedPrime.generators.push(g) + + log.debug('g = %d is safe to use with dh_prime', g) +} async function rsaPad( data: Buffer, @@ -102,6 +218,7 @@ async function rsaEncrypt( export async function doAuthorization( connection: SessionConnection, crypto: ICryptoProvider, + expiresIn?: number, ): Promise<[Buffer, Long, number]> { // eslint-disable-next-line dot-notation const session = connection['_session'] @@ -128,23 +245,26 @@ export async function doAuthorization( async function readNext(): Promise { return TlBinaryReader.deserializeObject( readerMap, - await connection.waitForNextMessage(), + await connection.waitForUnencryptedMessage(), 20, // skip mtproto header ) } const log = connection.log.create('auth') + if (expiresIn) log.prefix = '[PFS] ' const nonce = randomBytes(16) // Step 1: PQ request - log.debug('starting PQ handshake, nonce = %h', nonce) + log.debug('starting PQ handshake (temp = %b), nonce = %h', expiresIn, nonce) await sendPlainMessage({ _: 'mt_req_pq_multi', nonce }) const resPq = await readNext() if (resPq._ !== 'mt_resPQ') throw new Error('Step 1: answer was ' + resPq._) - if (!buffersEqual(resPq.nonce, nonce)) { throw new Error('Step 1: invalid nonce from server') } + if (!buffersEqual(resPq.nonce, nonce)) { + throw new Error('Step 1: invalid nonce from server') + } const serverKeys = resPq.serverPublicKeyFingerprints.map((it) => it.toUnsigned().toString(16), @@ -175,8 +295,8 @@ export async function doAuthorization( if (connection.params.testMode) dcId += 10000 if (connection.params.dc.mediaOnly) dcId = -dcId - const _pqInnerData: mtp.RawMt_p_q_inner_data_dc = { - _: 'mt_p_q_inner_data_dc', + const _pqInnerData: mtp.TypeP_Q_inner_data = { + _: expiresIn ? 'mt_p_q_inner_data_temp_dc' : 'mt_p_q_inner_data_dc', pq: resPq.pq, p, q, @@ -184,6 +304,7 @@ export async function doAuthorization( newNonce, serverNonce: resPq.serverNonce, dc: dcId, + expiresIn: expiresIn!, // whatever } const pqInnerData = TlBinaryWriter.serializeObject(writerMap, _pqInnerData) @@ -204,12 +325,20 @@ export async function doAuthorization( }) const serverDhParams = await readNext() - if (!mtp.isAnyServer_DH_Params(serverDhParams)) { throw new Error('Step 2.1: answer was ' + serverDhParams._) } + if (!mtp.isAnyServer_DH_Params(serverDhParams)) { + throw new Error('Step 2.1: answer was ' + serverDhParams._) + } - if (serverDhParams._ !== 'mt_server_DH_params_ok') { throw new Error('Step 2.1: answer was ' + serverDhParams._) } + if (serverDhParams._ !== 'mt_server_DH_params_ok') { + throw new Error('Step 2.1: answer was ' + serverDhParams._) + } - if (!buffersEqual(serverDhParams.nonce, nonce)) { throw Error('Step 2: invalid nonce from server') } - if (!buffersEqual(serverDhParams.serverNonce, resPq.serverNonce)) { throw Error('Step 2: invalid server nonce from server') } + if (!buffersEqual(serverDhParams.nonce, nonce)) { + throw Error('Step 2: invalid nonce from server') + } + if (!buffersEqual(serverDhParams.serverNonce, resPq.serverNonce)) { + throw Error('Step 2: invalid server nonce from server') + } // type was removed from schema in July 2021 // if (serverDhParams._ === 'mt_server_DH_params_fail') { @@ -222,7 +351,9 @@ export async function doAuthorization( log.debug('server DH ok') - if (serverDhParams.encryptedAnswer.length % 16 !== 0) { throw new Error('Step 2: AES block size is invalid') } + if (serverDhParams.encryptedAnswer.length % 16 !== 0) { + throw new Error('Step 2: AES block size is invalid') + } // Step 3: complete DH exchange const [key, iv] = await generateKeyAndIvFromNonce( @@ -248,20 +379,28 @@ export async function doAuthorization( plainTextAnswer.slice(20, serverDhInnerReader.pos), ), ) - ) { throw new Error('Step 3: invalid inner data hash') } + ) { + throw new Error('Step 3: invalid inner data hash') + } - if (serverDhInner._ !== 'mt_server_DH_inner_data') { throw Error('Step 3: inner data was ' + serverDhInner._) } - if (!buffersEqual(serverDhInner.nonce, nonce)) { throw Error('Step 3: invalid nonce from server') } - if (!buffersEqual(serverDhInner.serverNonce, resPq.serverNonce)) { throw Error('Step 3: invalid server nonce from server') } + if (serverDhInner._ !== 'mt_server_DH_inner_data') { + throw Error('Step 3: inner data was ' + serverDhInner._) + } + if (!buffersEqual(serverDhInner.nonce, nonce)) { + throw Error('Step 3: invalid nonce from server') + } + if (!buffersEqual(serverDhInner.serverNonce, resPq.serverNonce)) { + throw Error('Step 3: invalid server nonce from server') + } const dhPrime = bufferToBigInt(serverDhInner.dhPrime) const timeOffset = Math.floor(Date.now() / 1000) - serverDhInner.serverTime - // dhPrime is not checked because who cares lol :D - const g = bigInt(serverDhInner.g) const gA = bufferToBigInt(serverDhInner.gA) + checkDhPrime(log, dhPrime, serverDhInner.g) + let retryId = Long.ZERO const serverSalt = xorBuffer( newNonce.slice(0, 8), @@ -276,15 +415,24 @@ export async function doAuthorization( const authKeyAuxHash = (await crypto.sha1(authKey)).slice(0, 8) // validate DH params - if (g.lesserOrEquals(1) || g.greaterOrEquals(dhPrime.minus(bigInt.one))) { throw new Error('g is not within (1, dh_prime - 1)') } + if ( + g.lesserOrEquals(1) || + g.greaterOrEquals(dhPrime.minus(bigInt.one)) + ) { + throw new Error('g is not within (1, dh_prime - 1)') + } if ( gA.lesserOrEquals(1) || gA.greaterOrEquals(dhPrime.minus(bigInt.one)) - ) { throw new Error('g_a is not within (1, dh_prime - 1)') } + ) { + throw new Error('g_a is not within (1, dh_prime - 1)') + } if ( gB.lesserOrEquals(1) || gB.greaterOrEquals(dhPrime.minus(bigInt.one)) - ) { throw new Error('g_b is not within (1, dh_prime - 1)') } + ) { + throw new Error('g_b is not within (1, dh_prime - 1)') + } if (gA.lt(DH_SAFETY_RANGE) || gA.gt(dhPrime.minus(DH_SAFETY_RANGE))) { throw new Error( @@ -334,10 +482,16 @@ export async function doAuthorization( const dhGen = await readNext() - if (!mtp.isAnySet_client_DH_params_answer(dhGen)) { throw new Error('Step 4: answer was ' + dhGen._) } + if (!mtp.isAnySet_client_DH_params_answer(dhGen)) { + throw new Error('Step 4: answer was ' + dhGen._) + } - if (!buffersEqual(dhGen.nonce, nonce)) { throw Error('Step 4: invalid nonce from server') } - if (!buffersEqual(dhGen.serverNonce, resPq.serverNonce)) { throw Error('Step 4: invalid server nonce from server') } + if (!buffersEqual(dhGen.nonce, nonce)) { + throw Error('Step 4: invalid nonce from server') + } + if (!buffersEqual(dhGen.serverNonce, resPq.serverNonce)) { + throw Error('Step 4: invalid server nonce from server') + } log.debug('DH result: %s', dhGen._) @@ -351,7 +505,9 @@ export async function doAuthorization( Buffer.concat([newNonce, Buffer.from([2]), authKeyAuxHash]), ) - if (!buffersEqual(expectedHash.slice(4, 20), dhGen.newNonceHash2)) { throw Error('Step 4: invalid retry nonce hash from server') } + if (!buffersEqual(expectedHash.slice(4, 20), dhGen.newNonceHash2)) { + throw Error('Step 4: invalid retry nonce hash from server') + } // eslint-disable-next-line @typescript-eslint/no-explicit-any retryId = Long.fromBytesLE(authKeyAuxHash as any) continue @@ -363,7 +519,9 @@ export async function doAuthorization( Buffer.concat([newNonce, Buffer.from([1]), authKeyAuxHash]), ) - if (!buffersEqual(expectedHash.slice(4, 20), dhGen.newNonceHash1)) { throw Error('Step 4: invalid nonce hash from server') } + if (!buffersEqual(expectedHash.slice(4, 20), dhGen.newNonceHash1)) { + throw Error('Step 4: invalid nonce hash from server') + } log.info('authorization successful') diff --git a/packages/core/src/network/config-manager.ts b/packages/core/src/network/config-manager.ts new file mode 100644 index 00000000..f027857d --- /dev/null +++ b/packages/core/src/network/config-manager.ts @@ -0,0 +1,103 @@ +import { tl } from '@mtcute/tl' + +export class ConfigManager { + constructor(private _update: () => Promise) {} + + private _destroyed = false + private _config?: tl.RawConfig + private _cdnConfig?: tl.RawCdnConfig + + private _updateTimeout?: NodeJS.Timeout + private _updatingPromise?: Promise + + private _listeners: ((config: tl.RawConfig) => void)[] = [] + + get isStale(): boolean { + return !this._config || this._config.expires < Date.now() / 1000 + } + + update(force = false): Promise { + if (!force && !this.isStale) return Promise.resolve() + if (this._updatingPromise) return this._updatingPromise + + return (this._updatingPromise = this._update().then((config) => { + if (this._destroyed) return + + this.setConfig(config) + })) + } + + setConfig(config: tl.RawConfig): void { + this._config = config + + if (this._updateTimeout) clearTimeout(this._updateTimeout) + this._updateTimeout = setTimeout( + () => this.update(), + (config.expires - Date.now() / 1000) * 1000, + ) + + for (const cb of this._listeners) cb(config) + } + + onConfigUpdate(cb: (config: tl.RawConfig) => void): void { + this._listeners.push(cb) + } + + offConfigUpdate(cb: (config: tl.RawConfig) => void): void { + const idx = this._listeners.indexOf(cb) + if (idx >= 0) this._listeners.splice(idx, 1) + } + + getNow(): tl.RawConfig | undefined { + return this._config + } + + async get(): Promise { + if (this.isStale) await this.update() + + return this._config! + } + + destroy(): void { + if (this._updateTimeout) clearTimeout(this._updateTimeout) + this._listeners.length = 0 + this._destroyed = true + } + + async findOption(params: { + dcId: number + allowIpv6?: boolean + preferIpv6?: boolean + allowMedia?: boolean + preferMedia?: boolean + cdn?: boolean + }): Promise { + if (this.isStale) await this.update() + + const options = this._config!.dcOptions.filter((opt) => { + if (opt.tcpoOnly) return false // unsupported + if (opt.ipv6 && !params.allowIpv6) return false + if (opt.mediaOnly && !params.allowMedia) return false + if (opt.cdn && !params.cdn) return false + + return opt.id === params.dcId + }) + + if (params.preferMedia && params.preferIpv6) { + const r = options.find((opt) => opt.mediaOnly && opt.ipv6) + if (r) return r + } + + if (params.preferMedia) { + const r = options.find((opt) => opt.mediaOnly) + if (r) return r + } + + if (params.preferIpv6) { + const r = options.find((opt) => opt.ipv6) + if (r) return r + } + + return options[0] + } +} diff --git a/packages/core/src/network/index.ts b/packages/core/src/network/index.ts index 77d7ebd6..b066008a 100644 --- a/packages/core/src/network/index.ts +++ b/packages/core/src/network/index.ts @@ -1,3 +1,8 @@ +export { + ConnectionKind, + NetworkManagerExtraParams, + RpcCallOptions, +} from './network-manager' export * from './reconnection' export * from './session-connection' export * from './transports' diff --git a/packages/core/src/network/mtproto-session.ts b/packages/core/src/network/mtproto-session.ts index 6ead312b..1508b97f 100644 --- a/packages/core/src/network/mtproto-session.ts +++ b/packages/core/src/network/mtproto-session.ts @@ -2,30 +2,96 @@ import Long from 'long' import { mtp, tl } from '@mtcute/tl' import { - TlBinaryReader, TlBinaryWriter, TlReaderMap, TlSerializationCounter, TlWriterMap, } from '@mtcute/tl-runtime' -import { getRandomInt, ICryptoProvider, Logger, randomLong } from '../utils' -import { buffersEqual, randomBytes } from '../utils/buffer-utils' -import { createAesIgeForMessage } from '../utils/crypto/mtproto' +import { + ControllablePromise, + Deque, + getRandomInt, + ICryptoProvider, + Logger, + LongMap, + LruSet, + randomLong, + SortedArray, +} from '../utils' +import { AuthKey } from './auth-key' + +export interface PendingRpc { + method: string + data: Buffer + promise: ControllablePromise + stack?: string + gzipOverhead?: number + + sent?: boolean + msgId?: Long + seqNo?: number + containerId?: Long + acked?: boolean + initConn?: boolean + getState?: number + cancelled?: boolean + timeout?: NodeJS.Timeout +} + +export type PendingMessage = + | { + _: 'rpc' + rpc: PendingRpc + } + | { + _: 'container' + msgIds: Long[] + } + | { + _: 'state' + msgIds: Long[] + containerId: Long + } + | { + _: 'resend' + msgIds: Long[] + containerId: Long + } + | { + _: 'ping' + pingId: Long + containerId: Long + } + | { + _: 'destroy_session' + sessionId: Long + containerId: Long + } + | { + _: 'cancel' + msgId: Long + containerId: Long + } + | { + _: 'future_salts' + containerId: Long + } + | { + _: 'bind' + promise: ControllablePromise + } /** - * Class encapsulating a single MTProto session. - * Provides means to en-/decrypt messages + * Class encapsulating a single MTProto session and storing + * all the relevant state */ export class MtprotoSession { - readonly _crypto: ICryptoProvider - _sessionId = randomLong() - _authKey?: Buffer - _authKeyId?: Buffer - _authKeyClientSalt?: Buffer - _authKeyServerSalt?: Buffer + _authKey = new AuthKey(this._crypto, this.log, this._readerMap) + _authKeyTemp = new AuthKey(this._crypto, this.log, this._readerMap) + _authKeyTempSecondary = new AuthKey(this._crypto, this.log, this._readerMap) _timeOffset = 0 _lastMessageId = Long.ZERO @@ -33,190 +99,129 @@ export class MtprotoSession { serverSalt = Long.ZERO + /// state /// + // recent msg ids + recentOutgoingMsgIds = new LruSet(1000, false, true) + recentIncomingMsgIds = new LruSet(1000, false, true) + + // queues + queuedRpc = new Deque() + queuedAcks: Long[] = [] + queuedStateReq: Long[] = [] + queuedResendReq: Long[] = [] + queuedCancelReq: Long[] = [] + getStateSchedule = new SortedArray( + [], + (a, b) => a.getState! - b.getState!, + ) + + // requests info + pendingMessages = new LongMap() + destroySessionIdToMsgId = new LongMap() + + lastPingRtt = NaN + lastPingTime = 0 + lastPingMsgId = Long.ZERO + lastSessionCreatedUid = Long.ZERO + + initConnectionCalled = false + authorizationPending = false + + next429Timeout = 1000 + current429Timeout?: NodeJS.Timeout + next429ResetTimeout?: NodeJS.Timeout + constructor( - crypto: ICryptoProvider, + readonly _crypto: ICryptoProvider, readonly log: Logger, readonly _readerMap: TlReaderMap, readonly _writerMap: TlWriterMap, ) { - this._crypto = crypto + this.log.prefix = `[SESSION ${this._sessionId.toString(16)}] ` } - /** Whether session contains authKey */ - get authorized(): boolean { - return this._authKey !== undefined + get hasPendingMessages(): boolean { + return Boolean( + this.queuedRpc.length || + this.queuedAcks.length || + this.queuedStateReq.length || + this.queuedResendReq.length, + ) } - /** Setup keys based on authKey */ - async setupKeys(authKey?: Buffer | null): Promise { - if (authKey) { - this._authKey = authKey - this._authKeyClientSalt = authKey.slice(88, 120) - this._authKeyServerSalt = authKey.slice(96, 128) - this._authKeyId = (await this._crypto.sha1(this._authKey)).slice(-8) - } else { - this._authKey = undefined - this._authKeyClientSalt = undefined - this._authKeyServerSalt = undefined - this._authKeyId = undefined + /** + * Reset session by resetting auth key(s) and session state + */ + reset(withAuthKey = false): void { + if (withAuthKey) { + this._authKey.reset() + this._authKeyTemp.reset() + this._authKeyTempSecondary.reset() } + + clearTimeout(this.current429Timeout) + this.resetState() + this.resetLastPing(true) } - /** Reset session by removing authKey and values derived from it */ - reset(): void { + /** + * Reset session state and generate a new session ID. + * + * By default, also cancels any pending RPC requests. + * If `keepPending` is set to `true`, pending requests will be kept + */ + resetState(keepPending = false): void { this._lastMessageId = Long.ZERO this._seqNo = 0 - this._authKey = undefined - this._authKeyClientSalt = undefined - this._authKeyServerSalt = undefined - this._authKeyId = undefined this._sessionId = randomLong() - // no need to reset server salt + this.log.debug('session reset, new sid = %h', this._sessionId) + this.log.prefix = `[SESSION ${this._sessionId.toString(16)}] ` + + // reset session state + + if (!keepPending) { + for (const info of this.pendingMessages.values()) { + if (info._ === 'rpc') { + info.rpc.promise.reject(new Error('Session is reset')) + } + } + this.pendingMessages.clear() + } + + this.recentOutgoingMsgIds.clear() + this.recentIncomingMsgIds.clear() + + if (!keepPending) { + while (this.queuedRpc.length) { + const rpc = this.queuedRpc.popFront()! + + if (rpc.sent === false) { + rpc.promise.reject(new Error('Session is reset')) + } + } + } + + this.queuedAcks.length = 0 + this.queuedStateReq.length = 0 + this.queuedResendReq.length = 0 + this.getStateSchedule.clear() } - changeSessionId(): void { - this._sessionId = randomLong() - this._seqNo = 0 - } + enqueueRpc(rpc: PendingRpc, force?: boolean): boolean { + // already queued or cancelled + if ((!force && !rpc.sent) || rpc.cancelled) return false - /** Encrypt a single MTProto message using session's keys */ - async encryptMessage(message: Buffer): Promise { - if (!this._authKey) throw new Error('Keys are not set up!') - - let padding = - (16 /* header size */ + message.length + 12) /* min padding */ % 16 - padding = 12 + (padding ? 16 - padding : 0) - - const buf = Buffer.alloc(16 + message.length + padding) - - buf.writeInt32LE(this.serverSalt!.low) - buf.writeInt32LE(this.serverSalt!.high, 4) - buf.writeInt32LE(this._sessionId.low, 8) - buf.writeInt32LE(this._sessionId.high, 12) - message.copy(buf, 16) - randomBytes(padding).copy(buf, 16 + message.length) - - const messageKey = ( - await this._crypto.sha256( - Buffer.concat([this._authKeyClientSalt!, buf]), - ) - ).slice(8, 24) - const ige = await createAesIgeForMessage( - this._crypto, - this._authKey, - messageKey, - true, + rpc.sent = false + rpc.containerId = undefined + this.log.debug( + 'enqueued %s for sending (msg_id = %s)', + rpc.method, + rpc.msgId || 'n/a', ) - const encryptedData = await ige.encrypt(buf) + this.queuedRpc.pushBack(rpc) - return Buffer.concat([this._authKeyId!, messageKey, encryptedData]) - } - - /** Decrypt a single MTProto message using session's keys */ - async decryptMessage( - data: Buffer, - callback: (msgId: tl.Long, seqNo: number, data: TlBinaryReader) => void, - ): Promise { - if (!this._authKey) throw new Error('Keys are not set up!') - - const authKeyId = data.slice(0, 8) - const messageKey = data.slice(8, 24) - - let encryptedData = data.slice(24) - - if (!buffersEqual(authKeyId, this._authKeyId!)) { - this.log.warn( - '[%h] warn: received message with unknown authKey = %h (expected %h)', - this._sessionId, - authKeyId, - this._authKeyId, - ) - - return - } - - const padSize = encryptedData.length % 16 - - if (padSize !== 0) { - // data came from a codec that uses non-16-based padding. - // it is safe to drop those padding bytes - encryptedData = encryptedData.slice(0, -padSize) - } - - const ige = await createAesIgeForMessage( - this._crypto, - this._authKey!, - messageKey, - false, - ) - const innerData = await ige.decrypt(encryptedData) - - const expectedMessageKey = ( - await this._crypto.sha256( - Buffer.concat([this._authKeyServerSalt!, innerData]), - ) - ).slice(8, 24) - - if (!buffersEqual(messageKey, expectedMessageKey)) { - this.log.warn( - '[%h] received message with invalid messageKey = %h (expected %h)', - this._sessionId, - messageKey, - expectedMessageKey, - ) - - return - } - - const innerReader = new TlBinaryReader(this._readerMap, innerData) - innerReader.seek(8) // skip salt - const sessionId = innerReader.long() - const messageId = innerReader.long(true) - - if (sessionId.neq(this._sessionId)) { - this.log.warn( - 'ignoring message with invalid sessionId = %h', - sessionId, - ) - - return - } - - const seqNo = innerReader.uint() - const length = innerReader.uint() - - if (length > innerData.length - 32 /* header size */) { - this.log.warn( - 'ignoring message with invalid length: %d > %d', - length, - innerData.length - 32, - ) - - return - } - - if (length % 4 !== 0) { - this.log.warn( - 'ignoring message with invalid length: %d is not a multiple of 4', - length, - ) - - return - } - - const paddingSize = innerData.length - length - 32 // header size - - if (paddingSize < 12 || paddingSize > 1024) { - this.log.warn( - 'ignoring message with invalid padding size: %d', - paddingSize, - ) - - return - } - - callback(messageId, seqNo, innerReader) + return true } getMessageId(): Long { @@ -237,16 +242,55 @@ export class MtprotoSession { } getSeqNo(isContentRelated = true): number { - let seqNo = this._seqNo * 2 + let seqNo = this._seqNo if (isContentRelated) { seqNo += 1 - this._seqNo += 1 + this._seqNo += 2 } return seqNo } + /** Encrypt a single MTProto message using session's keys */ + async encryptMessage(message: Buffer): Promise { + const key = this._authKeyTemp.ready ? this._authKeyTemp : this._authKey + + return key.encryptMessage(message, this.serverSalt, this._sessionId) + } + + /** Decrypt a single MTProto message using session's keys */ + async decryptMessage( + data: Buffer, + callback: Parameters[2], + ): Promise { + if (!this._authKey.ready) throw new Error('Keys are not set up!') + + const authKeyId = data.slice(0, 8) + + let key: AuthKey + + if (this._authKey.match(authKeyId)) { + key = this._authKey + } else if (this._authKeyTemp.match(authKeyId)) { + key = this._authKeyTemp + } else if (this._authKeyTempSecondary.match(authKeyId)) { + key = this._authKeyTempSecondary + } else { + this.log.warn( + 'received message with unknown authKey = %h (expected %h or %h or %h)', + authKeyId, + this._authKey.id, + this._authKeyTemp.id, + this._authKeyTempSecondary.id, + ) + + return + } + + return key.decryptMessage(data, this._sessionId, callback) + } + writeMessage( writer: TlBinaryWriter, content: tl.TlObject | mtp.TlObject | Buffer, @@ -270,4 +314,43 @@ export class MtprotoSession { return messageId } + + onTransportFlood(callback: () => void) { + if (this.current429Timeout) return // already waiting + + // all active queries must be resent after a timeout + this.resetLastPing(true) + + const timeout = this.next429Timeout + + this.next429Timeout = Math.min(this.next429Timeout * 2, 32000) + clearTimeout(this.current429Timeout) + clearTimeout(this.next429ResetTimeout) + + this.current429Timeout = setTimeout(() => { + this.current429Timeout = undefined + callback() + }, timeout) + this.next429ResetTimeout = setTimeout(() => { + this.next429ResetTimeout = undefined + this.next429Timeout = 1000 + }, 60000) + + this.log.debug( + 'transport flood, waiting for %d ms before proceeding', + timeout, + ) + + return Date.now() + timeout + } + + resetLastPing(withTime = false): void { + if (withTime) this.lastPingTime = 0 + + if (!this.lastPingMsgId.isZero()) { + this.pendingMessages.delete(this.lastPingMsgId) + } + + this.lastPingMsgId = Long.ZERO + } } diff --git a/packages/core/src/network/multi-session-connection.ts b/packages/core/src/network/multi-session-connection.ts new file mode 100644 index 00000000..dad2c577 --- /dev/null +++ b/packages/core/src/network/multi-session-connection.ts @@ -0,0 +1,332 @@ +import EventEmitter from 'events' + +import { tl } from '@mtcute/tl' + +import { Logger } from '../utils' +import { MtprotoSession } from './mtproto-session' +import { + SessionConnection, + SessionConnectionParams, +} from './session-connection' +import { TransportFactory } from './transports' + +export class MultiSessionConnection extends EventEmitter { + private _log: Logger + readonly _sessions: MtprotoSession[] + private _enforcePfs = false + + constructor( + readonly params: SessionConnectionParams, + private _count: number, + log: Logger, + logPrefix = '', + ) { + super() + this._log = log.create('multi') + if (logPrefix) this._log.prefix = `[${logPrefix}] ` + this._enforcePfs = _count > 1 && params.isMainConnection + + this._sessions = [] + this._updateConnections() + } + + protected _connections: SessionConnection[] = [] + + setCount(count: number, connect = this.params.isMainConnection): void { + this._count = count + + this._updateConnections(connect) + } + + private _updateSessions(): void { + // there are two cases + // 1. this msc is main, in which case every connection should have its own session + // 2. this msc is not main, in which case all connections should share the same session + // if (!this.params.isMainConnection) { + // // case 2 + // this._log.debug( + // 'updating sessions count: %d -> 1', + // this._sessions.length, + // ) + // + // if (this._sessions.length === 0) { + // this._sessions.push( + // new MtprotoSession( + // this.params.crypto, + // this._log.create('session'), + // this.params.readerMap, + // this.params.writerMap, + // ), + // ) + // } + // + // // shouldn't happen, but just in case + // while (this._sessions.length > 1) { + // this._sessions.pop()!.reset() + // } + // + // return + // } + + this._log.debug( + 'updating sessions count: %d -> %d', + this._sessions.length, + this._count, + ) + + // case 1 + if (this._sessions.length === this._count) return + + if (this._sessions.length > this._count) { + // destroy extra sessions + for (let i = this._sessions.length - 1; i >= this._count; i--) { + this._sessions[i].reset() + } + + this._sessions.splice(this._count) + + return + } + + while (this._sessions.length < this._count) { + const idx = this._sessions.length + const session = new MtprotoSession( + this.params.crypto, + this._log.create('session'), + this.params.readerMap, + this.params.writerMap, + ) + + // brvh + if (idx !== 0) session._authKey = this._sessions[0]._authKey + + this._sessions.push(session) + } + } + + private _updateConnections(connect = false): void { + this._updateSessions() + if (this._connections.length === this._count) return + + this._log.debug( + 'updating connections count: %d -> %d', + this._connections.length, + this._count, + ) + + const newEnforcePfs = this._count > 1 && this.params.isMainConnection + const enforcePfsChanged = newEnforcePfs !== this._enforcePfs + + if (enforcePfsChanged) { + this._log.debug( + 'enforcePfs changed: %s -> %s', + this._enforcePfs, + newEnforcePfs, + ) + this._enforcePfs = newEnforcePfs + } + + if (this._connections.length > this._count) { + // destroy extra connections + for (let i = this._connections.length - 1; i >= this._count; i--) { + this._connections[i].removeAllListeners() + this._connections[i].destroy() + } + + this._connections.splice(this._count) + + return + } + + if (enforcePfsChanged) { + this._connections.forEach((conn) => { + conn.setUsePfs(this.params.usePfs || this._enforcePfs) + }) + } + + // create new connections + for (let i = this._connections.length; i < this._count; i++) { + const session = this._sessions[i] // this.params.isMainConnection ? // : + // this._sessions[0] + const conn = new SessionConnection( + { + ...this.params, + usePfs: this.params.usePfs || this._enforcePfs, + isMainConnection: this.params.isMainConnection && i === 0, + withUpdates: + this.params.isMainConnection && + !this.params.disableUpdates, + }, + session, + ) + + if (this.params.isMainConnection) { + conn.on('update', (update) => this.emit('update', update)) + } + conn.on('error', (err) => this.emit('error', err, conn)) + conn.on('key-change', (key) => { + this.emit('key-change', i, key) + + // notify other connections + for (const conn_ of this._connections) { + if (conn_ === conn) continue + conn_.onConnected() + } + }) + conn.on('tmp-key-change', (key, expires) => + this.emit('tmp-key-change', i, key, expires), + ) + conn.on('auth-begin', () => { + this._log.debug('received auth-begin from connection %d', i) + this.emit('auth-begin', i) + + // we need to reset temp auth keys if there are any left + + this._connections.forEach((conn_) => { + conn_._session._authKeyTemp.reset() + if (conn_ !== conn) conn_.reconnect() + }) + }) + conn.on('usable', () => this.emit('usable', i)) + conn.on('request-auth', () => this.emit('request-auth', i)) + conn.on('flood-done', () => { + this._log.debug('received flood-done from connection %d', i) + + this._connections.forEach((it) => it.flushWhenIdle()) + }) + + this._connections.push(conn) + if (connect) conn.connect() + } + } + + _destroyed = false + destroy(): void { + this._connections.forEach((conn) => conn.destroy()) + this._sessions.forEach((sess) => sess.reset()) + this.removeAllListeners() + + this._destroyed = true + } + + private _nextConnection = 0 + + sendRpc( + request: T, + stack?: string, + timeout?: number, + ): Promise { + // if (this.params.isMainConnection) { + // find the least loaded connection + let min = Infinity + let minIdx = 0 + + for (let i = 0; i < this._connections.length; i++) { + const conn = this._connections[i] + const total = + conn._session.queuedRpc.length + + conn._session.pendingMessages.size() + + if (total < min) { + min = total + minIdx = i + } + } + + return this._connections[minIdx].sendRpc(request, stack, timeout) + // } + + // round-robin connections + // since they all share the same session, it doesn't matter which one we use + // the connection chosen here will only affect the first attempt at sending + // return this._connections[ + // this._nextConnection++ % this._connections.length + // ].sendRpc(request, stack, timeout) + } + + connect(): void { + for (const conn of this._connections) { + conn.connect() + } + } + + ensureConnected(): void { + if (this._connections[0].isConnected) return + + this.connect() + } + + async setAuthKey( + authKey: Buffer | null, + temp = false, + idx = 0, + ): Promise { + const session = this._sessions[idx] + const key = temp ? session._authKeyTemp : session._authKey + await key.setup(authKey) + } + + resetAuthKeys(): void { + for (const session of this._sessions) { + session.reset(true) + } + this.notifyKeyChange() + } + + setInactivityTimeout(timeout?: number): void { + this._log.debug('setting inactivity timeout to %s', timeout) + + // for future connections (if any) + this.params.inactivityTimeout = timeout + + // for current connections + for (const conn of this._connections) { + conn.setInactivityTimeout(timeout) + } + } + + notifyKeyChange(): void { + // only expected to be called on non-main connections + const session = this._sessions[0] + + if (this.params.usePfs && !session._authKeyTemp.ready) { + this._log.debug( + 'temp auth key needed but not ready, ignoring key change', + ) + + return + } + + if (this._sessions[0].queuedRpc.length) { + // there are pending requests, we need to reconnect. + this._log.debug( + 'notifying key change on the connection due to queued rpc', + ) + this._connections[0].onConnected() + } + + // connection is idle, we don't need to notify it + } + + requestAuth(): void { + this._connections[0]._authorize() + } + + resetSessions(): void { + if (this.params.isMainConnection) { + for (const conn of this._connections) { + conn._resetSession() + } + } else { + this._connections[0]._resetSession() + } + } + + changeTransport(factory: TransportFactory): void { + this._connections.forEach((conn) => conn.changeTransport(factory)) + } + + getPoolSize(): number { + return this._connections.length + } +} diff --git a/packages/core/src/network/network-manager.ts b/packages/core/src/network/network-manager.ts new file mode 100644 index 00000000..5a6ebb04 --- /dev/null +++ b/packages/core/src/network/network-manager.ts @@ -0,0 +1,866 @@ +import { tl } from '@mtcute/tl' +import { TlReaderMap, TlWriterMap } from '@mtcute/tl-runtime' + +import { ITelegramStorage } from '../storage' +import { + createControllablePromise, + ICryptoProvider, + Logger, + sleep, +} from '../utils' +import { ConfigManager } from './config-manager' +import { MultiSessionConnection } from './multi-session-connection' +import { PersistentConnectionParams } from './persistent-connection' +import { + defaultReconnectionStrategy, + ReconnectionStrategy, +} from './reconnection' +import { + SessionConnection, + SessionConnectionParams, +} from './session-connection' +import { defaultTransportFactory, TransportFactory } from './transports' + +export type ConnectionKind = 'main' | 'upload' | 'download' | 'downloadSmall' + +const CLIENT_ERRORS = { + '303': 1, + '400': 1, + '401': 1, + '403': 1, + '404': 1, + '406': 1, + '420': 1, +} + +/** + * Params passed into {@link NetworkManager} by {@link TelegramClient}. + * This type is intended for internal usage only. + */ +export interface NetworkManagerParams { + storage: ITelegramStorage + crypto: ICryptoProvider + log: Logger + + apiId: number + initConnectionOptions?: Partial< + Omit + > + transport?: TransportFactory + reconnectionStrategy?: ReconnectionStrategy + floodSleepThreshold: number + maxRetryCount: number + disableUpdates?: boolean + testMode: boolean + layer: number + useIpv6: boolean + readerMap: TlReaderMap + writerMap: TlWriterMap + isPremium: boolean + _emitError: (err: Error, connection?: SessionConnection) => void + keepAliveAction: () => void +} + +export type ConnectionCountDelegate = ( + kind: ConnectionKind, + dcId: number, + isPremium: boolean +) => number + +const defaultConnectionCountDelegate: ConnectionCountDelegate = ( + kind, + dcId, + isPremium, +) => { + switch (kind) { + case 'main': + return 1 + case 'upload': + return isPremium || (dcId !== 2 && dcId !== 4) ? 8 : 4 + case 'download': + return isPremium ? 8 : 2 + case 'downloadSmall': + return 2 + } +} + +/** + * Additional params passed into {@link NetworkManager} by the user + * that customize the behavior of the manager + */ +export interface NetworkManagerExtraParams { + /** + * Whether to use PFS (Perfect Forward Secrecy) for all connections. + * This is disabled by default + */ + usePfs?: boolean + + /** + * Connection count for each connection kind. + * The function should be pure to avoid unexpected behavior. + * + * Defaults to TDLib logic: + * - main: handled internally, **cannot be changed here** + * - upload: if premium or dc id is other than 2 or 4, then 8, otherwise 4 + * - download: if premium then 8, otherwise 2 + * - downloadSmall: 2 + */ + connectionCount?: ConnectionCountDelegate + + /** + * Idle timeout for non-main connections, in ms + * Defaults to 60 seconds. + */ + inactivityTimeout?: number +} + +export interface RpcCallOptions { + /** + * If the call results in a `FLOOD_WAIT_X` error, + * the maximum amount of time to wait before retrying. + * + * If set to `0`, the call will not be retried. + * + * @default {@link BaseTelegramClientOptions.floodSleepThreshold} + */ + floodSleepThreshold?: number + + /** + * If the call results in an internal server error or a flood wait, + * the maximum amount of times to retry the call. + * + * @default {@link BaseTelegramClientOptions.maxRetryCount} + */ + maxRetryCount?: number + + /** + * Timeout for the call, in milliseconds. + * + * @default Infinity + */ + timeout?: number + + /** + * Kind of connection to use for this call. + * + * @default 'main' + */ + kind?: ConnectionKind + + /** + * ID of the DC to use for this call + */ + dcId?: number + + /** + * DC connection manager to use for this call. + * Overrides `dcId` if set. + */ + manager?: DcConnectionManager +} + +export class DcConnectionManager { + private __baseConnectionParams = (): SessionConnectionParams => ({ + crypto: this.manager.params.crypto, + initConnection: this.manager._initConnectionParams, + transportFactory: this.manager._transportFactory, + dc: this._dc, + testMode: this.manager.params.testMode, + reconnectionStrategy: this.manager._reconnectionStrategy, + layer: this.manager.params.layer, + disableUpdates: this.manager.params.disableUpdates, + readerMap: this.manager.params.readerMap, + writerMap: this.manager.params.writerMap, + usePfs: this.manager.params.usePfs, + isMainConnection: false, + inactivityTimeout: this.manager.params.inactivityTimeout ?? 60_000, + }) + + private _log = this.manager._log.create('dc-manager') + + main: MultiSessionConnection + + upload = new MultiSessionConnection( + this.__baseConnectionParams(), + this.manager._connectionCount( + 'upload', + this._dc.id, + this.manager.params.isPremium, + ), + this._log, + 'UPLOAD', + ) + + download = new MultiSessionConnection( + this.__baseConnectionParams(), + this.manager._connectionCount( + 'download', + this._dc.id, + this.manager.params.isPremium, + ), + this._log, + 'DOWNLOAD', + ) + + downloadSmall = new MultiSessionConnection( + this.__baseConnectionParams(), + this.manager._connectionCount( + 'downloadSmall', + this._dc.id, + this.manager.params.isPremium, + ), + this._log, + 'DOWNLOAD_SMALL', + ) + + private get _mainConnectionCount() { + if (!this.isPrimary) return 1 + + return this.manager.config.getNow()?.tmpSessions ?? 1 + } + + constructor( + readonly manager: NetworkManager, + readonly dcId: number, + readonly _dc: tl.RawDcOption, + public isPrimary = false, + ) { + this._log.prefix = `[DC ${dcId}] ` + + const mainParams = this.__baseConnectionParams() + mainParams.isMainConnection = true + + if (isPrimary) { + mainParams.inactivityTimeout = undefined + } + + this.main = new MultiSessionConnection( + mainParams, + this._mainConnectionCount, + this._log, + 'MAIN', + ) + + this._setupMulti('main') + this._setupMulti('upload') + this._setupMulti('download') + this._setupMulti('downloadSmall') + } + + private _setupMulti(kind: ConnectionKind): void { + const connection = this[kind] + + connection.on('key-change', (idx, key) => { + if (kind !== 'main') { + // main connection is responsible for authorization, + // and keys are then sent to other connections + this.manager._log.warn( + 'got key-change from non-main connection, ignoring', + ) + + return + } + + this.manager._log.debug( + 'key change for dc %d from connection %d', + this.dcId, + idx, + ) + this.manager._storage.setAuthKeyFor(this.dcId, key) + + // send key to other connections + Promise.all([ + this.upload.setAuthKey(key), + this.download.setAuthKey(key), + this.downloadSmall.setAuthKey(key), + ]).then(() => { + this.upload.notifyKeyChange() + this.download.notifyKeyChange() + this.downloadSmall.notifyKeyChange() + }) + }) + connection.on('tmp-key-change', (idx, key, expires) => { + if (kind !== 'main') { + this.manager._log.warn( + 'got tmp-key-change from non-main connection, ignoring', + ) + + return + } + + this.manager._log.debug( + 'temp key change for dc %d from connection %d', + this.dcId, + idx, + ) + this.manager._storage.setTempAuthKeyFor( + this.dcId, + idx, + key, + expires * 1000, + ) + + // send key to other connections + Promise.all([ + this.upload.setAuthKey(key, true), + this.download.setAuthKey(key, true), + this.downloadSmall.setAuthKey(key, true), + ]).then(() => { + this.upload.notifyKeyChange() + this.download.notifyKeyChange() + this.downloadSmall.notifyKeyChange() + }) + }) + + connection.on('auth-begin', () => { + // we need to propagate auth-begin to all connections + // to avoid them sending requests before auth is complete + if (kind !== 'main') { + this.manager._log.warn( + 'got auth-begin from non-main connection, ignoring', + ) + + return + } + + // reset key on non-main connections + // this event was already propagated to additional main connections + this.upload.resetAuthKeys() + this.download.resetAuthKeys() + this.downloadSmall.resetAuthKeys() + }) + + connection.on('request-auth', () => { + this.main.requestAuth() + }) + + connection.on('error', (err, conn) => { + this.manager.params._emitError(err, conn) + }) + } + + setIsPrimary(isPrimary: boolean): void { + if (this.isPrimary === isPrimary) return + this.isPrimary = isPrimary + + if (isPrimary) { + this.main.setInactivityTimeout(undefined) + } else { + this.main.setInactivityTimeout( + this.manager.params.inactivityTimeout ?? 60_000, + ) + } + } + + setIsPremium(isPremium: boolean): void { + this.upload.setCount( + this.manager._connectionCount('upload', this._dc.id, isPremium), + ) + this.download.setCount( + this.manager._connectionCount('download', this._dc.id, isPremium), + ) + this.downloadSmall.setCount( + this.manager._connectionCount( + 'downloadSmall', + this._dc.id, + isPremium, + ), + ) + } + + async loadKeys(): Promise { + const permanent = await this.manager._storage.getAuthKeyFor(this.dcId) + + await Promise.all([ + this.main.setAuthKey(permanent), + this.upload.setAuthKey(permanent), + this.download.setAuthKey(permanent), + this.downloadSmall.setAuthKey(permanent), + ]) + + if (!permanent) { + return false + } + + if (this.manager.params.usePfs) { + await Promise.all( + this.main._sessions.map(async (_, i) => { + const temp = await this.manager._storage.getAuthKeyFor( + this.dcId, + i, + ) + await this.main.setAuthKey(temp, true, i) + + if (i === 0) { + await Promise.all([ + this.upload.setAuthKey(temp, true), + this.download.setAuthKey(temp, true), + this.downloadSmall.setAuthKey(temp, true), + ]) + } + }), + ) + } + + return true + } +} + +export class NetworkManager { + readonly _log = this.params.log.create('network') + readonly _storage = this.params.storage + + readonly _initConnectionParams: tl.RawInitConnectionRequest + readonly _transportFactory: TransportFactory + readonly _reconnectionStrategy: ReconnectionStrategy + readonly _connectionCount: ConnectionCountDelegate + + protected readonly _dcConnections: Record = {} + protected _primaryDc?: DcConnectionManager + + private _keepAliveInterval?: NodeJS.Timeout + private _lastUpdateTime = 0 + private _updateHandler: (upd: tl.TypeUpdates) => void = () => {} + + constructor( + readonly params: NetworkManagerParams & NetworkManagerExtraParams, + readonly config: ConfigManager, + ) { + let deviceModel = 'mtcute on ' + let appVersion = 'unknown' + if (typeof process !== 'undefined' && typeof require !== 'undefined') { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const os = require('os') + deviceModel += `${os.type()} ${os.arch()} ${os.release()}` + + try { + // for production builds + // eslint-disable-next-line @typescript-eslint/no-var-requires + appVersion = require('../package.json').version + } catch (e) { + try { + // for development builds (additional /src/ in path) + // eslint-disable-next-line @typescript-eslint/no-var-requires + appVersion = require('../../package.json').version + } catch (e) {} + } + } else if (typeof navigator !== 'undefined') { + deviceModel += navigator.userAgent + } else deviceModel += 'unknown' + + this._initConnectionParams = { + _: 'initConnection', + deviceModel, + systemVersion: '1.0', + appVersion, + systemLangCode: 'en', + langPack: '', // "langPacks are for official apps only" + langCode: 'en', + ...(params.initConnectionOptions ?? {}), + apiId: params.apiId, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + query: null as any, + } + + this._transportFactory = params.transport ?? defaultTransportFactory + this._reconnectionStrategy = + params.reconnectionStrategy ?? defaultReconnectionStrategy + this._connectionCount = + params.connectionCount ?? defaultConnectionCountDelegate + + this._onConfigChanged = this._onConfigChanged.bind(this) + config.onConfigUpdate(this._onConfigChanged) + } + + private _switchPrimaryDc(dc: DcConnectionManager) { + if (this._primaryDc && this._primaryDc !== dc) { + this._primaryDc.setIsPrimary(false) + } + + this._primaryDc = dc + dc.setIsPrimary(true) + + dc.main.on('usable', () => { + this._lastUpdateTime = Date.now() + + if (this._keepAliveInterval) clearInterval(this._keepAliveInterval) + this._keepAliveInterval = setInterval(async () => { + if (Date.now() - this._lastUpdateTime > 900_000) { + // telegram asks to fetch pending updates if there are no updates for 15 minutes. + // it is up to the user to decide whether to do it or not + + this.params.keepAliveAction() + this._lastUpdateTime = Date.now() + } + }, 60_000) + + Promise.resolve(this._storage.getSelf()).then((self) => { + if (self?.isBot) { + // bots may receive tmpSessions, which we should respect + this.config.update(true).catch((e) => { + this.params._emitError(e) + }) + } + }) + }) + dc.main.on('update', (update) => { + this._lastUpdateTime = Date.now() + this._updateHandler(update) + }) + + dc.loadKeys() + .catch((e) => { + this.params._emitError(e) + }) + .then(() => { + dc.main.ensureConnected() + }) + } + + private _dcCreationPromise: Record> = {} + async _getOtherDc(dcId: number): Promise { + if (!this._dcConnections[dcId]) { + if (dcId in this._dcCreationPromise) { + this._log.debug('waiting for DC %d to be created', dcId) + await this._dcCreationPromise[dcId] + + return this._dcConnections[dcId] + } + + const promise = createControllablePromise() + this._dcCreationPromise[dcId] = promise + + this._log.debug('creating new DC %d', dcId) + + try { + const dcOption = await this.config.findOption({ + dcId, + allowIpv6: this.params.useIpv6, + preferIpv6: this.params.useIpv6, + allowMedia: true, + preferMedia: true, + cdn: false, + }) + + if (!dcOption) { + throw new Error(`Could not find DC ${dcId}`) + } + const dc = new DcConnectionManager(this, dcId, dcOption) + + if (!(await dc.loadKeys())) { + dc.main.requestAuth() + } + + this._dcConnections[dcId] = dc + promise.resolve() + } catch (e) { + promise.reject(e) + } + } + + return this._dcConnections[dcId] + } + + /** + * Perform initial connection to the default DC + * + * @param defaultDc Default DC to connect to + */ + async connect(defaultDc: tl.RawDcOption): Promise { + if (this._dcConnections[defaultDc.id]) { + // shouldn't happen + throw new Error('DC manager already exists') + } + + const dc = new DcConnectionManager(this, defaultDc.id, defaultDc) + this._dcConnections[defaultDc.id] = dc + this._switchPrimaryDc(dc) + } + + private async _exportAuthTo(manager: DcConnectionManager): Promise { + const auth = await this.call({ + _: 'auth.exportAuthorization', + dcId: manager.dcId, + }) + + const res = await this.call( + { + _: 'auth.importAuthorization', + id: auth.id, + bytes: auth.bytes, + }, + { manager }, + ) + + if (res._ !== 'auth.authorization') { + throw new Error( + `Unexpected response from auth.importAuthorization: ${res._}`, + ) + } + } + + async exportAuth(): Promise { + const dcs: Record = {} + const config = await this.config.get() + + for (const dc of config.dcOptions) { + if (dc.cdn) continue + dcs[dc.id] = dc.id + } + + for (const dc of Object.values(dcs)) { + if (dc === this._primaryDc!.dcId) continue + this._log.debug('exporting auth for dc %d', dc) + + const manager = await this._getOtherDc(dc) + await this._exportAuthTo(manager) + } + } + + setIsPremium(isPremium: boolean): void { + this._log.debug('setting isPremium to %s', isPremium) + this.params.isPremium = isPremium + Object.values(this._dcConnections).forEach((dc) => { + dc.setIsPremium(isPremium) + }) + } + + async notifyLoggedIn(auth: tl.auth.TypeAuthorization): Promise { + if ( + auth._ === 'auth.authorizationSignUpRequired' || + auth.user._ === 'userEmpty' + ) { + return + } + + if (auth.tmpSessions) { + this._primaryDc?.main.setCount(auth.tmpSessions) + } + + this.setIsPremium(auth.user.premium!) + + await this.exportAuth() + } + + resetSessions(): void { + const dc = this._primaryDc + if (!dc) return + + dc.main.resetSessions() + dc.upload.resetSessions() + dc.download.resetSessions() + dc.downloadSmall.resetSessions() + } + + private _onConfigChanged(config: tl.RawConfig): void { + if (config.tmpSessions) { + this._primaryDc?.main.setCount(config.tmpSessions) + } + } + + async changePrimaryDc(newDc: number): Promise { + if (newDc === this._primaryDc?.dcId) return + + const option = await this.config.findOption({ + dcId: newDc, + allowIpv6: this.params.useIpv6, + preferIpv6: this.params.useIpv6, + cdn: false, + allowMedia: false, + }) + + if (!option) { + throw new Error(`DC ${newDc} not found`) + } + + if (!this._dcConnections[newDc]) { + this._dcConnections[newDc] = new DcConnectionManager( + this, + newDc, + option, + ) + } + + this._storage.setDefaultDc(option) + + this._switchPrimaryDc(this._dcConnections[newDc]) + } + + private _floodWaitedRequests: Record = {} + async call( + message: T, + params?: RpcCallOptions, + stack?: string, + ): Promise { + if (!this._primaryDc) { + throw new Error('Not connected to any DC') + } + + const floodSleepThreshold = + params?.floodSleepThreshold ?? this.params.floodSleepThreshold + const maxRetryCount = params?.maxRetryCount ?? this.params.maxRetryCount + + // do not send requests that are in flood wait + if (message._ in this._floodWaitedRequests) { + const delta = this._floodWaitedRequests[message._] - Date.now() + + if (delta <= 3000) { + // flood waits below 3 seconds are "ignored" + delete this._floodWaitedRequests[message._] + } else if (delta <= this.params.floodSleepThreshold) { + await sleep(delta) + delete this._floodWaitedRequests[message._] + } else { + throw new tl.errors.FloodWaitXError(delta / 1000) + } + } + + let lastError: Error | null = null + + const kind = params?.kind ?? 'main' + let manager: DcConnectionManager + + if (params?.manager) { + manager = params.manager + } else if (params?.dcId && params.dcId !== this._primaryDc.dcId) { + manager = await this._getOtherDc(params.dcId) + } else { + manager = this._primaryDc + } + + let multi = manager[kind] + + for (let i = 0; i < maxRetryCount; i++) { + try { + const res = await multi.sendRpc(message, stack, params?.timeout) + + if (kind === 'main') { + this._lastUpdateTime = Date.now() + } + + return res + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (e: any) { + lastError = e + + if (e.code && !(e.code in CLIENT_ERRORS)) { + this._log.warn( + 'Telegram is having internal issues: %d %s, retrying', + e.code, + e.message, + ) + + if (e.message === 'WORKER_BUSY_TOO_LONG_RETRY') { + // according to tdlib, "it is dangerous to resend query without timeout, so use 1" + await sleep(1000) + } + continue + } + + if ( + e.constructor === tl.errors.FloodWaitXError || + e.constructor === tl.errors.SlowmodeWaitXError || + e.constructor === tl.errors.FloodTestPhoneWaitXError + ) { + if (e.constructor !== tl.errors.SlowmodeWaitXError) { + // SLOW_MODE_WAIT is chat-specific, not request-specific + this._floodWaitedRequests[message._] = + Date.now() + e.seconds * 1000 + } + + // In test servers, FLOOD_WAIT_0 has been observed, and sleeping for + // such a short amount will cause retries very fast leading to issues + if (e.seconds === 0) { + (e as tl.Mutable).seconds = 1 + } + + if (e.seconds <= floodSleepThreshold) { + this._log.info('Flood wait for %d seconds', e.seconds) + await sleep(e.seconds * 1000) + continue + } + } + + if (manager === this._primaryDc) { + if ( + e.constructor === tl.errors.PhoneMigrateXError || + e.constructor === tl.errors.UserMigrateXError || + e.constructor === tl.errors.NetworkMigrateXError + ) { + this._log.info('Migrate error, new dc = %d', e.new_dc) + + await this.changePrimaryDc(e.new_dc) + manager = this._primaryDc! + multi = manager[kind] + + continue + } + } else if ( + e.constructor === tl.errors.AuthKeyUnregisteredError + ) { + // we can try re-exporting auth from the primary connection + this._log.warn( + 'exported auth key error, trying re-exporting..', + ) + + await this._exportAuthTo(manager) + continue + } + + throw e + } + } + + throw lastError + } + + setUpdateHandler(handler: NetworkManager['_updateHandler']): void { + this._updateHandler = handler + } + + changeTransport(factory: TransportFactory): void { + Object.values(this._dcConnections).forEach((dc) => { + dc.main.changeTransport(factory) + dc.upload.changeTransport(factory) + dc.download.changeTransport(factory) + dc.downloadSmall.changeTransport(factory) + }) + } + + getPoolSize(kind: ConnectionKind, dcId?: number) { + const dc = dcId ? this._dcConnections[dcId] : this._primaryDc + + if (!dc) { + if (!this._primaryDc) { + throw new Error('Not connected to any DC') + } + + // guess based on the provided delegate. it is most likely correct, + // but we should give actual values if possible + return this._connectionCount( + kind, + dcId ?? this._primaryDc.dcId, + this.params.isPremium, + ) + } + + return dc[kind].getPoolSize() + } + + getPrimaryDcId() { + if (!this._primaryDc) throw new Error('Not connected to any DC') + + return this._primaryDc.dcId + } + + destroy(): void { + for (const dc of Object.values(this._dcConnections)) { + dc.main.destroy() + dc.upload.destroy() + dc.download.destroy() + dc.downloadSmall.destroy() + } + if (this._keepAliveInterval) clearInterval(this._keepAliveInterval) + this.config.offConfigUpdate(this._onConfigChanged) + } +} diff --git a/packages/core/src/network/persistent-connection.ts b/packages/core/src/network/persistent-connection.ts index c09303af..12d5a170 100644 --- a/packages/core/src/network/persistent-connection.ts +++ b/packages/core/src/network/persistent-connection.ts @@ -3,10 +3,6 @@ import EventEmitter from 'events' import { tl } from '@mtcute/tl' import { ICryptoProvider, Logger } from '../utils' -import { - ControllablePromise, - createControllablePromise, -} from '../utils/controllable-promise' import { ReconnectionStrategy } from './reconnection' import { ITelegramTransport, @@ -23,13 +19,18 @@ export interface PersistentConnectionParams { inactivityTimeout?: number } +let nextConnectionUid = 0 + /** * Base class for persistent connections. * Only used for {@link PersistentConnection} and used as a mean of code splitting. + * This class doesn't know anything about MTProto, it just manages the transport. */ export abstract class PersistentConnection extends EventEmitter { + private _uid = nextConnectionUid++ + readonly params: PersistentConnectionParams - private _transport!: ITelegramTransport + protected _transport!: ITelegramTransport private _sendOnceConnected: Buffer[] = [] @@ -41,10 +42,7 @@ export abstract class PersistentConnection extends EventEmitter { // inactivity timeout private _inactivityTimeout: NodeJS.Timeout | null = null - private _inactive = false - - // waitForMessage - private _pendingWaitForMessages: ControllablePromise[] = [] + private _inactive = true _destroyed = false _usable = false @@ -62,6 +60,14 @@ export abstract class PersistentConnection extends EventEmitter { super() this.params = params this.changeTransport(params.transportFactory) + + this.log.prefix = `[UID ${this._uid}] ` + + this._onInactivityTimeout = this._onInactivityTimeout.bind(this) + } + + get isConnected(): boolean { + return this._transport.state() !== TransportState.Idle } changeTransport(factory: TransportFactory): void { @@ -73,18 +79,36 @@ export abstract class PersistentConnection extends EventEmitter { this._transport.setup?.(this.params.crypto, this.log) this._transport.on('ready', this.onTransportReady.bind(this)) - this._transport.on('message', this.onTransportMessage.bind(this)) + this._transport.on('message', this.onMessage.bind(this)) this._transport.on('error', this.onTransportError.bind(this)) this._transport.on('close', this.onTransportClose.bind(this)) } onTransportReady(): void { // transport ready does not mean actual mtproto is ready - if (this._sendOnceConnected.length) { - this._transport.send(Buffer.concat(this._sendOnceConnected)) + const sendNext = () => { + if (!this._sendOnceConnected.length) { + this.onConnected() + + return + } + + const data = this._sendOnceConnected.shift()! + this._transport + .send(data) + .then(sendNext) + .catch((err) => { + this.log.error('error sending queued data: %s', err) + this._sendOnceConnected.unshift(data) + }) + } + + sendNext() + + return } - this._sendOnceConnected = [] + this.onConnected() } @@ -101,32 +125,12 @@ export abstract class PersistentConnection extends EventEmitter { } onTransportError(err: Error): void { - if (this._pendingWaitForMessages.length) { - this._pendingWaitForMessages.shift()!.reject(err) - - return - } - this._lastError = err this.onError(err) // transport is expected to emit `close` after `error` } - onTransportMessage(data: Buffer): void { - if (this._pendingWaitForMessages.length) { - this._pendingWaitForMessages.shift()!.resolve(data) - - return - } - - this.onMessage(data) - } - onTransportClose(): void { - Object.values(this._pendingWaitForMessages).forEach((prom) => - prom.reject(new Error('Connection closed')), - ) - // transport closed because of inactivity // obviously we dont want to reconnect then if (this._inactive) return @@ -139,13 +143,20 @@ export abstract class PersistentConnection extends EventEmitter { this._consequentFails, this._previousWait, ) - if (wait === false) return this.destroy() + + if (wait === false) { + this.destroy() + + return + } this.emit('wait', wait) this._previousWait = wait - if (this._reconnectionTimeout != null) { clearTimeout(this._reconnectionTimeout) } + if (this._reconnectionTimeout != null) { + clearTimeout(this._reconnectionTimeout) + } this._reconnectionTimeout = setTimeout(() => { if (this._destroyed) return this._reconnectionTimeout = null @@ -154,10 +165,14 @@ export abstract class PersistentConnection extends EventEmitter { } connect(): void { - if (this._transport.state() !== TransportState.Idle) { throw new Error('Connection is already opened!') } + if (this.isConnected) { + throw new Error('Connection is already opened!') + } if (this._destroyed) throw new Error('Connection is already destroyed!') - if (this._reconnectionTimeout != null) { clearTimeout(this._reconnectionTimeout) } + if (this._reconnectionTimeout != null) { + clearTimeout(this._reconnectionTimeout) + } this._inactive = false this._transport.connect(this.params.dc, this.params.testMode) @@ -168,8 +183,12 @@ export abstract class PersistentConnection extends EventEmitter { } destroy(): void { - if (this._reconnectionTimeout != null) { clearTimeout(this._reconnectionTimeout) } - if (this._inactivityTimeout != null) { clearTimeout(this._inactivityTimeout) } + if (this._reconnectionTimeout != null) { + clearTimeout(this._reconnectionTimeout) + } + if (this._inactivityTimeout != null) { + clearTimeout(this._inactivityTimeout) + } this._transport.close() this._transport.removeAllListeners() @@ -179,15 +198,32 @@ export abstract class PersistentConnection extends EventEmitter { protected _rescheduleInactivity(): void { if (!this.params.inactivityTimeout) return if (this._inactivityTimeout) clearTimeout(this._inactivityTimeout) - this._inactivityTimeout = setTimeout(() => { - this.log.info( - 'disconnected because of inactivity for %d', - this.params.inactivityTimeout, - ) - this._inactive = true - this._inactivityTimeout = null - this._transport.close() - }, this.params.inactivityTimeout) + this._inactivityTimeout = setTimeout( + this._onInactivityTimeout, + this.params.inactivityTimeout, + ) + } + + protected _onInactivityTimeout(): void { + this.log.info( + 'disconnected because of inactivity for %d', + this.params.inactivityTimeout, + ) + this._inactive = true + this._inactivityTimeout = null + this._transport.close() + } + + setInactivityTimeout(timeout?: number): void { + this.params.inactivityTimeout = timeout + + if (this._inactivityTimeout) { + clearTimeout(this._inactivityTimeout) + } + + if (timeout) { + this._rescheduleInactivity() + } } async send(data: Buffer): Promise { @@ -201,11 +237,4 @@ export abstract class PersistentConnection extends EventEmitter { this._sendOnceConnected.push(data) } } - - waitForNextMessage(): Promise { - const promise = createControllablePromise() - this._pendingWaitForMessages.push(promise) - - return promise - } } diff --git a/packages/core/src/network/session-connection.ts b/packages/core/src/network/session-connection.ts index 19f1fe95..eac9a262 100644 --- a/packages/core/src/network/session-connection.ts +++ b/packages/core/src/network/session-connection.ts @@ -1,5 +1,4 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -/* eslint-disable @typescript-eslint/no-unused-vars */ // will be reworked in MTQ-32 import Long from 'long' @@ -16,94 +15,40 @@ import { gzipDeflate, gzipInflate } from '@mtcute/tl-runtime/src/platform/gzip' import { ControllablePromise, createCancellablePromise, - Deque, + createControllablePromise, EarlyTimer, - Logger, - LongMap, - LruSet, + longFromBuffer, + randomBytes, randomLong, removeFromLongArray, - SortedArray, } from '../utils' +import { createAesIgeForMessageOld } from '../utils/crypto/mtproto' import { doAuthorization } from './authorization' -import { MtprotoSession } from './mtproto-session' +import { MtprotoSession, PendingMessage, PendingRpc } from './mtproto-session' import { PersistentConnection, PersistentConnectionParams, } from './persistent-connection' import { TransportError } from './transports' +const TEMP_AUTH_KEY_EXPIRY = 86400 + export interface SessionConnectionParams extends PersistentConnectionParams { initConnection: tl.RawInitConnectionRequest inactivityTimeout?: number niceStacks?: boolean layer: number disableUpdates?: boolean + withUpdates?: boolean + isMainConnection: boolean + usePfs?: boolean readerMap: TlReaderMap writerMap: TlWriterMap } -interface PendingRpc { - method: string - data: Buffer - promise: ControllablePromise - stack?: string - gzipOverhead?: number - - sent?: boolean - msgId?: Long - seqNo?: number - containerId?: Long - acked?: boolean - initConn?: boolean - getState?: number - cancelled?: boolean - timeout?: NodeJS.Timeout -} - -type PendingMessage = - | { - _: 'rpc' - rpc: PendingRpc - } - | { - _: 'container' - msgIds: Long[] - } - | { - _: 'state' - msgIds: Long[] - containerId: Long - } - | { - _: 'resend' - msgIds: Long[] - containerId: Long - } - | { - _: 'ping' - pingId: Long - containerId: Long - } - | { - _: 'destroy_session' - sessionId: Long - containerId: Long - } - | { - _: 'cancel' - msgId: Long - containerId: Long - } - | { - _: 'future_salts' - containerId: Long - } - -// destroy_session#e7512126 session_id:long -// todo -const DESTROY_SESSION_ID = Buffer.from('262151e7', 'hex') +// destroy_auth_key#d1435160 = DestroyAuthKeyRes; +// const DESTROY_AUTH_KEY = Buffer.from('605134d1', 'hex') function makeNiceStack( error: tl.errors.RpcError, @@ -115,61 +60,36 @@ function makeNiceStack( }\n at ${method}\n${stack.split('\n').slice(2).join('\n')}` } -let nextConnectionUid = 0 - /** * A connection to a single DC. */ export class SessionConnection extends PersistentConnection { readonly params!: SessionConnectionParams - private _uid = nextConnectionUid++ - private _session: MtprotoSession private _flushTimer = new EarlyTimer() - - /// internal state /// - - // recent msg ids - private _recentOutgoingMsgIds = new LruSet(1000, false, true) - private _recentIncomingMsgIds = new LruSet(1000, false, true) - - // queues - private _queuedRpc = new Deque() - private _queuedAcks: Long[] = [] - private _queuedStateReq: Long[] = [] - private _queuedResendReq: Long[] = [] - private _queuedCancelReq: Long[] = [] private _queuedDestroySession: Long[] = [] - private _getStateSchedule = new SortedArray( - [], - (a, b) => a.getState! - b.getState!, - ) - // requests info - private _pendingMessages = new LongMap() + // waitForMessage + private _pendingWaitForUnencrypted: [ + ControllablePromise, + NodeJS.Timeout + ][] = [] - private _initConnectionCalled = false + private _usePfs = this.params.usePfs ?? false + private _isPfsBindingPending = false + private _isPfsBindingPendingInBackground = false + private _pfsUpdateTimeout?: NodeJS.Timeout - private _lastPingRtt = NaN - private _lastPingTime = 0 - private _lastPingMsgId = Long.ZERO - private _lastSessionCreatedUid = Long.ZERO - - private _next429Timeout = 1000 - private _current429Timeout?: NodeJS.Timeout + private _inactivityPendingFlush = false private _readerMap: TlReaderMap private _writerMap: TlWriterMap - constructor(params: SessionConnectionParams, log: Logger) { - super(params, log.create('conn')) - this._updateLogPrefix() - this._session = new MtprotoSession( - params.crypto, - log.create('session'), - params.readerMap, - params.writerMap, - ) + constructor( + params: SessionConnectionParams, + readonly _session: MtprotoSession, + ) { + super(params, _session.log.create('conn')) this._flushTimer.onTimeout(this._flush.bind(this)) this._readerMap = params.readerMap @@ -177,30 +97,40 @@ export class SessionConnection extends PersistentConnection { this._handleRawMessage = this._handleRawMessage.bind(this) } - private _updateLogPrefix() { - this.log.prefix = `[UID ${this._uid}, DC ${this.params.dc.id}] ` + getAuthKey(temp = false): Buffer | null { + const key = temp ? this._session._authKeyTemp : this._session._authKey + + if (!key.ready) return null + + return key.key } - async changeDc(dc: tl.RawDcOption, authKey?: Buffer): Promise { - this.log.debug('dc changed (has_auth_key = %b) to: %j', authKey, dc) - this._updateLogPrefix() + setUsePfs(usePfs: boolean): void { + if (this._usePfs === usePfs) return - this._session.reset() - await this._session.setupKeys(authKey) - this.params.dc = dc - this.reconnect() - } + this.log.debug('use pfs changed to %s', usePfs) + this._usePfs = usePfs - setupKeys(authKey: Buffer | null): Promise { - return this._session.setupKeys(authKey) - } + if (!usePfs) { + this._isPfsBindingPending = false + this._isPfsBindingPendingInBackground = false + this._session._authKeyTemp.reset() + clearTimeout(this._pfsUpdateTimeout!) + } - getAuthKey(): Buffer | undefined { - return this._session._authKey + this._resetSession() } onTransportClose(): void { super.onTransportClose() + + Object.values(this._pendingWaitForUnencrypted).forEach( + ([prom, timeout]) => { + prom.reject(new Error('Connection closed')) + clearTimeout(timeout) + }, + ) + this.emit('disconnect') this.reset() @@ -212,88 +142,102 @@ export class SessionConnection extends PersistentConnection { } reset(forever = false): void { - this._initConnectionCalled = false - this._resetLastPing(true) + this._session.initConnectionCalled = false this._flushTimer.reset() - clearTimeout(this._current429Timeout!) if (forever) { - // reset all the queues, cancel all pending messages, etc - this._session.reset() - - for (const info of this._pendingMessages.values()) { - if (info._ === 'rpc') { - info.rpc.promise.reject(new Error('Connection destroyed')) - } - } - this._pendingMessages.clear() - - this._recentOutgoingMsgIds.clear() - this._recentIncomingMsgIds.clear() - - while (this._queuedRpc.length) { - const rpc = this._queuedRpc.popFront()! - - if (rpc.sent === false) { - rpc.promise.reject(new Error('Connection destroyed')) - } - } - - this._queuedAcks.length = 0 - this._queuedStateReq.length = 0 - this._queuedResendReq.length = 0 - this._getStateSchedule.clear() this.removeAllListeners() } } - protected async onConnected(): Promise { - if (this._session.authorized) { - this.onConnectionUsable() - } else { + onConnected(): void { + // check if we have all the needed keys + if (!this._session._authKey.ready) { + if (!this.params.isMainConnection) { + this.log.info('no auth key, waiting for main connection') + + // once it is done, we will be notified + return + } + + this.log.info('no perm auth key, authorizing...') this._authorize() + + // if we use pfs, we *could* also start temp key exchange here + // but telegram restricts us to only have one auth session per connection, + // and having a separate connection for pfs is not worth it + return } + + if (this._usePfs && !this._session._authKeyTemp.ready) { + this.log.info('no temp auth key but using pfs, authorizing') + this._authorizePfs() + + return + } + + this.log.info('auth keys are already available') + this.onConnectionUsable() } protected onError(error: Error): void { // https://core.telegram.org/mtproto/mtproto-_transports#_transport-errors if (error instanceof TransportError) { - this.log.error('transport error %d', error.code) - if (error.code === 404) { - this._session.reset() + // if we are using pfs, this could be due to the server + // forgetting our temp key (which is kinda weird but expected) + + if (this._usePfs) { + if ( + !this._isPfsBindingPending && + this._session._authKeyTemp.ready + ) { + this.log.info('transport error 404, reauthorizing pfs') + + // this is important! we must reset temp auth key before + // we proceed with new temp key derivation. + // otherwise, we can end up in an infinite loop in case it + // was actually perm_key that got 404-ed + // + // if temp key binding is already in process in background, + // _authorizePfs will mark it as foreground to prevent new + // queries from being sent (to avoid even more 404s) + this._session._authKeyTemp.reset() + this._authorizePfs() + this._onAllFailed('temp key expired, binding started') + + return + } else if (this._isPfsBindingPending) { + this.log.info( + 'transport error 404, pfs binding in progress', + ) + + this._onAllFailed('temp key expired, binding pending') + + return + } + + // otherwise, 404 must be referencing the perm_key + this.log.info('transport error 404, reauthorizing') + } + + // there happened a little trolling + this._session.reset(true) this.emit('key-change', null) this._authorize() return } + this.log.error('transport error %d', error.code) + // all pending queries must be resent + this._onAllFailed(`transport error ${error.code}`) + if (error.code === 429) { - // all active queries must be resent - const timeout = this._next429Timeout - - this._next429Timeout = Math.min(this._next429Timeout * 2, 16000) - clearTimeout(this._current429Timeout!) - this._current429Timeout = setTimeout(() => { - this._current429Timeout = undefined - this._flushTimer.emitNow() - }, timeout) - - this.log.debug( - 'transport flood, waiting for %d ms before proceeding', - timeout, + this._session.onTransportFlood( + this.emit.bind(this, 'flood-done'), ) - for (const msgId of this._pendingMessages.keys()) { - const info = this._pendingMessages.get(msgId)! - - if (info._ === 'container') { - this._pendingMessages.delete(msgId) - } else { - this._onMessageFailed(msgId, 'transport flood', true) - } - } - return } } @@ -304,30 +248,297 @@ export class SessionConnection extends PersistentConnection { protected onConnectionUsable() { super.onConnectionUsable() + if (this.params.withUpdates) { + // we must send some user-related rpc to the server to make sure that + // it will send us updates + this.sendRpc({ _: 'updates.getState' }).catch((err: any) => { + this.log.warn( + 'failed to send updates.getState: %s', + err.text || err.message, + ) + }) + } + // just in case this._flushTimer.emitBeforeNext(1000) } - private _authorize(): void { + _authorize(): void { + if (this._session.authorizationPending) { + this.log.info('_authorize(): authorization already in progress') + + return + } + + if (!this.params.isMainConnection) { + // we don't authorize on non-main connections + this.log.debug('_authorize(): non-main connection, requesting...') + this.emit('request-auth') + + return + } + + this._session.authorizationPending = true + this.emit('auth-begin') + doAuthorization(this, this.params.crypto) .then(async ([authKey, serverSalt, timeOffset]) => { - await this._session.setupKeys(authKey) + await this._session._authKey.setup(authKey) this._session.serverSalt = serverSalt this._session._timeOffset = timeOffset + this._session.authorizationPending = false + this.emit('key-change', authKey) + if (this._usePfs) { + return this._authorizePfs() + } this.onConnectionUsable() }) .catch((err) => { + this._session.authorizationPending = false this.log.error('Authorization error: %s', err.message) this.onError(err) this.reconnect() }) } + private _authorizePfs(background = false): void { + if (this._isPfsBindingPending) return + + if (this._pfsUpdateTimeout) { + clearTimeout(this._pfsUpdateTimeout) + this._pfsUpdateTimeout = undefined + } + + if (this._isPfsBindingPendingInBackground) { + // e.g. temp key has expired while we were binding a key in the background + // in this case, we shouldn't start pfs binding again, and instead wait for + // current operation to complete + this._isPfsBindingPendingInBackground = false + this._isPfsBindingPending = true + + return + } + + if (background) { + this._isPfsBindingPendingInBackground = true + } else { + this._isPfsBindingPending = true + } + + doAuthorization(this, this.params.crypto, TEMP_AUTH_KEY_EXPIRY) + .then(async ([tempAuthKey, tempServerSalt]) => { + if (!this._usePfs) { + this.log.info( + 'pfs has been disabled while generating temp key', + ) + + return + } + + const tempKey = await this._session._authKeyTempSecondary + await tempKey.setup(tempAuthKey) + + const msgId = this._session.getMessageId() + + this.log.debug( + 'binding temp_auth_key (%h) to perm_auth_key (%h), msg_id = %l...', + tempKey.id, + this._session._authKey.id, + msgId, + ) + + // we now need to bind the key + const inner: mtp.RawMt_bind_auth_key_inner = { + _: 'mt_bind_auth_key_inner', + nonce: randomLong(), + tempAuthKeyId: longFromBuffer(tempKey.id), + permAuthKeyId: longFromBuffer(this._session._authKey.id), + tempSessionId: this._session._sessionId, + expiresAt: + Math.floor(Date.now() / 1000) + TEMP_AUTH_KEY_EXPIRY, + } + + // encrypt using mtproto v1 (fucking kill me plz) + + const writer = TlBinaryWriter.alloc(this.params.writerMap, 80) + // = 40 (inner length) + 32 (mtproto header) + 8 (pad 72 so mod 16 = 0) + + writer.raw(randomBytes(16)) + writer.long(msgId) + writer.int(0) // seq_no + writer.int(40) // msg_len + writer.object(inner) + + const msgWithoutPadding = writer.result() + writer.raw(randomBytes(8)) + const msgWithPadding = writer.result() + + const hash = await this.params.crypto.sha1(msgWithoutPadding) + const msgKey = hash.slice(4, 20) + + const ige = await createAesIgeForMessageOld( + this.params.crypto, + this._session._authKey.key, + msgKey, + true, + ) + const encryptedData = await ige.encrypt(msgWithPadding) + const encryptedMessage = Buffer.concat([ + this._session._authKey.id, + msgKey, + encryptedData, + ]) + + const promise = createControllablePromise< + mtp.RawMt_rpc_error | boolean + >() + + // encrypt the message using temp key and same msg id + // this is a bit of a hack, but it works + // + // hacking inside main send loop to allow sending + // with another key is just too much hassle. + // we could just always use temp key if one is available, + // but that way we won't be able to refresh the key + // that is about to expire in the background without + // interrupting actual message flow + // decrypting is trivial though, since key id + // is in the first bytes of the message, and is never used later on. + + this._session.pendingMessages.set(msgId, { + _: 'bind', + promise, + }) + + const request: tl.auth.RawBindTempAuthKeyRequest = { + _: 'auth.bindTempAuthKey', + permAuthKeyId: inner.permAuthKeyId, + nonce: inner.nonce, + expiresAt: inner.expiresAt, + encryptedMessage, + } + const reqSize = TlSerializationCounter.countNeededBytes( + this._writerMap, + request, + ) + const reqWriter = TlBinaryWriter.alloc( + this._writerMap, + reqSize + 16, + ) + reqWriter.long(this._registerOutgoingMsgId(msgId)) + reqWriter.uint(this._session.getSeqNo()) + reqWriter.uint(reqSize) + reqWriter.object(request) + + // we can now send it as is + const requestEncrypted = await tempKey.encryptMessage( + reqWriter.result(), + tempServerSalt, + this._session._sessionId, + ) + await this.send(requestEncrypted) + + const res = await promise + + this._session.pendingMessages.delete(msgId) + + if (!this._usePfs) { + this.log.info( + 'pfs has been disabled while binding temp key', + ) + + return + } + + if (typeof res === 'object') { + this.log.error( + 'failed to bind temp key: %s:%s', + res.errorCode, + res.errorMessage, + ) + throw new Error('Failed to bind temporary key') + } + + // now we can swap the keys (secondary becomes primary, + // and primary is not immediately forgot because messages using it may still be in flight) + + this._session._authKeyTempSecondary = this._session._authKeyTemp + this._session._authKeyTemp = tempKey + this._session.serverSalt = tempServerSalt + + this.log.debug( + 'temp key has been bound, exp = %d', + inner.expiresAt, + ) + + this._isPfsBindingPending = false + this._isPfsBindingPendingInBackground = false + + // we must re-init connection after binding temp key + this._session.initConnectionCalled = false + + this.emit('tmp-key-change', tempAuthKey, inner.expiresAt) + this.onConnectionUsable() + + // set a timeout to update temp auth key in advance to avoid interruption + this._pfsUpdateTimeout = setTimeout(() => { + this._pfsUpdateTimeout = undefined + this.log.debug('temp key is expiring soon') + this._authorizePfs(true) + }, (TEMP_AUTH_KEY_EXPIRY - 60) * 1000) + }) + .catch((err) => { + this.log.error('PFS Authorization error: %s', err.message) + + if (this._isPfsBindingPendingInBackground) { + this._isPfsBindingPendingInBackground = false + + // if we are in background, we can just retry + return this._authorizePfs(true) + } + + this._isPfsBindingPending = false + this.onError(err) + this.reconnect() + }) + } + + waitForUnencryptedMessage(timeout = 5000): Promise { + const promise = createControllablePromise() + const timeoutId = setTimeout(() => { + promise.reject(new Error('Timeout')) + this._pendingWaitForUnencrypted = + this._pendingWaitForUnencrypted.filter( + (it) => it[0] !== promise, + ) + }, timeout) + this._pendingWaitForUnencrypted.push([promise, timeoutId]) + + return promise + } + protected async onMessage(data: Buffer): Promise { - if (!this._session.authorized) { + if (data.readInt32LE(0) === 0 && data.readInt32LE(4) === 0) { + // auth_key_id = 0, meaning it's an unencrypted message used for authorization + + if (this._pendingWaitForUnencrypted.length) { + const [promise, timeout] = + this._pendingWaitForUnencrypted.shift()! + clearTimeout(timeout) + promise.resolve(data) + } else { + this.log.debug( + 'unencrypted message received, but no one is waiting for it', + ) + } + + return + } + + if (!this._session._authKey.ready) { // if a message is received before authorization, // either the server is misbehaving, // or there was a problem with authorization. @@ -372,23 +583,17 @@ export class SessionConnection extends PersistentConnection { for (let i = 0; i < count; i++) { // msg_id:long seqno:int bytes:int const msgId = message.long() - message.uint() // seqno + const seqNo = message.uint() // seqno const length = message.uint() - // container can't contain other containers, so we are safe - const start = message.pos - const obj = message.object() + // container can't contain other containers, but can contain rpc_result + const obj = message.raw(length) - // ensure length - if (message.pos - start !== length) { - this.log.warn( - 'received message with invalid length in container (%d != %d)', - message.pos - start, - length, - ) - } - - this._handleMessage(msgId, obj) + this._handleRawMessage( + msgId, + seqNo, + new TlBinaryReader(this._readerMap, obj), + ) } return @@ -398,7 +603,7 @@ export class SessionConnection extends PersistentConnection { // rpc_result message.uint() - return this._onRpcResult(message) + return this._onRpcResult(messageId, message) } // we are safe.. i guess @@ -415,15 +620,15 @@ export class SessionConnection extends PersistentConnection { return } - if (this._recentIncomingMsgIds.has(messageId)) { + if (this._session.recentIncomingMsgIds.has(messageId)) { this.log.warn('warn: ignoring duplicate message %s', messageId) return } const message = message_ as mtp.TlObject - this.log.verbose('received %s (msg_id: %s)', message._, messageId) - this._recentIncomingMsgIds.add(messageId) + this.log.debug('received %s (msg_id: %l)', message._, messageId) + this._session.recentIncomingMsgIds.add(messageId) switch (message._) { case 'mt_msgs_ack': @@ -484,12 +689,24 @@ export class SessionConnection extends PersistentConnection { message, ) break + case 'mt_destroy_session_ok': + case 'mt_destroy_session_none': + this._onDestroySessionResult(message) + break default: if (tl.isAnyUpdates(message)) { if (this._usable && this.params.inactivityTimeout) { this._rescheduleInactivity() } + if (this.params.disableUpdates) { + this.log.warn( + 'received updates, but updates are disabled', + ) + // likely due to some request in the session missing invokeWithoutUpdates + break + } + this.emit('update', message) return @@ -499,7 +716,7 @@ export class SessionConnection extends PersistentConnection { } } - private _onRpcResult(message: TlBinaryReader): void { + private _onRpcResult(messageId: Long, message: TlBinaryReader): void { if (this._usable && this.params.inactivityTimeout) { this._rescheduleInactivity() } @@ -515,14 +732,14 @@ export class SessionConnection extends PersistentConnection { resultType = message.peekUint() } this.log.warn( - 'received rpc_result with %s with req_msg_id = 0', + 'received rpc_result with %j with req_msg_id = 0', resultType, ) return } - const msg = this._pendingMessages.get(reqMsgId) + const msg = this._session.pendingMessages.get(reqMsgId) if (!msg) { let result @@ -532,15 +749,11 @@ export class SessionConnection extends PersistentConnection { } catch (err) { result = '[failed to parse]' } - this.log.warn( - 'received rpc_result with %s with req_msg_id = 0', - result, - ) // check if the msg is one of the recent ones - if (this._recentOutgoingMsgIds.has(reqMsgId)) { + if (this._session.recentOutgoingMsgIds.has(reqMsgId)) { this.log.debug( - 'received rpc_result again for %l (contains %s)', + 'received rpc_result again for %l (contains %j)', reqMsgId, result, ) @@ -555,7 +768,17 @@ export class SessionConnection extends PersistentConnection { return } + this._sendAck(messageId) + + // special case for auth key binding if (msg._ !== 'rpc') { + if (msg._ === 'bind') { + this._sendAck(messageId) + msg.promise.resolve(message.object()) + + return + } + this.log.error( 'received rpc_result for %s request %l', msg._, @@ -564,6 +787,7 @@ export class SessionConnection extends PersistentConnection { return } + const rpc = msg.rpc const customReader = this._readerMap._results![rpc.method] @@ -573,7 +797,9 @@ export class SessionConnection extends PersistentConnection { // initConnection call was definitely received and // processed by the server, so we no longer need to use it - if (rpc.initConn) this._initConnectionCalled = true + if (rpc.initConn) { + this._session.initConnectionCalled = true + } this.log.verbose('<<< (%s) %j', rpc.method, result) @@ -587,6 +813,46 @@ export class SessionConnection extends PersistentConnection { rpc.method, ) + if (res.errorMessage === 'AUTH_KEY_PERM_EMPTY') { + // happens when temp auth key is not yet bound + // this shouldn't happen as we block any outbound communications + // until the temp key is derived and bound. + // + // i think it is also possible for the error to be returned + // when the temp key has expired, but this still shouldn't happen + // but this is tg, so something may go wrong, and we will receive this as an error + // (for god's sake why is this not in mtproto and instead hacked into the app layer) + this._authorizePfs() + this._onMessageFailed(reqMsgId, 'AUTH_KEY_PERM_EMPTY', true) + + return + } + + if (res.errorMessage === 'CONNECTION_NOT_INITED') { + // this seems to sometimes happen when using pfs + // no idea why, but tdlib also seems to handle these, so whatever + + this._session.initConnectionCalled = false + this._onMessageFailed(reqMsgId, res.errorMessage, true) + + // just setting this flag is not enough because the message + // is already serialized, so we do this awesome hack + this.sendRpc({ _: 'help.getNearestDc' }) + .then(() => { + this.log.debug( + 'additional help.getNearestDc for initConnection ok', + ) + }) + .catch((err) => { + this.log.debug( + 'additional help.getNearestDc for initConnection error: %s', + err, + ) + }) + + return + } + if (rpc.cancelled) return const error = tl.errors.createRpcErrorFromTl(res) @@ -610,14 +876,16 @@ export class SessionConnection extends PersistentConnection { } this._onMessageAcked(reqMsgId) - this._pendingMessages.delete(reqMsgId) + this._session.pendingMessages.delete(reqMsgId) } private _onMessageAcked(msgId: Long, inContainer = false): void { - const msg = this._pendingMessages.get(msgId) + const msg = this._session.pendingMessages.get(msgId) if (!msg) { - this.log.warn('received ack for unknown message %l', msgId) + if (!this._session.recentOutgoingMsgIds.has(msgId)) { + this.log.warn('received ack for unknown message %l', msgId) + } return } @@ -633,7 +901,7 @@ export class SessionConnection extends PersistentConnection { msg.msgIds.forEach((msgId) => this._onMessageAcked(msgId, true)) // we no longer need info about the container - this._pendingMessages.delete(msgId) + this._session.pendingMessages.delete(msgId) break case 'rpc': { @@ -650,21 +918,24 @@ export class SessionConnection extends PersistentConnection { if ( !inContainer && rpc.containerId && - this._pendingMessages.has(rpc.containerId) + this._session.pendingMessages.has(rpc.containerId) ) { // ack all the messages in that container this._onMessageAcked(rpc.containerId) } // this message could also already be in some queue, - removeFromLongArray(this._queuedStateReq, msgId) - removeFromLongArray(this._queuedResendReq, msgId) + removeFromLongArray(this._session.queuedStateReq, msgId) + removeFromLongArray(this._session.queuedResendReq, msgId) // if resend/state was already requested, it will simply be ignored - this._getStateSchedule.remove(rpc) + this._session.getStateSchedule.remove(rpc) break } + case 'bind': + break // do nothing, wait for the result + default: if (!inContainer) { this.log.warn( @@ -676,12 +947,36 @@ export class SessionConnection extends PersistentConnection { } } + private _onAllFailed(reason: string) { + // called when all the pending messages are to be resent + // e.g. when server returns 429 + + // most service messages can be omitted as stale + + for (const msgId of this._session.pendingMessages.keys()) { + const info = this._session.pendingMessages.get(msgId)! + + switch (info._) { + case 'container': + case 'state': + case 'resend': + case 'ping': + // no longer relevant + this._session.pendingMessages.delete(msgId) + break + default: + this._onMessageFailed(msgId, reason, true) + break + } + } + } + private _onMessageFailed( msgId: Long, reason: string, inContainer = false, ): void { - const msgInfo = this._pendingMessages.get(msgId) + const msgInfo = this._session.pendingMessages.get(msgId) if (!msgInfo) { this.log.debug( @@ -712,7 +1007,7 @@ export class SessionConnection extends PersistentConnection { reason, ) // restart ping - this._resetLastPing(true) + this._session.resetLastPing(true) break case 'rpc': { @@ -725,26 +1020,26 @@ export class SessionConnection extends PersistentConnection { ) // since the query was rejected, we can let it reassign msg_id to avoid containers - this._pendingMessages.delete(msgId) + this._session.pendingMessages.delete(msgId) rpc.msgId = undefined this._enqueueRpc(rpc, true) if ( !inContainer && rpc.containerId && - this._pendingMessages.has(rpc.containerId) + this._session.pendingMessages.has(rpc.containerId) ) { // fail all the messages in that container this._onMessageFailed(rpc.containerId, reason) } // this message could also already be in some queue, - removeFromLongArray(this._queuedStateReq, msgId) - removeFromLongArray(this._queuedResendReq, msgId) + removeFromLongArray(this._session.queuedStateReq, msgId) + removeFromLongArray(this._session.queuedResendReq, msgId) // if resend/state was already requested, it will simply be ignored - this._getStateSchedule.remove(rpc) + this._session.getStateSchedule.remove(rpc) break } @@ -755,7 +1050,7 @@ export class SessionConnection extends PersistentConnection { msgInfo.msgIds.length, reason, ) - this._queuedResendReq.splice(0, 0, ...msgInfo.msgIds) + this._session.queuedResendReq.splice(0, 0, ...msgInfo.msgIds) this._flushTimer.emitWhenIdle() break case 'state': @@ -765,32 +1060,29 @@ export class SessionConnection extends PersistentConnection { msgInfo.msgIds.length, reason, ) - this._queuedStateReq.splice(0, 0, ...msgInfo.msgIds) + this._session.queuedStateReq.splice(0, 0, ...msgInfo.msgIds) this._flushTimer.emitWhenIdle() break + case 'bind': + this.log.debug( + 'temp key binding request %l failed because of %s, retrying', + msgId, + reason, + ) + msgInfo.promise.reject(Error(reason)) } - this._pendingMessages.delete(msgId) - } - - private _resetLastPing(withTime = false): void { - if (withTime) this._lastPingTime = 0 - - if (!this._lastPingMsgId.isZero()) { - this._pendingMessages.delete(this._lastPingMsgId) - } - - this._lastPingMsgId = Long.ZERO + this._session.pendingMessages.delete(msgId) } private _registerOutgoingMsgId(msgId: Long): Long { - this._recentOutgoingMsgIds.add(msgId) + this._session.recentOutgoingMsgIds.add(msgId) return msgId } private _onPong({ msgId, pingId }: mtp.RawMt_pong): void { - const info = this._pendingMessages.get(msgId) + const info = this._session.pendingMessages.get(msgId) if (!info) { this.log.warn( @@ -822,8 +1114,8 @@ export class SessionConnection extends PersistentConnection { ) } - const rtt = Date.now() - this._lastPingTime - this._lastPingRtt = rtt + const rtt = Date.now() - this._session.lastPingTime + this._session.lastPingRtt = rtt if (info.containerId.neq(msgId)) { this._onMessageAcked(info.containerId) @@ -835,7 +1127,7 @@ export class SessionConnection extends PersistentConnection { pingId, rtt, ) - this._resetLastPing() + this._session.resetLastPing() } private _onBadServerSalt(msg: mtp.RawMt_bad_server_salt): void { @@ -878,6 +1170,8 @@ export class SessionConnection extends PersistentConnection { // something went very wrong, we need to reset the session this.log.error( 'received bad_msg_notification for msg_id = %l, code = %d. session will be reset', + msg.badMsgId, + msg.errorCode, ) this._resetSession() break @@ -889,7 +1183,7 @@ export class SessionConnection extends PersistentConnection { serverSalt, uniqueId, }: mtp.RawMt_new_session_created): void { - if (uniqueId.eq(this._lastSessionCreatedUid)) { + if (uniqueId.eq(this._session.lastSessionCreatedUid)) { this.log.debug( 'received new_session_created with the same uid = %l, ignoring', uniqueId, @@ -899,7 +1193,7 @@ export class SessionConnection extends PersistentConnection { } if ( - !this._lastSessionCreatedUid.isZero() && + !this._session.lastSessionCreatedUid.isZero() && !this.params.disableUpdates ) { // force the client to fetch missed updates @@ -916,14 +1210,22 @@ export class SessionConnection extends PersistentConnection { firstMsgId, ) - for (const msgId of this._pendingMessages.keys()) { - const val = this._pendingMessages.get(msgId)! + for (const msgId of this._session.pendingMessages.keys()) { + const val = this._session.pendingMessages.get(msgId)! + + if (val._ === 'bind') { + // should NOT happen. + if (msgId.lt(firstMsgId)) { + this._onMessageFailed(msgId, 'received in wrong session') + } + continue + } if (val._ === 'container') { if (msgId.lt(firstMsgId)) { // all messages in this container will be resent // info about this container is no longer needed - this._pendingMessages.delete(msgId) + this._session.pendingMessages.delete(msgId) } return @@ -944,13 +1246,15 @@ export class SessionConnection extends PersistentConnection { answerMsgId: Long, ): void { if (!msgId.isZero()) { - const info = this._pendingMessages.get(msgId) + const info = this._session.pendingMessages.get(msgId) if (!info) { - this.log.info( - 'received message info about unknown message %l', - msgId, - ) + if (!this._session.recentOutgoingMsgIds.has(msgId)) { + this.log.warn( + 'received message info about unknown message %l', + msgId, + ) + } return } @@ -986,14 +1290,14 @@ export class SessionConnection extends PersistentConnection { if ( !answerMsgId.isZero() && - !this._recentIncomingMsgIds.has(answerMsgId) + !this._session.recentIncomingMsgIds.has(answerMsgId) ) { this.log.debug( 'received message info for %l, but answer (%l) was not received yet', msgId, answerMsgId, ) - this._queuedResendReq.push(answerMsgId) + this._session.queuedResendReq.push(answerMsgId) this._flushTimer.emitWhenIdle() return @@ -1019,7 +1323,7 @@ export class SessionConnection extends PersistentConnection { } private _onMsgsStateInfo(msg: mtp.RawMt_msgs_state_info): void { - const info = this._pendingMessages.get(msg.reqMsgId) + const info = this._session.pendingMessages.get(msg.reqMsgId) if (!info) { this.log.warn( @@ -1043,45 +1347,51 @@ export class SessionConnection extends PersistentConnection { this._onMessagesInfo(info.msgIds, msg.info) } - private _enqueueRpc(rpc: PendingRpc, force?: boolean) { - // already queued or cancelled - if ((!force && !rpc.sent) || rpc.cancelled) return - - rpc.sent = false - rpc.containerId = undefined - this.log.debug( - 'enqueued %s for sending (msg_id = %s)', - rpc.method, - rpc.msgId || 'n/a', + private _onDestroySessionResult(msg: mtp.TypeDestroySessionRes): void { + const reqMsgId = this._session.destroySessionIdToMsgId.get( + msg.sessionId, ) - this._queuedRpc.pushBack(rpc) - this._flushTimer.emitWhenIdle() + if (!reqMsgId) { + this.log.warn( + 'received %s for unknown session %h', + msg._, + msg.sessionId, + ) + + return + } + + this._session.destroySessionIdToMsgId.delete(msg.sessionId) + this._session.pendingMessages.delete(reqMsgId) + this.log.debug('received %s for session %h', msg._, msg.sessionId) + } + + private _enqueueRpc(rpc: PendingRpc, force?: boolean) { + if (this._session.enqueueRpc(rpc, force)) { + this._flushTimer.emitWhenIdle() + } } _resetSession(): void { this._queuedDestroySession.push(this._session._sessionId) + this._session.resetState(true) this.reconnect() - this._session.changeSessionId() - this.log.debug('session reset, new sid = %l', this._session._sessionId) // once we receive new_session_created, all pending messages will be resent. - // clear getState/resend queues because they are not needed anymore - this._queuedStateReq.length = 0 - this._queuedResendReq.length = 0 this._flushTimer.reset() } private _sendAck(msgId: Long): void { - if (this._queuedAcks.length === 0) { + if (this._session.queuedAcks.length === 0) { this._flushTimer.emitBeforeNext(30000) } - this._queuedAcks.push(msgId) + this._session.queuedAcks.push(msgId) - if (this._queuedAcks.length >= 100) { - this._flushTimer.emitNow() + if (this._session.queuedAcks.length >= 100) { + this._flushTimer.emitWhenIdle() } } @@ -1110,7 +1420,7 @@ export class SessionConnection extends PersistentConnection { } } - if (!this._initConnectionCalled) { + if (!this._session.initConnectionCalled) { // we will wrap every rpc call with initConnection // until some of the requests wrapped with it is // either acked or returns rpc_result @@ -1120,11 +1430,13 @@ export class SessionConnection extends PersistentConnection { method, this.params.layer, ) + const proxy = this._transport.getMtproxyInfo?.() obj = { _: 'invokeWithLayer', layer: this.params.layer, query: { ...this.params.initConnection, + proxy, query: obj, }, } @@ -1239,17 +1551,47 @@ export class SessionConnection extends PersistentConnection { rpc.cancelled = true if (rpc.msgId) { - this._queuedCancelReq.push(rpc.msgId) + this._session.queuedCancelReq.push(rpc.msgId) this._flushTimer.emitWhenIdle() } else { // in case rpc wasn't sent yet (or had some error), // we can simply remove it from queue - this._queuedRpc.remove(rpc) + this._session.queuedRpc.remove(rpc) } } + protected _onInactivityTimeout() { + // we should send all pending acks and other service messages + // before dropping the connection + + if (!this._session.hasPendingMessages) { + this.log.debug('no pending service messages, closing connection') + super._onInactivityTimeout() + + return + } + + this._inactivityPendingFlush = true + this._flush() + } + + flushWhenIdle(): void { + this._flushTimer.emitWhenIdle() + } + private _flush(): void { - if (!this._session.authorized || this._current429Timeout) { + if ( + !this._session._authKey.ready || + this._isPfsBindingPending || + this._session.current429Timeout + ) { + this.log.debug( + 'skipping flush, connection is not usable (auth key ready = %b, pfs binding pending = %b, 429 timeout = %b)', + this._session._authKey.ready, + this._isPfsBindingPending, + Boolean(this._session.current429Timeout), + ) + // it will be flushed once connection is usable return } @@ -1264,22 +1606,25 @@ export class SessionConnection extends PersistentConnection { // schedule next flush // if there are more queued requests, flush immediately // (they likely didn't fit into one message) - if ( - this._queuedRpc.length || - this._queuedAcks.length || - this._queuedStateReq.length || - this._queuedResendReq.length - ) { - this._flush() + if (this._session.hasPendingMessages) { + // we schedule it on the next tick, so we can load-balance + // between multiple connections using the same session + this._flushTimer.emitWhenIdle() + } else if (this._inactivityPendingFlush) { + this.log.debug('pending messages sent, closing connection') + this._flushTimer.reset() + this._inactivityPendingFlush = false + + super._onInactivityTimeout() } else { - this._flushTimer.emitBefore(this._lastPingTime + 60000) + this._flushTimer.emitBefore(this._session.lastPingTime + 60000) } } private _doFlush(): void { this.log.debug( 'flushing send queue. queued rpc: %d', - this._queuedRpc.length, + this._session.queuedRpc.length, ) // oh bloody hell mate @@ -1312,14 +1657,14 @@ export class SessionConnection extends PersistentConnection { const now = Date.now() - if (this._queuedAcks.length) { - let acks = this._queuedAcks + if (this._session.queuedAcks.length) { + let acks = this._session.queuedAcks if (acks.length > 8192) { - this._queuedAcks = acks.slice(8192) + this._session.queuedAcks = acks.slice(8192) acks = acks.slice(0, 8192) } else { - this._queuedAcks = [] + this._session.queuedAcks = [] } const obj: mtp.RawMt_msgs_ack = { @@ -1335,13 +1680,15 @@ export class SessionConnection extends PersistentConnection { const getStateTime = now + 1500 - if (now - this._lastPingTime > 60000) { - if (!this._lastPingMsgId.isZero()) { + if (now - this._session.lastPingTime > 60000) { + if (!this._session.lastPingMsgId.isZero()) { this.log.warn( "didn't receive pong for previous ping (msg_id = %l)", - this._lastPingMsgId, + this._session.lastPingMsgId, + ) + this._session.pendingMessages.delete( + this._session.lastPingMsgId, ) - this._pendingMessages.delete(this._lastPingMsgId) } pingId = randomLong() @@ -1350,7 +1697,7 @@ export class SessionConnection extends PersistentConnection { pingId, } - this._lastPingTime = Date.now() + this._session.lastPingTime = Date.now() pingRequest = TlBinaryWriter.serializeObject(this._writerMap, obj) containerSize += pingRequest.length + 16 @@ -1358,25 +1705,28 @@ export class SessionConnection extends PersistentConnection { } { - if (this._queuedStateReq.length) { - let ids = this._queuedStateReq + if (this._session.queuedStateReq.length) { + let ids = this._session.queuedStateReq if (ids.length > 8192) { - this._queuedStateReq = ids.slice(8192) + this._session.queuedStateReq = ids.slice(8192) ids = ids.slice(0, 8192) } else { - this._queuedStateReq = [] + this._session.queuedStateReq = [] } getStateMsgIds = ids } - const idx = this._getStateSchedule.index( + const idx = this._session.getStateSchedule.index( { getState: now } as any, true, ) if (idx > 0) { - const toGetState = this._getStateSchedule.raw.splice(0, idx) + const toGetState = this._session.getStateSchedule.raw.splice( + 0, + idx, + ) if (!getStateMsgIds) getStateMsgIds = [] toGetState.forEach((it) => getStateMsgIds!.push(it.msgId!)) } @@ -1396,14 +1746,14 @@ export class SessionConnection extends PersistentConnection { } } - if (this._queuedResendReq.length) { - resendMsgIds = this._queuedResendReq + if (this._session.queuedResendReq.length) { + resendMsgIds = this._session.queuedResendReq if (resendMsgIds.length > 8192) { - this._queuedResendReq = resendMsgIds.slice(8192) + this._session.queuedResendReq = resendMsgIds.slice(8192) resendMsgIds = resendMsgIds.slice(0, 8192) } else { - this._queuedResendReq = [] + this._session.queuedResendReq = [] } const obj: mtp.RawMt_msg_resend_req = { @@ -1416,16 +1766,16 @@ export class SessionConnection extends PersistentConnection { messageCount += 1 } - if (this._queuedCancelReq.length) { - containerMessageCount += this._queuedCancelReq.length - containerSize += this._queuedCancelReq.length * 28 - cancelRpcs = this._queuedCancelReq - this._queuedCancelReq = [] + if (this._session.queuedCancelReq.length) { + containerMessageCount += this._session.queuedCancelReq.length + containerSize += this._session.queuedCancelReq.length * 28 + cancelRpcs = this._session.queuedCancelReq + this._session.queuedCancelReq = [] } if (this._queuedDestroySession.length) { - containerMessageCount += this._queuedCancelReq.length - containerSize += this._queuedCancelReq.length * 28 + containerMessageCount += this._queuedDestroySession.length + containerSize += this._queuedDestroySession.length * 28 destroySessions = this._queuedDestroySession this._queuedDestroySession = [] } @@ -1434,11 +1784,11 @@ export class SessionConnection extends PersistentConnection { const rpcToSend: PendingRpc[] = [] while ( - this._queuedRpc.length && + this._session.queuedRpc.length && containerSize < 32768 && // 2^15 containerMessageCount < 1020 ) { - const msg = this._queuedRpc.popFront()! + const msg = this._session.queuedRpc.popFront()! if (msg.cancelled) continue // note: we don't check for <2^15 here @@ -1452,13 +1802,17 @@ export class SessionConnection extends PersistentConnection { // if message was already assigned a msg_id, // we must wrap it in a container with a newer msg_id if (msg.msgId) forceContainer = true + + // having >1 upload.getFile within a container seems to cause flood_wait errors + // also a crutch for load-balancing + if (msg.method === 'upload.getFile') break } packetSize += containerSize messageCount += containerMessageCount + rpcToSend.length if (!messageCount) { - this.log.debug('flush failed: nothing to flush') + this.log.debug('flush did not happen: nothing to flush') return } @@ -1478,7 +1832,7 @@ export class SessionConnection extends PersistentConnection { const otherPendings: Exclude< PendingMessage, - { _: 'rpc' | 'container' } + { _: 'rpc' | 'container' | 'bind' } >[] = [] if (ackRequest) { @@ -1491,13 +1845,13 @@ export class SessionConnection extends PersistentConnection { pingMsgId = this._registerOutgoingMsgId( this._session.writeMessage(writer, pingRequest), ) - this._lastPingMsgId = pingMsgId + this._session.lastPingMsgId = pingMsgId const pingPending: PendingMessage = { _: 'ping', pingId: pingId!, containerId: pingMsgId, } - this._pendingMessages.set(pingMsgId, pingPending) + this._session.pendingMessages.set(pingMsgId, pingPending) otherPendings.push(pingPending) } @@ -1510,7 +1864,7 @@ export class SessionConnection extends PersistentConnection { msgIds: getStateMsgIds!, containerId: getStateMsgId, } - this._pendingMessages.set(getStateMsgId, getStatePending) + this._session.pendingMessages.set(getStateMsgId, getStatePending) otherPendings.push(getStatePending) } @@ -1523,7 +1877,7 @@ export class SessionConnection extends PersistentConnection { msgIds: resendMsgIds!, containerId: resendMsgId, } - this._pendingMessages.set(resendMsgId, resendPending) + this._session.pendingMessages.set(resendMsgId, resendPending) otherPendings.push(resendPending) } @@ -1541,7 +1895,7 @@ export class SessionConnection extends PersistentConnection { msgId, containerId: cancelMsgId, } - this._pendingMessages.set(cancelMsgId, pending) + this._session.pendingMessages.set(cancelMsgId, pending) otherPendings.push(pending) }) } @@ -1560,7 +1914,8 @@ export class SessionConnection extends PersistentConnection { sessionId, containerId: msgId, } - this._pendingMessages.set(msgId, pending) + this._session.pendingMessages.set(msgId, pending) + this._session.destroySessionIdToMsgId.set(sessionId, msgId) otherPendings.push(pending) }) } @@ -1584,7 +1939,7 @@ export class SessionConnection extends PersistentConnection { msg.msgId = msgId msg.seqNo = seqNo - this._pendingMessages.set(msgId, { + this._session.pendingMessages.set(msgId, { _: 'rpc', rpc: msg, }) @@ -1599,11 +1954,11 @@ export class SessionConnection extends PersistentConnection { // (re-)schedule get_state if needed if (msg.getState) { - this._getStateSchedule.remove(msg) + this._session.getStateSchedule.remove(msg) } if (!msg.acked) { msg.getState = getStateTime - this._getStateSchedule.insert(msg) + this._session.getStateSchedule.insert(msg) } writer.long(this._registerOutgoingMsgId(msg.msgId)) @@ -1650,7 +2005,10 @@ export class SessionConnection extends PersistentConnection { }) } - this._pendingMessages.set(containerId, { _: 'container', msgIds }) + this._session.pendingMessages.set(containerId, { + _: 'container', + msgIds, + }) } const result = writer.result() @@ -1685,7 +2043,9 @@ export class SessionConnection extends PersistentConnection { ) // put acks in the front so they are the first to be sent - if (ackMsgIds) this._queuedAcks.splice(0, 0, ...ackMsgIds) + if (ackMsgIds) { + this._session.queuedAcks.splice(0, 0, ...ackMsgIds) + } this._onMessageFailed(rootMsgId, 'unknown error') }) } diff --git a/packages/core/src/network/transports/abstract.ts b/packages/core/src/network/transports/abstract.ts index d0399746..435f0718 100644 --- a/packages/core/src/network/transports/abstract.ts +++ b/packages/core/src/network/transports/abstract.ts @@ -58,6 +58,8 @@ export interface ITelegramTransport extends EventEmitter { * This method is called before any other. */ setup?(crypto: ICryptoProvider, log: Logger): void + + getMtproxyInfo?(): tl.RawInputClientProxy } /** Transport factory function */ diff --git a/packages/core/src/network/transports/tcp.ts b/packages/core/src/network/transports/tcp.ts index 95f4b70e..d0b2b6d2 100644 --- a/packages/core/src/network/transports/tcp.ts +++ b/packages/core/src/network/transports/tcp.ts @@ -48,7 +48,9 @@ export abstract class BaseTcpTransport // eslint-disable-next-line @typescript-eslint/no-unused-vars connect(dc: tl.RawDcOption, testMode: boolean): void { - if (this._state !== TransportState.Idle) { throw new Error('Transport is not IDLE') } + if (this._state !== TransportState.Idle) { + throw new Error('Transport is not IDLE') + } if (!this.packetCodecInitialized) { this._packetCodec.setup?.(this._crypto, this.log) @@ -69,7 +71,9 @@ export abstract class BaseTcpTransport this.handleConnect.bind(this), ) - this._socket.on('data', (data) => this._packetCodec.feed(data)) + this._socket.on('data', (data) => { + this._packetCodec.feed(data) + }) this._socket.on('error', this.handleError.bind(this)) this._socket.on('close', this.close.bind(this)) } @@ -87,7 +91,7 @@ export abstract class BaseTcpTransport this._packetCodec.reset() } - async handleError(error: Error): Promise { + handleError(error: Error): void { this.log.error('error: %s', error.stack) this.emit('error', error) } @@ -99,7 +103,11 @@ export abstract class BaseTcpTransport if (initialMessage.length) { this._socket!.write(initialMessage, (err) => { if (err) { - this.emit('error', err) + this.log.error( + 'failed to write initial message: %s', + err.stack, + ) + this.emit('error') this.close() } else { this._state = TransportState.Ready @@ -113,12 +121,20 @@ export abstract class BaseTcpTransport } async send(bytes: Buffer): Promise { - if (this._state !== TransportState.Ready) { throw new Error('Transport is not READY') } + if (this._state !== TransportState.Ready) { + throw new Error('Transport is not READY') + } const framed = await this._packetCodec.encode(bytes) - return new Promise((res, rej) => { - this._socket!.write(framed, (err) => (err ? rej(err) : res())) + return new Promise((resolve, reject) => { + this._socket!.write(framed, (error) => { + if (error) { + reject(error) + } else { + resolve() + } + }) }) } } diff --git a/packages/core/src/storage/abstract.ts b/packages/core/src/storage/abstract.ts index c80ded2d..88d4ae5b 100644 --- a/packages/core/src/storage/abstract.ts +++ b/packages/core/src/storage/abstract.ts @@ -78,12 +78,27 @@ export interface ITelegramStorage { /** * Get auth_key for a given DC * (returning null will start authorization) + * For temp keys: should also return null if the key has expired + * + * @param dcId DC ID + * @param tempIndex Index of the temporary key (usually 0, used for multi-connections) */ - getAuthKeyFor(dcId: number): MaybeAsync + getAuthKeyFor(dcId: number, tempIndex?: number): MaybeAsync /** * Set auth_key for a given DC */ setAuthKeyFor(dcId: number, key: Buffer | null): MaybeAsync + /** + * Set temp_auth_key for a given DC + * expiresAt is unix time in ms + */ + setTempAuthKeyFor(dcId: number, index: number, key: Buffer | null, expiresAt: number): MaybeAsync + /** + * Remove all saved auth keys (both temp and perm) + * for the given DC. Used when perm_key becomes invalid, + * meaning all temp_keys also become invalid + */ + dropAuthKeysFor(dcId: number): MaybeAsync /** * Get information about currently logged in user (if available) diff --git a/packages/core/src/storage/memory.ts b/packages/core/src/storage/memory.ts index 9fff5a4d..27dbff58 100644 --- a/packages/core/src/storage/memory.ts +++ b/packages/core/src/storage/memory.ts @@ -15,6 +15,8 @@ export interface MemorySessionState { defaultDc: tl.RawDcOption | null authKeys: Record + authKeysTemp: Record + authKeysTempExpiry: Record // marked peer id -> entity info entities: Record @@ -110,6 +112,8 @@ export class MemoryStorage implements ITelegramStorage, IStateStorage { $version: CURRENT_VERSION, defaultDc: null, authKeys: {}, + authKeysTemp: {}, + authKeysTempExpiry: {}, entities: {}, phoneIndex: {}, usernameIndex: {}, @@ -187,14 +191,43 @@ export class MemoryStorage implements ITelegramStorage, IStateStorage { this._state.defaultDc = dc } + setTempAuthKeyFor( + dcId: number, + index: number, + key: Buffer | null, + expiresAt: number, + ): void { + const k = `${dcId}:${index}` + this._state.authKeysTemp[k] = key + this._state.authKeysTempExpiry[k] = expiresAt + } + setAuthKeyFor(dcId: number, key: Buffer | null): void { this._state.authKeys[dcId] = key } - getAuthKeyFor(dcId: number): Buffer | null { + getAuthKeyFor(dcId: number, tempIndex?: number): Buffer | null { + if (tempIndex !== undefined) { + const k = `${dcId}:${tempIndex}` + + if (Date.now() > (this._state.authKeysTempExpiry[k] ?? 0)) { return null } + + return this._state.authKeysTemp[k] + } + return this._state.authKeys[dcId] ?? null } + dropAuthKeysFor(dcId: number): void { + this._state.authKeys[dcId] = null + Object.keys(this._state.authKeysTemp).forEach((key) => { + if (key.startsWith(`${dcId}:`)) { + delete this._state.authKeysTemp[key] + delete this._state.authKeysTempExpiry[key] + } + }) + } + updatePeers(peers: PeerInfoWithUpdated[]): MaybeAsync { for (const peer of peers) { this._cachedFull.set(peer.id, peer.full) diff --git a/packages/core/src/utils/bigint-utils.ts b/packages/core/src/utils/bigint-utils.ts index 8d50b125..d5a7f65d 100644 --- a/packages/core/src/utils/bigint-utils.ts +++ b/packages/core/src/utils/bigint-utils.ts @@ -16,7 +16,9 @@ export function bigIntToBuffer( ): Buffer { const array = value.toArray(256).value - if (length !== 0 && array.length > length) { throw new Error('Value out of bounds') } + if (length !== 0 && array.length > length) { + throw new Error('Value out of bounds') + } if (length !== 0) { // padding @@ -60,6 +62,23 @@ export function randomBigInt(size: number): BigInteger { return bufferToBigInt(randomBytes(size)) } +/** + * Generate a random big integer of the given size (in bits) + * @param bits + */ +export function randomBigIntBits(bits: number): BigInteger { + let num = randomBigInt(Math.ceil(bits / 8)) + + const bitLength = num.bitLength() + + if (bitLength.gt(bits)) { + const toTrim = bigInt.randBetween(bitLength.minus(bits), 8) + num = num.shiftRight(toTrim) + } + + return num +} + /** * Generate a random big integer in the range [min, max) * @@ -80,3 +99,20 @@ export function randomBigIntInRange( return min.plus(result) } + +/** + * Compute the multiplicity of 2 in the prime factorization of n + * @param n + */ +export function twoMultiplicity(n: BigInteger): BigInteger { + if (n === bigInt.zero) return bigInt.zero + + let m = bigInt.zero + let pow = bigInt.one + + while (true) { + if (!n.and(pow).isZero()) return m + m = m.plus(bigInt.one) + pow = pow.shiftLeft(1) + } +} diff --git a/packages/core/src/utils/crypto/abstract.ts b/packages/core/src/utils/crypto/abstract.ts index 30291113..78d1e022 100644 --- a/packages/core/src/utils/crypto/abstract.ts +++ b/packages/core/src/utils/crypto/abstract.ts @@ -8,12 +8,6 @@ export interface IEncryptionScheme { decrypt(data: Buffer): MaybeAsync } -export interface IHashMethod { - update(data: Buffer): MaybeAsync - - digest(): MaybeAsync -} - export interface ICryptoProvider { initialize?(): MaybeAsync @@ -38,8 +32,6 @@ export interface ICryptoProvider { createAesEcb(key: Buffer): IEncryptionScheme - createMd5(): IHashMethod - factorizePQ(pq: Buffer): MaybeAsync<[Buffer, Buffer]> } diff --git a/packages/core/src/utils/crypto/forge-crypto.ts b/packages/core/src/utils/crypto/forge-crypto.ts index 16808abd..1407c817 100644 --- a/packages/core/src/utils/crypto/forge-crypto.ts +++ b/packages/core/src/utils/crypto/forge-crypto.ts @@ -3,7 +3,6 @@ import { BaseCryptoProvider, ICryptoProvider, IEncryptionScheme, - IHashMethod, } from './abstract' // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -108,15 +107,6 @@ export class ForgeCryptoProvider ) } - createMd5(): IHashMethod { - const hash = forge.md.md5.create() - - return { - update: (data) => hash.update(data.toString('binary')), - digest: () => Buffer.from(hash.digest().data, 'binary'), - } - } - hmacSha256(data: Buffer, key: Buffer): MaybeAsync { const hmac = forge.hmac.create() hmac.start('sha256', key.toString('binary')) diff --git a/packages/core/src/utils/crypto/miller-rabin.ts b/packages/core/src/utils/crypto/miller-rabin.ts new file mode 100644 index 00000000..f7cd62d6 --- /dev/null +++ b/packages/core/src/utils/crypto/miller-rabin.ts @@ -0,0 +1,43 @@ +import bigInt, { BigInteger } from 'big-integer' + +import { randomBigIntBits, twoMultiplicity } from '../bigint-utils' + +export function millerRabin(n: BigInteger, rounds = 20): boolean { + // small numbers: 0, 1 are not prime, 2, 3 are prime + if (n.lt(bigInt[4])) return n.gt(bigInt[1]) + if (n.isEven() || n.isNegative()) return false + + const nBits = n.bitLength().toJSNumber() + const nSub = n.minus(1) + + const r = twoMultiplicity(nSub) + const d = nSub.shiftRight(r) + + for (let i = 0; i < rounds; i++) { + let base + + do { + base = randomBigIntBits(nBits) + } while (base.leq(bigInt.one) || base.geq(nSub)) + + let x = base.modPow(d, n) + if (x.eq(bigInt.one) || x.eq(nSub)) continue + + let i = bigInt.zero + let y: BigInteger + + while (i.lt(r)) { + y = x.modPow(bigInt[2], n) + + if (x.eq(bigInt.one)) return false + if (x.eq(nSub)) break + i = i.plus(bigInt.one) + + x = y + } + + if (i.eq(r)) return false + } + + return true +} diff --git a/packages/core/src/utils/crypto/node-crypto.ts b/packages/core/src/utils/crypto/node-crypto.ts index 7b09e61b..0d802649 100644 --- a/packages/core/src/utils/crypto/node-crypto.ts +++ b/packages/core/src/utils/crypto/node-crypto.ts @@ -11,7 +11,6 @@ import { BaseCryptoProvider, ICryptoProvider, IEncryptionScheme, - IHashMethod, } from './abstract' export class NodeCryptoProvider @@ -83,10 +82,6 @@ export class NodeCryptoProvider return createHash('sha256').update(data).digest() } - createMd5(): IHashMethod { - return createHash('md5') as unknown as IHashMethod - } - hmacSha256(data: Buffer, key: Buffer): MaybeAsync { return createHmac('sha256', key).update(data).digest() } diff --git a/packages/core/src/utils/early-timer.ts b/packages/core/src/utils/early-timer.ts index c27952eb..eedf7f51 100644 --- a/packages/core/src/utils/early-timer.ts +++ b/packages/core/src/utils/early-timer.ts @@ -58,8 +58,8 @@ export class EarlyTimer { * Emit the timer right now */ emitNow(): void { - this._handler() this.reset() + this._handler() } /** diff --git a/packages/core/src/utils/logger.ts b/packages/core/src/utils/logger.ts index 974d94bc..f8a2d836 100644 --- a/packages/core/src/utils/logger.ts +++ b/packages/core/src/utils/logger.ts @@ -73,14 +73,27 @@ export class Logger { const val = args[idx] args.splice(idx, 1) - if (m === '%h') return Buffer.isBuffer(val) ? val.toString('hex') : String(val) + + if (m === '%h') { + if (Buffer.isBuffer(val)) return val.toString('hex') + if (typeof val === 'number') return val.toString(16) + + return String(val) + } if (m === '%b') return String(Boolean(val)) if (m === '%j') { return JSON.stringify(val, (k, v) => { - if (typeof v === 'object' && v.type === 'Buffer' && Array.isArray(v.data)) { + if ( + typeof v === 'object' && + v.type === 'Buffer' && + Array.isArray(v.data) + ) { let str = Buffer.from(v.data).toString('base64') - if (str.length > 300) str = str.slice(0, 300) + '...' + + if (str.length > 300) { + str = str.slice(0, 300) + '...' + } return str } @@ -137,10 +150,10 @@ export class LogManager extends Logger { static DEBUG = 4 static VERBOSE = 5 - constructor() { + constructor(tag = 'base') { // workaround because we cant pass this to super // eslint-disable-next-line @typescript-eslint/no-explicit-any - super(null as any, 'base') + super(null as any, tag) // eslint-disable-next-line @typescript-eslint/no-explicit-any ;(this as any).mgr = this } diff --git a/packages/core/src/utils/long-utils.ts b/packages/core/src/utils/long-utils.ts index b89b7ffb..5e603bbd 100644 --- a/packages/core/src/utils/long-utils.ts +++ b/packages/core/src/utils/long-utils.ts @@ -16,6 +16,21 @@ export function randomLong(unsigned = false): Long { return new Long(lo, hi, unsigned) } +/** + * Read a Long from a buffer + * + * @param buf Buffer to read from + * @param unsigned Whether the number should be unsigned + * @param le Whether the number is little-endian + */ +export function longFromBuffer(buf: Buffer, unsigned = false, le = true): Long { + if (le) { + return new Long(buf.readInt32LE(0), buf.readInt32LE(4), unsigned) + } + + return new Long(buf.readInt32BE(4), buf.readInt32BE(0), unsigned) +} + /** * Remove a Long from an array * diff --git a/packages/core/src/utils/platform/logging.ts b/packages/core/src/utils/platform/logging.ts index e8c09f93..bfac91a5 100644 --- a/packages/core/src/utils/platform/logging.ts +++ b/packages/core/src/utils/platform/logging.ts @@ -2,7 +2,7 @@ import { isatty } from 'tty' const isTty = isatty(process.stdout.fd) -const BASE_FORMAT = isTty ? '[%s] [%s] %s%s\x1b[0m - ' : '[%s] [%s] %s - ' +const BASE_FORMAT = isTty ? '%s [%s] [%s%s\x1b[0m] ' : '%s [%s] [%s] ' const LEVEL_NAMES = isTty ? [ '', // OFF diff --git a/packages/core/src/utils/platform/logging.web.ts b/packages/core/src/utils/platform/logging.web.ts index 0da120e4..a257c30a 100644 --- a/packages/core/src/utils/platform/logging.web.ts +++ b/packages/core/src/utils/platform/logging.web.ts @@ -1,4 +1,4 @@ -const BASE_FORMAT = '[%s] [%с%s%с] %c%s%c - ' +const BASE_FORMAT = '%s [%с%s%с] [%c%s%c] ' const LEVEL_NAMES = [ '', // OFF 'ERR', diff --git a/packages/core/tests/crypto-providers.spec.ts b/packages/core/tests/crypto-providers.spec.ts index 1ae337c3..42c6e576 100644 --- a/packages/core/tests/crypto-providers.spec.ts +++ b/packages/core/tests/crypto-providers.spec.ts @@ -164,31 +164,6 @@ export function testCryptoProvider(c: ICryptoProvider): void { '99706487a1cde613bc6de0b6f24b1c7aa448c8b9c3403e3467a8cad89340f53b', ) }) - - it('should calculate md5', async () => { - const test = async (...parts: string[]): Promise => { - const md5 = c.createMd5() - for (const p of parts) await md5.update(Buffer.from(p, 'hex')) - - return md5.digest() - } - - expect((await test()).toString('hex')).eq( - 'd41d8cd98f00b204e9800998ecf8427e', - ) - expect((await test('aaeeff')).toString('hex')).eq( - '9c20ec5e212b4fcfa4666a8b165c6d5d', - ) - expect((await test('aaeeffffeeaa')).toString('hex')).eq( - 'cf216071768a7b610d079e5eb7b68b74', - ) - expect((await test('aaeeff', 'ffeeaa')).toString('hex')).eq( - 'cf216071768a7b610d079e5eb7b68b74', - ) - expect((await test('aa', 'ee', 'ff', 'ffeeaa')).toString('hex')).eq( - 'cf216071768a7b610d079e5eb7b68b74', - ) - }) } describe('NodeCryptoProvider', () => { diff --git a/packages/core/tests/fuzz/fuzz-packet.spec.ts b/packages/core/tests/fuzz/fuzz-packet.spec.ts index e9ea7b97..3a013ef7 100644 --- a/packages/core/tests/fuzz/fuzz-packet.spec.ts +++ b/packages/core/tests/fuzz/fuzz-packet.spec.ts @@ -1,71 +1,71 @@ -import { expect } from 'chai' -import { randomBytes } from 'crypto' -import { describe, it } from 'mocha' - -import __tlReaderMap from '@mtcute/tl/binary/reader' -import { TlBinaryReader } from '@mtcute/tl-runtime' - -import { createTestTelegramClient } from './utils' - -// eslint-disable-next-line @typescript-eslint/no-var-requires -require('dotenv-flow').config() - -describe('fuzz : packet', async function () { - this.timeout(45000) - - it('random packet', async () => { - const client = createTestTelegramClient() - - await client.connect() - await client.waitUntilUsable() - - let errors = 0 - - const conn = client.primaryConnection - // eslint-disable-next-line dot-notation - const mtproto = conn['_session'] - - for (let i = 0; i < 100; i++) { - const payload = randomBytes(Math.round(Math.random() * 16) * 16) - - try { - // eslint-disable-next-line dot-notation - conn['_handleRawMessage']( - mtproto.getMessageId().sub(1), - 0, - new TlBinaryReader(__tlReaderMap, payload), - ) - } catch (e) { - errors += 1 - } - } - - // similar test, but this time only using object ids that do exist - const objectIds = Object.keys(__tlReaderMap) - - for (let i = 0; i < 100; i++) { - const payload = randomBytes( - (Math.round(Math.random() * 16) + 1) * 16, - ) - const objectId = parseInt( - objectIds[Math.round(Math.random() * objectIds.length)], - ) - payload.writeUInt32LE(objectId, 0) - - try { - // eslint-disable-next-line dot-notation - conn['_handleRawMessage']( - mtproto.getMessageId().sub(1), - 0, - new TlBinaryReader(__tlReaderMap, payload), - ) - } catch (e) { - errors += 1 - } - } - - await client.close() - - expect(errors).gt(0) - }) -}) +// import { expect } from 'chai' +// import { randomBytes } from 'crypto' +// import { describe, it } from 'mocha' +// +// import __tlReaderMap from '@mtcute/tl/binary/reader' +// import { TlBinaryReader } from '@mtcute/tl-runtime' +// +// import { createTestTelegramClient } from './utils' +// +// // eslint-disable-next-line @typescript-eslint/no-var-requires +// require('dotenv-flow').config() +// +// describe('fuzz : packet', async function () { +// this.timeout(45000) +// +// it('random packet', async () => { +// const client = createTestTelegramClient() +// +// await client.connect() +// await client.waitUntilUsable() +// +// let errors = 0 +// +// const conn = client.primaryConnection +// // eslint-disable-next-line dot-notation +// const mtproto = conn['_session'] +// +// for (let i = 0; i < 100; i++) { +// const payload = randomBytes(Math.round(Math.random() * 16) * 16) +// +// try { +// // eslint-disable-next-line dot-notation +// conn['_handleRawMessage']( +// mtproto.getMessageId().sub(1), +// 0, +// new TlBinaryReader(__tlReaderMap, payload), +// ) +// } catch (e) { +// errors += 1 +// } +// } +// +// // similar test, but this time only using object ids that do exist +// const objectIds = Object.keys(__tlReaderMap) +// +// for (let i = 0; i < 100; i++) { +// const payload = randomBytes( +// (Math.round(Math.random() * 16) + 1) * 16, +// ) +// const objectId = parseInt( +// objectIds[Math.round(Math.random() * objectIds.length)], +// ) +// payload.writeUInt32LE(objectId, 0) +// +// try { +// // eslint-disable-next-line dot-notation +// conn['_handleRawMessage']( +// mtproto.getMessageId().sub(1), +// 0, +// new TlBinaryReader(__tlReaderMap, payload), +// ) +// } catch (e) { +// errors += 1 +// } +// } +// +// await client.close() +// +// expect(errors).gt(0) +// }) +// }) diff --git a/packages/core/tests/fuzz/fuzz-session.spec.ts b/packages/core/tests/fuzz/fuzz-session.spec.ts index 78cde949..1a8af750 100644 --- a/packages/core/tests/fuzz/fuzz-session.spec.ts +++ b/packages/core/tests/fuzz/fuzz-session.spec.ts @@ -1,77 +1,77 @@ -import { expect } from 'chai' -import { randomBytes } from 'crypto' -import { describe, it } from 'mocha' - -import { sleep } from '../../src' -import { createTestTelegramClient } from './utils' - -// eslint-disable-next-line @typescript-eslint/no-var-requires -require('dotenv-flow').config() - -describe('fuzz : session', async function () { - this.timeout(45000) - - it('random auth_key', async () => { - const client = createTestTelegramClient() - - // random key - const initKey = randomBytes(256) - await client.storage.setAuthKeyFor(2, initKey) - - // client is supposed to handle this and generate a new key - - const errors: unknown[] = [] - - const errorHandler = (err: unknown) => { - errors.push(err) - } - - client.onError(errorHandler) - - await client.connect() - - await sleep(10000) - - await client.close() - - expect(errors.length).eq(0) - - expect((await client.storage.getAuthKeyFor(2))?.toString('hex')).not.eq( - initKey.toString('hex'), - ) - }) - - it('random auth_key for other dc', async () => { - const client = createTestTelegramClient() - - // random key for dc1 - const initKey = randomBytes(256) - await client.storage.setAuthKeyFor(1, initKey) - - // client is supposed to handle this and generate a new key - - const errors: unknown[] = [] - - const errorHandler = (err: unknown) => { - errors.push(err) - } - - client.onError(errorHandler) - - await client.connect() - await client.waitUntilUsable() - - const conn = await client.createAdditionalConnection(1) - await conn.sendRpc({ _: 'help.getConfig' }) - - await sleep(10000) - - await client.close() - - expect(errors.length).eq(0) - - expect((await client.storage.getAuthKeyFor(1))?.toString('hex')).not.eq( - initKey.toString('hex'), - ) - }) -}) +// import { expect } from 'chai' +// import { randomBytes } from 'crypto' +// import { describe, it } from 'mocha' +// +// import { sleep } from '../../src' +// import { createTestTelegramClient } from './utils' +// +// // eslint-disable-next-line @typescript-eslint/no-var-requires +// require('dotenv-flow').config() +// +// describe('fuzz : session', async function () { +// this.timeout(45000) +// +// it('random auth_key', async () => { +// const client = createTestTelegramClient() +// +// // random key +// const initKey = randomBytes(256) +// await client.storage.setAuthKeyFor(2, initKey) +// +// // client is supposed to handle this and generate a new key +// +// const errors: Error[] = [] +// +// const errorHandler = (err: Error) => { +// errors.push(err) +// } +// +// client.onError(errorHandler) +// +// await client.connect() +// +// await sleep(10000) +// +// await client.close() +// +// expect(errors.length).eq(0) +// +// expect((await client.storage.getAuthKeyFor(2))?.toString('hex')).not.eq( +// initKey.toString('hex'), +// ) +// }) +// +// it('random auth_key for other dc', async () => { +// const client = createTestTelegramClient() +// +// // random key for dc1 +// const initKey = randomBytes(256) +// await client.storage.setAuthKeyFor(1, initKey) +// +// // client is supposed to handle this and generate a new key +// +// const errors: Error[] = [] +// +// const errorHandler = (err: Error) => { +// errors.push(err) +// } +// +// client.onError(errorHandler) +// +// await client.connect() +// await client.waitUntilUsable() +// +// const conn = await client.createAdditionalConnection(1) +// await conn.sendRpc({ _: 'help.getConfig' }) +// +// await sleep(10000) +// +// await client.close() +// +// expect(errors.length).eq(0) +// +// expect((await client.storage.getAuthKeyFor(1))?.toString('hex')).not.eq( +// initKey.toString('hex'), +// ) +// }) +// }) diff --git a/packages/core/tests/fuzz/fuzz-transport.spec.ts b/packages/core/tests/fuzz/fuzz-transport.spec.ts index 460272f3..3be4e27a 100644 --- a/packages/core/tests/fuzz/fuzz-transport.spec.ts +++ b/packages/core/tests/fuzz/fuzz-transport.spec.ts @@ -1,128 +1,127 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import { expect } from 'chai' -import { randomBytes } from 'crypto' -import { EventEmitter } from 'events' -import { describe, it } from 'mocha' - -import { - BaseTelegramClient, - defaultDcs, - ITelegramTransport, - NodeCryptoProvider, - sleep, - tl, - TransportState, -} from '../../src' - -// eslint-disable-next-line @typescript-eslint/no-var-requires -require('dotenv-flow').config() - -class RandomBytesTransport extends EventEmitter implements ITelegramTransport { - dc!: tl.RawDcOption - interval?: NodeJS.Timeout - - close(): void { - clearInterval(this.interval) - this.emit('close') - this.interval = undefined - } - - connect(dc: tl.RawDcOption): void { - this.dc = dc - - setTimeout(() => this.emit('ready'), 0) - - this.interval = setInterval(() => { - this.emit('message', randomBytes(64)) - }, 100) - } - - currentDc(): tl.RawDcOption | null { - return this.dc - } - - send(_data: Buffer): Promise { - return Promise.resolve() - } - - state(): TransportState { - return this.interval ? TransportState.Ready : TransportState.Idle - } -} - -describe('fuzz : transport', function () { - this.timeout(30000) - - it('RandomBytesTransport (no auth)', async () => { - const client = new BaseTelegramClient({ - crypto: () => new NodeCryptoProvider(), - transport: () => new RandomBytesTransport(), - apiId: 0, - apiHash: '', - primaryDc: defaultDcs.defaultTestDc, - }) - client.log.level = 0 - - const errors: Error[] = [] - - client.onError((err) => { - errors.push(err) - }) - - await client.connect() - await sleep(15000) - await client.close() - - expect(errors.length).gt(0) - errors.forEach((err) => { - expect(err.message).match(/unknown object id/i) - }) - }) - - it('RandomBytesTransport (with auth)', async () => { - const client = new BaseTelegramClient({ - crypto: () => new NodeCryptoProvider(), - transport: () => new RandomBytesTransport(), - apiId: 0, - apiHash: '', - primaryDc: defaultDcs.defaultTestDc, - }) - client.log.level = 0 - - // random key just to make it think it already has one - await client.storage.setAuthKeyFor(2, randomBytes(256)) - - // in this case, there will be no actual errors, only - // warnings like 'received message with unknown authKey' - // - // to test for that, we hook into `decryptMessage` and make - // sure that it returns `null` - - await client.connect() - - let hadNonNull = false - - const decryptMessage = - // eslint-disable-next-line dot-notation - client.primaryConnection['_session'].decryptMessage - - // ехал any через any - // видит any - any, any - // сунул any any в any - // any any any any - // eslint-disable-next-line dot-notation - ;(client.primaryConnection['_session'] as any).decryptMessage = ( - buf: any, - cb: any, - ) => - decryptMessage.call(this, buf, (...args: any[]) => { - cb(...(args as any)) - hadNonNull = true - }) - - await sleep(15000) - await client.close() - - expect(hadNonNull).false - }) -}) +// import { expect } from 'chai' +// import { randomBytes } from 'crypto' +// import { EventEmitter } from 'events' +// import { describe, it } from 'mocha' +// +// import { +// BaseTelegramClient, +// defaultDcs, +// ITelegramTransport, +// NodeCryptoProvider, +// sleep, +// tl, +// TransportState, +// } from '../../src' +// +// // eslint-disable-next-line @typescript-eslint/no-var-requires +// require('dotenv-flow').config() +// +// class RandomBytesTransport extends EventEmitter implements ITelegramTransport { +// dc: tl.RawDcOption +// interval?: NodeJS.Timeout +// +// close(): void { +// clearInterval(this.interval) +// this.emit('close') +// this.interval = undefined +// } +// +// connect(dc: tl.RawDcOption): void { +// this.dc = dc +// +// setTimeout(() => this.emit('ready'), 0) +// +// this.interval = setInterval(() => { +// this.emit('message', randomBytes(64)) +// }, 100) +// } +// +// currentDc(): tl.RawDcOption | null { +// return this.dc +// } +// +// send(_data: Buffer): Promise { +// return Promise.resolve() +// } +// +// state(): TransportState { +// return this.interval ? TransportState.Ready : TransportState.Idle +// } +// } +// +// describe('fuzz : transport', function () { +// this.timeout(30000) +// +// it('RandomBytesTransport (no auth)', async () => { +// const client = new BaseTelegramClient({ +// crypto: () => new NodeCryptoProvider(), +// transport: () => new RandomBytesTransport(), +// apiId: 0, +// apiHash: '', +// defaultDc: defaultDcs.defaultTestDc, +// }) +// client.log.level = 0 +// +// const errors: Error[] = [] +// +// client.onError((err) => { +// errors.push(err) +// }) +// +// await client.connect() +// await sleep(15000) +// await client.close() +// +// expect(errors.length).gt(0) +// errors.forEach((err) => { +// expect(err.message).match(/unknown object id/i) +// }) +// }) +// +// it('RandomBytesTransport (with auth)', async () => { +// const client = new BaseTelegramClient({ +// crypto: () => new NodeCryptoProvider(), +// transport: () => new RandomBytesTransport(), +// apiId: 0, +// apiHash: '', +// defaultDc: defaultDcs.defaultTestDc, +// }) +// client.log.level = 0 +// +// // random key just to make it think it already has one +// await client.storage.setAuthKeyFor(2, randomBytes(256)) +// +// // in this case, there will be no actual errors, only +// // warnings like 'received message with unknown authKey' +// // +// // to test for that, we hook into `decryptMessage` and make +// // sure that it returns `null` +// +// await client.connect() +// +// let hadNonNull = false +// +// const decryptMessage = +// // eslint-disable-next-line dot-notation +// client.primaryConnection['_session'].decryptMessage +// +// // ехал any через any +// // видит any - any, any +// // сунул any any в any +// // any any any any +// // eslint-disable-next-line dot-notation +// ;(client.primaryConnection['_session'] as any).decryptMessage = ( +// buf: any, +// cb: any, +// ) => +// decryptMessage.call(this, buf, (...args: any[]) => { +// cb(...(args as any)) +// hadNonNull = true +// }) +// +// await sleep(15000) +// await client.close() +// +// expect(hadNonNull).false +// }) +// }) diff --git a/packages/core/tests/miller-rabin.spec.ts b/packages/core/tests/miller-rabin.spec.ts new file mode 100644 index 00000000..57583da1 --- /dev/null +++ b/packages/core/tests/miller-rabin.spec.ts @@ -0,0 +1,139 @@ +import bigInt from 'big-integer' +import { expect } from 'chai' +import { describe, it } from 'mocha' + +import { millerRabin } from '../src/utils/crypto/miller-rabin' + +describe('miller-rabin test', function () { + this.timeout(10000) // since miller-rabin factorization relies on RNG, it may take a while (or may not!) + + const testMillerRabin = (n: bigInt.BigNumber, isPrime: boolean) => { + expect(millerRabin(bigInt(n as number))).eq(isPrime) + } + + it('should correctly label small primes as probable primes', () => { + const smallOddPrimes = [2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31] + + for (const prime of smallOddPrimes) { + testMillerRabin(prime, true) + } + }) + + it('should correctly label small odd composite numbers as composite', () => { + const smallOddPrimes = [9, 15, 21, 25, 27, 33, 35] + + for (const prime of smallOddPrimes) { + testMillerRabin(prime, false) + } + }) + + // primes are generated using `openssl prime -generate -bits ` + + it('should work for 512-bit numbers', () => { + testMillerRabin( + '8411445470921866378538628788380866906358949375899610911537071281076627385046125382763689993349183284546479522400013151510610266158235924343045768103605519', + true, + ) + testMillerRabin( + '11167561990563990242158096122232207092938761092751537312016255867850441858086589598418467012717458858604863547175649456433632887622140170743409535470973399', + true, + ) + testMillerRabin( + '11006717791910450367418249787526506184731090161438431250022510598653874155081488487035840577645711578911087148186160668569071839053453201592321650008610329', + true, + ) + testMillerRabin( + '12224330340162812215033324917156282302617911690617664923428569636370785775561435789211091021550357876767050350997458404009005800772805534351607294516706177', + true, + ) + + // above numbers but -2 (not prime) + testMillerRabin( + '8411445470921866378538628788380866906358949375899610911537071281076627385046125382763689993349183284546479522400013151510610266158235924343045768103605517', + false, + ) + testMillerRabin( + '11167561990563990242158096122232207092938761092751537312016255867850441858086589598418467012717458858604863547175649456433632887622140170743409535470973397', + false, + ) + testMillerRabin( + '11006717791910450367418249787526506184731090161438431250022510598653874155081488487035840577645711578911087148186160668569071839053453201592321650008610327', + false, + ) + testMillerRabin( + '12224330340162812215033324917156282302617911690617664923428569636370785775561435789211091021550357876767050350997458404009005800772805534351607294516706175', + false, + ) + }) + + it('should work for 1024-bit numbers', () => { + testMillerRabin( + '94163180970530844245052892199633535954736903357996153321496979115367320260897793334681106861766748541439161886270777106456088209508872459550450259737267142959061663564218457086654112219462515165219295402175541003899136060178102898376369981338103600856012709228116661479275753497725541132207243717937379815409', + true, + ) + testMillerRabin( + '97324962433497727515811278760066576725849776656602017497363465683978397629803148191267105308901733336070351381654371470561376353774017284623969415330564867697353080030917333974193741719718950105404732792050882127213356260415251087867407489400712288570880407613514781891914135956778687719588061176455381937003', + true, + ) + testMillerRabin( + '92511311413226091818378551616231701579277597795073142338527410334932345968554993390789667936819230228388142960299649466238701015865565141753710450319875546944139442823075990348978746055937500467483161699883905850192191164043687791185635729923497381849380102040768674652775240505782671289535260164547714030567', + true, + ) + testMillerRabin( + '98801756216479639848708157708947504990501845258427605711852570166662700681215707617225664134994147912417941920327932092748574265476658124536672887141144222716123085451749764522435906007567360583062117498919471220566974634924384147341592903939264267901029640119196259026154529723870788246284629644039137378253', + true, + ) + + // above numbers but -2 (not prime) + testMillerRabin( + '94163180970530844245052892199633535954736903357996153321496979115367320260897793334681106861766748541439161886270777106456088209508872459550450259737267142959061663564218457086654112219462515165219295402175541003899136060178102898376369981338103600856012709228116661479275753497725541132207243717937379815407', + false, + ) + testMillerRabin( + '97324962433497727515811278760066576725849776656602017497363465683978397629803148191267105308901733336070351381654371470561376353774017284623969415330564867697353080030917333974193741719718950105404732792050882127213356260415251087867407489400712288570880407613514781891914135956778687719588061176455381937001', + false, + ) + testMillerRabin( + '92511311413226091818378551616231701579277597795073142338527410334932345968554993390789667936819230228388142960299649466238701015865565141753710450319875546944139442823075990348978746055937500467483161699883905850192191164043687791185635729923497381849380102040768674652775240505782671289535260164547714030565', + false, + ) + testMillerRabin( + '98801756216479639848708157708947504990501845258427605711852570166662700681215707617225664134994147912417941920327932092748574265476658124536672887141144222716123085451749764522435906007567360583062117498919471220566974634924384147341592903939264267901029640119196259026154529723870788246284629644039137378251', + false, + ) + }) + + it('should work for 2048-bit numbers', () => { + testMillerRabin( + '28608382334358769588283288249494859626901014972463291352091976543138105382282108662849885913053034513852843449409838151123568984617793641641937583673207501643041336002587032201383537626393235736734494131431069043382068545865505150651648610506542819001961332454611129372758714288168807328523359776577571626967649079147416191592855529888846889532625386469236278694936872628305052827422772792103722178298844645210242389265273407924858034431614414896134561928996888883994953322861399988094086562513898527391555490352156627307769278185444897960555995383228897584818577375695810423475039211516849716140051437120083274285367', + true, + ) + testMillerRabin( + '30244022694659482453371920976249272809817388822378671144866806600284132009663832003348737406289715119965835410140834733465553787513841966120831322372642881643693711233087233983267648392814127424201572290931937482043046169402667397610783447368703776842799852222745601531140231486417855517072392416789672922529566643118973930252809010605519948446055538976582290902060054788109497630796585770940656002892943575479533099350429655210881833493066716819282707441553612603960556051122162329171373373251909387401572866056121964608595895425640834764028568120995397759283490218181167000161310959711677055741632674632758727382743', + true, + ) + testMillerRabin( + '30560953105766401423987964658775999222308579908395527900931049506803845883459894704297458477118152899910620180302473409631442956208933061650967001020981432894530064472547770442696756724169958362395601360296775798187903794894866967342028337982275745956538015473621792510615113531964380246815875830970404687926061637030085629909804357717955251735074071072456074274947993921828878633638119117086342305530526661796817095624933200483138188878398983149622639425550360394901699701985050966685840649129419227936413574227792077082510807968104733387734970009620450108276446659342203263759999068046251645984039420643003580284779', + true, + ) + + // above numbers but -2 (not prime) + testMillerRabin( + '28608382334358769588283288249494859626901014972463291352091976543138105382282108662849885913053034513852843449409838151123568984617793641641937583673207501643041336002587032201383537626393235736734494131431069043382068545865505150651648610506542819001961332454611129372758714288168807328523359776577571626967649079147416191592855529888846889532625386469236278694936872628305052827422772792103722178298844645210242389265273407924858034431614414896134561928996888883994953322861399988094086562513898527391555490352156627307769278185444897960555995383228897584818577375695810423475039211516849716140051437120083274285365', + false, + ) + testMillerRabin( + '30244022694659482453371920976249272809817388822378671144866806600284132009663832003348737406289715119965835410140834733465553787513841966120831322372642881643693711233087233983267648392814127424201572290931937482043046169402667397610783447368703776842799852222745601531140231486417855517072392416789672922529566643118973930252809010605519948446055538976582290902060054788109497630796585770940656002892943575479533099350429655210881833493066716819282707441553612603960556051122162329171373373251909387401572866056121964608595895425640834764028568120995397759283490218181167000161310959711677055741632674632758727382741', + false, + ) + testMillerRabin( + '30560953105766401423987964658775999222308579908395527900931049506803845883459894704297458477118152899910620180302473409631442956208933061650967001020981432894530064472547770442696756724169958362395601360296775798187903794894866967342028337982275745956538015473621792510615113531964380246815875830970404687926061637030085629909804357717955251735074071072456074274947993921828878633638119117086342305530526661796817095624933200483138188878398983149622639425550360394901699701985050966685840649129419227936413574227792077082510807968104733387734970009620450108276446659342203263759999068046251645984039420643003580284777', + false, + ) + + // dh_prime used by telegram, as seen in https://core.telegram.org/mtproto/security_guidelines + const telegramDhPrime = + 'C7 1C AE B9 C6 B1 C9 04 8E 6C 52 2F 70 F1 3F 73 98 0D 40 23 8E 3E 21 C1 49 34 D0 37 56 3D 93 0F 48 19 8A 0A A7 C1 40 58 22 94 93 D2 25 30 F4 DB FA 33 6F 6E 0A C9 25 13 95 43 AE D4 4C CE 7C 37 20 FD 51 F6 94 58 70 5A C6 8C D4 FE 6B 6B 13 AB DC 97 46 51 29 69 32 84 54 F1 8F AF 8C 59 5F 64 24 77 FE 96 BB 2A 94 1D 5B CD 1D 4A C8 CC 49 88 07 08 FA 9B 37 8E 3C 4F 3A 90 60 BE E6 7C F9 A4 A4 A6 95 81 10 51 90 7E 16 27 53 B5 6B 0F 6B 41 0D BA 74 D8 A8 4B 2A 14 B3 14 4E 0E F1 28 47 54 FD 17 ED 95 0D 59 65 B4 B9 DD 46 58 2D B1 17 8D 16 9C 6B C4 65 B0 D6 FF 9C A3 92 8F EF 5B 9A E4 E4 18 FC 15 E8 3E BE A0 F8 7F A9 FF 5E ED 70 05 0D ED 28 49 F4 7B F9 59 D9 56 85 0C E9 29 85 1F 0D 81 15 F6 35 B1 05 EE 2E 4E 15 D0 4B 24 54 BF 6F 4F AD F0 34 B1 04 03 11 9C D8 E3 B9 2F CC 5B' + testMillerRabin(bigInt(telegramDhPrime.replace(/ /g, ''), 16), true) + }) +}) diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index c69f6e35..9637bdbb 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -4,7 +4,8 @@ "outDir": "./dist" }, "include": [ - "./src" + "./src", + "./tests" ], "typedocOptions": { "name": "@mtcute/core", diff --git a/packages/mtproxy/index.ts b/packages/mtproxy/index.ts index d5c82a7e..c09901e0 100644 --- a/packages/mtproxy/index.ts +++ b/packages/mtproxy/index.ts @@ -91,10 +91,20 @@ export class MtProxyTcpTransport extends BaseTcpTransport { } } + getMtproxyInfo(): tl.RawInputClientProxy { + return { + _: 'inputClientProxy', + address: this._proxy.host, + port: this._proxy.port, + } + } + _packetCodec!: IPacketCodec connect(dc: tl.RawDcOption, testMode: boolean): void { - if (this._state !== TransportState.Idle) { throw new Error('Transport is not IDLE') } + if (this._state !== TransportState.Idle) { + throw new Error('Transport is not IDLE') + } if (this._packetCodec && this._currentDc?.id !== dc.id) { // dc changed, thus the codec's init will change too diff --git a/packages/socks-proxy/package.json b/packages/socks-proxy/package.json index 6e3540b6..d84ffe23 100644 --- a/packages/socks-proxy/package.json +++ b/packages/socks-proxy/package.json @@ -12,6 +12,6 @@ }, "dependencies": { "@mtcute/core": "workspace:^1.0.0", - "ip6": "0.2.10" + "ip6": "0.2.7" } } diff --git a/packages/sqlite/index.ts b/packages/sqlite/index.ts index 3774e8aa..e4e73b10 100644 --- a/packages/sqlite/index.ts +++ b/packages/sqlite/index.ts @@ -54,61 +54,64 @@ function getInputPeer( throw new Error(`Invalid peer type: ${row.type}`) } -const CURRENT_VERSION = 2 +const CURRENT_VERSION = 3 -// language=SQLite +// language=SQLite format=false +const TEMP_AUTH_TABLE = ` + create table temp_auth_keys ( + dc integer not null, + idx integer not null, + key blob not null, + expires integer not null, + primary key (dc, idx) + ); +` + +// language=SQLite format=false const SCHEMA = ` - create table kv - ( - key text primary key, + create table kv ( + key text primary key, value text not null ); - create table state - ( - key text primary key, - value text not null, + create table state ( + key text primary key, + value text not null, expires number ); - create table auth_keys - ( - dc integer primary key, + create table auth_keys ( + dc integer primary key, key blob not null ); - create table pts - ( + ${TEMP_AUTH_TABLE} + + create table pts ( channel_id integer primary key, - pts integer not null + pts integer not null ); - create table entities - ( - id integer primary key, - hash text not null, - type text not null, + create table entities ( + id integer primary key, + hash text not null, + type text not null, username text, - phone text, - updated integer not null, - "full" blob + phone text, + updated integer not null, + "full" blob ); create index idx_entities_username on entities (username); create index idx_entities_phone on entities (phone); ` +// language=SQLite format=false const RESET = ` - delete - from kv - where key <> 'ver'; - delete - from state; - delete - from auth_keys; - delete - from pts; - delete - from entities + delete from kv where key <> 'ver'; + delete from state; + delete from auth_keys; + delete from pts; + delete from entities ` const USERNAME_TTL = 86400000 // 24 hours @@ -144,8 +147,14 @@ const STATEMENTS = { delState: 'delete from state where key = ?', getAuth: 'select key from auth_keys where dc = ?', + getAuthTemp: + 'select key from temp_auth_keys where dc = ? and idx = ? and expires > ?', setAuth: 'insert or replace into auth_keys (dc, key) values (?, ?)', + setAuthTemp: + 'insert or replace into temp_auth_keys (dc, idx, key, expires) values (?, ?, ?, ?)', delAuth: 'delete from auth_keys where dc = ?', + delAuthTemp: 'delete from temp_auth_keys where dc = ? and idx = ?', + delAllAuthTemp: 'delete from temp_auth_keys where dc = ?', getPts: 'select pts from pts where channel_id = ?', setPts: 'insert or replace into pts (channel_id, pts) values (?, ?)', @@ -376,12 +385,24 @@ export class SqliteStorage implements ITelegramStorage, IStateStorage { 'Unsupported session version, please migrate manually', ) } + + if (from === 2) { + // PFS support added + this._db.exec(TEMP_AUTH_TABLE) + from = 3 + } + + if (from !== CURRENT_VERSION) { + // an assertion just in case i messed up + throw new Error('Migration incomplete') + } } private _initializeStatements(): void { this._statements = {} as unknown as typeof this._statements Object.entries(STATEMENTS).forEach(([name, sql]) => { - this._statements[name as keyof typeof this._statements] = this._db.prepare(sql) + this._statements[name as keyof typeof this._statements] = + this._db.prepare(sql) }) } @@ -397,7 +418,7 @@ export class SqliteStorage implements ITelegramStorage, IStateStorage { const versionResult = this._db .prepare("select value from kv where key = 'ver'") .get() - const version = (versionResult as { value: number }).value + const version = Number((versionResult as { value: number }).value) this.log.debug('current db version = %d', version) @@ -426,7 +447,10 @@ export class SqliteStorage implements ITelegramStorage, IStateStorage { load(): void { this._db = sqlite3(this._filename, { - verbose: this.log.mgr.level === 5 ? this.log.verbose as Options['verbose'] : undefined, + verbose: + this.log.mgr.level === 5 ? + (this.log.verbose as Options['verbose']) : + undefined, }) this._initialize() @@ -481,8 +505,14 @@ export class SqliteStorage implements ITelegramStorage, IStateStorage { return this._getFromKv('def_dc') } - getAuthKeyFor(dcId: number): Buffer | null { - const row = this._statements.getAuth.get(dcId) + getAuthKeyFor(dcId: number, tempIndex?: number): Buffer | null { + let row + + if (tempIndex !== undefined) { + row = this._statements.getAuthTemp.get(dcId, tempIndex, Date.now()) + } else { + row = this._statements.getAuth.get(dcId) + } return row ? (row as { key: Buffer }).key : null } @@ -494,6 +524,27 @@ export class SqliteStorage implements ITelegramStorage, IStateStorage { ]) } + setTempAuthKeyFor( + dcId: number, + index: number, + key: Buffer | null, + expires: number, + ): void { + this._pending.push([ + key === null ? + this._statements.delAuthTemp : + this._statements.setAuthTemp, + key === null ? [dcId, index] : [dcId, index, key, expires], + ]) + } + + dropAuthKeysFor(dcId: number): void { + this._pending.push( + [this._statements.delAuth, [dcId]], + [this._statements.delAllAuthTemp, [dcId]], + ) + } + getSelf(): ITelegramStorage.SelfInfo | null { return this._getFromKv('self') } @@ -601,7 +652,9 @@ export class SqliteStorage implements ITelegramStorage, IStateStorage { const cached = this._cache?.get(peerId) if (cached) return cached.peer - const row = this._statements.getEntById.get(peerId) as SqliteEntity | null + const row = this._statements.getEntById.get( + peerId, + ) as SqliteEntity | null if (row) { const peer = getInputPeer(row) @@ -617,7 +670,9 @@ export class SqliteStorage implements ITelegramStorage, IStateStorage { } getPeerByPhone(phone: string): tl.TypeInputPeer | null { - const row = this._statements.getEntByPhone.get(phone) as SqliteEntity | null + const row = this._statements.getEntByPhone.get( + phone, + ) as SqliteEntity | null if (row) { const peer = getInputPeer(row) @@ -633,7 +688,9 @@ export class SqliteStorage implements ITelegramStorage, IStateStorage { } getPeerByUsername(username: string): tl.TypeInputPeer | null { - const row = this._statements.getEntByUser.get(username.toLowerCase()) as SqliteEntity | null + const row = this._statements.getEntByUser.get( + username.toLowerCase(), + ) as SqliteEntity | null if (!row || Date.now() - row.updated > USERNAME_TTL) return null if (row) { diff --git a/packages/tl/mtp-schema.json b/packages/tl/mtp-schema.json index f01e99a5..1a95644c 100644 --- a/packages/tl/mtp-schema.json +++ b/packages/tl/mtp-schema.json @@ -1 +1 @@ -[{"kind":"class","name":"mt_resPQ","id":85337187,"type":"ResPQ","arguments":[{"name":"nonce","type":"int128"},{"name":"server_nonce","type":"int128"},{"name":"pq","type":"bytes"},{"name":"server_public_key_fingerprints","type":"long","typeModifiers":{"isVector":true}}]},{"kind":"class","name":"mt_p_q_inner_data_dc","id":2851430293,"type":"P_Q_inner_data","arguments":[{"name":"pq","type":"bytes"},{"name":"p","type":"bytes"},{"name":"q","type":"bytes"},{"name":"nonce","type":"int128"},{"name":"server_nonce","type":"int128"},{"name":"new_nonce","type":"int256"},{"name":"dc","type":"int"}]},{"kind":"class","name":"mt_p_q_inner_data_temp_dc","id":1459478408,"type":"P_Q_inner_data","arguments":[{"name":"pq","type":"bytes"},{"name":"p","type":"bytes"},{"name":"q","type":"bytes"},{"name":"nonce","type":"int128"},{"name":"server_nonce","type":"int128"},{"name":"new_nonce","type":"int256"},{"name":"dc","type":"int"},{"name":"expires_in","type":"int"}]},{"kind":"class","name":"mt_server_DH_params_ok","id":3504867164,"type":"Server_DH_Params","arguments":[{"name":"nonce","type":"int128"},{"name":"server_nonce","type":"int128"},{"name":"encrypted_answer","type":"bytes"}]},{"kind":"class","name":"mt_server_DH_inner_data","id":3045658042,"type":"Server_DH_inner_data","arguments":[{"name":"nonce","type":"int128"},{"name":"server_nonce","type":"int128"},{"name":"g","type":"int"},{"name":"dh_prime","type":"bytes"},{"name":"g_a","type":"bytes"},{"name":"server_time","type":"int"}]},{"kind":"class","name":"mt_client_DH_inner_data","id":1715713620,"type":"Client_DH_Inner_Data","arguments":[{"name":"nonce","type":"int128"},{"name":"server_nonce","type":"int128"},{"name":"retry_id","type":"long"},{"name":"g_b","type":"bytes"}]},{"kind":"class","name":"mt_dh_gen_ok","id":1003222836,"type":"Set_client_DH_params_answer","arguments":[{"name":"nonce","type":"int128"},{"name":"server_nonce","type":"int128"},{"name":"new_nonce_hash1","type":"int128"}]},{"kind":"class","name":"mt_dh_gen_retry","id":1188831161,"type":"Set_client_DH_params_answer","arguments":[{"name":"nonce","type":"int128"},{"name":"server_nonce","type":"int128"},{"name":"new_nonce_hash2","type":"int128"}]},{"kind":"class","name":"mt_dh_gen_fail","id":2795351554,"type":"Set_client_DH_params_answer","arguments":[{"name":"nonce","type":"int128"},{"name":"server_nonce","type":"int128"},{"name":"new_nonce_hash3","type":"int128"}]},{"kind":"class","name":"mt_bind_auth_key_inner","id":1973679973,"type":"BindAuthKeyInner","arguments":[{"name":"nonce","type":"long"},{"name":"temp_auth_key_id","type":"long"},{"name":"perm_auth_key_id","type":"long"},{"name":"temp_session_id","type":"long"},{"name":"expires_at","type":"int"}]},{"kind":"class","name":"mt_rpc_error","id":558156313,"type":"RpcError","arguments":[{"name":"error_code","type":"int"},{"name":"error_message","type":"string"}]},{"kind":"class","name":"mt_rpc_answer_unknown","id":1579864942,"type":"RpcDropAnswer","arguments":[]},{"kind":"class","name":"mt_rpc_answer_dropped_running","id":3447252358,"type":"RpcDropAnswer","arguments":[]},{"kind":"class","name":"mt_rpc_answer_dropped","id":2755319991,"type":"RpcDropAnswer","arguments":[{"name":"msg_id","type":"long"},{"name":"seq_no","type":"int"},{"name":"bytes","type":"int"}]},{"kind":"class","name":"mt_future_salt","id":155834844,"type":"FutureSalt","arguments":[{"name":"valid_since","type":"int"},{"name":"valid_until","type":"int"},{"name":"salt","type":"long"}]},{"kind":"class","name":"mt_future_salts","id":2924480661,"type":"FutureSalts","arguments":[{"name":"req_msg_id","type":"long"},{"name":"now","type":"int"},{"name":"salts","type":"mt_future_salt","typeModifiers":{"isBareVector":true,"isBareType":true,"constructorId":155834844}}]},{"kind":"class","name":"mt_pong","id":880243653,"type":"Pong","arguments":[{"name":"msg_id","type":"long"},{"name":"ping_id","type":"long"}]},{"kind":"class","name":"mt_destroy_session_ok","id":3793765884,"type":"DestroySessionRes","arguments":[{"name":"session_id","type":"long"}]},{"kind":"class","name":"mt_destroy_session_none","id":1658015945,"type":"DestroySessionRes","arguments":[{"name":"session_id","type":"long"}]},{"kind":"class","name":"mt_new_session_created","id":2663516424,"type":"NewSession","arguments":[{"name":"first_msg_id","type":"long"},{"name":"unique_id","type":"long"},{"name":"server_salt","type":"long"}]},{"kind":"class","name":"mt_msgs_ack","id":1658238041,"type":"MsgsAck","arguments":[{"name":"msg_ids","type":"long","typeModifiers":{"isVector":true}}]},{"kind":"class","name":"mt_bad_msg_notification","id":2817521681,"type":"BadMsgNotification","arguments":[{"name":"bad_msg_id","type":"long"},{"name":"bad_msg_seqno","type":"int"},{"name":"error_code","type":"int"}]},{"kind":"class","name":"mt_bad_server_salt","id":3987424379,"type":"BadMsgNotification","arguments":[{"name":"bad_msg_id","type":"long"},{"name":"bad_msg_seqno","type":"int"},{"name":"error_code","type":"int"},{"name":"new_server_salt","type":"long"}]},{"kind":"class","name":"mt_msg_resend_req","id":2105940488,"type":"MsgResendReq","arguments":[{"name":"msg_ids","type":"long","typeModifiers":{"isVector":true}}]},{"kind":"class","name":"mt_msgs_state_req","id":3664378706,"type":"MsgsStateReq","arguments":[{"name":"msg_ids","type":"long","typeModifiers":{"isVector":true}}]},{"kind":"class","name":"mt_msgs_state_info","id":81704317,"type":"MsgsStateInfo","arguments":[{"name":"req_msg_id","type":"long"},{"name":"info","type":"bytes"}]},{"kind":"class","name":"mt_msgs_all_info","id":2361446705,"type":"MsgsAllInfo","arguments":[{"name":"msg_ids","type":"long","typeModifiers":{"isVector":true}},{"name":"info","type":"bytes"}]},{"kind":"class","name":"mt_msg_detailed_info","id":661470918,"type":"MsgDetailedInfo","arguments":[{"name":"msg_id","type":"long"},{"name":"answer_msg_id","type":"long"},{"name":"bytes","type":"int"},{"name":"status","type":"int"}]},{"kind":"class","name":"mt_msg_new_detailed_info","id":2157819615,"type":"MsgDetailedInfo","arguments":[{"name":"answer_msg_id","type":"long"},{"name":"bytes","type":"int"},{"name":"status","type":"int"}]},{"kind":"class","name":"mt_destroy_auth_key_ok","id":4133544404,"type":"DestroyAuthKeyRes","arguments":[]},{"kind":"class","name":"mt_destroy_auth_key_none","id":178201177,"type":"DestroyAuthKeyRes","arguments":[]},{"kind":"class","name":"mt_destroy_auth_key_fail","id":3926956819,"type":"DestroyAuthKeyRes","arguments":[]},{"kind":"class","name":"mt_http_wait","id":2459514271,"type":"HttpWait","arguments":[{"name":"max_delay","type":"int"},{"name":"wait_after","type":"int"},{"name":"max_wait","type":"int"}]},{"kind":"class","name":"mt_req_pq_multi","id":3195965169,"type":"ResPQ","arguments":[{"name":"nonce","type":"int128"}]},{"kind":"class","name":"mt_req_DH_params","id":3608339646,"type":"Server_DH_Params","arguments":[{"name":"nonce","type":"int128"},{"name":"server_nonce","type":"int128"},{"name":"p","type":"bytes"},{"name":"q","type":"bytes"},{"name":"public_key_fingerprint","type":"long"},{"name":"encrypted_data","type":"bytes"}]},{"kind":"class","name":"mt_set_client_DH_params","id":4110704415,"type":"Set_client_DH_params_answer","arguments":[{"name":"nonce","type":"int128"},{"name":"server_nonce","type":"int128"},{"name":"encrypted_data","type":"bytes"}]},{"kind":"class","name":"mt_rpc_drop_answer","id":1491380032,"type":"RpcDropAnswer","arguments":[{"name":"req_msg_id","type":"long"}]},{"kind":"class","name":"mt_get_future_salts","id":3105996036,"type":"FutureSalts","arguments":[{"name":"num","type":"int"}]},{"kind":"class","name":"mt_ping","id":2059302892,"type":"Pong","arguments":[{"name":"ping_id","type":"long"}]},{"kind":"class","name":"mt_ping_delay_disconnect","id":4081220492,"type":"Pong","arguments":[{"name":"ping_id","type":"long"},{"name":"disconnect_delay","type":"int"}]},{"kind":"class","name":"mt_destroy_session","id":3880853798,"type":"DestroySessionRes","arguments":[{"name":"session_id","type":"long"}]},{"kind":"class","name":"mt_destroy_auth_key","id":3510849888,"type":"DestroyAuthKeyRes","arguments":[]}] \ No newline at end of file +[{"kind":"class","name":"mt_resPQ","id":85337187,"type":"ResPQ","arguments":[{"name":"nonce","type":"int128"},{"name":"server_nonce","type":"int128"},{"name":"pq","type":"bytes"},{"name":"server_public_key_fingerprints","type":"long","typeModifiers":{"isVector":true}}]},{"kind":"class","name":"mt_p_q_inner_data_dc","id":2851430293,"type":"P_Q_inner_data","arguments":[{"name":"pq","type":"bytes"},{"name":"p","type":"bytes"},{"name":"q","type":"bytes"},{"name":"nonce","type":"int128"},{"name":"server_nonce","type":"int128"},{"name":"new_nonce","type":"int256"},{"name":"dc","type":"int"}]},{"kind":"class","name":"mt_p_q_inner_data_temp_dc","id":1459478408,"type":"P_Q_inner_data","arguments":[{"name":"pq","type":"bytes"},{"name":"p","type":"bytes"},{"name":"q","type":"bytes"},{"name":"nonce","type":"int128"},{"name":"server_nonce","type":"int128"},{"name":"new_nonce","type":"int256"},{"name":"dc","type":"int"},{"name":"expires_in","type":"int"}]},{"kind":"class","name":"mt_server_DH_params_ok","id":3504867164,"type":"Server_DH_Params","arguments":[{"name":"nonce","type":"int128"},{"name":"server_nonce","type":"int128"},{"name":"encrypted_answer","type":"bytes"}]},{"kind":"class","name":"mt_server_DH_inner_data","id":3045658042,"type":"Server_DH_inner_data","arguments":[{"name":"nonce","type":"int128"},{"name":"server_nonce","type":"int128"},{"name":"g","type":"int"},{"name":"dh_prime","type":"bytes"},{"name":"g_a","type":"bytes"},{"name":"server_time","type":"int"}]},{"kind":"class","name":"mt_client_DH_inner_data","id":1715713620,"type":"Client_DH_Inner_Data","arguments":[{"name":"nonce","type":"int128"},{"name":"server_nonce","type":"int128"},{"name":"retry_id","type":"long"},{"name":"g_b","type":"bytes"}]},{"kind":"class","name":"mt_dh_gen_ok","id":1003222836,"type":"Set_client_DH_params_answer","arguments":[{"name":"nonce","type":"int128"},{"name":"server_nonce","type":"int128"},{"name":"new_nonce_hash1","type":"int128"}]},{"kind":"class","name":"mt_dh_gen_retry","id":1188831161,"type":"Set_client_DH_params_answer","arguments":[{"name":"nonce","type":"int128"},{"name":"server_nonce","type":"int128"},{"name":"new_nonce_hash2","type":"int128"}]},{"kind":"class","name":"mt_dh_gen_fail","id":2795351554,"type":"Set_client_DH_params_answer","arguments":[{"name":"nonce","type":"int128"},{"name":"server_nonce","type":"int128"},{"name":"new_nonce_hash3","type":"int128"}]},{"kind":"class","name":"mt_bind_auth_key_inner","id":1973679973,"type":"BindAuthKeyInner","arguments":[{"name":"nonce","type":"long"},{"name":"temp_auth_key_id","type":"long"},{"name":"perm_auth_key_id","type":"long"},{"name":"temp_session_id","type":"long"},{"name":"expires_at","type":"int"}]},{"kind":"class","name":"mt_rpc_error","id":558156313,"type":"RpcError","arguments":[{"name":"error_code","type":"int"},{"name":"error_message","type":"string"}]},{"kind":"class","name":"mt_rpc_answer_unknown","id":1579864942,"type":"RpcDropAnswer","arguments":[]},{"kind":"class","name":"mt_rpc_answer_dropped_running","id":3447252358,"type":"RpcDropAnswer","arguments":[]},{"kind":"class","name":"mt_rpc_answer_dropped","id":2755319991,"type":"RpcDropAnswer","arguments":[{"name":"msg_id","type":"long"},{"name":"seq_no","type":"int"},{"name":"bytes","type":"int"}]},{"kind":"class","name":"mt_future_salt","id":155834844,"type":"FutureSalt","arguments":[{"name":"valid_since","type":"int"},{"name":"valid_until","type":"int"},{"name":"salt","type":"long"}]},{"kind":"class","name":"mt_future_salts","id":2924480661,"type":"FutureSalts","arguments":[{"name":"req_msg_id","type":"long"},{"name":"now","type":"int"},{"name":"salts","type":"mt_future_salt","typeModifiers":{"isBareVector":true,"isBareType":true,"constructorId":155834844}}]},{"kind":"class","name":"mt_pong","id":880243653,"type":"Pong","arguments":[{"name":"msg_id","type":"long"},{"name":"ping_id","type":"long"}]},{"kind":"class","name":"mt_destroy_session_ok","id":3793765884,"type":"DestroySessionRes","arguments":[{"name":"session_id","type":"long"}]},{"kind":"class","name":"mt_destroy_session_none","id":1658015945,"type":"DestroySessionRes","arguments":[{"name":"session_id","type":"long"}]},{"kind":"class","name":"mt_new_session_created","id":2663516424,"type":"NewSession","arguments":[{"name":"first_msg_id","type":"long"},{"name":"unique_id","type":"long"},{"name":"server_salt","type":"long"}]},{"kind":"class","name":"mt_msgs_ack","id":1658238041,"type":"MsgsAck","arguments":[{"name":"msg_ids","type":"long","typeModifiers":{"isVector":true}}]},{"kind":"class","name":"mt_bad_msg_notification","id":2817521681,"type":"BadMsgNotification","arguments":[{"name":"bad_msg_id","type":"long"},{"name":"bad_msg_seqno","type":"int"},{"name":"error_code","type":"int"}]},{"kind":"class","name":"mt_bad_server_salt","id":3987424379,"type":"BadMsgNotification","arguments":[{"name":"bad_msg_id","type":"long"},{"name":"bad_msg_seqno","type":"int"},{"name":"error_code","type":"int"},{"name":"new_server_salt","type":"long"}]},{"kind":"class","name":"mt_msg_resend_req","id":2105940488,"type":"MsgResendReq","arguments":[{"name":"msg_ids","type":"long","typeModifiers":{"isVector":true}}]},{"kind":"class","name":"mt_msgs_state_req","id":3664378706,"type":"MsgsStateReq","arguments":[{"name":"msg_ids","type":"long","typeModifiers":{"isVector":true}}]},{"kind":"class","name":"mt_msgs_state_info","id":81704317,"type":"MsgsStateInfo","arguments":[{"name":"req_msg_id","type":"long"},{"name":"info","type":"bytes"}]},{"kind":"class","name":"mt_msgs_all_info","id":2361446705,"type":"MsgsAllInfo","arguments":[{"name":"msg_ids","type":"long","typeModifiers":{"isVector":true}},{"name":"info","type":"bytes"}]},{"kind":"class","name":"mt_msg_detailed_info","id":661470918,"type":"MsgDetailedInfo","arguments":[{"name":"msg_id","type":"long"},{"name":"answer_msg_id","type":"long"},{"name":"bytes","type":"int"},{"name":"status","type":"int"}]},{"kind":"class","name":"mt_msg_new_detailed_info","id":2157819615,"type":"MsgDetailedInfo","arguments":[{"name":"answer_msg_id","type":"long"},{"name":"bytes","type":"int"},{"name":"status","type":"int"}]},{"kind":"class","name":"mt_destroy_auth_key_ok","id":4133544404,"type":"DestroyAuthKeyRes","arguments":[]},{"kind":"class","name":"mt_destroy_auth_key_none","id":178201177,"type":"DestroyAuthKeyRes","arguments":[]},{"kind":"class","name":"mt_destroy_auth_key_fail","id":3926956819,"type":"DestroyAuthKeyRes","arguments":[]},{"kind":"class","name":"mt_http_wait","id":2459514271,"type":"HttpWait","arguments":[{"name":"max_delay","type":"int"},{"name":"wait_after","type":"int"},{"name":"max_wait","type":"int"}]},{"kind":"class","name":"mt_req_pq_multi","id":3195965169,"type":"ResPQ","arguments":[{"name":"nonce","type":"int128"}]},{"kind":"class","name":"mt_req_DH_params","id":3608339646,"type":"Server_DH_Params","arguments":[{"name":"nonce","type":"int128"},{"name":"server_nonce","type":"int128"},{"name":"p","type":"bytes"},{"name":"q","type":"bytes"},{"name":"public_key_fingerprint","type":"long"},{"name":"encrypted_data","type":"bytes"}]},{"kind":"class","name":"mt_set_client_DH_params","id":4110704415,"type":"Set_client_DH_params_answer","arguments":[{"name":"nonce","type":"int128"},{"name":"server_nonce","type":"int128"},{"name":"encrypted_data","type":"bytes"}]},{"kind":"class","name":"mt_rpc_drop_answer","id":1491380032,"type":"RpcDropAnswer","arguments":[{"name":"req_msg_id","type":"long"}]},{"kind":"class","name":"mt_get_future_salts","id":3105996036,"type":"FutureSalts","arguments":[{"name":"num","type":"int"}]},{"kind":"class","name":"mt_ping","id":2059302892,"type":"Pong","arguments":[{"name":"ping_id","type":"long"}]},{"kind":"class","name":"mt_ping_delay_disconnect","id":4081220492,"type":"Pong","arguments":[{"name":"ping_id","type":"long"},{"name":"disconnect_delay","type":"int"}]},{"kind":"class","name":"mt_destroy_session","id":3880853798,"type":"DestroySessionRes","arguments":[{"name":"session_id","type":"long"}]},{"kind":"class","name":"mt_destroy_auth_key","id":3510849888,"type":"DestroyAuthKeyRes","arguments":[]}] diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 50113116..cfa8a90e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,5 +1,9 @@ lockfileVersion: '6.0' +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + importers: .: @@ -30,11 +34,11 @@ importers: specifier: 8.5.4 version: 8.5.4 '@typescript-eslint/eslint-plugin': - specifier: 5.59.8 - version: 5.59.8(@typescript-eslint/parser@5.59.8)(eslint@8.42.0)(typescript@5.0.4) + specifier: 6.4.0 + version: 6.4.0(@typescript-eslint/parser@6.4.0)(eslint@8.47.0)(typescript@5.0.4) '@typescript-eslint/parser': - specifier: 5.59.8 - version: 5.59.8(eslint@8.42.0)(typescript@5.0.4) + specifier: 6.4.0 + version: 6.4.0(eslint@8.47.0)(typescript@5.0.4) chai: specifier: 4.3.7 version: 4.3.7 @@ -42,23 +46,23 @@ importers: specifier: 3.2.0 version: 3.2.0 eslint: - specifier: 8.42.0 - version: 8.42.0 + specifier: 8.47.0 + version: 8.47.0 eslint-config-prettier: specifier: 8.8.0 - version: 8.8.0(eslint@8.42.0) + version: 8.8.0(eslint@8.47.0) eslint-import-resolver-typescript: - specifier: 3.5.5 - version: 3.5.5(@typescript-eslint/parser@5.59.8)(eslint-plugin-import@2.27.5)(eslint@8.42.0) + specifier: 3.6.0 + version: 3.6.0(@typescript-eslint/parser@6.4.0)(eslint-plugin-import@2.28.0)(eslint@8.47.0) eslint-plugin-ascii: specifier: 1.0.0 version: 1.0.0 eslint-plugin-import: - specifier: 2.27.5 - version: 2.27.5(@typescript-eslint/parser@5.59.8)(eslint-import-resolver-typescript@3.5.5)(eslint@8.42.0) + specifier: 2.28.0 + version: 2.28.0(@typescript-eslint/parser@6.4.0)(eslint-import-resolver-typescript@3.6.0)(eslint@8.47.0) eslint-plugin-simple-import-sort: specifier: 10.0.0 - version: 10.0.0(eslint@8.42.0) + version: 10.0.0(eslint@8.47.0) glob: specifier: 10.2.6 version: 10.2.6 @@ -272,8 +276,8 @@ importers: specifier: workspace:^1.0.0 version: link:../core ip6: - specifier: 0.2.10 - version: 0.2.10 + specifier: 0.2.7 + version: 0.2.7 packages/sqlite: dependencies: @@ -347,6 +351,11 @@ importers: packages: + /@aashutoshrathi/word-wrap@1.2.6: + resolution: {integrity: sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA==} + engines: {node: '>=0.10.0'} + dev: true + /@ampproject/remapping@2.2.0: resolution: {integrity: sha512-qRmjj8nj9qmLTQXXmaR1cck3UXSRMPrbsLJAasZpF+t3riI71BXed5ebIOYwQntykeZuhjsdweEc9BxH5Jc26w==} engines: {node: '>=6.0.0'} @@ -719,14 +728,14 @@ packages: '@jridgewell/trace-mapping': 0.3.9 dev: true - /@eslint-community/eslint-utils@4.4.0(eslint@8.42.0): + /@eslint-community/eslint-utils@4.4.0(eslint@8.47.0): resolution: {integrity: sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} peerDependencies: eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 dependencies: - eslint: 8.42.0 - eslint-visitor-keys: 3.4.1 + eslint: 8.47.0 + eslint-visitor-keys: 3.4.3 dev: true /@eslint-community/regexpp@4.5.1: @@ -734,13 +743,18 @@ packages: engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} dev: true - /@eslint/eslintrc@2.0.3: - resolution: {integrity: sha512-+5gy6OQfk+xx3q0d6jGZZC3f3KzAkXc/IanVxd1is/VIIziRqqt3ongQz0FiTUXqTk0c7aDB3OaFuKnuSoJicQ==} + /@eslint-community/regexpp@4.6.2: + resolution: {integrity: sha512-pPTNuaAG3QMH+buKyBIGJs3g/S5y0caxw0ygM3YyE6yJFySwiGGSzA+mM3KJ8QQvzeLh3blwgSonkFjgQdxzMw==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + dev: true + + /@eslint/eslintrc@2.1.2: + resolution: {integrity: sha512-+wvgpDsrB1YqAMdEUCcnTlpfVBH7Vqn6A/NT3D8WVXFIaKMlErPIZT3oCIAVCOtarRpMtelZLqJeU3t7WY6X6g==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} dependencies: ajv: 6.12.6 debug: 4.3.4(supports-color@8.1.1) - espree: 9.5.2 + espree: 9.6.1 globals: 13.20.0 ignore: 5.2.0 import-fresh: 3.3.0 @@ -751,8 +765,8 @@ packages: - supports-color dev: true - /@eslint/js@8.42.0: - resolution: {integrity: sha512-6SWlXpWU5AvId8Ac7zjzmIOqMOba/JWY8XZ4A7q7Gn1Vlfg/SFFIlrtHXt9nPn4op9ZPAkl91Jao+QQv3r/ukw==} + /@eslint/js@8.47.0: + resolution: {integrity: sha512-P6omY1zv5MItm93kLM8s2vr1HICJH8v0dvddDhysbIuZ+vcjOHg5Zbkf1mTkcmi2JA9oBG2anOkRnW8WJTS8Og==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} dev: true @@ -882,18 +896,6 @@ packages: dev: true optional: true - /@pkgr/utils@2.4.1: - resolution: {integrity: sha512-JOqwkgFEyi+OROIyq7l4Jy28h/WwhDnG/cPkXG2Z1iFbubB6jsHW1NDvmyOzTBxHr3yg68YGirmh1JUgMqa+9w==} - engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} - dependencies: - cross-spawn: 7.0.3 - fast-glob: 3.2.12 - is-glob: 4.0.3 - open: 9.1.0 - picocolors: 1.0.0 - tslib: 2.5.3 - dev: true - /@tokenizer/token@0.3.0: resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==} dev: false @@ -937,8 +939,8 @@ packages: resolution: {integrity: sha512-FhpRzf927MNQdRZP0J5DLIdTXhjLYzeUTmLAu69mnVksLH9CJY3IuSeEgbKUki7GQZm0WqDkGzyxju2EZGD2wA==} dev: true - /@types/json-schema@7.0.11: - resolution: {integrity: sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==} + /@types/json-schema@7.0.12: + resolution: {integrity: sha512-Hr5Jfhc9eYOQNPYO5WLDq/n4jqijdHNlDXjuAQkkt+mWdQR+XJToOHrsD4cPaMXpn6KO7y2+wM8AZEs8VpBLVA==} dev: true /@types/json5@0.0.29: @@ -980,133 +982,134 @@ packages: '@types/node': 18.16.0 dev: true - /@typescript-eslint/eslint-plugin@5.59.8(@typescript-eslint/parser@5.59.8)(eslint@8.42.0)(typescript@5.0.4): - resolution: {integrity: sha512-JDMOmhXteJ4WVKOiHXGCoB96ADWg9q7efPWHRViT/f09bA8XOMLAVHHju3l0MkZnG1izaWXYmgvQcUjTRcpShQ==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + /@typescript-eslint/eslint-plugin@6.4.0(@typescript-eslint/parser@6.4.0)(eslint@8.47.0)(typescript@5.0.4): + resolution: {integrity: sha512-62o2Hmc7Gs3p8SLfbXcipjWAa6qk2wZGChXG2JbBtYpwSRmti/9KHLqfbLs9uDigOexG+3PaQ9G2g3201FWLKg==} + engines: {node: ^16.0.0 || >=18.0.0} peerDependencies: - '@typescript-eslint/parser': ^5.0.0 - eslint: ^6.0.0 || ^7.0.0 || ^8.0.0 + '@typescript-eslint/parser': ^6.0.0 || ^6.0.0-alpha + eslint: ^7.0.0 || ^8.0.0 typescript: '*' peerDependenciesMeta: typescript: optional: true dependencies: '@eslint-community/regexpp': 4.5.1 - '@typescript-eslint/parser': 5.59.8(eslint@8.42.0)(typescript@5.0.4) - '@typescript-eslint/scope-manager': 5.59.8 - '@typescript-eslint/type-utils': 5.59.8(eslint@8.42.0)(typescript@5.0.4) - '@typescript-eslint/utils': 5.59.8(eslint@8.42.0)(typescript@5.0.4) + '@typescript-eslint/parser': 6.4.0(eslint@8.47.0)(typescript@5.0.4) + '@typescript-eslint/scope-manager': 6.4.0 + '@typescript-eslint/type-utils': 6.4.0(eslint@8.47.0)(typescript@5.0.4) + '@typescript-eslint/utils': 6.4.0(eslint@8.47.0)(typescript@5.0.4) + '@typescript-eslint/visitor-keys': 6.4.0 debug: 4.3.4(supports-color@8.1.1) - eslint: 8.42.0 - grapheme-splitter: 1.0.4 - ignore: 5.2.0 - natural-compare-lite: 1.4.0 - semver: 7.5.1 - tsutils: 3.21.0(typescript@5.0.4) + eslint: 8.47.0 + graphemer: 1.4.0 + ignore: 5.2.4 + natural-compare: 1.4.0 + semver: 7.5.4 + ts-api-utils: 1.0.1(typescript@5.0.4) typescript: 5.0.4 transitivePeerDependencies: - supports-color dev: true - /@typescript-eslint/parser@5.59.8(eslint@8.42.0)(typescript@5.0.4): - resolution: {integrity: sha512-AnR19RjJcpjoeGojmwZtCwBX/RidqDZtzcbG3xHrmz0aHHoOcbWnpDllenRDmDvsV0RQ6+tbb09/kyc+UT9Orw==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + /@typescript-eslint/parser@6.4.0(eslint@8.47.0)(typescript@5.0.4): + resolution: {integrity: sha512-I1Ah1irl033uxjxO9Xql7+biL3YD7w9IU8zF+xlzD/YxY6a4b7DYA08PXUUCbm2sEljwJF6ERFy2kTGAGcNilg==} + engines: {node: ^16.0.0 || >=18.0.0} peerDependencies: - eslint: ^6.0.0 || ^7.0.0 || ^8.0.0 + eslint: ^7.0.0 || ^8.0.0 typescript: '*' peerDependenciesMeta: typescript: optional: true dependencies: - '@typescript-eslint/scope-manager': 5.59.8 - '@typescript-eslint/types': 5.59.8 - '@typescript-eslint/typescript-estree': 5.59.8(typescript@5.0.4) + '@typescript-eslint/scope-manager': 6.4.0 + '@typescript-eslint/types': 6.4.0 + '@typescript-eslint/typescript-estree': 6.4.0(typescript@5.0.4) + '@typescript-eslint/visitor-keys': 6.4.0 debug: 4.3.4(supports-color@8.1.1) - eslint: 8.42.0 + eslint: 8.47.0 typescript: 5.0.4 transitivePeerDependencies: - supports-color dev: true - /@typescript-eslint/scope-manager@5.59.8: - resolution: {integrity: sha512-/w08ndCYI8gxGf+9zKf1vtx/16y8MHrZs5/tnjHhMLNSixuNcJavSX4wAiPf4aS5x41Es9YPCn44MIe4cxIlig==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + /@typescript-eslint/scope-manager@6.4.0: + resolution: {integrity: sha512-TUS7vaKkPWDVvl7GDNHFQMsMruD+zhkd3SdVW0d7b+7Zo+bd/hXJQ8nsiUZMi1jloWo6c9qt3B7Sqo+flC1nig==} + engines: {node: ^16.0.0 || >=18.0.0} dependencies: - '@typescript-eslint/types': 5.59.8 - '@typescript-eslint/visitor-keys': 5.59.8 + '@typescript-eslint/types': 6.4.0 + '@typescript-eslint/visitor-keys': 6.4.0 dev: true - /@typescript-eslint/type-utils@5.59.8(eslint@8.42.0)(typescript@5.0.4): - resolution: {integrity: sha512-+5M518uEIHFBy3FnyqZUF3BMP+AXnYn4oyH8RF012+e7/msMY98FhGL5SrN29NQ9xDgvqCgYnsOiKp1VjZ/fpA==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + /@typescript-eslint/type-utils@6.4.0(eslint@8.47.0)(typescript@5.0.4): + resolution: {integrity: sha512-TvqrUFFyGY0cX3WgDHcdl2/mMCWCDv/0thTtx/ODMY1QhEiyFtv/OlLaNIiYLwRpAxAtOLOY9SUf1H3Q3dlwAg==} + engines: {node: ^16.0.0 || >=18.0.0} peerDependencies: - eslint: '*' + eslint: ^7.0.0 || ^8.0.0 typescript: '*' peerDependenciesMeta: typescript: optional: true dependencies: - '@typescript-eslint/typescript-estree': 5.59.8(typescript@5.0.4) - '@typescript-eslint/utils': 5.59.8(eslint@8.42.0)(typescript@5.0.4) + '@typescript-eslint/typescript-estree': 6.4.0(typescript@5.0.4) + '@typescript-eslint/utils': 6.4.0(eslint@8.47.0)(typescript@5.0.4) debug: 4.3.4(supports-color@8.1.1) - eslint: 8.42.0 - tsutils: 3.21.0(typescript@5.0.4) + eslint: 8.47.0 + ts-api-utils: 1.0.1(typescript@5.0.4) typescript: 5.0.4 transitivePeerDependencies: - supports-color dev: true - /@typescript-eslint/types@5.59.8: - resolution: {integrity: sha512-+uWuOhBTj/L6awoWIg0BlWy0u9TyFpCHrAuQ5bNfxDaZ1Ppb3mx6tUigc74LHcbHpOHuOTOJrBoAnhdHdaea1w==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + /@typescript-eslint/types@6.4.0: + resolution: {integrity: sha512-+FV9kVFrS7w78YtzkIsNSoYsnOtrYVnKWSTVXoL1761CsCRv5wpDOINgsXpxD67YCLZtVQekDDyaxfjVWUJmmg==} + engines: {node: ^16.0.0 || >=18.0.0} dev: true - /@typescript-eslint/typescript-estree@5.59.8(typescript@5.0.4): - resolution: {integrity: sha512-Jy/lPSDJGNow14vYu6IrW790p7HIf/SOV1Bb6lZ7NUkLc2iB2Z9elESmsaUtLw8kVqogSbtLH9tut5GCX1RLDg==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + /@typescript-eslint/typescript-estree@6.4.0(typescript@5.0.4): + resolution: {integrity: sha512-iDPJArf/K2sxvjOR6skeUCNgHR/tCQXBsa+ee1/clRKr3olZjZ/dSkXPZjG6YkPtnW6p5D1egeEPMCW6Gn4yLA==} + engines: {node: ^16.0.0 || >=18.0.0} peerDependencies: typescript: '*' peerDependenciesMeta: typescript: optional: true dependencies: - '@typescript-eslint/types': 5.59.8 - '@typescript-eslint/visitor-keys': 5.59.8 + '@typescript-eslint/types': 6.4.0 + '@typescript-eslint/visitor-keys': 6.4.0 debug: 4.3.4(supports-color@8.1.1) globby: 11.1.0 is-glob: 4.0.3 - semver: 7.5.1 - tsutils: 3.21.0(typescript@5.0.4) + semver: 7.5.4 + ts-api-utils: 1.0.1(typescript@5.0.4) typescript: 5.0.4 transitivePeerDependencies: - supports-color dev: true - /@typescript-eslint/utils@5.59.8(eslint@8.42.0)(typescript@5.0.4): - resolution: {integrity: sha512-Tr65630KysnNn9f9G7ROF3w1b5/7f6QVCJ+WK9nhIocWmx9F+TmCAcglF26Vm7z8KCTwoKcNEBZrhlklla3CKg==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + /@typescript-eslint/utils@6.4.0(eslint@8.47.0)(typescript@5.0.4): + resolution: {integrity: sha512-BvvwryBQpECPGo8PwF/y/q+yacg8Hn/2XS+DqL/oRsOPK+RPt29h5Ui5dqOKHDlbXrAeHUTnyG3wZA0KTDxRZw==} + engines: {node: ^16.0.0 || >=18.0.0} peerDependencies: - eslint: ^6.0.0 || ^7.0.0 || ^8.0.0 + eslint: ^7.0.0 || ^8.0.0 dependencies: - '@eslint-community/eslint-utils': 4.4.0(eslint@8.42.0) - '@types/json-schema': 7.0.11 + '@eslint-community/eslint-utils': 4.4.0(eslint@8.47.0) + '@types/json-schema': 7.0.12 '@types/semver': 7.5.0 - '@typescript-eslint/scope-manager': 5.59.8 - '@typescript-eslint/types': 5.59.8 - '@typescript-eslint/typescript-estree': 5.59.8(typescript@5.0.4) - eslint: 8.42.0 - eslint-scope: 5.1.1 - semver: 7.5.1 + '@typescript-eslint/scope-manager': 6.4.0 + '@typescript-eslint/types': 6.4.0 + '@typescript-eslint/typescript-estree': 6.4.0(typescript@5.0.4) + eslint: 8.47.0 + semver: 7.5.4 transitivePeerDependencies: - supports-color - typescript dev: true - /@typescript-eslint/visitor-keys@5.59.8: - resolution: {integrity: sha512-pJhi2ms0x0xgloT7xYabil3SGGlojNNKjK/q6dB3Ey0uJLMjK2UDGJvHieiyJVW/7C3KI+Z4Q3pEHkm4ejA+xQ==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + /@typescript-eslint/visitor-keys@6.4.0: + resolution: {integrity: sha512-yJSfyT+uJm+JRDWYRYdCm2i+pmvXJSMtPR9Cq5/XQs4QIgNoLcoRtDdzsLbLsFM/c6um6ohQkg/MLxWvoIndJA==} + engines: {node: ^16.0.0 || >=18.0.0} dependencies: - '@typescript-eslint/types': 5.59.8 + '@typescript-eslint/types': 6.4.0 eslint-visitor-keys: 3.4.1 dev: true @@ -1122,12 +1125,12 @@ packages: resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==} dev: false - /acorn-jsx@5.3.2(acorn@8.8.2): + /acorn-jsx@5.3.2(acorn@8.10.0): resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 dependencies: - acorn: 8.8.2 + acorn: 8.10.0 dev: true /acorn-walk@8.2.0: @@ -1135,6 +1138,12 @@ packages: engines: {node: '>=0.4.0'} dev: true + /acorn@8.10.0: + resolution: {integrity: sha512-F0SAmZ8iUtS//m8DmCTA0jlh6TDKkHQyK6xc6V4KDTyZKA9dnvX9/3sRTVQrWm79glUAZbnmmNcdYwUIHWVybw==} + engines: {node: '>=0.4.0'} + hasBin: true + dev: true + /acorn@8.8.2: resolution: {integrity: sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw==} engines: {node: '>=0.4.0'} @@ -1318,6 +1327,17 @@ packages: engines: {node: '>=8'} dev: true + /array.prototype.findlastindex@1.2.2: + resolution: {integrity: sha512-tb5thFFlUcp7NdNF6/MpDk/1r/4awWG1FIz3YqDf+/zJSTezBb+/5WViH41obXULHVpDzoiCLpJ/ZO9YbJMsdw==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.2 + define-properties: 1.2.0 + es-abstract: 1.21.2 + es-shim-unscopables: 1.0.0 + get-intrinsic: 1.2.1 + dev: true + /array.prototype.flat@1.3.1: resolution: {integrity: sha512-roTU0KWIOmJ4DRLmwKd19Otg0/mT3qPNt0Qb3GWW8iObuZXxrjB/pzn0R3hqpRSWg4HCwqx+0vwOnWnvlOyeIA==} engines: {node: '>= 0.4'} @@ -1375,6 +1395,7 @@ packages: /big-integer@1.6.51: resolution: {integrity: sha512-GPEid2Y9QU1Exl1rpO9B2IPJGHPSupF5GnVIP0blYvNOMer2bTvSWs1jGOUg04hTmu67nmLsQ9TBo1puaotBHg==} engines: {node: '>=0.6'} + dev: false /binary-extensions@2.2.0: resolution: {integrity: sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==} @@ -1399,13 +1420,6 @@ packages: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} dev: true - /bplist-parser@0.2.0: - resolution: {integrity: sha512-z0M+byMThzQmD9NILRniCUXYsYpjwnlO8N5uCFaCqIOpqRsJCrQL9NK3JsD67CN5a08nF5oIL2bD6loTdHOuKw==} - engines: {node: '>= 5.10.0'} - dependencies: - big-integer: 1.6.51 - dev: true - /brace-expansion@1.1.11: resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==} dependencies: @@ -1447,13 +1461,6 @@ packages: ieee754: 1.2.1 dev: false - /bundle-name@3.0.0: - resolution: {integrity: sha512-PKA4BeSvBpQKQ8iPOGCSiell+N8P+Tf1DlwqmYhpe2gAhKPHn8EYOxVT+ShuGmhg8lN8XiSlS80yiExKXrURlw==} - engines: {node: '>=12'} - dependencies: - run-applescript: 5.0.0 - dev: true - /cacache@16.0.7: resolution: {integrity: sha512-a4zfQpp5vm4Ipdvbj+ZrPonikRhm6WBEd4zT1Yc1DXsmAxrPgDwWBLF/u/wTVXSFPIgOJ1U3ghSa2Xm4s3h28w==} engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} @@ -1901,24 +1908,6 @@ packages: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} dev: true - /default-browser-id@3.0.0: - resolution: {integrity: sha512-OZ1y3y0SqSICtE8DE4S8YOE9UZOJ8wO16fKWVP5J1Qz42kV9jcnMVFrEE/noXb/ss3Q4pZIH79kxofzyNNtUNA==} - engines: {node: '>=12'} - dependencies: - bplist-parser: 0.2.0 - untildify: 4.0.0 - dev: true - - /default-browser@4.0.0: - resolution: {integrity: sha512-wX5pXO1+BrhMkSbROFsyxUm0i/cJEScyNhA4PPxc41ICuv05ZZB/MX28s8aZx6xjmatvebIapF6hLEKEcpneUA==} - engines: {node: '>=14.16'} - dependencies: - bundle-name: 3.0.0 - default-browser-id: 3.0.0 - execa: 7.1.1 - titleize: 3.0.0 - dev: true - /default-require-extensions@3.0.0: resolution: {integrity: sha512-ek6DpXq/SCpvjhpFsLFRVtIxJCRw6fUR42lYMVZuUMK7n8eMz4Uh5clckdBjEpLhn/gEBZo7hDJnJcwdKLKQjg==} engines: {node: '>=8'} @@ -1926,11 +1915,6 @@ packages: strip-bom: 4.0.0 dev: true - /define-lazy-prop@3.0.0: - resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} - engines: {node: '>=12'} - dev: true - /define-properties@1.2.0: resolution: {integrity: sha512-xvqAVKGfT1+UAvPwKTVw/njhdQ8ZhXK4lI0bCIuCMrp2up9nPnaDftrLtmpTazqd1o+UY4zgzU+avtMbDP+ldA==} engines: {node: '>= 0.4'} @@ -2200,13 +2184,13 @@ packages: engines: {node: '>=10'} dev: true - /eslint-config-prettier@8.8.0(eslint@8.42.0): + /eslint-config-prettier@8.8.0(eslint@8.47.0): resolution: {integrity: sha512-wLbQiFre3tdGgpDv67NQKnJuTlcUVYHas3k+DZCc2U2BadthoEY4B7hLPvAxaqdyOGCzuLfii2fqGph10va7oA==} hasBin: true peerDependencies: eslint: '>=7.0.0' dependencies: - eslint: 8.42.0 + eslint: 8.47.0 dev: true /eslint-import-resolver-node@0.3.7: @@ -2214,13 +2198,13 @@ packages: dependencies: debug: 3.2.7 is-core-module: 2.12.1 - resolve: 1.22.2 + resolve: 1.22.4 transitivePeerDependencies: - supports-color dev: true - /eslint-import-resolver-typescript@3.5.5(@typescript-eslint/parser@5.59.8)(eslint-plugin-import@2.27.5)(eslint@8.42.0): - resolution: {integrity: sha512-TdJqPHs2lW5J9Zpe17DZNQuDnox4xo2o+0tE7Pggain9Rbc19ik8kFtXdxZ250FVx2kF4vlt2RSf4qlUpG7bhw==} + /eslint-import-resolver-typescript@3.6.0(@typescript-eslint/parser@6.4.0)(eslint-plugin-import@2.28.0)(eslint@8.47.0): + resolution: {integrity: sha512-QTHR9ddNnn35RTxlaEnx2gCxqFlF2SEN0SE2d17SqwyM7YOSI2GHWRYp5BiRkObTUNYPupC/3Fq2a0PpT+EKpg==} engines: {node: ^14.18.0 || >=16.0.0} peerDependencies: eslint: '*' @@ -2228,14 +2212,13 @@ packages: dependencies: debug: 4.3.4(supports-color@8.1.1) enhanced-resolve: 5.14.1 - eslint: 8.42.0 - eslint-module-utils: 2.8.0(@typescript-eslint/parser@5.59.8)(eslint-import-resolver-node@0.3.7)(eslint-import-resolver-typescript@3.5.5)(eslint@8.42.0) - eslint-plugin-import: 2.27.5(@typescript-eslint/parser@5.59.8)(eslint-import-resolver-typescript@3.5.5)(eslint@8.42.0) + eslint: 8.47.0 + eslint-module-utils: 2.8.0(@typescript-eslint/parser@6.4.0)(eslint-import-resolver-node@0.3.7)(eslint-import-resolver-typescript@3.6.0)(eslint@8.47.0) + eslint-plugin-import: 2.28.0(@typescript-eslint/parser@6.4.0)(eslint-import-resolver-typescript@3.6.0)(eslint@8.47.0) + fast-glob: 3.3.1 get-tsconfig: 4.6.0 - globby: 13.1.4 is-core-module: 2.12.1 is-glob: 4.0.3 - synckit: 0.8.5 transitivePeerDependencies: - '@typescript-eslint/parser' - eslint-import-resolver-node @@ -2243,7 +2226,7 @@ packages: - supports-color dev: true - /eslint-module-utils@2.8.0(@typescript-eslint/parser@5.59.8)(eslint-import-resolver-node@0.3.7)(eslint-import-resolver-typescript@3.5.5)(eslint@8.42.0): + /eslint-module-utils@2.8.0(@typescript-eslint/parser@6.4.0)(eslint-import-resolver-node@0.3.7)(eslint-import-resolver-typescript@3.6.0)(eslint@8.47.0): resolution: {integrity: sha512-aWajIYfsqCKRDgUfjEXNN/JlrzauMuSEy5sbd7WXbtW3EH6A6MpwEh42c7qD+MqQo9QMJ6fWLAeIJynx0g6OAw==} engines: {node: '>=4'} peerDependencies: @@ -2264,11 +2247,11 @@ packages: eslint-import-resolver-webpack: optional: true dependencies: - '@typescript-eslint/parser': 5.59.8(eslint@8.42.0)(typescript@5.0.4) + '@typescript-eslint/parser': 6.4.0(eslint@8.47.0)(typescript@5.0.4) debug: 3.2.7 - eslint: 8.42.0 + eslint: 8.47.0 eslint-import-resolver-node: 0.3.7 - eslint-import-resolver-typescript: 3.5.5(@typescript-eslint/parser@5.59.8)(eslint-plugin-import@2.27.5)(eslint@8.42.0) + eslint-import-resolver-typescript: 3.6.0(@typescript-eslint/parser@6.4.0)(eslint-plugin-import@2.28.0)(eslint@8.47.0) transitivePeerDependencies: - supports-color dev: true @@ -2277,8 +2260,8 @@ packages: resolution: {integrity: sha512-NT2asS7tLkXMKBk0GuX6eDUZvb5DWTFDCt7R6a8tvWs5P0my2ybxmCFy3Afxgdcam+wQRAn8JrldLmcK0H5Axg==} dev: true - /eslint-plugin-import@2.27.5(@typescript-eslint/parser@5.59.8)(eslint-import-resolver-typescript@3.5.5)(eslint@8.42.0): - resolution: {integrity: sha512-LmEt3GVofgiGuiE+ORpnvP+kAm3h6MLZJ4Q5HCyHADofsb4VzXFsRiWj3c0OFiV+3DWFh0qg3v9gcPlfc3zRow==} + /eslint-plugin-import@2.28.0(@typescript-eslint/parser@6.4.0)(eslint-import-resolver-typescript@3.6.0)(eslint@8.47.0): + resolution: {integrity: sha512-B8s/n+ZluN7sxj9eUf7/pRFERX0r5bnFA2dCaLHy2ZeaQEAz0k+ZZkFWRFHJAqxfxQDx6KLv9LeIki7cFdwW+Q==} engines: {node: '>=4'} peerDependencies: '@typescript-eslint/parser': '*' @@ -2287,22 +2270,25 @@ packages: '@typescript-eslint/parser': optional: true dependencies: - '@typescript-eslint/parser': 5.59.8(eslint@8.42.0)(typescript@5.0.4) + '@typescript-eslint/parser': 6.4.0(eslint@8.47.0)(typescript@5.0.4) array-includes: 3.1.6 + array.prototype.findlastindex: 1.2.2 array.prototype.flat: 1.3.1 array.prototype.flatmap: 1.3.1 debug: 3.2.7 doctrine: 2.1.0 - eslint: 8.42.0 + eslint: 8.47.0 eslint-import-resolver-node: 0.3.7 - eslint-module-utils: 2.8.0(@typescript-eslint/parser@5.59.8)(eslint-import-resolver-node@0.3.7)(eslint-import-resolver-typescript@3.5.5)(eslint@8.42.0) + eslint-module-utils: 2.8.0(@typescript-eslint/parser@6.4.0)(eslint-import-resolver-node@0.3.7)(eslint-import-resolver-typescript@3.6.0)(eslint@8.47.0) has: 1.0.3 is-core-module: 2.12.1 is-glob: 4.0.3 minimatch: 3.1.2 + object.fromentries: 2.0.6 + object.groupby: 1.0.0 object.values: 1.1.6 - resolve: 1.22.2 - semver: 6.3.0 + resolve: 1.22.4 + semver: 6.3.1 tsconfig-paths: 3.14.2 transitivePeerDependencies: - eslint-import-resolver-typescript @@ -2310,24 +2296,16 @@ packages: - supports-color dev: true - /eslint-plugin-simple-import-sort@10.0.0(eslint@8.42.0): + /eslint-plugin-simple-import-sort@10.0.0(eslint@8.47.0): resolution: {integrity: sha512-AeTvO9UCMSNzIHRkg8S6c3RPy5YEwKWSQPx3DYghLedo2ZQxowPFLGDN1AZ2evfg6r6mjBSZSLxLFsWSu3acsw==} peerDependencies: eslint: '>=5.0.0' dependencies: - eslint: 8.42.0 + eslint: 8.47.0 dev: true - /eslint-scope@5.1.1: - resolution: {integrity: sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==} - engines: {node: '>=8.0.0'} - dependencies: - esrecurse: 4.3.0 - estraverse: 4.3.0 - dev: true - - /eslint-scope@7.2.0: - resolution: {integrity: sha512-DYj5deGlHBfMt15J7rdtyKNq/Nqlv5KfU4iodrQ019XESsRnwXH9KAE0y3cwtUHDo2ob7CypAnCqefh6vioWRw==} + /eslint-scope@7.2.2: + resolution: {integrity: sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} dependencies: esrecurse: 4.3.0 @@ -2339,15 +2317,20 @@ packages: engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} dev: true - /eslint@8.42.0: - resolution: {integrity: sha512-ulg9Ms6E1WPf67PHaEY4/6E2tEn5/f7FXGzr3t9cBMugOmf1INYvuUwwh1aXQN4MfJ6a5K2iNwP3w4AColvI9A==} + /eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + dev: true + + /eslint@8.47.0: + resolution: {integrity: sha512-spUQWrdPt+pRVP1TTJLmfRNJJHHZryFmptzcafwSvHsceV81djHOdnEeDmkdotZyLNjDhrOasNK8nikkoG1O8Q==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} hasBin: true dependencies: - '@eslint-community/eslint-utils': 4.4.0(eslint@8.42.0) - '@eslint-community/regexpp': 4.5.1 - '@eslint/eslintrc': 2.0.3 - '@eslint/js': 8.42.0 + '@eslint-community/eslint-utils': 4.4.0(eslint@8.47.0) + '@eslint-community/regexpp': 4.6.2 + '@eslint/eslintrc': 2.1.2 + '@eslint/js': 8.47.0 '@humanwhocodes/config-array': 0.11.10 '@humanwhocodes/module-importer': 1.0.1 '@nodelib/fs.walk': 1.2.8 @@ -2357,9 +2340,9 @@ packages: debug: 4.3.4(supports-color@8.1.1) doctrine: 3.0.0 escape-string-regexp: 4.0.0 - eslint-scope: 7.2.0 - eslint-visitor-keys: 3.4.1 - espree: 9.5.2 + eslint-scope: 7.2.2 + eslint-visitor-keys: 3.4.3 + espree: 9.6.1 esquery: 1.5.0 esutils: 2.0.3 fast-deep-equal: 3.1.3 @@ -2369,7 +2352,6 @@ packages: globals: 13.20.0 graphemer: 1.4.0 ignore: 5.2.0 - import-fresh: 3.3.0 imurmurhash: 0.1.4 is-glob: 4.0.3 is-path-inside: 3.0.3 @@ -2379,21 +2361,20 @@ packages: lodash.merge: 4.6.2 minimatch: 3.1.2 natural-compare: 1.4.0 - optionator: 0.9.1 + optionator: 0.9.3 strip-ansi: 6.0.1 - strip-json-comments: 3.1.1 text-table: 0.2.0 transitivePeerDependencies: - supports-color dev: true - /espree@9.5.2: - resolution: {integrity: sha512-7OASN1Wma5fum5SrNhFMAMJxOUAbhyfQ8dQ//PJaJbNw0URTPWqIghHWt1MmAANKhHZIYOHruW4Kw4ruUWOdGw==} + /espree@9.6.1: + resolution: {integrity: sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} dependencies: - acorn: 8.8.2 - acorn-jsx: 5.3.2(acorn@8.8.2) - eslint-visitor-keys: 3.4.1 + acorn: 8.10.0 + acorn-jsx: 5.3.2(acorn@8.10.0) + eslint-visitor-keys: 3.4.3 dev: true /esprima@4.0.1: @@ -2416,11 +2397,6 @@ packages: estraverse: 5.3.0 dev: true - /estraverse@4.3.0: - resolution: {integrity: sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==} - engines: {node: '>=4.0'} - dev: true - /estraverse@5.3.0: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} @@ -2480,8 +2456,8 @@ packages: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} dev: true - /fast-glob@3.2.11: - resolution: {integrity: sha512-xrO3+1bxSo3ZVHAnqzyuewYT6aMFHRAd4Kcs92MAonjwQZLsK9d0SF1IyQ3k5PoirxTW0Oe/RqFgMQ6TcNE5Ew==} + /fast-glob@3.2.12: + resolution: {integrity: sha512-DVj4CQIYYow0BlaelwK1pHl5n5cRSJfM60UA0zK891sVInoPri2Ekj7+e1CT3/3qxXenpI+nBBmQAcJPJgaj4w==} engines: {node: '>=8.6.0'} dependencies: '@nodelib/fs.stat': 2.0.5 @@ -2491,8 +2467,8 @@ packages: micromatch: 4.0.5 dev: true - /fast-glob@3.2.12: - resolution: {integrity: sha512-DVj4CQIYYow0BlaelwK1pHl5n5cRSJfM60UA0zK891sVInoPri2Ekj7+e1CT3/3qxXenpI+nBBmQAcJPJgaj4w==} + /fast-glob@3.3.1: + resolution: {integrity: sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==} engines: {node: '>=8.6.0'} dependencies: '@nodelib/fs.stat': 2.0.5 @@ -2830,23 +2806,12 @@ packages: dependencies: array-union: 2.1.0 dir-glob: 3.0.1 - fast-glob: 3.2.11 + fast-glob: 3.2.12 ignore: 5.2.0 merge2: 1.4.1 slash: 3.0.0 dev: true - /globby@13.1.4: - resolution: {integrity: sha512-iui/IiiW+QrJ1X1hKH5qwlMQyv34wJAYwH1vrf8b9kBA4sNiif3gKsMHa+BrdnOpEudWjpotfa7LrTzB1ERS/g==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - dependencies: - dir-glob: 3.0.1 - fast-glob: 3.2.11 - ignore: 5.2.0 - merge2: 1.4.1 - slash: 4.0.0 - dev: true - /gopd@1.0.1: resolution: {integrity: sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==} dependencies: @@ -2856,10 +2821,6 @@ packages: /graceful-fs@4.2.10: resolution: {integrity: sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==} - /grapheme-splitter@1.0.4: - resolution: {integrity: sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==} - dev: true - /graphemer@1.4.0: resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} dev: true @@ -3012,6 +2973,7 @@ packages: /iconv-lite@0.6.3: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} + requiresBuild: true dependencies: safer-buffer: 2.1.2 dev: false @@ -3026,6 +2988,11 @@ packages: engines: {node: '>= 4'} dev: true + /ignore@5.2.4: + resolution: {integrity: sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==} + engines: {node: '>= 4'} + dev: true + /import-fresh@3.3.0: resolution: {integrity: sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==} engines: {node: '>=6'} @@ -3067,8 +3034,8 @@ packages: side-channel: 1.0.4 dev: true - /ip6@0.2.10: - resolution: {integrity: sha512-1LdpyKjhvepd6EbAU6rW4g14vuYtx5TnJX9TfZZBhsM6DsyPQLNzW12rtbUqXBMwqFrLVV/Gcxv0GNFvJp2cYA==} + /ip6@0.2.7: + resolution: {integrity: sha512-zEzGsxn4Uw33TByv0DdX/RRh+VsGfEctOp7CvJq/b4JEjY9OvPB58dsMYiEwIVLsIWHZSJPn3XG8mP9Qv3TG3g==} hasBin: true dev: false @@ -3120,6 +3087,12 @@ packages: has: 1.0.3 dev: true + /is-core-module@2.13.0: + resolution: {integrity: sha512-Z7dk6Qo8pOCp3l4tsX2C5ZVas4V+UxwQodwZhLopL91TX8UyyHEXafPcyoeeWuLrwzHcr3igO78wNLwHJHsMCQ==} + dependencies: + has: 1.0.3 + dev: true + /is-date-object@1.0.5: resolution: {integrity: sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==} engines: {node: '>= 0.4'} @@ -3127,18 +3100,6 @@ packages: has-tostringtag: 1.0.0 dev: true - /is-docker@2.2.1: - resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==} - engines: {node: '>=8'} - hasBin: true - dev: true - - /is-docker@3.0.0: - resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - hasBin: true - dev: true - /is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -3167,14 +3128,6 @@ packages: is-extglob: 2.1.1 dev: true - /is-inside-container@1.0.0: - resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} - engines: {node: '>=14.16'} - hasBin: true - dependencies: - is-docker: 3.0.0 - dev: true - /is-lambda@1.0.1: resolution: {integrity: sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==} dev: false @@ -3292,13 +3245,6 @@ packages: engines: {node: '>=0.10.0'} dev: true - /is-wsl@2.2.0: - resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} - engines: {node: '>=8'} - dependencies: - is-docker: 2.2.1 - dev: true - /isarray@1.0.0: resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} dev: false @@ -3882,10 +3828,6 @@ packages: resolution: {integrity: sha512-ONmRUqK7zj7DWX0D9ADe03wbwOBZxNAfF20PlGfCWQcD3+/MakShIHrMqx9YwPTfxDdF1zLeL+RGZiR9kGMLdg==} dev: false - /natural-compare-lite@1.4.0: - resolution: {integrity: sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g==} - dev: true - /natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} dev: true @@ -4074,6 +4016,24 @@ packages: object-keys: 1.1.1 dev: true + /object.fromentries@2.0.6: + resolution: {integrity: sha512-VciD13dswC4j1Xt5394WR4MzmAQmlgN72phd/riNp9vtD7tp4QQWJ0R4wvclXcafgcYK8veHRed2W6XeGBvcfg==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.2 + define-properties: 1.2.0 + es-abstract: 1.21.2 + dev: true + + /object.groupby@1.0.0: + resolution: {integrity: sha512-70MWG6NfRH9GnbZOikuhPPYzpUpof9iW2J9E4dW7FXTqPNb6rllE6u39SKwwiNh8lCwX3DDb5OgcKGiEBrTTyw==} + dependencies: + call-bind: 1.0.2 + define-properties: 1.2.0 + es-abstract: 1.21.2 + get-intrinsic: 1.2.1 + dev: true + /object.values@1.1.6: resolution: {integrity: sha512-FVVTkD1vENCsAcwNs9k6jea2uHC/X0+JcjG8YA60FN5CMaJmG95wT9jek/xX9nornqGRrBkKtzuAu2wuHpKqvw==} engines: {node: '>= 0.4'} @@ -4102,26 +4062,16 @@ packages: mimic-fn: 4.0.0 dev: true - /open@9.1.0: - resolution: {integrity: sha512-OS+QTnw1/4vrf+9hh1jc1jnYjzSG4ttTBB8UxOwAnInG3Uo4ssetzC1ihqaIHjLJnA5GGlRl6QlZXOTQhRBUvg==} - engines: {node: '>=14.16'} - dependencies: - default-browser: 4.0.0 - define-lazy-prop: 3.0.0 - is-inside-container: 1.0.0 - is-wsl: 2.2.0 - dev: true - - /optionator@0.9.1: - resolution: {integrity: sha512-74RlY5FCnhq4jRxVUPKDaRwrVNXMqsGsiW6AJw4XK8hmtm10wC0ypZBLw5IIp85NZMr91+qd1RvvENwg7jjRFw==} + /optionator@0.9.3: + resolution: {integrity: sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg==} engines: {node: '>= 0.8.0'} dependencies: + '@aashutoshrathi/word-wrap': 1.2.6 deep-is: 0.1.4 fast-levenshtein: 2.0.6 levn: 0.4.1 prelude-ls: 1.2.1 type-check: 0.4.0 - word-wrap: 1.2.3 dev: true /p-limit@2.3.0: @@ -4509,6 +4459,15 @@ packages: supports-preserve-symlinks-flag: 1.0.0 dev: true + /resolve@1.22.4: + resolution: {integrity: sha512-PXNdCiPqDqeUou+w1C2eTQbNfxKSuMxqTCuvlmmMsk1NWHL5fRrhY6Pl0qEYYc6+QqGClco1Qj8XnjPego4wfg==} + hasBin: true + dependencies: + is-core-module: 2.13.0 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + dev: true + /restore-cursor@3.1.0: resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} engines: {node: '>=8'} @@ -4545,13 +4504,6 @@ packages: glob: 10.2.6 dev: true - /run-applescript@5.0.0: - resolution: {integrity: sha512-XcT5rBksx1QdIhlFOCtgZkB99ZEouFZ1E2Kc2LHqNW13U3/74YGdkQRmThTwxy4QIyookibDKYZOPqX//6BlAg==} - engines: {node: '>=12'} - dependencies: - execa: 5.1.1 - dev: true - /run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} dependencies: @@ -4580,6 +4532,7 @@ packages: /safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + requiresBuild: true dev: false optional: true @@ -4593,6 +4546,11 @@ packages: hasBin: true dev: true + /semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + dev: true + /semver@7.5.0: resolution: {integrity: sha512-+XC0AD/R7Q2mPSRuy2Id0+CGTZ98+8f+KvwirxOKIEyid+XSx6HbC63p+O4IndTHuX5Z+JxQ0TghCkO5Cg/2HA==} engines: {node: '>=10'} @@ -4608,6 +4566,14 @@ packages: dependencies: lru-cache: 6.0.0 + /semver@7.5.4: + resolution: {integrity: sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==} + engines: {node: '>=10'} + hasBin: true + dependencies: + lru-cache: 6.0.0 + dev: true + /serialize-javascript@6.0.0: resolution: {integrity: sha512-Qr3TosvguFt8ePWqsvRfrKyQXIiW+nGbYpy8XK24NQHE83caxWt+mIymTT19DGFbNWNLfEwsrkSmN64lVWB9ag==} dependencies: @@ -4671,11 +4637,6 @@ packages: engines: {node: '>=8'} dev: true - /slash@4.0.0: - resolution: {integrity: sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew==} - engines: {node: '>=12'} - dev: true - /slice-ansi@3.0.0: resolution: {integrity: sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==} engines: {node: '>=8'} @@ -4944,14 +4905,6 @@ packages: engines: {node: '>= 0.4'} dev: true - /synckit@0.8.5: - resolution: {integrity: sha512-L1dapNV6vu2s/4Sputv8xGsCdAVlb5nRDMFU/E27D44l5U6cw1g0dGd45uLc+OXjNMmF4ntiMdCimzcjFKQI8Q==} - engines: {node: ^14.18.0 || >=16.0.0} - dependencies: - '@pkgr/utils': 2.4.1 - tslib: 2.5.3 - dev: true - /tapable@2.2.1: resolution: {integrity: sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==} engines: {node: '>=6'} @@ -5017,11 +4970,6 @@ packages: resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} dev: true - /titleize@3.0.0: - resolution: {integrity: sha512-KxVu8EYHDPBdUYdKZdKtU2aj2XfEx9AfjXxE/Aj0vT06w2icA09Vus1rh6eSu1y01akYg6BjIK/hxyLJINoMLQ==} - engines: {node: '>=12'} - dev: true - /to-fast-properties@2.0.0: resolution: {integrity: sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==} engines: {node: '>=4'} @@ -5047,6 +4995,15 @@ packages: engines: {node: '>=8'} dev: true + /ts-api-utils@1.0.1(typescript@5.0.4): + resolution: {integrity: sha512-lC/RGlPmwdrIBFTX59wwNzqh7aR2otPNPR/5brHZm/XKFYKsfqxihXUe9pU3JI+3vGkl+vyCoNNnPhJn3aLK1A==} + engines: {node: '>=16.13.0'} + peerDependencies: + typescript: '>=4.2.0' + dependencies: + typescript: 5.0.4 + dev: true + /ts-node@10.9.1(@types/node@18.16.0)(typescript@5.0.4): resolution: {integrity: sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw==} hasBin: true @@ -5096,24 +5053,10 @@ packages: strip-bom: 3.0.0 dev: true - /tslib@1.14.1: - resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} - dev: true - /tslib@2.5.3: resolution: {integrity: sha512-mSxlJJwl3BMEQCUNnxXBU9jP4JBktcEGhURcPR6VQVlnP0FdDEsIaz0C35dXNGLyRfrATNofF0F5p2KPxQgB+w==} dev: true - /tsutils@3.21.0(typescript@5.0.4): - resolution: {integrity: sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==} - engines: {node: '>= 6'} - peerDependencies: - typescript: '>=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta' - dependencies: - tslib: 1.14.1 - typescript: 5.0.4 - dev: true - /tunnel-agent@0.6.0: resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} dependencies: @@ -5217,11 +5160,6 @@ packages: engines: {node: '>= 10.0.0'} dev: true - /untildify@4.0.0: - resolution: {integrity: sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==} - engines: {node: '>=8'} - dev: true - /uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} dependencies: @@ -5295,11 +5233,6 @@ packages: string-width: 4.2.3 dev: false - /word-wrap@1.2.3: - resolution: {integrity: sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==} - engines: {node: '>=0.10.0'} - dev: true - /workerpool@6.2.1: resolution: {integrity: sha512-ILEIE97kDZvF9Wb9f6h5aXK4swSlKGUcOEGiIYb2OOu/IrDU9iwj0fD//SsA6E5ibwJxpEvhullJY4Sl4GcpAw==} dev: true diff --git a/tsconfig.json b/tsconfig.json index d925ca90..8156e927 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,6 +9,7 @@ "inlineSources": true, "declaration": true, "esModuleInterop": true, + "allowSyntheticDefaultImports": true, "strict": true, "noImplicitAny": true, "noImplicitThis": true,