klieo-provenance 3.5.0

Append-only Merkle hash chain with signed evidence bundles for agent and audit provenance.
Documentation
//! Signer port and signed evidence-bundle exporter.

use async_trait::async_trait;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};

use crate::chain::{ChainEntry, ProvenanceChain, GENESIS_HASH_HEX};
use crate::error::ProvenanceError;
use crate::verify::{KeyResolver, Verifier, VerifyError};

/// Detached signature bytes returned by a [`BundleSigner`].
///
/// **`public_key_hex` is informational only.** Verifiers
/// ([`EvidenceBundle::verify`]) MUST NOT consult this field when deciding
/// whether to trust a bundle — a malicious signer can always ship a
/// bundle with their OWN public key here, alongside a valid signature
/// against it (CWE-347 JWT-`alg=none` analogue). Trust derives
/// exclusively from a caller-supplied [`KeyResolver`] keyed on
/// [`SignatureBytes::key_id`].
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct SignatureBytes {
    /// Signature algorithm tag (e.g. `"ed25519"`). Matched against the
    /// [`Verifier::algorithm`] allowlist at verify time.
    pub algorithm: String,
    /// Hex-encoded signature.
    pub signature_hex: String,
    /// Hex-encoded public key. **Informational only — do not trust.**
    /// See struct-level docs.
    pub public_key_hex: String,
    /// Key identifier; used by [`KeyResolver`] to look up the trusted
    /// public key at verify time. Bundles without a `key_id` cannot be
    /// verified.
    pub key_id: Option<String>,
}

/// Abstract evidence-bundle signer port.
///
/// Implementations produce a detached signature over the canonical
/// payload bytes of an [`EvidenceBundle`]. Renamed from `Signer` so
/// the name does not collide with `ed25519_dalek::Signer` or other
/// crypto-crate `Signer` traits in downstream code.
#[async_trait]
pub trait BundleSigner: Send + Sync {
    /// Sign canonical payload bytes.
    async fn sign(&self, payload: &[u8]) -> Result<SignatureBytes, ProvenanceError>;
}

/// Deprecated alias for [`BundleSigner`].
#[deprecated(
    since = "0.7.0",
    note = "renamed to BundleSigner — Signer alias will be removed in klieo-provenance 3.0 (see ADR-041)"
)]
pub use BundleSigner as Signer;

/// Regulator-ready evidence pack.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct EvidenceBundle {
    /// Scope this bundle belongs to.
    pub scope: String,
    /// When the bundle was produced.
    pub generated_at: DateTime<Utc>,
    /// Who produced the bundle.
    pub generated_by: String,
    /// First sequence in the slice.
    pub from_sequence: u64,
    /// Last sequence (inclusive).
    pub to_sequence: u64,
    /// The chain slice.
    pub entries: Vec<ChainEntry>,
    /// Detached signature.
    pub signature: SignatureBytes,
}

impl EvidenceBundle {
    /// Canonical bytes input to the signer.
    pub fn canonical_payload(
        scope: &str,
        generated_at: DateTime<Utc>,
        generated_by: &str,
        entries: &[ChainEntry],
    ) -> Vec<u8> {
        let from_sequence = entries.first().map(|e| e.sequence).unwrap_or(0);
        let to_sequence = entries.last().map(|e| e.sequence).unwrap_or(0);
        let header = serde_json::json!({
            "scope": scope,
            "generated_at": ChainEntry::canonical_timestamp(generated_at),
            "generated_by": generated_by,
            "from_sequence": from_sequence,
            "to_sequence": to_sequence,
            "entry_count": entries.len(),
            "genesis_hash_hex": GENESIS_HASH_HEX,
        });
        let mut buf = serde_json::to_vec(&header).expect("header serialisable");
        for entry in entries {
            buf.push(0x1e);
            buf.extend_from_slice(&entry.sequence.to_be_bytes());
            buf.extend_from_slice(entry.previous_hash_hex.as_bytes());
            buf.extend_from_slice(ChainEntry::canonical_timestamp(entry.recorded_at).as_bytes());
            buf.extend_from_slice(&entry.event.canonical_bytes());
            buf.extend_from_slice(entry.entry_hash_hex.as_bytes());
        }
        buf
    }

    /// Build a bundle by signing a slice.
    pub async fn build(
        scope: String,
        generated_by: String,
        entries: Vec<ChainEntry>,
        signer: &dyn BundleSigner,
    ) -> Result<Self, ProvenanceError> {
        if entries.is_empty() {
            return Err(ProvenanceError::Invalid(
                "evidence bundle requires at least one entry".into(),
            ));
        }
        let from_sequence = entries.first().map(|e| e.sequence).unwrap();
        let to_sequence = entries.last().map(|e| e.sequence).unwrap();
        let generated_at = Utc::now();
        let payload = Self::canonical_payload(&scope, generated_at, &generated_by, &entries);
        let signature = signer.sign(&payload).await?;
        Ok(Self {
            scope,
            generated_at,
            generated_by,
            from_sequence,
            to_sequence,
            entries,
            signature,
        })
    }

    /// Verify the bundle end-to-end:
    ///
    /// 1. The bundle's `algorithm` tag matches the verifier's allowlist
    ///    (`verifier.algorithm()`). Defeats `alg=none`-style forgeries.
    /// 2. `signature.key_id` resolves to a trusted public key via the
    ///    caller-supplied [`KeyResolver`]. The bundle's
    ///    `public_key_hex` is NEVER consulted.
    /// 3. The canonical payload re-computed from `(scope, generated_at,
    ///    generated_by, entries)` is signed by `signature_hex` under the
    ///    trusted key.
    /// 4. The chain slice re-walks cleanly — every entry's
    ///    `entry_hash_hex` equals the recomputed hash, every
    ///    `previous_hash_hex` matches the prior entry, and sequences
    ///    are contiguous.
    ///
    /// Any failure returns a typed [`VerifyError`]. The bundle is
    /// trustworthy iff this method returns `Ok(())`.
    pub fn verify(
        &self,
        resolver: &dyn KeyResolver,
        verifier: &dyn Verifier,
    ) -> Result<(), VerifyError> {
        // 1. Algorithm allowlist check — runs before any cryptographic work.
        if self.signature.algorithm != verifier.algorithm() {
            return Err(VerifyError::UnsupportedAlgorithm(
                self.signature.algorithm.clone(),
            ));
        }
        // 2. Resolve trusted key.
        let key_id = self
            .signature
            .key_id
            .as_deref()
            .ok_or_else(|| VerifyError::UnknownKeyId("(none)".into()))?;
        let trusted_key = resolver
            .resolve(key_id)
            .ok_or_else(|| VerifyError::UnknownKeyId(key_id.to_string()))?;
        // 3. Reconstruct canonical payload + decode hex signature.
        let payload = Self::canonical_payload(
            &self.scope,
            self.generated_at,
            &self.generated_by,
            &self.entries,
        );
        let sig_bytes = hex::decode(&self.signature.signature_hex)
            .map_err(|e| VerifyError::Payload(format!("bad hex: {e}")))?;
        verifier.verify(&trusted_key, &payload, &sig_bytes)?;
        // 4. Merkle re-walk over the slice.
        let mut chain = ProvenanceChain::new(self.scope.clone());
        chain.entries = self.entries.clone();
        chain
            .verify()
            .map_err(|e| VerifyError::Chain(e.to_string()))?;
        Ok(())
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::chain::{ProvenanceChain, ProvenanceEvent, ProvenanceEventKind};
    use sha2::{Digest, Sha256};

    /// Trivial deterministic test signer: signature = sha256(payload).
    struct TestSigner;

    #[async_trait]
    impl BundleSigner for TestSigner {
        async fn sign(&self, payload: &[u8]) -> Result<SignatureBytes, ProvenanceError> {
            let digest = Sha256::digest(payload);
            Ok(SignatureBytes {
                algorithm: "sha256-test".into(),
                signature_hex: hex::encode(digest),
                public_key_hex: "test-key".into(),
                key_id: Some("test".into()),
            })
        }
    }

    fn populate_chain(scope: String) -> ProvenanceChain {
        let mut chain = ProvenanceChain::new(scope);
        for i in 0..3 {
            chain.append(ProvenanceEvent {
                kind: ProvenanceEventKind::LlmCall,
                actor: "agent:hello".into(),
                resource_ref: format!("run-{i}"),
                payload_hash_hex: "ab".repeat(32),
                metadata: serde_json::json!({"i": i}),
            });
        }
        chain
    }

    #[tokio::test]
    async fn build_signs_canonical_payload() {
        let scope = uuid::Uuid::new_v4().to_string();
        let chain = populate_chain(scope.clone());
        let bundle = EvidenceBundle::build(
            scope.clone(),
            "alice@bank.eu".into(),
            chain.entries.clone(),
            &TestSigner,
        )
        .await
        .unwrap();

        let expected_payload = EvidenceBundle::canonical_payload(
            &bundle.scope,
            bundle.generated_at,
            &bundle.generated_by,
            &bundle.entries,
        );
        let expected_hash = hex::encode(Sha256::digest(&expected_payload));
        assert_eq!(bundle.signature.signature_hex, expected_hash);
        assert_eq!(bundle.from_sequence, 0);
        assert_eq!(bundle.to_sequence, 2);
    }

    #[tokio::test]
    async fn build_rejects_empty_entries() {
        let scope = uuid::Uuid::new_v4().to_string();
        let err = EvidenceBundle::build(scope, "alice".into(), vec![], &TestSigner)
            .await
            .unwrap_err();
        assert!(err.to_string().contains("at least one entry"));
    }

    #[tokio::test]
    #[allow(deprecated)]
    async fn signer_alias_still_resolves_to_bundle_signer() {
        // Constructs a value via the deprecated `Signer` alias and uses
        // it where a `&dyn BundleSigner` is required. Compiling proves
        // the alias survives one release.
        let s: &dyn Signer = &TestSigner;
        let payload = b"hello";
        let sig = s.sign(payload).await.unwrap();
        assert_eq!(sig.algorithm, "sha256-test");
    }

    #[tokio::test]
    async fn canonical_payload_changes_with_entry_set() {
        let scope = uuid::Uuid::new_v4().to_string();
        let chain = populate_chain(scope.clone());
        let now = Utc::now();
        let p_full = EvidenceBundle::canonical_payload(&scope, now, "alice", &chain.entries);
        let p_partial =
            EvidenceBundle::canonical_payload(&scope, now, "alice", &chain.entries[..2]);
        assert_ne!(p_full, p_partial);
    }
}