simpleaes256cli 1.0.2

Minimal AES-256-GCM file encryption CLI — encrypt/decrypt files with a password
use std::{env, fs, process};

use aes_gcm::{
    aead::{Aead, AeadCore, KeyInit},
    Aes256Gcm, Key, Nonce,
};
use argon2::Argon2;
use rand::{rngs::OsRng, RngCore};
use rpassword::prompt_password;

// File format:
//   [0..4]         magic  "AE56"
//   [4..20]        salt   16 bytes  (Argon2id input)
//   [20..32]       nonce  12 bytes  (AES-GCM nonce)
//   [32..]         ciphertext + 16-byte GCM auth tag

const MAGIC: &[u8; 4] = b"AE56";
const SALT_LEN: usize = 16;
const NONCE_LEN: usize = 12;
const HEADER_LEN: usize = 4 + SALT_LEN + NONCE_LEN; // 32

fn die(msg: &str) -> ! {
    eprintln!("error: {msg}");
    process::exit(1);
}

fn derive_key(password: &[u8], salt: &[u8]) -> [u8; 32] {
    let mut key = [0u8; 32];
    Argon2::default()
        .hash_password_into(password, salt, &mut key)
        .unwrap_or_else(|_| die("key derivation failed"));
    key
}

fn encrypt(path: &str) {
    let pass    = prompt_password("Password: ").unwrap_or_else(|_| die("failed to read password"));
    let confirm = prompt_password("Confirm:  ").unwrap_or_else(|_| die("failed to read password"));
    if pass != confirm {
        die("passwords do not match");
    }

    let plaintext = fs::read(path)
        .unwrap_or_else(|e| die(&format!("cannot read '{path}': {e}")));

    let mut salt = [0u8; SALT_LEN];
    OsRng.fill_bytes(&mut salt);
    let nonce_arr = Aes256Gcm::generate_nonce(&mut OsRng);

    let key_bytes = derive_key(pass.as_bytes(), &salt);
    let cipher    = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(&key_bytes));

    let ciphertext = cipher
        .encrypt(&nonce_arr, plaintext.as_ref())
        .unwrap_or_else(|_| die("encryption failed"));

    let mut out = Vec::with_capacity(HEADER_LEN + ciphertext.len());
    out.extend_from_slice(MAGIC);
    out.extend_from_slice(&salt);
    out.extend_from_slice(nonce_arr.as_slice());
    out.extend_from_slice(&ciphertext);

    let out_path = format!("{path}.enc");
    fs::write(&out_path, &out)
        .unwrap_or_else(|e| die(&format!("cannot write '{out_path}': {e}")));

    // Force-remove read-only flag then delete
    let meta = fs::metadata(path).unwrap_or_else(|e| die(&format!("cannot stat '{path}': {e}")));
    let mut perms = meta.permissions();
    #[allow(clippy::permissions_set_readonly_false)]
    perms.set_readonly(false);
    let _ = fs::set_permissions(path, perms);
    fs::remove_file(path).unwrap_or_else(|e| die(&format!("cannot delete '{path}': {e}")));

    println!("Encrypted as {out_path}");
}

fn decrypt(path: &str) {
    let pass = prompt_password("Password: ").unwrap_or_else(|_| die("failed to read password"));

    let data = fs::read(path)
        .unwrap_or_else(|e| die(&format!("cannot read '{path}': {e}")));

    // Minimum: header (32) + GCM tag (16)
    if data.len() < HEADER_LEN + 16 || &data[..4] != MAGIC {
        die("not a valid .enc file");
    }

    let salt         = &data[4..4 + SALT_LEN];
    let nonce        = Nonce::from_slice(&data[4 + SALT_LEN..HEADER_LEN]);
    let cipher_bytes = &data[HEADER_LEN..];

    let key_bytes = derive_key(pass.as_bytes(), salt);
    let cipher    = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(&key_bytes));

    let plaintext = cipher
        .decrypt(nonce, cipher_bytes)
        .unwrap_or_else(|_| die("decryption failed — wrong password or corrupted file"));

    // Strip .enc suffix, or append .dec if input had no .enc extension
    let out_path = match path.strip_suffix(".enc") {
        Some(p) => p.to_string(),
        None    => format!("{path}.dec"),
    };

    fs::write(&out_path, &plaintext)
        .unwrap_or_else(|e| die(&format!("cannot write '{out_path}': {e}")));

    let meta = fs::metadata(path).unwrap_or_else(|e| die(&format!("cannot stat '{path}': {e}")));
    let mut perms = meta.permissions();
    #[allow(clippy::permissions_set_readonly_false)]
    perms.set_readonly(false);
    let _ = fs::set_permissions(path, perms);
    fs::remove_file(path).unwrap_or_else(|e| die(&format!("cannot delete '{path}': {e}")));

    println!("Decrypted as {out_path}");
}

fn main() {
    let args: Vec<String> = env::args().collect();

    if args.len() != 3 {
        eprintln!("Usage:");
        eprintln!("  aes256 -e <file> encrypt");
        eprintln!("  aes256 -d <file> decrypt");
        process::exit(1);
    }

    match args[1].as_str() {
        "-e" | "--encrypt" => encrypt(&args[2]),
        "-d" | "--decrypt" => decrypt(&args[2]),
        flag => {
            eprintln!("unknown flag '{flag}' — use -e or -d");
            process::exit(1);
        }
    }
}