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