NaiHe – Lightweight E2E encrypted chat, no accounts, no central server (Rust + Tauri)

Hey all. I wrote a small encrypted chat tool called NaiHe with Rust + Tauri. Designed for people in censored regions who need to talk without being watched.

The idea is simple — two people share a room name and a key, messages get encrypted client-side with ChaCha20-Poly1305 (Argon2id for KDF), and relayed through any MQTT broker. Broker sees nothing but ciphertext.

There's also a clipboard mode ("Cipher Pad") that lets you encrypt/decrypt text in your clipboard, so you can paste ciphertext into whatever app you already use — no need for the other person to install anything special.

Rust side handles all the crypto and MQTT transport. Frontend is React. The whole thing is one portable exe, around 5MB.

I'm releasing this anonymously and won't be maintaining it going forward. It's under the Unlicense — public domain, do whatever you want with it. If anyone wants to fork it, audit the crypto, add Linux/macOS builds, or just tear it apart for learning purposes, go for it.

Known limitations are in the README — no forward secrecy, no traffic obfuscation, no key exchange protocol, Windows only for now. I tried to be honest about what it can and can't do.

If you advertise a chat system for people who are worried about their government, you need to hide the metadata.

The broker knows which users are in the same room[1], allowing it to infer their social graph. It also knows other data like IP address and time. That kind of metadata add up fast.

Even if the broker was trusted, a passive, network-monitoring adversary can infer that people chatting through the same broker at the same time are probably connected to each other.

A few things I would look into if I were trying to design this:

  • Onion routing. For example, the experimental arti crate acts as a frontend for Tor.
  • Public mailboxes. For example, Naihe could hide the social graph from the broker by having all of the clients use the same MQTT topic and filter out unwanted messages by silently discarding any messages that fail to decrypt using the room key.

  1. Your MQTT topic is computed deterministically from the room key. Naihe/src-tauri/src/cipher.rs at 73b9ae5c4d71fdb18ce0593543efdde1575dadc2 · clinamen0/Naihe · GitHub ↩︎

2 Likes

Thank you very much for your reply. In fact, this project is a one-off project and a vision. The original idea came from fear — the fear of having your communications surveilled, and the legal risks that come with having your speech monitored.

It initially used a public MQTT server, which was later removed for various reasons. I want to leave the choice of a public MQTT server to future forks.

The onion routing idea is great, but I'm unable to continuously maintain and update this project, so it would need community forks.

My personal vision: the software should be simple, easy to use, quickly distributable, non-commercial, available to every ordinary person, maximizing the cost of censorship for authoritarian governments — until the day when we can finally see the light.

For this reason, I gave up many complex designs and ultimately kept only the framework. But your suggestions are valuable, and I'm very grateful.

Note: This forum account (clinamen0) will also be retired soon, to prevent tracing.

The onion routing, I admit, is pretty complex.

The public mailbox implementation? That's negative eleven lines of code, because your code already silently ignores messages that don't decrypt. All you have to do is not vary the topic string.

diff --git a/src-tauri/src/app.rs b/src-tauri/src/app.rs
index d2ffe36..455d669 100644
--- a/src-tauri/src/app.rs
+++ b/src-tauri/src/app.rs
@@ -74,14 +74,13 @@ async fn cmd_connect(
     offline: bool,
 ) -> Result<String, String> {
     let broker = transport::parse_broker(&server)?;
-    let topic = cipher::room_topic(&room);
     let persistent = offline && !server.trim().is_empty();
 
     let opts = transport::build_options(&broker, &room, &nickname, persistent);
     let (client, mut eventloop) = AsyncClient::new(opts, 64);
 
     client
-        .subscribe(&topic, QoS::AtLeastOnce)
+        .subscribe(&config::TOPIC, QoS::AtLeastOnce)
         .await
         .map_err(|e| format!("subscribe: {e}"))?;
 
@@ -90,7 +89,7 @@ async fn cmd_connect(
         let mut guard = state.0.lock().await;
         *guard = Some(transport::MqttLink {
             client: client.clone(),
-            topic: topic.clone(),
+            topic: config::TOPIC.clone(),
             nickname: nickname.clone(),
             passphrase: passphrase.clone(),
             is_persistent: persistent,
@@ -190,10 +189,12 @@ fn history_path(room: &str) -> std::path::PathBuf {
     let dir = std::path::PathBuf::from(base).join("naihe");
     std::fs::create_dir_all(&dir).ok();
 
-    // Reuse cipher module's topic hash for consistent naming
-    let topic = cipher::room_topic(room);
-    let name = topic.replace('/', "_");
-    dir.join(format!("{name}.{}", config::HISTORY_EXT))
+    // Use hash for naming that won't get filesystem corrupted.
+    let mut hasher = Sha256::new();
+    hasher.update(room.trim().as_bytes());
+    let digest = hasher.finalize();
+    let hex: String = digest.iter().take(4).map(|b| format!("{b:02x}")).collect();
+    dir.join(format!("{hex}.{}", config::HISTORY_EXT))
 }
 
 #[tauri::command]
@@ -238,6 +239,5 @@ async fn cmd_pull_offline(
     since: i64,
 ) -> Result<Vec<transport::ChatMessage>, String> {
     let broker = transport::parse_broker(&server)?;
-    let topic = cipher::room_topic(&room);
-    transport::fetch_offline(&broker.host, &topic, &passphrase, since).await
+    transport::fetch_offline(&broker.host, &passphrase, since).await
 }
diff --git a/src-tauri/src/cipher.rs b/src-tauri/src/cipher.rs
index 590527b..a167079 100644
--- a/src-tauri/src/cipher.rs
+++ b/src-tauri/src/cipher.rs
@@ -184,15 +184,6 @@ pub fn resembles_cipher(text: &str) -> bool {
     }
 }
 
-/// Hash a room name into an MQTT topic path.
-pub fn room_topic(room: &str) -> String {
-    let mut hasher = Sha256::new();
-    hasher.update(room.trim().as_bytes());
-    let digest = hasher.finalize();
-    let hex: String = digest.iter().take(4).map(|b| format!("{b:02x}")).collect();
-    format!("{}/{hex}", config::TOPIC_PREFIX)
-}
-
 #[cfg(test)]
 mod tests {
     use super::*;
diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs
index 993258a..1755934 100644
--- a/src-tauri/src/config.rs
+++ b/src-tauri/src/config.rs
@@ -17,9 +17,10 @@ pub const ARGON2_MEMORY_KB: u32 = 65536; // 64 MB
 pub const ARGON2_ITERATIONS: u32 = 3;
 pub const ARGON2_PARALLELISM: u32 = 4;
 
-/// Topic prefix for MQTT channels.
-/// Messages are published to "{TOPIC_PREFIX}/{room_hash}".
-pub const TOPIC_PREFIX: &str = "nh";
+/// Topic for MQTT channels.
+/// Messages are published to "{TOPIC}".
+/// Different channels are distinguished by discarding messages that won't decrypt.
+pub const TOPIC: &str = "nh";
 
 /// Client ID prefix for MQTT connections.
 pub const CLIENT_PREFIX: &str = "nh-";
diff --git a/src-tauri/src/transport.rs b/src-tauri/src/transport.rs
index 6b0ced0..6616067 100644
--- a/src-tauri/src/transport.rs
+++ b/src-tauri/src/transport.rs
@@ -45,12 +45,10 @@ pub fn build_options(
 ) -> MqttOptions {
     let client_id = if persistent {
         // Deterministic ID so the broker can queue messages
-        let room_hash = crate::cipher::room_topic(room);
         let nick_part: String = nickname.chars().take(4).collect();
         format!(
-            "{}{}-{}",
+            "{}-{}",
             config::CLIENT_PREFIX,
-            &room_hash[room_hash.len().saturating_sub(4)..],
             nick_part
         )
     } else {
@@ -86,7 +84,6 @@ pub struct ChatMessage {
 /// Fetch offline messages from the companion HTTP service.
 pub async fn fetch_offline(
     host: &str,
-    topic: &str,
     passphrase: &str,
     since: i64,
 ) -> Result<Vec<ChatMessage>, String> {
@@ -94,7 +91,7 @@ pub async fn fetch_offline(
         "http://{}:{}/messages?topic={}&since={}",
         host,
         config::OFFLINE_SERVICE_PORT,
-        topic,
+        &config::TOPIC,
         since
     );

To be clear, using the same MQTT topic for every user isn't quite a panacea. An evil broker could still figure out the social graph by watching who posts around the same time. But it hides the identity of lurkers, and, as I pointed out before, it requires -11 lines of code on net.

Your public mailbox approach is clean and I'm adopting it. The bandwidth tradeoff is acceptable at this scale, and it's a better foundation for traffic analysis resistance down the road. If you're interested in taking it further, feel free to fork and let me know — or if you'd like to maintain a long-term branch, I'm happy to link your fork from the original repo's docs.

I'll still be reachable on this account for at least the next 24 hours.

1 Like

Not really. Backseat driving on this is one thing, but I don't have the motivation to maintain security-hard chat software in the long term. Too much else going on.

I am currently working on a similar hobby-project, but implementing Tor routing + being p2p instead of using a broker. You might be interested in checking that out: GitHub - NielDuysters/arti-chat: Decentralized peer-to-peer messenger with reasonable anonymity over Tor. · GitHub