use pairwise NDR FFI directly

This commit is contained in:
Dev 2026-07-27 18:34:37 +03:00
parent 1de3d28156
commit 6e18fd3674
45 changed files with 5186 additions and 2036 deletions

2
.gitignore vendored
View File

@ -64,5 +64,5 @@ google-services.json
tools/arti-build/.arti-source/
tools/arti-build/target/
# Generated from the pinned iris-chat-rs source submodule.
# Generated from the pinned nostr-double-ratchet source submodule.
app/src/main/jniLibs/*/libndr_ffi.so

6
.gitmodules vendored
View File

@ -1,4 +1,4 @@
[submodule "vendor/iris-chat-rs"]
path = vendor/iris-chat-rs
url = https://github.com/irislib/iris-chat-rs.git
[submodule "vendor/nostr-double-ratchet"]
path = vendor/nostr-double-ratchet
url = https://github.com/irislib/nostr-double-ratchet.git
shallow = true

View File

@ -36,7 +36,8 @@ android {
"GITHUB_RELEASE_CERT_SHA256",
"\"$normalizedGithubReleaseCertSha256\""
)
// Maintainer-coordinated rollout remains dark until kind-1402 lands.
// Keep NDR dark until the Apple and Android implementations are
// reviewed and ready to be enabled together.
buildConfigField("boolean", "NDR_ROLLOUT_ENABLED", "false")
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@ -182,6 +183,8 @@ dependencies {
// Testing
testImplementation(libs.bundles.testing)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.test.ext.junit)
androidTestImplementation(libs.androidx.test.espresso.core)
androidTestImplementation(libs.bundles.compose.testing)
debugImplementation(libs.androidx.compose.ui.tooling)
}

View File

@ -0,0 +1,191 @@
package com.bitchat.android.nostr
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import java.io.File
import java.util.UUID
import org.json.JSONArray
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import uniffi.ndr_ffi.FfiKeyPair
import uniffi.ndr_ffi.PairwiseAction
import uniffi.ndr_ffi.PairwiseInvite
import uniffi.ndr_ffi.PairwiseManager
import uniffi.ndr_ffi.generateKeypair
@RunWith(AndroidJUnit4::class)
class PairwiseFfiInstrumentedTest {
@Test
fun directFfiHandshakeSendRestartDeduplicateExpiryAndRetirement() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
val testRoot = File(
context.filesDir,
"ndr-pairwise-ffi-${UUID.randomUUID()}"
)
assertTrue(testRoot.mkdirs())
val aliceKeys = generateKeypair()
val bobKeys = generateKeypair()
val alicePath = File(testRoot, "alice")
val bobPath = File(testRoot, "bob")
var alice: PairwiseManager? = null
var bob: PairwiseManager? = null
try {
alice = manager(aliceKeys, alicePath)
bob = manager(bobKeys, bobPath)
val inviteJson = alice.currentInviteEventJson()
PairwiseInvite.fromEventJson(inviteJson).use { invite ->
assertEquals(aliceKeys.publicKeyHex, invite.getPeerPubkeyHex())
}
val accepted = bob.acceptInviteFromEventJson(
inviteJson,
aliceKeys.publicKeyHex
)
assertEquals(aliceKeys.publicKeyHex, accepted.peerPubkeyHex)
assertTrue(accepted.createdNewSession)
val handshakeActions = bob.pendingActions()
assertEquals(handshakeActions, bob.pendingActions())
val response = handshakeActions.single { it.kind == "out_of_band" }
val bootstrap = handshakeActions.single { it.kind == "publish" }
assertNotNull(response.sessionId)
assertEquals(response.sessionId, bootstrap.sessionId)
assertEquals(aliceKeys.publicKeyHex, response.peerPubkeyHex)
assertPairwiseWireEvent(response, expectedKind = 1059)
assertPairwiseWireEvent(bootstrap, expectedKind = 1060)
bob.close()
bob = manager(bobKeys, bobPath)
assertEquals(handshakeActions, bob.pendingActions())
alice.processOutOfBandResponse(
requireNotNull(response.eventJson),
bobKeys.publicKeyHex
)
val halfReady = requireNotNull(alice.sessionInfo(bobKeys.publicKeyHex))
assertFalse(halfReady.sendReady)
alice.processEvent(requireNotNull(bootstrap.eventJson))
assertTrue(requireNotNull(alice.sessionInfo(bobKeys.publicKeyHex)).sendReady)
bob.ackActions(handshakeActions.map(PairwiseAction::actionId))
bob.close()
bob = manager(bobKeys, bobPath)
assertTrue(
bob.pendingActions().none { pending ->
pending.actionId in handshakeActions.map(PairwiseAction::actionId)
}
)
val expiresAtSeconds =
(System.currentTimeMillis() / 1_000L).toULong() + 3_600UL
val text = "bitchat1:expiring-direct-ffi"
val sent = alice.sendText(
bobKeys.publicKeyHex,
text,
expiresAtSeconds
)
val publish = alice.pendingActions().single { action ->
action.kind == "publish" && action.outerEventId == sent.outerEventId
}
assertPairwiseWireEvent(publish, expectedKind = 1060)
val publishJson = requireNotNull(publish.eventJson)
alice.close()
alice = manager(aliceKeys, alicePath)
val replayedPublish = alice.pendingActions().single { action ->
action.actionId == publish.actionId
}
assertEquals(publishJson, replayedPublish.eventJson)
bob.processEvent(publishJson)
bob.processEvent(publishJson)
val deliveries = bob.pendingActions().filter { it.kind == "delivery" }
assertEquals(1, deliveries.size)
val delivery = deliveries.single()
assertEquals(aliceKeys.publicKeyHex, delivery.peerPubkeyHex)
assertEquals(sent.innerEventId, delivery.innerEventId)
assertTrue(requireNotNull(delivery.innerEventId).matches(HEX_32))
assertEquals(expiresAtSeconds, delivery.expiresAtSeconds)
val innerJson = requireNotNull(delivery.innerEventJson)
val inner = JSONObject(innerJson)
assertEquals(14, inner.getInt("kind"))
assertEquals(aliceKeys.publicKeyHex, inner.getString("pubkey"))
assertEquals(text, inner.getString("content"))
assertTrue(
containsTag(
inner.getJSONArray("tags"),
"expiration",
expiresAtSeconds.toString()
)
)
bob.close()
bob = manager(bobKeys, bobPath)
val replayedDelivery = bob.pendingActions().single { action ->
action.actionId == delivery.actionId
}
assertEquals(innerJson, replayedDelivery.innerEventJson)
bob.ackActions(listOf(delivery.actionId))
bob.close()
bob = manager(bobKeys, bobPath)
assertTrue(bob.pendingActions().none { it.actionId == delivery.actionId })
alice.ackActions(listOf(publish.actionId))
alice.close()
alice = manager(aliceKeys, alicePath)
assertTrue(alice.pendingActions().none { it.actionId == publish.actionId })
assertTrue(alice.retirePeer(bobKeys.publicKeyHex))
assertFalse(alice.retirePeer(bobKeys.publicKeyHex))
alice.close()
alice = manager(aliceKeys, alicePath)
assertFalse(alice.knownPeerPubkeys().contains(bobKeys.publicKeyHex))
} finally {
alice?.close()
bob?.close()
testRoot.deleteRecursively()
}
}
private fun manager(keys: FfiKeyPair, path: File): PairwiseManager =
PairwiseManager.newWithStoragePath(
keys.publicKeyHex,
keys.privateKeyHex,
path.absolutePath
)
private fun assertPairwiseWireEvent(action: PairwiseAction, expectedKind: Int) {
val event = JSONObject(requireNotNull(action.eventJson))
assertEquals(expectedKind, event.getInt("kind"))
assertNotEquals(37368, event.getInt("kind"))
if (expectedKind == 1060) {
assertFalse(containsTag(event.getJSONArray("tags"), "p"))
}
}
private fun containsTag(
tags: JSONArray,
name: String,
expectedValue: String? = null
): Boolean =
(0 until tags.length()).any { index ->
val tag = tags.getJSONArray(index)
tag.length() > 0 &&
tag.getString(0) == name &&
(expectedValue == null ||
(tag.length() > 1 && tag.getString(1) == expectedValue))
}
companion object {
private val HEX_32 = Regex("^[0-9a-f]{64}$")
}
}

View File

@ -28,6 +28,20 @@ class BitchatApplication : Application() {
// Initialize favorites persistence early so MessageRouter/NostrTransport can use it on startup
try {
com.bitchat.android.favorites.FavoritesPersistenceService.initialize(this)
com.bitchat.android.favorites.FavoritesPersistenceService.shared
.setNdrPeerRetirementGuard { oldPeerPubkeyHex ->
if (!com.bitchat.android.model.NdrFeatureGate.isEnabled()) {
true
} else {
val identity =
com.bitchat.android.nostr.NostrIdentityBridge
.getCurrentNostrIdentity(this)
?: return@setNdrPeerRetirementGuard false
val ndr = com.bitchat.android.nostr.NdrNostrService.getInstance(this)
ndr.configureIfNeeded(identity)
ndr.retirePeer(oldPeerPubkeyHex)
}
}
} catch (_: Exception) { }
// Warm up Nostr identity to ensure npub is available for favorite notifications

View File

@ -60,7 +60,15 @@ interface FavoritesChangeListener {
* Manages favorites with NoiseNostr mapping
* Singleton pattern matching iOS implementation.
*/
class FavoritesPersistenceService private constructor(private val context: Context) {
class FavoritesPersistenceService private constructor(
private val stateManager: SecureIdentityStateManager
) {
internal constructor(
stateManager: SecureIdentityStateManager,
testOnly: Boolean
) : this(stateManager) {
require(testOnly) { "Injected favorites storage is test-only" }
}
companion object {
private const val TAG = "FavoritesPersistenceService"
@ -77,18 +85,21 @@ class FavoritesPersistenceService private constructor(private val context: Conte
if (INSTANCE == null) {
synchronized(this) {
if (INSTANCE == null) {
INSTANCE = FavoritesPersistenceService(context.applicationContext)
INSTANCE = FavoritesPersistenceService(
SecureIdentityStateManager(context.applicationContext)
)
}
}
}
}
}
private val stateManager = SecureIdentityStateManager(context)
private val gson = Gson()
private val favorites = mutableMapOf<String, FavoriteRelationship>() // noiseHex -> relationship
private val peerIdIndex = mutableMapOf<String, String>() // peerID (lowercase 16-hex) -> npub
private val listeners = mutableListOf<FavoritesChangeListener>()
private var ndrPeerRetirementGuard: ((oldPeerPubkeyHex: String) -> Boolean)? = null
private val ndrRebindsInProgress = mutableSetOf<String>()
init {
loadFavorites()
@ -96,6 +107,7 @@ class FavoritesPersistenceService private constructor(private val context: Conte
}
/** Get favorite status for Noise public key */
@Synchronized
fun getFavoriteStatus(noisePublicKey: ByteArray): FavoriteRelationship? {
val keyHex = ContactIdentityResolver.noiseKeyHex(noisePublicKey)
return favorites[keyHex]
@ -129,35 +141,73 @@ 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) {
fun updateNostrPublicKey(noisePublicKey: ByteArray, nostrPubkey: String): Boolean {
val keyHex = ContactIdentityResolver.noiseKeyHex(noisePublicKey)
val normalizedNpub = ContactIdentityResolver.nostrPubkeyHex(nostrPubkey)
?.let { ContactIdentityResolver.npubFromHex(it) }
?: nostrPubkey
val existing = favorites[keyHex]
if (existing != null) {
val updated = existing.copy(
peerNostrPublicKey = normalizedNpub,
lastUpdated = Date()
)
favorites[keyHex] = updated
} else {
val relationship = FavoriteRelationship(
peerNoisePublicKey = noisePublicKey,
peerNostrPublicKey = normalizedNpub,
peerNickname = "Unknown",
isFavorite = false,
theyFavoritedUs = false,
favoritedAt = Date(),
lastUpdated = Date()
)
favorites[keyHex] = relationship
val normalizedHex = ContactIdentityResolver.nostrPubkeyHex(nostrPubkey) ?: return false
val normalizedNpub = ContactIdentityResolver.npubFromHex(normalizedHex) ?: return false
var oldPeerPubkeyHex: String? = null
var originalNostrHex: String? = null
var originalNdrHex: String? = null
synchronized(this) {
if (keyHex in ndrRebindsInProgress ||
isIdentityBoundToAnotherFavorite(keyHex, normalizedHex)
) return false
val existing = favorites[keyHex]
val oldPeer = existing?.let(::effectiveNdrPeerPubkeyHex)
val isRebind = oldPeer != null &&
!oldPeer.equals(normalizedHex, ignoreCase = true)
val mustRetire = isRebind &&
!isIdentityReferencedByAnotherFavorite(keyHex, oldPeer)
if (!mustRetire) {
favorites[keyHex] = relationshipWithNostrIdentity(
existing = existing,
noisePublicKey = noisePublicKey,
normalizedNpub = normalizedNpub,
clearExplicitNdrPeer = isRebind
)
saveFavorites()
} else {
ndrRebindsInProgress.add(keyHex)
oldPeerPubkeyHex = oldPeer
originalNostrHex = existing.peerNostrPublicKey
?.let(ContactIdentityResolver::nostrPubkeyHex)
originalNdrHex = existing.peerNdrSessionPubkeyHex
}
}
val peerToRetire = oldPeerPubkeyHex
if (peerToRetire != null) {
if (!retireBeforeRebind(peerToRetire)) {
synchronized(this) { ndrRebindsInProgress.remove(keyHex) }
Log.e(TAG, "Refusing Nostr identity rebind before old NDR peer is retired")
return false
}
val committed = synchronized(this) {
val current = favorites[keyHex]
val bindingUnchanged =
current?.peerNostrPublicKey
?.let(ContactIdentityResolver::nostrPubkeyHex) == originalNostrHex &&
current?.peerNdrSessionPubkeyHex == originalNdrHex
val canCommit = bindingUnchanged &&
!isIdentityBoundToAnotherFavorite(keyHex, normalizedHex)
if (canCommit) {
favorites[keyHex] = relationshipWithNostrIdentity(
existing = current,
noisePublicKey = noisePublicKey,
normalizedNpub = normalizedNpub,
clearExplicitNdrPeer = true
)
saveFavorites()
}
ndrRebindsInProgress.remove(keyHex)
canCommit
}
if (!committed) return false
}
saveFavorites()
notifyChanged(keyHex)
Log.d(TAG, "Updated Nostr pubkey association for ${keyHex.take(16)}...")
return true
}
@ -202,6 +252,7 @@ class FavoritesPersistenceService private constructor(private val context: Conte
}
/** Update favorite status */
@Synchronized
fun updateFavoriteStatus(noisePublicKey: ByteArray, nickname: String, isFavorite: Boolean) {
val keyHex = ContactIdentityResolver.noiseKeyHex(noisePublicKey)
@ -234,6 +285,7 @@ class FavoritesPersistenceService private constructor(private val context: Conte
}
/** Update peer favorited-us flag */
@Synchronized
fun updatePeerFavoritedUs(noisePublicKey: ByteArray, theyFavoritedUs: Boolean) {
val keyHex = ContactIdentityResolver.noiseKeyHex(noisePublicKey)
val existing = favorites[keyHex]
@ -255,6 +307,7 @@ class FavoritesPersistenceService private constructor(private val context: Conte
fun getOurFavorites(): List<FavoriteRelationship> = favorites.values.filter { it.isFavorite }
fun getAllRelationships(): List<FavoriteRelationship> = favorites.values.toList()
@Synchronized
fun clearAllFavorites() {
favorites.clear()
saveFavorites()
@ -280,18 +333,69 @@ class FavoritesPersistenceService private constructor(private val context: Conte
}
/** Persist the owner pubkey used to look up this peer's ratchet session. */
fun updateNdrSessionPubkeyHex(noisePublicKey: ByteArray, peerPubkeyHex: String) {
val normalized = ContactIdentityResolver.nostrPubkeyHex(peerPubkeyHex) ?: return
fun updateNdrSessionPubkeyHex(noisePublicKey: ByteArray, peerPubkeyHex: String): Boolean {
val normalized = ContactIdentityResolver.nostrPubkeyHex(peerPubkeyHex) ?: return false
val keyHex = ContactIdentityResolver.noiseKeyHex(noisePublicKey)
val existing = favorites[keyHex] ?: return
if (existing.peerNdrSessionPubkeyHex == normalized) return
var oldPeerPubkeyHex: String? = null
var originalNostrHex: String? = null
var originalNdrHex: String? = null
synchronized(this) {
if (keyHex in ndrRebindsInProgress ||
isIdentityBoundToAnotherFavorite(keyHex, normalized)
) return false
val existing = favorites[keyHex] ?: return false
if (existing.peerNdrSessionPubkeyHex == normalized) return true
val oldPeer = effectiveNdrPeerPubkeyHex(existing)
val isRebind = oldPeer != null &&
!oldPeer.equals(normalized, ignoreCase = true)
val mustRetire = isRebind &&
!isIdentityReferencedByAnotherFavorite(keyHex, oldPeer)
if (!mustRetire) {
favorites[keyHex] = existing.copy(
peerNdrSessionPubkeyHex = normalized,
lastUpdated = Date()
)
saveFavorites()
} else {
ndrRebindsInProgress.add(keyHex)
oldPeerPubkeyHex = oldPeer
originalNostrHex = existing.peerNostrPublicKey
?.let(ContactIdentityResolver::nostrPubkeyHex)
originalNdrHex = existing.peerNdrSessionPubkeyHex
}
}
val peerToRetire = oldPeerPubkeyHex
if (peerToRetire != null) {
if (!retireBeforeRebind(peerToRetire)) {
synchronized(this) { ndrRebindsInProgress.remove(keyHex) }
Log.e(TAG, "Refusing NDR session rebind before old peer is retired")
return false
}
val committed = synchronized(this) {
val current = favorites[keyHex]
val bindingUnchanged =
current?.peerNostrPublicKey
?.let(ContactIdentityResolver::nostrPubkeyHex) == originalNostrHex &&
current?.peerNdrSessionPubkeyHex == originalNdrHex
val canCommit = current != null &&
bindingUnchanged &&
!isIdentityBoundToAnotherFavorite(keyHex, normalized)
if (canCommit) {
favorites[keyHex] = current.copy(
peerNdrSessionPubkeyHex = normalized,
lastUpdated = Date()
)
saveFavorites()
}
ndrRebindsInProgress.remove(keyHex)
canCommit
}
if (!committed) return false
}
favorites[keyHex] = existing.copy(
peerNdrSessionPubkeyHex = normalized,
lastUpdated = Date()
)
saveFavorites()
notifyChanged(keyHex)
return true
}
/** Resolve the best ratchet-session lookup key for this Noise identity. */
@ -372,6 +476,67 @@ class FavoritesPersistenceService private constructor(private val context: Conte
fun removeListener(listener: FavoritesChangeListener) {
synchronized(listeners) { listeners.remove(listener) }
}
@Synchronized
fun setNdrPeerRetirementGuard(
guard: ((oldPeerPubkeyHex: String) -> Boolean)?
) {
ndrPeerRetirementGuard = guard
}
private fun effectiveNdrPeerPubkeyHex(
relationship: FavoriteRelationship
): String? = relationship.peerNdrSessionPubkeyHex
?: relationship.peerNostrPublicKey?.let(ContactIdentityResolver::nostrPubkeyHex)
private fun relationshipWithNostrIdentity(
existing: FavoriteRelationship?,
noisePublicKey: ByteArray,
normalizedNpub: String,
clearExplicitNdrPeer: Boolean
): FavoriteRelationship = existing?.copy(
peerNostrPublicKey = normalizedNpub,
peerNdrSessionPubkeyHex =
if (clearExplicitNdrPeer) null else existing.peerNdrSessionPubkeyHex,
lastUpdated = Date()
) ?: FavoriteRelationship(
peerNoisePublicKey = noisePublicKey,
peerNostrPublicKey = normalizedNpub,
peerNickname = "Unknown",
isFavorite = false,
theyFavoritedUs = false,
favoritedAt = Date(),
lastUpdated = Date()
)
private fun isIdentityBoundToAnotherFavorite(
noiseKeyHex: String,
peerPubkeyHex: String
): Boolean = favorites.any { (otherNoiseKeyHex, relationship) ->
otherNoiseKeyHex != noiseKeyHex &&
relationshipReferencesIdentity(relationship, peerPubkeyHex)
}
private fun isIdentityReferencedByAnotherFavorite(
noiseKeyHex: String,
peerPubkeyHex: String
): Boolean = isIdentityBoundToAnotherFavorite(noiseKeyHex, peerPubkeyHex)
private fun relationshipReferencesIdentity(
relationship: FavoriteRelationship,
peerPubkeyHex: String
): Boolean =
relationship.peerNdrSessionPubkeyHex
?.equals(peerPubkeyHex, ignoreCase = true) == true ||
relationship.peerNostrPublicKey
?.let(ContactIdentityResolver::nostrPubkeyHex)
?.equals(peerPubkeyHex, ignoreCase = true) == true
private fun retireBeforeRebind(oldPeerPubkeyHex: String): Boolean =
runCatching {
ndrPeerRetirementGuard?.invoke(oldPeerPubkeyHex) == true
}.getOrDefault(false)
private fun notifyChanged(noiseKeyHex: String) {
runCatching { AppStateStore.canonicalizePrivateChats() }
val snapshot = synchronized(listeners) { listeners.toList() }

View File

@ -8,6 +8,8 @@ import androidx.core.content.FileProvider
import java.io.File
import java.io.FileOutputStream
import java.io.InputStream
import java.nio.file.Files
import java.nio.file.StandardCopyOption
import java.text.SimpleDateFormat
import java.util.*
@ -193,7 +195,8 @@ object FileUtils {
*/
fun saveIncomingFile(
context: Context,
file: com.bitchat.android.model.BitchatFilePacket
file: com.bitchat.android.model.BitchatFilePacket,
stableId: String? = null
): String {
val lowerMime = file.mimeType.lowercase()
val isImage = lowerMime.startsWith("image/")
@ -217,6 +220,17 @@ object FileUtils {
?: (if (isImage) "img" else "file"))
.replace(Regex("[^A-Za-z0-9._-]"), "_")
val ext = extFromMime(lowerMime)
if (stableId != null) {
require(stableId.matches(Regex("^[0-9a-fA-F]{64}$"))) {
"Stable incoming file ID must be a 32-byte hex event ID"
}
val transmittedName = (file.fileName.takeIf { it.isNotBlank() }
?: if (isImage) "image$ext" else "file$ext")
.replace(Regex("[^A-Za-z0-9._-]"), "_")
.take(80)
val stableName = "ndr_${stableId.lowercase()}_$transmittedName"
return saveIncomingFileAtomically(dir, stableName, file.content)
}
var safeName = if (baseName.contains('.')) baseName else baseName + ext
var idx = 1
while (java.io.File(dir, safeName).exists() && idx < 1000) {
@ -262,6 +276,37 @@ object FileUtils {
}
}
private fun saveIncomingFileAtomically(
directory: File,
fileName: String,
content: ByteArray
): String {
val target = File(directory, fileName)
if (target.isFile &&
target.length() == content.size.toLong() &&
runCatching { target.readBytes().contentEquals(content) }.getOrDefault(false)
) {
return target.absolutePath
}
val temporary = File(directory, ".$fileName.${UUID.randomUUID()}.tmp")
try {
FileOutputStream(temporary).use { output ->
output.write(content)
output.fd.sync()
}
Files.move(
temporary.toPath(),
target.toPath(),
StandardCopyOption.ATOMIC_MOVE,
StandardCopyOption.REPLACE_EXISTING
)
return target.absolutePath
} finally {
temporary.delete()
}
}
/**
* Classify BitchatMessageType from MIME string used in file messages.
*/

View File

@ -496,6 +496,13 @@ class SecureIdentityStateManager {
fun storeSecureValue(key: String, value: String) {
prefs.edit().putString(key, value).apply()
}
/**
* Durably store a value before acknowledging an external operation.
*/
fun storeSecureValueSynchronously(key: String, value: String): Boolean {
return prefs.edit().putString(key, value).commit()
}
/**
* Retrieve a string value from secure preferences

View File

@ -98,6 +98,18 @@ class BluetoothConnectionManager(
fun getCurrentLinkID(deviceAddress: String): String? =
connectionTracker.getCurrentLinkID(deviceAddress)
fun currentNdrTransportTarget(peerID: String): NdrTransportTarget? {
val deviceAddress = connectionTracker.addressPeerMap.entries
.firstOrNull { it.value == peerID }
?.key
?: return null
val linkID = connectionTracker.getCurrentLinkID(deviceAddress) ?: return null
return NdrTransportTarget(
endpointId = deviceAddress,
generationToken = linkID
)
}
private fun isBleTransportEnabled(): Boolean {
return try {
com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().bleEnabled.value
@ -366,6 +378,28 @@ class BluetoothConnectionManager(
serverManager.getCharacteristic()
)
}
fun sendPacketToNdrTargetConfirmed(
target: NdrTransportTarget,
routed: RoutedPacket,
preflight: () -> Boolean,
completion: (Boolean) -> Unit
) {
val linkID = target.generationToken as? String
if (!isActive || !isBleTransportEnabled() || linkID == null) {
completion(false)
return
}
packetBroadcaster.sendPacketToLinkConfirmed(
routed = routed,
deviceAddress = target.endpointId,
linkID = linkID,
gattServer = serverManager.getGattServer(),
characteristic = serverManager.getCharacteristic(),
preflight = preflight,
completion = completion
)
}
// Expose role controls for debug UI

View File

@ -23,6 +23,7 @@ import com.bitchat.android.service.TransportBridgeService
import kotlinx.coroutines.*
import java.util.*
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.math.sign
import kotlin.random.Random
@ -44,6 +45,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
companion object {
private const val TAG = "BluetoothMeshService"
private const val NDR_TRANSPORT_ID = "BLE"
private const val BLE_AUTHENTICATION_TIMEOUT_MS = 20_000L
private val MAX_TTL: UByte = com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS
}
@ -550,7 +552,18 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
authenticatedSession
)
) {
delegate?.didReceiveNdrEvent(peerID, payload, timestampMs)
val transportTarget =
connectionManager.currentNdrTransportTarget(peerID) ?: return
delegate?.didReceiveNdrEvent(
NdrMeshRoute(
transportId = NDR_TRANSPORT_ID,
peerID = peerID,
authenticatedSession = authenticatedSession,
transportTarget = transportTarget
),
payload,
timestampMs
)
}
}
}
@ -1178,33 +1191,105 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
sendNoisePayloadToPeer(payload, peerID, "verify response")
}
fun sendNdrEvent(peerID: String, eventPayload: String): Boolean {
if (!NdrFeatureGate.isEnabled()) return false
if (eventPayload.isBlank()) return false
fun currentNdrRoute(peerID: String, transportId: String? = null): NdrMeshRoute? {
if (!NdrFeatureGate.isEnabled() ||
(transportId != null && transportId != NDR_TRANSPORT_ID)
) return null
val authenticatedSession = authenticatedSessionProvingCapability(
peerID,
PeerCapabilities.NOSTR_DOUBLE_RATCHET
) ?: return false
sendNoisePayloadToPeer(
NoisePayload(
type = NoisePayloadType.NDR_EVENT,
data = eventPayload.toByteArray(Charsets.UTF_8)
),
peerID,
"NDR event",
authenticatedSession
) ?: return null
val transportTarget =
connectionManager.currentNdrTransportTarget(peerID) ?: return null
return NdrMeshRoute(
transportId = NDR_TRANSPORT_ID,
peerID = peerID,
authenticatedSession = authenticatedSession,
transportTarget = transportTarget
)
return true
}
fun sendNdrEvent(
route: NdrMeshRoute,
eventPayload: String,
isStillAuthorized: () -> Boolean,
completion: (admitted: Boolean) -> Unit
) {
if (!NdrFeatureGate.isEnabled() ||
route.transportId != NDR_TRANSPORT_ID ||
eventPayload.isBlank()
) {
completion(false)
return
}
val completionDelivered = AtomicBoolean(false)
fun complete(admitted: Boolean) {
if (completionDelivered.compareAndSet(false, true)) {
runCatching { completion(admitted) }
}
}
serviceScope.launch {
var handedToTransport = false
try {
val preflight = {
currentNdrRoute(route.peerID, route.transportId) == route &&
isStillAuthorized()
}
if (!preflight()) return@launch
val encrypted = encryptionService.encryptForSession(
NoisePayload(
type = NoisePayloadType.NDR_EVENT,
data = eventPayload.toByteArray(Charsets.UTF_8)
).encode(),
route.peerID,
route.authenticatedSession
)
val packet = BitchatPacket(
version = 1u,
type = MessageType.NOISE_ENCRYPTED.value,
senderID = hexStringToByteArray(myPeerID),
recipientID = hexStringToByteArray(route.peerID),
timestamp = System.currentTimeMillis().toULong(),
payload = encrypted,
signature = null,
ttl = com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS
)
val signedPacket = signPacketBeforeBroadcast(packet)
handedToTransport = true
connectionManager.sendPacketToNdrTargetConfirmed(
target = route.transportTarget,
routed = RoutedPacket(signedPacket),
preflight = preflight,
completion = ::complete
)
} catch (e: Exception) {
Log.e(TAG, "Failed to send NDR event to ${route.peerID}: ${e.message}")
} finally {
if (!handedToTransport) complete(false)
}
}
}
private fun sendNoisePayloadToPeer(
payload: NoisePayload,
recipientPeerID: String,
label: String,
expectedSession: com.bitchat.android.noise.AuthenticatedNoiseSession? = null
expectedSession: com.bitchat.android.noise.AuthenticatedNoiseSession? = null,
preflight: () -> Boolean = { true },
completion: ((admitted: Boolean) -> Unit)? = null
) {
serviceScope.launch {
val completionDelivered = AtomicBoolean(false)
fun complete(admitted: Boolean) {
if (completionDelivered.compareAndSet(false, true)) {
runCatching { completion?.invoke(admitted) }
}
}
val job = serviceScope.launch {
var admitted = false
try {
if (!preflight()) {
return@launch
}
val encrypted = if (expectedSession == null) {
encryptionService.encrypt(payload.encode(), recipientPeerID)
} else {
@ -1226,11 +1311,16 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
)
val signedPacket = signPacketBeforeBroadcast(packet)
broadcastRoutedPacket(RoutedPacket(signedPacket))
admitted = broadcastRoutedPacket(RoutedPacket(signedPacket))
} catch (e: Exception) {
Log.e(TAG, "Failed to send $label to $recipientPeerID: ${e.message}")
} finally {
complete(admitted)
}
}
job.invokeOnCompletion {
complete(false)
}
}
/**

View File

@ -180,6 +180,38 @@ class BluetoothPacketBroadcaster(
notifyDevice(serverTarget, data, gattServer, characteristic)
}
fun sendPacketToLinkConfirmed(
routed: RoutedPacket,
deviceAddress: String,
linkID: String,
gattServer: BluetoothGattServer?,
characteristic: BluetoothGattCharacteristic?,
preflight: () -> Boolean,
completion: (Boolean) -> Unit
) {
fragmentingSender.sendConfirmed(
routed = routed,
description = "BLE link $deviceAddress",
preflight = preflight,
sendSingle = sendSingle@{ single ->
val data = single.packet.toBinaryData(
padding = BLEPacketPaddingPolicy.shouldPadForBLE(single.packet.type)
) ?: return@sendSingle false
val currentLink = connectionTracker.getDeviceConnection(deviceAddress)
?.takeIf { it.linkID == linkID }
?: return@sendSingle false
if (currentLink.isClient) {
return@sendSingle writeToDeviceConn(currentLink, data)
}
val serverTarget = connectionTracker.getSubscribedDevices()
.firstOrNull { it.address == deviceAddress }
?: return@sendSingle false
notifyDevice(serverTarget, data, gattServer, characteristic)
},
completion = completion
)
}
private fun sendSinglePacketToPeer(
routed: RoutedPacket,
targetPeerID: String,

View File

@ -12,6 +12,7 @@ import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import java.security.MessageDigest
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicBoolean
/**
* Shared transport send wrapper that applies bitchat packet fragmentation and
@ -102,6 +103,77 @@ class FragmentingPacketSender(
return true
}
/**
* Completes successfully only after every fragment has been admitted by
* the exact transport target. [preflight] is re-run for every fragment so
* a replaced session, revoked favorite, or disconnected link stops the
* transfer without acknowledging its durable caller.
*/
fun sendConfirmed(
routed: RoutedPacket,
description: String,
preflight: () -> Boolean,
sendSingle: (RoutedPacket) -> Boolean,
completion: (Boolean) -> Unit
) {
val completionDelivered = AtomicBoolean(false)
fun complete(admitted: Boolean) {
if (completionDelivered.compareAndSet(false, true)) {
completion(admitted)
}
}
val transferId = transferIdFor(routed)
val packets = packetsForTransport(routed)
if (packets == null) {
complete(false)
return
}
val total = packets.size
val job = scope.launch(start = CoroutineStart.LAZY) {
var sent = 0
try {
if (transferId != null) {
TransferProgressManager.start(transferId, total)
}
for (packet in packets) {
if (!isActive || !preflight()) return@launch
val fragment = routed.copy(
packet = packet,
transferId = transferId,
preparedPackets = null
)
if (!sendSingle(fragment)) return@launch
sent += 1
if (transferId != null) {
TransferProgressManager.progress(transferId, sent, total)
}
if (sent < total) {
delay(interFragmentDelayMs)
}
}
if (transferId != null) {
TransferProgressManager.complete(transferId, total)
}
complete(true)
} catch (e: Exception) {
Log.e(logTag, "Confirmed fragment send failed for $description: ${e.message}", e)
} finally {
complete(false)
}
}
if (transferId != null) {
transferJobs[transferId] = job
job.invokeOnCompletion {
transferJobs.remove(transferId, job)
complete(false)
}
} else {
job.invokeOnCompletion { complete(false) }
}
job.start()
}
fun cancelTransfer(transferId: String): Boolean {
val job = transferJobs.remove(transferId) ?: return false
job.cancel()

View File

@ -26,6 +26,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicBoolean
/**
* Shared mesh coordinator that wires all mesh-layer components and provides common APIs
@ -433,7 +434,18 @@ class MeshCore(
authenticatedSession
)
) {
delegate?.didReceiveNdrEvent(peerID, payload, timestampMs)
val transportTarget =
transport.currentNdrTransportTarget(peerID) ?: return
delegate?.didReceiveNdrEvent(
NdrMeshRoute(
transportId = transport.id,
peerID = peerID,
authenticatedSession = authenticatedSession,
transportTarget = transportTarget
),
payload,
timestampMs
)
}
}
}
@ -740,31 +752,105 @@ class MeshCore(
sendNoisePayloadToPeer(payload, peerID)
}
fun sendNdrEvent(peerID: String, eventPayload: String): Boolean {
if (!NdrFeatureGate.isEnabled()) return false
if (eventPayload.isBlank()) return false
fun currentNdrRoute(peerID: String, transportId: String? = null): NdrMeshRoute? {
if (!NdrFeatureGate.isEnabled() ||
(transportId != null && transportId != transport.id)
) return null
val authenticatedSession = authenticatedSessionProvingCapability(
peerID,
PeerCapabilities.NOSTR_DOUBLE_RATCHET
) ?: return false
sendNoisePayloadToPeer(
NoisePayload(
type = NoisePayloadType.NDR_EVENT,
data = eventPayload.toByteArray(Charsets.UTF_8)
),
peerID,
authenticatedSession
) ?: return null
val transportTarget = transport.currentNdrTransportTarget(peerID) ?: return null
return NdrMeshRoute(
transportId = transport.id,
peerID = peerID,
authenticatedSession = authenticatedSession,
transportTarget = transportTarget
)
return true
}
fun sendNdrEvent(
route: NdrMeshRoute,
eventPayload: String,
isStillAuthorized: () -> Boolean,
completion: (admitted: Boolean) -> Unit
) {
if (!NdrFeatureGate.isEnabled() ||
route.transportId != transport.id ||
eventPayload.isBlank()
) {
completion(false)
return
}
val completionDelivered = AtomicBoolean(false)
fun complete(admitted: Boolean) {
if (completionDelivered.compareAndSet(false, true)) {
runCatching { completion(admitted) }
}
}
scope.launch {
var handedToTransport = false
try {
val preflight = {
currentNdrRoute(route.peerID, route.transportId) == route &&
isStillAuthorized()
}
if (!preflight()) return@launch
val encrypted = encryptionService.encryptForSession(
NoisePayload(
type = NoisePayloadType.NDR_EVENT,
data = eventPayload.toByteArray(Charsets.UTF_8)
).encode(),
route.peerID,
route.authenticatedSession
)
val packet = BitchatPacket(
version = 1u,
type = MessageType.NOISE_ENCRYPTED.value,
senderID = MeshPacketUtils.hexStringToByteArray(myPeerID),
recipientID = MeshPacketUtils.hexStringToByteArray(route.peerID),
timestamp = System.currentTimeMillis().toULong(),
payload = encrypted,
signature = null,
ttl = maxTtl
)
val signedPacket = signPacketBeforeBroadcast(packet)
handedToTransport = true
transport.sendPacketToNdrTargetConfirmed(
peerID = route.peerID,
target = route.transportTarget,
routed = RoutedPacket(signedPacket),
preflight = preflight,
completion = ::complete
)
} catch (e: Exception) {
Log.e("MeshCore", "Failed to send NDR event to ${route.peerID}: ${e.message}")
} finally {
if (!handedToTransport) complete(false)
}
}
}
private fun sendNoisePayloadToPeer(
payload: NoisePayload,
recipientPeerID: String,
expectedSession: com.bitchat.android.noise.AuthenticatedNoiseSession? = null
expectedSession: com.bitchat.android.noise.AuthenticatedNoiseSession? = null,
preflight: () -> Boolean = { true },
directAdmissionPeerID: String? = null,
completion: ((admitted: Boolean) -> Unit)? = null
) {
scope.launch {
val completionDelivered = AtomicBoolean(false)
fun complete(admitted: Boolean) {
if (completionDelivered.compareAndSet(false, true)) {
runCatching { completion?.invoke(admitted) }
}
}
val job = scope.launch {
var admitted = false
try {
if (!preflight()) {
return@launch
}
val encrypted = if (expectedSession == null) {
encryptionService.encrypt(payload.encode(), recipientPeerID)
} else {
@ -784,11 +870,22 @@ class MeshCore(
signature = null,
ttl = maxTtl
)
dispatchGlobal(RoutedPacket(signPacketBeforeBroadcast(packet)))
val signedPacket = signPacketBeforeBroadcast(packet)
admitted = if (directAdmissionPeerID != null) {
transport.sendPacketToPeer(directAdmissionPeerID, signedPacket)
} else {
dispatchGlobal(RoutedPacket(signedPacket))
true
}
} catch (e: Exception) {
Log.e("MeshCore", "Failed to send Noise payload to $recipientPeerID: ${e.message}")
} finally {
complete(admitted)
}
}
job.invokeOnCompletion {
complete(false)
}
}
fun sendBroadcastAnnounce() {

View File

@ -13,7 +13,7 @@ interface MeshDelegate {
fun didReceiveReadReceipt(messageID: String, recipientPeerID: String)
fun didReceiveVerifyChallenge(peerID: String, payload: ByteArray, timestampMs: Long) {}
fun didReceiveVerifyResponse(peerID: String, payload: ByteArray, timestampMs: Long) {}
fun didReceiveNdrEvent(peerID: String, payload: ByteArray, timestampMs: Long) {}
fun didReceiveNdrEvent(route: NdrMeshRoute, payload: ByteArray, timestampMs: Long) {}
/** Current Noise generation either proved peer state or exhausted its 5-second watchdog. */
fun didResolvePrivateMediaPolicy(peerID: String) {}
fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String?

View File

@ -1,6 +1,25 @@
package com.bitchat.android.mesh
import com.bitchat.android.model.BitchatFilePacket
import com.bitchat.android.noise.AuthenticatedNoiseSession
data class NdrTransportTarget(
val endpointId: String,
val generationToken: Any
)
/**
* One exact authenticated Noise generation on one transport.
*
* NDR OOB responses must never be routed through a reusable peer alias:
* replacing the Noise session invalidates this token.
*/
data class NdrMeshRoute(
val transportId: String,
val peerID: String,
val authenticatedSession: AuthenticatedNoiseSession,
val transportTarget: NdrTransportTarget
)
/**
* Transport-agnostic mesh service API for UI and routing layers.
@ -19,7 +38,15 @@ interface MeshService {
fun sendFavoriteNotification(peerID: String, isFavorite: Boolean) {}
fun sendVerifyChallenge(peerID: String, noiseKeyHex: String, nonceA: ByteArray)
fun sendVerifyResponse(peerID: String, noiseKeyHex: String, nonceA: ByteArray)
fun sendNdrEvent(peerID: String, payload: String): Boolean
fun currentNdrRoute(peerID: String, transportId: String? = null): NdrMeshRoute? = null
fun sendNdrEvent(
route: NdrMeshRoute,
payload: String,
isStillAuthorized: () -> Boolean,
completion: (admitted: Boolean) -> Unit
) {
completion(false)
}
fun sendFileBroadcast(file: BitchatFilePacket)
fun sendFilePrivate(recipientPeerID: String, file: BitchatFilePacket)
fun prepareFilePrivate(

View File

@ -13,6 +13,18 @@ interface MeshTransport {
fun sendPacketToPeer(peerID: String, packet: BitchatPacket): Boolean
fun currentNdrTransportTarget(peerID: String): NdrTransportTarget? = null
fun sendPacketToNdrTargetConfirmed(
peerID: String,
target: NdrTransportTarget,
routed: RoutedPacket,
preflight: () -> Boolean,
completion: (Boolean) -> Unit
) {
completion(false)
}
/**
* Send through an exact transport generation rather than a reusable peer alias.
* Transports that cannot prove the link identity must decline the operation.

View File

@ -23,6 +23,7 @@ class UnifiedMeshService(
companion object {
private const val TAG = "UnifiedMeshService"
private const val BLE_NDR_TRANSPORT_ID = "BLE"
}
override val myPeerID: String
@ -114,18 +115,39 @@ class UnifiedMeshService(
}
}
override fun sendNdrEvent(peerID: String, payload: String): Boolean {
if (!NdrFeatureGate.isEnabled()) return false
val capability = com.bitchat.android.model.PeerCapabilities.NOSTR_DOUBLE_RATCHET
return when {
bleSupportsAuthenticatedCapability(peerID, capability) ->
bluetooth.sendNdrEvent(peerID, payload)
wifiSupportsAuthenticatedCapability(peerID, capability) ->
wifiService()?.sendNdrEvent(peerID, payload) == true
else -> false
override fun currentNdrRoute(peerID: String, transportId: String?): NdrMeshRoute? {
if (!NdrFeatureGate.isEnabled()) return null
return when (transportId) {
null -> bluetooth.currentNdrRoute(peerID)
?: wifiService()?.currentNdrRoute(peerID)
BLE_NDR_TRANSPORT_ID ->
bluetooth.currentNdrRoute(peerID, transportId)
else -> wifiService()?.currentNdrRoute(peerID, transportId)
}
}
override fun sendNdrEvent(
route: NdrMeshRoute,
payload: String,
isStillAuthorized: () -> Boolean,
completion: (admitted: Boolean) -> Unit
) {
if (!NdrFeatureGate.isEnabled()) {
completion(false)
return
}
if (route.transportId == BLE_NDR_TRANSPORT_ID) {
bluetooth.sendNdrEvent(route, payload, isStillAuthorized, completion)
return
}
val wifi = wifiService()
if (wifi == null) {
completion(false)
return
}
wifi.sendNdrEvent(route, payload, isStillAuthorized, completion)
}
override fun sendFileBroadcast(file: BitchatFilePacket) {
when {
isBleEnabled() -> bluetooth.sendFileBroadcast(file)
@ -402,8 +424,8 @@ class UnifiedMeshService(
delegate?.didReceiveVerifyResponse(peerID, payload, timestampMs)
}
override fun didReceiveNdrEvent(peerID: String, payload: ByteArray, timestampMs: Long) {
delegate?.didReceiveNdrEvent(peerID, payload, timestampMs)
override fun didReceiveNdrEvent(route: NdrMeshRoute, payload: ByteArray, timestampMs: Long) {
delegate?.didReceiveNdrEvent(route, payload, timestampMs)
}
override fun didResolvePrivateMediaPolicy(peerID: String) {

View File

@ -5,8 +5,8 @@ import com.bitchat.android.BuildConfig
/**
* Coordinated rollout gate for Nostr double-ratchet transport.
*
* Production builds stay fail-closed until the kind-1402 envelope migration
* is implemented and the maintainers explicitly enable the rollout.
* Production builds stay fail-closed until the pairwise NDR implementations
* are reviewed and ready to be enabled together on Apple and Android.
*/
object NdrFeatureGate {
@Volatile

View File

@ -2,52 +2,88 @@ package com.bitchat.android.nostr
internal data class NdrApplicationMessage(
val content: String,
val timestampMs: Long
)
val timestampMs: Long,
val expiresAtSeconds: Long?
) {
fun isExpiredAt(nowSeconds: Long): Boolean =
expiresAtSeconds?.let { it <= nowSeconds } == true
}
internal object NdrApplicationMessageDecoder {
private const val PROTOCOL_TAG = "ndr-protocol"
private const val PROTOCOL_VALUE = "pairwise-rumor"
private const val VERSION_TAG = "ndr-version"
private const val VERSION_VALUE = "1"
private const val MILLISECOND_TIMESTAMP_TAG = "ms"
private const val EXPIRATION_TAG = "expiration"
private val UNSIGNED_DECIMAL = Regex("^[0-9]+$")
fun decode(
message: NdrDecryptedMessage,
fallbackTimestampMs: Long = System.currentTimeMillis()
): NdrApplicationMessage? {
fun decode(message: NdrDecryptedMessage): NdrApplicationMessage? =
runCatching { decodeStrict(message) }.getOrNull()
private fun decodeStrict(message: NdrDecryptedMessage): NdrApplicationMessage? {
val plaintext = message.content.trim()
if (!NdrInputPolicy.isWithinEncodedEventLimit(plaintext) ||
!NdrInputPolicy.isPubkeyHex(message.senderPubkeyHex) ||
message.senderDevicePubkeyHex?.let(NdrInputPolicy::isPubkeyHex) == false ||
message.conversationOwnerPubkeyHex?.let(NdrInputPolicy::isPubkeyHex) == false ||
message.eventId?.let(NdrInputPolicy::isEventIdHex) == false
!NdrInputPolicy.isEventIdHex(message.eventId)
) return null
// Compatibility with the earliest BitChat NDR prototype, which sent
// the embedded packet directly instead of the v1 pairwise rumor.
if (plaintext.startsWith("bitchat1:")) {
return NdrApplicationMessage(plaintext, fallbackTimestampMs)
}
val event = NostrEvent.fromJsonString(plaintext) ?: return null
if (event.kind != NostrKind.DIRECT_MESSAGE) return null
if (event.sig != null) return null
if (!NdrInputPolicy.isPubkeyHex(event.pubkey)) return null
if (!event.pubkey.equals(message.senderPubkeyHex, ignoreCase = true)) return null
if (event.createdAt <= 0 || event.id.isBlank()) return null
if (!event.id.equals(event.computeEventIdHex(), ignoreCase = true)) return null
if (!message.eventId.equals(event.id, ignoreCase = true)) return null
if (!NdrInputPolicy.hasBoundedTags(event)) return null
if (!event.hasTag(PROTOCOL_TAG, PROTOCOL_VALUE)) return null
if (!event.hasTag(VERSION_TAG, VERSION_VALUE)) return null
if (!event.hasExactlyOneTag(PROTOCOL_TAG, PROTOCOL_VALUE)) return null
if (!event.hasExactlyOneTag(VERSION_TAG, VERSION_VALUE)) return null
val timestampMs = event.requiredMillisecondTimestamp() ?: return null
val expiresAtSeconds = event.optionalExpirationSeconds() ?: run {
if (event.tags.any { it.firstOrNull() == EXPIRATION_TAG }) return null
null
}
val actionExpiresAtSeconds = message.expiresAtSeconds?.let {
if (it > Long.MAX_VALUE.toULong()) return null
it.toLong()
}
if (actionExpiresAtSeconds != expiresAtSeconds) return null
return NdrApplicationMessage(
content = event.content,
timestampMs = event.createdAt.toLong() * 1000L
timestampMs = timestampMs,
expiresAtSeconds = expiresAtSeconds
)
}
private fun NostrEvent.hasTag(name: String, value: String): Boolean {
return tags.any { tag ->
tag.size >= 2 && tag[0] == name && tag[1] == value
}
private fun NostrEvent.hasExactlyOneTag(name: String, value: String): Boolean {
val matches = tags.filter { it.firstOrNull() == name }
return matches.size == 1 &&
matches.single().size == 2 &&
matches.single()[1] == value
}
private fun NostrEvent.optionalExpirationSeconds(): Long? {
val matches = tags.filter { it.firstOrNull() == EXPIRATION_TAG }
if (matches.isEmpty()) return null
if (matches.size != 1) return null
val tag = matches.single()
if (tag.size != 2 || !UNSIGNED_DECIMAL.matches(tag[1])) return null
return tag[1]
.toULongOrNull()
?.takeIf { it <= Long.MAX_VALUE.toULong() }
?.toLong()
}
private fun NostrEvent.requiredMillisecondTimestamp(): Long? {
val matches = tags.filter { it.firstOrNull() == MILLISECOND_TIMESTAMP_TAG }
if (matches.size != 1) return null
val tag = matches.single()
if (tag.size != 2 || !UNSIGNED_DECIMAL.matches(tag[1])) return null
return tag[1]
.toULongOrNull()
?.takeIf { it <= Long.MAX_VALUE.toULong() }
?.toLong()
}
}

View File

@ -0,0 +1,51 @@
package com.bitchat.android.nostr
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
interface NdrEstablishedSessionMarkerStore {
fun contains(accountPubkeyHex: String): Boolean
fun mark(accountPubkeyHex: String)
fun clearAll()
}
/**
* A downgrade marker intentionally stored outside the ratchet database tree.
*
* If the database is later missing while this marker remains, the host must
* fail closed instead of silently creating a fresh no-session runtime.
*/
internal class FileNdrEstablishedSessionMarkerStore(
private val directory: File
) : NdrEstablishedSessionMarkerStore {
override fun contains(accountPubkeyHex: String): Boolean =
markerFile(accountPubkeyHex).isFile
override fun mark(accountPubkeyHex: String) {
check(NdrInputPolicy.isPubkeyHex(accountPubkeyHex))
if (!directory.exists() && !directory.mkdirs()) {
throw IOException("Failed to create NDR marker directory")
}
val marker = markerFile(accountPubkeyHex)
if (marker.isFile) return
val temporary = File(directory, ".${marker.name}.tmp")
FileOutputStream(temporary).use { output ->
output.write("pairwise-v1\n".toByteArray(Charsets.UTF_8))
output.fd.sync()
}
if (!temporary.renameTo(marker)) {
temporary.delete()
throw IOException("Failed to publish NDR downgrade marker")
}
}
override fun clearAll() {
if (directory.exists() && !directory.deleteRecursively()) {
throw IOException("Failed to clear NDR downgrade markers")
}
}
private fun markerFile(accountPubkeyHex: String): File =
File(directory, "${accountPubkeyHex.lowercase()}.established")
}

View File

@ -0,0 +1,133 @@
package com.bitchat.android.nostr
import com.bitchat.android.mesh.NdrMeshRoute
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.coroutines.resume
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
internal data class NdrInviteRetryToken(
val peerID: String,
val peerPubkeyHex: String,
val inviteEventId: String,
val route: NdrMeshRoute
)
internal data class NdrInviteRetryRequest(
val token: NdrInviteRetryToken,
val eventJson: String
)
/**
* Retries admission of one invite on one exact authenticated transport generation.
*
* A repeated trigger for the same token cannot reset its finite retry budget. Every delayed
* attempt is revalidated by the caller so a replaced Noise generation, changed invite,
* favorite revocation/rebind, or completed pairwise session makes the request stale.
*/
internal class NdrInviteRetryCoordinator(
private val scope: CoroutineScope,
private val retryDelaysMs: List<Long> = DEFAULT_RETRY_DELAYS_MS,
private val isStillValid: (NdrInviteRetryRequest) -> Boolean,
private val send: (
request: NdrInviteRetryRequest,
completion: (admitted: Boolean) -> Unit
) -> Unit,
private val onAdmitted: (NdrInviteRetryRequest) -> Unit
) {
private data class ActiveRetry(
val request: NdrInviteRetryRequest,
val job: Job
)
private val lock = Any()
private val activeRetries = mutableMapOf<String, ActiveRetry>()
fun start(request: NdrInviteRetryRequest) {
val peerID = request.token.peerID
val job = scope.launch(start = CoroutineStart.LAZY) {
runAttempts(request)
}
val shouldStart = synchronized(lock) {
val current = activeRetries[peerID]
if (current?.request?.token == request.token) {
false
} else {
current?.job?.cancel()
activeRetries[peerID] = ActiveRetry(request, job)
true
}
}
if (shouldStart) {
job.start()
} else {
job.cancel()
}
}
fun cancel(peerID: String) {
synchronized(lock) {
activeRetries.remove(peerID)
}?.job?.cancel()
}
fun retainPeers(peerIDs: Set<String>) {
val retired = synchronized(lock) {
val stalePeerIDs = activeRetries.keys - peerIDs
stalePeerIDs.mapNotNull(activeRetries::remove)
}
retired.forEach { it.job.cancel() }
}
fun cancelAll() {
val retired = synchronized(lock) {
activeRetries.values.toList().also { activeRetries.clear() }
}
retired.forEach { it.job.cancel() }
}
private suspend fun runAttempts(request: NdrInviteRetryRequest) {
for (attemptIndex in 0..retryDelaysMs.size) {
if (attemptIndex > 0) {
delay(retryDelaysMs[attemptIndex - 1])
}
if (!isCurrent(request) || !isStillValid(request)) return
val admitted = awaitAdmission(request)
if (!isCurrent(request)) return
if (admitted) {
onAdmitted(request)
return
}
}
}
private suspend fun awaitAdmission(request: NdrInviteRetryRequest): Boolean =
suspendCancellableCoroutine { continuation ->
val delivered = AtomicBoolean(false)
try {
send(request) { admitted ->
if (delivered.compareAndSet(false, true) && continuation.isActive) {
continuation.resume(admitted)
}
}
} catch (_: Exception) {
if (delivered.compareAndSet(false, true) && continuation.isActive) {
continuation.resume(false)
}
}
}
private fun isCurrent(request: NdrInviteRetryRequest): Boolean =
synchronized(lock) {
activeRetries[request.token.peerID]?.request === request
}
companion object {
internal val DEFAULT_RETRY_DELAYS_MS = listOf(250L, 500L, 1_000L, 2_000L)
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,28 @@
package com.bitchat.android.nostr
import com.bitchat.android.mesh.NdrMeshRoute
internal data class NdrFavoriteRouteBinding(
val isMutual: Boolean,
val peerPubkeyHex: String?
)
/**
* Rechecks both independent authorizations immediately before an OOB frame is encrypted:
* the exact Noise generation must still be live, and that generation's static key must still
* belong to the mutual favorite bound to the action's pairwise Nostr peer.
*/
internal object NdrOutOfBandRoutePolicy {
fun isAuthorized(
route: NdrMeshRoute,
expectedPeerPubkeyHex: String,
currentRoute: (peerID: String, transportId: String) -> NdrMeshRoute?,
favoriteBinding: (noisePublicKey: ByteArray) -> NdrFavoriteRouteBinding?
): Boolean {
if (!NdrInputPolicy.isPubkeyHex(expectedPeerPubkeyHex)) return false
if (currentRoute(route.peerID, route.transportId) != route) return false
val binding = favoriteBinding(route.authenticatedSession.remoteStaticKey) ?: return false
return binding.isMutual &&
binding.peerPubkeyHex?.equals(expectedPeerPubkeyHex, ignoreCase = true) == true
}
}

View File

@ -2,39 +2,50 @@ package com.bitchat.android.nostr
data class NdrPubSubEvent(
val kind: String,
val actionId: String = kind,
val subid: String? = null,
val filterJson: String? = null,
val eventJson: String? = null,
val peerPubkeyHex: String? = null,
val sessionId: String? = null,
val senderPubkeyHex: String? = null,
val senderDevicePubkeyHex: String? = null,
val conversationOwnerPubkeyHex: String? = null,
val content: String? = null,
val eventId: String? = null
val eventId: String? = null,
val expiresAtSeconds: ULong? = null
)
data class NdrDecryptedMessage(
val content: String,
val senderPubkeyHex: String,
val senderDevicePubkeyHex: String? = null,
val conversationOwnerPubkeyHex: String? = null,
val eventId: String? = null
) {
/**
* Iris sets [conversationOwnerPubkeyHex] on a local-sibling copy. The
* authenticated author remains [senderPubkeyHex], while app routing must
* use the remote conversation owner.
*/
val conversationPubkeyHex: String
get() = conversationOwnerPubkeyHex ?: senderPubkeyHex
val eventId: String,
val actionId: String,
val expiresAtSeconds: ULong? = null
)
val isLocalSiblingCopy: Boolean
get() = conversationOwnerPubkeyHex != null
enum class NdrDeliveryResult {
CONSUMED,
DUPLICATE,
REJECTED,
RETRY;
fun isAttributedToLocalAccount(localAccountPubkeyHex: String): Boolean =
!isLocalSiblingCopy ||
senderPubkeyHex.equals(localAccountPubkeyHex, ignoreCase = true)
val shouldAcknowledge: Boolean
get() = this != RETRY
}
enum class NdrSendResult {
SENT,
NO_SESSION,
FAILED
}
data class NdrOutOfBandPayload(
val actionId: String,
val eventJson: String,
val peerPubkeyHex: String,
internal val runtimeEpoch: Long? = null,
internal val runtime: NdrPairwiseRuntime? = null
)
internal object NdrInputPolicy {
const val MAX_ENCODED_EVENT_BYTES = 64 * 1024
private const val MAX_EVENT_TAGS = 64
@ -50,27 +61,25 @@ internal object NdrInputPolicy {
value.length <= MAX_ENCODED_EVENT_BYTES &&
value.toByteArray(Charsets.UTF_8).size <= MAX_ENCODED_EVENT_BYTES
fun hasBoundedTags(event: NostrEvent): Boolean {
if (event.tags.size > MAX_EVENT_TAGS) return false
return event.tags.all { tag ->
tag.size <= MAX_EVENT_TAG_VALUES &&
tag.all { value ->
value.length <= MAX_EVENT_TAG_VALUE_BYTES &&
value.toByteArray(Charsets.UTF_8).size <= MAX_EVENT_TAG_VALUE_BYTES
}
fun hasBoundedTags(event: NostrEvent): Boolean = runCatching {
event.tags.size <= MAX_EVENT_TAGS &&
event.tags.all { tag ->
tag.size <= MAX_EVENT_TAG_VALUES &&
tag.all { value ->
value.length <= MAX_EVENT_TAG_VALUE_BYTES &&
value.toByteArray(Charsets.UTF_8).size <= MAX_EVENT_TAG_VALUE_BYTES
}
}
}
}.getOrDefault(false)
}
data class NdrAcceptInviteResult(
val ownerPubkeyHex: String,
val inviterDevicePubkeyHex: String,
val deviceId: String,
val peerPubkeyHex: String,
val createdNewSession: Boolean
)
data class NdrOutOfBandProcessResult(
val outboundPayloads: List<String>,
val outboundPayloads: List<NdrOutOfBandPayload>,
val sessionLookupPubkeyHex: String? = null
)
@ -80,33 +89,67 @@ class NdrSessionNotReadyException(
) : Exception(message, cause)
interface NdrRelayManager {
fun subscribe(filter: NostrFilter, id: String, handler: (NostrEvent) -> Unit)
fun subscribe(filter: NostrFilter, id: String, handler: (NostrEvent) -> Boolean)
fun unsubscribe(id: String)
fun sendEvent(event: NostrEvent)
fun sendEventConfirmed(event: NostrEvent, completion: (accepted: Boolean) -> Unit)
fun cancelConfirmedEvent(eventId: String)
fun setOnConnectionAvailable(handler: () -> Unit)
}
interface NdrSessionManager {
fun init()
fun knownPeerOwnerPubkeys(): List<String>
fun setupUser(userPubkeyHex: String)
fun acceptInviteFromEventJson(eventJson: String, ownerPubkeyHintHex: String?): NdrAcceptInviteResult
fun acceptInviteFromUrl(inviteUrl: String, ownerPubkeyHintHex: String?): NdrAcceptInviteResult
fun interface NdrRetryCancellation {
fun cancel()
}
fun interface NdrRetryScheduler {
fun schedule(delayMs: Long, task: () -> Unit): NdrRetryCancellation
}
data class NdrPairwiseSessionInfo(
val sendReady: Boolean,
val receiveReady: Boolean,
val trackedSenderPubkeys: List<String>
) {
val isActive: Boolean
get() = sendReady || receiveReady
}
data class NdrPairwiseSendResult(
val innerEventId: String,
val outerEventId: String
)
interface NdrPairwiseRuntime {
fun currentInviteEventJson(): String?
fun currentInviteUrl(root: String): String?
fun acceptInviteFromEventJson(
eventJson: String,
expectedPeerPubkeyHex: String
): NdrAcceptInviteResult
fun acceptInviteFromUrl(
inviteUrl: String,
expectedPeerPubkeyHex: String
): NdrAcceptInviteResult
fun processEvent(eventJson: String)
fun processOutOfBandResponse(eventJson: String, expectedOwnerPubkeyHex: String)
fun drainEvents(): List<NdrPubSubEvent>
fun getActiveSessionState(peerPubkeyHex: String): String?
fun sendText(recipientPubkeyHex: String, text: String, expiresAtSeconds: ULong? = null): List<String>
fun processOutOfBandResponse(eventJson: String, expectedPeerPubkeyHex: String)
fun pendingActions(nowSeconds: ULong): List<NdrPubSubEvent>
fun ackActions(actionIds: List<String>)
fun sessionInfo(peerPubkeyHex: String): NdrPairwiseSessionInfo?
fun knownPeerPubkeys(): List<String>
fun retirePeer(peerPubkeyHex: String): Boolean
fun sendText(
recipientPubkeyHex: String,
text: String,
expiresAtSeconds: ULong? = null
): NdrPairwiseSendResult
fun getOurPubkeyHex(): String
fun getTotalSessions(): ULong
fun destroy()
}
interface NdrSessionManagerFactory {
interface NdrPairwiseRuntimeFactory {
fun newWithStoragePath(
ourPubkeyHex: String,
ourIdentityPrivkeyHex: String,
deviceId: String,
storagePath: String,
ownerPubkeyHex: String?
): NdrSessionManager
storagePath: String
): NdrPairwiseRuntime
}

View File

@ -61,6 +61,13 @@ class NostrDirectMessageHandler(
return false
}
@Synchronized
private fun hasProcessed(id: String): Boolean = id in seen
private fun markProcessed(id: String) {
dedupe(id)
}
fun configureDoubleRatchet(identity: NostrIdentity) {
if (!NdrFeatureGate.isEnabled()) {
invalidateDoubleRatchetAccount()
@ -76,16 +83,22 @@ class NostrDirectMessageHandler(
// deliveries while the replacement runtime is initialized.
ndrService.onDecryptedMessage = null
ndrService.configureIfNeeded(identity)
ndrService.onDecryptedMessage = callback@{ message ->
ndrService.onDecryptedMessage = callback@{ message, completion ->
if (!NdrFeatureGate.isEnabled() || !ndrAccountEpochs.isCurrent(epoch)) {
completion(NdrDeliveryResult.REJECTED)
return@callback
}
val currentIdentity =
NostrIdentityBridge.getCurrentNostrIdentity(application) ?: return@callback
if (!currentIdentity.publicKeyHex.equals(epoch.accountPubkeyHex, ignoreCase = true)) {
NostrIdentityBridge.getCurrentNostrIdentity(application)
if (currentIdentity == null) {
completion(NdrDeliveryResult.RETRY)
return@callback
}
onDoubleRatchetMessage(message, currentIdentity, epoch, receiveJob)
if (!currentIdentity.publicKeyHex.equals(epoch.accountPubkeyHex, ignoreCase = true)) {
completion(NdrDeliveryResult.REJECTED)
return@callback
}
onDoubleRatchetMessage(message, currentIdentity, epoch, receiveJob, completion)
}
}
@ -134,49 +147,66 @@ class NostrDirectMessageHandler(
message: NdrDecryptedMessage,
identity: NostrIdentity,
epoch: NdrAccountEpoch,
receiveJob: Job
receiveJob: Job,
completion: (NdrDeliveryResult) -> Unit
) {
scope.launch(Dispatchers.Default + receiveJob) {
var result = NdrDeliveryResult.RETRY
try {
if (!NdrFeatureGate.isEnabled() || !ndrAccountEpochs.isCurrent(epoch)) {
result = NdrDeliveryResult.REJECTED
return@launch
}
val dedupeId = message.eventId
?: "${message.senderPubkeyHex}:${message.content.hashCode()}"
var duplicate = false
if (!ndrAccountEpochs.runIfCurrent(epoch) {
duplicate = dedupe(dedupeId)
}
) return@launch
if (duplicate) return@launch
if (seenStore.hasProcessedNdr(dedupeId) || hasProcessed(dedupeId)) {
result = NdrDeliveryResult.DUPLICATE
return@launch
}
// iris-chat-rs returns a v1 unsigned kind-14 pairwise rumor.
// Bind that rumor to the ratchet-authenticated owner before
// The pairwise FFI returns a v1 unsigned kind-14 rumor.
// Bind that rumor to the ratchet-authenticated peer before
// allowing any inner fields into the application.
val applicationMessage =
NdrApplicationMessageDecoder.decode(message) ?: return@launch
val senderPubkey = message.senderPubkeyHex.lowercase()
if (!message.isAttributedToLocalAccount(identity.publicKeyHex)) {
val applicationMessage = NdrApplicationMessageDecoder.decode(message)
if (applicationMessage == null) {
result = NdrDeliveryResult.REJECTED
return@launch
}
val senderPubkey = message.senderPubkeyHex.lowercase()
if (dataManager.isGeohashUserBlocked(senderPubkey)) {
result = NdrDeliveryResult.REJECTED
return@launch
}
val conversationPubkey = message.conversationPubkeyHex.lowercase()
if (dataManager.isGeohashUserBlocked(conversationPubkey)) return@launch
if (!NdrFeatureGate.isEnabled() || !ndrAccountEpochs.isCurrent(epoch)) {
result = NdrDeliveryResult.REJECTED
return@launch
}
if (applicationMessage.isExpiredAt(System.currentTimeMillis() / 1_000L)) {
result = NdrDeliveryResult.REJECTED
return@launch
}
processEmbeddedBitChatContent(
result = processEmbeddedBitChatContent(
content = applicationMessage.content,
senderPubkey = senderPubkey,
conversationPubkey = conversationPubkey,
isLocalSiblingCopy = message.isLocalSiblingCopy,
timestamp = Date(applicationMessage.timestampMs),
geohash = "",
recipientIdentity = identity,
ndrEpoch = epoch
ndrEpoch = epoch,
ndrEventId = dedupeId,
expiresAtSeconds = applicationMessage.expiresAtSeconds
)
} catch (_: Exception) {
Log.e(TAG, "Failed to process double-ratchet message")
result = NdrDeliveryResult.RETRY
} finally {
if (result.shouldAcknowledge) {
if (seenStore.markProcessedNdr(message.eventId)) {
markProcessed(message.eventId)
} else {
result = NdrDeliveryResult.RETRY
}
}
completion(result)
}
}
}
@ -187,46 +217,52 @@ class NostrDirectMessageHandler(
timestamp: Date,
geohash: String,
recipientIdentity: NostrIdentity,
conversationPubkey: String = senderPubkey,
isLocalSiblingCopy: Boolean = false,
ndrEpoch: NdrAccountEpoch? = null
) {
if (!content.startsWith("bitchat1:")) return
ndrEpoch: NdrAccountEpoch? = null,
ndrEventId: String? = null,
expiresAtSeconds: Long? = null
): NdrDeliveryResult {
if (isExpired(expiresAtSeconds)) return NdrDeliveryResult.REJECTED
if (!content.startsWith("bitchat1:")) return NdrDeliveryResult.REJECTED
val packetData = base64URLDecode(content.removePrefix("bitchat1:")) ?: return
val packet = BitchatPacket.fromBinaryData(packetData) ?: return
if (packet.type != com.bitchat.android.protocol.MessageType.NOISE_ENCRYPTED.value) return
val packetData = base64URLDecode(content.removePrefix("bitchat1:"))
?: return NdrDeliveryResult.REJECTED
val packet = BitchatPacket.fromBinaryData(packetData)
?: return NdrDeliveryResult.REJECTED
if (packet.type != com.bitchat.android.protocol.MessageType.NOISE_ENCRYPTED.value) {
return NdrDeliveryResult.REJECTED
}
val noisePayload = NoisePayload.decode(packet.payload) ?: return
val convKey = "nostr_${conversationPubkey.take(16)}"
if (!runIfNdrEpochCurrent(ndrEpoch) {
repo.putNostrKeyMapping(convKey, conversationPubkey)
GeohashAliasRegistry.put(convKey, conversationPubkey)
val noisePayload = NoisePayload.decode(packet.payload)
?: return NdrDeliveryResult.REJECTED
val convKey = "nostr_${senderPubkey.take(16)}"
if (!runIfNdrMutationCurrent(ndrEpoch, expiresAtSeconds) {
repo.putNostrKeyMapping(convKey, senderPubkey)
GeohashAliasRegistry.put(convKey, senderPubkey)
if (geohash.isNotEmpty()) {
repo.setConversationGeohash(convKey, geohash)
GeohashConversationRegistry.set(convKey, geohash)
if (repo.getCachedNickname(conversationPubkey) == null) {
if (repo.getCachedNickname(senderPubkey) == null) {
val base =
repo.displayNameForNostrPubkeyUI(conversationPubkey).substringBefore("#")
repo.cacheNickname(conversationPubkey, base)
repo.displayNameForNostrPubkeyUI(senderPubkey).substringBefore("#")
repo.cacheNickname(senderPubkey, base)
}
repo.updateParticipant(geohash, conversationPubkey, timestamp)
repo.updateParticipant(geohash, senderPubkey, timestamp)
}
}
) return
) return NdrDeliveryResult.REJECTED
processNoisePayload(
return processNoisePayload(
payload = noisePayload,
conversationID = ContactDirectory.canonicalConversationId(convKey),
senderNickname = repo.displayNameForNostrPubkeyUI(conversationPubkey),
senderNickname = repo.displayNameForNostrPubkeyUI(senderPubkey),
timestamp = timestamp,
senderPubkey = senderPubkey,
conversationPubkey = conversationPubkey,
recipientIdentity = recipientIdentity,
allowAccountNdr = geohash.isEmpty(),
isLocalSiblingCopy = isLocalSiblingCopy,
ndrEpoch = ndrEpoch
ndrEpoch = ndrEpoch,
ndrEventId = ndrEventId,
expiresAtSeconds = expiresAtSeconds
)
}
@ -236,63 +272,43 @@ class NostrDirectMessageHandler(
senderNickname: String,
timestamp: Date,
senderPubkey: String,
conversationPubkey: String,
recipientIdentity: NostrIdentity,
allowAccountNdr: Boolean,
isLocalSiblingCopy: Boolean,
ndrEpoch: NdrAccountEpoch? = null
) {
if (!isNdrEpochCurrent(ndrEpoch)) return
when (payload.type) {
ndrEpoch: NdrAccountEpoch? = null,
ndrEventId: String? = null,
expiresAtSeconds: Long? = null
): NdrDeliveryResult {
if (!isNdrEpochCurrent(ndrEpoch) || isExpired(expiresAtSeconds)) {
return NdrDeliveryResult.REJECTED
}
return when (payload.type) {
NoisePayloadType.PRIVATE_MESSAGE -> {
val pm = PrivateMessagePacket.decode(payload.data) ?: return
val pm = PrivateMessagePacket.decode(payload.data)
?: return NdrDeliveryResult.REJECTED
val existingMessages = state.getPrivateChatsValue()[conversationID] ?: emptyList()
if (existingMessages.any { it.id == pm.messageID }) return
if (isLocalSiblingCopy) {
// A sibling device authored this message on our account.
// Show it as sent in the remote peer's thread, without
// acknowledging it, marking it unread, or notifying.
if (FavoriteControlMessage.parse(pm.content) != null) return
val message = BitchatMessage(
id = pm.messageID,
sender = state.getNicknameValue(),
content = pm.content,
timestamp = timestamp,
isRelay = false,
isPrivate = true,
recipientNickname =
repo.displayNameForNostrPubkeyUI(conversationPubkey),
// Existing Android conversation insertion routes from
// senderPeerID. Use the remote conversation ID here;
// sender nickname and status keep the row outgoing.
senderPeerID = conversationID,
deliveryStatus = DeliveryStatus.Sent
)
withContext(Dispatchers.Main) {
runIfNdrEpochCurrent(ndrEpoch) {
privateChatManager.handleIncomingPrivateMessage(
message = message,
suppressUnread = true,
origin = PrivateMessageOrigin.NOSTR
)
}
}
return
if (existingMessages.any { it.id == pm.messageID }) {
return NdrDeliveryResult.DUPLICATE
}
val favoriteControl = FavoriteControlMessage.parse(pm.content)
if (favoriteControl != null) {
if (!isNdrEpochCurrent(ndrEpoch)) return
handleFavoriteControl(
if (!isNdrEpochCurrent(ndrEpoch) || isExpired(expiresAtSeconds)) {
return NdrDeliveryResult.REJECTED
}
val favoriteResult = handleFavoriteControl(
favoriteControl,
conversationID,
senderNickname,
timestamp,
senderPubkey,
ndrEpoch
ndrEpoch,
ndrEventId,
expiresAtSeconds
)
if (!runIfNdrEpochCurrent(ndrEpoch) {
if (favoriteResult != NdrDeliveryResult.CONSUMED &&
favoriteResult != NdrDeliveryResult.DUPLICATE
) return favoriteResult
if (!runIfNdrMutationCurrent(ndrEpoch, expiresAtSeconds) {
if (!seenStore.hasDelivered(pm.messageID)) {
sendDeliveryAck(
pm.messageID,
@ -303,8 +319,8 @@ class NostrDirectMessageHandler(
seenStore.markDelivered(pm.messageID)
}
}
) return
return
) return NdrDeliveryResult.REJECTED
return favoriteResult
}
val message = BitchatMessage(
@ -325,7 +341,7 @@ class NostrDirectMessageHandler(
var messageAccepted = false
withContext(Dispatchers.Main) {
runIfNdrEpochCurrent(ndrEpoch) {
runIfNdrMutationCurrent(ndrEpoch, expiresAtSeconds) {
privateChatManager.handleIncomingPrivateMessage(
message = message,
suppressUnread = suppressUnread,
@ -334,9 +350,10 @@ class NostrDirectMessageHandler(
messageAccepted = true
}
}
if (!messageAccepted) return
if (!messageAccepted) return NdrDeliveryResult.REJECTED
if (!runIfNdrEpochCurrent(ndrEpoch) {
runCatching {
runIfNdrMutationCurrent(ndrEpoch, expiresAtSeconds) {
if (!seenStore.hasDelivered(pm.messageID)) {
sendDeliveryAck(
pm.messageID,
@ -366,38 +383,51 @@ class NostrDirectMessageHandler(
seenStore.markRead(pm.messageID)
}
}
) return
}
NdrDeliveryResult.CONSUMED
}
NoisePayloadType.DELIVERED -> {
if (isLocalSiblingCopy) return
val messageId = String(payload.data, Charsets.UTF_8)
var consumed = false
withContext(Dispatchers.Main) {
runIfNdrEpochCurrent(ndrEpoch) {
runIfNdrMutationCurrent(ndrEpoch, expiresAtSeconds) {
meshDelegateHandler.didReceiveDeliveryAck(messageId, conversationID)
consumed = true
}
}
if (consumed) NdrDeliveryResult.CONSUMED else NdrDeliveryResult.REJECTED
}
NoisePayloadType.READ_RECEIPT -> {
if (isLocalSiblingCopy) return
val messageId = String(payload.data, Charsets.UTF_8)
var consumed = false
withContext(Dispatchers.Main) {
runIfNdrEpochCurrent(ndrEpoch) {
runIfNdrMutationCurrent(ndrEpoch, expiresAtSeconds) {
meshDelegateHandler.didReceiveReadReceipt(messageId, conversationID)
consumed = true
}
}
if (consumed) NdrDeliveryResult.CONSUMED else NdrDeliveryResult.REJECTED
}
NoisePayloadType.FILE_TRANSFER -> {
if (isLocalSiblingCopy) return
// Properly handle encrypted file transfer
val file = BitchatFilePacket.decode(payload.data)
if (file != null) {
if (ndrEventId != null &&
state.getPrivateChatsValue()[conversationID]
.orEmpty()
.any { it.id.equals(ndrEventId, ignoreCase = true) }
) {
return NdrDeliveryResult.DUPLICATE
}
var message: BitchatMessage? = null
if (!runIfNdrEpochCurrent(ndrEpoch) {
val uniqueMsgId = java.util.UUID.randomUUID().toString().uppercase()
if (!runIfNdrMutationCurrent(ndrEpoch, expiresAtSeconds) {
val savedPath =
com.bitchat.android.features.file.FileUtils.saveIncomingFile(application, file)
com.bitchat.android.features.file.FileUtils.saveIncomingFile(
context = application,
file = file,
stableId = ndrEventId
)
message = BitchatMessage(
id = uniqueMsgId,
id = ndrEventId ?: java.util.UUID.randomUUID().toString().uppercase(),
sender = senderNickname,
content = savedPath,
type = com.bitchat.android.features.file.FileUtils.messageTypeForMime(file.mimeType),
@ -408,26 +438,43 @@ class NostrDirectMessageHandler(
senderPeerID = conversationID
)
}
) return
) return NdrDeliveryResult.REJECTED
val savedPath = message?.content
if (isExpired(expiresAtSeconds)) {
savedPath?.let { java.io.File(it).delete() }
return NdrDeliveryResult.REJECTED
}
var consumed = false
withContext(Dispatchers.Main) {
runIfNdrEpochCurrent(ndrEpoch) {
runIfNdrMutationCurrent(ndrEpoch, expiresAtSeconds) {
message?.let {
privateChatManager.handleIncomingPrivateMessage(
message = it,
suppressUnread = false,
origin = PrivateMessageOrigin.NOSTR
)
consumed = true
}
}
}
if (consumed) {
NdrDeliveryResult.CONSUMED
} else {
if (isExpired(expiresAtSeconds)) {
savedPath?.let { java.io.File(it).delete() }
}
NdrDeliveryResult.REJECTED
}
} else {
Log.w(TAG, "Failed to decode Nostr file transfer from $conversationID")
NdrDeliveryResult.REJECTED
}
}
NoisePayloadType.VERIFY_CHALLENGE,
NoisePayloadType.VERIFY_RESPONSE,
NoisePayloadType.PEER_STATE,
NoisePayloadType.NDR_EVENT -> Unit // Transport controls never arrive inside relay DMs.
NoisePayloadType.NDR_EVENT ->
NdrDeliveryResult.REJECTED // Transport controls never arrive inside relay DMs.
}
}
@ -447,6 +494,25 @@ class NostrDirectMessageHandler(
return ndrAccountEpochs.runIfCurrent(epoch, mutation)
}
private fun isExpired(expiresAtSeconds: Long?): Boolean =
expiresAtSeconds?.let { it <= System.currentTimeMillis() / 1_000L } == true
private fun runIfNdrMutationCurrent(
epoch: NdrAccountEpoch?,
expiresAtSeconds: Long?,
mutation: () -> Unit
): Boolean {
if (isExpired(expiresAtSeconds)) return false
var applied = false
val epochCurrent = runIfNdrEpochCurrent(epoch) {
if (!isExpired(expiresAtSeconds)) {
mutation()
applied = true
}
}
return epochCurrent && applied
}
private fun sendDeliveryAck(
messageId: String,
senderPubkey: String,
@ -479,20 +545,31 @@ class NostrDirectMessageHandler(
senderNickname: String,
timestamp: Date,
senderPubkey: String,
ndrEpoch: NdrAccountEpoch? = null
) {
try {
ndrEpoch: NdrAccountEpoch? = null,
ndrEventId: String? = null,
expiresAtSeconds: Long? = null
): NdrDeliveryResult {
return try {
if (isExpired(expiresAtSeconds)) return NdrDeliveryResult.REJECTED
val targetConversationID = ContactDirectory.canonicalConversationId(conversationID)
if (ndrEventId != null &&
state.getPrivateChatsValue()[targetConversationID]
.orEmpty()
.any { it.id.equals(ndrEventId, ignoreCase = true) }
) {
return NdrDeliveryResult.DUPLICATE
}
val senderNpub = control.npub ?: ContactIdentityResolver.npubFromHex(senderPubkey)
val noiseKey = senderNpub?.let { FavoritesPersistenceService.shared.findNoiseKey(it) }
?: FavoritesPersistenceService.shared.findNoiseKey(senderPubkey)
if (noiseKey == null) {
Log.w(TAG, "Favorite notification from Nostr sender without known Noise key: ${senderPubkey.take(16)}...")
return
return NdrDeliveryResult.REJECTED
}
var systemMessage: BitchatMessage? = null
if (!runIfNdrEpochCurrent(ndrEpoch) {
if (!runIfNdrMutationCurrent(ndrEpoch, expiresAtSeconds) {
FavoritesPersistenceService.shared.updatePeerFavoritedUs(
noiseKey,
control.isFavorite
@ -500,9 +577,6 @@ class NostrDirectMessageHandler(
senderNpub?.let {
FavoritesPersistenceService.shared.updateNostrPublicKey(noiseKey, it)
}
val targetConversationID =
ContactDirectory.canonicalConversationId(conversationID)
val relationship = FavoritesPersistenceService.shared.getFavoriteStatus(noiseKey)
val displayName = relationship
?.peerNickname
@ -519,6 +593,7 @@ class NostrDirectMessageHandler(
}
val action = if (control.isFavorite) "favorited" else "unfavorited"
systemMessage = BitchatMessage(
id = ndrEventId ?: java.util.UUID.randomUUID().toString().uppercase(),
sender = "system",
content = "$displayName $action you$guidance",
timestamp = timestamp,
@ -527,21 +602,37 @@ class NostrDirectMessageHandler(
senderPeerID = targetConversationID
)
}
) return
) return NdrDeliveryResult.REJECTED
var consumed = false
var duplicate = false
withContext(Dispatchers.Main) {
runIfNdrEpochCurrent(ndrEpoch) {
runIfNdrMutationCurrent(ndrEpoch, expiresAtSeconds) {
systemMessage?.let {
privateChatManager.handleIncomingPrivateMessage(
message = it,
suppressUnread = true,
origin = PrivateMessageOrigin.NOSTR
)
if (state.getPrivateChatsValue()[targetConversationID]
.orEmpty()
.any { existing -> existing.id.equals(it.id, ignoreCase = true) }
) {
duplicate = true
} else {
privateChatManager.handleIncomingPrivateMessage(
message = it,
suppressUnread = true,
origin = PrivateMessageOrigin.NOSTR
)
consumed = true
}
}
}
}
when {
duplicate -> NdrDeliveryResult.DUPLICATE
consumed -> NdrDeliveryResult.CONSUMED
else -> NdrDeliveryResult.REJECTED
}
} catch (e: Exception) {
Log.w(TAG, "Failed to handle Nostr favorite notification: ${e.message}")
NdrDeliveryResult.RETRY
}
}

View File

@ -54,7 +54,9 @@ class NostrEventDeduplicator(
private val tail = LRUNode("TAIL") // Dummy tail node
// Lock for thread-safe LRU operations
private val lruLock = Any()
@Suppress("PLATFORM_CLASS_MAPPED_TO_KOTLIN")
private val lruLock = java.lang.Object()
private val eventIdsBeingProcessed = mutableSetOf<String>()
// Statistics
@Volatile
@ -123,6 +125,55 @@ class NostrEventDeduplicator(
false
}
}
/**
* Runs [processor] without consuming the event ID first. The ID enters the
* dedupe cache only if the processor reports a successful durable commit.
*/
fun processEventAfterSuccess(
event: NostrEvent,
processor: (NostrEvent) -> Boolean
): Boolean {
totalChecks++
synchronized(lruLock) {
while (event.id in eventIdsBeingProcessed) {
try {
lruLock.wait()
} catch (_: InterruptedException) {
Thread.currentThread().interrupt()
return false
}
}
nodeMap[event.id]?.let { existing ->
moveToFront(existing)
duplicateCount++
return false
}
eventIdsBeingProcessed += event.id
}
var committed = false
try {
committed = processor(event)
return committed
} finally {
synchronized(lruLock) {
if (committed) {
val existing = nodeMap[event.id]
if (existing != null) {
moveToFront(existing)
} else {
addToFront(event.id)
if (nodeMap.size > maxCapacity) {
evictOldest()
}
}
}
eventIdsBeingProcessed.remove(event.id)
lruLock.notifyAll()
}
}
}
/**
* Get current statistics about the deduplicator

View File

@ -14,11 +14,19 @@ import java.util.concurrent.TimeUnit
import kotlin.math.min
import kotlin.math.pow
internal fun isNip20ConfirmedSuccess(accepted: Boolean, message: String?): Boolean =
accepted || message?.startsWith("duplicate:") == true
/**
* Manages WebSocket connections to Nostr relays
* Compatible with iOS implementation with Android-specific optimizations
*/
class NostrRelayManager private constructor() {
class NostrRelayManager internal constructor(
private val scope: CoroutineScope =
CoroutineScope(Dispatchers.IO + SupervisorJob()),
private val eventDeduplicator: NostrEventDeduplicator =
NostrEventDeduplicator.getInstance()
) {
companion object {
@JvmStatic
@ -46,6 +54,7 @@ class NostrRelayManager private constructor() {
private const val MAX_BACKOFF_INTERVAL = com.bitchat.android.util.AppConstants.Nostr.MAX_BACKOFF_INTERVAL_MS // 5 minutes
private const val BACKOFF_MULTIPLIER = com.bitchat.android.util.AppConstants.Nostr.BACKOFF_MULTIPLIER
private const val MAX_RECONNECT_ATTEMPTS = com.bitchat.android.util.AppConstants.Nostr.MAX_RECONNECT_ATTEMPTS
private const val CONFIRMED_PUBLISH_TIMEOUT_MS = 15_000L
// Track gift-wraps we initiated for logging
private val pendingGiftWrapIDs = ConcurrentHashMap.newKeySet<String>()
@ -84,6 +93,8 @@ class NostrRelayManager private constructor() {
private val connections = ConcurrentHashMap<String, WebSocket>()
private val subscriptions = ConcurrentHashMap<String, Set<String>>() // relay URL -> subscription IDs
private val messageHandlers = ConcurrentHashMap<String, (NostrEvent) -> Unit>()
private val commitAwareMessageHandlers =
ConcurrentHashMap<String, (NostrEvent) -> Boolean>()
// Persistent subscription tracking for robust reconnection
private val activeSubscriptions = ConcurrentHashMap<String, SubscriptionInfo>() // subscription ID -> info
@ -100,15 +111,19 @@ class NostrRelayManager private constructor() {
val originGeohash: String? = null // used for logging and grouping
)
// Event deduplication system
private val eventDeduplicator = NostrEventDeduplicator.getInstance()
// Message queue for reliability
private val messageQueue = mutableListOf<Pair<NostrEvent, List<String>>>()
private val messageQueueLock = Any()
// Coroutine scope for background operations
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private data class ConfirmedPublish(
val awaitingRelayUrls: MutableSet<String>,
val completion: (Boolean) -> Unit,
@Volatile var timeoutJob: Job? = null
)
private val confirmedPublishes = ConcurrentHashMap<String, ConfirmedPublish>()
@Volatile
private var ndrConnectionAvailableHandler: (() -> Unit)? = null
// Subscription validation timer
private var subscriptionValidationJob: Job? = null
@ -261,6 +276,10 @@ class NostrRelayManager private constructor() {
// Stop subscription validation
stopSubscriptionValidation()
confirmedPublishes.entries.toList().forEach { (eventId, tracker) ->
completeConfirmedPublish(eventId, tracker, accepted = false)
}
connections.values.forEach { webSocket ->
webSocket.close(1000, "Manual disconnect")
@ -294,6 +313,73 @@ class NostrRelayManager private constructor() {
}
}
}
/**
* Sends without using the process-local retry queue and completes only
* after at least one relay returns an accepted NIP-01 OK.
*/
fun sendEventConfirmed(
event: NostrEvent,
relayUrls: List<String>? = null,
completion: (Boolean) -> Unit
) {
val requestedRelays = (relayUrls ?: relaysList.map { it.url }).toSet()
val connectedTargets = requestedRelays.filterTo(linkedSetOf()) {
connections.containsKey(it)
}
if (connectedTargets.isEmpty()) {
completion(false)
return
}
val tracker = ConfirmedPublish(
awaitingRelayUrls = ConcurrentHashMap.newKeySet<String>().apply {
addAll(connectedTargets)
},
completion = completion
)
if (confirmedPublishes.putIfAbsent(event.id, tracker) != null) {
completion(false)
return
}
tracker.timeoutJob = scope.launch {
delay(CONFIRMED_PUBLISH_TIMEOUT_MS)
completeConfirmedPublish(event.id, tracker, accepted = false)
}
connectedTargets.forEach { relayUrl ->
val webSocket = connections[relayUrl]
if (webSocket == null || !sendToRelay(event, webSocket, relayUrl)) {
tracker.awaitingRelayUrls.remove(relayUrl)
}
}
if (tracker.awaitingRelayUrls.isEmpty()) {
completeConfirmedPublish(event.id, tracker, accepted = false)
return
}
}
fun cancelConfirmedEvent(eventId: String) {
val tracker = confirmedPublishes.remove(eventId) ?: return
tracker.timeoutJob?.cancel()
runCatching { tracker.completion(false) }
.onFailure { Log.w(TAG, "Confirmed publish cancellation callback failed") }
}
fun setNdrConnectionAvailableHandler(handler: () -> Unit) {
ndrConnectionAvailableHandler = handler
}
private fun completeConfirmedPublish(
eventId: String,
tracker: ConfirmedPublish,
accepted: Boolean
) {
if (!confirmedPublishes.remove(eventId, tracker)) return
tracker.timeoutJob?.cancel()
runCatching { tracker.completion(accepted) }
.onFailure { Log.w(TAG, "Confirmed publish callback failed") }
}
/**
* Subscribe to events matching a filter
@ -312,14 +398,54 @@ class NostrRelayManager private constructor() {
handler = handler,
targetRelayUrls = targetRelayUrls?.toSet()
)
activeSubscriptions[id] = subscriptionInfo
messageHandlers[id] = handler
return registerSubscription(
subscriptionInfo = subscriptionInfo,
ordinaryHandler = handler
)
}
// Send subscription to appropriate relays
/**
* NDR relay copies are considered seen only after the durable runtime
* commits them. A transient storage failure must leave another relay copy
* eligible for processing.
*/
fun subscribeAfterSuccessfulProcessing(
filter: NostrFilter,
id: String,
handler: (NostrEvent) -> Boolean
): String {
val subscriptionInfo = SubscriptionInfo(
id = id,
filter = filter,
handler = {}
)
return registerSubscription(
subscriptionInfo = subscriptionInfo,
commitAwareHandler = handler
)
}
/**
* Installs the complete handler mode before any relay can observe the REQ.
* Some WebSocket implementations can synchronously deliver a cached EVENT
* from inside send(), so handler replacement after send is already too late.
*/
private fun registerSubscription(
subscriptionInfo: SubscriptionInfo,
ordinaryHandler: ((NostrEvent) -> Unit)? = null,
commitAwareHandler: ((NostrEvent) -> Boolean)? = null
): String {
require((ordinaryHandler == null) != (commitAwareHandler == null))
activeSubscriptions[subscriptionInfo.id] = subscriptionInfo
if (commitAwareHandler != null) {
commitAwareMessageHandlers[subscriptionInfo.id] = commitAwareHandler
messageHandlers.remove(subscriptionInfo.id)
} else {
messageHandlers[subscriptionInfo.id] = requireNotNull(ordinaryHandler)
commitAwareMessageHandlers.remove(subscriptionInfo.id)
}
sendSubscriptionToRelays(subscriptionInfo)
return id
return subscriptionInfo.id
}
/**
@ -363,6 +489,7 @@ class NostrRelayManager private constructor() {
// Remove from persistent tracking
val subscriptionInfo = activeSubscriptions.remove(id)
messageHandlers.remove(id)
commitAwareMessageHandlers.remove(id)
if (subscriptionInfo == null) {
Log.w(TAG, "Attempted to unsubscribe from unknown subscription: $id")
@ -446,6 +573,7 @@ class NostrRelayManager private constructor() {
// Clear persistent subscription tracking
activeSubscriptions.clear()
messageHandlers.clear()
commitAwareMessageHandlers.clear()
subscriptions.clear()
// Clear routing caches (per-geohash relay selections)
@ -606,8 +734,8 @@ class NostrRelayManager private constructor() {
}
}
private fun sendToRelay(event: NostrEvent, webSocket: WebSocket, relayUrl: String) {
try {
private fun sendToRelay(event: NostrEvent, webSocket: WebSocket, relayUrl: String): Boolean {
return try {
val request = NostrRequest.Event(event)
val message = gson.toJson(request, NostrRequest::class.java)
@ -615,13 +743,16 @@ class NostrRelayManager private constructor() {
if (success) {
// Update relay stats
val relay = relaysList.find { it.url == relayUrl }
relay?.messagesSent = (relay?.messagesSent ?: 0) + 1
relay?.let { it.messagesSent += 1 }
updateRelaysList()
true
} else {
Log.e(TAG, "Failed to send event to $relayUrl: WebSocket send failed")
false
}
} catch (e: Exception) {
Log.e(TAG, "Failed to send event to $relayUrl: ${e.message}")
false
}
}
@ -639,7 +770,7 @@ class NostrRelayManager private constructor() {
is NostrResponse.Event -> {
// Update relay stats
val relay = relaysList.find { it.url == relayUrl }
relay?.messagesReceived = (relay?.messagesReceived ?: 0) + 1
relay?.let { it.messagesReceived += 1 }
updateRelaysList()
// CLIENT-SIDE FILTER ENFORCEMENT: Ensure this event matches the subscription's filter
@ -651,6 +782,17 @@ class NostrRelayManager private constructor() {
}
}
val commitAwareHandler =
commitAwareMessageHandlers[response.subscriptionId]
if (commitAwareHandler != null) {
scope.launch {
eventDeduplicator.processEventAfterSuccess(response.event) { event ->
commitAwareHandler(event)
}
}
return
}
// DEDUPLICATION: Check if we've already processed this event
eventDeduplicator.processEvent(response.event) { event ->
// Call handler for new events only
@ -671,7 +813,16 @@ class NostrRelayManager private constructor() {
is NostrResponse.Ok -> {
val wasGiftWrap = pendingGiftWrapIDs.remove(response.eventId)
if (!response.accepted) {
confirmedPublishes[response.eventId]?.let { tracker ->
if (isNip20ConfirmedSuccess(response.accepted, response.message)) {
completeConfirmedPublish(response.eventId, tracker, accepted = true)
} else if (tracker.awaitingRelayUrls.remove(relayUrl) &&
tracker.awaitingRelayUrls.isEmpty()
) {
completeConfirmedPublish(response.eventId, tracker, accepted = false)
}
}
if (!isNip20ConfirmedSuccess(response.accepted, response.message)) {
val level = if (wasGiftWrap) Log.WARN else Log.ERROR
Log.println(level, TAG, "Event rejected by relay $relayUrl: ${response.message ?: "no reason"}")
}
@ -692,6 +843,13 @@ class NostrRelayManager private constructor() {
private fun handleDisconnection(relayUrl: String, error: Throwable) {
connections.remove(relayUrl)
confirmedPublishes.entries.toList().forEach { (eventId, tracker) ->
if (tracker.awaitingRelayUrls.remove(relayUrl) &&
tracker.awaitingRelayUrls.isEmpty()
) {
completeConfirmedPublish(eventId, tracker, accepted = false)
}
}
// NOTE: Don't remove subscriptions here - keep them for restoration on reconnection
// subscriptions.remove(relayUrl) // REMOVED - this was causing subscription loss
@ -810,6 +968,8 @@ class NostrRelayManager private constructor() {
override fun onOpen(webSocket: WebSocket, response: Response) {
Log.i(TAG, "Connected to Nostr relay: $relayUrl")
updateRelayStatus(relayUrl, true)
runCatching { ndrConnectionAvailableHandler?.invoke() }
.onFailure { Log.w(TAG, "NDR reconnect callback failed") }
// Restore all active subscriptions for this relay
restoreSubscriptionsForRelay(relayUrl, webSocket)

View File

@ -12,6 +12,9 @@ import kotlinx.coroutines.*
import java.util.*
import java.util.concurrent.ConcurrentLinkedQueue
internal fun shouldUseLegacyNostrFallback(result: NdrSendResult): Boolean =
result == NdrSendResult.NO_SESSION
/**
* Nostr transport for offline private messages and receipts.
*/
@ -53,7 +56,8 @@ class NostrTransport(
content: String,
to: String,
recipientNickname: String,
messageID: String
messageID: String,
expiresAtSeconds: ULong? = null
) {
transportScope.launch {
try {
@ -102,7 +106,8 @@ class NostrTransport(
content = embedded,
fallbackRecipientHex = recipientHex,
senderIdentity = senderIdentity,
ndrRecipientHex = ndrRecipientHex
ndrRecipientHex = ndrRecipientHex,
expiresAtSeconds = expiresAtSeconds
)
} catch (e: Exception) {
@ -420,13 +425,30 @@ class NostrTransport(
content: String,
fallbackRecipientHex: String,
senderIdentity: NostrIdentity,
ndrRecipientHex: String = fallbackRecipientHex
ndrRecipientHex: String = fallbackRecipientHex,
expiresAtSeconds: ULong? = null
): Boolean {
if (NdrFeatureGate.isEnabled()) {
ndrService.configureIfNeeded(senderIdentity)
if (ndrService.sendIfPossible(content, ndrRecipientHex)) {
val sendResult = ndrService.sendIfPossible(
text = content,
peerPubkeyHex = ndrRecipientHex,
expiresAtSeconds = expiresAtSeconds
)
if (sendResult == NdrSendResult.SENT) {
return true
}
if (expiresAtSeconds != null && sendResult == NdrSendResult.NO_SESSION) {
Log.e(TAG, "NostrTransport: expiring message requires a pairwise session")
return false
}
if (!shouldUseLegacyNostrFallback(sendResult)) {
Log.e(TAG, "NostrTransport: pairwise send failed; refusing legacy downgrade")
return false
}
} else if (expiresAtSeconds != null) {
Log.e(TAG, "NostrTransport: expiring message requires pairwise transport")
return false
}
NostrProtocol.createPrivateMessage(

View File

@ -6,7 +6,8 @@ import com.bitchat.android.identity.SecureIdentityStateManager
import com.google.gson.Gson
/**
* Persistent store for message IDs we've already acknowledged (DELIVERED) or READ.
* Persistent store for message IDs we've already acknowledged (DELIVERED), READ,
* or durably committed from the pairwise ratchet.
* Limits to last MAX_IDS entries per set to avoid memory bloat.
*/
class SeenMessageStore private constructor(private val context: Context) {
@ -28,11 +29,13 @@ class SeenMessageStore private constructor(private val context: Context) {
private val delivered = LinkedHashSet<String>(MAX_IDS)
private val read = LinkedHashSet<String>(MAX_IDS)
private val ndrProcessed = LinkedHashSet<String>(MAX_IDS)
init { load() }
@Synchronized fun hasDelivered(id: String) = delivered.contains(id)
@Synchronized fun hasRead(id: String) = read.contains(id)
@Synchronized fun hasProcessedNdr(id: String) = ndrProcessed.contains(id)
@Synchronized fun markDelivered(id: String) {
if (delivered.remove(id)) delivered.add(id) else {
@ -50,9 +53,26 @@ class SeenMessageStore private constructor(private val context: Context) {
persist()
}
/**
* Returns only after the processed marker is committed to encrypted
* preferences. The pairwise action must not be acknowledged when this
* returns false.
*/
@Synchronized fun markProcessedNdr(id: String): Boolean {
if (ndrProcessed.contains(id)) return true
val previous = ndrProcessed.toList()
ndrProcessed.add(id)
trim(ndrProcessed)
if (persistSynchronously()) return true
ndrProcessed.clear()
ndrProcessed.addAll(previous)
return false
}
@Synchronized fun clear() {
delivered.clear()
read.clear()
ndrProcessed.clear()
persist()
}
@ -68,10 +88,14 @@ class SeenMessageStore private constructor(private val context: Context) {
try {
val json = secure.getSecureValue(STORAGE_KEY) ?: return
val data = gson.fromJson(json, StorePayload::class.java) ?: return
delivered.clear(); read.clear()
data.delivered.takeLast(MAX_IDS).forEach { delivered.add(it) }
data.read.takeLast(MAX_IDS).forEach { read.add(it) }
Log.d(TAG, "Loaded delivered=${delivered.size}, read=${read.size}")
delivered.clear(); read.clear(); ndrProcessed.clear()
data.delivered.orEmpty().takeLast(MAX_IDS).forEach { delivered.add(it) }
data.read.orEmpty().takeLast(MAX_IDS).forEach { read.add(it) }
data.ndrProcessed.orEmpty().takeLast(MAX_IDS).forEach { ndrProcessed.add(it) }
Log.d(
TAG,
"Loaded delivered=${delivered.size}, read=${read.size}, ndr=${ndrProcessed.size}"
)
} catch (e: Exception) {
Log.e(TAG, "Failed to load SeenMessageStore: ${e.message}")
}
@ -79,7 +103,7 @@ class SeenMessageStore private constructor(private val context: Context) {
@Synchronized private fun persist() {
try {
val payload = StorePayload(delivered.toList(), read.toList())
val payload = currentPayload()
val json = gson.toJson(payload)
secure.storeSecureValue(STORAGE_KEY, json)
} catch (e: Exception) {
@ -87,8 +111,22 @@ class SeenMessageStore private constructor(private val context: Context) {
}
}
@Synchronized private fun persistSynchronously(): Boolean = try {
secure.storeSecureValueSynchronously(STORAGE_KEY, gson.toJson(currentPayload()))
} catch (e: Exception) {
Log.e(TAG, "Failed to durably persist SeenMessageStore: ${e.message}")
false
}
private fun currentPayload() = StorePayload(
delivered = delivered.toList(),
read = read.toList(),
ndrProcessed = ndrProcessed.toList()
)
private data class StorePayload(
val delivered: List<String> = emptyList(),
val read: List<String> = emptyList()
val delivered: List<String>? = emptyList(),
val read: List<String>? = emptyList(),
val ndrProcessed: List<String>? = emptyList()
)
}

View File

@ -13,6 +13,7 @@ import kotlinx.coroutines.flow.asStateFlow
import com.bitchat.android.mesh.BluetoothMeshDelegate
import com.bitchat.android.mesh.BluetoothMeshService
import com.bitchat.android.mesh.MeshService
import com.bitchat.android.mesh.NdrMeshRoute
import com.bitchat.android.service.MeshServiceHolder
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.BitchatMessageType
@ -21,7 +22,14 @@ import com.bitchat.android.model.PeerCapabilities
import com.bitchat.android.nostr.NdrBootstrapAction
import com.bitchat.android.nostr.NdrBootstrapDecider
import com.bitchat.android.nostr.NdrBootstrapTriggerCoordinator
import com.bitchat.android.nostr.NdrFavoriteRouteBinding
import com.bitchat.android.nostr.NdrInviteRetryCoordinator
import com.bitchat.android.nostr.NdrInviteRetryRequest
import com.bitchat.android.nostr.NdrInviteRetryToken
import com.bitchat.android.nostr.NdrNostrService
import com.bitchat.android.nostr.NdrOutOfBandPayload
import com.bitchat.android.nostr.NdrOutOfBandRoutePolicy
import com.bitchat.android.nostr.NostrEvent
import com.bitchat.android.nostr.NostrIdentityBridge
import com.bitchat.android.protocol.BitchatPacket
@ -31,7 +39,6 @@ import com.bitchat.android.util.NotificationIntervalManager
import kotlinx.coroutines.delay
import java.util.Date
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.ConcurrentLinkedQueue
import kotlin.random.Random
import com.bitchat.android.services.VerificationService
import com.bitchat.android.identity.SecureIdentityStateManager
@ -171,8 +178,40 @@ class ChatViewModel(
private val ndrService by lazy { NdrNostrService.getInstance(getApplication()) }
private val ndrBootstrapAttemptMs = ConcurrentHashMap<String, Long>()
private val ndrNoiseHandshakeAttemptMs = ConcurrentHashMap<String, Long>()
private val ndrPendingOutOfBandPayloads =
ConcurrentHashMap<String, ConcurrentLinkedQueue<String>>()
private val ndrAvailablePeers = ConcurrentHashMap.newKeySet<String>()
private val ndrInviteRetries = NdrInviteRetryCoordinator(
scope = viewModelScope,
isStillValid = { request ->
val token = request.token
!ndrService.hasPairwiseSession(token.peerPubkeyHex) &&
NostrEvent.fromJsonString(ndrService.currentInviteEventJson() ?: "")
?.id == token.inviteEventId &&
isCurrentNdrRouteAuthorized(token.route, token.peerPubkeyHex)
},
send = { request, completion ->
val token = request.token
mesh.sendNdrEvent(
route = token.route,
payload = request.eventJson,
isStillAuthorized = {
!ndrService.hasPairwiseSession(token.peerPubkeyHex) &&
NostrEvent.fromJsonString(ndrService.currentInviteEventJson() ?: "")
?.id == token.inviteEventId &&
isCurrentNdrRouteAuthorized(token.route, token.peerPubkeyHex)
},
completion = completion
)
},
onAdmitted = { request ->
ndrBootstrapAttemptMs[request.token.peerID] = System.currentTimeMillis()
}
)
private val ndrOutOfBandDeliveryHandler: (
NdrOutOfBandPayload,
(Boolean) -> Unit
) -> Unit = { payload, completion ->
routeNdrOutOfBandPayload(payload, completion)
}
private val ndrBootstrapTriggers = NdrBootstrapTriggerCoordinator(
connectedPeerIDs = { state.getConnectedPeersValue() },
noiseKeyHexForPeer = { peerID ->
@ -186,25 +225,19 @@ class ChatViewModel(
override fun onFavoriteChanged(noiseKeyHex: String) {
viewModelScope.launch {
ndrBootstrapTriggers.onFavoriteChanged(noiseKeyHex)
ndrService.onOutOfBandTransportAvailable()
}
}
override fun onAllCleared() {
viewModelScope.launch {
ndrInviteRetries.cancelAll()
ndrBootstrapAttemptMs.clear()
ndrNoiseHandshakeAttemptMs.clear()
ndrPendingOutOfBandPayloads.clear()
ndrAvailablePeers.clear()
}
}
}
private val ndrOutOfBandPayloadListener: (String, List<String>) -> Unit =
listener@{ ownerPubkeyHex, payloads ->
if (!NdrFeatureGate.isEnabled()) return@listener
enqueuePendingNdrOutOfBandPayloads(ownerPubkeyHex, payloads)
viewModelScope.launch {
state.getConnectedPeersValue().forEach(::maybeBootstrapDoubleRatchetIfNeeded)
}
}
@ -370,9 +403,7 @@ class ChatViewModel(
// Initialize favorites persistence service
com.bitchat.android.favorites.FavoritesPersistenceService.initialize(getApplication())
FavoritesPersistenceService.shared.addListener(ndrFavoriteListener)
if (NdrFeatureGate.isEnabled()) {
ndrService.onOutOfBandPayloadsReady = ndrOutOfBandPayloadListener
}
ndrService.onOutOfBandPayload = ndrOutOfBandDeliveryHandler
// Load verified fingerprints from secure storage
verificationHandler.loadVerifiedFingerprints()
@ -393,9 +424,10 @@ class ChatViewModel(
runCatching {
FavoritesPersistenceService.shared.removeListener(ndrFavoriteListener)
}
if (ndrService.onOutOfBandPayloadsReady === ndrOutOfBandPayloadListener) {
ndrService.onOutOfBandPayloadsReady = null
if (ndrService.onOutOfBandPayload === ndrOutOfBandDeliveryHandler) {
ndrService.onOutOfBandPayload = null
}
ndrInviteRetries.cancelAll()
super.onCleared()
// Note: Mesh service lifecycle is now managed by MainActivity
}
@ -970,6 +1002,13 @@ class ChatViewModel(
override fun didUpdatePeerList(peers: List<String>) {
meshDelegateHandler.didUpdatePeerList(peers)
val currentPeers = peers.toSet()
val routeBecameAvailable = currentPeers.any(ndrAvailablePeers::add)
ndrAvailablePeers.retainAll(currentPeers)
ndrInviteRetries.retainPeers(currentPeers)
if (routeBecameAvailable) {
ndrService.onOutOfBandTransportAvailable()
}
peers.forEach { peerID ->
viewModelScope.launch {
maybeBootstrapDoubleRatchetIfNeeded(peerID)
@ -997,21 +1036,17 @@ class ChatViewModel(
verificationHandler.didReceiveVerifyResponse(peerID, payload)
}
override fun didReceiveNdrEvent(peerID: String, payload: ByteArray, timestampMs: Long) {
override fun didReceiveNdrEvent(
route: NdrMeshRoute,
payload: ByteArray,
timestampMs: Long
) {
if (!NdrFeatureGate.isEnabled()) return
val eventPayload = payload.toString(Charsets.UTF_8)
if (eventPayload.isBlank()) return
val peerInfo = mesh.getPeerInfo(peerID) ?: return
if (!mesh.peerSupportsAuthenticatedCapability(
peerID,
PeerCapabilities.NOSTR_DOUBLE_RATCHET
)
) {
Log.d(TAG, "Ignoring NDR OOB event without authenticated capability")
return
}
val noiseKey = peerInfo.noisePublicKey ?: return
val peerID = route.peerID
val noiseKey = route.authenticatedSession.remoteStaticKey
val relationship = FavoritesPersistenceService.shared.getFavoriteStatus(noiseKey)
if (relationship?.isMutual != true) {
Log.d(TAG, "Ignoring NDR OOB event without mutual favorite")
@ -1022,6 +1057,10 @@ class ChatViewModel(
ndrService.configureIfNeeded(identity)
val expectedPeerPubkeyHex =
FavoritesPersistenceService.shared.findNdrSessionPubkeyHex(noiseKey) ?: return
if (!isCurrentNdrRouteAuthorized(route, expectedPeerPubkeyHex)) {
Log.d(TAG, "Ignoring NDR OOB event from a replaced or rebound Noise generation")
return
}
val result = ndrService.processOutOfBandEventJson(
eventPayload,
expectedPeerPubkeyHex
@ -1029,27 +1068,28 @@ class ChatViewModel(
val sessionLookupPubkeyHex = listOfNotNull(
result.sessionLookupPubkeyHex,
expectedPeerPubkeyHex
).firstOrNull(ndrService::hasActiveSession)
).firstOrNull(ndrService::hasPairwiseSession)
if (sessionLookupPubkeyHex != null) {
FavoritesPersistenceService.shared.updateNdrSessionPubkeyHex(
val bindingCommitted =
FavoritesPersistenceService.shared.updateNdrSessionPubkeyHex(
noiseKey,
sessionLookupPubkeyHex
)
if (!bindingCommitted) {
Log.e(TAG, "Refusing to advance NDR bootstrap after session rebind failed")
return
}
ndrInviteRetries.cancel(peerID)
ndrBootstrapAttemptMs.remove(peerID)
ndrNoiseHandshakeAttemptMs.remove(peerID)
}
enqueuePendingNdrOutOfBandPayloads(
expectedPeerPubkeyHex,
result.outboundPayloads
)
viewModelScope.launch {
routePendingNdrOutOfBandPayloads(peerID, expectedPeerPubkeyHex)
}
ndrService.replayPendingOutOfBandPayloads()
}
override fun didResolvePrivateMediaPolicy(peerID: String) {
mediaSendingManager.retryPendingPrivateMedia(peerID)
ndrService.onOutOfBandTransportAvailable()
viewModelScope.launch {
ndrBootstrapTriggers.onAuthenticatedPolicyResolved(peerID)
}
@ -1068,25 +1108,39 @@ class ChatViewModel(
}
private fun maybeBootstrapDoubleRatchetIfNeeded(peerID: String) {
if (!NdrFeatureGate.isEnabled()) return
val peerInfo = mesh.getPeerInfo(peerID) ?: return
val noiseKey = peerInfo.noisePublicKey ?: return
val relationship = FavoritesPersistenceService.shared.getFavoriteStatus(noiseKey) ?: return
if (!relationship.isMutual) return
if (!NdrFeatureGate.isEnabled()) {
ndrInviteRetries.cancel(peerID)
return
}
val peerInfo = mesh.getPeerInfo(peerID)
val noiseKey = peerInfo?.noisePublicKey
val relationship = noiseKey?.let(FavoritesPersistenceService.shared::getFavoriteStatus)
if (noiseKey == null || relationship?.isMutual != true) {
ndrInviteRetries.cancel(peerID)
return
}
if (!mesh.peerSupportsAuthenticatedCapability(
peerID,
PeerCapabilities.NOSTR_DOUBLE_RATCHET
)
) return
) {
ndrInviteRetries.cancel(peerID)
return
}
val peerPubkeyHex =
FavoritesPersistenceService.shared.findNdrSessionPubkeyHex(noiseKey) ?: return
routePendingNdrOutOfBandPayloads(peerID, peerPubkeyHex)
FavoritesPersistenceService.shared.findNdrSessionPubkeyHex(noiseKey)
if (peerPubkeyHex == null) {
ndrInviteRetries.cancel(peerID)
return
}
ndrService.replayPendingOutOfBandPayloads()
val identity = NostrIdentityBridge.getCurrentNostrIdentity(getApplication()) ?: return
ndrService.configureIfNeeded(identity)
val hasActiveSession = ndrService.hasActiveSession(peerPubkeyHex)
if (hasActiveSession) {
val hasPairwiseSession = ndrService.hasPairwiseSession(peerPubkeyHex)
if (hasPairwiseSession) {
ndrInviteRetries.cancel(peerID)
ndrBootstrapAttemptMs.remove(peerID)
ndrNoiseHandshakeAttemptMs.remove(peerID)
return
@ -1098,7 +1152,7 @@ class ChatViewModel(
when (
NdrBootstrapDecider.decide(
hasActiveDoubleRatchet = hasActiveSession,
hasActiveDoubleRatchet = hasPairwiseSession,
hasEstablishedNoiseSession = hasEstablishedNoiseSession,
nowMs = now,
lastInviteAttemptMs = ndrBootstrapAttemptMs[peerID] ?: 0L,
@ -1115,41 +1169,91 @@ class ChatViewModel(
}
val invitePayload = ndrService.currentInviteEventJson() ?: return
val inviteEventId = NostrEvent.fromJsonString(invitePayload)
?.id
?.takeIf { it.length == 64 }
?: return
ndrNoiseHandshakeAttemptMs.remove(peerID)
if (mesh.sendNdrEvent(peerID, invitePayload)) {
ndrBootstrapAttemptMs[peerID] = now
}
val route = authorizedNdrRoute(peerID, peerPubkeyHex) ?: return
ndrInviteRetries.start(
NdrInviteRetryRequest(
token = NdrInviteRetryToken(
peerID = peerID,
peerPubkeyHex = peerPubkeyHex,
inviteEventId = inviteEventId,
route = route
),
eventJson = invitePayload
)
)
}
private fun enqueuePendingNdrOutOfBandPayloads(
ownerPubkeyHex: String,
payloads: List<String>
private fun routeNdrOutOfBandPayload(
payload: NdrOutOfBandPayload,
completion: (Boolean) -> Unit
) {
if (!NdrFeatureGate.isEnabled()) return
val owner = ownerPubkeyHex.lowercase()
if (!owner.matches(Regex("^[0-9a-f]{64}$"))) return
val queue = ndrPendingOutOfBandPayloads.computeIfAbsent(owner) {
ConcurrentLinkedQueue()
if (!NdrFeatureGate.isEnabled() || payload.eventJson.isBlank()) {
completion(false)
return
}
payloads
val peerPubkeyHex = payload.peerPubkeyHex.lowercase()
val candidatePeerIDs = linkedSetOf<String>()
FavoritesPersistenceService.shared
.findPeerIDForNostrPubkey(peerPubkeyHex)
?.let(candidatePeerIDs::add)
candidatePeerIDs.addAll(state.getConnectedPeersValue())
candidatePeerIDs.addAll(mesh.getPeerNicknames().keys)
val route = candidatePeerIDs
.asSequence()
.filter(String::isNotBlank)
.forEach(queue::offer)
.mapNotNull { authorizedNdrRoute(it, peerPubkeyHex) }
.firstOrNull()
if (route == null) {
completion(false)
return
}
mesh.sendNdrEvent(
route = route,
payload = payload.eventJson,
isStillAuthorized = {
isCurrentNdrRouteAuthorized(route, peerPubkeyHex)
},
completion = completion
)
}
private fun routePendingNdrOutOfBandPayloads(
private fun authorizedNdrRoute(
peerID: String,
ownerPubkeyHex: String
) {
if (!NdrFeatureGate.isEnabled()) return
val owner = ownerPubkeyHex.lowercase()
val queue = ndrPendingOutOfBandPayloads[owner] ?: return
while (true) {
val payload = queue.peek() ?: return
if (!mesh.sendNdrEvent(peerID, payload)) return
queue.poll()
peerPubkeyHex: String
): NdrMeshRoute? {
val route = mesh.currentNdrRoute(peerID) ?: return null
return route.takeIf {
isCurrentNdrRouteAuthorized(it, peerPubkeyHex)
}
}
private fun isCurrentNdrRouteAuthorized(
route: NdrMeshRoute,
peerPubkeyHex: String
): Boolean {
if (!NdrFeatureGate.isEnabled()) return false
return NdrOutOfBandRoutePolicy.isAuthorized(
route = route,
expectedPeerPubkeyHex = peerPubkeyHex,
currentRoute = mesh::currentNdrRoute,
favoriteBinding = { noiseKey ->
val favorites = FavoritesPersistenceService.shared
val relationship = favorites.getFavoriteStatus(noiseKey)
?: return@isAuthorized null
NdrFavoriteRouteBinding(
isMutual = relationship.isMutual,
peerPubkeyHex = relationship.peerNdrSessionPubkeyHex
?: relationship.peerNostrPublicKey
?.let(ContactIdentityResolver::nostrPubkeyHex)
)
}
)
}
// MARK: - Emergency Clear
@ -1166,7 +1270,7 @@ class ChatViewModel(
}
ndrBootstrapAttemptMs.clear()
ndrNoiseHandshakeAttemptMs.clear()
ndrPendingOutOfBandPayloads.clear()
ndrInviteRetries.cancelAll()
// Clear all UI managers
messageManager.clearAllMessages()
@ -1211,8 +1315,6 @@ class ChatViewModel(
if (ndrResetSucceeded && NdrFeatureGate.isEnabled()) {
// GeohashViewModel.panicReset() recreates the account identity and
// reinstalls the decrypted-message callback through initialize().
// Reinstall this VM-owned callback for roster-delayed OOB responses.
ndrService.onOutOfBandPayloadsReady = ndrOutOfBandPayloadListener
}
// Reset nickname

View File

@ -15,6 +15,8 @@ import androidx.annotation.RequiresPermission
import com.bitchat.android.crypto.EncryptionService
import com.bitchat.android.mesh.FragmentingPacketSender
import com.bitchat.android.mesh.MeshCore
import com.bitchat.android.mesh.NdrMeshRoute
import com.bitchat.android.mesh.NdrTransportTarget
import com.bitchat.android.mesh.MeshService
import com.bitchat.android.mesh.MeshTransport
import com.bitchat.android.mesh.PeerInfo
@ -1446,8 +1448,17 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
meshCore.sendVerifyResponse(peerID, noiseKeyHex, nonceA)
}
override fun sendNdrEvent(peerID: String, payload: String): Boolean =
meshCore.sendNdrEvent(peerID, payload)
override fun currentNdrRoute(peerID: String, transportId: String?): NdrMeshRoute? =
meshCore.currentNdrRoute(peerID, transportId)
override fun sendNdrEvent(
route: NdrMeshRoute,
payload: String,
isStillAuthorized: () -> Boolean,
completion: (admitted: Boolean) -> Unit
) {
meshCore.sendNdrEvent(route, payload, isStillAuthorized, completion)
}
/**
* Broadcasts a file (TLV payload) to all peers. Uses protocol version 2 to support
@ -1658,6 +1669,47 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
override fun sendPacketToPeer(peerID: String, packet: BitchatPacket): Boolean {
return this@WifiAwareMeshService.sendPacketToPeer(peerID, packet)
}
override fun currentNdrTransportTarget(peerID: String): NdrTransportTarget? {
val canonicalPeerID = connectionTracker.canonicalPeerId(peerID)
val socket = connectionTracker.getSocketForPeer(canonicalPeerID) ?: return null
return NdrTransportTarget(
endpointId = canonicalPeerID,
generationToken = socket
)
}
override fun sendPacketToNdrTargetConfirmed(
peerID: String,
target: NdrTransportTarget,
routed: RoutedPacket,
preflight: () -> Boolean,
completion: (Boolean) -> Unit
) {
val expectedSocket = target.generationToken as? SyncedSocket
if (expectedSocket == null) {
completion(false)
return
}
fragmentingSender.sendConfirmed(
routed = routed,
description = "Wi-Fi Aware NDR peer ${peerID.take(8)}",
preflight = preflight,
sendSingle = sendSingle@{ single ->
if (connectionTracker.getSocketForPeer(target.endpointId) !== expectedSocket) {
return@sendSingle false
}
val data = single.packet.toBinaryData() ?: return@sendSingle false
try {
expectedSocket.write(data)
true
} catch (_: IOException) {
false
}
},
completion = completion
)
}
override fun sendPacketToLink(
relayAddress: String,
ingressLinkID: String,

File diff suppressed because it is too large Load Diff

View File

@ -1 +1 @@
095e70489345df4d92dded686902f3dccb54cc45
0fe8caf2d4e24e2030ffae195597a2764613a659

View File

@ -1,17 +1,14 @@
# Android NDR FFI provenance
The Android bindings are generated from the pinned `vendor/iris-chat-rs`
submodule rather than from checked-in native libraries.
The Android bindings are generated from the pinned
`vendor/nostr-double-ratchet` submodule.
- Source repository: `https://github.com/irislib/iris-chat-rs.git`
- Source ref: `codex/bitchat-ffi-hardening`
- Source commit: `095e70489345df4d92dded686902f3dccb54cc45`
- Upstream base: `33f7732bbd300ed62fdf5bcf9da0a176efa7ff8c`
- Crate: `protocol-ffi` (`iris-chat-protocol-ffi`, library `ndr_ffi`)
- Protocol FFI version: `0.1.0`
- `nostr-double-ratchet`: `0.0.164` (locked by `protocol-ffi/Cargo.lock`)
- `nostr-double-ratchet-pairwise-codec`: `0.0.164` (locked by
`protocol-ffi/Cargo.lock`)
- Source repository: `https://github.com/irislib/nostr-double-ratchet.git`
- Source commit: `0fe8caf2d4e24e2030ffae195597a2764613a659`
- Upstream base: `master` at `c93f76a2b947f4288d2c7bcbecabe70ce197da5f`
- Crate: `ndr-pairwise-ffi` (library `ndr_ffi`)
- Runtime: durable single-identity pairwise sessions only; no AppKeys,
linked-device, sibling-sync, or group runtime
- Rust toolchain: `1.95.0`
- `cargo-ndk`: `4.1.2`
- Android NDK: `28.2.13676358`
@ -26,15 +23,8 @@ jobs build them from the pinned source before Gradle runs.
## Rollout sequencing
This source refresh does not implement or claim completion of the separate
private-envelope kind-1402 migration. Double-ratchet rollout remains on hold
until that protocol change has its own linked, reviewed Android implementation.
`BuildConfig.NDR_ROLLOUT_ENABLED` is therefore hard-coded to `false`.
Production builds do not advertise capability bit 11, configure or bootstrap
the FFI runtime, accept inbound NDR traffic, or send NDR relay/OOB traffic.
Account messages continue to use the existing Nostr gift-wrap path. Unit tests
use a debug-only override to exercise the dark implementation. Enabling the
gate requires the kind-1402 work to be linked and reviewed first; once enabled,
OOB payload type `0x22` is additionally restricted to an authenticated Noise
session with a mutual favorite that proves capability bit 11.
Rollout remains disabled until iOS and Android enable the pairwise protocol
together. Capability bit 11 and Noise payload `0x22` are accepted only for an
authenticated Noise peer with an exact current Nostr identity binding and a
mutual favorite advertising the same capability. The independent kind-1402
fallback-envelope migration can land before or after this work.

View File

@ -3,7 +3,9 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/../../../.." && pwd)"
SOURCE_DIR="${IRIS_CHAT_RS_DIR:-${REPO_ROOT}/vendor/iris-chat-rs}"
SOURCE_DIR="${NOSTR_DOUBLE_RATCHET_DIR:-${REPO_ROOT}/vendor/nostr-double-ratchet}"
CRATE_DIR="${SOURCE_DIR}/rust/crates/ndr-pairwise-ffi"
CRATE_MANIFEST="${CRATE_DIR}/Cargo.toml"
SOURCE_REVISION="$(tr -d '[:space:]' < "${SCRIPT_DIR}/SOURCE_REVISION")"
JNI_DIR="${REPO_ROOT}/app/src/main/jniLibs"
KOTLIN_DIR="${REPO_ROOT}/app/src/main/java/uniffi/ndr_ffi"
@ -14,29 +16,29 @@ cleanup() {
}
trap cleanup EXIT
if [[ ! -f "${SOURCE_DIR}/protocol-ffi/Cargo.toml" ]]; then
echo "iris-chat-rs protocol-ffi source not found at ${SOURCE_DIR}" >&2
echo "Run: git submodule update --init --checkout vendor/iris-chat-rs" >&2
if [[ ! -f "${CRATE_MANIFEST}" ]]; then
echo "nostr-double-ratchet pairwise FFI source not found at ${CRATE_DIR}" >&2
echo "Run: git submodule update --init --checkout vendor/nostr-double-ratchet" >&2
exit 1
fi
if ! git -C "${SOURCE_DIR}" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
echo "iris-chat-rs source must be the pinned Git submodule at ${SOURCE_DIR}" >&2
echo "nostr-double-ratchet source must be the pinned Git submodule at ${SOURCE_DIR}" >&2
exit 1
fi
SOURCE_WORKTREE="$(cd "${SOURCE_DIR}" && pwd -P)"
SOURCE_GIT_ROOT="$(git -C "${SOURCE_DIR}" rev-parse --show-toplevel)"
if [[ "${SOURCE_GIT_ROOT}" != "${SOURCE_WORKTREE}" ]]; then
echo "iris-chat-rs Git root is ${SOURCE_GIT_ROOT}; expected ${SOURCE_WORKTREE}" >&2
echo "nostr-double-ratchet Git root is ${SOURCE_GIT_ROOT}; expected ${SOURCE_WORKTREE}" >&2
exit 1
fi
ACTUAL_REVISION="$(git -C "${SOURCE_DIR}" rev-parse HEAD)"
if [[ "${ACTUAL_REVISION}" != "${SOURCE_REVISION}" ]]; then
echo "iris-chat-rs is at ${ACTUAL_REVISION}; expected ${SOURCE_REVISION}" >&2
echo "nostr-double-ratchet is at ${ACTUAL_REVISION}; expected ${SOURCE_REVISION}" >&2
exit 1
fi
if [[ -n "$(git -C "${SOURCE_DIR}" status --porcelain --untracked-files=all)" ]]; then
echo "iris-chat-rs source has local changes; refusing an unreproducible build" >&2
echo "nostr-double-ratchet source has local changes; refusing an unreproducible build" >&2
exit 1
fi
@ -68,8 +70,8 @@ export RUSTC_WRAPPER=""
mkdir -p "${BUILD_DIR}/jni" "${BUILD_DIR}/bindings"
(
cd "${SOURCE_DIR}/protocol-ffi"
cargo ndk \
cd "${CRATE_DIR}"
RUSTFLAGS="-C link-arg=-Wl,-z,max-page-size=16384" cargo ndk \
-t arm64-v8a \
-t armeabi-v7a \
-t x86_64 \
@ -82,10 +84,12 @@ mkdir -p "${BUILD_DIR}/jni" "${BUILD_DIR}/bindings"
)
(
cd "${SOURCE_DIR}/protocol-ffi"
cd "${CRATE_DIR}"
cargo run \
--locked \
--manifest-path "${SOURCE_DIR}/core/uniffi-bindgen/Cargo.toml" \
--manifest-path "${CRATE_MANIFEST}" \
--features bindgen \
--bin uniffi-bindgen \
-- \
generate \
--library "${BUILD_DIR}/jni/arm64-v8a/libndr_ffi.so" \
@ -105,9 +109,36 @@ for ABI in arm64-v8a armeabi-v7a x86_64 x86; do
cp "${BUILD_DIR}/jni/${ABI}/libndr_ffi.so" "${JNI_DIR}/${ABI}/libndr_ffi.so"
done
LLVM_READELF_CANDIDATES=(
"${NDR_ANDROID_NDK}"/toolchains/llvm/prebuilt/*/bin/llvm-readelf
)
if [[ "${#LLVM_READELF_CANDIDATES[@]}" -ne 1 ]] ||
[[ ! -x "${LLVM_READELF_CANDIDATES[0]}" ]]; then
echo "Unable to locate llvm-readelf in Android NDK ${EXPECTED_NDK_REVISION}" >&2
exit 1
fi
LLVM_READELF="${LLVM_READELF_CANDIDATES[0]}"
for ABI in arm64-v8a armeabi-v7a x86_64 x86; do
LIBRARY="${JNI_DIR}/${ABI}/libndr_ffi.so"
LOAD_ALIGNMENTS="$(
"${LLVM_READELF}" -lW "${LIBRARY}" |
awk '$1 == "LOAD" { print $NF }'
)"
if [[ -z "${LOAD_ALIGNMENTS}" ]]; then
echo "No ELF LOAD segments found in ${LIBRARY}" >&2
exit 1
fi
while IFS= read -r ALIGNMENT; do
if (( ALIGNMENT < 0x4000 )); then
echo "${LIBRARY} has non-16KiB LOAD alignment ${ALIGNMENT}" >&2
exit 1
fi
done <<< "${LOAD_ALIGNMENTS}"
done
mkdir -p "${KOTLIN_DIR}"
cp "${GENERATED_KOTLIN}" "${KOTLIN_DIR}/ndr_ffi.kt"
perl -pi -e 's/[ \t]+$//' "${KOTLIN_DIR}/ndr_ffi.kt"
perl -0777 -pi -e 's/\s+\z/\n/' "${KOTLIN_DIR}/ndr_ffi.kt"
echo "Built Android NDR FFI from iris-chat-rs ${SOURCE_REVISION}"
echo "Built Android pairwise NDR FFI from nostr-double-ratchet ${SOURCE_REVISION}"

View File

@ -0,0 +1,122 @@
package com.bitchat.android.favorites
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import com.bitchat.android.identity.SecureIdentityStateManager
import com.bitchat.android.services.ContactIdentityResolver
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import java.util.Date
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import kotlin.concurrent.thread
@RunWith(RobolectricTestRunner::class)
class FavoritesNdrRebindTest {
private lateinit var service: FavoritesPersistenceService
private val noiseA = ByteArray(32) { 1 }
private val noiseB = ByteArray(32) { 2 }
private val oldPeer = "11".repeat(32)
private val newPeer = "22".repeat(32)
@Before
fun setUp() {
val context = ApplicationProvider.getApplicationContext<Context>()
val preferences = context.getSharedPreferences(
"favorites-ndr-rebind-${System.nanoTime()}",
Context.MODE_PRIVATE
)
service = FavoritesPersistenceService(
stateManager = SecureIdentityStateManager(preferences, testOnly = true),
testOnly = true
)
}
@Test
fun failedRetirementRollsBackBindingWithoutHoldingFavoritesLock() {
assertTrue(service.updateNostrPublicKey(noiseA, oldPeer))
var favoritesLockWasAvailable = false
service.setNdrPeerRetirementGuard {
val completed = CountDownLatch(1)
thread {
service.getFavoriteStatus(noiseA)
completed.countDown()
}
favoritesLockWasAvailable = completed.await(1, TimeUnit.SECONDS)
false
}
assertFalse(service.updateNostrPublicKey(noiseA, newPeer))
assertTrue(favoritesLockWasAvailable)
assertEquals(
oldPeer,
service.findNdrSessionPubkeyHex(noiseA)
)
}
@Test
fun sameIdentityIsANoOpAndDoesNotRetire() {
assertTrue(service.updateNostrPublicKey(noiseA, oldPeer))
var retireCalls = 0
service.setNdrPeerRetirementGuard {
retireCalls += 1
false
}
assertTrue(service.updateNostrPublicKey(noiseA, oldPeer))
assertEquals(0, retireCalls)
assertEquals(oldPeer, service.findNdrSessionPubkeyHex(noiseA))
}
@Test
fun sharedLegacyIdentityIsRetiredOnlyAfterItsLastFavoriteMoves() {
insertLegacyRelationship(noiseA, oldPeer)
insertLegacyRelationship(noiseB, oldPeer)
val retired = mutableListOf<String>()
service.setNdrPeerRetirementGuard {
retired += it
true
}
assertTrue(service.updateNostrPublicKey(noiseA, newPeer))
assertTrue(retired.isEmpty())
val finalPeer = "33".repeat(32)
assertTrue(service.updateNostrPublicKey(noiseB, finalPeer))
assertEquals(listOf(oldPeer), retired)
assertEquals(newPeer, service.findNdrSessionPubkeyHex(noiseA))
assertEquals(finalPeer, service.findNdrSessionPubkeyHex(noiseB))
}
@Test
fun newIdentityCannotBeBoundToTwoFavorites() {
assertTrue(service.updateNostrPublicKey(noiseA, oldPeer))
assertFalse(service.updateNostrPublicKey(noiseB, oldPeer))
assertEquals(null, service.getFavoriteStatus(noiseB))
}
@Suppress("UNCHECKED_CAST")
private fun insertLegacyRelationship(noiseKey: ByteArray, peerPubkeyHex: String) {
val field = FavoritesPersistenceService::class.java.getDeclaredField("favorites")
field.isAccessible = true
val favorites = field.get(service) as MutableMap<String, FavoriteRelationship>
favorites[ContactIdentityResolver.noiseKeyHex(noiseKey)] = FavoriteRelationship(
peerNoisePublicKey = noiseKey,
peerNostrPublicKey =
requireNotNull(ContactIdentityResolver.npubFromHex(peerPubkeyHex)),
peerNickname = "legacy",
isFavorite = true,
theyFavoritedUs = true,
favoritedAt = Date(1),
lastUpdated = Date(1)
)
}
}

View File

@ -0,0 +1,91 @@
package com.bitchat.android.mesh
import com.bitchat.android.model.RoutedPacket
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.protocol.MessageType
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class FragmentingPacketSenderTest {
@Test
fun confirmedSendStaysPendingWhenExactRouteDisappearsBetweenFragments() = runTest {
val fragments = listOf(packet(1), packet(2), packet(3))
val sender = FragmentingPacketSender(
scope = this,
fragmentManager = null,
logTag = "FragmentingPacketSenderTest",
interFragmentDelayMs = 0
)
var preflightCalls = 0
val sent = mutableListOf<Int>()
var admitted: Boolean? = null
sender.sendConfirmed(
routed = RoutedPacket(
packet = fragments.first(),
preparedPackets = fragments
),
description = "exact generation",
preflight = {
preflightCalls += 1
preflightCalls == 1
},
sendSingle = {
sent += it.packet.payload.single().toInt()
true
},
completion = { admitted = it }
)
advanceUntilIdle()
assertEquals(listOf(1), sent)
assertFalse(admitted ?: true)
}
@Test
fun confirmedSendAcknowledgesOnlyAfterEveryFragmentIsAdmitted() = runTest {
val fragments = listOf(packet(1), packet(2), packet(3))
val sender = FragmentingPacketSender(
scope = this,
fragmentManager = null,
logTag = "FragmentingPacketSenderTest",
interFragmentDelayMs = 0
)
val sent = mutableListOf<Int>()
var admitted: Boolean? = null
sender.sendConfirmed(
routed = RoutedPacket(
packet = fragments.first(),
preparedPackets = fragments
),
description = "exact generation",
preflight = { true },
sendSingle = {
sent += it.packet.payload.single().toInt()
true
},
completion = { admitted = it }
)
advanceUntilIdle()
assertEquals(listOf(1, 2, 3), sent)
assertTrue(admitted == true)
}
private fun packet(value: Int) = BitchatPacket(
version = 1u,
type = MessageType.MESSAGE.value,
senderID = ByteArray(8) { 1 },
recipientID = ByteArray(8) { 2 },
timestamp = 1u,
payload = byteArrayOf(value.toByte()),
ttl = 1u
)
}

View File

@ -1,7 +1,9 @@
package com.bitchat.android.nostr
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class NdrApplicationMessageDecoderTest {
@ -12,15 +14,12 @@ class NdrApplicationMessageDecoderTest {
val event = pairwiseRumor(sender, "bitchat1:payload", 123)
val decoded = NdrApplicationMessageDecoder.decode(
NdrDecryptedMessage(
content = event.toJsonString(),
senderPubkeyHex = sender,
eventId = "01".repeat(32)
)
decrypted(event)
)
assertEquals("bitchat1:payload", decoded?.content)
assertEquals(123_000L, decoded?.timestampMs)
assertNull(decoded?.expiresAtSeconds)
}
@Test
@ -28,10 +27,7 @@ class NdrApplicationMessageDecoderTest {
val event = pairwiseRumor("cd".repeat(32), "bitchat1:payload", 123)
val decoded = NdrApplicationMessageDecoder.decode(
NdrDecryptedMessage(
content = event.toJsonString(),
senderPubkeyHex = sender
)
decrypted(event)
)
assertNull(decoded)
@ -49,27 +45,24 @@ class NdrApplicationMessageDecoderTest {
val event = unsigned.copy(id = unsigned.computeEventIdHex())
val decoded = NdrApplicationMessageDecoder.decode(
NdrDecryptedMessage(
content = event.toJsonString(),
senderPubkeyHex = sender
)
decrypted(event)
)
assertNull(decoded)
}
@Test
fun acceptsLegacyDirectEmbeddedPacket() {
fun rejectsLegacyDirectEmbeddedPacket() {
val decoded = NdrApplicationMessageDecoder.decode(
NdrDecryptedMessage(
content = "bitchat1:legacy",
senderPubkeyHex = sender
),
fallbackTimestampMs = 456L
senderPubkeyHex = sender,
eventId = "01".repeat(32),
actionId = "action-1"
)
)
assertEquals("bitchat1:legacy", decoded?.content)
assertEquals(456L, decoded?.timestampMs)
assertNull(decoded)
}
@Test
@ -77,7 +70,9 @@ class NdrApplicationMessageDecoderTest {
val decoded = NdrApplicationMessageDecoder.decode(
NdrDecryptedMessage(
content = "bitchat1:legacy",
senderPubkeyHex = "not-a-pubkey"
senderPubkeyHex = "not-a-pubkey",
eventId = "01".repeat(32),
actionId = "action-1"
)
)
@ -85,64 +80,240 @@ class NdrApplicationMessageDecoderTest {
}
@Test
fun rejectsMalformedMultiDeviceMetadata() {
fun malformedJsonShapeIsRejectedWithoutEscapingAnException() {
val decoded = NdrApplicationMessageDecoder.decode(
NdrDecryptedMessage(
content = """{"id":"${"01".repeat(32)}","kind":14}""",
senderPubkeyHex = sender,
eventId = "01".repeat(32),
actionId = "action-1"
)
)
assertNull(decoded)
}
@Test
fun rejectsSignedRumor() {
val event = pairwiseRumor(sender, "bitchat1:payload", 123)
.copy(sig = "01".repeat(64))
val decoded = NdrApplicationMessageDecoder.decode(
decrypted(event)
)
assertNull(decoded)
}
@Test
fun rejectsTamperedEmbeddedDeterministicId() {
val event = pairwiseRumor(sender, "bitchat1:payload", 123)
val tampered = event.copy(id = "01".repeat(32))
assertNull(
NdrApplicationMessageDecoder.decode(
decrypted(tampered, eventId = tampered.id)
)
)
}
@Test
fun rejectsMissingInvalidOrMismatchedAuthenticatedEventId() {
val event = pairwiseRumor(sender, "bitchat1:payload", 123)
assertNull(
NdrApplicationMessageDecoder.decode(
NdrDecryptedMessage(
content = event.toJsonString(),
senderPubkeyHex = sender,
senderDevicePubkeyHex = "invalid"
)
decrypted(event, eventId = "")
)
)
assertNull(
NdrApplicationMessageDecoder.decode(
NdrDecryptedMessage(
content = event.toJsonString(),
senderPubkeyHex = sender,
conversationOwnerPubkeyHex = "invalid"
)
decrypted(event, eventId = "not-an-event-id")
)
)
assertNull(
NdrApplicationMessageDecoder.decode(
decrypted(event, eventId = "02".repeat(32))
)
)
}
@Test
fun localSiblingRoutesToConversationOwnerWhileKeepingAuthenticatedSender() {
val conversationOwner = "cd".repeat(32)
val message = NdrDecryptedMessage(
content = "bitchat1:payload",
senderPubkeyHex = sender,
senderDevicePubkeyHex = "bc".repeat(32),
conversationOwnerPubkeyHex = conversationOwner
fun rejectsDuplicateOrConflictingVersionMarkers() {
val duplicate = pairwiseRumor(
sender,
"bitchat1:payload",
123,
extraTags = listOf(listOf("ndr-version", "1"))
)
val conflicting = pairwiseRumor(
sender,
"bitchat1:payload",
123,
extraTags = listOf(listOf("ndr-version", "2"))
)
assertEquals(sender, message.senderPubkeyHex)
assertEquals(conversationOwner, message.conversationPubkeyHex)
org.junit.Assert.assertTrue(message.isLocalSiblingCopy)
assertNull(
NdrApplicationMessageDecoder.decode(
decrypted(duplicate)
)
)
assertNull(
NdrApplicationMessageDecoder.decode(
decrypted(conflicting)
)
)
}
@Test
fun localSiblingMarkerRequiresAuthenticatedLocalAccountAuthor() {
val localAccount = "ef".repeat(32)
val validSibling = NdrDecryptedMessage(
content = "bitchat1:payload",
senderPubkeyHex = localAccount,
conversationOwnerPubkeyHex = sender
fun requiresExactlyOneUnsignedMillisecondTimestampAndUsesItDirectly() {
val valid = pairwiseRumor(
sender,
"bitchat1:payload",
createdAt = 123,
timestampMs = 42
)
val missingBase = NostrEvent(
pubkey = sender,
createdAt = 123,
kind = NostrKind.DIRECT_MESSAGE,
tags = listOf(
listOf("ndr-protocol", "pairwise-rumor"),
listOf("ndr-version", "1")
),
content = "bitchat1:payload"
)
val missing = missingBase.copy(id = missingBase.computeEventIdHex())
val malformed = pairwiseRumor(
sender,
"bitchat1:payload",
createdAt = 123,
timestampTagValue = "-1"
)
val duplicate = pairwiseRumor(
sender,
"bitchat1:payload",
createdAt = 123,
extraTags = listOf(listOf("ms", "124000"))
)
val misattributedSibling = validSibling.copy(senderPubkeyHex = "cd".repeat(32))
org.junit.Assert.assertTrue(validSibling.isAttributedToLocalAccount(localAccount))
org.junit.Assert.assertFalse(
misattributedSibling.isAttributedToLocalAccount(localAccount)
assertEquals(
42L,
NdrApplicationMessageDecoder.decode(
decrypted(valid)
)?.timestampMs
)
assertNull(
NdrApplicationMessageDecoder.decode(
decrypted(missing)
)
)
assertNull(
NdrApplicationMessageDecoder.decode(
decrypted(malformed)
)
)
assertNull(
NdrApplicationMessageDecoder.decode(
decrypted(duplicate)
)
)
}
@Test
fun extractsExpirationForLastMomentHostRecheck() {
val event = pairwiseRumor(
sender,
"bitchat1:payload",
123,
extraTags = listOf(listOf("expiration", "500"))
)
val decoded = NdrApplicationMessageDecoder.decode(
decrypted(event, expiresAtSeconds = 500uL)
)
assertEquals(500L, decoded?.expiresAtSeconds)
assertFalse(decoded!!.isExpiredAt(499L))
assertTrue(decoded.isExpiredAt(500L))
}
@Test
fun rejectsMalformedOrDuplicateExpiration() {
val malformed = pairwiseRumor(
sender,
"bitchat1:payload",
123,
extraTags = listOf(listOf("expiration", "tomorrow"))
)
val duplicate = pairwiseRumor(
sender,
"bitchat1:payload",
123,
extraTags = listOf(
listOf("expiration", "500"),
listOf("expiration", "501")
)
)
assertNull(
NdrApplicationMessageDecoder.decode(
decrypted(malformed)
)
)
assertNull(
NdrApplicationMessageDecoder.decode(
decrypted(duplicate)
)
)
}
@Test
fun rejectsMissingOrMismatchedActionExpiration() {
val expiring = pairwiseRumor(
sender,
"bitchat1:payload",
123,
extraTags = listOf(listOf("expiration", "500"))
)
val nonExpiring = pairwiseRumor(sender, "bitchat1:payload", 123)
assertNull(
NdrApplicationMessageDecoder.decode(
decrypted(expiring)
)
)
assertNull(
NdrApplicationMessageDecoder.decode(
decrypted(expiring, expiresAtSeconds = 501uL)
)
)
assertNull(
NdrApplicationMessageDecoder.decode(
decrypted(nonExpiring, expiresAtSeconds = 500uL)
)
)
}
private fun decrypted(
event: NostrEvent,
eventId: String = event.id,
expiresAtSeconds: ULong? = null
): NdrDecryptedMessage = NdrDecryptedMessage(
content = event.toJsonString(),
senderPubkeyHex = sender,
eventId = eventId,
actionId = "action-1",
expiresAtSeconds = expiresAtSeconds
)
private fun pairwiseRumor(
pubkey: String,
content: String,
createdAt: Int
createdAt: Int,
timestampMs: Long = createdAt.toLong() * 1_000L,
timestampTagValue: String = timestampMs.toString(),
extraTags: List<List<String>> = emptyList()
): NostrEvent {
val unsigned = NostrEvent(
pubkey = pubkey,
@ -150,8 +321,9 @@ class NdrApplicationMessageDecoderTest {
kind = NostrKind.DIRECT_MESSAGE,
tags = listOf(
listOf("ndr-protocol", "pairwise-rumor"),
listOf("ndr-version", "1")
),
listOf("ndr-version", "1"),
listOf("ms", timestampTagValue)
) + extraTags,
content = content
)
return unsigned.copy(id = unsigned.computeEventIdHex())

View File

@ -0,0 +1,150 @@
package com.bitchat.android.nostr
import com.bitchat.android.mesh.NdrMeshRoute
import com.bitchat.android.mesh.NdrTransportTarget
import com.bitchat.android.noise.AuthenticatedNoiseSession
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class NdrInviteRetryCoordinatorTest {
@Test
fun `retries rejected admission four times with bounded backoff`() = runTest {
val attemptTimes = mutableListOf<Long>()
val admitted = mutableListOf<NdrInviteRetryRequest>()
val request = request(generation = "generation-1")
val coordinator = NdrInviteRetryCoordinator(
scope = this,
isStillValid = { true },
send = { _, completion ->
attemptTimes += testScheduler.currentTime
completion(false)
},
onAdmitted = admitted::add
)
coordinator.start(request)
advanceUntilIdle()
coordinator.start(request.copy())
advanceUntilIdle()
assertEquals(listOf(0L, 250L, 750L, 1_750L, 3_750L), attemptTimes)
assertTrue(admitted.isEmpty())
}
@Test
fun `same token cannot reset retry budget`() = runTest {
var attempts = 0
val coordinator = NdrInviteRetryCoordinator(
scope = this,
isStillValid = { true },
send = { _, completion ->
attempts += 1
completion(false)
},
onAdmitted = {}
)
val first = request(generation = "generation-1")
val duplicate = first.copy()
coordinator.start(first)
runCurrent()
coordinator.start(duplicate)
advanceUntilIdle()
assertEquals(5, attempts)
}
@Test
fun `stale generation invite or favorite cancels before delayed retry`() = runTest {
var attempts = 0
var stillValid = true
val coordinator = NdrInviteRetryCoordinator(
scope = this,
isStillValid = { stillValid },
send = { _, completion ->
attempts += 1
completion(false)
},
onAdmitted = {}
)
coordinator.start(request(generation = "generation-1"))
runCurrent()
stillValid = false
advanceUntilIdle()
assertEquals(1, attempts)
}
@Test
fun `replacement generation gets a fresh token while old retry stays cancelled`() = runTest {
val attemptedGenerations = mutableListOf<Any>()
val coordinator = NdrInviteRetryCoordinator(
scope = this,
isStillValid = { true },
send = { request, completion ->
attemptedGenerations +=
request.token.route.transportTarget.generationToken
completion(false)
},
onAdmitted = {}
)
coordinator.start(request(generation = "generation-1"))
runCurrent()
coordinator.start(request(generation = "generation-2"))
advanceUntilIdle()
assertEquals(1, attemptedGenerations.count { it == "generation-1" })
assertEquals(5, attemptedGenerations.count { it == "generation-2" })
}
@Test
fun `successful admission stops retrying`() = runTest {
var attempts = 0
val admitted = mutableListOf<NdrInviteRetryRequest>()
val coordinator = NdrInviteRetryCoordinator(
scope = this,
isStillValid = { true },
send = { _, completion ->
attempts += 1
completion(attempts == 2)
},
onAdmitted = admitted::add
)
coordinator.start(request(generation = "generation-1"))
advanceUntilIdle()
assertEquals(2, attempts)
assertEquals(1, admitted.size)
}
private fun request(generation: String): NdrInviteRetryRequest =
NdrInviteRetryRequest(
token = NdrInviteRetryToken(
peerID = "peer-a",
peerPubkeyHex = "ab".repeat(32),
inviteEventId = "cd".repeat(32),
route = NdrMeshRoute(
transportId = "BLE",
peerID = "peer-a",
authenticatedSession = AuthenticatedNoiseSession(
remoteStaticKey = ByteArray(32) { 1 },
sessionToken = ByteArray(32) { 2 }
),
transportTarget = NdrTransportTarget(
endpointId = "endpoint-a",
generationToken = generation
)
)
),
eventJson = """{"id":"${"cd".repeat(32)}"}"""
)
}

View File

@ -0,0 +1,125 @@
package com.bitchat.android.nostr
import com.bitchat.android.mesh.NdrMeshRoute
import com.bitchat.android.mesh.NdrTransportTarget
import com.bitchat.android.noise.AuthenticatedNoiseSession
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class NdrOutOfBandRoutePolicyTest {
private val peerPubkey = "ab".repeat(32)
private val noiseKey = ByteArray(32) { 1 }
private val route = NdrMeshRoute(
transportId = "BLE",
peerID = "peer",
authenticatedSession = AuthenticatedNoiseSession(
remoteStaticKey = noiseKey,
sessionToken = ByteArray(32) { 2 }
),
transportTarget = NdrTransportTarget(
endpointId = "endpoint",
generationToken = "generation"
)
)
@Test
fun acceptsOnlyLiveGenerationWithExactMutualFavoriteBinding() {
assertTrue(
NdrOutOfBandRoutePolicy.isAuthorized(
route = route,
expectedPeerPubkeyHex = peerPubkey,
currentRoute = { _, _ -> route },
favoriteBinding = {
NdrFavoriteRouteBinding(
isMutual = true,
peerPubkeyHex = peerPubkey
)
}
)
)
}
@Test
fun rejectsReplacementNoiseGeneration() {
val replacement = route.copy(
authenticatedSession = AuthenticatedNoiseSession(
remoteStaticKey = noiseKey,
sessionToken = ByteArray(32) { 3 }
)
)
assertFalse(
NdrOutOfBandRoutePolicy.isAuthorized(
route = route,
expectedPeerPubkeyHex = peerPubkey,
currentRoute = { _, _ -> replacement },
favoriteBinding = {
NdrFavoriteRouteBinding(true, peerPubkey)
}
)
)
}
@Test
fun rejectsFavoriteRevocationOrNostrRebinding() {
assertFalse(
NdrOutOfBandRoutePolicy.isAuthorized(
route = route,
expectedPeerPubkeyHex = peerPubkey,
currentRoute = { _, _ -> route },
favoriteBinding = {
NdrFavoriteRouteBinding(false, peerPubkey)
}
)
)
assertFalse(
NdrOutOfBandRoutePolicy.isAuthorized(
route = route,
expectedPeerPubkeyHex = peerPubkey,
currentRoute = { _, _ -> route },
favoriteBinding = {
NdrFavoriteRouteBinding(true, "cd".repeat(32))
}
)
)
}
@Test
fun validatesTheExactTransportWhenMultipleRoutesCoexist() {
val wifiRoute = route.copy(
transportId = "WIFI_AWARE",
transportTarget = NdrTransportTarget(
endpointId = "wifi-endpoint",
generationToken = "wifi-generation"
)
)
assertTrue(
NdrOutOfBandRoutePolicy.isAuthorized(
route = wifiRoute,
expectedPeerPubkeyHex = peerPubkey,
currentRoute = { _, transportId ->
when (transportId) {
"BLE" -> route
"WIFI_AWARE" -> wifiRoute
else -> null
}
},
favoriteBinding = {
NdrFavoriteRouteBinding(true, peerPubkey)
}
)
)
assertFalse(
NdrOutOfBandRoutePolicy.isAuthorized(
route = wifiRoute,
expectedPeerPubkeyHex = peerPubkey,
currentRoute = { _, _ -> route },
favoriteBinding = {
NdrFavoriteRouteBinding(true, peerPubkey)
}
)
)
}
}

View File

@ -0,0 +1,108 @@
package com.bitchat.android.nostr
import java.util.concurrent.ConcurrentHashMap
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import okhttp3.Request
import okhttp3.WebSocket
import okio.ByteString
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
class NostrRelaySubscriptionRaceTest {
@Test
fun immediateEventDuringReqUsesCommitAwareHandler() {
val scope = CoroutineScope(Dispatchers.Unconfined + SupervisorJob())
val deduplicator = NostrEventDeduplicator(maxCapacity = 8)
val manager = NostrRelayManager(scope, deduplicator)
val subscriptionId = "commit-aware-race"
val event = NostrEvent(
id = "7a".repeat(32),
pubkey = "7b".repeat(32),
createdAt = 1,
kind = 1060,
tags = emptyList(),
content = "ciphertext",
sig = "signature"
)
var processed = 0
installConnection(
manager = manager,
relayUrl = RELAY_URL,
webSocket = ImmediateEventWebSocket {
deliverEvent(manager, subscriptionId, event)
}
)
manager.subscribeAfterSuccessfulProcessing(
filter = NostrFilter(kinds = listOf(1060)),
id = subscriptionId
) {
processed += 1
true
}
assertEquals(1, processed)
assertTrue(deduplicator.contains(event.id))
}
@Suppress("UNCHECKED_CAST")
private fun installConnection(
manager: NostrRelayManager,
relayUrl: String,
webSocket: WebSocket
) {
val field = NostrRelayManager::class.java.getDeclaredField("connections")
field.isAccessible = true
val connections =
field.get(manager) as ConcurrentHashMap<String, WebSocket>
connections[relayUrl] = webSocket
}
private fun deliverEvent(
manager: NostrRelayManager,
subscriptionId: String,
event: NostrEvent
) {
val method = NostrRelayManager::class.java.getDeclaredMethod(
"handleMessage",
String::class.java,
String::class.java
)
method.isAccessible = true
method.invoke(
manager,
"""["EVENT","$subscriptionId",${event.toJsonString()}]""",
RELAY_URL
)
}
private class ImmediateEventWebSocket(
private val onRequest: () -> Unit
) : WebSocket {
override fun request(): Request =
Request.Builder().url("https://relay.example").build()
override fun queueSize(): Long = 0L
override fun send(text: String): Boolean {
onRequest()
return true
}
override fun send(bytes: ByteString): Boolean = false
override fun close(code: Int, reason: String?): Boolean = true
override fun cancel() = Unit
}
companion object {
private const val RELAY_URL = "wss://relay.example"
}
}

View File

@ -0,0 +1,59 @@
package com.bitchat.android.nostr
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class NostrReliabilityPolicyTest {
@Test
fun commitAwareDedupeRetriesAfterFailureAndConsumesOnlySuccess() {
val deduplicator = NostrEventDeduplicator(maxCapacity = 8)
val event = event("01".repeat(32))
var attempts = 0
assertFalse(
deduplicator.processEventAfterSuccess(event) {
attempts += 1
false
}
)
assertTrue(
deduplicator.processEventAfterSuccess(event) {
attempts += 1
true
}
)
assertFalse(
deduplicator.processEventAfterSuccess(event) {
attempts += 1
true
}
)
assertEquals(2, attempts)
}
@Test
fun nip20DuplicateSuccessIsExactAndCaseSensitive() {
assertTrue(isNip20ConfirmedSuccess(accepted = true, message = null))
assertTrue(
isNip20ConfirmedSuccess(
accepted = false,
message = "duplicate: already have this event"
)
)
assertFalse(isNip20ConfirmedSuccess(false, "Duplicate: already have this event"))
assertFalse(isNip20ConfirmedSuccess(false, "duplicate"))
assertFalse(isNip20ConfirmedSuccess(false, " duplicate: already have this event"))
}
private fun event(id: String) = NostrEvent(
id = id,
pubkey = "02".repeat(32),
createdAt = 1,
kind = 1060,
tags = emptyList(),
content = "ciphertext",
sig = "signature"
)
}

1
vendor/iris-chat-rs vendored

@ -1 +0,0 @@
Subproject commit 095e70489345df4d92dded686902f3dccb54cc45

1
vendor/nostr-double-ratchet vendored Submodule

@ -0,0 +1 @@
Subproject commit 0fe8caf2d4e24e2030ffae195597a2764613a659