-
-
Save rovo89/dff47ed19fca0dfdda77503e66c2b7c7 to your computer and use it in GitHub Desktop.
"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(); | |
} |
Getting some errors around the headers setup, could you share the package.json
to know which dependencies versions to use please?
Edit: I was able to make the script run by changing this:
- config.headers.common['Authorization'] = `Hawk id="${rriot.u}", s="${rriot.s}", ts="${timestamp}", nonce="${nonce}", mac="${mac}"`;
+ config.headers['Authorization'] = `Hawk id="${rriot.u}", s="${rriot.s}", ts="${timestamp}", nonce="${nonce}", mac="${mac}"`;
Leaving here some findings in case anyone ever finds them useful.
I'm testing the test script on a Roborock S5 Max and I'm getting an error as soon I start/pause the robot. The problem is that the client.on('message'
logic to JSON.parse data.payload
expects a string but I have a buffer in it.
If I convert the buffer to string before parsing it I get a {t: number, dps: { 121: 5 }}
payload, which crashes the rr.emit
call below because it expects dps
to contain an id
and result
properties.
How to get 32-symbol token for the vacuum?
I run this script, got userdata.json
and homedata.json
. But I cannot figure out where to get valid token (it should be 32 len)
How to get 32-symbol token for the vacuum?
Are you sure you're talking about the Roborock API? I think its credentials/tokens are different from the Xiaomi/Mi Home token that you might be looking for.
Yep, looks like I go wrong way
Getting some errors around the headers setup, could you share the
package.json
to know which dependencies versions to use please?Edit: I was able to make the script run by changing this:
- config.headers.common['Authorization'] = `Hawk id="${rriot.u}", s="${rriot.s}", ts="${timestamp}", nonce="${nonce}", mac="${mac}"`; + config.headers['Authorization'] = `Hawk id="${rriot.u}", s="${rriot.s}", ts="${timestamp}", nonce="${nonce}", mac="${mac}"`;
Above change works great with:
yarn add axios@1.3.4
node login.js 'username' 'password'
cat userdata.json | grep token
Thanks!
Leaving here some findings in case anyone ever finds them useful.
I'm testing the test script on a Roborock S5 Max and I'm getting an error as soon I start/pause the robot. The problem is that the
client.on('message'
logic to JSON.parsedata.payload
expects a string but I have a buffer in it.If I convert the buffer to string before parsing it I get a
{t: number, dps: { 121: 5 }}
payload, which crashes therr.emit
call below because it expectsdps
to contain anid
andresult
properties.
Did you have any luck sending payloads to the Roborock. login.js and test.js are working, but how do I start cleaning, what payload has to be sent via mqtt
@rovo89 Taking a look at this as we are currently in the progress of trying to add support for the new dyad series for the python-roborock package. Where in librrcodec did you find the salt? We are thinking that there may be a different salt for the dyad series than there is for the normal robot vacuums
librrcodec.so here:
https://dogbolt.org/?id=0d200a6d-78bb-4842-aa3c-7a2207070bb8#Hex-Rays=12508
@Lash-L I don't remember the exact details, but as the comment says, the salt is derived from two things:
// 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';
Did you compare the manifest files?
Did you compare the manifest files?
Yep - appsecret is still the same.
Versioning changes from 1.0 to A01 for the devices, but even after changing that I've struggled to decrypt the bytes - so I was thinking it was potentially the salt.
And do they have a different app for those models? Just wondering, otherwise they'd need code to differentiate between the models.
And do they have a different app for those models? Just wondering, otherwise they'd need code to differentiate between the models.
Same app. From what I can tell models contain a 'pv' attribute(i think that is what it is called) - said pv attribute is either 1.0 or A01
From what I can tell
if (str2.equals("1.0")) {
bArr = RRCodecApi.codec(RRHomeSdk.getApplication(), bArr2, i, str3, 1);
} else {
bArr = str2.equals("A01") ? RRCodecApi.codec2(RRHomeSdk.getApplication(), bArr2, str3, i2, 1) : null;
}
Where RRCodeApi is:
public class RRCodecApi {
static {
try {
System.loadLibrary("rrcodec");
} catch (UnsatisfiedLinkError e) {
OooO0o.OooO("RRCodecApi", e);
new OooOO0O().OooO0O0(RRHomeSdk.getApplication(), "rrcodec");
}
}
public static native byte[] apiCodec(byte[] bArr, String str, int i);
public static native byte[] apiCodec2(String str, int i);
public static native byte[] codec(Object obj, byte[] bArr, int i, String str, int i2);
public static native byte[] codec2(Object obj, byte[] bArr, String str, int i, int i2);
public static native void init(Object obj, String str);
}```
There is a different codec for 1.0 than A01, i'm just not sure how to determine it
I checked my local copy of librrcodec.so, but I can't recall the location of the encoded salt. But since they introduced an additional method, there might be more changes to it - maybe even a different hash or crypto algorithm?
I'm afraid you'll need to trace the app yourself as I hardly have any spare time right now. :/
Hint: Close to the end of my investigations, I found out that using gdb can make analysis much easier. Might require to recompile the app as debuggable, not sure. Then put breakpoints on MD5_Update and MD5_Final and log the inputs/outputs.
Where are MD5_Update and MD5_Final? did you mean gdb or adb? I set up adb on the apk, but now looking at it, maybe you mean tot do gdb on the c code - which i'm not 100% on how i would do
edit: ah i see it is in opensll. I'll see if i can figure out how to do it
I meant gdb, which is a tool for debugging native code. It's not easy to use, and it requires additional steps to attach it to an Android app. Honestly, it was mostly trial and error and looking for hints online to get it working. Once attached, you can set breakpoints on functions in librrcodec.so and its dependencies, and inspect the variables.
Again, it requires quite some background knowledge in reverse-engineering of native code, but compared to my previous attempts, it felt much easier and more straight-forwards.
I'm also struggling with decoding messages with A01 instead of 1.0 as protocol version number in my adapter for ioBroker https://github.com/copystring/ioBroker.roborock/tree/main.
I have one user with a Robrock Zeo One wash dryer who asks to add support for it. See this: copystring/ioBroker.roborock#432 maybe we can work together to get decryption working.
@copystring ive made some decent headway. Feel free to message me on discord
@Lash-L thank you. Sure. Discord is good, but the link does not take me anywhere.
@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
Got you :)
Would be nice if you could share your findings here afterwards 😉
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
Script appears to be broken, just attempted to use it.