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

288 lines
20 KiB
Markdown

---
phase: 01-federation-mesh-hardening
plan: 06
type: execute
wave: 3
depends_on: ["01-04", "01-05"]
files_modified:
- core/archipelago/src/federation/types.rs
- core/archipelago/src/federation/sync.rs
- core/archipelago/src/federation/storage.rs
- core/archipelago/src/api/rpc/federation/handlers.rs
autonomous: false
requirements: [FED-05]
must_haves:
truths:
- "A trusted federated peer's Lightning URI is known locally after sync and is emitted by federation.list-nodes, so the picker can offer a one-click channel open by hostname"
- "The Lightning field on the federation sync payload is optional with a serde default, so a node running an older build syncs with a newer one in both directions without error"
- "A federated peer that advertises no Lightning URI is emitted without the field rather than with an empty string — the picker can tell 'no Lightning' from 'Lightning at an unknown address'"
- "An inbound Lightning URI that is not well-formed is rejected at sync time and never persisted or rendered"
- "A stale sync snapshot cannot clear a peer's previously known Lightning URI, consistent with the snapshot-ordering guard from plan 01-05"
prohibitions:
- statement: "A node's Lightning URI MUST NOT reach a party the operator has not federated with — it must never be re-exported in this node's own outbound peer hints on behalf of a third-party peer, so a peer-of-a-peer cannot harvest payment endpoints by federating one hop away"
category: privacy
- statement: "Lightning URI sharing MUST NOT be silently enabled in a way the operator cannot see or reverse — whatever default ships, the current sharing state is discoverable from the node's own settings surface and changing it takes effect on the next sync without a data migration"
category: transparency
artifacts:
- path: core/archipelago/src/federation/types.rs
provides: "Lightning identity field(s) on NodeStateSnapshot (and FederationPeerHint only if the decision selects it)"
contains: "lightning"
key_links:
- from: core/archipelago/src/federation/sync.rs
to: core/archipelago/src/federation/types.rs
via: "build_local_state populates the Lightning field from this node's lnd.getinfo identity"
pattern: "lightning"
- from: core/archipelago/src/federation/storage.rs
to: core/archipelago/src/api/rpc/federation/handlers.rs
via: "update_node_state persists the peer's Lightning URI; federation.list-nodes emits it"
pattern: "lightning"
---
<objective>
Carry a federated peer's Lightning URI over the federation sync payload, so the channel-open picker
can list **trusted nodes by hostname** and open a channel with one click.
Purpose: FED-05's primary list. RESEARCH.md Pitfall 5 is blunt: building the picker before the
backend can supply a federated peer's Lightning pubkey/host produces a UI that lists names and has
nothing to pass to `lnd.openchannel`. `NodeStateSnapshot` — the payload `federation.get-state` and
sync exchange — carries no Lightning fields at all today.
Output: the sync payload extended, the peer's URI persisted and emitted by `federation.list-nodes`,
and the sharing default explicitly chosen by the operator rather than assumed.
</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
@.planning/phases/01-federation-mesh-hardening/01-04-SUMMARY.md
@.planning/phases/01-federation-mesh-hardening/01-05-SUMMARY.md
@core/archipelago/src/federation/types.rs
</context>
## Artifacts this phase produces
Created or changed by **this plan**:
| Symbol | Kind | File |
|---|---|---|
| Lightning identity field(s) on `NodeStateSnapshot` | new optional serde-default field(s); exact names fixed by the Task 1 decision | `core/archipelago/src/federation/types.rs` |
| Lightning identity field(s) on `FederationPeerHint` | added **only if** the decision selects transitive sharing | same |
| `FederatedNode.lightning_uri: Option<String>` | new persisted field on the local node record | same |
| `build_local_state` Lightning parameter | changed fn signature in the federation sync builder | `core/archipelago/src/federation/sync.rs` |
| `share_lightning_uri` | server setting + its accessor, **only if** the decision selects opt-in gating | `core/archipelago/src/api/rpc/federation/handlers.rs` (+ server info) |
| `lightning_uri` on `federation.list-nodes` | new response field | `core/archipelago/src/api/rpc/federation/handlers.rs` |
<tasks>
<task type="checkpoint:decision" gate="blocking">
<name>Task 1: Decide the Lightning field shape and sharing default on the federation sync payload</name>
<decision>What shape does the Lightning identity take on the federation sync payload, and is sharing on by default or opt-in?</decision>
<context>
This writes a new field into `NodeStateSnapshot`, the payload every federated node exchanges on
every sync. Once a release carrying it reaches the fleet, deployed peers parse that shape — a
later change to the field name, the split, or the sharing scope needs a coordinated fleet
upgrade plus a cleanup of URIs already cached in every peer's `nodes.json`. That is a one-way
door, and the sources disagree about which way to walk through it:
- `01-CONTEXT.md` records "a federated peer's Lightning URI rides the federation sync payload
**by default** — federation trust is already bilateral and explicit", and marks this
**Claude's discretion, revisable** — not locked.
- `01-RESEARCH.md` Open Question 3 recommends the opposite: follow the `shared_location`
precedent (opt-in, default off) "since exposing a payment channel target more broadly than
intended has real-money implications."
A second, related question rides along: `NodeStateSnapshot.federated_peers` carries a
`FederationPeerHint` for each of this node's trusted peers, used for transitive discovery. If the
Lightning field goes on the hint too, then Alice syncing with Bob learns Bob's *peers'* Lightning
URIs — a payment endpoint reaching a party that node never federated with. Options B and C below
keep the field off the hint; only choose otherwise deliberately.
</context>
<options>
<option id="option-a">
<name>Single `lightning_uri` on the snapshot AND on the peer hint, shared by default</name>
<pros>Widest picker coverage — a peer-of-a-peer's URI is available without an extra sync hop; simplest single field; matches CONTEXT.md's default-on stance</pros>
<cons>Sends a payment endpoint to nodes the operator never federated with, which the plan's own privacy prohibition forbids; hardest to walk back once cached across the fleet</cons>
</option>
<option id="option-b">
<name>Single `lightning_uri` on the snapshot only, shared by default with direct federated peers (CONTEXT.md's stated default, narrowed)</name>
<pros>Implements CONTEXT.md's recorded discretion default; bilateral federation trust is already explicit, so no new consent surface is needed; one field, one hop, no transitive leak; ships the picker with real data on day one</pros>
<cons>Every existing federated pair starts sharing a payment endpoint on the OTA that carries it, with no per-operator prompt; reversing later means shipping an opt-out and waiting for peers to re-sync</cons>
</option>
<option id="option-c">
<name>Single `lightning_uri` on the snapshot only, gated behind an explicit opt-in setting defaulting off (RESEARCH.md Open Question 3)</name>
<pros>Mirrors the proven `shared_location` pattern exactly; no operator starts sharing a payment endpoint without acting; safest given real-money implications; the field itself stays additive so flipping the default later is a one-line change</pros>
<cons>The trusted-node picker is empty until both sides opt in, so the FED-05 flow needs a discoverable "turn on Lightning sharing" path or it looks broken; more surface to build in this plan</cons>
</option>
</options>
<resume-signal>Select: option-a, option-b, or option-c. If you pick option-c, also say where the toggle lives (a new row in the existing federation settings surface is the default assumption).</resume-signal>
</task>
<task type="tracer" tdd="true">
<name>Task 2: End-to-end — a trusted peer's Lightning URI reaches federation.list-nodes</name>
<reversibility rating="one-way">This adds a field to `NodeStateSnapshot`, the wire payload every
fleet node parses on every sync; after the OTA carrying it, changing the field's name, split, or
sharing scope requires a coordinated fleet upgrade and a cleanup of URIs already cached in peers'
node files.</reversibility>
<precondition>`lnd.getinfo` returns `identity_pubkey` and `uris` (delivered by plan 01-04, Task 1) — confirm by reading `core/archipelago/src/api/rpc/lnd/info.rs` for both field names before starting.</precondition>
<files>core/archipelago/src/federation/types.rs, core/archipelago/src/federation/sync.rs, core/archipelago/src/federation/storage.rs, core/archipelago/src/api/rpc/federation/handlers.rs</files>
<read_first>
- `core/archipelago/src/federation/types.rs` lines 104-146 — the `shared_location` (`lat`/`lon`)
opt-in field pair with its doc comment explaining absent-vs-null, and the `FederationPeerHint`
struct with its `pubkey`/`onion` split. These are the exact patterns to mirror.
- `core/archipelago/src/api/rpc/federation/handlers.rs` around lines 470-485 — the
`shared_location` gating block (`if data.server_info.share_location { ... } else { None }`)
and how it is threaded into `federation::build_local_state`.
- `core/archipelago/src/federation/sync.rs` around lines 225-265 — `build_local_state`'s
signature and where `shared_location` is mapped into the snapshot at construction time.
- `core/archipelago/src/federation/storage.rs` `update_node_state` as left by plan 01-05,
including the monotonicity guard and the `fips_npub` exemption comment — the new field follows
the same "learn from the peer's snapshot" treatment.
- `core/archipelago/src/api/rpc/lnd/info.rs` as left by plan 01-04 — the `identity_pubkey` /
`uris` field names and the 66-hex validation helper to reuse.
- `core/archipelago/src/api/rpc/lnd/channels.rs` `handle_lnd_openchannel` (from L238) — the
exact URI/pubkey/address parsing the picker will feed, so the persisted format matches what
that handler accepts.
</read_first>
<behavior>
- `build_local_state` called with a Lightning URI puts it on the produced snapshot; called
without one produces a snapshot with the field absent (not an empty string).
- A snapshot deserialized from a payload that has no Lightning field succeeds with the field
`None` — an older peer syncs fine.
- `update_node_state` with a snapshot carrying a well-formed URI persists it onto the
`FederatedNode`; with a malformed URI it leaves any previously stored value untouched.
- A snapshot rejected by the plan-01-05 monotonicity guard does not clear an already-known URI.
- `federation.list-nodes` emits `lightning_uri` for a node that has one and omits it otherwise.
</behavior>
<action>
Implement exactly the option selected in Task 1 — do not substitute a different shape, and do not
add the field to `FederationPeerHint` unless option-a was chosen. Record the chosen option id in
the SUMMARY.
Write the tests first and confirm they fail.
Add the Lightning field(s) to `NodeStateSnapshot` with `#[serde(default)]` and a doc comment that
states the sharing rule chosen in Task 1 and explicitly notes where it differs from the
`shared_location` analog directly above it. Add `#[serde(default)] pub lightning_uri: Option<String>`
to `FederatedNode` for the locally-persisted peer value, and update the `make_node` test helper
so the struct literal still compiles.
Thread the value into `build_local_state` the same way `shared_location` is threaded: an added
parameter, mapped into the snapshot at construction. At the `handlers.rs` call site, source it
from this node's own `lnd.getinfo` identity (prefer the first entry of `uris`; fall back to
composing `identity_pubkey` with the node's reachable host when `uris` is empty), gated per the
Task 1 decision. An LND that is down or has no URI yields `None`, never an empty string and never
a fabricated address.
In `update_node_state`, learn the peer's URI from the snapshot: validate the shape before storing
(the pubkey part is 66 hexadecimal characters; the `@host[:port]` remainder is optional, matching
what `handle_lnd_openchannel` accepts), and on a malformed value log at `debug!` and leave the
prior value alone.
In `handle_federation_list_nodes`, emit `lightning_uri` with the same `if let Some(...)`
conditional-insert pattern the other optional fields use.
</action>
<verify>
<automated>cd core &amp;&amp; cargo test -p archipelago federation</automated>
</verify>
<acceptance_criteria>
- `cd core && cargo test -p archipelago federation` exits 0 with the five behaviors above present as named cases.
- `grep -c 'lightning' core/archipelago/src/federation/types.rs` is at least 3.
- `grep -c 'serde(default)' core/archipelago/src/federation/types.rs` increased by at least 2 relative to the pre-change file.
- A round-trip test proves back-compat in both directions: a snapshot JSON with no Lightning key deserializes to `None`, and a snapshot serialized with the field deserializes cleanly after being stripped of unknown keys.
- `grep -c 'lightning_uri' core/archipelago/src/api/rpc/federation/handlers.rs` is at least 1.
- If and only if option-a was selected: `grep -c 'lightning' core/archipelago/src/federation/types.rs` includes an occurrence inside the `FederationPeerHint` struct. Otherwise `FederationPeerHint` has none — assert this either way and state which in the SUMMARY.
- `cd core && cargo test -p archipelago` exits 0.
</acceptance_criteria>
<done>A trusted federated peer's Lightning URI is synced, validated, persisted, and emitted — the picker's primary list now has real targets.</done>
</task>
<task type="auto">
<name>Task 3: Make the sharing state visible and reversible</name>
<files>core/archipelago/src/api/rpc/federation/handlers.rs, core/archipelago/src/federation/sync.rs</files>
<read_first>
- The Task 1 decision as recorded in the Task 2 SUMMARY notes.
- `core/archipelago/src/api/rpc/federation/handlers.rs` — the `share_location` server-info flag
and the `server.set-location` RPC that toggles it, for the accessor + persistence pattern.
- `core/archipelago/src/federation/sync.rs``build_local_state`'s tests (from L335) for the
assertion style.
</read_first>
<action>
Under option-c: add the `share_lightning_uri` server setting with a default of off, an RPC to
read and set it following the `server.set-location` shape, and make `build_local_state`'s
Lightning parameter `None` whenever the flag is off. Add tests: flag off produces a snapshot with
no Lightning field even when LND has one; flag on produces it; toggling the flag off then
re-syncing produces a snapshot without it.
Under option-a or option-b: add a read-only surface reporting the current sharing state and the
URI actually being shared, so the operator can see what is going out; and make the outbound value
`None` whenever this node's own Lightning is not installed or not reachable. Add tests: no LND
produces no Lightning field; a present LND produces the URI; the reported state matches what
`build_local_state` actually emits.
In both cases, add a test asserting a third-party peer's Lightning URI is never re-exported in
this node's own outbound peer hints — build a local state while holding a peer whose URI is
known, serialize it, and assert that URI string does not appear in the outbound payload's peer
hint section. This test is the mechanical form of this plan's privacy prohibition and must exist
regardless of which option was chosen.
</action>
<verify>
<automated>cd core &amp;&amp; cargo test -p archipelago federation::sync</automated>
</verify>
<acceptance_criteria>
- `cd core && cargo test -p archipelago federation::sync` exits 0.
- A test named for the third-party-URI-not-re-exported behavior exists and passes; temporarily injecting the peer URI into the outbound hint makes it fail (fail-first proof recorded in the SUMMARY).
- Under option-c only: `grep -c 'share_lightning_uri' core/archipelago/src/api/rpc/federation/handlers.rs` is at least 2.
- `cd core && cargo test -p archipelago` exits 0.
- `cd core && cargo clippy -p archipelago --all-targets` produces no new warnings in `federation`.
</acceptance_criteria>
<done>The operator can see, and change, what Lightning identity this node shares — and a peer's URI provably never travels one hop further.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| this node → federated peer (outbound snapshot) | This node's payment endpoint crosses to a remote party |
| federated peer → this node (inbound snapshot) | A remote party's claimed payment endpoint is persisted and later fed to `lnd.openchannel` |
| transitive peer hint | A third party's identity data can ride this node's outbound payload to a party it never federated with |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-01-21 | Information Disclosure | this node's Lightning payment endpoint reaching a non-federated party | high | mitigate | The Task 1 decision fixes the sharing scope explicitly; Task 3 adds the test proving a third-party URI is never re-exported in outbound peer hints, plus a fail-first proof |
| T-01-22 | Spoofing | a peer advertising a Lightning URI it does not control, redirecting a channel open and its funds | high | mitigate | The snapshot arrives over the existing ed25519-signature-verified federation path (unchanged); the URI is bound to that verified peer record and validated for shape before persistence. Not re-implemented here — the existing `identity::NodeIdentity::verify` path is reused, per RESEARCH.md V6 |
| T-01-23 | Tampering | a malformed or oversized URI corrupting the persisted node record | high | mitigate | 66-hex pubkey validation before persist; malformed input leaves the prior value untouched, with a test |
| T-01-24 | Tampering | a replayed older snapshot clearing a known Lightning URI | medium | mitigate | The plan-01-05 monotonicity guard rejects strictly-older snapshots; a test asserts a rejected snapshot does not clear the URI |
| T-01-25 | Repudiation | the operator unable to tell what identity their node is sharing | medium | mitigate | Task 3 adds the visible sharing state (a setting under option-c, a read-only report otherwise) |
</threat_model>
<verification>
- `cd core && cargo test -p archipelago` — green.
- Back-compat round-trip proven in both directions against a payload lacking the new field.
- The chosen option id is recorded in the SUMMARY, together with the fail-first proof for the no-re-export test.
</verification>
<success_criteria>
- The Lightning identity field exists on the federation sync payload in exactly the shape the operator chose, additively and back-compatibly.
- A trusted peer's URI is validated, persisted, and emitted by `federation.list-nodes`.
- A third party's URI provably never leaves this node in its own peer hints.
- The current sharing state is visible to the operator.
</success_criteria>
<output>
Create `.planning/phases/01-federation-mesh-hardening/01-06-SUMMARY.md` when done.
Stage by explicit path, commit, and `git push gitea-ai main`.
</output>