diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f37308..f812524 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ All notable changes to Mosh are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.2.2] - 2026-06-02 + +### Fixed +- **Stable Moss node identity across restarts.** The Moss transport identity + (libp2p key) was regenerated on every launch because the host never wired + Moss's keystore, so after a restart a peer saw a brand-new peer-id and the + connection flapped (rapid `peer_joined`/`peer_left`) instead of + re-establishing. The identity is now persisted in the encrypted store + (AES-256-GCM) and reused on restart. + ## [0.2.1] - 2026-06-02 ### Fixed diff --git a/package-lock.json b/package-lock.json index 4ad07b0..3969427 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "mosh", - "version": "0.2.1", + "version": "0.2.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mosh", - "version": "0.2.1", + "version": "0.2.2", "dependencies": { "@tabler/icons-react": "^3.41.1", "@tauri-apps/api": "^2", diff --git a/package.json b/package.json index 47eb084..1a64210 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "mosh", "private": true, - "version": "0.2.1", + "version": "0.2.2", "type": "module", "scripts": { "dev": "vite", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 4597326..0fa7d13 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3572,7 +3572,7 @@ dependencies = [ [[package]] name = "mosh" -version = "0.2.1" +version = "0.2.2" dependencies = [ "aes-gcm", "base64 0.22.1", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index d9fe2fb..78c688a 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mosh" -version = "0.2.1" +version = "0.2.2" description = "Desktop-first decentralized messenger" authors = ["Mosh contributors"] edition = "2021" diff --git a/src-tauri/src/adapters/moss_ffi.rs b/src-tauri/src/adapters/moss_ffi.rs index b0b4b92..5db6843 100644 --- a/src-tauri/src/adapters/moss_ffi.rs +++ b/src-tauri/src/adapters/moss_ffi.rs @@ -62,12 +62,35 @@ type MossGetMeshInfo = unsafe extern "C" fn(MossHandle) -> *mut c_char; type MossGetNatType = unsafe extern "C" fn(MossHandle) -> *mut c_char; type MossGetPublicKey = unsafe extern "C" fn(MossHandle) -> *mut u8; type MossFree = unsafe extern "C" fn(*mut c_void); +// Keystore callbacks let Moss persist its node identity through the host. +// Load is probed first with a null buffer to learn the size, then called again +// with a buffer of that capacity; it returns the number of bytes written. +type KeyStoreLoadCallback = unsafe extern "C" fn(*mut u8, u32) -> u32; +type KeyStoreSaveCallback = unsafe extern "C" fn(*const u8, u32); +type MossSetKeyStore = + unsafe extern "C" fn(Option, Option) -> i32; const EVENT_RING_CAPACITY: usize = 64; static RECEIVED_MESSAGES: Mutex> = Mutex::new(Vec::new()); static EVENT_LOG: Mutex> = Mutex::new(Vec::new()); +/// Backing store for the Moss node identity. Implemented by the encrypted +/// persistence layer; held in a process global because the C keystore callbacks +/// are context-free function pointers. +pub trait MossKeyStore: Send + Sync { + fn load_identity(&self) -> Option>; + fn save_identity(&self, bytes: &[u8]); +} + +static MOSS_KEYSTORE: Mutex>> = Mutex::new(None); + +/// Register the persistent backing store for the Moss node identity. Call once +/// (before any node is started) and then `MossFfiRuntime::install_keystore`. +pub fn set_moss_keystore(store: Arc) { + *MOSS_KEYSTORE.lock().expect("moss keystore lock poisoned") = Some(store); +} + #[cfg(test)] pub static MOSS_TEST_LOCK: Mutex<()> = Mutex::new(()); @@ -127,6 +150,7 @@ pub struct MossFfiRuntime { get_nat_type: MossGetNatType, get_public_key: MossGetPublicKey, free: MossFree, + set_key_store: MossSetKeyStore, } pub struct MossNode { @@ -182,10 +206,21 @@ impl MossFfiRuntime { get_nat_type: load_symbol(&library, b"Moss_GetNATType\0")?, get_public_key: load_symbol(&library, b"Moss_GetPublicKey\0")?, free: load_symbol(&library, b"Moss_Free\0")?, + set_key_store: load_symbol(&library, b"Moss_SetKeyStore\0")?, _library: ManuallyDrop::new(library), }) } + /// Wire Moss's identity keystore to the registered host store + /// ([`set_moss_keystore`]). Global in Moss, so call once after load and + /// before starting any node; afterwards the node identity persists across + /// restarts instead of being regenerated each launch. + pub fn install_keystore(&self) -> Result<(), MossFfiError> { + check_code("set_key_store", unsafe { + (self.set_key_store)(Some(keystore_load), Some(keystore_save)) + }) + } + pub fn init_node( self: &Arc, mesh_id: &str, @@ -486,6 +521,41 @@ unsafe extern "C" fn on_moss_message( .push(MossReceivedMessage { channel, payload }); } +/// Moss identity load callback. A probe call (null buffer / zero capacity) +/// returns the stored size; the real call copies up to `capacity` bytes and +/// returns the number written. Returns 0 when nothing is stored. +unsafe extern "C" fn keystore_load(buffer: *mut u8, capacity: u32) -> u32 { + let guard = MOSS_KEYSTORE.lock().expect("moss keystore lock poisoned"); + let Some(store) = guard.as_ref() else { + return 0; + }; + let Some(bytes) = store.load_identity() else { + return 0; + }; + let len = bytes.len() as u32; + if buffer.is_null() || capacity == 0 { + return len; // size probe + } + if capacity < len { + return 0; // buffer too small; Moss probes first, so this is defensive + } + unsafe { std::ptr::copy_nonoverlapping(bytes.as_ptr(), buffer, bytes.len()) }; + len +} + +/// Moss identity save callback. Persists the encoded identity bytes through the +/// registered host store. +unsafe extern "C" fn keystore_save(data: *const u8, len: u32) { + if data.is_null() || len == 0 { + return; + } + let bytes = unsafe { std::slice::from_raw_parts(data, len as usize) }.to_vec(); + let guard = MOSS_KEYSTORE.lock().expect("moss keystore lock poisoned"); + if let Some(store) = guard.as_ref() { + store.save_identity(&bytes); + } +} + unsafe extern "C" fn on_moss_event(event_type: i32, detail_json: *const c_char) { let detail = if detail_json.is_null() { String::new() @@ -515,6 +585,40 @@ unsafe extern "C" fn on_moss_event(event_type: i32, detail_json: *const c_char) mod tests { use super::*; + // Exercises the probe-then-copy keystore protocol against a mock store, + // without loading the Moss library. Process-isolated under nextest, so + // mutating the MOSS_KEYSTORE global here does not leak into other tests. + #[test] + fn keystore_callbacks_round_trip_identity() { + struct MemStore(Mutex>>); + impl MossKeyStore for MemStore { + fn load_identity(&self) -> Option> { + self.0.lock().unwrap().clone() + } + fn save_identity(&self, bytes: &[u8]) { + *self.0.lock().unwrap() = Some(bytes.to_vec()); + } + } + + set_moss_keystore(Arc::new(MemStore(Mutex::new(None)))); + + // Nothing stored yet: probe returns 0. + assert_eq!(unsafe { keystore_load(std::ptr::null_mut(), 0) }, 0); + + let identity = [7u8; 40]; + unsafe { keystore_save(identity.as_ptr(), identity.len() as u32) }; + + // Probe reports the stored size. + let size = unsafe { keystore_load(std::ptr::null_mut(), 0) }; + assert_eq!(size, identity.len() as u32); + + // Real read copies the bytes into the buffer. + let mut buffer = vec![0u8; size as usize]; + let read = unsafe { keystore_load(buffer.as_mut_ptr(), buffer.len() as u32) }; + assert_eq!(read, identity.len() as u32); + assert_eq!(buffer, identity); + } + const TEST_MESH: &str = "mosh-runtime-smoke"; const TEST_CHANNEL: &str = "mls-control"; const TEST_PAYLOAD: &[u8] = b"mosh-runtime-payload"; diff --git a/src-tauri/src/adapters/persistence.rs b/src-tauri/src/adapters/persistence.rs index a253833..e796c79 100644 --- a/src-tauri/src/adapters/persistence.rs +++ b/src-tauri/src/adapters/persistence.rs @@ -61,6 +61,9 @@ const DEK_KEY: &str = "history-dek-v1"; const MLS_SNAPSHOT: TableDefinition<&str, &[u8]> = TableDefinition::new("mls_snapshot"); const MESSAGES: TableDefinition<&str, &[u8]> = TableDefinition::new("messages"); const SESSIONS: TableDefinition<&str, &[u8]> = TableDefinition::new("sessions"); +const MOSS_IDENTITY: TableDefinition<&str, &[u8]> = TableDefinition::new("moss_identity"); +// Single-row table: the device's stable Moss transport identity (libp2p key). +const MOSS_IDENTITY_KEY: &str = "node-identity-v1"; pub struct Persistence { db: Database, @@ -113,6 +116,8 @@ impl Persistence { .map_err(|e| PersistenceError::Db(e.to_string()))?; wtx.open_table(SESSIONS) .map_err(|e| PersistenceError::Db(e.to_string()))?; + wtx.open_table(MOSS_IDENTITY) + .map_err(|e| PersistenceError::Db(e.to_string()))?; } wtx.commit() .map_err(|e| PersistenceError::Db(e.to_string()))?; @@ -233,6 +238,35 @@ impl Persistence { pub fn list_messages(&self, conversation_id: &str) -> Result>, PersistenceError> { self.range_prefix(MESSAGES, conversation_id) } + + /// The device's stable Moss transport identity (encrypted like everything + /// else). Persisting it keeps the node's peer-id constant across restarts, + /// which is required for peers to re-establish a connection instead of + /// flapping. + pub fn put_moss_identity(&self, raw: &[u8]) -> Result<(), PersistenceError> { + self.put(MOSS_IDENTITY, MOSS_IDENTITY_KEY, raw) + } + pub fn get_moss_identity(&self) -> Result>, PersistenceError> { + self.get(MOSS_IDENTITY, MOSS_IDENTITY_KEY) + } +} + +impl crate::adapters::moss_ffi::MossKeyStore for Persistence { + fn load_identity(&self) -> Option> { + match self.get_moss_identity() { + Ok(value) => value, + Err(e) => { + eprintln!("moss identity load failed: {e}"); + None + } + } + } + + fn save_identity(&self, bytes: &[u8]) { + if let Err(e) = self.put_moss_identity(bytes) { + eprintln!("moss identity save failed: {e}"); + } + } } #[cfg(test)] @@ -249,6 +283,8 @@ impl Persistence { .map_err(|e| PersistenceError::Db(e.to_string()))?; wtx.open_table(SESSIONS) .map_err(|e| PersistenceError::Db(e.to_string()))?; + wtx.open_table(MOSS_IDENTITY) + .map_err(|e| PersistenceError::Db(e.to_string()))?; } wtx.commit() .map_err(|e| PersistenceError::Db(e.to_string()))?; @@ -277,6 +313,20 @@ mod tests { assert!(decrypt_blob(&dek, &blob).is_err()); } + #[test] + fn moss_identity_round_trips() { + let path = std::env::temp_dir().join(format!("mosh-moss-id-{}.redb", std::process::id())); + let _ = std::fs::remove_file(&path); + let p = Persistence::open_with_dek(&path, [3u8; 32]).unwrap(); + + assert!(p.get_moss_identity().unwrap().is_none()); + let identity = vec![9u8; 129]; + p.put_moss_identity(&identity).unwrap(); + assert_eq!(p.get_moss_identity().unwrap(), Some(identity)); + + let _ = std::fs::remove_file(&path); + } + #[test] fn messages_round_trip_in_time_order() { let dir = std::env::temp_dir().join(format!("mosh-test-{}", std::process::id())); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5360bac..0d1b95a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -60,6 +60,16 @@ impl PrivateDmState { attachment_store: Arc, persistence: Option>, ) -> Self { + // Persist the Moss node identity so its peer-id stays stable across + // restarts; without this Moss mints a fresh identity each launch and + // peers flap on reconnect. The keystore is global to the Moss library + // and must be installed before any node starts (including rehydrate). + if let Some(store) = persistence.clone() { + adapters::moss_ffi::set_moss_keystore(store); + if let Err(error) = moss.install_keystore() { + eprintln!("moss keystore install failed: {error}"); + } + } let mut runtime = PrivateDmRuntime::from_shared(moss, attachment_store, persistence); runtime.rehydrate(); Self { diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 5d68a97..c8dcc15 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Mosh", - "version": "0.2.1", + "version": "0.2.2", "identifier": "app.mosh.desktop", "build": { "beforeDevCommand": "npm run moss:prepare && npm run dev",