296 lines
19 KiB
Markdown
296 lines
19 KiB
Markdown
---
|
|||
|
|
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"
|
||
|
|
---
|
||
|
|
|
||
|
|
<objective>
|
||
|
|
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`).
|
||
|
|
</objective>
|
||
|
|
|
||
|
|
<execution_context>
|
||
|
|
@$HOME/.claude/gsd-core/workflows/execute-plan.md
|
||
|
|
@$HOME/.claude/gsd-core/templates/summary.md
|
||
|
|
</execution_context>
|
||
|
|
|
||
|
|
<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
|
||
|
|
</context>
|
||
|
|
|
||
|
|
## 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` |
|
||
|
|
|
||
|
|
<tasks>
|
||
|
|
|
||
|
|
<task type="tracer" tdd="true">
|
||
|
|
<name>Task 1: End-to-end — this node's own Lightning URI reaches the RPC boundary</name>
|
||
|
|
<reversibility rating="reversible">Two additive optional fields on an internal RPC response; no
|
||
|
|
consumer breaks if they are removed again.</reversibility>
|
||
|
|
<files>core/archipelago/src/api/rpc/lnd/info.rs</files>
|
||
|
|
<read_first>
|
||
|
|
- `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.
|
||
|
|
</read_first>
|
||
|
|
<behavior>
|
||
|
|
- 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.
|
||
|
|
</behavior>
|
||
|
|
<action>
|
||
|
|
Write the tests first, in a `#[cfg(test)] mod tests` block in `info.rs`, driving a
|
||
|
|
`serde_json::from_str::<LndGetInfoResponse>(...)` 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.
|
||
|
|
</action>
|
||
|
|
<verify>
|
||
|
|
<automated>cd core && cargo test -p archipelago lnd::info</automated>
|
||
|
|
</verify>
|
||
|
|
<acceptance_criteria>
|
||
|
|
- `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.
|
||
|
|
</acceptance_criteria>
|
||
|
|
<done>`lnd.getinfo` carries the node's real Lightning identity and URIs, or an honest absence, proven by fixture tests.</done>
|
||
|
|
</task>
|
||
|
|
|
||
|
|
<task type="auto">
|
||
|
|
<name>Task 2: A meshed peer can advertise "I have Lightning" and its URI is stored</name>
|
||
|
|
<reversibility rating="costly">`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.</reversibility>
|
||
|
|
<files>core/archipelago/src/mesh/message_types.rs, core/archipelago/src/mesh/types.rs, core/archipelago/src/mesh/listener/dispatch.rs</files>
|
||
|
|
<read_first>
|
||
|
|
- `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<u8>` 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.
|
||
|
|
</read_first>
|
||
|
|
<action>
|
||
|
|
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.
|
||
|
|
</action>
|
||
|
|
<verify>
|
||
|
|
<automated>cd core && cargo test -p archipelago mesh::message_types mesh::listener</automated>
|
||
|
|
</verify>
|
||
|
|
<acceptance_criteria>
|
||
|
|
- `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.
|
||
|
|
</acceptance_criteria>
|
||
|
|
<done>The mesh understands a Lightning-capability advertisement, validates it, and records the peer's URI.</done>
|
||
|
|
</task>
|
||
|
|
|
||
|
|
<task type="auto">
|
||
|
|
<name>Task 3: Expose the meshed Lightning peers and the send path over RPC</name>
|
||
|
|
<files>core/archipelago/src/api/rpc/mesh/typed_messages.rs, core/archipelago/src/api/rpc/dispatcher.rs</files>
|
||
|
|
<read_first>
|
||
|
|
- `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.<verb>" =>
|
||
|
|
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.
|
||
|
|
</read_first>
|
||
|
|
<action>
|
||
|
|
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.
|
||
|
|
</action>
|
||
|
|
<verify>
|
||
|
|
<automated>cd core && cargo test -p archipelago mesh</automated>
|
||
|
|
</verify>
|
||
|
|
<acceptance_criteria>
|
||
|
|
- `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`.
|
||
|
|
</acceptance_criteria>
|
||
|
|
<done>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.</done>
|
||
|
|
</task>
|
||
|
|
|
||
|
|
</tasks>
|
||
|
|
|
||
|
|
<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>
|
||
|
|
|
||
|
|
<verification>
|
||
|
|
- `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.
|
||
|
|
</verification>
|
||
|
|
|
||
|
|
<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>
|
||
|
|
|
||
|
|
<output>
|
||
|
|
Create `.planning/phases/01-federation-mesh-hardening/01-04-SUMMARY.md` when done.
|
||
|
|
Stage by explicit path, commit, and `git push gitea-ai main`.
|
||
|
|
</output>
|