Scaffold Rust workspace: warzone-protocol, server, client, mule

4 crates, all compile. 16/17 tests pass.

warzone-protocol (core crypto):
- Seed-based identity (Ed25519 + X25519 from 32-byte seed via HKDF)
- BIP39 mnemonic encode/decode (24 words)
- Fingerprint type (SHA-256 truncated, displayed as xxxx:xxxx:xxxx:xxxx)
- ChaCha20-Poly1305 AEAD encrypt/decrypt with random nonce
- HKDF-SHA256 key derivation
- Pre-key bundle generation with Ed25519 signatures
- X3DH key exchange (simplified, needs X25519 identity key in bundle)
- Double Ratchet: full implementation with DH ratchet, chain ratchet,
  out-of-order message handling via skipped keys cache
- Message format (WarzoneMessage envelope + RatchetHeader)
- Session type with ratchet state
- Storage trait definitions (PreKeyStore, SessionStore, MessageQueue)

warzone-server (axum):
- sled database (keys, messages, one-time pre-keys)
- Routes: /v1/health, /v1/keys/register, /v1/keys/{fp},
  /v1/messages/send, /v1/messages/poll/{fp}, /v1/messages/{id}/ack

warzone-client (CLI):
- `warzone init` — generate seed, show mnemonic, save to ~/.warzone/
- `warzone recover <words>` — restore from mnemonic
- `warzone info` — show fingerprint and keys
- Seed storage at ~/.warzone/identity.seed (600 perms)
- Stubs for send, recv, chat commands

warzone-mule: Phase 4 placeholder

Known issue: X3DH test fails (initiate/respond use different DH ops
due to missing X25519 identity key in bundle). Fix in next step.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Siavash Sameni
2026-03-26 21:27:48 +04:00
parent 1e2a83402d
commit 651396fa13
5075 changed files with 36186 additions and 0 deletions

3169
warzone/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

77
warzone/Cargo.toml Normal file
View File

@@ -0,0 +1,77 @@
[workspace]
resolver = "2"
members = [
"crates/warzone-protocol",
"crates/warzone-server",
"crates/warzone-client",
"crates/warzone-mule",
]
[workspace.package]
version = "0.1.0"
edition = "2021"
license = "MIT"
rust-version = "1.75"
[workspace.dependencies]
# Crypto
ed25519-dalek = { version = "2", features = ["serde", "rand_core"] }
x25519-dalek = { version = "2", features = ["serde", "static_secrets"] }
curve25519-dalek = "4"
chacha20poly1305 = "0.10"
hkdf = "0.12"
sha2 = "0.10"
argon2 = "0.5"
rand = "0.8"
# BIP39
bip39 = "2"
# Serialization
serde = { version = "1", features = ["derive"] }
serde_json = "1"
bincode = "1"
# Async
tokio = { version = "1", features = ["full"] }
# Server
axum = "0.7"
tower = "0.4"
tower-http = { version = "0.5", features = ["cors", "trace"] }
# Client HTTP
reqwest = { version = "0.12", features = ["json"] }
# Database
sled = "0.34"
# CLI
clap = { version = "4", features = ["derive"] }
# TUI
ratatui = "0.28"
crossterm = "0.28"
# Logging
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
# Error handling
thiserror = "2"
anyhow = "1"
# Time
chrono = { version = "0.4", features = ["serde"] }
# Hex encoding
hex = "0.4"
# Base64
base64 = "0.22"
# UUID
uuid = { version = "1", features = ["v4", "serde"] }
# Zero secrets in memory
zeroize = { version = "1", features = ["derive"] }

View File

@@ -0,0 +1,23 @@
[package]
name = "warzone-client"
version.workspace = true
edition.workspace = true
[dependencies]
warzone-protocol = { path = "../warzone-protocol" }
tokio.workspace = true
reqwest.workspace = true
sled.workspace = true
clap.workspace = true
ratatui.workspace = true
crossterm.workspace = true
serde.workspace = true
serde_json.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
anyhow.workspace = true
argon2.workspace = true
chacha20poly1305.workspace = true
rand.workspace = true
zeroize.workspace = true
hex.workspace = true

View File

@@ -0,0 +1,16 @@
use crate::keystore;
pub fn run() -> anyhow::Result<()> {
let seed = keystore::load_seed()?;
let identity = seed.derive_identity();
let pub_id = identity.public_identity();
println!("Fingerprint: {}", pub_id.fingerprint);
println!("Signing key: {}", hex::encode(pub_id.signing.as_bytes()));
println!(
"Encryption key: {}",
hex::encode(pub_id.encryption.as_bytes())
);
Ok(())
}

View File

@@ -0,0 +1,27 @@
use warzone_protocol::identity::Seed;
use crate::keystore;
pub fn run() -> anyhow::Result<()> {
let seed = Seed::generate();
let identity = seed.derive_identity();
let pub_id = identity.public_identity();
let mnemonic = seed.to_mnemonic();
println!("Identity generated!\n");
println!("Fingerprint: {}", pub_id.fingerprint);
println!("\nRecovery mnemonic (WRITE THIS DOWN):\n");
for (i, word) in mnemonic.split_whitespace().enumerate() {
print!("{:>2}. {:<12}", i + 1, word);
if (i + 1) % 4 == 0 {
println!();
}
}
println!();
// Save encrypted seed
keystore::save_seed(&seed)?;
println!("Seed saved to ~/.warzone/identity.seed");
Ok(())
}

View File

@@ -0,0 +1,3 @@
pub mod info;
pub mod init;
pub mod recover;

View File

@@ -0,0 +1,17 @@
use warzone_protocol::identity::Seed;
use crate::keystore;
pub fn run(mnemonic: &str) -> anyhow::Result<()> {
let seed = Seed::from_mnemonic(mnemonic)?;
let identity = seed.derive_identity();
let pub_id = identity.public_identity();
println!("Identity recovered!");
println!("Fingerprint: {}", pub_id.fingerprint);
keystore::save_seed(&seed)?;
println!("Seed saved to ~/.warzone/identity.seed");
Ok(())
}

View File

@@ -0,0 +1,40 @@
//! Seed storage: encrypts at rest with Argon2 + ChaCha20-Poly1305.
//! For Phase 1, we store the seed in plaintext. Encryption is TODO.
use std::fs;
use std::path::PathBuf;
use warzone_protocol::identity::Seed;
fn seed_path() -> PathBuf {
let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
PathBuf::from(home).join(".warzone").join("identity.seed")
}
pub fn save_seed(seed: &Seed) -> anyhow::Result<()> {
let path = seed_path();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
// TODO: encrypt with passphrase (Argon2 + ChaCha20-Poly1305)
fs::write(&path, &seed.0)?;
// Set permissions to owner-only on Unix
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&path, fs::Permissions::from_mode(0o600))?;
}
Ok(())
}
pub fn load_seed() -> anyhow::Result<Seed> {
let path = seed_path();
let bytes = fs::read(&path)
.map_err(|_| anyhow::anyhow!("No identity found. Run `warzone init` first."))?;
if bytes.len() != 32 {
anyhow::bail!("Corrupted seed file");
}
let mut seed_bytes = [0u8; 32];
seed_bytes.copy_from_slice(&bytes);
Ok(Seed::from_bytes(seed_bytes))
}

View File

@@ -0,0 +1,5 @@
pub mod cli;
pub mod keystore;
pub mod net;
pub mod storage;
pub mod tui;

View File

@@ -0,0 +1,75 @@
use clap::{Parser, Subcommand};
mod cli;
mod keystore;
mod net;
mod storage;
mod tui;
#[derive(Parser)]
#[command(name = "warzone", about = "Warzone messenger client")]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
/// Generate a new identity (seed + keypair)
Init,
/// Recover identity from BIP39 mnemonic
Recover {
/// 24-word mnemonic
#[arg(num_args = 1..)]
words: Vec<String>,
},
/// Show your fingerprint and public key
Info,
/// Send an encrypted message
Send {
/// Recipient fingerprint (e.g. a3f8:c912:44be:7d01)
recipient: String,
/// Message text
message: String,
/// Server URL
#[arg(short, long, default_value = "http://localhost:7700")]
server: String,
},
/// Poll for and decrypt messages
Recv {
/// Server URL
#[arg(short, long, default_value = "http://localhost:7700")]
server: String,
},
/// Launch interactive TUI chat
Chat {
/// Server URL
#[arg(short, long, default_value = "http://localhost:7700")]
server: String,
},
}
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::Send {
recipient,
message,
server,
} => {
println!("TODO: send '{}' to {} via {}", message, recipient, server);
}
Commands::Recv { server } => {
println!("TODO: poll messages from {}", server);
}
Commands::Chat { server } => {
println!("TODO: launch TUI connected to {}", server);
}
}
Ok(())
}

View File

@@ -0,0 +1,2 @@
// HTTP client for talking to warzone-server.
// TODO: implement in Phase 1 step 9.

View File

@@ -0,0 +1,2 @@
// Local sled database: sessions, contacts, message history.
// TODO: implement in Phase 1 step 9.

View File

@@ -0,0 +1,2 @@
// TUI App struct and event loop.
// TODO: implement in Phase 1 step 10.

View File

@@ -0,0 +1,3 @@
// TUI scaffold — ratatui app.
// TODO: implement in Phase 1 step 10.
pub mod app;

View File

@@ -0,0 +1,9 @@
[package]
name = "warzone-mule"
version.workspace = true
edition.workspace = true
[dependencies]
warzone-protocol = { path = "../warzone-protocol" }
clap.workspace = true
anyhow.workspace = true

View File

@@ -0,0 +1 @@
// Mule protocol implementation — Phase 4.

View File

@@ -0,0 +1,4 @@
fn main() {
println!("warzone-mule: Phase 4 — not yet implemented");
println!("See DESIGN.md section 4 for the mule protocol specification.");
}

View File

@@ -0,0 +1,23 @@
[package]
name = "warzone-protocol"
version.workspace = true
edition.workspace = true
[dependencies]
ed25519-dalek.workspace = true
x25519-dalek.workspace = true
curve25519-dalek.workspace = true
chacha20poly1305.workspace = true
hkdf.workspace = true
sha2.workspace = true
rand.workspace = true
bip39.workspace = true
serde.workspace = true
serde_json.workspace = true
bincode.workspace = true
thiserror.workspace = true
hex.workspace = true
base64.workspace = true
uuid.workspace = true
zeroize.workspace = true
chrono.workspace = true

View File

@@ -0,0 +1,87 @@
use chacha20poly1305::{
aead::{Aead, KeyInit},
ChaCha20Poly1305, Nonce,
};
use hkdf::Hkdf;
use sha2::Sha256;
use crate::errors::ProtocolError;
/// HKDF-SHA256 key derivation.
pub fn hkdf_derive(ikm: &[u8], salt: &[u8], info: &[u8], len: usize) -> Vec<u8> {
let salt = if salt.is_empty() { None } else { Some(salt) };
let hk = Hkdf::<Sha256>::new(salt, ikm);
let mut output = vec![0u8; len];
hk.expand(info, &mut output)
.expect("HKDF output length should be valid");
output
}
/// Encrypt with ChaCha20-Poly1305. Returns nonce (12 bytes) || ciphertext.
pub fn aead_encrypt(key: &[u8; 32], plaintext: &[u8], aad: &[u8]) -> Vec<u8> {
let cipher = ChaCha20Poly1305::new(key.into());
let mut nonce_bytes = [0u8; 12];
rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut nonce_bytes);
let nonce = Nonce::from_slice(&nonce_bytes);
let ciphertext = cipher
.encrypt(nonce, chacha20poly1305::aead::Payload { msg: plaintext, aad })
.expect("encryption should not fail");
let mut result = Vec::with_capacity(12 + ciphertext.len());
result.extend_from_slice(&nonce_bytes);
result.extend_from_slice(&ciphertext);
result
}
/// Decrypt ChaCha20-Poly1305. Input: nonce (12 bytes) || ciphertext.
pub fn aead_decrypt(key: &[u8; 32], data: &[u8], aad: &[u8]) -> Result<Vec<u8>, ProtocolError> {
if data.len() < 12 {
return Err(ProtocolError::DecryptionFailed);
}
let (nonce_bytes, ciphertext) = data.split_at(12);
let cipher = ChaCha20Poly1305::new(key.into());
let nonce = Nonce::from_slice(nonce_bytes);
cipher
.decrypt(nonce, chacha20poly1305::aead::Payload { msg: ciphertext, aad })
.map_err(|_| ProtocolError::DecryptionFailed)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn aead_roundtrip() {
let key = [42u8; 32];
let plaintext = b"hello warzone";
let aad = b"associated data";
let encrypted = aead_encrypt(&key, plaintext, aad);
let decrypted = aead_decrypt(&key, &encrypted, aad).unwrap();
assert_eq!(decrypted, plaintext);
}
#[test]
fn aead_wrong_key_fails() {
let key = [42u8; 32];
let wrong_key = [99u8; 32];
let encrypted = aead_encrypt(&key, b"secret", b"");
assert!(aead_decrypt(&wrong_key, &encrypted, b"").is_err());
}
#[test]
fn aead_wrong_aad_fails() {
let key = [42u8; 32];
let encrypted = aead_encrypt(&key, b"secret", b"aad1");
assert!(aead_decrypt(&key, &encrypted, b"aad2").is_err());
}
#[test]
fn hkdf_deterministic() {
let a = hkdf_derive(b"input", b"salt", b"info", 32);
let b = hkdf_derive(b"input", b"salt", b"info", 32);
assert_eq!(a, b);
}
}

View File

@@ -0,0 +1,34 @@
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ProtocolError {
#[error("invalid seed length")]
InvalidSeedLength,
#[error("invalid mnemonic")]
InvalidMnemonic,
#[error("invalid fingerprint format")]
InvalidFingerprint,
#[error("invalid signature")]
InvalidSignature,
#[error("pre-key signature verification failed")]
PreKeySignatureInvalid,
#[error("X3DH key exchange failed: {0}")]
X3DHFailed(String),
#[error("ratchet error: {0}")]
RatchetError(String),
#[error("decryption failed")]
DecryptionFailed,
#[error("message too old (exceeded max skip)")]
MaxSkipExceeded,
#[error("serialization error: {0}")]
SerializationError(String),
}

View File

@@ -0,0 +1,182 @@
use ed25519_dalek::{SigningKey, VerifyingKey};
use sha2::{Digest, Sha256};
use x25519_dalek::StaticSecret;
use zeroize::{Zeroize, ZeroizeOnDrop};
use crate::crypto::hkdf_derive;
use crate::errors::ProtocolError;
use crate::types::Fingerprint;
/// The root secret — 32 bytes from which all keys are derived.
/// Displayed to users as a BIP39 mnemonic (24 words).
#[derive(Zeroize, ZeroizeOnDrop)]
pub struct Seed(pub [u8; 32]);
impl Seed {
/// Generate a new random seed.
pub fn generate() -> Self {
let mut bytes = [0u8; 32];
rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut bytes);
Seed(bytes)
}
/// Create seed from raw bytes.
pub fn from_bytes(bytes: [u8; 32]) -> Self {
Seed(bytes)
}
/// Derive the full identity keypair from this seed.
pub fn derive_identity(&self) -> IdentityKeyPair {
// Ed25519 signing key: HKDF(seed, info="warzone-ed25519")
let ed_bytes = hkdf_derive(&self.0, b"", b"warzone-ed25519", 32);
let mut ed_seed = [0u8; 32];
ed_seed.copy_from_slice(&ed_bytes);
let signing = SigningKey::from_bytes(&ed_seed);
ed_seed.zeroize();
// X25519 encryption key: HKDF(seed, info="warzone-x25519")
let x_bytes = hkdf_derive(&self.0, b"", b"warzone-x25519", 32);
let mut x_seed = [0u8; 32];
x_seed.copy_from_slice(&x_bytes);
let encryption = StaticSecret::from(x_seed);
x_seed.zeroize();
IdentityKeyPair {
signing,
encryption,
}
}
/// Convert to BIP39 mnemonic words.
pub fn to_mnemonic(&self) -> String {
crate::mnemonic::seed_to_mnemonic(&self.0)
}
/// Recover seed from BIP39 mnemonic words.
pub fn from_mnemonic(words: &str) -> Result<Self, ProtocolError> {
let bytes = crate::mnemonic::mnemonic_to_seed(words)?;
Ok(Seed(bytes))
}
}
/// The full identity keypair derived from a seed.
pub struct IdentityKeyPair {
pub signing: SigningKey,
pub encryption: StaticSecret,
}
impl IdentityKeyPair {
/// Get the public identity (safe to share).
pub fn public_identity(&self) -> PublicIdentity {
let verifying = self.signing.verifying_key();
let encryption_pub = x25519_dalek::PublicKey::from(&self.encryption);
let fingerprint = PublicIdentity::compute_fingerprint(&verifying);
PublicIdentity {
signing: verifying,
encryption: encryption_pub,
fingerprint,
}
}
}
/// The public portion of an identity — safe to share with anyone.
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct PublicIdentity {
#[serde(with = "verifying_key_serde")]
pub signing: VerifyingKey,
#[serde(with = "public_key_serde")]
pub encryption: x25519_dalek::PublicKey,
pub fingerprint: Fingerprint,
}
impl PublicIdentity {
fn compute_fingerprint(key: &VerifyingKey) -> Fingerprint {
let hash = Sha256::digest(key.as_bytes());
let mut fp = [0u8; 16];
fp.copy_from_slice(&hash[..16]);
Fingerprint(fp)
}
}
// Serde helpers for dalek types (serialize as bytes)
mod verifying_key_serde {
use ed25519_dalek::VerifyingKey;
use serde::{self, Deserialize, Deserializer, Serializer};
pub fn serialize<S>(key: &VerifyingKey, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_bytes(key.as_bytes())
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<VerifyingKey, D::Error>
where
D: Deserializer<'de>,
{
let bytes: Vec<u8> = Deserialize::deserialize(deserializer)?;
let arr: [u8; 32] = bytes
.try_into()
.map_err(|_| serde::de::Error::custom("invalid key length"))?;
VerifyingKey::from_bytes(&arr).map_err(serde::de::Error::custom)
}
}
mod public_key_serde {
use serde::{self, Deserialize, Deserializer, Serializer};
use x25519_dalek::PublicKey;
pub fn serialize<S>(key: &PublicKey, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_bytes(key.as_bytes())
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<PublicKey, D::Error>
where
D: Deserializer<'de>,
{
let bytes: Vec<u8> = Deserialize::deserialize(deserializer)?;
let arr: [u8; 32] = bytes
.try_into()
.map_err(|_| serde::de::Error::custom("invalid key length"))?;
Ok(PublicKey::from(arr))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn deterministic_derivation() {
let seed = Seed::from_bytes([42u8; 32]);
let id1 = seed.derive_identity();
let id2 = seed.derive_identity();
assert_eq!(
id1.signing.verifying_key().as_bytes(),
id2.signing.verifying_key().as_bytes(),
);
}
#[test]
fn mnemonic_roundtrip() {
let seed = Seed::generate();
let words = seed.to_mnemonic();
let recovered = Seed::from_mnemonic(&words).unwrap();
assert_eq!(seed.0, recovered.0);
}
#[test]
fn fingerprint_display() {
let seed = Seed::generate();
let id = seed.derive_identity();
let pub_id = id.public_identity();
let fp_str = pub_id.fingerprint.to_string();
// Format: xxxx:xxxx:xxxx:xxxx
assert_eq!(fp_str.len(), 19);
assert_eq!(fp_str.chars().filter(|c| *c == ':').count(), 3);
}
}

View File

@@ -0,0 +1,11 @@
pub mod types;
pub mod errors;
pub mod identity;
pub mod mnemonic;
pub mod crypto;
pub mod prekey;
pub mod x3dh;
pub mod ratchet;
pub mod message;
pub mod session;
pub mod store;

View File

@@ -0,0 +1,35 @@
use serde::{Deserialize, Serialize};
use crate::ratchet::RatchetHeader;
use crate::types::{Fingerprint, MessageId, SessionId};
#[derive(Clone, Debug, Serialize, Deserialize)]
pub enum MessageType {
Text,
File,
KeyExchange,
Receipt,
}
/// An encrypted message on the wire.
#[derive(Clone, Serialize, Deserialize)]
pub struct WarzoneMessage {
pub version: u8,
pub id: MessageId,
pub from: Fingerprint,
pub to: Fingerprint,
pub timestamp: i64,
pub msg_type: MessageType,
pub session_id: SessionId,
pub ratchet_header: RatchetHeader,
pub ciphertext: Vec<u8>,
pub signature: Vec<u8>,
}
/// Plaintext message content (inside the encrypted envelope).
#[derive(Clone, Debug, Serialize, Deserialize)]
pub enum MessageContent {
Text { body: String },
File { filename: String, data: Vec<u8> },
Receipt { message_id: MessageId },
}

View File

@@ -0,0 +1,37 @@
use bip39::Mnemonic;
use crate::errors::ProtocolError;
/// Encode 32 bytes as a BIP39 mnemonic (24 words).
pub fn seed_to_mnemonic(seed: &[u8; 32]) -> String {
// BIP39 with 256 bits of entropy = 24 words
let mnemonic = Mnemonic::from_entropy(seed).expect("32 bytes is valid BIP39 entropy");
mnemonic.to_string()
}
/// Decode a BIP39 mnemonic back to 32 bytes.
pub fn mnemonic_to_seed(words: &str) -> Result<[u8; 32], ProtocolError> {
let mnemonic: Mnemonic = words.parse().map_err(|_| ProtocolError::InvalidMnemonic)?;
let entropy = mnemonic.to_entropy();
if entropy.len() != 32 {
return Err(ProtocolError::InvalidSeedLength);
}
let mut seed = [0u8; 32];
seed.copy_from_slice(&entropy);
Ok(seed)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn roundtrip() {
let seed = [0xab; 32];
let words = seed_to_mnemonic(&seed);
let word_count = words.split_whitespace().count();
assert_eq!(word_count, 24);
let recovered = mnemonic_to_seed(&words).unwrap();
assert_eq!(seed, recovered);
}
}

View File

@@ -0,0 +1,114 @@
use ed25519_dalek::{Signature, Signer, Verifier};
use serde::{Deserialize, Serialize};
use x25519_dalek::{PublicKey, StaticSecret};
use crate::errors::ProtocolError;
use crate::identity::IdentityKeyPair;
/// A signed pre-key (medium-term, rotated periodically).
#[derive(Clone, Serialize, Deserialize)]
pub struct SignedPreKey {
pub id: u32,
pub public_key: [u8; 32],
pub signature: Vec<u8>,
pub timestamp: i64,
}
impl SignedPreKey {
/// Verify the signature against the identity signing key.
pub fn verify(&self, identity_key: &ed25519_dalek::VerifyingKey) -> Result<(), ProtocolError> {
let sig =
Signature::from_slice(&self.signature).map_err(|_| ProtocolError::InvalidSignature)?;
identity_key
.verify(&self.public_key, &sig)
.map_err(|_| ProtocolError::PreKeySignatureInvalid)
}
}
/// A one-time pre-key (used once, then discarded).
pub struct OneTimePreKey {
pub id: u32,
pub secret: StaticSecret,
pub public: PublicKey,
}
/// The public portion of a one-time pre-key (sent to server).
#[derive(Clone, Serialize, Deserialize)]
pub struct OneTimePreKeyPublic {
pub id: u32,
pub public_key: [u8; 32],
}
/// A full pre-key bundle that the server stores for a user.
/// Fetched by others to initiate X3DH key exchange.
#[derive(Clone, Serialize, Deserialize)]
pub struct PreKeyBundle {
pub identity_key: [u8; 32], // Ed25519 verifying key bytes
pub signed_pre_key: SignedPreKey,
pub one_time_pre_key: Option<OneTimePreKeyPublic>,
}
/// Generate a signed pre-key.
pub fn generate_signed_pre_key(identity: &IdentityKeyPair, id: u32) -> (StaticSecret, SignedPreKey) {
let secret = StaticSecret::random_from_rng(rand::rngs::OsRng);
let public = PublicKey::from(&secret);
let signature = identity.signing.sign(public.as_bytes());
let spk = SignedPreKey {
id,
public_key: *public.as_bytes(),
signature: signature.to_bytes().to_vec(),
timestamp: chrono::Utc::now().timestamp(),
};
(secret, spk)
}
/// Generate a batch of one-time pre-keys.
pub fn generate_one_time_pre_keys(start_id: u32, count: u32) -> Vec<OneTimePreKey> {
(start_id..start_id + count)
.map(|id| {
let secret = StaticSecret::random_from_rng(rand::rngs::OsRng);
let public = PublicKey::from(&secret);
OneTimePreKey {
id,
secret,
public,
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::identity::Seed;
#[test]
fn signed_pre_key_verify() {
let seed = Seed::generate();
let identity = seed.derive_identity();
let (_secret, spk) = generate_signed_pre_key(&identity, 1);
let pub_id = identity.public_identity();
assert!(spk.verify(&pub_id.signing).is_ok());
}
#[test]
fn signed_pre_key_reject_tampered() {
let seed = Seed::generate();
let identity = seed.derive_identity();
let (_secret, mut spk) = generate_signed_pre_key(&identity, 1);
spk.public_key[0] ^= 0xff; // tamper
let pub_id = identity.public_identity();
assert!(spk.verify(&pub_id.signing).is_err());
}
#[test]
fn generate_otpks() {
let keys = generate_one_time_pre_keys(0, 10);
assert_eq!(keys.len(), 10);
// All public keys should be unique
let pubs: Vec<_> = keys.iter().map(|k| *k.public.as_bytes()).collect();
let unique: std::collections::HashSet<_> = pubs.iter().collect();
assert_eq!(unique.len(), 10);
}
}

View File

@@ -0,0 +1,325 @@
//! Double Ratchet algorithm implementation.
//! Follows Signal's Double Ratchet specification.
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use x25519_dalek::{PublicKey, StaticSecret};
use crate::crypto::{aead_decrypt, aead_encrypt, hkdf_derive};
use crate::errors::ProtocolError;
const MAX_SKIP: u32 = 1000;
/// A message produced by the ratchet.
#[derive(Clone, Serialize, Deserialize)]
pub struct RatchetMessage {
pub header: RatchetHeader,
pub ciphertext: Vec<u8>,
}
/// Header included with each ratchet message.
#[derive(Clone, Serialize, Deserialize)]
pub struct RatchetHeader {
/// Current DH ratchet public key.
pub dh_public: [u8; 32],
/// Number of messages in the previous sending chain.
pub prev_chain_length: u32,
/// Message number in the current sending chain.
pub message_number: u32,
}
/// The Double Ratchet state machine.
#[derive(Serialize, Deserialize)]
pub struct RatchetState {
dh_self: Vec<u8>, // StaticSecret bytes (32)
dh_remote: Option<[u8; 32]>,
root_key: [u8; 32],
chain_key_send: Option<[u8; 32]>,
chain_key_recv: Option<[u8; 32]>,
send_count: u32,
recv_count: u32,
prev_send_count: u32,
skipped: BTreeMap<([u8; 32], u32), [u8; 32]>, // (dh_pub, n) -> message_key
}
impl RatchetState {
/// Initialize as Alice (initiator). Alice knows Bob's ratchet public key.
pub fn init_alice(shared_secret: [u8; 32], bob_ratchet_pub: PublicKey) -> Self {
let dh_self = StaticSecret::random_from_rng(rand::rngs::OsRng);
let dh_out = dh_self.diffie_hellman(&bob_ratchet_pub);
let (root_key, chain_key_send) = kdf_rk(&shared_secret, dh_out.as_bytes());
RatchetState {
dh_self: dh_self.to_bytes().to_vec(),
dh_remote: Some(*bob_ratchet_pub.as_bytes()),
root_key,
chain_key_send: Some(chain_key_send),
chain_key_recv: None,
send_count: 0,
recv_count: 0,
prev_send_count: 0,
skipped: BTreeMap::new(),
}
}
/// Initialize as Bob (responder). Bob uses his signed pre-key as initial ratchet key.
pub fn init_bob(shared_secret: [u8; 32], our_ratchet_secret: StaticSecret) -> Self {
RatchetState {
dh_self: our_ratchet_secret.to_bytes().to_vec(),
dh_remote: None,
root_key: shared_secret,
chain_key_send: None,
chain_key_recv: None,
send_count: 0,
recv_count: 0,
prev_send_count: 0,
skipped: BTreeMap::new(),
}
}
/// Get our current DH ratchet public key.
fn dh_public(&self) -> PublicKey {
let mut bytes = [0u8; 32];
bytes.copy_from_slice(&self.dh_self);
let secret = StaticSecret::from(bytes);
PublicKey::from(&secret)
}
fn dh_secret(&self) -> StaticSecret {
let mut bytes = [0u8; 32];
bytes.copy_from_slice(&self.dh_self);
StaticSecret::from(bytes)
}
/// Encrypt a plaintext message.
pub fn encrypt(&mut self, plaintext: &[u8]) -> Result<RatchetMessage, ProtocolError> {
// If we don't have a sending chain yet (Bob's first message), do a DH ratchet step
if self.chain_key_send.is_none() {
if self.dh_remote.is_none() {
return Err(ProtocolError::RatchetError(
"no remote DH key and no sending chain".into(),
));
}
self.dh_ratchet_step()?;
}
let ck = self
.chain_key_send
.as_ref()
.ok_or_else(|| ProtocolError::RatchetError("no sending chain".into()))?;
let (new_ck, message_key) = kdf_ck(ck);
self.chain_key_send = Some(new_ck);
let header = RatchetHeader {
dh_public: *self.dh_public().as_bytes(),
prev_chain_length: self.prev_send_count,
message_number: self.send_count,
};
// AAD: serialized header
let aad = bincode::serialize(&header)
.map_err(|e| ProtocolError::SerializationError(e.to_string()))?;
let ciphertext = aead_encrypt(&message_key, plaintext, &aad);
self.send_count += 1;
Ok(RatchetMessage { header, ciphertext })
}
/// Decrypt a received ratchet message.
pub fn decrypt(&mut self, message: &RatchetMessage) -> Result<Vec<u8>, ProtocolError> {
// Check skipped messages first
let key = (message.header.dh_public, message.header.message_number);
if let Some(mk) = self.skipped.remove(&key) {
let aad = bincode::serialize(&message.header)
.map_err(|e| ProtocolError::SerializationError(e.to_string()))?;
return aead_decrypt(&mk, &message.ciphertext, &aad);
}
// If the message's DH key differs from what we have, perform DH ratchet
let need_ratchet = match self.dh_remote {
Some(ref remote) => *remote != message.header.dh_public,
None => true,
};
if need_ratchet {
// Skip any missed messages in the current receiving chain
if self.chain_key_recv.is_some() {
self.skip_messages(message.header.prev_chain_length)?;
}
// DH ratchet step
let their_pub = PublicKey::from(message.header.dh_public);
// New receiving chain
let dh_recv = self.dh_secret().diffie_hellman(&their_pub);
let (rk, ck_recv) = kdf_rk(&self.root_key, dh_recv.as_bytes());
self.root_key = rk;
self.chain_key_recv = Some(ck_recv);
self.recv_count = 0;
// New sending chain
self.prev_send_count = self.send_count;
self.send_count = 0;
let new_dh = StaticSecret::random_from_rng(rand::rngs::OsRng);
let dh_send = new_dh.diffie_hellman(&their_pub);
let (rk2, ck_send) = kdf_rk(&self.root_key, dh_send.as_bytes());
self.root_key = rk2;
self.chain_key_send = Some(ck_send);
self.dh_self = new_dh.to_bytes().to_vec();
self.dh_remote = Some(message.header.dh_public);
}
// Skip to the message number
self.skip_messages(message.header.message_number)?;
// Derive message key
let ck = self
.chain_key_recv
.as_ref()
.ok_or_else(|| ProtocolError::RatchetError("no receiving chain".into()))?;
let (new_ck, message_key) = kdf_ck(ck);
self.chain_key_recv = Some(new_ck);
self.recv_count += 1;
let aad = bincode::serialize(&message.header)
.map_err(|e| ProtocolError::SerializationError(e.to_string()))?;
aead_decrypt(&message_key, &message.ciphertext, &aad)
}
fn skip_messages(&mut self, until: u32) -> Result<(), ProtocolError> {
if self.recv_count + MAX_SKIP < until {
return Err(ProtocolError::MaxSkipExceeded);
}
if let Some(ref ck) = self.chain_key_recv.clone() {
let dh_pub = self.dh_remote.unwrap_or([0u8; 32]);
let mut current_ck = *ck;
while self.recv_count < until {
let (new_ck, mk) = kdf_ck(&current_ck);
self.skipped.insert((dh_pub, self.recv_count), mk);
current_ck = new_ck;
self.recv_count += 1;
}
self.chain_key_recv = Some(current_ck);
}
Ok(())
}
fn dh_ratchet_step(&mut self) -> Result<(), ProtocolError> {
let their_pub = self
.dh_remote
.map(PublicKey::from)
.ok_or_else(|| ProtocolError::RatchetError("no remote key for ratchet".into()))?;
self.prev_send_count = self.send_count;
self.send_count = 0;
let new_dh = StaticSecret::random_from_rng(rand::rngs::OsRng);
let dh_out = new_dh.diffie_hellman(&their_pub);
let (rk, ck_send) = kdf_rk(&self.root_key, dh_out.as_bytes());
self.root_key = rk;
self.chain_key_send = Some(ck_send);
self.dh_self = new_dh.to_bytes().to_vec();
Ok(())
}
}
/// Root key KDF: derive new root key + chain key from DH output.
fn kdf_rk(root_key: &[u8; 32], dh_output: &[u8]) -> ([u8; 32], [u8; 32]) {
let derived = hkdf_derive(dh_output, root_key, b"warzone-ratchet-rk", 64);
let mut new_rk = [0u8; 32];
let mut chain_key = [0u8; 32];
new_rk.copy_from_slice(&derived[..32]);
chain_key.copy_from_slice(&derived[32..]);
(new_rk, chain_key)
}
/// Chain key KDF: derive new chain key + message key.
fn kdf_ck(chain_key: &[u8; 32]) -> ([u8; 32], [u8; 32]) {
let mk_bytes = hkdf_derive(chain_key, b"", b"warzone-ratchet-mk", 32);
let ck_bytes = hkdf_derive(chain_key, b"", b"warzone-ratchet-ck", 32);
let mut new_ck = [0u8; 32];
let mut mk = [0u8; 32];
new_ck.copy_from_slice(&ck_bytes);
mk.copy_from_slice(&mk_bytes);
(new_ck, mk)
}
#[cfg(test)]
mod tests {
use super::*;
fn make_pair() -> (RatchetState, RatchetState) {
let shared_secret = [42u8; 32];
let bob_ratchet = StaticSecret::random_from_rng(rand::rngs::OsRng);
let bob_ratchet_pub = PublicKey::from(&bob_ratchet);
let alice = RatchetState::init_alice(shared_secret, bob_ratchet_pub);
let bob = RatchetState::init_bob(shared_secret, bob_ratchet);
(alice, bob)
}
#[test]
fn basic_exchange() {
let (mut alice, mut bob) = make_pair();
let msg = alice.encrypt(b"hello bob").unwrap();
let plain = bob.decrypt(&msg).unwrap();
assert_eq!(plain, b"hello bob");
}
#[test]
fn bidirectional() {
let (mut alice, mut bob) = make_pair();
let m1 = alice.encrypt(b"hello bob").unwrap();
assert_eq!(bob.decrypt(&m1).unwrap(), b"hello bob");
let m2 = bob.encrypt(b"hello alice").unwrap();
assert_eq!(alice.decrypt(&m2).unwrap(), b"hello alice");
let m3 = alice.encrypt(b"how are you?").unwrap();
assert_eq!(bob.decrypt(&m3).unwrap(), b"how are you?");
}
#[test]
fn multiple_messages_same_direction() {
let (mut alice, mut bob) = make_pair();
let m1 = alice.encrypt(b"one").unwrap();
let m2 = alice.encrypt(b"two").unwrap();
let m3 = alice.encrypt(b"three").unwrap();
assert_eq!(bob.decrypt(&m1).unwrap(), b"one");
assert_eq!(bob.decrypt(&m2).unwrap(), b"two");
assert_eq!(bob.decrypt(&m3).unwrap(), b"three");
}
#[test]
fn out_of_order() {
let (mut alice, mut bob) = make_pair();
let m1 = alice.encrypt(b"one").unwrap();
let m2 = alice.encrypt(b"two").unwrap();
let m3 = alice.encrypt(b"three").unwrap();
// Deliver out of order
assert_eq!(bob.decrypt(&m3).unwrap(), b"three");
assert_eq!(bob.decrypt(&m1).unwrap(), b"one");
assert_eq!(bob.decrypt(&m2).unwrap(), b"two");
}
#[test]
fn many_messages() {
let (mut alice, mut bob) = make_pair();
for i in 0..100 {
let msg = format!("message {}", i);
let encrypted = alice.encrypt(msg.as_bytes()).unwrap();
let decrypted = bob.decrypt(&encrypted).unwrap();
assert_eq!(decrypted, msg.as_bytes());
}
}
}

View File

@@ -0,0 +1,14 @@
use serde::{Deserialize, Serialize};
use crate::ratchet::RatchetState;
use crate::types::{Fingerprint, SessionId};
/// A session represents an ongoing encrypted conversation with a peer.
#[derive(Serialize, Deserialize)]
pub struct Session {
pub id: SessionId,
pub peer: Fingerprint,
pub ratchet: RatchetState,
pub created_at: i64,
pub last_active: i64,
}

View File

@@ -0,0 +1,26 @@
//! Storage trait definitions. Implementations live in server/client crates.
use crate::errors::ProtocolError;
use crate::message::WarzoneMessage;
use crate::prekey::{OneTimePreKey, SignedPreKey};
use crate::session::Session;
use crate::types::{Fingerprint, MessageId};
pub trait PreKeyStore {
fn store_signed_pre_key(&mut self, key: SignedPreKey) -> Result<(), ProtocolError>;
fn load_signed_pre_key(&self, id: u32) -> Result<Option<SignedPreKey>, ProtocolError>;
fn store_one_time_pre_keys(&mut self, keys: Vec<OneTimePreKey>) -> Result<(), ProtocolError>;
fn take_one_time_pre_key(&mut self, id: u32) -> Result<Option<OneTimePreKey>, ProtocolError>;
fn count_one_time_pre_keys(&self) -> Result<usize, ProtocolError>;
}
pub trait SessionStore {
fn load_session(&self, peer: &Fingerprint) -> Result<Option<Session>, ProtocolError>;
fn store_session(&mut self, session: &Session) -> Result<(), ProtocolError>;
}
pub trait MessageQueue {
fn queue_message(&mut self, msg: &WarzoneMessage) -> Result<(), ProtocolError>;
fn fetch_messages(&self, recipient: &Fingerprint) -> Result<Vec<WarzoneMessage>, ProtocolError>;
fn delete_message(&mut self, id: &MessageId) -> Result<(), ProtocolError>;
}

View File

@@ -0,0 +1,68 @@
use serde::{Deserialize, Serialize};
use std::fmt;
/// Truncated SHA-256 hash of the public signing key (16 bytes).
/// The primary identity of a user — displayed as hex groups.
#[derive(Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct Fingerprint(pub [u8; 16]);
impl fmt::Display for Fingerprint {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"{:04x}:{:04x}:{:04x}:{:04x}",
u16::from_be_bytes([self.0[0], self.0[1]]),
u16::from_be_bytes([self.0[2], self.0[3]]),
u16::from_be_bytes([self.0[4], self.0[5]]),
u16::from_be_bytes([self.0[6], self.0[7]]),
)
}
}
impl fmt::Debug for Fingerprint {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "Fingerprint({})", self)
}
}
impl Fingerprint {
pub fn from_hex(s: &str) -> Result<Self, crate::errors::ProtocolError> {
let clean: String = s.chars().filter(|c| c.is_ascii_hexdigit()).collect();
let bytes = hex::decode(&clean)
.map_err(|_| crate::errors::ProtocolError::InvalidFingerprint)?;
if bytes.len() < 16 {
return Err(crate::errors::ProtocolError::InvalidFingerprint);
}
let mut fp = [0u8; 16];
fp.copy_from_slice(&bytes[..16]);
Ok(Fingerprint(fp))
}
pub fn to_hex(&self) -> String {
hex::encode(self.0)
}
}
/// Unique device identifier (derived from seed + device index).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct DeviceId(pub u32);
/// Unique session identifier.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct SessionId(pub uuid::Uuid);
impl SessionId {
pub fn new() -> Self {
SessionId(uuid::Uuid::new_v4())
}
}
/// Unique message identifier.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct MessageId(pub uuid::Uuid);
impl MessageId {
pub fn new() -> Self {
MessageId(uuid::Uuid::new_v4())
}
}

View File

@@ -0,0 +1,174 @@
//! X3DH (Extended Triple Diffie-Hellman) key agreement.
//! Follows Signal's X3DH specification.
use x25519_dalek::{PublicKey, StaticSecret};
use zeroize::Zeroize;
use crate::crypto::hkdf_derive;
use crate::errors::ProtocolError;
use crate::identity::IdentityKeyPair;
use crate::prekey::PreKeyBundle;
/// Result of initiating X3DH (Alice's side).
pub struct X3DHInitResult {
/// The shared secret (32 bytes), used to initialize the Double Ratchet.
pub shared_secret: [u8; 32],
/// Alice's ephemeral public key (sent to Bob).
pub ephemeral_public: PublicKey,
/// Which one-time pre-key was used (if any).
pub used_one_time_pre_key_id: Option<u32>,
}
/// Initiate X3DH key exchange (Alice's side).
///
/// Alice fetches Bob's pre-key bundle from the server, performs four DH
/// operations, and derives a shared secret.
pub fn initiate(
our_identity: &IdentityKeyPair,
their_bundle: &PreKeyBundle,
) -> Result<X3DHInitResult, ProtocolError> {
// Verify the signed pre-key signature
let their_identity = ed25519_dalek::VerifyingKey::from_bytes(
&their_bundle.identity_key,
)
.map_err(|_| ProtocolError::X3DHFailed("invalid identity key".into()))?;
their_bundle
.signed_pre_key
.verify(&their_identity)
.map_err(|_| ProtocolError::X3DHFailed("signed pre-key verification failed".into()))?;
// Bob's X25519 identity key: we need to convert Ed25519 verifying key → X25519
// For simplicity, we store X25519 public keys separately in bundles.
// Here we use the signed_pre_key's public key and the identity encryption key.
// In our model, the bundle carries the Ed25519 identity key for signing verification,
// but X3DH uses X25519 keys. We'll derive Bob's X25519 identity from the bundle.
//
// TODO: The bundle should also carry the X25519 identity public key.
// For now, we'll use the signed pre-key as SPK and skip IK DH.
// This is a simplification — full X3DH has 4 DH ops with IK.
let ephemeral_secret = StaticSecret::random_from_rng(rand::rngs::OsRng);
let ephemeral_public = PublicKey::from(&ephemeral_secret);
let their_spk = PublicKey::from(their_bundle.signed_pre_key.public_key);
// DH1: our_identity_x25519 * their_signed_pre_key
let dh1 = our_identity.encryption.diffie_hellman(&their_spk);
// DH2: our_ephemeral * their_identity_x25519
// TODO: need their X25519 identity key in bundle. Using SPK as stand-in.
let dh2 = ephemeral_secret.diffie_hellman(&their_spk);
// DH3: our_ephemeral * their_signed_pre_key
let dh3 = ephemeral_secret.diffie_hellman(&their_spk);
// DH4: our_ephemeral * their_one_time_pre_key (if available)
let mut dh_concat = Vec::with_capacity(128);
dh_concat.extend_from_slice(dh1.as_bytes());
dh_concat.extend_from_slice(dh2.as_bytes());
dh_concat.extend_from_slice(dh3.as_bytes());
let used_otpk_id = if let Some(ref otpk) = their_bundle.one_time_pre_key {
let their_otpk = PublicKey::from(otpk.public_key);
let dh4 = ephemeral_secret.diffie_hellman(&their_otpk);
dh_concat.extend_from_slice(dh4.as_bytes());
Some(otpk.id)
} else {
None
};
// KDF: derive 32-byte shared secret
let mut shared_secret = [0u8; 32];
let derived = hkdf_derive(&dh_concat, b"", b"warzone-x3dh", 32);
shared_secret.copy_from_slice(&derived);
dh_concat.zeroize();
Ok(X3DHInitResult {
shared_secret,
ephemeral_public,
used_one_time_pre_key_id: used_otpk_id,
})
}
/// Respond to X3DH key exchange (Bob's side).
///
/// Bob receives Alice's ephemeral public key and performs the same DH
/// operations to derive the same shared secret.
pub fn respond(
our_identity: &IdentityKeyPair,
our_signed_pre_key_secret: &StaticSecret,
our_one_time_pre_key_secret: Option<&StaticSecret>,
their_ephemeral_public: &PublicKey,
) -> Result<[u8; 32], ProtocolError> {
let their_eph = *their_ephemeral_public;
// DH1: their_identity_x25519 * our_signed_pre_key
// TODO: need their X25519 identity key. Using ephemeral as stand-in.
let dh1 = our_signed_pre_key_secret.diffie_hellman(&their_eph);
// DH2: their_ephemeral * our_identity_x25519
let dh2 = our_identity.encryption.diffie_hellman(&their_eph);
// DH3: their_ephemeral * our_signed_pre_key
let dh3 = our_signed_pre_key_secret.diffie_hellman(&their_eph);
let mut dh_concat = Vec::with_capacity(128);
dh_concat.extend_from_slice(dh1.as_bytes());
dh_concat.extend_from_slice(dh2.as_bytes());
dh_concat.extend_from_slice(dh3.as_bytes());
if let Some(otpk) = our_one_time_pre_key_secret {
let dh4 = otpk.diffie_hellman(&their_eph);
dh_concat.extend_from_slice(dh4.as_bytes());
}
let mut shared_secret = [0u8; 32];
let derived = hkdf_derive(&dh_concat, b"", b"warzone-x3dh", 32);
shared_secret.copy_from_slice(&derived);
dh_concat.zeroize();
Ok(shared_secret)
}
// TODO: Full X3DH implementation requires X25519 identity keys in the bundle.
// Current implementation is simplified. Fix in step 5 of the implementation plan.
#[cfg(test)]
mod tests {
use super::*;
use crate::identity::Seed;
use crate::prekey::{generate_one_time_pre_keys, generate_signed_pre_key};
#[test]
fn x3dh_shared_secret_matches() {
let alice_seed = Seed::generate();
let alice_id = alice_seed.derive_identity();
let bob_seed = Seed::generate();
let bob_id = bob_seed.derive_identity();
let (bob_spk_secret, bob_spk) = generate_signed_pre_key(&bob_id, 1);
let bob_otpks = generate_one_time_pre_keys(0, 1);
let bob_pub = bob_id.public_identity();
let bundle = PreKeyBundle {
identity_key: *bob_pub.signing.as_bytes(),
signed_pre_key: bob_spk,
one_time_pre_key: Some(crate::prekey::OneTimePreKeyPublic {
id: bob_otpks[0].id,
public_key: *bob_otpks[0].public.as_bytes(),
}),
};
let alice_result = initiate(&alice_id, &bundle).unwrap();
let bob_secret = respond(
&bob_id,
&bob_spk_secret,
Some(&bob_otpks[0].secret),
&alice_result.ephemeral_public,
)
.unwrap();
assert_eq!(alice_result.shared_secret, bob_secret);
}
}

View File

@@ -0,0 +1,23 @@
[package]
name = "warzone-server"
version.workspace = true
edition.workspace = true
[dependencies]
warzone-protocol = { path = "../warzone-protocol" }
tokio.workspace = true
axum.workspace = true
tower.workspace = true
tower-http.workspace = true
sled.workspace = true
serde.workspace = true
serde_json.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
clap.workspace = true
thiserror.workspace = true
anyhow.workspace = true
uuid.workspace = true
chrono.workspace = true
hex.workspace = true
base64.workspace = true

View File

@@ -0,0 +1,4 @@
pub struct ServerConfig {
pub bind_addr: String,
pub data_dir: String,
}

View File

@@ -0,0 +1,23 @@
use anyhow::Result;
pub struct Database {
pub keys: sled::Tree,
pub messages: sled::Tree,
pub otpks: sled::Tree,
_db: sled::Db,
}
impl Database {
pub fn open(data_dir: &str) -> Result<Self> {
let db = sled::open(data_dir)?;
let keys = db.open_tree("keys")?;
let messages = db.open_tree("messages")?;
let otpks = db.open_tree("otpks")?;
Ok(Database {
keys,
messages,
otpks,
_db: db,
})
}
}

View File

@@ -0,0 +1,16 @@
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
pub struct AppError(pub anyhow::Error);
impl IntoResponse for AppError {
fn into_response(self) -> Response {
(StatusCode::INTERNAL_SERVER_ERROR, self.0.to_string()).into_response()
}
}
impl<E: Into<anyhow::Error>> From<E> for AppError {
fn from(err: E) -> Self {
AppError(err.into())
}
}

View File

@@ -0,0 +1,5 @@
pub mod config;
pub mod db;
pub mod errors;
pub mod routes;
pub mod state;

View File

@@ -0,0 +1,39 @@
use clap::Parser;
mod config;
mod db;
mod errors;
mod routes;
mod state;
#[derive(Parser)]
#[command(name = "warzone-server", about = "Warzone messenger server")]
struct Cli {
/// Address to bind to
#[arg(short, long, default_value = "0.0.0.0:7700")]
bind: String,
/// Database directory
#[arg(short, long, default_value = "./warzone-data")]
data_dir: String,
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt::init();
let cli = Cli::parse();
tracing::info!("Warzone server starting on {}", cli.bind);
let state = state::AppState::new(&cli.data_dir)?;
let app = axum::Router::new()
.nest("/v1", routes::router())
.with_state(state);
let listener = tokio::net::TcpListener::bind(&cli.bind).await?;
tracing::info!("Listening on {}", cli.bind);
axum::serve(listener, app).await?;
Ok(())
}

View File

@@ -0,0 +1,12 @@
use axum::{routing::get, Json, Router};
use serde_json::json;
use crate::state::AppState;
pub fn routes() -> Router<AppState> {
Router::new().route("/health", get(health))
}
async fn health() -> Json<serde_json::Value> {
Json(json!({ "status": "ok", "version": env!("CARGO_PKG_VERSION") }))
}

View File

@@ -0,0 +1,46 @@
use axum::{
extract::{Path, State},
routing::{get, post},
Json, Router,
};
use serde::{Deserialize, Serialize};
use crate::state::AppState;
pub fn routes() -> Router<AppState> {
Router::new()
.route("/keys/register", post(register_keys))
.route("/keys/{fingerprint}", get(get_bundle))
}
#[derive(Deserialize)]
struct RegisterRequest {
fingerprint: String,
bundle: Vec<u8>, // bincode-serialized PreKeyBundle
}
#[derive(Serialize)]
struct RegisterResponse {
ok: bool,
}
async fn register_keys(
State(state): State<AppState>,
Json(req): Json<RegisterRequest>,
) -> Json<RegisterResponse> {
let _ = state.db.keys.insert(req.fingerprint.as_bytes(), req.bundle);
Json(RegisterResponse { ok: true })
}
async fn get_bundle(
State(state): State<AppState>,
Path(fingerprint): Path<String>,
) -> Result<Json<serde_json::Value>, axum::http::StatusCode> {
match state.db.keys.get(fingerprint.as_bytes()) {
Ok(Some(data)) => Ok(Json(serde_json::json!({
"fingerprint": fingerprint,
"bundle": base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &data),
}))),
_ => Err(axum::http::StatusCode::NOT_FOUND),
}
}

View File

@@ -0,0 +1,61 @@
use axum::{
extract::{Path, State},
routing::{delete, get, post},
Json, Router,
};
use serde::Deserialize;
use crate::state::AppState;
pub fn routes() -> Router<AppState> {
Router::new()
.route("/messages/send", post(send_message))
.route("/messages/poll/{fingerprint}", get(poll_messages))
.route("/messages/{id}/ack", delete(ack_message))
}
#[derive(Deserialize)]
struct SendRequest {
to: String,
message: Vec<u8>, // bincode-serialized WarzoneMessage
}
async fn send_message(
State(state): State<AppState>,
Json(req): Json<SendRequest>,
) -> Json<serde_json::Value> {
// Append to recipient's queue
let key = format!("queue:{}", req.to);
let _ = state.db.messages.insert(
format!("{}:{}", key, uuid::Uuid::new_v4()).as_bytes(),
req.message,
);
Json(serde_json::json!({ "ok": true }))
}
async fn poll_messages(
State(state): State<AppState>,
Path(fingerprint): Path<String>,
) -> Json<Vec<String>> {
let prefix = format!("queue:{}", fingerprint);
let mut messages = Vec::new();
for item in state.db.messages.scan_prefix(prefix.as_bytes()) {
if let Ok((_, value)) = item {
messages.push(base64::Engine::encode(
&base64::engine::general_purpose::STANDARD,
&value,
));
}
}
Json(messages)
}
async fn ack_message(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Json<serde_json::Value> {
// Scan for and remove the message with this ID
// In a real implementation, we'd have a proper index
let _ = state.db.messages.remove(id.as_bytes());
Json(serde_json::json!({ "ok": true }))
}

View File

@@ -0,0 +1,14 @@
mod health;
mod keys;
mod messages;
use axum::Router;
use crate::state::AppState;
pub fn router() -> Router<AppState> {
Router::new()
.merge(health::routes())
.merge(keys::routes())
.merge(messages::routes())
}

View File

@@ -0,0 +1,15 @@
use std::sync::Arc;
use crate::db::Database;
#[derive(Clone)]
pub struct AppState {
pub db: Arc<Database>,
}
impl AppState {
pub fn new(data_dir: &str) -> anyhow::Result<Self> {
let db = Database::open(data_dir)?;
Ok(AppState { db: Arc::new(db) })
}
}

View File

@@ -0,0 +1 @@
{"rustc_fingerprint":2226298908970031705,"outputs":{"17747080675513052775":{"success":true,"status":"","code":0,"stdout":"rustc 1.93.1 (01f6ddf75 2026-02-11)\nbinary: rustc\ncommit-hash: 01f6ddf7588f42ae2d7eb0a2f21d44e8e96674cf\ncommit-date: 2026-02-11\nhost: aarch64-apple-darwin\nrelease: 1.93.1\nLLVM version: 21.1.8\n","stderr":""},"7971740275564407648":{"success":true,"status":"","code":0,"stdout":"___\nlib___.rlib\nlib___.dylib\nlib___.dylib\nlib___.a\nlib___.dylib\n/Users/manwe/.rustup/toolchains/stable-aarch64-apple-darwin\noff\npacked\nunpacked\n___\ndebug_assertions\npanic=\"unwind\"\nproc_macro\ntarget_abi=\"\"\ntarget_arch=\"aarch64\"\ntarget_endian=\"little\"\ntarget_env=\"\"\ntarget_family=\"unix\"\ntarget_feature=\"aes\"\ntarget_feature=\"crc\"\ntarget_feature=\"dit\"\ntarget_feature=\"dotprod\"\ntarget_feature=\"dpb\"\ntarget_feature=\"dpb2\"\ntarget_feature=\"fcma\"\ntarget_feature=\"fhm\"\ntarget_feature=\"flagm\"\ntarget_feature=\"fp16\"\ntarget_feature=\"frintts\"\ntarget_feature=\"jsconv\"\ntarget_feature=\"lor\"\ntarget_feature=\"lse\"\ntarget_feature=\"neon\"\ntarget_feature=\"paca\"\ntarget_feature=\"pacg\"\ntarget_feature=\"pan\"\ntarget_feature=\"pmuv3\"\ntarget_feature=\"ras\"\ntarget_feature=\"rcpc\"\ntarget_feature=\"rcpc2\"\ntarget_feature=\"rdm\"\ntarget_feature=\"sb\"\ntarget_feature=\"sha2\"\ntarget_feature=\"sha3\"\ntarget_feature=\"ssbs\"\ntarget_feature=\"vh\"\ntarget_has_atomic=\"128\"\ntarget_has_atomic=\"16\"\ntarget_has_atomic=\"32\"\ntarget_has_atomic=\"64\"\ntarget_has_atomic=\"8\"\ntarget_has_atomic=\"ptr\"\ntarget_os=\"macos\"\ntarget_pointer_width=\"64\"\ntarget_vendor=\"apple\"\nunix\n","stderr":""}},"successes":{}}

View File

@@ -0,0 +1,3 @@
Signature: 8a477f597d28d172789f06886806bc55
# This file is a cache directory tag created by cargo.
# For information about cache directory tags see https://bford.info/cachedir/

View File

View File

@@ -0,0 +1 @@
This file has an mtime of when this was started.

View File

@@ -0,0 +1 @@
2b64005920b44b63

View File

@@ -0,0 +1 @@
{"rustc":13850170861107434965,"features":"[\"alloc\", \"getrandom\", \"rand_core\"]","declared_features":"[\"alloc\", \"arrayvec\", \"blobby\", \"bytes\", \"default\", \"dev\", \"getrandom\", \"heapless\", \"rand_core\", \"std\", \"stream\"]","target":6415113071054268027,"profile":8276155916380437441,"path":18434073550386094549,"deps":[[6039282458970808711,"crypto_common",false,5158716069762653590],[10520923840501062997,"generic_array",false,18046893680686087619]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/aead-3ce3004f99d0cec9/dep-lib-aead","checksum":false}}],"rustflags":[],"config":2069994364910194474,"compile_kind":0}

View File

@@ -0,0 +1 @@
This file has an mtime of when this was started.

View File

@@ -0,0 +1 @@
b4ada76d8d6ef624

View File

@@ -0,0 +1 @@
{"rustc":13850170861107434965,"features":"[\"alloc\", \"getrandom\", \"rand_core\"]","declared_features":"[\"alloc\", \"arrayvec\", \"blobby\", \"bytes\", \"default\", \"dev\", \"getrandom\", \"heapless\", \"rand_core\", \"std\", \"stream\"]","target":6415113071054268027,"profile":5347358027863023418,"path":18434073550386094549,"deps":[[6039282458970808711,"crypto_common",false,9398706008583991340],[10520923840501062997,"generic_array",false,13561802365898736142]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/aead-b4062926b3763b43/dep-lib-aead","checksum":false}}],"rustflags":[],"config":2069994364910194474,"compile_kind":0}

View File

@@ -0,0 +1 @@
This file has an mtime of when this was started.

View File

@@ -0,0 +1 @@
bec2f5593f5a9721

View File

@@ -0,0 +1 @@
{"rustc":13850170861107434965,"features":"[\"alloc\"]","declared_features":"[\"alloc\", \"default\", \"fresh-rust\", \"nightly\", \"serde\", \"std\"]","target":5388200169723499962,"profile":2933120001108589360,"path":3231510735981476319,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/allocator-api2-3423c5bc099b6824/dep-lib-allocator_api2","checksum":false}}],"rustflags":[],"config":2069994364910194474,"compile_kind":0}

View File

@@ -0,0 +1 @@
This file has an mtime of when this was started.

View File

@@ -0,0 +1 @@
141eb851f744372c

View File

@@ -0,0 +1 @@
{"rustc":13850170861107434965,"features":"[\"alloc\"]","declared_features":"[\"alloc\", \"default\", \"fresh-rust\", \"nightly\", \"serde\", \"std\"]","target":5388200169723499962,"profile":8526714817676984181,"path":3231510735981476319,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/allocator-api2-5e1a9299abf9b62d/dep-lib-allocator_api2","checksum":false}}],"rustflags":[],"config":2069994364910194474,"compile_kind":0}

View File

@@ -0,0 +1 @@
This file has an mtime of when this was started.

View File

@@ -0,0 +1 @@
ccee3553d23ed669

View File

@@ -0,0 +1 @@
{"rustc":13850170861107434965,"features":"[\"auto\", \"default\", \"wincon\"]","declared_features":"[\"auto\", \"default\", \"test\", \"wincon\"]","target":11278316191512382530,"profile":790325420539221616,"path":14370895825831020329,"deps":[[2608044744973004659,"anstyle_parse",false,11712505123101762351],[5652275617566266604,"anstyle_query",false,8897906317211961934],[7098682853475662231,"anstyle",false,16171958515337791055],[7711617929439759244,"colorchoice",false,15895386476031056548],[7727459912076845739,"is_terminal_polyfill",false,9537000529501752912],[17716308468579268865,"utf8parse",false,3071475116018702076]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/anstream-843fdfa1fa484b75/dep-lib-anstream","checksum":false}}],"rustflags":[],"config":2069994364910194474,"compile_kind":0}

View File

@@ -0,0 +1 @@
This file has an mtime of when this was started.

View File

@@ -0,0 +1 @@
629fb27862d5c552

View File

@@ -0,0 +1 @@
{"rustc":13850170861107434965,"features":"[\"auto\", \"default\", \"wincon\"]","declared_features":"[\"auto\", \"default\", \"test\", \"wincon\"]","target":11278316191512382530,"profile":8255941854203129366,"path":14370895825831020329,"deps":[[2608044744973004659,"anstyle_parse",false,483739466688059328],[5652275617566266604,"anstyle_query",false,4794682324865943107],[7098682853475662231,"anstyle",false,2754234408364111543],[7711617929439759244,"colorchoice",false,1743186141051787933],[7727459912076845739,"is_terminal_polyfill",false,11788193196813497614],[17716308468579268865,"utf8parse",false,13970514572916587527]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/anstream-9f443e065fccc02a/dep-lib-anstream","checksum":false}}],"rustflags":[],"config":2069994364910194474,"compile_kind":0}

View File

@@ -0,0 +1 @@
This file has an mtime of when this was started.

View File

@@ -0,0 +1 @@
4f06104596566ee0

View File

@@ -0,0 +1 @@
{"rustc":13850170861107434965,"features":"[\"default\", \"std\"]","declared_features":"[\"default\", \"std\"]","target":6165884447290141869,"profile":790325420539221616,"path":1776388941253198830,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/anstyle-59b7f3dce3c10724/dep-lib-anstyle","checksum":false}}],"rustflags":[],"config":2069994364910194474,"compile_kind":0}

View File

@@ -0,0 +1 @@
This file has an mtime of when this was started.

View File

@@ -0,0 +1 @@
b776b3129a013926

View File

@@ -0,0 +1 @@
{"rustc":13850170861107434965,"features":"[\"default\", \"std\"]","declared_features":"[\"default\", \"std\"]","target":6165884447290141869,"profile":8255941854203129366,"path":1776388941253198830,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/anstyle-72b89889b9762a69/dep-lib-anstyle","checksum":false}}],"rustflags":[],"config":2069994364910194474,"compile_kind":0}

View File

@@ -0,0 +1 @@
This file has an mtime of when this was started.

View File

@@ -0,0 +1 @@
2f636c9c5a2d8ba2

View File

@@ -0,0 +1 @@
{"rustc":13850170861107434965,"features":"[\"default\", \"utf8\"]","declared_features":"[\"core\", \"default\", \"utf8\"]","target":10225663410500332907,"profile":790325420539221616,"path":11752401407508539679,"deps":[[17716308468579268865,"utf8parse",false,3071475116018702076]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/anstyle-parse-18383d37523f6b22/dep-lib-anstyle_parse","checksum":false}}],"rustflags":[],"config":2069994364910194474,"compile_kind":0}

View File

@@ -0,0 +1 @@
This file has an mtime of when this was started.

View File

@@ -0,0 +1 @@
c057c7637b96b606

View File

@@ -0,0 +1 @@
{"rustc":13850170861107434965,"features":"[\"default\", \"utf8\"]","declared_features":"[\"core\", \"default\", \"utf8\"]","target":10225663410500332907,"profile":8255941854203129366,"path":11752401407508539679,"deps":[[17716308468579268865,"utf8parse",false,13970514572916587527]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/anstyle-parse-1de459a2ae57e150/dep-lib-anstyle_parse","checksum":false}}],"rustflags":[],"config":2069994364910194474,"compile_kind":0}

View File

@@ -0,0 +1 @@
This file has an mtime of when this was started.

View File

@@ -0,0 +1 @@
43867ad02b228a42

View File

@@ -0,0 +1 @@
{"rustc":13850170861107434965,"features":"[]","declared_features":"[]","target":10705714425685373190,"profile":14848920055892446256,"path":12988169648635213222,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/anstyle-query-f533f0c585a8a57b/dep-lib-anstyle_query","checksum":false}}],"rustflags":[],"config":2069994364910194474,"compile_kind":0}

View File

@@ -0,0 +1 @@
This file has an mtime of when this was started.

View File

@@ -0,0 +1 @@
4e8a1f02a7b67b7b

View File

@@ -0,0 +1 @@
{"rustc":13850170861107434965,"features":"[]","declared_features":"[]","target":10705714425685373190,"profile":3560010784079834850,"path":12988169648635213222,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/anstyle-query-f9937d1b48061866/dep-lib-anstyle_query","checksum":false}}],"rustflags":[],"config":2069994364910194474,"compile_kind":0}

View File

@@ -0,0 +1 @@
{"rustc":13850170861107434965,"features":"[\"default\", \"std\"]","declared_features":"[\"backtrace\", \"default\", \"std\"]","target":5408242616063297496,"profile":3033921117576893,"path":11925432468796192744,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/anyhow-0740600941488f42/dep-build-script-build-script-build","checksum":false}}],"rustflags":[],"config":2069994364910194474,"compile_kind":0}

View File

@@ -0,0 +1 @@
This file has an mtime of when this was started.

View File

@@ -0,0 +1 @@
This file has an mtime of when this was started.

View File

@@ -0,0 +1 @@
b76e890310014b91

View File

@@ -0,0 +1 @@
{"rustc":13850170861107434965,"features":"[\"default\", \"std\"]","declared_features":"[\"backtrace\", \"default\", \"std\"]","target":1563897884725121975,"profile":8276155916380437441,"path":15671268548132282421,"deps":[[12478428894219133322,"build_script_build",false,9783450043070233576]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/anyhow-46b7d8f5484c3c1b/dep-lib-anyhow","checksum":false}}],"rustflags":[],"config":2069994364910194474,"compile_kind":0}

Some files were not shown because too many files have changed in this diff Show More