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>
183 lines
5.4 KiB
Rust
183 lines
5.4 KiB
Rust
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);
|
|
}
|
|
}
|