vios_app 0.1.1

Small JSON vaults: Argon2id + AES-GCM, with optional AAD binding.
Documentation
use std::path::Path;

use clap::{Parser, Subcommand, ValueEnum};

use vios_app::vault::vault_crypto::{
    read_vault_auto_file_with_aadmode, write_vault_v2_file_with_aadmode, AadMode, VaultCryptoError,
};

#[derive(Copy, Clone, Debug, ValueEnum)]
enum AadChoice {
    /// Portable: no binding (can be moved freely)
    None,
    /// Bind to file path (moving breaks decryption)
    Path,
    /// Use custom bytes (hex) as AAD
    Custom,
}

fn get_pw(opt: Option<String>, env_name: &str, prompt: &str) -> String {
    if let Some(s) = opt {
        return s;
    }
    if let Ok(s) = std::env::var(env_name) {
        return s;
    }
    rpassword::prompt_password(prompt).expect("failed to read password")
}

/// Parse even-length ASCII hex into bytes; exits with code 2 on error.
fn parse_hex_or_die(s: &str) -> Vec<u8> {
    let s = s.trim();
    if s.len() % 2 != 0 || !s.chars().all(|c| c.is_ascii_hexdigit()) {
        eprintln!("ERROR: --aad-hex must be even-length ASCII hex");
        std::process::exit(2);
    }
    (0..s.len())
        .step_by(2)
        .map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap())
        .collect()
}

/// Build an AadMode and (optionally) a Vec<u8> that must be kept alive while using the mode.
/// Return value pattern lets the Vec live long enough without leaking.
fn make_mode(aad: AadChoice, aad_hex: &Option<String>) -> AadMode<'static> {
    match aad {
        AadChoice::None => AadMode::None,
        AadChoice::Path => AadMode::Path,
        AadChoice::Custom => {
            let hex = aad_hex.as_deref().unwrap_or_else(|| {
                eprintln!("ERROR: --aad custom requires --aad-hex <HEX>");
                std::process::exit(2);
            });
            let bytes = parse_hex_or_die(hex);
            // Leak: OK for short-lived CLI to avoid borrowing a local Vec.
            let leaked: &'static [u8] = Box::leak(bytes.into_boxed_slice());
            AadMode::Custom(leaked)
        }
    }
}

#[derive(Parser)]
#[command(name = "vaultkit")]
#[command(about = "Seal / open small JSON vaults (Argon2id + AES-GCM)")]
struct Cli {
    #[command(subcommand)]
    cmd: Cmd,
}

#[derive(Subcommand)]
enum Cmd {
    /// Create a new vault file from JSON input
    New {
        /// JSON input file
        #[arg(long)]
        input: String,
        /// Vault output path
        #[arg(long)]
        output: String,
        /// Password (or use env VIOS_VAULT_PASSWORD)
        #[arg(long)]
        password: Option<String>,
        /// AAD binding: none (portable), path (anti-relocation), or custom
        #[arg(long, value_enum, default_value_t = AadChoice::None)]
        aad: AadChoice,
        /// Required if --aad custom (ASCII hex)
        #[arg(long = "aad-hex")]
        aad_hex: Option<String>,
    },

    /// View plaintext JSON
    View {
        #[arg(long)]
        file: String,
        /// Password (or use env VIOS_VAULT_PASSWORD)
        #[arg(long)]
        password: Option<String>,
        #[arg(long, value_enum, default_value_t = AadChoice::None)]
        aad: AadChoice,
        /// Required if --aad custom (ASCII hex)
        #[arg(long = "aad-hex")]
        aad_hex: Option<String>,
    },

    /// Verify decryptability (no output)
    Verify {
        #[arg(long)]
        file: String,
        /// Password (or use env VIOS_VAULT_PASSWORD)
        #[arg(long)]
        password: Option<String>,
        #[arg(long, value_enum, default_value_t = AadChoice::None)]
        aad: AadChoice,
        /// Required if --aad custom (ASCII hex)
        #[arg(long = "aad-hex")]
        aad_hex: Option<String>,
    },

    /// Set/replace RFC3339 expiry in the JSON
    SetExpiry {
        #[arg(long)]
        file: String,
        #[arg(long = "expires-at")]
        expires_at: String,
        /// Password (or use env VIOS_VAULT_PASSWORD)
        #[arg(long)]
        password: Option<String>,
        #[arg(long, value_enum, default_value_t = AadChoice::None)]
        aad: AadChoice,
        /// Required if --aad custom (ASCII hex)
        #[arg(long = "aad-hex")]
        aad_hex: Option<String>,
    },

    /// Set the burn_on_view boolean
    SetBurnFlag {
        #[arg(long)]
        file: String,
        #[arg(long = "value")]
        value: bool,
        /// Password (or use env VIOS_VAULT_PASSWORD)
        #[arg(long)]
        password: Option<String>,
        #[arg(long, value_enum, default_value_t = AadChoice::None)]
        aad: AadChoice,
        /// Required if --aad custom (ASCII hex)
        #[arg(long = "aad-hex")]
        aad_hex: Option<String>,
    },

    /// Rotate the password (uses env: VIOS_OLD_PASSWORD, VIOS_NEW_PASSWORD)
    RotateKey {
        #[arg(long)]
        file: String,
        /// AAD binding when decrypting/rewriting the vault
        #[arg(long, value_enum, default_value_t = AadChoice::None)]
        aad: AadChoice,
        /// Required if --aad custom (ASCII hex)
        #[arg(long = "aad-hex")]
        aad_hex: Option<String>,
    },

    /// Delete the vault file
    Burn {
        #[arg(long)]
        file: String,
    },
}

fn main() -> anyhow::Result<()> {
    let cli = Cli::parse();
    match cli.cmd {
        Cmd::New {
            input,
            output,
            password,
            aad,
            aad_hex,
        } => {
            let pw = get_pw(password, "VIOS_VAULT_PASSWORD", "Enter password: ");
            let pt = std::fs::read(&input)?;
            let mode = make_mode(aad, &aad_hex);
            write_vault_v2_file_with_aadmode(Path::new(&output), &pw, &pt, mode)
                .map_err(|e| anyhow::anyhow!("encrypt+save -> {}: {}", output, err(&e)))?;
            println!("OK • New vault -> {}", output);
        }

        Cmd::View {
            file,
            password,
            aad,
            aad_hex,
        } => {
            let pw = get_pw(password, "VIOS_VAULT_PASSWORD", "Enter password: ");
            let mode = make_mode(aad, &aad_hex);
            let pt = read_vault_auto_file_with_aadmode(Path::new(&file), &pw, mode)
                .map_err(|e| anyhow::anyhow!("decrypt -> {}: {}", file, err(&e)))?;
            println!("{}", String::from_utf8_lossy(&pt));
        }

        Cmd::Verify {
            file,
            password,
            aad,
            aad_hex,
        } => {
            let pw = get_pw(password, "VIOS_VAULT_PASSWORD", "Enter password: ");
            let mode = make_mode(aad, &aad_hex);
            let _ = read_vault_auto_file_with_aadmode(Path::new(&file), &pw, mode)
                .map_err(|e| anyhow::anyhow!("decrypt -> {}: {}", file, err(&e)))?;
            println!("OK • decryptable");
        }

        Cmd::SetExpiry {
            file,
            expires_at,
            password,
            aad,
            aad_hex,
        } => {
            let pw = get_pw(password, "VIOS_VAULT_PASSWORD", "Enter password: ");
            let mode = make_mode(aad, &aad_hex);
            let pt = read_vault_auto_file_with_aadmode(Path::new(&file), &pw, mode)
                .map_err(|e| anyhow::anyhow!("decrypt -> {}: {}", file, err(&e)))?;
            let mut v: serde_json::Value = serde_json::from_slice(&pt)?;
            v["expires_at"] = serde_json::Value::String(expires_at);
            let out = serde_json::to_vec_pretty(&v)?;
            write_vault_v2_file_with_aadmode(Path::new(&file), &pw, &out, mode)
                .map_err(|e| anyhow::anyhow!("encrypt+save -> {}: {}", file, err(&e)))?;
            println!("OK • expires_at updated");
        }

        Cmd::SetBurnFlag {
            file,
            value,
            password,
            aad,
            aad_hex,
        } => {
            let pw = get_pw(password, "VIOS_VAULT_PASSWORD", "Enter password: ");
            let mode = make_mode(aad, &aad_hex);
            let pt = read_vault_auto_file_with_aadmode(Path::new(&file), &pw, mode)
                .map_err(|e| anyhow::anyhow!("decrypt -> {}: {}", file, err(&e)))?;
            let mut v: serde_json::Value = serde_json::from_slice(&pt)?;
            v["burn_on_view"] = serde_json::Value::Bool(value);
            let out = serde_json::to_vec_pretty(&v)?;
            write_vault_v2_file_with_aadmode(Path::new(&file), &pw, &out, mode)
                .map_err(|e| anyhow::anyhow!("encrypt+save -> {}: {}", file, err(&e)))?;
            println!("OK • burn_on_view = {}", value);
        }

        Cmd::RotateKey { file, aad, aad_hex } => {
            let old_pw = std::env::var("VIOS_OLD_PASSWORD")
                .map_err(|_| anyhow::anyhow!("set VIOS_OLD_PASSWORD"))?;
            let new_pw = std::env::var("VIOS_NEW_PASSWORD")
                .map_err(|_| anyhow::anyhow!("set VIOS_NEW_PASSWORD"))?;
            let mode = make_mode(aad, &aad_hex);
            let pt = read_vault_auto_file_with_aadmode(Path::new(&file), &old_pw, mode)
                .map_err(|e| anyhow::anyhow!("decrypt(old) -> {}: {}", file, err(&e)))?;
            write_vault_v2_file_with_aadmode(Path::new(&file), &new_pw, &pt, mode)
                .map_err(|e| anyhow::anyhow!("encrypt+save(new) -> {}: {}", file, err(&e)))?;
            println!("OK • password rotated");
        }

        Cmd::Burn { file } => {
            std::fs::remove_file(&file)?;
            println!("BURN • {}", file);
        }
    }
    Ok(())
}

fn err(e: &VaultCryptoError) -> String {
    use VaultCryptoError::*;
    match e {
        InvalidHeader => "invalid header".into(),
        UnsupportedVersion => "unsupported version".into(),
        UnsupportedKdf => "unsupported kdf".into(),
        CryptoFailure => "crypto failure".into(),
        Io(inner) => format!("io error: {}", inner),
        Format(s) => format!("format error: {}", s),
    }
}