lean_ctx/core/compliance_evidence/
model.rs1use ed25519_dalek::{Signer, SigningKey};
17use serde::{Deserialize, Serialize};
18
19use super::frameworks::FrameworkReport;
20
21pub const SCHEMA_VERSION: u32 = 1;
22pub const KIND: &str = "lean-ctx.compliance-report";
23
24#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
26pub struct Period {
27 pub from: String,
28 pub to: String,
29}
30
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
33pub struct OwaspRow {
34 pub id: String,
35 pub title: String,
36 pub coverage: String,
38}
39
40#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
42pub struct OwaspSection {
43 pub full: usize,
44 pub partial: usize,
45 pub minimal: usize,
46 pub rows: Vec<OwaspRow>,
47}
48
49#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
51pub struct EnforcementSection {
52 pub blocked: usize,
54 pub redacted: usize,
56 pub tool_calls: usize,
58 pub other_security: usize,
60 pub by_event: Vec<(String, usize)>,
62 pub by_tool_blocked: Vec<(String, usize)>,
64}
65
66#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
68pub struct AuditSection {
69 pub entries_in_period: usize,
70 pub chain_valid: bool,
72 pub anchor_prev_hash: String,
74 pub head_hash: String,
76}
77
78#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
80pub struct RetentionSection {
81 #[serde(skip_serializing_if = "Option::is_none")]
83 pub policy_pack: Option<String>,
84 #[serde(skip_serializing_if = "Option::is_none")]
86 pub policy_audit_retention_days: Option<u32>,
87 pub plan: String,
89 pub plan_source: String,
91 pub plan_audit_retention_days: u32,
93 #[serde(skip_serializing_if = "Option::is_none")]
95 pub plan_covers_policy: Option<bool>,
96}
97
98#[derive(Debug, Clone, PartialEq, Eq)]
100pub struct ReportVerifyResult {
101 pub signature_valid: bool,
105 pub signer_pinned: bool,
109 pub signer_public_key: Option<String>,
110 pub error: Option<String>,
111}
112
113#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
119pub struct ComplianceReportV1 {
120 pub schema_version: u32,
121 pub kind: String,
123 pub created_at: String,
124 pub lean_ctx_version: String,
125 pub agent_id: String,
126 pub project: String,
127 pub period: Period,
128 pub owasp: OwaspSection,
129 pub frameworks: Vec<FrameworkReport>,
130 pub enforcement: EnforcementSection,
131 pub audit: AuditSection,
132 pub retention: RetentionSection,
133 #[serde(skip_serializing_if = "Option::is_none")]
135 pub signer_public_key: Option<String>,
136 #[serde(skip_serializing_if = "Option::is_none")]
138 pub signature: Option<String>,
139}
140
141impl ComplianceReportV1 {
142 pub fn canonical_bytes(&self) -> Result<Vec<u8>, String> {
145 let mut clone = self.clone();
146 clone.signature = None;
147 clone.signer_public_key = None;
148 serde_json::to_vec(&clone).map_err(|e| format!("serialize for signing: {e}"))
149 }
150
151 pub fn sign(&mut self, agent_id: &str) -> Result<(), String> {
153 let key = crate::core::agent_identity::get_or_create_keypair(agent_id)?;
154 self.sign_with_key(&key)
155 }
156
157 pub fn sign_with_key(&mut self, key: &SigningKey) -> Result<(), String> {
160 self.signature = None;
161 self.signer_public_key = None;
162 let canonical = self.canonical_bytes()?;
163 let sig = key.sign(&canonical);
164 self.signer_public_key = Some(crate::core::agent_identity::hex_encode(
165 &key.verifying_key().to_bytes(),
166 ));
167 self.signature = Some(crate::core::agent_identity::hex_encode(&sig.to_bytes()));
168 Ok(())
169 }
170
171 pub fn verify_signer(&self, expected_signer: &str) -> ReportVerifyResult {
176 let mut result = self.verify();
177 if !result.signature_valid {
178 return result;
179 }
180 let embedded = self.signer_public_key.as_deref().unwrap_or_default();
181 if embedded.eq_ignore_ascii_case(expected_signer.trim()) {
182 result.signer_pinned = true;
183 } else {
184 result.signature_valid = false;
185 result.error =
186 Some("report is signed by a different key than the expected signer".into());
187 }
188 result
189 }
190
191 pub fn verify(&self) -> ReportVerifyResult {
196 let fail = |msg: &str| ReportVerifyResult {
197 signature_valid: false,
198 signer_pinned: false,
199 signer_public_key: self.signer_public_key.clone(),
200 error: Some(msg.to_string()),
201 };
202 let (Some(sig_hex), Some(pk_hex)) = (&self.signature, &self.signer_public_key) else {
203 return fail("artifact is not signed");
204 };
205 let (Ok(sig_bytes), Ok(pk_bytes)) = (
206 crate::core::agent_identity::hex_decode(sig_hex),
207 crate::core::agent_identity::hex_decode(pk_hex),
208 ) else {
209 return fail("malformed signature or public key hex");
210 };
211 let canonical = match self.canonical_bytes() {
212 Ok(c) => c,
213 Err(e) => return fail(&e),
214 };
215 if crate::core::agent_identity::verify_signature(&pk_bytes, &canonical, &sig_bytes) {
216 ReportVerifyResult {
217 signature_valid: true,
218 signer_pinned: false,
219 signer_public_key: Some(pk_hex.clone()),
220 error: None,
221 }
222 } else {
223 fail("signature does not match payload (tampered or wrong key)")
224 }
225 }
226}
227
228#[cfg(test)]
229pub(crate) mod tests {
230 use super::*;
231
232 pub(crate) fn sample() -> ComplianceReportV1 {
233 let report = super::super::frameworks::tests::answer(Some("pack v1.0.0"))
234 .frameworks
235 .remove(0);
236 ComplianceReportV1 {
237 schema_version: SCHEMA_VERSION,
238 kind: KIND.to_string(),
239 created_at: "2026-06-15T00:00:00+00:00".to_string(),
240 lean_ctx_version: "test".to_string(),
241 agent_id: "local".to_string(),
242 project: "proj".to_string(),
243 period: Period {
244 from: "2026-05-01T00:00:00+00:00".to_string(),
245 to: "2026-06-01T00:00:00+00:00".to_string(),
246 },
247 owasp: OwaspSection {
248 full: 8,
249 partial: 2,
250 minimal: 0,
251 rows: vec![OwaspRow {
252 id: "OWASP-AGENT-01".to_string(),
253 title: "Excessive Agency".to_string(),
254 coverage: "full".to_string(),
255 }],
256 },
257 frameworks: vec![report],
258 enforcement: EnforcementSection {
259 blocked: 3,
260 redacted: 5,
261 tool_calls: 100,
262 other_security: 0,
263 by_event: vec![("tool_denied".to_string(), 3)],
264 by_tool_blocked: vec![("ctx_url_read".to_string(), 3)],
265 },
266 audit: AuditSection {
267 entries_in_period: 108,
268 chain_valid: true,
269 anchor_prev_hash: "genesis".to_string(),
270 head_hash: "abc123".to_string(),
271 },
272 retention: RetentionSection {
273 policy_pack: Some("pack v1.0.0".to_string()),
274 policy_audit_retention_days: Some(365),
275 plan: "free".to_string(),
276 plan_source: "unverified".to_string(),
277 plan_audit_retention_days: 0,
278 plan_covers_policy: Some(false),
279 },
280 signer_public_key: None,
281 signature: None,
282 }
283 }
284
285 fn key() -> SigningKey {
286 let mut seed = [0u8; 32];
287 getrandom::fill(&mut seed).unwrap();
288 SigningKey::from_bytes(&seed)
289 }
290
291 #[test]
292 fn canonical_bytes_exclude_signature_fields() {
293 let mut r = sample();
294 let before = r.canonical_bytes().unwrap();
295 r.signature = Some("deadbeef".into());
296 r.signer_public_key = Some("cafe".into());
297 let after = r.canonical_bytes().unwrap();
298 assert_eq!(
299 before, after,
300 "signature fields must not affect signed bytes"
301 );
302 }
303
304 #[test]
305 fn sign_then_verify_roundtrips() {
306 let mut r = sample();
307 r.sign_with_key(&key()).unwrap();
308 assert!(
309 r.verify().signature_valid,
310 "freshly signed report must verify"
311 );
312 }
313
314 #[test]
315 fn verify_detects_tampered_counts() {
316 let mut r = sample();
317 r.sign_with_key(&key()).unwrap();
318 r.enforcement.blocked = 999;
319 assert!(
320 !r.verify().signature_valid,
321 "edited counts must fail verification"
322 );
323 }
324
325 #[test]
326 fn verify_detects_tampered_audit_head() {
327 let mut r = sample();
328 r.sign_with_key(&key()).unwrap();
329 r.audit.head_hash = "0000".into();
330 assert!(
331 !r.verify().signature_valid,
332 "rewriting the chain head must fail"
333 );
334 }
335
336 #[test]
339 fn a_re_signed_report_is_unpinned_and_fails_the_expected_signer() {
340 let producer = key();
341 let producer_pk =
342 crate::core::agent_identity::hex_encode(&producer.verifying_key().to_bytes());
343 let mut r = sample();
344 r.sign_with_key(&producer).unwrap();
345 let pinned = r.verify_signer(&producer_pk);
346 assert!(pinned.signature_valid && pinned.signer_pinned, "{pinned:?}");
347
348 r.enforcement.blocked = 0;
349 r.sign_with_key(&key()).unwrap();
350 let self_check = r.verify();
351 assert!(self_check.signature_valid && !self_check.signer_pinned);
352 let against = r.verify_signer(&producer_pk);
353 assert!(!against.signature_valid && !against.signer_pinned);
354 assert!(against.error.unwrap().contains("different key"));
355 }
356
357 #[test]
358 fn verify_rejects_unsigned() {
359 assert!(!sample().verify().signature_valid);
360 }
361
362 #[test]
363 fn json_roundtrip_is_byte_faithful_and_verifies() {
364 let mut r = sample();
365 r.sign_with_key(&key()).unwrap();
366 let json = serde_json::to_string_pretty(&r).unwrap();
367 let loaded: ComplianceReportV1 = serde_json::from_str(&json).unwrap();
368 assert_eq!(loaded, r, "round-trip must preserve every field");
369 assert!(
370 loaded.verify().signature_valid,
371 "loaded artifact still verifies"
372 );
373 }
374}