mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-08-29 07:16:08 +00:00
harden pairwise NDR durability and downgrade protection
This commit is contained in:
parent
eb85711b5f
commit
8f64c155ce
@ -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) { }
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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()
|
||||
)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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")
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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())
|
||||
}
|
||||
}
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user