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>
118 lines
4.3 KiB
Rust
118 lines
4.3 KiB
Rust
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(())
|
|
}
|