diff --git a/core/archipelago/src/api/rpc/lnd/info.rs b/core/archipelago/src/api/rpc/lnd/info.rs index 1aee5a2a..4f67a7a0 100644 --- a/core/archipelago/src/api/rpc/lnd/info.rs +++ b/core/archipelago/src/api/rpc/lnd/info.rs @@ -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, + /// The connection URIs LND advertises for this node (`pubkey@host:port`). + /// Empty when LND advertises none — an honest absence, not a placeholder. + uris: Vec, } #[derive(Debug, Deserialize)] @@ -24,6 +31,40 @@ struct LndGetInfoResponse { num_peers: Option, synced_to_chain: Option, block_height: Option, + #[serde(default)] + identity_pubkey: Option, + #[serde(default)] + uris: Vec, +} + +/// 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, Vec) { + 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))); + } +} diff --git a/core/archipelago/src/mesh/listener/decode.rs b/core/archipelago/src/mesh/listener/decode.rs index 3687f1f8..ef91638f 100644 --- a/core/archipelago/src/mesh/listener/decode.rs +++ b/core/archipelago/src/mesh/listener/decode.rs @@ -345,6 +345,8 @@ pub(super) async fn resolve_peer(state: &Arc, 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 diff --git a/core/archipelago/src/mesh/listener/dispatch.rs b/core/archipelago/src/mesh/listener/dispatch.rs index a23a81aa..e54f9319 100644 --- a/core/archipelago/src/mesh/listener/dispatch.rs +++ b/core/archipelago/src/mesh/listener/dispatch.rs @@ -485,6 +485,41 @@ pub(crate) async fn handle_typed_envelope_direct( } } + Some(MeshMessageType::LightningInfo) => { + match message_types::decode_payload::(&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::(&envelope.v) { diff --git a/core/archipelago/src/mesh/listener/session.rs b/core/archipelago/src/mesh/listener/session.rs index c6fb1de7..cca4b035 100644 --- a/core/archipelago/src/mesh/listener/session.rs +++ b/core/archipelago/src/mesh/listener/session.rs @@ -828,6 +828,11 @@ async fn refresh_contacts(device: &mut MeshRadioDevice, state: &Arc) // 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); } diff --git a/core/archipelago/src/mesh/message_types.rs b/core/archipelago/src/mesh/message_types.rs index f8bf3063..3a18a4a0 100644 --- a/core/archipelago/src/mesh/message_types.rs +++ b/core/archipelago/src/mesh/message_types.rs @@ -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, +} + +/// 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::().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:?}" + ); + } + } } diff --git a/core/archipelago/src/mesh/mod.rs b/core/archipelago/src/mesh/mod.rs index 5fd1a3a8..0e475c6b 100644 --- a/core/archipelago/src/mesh/mod.rs +++ b/core/archipelago/src/mesh/mod.rs @@ -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, } } diff --git a/core/archipelago/src/mesh/types.rs b/core/archipelago/src/mesh/types.rs index 43f64730..4e774524 100644 --- a/core/archipelago/src/mesh/types.rs +++ b/core/archipelago/src/mesh/types.rs @@ -111,6 +111,20 @@ pub struct MeshPeer { pub lat: Option, #[serde(default)] pub lon: Option, + /// 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, } impl MeshPeer { @@ -294,6 +308,7 @@ mod tests { pkc_capable: false, lat: None, lon: None, + lightning_uri: None, } }