nono-cli 0.64.1

CLI for nono capability-based sandbox
use crate::trust_cmd;
use nono::trust;
use nono::undo::{AuditAttestationSummary, SessionMetadata};
use nono::{NonoError, Result};
use std::fs;
use std::path::Path;
use zeroize::Zeroizing;

pub(crate) use nono::audit::AUDIT_ATTESTATION_BUNDLE_FILENAME;
const KEYSTORE_URI_PREFIX: &str = "keystore://";

pub(crate) struct AuditSigner {
    key_pair: trust::KeyPair,
    pub(crate) key_id: String,
    pub(crate) public_key_b64: String,
}

#[cfg(test)]
pub(crate) fn signer_from_key_pair(key_pair: trust::KeyPair) -> Result<AuditSigner> {
    let key_id = trust::key_id_hex(&key_pair)?;
    let public_key = trust::export_public_key(&key_pair)?;
    Ok(AuditSigner {
        key_pair,
        key_id,
        public_key_b64: trust::base64::base64_encode(public_key.as_bytes()),
    })
}

pub(crate) type AuditAttestationVerificationResult =
    nono::audit::AuditAttestationVerificationResult;

pub(crate) fn prepare_audit_signer(secret_ref: Option<&str>) -> Result<Option<AuditSigner>> {
    let Some(secret_ref) = secret_ref.filter(|value| !value.trim().is_empty()) else {
        return Ok(None);
    };

    let normalized_ref = normalize_signing_secret_ref(secret_ref);
    let pkcs8_b64 = nono::load_secret_by_ref(trust_cmd::TRUST_SERVICE, &normalized_ref)?;
    let pkcs8_bytes =
        Zeroizing::new(trust_cmd::base64_decode(pkcs8_b64.as_str()).map_err(|e| {
            NonoError::TrustSigning {
                path: "<audit-sign-key>".to_string(),
                reason: format!("invalid base64 PKCS#8 signing key: {e}"),
            }
        })?);
    let key_pair = trust_cmd::reconstruct_key_pair(&pkcs8_bytes)?;
    let key_id = trust::key_id_hex(&key_pair)?;
    let public_key = trust::export_public_key(&key_pair)?;
    let public_key_b64 = trust::base64::base64_encode(public_key.as_bytes());

    Ok(Some(AuditSigner {
        key_pair,
        key_id,
        public_key_b64,
    }))
}

pub(crate) fn write_audit_attestation(
    session_dir: &Path,
    metadata: &SessionMetadata,
    signer: &AuditSigner,
    redaction_policy: &nono::ScrubPolicy,
) -> Result<AuditAttestationSummary> {
    let (bundle_json, summary) = nono::audit::sign_audit_attestation_bundle(
        metadata,
        &signer.key_pair,
        &signer.key_id,
        &signer.public_key_b64,
        redaction_policy,
    )?;
    let bundle_path = session_dir.join(AUDIT_ATTESTATION_BUNDLE_FILENAME);
    fs::write(&bundle_path, bundle_json).map_err(|e| NonoError::TrustSigning {
        path: bundle_path.display().to_string(),
        reason: format!("failed to write audit attestation bundle: {e}"),
    })?;

    Ok(summary)
}

pub(crate) fn verify_audit_attestation(
    session_dir: &Path,
    metadata: &SessionMetadata,
    expected_public_key_file: Option<&Path>,
) -> Result<AuditAttestationVerificationResult> {
    let Some(summary) = metadata.audit_attestation.as_ref() else {
        return Ok(nono::audit::AuditAttestationVerificationResult {
            present: false,
            predicate_type: None,
            key_id: None,
            key_id_matches: false,
            signature_verified: false,
            merkle_root_matches: false,
            session_id_matches: false,
            expected_public_key_matches: expected_public_key_file.map(|_| false),
            verification_error: expected_public_key_file.map(|public_key_file| {
                format!(
                    "session has no audit attestation to verify against provided public key {}",
                    public_key_file.display()
                )
            }),
        });
    };
    let bundle_path = session_dir.join(&summary.bundle_filename);
    let bundle = match trust::load_bundle(&bundle_path) {
        Ok(bundle) => bundle,
        Err(err) => {
            return Ok(nono::audit::AuditAttestationVerificationResult {
                present: true,
                predicate_type: Some(summary.predicate_type.clone()),
                key_id: Some(summary.key_id.clone()),
                key_id_matches: false,
                signature_verified: false,
                merkle_root_matches: false,
                session_id_matches: false,
                expected_public_key_matches: None,
                verification_error: Some(err.to_string()),
            });
        }
    };
    let expected_public_key = expected_public_key_file
        .map(load_public_key_file)
        .transpose()?;

    nono::audit::verify_audit_attestation_bundle(
        &bundle,
        &bundle_path,
        metadata,
        expected_public_key.as_deref(),
    )
}

fn normalize_signing_secret_ref(secret_ref: &str) -> String {
    secret_ref
        .strip_prefix(KEYSTORE_URI_PREFIX)
        .unwrap_or(secret_ref)
        .to_string()
}

#[cfg(test)]
fn extract_statement(bundle: &trust::Bundle) -> Result<trust::InTotoStatement> {
    let bundle_json = bundle.to_json().map_err(|e| NonoError::TrustVerification {
        path: String::new(),
        reason: format!("failed to serialize audit attestation bundle: {e}"),
    })?;
    let bundle_value: serde_json::Value =
        serde_json::from_str(&bundle_json).map_err(|e| NonoError::TrustVerification {
            path: String::new(),
            reason: format!("invalid audit attestation bundle JSON: {e}"),
        })?;
    let envelope_value =
        bundle_value
            .get("dsseEnvelope")
            .ok_or_else(|| NonoError::TrustVerification {
                path: String::new(),
                reason: "audit attestation bundle missing dsseEnvelope".to_string(),
            })?;
    let envelope: trust::DsseEnvelope =
        serde_json::from_value(envelope_value.clone()).map_err(|e| {
            NonoError::TrustVerification {
                path: String::new(),
                reason: format!("invalid audit attestation DSSE envelope: {e}"),
            }
        })?;
    envelope.extract_statement()
}

fn load_public_key_file(path: &Path) -> Result<Vec<u8>> {
    let contents = fs::read_to_string(path).map_err(|e| NonoError::TrustVerification {
        path: path.display().to_string(),
        reason: format!("failed to read public key file: {e}"),
    })?;
    let trimmed = contents.trim();
    if trimmed.starts_with("-----BEGIN PUBLIC KEY-----") {
        let base64_body: String = trimmed
            .lines()
            .filter(|line| !line.starts_with("-----BEGIN") && !line.starts_with("-----END"))
            .collect();
        trust::base64::base64_decode(&base64_body).map_err(|e| NonoError::TrustVerification {
            path: path.display().to_string(),
            reason: format!("invalid PEM public key: {e}"),
        })
    } else {
        trust::base64::base64_decode(trimmed).map_err(|e| NonoError::TrustVerification {
            path: path.display().to_string(),
            reason: format!("invalid base64 DER public key: {e}"),
        })
    }
}

#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
    use super::*;
    use nono::undo::{AuditIntegritySummary, ContentHash};
    use std::path::PathBuf;

    const TEST_SIGNING_KEY_PEM: &str = "\
-----BEGIN PRIVATE KEY-----
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgskOkyJkTwlMZkm/L
eEleLY6bARaHFnqauYJqxNoJWvihRANCAASt6g2Zt0STlgF+wZ64JzdDRlpPeNr1
h56ZLEEqHfVWFhJWIKRSabtxYPV/VJyMv+lo3L0QwSKsouHs3dtF1zVQ
-----END PRIVATE KEY-----";

    fn sample_metadata() -> SessionMetadata {
        SessionMetadata {
            session_id: "sess-1".to_string(),
            started: "2026-04-22T12:00:00Z".to_string(),
            ended: Some("2026-04-22T12:00:01Z".to_string()),
            command: vec!["/bin/pwd".to_string()],
            executable_identity: None,
            tracked_paths: vec![PathBuf::from("/tmp/project")],
            snapshot_count: 0,
            exit_code: Some(0),
            merkle_roots: Vec::new(),
            network_events: Vec::new(),
            audit_event_count: 2,
            audit_integrity: Some(AuditIntegritySummary {
                hash_algorithm: "sha256".to_string(),
                event_count: 2,
                chain_head: ContentHash::from_bytes([0x11; 32]),
                merkle_root: ContentHash::from_bytes([0x22; 32]),
            }),
            audit_attestation: None,
        }
    }

    #[test]
    fn audit_attestation_round_trips() {
        let dir = tempfile::tempdir().unwrap();
        let key_pair = trust::generate_signing_key().unwrap();
        let key_id = trust::key_id_hex(&key_pair).unwrap();
        let public_key = trust::export_public_key(&key_pair).unwrap();
        let signer = AuditSigner {
            key_pair,
            key_id,
            public_key_b64: trust::base64::base64_encode(public_key.as_bytes()),
        };
        let mut metadata = sample_metadata();
        let summary = write_audit_attestation(
            dir.path(),
            &metadata,
            &signer,
            &nono::ScrubPolicy::secure_default(),
        )
        .unwrap();
        metadata.audit_attestation = Some(summary);

        let verified = verify_audit_attestation(dir.path(), &metadata, None).unwrap();
        assert!(verified.present);
        assert!(verified.key_id_matches);
        assert!(verified.signature_verified);
        assert!(verified.merkle_root_matches);
        assert!(verified.session_id_matches);
        assert_eq!(verified.expected_public_key_matches, None);
        assert!(verified.verification_error.is_none());
    }

    #[test]
    fn audit_attestation_predicate_scrubs_command_secrets() {
        let dir = tempfile::tempdir().unwrap();
        let key_pair = trust::generate_signing_key().unwrap();
        let key_id = trust::key_id_hex(&key_pair).unwrap();
        let public_key = trust::export_public_key(&key_pair).unwrap();
        let signer = AuditSigner {
            key_pair,
            key_id,
            public_key_b64: trust::base64::base64_encode(public_key.as_bytes()),
        };
        let mut metadata = sample_metadata();
        metadata.command = vec![
            "curl".to_string(),
            "-H".to_string(),
            "Authorization: Bearer real-token".to_string(),
            "https://example.com/api?token=query-secret&format=json".to_string(),
        ];

        write_audit_attestation(
            dir.path(),
            &metadata,
            &signer,
            &nono::ScrubPolicy::secure_default(),
        )
        .unwrap();

        let bundle_path = dir.path().join(AUDIT_ATTESTATION_BUNDLE_FILENAME);
        let bundle = trust::load_bundle(&bundle_path).unwrap();
        let statement = extract_statement(&bundle).unwrap();
        let command_json = statement
            .predicate
            .get("command")
            .and_then(|value| serde_json::to_string(value).ok())
            .unwrap();

        assert!(command_json.contains("[REDACTED]"));
        assert!(!command_json.contains("real-token"));
        assert!(!command_json.contains("query-secret"));
    }

    #[test]
    fn audit_attestation_predicate_records_redaction_policy_diff() {
        let dir = tempfile::tempdir().unwrap();
        let key_pair = trust::generate_signing_key().unwrap();
        let key_id = trust::key_id_hex(&key_pair).unwrap();
        let public_key = trust::export_public_key(&key_pair).unwrap();
        let signer = AuditSigner {
            key_pair,
            key_id,
            public_key_b64: trust::base64::base64_encode(public_key.as_bytes()),
        };
        let mut metadata = sample_metadata();
        metadata.command = vec![
            "curl".to_string(),
            "--private-token=private-secret".to_string(),
            "https://example.com/callback?state=visible&token=hidden".to_string(),
        ];
        let mut redactions = nono::ScrubPolicy::secure_default();
        redactions.add_flag("--private-token");
        redactions.remove_query_key("state");

        write_audit_attestation(dir.path(), &metadata, &signer, &redactions).unwrap();

        let bundle_path = dir.path().join(AUDIT_ATTESTATION_BUNDLE_FILENAME);
        let bundle = trust::load_bundle(&bundle_path).unwrap();
        let statement = extract_statement(&bundle).unwrap();
        let predicate_json = serde_json::to_string(&statement.predicate).unwrap();

        assert!(predicate_json.contains("--private-token=[REDACTED]"));
        assert!(predicate_json.contains("state=visible"));
        assert!(predicate_json.contains("\"added_flags\":[\"--private-token\"]"));
        assert!(predicate_json.contains("\"removed_query_keys\":[\"state\"]"));
        assert!(!predicate_json.contains("private-secret"));
        assert!(!predicate_json.contains("token=hidden"));
    }

    #[test]
    fn audit_attestation_file_uri_signer_loads() {
        let dir = tempfile::tempdir().unwrap();
        let key_file = dir.path().join("audit-signing-key.pk8.b64");
        let pkcs8_b64: String = TEST_SIGNING_KEY_PEM
            .lines()
            .filter(|line| !line.starts_with("-----BEGIN") && !line.starts_with("-----END"))
            .collect();
        fs::write(&key_file, pkcs8_b64).unwrap();

        let signer = prepare_audit_signer(Some(&format!("file://{}", key_file.display()))).unwrap();
        assert!(signer.is_some());
    }

    #[test]
    fn audit_attestation_mismatch_is_reported_not_fatal() {
        let dir = tempfile::tempdir().unwrap();
        let key_pair = trust::generate_signing_key().unwrap();
        let key_id = trust::key_id_hex(&key_pair).unwrap();
        let public_key = trust::export_public_key(&key_pair).unwrap();
        let signer = AuditSigner {
            key_pair,
            key_id,
            public_key_b64: trust::base64::base64_encode(public_key.as_bytes()),
        };
        let mut metadata = sample_metadata();
        let summary = write_audit_attestation(
            dir.path(),
            &metadata,
            &signer,
            &nono::ScrubPolicy::secure_default(),
        )
        .unwrap();
        metadata.audit_attestation = Some(summary);
        metadata.session_id = "tampered-session".to_string();

        let verified = verify_audit_attestation(dir.path(), &metadata, None).unwrap();
        assert!(verified.present);
        assert!(!verified.signature_verified);
        assert!(verified.verification_error.is_some());
    }
}