
История маскота: Хранитель туннелей
Во вселенной распределённых систем существует древний цифровой организм — Wormhole, или, как его называют в инженерных кругах, Хранитель туннелей.
Он не живёт в серверах, не привязан к IP‑адресам и не сохраняет ничего в облаке. Его среда — шум пустоты между узлами. Он рождается каждый раз, когда два устройства обмениваются публичными ключами. Его тело состоит из энергии шифрования, а глаза — это криптографические nonce, всегда уникальные, всегда непредсказуемые.
Он не говорит. Он доставляет.
Каждое сообщение, проходя через его туннель, исчезает для всего мира — кроме одного получателя. Он не знает, что вы говорите, но знает, что это должно остаться между вами.
Когда вы запускаете Wormhole Messenger — вы не просто открываете чат. Вы пробуждаете существо, которое живёт между пингами, между кадрами WebSocket. Маскот — это визуальный облик этого криптографического духа, появляющегося каждый раз, когда вы выбираете приватность.
Введение
Rust уже давно зарекомендовал себя как язык для системного программирования, но также отлично подходит для реализации сетевых приложений, особенно в контексте безопасной и параллельной обработки. В этой статье я хочу представить Wormhole Messenger — простой, но функциональный P2P-мессенджер с end-to-end шифрованием, реализованный с использованием WebSocket, sodiumoxide и библиотеки axum.
Цели и архитектура
Основные цели:
Простая архитектура, легко расширяемая до desktop/web
Использование современных async-решений (
tokio
,axum
)Минимальная зависимость от сторонних серверов
Упор на приватность и безопасность
Архитектурная схема:
┌──────────┐ WebSocket ┌──────────┐
│ Client │ <-------------------> │ Server │
└──────────┘ └──────────┘
▲ ▲
│ │
│ E2E NaCl box │
▼ ▼
┌──────────────────────┐ ┌──────────────────────┐
│ wormhole_keys.json │ │ messages.json │
│ username.pk │ └──────────────────────┘
└──────────────────────┘
Реализация: серверная часть
Сервер реализован на axum
и tokio-tungstenite
. Его задача — только ретрансляция сообщений. Шифрование осуществляется исключительно на клиенте.
async fn handle_socket(socket: WebSocket, tx: Sender<String>) { ... }
Каждое соединение обрабатывается независимо, с использованием канала передачи сообщений (broadcast::channel
). Сервер не хранит состояния и не расшифровывает данные.
use axum::{
extract::ws::{Message, WebSocketUpgrade},
routing::get,
Router,
};
Импорт из axum
:
WebSocketUpgrade
— тип для апгрейда HTTP-соединения до WebSocket.Message
— сообщение WebSocket (Text/Binary/Ping/Pong/Close).get
— маршрутизатор HTTP GET-запросов.Router
— основной объект маршрутизацииaxum
.
use std::net::SocketAddr;
use tokio::sync::broadcast;
use futures_util::{SinkExt, StreamExt};
Импорт вспомогательных библиотек:
SocketAddr
— адрес для прослушивания (IP + порт).broadcast
— канал для широковещательной передачи сообщений (всем подписчикам).SinkExt
,StreamExt
— трейты для работы с потоками (send/receive) WebSocket.
let (tx, _rx) = broadcast::channel::<String>(100);
Создаём broadcast
-канал:
tx
: передатчик, с помощью которого можно отправлять строки.rx
: приёмник (не используется напрямую, поэтому_rx
).Канал может хранить до 100 последних сообщений.
let app = Router::new().route("/ws", get(move |ws: WebSocketUpgrade| {
let tx = tx.clone();
async move {
ws.on_upgrade(move |socket| async move {
Создаём axum
-приложение и добавляем маршрут GET /ws
:
ws: WebSocketUpgrade
— автоматическая обработка апгрейда до WebSocket.Клонируем передатчик
tx
для каждого подключения (посколькуtx
реализуетClone
).Обрабатываем апгрейд WebSocket:
socket
— WebSocket-соединение между клиентом и сервером.
let (mut sender, mut receiver) = socket.split();
Разделяем WebSocket на:
sender
— поток исходящих сообщений.receiver
— поток входящих сообщений.
let mut rx = tx.subscribe();
let send_task = tokio::spawn(async move {
while let Ok(msg) = rx.recv().await {
if sender.send(Message::Text(msg)).await.is_err() {
break;
}
}
Создаём подписчика на broadcast
-канал, чтобы получать новые сообщения от других клиентов.
Создаём отдельную задачу, которая:
Ожидает сообщений из
rx
.Отправляет их обратно клиенту через WebSocket.
Цикл:
Получаем сообщение из канала.
Отправляем клиенту.
Если клиент отключился (
send
вернул ошибку), выходим из цикла.
let recv_task = tokio::spawn(async move {
while let Some(Ok(Message::Text(text))) = receiver.next().await {
let _ = tx.send(text);
}
});
let _ = tokio::join!(send_task, recv_task);
Вторая задача:
Слушает, что присылает клиент.
Рассылает полученное сообщение другим через
tx
.
Цикл:
Получает текстовое сообщение от клиента.
Отправляет его во
все
подпискиbroadcast
-канала.
Ждём завершения обеих задач.
Если одна завершится (например, клиент отключился), завершается и вторая.
Реализация: клиентская часть
Исходник client.rc
use tokio_tungstenite::connect_async;
use futures_util::{SinkExt, StreamExt};
use tokio::io::{self, AsyncBufReadExt};
use url::Url;
use sodiumoxide::crypto::box_;
use base64::{engine::general_purpose, Engine as _};
use std::fs;
use std::path::Path;
use serde::{Deserialize, Serialize};
use chrono::Utc;
use std::env;
use dotenv::dotenv;
#[derive(Serialize, Deserialize)]
struct StoredKeys {
public_key: String,
secret_key: String,
}
#[derive(Serialize, Deserialize)]
struct StoredMessage {
sender: String,
text: String,
timestamp: String,
}
fn load_or_generate_keys(username: &str) -> (box_::PublicKey, box_::SecretKey) {
let path = Path::new("wormhole_keys.json");
if path.exists() {
if let Ok(data) = fs::read_to_string(path) {
if let Ok(keys) = serde_json::from_str::<StoredKeys>(&data) {
if let (Ok(pk_bytes), Ok(sk_bytes)) = (
general_purpose::STANDARD.decode(&keys.public_key),
general_purpose::STANDARD.decode(&keys.secret_key),
) {
if let (Some(pk), Some(sk)) = (
box_::PublicKey::from_slice(&pk_bytes),
box_::SecretKey::from_slice(&sk_bytes),
) {
println!("Loaded saved keys.");
// Дополнительно сохраняем публичный ключ в файл
let pub_key_filename = format!("{}.pk", username);
fs::write(pub_key_filename, &keys.public_key).ok();
return (pk, sk);
}
}
}
}
println!("Corrupted keys file. Re-generating...");
}
let (pk, sk) = box_::gen_keypair();
let keys = StoredKeys {
public_key: general_purpose::STANDARD.encode(pk.0),
secret_key: general_purpose::STANDARD.encode(sk.0),
};
let data = serde_json::to_string_pretty(&keys).unwrap();
fs::write(path, data).expect("Failed to write keys file");
println!("🆕 Generated new keys.");
// Сохраняем публичный ключ отдельно по имени
let pub_key_filename = format!("{}.pk", username);
fs::write(pub_key_filename, &keys.public_key).expect("Failed to write public key file");
(pk, sk)
}
pub async fn run_client(username: String) {
sodiumoxide::init().unwrap();
let (my_pk, my_sk) = load_or_generate_keys(&username);
println!("Your public key (base64): {}", general_purpose::STANDARD.encode(my_pk.0));
println!("You can now chat securely as '{}'.", username);
let url = Url::parse("ws://127.0.0.1:3000/ws").unwrap();
let (ws_stream, _) = connect_async(url).await.expect("Failed to connect");
println!("Connected to server.");
let (mut write, mut read) = ws_stream.split();
let stdin = io::BufReader::new(io::stdin());
let mut lines = stdin.lines();
let sender_pk = my_pk.clone();
let sender_sk = my_sk.clone();
let send_task = tokio::spawn(async move {
while let Ok(Some(line)) = lines.next_line().await {
let nonce = box_::gen_nonce();
let ciphertext = box_::seal(line.as_bytes(), &nonce, &sender_pk, &sender_sk);
let payload = format!(
"{}:{}:{}:{}",
general_purpose::STANDARD.encode(sender_pk.0),
general_purpose::STANDARD.encode(nonce.0),
general_purpose::STANDARD.encode(ciphertext),
username
);
if write.send(tokio_tungstenite::tungstenite::Message::Text(payload)).await.is_err() {
break;
}
}
});
let recv_task = tokio::spawn(async move {
while let Some(Ok(tokio_tungstenite::tungstenite::Message::Text(text))) = read.next().await {
let parts: Vec<&str> = text.splitn(4, ':').collect();
if parts.len() != 4 {
println!("Malformed message");
continue;
}
let sender_pk_bytes = match general_purpose::STANDARD.decode(parts[0]) {
Ok(b) => b,
Err(_) => continue,
};
let nonce_bytes = match general_purpose::STANDARD.decode(parts[1]) {
Ok(b) => b,
Err(_) => continue,
};
let cipher_bytes = match general_purpose::STANDARD.decode(parts[2]) {
Ok(b) => b,
Err(_) => continue,
};
let sender_name = parts[3];
if let (Some(sender_pk), Some(nonce)) = (
box_::PublicKey::from_slice(&sender_pk_bytes),
box_::Nonce::from_slice(&nonce_bytes),
) {
match box_::open(&cipher_bytes, &nonce, &sender_pk, &my_sk) {
Ok(msg) => {
let text = String::from_utf8_lossy(&msg);
println!("{}: {}", sender_name, text);
store_message(sender_name, &text);
// Сохраняем публичный ключ отправителя, если его ещё нет
let pub_key_file = format!("{}.pk", sender_name);
if !Path::new(&pub_key_file).exists() {
fs::write(pub_key_file, general_purpose::STANDARD.encode(sender_pk.0)).ok();
}
}
Err(_) => println!("Failed to decrypt message from {}", sender_name),
}
}
}
});
let _ = tokio::join!(send_task, recv_task);
}
pub fn encrypt_for(to_username: &str, message: &str) {
sodiumoxide::init().unwrap();
// Загружаем публичный ключ получателя
let pk_path = format!("{}.pk", to_username);
let pk_str = match std::fs::read_to_string(&pk_path) {
Ok(s) => s.trim().to_string(),
Err(_) => {
println!("No public key file found for '{}'", to_username);
return;
}
};
let pk_bytes = match general_purpose::STANDARD.decode(&pk_str) {
Ok(b) => b,
Err(_) => {
println!("Failed to decode public key.");
return;
}
};
let recipient_pk = match box_::PublicKey::from_slice(&pk_bytes) {
Some(pk) => pk,
None => {
println!("Invalid public key format.");
return;
}
};
// Генерация одноразовой пары ключей
let (sender_pk, sender_sk) = box_::gen_keypair();
let nonce = box_::gen_nonce();
let ciphertext = box_::seal(message.as_bytes(), &nonce, &recipient_pk, &sender_sk);
let payload = format!(
"{}:{}:{}:ANON",
general_purpose::STANDARD.encode(sender_pk.0),
general_purpose::STANDARD.encode(nonce.0),
general_purpose::STANDARD.encode(ciphertext)
);
println!("📦 Encrypted message to '{}':\n{}", to_username, payload);
}
pub fn decrypt_message(payload: &str) {
sodiumoxide::init().unwrap();
let (_my_pk, my_sk) = load_or_generate_keys("You");
let parts: Vec<&str> = payload.splitn(4, ':').collect();
if parts.len() != 4 {
println!("Invalid message format.");
return;
}
let sender_pk_bytes = match general_purpose::STANDARD.decode(parts[0]) {
Ok(b) => b,
Err(_) => {
println!("Failed to decode sender public key.");
return;
}
};
let nonce_bytes = match general_purpose::STANDARD.decode(parts[1]) {
Ok(b) => b,
Err(_) => {
println!("Failed to decode nonce.");
return;
}
};
let cipher_bytes = match general_purpose::STANDARD.decode(parts[2]) {
Ok(b) => b,
Err(_) => {
println!("Failed to decode ciphertext.");
return;
}
};
let sender_name = parts[3];
if let (Some(sender_pk), Some(nonce)) = (
box_::PublicKey::from_slice(&sender_pk_bytes),
box_::Nonce::from_slice(&nonce_bytes),
) {
match box_::open(&cipher_bytes, &nonce, &sender_pk, &my_sk) {
Ok(msg) => {
let text = String::from_utf8_lossy(&msg);
println!("{} says: {}", sender_name, text);
}
Err(_) => println!("Failed to decrypt message from {}", sender_name),
}
} else {
println!("Invalid key or nonce format.");
}
}
fn store_message(sender: &str, text: &str) {
send_telegram_alert(sender, text);
let path = Path::new("messages.json");
let mut messages: Vec<StoredMessage> = if path.exists() {
match std::fs::read_to_string(path) {
Ok(data) => serde_json::from_str(&data).unwrap_or_default(),
Err(_) => vec![],
}
} else {
vec![]
};
messages.push(StoredMessage {
sender: sender.to_string(),
text: text.to_string(),
timestamp: Utc::now().to_rfc3339(),
});
let data = serde_json::to_string_pretty(&messages).unwrap();
std::fs::write(path, data).expect("Failed to write messages.json");
}
pub fn show_history() {
let path = Path::new("messages.json");
if !path.exists() {
println!("📭 No message history found.");
return;
}
let data = match std::fs::read_to_string(path) {
Ok(d) => d,
Err(_) => {
println!("Failed to read message history.");
return;
}
};
let messages: Vec<StoredMessage> = match serde_json::from_str(&data) {
Ok(m) => m,
Err(_) => {
println!("⚠Invalid message format in history.");
return;
}
};
for msg in messages {
println!("[{}] {}: {}", msg.timestamp, msg.sender, msg.text);
}
}
fn send_telegram_alert(sender: &str, message: &str) {
dotenv().ok(); // загружаем .env только один раз
let token = match env::var("TELEGRAM_TOKEN") {
Ok(val) => val,
Err(_) => {
eprintln!("TELEGRAM_TOKEN not set");
return;
}
};
let chat_id = match env::var("TELEGRAM_CHAT_ID") {
Ok(val) => val,
Err(_) => {
eprintln!("TELEGRAM_CHAT_ID not set");
return;
}
};
let text = format!("New message from {}:\n\"{}\"", sender, message);
let client = reqwest::blocking::Client::new();
let url = format!("https://api.telegram.org/bot{}/sendMessage", token);
let res = client.post(&url)
.json(&serde_json::json!({
"chat_id": chat_id,
"text": text
}))
.send();
if let Err(e) = res {
eprintln!("Telegram error: {}", e);
}
}
Клиент при старте:
Загружает или генерирует пару ключей NaCl (
PublicKey
,SecretKey
)Подключается к WebSocket-серверу
Шифрует сообщения через
sodiumoxide::crypto::box_
Отправляет сериализованный пакет (
sender_pk:nonce:ciphertext:sender_name
)
let ciphertext = box_::seal(message.as_bytes(), &nonce, &recipient_pk, &sender_sk);
use tokio_tungstenite::connect_async;
use futures_util::{SinkExt, StreamExt};
use tokio::io::{self, AsyncBufReadExt};
use url::Url;
use sodiumoxide::crypto::box_;
use base64::{engine::general_purpose, Engine as _};
use std::fs;
use std::path::Path;
use serde::{Deserialize, Serialize};
use chrono::Utc;
use std::env;
use dotenv::dotenv;
tokio_tungstenite
— WebSocket-клиент на базеtokio
.sodiumoxide::crypto::box_
— асимметричное шифрование на Curve25519.base64
— для кодирования ключей и сообщений.serde
— сериализация ключей и сообщений.chrono::Utc
— для отметки времени.dotenv
иenv
— для загрузки.env
переменных (Telegram-бот).
Структуры
struct StoredKeys {
public_key: String,
secret_key: String,
}
struct StoredMessage {
sender: String,
text: String,
timestamp: String,
}
StoredKeys
— для хранения ключей вwormhole_keys.json
.StoredMessage
— для хранения истории сообщений.
load_or_generate_keys(username)
Загружает ключи из
wormhole_keys.json
, если он существует.Если файл испорчен или не существует — генерирует новую пару.
Сохраняет публичный ключ отдельно:
username.pk
.Использует
base64
для хранения ключей в строковом виде.
run_client(username)
Инициализирует sodiumoxide.
Загружает или создаёт ключи.
Подключается к
ws://127.0.0.1:3000/ws
.Делит соединение на
write
(отправка) иread
(приём).
send_task
Читает строку из stdin.
Шифрует её с использованием своей пары ключей (
sender_pk
,sender_sk
) иnonce
.Формирует строку вида:
base64(pk):base64(nonce):base64(ciphertext):username
Отправляет на сервер.
recv_task
Ждёт сообщение от сервера.
Делит по
:
, декодирует.Расшифровывает сообщение, используя:
Публичный ключ отправителя (из сообщения)
Свою
secret_key
Показывает в терминале, сохраняет в
messages.json
.Если нет файла с ключом отправителя — сохраняет его (
sender.pk
).
CLI-функциональность
Клиент поддерживает режимы:
client <username>
— интерактивный чатencrypt <to> "<text>"
— шифрование сообщения вручнуюdecrypt "<payload>"
— расшифровка вручнуюhistory
— просмотр локальной истории
Также реализована отправка уведомлений в Telegram при получении новых сообщений (опционально, через .env
).
encrypt_for(to_username, message)
Загружает
to_
username.pk
.Генерирует одноразовую пару ключей.
Шифрует
message
.Печатает
base64(pk):base64(nonce):base64(cipher):ANON
— можно вставить в другой клиент.
decrypt_message(payload)
Парсит строку с зашифрованным сообщением.
Декодирует и расшифровывает.
Использует свои ключи (
You
).
store_message(sender, text)
Загружает
messages.json
.Добавляет новое сообщение с временной меткой.
Сохраняет файл.
Также вызывает
send_telegram_alert(...)
.
show_history()
Загружает
messages.json
.Выводит сообщения в виде:
[timestamp] sender: text
send_telegram_alert(sender, message)
Загружает
.env
, берётTELEGRAM_TOKEN
иCHAT_ID
.Отправляет сообщение в Telegram с помощью
reqwest
.
Используемый формат сообщения:
base64(sender_public_key):base64(nonce):base64(ciphertext):sender_name
Хранение ключей
Пара ключей сохраняется в wormhole_keys.json
. Публичный ключ также пишется в отдельный .pk
-файл (<username>.pk
) для облегчения использования в оффлайн-режиме:
let keys = StoredKeys {
public_key: base64::encode(pk.0),
secret_key: base64::encode(sk.0),
};
Преимущества и возможности расширения
Rust позволяет гарантировать безопасность памяти без сборщика мусора
Можно легко портировать на desktop (через Tauri) или web (через WebAssembly)
Расширение возможно за счёт подключения:
авторизации
мультиустройств
хранения метаданных (через sqlite или sled)
Исходный код: https://github.com/digkill/Wormhole
Заключение
Wormhole Messenger — это концепт, который демонстрирует, насколько просто можно реализовать безопасный мессенджер на Rust. С учётом минимализма кода и высокого уровня безопасности, он может послужить основой для более сложных решений в корпоративной или open-source среде.