Skip to main content

lean_ctx/gateway_server/
evidence.rs

1//! Signed usage-evidence export (enterprise#36, EU-AI-Act evidence trail).
2//!
3//! An [`EvidenceExportV1`] is a self-verifying JSON artifact over a time
4//! window of `usage_events`: daily aggregates (day × person × project ×
5//! model), the window totals, a BLAKE3 digest of the canonical row bytes and
6//! an Ed25519 signature with the gateway's persistent machine identity — the
7//! same keystore the signed savings ledger uses (enterprise#19), so one public
8//! key verifies both artifact families.
9//!
10//! Verification is offline (`verify`): recompute canonical bytes, check
11//! digest, check signature. Any altered byte fails. The artifact is a
12//! deterministic function of the database contents and the window — exporting
13//! twice yields byte-identical rows (stable ORDER BY, rounded sums).
14
15use ed25519_dalek::Signer;
16use serde::{Deserialize, Serialize};
17
18/// Schema discriminator for [`EvidenceExportV1`].
19pub const EVIDENCE_SCHEMA_V1: &str = "leanctx.evidence.v1";
20
21/// Signed, exportable usage evidence over a window.
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct EvidenceExportV1 {
24    /// Discriminator so a verifier can refuse unrelated signed JSON.
25    pub schema: String,
26    /// Window bounds (inclusive), RFC 3339 UTC.
27    pub from: String,
28    pub to: String,
29    /// Daily aggregates: day × person × project × model × provider.
30    pub rows: Vec<serde_json::Value>,
31    /// Row count (redundant with `rows.len()`, part of the signed payload).
32    pub row_count: u64,
33    /// Window totals over the raw events (not the aggregates).
34    pub totals: EvidenceTotals,
35    /// BLAKE3 hex digest of the canonical `rows` bytes.
36    pub rows_digest_blake3: String,
37    /// Ed25519 public key (hex). `None` until signed.
38    pub signer_public_key: Option<String>,
39    /// Ed25519 signature over the canonical bytes (hex). `None` until signed.
40    pub signature: Option<String>,
41}
42
43/// Aggregate totals of the exported window.
44#[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/// Outcome of [`EvidenceExportV1::verify`].
54#[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    /// Builds the unsigned artifact from already-aggregated rows + totals.
64    #[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    /// Deterministic bytes that get signed/verified: the whole struct with the
86    /// two signature fields cleared (same convention as `SignedSavingsBatchV1`).
87    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    /// Signs with the persistent machine identity (`agent_identity` keystore).
95    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    /// Signs with an explicit key (hermetic tests).
101    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    /// Offline verification: digest over `rows`, then the Ed25519 signature
114    /// over the canonical bytes with the embedded public key.
115    #[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
169/// BLAKE3 hex over the concatenated canonical row bytes (order-sensitive —
170/// the SQL ORDER BY is part of the contract).
171fn 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
182/// Builds + signs the artifact from the store for `[from, to]`.
183pub 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        // Row tampering breaks the digest.
272        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        // Totals tampering keeps the digest but breaks the signature.
278        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        // Unsigned artifacts are refused.
285        let unsigned = sample();
286        assert!(!unsigned.verify().signature_valid);
287    }
288
289    #[test]
290    fn export_is_deterministic_for_same_rows() {
291        // #498-adjacent: the artifact is a pure function of (window, rows).
292        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}