payload-loader 0.1.0

Production-ready Rust loader with cryptography, configuration, and security features
Documentation

//! # Loader
//!
//! A secure payload loader that fetches encrypted blobs, decrypts them in-memory using AEAD,
//! and consumes the plaintext as JSON configuration. No code injection—just safe serialization.
//!
//! ## Features
//! - Flexible URL resolution (plain, reversed, or base64-encoded)
//! - AEAD encryption with authenticated data (AAD)
//! - In-memory decryption with optional disk output (with warnings)
//! - Integrity verification via SHA-256
//! - Dry-run mode for validation without decryption
//! - Local blob decryption support

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};

// Constants
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 {
    /// AEAD AAD must match what was used during encryption
    #[arg(long, default_value = DEFAULT_AAD)]
    aad: String,

    /// Increase verbosity via RUST_LOG, e.g. `RUST_LOG=debug`
    #[command(subcommand)]
    cmd: Commands,
}

#[derive(Subcommand, Debug)]
enum Commands {
    /// Full pipeline: fetch -> transform -> decrypt (memory) -> consume (parse JSON)
    Run {
        #[arg(long)]
        url: Option<String>,

        /// URL stored reversed (runtime reconstructed)
        #[arg(long)]
        url_rev: Option<String>,

        /// URL stored base64 (runtime decoded)
        #[arg(long)]
        url_b64: Option<String>,

        /// 32-byte key (base64)
        #[arg(long)]
        key_b64: String,

        /// Optional: write decrypted bytes to disk (defaults to memory-only)
        #[arg(long)]
        out: Option<PathBuf>,
    },

    /// Only fetch + parse container + hash ciphertext. No decrypt.
    DryRun {
        #[arg(long)]
        url: Option<String>,
        #[arg(long)]
        url_rev: Option<String>,
        #[arg(long)]
        url_b64: Option<String>,
    },

    /// Decrypt a local .scf blob and consume it (parse JSON)
    Decrypt {
        #[arg(long)]
        input: PathBuf,
        #[arg(long)]
        key_b64: String,
        #[arg(long)]
        out: Option<PathBuf>,
    },

    /// Generate a sample JSON payload file (safe) for lab testing
    GenJson {
        #[arg(long)]
        out: PathBuf,
    },
}

#[derive(Debug, Deserialize)]
struct PayloadConfig {
    name: String,
    version: String,
    features: Vec<String>,
    // Add fields as needed for your lab
}

/// Main entry point: parse CLI args and dispatch to appropriate command handler
#[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());

            // Safe “consume”: parse JSON config
            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);

            // Integrity signal for ops/debug: SHA-256 of plaintext
            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!()
}