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)));
}
}
@@ -345,6 +345,8 @@ pub(super) async fn resolve_peer(state: &Arc<MeshState>, sender_prefix: &str) ->
pkc_capable: false,
lat: None,
lon: None,
// Set only by an explicit LightningInfo advert, never inferred.
lightning_uri: None,
};
let is_new = {
let mut peers = state.peers.write().await;
@@ -602,6 +604,8 @@ pub(super) async fn handle_identity_received(
pkc_capable: false,
lat: None,
lon: None,
// Set only by an explicit LightningInfo advert, never inferred.
lightning_uri: None,
};
let is_new = {
@@ -624,6 +628,14 @@ pub(super) async fn handle_identity_received(
peer.lat = existing.lat;
peer.lon = existing.lon;
}
// Same hazard as the name and position above: an identity advert
// carries no Lightning datum, and Reticulum re-emits one every
// announce tick, so a wholesale insert would drop a peer out of the
// channel-open picker about once a minute. The URI comes only from
// an explicit LightningInfo advert — preserve it.
if peer.lightning_uri.is_none() {
peer.lightning_uri = existing.lightning_uri.clone();
}
}
peers.insert(contact_id, peer.clone());
is_new
@@ -485,6 +485,41 @@ pub(crate) async fn handle_typed_envelope_direct(
}
}
Some(MeshMessageType::LightningInfo) => {
match message_types::decode_payload::<message_types::LightningInfoPayload>(&envelope.v)
{
Ok(info) => {
// Validate BEFORE touching stored state. This arrives over
// unauthenticated RF, and a peer that has already given us a
// good URI must not lose it to a later malformed one —
// otherwise anyone in range could blank out a real peer's
// entry in the channel-open picker (T-01-12).
if !message_types::is_valid_lightning_uri(&info.uri) {
warn!(
"Rejecting malformed lightning_info URI from contact {} — \
keeping any previously stored URI",
sender_contact_id
);
return;
}
let mut peers = state.peers.write().await;
if let Some(peer) = peers.get_mut(&sender_contact_id) {
// Newest advertisement wins: a node that moves host or
// rotates its port re-advertises, and the stale entry
// would just fail to dial. Overwrite, never accumulate.
peer.lightning_uri = Some(info.uri);
} else {
warn!(
"lightning_info from unknown contact {} — dropped (a peer record is \
created by identity/contact discovery, not by this advertisement)",
sender_contact_id
);
}
}
Err(e) => warn!("Failed to decode lightning_info payload: {}", e),
}
}
Some(MeshMessageType::ChannelInvite) => {
match message_types::decode_payload::<message_types::ChannelInvitePayload>(&envelope.v)
{
@@ -828,6 +828,11 @@ async fn refresh_contacts(device: &mut MeshRadioDevice, state: &Arc<MeshState>)
// it just because a refresh's snapshot didn't carry one.
lat: contact.lat.or_else(|| existing.and_then(|p| p.lat)),
lon: contact.lon.or_else(|| existing.and_then(|p| p.lon)),
// A contact refresh carries no Lightning datum — it comes
// only from an explicit LightningInfo advert. Preserve what
// was advertised, or a routine refresh would silently empty
// the channel-open picker.
lightning_uri: existing.and_then(|p| p.lightning_uri.clone()),
};
peers.insert(contact_id, peer);
}
+147
View File
@@ -76,6 +76,16 @@ pub enum MeshMessageType {
/// Reply to an AssistQuery — a chunk of the LLM's answer, addressed back to
/// the asker by `req_id`. Long answers span multiple chunks (`seq`/`done`).
AssistResponse = 25,
/// "I run Lightning, and this is how to reach me" — advertises the sender's
/// LND connection URI so the recipient can offer it as a channel-open
/// target.
///
/// Only ever sent on an explicit operator action against a chosen peer
/// (`mesh.send-lightning-info`). It is never auto-broadcast to contacts in
/// range, and a received advertisement is never re-advertised onward — the
/// URI is this node's payment endpoint, and who learns it is the operator's
/// choice (T-01-13).
LightningInfo = 26,
}
impl MeshMessageType {
@@ -107,6 +117,7 @@ impl MeshMessageType {
23 => Some(Self::ContentInline),
24 => Some(Self::AssistQuery),
25 => Some(Self::AssistResponse),
26 => Some(Self::LightningInfo),
_ => None,
}
}
@@ -142,6 +153,7 @@ impl MeshMessageType {
"content_inline" => Some(Self::ContentInline),
"assist_query" => Some(Self::AssistQuery),
"assist_response" => Some(Self::AssistResponse),
"lightning_info" => Some(Self::LightningInfo),
_ => None,
}
}
@@ -174,6 +186,7 @@ impl MeshMessageType {
Self::ContentInline => "content_inline",
Self::AssistQuery => "assist_query",
Self::AssistResponse => "assist_response",
Self::LightningInfo => "lightning_info",
}
}
}
@@ -729,6 +742,57 @@ pub struct PresencePayload {
pub last_active: u32,
}
/// LightningInfo — the sender's LND connection URI, so the recipient can offer
/// it as a channel-open target.
///
/// `uri` is the standard `pubkey@host:port` form (the `:port` suffix is
/// optional). `alias` is the node's human-readable name, carried so the picker
/// can label the entry without a second round trip; it is advisory and
/// unverified, so it must never be used as an identity.
///
/// Kept to two fields on purpose: this rides LoRa, where every byte is paid for
/// on air.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LightningInfoPayload {
pub uri: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub alias: Option<String>,
}
/// Is this a well-formed Lightning connection URI (`pubkey@host` or
/// `pubkey@host:port`)?
///
/// Validated before anything is stored, because this arrives over unauthenticated
/// RF: the pubkey part must be 66 hexadecimal characters (a compressed secp256k1
/// key, the same rule `lnd.openchannel` enforces) and the host part must be
/// non-empty. Deliberately does NOT resolve or dial the host — that would turn a
/// received advertisement into an outbound connection an attacker chose.
pub fn is_valid_lightning_uri(uri: &str) -> bool {
// Exactly one '@': "a@b@c" must not pass by splitting on the first one.
let mut parts = uri.split('@');
let (Some(pubkey), Some(host), None) = (parts.next(), parts.next(), parts.next()) else {
return false;
};
if pubkey.len() != 66 || !pubkey.chars().all(|c| c.is_ascii_hexdigit()) {
return false;
}
// Strip an optional :port and require the remaining host to be non-empty.
// rsplit_once so IPv6-ish hosts don't lose their body to the first colon.
let host_only = match host.rsplit_once(':') {
Some((h, port)) => {
if port.is_empty() || !port.chars().all(|c| c.is_ascii_digit()) {
return false;
}
if port.parse::<u16>().is_err() {
return false;
}
h
}
None => host,
};
!host_only.is_empty() && !host_only.contains(char::is_whitespace)
}
/// ChannelInvite — advertise/invite a peer to join a channel. `key` is an
/// optional base64 pre-shared secret; absent `key` means public.
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -959,4 +1023,87 @@ mod tests {
let decoded: BlockHeaderPayload = decode_payload(&encoded).unwrap();
assert_eq!(decoded.height, 890412);
}
const LN_PUBKEY: &str =
"03a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90";
#[test]
fn lightning_info_round_trips_through_u8_label_and_back() {
assert_eq!(MeshMessageType::from_u8(26), Some(MeshMessageType::LightningInfo));
assert_eq!(MeshMessageType::LightningInfo.label(), "lightning_info");
assert_eq!(
MeshMessageType::from_label("lightning_info"),
Some(MeshMessageType::LightningInfo)
);
// The discriminant is additive: 26 was unused before, so a deployed peer
// that predates this simply fails to decode it rather than mis-decoding
// it as some other type.
assert_eq!(MeshMessageType::LightningInfo as u8, 26);
}
#[test]
fn lightning_info_payload_round_trips_over_the_wire() {
let payload = LightningInfoPayload {
uri: format!("{LN_PUBKEY}@1.2.3.4:9735"),
alias: Some("archy".into()),
};
let encoded = encode_payload(&payload).unwrap();
let decoded: LightningInfoPayload = decode_payload(&encoded).unwrap();
assert_eq!(decoded.uri, payload.uri);
assert_eq!(decoded.alias.as_deref(), Some("archy"));
}
#[test]
fn lightning_info_payload_alias_is_optional_and_omitted_on_the_wire() {
let payload = LightningInfoPayload {
uri: format!("{LN_PUBKEY}@1.2.3.4:9735"),
alias: None,
};
let encoded = encode_payload(&payload).unwrap();
let decoded: LightningInfoPayload = decode_payload(&encoded).unwrap();
assert!(decoded.alias.is_none());
// skip_serializing_if keeps the absent alias off the air entirely.
let with_alias = encode_payload(&LightningInfoPayload {
uri: format!("{LN_PUBKEY}@1.2.3.4:9735"),
alias: Some("archy".into()),
})
.unwrap();
assert!(
encoded.len() < with_alias.len(),
"an absent alias must cost fewer bytes on air, not the same"
);
}
#[test]
fn valid_lightning_uris_are_accepted() {
assert!(is_valid_lightning_uri(&format!("{LN_PUBKEY}@1.2.3.4:9735")));
assert!(is_valid_lightning_uri(&format!("{LN_PUBKEY}@example.com:9735")));
// The :port suffix is optional.
assert!(is_valid_lightning_uri(&format!("{LN_PUBKEY}@1.2.3.4")));
assert!(is_valid_lightning_uri(&format!("{LN_PUBKEY}@abcdefghij.onion:9735")));
}
#[test]
fn malformed_lightning_uris_are_rejected() {
let bad = [
"".to_string(),
"no-at-sign".to_string(),
format!("{LN_PUBKEY}"), // no host at all
format!("{LN_PUBKEY}@"), // empty host
format!("@1.2.3.4:9735"), // no pubkey
format!("{}@1.2.3.4:9735", &LN_PUBKEY[..65]), // pubkey too short
format!("{}z@1.2.3.4:9735", &LN_PUBKEY[..65]), // pubkey not hex
format!("{LN_PUBKEY}@1.2.3.4:99999"), // port out of u16 range
format!("{LN_PUBKEY}@1.2.3.4:"), // empty port
format!("{LN_PUBKEY}@1.2.3.4:http"), // non-numeric port
format!("{LN_PUBKEY}@host with spaces:9735"),
format!("{LN_PUBKEY}@a@b:9735"), // two '@'
];
for uri in bad {
assert!(
!is_valid_lightning_uri(&uri),
"must reject malformed URI {uri:?}"
);
}
}
}
+4
View File
@@ -260,6 +260,9 @@ pub(crate) async fn upsert_federation_peer(
pkc_capable: existing.as_ref().map(|p| p.pkc_capable).unwrap_or(false),
lat: existing.as_ref().and_then(|p| p.lat),
lon: existing.as_ref().and_then(|p| p.lon),
// Federation seeding carries no Lightning datum today; keep whatever an
// explicit advert already established for this peer.
lightning_uri: existing.as_ref().and_then(|p| p.lightning_uri.clone()),
};
peers.insert(contact_id, peer);
// A radio twin of this node (same advert_name, no arch identity yet) can now
@@ -2416,6 +2419,7 @@ mod tests {
pkc_capable: false,
lat: None,
lon: None,
lightning_uri: None,
}
}
+15
View File
@@ -111,6 +111,20 @@ pub struct MeshPeer {
pub lat: Option<f64>,
#[serde(default)]
pub lon: Option<f64>,
/// This peer's advertised Lightning connection URI (`pubkey@host:port`).
///
/// Set ONLY from a received `LightningInfo` advertisement (or federation
/// seeding in a later plan) — never inferred, never defaulted. `None` means
/// "this peer has not told us it runs Lightning", which is what the
/// channel-open picker uses to decide whether to offer it as a request
/// target at all.
///
/// The advertisement is unauthenticated RF input, so this is a *request*
/// target the operator chooses to act on, not a trusted identity. It is
/// stored against the peer's authenticating key (`identity_pubkey_hex()`),
/// never the firmware routing key (T-01-11).
#[serde(default)]
pub lightning_uri: Option<String>,
}
impl MeshPeer {
@@ -294,6 +308,7 @@ mod tests {
pkc_capable: false,
lat: None,
lon: None,
lightning_uri: None,
}
}