Platform & Server
BLE Sync & Pairing
Face-to-face handshakes, GATT service attributes, seed derivations, and proximity gates.
Overview
Wiltkey implements in-person, face-to-face pairing over Bluetooth Low Energy (BLE). This guarantees that initial key exchanges cannot be intercepted remotely. The pairing flow enforces a proximity gate (RSSI strength) so that only devices physically touching or extremely close can complete a handshake.
Pairing Handshake Flow
How it Works
-
BLE Advertising & Proximity Check:
The responder advertises a local name with format:
WK:<shortNick>:<shortPubKeyHash>(1-on-1)WKG:<groupName>:<shortPubKeyHash>(Group Invite)
kNearRssi = -85 dBm -
GATT Handshake Channel:
Pairing utilizes a dedicated GATT service and characteristic:
- Service UUID:
4f7091f6-7ccf-f798-f5b2-098ed89c5b2d - Characteristic UUID:
098ed89c-5b2d-4f70-91f6-7ccff798f5b2(properties: Read/Write/Notify)
pairing_requestcontaining their device name, nickname, profile image, public key, and requested buffer size to the characteristic. - Service UUID:
-
Seed Derivation & Confirmation:
The responder displays an unlock confirmation dialogue. If accepted, the responder:
- Sorts the public key strings alphabetically:
[myPubKey, peerPubKey]..sort() - Derives the pairwise seed:
derivedSeed = SHA-256(sorted_keys) - Writes a JSON response mapping back to the characteristic containing its own public key and nickname.
addOrRechargeContactto generate their local pads and provision the secure session. - Sorts the public key strings alphabetically:
-
Group Invite Seed Distribution:
If the pairing type is a group invite, the host generates the response with
pairing_type: "group_invite"and encrypts the group's seed by XORing it with the derived pairwise seed:encryptedSeed = groupSeed XOR SHA-256(derivedSeed)The responder also sends the assigned laneslot_index. The joiner decrypts the seed using the derived seed and registers the group.
Key Files & Symbols
| File Path | Symbol Name | Description |
|---|---|---|
lib/features/proximity/controllers/ble_pairing_manager.dart |
BlePairingManager |
State controller managing Bluetooth scanning, advertising, and GATT handshakes. |
lib/features/proximity/controllers/ble_pairing_manager.dart |
restartScan() |
Tears down the active scan subscription and starts a clean one (plus a re-advertise). Backs the manual "refresh" button in the pairing screen's app bar, replicating the scan reset that leaving and re-entering the Pair tab performs. |
lib/features/proximity/controllers/ble_pairing_manager.dart |
isBluetoothOn / _listenToAdapterState() |
Tracks the live adapter power state via FlutterBluePlus.adapterState. Drives the pair screens' warning banner and auto-restarts discovery + advertising when the radio is toggled back on. |
.../proximity/presentation/widgets/bluetooth_off_banner.dart |
BluetoothOffBanner |
Warning banner shown on all three pair screens (1-on-1 pairing, group invite/host, group search/join) when Bluetooth is off, with a one-tap requestBluetoothOn() (Android system enable prompt). |
lib/features/proximity/controllers/ble_pairing_manager.dart |
respondToPairRequest() |
Builds derived seeds, encrypts group seeds, and writes GATT responses. |
lib/features/proximity/controllers/ble_pairing_manager.dart |
_encryptGroupSeed() |
Encrypts the group seed with the pairwise derived key. |
lib/features/proximity/controllers/ble_pairing_manager.dart |
_decryptGroupSeed() |
Decrypts the group seed with the pairwise derived key. |
Gotchas & Edge Cases
⚠️ SIGNAL HYSTERESIS
To prevent scanning devices from rapidly blinking on and off the interface due to signal fluctuations near the boundary,
To prevent scanning devices from rapidly blinking on and off the interface due to signal fluctuations near the boundary,
BlePairingManager uses smoothed RSSI averages and a grace window of 12 seconds (_inRangeGrace) before evicting a device from the range filter.
⚠️ BLUETOOTH OFF = SILENT STALL
With the adapter off,
With the adapter off,
startScan / advertising never start and the pair/wait screens otherwise just sit there — most visibly when hosting a group, where the host waits to be discovered. The manager subscribes to FlutterBluePlus.adapterState and exposes isBluetoothOn; every pair screen renders a BluetoothOffBanner (with an Android "turn on" prompt) when it's false. On the off→on transition the manager calls restartScan() so discovery + advertising recover in place, without the user leaving the screen.
⚠️ SCAN CAN STALL — MANUAL REFRESH
startScanningFlow() calls FlutterBluePlus.startScan(timeout: 45s), so discovery stops on its own after 45 seconds; sparse or slow advertisers can also leave a peer undiscovered. Leaving and re-entering the Pair tab unmounts PairingScreen (and its manager), tearing down and rebuilding the scan — which is why a tab switch "fixes" a missing device. The refresh button in the pairing screen's app bar exposes the same reset directly via restartScan() (cancel subscription → stopScan → fresh startScanningFlow() → re-advertise), so users don't have to switch tabs.
🛑 STRICT 512-BYTE GATT MTU LIMIT
Bluetooth GATT operations are strictly bound by the maximum negotiated Attribute MTU. In
Current Sizing Metrics (Theoretical & Practical limits):
Bluetooth GATT operations are strictly bound by the maximum negotiated Attribute MTU. In
BlePairingManager, we call device.requestMtu(512) on Android (the highest safe limit for standard attribute reads/writes). If the JSON pairing payload exceeds 512 bytes, the write is truncated or rejected, causing the pairing process to fail silently.
Current Sizing Metrics (Theoretical & Practical limits):
- 1-on-1 Chat Pairing Request: Currently runs at roughly 350 to 400 bytes. It contains the initiator's ED25519 public key (64 hex characters), user ID (64 hex characters), device name, nickname, and a base64-encoded pixel art avatar (typically 100-200 bytes). If the user name is unusually long or if the avatar resolution is increased, it will overflow the 512-byte MTU limit.
- Group Chat Join Response: Currently runs at roughly 380 to 450 bytes. It contains the group ID (64 hex characters), encrypted group seed (64 hex characters), host public key (64 hex characters), group name, lane configurations, and slot indexes. This is extremely tight and lies within 60-130 bytes of the absolute 512-byte limit. Long group names, long host names, or metadata expansion will easily exceed the MTU and cause pairing to fail.