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:
3169
warzone/Cargo.lock
generated
Normal file
3169
warzone/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
77
warzone/Cargo.toml
Normal file
77
warzone/Cargo.toml
Normal 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"] }
|
||||
23
warzone/crates/warzone-client/Cargo.toml
Normal file
23
warzone/crates/warzone-client/Cargo.toml
Normal 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
|
||||
16
warzone/crates/warzone-client/src/cli/info.rs
Normal file
16
warzone/crates/warzone-client/src/cli/info.rs
Normal 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(())
|
||||
}
|
||||
27
warzone/crates/warzone-client/src/cli/init.rs
Normal file
27
warzone/crates/warzone-client/src/cli/init.rs
Normal 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(())
|
||||
}
|
||||
3
warzone/crates/warzone-client/src/cli/mod.rs
Normal file
3
warzone/crates/warzone-client/src/cli/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub mod info;
|
||||
pub mod init;
|
||||
pub mod recover;
|
||||
17
warzone/crates/warzone-client/src/cli/recover.rs
Normal file
17
warzone/crates/warzone-client/src/cli/recover.rs
Normal 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(())
|
||||
}
|
||||
40
warzone/crates/warzone-client/src/keystore.rs
Normal file
40
warzone/crates/warzone-client/src/keystore.rs
Normal 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))
|
||||
}
|
||||
5
warzone/crates/warzone-client/src/lib.rs
Normal file
5
warzone/crates/warzone-client/src/lib.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
pub mod cli;
|
||||
pub mod keystore;
|
||||
pub mod net;
|
||||
pub mod storage;
|
||||
pub mod tui;
|
||||
75
warzone/crates/warzone-client/src/main.rs
Normal file
75
warzone/crates/warzone-client/src/main.rs
Normal 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(())
|
||||
}
|
||||
2
warzone/crates/warzone-client/src/net.rs
Normal file
2
warzone/crates/warzone-client/src/net.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
// HTTP client for talking to warzone-server.
|
||||
// TODO: implement in Phase 1 step 9.
|
||||
2
warzone/crates/warzone-client/src/storage.rs
Normal file
2
warzone/crates/warzone-client/src/storage.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
// Local sled database: sessions, contacts, message history.
|
||||
// TODO: implement in Phase 1 step 9.
|
||||
2
warzone/crates/warzone-client/src/tui/app.rs
Normal file
2
warzone/crates/warzone-client/src/tui/app.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
// TUI App struct and event loop.
|
||||
// TODO: implement in Phase 1 step 10.
|
||||
3
warzone/crates/warzone-client/src/tui/mod.rs
Normal file
3
warzone/crates/warzone-client/src/tui/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
// TUI scaffold — ratatui app.
|
||||
// TODO: implement in Phase 1 step 10.
|
||||
pub mod app;
|
||||
9
warzone/crates/warzone-mule/Cargo.toml
Normal file
9
warzone/crates/warzone-mule/Cargo.toml
Normal 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
|
||||
1
warzone/crates/warzone-mule/src/lib.rs
Normal file
1
warzone/crates/warzone-mule/src/lib.rs
Normal file
@@ -0,0 +1 @@
|
||||
// Mule protocol implementation — Phase 4.
|
||||
4
warzone/crates/warzone-mule/src/main.rs
Normal file
4
warzone/crates/warzone-mule/src/main.rs
Normal 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.");
|
||||
}
|
||||
23
warzone/crates/warzone-protocol/Cargo.toml
Normal file
23
warzone/crates/warzone-protocol/Cargo.toml
Normal 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
|
||||
87
warzone/crates/warzone-protocol/src/crypto.rs
Normal file
87
warzone/crates/warzone-protocol/src/crypto.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
34
warzone/crates/warzone-protocol/src/errors.rs
Normal file
34
warzone/crates/warzone-protocol/src/errors.rs
Normal 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),
|
||||
}
|
||||
182
warzone/crates/warzone-protocol/src/identity.rs
Normal file
182
warzone/crates/warzone-protocol/src/identity.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
11
warzone/crates/warzone-protocol/src/lib.rs
Normal file
11
warzone/crates/warzone-protocol/src/lib.rs
Normal 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;
|
||||
35
warzone/crates/warzone-protocol/src/message.rs
Normal file
35
warzone/crates/warzone-protocol/src/message.rs
Normal 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 },
|
||||
}
|
||||
37
warzone/crates/warzone-protocol/src/mnemonic.rs
Normal file
37
warzone/crates/warzone-protocol/src/mnemonic.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
114
warzone/crates/warzone-protocol/src/prekey.rs
Normal file
114
warzone/crates/warzone-protocol/src/prekey.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
325
warzone/crates/warzone-protocol/src/ratchet.rs
Normal file
325
warzone/crates/warzone-protocol/src/ratchet.rs
Normal 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(¤t_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());
|
||||
}
|
||||
}
|
||||
}
|
||||
14
warzone/crates/warzone-protocol/src/session.rs
Normal file
14
warzone/crates/warzone-protocol/src/session.rs
Normal 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,
|
||||
}
|
||||
26
warzone/crates/warzone-protocol/src/store.rs
Normal file
26
warzone/crates/warzone-protocol/src/store.rs
Normal 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>;
|
||||
}
|
||||
68
warzone/crates/warzone-protocol/src/types.rs
Normal file
68
warzone/crates/warzone-protocol/src/types.rs
Normal 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())
|
||||
}
|
||||
}
|
||||
174
warzone/crates/warzone-protocol/src/x3dh.rs
Normal file
174
warzone/crates/warzone-protocol/src/x3dh.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
23
warzone/crates/warzone-server/Cargo.toml
Normal file
23
warzone/crates/warzone-server/Cargo.toml
Normal 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
|
||||
4
warzone/crates/warzone-server/src/config.rs
Normal file
4
warzone/crates/warzone-server/src/config.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
pub struct ServerConfig {
|
||||
pub bind_addr: String,
|
||||
pub data_dir: String,
|
||||
}
|
||||
23
warzone/crates/warzone-server/src/db.rs
Normal file
23
warzone/crates/warzone-server/src/db.rs
Normal 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
16
warzone/crates/warzone-server/src/errors.rs
Normal file
16
warzone/crates/warzone-server/src/errors.rs
Normal 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())
|
||||
}
|
||||
}
|
||||
5
warzone/crates/warzone-server/src/lib.rs
Normal file
5
warzone/crates/warzone-server/src/lib.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
pub mod config;
|
||||
pub mod db;
|
||||
pub mod errors;
|
||||
pub mod routes;
|
||||
pub mod state;
|
||||
39
warzone/crates/warzone-server/src/main.rs
Normal file
39
warzone/crates/warzone-server/src/main.rs
Normal 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(())
|
||||
}
|
||||
12
warzone/crates/warzone-server/src/routes/health.rs
Normal file
12
warzone/crates/warzone-server/src/routes/health.rs
Normal 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") }))
|
||||
}
|
||||
46
warzone/crates/warzone-server/src/routes/keys.rs
Normal file
46
warzone/crates/warzone-server/src/routes/keys.rs
Normal 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),
|
||||
}
|
||||
}
|
||||
61
warzone/crates/warzone-server/src/routes/messages.rs
Normal file
61
warzone/crates/warzone-server/src/routes/messages.rs
Normal 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 }))
|
||||
}
|
||||
14
warzone/crates/warzone-server/src/routes/mod.rs
Normal file
14
warzone/crates/warzone-server/src/routes/mod.rs
Normal 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())
|
||||
}
|
||||
15
warzone/crates/warzone-server/src/state.rs
Normal file
15
warzone/crates/warzone-server/src/state.rs
Normal 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) })
|
||||
}
|
||||
}
|
||||
1
warzone/target/.rustc_info.json
Normal file
1
warzone/target/.rustc_info.json
Normal 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":{}}
|
||||
3
warzone/target/CACHEDIR.TAG
Normal file
3
warzone/target/CACHEDIR.TAG
Normal 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/
|
||||
0
warzone/target/debug/.cargo-lock
Normal file
0
warzone/target/debug/.cargo-lock
Normal file
Binary file not shown.
@@ -0,0 +1 @@
|
||||
This file has an mtime of when this was started.
|
||||
@@ -0,0 +1 @@
|
||||
2b64005920b44b63
|
||||
@@ -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}
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
This file has an mtime of when this was started.
|
||||
@@ -0,0 +1 @@
|
||||
b4ada76d8d6ef624
|
||||
@@ -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}
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
This file has an mtime of when this was started.
|
||||
@@ -0,0 +1 @@
|
||||
bec2f5593f5a9721
|
||||
@@ -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}
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
This file has an mtime of when this was started.
|
||||
@@ -0,0 +1 @@
|
||||
141eb851f744372c
|
||||
@@ -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}
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
This file has an mtime of when this was started.
|
||||
@@ -0,0 +1 @@
|
||||
ccee3553d23ed669
|
||||
@@ -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}
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
This file has an mtime of when this was started.
|
||||
@@ -0,0 +1 @@
|
||||
629fb27862d5c552
|
||||
@@ -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}
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
This file has an mtime of when this was started.
|
||||
@@ -0,0 +1 @@
|
||||
4f06104596566ee0
|
||||
@@ -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}
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
This file has an mtime of when this was started.
|
||||
@@ -0,0 +1 @@
|
||||
b776b3129a013926
|
||||
@@ -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}
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
This file has an mtime of when this was started.
|
||||
@@ -0,0 +1 @@
|
||||
2f636c9c5a2d8ba2
|
||||
@@ -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}
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
This file has an mtime of when this was started.
|
||||
@@ -0,0 +1 @@
|
||||
c057c7637b96b606
|
||||
@@ -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}
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
This file has an mtime of when this was started.
|
||||
@@ -0,0 +1 @@
|
||||
43867ad02b228a42
|
||||
@@ -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}
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
This file has an mtime of when this was started.
|
||||
@@ -0,0 +1 @@
|
||||
4e8a1f02a7b67b7b
|
||||
@@ -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}
|
||||
@@ -0,0 +1 @@
|
||||
814bee7375cbc357
|
||||
@@ -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}
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
This file has an mtime of when this was started.
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
This file has an mtime of when this was started.
|
||||
@@ -0,0 +1 @@
|
||||
b76e890310014b91
|
||||
@@ -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
Reference in New Issue
Block a user