TUI fixes: - /r and /reply now work: tracks last_dm_peer from received messages - /r switches peer to last DM sender, then type normally - /p @alias works as shortcut for /peer @alias - /eth shows Ethereum address in TUI - /unalias removes your alias Web fixes: - /p @alias and /peer @alias resolve and set peer - /r and /reply work (switch to last DM sender) - /unalias removes alias - /admin-unalias <alias> <password> for admin removal - File download now shows as clickable link (not auto-download) Server: - POST /v1/alias/unregister — remove own alias - POST /v1/alias/admin-remove — admin removes any alias - WARZONE_ADMIN_PASSWORD env var (default: "admin") Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
178 lines
5.4 KiB
Rust
178 lines
5.4 KiB
Rust
//! 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.
|
|
pub fn data_dir() -> PathBuf {
|
|
if let Ok(wz) = std::env::var("WARZONE_HOME") {
|
|
PathBuf::from(wz)
|
|
} else {
|
|
let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
|
|
PathBuf::from(home).join(".warzone")
|
|
}
|
|
}
|
|
|
|
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)?;
|
|
}
|
|
|
|
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 raw seed bytes (for deriving eth address etc).
|
|
pub fn load_seed_raw() -> anyhow::Result<[u8; 32]> {
|
|
let seed = load_seed()?;
|
|
Ok(seed.0)
|
|
}
|
|
|
|
/// Load seed, decrypting if necessary.
|
|
pub fn load_seed() -> anyhow::Result<Seed> {
|
|
let path = seed_path();
|
|
let bytes = fs::read(&path)
|
|
.map_err(|_| anyhow::anyhow!("No identity found. Run `warzone init` first."))?;
|
|
|
|
// 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)")
|
|
}
|
|
}
|