diff --git a/DESIGN.md b/DESIGN.md index a87ea11..63567a9 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -42,6 +42,18 @@ seed (32 bytes) → Ed25519 signing keypair + X25519 encryption keypair | CLI | `~/.warzone/identity.seed` (encrypted with passphrase via Argon2 + ChaCha20) | | Browser | IndexedDB (non-extractable CryptoKey) + seed backup prompt on first run | | Mobile (PWA) | Same as browser, seed shown as QR code for device transfer | +| Hardware wallet | Seed never leaves device. Ledger/Trezor sign via USB/BT HID. (Phase 2) | + +### Hardware Wallet Support (Phase 2) + +Ledger and Trezor can act as the key storage backend: +- Seed lives on the hardware wallet, never exported +- Ed25519 signing delegated to device (BIP44 path `m/44'/1234'/0'`) +- X25519 encryption key derived from Ed25519 via birkhoff conversion, or separate derivation path +- Client sends challenge → wallet displays → user confirms on device → signed response +- No passphrase needed (device handles authentication) +- Crates: `ledger-transport` (Ledger), `trezor-client` (Trezor) +- Protocol is unchanged — only the `KeyStore` backend differs ### Device Transfer diff --git a/warzone/Cargo.lock b/warzone/Cargo.lock index 37d1ae0..ceb1ab5 100644 --- a/warzone/Cargo.lock +++ b/warzone/Cargo.lock @@ -2564,6 +2564,7 @@ dependencies = [ "clap", "crossterm", "hex", + "libc", "rand", "ratatui", "reqwest", diff --git a/warzone/crates/warzone-client/Cargo.toml b/warzone/crates/warzone-client/Cargo.toml index e43d9ee..11312b9 100644 --- a/warzone/crates/warzone-client/Cargo.toml +++ b/warzone/crates/warzone-client/Cargo.toml @@ -24,5 +24,6 @@ hex.workspace = true base64.workspace = true x25519-dalek.workspace = true bincode.workspace = true +libc = "0.2" uuid.workspace = true chrono.workspace = true diff --git a/warzone/crates/warzone-client/src/keystore.rs b/warzone/crates/warzone-client/src/keystore.rs index 399628b..af2cefa 100644 --- a/warzone/crates/warzone-client/src/keystore.rs +++ b/warzone/crates/warzone-client/src/keystore.rs @@ -1,10 +1,24 @@ -//! Seed storage: encrypts at rest with Argon2 + ChaCha20-Poly1305. -//! For Phase 1, we store the seed in plaintext. Encryption is TODO. +//! Seed storage: encrypted at rest with Argon2id + ChaCha20-Poly1305. use std::fs; +use std::io::{self, Write}; use std::path::PathBuf; +use argon2::Argon2; +use chacha20poly1305::{ + aead::{Aead, KeyInit}, + ChaCha20Poly1305, Nonce, +}; +use rand::RngCore; use warzone_protocol::identity::Seed; +use zeroize::Zeroize; + +/// Magic bytes to identify encrypted seed files. +const MAGIC: &[u8; 4] = b"WZS1"; +/// Salt length for Argon2. +const SALT_LEN: usize = 16; +/// Nonce length for ChaCha20-Poly1305. +const NONCE_LEN: usize = 12; /// Get the warzone data directory. Respects WARZONE_HOME env var, /// falls back to ~/.warzone. @@ -21,30 +35,137 @@ fn seed_path() -> PathBuf { data_dir().join("identity.seed") } +/// Derive a 32-byte encryption key from a passphrase using Argon2id. +fn derive_key(passphrase: &[u8], salt: &[u8]) -> [u8; 32] { + let mut key = [0u8; 32]; + Argon2::default() + .hash_password_into(passphrase, salt, &mut key) + .expect("Argon2 should not fail with valid params"); + key +} + +/// Prompt for a passphrase (hidden input). +fn prompt_passphrase(prompt: &str) -> String { + eprint!("{}", prompt); + io::stderr().flush().unwrap(); + let mut pass = String::new(); + // Try to disable echo. If that fails (e.g. piped input), just read normally. + #[cfg(unix)] + { + use std::os::unix::io::AsRawFd; + let fd = io::stdin().as_raw_fd(); + let mut termios = unsafe { + let mut t = std::mem::zeroed(); + libc::tcgetattr(fd, &mut t); + t + }; + let old = termios; + termios.c_lflag &= !libc::ECHO; + unsafe { libc::tcsetattr(fd, libc::TCSANOW, &termios) }; + io::stdin().read_line(&mut pass).unwrap(); + unsafe { libc::tcsetattr(fd, libc::TCSANOW, &old) }; + eprintln!(); + } + #[cfg(not(unix))] + { + io::stdin().read_line(&mut pass).unwrap(); + } + pass.trim().to_string() +} + +/// Save seed encrypted with a passphrase. pub fn save_seed(seed: &Seed) -> anyhow::Result<()> { let path = seed_path(); if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; } - // TODO: encrypt with passphrase (Argon2 + ChaCha20-Poly1305) - fs::write(&path, &seed.0)?; - // Set permissions to owner-only on Unix + + let passphrase = prompt_passphrase("Set passphrase (empty for no encryption): "); + + if passphrase.is_empty() { + // Plaintext (legacy, for testing) + fs::write(&path, &seed.0)?; + } else { + let confirm = prompt_passphrase("Confirm passphrase: "); + if passphrase != confirm { + anyhow::bail!("Passphrases don't match"); + } + + let mut salt = [0u8; SALT_LEN]; + rand::rngs::OsRng.fill_bytes(&mut salt); + + let mut key = derive_key(passphrase.as_bytes(), &salt); + + let cipher = ChaCha20Poly1305::new((&key).into()); + let mut nonce_bytes = [0u8; NONCE_LEN]; + rand::rngs::OsRng.fill_bytes(&mut nonce_bytes); + let nonce = Nonce::from_slice(&nonce_bytes); + + let ciphertext = cipher + .encrypt(nonce, seed.0.as_slice()) + .map_err(|_| anyhow::anyhow!("encryption failed"))?; + + // File format: MAGIC(4) + salt(16) + nonce(12) + ciphertext(32+16=48) + let mut file_data = Vec::with_capacity(4 + SALT_LEN + NONCE_LEN + ciphertext.len()); + file_data.extend_from_slice(MAGIC); + file_data.extend_from_slice(&salt); + file_data.extend_from_slice(&nonce_bytes); + file_data.extend_from_slice(&ciphertext); + + fs::write(&path, &file_data)?; + key.zeroize(); + } + #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; fs::set_permissions(&path, fs::Permissions::from_mode(0o600))?; } + Ok(()) } +/// Load seed, decrypting if necessary. pub fn load_seed() -> anyhow::Result { let path = seed_path(); let bytes = fs::read(&path) .map_err(|_| anyhow::anyhow!("No identity found. Run `warzone init` first."))?; - if bytes.len() != 32 { - anyhow::bail!("Corrupted seed file"); + + // Check if encrypted + if bytes.len() >= 4 && &bytes[..4] == MAGIC { + // Encrypted format + if bytes.len() < 4 + SALT_LEN + NONCE_LEN + 48 { + anyhow::bail!("Corrupted encrypted seed file"); + } + + let salt = &bytes[4..4 + SALT_LEN]; + let nonce_bytes = &bytes[4 + SALT_LEN..4 + SALT_LEN + NONCE_LEN]; + let ciphertext = &bytes[4 + SALT_LEN + NONCE_LEN..]; + + let passphrase = prompt_passphrase("Passphrase: "); + let mut key = derive_key(passphrase.as_bytes(), salt); + + let cipher = ChaCha20Poly1305::new((&key).into()); + let nonce = Nonce::from_slice(nonce_bytes); + + let plaintext = cipher + .decrypt(nonce, ciphertext) + .map_err(|_| anyhow::anyhow!("Wrong passphrase"))?; + + key.zeroize(); + + if plaintext.len() != 32 { + anyhow::bail!("Corrupted seed data"); + } + let mut seed_bytes = [0u8; 32]; + seed_bytes.copy_from_slice(&plaintext); + Ok(Seed::from_bytes(seed_bytes)) + } else if bytes.len() == 32 { + // Legacy plaintext + let mut seed_bytes = [0u8; 32]; + seed_bytes.copy_from_slice(&bytes); + Ok(Seed::from_bytes(seed_bytes)) + } else { + anyhow::bail!("Corrupted seed file (unknown format)") } - let mut seed_bytes = [0u8; 32]; - seed_bytes.copy_from_slice(&bytes); - Ok(Seed::from_bytes(seed_bytes)) }