Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ The migration is an upgrade rather than a workaround. Nearby gave us no control

The whole point is one thing working, so test exactly that first.

**Setup.** Install the build on two phones (one iPhone and one Android is the interesting case). Grant the Bluetooth permissions on Android (and Location on Android 11 and below); Bluetooth on iOS. Keep the app in the **foreground** on both — background BLE is an unsolved problem, see the threat model.
**Setup.** Install the build on two phones (one iPhone and one Android is the interesting case). Grant the Bluetooth permissions on Android (and Location on Android 11 and below); Bluetooth on iOS. Keep the app in the **foreground** on both for reliable relaying — background BLE is only best-effort, see the threat model.

**1. Cut the network for real.**

Expand Down
4 changes: 4 additions & 0 deletions app.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@
"NSBonjourServices": [
"_nearby._tcp",
"_nearby._udp"
],
"UIBackgroundModes": [
"bluetooth-central",
"bluetooth-peripheral"
]
},
"bundleIdentifier": "com.anonymous.protestchat"
Expand Down
2 changes: 1 addition & 1 deletion docs/THREAT-MODEL.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ Residuals (see `docs/FORWARD-SECRECY.md`): no Double Ratchet / post-compromise h

What remains unproven: whether stopping and restarting advertising actually prompts each OS to re-randomise the link-layer BLE address. That is the only lever an app has, and it is not a guarantee. **Someone with a BLE sniffer and two phones could settle this in an afternoon, and it would be a genuinely valuable contribution.**
3. **Sybil resistance.** Identities are free, so per-identity rate limits are worthless. One device can present as hundreds and flood the mesh. Candidates: proof-of-work on send, web-of-trust relay budgets. Unsolved.
4. **iOS background operation.** iOS suspends BLE aggressively. A relay that only works with the screen on is a much weaker relay.
4. **Background relaying — partially addressed, limits remain.** iOS and Android both suspend BLE work when the app is not foregrounded, which is the single biggest reason a pocket phone stops relaying. We now declare the iOS `bluetooth-central` and `bluetooth-peripheral` background modes, pass CoreBluetooth state-restoration identifiers, and run an Android foreground service while the radio is on. These are best-effort: iOS still throttles scan and advertise rates, background modes are not a guarantee, and Android OEMs differ widely in how aggressively they kill a foreground service. **Keep the app open for reliable relaying.**
5. **Duress / decoy mode.** Panic wipe exists; a duress PIN opening a plausible decoy does not.
6. **Reproducible builds.**
7. **Channel passphrase strength.** No strength meter, no enforced minimum. At a protest passphrases spread by shouting and will be weak.
Expand Down
10 changes: 7 additions & 3 deletions modules/ble-mesh/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -334,9 +334,13 @@ Both `CBCentralManager` and `CBPeripheralManager` are constructed lazily on the
first `start*` call, because constructing either is what triggers the permission
prompt and an app that has not yet asked to use the radio should not be prompting.

Background operation is **not** configured (no `UIBackgroundModes`). iOS suspends
BLE aggressively and background mesh relaying is open problem #4 in the threat
model; keep the app in the foreground.
Background operation is **best-effort**. iOS has `bluetooth-central` and
`bluetooth-peripheral` `UIBackgroundModes` plus CoreBluetooth state-restoration
identifiers, and Android runs a `connectedDevice` foreground service while the
radio is on. These improve the odds that a pocketed phone continues to relay,
but they are not guarantees: iOS still throttles scan/advertise rates when
backgrounded, and Android OEMs vary widely in how aggressively they kill a
foreground service. Keep the app open for reliable relaying.

## Platform semantics the TS layer must paper over

Expand Down
13 changes: 13 additions & 0 deletions modules/ble-mesh/android/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,20 @@
android:name="android.permission.ACCESS_FINE_LOCATION"
android:maxSdkVersion="30" />

<!-- Foreground service: keeps the mesh process alive while backgrounded. -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<!-- Android 14+ requires a specific foreground-service type for BLE work. -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />

<!-- BLE, and specifically the peripheral role, are both required. -->
<uses-feature android:name="android.hardware.bluetooth_le" android:required="true" />

<application>
<service
android:name=".BleMeshService"
android:enabled="true"
android:exported="false"
android:foregroundServiceType="connectedDevice" />
</application>

</manifest>
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,7 @@ class BleMeshModule : Module() {
private var lastPublishedState: String? = null
private var housekeepingScheduled = false
private var stateReceiver: BroadcastReceiver? = null
private var serviceStarted = false

private val random = SecureRandom()

Expand Down Expand Up @@ -417,6 +418,7 @@ class BleMeshModule : Module() {
scheduleRotation()
scheduleHousekeeping()
startAdvertisingNow()
startForegroundServiceIfNeeded()
promise.resolve(null)
}
}
Expand All @@ -431,6 +433,7 @@ class BleMeshModule : Module() {
if (!ensureAdapter(promise)) return@post
scheduleHousekeeping()
startScanningNow()
startForegroundServiceIfNeeded()
promise.resolve(null)
}
}
Expand Down Expand Up @@ -604,6 +607,34 @@ class BleMeshModule : Module() {
// Radio lifecycle
// -------------------------------------------------------------------------

private fun startForegroundServiceIfNeeded() {
if (serviceStarted) return
val context = appContext.reactContext ?: return
val intent = Intent(context, BleMeshService::class.java)
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(intent)
} else {
@Suppress("DEPRECATION")
context.startService(intent)
}
serviceStarted = true
} catch (e: Throwable) {
// Foreground service is best-effort. If the OS refuses it, the radio still
// works while the app is foregrounded; emit an advisory error so the UI can
// tell the user background relay is not available.
emitError(BleErrorCode.INTERNAL, "Could not start mesh foreground service: ${describe(e)}")
}
}

private fun stopForegroundService() {
if (!serviceStarted) return
val context = appContext.reactContext ?: return
val intent = Intent(context, BleMeshService::class.java)
runCatching { context.stopService(intent) }
serviceStarted = false
}

private fun ensureAdapter(promise: Promise?): Boolean {
if (adapter == null) {
val context = appContext.reactContext
Expand Down Expand Up @@ -1122,6 +1153,7 @@ class BleMeshModule : Module() {

stopAdvertisingNow()
stopScanningNow()
stopForegroundService()

val announced = links.values.mapNotNull { if (it.announced) it.remoteTag else null }.distinct()
for (link in links.values) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
package expo.modules.blemesh

import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.IBinder

/**
* Foreground service that keeps the mesh radio process alive while the app is
* backgrounded.
*
* Android 8+ (API 26) throttles or stops background BLE work unless the app holds
* a foreground service. This service does not itself talk to the radio — the
* BleMeshModule does — it only creates a persistent notification so the OS
* knows the process is doing user-visible work. The module starts it when
* advertising or scanning begins and stops it in teardown().
*
* The notification is deliberately discreet: it does not appear on the lock
* screen and it does not describe what the app is doing, because a visible
* lock-screen tell would defeat the purpose of a privacy-first mesh app. The
* OS still shows the app label in the notification shade (unavoidable), but
* that is only visible to someone who already has the unlocked phone.
*/
class BleMeshService : Service() {

override fun onCreate() {
super.onCreate()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID,
"Bluetooth background use",
NotificationManager.IMPORTANCE_LOW
).apply {
description = "Used while the app is backgrounded."
}
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.createNotificationChannel(channel)
}
}

override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val notification = buildNotification()
startForeground(FOREGROUND_ID, notification)
return START_STICKY
}

private fun buildNotification(): Notification {
val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
Notification.Builder(this, CHANNEL_ID)
} else {
@Suppress("DEPRECATION")
Notification.Builder(this)
}

builder
.setContentTitle("Bluetooth active")
.setContentText("Running in the background.")
.setSmallIcon(android.R.drawable.stat_sys_data_bluetooth)
.setOngoing(true)
.setWhen(System.currentTimeMillis())

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
builder.setCategory(Notification.CATEGORY_SERVICE)
}

// Never show this notification on the lock screen. A persistent, self-
// describing lock-screen tell would be more damaging than the benefit of
// background relaying.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
builder.setVisibility(Notification.VISIBILITY_SECRET)
}

// Tapping the notification returns the user to the app rather than doing nothing.
val launchIntent = packageManager.getLaunchIntentForPackage(packageName)
if (launchIntent != null) {
val pendingIntent = PendingIntent.getActivity(
this,
0,
launchIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
builder.setContentIntent(pendingIntent)
}

return builder.build()
}

override fun onBind(intent: Intent?): IBinder? = null

companion object {
private const val CHANNEL_ID = "protestchat_mesh_relay"
private const val FOREGROUND_ID = 1
}
}
31 changes: 29 additions & 2 deletions modules/ble-mesh/ios/BleMeshModule.swift
Original file line number Diff line number Diff line change
Expand Up @@ -461,14 +461,20 @@ private final class BleMeshRadio: NSObject {
central = CBCentralManager(
delegate: self,
queue: queue,
options: [CBCentralManagerOptionShowPowerAlertKey: true]
options: [
CBCentralManagerOptionShowPowerAlertKey: true,
CBCentralManagerOptionRestoreIdentifierKey: "org.protestchat.central",
]
)
}
if peripheralManager == nil {
peripheralManager = CBPeripheralManager(
delegate: self,
queue: queue,
options: [CBPeripheralManagerOptionShowPowerAlertKey: false]
options: [
CBPeripheralManagerOptionShowPowerAlertKey: false,
CBPeripheralManagerOptionRestoreIdentifierKey: "org.protestchat.peripheral",
]
)
}
}
Expand Down Expand Up @@ -986,6 +992,18 @@ extension BleMeshRadio: CBCentralManagerDelegate {
applyCentralState()
}

func centralManager(_ manager: CBCentralManager, willRestoreState dict: [String: Any]) {
// iOS relaunched the app to handle a BLE event. If we were scanning when the
// OS terminated us, resume scanning so the mesh can discover peers again.
// Reconnections happen through fresh discovery; restoring connected
// peripherals is deliberately left to the next scan cycle to keep this path
// simple and safe.
if !wantScanning {
wantScanning = true
}
applyCentralState()
}

func centralManager(
_ manager: CBCentralManager,
didDiscover peripheral: CBPeripheral,
Expand Down Expand Up @@ -1154,6 +1172,15 @@ extension BleMeshRadio: CBPeripheralManagerDelegate {
applyPeripheralState()
}

func peripheralManager(_ manager: CBPeripheralManager, willRestoreState dict: [String: Any]) {
// iOS relaunched the app to handle a BLE event. If we were advertising when
// the OS terminated us, resume advertising so peers can still find us.
if !wantAdvertising {
wantAdvertising = true
}
applyPeripheralState()
}

func peripheralManagerDidStartAdvertising(
_ manager: CBPeripheralManager,
error: Error?
Expand Down
1 change: 1 addition & 0 deletions src/app/settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export default function SettingsScreen() {
copy('settings.limitationLocation'),
copy('settings.limitationShoulder'),
copy('settings.limitationContact'),
copy('settings.limitationBackground'),
];

const runWipe = async () => {
Expand Down
1 change: 1 addition & 0 deletions src/i18n/bn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ export const bn: Catalog = {
'settings.limitationLocation': 'আপনার অবস্থান শনাক্ত হওয়া। Bluetooth একটি রেডিও; সঠিক যন্ত্র থাকা কেউ বুঝতে পারে এখানে একটি ফোন পাঠাচ্ছে, যদিও বার্তা পড়তে পারে না।',
'settings.limitationShoulder': 'পাশে দাঁড়ানো কেউ আপনার স্ক্রিন পড়া।',
'settings.limitationContact': 'সামনাসামনি যাচাই না-করা যোগাযোগটি অন্য কেউ হওয়া।',
'settings.limitationBackground': 'Background relaying. iOS and Android may pause Bluetooth when the app is not on screen; a phone in a pocket may stop relaying. Keep the app open for reliable meshing.',
'settings.auditWarning': 'এই সফটওয়্যার স্বাধীনভাবে অডিট করা হয়নি। একে উপকারী ভাবুন, নিশ্চয়তা নয়। নিরাপত্তা এর ওপর নির্ভর করলে ধরে নিন দৃঢ়প্রতিজ্ঞ রাষ্ট্রীয় প্রতিপক্ষ তবুও জানতে পারে আপনি সেখানে ছিলেন এবং সম্প্রচার করছিলেন।',
'settings.openSourceA11y': '100 শতাংশ ওপেন সোর্স। GitHub-এ সোর্স কোড খোলে।',
'settings.openSource': '100% ওপেন সোর্স',
Expand Down
3 changes: 2 additions & 1 deletion src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ export const en = {
'settings.name': 'Your name',
'settings.nameHint': 'Stays on this phone. It is never broadcast — nearby devices see only a rotating random code, never this name — and it is shown to you, not to them.',
'settings.meshRadio': 'Mesh radio',
'settings.radioOnDetail': 'Reachable, and relaying sealed messages for people you will never meet.',
'settings.radioOnDetail': 'Reachable while this screen is open. Background relaying is best-effort: iOS and Android may pause Bluetooth when the app is not in the foreground, so keep protestchat open for reliable relay. On Android, the radio shows a persistent notification while the app is backgrounded.',
'settings.radioOffDetail': 'Nothing in, nothing out. You are also not relaying for anyone else.',
'settings.panicWipe': 'Panic wipe',
'settings.panicDetail': 'Erases every message, contact and key on this phone immediately, and gives you a fresh identity. There is no undo and no backup.',
Expand All @@ -198,6 +198,7 @@ export const en = {
'settings.limitationLocation': 'Being physically located. Bluetooth is a radio; anyone with the right equipment can tell that a phone here is transmitting, even though they cannot read it.',
'settings.limitationShoulder': 'Someone standing next to you reading your screen.',
'settings.limitationContact': 'A contact you never verified in person turning out to be someone else.',
'settings.limitationBackground': 'Background relaying. iOS and Android may pause Bluetooth when the app is not on screen; a phone in a pocket may stop relaying. Keep the app open for reliable meshing.',
'settings.auditWarning': 'This software has not been independently audited. Treat it as useful, not as guaranteed. If your safety depends on it, assume a determined state adversary can still learn that you were present and transmitting.',
'settings.openSourceA11y': '100 percent open source. Opens the source code on GitHub.',
'settings.openSource': '100% open source',
Expand Down
1 change: 1 addition & 0 deletions src/i18n/hi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,7 @@ export const hi: Catalog = {
'settings.limitationLocation': 'आपकी भौतिक जगह का पता लगना। Bluetooth एक रेडियो है; सही उपकरण वाला कोई भी जान सकता है कि यहाँ कोई फ़ोन प्रसारण कर रहा है, भले ही वह उसे पढ़ न सके।',
'settings.limitationShoulder': 'आपके पास खड़ा कोई व्यक्ति आपकी स्क्रीन पढ़ रहा हो।',
'settings.limitationContact': 'जिस संपर्क को आपने आमने-सामने सत्यापित नहीं किया, उसका कोई और निकलना।',
'settings.limitationBackground': 'Background relaying. iOS and Android may pause Bluetooth when the app is not on screen; a phone in a pocket may stop relaying. Keep the app open for reliable meshing.',
'settings.auditWarning': 'इस सॉफ़्टवेयर का स्वतंत्र ऑडिट नहीं हुआ है। इसे उपयोगी मानें, गारंटी नहीं। यदि आपकी सुरक्षा इस पर निर्भर है, तो मानें कि एक दृढ़ राज्य विरोधी फिर भी जान सकता है कि आप वहाँ मौजूद थे और प्रसारण कर रहे थे।',
'settings.openSourceA11y': '100 प्रतिशत ओपन सोर्स। GitHub पर सोर्स कोड खोलता है।',
'settings.openSource': '100% ओपन सोर्स',
Expand Down
1 change: 1 addition & 0 deletions src/i18n/mr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ export const mr: Catalog = {
'settings.limitationLocation': 'तुमचे प्रत्यक्ष स्थान शोधले जाणे. Bluetooth हा रेडिओ आहे; योग्य उपकरण असलेला कोणीही येथे फोन प्रसारित करत आहे हे ओळखू शकतो, जरी संदेश वाचता येत नसले तरी.',
'settings.limitationShoulder': 'शेजारी उभी व्यक्ती तुमची स्क्रीन वाचणे.',
'settings.limitationContact': 'प्रत्यक्ष पडताळणी न केलेला संपर्क दुसराच कोणी असणे.',
'settings.limitationBackground': 'Background relaying. iOS and Android may pause Bluetooth when the app is not on screen; a phone in a pocket may stop relaying. Keep the app open for reliable meshing.',
'settings.auditWarning': 'या सॉफ्टवेअरचे स्वतंत्र ऑडिट झालेले नाही. ते उपयोगी समजा, हमी नाही. तुमची सुरक्षितता यावर अवलंबून असल्यास, ठाम सरकारी विरोधकाला तुम्ही तिथे होतात आणि प्रसारण करत होतात हे कळू शकते असे गृहीत धरा.',
'settings.openSourceA11y': '100 टक्के ओपन सोर्स. GitHub वर सोर्स कोड उघडते.',
'settings.openSource': '100% ओपन सोर्स',
Expand Down
Loading
Loading