use std::collections::HashSet;
use std::fs::{File, OpenOptions};
use std::io::{self, BufRead, BufReader, BufWriter, Write};
use std::path::{Path, PathBuf};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::transcript::compute_fingerprint;
use rite_model::StepFact;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TranscriptFingerprint(String);
impl TranscriptFingerprint {
#[must_use]
pub fn from_string(s: String) -> Self {
Self(s)
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for TranscriptFingerprint {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
pub const GENESIS_HASH: &str =
"sha256:0000000000000000000000000000000000000000000000000000000000000000";
pub trait TranscriptSink: Send {
fn record(&mut self, at: DateTime<Utc>, fact: &StepFact) -> io::Result<()>;
fn finalize(&mut self) -> io::Result<TranscriptFingerprint>;
}
#[derive(Debug)]
pub struct JsonlFileSink {
jsonl_path: PathBuf,
writer: BufWriter<File>,
current_hash: String,
finalized: Option<TranscriptFingerprint>,
}
impl JsonlFileSink {
pub fn create(dir: &Path) -> io::Result<Self> {
let jsonl_path = dir.join("transcript.jsonl");
let file = OpenOptions::new()
.create_new(true)
.write(true)
.open(&jsonl_path)?;
Ok(Self {
jsonl_path,
writer: BufWriter::new(file),
current_hash: GENESIS_HASH.to_string(),
finalized: None,
})
}
#[must_use]
pub fn jsonl_path(&self) -> &Path {
&self.jsonl_path
}
}
impl TranscriptSink for JsonlFileSink {
fn record(&mut self, at: DateTime<Utc>, fact: &StepFact) -> io::Result<()> {
if self.finalized.is_some() {
return Err(io::Error::other("transcript already finalized"));
}
let line = ChainedFact {
prev_hash: &self.current_hash,
at,
fact,
};
let bytes = serde_json::to_vec(&line).map_err(io::Error::other)?;
self.current_hash = compute_fingerprint(&bytes);
self.writer.write_all(&bytes)?;
self.writer.write_all(b"\n")?;
self.writer.flush()?;
self.writer.get_ref().sync_data()?;
Ok(())
}
fn finalize(&mut self) -> io::Result<TranscriptFingerprint> {
if let Some(existing) = &self.finalized {
return Ok(existing.clone());
}
let fingerprint = TranscriptFingerprint(self.current_hash.clone());
self.finalized = Some(fingerprint.clone());
Ok(fingerprint)
}
}
#[derive(Debug, Default)]
pub struct InMemorySink {
facts: Vec<StepFact>,
current_hash: String,
finalized: Option<TranscriptFingerprint>,
}
impl InMemorySink {
#[must_use]
pub fn new() -> Self {
Self {
facts: Vec::new(),
current_hash: GENESIS_HASH.to_string(),
finalized: None,
}
}
#[must_use]
pub fn facts(&self) -> &[StepFact] {
&self.facts
}
#[must_use]
pub fn len(&self) -> usize {
self.facts.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.facts.is_empty()
}
}
impl TranscriptSink for InMemorySink {
fn record(&mut self, at: DateTime<Utc>, fact: &StepFact) -> io::Result<()> {
if self.finalized.is_some() {
return Err(io::Error::other("transcript already finalized"));
}
let line = ChainedFact {
prev_hash: &self.current_hash,
at,
fact,
};
let bytes = serde_json::to_vec(&line).map_err(io::Error::other)?;
self.current_hash = compute_fingerprint(&bytes);
self.facts.push(fact.clone());
Ok(())
}
fn finalize(&mut self) -> io::Result<TranscriptFingerprint> {
if let Some(existing) = &self.finalized {
return Ok(existing.clone());
}
let fingerprint = TranscriptFingerprint(self.current_hash.clone());
self.finalized = Some(fingerprint.clone());
Ok(fingerprint)
}
}
#[derive(Serialize)]
struct ChainedFact<'a> {
prev_hash: &'a str,
at: DateTime<Utc>,
fact: &'a StepFact,
}
#[derive(Deserialize)]
struct OwnedChainedFact {
prev_hash: String,
at: DateTime<Utc>,
fact: StepFact,
}
#[derive(Debug, Clone)]
pub struct TimedFact {
pub at: DateTime<Utc>,
pub fact: StepFact,
}
#[derive(Debug, Clone)]
pub struct TranscriptVerified {
pub fact_count: usize,
pub fingerprint: TranscriptFingerprint,
pub terminated: bool,
}
#[derive(Debug, Error)]
pub enum VerifyError {
#[error("io error: {0}")]
Io(#[from] io::Error),
#[error("line {line} is not a valid chained fact: {reason}")]
InvalidLine {
line: usize,
reason: String,
},
#[error("broken chain at line {line}: expected prev_hash {expected}, got {actual}")]
BrokenChain {
line: usize,
expected: String,
actual: String,
},
#[error("transcript is empty")]
Empty,
#[error("entropy source used before it was seeded")]
SeedMissing,
#[error("entropy source seeded more than once")]
DuplicateSeed,
#[error("unknown entropy derivation scheme: {0}")]
UnknownDerivation(String),
#[error("malformed entropy value: {0}")]
MalformedEntropy(String),
#[error("entropy draw at path '{path}' does not match re-derived value")]
DrawMismatch {
path: String,
},
#[error(
"entropy draw at path '{path}' records {len} bytes, beyond the {max}-byte HKDF-Expand limit"
)]
DrawTooLong {
path: String,
len: usize,
max: usize,
},
#[error("entropy draw path '{path}' does not match expected '{expected_prefix}<purpose>'")]
PathMismatch {
path: String,
expected_prefix: String,
},
#[error("entropy contribution at step '{step}' records epoch {recorded}, expected {expected}")]
EpochMismatch {
step: String,
recorded: u32,
expected: u32,
},
#[error("entropy draw path '{path}' recorded more than once")]
DuplicateDrawPath {
path: String,
},
}
pub fn verify_transcript(jsonl_path: &Path) -> Result<TranscriptVerified, VerifyError> {
let loaded = read_verified_transcript(jsonl_path)?;
Ok(TranscriptVerified {
fact_count: loaded.facts.len(),
fingerprint: loaded.fingerprint,
terminated: loaded.terminated,
})
}
#[derive(Debug, Clone)]
pub struct LoadedTranscript {
pub facts: Vec<TimedFact>,
pub fingerprint: TranscriptFingerprint,
pub terminated: bool,
}
pub fn read_verified_transcript(jsonl_path: &Path) -> Result<LoadedTranscript, VerifyError> {
let file = File::open(jsonl_path)?;
let reader = BufReader::new(file);
let mut expected_prev = GENESIS_HASH.to_string();
let mut last_hash = expected_prev.clone();
let mut facts: Vec<TimedFact> = Vec::new();
for (idx, line) in reader.lines().enumerate() {
let line = line?;
let line_no = idx.saturating_add(1);
let parsed: OwnedChainedFact =
serde_json::from_str(&line).map_err(|e| VerifyError::InvalidLine {
line: line_no,
reason: e.to_string(),
})?;
if parsed.prev_hash != expected_prev {
return Err(VerifyError::BrokenChain {
line: line_no,
expected: expected_prev,
actual: parsed.prev_hash,
});
}
last_hash = compute_fingerprint(line.as_bytes());
expected_prev.clone_from(&last_hash);
facts.push(TimedFact {
at: parsed.at,
fact: parsed.fact,
});
}
if facts.is_empty() {
return Err(VerifyError::Empty);
}
let terminated = matches!(
facts.last().map(|t| &t.fact),
Some(StepFact::CeremonyCompleted { .. } | StepFact::CeremonyFailed { .. }),
);
Ok(LoadedTranscript {
facts,
fingerprint: TranscriptFingerprint(last_hash),
terminated,
})
}
#[derive(Debug, Clone, Default)]
pub struct EntropyVerified {
pub derivation: Option<String>,
pub source: Option<String>,
pub contributions: usize,
pub values_verified: usize,
}
pub fn verify_entropy<'a>(
facts: impl IntoIterator<Item = &'a StepFact>,
) -> Result<EntropyVerified, VerifyError> {
let mut seed: Option<[u8; 32]> = None;
let mut epoch: u32 = 0;
let mut drawn_paths: HashSet<String> = HashSet::new();
let mut result = EntropyVerified::default();
for fact in facts {
match fact {
StepFact::EntropySeeded {
m,
source,
derivation,
..
} => {
if seed.is_some() {
return Err(VerifyError::DuplicateSeed);
}
if derivation != crate::entropy::DERIVATION_V1 {
return Err(VerifyError::UnknownDerivation(derivation.clone()));
}
let m_bytes = base16ct::lower::decode_vec(m)
.map_err(|e| VerifyError::MalformedEntropy(format!("seed m: {e}")))?;
seed = Some(crate::entropy::initial_seed(&m_bytes));
result.derivation = Some(derivation.clone());
result.source = Some(source.clone());
}
StepFact::EntropyContributed {
step,
epoch: recorded_epoch,
contribution,
..
} => {
let current = seed.as_ref().ok_or(VerifyError::SeedMissing)?;
let expected = epoch.saturating_add(1);
if *recorded_epoch != expected {
return Err(VerifyError::EpochMismatch {
step: step.as_str().to_string(),
recorded: *recorded_epoch,
expected,
});
}
seed = Some(crate::entropy::fold_seed(current, contribution.as_bytes()));
epoch = expected;
result.contributions = result.contributions.saturating_add(1);
}
StepFact::EntropyDrawn {
step, path, value, ..
} => {
let current = seed.as_ref().ok_or(VerifyError::SeedMissing)?;
let expected_prefix = crate::entropy::build_path(epoch, step, "");
if !path.starts_with(&expected_prefix) || path.len() == expected_prefix.len() {
return Err(VerifyError::PathMismatch {
path: path.clone(),
expected_prefix,
});
}
if !drawn_paths.insert(path.clone()) {
return Err(VerifyError::DuplicateDrawPath { path: path.clone() });
}
let recorded = base16ct::lower::decode_vec(value)
.map_err(|e| VerifyError::MalformedEntropy(format!("draw {path}: {e}")))?;
if recorded.len() > crate::entropy::MAX_DRAW_LEN {
return Err(VerifyError::DrawTooLong {
path: path.clone(),
len: recorded.len(),
max: crate::entropy::MAX_DRAW_LEN,
});
}
let expected = crate::entropy::derive_value(current, path, recorded.len());
if expected != recorded {
return Err(VerifyError::DrawMismatch { path: path.clone() });
}
result.values_verified = result.values_verified.saturating_add(1);
}
_ => {}
}
}
Ok(result)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_support::fixed_test_time as test_at;
fn sample_fact() -> StepFact {
StepFact::CeremonyStarted {
name: "Test".to_string(),
}
}
#[test]
fn in_memory_sink_records_and_finalizes() {
let mut sink = InMemorySink::new();
assert!(sink.is_empty());
sink.record(test_at(), &sample_fact()).expect("record");
assert_eq!(sink.len(), 1);
let fp = sink.finalize().expect("finalize");
assert!(fp.as_str().starts_with("sha256:"));
}
#[test]
fn in_memory_sink_rejects_record_after_finalize() {
let mut sink = InMemorySink::new();
sink.record(test_at(), &sample_fact()).expect("record");
sink.finalize().expect("finalize");
let err = sink
.record(test_at(), &sample_fact())
.expect_err("should fail");
assert!(err.to_string().contains("already finalized"));
}
#[test]
fn in_memory_sink_chain_advances() {
let mut sink = InMemorySink::new();
let initial = sink.current_hash.clone();
sink.record(test_at(), &sample_fact()).expect("record");
assert_ne!(sink.current_hash, initial);
let after_first = sink.current_hash.clone();
sink.record(test_at(), &sample_fact()).expect("record");
assert_ne!(sink.current_hash, after_first);
}
#[test]
fn record_persists_the_supplied_time_and_reader_recovers_it() {
let tmp = tempfile::tempdir().expect("tempdir");
let mut sink = JsonlFileSink::create(tmp.path()).expect("create");
let at = test_at();
sink.record(at, &sample_fact()).expect("record");
sink.finalize().expect("finalize");
let loaded = read_verified_transcript(sink.jsonl_path()).expect("read");
assert_eq!(loaded.facts.first().expect("one fact").at, at);
}
#[test]
fn jsonl_file_sink_writes_and_chains() {
let tmp = tempfile::tempdir().expect("tempdir");
let mut sink = JsonlFileSink::create(tmp.path()).expect("create");
sink.record(test_at(), &sample_fact()).expect("record");
sink.record(test_at(), &sample_fact()).expect("record");
let fp = sink.finalize().expect("finalize");
let jsonl = std::fs::read_to_string(sink.jsonl_path()).expect("read jsonl");
let mut lines = jsonl.lines();
let raw_first = lines.next().expect("first line");
let raw_second = lines.next().expect("second line");
assert!(lines.next().is_none(), "expected exactly two lines");
let first: serde_json::Value = serde_json::from_str(raw_first).expect("parse first");
let second: serde_json::Value = serde_json::from_str(raw_second).expect("parse second");
assert_eq!(
first.get("prev_hash").and_then(serde_json::Value::as_str),
Some(GENESIS_HASH),
);
let first_hash = compute_fingerprint(raw_first.as_bytes());
assert_eq!(
second.get("prev_hash").and_then(serde_json::Value::as_str),
Some(first_hash.as_str()),
);
let second_hash = compute_fingerprint(raw_second.as_bytes());
assert_eq!(fp.as_str(), second_hash);
}
#[test]
fn jsonl_file_sink_refuses_existing_file() {
let tmp = tempfile::tempdir().expect("tempdir");
let _first = JsonlFileSink::create(tmp.path()).expect("first");
let err = JsonlFileSink::create(tmp.path()).expect_err("second should fail");
assert_eq!(err.kind(), io::ErrorKind::AlreadyExists);
}
#[test]
fn verify_round_trip_succeeds_on_well_formed_transcript() {
let tmp = tempfile::tempdir().expect("tempdir");
let mut sink = JsonlFileSink::create(tmp.path()).expect("create");
sink.record(test_at(), &sample_fact()).expect("record");
sink.record(test_at(), &sample_fact()).expect("record");
sink.record(test_at(), &sample_fact()).expect("record");
let written = sink.finalize().expect("finalize");
let verified = verify_transcript(sink.jsonl_path()).expect("verify");
assert_eq!(verified.fact_count, 3);
assert_eq!(verified.fingerprint, written);
assert!(!verified.terminated);
}
#[test]
fn verify_detects_chain_break() {
let tmp = tempfile::tempdir().expect("tempdir");
let mut sink = JsonlFileSink::create(tmp.path()).expect("create");
sink.record(test_at(), &sample_fact()).expect("record");
sink.record(test_at(), &sample_fact()).expect("record");
let _ = sink.finalize().expect("finalize");
let path = sink.jsonl_path().to_path_buf();
let content = std::fs::read_to_string(&path).expect("read");
let mut lines: Vec<String> = content.lines().map(String::from).collect();
if let Some(second) = lines.get_mut(1) {
*second = second.replace("prev_hash\":\"sha256:", "prev_hash\":\"sha256:ff");
}
std::fs::write(&path, lines.join("\n") + "\n").expect("write");
let err = verify_transcript(&path).expect_err("should detect tamper");
assert!(matches!(err, VerifyError::BrokenChain { .. }));
}
#[test]
fn verify_empty_transcript_returns_empty_error() {
let tmp = tempfile::tempdir().expect("tempdir");
let path = tmp.path().join("transcript.jsonl");
std::fs::write(&path, "").expect("write empty");
let err = verify_transcript(&path).expect_err("empty");
assert!(matches!(err, VerifyError::Empty));
}
use rite_model::StepId;
fn seeded_fact(m: &[u8]) -> StepFact {
StepFact::EntropySeeded {
m: base16ct::lower::encode_string(m),
source: "os".to_string(),
derivation: crate::entropy::DERIVATION_V1.to_string(),
}
}
fn drawn_fact(seed: &[u8; 32], step: &str, path: &str, len: usize) -> StepFact {
StepFact::EntropyDrawn {
step: StepId::new(step),
path: path.to_string(),
value: base16ct::lower::encode_string(&crate::entropy::derive_value(seed, path, len)),
}
}
#[test]
fn verify_entropy_re_derives_a_clean_draw() {
let m = b"machine entropy bytes";
let seed = crate::entropy::initial_seed(m);
let facts = [
seeded_fact(m),
drawn_fact(&seed, "issue", "0/issue/cert-serial", 9),
];
let v = verify_entropy(facts.iter()).expect("verify");
assert_eq!(v.values_verified, 1);
assert_eq!(v.contributions, 0);
assert_eq!(v.derivation.as_deref(), Some("rite-kdf/v1"));
}
#[test]
fn verify_entropy_follows_the_ratchet_through_a_contribution() {
let m = b"machine entropy bytes";
let seed0 = crate::entropy::initial_seed(m);
let seed1 = crate::entropy::fold_seed(&seed0, b"3 1 6 4 2 5");
let facts = [
seeded_fact(m),
StepFact::EntropyContributed {
step: StepId::new("roll"),
epoch: 1,
contribution: "3 1 6 4 2 5".to_string(),
},
drawn_fact(&seed1, "issue", "1/issue/cert-serial", 9),
];
let v = verify_entropy(facts.iter()).expect("verify");
assert_eq!(v.contributions, 1);
assert_eq!(v.values_verified, 1);
}
#[test]
fn verify_entropy_detects_a_tampered_value() {
let m = b"machine entropy bytes";
let seed = crate::entropy::initial_seed(m);
let mut drawn = drawn_fact(&seed, "issue", "0/issue/cert-serial", 9);
if let StepFact::EntropyDrawn { value, .. } = &mut drawn {
*value = "deadbeefdeadbeefdead".to_string();
}
let facts = [seeded_fact(m), drawn];
let err = verify_entropy(facts.iter()).expect_err("tamper");
assert!(matches!(err, VerifyError::DrawMismatch { .. }));
}
#[test]
fn verify_entropy_rejects_unknown_derivation() {
let facts = [StepFact::EntropySeeded {
m: "00".to_string(),
source: "os".to_string(),
derivation: "rite-kdf/v99".to_string(),
}];
let err = verify_entropy(facts.iter()).expect_err("unknown scheme");
assert!(matches!(err, VerifyError::UnknownDerivation(_)));
}
#[test]
fn verify_entropy_rejects_draw_before_seed() {
let seed = crate::entropy::initial_seed(b"x");
let facts = [drawn_fact(&seed, "issue", "0/issue/cert-serial", 9)];
let err = verify_entropy(facts.iter()).expect_err("unseeded");
assert!(matches!(err, VerifyError::SeedMissing));
}
#[test]
fn verify_entropy_surfaces_the_seed_source() {
let m = b"machine entropy bytes";
let v = verify_entropy([seeded_fact(m)].iter()).expect("verify");
assert_eq!(v.source.as_deref(), Some("os"));
}
#[test]
fn verify_entropy_rejects_a_second_seed() {
let facts = [seeded_fact(b"first"), seeded_fact(b"second")];
let err = verify_entropy(facts.iter()).expect_err("re-seed");
assert!(matches!(err, VerifyError::DuplicateSeed));
}
#[test]
fn verify_entropy_rejects_an_oversized_draw_without_panicking() {
let m = b"machine entropy bytes";
let oversized = "ab".repeat(crate::entropy::MAX_DRAW_LEN.saturating_add(1));
let facts = [
seeded_fact(m),
StepFact::EntropyDrawn {
step: StepId::new("issue"),
path: "0/issue/cert-serial".to_string(),
value: oversized,
},
];
let err = verify_entropy(facts.iter()).expect_err("oversized draw");
assert!(matches!(err, VerifyError::DrawTooLong { len, .. } if len == 8161));
}
#[test]
fn verify_entropy_rejects_a_draw_path_with_the_wrong_epoch() {
let m = b"machine entropy bytes";
let seed = crate::entropy::initial_seed(m);
let facts = [
seeded_fact(m),
drawn_fact(&seed, "issue", "1/issue/cert-serial", 9),
];
let err = verify_entropy(facts.iter()).expect_err("wrong epoch");
assert!(matches!(err, VerifyError::PathMismatch { .. }));
}
#[test]
fn verify_entropy_rejects_a_draw_path_with_the_wrong_step() {
let m = b"machine entropy bytes";
let seed = crate::entropy::initial_seed(m);
let facts = [
seeded_fact(m),
drawn_fact(&seed, "issue", "0/other/cert-serial", 9),
];
let err = verify_entropy(facts.iter()).expect_err("wrong step");
assert!(matches!(err, VerifyError::PathMismatch { .. }));
}
#[test]
fn verify_entropy_rejects_an_empty_purpose() {
let m = b"machine entropy bytes";
let seed = crate::entropy::initial_seed(m);
let facts = [seeded_fact(m), drawn_fact(&seed, "issue", "0/issue/", 9)];
let err = verify_entropy(facts.iter()).expect_err("empty purpose");
assert!(matches!(err, VerifyError::PathMismatch { .. }));
}
#[test]
fn verify_entropy_rejects_a_contribution_epoch_out_of_sequence() {
let m = b"machine entropy bytes";
let facts = [
seeded_fact(m),
StepFact::EntropyContributed {
step: StepId::new("roll"),
epoch: 2,
contribution: "3 1 6 4 2 5".to_string(),
},
];
let err = verify_entropy(facts.iter()).expect_err("epoch skip");
assert!(matches!(
err,
VerifyError::EpochMismatch {
recorded: 2,
expected: 1,
..
}
));
}
#[test]
fn verify_entropy_rejects_a_duplicate_draw_path() {
let m = b"machine entropy bytes";
let seed = crate::entropy::initial_seed(m);
let facts = [
seeded_fact(m),
drawn_fact(&seed, "issue", "0/issue/cert-serial", 9),
drawn_fact(&seed, "issue", "0/issue/cert-serial", 9),
];
let err = verify_entropy(facts.iter()).expect_err("duplicate path");
assert!(matches!(err, VerifyError::DuplicateDrawPath { .. }));
}
}