From 9c61025c8837715a2e69dcc55e5fce05f479bbdb Mon Sep 17 00:00:00 2001 From: JPG Date: Thu, 30 Oct 2025 09:35:55 -0700 Subject: [PATCH 1/2] feat(dm): backward-compatible 2-byte TLV for DMs; capability bit in announcements; adaptive decode; per-peer selection; tests & docs --- CHANGELOG.md | 4 + .../android/mesh/BluetoothMeshService.kt | 16 ++-- .../bitchat/android/mesh/MessageHandler.kt | 5 +- .../com/bitchat/android/mesh/PeerManager.kt | 19 ++++- .../android/model/IdentityAnnouncement.kt | 36 ++++++++- .../bitchat/android/model/NoiseEncrypted.kt | 76 ++++++++++++++++++- .../android/protocol/ProtocolFeatures.kt | 8 ++ .../android/model/PrivateMessagePacketTest.kt | 33 ++++++++ docs/dm_tlv.md | 24 ++++++ 9 files changed, 203 insertions(+), 18 deletions(-) create mode 100644 app/src/main/java/com/bitchat/android/protocol/ProtocolFeatures.kt create mode 100644 app/src/test/java/com/bitchat/android/model/PrivateMessagePacketTest.kt create mode 100644 docs/dm_tlv.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 89adc1eb7..c8916ac40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## Unreleased + +- feat(dm): add backward-compatible 2-byte TLV for Direct Messages with capability signaling to enable Cashu token DMs. Receive supports both 1B/2B; send uses 2B when peer advertises support. + # Changelog All notable changes to this project will be documented in this file. diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt index ee5f0f167..46a59a60b 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt @@ -241,8 +241,8 @@ class BluetoothMeshService(private val context: Context) { 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 updatePeerInfo(peerID: String, nickname: String, noisePublicKey: ByteArray, signingPublicKey: ByteArray, isVerified: Boolean, features: Int): Boolean { + return peerManager.updatePeerInfo(peerID, nickname, noisePublicKey, signingPublicKey, isVerified, features) } // Packet operations @@ -744,7 +744,8 @@ class BluetoothMeshService(private val context: Context) { content = content ) - val tlvData = privateMessage.encode() + val use2Byte = peerManager.peerHasFeature(recipientPeerID, com.bitchat.android.protocol.ProtocolFeatures.DM_TLV_2BYTE) + val tlvData = if (use2Byte) privateMessage.encode2B() else privateMessage.encode() if (tlvData == null) { Log.e(TAG, "Failed to encode private message with TLV") return@launch @@ -869,7 +870,7 @@ class BluetoothMeshService(private val context: Context) { } // Create iOS-compatible IdentityAnnouncement with TLV encoding - val announcement = IdentityAnnouncement(nickname, staticKey, signingKey) + val announcement = IdentityAnnouncement(nickname, staticKey, signingKey, com.bitchat.android.protocol.ProtocolFeatures.DM_TLV_2BYTE) val tlvPayload = announcement.encode() if (tlvPayload == null) { Log.e(TAG, "Failed to encode announcement as TLV") @@ -918,7 +919,7 @@ class BluetoothMeshService(private val context: Context) { } // Create iOS-compatible IdentityAnnouncement with TLV encoding - val announcement = IdentityAnnouncement(nickname, staticKey, signingKey) + val announcement = IdentityAnnouncement(nickname, staticKey, signingKey, com.bitchat.android.protocol.ProtocolFeatures.DM_TLV_2BYTE) val tlvPayload = announcement.encode() if (tlvPayload == null) { Log.e(TAG, "Failed to encode peer announcement as TLV") @@ -1016,9 +1017,10 @@ class BluetoothMeshService(private val context: Context) { nickname: String, noisePublicKey: ByteArray, signingPublicKey: ByteArray, - isVerified: Boolean + isVerified: Boolean, + features: Int = 0 ): Boolean { - return peerManager.updatePeerInfo(peerID, nickname, noisePublicKey, signingPublicKey, isVerified) + return peerManager.updatePeerInfo(peerID, nickname, noisePublicKey, signingPublicKey, isVerified, features) } /** diff --git a/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt b/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt index d016dd37b..ca6c96946 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt @@ -267,7 +267,8 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro nickname = nickname, noisePublicKey = noisePublicKey, signingPublicKey = signingPublicKey, - isVerified = true + isVerified = true, + features = announcement.features ) ?: false // Update peer ID binding with noise public key for identity management @@ -583,7 +584,7 @@ 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 updatePeerInfo(peerID: String, nickname: String, noisePublicKey: ByteArray, signingPublicKey: ByteArray, isVerified: Boolean, features: Int = 0): Boolean // Packet operations fun sendPacket(packet: BitchatPacket) diff --git a/app/src/main/java/com/bitchat/android/mesh/PeerManager.kt b/app/src/main/java/com/bitchat/android/mesh/PeerManager.kt index 4c279d4ad..c49fce615 100644 --- a/app/src/main/java/com/bitchat/android/mesh/PeerManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/PeerManager.kt @@ -17,7 +17,8 @@ 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 features: Int = 0 // Feature bitmask from announcements ) { override fun equals(other: Any?): Boolean { if (this === other) return true @@ -39,6 +40,7 @@ data class PeerInfo( } else if (other.signingPublicKey != null) return false if (isVerifiedNickname != other.isVerifiedNickname) return false if (lastSeen != other.lastSeen) return false + if (features != other.features) return false return true } @@ -52,6 +54,7 @@ data class PeerInfo( result = 31 * result + (signingPublicKey?.contentHashCode() ?: 0) result = 31 * result + isVerifiedNickname.hashCode() result = 31 * result + lastSeen.hashCode() + result = 31 * result + features.hashCode() return result } } @@ -104,7 +107,8 @@ class PeerManager { nickname: String, noisePublicKey: ByteArray, signingPublicKey: ByteArray, - isVerified: Boolean + isVerified: Boolean, + features: Int = 0 ): Boolean { if (peerID == "unknown") return false @@ -121,7 +125,8 @@ class PeerManager { noisePublicKey = noisePublicKey, signingPublicKey = signingPublicKey, isVerifiedNickname = isVerified, - lastSeen = now + lastSeen = now, + features = features ) peers[peerID] = peerInfo @@ -151,6 +156,14 @@ class PeerManager { return peers[peerID] } + /** + * Check if a peer advertises a feature bit. + */ + fun peerHasFeature(peerID: String, featureBit: Int): Boolean { + val f = peers[peerID]?.features ?: 0 + return (f and featureBit) != 0 + } + /** * Check if peer is verified */ diff --git a/app/src/main/java/com/bitchat/android/model/IdentityAnnouncement.kt b/app/src/main/java/com/bitchat/android/model/IdentityAnnouncement.kt index 2dfbe9c23..246c525de 100644 --- a/app/src/main/java/com/bitchat/android/model/IdentityAnnouncement.kt +++ b/app/src/main/java/com/bitchat/android/model/IdentityAnnouncement.kt @@ -12,7 +12,8 @@ import com.bitchat.android.util.* data class IdentityAnnouncement( val nickname: String, val noisePublicKey: ByteArray, // Noise static public key (Curve25519.KeyAgreement) - val signingPublicKey: ByteArray // Ed25519 public key for signing + val signingPublicKey: ByteArray, // Ed25519 public key for signing + val features: Int = 0 // Optional feature bitmask (unknown bits ignored by legacy) ) : Parcelable { /** @@ -21,7 +22,8 @@ data class IdentityAnnouncement( private enum class TLVType(val value: UByte) { NICKNAME(0x01u), NOISE_PUBLIC_KEY(0x02u), - SIGNING_PUBLIC_KEY(0x03u); // NEW: Ed25519 signing public key + SIGNING_PUBLIC_KEY(0x03u), // NEW: Ed25519 signing public key + FEATURES(0x04u); // Optional feature bitmask (u8 length, up to 4 bytes value) companion object { fun fromValue(value: UByte): TLVType? { @@ -58,6 +60,21 @@ data class IdentityAnnouncement( result.add(signingPublicKey.size.toByte()) result.addAll(signingPublicKey.toList()) + // Optional: features bitmask (encode as minimal big-endian, up to 4 bytes) + if (features != 0) { + val featBytes = ByteArray(4) + featBytes[0] = ((features ushr 24) and 0xFF).toByte() + featBytes[1] = ((features ushr 16) and 0xFF).toByte() + featBytes[2] = ((features ushr 8) and 0xFF).toByte() + featBytes[3] = (features and 0xFF).toByte() + // Trim leading zeros to minimize length + val firstNonZero = featBytes.indexOfFirst { it.toInt() != 0 } + val valueBytes = if (firstNonZero == -1) byteArrayOf(0) else featBytes.copyOfRange(firstNonZero, 4) + result.add(TLVType.FEATURES.value.toByte()) + result.add(valueBytes.size.toByte()) + result.addAll(valueBytes.toList()) + } + return result.toByteArray() } @@ -73,6 +90,7 @@ data class IdentityAnnouncement( var nickname: String? = null var noisePublicKey: ByteArray? = null var signingPublicKey: ByteArray? = null + var features: Int = 0 while (offset + 2 <= dataCopy.size) { // Read TLV type @@ -102,6 +120,14 @@ data class IdentityAnnouncement( TLVType.SIGNING_PUBLIC_KEY -> { signingPublicKey = value } + TLVType.FEATURES -> { + // Parse big-endian up to 4 bytes + var f = 0 + value.forEach { b -> + f = (f shl 8) or (b.toInt() and 0xFF) + } + features = f + } null -> { // Unknown TLV; skip (tolerant decoder for forward compatibility) continue @@ -111,7 +137,7 @@ data class IdentityAnnouncement( // All three fields are required return if (nickname != null && noisePublicKey != null && signingPublicKey != null) { - IdentityAnnouncement(nickname, noisePublicKey, signingPublicKey) + IdentityAnnouncement(nickname, noisePublicKey, signingPublicKey, features) } else { null } @@ -128,6 +154,7 @@ data class IdentityAnnouncement( if (nickname != other.nickname) return false if (!noisePublicKey.contentEquals(other.noisePublicKey)) return false if (!signingPublicKey.contentEquals(other.signingPublicKey)) return false + if (features != other.features) return false return true } @@ -136,10 +163,11 @@ data class IdentityAnnouncement( var result = nickname.hashCode() result = 31 * result + noisePublicKey.contentHashCode() result = 31 * result + signingPublicKey.contentHashCode() + result = 31 * result + features.hashCode() return result } 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.joinToString("") { "%02x".format(it) }.take(16)}..., signingPublicKey=${signingPublicKey.joinToString("") { "%02x".format(it) }.take(16)}..., features=$features)" } } diff --git a/app/src/main/java/com/bitchat/android/model/NoiseEncrypted.kt b/app/src/main/java/com/bitchat/android/model/NoiseEncrypted.kt index 7f691a9cc..d17e2bf7e 100644 --- a/app/src/main/java/com/bitchat/android/model/NoiseEncrypted.kt +++ b/app/src/main/java/com/bitchat/android/model/NoiseEncrypted.kt @@ -118,8 +118,8 @@ data class PrivateMessagePacket( } /** - * Encode to TLV binary data - exactly like iOS - * Format: [type][length][value] for each field + * Encode to TLV binary data (legacy 1-byte TLV) + * Format: [type:u8][length:u8][value] for each field */ fun encode(): ByteArray? { val messageIDData = messageID.toByteArray(Charsets.UTF_8) @@ -144,12 +144,47 @@ data class PrivateMessagePacket( return result.toByteArray() } + + /** + * Encode to TLV binary data with 2-byte fields + * Format: [type:u16][length:u16][value] + */ + fun encode2B(): ByteArray? { + val messageIDData = messageID.toByteArray(Charsets.UTF_8) + val contentData = content.toByteArray(Charsets.UTF_8) + + // 2-byte length supports up to 65535 + if (messageIDData.size > 0xFFFF || contentData.size > 0xFFFF) return null + + val out = mutableListOf() + + fun put(type: UShort, value: ByteArray) { + // type u16 + out.add(((type.toInt() ushr 8) and 0xFF).toByte()) + out.add((type.toInt() and 0xFF).toByte()) + // length u16 + out.add(((value.size ushr 8) and 0xFF).toByte()) + out.add((value.size and 0xFF).toByte()) + out.addAll(value.toList()) + } + + put(0u, messageIDData) + put(1u, contentData) + + return out.toByteArray() + } companion object { /** * Decode from TLV binary data - exactly like iOS */ fun decode(data: ByteArray): PrivateMessagePacket? { + // Try 2-byte TLV first, then fallback to legacy 1-byte TLV + decode2B(data)?.let { return it } + return decode1B(data) + } + + private fun decode1B(data: ByteArray): PrivateMessagePacket? { var offset = 0 var messageID: String? = null var content: String? = null @@ -187,6 +222,43 @@ data class PrivateMessagePacket( null } } + + private fun decode2B(data: ByteArray): PrivateMessagePacket? { + var offset = 0 + var messageID: String? = null + var content: String? = null + + while (offset + 4 <= data.size) { + // Read type u16 + val tHigh = data[offset].toInt() and 0xFF + val tLow = data[offset + 1].toInt() and 0xFF + val type = (tHigh shl 8) or tLow + offset += 2 + + // Read length u16 + if (offset + 2 > data.size) return null + val lHigh = data[offset].toInt() and 0xFF + val lLow = data[offset + 1].toInt() and 0xFF + val length = (lHigh shl 8) or lLow + offset += 2 + + if (length < 0 || offset + length > data.size) return null + val value = data.copyOfRange(offset, offset + length) + offset += length + + when (type) { + 0 -> messageID = String(value, Charsets.UTF_8) + 1 -> content = String(value, Charsets.UTF_8) + else -> { + // Unknown type, ignore for forward compatibility + } + } + } + + return if (messageID != null && content != null) { + PrivateMessagePacket(messageID, content) + } else null + } } override fun toString(): String { diff --git a/app/src/main/java/com/bitchat/android/protocol/ProtocolFeatures.kt b/app/src/main/java/com/bitchat/android/protocol/ProtocolFeatures.kt new file mode 100644 index 000000000..f10f306e4 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/protocol/ProtocolFeatures.kt @@ -0,0 +1,8 @@ +package com.bitchat.android.protocol + +object ProtocolFeatures { + // Feature bits advertised in announcements (bitmask) + const val DM_TLV_2BYTE: Int = 1 shl 0 +} + + diff --git a/app/src/test/java/com/bitchat/android/model/PrivateMessagePacketTest.kt b/app/src/test/java/com/bitchat/android/model/PrivateMessagePacketTest.kt new file mode 100644 index 000000000..57fe03b9c --- /dev/null +++ b/app/src/test/java/com/bitchat/android/model/PrivateMessagePacketTest.kt @@ -0,0 +1,33 @@ +package com.bitchat.android.model + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Test + +class PrivateMessagePacketTest { + + @Test + fun encodeDecode1B_roundtrip() { + val pm = PrivateMessagePacket(messageID = "abc-123", content = "hello cashu") + val tlv = pm.encode() + assertNotNull(tlv) + val decoded = PrivateMessagePacket.decode(tlv!!) + assertNotNull(decoded) + assertEquals(pm.messageID, decoded!!.messageID) + assertEquals(pm.content, decoded.content) + } + + @Test + fun encodeDecode2B_roundtrip() { + val longContent = "x".repeat(300) + val pm = PrivateMessagePacket(messageID = "id-300", content = longContent) + val tlv = pm.encode2B() + assertNotNull(tlv) + val decoded = PrivateMessagePacket.decode(tlv!!) + assertNotNull(decoded) + assertEquals(pm.messageID, decoded!!.messageID) + assertEquals(pm.content, decoded.content) + } +} + + diff --git a/docs/dm_tlv.md b/docs/dm_tlv.md new file mode 100644 index 000000000..5af7104f8 --- /dev/null +++ b/docs/dm_tlv.md @@ -0,0 +1,24 @@ +# Direct Message TLV: 1-byte to 2-byte (Backward-Compatible) + +This change enables sending and receiving Cashu ecash tokens over Direct Messages by introducing a 2-byte TLV format for DMs while keeping backward compatibility. + +Key points: +- Receive path accepts both 1-byte and 2-byte TLV without negotiation (adaptive parser). +- Send path uses 2-byte TLV only when the peer advertises feature bit `DM_TLV_2BYTE` in announcements. +- Broadcast path unchanged. + +Formats: +- Legacy (1B): [type:u8][length:u8][value] +- New (2B): [type:u16][length:u16][value] + +Capability Signaling: +- Announcements include optional `FEATURES` TLV (type 0x04) carrying a big-endian bitmask. +- Bit 0 (`DM_TLV_2BYTE`) means peer supports 2-byte TLV for DMs. + +Security: +- Noise/AES-GCM unchanged; strict bounds checks on TLV length. + +Interop: +- Older peers ignore unknown `FEATURES` TLV and continue receiving DMs in legacy format. + + From 16bd4e5fd2ecb61f13e3c89dea1404a75c625d36 Mon Sep 17 00:00:00 2001 From: jpgaviria2 Date: Thu, 30 Oct 2025 23:04:27 -0700 Subject: [PATCH 2/2] docs: add bitpoints.me references and clean up whitespace --- CHANGELOG.md | 2 +- .../java/com/bitchat/android/protocol/ProtocolFeatures.kt | 2 -- .../com/bitchat/android/model/PrivateMessagePacketTest.kt | 2 -- docs/dm_tlv.md | 4 ++-- 4 files changed, 3 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c8916ac40..d685e65ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ ## Unreleased -- feat(dm): add backward-compatible 2-byte TLV for Direct Messages with capability signaling to enable Cashu token DMs. Receive supports both 1B/2B; send uses 2B when peer advertises support. +- feat(dm): add backward-compatible 2-byte TLV for Direct Messages with capability signaling to enable Cashu token DMs. Receive supports both 1B/2B; send uses 2B when peer advertises support. Enables integration with [bitpoints.me](https://github.com/bitpoints-cashu/bitpoints.me) for offline peer-to-peer Bitcoin payments. # Changelog diff --git a/app/src/main/java/com/bitchat/android/protocol/ProtocolFeatures.kt b/app/src/main/java/com/bitchat/android/protocol/ProtocolFeatures.kt index f10f306e4..213975ceb 100644 --- a/app/src/main/java/com/bitchat/android/protocol/ProtocolFeatures.kt +++ b/app/src/main/java/com/bitchat/android/protocol/ProtocolFeatures.kt @@ -4,5 +4,3 @@ object ProtocolFeatures { // Feature bits advertised in announcements (bitmask) const val DM_TLV_2BYTE: Int = 1 shl 0 } - - diff --git a/app/src/test/java/com/bitchat/android/model/PrivateMessagePacketTest.kt b/app/src/test/java/com/bitchat/android/model/PrivateMessagePacketTest.kt index 57fe03b9c..3869b1943 100644 --- a/app/src/test/java/com/bitchat/android/model/PrivateMessagePacketTest.kt +++ b/app/src/test/java/com/bitchat/android/model/PrivateMessagePacketTest.kt @@ -29,5 +29,3 @@ class PrivateMessagePacketTest { assertEquals(pm.content, decoded.content) } } - - diff --git a/docs/dm_tlv.md b/docs/dm_tlv.md index 5af7104f8..ea27d516f 100644 --- a/docs/dm_tlv.md +++ b/docs/dm_tlv.md @@ -2,6 +2,8 @@ This change enables sending and receiving Cashu ecash tokens over Direct Messages by introducing a 2-byte TLV format for DMs while keeping backward compatibility. +**Cashu Integration**: This change enables [bitpoints.me](https://github.com/bitpoints-cashu/bitpoints.me), a Cashu ecash wallet that integrates bitchat protocol for offline peer-to-peer Bitcoin payments over Bluetooth mesh. + Key points: - Receive path accepts both 1-byte and 2-byte TLV without negotiation (adaptive parser). - Send path uses 2-byte TLV only when the peer advertises feature bit `DM_TLV_2BYTE` in announcements. @@ -20,5 +22,3 @@ Security: Interop: - Older peers ignore unknown `FEATURES` TLV and continue receiving DMs in legacy format. - -