feat(01-04): the two Lightning facts the channel-open picker needs (FED-05)

Tasks 1 and 2 of 01-04. This node's own shareable URI, and a mesh
message a peer uses to advertise theirs.

lnd.getinfo now deserializes identity_pubkey and uris, which its
response struct simply did not declare before (RESEARCH.md Pitfall 5).
The identity mapping is split into a pure map_identity() so it is
testable without a live LND. A pubkey that is not 66 hex characters maps
to None rather than being forwarded: the same rule lnd.openchannel
enforces, applied where the operator is reading their own node's
identity instead of at the moment they try to open a channel. An absent
field yields an honest absence — never a fabricated or placeholder
identity.

MeshMessageType::LightningInfo = 26 is additive on a wire format shared
with every fleet node: 26 was unused, so a peer that predates this fails
to decode it rather than mis-decoding it as something else. Its payload
is deliberately two fields — this rides LoRa, where every byte is paid
for on air, and the optional alias is skip_serializing_if so an absent
one costs nothing (asserted, not assumed).

is_valid_lightning_uri() validates before anything is stored, because
this is unauthenticated RF input: 66-hex pubkey, non-empty host, optional
numeric :port, exactly one '@'. It deliberately does NOT resolve or dial
the host — that would turn a received advertisement into an outbound
connection an attacker chose.

Two preservation hazards found while wiring MeshPeer.lightning_uri, both
of which would have silently emptied the picker:

- decode.rs's identity-advert path does a WHOLESALE insert, preserving
  only advert_name and lat/lon by hand. Reticulum re-emits identity
  adverts every announce tick, so a stored URI would have been wiped
  about once a minute. Now preserved, alongside the same guard the name
  and position already had.
- session.rs's refresh_contacts and mod.rs's federation seeding rebuild
  the peer record wholesale too. Neither carries a Lightning datum, so
  both now carry the previous value forward rather than nulling it.

A malformed inbound URI is rejected before the write, leaving any
previously stored good URI intact — otherwise anyone in range could
blank out a real peer's picker entry (T-01-12).

Verified: 5/5 new lnd::info tests, 18/18 mesh::message_types (5 new),
cargo check --all-targets clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-02 20:42:45 -04:00
co-authored by Claude Opus 5
parent e205f2c34a
commit decb7c713b
7 changed files with 338 additions and 0 deletions
+120
View File
@@ -15,6 +15,13 @@ struct LndInfo {
balance_sats: i64,
channel_balance_sats: i64,
pending_open_balance: i64,
/// This node's Lightning identity pubkey, or `None` when LND did not
/// report one or reported one that is not a compressed secp256k1 key.
/// Never fabricated: the caller can tell "not available" from "available".
identity_pubkey: Option<String>,
/// The connection URIs LND advertises for this node (`pubkey@host:port`).
/// Empty when LND advertises none — an honest absence, not a placeholder.
uris: Vec<String>,
}
#[derive(Debug, Deserialize)]
@@ -24,6 +31,40 @@ struct LndGetInfoResponse {
num_peers: Option<u32>,
synced_to_chain: Option<bool>,
block_height: Option<u64>,
#[serde(default)]
identity_pubkey: Option<String>,
#[serde(default)]
uris: Vec<String>,
}
/// A compressed secp256k1 pubkey is 66 hexadecimal characters. Mirrors the
/// check `handle_lnd_openchannel` performs before dialling a peer, so a key
/// this function passes is one that handler would accept.
fn is_valid_identity_pubkey(pubkey: &str) -> bool {
pubkey.len() == 66 && pubkey.chars().all(|c| c.is_ascii_hexdigit())
}
/// Map LND's reported identity onto the RPC response.
///
/// Split out from the HTTP flow so it is testable without a live LND. A
/// malformed pubkey yields `None` rather than propagating a key that
/// `lnd.openchannel` would later reject — surfacing the problem here, where
/// the operator is reading their own node's identity, beats surfacing it at
/// the moment they try to open a channel.
fn map_identity(get_info: &LndGetInfoResponse) -> (Option<String>, Vec<String>) {
let identity_pubkey = match get_info.identity_pubkey.as_deref() {
Some(pubkey) if is_valid_identity_pubkey(pubkey) => Some(pubkey.to_string()),
Some(bad) => {
tracing::warn!(
len = bad.len(),
"LND getinfo returned an identity_pubkey that is not 66 hex characters — \
reporting no identity rather than a key lnd.openchannel would reject"
);
None
}
None => None,
};
(identity_pubkey, get_info.uris.clone())
}
#[derive(Debug, Deserialize)]
@@ -84,7 +125,11 @@ impl RpcHandler {
},
};
let (identity_pubkey, uris) = map_identity(&get_info);
let info = LndInfo {
identity_pubkey,
uris,
alias: get_info.alias.unwrap_or_default(),
num_active_channels: get_info.num_active_channels.unwrap_or(0),
num_peers: get_info.num_peers.unwrap_or(0),
@@ -218,3 +263,78 @@ impl RpcHandler {
}))
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A real compressed secp256k1 pubkey shape: 66 hex characters.
const GOOD_PUBKEY: &str =
"03a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90";
fn parse(body: &str) -> LndGetInfoResponse {
serde_json::from_str(body).expect("LND getinfo body must deserialize")
}
#[test]
fn full_body_yields_identity_and_uris() {
let parsed = parse(&format!(
r#"{{"alias":"archy","identity_pubkey":"{GOOD_PUBKEY}",
"uris":["{GOOD_PUBKEY}@1.2.3.4:9735","{GOOD_PUBKEY}@abcd.onion:9735"]}}"#
));
let (pubkey, uris) = map_identity(&parsed);
assert_eq!(pubkey.as_deref(), Some(GOOD_PUBKEY));
assert_eq!(uris.len(), 2, "both advertised URIs must survive the mapping");
assert!(uris[0].starts_with(GOOD_PUBKEY));
}
#[test]
fn absent_fields_yield_honest_absence_not_a_fabricated_identity() {
// The pre-existing fields must still deserialize with the new ones absent —
// this is the body every node running an older LND build returns.
let parsed = parse(r#"{"alias":"archy","num_peers":3,"synced_to_chain":true}"#);
let (pubkey, uris) = map_identity(&parsed);
assert!(pubkey.is_none(), "must not invent an identity");
assert!(uris.is_empty(), "must not invent a URI");
assert_eq!(parsed.alias.as_deref(), Some("archy"));
assert_eq!(parsed.num_peers, Some(3));
}
#[test]
fn malformed_pubkey_is_dropped_rather_than_propagated() {
// Too short, non-hex, and empty must all be refused. Propagating any of
// them would push the failure to lnd.openchannel, far from the cause.
for bad in ["deadbeef", "", &"z".repeat(66), &GOOD_PUBKEY[..65]] {
let parsed = parse(&format!(r#"{{"identity_pubkey":"{bad}"}}"#));
let (pubkey, _) = map_identity(&parsed);
assert!(
pubkey.is_none(),
"malformed pubkey {bad:?} must map to None, not be forwarded"
);
}
}
#[test]
fn a_malformed_pubkey_does_not_discard_the_advertised_uris() {
// The two facts are independent: a bad identity must not silently cost
// the caller the URI list, which is the datum the picker actually needs.
let parsed = parse(&format!(
r#"{{"identity_pubkey":"nope","uris":["{GOOD_PUBKEY}@1.2.3.4:9735"]}}"#
));
let (pubkey, uris) = map_identity(&parsed);
assert!(pubkey.is_none());
assert_eq!(uris.len(), 1);
}
#[test]
fn valid_pubkey_shape_matches_the_openchannel_rule() {
assert!(is_valid_identity_pubkey(GOOD_PUBKEY));
assert!(is_valid_identity_pubkey(&"0".repeat(66)));
assert!(!is_valid_identity_pubkey(&"0".repeat(65)));
assert!(!is_valid_identity_pubkey(&"0".repeat(67)));
assert!(!is_valid_identity_pubkey(&"g".repeat(66)));
}
}