mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-07-25 15:45:22 +00:00
Add QR verification payloads and mesh wiring
This commit is contained in:
@@ -7,12 +7,15 @@ import com.bitchat.android.model.BitchatMessage
|
|||||||
import com.bitchat.android.protocol.MessagePadding
|
import com.bitchat.android.protocol.MessagePadding
|
||||||
import com.bitchat.android.model.RoutedPacket
|
import com.bitchat.android.model.RoutedPacket
|
||||||
import com.bitchat.android.model.IdentityAnnouncement
|
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.BitchatPacket
|
||||||
import com.bitchat.android.protocol.MessageType
|
import com.bitchat.android.protocol.MessageType
|
||||||
import com.bitchat.android.protocol.SpecialRecipients
|
import com.bitchat.android.protocol.SpecialRecipients
|
||||||
import com.bitchat.android.model.RequestSyncPacket
|
import com.bitchat.android.model.RequestSyncPacket
|
||||||
import com.bitchat.android.sync.GossipSyncManager
|
import com.bitchat.android.sync.GossipSyncManager
|
||||||
import com.bitchat.android.util.toHexString
|
import com.bitchat.android.util.toHexString
|
||||||
|
import com.bitchat.android.services.VerificationService
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
import java.util.*
|
import java.util.*
|
||||||
import kotlin.math.sign
|
import kotlin.math.sign
|
||||||
@@ -72,6 +75,7 @@ class BluetoothMeshService(private val context: Context) {
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
Log.i(TAG, "Initializing BluetoothMeshService for peer=$myPeerID")
|
Log.i(TAG, "Initializing BluetoothMeshService for peer=$myPeerID")
|
||||||
|
VerificationService.configure(encryptionService)
|
||||||
setupDelegates()
|
setupDelegates()
|
||||||
messageHandler.packetProcessor = packetProcessor
|
messageHandler.packetProcessor = packetProcessor
|
||||||
//startPeriodicDebugLogging()
|
//startPeriodicDebugLogging()
|
||||||
@@ -414,6 +418,14 @@ class BluetoothMeshService(private val context: Context) {
|
|||||||
override fun onReadReceiptReceived(messageID: String, peerID: String) {
|
override fun onReadReceiptReceived(messageID: String, peerID: String) {
|
||||||
delegate?.didReceiveReadReceipt(messageID, peerID)
|
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
|
// 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
|
* Send broadcast announce with TLV-encoded identity announcement - exactly like iOS
|
||||||
@@ -1127,6 +1183,10 @@ class BluetoothMeshService(private val context: Context) {
|
|||||||
fun getIdentityFingerprint(): String {
|
fun getIdentityFingerprint(): String {
|
||||||
return encryptionService.getIdentityFingerprint()
|
return encryptionService.getIdentityFingerprint()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun getStaticNoisePublicKey(): ByteArray? {
|
||||||
|
return encryptionService.getStaticPublicKey()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if encryption icon should be shown for a peer
|
* Check if encryption icon should be shown for a peer
|
||||||
@@ -1283,6 +1343,8 @@ interface BluetoothMeshDelegate {
|
|||||||
fun didReceiveChannelLeave(channel: String, fromPeer: String)
|
fun didReceiveChannelLeave(channel: String, fromPeer: String)
|
||||||
fun didReceiveDeliveryAck(messageID: String, recipientPeerID: String)
|
fun didReceiveDeliveryAck(messageID: String, recipientPeerID: String)
|
||||||
fun didReceiveReadReceipt(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 decryptChannelMessage(encryptedContent: ByteArray, channel: String): String?
|
||||||
fun getNickname(): String?
|
fun getNickname(): String?
|
||||||
fun isFavorite(peerID: String): Boolean
|
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
|
// Simplified: Call delegate with messageID and peerID directly
|
||||||
delegate?.onReadReceiptReceived(messageID, peerID)
|
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) {
|
} catch (e: Exception) {
|
||||||
@@ -611,4 +619,6 @@ interface MessageHandlerDelegate {
|
|||||||
fun onChannelLeave(channel: String, fromPeer: String)
|
fun onChannelLeave(channel: String, fromPeer: String)
|
||||||
fun onDeliveryAckReceived(messageID: String, peerID: String)
|
fun onDeliveryAckReceived(messageID: String, peerID: String)
|
||||||
fun onReadReceiptReceived(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
|
PRIVATE_MESSAGE(0x01u), // Private chat message with TLV encoding
|
||||||
READ_RECEIPT(0x02u), // Message was read
|
READ_RECEIPT(0x02u), // Message was read
|
||||||
DELIVERED(0x03u), // Message was delivered
|
DELIVERED(0x03u), // Message was delivered
|
||||||
|
VERIFY_CHALLENGE(0x10u), // Verification challenge
|
||||||
|
VERIFY_RESPONSE(0x11u), // Verification response
|
||||||
FILE_TRANSFER(0x20u);
|
FILE_TRANSFER(0x20u);
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,12 @@ package com.bitchat.android.nostr
|
|||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import com.bitchat.android.model.BitchatFilePacket
|
||||||
import com.bitchat.android.model.BitchatMessage
|
import com.bitchat.android.model.BitchatMessage
|
||||||
import com.bitchat.android.model.DeliveryStatus
|
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.protocol.BitchatPacket
|
||||||
import com.bitchat.android.services.SeenMessageStore
|
import com.bitchat.android.services.SeenMessageStore
|
||||||
import com.bitchat.android.ui.ChatState
|
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
|
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 messageTimestamp = Date(giftWrap.createdAt * 1000L)
|
||||||
val convKey = "nostr_${senderPubkey.take(16)}"
|
val convKey = "nostr_${senderPubkey.take(16)}"
|
||||||
repo.putNostrKeyMapping(convKey, senderPubkey)
|
repo.putNostrKeyMapping(convKey, senderPubkey)
|
||||||
@@ -104,7 +108,7 @@ class NostrDirectMessageHandler(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun processNoisePayload(
|
private suspend fun processNoisePayload(
|
||||||
payload: com.bitchat.android.model.NoisePayload,
|
payload: NoisePayload,
|
||||||
convKey: String,
|
convKey: String,
|
||||||
senderNickname: String,
|
senderNickname: String,
|
||||||
timestamp: Date,
|
timestamp: Date,
|
||||||
@@ -112,8 +116,8 @@ class NostrDirectMessageHandler(
|
|||||||
recipientIdentity: NostrIdentity
|
recipientIdentity: NostrIdentity
|
||||||
) {
|
) {
|
||||||
when (payload.type) {
|
when (payload.type) {
|
||||||
com.bitchat.android.model.NoisePayloadType.PRIVATE_MESSAGE -> {
|
NoisePayloadType.PRIVATE_MESSAGE -> {
|
||||||
val pm = com.bitchat.android.model.PrivateMessagePacket.decode(payload.data) ?: return
|
val pm = PrivateMessagePacket.decode(payload.data) ?: return
|
||||||
val existingMessages = state.getPrivateChatsValue()[convKey] ?: emptyList()
|
val existingMessages = state.getPrivateChatsValue()[convKey] ?: emptyList()
|
||||||
if (existingMessages.any { it.id == pm.messageID }) return
|
if (existingMessages.any { it.id == pm.messageID }) return
|
||||||
|
|
||||||
@@ -148,21 +152,21 @@ class NostrDirectMessageHandler(
|
|||||||
seenStore.markRead(pm.messageID)
|
seenStore.markRead(pm.messageID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
com.bitchat.android.model.NoisePayloadType.DELIVERED -> {
|
NoisePayloadType.DELIVERED -> {
|
||||||
val messageId = String(payload.data, Charsets.UTF_8)
|
val messageId = String(payload.data, Charsets.UTF_8)
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
meshDelegateHandler.didReceiveDeliveryAck(messageId, convKey)
|
meshDelegateHandler.didReceiveDeliveryAck(messageId, convKey)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
com.bitchat.android.model.NoisePayloadType.READ_RECEIPT -> {
|
NoisePayloadType.READ_RECEIPT -> {
|
||||||
val messageId = String(payload.data, Charsets.UTF_8)
|
val messageId = String(payload.data, Charsets.UTF_8)
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
meshDelegateHandler.didReceiveReadReceipt(messageId, convKey)
|
meshDelegateHandler.didReceiveReadReceipt(messageId, convKey)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
com.bitchat.android.model.NoisePayloadType.FILE_TRANSFER -> {
|
NoisePayloadType.FILE_TRANSFER -> {
|
||||||
// Properly handle encrypted 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) {
|
if (file != null) {
|
||||||
val uniqueMsgId = java.util.UUID.randomUUID().toString().uppercase()
|
val uniqueMsgId = java.util.UUID.randomUUID().toString().uppercase()
|
||||||
val savedPath = com.bitchat.android.features.file.FileUtils.saveIncomingFile(application, file)
|
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")
|
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()))
|
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? {
|
override fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? {
|
||||||
return channelManager.decryptChannelMessage(encryptedContent, channel)
|
return channelManager.decryptChannelMessage(encryptedContent, channel)
|
||||||
|
|||||||
@@ -58,6 +58,10 @@ object AppConstants {
|
|||||||
const val HIGH_NONCE_WARNING_THRESHOLD: Long = 1_000_000_000L
|
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 {
|
object Protocol {
|
||||||
const val COMPRESSION_THRESHOLD_BYTES: Int = 100
|
const val COMPRESSION_THRESHOLD_BYTES: Int = 100
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user