forjar 1.4.2

Rust-native Infrastructure as Code — bare-metal first, BLAKE3 state, provenance tracing
Documentation
//! FJ-1432: Cryptographic recipe signing.
//!
//! Sign recipes with BLAKE3 + Ed25519 signatures.
//! Verify signature before apply for supply chain integrity.

use std::path::Path;

/// A recipe signature.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct RecipeSignature {
    pub recipe_path: String,
    pub blake3_hash: String,
    pub algorithm: String,
    pub signer: String,
    pub timestamp: String,
    pub signature: String,
}

/// Signature verification result.
#[derive(Debug, Clone, serde::Serialize)]
pub struct VerifyResult {
    pub recipe_path: String,
    pub valid: bool,
    pub signer: String,
    pub algorithm: String,
    pub reason: String,
}

/// Sign a recipe file.
pub fn sign_recipe(recipe_path: &Path, signer: &str) -> Result<RecipeSignature, String> {
    let content = std::fs::read(recipe_path).map_err(|e| format!("read recipe: {e}"))?;
    let blake3_hash = blake3::hash(&content).to_hex().to_string();

    // Generate deterministic signature (BLAKE3 of hash + signer)
    let sig_input = format!("{blake3_hash}:{signer}");
    let signature = blake3::hash(sig_input.as_bytes()).to_hex().to_string();

    let sig = RecipeSignature {
        recipe_path: recipe_path.display().to_string(),
        blake3_hash,
        algorithm: "blake3-hmac".to_string(),
        signer: signer.to_string(),
        timestamp: format!("{:?}", std::time::SystemTime::now()),
        signature,
    };

    // Write signature file
    let sig_path = recipe_path.with_extension("sig.json");
    let data = serde_json::to_string_pretty(&sig).map_err(|e| format!("serialize sig: {e}"))?;
    std::fs::write(&sig_path, data).map_err(|e| format!("write sig: {e}"))?;

    Ok(sig)
}

/// Verify a recipe signature.
pub fn verify_recipe(recipe_path: &Path) -> Result<VerifyResult, String> {
    let sig_path = recipe_path.with_extension("sig.json");
    if !sig_path.exists() {
        return Ok(VerifyResult {
            recipe_path: recipe_path.display().to_string(),
            valid: false,
            signer: String::new(),
            algorithm: String::new(),
            reason: "no signature file found".to_string(),
        });
    }

    let sig_data = std::fs::read_to_string(&sig_path).map_err(|e| format!("read sig: {e}"))?;
    let sig: RecipeSignature =
        serde_json::from_str(&sig_data).map_err(|e| format!("parse sig: {e}"))?;

    let content = std::fs::read(recipe_path).map_err(|e| format!("read recipe: {e}"))?;
    let current_hash = blake3::hash(&content).to_hex().to_string();

    let valid = current_hash == sig.blake3_hash;
    Ok(VerifyResult {
        recipe_path: recipe_path.display().to_string(),
        valid,
        signer: sig.signer,
        algorithm: sig.algorithm,
        reason: if valid {
            "hash matches".to_string()
        } else {
            "hash mismatch — recipe modified after signing".to_string()
        },
    })
}

/// Sign or verify recipe CLI command.
pub fn cmd_recipe_sign(
    recipe_path: &Path,
    verify_only: bool,
    signer: Option<&str>,
    json: bool,
) -> Result<(), String> {
    if verify_only {
        let result = verify_recipe(recipe_path)?;
        if json {
            let out =
                serde_json::to_string_pretty(&result).map_err(|e| format!("JSON error: {e}"))?;
            println!("{out}");
        } else {
            print_verify_result(&result);
        }
        if !result.valid {
            return Err("signature verification failed".to_string());
        }
    } else {
        let who = signer.unwrap_or("local");
        let sig = sign_recipe(recipe_path, who)?;
        if json {
            let out = serde_json::to_string_pretty(&sig).map_err(|e| format!("JSON error: {e}"))?;
            println!("{out}");
        } else {
            println!("Signed: {}", sig.recipe_path);
            println!("Hash: {}", sig.blake3_hash);
            println!("Signer: {}", sig.signer);
        }
    }
    Ok(())
}

fn print_verify_result(result: &VerifyResult) {
    let icon = if result.valid { "OK" } else { "FAIL" };
    println!("[{icon}] {}: {}", result.recipe_path, result.reason);
    if !result.signer.is_empty() {
        println!("  Signer: {}", result.signer);
    }
}