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, SecondsFormat, Utc};
use serde::Deserialize;
use thiserror::Error;
use rite_model::bundle::TRANSCRIPT_FILE;
use rite_model::commitment::{SALT_LEN, chain_node, fact_leaf, header_node};
use rite_model::{
FACT_TYPES, Level, Sha256Digest, StepFact, TRANSCRIPT_FORMAT, TRANSCRIPT_SCHEMA,
TranscriptHeader, canonical_json,
};
pub type TranscriptFingerprint = Sha256Digest;
pub trait TranscriptSink: Send {
fn begin(&mut self, header: &TranscriptHeader) -> io::Result<()>;
fn record(&mut self, at: DateTime<Utc>, level: Level, fact: &StepFact) -> io::Result<()>;
fn finalize(&mut self) -> io::Result<TranscriptFingerprint>;
}
#[derive(Debug, Default)]
struct Chain {
node: Option<[u8; 32]>,
levels: Vec<Level>,
finalized: bool,
}
enum Committed {
Header {
canonical: String,
node: [u8; 32],
levels: Vec<Level>,
},
Fact {
at: String,
level: Level,
salt: [u8; SALT_LEN],
canonical: String,
leaf: [u8; 32],
node: [u8; 32],
},
}
impl Committed {
fn node(&self) -> [u8; 32] {
match self {
Committed::Header { node, .. } | Committed::Fact { node, .. } => *node,
}
}
fn line(&self) -> String {
match self {
Committed::Header {
canonical, node, ..
} => format!(
"{{\"$schema\":\"{TRANSCRIPT_SCHEMA}\",\"header\":{canonical},\"chain\":\"{}\"}}",
Sha256Digest::from_bytes(node),
),
Committed::Fact {
at,
level,
salt,
canonical,
leaf,
node,
} => format!(
"{{\"at\":\"{at}\",\"level\":{},\"leaf\":\"{}\",\"chain\":\"{}\",\"salt\":\"{}\",\"fact\":{canonical}}}",
level.value(),
Sha256Digest::from_bytes(leaf),
Sha256Digest::from_bytes(node),
base16ct::lower::encode_string(salt),
),
}
}
}
impl Chain {
fn header(&self, header: &TranscriptHeader) -> io::Result<Committed> {
if self.node.is_some() {
return Err(io::Error::other("transcript header already written"));
}
let value = serde_json::to_value(header).map_err(io::Error::other)?;
let canonical = canonical_json(&value).map_err(io::Error::other)?;
let node = header_node(&canonical);
Ok(Committed::Header {
canonical,
node,
levels: header.levels.values().copied().collect(),
})
}
fn fact(&self, at: DateTime<Utc>, level: Level, fact: &StepFact) -> io::Result<Committed> {
let previous = self
.node
.ok_or_else(|| io::Error::other("transcript header not written"))?;
if self.finalized {
return Err(io::Error::other("transcript already finalized"));
}
if !self.levels.contains(&level) {
return Err(io::Error::other(format!(
"level {} is not declared in the transcript header",
level.value()
)));
}
let value = serde_json::to_value(fact).map_err(io::Error::other)?;
let canonical = canonical_json(&value).map_err(io::Error::other)?;
let salt: [u8; SALT_LEN] = crate::os_random::os_random()?;
let leaf = fact_leaf(&salt, &canonical);
let at = at.to_rfc3339_opts(SecondsFormat::Micros, true);
let node = chain_node(&previous, &at, level, &leaf);
Ok(Committed::Fact {
at,
level,
salt,
canonical,
leaf,
node,
})
}
fn advance(&mut self, committed: &Committed) {
self.node = Some(committed.node());
if let Committed::Header { levels, .. } = committed {
self.levels.clone_from(levels);
}
}
fn finalize(&mut self) -> io::Result<TranscriptFingerprint> {
let node = self
.node
.ok_or_else(|| io::Error::other("transcript header not written"))?;
self.finalized = true;
Ok(Sha256Digest::from_bytes(&node))
}
}
#[derive(Debug)]
pub struct JsonlFileSink {
jsonl_path: PathBuf,
writer: BufWriter<File>,
chain: Chain,
}
impl JsonlFileSink {
pub fn create(dir: &Path) -> io::Result<Self> {
let jsonl_path = dir.join(TRANSCRIPT_FILE);
let file = OpenOptions::new()
.create_new(true)
.write(true)
.open(&jsonl_path)?;
Ok(Self {
jsonl_path,
writer: BufWriter::new(file),
chain: Chain::default(),
})
}
fn append(&mut self, line: &str) -> io::Result<()> {
self.writer.write_all(line.as_bytes())?;
self.writer.write_all(b"\n")?;
self.writer.flush()?;
self.writer.get_ref().sync_data()
}
#[must_use]
pub fn jsonl_path(&self) -> &Path {
&self.jsonl_path
}
}
impl TranscriptSink for JsonlFileSink {
fn begin(&mut self, header: &TranscriptHeader) -> io::Result<()> {
let committed = self.chain.header(header)?;
self.append(&committed.line())?;
self.chain.advance(&committed);
Ok(())
}
fn record(&mut self, at: DateTime<Utc>, level: Level, fact: &StepFact) -> io::Result<()> {
let committed = self.chain.fact(at, level, fact)?;
self.append(&committed.line())?;
self.chain.advance(&committed);
Ok(())
}
fn finalize(&mut self) -> io::Result<TranscriptFingerprint> {
self.chain.finalize()
}
}
#[derive(Debug, Default)]
pub struct InMemorySink {
header: Option<TranscriptHeader>,
facts: Vec<StepFact>,
chain: Chain,
}
impl InMemorySink {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn header(&self) -> Option<&TranscriptHeader> {
self.header.as_ref()
}
#[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 begin(&mut self, header: &TranscriptHeader) -> io::Result<()> {
let committed = self.chain.header(header)?;
self.chain.advance(&committed);
self.header = Some(header.clone());
Ok(())
}
fn record(&mut self, at: DateTime<Utc>, level: Level, fact: &StepFact) -> io::Result<()> {
let committed = self.chain.fact(at, level, fact)?;
self.chain.advance(&committed);
self.facts.push(fact.clone());
Ok(())
}
fn finalize(&mut self) -> io::Result<TranscriptFingerprint> {
self.chain.finalize()
}
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct HeaderLine {
#[serde(rename = "$schema", default)]
_schema: Option<String>,
header: serde_json::Value,
chain: Sha256Digest,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct FactLine {
at: String,
level: Level,
#[serde(default, deserialize_with = "present")]
salt: Option<String>,
#[serde(default, deserialize_with = "present")]
fact: Option<serde_json::Value>,
leaf: Sha256Digest,
chain: Sha256Digest,
}
fn present<'de, D, T>(deserializer: D) -> Result<Option<T>, D::Error>
where
D: serde::Deserializer<'de>,
T: Deserialize<'de>,
{
T::deserialize(deserializer).map(Some)
}
#[derive(Debug, Clone)]
pub struct TimedFact {
pub line: usize,
pub at: DateTime<Utc>,
pub level: Level,
pub fact: StepFact,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WithheldLine {
pub line: usize,
pub level: Level,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UnknownFact {
pub line: usize,
pub level: Level,
pub type_name: String,
}
#[derive(Debug, Clone)]
pub struct TranscriptVerified {
pub header: TranscriptHeader,
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 transcript line: {reason}")]
InvalidLine {
line: usize,
reason: String,
},
#[error(
"line {line}: the fact does not match its leaf (computed {computed}, recorded {recorded})"
)]
LeafMismatch {
line: usize,
computed: String,
recorded: String,
},
#[error("line {line}: the chain breaks here (computed {computed}, recorded {recorded})")]
ChainMismatch {
line: usize,
computed: String,
recorded: String,
},
#[error("transcript is empty")]
Empty,
#[error("line 1 is not a transcript header: {0}")]
MissingHeader(String),
#[error("the header's levels are invalid: {0}")]
InvalidLevels(String),
#[error("unknown transcript format {0} (this verifier reads format {TRANSCRIPT_FORMAT})")]
UnknownFormat(u64),
#[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 {
header: loaded.header,
fact_count: loaded.facts.len(),
fingerprint: loaded.fingerprint,
terminated: loaded.terminated,
})
}
#[derive(Debug, Clone)]
pub struct LoadedTranscript {
pub header: TranscriptHeader,
pub facts: Vec<TimedFact>,
pub withheld: Vec<WithheldLine>,
pub unknown: Vec<UnknownFact>,
pub fingerprint: TranscriptFingerprint,
pub terminated: bool,
}
pub fn read_verified_transcript(jsonl_path: &Path) -> Result<LoadedTranscript, VerifyError> {
let file = File::open(jsonl_path)?;
verify_lines(BufReader::new(file).lines())
}
pub fn verify_jsonl(jsonl: &str) -> Result<LoadedTranscript, VerifyError> {
verify_lines(jsonl.lines().map(|line| Ok(line.to_string())))
}
fn verify_lines(
mut lines: impl Iterator<Item = io::Result<String>>,
) -> Result<LoadedTranscript, VerifyError> {
let first = lines.next().ok_or(VerifyError::Empty)??;
let (header, mut node) = read_header(&first)?;
let mut loaded = LoadedTranscript {
header,
facts: Vec::new(),
withheld: Vec::new(),
unknown: Vec::new(),
fingerprint: Sha256Digest::from_bytes(&node),
terminated: false,
};
for (idx, line) in lines.enumerate() {
let line = line?;
let line_no = idx.saturating_add(2);
node = read_fact_line(&line, line_no, &node, &mut loaded)?;
}
loaded.fingerprint = Sha256Digest::from_bytes(&node);
Ok(loaded)
}
pub fn disclose_transcript(jsonl: &str, threshold: Level) -> Result<String, VerifyError> {
let mut lines = jsonl.lines();
let header = lines.next().ok_or(VerifyError::Empty)?;
let mut out = String::with_capacity(jsonl.len());
out.push_str(header);
out.push('\n');
for (idx, line) in lines.enumerate() {
let line_no = idx.saturating_add(2);
let parsed: FactLine = serde_json::from_str(line).map_err(|e| invalid(line_no, &e))?;
if parsed.level > threshold && parsed.fact.is_some() {
out.push_str(&withheld_line(&parsed)?);
} else {
out.push_str(line);
}
out.push('\n');
}
Ok(out)
}
fn withheld_line(line: &FactLine) -> Result<String, VerifyError> {
let at = serde_json::to_string(&line.at).map_err(|e| invalid(0, &e))?;
Ok(format!(
"{{\"at\":{at},\"level\":{},\"leaf\":\"{}\",\"chain\":\"{}\"}}",
line.level.value(),
line.leaf,
line.chain,
))
}
fn read_header(line: &str) -> Result<(TranscriptHeader, [u8; 32]), VerifyError> {
let parsed: HeaderLine =
serde_json::from_str(line).map_err(|e| VerifyError::MissingHeader(e.to_string()))?;
let format = parsed
.header
.get("rite_transcript")
.and_then(serde_json::Value::as_u64)
.ok_or_else(|| VerifyError::MissingHeader("no rite_transcript version".to_string()))?;
if format != u64::from(TRANSCRIPT_FORMAT) {
return Err(VerifyError::UnknownFormat(format));
}
let canonical = canonical_json(&parsed.header).map_err(|e| invalid(1, &e))?;
let node = header_node(&canonical);
check_checkpoint(&parsed.chain, &node, |computed, recorded| {
VerifyError::ChainMismatch {
line: 1,
computed,
recorded,
}
})?;
let header: TranscriptHeader = serde_json::from_value(parsed.header)
.map_err(|e| VerifyError::MissingHeader(e.to_string()))?;
check_levels(&header)?;
Ok((header, node))
}
fn check_levels(header: &TranscriptHeader) -> Result<(), VerifyError> {
for (name, level) in Level::BUILT_IN {
if header.levels.get(name) != Some(&level) {
return Err(VerifyError::InvalidLevels(format!(
"'{name}' must be {}",
level.value()
)));
}
}
let mut seen = HashSet::new();
for level in header.levels.values() {
if !seen.insert(*level) {
return Err(VerifyError::InvalidLevels(format!(
"more than one name for {}",
level.value()
)));
}
}
Ok(())
}
fn read_fact_line(
line: &str,
line_no: usize,
previous: &[u8; 32],
loaded: &mut LoadedTranscript,
) -> Result<[u8; 32], VerifyError> {
let parsed: FactLine = serde_json::from_str(line).map_err(|e| invalid(line_no, &e))?;
if !loaded.header.declares(parsed.level) {
return Err(invalid(
line_no,
&format!(
"level {} is not declared in the header",
parsed.level.value()
),
));
}
let at: DateTime<Utc> = DateTime::parse_from_rfc3339(&parsed.at)
.map_err(|e| invalid(line_no, &e))?
.with_timezone(&Utc);
let leaf = match (parsed.salt, parsed.fact) {
(Some(salt), Some(fact)) => {
let salt = decode_salt(&salt).ok_or_else(|| {
invalid(
line_no,
&format!("salt is not {SALT_LEN} bytes of lowercase hex"),
)
})?;
let canonical = canonical_json(&fact).map_err(|e| invalid(line_no, &e))?;
let leaf = fact_leaf(&salt, &canonical);
check_checkpoint(&parsed.leaf, &leaf, |computed, recorded| {
VerifyError::LeafMismatch {
line: line_no,
computed,
recorded,
}
})?;
loaded.terminated = add_fact(fact, at, parsed.level, line_no, loaded)?;
leaf
}
(None, None) => {
loaded.withheld.push(WithheldLine {
line: line_no,
level: parsed.level,
});
loaded.terminated = false;
parsed.leaf.to_bytes()
}
_ => {
return Err(invalid(
line_no,
&"a line carries both `salt` and `fact`, or neither",
));
}
};
let node = chain_node(previous, &parsed.at, parsed.level, &leaf);
check_checkpoint(&parsed.chain, &node, |computed, recorded| {
VerifyError::ChainMismatch {
line: line_no,
computed,
recorded,
}
})?;
Ok(node)
}
fn add_fact(
fact: serde_json::Value,
at: DateTime<Utc>,
level: Level,
line_no: usize,
loaded: &mut LoadedTranscript,
) -> Result<bool, VerifyError> {
let type_name = fact
.get("type")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| invalid(line_no, &"the fact has no `type`"))?
.to_string();
if !FACT_TYPES.contains(&type_name.as_str()) {
loaded.unknown.push(UnknownFact {
line: line_no,
level,
type_name,
});
return Ok(false);
}
let fact: StepFact = serde_json::from_value(fact).map_err(|e| invalid(line_no, &e))?;
let terminal = matches!(
fact,
StepFact::CeremonyCompleted { .. } | StepFact::CeremonyFailed { .. }
);
loaded.facts.push(TimedFact {
line: line_no,
at,
level,
fact,
});
Ok(terminal)
}
fn decode_salt(hex: &str) -> Option<[u8; SALT_LEN]> {
base16ct::lower::decode_vec(hex).ok()?.try_into().ok()
}
fn check_checkpoint(
recorded: &Sha256Digest,
computed: &[u8; 32],
mismatch: impl FnOnce(String, String) -> VerifyError,
) -> Result<(), VerifyError> {
if recorded.to_bytes() == *computed {
Ok(())
} else {
Err(mismatch(
Sha256Digest::from_bytes(computed).to_string(),
recorded.to_string(),
))
}
}
fn invalid(line: usize, reason: &impl std::fmt::Display) -> VerifyError {
VerifyError::InvalidLine {
line,
reason: reason.to_string(),
}
}
#[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 {
crate::test_support::ceremony_started("Test")
}
fn begun_file_sink(dir: &Path) -> JsonlFileSink {
let mut sink = JsonlFileSink::create(dir).expect("create");
sink.begin(&crate::test_support::test_header())
.expect("header");
sink
}
#[test]
fn in_memory_sink_records_and_finalizes() {
let mut sink = crate::test_support::begun_sink();
assert!(sink.is_empty());
sink.record(test_at(), Level::PUBLIC, &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 = crate::test_support::begun_sink();
sink.record(test_at(), Level::PUBLIC, &sample_fact())
.expect("record");
sink.finalize().expect("finalize");
let err = sink
.record(test_at(), Level::PUBLIC, &sample_fact())
.expect_err("should fail");
assert!(err.to_string().contains("already finalized"));
}
#[test]
fn in_memory_sink_chain_advances() {
let mut sink = crate::test_support::begun_sink();
let initial = sink.chain.node;
sink.record(test_at(), Level::PUBLIC, &sample_fact())
.expect("record");
assert_ne!(sink.chain.node, initial);
let after_first = sink.chain.node;
sink.record(test_at(), Level::PUBLIC, &sample_fact())
.expect("record");
assert_ne!(sink.chain.node, after_first);
}
#[test]
fn record_persists_the_supplied_time_and_reader_recovers_it() {
let tmp = tempfile::tempdir().expect("tempdir");
let mut sink = begun_file_sink(tmp.path());
let at = test_at();
sink.record(at, Level::PUBLIC, &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_checkpoints_that_fold_to_the_fingerprint() {
let tmp = tempfile::tempdir().expect("tempdir");
let mut sink = begun_file_sink(tmp.path());
sink.record(test_at(), Level::PUBLIC, &sample_fact())
.expect("record");
sink.record(test_at(), Level::RESTRICTED, &sample_fact())
.expect("record");
let fp = sink.finalize().expect("finalize");
let jsonl = std::fs::read_to_string(sink.jsonl_path()).expect("read jsonl");
let lines: Vec<serde_json::Value> = jsonl
.lines()
.map(|l| serde_json::from_str(l).expect("parse line"))
.collect();
assert_eq!(lines.len(), 3, "expected a header and two facts");
assert!(lines.first().and_then(|l| l.get("header")).is_some());
let second = lines.get(2).expect("second fact");
assert_eq!(
second.get("level").and_then(serde_json::Value::as_u64),
Some(20)
);
assert_eq!(
lines
.last()
.and_then(|l| l.get("chain"))
.and_then(serde_json::Value::as_str),
Some(fp.as_str()),
"the last chain value is the fingerprint"
);
}
#[test]
fn two_records_of_the_same_fact_get_different_salts() {
let tmp = tempfile::tempdir().expect("tempdir");
let mut sink = begun_file_sink(tmp.path());
sink.record(test_at(), Level::PUBLIC, &sample_fact())
.expect("record");
sink.record(test_at(), Level::PUBLIC, &sample_fact())
.expect("record");
let jsonl = std::fs::read_to_string(sink.jsonl_path()).expect("read jsonl");
let salts: Vec<String> = jsonl
.lines()
.skip(1)
.map(|l| {
let v: serde_json::Value = serde_json::from_str(l).expect("parse");
v.get("salt")
.and_then(serde_json::Value::as_str)
.expect("salt")
.to_string()
})
.collect();
assert_eq!(salts.len(), 2);
assert_ne!(salts.first(), salts.last());
}
#[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 = begun_file_sink(tmp.path());
sink.record(test_at(), Level::PUBLIC, &sample_fact())
.expect("record");
sink.record(test_at(), Level::PUBLIC, &sample_fact())
.expect("record");
sink.record(test_at(), Level::PUBLIC, &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);
}
fn three_fact_transcript(dir: &Path) -> (PathBuf, Vec<String>) {
let mut sink = begun_file_sink(dir);
for _ in 0..3 {
sink.record(test_at(), Level::PUBLIC, &sample_fact())
.expect("record");
}
let path = sink.jsonl_path().to_path_buf();
let lines = std::fs::read_to_string(&path)
.expect("read")
.lines()
.map(String::from)
.collect();
(path, lines)
}
fn rewrite(path: &Path, lines: &[String]) {
std::fs::write(path, lines.join("\n") + "\n").expect("write");
}
fn edit_line(lines: &mut [String], index: usize, edit: impl FnOnce(&mut serde_json::Value)) {
let line = lines.get_mut(index).expect("line");
let mut value: serde_json::Value = serde_json::from_str(line).expect("parse");
edit(&mut value);
*line = serde_json::to_string(&value).expect("serialize");
}
#[test]
fn an_edited_fact_is_caught_at_its_leaf() {
let tmp = tempfile::tempdir().expect("tempdir");
let (path, mut lines) = three_fact_transcript(tmp.path());
edit_line(&mut lines, 2, |v| {
if let Some(fact) = v.get_mut("fact").and_then(serde_json::Value::as_object_mut) {
fact.insert("name".to_string(), serde_json::json!("Other"));
}
});
rewrite(&path, &lines);
let err = verify_transcript(&path).expect_err("tampered fact");
assert!(matches!(err, VerifyError::LeafMismatch { line: 3, .. }));
}
#[test]
fn an_edited_level_is_caught_at_its_chain_value() {
let tmp = tempfile::tempdir().expect("tempdir");
let (path, mut lines) = three_fact_transcript(tmp.path());
edit_line(&mut lines, 2, |v| {
if let Some(obj) = v.as_object_mut() {
obj.insert("level".to_string(), serde_json::json!(30));
}
});
rewrite(&path, &lines);
let err = verify_transcript(&path).expect_err("tampered level");
assert!(matches!(err, VerifyError::ChainMismatch { line: 3, .. }));
}
#[test]
fn a_deleted_line_breaks_the_chain_at_the_next_one() {
let tmp = tempfile::tempdir().expect("tempdir");
let (path, mut lines) = three_fact_transcript(tmp.path());
lines.remove(2);
rewrite(&path, &lines);
let err = verify_transcript(&path).expect_err("deleted line");
assert!(matches!(err, VerifyError::ChainMismatch { line: 3, .. }));
}
#[test]
fn a_withheld_line_folds_to_the_same_fingerprint() {
let tmp = tempfile::tempdir().expect("tempdir");
let (path, mut lines) = three_fact_transcript(tmp.path());
let complete = verify_transcript(&path).expect("complete").fingerprint;
edit_line(&mut lines, 2, |v| {
if let Some(obj) = v.as_object_mut() {
obj.remove("salt");
obj.remove("fact");
}
});
rewrite(&path, &lines);
let loaded = read_verified_transcript(&path).expect("withheld");
assert_eq!(loaded.fingerprint, complete);
assert_eq!(
loaded.withheld,
vec![WithheldLine {
line: 3,
level: Level::PUBLIC
}]
);
assert_eq!(loaded.facts.len(), 2);
}
#[test]
fn disclosing_withholds_above_the_threshold_and_keeps_the_fingerprint() {
let tmp = tempfile::tempdir().expect("tempdir");
let mut sink = begun_file_sink(tmp.path());
for level in [Level::PUBLIC, Level::CONFIDENTIAL, Level::RESTRICTED] {
sink.record(test_at(), level, &sample_fact())
.expect("record");
}
let complete = sink.finalize().expect("finalize");
let text = std::fs::read_to_string(sink.jsonl_path()).expect("read");
let disclosed = disclose_transcript(&text, Level::RESTRICTED).expect("disclose");
let path = tmp.path().join("disclosed.jsonl");
std::fs::write(&path, &disclosed).expect("write");
let loaded = read_verified_transcript(&path).expect("verify");
assert_eq!(loaded.fingerprint, complete);
assert_eq!(
loaded.withheld,
vec![WithheldLine {
line: 3,
level: Level::CONFIDENTIAL
}]
);
assert_eq!(loaded.facts.len(), 2);
assert_eq!(
disclose_transcript(&disclosed, Level::RESTRICTED).expect("again"),
disclosed
);
}
#[test]
fn a_line_with_a_fact_and_no_salt_is_invalid() {
let tmp = tempfile::tempdir().expect("tempdir");
let (path, mut lines) = three_fact_transcript(tmp.path());
edit_line(&mut lines, 2, |v| {
if let Some(obj) = v.as_object_mut() {
obj.remove("salt");
}
});
rewrite(&path, &lines);
let err = verify_transcript(&path).expect_err("half-withheld line");
assert!(matches!(err, VerifyError::InvalidLine { line: 3, .. }));
}
#[test]
fn a_line_with_null_salt_and_fact_is_not_withheld() {
let tmp = tempfile::tempdir().expect("tempdir");
let (path, mut lines) = three_fact_transcript(tmp.path());
edit_line(&mut lines, 2, |v| {
if let Some(obj) = v.as_object_mut() {
obj.insert("salt".to_string(), serde_json::Value::Null);
obj.insert("fact".to_string(), serde_json::Value::Null);
}
});
rewrite(&path, &lines);
let err = verify_transcript(&path).expect_err("null members");
assert!(matches!(err, VerifyError::InvalidLine { line: 3, .. }));
}
#[test]
fn an_unknown_fact_type_is_counted_not_refused() {
let tmp = tempfile::tempdir().expect("tempdir");
let mut sink = begun_file_sink(tmp.path());
sink.record(test_at(), Level::PUBLIC, &sample_fact())
.expect("record");
let path = sink.jsonl_path().to_path_buf();
let mut lines: Vec<String> = std::fs::read_to_string(&path)
.expect("read")
.lines()
.map(String::from)
.collect();
let previous: serde_json::Value =
serde_json::from_str(lines.last().expect("last")).expect("parse");
let previous_node = Sha256Digest::parse(
previous
.get("chain")
.and_then(serde_json::Value::as_str)
.expect("chain"),
)
.expect("digest")
.to_bytes();
let fact = serde_json::json!({ "type": "future_fact", "detail": 1 });
let salt = [7u8; SALT_LEN];
let leaf = fact_leaf(&salt, &canonical_json(&fact).expect("canonical"));
let at = "2026-09-22T10:00:00.000000Z";
let node = chain_node(&previous_node, at, Level::PUBLIC, &leaf);
lines.push(
serde_json::json!({
"at": at,
"level": 10,
"salt": base16ct::lower::encode_string(&salt),
"fact": fact,
"leaf": Sha256Digest::from_bytes(&leaf).as_str(),
"chain": Sha256Digest::from_bytes(&node).as_str(),
})
.to_string(),
);
rewrite(&path, &lines);
let loaded = read_verified_transcript(&path).expect("read");
assert_eq!(
loaded.unknown,
vec![UnknownFact {
line: 3,
level: Level::PUBLIC,
type_name: "future_fact".to_string()
}]
);
assert_eq!(loaded.facts.len(), 1);
}
#[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));
}
#[test]
fn a_fact_before_the_header_is_refused() {
let mut sink = InMemorySink::new();
let err = sink
.record(test_at(), Level::PUBLIC, &sample_fact())
.expect_err("no header yet");
assert!(err.to_string().contains("header not written"));
}
#[test]
fn a_second_header_is_refused() {
let mut sink = crate::test_support::begun_sink();
let err = sink
.begin(&crate::test_support::test_header())
.expect_err("second header");
assert!(err.to_string().contains("already written"));
}
#[test]
fn the_reader_returns_the_header() {
let tmp = tempfile::tempdir().expect("tempdir");
let mut sink = begun_file_sink(tmp.path());
sink.record(test_at(), Level::PUBLIC, &sample_fact())
.expect("record");
let loaded = read_verified_transcript(sink.jsonl_path()).expect("read");
assert_eq!(loaded.header, crate::test_support::test_header());
assert_eq!(loaded.facts.len(), 1);
}
fn transcript_with_header(dir: &Path, header: &TranscriptHeader) -> PathBuf {
let value = serde_json::to_value(header).expect("header json");
let node = header_node(&canonical_json(&value).expect("canonical"));
let line = serde_json::json!({
"header": value,
"chain": Sha256Digest::from_bytes(&node).as_str(),
})
.to_string();
let path = dir.join("transcript.jsonl");
std::fs::write(&path, format!("{line}\n")).expect("write");
path
}
#[test]
fn an_unknown_format_is_refused() {
let tmp = tempfile::tempdir().expect("tempdir");
let mut header = crate::test_support::test_header();
header.rite_transcript = 2;
let path = transcript_with_header(tmp.path(), &header);
let err = verify_transcript(&path).expect_err("unknown format");
assert!(matches!(err, VerifyError::UnknownFormat(2)));
}
#[test]
fn a_newer_vocabulary_is_read() {
let tmp = tempfile::tempdir().expect("tempdir");
let mut header = crate::test_support::test_header();
header.vocabulary = 9;
let path = transcript_with_header(tmp.path(), &header);
let loaded = read_verified_transcript(&path).expect("newer vocabulary");
assert_eq!(loaded.header.vocabulary, 9);
}
#[test]
fn a_moved_built_in_level_is_refused() {
let tmp = tempfile::tempdir().expect("tempdir");
let mut header = crate::test_support::test_header();
header.levels.insert("public".to_string(), Level::new(5));
let path = transcript_with_header(tmp.path(), &header);
let err = verify_transcript(&path).expect_err("moved built-in");
assert!(matches!(err, VerifyError::InvalidLevels(_)));
}
#[test]
fn a_line_at_an_undeclared_level_is_refused() {
let tmp = tempfile::tempdir().expect("tempdir");
let (path, mut lines) = three_fact_transcript(tmp.path());
edit_line(&mut lines, 2, |v| {
if let Some(obj) = v.as_object_mut() {
obj.insert("level".to_string(), 15.into());
}
});
rewrite(&path, &lines);
let err = verify_transcript(&path).expect_err("undeclared level");
assert!(
matches!(err, VerifyError::InvalidLine { line: 3, .. }),
"{err}"
);
}
#[test]
fn the_sink_refuses_a_level_the_header_does_not_declare() {
let tmp = tempfile::tempdir().expect("tempdir");
let mut sink = begun_file_sink(tmp.path());
let err = sink
.record(test_at(), Level::new(15), &sample_fact())
.expect_err("undeclared level");
assert!(err.to_string().contains("not declared"), "{err}");
sink.record(test_at(), Level::PUBLIC, &sample_fact())
.expect("record");
let loaded = read_verified_transcript(sink.jsonl_path()).expect("verify");
assert_eq!(loaded.facts.len(), 1);
}
#[test]
fn a_declared_organisation_level_is_accepted() {
let tmp = tempfile::tempdir().expect("tempdir");
let mut header = crate::test_support::test_header();
header.levels.insert("partners".to_string(), Level::new(15));
let mut sink = JsonlFileSink::create(tmp.path()).expect("create");
sink.begin(&header).expect("header");
sink.record(test_at(), Level::new(15), &sample_fact())
.expect("record");
let loaded = read_verified_transcript(sink.jsonl_path()).expect("read");
assert_eq!(loaded.facts.first().map(|t| t.level), Some(Level::new(15)));
}
#[test]
fn an_edited_header_is_caught() {
let tmp = tempfile::tempdir().expect("tempdir");
let path = transcript_with_header(tmp.path(), &crate::test_support::test_header());
let text = std::fs::read_to_string(&path).expect("read");
std::fs::write(&path, text.replace("\"dry_run\":false", "\"dry_run\":true"))
.expect("write");
let err = verify_transcript(&path).expect_err("edited header");
assert!(matches!(err, VerifyError::ChainMismatch { line: 1, .. }));
}
#[test]
fn a_transcript_starting_with_a_fact_is_refused() {
let tmp = tempfile::tempdir().expect("tempdir");
let (path, lines) = three_fact_transcript(tmp.path());
rewrite(&path, lines.get(1..).expect("facts"));
let err = verify_transcript(&path).expect_err("no header");
assert!(matches!(err, VerifyError::MissingHeader(_)));
}
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 { .. }));
}
}