mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-07-25 03:45:21 +00:00
* 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>
372 lines
16 KiB
Kotlin
372 lines
16 KiB
Kotlin
package com.bitchat.android.ui
|
|
|
|
import android.content.Context
|
|
import com.bitchat.android.R
|
|
import com.bitchat.android.favorites.FavoritesPersistenceService
|
|
import com.bitchat.android.identity.SecureIdentityStateManager
|
|
import com.bitchat.android.mesh.BluetoothMeshService
|
|
import com.bitchat.android.model.BitchatMessage
|
|
import com.bitchat.android.noise.NoiseSession
|
|
import com.bitchat.android.nostr.GeohashAliasRegistry
|
|
import com.bitchat.android.services.VerificationService
|
|
import com.bitchat.android.util.dataFromHexString
|
|
import com.bitchat.android.util.hexEncodedString
|
|
import kotlinx.coroutines.CoroutineScope
|
|
import kotlinx.coroutines.flow.MutableStateFlow
|
|
import kotlinx.coroutines.flow.StateFlow
|
|
import kotlinx.coroutines.flow.asStateFlow
|
|
import kotlinx.coroutines.launch
|
|
import java.security.MessageDigest
|
|
import java.util.Date
|
|
import java.util.concurrent.ConcurrentHashMap
|
|
|
|
/**
|
|
* Handles QR verification logic and state, extracted from ChatViewModel.
|
|
*/
|
|
class VerificationHandler(
|
|
private val context: Context,
|
|
private val scope: CoroutineScope,
|
|
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()
|
|
|
|
private val pendingQRVerifications = ConcurrentHashMap<String, PendingVerification>()
|
|
private val lastVerifyNonceByPeer = ConcurrentHashMap<String, ByteArray>()
|
|
private val lastInboundVerifyChallengeAt = ConcurrentHashMap<String, Long>()
|
|
private val lastMutualToastAt = ConcurrentHashMap<String, Long>()
|
|
|
|
fun loadVerifiedFingerprints() {
|
|
_verifiedFingerprints.value = identityManager.getVerifiedFingerprints()
|
|
}
|
|
|
|
fun isPeerVerified(peerID: String): Boolean {
|
|
if (peerID.startsWith("nostr_") || peerID.startsWith("nostr:")) return false
|
|
val fingerprint = getPeerFingerprintForDisplay(peerID)
|
|
return fingerprint != null && _verifiedFingerprints.value.contains(fingerprint)
|
|
}
|
|
|
|
fun isNoisePublicKeyVerified(noisePublicKey: ByteArray): Boolean {
|
|
val fingerprint = fingerprintFromNoiseBytes(noisePublicKey)
|
|
return _verifiedFingerprints.value.contains(fingerprint)
|
|
}
|
|
|
|
fun unverifyFingerprint(peerID: String) {
|
|
val fingerprint = meshService.getPeerFingerprint(peerID) ?: return
|
|
identityManager.setVerifiedFingerprint(fingerprint, false)
|
|
val current = _verifiedFingerprints.value.toMutableSet()
|
|
current.remove(fingerprint)
|
|
_verifiedFingerprints.value = current
|
|
}
|
|
|
|
fun beginQRVerification(qr: VerificationService.VerificationQR): Boolean {
|
|
val targetNoise = qr.noiseKeyHex.lowercase()
|
|
val peerID = state.getConnectedPeersValue().firstOrNull { pid ->
|
|
val noiseKeyHex = meshService.getPeerInfo(pid)?.noisePublicKey?.hexEncodedString()?.lowercase()
|
|
noiseKeyHex == targetNoise
|
|
} ?: return false
|
|
|
|
if (pendingQRVerifications.containsKey(peerID)) return true
|
|
val nonce = ByteArray(16)
|
|
java.security.SecureRandom().nextBytes(nonce)
|
|
val pending = PendingVerification(qr.noiseKeyHex, qr.signKeyHex, nonce, System.currentTimeMillis(), false)
|
|
pendingQRVerifications[peerID] = pending
|
|
|
|
if (meshService.getSessionState(peerID) is NoiseSession.NoiseSessionState.Established) {
|
|
meshService.sendVerifyChallenge(peerID, qr.noiseKeyHex, nonce)
|
|
pendingQRVerifications[peerID] = pending.copy(sent = true)
|
|
} else {
|
|
meshService.initiateNoiseHandshake(peerID)
|
|
}
|
|
fingerprintFromNoiseHex(qr.noiseKeyHex)?.let { fp ->
|
|
identityManager.cacheFingerprintNickname(fp, qr.nickname)
|
|
identityManager.cacheNoiseFingerprint(qr.noiseKeyHex, fp)
|
|
identityManager.cachePeerNoiseKey(peerID, qr.noiseKeyHex)
|
|
}
|
|
return true
|
|
}
|
|
|
|
fun sendPendingVerificationIfNeeded(peerID: String) {
|
|
val pending = pendingQRVerifications[peerID] ?: return
|
|
if (pending.sent) return
|
|
meshService.sendVerifyChallenge(peerID, pending.noiseKeyHex, pending.nonceA)
|
|
pendingQRVerifications[peerID] = pending.copy(sent = true)
|
|
}
|
|
|
|
fun didReceiveVerifyChallenge(peerID: String, payload: ByteArray) {
|
|
scope.launch {
|
|
val parsed = VerificationService.parseVerifyChallenge(payload) ?: return@launch
|
|
val myNoiseHex = meshService.getStaticNoisePublicKey()?.hexEncodedString()?.lowercase() ?: return@launch
|
|
if (parsed.first.lowercase() != myNoiseHex) return@launch
|
|
|
|
val lastNonce = lastVerifyNonceByPeer[peerID]
|
|
if (lastNonce != null && lastNonce.contentEquals(parsed.second)) return@launch
|
|
lastVerifyNonceByPeer[peerID] = parsed.second
|
|
|
|
val fp = meshService.getPeerFingerprint(peerID)
|
|
if (fp != null) {
|
|
lastInboundVerifyChallengeAt[fp] = System.currentTimeMillis()
|
|
if (_verifiedFingerprints.value.contains(fp)) {
|
|
val lastToast = lastMutualToastAt[fp] ?: 0L
|
|
if (System.currentTimeMillis() - lastToast > 60_000L) {
|
|
lastMutualToastAt[fp] = System.currentTimeMillis()
|
|
val name = resolvePeerDisplayName(peerID)
|
|
val body = context.getString(R.string.verify_mutual_match_body, name)
|
|
addVerificationSystemMessage(peerID, context.getString(R.string.verify_mutual_system_message, name))
|
|
sendVerificationNotification(context.getString(R.string.verify_mutual_match_title), body, peerID)
|
|
}
|
|
}
|
|
}
|
|
|
|
meshService.sendVerifyResponse(peerID, parsed.first, parsed.second)
|
|
}
|
|
}
|
|
|
|
fun didReceiveVerifyResponse(peerID: String, payload: ByteArray) {
|
|
scope.launch {
|
|
val resp = VerificationService.parseVerifyResponse(payload) ?: return@launch
|
|
val pending = pendingQRVerifications[peerID] ?: return@launch
|
|
if (!resp.noiseKeyHex.equals(pending.noiseKeyHex, ignoreCase = true)) return@launch
|
|
if (!resp.nonceA.contentEquals(pending.nonceA)) return@launch
|
|
|
|
val ok = VerificationService.verifyResponseSignature(
|
|
noiseKeyHex = resp.noiseKeyHex,
|
|
nonceA = resp.nonceA,
|
|
signature = resp.signature,
|
|
signerPublicKeyHex = pending.signKeyHex
|
|
)
|
|
if (!ok) return@launch
|
|
|
|
pendingQRVerifications.remove(peerID)
|
|
val fp = meshService.getPeerFingerprint(peerID) ?: return@launch
|
|
identityManager.setVerifiedFingerprint(fp, true)
|
|
val current = _verifiedFingerprints.value.toMutableSet()
|
|
current.add(fp)
|
|
_verifiedFingerprints.value = current
|
|
|
|
val name = resolvePeerDisplayName(peerID)
|
|
identityManager.cacheFingerprintNickname(fp, name)
|
|
val noiseKeyHex = try {
|
|
meshService.getPeerInfo(peerID)?.noisePublicKey?.hexEncodedString()
|
|
} catch (_: Exception) {
|
|
null
|
|
}
|
|
if (noiseKeyHex != null) {
|
|
identityManager.cachePeerNoiseKey(peerID, noiseKeyHex)
|
|
identityManager.cacheNoiseFingerprint(noiseKeyHex, fp)
|
|
}
|
|
addVerificationSystemMessage(peerID, context.getString(R.string.verify_success_system_message, name))
|
|
sendVerificationNotification(context.getString(R.string.verify_success_title), context.getString(R.string.verify_success_body, name), peerID)
|
|
|
|
val lastChallenge = lastInboundVerifyChallengeAt[fp] ?: 0L
|
|
if (System.currentTimeMillis() - lastChallenge < 600_000L) {
|
|
val lastToast = lastMutualToastAt[fp] ?: 0L
|
|
if (System.currentTimeMillis() - lastToast > 60_000L) {
|
|
lastMutualToastAt[fp] = System.currentTimeMillis()
|
|
val body = context.getString(R.string.verify_mutual_match_body, name)
|
|
addVerificationSystemMessage(peerID, context.getString(R.string.verify_mutual_system_message, name))
|
|
sendVerificationNotification(context.getString(R.string.verify_mutual_match_title), body, peerID)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fun getPeerFingerprintForDisplay(peerID: String): String? {
|
|
val fromMap = state.getPeerFingerprintsValue()[peerID]
|
|
if (fromMap != null) return fromMap
|
|
val hexRegex = Regex("^[0-9a-fA-F]+$")
|
|
return try {
|
|
when {
|
|
peerID.length == 64 && peerID.matches(hexRegex) -> {
|
|
identityManager.getCachedNoiseFingerprint(peerID)?.let { return it }
|
|
fingerprintFromNoiseHex(peerID)?.also { identityManager.cacheNoiseFingerprint(peerID, it) }
|
|
}
|
|
peerID.length == 16 && peerID.matches(hexRegex) -> {
|
|
val meshFp = meshService.getPeerFingerprint(peerID)
|
|
if (meshFp != null) return meshFp
|
|
identityManager.getCachedPeerFingerprint(peerID)?.let { return it }
|
|
identityManager.getCachedNoiseKey(peerID)?.let { noiseHex ->
|
|
identityManager.getCachedNoiseFingerprint(noiseHex)?.let { return it }
|
|
return fingerprintFromNoiseHex(noiseHex)?.also { identityManager.cacheNoiseFingerprint(noiseHex, it) }
|
|
}
|
|
val favorite = try {
|
|
FavoritesPersistenceService.shared.getFavoriteStatus(peerID)
|
|
} catch (_: Exception) {
|
|
null
|
|
}
|
|
favorite?.peerNoisePublicKey?.let { fingerprintFromNoiseBytes(it) }
|
|
}
|
|
peerID.startsWith("nostr_") -> {
|
|
val pubHex = GeohashAliasRegistry.get(peerID)
|
|
val noiseKey = pubHex?.let {
|
|
FavoritesPersistenceService.shared.findNoiseKey(it)
|
|
}
|
|
noiseKey?.let {
|
|
val noiseHex = it.hexEncodedString()
|
|
identityManager.getCachedNoiseFingerprint(noiseHex) ?: fingerprintFromNoiseBytes(it)
|
|
}
|
|
}
|
|
peerID.startsWith("nostr:") -> {
|
|
val prefix = peerID.removePrefix("nostr:").lowercase()
|
|
val pubHex = GeohashAliasRegistry
|
|
.snapshot()
|
|
.values
|
|
.firstOrNull { it.lowercase().startsWith(prefix) }
|
|
val noiseKey = pubHex?.let {
|
|
FavoritesPersistenceService.shared.findNoiseKey(it)
|
|
}
|
|
noiseKey?.let {
|
|
val noiseHex = it.hexEncodedString()
|
|
identityManager.getCachedNoiseFingerprint(noiseHex) ?: fingerprintFromNoiseBytes(it)
|
|
}
|
|
}
|
|
else -> {
|
|
val meshFp = meshService.getPeerFingerprint(peerID)
|
|
if (meshFp != null) return meshFp
|
|
identityManager.getCachedPeerFingerprint(peerID)?.let { return it }
|
|
identityManager.getCachedNoiseKey(peerID)?.let { noiseHex ->
|
|
identityManager.getCachedNoiseFingerprint(noiseHex)?.let { return it }
|
|
return fingerprintFromNoiseHex(noiseHex)?.also { identityManager.cacheNoiseFingerprint(noiseHex, it) }
|
|
}
|
|
val favorite = try {
|
|
FavoritesPersistenceService.shared.getFavoriteStatus(peerID)
|
|
} catch (_: Exception) {
|
|
null
|
|
}
|
|
favorite?.peerNoisePublicKey?.let { fingerprintFromNoiseBytes(it) }
|
|
}
|
|
}
|
|
} catch (_: Exception) {
|
|
null
|
|
}
|
|
}
|
|
|
|
fun resolvePeerDisplayNameForFingerprint(peerID: String): String {
|
|
val nicknameMap = state.peerNicknames.value
|
|
nicknameMap[peerID]?.let { return it }
|
|
try {
|
|
meshService.getPeerInfo(peerID)?.nickname?.let { return it }
|
|
} catch (_: Exception) { }
|
|
|
|
val fingerprint = getPeerFingerprintForDisplay(peerID)
|
|
fingerprint?.let { fp ->
|
|
identityManager.getCachedFingerprintNickname(fp)?.let { cached ->
|
|
if (cached.isNotBlank()) return cached
|
|
}
|
|
}
|
|
|
|
val hexRegex = Regex("^[0-9a-fA-F]+$")
|
|
if (peerID.length == 64 && peerID.matches(hexRegex)) {
|
|
val noiseKeyBytes = try {
|
|
peerID.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
|
|
} catch (_: Exception) { null }
|
|
val favorite = noiseKeyBytes?.let {
|
|
FavoritesPersistenceService.shared.getFavoriteStatus(it)
|
|
}
|
|
favorite?.peerNickname?.takeIf { it.isNotBlank() }?.let { return it }
|
|
}
|
|
|
|
if (peerID.length == 16 && peerID.matches(hexRegex)) {
|
|
val favorite = try {
|
|
FavoritesPersistenceService.shared.getFavoriteStatus(peerID)
|
|
} catch (_: Exception) {
|
|
null
|
|
}
|
|
favorite?.peerNickname?.takeIf { it.isNotBlank() }?.let { return it }
|
|
}
|
|
|
|
return peerID.take(8)
|
|
}
|
|
|
|
fun getMyFingerprint(): String {
|
|
return meshService.getIdentityFingerprint()
|
|
}
|
|
|
|
fun verifyFingerprintValue(fingerprint: String) {
|
|
if (fingerprint.isBlank()) return
|
|
identityManager.setVerifiedFingerprint(fingerprint, true)
|
|
val current = _verifiedFingerprints.value.toMutableSet()
|
|
current.add(fingerprint)
|
|
_verifiedFingerprints.value = current
|
|
}
|
|
|
|
fun unverifyFingerprintValue(fingerprint: String) {
|
|
if (fingerprint.isBlank()) return
|
|
identityManager.setVerifiedFingerprint(fingerprint, false)
|
|
val current = _verifiedFingerprints.value.toMutableSet()
|
|
current.remove(fingerprint)
|
|
_verifiedFingerprints.value = current
|
|
}
|
|
|
|
private fun addVerificationSystemMessage(peerID: String, text: String) {
|
|
val msg = BitchatMessage(
|
|
sender = "system",
|
|
content = text,
|
|
timestamp = Date(),
|
|
isRelay = false,
|
|
isPrivate = true,
|
|
senderPeerID = peerID
|
|
)
|
|
messageManager.addPrivateMessageNoUnread(peerID, msg)
|
|
}
|
|
|
|
private fun resolvePeerDisplayName(peerID: String): String {
|
|
val nick = try { meshService.getPeerInfo(peerID)?.nickname } catch (_: Exception) { null }
|
|
return nick ?: peerID.take(8)
|
|
}
|
|
|
|
private fun sendVerificationNotification(title: String, body: String, peerID: String) {
|
|
notificationManager.showVerificationNotification(title, body, peerID)
|
|
}
|
|
|
|
private fun fingerprintFromNoiseHex(noiseHex: String): String? {
|
|
val bytes = noiseHex.dataFromHexString() ?: return null
|
|
return fingerprintFromNoiseBytes(bytes)
|
|
}
|
|
|
|
fun fingerprintFromNoiseBytes(bytes: ByteArray): String {
|
|
val hash = MessageDigest.getInstance("SHA-256").digest(bytes)
|
|
return hash.hexEncodedString()
|
|
}
|
|
|
|
private data class PendingVerification(
|
|
val noiseKeyHex: String,
|
|
val signKeyHex: String,
|
|
val nonceA: ByteArray,
|
|
val startedAtMs: Long,
|
|
val sent: Boolean
|
|
) {
|
|
override fun equals(other: Any?): Boolean {
|
|
if (this === other) return true
|
|
if (javaClass != other?.javaClass) return false
|
|
|
|
other as PendingVerification
|
|
|
|
if (startedAtMs != other.startedAtMs) return false
|
|
if (sent != other.sent) return false
|
|
if (noiseKeyHex != other.noiseKeyHex) return false
|
|
if (signKeyHex != other.signKeyHex) return false
|
|
if (!nonceA.contentEquals(other.nonceA)) return false
|
|
|
|
return true
|
|
}
|
|
|
|
override fun hashCode(): Int {
|
|
var result = startedAtMs.hashCode()
|
|
result = 31 * result + sent.hashCode()
|
|
result = 31 * result + noiseKeyHex.hashCode()
|
|
result = 31 * result + signKeyHex.hashCode()
|
|
result = 31 * result + nonceA.contentHashCode()
|
|
return result
|
|
}
|
|
}
|
|
}
|