owarz / sense

hello sense sleep tracker local api server and homebridge plugin.
7 stars 0 forks source link

Protobuf and crypto #5

Open jhansche opened 1 day ago

jhansche commented 1 day ago

It looks like the current implementation assumes that:

  1. The full request is encrypted using the deviceKey
  2. The full request needs to be decrypted in order to parse it as the hello.SenseMessage protobuf type.

However, that's not how the Sense works - at least not mine. There are 2 body formats: one for Sense->server requests, and one for server->Sense responses. In both cases, the aes device key is only used for signing the body, not encrypting the the whole body.

This issue serves more as discussion and findings, as I haven't been able to fully get it working myself yet. I don't believe that the aesKey on my device matches any of those that your code attempts currently.

  1. Parsing Sense->server requests can be found here. The body consists of: <protobuf payload> + <random IV (16)> + <AES signature (32)>. The IV is 16 bytes, and signature is 32 bytes.
    /** Parse the <body><iv><sig> chunks */
    function decodeSignedMessage(rawBody, deviceId) {
      // See SignedMessage
      const sigChunk = rawBody.slice(rawBody.length - 32, rawBody.length); // 32
      const ivChunk = rawBody.slice(rawBody.length - 32 - 16, rawBody.length - 32); // 16
      const pbChunk = rawBody.slice(0, rawBody.length - 32 - 16);
      // Optionally you can verify the Sense signature here:
      //  verifySignature(ivChunk, sigChunk, pbChunk, deviceId);
      return pbChunk;
    }

    Since your server runs locally, there's little/no reason to actually go through with the signature verification, but this is how you would do so. One benefit of verifying the request signature, is confirmation that you have the right aes device key. Once you strip off the iv and sig, what's left is the protobuf chunk, and that can be parsed correctly using the proto message.decode() function.

  2. Server responses also need to be signed in a similar way, in order for the Sense to handle a response. The process is similar, but the order is different: <random IV (16)> + <AES signature (32)> + <protobuf payload>. See also kitsune parsing
    function signMessage(data, deviceId) {
      const iv = crypto.randomBytes(16);
      const hash = sha1(data, 32); // SHA1 hash of the data (20 bytes), padded to 32 bytes
      var cipher = crypto.createCipheriv("aes-128-cbc", Buffer.from(deviceKey), iv);
      cipher.setAutoPadding(false);
      // NOTE: the signature will be longer than 32, so we have to truncate it so that Sense can parse it
      const sig = Buffer.concat([cipher.update(hash), cipher.final()], 32);
      // Put it all together to send the response:
      return Buffer.concat([iv, sig, data]);
    }

For now I haven't been able to verify the aesKey of my Sense (neither the default key nor the salted device id hash work); but I do have an easy request to play with, as the only request sent by Sense that is being received by this repo's server is the request to time.hello.is, as that is the only request sent over http/80. All other requests are sent over https/443, and I haven't set up a reverse proxy for it yet.

What I've tried so far for verifying the signature only works on my signMessage() responses (2), but does not work on the Sense request (1), which seems to

function verifySignature(iv, sig, data, deviceId) {
    const deviceKey = generateDeviceKey(deviceId);
    var cipher = crypto.createDecipheriv("aes-128-cbc", Buffer.from(deviceKey), iv);
    cipher.setAutoPadding(false);

    // FIXME: cipher.final() fails because the `sig` was truncated
    const decrypted = Buffer.concat([cipher.update(sig), cipher.final()]);
    const hash = sha1(data, 32);
    if (decrypted.toString('hex') != hash.toString('hex')) {
      console.error("Signature mismatch", decrypted, "!=", hash, decrypted.length, hash.length);
      return false;
    }
    return true;
}
owarz commented 1 day ago

The information you provide is very valuable for me, it is a hobby project, in fact, I ran it in my own home now, but then I had a confusion and tried a few different approaches. I will fix and push it as soon as possible. yesterday I had the opportunity to work for a few hours. I made progress https://github.com/mxrch/ProtoDeep With this repo I found called Protodeep, I think I will stand up in a short time.

jhansche commented 11 hours ago

You can also use the cli tool protoc (installed as part of protobuf tools) along with xxd to decode the stream. Here's an example of the time.hello.is request that the Sense sends:

// Doesn't work, because this includes all the bytes, including <proto><iv><sig>
$ echo 080010808080809880b2df4a29f5f22512dc320a00f2e99436a808380dc80de4593a1110407ff7bc7dba9a4672095889ec4d168be6b7c036a26b111a | xxd -r -p | protoc --decode_raw
Failed to parse input.

but when you strip off the trailing 32 + 16 bytes for sig and iv, then the portion you're left with will parse correctly:

$ echo 08001080808080a0d6b9df4a | xxd -r -p | protoc --decode_raw
1: 0
2: 5385995856560259072
// This corresponds to the NTPDataPacket: https://github.com/hello/proto/blob/master/ntp.proto
// but the Sense device only includes the first 2 fields; the server fills in the 3rd and 4th fields in the response.

I'm still stuck trying to get the Sense to contact my server for sensor data though - how did you get around the SSL requirement? From what I can tell it still requires a valid SSL cert. My self-signed cert does not appear to work, as I'm not receiving any response.