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",
|
"exports": "./src/index.ts",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mtcute/core": "workspace:^",
|
"@mtcute/core": "workspace:^",
|
||||||
"@fuman/utils": "https://pkg.pr.new/teidesu/fuman/@fuman/utils@6017eb4",
|
"@fuman/utils": "https://pkg.pr.new/teidesu/fuman/@fuman/utils@b0c74cb",
|
||||||
"@fuman/net": "https://pkg.pr.new/teidesu/fuman/@fuman/net@6017eb4"
|
"@fuman/net": "https://pkg.pr.new/teidesu/fuman/@fuman/net@b0c74cb",
|
||||||
|
"@fuman/io": "https://pkg.pr.new/teidesu/fuman/@fuman/io@b0c74cb"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"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 './mtkruto/index.js'
|
||||||
export * from './pyrogram/index.js'
|
export * from './pyrogram/index.js'
|
||||||
export * from './telethon/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