use anyhow::{bail, Context, Result};
use clap::{Parser, Subcommand};
use mal_dev_core::{
container::ScfBlob,
crypto::decrypt_blob,
transform::{decode_base64_to_bytes, reverse_string},
transport::{build_client, fetch_bytes},
};
use serde::Deserialize;
use sha2::{Digest, Sha256};
use std::{path::PathBuf, time::Instant};
use tokio::fs;
use tracing::{debug, info, warn};
const DEFAULT_AAD: &str = "scf-aad-v1";
const EXPECTED_KEY_LENGTH: usize = 32;
const SAMPLE_CONFIG_NAME: &str = "lab-config";
const SAMPLE_CONFIG_VERSION: &str = "1.0";
#[derive(Parser, Debug)]
#[command(name = "loader")]
#[command(about = "Safe loader.exe: fetch + decrypt in-memory + consume (no injection)", long_about = None)]
struct Cli {
#[arg(long, default_value = DEFAULT_AAD)]
aad: String,
#[command(subcommand)]
cmd: Commands,
}
#[derive(Subcommand, Debug)]
enum Commands {
Run {
#[arg(long)]
url: Option<String>,
#[arg(long)]
url_rev: Option<String>,
#[arg(long)]
url_b64: Option<String>,
#[arg(long)]
key_b64: String,
#[arg(long)]
out: Option<PathBuf>,
},
DryRun {
#[arg(long)]
url: Option<String>,
#[arg(long)]
url_rev: Option<String>,
#[arg(long)]
url_b64: Option<String>,
},
Decrypt {
#[arg(long)]
input: PathBuf,
#[arg(long)]
key_b64: String,
#[arg(long)]
out: Option<PathBuf>,
},
GenJson {
#[arg(long)]
out: PathBuf,
},
}
#[derive(Debug, Deserialize)]
struct PayloadConfig {
name: String,
version: String,
features: Vec<String>,
}
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env().add_directive("info".parse()?))
.init();
let cli = Cli::parse();
let aad = cli.aad.as_bytes();
match cli.cmd {
Commands::Run { url, url_rev, url_b64, key_b64, out } => {
let start = Instant::now();
let resolved = resolve_url(url, url_rev, url_b64)?;
info!("resolved url ok");
let client = build_client().context("build http client")?;
let bytes = fetch_bytes(&client, &resolved).await.context("fetch blob")?;
info!("downloaded {} bytes", bytes.len());
let blob = ScfBlob::decode(&bytes).context("parse SCF container")?;
info!("container ok: version={} alg={} ct_len={}", blob.version, blob.alg, blob.ciphertext.len());
let key = decode_base64_to_bytes(&key_b64).context("decode key_b64")?;
let pt = decrypt_blob(&blob, &key, aad).context("decrypt (AEAD)")?;
info!("decrypted {} bytes in {:?}", pt.len(), start.elapsed());
let cfg: PayloadConfig = serde_json::from_slice(&pt).context("parse JSON payload config")?;
info!("consume ok: name={} version={}", cfg.name, cfg.version);
info!("features: {:?}", cfg.features);
let sha = Sha256::digest(&pt);
info!("plaintext sha256={:x}", sha);
if let Some(out_path) = out {
fs::write(&out_path, &pt).await.context("write plaintext")?;
warn!("wrote plaintext to disk at {} (memory-only is safer)", out_path.display());
}
Ok(())
}
Commands::DryRun { url, url_rev, url_b64 } => {
let resolved = resolve_url(url, url_rev, url_b64)?;
let client = build_client().context("build http client")?;
let bytes = fetch_bytes(&client, &resolved).await.context("fetch blob")?;
info!("downloaded {} bytes", bytes.len());
let blob = ScfBlob::decode(&bytes).context("parse SCF container")?;
info!("container ok: version={} alg={} nonce_len=12 ct_len={}", blob.version, blob.alg, blob.ciphertext.len());
let ct_sha = Sha256::digest(&blob.ciphertext);
info!("ciphertext sha256={:x}", ct_sha);
Ok(())
}
Commands::Decrypt { input, key_b64, out } => {
let raw = fs::read(&input).await.context("read input")?;
let blob = ScfBlob::decode(&raw).context("parse SCF container")?;
let key = decode_base64_to_bytes(&key_b64).context("decode key_b64")?;
let pt = decrypt_blob(&blob, &key, aad).context("decrypt (AEAD)")?;
let cfg: PayloadConfig = serde_json::from_slice(&pt).context("parse JSON payload config")?;
info!("consume ok: name={} version={}", cfg.name, cfg.version);
info!("features: {:?}", cfg.features);
let sha = Sha256::digest(&pt);
info!("plaintext sha256={:x}", sha);
if let Some(out_path) = out {
fs::write(&out_path, &pt).await.context("write plaintext")?;
warn!("wrote plaintext to disk at {} (memory-only is safer)", out_path.display());
} else {
warn!("no --out provided; plaintext kept in memory only");
}
Ok(())
}
Commands::GenJson { out } => {
let sample = serde_json::json!({
"name": "lab-config",
"version": "1.0",
"features": ["telemetry", "memory-only", "aead"]
});
let bytes = serde_json::to_vec_pretty(&sample).context("serialize sample json")?;
fs::write(&out, bytes).await.context("write json")?;
info!("wrote sample json to {}", out.display());
Ok(())
}
}
}
fn resolve_url(url: Option<String>, url_rev: Option<String>, url_b64: Option<String>) -> Result<String> {
let provided = url.is_some() as u8 + url_rev.is_some() as u8 + url_b64.is_some() as u8;
if provided != 1 {
bail!("provide exactly one of --url, --url-rev, or --url-b64");
}
if let Some(u) = url {
return Ok(u);
}
if let Some(r) = url_rev {
return Ok(reverse_string(&r));
}
if let Some(b64) = url_b64 {
let bytes = base64::engine::general_purpose::STANDARD
.decode(b64)
.context("decode url_b64")?;
let s = String::from_utf8(bytes).context("url_b64 is not valid utf-8")?;
return Ok(s);
}
unreachable!()
}