Compare commits
3 Commits
1e2a83402d
...
e364f437a2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e364f437a2 | ||
|
|
7451ad69bc | ||
|
|
651396fa13 |
2
warzone/.gitignore
vendored
Normal file
2
warzone/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/target/
|
||||
warzone-data/
|
||||
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);
|
||||
}
|
||||
}
|
||||
115
warzone/crates/warzone-protocol/src/prekey.rs
Normal file
115
warzone/crates/warzone-protocol/src/prekey.rs
Normal file
@@ -0,0 +1,115 @@
|
||||
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 identity_encryption_key: [u8; 32], // X25519 public 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())
|
||||
}
|
||||
}
|
||||
166
warzone/crates/warzone-protocol/src/x3dh.rs
Normal file
166
warzone/crates/warzone-protocol/src/x3dh.rs
Normal file
@@ -0,0 +1,166 @@
|
||||
//! 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()))?;
|
||||
|
||||
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);
|
||||
let their_identity_x25519 = PublicKey::from(their_bundle.identity_encryption_key);
|
||||
|
||||
// DH1: our_identity_x25519 * their_signed_pre_key
|
||||
let dh1 = our_identity.encryption.diffie_hellman(&their_spk);
|
||||
|
||||
// DH2: our_ephemeral * their_identity_x25519
|
||||
let dh2 = ephemeral_secret.diffie_hellman(&their_identity_x25519);
|
||||
|
||||
// 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_identity_x25519: &PublicKey,
|
||||
their_ephemeral_public: &PublicKey,
|
||||
) -> Result<[u8; 32], ProtocolError> {
|
||||
let their_eph = *their_ephemeral_public;
|
||||
|
||||
// DH1: our_signed_pre_key * their_identity_x25519
|
||||
let dh1 = our_signed_pre_key_secret.diffie_hellman(their_identity_x25519);
|
||||
|
||||
// DH2: our_identity_x25519 * their_ephemeral
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
#[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 alice_pub = alice_id.public_identity();
|
||||
|
||||
let bundle = PreKeyBundle {
|
||||
identity_key: *bob_pub.signing.as_bytes(),
|
||||
identity_encryption_key: *bob_pub.encryption.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_pub.encryption,
|
||||
&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;
|
||||
40
warzone/crates/warzone-server/src/main.rs
Normal file
40
warzone/crates/warzone-server/src/main.rs
Normal file
@@ -0,0 +1,40 @@
|
||||
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()
|
||||
.merge(routes::web_router())
|
||||
.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 }))
|
||||
}
|
||||
20
warzone/crates/warzone-server/src/routes/mod.rs
Normal file
20
warzone/crates/warzone-server/src/routes/mod.rs
Normal file
@@ -0,0 +1,20 @@
|
||||
mod health;
|
||||
mod keys;
|
||||
mod messages;
|
||||
mod web;
|
||||
|
||||
use axum::Router;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.merge(health::routes())
|
||||
.merge(keys::routes())
|
||||
.merge(messages::routes())
|
||||
}
|
||||
|
||||
/// Web UI router (served at root, outside /v1)
|
||||
pub fn web_router() -> Router<AppState> {
|
||||
web::routes()
|
||||
}
|
||||
371
warzone/crates/warzone-server/src/routes/web.rs
Normal file
371
warzone/crates/warzone-server/src/routes/web.rs
Normal file
@@ -0,0 +1,371 @@
|
||||
use axum::{
|
||||
response::Html,
|
||||
routing::get,
|
||||
Router,
|
||||
};
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new().route("/", get(web_ui))
|
||||
}
|
||||
|
||||
/// Serve the web client — a single-page app that talks to /v1/* APIs.
|
||||
/// Uses Web Crypto API for E2E encryption (same protocol as CLI client).
|
||||
async fn web_ui() -> Html<&'static str> {
|
||||
Html(WEB_HTML)
|
||||
}
|
||||
|
||||
const WEB_HTML: &str = r##"<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no,viewport-fit=cover">
|
||||
<meta name="theme-color" content="#0a0a1a">
|
||||
<title>Warzone</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html, body { height: 100%; overflow: hidden; }
|
||||
body { background: #0a0a1a; color: #c8d6e5; font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
display: flex; flex-direction: column; height: 100dvh; }
|
||||
|
||||
#setup { display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
flex: 1; padding: 20px; }
|
||||
#setup h1 { color: #e94560; margin-bottom: 8px; font-size: 1.5em; }
|
||||
#setup .subtitle { color: #555; margin-bottom: 24px; font-size: 0.85em; }
|
||||
#setup .fingerprint { background: #111; border: 1px solid #333; padding: 12px 20px;
|
||||
border-radius: 6px; font-size: 1.2em; color: #4ade80; letter-spacing: 2px;
|
||||
margin: 12px 0; font-family: monospace; }
|
||||
#setup .mnemonic { background: #111; border: 1px solid #333; padding: 16px; border-radius: 6px;
|
||||
margin: 12px 0; max-width: 400px; width: 100%; color: #e6a23c;
|
||||
font-size: 0.85em; line-height: 1.8; text-align: center; }
|
||||
#setup .warning { color: #e94560; font-size: 0.8em; margin: 8px 0; }
|
||||
.btn { padding: 10px 24px; background: #e94560; border: none; color: #fff; border-radius: 6px;
|
||||
cursor: pointer; font-family: inherit; font-size: 0.9em; margin: 4px; }
|
||||
.btn:hover { background: #c73e54; }
|
||||
.btn-secondary { background: #1a1a3e; border: 1px solid #444; }
|
||||
.btn-secondary:hover { background: #252550; }
|
||||
|
||||
#chat { display: none; flex-direction: column; flex: 1; }
|
||||
#chat-header { padding: 8px 12px; background: #111; border-bottom: 1px solid #222;
|
||||
display: flex; align-items: center; gap: 8px; }
|
||||
#chat-header .fp { color: #4ade80; font-size: 0.8em; }
|
||||
#chat-header .server { color: #555; font-size: 0.7em; margin-left: auto; }
|
||||
#messages { flex: 1; overflow-y: auto; padding: 12px; }
|
||||
.msg { padding: 4px 0; font-size: 0.85em; white-space: pre-wrap; word-wrap: break-word; }
|
||||
.msg .ts { color: #444; }
|
||||
.msg .sys { color: #5e9ca0; font-style: italic; }
|
||||
.msg .from { font-weight: bold; }
|
||||
.msg .dm { color: #ff6b9d; }
|
||||
#bottom { display: flex; padding: 8px; gap: 6px; border-top: 1px solid #222; background: #111; }
|
||||
#msg-input { flex: 1; padding: 10px; background: #1a1a2e; border: 1px solid #333;
|
||||
color: #c8d6e5; border-radius: 20px; font-family: inherit; font-size: 14px;
|
||||
resize: none; min-height: 40px; max-height: 120px; }
|
||||
#send-btn { padding: 10px 16px; background: #e94560; border: none; color: #fff;
|
||||
border-radius: 20px; cursor: pointer; font-size: 14px; min-height: 40px; }
|
||||
|
||||
#recover-section { display: none; margin-top: 12px; }
|
||||
#recover-input { width: 100%; max-width: 400px; padding: 10px; background: #1a1a2e;
|
||||
border: 1px solid #333; color: #c8d6e5; border-radius: 6px;
|
||||
font-family: inherit; min-height: 60px; resize: none; margin-bottom: 8px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div id="setup">
|
||||
<h1>WARZONE</h1>
|
||||
<div class="subtitle">end-to-end encrypted messenger</div>
|
||||
|
||||
<div id="new-identity" style="text-align:center">
|
||||
<button class="btn" onclick="generateIdentity()">Generate New Identity</button>
|
||||
<button class="btn btn-secondary" onclick="showRecover()">Recover from Mnemonic</button>
|
||||
|
||||
<div id="recover-section">
|
||||
<textarea id="recover-input" placeholder="Enter your 24-word mnemonic..." rows="3"></textarea>
|
||||
<br>
|
||||
<button class="btn" onclick="recoverIdentity()">Recover</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="identity-display" style="display:none; text-align:center">
|
||||
<div>Your fingerprint:</div>
|
||||
<div class="fingerprint" id="my-fingerprint"></div>
|
||||
<div class="mnemonic" id="my-mnemonic"></div>
|
||||
<div class="warning">WRITE DOWN YOUR MNEMONIC — it's the only way to recover your identity</div>
|
||||
<br>
|
||||
<button class="btn" onclick="enterChat()">Enter Chat</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="chat">
|
||||
<div id="chat-header">
|
||||
<span style="color:#e94560; font-weight:bold;">WZ</span>
|
||||
<span class="fp" id="header-fp"></span>
|
||||
<span class="server" id="header-server"></span>
|
||||
</div>
|
||||
<div id="messages"></div>
|
||||
<div id="bottom">
|
||||
<textarea id="msg-input" placeholder="Message... (Shift+Enter for newline)" rows="1"></textarea>
|
||||
<button id="send-btn" onclick="sendMessage()">▶</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ── State ──
|
||||
let seed = null; // Uint8Array(32)
|
||||
let signingKeyPair = null;
|
||||
let encryptionKeyPair = null;
|
||||
let fingerprint = '';
|
||||
let mnemonic = '';
|
||||
|
||||
const SERVER = window.location.origin;
|
||||
|
||||
// ── Crypto helpers (mirrors warzone-protocol in JS) ──
|
||||
|
||||
async function hkdfDerive(ikm, salt, info, length) {
|
||||
const key = await crypto.subtle.importKey('raw', ikm, 'HKDF', false, ['deriveBits']);
|
||||
const bits = await crypto.subtle.deriveBits(
|
||||
{ name: 'HKDF', hash: 'SHA-256', salt: salt, info: new TextEncoder().encode(info) },
|
||||
key, length * 8
|
||||
);
|
||||
return new Uint8Array(bits);
|
||||
}
|
||||
|
||||
async function deriveIdentity(seedBytes) {
|
||||
// Ed25519 for signing - derive 32 bytes
|
||||
const edSeed = await hkdfDerive(seedBytes, new Uint8Array(0), 'warzone-ed25519', 32);
|
||||
// X25519 for encryption - derive 32 bytes
|
||||
const xSeed = await hkdfDerive(seedBytes, new Uint8Array(0), 'warzone-x25519', 32);
|
||||
|
||||
// Import Ed25519 key pair
|
||||
// Note: Web Crypto doesn't support Ed25519 in all browsers yet.
|
||||
// For now we use ECDSA P-256 as a stand-in for the web client.
|
||||
// The CLI client uses the real Ed25519. Cross-client compatibility
|
||||
// will be handled via a compatibility layer in Phase 2.
|
||||
|
||||
// For the web client, we derive ECDH P-256 keys for encryption
|
||||
// and use them for the DM protocol (same as chat.py v14).
|
||||
const ecdhKeyPair = await crypto.subtle.generateKey(
|
||||
{ name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveBits']
|
||||
);
|
||||
|
||||
// Fingerprint: SHA-256 of the public key, first 16 bytes
|
||||
const pubExported = await crypto.subtle.exportKey('raw', ecdhKeyPair.publicKey);
|
||||
const hash = await crypto.subtle.digest('SHA-256', pubExported);
|
||||
const fpBytes = new Uint8Array(hash).slice(0, 16);
|
||||
|
||||
return {
|
||||
keyPair: ecdhKeyPair,
|
||||
fingerprint: fpBytes,
|
||||
seed: seedBytes,
|
||||
};
|
||||
}
|
||||
|
||||
function formatFingerprint(fpBytes) {
|
||||
const hex = Array.from(fpBytes).map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
return hex.slice(0,4) + ':' + hex.slice(4,8) + ':' + hex.slice(8,12) + ':' + hex.slice(12,16);
|
||||
}
|
||||
|
||||
// ── BIP39 (simplified — we store seed in localStorage, mnemonic is display only) ──
|
||||
// Full BIP39 requires the wordlist. For the web client, we'll hex-encode the seed
|
||||
// and let users copy it. Real BIP39 will come from WASM in Phase 2.
|
||||
|
||||
function seedToHexMnemonic(seedBytes) {
|
||||
return Array.from(seedBytes).map(b => b.toString(16).padStart(2, '0')).join(' ');
|
||||
}
|
||||
|
||||
function hexMnemonicToSeed(hex) {
|
||||
const clean = hex.replace(/\s+/g, '');
|
||||
if (clean.length !== 64) throw new Error('Invalid seed length');
|
||||
const bytes = new Uint8Array(32);
|
||||
for (let i = 0; i < 32; i++) {
|
||||
bytes[i] = parseInt(clean.substr(i * 2, 2), 16);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
// ── Identity management ──
|
||||
|
||||
async function generateIdentity() {
|
||||
seed = crypto.getRandomValues(new Uint8Array(32));
|
||||
const identity = await deriveIdentity(seed);
|
||||
encryptionKeyPair = identity.keyPair;
|
||||
fingerprint = formatFingerprint(identity.fingerprint);
|
||||
mnemonic = seedToHexMnemonic(seed);
|
||||
|
||||
// Save to localStorage
|
||||
localStorage.setItem('wz-seed', mnemonic);
|
||||
|
||||
// Display
|
||||
document.getElementById('my-fingerprint').textContent = fingerprint;
|
||||
document.getElementById('my-mnemonic').textContent = mnemonic;
|
||||
document.getElementById('new-identity').style.display = 'none';
|
||||
document.getElementById('identity-display').style.display = 'block';
|
||||
}
|
||||
|
||||
function showRecover() {
|
||||
document.getElementById('recover-section').style.display = 'block';
|
||||
}
|
||||
|
||||
async function recoverIdentity() {
|
||||
const input = document.getElementById('recover-input').value.trim();
|
||||
try {
|
||||
seed = hexMnemonicToSeed(input);
|
||||
const identity = await deriveIdentity(seed);
|
||||
encryptionKeyPair = identity.keyPair;
|
||||
fingerprint = formatFingerprint(identity.fingerprint);
|
||||
mnemonic = seedToHexMnemonic(seed);
|
||||
|
||||
localStorage.setItem('wz-seed', mnemonic);
|
||||
|
||||
document.getElementById('my-fingerprint').textContent = fingerprint;
|
||||
document.getElementById('my-mnemonic').textContent = '(recovered)';
|
||||
document.getElementById('new-identity').style.display = 'none';
|
||||
document.getElementById('identity-display').style.display = 'block';
|
||||
} catch(e) {
|
||||
alert('Invalid seed: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function tryAutoLoad() {
|
||||
const saved = localStorage.getItem('wz-seed');
|
||||
if (!saved) return;
|
||||
try {
|
||||
seed = hexMnemonicToSeed(saved);
|
||||
const identity = await deriveIdentity(seed);
|
||||
encryptionKeyPair = identity.keyPair;
|
||||
fingerprint = formatFingerprint(identity.fingerprint);
|
||||
enterChat();
|
||||
} catch(e) {
|
||||
localStorage.removeItem('wz-seed');
|
||||
}
|
||||
}
|
||||
|
||||
function enterChat() {
|
||||
document.getElementById('setup').style.display = 'none';
|
||||
document.getElementById('chat').style.display = 'flex';
|
||||
document.getElementById('header-fp').textContent = fingerprint;
|
||||
document.getElementById('header-server').textContent = SERVER;
|
||||
addSystemMsg('Identity loaded: ' + fingerprint);
|
||||
addSystemMsg('Type /help for commands');
|
||||
|
||||
// Register key with server
|
||||
registerKey();
|
||||
// Start polling
|
||||
pollLoop();
|
||||
}
|
||||
|
||||
// ── Chat ──
|
||||
|
||||
const $messages = document.getElementById('messages');
|
||||
const $input = document.getElementById('msg-input');
|
||||
|
||||
function addSystemMsg(text) {
|
||||
const d = document.createElement('div');
|
||||
d.className = 'msg';
|
||||
const t = new Date().toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'});
|
||||
d.innerHTML = '<span class="ts">' + t + '</span> <span class="sys">' + escHtml(text) + '</span>';
|
||||
$messages.appendChild(d);
|
||||
$messages.scrollTop = $messages.scrollHeight;
|
||||
}
|
||||
|
||||
function addChatMsg(from, text, isDM) {
|
||||
const d = document.createElement('div');
|
||||
d.className = 'msg';
|
||||
const t = new Date().toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'});
|
||||
const cls = isDM ? 'dm' : 'from';
|
||||
const prefix = isDM ? '🔒 ' : '';
|
||||
d.innerHTML = '<span class="ts">' + t + '</span> <span class="' + cls + '">' + prefix + escHtml(from) + '</span>: ' + escHtml(text);
|
||||
$messages.appendChild(d);
|
||||
$messages.scrollTop = $messages.scrollHeight;
|
||||
}
|
||||
|
||||
function escHtml(s) {
|
||||
const d = document.createElement('div');
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
async function registerKey() {
|
||||
const pubJwk = await crypto.subtle.exportKey('jwk', encryptionKeyPair.publicKey);
|
||||
try {
|
||||
await fetch(SERVER + '/v1/keys/register', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
fingerprint: fingerprint,
|
||||
bundle: Array.from(new TextEncoder().encode(JSON.stringify(pubJwk)))
|
||||
})
|
||||
});
|
||||
addSystemMsg('Key registered with server');
|
||||
} catch(e) {
|
||||
addSystemMsg('Failed to register key: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function pollLoop() {
|
||||
while (true) {
|
||||
try {
|
||||
const resp = await fetch(SERVER + '/v1/messages/poll/' + encodeURIComponent(fingerprint));
|
||||
if (resp.ok) {
|
||||
const msgs = await resp.json();
|
||||
for (const msg of msgs) {
|
||||
// TODO: decrypt with ratchet. For now just display.
|
||||
addChatMsg('encrypted', '[encrypted message — ratchet decryption TODO]', true);
|
||||
}
|
||||
}
|
||||
} catch(e) {
|
||||
// Server offline, retry
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 5000));
|
||||
}
|
||||
}
|
||||
|
||||
function sendMessage() {
|
||||
const text = $input.value.trim();
|
||||
if (!text) return;
|
||||
|
||||
if (text === '/help') {
|
||||
addSystemMsg('Commands:');
|
||||
addSystemMsg(' /info — show your fingerprint');
|
||||
addSystemMsg(' /seed — show your seed (CAREFUL!)');
|
||||
addSystemMsg(' /send <fingerprint> <message> — send encrypted message');
|
||||
addSystemMsg(' /help — this help');
|
||||
$input.value = '';
|
||||
return;
|
||||
}
|
||||
if (text === '/info') {
|
||||
addSystemMsg('Fingerprint: ' + fingerprint);
|
||||
$input.value = '';
|
||||
return;
|
||||
}
|
||||
if (text === '/seed') {
|
||||
addSystemMsg('Seed: ' + seedToHexMnemonic(seed));
|
||||
$input.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: /send command, general chat via groups
|
||||
addSystemMsg('Message sending not yet wired — Phase 1 in progress');
|
||||
$input.value = '';
|
||||
}
|
||||
|
||||
// Keyboard
|
||||
$input.onkeydown = function(e) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
sendMessage();
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-resize
|
||||
$input.addEventListener('input', function() {
|
||||
this.style.height = 'auto';
|
||||
this.style.height = Math.min(this.scrollHeight, 120) + 'px';
|
||||
});
|
||||
|
||||
// Auto-load saved identity on page load
|
||||
tryAutoLoad();
|
||||
</script>
|
||||
</body>
|
||||
</html>"##;
|
||||
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) })
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user