From 82f5061aa19cd11c09bfe05b8b1ea42c4350c49f Mon Sep 17 00:00:00 2001 From: Siavash Sameni Date: Thu, 26 Mar 2026 21:40:21 +0400 Subject: [PATCH] Wire E2E messaging: send, recv, session persistence, auto-registration CLI client (warzone): - `warzone init` now generates pre-key bundle (1 SPK + 10 OTPKs), stores secrets in local sled DB, saves bundle for server registration - `warzone register -s ` registers bundle with server - `warzone send -s ` full E2E flow: - Auto-registers bundle on first use - Fetches recipient's pre-key bundle - Performs X3DH key exchange (first message) or uses existing session - Encrypts with Double Ratchet - Sends WireMessage envelope to server - `warzone recv -s ` polls and decrypts: - Handles KeyExchange messages (X3DH respond + ratchet init as Bob) - Handles Message (decrypt with existing ratchet session) - Saves session state after each decrypt Wire protocol (WireMessage enum): - KeyExchange variant: sender identity, ephemeral key, OTPK id, ratchet msg - Message variant: sender fingerprint + ratchet message Session persistence: - Ratchet state serialized with bincode, stored in sled (~/.warzone/db) - Pre-key secrets stored in sled, OTPKs consumed on use - Sessions keyed by peer fingerprint Networking (net.rs): - register_bundle, fetch_bundle, send_message, poll_messages - JSON API over HTTP, bundles serialized with bincode + base64 Co-Authored-By: Claude Opus 4.6 (1M context) --- warzone/Cargo.lock | 5 + warzone/crates/warzone-client/Cargo.toml | 5 + warzone/crates/warzone-client/src/cli/init.rs | 73 +++++++++- warzone/crates/warzone-client/src/cli/mod.rs | 2 + warzone/crates/warzone-client/src/cli/recv.rs | 117 ++++++++++++++++ warzone/crates/warzone-client/src/cli/send.rs | 91 +++++++++++++ warzone/crates/warzone-client/src/main.rs | 22 ++- warzone/crates/warzone-client/src/net.rs | 125 +++++++++++++++++- warzone/crates/warzone-client/src/storage.rs | 96 +++++++++++++- 9 files changed, 527 insertions(+), 9 deletions(-) create mode 100644 warzone/crates/warzone-client/src/cli/recv.rs create mode 100644 warzone/crates/warzone-client/src/cli/send.rs diff --git a/warzone/Cargo.lock b/warzone/Cargo.lock index ffc24c7..d27f6be 100644 --- a/warzone/Cargo.lock +++ b/warzone/Cargo.lock @@ -2557,7 +2557,10 @@ version = "0.1.0" dependencies = [ "anyhow", "argon2", + "base64", + "bincode", "chacha20poly1305", + "chrono", "clap", "crossterm", "hex", @@ -2570,7 +2573,9 @@ dependencies = [ "tokio", "tracing", "tracing-subscriber", + "uuid", "warzone-protocol", + "x25519-dalek", "zeroize", ] diff --git a/warzone/crates/warzone-client/Cargo.toml b/warzone/crates/warzone-client/Cargo.toml index b51a408..e43d9ee 100644 --- a/warzone/crates/warzone-client/Cargo.toml +++ b/warzone/crates/warzone-client/Cargo.toml @@ -21,3 +21,8 @@ chacha20poly1305.workspace = true rand.workspace = true zeroize.workspace = true hex.workspace = true +base64.workspace = true +x25519-dalek.workspace = true +bincode.workspace = true +uuid.workspace = true +chrono.workspace = true diff --git a/warzone/crates/warzone-client/src/cli/init.rs b/warzone/crates/warzone-client/src/cli/init.rs index 52cb428..8612941 100644 --- a/warzone/crates/warzone-client/src/cli/init.rs +++ b/warzone/crates/warzone-client/src/cli/init.rs @@ -1,8 +1,14 @@ +use anyhow::Result; use warzone_protocol::identity::Seed; +use warzone_protocol::prekey::{ + generate_one_time_pre_keys, generate_signed_pre_key, OneTimePreKeyPublic, PreKeyBundle, +}; use crate::keystore; +use crate::net::ServerClient; +use crate::storage::LocalDb; -pub fn run() -> anyhow::Result<()> { +pub fn run() -> Result<()> { let seed = Seed::generate(); let identity = seed.derive_identity(); let pub_id = identity.public_identity(); @@ -23,5 +29,70 @@ pub fn run() -> anyhow::Result<()> { keystore::save_seed(&seed)?; println!("Seed saved to ~/.warzone/identity.seed"); + // Generate pre-keys and store secrets locally + let db = LocalDb::open()?; + + let (spk_secret, signed_pre_key) = generate_signed_pre_key(&identity, 1); + db.save_signed_pre_key(1, &spk_secret)?; + + let otpks = generate_one_time_pre_keys(0, 10); + for otpk in &otpks { + db.save_one_time_pre_key(otpk.id, &otpk.secret)?; + } + + println!( + "Generated 1 signed pre-key + {} one-time pre-keys", + otpks.len() + ); + + // Build bundle for server registration + let bundle = PreKeyBundle { + identity_key: *pub_id.signing.as_bytes(), + identity_encryption_key: *pub_id.encryption.as_bytes(), + signed_pre_key, + one_time_pre_key: Some(OneTimePreKeyPublic { + id: otpks[0].id, + public_key: *otpks[0].public.as_bytes(), + }), + }; + + // Store bundle locally for later registration + let bundle_bytes = bincode::serialize(&bundle)?; + let home = std::env::var("HOME").unwrap_or_else(|_| ".".into()); + let bundle_path = std::path::Path::new(&home) + .join(".warzone") + .join("bundle.bin"); + std::fs::write(&bundle_path, &bundle_bytes)?; + + println!("\nTo register with a server, run:"); + println!( + " warzone send -s http://server:7700" + ); + println!("\nOr register your key bundle manually:"); + println!(" (bundle auto-registered on first send)"); + + Ok(()) +} + +/// Register the local bundle with a server. Called automatically before first send. +pub async fn register_with_server(server_url: &str) -> Result<()> { + let seed = keystore::load_seed()?; + let identity = seed.derive_identity(); + let pub_id = identity.public_identity(); + let fp = pub_id.fingerprint.to_string(); + + let home = std::env::var("HOME").unwrap_or_else(|_| ".".into()); + let bundle_path = std::path::Path::new(&home) + .join(".warzone") + .join("bundle.bin"); + + let bundle_bytes = std::fs::read(&bundle_path) + .map_err(|_| anyhow::anyhow!("No bundle found. Run `warzone init` first."))?; + let bundle: PreKeyBundle = bincode::deserialize(&bundle_bytes)?; + + let client = ServerClient::new(server_url); + client.register_bundle(&fp, &bundle).await?; + println!("Bundle registered with {}", server_url); + Ok(()) } diff --git a/warzone/crates/warzone-client/src/cli/mod.rs b/warzone/crates/warzone-client/src/cli/mod.rs index 10e36ab..42a0d11 100644 --- a/warzone/crates/warzone-client/src/cli/mod.rs +++ b/warzone/crates/warzone-client/src/cli/mod.rs @@ -1,3 +1,5 @@ pub mod info; pub mod init; pub mod recover; +pub mod send; +pub mod recv; diff --git a/warzone/crates/warzone-client/src/cli/recv.rs b/warzone/crates/warzone-client/src/cli/recv.rs new file mode 100644 index 0000000..7577a10 --- /dev/null +++ b/warzone/crates/warzone-client/src/cli/recv.rs @@ -0,0 +1,117 @@ +use anyhow::{Context, Result}; +use warzone_protocol::ratchet::RatchetState; +use warzone_protocol::types::Fingerprint; +use warzone_protocol::x3dh; +use x25519_dalek::PublicKey; + +use crate::cli::send::WireMessage; +use crate::keystore; +use crate::net::ServerClient; +use crate::storage::LocalDb; + +pub async fn run(server_url: &str) -> Result<()> { + let seed = keystore::load_seed()?; + let identity = seed.derive_identity(); + let our_pub = identity.public_identity(); + let our_fp = our_pub.fingerprint.to_string(); + let db = LocalDb::open()?; + let client = ServerClient::new(server_url); + + println!("Polling for messages as {}...", our_fp); + + let messages = client.poll_messages(&our_fp).await?; + + if messages.is_empty() { + println!("No new messages."); + return Ok(()); + } + + println!("Received {} message(s):\n", messages.len()); + + for raw in &messages { + match bincode::deserialize::(raw) { + Ok(WireMessage::KeyExchange { + sender_fingerprint, + sender_identity_encryption_key, + ephemeral_public, + used_one_time_pre_key_id, + ratchet_message, + }) => { + let sender_fp = Fingerprint::from_hex(&sender_fingerprint) + .context("invalid sender fingerprint")?; + + // Load our signed pre-key secret + let spk_id = 1u32; // We use ID 1 for our signed pre-key + let spk_secret = db + .load_signed_pre_key(spk_id)? + .context("missing signed pre-key — run `warzone init` first")?; + + // Load one-time pre-key if used + let otpk_secret = if let Some(id) = used_one_time_pre_key_id { + db.take_one_time_pre_key(id)? + } else { + None + }; + + // X3DH respond + let their_identity_x25519 = PublicKey::from(sender_identity_encryption_key); + let their_ephemeral = PublicKey::from(ephemeral_public); + + let shared_secret = x3dh::respond( + &identity, + &spk_secret, + otpk_secret.as_ref(), + &their_identity_x25519, + &their_ephemeral, + ) + .context("X3DH respond failed")?; + + // Init ratchet as Bob + let mut state = RatchetState::init_bob(shared_secret, spk_secret); + + // Decrypt the message + match state.decrypt(&ratchet_message) { + Ok(plaintext) => { + let text = String::from_utf8_lossy(&plaintext); + println!(" [{}] {}: {}", "new session", sender_fingerprint, text); + db.save_session(&sender_fp, &state)?; + } + Err(e) => { + eprintln!(" [{}] decrypt failed: {}", sender_fingerprint, e); + } + } + } + Ok(WireMessage::Message { + sender_fingerprint, + ratchet_message, + }) => { + let sender_fp = Fingerprint::from_hex(&sender_fingerprint) + .context("invalid sender fingerprint")?; + + match db.load_session(&sender_fp)? { + Some(mut state) => match state.decrypt(&ratchet_message) { + Ok(plaintext) => { + let text = String::from_utf8_lossy(&plaintext); + println!(" {}: {}", sender_fingerprint, text); + db.save_session(&sender_fp, &state)?; + } + Err(e) => { + eprintln!(" [{}] decrypt failed: {}", sender_fingerprint, e); + } + }, + None => { + eprintln!( + " [{}] no session — cannot decrypt (need key exchange first)", + sender_fingerprint + ); + } + } + } + Err(e) => { + eprintln!(" failed to deserialize message: {}", e); + } + } + } + + Ok(()) +} diff --git a/warzone/crates/warzone-client/src/cli/send.rs b/warzone/crates/warzone-client/src/cli/send.rs new file mode 100644 index 0000000..a1adf1f --- /dev/null +++ b/warzone/crates/warzone-client/src/cli/send.rs @@ -0,0 +1,91 @@ +use anyhow::{Context, Result}; +use warzone_protocol::identity::Seed; +use warzone_protocol::message::{MessageContent, MessageType, WarzoneMessage}; +use warzone_protocol::ratchet::{RatchetMessage, RatchetState}; +use warzone_protocol::types::{Fingerprint, MessageId, SessionId}; +use warzone_protocol::x3dh; +use x25519_dalek::PublicKey; + +use crate::keystore; +use crate::net::ServerClient; +use crate::storage::LocalDb; + +/// The wire envelope: contains either a key exchange init or a ratchet message. +#[derive(serde::Serialize, serde::Deserialize)] +pub enum WireMessage { + /// First message to a peer: includes X3DH ephemeral key + ratchet message. + KeyExchange { + sender_fingerprint: String, + sender_identity_encryption_key: [u8; 32], + ephemeral_public: [u8; 32], + used_one_time_pre_key_id: Option, + ratchet_message: RatchetMessage, + }, + /// Subsequent messages: just ratchet messages. + Message { + sender_fingerprint: String, + ratchet_message: RatchetMessage, + }, +} + +pub async fn run(recipient_fp: &str, message: &str, server_url: &str) -> Result<()> { + let seed = keystore::load_seed()?; + let identity = seed.derive_identity(); + let our_pub = identity.public_identity(); + let db = LocalDb::open()?; + let client = ServerClient::new(server_url); + + let recipient = Fingerprint::from_hex(recipient_fp) + .context("invalid recipient fingerprint")?; + + // Check for existing session + let mut ratchet = db.load_session(&recipient)?; + + let wire_msg = if let Some(ref mut state) = ratchet { + // Existing session — just encrypt with ratchet + let encrypted = state.encrypt(message.as_bytes()) + .context("ratchet encrypt failed")?; + db.save_session(&recipient, state)?; + + WireMessage::Message { + sender_fingerprint: our_pub.fingerprint.to_string(), + ratchet_message: encrypted, + } + } else { + // No session — perform X3DH key exchange + println!("No existing session. Fetching key bundle for {}...", recipient_fp); + + let bundle = client.fetch_bundle(recipient_fp).await + .context("failed to fetch recipient's bundle. Are they registered?")?; + + let x3dh_result = x3dh::initiate(&identity, &bundle) + .context("X3DH key exchange failed")?; + + // Init ratchet as Alice + let their_spk = PublicKey::from(bundle.signed_pre_key.public_key); + let mut state = RatchetState::init_alice(x3dh_result.shared_secret, their_spk); + + let encrypted = state.encrypt(message.as_bytes()) + .context("ratchet encrypt failed")?; + + // Save session + db.save_session(&recipient, &state)?; + + WireMessage::KeyExchange { + sender_fingerprint: our_pub.fingerprint.to_string(), + sender_identity_encryption_key: *our_pub.encryption.as_bytes(), + ephemeral_public: *x3dh_result.ephemeral_public.as_bytes(), + used_one_time_pre_key_id: x3dh_result.used_one_time_pre_key_id, + ratchet_message: encrypted, + } + }; + + // Serialize and send + let encoded = bincode::serialize(&wire_msg) + .context("failed to serialize wire message")?; + + client.send_message(recipient_fp, &encoded).await?; + + println!("Message sent to {}", recipient_fp); + Ok(()) +} diff --git a/warzone/crates/warzone-client/src/main.rs b/warzone/crates/warzone-client/src/main.rs index 9b3c997..aacc6e8 100644 --- a/warzone/crates/warzone-client/src/main.rs +++ b/warzone/crates/warzone-client/src/main.rs @@ -15,7 +15,7 @@ struct Cli { #[derive(Subcommand)] enum Commands { - /// Generate a new identity (seed + keypair) + /// Generate a new identity (seed + keypair + pre-keys) Init, /// Recover identity from BIP39 mnemonic Recover { @@ -25,6 +25,12 @@ enum Commands { }, /// Show your fingerprint and public key Info, + /// Register your key bundle with a server + Register { + /// Server URL + #[arg(short, long, default_value = "http://localhost:7700")] + server: String, + }, /// Send an encrypted message Send { /// Recipient fingerprint (e.g. a3f8:c912:44be:7d01) @@ -49,22 +55,30 @@ enum Commands { }, } -fn main() -> anyhow::Result<()> { +#[tokio::main] +async fn main() -> anyhow::Result<()> { let cli = Cli::parse(); match cli.command { Commands::Init => cli::init::run()?, Commands::Recover { words } => cli::recover::run(&words.join(" "))?, Commands::Info => cli::info::run()?, + Commands::Register { server } => { + cli::init::register_with_server(&server).await?; + } Commands::Send { recipient, message, server, } => { - println!("TODO: send '{}' to {} via {}", message, recipient, server); + // Auto-register bundle on first send + if let Err(_) = cli::init::register_with_server(&server).await { + eprintln!("Warning: failed to register bundle with server"); + } + cli::send::run(&recipient, &message, &server).await?; } Commands::Recv { server } => { - println!("TODO: poll messages from {}", server); + cli::recv::run(&server).await?; } Commands::Chat { server } => { println!("TODO: launch TUI connected to {}", server); diff --git a/warzone/crates/warzone-client/src/net.rs b/warzone/crates/warzone-client/src/net.rs index 41be3ba..ff34089 100644 --- a/warzone/crates/warzone-client/src/net.rs +++ b/warzone/crates/warzone-client/src/net.rs @@ -1,2 +1,123 @@ -// HTTP client for talking to warzone-server. -// TODO: implement in Phase 1 step 9. +//! HTTP client for talking to warzone-server. + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use warzone_protocol::prekey::PreKeyBundle; + +#[derive(Clone)] +pub struct ServerClient { + pub base_url: String, + client: reqwest::Client, +} + +#[derive(Serialize)] +struct RegisterRequest { + fingerprint: String, + bundle: Vec, +} + +#[derive(Serialize)] +struct SendRequest { + to: String, + message: Vec, +} + +#[derive(Deserialize)] +struct BundleResponse { + fingerprint: String, + bundle: String, // base64 +} + +impl ServerClient { + pub fn new(base_url: &str) -> Self { + ServerClient { + base_url: base_url.trim_end_matches('/').to_string(), + client: reqwest::Client::new(), + } + } + + /// Register our pre-key bundle with the server. + pub async fn register_bundle( + &self, + fingerprint: &str, + bundle: &PreKeyBundle, + ) -> Result<()> { + let encoded = + bincode::serialize(bundle).context("failed to serialize bundle")?; + self.client + .post(format!("{}/v1/keys/register", self.base_url)) + .json(&RegisterRequest { + fingerprint: fingerprint.to_string(), + bundle: encoded, + }) + .send() + .await + .context("failed to register bundle")?; + Ok(()) + } + + /// Fetch a user's pre-key bundle from the server. + pub async fn fetch_bundle(&self, fingerprint: &str) -> Result { + let resp: BundleResponse = self + .client + .get(format!( + "{}/v1/keys/{}", + self.base_url, fingerprint + )) + .send() + .await + .context("failed to fetch bundle")? + .json() + .await + .context("failed to parse bundle response")?; + + let bytes = base64::Engine::decode( + &base64::engine::general_purpose::STANDARD, + &resp.bundle, + ) + .context("failed to decode base64 bundle")?; + + bincode::deserialize(&bytes).context("failed to deserialize bundle") + } + + /// Send an encrypted message to the server for delivery. + pub async fn send_message(&self, to: &str, message: &[u8]) -> Result<()> { + self.client + .post(format!("{}/v1/messages/send", self.base_url)) + .json(&SendRequest { + to: to.to_string(), + message: message.to_vec(), + }) + .send() + .await + .context("failed to send message")?; + Ok(()) + } + + /// Poll for messages addressed to us. + pub async fn poll_messages(&self, fingerprint: &str) -> Result>> { + let resp: Vec = self + .client + .get(format!( + "{}/v1/messages/poll/{}", + self.base_url, fingerprint + )) + .send() + .await + .context("failed to poll messages")? + .json() + .await + .context("failed to parse poll response")?; + + let mut messages = Vec::new(); + for b64 in resp { + if let Ok(bytes) = base64::Engine::decode( + &base64::engine::general_purpose::STANDARD, + &b64, + ) { + messages.push(bytes); + } + } + Ok(messages) + } +} diff --git a/warzone/crates/warzone-client/src/storage.rs b/warzone/crates/warzone-client/src/storage.rs index 274a705..ed4d526 100644 --- a/warzone/crates/warzone-client/src/storage.rs +++ b/warzone/crates/warzone-client/src/storage.rs @@ -1,2 +1,94 @@ -// Local sled database: sessions, contacts, message history. -// TODO: implement in Phase 1 step 9. +//! Local sled database: sessions, pre-keys, message history. + +use anyhow::{Context, Result}; +use warzone_protocol::ratchet::RatchetState; +use warzone_protocol::types::Fingerprint; +use x25519_dalek::StaticSecret; + +pub struct LocalDb { + sessions: sled::Tree, + pre_keys: sled::Tree, + _db: sled::Db, +} + +impl LocalDb { + pub fn open() -> Result { + let home = std::env::var("HOME").unwrap_or_else(|_| ".".into()); + let path = std::path::Path::new(&home).join(".warzone").join("db"); + let db = sled::open(&path).context("failed to open local database")?; + let sessions = db.open_tree("sessions")?; + let pre_keys = db.open_tree("pre_keys")?; + Ok(LocalDb { + sessions, + pre_keys, + _db: db, + }) + } + + /// Save a ratchet session for a peer. + pub fn save_session(&self, peer: &Fingerprint, state: &RatchetState) -> Result<()> { + let key = peer.to_hex(); + let data = bincode::serialize(state).context("failed to serialize session")?; + self.sessions.insert(key.as_bytes(), data)?; + self.sessions.flush()?; + Ok(()) + } + + /// Load a ratchet session for a peer. + pub fn load_session(&self, peer: &Fingerprint) -> Result> { + let key = peer.to_hex(); + match self.sessions.get(key.as_bytes())? { + Some(data) => { + let state = bincode::deserialize(&data) + .context("failed to deserialize session")?; + Ok(Some(state)) + } + None => Ok(None), + } + } + + /// Store the signed pre-key secret (for X3DH respond). + pub fn save_signed_pre_key(&self, id: u32, secret: &StaticSecret) -> Result<()> { + let key = format!("spk:{}", id); + self.pre_keys + .insert(key.as_bytes(), secret.to_bytes().as_slice())?; + self.pre_keys.flush()?; + Ok(()) + } + + /// Load the signed pre-key secret. + pub fn load_signed_pre_key(&self, id: u32) -> Result> { + let key = format!("spk:{}", id); + match self.pre_keys.get(key.as_bytes())? { + Some(data) => { + let mut bytes = [0u8; 32]; + bytes.copy_from_slice(&data); + Ok(Some(StaticSecret::from(bytes))) + } + None => Ok(None), + } + } + + /// Store a one-time pre-key secret. + pub fn save_one_time_pre_key(&self, id: u32, secret: &StaticSecret) -> Result<()> { + let key = format!("otpk:{}", id); + self.pre_keys + .insert(key.as_bytes(), secret.to_bytes().as_slice())?; + self.pre_keys.flush()?; + Ok(()) + } + + /// Load and remove a one-time pre-key secret. + pub fn take_one_time_pre_key(&self, id: u32) -> Result> { + let key = format!("otpk:{}", id); + match self.pre_keys.remove(key.as_bytes())? { + Some(data) => { + let mut bytes = [0u8; 32]; + bytes.copy_from_slice(&data); + self.pre_keys.flush()?; + Ok(Some(StaticSecret::from(bytes))) + } + None => Ok(None), + } + } +}