Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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. Enables integration with [bitpoints.me](https://github.com/bitpoints-cashu/bitpoints.me) for offline peer-to-peer Bitcoin payments.

# Changelog

All notable changes to this project will be documented in this file.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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)
}

/**
Expand Down
5 changes: 3 additions & 2 deletions app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
19 changes: 16 additions & 3 deletions app/src/main/java/com/bitchat/android/mesh/PeerManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}
Expand All @@ -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
}
}
Expand Down Expand Up @@ -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

Expand All @@ -121,7 +125,8 @@ class PeerManager {
noisePublicKey = noisePublicKey,
signingPublicKey = signingPublicKey,
isVerifiedNickname = isVerified,
lastSeen = now
lastSeen = now,
features = features
)

peers[peerID] = peerInfo
Expand Down Expand Up @@ -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
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

/**
Expand All @@ -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? {
Expand Down Expand Up @@ -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()
}

Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
}
Expand All @@ -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
}
Expand All @@ -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)"
}
}
76 changes: 74 additions & 2 deletions app/src/main/java/com/bitchat/android/model/NoiseEncrypted.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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<Byte>()

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
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.bitchat.android.protocol

object ProtocolFeatures {
// Feature bits advertised in announcements (bitmask)
const val DM_TLV_2BYTE: Int = 1 shl 0
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
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)
}
}
Loading