mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-08-22 07:06:05 +00:00
use pairwise NDR FFI directly
This commit is contained in:
parent
1de3d28156
commit
6e18fd3674
2
.gitignore
vendored
2
.gitignore
vendored
@ -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
6
.gitmodules
vendored
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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}$")
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -60,7 +60,15 @@ interface FavoritesChangeListener {
|
||||
* Manages favorites with Noise↔Nostr 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() }
|
||||
|
||||
@ -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.
|
||||
*/
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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?
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@ -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")
|
||||
}
|
||||
@ -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
@ -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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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()
|
||||
)
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
@ -1 +1 @@
|
||||
095e70489345df4d92dded686902f3dccb54cc45
|
||||
0fe8caf2d4e24e2030ffae195597a2764613a659
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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}"
|
||||
|
||||
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
@ -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())
|
||||
|
||||
@ -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)}"}"""
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -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)
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
@ -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
vendor/iris-chat-rs
vendored
@ -1 +0,0 @@
|
||||
Subproject commit 095e70489345df4d92dded686902f3dccb54cc45
|
||||
1
vendor/nostr-double-ratchet
vendored
Submodule
1
vendor/nostr-double-ratchet
vendored
Submodule
@ -0,0 +1 @@
|
||||
Subproject commit 0fe8caf2d4e24e2030ffae195597a2764613a659
|
||||
Loading…
x
Reference in New Issue
Block a user