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

View File

@@ -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<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)
}
}