Skip to content

feat: warn user on inbound LXMF messages with unverified signatures#876

Draft
torlando-tech wants to merge 1 commit into
mainfrom
feat/unverified-message-warning
Draft

feat: warn user on inbound LXMF messages with unverified signatures#876
torlando-tech wants to merge 1 commit into
mainfrom
feat/unverified-message-warning

Conversation

@torlando-tech

@torlando-tech torlando-tech commented May 1, 2026

Copy link
Copy Markdown
Owner

Summary

Warns the user on inbound LXMF messages whose sender signature could not be verified — the signature-forgery class where anyone who knows a recipient's destination hash can encrypt a packet claiming any source identity. Mirrors Sideband's app-layer warn-and-ingest pattern (not silent-drop), so legitimate first-contact messages aren't lost.

Rebased onto current main and reworked from the original approach. It now reads the signature-verification state the LXMF layer already produces (a field on the delivered message) rather than depending on a new callback API — so there is no library version bump required.

How it works

The LXMF layer already determines, per delivered message, whether the signature verified against a known sender identity. This PR threads that state to the UI on both backends:

  • Kotlin backend (NativeRnsBackendImpl): reads LXMessage.signatureValidated.
  • Python backend (PythonEventBridge): reads signature_validated, which event_bridge.py already emits on the delivery payload.

Data path:

backend (signatureValidated) 
  → ReceivedMessage.signatureVerified   (@Parcelize — crosses the :reticulum → UI IPC seam)
  → MessageEntity.signatureVerified      (Room)
  → Message → MessageUi.signatureVerified
  → UnverifiedSenderChip                 (rendered above any bubble shape)
  → MessageDetailScreen "Signature" card (Verified / Unverified)

signatureVerified semantics:

  • true — signature checked against a known source identity, valid.
  • false — signature could not be verified; UI warns.
  • null — sent messages (signed locally) and legacy rows; treated as "no warning".

Backend asymmetry (intentional, documented)

Python LXMF's lxmf_delivery delivers both SOURCE_UNKNOWN and SIGNATURE_INVALID to the app, so the Python flavor warns on both. LXMF-kt's router drops SIGNATURE_INVALID before the app sees it, so the Kotlin flavor warns on SOURCE_UNKNOWN only. Neither ever presents a forgeable message as trusted. (Aligning the two — e.g. distinguishing the reason in the UI, or revisiting the LXMF-kt drop — is a possible follow-up, tracked separately.)

Room migration

Schema 2 → 3. Additive MIGRATION_2_3 adds the nullable messages.signatureVerified INTEGER column; existing rows backfill to NULL ("no warning" — showing every legacy message as unverified would be inaccurate). Wired into both ColumbaDatabase builders (DatabaseModule + the :reticulum-process ServiceDatabaseProvider). Covered by a migration round-trip test.

UI

UnverifiedSenderChip (new ui/components/ composable, alongside the existing chips/banners) rendered above the bubble when signatureVerified == false, using colorScheme.errorContainer to match existing warning affordances. MessageDetailScreen gains a "Signature" info card (Verified / Unverified + threat-model explanation).

Wire compatibility

Unchanged. No change to LXMessage decoding, router behavior, or on-the-wire bytes. Cross-impl interop with python LXMF and iOS LXMF is unaffected — the only changes are the new local DB column and the in-app warning.

Test plan

  • ktlintCheck detekt cpdCheck green
  • Module tests (rns-api, rns-ipc, rns-backend-kt, rns-backend-py, data incl. new Migration2To3Test) green
  • App unit tests green
  • On-device: with a peer whose announce hasn't been observed, send an opportunistic message → bubble shows the "⚠ Unverified sender" chip; after that peer announces, the next message renders without the chip
  • Regression: a known sender (announce already observed) renders normal, unwarned bubbles

🤖 Generated with Claude Code

@greptile-apps

greptile-apps Bot commented May 1, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR closes a consumer-side LXMF signature-forgery gap by threading a signatureVerified: Boolean? flag end-to-end from the RNS backend callbacks through persistence (Room schema v2→v3) to a new UnverifiedSenderChip composable and MessageDetailScreen info card, following a warn-and-ingest pattern.

  • Data path: Sealed callback reading → ReceivedMessage.signatureVerifiedDataMessageMessageEntity (nullable INTEGER column, backfilled NULL for legacy rows) → MessageUi → UI warning chip.
  • Room migration: Additive ALTER TABLE messages ADD COLUMN signatureVerified INTEGER; registered in both DatabaseModule and ServiceDatabaseProvider; covered by a Robolectric round-trip test.
  • Python backend bug: PythonEventBridge uses dictBool(\"signature_validated\"), which returns false (not null) when the key is absent — any Python bridge that does not yet emit signature_validated will trigger spurious "Unverified sender" warnings on every received message.

Confidence Score: 4/5

Safe to merge for Kotlin-backend users; the Python-backend path has a false-positive warning bug that should be fixed before shipping to users running the Python bridge.

The Kotlin backend wiring, Room migration, and UI rendering are all correct. On the Python backend path, dictBool returns false instead of null when signature_validated is absent from the payload, so any Python bridge that hasn't been updated to emit the field will cause every received message to display a spurious "Unverified sender" warning. This is a present defect in the shipped data path, not a speculative concern.

rns-backend-py/.../PythonEventBridge.kt — the dictBool call needs to be replaced with a nullable-returning alternative so absent keys produce null (no warning) rather than false (false alarm).

Important Files Changed

Filename Overview
rns-backend-py/src/main/kotlin/network/columba/app/rns/backend/py/PythonEventBridge.kt Uses dictBool for signature_validated, which defaults to false (not null) when the key is absent, causing false "Unverified sender" UI warnings whenever the Python bridge payload omits the field.
rns-backend-kt/src/main/kotlin/network/columba/app/rns/backend/kt/NativeRnsBackendImpl.kt Adds delivery-callback logging for unverified messages and threads message.signatureValidated through buildReceivedMessage onto ReceivedMessage.signatureVerified; logic is correct for the Kotlin backend.
data/src/main/java/network/columba/app/data/db/ColumbaDatabase.kt Bumps schema version to 3 with a correct additive ALTER TABLE messages ADD COLUMN signatureVerified INTEGER; migration object registered in both providers.
data/src/main/java/network/columba/app/data/repository/ConversationRepository.kt Extracts buildMessageEntity helper and threads signatureVerified from Message through to MessageEntity; refactor is clean and all fields are preserved.
app/src/main/java/network/columba/app/ui/components/UnverifiedSenderChip.kt New composable rendering the warning chip with errorContainer styling; accessibility handled correctly with decorative icon.
data/src/test/java/network/columba/app/data/db/Migration2To3Test.kt Round-trip Robolectric test verifying column addition and null backfill for pre-existing rows; covers the critical correctness invariant.

Sequence Diagram

sequenceDiagram
    participant LXMFkt as LXMF-kt Router
    participant NativeBackend as NativeRnsBackendImpl
    participant PyBridge as PythonEventBridge
    participant Collector as MessageCollector
    participant Repo as ConversationRepository
    participant Room as Room DB (messages)
    participant UI as MessagingScreen / MessageDetailScreen

    LXMFkt->>NativeBackend: registerDeliveryCallback(LXMessage)
    Note over NativeBackend: signatureValidated = message.signatureValidated
    NativeBackend->>Collector: handleIncomingMessage(message)
    Collector->>Repo: saveMessage(DataMessage(signatureVerified))

    PyBridge->>Collector: "_messages.tryEmit(ReceivedMessage(signatureVerified=dictBool(...)))"
    Note over PyBridge: dictBool defaults to false if key absent

    Repo->>Room: INSERT MessageEntity(signatureVerified INTEGER)
    Room-->>UI: MessageEntity.toMessage().signatureVerified
    UI->>UI: "if signatureVerified == false → UnverifiedSenderChip"
    UI->>UI: signatureVerified?.let → Signature info card
Loading

Fix All in Claude Code

Prompt To Fix All With AI
Fix the following 1 code review issue. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 1
rns-backend-py/src/main/kotlin/network/columba/app/rns/backend/py/PythonEventBridge.kt:251
**`dictBool` defaults to `false`, not `null`, when the key is absent**

`PyObject.dictBool` (defined in `PythonExt.kt`) returns `Boolean` (non-nullable) with a fallback of `false` when the key is missing from the payload — unlike every other nullable dict helper in that file. If `event_bridge.py` does not yet include `signature_validated` (older bridge version, partial rollout, or any edge case), `signatureVerified` is set to `false` instead of `null`. The UI treats `false` as a confirmed unverified sender and renders "⚠ Unverified sender — may be forged" on every such message, producing a security false alarm for every message delivered through an un-updated Python bridge. `null` is the correct sentinel for "state unknown" — the UI already handles it as "no warning". Use `dictGet("signature_validated")?.toJava(Boolean::class.javaObjectType)` directly, or introduce a `dictBoolOrNull` helper parallel to `dictDouble`.

Reviews (4): Last reviewed commit: "feat: warn user on inbound LXMF messages..." | Re-trigger Greptile

Comment thread data/src/main/java/network/columba/app/data/db/migrations/Migrations.kt Outdated
@torlando-tech torlando-tech force-pushed the feat/unverified-message-warning branch from ca38e48 to 680ee98 Compare May 8, 2026 06:55
LXMF wire encryption protects confidentiality, not authenticity: anyone
who knows a recipient's destination hash can encrypt a forged packet
claiming any source identity. The signature is what proves authenticity.
This surfaces the per-message signature-verification state through the
receive path so the UI warns on senders whose signature could not be
verified (the forgery class), mirroring Sideband's warn-and-ingest
pattern rather than silently dropping (which would lose legitimate
first-contact messages).

Both backends are covered:
- Kotlin (NativeRnsBackendImpl): reads LXMessage.signatureValidated.
- Python (PythonEventBridge): reads signature_validated from the
  event_bridge.py delivery payload (already emitted upstream).

Data path: ReceivedMessage.signatureVerified (@parcelize — crosses the
:reticulum -> UI IPC seam) -> MessageEntity (Room) -> Message -> MessageUi
-> UnverifiedSenderChip rendered above any bubble shape, plus a Signature
card in the message-detail screen. The flag is a definite true/false for
received messages and null for sent/legacy rows (treated as "no warning").

Room schema 2 -> 3: additive MIGRATION_2_3 adds the nullable
signatureVerified column (legacy rows backfill null), wired into both
ColumbaDatabase builders.

Backend asymmetry worth noting: python LXMF delivers both SOURCE_UNKNOWN
and SIGNATURE_INVALID to the app (the python flavor warns on both),
whereas LXMF-kt drops SIGNATURE_INVALID at the router (the kotlin flavor
warns on SOURCE_UNKNOWN only). Neither ever presents a forgeable message
as trusted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@torlando-tech torlando-tech force-pushed the feat/unverified-message-warning branch from 680ee98 to 63b0359 Compare June 1, 2026 16:09
@torlando-agent torlando-agent Bot marked this pull request as draft June 1, 2026 16:11
@sentry

sentry Bot commented Jun 1, 2026

Copy link
Copy Markdown
Contributor

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

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.

1 participant