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

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

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

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

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

warzone-mule: Phase 4 placeholder

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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