This commit is contained in:
CC
2026-06-10 14:14:28 +02:00
parent 2bd2751a6a
commit fc38a8c6b6
57 changed files with 1157 additions and 621 deletions
@@ -3,6 +3,7 @@ package com.bitchat.android.favorites
import android.content.Context
import android.util.Log
import com.bitchat.android.identity.SecureIdentityStateManager
import com.bitchat.android.util.toHexString
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import java.util.*
@@ -95,7 +96,7 @@ class FavoritesPersistenceService private constructor(private val context: Conte
/** Get favorite status for Noise public key */
fun getFavoriteStatus(noisePublicKey: ByteArray): FavoriteRelationship? {
val keyHex = noisePublicKey.joinToString("") { "%02x".format(it) }
val keyHex = noisePublicKey.toHexString()
return favorites[keyHex]
}
@@ -103,7 +104,7 @@ class FavoritesPersistenceService private constructor(private val context: Conte
fun getFavoriteStatus(peerID: String): FavoriteRelationship? {
val pid = peerID.lowercase()
for ((_, relationship) in favorites) {
val noiseKeyHex = relationship.peerNoisePublicKey.joinToString("") { "%02x".format(it) }
val noiseKeyHex = relationship.peerNoisePublicKey.toHexString()
if (noiseKeyHex.startsWith(pid)) return relationship
}
return null
@@ -111,7 +112,7 @@ class FavoritesPersistenceService private constructor(private val context: Conte
/** Update Nostr public key for a peer (indexed by Noise key) */
fun updateNostrPublicKey(noisePublicKey: ByteArray, nostrPubkey: String) {
val keyHex = noisePublicKey.joinToString("") { "%02x".format(it) }
val keyHex = noisePublicKey.toHexString()
val existing = favorites[keyHex]
if (existing != null) {
@@ -168,7 +169,7 @@ class FavoritesPersistenceService private constructor(private val context: Conte
// Find relationship with matching nostr pubkey (normalized to hex) and then try to map to current peerID via noise key prefix
val rel = favorites.values.firstOrNull { it.peerNostrPublicKey?.let { stored -> normalizeNostrKeyToHex(stored) } == targetHex }
if (rel != null) {
val noiseHex = rel.peerNoisePublicKey.joinToString("") { "%02x".format(it) }
val noiseHex = rel.peerNoisePublicKey.toHexString()
// Return 16-hex prefix as best-effort if no explicit mapping exists
return noiseHex.take(16)
}
@@ -178,7 +179,7 @@ class FavoritesPersistenceService private constructor(private val context: Conte
/** Update favorite status */
fun updateFavoriteStatus(noisePublicKey: ByteArray, nickname: String, isFavorite: Boolean) {
val keyHex = noisePublicKey.joinToString("") { "%02x".format(it) }
val keyHex = noisePublicKey.toHexString()
val existing = favorites[keyHex]
@@ -210,7 +211,7 @@ class FavoritesPersistenceService private constructor(private val context: Conte
/** Update peer favorited-us flag */
fun updatePeerFavoritedUs(noisePublicKey: ByteArray, theyFavoritedUs: Boolean) {
val keyHex = noisePublicKey.joinToString("") { "%02x".format(it) }
val keyHex = noisePublicKey.toHexString()
val existing = favorites[keyHex]
if (existing != null) {
@@ -248,7 +249,7 @@ class FavoritesPersistenceService private constructor(private val context: Conte
/** Find Nostr pubkey by Noise key */
fun findNostrPubkey(forNoiseKey: ByteArray): String? {
val keyHex = forNoiseKey.joinToString("") { "%02x".format(it) }
val keyHex = forNoiseKey.toHexString()
return favorites[keyHex]?.peerNostrPublicKey
}
@@ -330,7 +331,7 @@ class FavoritesPersistenceService private constructor(private val context: Conte
private fun normalizeNostrKeyToHex(value: String): String? = try {
if (value.startsWith("npub1")) {
val (hrp, data) = com.bitchat.android.nostr.Bech32.decode(value)
if (hrp != "npub") null else data.joinToString("") { "%02x".format(it) }
if (hrp != "npub") null else data.toHexString()
} else value.lowercase()
} catch (_: Exception) { null }
}
@@ -348,7 +349,7 @@ private data class FavoriteRelationshipData(
companion object {
fun fromFavoriteRelationship(relationship: FavoriteRelationship): FavoriteRelationshipData {
return FavoriteRelationshipData(
peerNoisePublicKeyHex = relationship.peerNoisePublicKey.joinToString("") { "%02x".format(it) },
peerNoisePublicKeyHex = relationship.peerNoisePublicKey.toHexString(),
peerNostrPublicKey = relationship.peerNostrPublicKey,
peerNickname = relationship.peerNickname,
isFavorite = relationship.isFavorite,
@@ -4,10 +4,9 @@ import android.content.Context
import android.content.SharedPreferences
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import java.security.MessageDigest
import android.util.Base64
import android.util.Log
import com.bitchat.android.util.hexEncodedString
import com.bitchat.android.util.Hashing
import androidx.core.content.edit
/**
@@ -175,9 +174,7 @@ class SecureIdentityStateManager(private val context: Context) {
* Generate fingerprint from public key (SHA-256 hash)
*/
fun generateFingerprint(publicKeyData: ByteArray): String {
val digest = MessageDigest.getInstance("SHA-256")
val hash = digest.digest(publicKeyData)
return hash.hexEncodedString()
return Hashing.sha256Hex(publicKeyData)
}
/**
@@ -10,6 +10,7 @@ import android.os.ParcelUuid
import android.util.Log
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.util.AppConstants
import com.bitchat.android.util.PeerId
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@@ -323,7 +324,7 @@ class BluetoothGattClientManager(
// Try to extract peerID from Service Data (if available) for stable identity
val serviceData = scanRecord?.getServiceData(ParcelUuid(AppConstants.Mesh.Gatt.SERVICE_UUID))
val peerID = if (serviceData != null && serviceData.size >= 8) {
serviceData.joinToString("") { "%02x".format(it) }
PeerId.fromBytes(serviceData)
} else {
null
}
@@ -515,7 +516,7 @@ class BluetoothGattClientManager(
Log.i(TAG, "Client: Received packet from ${gatt.device.address}, size: ${value.size} bytes")
val packet = BitchatPacket.fromBinaryData(value)
if (packet != null) {
val peerID = packet.senderID.take(8).toByteArray().joinToString("") { "%02x".format(it) }
val peerID = PeerId.fromBytes(packet.senderID)
Log.d(TAG, "Client: Parsed packet type ${packet.type} from $peerID")
delegate?.onPacketReceived(packet, peerID, gatt.device)
} else {
@@ -10,6 +10,7 @@ import android.os.ParcelUuid
import android.util.Log
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.util.AppConstants
import com.bitchat.android.util.PeerId
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@@ -223,7 +224,7 @@ class BluetoothGattServerManager(
Log.i(TAG, "Server: Received packet from ${device.address}, size: ${value.size} bytes")
val packet = BitchatPacket.fromBinaryData(value)
if (packet != null) {
val peerID = packet.senderID.take(8).toByteArray().joinToString("") { "%02x".format(it) }
val peerID = PeerId.fromBytes(packet.senderID)
Log.d(TAG, "Server: Parsed packet type ${packet.type} from $peerID")
delegate?.onPacketReceived(packet, peerID, device)
} else {
@@ -377,7 +378,7 @@ class BluetoothGattServerManager(
val mode = try {
powerManager.getPowerInfo().split("Current Mode: ")[1].split("\n")[0]
} catch (_: Exception) { "unknown" }
Log.i(TAG, "Advertising started (power mode: $mode) with stable ID: ${peerIDBytes.joinToString("") { "%02x".format(it) }}")
Log.i(TAG, "Advertising started (power mode: $mode) with stable ID: ${PeerId.fromBytes(peerIDBytes)}")
}
override fun onStartFailure(errorCode: Int) {
@@ -14,6 +14,8 @@ 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.Hashing
import com.bitchat.android.util.PeerId
import com.bitchat.android.util.toHexString
import com.bitchat.android.services.VerificationService
import kotlinx.coroutines.*
@@ -184,8 +186,8 @@ class BluetoothMeshService(private val context: Context) {
val responsePacket = BitchatPacket(
version = 1u,
type = MessageType.NOISE_HANDSHAKE.value,
senderID = hexStringToByteArray(myPeerID),
recipientID = hexStringToByteArray(peerID),
senderID = PeerId.toBytes(myPeerID),
recipientID = PeerId.toBytes(peerID),
timestamp = System.currentTimeMillis().toULong(),
payload = response,
ttl = MAX_TTL
@@ -296,8 +298,8 @@ class BluetoothMeshService(private val context: Context) {
val packet = BitchatPacket(
version = 1u,
type = MessageType.NOISE_HANDSHAKE.value,
senderID = hexStringToByteArray(myPeerID),
recipientID = hexStringToByteArray(peerID),
senderID = PeerId.toBytes(myPeerID),
recipientID = PeerId.toBytes(peerID),
timestamp = System.currentTimeMillis().toULong(),
payload = handshakeData,
ttl = MAX_TTL
@@ -662,7 +664,7 @@ class BluetoothMeshService(private val context: Context) {
val packet = BitchatPacket(
version = 1u,
type = MessageType.MESSAGE.value,
senderID = hexStringToByteArray(myPeerID),
senderID = PeerId.toBytes(myPeerID),
recipientID = SpecialRecipients.BROADCAST,
timestamp = System.currentTimeMillis().toULong(),
payload = content.toByteArray(Charsets.UTF_8),
@@ -692,7 +694,7 @@ class BluetoothMeshService(private val context: Context) {
val packet = BitchatPacket(
version = 2u, // FILE_TRANSFER uses v2 for 4-byte payload length to support large files
type = MessageType.FILE_TRANSFER.value,
senderID = hexStringToByteArray(myPeerID),
senderID = PeerId.toBytes(myPeerID),
recipientID = SpecialRecipients.BROADCAST,
timestamp = System.currentTimeMillis().toULong(),
payload = payload,
@@ -701,7 +703,7 @@ class BluetoothMeshService(private val context: Context) {
)
val signed = signPacketBeforeBroadcast(packet)
// Use a stable transferId based on the file TLV payload for progress tracking
val transferId = sha256Hex(payload)
val transferId = Hashing.sha256Hex(payload)
connectionManager.broadcastPacket(RoutedPacket(signed, transferId = transferId))
try { gossipSyncManager.onPublicPacketSeen(signed) } catch (_: Exception) { }
}
@@ -744,8 +746,8 @@ class BluetoothMeshService(private val context: Context) {
val packet = BitchatPacket(
version = 1u,
type = MessageType.NOISE_ENCRYPTED.value,
senderID = hexStringToByteArray(myPeerID),
recipientID = hexStringToByteArray(recipientPeerID),
senderID = PeerId.toBytes(myPeerID),
recipientID = PeerId.toBytes(recipientPeerID),
timestamp = System.currentTimeMillis().toULong(),
payload = encrypted,
signature = null,
@@ -755,7 +757,7 @@ class BluetoothMeshService(private val context: Context) {
// 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)
val transferId = Hashing.sha256Hex(filePayload)
connectionManager.broadcastPacket(RoutedPacket(signed, transferId = transferId))
} catch (e: Exception) {
@@ -777,13 +779,6 @@ class BluetoothMeshService(private val context: Context) {
return connectionManager.cancelTransfer(transferId)
}
// Local helper to hash payloads to a stable hex ID for progress mapping
private fun sha256Hex(bytes: ByteArray): String = try {
val md = java.security.MessageDigest.getInstance("SHA-256")
md.update(bytes)
md.digest().joinToString("") { "%02x".format(it) }
} catch (_: Exception) { bytes.size.toString(16) }
/**
* Send private message - SIMPLIFIED iOS-compatible version
* Uses NoisePayloadType system exactly like iOS SimplifiedBluetoothService
@@ -823,8 +818,8 @@ class BluetoothMeshService(private val context: Context) {
val packet = BitchatPacket(
version = 1u,
type = MessageType.NOISE_ENCRYPTED.value,
senderID = hexStringToByteArray(myPeerID),
recipientID = hexStringToByteArray(recipientPeerID),
senderID = PeerId.toBytes(myPeerID),
recipientID = PeerId.toBytes(recipientPeerID),
timestamp = System.currentTimeMillis().toULong(),
payload = encrypted,
signature = null,
@@ -889,8 +884,8 @@ class BluetoothMeshService(private val context: Context) {
val packet = BitchatPacket(
version = 1u,
type = MessageType.NOISE_ENCRYPTED.value,
senderID = hexStringToByteArray(myPeerID),
recipientID = hexStringToByteArray(recipientPeerID),
senderID = PeerId.toBytes(myPeerID),
recipientID = PeerId.toBytes(recipientPeerID),
timestamp = System.currentTimeMillis().toULong(),
payload = encrypted,
signature = null,
@@ -937,8 +932,8 @@ class BluetoothMeshService(private val context: Context) {
val packet = BitchatPacket(
version = 1u,
type = MessageType.NOISE_ENCRYPTED.value,
senderID = hexStringToByteArray(myPeerID),
recipientID = hexStringToByteArray(recipientPeerID),
senderID = PeerId.toBytes(myPeerID),
recipientID = PeerId.toBytes(recipientPeerID),
timestamp = System.currentTimeMillis().toULong(),
payload = encrypted,
signature = null,
@@ -1247,27 +1242,6 @@ class BluetoothMeshService(private val context: Context) {
}
}
/**
* Convert hex string peer ID to binary data (8 bytes) - exactly same as iOS
*/
private fun hexStringToByteArray(hexString: String): ByteArray {
val result = ByteArray(8) { 0 } // Initialize with zeros, exactly 8 bytes
var tempID = hexString
var index = 0
while (tempID.length >= 2 && index < 8) {
val hexByte = tempID.substring(0, 2)
val byte = hexByte.toIntOrNull(16)?.toByte()
if (byte != null) {
result[index] = byte
}
tempID = tempID.substring(2)
index++
}
return result
}
/**
* Sign packet before broadcasting using our signing private key
*/
@@ -1277,12 +1251,12 @@ class BluetoothMeshService(private val context: Context) {
val withRoute = try {
val rec = packet.recipientID
if (rec != null && !rec.contentEquals(SpecialRecipients.BROADCAST)) {
val dest = rec.joinToString("") { b -> "%02x".format(b) }
val dest = rec.toHexString()
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) }
val hopsBytes = intermediates.map { PeerId.toBytes(it) }
// Attach route and upgrade to v2 (required for HAS_ROUTE flag)
packet.copy(route = hopsBytes, version = 2u)
} else packet.copy(route = null)
@@ -9,6 +9,7 @@ import android.util.Log
import com.bitchat.android.protocol.SpecialRecipients
import com.bitchat.android.model.RoutedPacket
import com.bitchat.android.protocol.MessageType
import com.bitchat.android.util.Hashing
import com.bitchat.android.util.toHexString
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -122,7 +123,7 @@ class BluetoothPacketBroadcaster(
val packet = routed.packet
val isFile = packet.type == MessageType.FILE_TRANSFER.value
// Prefer caller-provided transferId (e.g., for encrypted media), else derive for FILE_TRANSFER
val transferId = routed.transferId ?: (if (isFile) sha256Hex(packet.payload) else null)
val transferId = routed.transferId ?: (if (isFile) Hashing.sha256Hex(packet.payload) else null)
// Check if we need to fragment
if (fragmentManager != null) {
val fragments = try {
@@ -193,7 +194,7 @@ class BluetoothPacketBroadcaster(
val data = packet.toBinaryData() ?: return false
val isFile = packet.type == MessageType.FILE_TRANSFER.value
// Prefer caller-provided transferId (e.g., for encrypted media), else derive for FILE_TRANSFER
val transferId = routed.transferId ?: (if (isFile) sha256Hex(packet.payload) else null)
val transferId = routed.transferId ?: (if (isFile) Hashing.sha256Hex(packet.payload) else null)
if (transferId != null) {
TransferProgressManager.start(transferId, 1)
}
@@ -236,13 +237,6 @@ class BluetoothPacketBroadcaster(
return false
}
private fun sha256Hex(bytes: ByteArray): String = try {
val md = java.security.MessageDigest.getInstance("SHA-256")
md.update(bytes)
md.digest().joinToString("") { "%02x".format(it) }
} catch (_: Exception) { bytes.size.toString(16) }
/**
* Public entry point for broadcasting - submits request to actor for serialization
*/
@@ -8,6 +8,7 @@ import com.bitchat.android.model.RoutedPacket
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.protocol.MessageType
import com.bitchat.android.sync.PacketIdUtil
import com.bitchat.android.util.PeerId
import com.bitchat.android.util.toHexString
import kotlinx.coroutines.*
import java.util.*
@@ -181,8 +182,8 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
val packet = BitchatPacket(
version = 1u,
type = MessageType.NOISE_ENCRYPTED.value,
senderID = hexStringToByteArray(myPeerID),
recipientID = hexStringToByteArray(senderPeerID),
senderID = PeerId.toBytes(myPeerID),
recipientID = PeerId.toBytes(senderPeerID),
timestamp = System.currentTimeMillis().toULong(),
payload = encryptedPayload,
signature = null,
@@ -302,8 +303,8 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
val responsePacket = BitchatPacket(
version = 1u,
type = MessageType.NOISE_HANDSHAKE.value,
senderID = hexStringToByteArray(myPeerID),
recipientID = hexStringToByteArray(peerID),
senderID = PeerId.toBytes(myPeerID),
recipientID = PeerId.toBytes(peerID),
timestamp = System.currentTimeMillis().toULong(),
payload = response,
signature = null,
@@ -468,27 +469,6 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
}
}
/**
* Convert hex string peer ID to binary data (8 bytes) - same as iOS implementation
*/
private fun hexStringToByteArray(hexString: String): ByteArray {
val result = ByteArray(8) { 0 } // Initialize with zeros, exactly 8 bytes
var tempID = hexString
var index = 0
while (tempID.length >= 2 && index < 8) {
val hexByte = tempID.substring(0, 2)
val byte = hexByte.toIntOrNull(16)?.toByte()
if (byte != null) {
result[index] = byte
}
tempID = tempID.substring(2)
index++
}
return result
}
/**
* Shutdown the handler
*/
@@ -4,6 +4,7 @@ import com.bitchat.android.protocol.MessageType
import android.util.Log
import com.bitchat.android.model.RoutedPacket
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.util.PeerId
import com.bitchat.android.util.toHexString
import kotlinx.coroutines.*
import kotlin.random.Random
@@ -66,7 +67,7 @@ class PacketRelayManager(private val myPeerID: String) {
Log.w(TAG, "Packet with duplicate hops dropped")
return
}
val myIdBytes = hexStringToPeerBytes(myPeerID)
val myIdBytes = PeerId.toBytes(myPeerID)
val index = route.indexOfFirst { it.contentEquals(myIdBytes) }
if (index >= 0) {
val nextHopIdHex: String? = run {
@@ -186,15 +187,3 @@ interface PacketRelayManagerDelegate {
fun broadcastPacket(routed: RoutedPacket)
fun sendToPeer(peerID: String, routed: RoutedPacket): Boolean
}
private fun hexStringToPeerBytes(hex: String): ByteArray {
val result = ByteArray(8)
var idx = 0
var out = 0
while (idx + 1 < hex.length && out < 8) {
val b = hex.substring(idx, idx + 2).toIntOrNull(16)?.toByte() ?: 0
result[out++] = b
idx += 2
}
return result
}
@@ -1,7 +1,7 @@
package com.bitchat.android.mesh
import android.util.Log
import java.security.MessageDigest
import com.bitchat.android.util.Hashing
import java.util.concurrent.ConcurrentHashMap
/**
@@ -204,9 +204,7 @@ class PeerFingerprintManager private constructor() {
* @return The hex-encoded SHA-256 hash
*/
private fun calculateFingerprint(publicKey: ByteArray): String {
val digest = MessageDigest.getInstance("SHA-256")
val hash = digest.digest(publicKey)
return hash.joinToString("") { "%02x".format(it) }
return Hashing.sha256Hex(publicKey)
}
/**
@@ -1,5 +1,9 @@
package com.bitchat.android.model
import com.bitchat.android.protocol.TlvLengthSize
import com.bitchat.android.protocol.TlvReader
import com.bitchat.android.protocol.TlvWriter
import com.bitchat.android.protocol.UnknownTlvPolicy
import java.nio.ByteBuffer
import java.nio.ByteOrder
@@ -7,16 +11,13 @@ import java.nio.ByteOrder
* BitchatFilePacket: TLV-encoded file transfer payload for BLE mesh.
* TLVs:
* - 0x01: filename (UTF-8)
* - 0x02: file size (8 bytes, UInt64)
* - 0x02: file size (4 bytes, UInt32)
* - 0x03: mime type (UTF-8)
* - 0x04: content (bytes) — may appear multiple times for large files
* - 0x04: content (bytes)
*
* Length field for TLV is 2 bytes (UInt16, big-endian) for all TLVs.
* For large files, CONTENT is chunked into multiple TLVs of up to 65535 bytes each.
*
* Note: The outer BitchatPacket uses version 2 (4-byte payload length), so this
* TLV payload can exceed 64 KiB even though each TLV value is limited to 65535 bytes.
* Transport-level fragmentation then splits the final packet for BLE MTU.
* FILE_NAME, FILE_SIZE, and MIME_TYPE use 2-byte big-endian lengths.
* CONTENT uses a 4-byte big-endian length so the outer v2 packet can carry
* payloads larger than 64 KiB before transport fragmentation.
*/
data class BitchatFilePacket(
val fileName: String,
@@ -30,87 +31,53 @@ data class BitchatFilePacket(
}
fun encode(): ByteArray? {
try {
android.util.Log.d("BitchatFilePacket", "🔄 Encoding: name=$fileName, size=$fileSize, mime=$mimeType")
val nameBytes = fileName.toByteArray(Charsets.UTF_8)
val mimeBytes = mimeType.toByteArray(Charsets.UTF_8)
// Validate bounds for 2-byte TLV lengths (per-TLV). CONTENT may exceed 65535 and will be chunked.
if (nameBytes.size > 0xFFFF || mimeBytes.size > 0xFFFF) {
android.util.Log.e("BitchatFilePacket", "❌ TLV field too large: name=${nameBytes.size}, mime=${mimeBytes.size} (max: 65535)")
return try {
val nameBytes = fileName.toByteArray(Charsets.UTF_8)
val mimeBytes = mimeType.toByteArray(Charsets.UTF_8)
if (nameBytes.size > 0xFFFF || mimeBytes.size > 0xFFFF) {
return null
}
if (content.size > 0xFFFF) {
android.util.Log.d("BitchatFilePacket", "📦 Content exceeds 65535 bytes (${content.size}); will be split into multiple CONTENT TLVs")
} else {
android.util.Log.d("BitchatFilePacket", "📏 TLV sizes OK: name=${nameBytes.size}, mime=${mimeBytes.size}, content=${content.size}")
}
val sizeFieldLen = 4 // UInt32 for FILE_SIZE (changed from 8 bytes)
val contentLenFieldLen = 4 // UInt32 for CONTENT TLV as requested
// Compute capacity: header TLVs + single CONTENT TLV with 4-byte length
val contentTLVBytes = 1 + contentLenFieldLen + content.size
val capacity = (1 + 2 + nameBytes.size) + (1 + 2 + sizeFieldLen) + (1 + 2 + mimeBytes.size) + contentTLVBytes
val buf = ByteBuffer.allocate(capacity).order(ByteOrder.BIG_ENDIAN)
val sizeBytes = ByteBuffer.allocate(4)
.order(ByteOrder.BIG_ENDIAN)
.putInt(fileSize.toInt())
.array()
// FILE_NAME
buf.put(TLVType.FILE_NAME.v.toByte())
buf.putShort(nameBytes.size.toShort())
buf.put(nameBytes)
// FILE_SIZE (4 bytes)
buf.put(TLVType.FILE_SIZE.v.toByte())
buf.putShort(sizeFieldLen.toShort())
buf.putInt(fileSize.toInt())
// MIME_TYPE
buf.put(TLVType.MIME_TYPE.v.toByte())
buf.putShort(mimeBytes.size.toShort())
buf.put(mimeBytes)
// CONTENT (single TLV with 4-byte length)
buf.put(TLVType.CONTENT.v.toByte())
buf.putInt(content.size)
buf.put(content)
val result = buf.array()
android.util.Log.d("BitchatFilePacket", "✅ Encoded successfully: ${result.size} bytes total")
return result
TlvWriter()
.put(TLVType.FILE_NAME.v.toInt(), nameBytes, TlvLengthSize.TWO_BYTES)
.put(TLVType.FILE_SIZE.v.toInt(), sizeBytes, TlvLengthSize.TWO_BYTES)
.put(TLVType.MIME_TYPE.v.toInt(), mimeBytes, TlvLengthSize.TWO_BYTES)
.put(TLVType.CONTENT.v.toInt(), content, TlvLengthSize.FOUR_BYTES)
.toByteArray()
} catch (e: Exception) {
android.util.Log.e("BitchatFilePacket", "❌ Encoding failed: ${e.message}", e)
return null
null
}
}
companion object {
fun decode(data: ByteArray): BitchatFilePacket? {
android.util.Log.d("BitchatFilePacket", "🔄 Decoding ${data.size} bytes")
try {
var off = 0
val knownTypes = TLVType.values().map { it.v.toInt() }.toSet()
val fields = TlvReader.decode(
data = data,
defaultLengthSize = TlvLengthSize.TWO_BYTES,
unknownPolicy = UnknownTlvPolicy.FAIL,
knownTypes = knownTypes
) { type ->
if (type == TLVType.CONTENT.v.toInt()) TlvLengthSize.FOUR_BYTES else TlvLengthSize.TWO_BYTES
} ?: return null
var name: String? = null
var size: Long? = null
var mime: String? = null
var contentBytes: ByteArray? = null
while (off + 3 <= data.size) { // minimum TLV header size (type + 2 bytes length)
val t = TLVType.from(data[off].toUByte()) ?: return null
off += 1
// CONTENT uses 4-byte length; others use 2-byte length
val len: Int
if (t == TLVType.CONTENT) {
if (off + 4 > data.size) return null
len = ((data[off].toInt() and 0xFF) shl 24) or ((data[off + 1].toInt() and 0xFF) shl 16) or ((data[off + 2].toInt() and 0xFF) shl 8) or (data[off + 3].toInt() and 0xFF)
off += 4
} else {
if (off + 2 > data.size) return null
len = ((data[off].toInt() and 0xFF) shl 8) or (data[off + 1].toInt() and 0xFF)
off += 2
}
if (len < 0 || off + len > data.size) return null
val value = data.copyOfRange(off, off + len)
off += len
for (field in fields) {
val t = TLVType.from(field.type.toUByte()) ?: return null
val value = field.value
when (t) {
TLVType.FILE_NAME -> name = String(value, Charsets.UTF_8)
TLVType.FILE_SIZE -> {
if (len != 4) return null
if (value.size != 4) return null
val bb = ByteBuffer.wrap(value).order(ByteOrder.BIG_ENDIAN)
size = bb.int.toLong()
}
@@ -128,14 +95,10 @@ data class BitchatFilePacket(
val c = contentBytes ?: return null
val s = size ?: c.size.toLong()
val m = mime ?: "application/octet-stream"
val result = BitchatFilePacket(n, s, m, c)
android.util.Log.d("BitchatFilePacket", "✅ Decoded: name=$n, size=$s, mime=$m, content=${c.size} bytes")
return result
return BitchatFilePacket(n, s, m, c)
} catch (e: Exception) {
android.util.Log.e("BitchatFilePacket", "❌ Decoding failed: ${e.message}", e)
return null
}
}
}
}
@@ -1,6 +1,7 @@
package com.bitchat.android.model
import com.bitchat.android.protocol.MessageType
import com.bitchat.android.util.toHexString
/**
* FragmentPayload - 100% iOS-compatible fragment payload structure
@@ -111,7 +112,7 @@ data class FragmentPayload(
* Get fragment ID as hex string for logging/debugging
*/
fun getFragmentIDString(): String {
return fragmentID.joinToString("") { "%02x".format(it) }
return fragmentID.toHexString()
}
/**
@@ -1,8 +1,12 @@
package com.bitchat.android.model
import android.os.Parcelable
import com.bitchat.android.protocol.TlvLengthSize
import com.bitchat.android.protocol.TlvReader
import com.bitchat.android.protocol.TlvWriter
import com.bitchat.android.protocol.UnknownTlvPolicy
import com.bitchat.android.util.toHexString
import kotlinx.parcelize.Parcelize
import com.bitchat.android.util.*
/**
* Identity announcement structure with TLV encoding
@@ -36,29 +40,15 @@ data class IdentityAnnouncement(
fun encode(): ByteArray? {
val nicknameData = nickname.toByteArray(Charsets.UTF_8)
// Check size limits
if (nicknameData.size > 255 || noisePublicKey.size > 255 || signingPublicKey.size > 255) {
return null
return try {
TlvWriter()
.put(TLVType.NICKNAME.value.toInt(), nicknameData, TlvLengthSize.ONE_BYTE)
.put(TLVType.NOISE_PUBLIC_KEY.value.toInt(), noisePublicKey, TlvLengthSize.ONE_BYTE)
.put(TLVType.SIGNING_PUBLIC_KEY.value.toInt(), signingPublicKey, TlvLengthSize.ONE_BYTE)
.toByteArray()
} catch (_: IllegalArgumentException) {
null
}
val result = mutableListOf<Byte>()
// TLV for nickname
result.add(TLVType.NICKNAME.value.toByte())
result.add(nicknameData.size.toByte())
result.addAll(nicknameData.toList())
// TLV for noise public key
result.add(TLVType.NOISE_PUBLIC_KEY.value.toByte())
result.add(noisePublicKey.size.toByte())
result.addAll(noisePublicKey.toList())
// TLV for signing public key
result.add(TLVType.SIGNING_PUBLIC_KEY.value.toByte())
result.add(signingPublicKey.size.toByte())
result.addAll(signingPublicKey.toList())
return result.toByteArray()
}
companion object {
@@ -66,45 +56,29 @@ data class IdentityAnnouncement(
* Decode from TLV binary data matching iOS implementation
*/
fun decode(data: ByteArray): IdentityAnnouncement? {
// Create defensive copy
val dataCopy = data.copyOf()
var offset = 0
var nickname: String? = null
var noisePublicKey: ByteArray? = null
var signingPublicKey: ByteArray? = null
while (offset + 2 <= dataCopy.size) {
// Read TLV type
val typeValue = dataCopy[offset].toUByte()
val type = TLVType.fromValue(typeValue)
offset += 1
// Read TLV length
val length = dataCopy[offset].toUByte().toInt()
offset += 1
// Check bounds
if (offset + length > dataCopy.size) return null
// Read TLV value
val value = dataCopy.sliceArray(offset until offset + length)
offset += length
// Process known TLV types, skip unknown ones for forward compatibility
val knownTypes = TLVType.values().map { it.value.toInt() }.toSet()
val fields = TlvReader.decode(
data = data,
defaultLengthSize = TlvLengthSize.ONE_BYTE,
unknownPolicy = UnknownTlvPolicy.SKIP,
knownTypes = knownTypes
) ?: return null
for (field in fields) {
val type = TLVType.fromValue(field.type.toUByte()) ?: continue
when (type) {
TLVType.NICKNAME -> {
nickname = String(value, Charsets.UTF_8)
nickname = String(field.value, Charsets.UTF_8)
}
TLVType.NOISE_PUBLIC_KEY -> {
noisePublicKey = value
noisePublicKey = field.value
}
TLVType.SIGNING_PUBLIC_KEY -> {
signingPublicKey = value
}
null -> {
// Unknown TLV; skip (tolerant decoder for forward compatibility)
continue
signingPublicKey = field.value
}
}
}
@@ -140,6 +114,6 @@ data class IdentityAnnouncement(
}
override fun toString(): String {
return "IdentityAnnouncement(nickname='$nickname', noisePublicKey=${noisePublicKey.joinToString("") { "%02x".format(it) }.take(16)}..., signingPublicKey=${signingPublicKey.joinToString("") { "%02x".format(it) }.take(16)}...)"
return "IdentityAnnouncement(nickname='$nickname', noisePublicKey=${noisePublicKey.toHexString().take(16)}..., signingPublicKey=${signingPublicKey.toHexString().take(16)}...)"
}
}
@@ -1,6 +1,10 @@
package com.bitchat.android.model
import android.os.Parcelable
import com.bitchat.android.protocol.TlvLengthSize
import com.bitchat.android.protocol.TlvReader
import com.bitchat.android.protocol.TlvWriter
import com.bitchat.android.protocol.UnknownTlvPolicy
import kotlinx.parcelize.Parcelize
/**
@@ -127,24 +131,14 @@ data class PrivateMessagePacket(
val messageIDData = messageID.toByteArray(Charsets.UTF_8)
val contentData = content.toByteArray(Charsets.UTF_8)
// Check size limits (TLV length field is 1 byte = max 255)
if (messageIDData.size > 255 || contentData.size > 255) {
return null
return try {
TlvWriter()
.put(TLVType.MESSAGE_ID.value.toInt(), messageIDData, TlvLengthSize.ONE_BYTE)
.put(TLVType.CONTENT.value.toInt(), contentData, TlvLengthSize.ONE_BYTE)
.toByteArray()
} catch (_: IllegalArgumentException) {
null
}
val result = mutableListOf<Byte>()
// TLV for messageID
result.add(TLVType.MESSAGE_ID.value.toByte())
result.add(messageIDData.size.toByte())
result.addAll(messageIDData.toList())
// TLV for content
result.add(TLVType.CONTENT.value.toByte())
result.add(contentData.size.toByte())
result.addAll(contentData.toList())
return result.toByteArray()
}
companion object {
@@ -152,33 +146,25 @@ data class PrivateMessagePacket(
* Decode from TLV binary data - exactly like iOS
*/
fun decode(data: ByteArray): PrivateMessagePacket? {
var offset = 0
var messageID: String? = null
var content: String? = null
while (offset + 2 <= data.size) {
// Read TLV type
val typeValue = data[offset].toUByte()
val type = TLVType.fromValue(typeValue) ?: return null
offset += 1
// Read TLV length
val length = data[offset].toUByte().toInt()
offset += 1
// Check bounds
if (offset + length > data.size) return null
// Read TLV value
val value = data.copyOfRange(offset, offset + length)
offset += length
val knownTypes = TLVType.values().map { it.value.toInt() }.toSet()
val fields = TlvReader.decode(
data = data,
defaultLengthSize = TlvLengthSize.ONE_BYTE,
unknownPolicy = UnknownTlvPolicy.FAIL,
knownTypes = knownTypes
) ?: return null
for (field in fields) {
val type = TLVType.fromValue(field.type.toUByte()) ?: return null
when (type) {
TLVType.MESSAGE_ID -> {
messageID = String(value, Charsets.UTF_8)
messageID = String(field.value, Charsets.UTF_8)
}
TLVType.CONTENT -> {
content = String(value, Charsets.UTF_8)
content = String(field.value, Charsets.UTF_8)
}
}
}
@@ -1,5 +1,9 @@
package com.bitchat.android.model
import com.bitchat.android.protocol.TlvLengthSize
import com.bitchat.android.protocol.TlvReader
import com.bitchat.android.protocol.TlvWriter
import com.bitchat.android.protocol.UnknownTlvPolicy
import com.bitchat.android.sync.SyncDefaults
/**
@@ -15,30 +19,21 @@ data class RequestSyncPacket(
val data: ByteArray
) {
fun encode(): ByteArray {
val out = ArrayList<Byte>()
fun putTLV(t: Int, v: ByteArray) {
out.add(t.toByte())
val len = v.size
out.add(((len ushr 8) and 0xFF).toByte())
out.add((len and 0xFF).toByte())
out.addAll(v.toList())
}
// P
putTLV(0x01, byteArrayOf(p.toByte()))
// M (uint32)
val m32 = m.coerceAtMost(0xffff_ffffL)
putTLV(
0x02,
byteArrayOf(
((m32 ushr 24) and 0xFF).toByte(),
((m32 ushr 16) and 0xFF).toByte(),
((m32 ushr 8) and 0xFF).toByte(),
(m32 and 0xFF).toByte()
return TlvWriter()
.put(0x01, byteArrayOf(p.toByte()), TlvLengthSize.TWO_BYTES)
.put(
0x02,
byteArrayOf(
((m32 ushr 24) and 0xFF).toByte(),
((m32 ushr 16) and 0xFF).toByte(),
((m32 ushr 8) and 0xFF).toByte(),
(m32 and 0xFF).toByte()
),
TlvLengthSize.TWO_BYTES
)
)
// data
putTLV(0x03, data)
return out.toByteArray()
.put(0x03, data, TlvLengthSize.TWO_BYTES)
.toByteArray()
}
companion object {
@@ -46,28 +41,30 @@ data class RequestSyncPacket(
const val MAX_ACCEPT_FILTER_BYTES: Int = SyncDefaults.MAX_ACCEPT_FILTER_BYTES
fun decode(data: ByteArray): RequestSyncPacket? {
var off = 0
var p: Int? = null
var m: Long? = null
var payload: ByteArray? = null
while (off + 3 <= data.size) {
val t = (data[off].toInt() and 0xFF); off += 1
val len = ((data[off].toInt() and 0xFF) shl 8) or (data[off+1].toInt() and 0xFF); off += 2
if (off + len > data.size) return null
val v = data.copyOfRange(off, off + len); off += len
when (t) {
0x01 -> if (len == 1) p = (v[0].toInt() and 0xFF)
0x02 -> if (len == 4) {
val mm = ((v[0].toLong() and 0xFF) shl 24) or
((v[1].toLong() and 0xFF) shl 16) or
((v[2].toLong() and 0xFF) shl 8) or
(v[3].toLong() and 0xFF)
val fields = TlvReader.decode(
data = data,
defaultLengthSize = TlvLengthSize.TWO_BYTES,
unknownPolicy = UnknownTlvPolicy.SKIP,
knownTypes = setOf(0x01, 0x02, 0x03)
) ?: return null
for (field in fields) {
when (field.type) {
0x01 -> if (field.value.size == 1) p = (field.value[0].toInt() and 0xFF)
0x02 -> if (field.value.size == 4) {
val mm = ((field.value[0].toLong() and 0xFF) shl 24) or
((field.value[1].toLong() and 0xFF) shl 16) or
((field.value[2].toLong() and 0xFF) shl 8) or
(field.value[3].toLong() and 0xFF)
m = mm
}
0x03 -> {
if (v.size > MAX_ACCEPT_FILTER_BYTES) return null
payload = v
if (field.value.size > MAX_ACCEPT_FILTER_BYTES) return null
payload = field.value
}
}
}
@@ -1,7 +1,7 @@
package com.bitchat.android.noise
import android.util.Log
import java.security.MessageDigest
import com.bitchat.android.util.Hashing
import java.util.concurrent.ConcurrentHashMap
import javax.crypto.Cipher
import javax.crypto.SecretKeyFactory
@@ -174,9 +174,7 @@ class NoiseChannelEncryption {
val key = channelKeys[channel] ?: return null
return try {
val digest = MessageDigest.getInstance("SHA-256")
val hash = digest.digest(key.encoded)
hash.joinToString("") { "%02x".format(it) }
Hashing.sha256Hex(key.encoded)
} catch (e: Exception) {
Log.e(TAG, "Failed to calculate key commitment: ${e.message}")
null
@@ -5,7 +5,7 @@ import android.util.Log
import com.bitchat.android.identity.SecureIdentityStateManager
import com.bitchat.android.mesh.PeerFingerprintManager
import com.bitchat.android.noise.southernstorm.protocol.Noise
import java.security.MessageDigest
import com.bitchat.android.util.Hashing
import java.security.SecureRandom
import java.util.concurrent.ConcurrentHashMap
@@ -133,9 +133,7 @@ class NoiseEncryptionService(private val context: Context) {
* Get our identity fingerprint (SHA-256 hash of static public key)
*/
fun getIdentityFingerprint(): String {
val digest = MessageDigest.getInstance("SHA-256")
val hash = digest.digest(staticIdentityPublicKey)
return hash.joinToString("") { "%02x".format(it) }
return Hashing.sha256Hex(staticIdentityPublicKey)
}
/**
@@ -387,9 +385,7 @@ class NoiseEncryptionService(private val context: Context) {
* Calculate fingerprint from public key (SHA-256 hash)
*/
private fun calculateFingerprint(publicKey: ByteArray): String {
val digest = MessageDigest.getInstance("SHA-256")
val hash = digest.digest(publicKey)
return hash.joinToString("") { "%02x".format(it) }
return Hashing.sha256Hex(publicKey)
}
// MARK: - Packet Signing/Verification
@@ -2,6 +2,8 @@ package com.bitchat.android.nostr
import android.content.Context
import android.util.Log
import com.bitchat.android.util.hexToByteArray
import com.bitchat.android.util.toHexString
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -1,5 +1,7 @@
package com.bitchat.android.nostr
import com.bitchat.android.util.hexToByteArray
import com.bitchat.android.util.toHexString
import org.bouncycastle.crypto.ec.CustomNamedCurves
import org.bouncycastle.crypto.params.ECDomainParameters
import org.bouncycastle.crypto.params.ECPrivateKeyParameters
@@ -6,6 +6,9 @@ import com.bitchat.android.model.PrivateMessagePacket
import com.bitchat.android.model.NoisePayloadType
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.protocol.MessageType
import com.bitchat.android.util.Hex
import com.bitchat.android.util.PeerId
import com.bitchat.android.util.toHexString
import java.util.*
/**
@@ -41,8 +44,8 @@ object NostrEmbeddedBitChat {
val packet = BitchatPacket(
version = 1u,
type = MessageType.NOISE_ENCRYPTED.value,
senderID = hexStringToByteArray(senderPeerID),
recipientID = hexStringToByteArray(recipientIDHex),
senderID = PeerId.toBytes(senderPeerID),
recipientID = PeerId.toBytes(recipientIDHex),
timestamp = System.currentTimeMillis().toULong(),
payload = payload,
signature = null,
@@ -81,8 +84,8 @@ object NostrEmbeddedBitChat {
val packet = BitchatPacket(
version = 1u,
type = MessageType.NOISE_ENCRYPTED.value,
senderID = hexStringToByteArray(senderPeerID),
recipientID = hexStringToByteArray(recipientIDHex),
senderID = PeerId.toBytes(senderPeerID),
recipientID = PeerId.toBytes(recipientIDHex),
timestamp = System.currentTimeMillis().toULong(),
payload = payload,
signature = null,
@@ -118,7 +121,7 @@ object NostrEmbeddedBitChat {
val packet = BitchatPacket(
version = 1u,
type = MessageType.NOISE_ENCRYPTED.value,
senderID = hexStringToByteArray(senderPeerID),
senderID = PeerId.toBytes(senderPeerID),
recipientID = null, // No recipient for geohash DMs
timestamp = System.currentTimeMillis().toULong(),
payload = payload,
@@ -153,7 +156,7 @@ object NostrEmbeddedBitChat {
val packet = BitchatPacket(
version = 1u,
type = MessageType.NOISE_ENCRYPTED.value,
senderID = hexStringToByteArray(senderPeerID),
senderID = PeerId.toBytes(senderPeerID),
recipientID = null, // No recipient for geohash DMs
timestamp = System.currentTimeMillis().toULong(),
payload = payload,
@@ -174,16 +177,16 @@ object NostrEmbeddedBitChat {
*/
private fun normalizeRecipientPeerID(recipientPeerID: String): String {
try {
val maybeData = hexStringToByteArray(recipientPeerID)
val maybeData = Hex.decode(recipientPeerID) ?: return recipientPeerID
return when (maybeData.size) {
32 -> {
// Treat as Noise static public key; derive peerID from fingerprint
// For now, return first 8 bytes as hex (simplified)
maybeData.take(8).joinToString("") { "%02x".format(it) }
maybeData.copyOfRange(0, 8).toHexString()
}
8 -> {
// Already an 8-byte peer ID
recipientPeerID
PeerId.fromBytes(maybeData)
}
else -> {
// Fallback: return as-is (expecting 16 hex chars)
@@ -207,28 +210,4 @@ object NostrEmbeddedBitChat {
.replace("=", "")
}
/**
* Convert hex string to byte array
*/
private fun hexStringToByteArray(hexString: String): ByteArray {
if (hexString.length % 2 != 0) {
return ByteArray(8) // Return 8-byte array filled with zeros
}
val result = ByteArray(8) { 0 } // Exactly 8 bytes like iOS
var tempID = hexString
var index = 0
while (tempID.length >= 2 && index < 8) {
val hexByte = tempID.substring(0, 2)
val byte = hexByte.toIntOrNull(16)?.toByte()
if (byte != null) {
result[index] = byte
}
tempID = tempID.substring(2)
index++
}
return result
}
}
@@ -3,7 +3,8 @@ package com.bitchat.android.nostr
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import com.google.gson.annotations.SerializedName
import java.security.MessageDigest
import com.bitchat.android.util.Hashing
import com.bitchat.android.util.toHexString
/**
* Nostr Event structure following NIP-01
@@ -134,13 +135,11 @@ data class NostrEvent(
val gson = GsonBuilder().disableHtmlEscaping().create()
val jsonString = gson.toJson(serialized)
// SHA256 hash of the JSON string
val digest = MessageDigest.getInstance("SHA-256")
val jsonBytes = jsonString.toByteArray(Charsets.UTF_8)
val hash = digest.digest(jsonBytes)
val hash = Hashing.sha256(jsonBytes)
// Convert to hex
val hexId = hash.joinToString("") { "%02x".format(it) }
val hexId = hash.toHexString()
return Pair(hexId, hash)
}
@@ -217,15 +216,3 @@ object NostrKind {
const val EPHEMERAL_EVENT = 20000 // For geohash channels
const val GEOHASH_PRESENCE = 20001 // For geohash presence heartbeat
}
/**
* Extension functions for hex encoding/decoding
*/
fun String.hexToByteArray(): ByteArray {
check(length % 2 == 0) { "Must have an even length" }
return chunked(2)
.map { it.toInt(16).toByte() }
.toByteArray()
}
fun ByteArray.toHexString(): String = joinToString("") { "%02x".format(it) }
@@ -3,7 +3,9 @@ package com.bitchat.android.nostr
import android.content.Context
import android.util.Log
import com.bitchat.android.identity.SecureIdentityStateManager
import java.security.MessageDigest
import com.bitchat.android.util.Hashing
import com.bitchat.android.util.hexToByteArray
import com.bitchat.android.util.toHexString
import java.security.SecureRandom
/**
@@ -25,7 +27,7 @@ data class NostrIdentity(
*/
fun generate(): NostrIdentity {
val (privateKeyHex, publicKeyHex) = NostrCrypto.generateKeyPair()
val npub = Bech32.encode("npub", publicKeyHex.hexToByteArrayLocal())
val npub = Bech32.encode("npub", publicKeyHex.hexToByteArray())
Log.d(TAG, "Generated new Nostr identity: npub=$npub")
@@ -46,7 +48,7 @@ data class NostrIdentity(
}
val publicKeyHex = NostrCrypto.derivePublicKey(privateKeyHex)
val npub = Bech32.encode("npub", publicKeyHex.hexToByteArrayLocal())
val npub = Bech32.encode("npub", publicKeyHex.hexToByteArray())
return NostrIdentity(
privateKeyHex = privateKeyHex,
@@ -61,10 +63,8 @@ data class NostrIdentity(
*/
fun fromSeed(seed: String): NostrIdentity {
// Hash the seed to create a private key
val digest = MessageDigest.getInstance("SHA-256")
val seedBytes = seed.toByteArray(Charsets.UTF_8)
val privateKeyBytes = digest.digest(seedBytes)
val privateKeyHex = privateKeyBytes.joinToString("") { "%02x".format(it) }
val privateKeyHex = Hashing.sha256Hex(seedBytes)
return fromPrivateKey(privateKeyHex)
}
@@ -149,7 +149,7 @@ object NostrIdentityBridge {
// Try a few iterations to ensure a valid key can be formed (exactly like iOS)
for (i in 0 until 10) {
val candidateKey = candidateKey(seed, geohashBytes, i.toUInt())
val candidateKeyHex = candidateKey.toHexStringLocal()
val candidateKeyHex = candidateKey.toHexString()
if (NostrCrypto.isValidPrivateKey(candidateKeyHex)) {
val identity = NostrIdentity.fromPrivateKey(candidateKeyHex)
@@ -164,10 +164,9 @@ object NostrIdentityBridge {
// As a final fallback, hash the seed+msg and try again (exactly like iOS)
val combined = seed + geohashBytes
val digest = MessageDigest.getInstance("SHA-256")
val fallbackKey = digest.digest(combined)
val fallbackKey = Hashing.sha256(combined)
val fallbackIdentity = NostrIdentity.fromPrivateKey(fallbackKey.toHexStringLocal())
val fallbackIdentity = NostrIdentity.fromPrivateKey(fallbackKey.toHexString())
// Cache the fallback result too
geohashIdentityCache[forGeohash] = fallbackIdentity
@@ -280,15 +279,6 @@ object NostrIdentityBridge {
}
}
// Extension functions for data conversion
private fun String.hexToByteArrayLocal(): ByteArray {
return chunked(2).map { it.toInt(16).toByte() }.toByteArray()
}
private fun ByteArray.toHexStringLocal(): String {
return joinToString("") { "%02x".format(it) }
}
private fun UInt.toLittleEndianBytes(): ByteArray {
val bytes = ByteArray(4)
bytes[0] = (this and 0xFFu).toByte()
@@ -3,7 +3,6 @@ package com.bitchat.android.nostr
import android.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.security.MessageDigest
import kotlin.random.Random
/**
@@ -2,6 +2,8 @@ package com.bitchat.android.nostr
import android.content.Context
import android.util.Log
import com.bitchat.android.util.hexToByteArray
import com.bitchat.android.util.toHexString
import kotlinx.coroutines.*
/**
@@ -4,6 +4,8 @@ import android.content.Context
import android.util.Log
import com.bitchat.android.model.ReadReceipt
import com.bitchat.android.model.NoisePayloadType
import com.bitchat.android.util.PeerId
import com.bitchat.android.util.toHexString
import kotlinx.coroutines.*
import java.util.*
import java.util.concurrent.ConcurrentLinkedQueue
@@ -79,7 +81,7 @@ class NostrTransport(
Log.e(TAG, "NostrTransport: recipient key not npub (hrp=$hrp)")
return@launch
}
data.joinToString("") { "%02x".format(it) }
data.toHexString()
} catch (e: Exception) {
Log.e(TAG, "NostrTransport: failed to decode npub -> hex: $e")
return@launch
@@ -174,7 +176,7 @@ class NostrTransport(
scheduleNextReadAck()
return@launch
}
data.joinToString("") { "%02x".format(it) }
data.toHexString()
} catch (e: Exception) {
scheduleNextReadAck()
return@launch
@@ -248,7 +250,7 @@ class NostrTransport(
val recipientHex = try {
val (hrp, data) = Bech32.decode(recipientNostrPubkey)
if (hrp != "npub") return@launch
data.joinToString("") { "%02x".format(it) }
data.toHexString()
} catch (e: Exception) {
return@launch
}
@@ -306,7 +308,7 @@ class NostrTransport(
val recipientHex = try {
val (hrp, data) = Bech32.decode(recipientNostrPubkey)
if (hrp != "npub") return@launch
data.joinToString("") { "%02x".format(it) }
data.toHexString()
} catch (e: Exception) {
return@launch
}
@@ -486,7 +488,7 @@ class NostrTransport(
com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNostrPubkeyForPeerID(peerID)?.let { return it }
// 2) Legacy path: resolve by noise public key association
val noiseKey = hexStringToByteArray(peerID)
val noiseKey = PeerId.toBytes(peerID)
val favoriteStatus = com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(noiseKey)
if (favoriteStatus?.peerNostrPublicKey != null) return favoriteStatus.peerNostrPublicKey
@@ -503,14 +505,6 @@ class NostrTransport(
}
}
/**
* Convert full hex string to byte array
*/
private fun hexStringToByteArray(hexString: String): ByteArray {
val clean = if (hexString.length % 2 == 0) hexString else "0$hexString"
return clean.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
}
fun cleanup() {
transportScope.cancel()
}
@@ -3,13 +3,13 @@ package com.bitchat.android.nostr
import android.app.Application
import android.content.SharedPreferences
import android.util.Log
import com.bitchat.android.util.Hashing
import java.io.BufferedReader
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.io.InputStream
import java.io.InputStreamReader
import java.security.MessageDigest
import java.util.concurrent.TimeUnit
import kotlin.math.*
import kotlinx.coroutines.CoroutineScope
@@ -284,20 +284,6 @@ object RelayDirectory {
} catch (_: Exception) { "error" }
private fun streamSha256Hex(input: InputStream): String {
val digest = MessageDigest.getInstance("SHA-256")
val buf = ByteArray(8192)
var read: Int
while (true) {
read = input.read(buf)
if (read <= 0) break
digest.update(buf, 0, read)
}
val bytes = digest.digest()
return bytes.joinToString("") { b ->
val v = b.toInt() and 0xff
val s = Integer.toHexString(v)
if (s.length == 1) "0$s" else s
}
return Hashing.sha256Hex(input)
}
}
@@ -1,6 +1,7 @@
package com.bitchat.android.protocol
import android.os.Parcelable
import com.bitchat.android.util.PeerId
import kotlinx.parcelize.Parcelize
import java.nio.ByteBuffer
import java.nio.ByteOrder
@@ -71,7 +72,7 @@ data class BitchatPacket(
) : this(
version = 1u,
type = type,
senderID = hexStringToByteArray(senderID),
senderID = PeerId.toBytes(senderID),
recipientID = null,
timestamp = (System.currentTimeMillis()).toULong(),
payload = payload,
@@ -108,27 +109,6 @@ data class BitchatPacket(
fun fromBinaryData(data: ByteArray): BitchatPacket? {
return BinaryProtocol.decode(data)
}
/**
* Convert hex string peer ID to binary data (8 bytes) - exactly same as iOS
*/
private fun hexStringToByteArray(hexString: String): ByteArray {
val result = ByteArray(8) { 0 } // Initialize with zeros, exactly 8 bytes
var tempID = hexString
var index = 0
while (tempID.length >= 2 && index < 8) {
val hexByte = tempID.substring(0, 2)
val byte = hexByte.toIntOrNull(16)?.toByte()
if (byte != null) {
result[index] = byte
}
tempID = tempID.substring(2)
index++
}
return result
}
}
override fun equals(other: Any?): Boolean {
@@ -0,0 +1,110 @@
package com.bitchat.android.protocol
enum class TlvLengthSize(val byteCount: Int, val maxValue: Long) {
ONE_BYTE(1, 0xFFL),
TWO_BYTES(2, 0xFFFFL),
FOUR_BYTES(4, 0xFFFF_FFFFL)
}
enum class UnknownTlvPolicy {
SKIP,
FAIL
}
data class TlvField(
val type: Int,
val value: ByteArray
)
class TlvWriter {
private val output = ArrayList<Byte>()
fun put(type: Int, value: ByteArray, lengthSize: TlvLengthSize): TlvWriter {
require(type in 0..0xFF) { "TLV type must fit in one byte" }
require(value.size.toLong() <= lengthSize.maxValue) {
"TLV value length ${value.size} exceeds ${lengthSize.maxValue}"
}
output.add(type.toByte())
writeLength(value.size.toLong(), lengthSize)
output.addAll(value.toList())
return this
}
fun toByteArray(): ByteArray {
return output.toByteArray()
}
private fun writeLength(length: Long, lengthSize: TlvLengthSize) {
when (lengthSize) {
TlvLengthSize.ONE_BYTE -> output.add((length and 0xFF).toByte())
TlvLengthSize.TWO_BYTES -> {
output.add(((length ushr 8) and 0xFF).toByte())
output.add((length and 0xFF).toByte())
}
TlvLengthSize.FOUR_BYTES -> {
output.add(((length ushr 24) and 0xFF).toByte())
output.add(((length ushr 16) and 0xFF).toByte())
output.add(((length ushr 8) and 0xFF).toByte())
output.add((length and 0xFF).toByte())
}
}
}
}
object TlvReader {
fun decode(
data: ByteArray,
defaultLengthSize: TlvLengthSize,
unknownPolicy: UnknownTlvPolicy = UnknownTlvPolicy.SKIP,
knownTypes: Set<Int>? = null,
lengthSizeForType: (Int) -> TlvLengthSize = { defaultLengthSize }
): List<TlvField>? {
val fields = ArrayList<TlvField>()
var offset = 0
while (offset < data.size) {
if (data.size - offset < 1) return null
val type = data[offset].toInt() and 0xFF
offset += 1
val lengthSize = lengthSizeForType(type)
if (data.size - offset < lengthSize.byteCount) return null
val length = readLength(data, offset, lengthSize) ?: return null
offset += lengthSize.byteCount
if (length > Int.MAX_VALUE || data.size - offset < length.toInt()) return null
val valueLength = length.toInt()
val value = data.copyOfRange(offset, offset + valueLength)
offset += valueLength
if (knownTypes != null && type !in knownTypes) {
when (unknownPolicy) {
UnknownTlvPolicy.SKIP -> continue
UnknownTlvPolicy.FAIL -> return null
}
}
fields.add(TlvField(type, value))
}
return fields
}
private fun readLength(data: ByteArray, offset: Int, lengthSize: TlvLengthSize): Long? {
return when (lengthSize) {
TlvLengthSize.ONE_BYTE -> data[offset].toLong() and 0xFFL
TlvLengthSize.TWO_BYTES -> {
((data[offset].toLong() and 0xFFL) shl 8) or
(data[offset + 1].toLong() and 0xFFL)
}
TlvLengthSize.FOUR_BYTES -> {
((data[offset].toLong() and 0xFFL) shl 24) or
((data[offset + 1].toLong() and 0xFFL) shl 16) or
((data[offset + 2].toLong() and 0xFFL) shl 8) or
(data[offset + 3].toLong() and 0xFFL)
}
}
}
}
@@ -1,6 +1,8 @@
package com.bitchat.android.services
import com.bitchat.android.ui.ChatState
import com.bitchat.android.util.hexToByteArray
import com.bitchat.android.util.toHexString
object ConversationAliasResolver {
@@ -19,7 +21,7 @@ object ConversationAliasResolver {
if (pubHex != null) {
val noiseKey = findNoiseKeyForNostr(pubHex)
if (noiseKey != null) {
val noiseHex = noiseKey.joinToString("") { b -> "%02x".format(b) }
val noiseHex = noiseKey.toHexString()
// Prefer a connected mesh peer that matches this noise key
val meshPeer = connectedPeers.firstOrNull { pid ->
meshNoiseKeyForPeer(pid)?.contentEquals(noiseKey) == true
@@ -29,7 +31,7 @@ object ConversationAliasResolver {
}
} else if (peer.length == 64 && peer.matches(Regex("^[0-9a-fA-F]+$"))) {
// Peer is full noise key hex: upgrade to active mesh peer if available
val noiseKey = peer.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
val noiseKey = peer.hexToByteArray()
val meshPeer = connectedPeers.firstOrNull { pid ->
meshNoiseKeyForPeer(pid)?.contentEquals(noiseKey) == true
}
@@ -5,6 +5,8 @@ import android.util.Log
import com.bitchat.android.mesh.BluetoothMeshService
import com.bitchat.android.model.ReadReceipt
import com.bitchat.android.nostr.NostrTransport
import com.bitchat.android.util.hexToByteArrayOrNull
import com.bitchat.android.util.toHexString
/**
* Routes messages between BLE mesh and Nostr transports, matching iOS behavior.
@@ -164,7 +166,7 @@ class MessageRouter private constructor(
return try {
// Full Noise key hex
if (peerID.length == 64 && peerID.matches(Regex("^[0-9a-fA-F]+$"))) {
val noiseKey = hexToBytes(peerID)
val noiseKey = peerID.hexToByteArrayOrNull() ?: return false
val fav = com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(noiseKey)
fav?.isMutual == true && fav.peerNostrPublicKey != null
} else if (peerID.length == 16 && peerID.matches(Regex("^[0-9a-fA-F]+$"))) {
@@ -177,16 +179,11 @@ class MessageRouter private constructor(
} catch (_: Exception) { false }
}
private fun hexToBytes(hex: String): ByteArray {
val clean = if (hex.length % 2 == 0) hex else "0$hex"
return clean.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
}
private fun resolveMeshPeerForNoiseHex(noiseHex: String): String? {
return try {
mesh.getPeerNicknames().keys.firstOrNull { pid ->
val info = mesh.getPeerInfo(pid)
val keyHex = info?.noisePublicKey?.joinToString("") { b -> "%02x".format(b) }
val keyHex = info?.noisePublicKey?.toHexString()
keyHex != null && keyHex.equals(noiseHex, ignoreCase = true)
}
} catch (_: Exception) { null }
@@ -197,7 +194,7 @@ class MessageRouter private constructor(
peers.forEach { pid ->
flushOutboxFor(pid)
val noiseHex = try {
mesh.getPeerInfo(pid)?.noisePublicKey?.joinToString("") { b -> "%02x".format(b) }
mesh.getPeerInfo(pid)?.noisePublicKey?.toHexString()
} catch (_: Exception) { null }
noiseHex?.let { flushOutboxFor(it) }
}
@@ -207,7 +204,7 @@ class MessageRouter private constructor(
fun onSessionEstablished(peerID: String) {
flushOutboxFor(peerID)
val noiseHex = try {
mesh.getPeerInfo(peerID)?.noisePublicKey?.joinToString("") { b -> "%02x".format(b) }
mesh.getPeerInfo(peerID)?.noisePublicKey?.toHexString()
} catch (_: Exception) { null }
noiseHex?.let { flushOutboxFor(it) }
}
@@ -1,6 +1,11 @@
package com.bitchat.android.services.meshgraph
import android.util.Log
import com.bitchat.android.protocol.TlvLengthSize
import com.bitchat.android.protocol.TlvReader
import com.bitchat.android.protocol.TlvWriter
import com.bitchat.android.protocol.UnknownTlvPolicy
import com.bitchat.android.util.PeerId
/**
* Gossip TLV helpers for embedding direct neighbor peer IDs in ANNOUNCE payloads.
@@ -15,12 +20,14 @@ object GossipTLV {
*/
fun encodeNeighbors(peerIDs: List<String>): ByteArray {
val unique = peerIDs.distinct().take(10)
val valueBytes = unique.flatMap { id -> hexStringPeerIdTo8Bytes(id).toList() }.toByteArray()
val valueBytes = unique.flatMap { id -> PeerId.toBytes(id).toList() }.toByteArray()
if (valueBytes.size > 255) {
// Safety check, though 10*8 = 80 bytes, so well under 255
Log.w("GossipTLV", "Neighbors value exceeds 255, truncating")
}
return byteArrayOf(DIRECT_NEIGHBORS_TYPE.toByte(), valueBytes.size.toByte()) + valueBytes
return TlvWriter()
.put(DIRECT_NEIGHBORS_TYPE.toInt(), valueBytes, TlvLengthSize.ONE_BYTE)
.toByteArray()
}
/**
@@ -29,22 +36,21 @@ object GossipTLV {
*/
fun decodeNeighborsFromAnnouncementPayload(payload: ByteArray): List<String>? {
val result = mutableListOf<String>()
var offset = 0
while (offset + 2 <= payload.size) {
val type = payload[offset].toUByte()
val len = payload[offset + 1].toUByte().toInt()
offset += 2
if (offset + len > payload.size) break
val value = payload.sliceArray(offset until offset + len)
offset += len
val fields = TlvReader.decode(
data = payload,
defaultLengthSize = TlvLengthSize.ONE_BYTE,
unknownPolicy = UnknownTlvPolicy.SKIP,
knownTypes = setOf(DIRECT_NEIGHBORS_TYPE.toInt())
) ?: return null
if (type == DIRECT_NEIGHBORS_TYPE) {
for (field in fields) {
if (field.type == DIRECT_NEIGHBORS_TYPE.toInt()) {
// Value is N*8 bytes of peer IDs
var pos = 0
while (pos + 8 <= value.size) {
val idBytes = value.sliceArray(pos until pos + 8)
result.add(bytesToPeerIdHex(idBytes))
pos += 8
while (pos + PeerId.BYTE_LENGTH <= field.value.size) {
val idBytes = field.value.sliceArray(pos until pos + PeerId.BYTE_LENGTH)
result.add(PeerId.fromBytes(idBytes))
pos += PeerId.BYTE_LENGTH
}
return result // present (possibly empty)
}
@@ -52,26 +58,4 @@ object GossipTLV {
// Not present
return null
}
private fun hexStringPeerIdTo8Bytes(hexString: String): ByteArray {
val clean = hexString.lowercase().take(16)
val result = ByteArray(8) { 0 }
var idx = 0
var out = 0
while (idx + 1 < clean.length && out < 8) {
val byteStr = clean.substring(idx, idx + 2)
val b = byteStr.toIntOrNull(16)?.toByte() ?: 0
result[out++] = b
idx += 2
}
return result
}
private fun bytesToPeerIdHex(bytes: ByteArray): String {
val sb = StringBuilder()
for (b in bytes.take(8)) {
sb.append(String.format("%02x", b))
}
return sb.toString()
}
}
@@ -6,6 +6,9 @@ import com.bitchat.android.model.RequestSyncPacket
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.protocol.MessageType
import com.bitchat.android.protocol.SpecialRecipients
import com.bitchat.android.util.PeerId
import com.bitchat.android.util.hexToByteArray
import com.bitchat.android.util.toHexString
import kotlinx.coroutines.*
import java.util.concurrent.ConcurrentHashMap
@@ -101,7 +104,7 @@ class GossipSyncManager(
if (!isBroadcastMessage && !isAnnouncement) return
val idBytes = PacketIdUtil.computeIdBytes(packet)
val id = idBytes.joinToString("") { b -> "%02x".format(b) }
val id = idBytes.toHexString()
if (isBroadcastMessage) {
synchronized(messages) {
@@ -122,7 +125,7 @@ class GossipSyncManager(
return
}
// senderID is fixed-size 8 bytes; map to hex string for key
val sender = packet.senderID.joinToString("") { b -> "%02x".format(b) }
val sender = packet.senderID.toHexString()
latestAnnouncementByPeer[sender] = id to packet
// Enforce capacity (remove oldest when exceeded)
val cap = configProvider.seenCapacity().coerceAtLeast(1)
@@ -138,7 +141,7 @@ class GossipSyncManager(
val packet = BitchatPacket(
type = MessageType.REQUEST_SYNC.value,
senderID = hexStringToByteArray(myPeerID),
senderID = PeerId.toBytes(myPeerID),
timestamp = System.currentTimeMillis().toULong(),
payload = payload,
ttl = com.bitchat.android.util.AppConstants.SYNC_TTL_HOPS // neighbors only
@@ -153,8 +156,8 @@ class GossipSyncManager(
val packet = BitchatPacket(
type = MessageType.REQUEST_SYNC.value,
senderID = hexStringToByteArray(myPeerID),
recipientID = hexStringToByteArray(peerID),
senderID = PeerId.toBytes(myPeerID),
recipientID = PeerId.toBytes(peerID),
timestamp = System.currentTimeMillis().toULong(),
payload = payload,
ttl = com.bitchat.android.util.AppConstants.SYNC_TTL_HOPS // neighbor only
@@ -177,7 +180,7 @@ class GossipSyncManager(
// 1) Announcements: send latest per peerID if remote doesn't have them
for ((_, pair) in latestAnnouncementByPeer.entries) {
val (id, pkt) = pair
val idBytes = hexToBytes(id)
val idBytes = id.hexToByteArray()
if (!mightContain(idBytes)) {
// Send original packet unchanged to requester only (keep local TTL)
val toSend = pkt.copy(ttl = com.bitchat.android.util.AppConstants.SYNC_TTL_HOPS)
@@ -198,31 +201,6 @@ class GossipSyncManager(
}
}
private fun hexStringToByteArray(hexString: String): ByteArray {
val result = ByteArray(8) { 0 }
var tempID = hexString
var index = 0
while (tempID.length >= 2 && index < 8) {
val hexByte = tempID.substring(0, 2)
val byte = hexByte.toIntOrNull(16)?.toByte()
if (byte != null) result[index] = byte
tempID = tempID.substring(2)
index++
}
return result
}
private fun hexToBytes(hex: String): ByteArray {
val clean = if (hex.length % 2 == 0) hex else "0$hex"
val out = ByteArray(clean.length / 2)
var i = 0
while (i < clean.length) {
out[i/2] = clean.substring(i, i+2).toInt(16).toByte()
i += 2
}
return out
}
private fun buildGcsPayload(): ByteArray {
// Collect candidates: latest announcement per peer + recent broadcast messages
val list = ArrayList<BitchatPacket>()
@@ -276,7 +254,7 @@ class GossipSyncManager(
val toRemove = mutableListOf<String>()
synchronized(messages) {
for ((id, message) in messages) {
val sender = message.senderID.joinToString("") { b -> "%02x".format(b) }
val sender = message.senderID.toHexString()
if (sender == peerID) toRemove.add(id)
}
}
@@ -300,7 +278,7 @@ class GossipSyncManager(
val idsToRemove = mutableListOf<String>()
synchronized(messages) {
for ((id, message) in messages) {
val sender = message.senderID.joinToString("") { b -> "%02x".format(b) }
val sender = message.senderID.toHexString()
if (sender == key) {
idsToRemove.add(id)
}
@@ -1,7 +1,8 @@
package com.bitchat.android.sync
import com.bitchat.android.protocol.BitchatPacket
import java.security.MessageDigest
import com.bitchat.android.util.Hashing
import com.bitchat.android.util.toHexString
/**
* Deterministic packet ID helper for sync purposes.
@@ -11,21 +12,22 @@ import java.security.MessageDigest
*/
object PacketIdUtil {
fun computeIdBytes(packet: BitchatPacket): ByteArray {
val md = MessageDigest.getInstance("SHA-256")
md.update(packet.type.toByte())
md.update(packet.senderID)
// Timestamp as 8 bytes big-endian
val data = ByteArray(1 + packet.senderID.size + 8 + packet.payload.size)
var offset = 0
data[offset++] = packet.type.toByte()
packet.senderID.copyInto(data, destinationOffset = offset)
offset += packet.senderID.size
val ts = packet.timestamp.toLong()
for (i in 7 downTo 0) {
md.update(((ts ushr (i * 8)) and 0xFF).toByte())
data[offset++] = ((ts ushr (i * 8)) and 0xFF).toByte()
}
md.update(packet.payload)
val digest = md.digest()
return digest.copyOf(16) // 128-bit ID
packet.payload.copyInto(data, destinationOffset = offset)
return Hashing.sha256(data).copyOf(16) // 128-bit ID
}
fun computeIdHex(packet: BitchatPacket): String {
return computeIdBytes(packet).joinToString("") { b -> "%02x".format(b) }
return computeIdBytes(packet).toHexString()
}
}
@@ -2,7 +2,6 @@ package com.bitchat.android.ui
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import java.security.MessageDigest
import javax.crypto.Cipher
import javax.crypto.spec.GCMParameterSpec
import javax.crypto.spec.SecretKeySpec
@@ -30,7 +30,6 @@ import com.bitchat.android.noise.NoiseSession
import com.bitchat.android.nostr.GeohashAliasRegistry
import com.bitchat.android.util.dataFromHexString
import com.bitchat.android.util.hexEncodedString
import java.security.MessageDigest
/**
* Refactored ChatViewModel - Main coordinator for bitchat functionality
@@ -5,8 +5,8 @@ import com.bitchat.android.mesh.BluetoothMeshService
import com.bitchat.android.model.BitchatFilePacket
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.BitchatMessageType
import com.bitchat.android.util.Hashing
import java.util.Date
import java.security.MessageDigest
/**
* Handles media file sending operations (voice notes, images, generic files)
@@ -181,8 +181,8 @@ class MediaSendingManager(
}
Log.d(TAG, "🔒 Encoded private packet: ${payload.size} bytes")
val transferId = sha256Hex(payload)
val contentHash = sha256Hex(filePacket.content)
val transferId = Hashing.sha256Hex(payload)
val contentHash = Hashing.sha256Hex(filePacket.content)
Log.d(TAG, "📤 FILE_TRANSFER send (private): name='${filePacket.fileName}', size=${filePacket.fileSize}, mime='${filePacket.mimeType}', sha256=$contentHash, to=${toPeerID.take(8)} transferId=${transferId.take(16)}")
@@ -232,8 +232,8 @@ class MediaSendingManager(
}
Log.d(TAG, "🔓 Encoded broadcast packet: ${payload.size} bytes")
val transferId = sha256Hex(payload)
val contentHash = sha256Hex(filePacket.content)
val transferId = Hashing.sha256Hex(payload)
val contentHash = Hashing.sha256Hex(filePacket.content)
Log.d(TAG, "📤 FILE_TRANSFER send (broadcast): name='${filePacket.fileName}', size=${filePacket.fileSize}, mime='${filePacket.mimeType}', sha256=$contentHash, transferId=${transferId.take(16)}")
@@ -339,11 +339,4 @@ class MediaSendingManager(
}
}
private fun sha256Hex(bytes: ByteArray): String = try {
val md = MessageDigest.getInstance("SHA-256")
md.update(bytes)
md.digest().joinToString("") { "%02x".format(it) }
} catch (_: Exception) {
bytes.size.toString(16)
}
}
@@ -5,6 +5,7 @@ import com.bitchat.android.ui.NotificationTextUtils
import com.bitchat.android.mesh.BluetoothMeshService
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.DeliveryStatus
import com.bitchat.android.util.toHexString
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import java.util.Date
@@ -130,7 +131,7 @@ class MeshDelegateHandler(
favs.firstNotNullOfOrNull { rel ->
rel.peerNostrPublicKey?.let { s ->
runCatching { com.bitchat.android.nostr.Bech32.decode(s) }.getOrNull()?.let { dec ->
if (dec.first == "npub") dec.second.joinToString("") { b -> "%02x".format(b) } else null
if (dec.first == "npub") dec.second.toHexString() else null
}
}
}?.takeIf { it.startsWith(prefix, ignoreCase = true) }
@@ -154,7 +155,7 @@ class MeshDelegateHandler(
} catch (_: Exception) { null }
if (favoriteRel?.isMutual == true) {
val noiseHex = favoriteRel.peerNoisePublicKey.joinToString("") { b -> "%02x".format(b) }
val noiseHex = favoriteRel.peerNoisePublicKey.toHexString()
if (noiseHex != currentPeer) {
com.bitchat.android.services.ConversationAliasResolver.unifyChatsIntoPeer(
state = state,
@@ -175,14 +176,14 @@ class MeshDelegateHandler(
try {
val info = getPeerInfo(pid)
val noiseKey = info?.noisePublicKey ?: return@forEach
val noiseHex = noiseKey.joinToString("") { b -> "%02x".format(b) }
val noiseHex = noiseKey.toHexString()
// Derive temp nostr key from favorites npub
val npub = com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNostrPubkey(noiseKey)
val tempNostrKey: String? = try {
if (npub != null) {
val (hrp, data) = com.bitchat.android.nostr.Bech32.decode(npub)
if (hrp == "npub") "nostr_${data.joinToString("") { b -> "%02x".format(b) }.take(16)}" else null
if (hrp == "npub") "nostr_${data.toHexString().take(16)}" else null
} else null
} catch (_: Exception) { null }
@@ -2,6 +2,7 @@ package com.bitchat.android.ui
import com.bitchat.android.R
import android.util.Log
import com.bitchat.android.util.toHexString
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
@@ -333,7 +334,7 @@ fun PeopleSection(
// Build mapping of connected peerID -> noise key hex to unify with offline favorites
val noiseHexByPeerID: Map<String, String> = connectedPeers.associateWith { pid ->
try {
viewModel.meshService.getPeerInfo(pid)?.noisePublicKey?.joinToString("") { b -> "%02x".format(b) }
viewModel.meshService.getPeerInfo(pid)?.noisePublicKey?.toHexString()
} catch (_: Exception) { null }
}.filterValues { it != null }.mapValues { it.value!! }
@@ -367,7 +368,7 @@ fun PeopleSection(
// Offline favorites (exclude ones mapped to connected)
val offlineFavorites = com.bitchat.android.favorites.FavoritesPersistenceService.shared.getOurFavorites()
offlineFavorites.forEach { fav ->
val favPeerID = fav.peerNoisePublicKey.joinToString("") { b -> "%02x".format(b) }
val favPeerID = fav.peerNoisePublicKey.toHexString()
val isMappedToConnected = noiseHexByPeerID.values.any { it.equals(favPeerID, ignoreCase = true) }
if (!isMappedToConnected) {
val dn = peerNicknames[favPeerID] ?: fav.peerNickname
@@ -435,7 +436,7 @@ fun PeopleSection(
// Append offline favorites we actively favorite (and not currently connected)
offlineFavorites.forEach { fav ->
val favPeerID = fav.peerNoisePublicKey.joinToString("") { b -> "%02x".format(b) }
val favPeerID = fav.peerNoisePublicKey.toHexString()
// If any connected peer maps to this noise key, skip showing the offline entry
val isMappedToConnected = noiseHexByPeerID.values.any { it.equals(favPeerID, ignoreCase = true) }
if (isMappedToConnected) return@forEach
@@ -446,7 +447,7 @@ fun PeopleSection(
if (npubOrHex != null) {
val hex = if (npubOrHex.startsWith("npub")) {
val (hrp, data) = com.bitchat.android.nostr.Bech32.decode(npubOrHex)
if (hrp == "npub") data.joinToString("") { "%02x".format(it) } else null
if (hrp == "npub") data.toHexString() else null
} else {
npubOrHex.lowercase()
}
@@ -3,7 +3,9 @@ package com.bitchat.android.ui
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.DeliveryStatus
import com.bitchat.android.mesh.PeerFingerprintManager
import java.security.MessageDigest
import com.bitchat.android.util.Hashing
import com.bitchat.android.util.hexToByteArray
import com.bitchat.android.util.toHexString
import com.bitchat.android.mesh.BluetoothMeshService
import java.util.*
@@ -131,10 +133,8 @@ class PrivateChatManager(
// compute a synthetic fingerprint (SHA-256 of public key) to allow unfollowing offline peers
if (fingerprint == null && peerID.length == 64 && peerID.matches(Regex("^[0-9a-fA-F]+$"))) {
try {
val pubBytes = peerID.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
val digest = java.security.MessageDigest.getInstance("SHA-256")
val fpBytes = digest.digest(pubBytes)
fingerprint = fpBytes.joinToString("") { "%02x".format(it) }
val pubBytes = peerID.hexToByteArray()
fingerprint = Hashing.sha256Hex(pubBytes)
Log.d(TAG, "Computed fingerprint from noise key hex for offline toggle: $fingerprint")
} catch (e: Exception) {
Log.w(TAG, "Failed to compute fingerprint from noise key hex: ${e.message}")
@@ -432,13 +432,13 @@ class PrivateChatManager(
// If we know the sender's Nostr pubkey for this peer via favorites, derive temp key
try {
val noiseKeyBytes = targetPeerID.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
val noiseKeyBytes = targetPeerID.hexToByteArray()
val npub = com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNostrPubkey(noiseKeyBytes)
if (npub != null) {
// Normalize to hex to match how we formed temp keys (nostr_<pub16>)
val (hrp, data) = com.bitchat.android.nostr.Bech32.decode(npub)
if (hrp == "npub") {
val pubHex = data.joinToString("") { "%02x".format(it) }
val pubHex = data.toHexString()
tryMergeKeys.add("nostr_${pubHex.take(16)}")
}
}
@@ -9,6 +9,7 @@ import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.noise.NoiseSession
import com.bitchat.android.nostr.GeohashAliasRegistry
import com.bitchat.android.services.VerificationService
import com.bitchat.android.util.Hashing
import com.bitchat.android.util.dataFromHexString
import com.bitchat.android.util.hexEncodedString
import kotlinx.coroutines.CoroutineScope
@@ -16,7 +17,6 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import java.security.MessageDigest
import java.util.Date
import java.util.concurrent.ConcurrentHashMap
@@ -333,8 +333,7 @@ class VerificationHandler(
}
fun fingerprintFromNoiseBytes(bytes: ByteArray): String {
val hash = MessageDigest.getInstance("SHA-256").digest(bytes)
return hash.hexEncodedString()
return Hashing.sha256Hex(bytes)
}
private data class PendingVerification(
@@ -12,24 +12,11 @@ import java.util.*
// MARK: - Hex Encoding/Decoding Extensions
fun ByteArray.hexEncodedString(): String {
if (this.isEmpty()) {
return ""
}
return this.joinToString("") { "%02x".format(it) }
return Hex.encode(this)
}
fun String.dataFromHexString(): ByteArray? {
val len = this.length / 2
val data = ByteArray(len)
var index = 0
for (i in 0 until len) {
val hexByte = this.substring(i * 2, i * 2 + 2)
val byte = hexByte.toIntOrNull(16)?.toByte() ?: return null
data[index++] = byte
}
return data
return Hex.decode(this)
}
// MARK: - Binary Encoding Utilities
@@ -201,7 +188,7 @@ class BinaryDataReader(private val data: ByteArray) {
offset += 16
// Convert 16 bytes to UUID string format
val uuid = uuidData.joinToString("") { "%02x".format(it) }
val uuid = uuidData.toHexString()
// Insert hyphens at proper positions: 8-4-4-4-12
val result = StringBuilder()
@@ -340,7 +327,7 @@ fun ByteArray.readUUID(at: IntArray): String? {
at[0] += 16
// Convert 16 bytes to UUID string format
val uuid = uuidData.joinToString("") { "%02x".format(it) }
val uuid = uuidData.toHexString()
// Insert hyphens at proper positions: 8-4-4-4-12
val result = StringBuilder()
@@ -1,8 +0,0 @@
package com.bitchat.android.util
/**
* Extension function to convert a ByteArray to a hexadecimal string.
*/
fun ByteArray.toHexString(): String {
return this.joinToString("") { "%02x".format(it) }
}
@@ -21,6 +21,6 @@ data class ByteArrayWrapper(val bytes: ByteArray) {
}
fun toHexString(): String {
return bytes.joinToString("") { "%02x".format(it) }
return Hex.encode(bytes)
}
}
@@ -0,0 +1,31 @@
package com.bitchat.android.util
import java.io.InputStream
import java.security.MessageDigest
object Hashing {
fun sha256(bytes: ByteArray): ByteArray {
return MessageDigest.getInstance("SHA-256").digest(bytes)
}
fun sha256Hex(bytes: ByteArray): String {
return Hex.encode(sha256(bytes))
}
fun sha256Hex(text: String): String {
return sha256Hex(text.toByteArray(Charsets.UTF_8))
}
fun sha256Hex(input: InputStream): String {
val digest = MessageDigest.getInstance("SHA-256")
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
while (true) {
val read = input.read(buffer)
if (read <= 0) break
digest.update(buffer, 0, read)
}
return Hex.encode(digest.digest())
}
}
@@ -0,0 +1,49 @@
package com.bitchat.android.util
object Hex {
private val digits = "0123456789abcdef".toCharArray()
fun encode(bytes: ByteArray): String {
val chars = CharArray(bytes.size * 2)
var index = 0
for (byte in bytes) {
val value = byte.toInt() and 0xFF
chars[index++] = digits[value ushr 4]
chars[index++] = digits[value and 0x0F]
}
return String(chars)
}
fun decode(hex: String, allowOddLength: Boolean = false): ByteArray? {
val clean = hex.trim()
val normalized = when {
clean.length % 2 == 0 -> clean
allowOddLength -> "0$clean"
else -> return null
}
val output = ByteArray(normalized.length / 2)
var inputIndex = 0
var outputIndex = 0
while (inputIndex < normalized.length) {
val high = normalized[inputIndex].digitToIntOrNull(16) ?: return null
val low = normalized[inputIndex + 1].digitToIntOrNull(16) ?: return null
output[outputIndex++] = ((high shl 4) or low).toByte()
inputIndex += 2
}
return output
}
fun decodeOrThrow(hex: String, allowOddLength: Boolean = false): ByteArray {
return decode(hex, allowOddLength)
?: throw IllegalArgumentException("Invalid hex string")
}
}
fun ByteArray.toHexString(): String = Hex.encode(this)
fun String.hexToByteArray(): ByteArray = Hex.decodeOrThrow(this)
fun String.hexToByteArrayOrNull(allowOddLength: Boolean = false): ByteArray? {
return Hex.decode(this, allowOddLength)
}
@@ -0,0 +1,38 @@
package com.bitchat.android.util
object PeerId {
const val BYTE_LENGTH = 8
const val HEX_LENGTH = BYTE_LENGTH * 2
fun fromBytes(bytes: ByteArray): String {
return Hex.encode(bytes.copyOf(BYTE_LENGTH))
}
fun parse(peerIdHex: String): ByteArray? {
val clean = peerIdHex.trim()
if (clean.length != HEX_LENGTH) return null
return Hex.decode(clean)?.takeIf { it.size == BYTE_LENGTH }
}
fun toBytes(peerIdHex: String): ByteArray {
val result = ByteArray(BYTE_LENGTH)
val clean = peerIdHex.trim()
var inputIndex = 0
var outputIndex = 0
while (inputIndex + 1 < clean.length && outputIndex < BYTE_LENGTH) {
val value = clean.substring(inputIndex, inputIndex + 2).toIntOrNull(16)
if (value != null) {
result[outputIndex] = value.toByte()
}
inputIndex += 2
outputIndex += 1
}
return result
}
fun normalize(peerIdHex: String): String {
return fromBytes(toBytes(peerIdHex))
}
}
@@ -4,6 +4,7 @@ package com.bitchat.android.mesh
import com.bitchat.android.model.RoutedPacket
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.protocol.MessageType
import com.bitchat.android.util.PeerId
import com.bitchat.android.util.toHexString
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
@@ -37,8 +38,8 @@ class PacketRelayManagerTest {
private fun createPacket(route: List<ByteArray>?, recipient: String? = null): BitchatPacket {
return BitchatPacket(
type = MessageType.MESSAGE.value,
senderID = hexStringToPeerBytes(otherPeerID),
recipientID = recipient?.let { hexStringToPeerBytes(it) },
senderID = PeerId.toBytes(otherPeerID),
recipientID = recipient?.let { PeerId.toBytes(it) },
timestamp = System.currentTimeMillis().toULong(),
payload = "hello".toByteArray(),
ttl = 5u,
@@ -49,8 +50,8 @@ class PacketRelayManagerTest {
@Test
fun `packet with duplicate hops is dropped`() = runTest {
val route = listOf(
hexStringToPeerBytes(nextHopPeerID),
hexStringToPeerBytes(nextHopPeerID)
PeerId.toBytes(nextHopPeerID),
PeerId.toBytes(nextHopPeerID)
)
val packet = createPacket(route)
val routedPacket = RoutedPacket(packet, otherPeerID)
@@ -64,8 +65,8 @@ class PacketRelayManagerTest {
@Test
fun `valid source-routed packet is relayed to next hop`() = runTest {
val route = listOf(
hexStringToPeerBytes(myPeerID),
hexStringToPeerBytes(nextHopPeerID)
PeerId.toBytes(myPeerID),
PeerId.toBytes(nextHopPeerID)
)
val packet = createPacket(route, finalRecipientID)
val routedPacket = RoutedPacket(packet, otherPeerID)
@@ -80,7 +81,7 @@ class PacketRelayManagerTest {
@Test
fun `last hop does not relay further`() = runTest {
val route = listOf(
hexStringToPeerBytes(myPeerID)
PeerId.toBytes(myPeerID)
)
val packet = createPacket(route, finalRecipientID)
val routedPacket = RoutedPacket(packet, otherPeerID)
@@ -103,15 +104,4 @@ class PacketRelayManagerTest {
verify(delegate).broadcastPacket(any())
}
private fun hexStringToPeerBytes(hex: String): ByteArray {
val result = ByteArray(8)
var idx = 0
var out = 0
while (idx + 1 < hex.length && out < 8) {
val b = hex.substring(idx, idx + 2).toIntOrNull(16)?.toByte() ?: 0
result[out++] = b
idx += 2
}
return result
}
}
@@ -0,0 +1,110 @@
package com.bitchat.android.model
import com.bitchat.android.protocol.TlvLengthSize
import com.bitchat.android.protocol.TlvWriter
import com.bitchat.android.services.meshgraph.GossipTLV
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Test
class TlvPacketRegressionTest {
@Test
fun `identity announcement skips unknown tlv fields`() {
val noiseKey = ByteArray(32) { 0x11 }
val signingKey = ByteArray(32) { 0x22 }
val encoded = TlvWriter()
.put(0x7f, byteArrayOf(0x55), TlvLengthSize.ONE_BYTE)
.put(0x01, "alice".toByteArray(), TlvLengthSize.ONE_BYTE)
.put(0x02, noiseKey, TlvLengthSize.ONE_BYTE)
.put(0x03, signingKey, TlvLengthSize.ONE_BYTE)
.toByteArray()
val decoded = IdentityAnnouncement.decode(encoded)
assertNotNull(decoded)
assertEquals("alice", decoded!!.nickname)
assertArrayEquals(noiseKey, decoded.noisePublicKey)
assertArrayEquals(signingKey, decoded.signingPublicKey)
}
@Test
fun `private message rejects unknown tlv fields`() {
val encoded = TlvWriter()
.put(0x00, "msg-1".toByteArray(), TlvLengthSize.ONE_BYTE)
.put(0x7f, byteArrayOf(0x55), TlvLengthSize.ONE_BYTE)
.put(0x01, "hello".toByteArray(), TlvLengthSize.ONE_BYTE)
.toByteArray()
assertNull(PrivateMessagePacket.decode(encoded))
}
@Test
fun `request sync skips unknown tlv fields and enforces payload cap`() {
val encoded = TlvWriter()
.put(0x7f, byteArrayOf(0x55), TlvLengthSize.TWO_BYTES)
.put(0x01, byteArrayOf(4), TlvLengthSize.TWO_BYTES)
.put(0x02, byteArrayOf(0, 0, 0, 16), TlvLengthSize.TWO_BYTES)
.put(0x03, byteArrayOf(1, 2, 3), TlvLengthSize.TWO_BYTES)
.toByteArray()
val tooLarge = TlvWriter()
.put(0x01, byteArrayOf(4), TlvLengthSize.TWO_BYTES)
.put(0x02, byteArrayOf(0, 0, 0, 16), TlvLengthSize.TWO_BYTES)
.put(0x03, ByteArray(RequestSyncPacket.MAX_ACCEPT_FILTER_BYTES + 1), TlvLengthSize.TWO_BYTES)
.toByteArray()
val decoded = RequestSyncPacket.decode(encoded)
assertNotNull(decoded)
assertEquals(4, decoded!!.p)
assertEquals(16L, decoded.m)
assertArrayEquals(byteArrayOf(1, 2, 3), decoded.data)
assertNull(RequestSyncPacket.decode(tooLarge))
}
@Test
fun `file packet uses four byte content length for large payloads`() {
val content = ByteArray(70_000) { (it % 251).toByte() }
val packet = BitchatFilePacket(
fileName = "large.bin",
fileSize = content.size.toLong(),
mimeType = "application/octet-stream",
content = content
)
val encoded = packet.encode()
val decoded = BitchatFilePacket.decode(encoded!!)
val contentTypeOffset = encoded.indexOf(0x04.toByte())
val contentLength =
((encoded[contentTypeOffset + 1].toInt() and 0xFF) shl 24) or
((encoded[contentTypeOffset + 2].toInt() and 0xFF) shl 16) or
((encoded[contentTypeOffset + 3].toInt() and 0xFF) shl 8) or
(encoded[contentTypeOffset + 4].toInt() and 0xFF)
assertEquals(content.size, contentLength)
assertNotNull(decoded)
assertArrayEquals(content, decoded!!.content)
}
@Test
fun `file packet rejects unknown tlv fields`() {
val encoded = TlvWriter()
.put(0x7f, byteArrayOf(0x55), TlvLengthSize.TWO_BYTES)
.put(0x01, "x.bin".toByteArray(), TlvLengthSize.TWO_BYTES)
.put(0x02, byteArrayOf(0, 0, 0, 1), TlvLengthSize.TWO_BYTES)
.put(0x03, "application/octet-stream".toByteArray(), TlvLengthSize.TWO_BYTES)
.put(0x04, byteArrayOf(1), TlvLengthSize.FOUR_BYTES)
.toByteArray()
assertNull(BitchatFilePacket.decode(encoded))
}
@Test
fun `gossip tlv uses shared peer id and tlv helpers`() {
val encoded = GossipTLV.encodeNeighbors(listOf("0102030405060708", "aabbcc"))
assertEquals(listOf("0102030405060708", "aabbcc0000000000"), GossipTLV.decodeNeighborsFromAnnouncementPayload(encoded))
}
}
@@ -0,0 +1,74 @@
package com.bitchat.android.protocol
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class TlvTest {
@Test
fun `writer and reader support one two and four byte lengths`() {
val oneByteValue = byteArrayOf(0x7f)
val twoByteValue = ByteArray(256) { it.toByte() }
val fourByteValue = ByteArray(70_000) { (it % 251).toByte() }
val encoded = TlvWriter()
.put(0x01, oneByteValue, TlvLengthSize.ONE_BYTE)
.put(0x02, twoByteValue, TlvLengthSize.TWO_BYTES)
.put(0x03, fourByteValue, TlvLengthSize.FOUR_BYTES)
.toByteArray()
val fields = TlvReader.decode(encoded, TlvLengthSize.ONE_BYTE) { type ->
when (type) {
0x02 -> TlvLengthSize.TWO_BYTES
0x03 -> TlvLengthSize.FOUR_BYTES
else -> TlvLengthSize.ONE_BYTE
}
}
assertEquals(3, fields!!.size)
assertArrayEquals(oneByteValue, fields[0].value)
assertArrayEquals(twoByteValue, fields[1].value)
assertArrayEquals(fourByteValue, fields[2].value)
}
@Test
fun `unknown tlv policy skips or fails consistently`() {
val encoded = TlvWriter()
.put(0x01, byteArrayOf(1), TlvLengthSize.ONE_BYTE)
.put(0x7f, byteArrayOf(9), TlvLengthSize.ONE_BYTE)
.put(0x02, byteArrayOf(2), TlvLengthSize.ONE_BYTE)
.toByteArray()
val skipped = TlvReader.decode(
data = encoded,
defaultLengthSize = TlvLengthSize.ONE_BYTE,
unknownPolicy = UnknownTlvPolicy.SKIP,
knownTypes = setOf(0x01, 0x02)
)
val failed = TlvReader.decode(
data = encoded,
defaultLengthSize = TlvLengthSize.ONE_BYTE,
unknownPolicy = UnknownTlvPolicy.FAIL,
knownTypes = setOf(0x01, 0x02)
)
assertEquals(listOf(0x01, 0x02), skipped!!.map { it.type })
assertNull(failed)
}
@Test
fun `reader rejects truncated values and writer rejects oversized values`() {
val truncated = byteArrayOf(0x01, 0x03, 0x41)
assertNull(TlvReader.decode(truncated, TlvLengthSize.ONE_BYTE))
try {
TlvWriter().put(0x01, ByteArray(256), TlvLengthSize.ONE_BYTE)
throw AssertionError("Expected oversized one-byte TLV value to fail")
} catch (_: IllegalArgumentException) {
// Expected.
}
}
}
@@ -0,0 +1,94 @@
package com.bitchat.android.ui.debug
import android.os.Build
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.protocol.MessageType
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [Build.VERSION_CODES.P], manifest = Config.NONE)
class DebugSettingsManagerTest {
private lateinit var manager: DebugSettingsManager
@Before
fun setup() {
manager = DebugSettingsManager.getInstance()
manager.resetForTesting()
manager.setVerboseLoggingEnabled(true)
}
@After
fun tearDown() {
manager.resetForTesting()
}
@Test
fun `logIncoming emits one typed packet trace`() {
val packet = BitchatPacket(
type = MessageType.MESSAGE.value,
ttl = 7u,
senderID = "aaaabbbbccccdddd",
payload = byteArrayOf(1, 2, 3)
)
manager.logIncoming(
packet = packet,
fromPeerID = "aaaabbbbccccdddd",
fromNickname = "alice",
fromDeviceAddress = "AA:BB:CC:DD:EE:FF",
myPeerID = "1111222233334444"
)
val packetMessages = manager.debugMessages.value.filterIsInstance<DebugMessage.PacketEvent>()
assertEquals("Incoming packet should create one packet event", 1, packetMessages.size)
assertTrue(packetMessages.single().content.contains("Incoming"))
assertTrue(packetMessages.single().content.contains("MESSAGE"))
}
@Test
fun `logOutgoing relay emits one relay event without packet duplicate`() {
manager.logOutgoing(
packetType = "MESSAGE",
toPeerID = "bbbbccccddddeeee",
toNickname = "bob",
toDeviceAddress = "11:22:33:44:55:66",
previousHopPeerID = "aaaabbbbccccdddd",
packetVersion = 1u,
routeInfo = "routed: 2 hops",
ttl = 6u
)
val packetMessages = manager.debugMessages.value.filterIsInstance<DebugMessage.PacketEvent>()
val relayMessages = manager.debugMessages.value.filterIsInstance<DebugMessage.RelayEvent>()
assertEquals("Relay send should not also create a packet event", 0, packetMessages.size)
assertEquals("Relay send should create one relay event", 1, relayMessages.size)
assertTrue(relayMessages.single().content.contains("Relay"))
assertTrue(relayMessages.single().content.contains("MESSAGE"))
}
@Test
fun `validation failures are hidden when verbose logging is disabled`() {
manager.setVerboseLoggingEnabled(false)
val packet = BitchatPacket(
type = MessageType.MESSAGE.value,
ttl = 7u,
senderID = "aaaabbbbccccdddd",
payload = byteArrayOf(1)
)
manager.logPacketValidationFailure(packet, "aaaabbbbccccdddd", "invalid signature")
val validationMessages = manager.debugMessages.value.filterIsInstance<DebugMessage.ValidationEvent>()
assertEquals(0, validationMessages.size)
}
}
@@ -0,0 +1,58 @@
package com.bitchat.android.util
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class HexPeerHashingTest {
@Test
fun `hex encodes lowercase and decodes mixed case`() {
val bytes = byteArrayOf(0x00, 0x0f, 0x10, 0x7f, 0xff.toByte())
assertEquals("000f107fff", Hex.encode(bytes))
assertArrayEquals(bytes, Hex.decode("000F107fFF"))
assertEquals("000f107fff", bytes.toHexString())
assertArrayEquals(bytes, "000f107fff".hexToByteArray())
}
@Test
fun `hex decode rejects malformed input unless odd length is explicitly allowed`() {
assertNull(Hex.decode("abc"))
assertArrayEquals(byteArrayOf(0x0a, 0xbc.toByte()), Hex.decode("abc", allowOddLength = true))
assertNull(Hex.decode("00xx"))
assertNull("00xx".hexToByteArrayOrNull())
}
@Test
fun `peer id parsing is fixed width with truncation and zero fill`() {
assertArrayEquals(
byteArrayOf(0x11, 0x22, 0x00, 0x44, 0, 0, 0, 0),
PeerId.toBytes("1122zz44")
)
assertArrayEquals(
byteArrayOf(0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77),
PeerId.toBytes("00112233445566778899")
)
assertEquals("1122004400000000", PeerId.normalize("1122zz44"))
}
@Test
fun `peer id strict parse requires exactly eight valid bytes`() {
val bytes = byteArrayOf(0, 1, 2, 3, 4, 5, 6, 7)
assertArrayEquals(bytes, PeerId.parse("0001020304050607"))
assertEquals("0001020304050607", PeerId.fromBytes(bytes))
assertNull(PeerId.parse("000102"))
assertNull(PeerId.parse("00010203040506xx"))
}
@Test
fun `hashing sha256 hex matches known vector`() {
assertEquals(
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
Hashing.sha256Hex("abc")
)
}
}