1use ed25519_dalek::Signer;
16use serde::{Deserialize, Serialize};
17
18pub const EVIDENCE_SCHEMA_V1: &str = "leanctx.evidence.v1";
20
21#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct EvidenceExportV1 {
24 pub schema: String,
26 pub from: String,
28 pub to: String,
29 pub rows: Vec<serde_json::Value>,
31 pub row_count: u64,
33 pub totals: EvidenceTotals,
35 pub rows_digest_blake3: String,
37 pub signer_public_key: Option<String>,
39 pub signature: Option<String>,
41}
42
43#[derive(Debug, Clone, Default, Serialize, Deserialize)]
45pub struct EvidenceTotals {
46 pub requests: u64,
47 pub cost_usd: f64,
48 pub saved_usd: f64,
49 pub reference_cost_usd: f64,
50 pub persons: u64,
51}
52
53#[derive(Debug, Clone)]
55pub struct EvidenceVerifyResult {
56 pub signature_valid: bool,
57 pub digest_valid: bool,
58 pub signer_public_key: Option<String>,
59 pub error: Option<String>,
60}
61
62impl EvidenceExportV1 {
63 #[must_use]
65 pub fn build(
66 from: chrono::DateTime<chrono::Utc>,
67 to: chrono::DateTime<chrono::Utc>,
68 rows: Vec<serde_json::Value>,
69 totals: EvidenceTotals,
70 ) -> Self {
71 let digest = rows_digest(&rows);
72 Self {
73 schema: EVIDENCE_SCHEMA_V1.to_string(),
74 from: from.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
75 to: to.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
76 row_count: rows.len() as u64,
77 rows,
78 totals,
79 rows_digest_blake3: digest,
80 signer_public_key: None,
81 signature: None,
82 }
83 }
84
85 pub fn canonical_bytes(&self) -> Result<Vec<u8>, String> {
88 let mut clone = self.clone();
89 clone.signature = None;
90 clone.signer_public_key = None;
91 serde_json::to_vec(&clone).map_err(|e| format!("serialize for signing: {e}"))
92 }
93
94 pub fn sign(&mut self, agent_id: &str) -> Result<(), String> {
96 let key = crate::core::agent_identity::get_or_create_keypair(agent_id)?;
97 self.sign_with_key(&key)
98 }
99
100 pub fn sign_with_key(&mut self, key: &ed25519_dalek::SigningKey) -> Result<(), String> {
102 self.signature = None;
103 self.signer_public_key = None;
104 let canonical = self.canonical_bytes()?;
105 let sig = key.sign(&canonical);
106 self.signer_public_key = Some(crate::core::agent_identity::hex_encode(
107 &key.verifying_key().to_bytes(),
108 ));
109 self.signature = Some(crate::core::agent_identity::hex_encode(&sig.to_bytes()));
110 Ok(())
111 }
112
113 #[must_use]
116 pub fn verify(&self) -> EvidenceVerifyResult {
117 let fail = |digest_valid: bool, msg: &str| EvidenceVerifyResult {
118 signature_valid: false,
119 digest_valid,
120 signer_public_key: self.signer_public_key.clone(),
121 error: Some(msg.to_string()),
122 };
123
124 if self.schema != EVIDENCE_SCHEMA_V1 {
125 return fail(false, "unknown schema");
126 }
127 let digest_valid = rows_digest(&self.rows) == self.rows_digest_blake3
128 && self.row_count == self.rows.len() as u64;
129 if !digest_valid {
130 return fail(false, "rows digest mismatch — rows were altered");
131 }
132
133 let (Some(sig_hex), Some(pk_hex)) = (&self.signature, &self.signer_public_key) else {
134 return fail(digest_valid, "artifact is not signed");
135 };
136 let Ok(pk_bytes) = crate::core::agent_identity::hex_decode(pk_hex) else {
137 return fail(digest_valid, "invalid public key hex");
138 };
139 let Ok(pk_arr) = <[u8; 32]>::try_from(pk_bytes.as_slice()) else {
140 return fail(digest_valid, "public key must be 32 bytes");
141 };
142 let Ok(vk) = ed25519_dalek::VerifyingKey::from_bytes(&pk_arr) else {
143 return fail(digest_valid, "invalid Ed25519 public key");
144 };
145 let Ok(sig_bytes) = crate::core::agent_identity::hex_decode(sig_hex) else {
146 return fail(digest_valid, "invalid signature hex");
147 };
148 let Ok(sig_arr) = <[u8; 64]>::try_from(sig_bytes.as_slice()) else {
149 return fail(digest_valid, "signature must be 64 bytes");
150 };
151 let signature = ed25519_dalek::Signature::from_bytes(&sig_arr);
152 let canonical = match self.canonical_bytes() {
153 Ok(b) => b,
154 Err(e) => return fail(digest_valid, &e),
155 };
156 use ed25519_dalek::Verifier;
157 match vk.verify(&canonical, &signature) {
158 Ok(()) => EvidenceVerifyResult {
159 signature_valid: true,
160 digest_valid,
161 signer_public_key: self.signer_public_key.clone(),
162 error: None,
163 },
164 Err(_) => fail(digest_valid, "signature does not match canonical payload"),
165 }
166 }
167}
168
169fn rows_digest(rows: &[serde_json::Value]) -> String {
172 let mut hasher = blake3::Hasher::new();
173 for row in rows {
174 if let Ok(bytes) = serde_json::to_vec(row) {
175 hasher.update(&bytes);
176 hasher.update(b"\n");
177 }
178 }
179 hasher.finalize().to_hex().to_string()
180}
181
182pub async fn generate(
184 pool: &deadpool_postgres::Pool,
185 from: chrono::DateTime<chrono::Utc>,
186 to: chrono::DateTime<chrono::Utc>,
187) -> anyhow::Result<EvidenceExportV1> {
188 let rows = super::store::evidence_rows(pool, from, to).await?;
189 let totals = window_totals(pool, from, to).await?;
190 let mut artifact = EvidenceExportV1::build(from, to, rows, totals);
191 artifact
192 .sign("gateway-evidence")
193 .map_err(|e| anyhow::anyhow!("sign evidence export: {e}"))?;
194 Ok(artifact)
195}
196
197async fn window_totals(
198 pool: &deadpool_postgres::Pool,
199 from: chrono::DateTime<chrono::Utc>,
200 to: chrono::DateTime<chrono::Utc>,
201) -> anyhow::Result<EvidenceTotals> {
202 let client = pool.get().await?;
203 let row = client
204 .query_one(
205 "SELECT count(*), coalesce(sum(cost_usd), 0), coalesce(sum(saved_usd), 0), \
206 coalesce(sum(reference_cost_usd), 0), count(DISTINCT person) \
207 FROM usage_events WHERE ts >= $1 AND ts <= $2",
208 &[&from, &to],
209 )
210 .await?;
211 Ok(EvidenceTotals {
212 requests: u64::try_from(row.get::<_, i64>(0)).unwrap_or(0),
213 cost_usd: row.get::<_, f64>(1),
214 saved_usd: row.get::<_, f64>(2),
215 reference_cost_usd: row.get::<_, f64>(3),
216 persons: u64::try_from(row.get::<_, i64>(4)).unwrap_or(0),
217 })
218}
219
220#[cfg(test)]
221mod tests {
222 use super::*;
223 use serde_json::json;
224
225 fn sample() -> EvidenceExportV1 {
226 let from = chrono::DateTime::parse_from_rfc3339("2026-06-01T00:00:00Z")
227 .unwrap()
228 .with_timezone(&chrono::Utc);
229 let to = chrono::DateTime::parse_from_rfc3339("2026-06-30T23:59:59Z")
230 .unwrap()
231 .with_timezone(&chrono::Utc);
232 EvidenceExportV1::build(
233 from,
234 to,
235 vec![
236 json!({"date":"2026-06-01","person":"p:abc","project":"web","model":"claude-sonnet-4-5","provider":"Anthropic","requests":12,"cost_usd":1.25}),
237 json!({"date":"2026-06-02","person":"p:abc","project":"web","model":"gpt-4o-mini","provider":"foundry","requests":30,"cost_usd":0.42}),
238 ],
239 EvidenceTotals {
240 requests: 42,
241 cost_usd: 1.67,
242 saved_usd: 0.9,
243 reference_cost_usd: 3.1,
244 persons: 1,
245 },
246 )
247 }
248
249 fn test_key() -> ed25519_dalek::SigningKey {
250 ed25519_dalek::SigningKey::from_bytes(&[7u8; 32])
251 }
252
253 #[test]
254 fn sign_then_verify_roundtrips() {
255 let mut artifact = sample();
256 artifact.sign_with_key(&test_key()).unwrap();
257 let result = artifact.verify();
258 assert!(result.digest_valid);
259 assert!(result.signature_valid, "{:?}", result.error);
260 assert_eq!(
261 result.signer_public_key, artifact.signer_public_key,
262 "verify must report the embedded signer"
263 );
264 }
265
266 #[test]
267 fn any_tamper_breaks_verification() {
268 let mut artifact = sample();
269 artifact.sign_with_key(&test_key()).unwrap();
270
271 let mut tampered = artifact.clone();
273 tampered.rows[0]["cost_usd"] = json!(0.01);
274 let r = tampered.verify();
275 assert!(!r.digest_valid && !r.signature_valid);
276
277 let mut tampered = artifact.clone();
279 tampered.totals.cost_usd = 0.0;
280 let r = tampered.verify();
281 assert!(r.digest_valid);
282 assert!(!r.signature_valid);
283
284 let unsigned = sample();
286 assert!(!unsigned.verify().signature_valid);
287 }
288
289 #[test]
290 fn export_is_deterministic_for_same_rows() {
291 let a = sample();
293 let b = sample();
294 assert_eq!(a.rows_digest_blake3, b.rows_digest_blake3);
295 assert_eq!(
296 a.canonical_bytes().unwrap(),
297 b.canonical_bytes().unwrap(),
298 "same inputs must produce byte-identical canonical payloads"
299 );
300 }
301}