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 <url>` registers bundle with server
- `warzone send <fp> <msg> -s <url>` 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 <url>` 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) <noreply@anthropic.com>
This commit is contained in:
Siavash Sameni
2026-03-26 21:40:21 +04:00
parent e364f437a2
commit 82f5061aa1
9 changed files with 527 additions and 9 deletions

5
warzone/Cargo.lock generated
View File

@@ -2557,7 +2557,10 @@ version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"argon2", "argon2",
"base64",
"bincode",
"chacha20poly1305", "chacha20poly1305",
"chrono",
"clap", "clap",
"crossterm", "crossterm",
"hex", "hex",
@@ -2570,7 +2573,9 @@ dependencies = [
"tokio", "tokio",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
"uuid",
"warzone-protocol", "warzone-protocol",
"x25519-dalek",
"zeroize", "zeroize",
] ]

View File

@@ -21,3 +21,8 @@ chacha20poly1305.workspace = true
rand.workspace = true rand.workspace = true
zeroize.workspace = true zeroize.workspace = true
hex.workspace = true hex.workspace = true
base64.workspace = true
x25519-dalek.workspace = true
bincode.workspace = true
uuid.workspace = true
chrono.workspace = true

View File

@@ -1,8 +1,14 @@
use anyhow::Result;
use warzone_protocol::identity::Seed; 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::keystore;
use crate::net::ServerClient;
use crate::storage::LocalDb;
pub fn run() -> anyhow::Result<()> { pub fn run() -> Result<()> {
let seed = Seed::generate(); let seed = Seed::generate();
let identity = seed.derive_identity(); let identity = seed.derive_identity();
let pub_id = identity.public_identity(); let pub_id = identity.public_identity();
@@ -23,5 +29,70 @@ pub fn run() -> anyhow::Result<()> {
keystore::save_seed(&seed)?; keystore::save_seed(&seed)?;
println!("Seed saved to ~/.warzone/identity.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 <recipient-fingerprint> <message> -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(()) Ok(())
} }

View File

@@ -1,3 +1,5 @@
pub mod info; pub mod info;
pub mod init; pub mod init;
pub mod recover; pub mod recover;
pub mod send;
pub mod recv;

View File

@@ -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::<WireMessage>(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(())
}

View File

@@ -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<u32>,
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(())
}

View File

@@ -15,7 +15,7 @@ struct Cli {
#[derive(Subcommand)] #[derive(Subcommand)]
enum Commands { enum Commands {
/// Generate a new identity (seed + keypair) /// Generate a new identity (seed + keypair + pre-keys)
Init, Init,
/// Recover identity from BIP39 mnemonic /// Recover identity from BIP39 mnemonic
Recover { Recover {
@@ -25,6 +25,12 @@ enum Commands {
}, },
/// Show your fingerprint and public key /// Show your fingerprint and public key
Info, 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 an encrypted message
Send { Send {
/// Recipient fingerprint (e.g. a3f8:c912:44be:7d01) /// 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(); let cli = Cli::parse();
match cli.command { match cli.command {
Commands::Init => cli::init::run()?, Commands::Init => cli::init::run()?,
Commands::Recover { words } => cli::recover::run(&words.join(" "))?, Commands::Recover { words } => cli::recover::run(&words.join(" "))?,
Commands::Info => cli::info::run()?, Commands::Info => cli::info::run()?,
Commands::Register { server } => {
cli::init::register_with_server(&server).await?;
}
Commands::Send { Commands::Send {
recipient, recipient,
message, message,
server, 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 } => { Commands::Recv { server } => {
println!("TODO: poll messages from {}", server); cli::recv::run(&server).await?;
} }
Commands::Chat { server } => { Commands::Chat { server } => {
println!("TODO: launch TUI connected to {}", server); println!("TODO: launch TUI connected to {}", server);

View File

@@ -1,2 +1,123 @@
// HTTP client for talking to warzone-server. //! HTTP client for talking to warzone-server.
// TODO: implement in Phase 1 step 9.
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<u8>,
}
#[derive(Serialize)]
struct SendRequest {
to: String,
message: Vec<u8>,
}
#[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<PreKeyBundle> {
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<Vec<Vec<u8>>> {
let resp: Vec<String> = 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)
}
}

View File

@@ -1,2 +1,94 @@
// Local sled database: sessions, contacts, message history. //! Local sled database: sessions, pre-keys, message history.
// TODO: implement in Phase 1 step 9.
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<Self> {
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<Option<RatchetState>> {
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<Option<StaticSecret>> {
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<Option<StaticSecret>> {
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),
}
}
}