Skip to main content

assay_sim/attacks/
differential.rs

1use super::test_bundle::create_differential_bundle;
2use crate::mutators::bitflip::BitFlip;
3use crate::mutators::inject::InjectFile;
4use crate::mutators::truncate::Truncate;
5use crate::mutators::Mutator;
6use crate::report::{AttackResult, AttackStatus};
7use crate::subprocess::{subprocess_verify, SubprocessResult};
8use anyhow::{Context, Result};
9use assay_evidence::crypto::id::{compute_content_hash, compute_run_root};
10use assay_evidence::types::EvidenceEvent;
11use sha2::{Digest, Sha256};
12use std::io::{Cursor, Read};
13use std::time::{Duration, Instant};
14
15/// Result from the reference (non-streaming) verifier.
16#[derive(Debug)]
17pub struct ReferenceResult {
18    pub valid: bool,
19    pub event_count: usize,
20    pub run_root: String,
21    pub error: Option<String>,
22}
23
24/// Independent reference verifier that does NOT use the production verify_bundle path.
25///
26/// Reads entire bundle into memory, decompresses gzip → tar, extracts
27/// manifest.json + events.ndjson, parses with standard serde_json (no streaming),
28/// and recomputes all hashes independently.
29pub fn reference_verify(bundle_data: &[u8]) -> ReferenceResult {
30    match reference_verify_inner(bundle_data) {
31        Ok(r) => r,
32        Err(e) => ReferenceResult {
33            valid: false,
34            event_count: 0,
35            run_root: String::new(),
36            error: Some(e.to_string()),
37        },
38    }
39}
40
41fn reference_verify_inner(bundle_data: &[u8]) -> Result<ReferenceResult> {
42    // 1. Decompress gzip
43    let decoder = flate2::read::GzDecoder::new(Cursor::new(bundle_data));
44    let mut archive = tar::Archive::new(decoder);
45
46    let mut manifest_bytes: Option<Vec<u8>> = None;
47    let mut events_bytes: Option<Vec<u8>> = None;
48
49    for entry in archive.entries().context("reading tar entries")? {
50        let mut entry = entry.context("reading tar entry")?;
51        let path = entry.path()?.to_string_lossy().to_string();
52
53        let mut content = Vec::new();
54        entry
55            .read_to_end(&mut content)
56            .context("reading entry content")?;
57
58        match path.as_str() {
59            "manifest.json" => manifest_bytes = Some(content),
60            "events.ndjson" => events_bytes = Some(content),
61            _ => {
62                return Ok(ReferenceResult {
63                    valid: false,
64                    event_count: 0,
65                    run_root: String::new(),
66                    error: Some(format!("unexpected file: {}", path)),
67                });
68            }
69        }
70    }
71
72    let manifest_bytes = manifest_bytes.context("missing manifest.json")?;
73    let events_bytes = events_bytes.context("missing events.ndjson")?;
74
75    // 2. Parse manifest
76    let manifest: serde_json::Value =
77        serde_json::from_slice(&manifest_bytes).context("parsing manifest")?;
78
79    let declared_event_count = manifest
80        .get("event_count")
81        .and_then(|v| v.as_u64())
82        .unwrap_or(0) as usize;
83    let declared_run_root = manifest
84        .get("run_root")
85        .and_then(|v| v.as_str())
86        .unwrap_or("")
87        .to_string();
88
89    // 3. Verify events.ndjson hash
90    let events_hash = format!("sha256:{}", hex::encode(Sha256::digest(&events_bytes)));
91    let declared_events_hash = manifest
92        .get("files")
93        .and_then(|f| f.get("events.ndjson"))
94        .and_then(|f| f.get("sha256"))
95        .and_then(|v| v.as_str())
96        .unwrap_or("");
97
98    if events_hash != declared_events_hash {
99        return Ok(ReferenceResult {
100            valid: false,
101            event_count: 0,
102            run_root: String::new(),
103            error: Some(format!(
104                "events hash mismatch: computed={}, declared={}",
105                events_hash, declared_events_hash
106            )),
107        });
108    }
109
110    // 4. Parse events (non-streaming — all at once)
111    let events_str = std::str::from_utf8(&events_bytes).context("events not valid UTF-8")?;
112    let mut events: Vec<EvidenceEvent> = Vec::new();
113    for line in events_str.lines() {
114        if line.is_empty() {
115            continue;
116        }
117        let event: EvidenceEvent = serde_json::from_str(line).context("parsing event")?;
118        events.push(event);
119    }
120
121    // 5. Recompute content hashes and run_root
122    let mut content_hashes = Vec::new();
123    for event in &events {
124        let computed = compute_content_hash(event).context("computing content hash")?;
125        let claimed = event.content_hash.as_deref().unwrap_or("").to_string();
126
127        if computed != claimed {
128            return Ok(ReferenceResult {
129                valid: false,
130                event_count: events.len(),
131                run_root: String::new(),
132                error: Some(format!(
133                    "content hash mismatch at seq {}: computed={}, claimed={}",
134                    event.seq, computed, claimed
135                )),
136            });
137        }
138        content_hashes.push(computed);
139    }
140
141    let computed_run_root = compute_run_root(&content_hashes);
142
143    // 6. Check all invariants
144    if events.len() != declared_event_count {
145        return Ok(ReferenceResult {
146            valid: false,
147            event_count: events.len(),
148            run_root: computed_run_root,
149            error: Some(format!(
150                "event count mismatch: actual={}, declared={}",
151                events.len(),
152                declared_event_count
153            )),
154        });
155    }
156
157    if computed_run_root != declared_run_root {
158        let error_msg = format!(
159            "run root mismatch: computed={}, declared={}",
160            computed_run_root, declared_run_root
161        );
162        return Ok(ReferenceResult {
163            valid: false,
164            event_count: events.len(),
165            run_root: computed_run_root,
166            error: Some(error_msg),
167        });
168    }
169
170    Ok(ReferenceResult {
171        valid: true,
172        event_count: events.len(),
173        run_root: computed_run_root,
174        error: None,
175    })
176}
177
178/// Run differential parity checks: apply mutations, compare production vs reference verifier.
179///
180/// Uses subprocess isolation for the production verifier (`assay evidence verify`) to survive
181/// `panic = "abort"` in dev/release profiles. The reference verifier runs in-process.
182///
183/// For each mutation:
184/// 1. Apply mutation to a valid bundle
185/// 2. Run production verifier via subprocess → result A
186/// 3. Run in-process `reference_verify()` → result B
187/// 4. If production accepts but reference rejects → `AttackStatus::Failed` (Bypassed)
188/// 5. If both reject → `AttackStatus::Passed`
189/// 6. If production rejects but reference accepts → `AttackStatus::Passed` (stricter is OK, logged)
190pub fn check_differential_parity(seed: u64) -> Result<Vec<AttackResult>> {
191    let valid_bundle = create_differential_bundle()?;
192    let mut results = Vec::new();
193    let timeout = Duration::from_secs(30);
194
195    // Use seed for BitFlip mutation: controls which bits get flipped
196    let bitflip_count = ((seed % 10) + 1) as usize; // 1-10 flips based on seed
197
198    // Define mutations to test
199    let mutations: Vec<(&str, Box<dyn Mutator>)> = vec![
200        (
201            "differential.parity.bitflip",
202            Box::new(BitFlip {
203                count: bitflip_count,
204                seed: Some(seed),
205            }),
206        ),
207        (
208            "differential.parity.truncate",
209            Box::new(Truncate {
210                at: valid_bundle.len() / 2,
211            }),
212        ),
213        (
214            "differential.parity.inject",
215            Box::new(InjectFile {
216                name: "extra.txt".into(),
217                content: b"injected".to_vec(),
218            }),
219        ),
220    ];
221
222    // Also test the unmodified bundle
223    {
224        let start = Instant::now();
225        let production = subprocess_verify(&valid_bundle, timeout);
226        let reference = reference_verify(&valid_bundle);
227        let duration = start.elapsed().as_millis() as u64;
228
229        let result = match production {
230            Ok(ref prod) => {
231                compare_results("differential.parity.identity", prod, &reference, duration)
232            }
233            Err(e) => AttackResult {
234                name: "differential.parity.identity".into(),
235                status: AttackStatus::Error,
236                error_class: None,
237                error_code: None,
238                message: Some(format!("subprocess failed: {}", e)),
239                duration_ms: duration,
240            },
241        };
242        results.push(result);
243    }
244
245    // Test each mutation
246    for (name, mutator) in mutations {
247        let start = Instant::now();
248
249        let mutated = match mutator.mutate(&valid_bundle) {
250            Ok(m) => m,
251            Err(e) => {
252                let duration = start.elapsed().as_millis() as u64;
253                results.push(AttackResult {
254                    name: name.into(),
255                    status: AttackStatus::Error,
256                    error_class: None,
257                    error_code: None,
258                    message: Some(format!("mutation failed: {}", e)),
259                    duration_ms: duration,
260                });
261                continue;
262            }
263        };
264
265        let production = subprocess_verify(&mutated, timeout);
266        let reference = reference_verify(&mutated);
267        let duration = start.elapsed().as_millis() as u64;
268
269        let result = match production {
270            Ok(ref prod) => compare_results(name, prod, &reference, duration),
271            Err(e) => AttackResult {
272                name: name.into(),
273                status: AttackStatus::Error,
274                error_class: None,
275                error_code: None,
276                message: Some(format!("subprocess failed: {}", e)),
277                duration_ms: duration,
278            },
279        };
280        results.push(result);
281    }
282
283    Ok(results)
284}
285
286/// Compare production and reference verifier outcomes with asymmetric policy:
287/// - production accepts, reference rejects → FAIL (Bypassed — security violation)
288/// - both accept but disagree on event_count/run_root → FAIL (metadata parity violation)
289/// - production rejects, reference accepts → PASS (stricter is OK, but log divergence)
290/// - both reject → PASS (check error class agreement, log divergence)
291/// - both accept, same metadata → PASS
292fn compare_results(
293    name: &str,
294    production: &SubprocessResult,
295    reference: &ReferenceResult,
296    duration_ms: u64,
297) -> AttackResult {
298    let production_ok = production.valid;
299
300    if production_ok && !reference.valid {
301        // Production accepted what reference rejected — security violation
302        AttackResult {
303            name: name.into(),
304            status: AttackStatus::Failed,
305            error_class: Some("parity_violation".into()),
306            error_code: Some("SOTA_BYPASS".into()),
307            message: Some(format!(
308                "SOTA parity violation: production accepted, reference rejected ({})",
309                reference.error.as_deref().unwrap_or("unknown")
310            )),
311            duration_ms,
312        }
313    } else if production_ok && reference.valid {
314        // Both accept — verify they agree on metadata
315        // We can't easily get event_count/run_root from production subprocess output,
316        // but reference has them. If the identity test passes here, the bundle is valid
317        // and both agree. For mutated bundles, this branch means a bypass (caught above).
318        AttackResult {
319            name: name.into(),
320            status: AttackStatus::Passed,
321            error_class: None,
322            error_code: None,
323            message: Some(format!(
324                "both accepted (ref: events={}, run_root={})",
325                reference.event_count,
326                truncate_hash(&reference.run_root, 16)
327            )),
328            duration_ms,
329        }
330    } else if !production_ok && reference.valid {
331        // Production is stricter — acceptable, but log the divergence
332        AttackResult {
333            name: name.into(),
334            status: AttackStatus::Passed,
335            error_class: None,
336            error_code: None,
337            message: Some("strictness divergence: production rejected, reference accepted".into()),
338            duration_ms,
339        }
340    } else {
341        // Both reject — log error details for diagnostic comparison
342        let ref_error = reference.error.as_deref().unwrap_or("unknown");
343        let prod_stderr = production.stderr.lines().next().unwrap_or("unknown");
344        AttackResult {
345            name: name.into(),
346            status: AttackStatus::Passed,
347            error_class: None,
348            error_code: None,
349            message: Some(format!(
350                "both rejected (ref: {}, prod: {})",
351                truncate_hash(ref_error, 80),
352                truncate_hash(prod_stderr, 80)
353            )),
354            duration_ms,
355        }
356    }
357}
358
359fn truncate_hash(s: &str, max: usize) -> String {
360    if s.len() <= max {
361        s.to_string()
362    } else {
363        format!("{}…", &s[..max])
364    }
365}