mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-07-24 21:45:21 +00:00
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user