mobile/native

Reverse-Engineering a BLE Lock: Building the v9 Unlock Protocol

The feature reads as one line on a spec: “tap to open the door.” Underneath it is a versioned BLE protocol that had reached v9, a challenge/response handshake with real cryptography, and two native platforms that disagree about almost everything except the radio. Here is what it took to make the tap feel instant and stay honest.

The handshake, not the button

A SmartLock never trusts a bare “open” command — anyone with a BLE sniffer could replay it. Instead the lock advertises, the phone connects, and the lock hands back a challenge on a dedicated characteristic. The phone has to prove it holds the shared secret by returning a correctly constructed, signed payload before the bolt moves.

So the client’s job isn’t “send unlock.” It’s: discover the right service and challenge characteristic, read the challenge, build a payload bound to that challenge and the lock’s company ID, encrypt it, sign it, and write it back within the window before the lock drops the connection.

Constructing the v9 payload

v9 tightened the format: the payload is AES-encrypted and then HMAC-signed, keyed to the challenge the lock just issued plus a company identifier. The signature is what makes a captured packet useless a second time — the challenge changes every session, so a replayed payload fails verification on the lock.

The exact byte layout is the lock vendor’s, but the shape is the part worth remembering — encrypt for confidentiality, sign for authenticity, and bind both to a per-session challenge so nothing replays.

// Illustrative shape of the v9 active-unlock payload (no real keys/UUIDs).
function buildUnlockPayload(challenge: Uint8Array, ctx: LockCtx) {
  const body = encodePayload({
    companyId: ctx.companyId,
    challenge,               // bound to THIS session
    action: Action.Unlock,
    nonce: randomBytes(12),
  });

  const ciphertext = aesEncrypt(body, ctx.aesKey);      // confidentiality
  const signature  = hmacSign(ciphertext, ctx.hmacKey); // authenticity
  return concat(ciphertext, signature);
}

Two radios, two native modules

React Native gets you the UI, but background BLE scanning and a tight write-after-read handshake belong in native code. On Android that meant a SmartLockServicePackage exposing the scan/unlock lifecycle to JS; on iOS, a Swift SmartLockWalkingManager owning the CoreBluetooth central, connection state, and the same payload construction.

Keeping the crypto and the state machine on the native side — rather than marshalling half-finished handshakes across the bridge — is what kept unlock latency low and the connection window from timing out. JS asks for “unlock this door”; the native module does the dance.

Walking mode and the battery budget

“Nearby Doors” means the app has to know which locks are in range before you decide to open one. That’s a continuous scan, and a naive continuous scan is a battery fire. The answer was a scoped walking mode: scanning ramps up when the app has reason to believe you’re near managed doors and stops when you’re not, driven from native so it survives the JS lifecycle.

The widget then only ever shows reachable locks, so the unlock tap is a foregone conclusion instead of a hopeful request.

Web unlock: same protocol, different trigger

Operators also needed to open a door from the web console. Rather than fork the logic, the web path reuses the same action model — the hard part was that the action types coming back from the backend’s RoomInfo weren’t consistent across rooms, so the button sometimes didn’t know what it was allowed to do.

Normalizing that action-type format server-side made web unlock behave identically to the app: one door model, two triggers, one protocol underneath.

What I’d tell the next person

Put the protocol state machine and the crypto in native code — the bridge is not your friend inside a timing-sensitive handshake. Bind every payload to a per-session challenge and you get replay resistance almost for free. And treat “nearby” as a battery decision first and a UX decision second; the widget is only as good as the scan strategy behind it.

← All postsNext: One Notification, Eight Apps, Zero Duplicates