diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index f6de2444..7222b63d 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -90,6 +90,15 @@ dependencies {
// Permissions
implementation(libs.accompanist.permissions)
+
+ // QR
+ implementation(libs.zxing.core)
+ implementation(libs.mlkit.barcode.scanning)
+
+ // CameraX
+ implementation(libs.androidx.camera.camera2)
+ implementation(libs.androidx.camera.lifecycle)
+ implementation(libs.androidx.camera.compose)
// Cryptography
implementation(libs.bundles.cryptography)
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index f4ca2b89..14a01098 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -31,6 +31,8 @@
+
+
@@ -47,6 +49,7 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/java/com/bitchat/android/MainActivity.kt b/app/src/main/java/com/bitchat/android/MainActivity.kt
index 717a24ed..a87c4a25 100644
--- a/app/src/main/java/com/bitchat/android/MainActivity.kt
+++ b/app/src/main/java/com/bitchat/android/MainActivity.kt
@@ -40,6 +40,7 @@ import com.bitchat.android.ui.ChatViewModel
import com.bitchat.android.ui.OrientationAwareActivity
import com.bitchat.android.ui.theme.BitchatTheme
import com.bitchat.android.nostr.PoWPreferenceManager
+import com.bitchat.android.services.VerificationService
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@@ -655,6 +656,7 @@ class MainActivity : OrientationAwareActivity() {
// Handle any notification intent
handleNotificationIntent(intent)
+ handleVerificationIntent(intent)
// Small delay to ensure mesh service is fully initialized
delay(500)
@@ -683,6 +685,7 @@ class MainActivity : OrientationAwareActivity() {
// Handle notification intents when app is already running
if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) {
handleNotificationIntent(intent)
+ handleVerificationIntent(intent)
}
}
@@ -788,6 +791,17 @@ class MainActivity : OrientationAwareActivity() {
}
}
+ private fun handleVerificationIntent(intent: Intent) {
+ val uri = intent.data ?: return
+ if (uri.scheme != "bitchat" || uri.host != "verify") return
+
+ chatViewModel.showVerificationSheet()
+ val qr = VerificationService.verifyScannedQR(uri.toString())
+ if (qr != null) {
+ chatViewModel.beginQRVerification(qr)
+ }
+ }
+
override fun onDestroy() {
super.onDestroy()
diff --git a/app/src/main/java/com/bitchat/android/identity/SecureIdentityStateManager.kt b/app/src/main/java/com/bitchat/android/identity/SecureIdentityStateManager.kt
index 2b0b2bdd..012ea3c4 100644
--- a/app/src/main/java/com/bitchat/android/identity/SecureIdentityStateManager.kt
+++ b/app/src/main/java/com/bitchat/android/identity/SecureIdentityStateManager.kt
@@ -5,7 +5,10 @@ import android.content.SharedPreferences
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import java.security.MessageDigest
+import android.util.Base64
import android.util.Log
+import com.bitchat.android.util.hexEncodedString
+import androidx.core.content.edit
/**
* Manages persistent identity storage and peer ID rotation - 100% compatible with iOS implementation
@@ -24,9 +27,15 @@ class SecureIdentityStateManager(private val context: Context) {
private const val KEY_STATIC_PUBLIC_KEY = "static_public_key"
private const val KEY_SIGNING_PRIVATE_KEY = "signing_private_key"
private const val KEY_SIGNING_PUBLIC_KEY = "signing_public_key"
+ private const val KEY_VERIFIED_FINGERPRINTS = "verified_fingerprints"
+ private const val KEY_CACHED_PEER_FINGERPRINTS = "cached_peer_fingerprints"
+ private const val KEY_CACHED_PEER_NOISE_KEYS = "cached_peer_noise_keys"
+ private const val KEY_CACHED_NOISE_FINGERPRINTS = "cached_noise_fingerprints"
+ private const val KEY_CACHED_FINGERPRINT_NICKNAMES = "cached_fingerprint_nicknames"
}
private val prefs: SharedPreferences
+ private val lock = Any()
init {
// Create master key for encryption
@@ -168,7 +177,7 @@ class SecureIdentityStateManager(private val context: Context) {
fun generateFingerprint(publicKeyData: ByteArray): String {
val digest = MessageDigest.getInstance("SHA-256")
val hash = digest.digest(publicKeyData)
- return hash.joinToString("") { "%02x".format(it) }
+ return hash.hexEncodedString()
}
/**
@@ -178,6 +187,112 @@ class SecureIdentityStateManager(private val context: Context) {
// SHA-256 fingerprint should be 64 hex characters
return fingerprint.matches(Regex("^[a-fA-F0-9]{64}$"))
}
+
+ // MARK: - Verified Fingerprints
+
+ fun getVerifiedFingerprints(): Set {
+ return prefs.getStringSet(KEY_VERIFIED_FINGERPRINTS, emptySet())?.toSet() ?: emptySet()
+ }
+
+ fun isVerifiedFingerprint(fingerprint: String): Boolean {
+ return getVerifiedFingerprints().contains(fingerprint)
+ }
+
+ fun setVerifiedFingerprint(fingerprint: String, verified: Boolean) {
+ if (!isValidFingerprint(fingerprint)) return
+ synchronized(lock) {
+ val current = prefs.getStringSet(KEY_VERIFIED_FINGERPRINTS, emptySet())?.toMutableSet() ?: mutableSetOf()
+ if (verified) {
+ current.add(fingerprint)
+ } else {
+ current.remove(fingerprint)
+ }
+ prefs.edit { putStringSet(KEY_VERIFIED_FINGERPRINTS, current) }
+ }
+ }
+
+ fun getCachedPeerFingerprint(peerID: String): String? {
+ val pid = peerID.lowercase()
+ // Reading is safe without lock for SharedPreferences, but synchronizing ensures memory visibility
+ // if we are paranoid, but SharedPreferences is generally thread-safe for reads.
+ // However, to ensure we don't read a partial update (unlikely with SP), we can leave it.
+ // The critical part is the write.
+ val entries = prefs.getStringSet(KEY_CACHED_PEER_FINGERPRINTS, emptySet()) ?: return null
+ val entry = entries.firstOrNull { it.startsWith("$pid:") } ?: return null
+ return entry.substringAfter(':').takeIf { isValidFingerprint(it) }
+ }
+
+ fun cachePeerFingerprint(peerID: String, fingerprint: String) {
+ if (!isValidFingerprint(fingerprint)) return
+ val pid = peerID.lowercase()
+ synchronized(lock) {
+ val current = prefs.getStringSet(KEY_CACHED_PEER_FINGERPRINTS, emptySet())?.toMutableSet() ?: mutableSetOf()
+ current.removeAll { it.startsWith("$pid:") }
+ current.add("$pid:$fingerprint")
+ prefs.edit { putStringSet(KEY_CACHED_PEER_FINGERPRINTS, current) }
+ }
+ }
+
+ fun getCachedNoiseKey(peerID: String): String? {
+ val pid = peerID.lowercase()
+ val entries = prefs.getStringSet(KEY_CACHED_PEER_NOISE_KEYS, emptySet()) ?: return null
+ val entry = entries.firstOrNull { it.startsWith("$pid=") } ?: return null
+ return entry.substringAfter('=').takeIf { it.matches(Regex("^[a-fA-F0-9]{64}$")) }
+ }
+
+ fun cachePeerNoiseKey(peerID: String, noiseKeyHex: String) {
+ if (!noiseKeyHex.matches(Regex("^[a-fA-F0-9]{64}$"))) return
+ val pid = peerID.lowercase()
+ synchronized(lock) {
+ val current = prefs.getStringSet(KEY_CACHED_PEER_NOISE_KEYS, emptySet())?.toMutableSet() ?: mutableSetOf()
+ current.removeAll { it.startsWith("$pid=") }
+ current.add("$pid=${noiseKeyHex.lowercase()}")
+ prefs.edit { putStringSet(KEY_CACHED_PEER_NOISE_KEYS, current) }
+ }
+ }
+
+ fun getCachedNoiseFingerprint(noiseKeyHex: String): String? {
+ val key = noiseKeyHex.lowercase()
+ val entries = prefs.getStringSet(KEY_CACHED_NOISE_FINGERPRINTS, emptySet()) ?: return null
+ val entry = entries.firstOrNull { it.startsWith("$key=") } ?: return null
+ return entry.substringAfter('=').takeIf { isValidFingerprint(it) }
+ }
+
+ fun cacheNoiseFingerprint(noiseKeyHex: String, fingerprint: String) {
+ if (!isValidFingerprint(fingerprint)) return
+ if (!noiseKeyHex.matches(Regex("^[a-fA-F0-9]{64}$"))) return
+ val key = noiseKeyHex.lowercase()
+ synchronized(lock) {
+ val current = prefs.getStringSet(KEY_CACHED_NOISE_FINGERPRINTS, emptySet())?.toMutableSet() ?: mutableSetOf()
+ current.removeAll { it.startsWith("$key=") }
+ current.add("$key=$fingerprint")
+ prefs.edit { putStringSet(KEY_CACHED_NOISE_FINGERPRINTS, current) }
+ }
+ }
+
+ fun getCachedFingerprintNickname(fingerprint: String): String? {
+ if (!isValidFingerprint(fingerprint)) return null
+ val key = fingerprint.lowercase()
+ val entries = prefs.getStringSet(KEY_CACHED_FINGERPRINT_NICKNAMES, emptySet()) ?: return null
+ val entry = entries.firstOrNull { it.startsWith("$key=") } ?: return null
+ val encoded = entry.substringAfter('=')
+ return runCatching {
+ val bytes = Base64.decode(encoded, Base64.NO_WRAP)
+ String(bytes, Charsets.UTF_8)
+ }.getOrNull()
+ }
+
+ fun cacheFingerprintNickname(fingerprint: String, nickname: String) {
+ if (!isValidFingerprint(fingerprint)) return
+ val key = fingerprint.lowercase()
+ val encoded = Base64.encodeToString(nickname.toByteArray(Charsets.UTF_8), Base64.NO_WRAP)
+ synchronized(lock) {
+ val current = prefs.getStringSet(KEY_CACHED_FINGERPRINT_NICKNAMES, emptySet())?.toMutableSet() ?: mutableSetOf()
+ current.removeAll { it.startsWith("$key=") }
+ current.add("$key=$encoded")
+ prefs.edit { putStringSet(KEY_CACHED_FINGERPRINT_NICKNAMES, current) }
+ }
+ }
// MARK: - Peer ID Rotation Management (removed)
// Android now derives peer ID from the persisted Noise identity fingerprint.
diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt
index 3de48407..91f88919 100644
--- a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt
+++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt
@@ -7,12 +7,15 @@ import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.protocol.MessagePadding
import com.bitchat.android.model.RoutedPacket
import com.bitchat.android.model.IdentityAnnouncement
+import com.bitchat.android.model.NoisePayload
+import com.bitchat.android.model.NoisePayloadType
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.protocol.MessageType
import com.bitchat.android.protocol.SpecialRecipients
import com.bitchat.android.model.RequestSyncPacket
import com.bitchat.android.sync.GossipSyncManager
import com.bitchat.android.util.toHexString
+import com.bitchat.android.services.VerificationService
import kotlinx.coroutines.*
import java.util.*
import kotlin.math.sign
@@ -72,6 +75,7 @@ class BluetoothMeshService(private val context: Context) {
init {
Log.i(TAG, "Initializing BluetoothMeshService for peer=$myPeerID")
+ VerificationService.configure(encryptionService)
setupDelegates()
messageHandler.packetProcessor = packetProcessor
//startPeriodicDebugLogging()
@@ -414,6 +418,14 @@ class BluetoothMeshService(private val context: Context) {
override fun onReadReceiptReceived(messageID: String, peerID: String) {
delegate?.didReceiveReadReceipt(messageID, peerID)
}
+
+ override fun onVerifyChallengeReceived(peerID: String, payload: ByteArray, timestampMs: Long) {
+ delegate?.didReceiveVerifyChallenge(peerID, payload, timestampMs)
+ }
+
+ override fun onVerifyResponseReceived(peerID: String, payload: ByteArray, timestampMs: Long) {
+ delegate?.didReceiveVerifyResponse(peerID, payload, timestampMs)
+ }
}
// PacketProcessor delegates
@@ -939,6 +951,50 @@ class BluetoothMeshService(private val context: Context) {
}
}
}
+
+ // MARK: QR Verification over Noise
+
+ fun sendVerifyChallenge(peerID: String, noiseKeyHex: String, nonceA: ByteArray) {
+ val tlv = VerificationService.buildVerifyChallenge(noiseKeyHex, nonceA)
+ val payload = NoisePayload(
+ type = NoisePayloadType.VERIFY_CHALLENGE,
+ data = tlv
+ )
+ sendNoisePayloadToPeer(payload, peerID, "verify challenge")
+ }
+
+ fun sendVerifyResponse(peerID: String, noiseKeyHex: String, nonceA: ByteArray) {
+ val tlv = VerificationService.buildVerifyResponse(noiseKeyHex, nonceA) ?: return
+ val payload = NoisePayload(
+ type = NoisePayloadType.VERIFY_RESPONSE,
+ data = tlv
+ )
+ sendNoisePayloadToPeer(payload, peerID, "verify response")
+ }
+
+ private fun sendNoisePayloadToPeer(payload: NoisePayload, recipientPeerID: String, label: String) {
+ serviceScope.launch {
+ try {
+ val encrypted = encryptionService.encrypt(payload.encode(), recipientPeerID)
+ val packet = BitchatPacket(
+ version = 1u,
+ type = MessageType.NOISE_ENCRYPTED.value,
+ senderID = hexStringToByteArray(myPeerID),
+ recipientID = hexStringToByteArray(recipientPeerID),
+ timestamp = System.currentTimeMillis().toULong(),
+ payload = encrypted,
+ signature = null,
+ ttl = com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS
+ )
+
+ val signedPacket = signPacketBeforeBroadcast(packet)
+ connectionManager.broadcastPacket(RoutedPacket(signedPacket))
+ Log.d(TAG, "📤 Sent $label to $recipientPeerID (${payload.data.size} bytes)")
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to send $label to $recipientPeerID: ${e.message}")
+ }
+ }
+ }
/**
* Send broadcast announce with TLV-encoded identity announcement - exactly like iOS
@@ -1127,6 +1183,10 @@ class BluetoothMeshService(private val context: Context) {
fun getIdentityFingerprint(): String {
return encryptionService.getIdentityFingerprint()
}
+
+ fun getStaticNoisePublicKey(): ByteArray? {
+ return encryptionService.getStaticPublicKey()
+ }
/**
* Check if encryption icon should be shown for a peer
@@ -1283,6 +1343,8 @@ interface BluetoothMeshDelegate {
fun didReceiveChannelLeave(channel: String, fromPeer: String)
fun didReceiveDeliveryAck(messageID: String, recipientPeerID: String)
fun didReceiveReadReceipt(messageID: String, recipientPeerID: String)
+ fun didReceiveVerifyChallenge(peerID: String, payload: ByteArray, timestampMs: Long)
+ fun didReceiveVerifyResponse(peerID: String, payload: ByteArray, timestampMs: Long)
fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String?
fun getNickname(): String?
fun isFavorite(peerID: String): Boolean
diff --git a/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt b/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt
index d016dd37..ce8b0d9e 100644
--- a/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt
+++ b/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt
@@ -157,6 +157,14 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
// Simplified: Call delegate with messageID and peerID directly
delegate?.onReadReceiptReceived(messageID, peerID)
}
+ com.bitchat.android.model.NoisePayloadType.VERIFY_CHALLENGE -> {
+ Log.d(TAG, "🔐 Verify challenge received from $peerID (${noisePayload.data.size} bytes)")
+ delegate?.onVerifyChallengeReceived(peerID, noisePayload.data, packet.timestamp.toLong())
+ }
+ com.bitchat.android.model.NoisePayloadType.VERIFY_RESPONSE -> {
+ Log.d(TAG, "🔐 Verify response received from $peerID (${noisePayload.data.size} bytes)")
+ delegate?.onVerifyResponseReceived(peerID, noisePayload.data, packet.timestamp.toLong())
+ }
}
} catch (e: Exception) {
@@ -611,4 +619,6 @@ interface MessageHandlerDelegate {
fun onChannelLeave(channel: String, fromPeer: String)
fun onDeliveryAckReceived(messageID: String, peerID: String)
fun onReadReceiptReceived(messageID: String, peerID: String)
+ fun onVerifyChallengeReceived(peerID: String, payload: ByteArray, timestampMs: Long)
+ fun onVerifyResponseReceived(peerID: String, payload: ByteArray, timestampMs: Long)
}
diff --git a/app/src/main/java/com/bitchat/android/model/NoiseEncrypted.kt b/app/src/main/java/com/bitchat/android/model/NoiseEncrypted.kt
index 7f691a9c..c46a114f 100644
--- a/app/src/main/java/com/bitchat/android/model/NoiseEncrypted.kt
+++ b/app/src/main/java/com/bitchat/android/model/NoiseEncrypted.kt
@@ -21,6 +21,8 @@ enum class NoisePayloadType(val value: UByte) {
PRIVATE_MESSAGE(0x01u), // Private chat message with TLV encoding
READ_RECEIPT(0x02u), // Message was read
DELIVERED(0x03u), // Message was delivered
+ VERIFY_CHALLENGE(0x10u), // Verification challenge
+ VERIFY_RESPONSE(0x11u), // Verification response
FILE_TRANSFER(0x20u);
diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt b/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt
index 3dcb60d6..18c12e4d 100644
--- a/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt
+++ b/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt
@@ -2,8 +2,12 @@ package com.bitchat.android.nostr
import android.app.Application
import android.util.Log
+import com.bitchat.android.model.BitchatFilePacket
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.DeliveryStatus
+import com.bitchat.android.model.NoisePayload
+import com.bitchat.android.model.NoisePayloadType
+import com.bitchat.android.model.PrivateMessagePacket
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.services.SeenMessageStore
import com.bitchat.android.ui.ChatState
@@ -71,7 +75,7 @@ class NostrDirectMessageHandler(
if (packet.type != com.bitchat.android.protocol.MessageType.NOISE_ENCRYPTED.value) return@launch
- val noisePayload = com.bitchat.android.model.NoisePayload.decode(packet.payload) ?: return@launch
+ val noisePayload = NoisePayload.decode(packet.payload) ?: return@launch
val messageTimestamp = Date(giftWrap.createdAt * 1000L)
val convKey = "nostr_${senderPubkey.take(16)}"
repo.putNostrKeyMapping(convKey, senderPubkey)
@@ -104,7 +108,7 @@ class NostrDirectMessageHandler(
}
private suspend fun processNoisePayload(
- payload: com.bitchat.android.model.NoisePayload,
+ payload: NoisePayload,
convKey: String,
senderNickname: String,
timestamp: Date,
@@ -112,8 +116,8 @@ class NostrDirectMessageHandler(
recipientIdentity: NostrIdentity
) {
when (payload.type) {
- com.bitchat.android.model.NoisePayloadType.PRIVATE_MESSAGE -> {
- val pm = com.bitchat.android.model.PrivateMessagePacket.decode(payload.data) ?: return
+ NoisePayloadType.PRIVATE_MESSAGE -> {
+ val pm = PrivateMessagePacket.decode(payload.data) ?: return
val existingMessages = state.getPrivateChatsValue()[convKey] ?: emptyList()
if (existingMessages.any { it.id == pm.messageID }) return
@@ -148,21 +152,21 @@ class NostrDirectMessageHandler(
seenStore.markRead(pm.messageID)
}
}
- com.bitchat.android.model.NoisePayloadType.DELIVERED -> {
+ NoisePayloadType.DELIVERED -> {
val messageId = String(payload.data, Charsets.UTF_8)
withContext(Dispatchers.Main) {
meshDelegateHandler.didReceiveDeliveryAck(messageId, convKey)
}
}
- com.bitchat.android.model.NoisePayloadType.READ_RECEIPT -> {
+ NoisePayloadType.READ_RECEIPT -> {
val messageId = String(payload.data, Charsets.UTF_8)
withContext(Dispatchers.Main) {
meshDelegateHandler.didReceiveReadReceipt(messageId, convKey)
}
}
- com.bitchat.android.model.NoisePayloadType.FILE_TRANSFER -> {
+ NoisePayloadType.FILE_TRANSFER -> {
// Properly handle encrypted file transfer
- val file = com.bitchat.android.model.BitchatFilePacket.decode(payload.data)
+ val file = BitchatFilePacket.decode(payload.data)
if (file != null) {
val uniqueMsgId = java.util.UUID.randomUUID().toString().uppercase()
val savedPath = com.bitchat.android.features.file.FileUtils.saveIncomingFile(application, file)
@@ -185,6 +189,8 @@ class NostrDirectMessageHandler(
Log.w(TAG, "⚠️ Failed to decode Nostr file transfer from $convKey")
}
}
+ NoisePayloadType.VERIFY_CHALLENGE,
+ NoisePayloadType.VERIFY_RESPONSE -> Unit // Ignore verification payloads in Nostr direct messages
}
}
diff --git a/app/src/main/java/com/bitchat/android/services/VerificationService.kt b/app/src/main/java/com/bitchat/android/services/VerificationService.kt
new file mode 100644
index 00000000..06dcd6f3
--- /dev/null
+++ b/app/src/main/java/com/bitchat/android/services/VerificationService.kt
@@ -0,0 +1,294 @@
+package com.bitchat.android.services
+
+import android.net.Uri
+import android.util.Base64
+import com.bitchat.android.crypto.EncryptionService
+import com.bitchat.android.util.AppConstants
+import com.bitchat.android.util.dataFromHexString
+import com.bitchat.android.util.hexEncodedString
+import java.io.ByteArrayOutputStream
+import java.security.SecureRandom
+import androidx.core.net.toUri
+import java.lang.ref.WeakReference
+
+/**
+ * QR verification helpers: schema, signing, and basic challenge/response helpers.
+ */
+object VerificationService {
+ private const val CONTEXT = "bitchat-verify-v1"
+ private const val RESPONSE_CONTEXT = "bitchat-verify-resp-v1"
+
+ private var encryptionServiceRef: WeakReference? = null
+
+ fun configure(encryptionService: EncryptionService) {
+ this.encryptionServiceRef = WeakReference(encryptionService)
+ }
+
+ data class VerificationQR(
+ val v: Int,
+ val noiseKeyHex: String,
+ val signKeyHex: String,
+ val npub: String?,
+ val nickname: String,
+ val ts: Long,
+ val nonceB64: String,
+ val sigHex: String
+ ) {
+ fun canonicalBytes(): ByteArray {
+ val out = ByteArrayOutputStream()
+
+ fun appendField(value: String) {
+ val data = value.toByteArray(Charsets.UTF_8)
+ val len = minOf(data.size, 255)
+ out.write(len)
+ out.write(data, 0, len)
+ }
+
+ appendField(CONTEXT)
+ appendField(v.toString())
+ appendField(noiseKeyHex.lowercase())
+ appendField(signKeyHex.lowercase())
+ appendField(npub ?: "")
+ appendField(nickname)
+ appendField(ts.toString())
+ appendField(nonceB64)
+ return out.toByteArray()
+ }
+
+ fun toUrlString(): String {
+ val builder = Uri.Builder()
+ .scheme("bitchat")
+ .authority("verify")
+ .appendQueryParameter("v", v.toString())
+ .appendQueryParameter("noise", noiseKeyHex)
+ .appendQueryParameter("sign", signKeyHex)
+ .appendQueryParameter("nick", nickname)
+ .appendQueryParameter("ts", ts.toString())
+ .appendQueryParameter("nonce", nonceB64)
+ .appendQueryParameter("sig", sigHex)
+ if (npub != null) {
+ builder.appendQueryParameter("npub", npub)
+ }
+ return builder.build().toString()
+ }
+
+ companion object {
+ fun fromUrlString(urlString: String): VerificationQR? {
+ val uri = runCatching { urlString.toUri() }.getOrNull() ?: return null
+ if (uri.scheme != "bitchat" || uri.host != "verify") return null
+
+ val vStr = uri.getQueryParameter("v") ?: return null
+ val v = vStr.toIntOrNull() ?: return null
+ val noise = uri.getQueryParameter("noise") ?: return null
+ val sign = uri.getQueryParameter("sign") ?: return null
+ val nick = uri.getQueryParameter("nick") ?: return null
+ val tsStr = uri.getQueryParameter("ts") ?: return null
+ val ts = tsStr.toLongOrNull() ?: return null
+ val nonce = uri.getQueryParameter("nonce") ?: return null
+ val sig = uri.getQueryParameter("sig") ?: return null
+ val npub = uri.getQueryParameter("npub")
+
+ return VerificationQR(
+ v = v,
+ noiseKeyHex = noise,
+ signKeyHex = sign,
+ npub = npub,
+ nickname = nick,
+ ts = ts,
+ nonceB64 = nonce,
+ sigHex = sig
+ )
+ }
+ }
+ }
+
+ fun buildMyQRString(nickname: String, npub: String?): String? {
+ val service = encryptionServiceRef?.get() ?: return null
+ val cache = Cache.last
+ if (cache != null && cache.nickname == nickname && cache.npub == npub) {
+ if (System.currentTimeMillis() - cache.builtAtMs < 60_000L) {
+ return cache.value
+ }
+ }
+
+ val noiseKey = service.getStaticPublicKey()?.hexEncodedString() ?: return null
+ val signKey = service.getSigningPublicKey()?.hexEncodedString() ?: return null
+ val ts = System.currentTimeMillis() / 1000L
+ val nonce = ByteArray(16)
+ SecureRandom().nextBytes(nonce)
+ val nonceB64 = Base64.encodeToString(
+ nonce,
+ Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING
+ )
+
+ val payload = VerificationQR(
+ v = 1,
+ noiseKeyHex = noiseKey,
+ signKeyHex = signKey,
+ npub = npub,
+ nickname = nickname,
+ ts = ts,
+ nonceB64 = nonceB64,
+ sigHex = ""
+ )
+
+ val signature = service.signData(payload.canonicalBytes()) ?: return null
+ val signed = payload.copy(sigHex = signature.hexEncodedString())
+ val out = signed.toUrlString()
+ Cache.last = CacheEntry(nickname, npub, System.currentTimeMillis(), out)
+ return out
+ }
+
+ fun verifyScannedQR(
+ urlString: String,
+ maxAgeSeconds: Long = AppConstants.Verification.QR_MAX_AGE_SECONDS
+ ): VerificationQR? {
+ val service = encryptionServiceRef?.get() ?: return null
+ val qr = VerificationQR.fromUrlString(urlString) ?: return null
+ val now = System.currentTimeMillis() / 1000L
+ if (now - qr.ts > maxAgeSeconds) return null
+
+ val sig = qr.sigHex.dataFromHexString() ?: return null
+ val signKey = qr.signKeyHex.dataFromHexString() ?: return null
+ val ok = service.verifyEd25519Signature(sig, qr.canonicalBytes(), signKey)
+ return if (ok) qr else null
+ }
+
+ fun buildVerifyChallenge(noiseKeyHex: String, nonceA: ByteArray): ByteArray {
+ val noiseData = noiseKeyHex.toByteArray(Charsets.UTF_8)
+ val out = ByteArrayOutputStream()
+ out.write(0x01)
+ out.write(minOf(noiseData.size, 255))
+ out.write(noiseData, 0, minOf(noiseData.size, 255))
+ out.write(0x02)
+ out.write(minOf(nonceA.size, 255))
+ out.write(nonceA, 0, minOf(nonceA.size, 255))
+ return out.toByteArray()
+ }
+
+ fun buildVerifyResponse(noiseKeyHex: String, nonceA: ByteArray): ByteArray? {
+ val service = encryptionServiceRef?.get() ?: return null
+ val noiseData = noiseKeyHex.toByteArray(Charsets.UTF_8)
+ val msg = ByteArrayOutputStream()
+ msg.write(RESPONSE_CONTEXT.toByteArray(Charsets.UTF_8))
+ msg.write(minOf(noiseData.size, 255))
+ msg.write(noiseData, 0, minOf(noiseData.size, 255))
+ msg.write(nonceA)
+ val sig = service.signData(msg.toByteArray()) ?: return null
+
+ val out = ByteArrayOutputStream()
+ out.write(0x01)
+ out.write(minOf(noiseData.size, 255))
+ out.write(noiseData, 0, minOf(noiseData.size, 255))
+ out.write(0x02)
+ out.write(minOf(nonceA.size, 255))
+ out.write(nonceA, 0, minOf(nonceA.size, 255))
+ out.write(0x03)
+ out.write(minOf(sig.size, 255))
+ out.write(sig, 0, minOf(sig.size, 255))
+ return out.toByteArray()
+ }
+
+ fun parseVerifyChallenge(data: ByteArray): Pair? {
+ var idx = 0
+
+ fun take(n: Int): ByteArray? {
+ if (idx + n > data.size) return null
+ val out = data.copyOfRange(idx, idx + n)
+ idx += n
+ return out
+ }
+
+ val t1 = take(1) ?: return null
+ if (t1[0].toInt() != 0x01) return null
+ val l1 = take(1)?.get(0)?.toInt() ?: return null
+ val noiseBytes = take(l1) ?: return null
+ val noise = noiseBytes.toString(Charsets.UTF_8)
+
+ val t2 = take(1) ?: return null
+ if (t2[0].toInt() != 0x02) return null
+ val l2 = take(1)?.get(0)?.toInt() ?: return null
+ val nonce = take(l2) ?: return null
+
+ return noise to nonce
+ }
+
+ data class VerifyResponse(val noiseKeyHex: String, val nonceA: ByteArray, val signature: ByteArray) {
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (javaClass != other?.javaClass) return false
+
+ other as VerifyResponse
+
+ if (noiseKeyHex != other.noiseKeyHex) return false
+ if (!nonceA.contentEquals(other.nonceA)) return false
+ if (!signature.contentEquals(other.signature)) return false
+
+ return true
+ }
+
+ override fun hashCode(): Int {
+ var result = noiseKeyHex.hashCode()
+ result = 31 * result + nonceA.contentHashCode()
+ result = 31 * result + signature.contentHashCode()
+ return result
+ }
+ }
+
+ fun parseVerifyResponse(data: ByteArray): VerifyResponse? {
+ var idx = 0
+
+ fun take(n: Int): ByteArray? {
+ if (idx + n > data.size) return null
+ val out = data.copyOfRange(idx, idx + n)
+ idx += n
+ return out
+ }
+
+ val t1 = take(1) ?: return null
+ if (t1[0].toInt() != 0x01) return null
+ val l1 = take(1)?.get(0)?.toInt() ?: return null
+ val noiseBytes = take(l1) ?: return null
+ val noise = noiseBytes.toString(Charsets.UTF_8)
+
+ val t2 = take(1) ?: return null
+ if (t2[0].toInt() != 0x02) return null
+ val l2 = take(1)?.get(0)?.toInt() ?: return null
+ val nonce = take(l2) ?: return null
+
+ val t3 = take(1) ?: return null
+ if (t3[0].toInt() != 0x03) return null
+ val l3 = take(1)?.get(0)?.toInt() ?: return null
+ val sig = take(l3) ?: return null
+
+ return VerifyResponse(noise, nonce, sig)
+ }
+
+ fun verifyResponseSignature(
+ noiseKeyHex: String,
+ nonceA: ByteArray,
+ signature: ByteArray,
+ signerPublicKeyHex: String
+ ): Boolean {
+ val service = encryptionServiceRef?.get() ?: return false
+ val noiseData = noiseKeyHex.toByteArray(Charsets.UTF_8)
+ val msg = ByteArrayOutputStream()
+ msg.write(RESPONSE_CONTEXT.toByteArray(Charsets.UTF_8))
+ msg.write(minOf(noiseData.size, 255))
+ msg.write(noiseData, 0, minOf(noiseData.size, 255))
+ msg.write(nonceA)
+ val signerKey = signerPublicKeyHex.dataFromHexString() ?: return false
+ return service.verifyEd25519Signature(signature, msg.toByteArray(), signerKey)
+ }
+
+ private data class CacheEntry(
+ val nickname: String,
+ val npub: String?,
+ val builtAtMs: Long,
+ val value: String
+ )
+
+ private object Cache {
+ var last: CacheEntry? = null
+ }
+}
diff --git a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt
index 7c5fa7a0..d931c027 100644
--- a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt
+++ b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt
@@ -9,6 +9,7 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bluetooth
+import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Public
import androidx.compose.material.icons.filled.Warning
@@ -681,6 +682,27 @@ fun AboutSheet(
}
}
+@Composable
+fun CloseButton(
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ IconButton(
+ onClick = onClick,
+ modifier = modifier
+ .size(32.dp),
+ colors = IconButtonDefaults.iconButtonColors(
+ contentColor = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f),
+ containerColor = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.1f)
+ )
+ ) {
+ Icon(
+ imageVector = Icons.Default.Close,
+ contentDescription = "Close",
+ modifier = Modifier.size(18.dp)
+ )
+ }
+}
/**
* Password prompt dialog for password-protected channels
* Kept as dialog since it requires user input
diff --git a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt
index 1c5c3a11..658d6088 100644
--- a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt
+++ b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt
@@ -320,6 +320,10 @@ private fun PrivateChatHeader(
) {
val colorScheme = MaterialTheme.colorScheme
val isNostrDM = peerID.startsWith("nostr_") || peerID.startsWith("nostr:")
+ val verifiedFingerprints by viewModel.verifiedFingerprints.collectAsStateWithLifecycle()
+ val isVerified = remember(peerID, verifiedFingerprints) {
+ viewModel.isPeerVerified(peerID, verifiedFingerprints)
+ }
// Determine mutual favorite state for this peer (supports mesh ephemeral 16-hex via favorites lookup)
val isMutualFavorite = remember(peerID, peerNicknames) {
try {
@@ -417,17 +421,33 @@ private fun PrivateChatHeader(
// Show a globe when chatting via Nostr alias, or when mesh session not established but mutual favorite exists
val showGlobe = isNostrDM || (sessionState != "established" && isMutualFavorite)
+ val securityModifier = if (!isNostrDM) {
+ Modifier.clickable { viewModel.showSecurityVerificationSheet() }
+ } else {
+ Modifier
+ }
+
if (showGlobe) {
Icon(
imageVector = Icons.Outlined.Public,
contentDescription = stringResource(R.string.cd_nostr_reachable),
- modifier = Modifier.size(14.dp),
+ modifier = Modifier.size(14.dp).then(securityModifier),
tint = Color(0xFF9B59B6) // Purple like iOS
)
} else {
NoiseSessionIcon(
sessionState = sessionState,
- modifier = Modifier.size(14.dp)
+ modifier = Modifier.size(14.dp).then(securityModifier)
+ )
+ }
+
+ if (isVerified) {
+ Spacer(modifier = Modifier.width(4.dp))
+ Icon(
+ imageVector = Icons.Filled.Verified,
+ contentDescription = stringResource(R.string.verify_title),
+ modifier = Modifier.size(14.dp).then(securityModifier),
+ tint = Color(0xFF32D74B)
)
}
diff --git a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt
index 3c2e262b..c196ece3 100644
--- a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt
+++ b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt
@@ -58,6 +58,8 @@ fun ChatScreen(viewModel: ChatViewModel) {
val showMentionSuggestions by viewModel.showMentionSuggestions.collectAsStateWithLifecycle()
val mentionSuggestions by viewModel.mentionSuggestions.collectAsStateWithLifecycle()
val showAppInfo by viewModel.showAppInfo.collectAsStateWithLifecycle()
+ val showVerificationSheet by viewModel.showVerificationSheet.collectAsStateWithLifecycle()
+ val showSecurityVerificationSheet by viewModel.showSecurityVerificationSheet.collectAsStateWithLifecycle()
var messageText by remember { mutableStateOf(TextFieldValue("")) }
var showPasswordPrompt by remember { mutableStateOf(false) }
@@ -322,6 +324,10 @@ fun ChatScreen(viewModel: ChatViewModel) {
SidebarOverlay(
viewModel = viewModel,
onDismiss = { viewModel.hideSidebar() },
+ onShowVerification = {
+ viewModel.showVerificationSheet(fromSidebar = true)
+ viewModel.hideSidebar()
+ },
modifier = Modifier.fillMaxSize()
)
}
@@ -368,7 +374,11 @@ fun ChatScreen(viewModel: ChatViewModel) {
},
selectedUserForSheet = selectedUserForSheet,
selectedMessageForSheet = selectedMessageForSheet,
- viewModel = viewModel
+ viewModel = viewModel,
+ showVerificationSheet = showVerificationSheet,
+ onVerificationSheetDismiss = viewModel::hideVerificationSheet,
+ showSecurityVerificationSheet = showSecurityVerificationSheet,
+ onSecurityVerificationSheetDismiss = viewModel::hideSecurityVerificationSheet
)
}
@@ -516,7 +526,11 @@ private fun ChatDialogs(
onUserSheetDismiss: () -> Unit,
selectedUserForSheet: String,
selectedMessageForSheet: BitchatMessage?,
- viewModel: ChatViewModel
+ viewModel: ChatViewModel,
+ showVerificationSheet: Boolean,
+ onVerificationSheetDismiss: () -> Unit,
+ showSecurityVerificationSheet: Boolean,
+ onSecurityVerificationSheetDismiss: () -> Unit
) {
// Password dialog
PasswordPromptDialog(
@@ -570,4 +584,20 @@ private fun ChatDialogs(
viewModel = viewModel
)
}
+
+ if (showVerificationSheet) {
+ VerificationSheet(
+ isPresented = showVerificationSheet,
+ onDismiss = onVerificationSheetDismiss,
+ viewModel = viewModel
+ )
+ }
+
+ if (showSecurityVerificationSheet) {
+ SecurityVerificationSheet(
+ isPresented = showSecurityVerificationSheet,
+ onDismiss = onSecurityVerificationSheetDismiss,
+ viewModel = viewModel
+ )
+ }
}
diff --git a/app/src/main/java/com/bitchat/android/ui/ChatState.kt b/app/src/main/java/com/bitchat/android/ui/ChatState.kt
index 6d0f2364..c4ae3db0 100644
--- a/app/src/main/java/com/bitchat/android/ui/ChatState.kt
+++ b/app/src/main/java/com/bitchat/android/ui/ChatState.kt
@@ -122,6 +122,12 @@ class ChatState(
// Navigation state
private val _showAppInfo = MutableStateFlow(false)
val showAppInfo: StateFlow = _showAppInfo.asStateFlow()
+
+ private val _showVerificationSheet = MutableStateFlow(false)
+ val showVerificationSheet: StateFlow = _showVerificationSheet.asStateFlow()
+
+ private val _showSecurityVerificationSheet = MutableStateFlow(false)
+ val showSecurityVerificationSheet: StateFlow = _showSecurityVerificationSheet.asStateFlow()
// Location channels state (for Nostr geohash features)
private val _selectedLocationChannel = MutableStateFlow(com.bitchat.android.geohash.ChannelID.Mesh)
@@ -302,6 +308,14 @@ class ChatState(
fun setShowAppInfo(show: Boolean) {
_showAppInfo.value = show
}
+
+ fun setShowVerificationSheet(show: Boolean) {
+ _showVerificationSheet.value = show
+ }
+
+ fun setShowSecurityVerificationSheet(show: Boolean) {
+ _showSecurityVerificationSheet.value = show
+ }
fun setSelectedLocationChannel(channel: com.bitchat.android.geohash.ChannelID?) {
_selectedLocationChannel.value = channel
diff --git a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt
index 1196396d..58d5c77f 100644
--- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt
+++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt
@@ -5,11 +5,15 @@ import android.util.Log
import androidx.core.app.NotificationManagerCompat
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
+import com.bitchat.android.favorites.FavoritesPersistenceService
import kotlinx.coroutines.flow.StateFlow
+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.model.BitchatMessage
import com.bitchat.android.model.BitchatMessageType
+import com.bitchat.android.nostr.NostrIdentityBridge
import com.bitchat.android.protocol.BitchatPacket
@@ -19,6 +23,13 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.util.Date
import kotlin.random.Random
+import com.bitchat.android.services.VerificationService
+import com.bitchat.android.identity.SecureIdentityStateManager
+import com.bitchat.android.noise.NoiseSession
+import com.bitchat.android.nostr.GeohashAliasRegistry
+import com.bitchat.android.util.dataFromHexString
+import com.bitchat.android.util.hexEncodedString
+import java.security.MessageDigest
/**
* Refactored ChatViewModel - Main coordinator for bitchat functionality
@@ -46,6 +57,20 @@ class ChatViewModel(
mediaSendingManager.sendImageNote(toPeerIDOrNull, channelOrNull, filePath)
}
+ fun getCurrentNpub(): String? {
+ return try {
+ NostrIdentityBridge
+ .getCurrentNostrIdentity(getApplication())
+ ?.npub
+ } catch (_: Exception) {
+ null
+ }
+ }
+
+ fun buildMyQRString(nickname: String, npub: String?): String {
+ return VerificationService.buildMyQRString(nickname, npub) ?: ""
+ }
+
// MARK: - State management
private val state = ChatState(
scope = viewModelScope,
@@ -57,6 +82,7 @@ class ChatViewModel(
// Specialized managers
private val dataManager = DataManager(application.applicationContext)
+ private val identityManager by lazy { SecureIdentityStateManager(getApplication()) }
private val messageManager = MessageManager(state)
private val channelManager = ChannelManager(state, messageManager, dataManager, viewModelScope)
@@ -75,6 +101,17 @@ class ChatViewModel(
NotificationIntervalManager()
)
+ private val verificationHandler = VerificationHandler(
+ context = application.applicationContext,
+ scope = viewModelScope,
+ meshService = meshService,
+ identityManager = identityManager,
+ state = state,
+ notificationManager = notificationManager,
+ messageManager = messageManager
+ )
+ val verifiedFingerprints = verificationHandler.verifiedFingerprints
+
// Media file sending manager
private val mediaSendingManager = MediaSendingManager(state, messageManager, channelManager, meshService)
@@ -134,6 +171,8 @@ class ChatViewModel(
val peerRSSI: StateFlow