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}