Skip to main content

Proximity proof

Overview

info

The following instructions only help satisfy Kisi's reader restriction, and implicitly also Kisi's geofence restriction. Tap to unlock will not work unless you fully integrate Kisi's mobile SDKs.

To build a secure, proximity-based unlock flow on top of Kisi, any mobile app needs to:

  1. Detect a nearby Kisi iBeacon.
  2. Parse the beacon's advertisement (UUID, Major & Minor).
  3. Extract the lock's unique identifier (lock ID).
  4. Generate a time-based one-time password (TOTP) for that lock.
  5. Send a proximity proof (the TOTP) to Kisi's unlock API.

Kisi readers rotate among five UUIDs to improve detection and pack both the lock ID and TOTP into the 32-bit Major+Minor fields.

iBeacon format

Each Kisi reader advertises as an iBeacon with:

  • UUID (128 bits)
  • Major (16 bits)
  • Minor (16 bits)

The app needs to subscribe to Kisi's UUIDs using the mobile operation system's APIs, then read Major+Minor when in range.

UUID

Each reader cycles through five fixed UUIDs. Your scanner should listen for all of them:

UUID
6dfe2064-5186-11ea-8dfe-4b73218bf946
6f9e62a8-5186-11ea-8ed0-4314341d01dd
71fb9eb2-5186-11ea-b1f4-bb928fefb7fd
73f789ec-5186-11ea-9973-87896ce82dd8
74689eac-5186-11ea-be2d-27ec120c3b0f

Major + Minor

To transmit lock identification and a one-time password (OTP) via a standard iBeacon payload, we encode both values into the major and minor fields, which are each 16 bits (total: 32 bits).

Since this space is limited, we truncate both the lock ID and OTP as follows:

  • Lock ID (22 bits)
    • The Lock ID is encoded into the upper 22 bits of the combined 32-bit value.
    • This limits the addressable lock ID space to: 2²² = 4,194,304 unique IDs
    • This is sufficient for scalability up to ~4 million locks.
  • OTP (10 bits)
    • The OTP is encoded into the lower 10 bits.
    • This results in a maximum range of: 2¹⁰ = 1024 → OTPs from 000 to 1023

Unlike more widely adopted 6-digit TOTPs, this yields a 3-digit OTP, which trades off entropy for compatibility with the iBeacon packet format.

The order in the 32 bit value is as follows:

  • Lock ID (bits 0…21): unsigned, MSB
  • TOTP (bits 22…31): unsigned, MSB

Bit diagram:

  Major (16 bits)         Minor (16 bits)
┌────────────────────┬──────────────────────────┐
| xxxx xxxx xxxx xxxx yyyy yyyy yy|yy yyyy yyyy |
└────────────────────┴──────────────────────────┘
↑ ↑ ↑ ↑
└────────┬─────────────────────┘ └───┬──────┘
│ │
Lock ID ─┘ TOTP ─┘

Extracting the TOTP and lock ID

After detecting a beacon:

fun handleBeacon(major: Int, minor: Int) {
val combined = (major.toLong() shl 16) or (minor.toLong() and 0xFFFF)
val lockID = (combined shr 10).toInt() // bits 10…31
val otp = (combined and 0x3FF).toInt() // bits 0…9
println("🔒 Lock ID: $lockID")
println("🔑 TOTP: $otp")
}

Sending the proximity proof

After extracting lockID and otp from the beacon, send them to the unlock endpoint. For example, using dummy iBeacon values:

// Dummy iBeacon values (scanned from Android BLE API)
val beaconMajor = 0x1234
val beaconMinor = 0x03E8

// Combine and extract lockID and TOTP
val combined = (beaconMajor.toLong() shl 16) or (beaconMinor.toLong() and 0xFFFF)
val lockID = (combined shr 10).toInt()
val otp = (combined and 0x3FF).toInt()

val otpString = String.format("%03d", otp) // zero-pad to 3 digits

val json = JSONObject().apply {
put("lock", JSONObject().apply {
put("proximity_proof", otpString)
})
}
val body = json.toString().toRequestBody("application/json".toMediaType())

val request = Request.Builder()
.url("https://api.kisi.io/locks/$lockID/unlock")
.addHeader("Authorization", "Bearer $accessToken")
.post(body)
.build()

OkHttpClient().newCall(request).enqueue(object : Callback {
override fun onResponse(call: Call, response: Response) { /*…*/ }
override fun onFailure(call: Call, e: IOException) { /*…*/ }
})