Compare commits
2 Commits
e364f437a2
...
60a7006ed9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
60a7006ed9 | ||
|
|
82f5061aa1 |
5
warzone/Cargo.lock
generated
5
warzone/Cargo.lock
generated
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
507
warzone/docs/CLIENT.md
Normal file
507
warzone/docs/CLIENT.md
Normal file
@@ -0,0 +1,507 @@
|
||||
# Warzone Client -- Operation Guide
|
||||
|
||||
---
|
||||
|
||||
## 1. Installation
|
||||
|
||||
### Build from Source
|
||||
|
||||
Requires Rust 1.75+.
|
||||
|
||||
```bash
|
||||
cd warzone/
|
||||
cargo build -p warzone-client --release
|
||||
```
|
||||
|
||||
The binary is at `target/release/warzone`. You can copy it anywhere or add
|
||||
`target/release` to your `PATH`.
|
||||
|
||||
```bash
|
||||
# Optional: install to ~/.cargo/bin
|
||||
cargo install --path crates/warzone-client
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Quick Start
|
||||
|
||||
```bash
|
||||
# 1. Generate a new identity
|
||||
warzone init
|
||||
|
||||
# 2. Register your key bundle with a server
|
||||
warzone register -s http://wz.example.com:7700
|
||||
|
||||
# 3. Send an encrypted message
|
||||
warzone send a3f8:c912:44be:7d01 "Hello from Warzone" -s http://wz.example.com:7700
|
||||
|
||||
# 4. Poll for incoming messages
|
||||
warzone recv -s http://wz.example.com:7700
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. CLI Commands
|
||||
|
||||
### warzone init
|
||||
|
||||
Generate a new identity (seed, keypair, and pre-keys).
|
||||
|
||||
```bash
|
||||
$ warzone init
|
||||
Identity generated!
|
||||
|
||||
Fingerprint: b7d1:e845:0022:9f3a
|
||||
|
||||
Recovery mnemonic (WRITE THIS DOWN):
|
||||
|
||||
1. abandon 2. ability 3. able 4. about
|
||||
5. above 6. absent 7. absorb 8. abstract
|
||||
9. absurd 10. abuse 11. access 12. accident
|
||||
13. account 14. accuse 15. achieve 16. acid
|
||||
17. acoustic 18. acquire 19. across 20. act
|
||||
21. action 22. actor 23. actress 24. actual
|
||||
|
||||
Seed saved to ~/.warzone/identity.seed
|
||||
Generated 1 signed pre-key + 10 one-time pre-keys
|
||||
|
||||
To register with a server, run:
|
||||
warzone send <recipient-fingerprint> <message> -s http://server:7700
|
||||
|
||||
Or register your key bundle manually:
|
||||
(bundle auto-registered on first send)
|
||||
```
|
||||
|
||||
**What happens:**
|
||||
1. Generates 32 random bytes (seed) from `OsRng`.
|
||||
2. Derives Ed25519 signing key and X25519 encryption key from the seed.
|
||||
3. Converts seed to a 24-word BIP39 mnemonic and displays it.
|
||||
4. Saves the raw seed to `~/.warzone/identity.seed` (mode 0600 on Unix).
|
||||
5. Generates 1 signed pre-key (id=1) and 10 one-time pre-keys (ids 0-9).
|
||||
6. Stores pre-key secrets in the local sled database at `~/.warzone/db/`.
|
||||
7. Saves the public pre-key bundle to `~/.warzone/bundle.bin`.
|
||||
|
||||
---
|
||||
|
||||
### warzone recover \<words...\>
|
||||
|
||||
Recover an identity from a BIP39 mnemonic.
|
||||
|
||||
```bash
|
||||
$ warzone recover abandon ability able about above absent absorb abstract \
|
||||
absurd abuse access accident account accuse achieve acid \
|
||||
acoustic acquire across act action actor actress actual
|
||||
Identity recovered!
|
||||
Fingerprint: b7d1:e845:0022:9f3a
|
||||
Seed saved to ~/.warzone/identity.seed
|
||||
```
|
||||
|
||||
**Note:** recovery restores the seed and keypair but does NOT restore
|
||||
pre-keys or sessions. You will need to run `warzone init`-style pre-key
|
||||
generation separately or your contacts will need to re-establish sessions.
|
||||
|
||||
---
|
||||
|
||||
### warzone info
|
||||
|
||||
Display your fingerprint and public keys.
|
||||
|
||||
```bash
|
||||
$ warzone info
|
||||
Fingerprint: b7d1:e845:0022:9f3a
|
||||
Signing key: 3a7c... (64 hex chars)
|
||||
Encryption key: 9d2f... (64 hex chars)
|
||||
```
|
||||
|
||||
Requires a saved identity (`~/.warzone/identity.seed`).
|
||||
|
||||
---
|
||||
|
||||
### warzone register
|
||||
|
||||
Register your pre-key bundle with a server.
|
||||
|
||||
```bash
|
||||
$ warzone register -s http://wz.example.com:7700
|
||||
Bundle registered with http://wz.example.com:7700
|
||||
```
|
||||
|
||||
**Flags:**
|
||||
|
||||
| Flag | Short | Default | Description |
|
||||
|------|-------|---------|-------------|
|
||||
| `--server` | `-s` | `http://localhost:7700` | Server URL |
|
||||
|
||||
This uploads `~/.warzone/bundle.bin` to the server. Registration is also
|
||||
performed automatically on the first `send`.
|
||||
|
||||
---
|
||||
|
||||
### warzone send
|
||||
|
||||
Send an encrypted message to a recipient.
|
||||
|
||||
```bash
|
||||
$ warzone send a3f8:c912:44be:7d01 "Hello, are you safe?" -s http://wz.example.com:7700
|
||||
No existing session. Fetching key bundle for a3f8:c912:44be:7d01...
|
||||
Bundle registered with http://wz.example.com:7700
|
||||
Message sent to a3f8:c912:44be:7d01
|
||||
```
|
||||
|
||||
**Arguments:**
|
||||
|
||||
| Argument | Description |
|
||||
|----------|-------------|
|
||||
| `recipient` | Recipient fingerprint (e.g. `a3f8:c912:44be:7d01`) |
|
||||
| `message` | Message text (quote if it contains spaces) |
|
||||
|
||||
**Flags:**
|
||||
|
||||
| Flag | Short | Default | Description |
|
||||
|------|-------|---------|-------------|
|
||||
| `--server` | `-s` | `http://localhost:7700` | Server URL |
|
||||
|
||||
**Behavior:**
|
||||
1. Auto-registers your bundle with the server (if not already done).
|
||||
2. Checks for an existing Double Ratchet session with the recipient.
|
||||
3. If no session exists:
|
||||
- Fetches recipient's pre-key bundle from the server.
|
||||
- Verifies the signed pre-key signature.
|
||||
- Performs X3DH key exchange.
|
||||
- Initializes the Double Ratchet as Alice (initiator).
|
||||
- Sends a `WireMessage::KeyExchange` containing the X3DH parameters
|
||||
and the first encrypted message.
|
||||
4. If a session exists:
|
||||
- Encrypts using the existing ratchet.
|
||||
- Sends a `WireMessage::Message`.
|
||||
5. Updates the local session state.
|
||||
|
||||
---
|
||||
|
||||
### warzone recv
|
||||
|
||||
Poll for and decrypt incoming messages.
|
||||
|
||||
```bash
|
||||
$ warzone recv -s http://wz.example.com:7700
|
||||
Polling for messages as b7d1:e845:0022:9f3a...
|
||||
Received 2 message(s):
|
||||
|
||||
[new session] a3f8:c912:44be:7d01: Hello, are you safe?
|
||||
a3f8:c912:44be:7d01: I'm sending supplies tomorrow.
|
||||
```
|
||||
|
||||
**Flags:**
|
||||
|
||||
| Flag | Short | Default | Description |
|
||||
|------|-------|---------|-------------|
|
||||
| `--server` | `-s` | `http://localhost:7700` | Server URL |
|
||||
|
||||
**Behavior:**
|
||||
1. Polls `/v1/messages/poll/{our_fingerprint}`.
|
||||
2. For each message:
|
||||
- Deserializes the `WireMessage` from bincode.
|
||||
- **KeyExchange:** loads signed pre-key secret and (if applicable)
|
||||
one-time pre-key secret from local storage, performs X3DH respond,
|
||||
initializes ratchet as Bob, decrypts the message, and saves the session.
|
||||
- **Message:** loads existing session, decrypts with the ratchet, saves
|
||||
updated session state.
|
||||
3. Prints decrypted messages to stdout.
|
||||
|
||||
**Note:** messages are currently NOT acknowledged after polling. They will
|
||||
be returned again on the next poll. Acknowledgment is TODO.
|
||||
|
||||
---
|
||||
|
||||
### warzone chat
|
||||
|
||||
Launch the interactive TUI.
|
||||
|
||||
```bash
|
||||
$ warzone chat -s http://wz.example.com:7700
|
||||
TODO: launch TUI connected to http://wz.example.com:7700
|
||||
```
|
||||
|
||||
**Status:** not yet implemented. The TUI will use `ratatui` and `crossterm`
|
||||
(dependencies are already in `Cargo.toml`). Planned for Phase 2.
|
||||
|
||||
---
|
||||
|
||||
## 4. Identity Management
|
||||
|
||||
### Storage Layout
|
||||
|
||||
```
|
||||
~/.warzone/
|
||||
identity.seed # 32-byte raw seed (plaintext -- encryption is TODO)
|
||||
bundle.bin # bincode-serialized PreKeyBundle (public data)
|
||||
db/ # sled database directory
|
||||
sessions/ # Double Ratchet state per peer
|
||||
pre_keys/ # signed and one-time pre-key secrets
|
||||
```
|
||||
|
||||
### File Permissions
|
||||
|
||||
On Unix, `identity.seed` is created with mode `0600` (owner read/write only).
|
||||
The sled database directory inherits default permissions.
|
||||
|
||||
### Seed Security
|
||||
|
||||
**Current state:** the seed is stored as **plaintext** 32 bytes. This is a
|
||||
known Phase 1 limitation.
|
||||
|
||||
**Planned (Phase 2):** encrypt the seed at rest using:
|
||||
- Passphrase input at startup
|
||||
- Argon2id key derivation from passphrase
|
||||
- ChaCha20-Poly1305 encryption of the seed bytes
|
||||
|
||||
### Mnemonic Backup
|
||||
|
||||
The 24-word BIP39 mnemonic shown during `init` is the ONLY way to recover
|
||||
your identity if you lose `~/.warzone/`. Write it down on paper and store it
|
||||
securely.
|
||||
|
||||
The mnemonic is displayed once at generation time and can be recovered from
|
||||
the seed using the protocol library, but the CLI does not currently expose a
|
||||
"show mnemonic" command.
|
||||
|
||||
### Recovery
|
||||
|
||||
```bash
|
||||
warzone recover word1 word2 word3 ... word24
|
||||
```
|
||||
|
||||
This recreates `~/.warzone/identity.seed` with the same seed. The same
|
||||
fingerprint and keypairs are derived deterministically. However:
|
||||
|
||||
- Pre-keys are NOT regenerated. Run `warzone init` on a fresh directory to
|
||||
generate new pre-keys (this will also generate a new seed, so you would need
|
||||
to coordinate).
|
||||
- Sessions are NOT recovered. All contacts will need to establish new sessions.
|
||||
|
||||
**TODO:** a `recover` flow that also regenerates pre-keys without creating a
|
||||
new seed.
|
||||
|
||||
---
|
||||
|
||||
## 5. Web Client
|
||||
|
||||
The web client is served by the server at `/`. Open it in a browser:
|
||||
|
||||
```
|
||||
http://localhost:7700/
|
||||
```
|
||||
|
||||
### Features
|
||||
|
||||
- **Generate New Identity:** creates a random 32-byte seed in the browser.
|
||||
- **Recover from Mnemonic:** paste a hex-encoded seed (not BIP39 words;
|
||||
hex encoding is used as a placeholder).
|
||||
- **Chat interface:** dark-themed monospace UI with message display.
|
||||
- **Commands:**
|
||||
- `/help` -- show available commands
|
||||
- `/info` -- show your fingerprint
|
||||
- `/seed` -- display your seed (hex-encoded)
|
||||
|
||||
### How It Works
|
||||
|
||||
1. Seed is generated with `crypto.getRandomValues(32)`.
|
||||
2. ECDH P-256 keypair is derived (not X25519 -- Web Crypto limitation).
|
||||
3. Fingerprint is `SHA-256(ECDH_public_key)[0..16]` formatted as 4 hex
|
||||
groups.
|
||||
4. Seed is saved in `localStorage` under key `wz-seed`.
|
||||
5. On page load, the client tries to auto-load a saved seed.
|
||||
6. Public key is registered with the server via `POST /v1/keys/register`.
|
||||
7. Messages are polled every 5 seconds from `/v1/messages/poll/{fingerprint}`.
|
||||
|
||||
### Limitations
|
||||
|
||||
- **No cross-client compatibility:** the web client uses P-256 while the CLI
|
||||
uses X25519/Ed25519. Messages between the two cannot be decrypted. This
|
||||
will be resolved in Phase 2 (WASM port of the protocol library).
|
||||
- **No Double Ratchet:** message decryption is not implemented in JS.
|
||||
Received messages display as `[encrypted message]`.
|
||||
- **No BIP39:** seed is shown as hex bytes, not mnemonic words.
|
||||
- **Unencrypted seed storage:** `localStorage` is accessible to any JS on
|
||||
the same origin.
|
||||
|
||||
---
|
||||
|
||||
## 6. Session Management
|
||||
|
||||
### How Sessions Work
|
||||
|
||||
A "session" is a Double Ratchet state between you and one peer, identified
|
||||
by their fingerprint.
|
||||
|
||||
1. **First message to a peer:** X3DH key exchange establishes a shared secret.
|
||||
The ratchet is initialized. The session is saved in `~/.warzone/db/`
|
||||
under the `sessions` tree, keyed by the peer's fingerprint (hex-encoded).
|
||||
|
||||
2. **Subsequent messages:** the ratchet state is loaded, used to encrypt or
|
||||
decrypt, then saved back.
|
||||
|
||||
3. **Bidirectional:** both parties maintain the same session. When Bob
|
||||
receives Alice's KeyExchange, he initializes his side of the ratchet. From
|
||||
then on, both use `WireMessage::Message`.
|
||||
|
||||
### Session Storage
|
||||
|
||||
Sessions are serialized with `bincode` and stored in the `sessions` sled
|
||||
tree. The key is the peer's 32-character hex fingerprint.
|
||||
|
||||
### Session Reset
|
||||
|
||||
There is currently no command to reset a session. If a session becomes
|
||||
corrupted or out of sync:
|
||||
|
||||
1. Delete the local database: `rm -rf ~/.warzone/db/`
|
||||
2. Re-run `warzone init` to generate new pre-keys.
|
||||
3. Re-register with the server.
|
||||
4. Your contact must also reset their session with you.
|
||||
|
||||
**TODO (Phase 2):** a `warzone reset-session <fingerprint>` command.
|
||||
|
||||
---
|
||||
|
||||
## 7. Pre-Key Management
|
||||
|
||||
### What Are Pre-Keys
|
||||
|
||||
Pre-keys enable asynchronous session establishment. When Alice wants to
|
||||
message Bob for the first time:
|
||||
|
||||
1. Alice fetches Bob's **pre-key bundle** from the server.
|
||||
2. The bundle contains Bob's public identity key, a signed pre-key, and
|
||||
optionally a one-time pre-key.
|
||||
3. Alice uses these to perform X3DH without Bob being online.
|
||||
|
||||
### Pre-Key Types
|
||||
|
||||
| Type | Quantity | Lifetime | Purpose |
|
||||
|------|----------|----------|---------|
|
||||
| Signed pre-key | 1 (id=1) | Long-term (no rotation yet) | Medium-term DH key, signed by identity |
|
||||
| One-time pre-keys | 10 (ids 0-9) | Single use | Consumed during X3DH, then deleted |
|
||||
|
||||
### When to Replenish
|
||||
|
||||
One-time pre-keys are consumed when someone initiates a session with you.
|
||||
After all 10 are used, X3DH falls back to using only the signed pre-key
|
||||
(DH4 is skipped), which provides slightly weaker security properties.
|
||||
|
||||
**Current state:** there is no automatic replenishment. You must manually
|
||||
re-initialize if you expect many incoming new sessions.
|
||||
|
||||
**TODO (Phase 2):** the server will notify the client when one-time pre-key
|
||||
supply is low, and the client will upload fresh ones automatically.
|
||||
|
||||
---
|
||||
|
||||
## 8. Security Model
|
||||
|
||||
### What Is Encrypted
|
||||
|
||||
- **Message body:** encrypted with ChaCha20-Poly1305 using per-message keys
|
||||
from the Double Ratchet. Even the server cannot read it.
|
||||
|
||||
### What Is NOT Encrypted
|
||||
|
||||
- **Sender fingerprint:** visible to the server and anyone intercepting
|
||||
traffic.
|
||||
- **Recipient fingerprint:** visible to the server (needed for routing).
|
||||
- **Message size:** visible to the server.
|
||||
- **Timing:** when messages are sent and received.
|
||||
- **IP addresses:** visible to the server and network observers.
|
||||
- **Seed on disk:** stored as plaintext (encryption TODO).
|
||||
|
||||
### Threat Model
|
||||
|
||||
| Threat | Protected? | Notes |
|
||||
|--------|-----------|-------|
|
||||
| Server reads messages | Yes | E2E encryption; server sees only ciphertext |
|
||||
| Network eavesdropper reads messages | Yes | E2E encryption |
|
||||
| Server impersonates a user | Partially | Pre-key signatures prevent forgery of signed pre-keys, but the server could substitute a fake bundle (no key transparency yet) |
|
||||
| Compromised past session key | Yes | Forward secrecy via chain ratchet; break-in recovery via DH ratchet |
|
||||
| Stolen device (seed file) | No | Seed is plaintext on disk (encryption TODO) |
|
||||
| Metadata analysis (who talks to whom) | No | Fingerprints visible to server |
|
||||
| Active MITM on first contact | Partially | TOFU model; no out-of-band verification mechanism in the client yet |
|
||||
| One-time pre-keys exhausted | Graceful degradation | X3DH works without OT pre-keys but with reduced replay protection |
|
||||
|
||||
### Trust Model
|
||||
|
||||
**Trust on first use (TOFU):** the first time you message someone, you trust
|
||||
that the server returns their genuine pre-key bundle. There is no
|
||||
verification step yet.
|
||||
|
||||
**Planned (Phase 3):** DNS-based key transparency where users publish
|
||||
self-signed public keys in DNS TXT records, allowing cross-verification
|
||||
independent of the server.
|
||||
|
||||
---
|
||||
|
||||
## 9. Troubleshooting
|
||||
|
||||
### "No identity found. Run `warzone init` first."
|
||||
|
||||
You haven't generated an identity, or `~/.warzone/identity.seed` is missing.
|
||||
|
||||
```bash
|
||||
warzone init
|
||||
```
|
||||
|
||||
### "No bundle found. Run `warzone init` first."
|
||||
|
||||
The pre-key bundle file `~/.warzone/bundle.bin` is missing. This happens if
|
||||
you ran `recover` without a full `init`.
|
||||
|
||||
Re-run `warzone init` (this will generate a NEW identity). To keep your
|
||||
recovered identity, you would need to manually regenerate pre-keys (not yet
|
||||
supported as a standalone command).
|
||||
|
||||
### "failed to fetch recipient's bundle. Are they registered?"
|
||||
|
||||
The recipient has not registered their pre-key bundle with the server, or
|
||||
you are using the wrong server URL, or the fingerprint is incorrect.
|
||||
|
||||
- Verify the fingerprint (ask the recipient for theirs via `warzone info`).
|
||||
- Verify the server URL.
|
||||
- Ask the recipient to run `warzone register -s <server>`.
|
||||
|
||||
### "X3DH respond failed" / "missing signed pre-key"
|
||||
|
||||
Your signed pre-key secret is missing from the local database. This can
|
||||
happen if:
|
||||
- The database was deleted or corrupted.
|
||||
- You recovered an identity but did not regenerate pre-keys.
|
||||
|
||||
Fix: re-initialize with `warzone init` (generates a new identity) or restore
|
||||
from backup.
|
||||
|
||||
### "decrypt failed" / "no session"
|
||||
|
||||
- **"no session"**: you received a `WireMessage::Message` from someone you
|
||||
have no session with. This means you missed their initial `KeyExchange`
|
||||
message, or your session database was lost. Ask them to re-send their first
|
||||
message.
|
||||
- **"decrypt failed"**: the ratchet state is out of sync. This can happen if
|
||||
one side's state was lost or if messages were duplicated. Reset the session
|
||||
on both sides.
|
||||
|
||||
### Messages Keep Reappearing on recv
|
||||
|
||||
Messages are not auto-acknowledged after polling. This is a known Phase 1
|
||||
limitation. The same messages will be returned on every `recv` call.
|
||||
|
||||
**Workaround:** none currently. Acknowledgment will be added in Phase 2.
|
||||
|
||||
### Corrupted Database
|
||||
|
||||
If `~/.warzone/db/` is corrupted:
|
||||
|
||||
```bash
|
||||
rm -rf ~/.warzone/db/
|
||||
warzone init # regenerate pre-keys (NOTE: generates a new identity)
|
||||
```
|
||||
|
||||
To keep your existing identity, manually copy `identity.seed` before
|
||||
deleting, then use `warzone recover` after re-init.
|
||||
520
warzone/docs/PROTOCOL.md
Normal file
520
warzone/docs/PROTOCOL.md
Normal file
@@ -0,0 +1,520 @@
|
||||
# Warzone Protocol Specification
|
||||
|
||||
This document describes the cryptographic protocol used by Warzone messenger
|
||||
as currently implemented in the `warzone-protocol` crate.
|
||||
|
||||
---
|
||||
|
||||
## 1. Identity Model
|
||||
|
||||
### Seed-Based Identity
|
||||
|
||||
Every identity begins with a **seed**: 32 cryptographically random bytes
|
||||
generated from `OsRng`.
|
||||
|
||||
```
|
||||
seed (32 bytes, from OsRng)
|
||||
|
|
||||
+-- HKDF-SHA256(seed, info="warzone-ed25519") --> Ed25519 signing keypair
|
||||
|
|
||||
+-- HKDF-SHA256(seed, info="warzone-x25519") --> X25519 encryption keypair
|
||||
```
|
||||
|
||||
The seed is the single root secret. Both key derivations use HKDF with an
|
||||
empty salt and distinct `info` strings for domain separation.
|
||||
|
||||
### Key Types
|
||||
|
||||
| Key | Algorithm | Purpose |
|
||||
|-----|-----------|---------|
|
||||
| Signing keypair | Ed25519 (via `ed25519-dalek`) | Signs pre-keys, proves identity |
|
||||
| Encryption keypair | X25519 (via `x25519-dalek`) | Diffie-Hellman key exchange |
|
||||
|
||||
### Fingerprint
|
||||
|
||||
The fingerprint is the primary user identifier. It is computed as:
|
||||
|
||||
```
|
||||
fingerprint = SHA-256(Ed25519_public_key)[0..16] // first 16 bytes
|
||||
```
|
||||
|
||||
Displayed as four colon-separated groups of 4 hex digits (8 bytes / 64 bits
|
||||
of the 128-bit fingerprint):
|
||||
|
||||
```
|
||||
a3f8:c912:44be:7d01
|
||||
```
|
||||
|
||||
Note: the `Display` implementation uses only the first 8 bytes (4 groups of
|
||||
`u16`). The full 16 bytes are stored internally and used for session keying
|
||||
and lookups. The `from_hex` parser strips colons and decodes all 16 bytes.
|
||||
|
||||
### BIP39 Mnemonic
|
||||
|
||||
The 32-byte seed is presented to users as a 24-word BIP39 mnemonic for
|
||||
human-readable backup. Recovery works by converting the mnemonic back to 32
|
||||
bytes and re-deriving the same keypairs deterministically.
|
||||
|
||||
### PublicIdentity
|
||||
|
||||
The shareable portion of an identity:
|
||||
|
||||
```rust
|
||||
pub struct PublicIdentity {
|
||||
pub signing: VerifyingKey, // Ed25519 public key (32 bytes)
|
||||
pub encryption: PublicKey, // X25519 public key (32 bytes)
|
||||
pub fingerprint: Fingerprint, // SHA-256(signing)[0..16]
|
||||
}
|
||||
```
|
||||
|
||||
Serialized with serde; the dalek types use raw-bytes serialization.
|
||||
|
||||
---
|
||||
|
||||
## 2. Pre-Key Bundles
|
||||
|
||||
Pre-key bundles enable asynchronous key exchange (the recipient does not need
|
||||
to be online when the sender initiates a session).
|
||||
|
||||
### Signed Pre-Key
|
||||
|
||||
A medium-term X25519 keypair signed by the identity Ed25519 key:
|
||||
|
||||
```rust
|
||||
pub struct SignedPreKey {
|
||||
pub id: u32,
|
||||
pub public_key: [u8; 32], // X25519 public key
|
||||
pub signature: Vec<u8>, // Ed25519 signature over public_key
|
||||
pub timestamp: i64, // unix timestamp of generation
|
||||
}
|
||||
```
|
||||
|
||||
The signature covers `public_key` directly (the raw 32 bytes). Verification
|
||||
uses the identity's Ed25519 verifying key.
|
||||
|
||||
### One-Time Pre-Key
|
||||
|
||||
A single-use X25519 keypair. Each key has a numeric `id`. After a key exchange
|
||||
consumes it, the private half is deleted.
|
||||
|
||||
```rust
|
||||
pub struct OneTimePreKeyPublic {
|
||||
pub id: u32,
|
||||
pub public_key: [u8; 32], // X25519 public key
|
||||
}
|
||||
```
|
||||
|
||||
### Bundle Format
|
||||
|
||||
The complete bundle uploaded to the server:
|
||||
|
||||
```rust
|
||||
pub struct PreKeyBundle {
|
||||
pub identity_key: [u8; 32], // Ed25519 verifying key
|
||||
pub identity_encryption_key: [u8; 32], // X25519 identity public key
|
||||
pub signed_pre_key: SignedPreKey,
|
||||
pub one_time_pre_key: Option<OneTimePreKeyPublic>,
|
||||
}
|
||||
```
|
||||
|
||||
Serialized with `bincode` for the wire and for local storage.
|
||||
|
||||
### Lifecycle
|
||||
|
||||
1. `warzone init` generates 1 signed pre-key (id=1) and 10 one-time pre-keys
|
||||
(ids 0-9).
|
||||
2. Private halves are stored in the local sled database under the `pre_keys`
|
||||
tree (keys: `spk:<id>`, `otpk:<id>`).
|
||||
3. The public bundle is saved to `~/.warzone/bundle.bin`.
|
||||
4. On first `send` (or explicit `register`), the bundle is uploaded to the
|
||||
server.
|
||||
5. When a one-time pre-key is consumed during X3DH, it is atomically removed
|
||||
from local storage (`take_one_time_pre_key`).
|
||||
|
||||
**TODO (Phase 2):** automatic replenishment of one-time pre-keys when supply
|
||||
runs low; signed pre-key rotation on a schedule.
|
||||
|
||||
---
|
||||
|
||||
## 3. X3DH Key Exchange
|
||||
|
||||
The implementation follows Signal's Extended Triple Diffie-Hellman (X3DH)
|
||||
specification.
|
||||
|
||||
### Initiator (Alice)
|
||||
|
||||
Alice fetches Bob's `PreKeyBundle` from the server, then:
|
||||
|
||||
```
|
||||
1. Verify signed_pre_key.signature against identity_key
|
||||
2. Generate ephemeral X25519 keypair (ek)
|
||||
3. Compute four DH values:
|
||||
DH1 = X25519(Alice_identity_x25519, Bob_signed_pre_key)
|
||||
DH2 = X25519(Alice_ephemeral, Bob_identity_x25519)
|
||||
DH3 = X25519(Alice_ephemeral, Bob_signed_pre_key)
|
||||
DH4 = X25519(Alice_ephemeral, Bob_one_time_pre_key) [if present]
|
||||
4. Concatenate: DH1 || DH2 || DH3 [|| DH4]
|
||||
5. shared_secret = HKDF-SHA256(concat, salt="", info="warzone-x3dh", len=32)
|
||||
6. Zeroize the DH concatenation
|
||||
```
|
||||
|
||||
The result includes:
|
||||
- `shared_secret` (32 bytes) -- used to initialize the Double Ratchet
|
||||
- `ephemeral_public` -- sent to Bob
|
||||
- `used_one_time_pre_key_id` -- tells Bob which OT pre-key was consumed
|
||||
|
||||
### Responder (Bob)
|
||||
|
||||
Bob receives Alice's ephemeral public key plus her identity encryption key
|
||||
and computes the same DH operations in the mirror order:
|
||||
|
||||
```
|
||||
DH1 = X25519(Bob_signed_pre_key_secret, Alice_identity_x25519)
|
||||
DH2 = X25519(Bob_identity_x25519, Alice_ephemeral)
|
||||
DH3 = X25519(Bob_signed_pre_key_secret, Alice_ephemeral)
|
||||
DH4 = X25519(Bob_one_time_pre_key, Alice_ephemeral) [if used]
|
||||
```
|
||||
|
||||
The concatenation and HKDF produce the identical `shared_secret`.
|
||||
|
||||
### ASCII Diagram
|
||||
|
||||
```
|
||||
Alice Server Bob
|
||||
| | |
|
||||
|--- fetch Bob's bundle ------>| |
|
||||
|<-- PreKeyBundle -------------| |
|
||||
| | |
|
||||
| [verify SPK signature] | |
|
||||
| [generate ephemeral key] | |
|
||||
| [DH1..DH4 -> HKDF] | |
|
||||
| [init ratchet as Alice] | |
|
||||
| | |
|
||||
|--- WireMessage::KeyExchange -|---> queue for Bob |
|
||||
| (ephemeral_pub, otpk_id, | |
|
||||
| ratchet_message) | |
|
||||
| | |
|
||||
| | Bob polls ----------->|
|
||||
| |<-- WireMessage::KeyExchange |
|
||||
| | |
|
||||
| | [load SPK secret, OT secret]|
|
||||
| | [DH1..DH4 -> HKDF] |
|
||||
| | [init ratchet as Bob] |
|
||||
| | [decrypt first message] |
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Double Ratchet
|
||||
|
||||
The Double Ratchet provides forward secrecy and break-in recovery. The
|
||||
implementation follows Signal's Double Ratchet specification.
|
||||
|
||||
### State
|
||||
|
||||
```rust
|
||||
pub struct RatchetState {
|
||||
dh_self: Vec<u8>, // our current X25519 secret (32 bytes)
|
||||
dh_remote: Option<[u8; 32]>, // their current DH public key
|
||||
root_key: [u8; 32], // root chain key
|
||||
chain_key_send: Option<[u8; 32]>, // sending chain key
|
||||
chain_key_recv: Option<[u8; 32]>, // receiving chain key
|
||||
send_count: u32, // messages sent in current sending chain
|
||||
recv_count: u32, // messages received in current receiving chain
|
||||
prev_send_count: u32, // messages in previous sending chain
|
||||
skipped: BTreeMap<([u8; 32], u32), [u8; 32]>, // cached keys for out-of-order messages
|
||||
}
|
||||
```
|
||||
|
||||
### Initialization
|
||||
|
||||
**Alice (initiator):**
|
||||
1. Receives `shared_secret` from X3DH and Bob's signed pre-key public as the
|
||||
initial remote ratchet key.
|
||||
2. Generates a fresh DH keypair.
|
||||
3. Performs `kdf_rk(shared_secret, DH(new_key, bob_spk))` to produce the
|
||||
first root key and sending chain key.
|
||||
4. No receiving chain yet (waits for Bob's first message).
|
||||
|
||||
**Bob (responder):**
|
||||
1. Receives the same `shared_secret` from X3DH.
|
||||
2. Uses his signed pre-key secret as the initial DH self key.
|
||||
3. Root key = shared_secret. No chain keys yet (waits for Alice's first
|
||||
message to trigger the first DH ratchet step).
|
||||
|
||||
### Sending a Message
|
||||
|
||||
```
|
||||
1. If no sending chain exists, perform a DH ratchet step first
|
||||
2. (new_chain_key, message_key) = kdf_ck(chain_key_send)
|
||||
3. chain_key_send = new_chain_key
|
||||
4. header = RatchetHeader { dh_public, prev_chain_length, message_number }
|
||||
5. aad = bincode::serialize(header)
|
||||
6. ciphertext = AEAD_encrypt(message_key, plaintext, aad)
|
||||
7. send_count += 1
|
||||
8. Return RatchetMessage { header, ciphertext }
|
||||
```
|
||||
|
||||
### Receiving a Message
|
||||
|
||||
```
|
||||
1. Check skipped message cache: if (dh_public, message_number) is cached,
|
||||
use that message key to decrypt and return
|
||||
2. If message.dh_public != dh_remote:
|
||||
a. Skip any missed messages in the current receiving chain
|
||||
b. DH ratchet step:
|
||||
- New receiving chain: kdf_rk(root_key, DH(our_secret, their_new_pub))
|
||||
- New sending chain: kdf_rk(root_key, DH(new_secret, their_new_pub))
|
||||
- Reset counters
|
||||
3. Skip messages up to message_number (cache skipped keys)
|
||||
4. (new_chain_key, message_key) = kdf_ck(chain_key_recv)
|
||||
5. aad = bincode::serialize(header)
|
||||
6. plaintext = AEAD_decrypt(message_key, ciphertext, aad)
|
||||
```
|
||||
|
||||
### Skipped Messages
|
||||
|
||||
When messages arrive out of order, the ratchet fast-forwards the receiving
|
||||
chain and caches the intermediate message keys in `skipped`. A maximum of
|
||||
`MAX_SKIP = 1000` messages can be skipped in one step to prevent resource
|
||||
exhaustion.
|
||||
|
||||
Cached keys are indexed by `(dh_public_key, message_number)` and are consumed
|
||||
(removed from the map) on first use.
|
||||
|
||||
### Message Header
|
||||
|
||||
```rust
|
||||
pub struct RatchetHeader {
|
||||
pub dh_public: [u8; 32], // sender's current DH ratchet public key
|
||||
pub prev_chain_length: u32, // messages in previous sending chain
|
||||
pub message_number: u32, // index in current sending chain
|
||||
}
|
||||
```
|
||||
|
||||
### DH Ratchet Diagram
|
||||
|
||||
```
|
||||
Alice Bob
|
||||
| |
|
||||
| send_chain_0 (from X3DH) |
|
||||
|------- msg 0 (dh_pub_A0) ---------------------->|
|
||||
|------- msg 1 (dh_pub_A0) ---------------------->|
|
||||
| |
|
||||
| recv: new dh_pub_A0 |
|
||||
| DH ratchet step |
|
||||
| send_chain_1 |
|
||||
|<------ msg 0 (dh_pub_B1) -----------------------|
|
||||
| |
|
||||
| recv: new dh_pub_B1 |
|
||||
| DH ratchet step |
|
||||
| send_chain_2 |
|
||||
|------- msg 0 (dh_pub_A2) ---------------------->|
|
||||
| |
|
||||
```
|
||||
|
||||
Each direction change triggers a DH ratchet step, producing new chain keys
|
||||
and providing forward secrecy and break-in recovery.
|
||||
|
||||
---
|
||||
|
||||
## 5. KDF Chains
|
||||
|
||||
All key derivation uses HKDF-SHA256 (via the `hkdf` crate with `sha2`).
|
||||
|
||||
### hkdf_derive
|
||||
|
||||
```rust
|
||||
fn hkdf_derive(ikm: &[u8], salt: &[u8], info: &[u8], len: usize) -> Vec<u8>
|
||||
```
|
||||
|
||||
- Empty salt is treated as `None` (HKDF uses a zero-filled salt internally).
|
||||
- `info` provides domain separation.
|
||||
|
||||
### Domain Separation Strings
|
||||
|
||||
| Context | info string | salt | Input |
|
||||
|---------|-------------|------|-------|
|
||||
| Ed25519 key from seed | `warzone-ed25519` | (empty) | seed |
|
||||
| X25519 key from seed | `warzone-x25519` | (empty) | seed |
|
||||
| X3DH shared secret | `warzone-x3dh` | (empty) | DH1\|\|DH2\|\|DH3[\|\|DH4] |
|
||||
| Root key ratchet | `warzone-ratchet-rk` | root_key | DH output |
|
||||
| Chain key -> message key | `warzone-ratchet-mk` | (empty) | chain_key |
|
||||
| Chain key -> next chain key | `warzone-ratchet-ck` | (empty) | chain_key |
|
||||
|
||||
### Root Key KDF (kdf_rk)
|
||||
|
||||
```
|
||||
derived = HKDF(ikm=dh_output, salt=root_key, info="warzone-ratchet-rk", len=64)
|
||||
new_root_key = derived[0..32]
|
||||
new_chain_key = derived[32..64]
|
||||
```
|
||||
|
||||
### Chain Key KDF (kdf_ck)
|
||||
|
||||
```
|
||||
message_key = HKDF(ikm=chain_key, salt="", info="warzone-ratchet-mk", len=32)
|
||||
new_chain_key = HKDF(ikm=chain_key, salt="", info="warzone-ratchet-ck", len=32)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. AEAD Encryption
|
||||
|
||||
All symmetric encryption uses **ChaCha20-Poly1305** (via the
|
||||
`chacha20poly1305` crate).
|
||||
|
||||
### Encrypt
|
||||
|
||||
```
|
||||
1. Generate 12-byte random nonce from OsRng
|
||||
2. ciphertext = ChaCha20-Poly1305(key, nonce, plaintext, aad)
|
||||
3. Output = nonce (12 bytes) || ciphertext (includes 16-byte Poly1305 tag)
|
||||
```
|
||||
|
||||
### Decrypt
|
||||
|
||||
```
|
||||
1. Split input: first 12 bytes = nonce, remainder = ciphertext+tag
|
||||
2. plaintext = ChaCha20-Poly1305_decrypt(key, nonce, ciphertext, aad)
|
||||
```
|
||||
|
||||
### Associated Data
|
||||
|
||||
For ratchet messages, the AAD is the `bincode`-serialized `RatchetHeader`.
|
||||
This binds the ciphertext to the specific ratchet position and prevents
|
||||
header manipulation.
|
||||
|
||||
---
|
||||
|
||||
## 7. Wire Format
|
||||
|
||||
### WireMessage Enum
|
||||
|
||||
The top-level wire format is a `bincode`-serialized enum:
|
||||
|
||||
```rust
|
||||
pub enum WireMessage {
|
||||
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,
|
||||
},
|
||||
Message {
|
||||
sender_fingerprint: String,
|
||||
ratchet_message: RatchetMessage,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
**KeyExchange** is sent as the first message in a new session. It carries the
|
||||
X3DH parameters that the recipient needs to derive the shared secret and
|
||||
establish the ratchet.
|
||||
|
||||
**Message** is sent for all subsequent messages in an established session.
|
||||
|
||||
### RatchetMessage
|
||||
|
||||
```rust
|
||||
pub struct RatchetMessage {
|
||||
pub header: RatchetHeader, // DH public key, counters
|
||||
pub ciphertext: Vec<u8>, // nonce || ChaCha20-Poly1305 ciphertext
|
||||
}
|
||||
```
|
||||
|
||||
### WarzoneMessage (Defined But Not Yet Used on Wire)
|
||||
|
||||
The `message.rs` module defines a higher-level envelope:
|
||||
|
||||
```rust
|
||||
pub struct WarzoneMessage {
|
||||
pub version: u8,
|
||||
pub id: MessageId,
|
||||
pub from: Fingerprint,
|
||||
pub to: Fingerprint,
|
||||
pub timestamp: i64,
|
||||
pub msg_type: MessageType, // Text, File, KeyExchange, Receipt
|
||||
pub session_id: SessionId,
|
||||
pub ratchet_header: RatchetHeader,
|
||||
pub ciphertext: Vec<u8>,
|
||||
pub signature: Vec<u8>, // Ed25519 signature
|
||||
}
|
||||
```
|
||||
|
||||
**Status:** this struct is defined but the current send/recv flow uses the
|
||||
simpler `WireMessage` enum directly. The `WarzoneMessage` envelope with
|
||||
signatures, message IDs, and session tracking will be integrated in Phase 2.
|
||||
|
||||
### MessageContent (Plaintext, Inside Envelope)
|
||||
|
||||
```rust
|
||||
pub enum MessageContent {
|
||||
Text { body: String },
|
||||
File { filename: String, data: Vec<u8> },
|
||||
Receipt { message_id: MessageId },
|
||||
}
|
||||
```
|
||||
|
||||
**Status:** not yet used. Currently, raw UTF-8 bytes are encrypted directly.
|
||||
Structured content types will be used in Phase 2.
|
||||
|
||||
### Serialization
|
||||
|
||||
- **Wire (client <-> server):** `bincode` for `WireMessage` and
|
||||
`PreKeyBundle`. The server stores raw bincode blobs.
|
||||
- **Server API:** JSON for request/response wrappers. Binary payloads are
|
||||
base64-encoded within JSON.
|
||||
- **Local storage:** `bincode` for `RatchetState` and pre-key secrets in the
|
||||
sled database.
|
||||
|
||||
---
|
||||
|
||||
## 8. Transport
|
||||
|
||||
### Current Transport
|
||||
|
||||
HTTP POST/GET over TCP via `reqwest` (client) and `axum` (server). No TLS in
|
||||
the current implementation; TLS is expected to be provided by a reverse proxy.
|
||||
|
||||
Messages are delivered via polling: the client periodically GETs
|
||||
`/v1/messages/poll/{fingerprint}`.
|
||||
|
||||
### Future Transports (Phase 2+)
|
||||
|
||||
- WebSocket for real-time push
|
||||
- Server-to-server federation (Phase 3)
|
||||
- Bluetooth, LoRa, Wi-Fi Direct, USB sneakernet (Phase 4-5)
|
||||
|
||||
---
|
||||
|
||||
## 9. Security Properties
|
||||
|
||||
### What Is Achieved (Phase 1)
|
||||
|
||||
- **Confidentiality:** messages are encrypted with ChaCha20-Poly1305 using
|
||||
per-message keys derived from the Double Ratchet.
|
||||
- **Forward secrecy:** compromising the current ratchet state does not reveal
|
||||
past message keys (chain ratchet is one-way).
|
||||
- **Break-in recovery:** after a DH ratchet step, a compromised state becomes
|
||||
useless for future messages.
|
||||
- **Asynchronous key exchange:** X3DH allows session establishment without
|
||||
both parties being online simultaneously.
|
||||
- **Out-of-order tolerance:** skipped message keys are cached (up to 1000).
|
||||
- **Server learns nothing:** the server stores and forwards opaque bincode
|
||||
blobs. It never sees plaintext.
|
||||
|
||||
### What Is NOT Yet Implemented
|
||||
|
||||
- **Message signing:** `WarzoneMessage.signature` is defined but not populated.
|
||||
Currently, messages are not authenticated by Ed25519 signature. (Phase 2)
|
||||
- **Sealed sender:** the server can see sender and recipient fingerprints in
|
||||
the clear. (Phase 6)
|
||||
- **Key transparency:** no DNS-based verification of public keys. (Phase 3)
|
||||
- **Seed encryption at rest:** the seed file is stored as plaintext 32 bytes.
|
||||
Argon2 + ChaCha20-Poly1305 encryption is TODO.
|
||||
- **Pre-key replenishment:** one-time pre-keys are not automatically
|
||||
replenished after consumption.
|
||||
- **Message deduplication:** no dedup on the server or client.
|
||||
- **Group encryption:** Sender Keys not yet implemented. (Phase 2)
|
||||
429
warzone/docs/SERVER.md
Normal file
429
warzone/docs/SERVER.md
Normal file
@@ -0,0 +1,429 @@
|
||||
# Warzone Server -- Operation & Administration
|
||||
|
||||
---
|
||||
|
||||
## 1. Building
|
||||
|
||||
The server is part of the Cargo workspace. From the workspace root:
|
||||
|
||||
```bash
|
||||
# Debug build
|
||||
cargo build -p warzone-server
|
||||
|
||||
# Release build (recommended for deployment)
|
||||
cargo build -p warzone-server --release
|
||||
```
|
||||
|
||||
The resulting binary is at `target/release/warzone-server` (or
|
||||
`target/debug/warzone-server`). It is a single statically-linked binary with
|
||||
no runtime dependencies beyond libc.
|
||||
|
||||
### Minimum Rust Version
|
||||
|
||||
Rust 1.75 or later (set via `rust-version = "1.75"` in `Cargo.toml`).
|
||||
|
||||
---
|
||||
|
||||
## 2. Running
|
||||
|
||||
```bash
|
||||
# Default: bind 0.0.0.0:7700, data in ./warzone-data
|
||||
./warzone-server
|
||||
|
||||
# Custom bind address and data directory
|
||||
./warzone-server --bind 127.0.0.1:8080 --data-dir /var/lib/warzone
|
||||
```
|
||||
|
||||
### CLI Flags
|
||||
|
||||
| Flag | Short | Default | Description |
|
||||
|------|-------|---------|-------------|
|
||||
| `--bind` | `-b` | `0.0.0.0:7700` | Address and port to listen on |
|
||||
| `--data-dir` | `-d` | `./warzone-data` | Directory for sled database files |
|
||||
|
||||
### Logging
|
||||
|
||||
The server uses `tracing-subscriber`. Control log level with the `RUST_LOG`
|
||||
environment variable:
|
||||
|
||||
```bash
|
||||
RUST_LOG=info ./warzone-server
|
||||
RUST_LOG=warzone_server=debug ./warzone-server
|
||||
RUST_LOG=trace ./warzone-server # very verbose
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. API Reference
|
||||
|
||||
All API endpoints are under the `/v1` prefix. The web UI is served at `/`.
|
||||
|
||||
### Health Check
|
||||
|
||||
```
|
||||
GET /v1/health
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"version": "0.1.0"
|
||||
}
|
||||
```
|
||||
|
||||
Use this for monitoring, load balancer health probes, and uptime checks.
|
||||
|
||||
---
|
||||
|
||||
### Register Key Bundle
|
||||
|
||||
```
|
||||
POST /v1/keys/register
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
**Request body:**
|
||||
```json
|
||||
{
|
||||
"fingerprint": "a3f8:c912:44be:7d01",
|
||||
"bundle": [/* bincode-serialized PreKeyBundle as byte array */]
|
||||
}
|
||||
```
|
||||
|
||||
The `bundle` field is a JSON array of unsigned bytes (the raw bincode
|
||||
serialization of a `PreKeyBundle`).
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"ok": true
|
||||
}
|
||||
```
|
||||
|
||||
**Behavior:** stores the bundle in the `keys` sled tree, keyed by the
|
||||
fingerprint string. Overwrites any existing bundle for the same fingerprint.
|
||||
|
||||
---
|
||||
|
||||
### Fetch Key Bundle
|
||||
|
||||
```
|
||||
GET /v1/keys/{fingerprint}
|
||||
```
|
||||
|
||||
**Path parameter:** the fingerprint string, e.g. `a3f8:c912:44be:7d01`.
|
||||
|
||||
**Response (200):**
|
||||
```json
|
||||
{
|
||||
"fingerprint": "a3f8:c912:44be:7d01",
|
||||
"bundle": "base64-encoded-bincode-bytes..."
|
||||
}
|
||||
```
|
||||
|
||||
The `bundle` value is standard base64-encoded bincode. The client decodes
|
||||
base64, then deserializes with bincode to recover the `PreKeyBundle`.
|
||||
|
||||
**Response (404):** returned if no bundle is registered for the fingerprint.
|
||||
|
||||
---
|
||||
|
||||
### Send Message
|
||||
|
||||
```
|
||||
POST /v1/messages/send
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
**Request body:**
|
||||
```json
|
||||
{
|
||||
"to": "b7d1:e845:0022:9f3a",
|
||||
"message": [/* bincode-serialized WireMessage as byte array */]
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"ok": true
|
||||
}
|
||||
```
|
||||
|
||||
**Behavior:** the message bytes are stored in the `messages` sled tree under
|
||||
the key `queue:{recipient_fingerprint}:{uuid}`. The UUID is generated
|
||||
server-side to ensure unique keys.
|
||||
|
||||
The server does NOT parse, validate, or inspect the message contents. It is an
|
||||
opaque blob.
|
||||
|
||||
---
|
||||
|
||||
### Poll Messages
|
||||
|
||||
```
|
||||
GET /v1/messages/poll/{fingerprint}
|
||||
```
|
||||
|
||||
**Response (200):**
|
||||
```json
|
||||
[
|
||||
"base64-encoded-message-1",
|
||||
"base64-encoded-message-2"
|
||||
]
|
||||
```
|
||||
|
||||
Returns a JSON array of base64-encoded message blobs. Each blob is a
|
||||
bincode-serialized `WireMessage`. An empty array means no messages.
|
||||
|
||||
**Behavior:** scans the `messages` sled tree for all keys prefixed with
|
||||
`queue:{fingerprint}`. Messages are NOT deleted by polling; they remain until
|
||||
explicitly acknowledged.
|
||||
|
||||
---
|
||||
|
||||
### Acknowledge Message
|
||||
|
||||
```
|
||||
DELETE /v1/messages/{id}/ack
|
||||
```
|
||||
|
||||
**Path parameter:** the message storage key (currently the full sled key
|
||||
including the `queue:` prefix and UUID).
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"ok": true
|
||||
}
|
||||
```
|
||||
|
||||
**Behavior:** removes the message from the `messages` tree.
|
||||
|
||||
**Note:** the current implementation requires knowing the exact sled key to
|
||||
acknowledge. A proper message-ID-based index is planned for Phase 2.
|
||||
|
||||
---
|
||||
|
||||
## 4. Web UI
|
||||
|
||||
The server serves a single-page web client at the root path `/`.
|
||||
|
||||
```
|
||||
GET /
|
||||
```
|
||||
|
||||
Returns an HTML page with embedded CSS and JavaScript. The web client provides:
|
||||
|
||||
- **Identity generation:** generates a random 32-byte seed in the browser
|
||||
using `crypto.getRandomValues()`.
|
||||
- **Identity recovery:** paste a hex-encoded seed to recover.
|
||||
- **Fingerprint display:** shows the user's fingerprint in the header.
|
||||
- **Key registration:** automatically registers a public key with the server
|
||||
on entry.
|
||||
- **Message polling:** polls `/v1/messages/poll/{fingerprint}` every 5 seconds.
|
||||
- **Slash commands:** `/help`, `/info`, `/seed`.
|
||||
|
||||
### Web Client Limitations
|
||||
|
||||
- Uses ECDH P-256 (Web Crypto API) instead of X25519. Cross-client
|
||||
compatibility with the CLI is not yet implemented. (Phase 2)
|
||||
- Does not use BIP39 mnemonics; seed is displayed as hex.
|
||||
- Message decryption is not yet wired (Double Ratchet in JS is TODO).
|
||||
- The seed is stored in `localStorage` (unencrypted).
|
||||
|
||||
---
|
||||
|
||||
## 5. Database
|
||||
|
||||
The server uses **sled** (embedded key-value store). All data lives under the
|
||||
directory specified by `--data-dir`.
|
||||
|
||||
### Trees (Tables)
|
||||
|
||||
| Tree | Key format | Value | Purpose |
|
||||
|------|-----------|-------|---------|
|
||||
| `keys` | fingerprint string (UTF-8 bytes) | bincode `PreKeyBundle` | Pre-key bundle storage |
|
||||
| `messages` | `queue:{fingerprint}:{uuid}` (UTF-8 bytes) | bincode `WireMessage` | Message queue |
|
||||
| `otpks` | (reserved) | (reserved) | One-time pre-key tracking (not yet used server-side) |
|
||||
|
||||
### Data Directory Structure
|
||||
|
||||
```
|
||||
warzone-data/
|
||||
db # sled database file
|
||||
conf # sled config
|
||||
blobs/ # sled blob storage (if any)
|
||||
snap.*/ # sled snapshots
|
||||
```
|
||||
|
||||
The exact file layout is managed by sled internally. The entire directory
|
||||
should be treated as a unit for backup.
|
||||
|
||||
### What the Server Stores
|
||||
|
||||
- **Pre-key bundles:** public keys only. The server never holds private keys.
|
||||
- **Encrypted message blobs:** opaque binary data. The server cannot read
|
||||
message contents.
|
||||
- **Metadata visible to server:** sender fingerprint, recipient fingerprint,
|
||||
message size, timestamps (implicit from storage order).
|
||||
|
||||
---
|
||||
|
||||
## 6. Deployment
|
||||
|
||||
### Single Binary
|
||||
|
||||
The recommended deployment is a single `warzone-server` binary behind a
|
||||
reverse proxy for TLS termination.
|
||||
|
||||
### Reverse Proxy (nginx)
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name wz.example.com;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/wz.example.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/wz.example.com/privkey.pem;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:7700;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# WebSocket support (for future real-time push)
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When using a reverse proxy, bind the server to localhost only:
|
||||
|
||||
```bash
|
||||
./warzone-server --bind 127.0.0.1:7700
|
||||
```
|
||||
|
||||
### systemd Service
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Warzone Messenger Server
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=warzone
|
||||
ExecStart=/usr/local/bin/warzone-server --bind 127.0.0.1:7700 --data-dir /var/lib/warzone
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
Environment=RUST_LOG=info
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Monitoring
|
||||
|
||||
### Health Endpoint
|
||||
|
||||
```bash
|
||||
curl http://localhost:7700/v1/health
|
||||
# {"status":"ok","version":"0.1.0"}
|
||||
```
|
||||
|
||||
Use this for:
|
||||
- Load balancer health checks
|
||||
- Uptime monitoring (e.g., with `uptime-kuma`, Prometheus blackbox exporter)
|
||||
- Deployment verification
|
||||
|
||||
### Logs
|
||||
|
||||
All request activity is logged via `tracing`. In production, pipe to a log
|
||||
aggregator or use `journalctl -u warzone-server`.
|
||||
|
||||
---
|
||||
|
||||
## 8. Security Considerations
|
||||
|
||||
### The Server Is a Dumb Relay
|
||||
|
||||
The server never sees plaintext message content. It stores and forwards
|
||||
opaque encrypted blobs. Even if the server is fully compromised, an attacker
|
||||
gains:
|
||||
|
||||
- **Encrypted message blobs** (useless without recipient's private keys)
|
||||
- **Public pre-key bundles** (public by design)
|
||||
- **Metadata:** who is messaging whom, when, and how often
|
||||
|
||||
### What the Server CAN See
|
||||
|
||||
| Data | Visible to server |
|
||||
|------|-------------------|
|
||||
| Message plaintext | No |
|
||||
| Sender fingerprint | Yes (in `WireMessage`) |
|
||||
| Recipient fingerprint | Yes (used for routing) |
|
||||
| Message size | Yes |
|
||||
| Timing | Yes |
|
||||
| IP addresses | Yes (from HTTP) |
|
||||
| Pre-key bundles (public keys) | Yes |
|
||||
|
||||
### Mitigations for Metadata (Future)
|
||||
|
||||
- **Sealed sender** (Phase 6): hide sender identity from the server.
|
||||
- **Padding:** fixed-size messages to prevent size-based analysis.
|
||||
- **Onion routing** (Phase 6): hide IP addresses via relay chains.
|
||||
|
||||
### Access Control
|
||||
|
||||
The current server has **no authentication**. Anyone can:
|
||||
- Register a key bundle for any fingerprint
|
||||
- Poll messages for any fingerprint
|
||||
- Send messages to any fingerprint
|
||||
|
||||
**TODO (Phase 2):** authentication via Ed25519 challenge-response. Clients
|
||||
sign requests to prove they own the fingerprint they claim.
|
||||
|
||||
---
|
||||
|
||||
## 9. Backup and Recovery
|
||||
|
||||
### Database Backup
|
||||
|
||||
The sled database can be backed up by copying the entire data directory while
|
||||
the server is stopped:
|
||||
|
||||
```bash
|
||||
systemctl stop warzone-server
|
||||
cp -r /var/lib/warzone /backup/warzone-$(date +%Y%m%d)
|
||||
systemctl start warzone-server
|
||||
```
|
||||
|
||||
**Warning:** copying the sled directory while the server is running may
|
||||
produce an inconsistent snapshot. Stop the server first or use filesystem-level
|
||||
snapshots (LVM, ZFS, btrfs).
|
||||
|
||||
### Recovery
|
||||
|
||||
1. Stop the server.
|
||||
2. Replace the data directory with the backup.
|
||||
3. Start the server.
|
||||
|
||||
Messages queued after the backup was taken will be lost. Since all messages
|
||||
are E2E encrypted, there is no way to recover them from any other source.
|
||||
|
||||
### Data Loss Impact
|
||||
|
||||
- **Lost key bundles:** users must re-register. No security impact (public
|
||||
data).
|
||||
- **Lost message queue:** undelivered messages are permanently lost. Senders
|
||||
will not know delivery failed (no delivery receipts yet).
|
||||
- **Corrupted database:** sled includes crash recovery. If the database is
|
||||
corrupt beyond recovery, delete it and start fresh. Users re-register.
|
||||
Reference in New Issue
Block a user