Skip to main content

ahu/
verify.rs

1use sha2::{Digest, Sha256};
2
3use crate::bundle::Bundle;
4use crate::error::{AhuError, Result};
5use crate::index::validate_sort_order;
6
7/// Results of bundle verification.
8#[derive(Debug)]
9pub struct VerifyResult {
10    pub header_ok: bool,
11    pub manifest_ok: bool,
12    pub index_digest_ok: bool,
13    pub data_digest_ok: bool,
14    pub sort_order_ok: bool,
15    pub entry_bounds_ok: bool,
16    pub seal_present: bool,
17    pub entry_count_matches: bool,
18    pub warnings: Vec<String>,
19}
20
21/// Verify a bundle's structural integrity.
22///
23/// This checks everything *except* the CMS seal signature (which requires
24/// a trust anchor) and individual OCSP response signatures (which are
25/// expensive at scale and optional per §3.1 of the spec).
26pub fn verify_structure(bundle: &Bundle) -> Result<VerifyResult> {
27    let mut result = VerifyResult {
28        header_ok: true,
29        manifest_ok: true,
30        index_digest_ok: false,
31        data_digest_ok: false,
32        sort_order_ok: false,
33        entry_bounds_ok: true,
34        seal_present: !bundle.seal_bytes.is_empty(),
35        entry_count_matches: false,
36        warnings: Vec::new(),
37    };
38
39    // Verify index digest.
40    let mut index_bytes = Vec::new();
41    for record in &bundle.index {
42        record.write_to(&mut index_bytes)?;
43    }
44    let computed_index_digest: [u8; 32] = Sha256::digest(&index_bytes).into();
45    result.index_digest_ok = computed_index_digest == bundle.manifest.integrity.index_digest;
46    if !result.index_digest_ok {
47        return Err(AhuError::IndexDigestMismatch {
48            expected: hex::encode(bundle.manifest.integrity.index_digest),
49            actual: hex::encode(computed_index_digest),
50        });
51    }
52
53    // Verify data digest.
54    let computed_data_digest: [u8; 32] = Sha256::digest(&bundle.data).into();
55    result.data_digest_ok = computed_data_digest == bundle.manifest.integrity.data_digest;
56    if !result.data_digest_ok {
57        return Err(AhuError::DataDigestMismatch {
58            expected: hex::encode(bundle.manifest.integrity.data_digest),
59            actual: hex::encode(computed_data_digest),
60        });
61    }
62
63    // Verify sort order.
64    validate_sort_order(&bundle.index)?;
65    result.sort_order_ok = true;
66
67    // Verify entry count.
68    result.entry_count_matches = bundle.index.len() as u64 == bundle.manifest.entry_count;
69    if !result.entry_count_matches {
70        result.warnings.push(format!(
71            "entry_count in manifest ({}) does not match index record count ({})",
72            bundle.manifest.entry_count,
73            bundle.index.len()
74        ));
75    }
76
77    // Verify entry bounds (each record's offset+length within the data section).
78    for record in &bundle.index {
79        if record.is_tombstone() {
80            continue;
81        }
82        if bundle.entry_bytes(record).is_none() {
83            result.entry_bounds_ok = false;
84            return Err(AhuError::EntryOutOfBounds {
85                offset: record.data_offset,
86                length: record.data_length,
87            });
88        }
89    }
90
91    // Check for delta-specific requirements.
92    if bundle.manifest.bundle_type == crate::manifest::BundleType::Delta
93        && bundle.manifest.continuity.base_manifest_digest.is_none()
94    {
95        return Err(AhuError::DeltaMissingBase);
96    }
97
98    // Warn about missing seal.
99    if !result.seal_present {
100        result.warnings.push("seal section is empty".into());
101    }
102
103    Ok(result)
104}
105
106/// Compute the SHA-256 digest of the manifest bytes.
107pub fn manifest_digest(manifest_bytes: &[u8]) -> [u8; 32] {
108    Sha256::digest(manifest_bytes).into()
109}
110
111/// Check epoch high-water marks. Returns errors for any rollback attempt.
112pub fn check_epochs(
113    bundle: &Bundle,
114    high_water_marks: &std::collections::HashMap<(String, Vec<u8>), u64>,
115) -> Result<()> {
116    for scope in &bundle.manifest.ca_scopes {
117        let key = (
118            bundle.manifest.producer_id.clone(),
119            scope.issuer_key_hash.clone(),
120        );
121        if let Some(&hw) = high_water_marks.get(&key) {
122            if scope.epoch <= hw {
123                return Err(AhuError::EpochRollback {
124                    scope: hex::encode(&scope.issuer_key_hash),
125                    epoch: scope.epoch,
126                    high_water: hw,
127                });
128            }
129        }
130    }
131    Ok(())
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137    use crate::bundle::BundleBuilder;
138    use crate::manifest::*;
139    use sha2::Sha256;
140    use uuid::Uuid;
141
142    fn test_manifest() -> Manifest {
143        Manifest {
144            format_version: 1,
145            bundle_id: Uuid::nil(),
146            producer_id: "test".into(),
147            created_at: 1700000000,
148            bundle_type: BundleType::Full,
149            ca_scopes: vec![CaScope {
150                hash_algorithm: vec![0x01],
151                issuer_name_hash: vec![0xAA; 32],
152                issuer_key_hash: vec![0xBB; 32],
153                epoch: 5,
154                responder_id: ResponderId {
155                    id_type: ResponderIdType::ByKey,
156                    value: vec![0xCC; 20],
157                },
158                responder_chain: None,
159                signature_algorithm: vec![0x02],
160                completeness: Completeness::AuthoritativeComplete,
161            }],
162            window: Window {
163                produced_at: 1700000000,
164                this_update_min: 1700000000,
165                next_update_min: 1700086400,
166                next_update_max: 1700093600,
167            },
168            integrity: Integrity {
169                index_digest: [0; 32],
170                data_digest: [0; 32],
171            },
172            entry_count: 0,
173            continuity: Continuity {
174                prev_manifest_digest: None,
175                base_manifest_digest: None,
176                chain_length: 0,
177            },
178            shard: None,
179            compression: None,
180            extensions: None,
181        }
182    }
183
184    #[test]
185    fn verify_valid_bundle() {
186        let mut builder = BundleBuilder::new(test_manifest());
187        builder.add_entry([0xAA; 32], b"response".to_vec());
188
189        let bytes = builder.build(|m| Ok(Sha256::digest(m).to_vec())).unwrap();
190
191        let bundle = Bundle::from_bytes(&bytes).unwrap();
192        let result = verify_structure(&bundle).unwrap();
193        assert!(result.index_digest_ok);
194        assert!(result.data_digest_ok);
195        assert!(result.sort_order_ok);
196        assert!(result.entry_bounds_ok);
197        assert!(result.entry_count_matches);
198    }
199
200    #[test]
201    fn epoch_rollback_rejected() {
202        let mut builder = BundleBuilder::new(test_manifest());
203        builder.add_entry([0xAA; 32], b"response".to_vec());
204
205        let bytes = builder.build(|m| Ok(Sha256::digest(m).to_vec())).unwrap();
206
207        let bundle = Bundle::from_bytes(&bytes).unwrap();
208
209        let mut hw = std::collections::HashMap::new();
210        hw.insert(("test".to_string(), vec![0xBB; 32]), 5);
211
212        let err = check_epochs(&bundle, &hw).unwrap_err();
213        assert!(matches!(err, AhuError::EpochRollback { .. }));
214    }
215}