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)
- DMs and adverts carry the sender's public key → blockable by identity, reliably.
- Channel/Public messages carry NO public key.
ChannelMessage.senderKeyis alwaysnull(lib/models/channel_message.dart; firmwareBaseChatMesh.cpp:367-387); sender is a plaintext"name: msg"string over a shared PSK. Channel blocking is a name↔key resolution problem. - 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.
cmdRemoveContact(15) is not a block — firmware re-adds the contact on its next advert.- Contact names track the pubkey. On each advert,
_handleContactAdvert(meshcore_connector.dart:4639; firmwareBaseChatMesh.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 ofRESP_CODE_DEVICE_INFO(reply toCMD_DEVICE_QUERY); gated onFIRMWARE_VER_CODE ≥ 15and the bit set. App exposesbool 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 0x01BLOCK_ADD [0xC2][0x01][pubkey:32][0xC2][0x01][ok]— ok=1 present-after-call, ok=0 store-full0x02BLOCK_REMOVE [0xC2][0x02][pubkey:32][0xC2][0x02][ok]— ok=1 removed, ok=0 not-present0x03BLOCK_LIST [0xC2][0x03]streamed dump (framing below) 0x04BLOCK_CLEAR [0xC2][0x04][0xC2][0x04][ok]Reply shape (as-built): success is always the 3-byte
[0xC2][sub][ok]—okis 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 NOT0xC2-prefixed — the app must recognize the generic 2-byte error frame and must not wait for a0xC2echo.BLOCK_LISTdump framing (as-built): START[0xC2 0x03 0xFF count]→ one key per idle pass[0xC2 0x03 index pubkey:32]→ END[0xC2 0x03 0xFE]. On anAPP_STARTreconnect 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 STARTcountagainst key-frames received — fewer ⇒ truncated ⇒ re-requestBLOCK_LISTwhen 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
/blocksfile), 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 overflowADDreturnsok=0(not an error) — those keys simply aren't node-portable; tolerate it, local stays authoritative. Idempotent nuance: an already-stored key returnsok=1even at capacity (dedup short-circuits before the full check), sook=0strictly 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
0xC2frame is theAPP_STARTearly-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:
- Pull
BLOCK_LIST(0xC2/0x03). - Add node entries missing locally → local. (Never remove a local entry.)
- Push local entries missing on the node →
BLOCK_ADD(0xC2/0x01). - 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_STARTearly-END), re-request once the link settles; treat only a complete dump as the node set.
- Pull
- 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
timestampstrictly 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.