288 lines
20 KiB
Markdown
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 && 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 && 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>
|