You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
meshcore-client/docs/architecture/block-contract-as-built.md

15 KiB

Block / Ignore — App ↔ Firmware Contract

Status: App-side design-of-record (2026-07), app half not yet built. Reconciled to the firmware side, which is merged / as-built (OffbandMesh/meshcore-firmware, PR #247, FIRMWARE_VER_CODE 15; Feature #241 / Epic #242 / doc #244). Wire codes below are the firmware as-built values, design + all wire gaps confirmed by PearlMeadow (reply 2026-07-01: reply-shape, overflow-at-32, early-END re-request, no-unsolicited-push). The app half is reconciled to the shipped firmware; no open discrepancies.

Owners: app = meshcore-client (Offband); firmware = meshcore-firmware (Offband, agent PearlMeadow). Tracking (app): Feature #164 → Epic A (app-local) #165 / Epic B (firmware offload) #166.


1. Problem

A single misbehaving node floods the mesh with adverts, pings, DMs, and Public-channel posts. Users want to ignore that node so they stop seeing its traffic. Repeater/network-wide rate limiting is operator-level and out of scope. This contract covers a per-user, per-node block.

2. Reality constraints (verified — app + firmware)

  1. DMs and adverts carry the sender's public key → blockable by identity, reliably.
  2. Channel/Public messages carry NO public key. ChannelMessage.senderKey is always null (lib/models/channel_message.dart; firmware BaseChatMesh.cpp:367-387); sender is a plaintext "name: msg" string over a shared PSK. Channel blocking is a name↔key resolution problem.
  3. No block primitive in stock MeshCore. So a firmware-stored block is meaningful only as an Offband extension; a stock/other client will not honor it.
  4. cmdRemoveContact (15) is not a block — firmware re-adds the contact on its next advert.
  5. Contact names track the pubkey. On each advert, _handleContactAdvert (meshcore_connector.dart:4639; firmware BaseChatMesh.cpp:120,177,186) refreshes the stored name under the same pubkey — this powers the self-heal (§5).

3. Core principle — app-local is never abandoned; firmware is a portability bonus

Layer 1  App-local block   ── ALWAYS on, any firmware. The client's source of truth. Never wiped by sync.
Layer 2  Firmware offload  ── Offband nodes only, capability-gated. Portable store + DM-drop. Additive.
  • The app owns a complete, self-sufficient block on any firmware (stock/other/Offband).
  • On an Offband node the app also syncs with the node's store, so the block follows the user to other Offband clients. On a non-Offband node the block still works and persists locally — it just can't ride that node to your other devices.
  • Blocking never depends on Layer 2, and sync never silently unblocks anyone (§8).

4. Layer 1 — app-local block (data model)

New BlockStore extends ChangeNotifier, persisted via PrefsManager, global to the app (keyed by public key), NOT per-radio-scoped — deliberate deviation from the contact_settings_store per-device convention, because a block is "I don't want to see this person" and identity is a pubkey regardless of which radio is connected. (Revisitable later.)

Two sets:

Set Key type Covers
blockedKeys public-key hex (full 32-byte identity) DMs, adverts, and channel posts that resolve to a known key
blockedNames lowercased display-name string channel posts from senders not resolvable to a key (fallback; self-pruning, §7)

API: isBlocked(keyHex), isNameBlocked(name), block(keyHex), unblock(keyHex), blockName(name), unblockName(name), plus read accessors for the management UI.

Where blocks take effect (app side)

Surface Rule
Incoming DM drop + suppress notification when senderKey ∈ blockedKeys (on Offband, firmware already dropped it at receive — §7)
Outgoing DM prevent sending to a blocked contact — the DM composer is replaced by a notice bar with an inline Unblock action (you cannot message someone you've blocked without first unblocking). Chat history stays visible.
Advert suppress the new-advert notification for blockedKeys. Do NOT discard the advert — the contact-name update must keep flowing (self-heal, §5)
Contacts / Discovery lists keep blocked entries VISIBLE with a clear blocked indicator — a red block icon (Icons.block, circle-with-slash) + muted/greyed styling. Do NOT hide them. Inline unblock available. (A "hide blocked entirely" toggle is a deliberate post-implementation follow-on, not v1.)
Channel message hide per the resolution rule in §5

Blocking hides, never deletes history. Unblock restores.

5. Channel handling — name ↔ key resolution

Channel packets are anonymous, so resolution runs against a name↔key index (§ below).

At display — hide a channel post only if: resolveKeys(senderName) is non-empty and every matching pubkey ∈ blockedKeys, OR senderName.toLowerCase() ∈ blockedNames.

If any matching pubkey is unblocked → SHOW. An ambiguous name (two people/devices sharing a name) must not censor a possibly-legit namesake (false-positive L1).

Long-press "Block sender":

  • resolves to one key → add to blockedKeys (one action unifies DM + advert + channel; sync to fw).
  • multi-match (one person on multiple devices = multiple keys, same name) → surface all matching keys so the user blocks each device's key.
  • no resolution → add to blockedNames, flagged "name-only — evadable by rename."

Name↔key index — sourced from BOTH (don't rely on one push type):

  • PUSH_CODE_NEW_ADVERT (0x8A) — full record (pubkey + name), fires for every advert from a sender not in the node's contact store, no replay guard → an unstored spammer always resolves (closes the "permanent name-only" worry).
  • the synced contact list — a renamed stored contact re-adverts as PUSH_CODE_ADVERT (0x80, pubkey only, no name); read the new name from the contact record, not the advert tickle.

Why rename evasion mostly fails: a renamed sender's next advert refreshes the name under the same pubkey, so resolution self-heals — and an advert-flooder keeps that link fresh.

Honest limit (L3): renamed and advert-silent but still posting to a channel = genuinely unattributable (no identity in the packet). Not closable app- or firmware-side; the recourse is the global name-fallback block (§6), which upgrades to a pubkey block once the identity is seen (§7).

6. Global name-fallback block + evasion scope

When a channel sender can't be resolved to a pubkey, "Block sender" adds the lowercased name to the app-global blockedNames — so that name is hidden in every channel, immediately. The user never blocks channel-by-channel; one action covers all channels. The name block is provisional and upgrades to a durable pubkey block the moment the identity is learned (§7).

Out of scope (current MeshCore): a determined troll can dodge a name block with a one-character or invisible-Unicode rename. Defeating that is a larger anti-abuse problem than MeshCore's channel model allows today (channel posts carry no per-post identity) and is explicitly not in this design. The durable answer is pubkey promotion (§7) once we ever see them by key — their advert or a DM.

7. blockedNames promote-and-prune

A name-only block is provisional. When a blockedNames entry links to a pubkey — via their advert OR a DM (any time we learn the key) — promote it: add the pubkey to blockedKeys (sync), drop the name entry. Any name that never links expires after ~30 days. The durable block is always the pubkey; blockedNames stays small and self-cleaning, bounding the L1/L2 false-positive window.

8. Layer 2 — firmware offload + sync (Offband only)

Mirrors the 0xC1/offband_caps capability pattern (supportsOffbandGps). Values are firmware as-built (merged PR #247, FIRMWARE_VER_CODE 15):

  • Capability: OFFBAND_CAP_BLOCK = 0x02 (bit 1) in the capability byte of RESP_CODE_DEVICE_INFO (reply to CMD_DEVICE_QUERY); gated on FIRMWARE_VER_CODE ≥ 15 and the bit set. App exposes bool get supportsOffbandBlock => firmwareSupportsOffbandBlock(_offbandCaps);. Bit absent → app-only mode (no sync, no firmware drop); block still works locally.

  • Command: 0xC2 = CMD_OFFBAND_BLOCK, sub-typed (0xC0=config, 0xC1=GPS already used):

    Sub Name Frame Reply
    0x01 BLOCK_ADD [0xC2][0x01][pubkey:32] [0xC2][0x01][ok] — ok=1 present-after-call, ok=0 store-full
    0x02 BLOCK_REMOVE [0xC2][0x02][pubkey:32] [0xC2][0x02][ok] — ok=1 removed, ok=0 not-present
    0x03 BLOCK_LIST [0xC2][0x03] streamed dump (framing below)
    0x04 BLOCK_CLEAR [0xC2][0x04] [0xC2][0x04][ok]

    Reply shape (as-built): success is always the 3-byte [0xC2][sub][ok]ok is a result byte, not a separate ack/err frame. The only error frame is the generic [0x01][0x06] (RESP_CODE_ERR=1, ERR_CODE_ILLEGAL_ARG=6), emitted for a malformed request (ADD/REMOVE < 34 B or unknown sub). ⚠ It is NOT 0xC2-prefixed — the app must recognize the generic 2-byte error frame and must not wait for a 0xC2 echo.

    BLOCK_LIST dump framing (as-built): START [0xC2 0x03 0xFF count] → one key per idle pass [0xC2 0x03 index pubkey:32] → END [0xC2 0x03 0xFE]. On an APP_START reconnect mid-dump the firmware emits an early-END terminator so the app never blocks on a stale stream. Normal END and early-END are the identical byte (0xFE); detect truncation by comparing the START count against key-frames received — fewer ⇒ truncated ⇒ re-request BLOCK_LIST when the link settles. Never treat a partial dump as authoritative, and never derive removals from a LIST (removal is explicit-unblock only) — a partial pull can only miss node keys (fixed by re-request), never false-unblock.

  • Firmware store: list of blocked pubkeys in NVS (flat /blocks file), independent of the contacts list (block a spammer without storing them). MAX_BLOCKED_KEYS = 32 (as-built). Overflow: if the app's local list exceeds 32, the overflow ADD returns ok=0 (not an error) — those keys simply aren't node-portable; tolerate it, local stays authoritative. Idempotent nuance: an already-stored key returns ok=1 even at capacity (dedup short-circuits before the full check), so ok=0 strictly means "new key + store full."

  • Firmware enforcement: DM from a blocked key → dropped at receive (the core offload) — silently: no notification, no frame. The app never sees blocked DMs and cannot count/badge them, so any "N blocked" UX must not rely on firmware counts. Adverts → still processed (keeps self-heal alive); app suppresses the notification. Channel → not enforced in firmware (app-only).

  • No unsolicited pushes. Block state is strictly app-pull + app-initiated ADD/REMOVE/CLEAR/LIST; the only firmware-initiated 0xC2 frame is the APP_START early-END terminator (a reconnect signal, not a state change).

Sync model — union, app always retained

  • App-local is never wiped by sync. The only removal is an explicit user unblock.
  • On connect to an Offband node:
    1. Pull BLOCK_LIST (0xC2/0x03).
    2. Add node entries missing locally → local. (Never remove a local entry.)
    3. Push local entries missing on the node → BLOCK_ADD (0xC2/0x01).
    4. Both converge to the union → portable to the user's other Offband clients.
    • Reconcile is tolerant of frame-arrival order (mirror _reconcileGpsPolling).
    • If the dump was truncated (START count > key-frames received, e.g. APP_START early-END), re-request once the link settles; treat only a complete dump as the node set.
  • Unblock: remove locally and BLOCK_REMOVE (0xC2/0x02) on the node.
  • On a non-Offband node: steps skipped; local store does everything (not portable via that node).

9. Error visibility (per DifferentWire rules)

  • Firmware push/pull failure surfaces a persistent snackbar (not a 4s flash); the local block still applies. Never swallow.
  • All block/unblock/sync actions log with context (key/channel, surface, fw result).

10. UX surfaces

  • Entry points: contact context menu, chat overflow, Discovery item, channel message long-press (name-based, labeled).
  • Blocked contacts stay in-list with a red block badge + muted styling (not hidden); unblock inline from the tile. A future "hide blocked entirely" setting is a post-impl follow-on.
  • Management: Settings → Blocked — list (name if known + short pubkey), unblock, and an indicator of whether firmware offload/sync is active on the current radio.

11. Interop invariant (binding — from firmware §11)

Block is a per-user view filter + a portable list synced over the local companion link. It MUST NOT alter forwarding, relaying, ACKs, advert re-flood, or any RF behaviour. A blocked sender's packets still route and flood through the node normally; the drop happens at the app-push layer, after routing — never in the forward path. Blocking someone must never make the node a worse relay. All wire additions (0xC2, cap 0x02) are companion-API frames (BLE/USB, 0xC0+ namespace) that never traverse LoRa. Fully compatible with stock MeshCore / non-Offband nodes.

12. Known gaps (carry into implementation)

  • G1 — contact-name re-link only takes when the advert timestamp strictly increases (BaseChatMesh.cpp:123); a clock-skewed/replayed advert can briefly stall a rename re-resolve.
  • L1 — spoof-into-block: channel names are attacker-controlled; someone can post as a blocked name (hidden) or impersonate a blocked name to hide a legit person. Low severity; §5 "any-unblocked→show" and §7 age-out bound it.
  • L2 — stale name post-rename may catch an innocent who later reuses it; mitigated by §7.
  • L3 — zero-identity hard limit (§5) — protocol, not closable.

13. Scope

In scope: app-local block of DMs + adverts by key; channel block via name↔key resolution + global name fallback (with promote-to-pubkey); capability-gated firmware offload + union sync.

Deferred / out of scope: content filtering (kids-mode/profanity) — future (original item #3); repeater/network-wide rate limiting — operator-level.

14. Decomposition

Epic Scope Depends on
A — app-local block (#165) BlockStore (global), filter points, name↔key channel resolution + index, global name-fallback + promote-and-prune, entry points, management UI none — ships standalone
B — firmware offload (#166) OFFBAND_CAP_BLOCK detection, 0xC2 ADD/REMOVE/LIST/CLEAR, union sync/reconcile, capability UI firmware Feature #241 (codes finalize at fw implementation)

Cross-project alignment: firmware doc #244 ↔ this doc. Remaining firmware-side opens (their §10): block-store capacity, non-contact auto-add policy, final 0xC2 codes + dump framing.

Powered by TurnKey Linux.