feat(convert): tdata conversion
This commit is contained in:
parent
a711ebaa1d
commit
0d97b10ff5
21 changed files with 6390 additions and 3 deletions
|
@ -10,10 +10,12 @@
|
|||
"exports": "./src/index.ts",
|
||||
"dependencies": {
|
||||
"@mtcute/core": "workspace:^",
|
||||
"@fuman/utils": "https://pkg.pr.new/teidesu/fuman/@fuman/utils@6017eb4",
|
||||
"@fuman/net": "https://pkg.pr.new/teidesu/fuman/@fuman/net@6017eb4"
|
||||
"@fuman/utils": "https://pkg.pr.new/teidesu/fuman/@fuman/utils@b0c74cb",
|
||||
"@fuman/net": "https://pkg.pr.new/teidesu/fuman/@fuman/net@b0c74cb",
|
||||
"@fuman/io": "https://pkg.pr.new/teidesu/fuman/@fuman/io@b0c74cb"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@mtcute/test": "workspace:^"
|
||||
"@mtcute/test": "workspace:^",
|
||||
"@mtcute/node": "workspace:^"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,3 +3,4 @@ export * from './gramjs/index.js'
|
|||
export * from './mtkruto/index.js'
|
||||
export * from './pyrogram/index.js'
|
||||
export * from './telethon/index.js'
|
||||
export * from './tdesktop/index.js'
|
||||
|
|
Binary file not shown.
Binary file not shown.
BIN
packages/convert/src/tdesktop/__fixtures__/multiacc/key_datas
Normal file
BIN
packages/convert/src/tdesktop/__fixtures__/multiacc/key_datas
Normal file
Binary file not shown.
Binary file not shown.
BIN
packages/convert/src/tdesktop/__fixtures__/passcode/key_datas
Normal file
BIN
packages/convert/src/tdesktop/__fixtures__/passcode/key_datas
Normal file
Binary file not shown.
Binary file not shown.
BIN
packages/convert/src/tdesktop/__fixtures__/simple/key_datas
Normal file
BIN
packages/convert/src/tdesktop/__fixtures__/simple/key_datas
Normal file
Binary file not shown.
5519
packages/convert/src/tdesktop/__snapshots__/tdata.test.ts.snap
Normal file
5519
packages/convert/src/tdesktop/__snapshots__/tdata.test.ts.snap
Normal file
File diff suppressed because it is too large
Load diff
73
packages/convert/src/tdesktop/convert.ts
Normal file
73
packages/convert/src/tdesktop/convert.ts
Normal file
|
@ -0,0 +1,73 @@
|
|||
import { type StringSessionData, readStringSession } from '@mtcute/core/utils.js'
|
||||
import type { MaybeArray } from '@fuman/utils'
|
||||
import { Long } from '@mtcute/core'
|
||||
|
||||
import { DC_MAPPING_PROD } from '../dcs.js'
|
||||
|
||||
import type { TdataOptions } from './tdata.js'
|
||||
import { Tdata } from './tdata.js'
|
||||
import type { InputTdKeyData } from './types.js'
|
||||
|
||||
export async function convertFromTdata(tdata: Tdata | TdataOptions, accountIdx = 0): Promise<StringSessionData> {
|
||||
if (!(tdata instanceof Tdata)) {
|
||||
tdata = await Tdata.open(tdata)
|
||||
}
|
||||
|
||||
const auth = await tdata.readMtpAuthorization(accountIdx)
|
||||
const authKey = auth.authKeys.find(it => it.dcId === auth.mainDcId)
|
||||
if (!authKey) throw new Error('Failed to find auth key')
|
||||
|
||||
return {
|
||||
version: 3,
|
||||
primaryDcs: DC_MAPPING_PROD[auth.mainDcId],
|
||||
authKey: authKey.key,
|
||||
self: {
|
||||
userId: auth.userId.toNumber(),
|
||||
isBot: false,
|
||||
isPremium: false,
|
||||
usernames: [],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export async function convertToTdata(
|
||||
sessions: MaybeArray<StringSessionData | string>,
|
||||
tdata: Tdata | TdataOptions,
|
||||
): Promise<void> {
|
||||
if (!Array.isArray(sessions)) {
|
||||
sessions = [sessions]
|
||||
}
|
||||
|
||||
if (!(tdata instanceof Tdata)) {
|
||||
const keyData: InputTdKeyData = {
|
||||
count: sessions.length,
|
||||
order: Array.from({ length: sessions.length }, (_, i) => i),
|
||||
active: 0,
|
||||
}
|
||||
tdata = await Tdata.create({
|
||||
keyData,
|
||||
...tdata,
|
||||
})
|
||||
}
|
||||
|
||||
for (let i = 0; i < sessions.length; i++) {
|
||||
let session = sessions[i]
|
||||
|
||||
if (typeof session === 'string') {
|
||||
session = readStringSession(session)
|
||||
}
|
||||
|
||||
await tdata.writeMtpAuthorization({
|
||||
userId: Long.fromNumber(session.self?.userId ?? 0),
|
||||
mainDcId: session.primaryDcs.main.id,
|
||||
authKeys: [
|
||||
{
|
||||
dcId: session.primaryDcs.main.id,
|
||||
key: session.authKey,
|
||||
},
|
||||
],
|
||||
authKeysToDestroy: [],
|
||||
}, i)
|
||||
await tdata.writeEmptyMapFile(i)
|
||||
}
|
||||
}
|
55
packages/convert/src/tdesktop/docs.md
Normal file
55
packages/convert/src/tdesktop/docs.md
Normal file
|
@ -0,0 +1,55 @@
|
|||
## How it works
|
||||
|
||||
### Data name
|
||||
|
||||
TDesktop allows for multiple "data names", which is basically
|
||||
a prefix for data storage. Default one is `data`, but you can choose
|
||||
any using `-key` CLI parameter.
|
||||
|
||||
### Local key
|
||||
|
||||
TDesktop uses something called "local key" to encrypt most of the files.
|
||||
The local key itself is stored in `key_datas`, where `data` is the default
|
||||
data name. That file can be passcode-protected, in which case you will
|
||||
need a correct passcode to decrypt it.
|
||||
|
||||
### Encryption
|
||||
|
||||
Without going too deep into details, encryption used is the same
|
||||
as the one used in MTProto v1 for message encryption (see
|
||||
[Telegram docs](https://core.telegram.org/mtproto/description_v1#defining-aes-key-and-initialization-vector)
|
||||
for details).
|
||||
|
||||
There, instead of `auth_key` a local key is used, and instead of
|
||||
`msg_key` lower 16 bytes of sha1 of the contents are used.
|
||||
|
||||
Before encrypting (and computing sha1), content size is prepended, and
|
||||
the result is padded.
|
||||
|
||||
### File naming
|
||||
|
||||
To name different files, TDesktop uses 8 lower bytes of md5 hash,
|
||||
with nibbles switched (i.e. `0xFE => 0xEF`).
|
||||
|
||||
So, for example:
|
||||
|
||||
```typescript
|
||||
const filename = 'data'
|
||||
const md5 = crypto.md5(filename) // 8d777f385d3dfec8815d20f7496026dc
|
||||
const md5Lower = md5.slice(0, 8) // 8d777f385d3dfec8
|
||||
const result = swap8(md5Lower).toUpperHex() // D877F783D5D3EF8C
|
||||
```
|
||||
|
||||
`D877F783D5D3EF8C` is the folder that is most likely present in your
|
||||
`tdata` folder, which is derived simply from `data` and is used
|
||||
for your first account data.
|
||||
|
||||
### Multi accounts
|
||||
|
||||
For second, third, etc. accounts, TDesktop appends `#2`, `#3` etc.
|
||||
to the base data name respectively.
|
||||
|
||||
### MTProto auth keys
|
||||
|
||||
Auth keys are stored in a file named same as account data folder but with
|
||||
`s` appended. So, for the first account that would be `D877F783D5D3EF8Cs`.
|
6
packages/convert/src/tdesktop/index.ts
Normal file
6
packages/convert/src/tdesktop/index.ts
Normal file
|
@ -0,0 +1,6 @@
|
|||
import * as qt from './qt-bundle.js'
|
||||
|
||||
export { qt }
|
||||
export * from './convert.js'
|
||||
export * from './tdata.js'
|
||||
export * from './types.js'
|
4
packages/convert/src/tdesktop/qt-bundle.ts
Normal file
4
packages/convert/src/tdesktop/qt-bundle.ts
Normal file
|
@ -0,0 +1,4 @@
|
|||
import * as read from './qt-reader.js'
|
||||
import * as write from './qt-writer.js'
|
||||
|
||||
export { read, write }
|
35
packages/convert/src/tdesktop/qt-reader.ts
Normal file
35
packages/convert/src/tdesktop/qt-reader.ts
Normal file
|
@ -0,0 +1,35 @@
|
|||
import { Long } from '@mtcute/core'
|
||||
import { type ISyncReadable, read } from '@fuman/io'
|
||||
import { u8 } from '@fuman/utils'
|
||||
|
||||
export function readQByteArray(readable: ISyncReadable): Uint8Array {
|
||||
const length = read.uint32be(readable)
|
||||
if (length === 0 || length === 0xFFFFFFFF) {
|
||||
return u8.empty
|
||||
}
|
||||
|
||||
return read.exactly(readable, length)
|
||||
}
|
||||
|
||||
export function readLong(readable: ISyncReadable): Long {
|
||||
const high = read.int32be(readable)
|
||||
const low = read.int32be(readable)
|
||||
|
||||
return new Long(low, high)
|
||||
}
|
||||
|
||||
export function readCharArray(readable: ISyncReadable): Uint8Array {
|
||||
const buf = readQByteArray(readable)
|
||||
|
||||
if (buf.length > 0) {
|
||||
// drop the last byte, which is always 0
|
||||
return buf.subarray(0, buf.length - 1)
|
||||
}
|
||||
return buf
|
||||
}
|
||||
|
||||
const u16Decoder = new TextDecoder('utf-16be')
|
||||
export function readQString(readable: ISyncReadable): string {
|
||||
const bytes = readQByteArray(readable)
|
||||
return u16Decoder.decode(bytes)
|
||||
}
|
31
packages/convert/src/tdesktop/qt-writer.ts
Normal file
31
packages/convert/src/tdesktop/qt-writer.ts
Normal file
|
@ -0,0 +1,31 @@
|
|||
import type { ISyncWritable } from '@fuman/io'
|
||||
import { write } from '@fuman/io'
|
||||
import { u8 } from '@fuman/utils'
|
||||
import type { Long } from '@mtcute/core'
|
||||
|
||||
export function writeQByteArray(into: ISyncWritable, buf: Uint8Array): void {
|
||||
write.uint32be(into, buf.length)
|
||||
write.bytes(into, buf)
|
||||
}
|
||||
|
||||
export function writeLong(into: ISyncWritable, long: Long): void {
|
||||
write.int32be(into, long.high)
|
||||
write.int32be(into, long.low)
|
||||
}
|
||||
|
||||
export function writeCharArray(into: ISyncWritable, buf: Uint8Array): void {
|
||||
const bytes = u8.alloc(buf.length + 1)
|
||||
bytes.set(buf)
|
||||
bytes[buf.length] = 0
|
||||
|
||||
writeQByteArray(into, bytes)
|
||||
}
|
||||
|
||||
export function writeQString(into: ISyncWritable, str: string): void {
|
||||
const length = str.length * 2
|
||||
write.uint32be(into, length)
|
||||
|
||||
for (let i = 0; i < length; i++) {
|
||||
write.uint16be(into, str.charCodeAt(i))
|
||||
}
|
||||
}
|
106
packages/convert/src/tdesktop/tdata.test.ts
Normal file
106
packages/convert/src/tdesktop/tdata.test.ts
Normal file
|
@ -0,0 +1,106 @@
|
|||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Long } from '@mtcute/core'
|
||||
|
||||
import type { INodeFsLike } from '../utils/fs.js'
|
||||
import { getDefaultCryptoProvider } from '../utils/crypto.js'
|
||||
|
||||
import { Tdata } from './tdata.js'
|
||||
|
||||
class FakeFs implements INodeFsLike {
|
||||
readonly files = new Map<string, Uint8Array>()
|
||||
|
||||
async readFile(path: string): Promise<Uint8Array> {
|
||||
return this.files.get(path)!
|
||||
}
|
||||
|
||||
async writeFile(path: string, data: Uint8Array): Promise<void> {
|
||||
this.files.set(path, data)
|
||||
}
|
||||
|
||||
async stat(path: string): Promise<{ size: number, lastModified: number }> {
|
||||
return {
|
||||
size: this.files.get(path)!.length,
|
||||
lastModified: 0,
|
||||
}
|
||||
}
|
||||
|
||||
mkdir(): Promise<void> {
|
||||
return Promise.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
describe('tdata', () => {
|
||||
it('should read simple tdata', async () => {
|
||||
const tdata = await Tdata.open({
|
||||
path: fileURLToPath(new URL('./__fixtures__/simple', import.meta.url)),
|
||||
})
|
||||
|
||||
const auth = await tdata.readMtpAuthorization()
|
||||
|
||||
expect({ auth, key: tdata.keyData }).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it('should read passcode-protected tdata', async () => {
|
||||
const tdata = await Tdata.open({
|
||||
path: fileURLToPath(new URL('./__fixtures__/passcode', import.meta.url)),
|
||||
passcode: '123123',
|
||||
})
|
||||
|
||||
const auth = await tdata.readMtpAuthorization()
|
||||
|
||||
expect({ auth, key: tdata.keyData }).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it('should throw on invalid passcode', async () => {
|
||||
await expect(Tdata.open({
|
||||
path: fileURLToPath(new URL('./__fixtures__/passcode', import.meta.url)),
|
||||
passcode: '123',
|
||||
})).rejects.toThrow('Failed to decrypt, invalid password?')
|
||||
})
|
||||
|
||||
it('should read multi-account tdata', async () => {
|
||||
const tdata = await Tdata.open({
|
||||
path: fileURLToPath(new URL('./__fixtures__/multiacc', import.meta.url)),
|
||||
})
|
||||
|
||||
const auth0 = await tdata.readMtpAuthorization(0)
|
||||
const auth1 = await tdata.readMtpAuthorization(1)
|
||||
|
||||
expect({ auth0, auth1, key: tdata.keyData }).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it('should write simple tdata', async () => {
|
||||
const fs = new FakeFs()
|
||||
const crypto = await getDefaultCryptoProvider()
|
||||
crypto.randomBytes = size => new Uint8Array(size)
|
||||
|
||||
const tdata = await Tdata.create({
|
||||
path: '/',
|
||||
fs,
|
||||
crypto,
|
||||
keyData: {
|
||||
count: 1,
|
||||
order: [0],
|
||||
active: 0,
|
||||
},
|
||||
})
|
||||
|
||||
const key = new Uint8Array(256)
|
||||
key.fill(1)
|
||||
await tdata.writeMtpAuthorization({
|
||||
userId: Long.fromNumber(12345678),
|
||||
mainDcId: 2,
|
||||
authKeys: [
|
||||
{
|
||||
dcId: 2,
|
||||
key,
|
||||
},
|
||||
],
|
||||
authKeysToDestroy: [],
|
||||
})
|
||||
|
||||
expect(fs.files).toMatchSnapshot()
|
||||
})
|
||||
})
|
494
packages/convert/src/tdesktop/tdata.ts
Normal file
494
packages/convert/src/tdesktop/tdata.ts
Normal file
|
@ -0,0 +1,494 @@
|
|||
import { dirname, join } from 'node:path/posix'
|
||||
|
||||
import { Bytes, read, write } from '@fuman/io'
|
||||
import type { UnsafeMutable } from '@fuman/utils'
|
||||
import { typed, u8, utf8 } from '@fuman/utils'
|
||||
import { createAesIgeForMessageOld } from '@mtcute/core/utils.js'
|
||||
import { Long, MtUnsupportedError } from '@mtcute/core'
|
||||
|
||||
import type { INodeFsLike } from '../utils/fs.js'
|
||||
import { type IExtendedCryptoProvider, getDefaultCryptoProvider } from '../utils/crypto.js'
|
||||
|
||||
import { readLong, readQByteArray } from './qt-reader.js'
|
||||
import type { InputTdKeyData, TdAuthKey, TdKeyData, TdMtpAuthorization } from './types.js'
|
||||
import { writeLong, writeQByteArray } from './qt-writer.js'
|
||||
|
||||
const TDF_MAGIC = /* #__PURE__ */ utf8.encoder.encode('TDF$')
|
||||
const TDF_VERSION = 5008003
|
||||
const MTP_AUTHORIZATION_BLOCK = 0x4B // see https://github.com/telegramdesktop/tdesktop/blob/dev/Telegram/SourceFiles/storage/details/storage_settings_scheme.h
|
||||
const HEX_ALPHABET = '0123456789ABCDEF'
|
||||
|
||||
function toFilePart(key: Uint8Array): string {
|
||||
let str = ''
|
||||
// we need to swap nibbles for whatever reason
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const b = key[i]
|
||||
const low = b & 0x0F
|
||||
const high = b >> 4
|
||||
str += HEX_ALPHABET[low] + HEX_ALPHABET[high]
|
||||
}
|
||||
return str
|
||||
}
|
||||
|
||||
export interface TdataOptions {
|
||||
/** Full path to the tdata directory */
|
||||
path: string
|
||||
|
||||
/**
|
||||
* File system to use for reading/writing.
|
||||
*
|
||||
* @default `import('node:fs/promises')`
|
||||
*/
|
||||
fs?: INodeFsLike
|
||||
|
||||
/**
|
||||
* Crypto functions to use for encryption/decryption.
|
||||
*
|
||||
* @default `node:crypto`-based implementation
|
||||
*/
|
||||
crypto?: IExtendedCryptoProvider
|
||||
|
||||
/**
|
||||
* Whether to ignore TDF version mismatch.
|
||||
* If set to `true`, the version will be ignored and the file will be read as is,
|
||||
* however the probability of errors is higher.
|
||||
*/
|
||||
ignoreVersion?: boolean
|
||||
|
||||
/**
|
||||
* Whether the host machine has LE processor (default true, try changing in case of errors)
|
||||
*/
|
||||
le?: boolean
|
||||
|
||||
/**
|
||||
* Value of -key cli parameter.
|
||||
* Defaults to `data`
|
||||
*/
|
||||
dataKey?: string
|
||||
|
||||
/**
|
||||
* Local passcode
|
||||
*/
|
||||
passcode?: string
|
||||
}
|
||||
|
||||
export class Tdata {
|
||||
private constructor(
|
||||
readonly options: TdataOptions,
|
||||
readonly fs: INodeFsLike,
|
||||
readonly crypto: IExtendedCryptoProvider,
|
||||
) {}
|
||||
|
||||
readonly keyData!: TdKeyData
|
||||
|
||||
static async open(options: TdataOptions): Promise<Tdata> {
|
||||
const fs: INodeFsLike = options.fs ?? (await import('node:fs/promises') as unknown as INodeFsLike)
|
||||
const crypto: IExtendedCryptoProvider = options.crypto ?? (await getDefaultCryptoProvider())
|
||||
await crypto.initialize?.()
|
||||
|
||||
const tdata = new Tdata(options, fs, crypto)
|
||||
;(tdata as UnsafeMutable<Tdata>).keyData = await tdata.readKeyData()
|
||||
|
||||
return tdata
|
||||
}
|
||||
|
||||
static async create(options: TdataOptions & { keyData: InputTdKeyData }): Promise<Tdata> {
|
||||
const fs: INodeFsLike = options.fs ?? (await import('node:fs/promises') as unknown as INodeFsLike)
|
||||
const crypto: IExtendedCryptoProvider = options.crypto ?? (await getDefaultCryptoProvider())
|
||||
await crypto.initialize?.()
|
||||
|
||||
const tdata = new Tdata(options, fs, crypto)
|
||||
const keyData: TdKeyData = {
|
||||
...options.keyData,
|
||||
localKey: options.keyData.localKey ?? crypto.randomBytes(256),
|
||||
version: options.keyData.version ?? TDF_VERSION,
|
||||
}
|
||||
;(tdata as UnsafeMutable<Tdata>).keyData = keyData
|
||||
|
||||
await tdata.writeKeyData(keyData)
|
||||
|
||||
return tdata
|
||||
}
|
||||
|
||||
#readInt32(buf: Uint8Array): number {
|
||||
return (this.options.le ?? true) ? read.int32le(buf) : read.int32be(buf)
|
||||
}
|
||||
|
||||
#writeInt32(buf: Uint8Array, val: number): Uint8Array {
|
||||
if (this.options.le ?? true) {
|
||||
write.int32le(buf, val)
|
||||
} else {
|
||||
write.int32be(buf, val)
|
||||
}
|
||||
|
||||
return buf
|
||||
}
|
||||
|
||||
getDataName(idx: number): string {
|
||||
let res = this.options.dataKey ?? 'data'
|
||||
|
||||
if (idx > 0) {
|
||||
res += `#${idx + 1}`
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
async readFile(filename: string): Promise<[number, Uint8Array]> {
|
||||
const order: string[] = []
|
||||
|
||||
const modern = `${filename}s`
|
||||
if (await this.fs.stat(join(this.options.path, modern))) {
|
||||
order.push(modern)
|
||||
} else {
|
||||
const try0 = `${filename}0`
|
||||
const try1 = `${filename}1`
|
||||
|
||||
const try0s = await this.fs.stat(join(this.options.path, try0))
|
||||
const try1s = await this.fs.stat(join(this.options.path, try1))
|
||||
|
||||
if (try0s) {
|
||||
order.push(try0)
|
||||
|
||||
if (try1s) {
|
||||
order.push(try1)
|
||||
if (try0s.lastModified < try1s.lastModified) {
|
||||
order.reverse()
|
||||
}
|
||||
}
|
||||
} else if (try1s) {
|
||||
order.push(try1)
|
||||
}
|
||||
}
|
||||
|
||||
let lastError = 'file not found'
|
||||
|
||||
for (const file of order) {
|
||||
const data = await this.fs.readFile(join(this.options.path, file))
|
||||
const magic = data.subarray(0, 4)
|
||||
|
||||
if (!typed.equal(magic, TDF_MAGIC)) {
|
||||
lastError = 'invalid magic'
|
||||
continue
|
||||
}
|
||||
|
||||
const versionBytes = data.subarray(4, 8)
|
||||
const version = this.#readInt32(versionBytes)
|
||||
if (version > TDF_VERSION && !this.options.ignoreVersion) {
|
||||
lastError = `Unsupported version: ${version}`
|
||||
continue
|
||||
}
|
||||
|
||||
const dataSize = data.length - 24
|
||||
const bytes = data.subarray(8, dataSize + 8)
|
||||
|
||||
const md5 = await this.crypto.createHash('md5')
|
||||
await md5.update(bytes)
|
||||
await md5.update(this.#writeInt32(u8.alloc(4), dataSize))
|
||||
await md5.update(versionBytes)
|
||||
await md5.update(magic)
|
||||
|
||||
const hash = await md5.digest()
|
||||
if (!typed.equal(hash, data.subarray(dataSize + 8))) {
|
||||
lastError = 'md5 mismatch'
|
||||
continue
|
||||
}
|
||||
|
||||
return [version, bytes]
|
||||
}
|
||||
|
||||
throw new Error(`failed to read ${filename}, last error: ${lastError}`)
|
||||
}
|
||||
|
||||
async writeFile(
|
||||
filename: string,
|
||||
data: Uint8Array,
|
||||
mkdir = false,
|
||||
): Promise<void> {
|
||||
filename = join(this.options.path, `${filename}s`)
|
||||
|
||||
const version = this.#writeInt32(u8.alloc(4), TDF_VERSION)
|
||||
const dataSize = this.#writeInt32(u8.alloc(4), data.length)
|
||||
const md5 = await this.crypto.createHash('md5')
|
||||
await md5.update(data)
|
||||
await md5.update(dataSize)
|
||||
await md5.update(version)
|
||||
await md5.update(TDF_MAGIC)
|
||||
|
||||
if (mkdir) {
|
||||
await this.fs.mkdir(dirname(filename), { recursive: true })
|
||||
}
|
||||
|
||||
await this.fs.writeFile(
|
||||
filename,
|
||||
u8.concat([TDF_MAGIC, version, data, await md5.digest()]),
|
||||
)
|
||||
}
|
||||
|
||||
async createLocalKey(
|
||||
salt: Uint8Array,
|
||||
passcode: string = this.options.passcode ?? '',
|
||||
): Promise<Uint8Array> {
|
||||
const hasher = await this.crypto.createHash('sha512')
|
||||
hasher.update(salt)
|
||||
hasher.update(utf8.encoder.encode(passcode))
|
||||
hasher.update(salt)
|
||||
const hash = await hasher.digest()
|
||||
|
||||
return this.crypto.pbkdf2(
|
||||
hash,
|
||||
salt,
|
||||
passcode === '' ? 1 : 100000,
|
||||
256,
|
||||
'sha512',
|
||||
)
|
||||
}
|
||||
|
||||
async decryptLocal(encrypted: Uint8Array, key: Uint8Array): Promise<Uint8Array> {
|
||||
const encryptedKey = encrypted.subarray(0, 16)
|
||||
const encryptedData = encrypted.subarray(16)
|
||||
|
||||
const ige = createAesIgeForMessageOld(
|
||||
this.crypto,
|
||||
key,
|
||||
encryptedKey,
|
||||
false,
|
||||
)
|
||||
const decrypted = ige.decrypt(encryptedData)
|
||||
|
||||
if (
|
||||
!typed.equal(
|
||||
this.crypto.sha1(decrypted).subarray(0, 16),
|
||||
encryptedKey,
|
||||
)
|
||||
) {
|
||||
throw new Error('Failed to decrypt, invalid password?')
|
||||
}
|
||||
|
||||
const fullLen = encryptedData.length
|
||||
const dataLen = this.#readInt32(decrypted)
|
||||
|
||||
if (
|
||||
dataLen > decrypted.length
|
||||
|| dataLen <= fullLen - 16
|
||||
|| dataLen < 4
|
||||
) {
|
||||
throw new Error('Failed to decrypt, invalid data length')
|
||||
}
|
||||
|
||||
return decrypted.subarray(4, dataLen)
|
||||
}
|
||||
|
||||
async encryptLocal(data: Uint8Array, key: Uint8Array): Promise<Uint8Array> {
|
||||
const dataSize = data.length + 4
|
||||
const padding: Uint8Array
|
||||
= dataSize & 0x0F
|
||||
? this.crypto.randomBytes(0x10 - (dataSize & 0x0F))
|
||||
: u8.empty
|
||||
|
||||
const toEncrypt = u8.alloc(dataSize + padding.length)
|
||||
this.#writeInt32(toEncrypt, dataSize)
|
||||
toEncrypt.set(data, 4)
|
||||
toEncrypt.set(padding, dataSize)
|
||||
|
||||
const encryptedKey = this.crypto.sha1(toEncrypt).subarray(0, 16)
|
||||
|
||||
const ige = createAesIgeForMessageOld(
|
||||
this.crypto,
|
||||
key,
|
||||
encryptedKey,
|
||||
false,
|
||||
)
|
||||
const encryptedData = ige.encrypt(toEncrypt)
|
||||
|
||||
return u8.concat2(encryptedKey, encryptedData)
|
||||
}
|
||||
|
||||
async readKeyData(): Promise<TdKeyData> {
|
||||
const [version, data] = await this.readFile(`key_${this.options.dataKey ?? 'data'}`)
|
||||
const bytes = Bytes.from(data)
|
||||
|
||||
const salt = readQByteArray(bytes)
|
||||
const keyEncrypted = readQByteArray(bytes)
|
||||
const infoEncrypted = readQByteArray(bytes)
|
||||
|
||||
const passcodeKey = await this.createLocalKey(salt)
|
||||
const keyInnerData = await this.decryptLocal(keyEncrypted, passcodeKey)
|
||||
const infoDecrypted = await this.decryptLocal(
|
||||
infoEncrypted,
|
||||
keyInnerData,
|
||||
)
|
||||
const info = Bytes.from(infoDecrypted)
|
||||
|
||||
const localKey = keyInnerData
|
||||
const count = read.int32be(info)
|
||||
const order = [...Array(count)].map(() => read.int32be(info))
|
||||
const active = read.int32be(info)
|
||||
|
||||
return {
|
||||
version,
|
||||
localKey,
|
||||
count,
|
||||
order,
|
||||
active,
|
||||
}
|
||||
}
|
||||
|
||||
async writeKeyData(keyData: TdKeyData): Promise<void> {
|
||||
const info = Bytes.alloc()
|
||||
write.int32be(info, keyData.count)
|
||||
keyData.order.forEach(i => write.int32be(info, i))
|
||||
write.int32be(info, keyData.active)
|
||||
const infoDecrypted = info.result()
|
||||
|
||||
const infoEncrypted = await this.encryptLocal(infoDecrypted, keyData.localKey)
|
||||
|
||||
const salt = this.crypto.randomBytes(32)
|
||||
const passcodeKey = await this.createLocalKey(salt)
|
||||
|
||||
const keyEncrypted = await this.encryptLocal(keyData.localKey, passcodeKey)
|
||||
|
||||
const data = Bytes.alloc()
|
||||
writeQByteArray(data, salt)
|
||||
writeQByteArray(data, keyEncrypted)
|
||||
writeQByteArray(data, infoEncrypted)
|
||||
|
||||
await this.writeFile(`key_${this.options.dataKey ?? 'data'}`, data.result(), true)
|
||||
}
|
||||
|
||||
async computeDataNameKey(accountIdx: number): Promise<Uint8Array> {
|
||||
const md5 = await this.crypto.createHash('md5')
|
||||
await md5.update(utf8.encoder.encode(this.getDataName(accountIdx)))
|
||||
const r = await md5.digest()
|
||||
return r.subarray(0, 8)
|
||||
}
|
||||
|
||||
async computeDataNameKeyHex(accountIdx: number): Promise<string> {
|
||||
return toFilePart(await this.computeDataNameKey(accountIdx))
|
||||
}
|
||||
|
||||
async readEncryptedFile(filename: string): Promise<[number, Uint8Array]> {
|
||||
const [version, data] = await this.readFile(filename)
|
||||
|
||||
const encrypted = readQByteArray(Bytes.from(data))
|
||||
const decrypted = await this.decryptLocal(encrypted, this.keyData.localKey)
|
||||
|
||||
return [version, decrypted]
|
||||
}
|
||||
|
||||
async writeEncryptedFile(
|
||||
filename: string,
|
||||
data: Uint8Array,
|
||||
mkdir = false,
|
||||
): Promise<void> {
|
||||
const encryptedInner = await this.encryptLocal(data, this.keyData.localKey)
|
||||
|
||||
const writer = Bytes.alloc(data.length + 4)
|
||||
writeQByteArray(writer, encryptedInner)
|
||||
|
||||
await this.writeFile(filename, writer.result(), mkdir)
|
||||
}
|
||||
|
||||
async readMtpAuthorization(accountIdx: number = 0): Promise<TdMtpAuthorization> {
|
||||
const [, mtpData] = await this.readEncryptedFile(
|
||||
await this.computeDataNameKeyHex(accountIdx),
|
||||
)
|
||||
|
||||
// nb: this is pretty much a hack that relies on the fact that
|
||||
// most of the time the mtp auth data is in the first setting
|
||||
// since the settings are not length-prefixed, we can't skip unknown settings,
|
||||
// as we need to know their type.
|
||||
// and this is very much tied to the actual tdesktop version, and would be a nightmare to maintain
|
||||
let bytes = Bytes.from(mtpData)
|
||||
|
||||
const header = read.int32be(bytes)
|
||||
if (header !== MTP_AUTHORIZATION_BLOCK) {
|
||||
throw new MtUnsupportedError(`expected first setting to be mtp auth data, got 0x${header.toString(16)}`)
|
||||
}
|
||||
|
||||
const mtpAuthBlock = readQByteArray(bytes)
|
||||
|
||||
bytes = Bytes.from(mtpAuthBlock)
|
||||
|
||||
const legacyUserId = read.int32be(bytes)
|
||||
const legacyMainDcId = read.int32be(bytes)
|
||||
|
||||
let userId, mainDcId
|
||||
if (legacyMainDcId === -1 && legacyMainDcId === -1) {
|
||||
userId = readLong(bytes)
|
||||
mainDcId = read.int32be(bytes)
|
||||
} else {
|
||||
userId = Long.fromInt(legacyUserId)
|
||||
mainDcId = legacyMainDcId
|
||||
}
|
||||
|
||||
function readKeys(target: TdAuthKey[]) {
|
||||
const count = read.uint32be(bytes)
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const dcId = read.int32be(bytes)
|
||||
const key = read.exactly(bytes, 256)
|
||||
target.push({ dcId, key })
|
||||
}
|
||||
}
|
||||
|
||||
const authKeys: TdAuthKey[] = []
|
||||
const authKeysToDestroy: TdAuthKey[] = []
|
||||
|
||||
readKeys(authKeys)
|
||||
readKeys(authKeysToDestroy)
|
||||
|
||||
return {
|
||||
userId,
|
||||
mainDcId,
|
||||
authKeys,
|
||||
authKeysToDestroy,
|
||||
}
|
||||
}
|
||||
|
||||
async writeMtpAuthorization(auth: TdMtpAuthorization, accountIdx = 0): Promise<void> {
|
||||
const bytes = Bytes.alloc()
|
||||
|
||||
// legacy user id & dc id
|
||||
write.int32be(bytes, -1)
|
||||
write.int32be(bytes, -1)
|
||||
writeLong(bytes, auth.userId)
|
||||
write.int32be(bytes, auth.mainDcId)
|
||||
|
||||
function writeKeys(keys: TdAuthKey[]) {
|
||||
write.uint32be(bytes, keys.length)
|
||||
keys.forEach((k) => {
|
||||
write.int32be(bytes, k.dcId)
|
||||
write.bytes(bytes, k.key)
|
||||
})
|
||||
}
|
||||
|
||||
writeKeys(auth.authKeys)
|
||||
writeKeys(auth.authKeysToDestroy)
|
||||
|
||||
const file = Bytes.alloc()
|
||||
write.int32be(file, MTP_AUTHORIZATION_BLOCK)
|
||||
writeQByteArray(file, bytes.result())
|
||||
|
||||
await this.writeEncryptedFile(
|
||||
await this.computeDataNameKeyHex(accountIdx),
|
||||
file.result(),
|
||||
)
|
||||
}
|
||||
|
||||
async writeEmptyMapFile(accountIdx: number): Promise<void> {
|
||||
// without this file tdesktop will not "see" the account
|
||||
// however just creating an empty file seems to be enough to make it happy
|
||||
|
||||
const writer = Bytes.alloc()
|
||||
writeQByteArray(writer, u8.empty) // legacySalt
|
||||
writeQByteArray(writer, u8.empty) // legacyKeyEncrypted
|
||||
writeQByteArray(writer, await this.encryptLocal(u8.empty, this.keyData.localKey))
|
||||
|
||||
await this.writeFile(
|
||||
join(await this.computeDataNameKeyHex(accountIdx), 'map'),
|
||||
writer.result(),
|
||||
true,
|
||||
)
|
||||
}
|
||||
}
|
26
packages/convert/src/tdesktop/types.ts
Normal file
26
packages/convert/src/tdesktop/types.ts
Normal file
|
@ -0,0 +1,26 @@
|
|||
import type { Long } from '@mtcute/core'
|
||||
|
||||
export interface TdAuthKey {
|
||||
dcId: number
|
||||
key: Uint8Array
|
||||
}
|
||||
|
||||
export interface TdMtpAuthorization {
|
||||
userId: Long
|
||||
mainDcId: number
|
||||
authKeys: TdAuthKey[]
|
||||
authKeysToDestroy: TdAuthKey[]
|
||||
}
|
||||
|
||||
export interface InputTdKeyData {
|
||||
localKey?: Uint8Array
|
||||
version?: number
|
||||
count: number
|
||||
order: number[]
|
||||
active: number
|
||||
}
|
||||
|
||||
export interface TdKeyData extends InputTdKeyData {
|
||||
version: number
|
||||
localKey: Uint8Array
|
||||
}
|
29
packages/convert/src/utils/crypto.ts
Normal file
29
packages/convert/src/utils/crypto.ts
Normal file
|
@ -0,0 +1,29 @@
|
|||
import type { MaybePromise } from '@fuman/utils'
|
||||
import type { ICryptoProvider } from '@mtcute/core/utils.js'
|
||||
|
||||
export interface IExtendedCryptoProvider extends ICryptoProvider {
|
||||
createHash(algorithm: 'md5' | 'sha512'): MaybePromise<{
|
||||
update(data: Uint8Array): MaybePromise<void>
|
||||
digest(): MaybePromise<Uint8Array>
|
||||
}>
|
||||
}
|
||||
|
||||
export async function getDefaultCryptoProvider(): Promise<IExtendedCryptoProvider> {
|
||||
const crypto = /* @vite-ignore */ await import('node:crypto')
|
||||
const { NodeCryptoProvider } = /* @vite-ignore */ await import('@mtcute/node/utils.js')
|
||||
|
||||
return new (class extends NodeCryptoProvider implements IExtendedCryptoProvider {
|
||||
createHash(algorithm: 'md5' | 'sha512') {
|
||||
const hasher = crypto.createHash(algorithm)
|
||||
|
||||
return {
|
||||
update(data: Uint8Array) {
|
||||
hasher.update(data)
|
||||
},
|
||||
digest() {
|
||||
return hasher.digest() as unknown as Uint8Array
|
||||
},
|
||||
}
|
||||
}
|
||||
})()
|
||||
}
|
6
packages/convert/src/utils/fs.ts
Normal file
6
packages/convert/src/utils/fs.ts
Normal file
|
@ -0,0 +1,6 @@
|
|||
export interface INodeFsLike {
|
||||
readFile(path: string): Promise<Uint8Array>
|
||||
writeFile(path: string, data: Uint8Array): Promise<void>
|
||||
mkdir(path: string, options?: { recursive?: boolean }): Promise<void>
|
||||
stat(path: string): Promise<{ size: number, lastModified: number }>
|
||||
}
|
Loading…
Reference in a new issue