Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XdCVMpGfBJSGPfj6Hs5jnV |
||
|---|---|---|
| .github/workflows | ||
| cmd/fwdsvc | ||
| configs | ||
| docs | ||
| internal | ||
| scripts | ||
| tests/interop | ||
| .gitignore | ||
| go.mod | ||
| go.sum | ||
| README.md | ||
reticulum-group-chat (fwdsvc)
A Reticulum group chat. fwdsvc is a small daemon that hosts a
multi-user text chat over the Reticulum
Network, using LXMF
for message delivery. Each chat is one running service. Anyone with the
service's destination hash can /join, send messages, and have them
fanned out to every other member — a many-to-many group chat over
whatever Reticulum transport(s) you have available (LoRa, TCP/IP, your
own mesh, etc.).
If you've used IRC, Matrix, or Telegram groups, the user experience is similar. The difference is that the chat travels over Reticulum, so it works on radios with no Internet, can be relayed across mixed LoRa+TCP+I2P meshes, and the operator is just one person running this binary on a Pi.
- No third-party Reticulum library. Pure-Go implementation of the protocol layers we need, written directly against the spec.
- Verified against upstream Python
rns+LXMFat the byte level (static test vectors plus a live subprocess interop harness running on CI). - Live-tested end-to-end with Sideband, NomadNet, and MeshChat over public Reticulum testnet entry nodes.
- One static binary — runs unattended on a Raspberry Pi, a Debian server, macOS, or Windows. No runtime, no Python, no daemon zoo.
Table of contents
- What this is, and how it works
- Features
- Install and first run
- Commands
- Configuration reference
- Deployment recipes
- Operations
- Wire-format support
- Reactions: what a client must implement
- Limitations
- Build from source
- Project info
What this is, and how it works
Reticulum gives every participant a cryptographic identity — a keypair stored in a small file. From the identity you derive a destination hash, a 16-byte address other peers route to. Reticulum announces propagate destination → identity bindings across the mesh so anyone in range can encrypt to anyone else.
LXMF rides on top of Reticulum: signed, encrypted, store-and-forward messages addressed to a destination hash, deliverable opportunistically (one Reticulum packet) or over a Reticulum Link for larger payloads.
fwdsvc is one LXMF endpoint that behaves like a chatroom:
┌──────────┐ ┌──────────┐ ┌──────────┐
│ Alice │ /join, msg │ fwdsvc │ forwarded │ Bob │
│ Sideband │ ─────────────▶ │ daemon │ ─────────────▶ │ NomadNet │
└──────────┘ │ │ └──────────┘
│ roster: │
┌──────────┐ │ Alice │ ┌──────────┐
│ Carol │ /join, msg │ Bob │ forwarded │ Dave │
│ MeshChat │ ─────────────▶ │ Carol │ ─────────────▶ │ Sideband │
└──────────┘ │ Dave │ └──────────┘
└──────────┘
- Each message Alice sends to the daemon's destination hash is
forwarded to every other member of the roster, prefixed with
[Alice]so receivers see who said what. - The daemon never sees plaintext from any peer except via its own identity's decryption, and it re-encrypts per recipient on the way out — same trust model as any LXMF peer.
- Joining is by sending
/join. Roster membership is auto-pruned for anyone not heard from (no announce, no message) for four weeks, and for lurkers who keep announcing but never send a chat message for six weeks, by default.
Glossary
| Term | Meaning |
|---|---|
| Identity | 64-byte X25519 + Ed25519 keypair on disk (or in service.identity_b64). Lost identity = lost destination hash = your roster has to re-add you. |
| Destination hash | 16-byte / 32-hex-char address. The thing your users put in their LXMF client to message the service. |
| Announce | A signed broadcast that teaches the mesh "destination D belongs to identity I, reachable via these hops." fwdsvc re-announces itself every announce_interval (default 10 min). |
| Roster | The set of members. Living in state.json. |
| Replay | When a new member joins, the daemon ships them the most recent N messages so they can pick up the conversation. |
| Opportunistic / Link / Resource | The three LXMF delivery paths, picked automatically by payload size. See Wire-format support. |
Features
- Explicit
/joinopt-in. A first message from a stranger gets a private invitation reply, not a forward. Avoids the "I sent one test message and now strangers are getting it" UX. - Default nickname from announces. A member with no nickname set
picks one up automatically from their announced display name,
sanitised to
[A-Za-z0-9_-]{1,24}("Bob & Alice"→"Bob_Alice"; all-emoji collapses to empty and stays unset). Applied at/jointime and on every subsequent announce until a nickname exists, so a user who joined before their first announce arrived still gets named once it does./nickis authoritative: once set, announces never overwrite. - Replay on join. New (and returning) members receive the most recent buffered messages so they can read the prior conversation. Defaults: last 100 messages, nothing older than 7 days. Configurable.
- Pause without leaving. A member can
/pauseto stop receiving (and sending) forwards while staying on the roster./resumereverses. - Per-message char cap (
max_inbound_chars, default 500). Oversize non-command messages get a polite reject reply and aren't forwarded. - Auto-prune. Members not heard from for
prune_after(default 4 weeks) are removed automatically. A second window,prune_silent_after(default 6 weeks), also removes lurkers who keep announcing (or run the odd command) but never send a chat message. Either way they can/joinagain any time. - Forwarded content sanitised. Bytes outside printable + TAB/LF/CR
become
?before forwarding — no ANSI-escape injection into other users' terminals. - Outbound retry queue. Every outbound message goes through a
persistent queue mirroring LXMF's
LXMRouter.process_outboundpolicy: 5 attempts with exponential backoff (10 s doubling to a 60 s cap), 7-secondpath?-backed defer when the recipient hasn't announced. Survives restarts viaoutbound.json. Drained by an 8-worker pool so a slow send to one recipient doesn't block command replies to others. Picker prioritises by recipient recency (peers we just heard announce go first). - Announce cache survives restart. Verified announces persist to
announces.json— after a restart, every previously-known peer is immediately addressable instead of waiting up to oneannounce_intervalfor them to re-announce. Entries older than 30 days are dropped at load time. - Three LXMF delivery paths, picked automatically.
Opportunistic (one packet, fire-and-forget) for small replies; Link
DATA (one Token-framed packet over a Reticulum Link) for medium
payloads; SPEC §10 Resource transfer for anything bigger (long
/usersreplies on big rosters, long chat messages, etc.). - Propagation-node store-and-forward (opt-in, v1.13.0+). With
[propagation] enabled = true, messages that exhaust their direct retry budget are handed to an LXMF propagation node (SPEC §5.8) instead of dropped — offline members get them on their client's next sync. Nodes are auto-discovered fromlxmf.propagationannounces (or pinned by hash), the node's declared per-transfer limit is respected, and required §5.7 proof-of-work stamps are ground automatically.mode = "always"routes everything via the node. See[propagation]. - Mod / admin moderation. Config-file
adminsandmodslists get/kick,/ban,/unban,/announce,/path, and the cross-user form of/nick. Admins can also grant/clear roles at runtime with/usermode— no config edit or restart. - Invite-only (locked) groups. Set
locked = trueand/joinis refused for everyone except config admins/mods; the only way in is a mod/admin running/adduser <hash>, which adds the user and sends them a welcome + replay./removeusertakes them back out. Config admins/mods are auto-enrolled as members at startup so the operator actually receives messages. See Private (invite-only) groups. - Duplicate suppression. Inbound messages are de-duplicated by LXMF
message_idwithindedup_window(default 1h), so a message Reticulum delivers more than once (multi-path, retransmit, propagation-node replay) is fanned out only once. A single-instance lock also refuses to start a second daemon on the samestate_path— the other classic cause of echoed messages. - Bind-once identity. Embed your identity in
config.tomlviaidentity_b64and the config file is the single source of truth — reinstall on any machine, same destination hash, same chat for everyone. - Self-healing TCP interface.
tcp_clientinterfaces auto-redial with capped exponential backoff after any drop (peer restart, NAT timeout, transient network failure). TCP keepalive on dialed sockets surfaces silent peer drops within ~2 minutes instead of waiting for the next outbound write to fail. The service does not need to be restarted after an upstream blip.
Install and first run
1. Get the binary
Download the latest release for your platform:
| Asset | Target |
|---|---|
fwdsvc-linux-amd64 |
x86_64 Linux (Debian/Ubuntu/etc.) |
fwdsvc-linux-arm64 |
ARM64 Linux (RPi 4/5 64-bit, most ARM SBCs) |
fwdsvc-linux-armv7 |
32-bit ARMv7 (RPi 2/3 32-bit) |
fwdsvc-linux-armv6 |
32-bit ARMv6 (RPi Zero, RPi 1) |
fwdsvc-darwin-arm64 |
Apple Silicon macOS |
fwdsvc-windows-amd64.exe |
x86_64 Windows |
On Linux/macOS, chmod +x fwdsvc-… after download.
Third-party packages
fwdsvc is also packaged for Nix / NixOS in
nixpkgs (contributed by
@drupol — thank you).
Third-party packages are maintained independently of this project. We don't control when they update, so a packaged build can sit several releases behind — which matters here, because releases have included security fixes and fixes for messages being silently dropped. If you install from a package manager, check that
fwdsvc --version
matches the latest release. If it doesn't, prefer the release binary above or a source build until the package catches up.
2. Make a config
mkdir -p ~/.fwdsvc
curl -L https://raw.githubusercontent.com/thatSFguy/reticulum-group-chat/main/configs/fwdsvc.example.toml \
-o ~/.fwdsvc/config.toml
Open ~/.fwdsvc/config.toml and edit:
display_name— what your service shows in its announces. Visible to every Reticulum node it reaches.[[interfaces]]addr— a reachable Reticulum peer to dial. A community testnet node (rns.chicagonomad.net:4242,rns.michmesh.net:7822, etc.) works for getting started; for production you'd point at a localrnsdyou control or your own TCP-attached gateway.- Leave
admins = []for now; you'll add yourself in step 4.
3. Run it once to generate an identity
./fwdsvc -config ~/.fwdsvc/config.toml
First lines on stdout:
fwdsvc 1.11.0 starting (linux/amd64)
fwdsvc 2026/05/11 16:00:00 interface tcp_client connected: rns.chicagonomad.net:4242
fwdsvc 2026/05/11 16:00:00 service identity hash: 359fc3967f984a529874d0960c6ee782
fwdsvc 2026/05/11 16:00:00 delivery destination : 4c87fb86ccfdff39a3d1e22060ba1789
fwdsvc 2026/05/11 16:00:00 display name : My Group Chat
The delivery destination (second hash) is the address your users will message in their LXMF client. Share that with your group — it's the chat's stable identifier.
4. Add yourself as admin
From your LXMF client (Sideband on phone, NomadNet on desktop, MeshChat, etc.) send the service any short message. The forwarder will log:
new sender contact: full dest_hash = 0b0501efed0844bb064bc6df4cba43bb
Stop the service (Ctrl-C), put that 32-character hex string in
admins, and restart:
admins = [
"0b0501efed0844bb064bc6df4cba43bb",
]
Important:
adminsandmodsMUST be top-level keys inconfig.toml, before any[section]header. TOML scopes top-level keys to whichever section is currently active, so putting them after[service]silently makes themservice.admins.
5. Join from your client
From your LXMF client, send /join to the daemon's delivery
destination. You'll get a confirmation reply and from now on every
forwarded message from other members lands in your inbox.
Send /? to see the commands available to you (admins see the full
moderation set).
Your friends do the same against the same delivery destination, and they're all in the chat.
Commands
/? (or /help) replies are role-aware — non-members only see
commands that work for them, mods see the moderation set, admins see
everything.
User commands
| Command | Who | Effect |
|---|---|---|
/? or /help |
anyone | List commands available to you |
/about or /version |
anyone | Show version and repo URL |
/users |
members | List roster (paused members marked [paused]). Long rosters list the first 200 with a "… and N more" footer; the total is always shown. |
/mods |
anyone | List configured mods |
/admin |
anyone | List configured admins |
/join |
non-members | Opt in: receive forwarded messages, your messages get forwarded. In a locked group this is refused with your own address echoed back to hand to an admin. |
/leave |
members | Leave the chat (you can /join again later) |
/pause |
members | Stop receiving forwards (and stop forwarding yours) |
/resume |
members | Reverse /pause |
/textonly |
members | Skip attachments — receive only the text body of forwarded messages. Intended for users on slow / metered links. |
/showall |
members | Reverse /textonly — resume receiving attachments. |
/nick <newname> |
members | Change own nickname (1–24 chars from [A-Za-z0-9_-]) |
Mod / admin commands
| Command | Who | Effect |
|---|---|---|
/nick <user> <newname> |
mods, admins | Change another user's nickname |
/kick <user> |
mods, admins | Remove from roster (user can /join again) |
/ban <user> |
mods, admins | Add to banlist; future /joins and messages refused |
/unban <user> |
mods, admins | Remove from banlist |
/adduser <hash> |
mods, admins | Add a user by their 32-hex destination hash — the invite path for a locked group. Adds them to the roster and sends a welcome + replay. |
/removeuser <user> |
mods, admins | Remove a member. In a locked group they can't return until re-added (unlike /kick, which lets them /join again in an open group). Not punitive like /ban. |
/announce |
mods, admins | Broadcast a fresh Reticulum announce immediately |
/path <user> |
mods, admins | Show what the transport knows about reaching <user>: cached announce age, hop count, next-hop transport_id, whether an Active Link is open. Mostly for troubleshooting delivery problems. The hop count is approximate: peers running RNS ≥ 1.3.7 with the local_hops_delta privacy option inflate it by a constant 2–7 (SPEC §2.4). |
/usermode <admin|mod|user> <user> |
admins | Grant or clear a runtime role for a roster member — no config edit or restart needed. admin/mod promote; user clears the runtime grant. |
<user> accepts a nickname (case-insensitive) or a
destination-hash prefix (≥ 4 hex chars). When two members would
match the prefix, the daemon refuses with a disambiguation reply.
/usermode and the config lists. A runtime role only ever raises
the role granted by the config admins/mods lists — it never lowers
it. The effective role is max(config role, runtime role). So:
/usermode mod Alicepromotes a regular member to mod;/usermode user Aliceclears that grant again.- A role granted in the config file cannot be demoted from chat — if
you
/usermode usersomeone who is a config admin/mod, the daemon tells you their effective role is unchanged and to edit the config. This is deliberate: you can't strip a config-defined admin's powers from inside the room. - Runtime grants persist in the roster state file across restarts.
- An admin can't clear their own runtime admin grant (anti-lockout); a config-defined admin is inherently safe.
Examples
> /join
Joined. You'll receive forwarded messages from now on. /pause to mute,
/leave to exit, /? for help.
> /nick Alice
Nickname set to Alice.
> /users
Users (3):
Alice — 0b0501ef
Bob — ffeeddcc
(no nick) — 1234abcd
> Hi everyone!
(message fans out to Bob and the unnicked user with prefix `[Alice] Hi everyone!`)
Private (invite-only) groups
By default anyone with the destination hash can /join. Set
locked = true under [service] to run a closed group:
/joinfrom a regular user is refused with a "closed group" reply that echoes them their own destination hash to hand to an admin.- A mod/admin adds them with
/adduser <hash>— the user is placed on the roster and sent a welcome plus replay, exactly as if they had joined./removeuser <user>takes them back out; while locked they can't return until re-added. - Config admins/mods are auto-enrolled as members at startup, so the
operator receives messages without a separate
/join— being an admin grants command powers, not delivery, and this closes that gap. An admin who wants to moderate silently uses/pause; one who/leaves is re-enrolled on the next restart (remove them from the config to opt out for good). - Config admins/mods always bypass the lock, so you can't lock yourself out.
TOML gotcha:
lockedmust sit under the[service]header. Alocked = trueplaced above the first[section]is a top-level key that TOML silently ignores — the group stays open and/joinstill works. The startup log printslocked (invite-only): true; check it to confirm the setting took effect.
Configuration reference
The config is a single TOML file. Default location is
~/.fwdsvc/config.toml; override with -config <path>.
Root-level keys
| Key | Type | Default | Description |
|---|---|---|---|
admins |
array of hex strings | [] |
Destination hashes of admins. Get all the mod commands. |
mods |
array of hex strings | [] |
Destination hashes of mods. Get the moderation commands minus admin-only ones. |
Both lists MUST be declared at the top of the file, before any
[section] header.
[service]
| Key | Type | Default | Description |
|---|---|---|---|
display_name |
string | "Group Chat - send /join" |
Shown in announces. |
identity_path |
path | ~/.fwdsvc/identity |
Where the service's identity is stored. Ignored if identity_b64 is set. |
identity_b64 |
string | unset | Base64 of the 64-byte identity. When set, this is authoritative and identity_path is ignored. See Identity backup. |
state_path |
path | ~/.fwdsvc/state.json |
Roster + banlist. |
history_path |
path | ~/.fwdsvc/history.json |
Replay ring buffer. |
log_path |
path | unset | If set, append the daemon log to this file (in addition to stdout). |
prune_after |
duration | "4w" |
Drop a member we haven't heard from (no announce, no message) for this long. |
prune_silent_after |
duration | "6w" |
Drop a member who hasn't sent a chat message in this long, even if still announcing or running commands (/join resets it). 0 disables. |
prune_interval |
duration | "1h" |
How often the prune sweep runs. |
announce_interval |
duration | "10m" |
How often we re-announce ourselves. |
max_inbound_chars |
int | 500 |
Reject non-command messages longer than this many UTF-8 chars. 0 disables. |
max_members |
int | 512 |
Cap on roster size. /join past the cap is refused. Defaults to a bound rather than unlimited because in an open group anyone can mint identities and join, and roster size multiplies every fan-out. 0 = unlimited (not recommended unless locked = true). |
locked |
bool | false |
Invite-only mode. /join is refused for non-admins/mods; membership is managed with /adduser / /removeuser, and config admins/mods are auto-enrolled. See Private (invite-only) groups. Must be placed under [service]. |
inbound_stamp_cost |
int | 0 |
SPEC §5.7.2 proof-of-work required of anyone messaging the group, announced in our app_data so senders know to pay it. Cost is in leading zero bits, so difficulty doubles per step — 8 is about a second on a phone, 16 is minutes. 0 announces no cost and checks nothing. The only limit here that charges the sender; everything else caps what we do after the message has arrived. Range 0–254. |
enforce_inbound_stamps |
bool | false |
Drop messages that don't clear inbound_stamp_cost instead of accepting them and logging what they paid (SPEC §5.7.4; matches upstream LXMF's _enforce_stamps default). Watch the inbound stamp: log lines first — a client that doesn't implement stamps becomes silently undeliverable to the group the moment this goes on. Requires inbound_stamp_cost > 0. |
dedup_window |
duration | "1h" |
Remember each inbound message_id for this long and drop redeliveries (multi-path / retransmit / propagation replay) before forwarding. 0 disables. |
forward_attachments |
bool | true |
Pass LXMF non-text fields (images, etc.) through forwarding. false forwards text only; a sender whose file, image or audio is dropped is told so privately. |
max_attachment_bytes |
int | 1024000 (1000 KiB) |
Per-field msgpack size cap. Oversize attachments are dropped with an inline [image not forwarded: …] note on the forwarded body; text body still delivers. 0 disables the cap. |
forwarded_fields |
int list | [6, 48, 49, 64, 65, 66] |
Allowlist of LXMF field keys to forward when forward_attachments=true. Default covers FIELD_IMAGE (6) plus the upstream LXMF 1.0.0 message-meta fields: reply-to (FIELD_REPLY_TO 0x30=48 message-id, FIELD_REPLY_QUOTE 0x31=49 quoted text), tap-back reactions (FIELD_REACTION 0x40=64), comments (FIELD_COMMENT 0x41=65), and continuations (FIELD_CONTINUATION 0x42=66). Add 5 for files, 7 for audio once your senders/receivers handle them — until you do, a sender attaching one gets a private reply saying the group does not accept that kind. |
id_cache_ttl |
duration | 24h |
How long fwdsvc remembers each fan-out's per-recipient LXMF message_id so reactions and reply-to fields can be rewritten per recipient (v1.6.0+). 0 disables — reactions then show "[someone reacted]" without landing on a bubble. Going longer just grows memory; each cache entry is ~50 bytes × roster size. |
id_cache_max |
int | 10000 |
Hard cap on id_cache_ttl entry count (LRU evicts oldest). One fan-out to N recipients counts as N entries. 0 = unbounded. |
Durations are Go time.ParseDuration plus d (days) and w
(weeks): "30s", "5m", "24h", "7d", "4w".
[[interfaces]]
Repeated table — one entry per Reticulum I/O interface. Currently only
tcp_client is supported.
| Key | Type | Default | Description |
|---|---|---|---|
type |
string | required | "tcp_client". |
addr |
string | required | host:port of a TCPServerInterface peer to dial. |
timeout |
duration | "0" |
Dial timeout. 0 = stdlib default (~30 s). |
[[interfaces]]
type = "tcp_client"
addr = "rns.chicagonomad.net:4242"
timeout = "10s"
[[interfaces]]
type = "tcp_client"
addr = "10.0.0.42:4242" # your own rnsd on the LAN
fwdsvc broadcasts on all interfaces; redundancy is fine.
[replay]
| Key | Type | Default | Description |
|---|---|---|---|
count |
int | 100 |
Max messages replayed when a member joins (or rejoins). 0 disables replay entirely. |
max_age |
duration | "7d" |
Skip messages older than this in replay. |
[propagation]
Store-and-forward delivery via LXMF propagation nodes (SPEC §5.8). Disabled by default; existing deployments are unaffected.
| Key | Type | Default | Description |
|---|---|---|---|
enabled |
bool | false |
Turn propagation-node submission on. |
mode |
string | "fallback" |
"fallback": direct delivery first, re-route via the propagation node only after the direct retry budget (5 attempts) is exhausted. "always": every outbound message goes to the propagation node only — nothing is dropped for being offline, at the cost of sync latency. |
node |
hex | unset | Pin a specific propagation node by its lxmf.propagation destination hash (32 hex chars). Most operators should leave this unset — see Choosing a propagation node. Unset = auto-select: among nodes that are accepting and whose announced parameters we can satisfy, the one known longest and heard from recently. |
direct_attempts |
int | 3 |
Shortens the direct retry budget, but only while a propagation node is available to fall back to — failing over after 3 attempts (~75 s) instead of the standard 5 (~3.5 min) gets an offline member's mail onto the node sooner. Whenever no node is known, the full 5-attempt budget still applies before a message is dropped. Both numbers are printed at startup. Set to 5 to disable the shortening. Fallback mode only. |
Choosing a propagation node
Leave node unset and let auto-discovery pick, unless you know that
your members all sync from one particular node. That caveat is doing
the work — read on for why it usually doesn't apply.
The thing that decides whether store-and-forward works is not security, it is the fetch side: a message is only delivered once the recipient pulls it down. If this service uploads to node A and a member polls node B, delivery depends on A and B peering and syncing — best-effort, possibly slow, possibly never. Pinning is only a fix for that when everyone involved shares the same node.
Why auto is the right default. From one instance's log over five weeks (2026-06-20 → 2026-07-27, single hub, single vantage point — your view will differ):
| Observation | Count |
|---|---|
| Distinct nodes seen accepting messages | 632 |
| Distinct nodes seen refusing messages | 236 |
| Nodes that flapped between accepting and refusing | 215 |
| Propagation announces carrying a display name | 0 of 2200 |
Each row argues against pinning blind:
- 632 candidates. If members choose their own node independently, pinning one gives you a negligible chance of matching any given member. The determinism argument evaporates unless the group has genuinely agreed on a node.
- 215 of them flapped. A third of the population goes in and out of
accepting messages. Pin one of those and store-and-forward silently
goes intermittent, with no failover. Auto-selection re-checks the
acceptingflag at selection time, so it routes around a node that has stopped taking mail. - No node advertises a name. Per SPEC §5.8.5 the
lxmf.propagationannounce app_data carries the 7-element parameter array, not a display string — every one of those 2200 announces had an empty name. There is no way to recognise "the community's node" from announces alone; someone has to hand you the hash out of band.
Auto-selection prefers the node known longest among those currently accepting and satisfiable, rather than the most recently heard. That resists a newcomer flooding announces to seize the role, and it selects for uptime — the one useful property you can still select for once locality is unknowable.
Pin a node when you actually know one: your own propagation node, or one the group has agreed on and whose hash you were given directly. In that case pinning is clearly better — it makes the round trip deterministic, prevents anyone claiming the role by announcing, and lets you choose which operator sees your traffic metadata.
The cost of pinning is failover. A pinned node is used unconditionally — if it goes offline or stops accepting, store-and-forward stops until it returns or you change the setting. Direct delivery to online members is unaffected either way, so the failure is bounded: you lose the safety net, not the service.
Set expectations either way. With a membership that uses assorted
nodes, store-and-forward is a best-effort improvement rather than a
guarantee — beyond the upload, delivery rests on inter-node syncing that
no setting here influences. In mode = "fallback" that is still purely
a win: the members it misses are the ones who would have received
nothing at all. If you need it to be reliable, the fix is not a config
value — run a propagation node for your community and have members
point their clients at it.
Trust note. A propagation node cannot read your messages — bodies
are encrypted to the recipient. It can see each recipient's
destination hash (so over time, your roster), this service's identity,
and the timing and size of everything sent. It can also silently
discard what it is given. Submitted data cannot be recalled. In
mode = "always" this covers every group message.
Notes:
- Delivery to the node rides a Reticulum Link and succeeds when the node holds the message; the member's client (Sideband, MeshChat, …) fetches it on its next propagation sync. Members must have a propagation node configured client-side for this to reach them.
- If the node's announce declares a stamp cost (§5.8.5), the required proof-of-work stamp is computed automatically, up to 20 bits — nodes demanding more are skipped at selection time (defense against a hostile announce pinning the CPU).
- The node's announced per-transfer size limit is enforced before upload; oversize messages fail that route rather than being silently truncated.
Deployment recipes
Linux + systemd (recommended)
- Put the binary in
/usr/local/bin/fwdsvcandchmod 755it. - Create a system user:
sudo useradd --system --home /var/lib/fwdsvc --create-home --shell /usr/sbin/nologin fwdsvc - Put
config.tomlat/etc/fwdsvc/config.toml. Make sureidentity_path,state_path,history_pathall point under/var/lib/fwdsvc/(e.g./var/lib/fwdsvc/identity) —~/doesn't expand under a system user. - Drop this in
/etc/systemd/system/fwdsvc.service:[Unit] Description=Reticulum forwarding service (group chat) After=network-online.target Wants=network-online.target [Service] Type=simple User=fwdsvc Group=fwdsvc ExecStart=/usr/local/bin/fwdsvc -config /etc/fwdsvc/config.toml Restart=on-failure RestartSec=5 # Optional hardening: NoNewPrivileges=true ProtectSystem=strict ProtectHome=true ReadWritePaths=/var/lib/fwdsvc PrivateTmp=true [Install] WantedBy=multi-user.target - Enable and start:
sudo systemctl daemon-reload sudo systemctl enable --now fwdsvc sudo journalctl -u fwdsvc -f
Raspberry Pi
Same as Linux + systemd. Pick the right binary for your Pi:
- Pi 4/5 with 64-bit OS →
fwdsvc-linux-arm64 - Pi 2/3 with 32-bit OS →
fwdsvc-linux-armv7 - Pi Zero / Pi 1 →
fwdsvc-linux-armv6
If you have a real LoRa modem (RNode, etc.), run upstream
rnsd alongside fwdsvc
with the radio attached, and point fwdsvc at rnsd via
tcp_client → 127.0.0.1:4242. fwdsvc doesn't speak serial / LoRa
directly — rnsd is the radio half.
Windows
fwdsvc-windows-amd64.exe runs the same way:
.\fwdsvc-windows-amd64.exe -config "$env:USERPROFILE\.fwdsvc\config.toml"
For unattended startup, register it with Task Scheduler to run on
boot as a specific user (Action = "Start a program", Program =
fwdsvc-windows-amd64.exe, Arguments = -config ...\config.toml,
Trigger = "At startup", Settings = "Restart on failure"). Or use NSSM
to install it as a Windows service if you prefer that workflow.
macOS
Either a launchd plist in ~/Library/LaunchAgents/ or just running
under a tmux/screen session. The binary is the same Mach-O
universal-ish format; allow it through Gatekeeper the first time:
xattr -d com.apple.quarantine fwdsvc-darwin-arm64.
Identity backup
If you redeploy to a different machine and only carry config.toml
over, you'd lose the service's identity — and therefore its
destination hash — without it. To make config.toml self-sufficient:
- Run
fwdsvconce. It writes~/.fwdsvc/identity.b64.txt(mode 0600) on first-run identity generation. Same secrecy class as the identity file itself. - Open that file and copy the base64 string.
- In
config.tomlunder[service]:identity_b64 = "<paste here>" - Restart. The log will read:
identity loaded from config (identity_b64); ignoring …/identity
After that, config.toml alone is enough to restore the service on
any machine — same identity, same destination hash, same chat for
every existing member.
Running multiple groups in parallel
Each group chat is one fwdsvc instance — one identity, one
destination hash, one config, one running process. To host several
groups on the same machine, run one instance per group. The only hard
rule:
Every instance needs its own
identity_path,state_path, andhistory_path. Two processes sharing an identity both answer to the same destination hash and both fan out every message, so members receive duplicates. This is also why a restart script is a bad idea — if it starts a new process without reliably killing the old one, the leftovers keep forwarding and you get echoes. Let a supervisor (systemd) own the single process instead.
Full worked example: configs/multi-group/
has four ready-to-run configs — two public and two private (locked) groups —
plus a README with the systemd and
by-hand launch commands. The rest of this section walks through the same
pattern inline.
Give each group a config with distinct paths — e.g.
/etc/fwdsvc/groupA.toml:
[service]
display_name = "Group A"
identity_path = "/var/lib/fwdsvc/groupA/identity"
state_path = "/var/lib/fwdsvc/groupA/state.json"
history_path = "/var/lib/fwdsvc/groupA/history.json"
and /etc/fwdsvc/groupB.toml the same but under groupB/ with
display_name = "Group B". They can share one upstream interface (the
same tcp_client peer or a local rnsd) or use different ones — that's
independent of keeping their state separate.
With systemd, a templated unit runs one supervised process per group.
Drop this in /etc/systemd/system/fwdsvc@.service:
[Unit]
Description=Reticulum group chat (%i)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=fwdsvc
Group=fwdsvc
ExecStart=/usr/local/bin/fwdsvc -config /etc/fwdsvc/%i.toml
Restart=on-failure
RestartSec=5
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/lib/fwdsvc
PrivateTmp=true
[Install]
WantedBy=multi-user.target
%i expands to the name after the @, so fwdsvc@groupA reads
/etc/fwdsvc/groupA.toml. Enable each group and tail its own journal:
sudo systemctl enable --now fwdsvc@groupA fwdsvc@groupB
sudo journalctl -u fwdsvc@groupA -f
To confirm you're running exactly one process per group (and no orphans
from an earlier manual start), pgrep -af fwdsvc should list one line
per group and nothing more. Each instance is independent for resource
purposes — see Scaling and resource use.
Operations
State on disk
Everything lives in the directory whose paths you configured in
[service] (default ~/.fwdsvc/):
| File | Purpose | Backup-worthy? |
|---|---|---|
config.toml |
Your config. Optionally embeds the identity. | Yes |
identity |
Service identity (64 bytes). Lose it → lose your destination hash. | Yes (or use identity_b64) |
identity.b64.txt |
Base64 form of identity, written on first-run for backup. |
Yes |
state.json |
Roster + banlist. Atomic writes. | Yes |
history.json |
Replay ring buffer. | Optional (loss only affects replay-on-join) |
outbound.json |
Pending outbound retries. | Optional (loss costs at most a few queued messages) |
announces.json |
Cached peer paths. | No (regenerates from inbound announces) |
fwdsvc.log |
If log_path is set, the rolling daemon log. |
No |
Logs
Without log_path, everything goes to stdout — let systemd journal
or your terminal absorb it. With log_path, both stdout and the
file get the same lines. Each log line is RFC3339-ish timestamped
to the microsecond. Examples worth recognising:
| Pattern | Meaning |
|---|---|
interface tcp_client connected: … |
TCP interface up. |
announce verified (new|returning): … |
We learned a path to a peer. |
cmd from=<hash> name=/<cmd> |
A command arrived. |
cmd reply queued: … |
The reply went onto the outbound queue. |
outbound: attempt N/5 to <hash> failed: … |
A delivery attempt failed; retrying. |
outbound: failing message id=… after 5 attempts: … |
Gave up after the retry budget. |
resource sender: ADV retry N/4 for <hash> |
Resource transfer's ADV phase is retrying because the receiver hasn't requested any parts yet. |
nick from announce: adopted "X" for … |
Auto-defaulted a nickname from an inbound announce (v1.3.5+). |
tcp interface … disconnected: … — reconnecting |
Upstream TCP drop; supervisor will redial with backoff (v1.3.6+). |
tcp interface … reconnected |
Reconnect succeeded; interface is live again (v1.3.6+). |
Scaling and resource use
A few load-related facts worth knowing before you grow the roster past a few dozen — none are currently a problem at typical sizes but they shape what you'd notice first if you pushed harder:
- Outbound queue depth scales linearly with active roster. Each
inbound chat message produces one
outbound.jsonentry per active (non-paused) recipient. A 60-member roster + one message in flight = up to ~59 pending entries. They drain promptly when recipients are reachable; an unreachable recipient rides retries with exponential backoff (10 s → 60 s cap, each attempt also waiting up to 15 s for a delivery proof). With the propagation fallback active that'sdirect_attemptstries (default 3, ~75 s) before the message is handed to the node; without a node it's the full 5 attempts (~3 minutes) before the queue gives up. - Drain concurrency is fixed at 8 workers, not scaled with roster
size (
outboundWorkersconstant ininternal/service/outbound.go). Eight absorbs several concurrent 15-second delivery-proof waits on offline members without head-of-line blocking replies to online ones. For very large rosters (hundreds of members) on a fast interface, raising the constant and rebuilding would speed up fan-out. - No upper bound on pending depth. Nothing rejects new messages when the queue is long. In steady state the queue is bounded by chat cadence × the per-recipient retry window, not by anything explicit. Worth knowing if you ever script a flood through the relay.
outbound.jsonis rewritten on everyEnqueue— fanning out one message to N recipients does N full-file writes, each marshaling up to N entries (O(N²) in disk write volume per fan-out). Negligible at current sizes; the first thing that would need a batched-persist refactor if the roster grew toward several hundred.- Attachments are not persisted. Per-message LXMF fields (e.g. a
forwarded
FIELD_IMAGE) live in memory only — a crash between enqueue and send drops the image but keeps the text body, which re-sends on restart. Acceptable degradation; sender can always resend. - History buffer is bounded by
replay.count(default 100). Older forwarded lines roll off when a new one is appended.
Troubleshooting
My users never see replies to /users (or other commands). A
slow or stale path causes Link / Resource sends to time out. Try
/announce from an admin to push a fresh announce of the daemon
into the mesh, and have the affected user re-announce from their
client. Use /path <user> (admin) to see what the daemon knows
about reaching them — if LinkActive=false and the announce is
many hours old, the path is the problem, not fwdsvc. Restarting
the affected user's client usually re-announces it immediately.
My users never see ANY messages from me. Verify the daemon is
actually reaching the network: look for interface tcp_client connected at startup, and announce verified lines (which mean
inbound traffic is flowing). If neither shows up after 30s, the
configured [[interfaces]] address isn't reachable from this host.
/join worked but nothing forwards. Check the sender isn't
paused (/users would show [paused]). Check the daemon log — if
a forward fails 5/5 times for a recipient, you'll see one
failing message id=… line and that recipient missed that message,
but the chat continues for everyone else.
The destination hash changed after a redeploy. You lost the
identity. Either restore the identity file or, better, set
identity_b64 in config.toml so this can never happen again.
Path table looks stale. rm ~/.fwdsvc/announces.json and
restart — the cache will rebuild from live announces (cost: one
announce_interval of waiting for path discovery).
Upgrading
- Stop the service.
- Replace the binary.
- Start the service.
The on-disk state format is stable; new versions read older
state.json, history.json, outbound.json, announces.json
files. If a future release ever breaks compatibility it'll be called
out in the release notes.
Removing an admin or mod
There is no /promote or /demote runtime command — admin/mod
membership is config-only by design (auditable via git diff). Edit
admins or mods in config.toml, restart.
Wire-format support
Below are the parts of the Reticulum / LXMF stack fwdsvc actually
speaks. Each one has at least one of: a static byte-level test
vector against canonical Python output, a passing live subprocess
interop test against rns 1.2.0 + LXMF 0.9.6, or confirmed live
round-trip with a third-party LXMF client.
- Identity — X25519 + Ed25519 keypair, on-disk format, identity and destination hash derivation (SPEC §1).
- Token cipher — AES-256-CBC + HMAC-SHA256 + HKDF-SHA256 with the
identity_hashsalt gotcha (SPEC §3). - Packet header — HEADER_1 / HEADER_2 codec including the hashable-part rule that makes proofs survive HEADER_1↔HEADER_2 in flight (SPEC §2).
- HDLC framing — for
tcp_clientinterfaces (SPEC §8.2). - Announce — build, parse, verify (with and without ratchet),
including the SPEC §9.3 msgpack
bin-vs-strgotcha forapp_data. - Opportunistic LXMF — full sign / encrypt / decrypt / verify in both directions, including SPEC §5.6 dual-msgpack-variant tolerance.
- PROOF emission and tracking (SPEC §6.5) — every inbound CTX_NONE
DATA at a SINGLE destination is acknowledged with a 64-byte
implicit-form proof so senders'
PacketReceipts resolve. Outbound opportunistic sends track the recipient's proof in return (accepting implicit and explicit forms):Sendblocks up to 15 s for it, so an offline recipient surfaces as a failed attempt that the retry queue — and the propagation fallback — can act on, instead of a silent fire-and-forget "success". - Path requests (SPEC §7.1) — when a sender we can't verify
contacts us, we issue a
path?broadcast; a path-aware relay's path-response announce gives us their public key. Per-target 60 s dedup window with periodic sweep. - HEADER_2 originator conversion (SPEC §2.3) — outbound DATA to a
multi-hop recipient is HEADER_2 with the cached next-hop
transport_id. - Reticulum Link (SPEC §6) — full LINKREQUEST / LRPROOF handshake (byte-exact against the spec test vector), ECDH+HKDF session keys, link-form Token cipher, link-DATA framing, SPEC §6.5.6 explicit-form 96-byte link PROOFs. Idle links auto-close after 15 min; KEEPALIVE every 4 min.
- Delivery stamps (SPEC §5.7.2, outbound) — when a recipient's
announce declares a
stamp_cost(§4.3 element [1]), grinds the proof-of-work over the message_id on the 3000-round (768 KiB) workblock and carries it as payload element [4], so peers that enforce stamps accept our direct sends. Capped at 16 bits: the cost is chosen by the recipient and the grind runs once per member per message, so an absurd demand is refused outright rather than allowed to occupy an outbound worker. Inbound validation is out of scope — a stamp we receive is carried through unverified. - Propagation-node submission (SPEC §5.8, sender side) — packs
the propagated wire form (
dest_hash || Token-encrypted body, wrapped in the msgpack upload envelope), parseslxmf.propagationannounce app_data (the strict 7-element §5.8.5 shape, including the[5]-as-list interop gotcha), grinds §5.7.2 propagation stamps (1000-round PN workblock) when the node requires them, and uploads over a Link — single link DATA or Resource by size. The server role and/getretrieval are out of scope (members' clients fetch their own mail). - Resource transfer (SPEC §10) — full sender and receiver. Send:
link-encrypt the body, slice into raw-ciphertext parts, advertise
via msgpack ADV, fulfill receiver-driven REQs, validate the
receiver's RESOURCE_PRF in constant time. Receive: parse ADV,
fetch parts, verify, decrypt. Roughly 1 MiB / 74 parts per
resource; inbound
c=1(bz2-compressed) andn>74ADVs are rejected (bomb defense — seedocs/resource-security-audit.md). Multi-segment transfers (§10.11) are refused in both directions (SetMaxResourceSegments(1)): the send path is single-segment, so accepting one inbound would mean reassembling a bodyfwdsvcthen could not relay. An oversize sender gets aRESOURCE_RCLand stops retransmitting rather than timing out.max_attachment_bytesdrops the oversize field before forwarding, which is what keeps every relayed message inside one segment to begin with.
Delivery-path selection is automatic: ≤ ~280 bytes is opportunistic,
≤ 431 bytes plaintext over a Reticulum Link is single-packet Link
DATA, anything bigger is Resource transfer over that same Link. So
long /users replies on a big roster ship the full list — size is
not a delivery constraint.
Reactions: what a client must implement
Reactions ride on FIELD_REACTION (0x40, LXMF 1.0.0). Because fwdsvc
re-originates every message (re-signs it as the service), a client needs
the four things below for reactions to work through the relay. Full
wire detail, rationale, and the cross-client convention are in
docs/reaction-attribution.md — written
so Sideband / MeshChatX / Columba / ratspeak (or any LXMF client) can
implement against it.
-
Decode nested integer-keyed field maps. A reaction's fields are
{0x40: {0x00: <raw 32-byte target message_id>, 0x01: <UTF-8 content>}}— the inner dict has integer keys. Many msgpack libraries decode a nested map value into a string-keyed map and reject integer keys; Go'svmihailenco/msgpackfails withinvalid code=0 decoding string/bytes lengthand drops the entire message. Decode into an untyped / any-keyed map at every nesting level (Go:Decoder.SetMapDecoder(func(d){ return d.DecodeUntypedMap() })), and tolerate any integer key width (int8/int64/…) andbytes-or-strvalues. The same applies toFIELD_COMMENT(0x41) andFIELD_CONTINUATION(0x42). This is the #1 cause of "reactions silently don't work" — the tell is that replies (0x30, raw bytes at the top level) work but reactions vanish, and over a Link the sender retries forever (it never gets a delivery proof). -
Emit reactions on
0x40with emptycontent/title:0x00= the raw 32-bytemessage_idof the target as your client received it,0x01= the reaction text (e.g. an emoji). Do not use the pre-1.0.0fields[16]shape —fwdsvcno longer accepts it. -
Send the reaction to the relay. No special routing needed: the relayed message's
source_hashis the relay (we re-sign), so a reaction addressed to the message you're reacting to naturally goes back to the relay for fan-out. -
Attribute by the originator stamp. So reactions aren't all attributed to the relay,
fwdsvcstamps the reactor'ssource_hashinto custom fields. When a received reaction carriesfields[0xFB] == "originator-identity"(exact UTF-8) and a well-formedfields[0xFC](the reactor's raw 16-bytesource_hash— itslxmf.deliverydestination hash, the same value a direct reaction carries and what contacts are keyed by, per SPEC §5.9.8 / §9.1; not the identity hash, which would orphan the lookup), attribute / aggregate by that hash instead of the carryingsource_hash; fall back tosource_hashwhen the stamp is absent. ⚠️ The stamp is unauthenticated — honor it only when the reaction arrived via a trusted relay (e.g. the source that delivered the reacted-to message), and validate0xFCis 16 bytes, else a direct peer can forge attribution to anyone. Aggregate by(reactor-identity, reaction-content). Full convention + the trust rules:docs/reaction-attribution.md.
Target binding "just works": the relay rewrites the reaction's target
message_id per recipient (each member computed a different id for the
same bubble), so you react with the id your client holds and it lands on
the right message everywhere.
Limitations
The implementation is intentionally minimal — just enough Reticulum + LXMF to run a group-chat hub. Notable gaps:
- Single TCP interface type —
tcp_clientonly. No LoRa / RNode-serial, no UDP, no AutoInterface (LAN multicast), no I2P. A Pi with a real LoRa modem will need to run upstreamrnsdalongsidefwdsvcand pointfwdsvcatrnsdover TCP. - No transit relay.
fwdsvcis a leaf node — it doesn't forward third-party packets. - No automatic TCP reconnect. If the configured
tcp_clientinterface drops, the service logs and continues; you have to restart it. Use systemdRestart=on-failure(already in the recipe above). - No ratchets on our own key.
fwdsvcpublishes no §7.3 ratchet, so every message sent to the group is encrypted to our long-term X25519 key and a future compromise of it decrypts past traffic. Outbound is better off: since the library gained ratchet support we encrypt to a peer's announced ratchet automatically when they publish one, so what we send to a ratcheting member is already forward-secret. Enabling one on our side is a config and key-rotation question, not a protocol gap. - Stamps: both directions, charging is opt-in. Both §5.7 proof-of-
work flavors are generated — delivery stamps for recipients whose
announce declares a
stamp_cost, and propagation stamps for nodes that require them. Inbound stamps are now validated and scored as well: setinbound_stamp_costand every sender pays before the fan-out, withenforce_inbound_stampsdeciding whether a message that underpays is dropped or merely logged. Both default to off, so out of the box a sender still costs the group nothing to message and the fan-out is worth rate-limiting by other means. Tickets (§5.7.3) are supported by the library but not wired up here, so a member has no way to earn an exemption from the grind yet. - Propagation: sender side only. fwdsvc can submit messages to
a propagation node (see
[propagation]), but does not act as a propagation node itself and does not retrieve its own inbound mail via/get— people messaging the group need the service reachable directly. - Limited LXMF field support.
FIELD_IMAGE(6) and the upstream LXMF 1.0.0 message-meta fields — reply-to (0x30=48 +0x31=49), reactions (FIELD_REACTION 0x40=64), comments (0x41=65), and continuations (0x42=66) — are forwarded through group chat by default;FIELD_FILE_ATTACHMENTS(5) andFIELD_AUDIO(7) can be enabled per-operator viaforwarded_fields. Stickers, embedded LXMs, telemetry, icon-appearance, and command fields are still parsed but discarded. A sender whose attachment (file, image or audio) is refused by the policy gets a private reply saying so — otherwise a voice clip reassembles perfectly and then vanishes with no signal to anyone. Refused metadata (a reaction, say) is silent, since the user did not choose to attach it. - Reactions / reply-to lifetime. Because the relay re-emits each
forwarded message under its own identity, every recipient computes
a different
message_idfor the same bubble — so cross-client reactions and replies need per-recipient rewriting. fwdsvc does this in-memory via a TTL cache (id_cache_ttl, default 24h), so reactions / replies to messages relayed within that window bind correctly on every other member's client. Past the TTL (or after a service restart, since the cache is not persisted) the binding falls back to legacy behavior — reactions don't render, replies show only theirfields[0x31]quote preview. - Reaction attribution. Because the relay re-signs each reaction as
itself, a reaction's
source_hashbecomes fwdsvc — and a reaction has no body to carry a[Nick]prefix, so naive relaying collapses every reaction onto the service. fwdsvc stamps the original reactor'ssource_hash(its destination hash, what contacts are keyed by — SPEC §9.1) into the upstream custom fields (FIELD_CUSTOM_TYPE 0xFB = "originator-identity",FIELD_CUSTOM_DATA 0xFC = reactor source_hash) so cooperating clients attribute the reaction to the reactor, not the relay. The0x40wire format is untouched and the stamp is purely additive (spec-only clients ignore it). This is an app-layer interop convention documented for other clients indocs/reaction-attribution.md. Replies need no stamp — they carry a body and ride the[Nick]path. - No voice / audio. Text-only. We register only the
lxmf.deliveryaspect — nevercall.audio. Some MeshChat users see a brief "incoming call" notification attributed tofwdsvcshortly after our announce. Audit on our side ruled out every code path that could possibly target acall.audiodest_hash; suspected upstream cause + diagnostic ask indocs/meshchat-call-codec-mismatch-issue.md. Not afwdsvcbug. - IFAC packets rejected. Packets with the IFAC flag set are
refused at parse with
errIFACUnsupported. Real IFAC support would require new interface config and is not on the roadmap.
Build from source
Requires Go 1.26 or newer.
git clone https://github.com/thatSFguy/reticulum-group-chat
cd reticulum-group-chat
go mod tidy
go build -o fwdsvc ./cmd/fwdsvc
go test ./...
Cross-compile every release target into build/:
./scripts/build-all.sh
ls -lh build/
Verification
Three increasingly strong levels of test:
# 1. Default unit + spec test vectors. Static byte-level equality
# against canonical Python rns 1.2.0 / LXMF 0.9.6 vectors loaded
# from ../reticulum-specifications/test-vectors/ (skipped cleanly
# if the spec sibling repo isn't checked out).
go test ./...
# 2. Live Python subprocess interop. Spawns a Python helper that
# drives upstream rns + LXMF directly and exchanges fresh
# announce + opportunistic-LXMF bytes with the Go code in BOTH
# directions. Requires `pip install rns lxmf` and `python` on
# PATH. Skipped otherwise. Also runs on CI on every push.
go test -tags=interop ./tests/interop/...
Plus a live mesh interop check during development: the service
runs against a community-run testnet entry node
(rns.chicagonomad.net, rns.michmesh.net) and is exercised
end-to-end with Sideband / NomadNet / MeshChat — announce
propagation, opportunistic LXMF send, PROOF emission, path-request
resolving an unannounced sender, Link + Resource delivery,
round-tripping back to the mobile UI.
Project info
License
MIT.
Contributing
This implementation tracks the canonical Reticulum / LXMF spec directly. Wire-format changes should reference the relevant SPEC.md section number in the commit message and either include a static test vector or pass live interop.
Issues that find a discrepancy between this implementation and
upstream Python rns / LXMF: please cite the upstream
file:line and a runtime reproduction in the report.