--- phase: 01-federation-mesh-hardening plan: 04 type: execute wave: 1 depends_on: [] files_modified: - 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 autonomous: true requirements: [FED-05] must_haves: truths: - "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" prohibitions: - statement: "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" category: privacy artifacts: - path: core/archipelago/src/api/rpc/lnd/info.rs provides: "identity_pubkey + uris on the lnd.getinfo response" contains: "identity_pubkey" - path: core/archipelago/src/mesh/message_types.rs provides: "LightningInfo typed message + payload" contains: "LightningInfo" key_links: - from: core/archipelago/src/mesh/listener/dispatch.rs to: core/archipelago/src/mesh/types.rs via: "inbound LightningInfo envelope writes MeshPeer.lightning_uri" pattern: "lightning_uri" - from: core/archipelago/src/api/rpc/dispatcher.rs to: core/archipelago/src/api/rpc/mesh/typed_messages.rs via: "mesh.lightning-peers and mesh.send-lightning-info match arms" pattern: "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`). @$HOME/.claude/gsd-core/workflows/execute-plan.md @$HOME/.claude/gsd-core/templates/summary.md @.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` | new response field on `lnd.getinfo` | `core/archipelago/src/api/rpc/lnd/info.rs` | | `LndInfo.uris: Vec` | 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` | 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` and `uris: Vec` to `LndGetInfoResponse` with `#[serde(default)]`, and the corresponding `identity_pubkey: Option` and `uris: Vec` 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` with `#[serde(default)]`. Follow the surrounding payload structs' serde conventions. Add `#[serde(default)] pub lightning_uri: Option` 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. ## 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 | - `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. - `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. Create `.planning/phases/01-federation-mesh-hardening/01-04-SUMMARY.md` when done. Stage by explicit path, commit, and `git push gitea-ai main`.