2 Commits

Author SHA1 Message Date
Siavash Sameni
60a7006ed9 Add documentation: protocol spec, server admin, client guide
docs/PROTOCOL.md (520 lines):
- Identity model (seed → Ed25519 + X25519 via HKDF)
- X3DH key exchange (4 DH operations, ASCII flow diagram)
- Double Ratchet (chain/DH ratchet, skipped keys, state machine)
- KDF chains with domain separation strings
- AEAD (ChaCha20-Poly1305)
- Wire format (WireMessage enum, bincode serialization)
- Pre-key bundle format and lifecycle

docs/SERVER.md (429 lines):
- Build and run instructions
- Full API reference with request/response examples
- Database structure (sled trees)
- Deployment (nginx reverse proxy, systemd unit)
- Security considerations
- Backup and recovery

docs/CLIENT.md (507 lines):
- Quick start guide
- All CLI commands with examples
- Identity management and mnemonic backup
- Web client usage and limitations
- Session and pre-key management
- Threat model table
- Troubleshooting guide

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 21:59:19 +04:00
Siavash Sameni
82f5061aa1 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>
2026-03-26 21:40:21 +04:00
12 changed files with 1983 additions and 9 deletions

5
warzone/Cargo.lock generated
View File

@@ -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",
]

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,117 @@
use anyhow::{Context, Result};
use warzone_protocol::ratchet::RatchetState;
use warzone_protocol::types::Fingerprint;
use warzone_protocol::x3dh;
use x25519_dalek::PublicKey;
use crate::cli::send::WireMessage;
use crate::keystore;
use crate::net::ServerClient;
use crate::storage::LocalDb;
pub async fn run(server_url: &str) -> Result<()> {
let seed = keystore::load_seed()?;
let identity = seed.derive_identity();
let our_pub = identity.public_identity();
let our_fp = our_pub.fingerprint.to_string();
let db = LocalDb::open()?;
let client = ServerClient::new(server_url);
println!("Polling for messages as {}...", our_fp);
let messages = client.poll_messages(&our_fp).await?;
if messages.is_empty() {
println!("No new messages.");
return Ok(());
}
println!("Received {} message(s):\n", messages.len());
for raw in &messages {
match bincode::deserialize::<WireMessage>(raw) {
Ok(WireMessage::KeyExchange {
sender_fingerprint,
sender_identity_encryption_key,
ephemeral_public,
used_one_time_pre_key_id,
ratchet_message,
}) => {
let sender_fp = Fingerprint::from_hex(&sender_fingerprint)
.context("invalid sender fingerprint")?;
// Load our signed pre-key secret
let spk_id = 1u32; // We use ID 1 for our signed pre-key
let spk_secret = db
.load_signed_pre_key(spk_id)?
.context("missing signed pre-key — run `warzone init` first")?;
// Load one-time pre-key if used
let otpk_secret = if let Some(id) = used_one_time_pre_key_id {
db.take_one_time_pre_key(id)?
} else {
None
};
// X3DH respond
let their_identity_x25519 = PublicKey::from(sender_identity_encryption_key);
let their_ephemeral = PublicKey::from(ephemeral_public);
let shared_secret = x3dh::respond(
&identity,
&spk_secret,
otpk_secret.as_ref(),
&their_identity_x25519,
&their_ephemeral,
)
.context("X3DH respond failed")?;
// Init ratchet as Bob
let mut state = RatchetState::init_bob(shared_secret, spk_secret);
// Decrypt the message
match state.decrypt(&ratchet_message) {
Ok(plaintext) => {
let text = String::from_utf8_lossy(&plaintext);
println!(" [{}] {}: {}", "new session", sender_fingerprint, text);
db.save_session(&sender_fp, &state)?;
}
Err(e) => {
eprintln!(" [{}] decrypt failed: {}", sender_fingerprint, e);
}
}
}
Ok(WireMessage::Message {
sender_fingerprint,
ratchet_message,
}) => {
let sender_fp = Fingerprint::from_hex(&sender_fingerprint)
.context("invalid sender fingerprint")?;
match db.load_session(&sender_fp)? {
Some(mut state) => match state.decrypt(&ratchet_message) {
Ok(plaintext) => {
let text = String::from_utf8_lossy(&plaintext);
println!(" {}: {}", sender_fingerprint, text);
db.save_session(&sender_fp, &state)?;
}
Err(e) => {
eprintln!(" [{}] decrypt failed: {}", sender_fingerprint, e);
}
},
None => {
eprintln!(
" [{}] no session — cannot decrypt (need key exchange first)",
sender_fingerprint
);
}
}
}
Err(e) => {
eprintln!(" failed to deserialize message: {}", e);
}
}
}
Ok(())
}

View File

@@ -0,0 +1,91 @@
use anyhow::{Context, Result};
use warzone_protocol::identity::Seed;
use warzone_protocol::message::{MessageContent, MessageType, WarzoneMessage};
use warzone_protocol::ratchet::{RatchetMessage, RatchetState};
use warzone_protocol::types::{Fingerprint, MessageId, SessionId};
use warzone_protocol::x3dh;
use x25519_dalek::PublicKey;
use crate::keystore;
use crate::net::ServerClient;
use crate::storage::LocalDb;
/// The wire envelope: contains either a key exchange init or a ratchet message.
#[derive(serde::Serialize, serde::Deserialize)]
pub enum WireMessage {
/// First message to a peer: includes X3DH ephemeral key + ratchet message.
KeyExchange {
sender_fingerprint: String,
sender_identity_encryption_key: [u8; 32],
ephemeral_public: [u8; 32],
used_one_time_pre_key_id: Option<u32>,
ratchet_message: RatchetMessage,
},
/// Subsequent messages: just ratchet messages.
Message {
sender_fingerprint: String,
ratchet_message: RatchetMessage,
},
}
pub async fn run(recipient_fp: &str, message: &str, server_url: &str) -> Result<()> {
let seed = keystore::load_seed()?;
let identity = seed.derive_identity();
let our_pub = identity.public_identity();
let db = LocalDb::open()?;
let client = ServerClient::new(server_url);
let recipient = Fingerprint::from_hex(recipient_fp)
.context("invalid recipient fingerprint")?;
// Check for existing session
let mut ratchet = db.load_session(&recipient)?;
let wire_msg = if let Some(ref mut state) = ratchet {
// Existing session — just encrypt with ratchet
let encrypted = state.encrypt(message.as_bytes())
.context("ratchet encrypt failed")?;
db.save_session(&recipient, state)?;
WireMessage::Message {
sender_fingerprint: our_pub.fingerprint.to_string(),
ratchet_message: encrypted,
}
} else {
// No session — perform X3DH key exchange
println!("No existing session. Fetching key bundle for {}...", recipient_fp);
let bundle = client.fetch_bundle(recipient_fp).await
.context("failed to fetch recipient's bundle. Are they registered?")?;
let x3dh_result = x3dh::initiate(&identity, &bundle)
.context("X3DH key exchange failed")?;
// Init ratchet as Alice
let their_spk = PublicKey::from(bundle.signed_pre_key.public_key);
let mut state = RatchetState::init_alice(x3dh_result.shared_secret, their_spk);
let encrypted = state.encrypt(message.as_bytes())
.context("ratchet encrypt failed")?;
// Save session
db.save_session(&recipient, &state)?;
WireMessage::KeyExchange {
sender_fingerprint: our_pub.fingerprint.to_string(),
sender_identity_encryption_key: *our_pub.encryption.as_bytes(),
ephemeral_public: *x3dh_result.ephemeral_public.as_bytes(),
used_one_time_pre_key_id: x3dh_result.used_one_time_pre_key_id,
ratchet_message: encrypted,
}
};
// Serialize and send
let encoded = bincode::serialize(&wire_msg)
.context("failed to serialize wire message")?;
client.send_message(recipient_fp, &encoded).await?;
println!("Message sent to {}", recipient_fp);
Ok(())
}

View File

@@ -15,7 +15,7 @@ struct Cli {
#[derive(Subcommand)]
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);

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

View File

@@ -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
View 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
View 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
View 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.