Skip to main content

confium_transparency/ers/
mod.rs

1//! RFC 4998 Evidence Record Syntax (ERS) for long-term archival.
2//!
3//! Implements Evidence Records that protect artifacts over decades
4//! as hash algorithms weaken. Periodic re-timestamping with stronger
5//! algorithms maintains verifiability.
6//!
7//! Confium extends standard ERS with periodic re-quorum: every N years,
8//! the current quorum re-signs and re-encrypts archives under current
9//! algorithm suites.
10//!
11//! See `TODO.roadmap/37-long-term-archival.md` for full spec.
12
13#![forbid(unsafe_code)]
14#![allow(missing_docs)] // TODO: document before 1.0
15
16use chrono::{DateTime, Utc};
17use serde::{Deserialize, Serialize};
18use sha2::{Digest, Sha256};
19
20/// Hash algorithm identifier.
21#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(rename_all = "snake_case")]
23pub enum HashAlgorithm {
24    /// SHA-256.
25    Sha256,
26    /// SHA-384.
27    Sha384,
28    /// SHA-512.
29    Sha512,
30    /// SHA3-256.
31    Sha3_256,
32}
33
34/// An Evidence Record (RFC 4998).
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct EvidenceRecord {
37    /// ERS version.
38    pub version: u32,
39    /// Digest algorithms used (one per ArchiveTimeStampSequence entry).
40    pub digest_algorithms: Vec<HashAlgorithm>,
41    /// Sequence of archive timestamps (added as algorithms age).
42    pub archive_time_stamp_sequences: Vec<ArchiveTimeStampSequence>,
43}
44
45/// A sequence of archive timestamps covering a time period.
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct ArchiveTimeStampSequence {
48    /// Sequence number (0-based).
49    pub sequence_number: u32,
50    /// Reduced hash tree (Merkle tree of artifact hashes).
51    pub reduced_hash_tree: Vec<[u8; 32]>,
52    /// The RFC 3161 timestamp token.
53    pub time_stamp: TimeStamp,
54    /// When the timestamp was applied.
55    pub applied_at: DateTime<Utc>,
56}
57
58/// An RFC 3161 timestamp token.
59#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct TimeStamp {
61    /// TSA (Time Stamping Authority) identifier.
62    pub tsa_id: String,
63    /// Timestamp token bytes (PKCS#7 SignedData from TSA).
64    pub token: Vec<u8>,
65    /// Hash that was timestamped.
66    pub hashed_message: [u8; 32],
67}
68
69/// Errors during ERS operations.
70#[derive(Debug, thiserror::Error)]
71pub enum ErsError {
72    /// Hash algorithm mismatch.
73    #[error("hash algorithm mismatch: expected {expected:?}, got {actual:?}")]
74    HashMismatch {
75        /// Expected.
76        expected: HashAlgorithm,
77        /// Actual.
78        actual: HashAlgorithm,
79    },
80    /// TSA not trusted.
81    #[error("TSA not trusted: {0}")]
82    UntrustedTsa(String),
83    /// Hash chain broken.
84    #[error("hash chain broken at sequence {0}")]
85    BrokenChain(u32),
86    /// The record uses an algorithm the verifier can't hash with.
87    ///
88    /// The evidence-record data model stores fixed 32-byte digests,
89    /// which fits SHA-256 but not SHA-384 (48 bytes) or SHA-512
90    /// (64 bytes). Supporting those requires widening the hash
91    /// fields to `Vec<u8>` — tracked as a follow-up. Verification
92    /// refuses rather than truncating a stronger digest, which would
93    /// weaken it.
94    #[error("algorithm {0:?} not supported by the verifier's 32-byte digest model")]
95    UnsupportedAlgorithm(HashAlgorithm),
96}
97
98/// A trusted Time Stamping Authority identity.
99#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct Tsa {
101    /// TSA identifier (must match `TimeStamp::tsa_id`).
102    pub id: String,
103}
104
105/// Per-sequence outcome of [`verify_evidence_record`].
106#[derive(Debug, Clone, Serialize, Deserialize)]
107pub struct SequenceCheck {
108    /// Sequence number this check covers.
109    pub sequence_number: u32,
110    /// Whether the digest, TSA, and ordering checks all passed.
111    pub verified: bool,
112    /// First failure, if any.
113    pub error: Option<String>,
114}
115
116/// Outcome of [`verify_evidence_record`].
117#[derive(Debug, Clone, Serialize, Deserialize)]
118pub struct ErsVerificationResult {
119    /// True iff every sequence verified.
120    pub valid: bool,
121    /// Per-sequence detail, in record order.
122    pub sequences: Vec<SequenceCheck>,
123}
124
125/// Hash `data` with `algorithm`, returning the 32-byte digest.
126fn hash_with(algorithm: &HashAlgorithm, data: &[u8]) -> Result<[u8; 32], ErsError> {
127    match algorithm {
128        HashAlgorithm::Sha256 => {
129            let d: [u8; 32] = Sha256::digest(data).into();
130            Ok(d)
131        }
132        other => Err(ErsError::UnsupportedAlgorithm(other.clone())),
133    }
134}
135
136/// Verify an Evidence Record end-to-end against the artifact it
137/// protects and the set of trusted TSAs.
138///
139/// Checks, per RFC 4998 as realized by this data model:
140///
141/// 1. `digest_algorithms.len()` matches the sequence count.
142/// 2. Every sequence's `hashed_message` equals the digest of
143///    `artifact` under that sequence's declared algorithm (each
144///    renewal re-hashes the same artifact under a stronger hash).
145/// 3. Every `time_stamp.tsa_id` is in `trusted_tsas`.
146/// 4. `applied_at` timestamps are non-decreasing across sequences.
147///
148/// Sequences using algorithms whose digests don't fit the model's
149/// 32-byte fields (SHA-384/512, SHA3-256) are reported as
150/// unsupported — see [`ErsError::UnsupportedAlgorithm`] — rather
151/// than being silently skipped or truncated.
152pub fn verify_evidence_record(
153    record: &EvidenceRecord,
154    artifact: &[u8],
155    trusted_tsas: &[Tsa],
156) -> Result<ErsVerificationResult, ErsError> {
157    let mut sequences = Vec::with_capacity(record.archive_time_stamp_sequences.len());
158    let mut all_valid = record.digest_algorithms.len() == record.archive_time_stamp_sequences.len();
159    let mut prev_applied_at: Option<DateTime<Utc>> = None;
160
161    for (i, seq) in record.archive_time_stamp_sequences.iter().enumerate() {
162        let algorithm = record.digest_algorithms.get(i);
163        let mut err = match algorithm {
164            None => Some("no digest algorithm declared for this sequence".to_string()),
165            Some(alg) => match hash_with(alg, artifact) {
166                Ok(digest) if seq.time_stamp.hashed_message == digest => None,
167                Ok(_) => Some(format!("artifact digest mismatch under {alg:?}")),
168                Err(e) => Some(e.to_string()),
169            },
170        };
171
172        if err.is_none() && !trusted_tsas.iter().any(|t| t.id == seq.time_stamp.tsa_id) {
173            err = Some(format!("untrusted TSA: {}", seq.time_stamp.tsa_id));
174        }
175
176        if err.is_none() {
177            if let Some(prev) = prev_applied_at {
178                if seq.applied_at < prev {
179                    err = Some("timestamp went backwards".to_string());
180                }
181            }
182        }
183
184        prev_applied_at = Some(seq.applied_at);
185        let verified = err.is_none();
186        if !verified {
187            all_valid = false;
188        }
189        sequences.push(SequenceCheck {
190            sequence_number: seq.sequence_number,
191            verified,
192            error: err,
193        });
194    }
195
196    Ok(ErsVerificationResult {
197        valid: all_valid,
198        sequences,
199    })
200}
201
202/// Build an initial Evidence Record for an artifact.
203pub fn build_initial_evidence_record(
204    artifact_hash: [u8; 32],
205    algorithm: HashAlgorithm,
206    tsa_id: impl Into<String>,
207    timestamp_token: Vec<u8>,
208) -> EvidenceRecord {
209    let ts = TimeStamp {
210        tsa_id: tsa_id.into(),
211        token: timestamp_token,
212        hashed_message: artifact_hash,
213    };
214    let seq = ArchiveTimeStampSequence {
215        sequence_number: 0,
216        reduced_hash_tree: vec![artifact_hash],
217        time_stamp: ts,
218        applied_at: Utc::now(),
219    };
220    EvidenceRecord {
221        version: 1,
222        digest_algorithms: vec![algorithm],
223        archive_time_stamp_sequences: vec![seq],
224    }
225}
226
227/// Renew an existing Evidence Record by adding a new timestamp sequence
228/// with a stronger hash algorithm.
229pub fn renew_evidence_record(
230    existing: &mut EvidenceRecord,
231    new_algorithm: HashAlgorithm,
232    new_artifact_hash: [u8; 32],
233    tsa_id: impl Into<String>,
234    timestamp_token: Vec<u8>,
235) {
236    let next_seq = existing.archive_time_stamp_sequences.len() as u32;
237    let ts = TimeStamp {
238        tsa_id: tsa_id.into(),
239        token: timestamp_token,
240        hashed_message: new_artifact_hash,
241    };
242    let seq = ArchiveTimeStampSequence {
243        sequence_number: next_seq,
244        reduced_hash_tree: vec![new_artifact_hash],
245        time_stamp: ts,
246        applied_at: Utc::now(),
247    };
248    existing.digest_algorithms.push(new_algorithm);
249    existing.archive_time_stamp_sequences.push(seq);
250}
251
252/// Count renewal rounds applied so far.
253pub fn renewal_count(record: &EvidenceRecord) -> u32 {
254    record.archive_time_stamp_sequences.len() as u32
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260
261    #[test]
262    fn build_initial_record() {
263        let r = build_initial_evidence_record(
264            [1u8; 32],
265            HashAlgorithm::Sha256,
266            "tsa.example.com",
267            vec![0u8; 100],
268        );
269        assert_eq!(renewal_count(&r), 1);
270        assert_eq!(r.digest_algorithms, vec![HashAlgorithm::Sha256]);
271    }
272
273    #[test]
274    fn renew_adds_sequence() {
275        let mut r = build_initial_evidence_record([1u8; 32], HashAlgorithm::Sha256, "tsa", vec![]);
276        renew_evidence_record(&mut r, HashAlgorithm::Sha384, [2u8; 32], "tsa2", vec![]);
277        assert_eq!(renewal_count(&r), 2);
278        assert_eq!(r.digest_algorithms.len(), 2);
279    }
280
281    fn sha256(data: &[u8]) -> [u8; 32] {
282        use sha2::Digest;
283        Sha256::digest(data).into()
284    }
285
286    fn trusted(id: &str) -> Vec<Tsa> {
287        vec![Tsa { id: id.to_string() }]
288    }
289
290    #[test]
291    fn verify_accepts_valid_initial_record() {
292        let artifact = b"calibration report 2026";
293        let digest = sha256(artifact);
294        let r = build_initial_evidence_record(digest, HashAlgorithm::Sha256, "tsa", vec![0u8; 100]);
295        let res = verify_evidence_record(&r, artifact, &trusted("tsa")).unwrap();
296        assert!(res.valid);
297        assert_eq!(res.sequences.len(), 1);
298        assert!(res.sequences[0].verified);
299    }
300
301    #[test]
302    fn verify_rejects_wrong_artifact() {
303        let digest = sha256(b"real artifact");
304        let r = build_initial_evidence_record(digest, HashAlgorithm::Sha256, "tsa", vec![]);
305        let res = verify_evidence_record(&r, b"tampered artifact", &trusted("tsa")).unwrap();
306        assert!(!res.valid);
307        assert!(
308            res.sequences[0]
309                .error
310                .as_deref()
311                .unwrap()
312                .contains("digest mismatch")
313        );
314    }
315
316    #[test]
317    fn verify_rejects_untrusted_tsa() {
318        let artifact = b"artifact";
319        let r = build_initial_evidence_record(
320            sha256(artifact),
321            HashAlgorithm::Sha256,
322            "rogue-tsa",
323            vec![],
324        );
325        let res = verify_evidence_record(&r, artifact, &trusted("good-tsa")).unwrap();
326        assert!(!res.valid);
327        assert!(
328            res.sequences[0]
329                .error
330                .as_deref()
331                .unwrap()
332                .contains("untrusted TSA")
333        );
334    }
335
336    #[test]
337    fn verify_rejects_backwards_timestamps() {
338        let artifact = b"artifact";
339        let mut r =
340            build_initial_evidence_record(sha256(artifact), HashAlgorithm::Sha256, "tsa", vec![]);
341        // Second sequence with an earlier applied_at.
342        let earlier = Utc::now() - chrono::Duration::days(365);
343        r.archive_time_stamp_sequences
344            .push(ArchiveTimeStampSequence {
345                sequence_number: 1,
346                reduced_hash_tree: vec![sha256(artifact)],
347                time_stamp: TimeStamp {
348                    tsa_id: "tsa".into(),
349                    token: vec![],
350                    hashed_message: sha256(artifact),
351                },
352                applied_at: earlier,
353            });
354        r.digest_algorithms.push(HashAlgorithm::Sha256);
355        let res = verify_evidence_record(&r, artifact, &trusted("tsa")).unwrap();
356        assert!(!res.valid);
357        assert!(
358            res.sequences[1]
359                .error
360                .as_deref()
361                .unwrap()
362                .contains("backwards")
363        );
364    }
365
366    #[test]
367    fn verify_rejects_algorithm_count_mismatch() {
368        let artifact = b"artifact";
369        let mut r =
370            build_initial_evidence_record(sha256(artifact), HashAlgorithm::Sha256, "tsa", vec![]);
371        r.digest_algorithms.clear();
372        let res = verify_evidence_record(&r, artifact, &trusted("tsa")).unwrap();
373        assert!(!res.valid);
374        assert!(
375            res.sequences[0]
376                .error
377                .as_deref()
378                .unwrap()
379                .contains("no digest algorithm")
380        );
381    }
382
383    #[test]
384    fn verify_reports_unsupported_algorithm_honestly() {
385        let artifact = b"artifact";
386        let mut r =
387            build_initial_evidence_record(sha256(artifact), HashAlgorithm::Sha256, "tsa", vec![]);
388        renew_evidence_record(&mut r, HashAlgorithm::Sha384, [9u8; 32], "tsa", vec![]);
389        let res = verify_evidence_record(&r, artifact, &trusted("tsa")).unwrap();
390        assert!(!res.valid);
391        // First sequence fine, second reports the model limitation.
392        assert!(res.sequences[0].verified);
393        assert!(
394            res.sequences[1]
395                .error
396                .as_deref()
397                .unwrap()
398                .contains("not supported")
399        );
400    }
401}