Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "mosh",
"private": true,
"version": "0.2.1",
"version": "0.2.2",
"type": "module",
"scripts": {
"dev": "vite",
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
104 changes: 104 additions & 0 deletions src-tauri/src/adapters/moss_ffi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<KeyStoreLoadCallback>, Option<KeyStoreSaveCallback>) -> i32;

const EVENT_RING_CAPACITY: usize = 64;

static RECEIVED_MESSAGES: Mutex<Vec<MossReceivedMessage>> = Mutex::new(Vec::new());
static EVENT_LOG: Mutex<Vec<MossEvent>> = 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<Vec<u8>>;
fn save_identity(&self, bytes: &[u8]);
}

static MOSS_KEYSTORE: Mutex<Option<Arc<dyn MossKeyStore>>> = 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<dyn MossKeyStore>) {
*MOSS_KEYSTORE.lock().expect("moss keystore lock poisoned") = Some(store);
}

#[cfg(test)]
pub static MOSS_TEST_LOCK: Mutex<()> = Mutex::new(());

Expand Down Expand Up @@ -127,6 +150,7 @@ pub struct MossFfiRuntime {
get_nat_type: MossGetNatType,
get_public_key: MossGetPublicKey,
free: MossFree,
set_key_store: MossSetKeyStore,
}

pub struct MossNode {
Expand Down Expand Up @@ -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<Self>,
mesh_id: &str,
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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<Option<Vec<u8>>>);
impl MossKeyStore for MemStore {
fn load_identity(&self) -> Option<Vec<u8>> {
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";
Expand Down
50 changes: 50 additions & 0 deletions src-tauri/src/adapters/persistence.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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()))?;
Expand Down Expand Up @@ -233,6 +238,35 @@ impl Persistence {
pub fn list_messages(&self, conversation_id: &str) -> Result<Vec<Vec<u8>>, 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<Option<Vec<u8>>, PersistenceError> {
self.get(MOSS_IDENTITY, MOSS_IDENTITY_KEY)
}
}

impl crate::adapters::moss_ffi::MossKeyStore for Persistence {
fn load_identity(&self) -> Option<Vec<u8>> {
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)]
Expand All @@ -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()))?;
Expand Down Expand Up @@ -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()));
Expand Down
10 changes: 10 additions & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,16 @@ impl PrivateDmState {
attachment_store: Arc<AttachmentStore>,
persistence: Option<Arc<adapters::persistence::Persistence>>,
) -> 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 {
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
Loading