use ed25519_dalek::{Signer, SigningKey};
use serde::{Deserialize, Serialize};
use crate::core::compliance::FrameworkReport;
pub const SCHEMA_VERSION: u32 = 1;
pub const KIND: &str = "lean-ctx.compliance-report";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Period {
pub from: String,
pub to: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct OwaspRow {
pub id: String,
pub title: String,
pub coverage: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct OwaspSection {
pub full: usize,
pub partial: usize,
pub minimal: usize,
pub rows: Vec<OwaspRow>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EnforcementSection {
pub blocked: usize,
pub redacted: usize,
pub tool_calls: usize,
pub other_security: usize,
pub by_event: Vec<(String, usize)>,
pub by_tool_blocked: Vec<(String, usize)>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AuditSection {
pub entries_in_period: usize,
pub chain_valid: bool,
pub anchor_prev_hash: String,
pub head_hash: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RetentionSection {
#[serde(skip_serializing_if = "Option::is_none")]
pub policy_pack: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub policy_audit_retention_days: Option<u32>,
pub plan: String,
pub plan_source: String,
pub plan_audit_retention_days: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub plan_covers_policy: Option<bool>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReportVerifyResult {
pub signature_valid: bool,
pub signer_public_key: Option<String>,
pub error: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ComplianceReportV1 {
pub schema_version: u32,
pub kind: String,
pub created_at: String,
pub lean_ctx_version: String,
pub agent_id: String,
pub project: String,
pub period: Period,
pub owasp: OwaspSection,
pub frameworks: Vec<FrameworkReport>,
pub enforcement: EnforcementSection,
pub audit: AuditSection,
pub retention: RetentionSection,
#[serde(skip_serializing_if = "Option::is_none")]
pub signer_public_key: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub signature: Option<String>,
}
impl ComplianceReportV1 {
pub fn canonical_bytes(&self) -> Result<Vec<u8>, String> {
let mut clone = self.clone();
clone.signature = None;
clone.signer_public_key = None;
serde_json::to_vec(&clone).map_err(|e| format!("serialize for signing: {e}"))
}
pub fn sign(&mut self, agent_id: &str) -> Result<(), String> {
let key = crate::core::agent_identity::get_or_create_keypair(agent_id)?;
self.sign_with_key(&key)
}
pub fn sign_with_key(&mut self, key: &SigningKey) -> Result<(), String> {
self.signature = None;
self.signer_public_key = None;
let canonical = self.canonical_bytes()?;
let sig = key.sign(&canonical);
self.signer_public_key = Some(crate::core::agent_identity::hex_encode(
&key.verifying_key().to_bytes(),
));
self.signature = Some(crate::core::agent_identity::hex_encode(&sig.to_bytes()));
Ok(())
}
pub fn verify(&self) -> ReportVerifyResult {
let fail = |msg: &str| ReportVerifyResult {
signature_valid: false,
signer_public_key: self.signer_public_key.clone(),
error: Some(msg.to_string()),
};
let (Some(sig_hex), Some(pk_hex)) = (&self.signature, &self.signer_public_key) else {
return fail("artifact is not signed");
};
let (Ok(sig_bytes), Ok(pk_bytes)) = (
crate::core::agent_identity::hex_decode(sig_hex),
crate::core::agent_identity::hex_decode(pk_hex),
) else {
return fail("malformed signature or public key hex");
};
let canonical = match self.canonical_bytes() {
Ok(c) => c,
Err(e) => return fail(&e),
};
if crate::core::agent_identity::verify_signature(&pk_bytes, &canonical, &sig_bytes) {
ReportVerifyResult {
signature_valid: true,
signer_public_key: Some(pk_hex.clone()),
error: None,
}
} else {
fail("signature does not match payload (tampered or wrong key)")
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::compliance;
use crate::core::policy::{builtin, resolve};
fn sample() -> ComplianceReportV1 {
let mapping = compliance::get("soc2").unwrap();
let resolved = resolve(&builtin::get("soc2-context").unwrap()).unwrap();
let report = compliance::report(mapping, Some(&resolved));
ComplianceReportV1 {
schema_version: SCHEMA_VERSION,
kind: KIND.to_string(),
created_at: "2026-06-15T00:00:00+00:00".to_string(),
lean_ctx_version: "test".to_string(),
agent_id: "local".to_string(),
project: "proj".to_string(),
period: Period {
from: "2026-05-01T00:00:00+00:00".to_string(),
to: "2026-06-01T00:00:00+00:00".to_string(),
},
owasp: OwaspSection {
full: 8,
partial: 2,
minimal: 0,
rows: vec![OwaspRow {
id: "OWASP-AGENT-01".to_string(),
title: "Excessive Agency".to_string(),
coverage: "full".to_string(),
}],
},
frameworks: vec![report],
enforcement: EnforcementSection {
blocked: 3,
redacted: 5,
tool_calls: 100,
other_security: 0,
by_event: vec![("tool_denied".to_string(), 3)],
by_tool_blocked: vec![("ctx_url_read".to_string(), 3)],
},
audit: AuditSection {
entries_in_period: 108,
chain_valid: true,
anchor_prev_hash: "genesis".to_string(),
head_hash: "abc123".to_string(),
},
retention: RetentionSection {
policy_pack: Some("soc2-context v1.0.0".to_string()),
policy_audit_retention_days: Some(365),
plan: "free".to_string(),
plan_source: "unverified".to_string(),
plan_audit_retention_days: 0,
plan_covers_policy: Some(false),
},
signer_public_key: None,
signature: None,
}
}
fn key() -> SigningKey {
let mut seed = [0u8; 32];
getrandom::fill(&mut seed).unwrap();
SigningKey::from_bytes(&seed)
}
#[test]
fn canonical_bytes_exclude_signature_fields() {
let mut r = sample();
let before = r.canonical_bytes().unwrap();
r.signature = Some("deadbeef".into());
r.signer_public_key = Some("cafe".into());
let after = r.canonical_bytes().unwrap();
assert_eq!(
before, after,
"signature fields must not affect signed bytes"
);
}
#[test]
fn sign_then_verify_roundtrips() {
let mut r = sample();
r.sign_with_key(&key()).unwrap();
assert!(
r.verify().signature_valid,
"freshly signed report must verify"
);
}
#[test]
fn verify_detects_tampered_counts() {
let mut r = sample();
r.sign_with_key(&key()).unwrap();
r.enforcement.blocked = 999;
assert!(
!r.verify().signature_valid,
"edited counts must fail verification"
);
}
#[test]
fn verify_detects_tampered_audit_head() {
let mut r = sample();
r.sign_with_key(&key()).unwrap();
r.audit.head_hash = "0000".into();
assert!(
!r.verify().signature_valid,
"rewriting the chain head must fail"
);
}
#[test]
fn verify_rejects_unsigned() {
assert!(!sample().verify().signature_valid);
}
#[test]
fn json_roundtrip_is_byte_faithful_and_verifies() {
let mut r = sample();
r.sign_with_key(&key()).unwrap();
let json = serde_json::to_string_pretty(&r).unwrap();
let loaded: ComplianceReportV1 = serde_json::from_str(&json).unwrap();
assert_eq!(loaded, r, "round-trip must preserve every field");
assert!(
loaded.verify().signature_valid,
"loaded artifact still verifies"
);
}
}