Add QR verification payloads and mesh wiring

This commit is contained in:
yet300
2025-12-31 18:45:47 +04:00
parent a642b22e0a
commit 0b814be69e
7 changed files with 394 additions and 8 deletions
@@ -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
@@ -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)
}
@@ -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);
@@ -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
}
}
@@ -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<EncryptionService>? = 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<String, ByteArray>? {
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
}
}
@@ -216,6 +216,14 @@ class MeshDelegateHandler(
messageManager.updateMessageDeliveryStatus(messageID, DeliveryStatus.Read(recipientPeerID, Date()))
}
}
override fun didReceiveVerifyChallenge(peerID: String, payload: ByteArray, timestampMs: Long) {
// Handled by ChatViewModel for verification flow
}
override fun didReceiveVerifyResponse(peerID: String, payload: ByteArray, timestampMs: Long) {
// Handled by ChatViewModel for verification flow
}
override fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? {
return channelManager.decryptChannelMessage(encryptedContent, channel)
@@ -58,6 +58,10 @@ object AppConstants {
const val HIGH_NONCE_WARNING_THRESHOLD: Long = 1_000_000_000L
}
object Verification {
const val QR_MAX_AGE_SECONDS: Long = 300L // 5 minutes
}
object Protocol {
const val COMPRESSION_THRESHOLD_BYTES: Int = 100
}