fix(security): Clear in-memory keys during panic mode (#596)

* fix(security): Clear in-memory keys during panic mode #588

* feat: Recreate mesh service after panic clear (#602)

* feat: Recreate mesh service after panic clear

This commit refactors the panic clear process to ensure a new mesh identity is immediately created and applied.

Previously, the `ChatViewModel` would clear sensitive data, but the recreation of the `BluetoothMeshService` was handled externally. This could lead to a delay or failure in adopting the new identity.

Key changes:
- Introduces `MeshServiceHolder` to manage the lifecycle of the `BluetoothMeshService` instance.
- Adds `recreateMeshServiceAfterPanic()` to `ChatViewModel`, which now explicitly clears the old service instance and creates a new one with a regenerated identity.
- The `meshService` property in `ChatViewModel` is now a `var` to allow it to be replaced with the fresh instance post-panic.
- The new service is started, and a broadcast announcement is sent immediately, ensuring the new peer ID is used on the network.

* fix: Ensure mesh service is properly managed in foreground service

* refactor: Decouple handlers from direct service reference

This commit updates the `VerificationHandler` and `MediaSendingManager` to receive the `meshService` via a lambda function (`getMeshService`) instead of a direct reference.

This change decouples the handlers from the service instance, preventing them from holding a stale reference if the service reconnects or changes. By invoking the lambda to get the current service instance when needed, it ensures they always interact with the active `meshService`.

* fix: restart bluetooth

---------

Co-authored-by: Moe Hamade <69801237+moehamade@users.noreply.github.com>
This commit is contained in:
callebtc
2026-01-15 15:11:10 +07:00
committed by GitHub
co-authored by Moe Hamade
parent 9dfebd73db
commit ae67fa4344
9 changed files with 183 additions and 31 deletions
@@ -145,6 +145,12 @@ open class EncryptionService(private val context: Context) {
val prefs = context.getSharedPreferences("bitchat_crypto", Context.MODE_PRIVATE)
prefs.edit().remove(ED25519_PRIVATE_KEY_PREF).apply()
Log.d(TAG, "🗑️ Cleared Ed25519 signing keys from preferences")
// Generate new keys immediately
val keyPair = loadOrCreateEd25519KeyPair()
ed25519PrivateKey = keyPair.private as Ed25519PrivateKeyParameters
ed25519PublicKey = keyPair.public as Ed25519PublicKeyParameters
Log.d(TAG, "✅ Rotated Ed25519 signing keys in memory")
} catch (e: Exception) {
Log.e(TAG, "❌ Failed to clear Ed25519 keys: ${e.message}")
}
@@ -1375,6 +1375,9 @@ class BluetoothMeshService(private val context: Context) {
fun clearAllInternalData() {
Log.w(TAG, "🚨 Clearing all mesh service internal data")
try {
// Stop services to cease broadcasting old ID immediately
stopServices()
// Clear all managers
fragmentManager.clearAllFragments()
storeForwardManager.clearAllCache()
@@ -29,15 +29,15 @@ class NoiseEncryptionService(private val context: Context) {
}
// Static identity key (persistent across app restarts) - loaded from secure storage
private val staticIdentityPrivateKey: ByteArray
private val staticIdentityPublicKey: ByteArray
private var staticIdentityPrivateKey: ByteArray
private var staticIdentityPublicKey: ByteArray
// Ed25519 signing key (persistent across app restarts) - loaded from secure storage
private val signingPrivateKey: ByteArray
private val signingPublicKey: ByteArray
private var signingPrivateKey: ByteArray
private var signingPublicKey: ByteArray
// Session management
private val sessionManager: NoiseSessionManager
private lateinit var sessionManager: NoiseSessionManager
// Channel encryption for password-protected channels
private val channelEncryption = NoiseChannelEncryption()
@@ -56,6 +56,32 @@ class NoiseEncryptionService(private val context: Context) {
// Initialize identity state manager for persistent storage
identityStateManager = SecureIdentityStateManager(context)
// Load or create keys - temporary placeholders
staticIdentityPrivateKey = ByteArray(32)
staticIdentityPublicKey = ByteArray(32)
signingPrivateKey = ByteArray(32)
signingPublicKey = ByteArray(32)
loadOrGenerateKeys()
// Initialize session manager
initializeSessionManager()
}
private fun initializeSessionManager() {
// Create new session manager with current keys
sessionManager = NoiseSessionManager(staticIdentityPrivateKey, staticIdentityPublicKey)
// Set up session callbacks
sessionManager.onSessionEstablished = { peerID, remoteStaticKey ->
handleSessionEstablished(peerID, remoteStaticKey)
}
// Ensure any other callbacks are wired if needed
// sessionManager.onSessionFailed could be wired if we exposed it
}
private fun loadOrGenerateKeys() {
// Load or create static identity key (persistent across sessions)
val loadedKeyPair = identityStateManager.loadStaticKey()
if (loadedKeyPair != null) {
@@ -89,16 +115,8 @@ class NoiseEncryptionService(private val context: Context) {
identityStateManager.saveSigningKey(signingPrivateKey, signingPublicKey)
Log.d(TAG, "Generated and saved new Ed25519 signing key")
}
// Initialize session manager
sessionManager = NoiseSessionManager(staticIdentityPrivateKey, staticIdentityPublicKey)
// Set up session callbacks
sessionManager.onSessionEstablished = { peerID, remoteStaticKey ->
handleSessionEstablished(peerID, remoteStaticKey)
}
}
// MARK: - Public Interface
/**
@@ -135,7 +153,23 @@ class NoiseEncryptionService(private val context: Context) {
* Clear persistent identity (for panic mode)
*/
fun clearPersistentIdentity() {
Log.w(TAG, "🚨 Panic Mode: Clearing persistent identity and rotating in-memory keys")
// 1. Clear storage
identityStateManager.clearIdentityData()
// 2. Clear all sessions immediately
if (::sessionManager.isInitialized) {
sessionManager.shutdown()
}
// 3. Regenerate keys immediately (in-memory rotation)
loadOrGenerateKeys()
// 4. Re-initialize SessionManager with new keys
initializeSessionManager()
Log.d(TAG, "✅ Identity cleared and keys rotated")
}
// MARK: - Handshake Management
@@ -478,7 +512,9 @@ class NoiseEncryptionService(private val context: Context) {
* Clean shutdown
*/
fun shutdown() {
sessionManager.shutdown()
if (::sessionManager.isInitialized) {
sessionManager.shutdown()
}
channelEncryption.clear()
// No need to clear fingerprints here - they are managed centrally
}
@@ -10,6 +10,7 @@ import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import com.bitchat.android.MainActivity
@@ -112,7 +113,8 @@ class MeshForegroundService : Service() {
private lateinit var notificationManager: NotificationManagerCompat
private var updateJob: Job? = null
private var meshService: BluetoothMeshService? = null
private val meshService: BluetoothMeshService?
get() = MeshServiceHolder.meshService
private val serviceJob = Job()
private val scope = CoroutineScope(Dispatchers.Default + serviceJob)
private var isInForeground: Boolean = false
@@ -123,15 +125,15 @@ class MeshForegroundService : Service() {
notificationManager = NotificationManagerCompat.from(this)
createChannel()
// Adopt or create the mesh service
// Ensure mesh service exists in holder (create if needed)
val existing = MeshServiceHolder.meshService
meshService = existing ?: MeshServiceHolder.getOrCreate(applicationContext)
if (existing != null) {
android.util.Log.d("MeshForegroundService", "Adopted existing BluetoothMeshService from holder")
Log.d("MeshForegroundService", "Using existing BluetoothMeshService from holder")
} else {
android.util.Log.i("MeshForegroundService", "Created/adopted new BluetoothMeshService via holder")
val created = MeshServiceHolder.getOrCreate(applicationContext)
Log.i("MeshForegroundService", "Created new BluetoothMeshService via holder")
MeshServiceHolder.attach(created)
}
MeshServiceHolder.attach(meshService!!)
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
@@ -227,7 +229,8 @@ class MeshForegroundService : Service() {
if (!hasBluetoothPermissions()) return
try {
android.util.Log.d("MeshForegroundService", "Ensuring mesh service is started")
meshService?.startServices()
val service = MeshServiceHolder.getOrCreate(applicationContext)
service.startServices()
} catch (e: Exception) {
android.util.Log.e("MeshForegroundService", "Failed to start mesh service: ${e.message}")
}
@@ -50,6 +50,12 @@ class SeenMessageStore private constructor(private val context: Context) {
persist()
}
@Synchronized fun clear() {
delivered.clear()
read.clear()
persist()
}
private fun trim(set: LinkedHashSet<String>) {
if (set.size <= MAX_IDS) return
val it = set.iterator()
@@ -11,6 +11,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import com.bitchat.android.mesh.BluetoothMeshDelegate
import com.bitchat.android.mesh.BluetoothMeshService
import com.bitchat.android.service.MeshServiceHolder
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.BitchatMessageType
import com.bitchat.android.nostr.NostrIdentityBridge
@@ -37,8 +38,12 @@ import java.security.MessageDigest
*/
class ChatViewModel(
application: Application,
val meshService: BluetoothMeshService
initialMeshService: BluetoothMeshService
) : AndroidViewModel(application), BluetoothMeshDelegate {
// Made var to support mesh service replacement after panic clear
var meshService: BluetoothMeshService = initialMeshService
private set
private val debugManager by lazy { try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance() } catch (e: Exception) { null } }
companion object {
@@ -104,7 +109,7 @@ class ChatViewModel(
private val verificationHandler = VerificationHandler(
context = application.applicationContext,
scope = viewModelScope,
meshService = meshService,
getMeshService = { meshService },
identityManager = identityManager,
state = state,
notificationManager = notificationManager,
@@ -113,7 +118,7 @@ class ChatViewModel(
val verifiedFingerprints = verificationHandler.verifiedFingerprints
// Media file sending manager
private val mediaSendingManager = MediaSendingManager(state, messageManager, channelManager, meshService)
private val mediaSendingManager = MediaSendingManager(state, messageManager, channelManager) { meshService }
// Delegate handler for mesh callbacks
private val meshDelegateHandler = MeshDelegateHandler(
@@ -908,6 +913,11 @@ class ChatViewModel(
privateChatManager.clearAllPrivateChats()
dataManager.clearAllData()
// Clear seen message store
try {
com.bitchat.android.services.SeenMessageStore.getInstance(getApplication()).clear()
} catch (_: Exception) { }
// Clear all mesh service data
clearAllMeshServiceData()
@@ -935,10 +945,37 @@ class ChatViewModel(
state.setNickname(newNickname)
dataManager.saveNickname(newNickname)
Log.w(TAG, "🚨 PANIC MODE COMPLETED - All sensitive data cleared")
// Note: Mesh service restart is now handled by MainActivity
// This method now only clears data, not mesh service lifecycle
// Recreate mesh service with fresh identity
recreateMeshServiceAfterPanic()
Log.w(TAG, "🚨 PANIC MODE COMPLETED - New identity: ${meshService.myPeerID}")
}
/**
* Recreate the mesh service with a fresh identity after panic clear.
* This ensures the new cryptographic keys are used for a new peer ID.
*/
private fun recreateMeshServiceAfterPanic() {
val oldPeerID = meshService.myPeerID
// Clear the holder so getOrCreate() returns a fresh instance
MeshServiceHolder.clear()
// Create fresh mesh service with new identity (keys were regenerated in clearAllCryptographicData)
val freshMeshService = MeshServiceHolder.getOrCreate(getApplication())
// Replace our reference and set up the new service
meshService = freshMeshService
meshService.delegate = this
// Restart mesh operations with new identity
meshService.startServices()
meshService.sendBroadcastAnnounce()
Log.d(
TAG,
"✅ Mesh service recreated. Old peerID: $oldPeerID, New peerID: ${meshService.myPeerID}"
)
}
/**
@@ -16,8 +16,11 @@ class MediaSendingManager(
private val state: ChatState,
private val messageManager: MessageManager,
private val channelManager: ChannelManager,
private val meshService: BluetoothMeshService
private val getMeshService: () -> BluetoothMeshService
) {
// Helper to get current mesh service (may change after panic clear)
private val meshService: BluetoothMeshService
get() = getMeshService()
companion object {
private const val TAG = "MediaSendingManager"
private const val MAX_FILE_SIZE = com.bitchat.android.util.AppConstants.Media.MAX_FILE_SIZE_BYTES // 50MB limit
@@ -26,12 +26,15 @@ import java.util.concurrent.ConcurrentHashMap
class VerificationHandler(
private val context: Context,
private val scope: CoroutineScope,
private val meshService: BluetoothMeshService,
private val getMeshService: () -> BluetoothMeshService,
private val identityManager: SecureIdentityStateManager,
private val state: ChatState,
private val notificationManager: NotificationManager,
private val messageManager: MessageManager
) {
// Helper to get current mesh service (may change after panic clear)
private val meshService: BluetoothMeshService
get() = getMeshService()
private val _verifiedFingerprints = MutableStateFlow<Set<String>>(emptySet())
val verifiedFingerprints: StateFlow<Set<String>> = _verifiedFingerprints.asStateFlow()
@@ -0,0 +1,55 @@
package com.bitchat.android.crypto
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import com.bitchat.android.noise.NoiseEncryptionService
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNotNull
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import java.util.Arrays
@RunWith(RobolectricTestRunner::class)
class EncryptionServiceTest {
private lateinit var context: Context
private lateinit var encryptionService: EncryptionService
@Before
fun setup() {
context = ApplicationProvider.getApplicationContext()
encryptionService = EncryptionService(context)
}
@Test
fun `test clearPersistentIdentity changes keys`() {
// 1. Get initial keys
val initialStaticKey = encryptionService.getStaticPublicKey()
val initialSigningKey = encryptionService.getSigningPublicKey()
val initialFingerprint = encryptionService.getIdentityFingerprint()
assertNotNull("Initial static key should not be null", initialStaticKey)
assertNotNull("Initial signing key should not be null", initialSigningKey)
// 2. Call clearPersistentIdentity (Panic Mode)
encryptionService.clearPersistentIdentity()
// 3. Get keys again.
val afterStaticKey = encryptionService.getStaticPublicKey()
val afterSigningKey = encryptionService.getSigningPublicKey()
val afterFingerprint = encryptionService.getIdentityFingerprint()
// 4. Verify keys are different (Panic Mode should clear/rotate in-memory keys)
// Note: We use string comparison for byte arrays to be safe in assertion messages
assertNotEquals("Static key should change after panic",
Arrays.toString(initialStaticKey), Arrays.toString(afterStaticKey))
assertNotEquals("Signing key should change after panic",
Arrays.toString(initialSigningKey), Arrays.toString(afterSigningKey))
assertNotEquals("Fingerprint should change after panic",
initialFingerprint, afterFingerprint)
}
}