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};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct SignatureBytes {
pub algorithm: String,
pub signature_hex: String,
pub public_key_hex: String,
pub key_id: Option<String>,
}
#[async_trait]
pub trait BundleSigner: Send + Sync {
async fn sign(&self, payload: &[u8]) -> Result<SignatureBytes, ProvenanceError>;
}
#[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;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct EvidenceBundle {
pub scope: String,
pub generated_at: DateTime<Utc>,
pub generated_by: String,
pub from_sequence: u64,
pub to_sequence: u64,
pub entries: Vec<ChainEntry>,
pub signature: SignatureBytes,
}
impl EvidenceBundle {
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
}
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,
})
}
pub fn verify(
&self,
resolver: &dyn KeyResolver,
verifier: &dyn Verifier,
) -> Result<(), VerifyError> {
if self.signature.algorithm != verifier.algorithm() {
return Err(VerifyError::UnsupportedAlgorithm(
self.signature.algorithm.clone(),
));
}
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()))?;
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)?;
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};
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() {
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);
}
}