mtproton
Version:
Telegram API JS (MTProto) client library for browser and nodejs
781 lines (615 loc) • 21.8 kB
JavaScript
const bigInt = require('big-integer');
const debounce = require('lodash.debounce');
const AES = require('../crypto/aes');
const builderMap = require('../tl/builder');
const Serializer = require('../tl/serializer');
const Deserializer = require('../tl/deserializer');
const {
xorBytes,
intsToLong,
concatBytes,
getRandomInt,
bytesIsEqual,
bigIntToBytes,
bytesToBigInt,
longToBytesRaw,
bytesToBytesRaw,
} = require('../utils/common');
const baseDebug = require('../utils/common/base-debug');
const pqPrimeFactorization = require('../crypto/pq');
class RPC {
constructor({ dc, context, transport }) {
this.dc = dc;
this.crypto = context.crypto;
this.context = context;
this.transport = transport;
this.debug = baseDebug.extend(`rpc-${this.dc.id}`);
this.debug('init');
this.isAuth = false;
this.pendingAcks = [];
this.messagesWaitAuth = [];
this.messagesWaitResponse = new Map();
this.updateSession();
this.transport.on('open', this.handleTransportOpen.bind(this));
this.transport.on('error', this.handleTransportError.bind(this));
this.transport.on('message', this.handleTransportMessage.bind(this));
this.sendAcks = debounce(() => {
if (!this.pendingAcks.length || !this.isReady) {
return;
}
const serializer = new Serializer(builderMap.mt_msgs_ack, {
msg_ids: this.pendingAcks,
});
const bytes = serializer.getBytes();
this.pendingAcks = [];
this.sendEncryptedMessage(bytes, {
isContentRelated: false,
});
}, 500);
}
get isReady() {
return this.isAuth && this.transport.isAvailable;
}
async handleTransportError(payload) {
const { type } = payload;
this.debug('transport error', payload);
// https://core.telegram.org/mtproto/mtproto-transports#transport-errors
if (type === 'transport') {
// Auth key not found
if (payload.code === 404) {
await this.setStorageItem('authKey', null);
await this.setStorageItem('serverSalt', null);
}
// transport flood
if (payload.code === 429) {
this.debug('transport flood');
}
}
}
async handleTransportOpen() {
const authKey = await this.getStorageItem('authKey');
const serverSalt = await this.getStorageItem('serverSalt');
if (authKey && serverSalt) {
this.handleMessage = this.handleEncryptedMessage;
this.isAuth = true;
this.sendWaitMessages();
// This request is necessary to ensure that you start interacting with the server. If we have not made any request, the server will not send us updates.
this.call('help.getConfig')
.then((result) => {
// TODO: Handle config
})
.catch((error) => {
this.debug(`error when calling the method help.getConfig:`, error);
});
} else {
this.nonce = this.crypto.getRandomBytes(16);
this.handleMessage = this.handlePQResponse;
this.sendPlainMessage(builderMap.mt_req_pq_multi, { nonce: this.nonce });
}
}
async handleTransportMessage(buffer) {
this.handleMessage(buffer);
}
async handlePQResponse(buffer) {
const deserializer = new Deserializer(buffer);
deserializer.long(); // auth_key_id
deserializer.long(); // msg_id
deserializer.int32(); // msg_len
const responsePQ = deserializer.predicate();
const {
pq,
nonce,
server_nonce,
server_public_key_fingerprints,
} = responsePQ;
if (!bytesIsEqual(this.nonce, nonce)) {
throw new Error('The nonce are not equal');
}
const publicKey = await this.crypto.rsa.getRsaKeyByFingerprints(
server_public_key_fingerprints
);
const [p, q] = pqPrimeFactorization(pq);
this.newNonce = this.crypto.getRandomBytes(32);
this.serverNonce = server_nonce;
const serializer = new Serializer(builderMap.mt_p_q_inner_data, {
pq: pq,
p: p,
q: q,
nonce: this.nonce,
server_nonce: this.serverNonce,
new_nonce: this.newNonce,
});
const data = serializer.getBytes();
const dataHash = await this.crypto.SHA1(data);
const innerData = this.crypto.getRandomBytes(255);
innerData.set(dataHash);
innerData.set(data, dataHash.length);
const encryptedData = this.crypto.rsa.encrypt(publicKey, innerData);
this.sendPlainMessage(builderMap.mt_req_DH_params, {
nonce: this.nonce,
server_nonce: this.serverNonce,
p: p,
q: q,
public_key_fingerprint: publicKey.fingerprint,
encrypted_data: encryptedData,
});
this.handleMessage = this.handleDHParams;
}
async handleDHParams(buffer) {
const deserializer = new Deserializer(buffer);
deserializer.long(); // auth_key_id
deserializer.long(); // msg_id
deserializer.int32(); // msg_len
const serverDH = deserializer.predicate();
const { nonce, server_nonce, encrypted_answer } = serverDH;
if (!bytesIsEqual(this.nonce, nonce)) {
throw new Error('The nonce are not equal');
}
if (!bytesIsEqual(this.serverNonce, server_nonce)) {
throw new Error('The server_nonce are not equal');
}
this.tmpAesKey = concatBytes(
await this.crypto.SHA1(concatBytes(this.newNonce, this.serverNonce)),
(
await this.crypto.SHA1(concatBytes(this.serverNonce, this.newNonce))
).slice(0, 12)
);
this.tmpAesIV = concatBytes(
(
await this.crypto.SHA1(concatBytes(this.serverNonce, this.newNonce))
).slice(12, 20),
await this.crypto.SHA1(concatBytes(this.newNonce, this.newNonce)),
this.newNonce.slice(0, 4)
);
const decryptedData = new AES.IGE(this.tmpAesKey, this.tmpAesIV).decrypt(
encrypted_answer
);
const innerDataHash = decryptedData.slice(0, 20);
const innerDeserializer = new Deserializer(decryptedData.slice(20).buffer);
const serverDHInnerData = innerDeserializer.predicate();
if (
!bytesIsEqual(
innerDataHash,
await this.crypto.SHA1(
decryptedData.slice(20, 20 + innerDeserializer.offset)
)
)
) {
throw new Error('Invalid hash in DH params decrypted data');
}
await this.context.storage.set(
'timeOffset',
Math.floor(Date.now() / 1000) - serverDHInnerData.server_time
);
this.dhPrime = bytesToBigInt(serverDHInnerData.dh_prime);
this.g = bigInt(serverDHInnerData.g);
this.gA = bytesToBigInt(serverDHInnerData.g_a);
this.verifyDhParams(this.g, this.dhPrime, this.gA);
this.generateDH();
}
verifyDhParams(g, dhPrime, gA) {
if (g.toJSNumber() !== 3) {
throw new Error('Server_DH_inner_data.g must be equal to 3');
}
if (
dhPrime.toString(16) !==
'c71caeb9c6b1c9048e6c522f70f13f73980d40238e3e21c14934d037563d930f48198a0aa7c14058229493d22530f4dbfa336f6e0ac925139543aed44cce7c3720fd51f69458705ac68cd4fe6b6b13abdc9746512969328454f18faf8c595f642477fe96bb2a941d5bcd1d4ac8cc49880708fa9b378e3c4f3a9060bee67cf9a4a4a695811051907e162753b56b0f6b410dba74d8a84b2a14b3144e0ef1284754fd17ed950d5965b4b9dd46582db1178d169c6bc465b0d6ff9ca3928fef5b9ae4e418fc15e83ebea0f87fa9ff5eed70050ded2849f47bf959d956850ce929851f0d8115f635b105ee2e4e15d04b2454bf6f4fadf034b10403119cd8e3b92fcc5b'
) {
throw new Error('Server_DH_inner_data.dh_prime incorrect');
}
if (gA.lesserOrEquals(bigInt.one)) {
throw new Error('Server_DH_inner_data.g_a incorrect: g_a <= 1');
}
if (gA.greaterOrEquals(dhPrime.minus(bigInt.one))) {
throw new Error(
'Server_DH_inner_data.g_a incorrect: g_a >= dh_prime - 1'
);
}
const twoPow = bigInt(2).pow(2048 - 64);
if (gA.lesser(twoPow)) {
throw new Error('Server_DH_inner_data.g_a incorrect: g_a < 2^{2048-64}');
}
if (gA.greaterOrEquals(dhPrime.minus(twoPow))) {
throw new Error(
'Server_DH_inner_data.g_a incorrect: g_a >= dh_prime - 2^{2048-64}'
);
}
}
async generateDH(retryId = 0) {
const b = bytesToBigInt(this.crypto.getRandomBytes(256));
const authKey = bigIntToBytes(this.gA.modPow(b, this.dhPrime));
const serverSalt = xorBytes(
this.newNonce.slice(0, 8),
this.serverNonce.slice(0, 8)
);
await this.setStorageItem('authKey', bytesToBytesRaw(authKey));
await this.setStorageItem('serverSalt', bytesToBytesRaw(serverSalt));
this.authKeyAuxHash = bytesToBytesRaw(
(await this.crypto.SHA1(authKey)).slice(0, 8)
);
const serializer = new Serializer(builderMap.mt_client_DH_inner_data, {
nonce: this.nonce,
server_nonce: this.serverNonce,
retry_id: retryId,
g_b: bigIntToBytes(this.g.modPow(b, this.dhPrime)),
});
const innerData = serializer.getBytes();
const innerDataHash = await this.crypto.SHA1(innerData);
const paddingLength = 16 - ((innerDataHash.length + innerData.length) % 16);
const encryptedData = new AES.IGE(this.tmpAesKey, this.tmpAesIV).encrypt(
concatBytes(
innerDataHash,
innerData,
this.crypto.getRandomBytes(paddingLength)
)
);
this.sendPlainMessage(builderMap.mt_set_client_DH_params, {
nonce: this.nonce,
server_nonce: this.serverNonce,
encrypted_data: encryptedData,
});
this.handleMessage = this.handleDHAnswer;
}
async handleDHAnswer(buffer) {
const deserializer = new Deserializer(buffer);
deserializer.long(); // auth_key_id
deserializer.long(); // msg_id
deserializer.int32(); // msg_len
const serverDHAnswer = deserializer.predicate();
const { nonce, server_nonce } = serverDHAnswer;
if (!bytesIsEqual(this.nonce, nonce)) {
throw new Error('The nonce are not equal');
}
if (!bytesIsEqual(this.serverNonce, server_nonce)) {
throw new Error('The server_nonce are not equal');
}
if (serverDHAnswer._ === 'mt_dh_gen_ok') {
const hash = (
await this.crypto.SHA1(
concatBytes(this.newNonce, [1], this.authKeyAuxHash)
)
).slice(4, 20);
if (!bytesIsEqual(hash, serverDHAnswer.new_nonce_hash1)) {
throw new Error(`Invalid hash in mt_dh_gen_ok`);
}
this.handleMessage = this.handleEncryptedMessage;
this.isAuth = true;
this.sendWaitMessages();
return;
}
if (serverDHAnswer._ === 'mt_dh_gen_retry') {
const hash = (
await this.crypto.SHA1(
concatBytes(this.newNonce, [2], this.authKeyAuxHash)
)
).slice(4, 20);
if (!bytesIsEqual(hash, serverDHAnswer.new_nonce_hash2)) {
throw new Error(`Invalid hash in mt_dh_gen_retry`);
}
this.generateDH(this.authKeyAuxHash);
return;
}
if (serverDHAnswer._ === 'mt_dh_gen_fail') {
const hash = (
await this.crypto.SHA1(
concatBytes(this.newNonce, [3], this.authKeyAuxHash)
)
).slice(4, 20);
if (!bytesIsEqual(hash, serverDHAnswer.new_nonce_hash3)) {
throw new Error(`Invalid hash in mt_dh_gen_fail`);
}
throw new Error(`mt_dh_gen_fail`);
}
throw new Error(`Invalid Set_client_DH_params_answer: ${serverDHAnswer}`);
}
async sendWaitMessages() {
// Resend unacknowledged messages
for (let message of this.messagesWaitResponse.values()) {
if (message.isAck) {
continue;
}
const { method, params, resolve, reject } = message;
this.call(method, params).then(resolve).catch(reject);
}
this.messagesWaitAuth.forEach((message) => {
const { method, params, resolve, reject } = message;
this.call(method, params).then(resolve).catch(reject);
});
this.messagesWaitAuth = [];
}
async handleEncryptedMessage(buffer) {
const authKey = new Uint8Array(await this.getStorageItem('authKey'));
const deserializer = new Deserializer(buffer);
const authKeyId = deserializer.long();
const messageKey = deserializer.int128();
const encryptedData = deserializer.byteView.slice(deserializer.offset);
const plaintextData = (
await this.getAESInstance(authKey, messageKey, true)
).decrypt(encryptedData);
const computedMessageKey = (
await this.crypto.SHA256(
concatBytes(authKey.slice(96, 128), plaintextData)
)
).slice(8, 24);
if (!bytesIsEqual(messageKey, computedMessageKey)) {
console.warn(`Incorrect msg_key`);
return;
}
const plainDeserializer = new Deserializer(plaintextData.buffer);
const salt = plainDeserializer.long();
const sessionId = plainDeserializer.long();
const messageId = plainDeserializer.long();
const seqNo = plainDeserializer.uint32();
const length = plainDeserializer.uint32();
if (length > plaintextData.length) {
console.warn(
`Length in message ${messageId} to exceed the plaintext length:`,
`${length} > ${plaintextData.length}`
);
return;
}
if (length % 4 !== 0) {
console.warn(
`Length ${length} in message ${messageId} is not a multiple of four`
);
return;
}
const result = plainDeserializer.predicate();
this.handleDecryptedMessage(result, { messageId, seqNo });
}
async handleDecryptedMessage(message, params = {}) {
const { messageId } = params;
if (bigInt(messageId).isEven()) {
this.debug('message id from server is even', message);
return;
}
if (this.pendingAcks.includes(messageId)) {
this.sendAcks();
return;
}
if (message._ === 'mt_msg_container') {
this.debug('handling container');
message.messages.forEach((message) => {
this.handleDecryptedMessage(message.body, {
messageId: message.msg_id,
});
});
return;
}
if (['mt_bad_server_salt', 'mt_bad_msg_notification'].includes(message._)) {
this.debug(`handling ${message._} for message ${message.bad_msg_id}`);
if (message.error_code === 48) {
await this.setStorageItem(
'serverSalt',
longToBytesRaw(message.new_server_salt)
);
}
if ([16, 17].includes(message.error_code)) {
const serverTime = bigInt(messageId).shiftRight(32).toJSNumber();
const timeOffset = Math.floor(Date.now() / 1000) - serverTime;
await this.context.storage.set('timeOffset', timeOffset);
this.lastMessageId = [0, 0];
}
const waitMessage = this.messagesWaitResponse.get(message.bad_msg_id);
if (waitMessage) {
this.call(waitMessage.method, waitMessage.params)
.then(waitMessage.resolve)
.catch(waitMessage.reject);
this.messagesWaitResponse.delete(message.bad_msg_id);
} else {
this.debug(`${message._} for a non-existent message`, message);
}
return;
}
if (message._ === 'mt_new_session_created') {
this.debug(`handling new session created`);
this.ackMessage(messageId);
await this.setStorageItem(
'serverSalt',
longToBytesRaw(message.server_salt)
);
return;
}
if (message._ === 'mt_msgs_ack') {
this.debug('handling acknowledge for', message.msg_ids);
message.msg_ids.forEach((msgId) => {
const waitMessage = this.messagesWaitResponse.get(msgId);
const nextWaitMessage = {
...waitMessage,
isAck: true,
};
this.messagesWaitResponse.set(msgId, nextWaitMessage);
});
return;
}
if (message._ === 'mt_rpc_result') {
this.ackMessage(messageId);
this.debug('handling RPC result for message', message.req_msg_id);
const waitMessage = this.messagesWaitResponse.get(message.req_msg_id);
if (message.result._ === 'mt_rpc_error') {
waitMessage.reject(message.result);
} else {
waitMessage.resolve(message.result);
}
this.messagesWaitResponse.delete(message.req_msg_id);
return;
}
this.debug('handling update', message._);
this.ackMessage(messageId);
this.context.updates.emit(message._, message);
}
ackMessage(messageId) {
this.pendingAcks.push(messageId);
this.sendAcks();
}
async call(method, params = {}) {
if (!this.isReady) {
return new Promise((resolve, reject) => {
this.messagesWaitAuth.push({ method, params, resolve, reject });
});
}
const { api_id, api_hash } = this.context;
const initConnectionParams = {
api_id,
device_model: 'mtproton',
system_version: '6.0.0',
app_version: '1.0.0',
system_lang_code: 'en',
lang_code: 'en',
...this.context.initConnectionParams,
};
const serializer = new Serializer(builderMap.invokeWithLayer, {
layer: 133,
query: {
_: 'initConnection',
...initConnectionParams,
query: {
_: method,
api_id,
api_hash,
...params,
},
},
});
const bytes = serializer.getBytes();
return new Promise(async (resolve, reject) => {
const messageId = await this.sendEncryptedMessage(bytes);
const messageIdAsKey = intsToLong(messageId[0], messageId[1]);
this.messagesWaitResponse.set(messageIdAsKey, {
method,
params,
resolve,
reject,
isAck: false,
});
});
}
// https://core.telegram.org/mtproto/description#schematic-presentation-of-messages
// Encrypted Message:
// 1. auth_key_id (int64)
// 2. msg_key (int128)
// 3. encrypted_data
// encrypted_data:
// 4. salt (int64)
// 5. session_id (int64)
// 6. message_id (int64)
// 7. seq_no (int32)
// 8. message_data_length (int32)
// 9. message_data
// 10. padding 12..1024
async sendEncryptedMessage(data, options = {}) {
const { isContentRelated = true } = options;
const authKey = new Uint8Array(await this.getStorageItem('authKey'));
const serverSalt = new Uint8Array(await this.getStorageItem('serverSalt'));
const messageId = await this.getMessageId();
const seqNo = this.getSeqNo(isContentRelated);
const minPadding = 12;
const unpadded = (32 + data.length + minPadding) % 16;
const padding = minPadding + (unpadded ? 16 - unpadded : 0);
const { crypto, sessionId } = this;
const plainDataSerializer = new Serializer(function () {
this.bytesRaw(serverSalt);
this.bytesRaw(sessionId);
this.long(messageId);
this.int32(seqNo);
this.uint32(data.length);
this.bytesRaw(data);
this.bytesRaw(crypto.getRandomBytes(padding));
});
const plainData = plainDataSerializer.getBytes();
const messageKeyLarge = await crypto.SHA256(
concatBytes(authKey.slice(88, 120), plainData)
);
const messageKey = messageKeyLarge.slice(8, 24);
const encryptedData = (
await this.getAESInstance(authKey, messageKey, false)
).encrypt(plainData);
const authKeyId = (await crypto.SHA1(authKey)).slice(-8);
const serializer = new Serializer(function () {
this.bytesRaw(authKeyId);
this.bytesRaw(messageKey);
this.bytesRaw(encryptedData);
});
this.transport.send(serializer.getBytes());
return messageId;
}
async sendPlainMessage(fn, params) {
const serializer = new Serializer(fn, params);
const requestBytes = serializer.getBytes();
const requestLength = requestBytes.length;
const messageId = await this.getMessageId();
const header = new Serializer(function () {
this.long([0, 0]);
this.long(messageId);
this.uint32(requestLength);
});
const headerBytes = header.getBytes();
const headerLength = headerBytes.length;
const resultBuffer = new ArrayBuffer(headerLength + requestLength);
const resultBytes = new Uint8Array(resultBuffer);
resultBytes.set(headerBytes);
resultBytes.set(requestBytes, headerLength);
this.transport.send(resultBytes);
}
async getMessageId() {
// @TODO: Check timeOffset
const timeOffset = await this.context.storage.get('timeOffset');
const timeTicks = Date.now();
const timeSec = Math.floor(timeTicks / 1000) + timeOffset;
const timeMSec = timeTicks % 1000;
const random = getRandomInt(0xffff);
const { lastMessageId } = this;
let messageId = [timeSec, (timeMSec << 21) | (random << 3) | 4];
if (
lastMessageId[0] > messageId[0] ||
(lastMessageId[0] == messageId[0] && lastMessageId[1] >= messageId[1])
) {
messageId = [lastMessageId[0], lastMessageId[1] + 4];
}
this.lastMessageId = messageId;
return messageId;
}
getSeqNo(isContentRelated = true) {
let seqNo = this.seqNo * 2;
if (isContentRelated) {
seqNo += 1;
this.seqNo += 1;
}
return seqNo;
}
updateSession() {
this.seqNo = 0;
this.sessionId = this.crypto.getRandomBytes(8);
this.lastMessageId = [
0, // low
0, // high
];
}
async getAESInstance(authKey, messageKey, isServer) {
const x = isServer ? 8 : 0;
const sha256a = await this.crypto.SHA256(
concatBytes(messageKey, authKey.slice(x, 36 + x))
);
const sha256b = await this.crypto.SHA256(
concatBytes(authKey.slice(40 + x, 76 + x), messageKey)
);
const aesKey = concatBytes(
sha256a.slice(0, 8),
sha256b.slice(8, 24),
sha256a.slice(24, 32)
);
const aesIV = concatBytes(
sha256b.slice(0, 8),
sha256a.slice(8, 24),
sha256b.slice(24, 32)
);
return new AES.IGE(aesKey, aesIV);
}
async setStorageItem(key, value) {
return this.context.storage.set(`${this.dc.id}${key}`, value);
}
async getStorageItem(key) {
return this.context.storage.get(`${this.dc.id}${key}`);
}
}
module.exports = RPC;