diff --git a/docs/reticulum-sidecar-ipc.md b/docs/reticulum-sidecar-ipc.md index b081bf9ef..3cc515e3b 100644 --- a/docs/reticulum-sidecar-ipc.md +++ b/docs/reticulum-sidecar-ipc.md @@ -17,15 +17,16 @@ Electron main validates proxy paths: must start with `/api/v1/` (no `..` segment ### Identity -| Method | Path | Body / notes | Response | -| ------ | --------------------------------- | -------------------------------------------------- | --------------------------------------------------------- | -| GET | `/api/v1/identity/status` | | `{ configured, identity_hash, lxmf_hash, display_name? }` | -| POST | `/api/v1/identity/generate` | `{ display_name?, replace? }` | `{ ok, mnemonic?, identity_hash, lxmf_hash }` | -| POST | `/api/v1/identity/import` | `{ mnemonic, display_name?, replace? }` | `{ ok, identity_hash, lxmf_hash }` | -| POST | `/api/v1/identity/import-backup` | `{ backup, passphrase?, display_name?, replace? }` | `{ ok, identity_hash, lxmf_hash, metadata_only? }` | -| POST | `/api/v1/identity/import-private` | `{ private_key, display_name?, replace? }` | `{ ok, identity_hash, lxmf_hash }` | -| POST | `/api/v1/identity/export` | `{ passphrase }` | `{ ok, backup? }` | -| POST | `/api/v1/identity/display-name` | `{ display_name }` | `{ ok }` | +| Method | Path | Body / notes | Response | +| ------ | --------------------------------- | -------------------------------------------------- | ---------------------------------------------------------------------- | +| GET | `/api/v1/identity/status` | | `{ configured, identity_hash, lxmf_hash, display_name?, public_key? }` | +| POST | `/api/v1/identity/register-known` | `{ destination_hash, public_key }` | `{ ok }` (registers 64-byte pubkey for Direct LXMF / Columba QR) | +| POST | `/api/v1/identity/generate` | `{ display_name?, replace? }` | `{ ok, mnemonic?, identity_hash, lxmf_hash }` | +| POST | `/api/v1/identity/import` | `{ mnemonic, display_name?, replace? }` | `{ ok, identity_hash, lxmf_hash }` | +| POST | `/api/v1/identity/import-backup` | `{ backup, passphrase?, display_name?, replace? }` | `{ ok, identity_hash, lxmf_hash, metadata_only? }` | +| POST | `/api/v1/identity/import-private` | `{ private_key, display_name?, replace? }` | `{ ok, identity_hash, lxmf_hash }` | +| POST | `/api/v1/identity/export` | `{ passphrase }` | `{ ok, backup? }` | +| POST | `/api/v1/identity/display-name` | `{ display_name }` | `{ ok }` | ### Interfaces @@ -97,14 +98,14 @@ Routing bias between **RF** (LoRa / RNode) and **network** (TCP/UDP/I2P/gateway/ ### Peers, topology, and propagation -| Method | Path | Body / notes | Response | -| ------ | ---------------------------- | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| GET | `/api/v1/peers` | `?refresh=1` optional | `{ peers: [] }` — live path table when `rns-stack` enabled; without `refresh=1` may serve a short-TTL maintenance cache; `refresh=1`/`true` forces live `GetPathTable` (manual Refresh). `display_name` overlayed from contacts/Nomad/announce label cache | -| POST | `/api/v1/peers/{hash}/path` | | `{ ok }` — emits `peers_updated` WS on success | -| POST | `/api/v1/peers/{hash}/probe` | | `{ ok, hops? }` live; `{ ok, mode, hash }` stub — emits `peers_updated` on success | -| POST | `/api/v1/ping` | `{ destination_hash }` | `{ ok, rtt_ms? }` | -| GET | `/api/v1/topology` | | `{ nodes, edges, total?, shown?, truncated? }` — `via_hash` is the immediate RNS next hop (transport id); sidecar infers `self → relay` when needed | -| GET | `/api/v1/rmap/discovered` | | `{ discovered: RmapDiscoveredWireRow[] }` — local RMAP v4 heard interfaces (7-day TTL eviction in rsReticulum DiscoveryStore) | +| Method | Path | Body / notes | Response | +| ------ | ---------------------------- | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| GET | `/api/v1/peers` | `?refresh=1` optional | `{ peers: [] }` — live path table when `rns-stack` enabled; without `refresh=1` may serve a short-TTL maintenance cache; `refresh=1`/`true` forces live `GetPathTable` (manual Refresh). `display_name` overlayed from contacts/Nomad/announce label cache; optional `public_key` (128 hex) when known from announces | +| POST | `/api/v1/peers/{hash}/path` | | `{ ok }` — emits `peers_updated` WS on success | +| POST | `/api/v1/peers/{hash}/probe` | | `{ ok, hops? }` live; `{ ok, mode, hash }` stub — emits `peers_updated` on success | +| POST | `/api/v1/ping` | `{ destination_hash }` | `{ ok, rtt_ms? }` | +| GET | `/api/v1/topology` | | `{ nodes, edges, total?, shown?, truncated? }` — `via_hash` is the immediate RNS next hop (transport id); sidecar infers `self → relay` when needed | +| GET | `/api/v1/rmap/discovered` | | `{ discovered: RmapDiscoveredWireRow[] }` — local RMAP v4 heard interfaces (7-day TTL eviction in rsReticulum DiscoveryStore) | **`RmapDiscoveredWireRow` fields** (see `src/shared/reticulum-types.ts`): `discovery_hash`, `transport_id`, `discovery_name`, `interface_type`, `latitude`, `longitude`, `height`, `transport_enabled`, `reachable_on`, LoRa RF fields (`frequency`, `bandwidth`, `spreading_factor`, …), `hops`, `stamp_value`, `discovered`, `last_heard`, `heard_count`, `status` (`available`/`stale`/`unknown`), `has_coordinates`. Renderer caps at 2,000 newest rows with client-side TTL eviction. diff --git a/docs/reticulum.md b/docs/reticulum.md index 475eb910e..d92a75efa 100644 --- a/docs/reticulum.md +++ b/docs/reticulum.md @@ -262,7 +262,7 @@ When multiple enabled local RNode interfaces are connected, the interface list s - **Identity:** generate BIP-39 recovery phrase, import **private key** (paste or file picker via `reticulum:showIdentityImportDialog`), import **backup JSON**, export with passphrase, display name; **replace identity** confirm when keys already exist (`replace: true` on generate/import) - **Identity slots:** Network panel lists local slots (`GET /api/v1/identities`), create / switch / delete (`POST /api/v1/identities`, `/switch`, `/delete`). Create/switch are serialized and commit the active pointer only after the working key is applied; the sidecar restarts after a successful change. Soft cap **16** slots; display names are sanitized (control chars rejected, max 128 chars). -- **Identity / contact QR:** share via `QrCodeImage` (`lxm://identity/…`, `lxm://contact/…`); ingest via paste/file/camera (`QrIngestControl`). OS deep links use the registered **`lxm://`** scheme (`electron-builder.yml`); external contact imports require confirmation (`MeshClientDeepLinkHost`). Encrypted LXMF paper messages are not supported yet. +- **Identity / contact QR:** share via `QrCodeImage` — Columba-compatible **`lxma://:`** when the sidecar reports a public key (identity Network QR and peer detail when known); otherwise mesh-client **`lxm://identity/…`** / **`lxm://contact/…`**. Ingest via paste/file/camera (`QrIngestControl`) including `lxma://` (registers pubkey + saved contact). OS deep links use the registered **`lxm://`** scheme (`electron-builder.yml`); `lxma://` / `meshcore://` are handled when opened/pasted (not OS-registered). External contact imports require confirmation (`MeshClientDeepLinkHost`). Encrypted LXMF paper messages are not supported yet. - **Peer fingerprint verification:** Peer detail can mark a contact verified (pins `verified_identity_hash` + `verified_at` in SQLite via `db:setReticulumDestinationVerified`) and warns on mismatch when the live announce hash drifts. - **Header self label:** when configured, the app header shows your Network **display name** (`reticulumSelfNodeLabel.ts`) — not a hash-prefix stub; omit the `Node:` label when no real name is set - **Identity vault:** optional passcode (minimum 8 characters) to encrypt secrets in the main process; unlock is rate-limited diff --git a/reticulum-sidecar/src/api/identity.rs b/reticulum-sidecar/src/api/identity.rs index 364be2252..2bf35d39a 100644 --- a/reticulum-sidecar/src/api/identity.rs +++ b/reticulum-sidecar/src/api/identity.rs @@ -51,12 +51,34 @@ pub struct DisplayNameBody { pub async fn identity_status(State(stack): State>) -> Json { let id = stack.identity_status().await; - Json(serde_json::json!({ + let public_key = stack.identity_public_key_hex().await; + let mut body = serde_json::json!({ "configured": id.configured, "identity_hash": id.identity_hash, "lxmf_hash": id.lxmf_hash, "display_name": id.display_name, - })) + }); + if let Some(pk) = public_key { + body["public_key"] = serde_json::Value::String(pk); + } + Json(body) +} + +#[derive(Deserialize)] +pub struct RegisterKnownBody { + pub destination_hash: String, + pub public_key: String, +} + +/// Register a peer LXMF destination public key (Columba `lxma://` import). +pub async fn identity_register_known( + State(stack): State>, + Json(body): Json, +) -> Json { + match stack.register_known_identity(&body.destination_hash, &body.public_key) { + Ok(()) => Json(serde_json::json!({ "ok": true })), + Err(e) => Json(serde_json::json!({ "ok": false, "error": e })), + } } /// Generate a new identity. The response includes the mnemonic **once** so the diff --git a/reticulum-sidecar/src/api/mod.rs b/reticulum-sidecar/src/api/mod.rs index c777ffb51..f53da1483 100644 --- a/reticulum-sidecar/src/api/mod.rs +++ b/reticulum-sidecar/src/api/mod.rs @@ -32,6 +32,10 @@ pub fn router(stack: Arc) -> Router { .route("/api/v1/status", get(status::status)) .route("/api/v1/app/info", get(status::app_info)) .route("/api/v1/identity/status", get(identity::identity_status)) + .route( + "/api/v1/identity/register-known", + post(identity::identity_register_known), + ) .route( "/api/v1/identity/generate", post(identity::identity_generate), diff --git a/reticulum-sidecar/src/stack/identity_apply.rs b/reticulum-sidecar/src/stack/identity_apply.rs index 1d04510dc..bd47afac6 100644 --- a/reticulum-sidecar/src/stack/identity_apply.rs +++ b/reticulum-sidecar/src/stack/identity_apply.rs @@ -258,6 +258,19 @@ mod tests { assert_eq!(state.identity.lxmf_hash, expected.lxmf_hash); } + #[test] + fn applied_identity_exposes_64_byte_public_key() { + let (_root, config_dir, storage_dir) = temp_dirs(); + let (identity, _) = generate_identity_with_mnemonic().unwrap(); + let expected = hex::encode(identity.get_public_key()); + assert_eq!(expected.len(), 128); + let mut state = PersistedState::default_empty(); + apply_unified_identity(&mut state, &config_dir, &storage_dir, &identity, None, None) + .unwrap(); + let loaded = load_identity_from_file(&config_dir).unwrap(); + assert_eq!(hex::encode(loaded.get_public_key()), expected); + } + #[test] fn reconcile_fixes_stale_json() { let (_root, config_dir, storage_dir) = temp_dirs(); diff --git a/reticulum-sidecar/src/stack/live.rs b/reticulum-sidecar/src/stack/live.rs index 70b2d9514..8b7f7ce63 100644 --- a/reticulum-sidecar/src/stack/live.rs +++ b/reticulum-sidecar/src/stack/live.rs @@ -2300,11 +2300,26 @@ impl LiveBridge { .ok() .map(|c| c.clone()) .unwrap_or_default(); + let pubkey_lookup = outbound + .lock() + .ok() + .map(|d| { + entries + .iter() + .filter_map(|e| { + let destination_hash = hex::encode(e.hash); + d.public_key_for(&destination_hash) + .map(|pk| (destination_hash, hex::encode(pk))) + }) + .collect::>() + }) + .unwrap_or_default(); let peer_rows: Vec = entries .iter() .map(|e| { let destination_hash = hex::encode(e.hash); let display_name = name_lookup.get(&destination_hash).cloned(); + let public_key = pubkey_lookup.get(&destination_hash).cloned(); PeerRow { destination_hash, display_name, @@ -2313,6 +2328,7 @@ impl LiveBridge { interface: Some(e.interface.clone()), path_hash: e.via.map(hex::encode), via_hash: e.via.map(hex::encode), + public_key, } }) .collect(); @@ -2356,6 +2372,7 @@ impl LiveBridge { "interface": p.interface, "path_hash": p.path_hash, "via_hash": p.via_hash, + "public_key": p.public_key, }) }) .collect(); @@ -3359,6 +3376,25 @@ impl LiveBridge { .unwrap_or_default() } + /// Local identity public key as 128 lowercase hex (X25519 ∥ Ed25519). + pub fn identity_public_key_hex(&self) -> String { + hex::encode(self.identity.get_public_key()) + } + + /// Register a peer destination public key for Direct LXMF / Columba QR import. + pub fn register_known_identity( + &self, + destination_hash: &str, + public_key: [u8; 64], + ) -> Result<(), String> { + let mut driver = self + .outbound + .lock() + .map_err(|_| "outbound driver lock poisoned".to_string())?; + driver.register_identity_key(destination_hash, public_key); + Ok(()) + } + /// Fetch path-table peers. When `force` is false and the maintenance cache is /// fresher than [`PATH_PEER_CACHE_TTL`], return that snapshot (avoids a second /// GetPathTable on every automatic poll). @@ -3392,11 +3428,28 @@ impl LiveBridge { .ok() .map(|c| c.clone()) .unwrap_or_default(); + let pubkey_lookup = self + .outbound + .lock() + .ok() + .map(|d| { + // Collect known keys once per fetch to avoid locking per peer. + entries + .iter() + .filter_map(|e| { + let destination_hash = hex::encode(e.hash); + d.public_key_for(&destination_hash) + .map(|pk| (destination_hash, hex::encode(pk))) + }) + .collect::>() + }) + .unwrap_or_default(); let peers: Vec = entries .iter() .map(|e| { let destination_hash = hex::encode(e.hash); let display_name = name_lookup.get(&destination_hash).cloned(); + let public_key = pubkey_lookup.get(&destination_hash).cloned(); PeerRow { destination_hash, display_name, @@ -3405,6 +3458,7 @@ impl LiveBridge { interface: Some(e.interface.clone()), path_hash: e.via.map(hex::encode), via_hash: e.via.map(hex::encode), + public_key, } }) .collect(); @@ -4579,6 +4633,7 @@ fn peer_route_fields_equal(a: &PeerRow, b: &PeerRow) -> bool { && a.interface == b.interface && a.path_hash == b.path_hash && a.via_hash == b.via_hash + && a.public_key == b.public_key } /// Pure announce classification for propagation sync targets. @@ -4667,6 +4722,28 @@ mod announce_display_name_tests { assert_eq!(added, vec!["cc".to_string()]); } + #[test] + fn peer_route_fields_equal_includes_public_key() { + let base = PeerRow { + destination_hash: "aa".into(), + display_name: Some("Alice".into()), + hops: Some(1), + last_seen: Some(1), + interface: Some("tcp".into()), + path_hash: Some("bb".into()), + via_hash: Some("cc".into()), + public_key: Some("dd".repeat(64)), + }; + let mut other = base.clone(); + other.last_seen = Some(99); + other.display_name = Some("Bob".into()); + assert!(peer_route_fields_equal(&base, &other)); + other.public_key = Some("ee".repeat(64)); + assert!(!peer_route_fields_equal(&base, &other)); + other.public_key = None; + assert!(!peer_route_fields_equal(&base, &other)); + } + #[test] fn force_path_refresh_rejects_stale_route_until_absent_then_accepts_refresh() { // Existing stale route still installed — must not accept yet. diff --git a/reticulum-sidecar/src/stack/lxmf_outbound.rs b/reticulum-sidecar/src/stack/lxmf_outbound.rs index c827661f7..d243ee7e2 100644 --- a/reticulum-sidecar/src/stack/lxmf_outbound.rs +++ b/reticulum-sidecar/src/stack/lxmf_outbound.rs @@ -1433,6 +1433,18 @@ mod tests { assert!(gate.should_warn(dest(4), 121.0)); } + #[test] + fn register_identity_key_is_retrievable() { + let identity = Identity::new(); + let (tx, _rx) = mpsc::channel(8); + let mut driver = LxmfOutboundDriver::new(tx, &identity, "aabb".repeat(8), "me".into()); + let dest = "0123456789abcdef0123456789abcdef"; + let key = [0x7au8; 64]; + driver.register_identity_key(dest, key); + assert_eq!(driver.public_key_for(dest), Some(key)); + assert_eq!(driver.public_key_for(&dest.to_uppercase()), Some(key)); + } + #[test] fn pin_identity_survives_eviction_flood() { let identity = Identity::new(); diff --git a/reticulum-sidecar/src/stack/mod.rs b/reticulum-sidecar/src/stack/mod.rs index 10c8462f8..74fe7bc81 100644 --- a/reticulum-sidecar/src/stack/mod.rs +++ b/reticulum-sidecar/src/stack/mod.rs @@ -74,6 +74,38 @@ pub use types::{ const NOMAD_REQUIRES_STACK: &str = "Nomad serving requires an rns-stack sidecar build"; const NOMAD_DISPLAY_NAME_MAX_CHARS: usize = 128; +/// Parse Columba register-known inputs and require dest == LXMF delivery hash of the key. +#[cfg(feature = "rns-stack")] +fn validated_known_identity_key( + destination_hash: &str, + public_key_hex: &str, +) -> Result<(String, [u8; 64]), String> { + use rns_identity::destination::Destination; + use rns_identity::identity::Identity; + + let dest = destination_hash.trim().to_lowercase(); + if dest.len() != 32 || !dest.chars().all(|c| c.is_ascii_hexdigit()) { + return Err("invalid_destination_hash".into()); + } + let key_hex = public_key_hex.trim().to_lowercase(); + if key_hex.len() != 128 || !key_hex.chars().all(|c| c.is_ascii_hexdigit()) { + return Err("invalid_public_key".into()); + } + let bytes = hex::decode(&key_hex).map_err(|_| "invalid_public_key".to_string())?; + let key: [u8; 64] = bytes + .try_into() + .map_err(|_| "invalid_public_key".to_string())?; + let identity = Identity::from_public_key(&key).map_err(|_| "invalid_public_key".to_string())?; + let expected = hex::encode(Destination::hash_from_name_and_identity( + identity_apply::LXMF_APP_NAME, + Some(&identity.hash), + )); + if expected != dest { + return Err("destination_mismatch".into()); + } + Ok((dest, key)) +} + /// Trim, reject control characters, and cap length for announce/UI display names. fn sanitize_nomad_display_name(name: &str) -> Result { let trimmed = name.trim(); @@ -453,6 +485,60 @@ impl StackHandle { } } + /// Local identity public key as 128 lowercase hex when identity is configured. + /// + /// Matches [`Self::identity_status`]: prefer the on-disk identity when its hash + /// matches status (covers replace-before-live-restart), else the live bridge + /// only when its hash matches status. + pub async fn identity_public_key_hex(&self) -> Option { + let status = self.inner.read().await.identity.clone(); + if !status.configured { + return None; + } + #[cfg(feature = "rns-stack")] + { + if let Ok(id) = identity_apply::load_identity_from_file(&self.config_dir) { + let file_hash = hex::encode(id.hash); + if file_hash.eq_ignore_ascii_case(&status.identity_hash) { + return Some(hex::encode(id.get_public_key())); + } + } + if let Some(live) = &self.live { + if live + .identity_hash_hex() + .eq_ignore_ascii_case(&status.identity_hash) + { + return Some(live.identity_public_key_hex()); + } + } + None + } + #[cfg(not(feature = "rns-stack"))] + { + None + } + } + + /// Register a destination hash + 64-byte public key for Direct LXMF (Columba QR). + pub fn register_known_identity( + &self, + destination_hash: &str, + public_key_hex: &str, + ) -> Result<(), String> { + #[cfg(feature = "rns-stack")] + { + let (dest, key) = validated_known_identity_key(destination_hash, public_key_hex)?; + let live = self.require_live()?; + live.register_known_identity(&dest, key)?; + Ok(()) + } + #[cfg(not(feature = "rns-stack"))] + { + let _ = (destination_hash, public_key_hex); + Err("identity operations require an rns-stack sidecar build".into()) + } + } + async fn ensure_identity_replace_allowed(&self, replace: bool) -> Result<(), String> { let configured = self.inner.read().await.identity.configured; if configured && !replace { @@ -2888,6 +2974,7 @@ mod tests { interface: None, path_hash: None, via_hash: None, + public_key: None, }]; let empty = merge_live_peer_fetch(&mut cache, Ok(vec![])); assert_eq!(empty.len(), 1); @@ -2909,6 +2996,7 @@ mod tests { interface: Some("tcp".into()), path_hash: None, via_hash: None, + public_key: None, }; let fetched = merge_live_peer_fetch(&mut cache, Ok(vec![row.clone()])); assert_eq!(fetched.len(), 1); @@ -2926,6 +3014,7 @@ mod tests { interface: None, path_hash: None, via_hash: None, + public_key: None, }]; let fetched = sync_live_peer_cache(&mut cache, vec![]); assert!(fetched.is_empty()); @@ -2942,6 +3031,7 @@ mod tests { interface: None, path_hash: None, via_hash: None, + public_key: None, }]; let fetched = sync_live_peer_cache( &mut cache, @@ -2953,6 +3043,7 @@ mod tests { interface: Some("tcp".into()), path_hash: None, via_hash: None, + public_key: None, }], ); assert_eq!(fetched.len(), 1); @@ -2971,6 +3062,7 @@ mod tests { interface: Some("tcp".into()), path_hash: None, via_hash: None, + public_key: None, }; let fetched = sync_live_peer_cache(&mut cache, vec![row.clone()]); assert_eq!(fetched.len(), 1); @@ -3022,6 +3114,7 @@ mod tests { interface: None, path_hash: None, via_hash: None, + public_key: None, }]; let live = PeerRow { destination_hash: "ccdd02".into(), @@ -3031,6 +3124,7 @@ mod tests { interface: Some("tcp".into()), path_hash: None, via_hash: None, + public_key: None, }; let merged = sync_live_peer_cache(&mut cache, vec![live]); assert_eq!(merged.len(), 2); @@ -3050,6 +3144,7 @@ mod tests { interface: None, path_hash: None, via_hash: None, + public_key: None, }) .collect(); // One orphan older than TTL must be dropped even if under the count cap. @@ -3061,6 +3156,7 @@ mod tests { interface: None, path_hash: None, via_hash: None, + public_key: None, }); let live = PeerRow { destination_hash: "aa".repeat(16), @@ -3070,6 +3166,7 @@ mod tests { interface: Some("tcp".into()), path_hash: None, via_hash: None, + public_key: None, }; let merged = sync_live_peer_cache(&mut cache, vec![live]); assert!(merged.iter().any(|p| p.destination_hash == "aa".repeat(16))); @@ -3316,4 +3413,77 @@ mod tests { let _ = std::fs::remove_dir_all(config_dir); let _ = std::fs::remove_dir_all(storage_dir); } + + #[cfg(feature = "rns-stack")] + #[tokio::test] + async fn identity_public_key_hex_matches_status_after_replace_before_live_restart() { + let (config_dir, storage_dir) = temp_stack_dirs(); + let (tx, _) = broadcast::channel(8); + let handle = Box::pin(StackHandle::bootstrap( + config_dir.clone(), + storage_dir.clone(), + tx, + )) + .await; + let first = handle + .identity_generate(None, false) + .await + .expect("generate first identity"); + let first_key = handle + .identity_public_key_hex() + .await + .expect("first public key"); + assert_eq!(first_key.len(), 128); + + let second = handle + .identity_generate(None, true) + .await + .expect("replace identity"); + assert_ne!(first.identity_hash, second.identity_hash); + // Live bridge is not restarted in-process (only stack_restart_requested is emitted). + let status = handle.identity_status().await; + assert_eq!(status.identity_hash, second.identity_hash); + let key = handle + .identity_public_key_hex() + .await + .expect("public key after replace"); + let file_id = + identity_apply::load_identity_from_file(&config_dir).expect("load replaced identity"); + assert_eq!(key, hex::encode(file_id.get_public_key())); + assert_eq!(hex::encode(file_id.hash), second.identity_hash); + assert_ne!(key, first_key); + + let _ = std::fs::remove_dir_all(config_dir); + let _ = std::fs::remove_dir_all(storage_dir); + } + + #[cfg(feature = "rns-stack")] + #[test] + fn validated_known_identity_key_accepts_matching_lxmf_dest() { + use rns_identity::destination::Destination; + use rns_identity::identity::Identity; + + let identity = Identity::new(); + let key = identity.get_public_key(); + let dest = hex::encode(Destination::hash_from_name_and_identity( + identity_apply::LXMF_APP_NAME, + Some(&identity.hash), + )); + let (parsed_dest, parsed_key) = + validated_known_identity_key(&dest, &hex::encode(key)).expect("valid pair"); + assert_eq!(parsed_dest, dest); + assert_eq!(parsed_key, key); + } + + #[cfg(feature = "rns-stack")] + #[test] + fn validated_known_identity_key_rejects_mismatched_dest() { + use rns_identity::identity::Identity; + + let identity = Identity::new(); + let key_hex = hex::encode(identity.get_public_key()); + let err = validated_known_identity_key("aa".repeat(16).as_str(), &key_hex) + .expect_err("mismatched dest"); + assert_eq!(err, "destination_mismatch"); + } } diff --git a/reticulum-sidecar/src/stack/persistence.rs b/reticulum-sidecar/src/stack/persistence.rs index 6a62b32d1..dbd75dab1 100644 --- a/reticulum-sidecar/src/stack/persistence.rs +++ b/reticulum-sidecar/src/stack/persistence.rs @@ -639,6 +639,7 @@ impl PersistedState { interface: None, path_hash: None, via_hash: None, + public_key: None, }); } } @@ -946,6 +947,7 @@ mod tests { interface: None, path_hash: None, via_hash: None, + public_key: None, } } diff --git a/reticulum-sidecar/src/stack/topology.rs b/reticulum-sidecar/src/stack/topology.rs index fff9bd81a..53a03d14e 100644 --- a/reticulum-sidecar/src/stack/topology.rs +++ b/reticulum-sidecar/src/stack/topology.rs @@ -70,6 +70,7 @@ pub fn build_topology(peers: &[PeerRow]) -> (Vec, Vec) { interface: peer.interface.clone(), path_hash: None, via_hash: None, + public_key: None, }); } } @@ -265,6 +266,7 @@ mod tests { interface: Some("tcp".into()), path_hash: via.map(str::to_string), via_hash: via.map(str::to_string), + public_key: None, } } @@ -355,6 +357,7 @@ mod tests { interface: None, path_hash: None, via_hash: None, + public_key: None, }]; let mut names = HashMap::new(); names.insert("abc".into(), "Alice".into()); @@ -373,6 +376,7 @@ mod tests { interface: Some("tcp".into()), path_hash: None, via_hash: None, + public_key: None, }, PeerRow { destination_hash: "ccdd".into(), @@ -382,6 +386,7 @@ mod tests { interface: Some("tcp".into()), path_hash: None, via_hash: None, + public_key: None, }, ]; let mut name_by_hash = build_topology_name_map( diff --git a/reticulum-sidecar/src/stack/types.rs b/reticulum-sidecar/src/stack/types.rs index 1b5935eb2..8fa3fa0b9 100644 --- a/reticulum-sidecar/src/stack/types.rs +++ b/reticulum-sidecar/src/stack/types.rs @@ -93,6 +93,9 @@ pub struct PeerRow { pub path_hash: Option, #[serde(default)] pub via_hash: Option, + /// 64-byte X25519+Ed25519 public key as 128 hex chars when known from announces. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub public_key: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/src/renderer/components/NodeDetailModal.tsx b/src/renderer/components/NodeDetailModal.tsx index 2ea81c22c..0b2e68d41 100644 --- a/src/renderer/components/NodeDetailModal.tsx +++ b/src/renderer/components/NodeDetailModal.tsx @@ -13,6 +13,7 @@ import { } from '@/renderer/lib/meshtasticRemoteAdminKeyStorage'; import { getOfflineIdentityIdForProtocol } from '@/renderer/lib/offlineProtocolIdentities'; import { formatIsoDateTime } from '@/shared/formatIsoDate'; +import { buildMeshcoreContactAddUri, type MeshcoreContactType } from '@/shared/meshClientDeepLink'; import { isDeleteActiveMqttIdentityError } from '@/shared/meshtasticDeleteNodeError'; import { formatMeshtasticNodeId } from '@/shared/nodeNameUtils'; @@ -44,6 +45,7 @@ import { MESHCORE_CHAT_STUB_ID_MIN, MESHCORE_CONTACTS_CRITICAL_THRESHOLD, MESHCORE_MAX_CONTACTS, + meshcoreContactTypeFromHwModel, meshcoreTracePathLenToHops, } from '../lib/meshcoreUtils'; import { @@ -71,6 +73,7 @@ import { HelpTooltip } from './HelpTooltip'; import { MeshcoreRepeaterPasswordControls } from './MeshcoreRepeaterPasswordControls'; import { MeshcoreRouteChain } from './MeshcoreRouteChain'; import NodeInfoBody, { formatSecondsAgo } from './NodeInfoBody'; +import QrCodeImage from './QrCodeImage'; import SnrIndicator from './SnrIndicator'; const TRACE_ROUTE_UI_TIMEOUT_MS = 120_000; @@ -330,6 +333,7 @@ export default function NodeDetailModal({ meshcoreNeighborsRef.current = meshcoreNeighbors; const [exportContactPending, setExportContactPending] = useState(false); const [shareContactPending, setShareContactPending] = useState(false); + const [showMeshcoreContactQr, setShowMeshcoreContactQr] = useState(false); const [radioContactCount, setRadioContactCount] = useState(null); const [contactOnRadio, setContactOnRadio] = useState(null); const [addRemoveLoading, setAddRemoveLoading] = useState(false); @@ -419,6 +423,7 @@ export default function NodeDetailModal({ setShowMeshcoreNeighbors(false); setExportContactPending(false); setShareContactPending(false); + setShowMeshcoreContactQr(false); }, [node?.node_id]); // Detect position update after a request was sent (gate on state, not ref — avoids flash on open) @@ -467,6 +472,29 @@ export default function NodeDetailModal({ // Fetch on_radio status and contact count for MeshCore const [contactPubkey, setContactPubkey] = useState(null); + const { + nodeStaleThresholdMs, + nodeOfflineThresholdMs, + protocol: activeProtocol, + } = useRadioProvider(protocol ?? 'meshtastic'); + const isMeshcoreProtocol = activeProtocol === 'meshcore'; + + const meshcoreContactQrUri = useMemo(() => { + if (!isMeshcoreProtocol || !contactPubkey || !node) return null; + const typeRaw = meshcoreContactTypeFromHwModel(node.hw_model ?? 'Chat') ?? 1; + const type = (typeRaw >= 1 && typeRaw <= 4 ? typeRaw : 1) as MeshcoreContactType; + try { + return buildMeshcoreContactAddUri({ + name: node.long_name || node.short_name || `Node-${node.node_id.toString(16)}`, + publicKeyHex: contactPubkey, + type, + }); + } catch { + // catch-no-log-ok Invalid pubkey simply hides the share QR. + return null; + } + }, [isMeshcoreProtocol, contactPubkey, node]); + const ensureRemoteRpcAccess = useCallback( async ( nodeId: number, @@ -571,10 +599,6 @@ export default function NodeDetailModal({ }; }, [traceRoutePending, t]); - const { nodeStaleThresholdMs, nodeOfflineThresholdMs } = useRadioProvider( - protocol ?? 'meshtastic', - ); - if (!node) return null; const hexId = formatMeshtasticNodeId(node.node_id); @@ -2095,6 +2119,27 @@ export default function NodeDetailModal({ : t('nodeDetailModal.shareContact')} )} + {isMeshcoreProtocol && meshcoreContactQrUri ? ( + + ) : null} + {isMeshcoreProtocol && showMeshcoreContactQr && meshcoreContactQrUri ? ( +
+ +
+ ) : null} {protocol === 'meshcore' && contactPubkey && contactOnRadio === false && ( + {ch.secret?.length === 16 ? ( + + ) : null} {confirmDeleteIdx === ch.index ? (