mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-07-25 02:45:20 +00:00
Migrate private media without silent downgrades
This commit is contained in:
@@ -50,6 +50,23 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
val myPeerID: String = encryptionService.getIdentityFingerprint().take(16)
|
||||
private val peerManager = PeerManager()
|
||||
private val fragmentManager = FragmentManager()
|
||||
private val privateMediaSecurity = PrivateMediaSecurityController(
|
||||
peerInfoProvider = peerManager::getPeerInfo,
|
||||
authenticatedRemoteStaticProvider = encryptionService::getAuthenticatedRemoteStaticKey,
|
||||
pinStore = SecurePrivateMediaCapabilityPinStore(context)
|
||||
)
|
||||
private val privateMediaPreparer by lazy {
|
||||
PrivateMediaTransferPreparer(
|
||||
senderID = hexStringToByteArray(myPeerID),
|
||||
ttl = MAX_TTL,
|
||||
policyProvider = privateMediaSecurity::sendPolicy,
|
||||
encrypt = { plaintext, peerID ->
|
||||
runCatching { encryptionService.encrypt(plaintext, peerID) }.getOrNull()
|
||||
},
|
||||
finalizeRoutedAndSigned = ::routeAndSignPrivateMediaStrict,
|
||||
fragment = fragmentManager::createFragments
|
||||
)
|
||||
}
|
||||
private val securityManager = SecurityManager(encryptionService, myPeerID)
|
||||
private val storeForwardManager = StoreForwardManager()
|
||||
private val messageHandler = MessageHandler(myPeerID, context.applicationContext)
|
||||
@@ -218,6 +235,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
// SecurityManager delegate for key exchange notifications
|
||||
securityManager.delegate = object : SecurityManagerDelegate {
|
||||
override fun onKeyExchangeCompleted(peerID: String, peerPublicKeyData: ByteArray) {
|
||||
privateMediaSecurity.refreshAuthenticatedCapability(peerID)
|
||||
// Send announcement and cached messages after key exchange
|
||||
serviceScope.launch {
|
||||
Log.d(TAG, "Key exchange completed with $peerID; sending follow-ups")
|
||||
@@ -297,8 +315,19 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
return peerManager.getPeerInfo(peerID)
|
||||
}
|
||||
|
||||
override fun updatePeerInfo(peerID: String, nickname: String, noisePublicKey: ByteArray, signingPublicKey: ByteArray, isVerified: Boolean): Boolean {
|
||||
return peerManager.updatePeerInfo(peerID, nickname, noisePublicKey, signingPublicKey, isVerified)
|
||||
override fun updatePeerInfoFromVerifiedAnnouncement(peerID: String, nickname: String, noisePublicKey: ByteArray, signingPublicKey: ByteArray, isVerified: Boolean, capabilities: com.bitchat.android.model.PeerCapabilities?): Boolean {
|
||||
return peerManager.updatePeerInfoFromVerifiedAnnouncement(
|
||||
peerID,
|
||||
nickname,
|
||||
noisePublicKey,
|
||||
signingPublicKey,
|
||||
isVerified,
|
||||
capabilities
|
||||
)
|
||||
}
|
||||
|
||||
override fun onVerifiedAnnouncementProcessed(peerID: String) {
|
||||
privateMediaSecurity.refreshAuthenticatedCapability(peerID)
|
||||
}
|
||||
|
||||
// Packet operations
|
||||
@@ -820,70 +849,63 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a file as an encrypted private message using Noise protocol
|
||||
*/
|
||||
/** Safe non-interactive entry point: encrypted sends commit; legacy sends require UI consent. */
|
||||
fun sendFilePrivate(recipientPeerID: String, file: com.bitchat.android.model.BitchatFilePacket) {
|
||||
try {
|
||||
Log.d(TAG, "📤 sendFilePrivate (ENCRYPTED): to=$recipientPeerID, name=${file.fileName}, size=${file.fileSize}")
|
||||
|
||||
serviceScope.launch {
|
||||
// Check if we have an established Noise session
|
||||
if (encryptionService.hasEstablishedSession(recipientPeerID)) {
|
||||
try {
|
||||
// Encode the file packet as TLV
|
||||
val filePayload = file.encode()
|
||||
if (filePayload == null) {
|
||||
Log.e(TAG, "❌ Failed to encode file packet for private send")
|
||||
return@launch
|
||||
val payload = file.encode() ?: return
|
||||
when (val prepared = prepareFilePrivate(
|
||||
recipientPeerID,
|
||||
file,
|
||||
sha256Hex(payload),
|
||||
allowLegacyFallback = false
|
||||
)) {
|
||||
is PrivateMediaPreparation.Ready -> prepared.transfer.commit()
|
||||
is PrivateMediaPreparation.RequiresLegacyConsent ->
|
||||
Log.w(TAG, "Private media requires explicit one-shot legacy consent")
|
||||
PrivateMediaPreparation.NeedsHandshake -> {
|
||||
Log.i(TAG, "Private media needs a Noise handshake; initiating without sending")
|
||||
initiateNoiseHandshake(recipientPeerID)
|
||||
}
|
||||
is PrivateMediaPreparation.Rejected ->
|
||||
Log.w(TAG, "Private media blocked: ${prepared.reason}")
|
||||
}
|
||||
}
|
||||
|
||||
fun prepareFilePrivate(
|
||||
recipientPeerID: String,
|
||||
file: com.bitchat.android.model.BitchatFilePacket,
|
||||
transferId: String,
|
||||
allowLegacyFallback: Boolean
|
||||
): PrivateMediaPreparation {
|
||||
return when (val outcome = privateMediaPreparer.prepare(
|
||||
recipientPeerID = recipientPeerID,
|
||||
recipientID = hexStringToByteArray(recipientPeerID),
|
||||
file = file,
|
||||
allowLegacyFallback = allowLegacyFallback
|
||||
)) {
|
||||
is PrivateMediaBuildOutcome.RequiresLegacyConsent ->
|
||||
PrivateMediaPreparation.RequiresLegacyConsent(outcome.warning)
|
||||
PrivateMediaBuildOutcome.NeedsHandshake ->
|
||||
PrivateMediaPreparation.NeedsHandshake
|
||||
is PrivateMediaBuildOutcome.Rejected ->
|
||||
PrivateMediaPreparation.Rejected(outcome.reason)
|
||||
is PrivateMediaBuildOutcome.Ready -> {
|
||||
val built = outcome.built
|
||||
val routed = RoutedPacket(
|
||||
packet = built.packet,
|
||||
transferId = transferId,
|
||||
preparedPackets = built.fragments
|
||||
)
|
||||
PrivateMediaPreparation.Ready(
|
||||
PreparedPrivateMediaTransfer(transferId, built.wireMode) {
|
||||
if (!isBleTransportEnabled()) {
|
||||
false
|
||||
} else {
|
||||
broadcastRoutedPacket(routed)
|
||||
true
|
||||
}
|
||||
Log.d(TAG, "📦 Encoded file TLV: ${filePayload.size} bytes")
|
||||
|
||||
// Create NoisePayload wrapper (type byte + file TLV data) - same as iOS
|
||||
val noisePayload = com.bitchat.android.model.NoisePayload(
|
||||
type = com.bitchat.android.model.NoisePayloadType.FILE_TRANSFER,
|
||||
data = filePayload
|
||||
)
|
||||
|
||||
// Encrypt the payload using Noise
|
||||
val encrypted = encryptionService.encrypt(noisePayload.encode(), recipientPeerID)
|
||||
if (encrypted == null) {
|
||||
Log.e(TAG, "❌ Failed to encrypt file for $recipientPeerID")
|
||||
return@launch
|
||||
}
|
||||
Log.d(TAG, "🔐 Encrypted file payload: ${encrypted.size} bytes")
|
||||
|
||||
// Create NOISE_ENCRYPTED packet (not FILE_TRANSFER!)
|
||||
val packet = BitchatPacket(
|
||||
version = if (encrypted.size > 0xFFFF) 2u else 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
|
||||
)
|
||||
|
||||
// Sign and send the encrypted packet
|
||||
val signed = signPacketBeforeBroadcast(packet)
|
||||
// Use a stable transferId based on the unencrypted file TLV payload for progress tracking
|
||||
val transferId = sha256Hex(filePayload)
|
||||
broadcastRoutedPacket(RoutedPacket(signed, transferId = transferId))
|
||||
Log.d(TAG, "✅ Sent encrypted file to $recipientPeerID")
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "❌ Failed to encrypt file for $recipientPeerID: ${e.message}", e)
|
||||
}
|
||||
} else {
|
||||
// No session - initiate handshake but don't queue file
|
||||
Log.w(TAG, "⚠️ No Noise session with $recipientPeerID for file transfer, initiating handshake")
|
||||
messageHandler.delegate?.initiateNoiseHandshake(recipientPeerID)
|
||||
}
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "❌ sendFilePrivate failed: ${e.message}", e)
|
||||
Log.e(TAG, "❌ File: to=$recipientPeerID, name=${file.fileName}, size=${file.fileSize}")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1099,7 +1121,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
}
|
||||
|
||||
// Create iOS-compatible IdentityAnnouncement with TLV encoding
|
||||
val announcement = IdentityAnnouncement(nickname, staticKey, signingKey)
|
||||
val announcement = IdentityAnnouncement.forLocalPeer(nickname, staticKey, signingKey)
|
||||
var tlvPayload = announcement.encode()
|
||||
if (tlvPayload == null) {
|
||||
Log.e(TAG, "Failed to encode announcement as TLV")
|
||||
@@ -1162,7 +1184,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
}
|
||||
|
||||
// Create iOS-compatible IdentityAnnouncement with TLV encoding
|
||||
val announcement = IdentityAnnouncement(nickname, staticKey, signingKey)
|
||||
val announcement = IdentityAnnouncement.forLocalPeer(nickname, staticKey, signingKey)
|
||||
var tlvPayload = announcement.encode()
|
||||
if (tlvPayload == null) {
|
||||
Log.e(TAG, "Failed to encode peer announcement as TLV")
|
||||
@@ -1403,24 +1425,44 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
/**
|
||||
* Sign packet before broadcasting using our signing private key
|
||||
*/
|
||||
private fun applyRouteIfAvailable(packet: BitchatPacket): BitchatPacket {
|
||||
return try {
|
||||
val recipient = packet.recipientID
|
||||
if (recipient != null && !recipient.contentEquals(SpecialRecipients.BROADCAST)) {
|
||||
val destination = recipient.joinToString("") { byte -> "%02x".format(byte) }
|
||||
val path = com.bitchat.android.services.meshgraph.RoutePlanner.shortestPath(
|
||||
myPeerID,
|
||||
destination
|
||||
)
|
||||
if (path != null && path.size >= 3) {
|
||||
val intermediates = path.subList(1, path.size - 1)
|
||||
packet.copy(
|
||||
route = intermediates.map(::hexStringToByteArray),
|
||||
version = 2u
|
||||
)
|
||||
} else {
|
||||
packet.copy(route = null)
|
||||
}
|
||||
} else {
|
||||
packet
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
packet
|
||||
}
|
||||
}
|
||||
|
||||
/** Private media must never fall back to an unsigned packet. */
|
||||
private fun routeAndSignPrivateMediaStrict(packet: BitchatPacket): BitchatPacket? {
|
||||
val routed = applyRouteIfAvailable(packet)
|
||||
val signingBytes = routed.toBinaryDataForSigning() ?: return null
|
||||
val signature = encryptionService.signData(signingBytes) ?: return null
|
||||
return routed.copy(signature = signature)
|
||||
}
|
||||
|
||||
private fun signPacketBeforeBroadcast(packet: BitchatPacket): BitchatPacket {
|
||||
return try {
|
||||
// Optionally compute and attach a source route for addressed packets
|
||||
val withRoute = try {
|
||||
val rec = packet.recipientID
|
||||
if (rec != null && !rec.contentEquals(SpecialRecipients.BROADCAST)) {
|
||||
val dest = rec.joinToString("") { b -> "%02x".format(b) }
|
||||
val path = com.bitchat.android.services.meshgraph.RoutePlanner.shortestPath(myPeerID, dest)
|
||||
if (path != null && path.size >= 3) {
|
||||
// Exclude first (sender) and last (recipient); only intermediates
|
||||
val intermediates = path.subList(1, path.size - 1)
|
||||
val hopsBytes = intermediates.map { hexStringToByteArray(it) }
|
||||
Log.d(TAG, "✅ Signed packet type ${packet.type} (route ${hopsBytes.size} hops: $intermediates)")
|
||||
// Attach route and upgrade to v2 (required for HAS_ROUTE flag)
|
||||
packet.copy(route = hopsBytes, version = 2u)
|
||||
} else packet.copy(route = null)
|
||||
} else packet
|
||||
} catch (_: Exception) { packet }
|
||||
val withRoute = applyRouteIfAvailable(packet)
|
||||
|
||||
// Get the canonical packet data for signing (without signature)
|
||||
val packetDataForSigning = withRoute.toBinaryDataForSigning()
|
||||
|
||||
@@ -51,97 +51,123 @@ class FragmentManager {
|
||||
* Create fragments from a large packet - 100% iOS Compatible
|
||||
* Matches iOS sendFragmentedPacket() implementation exactly
|
||||
*/
|
||||
fun createFragments(packet: BitchatPacket): List<BitchatPacket> {
|
||||
/** Generic/public packets retain the full UInt16 fragment-count range. */
|
||||
fun createFragments(packet: BitchatPacket): List<BitchatPacket> =
|
||||
createFragments(packet, 0xFFFF)
|
||||
|
||||
/**
|
||||
* Create a fragment plan with a caller-selected bound. Private media uses
|
||||
* 256 for cross-platform admission; generic/public traffic retains the
|
||||
* UInt16 wire limit.
|
||||
*/
|
||||
fun createFragments(packet: BitchatPacket, maxFragments: Int): List<BitchatPacket> {
|
||||
try {
|
||||
if (maxFragments !in 1..0xFFFF) {
|
||||
Log.w(TAG, "Rejecting invalid outbound fragment limit: $maxFragments")
|
||||
return emptyList()
|
||||
}
|
||||
Log.d(TAG, "🔀 Creating fragments for packet type ${packet.type}, payload: ${packet.payload.size} bytes")
|
||||
val encoded = packet.toBinaryData()
|
||||
val encoded = packet.toBinaryData()
|
||||
if (encoded == null) {
|
||||
Log.e(TAG, "❌ Failed to encode packet to binary data")
|
||||
return emptyList()
|
||||
}
|
||||
Log.d(TAG, "📦 Encoded to ${encoded.size} bytes")
|
||||
|
||||
// Fragment the unpadded frame; each fragment will be encoded (and padded) independently - iOS fix
|
||||
val fullData = try {
|
||||
|
||||
// Fragment the unpadded frame; each fragment will be encoded (and padded) independently - iOS fix
|
||||
val fullData = try {
|
||||
MessagePadding.unpad(encoded)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "❌ Failed to unpad data: ${e.message}", e)
|
||||
return emptyList()
|
||||
}
|
||||
Log.d(TAG, "📏 Unpadded to ${fullData.size} bytes")
|
||||
|
||||
// iOS logic: if data.count > 512 && packet.type != MessageType.fragment.rawValue
|
||||
if (fullData.size <= FRAGMENT_SIZE_THRESHOLD) {
|
||||
return listOf(packet) // No fragmentation needed
|
||||
}
|
||||
|
||||
val fragments = mutableListOf<BitchatPacket>()
|
||||
|
||||
// iOS: let fragmentID = Data((0..<8).map { _ in UInt8.random(in: 0...255) })
|
||||
val fragmentID = FragmentPayload.generateFragmentID()
|
||||
|
||||
// iOS: stride(from: 0, to: fullData.count, by: maxFragmentSize)
|
||||
// Calculate dynamic fragment size to fit in MTU (512)
|
||||
// Packet = Header + Sender + Recipient + Route + FragmentHeader + Payload + PaddingBuffer
|
||||
val hasRoute = packet.route != null
|
||||
val version = if (hasRoute) 2 else 1
|
||||
val headerSize = if (version == 2) 15 else 13
|
||||
val senderSize = 8
|
||||
val recipientSize = if (packet.recipientID != null) 8 else 0
|
||||
// Route: 1 byte count + 8 bytes per hop
|
||||
val routeSize = if (hasRoute) (1 + (packet.route?.size ?: 0) * 8) else 0
|
||||
val fragmentHeaderSize = 13 // FragmentPayload header
|
||||
val paddingBuffer = 16 // MessagePadding.optimalBlockSize adds 16 bytes overhead
|
||||
|
||||
// 512 - Overhead
|
||||
val packetOverhead = headerSize + senderSize + recipientSize + routeSize + fragmentHeaderSize + paddingBuffer
|
||||
val maxDataSize = (512 - packetOverhead).coerceAtMost(MAX_FRAGMENT_SIZE)
|
||||
|
||||
if (maxDataSize <= 0) {
|
||||
Log.e(TAG, "❌ Calculated maxDataSize is non-positive ($maxDataSize). Route too large?")
|
||||
return emptyList()
|
||||
}
|
||||
// iOS logic: if data.count > 512 && packet.type != MessageType.fragment.rawValue
|
||||
if (fullData.size <= FRAGMENT_SIZE_THRESHOLD) {
|
||||
return listOf(packet) // No fragmentation needed
|
||||
}
|
||||
|
||||
Log.d(TAG, "📏 Dynamic fragment size: $maxDataSize (MAX: $MAX_FRAGMENT_SIZE, Overhead: $packetOverhead)")
|
||||
val fragments = mutableListOf<BitchatPacket>()
|
||||
|
||||
val fragmentChunks = stride(0, fullData.size, maxDataSize) { offset ->
|
||||
val endOffset = minOf(offset + maxDataSize, fullData.size)
|
||||
fullData.sliceArray(offset..<endOffset)
|
||||
}
|
||||
|
||||
Log.d(TAG, "Creating ${fragmentChunks.size} fragments for ${fullData.size} byte packet (iOS compatible)")
|
||||
|
||||
// iOS: for (index, fragment) in fragments.enumerated()
|
||||
for (index in fragmentChunks.indices) {
|
||||
val fragmentData = fragmentChunks[index]
|
||||
|
||||
// Create iOS-compatible fragment payload
|
||||
val fragmentPayload = FragmentPayload(
|
||||
fragmentID = fragmentID,
|
||||
index = index,
|
||||
total = fragmentChunks.size,
|
||||
originalType = packet.type,
|
||||
data = fragmentData
|
||||
)
|
||||
|
||||
// iOS: MessageType.fragment.rawValue (single fragment type)
|
||||
// Fix: Fragments must inherit source route and use v2 if routed
|
||||
val fragmentPacket = BitchatPacket(
|
||||
version = if (packet.route != null) 2u else 1u,
|
||||
type = MessageType.FRAGMENT.value,
|
||||
ttl = packet.ttl,
|
||||
senderID = packet.senderID,
|
||||
recipientID = packet.recipientID,
|
||||
timestamp = packet.timestamp,
|
||||
payload = fragmentPayload.encode(),
|
||||
route = packet.route,
|
||||
signature = null // iOS: signature: nil
|
||||
)
|
||||
|
||||
fragments.add(fragmentPacket)
|
||||
}
|
||||
|
||||
Log.d(TAG, "✅ Created ${fragments.size} fragments successfully")
|
||||
// iOS: let fragmentID = Data((0..<8).map { _ in UInt8.random(in: 0...255) })
|
||||
val fragmentID = FragmentPayload.generateFragmentID()
|
||||
|
||||
// iOS: stride(from: 0, to: fullData.count, by: maxFragmentSize)
|
||||
// Calculate dynamic fragment size to fit in MTU (512)
|
||||
// Packet = Header + Sender + Recipient + Route + FragmentHeader + Payload + PaddingBuffer
|
||||
val hasRoute = packet.route != null
|
||||
val version = if (hasRoute) 2 else 1
|
||||
val headerSize = if (version == 2) 15 else 13
|
||||
val senderSize = 8
|
||||
val recipientSize = if (packet.recipientID != null) 8 else 0
|
||||
// Route: 1 byte count + 8 bytes per hop
|
||||
val routeSize = if (hasRoute) (1 + (packet.route?.size ?: 0) * 8) else 0
|
||||
val fragmentHeaderSize = 13 // FragmentPayload header
|
||||
val paddingBuffer = 16 // MessagePadding.optimalBlockSize adds 16 bytes overhead
|
||||
|
||||
// 512 - Overhead
|
||||
val packetOverhead = headerSize + senderSize + recipientSize + routeSize + fragmentHeaderSize + paddingBuffer
|
||||
val maxDataSize = (512 - packetOverhead).coerceAtMost(MAX_FRAGMENT_SIZE)
|
||||
|
||||
if (maxDataSize <= 0) {
|
||||
Log.e(TAG, "❌ Calculated maxDataSize is non-positive ($maxDataSize). Route too large?")
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
Log.d(TAG, "📏 Dynamic fragment size: $maxDataSize (MAX: $MAX_FRAGMENT_SIZE, Overhead: $packetOverhead)")
|
||||
|
||||
val requiredFragments = (
|
||||
(fullData.size.toLong() + maxDataSize.toLong() - 1L) / maxDataSize.toLong()
|
||||
).toInt()
|
||||
if (requiredFragments > maxFragments) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"Rejecting outbound packet requiring $requiredFragments fragments " +
|
||||
"(caller cap: $maxFragments)"
|
||||
)
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
// Do not allocate chunk copies until the plan passes the hard bound.
|
||||
val fragmentChunks = stride(0, fullData.size, maxDataSize) { offset ->
|
||||
val endOffset = minOf(offset + maxDataSize, fullData.size)
|
||||
fullData.sliceArray(offset..<endOffset)
|
||||
}
|
||||
|
||||
Log.d(TAG, "Creating ${fragmentChunks.size} fragments for ${fullData.size} byte packet (iOS compatible)")
|
||||
|
||||
// iOS: for (index, fragment) in fragments.enumerated()
|
||||
for (index in fragmentChunks.indices) {
|
||||
val fragmentData = fragmentChunks[index]
|
||||
|
||||
// Create iOS-compatible fragment payload
|
||||
val fragmentPayload = FragmentPayload(
|
||||
fragmentID = fragmentID,
|
||||
index = index,
|
||||
total = fragmentChunks.size,
|
||||
originalType = packet.type,
|
||||
data = fragmentData
|
||||
)
|
||||
|
||||
// iOS: MessageType.fragment.rawValue (single fragment type)
|
||||
// Fix: Fragments must inherit source route and use v2 if routed
|
||||
val fragmentPacket = BitchatPacket(
|
||||
version = if (packet.route != null) 2u else 1u,
|
||||
type = MessageType.FRAGMENT.value,
|
||||
ttl = packet.ttl,
|
||||
senderID = packet.senderID,
|
||||
recipientID = packet.recipientID,
|
||||
timestamp = packet.timestamp,
|
||||
payload = fragmentPayload.encode(),
|
||||
route = packet.route,
|
||||
signature = null // iOS: signature: nil
|
||||
)
|
||||
|
||||
fragments.add(fragmentPacket)
|
||||
}
|
||||
|
||||
Log.d(TAG, "✅ Created ${fragments.size} fragments successfully")
|
||||
return fragments
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "❌ Fragment creation failed: ${e.message}", e)
|
||||
|
||||
@@ -31,14 +31,20 @@ class FragmentingPacketSender(
|
||||
sendSingle: (RoutedPacket) -> Boolean
|
||||
): Boolean {
|
||||
val transferId = transferIdFor(routed)
|
||||
val packets = packetsForTransport(routed.packet) ?: return false
|
||||
val packets = packetsForTransport(routed) ?: return false
|
||||
val total = packets.size
|
||||
|
||||
if (total <= 1) {
|
||||
if (transferId != null) {
|
||||
TransferProgressManager.start(transferId, 1)
|
||||
}
|
||||
val sent = sendSingle(routed.copy(packet = packets.first(), transferId = transferId))
|
||||
val sent = sendSingle(
|
||||
routed.copy(
|
||||
packet = packets.first(),
|
||||
transferId = transferId,
|
||||
preparedPackets = null
|
||||
)
|
||||
)
|
||||
if (sent && transferId != null) {
|
||||
TransferProgressManager.progress(transferId, 1, 1)
|
||||
TransferProgressManager.complete(transferId, 1)
|
||||
@@ -57,7 +63,11 @@ class FragmentingPacketSender(
|
||||
if (!isActive) return@launch
|
||||
if (transferId != null && transferJobs[transferId]?.isCancelled == true) return@launch
|
||||
|
||||
val fragment = routed.copy(packet = packet, transferId = transferId)
|
||||
val fragment = routed.copy(
|
||||
packet = packet,
|
||||
transferId = transferId,
|
||||
preparedPackets = null
|
||||
)
|
||||
val delivered = try {
|
||||
sendSingle(fragment)
|
||||
} catch (e: Exception) {
|
||||
@@ -98,7 +108,17 @@ class FragmentingPacketSender(
|
||||
return true
|
||||
}
|
||||
|
||||
private fun packetsForTransport(packet: BitchatPacket): List<BitchatPacket>? {
|
||||
private fun packetsForTransport(routed: RoutedPacket): List<BitchatPacket>? {
|
||||
routed.preparedPackets?.let { prepared ->
|
||||
if (prepared.isEmpty() ||
|
||||
prepared.size > com.bitchat.android.util.AppConstants.Fragmentation.MAX_FRAGMENTS_PER_ID) {
|
||||
Log.e(logTag, "Rejected invalid prepared fragment plan (${prepared.size} packets)")
|
||||
return null
|
||||
}
|
||||
return prepared
|
||||
}
|
||||
|
||||
val packet = routed.packet
|
||||
if (packet.type == MessageType.FRAGMENT.value) {
|
||||
return listOf(packet)
|
||||
}
|
||||
|
||||
@@ -51,6 +51,23 @@ class MeshCore(
|
||||
|
||||
private val peerManager = PeerManager()
|
||||
val fragmentManager = FragmentManager()
|
||||
private val privateMediaSecurity = PrivateMediaSecurityController(
|
||||
peerInfoProvider = peerManager::getPeerInfo,
|
||||
authenticatedRemoteStaticProvider = encryptionService::getAuthenticatedRemoteStaticKey,
|
||||
pinStore = SecurePrivateMediaCapabilityPinStore(context)
|
||||
)
|
||||
private val privateMediaPreparer by lazy {
|
||||
PrivateMediaTransferPreparer(
|
||||
senderID = MeshPacketUtils.hexStringToByteArray(myPeerID),
|
||||
ttl = maxTtl,
|
||||
policyProvider = privateMediaSecurity::sendPolicy,
|
||||
encrypt = { plaintext, peerID ->
|
||||
runCatching { encryptionService.encrypt(plaintext, peerID) }.getOrNull()
|
||||
},
|
||||
finalizeRoutedAndSigned = ::routeAndSignPrivateMediaStrict,
|
||||
fragment = fragmentManager::createFragments
|
||||
)
|
||||
}
|
||||
private val securityManager = SecurityManager(encryptionService, myPeerID)
|
||||
private val storeForwardManager = StoreForwardManager()
|
||||
private val messageHandler = MessageHandler(myPeerID, context.applicationContext)
|
||||
@@ -158,6 +175,7 @@ class MeshCore(
|
||||
|
||||
securityManager.delegate = object : SecurityManagerDelegate {
|
||||
override fun onKeyExchangeCompleted(peerID: String, peerPublicKeyData: ByteArray) {
|
||||
privateMediaSecurity.refreshAuthenticatedCapability(peerID)
|
||||
scope.launch {
|
||||
delay(100)
|
||||
sendAnnouncementToPeer(peerID)
|
||||
@@ -225,14 +243,26 @@ class MeshCore(
|
||||
return peerManager.getPeerInfo(peerID)
|
||||
}
|
||||
|
||||
override fun updatePeerInfo(
|
||||
override fun updatePeerInfoFromVerifiedAnnouncement(
|
||||
peerID: String,
|
||||
nickname: String,
|
||||
noisePublicKey: ByteArray,
|
||||
signingPublicKey: ByteArray,
|
||||
isVerified: Boolean
|
||||
isVerified: Boolean,
|
||||
capabilities: com.bitchat.android.model.PeerCapabilities?
|
||||
): Boolean {
|
||||
return peerManager.updatePeerInfo(peerID, nickname, noisePublicKey, signingPublicKey, isVerified)
|
||||
return peerManager.updatePeerInfoFromVerifiedAnnouncement(
|
||||
peerID,
|
||||
nickname,
|
||||
noisePublicKey,
|
||||
signingPublicKey,
|
||||
isVerified,
|
||||
capabilities
|
||||
)
|
||||
}
|
||||
|
||||
override fun onVerifiedAnnouncementProcessed(peerID: String) {
|
||||
privateMediaSecurity.refreshAuthenticatedCapability(peerID)
|
||||
}
|
||||
|
||||
override fun sendPacket(packet: BitchatPacket) {
|
||||
@@ -455,31 +485,61 @@ class MeshCore(
|
||||
}
|
||||
|
||||
fun sendFilePrivate(recipientPeerID: String, file: BitchatFilePacket) {
|
||||
try {
|
||||
scope.launch {
|
||||
if (!encryptionService.hasEstablishedSession(recipientPeerID)) {
|
||||
initiateNoiseHandshake(recipientPeerID)
|
||||
return@launch
|
||||
}
|
||||
val tlv = file.encode() ?: return@launch
|
||||
val np = NoisePayload(type = NoisePayloadType.FILE_TRANSFER, data = tlv).encode()
|
||||
val enc = encryptionService.encrypt(np, recipientPeerID)
|
||||
val packet = BitchatPacket(
|
||||
version = if (enc.size > 0xFFFF) 2u else 1u,
|
||||
type = MessageType.NOISE_ENCRYPTED.value,
|
||||
senderID = MeshPacketUtils.hexStringToByteArray(myPeerID),
|
||||
recipientID = MeshPacketUtils.hexStringToByteArray(recipientPeerID),
|
||||
timestamp = System.currentTimeMillis().toULong(),
|
||||
payload = enc,
|
||||
signature = null,
|
||||
ttl = maxTtl
|
||||
val payload = file.encode() ?: return
|
||||
when (val prepared = prepareFilePrivate(
|
||||
recipientPeerID,
|
||||
file,
|
||||
MeshPacketUtils.sha256Hex(payload),
|
||||
allowLegacyFallback = false
|
||||
)) {
|
||||
is PrivateMediaPreparation.Ready -> prepared.transfer.commit()
|
||||
is PrivateMediaPreparation.RequiresLegacyConsent ->
|
||||
Log.w("MeshCore", "Private media requires explicit one-shot legacy consent")
|
||||
PrivateMediaPreparation.NeedsHandshake -> {
|
||||
Log.i("MeshCore", "Private media needs a Noise handshake; initiating without sending")
|
||||
initiateNoiseHandshake(recipientPeerID)
|
||||
}
|
||||
is PrivateMediaPreparation.Rejected ->
|
||||
Log.w("MeshCore", "Private media blocked: ${prepared.reason}")
|
||||
}
|
||||
}
|
||||
|
||||
fun prepareFilePrivate(
|
||||
recipientPeerID: String,
|
||||
file: BitchatFilePacket,
|
||||
transferId: String,
|
||||
allowLegacyFallback: Boolean
|
||||
): PrivateMediaPreparation {
|
||||
return when (val outcome = privateMediaPreparer.prepare(
|
||||
recipientPeerID = recipientPeerID,
|
||||
recipientID = MeshPacketUtils.hexStringToByteArray(recipientPeerID),
|
||||
file = file,
|
||||
allowLegacyFallback = allowLegacyFallback
|
||||
)) {
|
||||
is PrivateMediaBuildOutcome.RequiresLegacyConsent ->
|
||||
PrivateMediaPreparation.RequiresLegacyConsent(outcome.warning)
|
||||
PrivateMediaBuildOutcome.NeedsHandshake ->
|
||||
PrivateMediaPreparation.NeedsHandshake
|
||||
is PrivateMediaBuildOutcome.Rejected ->
|
||||
PrivateMediaPreparation.Rejected(outcome.reason)
|
||||
is PrivateMediaBuildOutcome.Ready -> {
|
||||
val built = outcome.built
|
||||
val routed = RoutedPacket(
|
||||
packet = built.packet,
|
||||
transferId = transferId,
|
||||
preparedPackets = built.fragments
|
||||
)
|
||||
val signed = signPacketBeforeBroadcast(packet)
|
||||
val transferId = MeshPacketUtils.sha256Hex(tlv)
|
||||
dispatchGlobal(RoutedPacket(signed, transferId = transferId))
|
||||
PrivateMediaPreparation.Ready(
|
||||
PreparedPrivateMediaTransfer(transferId, built.wireMode) {
|
||||
if (!isActive) {
|
||||
false
|
||||
} else {
|
||||
dispatchGlobal(routed)
|
||||
true
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("MeshCore", "sendFilePrivate failed: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -600,7 +660,7 @@ class MeshCore(
|
||||
Log.e("MeshCore", "No signing public key available for announcement")
|
||||
return@launch
|
||||
}
|
||||
val announcement = IdentityAnnouncement(nickname, staticKey, signingKey)
|
||||
val announcement = IdentityAnnouncement.forLocalPeer(nickname, staticKey, signingKey)
|
||||
val tlvPayload = buildAnnouncementPayload(announcement, nickname) ?: return@launch
|
||||
val announcePacket = BitchatPacket(
|
||||
type = MessageType.ANNOUNCE.value,
|
||||
@@ -621,7 +681,7 @@ class MeshCore(
|
||||
?: myPeerID
|
||||
val staticKey = encryptionService.getStaticPublicKey() ?: return
|
||||
val signingKey = encryptionService.getSigningPublicKey() ?: return
|
||||
val announcement = IdentityAnnouncement(nickname, staticKey, signingKey)
|
||||
val announcement = IdentityAnnouncement.forLocalPeer(nickname, staticKey, signingKey)
|
||||
val tlvPayload = buildAnnouncementPayload(announcement, nickname) ?: return
|
||||
val packet = BitchatPacket(
|
||||
type = MessageType.ANNOUNCE.value,
|
||||
@@ -806,28 +866,42 @@ class MeshCore(
|
||||
encryptionService.clearPersistentIdentity()
|
||||
}
|
||||
|
||||
private fun signPacketBeforeBroadcast(packet: BitchatPacket): BitchatPacket {
|
||||
private fun applyRouteIfAvailable(packet: BitchatPacket): BitchatPacket {
|
||||
return try {
|
||||
val withRoute = try {
|
||||
val recipient = packet.recipientID
|
||||
if (recipient != null && !recipient.contentEquals(SpecialRecipients.BROADCAST)) {
|
||||
val destination = recipient.toHexString()
|
||||
val path = com.bitchat.android.services.meshgraph.RoutePlanner.shortestPath(myPeerID, destination)
|
||||
if (path != null && path.size >= 3) {
|
||||
val intermediates = path.subList(1, path.size - 1)
|
||||
packet.copy(
|
||||
route = intermediates.map { MeshPacketUtils.hexStringToByteArray(it) },
|
||||
version = 2u
|
||||
)
|
||||
} else {
|
||||
packet.copy(route = null)
|
||||
}
|
||||
val recipient = packet.recipientID
|
||||
if (recipient != null && !recipient.contentEquals(SpecialRecipients.BROADCAST)) {
|
||||
val destination = recipient.toHexString()
|
||||
val path = com.bitchat.android.services.meshgraph.RoutePlanner.shortestPath(
|
||||
myPeerID,
|
||||
destination
|
||||
)
|
||||
if (path != null && path.size >= 3) {
|
||||
val intermediates = path.subList(1, path.size - 1)
|
||||
packet.copy(
|
||||
route = intermediates.map { MeshPacketUtils.hexStringToByteArray(it) },
|
||||
version = 2u
|
||||
)
|
||||
} else {
|
||||
packet
|
||||
packet.copy(route = null)
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
} else {
|
||||
packet
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
packet
|
||||
}
|
||||
}
|
||||
|
||||
private fun routeAndSignPrivateMediaStrict(packet: BitchatPacket): BitchatPacket? {
|
||||
val routed = applyRouteIfAvailable(packet)
|
||||
val signingBytes = routed.toBinaryDataForSigning() ?: return null
|
||||
val signature = encryptionService.signData(signingBytes) ?: return null
|
||||
return routed.copy(signature = signature)
|
||||
}
|
||||
|
||||
private fun signPacketBeforeBroadcast(packet: BitchatPacket): BitchatPacket {
|
||||
return try {
|
||||
val withRoute = applyRouteIfAvailable(packet)
|
||||
|
||||
val packetDataForSigning = withRoute.toBinaryDataForSigning() ?: return withRoute
|
||||
val signature = encryptionService.signData(packetDataForSigning)
|
||||
|
||||
@@ -21,6 +21,12 @@ interface MeshService {
|
||||
fun sendVerifyResponse(peerID: String, noiseKeyHex: String, nonceA: ByteArray)
|
||||
fun sendFileBroadcast(file: BitchatFilePacket)
|
||||
fun sendFilePrivate(recipientPeerID: String, file: BitchatFilePacket)
|
||||
fun prepareFilePrivate(
|
||||
recipientPeerID: String,
|
||||
file: BitchatFilePacket,
|
||||
transferId: String,
|
||||
allowLegacyFallback: Boolean
|
||||
): PrivateMediaPreparation
|
||||
fun cancelFileTransfer(transferId: String): Boolean
|
||||
|
||||
fun sendBroadcastAnnounce()
|
||||
|
||||
@@ -275,14 +275,20 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
|
||||
val signingPublicKey = announcement.signingPublicKey
|
||||
|
||||
// Update peer info with verification status through new method
|
||||
val isFirstAnnounce = delegate?.updatePeerInfo(
|
||||
val isFirstAnnounce = delegate?.updatePeerInfoFromVerifiedAnnouncement(
|
||||
peerID = peerID,
|
||||
nickname = nickname,
|
||||
noisePublicKey = noisePublicKey,
|
||||
signingPublicKey = signingPublicKey,
|
||||
isVerified = true
|
||||
isVerified = true,
|
||||
capabilities = announcement.capabilities
|
||||
) ?: false
|
||||
|
||||
// Promotion of security-sensitive capabilities happens only after the
|
||||
// signature check above and must additionally bind to the authenticated
|
||||
// Noise remote-static key in the transport service.
|
||||
delegate?.onVerifiedAnnouncementProcessed(peerID)
|
||||
|
||||
// Update peer ID binding with noise public key for identity management
|
||||
delegate?.updatePeerIDBinding(
|
||||
newPeerID = peerID,
|
||||
@@ -438,14 +444,26 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
|
||||
*/
|
||||
private suspend fun handlePrivateMessage(packet: BitchatPacket, peerID: String) {
|
||||
try {
|
||||
// Verify signature if present
|
||||
if (packet.signature != null && !delegate?.verifySignature(packet, peerID)!!) {
|
||||
val isFileTransfer = com.bitchat.android.protocol.MessageType.fromValue(packet.type) ==
|
||||
com.bitchat.android.protocol.MessageType.FILE_TRANSFER
|
||||
val signatureIsValid = packet.signature != null &&
|
||||
delegate?.verifySignature(packet, peerID) == true
|
||||
|
||||
// Migration fallback is visible to relays, so sender authenticity
|
||||
// is mandatory. Never accept an unsigned directed raw file.
|
||||
if (isFileTransfer && !signatureIsValid) {
|
||||
Log.w(TAG, "Unsigned or invalid signed private file from $peerID")
|
||||
return
|
||||
}
|
||||
|
||||
// Preserve prior behavior for other directed packet types: verify
|
||||
// a signature whenever one is present.
|
||||
if (!isFileTransfer && packet.signature != null && !signatureIsValid) {
|
||||
Log.w(TAG, "Invalid signature for private message from $peerID")
|
||||
return
|
||||
}
|
||||
|
||||
// Try file packet first (voice, image, etc.) and log outcome for FILE_TRANSFER
|
||||
val isFileTransfer = com.bitchat.android.protocol.MessageType.fromValue(packet.type) == com.bitchat.android.protocol.MessageType.FILE_TRANSFER
|
||||
val file = com.bitchat.android.model.BitchatFilePacket.decode(packet.payload)
|
||||
if (file != null) {
|
||||
if (isFileTransfer) {
|
||||
@@ -604,7 +622,15 @@ interface MessageHandlerDelegate {
|
||||
fun getNetworkSize(): Int
|
||||
fun getMyNickname(): String?
|
||||
fun getPeerInfo(peerID: String): PeerInfo?
|
||||
fun updatePeerInfo(peerID: String, nickname: String, noisePublicKey: ByteArray, signingPublicKey: ByteArray, isVerified: Boolean): Boolean
|
||||
fun updatePeerInfoFromVerifiedAnnouncement(
|
||||
peerID: String,
|
||||
nickname: String,
|
||||
noisePublicKey: ByteArray,
|
||||
signingPublicKey: ByteArray,
|
||||
isVerified: Boolean,
|
||||
capabilities: com.bitchat.android.model.PeerCapabilities? = null
|
||||
): Boolean
|
||||
fun onVerifiedAnnouncementProcessed(peerID: String) {}
|
||||
|
||||
// Packet operations
|
||||
fun sendPacket(packet: BitchatPacket)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.bitchat.android.mesh
|
||||
|
||||
import android.util.Log
|
||||
import com.bitchat.android.model.PeerCapabilities
|
||||
import kotlinx.coroutines.*
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
@@ -17,7 +18,11 @@ data class PeerInfo(
|
||||
var noisePublicKey: ByteArray?,
|
||||
var signingPublicKey: ByteArray?, // NEW: Ed25519 public key for verification
|
||||
var isVerifiedNickname: Boolean, // NEW: Verification status flag
|
||||
var lastSeen: Long // Using Long instead of Date for simplicity
|
||||
var lastSeen: Long, // Using Long instead of Date for simplicity
|
||||
var capabilities: PeerCapabilities? = null, // null means a signed old-client announce omitted TLV 0x05
|
||||
var hasVerifiedAnnouncement: Boolean = false,
|
||||
/** Noise key that the preserved capability state was actually signed alongside. */
|
||||
var verifiedAnnouncementNoisePublicKey: ByteArray? = null
|
||||
) {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
@@ -39,6 +44,14 @@ data class PeerInfo(
|
||||
} else if (other.signingPublicKey != null) return false
|
||||
if (isVerifiedNickname != other.isVerifiedNickname) return false
|
||||
if (lastSeen != other.lastSeen) return false
|
||||
if (capabilities != other.capabilities) return false
|
||||
if (hasVerifiedAnnouncement != other.hasVerifiedAnnouncement) return false
|
||||
val thisVerifiedAnnouncementKey = verifiedAnnouncementNoisePublicKey
|
||||
val otherVerifiedAnnouncementKey = other.verifiedAnnouncementNoisePublicKey
|
||||
if (thisVerifiedAnnouncementKey != null) {
|
||||
if (otherVerifiedAnnouncementKey == null) return false
|
||||
if (!thisVerifiedAnnouncementKey.contentEquals(otherVerifiedAnnouncementKey)) return false
|
||||
} else if (otherVerifiedAnnouncementKey != null) return false
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -52,6 +65,9 @@ data class PeerInfo(
|
||||
result = 31 * result + (signingPublicKey?.contentHashCode() ?: 0)
|
||||
result = 31 * result + isVerifiedNickname.hashCode()
|
||||
result = 31 * result + lastSeen.hashCode()
|
||||
result = 31 * result + (capabilities?.hashCode() ?: 0)
|
||||
result = 31 * result + hasVerifiedAnnouncement.hashCode()
|
||||
result = 31 * result + (verifiedAnnouncementNoisePublicKey?.contentHashCode() ?: 0)
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -108,6 +124,53 @@ class PeerManager {
|
||||
noisePublicKey: ByteArray,
|
||||
signingPublicKey: ByteArray,
|
||||
isVerified: Boolean
|
||||
): Boolean {
|
||||
val existing = peers[peerID]
|
||||
return updatePeerInfoInternal(
|
||||
peerID = peerID,
|
||||
nickname = nickname,
|
||||
noisePublicKey = noisePublicKey,
|
||||
signingPublicKey = signingPublicKey,
|
||||
isVerified = isVerified,
|
||||
capabilities = existing?.capabilities,
|
||||
hasVerifiedAnnouncement = existing?.hasVerifiedAnnouncement == true,
|
||||
verifiedAnnouncementNoisePublicKey = existing?.verifiedAnnouncementNoisePublicKey
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the exact capability state from a signature-verified announce.
|
||||
* A null value is meaningful: the peer signed an old-format announce that
|
||||
* omitted TLV 0x05. Normal peer refreshes use [updatePeerInfo] and retain
|
||||
* the last signed capability state instead of accidentally erasing it.
|
||||
*/
|
||||
fun updatePeerInfoFromVerifiedAnnouncement(
|
||||
peerID: String,
|
||||
nickname: String,
|
||||
noisePublicKey: ByteArray,
|
||||
signingPublicKey: ByteArray,
|
||||
isVerified: Boolean,
|
||||
capabilities: PeerCapabilities?
|
||||
): Boolean = updatePeerInfoInternal(
|
||||
peerID = peerID,
|
||||
nickname = nickname,
|
||||
noisePublicKey = noisePublicKey,
|
||||
signingPublicKey = signingPublicKey,
|
||||
isVerified = isVerified,
|
||||
capabilities = capabilities,
|
||||
hasVerifiedAnnouncement = true,
|
||||
verifiedAnnouncementNoisePublicKey = noisePublicKey.copyOf()
|
||||
)
|
||||
|
||||
private fun updatePeerInfoInternal(
|
||||
peerID: String,
|
||||
nickname: String,
|
||||
noisePublicKey: ByteArray,
|
||||
signingPublicKey: ByteArray,
|
||||
isVerified: Boolean,
|
||||
capabilities: PeerCapabilities?,
|
||||
hasVerifiedAnnouncement: Boolean,
|
||||
verifiedAnnouncementNoisePublicKey: ByteArray?
|
||||
): Boolean {
|
||||
if (peerID == "unknown") return false
|
||||
|
||||
@@ -125,6 +188,9 @@ class PeerManager {
|
||||
val noiseKeyChanged = existingPeer != null && !keysMatch(existingPeer.noisePublicKey, noisePublicKey)
|
||||
val signingKeyChanged = existingPeer != null && !keysMatch(existingPeer.signingPublicKey, signingPublicKey)
|
||||
val connectedChanged = existingPeer != null && existingPeer.isConnected != true
|
||||
val capabilitiesChanged = existingPeer != null && existingPeer.capabilities != capabilities
|
||||
val announcementStateChanged = existingPeer != null &&
|
||||
existingPeer.hasVerifiedAnnouncement != hasVerifiedAnnouncement
|
||||
|
||||
// Update or create peer info
|
||||
val peerInfo = PeerInfo(
|
||||
@@ -135,7 +201,10 @@ class PeerManager {
|
||||
noisePublicKey = noisePublicKey,
|
||||
signingPublicKey = signingPublicKey,
|
||||
isVerifiedNickname = isVerified,
|
||||
lastSeen = now
|
||||
lastSeen = now,
|
||||
capabilities = capabilities,
|
||||
hasVerifiedAnnouncement = hasVerifiedAnnouncement,
|
||||
verifiedAnnouncementNoisePublicKey = verifiedAnnouncementNoisePublicKey?.copyOf()
|
||||
)
|
||||
|
||||
peers[peerID] = peerInfo
|
||||
@@ -147,7 +216,8 @@ class PeerManager {
|
||||
val shouldNotify = when {
|
||||
isNewPeer && isVerified -> true
|
||||
wasVerified != isVerified -> true
|
||||
nicknameChanged || noiseKeyChanged || signingKeyChanged || connectedChanged -> true
|
||||
nicknameChanged || noiseKeyChanged || signingKeyChanged || connectedChanged ||
|
||||
capabilitiesChanged || announcementStateChanged -> true
|
||||
else -> false
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
package com.bitchat.android.mesh
|
||||
|
||||
import android.content.Context
|
||||
import com.bitchat.android.identity.SecureIdentityStateManager
|
||||
import com.bitchat.android.model.PeerCapabilities
|
||||
import java.security.MessageDigest
|
||||
|
||||
internal interface PrivateMediaCapabilityPinStore {
|
||||
fun contains(fingerprint: String): Boolean
|
||||
fun insert(fingerprint: String)
|
||||
}
|
||||
|
||||
internal class SecurePrivateMediaCapabilityPinStore(context: Context) :
|
||||
PrivateMediaCapabilityPinStore {
|
||||
private val identityState = SecureIdentityStateManager(context.applicationContext)
|
||||
|
||||
override fun contains(fingerprint: String): Boolean =
|
||||
identityState.isPrivateMediaCapable(fingerprint)
|
||||
|
||||
override fun insert(fingerprint: String) {
|
||||
identityState.markPrivateMediaCapable(fingerprint)
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed interface PrivateMediaPolicyDecision {
|
||||
data object Encrypted : PrivateMediaPolicyDecision
|
||||
data object RequiresLegacyConsent : PrivateMediaPolicyDecision
|
||||
data object NeedsHandshake : PrivateMediaPolicyDecision
|
||||
data class Blocked(val reason: String) : PrivateMediaPolicyDecision
|
||||
}
|
||||
|
||||
/**
|
||||
* Binds an advertised private-media capability to a live Noise remote-static
|
||||
* key and persists an HSTS-style pin by that authenticated key's SHA-256
|
||||
* fingerprint. Announcements alone can never create a pin.
|
||||
*/
|
||||
internal class PrivateMediaSecurityController(
|
||||
private val peerInfoProvider: (String) -> PeerInfo?,
|
||||
private val authenticatedRemoteStaticProvider: (String) -> ByteArray?,
|
||||
private val pinStore: PrivateMediaCapabilityPinStore
|
||||
) {
|
||||
fun refreshAuthenticatedCapability(peerID: String): Boolean {
|
||||
val remoteStatic = authenticatedRemoteStaticProvider(peerID)
|
||||
?.takeIf { it.size == 32 }
|
||||
?: return false
|
||||
val peer = peerInfoProvider(peerID) ?: return false
|
||||
if (!peer.hasVerifiedAnnouncement) return false
|
||||
val announcedStatic = peer.verifiedAnnouncementNoisePublicKey ?: return false
|
||||
if (!announcedStatic.contentEquals(remoteStatic)) return false
|
||||
if (peer.capabilities?.contains(PeerCapabilities.PRIVATE_MEDIA) != true) return false
|
||||
|
||||
pinStore.insert(fingerprint(remoteStatic))
|
||||
return true
|
||||
}
|
||||
|
||||
fun sendPolicy(peerID: String): PrivateMediaPolicyDecision {
|
||||
val remoteStatic = authenticatedRemoteStaticProvider(peerID)
|
||||
?.takeIf { it.size == 32 }
|
||||
?: return PrivateMediaPolicyDecision.NeedsHandshake
|
||||
val authenticatedFingerprint = fingerprint(remoteStatic)
|
||||
|
||||
// Once a fingerprint has authenticated encrypted media support, never
|
||||
// downgrade it because a later announce omits or clears the bit.
|
||||
if (pinStore.contains(authenticatedFingerprint)) {
|
||||
return PrivateMediaPolicyDecision.Encrypted
|
||||
}
|
||||
|
||||
val peer = peerInfoProvider(peerID)
|
||||
?: return PrivateMediaPolicyDecision.Blocked(
|
||||
"No signature-verified identity announcement is available"
|
||||
)
|
||||
if (!peer.hasVerifiedAnnouncement) {
|
||||
return PrivateMediaPolicyDecision.Blocked(
|
||||
"The peer identity announcement has not been verified"
|
||||
)
|
||||
}
|
||||
val announcedStatic = peer.verifiedAnnouncementNoisePublicKey
|
||||
?: return PrivateMediaPolicyDecision.Blocked(
|
||||
"The verified announcement did not contain a Noise identity"
|
||||
)
|
||||
if (!announcedStatic.contentEquals(remoteStatic)) {
|
||||
return PrivateMediaPolicyDecision.Blocked(
|
||||
"The authenticated Noise identity does not match the verified announcement"
|
||||
)
|
||||
}
|
||||
|
||||
return if (peer.capabilities?.contains(PeerCapabilities.PRIVATE_MEDIA) == true) {
|
||||
pinStore.insert(authenticatedFingerprint)
|
||||
PrivateMediaPolicyDecision.Encrypted
|
||||
} else {
|
||||
// Both a missing TLV and an explicitly empty bitfield are legacy.
|
||||
PrivateMediaPolicyDecision.RequiresLegacyConsent
|
||||
}
|
||||
}
|
||||
|
||||
internal fun authenticatedFingerprint(peerID: String): String? =
|
||||
authenticatedRemoteStaticProvider(peerID)
|
||||
?.takeIf { it.size == 32 }
|
||||
?.let(::fingerprint)
|
||||
|
||||
private fun fingerprint(publicKey: ByteArray): String =
|
||||
MessageDigest.getInstance("SHA-256")
|
||||
.digest(publicKey)
|
||||
.joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package com.bitchat.android.mesh
|
||||
|
||||
import com.bitchat.android.model.BitchatFilePacket
|
||||
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 java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
enum class PrivateMediaWireMode {
|
||||
ENCRYPTED_NOISE_0X20,
|
||||
SIGNED_DIRECTED_RAW_0X22
|
||||
}
|
||||
|
||||
class PreparedPrivateMediaTransfer internal constructor(
|
||||
val transferId: String,
|
||||
val wireMode: PrivateMediaWireMode,
|
||||
private val commitAction: () -> Boolean
|
||||
) {
|
||||
private val committed = AtomicBoolean(false)
|
||||
|
||||
/** A prepared transfer is single-use, including after a failed commit. */
|
||||
fun commit(): Boolean {
|
||||
if (!committed.compareAndSet(false, true)) return false
|
||||
return commitAction()
|
||||
}
|
||||
}
|
||||
|
||||
sealed interface PrivateMediaPreparation {
|
||||
data class Ready(val transfer: PreparedPrivateMediaTransfer) : PrivateMediaPreparation
|
||||
data class RequiresLegacyConsent(val warning: String) : PrivateMediaPreparation
|
||||
data object NeedsHandshake : PrivateMediaPreparation
|
||||
data class Rejected(val reason: String) : PrivateMediaPreparation
|
||||
}
|
||||
|
||||
internal data class BuiltPrivateMediaTransfer(
|
||||
val packet: BitchatPacket,
|
||||
val fragments: List<BitchatPacket>,
|
||||
val wireMode: PrivateMediaWireMode
|
||||
)
|
||||
|
||||
internal sealed interface PrivateMediaBuildOutcome {
|
||||
data class Ready(val built: BuiltPrivateMediaTransfer) : PrivateMediaBuildOutcome
|
||||
data class RequiresLegacyConsent(val warning: String) : PrivateMediaBuildOutcome
|
||||
data object NeedsHandshake : PrivateMediaBuildOutcome
|
||||
data class Rejected(val reason: String) : PrivateMediaBuildOutcome
|
||||
}
|
||||
|
||||
/** Builds, routes, signs, and fragments exactly once before UI local echo. */
|
||||
internal class PrivateMediaTransferPreparer(
|
||||
private val senderID: ByteArray,
|
||||
private val ttl: UByte,
|
||||
private val policyProvider: (String) -> PrivateMediaPolicyDecision,
|
||||
private val encrypt: (ByteArray, String) -> ByteArray?,
|
||||
private val finalizeRoutedAndSigned: (BitchatPacket) -> BitchatPacket?,
|
||||
private val fragment: (BitchatPacket, Int) -> List<BitchatPacket>,
|
||||
private val now: () -> ULong = { System.currentTimeMillis().toULong() }
|
||||
) {
|
||||
fun prepare(
|
||||
recipientPeerID: String,
|
||||
recipientID: ByteArray,
|
||||
file: BitchatFilePacket,
|
||||
allowLegacyFallback: Boolean
|
||||
): PrivateMediaBuildOutcome {
|
||||
val policy = policyProvider(recipientPeerID)
|
||||
val mode = when (policy) {
|
||||
PrivateMediaPolicyDecision.Encrypted -> PrivateMediaWireMode.ENCRYPTED_NOISE_0X20
|
||||
PrivateMediaPolicyDecision.RequiresLegacyConsent -> {
|
||||
if (!allowLegacyFallback) {
|
||||
return PrivateMediaBuildOutcome.RequiresLegacyConsent(
|
||||
"This older client cannot receive encrypted private media. " +
|
||||
"Sending this one file will expose its contents to mesh relays, " +
|
||||
"although the directed packet will still be signed."
|
||||
)
|
||||
}
|
||||
PrivateMediaWireMode.SIGNED_DIRECTED_RAW_0X22
|
||||
}
|
||||
PrivateMediaPolicyDecision.NeedsHandshake ->
|
||||
return PrivateMediaBuildOutcome.NeedsHandshake
|
||||
is PrivateMediaPolicyDecision.Blocked ->
|
||||
return PrivateMediaBuildOutcome.Rejected(policy.reason)
|
||||
}
|
||||
|
||||
val filePayload = file.encode()
|
||||
?: return PrivateMediaBuildOutcome.Rejected("Failed to encode private media")
|
||||
|
||||
val packet = when (mode) {
|
||||
PrivateMediaWireMode.ENCRYPTED_NOISE_0X20 -> {
|
||||
val plaintext = NoisePayload(NoisePayloadType.FILE_TRANSFER, filePayload).encode()
|
||||
val ciphertext = try {
|
||||
encrypt(plaintext, recipientPeerID)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
} ?: return PrivateMediaBuildOutcome.Rejected(
|
||||
"The authenticated Noise session could not encrypt this file"
|
||||
)
|
||||
BitchatPacket(
|
||||
version = if (ciphertext.size > 0xFFFF) 2u else 1u,
|
||||
type = MessageType.NOISE_ENCRYPTED.value,
|
||||
senderID = senderID.copyOf(),
|
||||
recipientID = recipientID.copyOf(),
|
||||
timestamp = now(),
|
||||
payload = ciphertext,
|
||||
signature = null,
|
||||
ttl = ttl
|
||||
)
|
||||
}
|
||||
|
||||
PrivateMediaWireMode.SIGNED_DIRECTED_RAW_0X22 -> BitchatPacket(
|
||||
version = 2u,
|
||||
type = MessageType.FILE_TRANSFER.value,
|
||||
senderID = senderID.copyOf(),
|
||||
recipientID = recipientID.copyOf(),
|
||||
timestamp = now(),
|
||||
payload = filePayload,
|
||||
signature = null,
|
||||
ttl = ttl
|
||||
)
|
||||
}
|
||||
|
||||
val finalized = finalizeRoutedAndSigned(packet)
|
||||
?: return PrivateMediaBuildOutcome.Rejected(
|
||||
if (mode == PrivateMediaWireMode.SIGNED_DIRECTED_RAW_0X22) {
|
||||
"Could not sign the legacy private-media packet; nothing was sent"
|
||||
} else {
|
||||
"Could not sign the encrypted private-media packet; nothing was sent"
|
||||
}
|
||||
)
|
||||
if (finalized.signature?.size != 64) {
|
||||
return PrivateMediaBuildOutcome.Rejected(
|
||||
"Could not produce a valid Ed25519 private-media signature; nothing was sent"
|
||||
)
|
||||
}
|
||||
|
||||
val maxPrivateFragments =
|
||||
com.bitchat.android.util.AppConstants.Fragmentation.MAX_FRAGMENTS_PER_ID
|
||||
val fragments = fragment(finalized, maxPrivateFragments)
|
||||
if (fragments.isEmpty()) {
|
||||
return PrivateMediaBuildOutcome.Rejected(
|
||||
"File exceeds the private-media v1 limit of 256 final mesh fragments"
|
||||
)
|
||||
}
|
||||
if (fragments.size > maxPrivateFragments) {
|
||||
return PrivateMediaBuildOutcome.Rejected(
|
||||
"File exceeds the private-media v1 limit of 256 final mesh fragments"
|
||||
)
|
||||
}
|
||||
|
||||
return PrivateMediaBuildOutcome.Ready(
|
||||
BuiltPrivateMediaTransfer(finalized, fragments, mode)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -71,15 +71,17 @@ class SecurityManager(private val encryptionService: EncryptionService, private
|
||||
Log.d(TAG, "Allowing duplicate ANNOUNCE from direct neighbor: $messageID")
|
||||
}
|
||||
|
||||
// Add to processed messages
|
||||
processedMessages.add(messageID)
|
||||
messageTimestamps[messageID] = currentTime
|
||||
|
||||
// Enforce mandatory signature verification
|
||||
if (!verifyPacketSignature(packet, peerID)) {
|
||||
Log.w(TAG, "Dropping packet from $peerID due to signature verification failure")
|
||||
return false
|
||||
}
|
||||
|
||||
// Record only authenticated packets. Recording an attacker-controlled
|
||||
// invalid packet first would let it poison duplicate detection for a
|
||||
// later legitimate packet with the same timestamp and payload.
|
||||
processedMessages.add(messageID)
|
||||
messageTimestamps[messageID] = currentTime
|
||||
|
||||
Log.d(TAG, "Packet validation passed for $peerID, messageID: $messageID")
|
||||
return true
|
||||
@@ -154,21 +156,13 @@ class SecurityManager(private val encryptionService: EncryptionService, private
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify packet signature
|
||||
* Verify a packet signature against the signing key learned from the
|
||||
* peer's verified announcement. Signatures cover the canonical packet,
|
||||
* not only its payload; otherwise routing and recipient fields could be
|
||||
* changed without invalidating the signature.
|
||||
*/
|
||||
fun verifySignature(packet: BitchatPacket, peerID: String): Boolean {
|
||||
return packet.signature?.let { signature ->
|
||||
try {
|
||||
val isValid = encryptionService.verify(signature, packet.payload, peerID)
|
||||
if (!isValid) {
|
||||
Log.w(TAG, "Invalid signature for packet from $peerID")
|
||||
}
|
||||
isValid
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to verify signature from $peerID: ${e.message}")
|
||||
false
|
||||
}
|
||||
} ?: true // No signature means verification passes
|
||||
return verifyPacketSignature(packet, peerID)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -129,6 +129,41 @@ class UnifiedMeshService(
|
||||
}
|
||||
}
|
||||
|
||||
override fun prepareFilePrivate(
|
||||
recipientPeerID: String,
|
||||
file: BitchatFilePacket,
|
||||
transferId: String,
|
||||
allowLegacyFallback: Boolean
|
||||
): PrivateMediaPreparation {
|
||||
return when {
|
||||
isBleReady(recipientPeerID) -> bluetooth.prepareFilePrivate(
|
||||
recipientPeerID,
|
||||
file,
|
||||
transferId,
|
||||
allowLegacyFallback
|
||||
)
|
||||
isWifiReady(recipientPeerID) -> wifiService()?.prepareFilePrivate(
|
||||
recipientPeerID,
|
||||
file,
|
||||
transferId,
|
||||
allowLegacyFallback
|
||||
) ?: PrivateMediaPreparation.Rejected("Wi-Fi Aware transport is unavailable")
|
||||
isBleConnected(recipientPeerID) || (isBleEnabled() && !isWifiConnected(recipientPeerID)) ->
|
||||
bluetooth.prepareFilePrivate(
|
||||
recipientPeerID,
|
||||
file,
|
||||
transferId,
|
||||
allowLegacyFallback
|
||||
)
|
||||
else -> wifiService()?.prepareFilePrivate(
|
||||
recipientPeerID,
|
||||
file,
|
||||
transferId,
|
||||
allowLegacyFallback
|
||||
) ?: PrivateMediaPreparation.Rejected("No local transport is available for this peer")
|
||||
}
|
||||
}
|
||||
|
||||
override fun cancelFileTransfer(transferId: String): Boolean {
|
||||
val bleCancelled = try { bluetooth.cancelFileTransfer(transferId) } catch (_: Exception) { false }
|
||||
val wifiCancelled = try { wifiService()?.cancelFileTransfer(transferId) == true } catch (_: Exception) { false }
|
||||
|
||||
Reference in New Issue
Block a user