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:
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user