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:
@@ -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 <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(())
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
pub mod info;
|
||||
pub mod init;
|
||||
pub mod recover;
|
||||
pub mod send;
|
||||
pub mod recv;
|
||||
|
||||
117
warzone/crates/warzone-client/src/cli/recv.rs
Normal file
117
warzone/crates/warzone-client/src/cli/recv.rs
Normal 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(())
|
||||
}
|
||||
91
warzone/crates/warzone-client/src/cli/send.rs
Normal file
91
warzone/crates/warzone-client/src/cli/send.rs
Normal 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(())
|
||||
}
|
||||
Reference in New Issue
Block a user