Skip to content

feat(bluetooth): Add trusted device pairing and encrypted sync#3223

Open
blacklight wants to merge 6 commits into
CatimaLoyalty:wip/wearosfrom
blacklight:feat/wear-bluetooth-security
Open

feat(bluetooth): Add trusted device pairing and encrypted sync#3223
blacklight wants to merge 6 commits into
CatimaLoyalty:wip/wearosfrom
blacklight:feat/wear-bluetooth-security

Conversation

@blacklight

Copy link
Copy Markdown

Bluetooth Wear Sync Security Implementation

Current State

  • protect.card_locker.wearos.BluetoothServerService is a Classic Bluetooth RFCOMM server (not BLE). It advertises the UUID e5b4f020-3a7e-4b6d-9f2c-1a8c5d3e7f90 with the name CatimaWear.
  • The server accepts any incoming RFCOMM connection and immediately returns unarchived loyalty card data (store, cardId, barcodeId, barcodeType, headerColor) as plaintext JSON.
  • The Wear OS client (me.hackerchick.catima.wear.BluetoothCardClient) scans the phone's bonded devices and tries to connect to each one using the same UUID.
  • The RFCOMM sockets are created with the secure APIs on both sides (listenUsingRfcommWithServiceRecord / createRfcommSocketToServiceRecord), which means the Bluetooth link is encrypted and authenticated by the OS pairing/link key.
  • There is no app-level authorization: any device that succeeds in pairing/bonding with the phone can connect and scrape card data. Because the UUID and protocol are public, a malicious app on a paired device is the primary risk.

Threat Model

  • An attacker needs to be within Bluetooth range and either:
    • already be OS-paired with the phone, or
    • trick the user into accepting an OS pairing dialog.
  • Once a connection is accepted, the attacker can enumerate all card pages (/V1/CARDS_REQUEST_PAGE/<n>) until the full set is exfiltrated.
  • Catima currently provides no UI to review or revoke which bonded devices are allowed to sync.

Proposed Mitigations

1. App-level pairing confirmation

  • Maintain a Catima-specific trusted-device list keyed by the Bluetooth MAC address and stored in private app storage.
  • When a device connects for the first time (or is no longer trusted), the server will not return card data. It will reply with AUTH_REQUIRED and show a notification/activity on the phone:
    • "Allow <device name> to access your cards?"
    • Actions: Allow / Block
  • Only after the user taps Allow is the device added to the trusted list and a per-device key generated.
  • A blocked device remains bonded at the OS level but is ignored by Catima.

2. Paired devices list in Settings

  • Add a "Paired Wear devices" entry under the Wear OS settings category.
  • It shows the list of trusted/blocked devices with names and addresses.
  • Each entry offers a Remove action that:
    • removes the device from Catima's trusted/blocked lists,
    • deletes the per-device encryption key,
    • cancels any active pairing notification for that device,
    • leaves the OS-level Bluetooth bond intact so Wear OS pairing is not affected.

3. Application-layer encryption

  • Bump the sync protocol to version 2.
  • After authorization, the phone generates a random 256-bit AES-GCM key, associates it with the trusted MAC address, and sends it to the watch during the /V2/AUTH handshake. The key exchange happens over the already authenticated and encrypted RFCOMM link.
  • All subsequent card data is encrypted with AES-256-GCM:
    • a fresh random 12-byte nonce for every message,
    • line-based framing: <base64(nonce)>:<base64(ciphertext+tag)>\n.
  • The watch stores the key in its private app storage. If the watch loses the key, the user can unpair and re-pair to re-exchange it.

Limitations and Caveats

  • The application-layer key is sent over the Bluetooth RFCOMM link. Its confidentiality therefore relies on the OS's Bluetooth link encryption and authentication. It does not protect against a malicious Bluetooth stack or a device that successfully impersonates a bonded peer.
  • Removing a device from the Catima list does not remove the OS Bluetooth bond. If the user wants to fully unpair the watch at the OS level, they must do so in system Bluetooth settings.
  • MAC addresses can be randomized or spoofed on some hardware. For a higher assurance pairing, a future iteration could use a pairing code, Bluetooth LE Companion Device Manager, or device attestation.
  • The UUID is intended as an identifier, not a secret. Security should not rely on UUID secrecy; it should rely on pairing, per-device authorization, and encryption.

- Pairing now required before a watch can sync loyalty cards over
  Bluetooth.
- Adds encryption, trusted/blocked device management, and user-facing
  notifications for allow/block decisions.

@TheLastProject TheLastProject left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I haven't tested this code yet, just looking through it. I'm definitely struggling at some parts. I understand encrypting adds complexity, but the general communication flow has become hard to understand so I do think we'll probably have to change this a bit.

Perhaps a flow more like this would be easier to maintain?
Each onResume:

  1. Watch calls /VERSIONS (always cleartext) to see which API version is supported
  2. Watch call /AUTH with a public key, if the MAC + pubkey combination is known by the Android app, return OK with a pubkey the watch should use. Otherwise return UNAUTHORIZED (on Unauthorized, show an alert on the watch)
  3. If OK, call the card sync, etc.

So, basically, the old flow with just 1 additional step. The decryption is just hooked up in the regular flow so the regular loop doesn't have to encrypt/decrypt, it just sees the decrypted data.

But maybe I'm missing something :)

Comment thread shared/src/main/java/protect/card_locker/shared/WearBluetoothProtocol.kt Outdated
Comment on lines +291 to +302
val adapter = context.getSystemService(BLUETOOTH_SERVICE) as? BluetoothManager
val bondedDevices = try {
adapter?.adapter?.bondedDevices ?: emptySet()
} catch (_: SecurityException) {
emptySet<BluetoothDevice>()
}
val deviceMap = bondedDevices.associateBy { it.address }

val entries = knownAddresses.map { address ->
val name = deviceName(deviceMap[address], address)
getString(R.string.settings_wear_sync_device_name, name, address)
}.toTypedArray()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just so I get this right: you're using a local list of trusted devices, but you only store the mac address, and you get the device name from the Bluetooth adapter? Or am I misunderstanding?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The display name is fetched from BluetoothAdapter.getBondedDevices() at runtime, and falls back to the raw MAC address if the device isn't bonded or the name can't be read. The stored "trusted device" data itself is just the MAC + key.

Comment thread app/src/main/res/values/strings.xml Outdated
Comment on lines +23 to +29
fun isPostNotificationsGranted(context: Context): Boolean =
Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
ContextCompat.checkSelfPermission(
context,
Manifest.permission.POST_NOTIFICATIONS
) == PackageManager.PERMISSION_GRANTED

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It feels kinda inconsistent to me that for Bluetooth this is split into 2 functions (required + granted), but for notifications it's not.

Comment on lines +52 to +61
val completed = AtomicBoolean(false)
val handler = Handler(Looper.getMainLooper())
val timeoutRunnable = Runnable {
if (completed.compareAndSet(false, true)) {
Log.w(TAG, "Bluetooth card sync timed out")
onResult(null, SyncStatus.PHONE_NOT_REACHABLE)
}
}
handler.postDelayed(timeoutRunnable, 20000)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's this for?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The 20 seconds timeout caps the entire Bluetooth card sync operation. If the background sync doesn't finish within 20 seconds, the Runnable fires PHONE_NOT_REACHABLE.

} finally {
handler.removeCallbacks(timeoutRunnable)
if (completed.compareAndSet(false, true)) {
onResult(result, status)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also don't really understand this change here

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This thread runs the actual Bluetooth work. If it finishes first, it removes the timeout and calls onResult with the real result.

The timeoutRunnable is the 20 s safety net. If the thread is stuck connecting or waiting on I/O, the runnable fires and reports PHONE_NOT_REACHABLE.

Comment on lines +156 to +161
WearBluetoothProtocol.BT_CMD_AUTH,
WearBluetoothProtocol.BT_CMD_AUTH_RESET -> {
// Re-exchange the key on every auth so the watch can never use a stale key
// after the phone has reset/trusted it again.
handleAuthenticatedSession(reader, writer, address, deviceName)
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm a bit confused what's happening here.

Is WearBluetoothProtocol.BT_CMD_AUTH sent in cleartext and then all the sync commands are sent in non-cleartext? I find that the flow has gotten quite hard to understand (and maintain/extend) here.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a common pattern in simple symmetric key encryption. The key is exchanged in cleartext and then all the following communication will use that key. See #3223 (comment) for my comment on the current encryption protocol.

No official release has been done yet -> no need to bump the protocol
version.

Addresses: CatimaLoyalty#3223 (comment)
The Bluetooth pairing authorization notification was using
`NotificationCompat.CATEGORY_CALL`, which is semantically meant for
incoming phone/video calls.

`CATEGORY_STATUS` is a better fit for a device connection request since
no call is involved - even though it's still not the semantically
perferct choice.
…IBLE

The watch cannot know which side of a protocol mismatch is older, so
PHONE_OUTDATED and WATCH_OUTDATED are replaced by a neutral
VERSION_INCOMPATIBLE status.

The UI now tells the user to update both apps, and the watch stops
retrying sync when versions are incompatible.

Addresses: CatimaLoyalty#3223 (comment)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants