--- phase: 01-federation-mesh-hardening plan: 07 type: execute wave: 2 depends_on: ["01-04"] files_modified: - core/archipelago/src/mesh/message_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: - "A user can send a channel-open request to a meshed Lightning peer, carrying this node's own Lightning URI, an optional amount, and an optional message" - "A received channel-open request appears in the recipient's mesh conversation as a typed message showing the requester's URI and note, using the existing typed-message rendering path" - "Sending a channel-open request requires an explicit target peer — there is no broadcast form" - "A channel-open request whose payload URI is malformed is rejected on receipt and never stored as a message" - "Two channel-open requests sent to the same peer in quick succession produce two distinct messages with distinct sender sequence numbers, and neither is silently dropped (FED-05 concurrency edge, mesh half)" - "A request is never rendered or reported as an opened or funded channel — it carries no channel state" prohibitions: - statement: "A channel-open request MUST NOT be presented anywhere as an accepted, open, or funded channel — a request that has not been acted on by the recipient must never appear in a channel list, a balance, or a connected-peer count" category: transparency - statement: "Receiving a channel-open request MUST NOT cause the node to open a channel, connect to the requester, or move funds on its own — acting on a request is always a separate, explicit human decision" category: safety artifacts: - path: core/archipelago/src/mesh/message_types.rs provides: "ChannelOpenRequest typed message + payload" contains: "ChannelOpenRequest" key_links: - from: core/archipelago/src/api/rpc/dispatcher.rs to: core/archipelago/src/api/rpc/mesh/typed_messages.rs via: "mesh.request-channel match arm" pattern: "mesh.request-channel" - from: core/archipelago/src/mesh/listener/dispatch.rs to: core/archipelago/src/mesh/types.rs via: "inbound ChannelOpenRequest is stored as a MeshMessage with its typed payload" pattern: "ChannelOpenRequest" --- Give a meshed Lightning peer a way to be *asked* for a channel: a typed mesh message carrying the requester's Lightning URI and an optional note, sent to one chosen peer and rendered in the recipient's conversation. Purpose: FED-05's second list. CONTEXT.md locks the semantics — meshed peers with Lightning installed are nodes you "request to open a channel with", not nodes you open against directly, because mesh peers are not bilaterally trusted the way federated nodes are. 01-UI-SPEC.md fixes the UI verb ("Request Channel", reusing `PeerRequestModal.vue`'s message field and busy states). PATTERNS.md records that the send/receive shape for this is `typed_messages.rs`'s existing reaction/reply family — a struct-per-message-type serialized into the standard envelope — and that no capability/request mechanism exists yet to extend. Output: `MeshMessageType::ChannelOpenRequest`, its payload, an inbound arm that stores it as a conversation message, and a `mesh.request-channel` RPC. @$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-UI-SPEC.md @.planning/phases/01-federation-mesh-hardening/01-PATTERNS.md @.planning/phases/01-federation-mesh-hardening/01-04-SUMMARY.md @core/archipelago/src/mesh/message_types.rs ## Artifacts this phase produces Created or changed by **this plan**: | Symbol | Kind | File | |---|---|---| | `MeshMessageType::ChannelOpenRequest = 27` (label `channel_open_request`) | new wire message type | `core/archipelago/src/mesh/message_types.rs` | | `ChannelOpenRequestPayload { uri, amount_sats, message }` | new CBOR payload struct | same | | inbound `ChannelOpenRequest` arm | listener dispatch arm storing the request as a `MeshMessage` | `core/archipelago/src/mesh/listener/dispatch.rs` | | `handle_mesh_request_channel` | new RPC handler (`mesh.request-channel`) | `core/archipelago/src/api/rpc/mesh/typed_messages.rs` | | `mesh.request-channel` | dispatcher match arm | `core/archipelago/src/api/rpc/dispatcher.rs` | Task 1: End-to-end — a channel-open request is sent to one peer and lands in their conversation `MeshMessageType` is a radio wire format read by every fleet node after the next OTA; the discriminant and payload shape become externally visible, so a later change needs a coordinated fleet upgrade. Kept additive on an unused discriminant with serde-default optional payload fields, so older nodes ignore it rather than erroring. `MeshMessageType::LightningInfo = 26` exists (plan 01-04, Task 2) — confirm the highest current discriminant by reading `core/archipelago/src/mesh/message_types.rs` before choosing this type's number. core/archipelago/src/mesh/message_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 - `core/archipelago/src/mesh/message_types.rs` — the enum and the four places every variant is registered (`enum`, `from_u8`, `from_label`, `label`), `InvoicePayload` (from L413) as the closest payload analog (it also carries a payment-ish string plus an optional amount), and the `TypedEnvelope` doc comment on `compact_bytes` and LoRa frame size. - `core/archipelago/src/api/rpc/mesh/typed_messages.rs` lines 637-760 — `handle_mesh_send_reply` and `handle_mesh_send_reaction`: param extraction, target-peer resolution, sequence-number allocation, `TypedEnvelope::new(...).with_seq(seq)`, and the send call. - `core/archipelago/src/mesh/listener/dispatch.rs` lines 430-500 — the `Reaction` and `Presence` inbound arms, and how an inbound typed message is turned into a stored `MeshMessage` with `message_type` and `typed_payload` set. - `core/archipelago/src/mesh/types.rs` lines 139-180 — the `MeshMessage` fields the stored request must populate (`plaintext` is the human-readable fallback shown in list views). - `core/archipelago/src/api/rpc/dispatcher.rs` lines 390-440 — the one-line registration convention. - `.planning/phases/01-federation-mesh-hardening/01-UI-SPEC.md` — the Copywriting Contract row for "Primary CTA — meshed Lightning peer", which fixes what the UI in plan 01-08 will send. - `MeshMessageType` round-trips the new variant through `from_u8`, `from_label`, and `label`. - `handle_mesh_request_channel` with no target peer in params returns an error; with an unknown target peer it returns an error naming the peer. - `handle_mesh_request_channel` with a target sends exactly one envelope of the new type, whose payload carries this node's own Lightning URI and the caller's optional amount and message. - Two consecutive calls to the same target allocate two different sequence numbers. - An inbound envelope of the new type with a well-formed URI is stored as a `MeshMessage` whose `message_type` is the new label and whose `typed_payload` carries the request fields. - An inbound envelope whose payload URI is malformed stores nothing and logs a warning. Write the tests first and confirm they fail. Add `ChannelOpenRequest = 27` to `MeshMessageType` (confirm 27 is unused first) with a doc comment stating that this is a *request*, that it carries no channel state, and that receiving one never causes the node to act. Register it in `from_u8`, `from_label` ("channel_open_request"), and `label`. Add `ChannelOpenRequestPayload` beside the other payload structs: a required `uri: String` (the requester's own `pubkey@host:port`), plus `#[serde(default)] amount_sats: Option` and `#[serde(default)] message: Option`. Bound the optional message length before send so a long note cannot blow past the LoRa framing budget the `TypedEnvelope` doc comment warns about; truncate at the send side rather than rejecting, and say so in a comment. Add `handle_mesh_request_channel` following the `handle_mesh_send_reply` shape: require a target peer identifier in params and error without one (there is no broadcast form); read this node's own Lightning URI via the identity path plan 01-04 added to `lnd.getinfo`, and error with a clear message when it is unavailable rather than sending an empty request; build the payload, wrap it in a `TypedEnvelope` with a freshly allocated sequence number, and send it to that peer only. Register it in the dispatcher as `"mesh.request-channel"`. Add the inbound arm in `dispatch.rs` mirroring the `Reaction` arm: deserialize the payload, validate the URI shape (66-hex pubkey part, optional `@host[:port]`), and on success store a `MeshMessage` with the new label as `message_type`, the payload as `typed_payload`, and a human-readable `plaintext` summary naming the requester and the requested amount when present. On a malformed URI, log at `warn!` and store nothing. Do not add any code path that connects to, opens a channel with, or funds the requester on receipt. The inbound arm's only effect is storing a message. cd core && cargo test -p archipelago mesh::message_types mesh::listener api::rpc::mesh - `cd core && cargo test -p archipelago mesh::message_types mesh::listener api::rpc::mesh` exits 0 with the six behaviors above present as named cases. - `grep -c 'ChannelOpenRequest' core/archipelago/src/mesh/message_types.rs` is at least 5. - `grep -c 'channel_open_request' core/archipelago/src/mesh/message_types.rs` is at least 2. - `grep -c '"mesh.request-channel"' core/archipelago/src/api/rpc/dispatcher.rs` equals 1. - `grep -c 'ChannelOpenRequest' core/archipelago/src/mesh/listener/dispatch.rs` is at least 1. - The inbound arm contains no call to any `openchannel`, `connectpeer`, or send-funds path — verified by reading the arm and recorded in the SUMMARY. - `cd core && cargo test -p archipelago` exits 0. - The SUMMARY records the pre-implementation failing test output. A channel-open request travels from an RPC call to a chosen peer's conversation, with no side effect beyond a stored message. Task 2: Harden the request path against duplicates, oversize, and misuse core/archipelago/src/api/rpc/mesh/typed_messages.rs, core/archipelago/src/mesh/listener/dispatch.rs - `core/archipelago/src/api/rpc/mesh/typed_messages.rs` as left by Task 1, plus the `handle_mesh_send_content_inline` size-tier logic for how this codebase bounds payload size before a send. - `core/archipelago/src/mesh/outbox.rs` — whether an outbound send is queued and retried, so the duplicate-suppression window is placed where it will actually see both attempts. - `core/archipelago/src/mesh/types.rs` — `MeshPeer`'s authenticating-key accessor doc comment (never use the firmware routing key for authentication). Add a short duplicate-suppression window to `handle_mesh_request_channel`: a second request to the same target peer within a bounded interval returns a distinct, non-error result reporting that a request was already sent, rather than emitting a second envelope. The UI in plan 01-08 also disables its button while a send is in flight, but a backend guard is what actually stops a double-click or a retried RPC from spamming a peer over a slow radio link. Two requests separated by more than the window must both go out — the window suppresses accidental duplicates, not legitimate repeat requests. Add tests for both sides of the window. Bound the inbound side too: reject an inbound payload whose message field exceeds the same length bound the send side truncates at, and reject an `amount_sats` outside the range `handle_lnd_openchannel` accepts (its existing 20,000..=16,777,215 sat bounds) so a request can never carry an amount the recipient could not act on. Read `channels.rs` for those exact bounds rather than restating them from memory. Attribute the stored inbound message to the peer's authenticating identity key, not the firmware routing key, following the `MeshPeer` accessor's documented rule — a request that claims to be from a trusted peer must be attributable. cd core && cargo test -p archipelago mesh api::rpc::mesh - `cd core && cargo test -p archipelago mesh api::rpc::mesh` exits 0, including a within-window suppression case and an outside-window pass-through case. - A test asserts an inbound request with an out-of-range `amount_sats` is rejected, using the bounds read from `channels.rs` rather than hardcoded duplicates of them. - `cd core && cargo test -p archipelago` exits 0. - `cd core && cargo clippy -p archipelago --all-targets` produces no new warnings in `mesh` or `api::rpc::mesh`. Accidental duplicate requests are suppressed, oversize and out-of-range requests are refused, and every stored request is attributable to a verified identity. ## Trust Boundaries | Boundary | Description | |----------|-------------| | radio peer → typed-envelope decode → stored message | Untrusted RF input becomes a conversation entry naming a payment endpoint | | operator RPC → outbound request | An operator action discloses this node's payment endpoint to a chosen mesh peer | ## STRIDE Threat Register | Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | |-----------|----------|-----------|----------|-------------|-----------------| | T-01-26 | Spoofing | a peer sending a request that appears to come from a trusted node, luring a channel open to an attacker's URI | high | mitigate | The stored message is attributed to the peer's verified archipelago identity key, never the firmware routing key (Task 2); the recipient's action on a request is always explicit and human | | T-01-27 | Elevation of Privilege | a received request causing an automatic channel open or fund movement | high | mitigate | The inbound arm's only effect is storing a message; the acceptance criteria require reading the arm and recording that it contains no open/connect/send-funds call | | T-01-28 | Denial of Service | request flooding filling a peer's conversation or saturating a LoRa link | high | mitigate | Send-side duplicate-suppression window plus inbound length and amount bounds (Task 2) | | T-01-29 | Information Disclosure | broadcasting this node's payment endpoint to every contact in range | high | mitigate | A target peer is required; the handler errors without one and there is no broadcast form | | T-01-30 | Tampering | an oversize payload fragmenting into unreassemblable LoRa chunks | medium | mitigate | The message field is truncated at the send side against the framing budget the `TypedEnvelope` doc comment describes; inbound oversize is rejected | | T-01-SC | Tampering | npm/pip/cargo installs | high | mitigate | No new crates. 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. - The SUMMARY states, from a direct read, that the inbound arm performs no Lightning action. - A new typed mesh message carries a channel-open request to one named peer. - Receiving one stores a conversation message and does nothing else. - Duplicates within a short window are suppressed; legitimate repeats are not. - Malformed URIs, oversize notes, and out-of-range amounts are refused. Create `.planning/phases/01-federation-mesh-hardening/01-07-SUMMARY.md` when done. Stage by explicit path, commit, and `git push gitea-ai main`.