Files
archy/.planning/phases/01-federation-mesh-hardening/01-04-PLAN.md

19 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
01-federation-mesh-hardening 04 execute 1
core/archipelago/src/api/rpc/lnd/info.rs
core/archipelago/src/mesh/message_types.rs
core/archipelago/src/mesh/types.rs
core/archipelago/src/mesh/listener/dispatch.rs
core/archipelago/src/api/rpc/mesh/typed_messages.rs
core/archipelago/src/api/rpc/dispatcher.rs
true
FED-05
truths prohibitions artifacts key_links
lnd.getinfo returns this node's Lightning identity_pubkey and its advertised connection URIs, so the UI has something real to copy and share
A node with no reachable LND, or an LND that advertises no URI, yields an absent identity rather than a fabricated one — the caller can tell 'not available' from 'available'
A meshed peer that advertises Lightning is recorded with its URI on the mesh peer record and is listed by mesh.lightning-peers
mesh.lightning-peers returns an empty list, not an error, when no meshed peer has advertised Lightning (FED-05 empty edge, mesh half)
A peer that advertises Lightning twice appears once in mesh.lightning-peers, with the most recent URI (FED-05 adjacency edge, mesh half)
mesh.lightning-peers returns peers in a deterministic order so the picker list does not reshuffle between reads (FED-05 ordering edge, mesh half)
An inbound Lightning advertisement whose URI is not well-formed is rejected and does not overwrite a previously known good URI for that peer
statement category
A node's Lightning URI MUST NOT be advertised to parties the operator has not chosen to reach — the advertisement is sent on an explicit send, never auto-broadcast to every radio contact in range, and a received URI is never re-broadcast onward to third parties privacy
path provides contains
core/archipelago/src/api/rpc/lnd/info.rs identity_pubkey + uris on the lnd.getinfo response identity_pubkey
path provides contains
core/archipelago/src/mesh/message_types.rs LightningInfo typed message + payload LightningInfo
from to via pattern
core/archipelago/src/mesh/listener/dispatch.rs core/archipelago/src/mesh/types.rs inbound LightningInfo envelope writes MeshPeer.lightning_uri lightning_uri
from to via pattern
core/archipelago/src/api/rpc/dispatcher.rs core/archipelago/src/api/rpc/mesh/typed_messages.rs mesh.lightning-peers and mesh.send-lightning-info match arms mesh.lightning-peers
Give the platform the two Lightning facts the channel-open UI needs from the mesh side: **this node's own shareable URI**, and **which meshed peers have Lightning installed and what their URI is**.

Purpose: FED-05, whose scope is LOCKED in CONTEXT.md — the "public/other" list in the channel-open picker is meshed peer nodes that have Lightning installed, not lnd listpeers, not a curated directory, not a live LN-graph query. That requires peers to advertise a Lightning capability plus their URI over the mesh. RESEARCH.md Pitfall 5 confirms neither datum exists today: handle_lnd_getinfo fetches LND's /v1/getinfo but its response struct does not deserialize identity_pubkey or uris, and PATTERNS.md records that mesh peer capability advertisement has no analog in the codebase — it is genuinely new surface, to be built on the existing typed-envelope pattern. Output: an extended lnd.getinfo, a new LightningInfo typed mesh message, a lightning_uri field on MeshPeer, and two new RPCs (mesh.lightning-peers, mesh.send-lightning-info).

<execution_context> @$HOME/.claude/gsd-core/workflows/execute-plan.md @$HOME/.claude/gsd-core/templates/summary.md </execution_context>

@.planning/PROJECT.md @.planning/STATE.md @.planning/phases/01-federation-mesh-hardening/01-CONTEXT.md @.planning/phases/01-federation-mesh-hardening/01-RESEARCH.md @.planning/phases/01-federation-mesh-hardening/01-PATTERNS.md @core/archipelago/src/mesh/message_types.rs

Artifacts this phase produces

Created or changed by this plan:

Symbol Kind File
LndInfo.identity_pubkey: Option<String> new response field on lnd.getinfo core/archipelago/src/api/rpc/lnd/info.rs
LndInfo.uris: Vec<String> new response field on lnd.getinfo same
LndGetInfoResponse.identity_pubkey / .uris new deserialized LND REST fields same
MeshMessageType::LightningInfo = 26 (label lightning_info) new wire message type core/archipelago/src/mesh/message_types.rs
LightningInfoPayload { uri, alias } new CBOR payload struct same
MeshPeer.lightning_uri: Option<String> new optional peer field core/archipelago/src/mesh/types.rs
handle_mesh_lightning_peers new RPC handler (mesh.lightning-peers) core/archipelago/src/api/rpc/mesh/typed_messages.rs
handle_mesh_send_lightning_info new RPC handler (mesh.send-lightning-info) same
mesh.lightning-peers, mesh.send-lightning-info dispatcher match arms core/archipelago/src/api/rpc/dispatcher.rs
Task 1: End-to-end — this node's own Lightning URI reaches the RPC boundary Two additive optional fields on an internal RPC response; no consumer breaks if they are removed again. core/archipelago/src/api/rpc/lnd/info.rs - `core/archipelago/src/api/rpc/lnd/info.rs` lines 1-110 — the `LndInfo` serialize struct, the `LndGetInfoResponse` deserialize struct (which currently declares only `alias`, `num_active_channels`, `num_peers`, `synced_to_chain`, `block_height`), and how `handle_lnd_getinfo` maps one into the other with `unwrap_or_default()`. - `core/archipelago/src/api/rpc/lnd/channels.rs` around `handle_lnd_openchannel` (from L238) — the sibling handler's pubkey validation (66 hex chars) and error-shaping style to mirror. - Deserializing an LND `/v1/getinfo` body that contains `identity_pubkey` and a non-empty `uris` array yields both on the mapped response. - Deserializing a body with neither field present succeeds and yields `identity_pubkey: None` and an empty `uris` vector — never a fabricated or placeholder identity. - A body whose `identity_pubkey` is not 66 hex characters yields `identity_pubkey: None` rather than propagating a malformed key that `lnd.openchannel` would later reject. Write the tests first, in a `#[cfg(test)] mod tests` block in `info.rs`, driving a `serde_json::from_str::(...)` over three fixture bodies (full, empty, malformed pubkey) plus the mapping function. Extract the `LndGetInfoResponse` → `LndInfo` identity mapping into a small pure function so it is testable without an HTTP call; keep the existing HTTP flow otherwise untouched.
Add `identity_pubkey: Option<String>` and `uris: Vec<String>` to `LndGetInfoResponse` with
`#[serde(default)]`, and the corresponding `identity_pubkey: Option<String>` and
`uris: Vec<String>` to the serialized `LndInfo`. Validate the pubkey shape the same way
`handle_lnd_openchannel` does (66 hexadecimal characters) before forwarding it; on failure
forward `None`, and log at `warn!` naming the field.

Do not change any existing `LndInfo` field name or type — `HomeWalletCard.vue`, `Server.vue`,
and `Web5Wallet.vue` all read this response.
cd core && cargo test -p archipelago lnd::info - `cd core && cargo test -p archipelago lnd::info` exits 0 with at least 3 test cases. - `grep -c 'identity_pubkey' core/archipelago/src/api/rpc/lnd/info.rs` is at least 4. - `grep -c 'uris' core/archipelago/src/api/rpc/lnd/info.rs` is at least 3. - `cd core && cargo build -p archipelago` exits 0. - The SUMMARY records the pre-implementation failing output of the fixture tests. `lnd.getinfo` carries the node's real Lightning identity and URIs, or an honest absence, proven by fixture tests. Task 2: A meshed peer can advertise "I have Lightning" and its URI is stored `MeshMessageType` is a radio wire format shared with every fleet node; the new discriminant and its CBOR payload shape become readable by deployed peers after the next OTA, so changing the payload later needs a coordinated fleet upgrade. Kept additive (unused discriminant, optional payload fields) so old nodes simply ignore it. core/archipelago/src/mesh/message_types.rs, core/archipelago/src/mesh/types.rs, core/archipelago/src/mesh/listener/dispatch.rs - `core/archipelago/src/mesh/message_types.rs` lines 28-200 — the `#[repr(u8)] MeshMessageType` enum (highest current discriminant is `AssistResponse = 25`), and the three places every new variant must be added: the enum, `from_u8`, `from_label`, and `label`. Also read `ReactionPayload` (from L533) and `PresencePayload` (from L727) for payload struct conventions, and the `TypedEnvelope` doc comment about `compact_bytes` (a plain derived `Vec` bloats every message on the wire — this matters on LoRa). - `core/archipelago/src/mesh/types.rs` lines 60-118 — the `MeshPeer` struct and its `#[serde(default)]` optional-field convention (see `lat`/`lon`, `pkc_capable`). - `core/archipelago/src/mesh/listener/dispatch.rs` around lines 430-490 — the `Some(MeshMessageType::Reaction)` and `Some(MeshMessageType::Presence)` inbound arms: how a decoded envelope is matched, its payload deserialized, and peer/message state mutated. Add `LightningInfo = 26` to `MeshMessageType` with a doc comment stating what it advertises and that it is only ever sent on an explicit operator action. Register it in `from_u8` (26), `from_label` ("lightning_info"), and `label`.
Add `LightningInfoPayload` next to the other payload structs: a required `uri: String` (the
`pubkey@host:port` form) and an optional `alias: Option<String>` with `#[serde(default)]`.
Follow the surrounding payload structs' serde conventions.

Add `#[serde(default)] pub lightning_uri: Option<String>` to `MeshPeer`, with a doc comment
saying it is set only from a received `LightningInfo` advertisement (or federation seeding in a
later plan) and is what the channel-open picker offers as a request target.

Add an inbound arm in `dispatch.rs` for the new type, mirroring the shape of the `Reaction` and
`Presence` arms: deserialize the payload, validate the URI before storing (a `pubkey@host` form
whose pubkey part is 66 hex characters; the `:port` suffix is optional), and on success write it
onto the resolved `MeshPeer`. On a malformed URI, log at `warn!` and return without touching a
previously stored value. Store the newest advertisement when a peer advertises more than once —
overwrite, do not accumulate.

Add unit tests in `message_types.rs` covering the round-trip of the new discriminant through
`from_u8`/`from_label`/`label`, and a `dispatch.rs`-level test (or a pure helper test if
`dispatch.rs` has no test harness) asserting that a malformed URI leaves a previously stored good
URI intact.
cd core && cargo test -p archipelago mesh::message_types mesh::listener - `cd core && cargo test -p archipelago mesh::message_types mesh::listener` exits 0. - `grep -c 'LightningInfo' core/archipelago/src/mesh/message_types.rs` is at least 5 (enum, from_u8, from_label, label, payload doc). - `grep -Eq 'lightning_info' core/archipelago/src/mesh/message_types.rs` succeeds. - `grep -c 'lightning_uri' core/archipelago/src/mesh/types.rs` is at least 1. - `grep -c 'LightningInfo' core/archipelago/src/mesh/listener/dispatch.rs` is at least 1. - `cd core && cargo test -p archipelago` exits 0. The mesh understands a Lightning-capability advertisement, validates it, and records the peer's URI. Task 3: Expose the meshed Lightning peers and the send path over RPC core/archipelago/src/api/rpc/mesh/typed_messages.rs, core/archipelago/src/api/rpc/dispatcher.rs - `core/archipelago/src/api/rpc/mesh/typed_messages.rs` around `handle_mesh_contacts_list` (from L1218) — the canonical read-handler shape: `self.mesh_service.read().await`, the "Mesh service not running" error, `shared_state()`, then `.read().await` on the relevant map. - The same file around `handle_mesh_send_reaction` (in the L637-976 family) — the canonical send-handler shape: build a payload, wrap in `TypedEnvelope::new(...).with_seq(seq)`, send. - `core/archipelago/src/api/rpc/dispatcher.rs` lines 390-440 — the one-line `"mesh." => self.handle_...(params).await,` registration convention. - `core/archipelago/src/server.rs` around `is_peer_allowed_path` (from L1270) — confirm whether the new RPCs need peer reachability. They are operator-local calls over `/rpc/v1`, which is already in the allow-list; do NOT widen that list. Add `handle_mesh_lightning_peers`: read the mesh peer map, keep only peers whose `lightning_uri` is set, collapse duplicates by the peer's authenticating key (the `MeshPeer` accessor that prefers the verified archipelago identity key over the firmware routing key) keeping the most recently heard entry, and return a stable-sorted array — sort by display name, then by contact id as the tiebreak, so the picker list does not reshuffle between reads. Each entry carries at minimum: contact id, display name, `lightning_uri`, `last_heard`, `reachable`, and `hops`. Returning zero matching peers is an empty array with a success result, never an error.
Add `handle_mesh_send_lightning_info`: take a target peer identifier in params, read this node's
own URI from the `lnd.getinfo` path built in Task 1, refuse with a clear error when no URI is
available (LND down, or no advertised URI) rather than sending an empty advertisement, then send
a `LightningInfo` envelope to that peer only. It must not broadcast to all contacts: the target
is required, and the handler returns an error when it is absent.

Register both in the dispatcher as `"mesh.lightning-peers"` and `"mesh.send-lightning-info"`,
following the existing one-line convention.

Add tests covering: empty peer map yields an empty array; two advertisements from the same peer
yield one entry with the newer URI; ordering is stable across two consecutive calls over the
same peer set; `handle_mesh_send_lightning_info` with no target errors.
cd core && cargo test -p archipelago mesh - `cd core && cargo test -p archipelago mesh` exits 0. - `grep -c '"mesh.lightning-peers"' core/archipelago/src/api/rpc/dispatcher.rs` equals 1. - `grep -c '"mesh.send-lightning-info"' core/archipelago/src/api/rpc/dispatcher.rs` equals 1. - `grep -c 'handle_mesh_lightning_peers' core/archipelago/src/api/rpc/mesh/typed_messages.rs` is at least 1. - `grep -c 'is_peer_allowed_path' core/archipelago/src/server.rs` is unchanged from before this plan (the peer allow-list is not widened). - `cd core && cargo test -p archipelago` exits 0. - `cd core && cargo clippy -p archipelago --all-targets` produces no new warnings in `api::rpc::mesh` or `mesh::message_types`. The picker's meshed-Lightning-peer list has a real, deterministic, deduplicated data source, and a node can advertise its own URI to a chosen peer.

<threat_model>

Trust Boundaries

Boundary Description
radio peer → typed-envelope decode → MeshPeer Untrusted, unauthenticated-by-default RF input mutates local peer state
LND REST (/v1/getinfo) → daemon Local service response parsed into an RPC payload the UI displays and copies
operator RPC → outbound mesh send An operator action that discloses this node's payment endpoint to a chosen peer

STRIDE Threat Register

Threat ID Category Component Severity Disposition Mitigation Plan
T-01-11 Spoofing a radio peer advertising someone else's Lightning URI to redirect a channel open high mitigate The advertisement is stored against the peer's authenticating key (the verified archipelago identity key, never the firmware routing key — see MeshPeer's auth-key accessor doc); the UI in plan 01-06 labels these peers as request targets, not trusted opens
T-01-12 Tampering a malformed or oversized URI corrupting stored peer state high mitigate URI shape validated before store (66-hex pubkey part); invalid input leaves any previously stored value untouched; test asserts this
T-01-13 Information Disclosure this node's payment endpoint leaking to every radio contact in range high mitigate mesh.send-lightning-info requires an explicit target and errors without one; there is no broadcast path, and a received URI is never re-advertised onward
T-01-14 Denial of Service advertisement flooding growing the peer map unboundedly medium accept The advertisement writes a field on an existing peer record rather than creating records; peer-map growth is governed by the pre-existing contact-discovery limits, unchanged here
T-01-15 Elevation of Privilege a new RPC becoming peer-reachable and letting a remote peer enumerate Lightning peers high mitigate Both RPCs ride the existing /rpc/v1 operator surface; the acceptance criteria assert is_peer_allowed_path is not widened
T-01-SC Tampering npm/pip/cargo installs high mitigate No new crates are introduced. If one becomes necessary, stop and run the Package Legitimacy Gate before installing
</threat_model>
- `cd core && cargo test -p archipelago` — green. - `cd core && cargo clippy -p archipelago --all-targets` — no new warnings in the touched modules. - Fixture-test failure output captured before the Task 1 implementation.

<success_criteria>

  • lnd.getinfo exposes a real identity pubkey and URI list, or an honest absence.
  • A LightningInfo mesh message exists, is validated on receipt, and populates MeshPeer.lightning_uri.
  • mesh.lightning-peers returns a deduplicated, deterministically ordered list and an empty array when there are none.
  • mesh.send-lightning-info requires an explicit target and refuses to send an empty advertisement.
  • The peer HTTP allow-list is unchanged. </success_criteria>
Create `.planning/phases/01-federation-mesh-hardening/01-04-SUMMARY.md` when done. Stage by explicit path, commit, and `git push gitea-ai main`.