homebridge-miot
Version:
Homebridge plugin for devices supporting the miot protocol
528 lines (460 loc) • 15.3 kB
JavaScript
const crypto = require('crypto');
const fetch = require('node-fetch');
const randomstring = require('randomstring');
const querystring = require('querystring');
const Errors = require("../utils/Errors.js");
const CustomCryptRC4 = require("../utils/CustomCryptRC4.js");
const DEFAULT_EQUEST_TIMEOUT = 5000;
class MiCloud {
constructor(logger) {
this.logger = logger;
this.username = null;
this.password = null;
this.ssecurity = null;
this.userId = null;
this.serviceToken = null;
this.country = 'cn';
this.requestTimeout = DEFAULT_EQUEST_TIMEOUT;
this.useUnencryptedRequests = false;
this.availableCountries = ['ru', 'us', 'tw', 'sg', 'cn', 'de', 'in', 'i2'];
this.locale = 'en';
// constants
this.AGENT_ID = randomstring.generate({
length: 13,
charset: 'ABCDEF',
});
this.USERAGENT = `Android-7.1.1-1.0.0-ONEPLUS A3010-136-${this.AGENT_ID} APP/xiaomi.smarthome APPV/62830`;
this.CLIENT_ID = randomstring.generate({
length: 6,
charset: 'alphabetic',
capitalization: 'uppercase',
});
}
isLoggedIn() {
return !!this.serviceToken;
}
setCountry(country = 'cn') {
if (!this.availableCountries.includes(country)) {
throw new Error(`The country ${country} is not supported, list of supported countries is ${this.availableCountries.join(', ')}`);
}
this.country = country;
}
setRequestTimeout(timeout = DEFAULT_EQUEST_TIMEOUT) {
if (!timeout || timeout < 2000) {
timeout = 2000; // make sure we stay above 2000ms since those requests might take some time
}
this.requestTimeout = timeout;
}
setUseUnencryptedRequests(unencrypted = false) {
this.useUnencryptedRequests = unencrypted;
}
getServiceToken() {
if (this.isLoggedIn()) {
return {
ssecurity: this.ssecurity,
userId: this.userId,
serviceToken: this.serviceToken
};
}
}
setServiceToken(tokenJson) {
if (tokenJson) {
const {
ssecurity,
userId,
serviceToken
} = tokenJson;
if (ssecurity && userId && serviceToken) {
this.ssecurity = ssecurity;
this.userId = userId;
this.serviceToken = serviceToken;
}
}
}
async login(username, password) {
this.logger.debug(`(MiCloud) Log in to MiCloud with username ${username}. Request timeout: ${this.requestTimeout} milliseconds.`);
if (this.isLoggedIn()) {
throw new Error(`You are already logged in with username ${this.username}. Login not required!`);
}
const {
sign
} = await this._loginStep1();
const {
ssecurity,
userId,
location
} = await this._loginStep2(username, password, sign);
const {
serviceToken
} = await this._loginStep3(sign.indexOf('http') === -1 ? location : sign);
this.logger.debug(`(MiCloud) Login successful!`);
this.username = username;
this.password = password;
this.ssecurity = ssecurity;
this.userId = userId;
this.serviceToken = serviceToken;
}
logout() {
if (!this.isLoggedIn()) {
throw new Error('You are not logged in! Cannot log out!');
}
this.logger.debug(`(MiCloud) Logout from MiCloud for username ${this.username}`);
this.username = null;
this.password = null;
this.ssecurity = null;
this.userId = null;
this.serviceToken = null;
this.setCountry('cn');
}
refreshServiceToken() {
this.logger.debug(`(MiCloud) Refreshing MiCloud service token for username ${this.username}`);
this.ssecurity = null;
this.userId = null;
this.serviceToken = null;
this.login(this.username, this.password);
}
async request(path, data) {
if (this.useUnencryptedRequests) {
return await this._requestUnencrypted(path, data);
} else {
return await this._requestEncrypted(path, data);
}
}
async getDevices(deviceIds) {
const req = deviceIds ? {
dids: deviceIds,
} : {
getVirtualModel: false,
getHuamiDevices: 0,
};
// {"getVirtualModel":true,"getHuamiDevices":1,"get_split_device":false,"support_smart_home":true}
const data = await this.request('/home/device_list', req);
return data.result.list;
}
async getDevice(deviceId) {
const req = {
dids: [String(deviceId)]
};
const data = await this.request('/home/device_list', req);
return data.result.list[0];
}
// this passes the commands to the device 1:1
async miioCall(deviceId, method, params) {
const req = {
method,
params
};
const data = await this.request(`/home/rpc/${deviceId}`, req);
return data.result;
}
// the below methods always use miot protocol even for old device which does not support them locally
async miotGetProps(params) {
const req = {
params
};
const data = await this.request(`/miotspec/prop/get`, req);
return data.result;
}
async miotSetProps(params) {
const req = {
params
};
const data = await this.request(`/miotspec/prop/set`, req);
return data.result;
}
async miotAction(params) {
const req = {
params
};
const data = await this.request(`/miotspec/action`, req);
return data.result;
}
// private stuff
async _requestUnencrypted(path, data) {
if (!this.isLoggedIn()) {
throw new Error('You are not logged in! Cannot make a request!');
}
const url = this._getApiUrl(this.country) + path;
const params = {
data: JSON.stringify(data),
};
const nonce = this._generateNonce();
const signedNonce = this._signedNonce(this.ssecurity, nonce);
const signature = this._generateSignature(path, signedNonce, nonce, params);
const body = {
_nonce: nonce,
data: params.data,
signature,
};
this.logger.deepDebug(`(MiCloud) Unencrypted request ${url} - ${JSON.stringify(body)}`);
const res = await fetch(url, {
method: 'POST',
timeout: this.requestTimeout,
headers: {
'User-Agent': this.USERAGENT,
'x-xiaomi-protocal-flag-cli': 'PROTOCAL-HTTP2',
'Content-Type': 'application/x-www-form-urlencoded',
Cookie: [
'sdkVersion=accountsdk-18.8.15',
`deviceId=${this.CLIENT_ID}`,
`userId=${this.userId}`,
`yetAnotherServiceToken=${this.serviceToken}`,
`serviceToken=${this.serviceToken}`,
`locale=${this.locale}`,
'channel=MI_APP_STORE'
].join('; '),
},
body: querystring.stringify(body),
});
if (!res.ok) {
throw new Error(`Request error with status ${res.status} ${res.statusText}`);
}
const json = await res.json();
if (json && !json.result && json.message && json.message.length > 0) {
this.logger.debug(`(MiCloud) No result in response from MiCloud! Message: ${json.message}`);
}
return json;
}
async _requestEncrypted(path, data) {
if (!this.isLoggedIn()) {
throw new Error('You are not logged in! Cannot make a request!');
}
const url = this._getApiUrl(this.country) + path;
const params = {
data: JSON.stringify(data),
};
const nonce = this._generateNonce();
const signedNonce = this._signedNonce(this.ssecurity, nonce);
const body = this._generateRc4Body(url, signedNonce, nonce, params, this.ssecurity);
this.logger.deepDebug(`(MiCloud) Encrypted request ${url} - ${JSON.stringify(data)}`);
const res = await fetch(url, {
method: 'POST',
timeout: this.requestTimeout,
headers: {
'User-Agent': this.USERAGENT,
'x-xiaomi-protocal-flag-cli': 'PROTOCAL-HTTP2',
'Accept-Encoding': 'identity',
'Content-Type': 'application/x-www-form-urlencoded',
'MIOT-ENCRYPT-ALGORITHM': 'ENCRYPT-RC4',
Cookie: [
'sdkVersion=accountsdk-18.8.15',
`deviceId=${this.CLIENT_ID}`,
`userId=${this.userId}`,
`yetAnotherServiceToken=${this.serviceToken}`,
`serviceToken=${this.serviceToken}`,
`locale=${this.locale}`,
'channel=MI_APP_STORE'
].join('; '),
},
body: querystring.stringify(body)
});
if (!res.ok) {
throw new Error(`Request error with status ${res.status} ${res.statusText}`);
}
const responseText = await res.text();
const decryptedText = this._decryptRc4(signedNonce, responseText);
const json = JSON.parse(decryptedText);
if (json && !json.result && json.message && json.message.length > 0) {
this.logger.debug(`(MiCloud) No result in response from MiCloud! Message: ${json.message}`);
}
return json;
}
_getApiUrl(country) {
country = country.trim().toLowerCase();
return `https://${country === 'cn' ? '' : `${country}.`}api.io.mi.com/app`;
}
_parseJson(str) {
if (str.indexOf('&&&START&&&') === 0) {
str = str.replace('&&&START&&&', '');
}
return JSON.parse(str);
}
_generateSignature(path, _signedNonce, nonce, params) {
const exps = [];
exps.push(path);
exps.push(_signedNonce);
exps.push(nonce);
const paramKeys = Object.keys(params);
paramKeys.sort();
for (let i = 0, {
length
} = paramKeys; i < length; i++) {
const key = paramKeys[i];
exps.push(`${key}=${params[key]}`);
}
return crypto
.createHmac('sha256', Buffer.from(_signedNonce, 'base64'))
.update(exps.join('&'))
.digest('base64');
}
_generateNonce() {
const buf = Buffer.allocUnsafe(12);
buf.write(crypto.randomBytes(8).toString('hex'), 0, 'hex');
buf.writeInt32BE(parseInt(Date.now() / 60000, 10), 8);
return buf.toString('base64');
}
_signedNonce(ssecret, nonce) {
const s = Buffer.from(ssecret, 'base64');
const n = Buffer.from(nonce, 'base64');
return crypto.createHash('sha256').update(s).update(n).digest('base64');
}
_generateRc4Body(url, signedNonce, nonce, params, ssecurity) {
params['rc4_hash__'] = this._generateEncSignature(url, 'POST', signedNonce, params);
for (const [key, value] of Object.entries(params)) {
params[key] = this._encryptRc4(signedNonce, value);
}
params['signature'] = this._generateEncSignature(url, 'POST', signedNonce, params);
params['ssecurity'] = ssecurity;
params['_nonce'] = nonce;
return params;
}
_generateEncSignature(url, method, signedNonce, params) {
const signatureArr = [];
signatureArr.push(method.toUpperCase());
signatureArr.push(url.split('com')[1].replace('/app/', '/'));
const paramKeys = Object.keys(params);
paramKeys.sort();
for (let i = 0, {
length
} = paramKeys; i < length; i++) {
const key = paramKeys[i];
signatureArr.push(`${key}=${params[key]}`);
}
signatureArr.push(signedNonce);
const signatureStr = signatureArr.join('&');
return crypto.createHash('sha1').update(signatureStr).digest('base64');
}
_encryptRc4(password, payload) {
let k = Buffer.from(password, 'base64');
//----------## crypto rc4 ##----------
//Since nodejs v18.x.x, rc4 seems deaprecated in crypto? due to a new openssl?
//let cipher = crypto.createCipheriv('rc4', k, '');
//cipher.update(new Buffer(1024));
//return cipher.update(payload).toString('base64');
//----------## crypto end ##----------
// for now lets use a custom rc4 implementation
let cipher = CustomCryptRC4.create(k, 1024);
return cipher.encode(payload);
}
_decryptRc4(password, payload) {
let k = Buffer.from(password, 'base64');
let p = Buffer.from(payload, 'base64');
//----------## crypto rc4 ##----------
//Since nodejs v18.x.x, rc4 seems deaprecated in crypto? due to a new openssl?
//let decipher = crypto.createDecipheriv('rc4', k, '');
//decipher.update(new Buffer(1024));
//return decipher.update(p).toString();
//----------## crypto end ##----------
// for now lets use a custom rc4 implementation
let decipher = CustomCryptRC4.create(k, 1024);
return decipher.decode(p);
}
async _loginStep1() {
const url = 'https://account.xiaomi.com/pass/serviceLogin?sid=xiaomiio&_json=true';
const res = await fetch(url);
const content = await res.text();
const {
statusText
} = res;
this.logger.debug(`(MiCloud) Login step 1`);
this.logger.deepDebug(`(MiCloud) Login step 1 result: ${statusText} - ${content}`);
if (!res.ok) {
throw new Error(`Response step 1 error with status ${statusText}`);
}
const data = this._parseJson(content);
if (!data._sign) {
throw new Error('Login step 1 failed');
}
return {
sign: data._sign,
};
}
async _loginStep2(username, password, sign) {
const formData = querystring.stringify({
hash: crypto
.createHash('md5')
.update(password)
.digest('hex')
.toUpperCase(),
_json: 'true',
sid: 'xiaomiio',
callback: 'https://sts.api.io.mi.com/sts',
qs: '%3Fsid%3Dxiaomiio%26_json%3Dtrue',
_sign: sign,
user: username,
});
const url = 'https://account.xiaomi.com/pass/serviceLoginAuth2';
const res = await fetch(url, {
method: 'POST',
body: formData,
headers: {
'User-Agent': this.USERAGENT,
'Content-Type': 'application/x-www-form-urlencoded',
Cookie: [
'sdkVersion=accountsdk-18.8.15',
`deviceId=${this.CLIENT_ID};`
].join('; '),
},
});
const content = await res.text();
const {
statusText
} = res;
this.logger.debug(`(MiCloud) Login step 2`);
this.logger.deepDebug(`(MiCloud) Login step 2 result: ${statusText} - ${content}`);
if (!res.ok) {
throw new Error(`Response step 2 error with status ${statusText}`);
}
const {
ssecurity,
userId,
location,
notificationUrl,
} = this._parseJson(content);
if (!ssecurity && notificationUrl) {
throw new Errors.TwoFactorRequired(notificationUrl);
}
if (!ssecurity || !userId || !location) {
throw new Error('Login step 2 failed');
}
this.ssecurity = ssecurity; // Buffer.from(data.ssecurity, 'base64').toString('hex');
this.userId = userId;
return {
ssecurity,
userId,
location,
};
}
async _loginStep3(location) {
const url = location;
const res = await fetch(url);
const content = await res.text();
const {
statusText
} = res;
this.logger.debug(`(MiCloud) Login step 3`);
this.logger.deepDebug(`(MiCloud) Login step 3 result: ${statusText} - ${content}`);
if (!res.ok) {
throw new Error(`Response step 3 error with status ${statusText}`);
}
const headers = res.headers.raw();
const cookies = headers['set-cookie'];
let serviceToken;
cookies.forEach(cookieStr => {
const cookie = cookieStr.split('; ')[0];
const idx = cookie.indexOf('=');
const key = cookie.substr(0, idx);
const value = cookie.substr(idx + 1, cookie.length).trim();
if (key === 'serviceToken') {
serviceToken = value;
}
});
if (!serviceToken) {
throw new Error('Login step 3 failed');
}
return {
serviceToken,
};
}
}
module.exports = MiCloud;