Skip to main content

made_core/entities/
audit_chain.rs

1//! [`AuditChain`] — verification of a ceremony's journal.
2//!
3//! The verifier depends on nothing but the records. It does not know
4//! which store produced them, so a host cannot satisfy it by asserting
5//! its own integrity: the answer comes from the bytes that were
6//! written, which is the point of chaining them in the first place.
7
8use crate::value_objects::{AuditChainDefect, AuditChainVerdict, AuditSequence};
9
10use super::AuditRecord;
11
12/// Stateless verification of an ordered journal.
13#[derive(Debug)]
14pub struct AuditChain;
15
16impl AuditChain {
17    /// Verify records given in the order the journal returned them.
18    ///
19    /// An empty journal is intact: nothing was written, so nothing was
20    /// tampered with. Verification stops at the first defect — past it,
21    /// no statement about the remaining records would be sound.
22    #[must_use]
23    pub fn verify(records: &[AuditRecord]) -> AuditChainVerdict {
24        let Some((first, rest)) = records.split_first() else {
25            return AuditChainVerdict::Intact;
26        };
27
28        if !first.sequence().is_first() {
29            return AuditChainVerdict::Broken(AuditChainDefect::DoesNotStartAtTheBeginning {
30                found: first.sequence(),
31            });
32        }
33        if first.previous_record_hash().is_some() {
34            return AuditChainVerdict::Broken(AuditChainDefect::UnexpectedRoot {
35                at: first.sequence(),
36            });
37        }
38        if let Some(verdict) = digest_defect(first) {
39            return verdict;
40        }
41
42        let mut previous = first;
43        for record in rest {
44            if record.ceremony_id() != previous.ceremony_id() {
45                return AuditChainVerdict::Broken(AuditChainDefect::ForeignCeremony {
46                    at: record.sequence(),
47                });
48            }
49            if !record.sequence().follows(previous.sequence()) {
50                return AuditChainVerdict::Broken(AuditChainDefect::SequenceBroken {
51                    expected: previous.sequence().next(),
52                    found: record.sequence(),
53                });
54            }
55            match record.previous_record_hash() {
56                None => {
57                    return AuditChainVerdict::Broken(AuditChainDefect::UnexpectedRoot {
58                        at: record.sequence(),
59                    })
60                }
61                Some(hash) if hash != previous.record_hash() => {
62                    return AuditChainVerdict::Broken(AuditChainDefect::LinkBroken {
63                        at: record.sequence(),
64                    })
65                }
66                Some(_) => {}
67            }
68            if let Some(verdict) = digest_defect(record) {
69                return verdict;
70            }
71            previous = record;
72        }
73
74        AuditChainVerdict::Intact
75    }
76
77    /// The position the next record must occupy.
78    #[must_use]
79    pub fn next_sequence(records: &[AuditRecord]) -> AuditSequence {
80        records
81            .last()
82            .map_or(AuditSequence::FIRST, |record| record.sequence().next())
83    }
84}
85
86/// A record whose digest cannot even be recomputed is treated as
87/// altered: an unrenderable timestamp means the stored bytes are not
88/// what this implementation would have written.
89fn digest_defect(record: &AuditRecord) -> Option<AuditChainVerdict> {
90    match record.digest_is_intact() {
91        Ok(true) => None,
92        Ok(false) | Err(_) => Some(AuditChainVerdict::Broken(AuditChainDefect::DigestAltered {
93            at: record.sequence(),
94        })),
95    }
96}
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101    use crate::entities::AuditFact;
102    use crate::value_objects::{
103        AuditActor, AuditActorKind, AuditEventType, CeremonyId, CeremonyName, CeremonyVersion,
104        EventId,
105    };
106    use serde_json::Value;
107    use time::macros::datetime;
108
109    fn fact(event_id: &str, ceremony: &str) -> AuditFact {
110        AuditFact {
111            event_id: EventId::new(event_id).unwrap(),
112            event_type: AuditEventType::StepCompleted,
113            ceremony_id: CeremonyId::new(ceremony).unwrap(),
114            definition_name: CeremonyName::new("planning_ceremony").unwrap(),
115            definition_version: CeremonyVersion::v1(),
116            occurred_at: datetime!(2026-07-29 09:00:00 UTC),
117            actor: AuditActor::new("engineer-1", AuditActorKind::Human, None).unwrap(),
118            correlation_id: None,
119            causation_id: None,
120            trace: None,
121        }
122    }
123
124    fn chain() -> Vec<AuditRecord> {
125        let first = AuditRecord::first(fact("e1", "ceremony-1")).unwrap();
126        let second = AuditRecord::following(fact("e2", "ceremony-1"), &first).unwrap();
127        let third = AuditRecord::following(fact("e3", "ceremony-1"), &second).unwrap();
128        vec![first, second, third]
129    }
130
131    fn tampered(record: &AuditRecord, mutate: impl FnOnce(&mut Value)) -> AuditRecord {
132        let mut json = serde_json::to_value(record).unwrap();
133        mutate(&mut json);
134        serde_json::from_value(json).unwrap()
135    }
136
137    #[test]
138    fn an_empty_journal_is_intact() {
139        assert!(AuditChain::verify(&[]).is_intact());
140        assert_eq!(AuditChain::next_sequence(&[]), AuditSequence::FIRST);
141    }
142
143    #[test]
144    fn a_well_formed_journal_is_intact() {
145        let records = chain();
146
147        assert!(AuditChain::verify(&records).is_intact());
148        assert_eq!(AuditChain::next_sequence(&records).value(), 4);
149    }
150
151    #[test]
152    fn a_journal_missing_its_opening_records_is_detected() {
153        let records = chain();
154
155        assert_eq!(
156            AuditChain::verify(&records[1..]).defect(),
157            Some(AuditChainDefect::DoesNotStartAtTheBeginning {
158                found: AuditSequence::new(2).unwrap()
159            })
160        );
161    }
162
163    #[test]
164    fn a_record_removed_from_the_middle_is_detected() {
165        let records = chain();
166        let gapped = vec![records[0].clone(), records[2].clone()];
167
168        assert_eq!(
169            AuditChain::verify(&gapped).defect(),
170            Some(AuditChainDefect::SequenceBroken {
171                expected: AuditSequence::new(2).unwrap(),
172                found: AuditSequence::new(3).unwrap(),
173            })
174        );
175    }
176
177    #[test]
178    fn an_altered_record_is_detected_at_its_own_position() {
179        let mut records = chain();
180        records[1] = tampered(&records[1], |json| {
181            json["actor"]["actor_id"] = "someone-else".into();
182        });
183
184        assert_eq!(
185            AuditChain::verify(&records).defect(),
186            Some(AuditChainDefect::DigestAltered {
187                at: AuditSequence::new(2).unwrap()
188            })
189        );
190    }
191
192    #[test]
193    fn a_substituted_record_breaks_the_link_of_the_one_after_it() {
194        let records = chain();
195        let forged = AuditRecord::following(fact("forged", "ceremony-1"), &records[0]).unwrap();
196        let substituted = vec![records[0].clone(), forged, records[2].clone()];
197
198        // The forgery is internally sound — it is the third record,
199        // still naming the digest of the record it really followed,
200        // that exposes the substitution.
201        assert_eq!(
202            AuditChain::verify(&substituted).defect(),
203            Some(AuditChainDefect::LinkBroken {
204                at: AuditSequence::new(3).unwrap()
205            })
206        );
207    }
208
209    #[test]
210    fn a_grafted_foreign_record_is_detected() {
211        let records = chain();
212        let foreign = AuditRecord::first(fact("f1", "ceremony-2")).unwrap();
213        let grafted = vec![records[0].clone(), foreign];
214
215        assert_eq!(
216            AuditChain::verify(&grafted).defect(),
217            Some(AuditChainDefect::ForeignCeremony {
218                at: AuditSequence::FIRST
219            })
220        );
221    }
222
223    #[test]
224    fn an_opening_record_that_claims_a_predecessor_is_detected() {
225        let records = chain();
226        let rooted = tampered(&records[0], |json| {
227            json["previous_record_hash"] = serde_json::to_value([9_u8; 32]).unwrap();
228        });
229
230        assert_eq!(
231            AuditChain::verify(&[rooted]).defect(),
232            Some(AuditChainDefect::UnexpectedRoot {
233                at: AuditSequence::FIRST
234            })
235        );
236    }
237
238    #[test]
239    fn a_later_record_that_claims_no_predecessor_is_detected() {
240        let mut records = chain();
241        records[1] = tampered(&records[1], |json| {
242            json["previous_record_hash"] = Value::Null;
243        });
244
245        assert_eq!(
246            AuditChain::verify(&records).defect(),
247            Some(AuditChainDefect::UnexpectedRoot {
248                at: AuditSequence::new(2).unwrap()
249            })
250        );
251    }
252
253    #[test]
254    fn verification_stops_at_the_first_defect() {
255        let mut records = chain();
256        records[1] = tampered(&records[1], |json| {
257            json["event_type"] = "step_failed".into();
258        });
259        records[2] = tampered(&records[2], |json| {
260            json["event_type"] = "step_failed".into();
261        });
262
263        // Both are altered; only the earlier one is reported, because
264        // past a break nothing further can be trusted.
265        assert_eq!(
266            AuditChain::verify(&records)
267                .defect()
268                .map(AuditChainDefect::at),
269            Some(AuditSequence::new(2).unwrap())
270        );
271    }
272}