1use chrono::{DateTime, Utc};
7use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
8use serde::{Deserialize, Serialize};
9use sha2::{Digest, Sha256};
10use std::collections::HashMap;
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
14#[serde(tag = "type")]
15pub enum AuditIdentity {
16 #[serde(rename = "llm")]
18 Llm { model_id: String },
19 #[serde(rename = "human")]
21 Human { user_id: String, name: String },
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
26#[serde(rename_all = "snake_case")]
27pub enum AuditVerdict {
28 Approved,
29 Rejected,
30 NeedsRevision,
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct CriticAuditEntry {
36 pub entry_id: String,
38 pub director_output_hash: String,
40 pub critic_assessment_hash: String,
42 pub verdict: AuditVerdict,
44 pub dimension_scores: HashMap<String, f64>,
46 pub score: f64,
48 pub critic_identity: AuditIdentity,
50 pub timestamp: DateTime<Utc>,
52 pub chain_hash: String,
54 pub signature: String,
56 pub iteration: u32,
58}
59
60pub struct RecordParams<'a> {
62 pub director_output: &'a str,
64 pub critic_assessment: &'a str,
66 pub verdict: AuditVerdict,
68 pub dimension_scores: HashMap<String, f64>,
70 pub score: f64,
72 pub critic_identity: AuditIdentity,
74 pub iteration: u32,
76}
77
78pub struct AuditChain {
80 entries: Vec<CriticAuditEntry>,
81 signing_key: SigningKey,
82 last_chain_hash: String,
83}
84
85impl AuditChain {
86 pub fn new(signing_key: SigningKey) -> Self {
88 let genesis = sha256_hex(b"genesis");
89 Self {
90 entries: Vec::new(),
91 signing_key,
92 last_chain_hash: genesis,
93 }
94 }
95
96 pub fn record(&mut self, params: RecordParams<'_>) -> CriticAuditEntry {
98 let entry_id = uuid::Uuid::new_v4().to_string();
99 let director_output_hash = sha256_hex(params.director_output.as_bytes());
100 let critic_assessment_hash = sha256_hex(params.critic_assessment.as_bytes());
101 let timestamp = Utc::now();
102
103 let entry_data = format!(
105 "{}|{}|{}|{:?}|{}|{}|{}",
106 entry_id,
107 director_output_hash,
108 critic_assessment_hash,
109 params.verdict,
110 params.score,
111 timestamp.to_rfc3339(),
112 params.iteration
113 );
114 let chain_input = format!("{}{}", self.last_chain_hash, entry_data);
115 let chain_hash = sha256_hex(chain_input.as_bytes());
116
117 let signature_bytes = self.signing_key.sign(chain_hash.as_bytes());
119 let signature = hex::encode(signature_bytes.to_bytes());
120
121 let entry = CriticAuditEntry {
122 entry_id,
123 director_output_hash,
124 critic_assessment_hash,
125 verdict: params.verdict,
126 dimension_scores: params.dimension_scores,
127 score: params.score,
128 critic_identity: params.critic_identity,
129 timestamp,
130 chain_hash: chain_hash.clone(),
131 signature,
132 iteration: params.iteration,
133 };
134
135 self.last_chain_hash = chain_hash;
136 self.entries.push(entry.clone());
137 entry
138 }
139
140 pub fn entries(&self) -> &[CriticAuditEntry] {
142 &self.entries
143 }
144
145 pub fn verifying_key(&self) -> VerifyingKey {
147 self.signing_key.verifying_key()
148 }
149
150 pub fn verify(&self, verifying_key: &VerifyingKey) -> Result<(), AuditError> {
152 verify_chain(&self.entries, verifying_key)
153 }
154
155 pub fn len(&self) -> usize {
157 self.entries.len()
158 }
159
160 pub fn is_empty(&self) -> bool {
162 self.entries.is_empty()
163 }
164}
165
166pub fn verify_chain(
170 entries: &[CriticAuditEntry],
171 verifying_key: &VerifyingKey,
172) -> Result<(), AuditError> {
173 let mut expected_prev_hash = sha256_hex(b"genesis");
174
175 for (i, entry) in entries.iter().enumerate() {
176 let entry_data = format!(
178 "{}|{}|{}|{:?}|{}|{}|{}",
179 entry.entry_id,
180 entry.director_output_hash,
181 entry.critic_assessment_hash,
182 entry.verdict,
183 entry.score,
184 entry.timestamp.to_rfc3339(),
185 entry.iteration
186 );
187 let chain_input = format!("{}{}", expected_prev_hash, entry_data);
188 let expected_chain_hash = sha256_hex(chain_input.as_bytes());
189
190 if entry.chain_hash != expected_chain_hash {
191 return Err(AuditError::ChainIntegrity {
192 entry_index: i,
193 expected: expected_chain_hash,
194 found: entry.chain_hash.clone(),
195 });
196 }
197
198 let sig_bytes =
200 hex::decode(&entry.signature).map_err(|e| AuditError::InvalidSignature {
201 entry_index: i,
202 message: format!("hex decode failed: {}", e),
203 })?;
204
205 let sig_array: [u8; 64] =
206 sig_bytes
207 .as_slice()
208 .try_into()
209 .map_err(|_| AuditError::InvalidSignature {
210 entry_index: i,
211 message: "signature must be 64 bytes".into(),
212 })?;
213
214 let signature = Signature::from_bytes(&sig_array);
215
216 verifying_key
217 .verify(entry.chain_hash.as_bytes(), &signature)
218 .map_err(|e| AuditError::InvalidSignature {
219 entry_index: i,
220 message: e.to_string(),
221 })?;
222
223 expected_prev_hash = entry.chain_hash.clone();
224 }
225
226 Ok(())
227}
228
229fn sha256_hex(data: &[u8]) -> String {
231 let mut hasher = Sha256::new();
232 hasher.update(data);
233 hex::encode(hasher.finalize())
234}
235
236#[derive(Debug, thiserror::Error)]
238pub enum AuditError {
239 #[error(
240 "Chain integrity violation at entry {entry_index}: expected {expected}, found {found}"
241 )]
242 ChainIntegrity {
243 entry_index: usize,
244 expected: String,
245 found: String,
246 },
247
248 #[error("Invalid signature at entry {entry_index}: {message}")]
249 InvalidSignature { entry_index: usize, message: String },
250}
251
252#[cfg(test)]
253mod tests {
254 use super::*;
255
256 fn test_signing_key() -> SigningKey {
257 use rand::RngCore;
258 let mut secret = [0u8; 32];
259 rand::thread_rng().fill_bytes(&mut secret);
260 SigningKey::from_bytes(&secret)
261 }
262
263 #[test]
264 fn test_record_and_verify() {
265 let key = test_signing_key();
266 let mut chain = AuditChain::new(key);
267
268 chain.record(RecordParams {
269 director_output: "The analysis shows...",
270 critic_assessment: "Good analysis, approved.",
271 verdict: AuditVerdict::Approved,
272 dimension_scores: HashMap::new(),
273 score: 0.9,
274 critic_identity: AuditIdentity::Llm {
275 model_id: "claude-sonnet".into(),
276 },
277 iteration: 1,
278 });
279
280 assert_eq!(chain.len(), 1);
281 assert!(chain.verify(&chain.verifying_key()).is_ok());
282 }
283
284 #[test]
285 fn test_multi_entry_chain() {
286 let key = test_signing_key();
287 let mut chain = AuditChain::new(key);
288
289 for i in 0..5 {
290 chain.record(RecordParams {
291 director_output: &format!("Director output {}", i),
292 critic_assessment: &format!("Critic review {}", i),
293 verdict: if i < 4 {
294 AuditVerdict::NeedsRevision
295 } else {
296 AuditVerdict::Approved
297 },
298 dimension_scores: {
299 let mut scores = HashMap::new();
300 scores.insert("accuracy".into(), 0.5 + (i as f64) * 0.1);
301 scores
302 },
303 score: 0.5 + (i as f64) * 0.1,
304 critic_identity: AuditIdentity::Llm {
305 model_id: "claude-sonnet".into(),
306 },
307 iteration: i as u32 + 1,
308 });
309 }
310
311 assert_eq!(chain.len(), 5);
312 assert!(chain.verify(&chain.verifying_key()).is_ok());
313 }
314
315 #[test]
316 fn test_tampered_chain_hash_detected() {
317 let key = test_signing_key();
318 let verifying_key = key.verifying_key();
319 let mut chain = AuditChain::new(key);
320
321 chain.record(RecordParams {
322 director_output: "output 1",
323 critic_assessment: "review 1",
324 verdict: AuditVerdict::Approved,
325 dimension_scores: HashMap::new(),
326 score: 0.8,
327 critic_identity: AuditIdentity::Llm {
328 model_id: "test".into(),
329 },
330 iteration: 1,
331 });
332 chain.record(RecordParams {
333 director_output: "output 2",
334 critic_assessment: "review 2",
335 verdict: AuditVerdict::Approved,
336 dimension_scores: HashMap::new(),
337 score: 0.9,
338 critic_identity: AuditIdentity::Llm {
339 model_id: "test".into(),
340 },
341 iteration: 2,
342 });
343
344 let mut tampered = chain.entries().to_vec();
346 tampered[0].chain_hash = sha256_hex(b"tampered");
347
348 let result = verify_chain(&tampered, &verifying_key);
349 assert!(result.is_err());
350 match result.unwrap_err() {
351 AuditError::ChainIntegrity { entry_index, .. } => assert_eq!(entry_index, 0),
352 other => panic!("Expected ChainIntegrity, got {:?}", other),
353 }
354 }
355
356 #[test]
357 fn test_wrong_key_rejected() {
358 let key = test_signing_key();
359 let wrong_key = test_signing_key();
360 let mut chain = AuditChain::new(key);
361
362 chain.record(RecordParams {
363 director_output: "output",
364 critic_assessment: "review",
365 verdict: AuditVerdict::Approved,
366 dimension_scores: HashMap::new(),
367 score: 0.9,
368 critic_identity: AuditIdentity::Human {
369 user_id: "user-1".into(),
370 name: "Alice".into(),
371 },
372 iteration: 1,
373 });
374
375 let result = verify_chain(chain.entries(), &wrong_key.verifying_key());
376 assert!(result.is_err());
377 match result.unwrap_err() {
378 AuditError::InvalidSignature { entry_index, .. } => assert_eq!(entry_index, 0),
379 other => panic!("Expected InvalidSignature, got {:?}", other),
380 }
381 }
382
383 #[test]
384 fn test_entry_serialization() {
385 let key = test_signing_key();
386 let mut chain = AuditChain::new(key);
387
388 let entry = chain.record(RecordParams {
389 director_output: "test output",
390 critic_assessment: "test review",
391 verdict: AuditVerdict::NeedsRevision,
392 dimension_scores: {
393 let mut m = HashMap::new();
394 m.insert("accuracy".into(), 0.7);
395 m.insert("completeness".into(), 0.8);
396 m
397 },
398 score: 0.75,
399 critic_identity: AuditIdentity::Llm {
400 model_id: "claude-sonnet".into(),
401 },
402 iteration: 1,
403 });
404
405 let json = serde_json::to_string(&entry).unwrap();
406 let restored: CriticAuditEntry = serde_json::from_str(&json).unwrap();
407 assert_eq!(restored.entry_id, entry.entry_id);
408 assert_eq!(restored.verdict, AuditVerdict::NeedsRevision);
409 assert_eq!(restored.dimension_scores.len(), 2);
410 }
411
412 #[test]
413 fn test_empty_chain_verifies() {
414 let key = test_signing_key();
415 let chain = AuditChain::new(key);
416 assert!(chain.is_empty());
417 assert!(chain.verify(&chain.verifying_key()).is_ok());
418 }
419
420 #[test]
421 fn test_sha256_deterministic() {
422 let hash1 = sha256_hex(b"hello world");
423 let hash2 = sha256_hex(b"hello world");
424 assert_eq!(hash1, hash2);
425 assert_ne!(hash1, sha256_hex(b"different input"));
426 }
427
428 #[test]
429 fn test_chain_order_matters() {
430 let key = test_signing_key();
431 let verifying_key = key.verifying_key();
432 let mut chain = AuditChain::new(key);
433
434 chain.record(RecordParams {
435 director_output: "first",
436 critic_assessment: "review first",
437 verdict: AuditVerdict::NeedsRevision,
438 dimension_scores: HashMap::new(),
439 score: 0.5,
440 critic_identity: AuditIdentity::Llm {
441 model_id: "test".into(),
442 },
443 iteration: 1,
444 });
445 chain.record(RecordParams {
446 director_output: "second",
447 critic_assessment: "review second",
448 verdict: AuditVerdict::Approved,
449 dimension_scores: HashMap::new(),
450 score: 0.9,
451 critic_identity: AuditIdentity::Llm {
452 model_id: "test".into(),
453 },
454 iteration: 2,
455 });
456
457 let mut swapped = chain.entries().to_vec();
459 swapped.swap(0, 1);
460
461 let result = verify_chain(&swapped, &verifying_key);
462 assert!(result.is_err());
463 }
464}