exochain-node 0.2.3

EXOCHAIN distributed node — single binary for joining and participating in the constitutional governance network
//! LiveSafe public-output AVC ceremony CLI implementation.

use std::{fs, io::Write, path::Path};

use exo_authority::permission::Permission;
use exo_core::{Did, Timestamp, crypto::KeyPair};
use serde::{Deserialize, Serialize};
use zeroize::{Zeroize, Zeroizing};

use crate::cli::{
    AvcCommand, LivesafePublicOutputCeremonyCommand, LivesafePublicOutputCeremonyPrepareArgs,
    LivesafePublicOutputCeremonyRegisterArgs,
};

#[derive(Deserialize, Zeroize)]
#[zeroize(drop)]
struct IssuerSigningMaterial {
    #[zeroize(skip)]
    issuer_did: Did,
    signing_secret_hex: String,
}

#[derive(Serialize)]
struct RegistrationCommandOutput {
    credential_id: exo_core::Hash256,
    authorization_request: exo_avc::LivesafePublicOutputAuthorizationRequestMaterial,
    http_status: u16,
    response_body: String,
}

pub async fn run_avc_command(command: AvcCommand) -> anyhow::Result<()> {
    match command {
        AvcCommand::LivesafePublicOutputCeremony { command } => match command {
            LivesafePublicOutputCeremonyCommand::Prepare(args) => run_prepare(args),
            LivesafePublicOutputCeremonyCommand::Register(args) => run_register(args).await,
        },
    }
}

fn run_prepare(args: LivesafePublicOutputCeremonyPrepareArgs) -> anyhow::Result<()> {
    let issuer_did = Did::new(&args.issuer_did)
        .map_err(|error| anyhow::anyhow!("invalid issuer DID: {error}"))?;
    let signing_material = read_signing_material(&args.issuer_secret_input)?;
    if signing_material.issuer_did != issuer_did {
        anyhow::bail!(
            "issuer signing material DID {} does not match --issuer-did {}",
            signing_material.issuer_did,
            issuer_did
        );
    }
    let signing_secret = decode_fixed_32(signing_material.signing_secret_hex.as_str())?;
    let issuer_keypair = KeyPair::from_secret_bytes(*signing_secret)
        .map_err(|error| anyhow::anyhow!("issuer signing key is invalid: {error}"))?;
    if let Some(path) = &args.evidence_input {
        require_non_empty_evidence_file(path)?;
    }
    let evidence_hash = exo_avc::parse_livesafe_public_output_evidence_sha256(&args.evidence_hash)
        .map_err(|error| anyhow::anyhow!("{error}"))?;
    let input = exo_avc::LivesafePublicOutputCredentialCeremonyInput {
        issuer_did,
        issuer_authority_scope: livesafe_public_output_scope(),
        credential_subject_did: Did::new(
            exo_avc::LIVESAFE_PUBLIC_ADAPTER_OUTPUT_CREDENTIAL_SUBJECT_DID,
        )
        .map_err(|error| anyhow::anyhow!("invalid LiveSafe credential subject DID: {error}"))?,
        public_subject: exo_avc::LIVESAFE_PUBLIC_ADAPTER_OUTPUT_AUTHORIZATION_SUBJECT.into(),
        public_audience: exo_avc::LIVESAFE_PUBLIC_ADAPTER_OUTPUT_AUTHORIZATION_AUDIENCE.into(),
        allowed_claim_names: vec![
            exo_avc::LIVESAFE_PUBLIC_ADAPTER_OUTPUT_AUTHORIZATION_DOMAIN.into(),
        ],
        evidence: exo_avc::LivesafePublicOutputCredentialCeremonyEvidence {
            sha256_hash: evidence_hash,
        },
        not_before: Timestamp::new(args.not_before_physical_ms, 0),
        expires_at: Timestamp::new(args.expires_at_physical_ms, 0),
        idempotency_key: args.idempotency_key,
    };
    let output = exo_avc::issue_livesafe_public_output_credential_ceremony(input, |payload| {
        issuer_keypair.sign(payload)
    })
    .map_err(|error| anyhow::anyhow!("{error}"))?;
    write_output(args.output.as_deref(), &output)
}

async fn run_register(args: LivesafePublicOutputCeremonyRegisterArgs) -> anyhow::Result<()> {
    let package: exo_avc::LivesafePublicOutputCredentialCeremonyOutput = read_json(&args.input)?;
    let bearer = read_admin_bearer(&args)?;
    let url = avc_issue_url(&args.node_url);
    let response = reqwest::Client::new()
        .post(url)
        .bearer_auth(bearer.as_str())
        .json(&package.issue_request)
        .send()
        .await?;
    let status = response.status();
    let body = response.text().await?;
    let sanitized_body = redact_token(&body, bearer.as_str());
    let output = RegistrationCommandOutput {
        credential_id: package.credential_id,
        authorization_request: package.authorization_request,
        http_status: status.as_u16(),
        response_body: sanitized_body,
    };
    if !status.is_success() {
        anyhow::bail!(
            "LiveSafe public-output credential registration failed: HTTP {}: {}",
            output.http_status,
            output.response_body
        );
    }
    write_output(args.output.as_deref(), &output)
}

fn livesafe_public_output_scope() -> exo_avc::AuthorityScope {
    exo_avc::AuthorityScope {
        permissions: vec![Permission::Read],
        tools: vec![exo_avc::LIVESAFE_PUBLIC_ADAPTER_OUTPUT_AUTHORIZATION_DOMAIN.into()],
        data_classes: vec![exo_avc::DataClass::Public],
        counterparties: vec![],
        jurisdictions: vec!["US".into()],
    }
}

fn avc_issue_url(base: &str) -> String {
    let trimmed = base.trim_end_matches('/');
    if trimmed.ends_with("/api/v1/avc/issue") {
        trimmed.to_owned()
    } else {
        format!("{trimmed}/api/v1/avc/issue")
    }
}

fn read_admin_bearer(
    args: &LivesafePublicOutputCeremonyRegisterArgs,
) -> anyhow::Result<Zeroizing<String>> {
    let token = match (&args.admin_bearer_env, &args.admin_bearer_file) {
        (Some(env_name), None) => Zeroizing::new(std::env::var(env_name).map_err(|error| {
            anyhow::anyhow!("admin bearer env var {env_name} is unavailable: {error}")
        })?),
        (None, Some(path)) => {
            let raw = fs::read_to_string(path).map_err(|error| {
                anyhow::anyhow!(
                    "admin bearer file {} is unavailable: {error}",
                    path.display()
                )
            })?;
            Zeroizing::new(raw)
        }
        _ => anyhow::bail!("exactly one admin bearer source is required"),
    };
    let trimmed = token.trim();
    if trimmed.is_empty() {
        anyhow::bail!("admin bearer source is empty");
    }
    Ok(Zeroizing::new(trimmed.to_owned()))
}

fn require_non_empty_evidence_file(path: &Path) -> anyhow::Result<()> {
    let metadata = fs::metadata(path).map_err(|error| {
        anyhow::anyhow!(
            "failed to inspect evidence input {}: {error}",
            path.display()
        )
    })?;
    if !metadata.is_file() {
        anyhow::bail!("evidence input {} is not a file", path.display());
    }
    if metadata.len() == 0 {
        anyhow::bail!("evidence input {} is empty", path.display());
    }
    Ok(())
}

fn redact_token(text: &str, token: &str) -> String {
    text.replace(token, "[redacted-admin-bearer]")
}

fn read_json<T: for<'de> Deserialize<'de>>(path: &Path) -> anyhow::Result<T> {
    let bytes = fs::read(path)
        .map_err(|error| anyhow::anyhow!("failed to read {}: {error}", path.display()))?;
    serde_json::from_slice(&bytes)
        .map_err(|error| anyhow::anyhow!("failed to parse JSON {}: {error}", path.display()))
}

fn read_signing_material(path: &Path) -> anyhow::Result<IssuerSigningMaterial> {
    let bytes = Zeroizing::new(
        fs::read(path)
            .map_err(|error| anyhow::anyhow!("failed to read {}: {error}", path.display()))?,
    );
    serde_json::from_slice(bytes.as_slice())
        .map_err(|error| anyhow::anyhow!("failed to parse JSON {}: {error}", path.display()))
}

fn write_output<T: Serialize>(path: Option<&Path>, value: &T) -> anyhow::Result<()> {
    match path {
        Some(path) => write_json(path, value),
        None => {
            println!("{}", serde_json::to_string_pretty(value)?);
            Ok(())
        }
    }
}

fn write_json<T: Serialize>(path: &Path, value: &T) -> anyhow::Result<()> {
    let bytes = serde_json::to_vec_pretty(value)?;
    write_json_bytes(path, bytes.as_slice())
}

#[cfg(unix)]
fn write_json_bytes(path: &Path, bytes: &[u8]) -> anyhow::Result<()> {
    use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};

    let mut file = fs::OpenOptions::new()
        .create_new(true)
        .write(true)
        .mode(0o600)
        .open(path)?;
    file.set_permissions(fs::Permissions::from_mode(0o600))?;
    file.write_all(bytes)?;
    file.sync_all()?;
    file.set_permissions(fs::Permissions::from_mode(0o600))?;
    Ok(())
}

#[cfg(not(unix))]
fn write_json_bytes(path: &Path, bytes: &[u8]) -> anyhow::Result<()> {
    let mut file = fs::OpenOptions::new()
        .create_new(true)
        .write(true)
        .open(path)?;
    file.write_all(bytes)?;
    file.sync_all()?;
    Ok(())
}

fn decode_fixed_32(value: &str) -> anyhow::Result<Zeroizing<[u8; 32]>> {
    let bytes = Zeroizing::new(hex::decode(value.trim())?);
    if bytes.len() != 32 {
        anyhow::bail!("expected 32-byte signing secret");
    }
    let mut result = Zeroizing::new([0u8; 32]);
    result.copy_from_slice(bytes.as_slice());
    Ok(result)
}