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