From 6e18fd3674edfda0e8a722e6a4b3510f59acb250 Mon Sep 17 00:00:00 2001 From: Dev Date: Mon, 27 Jul 2026 18:34:37 +0300 Subject: [PATCH] use pairwise NDR FFI directly --- .gitignore | 2 +- .gitmodules | 6 +- app/build.gradle.kts | 5 +- .../nostr/PairwiseFfiInstrumentedTest.kt | 191 ++ .../com/bitchat/android/BitchatApplication.kt | 14 + .../favorites/FavoritesPersistenceService.kt | 237 ++- .../android/features/file/FileUtils.kt | 47 +- .../identity/SecureIdentityStateManager.kt | 7 + .../mesh/BluetoothConnectionManager.kt | 34 + .../android/mesh/BluetoothMeshService.kt | 124 +- .../mesh/BluetoothPacketBroadcaster.kt | 32 + .../android/mesh/FragmentingPacketSender.kt | 72 + .../java/com/bitchat/android/mesh/MeshCore.kt | 129 +- .../com/bitchat/android/mesh/MeshDelegate.kt | 2 +- .../com/bitchat/android/mesh/MeshService.kt | 29 +- .../com/bitchat/android/mesh/MeshTransport.kt | 12 + .../android/mesh/UnifiedMeshService.kt | 44 +- .../bitchat/android/model/NdrFeatureGate.kt | 4 +- .../nostr/NdrApplicationMessageDecoder.kt | 80 +- .../nostr/NdrEstablishedSessionMarkerStore.kt | 51 + .../nostr/NdrInviteRetryCoordinator.kt | 133 ++ .../bitchat/android/nostr/NdrNostrService.kt | 1122 +++++++---- .../android/nostr/NdrOutOfBandRoutePolicy.kt | 28 + .../com/bitchat/android/nostr/NdrTypes.kt | 141 +- .../nostr/NostrDirectMessageHandler.kt | 339 ++-- .../android/nostr/NostrEventDeduplicator.kt | 53 +- .../android/nostr/NostrRelayManager.kt | 196 +- .../bitchat/android/nostr/NostrTransport.kt | 30 +- .../android/services/SeenMessageStore.kt | 54 +- .../com/bitchat/android/ui/ChatViewModel.kt | 252 ++- .../wifi-aware/WifiAwareMeshService.kt | 56 +- app/src/main/java/uniffi/ndr_ffi/ndr_ffi.kt | 1016 +++++----- app/src/main/ndr-ffi/SOURCE_REVISION | 2 +- app/src/main/ndr-ffi/VENDORED_FROM.md | 36 +- app/src/main/ndr-ffi/build-android.sh | 57 +- .../favorites/FavoritesNdrRebindTest.kt | 122 ++ .../mesh/FragmentingPacketSenderTest.kt | 91 + .../nostr/NdrApplicationMessageDecoderTest.kt | 280 ++- .../nostr/NdrInviteRetryCoordinatorTest.kt | 150 ++ .../android/nostr/NdrNostrServiceTest.kt | 1648 ++++++++++------- .../nostr/NdrOutOfBandRoutePolicyTest.kt | 125 ++ .../nostr/NostrRelaySubscriptionRaceTest.kt | 108 ++ .../nostr/NostrReliabilityPolicyTest.kt | 59 + vendor/iris-chat-rs | 1 - vendor/nostr-double-ratchet | 1 + 45 files changed, 5186 insertions(+), 2036 deletions(-) create mode 100644 app/src/androidTest/kotlin/com/bitchat/android/nostr/PairwiseFfiInstrumentedTest.kt create mode 100644 app/src/main/java/com/bitchat/android/nostr/NdrEstablishedSessionMarkerStore.kt create mode 100644 app/src/main/java/com/bitchat/android/nostr/NdrInviteRetryCoordinator.kt create mode 100644 app/src/main/java/com/bitchat/android/nostr/NdrOutOfBandRoutePolicy.kt create mode 100644 app/src/test/kotlin/com/bitchat/android/favorites/FavoritesNdrRebindTest.kt create mode 100644 app/src/test/kotlin/com/bitchat/android/mesh/FragmentingPacketSenderTest.kt create mode 100644 app/src/test/kotlin/com/bitchat/android/nostr/NdrInviteRetryCoordinatorTest.kt create mode 100644 app/src/test/kotlin/com/bitchat/android/nostr/NdrOutOfBandRoutePolicyTest.kt create mode 100644 app/src/test/kotlin/com/bitchat/android/nostr/NostrRelaySubscriptionRaceTest.kt create mode 100644 app/src/test/kotlin/com/bitchat/android/nostr/NostrReliabilityPolicyTest.kt delete mode 160000 vendor/iris-chat-rs create mode 160000 vendor/nostr-double-ratchet diff --git a/.gitignore b/.gitignore index 668b81ff..aa079292 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/.gitmodules b/.gitmodules index 51a9ad20..743725be 100644 --- a/.gitmodules +++ b/.gitmodules @@ -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 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 00bdb184..2f249802 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) } diff --git a/app/src/androidTest/kotlin/com/bitchat/android/nostr/PairwiseFfiInstrumentedTest.kt b/app/src/androidTest/kotlin/com/bitchat/android/nostr/PairwiseFfiInstrumentedTest.kt new file mode 100644 index 00000000..0d6df486 --- /dev/null +++ b/app/src/androidTest/kotlin/com/bitchat/android/nostr/PairwiseFfiInstrumentedTest.kt @@ -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}$") + } +} diff --git a/app/src/main/java/com/bitchat/android/BitchatApplication.kt b/app/src/main/java/com/bitchat/android/BitchatApplication.kt index 282f3295..5a04a9fc 100644 --- a/app/src/main/java/com/bitchat/android/BitchatApplication.kt +++ b/app/src/main/java/com/bitchat/android/BitchatApplication.kt @@ -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 diff --git a/app/src/main/java/com/bitchat/android/favorites/FavoritesPersistenceService.kt b/app/src/main/java/com/bitchat/android/favorites/FavoritesPersistenceService.kt index ee61501d..7756b144 100644 --- a/app/src/main/java/com/bitchat/android/favorites/FavoritesPersistenceService.kt +++ b/app/src/main/java/com/bitchat/android/favorites/FavoritesPersistenceService.kt @@ -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() // noiseHex -> relationship private val peerIdIndex = mutableMapOf() // peerID (lowercase 16-hex) -> npub private val listeners = mutableListOf() + private var ndrPeerRetirementGuard: ((oldPeerPubkeyHex: String) -> Boolean)? = null + private val ndrRebindsInProgress = mutableSetOf() 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 = favorites.values.filter { it.isFavorite } fun getAllRelationships(): List = 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() } diff --git a/app/src/main/java/com/bitchat/android/features/file/FileUtils.kt b/app/src/main/java/com/bitchat/android/features/file/FileUtils.kt index 10765416..c4115994 100644 --- a/app/src/main/java/com/bitchat/android/features/file/FileUtils.kt +++ b/app/src/main/java/com/bitchat/android/features/file/FileUtils.kt @@ -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. */ diff --git a/app/src/main/java/com/bitchat/android/identity/SecureIdentityStateManager.kt b/app/src/main/java/com/bitchat/android/identity/SecureIdentityStateManager.kt index ab00b137..fdd309d4 100644 --- a/app/src/main/java/com/bitchat/android/identity/SecureIdentityStateManager.kt +++ b/app/src/main/java/com/bitchat/android/identity/SecureIdentityStateManager.kt @@ -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 diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt index 17e4ce28..c32f5d05 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt @@ -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 diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt index 0925ded9..c0fa47d2 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt @@ -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) + } } /** diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt index 0e9b0c76..410a3a90 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt @@ -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, diff --git a/app/src/main/java/com/bitchat/android/mesh/FragmentingPacketSender.kt b/app/src/main/java/com/bitchat/android/mesh/FragmentingPacketSender.kt index 3df51baf..a298cfb0 100644 --- a/app/src/main/java/com/bitchat/android/mesh/FragmentingPacketSender.kt +++ b/app/src/main/java/com/bitchat/android/mesh/FragmentingPacketSender.kt @@ -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() diff --git a/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt b/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt index fe8f56f1..eafb4f46 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt @@ -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() { diff --git a/app/src/main/java/com/bitchat/android/mesh/MeshDelegate.kt b/app/src/main/java/com/bitchat/android/mesh/MeshDelegate.kt index 932a9b9b..d5bea74b 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MeshDelegate.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MeshDelegate.kt @@ -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? diff --git a/app/src/main/java/com/bitchat/android/mesh/MeshService.kt b/app/src/main/java/com/bitchat/android/mesh/MeshService.kt index 4bfbe212..24c799a4 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MeshService.kt @@ -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( diff --git a/app/src/main/java/com/bitchat/android/mesh/MeshTransport.kt b/app/src/main/java/com/bitchat/android/mesh/MeshTransport.kt index b3f2fcc4..40538335 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MeshTransport.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MeshTransport.kt @@ -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. diff --git a/app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt b/app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt index e35bf927..bde744fd 100644 --- a/app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt @@ -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) { diff --git a/app/src/main/java/com/bitchat/android/model/NdrFeatureGate.kt b/app/src/main/java/com/bitchat/android/model/NdrFeatureGate.kt index 0d378cdb..3373dbee 100644 --- a/app/src/main/java/com/bitchat/android/model/NdrFeatureGate.kt +++ b/app/src/main/java/com/bitchat/android/model/NdrFeatureGate.kt @@ -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 diff --git a/app/src/main/java/com/bitchat/android/nostr/NdrApplicationMessageDecoder.kt b/app/src/main/java/com/bitchat/android/nostr/NdrApplicationMessageDecoder.kt index 4590db32..5fb41a12 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NdrApplicationMessageDecoder.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NdrApplicationMessageDecoder.kt @@ -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() } } diff --git a/app/src/main/java/com/bitchat/android/nostr/NdrEstablishedSessionMarkerStore.kt b/app/src/main/java/com/bitchat/android/nostr/NdrEstablishedSessionMarkerStore.kt new file mode 100644 index 00000000..946b8a70 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/nostr/NdrEstablishedSessionMarkerStore.kt @@ -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") +} diff --git a/app/src/main/java/com/bitchat/android/nostr/NdrInviteRetryCoordinator.kt b/app/src/main/java/com/bitchat/android/nostr/NdrInviteRetryCoordinator.kt new file mode 100644 index 00000000..4bb40376 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/nostr/NdrInviteRetryCoordinator.kt @@ -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 = 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() + + 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) { + 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) + } +} diff --git a/app/src/main/java/com/bitchat/android/nostr/NdrNostrService.kt b/app/src/main/java/com/bitchat/android/nostr/NdrNostrService.kt index 69d1ab9d..db377803 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NdrNostrService.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NdrNostrService.kt @@ -1,37 +1,56 @@ package com.bitchat.android.nostr -import android.annotation.SuppressLint import android.content.Context -import android.content.SharedPreferences import android.util.Log -import androidx.core.content.edit import com.bitchat.android.model.NdrFeatureGate import com.google.gson.JsonArray import com.google.gson.JsonObject import com.google.gson.JsonParser +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import kotlin.math.min class NdrNostrService( private val relayManager: NdrRelayManager, - private val runtimeFactory: NdrSessionManagerFactory, + private val runtimeFactory: NdrPairwiseRuntimeFactory, private val storageDirectoryProvider: () -> String, - private val deviceIdProvider: () -> String, private val storageResetter: () -> Unit = { val storageDirectory = java.io.File(storageDirectoryProvider()) if (storageDirectory.exists() && !storageDirectory.deleteRecursively()) { throw java.io.IOException("Failed to delete ${storageDirectory.absolutePath}") } }, - private val deviceIdResetter: () -> Unit = {}, - private val inviteOwnerResolver: (String) -> String? = Companion::resolveInviteOwnerPubkeyHex + private val establishedSessionMarkers: NdrEstablishedSessionMarkerStore = + FileNdrEstablishedSessionMarkerStore( + requireNotNull(java.io.File(storageDirectoryProvider()).parentFile) + .resolve("ndr-established-sessions") + ), + private val pairwiseStateExists: (String) -> Boolean = Companion::pairwiseStateExists, + private val invitePeerResolver: (String) -> String? = Companion::resolvePairwiseInvitePubkeyHex, + private val retryScheduler: NdrRetryScheduler = Companion.DEFAULT_RETRY_SCHEDULER, + private val nowSecondsProvider: () -> ULong = { + (System.currentTimeMillis() / 1_000L).coerceAtLeast(0L).toULong() + } ) { companion object { private const val TAG = "NdrNostrService" - private const val COMPACT_INVITE_URL_ROOT = "https://b" - private const val NDR_APP_KEYS_KIND = 37368 - private const val NDR_APP_KEYS_TYPE = "app_keys_roster_snapshot" private const val NDR_MESSAGE_KIND = 1060 - private const val MAX_BUFFERED_DECRYPTED_MESSAGES = 128 + private const val PUBLISH_RETRY_INITIAL_DELAY_MS = 1_000L + private const val PUBLISH_RETRY_MAX_DELAY_MS = 30_000L + private const val OOB_RETRY_INITIAL_DELAY_MS = 250L + private const val OOB_RETRY_MAX_DELAY_MS = 4_000L + private const val OOB_RETRY_MAX_ATTEMPTS = 5 + private const val PAIRWISE_STATE_PREFIX = "ndr-pairwise-state-v1-" + private const val PAIRWISE_STATE_SUFFIX = ".json" + + private val RETRY_EXECUTOR = Executors.newSingleThreadScheduledExecutor { runnable -> + Thread(runnable, "ndr-publish-retry").apply { isDaemon = true } + } + private val DEFAULT_RETRY_SCHEDULER = NdrRetryScheduler { delayMs, task -> + val future = RETRY_EXECUTOR.schedule(task, delayMs, TimeUnit.MILLISECONDS) + NdrRetryCancellation { future.cancel(false) } + } @Volatile private var INSTANCE: NdrNostrService? = null @@ -44,36 +63,51 @@ class NdrNostrService( private fun create(context: Context): NdrNostrService { val storageDirectory = context.filesDir.resolve("ndr") - val preferences = context.getSharedPreferences("bitchat_ndr", Context.MODE_PRIVATE) + val establishedMarkerDirectory = + context.filesDir.resolve("ndr-established-sessions") val relayManager = object : NdrRelayManager { - override fun subscribe(filter: NostrFilter, id: String, handler: (NostrEvent) -> Unit) { - NostrRelayManager.getInstance(context).subscribe(filter, id, handler) + override fun subscribe( + filter: NostrFilter, + id: String, + handler: (NostrEvent) -> Boolean + ) { + NostrRelayManager.getInstance(context) + .subscribeAfterSuccessfulProcessing(filter, id, handler) } override fun unsubscribe(id: String) { NostrRelayManager.getInstance(context).unsubscribe(id) } - override fun sendEvent(event: NostrEvent) { - NostrRelayManager.getInstance(context).sendEvent(event) + override fun sendEventConfirmed( + event: NostrEvent, + completion: (accepted: Boolean) -> Unit + ) { + NostrRelayManager.getInstance(context) + .sendEventConfirmed(event, completion = completion) + } + + override fun cancelConfirmedEvent(eventId: String) { + NostrRelayManager.getInstance(context).cancelConfirmedEvent(eventId) + } + + override fun setOnConnectionAvailable(handler: () -> Unit) { + NostrRelayManager.getInstance(context) + .setNdrConnectionAvailableHandler(handler) } } - val runtimeFactory = object : NdrSessionManagerFactory { + val runtimeFactory = object : NdrPairwiseRuntimeFactory { override fun newWithStoragePath( ourPubkeyHex: String, ourIdentityPrivkeyHex: String, - deviceId: String, - storagePath: String, - ownerPubkeyHex: String? - ): NdrSessionManager { - return UniffiNdrSessionManager( - uniffi.ndr_ffi.SessionManagerHandle.newWithStoragePath( + storagePath: String + ): NdrPairwiseRuntime { + return UniffiNdrPairwiseRuntime( + uniffi.ndr_ffi.PairwiseManager.newWithStoragePath( ourPubkeyHex, ourIdentityPrivkeyHex, - deviceId, - storagePath, - ownerPubkeyHex + storagePath ) ) } @@ -85,90 +119,113 @@ class NdrNostrService( storageDirectoryProvider = { storageDirectory.apply { mkdirs() }.absolutePath }, - deviceIdProvider = { - preferences.getString("device_id", null) ?: java.util.UUID.randomUUID().toString().also { - preferences.edit { putString("device_id", it) } - } - }, storageResetter = { if (storageDirectory.exists() && !storageDirectory.deleteRecursively()) { throw java.io.IOException("Failed to delete ${storageDirectory.absolutePath}") } }, - deviceIdResetter = { - removeDeviceIdSynchronously(preferences) - } + establishedSessionMarkers = + FileNdrEstablishedSessionMarkerStore(establishedMarkerDirectory) ) } - @SuppressLint("UseKtx") - private fun removeDeviceIdSynchronously(preferences: SharedPreferences) { - // KTX's commit=true overload discards Editor.commit()'s result, but - // panic reset must fail closed unless the device-id wipe is durable. - check(preferences.edit().remove("device_id").commit()) { - "Failed to clear NDR device id" - } - } - - private fun resolveInviteOwnerPubkeyHex(payload: String): String? { + private fun resolvePairwiseInvitePubkeyHex(payload: String): String? { return try { val invite = if (payload.startsWith("{")) { - uniffi.ndr_ffi.InviteHandle.fromEventJson(payload) + uniffi.ndr_ffi.PairwiseInvite.fromEventJson(payload) } else { - uniffi.ndr_ffi.InviteHandle.fromUrl(payload) + uniffi.ndr_ffi.PairwiseInvite.fromUrl(payload) + } + invite.use { + it.`getPeerPubkeyHex`() + .lowercase() + .takeIf(NdrInputPolicy::isPubkeyHex) } - invite.use { it.`getOwnerPubkeyHex`().lowercase() } } catch (_: Throwable) { null } } + + private fun pairwiseStateExists(storagePath: String): Boolean { + val directory = java.io.File(storagePath) + return directory.isDirectory && + directory.listFiles()?.any { file -> + file.isFile && + file.name.startsWith(PAIRWISE_STATE_PREFIX) && + file.name.endsWith(PAIRWISE_STATE_SUFFIX) + } == true + } } @Volatile - var onDecryptedMessage: ((NdrDecryptedMessage) -> Unit)? = null + var onDecryptedMessage: (( + message: NdrDecryptedMessage, + completion: (NdrDeliveryResult) -> Unit + ) -> Unit)? = null @Synchronized set(value) { field = value if (value != null && NdrFeatureGate.isEnabled()) { - while (bufferedDecryptedMessages.isNotEmpty()) { - value(bufferedDecryptedMessages.removeFirst()) - } + drainAndApplyPubSubEventsLocked() } } @Volatile - var onOutOfBandPayloadsReady: ((ownerPubkeyHex: String, payloads: List) -> Unit)? = null + var onOutOfBandPayload: (( + payload: NdrOutOfBandPayload, + completion: (admitted: Boolean) -> Unit + ) -> Unit)? = null + @Synchronized set(value) { + field = value + if (value != null && NdrFeatureGate.isEnabled()) { + drainAndApplyPubSubEventsLocked() + } + } @Volatile - private var sessionManager: NdrSessionManager? = null + private var pairwiseRuntime: NdrPairwiseRuntime? = null @Volatile private var configuredForPubkeyHex: String? = null @Volatile - private var cachedInviteEventJson: String? = null + private var configurationFailurePubkeyHex: String? = null @Volatile private var panicResetBlocked = false private val activeSubIds = linkedSetOf() - private val appKeysSubscriptionIdByOwner = linkedMapOf() - private val appKeysOwnerBySubscriptionId = linkedMapOf() - private val durableAppKeysOwners = linkedSetOf() - private val pendingInvitesByOwner = linkedMapOf() - private val bufferedDecryptedMessages = ArrayDeque() + private val inFlightRelayEventsByActionId = linkedMapOf() + private val publishRetryAttempts = linkedMapOf() + private val publishRetryTasks = linkedMapOf() + private val outOfBandRetryAttempts = linkedMapOf() + private val outOfBandRetryTasks = linkedMapOf() + private val dispatchedDeliveryActionIds = linkedSetOf() + private val dispatchedOutOfBandActionIds = linkedSetOf() + private val knownActivePeerPubkeys = linkedSetOf() + private var nextRuntimeEpoch = 0L + private var activeRuntimeEpoch: Long? = null + + init { + relayManager.setOnConnectionAvailable { + onRelayConnectionAvailable() + } + } @get:Synchronized val isConfigured: Boolean - get() = NdrFeatureGate.isEnabled() && sessionManager != null + get() = NdrFeatureGate.isEnabled() && pairwiseRuntime != null @Synchronized - fun currentInviteEventJson(): String? = - cachedInviteEventJson.takeIf { NdrFeatureGate.isEnabled() } + fun currentInviteEventJson(): String? { + if (!NdrFeatureGate.isEnabled()) return null + return runCatching { pairwiseRuntime?.currentInviteEventJson() }.getOrNull() + } @Synchronized fun configureIfNeeded(identity: NostrIdentity) { if (!NdrFeatureGate.isEnabled()) { teardownLocked() + configurationFailurePubkeyHex = null return } if (panicResetBlocked) { @@ -176,7 +233,14 @@ class NdrNostrService( return } val pubkeyHex = identity.publicKeyHex.lowercase() - if (configuredForPubkeyHex == pubkeyHex && sessionManager != null) { + if (configurationFailurePubkeyHex == pubkeyHex) { + Log.e(TAG, "Refusing to reopen failed NDR storage before reset or identity change") + return + } + if (configurationFailurePubkeyHex != null) { + configurationFailurePubkeyHex = null + } + if (configuredForPubkeyHex == pubkeyHex && pairwiseRuntime != null) { return } @@ -184,35 +248,65 @@ class NdrNostrService( configuredForPubkeyHex = pubkeyHex try { + val storagePath = java.io.File( + storageDirectoryProvider(), + "pairwise-v1/$pubkeyHex" + ).absolutePath + if (establishedSessionMarkers.contains(pubkeyHex) && + !pairwiseStateExists(storagePath) + ) { + throw java.io.IOException( + "Established pairwise state is missing for $pubkeyHex" + ) + } val runtime = runtimeFactory.newWithStoragePath( ourPubkeyHex = pubkeyHex, ourIdentityPrivkeyHex = identity.privateKeyHex, - deviceId = deviceIdProvider(), - // The FFI storage adapter uses fixed filenames. Namespace them - // by account owner so an identity switch cannot load another - // account's ratchet database. - storagePath = java.io.File( - storageDirectoryProvider(), - pubkeyHex - ).absolutePath, - ownerPubkeyHex = null + storagePath = storagePath ) - runtime.init() - sessionManager = runtime - restoreDurableAppKeysSubscriptionsLocked(runtime) + pairwiseRuntime = runtime + activeRuntimeEpoch = ++nextRuntimeEpoch + markEstablishedIfNeededLocked(runtime) drainAndApplyPubSubEventsLocked() } catch (_: Throwable) { Log.e(TAG, "Failed to configure NDR") teardownLocked() + configurationFailurePubkeyHex = pubkeyHex } } @Synchronized fun hasActiveSession(peerPubkeyHex: String): Boolean { if (!NdrFeatureGate.isEnabled()) return false - val runtime = sessionManager ?: return false + val runtime = pairwiseRuntime ?: return false + val peer = peerPubkeyHex.lowercase() return try { - runtime.getActiveSessionState(peerPubkeyHex.lowercase()) != null + val sessionInfo = runtime.sessionInfo(peer) ?: return false + knownActivePeerPubkeys.add(peer) + markEstablishedIfNeededLocked(runtime) + sessionInfo.isActive + } catch (_: Throwable) { + false + } + } + + /** + * True for any durable native session record, including the half-ready state between the + * authenticated OOB response and bootstrap kind-1060 delivery. + */ + @Synchronized + fun hasPairwiseSession(peerPubkeyHex: String): Boolean { + if (!NdrFeatureGate.isEnabled()) return false + val runtime = pairwiseRuntime ?: return false + val peer = peerPubkeyHex.lowercase() + return try { + if (runtime.sessionInfo(peer) == null) { + false + } else { + knownActivePeerPubkeys.add(peer) + markEstablishedIfNeededLocked(runtime) + true + } } catch (_: Throwable) { false } @@ -221,26 +315,66 @@ class NdrNostrService( @Synchronized fun activeSessionStateJson(peerPubkeyHex: String): String? { if (!NdrFeatureGate.isEnabled()) return null - val runtime = sessionManager ?: return null + val runtime = pairwiseRuntime ?: return null return try { - runtime.getActiveSessionState(peerPubkeyHex.lowercase()) + runtime.sessionInfo(peerPubkeyHex.lowercase())?.let { info -> + """{"send_ready":${info.sendReady},"receive_ready":${info.receiveReady}}""" + } } catch (_: Throwable) { null } } @Synchronized - fun sendIfPossible(text: String, peerPubkeyHex: String): Boolean { - if (!NdrFeatureGate.isEnabled()) return false - val runtime = sessionManager ?: return false - if (!hasActiveSession(peerPubkeyHex)) return false + fun sendIfPossible( + text: String, + peerPubkeyHex: String, + expiresAtSeconds: ULong? = null + ): NdrSendResult { + if (!NdrFeatureGate.isEnabled()) return NdrSendResult.NO_SESSION + val runtime = pairwiseRuntime ?: return if (configurationFailurePubkeyHex != null) { + NdrSendResult.FAILED + } else { + NdrSendResult.NO_SESSION + } + val peer = peerPubkeyHex.lowercase() + val sessionInfo = try { + runtime.sessionInfo(peer) + } catch (_: Throwable) { + return NdrSendResult.FAILED + } + if (sessionInfo == null) { + return NdrSendResult.NO_SESSION + } + knownActivePeerPubkeys.add(peer) + if (!sessionInfo.sendReady) { + return NdrSendResult.FAILED + } return try { - runtime.sendText(peerPubkeyHex.lowercase(), text, null) + runtime.sendText(peer, text, expiresAtSeconds) + markEstablishedIfNeededLocked(runtime) drainAndApplyPubSubEventsLocked() - true + NdrSendResult.SENT } catch (_: Throwable) { Log.d(TAG, "NDR send failed") drainAndApplyPubSubEventsLocked() + NdrSendResult.FAILED + } + } + + @Synchronized + fun retirePeer(peerPubkeyHex: String): Boolean { + if (!NdrFeatureGate.isEnabled()) return false + val peer = peerPubkeyHex.lowercase() + if (!NdrInputPolicy.isPubkeyHex(peer)) return false + val runtime = pairwiseRuntime ?: return false + return try { + runtime.retirePeer(peer) + knownActivePeerPubkeys.remove(peer) + drainAndApplyPubSubEventsLocked() + true + } catch (_: Throwable) { + Log.e(TAG, "Failed to retire rebound NDR peer") false } } @@ -253,7 +387,7 @@ class NdrNostrService( if (!NdrFeatureGate.isEnabled()) { return NdrOutOfBandProcessResult(emptyList()) } - val runtime = sessionManager ?: return NdrOutOfBandProcessResult(emptyList()) + val runtime = pairwiseRuntime ?: return NdrOutOfBandProcessResult(emptyList()) val trimmedPayload = eventJson.trim() val expectedPeer = expectedPeerPubkeyHex ?.lowercase() @@ -262,41 +396,32 @@ class NdrNostrService( if (!NdrInputPolicy.isWithinEncodedEventLimit(trimmedPayload)) { return NdrOutOfBandProcessResult(emptyList()) } - val inboundInvite = parseOutOfBandInvite(trimmedPayload) val parsedEvent = NostrEvent.fromJsonString(trimmedPayload) if (parsedEvent != null && !NdrInputPolicy.hasBoundedTags(parsedEvent)) { return NdrOutOfBandProcessResult(emptyList()) } + val inboundInvite = runCatching { + parseOutOfBandInvite(trimmedPayload) + }.getOrNull() var acceptResult: NdrAcceptInviteResult? = null var processingSucceeded = false - // Invite payloads carry an owner identity we can bind to the authenticated - // favorite. Other OOB responses may be gift wraps whose outer pubkey is - // intentionally ephemeral, so they must not be compared to the owner key. - val claimedPeer = inboundInvite?.ownerPubkeyHex + // Pairwise invites must be authored by the exact peer authenticated by + // the Noise session. Gift-wrap response authors remain ephemeral. + val claimedPeer = inboundInvite?.peerPubkeyHex if (claimedPeer != null && claimedPeer != expectedPeer) { - Log.w(TAG, "Rejecting OOB event with an authenticated-owner mismatch") + Log.w(TAG, "Rejecting OOB event with an authenticated-peer mismatch") return NdrOutOfBandProcessResult(emptyList()) } - if (inboundInvite != null) { - if (pendingInvitesByOwner.containsKey(expectedPeer)) { - return NdrOutOfBandProcessResult( - outboundPayloads = emptyList(), - sessionLookupPubkeyHex = expectedPeer - ) - } - } try { when { inboundInvite?.transport == OutOfBandInviteTransport.EVENT_JSON -> { acceptResult = runtime.acceptInviteFromEventJson(trimmedPayload, expectedPeer) - pendingInvitesByOwner.remove(expectedPeer) processingSucceeded = true } inboundInvite?.transport == OutOfBandInviteTransport.URL -> { acceptResult = runtime.acceptInviteFromUrl(trimmedPayload, expectedPeer) - pendingInvitesByOwner.remove(expectedPeer) processingSucceeded = true } parsedEvent?.kind == NostrKind.GIFT_WRAP -> { @@ -307,40 +432,33 @@ class NdrNostrService( Log.w(TAG, "Rejecting non-handshake OOB payload") } } - } catch (t: NdrSessionNotReadyException) { - if (inboundInvite != null) { - pendingInvitesByOwner[expectedPeer] = PendingOutOfBandInvite( - payload = trimmedPayload, - transport = inboundInvite.transport - ) - Log.d(TAG, "Retaining invite until its signed device roster arrives") - } else { - Log.d(TAG, "OOB session is not ready") - } + } catch (_: NdrSessionNotReadyException) { + Log.d(TAG, "OOB session is not ready") } catch (_: Throwable) { Log.d(TAG, "Ignoring invalid OOB event") } - if (processingSucceeded && hasActiveSession(expectedPeer)) { - ensureDurableAppKeysSubscriptionLocked(expectedPeer, runtime) - } - val outOfBandPublishes = - drainAndApplyPubSubEventsLocked(collectOutOfBandPublishes = true) - val sessionLookupPubkeyHex = acceptResult?.ownerPubkeyHex?.lowercase() - ?: expectedPeer - - if (inboundInvite != null && - inboundInvite.transport == OutOfBandInviteTransport.EVENT_JSON && - outOfBandPublishes.isEmpty() && - hasActiveSession(sessionLookupPubkeyHex) - ) { - preferredInviteOobPayload()?.let { - return NdrOutOfBandProcessResult( - outboundPayloads = outOfBandPublishes + it, - sessionLookupPubkeyHex = sessionLookupPubkeyHex - ) + acceptResult?.let { + if (it.peerPubkeyHex.lowercase() != expectedPeer) { + Log.w(TAG, "Rejecting OOB result with an authenticated-peer mismatch") + return NdrOutOfBandProcessResult(emptyList()) } } + if (processingSucceeded && + runCatching { markEstablishedIfNeededLocked(runtime) }.isFailure + ) { + Log.e(TAG, "Failed to persist NDR established-session marker") + return NdrOutOfBandProcessResult(emptyList()) + } + val collectOutOfBandPublishes = onOutOfBandPayload == null + val outOfBandPublishes = drainAndApplyPubSubEventsLocked( + collectOutOfBandPublishes = collectOutOfBandPublishes + ) + val sessionLookupPubkeyHex = if (processingSucceeded) { + acceptResult?.peerPubkeyHex?.lowercase() ?: expectedPeer + } else { + null + } return NdrOutOfBandProcessResult( outboundPayloads = outOfBandPublishes, @@ -349,89 +467,94 @@ class NdrNostrService( } @Synchronized - fun processInboundRelayEvent(event: NostrEvent) { - if (!NdrFeatureGate.isEnabled()) return - val runtime = sessionManager ?: return - if (event.kind != NDR_MESSAGE_KIND && event.kind != NDR_APP_KEYS_KIND) return - if (!NdrInputPolicy.hasBoundedTags(event)) return - val eventJson = event.toJsonString() - if (!NdrInputPolicy.isWithinEncodedEventLimit(eventJson)) return + fun processInboundRelayEvent(event: NostrEvent): Boolean { + if (!NdrFeatureGate.isEnabled()) return false + val runtime = pairwiseRuntime ?: return false + val runtimeEpoch = activeRuntimeEpoch ?: return false + return processInboundRelayEventLocked(event, runtime, runtimeEpoch) + } - try { - runtime.processEvent(eventJson) - } catch (_: Throwable) { - Log.d(TAG, "Ignoring invalid NDR relay event") - drainAndApplyPubSubEventsLocked() - return - } - - retryPendingInviteForRelayEventLocked(runtime, event) + @Synchronized + fun replayPendingOutOfBandPayloads() { + if (!NdrFeatureGate.isEnabled() || onOutOfBandPayload == null) return drainAndApplyPubSubEventsLocked() } - private fun retryPendingInviteForRelayEventLocked( - runtime: NdrSessionManager, - event: NostrEvent - ) { - if (event.kind != NDR_APP_KEYS_KIND || - event.tags.none { tag -> - tag.size >= 2 && tag[0] == "type" && tag[1] == NDR_APP_KEYS_TYPE - } - ) return - val ownerPubkeyHex = event.pubkey.lowercase() - val pending = pendingInvitesByOwner[ownerPubkeyHex] ?: return + @Synchronized + fun onOutOfBandTransportAvailable() { + if (!NdrFeatureGate.isEnabled() || pairwiseRuntime == null) return + outOfBandRetryTasks.values.forEach(NdrRetryCancellation::cancel) + outOfBandRetryTasks.clear() + outOfBandRetryAttempts.clear() + drainAndApplyPubSubEventsLocked() + } + + private fun processInboundRelayEventLocked( + event: NostrEvent, + runtime: NdrPairwiseRuntime, + runtimeEpoch: Long + ): Boolean { + if (!isCurrentRuntimeLocked(runtime, runtimeEpoch)) return false + if (event.kind != NDR_MESSAGE_KIND) return false + if (!NdrInputPolicy.hasBoundedTags(event)) return false + if (event.tags.any { it.firstOrNull() == "p" }) { + Log.w(TAG, "Rejecting recipient-tagged inbound NDR relay event") + return false + } + val eventJson = event.toJsonString() + if (!NdrInputPolicy.isWithinEncodedEventLimit(eventJson)) return false try { - when (pending.transport) { - OutOfBandInviteTransport.EVENT_JSON -> - runtime.acceptInviteFromEventJson(pending.payload, ownerPubkeyHex) - OutOfBandInviteTransport.URL -> - runtime.acceptInviteFromUrl(pending.payload, ownerPubkeyHex) - } - pendingInvitesByOwner.remove(ownerPubkeyHex) - if (hasActiveSession(ownerPubkeyHex)) { - ensureDurableAppKeysSubscriptionLocked(ownerPubkeyHex, runtime) - } - } catch (_: NdrSessionNotReadyException) { - return + runtime.processEvent(eventJson) + markEstablishedIfNeededLocked(runtime) } catch (_: Throwable) { - pendingInvitesByOwner.remove(ownerPubkeyHex) - Log.w(TAG, "Dropping retained invite after roster validation failed") - return + Log.d(TAG, "Ignoring invalid NDR relay event") + drainAndApplyPubSubEventsLocked() + return false } - val outboundPayloads = - drainAndApplyPubSubEventsLocked(collectOutOfBandPublishes = true) - if (outboundPayloads.isNotEmpty()) { - if (NdrFeatureGate.isEnabled()) { - onOutOfBandPayloadsReady?.invoke(ownerPubkeyHex, outboundPayloads) - } - } + drainAndApplyPubSubEventsLocked() + return true } @Synchronized private fun drainAndApplyPubSubEventsLocked( collectOutOfBandPublishes: Boolean = false - ): List { - val runtime = sessionManager ?: return emptyList() - val outOfBandPublishes = mutableListOf() + ): List { + val runtime = pairwiseRuntime ?: return emptyList() + val runtimeEpoch = activeRuntimeEpoch ?: return emptyList() + val outOfBandPublishes = mutableListOf() + val acknowledgedActionIds = mutableListOf() val events = try { - runtime.drainEvents() + runtime.pendingActions(nowSecondsProvider()) } catch (_: Throwable) { Log.e(TAG, "Failed to drain NDR events") return emptyList() } + val sessionsAwaitingOutOfBandAdmission = events + .asSequence() + .filter { it.kind == "out_of_band" } + .mapNotNull(NdrPubSubEvent::sessionId) + .toSet() events.forEach { event -> - applyPubSubEventLocked( + if (applyPubSubEventLocked( + runtime = runtime, + runtimeEpoch = runtimeEpoch, event = event, + sessionsAwaitingOutOfBandAdmission = sessionsAwaitingOutOfBandAdmission, collectOutOfBandPublish = if (collectOutOfBandPublishes) { { value -> outOfBandPublishes.add(value) } } else { null } - ) + )) { + acknowledgedActionIds += event.actionId + } + } + if (acknowledgedActionIds.isNotEmpty()) { + acknowledgeEventsLocked(runtime, runtimeEpoch, acknowledgedActionIds) } return outOfBandPublishes @@ -439,123 +562,423 @@ class NdrNostrService( @Synchronized private fun applyPubSubEventLocked( + runtime: NdrPairwiseRuntime, + runtimeEpoch: Long, event: NdrPubSubEvent, - collectOutOfBandPublish: ((String) -> Unit)? - ) { - when (event.kind) { + sessionsAwaitingOutOfBandAdmission: Set, + collectOutOfBandPublish: ((NdrOutOfBandPayload) -> Unit)? + ): Boolean { + if (event.actionId.isBlank()) { + Log.w(TAG, "Ignoring NDR action without a stable id") + return false + } + return when (event.kind) { "subscribe" -> { - val subid = event.subid ?: return - val filterJson = event.filterJson ?: return + val subid = event.subid ?: return true + val filterJson = event.filterJson ?: return true + if (hasRecipientFilter(filterJson)) { + Log.w(TAG, "Rejecting recipient-bearing NDR relay filter") + return true + } val filter = try { parseFilterJson(filterJson) } catch (_: Throwable) { Log.w(TAG, "Ignoring malformed NDR relay filter") - return + return true } - if (shouldIgnoreNdrSubscription(filter)) return + if (!isPairwiseMessageSubscription(filter)) return true if (!activeSubIds.add(subid)) { - return - } - val appKeysOwner = appKeysSubscriptionOwner(filter) - if (appKeysOwner != null) { - if (appKeysSubscriptionIdByOwner.containsKey(appKeysOwner)) { - activeSubIds.remove(subid) - return - } - appKeysSubscriptionIdByOwner[appKeysOwner] = subid - appKeysOwnerBySubscriptionId[subid] = appKeysOwner + return true } try { relayManager.subscribe(filter, subid) { inbound -> - processInboundRelayEvent(inbound) + synchronized(this) { + processInboundRelayEventLocked( + inbound, + runtime, + runtimeEpoch + ) + } } + true } catch (_: Throwable) { activeSubIds.remove(subid) - if (appKeysOwner != null) { - appKeysSubscriptionIdByOwner.remove(appKeysOwner) - appKeysOwnerBySubscriptionId.remove(subid) - durableAppKeysOwners.remove(appKeysOwner) - } Log.w(TAG, "Failed to install NDR relay filter") + false } } "unsubscribe" -> { - val subid = event.subid ?: return - appKeysOwnerBySubscriptionId.remove(subid)?.let { owner -> - if (appKeysSubscriptionIdByOwner[owner] == subid) { - appKeysSubscriptionIdByOwner.remove(owner) - durableAppKeysOwners.remove(owner) - } - } + val subid = event.subid ?: return true if (activeSubIds.remove(subid)) { - relayManager.unsubscribe(subid) + try { + relayManager.unsubscribe(subid) + true + } catch (_: Throwable) { + activeSubIds.add(subid) + Log.w(TAG, "Failed to remove NDR relay filter") + false + } + } else { + true } } - "publish_signed" -> { - val eventJson = event.eventJson ?: return - val nostrEvent = NostrEvent.fromJsonString(eventJson) ?: return - when { - isDoubleRatchetInviteEvent(nostrEvent) -> { - cachedInviteEventJson = eventJson - collectOutOfBandPublish?.invoke(eventJson) + "publish" -> { + val sessionId = event.sessionId?.takeIf(String::isNotBlank) + ?: run { + Log.w(TAG, "Rejecting NDR publish without a session id") + return true } - - nostrEvent.kind == NostrKind.GIFT_WRAP -> { - collectOutOfBandPublish?.invoke(eventJson) - } - - else -> relayManager.sendEvent(nostrEvent) + if (sessionId in sessionsAwaitingOutOfBandAdmission) { + return false } + val eventJson = event.eventJson ?: return true + val nostrEvent = NostrEvent.fromJsonString(eventJson) ?: return true + if (nostrEvent.kind != NDR_MESSAGE_KIND) { + Log.w(TAG, "Rejecting non-message NDR relay publish") + return true + } + if (nostrEvent.tags.any { it.firstOrNull() == "p" }) { + Log.w(TAG, "Rejecting recipient-tagged NDR relay publish") + return true + } + if (event.actionId in publishRetryTasks || + event.actionId in inFlightRelayEventsByActionId + ) return false + inFlightRelayEventsByActionId[event.actionId] = nostrEvent.id + try { + relayManager.sendEventConfirmed(nostrEvent) { accepted -> + synchronized(this) { + if (!isCurrentRuntimeLocked(runtime, runtimeEpoch)) { + return@synchronized + } + inFlightRelayEventsByActionId.remove(event.actionId) + if (accepted) { + val acknowledged = acknowledgeEventsLocked( + runtime, + runtimeEpoch, + listOf(event.actionId) + ) + if (acknowledged) { + clearPublishRetryLocked(event.actionId) + } else { + schedulePublishRetryLocked( + runtime, + runtimeEpoch, + event.actionId + ) + } + } else { + schedulePublishRetryLocked( + runtime, + runtimeEpoch, + event.actionId + ) + } + } + } + } catch (_: Throwable) { + inFlightRelayEventsByActionId.remove(event.actionId) + schedulePublishRetryLocked(runtime, runtimeEpoch, event.actionId) + Log.w(TAG, "Failed to queue NDR relay publish") + } + false } - "decrypted_message" -> { - if (!NdrFeatureGate.isEnabled()) return - val content = event.content ?: return - val senderPubkeyHex = event.senderPubkeyHex ?: return + "out_of_band" -> { + if (event.sessionId.isNullOrBlank()) { + Log.w(TAG, "Rejecting NDR out-of-band action without a session id") + return true + } + val eventJson = event.eventJson ?: return true + val nostrEvent = NostrEvent.fromJsonString(eventJson) ?: return true + if (nostrEvent.kind != NostrKind.GIFT_WRAP) { + Log.w(TAG, "Rejecting invalid NDR out-of-band action") + return true + } + val peerPubkeyHex = event.peerPubkeyHex + ?.lowercase() + ?.takeIf(NdrInputPolicy::isPubkeyHex) + ?: run { + Log.w(TAG, "Rejecting NDR out-of-band action without an exact peer") + return true + } + val payload = NdrOutOfBandPayload( + actionId = event.actionId, + eventJson = eventJson, + peerPubkeyHex = peerPubkeyHex, + runtimeEpoch = runtimeEpoch, + runtime = runtime + ) + if (collectOutOfBandPublish != null) { + collectOutOfBandPublish(payload) + } else { + onOutOfBandPayload?.let { callback -> + dispatchOutOfBandPayloadLocked( + runtime, + runtimeEpoch, + payload, + callback + ) + } + } + false + } + + "delivery" -> { + if (!NdrFeatureGate.isEnabled()) return true + val content = event.content ?: return true + val senderPubkeyHex = event.senderPubkeyHex ?: return true + val innerEventId = event.eventId ?: return true if (!NdrInputPolicy.isWithinEncodedEventLimit(content) || !NdrInputPolicy.isPubkeyHex(senderPubkeyHex) || - event.senderDevicePubkeyHex?.let(NdrInputPolicy::isPubkeyHex) == false || - event.conversationOwnerPubkeyHex?.let(NdrInputPolicy::isPubkeyHex) == false || - event.eventId?.let(NdrInputPolicy::isEventIdHex) == false - ) return + !NdrInputPolicy.isEventIdHex(innerEventId) + ) return true val message = NdrDecryptedMessage( + actionId = event.actionId, content = content, senderPubkeyHex = senderPubkeyHex.lowercase(), - senderDevicePubkeyHex = event.senderDevicePubkeyHex?.lowercase(), - conversationOwnerPubkeyHex = event.conversationOwnerPubkeyHex?.lowercase(), - eventId = event.eventId?.lowercase() + eventId = innerEventId.lowercase(), + expiresAtSeconds = event.expiresAtSeconds ) val callback = onDecryptedMessage if (callback != null) { - callback(message) - } else { - if (bufferedDecryptedMessages.size >= MAX_BUFFERED_DECRYPTED_MESSAGES) { - bufferedDecryptedMessages.removeFirst() - } - bufferedDecryptedMessages.addLast(message) + dispatchDecryptedMessageLocked( + runtime, + runtimeEpoch, + message, + callback + ) } + false } + + else -> true } } + @Synchronized + private fun dispatchDecryptedMessageLocked( + runtime: NdrPairwiseRuntime, + runtimeEpoch: Long, + message: NdrDecryptedMessage, + callback: ( + message: NdrDecryptedMessage, + completion: (NdrDeliveryResult) -> Unit + ) -> Unit + ) { + if (!dispatchedDeliveryActionIds.add(message.actionId)) return + try { + callback(message) { result -> + synchronized(this) { + if (!isCurrentRuntimeLocked(runtime, runtimeEpoch)) { + return@synchronized + } + dispatchedDeliveryActionIds.remove(message.actionId) + if (result.shouldAcknowledge) { + acknowledgeEventsLocked( + runtime, + runtimeEpoch, + listOf(message.actionId) + ) + } + } + } + } catch (_: Throwable) { + dispatchedDeliveryActionIds.remove(message.actionId) + Log.w(TAG, "NDR delivery callback failed") + } + } + + @Synchronized + fun acknowledgeOutOfBandPayload(payload: NdrOutOfBandPayload): Boolean { + val runtime = payload.runtime ?: return false + val runtimeEpoch = payload.runtimeEpoch ?: return false + if (!NdrFeatureGate.isEnabled()) return false + val acknowledged = + acknowledgeEventsLocked(runtime, runtimeEpoch, listOf(payload.actionId)) + if (acknowledged) { + clearOutOfBandRetryLocked(payload.actionId) + drainAndApplyPubSubEventsLocked() + } + return acknowledged + } + + @Synchronized + private fun dispatchOutOfBandPayloadLocked( + runtime: NdrPairwiseRuntime, + runtimeEpoch: Long, + payload: NdrOutOfBandPayload, + callback: ( + payload: NdrOutOfBandPayload, + completion: (admitted: Boolean) -> Unit + ) -> Unit + ) { + if (!dispatchedOutOfBandActionIds.add(payload.actionId)) return + try { + callback(payload) { admitted -> + synchronized(this) { + if (!isCurrentRuntimeLocked(runtime, runtimeEpoch)) { + return@synchronized + } + dispatchedOutOfBandActionIds.remove(payload.actionId) + if (admitted) { + val acknowledged = acknowledgeEventsLocked( + runtime, + runtimeEpoch, + listOf(payload.actionId) + ) + if (acknowledged) { + clearOutOfBandRetryLocked(payload.actionId) + // A publish from this exact handshake session may + // now cross the relay boundary. + drainAndApplyPubSubEventsLocked() + } else { + scheduleOutOfBandRetryLocked( + runtime, + runtimeEpoch, + payload.actionId + ) + } + } else { + scheduleOutOfBandRetryLocked( + runtime, + runtimeEpoch, + payload.actionId + ) + } + } + } + } catch (_: Throwable) { + dispatchedOutOfBandActionIds.remove(payload.actionId) + scheduleOutOfBandRetryLocked(runtime, runtimeEpoch, payload.actionId) + Log.w(TAG, "NDR out-of-band delivery callback failed") + } + } + + private fun acknowledgeEventsLocked( + runtime: NdrPairwiseRuntime, + runtimeEpoch: Long, + actionIds: List + ): Boolean { + if (!isCurrentRuntimeLocked(runtime, runtimeEpoch)) return false + return try { + runtime.ackActions(actionIds.distinct()) + true + } catch (_: Throwable) { + Log.w(TAG, "Failed to acknowledge NDR actions") + false + } + } + + private fun isCurrentRuntimeLocked( + runtime: NdrPairwiseRuntime, + runtimeEpoch: Long + ): Boolean { + return pairwiseRuntime === runtime && activeRuntimeEpoch == runtimeEpoch + } + + private fun markEstablishedIfNeededLocked(runtime: NdrPairwiseRuntime) { + val accountPubkeyHex = configuredForPubkeyHex ?: return + val hasPairwiseSessionRecord = runtime.knownPeerPubkeys().any { peerPubkeyHex -> + runtime.sessionInfo(peerPubkeyHex) != null + } + if (hasPairwiseSessionRecord) { + establishedSessionMarkers.mark(accountPubkeyHex) + } + } + + private fun schedulePublishRetryLocked( + runtime: NdrPairwiseRuntime, + runtimeEpoch: Long, + actionId: String + ) { + if (!isCurrentRuntimeLocked(runtime, runtimeEpoch) || + actionId in publishRetryTasks + ) return + val attempt = publishRetryAttempts[actionId] ?: 0 + val multiplier = 1L shl min(attempt, 5) + val delayMs = (PUBLISH_RETRY_INITIAL_DELAY_MS * multiplier) + .coerceAtMost(PUBLISH_RETRY_MAX_DELAY_MS) + publishRetryAttempts[actionId] = attempt + 1 + publishRetryTasks[actionId] = retryScheduler.schedule(delayMs) { + synchronized(this) { + if (!isCurrentRuntimeLocked(runtime, runtimeEpoch)) { + return@synchronized + } + publishRetryTasks.remove(actionId) + drainAndApplyPubSubEventsLocked() + } + } + } + + private fun clearPublishRetryLocked(actionId: String) { + publishRetryTasks.remove(actionId)?.cancel() + publishRetryAttempts.remove(actionId) + } + + private fun scheduleOutOfBandRetryLocked( + runtime: NdrPairwiseRuntime, + runtimeEpoch: Long, + actionId: String + ) { + if (!isCurrentRuntimeLocked(runtime, runtimeEpoch) || + actionId in outOfBandRetryTasks + ) return + val attempt = outOfBandRetryAttempts[actionId] ?: 0 + if (attempt >= OOB_RETRY_MAX_ATTEMPTS) return + val multiplier = 1L shl min(attempt, 4) + val delayMs = (OOB_RETRY_INITIAL_DELAY_MS * multiplier) + .coerceAtMost(OOB_RETRY_MAX_DELAY_MS) + outOfBandRetryAttempts[actionId] = attempt + 1 + outOfBandRetryTasks[actionId] = retryScheduler.schedule(delayMs) { + synchronized(this) { + if (!isCurrentRuntimeLocked(runtime, runtimeEpoch)) { + return@synchronized + } + outOfBandRetryTasks.remove(actionId) + drainAndApplyPubSubEventsLocked() + } + } + } + + private fun clearOutOfBandRetryLocked(actionId: String) { + outOfBandRetryTasks.remove(actionId)?.cancel() + outOfBandRetryAttempts.remove(actionId) + } + + @Synchronized + private fun onRelayConnectionAvailable() { + if (pairwiseRuntime == null || activeRuntimeEpoch == null) return + publishRetryTasks.values.forEach(NdrRetryCancellation::cancel) + publishRetryTasks.clear() + drainAndApplyPubSubEventsLocked() + } + @Synchronized private fun teardownLocked() { + val runtime = pairwiseRuntime + pairwiseRuntime = null + activeRuntimeEpoch = null + publishRetryTasks.values.forEach(NdrRetryCancellation::cancel) + publishRetryTasks.clear() + publishRetryAttempts.clear() + outOfBandRetryTasks.values.forEach(NdrRetryCancellation::cancel) + outOfBandRetryTasks.clear() + outOfBandRetryAttempts.clear() + val confirmedEventIds = inFlightRelayEventsByActionId.values.toSet() + inFlightRelayEventsByActionId.clear() + confirmedEventIds.forEach(relayManager::cancelConfirmedEvent) activeSubIds.forEach { subId -> runCatching { relayManager.unsubscribe(subId) } .onFailure { Log.w(TAG, "Failed to unsubscribe NDR relay filter") } } activeSubIds.clear() - appKeysSubscriptionIdByOwner.clear() - appKeysOwnerBySubscriptionId.clear() - durableAppKeysOwners.clear() - pendingInvitesByOwner.clear() - bufferedDecryptedMessages.clear() - cachedInviteEventJson = null + dispatchedDeliveryActionIds.clear() + dispatchedOutOfBandActionIds.clear() + knownActivePeerPubkeys.clear() configuredForPubkeyHex = null - val runtime = sessionManager - sessionManager = null runCatching { runtime?.destroy() } .onFailure { Log.w(TAG, "Failed to destroy NDR runtime") } } @@ -563,15 +986,18 @@ class NdrNostrService( @Synchronized fun resetForPanic(): Boolean { onDecryptedMessage = null - onOutOfBandPayloadsReady = null teardownLocked() val storageCleared = runCatching(storageResetter) .onFailure { Log.w(TAG, "Failed to delete NDR storage") } .isSuccess - val deviceIdCleared = runCatching(deviceIdResetter) - .onFailure { Log.w(TAG, "Failed to reset NDR device id") } - .isSuccess - panicResetBlocked = !(storageCleared && deviceIdCleared) + val markersCleared = storageCleared && + runCatching(establishedSessionMarkers::clearAll) + .onFailure { Log.w(TAG, "Failed to delete NDR downgrade markers") } + .isSuccess + panicResetBlocked = !markersCleared + if (markersCleared) { + configurationFailurePubkeyHex = null + } return !panicResetBlocked } @@ -591,12 +1017,7 @@ class NdrNostrService( } private data class ParsedOutOfBandInvite( - val ownerPubkeyHex: String, - val transport: OutOfBandInviteTransport - ) - - private data class PendingOutOfBandInvite( - val payload: String, + val peerPubkeyHex: String, val transport: OutOfBandInviteTransport ) @@ -606,70 +1027,30 @@ class NdrNostrService( if (payload.startsWith("{")) { val event = NostrEvent.fromJsonString(payload) ?: return null if (!isDoubleRatchetInviteEvent(event)) return null - val ownerPubkeyHex = inviteOwnerResolver(payload)?.lowercase() ?: return null + val peerPubkeyHex = invitePeerResolver(payload)?.lowercase() ?: return null return ParsedOutOfBandInvite( - ownerPubkeyHex = ownerPubkeyHex, + peerPubkeyHex = peerPubkeyHex, transport = OutOfBandInviteTransport.EVENT_JSON ) } - val ownerPubkeyHex = inviteOwnerResolver(payload)?.lowercase() ?: return null + val peerPubkeyHex = invitePeerResolver(payload)?.lowercase() ?: return null return ParsedOutOfBandInvite( - ownerPubkeyHex = ownerPubkeyHex, + peerPubkeyHex = peerPubkeyHex, transport = OutOfBandInviteTransport.URL ) } - private fun preferredInviteOobPayload(): String? { - val inviteEventJson = cachedInviteEventJson ?: return null - return compactInviteUrl(inviteEventJson) ?: inviteEventJson + private fun isPairwiseMessageSubscription(filter: NostrFilter): Boolean { + return filter.kinds == listOf(NDR_MESSAGE_KIND) && + filter.authors.orEmpty().isNotEmpty() && + filter.authors.orEmpty().all(NdrInputPolicy::isPubkeyHex) } - private fun compactInviteUrl(eventJson: String): String? { - return try { - val invite = uniffi.ndr_ffi.InviteHandle.fromEventJson(eventJson) - invite.use { it.`toUrl`(COMPACT_INVITE_URL_ROOT) } - } catch (_: Throwable) { - null - } - } - - private fun shouldIgnoreNdrSubscription(filter: NostrFilter): Boolean { - val kinds = filter.kinds.orEmpty() - // BitChat exchanges every invite/response bootstrap payload over an - // authenticated local transport, never through public relay discovery. - return NostrKind.GIFT_WRAP in kinds || 30078 in kinds - } - - private fun restoreDurableAppKeysSubscriptionsLocked(runtime: NdrSessionManager) { - runtime.knownPeerOwnerPubkeys().forEach { owner -> - ensureDurableAppKeysSubscriptionLocked(owner, runtime) - } - } - - private fun ensureDurableAppKeysSubscriptionLocked( - ownerPubkeyHex: String, - runtime: NdrSessionManager - ) { - val owner = ownerPubkeyHex.lowercase() - .takeIf(NdrInputPolicy::isPubkeyHex) - ?: return - if (owner in durableAppKeysOwners) return - try { - // setupUser emits both AppKeys and invite-discovery filters. The - // former stays live for device revocation; policy drops the latter. - runtime.setupUser(owner) - durableAppKeysOwners.add(owner) - } catch (_: Throwable) { - Log.w(TAG, "Failed to retain NDR AppKeys updates") - } - } - - private fun appKeysSubscriptionOwner(filter: NostrFilter): String? { - if (filter.kinds != listOf(NDR_APP_KEYS_KIND)) return null - val owner = filter.authors?.singleOrNull()?.lowercase() ?: return null - return owner.takeIf(NdrInputPolicy::isPubkeyHex) - } + private fun hasRecipientFilter(filterJson: String): Boolean = + runCatching { + JsonParser.parseString(filterJson).asJsonObject.has("#p") + }.getOrDefault(true) private fun parseFilterJson(filterJson: String): NostrFilter { val root = JsonParser.parseString(filterJson).asJsonObject @@ -704,94 +1085,113 @@ class NdrNostrService( } } -private class UniffiNdrSessionManager( - private val handle: uniffi.ndr_ffi.SessionManagerHandle -) : NdrSessionManager { - override fun init() { - handle.`init`() - } +private class UniffiNdrPairwiseRuntime( + private val manager: uniffi.ndr_ffi.PairwiseManager +) : NdrPairwiseRuntime { + override fun currentInviteEventJson(): String = manager.`currentInviteEventJson`() - override fun knownPeerOwnerPubkeys(): List = - handle.`knownPeerOwnerPubkeys`() - - override fun setupUser(userPubkeyHex: String) { - handle.`setupUser`(userPubkeyHex) - } + override fun currentInviteUrl(root: String): String = manager.`currentInviteUrl`(root) override fun acceptInviteFromEventJson( eventJson: String, - ownerPubkeyHintHex: String? + expectedPeerPubkeyHex: String ): NdrAcceptInviteResult { val result = try { - handle.`acceptInviteFromEventJson`(eventJson, ownerPubkeyHintHex) + manager.`acceptInviteFromEventJson`(eventJson, expectedPeerPubkeyHex) } catch (t: uniffi.ndr_ffi.NdrException.SessionNotReady) { throw NdrSessionNotReadyException(t.message, t) } + val peer = result.peerPubkeyHex.lowercase() + check(peer == expectedPeerPubkeyHex.lowercase()) { + "Pairwise invite identity mismatch" + } return NdrAcceptInviteResult( - ownerPubkeyHex = result.ownerPubkeyHex, - inviterDevicePubkeyHex = result.inviterDevicePubkeyHex, - deviceId = result.deviceId, + peerPubkeyHex = peer, createdNewSession = result.createdNewSession ) } override fun acceptInviteFromUrl( inviteUrl: String, - ownerPubkeyHintHex: String? + expectedPeerPubkeyHex: String ): NdrAcceptInviteResult { val result = try { - handle.`acceptInviteFromUrl`(inviteUrl, ownerPubkeyHintHex) + manager.`acceptInviteFromUrl`(inviteUrl, expectedPeerPubkeyHex) } catch (t: uniffi.ndr_ffi.NdrException.SessionNotReady) { throw NdrSessionNotReadyException(t.message, t) } + val peer = result.peerPubkeyHex.lowercase() + check(peer == expectedPeerPubkeyHex.lowercase()) { + "Pairwise invite identity mismatch" + } return NdrAcceptInviteResult( - ownerPubkeyHex = result.ownerPubkeyHex, - inviterDevicePubkeyHex = result.inviterDevicePubkeyHex, - deviceId = result.deviceId, + peerPubkeyHex = peer, createdNewSession = result.createdNewSession ) } override fun processEvent(eventJson: String) { - handle.`processEvent`(eventJson) + manager.`processEvent`(eventJson) } override fun processOutOfBandResponse( eventJson: String, - expectedOwnerPubkeyHex: String + expectedPeerPubkeyHex: String ) { - handle.`processOutOfBandResponse`(eventJson, expectedOwnerPubkeyHex) + manager.`processOutOfBandResponse`(eventJson, expectedPeerPubkeyHex) } - override fun drainEvents(): List { - return handle.`drainEvents`().map { + override fun pendingActions(nowSeconds: ULong): List = + manager.`pendingActionsAt`(nowSeconds).map { NdrPubSubEvent( + actionId = it.actionId, kind = it.kind, - subid = it.subid, + sessionId = it.sessionId, + subid = it.subscriptionId, filterJson = it.filterJson, eventJson = it.eventJson, - senderPubkeyHex = it.senderPubkeyHex, - senderDevicePubkeyHex = it.senderDevicePubkeyHex, - conversationOwnerPubkeyHex = it.conversationOwnerPubkeyHex, - content = it.content, - eventId = it.eventId + peerPubkeyHex = it.peerPubkeyHex?.lowercase(), + senderPubkeyHex = it.peerPubkeyHex?.lowercase(), + content = it.innerEventJson, + eventId = it.innerEventId?.lowercase(), + expiresAtSeconds = it.expiresAtSeconds ) } + + override fun ackActions(actionIds: List) { + manager.`ackActions`(actionIds) } - override fun getActiveSessionState(peerPubkeyHex: String): String? { - return handle.`getActiveSessionState`(peerPubkeyHex) + override fun sessionInfo(peerPubkeyHex: String): NdrPairwiseSessionInfo? = + manager.`sessionInfo`(peerPubkeyHex)?.let { + NdrPairwiseSessionInfo( + sendReady = it.sendReady, + receiveReady = it.receiveReady, + trackedSenderPubkeys = it.trackedSenderPubkeys.map(String::lowercase) + ) + } + + override fun knownPeerPubkeys(): List = + manager.`knownPeerPubkeys`().map(String::lowercase) + + override fun retirePeer(peerPubkeyHex: String): Boolean = + manager.`retirePeer`(peerPubkeyHex) + + override fun sendText( + recipientPubkeyHex: String, + text: String, + expiresAtSeconds: ULong? + ): NdrPairwiseSendResult { + val result = manager.`sendText`(recipientPubkeyHex, text, expiresAtSeconds) + return NdrPairwiseSendResult( + innerEventId = result.innerEventId, + outerEventId = result.outerEventId + ) } - override fun sendText(recipientPubkeyHex: String, text: String, expiresAtSeconds: ULong?): List { - return handle.`sendText`(recipientPubkeyHex, text, expiresAtSeconds) - } + override fun getOurPubkeyHex(): String = manager.`getOurPubkeyHex`() - override fun getOurPubkeyHex(): String = handle.`getOurPubkeyHex`() + override fun getTotalSessions(): ULong = manager.`getTotalSessions`() - override fun getTotalSessions(): ULong = handle.`getTotalSessions`() - - override fun destroy() { - handle.destroy() - } + override fun destroy() = manager.destroy() } diff --git a/app/src/main/java/com/bitchat/android/nostr/NdrOutOfBandRoutePolicy.kt b/app/src/main/java/com/bitchat/android/nostr/NdrOutOfBandRoutePolicy.kt new file mode 100644 index 00000000..a31b73de --- /dev/null +++ b/app/src/main/java/com/bitchat/android/nostr/NdrOutOfBandRoutePolicy.kt @@ -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 + } +} diff --git a/app/src/main/java/com/bitchat/android/nostr/NdrTypes.kt b/app/src/main/java/com/bitchat/android/nostr/NdrTypes.kt index c66bcd03..ac136f6b 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NdrTypes.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NdrTypes.kt @@ -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, + val outboundPayloads: List, 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 - 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 +) { + 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 - fun getActiveSessionState(peerPubkeyHex: String): String? - fun sendText(recipientPubkeyHex: String, text: String, expiresAtSeconds: ULong? = null): List + fun processOutOfBandResponse(eventJson: String, expectedPeerPubkeyHex: String) + fun pendingActions(nowSeconds: ULong): List + fun ackActions(actionIds: List) + fun sessionInfo(peerPubkeyHex: String): NdrPairwiseSessionInfo? + fun knownPeerPubkeys(): List + 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 } diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt b/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt index ab076fa8..4aa8f33d 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt @@ -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 } } diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrEventDeduplicator.kt b/app/src/main/java/com/bitchat/android/nostr/NostrEventDeduplicator.kt index 0638eafd..9283dc59 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrEventDeduplicator.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrEventDeduplicator.kt @@ -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() // 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 diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt b/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt index f50d65cc..251dba28 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt @@ -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() @@ -84,6 +93,8 @@ class NostrRelayManager private constructor() { private val connections = ConcurrentHashMap() private val subscriptions = ConcurrentHashMap>() // relay URL -> subscription IDs private val messageHandlers = ConcurrentHashMap Unit>() + private val commitAwareMessageHandlers = + ConcurrentHashMap Boolean>() // Persistent subscription tracking for robust reconnection private val activeSubscriptions = ConcurrentHashMap() // 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>>() private val messageQueueLock = Any() - - // Coroutine scope for background operations - private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + private data class ConfirmedPublish( + val awaitingRelayUrls: MutableSet, + val completion: (Boolean) -> Unit, + @Volatile var timeoutJob: Job? = null + ) + + private val confirmedPublishes = ConcurrentHashMap() + @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? = 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().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) diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrTransport.kt b/app/src/main/java/com/bitchat/android/nostr/NostrTransport.kt index 4cb1e26e..7e74e638 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrTransport.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrTransport.kt @@ -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( diff --git a/app/src/main/java/com/bitchat/android/services/SeenMessageStore.kt b/app/src/main/java/com/bitchat/android/services/SeenMessageStore.kt index 15e1c7ca..0b43ae16 100644 --- a/app/src/main/java/com/bitchat/android/services/SeenMessageStore.kt +++ b/app/src/main/java/com/bitchat/android/services/SeenMessageStore.kt @@ -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(MAX_IDS) private val read = LinkedHashSet(MAX_IDS) + private val ndrProcessed = LinkedHashSet(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 = emptyList(), - val read: List = emptyList() + val delivered: List? = emptyList(), + val read: List? = emptyList(), + val ndrProcessed: List? = emptyList() ) } diff --git a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt index 3f9bf0b0..82a70226 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -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() private val ndrNoiseHandshakeAttemptMs = ConcurrentHashMap() - private val ndrPendingOutOfBandPayloads = - ConcurrentHashMap>() + private val ndrAvailablePeers = ConcurrentHashMap.newKeySet() + 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) -> 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) { 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 + 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() + 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 diff --git a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt index ff0613f7..61a21ca5 100644 --- a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt +++ b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt @@ -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, diff --git a/app/src/main/java/uniffi/ndr_ffi/ndr_ffi.kt b/app/src/main/java/uniffi/ndr_ffi/ndr_ffi.kt index 5ce7987f..d44fc682 100644 --- a/app/src/main/java/uniffi/ndr_ffi/ndr_ffi.kt +++ b/app/src/main/java/uniffi/ndr_ffi/ndr_ffi.kt @@ -755,14 +755,6 @@ internal interface UniffiForeignFutureCompleteVoid : com.sun.jna.Callback { - - - - - - - - @@ -793,53 +785,45 @@ fun uniffi_ndr_ffi_checksum_func_generate_keypair( ): Short fun uniffi_ndr_ffi_checksum_func_version( ): Short -fun uniffi_ndr_ffi_checksum_method_invitehandle_get_inviter_pubkey_hex( +fun uniffi_ndr_ffi_checksum_method_pairwiseinvite_get_peer_pubkey_hex( ): Short -fun uniffi_ndr_ffi_checksum_method_invitehandle_get_owner_pubkey_hex( +fun uniffi_ndr_ffi_checksum_method_pairwiseinvite_to_url( ): Short -fun uniffi_ndr_ffi_checksum_method_invitehandle_to_url( +fun uniffi_ndr_ffi_checksum_method_pairwisemanager_accept_invite_from_event_json( ): Short -fun uniffi_ndr_ffi_checksum_method_sessionmanagerhandle_accept_invite_from_event_json( +fun uniffi_ndr_ffi_checksum_method_pairwisemanager_accept_invite_from_url( ): Short -fun uniffi_ndr_ffi_checksum_method_sessionmanagerhandle_accept_invite_from_url( +fun uniffi_ndr_ffi_checksum_method_pairwisemanager_ack_actions( ): Short -fun uniffi_ndr_ffi_checksum_method_sessionmanagerhandle_drain_events( +fun uniffi_ndr_ffi_checksum_method_pairwisemanager_current_invite_event_json( ): Short -fun uniffi_ndr_ffi_checksum_method_sessionmanagerhandle_get_active_session_state( +fun uniffi_ndr_ffi_checksum_method_pairwisemanager_current_invite_url( ): Short -fun uniffi_ndr_ffi_checksum_method_sessionmanagerhandle_get_device_id( +fun uniffi_ndr_ffi_checksum_method_pairwisemanager_get_our_pubkey_hex( ): Short -fun uniffi_ndr_ffi_checksum_method_sessionmanagerhandle_get_message_push_author_pubkeys( +fun uniffi_ndr_ffi_checksum_method_pairwisemanager_get_total_sessions( ): Short -fun uniffi_ndr_ffi_checksum_method_sessionmanagerhandle_get_message_push_session_states( +fun uniffi_ndr_ffi_checksum_method_pairwisemanager_known_peer_pubkeys( ): Short -fun uniffi_ndr_ffi_checksum_method_sessionmanagerhandle_get_our_pubkey_hex( +fun uniffi_ndr_ffi_checksum_method_pairwisemanager_pending_actions( ): Short -fun uniffi_ndr_ffi_checksum_method_sessionmanagerhandle_get_owner_pubkey_hex( +fun uniffi_ndr_ffi_checksum_method_pairwisemanager_pending_actions_at( ): Short -fun uniffi_ndr_ffi_checksum_method_sessionmanagerhandle_get_total_sessions( +fun uniffi_ndr_ffi_checksum_method_pairwisemanager_process_event( ): Short -fun uniffi_ndr_ffi_checksum_method_sessionmanagerhandle_init( +fun uniffi_ndr_ffi_checksum_method_pairwisemanager_process_out_of_band_response( ): Short -fun uniffi_ndr_ffi_checksum_method_sessionmanagerhandle_known_peer_owner_pubkeys( +fun uniffi_ndr_ffi_checksum_method_pairwisemanager_retire_peer( ): Short -fun uniffi_ndr_ffi_checksum_method_sessionmanagerhandle_process_event( +fun uniffi_ndr_ffi_checksum_method_pairwisemanager_send_text( ): Short -fun uniffi_ndr_ffi_checksum_method_sessionmanagerhandle_process_out_of_band_response( +fun uniffi_ndr_ffi_checksum_method_pairwisemanager_session_info( ): Short -fun uniffi_ndr_ffi_checksum_method_sessionmanagerhandle_send_text( +fun uniffi_ndr_ffi_checksum_constructor_pairwiseinvite_from_event_json( ): Short -fun uniffi_ndr_ffi_checksum_method_sessionmanagerhandle_send_text_with_inner_id( +fun uniffi_ndr_ffi_checksum_constructor_pairwiseinvite_from_url( ): Short -fun uniffi_ndr_ffi_checksum_method_sessionmanagerhandle_setup_user( -): Short -fun uniffi_ndr_ffi_checksum_constructor_invitehandle_from_event_json( -): Short -fun uniffi_ndr_ffi_checksum_constructor_invitehandle_from_url( -): Short -fun uniffi_ndr_ffi_checksum_constructor_sessionmanagerhandle_new( -): Short -fun uniffi_ndr_ffi_checksum_constructor_sessionmanagerhandle_new_with_storage_path( +fun uniffi_ndr_ffi_checksum_constructor_pairwisemanager_new_with_storage_path( ): Short fun ffi_ndr_ffi_uniffi_contract_version( ): Int @@ -890,63 +874,55 @@ internal interface UniffiLib : Library { } // FFI functions - fun uniffi_ndr_ffi_fn_clone_invitehandle(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, + fun uniffi_ndr_ffi_fn_clone_pairwiseinvite(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, ): Pointer -fun uniffi_ndr_ffi_fn_free_invitehandle(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_ndr_ffi_fn_free_pairwiseinvite(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, ): Unit -fun uniffi_ndr_ffi_fn_constructor_invitehandle_from_event_json(`eventJson`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_ndr_ffi_fn_constructor_pairwiseinvite_from_event_json(`eventJson`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): Pointer -fun uniffi_ndr_ffi_fn_constructor_invitehandle_from_url(`url`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_ndr_ffi_fn_constructor_pairwiseinvite_from_url(`url`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): Pointer -fun uniffi_ndr_ffi_fn_method_invitehandle_get_inviter_pubkey_hex(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_ndr_ffi_fn_method_pairwiseinvite_get_peer_pubkey_hex(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue -fun uniffi_ndr_ffi_fn_method_invitehandle_get_owner_pubkey_hex(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_ndr_ffi_fn_method_pairwiseinvite_to_url(`ptr`: Pointer,`root`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue -fun uniffi_ndr_ffi_fn_method_invitehandle_to_url(`ptr`: Pointer,`root`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, -): RustBuffer.ByValue -fun uniffi_ndr_ffi_fn_clone_sessionmanagerhandle(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_ndr_ffi_fn_clone_pairwisemanager(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, ): Pointer -fun uniffi_ndr_ffi_fn_free_sessionmanagerhandle(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_ndr_ffi_fn_free_pairwisemanager(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, ): Unit -fun uniffi_ndr_ffi_fn_constructor_sessionmanagerhandle_new(`ourPubkeyHex`: RustBuffer.ByValue,`ourIdentityPrivkeyHex`: RustBuffer.ByValue,`deviceId`: RustBuffer.ByValue,`ownerPubkeyHex`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_ndr_ffi_fn_constructor_pairwisemanager_new_with_storage_path(`ourPubkeyHex`: RustBuffer.ByValue,`ourIdentityPrivateKeyHex`: RustBuffer.ByValue,`storagePath`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): Pointer -fun uniffi_ndr_ffi_fn_constructor_sessionmanagerhandle_new_with_storage_path(`ourPubkeyHex`: RustBuffer.ByValue,`ourIdentityPrivkeyHex`: RustBuffer.ByValue,`deviceId`: RustBuffer.ByValue,`storagePath`: RustBuffer.ByValue,`ownerPubkeyHex`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, -): Pointer -fun uniffi_ndr_ffi_fn_method_sessionmanagerhandle_accept_invite_from_event_json(`ptr`: Pointer,`eventJson`: RustBuffer.ByValue,`ownerPubkeyHintHex`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_ndr_ffi_fn_method_pairwisemanager_accept_invite_from_event_json(`ptr`: Pointer,`eventJson`: RustBuffer.ByValue,`authenticatedPeerPubkeyHex`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue -fun uniffi_ndr_ffi_fn_method_sessionmanagerhandle_accept_invite_from_url(`ptr`: Pointer,`inviteUrl`: RustBuffer.ByValue,`ownerPubkeyHintHex`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_ndr_ffi_fn_method_pairwisemanager_accept_invite_from_url(`ptr`: Pointer,`inviteUrl`: RustBuffer.ByValue,`authenticatedPeerPubkeyHex`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue -fun uniffi_ndr_ffi_fn_method_sessionmanagerhandle_drain_events(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_ndr_ffi_fn_method_pairwisemanager_ack_actions(`ptr`: Pointer,`actionIds`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, +): Unit +fun uniffi_ndr_ffi_fn_method_pairwisemanager_current_invite_event_json(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue -fun uniffi_ndr_ffi_fn_method_sessionmanagerhandle_get_active_session_state(`ptr`: Pointer,`peerPubkeyHex`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_ndr_ffi_fn_method_pairwisemanager_current_invite_url(`ptr`: Pointer,`root`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue -fun uniffi_ndr_ffi_fn_method_sessionmanagerhandle_get_device_id(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_ndr_ffi_fn_method_pairwisemanager_get_our_pubkey_hex(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue -fun uniffi_ndr_ffi_fn_method_sessionmanagerhandle_get_message_push_author_pubkeys(`ptr`: Pointer,`peerOwnerPubkeyHex`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, -): RustBuffer.ByValue -fun uniffi_ndr_ffi_fn_method_sessionmanagerhandle_get_message_push_session_states(`ptr`: Pointer,`peerOwnerPubkeyHex`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, -): RustBuffer.ByValue -fun uniffi_ndr_ffi_fn_method_sessionmanagerhandle_get_our_pubkey_hex(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, -): RustBuffer.ByValue -fun uniffi_ndr_ffi_fn_method_sessionmanagerhandle_get_owner_pubkey_hex(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, -): RustBuffer.ByValue -fun uniffi_ndr_ffi_fn_method_sessionmanagerhandle_get_total_sessions(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_ndr_ffi_fn_method_pairwisemanager_get_total_sessions(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, ): Long -fun uniffi_ndr_ffi_fn_method_sessionmanagerhandle_init(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, -): Unit -fun uniffi_ndr_ffi_fn_method_sessionmanagerhandle_known_peer_owner_pubkeys(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_ndr_ffi_fn_method_pairwisemanager_known_peer_pubkeys(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue -fun uniffi_ndr_ffi_fn_method_sessionmanagerhandle_process_event(`ptr`: Pointer,`eventJson`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, -): Unit -fun uniffi_ndr_ffi_fn_method_sessionmanagerhandle_process_out_of_band_response(`ptr`: Pointer,`eventJson`: RustBuffer.ByValue,`expectedOwnerPubkeyHex`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, -): Unit -fun uniffi_ndr_ffi_fn_method_sessionmanagerhandle_send_text(`ptr`: Pointer,`recipientPubkeyHex`: RustBuffer.ByValue,`text`: RustBuffer.ByValue,`expiresAtSeconds`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_ndr_ffi_fn_method_pairwisemanager_pending_actions(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue -fun uniffi_ndr_ffi_fn_method_sessionmanagerhandle_send_text_with_inner_id(`ptr`: Pointer,`recipientPubkeyHex`: RustBuffer.ByValue,`text`: RustBuffer.ByValue,`expiresAtSeconds`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_ndr_ffi_fn_method_pairwisemanager_pending_actions_at(`ptr`: Pointer,`nowSeconds`: Long,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue -fun uniffi_ndr_ffi_fn_method_sessionmanagerhandle_setup_user(`ptr`: Pointer,`userPubkeyHex`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_ndr_ffi_fn_method_pairwisemanager_process_event(`ptr`: Pointer,`eventJson`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): Unit -fun uniffi_ndr_ffi_fn_func_derive_public_key(`privkeyHex`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, +fun uniffi_ndr_ffi_fn_method_pairwisemanager_process_out_of_band_response(`ptr`: Pointer,`eventJson`: RustBuffer.ByValue,`authenticatedPeerPubkeyHex`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, +): Unit +fun uniffi_ndr_ffi_fn_method_pairwisemanager_retire_peer(`ptr`: Pointer,`peerPubkeyHex`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, +): Byte +fun uniffi_ndr_ffi_fn_method_pairwisemanager_send_text(`ptr`: Pointer,`peerPubkeyHex`: RustBuffer.ByValue,`text`: RustBuffer.ByValue,`expiresAtSeconds`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, +): RustBuffer.ByValue +fun uniffi_ndr_ffi_fn_method_pairwisemanager_session_info(`ptr`: Pointer,`peerPubkeyHex`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, +): RustBuffer.ByValue +fun uniffi_ndr_ffi_fn_func_derive_public_key(`privateKeyHex`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue fun uniffi_ndr_ffi_fn_func_generate_keypair(uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue @@ -1078,7 +1054,7 @@ private fun uniffiCheckContractApiVersion(lib: IntegrityCheckingUniffiLib) { } @Suppress("UNUSED_PARAMETER") private fun uniffiCheckApiChecksums(lib: IntegrityCheckingUniffiLib) { - if (lib.uniffi_ndr_ffi_checksum_func_derive_public_key() != 46297.toShort()) { + if (lib.uniffi_ndr_ffi_checksum_func_derive_public_key() != 22065.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } if (lib.uniffi_ndr_ffi_checksum_func_generate_keypair() != 57537.toShort()) { @@ -1087,76 +1063,64 @@ private fun uniffiCheckApiChecksums(lib: IntegrityCheckingUniffiLib) { if (lib.uniffi_ndr_ffi_checksum_func_version() != 35402.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } - if (lib.uniffi_ndr_ffi_checksum_method_invitehandle_get_inviter_pubkey_hex() != 62322.toShort()) { + if (lib.uniffi_ndr_ffi_checksum_method_pairwiseinvite_get_peer_pubkey_hex() != 25596.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } - if (lib.uniffi_ndr_ffi_checksum_method_invitehandle_get_owner_pubkey_hex() != 17484.toShort()) { + if (lib.uniffi_ndr_ffi_checksum_method_pairwiseinvite_to_url() != 1141.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } - if (lib.uniffi_ndr_ffi_checksum_method_invitehandle_to_url() != 41511.toShort()) { + if (lib.uniffi_ndr_ffi_checksum_method_pairwisemanager_accept_invite_from_event_json() != 42574.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } - if (lib.uniffi_ndr_ffi_checksum_method_sessionmanagerhandle_accept_invite_from_event_json() != 39696.toShort()) { + if (lib.uniffi_ndr_ffi_checksum_method_pairwisemanager_accept_invite_from_url() != 29995.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } - if (lib.uniffi_ndr_ffi_checksum_method_sessionmanagerhandle_accept_invite_from_url() != 49858.toShort()) { + if (lib.uniffi_ndr_ffi_checksum_method_pairwisemanager_ack_actions() != 17265.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } - if (lib.uniffi_ndr_ffi_checksum_method_sessionmanagerhandle_drain_events() != 31848.toShort()) { + if (lib.uniffi_ndr_ffi_checksum_method_pairwisemanager_current_invite_event_json() != 41966.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } - if (lib.uniffi_ndr_ffi_checksum_method_sessionmanagerhandle_get_active_session_state() != 4999.toShort()) { + if (lib.uniffi_ndr_ffi_checksum_method_pairwisemanager_current_invite_url() != 50454.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } - if (lib.uniffi_ndr_ffi_checksum_method_sessionmanagerhandle_get_device_id() != 8402.toShort()) { + if (lib.uniffi_ndr_ffi_checksum_method_pairwisemanager_get_our_pubkey_hex() != 24347.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } - if (lib.uniffi_ndr_ffi_checksum_method_sessionmanagerhandle_get_message_push_author_pubkeys() != 45983.toShort()) { + if (lib.uniffi_ndr_ffi_checksum_method_pairwisemanager_get_total_sessions() != 5478.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } - if (lib.uniffi_ndr_ffi_checksum_method_sessionmanagerhandle_get_message_push_session_states() != 64635.toShort()) { + if (lib.uniffi_ndr_ffi_checksum_method_pairwisemanager_known_peer_pubkeys() != 21367.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } - if (lib.uniffi_ndr_ffi_checksum_method_sessionmanagerhandle_get_our_pubkey_hex() != 19398.toShort()) { + if (lib.uniffi_ndr_ffi_checksum_method_pairwisemanager_pending_actions() != 4469.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } - if (lib.uniffi_ndr_ffi_checksum_method_sessionmanagerhandle_get_owner_pubkey_hex() != 49647.toShort()) { + if (lib.uniffi_ndr_ffi_checksum_method_pairwisemanager_pending_actions_at() != 26221.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } - if (lib.uniffi_ndr_ffi_checksum_method_sessionmanagerhandle_get_total_sessions() != 21120.toShort()) { + if (lib.uniffi_ndr_ffi_checksum_method_pairwisemanager_process_event() != 51097.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } - if (lib.uniffi_ndr_ffi_checksum_method_sessionmanagerhandle_init() != 25634.toShort()) { + if (lib.uniffi_ndr_ffi_checksum_method_pairwisemanager_process_out_of_band_response() != 48382.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } - if (lib.uniffi_ndr_ffi_checksum_method_sessionmanagerhandle_known_peer_owner_pubkeys() != 31004.toShort()) { + if (lib.uniffi_ndr_ffi_checksum_method_pairwisemanager_retire_peer() != 12247.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } - if (lib.uniffi_ndr_ffi_checksum_method_sessionmanagerhandle_process_event() != 18483.toShort()) { + if (lib.uniffi_ndr_ffi_checksum_method_pairwisemanager_send_text() != 20592.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } - if (lib.uniffi_ndr_ffi_checksum_method_sessionmanagerhandle_process_out_of_band_response() != 48675.toShort()) { + if (lib.uniffi_ndr_ffi_checksum_method_pairwisemanager_session_info() != 49395.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } - if (lib.uniffi_ndr_ffi_checksum_method_sessionmanagerhandle_send_text() != 56962.toShort()) { + if (lib.uniffi_ndr_ffi_checksum_constructor_pairwiseinvite_from_event_json() != 9371.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } - if (lib.uniffi_ndr_ffi_checksum_method_sessionmanagerhandle_send_text_with_inner_id() != 55155.toShort()) { + if (lib.uniffi_ndr_ffi_checksum_constructor_pairwiseinvite_from_url() != 12100.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } - if (lib.uniffi_ndr_ffi_checksum_method_sessionmanagerhandle_setup_user() != 41291.toShort()) { - throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") - } - if (lib.uniffi_ndr_ffi_checksum_constructor_invitehandle_from_event_json() != 46387.toShort()) { - throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") - } - if (lib.uniffi_ndr_ffi_checksum_constructor_invitehandle_from_url() != 28197.toShort()) { - throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") - } - if (lib.uniffi_ndr_ffi_checksum_constructor_sessionmanagerhandle_new() != 47765.toShort()) { - throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") - } - if (lib.uniffi_ndr_ffi_checksum_constructor_sessionmanagerhandle_new_with_storage_path() != 5699.toShort()) { + if (lib.uniffi_ndr_ffi_checksum_constructor_pairwisemanager_new_with_storage_path() != 24319.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } } @@ -1504,18 +1468,16 @@ public object FfiConverterString: FfiConverter { // -public interface InviteHandleInterface { +public interface PairwiseInviteInterface { - fun `getInviterPubkeyHex`(): kotlin.String - - fun `getOwnerPubkeyHex`(): kotlin.String + fun `getPeerPubkeyHex`(): kotlin.String fun `toUrl`(`root`: kotlin.String): kotlin.String companion object } -open class InviteHandle: Disposable, AutoCloseable, InviteHandleInterface +open class PairwiseInvite: Disposable, AutoCloseable, PairwiseInviteInterface { constructor(pointer: Pointer) { @@ -1585,7 +1547,7 @@ open class InviteHandle: Disposable, AutoCloseable, InviteHandleInterface override fun run() { pointer?.let { ptr -> uniffiRustCall { status -> - UniffiLib.INSTANCE.uniffi_ndr_ffi_fn_free_invitehandle(ptr, status) + UniffiLib.INSTANCE.uniffi_ndr_ffi_fn_free_pairwiseinvite(ptr, status) } } } @@ -1593,28 +1555,15 @@ open class InviteHandle: Disposable, AutoCloseable, InviteHandleInterface fun uniffiClonePointer(): Pointer { return uniffiRustCall() { status -> - UniffiLib.INSTANCE.uniffi_ndr_ffi_fn_clone_invitehandle(pointer!!, status) + UniffiLib.INSTANCE.uniffi_ndr_ffi_fn_clone_pairwiseinvite(pointer!!, status) } } - override fun `getInviterPubkeyHex`(): kotlin.String { + override fun `getPeerPubkeyHex`(): kotlin.String { return FfiConverterString.lift( callWithPointer { uniffiRustCall() { _status -> - UniffiLib.INSTANCE.uniffi_ndr_ffi_fn_method_invitehandle_get_inviter_pubkey_hex( - it, _status) -} - } - ) - } - - - - @Throws(NdrException::class)override fun `getOwnerPubkeyHex`(): kotlin.String { - return FfiConverterString.lift( - callWithPointer { - uniffiRustCallWithError(NdrException) { _status -> - UniffiLib.INSTANCE.uniffi_ndr_ffi_fn_method_invitehandle_get_owner_pubkey_hex( + UniffiLib.INSTANCE.uniffi_ndr_ffi_fn_method_pairwiseinvite_get_peer_pubkey_hex( it, _status) } } @@ -1627,7 +1576,7 @@ open class InviteHandle: Disposable, AutoCloseable, InviteHandleInterface return FfiConverterString.lift( callWithPointer { uniffiRustCallWithError(NdrException) { _status -> - UniffiLib.INSTANCE.uniffi_ndr_ffi_fn_method_invitehandle_to_url( + UniffiLib.INSTANCE.uniffi_ndr_ffi_fn_method_pairwiseinvite_to_url( it, FfiConverterString.lower(`root`),_status) } } @@ -1640,10 +1589,10 @@ open class InviteHandle: Disposable, AutoCloseable, InviteHandleInterface companion object { - @Throws(NdrException::class) fun `fromEventJson`(`eventJson`: kotlin.String): InviteHandle { - return FfiConverterTypeInviteHandle.lift( + @Throws(NdrException::class) fun `fromEventJson`(`eventJson`: kotlin.String): PairwiseInvite { + return FfiConverterTypePairwiseInvite.lift( uniffiRustCallWithError(NdrException) { _status -> - UniffiLib.INSTANCE.uniffi_ndr_ffi_fn_constructor_invitehandle_from_event_json( + UniffiLib.INSTANCE.uniffi_ndr_ffi_fn_constructor_pairwiseinvite_from_event_json( FfiConverterString.lower(`eventJson`),_status) } ) @@ -1651,10 +1600,10 @@ open class InviteHandle: Disposable, AutoCloseable, InviteHandleInterface - @Throws(NdrException::class) fun `fromUrl`(`url`: kotlin.String): InviteHandle { - return FfiConverterTypeInviteHandle.lift( + @Throws(NdrException::class) fun `fromUrl`(`url`: kotlin.String): PairwiseInvite { + return FfiConverterTypePairwiseInvite.lift( uniffiRustCallWithError(NdrException) { _status -> - UniffiLib.INSTANCE.uniffi_ndr_ffi_fn_constructor_invitehandle_from_url( + UniffiLib.INSTANCE.uniffi_ndr_ffi_fn_constructor_pairwiseinvite_from_url( FfiConverterString.lower(`url`),_status) } ) @@ -1669,25 +1618,25 @@ open class InviteHandle: Disposable, AutoCloseable, InviteHandleInterface /** * @suppress */ -public object FfiConverterTypeInviteHandle: FfiConverter { +public object FfiConverterTypePairwiseInvite: FfiConverter { - override fun lower(value: InviteHandle): Pointer { + override fun lower(value: PairwiseInvite): Pointer { return value.uniffiClonePointer() } - override fun lift(value: Pointer): InviteHandle { - return InviteHandle(value) + override fun lift(value: Pointer): PairwiseInvite { + return PairwiseInvite(value) } - override fun read(buf: ByteBuffer): InviteHandle { + override fun read(buf: ByteBuffer): PairwiseInvite { // The Rust code always writes pointers as 8 bytes, and will // fail to compile if they don't fit. return lift(Pointer(buf.getLong())) } - override fun allocationSize(value: InviteHandle) = 8UL + override fun allocationSize(value: PairwiseInvite) = 8UL - override fun write(value: InviteHandle, buf: ByteBuffer) { + override fun write(value: PairwiseInvite, buf: ByteBuffer) { // The Rust code always expects pointers written as 8 bytes, // and will fail to compile if they don't fit. buf.putLong(Pointer.nativeValue(lower(value))) @@ -1793,46 +1742,42 @@ public object FfiConverterTypeInviteHandle: FfiConverter // -public interface SessionManagerHandleInterface { +public interface PairwiseManagerInterface { - fun `acceptInviteFromEventJson`(`eventJson`: kotlin.String, `ownerPubkeyHintHex`: kotlin.String?): SessionManagerAcceptInviteResult + fun `acceptInviteFromEventJson`(`eventJson`: kotlin.String, `authenticatedPeerPubkeyHex`: kotlin.String): PairwiseAcceptResult - fun `acceptInviteFromUrl`(`inviteUrl`: kotlin.String, `ownerPubkeyHintHex`: kotlin.String?): SessionManagerAcceptInviteResult + fun `acceptInviteFromUrl`(`inviteUrl`: kotlin.String, `authenticatedPeerPubkeyHex`: kotlin.String): PairwiseAcceptResult - fun `drainEvents`(): List + fun `ackActions`(`actionIds`: List) - fun `getActiveSessionState`(`peerPubkeyHex`: kotlin.String): kotlin.String? + fun `currentInviteEventJson`(): kotlin.String - fun `getDeviceId`(): kotlin.String - - fun `getMessagePushAuthorPubkeys`(`peerOwnerPubkeyHex`: kotlin.String): List - - fun `getMessagePushSessionStates`(`peerOwnerPubkeyHex`: kotlin.String): List + fun `currentInviteUrl`(`root`: kotlin.String): kotlin.String fun `getOurPubkeyHex`(): kotlin.String - fun `getOwnerPubkeyHex`(): kotlin.String - fun `getTotalSessions`(): kotlin.ULong - fun `init`() + fun `knownPeerPubkeys`(): List - fun `knownPeerOwnerPubkeys`(): List + fun `pendingActions`(): List + + fun `pendingActionsAt`(`nowSeconds`: kotlin.ULong): List fun `processEvent`(`eventJson`: kotlin.String) - fun `processOutOfBandResponse`(`eventJson`: kotlin.String, `expectedOwnerPubkeyHex`: kotlin.String) + fun `processOutOfBandResponse`(`eventJson`: kotlin.String, `authenticatedPeerPubkeyHex`: kotlin.String) - fun `sendText`(`recipientPubkeyHex`: kotlin.String, `text`: kotlin.String, `expiresAtSeconds`: kotlin.ULong?): List + fun `retirePeer`(`peerPubkeyHex`: kotlin.String): kotlin.Boolean - fun `sendTextWithInnerId`(`recipientPubkeyHex`: kotlin.String, `text`: kotlin.String, `expiresAtSeconds`: kotlin.ULong?): SendTextResult + fun `sendText`(`peerPubkeyHex`: kotlin.String, `text`: kotlin.String, `expiresAtSeconds`: kotlin.ULong?): PairwiseSendResult - fun `setupUser`(`userPubkeyHex`: kotlin.String) + fun `sessionInfo`(`peerPubkeyHex`: kotlin.String): PairwiseSessionInfo? companion object } -open class SessionManagerHandle: Disposable, AutoCloseable, SessionManagerHandleInterface +open class PairwiseManager: Disposable, AutoCloseable, PairwiseManagerInterface { constructor(pointer: Pointer) { @@ -1850,13 +1795,6 @@ open class SessionManagerHandle: Disposable, AutoCloseable, SessionManagerHandle this.pointer = null this.cleanable = UniffiLib.CLEANER.register(this, UniffiCleanAction(pointer)) } - constructor(`ourPubkeyHex`: kotlin.String, `ourIdentityPrivkeyHex`: kotlin.String, `deviceId`: kotlin.String, `ownerPubkeyHex`: kotlin.String?) : - this( - uniffiRustCallWithError(NdrException) { _status -> - UniffiLib.INSTANCE.uniffi_ndr_ffi_fn_constructor_sessionmanagerhandle_new( - FfiConverterString.lower(`ourPubkeyHex`),FfiConverterString.lower(`ourIdentityPrivkeyHex`),FfiConverterString.lower(`deviceId`),FfiConverterOptionalString.lower(`ownerPubkeyHex`),_status) -} - ) protected val pointer: Pointer? protected val cleanable: UniffiCleaner.Cleanable @@ -1909,7 +1847,7 @@ open class SessionManagerHandle: Disposable, AutoCloseable, SessionManagerHandle override fun run() { pointer?.let { ptr -> uniffiRustCall { status -> - UniffiLib.INSTANCE.uniffi_ndr_ffi_fn_free_sessionmanagerhandle(ptr, status) + UniffiLib.INSTANCE.uniffi_ndr_ffi_fn_free_pairwisemanager(ptr, status) } } } @@ -1917,17 +1855,17 @@ open class SessionManagerHandle: Disposable, AutoCloseable, SessionManagerHandle fun uniffiClonePointer(): Pointer { return uniffiRustCall() { status -> - UniffiLib.INSTANCE.uniffi_ndr_ffi_fn_clone_sessionmanagerhandle(pointer!!, status) + UniffiLib.INSTANCE.uniffi_ndr_ffi_fn_clone_pairwisemanager(pointer!!, status) } } - @Throws(NdrException::class)override fun `acceptInviteFromEventJson`(`eventJson`: kotlin.String, `ownerPubkeyHintHex`: kotlin.String?): SessionManagerAcceptInviteResult { - return FfiConverterTypeSessionManagerAcceptInviteResult.lift( + @Throws(NdrException::class)override fun `acceptInviteFromEventJson`(`eventJson`: kotlin.String, `authenticatedPeerPubkeyHex`: kotlin.String): PairwiseAcceptResult { + return FfiConverterTypePairwiseAcceptResult.lift( callWithPointer { uniffiRustCallWithError(NdrException) { _status -> - UniffiLib.INSTANCE.uniffi_ndr_ffi_fn_method_sessionmanagerhandle_accept_invite_from_event_json( - it, FfiConverterString.lower(`eventJson`),FfiConverterOptionalString.lower(`ownerPubkeyHintHex`),_status) + UniffiLib.INSTANCE.uniffi_ndr_ffi_fn_method_pairwisemanager_accept_invite_from_event_json( + it, FfiConverterString.lower(`eventJson`),FfiConverterString.lower(`authenticatedPeerPubkeyHex`),_status) } } ) @@ -1935,12 +1873,12 @@ open class SessionManagerHandle: Disposable, AutoCloseable, SessionManagerHandle - @Throws(NdrException::class)override fun `acceptInviteFromUrl`(`inviteUrl`: kotlin.String, `ownerPubkeyHintHex`: kotlin.String?): SessionManagerAcceptInviteResult { - return FfiConverterTypeSessionManagerAcceptInviteResult.lift( + @Throws(NdrException::class)override fun `acceptInviteFromUrl`(`inviteUrl`: kotlin.String, `authenticatedPeerPubkeyHex`: kotlin.String): PairwiseAcceptResult { + return FfiConverterTypePairwiseAcceptResult.lift( callWithPointer { uniffiRustCallWithError(NdrException) { _status -> - UniffiLib.INSTANCE.uniffi_ndr_ffi_fn_method_sessionmanagerhandle_accept_invite_from_url( - it, FfiConverterString.lower(`inviteUrl`),FfiConverterOptionalString.lower(`ownerPubkeyHintHex`),_status) + UniffiLib.INSTANCE.uniffi_ndr_ffi_fn_method_pairwisemanager_accept_invite_from_url( + it, FfiConverterString.lower(`inviteUrl`),FfiConverterString.lower(`authenticatedPeerPubkeyHex`),_status) } } ) @@ -1948,122 +1886,75 @@ open class SessionManagerHandle: Disposable, AutoCloseable, SessionManagerHandle - @Throws(NdrException::class)override fun `drainEvents`(): List { - return FfiConverterSequenceTypePubSubEvent.lift( - callWithPointer { - uniffiRustCallWithError(NdrException) { _status -> - UniffiLib.INSTANCE.uniffi_ndr_ffi_fn_method_sessionmanagerhandle_drain_events( - it, _status) -} - } - ) - } - - - - @Throws(NdrException::class)override fun `getActiveSessionState`(`peerPubkeyHex`: kotlin.String): kotlin.String? { - return FfiConverterOptionalString.lift( - callWithPointer { - uniffiRustCallWithError(NdrException) { _status -> - UniffiLib.INSTANCE.uniffi_ndr_ffi_fn_method_sessionmanagerhandle_get_active_session_state( - it, FfiConverterString.lower(`peerPubkeyHex`),_status) -} - } - ) - } - - - override fun `getDeviceId`(): kotlin.String { - return FfiConverterString.lift( - callWithPointer { - uniffiRustCall() { _status -> - UniffiLib.INSTANCE.uniffi_ndr_ffi_fn_method_sessionmanagerhandle_get_device_id( - it, _status) -} - } - ) - } - - - - @Throws(NdrException::class)override fun `getMessagePushAuthorPubkeys`(`peerOwnerPubkeyHex`: kotlin.String): List { - return FfiConverterSequenceString.lift( - callWithPointer { - uniffiRustCallWithError(NdrException) { _status -> - UniffiLib.INSTANCE.uniffi_ndr_ffi_fn_method_sessionmanagerhandle_get_message_push_author_pubkeys( - it, FfiConverterString.lower(`peerOwnerPubkeyHex`),_status) -} - } - ) - } - - - - @Throws(NdrException::class)override fun `getMessagePushSessionStates`(`peerOwnerPubkeyHex`: kotlin.String): List { - return FfiConverterSequenceTypeMessagePushSessionStateResult.lift( - callWithPointer { - uniffiRustCallWithError(NdrException) { _status -> - UniffiLib.INSTANCE.uniffi_ndr_ffi_fn_method_sessionmanagerhandle_get_message_push_session_states( - it, FfiConverterString.lower(`peerOwnerPubkeyHex`),_status) -} - } - ) - } - - - override fun `getOurPubkeyHex`(): kotlin.String { - return FfiConverterString.lift( - callWithPointer { - uniffiRustCall() { _status -> - UniffiLib.INSTANCE.uniffi_ndr_ffi_fn_method_sessionmanagerhandle_get_our_pubkey_hex( - it, _status) -} - } - ) - } - - - override fun `getOwnerPubkeyHex`(): kotlin.String { - return FfiConverterString.lift( - callWithPointer { - uniffiRustCall() { _status -> - UniffiLib.INSTANCE.uniffi_ndr_ffi_fn_method_sessionmanagerhandle_get_owner_pubkey_hex( - it, _status) -} - } - ) - } - - - override fun `getTotalSessions`(): kotlin.ULong { - return FfiConverterULong.lift( - callWithPointer { - uniffiRustCall() { _status -> - UniffiLib.INSTANCE.uniffi_ndr_ffi_fn_method_sessionmanagerhandle_get_total_sessions( - it, _status) -} - } - ) - } - - - - @Throws(NdrException::class)override fun `init`() + @Throws(NdrException::class)override fun `ackActions`(`actionIds`: List) = callWithPointer { uniffiRustCallWithError(NdrException) { _status -> - UniffiLib.INSTANCE.uniffi_ndr_ffi_fn_method_sessionmanagerhandle_init( - it, _status) + UniffiLib.INSTANCE.uniffi_ndr_ffi_fn_method_pairwisemanager_ack_actions( + it, FfiConverterSequenceString.lower(`actionIds`),_status) } } - override fun `knownPeerOwnerPubkeys`(): List { + + @Throws(NdrException::class)override fun `currentInviteEventJson`(): kotlin.String { + return FfiConverterString.lift( + callWithPointer { + uniffiRustCallWithError(NdrException) { _status -> + UniffiLib.INSTANCE.uniffi_ndr_ffi_fn_method_pairwisemanager_current_invite_event_json( + it, _status) +} + } + ) + } + + + + @Throws(NdrException::class)override fun `currentInviteUrl`(`root`: kotlin.String): kotlin.String { + return FfiConverterString.lift( + callWithPointer { + uniffiRustCallWithError(NdrException) { _status -> + UniffiLib.INSTANCE.uniffi_ndr_ffi_fn_method_pairwisemanager_current_invite_url( + it, FfiConverterString.lower(`root`),_status) +} + } + ) + } + + + + @Throws(NdrException::class)override fun `getOurPubkeyHex`(): kotlin.String { + return FfiConverterString.lift( + callWithPointer { + uniffiRustCallWithError(NdrException) { _status -> + UniffiLib.INSTANCE.uniffi_ndr_ffi_fn_method_pairwisemanager_get_our_pubkey_hex( + it, _status) +} + } + ) + } + + + + @Throws(NdrException::class)override fun `getTotalSessions`(): kotlin.ULong { + return FfiConverterULong.lift( + callWithPointer { + uniffiRustCallWithError(NdrException) { _status -> + UniffiLib.INSTANCE.uniffi_ndr_ffi_fn_method_pairwisemanager_get_total_sessions( + it, _status) +} + } + ) + } + + + + @Throws(NdrException::class)override fun `knownPeerPubkeys`(): List { return FfiConverterSequenceString.lift( callWithPointer { - uniffiRustCall() { _status -> - UniffiLib.INSTANCE.uniffi_ndr_ffi_fn_method_sessionmanagerhandle_known_peer_owner_pubkeys( + uniffiRustCallWithError(NdrException) { _status -> + UniffiLib.INSTANCE.uniffi_ndr_ffi_fn_method_pairwisemanager_known_peer_pubkeys( it, _status) } } @@ -2072,11 +1963,37 @@ open class SessionManagerHandle: Disposable, AutoCloseable, SessionManagerHandle + @Throws(NdrException::class)override fun `pendingActions`(): List { + return FfiConverterSequenceTypePairwiseAction.lift( + callWithPointer { + uniffiRustCallWithError(NdrException) { _status -> + UniffiLib.INSTANCE.uniffi_ndr_ffi_fn_method_pairwisemanager_pending_actions( + it, _status) +} + } + ) + } + + + + @Throws(NdrException::class)override fun `pendingActionsAt`(`nowSeconds`: kotlin.ULong): List { + return FfiConverterSequenceTypePairwiseAction.lift( + callWithPointer { + uniffiRustCallWithError(NdrException) { _status -> + UniffiLib.INSTANCE.uniffi_ndr_ffi_fn_method_pairwisemanager_pending_actions_at( + it, FfiConverterULong.lower(`nowSeconds`),_status) +} + } + ) + } + + + @Throws(NdrException::class)override fun `processEvent`(`eventJson`: kotlin.String) = callWithPointer { uniffiRustCallWithError(NdrException) { _status -> - UniffiLib.INSTANCE.uniffi_ndr_ffi_fn_method_sessionmanagerhandle_process_event( + UniffiLib.INSTANCE.uniffi_ndr_ffi_fn_method_pairwisemanager_process_event( it, FfiConverterString.lower(`eventJson`),_status) } } @@ -2084,24 +2001,24 @@ open class SessionManagerHandle: Disposable, AutoCloseable, SessionManagerHandle - @Throws(NdrException::class)override fun `processOutOfBandResponse`(`eventJson`: kotlin.String, `expectedOwnerPubkeyHex`: kotlin.String) + @Throws(NdrException::class)override fun `processOutOfBandResponse`(`eventJson`: kotlin.String, `authenticatedPeerPubkeyHex`: kotlin.String) = callWithPointer { uniffiRustCallWithError(NdrException) { _status -> - UniffiLib.INSTANCE.uniffi_ndr_ffi_fn_method_sessionmanagerhandle_process_out_of_band_response( - it, FfiConverterString.lower(`eventJson`),FfiConverterString.lower(`expectedOwnerPubkeyHex`),_status) + UniffiLib.INSTANCE.uniffi_ndr_ffi_fn_method_pairwisemanager_process_out_of_band_response( + it, FfiConverterString.lower(`eventJson`),FfiConverterString.lower(`authenticatedPeerPubkeyHex`),_status) } } - @Throws(NdrException::class)override fun `sendText`(`recipientPubkeyHex`: kotlin.String, `text`: kotlin.String, `expiresAtSeconds`: kotlin.ULong?): List { - return FfiConverterSequenceString.lift( + @Throws(NdrException::class)override fun `retirePeer`(`peerPubkeyHex`: kotlin.String): kotlin.Boolean { + return FfiConverterBoolean.lift( callWithPointer { uniffiRustCallWithError(NdrException) { _status -> - UniffiLib.INSTANCE.uniffi_ndr_ffi_fn_method_sessionmanagerhandle_send_text( - it, FfiConverterString.lower(`recipientPubkeyHex`),FfiConverterString.lower(`text`),FfiConverterOptionalULong.lower(`expiresAtSeconds`),_status) + UniffiLib.INSTANCE.uniffi_ndr_ffi_fn_method_pairwisemanager_retire_peer( + it, FfiConverterString.lower(`peerPubkeyHex`),_status) } } ) @@ -2109,12 +2026,12 @@ open class SessionManagerHandle: Disposable, AutoCloseable, SessionManagerHandle - @Throws(NdrException::class)override fun `sendTextWithInnerId`(`recipientPubkeyHex`: kotlin.String, `text`: kotlin.String, `expiresAtSeconds`: kotlin.ULong?): SendTextResult { - return FfiConverterTypeSendTextResult.lift( + @Throws(NdrException::class)override fun `sendText`(`peerPubkeyHex`: kotlin.String, `text`: kotlin.String, `expiresAtSeconds`: kotlin.ULong?): PairwiseSendResult { + return FfiConverterTypePairwiseSendResult.lift( callWithPointer { uniffiRustCallWithError(NdrException) { _status -> - UniffiLib.INSTANCE.uniffi_ndr_ffi_fn_method_sessionmanagerhandle_send_text_with_inner_id( - it, FfiConverterString.lower(`recipientPubkeyHex`),FfiConverterString.lower(`text`),FfiConverterOptionalULong.lower(`expiresAtSeconds`),_status) + UniffiLib.INSTANCE.uniffi_ndr_ffi_fn_method_pairwisemanager_send_text( + it, FfiConverterString.lower(`peerPubkeyHex`),FfiConverterString.lower(`text`),FfiConverterOptionalULong.lower(`expiresAtSeconds`),_status) } } ) @@ -2122,15 +2039,16 @@ open class SessionManagerHandle: Disposable, AutoCloseable, SessionManagerHandle - @Throws(NdrException::class)override fun `setupUser`(`userPubkeyHex`: kotlin.String) - = + @Throws(NdrException::class)override fun `sessionInfo`(`peerPubkeyHex`: kotlin.String): PairwiseSessionInfo? { + return FfiConverterOptionalTypePairwiseSessionInfo.lift( callWithPointer { uniffiRustCallWithError(NdrException) { _status -> - UniffiLib.INSTANCE.uniffi_ndr_ffi_fn_method_sessionmanagerhandle_setup_user( - it, FfiConverterString.lower(`userPubkeyHex`),_status) + UniffiLib.INSTANCE.uniffi_ndr_ffi_fn_method_pairwisemanager_session_info( + it, FfiConverterString.lower(`peerPubkeyHex`),_status) } } - + ) + } @@ -2138,11 +2056,11 @@ open class SessionManagerHandle: Disposable, AutoCloseable, SessionManagerHandle companion object { - @Throws(NdrException::class) fun `newWithStoragePath`(`ourPubkeyHex`: kotlin.String, `ourIdentityPrivkeyHex`: kotlin.String, `deviceId`: kotlin.String, `storagePath`: kotlin.String, `ownerPubkeyHex`: kotlin.String?): SessionManagerHandle { - return FfiConverterTypeSessionManagerHandle.lift( + @Throws(NdrException::class) fun `newWithStoragePath`(`ourPubkeyHex`: kotlin.String, `ourIdentityPrivateKeyHex`: kotlin.String, `storagePath`: kotlin.String): PairwiseManager { + return FfiConverterTypePairwiseManager.lift( uniffiRustCallWithError(NdrException) { _status -> - UniffiLib.INSTANCE.uniffi_ndr_ffi_fn_constructor_sessionmanagerhandle_new_with_storage_path( - FfiConverterString.lower(`ourPubkeyHex`),FfiConverterString.lower(`ourIdentityPrivkeyHex`),FfiConverterString.lower(`deviceId`),FfiConverterString.lower(`storagePath`),FfiConverterOptionalString.lower(`ownerPubkeyHex`),_status) + UniffiLib.INSTANCE.uniffi_ndr_ffi_fn_constructor_pairwisemanager_new_with_storage_path( + FfiConverterString.lower(`ourPubkeyHex`),FfiConverterString.lower(`ourIdentityPrivateKeyHex`),FfiConverterString.lower(`storagePath`),_status) } ) } @@ -2156,25 +2074,25 @@ open class SessionManagerHandle: Disposable, AutoCloseable, SessionManagerHandle /** * @suppress */ -public object FfiConverterTypeSessionManagerHandle: FfiConverter { +public object FfiConverterTypePairwiseManager: FfiConverter { - override fun lower(value: SessionManagerHandle): Pointer { + override fun lower(value: PairwiseManager): Pointer { return value.uniffiClonePointer() } - override fun lift(value: Pointer): SessionManagerHandle { - return SessionManagerHandle(value) + override fun lift(value: Pointer): PairwiseManager { + return PairwiseManager(value) } - override fun read(buf: ByteBuffer): SessionManagerHandle { + override fun read(buf: ByteBuffer): PairwiseManager { // The Rust code always writes pointers as 8 bytes, and will // fail to compile if they don't fit. return lift(Pointer(buf.getLong())) } - override fun allocationSize(value: SessionManagerHandle) = 8UL + override fun allocationSize(value: PairwiseManager) = 8UL - override fun write(value: SessionManagerHandle, buf: ByteBuffer) { + override fun write(value: PairwiseManager, buf: ByteBuffer) { // The Rust code always expects pointers written as 8 bytes, // and will fail to compile if they don't fit. buf.putLong(Pointer.nativeValue(lower(value))) @@ -2215,138 +2133,8 @@ public object FfiConverterTypeFfiKeyPair: FfiConverterRustBuffer { -data class MessagePushSessionStateResult ( - var `stateJson`: kotlin.String, - var `trackedSenderPubkeys`: List, - var `hasReceivingCapability`: kotlin.Boolean -) { - - companion object -} - -/** - * @suppress - */ -public object FfiConverterTypeMessagePushSessionStateResult: FfiConverterRustBuffer { - override fun read(buf: ByteBuffer): MessagePushSessionStateResult { - return MessagePushSessionStateResult( - FfiConverterString.read(buf), - FfiConverterSequenceString.read(buf), - FfiConverterBoolean.read(buf), - ) - } - - override fun allocationSize(value: MessagePushSessionStateResult) = ( - FfiConverterString.allocationSize(value.`stateJson`) + - FfiConverterSequenceString.allocationSize(value.`trackedSenderPubkeys`) + - FfiConverterBoolean.allocationSize(value.`hasReceivingCapability`) - ) - - override fun write(value: MessagePushSessionStateResult, buf: ByteBuffer) { - FfiConverterString.write(value.`stateJson`, buf) - FfiConverterSequenceString.write(value.`trackedSenderPubkeys`, buf) - FfiConverterBoolean.write(value.`hasReceivingCapability`, buf) - } -} - - - -data class PubSubEvent ( - var `kind`: kotlin.String, - var `subid`: kotlin.String?, - var `filterJson`: kotlin.String?, - var `eventJson`: kotlin.String?, - var `senderPubkeyHex`: kotlin.String?, - var `senderDevicePubkeyHex`: kotlin.String?, - var `conversationOwnerPubkeyHex`: kotlin.String?, - var `content`: kotlin.String?, - var `eventId`: kotlin.String? -) { - - companion object -} - -/** - * @suppress - */ -public object FfiConverterTypePubSubEvent: FfiConverterRustBuffer { - override fun read(buf: ByteBuffer): PubSubEvent { - return PubSubEvent( - FfiConverterString.read(buf), - FfiConverterOptionalString.read(buf), - FfiConverterOptionalString.read(buf), - FfiConverterOptionalString.read(buf), - FfiConverterOptionalString.read(buf), - FfiConverterOptionalString.read(buf), - FfiConverterOptionalString.read(buf), - FfiConverterOptionalString.read(buf), - FfiConverterOptionalString.read(buf), - ) - } - - override fun allocationSize(value: PubSubEvent) = ( - FfiConverterString.allocationSize(value.`kind`) + - FfiConverterOptionalString.allocationSize(value.`subid`) + - FfiConverterOptionalString.allocationSize(value.`filterJson`) + - FfiConverterOptionalString.allocationSize(value.`eventJson`) + - FfiConverterOptionalString.allocationSize(value.`senderPubkeyHex`) + - FfiConverterOptionalString.allocationSize(value.`senderDevicePubkeyHex`) + - FfiConverterOptionalString.allocationSize(value.`conversationOwnerPubkeyHex`) + - FfiConverterOptionalString.allocationSize(value.`content`) + - FfiConverterOptionalString.allocationSize(value.`eventId`) - ) - - override fun write(value: PubSubEvent, buf: ByteBuffer) { - FfiConverterString.write(value.`kind`, buf) - FfiConverterOptionalString.write(value.`subid`, buf) - FfiConverterOptionalString.write(value.`filterJson`, buf) - FfiConverterOptionalString.write(value.`eventJson`, buf) - FfiConverterOptionalString.write(value.`senderPubkeyHex`, buf) - FfiConverterOptionalString.write(value.`senderDevicePubkeyHex`, buf) - FfiConverterOptionalString.write(value.`conversationOwnerPubkeyHex`, buf) - FfiConverterOptionalString.write(value.`content`, buf) - FfiConverterOptionalString.write(value.`eventId`, buf) - } -} - - - -data class SendTextResult ( - var `innerId`: kotlin.String, - var `outerEventIds`: List -) { - - companion object -} - -/** - * @suppress - */ -public object FfiConverterTypeSendTextResult: FfiConverterRustBuffer { - override fun read(buf: ByteBuffer): SendTextResult { - return SendTextResult( - FfiConverterString.read(buf), - FfiConverterSequenceString.read(buf), - ) - } - - override fun allocationSize(value: SendTextResult) = ( - FfiConverterString.allocationSize(value.`innerId`) + - FfiConverterSequenceString.allocationSize(value.`outerEventIds`) - ) - - override fun write(value: SendTextResult, buf: ByteBuffer) { - FfiConverterString.write(value.`innerId`, buf) - FfiConverterSequenceString.write(value.`outerEventIds`, buf) - } -} - - - -data class SessionManagerAcceptInviteResult ( - var `ownerPubkeyHex`: kotlin.String, - var `inviterDevicePubkeyHex`: kotlin.String, - var `deviceId`: kotlin.String, +data class PairwiseAcceptResult ( + var `peerPubkeyHex`: kotlin.String, var `createdNewSession`: kotlin.Boolean ) { @@ -2356,33 +2144,163 @@ data class SessionManagerAcceptInviteResult ( /** * @suppress */ -public object FfiConverterTypeSessionManagerAcceptInviteResult: FfiConverterRustBuffer { - override fun read(buf: ByteBuffer): SessionManagerAcceptInviteResult { - return SessionManagerAcceptInviteResult( - FfiConverterString.read(buf), - FfiConverterString.read(buf), +public object FfiConverterTypePairwiseAcceptResult: FfiConverterRustBuffer { + override fun read(buf: ByteBuffer): PairwiseAcceptResult { + return PairwiseAcceptResult( FfiConverterString.read(buf), FfiConverterBoolean.read(buf), ) } - override fun allocationSize(value: SessionManagerAcceptInviteResult) = ( - FfiConverterString.allocationSize(value.`ownerPubkeyHex`) + - FfiConverterString.allocationSize(value.`inviterDevicePubkeyHex`) + - FfiConverterString.allocationSize(value.`deviceId`) + + override fun allocationSize(value: PairwiseAcceptResult) = ( + FfiConverterString.allocationSize(value.`peerPubkeyHex`) + FfiConverterBoolean.allocationSize(value.`createdNewSession`) ) - override fun write(value: SessionManagerAcceptInviteResult, buf: ByteBuffer) { - FfiConverterString.write(value.`ownerPubkeyHex`, buf) - FfiConverterString.write(value.`inviterDevicePubkeyHex`, buf) - FfiConverterString.write(value.`deviceId`, buf) + override fun write(value: PairwiseAcceptResult, buf: ByteBuffer) { + FfiConverterString.write(value.`peerPubkeyHex`, buf) FfiConverterBoolean.write(value.`createdNewSession`, buf) } } +data class PairwiseAction ( + var `actionId`: kotlin.String, + var `kind`: kotlin.String, + var `sessionId`: kotlin.String?, + var `subscriptionId`: kotlin.String?, + var `filterJson`: kotlin.String?, + var `eventJson`: kotlin.String?, + var `peerPubkeyHex`: kotlin.String?, + var `innerEventJson`: kotlin.String?, + var `innerEventId`: kotlin.String?, + var `outerEventId`: kotlin.String?, + var `expiresAtSeconds`: kotlin.ULong? +) { + + companion object +} + +/** + * @suppress + */ +public object FfiConverterTypePairwiseAction: FfiConverterRustBuffer { + override fun read(buf: ByteBuffer): PairwiseAction { + return PairwiseAction( + FfiConverterString.read(buf), + FfiConverterString.read(buf), + FfiConverterOptionalString.read(buf), + FfiConverterOptionalString.read(buf), + FfiConverterOptionalString.read(buf), + FfiConverterOptionalString.read(buf), + FfiConverterOptionalString.read(buf), + FfiConverterOptionalString.read(buf), + FfiConverterOptionalString.read(buf), + FfiConverterOptionalString.read(buf), + FfiConverterOptionalULong.read(buf), + ) + } + + override fun allocationSize(value: PairwiseAction) = ( + FfiConverterString.allocationSize(value.`actionId`) + + FfiConverterString.allocationSize(value.`kind`) + + FfiConverterOptionalString.allocationSize(value.`sessionId`) + + FfiConverterOptionalString.allocationSize(value.`subscriptionId`) + + FfiConverterOptionalString.allocationSize(value.`filterJson`) + + FfiConverterOptionalString.allocationSize(value.`eventJson`) + + FfiConverterOptionalString.allocationSize(value.`peerPubkeyHex`) + + FfiConverterOptionalString.allocationSize(value.`innerEventJson`) + + FfiConverterOptionalString.allocationSize(value.`innerEventId`) + + FfiConverterOptionalString.allocationSize(value.`outerEventId`) + + FfiConverterOptionalULong.allocationSize(value.`expiresAtSeconds`) + ) + + override fun write(value: PairwiseAction, buf: ByteBuffer) { + FfiConverterString.write(value.`actionId`, buf) + FfiConverterString.write(value.`kind`, buf) + FfiConverterOptionalString.write(value.`sessionId`, buf) + FfiConverterOptionalString.write(value.`subscriptionId`, buf) + FfiConverterOptionalString.write(value.`filterJson`, buf) + FfiConverterOptionalString.write(value.`eventJson`, buf) + FfiConverterOptionalString.write(value.`peerPubkeyHex`, buf) + FfiConverterOptionalString.write(value.`innerEventJson`, buf) + FfiConverterOptionalString.write(value.`innerEventId`, buf) + FfiConverterOptionalString.write(value.`outerEventId`, buf) + FfiConverterOptionalULong.write(value.`expiresAtSeconds`, buf) + } +} + + + +data class PairwiseSendResult ( + var `innerEventId`: kotlin.String, + var `outerEventId`: kotlin.String +) { + + companion object +} + +/** + * @suppress + */ +public object FfiConverterTypePairwiseSendResult: FfiConverterRustBuffer { + override fun read(buf: ByteBuffer): PairwiseSendResult { + return PairwiseSendResult( + FfiConverterString.read(buf), + FfiConverterString.read(buf), + ) + } + + override fun allocationSize(value: PairwiseSendResult) = ( + FfiConverterString.allocationSize(value.`innerEventId`) + + FfiConverterString.allocationSize(value.`outerEventId`) + ) + + override fun write(value: PairwiseSendResult, buf: ByteBuffer) { + FfiConverterString.write(value.`innerEventId`, buf) + FfiConverterString.write(value.`outerEventId`, buf) + } +} + + + +data class PairwiseSessionInfo ( + var `sendReady`: kotlin.Boolean, + var `receiveReady`: kotlin.Boolean, + var `trackedSenderPubkeys`: List +) { + + companion object +} + +/** + * @suppress + */ +public object FfiConverterTypePairwiseSessionInfo: FfiConverterRustBuffer { + override fun read(buf: ByteBuffer): PairwiseSessionInfo { + return PairwiseSessionInfo( + FfiConverterBoolean.read(buf), + FfiConverterBoolean.read(buf), + FfiConverterSequenceString.read(buf), + ) + } + + override fun allocationSize(value: PairwiseSessionInfo) = ( + FfiConverterBoolean.allocationSize(value.`sendReady`) + + FfiConverterBoolean.allocationSize(value.`receiveReady`) + + FfiConverterSequenceString.allocationSize(value.`trackedSenderPubkeys`) + ) + + override fun write(value: PairwiseSessionInfo, buf: ByteBuffer) { + FfiConverterBoolean.write(value.`sendReady`, buf) + FfiConverterBoolean.write(value.`receiveReady`, buf) + FfiConverterSequenceString.write(value.`trackedSenderPubkeys`, buf) + } +} + + + sealed class NdrException: kotlin.Exception() { @@ -2403,31 +2321,7 @@ sealed class NdrException: kotlin.Exception() { get() = "v1=${ v1 }" } - class CryptoFailure( - - val v1: kotlin.String - ) : NdrException() { - override val message - get() = "v1=${ v1 }" - } - - class StateMismatch( - - val v1: kotlin.String - ) : NdrException() { - override val message - get() = "v1=${ v1 }" - } - - class Serialization( - - val v1: kotlin.String - ) : NdrException() { - override val message - get() = "v1=${ v1 }" - } - - class InviteException( + class PeerMismatch( val v1: kotlin.String ) : NdrException() { @@ -2443,6 +2337,30 @@ sealed class NdrException: kotlin.Exception() { get() = "v1=${ v1 }" } + class QueueFull( + + val v1: kotlin.String + ) : NdrException() { + override val message + get() = "v1=${ v1 }" + } + + class Storage( + + val v1: kotlin.String + ) : NdrException() { + override val message + get() = "v1=${ v1 }" + } + + class CryptoFailure( + + val v1: kotlin.String + ) : NdrException() { + override val message + get() = "v1=${ v1 }" + } + companion object ErrorHandler : UniffiRustCallStatusErrorHandler { override fun lift(error_buf: RustBuffer.ByValue): NdrException = FfiConverterTypeNdrError.lift(error_buf) @@ -2465,19 +2383,19 @@ public object FfiConverterTypeNdrError : FfiConverterRustBuffer { 2 -> NdrException.InvalidEvent( FfiConverterString.read(buf), ) - 3 -> NdrException.CryptoFailure( + 3 -> NdrException.PeerMismatch( FfiConverterString.read(buf), ) - 4 -> NdrException.StateMismatch( + 4 -> NdrException.SessionNotReady( FfiConverterString.read(buf), ) - 5 -> NdrException.Serialization( + 5 -> NdrException.QueueFull( FfiConverterString.read(buf), ) - 6 -> NdrException.InviteException( + 6 -> NdrException.Storage( FfiConverterString.read(buf), ) - 7 -> NdrException.SessionNotReady( + 7 -> NdrException.CryptoFailure( FfiConverterString.read(buf), ) else -> throw RuntimeException("invalid error enum value, something is very wrong!!") @@ -2496,22 +2414,7 @@ public object FfiConverterTypeNdrError : FfiConverterRustBuffer { 4UL + FfiConverterString.allocationSize(value.v1) ) - is NdrException.CryptoFailure -> ( - // Add the size for the Int that specifies the variant plus the size needed for all fields - 4UL - + FfiConverterString.allocationSize(value.v1) - ) - is NdrException.StateMismatch -> ( - // Add the size for the Int that specifies the variant plus the size needed for all fields - 4UL - + FfiConverterString.allocationSize(value.v1) - ) - is NdrException.Serialization -> ( - // Add the size for the Int that specifies the variant plus the size needed for all fields - 4UL - + FfiConverterString.allocationSize(value.v1) - ) - is NdrException.InviteException -> ( + is NdrException.PeerMismatch -> ( // Add the size for the Int that specifies the variant plus the size needed for all fields 4UL + FfiConverterString.allocationSize(value.v1) @@ -2521,6 +2424,21 @@ public object FfiConverterTypeNdrError : FfiConverterRustBuffer { 4UL + FfiConverterString.allocationSize(value.v1) ) + is NdrException.QueueFull -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + + FfiConverterString.allocationSize(value.v1) + ) + is NdrException.Storage -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + + FfiConverterString.allocationSize(value.v1) + ) + is NdrException.CryptoFailure -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + + FfiConverterString.allocationSize(value.v1) + ) } } @@ -2536,27 +2454,27 @@ public object FfiConverterTypeNdrError : FfiConverterRustBuffer { FfiConverterString.write(value.v1, buf) Unit } - is NdrException.CryptoFailure -> { + is NdrException.PeerMismatch -> { buf.putInt(3) FfiConverterString.write(value.v1, buf) Unit } - is NdrException.StateMismatch -> { + is NdrException.SessionNotReady -> { buf.putInt(4) FfiConverterString.write(value.v1, buf) Unit } - is NdrException.Serialization -> { + is NdrException.QueueFull -> { buf.putInt(5) FfiConverterString.write(value.v1, buf) Unit } - is NdrException.InviteException -> { + is NdrException.Storage -> { buf.putInt(6) FfiConverterString.write(value.v1, buf) Unit } - is NdrException.SessionNotReady -> { + is NdrException.CryptoFailure -> { buf.putInt(7) FfiConverterString.write(value.v1, buf) Unit @@ -2633,6 +2551,38 @@ public object FfiConverterOptionalString: FfiConverterRustBuffer +/** + * @suppress + */ +public object FfiConverterOptionalTypePairwiseSessionInfo: FfiConverterRustBuffer { + override fun read(buf: ByteBuffer): PairwiseSessionInfo? { + if (buf.get().toInt() == 0) { + return null + } + return FfiConverterTypePairwiseSessionInfo.read(buf) + } + + override fun allocationSize(value: PairwiseSessionInfo?): ULong { + if (value == null) { + return 1UL + } else { + return 1UL + FfiConverterTypePairwiseSessionInfo.allocationSize(value) + } + } + + override fun write(value: PairwiseSessionInfo?, buf: ByteBuffer) { + if (value == null) { + buf.put(0) + } else { + buf.put(1) + FfiConverterTypePairwiseSessionInfo.write(value, buf) + } + } +} + + + + /** * @suppress */ @@ -2664,60 +2614,32 @@ public object FfiConverterSequenceString: FfiConverterRustBuffer> { - override fun read(buf: ByteBuffer): List { +public object FfiConverterSequenceTypePairwiseAction: FfiConverterRustBuffer> { + override fun read(buf: ByteBuffer): List { val len = buf.getInt() - return List(len) { - FfiConverterTypeMessagePushSessionStateResult.read(buf) + return List(len) { + FfiConverterTypePairwiseAction.read(buf) } } - override fun allocationSize(value: List): ULong { + override fun allocationSize(value: List): ULong { val sizeForLength = 4UL - val sizeForItems = value.map { FfiConverterTypeMessagePushSessionStateResult.allocationSize(it) }.sum() + val sizeForItems = value.map { FfiConverterTypePairwiseAction.allocationSize(it) }.sum() return sizeForLength + sizeForItems } - override fun write(value: List, buf: ByteBuffer) { + override fun write(value: List, buf: ByteBuffer) { buf.putInt(value.size) value.iterator().forEach { - FfiConverterTypeMessagePushSessionStateResult.write(it, buf) + FfiConverterTypePairwiseAction.write(it, buf) } } } - - - - -/** - * @suppress - */ -public object FfiConverterSequenceTypePubSubEvent: FfiConverterRustBuffer> { - override fun read(buf: ByteBuffer): List { - val len = buf.getInt() - return List(len) { - FfiConverterTypePubSubEvent.read(buf) - } - } - - override fun allocationSize(value: List): ULong { - val sizeForLength = 4UL - val sizeForItems = value.map { FfiConverterTypePubSubEvent.allocationSize(it) }.sum() - return sizeForLength + sizeForItems - } - - override fun write(value: List, buf: ByteBuffer) { - buf.putInt(value.size) - value.iterator().forEach { - FfiConverterTypePubSubEvent.write(it, buf) - } - } -} - @Throws(NdrException::class) fun `derivePublicKey`(`privkeyHex`: kotlin.String): kotlin.String { + @Throws(NdrException::class) fun `derivePublicKey`(`privateKeyHex`: kotlin.String): kotlin.String { return FfiConverterString.lift( uniffiRustCallWithError(NdrException) { _status -> UniffiLib.INSTANCE.uniffi_ndr_ffi_fn_func_derive_public_key( - FfiConverterString.lower(`privkeyHex`),_status) + FfiConverterString.lower(`privateKeyHex`),_status) } ) } diff --git a/app/src/main/ndr-ffi/SOURCE_REVISION b/app/src/main/ndr-ffi/SOURCE_REVISION index e72a077d..9c18dcab 100644 --- a/app/src/main/ndr-ffi/SOURCE_REVISION +++ b/app/src/main/ndr-ffi/SOURCE_REVISION @@ -1 +1 @@ -095e70489345df4d92dded686902f3dccb54cc45 +0fe8caf2d4e24e2030ffae195597a2764613a659 diff --git a/app/src/main/ndr-ffi/VENDORED_FROM.md b/app/src/main/ndr-ffi/VENDORED_FROM.md index 249e0b5a..e7315d6c 100644 --- a/app/src/main/ndr-ffi/VENDORED_FROM.md +++ b/app/src/main/ndr-ffi/VENDORED_FROM.md @@ -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. diff --git a/app/src/main/ndr-ffi/build-android.sh b/app/src/main/ndr-ffi/build-android.sh index 6d3c2af4..45e9e02f 100755 --- a/app/src/main/ndr-ffi/build-android.sh +++ b/app/src/main/ndr-ffi/build-android.sh @@ -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}" diff --git a/app/src/test/kotlin/com/bitchat/android/favorites/FavoritesNdrRebindTest.kt b/app/src/test/kotlin/com/bitchat/android/favorites/FavoritesNdrRebindTest.kt new file mode 100644 index 00000000..78b41f32 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/favorites/FavoritesNdrRebindTest.kt @@ -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() + 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() + 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 + favorites[ContactIdentityResolver.noiseKeyHex(noiseKey)] = FavoriteRelationship( + peerNoisePublicKey = noiseKey, + peerNostrPublicKey = + requireNotNull(ContactIdentityResolver.npubFromHex(peerPubkeyHex)), + peerNickname = "legacy", + isFavorite = true, + theyFavoritedUs = true, + favoritedAt = Date(1), + lastUpdated = Date(1) + ) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/mesh/FragmentingPacketSenderTest.kt b/app/src/test/kotlin/com/bitchat/android/mesh/FragmentingPacketSenderTest.kt new file mode 100644 index 00000000..ca2284f6 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/mesh/FragmentingPacketSenderTest.kt @@ -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() + 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() + 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 + ) +} diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/NdrApplicationMessageDecoderTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/NdrApplicationMessageDecoderTest.kt index e77f0e6e..8dcd9bdd 100644 --- a/app/src/test/kotlin/com/bitchat/android/nostr/NdrApplicationMessageDecoderTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/nostr/NdrApplicationMessageDecoderTest.kt @@ -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> = 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()) diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/NdrInviteRetryCoordinatorTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/NdrInviteRetryCoordinatorTest.kt new file mode 100644 index 00000000..90782cdb --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/nostr/NdrInviteRetryCoordinatorTest.kt @@ -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() + val admitted = mutableListOf() + 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() + 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() + 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)}"}""" + ) +} diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/NdrNostrServiceTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/NdrNostrServiceTest.kt index db64dd72..6d4138a3 100644 --- a/app/src/test/kotlin/com/bitchat/android/nostr/NdrNostrServiceTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/nostr/NdrNostrServiceTest.kt @@ -13,6 +13,9 @@ import java.util.concurrent.TimeUnit import kotlin.concurrent.thread class NdrNostrServiceTest { + private val localPubkey = "22".repeat(32) + private val peerPubkey = "cc".repeat(32) + @Before fun enableNdrForTest() { NdrFeatureGate.setEnabledForTests(true) @@ -26,276 +29,472 @@ class NdrNostrServiceTest { @Test fun disabledRolloutGateRefusesToCreateRuntime() { NdrFeatureGate.setEnabledForTests(false) - val runtime = FakeNdrSessionManager() - val runtimeFactory = FakeNdrRuntimeFactory(runtime) - val service = NdrNostrService( - relayManager = FakeRelayManager(), - runtimeFactory = runtimeFactory, - storageDirectoryProvider = { "/tmp/ndr-test" }, - deviceIdProvider = { "device-1" } - ) + val runtime = FakeNdrPairwiseRuntime() + val factory = FakeNdrRuntimeFactory(runtime) + val service = service(runtimeFactory = factory) service.configureIfNeeded(testIdentity()) assertFalse(service.isConfigured) - assertEquals(0, runtimeFactory.createdCount) - assertFalse(service.sendIfPossible("hello", "aa".repeat(32))) + assertEquals(0, factory.createdCount) + assertEquals(NdrSendResult.NO_SESSION, service.sendIfPossible("hello", peerPubkey)) } @Test - fun configureCachesInviteAndSkipsOobSubscriptions() { - val relayManager = FakeRelayManager() - val runtime = FakeNdrSessionManager().apply { - drainedEvents += NdrPubSubEvent( - kind = "publish_signed", - eventJson = """ - {"id":"invite1","pubkey":"sender","created_at":1,"kind":30078,"tags":[["l","double-ratchet/invites"]],"content":"invite","sig":"sig"} - """.trimIndent() - ) - drainedEvents += NdrPubSubEvent( - kind = "subscribe", - subid = "giftwrap-oob", - filterJson = """{"kinds":[1059],"#p":["peer"]}""" - ) - drainedEvents += NdrPubSubEvent( - kind = "subscribe", - subid = "unlabeled-invite-discovery", - filterJson = """{"authors":["peer"],"kinds":[30078]}""" - ) - drainedEvents += NdrPubSubEvent( + fun configureCachesPairwiseInviteAndInstallsOnlyKind1060Subscription() { + val relay = FakeRelayManager() + val runtime = FakeNdrPairwiseRuntime().apply { + currentInvite = inviteEvent(peerPubkey) + pendingEvents += NdrPubSubEvent( kind = "subscribe", + actionId = "message-sub", subid = "messages", - filterJson = """{"authors":["peer"],"kinds":[1060]}""" + filterJson = """{"authors":["$peerPubkey"],"kinds":[1060]}""" + ) + pendingEvents += NdrPubSubEvent( + kind = "subscribe", + actionId = "appkeys-sub", + subid = "appkeys", + filterJson = """{"authors":["$peerPubkey"],"kinds":[37368]}""" + ) + pendingEvents += NdrPubSubEvent( + kind = "subscribe", + actionId = "invite-sub", + subid = "invites", + filterJson = """{"authors":["$peerPubkey"],"kinds":[30078]}""" + ) + pendingEvents += NdrPubSubEvent( + kind = "subscribe", + actionId = "recipient-sub", + subid = "recipient", + filterJson = """{"kinds":[1060],"#p":["$localPubkey"]}""" ) } - val runtimeFactory = FakeNdrRuntimeFactory(runtime) - val service = NdrNostrService( - relayManager = relayManager, - runtimeFactory = runtimeFactory, - storageDirectoryProvider = { "/tmp/ndr-test" }, - deviceIdProvider = { "device-1" } - ) - - service.configureIfNeeded( - NostrIdentity( - privateKeyHex = "11".repeat(32), - publicKeyHex = "22".repeat(32), - npub = "npub-test", - createdAt = 1L - ) - ) - - assertEquals("invite1", NostrEvent.fromJsonString(service.currentInviteEventJson()!!)?.id) - assertEquals(listOf("messages"), relayManager.subscriptions.map { it.id }) - assertEquals("/tmp/ndr-test/${"22".repeat(32)}", runtimeFactory.lastStoragePath) - } - - @Test - fun configureRestoresKnownPeerAppKeysFeedAndForwardsLaterRoster() { - val peer = "ab".repeat(32) - val relayManager = FakeRelayManager() - val runtime = FakeNdrSessionManager().apply { - knownPeerOwners += peer - setupUserEvents[peer] = listOf( - NdrPubSubEvent( - kind = "subscribe", - subid = "restored-app-keys", - filterJson = """{"authors":["$peer"],"kinds":[37368]}""" - ), - NdrPubSubEvent( - kind = "subscribe", - subid = "restored-invite-discovery", - filterJson = """{"authors":["$peer"],"kinds":[30078]}""" - ) - ) - } - val service = NdrNostrService( - relayManager = relayManager, - runtimeFactory = FakeNdrRuntimeFactory(runtime), - storageDirectoryProvider = { "/tmp/ndr-test" }, - deviceIdProvider = { "device-1" } - ) + val factory = FakeNdrRuntimeFactory(runtime) + val service = service(relay, factory) service.configureIfNeeded(testIdentity()) - assertEquals(listOf(peer), runtime.setupUserCalls) - val rosterSubscription = relayManager.subscriptions.single() - assertEquals(listOf(37368), rosterSubscription.filter.kinds) - assertEquals(listOf(peer), rosterSubscription.filter.authors) + assertEquals( + inviteEvent(peerPubkey), + service.currentInviteEventJson() + ) + assertEquals(listOf("messages"), relay.subscriptions.map { it.id }) + assertEquals( + setOf("message-sub", "appkeys-sub", "invite-sub", "recipient-sub"), + runtime.ackedActionIds.toSet() + ) + assertEquals( + "/tmp/ndr-test/pairwise-v1/$localPubkey", + factory.lastStoragePath + ) + } - relayManager.emit( - rosterSubscription.id, + @Test + fun appKeysRelayTrafficIsRejectedWithoutEnteringRuntimeOrRelay() { + val relay = FakeRelayManager() + val runtime = FakeNdrPairwiseRuntime().apply { + pendingEvents += NdrPubSubEvent( + kind = "publish", + actionId = "appkeys-publish", + sessionId = "appkeys-session", + eventJson = appKeysEvent(peerPubkey) + ) + } + val service = service(relay, FakeNdrRuntimeFactory(runtime)) + service.configureIfNeeded(testIdentity()) + + service.processInboundRelayEvent( NostrEvent( id = "01".repeat(32), - pubkey = peer, - createdAt = 2, + pubkey = peerPubkey, + createdAt = 1, kind = 37368, tags = listOf(listOf("type", "app_keys_roster_snapshot")), - content = "later-signed-roster", + content = "roster", sig = "sig" ) ) - assertEquals(1, runtime.processedEvents.size) - assertEquals( - "01".repeat(32), - NostrEvent.fromJsonString(runtime.processedEvents.single())?.id - ) + assertTrue(runtime.processedEvents.isEmpty()) + assertTrue(relay.sentEvents.isEmpty()) + assertTrue("appkeys-publish" in runtime.ackedActionIds) } @Test - fun configureBuffersDecryptedMessagesUntilCallbackIsInstalled() { - val runtime = FakeNdrSessionManager().apply { - drainedEvents += NdrPubSubEvent( - kind = "decrypted_message", - senderPubkeyHex = "ab".repeat(32), - content = "bitchat1:pending", - eventId = "01".repeat(32) + fun relayPublishIsAcknowledgedOnlyAfterHostQueueAcceptsIt() { + val event = messageEvent(peerPubkey) + val runtime = FakeNdrPairwiseRuntime().apply { + pendingEvents += NdrPubSubEvent( + kind = "publish", + actionId = "relay-publish", + sessionId = "relay-session", + eventJson = event.toJsonString() ) } - val service = NdrNostrService( - relayManager = FakeRelayManager(), - runtimeFactory = FakeNdrRuntimeFactory(runtime), - storageDirectoryProvider = { "/tmp/ndr-test" }, - deviceIdProvider = { "device-1" } + val failingRelay = FakeRelayManager(failSend = true) + val scheduler = FakeRetryScheduler() + val service = service( + failingRelay, + FakeNdrRuntimeFactory(runtime), + retryScheduler = scheduler ) service.configureIfNeeded(testIdentity()) - var delivered: NdrDecryptedMessage? = null - service.onDecryptedMessage = { delivered = it } + assertFalse("relay-publish" in runtime.ackedActionIds) + assertEquals(1_000L, scheduler.scheduled.single().delayMs) - assertEquals("bitchat1:pending", delivered?.content) - assertEquals("ab".repeat(32), delivered?.conversationPubkeyHex) + failingRelay.failSend = false + scheduler.runNext() + + assertTrue("relay-publish" in runtime.ackedActionIds) + assertEquals(listOf(event.id), failingRelay.sentEvents.map { it.id }) } @Test - fun decryptedMessageBufferIsBoundedAndDropsOldest() { - val runtime = FakeNdrSessionManager().apply { + fun recipientTaggedRelayPublishIsRejectedWithoutLeavingHost() { + val runtime = FakeNdrPairwiseRuntime().apply { + pendingEvents += NdrPubSubEvent( + kind = "publish", + actionId = "recipient-publish", + sessionId = "recipient-session", + eventJson = messageEvent(peerPubkey).copy( + tags = listOf(listOf("p", localPubkey)) + ).toJsonString() + ) + } + val relay = FakeRelayManager() + val service = service(relay, FakeNdrRuntimeFactory(runtime)) + + service.configureIfNeeded(testIdentity()) + + assertTrue(relay.sentEvents.isEmpty()) + assertEquals(listOf("recipient-publish"), runtime.ackedActionIds) + } + + @Test + fun outOfBandAdmissionBlocksOnlyTheMatchingSessionPublish() { + val blockedEvent = messageEvent(peerPubkey).copy(id = "31".repeat(32)) + val unrelatedEvent = messageEvent(peerPubkey).copy(id = "32".repeat(32)) + val runtime = FakeNdrPairwiseRuntime().apply { + pendingEvents += NdrPubSubEvent( + kind = "publish", + actionId = "blocked-publish", + sessionId = "session-a", + eventJson = blockedEvent.toJsonString() + ) + pendingEvents += NdrPubSubEvent( + kind = "out_of_band", + actionId = "oob-a", + sessionId = "session-a", + eventJson = giftWrapEvent(), + peerPubkeyHex = peerPubkey + ) + pendingEvents += NdrPubSubEvent( + kind = "publish", + actionId = "unrelated-publish", + sessionId = "session-b", + eventJson = unrelatedEvent.toJsonString() + ) + } + var oobCompletion: ((Boolean) -> Unit)? = null + val relay = FakeRelayManager() + val service = service(relay, FakeNdrRuntimeFactory(runtime)) + service.onOutOfBandPayload = { _, completion -> oobCompletion = completion } + + service.configureIfNeeded(testIdentity()) + + assertEquals(listOf(unrelatedEvent.id), relay.sentEvents.map(NostrEvent::id)) + assertFalse("blocked-publish" in runtime.ackedActionIds) + assertTrue("unrelated-publish" in runtime.ackedActionIds) + + oobCompletion?.invoke(true) + + assertEquals( + listOf(unrelatedEvent.id, blockedEvent.id), + relay.sentEvents.map(NostrEvent::id) + ) + assertTrue("oob-a" in runtime.ackedActionIds) + assertTrue("blocked-publish" in runtime.ackedActionIds) + } + + @Test + fun pendingOrRejectedRelayConfirmationNeverAcknowledgesOrDuplicatesInFlightSend() { + val event = messageEvent(peerPubkey) + val runtime = FakeNdrPairwiseRuntime().apply { + pendingEvents += NdrPubSubEvent( + kind = "publish", + actionId = "relay-publish", + sessionId = "relay-session", + eventJson = event.toJsonString() + ) + } + val relay = FakeRelayManager(confirmationResult = null) + val scheduler = FakeRetryScheduler() + val service = service( + relay, + FakeNdrRuntimeFactory(runtime), + retryScheduler = scheduler + ) + + service.configureIfNeeded(testIdentity()) + service.processInboundRelayEvent(messageEvent(peerPubkey)) + + assertEquals(1, relay.sentEvents.size) + assertFalse("relay-publish" in runtime.ackedActionIds) + + relay.confirmations.removeFirst().completion(false) + service.processInboundRelayEvent(messageEvent(peerPubkey)) + + assertEquals(1, relay.sentEvents.size) + assertEquals(1_000L, scheduler.scheduled.last().delayMs) + scheduler.runNext() + + assertEquals(2, relay.sentEvents.size) + assertFalse("relay-publish" in runtime.ackedActionIds) + + relay.confirmations.removeFirst().completion(false) + assertEquals(2_000L, scheduler.scheduled.last().delayMs) + relay.reconnect() + assertEquals(3, relay.sentEvents.size) + + relay.confirmations.removeFirst().completion(true) + assertTrue("relay-publish" in runtime.ackedActionIds) + } + + @Test + fun teardownCancelsScheduledPublishRetry() { + val runtime = FakeNdrPairwiseRuntime().apply { + pendingEvents += relayPublishAction("relay-publish", peerPubkey) + } + val relay = FakeRelayManager(failSend = true) + val scheduler = FakeRetryScheduler() + val service = service( + relayManager = relay, + runtimeFactory = FakeNdrRuntimeFactory(runtime), + storageResetter = {}, + retryScheduler = scheduler + ) + service.configureIfNeeded(testIdentity()) + val scheduled = scheduler.scheduled.single() + + assertTrue(service.resetForPanic()) + relay.failSend = false + scheduler.runNext() + + assertTrue(scheduled.canceled) + assertTrue(relay.sentEvents.isEmpty()) + } + + @Test + fun bufferedDeliveryIsAcknowledgedOnlyAfterConsumerCompletion() { + val runtime = FakeNdrPairwiseRuntime().apply { + pendingEvents += decryptedAction("delivery-1", peerPubkey, "bitchat1:pending") + } + val service = service(runtimeFactory = FakeNdrRuntimeFactory(runtime)) + service.configureIfNeeded(testIdentity()) + + var delivered: NdrDecryptedMessage? = null + var completion: ((NdrDeliveryResult) -> Unit)? = null + service.onDecryptedMessage = { message, callback -> + delivered = message + completion = callback + } + + assertEquals("delivery-1", delivered?.actionId) + assertFalse("delivery-1" in runtime.ackedActionIds) + + completion?.invoke(NdrDeliveryResult.CONSUMED) + + assertTrue("delivery-1" in runtime.ackedActionIds) + } + + @Test + fun retryResultLeavesDeliveryPendingAndAllowsRedelivery() { + val runtime = FakeNdrPairwiseRuntime().apply { + pendingEvents += decryptedAction("delivery-retry", peerPubkey, "bitchat1:pending") + } + val service = service(runtimeFactory = FakeNdrRuntimeFactory(runtime)) + var deliveryCount = 0 + service.onDecryptedMessage = { _, completion -> + deliveryCount += 1 + completion(NdrDeliveryResult.RETRY) + } + service.configureIfNeeded(testIdentity()) + + assertEquals(1, deliveryCount) + assertFalse("delivery-retry" in runtime.ackedActionIds) + + service.processInboundRelayEvent(messageEvent(peerPubkey)) + + assertEquals(2, deliveryCount) + assertFalse("delivery-retry" in runtime.ackedActionIds) + } + + @Test + fun definitiveRejectedDeliveryIsAcknowledged() { + val runtime = FakeNdrPairwiseRuntime().apply { + pendingEvents += decryptedAction("delivery-rejected", peerPubkey, "invalid") + } + val service = service(runtimeFactory = FakeNdrRuntimeFactory(runtime)) + service.onDecryptedMessage = { _, completion -> + completion(NdrDeliveryResult.REJECTED) + } + + service.configureIfNeeded(testIdentity()) + + assertTrue("delivery-rejected" in runtime.ackedActionIds) + } + + @Test + fun missingConsumerLeavesEveryDeliveryDurableWithoutEviction() { + val runtime = FakeNdrPairwiseRuntime().apply { repeat(129) { index -> - drainedEvents += NdrPubSubEvent( - kind = "decrypted_message", - senderPubkeyHex = "ab".repeat(32), - content = "bitchat1:pending-$index", - eventId = index.toString(16).padStart(64, '0') + pendingEvents += decryptedAction( + actionId = "delivery-$index", + sender = peerPubkey, + content = "bitchat1:pending-$index" ) } } - val service = NdrNostrService( - relayManager = FakeRelayManager(), - runtimeFactory = FakeNdrRuntimeFactory(runtime), - storageDirectoryProvider = { "/tmp/ndr-test" }, - deviceIdProvider = { "device-1" } - ) - + val service = service(runtimeFactory = FakeNdrRuntimeFactory(runtime)) service.configureIfNeeded(testIdentity()) + assertTrue(runtime.ackedActionIds.isEmpty()) + assertEquals(129, runtime.pendingEvents.size) + val delivered = mutableListOf() - service.onDecryptedMessage = { delivered += it.content } + service.onDecryptedMessage = { message, completion -> + delivered += message.content + completion(NdrDeliveryResult.CONSUMED) + } - assertEquals(128, delivered.size) - assertEquals("bitchat1:pending-1", delivered.first()) + assertEquals(129, delivered.size) + assertEquals("bitchat1:pending-0", delivered.first()) assertEquals("bitchat1:pending-128", delivered.last()) + assertEquals(129, runtime.ackedActionIds.distinct().size) } @Test - fun processOutOfBandInviteReturnsGiftWrapResponseWithoutPublishingIt() { - val relayManager = FakeRelayManager() - val runtime = FakeNdrSessionManager().apply { - acceptInviteEvents += NdrPubSubEvent( - kind = "publish_signed", - eventJson = """ - {"id":"response1","pubkey":"sender","created_at":1,"kind":1059,"tags":[["p","peer"]],"content":"wrapped","sig":"sig"} - """.trimIndent() - ) + fun outOfBandInviteReturnsResponseWithoutRelayPublishOrEarlyAck() { + val response = giftWrapEvent() + val runtime = FakeNdrPairwiseRuntime(mutableSetOf(peerPubkey)).apply { + acceptInviteEvents += outOfBandAction("response-action", response) + acceptInviteResult = NdrAcceptInviteResult(peerPubkey, createdNewSession = true) } - val service = NdrNostrService( - relayManager = relayManager, + val relay = FakeRelayManager() + val service = service( + relayManager = relay, runtimeFactory = FakeNdrRuntimeFactory(runtime), - storageDirectoryProvider = { "/tmp/ndr-test" }, - deviceIdProvider = { "device-1" }, - inviteOwnerResolver = ::eventPubkey - ) - service.configureIfNeeded( - NostrIdentity( - privateKeyHex = "11".repeat(32), - publicKeyHex = "22".repeat(32), - npub = "npub-test", - createdAt = 1L - ) - ) - - val outbound = service.processOutOfBandEventJson( - """ - {"id":"invite1","pubkey":"${"cc".repeat(32)}","created_at":1,"kind":30078,"tags":[["l","double-ratchet/invites"]],"content":"invite","sig":"sig"} - """.trimIndent(), - expectedPeerPubkeyHex = "cc".repeat(32) - ) - - assertEquals(1, outbound.outboundPayloads.size) - assertEquals("response1", NostrEvent.fromJsonString(outbound.outboundPayloads.single())?.id) - assertTrue(relayManager.sentEvents.isEmpty()) - } - - @Test - fun successfulOwnerBootstrapPromotesDurableAppKeysFeed() { - val owner = "cc".repeat(32) - val relayManager = FakeRelayManager() - val runtime = FakeNdrSessionManager(mutableSetOf(owner)).apply { - acceptInviteEventResult = NdrAcceptInviteResult( - ownerPubkeyHex = owner, - inviterDevicePubkeyHex = "aa".repeat(32), - deviceId = "owner-device", - createdNewSession = true - ) - setupUserEvents[owner] = listOf( - NdrPubSubEvent( - kind = "subscribe", - subid = "durable-owner-app-keys", - filterJson = """{"authors":["$owner"],"kinds":[37368]}""" - ), - NdrPubSubEvent( - kind = "subscribe", - subid = "owner-invite-discovery", - filterJson = """{"authors":["$owner"],"kinds":[30078]}""" - ) - ) - } - val service = NdrNostrService( - relayManager = relayManager, - runtimeFactory = FakeNdrRuntimeFactory(runtime), - storageDirectoryProvider = { "/tmp/ndr-test" }, - deviceIdProvider = { "device-1" }, - inviteOwnerResolver = { owner } - ) - service.configureIfNeeded(testIdentity()) - - service.processOutOfBandEventJson( - """ - {"id":"invite1","pubkey":"${"aa".repeat(32)}","created_at":1,"kind":30078,"tags":[["l","double-ratchet/invites"]],"content":"invite","sig":"sig"} - """.trimIndent(), - expectedPeerPubkeyHex = owner - ) - - assertEquals(listOf(owner), runtime.setupUserCalls) - assertEquals(listOf(37368), relayManager.subscriptions.single().filter.kinds) - assertTrue(relayManager.subscriptions.none { 30078 in it.filter.kinds.orEmpty() }) - } - - @Test - fun outOfBandProcessingRequiresAuthenticatedOwner() { - val runtime = FakeNdrSessionManager() - val service = NdrNostrService( - relayManager = FakeRelayManager(), - runtimeFactory = FakeNdrRuntimeFactory(runtime), - storageDirectoryProvider = { "/tmp/ndr-test" }, - deviceIdProvider = { "device-1" }, - inviteOwnerResolver = { "cc".repeat(32) } + invitePeerResolver = { peerPubkey } ) service.configureIfNeeded(testIdentity()) val result = service.processOutOfBandEventJson( - """ - {"id":"invite1","pubkey":"${"cc".repeat(32)}","created_at":1,"kind":30078,"tags":[["l","double-ratchet/invites"]],"content":"invite","sig":"sig"} - """.trimIndent() + inviteEvent(peerPubkey), + expectedPeerPubkeyHex = peerPubkey + ) + + assertEquals(peerPubkey, result.sessionLookupPubkeyHex) + assertEquals(listOf("response-action"), result.outboundPayloads.map { it.actionId }) + assertEquals(response, result.outboundPayloads.single().eventJson) + assertEquals(peerPubkey, result.outboundPayloads.single().peerPubkeyHex) + assertTrue(relay.sentEvents.isEmpty()) + assertFalse("response-action" in runtime.ackedActionIds) + + assertTrue(service.acknowledgeOutOfBandPayload(result.outboundPayloads.single())) + assertTrue("response-action" in runtime.ackedActionIds) + } + + @Test + fun pendingOutOfBandActionReplaysOnConfigureAndAcksOnlyAfterAsyncAdmission() { + val response = giftWrapEvent() + val runtime = FakeNdrPairwiseRuntime().apply { + pendingEvents += outOfBandAction("pending-oob", response) + } + val service = service(runtimeFactory = FakeNdrRuntimeFactory(runtime)) + var delivered: NdrOutOfBandPayload? = null + var completion: ((Boolean) -> Unit)? = null + service.onOutOfBandPayload = { payload, callback -> + delivered = payload + completion = callback + } + + service.configureIfNeeded(testIdentity()) + + assertEquals("pending-oob", delivered?.actionId) + assertEquals(peerPubkey, delivered?.peerPubkeyHex) + assertFalse("pending-oob" in runtime.ackedActionIds) + + completion?.invoke(true) + + assertTrue("pending-oob" in runtime.ackedActionIds) + } + + @Test + fun failedOutOfBandAdmissionRemainsDurableAndReplaysOnReconnect() { + val relay = FakeRelayManager() + val runtime = FakeNdrPairwiseRuntime().apply { + pendingEvents += outOfBandAction("pending-oob", giftWrapEvent()) + } + val service = service(relay, FakeNdrRuntimeFactory(runtime)) + val completions = mutableListOf<(Boolean) -> Unit>() + service.onOutOfBandPayload = { _, completion -> + completions += completion + } + service.configureIfNeeded(testIdentity()) + + assertEquals(1, completions.size) + completions.single().invoke(false) + assertFalse("pending-oob" in runtime.ackedActionIds) + + relay.reconnect() + + assertEquals(2, completions.size) + completions.last().invoke(true) + assertTrue("pending-oob" in runtime.ackedActionIds) + } + + @Test + fun outOfBandRetriesAreBoundedUntilRouteAvailabilityChanges() { + val scheduler = FakeRetryScheduler() + val runtime = FakeNdrPairwiseRuntime().apply { + pendingEvents += outOfBandAction("pending-oob", giftWrapEvent()) + } + val service = service( + runtimeFactory = FakeNdrRuntimeFactory(runtime), + retryScheduler = scheduler + ) + var attempts = 0 + service.onOutOfBandPayload = { _, completion -> + attempts += 1 + completion(false) + } + + service.configureIfNeeded(testIdentity()) + repeat(5) { scheduler.runNext() } + + assertEquals(6, attempts) + assertTrue(scheduler.scheduled.isEmpty()) + assertFalse("pending-oob" in runtime.ackedActionIds) + + service.onOutOfBandTransportAvailable() + + assertEquals(7, attempts) + assertTrue(scheduler.scheduled.isNotEmpty()) + } + + @Test + fun inviteWhosePeerDoesNotMatchAuthenticatedFavoriteIsRejectedBeforeRuntime() { + val runtime = FakeNdrPairwiseRuntime() + val service = service( + runtimeFactory = FakeNdrRuntimeFactory(runtime), + invitePeerResolver = { "aa".repeat(32) } + ) + service.configureIfNeeded(testIdentity()) + + val result = service.processOutOfBandEventJson( + inviteEvent("aa".repeat(32)), + expectedPeerPubkeyHex = peerPubkey ) assertTrue(result.outboundPayloads.isEmpty()) @@ -303,42 +502,43 @@ class NdrNostrServiceTest { } @Test - fun outOfBandPathRejectsNonHandshakeEvents() { - val runtime = FakeNdrSessionManager() - val service = NdrNostrService( - relayManager = FakeRelayManager(), + fun outOfBandProcessingRequiresAuthenticatedPeer() { + val runtime = FakeNdrPairwiseRuntime() + val service = service( runtimeFactory = FakeNdrRuntimeFactory(runtime), - storageDirectoryProvider = { "/tmp/ndr-test" }, - deviceIdProvider = { "device-1" } + invitePeerResolver = { peerPubkey } ) service.configureIfNeeded(testIdentity()) - service.processOutOfBandEventJson( - """ - {"id":"message1","pubkey":"${"cc".repeat(32)}","created_at":1,"kind":1060,"tags":[],"content":"ciphertext","sig":"sig"} - """.trimIndent(), - expectedPeerPubkeyHex = "cc".repeat(32) - ) + val result = service.processOutOfBandEventJson(inviteEvent(peerPubkey)) - assertTrue(runtime.processedEvents.isEmpty()) - assertTrue(runtime.processedOutOfBandResponses.isEmpty()) + assertTrue(result.outboundPayloads.isEmpty()) + assertTrue(runtime.acceptedInvites.isEmpty()) } @Test - fun relayPathRejectsKindsOutsideNdrProtocol() { - val runtime = FakeNdrSessionManager() - val service = NdrNostrService( - relayManager = FakeRelayManager(), - runtimeFactory = FakeNdrRuntimeFactory(runtime), - storageDirectoryProvider = { "/tmp/ndr-test" }, - deviceIdProvider = { "device-1" } - ) + fun authenticatedGiftWrapResponseMayUseEphemeralOuterPubkey() { + val runtime = FakeNdrPairwiseRuntime() + val service = service(runtimeFactory = FakeNdrRuntimeFactory(runtime)) + service.configureIfNeeded(testIdentity()) + val response = giftWrapEvent() + + service.processOutOfBandEventJson(response, peerPubkey) + + assertEquals(listOf(response to peerPubkey), runtime.processedOutOfBandResponses) + assertTrue(runtime.processedEvents.isEmpty()) + } + + @Test + fun relayPathAcceptsOnlyKind1060() { + val runtime = FakeNdrPairwiseRuntime() + val service = service(runtimeFactory = FakeNdrRuntimeFactory(runtime)) service.configureIfNeeded(testIdentity()) service.processInboundRelayEvent( NostrEvent( - id = "event1", - pubkey = "cc".repeat(32), + id = "02".repeat(32), + pubkey = peerPubkey, createdAt = 1, kind = NostrKind.TEXT_NOTE, tags = emptyList(), @@ -346,358 +546,171 @@ class NdrNostrServiceTest { sig = "sig" ) ) - - assertTrue(runtime.processedEvents.isEmpty()) - } - - @Test - fun inboundDecryptedMessageCallsCallback() { - val relayManager = FakeRelayManager() - val runtime = FakeNdrSessionManager().apply { - processEvents += NdrPubSubEvent( - kind = "decrypted_message", - senderPubkeyHex = "ab".repeat(32), - senderDevicePubkeyHex = "bc".repeat(32), - conversationOwnerPubkeyHex = "cd".repeat(32), - content = "bitchat1:payload", - eventId = "01".repeat(32) - ) - } - val service = NdrNostrService( - relayManager = relayManager, - runtimeFactory = FakeNdrRuntimeFactory(runtime), - storageDirectoryProvider = { "/tmp/ndr-test" }, - deviceIdProvider = { "device-1" } - ) - service.configureIfNeeded( - NostrIdentity( - privateKeyHex = "11".repeat(32), - publicKeyHex = "22".repeat(32), - npub = "npub-test", - createdAt = 1L - ) - ) - - var message: NdrDecryptedMessage? = null - service.onDecryptedMessage = { message = it } - service.processInboundRelayEvent( - NostrEvent( - id = "outer-1", - pubkey = "cd".repeat(32), - createdAt = 123, - kind = 1060, - tags = listOf(listOf("p", "22".repeat(32))), - content = "ciphertext", - sig = "sig" + messageEvent(peerPubkey).copy( + id = "03".repeat(32), + tags = listOf(listOf("p", localPubkey)) ) ) + service.processInboundRelayEvent(messageEvent(peerPubkey)) - assertEquals("01".repeat(32), message?.eventId) - assertEquals("bitchat1:payload", message?.content) - assertEquals("ab".repeat(32), message?.senderPubkeyHex) - assertEquals("bc".repeat(32), message?.senderDevicePubkeyHex) - assertEquals("cd".repeat(32), message?.conversationOwnerPubkeyHex) + assertEquals(1, runtime.processedEvents.size) + assertEquals(1060, NostrEvent.fromJsonString(runtime.processedEvents.single())?.kind) } @Test - fun processOutOfBandInviteUsesOwnerRatherThanDeviceSigner() { - val relayManager = FakeRelayManager() - val runtime = FakeNdrSessionManager( - activeSessionPeers = mutableSetOf("cc".repeat(32)) - ).apply { - acceptInviteEventResult = NdrAcceptInviteResult( - ownerPubkeyHex = "cc".repeat(32), - inviterDevicePubkeyHex = "aa".repeat(32), - deviceId = "device-1", - createdNewSession = true - ) + fun latePublishConfirmationFromPreviousAccountCannotAckReplacementRuntime() { + val oldRuntime = FakeNdrPairwiseRuntime().apply { + pendingEvents += relayPublishAction("shared-action", peerPubkey) } - val service = NdrNostrService( - relayManager = relayManager, - runtimeFactory = FakeNdrRuntimeFactory(runtime), - storageDirectoryProvider = { "/tmp/ndr-test" }, - deviceIdProvider = { "device-1" }, - inviteOwnerResolver = { "cc".repeat(32) } - ) - service.configureIfNeeded( - NostrIdentity( - privateKeyHex = "11".repeat(32), - publicKeyHex = "22".repeat(32), - npub = "npub-test", - createdAt = 1L - ) - ) - - val result = service.processOutOfBandEventJson( - """ - {"id":"invite1","pubkey":"${"aa".repeat(32)}","created_at":1,"kind":30078,"tags":[["l","double-ratchet/invites"]],"content":"invite","sig":"sig"} - """.trimIndent(), - expectedPeerPubkeyHex = "cc".repeat(32) - ) - - assertEquals("cc".repeat(32), result.sessionLookupPubkeyHex) - assertEquals(listOf("cc".repeat(32)), runtime.acceptedInviteOwnerHints) - } - - @Test - fun rejectsInviteWhoseOwnerDoesNotMatchAuthenticatedFavorite() { - val runtime = FakeNdrSessionManager() - val service = NdrNostrService( - relayManager = FakeRelayManager(), - runtimeFactory = FakeNdrRuntimeFactory(runtime), - storageDirectoryProvider = { "/tmp/ndr-test" }, - deviceIdProvider = { "device-1" }, - inviteOwnerResolver = ::eventPubkey - ) - service.configureIfNeeded( - NostrIdentity( - privateKeyHex = "11".repeat(32), - publicKeyHex = "22".repeat(32), - npub = "npub-test", - createdAt = 1L - ) - ) - - val result = service.processOutOfBandEventJson( - """ - {"id":"invite1","pubkey":"${"aa".repeat(32)}","created_at":1,"kind":30078,"tags":[["l","double-ratchet/invites"]],"content":"invite","sig":"sig"} - """.trimIndent(), - expectedPeerPubkeyHex = "cc".repeat(32) - ) - - assertTrue(result.outboundPayloads.isEmpty()) - assertTrue(runtime.acceptedInvites.isEmpty()) - } - - @Test - fun acceptsAuthenticatedGiftWrapResponseWithEphemeralOuterPubkey() { - val runtime = FakeNdrSessionManager() - val service = NdrNostrService( - relayManager = FakeRelayManager(), - runtimeFactory = FakeNdrRuntimeFactory(runtime), - storageDirectoryProvider = { "/tmp/ndr-test" }, - deviceIdProvider = { "device-1" } - ) - service.configureIfNeeded( - NostrIdentity( - privateKeyHex = "11".repeat(32), - publicKeyHex = "22".repeat(32), - npub = "npub-test", - createdAt = 1L - ) - ) - val giftWrap = """ - {"id":"response1","pubkey":"${"ee".repeat(32)}","created_at":1,"kind":1059,"tags":[["p","${"22".repeat(32)}"]],"content":"wrapped","sig":"sig"} - """.trimIndent() - - service.processOutOfBandEventJson( - giftWrap, - expectedPeerPubkeyHex = "cc".repeat(32) - ) - - assertEquals( - listOf(giftWrap to "cc".repeat(32)), - runtime.processedOutOfBandResponses - ) - assertTrue(runtime.processedEvents.isEmpty()) - } - - @Test - fun missingOwnerRosterRetainsAndRetriesInviteOnceAppKeysArrive() { - val owner = "cc".repeat(32) - val device = "aa".repeat(32) - val response = """ - {"id":"response1","pubkey":"sender","created_at":1,"kind":1059,"tags":[["p","peer"]],"content":"wrapped","sig":"sig"} - """.trimIndent() - val invite = """ - {"id":"invite1","pubkey":"$device","created_at":1,"kind":30078,"tags":[["l","double-ratchet/invites"]],"content":"invite","sig":"sig"} - """.trimIndent() - val relayManager = FakeRelayManager() - val runtime = FakeNdrSessionManager(mutableSetOf(owner)).apply { - acceptInviteFailuresRemaining = 1 - acceptInviteEventResult = NdrAcceptInviteResult( - ownerPubkeyHex = owner, - inviterDevicePubkeyHex = device, - deviceId = "owner-device", - createdNewSession = true - ) - blockedAcceptInviteEvents += NdrPubSubEvent( - kind = "subscribe", - subid = "invite-owner-app-keys", - filterJson = """{"authors":["$owner"],"kinds":[37368],"limit":16}""" - ) - setupUserEvents[owner] = listOf( - NdrPubSubEvent( - kind = "subscribe", - subid = "duplicate-owner-app-keys", - filterJson = """{"authors":["$owner"],"kinds":[37368]}""" - ), - NdrPubSubEvent( - kind = "subscribe", - subid = "owner-invite-discovery", - filterJson = """{"authors":["$owner"],"kinds":[30078]}""" - ) - ) - acceptInviteEvents += NdrPubSubEvent( - kind = "publish_signed", - eventJson = response - ) + val newRuntime = FakeNdrPairwiseRuntime().apply { + pendingEvents += relayPublishAction("shared-action", peerPubkey) } - val service = NdrNostrService( - relayManager = relayManager, - runtimeFactory = FakeNdrRuntimeFactory(runtime), - storageDirectoryProvider = { "/tmp/ndr-test" }, - deviceIdProvider = { "device-1" }, - inviteOwnerResolver = { owner } + val relay = FakeRelayManager(confirmationResult = null) + val service = service( + relayManager = relay, + runtimeFactory = SequencedNdrRuntimeFactory(listOf(oldRuntime, newRuntime)) ) service.configureIfNeeded(testIdentity()) - val retriedPayloads = mutableListOf>>() - service.onOutOfBandPayloadsReady = { peerOwner, payloads -> - retriedPayloads += peerOwner to payloads + + service.configureIfNeeded( + NostrIdentity( + privateKeyHex = "33".repeat(32), + publicKeyHex = "44".repeat(32), + npub = "npub-replacement", + createdAt = 2L + ) + ) + + assertEquals(1, relay.canceledConfirmations.size) + relay.canceledConfirmations.single().completion(true) + assertTrue(newRuntime.ackedActionIds.isEmpty()) + + relay.confirmations.single().completion(true) + assertEquals(listOf("shared-action"), newRuntime.ackedActionIds) + assertTrue(oldRuntime.ackedActionIds.isEmpty()) + } + + @Test + fun latePublishConfirmationAfterPanicCannotAckSameAccountReplacement() { + val oldRuntime = FakeNdrPairwiseRuntime().apply { + pendingEvents += relayPublishAction("shared-action", peerPubkey) } - - val first = service.processOutOfBandEventJson(invite, owner) - val duplicate = service.processOutOfBandEventJson(invite, owner) - val rotatedInvite = service.processOutOfBandEventJson( - invite.replace("\"id\":\"invite1\"", "\"id\":\"invite2\""), - owner + val newRuntime = FakeNdrPairwiseRuntime().apply { + pendingEvents += relayPublishAction("shared-action", peerPubkey) + } + val relay = FakeRelayManager(confirmationResult = null) + val service = service( + relayManager = relay, + runtimeFactory = SequencedNdrRuntimeFactory(listOf(oldRuntime, newRuntime)), + storageResetter = {} ) + service.configureIfNeeded(testIdentity()) - assertTrue(first.outboundPayloads.isEmpty()) - assertTrue(duplicate.outboundPayloads.isEmpty()) - assertTrue(rotatedInvite.outboundPayloads.isEmpty()) - assertEquals(1, runtime.acceptedInvites.size) - assertEquals( - listOf("invite-owner-app-keys"), - relayManager.subscriptions.map { it.id } + assertTrue(service.resetForPanic()) + service.configureIfNeeded(testIdentity()) + + relay.canceledConfirmations.single().completion(true) + assertTrue(newRuntime.ackedActionIds.isEmpty()) + + relay.confirmations.single().completion(true) + assertEquals(listOf("shared-action"), newRuntime.ackedActionIds) + assertTrue(oldRuntime.ackedActionIds.isEmpty()) + } + + @Test + fun lateDeliveryAndOobCompletionCannotAckReplacementRuntime() { + val oldRuntime = FakeNdrPairwiseRuntime(mutableSetOf(peerPubkey)).apply { + pendingEvents += decryptedAction("shared-delivery", peerPubkey, "bitchat1:pending") + acceptInviteEvents += outOfBandAction("shared-oob", giftWrapEvent()) + acceptInviteResult = NdrAcceptInviteResult(peerPubkey, createdNewSession = true) + } + val newRuntime = FakeNdrPairwiseRuntime() + val service = service( + runtimeFactory = SequencedNdrRuntimeFactory(listOf(oldRuntime, newRuntime)), + invitePeerResolver = { peerPubkey } ) + var deliveryCompletion: ((NdrDeliveryResult) -> Unit)? = null + service.onDecryptedMessage = { _, completion -> + deliveryCompletion = completion + } + service.configureIfNeeded(testIdentity()) + val oldPayload = service.processOutOfBandEventJson( + inviteEvent(peerPubkey), + peerPubkey + ).outboundPayloads.single() - relayManager.emit( - "invite-owner-app-keys", - NostrEvent( - id = "not-app-keys", - pubkey = owner, - createdAt = 2, - kind = 37368, - tags = listOf(listOf("type", "something_else")), - content = "ignored", - sig = "sig" + service.configureIfNeeded( + NostrIdentity( + privateKeyHex = "33".repeat(32), + publicKeyHex = "44".repeat(32), + npub = "npub-replacement", + createdAt = 2L ) ) - assertEquals(1, runtime.acceptedInvites.size) + deliveryCompletion?.invoke(NdrDeliveryResult.CONSUMED) - relayManager.emit( - "invite-owner-app-keys", - NostrEvent( - id = "app-keys-1", - pubkey = owner, - createdAt = 2, - kind = 37368, - tags = listOf(listOf("type", "app_keys_roster_snapshot")), - content = "signed-roster", - sig = "sig" - ) - ) - - assertEquals(2, runtime.acceptedInvites.size) - assertEquals(listOf(owner), runtime.setupUserCalls) - assertEquals( - listOf("invite-owner-app-keys"), - relayManager.subscriptions.map { it.id } - ) - assertEquals(listOf(owner to listOf(response)), retriedPayloads) + assertFalse(service.acknowledgeOutOfBandPayload(oldPayload)) + assertTrue(newRuntime.ackedActionIds.isEmpty()) + assertTrue(oldRuntime.ackedActionIds.isEmpty()) } @Test fun panicResetDestroysRuntimeAndClearsPersistentState() { - val runtime = FakeNdrSessionManager() + val runtime = FakeNdrPairwiseRuntime() var storageReset = false - var deviceIdReset = false - val service = NdrNostrService( - relayManager = FakeRelayManager(), + val service = service( runtimeFactory = FakeNdrRuntimeFactory(runtime), - storageDirectoryProvider = { "/tmp/ndr-test" }, - deviceIdProvider = { "device-1" }, - storageResetter = { storageReset = true }, - deviceIdResetter = { deviceIdReset = true } + storageResetter = { storageReset = true } ) - service.configureIfNeeded( - NostrIdentity( - privateKeyHex = "11".repeat(32), - publicKeyHex = "22".repeat(32), - npub = "npub-test", - createdAt = 1L - ) - ) - service.onDecryptedMessage = {} - service.onOutOfBandPayloadsReady = { _, _ -> } + service.configureIfNeeded(testIdentity()) + service.onDecryptedMessage = { _, completion -> + completion(NdrDeliveryResult.CONSUMED) + } assertTrue(service.resetForPanic()) assertFalse(service.isConfigured) assertNull(service.currentInviteEventJson()) assertNull(service.onDecryptedMessage) - assertNull(service.onOutOfBandPayloadsReady) assertTrue(runtime.destroyed) assertTrue(storageReset) - assertTrue(deviceIdReset) } @Test fun failedPanicStorageWipeKeepsNdrDisabled() { - val runtime = FakeNdrSessionManager() - val runtimeFactory = FakeNdrRuntimeFactory(runtime) - val service = NdrNostrService( - relayManager = FakeRelayManager(), - runtimeFactory = runtimeFactory, - storageDirectoryProvider = { "/tmp/ndr-test" }, - deviceIdProvider = { "device-1" }, + val runtime = FakeNdrPairwiseRuntime() + val factory = FakeNdrRuntimeFactory(runtime) + val service = service( + runtimeFactory = factory, storageResetter = { throw java.io.IOException("busy") } ) - val identity = NostrIdentity( - privateKeyHex = "11".repeat(32), - publicKeyHex = "22".repeat(32), - npub = "npub-test", - createdAt = 1L - ) - service.configureIfNeeded(identity) + service.configureIfNeeded(testIdentity()) assertFalse(service.resetForPanic()) - service.configureIfNeeded(identity) + service.configureIfNeeded(testIdentity()) assertFalse(service.isConfigured) - assertEquals(1, runtimeFactory.createdCount) + assertEquals(1, factory.createdCount) } @Test fun panicResetWaitsForInFlightRuntimeMutation() { - val peer = "aa".repeat(32) val sendEntered = CountDownLatch(1) val releaseSend = CountDownLatch(1) val resetFinished = CountDownLatch(1) - val runtime = FakeNdrSessionManager(mutableSetOf(peer)).apply { + val runtime = FakeNdrPairwiseRuntime(mutableSetOf(peerPubkey)).apply { sendTextEntered = sendEntered releaseSendText = releaseSend } - val service = NdrNostrService( - relayManager = FakeRelayManager(), + val service = service( runtimeFactory = FakeNdrRuntimeFactory(runtime), - storageDirectoryProvider = { "/tmp/ndr-test" }, - deviceIdProvider = { "device-1" }, storageResetter = {} ) - service.configureIfNeeded( - NostrIdentity( - privateKeyHex = "11".repeat(32), - publicKeyHex = "22".repeat(32), - npub = "npub-test", - createdAt = 1L - ) - ) + service.configureIfNeeded(testIdentity()) val sendThread = thread(start = true, name = "ndr-test-send") { - service.sendIfPossible("hello", peer) + service.sendIfPossible("hello", peerPubkey) } assertTrue(sendEntered.await(2, TimeUnit.SECONDS)) @@ -719,229 +732,578 @@ class NdrNostrServiceTest { } @Test - fun sendIfPossibleReturnsFalseWhenNoActiveSessionExists() { - val relayManager = FakeRelayManager() - val runtime = FakeNdrSessionManager().apply { - sendTextResult = listOf("outer-1") - } - val service = NdrNostrService( - relayManager = relayManager, - runtimeFactory = FakeNdrRuntimeFactory(runtime), - storageDirectoryProvider = { "/tmp/ndr-test" }, - deviceIdProvider = { "device-1" } - ) - service.configureIfNeeded( - NostrIdentity( - privateKeyHex = "11".repeat(32), - publicKeyHex = "22".repeat(32), - npub = "npub-test", - createdAt = 1L + fun sendRequiresActivePairwiseSessionAndPublishesExactlyOneKind1060() { + val relay = FakeRelayManager() + val runtime = FakeNdrPairwiseRuntime(mutableSetOf(peerPubkey)).apply { + sendTextEvents += NdrPubSubEvent( + kind = "publish", + actionId = "message-action", + sessionId = "message-session", + eventJson = messageEvent(localPubkey).toJsonString() ) + } + val service = service(relay, FakeNdrRuntimeFactory(runtime)) + service.configureIfNeeded(testIdentity()) + + assertEquals( + NdrSendResult.NO_SESSION, + service.sendIfPossible("hello", "aa".repeat(32)) + ) + assertEquals(NdrSendResult.SENT, service.sendIfPossible("hello", peerPubkey)) + + assertEquals(listOf(peerPubkey), runtime.sendTextCalls) + assertEquals(listOf(1060), relay.sentEvents.map { it.kind }) + assertTrue("message-action" in runtime.ackedActionIds) + } + + @Test + fun outboundAbsoluteExpirationIsForwardedToPairwiseRuntime() { + val expiresAtSeconds = 4_102_444_800uL + val runtime = FakeNdrPairwiseRuntime(mutableSetOf(peerPubkey)) + val service = service(runtimeFactory = FakeNdrRuntimeFactory(runtime)) + service.configureIfNeeded(testIdentity()) + + val result = service.sendIfPossible( + text = "disappearing", + peerPubkeyHex = peerPubkey, + expiresAtSeconds = expiresAtSeconds ) - assertFalse(service.sendIfPossible("hello", "aa".repeat(32))) + assertEquals(NdrSendResult.SENT, result) + assertEquals(listOf(peerPubkey), runtime.sendTextCalls) + assertEquals(listOf(expiresAtSeconds), runtime.sendTextExpirationCalls) + } + + @Test + fun activePairwiseSessionSendFailureIsNotReportedAsNoSession() { + val runtime = FakeNdrPairwiseRuntime(mutableSetOf(peerPubkey)).apply { + sendTextFailure = java.io.IOException("storage unavailable") + } + val relay = FakeRelayManager() + val service = service(relay, FakeNdrRuntimeFactory(runtime)) + service.configureIfNeeded(testIdentity()) + + val result = service.sendIfPossible("hello", peerPubkey) + + assertEquals(NdrSendResult.FAILED, result) + assertTrue(relay.sentEvents.isEmpty()) + } + + @Test + fun persistedHalfReadyPairwiseSessionNeverFallsBackAfterRestart() { + val markers = InMemoryMarkerStore() + val runtime = FakeNdrPairwiseRuntime().apply { + halfReadySessionPeers += peerPubkey + } + val service = service( + runtimeFactory = FakeNdrRuntimeFactory(runtime), + markerStore = markers + ) + service.configureIfNeeded(testIdentity()) + + assertTrue(service.hasPairwiseSession(peerPubkey)) + assertFalse(service.hasActiveSession(peerPubkey)) + assertEquals( + NdrSendResult.FAILED, + service.sendIfPossible("must-not-downgrade", peerPubkey) + ) + assertTrue(runtime.sendTextCalls.isEmpty()) + assertTrue(markers.contains(localPubkey)) + + val replacementFactory = FakeNdrRuntimeFactory(FakeNdrPairwiseRuntime()) + val restartedWithMissingState = service( + runtimeFactory = replacementFactory, + markerStore = markers, + pairwiseStateExists = { false } + ) + restartedWithMissingState.configureIfNeeded(testIdentity()) + + assertFalse(restartedWithMissingState.isConfigured) + assertEquals(0, replacementFactory.createdCount) + } + + @Test + fun activeSessionLookupFailureFailsClosed() { + val runtime = FakeNdrPairwiseRuntime(mutableSetOf(peerPubkey)) + val service = service(runtimeFactory = FakeNdrRuntimeFactory(runtime)) + service.configureIfNeeded(testIdentity()) + assertTrue(service.hasActiveSession(peerPubkey)) + runtime.activeSessionLookupFailure = java.io.IOException("database unavailable") + + assertEquals( + NdrSendResult.FAILED, + service.sendIfPossible("hello", peerPubkey) + ) assertTrue(runtime.sendTextCalls.isEmpty()) } @Test - fun sendIfPossibleReturnsTrueWhenActiveSessionQueuesNoRelayPublish() { - val peer = "aa".repeat(32) - val relayManager = FakeRelayManager() - val runtime = FakeNdrSessionManager(mutableSetOf(peer)).apply { - sendTextResult = emptyList() - } - val service = NdrNostrService( - relayManager = relayManager, - runtimeFactory = FakeNdrRuntimeFactory(runtime), - storageDirectoryProvider = { "/tmp/ndr-test" }, - deviceIdProvider = { "device-1" } + fun configurationFailureFailsClosedUntilIdentityChanges() { + val replacementRuntime = FakeNdrPairwiseRuntime() + val factory = FailingThenSucceedingNdrRuntimeFactory(replacementRuntime) + val service = service(runtimeFactory = factory) + + service.configureIfNeeded(testIdentity()) + service.configureIfNeeded(testIdentity()) + + assertFalse(service.isConfigured) + assertEquals(1, factory.createdCount) + assertEquals( + NdrSendResult.FAILED, + service.sendIfPossible("must-not-downgrade", peerPubkey) ) + service.configureIfNeeded( NostrIdentity( - privateKeyHex = "11".repeat(32), - publicKeyHex = "22".repeat(32), - npub = "npub-test", - createdAt = 1L + privateKeyHex = "33".repeat(32), + publicKeyHex = "44".repeat(32), + npub = "npub-replacement", + createdAt = 2L ) ) - assertTrue(service.sendIfPossible("hello", peer)) - assertEquals(listOf(peer), runtime.sendTextCalls) + assertTrue(service.isConfigured) + assertEquals(2, factory.createdCount) } - private fun extractNostrKind(eventJson: String): Int { - return requireNotNull(NostrEvent.fromJsonString(eventJson)?.kind) + @Test + fun establishedMarkerWithMissingPairwiseStateFailsClosedBeforeRuntimeOpen() { + val markers = InMemoryMarkerStore().apply { mark(localPubkey) } + val factory = FakeNdrRuntimeFactory(FakeNdrPairwiseRuntime()) + val service = service( + runtimeFactory = factory, + markerStore = markers, + pairwiseStateExists = { false } + ) + + service.configureIfNeeded(testIdentity()) + + assertFalse(service.isConfigured) + assertEquals(0, factory.createdCount) + assertEquals( + NdrSendResult.FAILED, + service.sendIfPossible("must-not-downgrade", peerPubkey) + ) } - private fun eventPubkey(eventJson: String): String? { - return NostrEvent.fromJsonString(eventJson)?.pubkey + @Test + fun peerRetirementIsDurableAndFailureAbortsHostRebind() { + val runtime = FakeNdrPairwiseRuntime(mutableSetOf(peerPubkey)) + val service = service(runtimeFactory = FakeNdrRuntimeFactory(runtime)) + service.configureIfNeeded(testIdentity()) + + assertTrue(service.retirePeer(peerPubkey)) + assertEquals(listOf(peerPubkey), runtime.retiredPeers) + assertFalse(service.hasActiveSession(peerPubkey)) + + runtime.retirePeerFailure = java.io.IOException("storage unavailable") + assertFalse(service.retirePeer("aa".repeat(32))) } + @Test + fun runtimeInitializationFailureDestroysPartialRuntimeAndFailsClosed() { + val runtime = FakeNdrPairwiseRuntime().apply { + knownPeerPubkeysFailure = java.io.IOException("database unavailable") + } + val factory = FakeNdrRuntimeFactory(runtime) + val service = service(runtimeFactory = factory) + + service.configureIfNeeded(testIdentity()) + service.configureIfNeeded(testIdentity()) + + assertTrue(runtime.destroyed) + assertFalse(service.isConfigured) + assertEquals(1, factory.createdCount) + assertEquals(NdrSendResult.FAILED, service.sendIfPossible("hello", peerPubkey)) + } + + @Test + fun successfulPanicResetClearsConfigurationFailureLatch() { + val replacementRuntime = FakeNdrPairwiseRuntime() + val factory = FailingThenSucceedingNdrRuntimeFactory(replacementRuntime) + val service = service( + runtimeFactory = factory, + storageResetter = {} + ) + service.configureIfNeeded(testIdentity()) + + assertEquals(NdrSendResult.FAILED, service.sendIfPossible("hello", peerPubkey)) + assertTrue(service.resetForPanic()) + service.configureIfNeeded(testIdentity()) + + assertTrue(service.isConfigured) + assertEquals(2, factory.createdCount) + } + + @Test + fun legacyFallbackPolicyAllowsOnlyMissingPairwiseSession() { + assertTrue(shouldUseLegacyNostrFallback(NdrSendResult.NO_SESSION)) + assertFalse(shouldUseLegacyNostrFallback(NdrSendResult.FAILED)) + assertFalse(shouldUseLegacyNostrFallback(NdrSendResult.SENT)) + } + + private fun service( + relayManager: FakeRelayManager = FakeRelayManager(), + runtimeFactory: NdrPairwiseRuntimeFactory, + storageResetter: () -> Unit = {}, + invitePeerResolver: (String) -> String? = { null }, + retryScheduler: NdrRetryScheduler = FakeRetryScheduler(), + markerStore: NdrEstablishedSessionMarkerStore = InMemoryMarkerStore(), + pairwiseStateExists: (String) -> Boolean = { false } + ) = NdrNostrService( + relayManager = relayManager, + runtimeFactory = runtimeFactory, + storageDirectoryProvider = { "/tmp/ndr-test" }, + storageResetter = storageResetter, + establishedSessionMarkers = markerStore, + pairwiseStateExists = pairwiseStateExists, + invitePeerResolver = invitePeerResolver, + retryScheduler = retryScheduler + ) + private fun testIdentity() = NostrIdentity( privateKeyHex = "11".repeat(32), - publicKeyHex = "22".repeat(32), + publicKeyHex = localPubkey, npub = "npub-test", createdAt = 1L ) + private fun inviteEvent(pubkey: String): String = """ + {"id":"${"10".repeat(32)}","pubkey":"$pubkey","created_at":1,"kind":30078,"tags":[["l","double-ratchet/invites"]],"content":"invite","sig":"sig"} + """.trimIndent() + + private fun giftWrapEvent(): String = """ + {"id":"${"11".repeat(32)}","pubkey":"${"ee".repeat(32)}","created_at":1,"kind":1059,"tags":[["p","$localPubkey"]],"content":"wrapped","sig":"sig"} + """.trimIndent() + + private fun appKeysEvent(pubkey: String): String = """ + {"id":"${"12".repeat(32)}","pubkey":"$pubkey","created_at":1,"kind":37368,"tags":[["type","app_keys_roster_snapshot"]],"content":"roster","sig":"sig"} + """.trimIndent() + + private fun messageEvent(pubkey: String) = NostrEvent( + id = "13".repeat(32), + pubkey = pubkey, + createdAt = 1, + kind = 1060, + tags = emptyList(), + content = "ciphertext", + sig = "sig" + ) + + private fun relayPublishAction(actionId: String, pubkey: String) = NdrPubSubEvent( + kind = "publish", + actionId = actionId, + sessionId = "session-$actionId", + eventJson = messageEvent(pubkey).toJsonString() + ) + + private fun outOfBandAction( + actionId: String, + eventJson: String, + peer: String = peerPubkey + ) = NdrPubSubEvent( + kind = "out_of_band", + actionId = actionId, + sessionId = "session-$actionId", + eventJson = eventJson, + peerPubkeyHex = peer + ) + + private fun decryptedAction( + actionId: String, + sender: String, + content: String + ) = NdrPubSubEvent( + kind = "delivery", + actionId = actionId, + senderPubkeyHex = sender, + content = content, + eventId = actionId.hashCode().toUInt().toString(16).padStart(64, '0') + ) + private class FakeNdrRuntimeFactory( - private val runtime: FakeNdrSessionManager - ) : NdrSessionManagerFactory { + private val runtime: FakeNdrPairwiseRuntime + ) : NdrPairwiseRuntimeFactory { var lastStoragePath: String? = null var createdCount: Int = 0 override fun newWithStoragePath( ourPubkeyHex: String, ourIdentityPrivkeyHex: String, - deviceId: String, - storagePath: String, - ownerPubkeyHex: String? - ): NdrSessionManager { + storagePath: String + ): NdrPairwiseRuntime { lastStoragePath = storagePath createdCount += 1 return runtime } } - private class FakeRelayManager : NdrRelayManager { + private class SequencedNdrRuntimeFactory( + runtimes: List + ) : NdrPairwiseRuntimeFactory { + private val remaining = ArrayDeque(runtimes) + + override fun newWithStoragePath( + ourPubkeyHex: String, + ourIdentityPrivkeyHex: String, + storagePath: String + ): NdrPairwiseRuntime = remaining.removeFirst() + } + + private class FailingThenSucceedingNdrRuntimeFactory( + private val runtime: FakeNdrPairwiseRuntime + ) : NdrPairwiseRuntimeFactory { + var createdCount = 0 + + override fun newWithStoragePath( + ourPubkeyHex: String, + ourIdentityPrivkeyHex: String, + storagePath: String + ): NdrPairwiseRuntime { + createdCount += 1 + if (createdCount == 1) throw java.io.IOException("database unavailable") + return runtime + } + } + + private class FakeRelayManager( + var failSend: Boolean = false, + var failSubscribe: Boolean = false, + var confirmationResult: Boolean? = true + ) : NdrRelayManager { data class Subscription( val id: String, val filter: NostrFilter, - val handler: (NostrEvent) -> Unit + val handler: (NostrEvent) -> Boolean + ) + data class Confirmation( + val eventId: String, + val completion: (Boolean) -> Unit ) val subscriptions = mutableListOf() val unsubscribed = mutableListOf() val sentEvents = mutableListOf() + val confirmations = ArrayDeque() + val canceledConfirmations = mutableListOf() + private var connectionAvailableHandler: (() -> Unit)? = null - override fun subscribe(filter: NostrFilter, id: String, handler: (NostrEvent) -> Unit) { - subscriptions += Subscription(id = id, filter = filter, handler = handler) + override fun subscribe(filter: NostrFilter, id: String, handler: (NostrEvent) -> Boolean) { + if (failSubscribe) throw java.io.IOException("subscribe failed") + subscriptions += Subscription(id, filter, handler) } override fun unsubscribe(id: String) { unsubscribed += id } - override fun sendEvent(event: NostrEvent) { + override fun sendEventConfirmed( + event: NostrEvent, + completion: (accepted: Boolean) -> Unit + ) { + if (failSend) throw java.io.IOException("send failed") sentEvents += event + val result = confirmationResult + if (result == null) { + confirmations.addLast(Confirmation(event.id, completion)) + } else { + completion(result) + } } - fun emit(id: String, event: NostrEvent) { - requireNotNull(subscriptions.lastOrNull { it.id == id }).handler(event) + override fun cancelConfirmedEvent(eventId: String) { + val retained = ArrayDeque() + while (confirmations.isNotEmpty()) { + val confirmation = confirmations.removeFirst() + if (confirmation.eventId == eventId) { + canceledConfirmations += confirmation + confirmation.completion(false) + } else { + retained.addLast(confirmation) + } + } + confirmations.addAll(retained) + } + + override fun setOnConnectionAvailable(handler: () -> Unit) { + connectionAvailableHandler = handler + } + + fun reconnect() { + connectionAvailableHandler?.invoke() } } - private class FakeNdrSessionManager( + private class FakeRetryScheduler : NdrRetryScheduler { + data class ScheduledTask( + val delayMs: Long, + val task: () -> Unit, + var canceled: Boolean = false + ) + + val scheduled = ArrayDeque() + + override fun schedule(delayMs: Long, task: () -> Unit): NdrRetryCancellation { + val scheduledTask = ScheduledTask(delayMs, task) + scheduled.addLast(scheduledTask) + return NdrRetryCancellation { scheduledTask.canceled = true } + } + + fun runNext() { + while (scheduled.isNotEmpty()) { + val scheduledTask = scheduled.removeFirst() + if (!scheduledTask.canceled) { + scheduledTask.task() + return + } + } + } + } + + private class InMemoryMarkerStore : NdrEstablishedSessionMarkerStore { + private val marked = mutableSetOf() + + override fun contains(accountPubkeyHex: String): Boolean = + accountPubkeyHex.lowercase() in marked + + override fun mark(accountPubkeyHex: String) { + marked += accountPubkeyHex.lowercase() + } + + override fun clearAll() { + marked.clear() + } + } + + private class FakeNdrPairwiseRuntime( private val activeSessionPeers: MutableSet = mutableSetOf() - ) : NdrSessionManager { - val drainedEvents = ArrayDeque() + ) : NdrPairwiseRuntime { + val halfReadySessionPeers = mutableSetOf() + val pendingEvents = mutableListOf() + val ackedActionIds = mutableListOf() val processedEvents = mutableListOf() val processedOutOfBandResponses = mutableListOf>() val acceptedInvites = mutableListOf() val acceptedInviteUrls = mutableListOf() val acceptInviteEvents = mutableListOf() val acceptInviteUrlEvents = mutableListOf() - val blockedAcceptInviteEvents = mutableListOf() val processEvents = mutableListOf() - val acceptedInviteOwnerHints = mutableListOf() - val acceptedInviteUrlOwnerHints = mutableListOf() + val sendTextEvents = mutableListOf() val sendTextCalls = mutableListOf() - val knownPeerOwners = mutableListOf() - val setupUserCalls = mutableListOf() - val setupUserEvents = mutableMapOf>() - var acceptInviteEventResult = NdrAcceptInviteResult( - ownerPubkeyHex = "aa".repeat(32), - inviterDevicePubkeyHex = "bb".repeat(32), - deviceId = "device-1", + val sendTextExpirationCalls = mutableListOf() + var acceptInviteResult = NdrAcceptInviteResult( + peerPubkeyHex = "aa".repeat(32), createdNewSession = true ) - var acceptInviteUrlResult = NdrAcceptInviteResult( - ownerPubkeyHex = "aa".repeat(32), - inviterDevicePubkeyHex = "bb".repeat(32), - deviceId = "device-1", - createdNewSession = true - ) - var sendTextResult: List = listOf("outer-1") - var acceptInviteFailuresRemaining: Int = 0 - var destroyed: Boolean = false + var acceptInviteUrlResult = acceptInviteResult + var currentInvite: String? = null + var destroyed = false var sendTextEntered: CountDownLatch? = null var releaseSendText: CountDownLatch? = null + var sendTextFailure: Throwable? = null + var activeSessionLookupFailure: Throwable? = null + var knownPeerPubkeysFailure: Throwable? = null + var retirePeerFailure: Throwable? = null + val retiredPeers = mutableListOf() @Volatile - var sendTextCompleted: Boolean = false - var destroyedAfterSendCompleted: Boolean = false + var sendTextCompleted = false + var destroyedAfterSendCompleted = false - override fun init() = Unit + override fun currentInviteEventJson(): String? = currentInvite - override fun knownPeerOwnerPubkeys(): List = - knownPeerOwners.toList() - - override fun setupUser(userPubkeyHex: String) { - setupUserCalls += userPubkeyHex - drainedEvents.addAll(setupUserEvents[userPubkeyHex].orEmpty()) - } + override fun currentInviteUrl(root: String): String? = null override fun acceptInviteFromEventJson( eventJson: String, - ownerPubkeyHintHex: String? + expectedPeerPubkeyHex: String ): NdrAcceptInviteResult { acceptedInvites += eventJson - acceptedInviteOwnerHints += ownerPubkeyHintHex - if (acceptInviteFailuresRemaining > 0) { - acceptInviteFailuresRemaining -= 1 - drainedEvents.addAll(blockedAcceptInviteEvents) - throw NdrSessionNotReadyException("missing owner roster") - } - drainedEvents.addAll(acceptInviteEvents) - return acceptInviteEventResult + pendingEvents += acceptInviteEvents + return acceptInviteResult } override fun acceptInviteFromUrl( inviteUrl: String, - ownerPubkeyHintHex: String? + expectedPeerPubkeyHex: String ): NdrAcceptInviteResult { acceptedInviteUrls += inviteUrl - acceptedInviteUrlOwnerHints += ownerPubkeyHintHex - drainedEvents.addAll(acceptInviteUrlEvents) + pendingEvents += acceptInviteUrlEvents return acceptInviteUrlResult } override fun processEvent(eventJson: String) { processedEvents += eventJson - drainedEvents.addAll(processEvents) + pendingEvents += processEvents } override fun processOutOfBandResponse( eventJson: String, - expectedOwnerPubkeyHex: String + expectedPeerPubkeyHex: String ) { - processedOutOfBandResponses += eventJson to expectedOwnerPubkeyHex + processedOutOfBandResponses += eventJson to expectedPeerPubkeyHex } - override fun drainEvents(): List = buildList { - while (drainedEvents.isNotEmpty()) { - add(drainedEvents.removeFirst()) + override fun pendingActions(nowSeconds: ULong): List = + pendingEvents.toList() + + override fun ackActions(actionIds: List) { + ackedActionIds += actionIds + pendingEvents.removeAll { it.actionId in actionIds } + } + + override fun sessionInfo(peerPubkeyHex: String): NdrPairwiseSessionInfo? { + activeSessionLookupFailure?.let { throw it } + val peer = peerPubkeyHex.lowercase() + return when { + peer in activeSessionPeers -> + NdrPairwiseSessionInfo( + sendReady = true, + receiveReady = true, + trackedSenderPubkeys = listOf(peer) + ) + peer in halfReadySessionPeers -> + NdrPairwiseSessionInfo( + sendReady = false, + receiveReady = false, + trackedSenderPubkeys = emptyList() + ) + else -> null } } - override fun getActiveSessionState(peerPubkeyHex: String): String? { - return peerPubkeyHex.takeIf { activeSessionPeers.contains(it.lowercase()) }?.let { """{"peer":"$it"}""" } + override fun knownPeerPubkeys(): List { + knownPeerPubkeysFailure?.let { throw it } + return (activeSessionPeers + halfReadySessionPeers).toList() + } + + override fun retirePeer(peerPubkeyHex: String): Boolean { + retirePeerFailure?.let { throw it } + retiredPeers += peerPubkeyHex.lowercase() + return activeSessionPeers.remove(peerPubkeyHex.lowercase()) || + halfReadySessionPeers.remove(peerPubkeyHex.lowercase()) } override fun sendText( recipientPubkeyHex: String, text: String, expiresAtSeconds: ULong? - ): List { + ): NdrPairwiseSendResult { sendTextCalls += recipientPubkeyHex + sendTextExpirationCalls += expiresAtSeconds sendTextEntered?.countDown() releaseSendText?.await(2, TimeUnit.SECONDS) + sendTextFailure?.let { throw it } sendTextCompleted = true - return sendTextResult + pendingEvents += sendTextEvents + return NdrPairwiseSendResult( + innerEventId = "21".repeat(32), + outerEventId = "22".repeat(32) + ) } override fun getOurPubkeyHex(): String = "22".repeat(32) - override fun getTotalSessions(): ULong = 0u + override fun getTotalSessions(): ULong = activeSessionPeers.size.toULong() override fun destroy() { destroyedAfterSendCompleted = sendTextCompleted diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/NdrOutOfBandRoutePolicyTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/NdrOutOfBandRoutePolicyTest.kt new file mode 100644 index 00000000..f44263f6 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/nostr/NdrOutOfBandRoutePolicyTest.kt @@ -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) + } + ) + ) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/NostrRelaySubscriptionRaceTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/NostrRelaySubscriptionRaceTest.kt new file mode 100644 index 00000000..c8c9c7fe --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/nostr/NostrRelaySubscriptionRaceTest.kt @@ -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 + 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" + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/NostrReliabilityPolicyTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/NostrReliabilityPolicyTest.kt new file mode 100644 index 00000000..428b195b --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/nostr/NostrReliabilityPolicyTest.kt @@ -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" + ) +} diff --git a/vendor/iris-chat-rs b/vendor/iris-chat-rs deleted file mode 160000 index 095e7048..00000000 --- a/vendor/iris-chat-rs +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 095e70489345df4d92dded686902f3dccb54cc45 diff --git a/vendor/nostr-double-ratchet b/vendor/nostr-double-ratchet new file mode 160000 index 00000000..0fe8caf2 --- /dev/null +++ b/vendor/nostr-double-ratchet @@ -0,0 +1 @@ +Subproject commit 0fe8caf2d4e24e2030ffae195597a2764613a659