Skip to main content

rite_model/
transcript.rs

1//! Transcript data types for ceremony audit trails.
2//!
3//! These types model the structure of ceremony execution records. A transcript
4//! is a hash-chained sequence of [`ChainedEvent`] values that captures what
5//! happened during a ceremony run, who participated, and what was produced.
6//!
7//! The runtime-side functions (`read_transcript`, `verify_transcript`,
8//! `compute_fingerprint`) live in `rite-runtime` — their signatures may still
9//! evolve before the first stable release. The types here are the stable,
10//! semver-committed portion of the transcript API.
11
12use chrono::{DateTime, Utc};
13use serde::{Deserialize, Serialize};
14use std::collections::{BTreeMap, HashMap};
15
16use crate::ActionType;
17
18/// Schema version for the JSONL transcript format.
19///
20/// Increment when the format changes in a way that breaks existing parsers or
21/// verification tools.
22pub const TRANSCRIPT_SCHEMA_VERSION: &str = "0.1";
23
24/// Genesis hash for the first event in the hash chain.
25///
26/// Uses all-zeros — a valid SHA-256 representation that cannot collide with any
27/// real event hash. The first event has `prev: GENESIS_HASH`.
28pub const GENESIS_HASH: &str =
29    "sha256:0000000000000000000000000000000000000000000000000000000000000000";
30
31/// Information about the ceremony definition.
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct CeremonyInfo {
34    /// SHA-256 fingerprint of the ceremony YAML file.
35    pub fingerprint: String,
36    /// Ceremony name.
37    pub name: String,
38}
39
40/// Information about resolved ceremony inputs (parameters, materials).
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct InstanceInfo {
43    /// SHA-256 fingerprint of all resolved runtime inputs (parameters + material fingerprints).
44    pub fingerprint: String,
45    /// Resolved parameters (names, dates, labels).
46    ///
47    /// Uses `BTreeMap` for deterministic serialization order in JSONL transcripts.
48    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
49    pub parameters: BTreeMap<String, serde_json::Value>,
50    /// SHA-256 fingerprints of digital materials, keyed by material ID.
51    ///
52    /// Physical materials have no digital fingerprint and are omitted.
53    /// Uses `BTreeMap` for deterministic serialization order in JSONL transcripts.
54    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
55    pub materials: BTreeMap<String, String>,
56}
57
58/// A participant assigned to a role for this ceremony execution.
59///
60/// Recorded in `CeremonyStart` so the transcript is self-contained: a verifier
61/// can identify every participant without consulting external sources.
62#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct ParticipantRecord {
64    /// Role identifier (e.g. `crypto_officer`, `witness__1`).
65    pub role_id: String,
66    /// Human-readable role name (e.g. "Crypto Officer").
67    pub role_name: String,
68    /// Named individual assigned to this role, if specified.
69    #[serde(skip_serializing_if = "Option::is_none")]
70    pub person: Option<String>,
71}
72
73/// A single component in the release manifest (filesystem or binary).
74#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct ImageComponent {
76    /// SHA-256 hash of this component.
77    pub hash: String,
78}
79
80/// Release manifest, inlined from the USB image at ceremony runtime startup.
81///
82/// Build-time values with stronger provenance than self-reported hashes:
83/// computed outside the running binary and embedded in the ISO at build time.
84#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct ImageManifest {
86    /// Image version string.
87    pub version: String,
88    /// Filesystem (squashfs) component measurements.
89    pub filesystem: ImageComponent,
90    /// Binary component measurements.
91    pub binary: ImageComponent,
92    /// PCR measurements from the TPM (if available).
93    // External data from the ISO; order irrelevant — keep HashMap
94    #[serde(skip_serializing_if = "Option::is_none")]
95    pub pcr: Option<HashMap<String, String>>,
96}
97
98/// A single component measured by the initrd hook.
99#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct InitrdComponent {
101    /// SHA-256 hash of this component (if measured).
102    pub hash: Option<String>,
103    /// Whether the hash was verified against the manifest.
104    #[serde(skip_serializing_if = "Option::is_none")]
105    pub verified: Option<bool>,
106}
107
108/// Measurements written by the initrd hook before squashfs was mounted.
109///
110/// Read from `/run/rite/initrd-measurements.json` at runtime startup.
111#[derive(Debug, Clone, Serialize, Deserialize)]
112pub struct InitrdMeasurements {
113    /// Filesystem image measurements.
114    pub filesystem: InitrdComponent,
115    /// Ceremony file measurements.
116    pub ceremony: InitrdComponent,
117}
118
119/// Information about the rite binary.
120#[derive(Debug, Clone, Serialize, Deserialize)]
121pub struct BinaryInfo {
122    /// SHA-256 fingerprint of the binary (if available).
123    #[serde(skip_serializing_if = "Option::is_none")]
124    pub fingerprint: Option<String>,
125    /// Binary version.
126    pub version: String,
127}
128
129/// Transcript completion status.
130#[non_exhaustive]
131#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
132#[serde(rename_all = "snake_case")]
133pub enum TranscriptStatus {
134    /// Ceremony completed successfully.
135    Completed,
136    /// Ceremony was interrupted (crash, abort, error).
137    Interrupted,
138    /// Ceremony is still in progress.
139    InProgress,
140}
141
142/// Outcome of a step execution as recorded in the transcript.
143#[derive(Debug, Clone, Serialize, Deserialize)]
144pub struct EventOutcome {
145    /// Status: `completed` or `skipped`.
146    pub status: String,
147    /// Human-readable message or skip reason.
148    #[serde(skip_serializing_if = "Option::is_none")]
149    pub message: Option<String>,
150}
151
152/// Evidence produced by a step execution.
153///
154/// Uses `BTreeMap` for deterministic serialization order in JSONL transcripts.
155#[derive(Debug, Clone, Default, Serialize, Deserialize)]
156pub struct StepEvidence {
157    /// Generic key-value evidence data.
158    #[serde(flatten)]
159    pub data: BTreeMap<String, serde_json::Value>,
160}
161
162impl StepEvidence {
163    /// Create a new empty evidence set.
164    pub fn new() -> Self {
165        Self {
166            data: BTreeMap::new(),
167        }
168    }
169
170    /// Insert a key-value pair into the evidence.
171    pub fn insert(&mut self, key: impl Into<String>, value: impl Into<serde_json::Value>) {
172        self.data.insert(key.into(), value.into());
173    }
174
175    /// Insert a key-value pair and return self (builder pattern).
176    #[must_use]
177    pub fn with(mut self, key: impl Into<String>, value: impl Into<serde_json::Value>) -> Self {
178        self.insert(key, value);
179        self
180    }
181}
182
183/// Event data types for JSONL transcript entries.
184#[non_exhaustive]
185#[derive(Debug, Clone, Serialize, Deserialize)]
186#[serde(tag = "type", rename_all = "snake_case")]
187#[allow(clippy::large_enum_variant)]
188pub enum EventData {
189    /// Ceremony started.
190    CeremonyStart {
191        /// Transcript format version.
192        schema_version: String,
193        /// Whether this is a dry run.
194        #[serde(default, skip_serializing_if = "std::ops::Not::not")]
195        dry_run: bool,
196        /// Ceremony identity and fingerprint.
197        ceremony: CeremonyInfo,
198        /// Instance-specific parameters (absent if ceremony has no parameters).
199        #[serde(skip_serializing_if = "Option::is_none")]
200        instance: Option<InstanceInfo>,
201        /// Binary identity and version.
202        binary: BinaryInfo,
203        /// Live USB image manifest (absent when not running from a live image).
204        #[serde(skip_serializing_if = "Option::is_none")]
205        image: Option<ImageManifest>,
206        /// Initrd measurements (absent when not running from a live image).
207        #[serde(skip_serializing_if = "Option::is_none")]
208        initrd: Option<InitrdMeasurements>,
209        /// OS environment variables at ceremony start (if captured).
210        // environment order is irrelevant — keep HashMap
211        #[serde(skip_serializing_if = "Option::is_none")]
212        environment: Option<HashMap<String, String>>,
213        /// Participants assigned to roles for this execution.
214        ///
215        /// Makes the transcript self-contained: role names and person assignments
216        /// are recorded here so audit records are standalone.
217        #[serde(default, skip_serializing_if = "Vec::is_empty")]
218        participants: Vec<ParticipantRecord>,
219        /// Timestamp when the ceremony started.
220        started_at: DateTime<Utc>,
221    },
222    /// Step execution.
223    Step {
224        /// Step identifier from the ceremony definition.
225        step_id: String,
226        /// Action type performed.
227        action: ActionType,
228        /// Role that performed this step (if role-bound).
229        #[serde(skip_serializing_if = "Option::is_none")]
230        role: Option<String>,
231        /// When the step started.
232        started_at: DateTime<Utc>,
233        /// When the step completed.
234        completed_at: DateTime<Utc>,
235        /// Whether the step completed or was skipped.
236        outcome: EventOutcome,
237        /// Action-specific evidence collected during execution.
238        evidence: StepEvidence,
239    },
240    /// Evidence file added (photo, document, etc.).
241    EvidenceAdd {
242        /// Path to the evidence file.
243        path: String,
244        /// SHA-256 fingerprint of the file.
245        hash: String,
246        /// File size in bytes.
247        size: u64,
248        /// MIME type (if known).
249        #[serde(skip_serializing_if = "Option::is_none")]
250        mime: Option<String>,
251    },
252    /// Artifact produced by ceremony.
253    ArtifactProduce {
254        /// Artifact ID (from the ceremony definition).
255        source: String,
256        /// Path where the artifact was written.
257        path: String,
258        /// SHA-256 fingerprint of the written file.
259        hash: String,
260        /// File size in bytes.
261        size: u64,
262        /// MIME type (if known).
263        #[serde(skip_serializing_if = "Option::is_none")]
264        mime: Option<String>,
265    },
266    /// Deviation from expected procedure.
267    Deviation {
268        /// Human-readable reason for the deviation.
269        reason: String,
270        /// Step being deviated from (if applicable).
271        #[serde(skip_serializing_if = "Option::is_none")]
272        step_id: Option<String>,
273        /// When the deviation was recorded.
274        recorded_at: DateTime<Utc>,
275    },
276    /// Ceremony completed.
277    CeremonyComplete {
278        /// When the ceremony finished.
279        completed_at: DateTime<Utc>,
280        /// Final completion status.
281        status: TranscriptStatus,
282    },
283}
284
285/// A hash-chained event in the JSONL transcript.
286///
287/// The `hash` field is `H(H(prev) || H(data))` using SHA-256, encoded as
288/// `"sha256:{lowercase_hex}"`. Use `read_transcript` in `rite-runtime` to
289/// parse and verify a transcript; individual hashes are verified there.
290#[derive(Debug, Clone, Serialize, Deserialize)]
291pub struct ChainedEvent {
292    /// Hash of the previous event (`GENESIS_HASH` for the first event).
293    pub prev: String,
294    /// Event data.
295    pub data: EventData,
296    /// SHA-256 hash of this event computed as H(H(prev) || H(data)).
297    pub hash: String,
298}
299
300/// Result of verifying a single artifact file.
301#[derive(Debug, Clone)]
302pub struct ArtifactVerification {
303    /// Artifact ID (from the ceremony definition).
304    pub source: String,
305    /// Path where the artifact was written.
306    pub path: String,
307    /// Expected SHA-256 fingerprint (from transcript).
308    pub expected_hash: String,
309    /// Whether the computed hash matched the expected hash.
310    pub verified: bool,
311    /// Error description if verification failed.
312    pub error: Option<String>,
313}
314
315/// Result of transcript verification.
316#[non_exhaustive]
317#[derive(Debug)]
318#[allow(clippy::large_enum_variant)]
319pub enum VerificationResult {
320    /// Transcript is valid and fingerprint matches.
321    Valid {
322        /// Final SHA-256 fingerprint of the transcript chain.
323        fingerprint: String,
324        /// Completion status recorded in the final event.
325        status: TranscriptStatus,
326        /// Whether this was a dry run (no actual operations performed).
327        dry_run: bool,
328        /// Artifact file verification results.
329        artifacts: Vec<ArtifactVerification>,
330        /// Build-time image manifest (from `CeremonyStart`).
331        image: Option<ImageManifest>,
332        /// Boot-time initrd measurements (from `CeremonyStart`).
333        initrd: Option<InitrdMeasurements>,
334        /// Nonce from the first `tpm_attest` step evidence, if any.
335        tpm_nonce: Option<String>,
336        /// PCR values from the first `tpm_attest` step evidence, if any.
337        ///
338        /// Uses `BTreeMap` for deterministic display order.
339        tpm_pcrs: BTreeMap<String, String>,
340    },
341    /// Transcript has been tampered with.
342    Invalid {
343        /// Expected hash (from the `hash` field of the final event).
344        expected: String,
345        /// Computed hash (recomputed from transcript content).
346        computed: String,
347    },
348    /// Transcript is incomplete (no final fingerprint).
349    Incomplete {
350        /// Last recorded status.
351        status: TranscriptStatus,
352        /// Number of events parsed before the transcript ends.
353        events_count: usize,
354    },
355}
356
357impl std::fmt::Display for VerificationResult {
358    #[allow(clippy::too_many_lines)]
359    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
360        match self {
361            VerificationResult::Valid {
362                fingerprint,
363                status,
364                dry_run,
365                artifacts,
366                image,
367                initrd,
368                tpm_nonce,
369                tpm_pcrs,
370            } => {
371                if *dry_run {
372                    writeln!(
373                        f,
374                        "WARNING: This is a DRY RUN transcript - no actual cryptographic operations were performed"
375                    )?;
376                }
377                writeln!(f, "Transcript integrity verified.")?;
378                writeln!(f, "  Status:      {status:?}")?;
379                writeln!(f, "  Fingerprint: {fingerprint}")?;
380
381                if let Some(img) = image {
382                    writeln!(f, "\nImage (build-time):")?;
383                    writeln!(f, "  Version:    {}", img.version)?;
384                    writeln!(f, "  Filesystem: {}", img.filesystem.hash)?;
385                    writeln!(f, "  Binary:     {}", img.binary.hash)?;
386                }
387
388                if let Some(imd) = initrd {
389                    writeln!(f, "\nInitrd measurements (boot-time, pre-mount):")?;
390                    let fs_hash = imd.filesystem.hash.as_deref().unwrap_or("(none)");
391                    let fs_verified = match imd.filesystem.verified {
392                        Some(true) => " [verified]",
393                        Some(false) => " [not verified]",
394                        None => "",
395                    };
396                    writeln!(f, "  Filesystem: {fs_hash}{fs_verified}")?;
397                    let cer_hash = imd.ceremony.hash.as_deref().unwrap_or("(none)");
398                    writeln!(f, "  Ceremony:   {cer_hash}")?;
399                }
400
401                if let (Some(img), Some(imd)) = (image, initrd) {
402                    writeln!(f, "\nCross-checks:")?;
403                    if let Some(ref fs_hash) = imd.filesystem.hash {
404                        let matches = fs_hash == &img.filesystem.hash;
405                        writeln!(
406                            f,
407                            "  initrd.filesystem == image.filesystem: {}",
408                            if matches { "match" } else { "MISMATCH" }
409                        )?;
410                    }
411                    match imd.filesystem.verified {
412                        Some(true) => writeln!(f, "  initrd verification gate:            passed")?,
413                        Some(false) => {
414                            writeln!(f, "  initrd verification gate:            FAILED")?;
415                        }
416                        None => {}
417                    }
418                }
419
420                if !tpm_pcrs.is_empty() || tpm_nonce.is_some() {
421                    writeln!(f, "\nTPM attestation:")?;
422                    if let Some(nonce) = tpm_nonce {
423                        writeln!(f, "  Nonce: {nonce}")?;
424                    }
425                    for (pcr, val) in tpm_pcrs {
426                        writeln!(f, "  PCR {pcr}: {val}")?;
427                    }
428                } else {
429                    writeln!(
430                        f,
431                        "\nNo hardware attestation recorded (no tpm_attest step in transcript)."
432                    )?;
433                }
434
435                if !artifacts.is_empty() {
436                    let verified_count = artifacts.iter().filter(|a| a.verified).count();
437                    let total = artifacts.len();
438                    writeln!(
439                        f,
440                        "\nArtifact verification: {verified_count}/{total} files verified"
441                    )?;
442                    for artifact in artifacts {
443                        if artifact.verified {
444                            writeln!(f, "  \u{2713} {} ({})", artifact.path, artifact.source)?;
445                        } else if let Some(error) = &artifact.error {
446                            writeln!(
447                                f,
448                                "  \u{2717} {} ({}): {}",
449                                artifact.path, artifact.source, error
450                            )?;
451                        } else {
452                            writeln!(
453                                f,
454                                "  \u{2717} {} ({}): Hash mismatch",
455                                artifact.path, artifact.source
456                            )?;
457                        }
458                    }
459                }
460                Ok(())
461            }
462            VerificationResult::Invalid { expected, computed } => write!(
463                f,
464                "TAMPERED: Fingerprint mismatch!\n  Expected: {expected}\n  Computed: {computed}"
465            ),
466            VerificationResult::Incomplete {
467                status,
468                events_count,
469            } => write!(
470                f,
471                "Incomplete transcript (no final fingerprint). Status: {status:?}. Events recorded: {events_count}"
472            ),
473        }
474    }
475}
476
477/// A fully parsed and verified transcript.
478#[derive(Debug)]
479pub struct ParsedTranscript {
480    /// All chained events in order.
481    pub events: Vec<ChainedEvent>,
482    /// Final chain fingerprint (hash of the last event).
483    pub fingerprint: String,
484    /// Whether this was a dry run.
485    pub dry_run: bool,
486    /// Completion status.
487    pub status: TranscriptStatus,
488}