Skip to content

feat: add block & blackhole peer protection#601

Merged
torlando-tech merged 5 commits intomainfrom
feat/block-blackhole
Mar 11, 2026
Merged

feat: add block & blackhole peer protection#601
torlando-tech merged 5 commits intomainfrom
feat/block-blackhole

Conversation

@torlando-tech
Copy link
Copy Markdown
Owner

Summary

  • Block (LXMF-level): Silently drops messages from a peer via LXMRouter.ignore_destination(). Operates on destination hashes. Blocked conversations are hidden from the chat list.
  • Blackhole (Reticulum Transport-level): Drops path entries and invalidates announces via reticulum.blackhole_identity(). Operates on identity hashes. Always available as a toggle — when transport is disabled, a warning explains the blackhole will activate when transport is later enabled.
  • Defense in depth: DB check in ServicePersistenceManager catches messages during the window between Python init and LXMF ignore list restore.

Changes

Database (Layer 1)

  • New BlockedPeerEntity with composite PK (peerHash + identityHash), FK cascade to LocalIdentityEntity
  • New BlockedPeerDao with suspend + Flow methods (CRUD, hash lists, blackhole queries, peer count)
  • Room migration 40→41 creating blocked_peers table
  • ConversationDao: NOT EXISTS filter hides blocked peers from enriched conversation queries
  • New BlockedPeerRepository with active-identity-scoped methods

Python (Layer 2)

  • New blocking_manager.py: BlockingManager class with block/unblock/blackhole/restore methods
  • reticulum_wrapper.py: thin delegation to BlockingManager

Service (Layer 3)

  • ServicePersistenceManager: isBlockedPeer() DB check before shouldBlockUnknownSender check
  • ReticulumServiceBinder: 6 new AIDL methods, LXMF block list restore at startup
  • ReticulumProtocol + ServiceReticulumProtocol: block/unblock/blackhole bridge methods

UI (Layer 4)

  • BlockUserDialog: confirmation dialog with "delete messages" and "blackhole" checkboxes
  • Entry point 1: Chat list context menu (long-press conversation)
  • Entry point 2: Messaging screen overflow menu (inside chat)
  • Entry point 3: Announce detail screen (block button)
  • BlockedUsersScreen: Settings management screen with unblock + blackhole toggle per peer
  • PrivacyCard: "Blocked Users (N)" navigation row in Settings

Test plan

  • BlockedPeerDaoTest — 14 tests (insert, query, delete, FK cascade, Flow reactivity, hash lists, blackhole toggle, upsert)
  • ServicePersistenceManagerDatabaseTest — 2 new tests (blocks message from blocked peer, allows message from non-blocked peer)
  • ChatsViewModelTest — 5 new tests (block persists + notifies protocol, blackhole, delete conversation, null identity hash, error handling)
  • ChatsScreenTest — updated all ConversationContextMenu calls with onBlockUser
  • MessagingViewModelTest, MessagingViewModelImageLoadingTest, AnnounceStreamViewModelTest — constructor updates verified
  • Manual: long-press conversation → Block → verify conversation disappears → Settings → Blocked Users → verify listed → Unblock → verify conversation reappears
  • Manual: block from messaging overflow menu → verify navigates back to chat list
  • Manual: block from announce detail screen → verify dialog and block behavior

🤖 Generated with Claude Code

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Mar 5, 2026

Greptile Summary

This PR implements a two-layer peer protection system: LXMF-level blocking (via LXMRouter.ignore_destination()) and Reticulum transport-level blackholing (via reticulum.blackhole_identity()). The architecture is sound — a new blocked_peers Room table (migration 41→42) serves as the persistent source of truth, the LXMF ignore list is restored at startup, and a DB check in ServicePersistenceManager provides defense-in-depth during the startup window. Three UI entry points (chat list, messaging screen, announce detail) and a dedicated Settings → Blocked Users screen complete the feature.

Issues found:

  • Logic bug: In AnnounceDetailScreen, the deleteMessages boolean returned by BlockUserDialog.onConfirm is silently discarded — AnnounceStreamViewModel.blockPeer() has no deleteConversation parameter, so checking "Also delete conversation and messages" from this entry point has no effect.
  • Stale references: reticulum_wrapper.py's _get_blocking_manager() caches a BlockingManager that captures self.router and self.reticulum at construction time. If either is replaced after a transport reconnect, the manager silently operates on stale objects.
  • Doc mismatch: The PR description states "Room migration 40→41" but the actual migration is 41→42 (database version bumps from 41 to 42).
  • Snapshot transport status: isTransportEnabled is fetched once at ViewModel init in ChatsViewModel, MessagingViewModel, and AnnounceStreamViewModel. The warning text in BlockUserDialog ("Transport is currently disabled…") may be stale if transport mode changes while the screen is open.

Confidence Score: 3/5

  • Safe to merge with the deleteMessages bug fixed — users can work around it until then, but the checkbox currently misleads them.
  • One confirmed logic bug (delete checkbox in announce detail has no effect), one potential runtime issue (stale BlockingManager references on reconnect), and a documentation discrepancy in the migration version. The core blocking/blackholing infrastructure is well-tested and the DB layer is solid.
  • app/src/main/java/com/lxmf/messenger/ui/screens/AnnounceDetailScreen.kt (deleteMessages ignored) and python/reticulum_wrapper.py (stale BlockingManager caching).

Important Files Changed

Filename Overview
python/blocking_manager.py New BlockingManager class handling LXMF block and Reticulum transport blackhole operations. blackhole_identity/unblackhole_identity now correctly use result is True (not result is not False), and restore_blocked_destinations correctly processes hashes individually. The BlockingManager captures router and reticulum at construction time and will hold stale references if those objects are later replaced.
app/src/main/java/com/lxmf/messenger/ui/screens/AnnounceDetailScreen.kt Adds a Block button and wires BlockUserDialog. The deleteMessages parameter returned by the dialog's onConfirm callback is silently discarded — AnnounceStreamViewModel.blockPeer() has no deleteConversation parameter, so the "Also delete conversation and messages" checkbox has no effect from this entry point.
app/src/main/java/com/lxmf/messenger/service/binder/ReticulumServiceBinder.kt Adds six new AIDL implementations for block/unblock/blackhole/restore. Error paths now correctly use JSONObject().put() instead of string interpolation. The startup restoreBlockedDestinations() helper queries the DB directly and passes a Python list to the wrapper. No new issues found here.
data/src/main/java/com/lxmf/messenger/data/di/DatabaseModule.kt Adds MIGRATION_41_42 which correctly creates the blocked_peers table with FK and index. Note: the PR description incorrectly describes this as migration 40→41. The migration SQL itself is correct.
data/src/main/java/com/lxmf/messenger/data/db/dao/ConversationDao.kt Adds a NOT EXISTS subquery to both enriched conversation queries to hide blocked peers from the chat list. The filter is correctly scoped by both peerHash and identityHash. No issues found.
app/src/main/java/com/lxmf/messenger/service/persistence/ServicePersistenceManager.kt Adds a DB-level isBlockedPeer() check before the shouldBlockUnknownSender check to provide defense-in-depth during the window before the LXMF ignore list is restored. Correctly fails open on DB error. No issues found.
app/src/main/java/com/lxmf/messenger/viewmodel/ChatsViewModel.kt Adds blockUser() wiring DB persistence, LXMF block, optional blackhole, and optional conversation deletion. Transport status is fetched as a one-shot snapshot at init — stale if transport is toggled during the session (low severity, only affects the warning text in the block dialog).
python/reticulum_wrapper.py Thin delegation to BlockingManager via a lazy-init pattern. The cached BlockingManager captures self.router and self.reticulum at init time and is never re-created if those objects are replaced after a reconnect, leaving the manager with stale references.

Sequence Diagram

sequenceDiagram
    participant UI as UI (ChatsScreen / MessagingScreen / AnnounceDetailScreen)
    participant VM as ViewModel (ChatsVM / MessagingVM / AnnounceStreamVM)
    participant Repo as BlockedPeerRepository
    participant DB as Room DB (BlockedPeerDao)
    participant Binder as ReticulumServiceBinder
    participant Py as BlockingManager (Python)
    participant LXMF as LXMRouter
    participant RNS as RNS.Reticulum (Transport)

    UI->>VM: blockUser(peerHash, identityHash, blackhole, deleteConv)
    VM->>Repo: blockPeer(peerHash, identityHash, displayName, blackhole)
    Repo->>DB: insertBlockedPeer(entity)
    VM->>Binder: reticulumProtocol.blockDestination(peerHash)
    Binder->>Py: wrapper.block_destination(hex)
    Py->>LXMF: router.ignore_destination(bytes)
    opt blackhole && identityHash != null
        VM->>Binder: reticulumProtocol.blackholeIdentity(identityHash)
        Binder->>Py: wrapper.blackhole_identity(hex)
        Py->>RNS: reticulum.blackhole_identity(bytes)
    end
    opt deleteConversation
        VM->>Repo: conversationRepository.deleteConversation(peerHash)
    end

    Note over DB,LXMF: On app restart — LXMF ignore list must be restored
    Binder->>DB: blockedPeerDao.getBlockedPeerHashes(activeIdentityHash)
    Binder->>Py: wrapper.restore_blocked_destinations(pyList)
    Py->>LXMF: router.ignore_destination(bytes) per hash

    Note over DB: Defense-in-depth: ServicePersistenceManager<br/>checks DB before accepting any message
Loading

Comments Outside Diff (4)

  1. python/reticulum_wrapper.py, line 8144-8149 (link)

    Stale router/reticulum references after reconnect

    _get_blocking_manager() captures self.router and self.reticulum at lazy-init time and never re-initialises the BlockingManager afterwards. If ReticulumWrapper replaces either of these objects (e.g. after a transport reconnect or router restart), the cached BlockingManager will still hold the original, now-stale references, silently operating on a dead router/reticulum instance.

    Consider either:

    1. Not caching the BlockingManager (create it on every delegation call — it holds no mutable state of its own), or
    2. Invalidating _blocking_manager wherever self.router or self.reticulum are reassigned.
  2. data/src/main/java/com/lxmf/messenger/data/di/DatabaseModule.kt, line 1592-1614 (link)

    Migration version mismatch with PR description

    The PR description states "Room migration 40→41 creating blocked_peers table", but the actual migration here is MIGRATION_41_42 and the database version bumps from 41 to 42. The migration SQL is correct, but the PR description (and presumably changelog or release notes) documents the wrong version numbers. This should be updated to reflect the actual 41→42 migration to avoid confusing future maintainers tracing schema history.

  3. app/src/main/java/com/lxmf/messenger/viewmodel/ChatsViewModel.kt, line 1329-1337 (link)

    isTransportEnabled is a one-shot snapshot, not reactive

    _isTransportEnabled is fetched once in init and never updated. If the user enables/disables transport mode while this screen is alive (e.g., they go to Settings → toggle transport, then return to Chats), the BlockUserDialog will display the transport warning based on the stale snapshot. The same pattern appears in MessagingViewModel and AnnounceStreamViewModel.

    Since isTransportEnabled only affects the warning text inside the block dialog (a low-stakes, infrequent operation), this is unlikely to cause a hard failure — but if the transport status can change while the app is running, consider subscribing to a transport-status flow or at least refreshing the value each time the dialog is opened.

  4. app/src/main/java/com/lxmf/messenger/ui/screens/AnnounceDetailScreen.kt, line 449-464 (link)

    deleteMessages silently discarded when blocking from announce detail

    The BlockUserDialog callback receives both deleteMessages and blackholeEnabled, but deleteMessages is never forwarded — AnnounceStreamViewModel.blockPeer() has no deleteConversation parameter. If the user checks "Also delete conversation and messages" in this entry point, nothing is deleted. The checkbox appears functional but has zero effect.

    This needs either:

    1. A deleteConversation: Boolean parameter added to AnnounceStreamViewModel.blockPeer() with a corresponding conversationRepository.deleteConversation(destinationHash) call inside it, or
    2. The checkbox hidden/suppressed from this entry point (since the user may not have an existing conversation to delete from the announce detail screen).

Last reviewed commit: 89edff0

@torlando-tech torlando-tech linked an issue Mar 5, 2026 that may be closed by this pull request
@sentry
Copy link
Copy Markdown
Contributor

sentry bot commented Mar 5, 2026

Codecov Report

❌ Patch coverage is 87.50000% with 12 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
python/reticulum_wrapper.py 41.17% 10 Missing ⚠️
python/blocking_manager.py 97.46% 2 Missing ⚠️

📢 Thoughts on this report? Let us know!

@torlando-tech
Copy link
Copy Markdown
Owner Author

@greptileai

@torlando-tech torlando-tech added this to the v0.10.0 milestone Mar 7, 2026
Comment on lines +48 to +49
blockedPeerRepository.unblockPeer(peer.peerHash)
reticulumProtocol.unblockDestination(peer.peerHash)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: The unblockUser function updates the database before the transport layer. If the transport layer call fails, the user remains blocked but appears unblocked in the UI.
Severity: MEDIUM

Suggested Fix

Reverse the order of operations. First, call reticulumProtocol.unblockDestination to update the transport layer. Only upon its success should the database be updated by calling blockedPeerRepository.unblockPeer. Alternatively, implement a rollback mechanism for the database change if the transport layer call fails.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location:
app/src/main/java/com/lxmf/messenger/viewmodel/BlockedUsersViewModel.kt#L48-L49

Potential issue: In the `unblockUser` function, a user is removed from the local
database via `blockedPeerRepository.unblockPeer` before the corresponding call to
`reticulumProtocol.unblockDestination` removes them from the transport layer's ignore
list. There is no error handling to roll back the database change if the transport layer
call fails. This results in an inconsistent state where the user appears unblocked in
the application's UI, but their messages will continue to be silently dropped by the
transport layer.

torlando-tech and others added 5 commits March 10, 2026 23:25
Two-layer defense against spam and harassment on Reticulum mesh networks:

- Block (LXMF-level): silently drops messages from a peer via LXMRouter.ignore_destination()
- Blackhole (Reticulum Transport-level): drops path entries and invalidates announces via reticulum.blackhole_identity()

Database: new blocked_peers table with composite PK, Room migration 40→41, ConversationDao filters blocked peers from chat list.
Service: defense-in-depth DB check in ServicePersistenceManager, LXMF block list restore at startup.
UI: BlockUserDialog with optional delete + blackhole checkboxes, entry points from chat context menu, messaging overflow menu, and announce detail screen. BlockedUsersScreen in Settings for management.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Suppress TooManyFunctions on DatabaseModule (Hilt modules legitimately have one @provides per DAO)
- Remove unused deleteConversation param from AnnounceStreamViewModel.blockPeer()
- Add real assertions (slot captures + assertEquals) to ChatsViewModelTest block tests to satisfy NoVerifyOnlyTests rule
- Replace string interpolation with JSONObject in ReticulumServiceBinder error JSON (prevents invalid JSON from exception messages with special chars)
- Add interactive blackhole Switch toggle to BlockedPeerCard in BlockedUsersScreen

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Add isTransportEnabled stub to AnnounceDetailScreenTest mock (fixes Shard 3/4 failure)
- Add test_blocking_manager.py with 25 tests covering all BlockingManager methods
- Fix fragile success check: treat None return as failure (not just False)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Use `result is True` instead of `result is not None and result is not False`
  for blackhole/unblackhole success checks (prevents None being treated as success)
- Make restore_blocked_destinations handle per-hash errors gracefully instead of
  failing atomically (a single bad hash no longer aborts restoring all others)
- Add test_restore_partial_failure test case

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…wModelTest

The constructor call at line 992 was missing the blockedPeerRepository
argument added in the block & blackhole feature, causing CI compilation
failure.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@torlando-tech
Copy link
Copy Markdown
Owner Author

@greptileai

@torlando-tech torlando-tech merged commit d76783b into main Mar 11, 2026
15 checks passed
@torlando-tech torlando-tech deleted the feat/block-blackhole branch March 11, 2026 04:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Implement blackhole function

1 participant