greentic-setup 0.4.28

End-to-end bundle setup engine for the Greentic platform — pack discovery, QA-driven configuration, secrets persistence, and bundle lifecycle management
Documentation
use aes_gcm_siv::aead::{Aead, KeyInit};
use aes_gcm_siv::{Aes256GcmSiv, Nonce};
use anyhow::{Result, anyhow};
use base64::Engine as _;
use base64::engine::general_purpose::STANDARD;
use rand::Rng;
use rpassword::prompt_password;
use serde_json::{Map as JsonMap, Value};
use sha2::{Digest, Sha256};

const ENCRYPTED_KIND: &str = "aes-256-gcm-siv-v1";
const MARKER_FIELD: &str = "__greentic_encrypted__";
const NONCE_FIELD: &str = "nonce";
const CIPHERTEXT_FIELD: &str = "ciphertext";

pub fn prompt_for_key(action: &str) -> Result<String> {
    let prompt = format!("Answer file key for {action}: ");
    let key = prompt_password(prompt).map_err(|err| anyhow!("read key: {err}"))?;
    if key.is_empty() {
        return Err(anyhow!("key cannot be empty"));
    }
    Ok(key)
}

pub fn has_encrypted_values(value: &Value) -> bool {
    match value {
        Value::Object(map) => is_encrypted_value(map) || map.values().any(has_encrypted_values),
        Value::Array(items) => items.iter().any(has_encrypted_values),
        _ => false,
    }
}

pub fn encrypt_value(value: &Value, key: &str) -> Result<Value> {
    let plaintext = serde_json::to_vec(value)?;
    let derived_key = derive_key(key);
    let cipher =
        Aes256GcmSiv::new_from_slice(&derived_key).map_err(|err| anyhow!("init cipher: {err}"))?;
    let mut nonce_bytes = [0_u8; 12];
    rand::rng().fill_bytes(&mut nonce_bytes);
    let nonce = Nonce::from_slice(&nonce_bytes);
    let ciphertext = cipher
        .encrypt(nonce, plaintext.as_ref())
        .map_err(|err| anyhow!("encrypt answer value: {err}"))?;
    Ok(serde_json::json!({
        MARKER_FIELD: ENCRYPTED_KIND,
        NONCE_FIELD: STANDARD.encode(nonce_bytes),
        CIPHERTEXT_FIELD: STANDARD.encode(ciphertext),
    }))
}

pub fn decrypt_value(value: &Value, key: &str) -> Result<Value> {
    let map = value
        .as_object()
        .ok_or_else(|| anyhow!("encrypted answer payload must be an object"))?;
    if !is_encrypted_value(map) {
        return Ok(value.clone());
    }
    let nonce_b64 = map
        .get(NONCE_FIELD)
        .and_then(Value::as_str)
        .ok_or_else(|| anyhow!("encrypted answer payload missing nonce"))?;
    let ciphertext_b64 = map
        .get(CIPHERTEXT_FIELD)
        .and_then(Value::as_str)
        .ok_or_else(|| anyhow!("encrypted answer payload missing ciphertext"))?;
    let nonce_bytes = STANDARD
        .decode(nonce_b64)
        .map_err(|err| anyhow!("decode nonce: {err}"))?;
    let ciphertext = STANDARD
        .decode(ciphertext_b64)
        .map_err(|err| anyhow!("decode ciphertext: {err}"))?;
    let derived_key = derive_key(key);
    let cipher =
        Aes256GcmSiv::new_from_slice(&derived_key).map_err(|err| anyhow!("init cipher: {err}"))?;
    let plaintext = cipher
        .decrypt(Nonce::from_slice(&nonce_bytes), ciphertext.as_ref())
        .map_err(|err| anyhow!("decrypt answer value: {err}"))?;
    serde_json::from_slice(&plaintext).map_err(|err| anyhow!("decode decrypted JSON: {err}"))
}

pub fn decrypt_tree(value: &Value, key: &str) -> Result<Value> {
    match value {
        Value::Object(map) => {
            if is_encrypted_value(map) {
                return decrypt_value(value, key);
            }
            let mut out = JsonMap::new();
            for (k, v) in map {
                out.insert(k.clone(), decrypt_tree(v, key)?);
            }
            Ok(Value::Object(out))
        }
        Value::Array(items) => {
            let mut out = Vec::with_capacity(items.len());
            for item in items {
                out.push(decrypt_tree(item, key)?);
            }
            Ok(Value::Array(out))
        }
        _ => Ok(value.clone()),
    }
}

fn derive_key(key: &str) -> [u8; 32] {
    let digest = Sha256::digest(key.as_bytes());
    let mut out = [0_u8; 32];
    out.copy_from_slice(&digest);
    out
}

fn is_encrypted_value(map: &JsonMap<String, Value>) -> bool {
    map.get(MARKER_FIELD).and_then(Value::as_str) == Some(ENCRYPTED_KIND)
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    #[test]
    fn encrypt_decrypt_roundtrip() {
        let value = json!({"secret": "abc", "enabled": true});
        let encrypted = encrypt_value(&value, "demo-key").expect("encrypt");
        assert!(has_encrypted_values(&encrypted));
        let decrypted = decrypt_value(&encrypted, "demo-key").expect("decrypt");
        assert_eq!(decrypted, value);
    }

    #[test]
    fn decrypt_tree_walks_nested_values() {
        let inner = encrypt_value(&json!("abc"), "demo-key").expect("encrypt");
        let doc = json!({"setup_answers": {"provider": {"token": inner}}});
        let decrypted = decrypt_tree(&doc, "demo-key").expect("decrypt tree");
        assert_eq!(
            decrypted["setup_answers"]["provider"]["token"],
            json!("abc")
        );
    }

    #[test]
    fn has_encrypted_values_detects_arrays_and_nested_objects() {
        let plain = json!({"list": [1, 2, 3]});
        assert!(!has_encrypted_values(&plain));

        let encrypted = encrypt_value(&json!("xyz"), "demo-key").expect("encrypt");
        let wrapped = json!({"nested": [{"v": encrypted}]});
        assert!(has_encrypted_values(&wrapped));
    }

    #[test]
    fn decrypt_value_passthrough_for_plain_object() {
        let value = json!({"k": "v"});
        let out = decrypt_value(&value, "any-key").expect("passthrough");
        assert_eq!(out, value);
    }

    #[test]
    fn decrypt_value_rejects_missing_nonce() {
        let encrypted = encrypt_value(&json!("secret"), "demo-key").expect("encrypt");
        let mut map = encrypted.as_object().cloned().expect("object");
        map.remove(NONCE_FIELD);
        let err = decrypt_value(&Value::Object(map), "demo-key")
            .expect_err("missing nonce should fail")
            .to_string();
        assert!(err.contains("missing nonce"), "unexpected error: {err}");
    }

    #[test]
    fn decrypt_value_rejects_invalid_base64() {
        let encrypted = encrypt_value(&json!("secret"), "demo-key").expect("encrypt");
        let mut map = encrypted.as_object().cloned().expect("object");
        map.insert(NONCE_FIELD.to_string(), Value::String("***".to_string()));
        let err = decrypt_value(&Value::Object(map), "demo-key")
            .expect_err("invalid nonce encoding should fail")
            .to_string();
        assert!(err.contains("decode nonce"), "unexpected error: {err}");
    }

    #[test]
    fn decrypt_value_fails_with_wrong_key() {
        let encrypted = encrypt_value(&json!({"token": "abc"}), "key-a").expect("encrypt");
        let err = decrypt_value(&encrypted, "key-b")
            .expect_err("wrong key should fail")
            .to_string();
        assert!(
            err.contains("decrypt answer value"),
            "unexpected error: {err}"
        );
    }
}