From 8f64c155ce78228a0721a9b706e127f27c69f514 Mon Sep 17 00:00:00 2001 From: Dev Date: Mon, 27 Jul 2026 20:05:11 +0300 Subject: [PATCH] harden pairwise NDR durability and downgrade protection --- .../com/bitchat/android/BitchatApplication.kt | 26 +- .../java/com/bitchat/android/MainActivity.kt | 6 + .../favorites/FavoritesPersistenceService.kt | 724 ++++++++++++++---- .../identity/SecureIdentityStateManager.kt | 55 +- .../nostr/NdrEstablishedSessionMarkerStore.kt | 110 ++- .../bitchat/android/nostr/NdrNostrService.kt | 204 ++++- .../android/nostr/NdrPanicStartupRecovery.kt | 75 ++ .../nostr/NdrPanicStorageQuarantine.kt | 125 +++ .../nostr/NostrDirectMessageHandler.kt | 16 + .../bitchat/android/nostr/NostrTransport.kt | 99 ++- .../android/service/MeshForegroundService.kt | 12 + .../com/bitchat/android/ui/ChatViewModel.kt | 94 ++- .../favorites/FavoritesNdrRebindTest.kt | 537 ++++++++++++- ...rivateMediaCapabilityPinPersistenceTest.kt | 59 ++ ...ileNdrEstablishedSessionMarkerStoreTest.kt | 54 ++ .../FileNdrPanicStorageQuarantineTest.kt | 62 ++ .../android/nostr/NdrNostrServiceTest.kt | 210 ++++- .../nostr/NdrPanicStartupRecoveryTest.kt | 149 ++++ 18 files changed, 2348 insertions(+), 269 deletions(-) create mode 100644 app/src/main/java/com/bitchat/android/nostr/NdrPanicStartupRecovery.kt create mode 100644 app/src/main/java/com/bitchat/android/nostr/NdrPanicStorageQuarantine.kt create mode 100644 app/src/test/kotlin/com/bitchat/android/nostr/FileNdrEstablishedSessionMarkerStoreTest.kt create mode 100644 app/src/test/kotlin/com/bitchat/android/nostr/FileNdrPanicStorageQuarantineTest.kt create mode 100644 app/src/test/kotlin/com/bitchat/android/nostr/NdrPanicStartupRecoveryTest.kt diff --git a/app/src/main/java/com/bitchat/android/BitchatApplication.kt b/app/src/main/java/com/bitchat/android/BitchatApplication.kt index 5a04a9fc..c7cd6c19 100644 --- a/app/src/main/java/com/bitchat/android/BitchatApplication.kt +++ b/app/src/main/java/com/bitchat/android/BitchatApplication.kt @@ -13,6 +13,16 @@ class BitchatApplication : Application() { override fun onCreate() { super.onCreate() + if (!com.bitchat.android.nostr.NdrPanicStartupRecovery + .recoverBeforeNetwork(this) + ) { + android.util.Log.e( + "BitchatApplication", + "Network startup blocked until panic wipe retry succeeds" + ) + return + } + // Initialize Tor first so any early network goes over Tor try { val torProvider = ArtiTorManager.getInstance() @@ -30,16 +40,16 @@ class BitchatApplication : Application() { 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) + val identity = + com.bitchat.android.nostr.NostrIdentityBridge + .getCurrentNostrIdentity(this) + ?: return@setNdrPeerRetirementGuard false + val ndr = com.bitchat.android.nostr.NdrNostrService.getInstance(this) + if (com.bitchat.android.model.NdrFeatureGate.isEnabled()) { ndr.configureIfNeeded(identity) ndr.retirePeer(oldPeerPubkeyHex) + } else { + ndr.retirePeerForMaintenance(identity, oldPeerPubkeyHex) } } } catch (_: Exception) { } diff --git a/app/src/main/java/com/bitchat/android/MainActivity.kt b/app/src/main/java/com/bitchat/android/MainActivity.kt index 2ae6bab4..31961e34 100644 --- a/app/src/main/java/com/bitchat/android/MainActivity.kt +++ b/app/src/main/java/com/bitchat/android/MainActivity.kt @@ -82,6 +82,12 @@ class MainActivity : OrientationAwareActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + if (!com.bitchat.android.nostr.NdrPanicStartupRecovery + .isNetworkStartupAllowed() + ) { + finishAndRemoveTask() + return + } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { this.setRecentsScreenshotEnabled(false) } 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 7756b144..d4138ea2 100644 --- a/app/src/main/java/com/bitchat/android/favorites/FavoritesPersistenceService.kt +++ b/app/src/main/java/com/bitchat/android/favorites/FavoritesPersistenceService.kt @@ -16,6 +16,7 @@ data class FavoriteRelationship( val peerNoisePublicKey: ByteArray, // Noise static public key (32 bytes) val peerNostrPublicKey: String?, // npub bech32 string val peerNdrSessionPubkeyHex: String? = null, + val ndrRequired: Boolean = false, val peerNickname: String, val isFavorite: Boolean, // We favorited them val theyFavoritedUs: Boolean, // They favorited us @@ -33,6 +34,7 @@ data class FavoriteRelationship( if (!peerNoisePublicKey.contentEquals(other.peerNoisePublicKey)) return false if (peerNostrPublicKey != other.peerNostrPublicKey) return false if (peerNdrSessionPubkeyHex != other.peerNdrSessionPubkeyHex) return false + if (ndrRequired != other.ndrRequired) return false if (peerNickname != other.peerNickname) return false if (isFavorite != other.isFavorite) return false if (theyFavoritedUs != other.theyFavoritedUs) return false @@ -44,6 +46,7 @@ data class FavoriteRelationship( var result = peerNoisePublicKey.contentHashCode() result = 31 * result + (peerNostrPublicKey?.hashCode() ?: 0) result = 31 * result + (peerNdrSessionPubkeyHex?.hashCode() ?: 0) + result = 31 * result + ndrRequired.hashCode() result = 31 * result + peerNickname.hashCode() result = 31 * result + isFavorite.hashCode() result = 31 * result + theyFavoritedUs.hashCode() @@ -72,8 +75,9 @@ class FavoritesPersistenceService private constructor( companion object { private const val TAG = "FavoritesPersistenceService" - private const val FAVORITES_KEY = "favorite_relationships" // noiseHex -> relationship - private const val PEERID_INDEX_KEY = "favorite_peerid_index" // peerID(16-hex) -> npub + internal const val FAVORITES_KEY = "favorite_relationships" + internal const val PEERID_INDEX_KEY = "favorite_peerid_index" + internal const val NDR_REBIND_JOURNAL_KEY = "favorite_ndr_rebind_v1" @Volatile private var INSTANCE: FavoritesPersistenceService? = null @@ -100,9 +104,13 @@ class FavoritesPersistenceService private constructor( private val listeners = mutableListOf() private var ndrPeerRetirementGuard: ((oldPeerPubkeyHex: String) -> Boolean)? = null private val ndrRebindsInProgress = mutableSetOf() + private var pendingNdrRebind: FavoriteNdrRebindJournal? = null + private var ndrRebindJournalCorrupt = false + private var favoritesStorageUnreadable = false init { loadFavorites() + loadNdrRebindJournal() loadPeerIdIndex() } @@ -110,21 +118,26 @@ class FavoritesPersistenceService private constructor( @Synchronized fun getFavoriteStatus(noisePublicKey: ByteArray): FavoriteRelationship? { val keyHex = ContactIdentityResolver.noiseKeyHex(noisePublicKey) - return favorites[keyHex] + return favoriteForReadLocked(keyHex) } /** Get favorite status for a mesh peer ID or full Noise public key hex. */ + @Synchronized fun getFavoriteStatus(peerID: String): FavoriteRelationship? { val pid = peerID.trim().lowercase() if (ContactIdentityResolver.isNoiseKeyHex(pid)) { - return favorites[pid] + return favoriteForReadLocked(pid) } ContactIdentityResolver.fingerprintFromContactConversationId(pid)?.let { fingerprint -> - return favorites.values.firstOrNull { relationship -> + return favorites.entries.firstNotNullOfOrNull { (keyHex, _) -> + val relationship = favoriteForReadLocked(keyHex) + ?: return@firstNotNullOfOrNull null ContactIdentityResolver.fingerprintHex(relationship.peerNoisePublicKey) .equals(fingerprint, ignoreCase = true) + .takeIf { it } + ?.let { relationship } } } @@ -132,8 +145,12 @@ class FavoritesPersistenceService private constructor( peerIdIndex[pid]?.let { indexedNpub -> findNoiseKey(indexedNpub)?.let { return getFavoriteStatus(it) } } - return favorites.values.firstOrNull { relationship -> - ContactIdentityResolver.peerIdForNoiseKey(relationship.peerNoisePublicKey) == pid + return favorites.entries.firstNotNullOfOrNull { (keyHex, _) -> + val relationship = favoriteForReadLocked(keyHex) + ?: return@firstNotNullOfOrNull null + relationship.takeIf { + ContactIdentityResolver.peerIdForNoiseKey(it.peerNoisePublicKey) == pid + } } } @@ -145,65 +162,58 @@ class FavoritesPersistenceService private constructor( val keyHex = ContactIdentityResolver.noiseKeyHex(noisePublicKey) 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 || + recoverPendingNdrRebind() + var journal: FavoriteNdrRebindJournal? = null + val committedWithoutRetirement = synchronized(this) { + if (ndrRebindJournalCorrupt || + favoritesStorageUnreadable || + pendingNdrRebind != null || + ndrRebindsInProgress.isNotEmpty() || 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) + val wasNdrRequired = existing?.ndrRequired == true || + existing?.peerNdrSessionPubkeyHex != null + val requiresRetirement = wasNdrRequired && isRebind + val updated = relationshipWithNostrIdentity( + existing = existing, + noisePublicKey = noisePublicKey, + normalizedNpub = normalizedNpub, + clearExplicitNdrPeer = requiresRetirement, + requireNdr = wasNdrRequired + ) + val mustRetire = requiresRetirement && + !isIdentityReferencedByAnotherFavorite(keyHex, requireNotNull(oldPeer)) if (!mustRetire) { - favorites[keyHex] = relationshipWithNostrIdentity( - existing = existing, - noisePublicKey = noisePublicKey, - normalizedNpub = normalizedNpub, - clearExplicitNdrPeer = isRebind - ) - saveFavorites() + commitFavoriteUpdateLocked(keyHex, updated) } else { - ndrRebindsInProgress.add(keyHex) - oldPeerPubkeyHex = oldPeer - originalNostrHex = existing.peerNostrPublicKey - ?.let(ContactIdentityResolver::nostrPubkeyHex) - originalNdrHex = existing.peerNdrSessionPubkeyHex + journal = FavoriteNdrRebindJournal( + noiseKeyHex = keyHex, + oldPeerPubkeyHex = requireNotNull(oldPeer), + expectedNostrPubkeyHex = existing.peerNostrPublicKey + ?.let(ContactIdentityResolver::nostrPubkeyHex), + expectedNdrSessionPubkeyHex = existing.peerNdrSessionPubkeyHex, + expectedNdrRequired = true, + targetNostrPubkeyHex = normalizedHex, + targetNdrSessionPubkeyHex = null, + targetNdrRequired = true, + retireOldPeer = true + ) + beginNdrRebindLocked(requireNotNull(journal)) + false } } - 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 + val pendingJournal = journal + val committed = if (pendingJournal == null) { + committedWithoutRetirement + } else { + completePendingNdrRebind(pendingJournal) } + if (!committed) return false notifyChanged(keyHex) Log.d(TAG, "Updated Nostr pubkey association for ${keyHex.take(16)}...") @@ -212,15 +222,25 @@ class FavoritesPersistenceService private constructor( /** Update Nostr pubkey for a specific mesh peerID. */ + @Synchronized fun updateNostrPublicKeyForPeerID(peerID: String, nostrPubkey: String) { + if (ndrRebindJournalCorrupt || + favoritesStorageUnreadable || + pendingNdrRebind != null + ) return val pid = peerID.trim().lowercase() val normalizedNpub = ContactIdentityResolver.nostrPubkeyHex(nostrPubkey) ?.let { ContactIdentityResolver.npubFromHex(it) } ?: nostrPubkey if (ContactIdentityResolver.isMeshPeerId(pid)) { - peerIdIndex[pid] = normalizedNpub - savePeerIdIndex() - Log.d(TAG, "Indexed npub for peerID ${pid.take(8)}…") + val snapshot = peerIdIndex.toMutableMap().apply { + this[pid] = normalizedNpub + } + if (commitPeerIdIndexSnapshotLocked(snapshot)) { + peerIdIndex.clear() + peerIdIndex.putAll(snapshot) + Log.d(TAG, "Indexed npub for peerID ${pid.take(8)}…") + } } else { Log.w(TAG, "updateNostrPublicKeyForPeerID called with non-16hex peerID: $peerID") } @@ -228,12 +248,14 @@ class FavoritesPersistenceService private constructor( /** Resolve Nostr pubkey via current peerID mapping or stored Noise identity. */ + @Synchronized fun findNostrPubkeyForPeerID(peerID: String): String? { val pid = peerID.trim().lowercase() return peerIdIndex[pid] ?: getFavoriteStatus(pid)?.peerNostrPublicKey } /** Resolve mesh peerID for a given Nostr pubkey (npub or hex). */ + @Synchronized fun findPeerIDForNostrPubkey(nostrPubkey: String): String? { val targetHex = ContactIdentityResolver.nostrPubkeyHex(nostrPubkey) ?: return null @@ -241,9 +263,14 @@ class FavoritesPersistenceService private constructor( ContactIdentityResolver.nostrPubkeyHex(stored) == targetHex }?.let { return it.key } - favorites.values.firstOrNull { relationship -> - relationship.peerNostrPublicKey?.let { ContactIdentityResolver.nostrPubkeyHex(it) } == targetHex || - relationship.peerNdrSessionPubkeyHex == targetHex + favorites.entries.firstNotNullOfOrNull { (keyHex, _) -> + val relationship = favoriteForReadLocked(keyHex) + ?: return@firstNotNullOfOrNull null + relationship.takeIf { + it.peerNostrPublicKey + ?.let(ContactIdentityResolver::nostrPubkeyHex) == targetHex || + it.peerNdrSessionPubkeyHex == targetHex + } }?.let { relationship -> return ContactIdentityResolver.peerIdForNoiseKey(relationship.peerNoisePublicKey) } @@ -255,6 +282,10 @@ class FavoritesPersistenceService private constructor( @Synchronized fun updateFavoriteStatus(noisePublicKey: ByteArray, nickname: String, isFavorite: Boolean) { val keyHex = ContactIdentityResolver.noiseKeyHex(noisePublicKey) + if (ndrRebindJournalCorrupt || + favoritesStorageUnreadable || + pendingNdrRebind?.noiseKeyHex == keyHex + ) return val existing = favorites[keyHex] @@ -277,8 +308,7 @@ class FavoritesPersistenceService private constructor( ) } - favorites[keyHex] = updated - saveFavorites() + if (!commitFavoriteUpdateLocked(keyHex, updated)) return notifyChanged(keyHex) Log.d(TAG, "Updated favorite status for $nickname: $isFavorite") @@ -288,6 +318,10 @@ class FavoritesPersistenceService private constructor( @Synchronized fun updatePeerFavoritedUs(noisePublicKey: ByteArray, theyFavoritedUs: Boolean) { val keyHex = ContactIdentityResolver.noiseKeyHex(noisePublicKey) + if (ndrRebindJournalCorrupt || + favoritesStorageUnreadable || + pendingNdrRebind?.noiseKeyHex == keyHex + ) return val existing = favorites[keyHex] if (existing != null) { @@ -295,119 +329,401 @@ class FavoritesPersistenceService private constructor( theyFavoritedUs = theyFavoritedUs, lastUpdated = Date() ) - favorites[keyHex] = updated - saveFavorites() + if (!commitFavoriteUpdateLocked(keyHex, updated)) return notifyChanged(keyHex) Log.d(TAG, "Updated peer favorited us for ${keyHex.take(16)}...: $theyFavoritedUs") } } - fun getMutualFavorites(): List = favorites.values.filter { it.isMutual } - fun getOurFavorites(): List = favorites.values.filter { it.isFavorite } - fun getAllRelationships(): List = favorites.values.toList() + @Synchronized + fun getMutualFavorites(): List = + favoritesForReadLocked().filter { it.isMutual } @Synchronized - fun clearAllFavorites() { + fun getOurFavorites(): List = + favoritesForReadLocked().filter { it.isFavorite } + + @Synchronized + fun getAllRelationships(): List = favoritesForReadLocked() + + /** + * Clear contact bindings only after the caller has durably wiped native NDR state. + */ + @Synchronized + fun clearAllFavoritesAfterNdrReset(): Boolean { + val committed = runCatching { + stateManager.commitSecureValuesSynchronously( + removals = setOf( + FAVORITES_KEY, + PEERID_INDEX_KEY, + NDR_REBIND_JOURNAL_KEY + ) + ) + }.getOrDefault(false) + if (!committed) return false + favorites.clear() - saveFavorites() peerIdIndex.clear() - savePeerIdIndex() + pendingNdrRebind = null + ndrRebindsInProgress.clear() + ndrRebindJournalCorrupt = false + favoritesStorageUnreadable = false Log.i(TAG, "Cleared all favorites") notifyAllCleared() + return true } /** Find Noise key by Nostr pubkey */ + @Synchronized fun findNoiseKey(forNostrPubkey: String): ByteArray? { val targetHex = ContactIdentityResolver.nostrPubkeyHex(forNostrPubkey) ?: return null - return favorites.values.firstOrNull { rel -> - rel.peerNostrPublicKey?.let { stored -> ContactIdentityResolver.nostrPubkeyHex(stored) } == targetHex || - rel.peerNdrSessionPubkeyHex == targetHex - }?.peerNoisePublicKey + return favorites.entries.firstNotNullOfOrNull { (keyHex, _) -> + val relationship = favoriteForReadLocked(keyHex) + ?: return@firstNotNullOfOrNull null + relationship.peerNoisePublicKey.takeIf { + relationship.peerNostrPublicKey + ?.let(ContactIdentityResolver::nostrPubkeyHex) == targetHex || + relationship.peerNdrSessionPubkeyHex == targetHex + } + } } /** Find Nostr pubkey by Noise key */ + @Synchronized fun findNostrPubkey(forNoiseKey: ByteArray): String? { val keyHex = ContactIdentityResolver.noiseKeyHex(forNoiseKey) - return favorites[keyHex]?.peerNostrPublicKey + return favoriteForReadLocked(keyHex)?.peerNostrPublicKey } /** Persist the owner pubkey used to look up this peer's ratchet session. */ fun updateNdrSessionPubkeyHex(noisePublicKey: ByteArray, peerPubkeyHex: String): Boolean { val normalized = ContactIdentityResolver.nostrPubkeyHex(peerPubkeyHex) ?: return false val keyHex = ContactIdentityResolver.noiseKeyHex(noisePublicKey) - var oldPeerPubkeyHex: String? = null - var originalNostrHex: String? = null - var originalNdrHex: String? = null - synchronized(this) { - if (keyHex in ndrRebindsInProgress || + recoverPendingNdrRebind() + var journal: FavoriteNdrRebindJournal? = null + val committedWithoutRetirement = synchronized(this) { + if (ndrRebindJournalCorrupt || + favoritesStorageUnreadable || + pendingNdrRebind != null || + ndrRebindsInProgress.isNotEmpty() || isIdentityBoundToAnotherFavorite(keyHex, normalized) ) return false val existing = favorites[keyHex] ?: return false - if (existing.peerNdrSessionPubkeyHex == normalized) return true + if (existing.peerNdrSessionPubkeyHex == normalized && existing.ndrRequired) { + 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() + val wasNdrRequired = existing.ndrRequired || + existing.peerNdrSessionPubkeyHex != null + val requiresRetirement = wasNdrRequired && isRebind + val updated = existing.copy( + peerNdrSessionPubkeyHex = normalized, + ndrRequired = true, + lastUpdated = Date() + ) + val mustRetire = requiresRetirement && + !isIdentityReferencedByAnotherFavorite(keyHex, requireNotNull(oldPeer)) + val needsDurablePin = !existing.ndrRequired + if (!mustRetire && (!needsDurablePin || oldPeer == null)) { + val committed = commitFavoriteUpdateLocked(keyHex, updated) + if (!committed && needsDurablePin) { + favoritesStorageUnreadable = true + } + committed } else { - ndrRebindsInProgress.add(keyHex) - oldPeerPubkeyHex = oldPeer - originalNostrHex = existing.peerNostrPublicKey - ?.let(ContactIdentityResolver::nostrPubkeyHex) - originalNdrHex = existing.peerNdrSessionPubkeyHex + journal = FavoriteNdrRebindJournal( + noiseKeyHex = keyHex, + oldPeerPubkeyHex = requireNotNull(oldPeer), + expectedNostrPubkeyHex = existing.peerNostrPublicKey + ?.let(ContactIdentityResolver::nostrPubkeyHex), + expectedNdrSessionPubkeyHex = existing.peerNdrSessionPubkeyHex, + expectedNdrRequired = existing.ndrRequired, + targetNostrPubkeyHex = existing.peerNostrPublicKey + ?.let(ContactIdentityResolver::nostrPubkeyHex), + targetNdrSessionPubkeyHex = normalized, + targetNdrRequired = true, + retireOldPeer = mustRetire + ) + beginNdrRebindLocked(requireNotNull(journal)) + false } } - 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 + val pendingJournal = journal + val committed = if (pendingJournal == null) { + committedWithoutRetirement + } else { + completePendingNdrRebind(pendingJournal) } + if (!committed) return false notifyChanged(keyHex) return true } /** Resolve the best ratchet-session lookup key for this Noise identity. */ + @Synchronized fun findNdrSessionPubkeyHex(forNoiseKey: ByteArray): String? { val keyHex = ContactIdentityResolver.noiseKeyHex(forNoiseKey) + pendingNdrRebind + ?.takeIf { it.noiseKeyHex == keyHex } + ?.targetEffectivePeerPubkeyHex() + ?.let { return it } val relationship = favorites[keyHex] ?: return null return relationship.peerNdrSessionPubkeyHex ?: relationship.peerNostrPublicKey?.let(ContactIdentityResolver::nostrPubkeyHex) } + @Synchronized + fun isNdrRequired(forNoiseKey: ByteArray): Boolean { + val keyHex = ContactIdentityResolver.noiseKeyHex(forNoiseKey) + return favorites[keyHex]?.ndrRequired == true || + pendingNdrRebind?.noiseKeyHex == keyHex + } + + @Synchronized + fun isNdrRebindBlocked(forNoiseKey: ByteArray): Boolean { + val keyHex = ContactIdentityResolver.noiseKeyHex(forNoiseKey) + return ndrRebindJournalCorrupt || + favoritesStorageUnreadable || + pendingNdrRebind?.noiseKeyHex == keyHex + } + + @Synchronized + fun isNdrProtectionStateReadable(): Boolean = + !ndrRebindJournalCorrupt && !favoritesStorageUnreadable + + /** + * Stored-only binding used for authenticated NDR routes. + * + * A journal target is not authorized until the target favorite itself is durable. + */ + @Synchronized + fun getStoredFavoriteForNdrRoute( + noisePublicKey: ByteArray + ): FavoriteRelationship? { + if (ndrRebindJournalCorrupt || favoritesStorageUnreadable) return null + val keyHex = ContactIdentityResolver.noiseKeyHex(noisePublicKey) + val relationship = favorites[keyHex] ?: return null + val journal = pendingNdrRebind + return if (journal?.noiseKeyHex == keyHex) { + relationship.takeIf(journal::matchesTargetBinding) + } else { + relationship + } + } + + /** + * Accept relay-delivered NDR content only from a currently bound, unquarantined peer. + */ + @Synchronized + fun isCurrentNdrPeerAuthorized(peerPubkeyHex: String): Boolean { + val normalized = ContactIdentityResolver.nostrPubkeyHex(peerPubkeyHex) ?: return false + if (ndrRebindJournalCorrupt || favoritesStorageUnreadable) return false + return favorites.entries.any { (keyHex, _) -> + val noiseKey = favorites[keyHex]?.peerNoisePublicKey + ?: return@any false + getStoredFavoriteForNdrRoute(noiseKey) + ?.let(::effectiveNdrPeerPubkeyHex) + ?.equals(normalized, ignoreCase = true) == true + } + } + + /** + * Legacy kind-1059 is allowed only before a contact has a durable NDR pin. + */ + @Synchronized + fun isLegacyNostrInboundAllowed(peerPubkeyHex: String): Boolean { + val normalized = ContactIdentityResolver.nostrPubkeyHex(peerPubkeyHex) ?: return false + if (ndrRebindJournalCorrupt || favoritesStorageUnreadable) return false + val journal = pendingNdrRebind + if (journal != null && normalized in journal.quarantinedIdentityPubkeys()) { + return false + } + return favorites.entries.none { (keyHex, relationship) -> + keyHex != journal?.noiseKeyHex && + relationship.ndrRequired && + relationshipReferencesIdentity(relationship, normalized) + } + } + // MARK: - Persistence + private fun favoriteForReadLocked(keyHex: String): FavoriteRelationship? { + val relationship = favorites[keyHex] ?: return null + val journal = pendingNdrRebind + return if (journal?.noiseKeyHex == keyHex) { + journal.applyTarget(relationship) + } else { + relationship + } + } + + private fun favoritesForReadLocked(): List = + favorites.keys.mapNotNull(::favoriteForReadLocked) + + private fun commitFavoriteUpdateLocked( + keyHex: String, + relationship: FavoriteRelationship + ): Boolean { + val snapshot = favorites.toMutableMap() + snapshot[keyHex] = relationship + if (!commitFavoritesSnapshotLocked(snapshot)) return false + favorites.clear() + favorites.putAll(snapshot) + return true + } + + private fun commitFavoritesSnapshotLocked( + snapshot: Map + ): Boolean = runCatching { + stateManager.commitSecureValuesSynchronously( + values = mapOf(FAVORITES_KEY to favoritesJson(snapshot)) + ) + }.getOrDefault(false) + + private fun commitPeerIdIndexSnapshotLocked( + snapshot: Map + ): Boolean = runCatching { + stateManager.commitSecureValuesSynchronously( + values = mapOf(PEERID_INDEX_KEY to gson.toJson(snapshot)) + ) + }.getOrDefault(false) + + private fun favoritesJson( + snapshot: Map + ): String { + val data = snapshot.mapValues { (_, relationship) -> + FavoriteRelationshipData.fromFavoriteRelationship(relationship) + } + return gson.toJson(data) + } + + private fun beginNdrRebindLocked(journal: FavoriteNdrRebindJournal): Boolean { + if (pendingNdrRebind != null || + ndrRebindJournalCorrupt || + favoritesStorageUnreadable + ) return false + if (!journal.isValid()) return false + val journalJson = gson.toJson(journal) + val committed = runCatching { + stateManager.commitSecureValuesSynchronously( + values = mapOf(NDR_REBIND_JOURNAL_KEY to journalJson) + ) + }.getOrDefault(false) + if (!committed) { + favoritesStorageUnreadable = true + return false + } + pendingNdrRebind = journal + ndrRebindsInProgress.add(journal.noiseKeyHex) + return true + } + + private fun completePendingNdrRebind( + expectedJournal: FavoriteNdrRebindJournal + ): Boolean { + val ownsCurrentBinding = synchronized(this) { + val journal = pendingNdrRebind + if (journal != expectedJournal || + ndrRebindJournalCorrupt || + favoritesStorageUnreadable + ) { + false + } else { + val current = favorites[journal.noiseKeyHex] + val currentMatchesExpected = current != null && + journal.matchesExpectedBinding(current) + val currentMatchesTarget = current != null && + journal.matchesTargetBinding(current) + val targetCollides = journal.targetIdentityPubkeys().any { target -> + isIdentityBoundToAnotherFavorite(journal.noiseKeyHex, target) + } + if (current == null || + (!currentMatchesExpected && !currentMatchesTarget) || + targetCollides + ) { + ndrRebindJournalCorrupt = true + false + } else { + true + } + } + } + if (!ownsCurrentBinding) return false + + if (expectedJournal.retireOldPeer && + !retireBeforeRebind(expectedJournal.oldPeerPubkeyHex) + ) { + Log.e(TAG, "NDR rebind remains quarantined because old peer retirement failed") + return false + } + + return synchronized(this) { + if (pendingNdrRebind != expectedJournal || + ndrRebindJournalCorrupt || + favoritesStorageUnreadable + ) { + false + } else { + val current = favorites[expectedJournal.noiseKeyHex] + val currentMatchesExpected = current != null && + expectedJournal.matchesExpectedBinding(current) + val currentMatchesTarget = current != null && + expectedJournal.matchesTargetBinding(current) + val targetCollides = expectedJournal.targetIdentityPubkeys().any { target -> + isIdentityBoundToAnotherFavorite( + expectedJournal.noiseKeyHex, + target + ) + } + if (current == null || + (!currentMatchesExpected && !currentMatchesTarget) || + targetCollides + ) { + ndrRebindJournalCorrupt = true + false + } else { + val targetCommitted = currentMatchesTarget || + commitFavoriteUpdateLocked( + expectedJournal.noiseKeyHex, + expectedJournal.applyTarget(current) + ) + val targetVerified = targetCommitted && + favorites[expectedJournal.noiseKeyHex] + ?.let(expectedJournal::matchesTargetBinding) == true + if (!targetVerified) { + false + } else { + val cleared = runCatching { + stateManager.commitSecureValuesSynchronously( + removals = setOf(NDR_REBIND_JOURNAL_KEY) + ) + }.getOrDefault(false) + if (cleared) { + pendingNdrRebind = null + ndrRebindsInProgress.remove(expectedJournal.noiseKeyHex) + } + cleared + } + } + } + } + } + + private fun recoverPendingNdrRebind(): Boolean { + val journal = synchronized(this) { + if (ndrRebindJournalCorrupt || favoritesStorageUnreadable) return false + pendingNdrRebind + } ?: return true + val recovered = completePendingNdrRebind(journal) + if (recovered) notifyChanged(journal.noiseKeyHex) + return recovered + } + private fun loadFavorites() { try { val favoritesJson = stateManager.getSecureValue(FAVORITES_KEY) @@ -416,27 +732,55 @@ class FavoritesPersistenceService private constructor( val data: Map = gson.fromJson(favoritesJson, type) favorites.clear() + var needsNdrPinMigration = false data.forEach { (key, relationshipData) -> - favorites[key] = relationshipData.toFavoriteRelationship() + val relationship = relationshipData.toFavoriteRelationship() + favorites[key] = relationship + val storedNdrPeer = relationshipData.peerNdrSessionPubkeyHex + if (storedNdrPeer != null && + relationship.peerNdrSessionPubkeyHex == null + ) { + favoritesStorageUnreadable = true + } else if (storedNdrPeer != null && + (!relationshipData.ndrRequired || + storedNdrPeer != relationship.peerNdrSessionPubkeyHex) + ) { + needsNdrPinMigration = true + } + } + if (needsNdrPinMigration && + !commitFavoritesSnapshotLocked(favorites) + ) { + favoritesStorageUnreadable = true + Log.e(TAG, "Failed to durably migrate existing NDR downgrade pins") } Log.d(TAG, "Loaded ${favorites.size} favorite relationships") } } catch (e: Exception) { + favoritesStorageUnreadable = true Log.e(TAG, "Failed to load favorites: ${e.message}") } } - private fun saveFavorites() { - try { - val data = favorites.mapValues { (_, relationship) -> - FavoriteRelationshipData.fromFavoriteRelationship(relationship) - } - val favoritesJson = gson.toJson(data) - stateManager.storeSecureValue(FAVORITES_KEY, favoritesJson) - Log.d(TAG, "Saved ${favorites.size} favorite relationships") - } catch (e: Exception) { - Log.e(TAG, "Failed to save favorites: ${e.message}") + private fun loadNdrRebindJournal() { + val journalJson = runCatching { + stateManager.getSecureValue(NDR_REBIND_JOURNAL_KEY) + }.getOrElse { + ndrRebindJournalCorrupt = true + Log.e(TAG, "Failed to read NDR rebind journal") + return + } ?: return + + val journal = runCatching { + gson.fromJson(journalJson, FavoriteNdrRebindJournal::class.java) + }.getOrNull() + if (journal == null || !journal.isValid()) { + ndrRebindJournalCorrupt = true + Log.e(TAG, "Invalid NDR rebind journal; keeping transport fail-closed") + return } + pendingNdrRebind = journal + ndrRebindsInProgress.add(journal.noiseKeyHex) } private fun loadPeerIdIndex() { @@ -459,16 +803,6 @@ class FavoritesPersistenceService private constructor( } } - private fun savePeerIdIndex() { - try { - val json = gson.toJson(peerIdIndex) - stateManager.storeSecureValue(PEERID_INDEX_KEY, json) - Log.d(TAG, "Saved ${peerIdIndex.size} peerID→npub mappings") - } catch (e: Exception) { - Log.e(TAG, "Failed to save peerID index: ${e.message}") - } - } - // MARK: - Listeners fun addListener(listener: FavoritesChangeListener) { synchronized(listeners) { if (!listeners.contains(listener)) listeners.add(listener) } @@ -477,11 +811,13 @@ class FavoritesPersistenceService private constructor( synchronized(listeners) { listeners.remove(listener) } } - @Synchronized fun setNdrPeerRetirementGuard( guard: ((oldPeerPubkeyHex: String) -> Boolean)? ) { - ndrPeerRetirementGuard = guard + synchronized(this) { + ndrPeerRetirementGuard = guard + } + if (guard != null) recoverPendingNdrRebind() } private fun effectiveNdrPeerPubkeyHex( @@ -493,15 +829,18 @@ class FavoritesPersistenceService private constructor( existing: FavoriteRelationship?, noisePublicKey: ByteArray, normalizedNpub: String, - clearExplicitNdrPeer: Boolean + clearExplicitNdrPeer: Boolean, + requireNdr: Boolean ): FavoriteRelationship = existing?.copy( peerNostrPublicKey = normalizedNpub, peerNdrSessionPubkeyHex = if (clearExplicitNdrPeer) null else existing.peerNdrSessionPubkeyHex, + ndrRequired = requireNdr, lastUpdated = Date() ) ?: FavoriteRelationship( peerNoisePublicKey = noisePublicKey, peerNostrPublicKey = normalizedNpub, + ndrRequired = requireNdr, peerNickname = "Unknown", isFavorite = false, theyFavoritedUs = false, @@ -532,10 +871,10 @@ class FavoritesPersistenceService private constructor( ?.let(ContactIdentityResolver::nostrPubkeyHex) ?.equals(peerPubkeyHex, ignoreCase = true) == true - private fun retireBeforeRebind(oldPeerPubkeyHex: String): Boolean = - runCatching { - ndrPeerRetirementGuard?.invoke(oldPeerPubkeyHex) == true - }.getOrDefault(false) + private fun retireBeforeRebind(oldPeerPubkeyHex: String): Boolean { + val guard = synchronized(this) { ndrPeerRetirementGuard } ?: return false + return runCatching { guard(oldPeerPubkeyHex) }.getOrDefault(false) + } private fun notifyChanged(noiseKeyHex: String) { runCatching { AppStateStore.canonicalizePrivateChats() } @@ -553,6 +892,7 @@ private data class FavoriteRelationshipData( val peerNoisePublicKeyHex: String, val peerNostrPublicKey: String?, val peerNdrSessionPubkeyHex: String? = null, + val ndrRequired: Boolean = false, val peerNickname: String, val isFavorite: Boolean, val theyFavoritedUs: Boolean, @@ -565,6 +905,7 @@ private data class FavoriteRelationshipData( peerNoisePublicKeyHex = ContactIdentityResolver.noiseKeyHex(relationship.peerNoisePublicKey), peerNostrPublicKey = relationship.peerNostrPublicKey, peerNdrSessionPubkeyHex = relationship.peerNdrSessionPubkeyHex, + ndrRequired = relationship.ndrRequired, peerNickname = relationship.peerNickname, isFavorite = relationship.isFavorite, theyFavoritedUs = relationship.theyFavoritedUs, @@ -576,10 +917,13 @@ private data class FavoriteRelationshipData( fun toFavoriteRelationship(): FavoriteRelationship { val noiseKeyBytes = ContactIdentityResolver.bytesFromHex(peerNoisePublicKeyHex) ?: ByteArray(0) + val normalizedNdrSessionPubkeyHex = peerNdrSessionPubkeyHex + ?.let(ContactIdentityResolver::nostrPubkeyHex) return FavoriteRelationship( peerNoisePublicKey = noiseKeyBytes, peerNostrPublicKey = peerNostrPublicKey, - peerNdrSessionPubkeyHex = peerNdrSessionPubkeyHex, + peerNdrSessionPubkeyHex = normalizedNdrSessionPubkeyHex, + ndrRequired = ndrRequired || normalizedNdrSessionPubkeyHex != null, peerNickname = peerNickname, isFavorite = isFavorite, theyFavoritedUs = theyFavoritedUs, @@ -588,3 +932,83 @@ private data class FavoriteRelationshipData( ) } } + +private data class FavoriteNdrRebindJournal( + val version: Int = 1, + val noiseKeyHex: String, + val oldPeerPubkeyHex: String, + val expectedNostrPubkeyHex: String?, + val expectedNdrSessionPubkeyHex: String?, + val expectedNdrRequired: Boolean, + val targetNostrPubkeyHex: String?, + val targetNdrSessionPubkeyHex: String?, + val targetNdrRequired: Boolean, + val retireOldPeer: Boolean +) { + fun isValid(): Boolean { + val isPubkey: (String) -> Boolean = { + it.length == 64 && it.all { character -> character in "0123456789abcdef" } + } + return version == 1 && + isPubkey(noiseKeyHex) && + isPubkey(oldPeerPubkeyHex) && + listOfNotNull( + expectedNostrPubkeyHex, + expectedNdrSessionPubkeyHex, + targetNostrPubkeyHex, + targetNdrSessionPubkeyHex + ).all(isPubkey) && + expectedEffectivePeerPubkeyHex() == oldPeerPubkeyHex && + targetEffectivePeerPubkeyHex() != null && + targetNdrRequired && + (if (retireOldPeer) { + (expectedNdrRequired || expectedNdrSessionPubkeyHex != null) && + targetEffectivePeerPubkeyHex() != oldPeerPubkeyHex + } else { + !expectedNdrRequired && targetNdrSessionPubkeyHex != null + }) + } + + private fun expectedEffectivePeerPubkeyHex(): String? = + expectedNdrSessionPubkeyHex ?: expectedNostrPubkeyHex + + fun targetEffectivePeerPubkeyHex(): String? = + targetNdrSessionPubkeyHex ?: targetNostrPubkeyHex + + fun targetIdentityPubkeys(): Set = + listOfNotNull(targetNostrPubkeyHex, targetNdrSessionPubkeyHex).toSet() + + fun quarantinedIdentityPubkeys(): Set = + buildSet { + add(oldPeerPubkeyHex) + addAll( + listOfNotNull( + expectedNostrPubkeyHex, + expectedNdrSessionPubkeyHex, + targetNostrPubkeyHex, + targetNdrSessionPubkeyHex + ) + ) + } + + fun matchesExpectedBinding(relationship: FavoriteRelationship): Boolean = + relationship.peerNostrPublicKey + ?.let(ContactIdentityResolver::nostrPubkeyHex) == expectedNostrPubkeyHex && + relationship.peerNdrSessionPubkeyHex == expectedNdrSessionPubkeyHex && + relationship.ndrRequired == expectedNdrRequired + + fun matchesTargetBinding(relationship: FavoriteRelationship): Boolean = + relationship.peerNostrPublicKey + ?.let(ContactIdentityResolver::nostrPubkeyHex) == targetNostrPubkeyHex && + relationship.peerNdrSessionPubkeyHex == targetNdrSessionPubkeyHex && + relationship.ndrRequired == targetNdrRequired + + fun applyTarget(relationship: FavoriteRelationship): FavoriteRelationship = + relationship.copy( + peerNostrPublicKey = targetNostrPubkeyHex + ?.let(ContactIdentityResolver::npubFromHex), + peerNdrSessionPubkeyHex = targetNdrSessionPubkeyHex, + ndrRequired = targetNdrRequired, + lastUpdated = Date() + ) +} 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 fdd309d4..b2dc8ba0 100644 --- a/app/src/main/java/com/bitchat/android/identity/SecureIdentityStateManager.kt +++ b/app/src/main/java/com/bitchat/android/identity/SecureIdentityStateManager.kt @@ -37,6 +37,11 @@ class SecureIdentityStateManager { private const val KEY_CACHED_FINGERPRINT_NICKNAMES = "cached_fingerprint_nicknames" private const val KEY_PRIVATE_MEDIA_CAPABILITY_PINS = "private_media_capability_pins_v1" private const val KEY_AUTHENTICATED_PEER_STATES = "authenticated_peer_states_v1" + private val NDR_PROTECTION_KEYS = setOf( + "favorite_relationships", + "favorite_peerid_index", + "favorite_ndr_rebind_v1" + ) // BLE, Wi-Fi Aware, and Noise services each hold their own manager // instance over the same encrypted preferences. Serialize pin updates @@ -466,18 +471,38 @@ class SecureIdentityStateManager { * Clear all identity data (for panic mode) */ @SuppressLint("UseKtx") - fun clearIdentityData() { - try { - synchronized(privateMediaPinsLock) { + fun clearIdentityData(): Boolean { + return try { + val cleared = synchronized(privateMediaPinsLock) { privateMediaPinsEpoch += 1 privateMediaPinsEpochAtCreation = privateMediaPinsEpoch - if (!prefs.edit().clear().commit()) { - Log.e(TAG, "Identity preference wipe could not be committed") + synchronized(lock) { + val protectedValues = NDR_PROTECTION_KEYS.mapNotNull { key -> + prefs.getString(key, null)?.let { value -> key to value } + }.toMap() + val editor = prefs.edit().clear() + protectedValues.forEach { (key, value) -> + editor.putString(key, value) + } + val committed = editor.commit() + val storedValues = prefs.all + val wipeVerified = + storedValues.keys == protectedValues.keys && + protectedValues.all { (key, value) -> + storedValues[key] == value + } + committed && wipeVerified } } - Log.w(TAG, "All identity data cleared") + if (cleared) { + Log.w(TAG, "All identity data cleared") + } else { + Log.e(TAG, "Identity preference wipe could not be committed safely") + } + cleared } catch (e: Exception) { Log.e(TAG, "Failed to clear identity data: ${e.message}") + false } } @@ -501,7 +526,23 @@ class SecureIdentityStateManager { * Durably store a value before acknowledging an external operation. */ fun storeSecureValueSynchronously(key: String, value: String): Boolean { - return prefs.edit().putString(key, value).commit() + return commitSecureValuesSynchronously(mapOf(key to value)) + } + + /** + * Atomically commit and read back a set of secure string mutations. + */ + fun commitSecureValuesSynchronously( + values: Map = emptyMap(), + removals: Set = emptySet() + ): Boolean = synchronized(lock) { + if (values.keys.any { it in removals }) return@synchronized false + val editor = prefs.edit() + values.forEach { (key, value) -> editor.putString(key, value) } + removals.forEach(editor::remove) + if (!editor.commit()) return@synchronized false + values.all { (key, value) -> prefs.getString(key, null) == value } && + removals.none(prefs::contains) } /** diff --git a/app/src/main/java/com/bitchat/android/nostr/NdrEstablishedSessionMarkerStore.kt b/app/src/main/java/com/bitchat/android/nostr/NdrEstablishedSessionMarkerStore.kt index 946b8a70..a0dadc36 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NdrEstablishedSessionMarkerStore.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NdrEstablishedSessionMarkerStore.kt @@ -3,11 +3,20 @@ package com.bitchat.android.nostr import java.io.File import java.io.FileOutputStream import java.io.IOException +import java.nio.channels.FileChannel +import java.nio.file.Files +import java.nio.file.LinkOption +import java.nio.file.NoSuchFileException +import java.nio.file.StandardOpenOption +import java.nio.file.attribute.BasicFileAttributes interface NdrEstablishedSessionMarkerStore { fun contains(accountPubkeyHex: String): Boolean fun mark(accountPubkeyHex: String) - fun clearAll() + fun clearEstablishedSessions() + fun isPanicWipeRequired(): Boolean + fun markPanicWipeRequired() + fun clearPanicWipeRequired() } /** @@ -20,32 +29,99 @@ internal class FileNdrEstablishedSessionMarkerStore( private val directory: File ) : NdrEstablishedSessionMarkerStore { override fun contains(accountPubkeyHex: String): Boolean = - markerFile(accountPubkeyHex).isFile + markerExists(markerFile(accountPubkeyHex)) override fun mark(accountPubkeyHex: String) { check(NdrInputPolicy.isPubkeyHex(accountPubkeyHex)) - if (!directory.exists() && !directory.mkdirs()) { - throw IOException("Failed to create NDR marker directory") + publishMarker(markerFile(accountPubkeyHex), "pairwise-v1\n") + } + + override fun clearEstablishedSessions() { + if (!directoryExists()) return + val entries = directory.listFiles() + ?: throw IOException("Failed to inspect NDR marker directory") + entries.filter { it.name.endsWith(".established") } + .forEach { marker -> + if (!markerExists(marker) || + !marker.delete() || + markerExists(marker) + ) { + throw IOException("Failed to clear NDR downgrade marker") + } + } + syncDirectory() + } + + override fun isPanicWipeRequired(): Boolean = + markerExists(panicWipeMarker()) + + override fun markPanicWipeRequired() { + publishMarker(panicWipeMarker(), "panic-wipe-required-v1\n") + } + + override fun clearPanicWipeRequired() { + val marker = panicWipeMarker() + if (markerExists(marker) && (!marker.delete() || markerExists(marker))) { + throw IOException("Failed to clear NDR panic marker") } - 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") + if (directoryExists()) { + syncDirectory() } } - override fun clearAll() { - if (directory.exists() && !directory.deleteRecursively()) { - throw IOException("Failed to clear NDR downgrade markers") + private fun publishMarker(marker: File, content: String) { + if (!directoryExists() && !directory.mkdirs()) { + throw IOException("Failed to create NDR marker directory") + } + if (markerExists(marker)) return + val temporary = File(directory, ".${marker.name}.tmp") + FileOutputStream(temporary).use { output -> + output.write(content.toByteArray(Charsets.UTF_8)) + output.fd.sync() + } + if (!temporary.renameTo(marker) || !markerExists(marker)) { + temporary.delete() + throw IOException("Failed to publish NDR marker") + } + syncDirectory() + } + + private fun directoryExists(): Boolean = + readAttributesOrNull(directory)?.let { attributes -> + if (!attributes.isDirectory) { + throw IOException("NDR marker path is not a directory") + } + true + } ?: false + + private fun markerExists(marker: File): Boolean = + readAttributesOrNull(marker)?.let { attributes -> + if (!attributes.isRegularFile) { + throw IOException("NDR marker path is not a regular file") + } + true + } ?: false + + private fun readAttributesOrNull(file: File): BasicFileAttributes? = + try { + Files.readAttributes( + file.toPath(), + BasicFileAttributes::class.java, + LinkOption.NOFOLLOW_LINKS + ) + } catch (_: NoSuchFileException) { + null + } + + private fun syncDirectory() { + FileChannel.open(directory.toPath(), StandardOpenOption.READ).use { channel -> + channel.force(true) } } private fun markerFile(accountPubkeyHex: String): File = File(directory, "${accountPubkeyHex.lowercase()}.established") + + private fun panicWipeMarker(): File = + File(directory, "panic-wipe-required") } 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 ca4feae6..92f788d5 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NdrNostrService.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NdrNostrService.kt @@ -66,6 +66,8 @@ class NdrNostrService( requireNotNull(java.io.File(storageDirectoryProvider()).parentFile) .resolve("ndr-established-sessions") ), + private val panicStorageQuarantine: NdrPanicStorageQuarantine = + FileNdrPanicStorageQuarantine(java.io.File(storageDirectoryProvider())), private val pairwiseStateExists: (String) -> Boolean = Companion::pairwiseStateExists, private val invitePeerResolver: (String) -> String? = Companion::resolvePairwiseInvitePubkeyHex, private val retryScheduler: NdrRetryScheduler = Companion.DEFAULT_RETRY_SCHEDULER, @@ -203,7 +205,10 @@ class NdrNostrService( private var configurationFailurePubkeyHex: String? = null @Volatile - private var panicResetBlocked = false + private var panicResetBlocked = runCatching { + establishedSessionMarkers.isPanicWipeRequired() || + panicStorageQuarantine.isPending() + }.getOrDefault(true) private val activeSubIds = linkedSetOf() private val inFlightRelayEventsByActionId = linkedMapOf() @@ -227,6 +232,10 @@ class NdrNostrService( val isConfigured: Boolean get() = NdrFeatureGate.isEnabled() && pairwiseRuntime != null + @get:Synchronized + val isPanicWipeRequired: Boolean + get() = panicResetBlocked + @Synchronized fun currentInviteEventJson(): String? { if (!NdrFeatureGate.isEnabled()) return null @@ -240,20 +249,30 @@ class NdrNostrService( configurationFailurePubkeyHex = null return } + configureRuntimeIfNeededLocked(identity, drainPendingActions = true) + } + + private fun configureRuntimeIfNeededLocked( + identity: NostrIdentity, + drainPendingActions: Boolean + ): Boolean { if (panicResetBlocked) { Log.e(TAG, "Refusing to configure NDR after an incomplete panic wipe") - return + return false } val pubkeyHex = identity.publicKeyHex.lowercase() if (configurationFailurePubkeyHex == pubkeyHex) { Log.e(TAG, "Refusing to reopen failed NDR storage before reset or identity change") - return + return false } if (configurationFailurePubkeyHex != null) { configurationFailurePubkeyHex = null } if (configuredForPubkeyHex == pubkeyHex && pairwiseRuntime != null) { - return + if (drainPendingActions) { + drainAndApplyPubSubEventsLocked() + } + return true } teardownLocked() @@ -278,12 +297,18 @@ class NdrNostrService( ) pairwiseRuntime = runtime activeRuntimeEpoch = ++nextRuntimeEpoch - markEstablishedIfNeededLocked(runtime) - drainAndApplyPubSubEventsLocked() + if (!persistEstablishedMarkerIfNeededLocked(runtime)) { + return false + } + if (drainPendingActions) { + drainAndApplyPubSubEventsLocked() + } + return true } catch (_: Throwable) { Log.e(TAG, "Failed to configure NDR") teardownLocked() configurationFailurePubkeyHex = pubkeyHex + return false } } @@ -295,8 +320,8 @@ class NdrNostrService( return try { val sessionInfo = runtime.sessionInfo(peer) ?: return false knownActivePeerPubkeys.add(peer) - markEstablishedIfNeededLocked(runtime) - sessionInfo.isActive + persistEstablishedMarkerIfNeededLocked(runtime) && + sessionInfo.isActive } catch (_: Throwable) { false } @@ -316,8 +341,7 @@ class NdrNostrService( false } else { knownActivePeerPubkeys.add(peer) - markEstablishedIfNeededLocked(runtime) - true + persistEstablishedMarkerIfNeededLocked(runtime) } } catch (_: Throwable) { false @@ -364,7 +388,9 @@ class NdrNostrService( } return try { runtime.sendText(peer, text, expiresAtSeconds) - markEstablishedIfNeededLocked(runtime) + if (!persistEstablishedMarkerIfNeededLocked(runtime)) { + return NdrSendResult.FAILED + } drainAndApplyPubSubEventsLocked() NdrSendResult.SENT } catch (_: Throwable) { @@ -377,17 +403,65 @@ class NdrNostrService( @Synchronized fun retirePeer(peerPubkeyHex: String): Boolean { if (!NdrFeatureGate.isEnabled()) return false + return retirePeerLocked(peerPubkeyHex, drainPendingActions = true) + } + + /** + * Open existing native state only to durably retire a rebound peer while rollout is off. + */ + @Synchronized + fun retirePeerForMaintenance( + identity: NostrIdentity, + peerPubkeyHex: String + ): Boolean { + if (!configureRuntimeIfNeededLocked(identity, drainPendingActions = false)) { + return false + } + return retirePeerLocked(peerPubkeyHex, drainPendingActions = false) + } + + private fun retirePeerLocked( + peerPubkeyHex: String, + drainPendingActions: Boolean + ): Boolean { 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 + val existedBefore = try { + runtime.sessionInfo(peer) != null } catch (_: Throwable) { - Log.e(TAG, "Failed to retire rebound NDR peer") - false + return false + } + if (!existedBefore) { + knownActivePeerPubkeys.remove(peer) + return true + } + return try { + val retired = runtime.retirePeer(peer) + val absentAfter = runtime.sessionInfo(peer) == null + if (retired || absentAfter) { + knownActivePeerPubkeys.remove(peer) + if (drainPendingActions) { + drainAndApplyPubSubEventsLocked() + } + true + } else { + false + } + } catch (_: Throwable) { + val absentAfter = runCatching { + runtime.sessionInfo(peer) == null + }.getOrDefault(false) + if (absentAfter) { + knownActivePeerPubkeys.remove(peer) + if (drainPendingActions) { + runCatching { drainAndApplyPubSubEventsLocked() } + } + true + } else { + Log.e(TAG, "Failed to retire rebound NDR peer") + false + } } } @@ -425,6 +499,17 @@ class NdrNostrService( Log.w(TAG, "Rejecting OOB event with an authenticated-peer mismatch") return NdrOutOfBandProcessResult(emptyList()) } + val canMutateSession = + inboundInvite?.transport == OutOfBandInviteTransport.EVENT_JSON || + inboundInvite?.transport == OutOfBandInviteTransport.URL || + parsedEvent?.kind == NostrKind.GIFT_WRAP + if (!canMutateSession) { + Log.w(TAG, "Rejecting non-handshake OOB payload") + return NdrOutOfBandProcessResult(emptyList()) + } + if (!persistEstablishedMarkerIfNeededLocked(runtime, force = true)) { + return NdrOutOfBandProcessResult(emptyList()) + } try { when { @@ -440,9 +525,7 @@ class NdrNostrService( runtime.processOutOfBandResponse(trimmedPayload, expectedPeer) processingSucceeded = true } - else -> { - Log.w(TAG, "Rejecting non-handshake OOB payload") - } + else -> Unit } } catch (_: NdrSessionNotReadyException) { Log.d(TAG, "OOB session is not ready") @@ -457,9 +540,8 @@ class NdrNostrService( } } if (processingSucceeded && - runCatching { markEstablishedIfNeededLocked(runtime) }.isFailure + !persistEstablishedMarkerIfNeededLocked(runtime) ) { - Log.e(TAG, "Failed to persist NDR established-session marker") return NdrOutOfBandProcessResult(emptyList()) } val collectOutOfBandPublishes = onOutOfBandPayload == null @@ -518,7 +600,9 @@ class NdrNostrService( try { runtime.processEvent(eventJson) - markEstablishedIfNeededLocked(runtime) + if (!persistEstablishedMarkerIfNeededLocked(runtime)) { + return false + } } catch (_: Throwable) { Log.d(TAG, "Ignoring invalid NDR relay event") drainAndApplyPubSubEventsLocked() @@ -891,13 +975,25 @@ class NdrNostrService( 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 persistEstablishedMarkerIfNeededLocked( + runtime: NdrPairwiseRuntime, + force: Boolean = false + ): Boolean { + val accountPubkeyHex = configuredForPubkeyHex ?: return false + return try { + val hasPairwiseSessionRecord = force || + runtime.knownPeerPubkeys().any { peerPubkeyHex -> + runtime.sessionInfo(peerPubkeyHex) != null + } + if (hasPairwiseSessionRecord) { + establishedSessionMarkers.mark(accountPubkeyHex) + } + true + } catch (_: Throwable) { + Log.e(TAG, "Failed to persist NDR established-session marker") + configurationFailurePubkeyHex = accountPubkeyHex + teardownLocked() + false } } @@ -999,18 +1095,54 @@ class NdrNostrService( fun resetForPanic(): Boolean { onDecryptedMessage = null teardownLocked() - val storageCleared = runCatching(storageResetter) + panicResetBlocked = true + + val quarantineEstablished = runCatching { + panicStorageQuarantine.begin() + }.onFailure { + Log.w(TAG, "Failed to establish NDR panic quarantine") + }.isSuccess + val retryMarkerPersisted = runCatching { + establishedSessionMarkers.markPanicWipeRequired() + }.onFailure { + Log.w(TAG, "Failed to persist NDR panic retry marker") + }.isSuccess + + val activeStorageCleared = runCatching(storageResetter) .onFailure { Log.w(TAG, "Failed to delete NDR storage") } .isSuccess + val quarantineCleared = if (quarantineEstablished) { + runCatching(panicStorageQuarantine::wipeNativeState) + .onFailure { Log.w(TAG, "Failed to wipe quarantined NDR storage") } + .isSuccess + } else { + true + } + val storageCleared = activeStorageCleared && quarantineCleared val markersCleared = storageCleared && - runCatching(establishedSessionMarkers::clearAll) + runCatching(establishedSessionMarkers::clearEstablishedSessions) .onFailure { Log.w(TAG, "Failed to delete NDR downgrade markers") } .isSuccess - panicResetBlocked = !markersCleared - if (markersCleared) { + return (quarantineEstablished || retryMarkerPersisted) && markersCleared + } + + /** + * Clear the retry marker only after host identities and contact pins are wiped. + */ + @Synchronized + fun completePanicReset(): Boolean { + if (!panicResetBlocked) return true + val completed = runCatching { + establishedSessionMarkers.clearPanicWipeRequired() + panicStorageQuarantine.clear() + }.onFailure { + Log.w(TAG, "Failed to clear NDR panic retry state") + }.isSuccess + if (completed) { + panicResetBlocked = false configurationFailurePubkeyHex = null } - return !panicResetBlocked + return completed } private fun isDoubleRatchetInviteEvent(event: NostrEvent): Boolean { diff --git a/app/src/main/java/com/bitchat/android/nostr/NdrPanicStartupRecovery.kt b/app/src/main/java/com/bitchat/android/nostr/NdrPanicStartupRecovery.kt new file mode 100644 index 00000000..d18378f1 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/nostr/NdrPanicStartupRecovery.kt @@ -0,0 +1,75 @@ +package com.bitchat.android.nostr + +import android.content.Context +import android.util.Log +import com.bitchat.android.favorites.FavoritesPersistenceService +import com.bitchat.android.identity.SecureIdentityStateManager + +internal object NdrPanicStartupRecovery { + private const val TAG = "NdrPanicStartup" + + @Volatile + private var networkStartupAllowed = true + + fun recoverBeforeNetwork(context: Context): Boolean { + val filesDirectory = context.filesDir + val markerStore = FileNdrEstablishedSessionMarkerStore( + filesDirectory.resolve("ndr-established-sessions") + ) + val panicStorageQuarantine = FileNdrPanicStorageQuarantine( + filesDirectory.resolve("ndr") + ) + return recoverBeforeNetwork( + markerStore = markerStore, + panicStorageQuarantine = panicStorageQuarantine, + clearIdentity = { + SecureIdentityStateManager(context.applicationContext) + .clearIdentityData() + }, + clearFavorites = { + FavoritesPersistenceService.initialize(context.applicationContext) + FavoritesPersistenceService.shared.clearAllFavoritesAfterNdrReset() + } + ) + } + + internal fun recoverBeforeNetwork( + markerStore: NdrEstablishedSessionMarkerStore, + panicStorageQuarantine: NdrPanicStorageQuarantine, + clearIdentity: () -> Boolean, + clearFavorites: () -> Boolean + ): Boolean { + val retryRequired = runCatching { + val markerRetryRequired = markerStore.isPanicWipeRequired() + val quarantineRetryRequired = panicStorageQuarantine.isPending() + markerRetryRequired || quarantineRetryRequired + }.getOrElse { + networkStartupAllowed = false + return false + } + if (!retryRequired) { + networkStartupAllowed = true + return true + } + + val recovered = runCatching { + panicStorageQuarantine.begin() + panicStorageQuarantine.wipeNativeState() + markerStore.clearEstablishedSessions() + check(clearIdentity()) { "Failed to clear host identity state" } + check(clearFavorites()) { "Failed to clear NDR contact protection state" } + markerStore.clearPanicWipeRequired() + panicStorageQuarantine.clear() + }.onFailure { + Log.e(TAG, "Blocking network startup until panic wipe retry succeeds") + }.isSuccess + networkStartupAllowed = recovered + return recovered + } + + fun isNetworkStartupAllowed(): Boolean = networkStartupAllowed + + fun blockNetworkStartup() { + networkStartupAllowed = false + } +} diff --git a/app/src/main/java/com/bitchat/android/nostr/NdrPanicStorageQuarantine.kt b/app/src/main/java/com/bitchat/android/nostr/NdrPanicStorageQuarantine.kt new file mode 100644 index 00000000..1c7f6fc0 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/nostr/NdrPanicStorageQuarantine.kt @@ -0,0 +1,125 @@ +package com.bitchat.android.nostr + +import java.io.File +import java.io.IOException +import java.nio.channels.FileChannel +import java.nio.file.Files +import java.nio.file.LinkOption +import java.nio.file.NoSuchFileException +import java.nio.file.StandardOpenOption +import java.nio.file.attribute.BasicFileAttributes + +/** + * A durable panic-wipe latch that is independent of the NDR marker store. + * + * The active NDR directory is renamed out of its normal path before any + * destructive work. The empty quarantine directory remains as retry evidence + * until native state, host identity, and contact protection state are all + * durably gone. + */ +interface NdrPanicStorageQuarantine { + fun isPending(): Boolean + fun begin() + fun wipeNativeState() + fun clear() +} + +internal class FileNdrPanicStorageQuarantine( + private val storageDirectory: File, + private val quarantineDirectory: File = + requireNotNull(storageDirectory.parentFile).resolve("ndr-panic-quarantine-v1") +) : NdrPanicStorageQuarantine { + private val parentDirectory: File = + requireNotNull(storageDirectory.parentFile) + + override fun isPending(): Boolean = + directoryExists(quarantineDirectory, "NDR panic quarantine") + + override fun begin() { + if (!directoryExists(parentDirectory, "NDR storage parent")) { + throw IOException("NDR storage parent is unavailable") + } + if (isPending()) { + syncDirectory(parentDirectory) + return + } + + val latchCreated = if ( + directoryExists(storageDirectory, "Active NDR storage") + ) { + storageDirectory.renameTo(quarantineDirectory) + } else { + quarantineDirectory.mkdir() + } + if (!latchCreated || !isPending()) { + throw IOException("Failed to establish NDR panic quarantine") + } + syncDirectory(parentDirectory) + } + + override fun wipeNativeState() { + if (!isPending()) { + throw IOException("NDR panic quarantine is not established") + } + + val quarantinedEntries = quarantineDirectory.listFiles() + ?: throw IOException("Failed to inspect NDR panic quarantine") + quarantinedEntries.forEach { entry -> + if (!entry.deleteRecursively() || pathExists(entry)) { + throw IOException("Failed to wipe quarantined NDR state") + } + } + syncDirectory(quarantineDirectory) + + if (pathExists(storageDirectory) && + (!storageDirectory.deleteRecursively() || pathExists(storageDirectory)) + ) { + throw IOException("Failed to wipe active NDR state during panic") + } + syncDirectory(parentDirectory) + } + + override fun clear() { + if (!isPending()) return + if (pathExists(storageDirectory)) { + throw IOException("Active NDR state exists while completing panic wipe") + } + val remaining = quarantineDirectory.listFiles() + ?: throw IOException("Failed to inspect NDR panic quarantine") + if (remaining.isNotEmpty()) { + throw IOException("Quarantined NDR state remains") + } + if (!quarantineDirectory.delete() || pathExists(quarantineDirectory)) { + throw IOException("Failed to clear NDR panic quarantine") + } + syncDirectory(parentDirectory) + } + + private fun directoryExists(file: File, description: String): Boolean = + readAttributesOrNull(file)?.let { attributes -> + if (!attributes.isDirectory) { + throw IOException("$description is not a directory") + } + true + } ?: false + + private fun pathExists(file: File): Boolean = + readAttributesOrNull(file) != null + + private fun readAttributesOrNull(file: File): BasicFileAttributes? = + try { + Files.readAttributes( + file.toPath(), + BasicFileAttributes::class.java, + LinkOption.NOFOLLOW_LINKS + ) + } catch (_: NoSuchFileException) { + null + } + + private fun syncDirectory(directory: File) { + FileChannel.open(directory.toPath(), StandardOpenOption.READ).use { channel -> + channel.force(true) + } + } +} 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 81b1c555..5809c880 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt @@ -125,6 +125,14 @@ class NostrDirectMessageHandler( val (content, rawSenderPubkey, rumorTimestamp) = decryptResult val senderPubkey = rawSenderPubkey.lowercase() + val legacyAllowed = runCatching { + FavoritesPersistenceService.shared + .isLegacyNostrInboundAllowed(senderPubkey) + }.getOrDefault(false) + if (!legacyAllowed) { + Log.w(TAG, "Rejecting legacy DM for an NDR-pinned contact") + return@launch + } // If sender is blocked for geohash contexts, drop any events from this pubkey // Applies to both geohash DMs (geohash != "") and account DMs (geohash == "") @@ -172,6 +180,14 @@ class NostrDirectMessageHandler( return@launch } val senderPubkey = message.senderPubkeyHex.lowercase() + val senderIsCurrent = runCatching { + FavoritesPersistenceService.shared + .isCurrentNdrPeerAuthorized(senderPubkey) + }.getOrDefault(false) + if (!senderIsCurrent) { + result = NdrDeliveryResult.REJECTED + return@launch + } if (dataManager.isGeohashUserBlocked(senderPubkey)) { result = NdrDeliveryResult.REJECTED return@launch 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 7e74e638..3402e380 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrTransport.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrTransport.kt @@ -12,8 +12,23 @@ import kotlinx.coroutines.* import java.util.* import java.util.concurrent.ConcurrentLinkedQueue -internal fun shouldUseLegacyNostrFallback(result: NdrSendResult): Boolean = - result == NdrSendResult.NO_SESSION +internal fun shouldUseLegacyNostrFallback( + result: NdrSendResult, + ndrRequired: Boolean = false, + rebindBlocked: Boolean = false +): Boolean = + result == NdrSendResult.NO_SESSION && !ndrRequired && !rebindBlocked + +internal fun isLegacyNostrAllowedWhenNdrDisabled( + ndrRequired: Boolean, + rebindBlocked: Boolean +): Boolean = !ndrRequired && !rebindBlocked + +private data class NdrRecipientResolution( + val peerPubkeyHex: String, + val ndrRequired: Boolean, + val rebindBlocked: Boolean +) /** * Nostr transport for offline private messages and receipts. @@ -79,7 +94,7 @@ class NostrTransport( Log.e(TAG, "NostrTransport: recipient key is not a valid Nostr pubkey") return@launch } - val ndrRecipientHex = resolveNdrRecipientHex(to, recipientHex) + val ndrRecipient = resolveNdrRecipient(to, recipientHex) val recipientPeerIDForEmbed = try { com.bitchat.android.favorites.FavoritesPersistenceService.shared @@ -106,7 +121,7 @@ class NostrTransport( content = embedded, fallbackRecipientHex = recipientHex, senderIdentity = senderIdentity, - ndrRecipientHex = ndrRecipientHex, + ndrRecipient = ndrRecipient, expiresAtSeconds = expiresAtSeconds ) @@ -159,7 +174,7 @@ class NostrTransport( scheduleNextReadAck() return@launch } - val ndrRecipientHex = resolveNdrRecipientHex(item.peerID, recipientHex) + val ndrRecipient = resolveNdrRecipient(item.peerID, recipientHex) val ack = NostrEmbeddedBitChat.encodeAckForNostr( type = NoisePayloadType.READ_RECEIPT, @@ -178,7 +193,7 @@ class NostrTransport( content = ack, fallbackRecipientHex = recipientHex, senderIdentity = senderIdentity, - ndrRecipientHex = ndrRecipientHex + ndrRecipient = ndrRecipient ) scheduleNextReadAck() @@ -220,7 +235,7 @@ class NostrTransport( if (recipientHex == null) { return@launch } - val ndrRecipientHex = resolveNdrRecipientHex(to, recipientHex) + val ndrRecipient = resolveNdrRecipient(to, recipientHex) val embedded = NostrEmbeddedBitChat.encodePMForNostr( content = content, @@ -238,7 +253,7 @@ class NostrTransport( content = embedded, fallbackRecipientHex = recipientHex, senderIdentity = senderIdentity, - ndrRecipientHex = ndrRecipientHex + ndrRecipient = ndrRecipient ) } catch (e: Exception) { @@ -267,7 +282,7 @@ class NostrTransport( if (recipientHex == null) { return@launch } - val ndrRecipientHex = resolveNdrRecipientHex(to, recipientHex) + val ndrRecipient = resolveNdrRecipient(to, recipientHex) val ack = NostrEmbeddedBitChat.encodeAckForNostr( type = NoisePayloadType.DELIVERED, @@ -285,7 +300,7 @@ class NostrTransport( content = ack, fallbackRecipientHex = recipientHex, senderIdentity = senderIdentity, - ndrRecipientHex = ndrRecipientHex + ndrRecipient = ndrRecipient ) } catch (e: Exception) { @@ -425,14 +440,22 @@ class NostrTransport( content: String, fallbackRecipientHex: String, senderIdentity: NostrIdentity, - ndrRecipientHex: String = fallbackRecipientHex, + ndrRecipient: NdrRecipientResolution = NdrRecipientResolution( + peerPubkeyHex = fallbackRecipientHex, + ndrRequired = false, + rebindBlocked = false + ), expiresAtSeconds: ULong? = null ): Boolean { + if (ndrRecipient.rebindBlocked) { + Log.e(TAG, "NostrTransport: recipient rebind is quarantined") + return false + } if (NdrFeatureGate.isEnabled()) { ndrService.configureIfNeeded(senderIdentity) val sendResult = ndrService.sendIfPossible( text = content, - peerPubkeyHex = ndrRecipientHex, + peerPubkeyHex = ndrRecipient.peerPubkeyHex, expiresAtSeconds = expiresAtSeconds ) if (sendResult == NdrSendResult.SENT) { @@ -442,12 +465,22 @@ class NostrTransport( Log.e(TAG, "NostrTransport: expiring message requires a pairwise session") return false } - if (!shouldUseLegacyNostrFallback(sendResult)) { + if (!shouldUseLegacyNostrFallback( + result = sendResult, + ndrRequired = ndrRecipient.ndrRequired, + rebindBlocked = ndrRecipient.rebindBlocked + ) + ) { 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") + } else if (expiresAtSeconds != null || + !isLegacyNostrAllowedWhenNdrDisabled( + ndrRequired = ndrRecipient.ndrRequired, + rebindBlocked = ndrRecipient.rebindBlocked + ) + ) { + Log.e(TAG, "NostrTransport: pairwise transport is required") return false } @@ -462,15 +495,39 @@ class NostrTransport( return false } - private fun resolveNdrRecipientHex(target: String, fallbackRecipientHex: String): String { - if (!NdrFeatureGate.isEnabled()) return fallbackRecipientHex + private fun resolveNdrRecipient( + target: String, + fallbackRecipientHex: String + ): NdrRecipientResolution { return try { val favorites = com.bitchat.android.favorites.FavoritesPersistenceService.shared - val relationship = favorites.getFavoriteStatus(target) ?: return fallbackRecipientHex - favorites.findNdrSessionPubkeyHex(relationship.peerNoisePublicKey) - ?: fallbackRecipientHex + if (!favorites.isNdrProtectionStateReadable()) { + return NdrRecipientResolution( + peerPubkeyHex = fallbackRecipientHex, + ndrRequired = true, + rebindBlocked = true + ) + } + val relationship = favorites.getFavoriteStatus(target) + ?: return NdrRecipientResolution( + peerPubkeyHex = fallbackRecipientHex, + ndrRequired = false, + rebindBlocked = false + ) + NdrRecipientResolution( + peerPubkeyHex = + favorites.findNdrSessionPubkeyHex(relationship.peerNoisePublicKey) + ?: fallbackRecipientHex, + ndrRequired = favorites.isNdrRequired(relationship.peerNoisePublicKey), + rebindBlocked = + favorites.isNdrRebindBlocked(relationship.peerNoisePublicKey) + ) } catch (_: Exception) { - fallbackRecipientHex + NdrRecipientResolution( + peerPubkeyHex = fallbackRecipientHex, + ndrRequired = true, + rebindBlocked = true + ) } } diff --git a/app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt b/app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt index d9ad2d7f..117ba413 100644 --- a/app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt +++ b/app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt @@ -118,6 +118,12 @@ class MeshForegroundService : Service() { override fun onCreate() { super.onCreate() + if (!com.bitchat.android.nostr.NdrPanicStartupRecovery + .isNetworkStartupAllowed() + ) { + stopSelf() + return + } notificationManager = NotificationManagerCompat.from(this) createChannel() @@ -134,6 +140,12 @@ class MeshForegroundService : Service() { } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + if (!com.bitchat.android.nostr.NdrPanicStartupRecovery + .isNetworkStartupAllowed() + ) { + stopSelf() + return START_NOT_STICKY + } if (isShuttingDown && intent?.action == ACTION_START) { AppShutdownCoordinator.cancelPendingShutdown() isShuttingDown = false 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 be27db44..a44f658c 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -1028,6 +1028,14 @@ class ChatViewModel( Log.d(TAG, "Ignoring NDR OOB event from a replaced or rebound Noise generation") return } + if (!FavoritesPersistenceService.shared.updateNdrSessionPubkeyHex( + noiseKey, + expectedPeerPubkeyHex + ) + ) { + Log.e(TAG, "Refusing NDR OOB processing before its downgrade pin is durable") + return + } val result = ndrService.processOutOfBandEventJson( eventPayload, expectedPeerPubkeyHex @@ -1107,6 +1115,14 @@ class ChatViewModel( val hasPairwiseSession = ndrService.hasPairwiseSession(peerPubkeyHex) if (hasPairwiseSession) { + if (!FavoritesPersistenceService.shared.updateNdrSessionPubkeyHex( + noiseKey, + peerPubkeyHex + ) + ) { + Log.e(TAG, "Existing NDR session remains quarantined until its pin is durable") + return + } ndrInviteRetries.cancel(peerID) ndrBootstrapAttemptMs.remove(peerID) ndrNoiseHandshakeAttemptMs.remove(peerID) @@ -1210,7 +1226,7 @@ class ChatViewModel( currentRoute = mesh::currentNdrRoute, favoriteBinding = { noiseKey -> val favorites = FavoritesPersistenceService.shared - val relationship = favorites.getFavoriteStatus(noiseKey) + val relationship = favorites.getStoredFavoriteForNdrRoute(noiseKey) ?: return@isAuthorized null NdrFavoriteRouteBinding( isMutual = relationship.isMutual, @@ -1254,12 +1270,47 @@ class ChatViewModel( try { com.bitchat.android.services.SeenMessageStore.getInstance(getApplication()).clear() } catch (_: Exception) { } + + if (!ndrResetSucceeded) { + com.bitchat.android.nostr.NdrPanicStartupRecovery.blockNetworkStartup() + try { + mesh.stopServices() + } catch (_: Exception) { } + try { + com.bitchat.android.nostr.NostrRelayManager + .getInstance(getApplication()) + .disconnect() + } catch (_: Exception) { } + clearAllMeshServiceData() + clearAllCryptographicData(ndrResetSucceeded = false) + notificationManager.clearAllNotifications() + com.bitchat.android.features.file.FileUtils.clearAllMedia(getApplication()) + Log.e( + TAG, + "PANIC MODE INCOMPLETE - identity recreation blocked until NDR wipe retry" + ) + return + } // Clear all mesh service data clearAllMeshServiceData() // Clear all cryptographic data - clearAllCryptographicData() + val cryptographicClearSucceeded = + clearAllCryptographicData(ndrResetSucceeded = true) + if (!cryptographicClearSucceeded || !ndrService.completePanicReset()) { + com.bitchat.android.nostr.NdrPanicStartupRecovery.blockNetworkStartup() + try { + mesh.stopServices() + } catch (_: Exception) { } + try { + com.bitchat.android.nostr.NostrRelayManager + .getInstance(getApplication()) + .disconnect() + } catch (_: Exception) { } + Log.e(TAG, "PANIC MODE INCOMPLETE - final wipe commit failed") + return + } // Clear all notifications notificationManager.clearAllNotifications() @@ -1346,33 +1397,46 @@ class ChatViewModel( /** * Clear all cryptographic data including persistent identity */ - private fun clearAllCryptographicData() { - try { + private fun clearAllCryptographicData(ndrResetSucceeded: Boolean): Boolean { + return try { + var completed = true // Clear encryption service persistent identity (Ed25519 signing keys) mesh.clearAllEncryptionData() // Clear secure identity state (if used) try { val identityManager = SecureIdentityStateManager(getApplication()) - identityManager.clearIdentityData() - // Also clear secure values used by FavoritesPersistenceService (favorites + peerID index) - try { - identityManager.clearSecureValues("favorite_relationships", "favorite_peerid_index") - } catch (_: Exception) { } - Log.d(TAG, "✅ Cleared secure identity state and secure favorites store") + if (identityManager.clearIdentityData()) { + Log.d(TAG, "✅ Cleared secure identity state") + } else { + Log.e(TAG, "Secure identity wipe was not durably committed") + completed = false + } } catch (e: Exception) { Log.d(TAG, "SecureIdentityStateManager not available or already cleared: ${e.message}") + completed = false } - // Clear FavoritesPersistenceService persistent relationships - try { - FavoritesPersistenceService.shared.clearAllFavorites() - Log.d(TAG, "✅ Cleared FavoritesPersistenceService relationships") - } catch (_: Exception) { } + if (ndrResetSucceeded) { + try { + if (FavoritesPersistenceService.shared.clearAllFavoritesAfterNdrReset()) { + Log.d(TAG, "✅ Cleared FavoritesPersistenceService relationships") + } else { + Log.e(TAG, "Favorites clear was not durably committed") + completed = false + } + } catch (_: Exception) { + completed = false + } + } else { + Log.e(TAG, "Preserving NDR contact pins because native state wipe failed") + } Log.d(TAG, "✅ Cleared all cryptographic data") + completed } catch (e: Exception) { Log.e(TAG, "❌ Error clearing cryptographic data: ${e.message}") + false } } diff --git a/app/src/test/kotlin/com/bitchat/android/favorites/FavoritesNdrRebindTest.kt b/app/src/test/kotlin/com/bitchat/android/favorites/FavoritesNdrRebindTest.kt index 78b41f32..21a8e651 100644 --- a/app/src/test/kotlin/com/bitchat/android/favorites/FavoritesNdrRebindTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/favorites/FavoritesNdrRebindTest.kt @@ -1,11 +1,13 @@ package com.bitchat.android.favorites import android.content.Context +import android.content.SharedPreferences 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.assertNull import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test @@ -19,6 +21,7 @@ import kotlin.concurrent.thread @RunWith(RobolectricTestRunner::class) class FavoritesNdrRebindTest { private lateinit var service: FavoritesPersistenceService + private lateinit var preferences: FaultInjectingSharedPreferences private val noiseA = ByteArray(32) { 1 } private val noiseB = ByteArray(32) { 2 } private val oldPeer = "11".repeat(32) @@ -27,19 +30,208 @@ class FavoritesNdrRebindTest { @Before fun setUp() { val context = ApplicationProvider.getApplicationContext() - val preferences = context.getSharedPreferences( - "favorites-ndr-rebind-${System.nanoTime()}", - Context.MODE_PRIVATE + preferences = FaultInjectingSharedPreferences( + context.getSharedPreferences( + "favorites-ndr-rebind-${System.nanoTime()}", + Context.MODE_PRIVATE + ) ) + service = newService() + } + + private fun newService(): FavoritesPersistenceService { service = FavoritesPersistenceService( stateManager = SecureIdentityStateManager(preferences, testOnly = true), testOnly = true ) + return service } @Test - fun failedRetirementRollsBackBindingWithoutHoldingFavoritesLock() { + fun persistenceFailureBeforeRebindDoesNotRetireOrReportSuccess() { assertTrue(service.updateNostrPublicKey(noiseA, oldPeer)) + assertTrue(service.updateNdrSessionPubkeyHex(noiseA, oldPeer)) + var retireCalls = 0 + service.setNdrPeerRetirementGuard { + retireCalls += 1 + true + } + + preferences.failNextWrite = true + assertFalse(service.updateNdrSessionPubkeyHex(noiseA, newPeer)) + + assertEquals(0, retireCalls) + val restarted = newService() + assertEquals(oldPeer, restarted.findNdrSessionPubkeyHex(noiseA)) + assertTrue(restarted.isNdrRequired(noiseA)) + } + + @Test + fun failedTargetPersistenceKeepsJournalAndExposesOnlyTargetRoute() { + assertTrue(service.updateNostrPublicKey(noiseA, oldPeer)) + assertTrue(service.updateNdrSessionPubkeyHex(noiseA, oldPeer)) + var retireCalls = 0 + service.setNdrPeerRetirementGuard { + retireCalls += 1 + true + } + + preferences.successfulWritesBeforeFailure = 1 + assertFalse(service.updateNdrSessionPubkeyHex(noiseA, newPeer)) + + assertEquals(1, retireCalls) + assertTrue(service.isNdrRebindBlocked(noiseA)) + assertTrue(service.isNdrRequired(noiseA)) + assertEquals(newPeer, service.findNdrSessionPubkeyHex(noiseA)) + assertEquals(noiseA.toList(), service.findNoiseKey(newPeer)?.toList()) + assertFalse(service.isCurrentNdrPeerAuthorized(oldPeer)) + assertNull(service.getStoredFavoriteForNdrRoute(noiseA)) + } + + @Test + fun restartCompletesJournalBeforeUnblockingTarget() { + assertTrue(service.updateNostrPublicKey(noiseA, oldPeer)) + assertTrue(service.updateNdrSessionPubkeyHex(noiseA, oldPeer)) + var failedRetireCalls = 0 + service.setNdrPeerRetirementGuard { + failedRetireCalls += 1 + false + } + + assertFalse(service.updateNdrSessionPubkeyHex(noiseA, newPeer)) + assertEquals(1, failedRetireCalls) + assertEquals(newPeer, service.findNdrSessionPubkeyHex(noiseA)) + assertTrue(service.isNdrRequired(noiseA)) + assertTrue(service.isNdrRebindBlocked(noiseA)) + + val retiredAfterRestart = mutableListOf() + val restarted = newService() + restarted.setNdrPeerRetirementGuard { + retiredAfterRestart += it + true + } + + assertEquals(listOf(oldPeer), retiredAfterRestart) + assertEquals(newPeer, restarted.findNdrSessionPubkeyHex(noiseA)) + assertTrue(restarted.isNdrRequired(noiseA)) + assertFalse(restarted.isNdrRebindBlocked(noiseA)) + + val verifiedRestart = newService() + assertEquals(newPeer, verifiedRestart.findNdrSessionPubkeyHex(noiseA)) + assertTrue(verifiedRestart.isNdrRequired(noiseA)) + assertFalse(verifiedRestart.isNdrRebindBlocked(noiseA)) + } + + @Test + fun failedJournalRemovalRetriesIdempotentRetirementAfterRestart() { + assertTrue(service.updateNostrPublicKey(noiseA, oldPeer)) + assertTrue(service.updateNdrSessionPubkeyHex(noiseA, oldPeer)) + val retired = mutableListOf() + service.setNdrPeerRetirementGuard { + retired += it + true + } + preferences.failNextRemovalOf = + FavoritesPersistenceService.NDR_REBIND_JOURNAL_KEY + + assertFalse(service.updateNdrSessionPubkeyHex(noiseA, newPeer)) + assertEquals(listOf(oldPeer), retired) + assertTrue(service.isNdrRebindBlocked(noiseA)) + assertTrue(service.isNdrRequired(noiseA)) + assertEquals( + newPeer, + service.getStoredFavoriteForNdrRoute(noiseA) + ?.peerNdrSessionPubkeyHex + ) + assertTrue(service.isCurrentNdrPeerAuthorized(newPeer)) + + val restarted = newService() + restarted.setNdrPeerRetirementGuard { + retired += it + true + } + + assertEquals(listOf(oldPeer, oldPeer), retired) + assertEquals(newPeer, restarted.findNdrSessionPubkeyHex(noiseA)) + assertTrue(restarted.isNdrRequired(noiseA)) + assertFalse(restarted.isNdrRebindBlocked(noiseA)) + } + + @Test + fun initialIdentityPersistenceFailureDoesNotMutateMemory() { + preferences.failNextWrite = true + + assertFalse(service.updateNostrPublicKey(noiseA, oldPeer)) + + assertNull(service.getFavoriteStatus(noiseA)) + assertNull(newService().getFavoriteStatus(noiseA)) + } + + @Test + fun existingNativeSessionBackfillRetriesAfterPinMarkerWriteFailure() { + assertTrue(service.updateNostrPublicKey(noiseA, oldPeer)) + preferences.failNextWrite = true + + assertFalse(service.updateNdrSessionPubkeyHex(noiseA, oldPeer)) + assertFalse(service.isNdrProtectionStateReadable()) + assertTrue(service.isNdrRebindBlocked(noiseA)) + + val restarted = newService() + assertFalse(restarted.isNdrRequired(noiseA)) + assertTrue(restarted.updateNdrSessionPubkeyHex(noiseA, oldPeer)) + assertTrue(restarted.isNdrRequired(noiseA)) + assertFalse(restarted.isNdrRebindBlocked(noiseA)) + assertTrue(newService().isNdrRequired(noiseA)) + } + + @Test + fun pinOnlyJournalRecoversAfterCrashBeforeFavoriteCommit() { + assertTrue(service.updateNostrPublicKey(noiseA, oldPeer)) + preferences.successfulWritesBeforeFailure = 1 + + assertFalse(service.updateNdrSessionPubkeyHex(noiseA, oldPeer)) + assertTrue(service.isNdrRequired(noiseA)) + assertTrue(service.isNdrRebindBlocked(noiseA)) + assertNull(service.getStoredFavoriteForNdrRoute(noiseA)) + + val restarted = newService() + restarted.setNdrPeerRetirementGuard { + error("pin-only recovery must not retire") + } + + assertTrue(restarted.isNdrRequired(noiseA)) + assertFalse(restarted.isNdrRebindBlocked(noiseA)) + assertEquals( + oldPeer, + restarted.getStoredFavoriteForNdrRoute(noiseA) + ?.peerNdrSessionPubkeyHex + ) + } + + @Test + fun corruptJournalFailsClosed() { + assertTrue(service.updateNostrPublicKey(noiseA, oldPeer)) + assertTrue( + preferences.edit() + .putString( + FavoritesPersistenceService.NDR_REBIND_JOURNAL_KEY, + """{"version":1,"noiseKeyHex":"broken"}""" + ) + .commit() + ) + + val restarted = newService() + + assertTrue(restarted.isNdrRebindBlocked(noiseA)) + assertFalse(restarted.updateNdrSessionPubkeyHex(noiseA, newPeer)) + assertEquals(oldPeer, restarted.findNdrSessionPubkeyHex(noiseA)) + assertNull(restarted.getStoredFavoriteForNdrRoute(noiseA)) + } + + @Test + fun failedRetirementQuarantinesTargetWithoutHoldingFavoritesLock() { + assertTrue(service.updateNostrPublicKey(noiseA, oldPeer)) + assertTrue(service.updateNdrSessionPubkeyHex(noiseA, oldPeer)) var favoritesLockWasAvailable = false service.setNdrPeerRetirementGuard { val completed = CountDownLatch(1) @@ -55,9 +247,11 @@ class FavoritesNdrRebindTest { assertTrue(favoritesLockWasAvailable) assertEquals( - oldPeer, + newPeer, service.findNdrSessionPubkeyHex(noiseA) ) + assertTrue(service.isNdrRequired(noiseA)) + assertTrue(service.isNdrRebindBlocked(noiseA)) } @Test @@ -76,9 +270,45 @@ class FavoritesNdrRebindTest { } @Test - fun sharedLegacyIdentityIsRetiredOnlyAfterItsLastFavoriteMoves() { - insertLegacyRelationship(noiseA, oldPeer) - insertLegacyRelationship(noiseB, oldPeer) + fun equivalentNpubAndHexAssignmentDoesNotWedgePinnedContact() { + val oldNpub = requireNotNull(ContactIdentityResolver.npubFromHex(oldPeer)) + assertTrue(service.updateNostrPublicKey(noiseA, oldNpub)) + assertTrue(service.updateNdrSessionPubkeyHex(noiseA, oldPeer)) + var retireCalls = 0 + service.setNdrPeerRetirementGuard { + retireCalls += 1 + true + } + + assertTrue(service.updateNostrPublicKey(noiseA, oldPeer.uppercase())) + + assertEquals(0, retireCalls) + assertTrue(service.isNdrRequired(noiseA)) + assertFalse(service.isNdrRebindBlocked(noiseA)) + assertEquals(oldPeer, service.findNdrSessionPubkeyHex(noiseA)) + } + + @Test + fun preNdrNostrRebindDoesNotPinOrRetireLegacyContact() { + assertTrue(service.updateNostrPublicKey(noiseA, oldPeer)) + var retireCalls = 0 + service.setNdrPeerRetirementGuard { + retireCalls += 1 + true + } + + assertTrue(service.updateNostrPublicKey(noiseA, newPeer)) + + assertEquals(0, retireCalls) + assertFalse(service.isNdrRequired(noiseA)) + assertFalse(service.isNdrRebindBlocked(noiseA)) + assertEquals(newPeer, service.findNdrSessionPubkeyHex(noiseA)) + } + + @Test + fun sharedPinnedIdentityIsRetiredOnlyAfterItsLastFavoriteMoves() { + insertRelationship(noiseA, oldPeer, pinned = true) + insertRelationship(noiseB, oldPeer, pinned = true) val retired = mutableListOf() service.setNdrPeerRetirementGuard { retired += it @@ -87,12 +317,200 @@ class FavoritesNdrRebindTest { assertTrue(service.updateNostrPublicKey(noiseA, newPeer)) assertTrue(retired.isEmpty()) + assertTrue(service.isNdrRequired(noiseA)) val finalPeer = "33".repeat(32) assertTrue(service.updateNostrPublicKey(noiseB, finalPeer)) assertEquals(listOf(oldPeer), retired) assertEquals(newPeer, service.findNdrSessionPubkeyHex(noiseA)) assertEquals(finalPeer, service.findNdrSessionPubkeyHex(noiseB)) + assertTrue(service.isNdrRequired(noiseB)) + } + + @Test + fun existingExplicitNdrBindingMigratesToDurablePin() { + val noiseHex = ContactIdentityResolver.noiseKeyHex(noiseA) + val oldNpub = requireNotNull(ContactIdentityResolver.npubFromHex(oldPeer)) + val legacyJson = """ + { + "$noiseHex": { + "peerNoisePublicKeyHex": "$noiseHex", + "peerNostrPublicKey": "$oldNpub", + "peerNdrSessionPubkeyHex": "$oldPeer", + "peerNickname": "legacy", + "isFavorite": true, + "theyFavoritedUs": true, + "favoritedAt": 1, + "lastUpdated": 1 + } + } + """.trimIndent() + assertTrue( + preferences.edit() + .putString(FavoritesPersistenceService.FAVORITES_KEY, legacyJson) + .commit() + ) + + val migrated = newService() + + assertTrue(migrated.isNdrRequired(noiseA)) + assertTrue( + requireNotNull( + preferences.getString(FavoritesPersistenceService.FAVORITES_KEY, null) + ).contains("\"ndrRequired\":true") + ) + assertTrue(newService().isNdrRequired(noiseA)) + } + + @Test + fun oldPeerIsRetiredOnlyAfterJournalWhileStoredBindingIsStillOld() { + assertTrue(service.updateNostrPublicKey(noiseA, oldPeer)) + assertTrue(service.updateNdrSessionPubkeyHex(noiseA, oldPeer)) + var storedBindingWasOld = false + var journalWasPresent = false + service.setNdrPeerRetirementGuard { + val stored = requireNotNull( + preferences.getString(FavoritesPersistenceService.FAVORITES_KEY, null) + ) + storedBindingWasOld = + stored.contains("\"peerNdrSessionPubkeyHex\":\"$oldPeer\"") && + !stored.contains("\"peerNdrSessionPubkeyHex\":\"$newPeer\"") + journalWasPresent = + preferences.contains(FavoritesPersistenceService.NDR_REBIND_JOURNAL_KEY) + true + } + + assertTrue(service.updateNdrSessionPubkeyHex(noiseA, newPeer)) + + assertTrue(journalWasPresent) + assertTrue(storedBindingWasOld) + assertEquals(newPeer, service.findNdrSessionPubkeyHex(noiseA)) + } + + @Test + fun journalCannotRetirePeerItDoesNotOwn() { + assertTrue(service.updateNostrPublicKey(noiseA, oldPeer)) + assertTrue(service.updateNdrSessionPubkeyHex(noiseA, oldPeer)) + val unrelated = "44".repeat(32) + val noiseHex = ContactIdentityResolver.noiseKeyHex(noiseA) + val invalidJournal = """ + { + "version": 1, + "noiseKeyHex": "$noiseHex", + "oldPeerPubkeyHex": "$unrelated", + "expectedNostrPubkeyHex": "$oldPeer", + "expectedNdrSessionPubkeyHex": "$oldPeer", + "expectedNdrRequired": true, + "targetNostrPubkeyHex": "$oldPeer", + "targetNdrSessionPubkeyHex": "$newPeer", + "targetNdrRequired": true, + "retireOldPeer": true + } + """.trimIndent() + assertTrue( + preferences.edit() + .putString( + FavoritesPersistenceService.NDR_REBIND_JOURNAL_KEY, + invalidJournal + ) + .commit() + ) + var retireCalls = 0 + val restarted = newService() + + restarted.setNdrPeerRetirementGuard { + retireCalls += 1 + true + } + + assertEquals(0, retireCalls) + assertTrue(restarted.isNdrRebindBlocked(noiseA)) + assertEquals(oldPeer, restarted.findNdrSessionPubkeyHex(noiseA)) + } + + @Test + fun targetIsRevalidatedAfterRetirementBeforeJournalClear() { + assertTrue(service.updateNostrPublicKey(noiseA, oldPeer)) + assertTrue(service.updateNdrSessionPubkeyHex(noiseA, oldPeer)) + service.setNdrPeerRetirementGuard { + insertRelationship(noiseA, "55".repeat(32), pinned = true) + true + } + + assertFalse(service.updateNdrSessionPubkeyHex(noiseA, newPeer)) + + assertTrue(service.isNdrRebindBlocked(noiseA)) + assertTrue( + preferences.contains(FavoritesPersistenceService.NDR_REBIND_JOURNAL_KEY) + ) + } + + @Test + fun stalePeerIsRejectedDuringRebindButSharedCurrentPeerRemainsAuthorized() { + insertRelationship(noiseA, oldPeer, pinned = true) + assertTrue(service.isCurrentNdrPeerAuthorized(oldPeer)) + service.setNdrPeerRetirementGuard { false } + + assertFalse(service.updateNdrSessionPubkeyHex(noiseA, newPeer)) + + assertFalse(service.isCurrentNdrPeerAuthorized(oldPeer)) + assertFalse(service.isCurrentNdrPeerAuthorized(newPeer)) + + insertRelationship(noiseB, oldPeer, pinned = true) + assertTrue(service.isCurrentNdrPeerAuthorized(oldPeer)) + } + + @Test + fun pendingTargetIsReservedAgainstEveryOtherFavorite() { + assertTrue(service.updateNostrPublicKey(noiseA, oldPeer)) + assertTrue(service.updateNdrSessionPubkeyHex(noiseA, oldPeer)) + service.setNdrPeerRetirementGuard { false } + assertFalse(service.updateNdrSessionPubkeyHex(noiseA, newPeer)) + + assertFalse(service.updateNostrPublicKey(noiseB, newPeer)) + assertNull(service.getFavoriteStatus(noiseB)) + assertEquals(noiseA.toList(), service.findNoiseKey(newPeer)?.toList()) + } + + @Test + fun legacyInboundIsRejectedForPinJournalAndUnreadableProtectionState() { + assertTrue(service.updateNostrPublicKey(noiseA, oldPeer)) + assertTrue(service.isLegacyNostrInboundAllowed(oldPeer)) + + assertTrue(service.updateNdrSessionPubkeyHex(noiseA, oldPeer)) + assertFalse(service.isLegacyNostrInboundAllowed(oldPeer)) + + service.setNdrPeerRetirementGuard { false } + assertFalse(service.updateNdrSessionPubkeyHex(noiseA, newPeer)) + assertFalse(service.isLegacyNostrInboundAllowed(oldPeer)) + assertFalse(service.isLegacyNostrInboundAllowed(newPeer)) + + assertTrue( + preferences.edit() + .remove(FavoritesPersistenceService.NDR_REBIND_JOURNAL_KEY) + .putString(FavoritesPersistenceService.FAVORITES_KEY, "{broken") + .commit() + ) + val unreadable = newService() + assertFalse(unreadable.isNdrProtectionStateReadable()) + assertFalse(unreadable.isLegacyNostrInboundAllowed("66".repeat(32))) + } + + @Test + fun successfulNativeResetAtomicallyClearsPinsAndJournal() { + assertTrue(service.updateNostrPublicKey(noiseA, oldPeer)) + assertTrue(service.updateNdrSessionPubkeyHex(noiseA, oldPeer)) + service.setNdrPeerRetirementGuard { false } + assertFalse(service.updateNdrSessionPubkeyHex(noiseA, newPeer)) + + assertTrue(service.clearAllFavoritesAfterNdrReset()) + + assertNull(service.getFavoriteStatus(noiseA)) + assertFalse(preferences.contains(FavoritesPersistenceService.FAVORITES_KEY)) + assertFalse(preferences.contains(FavoritesPersistenceService.PEERID_INDEX_KEY)) + assertFalse( + preferences.contains(FavoritesPersistenceService.NDR_REBIND_JOURNAL_KEY) + ) } @Test @@ -104,7 +522,11 @@ class FavoritesNdrRebindTest { } @Suppress("UNCHECKED_CAST") - private fun insertLegacyRelationship(noiseKey: ByteArray, peerPubkeyHex: String) { + private fun insertRelationship( + noiseKey: ByteArray, + peerPubkeyHex: String, + pinned: Boolean + ) { val field = FavoritesPersistenceService::class.java.getDeclaredField("favorites") field.isAccessible = true val favorites = field.get(service) as MutableMap @@ -112,11 +534,106 @@ class FavoritesNdrRebindTest { peerNoisePublicKey = noiseKey, peerNostrPublicKey = requireNotNull(ContactIdentityResolver.npubFromHex(peerPubkeyHex)), - peerNickname = "legacy", + peerNdrSessionPubkeyHex = peerPubkeyHex.takeIf { pinned }, + ndrRequired = pinned, + peerNickname = "test", isFavorite = true, theyFavoritedUs = true, favoritedAt = Date(1), lastUpdated = Date(1) ) } + + private class FaultInjectingSharedPreferences( + private val delegate: SharedPreferences + ) : SharedPreferences by delegate { + var failNextWrite: Boolean = false + var successfulWritesBeforeFailure: Int? = null + var failNextRemovalOf: String? = null + + override fun edit(): SharedPreferences.Editor = + FaultInjectingEditor(delegate.edit()) + + private inner class FaultInjectingEditor( + private val delegateEditor: SharedPreferences.Editor + ) : SharedPreferences.Editor by delegateEditor { + private val removedKeys = mutableSetOf() + + override fun putString( + key: String?, + value: String? + ): SharedPreferences.Editor { + delegateEditor.putString(key, value) + return this + } + + override fun putStringSet( + key: String?, + values: MutableSet? + ): SharedPreferences.Editor { + delegateEditor.putStringSet(key, values) + return this + } + + override fun putInt(key: String?, value: Int): SharedPreferences.Editor { + delegateEditor.putInt(key, value) + return this + } + + override fun putLong(key: String?, value: Long): SharedPreferences.Editor { + delegateEditor.putLong(key, value) + return this + } + + override fun putFloat(key: String?, value: Float): SharedPreferences.Editor { + delegateEditor.putFloat(key, value) + return this + } + + override fun putBoolean(key: String?, value: Boolean): SharedPreferences.Editor { + delegateEditor.putBoolean(key, value) + return this + } + + override fun remove(key: String?): SharedPreferences.Editor { + delegateEditor.remove(key) + key?.let(removedKeys::add) + return this + } + + override fun clear(): SharedPreferences.Editor { + delegateEditor.clear() + return this + } + + override fun commit(): Boolean { + if (shouldFailWrite()) return false + return delegateEditor.commit() + } + + override fun apply() { + if (shouldFailWrite()) return + delegateEditor.apply() + } + + private fun shouldFailWrite(): Boolean { + val removalKey = failNextRemovalOf + if (removalKey != null && removalKey in removedKeys) { + failNextRemovalOf = null + return true + } + if (failNextWrite) { + failNextWrite = false + return true + } + val writesRemaining = successfulWritesBeforeFailure ?: return false + if (writesRemaining == 0) { + successfulWritesBeforeFailure = null + return true + } + successfulWritesBeforeFailure = writesRemaining - 1 + return false + } + } + } } diff --git a/app/src/test/kotlin/com/bitchat/android/identity/PrivateMediaCapabilityPinPersistenceTest.kt b/app/src/test/kotlin/com/bitchat/android/identity/PrivateMediaCapabilityPinPersistenceTest.kt index 6d865582..da70113c 100644 --- a/app/src/test/kotlin/com/bitchat/android/identity/PrivateMediaCapabilityPinPersistenceTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/identity/PrivateMediaCapabilityPinPersistenceTest.kt @@ -1,6 +1,7 @@ package com.bitchat.android.identity import android.content.Context +import android.content.SharedPreferences import com.bitchat.android.model.AuthenticatedPeerState import com.bitchat.android.model.PeerCapabilities import org.junit.Assert.assertArrayEquals @@ -70,4 +71,62 @@ class PrivateMediaCapabilityPinPersistenceTest { assertEquals(null, afterPanic.getAuthenticatedPeerState(fingerprint)) assertFalse(afterPanic.isPrivateMediaCapable(fingerprint)) } + + @Test + fun `identity panic wipe preserves NDR protection until native reset succeeds`() { + val protected = mapOf( + "favorite_relationships" to """{"contact":"pinned"}""", + "favorite_peerid_index" to """{"peer":"npub"}""", + "favorite_ndr_rebind_v1" to """{"version":1}""" + ) + protected.forEach { (key, value) -> + assertTrue(manager.storeSecureValueSynchronously(key, value)) + } + assertTrue(manager.storeSecureValueSynchronously("unrelated_secret", "remove-me")) + + assertTrue(manager.clearIdentityData()) + + protected.forEach { (key, value) -> + assertEquals(value, manager.getSecureValue(key)) + } + assertEquals(null, manager.getSecureValue("unrelated_secret")) + } + + @Test + fun `identity panic wipe reports a failed durable commit`() { + val failingPreferences = CommitFailingSharedPreferences(prefs) + val failingManager = SecureIdentityStateManager(failingPreferences, testOnly = true) + assertTrue(failingManager.storeSecureValueSynchronously("unrelated_secret", "keep")) + + failingPreferences.failCommits = true + + assertFalse(failingManager.clearIdentityData()) + assertEquals("keep", failingManager.getSecureValue("unrelated_secret")) + } + + private class CommitFailingSharedPreferences( + private val delegate: SharedPreferences + ) : SharedPreferences by delegate { + var failCommits = false + + override fun edit(): SharedPreferences.Editor = + CommitFailingEditor(delegate.edit()) + + private inner class CommitFailingEditor( + private val delegateEditor: SharedPreferences.Editor + ) : SharedPreferences.Editor by delegateEditor { + override fun clear(): SharedPreferences.Editor { + delegateEditor.clear() + return this + } + + override fun putString(key: String?, value: String?): SharedPreferences.Editor { + delegateEditor.putString(key, value) + return this + } + + override fun commit(): Boolean = + if (failCommits) false else delegateEditor.commit() + } + } } diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/FileNdrEstablishedSessionMarkerStoreTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/FileNdrEstablishedSessionMarkerStoreTest.kt new file mode 100644 index 00000000..73f7a308 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/nostr/FileNdrEstablishedSessionMarkerStoreTest.kt @@ -0,0 +1,54 @@ +package com.bitchat.android.nostr + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.IOException +import java.nio.file.Files + +class FileNdrEstablishedSessionMarkerStoreTest { + private val accountPubkey = "ab".repeat(32) + + @Test + fun establishedAndPanicMarkersHaveIndependentDurableLifetimes() { + val directory = Files.createTempDirectory("ndr-marker-store") + .resolve("markers") + .toFile() + val store = FileNdrEstablishedSessionMarkerStore(directory) + + assertFalse(store.contains(accountPubkey)) + assertFalse(store.isPanicWipeRequired()) + + store.mark(accountPubkey) + store.markPanicWipeRequired() + assertTrue(store.contains(accountPubkey)) + assertTrue(store.isPanicWipeRequired()) + + store.clearEstablishedSessions() + assertFalse(store.contains(accountPubkey)) + assertTrue(store.isPanicWipeRequired()) + + store.clearPanicWipeRequired() + assertFalse(store.isPanicWipeRequired()) + } + + @Test + fun unreadableMarkerDirectoryShapeFailsClosed() { + val directory = Files.createTempDirectory("ndr-marker-invalid") + .resolve("markers") + .toFile() + directory.writeText("not-a-directory") + val store = FileNdrEstablishedSessionMarkerStore(directory) + + assertThrows(IOException::class.java) { + store.contains(accountPubkey) + } + assertThrows(IOException::class.java) { + store.isPanicWipeRequired() + } + assertThrows(IOException::class.java) { + store.clearEstablishedSessions() + } + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/FileNdrPanicStorageQuarantineTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/FileNdrPanicStorageQuarantineTest.kt new file mode 100644 index 00000000..211fed7e --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/nostr/FileNdrPanicStorageQuarantineTest.kt @@ -0,0 +1,62 @@ +package com.bitchat.android.nostr + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.nio.file.Files + +class FileNdrPanicStorageQuarantineTest { + @Test + fun activeStateIsRenamedBeforeWipeAndResidueSurvivesRestartUntilCompletion() { + val parent = Files.createTempDirectory("ndr-panic-quarantine").toFile() + val storage = parent.resolve("ndr").apply { + resolve("pairwise-v1/account/state").apply { + parentFile.mkdirs() + writeText("sensitive") + } + } + val quarantineDirectory = parent.resolve("quarantine") + val quarantine = FileNdrPanicStorageQuarantine(storage, quarantineDirectory) + + quarantine.begin() + + assertFalse(storage.exists()) + assertTrue(quarantineDirectory.resolve("pairwise-v1/account/state").isFile) + assertTrue( + FileNdrPanicStorageQuarantine(storage, quarantineDirectory).isPending() + ) + + quarantine.wipeNativeState() + + assertTrue(quarantine.isPending()) + assertTrue(quarantineDirectory.listFiles()?.isEmpty() == true) + + quarantine.clear() + + assertFalse(quarantine.isPending()) + assertFalse(quarantineDirectory.exists()) + } + + @Test + fun retryWipesAnyActiveStateCreatedBesideExistingQuarantine() { + val parent = Files.createTempDirectory("ndr-panic-retry").toFile() + val storage = parent.resolve("ndr") + val quarantineDirectory = parent.resolve("quarantine").apply { mkdirs() } + storage.resolve("late/state").apply { + parentFile.mkdirs() + writeText("sensitive") + } + quarantineDirectory.resolve("old/state").apply { + parentFile.mkdirs() + writeText("sensitive") + } + val quarantine = FileNdrPanicStorageQuarantine(storage, quarantineDirectory) + + quarantine.begin() + quarantine.wipeNativeState() + + assertFalse(storage.exists()) + assertTrue(quarantineDirectory.listFiles()?.isEmpty() == true) + assertTrue(quarantine.isPending()) + } +} 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 6d4138a3..46578a4f 100644 --- a/app/src/test/kotlin/com/bitchat/android/nostr/NdrNostrServiceTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/nostr/NdrNostrServiceTest.kt @@ -40,6 +40,29 @@ class NdrNostrServiceTest { assertEquals(NdrSendResult.NO_SESSION, service.sendIfPossible("hello", peerPubkey)) } + @Test + fun disabledRolloutCanRetirePinnedPeerWithoutStartingTransport() { + NdrFeatureGate.setEnabledForTests(false) + val relay = FakeRelayManager() + val runtime = FakeNdrPairwiseRuntime(mutableSetOf(peerPubkey)).apply { + pendingEvents += NdrPubSubEvent( + kind = "subscribe", + actionId = "must-stay-dormant", + subid = "messages", + filterJson = """{"authors":["$peerPubkey"],"kinds":[1060]}""" + ) + } + val factory = FakeNdrRuntimeFactory(runtime) + val service = service(relay, factory) + + assertTrue(service.retirePeerForMaintenance(testIdentity(), peerPubkey)) + + assertFalse(service.isConfigured) + assertEquals(1, factory.createdCount) + assertEquals(listOf(peerPubkey), runtime.retiredPeers) + assertTrue(relay.subscriptions.isEmpty()) + } + @Test fun configureCachesPairwiseInviteAndInstallsOnlyKind1060Subscription() { val relay = FakeRelayManager() @@ -279,6 +302,7 @@ class NdrNostrServiceTest { val scheduled = scheduler.scheduled.single() assertTrue(service.resetForPanic()) + assertTrue(service.completePanicReset()) relay.failSend = false scheduler.runNext() @@ -516,6 +540,33 @@ class NdrNostrServiceTest { assertTrue(runtime.acceptedInvites.isEmpty()) } + @Test + fun markerFailureTearsDownAndLatchesBeforeSessionCreatingFfiCall() { + val markers = InMemoryMarkerStore().apply { + markFailure = java.io.IOException("marker storage unavailable") + } + val runtime = FakeNdrPairwiseRuntime() + val factory = FakeNdrRuntimeFactory(runtime) + val service = service( + runtimeFactory = factory, + markerStore = markers, + invitePeerResolver = { peerPubkey } + ) + service.configureIfNeeded(testIdentity()) + + val result = service.processOutOfBandEventJson( + inviteEvent(peerPubkey), + expectedPeerPubkeyHex = peerPubkey + ) + + assertTrue(result.outboundPayloads.isEmpty()) + assertTrue(runtime.acceptedInvites.isEmpty()) + assertTrue(runtime.destroyed) + assertEquals(NdrSendResult.FAILED, service.sendIfPossible("blocked", peerPubkey)) + service.configureIfNeeded(testIdentity()) + assertEquals(1, factory.createdCount) + } + @Test fun authenticatedGiftWrapResponseMayUseEphemeralOuterPubkey() { val runtime = FakeNdrPairwiseRuntime() @@ -608,6 +659,7 @@ class NdrNostrServiceTest { service.configureIfNeeded(testIdentity()) assertTrue(service.resetForPanic()) + assertTrue(service.completePanicReset()) service.configureIfNeeded(testIdentity()) relay.canceledConfirmations.single().completion(true) @@ -669,6 +721,7 @@ class NdrNostrServiceTest { } assertTrue(service.resetForPanic()) + assertTrue(service.completePanicReset()) assertFalse(service.isConfigured) assertNull(service.currentInviteEventJson()) @@ -679,19 +732,78 @@ class NdrNostrServiceTest { @Test fun failedPanicStorageWipeKeepsNdrDisabled() { + val markers = InMemoryMarkerStore() val runtime = FakeNdrPairwiseRuntime() val factory = FakeNdrRuntimeFactory(runtime) val service = service( runtimeFactory = factory, - storageResetter = { throw java.io.IOException("busy") } + storageResetter = { throw java.io.IOException("busy") }, + markerStore = markers ) service.configureIfNeeded(testIdentity()) assertFalse(service.resetForPanic()) + assertTrue(service.isPanicWipeRequired) service.configureIfNeeded(testIdentity()) assertFalse(service.isConfigured) assertEquals(1, factory.createdCount) + + val restartedFactory = FakeNdrRuntimeFactory(FakeNdrPairwiseRuntime()) + val restarted = service( + runtimeFactory = restartedFactory, + storageResetter = {}, + markerStore = markers + ) + restarted.configureIfNeeded(testIdentity()) + assertTrue(restarted.isPanicWipeRequired) + assertFalse(restarted.isConfigured) + assertEquals(0, restartedFactory.createdCount) + + assertTrue(restarted.resetForPanic()) + assertTrue(restarted.completePanicReset()) + restarted.configureIfNeeded(testIdentity()) + assertFalse(restarted.isPanicWipeRequired) + assertTrue(restarted.isConfigured) + assertEquals(1, restartedFactory.createdCount) + } + + @Test + fun failedPrimaryPanicMarkerStillBlocksRestartViaQuarantine() { + val markers = InMemoryMarkerStore().apply { + panicMarkFailure = java.io.IOException("marker unavailable") + } + val quarantine = InMemoryPanicStorageQuarantine() + val service = service( + runtimeFactory = FakeNdrRuntimeFactory(FakeNdrPairwiseRuntime()), + storageResetter = {}, + markerStore = markers, + panicStorageQuarantine = quarantine + ) + service.configureIfNeeded(testIdentity()) + + assertTrue(service.resetForPanic()) + assertFalse(markers.isPanicWipeRequired()) + assertTrue(quarantine.pending) + + val restartedFactory = FakeNdrRuntimeFactory(FakeNdrPairwiseRuntime()) + val restarted = service( + runtimeFactory = restartedFactory, + storageResetter = {}, + markerStore = markers, + panicStorageQuarantine = quarantine + ) + restarted.configureIfNeeded(testIdentity()) + + assertTrue(restarted.isPanicWipeRequired) + assertFalse(restarted.isConfigured) + assertEquals(0, restartedFactory.createdCount) + + markers.panicMarkFailure = null + assertTrue(restarted.resetForPanic()) + assertTrue(restarted.completePanicReset()) + assertFalse(quarantine.pending) + assertFalse(restarted.isPanicWipeRequired) } @Test @@ -728,6 +840,7 @@ class NdrNostrServiceTest { } assertTrue(resetSucceeded) + assertTrue(service.completePanicReset()) assertTrue(runtime.destroyedAfterSendCompleted) } @@ -888,16 +1001,30 @@ class NdrNostrServiceTest { @Test fun peerRetirementIsDurableAndFailureAbortsHostRebind() { - val runtime = FakeNdrPairwiseRuntime(mutableSetOf(peerPubkey)) + val failingPeer = "aa".repeat(32) + val runtime = FakeNdrPairwiseRuntime(mutableSetOf(peerPubkey, failingPeer)) val service = service(runtimeFactory = FakeNdrRuntimeFactory(runtime)) service.configureIfNeeded(testIdentity()) assertTrue(service.retirePeer(peerPubkey)) assertEquals(listOf(peerPubkey), runtime.retiredPeers) assertFalse(service.hasActiveSession(peerPubkey)) + assertTrue(service.retirePeer(peerPubkey)) runtime.retirePeerFailure = java.io.IOException("storage unavailable") - assertFalse(service.retirePeer("aa".repeat(32))) + assertFalse(service.retirePeer(failingPeer)) + } + + @Test + fun peerRetirementExceptionAfterDurableRemovalIsIdempotentSuccess() { + val runtime = FakeNdrPairwiseRuntime(mutableSetOf(peerPubkey)).apply { + retirePeerFailureAfterRemoval = java.io.IOException("response lost") + } + val service = service(runtimeFactory = FakeNdrRuntimeFactory(runtime)) + service.configureIfNeeded(testIdentity()) + + assertTrue(service.retirePeer(peerPubkey)) + assertFalse(service.hasActiveSession(peerPubkey)) } @Test @@ -929,6 +1056,7 @@ class NdrNostrServiceTest { assertEquals(NdrSendResult.FAILED, service.sendIfPossible("hello", peerPubkey)) assertTrue(service.resetForPanic()) + assertTrue(service.completePanicReset()) service.configureIfNeeded(testIdentity()) assertTrue(service.isConfigured) @@ -940,6 +1068,36 @@ class NdrNostrServiceTest { assertTrue(shouldUseLegacyNostrFallback(NdrSendResult.NO_SESSION)) assertFalse(shouldUseLegacyNostrFallback(NdrSendResult.FAILED)) assertFalse(shouldUseLegacyNostrFallback(NdrSendResult.SENT)) + assertFalse( + shouldUseLegacyNostrFallback( + NdrSendResult.NO_SESSION, + ndrRequired = true + ) + ) + assertFalse( + shouldUseLegacyNostrFallback( + NdrSendResult.NO_SESSION, + rebindBlocked = true + ) + ) + assertTrue( + isLegacyNostrAllowedWhenNdrDisabled( + ndrRequired = false, + rebindBlocked = false + ) + ) + assertFalse( + isLegacyNostrAllowedWhenNdrDisabled( + ndrRequired = true, + rebindBlocked = false + ) + ) + assertFalse( + isLegacyNostrAllowedWhenNdrDisabled( + ndrRequired = false, + rebindBlocked = true + ) + ) } private fun service( @@ -949,6 +1107,8 @@ class NdrNostrServiceTest { invitePeerResolver: (String) -> String? = { null }, retryScheduler: NdrRetryScheduler = FakeRetryScheduler(), markerStore: NdrEstablishedSessionMarkerStore = InMemoryMarkerStore(), + panicStorageQuarantine: NdrPanicStorageQuarantine = + InMemoryPanicStorageQuarantine(), pairwiseStateExists: (String) -> Boolean = { false } ) = NdrNostrService( relayManager = relayManager, @@ -956,6 +1116,7 @@ class NdrNostrServiceTest { storageDirectoryProvider = { "/tmp/ndr-test" }, storageResetter = storageResetter, establishedSessionMarkers = markerStore, + panicStorageQuarantine = panicStorageQuarantine, pairwiseStateExists = pairwiseStateExists, invitePeerResolver = invitePeerResolver, retryScheduler = retryScheduler @@ -1162,17 +1323,53 @@ class NdrNostrServiceTest { private class InMemoryMarkerStore : NdrEstablishedSessionMarkerStore { private val marked = mutableSetOf() + var markFailure: Throwable? = null + var panicMarkFailure: Throwable? = null + private var panicWipeRequired = false override fun contains(accountPubkeyHex: String): Boolean = accountPubkeyHex.lowercase() in marked override fun mark(accountPubkeyHex: String) { + markFailure?.let { throw it } marked += accountPubkeyHex.lowercase() } - override fun clearAll() { + override fun clearEstablishedSessions() { marked.clear() } + + override fun isPanicWipeRequired(): Boolean = panicWipeRequired + + override fun markPanicWipeRequired() { + panicMarkFailure?.let { throw it } + panicWipeRequired = true + } + + override fun clearPanicWipeRequired() { + panicWipeRequired = false + } + } + + private class InMemoryPanicStorageQuarantine : NdrPanicStorageQuarantine { + var pending = false + var nativeStateWiped = false + + override fun isPending(): Boolean = pending + + override fun begin() { + pending = true + } + + override fun wipeNativeState() { + check(pending) + nativeStateWiped = true + } + + override fun clear() { + check(nativeStateWiped) + pending = false + } } private class FakeNdrPairwiseRuntime( @@ -1204,6 +1401,7 @@ class NdrNostrServiceTest { var activeSessionLookupFailure: Throwable? = null var knownPeerPubkeysFailure: Throwable? = null var retirePeerFailure: Throwable? = null + var retirePeerFailureAfterRemoval: Throwable? = null val retiredPeers = mutableListOf() @Volatile var sendTextCompleted = false @@ -1279,8 +1477,10 @@ class NdrNostrServiceTest { override fun retirePeer(peerPubkeyHex: String): Boolean { retirePeerFailure?.let { throw it } retiredPeers += peerPubkeyHex.lowercase() - return activeSessionPeers.remove(peerPubkeyHex.lowercase()) || + val removed = activeSessionPeers.remove(peerPubkeyHex.lowercase()) || halfReadySessionPeers.remove(peerPubkeyHex.lowercase()) + retirePeerFailureAfterRemoval?.let { throw it } + return removed } override fun sendText( diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/NdrPanicStartupRecoveryTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/NdrPanicStartupRecoveryTest.kt new file mode 100644 index 00000000..6cd26c60 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/nostr/NdrPanicStartupRecoveryTest.kt @@ -0,0 +1,149 @@ +package com.bitchat.android.nostr + +import org.junit.After +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class NdrPanicStartupRecoveryTest { + @After + fun restoreNetworkGate() { + NdrPanicStartupRecovery.recoverBeforeNetwork( + markerStore = InMemoryMarkerStore(retryRequired = false), + panicStorageQuarantine = InMemoryQuarantine(pending = false), + clearIdentity = { true }, + clearFavorites = { true } + ) + } + + @Test + fun startupRetryFinishesNativeAndHostWipeBeforeAllowingNetwork() { + val markers = InMemoryMarkerStore(retryRequired = true) + val quarantine = InMemoryQuarantine(pending = false) + var identityCleared = false + var favoritesCleared = false + + val recovered = NdrPanicStartupRecovery.recoverBeforeNetwork( + markerStore = markers, + panicStorageQuarantine = quarantine, + clearIdentity = { + assertTrue(quarantine.nativeStateWiped) + identityCleared = true + true + }, + clearFavorites = { + assertTrue(identityCleared) + favoritesCleared = true + true + } + ) + + assertTrue(recovered) + assertTrue(identityCleared) + assertTrue(favoritesCleared) + assertFalse(markers.retryRequired) + assertFalse(quarantine.pending) + assertTrue(NdrPanicStartupRecovery.isNetworkStartupAllowed()) + } + + @Test + fun failedHostWipeKeepsStartupInertAndRetryMarkerDurable() { + val markers = InMemoryMarkerStore(retryRequired = true) + val quarantine = InMemoryQuarantine(pending = false) + + val recovered = NdrPanicStartupRecovery.recoverBeforeNetwork( + markerStore = markers, + panicStorageQuarantine = quarantine, + clearIdentity = { true }, + clearFavorites = { false } + ) + + assertFalse(recovered) + assertTrue(markers.retryRequired) + assertTrue(quarantine.pending) + assertFalse(NdrPanicStartupRecovery.isNetworkStartupAllowed()) + } + + @Test + fun failedIdentityWipeKeepsStartupInertAndBothRetrySignalsDurable() { + val markers = InMemoryMarkerStore(retryRequired = true) + val quarantine = InMemoryQuarantine(pending = false) + var favoritesClearCalled = false + + val recovered = NdrPanicStartupRecovery.recoverBeforeNetwork( + markerStore = markers, + panicStorageQuarantine = quarantine, + clearIdentity = { false }, + clearFavorites = { + favoritesClearCalled = true + true + } + ) + + assertFalse(recovered) + assertFalse(favoritesClearCalled) + assertTrue(markers.retryRequired) + assertTrue(quarantine.pending) + assertFalse(NdrPanicStartupRecovery.isNetworkStartupAllowed()) + } + + @Test + fun quarantineResidueAloneTriggersRecoveryBeforeNetworkStartup() { + val markers = InMemoryMarkerStore(retryRequired = false) + val quarantine = InMemoryQuarantine(pending = true) + var identityCleared = false + + val recovered = NdrPanicStartupRecovery.recoverBeforeNetwork( + markerStore = markers, + panicStorageQuarantine = quarantine, + clearIdentity = { + identityCleared = true + true + }, + clearFavorites = { true } + ) + + assertTrue(recovered) + assertTrue(identityCleared) + assertTrue(quarantine.nativeStateWiped) + assertFalse(quarantine.pending) + assertTrue(NdrPanicStartupRecovery.isNetworkStartupAllowed()) + } + + private class InMemoryMarkerStore( + var retryRequired: Boolean + ) : NdrEstablishedSessionMarkerStore { + override fun contains(accountPubkeyHex: String): Boolean = false + override fun mark(accountPubkeyHex: String) = Unit + override fun clearEstablishedSessions() = Unit + override fun isPanicWipeRequired(): Boolean = retryRequired + override fun markPanicWipeRequired() { + retryRequired = true + } + override fun clearPanicWipeRequired() { + retryRequired = false + } + } + + private class InMemoryQuarantine( + var pending: Boolean + ) : NdrPanicStorageQuarantine { + var nativeStateWiped = false + + override fun isPending(): Boolean = pending + + override fun begin() { + pending = true + } + + override fun wipeNativeState() { + check(pending) + nativeStateWiped = true + } + + override fun clear() { + check(nativeStateWiped) + pending = false + } + } +}