harden pairwise NDR durability and downgrade protection

This commit is contained in:
Dev 2026-07-27 20:05:11 +03:00
parent eb85711b5f
commit 8f64c155ce
18 changed files with 2348 additions and 269 deletions

View File

@ -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) { }

View File

@ -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)
}

View File

@ -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<FavoritesChangeListener>()
private var ndrPeerRetirementGuard: ((oldPeerPubkeyHex: String) -> Boolean)? = null
private val ndrRebindsInProgress = mutableSetOf<String>()
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<FavoriteRelationship> = favorites.values.filter { it.isMutual }
fun getOurFavorites(): List<FavoriteRelationship> = favorites.values.filter { it.isFavorite }
fun getAllRelationships(): List<FavoriteRelationship> = favorites.values.toList()
@Synchronized
fun getMutualFavorites(): List<FavoriteRelationship> =
favoritesForReadLocked().filter { it.isMutual }
@Synchronized
fun clearAllFavorites() {
fun getOurFavorites(): List<FavoriteRelationship> =
favoritesForReadLocked().filter { it.isFavorite }
@Synchronized
fun getAllRelationships(): List<FavoriteRelationship> = 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<FavoriteRelationship> =
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<String, FavoriteRelationship>
): Boolean = runCatching {
stateManager.commitSecureValuesSynchronously(
values = mapOf(FAVORITES_KEY to favoritesJson(snapshot))
)
}.getOrDefault(false)
private fun commitPeerIdIndexSnapshotLocked(
snapshot: Map<String, String>
): Boolean = runCatching {
stateManager.commitSecureValuesSynchronously(
values = mapOf(PEERID_INDEX_KEY to gson.toJson(snapshot))
)
}.getOrDefault(false)
private fun favoritesJson(
snapshot: Map<String, FavoriteRelationship>
): 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<String, FavoriteRelationshipData> = 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<String> =
listOfNotNull(targetNostrPubkeyHex, targetNdrSessionPubkeyHex).toSet()
fun quarantinedIdentityPubkeys(): Set<String> =
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()
)
}

View File

@ -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<String, String> = emptyMap(),
removals: Set<String> = 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)
}
/**

View File

@ -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")
}

View File

@ -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<String>()
private val inFlightRelayEventsByActionId = linkedMapOf<String, String>()
@ -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 {

View File

@ -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
}
}

View File

@ -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)
}
}
}

View File

@ -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

View File

@ -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
)
}
}

View File

@ -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

View File

@ -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
}
}

View File

@ -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<Context>()
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<String>()
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<String>()
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<String>()
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<String, FavoriteRelationship>
@ -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<String>()
override fun putString(
key: String?,
value: String?
): SharedPreferences.Editor {
delegateEditor.putString(key, value)
return this
}
override fun putStringSet(
key: String?,
values: MutableSet<String>?
): 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
}
}
}
}

View File

@ -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()
}
}
}

View File

@ -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()
}
}
}

View File

@ -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())
}
}

View File

@ -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<String>()
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<String>()
@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(

View File

@ -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
}
}
}