Skip to content

Instantly share code, notes, and snippets.

@rovo89

rovo89/login.js Secret

Last active June 20, 2024 12:45
Show Gist options
  • Save rovo89/dff47ed19fca0dfdda77503e66c2b7c7 to your computer and use it in GitHub Desktop.
Save rovo89/dff47ed19fca0dfdda77503e66c2b7c7 to your computer and use it in GitHub Desktop.
Roborock
"use strict";
const fs = require('fs');
const axios = require('axios');
const crypto = require('crypto');
const userdataFilename = 'userdata.json';
const homedataFilename = 'homedata.json';
const username = process.argv[2];
const password = process.argv[3];
async function main() {
// Initialize the login API (which is needed to get access to the real API).
const loginApi = axios.create({
baseURL: 'https://euiot.roborock.com',
headers: {
'header_clientid': crypto.createHash('md5').update(username).update('should_be_unique').digest().toString('base64'),
},
});
// api/v1/getUrlByEmail(email = ...)
// Try to load existing userdata.
let userdata;
if (fs.existsSync(userdataFilename)) {
userdata = JSON.parse(fs.readFileSync(userdataFilename, 'utf8'));
} else {
// Log in.
userdata = await loginApi.post('api/v1/login', new URLSearchParams({username: username, password: password, needtwostepauth: 'false'}).toString()).then(res => res.data.data);
fs.writeFileSync(userdataFilename, JSON.stringify(userdata, null, 2, 'utf8'));
// Alternative without password:
// await loginApi.post('api/v1/sendEmailCode', new url.URLSearchParams({username: username, type: 'auth'}).toString()).then(res => res.data);
// // ... get code from user ...
// userdata = await loginApi.post('api/v1/loginWithCode', new url.URLSearchParams({username: username, verifycode: code, verifycodetype: 'AUTH_EMAIL_CODE'}).toString()).then(res => res.data.data);
}
loginApi.defaults.headers.common['Authorization'] = userdata.token;
const rriot = userdata.rriot;
// Get home details.
const homeId = await loginApi.get('api/v1/getHomeDetail').then(res => res.data.data.rrHomeId);
// Initialize the real API.
const api = axios.create({
baseURL: rriot.r.a,
});
api.interceptors.request.use(config => {
const timestamp = Math.floor(Date.now() / 1000);
const nonce = crypto.randomBytes(6).toString('base64').substring(0, 6).replace('+', 'X').replace('/', 'Y');
const url = new URL(api.getUri(config));
const prestr = [rriot.u, rriot.s, nonce, timestamp, md5hex(url.pathname), /*queryparams*/ '', /*body*/ ''].join(':');
const mac = crypto.createHmac('sha256', rriot.h).update(prestr).digest('base64');
config.headers.common['Authorization'] = `Hawk id="${rriot.u}", s="${rriot.s}", ts="${timestamp}", nonce="${nonce}", mac="${mac}"`;
return config;
});
const homedata = await api.get(`user/homes/${homeId}`).then(res => res.data.result);
fs.writeFileSync(homedataFilename, JSON.stringify(homedata, null, 2, 'utf8'));
}
main();
////////////////////////////////////////////////////////////////////////////////////////////////////
function md5hex(str) {
return crypto.createHash('md5').update(str).digest('hex');
}
"use strict";
const fs = require('fs');
const mqtt = require('mqtt')
const crypto = require('crypto');
const Parser = require('binary-parser').Parser;
const CRC32 = require('crc-32');
const zlib = require('zlib');
const EventEmitter = require('node:events');
const userdataFilename = 'userdata.json';
const homedataFilename = 'homedata.json';
const rriot = JSON.parse(fs.readFileSync(userdataFilename, 'utf8')).rriot;
const homedata = JSON.parse(fs.readFileSync(homedataFilename, 'utf8'));
const devices = homedata.devices.concat(homedata.receivedDevices);
const localKeys = new Map(devices.map(device => [device.duid, device.localKey]));
let seq = 1;
let random = 4711; // Should be initialized with a number 0 - 1999?
let idCounter = 1;
const endpoint = md5bin(rriot.k).subarray(8, 14).toString('base64'); // Could be a random but rather static string. The app generates it on first run.
const nonce = crypto.randomBytes(16);
// This value is stored hardcoded in librrcodec.so, encrypted by the value of "com.roborock.iotsdk.appsecret" from AndroidManifest.xml.
const salt = 'TXdfu$jyZ#TZHsg4';
const rr = new EventEmitter();
const mqttMessageParser = new Parser()
.endianess('big')
.string('version', {length: 3})
.uint32('seq')
.uint32('random')
.uint32('timestamp')
.uint16('protocol')
.uint16('payloadLen')
.buffer('payload', {length: 'payloadLen'})
.uint32('crc32');
const protocol301Parser = new Parser()
.endianess('little')
.string('endpoint', {length: 15, stripNull: true})
.uint8('unknown1')
.uint16('id')
.buffer('unknown2', {length: 6});
const mqttUser = md5hex(rriot.u + ':' + rriot.k).substring(2, 10);
const mqttPassword = md5hex(rriot.s + ':' + rriot.k).substring(16);
const client = mqtt.connect(rriot.r.m, {username: mqttUser, password: mqttPassword, keepalive: 30})
client.on('connect', function () {
client.subscribe(`rr/m/o/${rriot.u}/${mqttUser}/#`, function(err, granted) {
if (!err) {
const deviceId = devices[0].duid; // Simply use the first device.
sendRequest(deviceId, 'get_prop', ['get_status']).then(result => {
console.log(result);
});
sendRequest(deviceId, 'get_map_v1', [], true).then(result => {
console.log(result);
});
}
})
})
function _decodeMsg(msg, localKey) {
// Do some checks before trying to decode the message.
if (msg.toString('latin1', 0, 3) !== '1.0') {
throw new Error('Unknown protocol version');
}
const crc32 = CRC32.buf(msg.subarray(0, msg.length - 4)) >>> 0;
const expectedCrc32 = msg.readUint32BE(msg.length - 4);
if (crc32 != expectedCrc32) {
throw new Error(`Wrong CRC32 ${crc32}, expected ${expectedCrc32}`);
}
const data = mqttMessageParser.parse(msg);
delete data.payloadLen;
const aesKey = md5bin(_encodeTimestamp(data.timestamp) + localKey + salt);
const decipher = crypto.createDecipheriv('aes-128-ecb', aesKey, null);
data.payload = Buffer.concat([decipher.update(data.payload), decipher.final()]);
return data;
}
client.on('message', function (topic, message) {
const deviceId = topic.split('/').slice(-1)[0];
let data = _decodeMsg(message, localKeys.get(deviceId));
rr.emit('response.raw', deviceId, data);
if (data.protocol == 102) {
let dps = JSON.parse(JSON.parse(data.payload).dps['102']);
rr.emit('response.102', deviceId, dps.id, dps.result[0]);
} else if (data.protocol == 301) {
const data2 = protocol301Parser.parse(data.payload.subarray(0, 24));
if (endpoint.startsWith(data2.endpoint)) {
const iv = Buffer.alloc(16, 0);
const decipher = crypto.createDecipheriv('aes-128-cbc', nonce, iv);
let decrypted = Buffer.concat([decipher.update(data.payload.subarray(24)), decipher.final()]);
decrypted = zlib.gunzipSync(decrypted);
rr.emit('response.301', deviceId, data2.id, decrypted);
}
}
});
function _encodeTimestamp(timestamp) {
const hex = timestamp.toString(16).padStart(8, '0').split('');
return [5,6,3,7,1,2,0,4].map(idx => hex[idx]).join('');
}
async function sendRequest(deviceId, method, params, secure = false) {
const timestamp = Math.floor(Date.now() / 1000);
let requestId = idCounter++;
let inner = {id: requestId, method: method, params: params};
if (secure) {
inner.security = {endpoint: endpoint, nonce: nonce.toString('hex').toUpperCase()};
}
let payload = JSON.stringify({t: timestamp, dps: {'101': JSON.stringify(inner)}});
return new Promise((resolve, reject) => {
rr.on('response.102', (deviceId, id, result) => {
if (id == requestId) {
if (secure) {
if (result !== 'ok') {
reject(result);
}
} else {
resolve(result);
}
}
});
if (secure) {
rr.on('response.301', (deviceId, id, result) => {
if (id == requestId) {
resolve(result);
}
});
}
sendMsgRaw(deviceId, 101, timestamp, payload);
});
}
function sendMsgRaw(deviceId, protocol, timestamp, payload) {
const localKey = localKeys.get(deviceId);
const aesKey = md5bin(_encodeTimestamp(timestamp) + localKey + salt);
const cipher = crypto.createCipheriv('aes-128-ecb', aesKey, null);
const encrypted = Buffer.concat([cipher.update(payload), cipher.final()]);
const msg = Buffer.alloc(23 + encrypted.length);
msg.write('1.0');
msg.writeUint32BE(seq++ & 0xffffffff, 3);
msg.writeUint32BE(random++ & 0xffffffff, 7);
msg.writeUint32BE(timestamp, 11);
msg.writeUint16BE(protocol, 15);
msg.writeUint16BE(encrypted.length, 17);
encrypted.copy(msg, 19);
const crc32 = CRC32.buf(msg.subarray(0, msg.length - 4)) >>> 0;
msg.writeUint32BE(crc32, msg.length - 4);
client.publish(`rr/m/i/${rriot.u}/${mqttUser}/${deviceId}`, msg);
}
function md5hex(str) {
return crypto.createHash('md5').update(str).digest('hex');
}
function md5bin(str) {
return crypto.createHash('md5').update(str).digest();
}
@copystring
Copy link

@Lash-L thank you. Sure. Discord is good, but the link does not take me anywhere.

@Lash-L
Copy link

Lash-L commented Dec 14, 2023

@Lash-L thank you. Sure. Discord is good, but the link does not take me anywhere.

Weird. My username is conway220 or Lash-L just send me a friend request

@copystring
Copy link

Got you :)

@rovo89
Copy link
Author

rovo89 commented Dec 15, 2023

Would be nice if you could share your findings here afterwards 😉

@Lash-L
Copy link

Lash-L commented Dec 15, 2023

Would be nice if you could share your findings here afterwards 😉

That’s my plan! I plan to do a pretty In detail write up on our library’s documentation page and I can link it here. Or you’d like to be included as we go, feel free to add me on discord and I can make a group. Otherwise, as long as I’m successful, I’ll publish my findings and make sure I post them here

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment