Skip to content

Instantly share code, notes, and snippets.

@rovo89

rovo89/login.js Secret

Last active December 15, 2023 13:02
Show Gist options
  • Star 11 You must be signed in to star a gist
  • Fork 5 You must be signed in to fork a gist
  • 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();
}
@4c0d3r
Copy link

4c0d3r commented Dec 7, 2022

Script appears to be broken, just attempted to use it.

@FezVrasta
Copy link

FezVrasta commented Dec 12, 2022

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}"`;

@FezVrasta
Copy link

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.

@o-mega
Copy link

o-mega commented Jan 31, 2023

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)

@rovo89
Copy link
Author

rovo89 commented Jan 31, 2023

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.

@o-mega
Copy link

o-mega commented Jan 31, 2023

Yep, looks like I go wrong way

@bkleef
Copy link

bkleef commented Mar 31, 2023

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!

@k3067e3
Copy link

k3067e3 commented May 2, 2023

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.

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

@Lash-L
Copy link

Lash-L commented Dec 11, 2023

@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

@rovo89
Copy link
Author

rovo89 commented Dec 11, 2023

@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?

@Lash-L
Copy link

Lash-L commented Dec 11, 2023

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.

@rovo89
Copy link
Author

rovo89 commented Dec 11, 2023

And do they have a different app for those models? Just wondering, otherwise they'd need code to differentiate between the models.

@Lash-L
Copy link

Lash-L commented Dec 11, 2023

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

@rovo89
Copy link
Author

rovo89 commented Dec 11, 2023

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.

@Lash-L
Copy link

Lash-L commented Dec 12, 2023

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

@rovo89
Copy link
Author

rovo89 commented Dec 12, 2023

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.

@copystring
Copy link

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.

@Lash-L
Copy link

Lash-L commented Dec 14, 2023

@copystring ive made some decent headway. Feel free to message me on discord

https://discord.gg/pq4qNrFg

@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