Skip to content

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

Merged
TheLastProject merged 22 commits into
CatimaLoyalty:wip/wearosfrom
blacklight:feat/wear-bluetooth-security
Aug 1, 2026
Merged

feat(bluetooth): Add trusted device pairing and encrypted sync#3223
TheLastProject merged 22 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 thread app/src/main/java/protect/card_locker/preferences/SettingsActivity.kt Outdated
Comment thread app/src/main/res/values/strings.xml Outdated
Comment thread app/src/main/java/protect/card_locker/wearos/BluetoothServerService.kt Outdated
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)
Comment thread app/src/main/res/values/strings.xml Outdated
Comment thread app/src/main/res/values/strings.xml Outdated
Comment thread app/src/main/java/protect/card_locker/preferences/SettingsActivity.kt Outdated
Comment thread app/src/main/res/values/strings.xml Outdated
Comment thread app/src/main/java/protect/card_locker/preferences/SettingsActivity.kt Outdated
The Wear OS device settings previously mixed trusted and blocked
devices in a single "Paired Wear devices" dialog. Add a separate
"Blocked Wear devices" preference and dialog so each list is shown
and managed independently.

- Remove from the allowed list calls untrustDevice().
- Remove from the blocked list calls unblockDevice().
- Update summaries and dialog refresh logic for both preferences.
- Ensure trustDevice() also removes the address from the blocked
  set so a device cannot be trusted and blocked simultaneously.

Addresses: CatimaLoyalty#3223 (comment)
…ecks

Mirror the existing Bluetooth permission helper pattern by exposing
isPostNotificationsRequired() and making isPostNotificationsGranted()
use it. Update WearSyncServiceManager to use the helper for both
permissions.

Addresses: CatimaLoyalty#3223 (comment)
Replace the AES-256-GCM application-layer encryption with a simpler
shared-secret token scheme.

Bluetooth link-layer encryption already protects the transport, so the
phone-watch AES key exchange was redundant.

The watch now generates and persists a 256-bit token, sends
TOKEN:<token> before each V1 command, and the server authorizes or
denies requests based on the stored token.

New devices and token mismatches surface pairing notifications.

Addresses: CatimaLoyalty#3223 (comment)
…ear OS

Different companion apps should display different notification titles.

Addresses: CatimaLoyalty#3223 (comment)
@TheLastProject TheLastProject mentioned this pull request Jul 30, 2026
10 tasks

@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.

This feels a lot better! I'm quite happy with this. I added a few small notes to #3218 for some things I feel should be fixed but can be done outside of this MR to not scope creep to much.

Just have a few last things I think should be part of this MR :)

Comment thread app/src/main/java/protect/card_locker/wearos/BluetoothServerService.kt Outdated
Comment thread wear/src/main/res/values/strings.xml Outdated
Comment thread app/src/main/java/protect/card_locker/preferences/SettingsActivity.kt Outdated
Comment thread app/src/main/res/values/strings.xml Outdated
@TheLastProject

TheLastProject commented Jul 30, 2026

Copy link
Copy Markdown
Member

Thank you for your patience by the way, I know I can be a bit of a perfectionist at times but I just want to make sure the users will have the best experience possible and that I can have an easy time maintaining things in the future :)

blacklight and others added 6 commits July 31, 2026 14:50
Co-authored-by: Sylvia van Os <sylvia@hackerchick.me>
Make it clear that tapping an entry in the allowed or blocked Wear
device list opens the removal confirmation dialog.

Closes: CatimaLoyalty#3223 (comment)
Convert settings_wear_sync_devices_summary_count and
settings_wear_sync_blocked_devices_summary_count from <string> to
<plurals>, matching the existing groupCardCountWithArchived pattern.

Update SettingsActivity to use resources.getQuantityString() for the
correct singular/plural form.

Closes: CatimaLoyalty#3223 (comment)
Swap the Wear Bluetooth wire format from token-then-command to
command-then-token.

The server now inspects the version prefix on the command before reading
or validating the token, making it possible to select a version-specific
token parser in the future.

Closes: CatimaLoyalty#3223 (comment)
We need a custom dialog layout for Wear OS sync allowed and blocked
device lists, which can support both a text item and a list.

@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.

This seems fine, one last nitpick before I can hit merge :)

Comment thread app/src/main/res/layout/wear_sync_device_list.xml Outdated
Comment thread app/src/main/res/layout/wear_sync_device_list_item.xml Outdated
blacklight and others added 2 commits July 31, 2026 22:44
Co-authored-by: Sylvia van Os <sylvia@hackerchick.me>
Catch IOException during read loop and log normal disconnect

Closes: CatimaLoyalty#3223 (comment)
@TheLastProject

Copy link
Copy Markdown
Member

One last paddingStart/End left 🙈

Co-authored-by: Sylvia van Os <sylvia@hackerchick.me>
@TheLastProject
TheLastProject merged commit ea29865 into CatimaLoyalty:wip/wearos Aug 1, 2026
@TheLastProject

Copy link
Copy Markdown
Member

Thanks! :)

TheLastProject added a commit that referenced this pull request Aug 7, 2026
Co-authored-by: Sylvia van Os <sylvia@hackerchick.me>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants