diff --git a/Cargo.lock b/Cargo.lock index c7ad649..5a64b40 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -251,6 +251,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + [[package]] name = "colorchoice" version = "1.0.5" @@ -400,6 +406,15 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + [[package]] name = "darling" version = "0.23.0" @@ -524,6 +539,7 @@ dependencies = [ "block-buffer 0.12.0", "const-oid 0.10.2", "crypto-common 0.2.1", + "ctutils", ] [[package]] @@ -848,6 +864,15 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hkdf" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" +dependencies = [ + "hmac 0.13.0", +] + [[package]] name = "hmac" version = "0.12.1" @@ -857,6 +882,15 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.2", +] + [[package]] name = "httparse" version = "1.10.1" @@ -1551,7 +1585,7 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" dependencies = [ - "hmac", + "hmac 0.12.1", "subtle", ] @@ -1640,6 +1674,8 @@ dependencies = [ "base64ct", "chrono", "crossbeam-channel", + "hkdf", + "rand 0.10.1", "rite-model", "rite-resolver", "rite-sdk", @@ -1669,7 +1705,6 @@ dependencies = [ "der", "openssl", "p256", - "rand 0.10.1", "rite-model", "rite-openssl", "rite-runtime", diff --git a/Cargo.toml b/Cargo.toml index 65a456f..2fe87bd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -69,6 +69,7 @@ x509-cert = "0.2.5" der = "0.7.10" sha2 = "0.11.0" sha1 = "0.11.0" +hkdf = "0.13.0" rsa = "0.9.10" p256 = { version = "0.13.2", features = ["ecdsa", "pkcs8"] } diff --git a/crates/rite-model/src/transcript.rs b/crates/rite-model/src/transcript.rs index 1e49f1e..e814aeb 100644 --- a/crates/rite-model/src/transcript.rs +++ b/crates/rite-model/src/transcript.rs @@ -263,6 +263,57 @@ pub enum StepFact { /// Wall-clock timestamp at failure. failed_at: DateTime, }, + /// The ceremony entropy source was seeded with machine randomness. + /// + /// Emitted once by the runner at ceremony start (run-metadata). Records + /// the machine contribution `m` and the frozen derivation scheme so any + /// value the ceremony later draws is re-derivable from the transcript + /// alone. Part of the [entropy source](StepFact::EntropyDrawn) family. + /// + /// Carries no timestamp of its own: it is emitted immediately after + /// [`CeremonyStarted`](StepFact::CeremonyStarted), whose `started_at` + /// stamps that instant. + EntropySeeded { + /// Lowercase hex of the gathered machine entropy `m`. + m: String, + /// Provenance of `m` (e.g. `os`). A single label today; comma-separated + /// if more than one source is ever mixed. + source: String, + /// Frozen derivation-scheme tag (e.g. `rite-kdf/v1`) that pins the + /// entire construction. A verifier rejects an unrecognised value. + derivation: String, + }, + /// A human folded additional entropy into the seed, advancing the ratchet. + /// + /// Emitted by the authored `gather_entropy` step. The verbatim operator + /// contribution is recorded so the epoch chain re-folds identically; it is + /// public, witnessed entropy, not a secret. Timed by its enclosing step + /// boundaries (and by the `PromptAnswered` that captured the input). + EntropyContributed { + /// Step under which the contribution was gathered. + step: StepId, + /// Epoch index produced by this fold (1 for the first contribution). + epoch: u32, + /// Verbatim operator contribution, fed as UTF-8 into the ratchet. + contribution: String, + }, + /// A value was drawn from the entropy source (a nonce, certificate serial, + /// or challenge). + /// + /// Emitted whenever an action draws bytes from the entropy source. The + /// derivation `path` plus the recorded seed let `rite verify` re-derive + /// the value and confirm the right value reached the right consumer. Like + /// other action-emitted evidence, it is timed by its enclosing step. + EntropyDrawn { + /// Step that drew the value. + step: StepId, + /// Derivation path `//`. + path: String, + /// Number of bytes drawn (cross-checks `value`). + len: usize, + /// Lowercase hex of the derived bytes. + value: String, + }, } /// JSON-shape snapshot tests, the tripwire for accidental wire-format breaks. @@ -639,4 +690,57 @@ mod schema_snapshot_tests { }), ); } + + #[test] + fn entropy_seeded() { + assert_json( + &StepFact::EntropySeeded { + m: "00112233".to_string(), + source: "os".to_string(), + derivation: "rite-kdf/v1".to_string(), + }, + &json!({ + "type": "entropy_seeded", + "m": "00112233", + "source": "os", + "derivation": "rite-kdf/v1", + }), + ); + } + + #[test] + fn entropy_contributed() { + assert_json( + &StepFact::EntropyContributed { + step: StepId::new("roll_dice"), + epoch: 1, + contribution: "3 1 6 4 2 5".to_string(), + }, + &json!({ + "type": "entropy_contributed", + "step": "roll_dice", + "epoch": 1, + "contribution": "3 1 6 4 2 5", + }), + ); + } + + #[test] + fn entropy_drawn() { + assert_json( + &StepFact::EntropyDrawn { + step: StepId::new("issue"), + path: "0/issue/cert-serial".to_string(), + len: 9, + value: "aabbccddeeff00112233".to_string(), + }, + &json!({ + "type": "entropy_drawn", + "step": "issue", + "path": "0/issue/cert-serial", + "len": 9, + "value": "aabbccddeeff00112233", + }), + ); + } } diff --git a/crates/rite-model/src/types.rs b/crates/rite-model/src/types.rs index 887a7bc..137f659 100644 --- a/crates/rite-model/src/types.rs +++ b/crates/rite-model/src/types.rs @@ -100,6 +100,13 @@ pub enum ActionType { ExportPublic, /// Formal attestation statement. Attest, + /// Fold human-supplied entropy into the ceremony seed. + /// + /// A participant supplies a free-form random value (for example, the + /// result of rolling physical dice), which is mixed into the entropy + /// source's ratchet. Any later drawn value reflects the contribution and + /// stays re-derivable by `rite verify`. + GatherEntropy, /// TPM attestation with PCR measurements and cryptographic quotes. /// /// Requires the `rite-tpm` backend. @@ -186,6 +193,7 @@ impl ActionType { ActionType::OralReadback => "Read back a value aloud for verification.", ActionType::MachineInfo => "Record system and environment information.", ActionType::Attest => "Record a signed attestation from a participant.", + ActionType::GatherEntropy => "Fold human-supplied entropy into the ceremony seed.", ActionType::TpmAttest => "Record TPM platform attestation (PCR values).", ActionType::GenerateKeypair => "Generate an asymmetric keypair.", ActionType::ExportPublic => "Export the public component of a keypair.", @@ -213,6 +221,7 @@ impl std::fmt::Display for ActionType { ActionType::UnwrapKey => write!(f, "unwrap_key"), ActionType::ExportPublic => write!(f, "export_public"), ActionType::Attest => write!(f, "attest"), + ActionType::GatherEntropy => write!(f, "gather_entropy"), ActionType::TpmAttest => write!(f, "tpm_attest"), ActionType::PivReadCertificate => write!(f, "piv_read_certificate"), ActionType::PivSign => write!(f, "piv_sign"), diff --git a/crates/rite-runtime/Cargo.toml b/crates/rite-runtime/Cargo.toml index 010425c..037a7ad 100644 --- a/crates/rite-runtime/Cargo.toml +++ b/crates/rite-runtime/Cargo.toml @@ -20,6 +20,8 @@ serde_json = { workspace = true } thiserror = { workspace = true } chrono = { workspace = true } sha2 = { workspace = true } +hkdf = { workspace = true } +rand = { workspace = true } base16ct = { workspace = true } base32ct = { workspace = true } base64ct = { workspace = true } diff --git a/crates/rite-runtime/src/display.rs b/crates/rite-runtime/src/display.rs index cd17419..b272adb 100644 --- a/crates/rite-runtime/src/display.rs +++ b/crates/rite-runtime/src/display.rs @@ -57,6 +57,16 @@ pub fn fact_summary(fact: &StepFact) -> Option<(Icon, String)> { StepFact::CeremonyFailed { error, .. } => { Some((Icon::Cross, format!("Ceremony failed: {}", error.message))) } + StepFact::EntropySeeded { source, .. } => { + Some((Icon::Info, format!("Entropy source seeded ({source})"))) + } + StepFact::EntropyContributed { epoch, .. } => Some(( + Icon::Info, + format!("Entropy contribution folded (epoch {epoch})"), + )), + StepFact::EntropyDrawn { path, .. } => { + Some((Icon::Info, format!("Random value drawn: {path}"))) + } // `StepFact` is `#[non_exhaustive]`; future variants may carry // payloads the live UI shouldn't blindly Debug-print. Surface a // typed placeholder until each new variant is given a summary. diff --git a/crates/rite-runtime/src/entropy.rs b/crates/rite-runtime/src/entropy.rs new file mode 100644 index 0000000..fa6775e --- /dev/null +++ b/crates/rite-runtime/src/entropy.rs @@ -0,0 +1,275 @@ +//! Ceremony entropy source: a single, auditable, reproducible origin for +//! every random value a ceremony consumes. +//! +//! # The split: gather vs derive +//! +//! Randomness enters the source once, by *gathering* non-deterministic bytes +//! from the host (the machine seed `m`, and optionally free-form human +//! contributions). Everything after that is *derivation*: a pure, frozen +//! function of the gathered bytes. The gathered bytes are recorded in the +//! transcript, so the derivation, and therefore every value drawn, replays +//! identically from the transcript alone, decades later, in any language. +//! +//! # The `rite-kdf/v1` scheme +//! +//! All steps use HKDF-SHA-256 (RFC 5869). +//! +//! ```text +//! seed_0 = HKDF-Extract(salt = "rite/seed/v1", IKM = m) +//! seed_{k+1} = HKDF-Extract(salt = seed_k, IKM = utf8(h_k)) +//! path = "//" +//! value(path) = HKDF-Expand(seed_epoch, info = "rite/nonce/v1/" || path, len) +//! ``` +//! +//! The seed is an *epoch chain* (a ratchet): each human contribution `h_k` +//! advances the epoch by extracting a new seed with the previous seed as the +//! HMAC salt (key) and the contribution as the IKM (message). This is the +//! TLS 1.3 key-schedule shape. Because the prior high-entropy seed is the key, +//! a weak or empty contribution can never reduce strength below the machine +//! seed; an unpredictable one only adds. A value is drawn from whichever epoch +//! seed is current when the draw happens, so there is no constraint that human +//! steps precede draws. +//! +//! # Why this is reproducible but a PRNG would not be +//! +//! [`CeremonyRandom`] is a deterministic fold over recorded inputs, not opaque +//! generator state. A verifier rebuilds `seed_0` from the recorded `m`, folds +//! each recorded contribution in transcript order, and re-derives any value +//! straight from its recorded path. No draw order, internal buffer, or library +//! version affects the output. + +use std::collections::HashSet; + +use hkdf::Hkdf; +use rite_model::StepId; +use sha2::Sha256; + +/// Frozen-scheme tag recorded with the machine seed. It pins the *entire* +/// construction (Extract/Expand, the ratchet rule, the `info`/path byte +/// encodings), not merely the hash. A verifier treats it as a selector among +/// known-good schemes it already trusts and rejects any value it does not +/// recognise, it is never an instruction to trust an arbitrary algorithm. +pub const DERIVATION_V1: &str = "rite-kdf/v1"; + +/// Salt for the initial `HKDF-Extract` that turns the machine seed into +/// `seed_0`. Part of the frozen `rite-kdf/v1` scheme. +const SEED_SALT_V1: &[u8] = b"rite/seed/v1"; + +/// Prefix prepended to every derivation path to form the `HKDF-Expand` info +/// string. Part of the frozen `rite-kdf/v1` scheme. +const VALUE_INFO_PREFIX_V1: &str = "rite/nonce/v1/"; + +/// Length in bytes of an epoch seed (the HKDF pseudo-random key). +const SEED_LEN: usize = 32; + +/// `HKDF-Extract(salt, ikm)` over SHA-256, returning the 32-byte PRK. +/// +/// The `salt` is a fixed domain-separation constant (RFC 5869 permits a +/// non-secret, fixed salt). Uniqueness and unpredictability come from the IKM +/// (the per-ceremony machine entropy), not the salt, so a constant salt here is +/// correct, not the password-KDF static-salt anti-pattern. +fn extract(salt: &[u8], ikm: &[u8]) -> [u8; SEED_LEN] { + let (prk, _hk) = Hkdf::::extract(Some(salt), ikm); + prk.into() +} + +/// `HKDF-Expand(prk, info, len)` over SHA-256. +/// +/// # Panics +/// +/// Never in practice. `from_prk` only rejects a PRK shorter than the hash +/// output, but every seed here is exactly 32 bytes; `expand` only fails when +/// `len` exceeds `255 * 32` bytes, far beyond any nonce or serial a ceremony +/// draws. Both `expect`s therefore guard true invariants and are allowed. +#[allow(clippy::expect_used)] +fn expand(prk: &[u8], info: &[u8], len: usize) -> Vec { + let hk = Hkdf::::from_prk(prk).expect("seed is a valid HKDF PRK length"); + let mut out = vec![0u8; len]; + hk.expand(info, &mut out) + .expect("draw length is within HKDF-Expand limits"); + out +} + +/// Compute `seed_0` from the machine entropy `m`. +#[must_use] +pub fn initial_seed(m: &[u8]) -> [u8; SEED_LEN] { + extract(SEED_SALT_V1, m) +} + +/// Advance the ratchet by one epoch, folding in a human contribution. +#[must_use] +pub fn fold_seed(current: &[u8; SEED_LEN], contribution: &[u8]) -> [u8; SEED_LEN] { + extract(current, contribution) +} + +/// Derive the value at `path` from the given epoch seed. +/// +/// The verifier calls this directly with a recorded path, so it must stay a +/// pure function of `(seed_epoch, path, len)`. +#[must_use] +pub fn derive_value(seed_epoch: &[u8; SEED_LEN], path: &str, len: usize) -> Vec { + let mut info = Vec::with_capacity(VALUE_INFO_PREFIX_V1.len().saturating_add(path.len())); + info.extend_from_slice(VALUE_INFO_PREFIX_V1.as_bytes()); + info.extend_from_slice(path.as_bytes()); + expand(seed_epoch, &info, len) +} + +/// Build a derivation path. The single place the path format lives, so the +/// draw side and the verify side cannot drift. A `purpose` is drawn at most +/// once per step, so `(epoch, step, purpose)` uniquely identifies a value. +#[must_use] +pub fn build_path(epoch: u32, step: &StepId, purpose: &str) -> String { + format!("{epoch}/{step}/{purpose}", step = step.as_str()) +} + +/// A value drawn from the source, paired with the path that derives it. +#[derive(Debug, Clone)] +pub struct Draw { + /// Derivation path: `//`. + pub path: String, + /// The derived bytes. + pub value: Vec, +} + +/// The per-ceremony entropy source. +/// +/// Owns the running epoch seed, the epoch index, and the set of paths already +/// issued. Held by the [`Reporter`](crate::Reporter), which records a fact for +/// every gather and every draw so the source can be reconstructed from the +/// transcript. +/// +/// The issued-paths set is internal runtime state only, never serialized; it +/// exists so a repeated `(step, purpose)` is rejected rather than silently +/// reusing a value. +#[derive(Debug, Clone)] +pub struct CeremonyRandom { + seed_epoch: [u8; SEED_LEN], + epoch: u32, + issued: HashSet, +} + +impl CeremonyRandom { + /// Build a source from gathered machine entropy `m` (epoch 0). + #[must_use] + pub fn from_machine_seed(m: &[u8]) -> Self { + Self { + seed_epoch: initial_seed(m), + epoch: 0, + issued: HashSet::new(), + } + } + + /// The current epoch index (0 before any human contribution). + #[must_use] + pub fn epoch(&self) -> u32 { + self.epoch + } + + /// Fold a human contribution into the seed, advancing the epoch. + pub fn fold(&mut self, contribution: &[u8]) { + self.seed_epoch = fold_seed(&self.seed_epoch, contribution); + self.epoch = self.epoch.saturating_add(1); + } + + /// Draw `len` bytes for `(step, purpose)`. + /// + /// A `purpose` is drawn at most once per step. Returns `None` if this + /// `(step, purpose)` was already drawn, since deriving it again would reuse + /// the value; the caller turns that into a hard error. On success returns + /// the value together with its derivation path so the caller can record + /// both in the transcript. + pub fn draw(&mut self, step: &StepId, purpose: &str, len: usize) -> Option { + let path = build_path(self.epoch, step, purpose); + if !self.issued.insert(path.clone()) { + return None; + } + let value = derive_value(&self.seed_epoch, &path, len); + Some(Draw { path, value }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn step(id: &str) -> StepId { + StepId::new(id) + } + + #[test] + fn derivation_is_reproducible_from_recorded_inputs() { + let m = b"machine entropy"; + let seed = initial_seed(m); + // A verifier with only `m` and the path recomputes the same value. + let path = build_path(0, &step("issue"), "cert-serial"); + let a = derive_value(&seed, &path, 9); + let b = derive_value(&initial_seed(m), &path, 9); + assert_eq!(a, b); + assert_eq!(a.len(), 9); + } + + #[test] + fn different_paths_yield_different_values() { + let seed = initial_seed(b"m"); + let vp = derive_value(&seed, &build_path(0, &step("s"), "p"), 16); + let vq = derive_value(&seed, &build_path(0, &step("s"), "q"), 16); + let vs2 = derive_value(&seed, &build_path(0, &step("s2"), "p"), 16); + assert_ne!(vp, vq); + assert_ne!(vp, vs2); + } + + #[test] + fn draw_rejects_a_repeated_purpose_in_the_same_step() { + let mut r = CeremonyRandom::from_machine_seed(b"m"); + let first = r.draw(&step("s"), "tpm-quote", 20).expect("first draw"); + assert_eq!(first.path, "0/s/tpm-quote"); + // The same (step, purpose) again would reuse the value: rejected. + assert!(r.draw(&step("s"), "tpm-quote", 20).is_none()); + // A different purpose under the same step is fine. + let other = r + .draw(&step("s"), "cert-serial", 9) + .expect("distinct purpose"); + assert_eq!(other.path, "0/s/cert-serial"); + assert_ne!(first.value, other.value); + } + + #[test] + fn folding_advances_epoch_and_changes_draws() { + let mut plain = CeremonyRandom::from_machine_seed(b"m"); + let mut folded = CeremonyRandom::from_machine_seed(b"m"); + folded.fold(b"3 1 6 4 2 5"); + assert_eq!(plain.epoch(), 0); + assert_eq!(folded.epoch(), 1); + + let p = plain.draw(&step("s"), "p", 16).expect("draw"); + let f = folded.draw(&step("s"), "p", 16).expect("draw"); + // Same step/purpose, different epoch seed: different value and path. + assert_eq!(p.path, "0/s/p"); + assert_eq!(f.path, "1/s/p"); + assert_ne!(p.value, f.value); + } + + #[test] + fn fold_is_a_deterministic_function_of_inputs() { + // Two sources fed the same machine seed and the same contribution in + // the same order must derive identically: the verifier's replay. + let mut a = CeremonyRandom::from_machine_seed(b"seed"); + let mut b = CeremonyRandom::from_machine_seed(b"seed"); + a.fold(b"alice"); + b.fold(b"alice"); + assert_eq!( + a.draw(&step("s"), "p", 32).expect("draw").value, + b.draw(&step("s"), "p", 32).expect("draw").value + ); + } + + #[test] + fn empty_contribution_still_advances_but_never_panics() { + // A weak or empty human input is the HMAC message, not the key, so it + // is always safe to accept. + let mut r = CeremonyRandom::from_machine_seed(b"m"); + r.fold(b""); + assert_eq!(r.epoch(), 1); + assert_eq!(r.draw(&step("s"), "p", 8).expect("draw").value.len(), 8); + } +} diff --git a/crates/rite-runtime/src/executor.rs b/crates/rite-runtime/src/executor.rs index ca6d6a7..0ba5070 100644 --- a/crates/rite-runtime/src/executor.rs +++ b/crates/rite-runtime/src/executor.rs @@ -45,6 +45,10 @@ pub enum ExecutionError { #[error("Invalid params: {0}")] InvalidParams(String), + /// The host operating system failed to provide entropy for the seed. + #[error("Failed to gather machine entropy: {0}")] + EntropyError(String), + /// A material could not be loaded. #[error("Failed to load material '{name}': {reason}")] MaterialLoadFailed { diff --git a/crates/rite-runtime/src/lib.rs b/crates/rite-runtime/src/lib.rs index a7d0c8e..4e2b5a3 100644 --- a/crates/rite-runtime/src/lib.rs +++ b/crates/rite-runtime/src/lib.rs @@ -24,6 +24,7 @@ mod actions; mod artifact_resolver; mod backend; mod display; +mod entropy; mod executor; mod expressions; mod output_config; @@ -63,6 +64,10 @@ pub use system_info::{ // Shared formatter for live frontends. pub use display::{fact_summary, signal_summary, truncate_for_display}; +// Entropy source: the single auditable source of ceremony randomness, plus +// the pure `rite-kdf/v1` derivation used by both the draw path and `rite verify`. +pub use entropy::{CeremonyRandom, DERIVATION_V1, Draw, derive_value, fold_seed, initial_seed}; + // Reporter: action-facing handle for facts, signals, and prompts. pub use reporter::{Reporter, ReporterError}; @@ -71,8 +76,8 @@ pub use runner::{Action, ActionError, ActionRegistry, ExecutionSummary, Executor // Transcript sink, the durable consumer of `StepFact`s. pub use transcript_sink::{ - InMemorySink, JsonlFileSink, LoadedTranscript, TranscriptFingerprint, TranscriptSink, - TranscriptVerified, VerifyError, read_verified_transcript, + EntropyVerified, InMemorySink, JsonlFileSink, LoadedTranscript, TranscriptFingerprint, + TranscriptSink, TranscriptVerified, VerifyError, read_verified_transcript, verify_entropy, verify_transcript as verify_step_fact_transcript, }; diff --git a/crates/rite-runtime/src/reporter.rs b/crates/rite-runtime/src/reporter.rs index 72b6589..85f364e 100644 --- a/crates/rite-runtime/src/reporter.rs +++ b/crates/rite-runtime/src/reporter.rs @@ -29,10 +29,16 @@ use rite_model::{ErrorRecord, Prompt, ResponseRecord, StepFact, StepId}; use secrecy::ExposeSecret; use thiserror::Error; +use crate::entropy::CeremonyRandom; use crate::protocol::{ExecEvent, Icon, PromptId, Response, UiCommand, UiSignal}; use crate::transcript::sha256_hex; use crate::transcript_sink::TranscriptSink; +/// Placeholder seed installed by [`Reporter::new`] and replaced by +/// [`Reporter::seed_entropy`] at ceremony start. Never used for a real draw: +/// the runner always seeds the source before the first step can run. +const UNSEEDED_PLACEHOLDER: &[u8] = b"rite/uninitialized-entropy"; + /// Errors that can arise while emitting events or awaiting a response. #[derive(Debug, Error)] pub enum ReporterError { @@ -50,6 +56,16 @@ pub enum ReporterError { /// Reporter was asked to emit a step-scoped signal but no step is set. #[error("internal: no current step set for {0}")] NoCurrentStep(&'static str), + /// An action drew the same `(step, purpose)` twice. A purpose is drawn at + /// most once per step; a repeat would reuse the value, so it is a bug in + /// the action and fails the ceremony. + #[error("duplicate entropy draw for purpose '{purpose}' in step '{step}'")] + DuplicateDraw { + /// Step that issued the duplicate draw. + step: StepId, + /// Purpose drawn more than once. + purpose: String, + }, } impl ReporterError { @@ -61,6 +77,7 @@ impl ReporterError { ReporterError::Disconnected => "frontend_disconnected", ReporterError::Transcript(_) => "transcript_io", ReporterError::NoCurrentStep(_) => "internal_no_current_step", + ReporterError::DuplicateDraw { .. } => "duplicate_entropy_draw", }; ErrorRecord::new(kind, self.to_string()) } @@ -73,6 +90,9 @@ pub struct Reporter<'a> { transcript: &'a mut dyn TranscriptSink, next_prompt_id: u64, current_step: Option, + /// The ceremony entropy source: the single auditable origin of every + /// random value drawn during the run. Seeded by the runner at start. + random: CeremonyRandom, } impl<'a> Reporter<'a> { @@ -88,9 +108,20 @@ impl<'a> Reporter<'a> { transcript, next_prompt_id: 0, current_step: None, + random: CeremonyRandom::from_machine_seed(UNSEEDED_PLACEHOLDER), } } + /// Install the machine seed for the entropy source. + /// + /// Called once by the runner at ceremony start, before any step can draw, + /// replacing the placeholder seed from [`Reporter::new`]. The caller is + /// responsible for recording the corresponding [`StepFact::EntropySeeded`] + /// so the source stays reconstructible from the transcript. + pub fn seed_entropy(&mut self, m: &[u8]) { + self.random = CeremonyRandom::from_machine_seed(m); + } + /// Set the step that subsequent log lines and progress signals are /// attributed to. Called by the executor at each step boundary. pub fn set_current_step(&mut self, step: Option) { @@ -124,6 +155,66 @@ impl<'a> Reporter<'a> { Ok(()) } + /// Draw `len` bytes from the ceremony entropy source for `purpose`. + /// + /// The generic primitive behind every nonce, certificate serial, and + /// challenge: the caller names a purpose, the source derives the value + /// from the current epoch seed and records an [`StepFact::EntropyDrawn`] + /// carrying the derivation path and value, so `rite verify` can re-derive + /// it. Requires a current step (the path is step-scoped). + /// + /// # Errors + /// + /// [`ReporterError::NoCurrentStep`] if called outside a step, or + /// [`ReporterError::Transcript`] / [`ReporterError::Disconnected`] if the + /// fact cannot be emitted. + pub fn draw(&mut self, purpose: &str, len: usize) -> Result, ReporterError> { + let step = self + .current_step + .clone() + .ok_or(ReporterError::NoCurrentStep("draw"))?; + let drawn = + self.random + .draw(&step, purpose, len) + .ok_or_else(|| ReporterError::DuplicateDraw { + step: step.clone(), + purpose: purpose.to_string(), + })?; + self.fact(StepFact::EntropyDrawn { + step, + path: drawn.path, + len, + value: base16ct::lower::encode_string(&drawn.value), + })?; + Ok(drawn.value) + } + + /// Fold a human entropy contribution into the seed, advancing the ratchet. + /// + /// The contribution is public, witnessed entropy (the HMAC *message* keyed + /// by the prior seed, never a secret), so any input is safe and only adds + /// unpredictability. Records an [`StepFact::EntropyContributed`] carrying + /// the verbatim contribution and the new epoch, so the chain re-folds + /// identically on verification. Requires a current step. + /// + /// # Errors + /// + /// [`ReporterError::NoCurrentStep`] if called outside a step, or + /// [`ReporterError::Transcript`] / [`ReporterError::Disconnected`] if the + /// fact cannot be emitted. + pub fn fold_entropy(&mut self, contribution: &str) -> Result<(), ReporterError> { + let step = self + .current_step + .clone() + .ok_or(ReporterError::NoCurrentStep("fold_entropy"))?; + self.random.fold(contribution.as_bytes()); + self.fact(StepFact::EntropyContributed { + step, + epoch: self.random.epoch(), + contribution: contribution.to_string(), + }) + } + /// Emit a raw [`UiSignal`]. Never recorded to the transcript. /// /// Signals are ephemeral operator-assistance: a frontend may drop any of diff --git a/crates/rite-runtime/src/runner.rs b/crates/rite-runtime/src/runner.rs index 1e8cd2d..1625eb9 100644 --- a/crates/rite-runtime/src/runner.rs +++ b/crates/rite-runtime/src/runner.rs @@ -36,12 +36,15 @@ use std::sync::Arc; use chrono::Utc; use crossbeam_channel::{Receiver, Sender}; +use rand::TryRng; +use rand::rngs::SysRng; use rite_model::{ActId, ActionType, ArtifactId, Ceremony, MaterialId, OutputId, ParamId, RoleId}; use rite_sdk::{Backend, BackendError}; use thiserror::Error; use crate::actions::ActionMetadata; use crate::backend::BackendRegistry; +use crate::entropy::DERIVATION_V1; use crate::executor::{ ExecutionError, load_material_artifact, step_info_from, write_artifact_to_disk, }; @@ -55,6 +58,27 @@ use crate::system_info::StartupSnapshot; use crate::transcript_sink::{TranscriptFingerprint, TranscriptSink}; use rite_model::{ErrorRecord, Prompt, StepFact, StepOutcome}; +/// Gather the machine entropy `m` that seeds the ceremony entropy source. +/// +/// Sourced directly from the host OS RNG ([`SysRng`]), independent of any +/// ceremony backend, so a device the ceremony later challenges cannot +/// influence its own challenge nonce. A dry run instead returns a fixed, +/// clearly-labelled sentinel so a re-derived value can never be mistaken for +/// one produced under real entropy. +fn gather_machine_entropy(dry_run: bool) -> Result<([u8; 32], String), ExecutionError> { + let mut m = [0u8; 32]; + if dry_run { + for (slot, byte) in m.iter_mut().zip(b"rite-dry-run-not-real-entropy") { + *slot = *byte; + } + return Ok((m, "dry-run".to_string())); + } + SysRng + .try_fill_bytes(&mut m) + .map_err(|e| ExecutionError::EntropyError(e.to_string()))?; + Ok((m, "os".to_string())) +} + /// Errors that may surface from an [`Action`] handler. #[derive(Debug, Error)] pub enum ActionError { @@ -82,7 +106,9 @@ impl From for ActionError { ReporterError::Aborted => ActionError::Aborted, ReporterError::Disconnected => ActionError::Disconnected, ReporterError::Transcript(e) => ActionError::Transcript(e), - ReporterError::NoCurrentStep(_) => ActionError::Failed(value.to_string()), + ReporterError::NoCurrentStep(_) | ReporterError::DuplicateDraw { .. } => { + ActionError::Failed(value.to_string()) + } } } } @@ -111,6 +137,7 @@ impl From for ExecutionError { } ReporterError::Transcript(e) => ExecutionError::TranscriptError(e.to_string()), ReporterError::NoCurrentStep(_) => ExecutionError::TranscriptError(value.to_string()), + ReporterError::DuplicateDraw { .. } => ExecutionError::EntropyError(value.to_string()), } } } @@ -340,6 +367,18 @@ impl Executor { started_at: Utc::now(), })?; + // Establish the ceremony entropy source before any step can draw from + // it. This machine seed is run-metadata (the runner-emitted exception + // to "facts come from actions"); human contributions, by contrast, are + // authored `gather_entropy` steps that fold into the ratchet later. + let (m, source) = gather_machine_entropy(dry_run)?; + reporter.seed_entropy(&m); + reporter.fact(StepFact::EntropySeeded { + m: base16ct::lower::encode_string(&m), + source, + derivation: DERIVATION_V1.to_string(), + })?; + // Pre-ceremony overview: descriptive metadata for the UI's // Overview screen. Sent as a UI-only signal, not a transcript // fact, because the YAML is the source of truth for these fields @@ -552,6 +591,7 @@ impl ExecutionError { ExecutionError::StepFailed { .. } => "step_failed", ExecutionError::UnknownAction(_) => "unknown_action", ExecutionError::InvalidParams(_) => "invalid_params", + ExecutionError::EntropyError(_) => "entropy_error", ExecutionError::MaterialLoadFailed { .. } => "material_load_failed", ExecutionError::OutputWriteFailed { .. } => "output_write_failed", ExecutionError::TranscriptError(_) => "transcript_error", @@ -689,6 +729,7 @@ sections: kinds, vec![ "ceremony_started", + "entropy_seeded", "step_started", "step_completed", "ceremony_completed", @@ -913,6 +954,9 @@ sections: StepFact::StepCompleted { .. } => "step_completed", StepFact::CeremonyCompleted { .. } => "ceremony_completed", StepFact::CeremonyFailed { .. } => "ceremony_failed", + StepFact::EntropySeeded { .. } => "entropy_seeded", + StepFact::EntropyContributed { .. } => "entropy_contributed", + StepFact::EntropyDrawn { .. } => "entropy_drawn", _ => "unknown", } } diff --git a/crates/rite-runtime/src/transcript_sink.rs b/crates/rite-runtime/src/transcript_sink.rs index 8b2a219..ae3636b 100644 --- a/crates/rite-runtime/src/transcript_sink.rs +++ b/crates/rite-runtime/src/transcript_sink.rs @@ -292,6 +292,24 @@ pub enum VerifyError { /// The transcript file has zero lines. #[error("transcript is empty")] Empty, + /// A value was drawn (or a contribution folded) before any + /// `EntropySeeded` fact established the source. + #[error("entropy source used before it was seeded")] + SeedMissing, + /// The seed fact declares a derivation scheme the verifier does not + /// recognise. Never trust an unknown scheme (the JWT-`alg` lesson). + #[error("unknown entropy derivation scheme: {0}")] + UnknownDerivation(String), + /// A recorded hex value (the seed `m` or a drawn value) did not decode. + #[error("malformed entropy value: {0}")] + MalformedEntropy(String), + /// A drawn value does not match what re-derivation from the recorded + /// seed and path produces: the source was tampered with. + #[error("entropy draw at path '{path}' does not match re-derived value")] + DrawMismatch { + /// Derivation path of the mismatched draw. + path: String, + }, } /// Verify a JSONL transcript file produced by [`JsonlFileSink`]. @@ -377,6 +395,73 @@ pub fn read_verified_transcript(jsonl_path: &Path) -> Result, + /// Number of human contributions folded into the ratchet. + pub contributions: usize, + /// Number of drawn values that re-derived to their recorded value. + pub values_verified: usize, +} + +/// Re-derive a transcript's entropy source and confirm every drawn value. +/// +/// Replays the `rite-kdf/v1` ratchet over the recorded facts: it rebuilds +/// `seed_0` from the recorded machine entropy, folds each human contribution +/// in chain order, and for every [`StepFact::EntropyDrawn`] re-derives the +/// value from the recorded path and checks it against the recorded value. A +/// tampered seed, contribution, path, or value fails the check. +/// +/// The caller is expected to have chain-verified the facts first (via +/// [`read_verified_transcript`]); this function only re-derives. +/// +/// # Errors +/// +/// Returns a [`VerifyError`] variant describing the first inconsistency: +/// [`VerifyError::SeedMissing`], [`VerifyError::UnknownDerivation`], +/// [`VerifyError::MalformedEntropy`], or [`VerifyError::DrawMismatch`]. +pub fn verify_entropy(facts: &[StepFact]) -> Result { + let mut seed: Option<[u8; 32]> = None; + let mut result = EntropyVerified::default(); + + for fact in facts { + match fact { + StepFact::EntropySeeded { m, derivation, .. } => { + 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()); + } + StepFact::EntropyContributed { contribution, .. } => { + let current = seed.as_ref().ok_or(VerifyError::SeedMissing)?; + seed = Some(crate::entropy::fold_seed(current, contribution.as_bytes())); + result.contributions = result.contributions.saturating_add(1); + } + StepFact::EntropyDrawn { + path, len, value, .. + } => { + let current = seed.as_ref().ok_or(VerifyError::SeedMissing)?; + let expected = base16ct::lower::encode_string(&crate::entropy::derive_value( + current, path, *len, + )); + if &expected != value { + return Err(VerifyError::DrawMismatch { path: path.clone() }); + } + result.values_verified = result.values_verified.saturating_add(1); + } + _ => {} + } + } + + Ok(result) +} + #[cfg(test)] mod tests { use chrono::Utc; @@ -501,4 +586,89 @@ mod tests { 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(), + len, + 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 = vec![ + seeded_fact(m), + drawn_fact(&seed, "issue", "0/issue/cert-serial", 9), + ]; + let v = verify_entropy(&facts).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 = vec![ + seeded_fact(m), + StepFact::EntropyContributed { + step: StepId::new("roll"), + epoch: 1, + contribution: "3 1 6 4 2 5".to_string(), + }, + // Drawn after the fold, so it must derive from the epoch-1 seed. + drawn_fact(&seed1, "issue", "1/issue/cert-serial", 9), + ]; + let v = verify_entropy(&facts).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 = vec![seeded_fact(m), drawn]; + let err = verify_entropy(&facts).expect_err("tamper"); + assert!(matches!(err, VerifyError::DrawMismatch { .. })); + } + + #[test] + fn verify_entropy_rejects_unknown_derivation() { + let facts = vec![StepFact::EntropySeeded { + m: "00".to_string(), + source: "os".to_string(), + derivation: "rite-kdf/v99".to_string(), + }]; + let err = verify_entropy(&facts).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 = vec![drawn_fact(&seed, "issue", "0/issue/cert-serial", 9)]; + let err = verify_entropy(&facts).expect_err("unseeded"); + assert!(matches!(err, VerifyError::SeedMissing)); + } } diff --git a/crates/rite-stdlib/Cargo.toml b/crates/rite-stdlib/Cargo.toml index 37cc793..dfb1e2a 100644 --- a/crates/rite-stdlib/Cargo.toml +++ b/crates/rite-stdlib/Cargo.toml @@ -16,7 +16,7 @@ default = ["verification", "attestation", "crypto", "pki", "openssl"] verification = ["dep:subtle", "dep:sysinfo"] attestation = [] crypto = [] -pki = ["dep:x509-cert", "dep:der", "dep:sha1", "dep:rsa", "dep:p256", "dep:rand"] +pki = ["dep:x509-cert", "dep:der", "dep:sha1", "dep:rsa", "dep:p256"] openssl = ["dep:rite-openssl"] openssl-vendored = ["openssl", "rite-openssl/vendored"] full = ["verification", "attestation", "crypto", "pki", "openssl"] @@ -34,7 +34,6 @@ sha2 = { workspace = true } subtle = { workspace = true, optional = true } sysinfo = { workspace = true, optional = true } -rand = { workspace = true, optional = true } x509-cert = { workspace = true, optional = true } der = { workspace = true, optional = true } sha1 = { workspace = true, optional = true } diff --git a/crates/rite-stdlib/src/entropy/gather.rs b/crates/rite-stdlib/src/entropy/gather.rs new file mode 100644 index 0000000..e935857 --- /dev/null +++ b/crates/rite-stdlib/src/entropy/gather.rs @@ -0,0 +1,86 @@ +//! `gather_entropy` action, fold human-supplied entropy into the ceremony seed. + +use rite_model::{ActionType, Prompt, ValidatorSpec}; +use rite_runtime::{ + Action, ActionCategory, ActionError, ActionMetadata, HandlerContext, Icon, Reporter, Response, + StepInfo, StepResult, parse_params, +}; +use rite_sdk::Backend; + +use crate::params::GatherEntropyParams; + +/// Default instruction. Dice is only a suggestion: the operator may type any +/// random value, and the method can be made mandatory by overriding +/// `instruction` in the ceremony. +const DEFAULT_INSTRUCTION: &str = + "Generate a random value, for example roll a die 10 times and type the result."; + +/// Placeholder contribution folded during a dry run, where no operator is +/// prompted. Deterministic, so a dry-run transcript still re-derives. +const DRY_RUN_CONTRIBUTION: &str = "dry-run-entropy"; + +/// Fold a human entropy contribution into the ceremony entropy source. +/// +/// Prompts the assigned participant for a free-form random value and mixes it +/// into the seed ratchet via [`Reporter::fold_entropy`], advancing the epoch. +/// The contribution is public, witnessed entropy: any input is safe and only +/// adds unpredictability, so it is validated non-empty but never rejected. +pub struct GatherEntropyAction; + +impl Action for GatherEntropyAction { + fn metadata(&self) -> ActionMetadata { + ActionMetadata { + action_type: ActionType::GatherEntropy, + description: "Gather human entropy into the ceremony seed", + category: ActionCategory::Verification, + } + } + + fn apply_defaults(&self, params: &mut serde_json::Value, _step: &StepInfo) { + if !params.is_object() { + *params = serde_json::json!({}); + } + if let Some(obj) = params.as_object_mut() { + obj.entry("instruction".to_string()) + .or_insert_with(|| serde_json::json!(DEFAULT_INSTRUCTION)); + } + } + + fn execute( + &self, + step: &StepInfo, + ctx: &HandlerContext, + params: &serde_json::Value, + reporter: &mut Reporter<'_>, + _backend: Option<&mut dyn Backend>, + ) -> Result { + let typed: GatherEntropyParams = parse_params(params)?; + let instruction = typed + .instruction + .unwrap_or_else(|| DEFAULT_INSTRUCTION.to_string()); + + let role_display = ctx.resolve_role_name(step.role_str().unwrap_or("Participant")); + + reporter.log(Icon::Info, instruction.clone())?; + + if ctx.dry_run { + reporter.log(Icon::Info, "[dry run, folding placeholder entropy]")?; + reporter.fold_entropy(DRY_RUN_CONTRIBUTION)?; + } else { + let response = reporter.prompt(&Prompt::Text { + label: instruction, + validator: ValidatorSpec::NonEmpty, + })?; + let Response::Text(contribution) = response else { + return Err(ActionError::Failed( + "expected a text response for the entropy contribution".to_string(), + )); + }; + reporter.fold_entropy(&contribution)?; + } + + Ok(StepResult::completed(format!( + "Entropy contribution recorded for {role_display}" + ))) + } +} diff --git a/crates/rite-stdlib/src/entropy/mod.rs b/crates/rite-stdlib/src/entropy/mod.rs new file mode 100644 index 0000000..e5dc1f0 --- /dev/null +++ b/crates/rite-stdlib/src/entropy/mod.rs @@ -0,0 +1,5 @@ +//! Entropy action handlers. + +mod gather; + +pub use gather::GatherEntropyAction; diff --git a/crates/rite-stdlib/src/lib.rs b/crates/rite-stdlib/src/lib.rs index 04063ea..5eb0e26 100644 --- a/crates/rite-stdlib/src/lib.rs +++ b/crates/rite-stdlib/src/lib.rs @@ -18,7 +18,7 @@ //! - `verification`: verification actions (requires `subtle`, `sysinfo`) //! - `attestation`: attestation recording //! - `crypto`: crypto actions (`generate_keypair`, `export_public`, `wrap_key`, `unwrap_key`) -//! - `pki`: PKI actions (`generate_csr`, `issue_certificate`; requires `x509-cert`, `der`, `sha1`, `rsa`, `p256`, `rand`) +//! - `pki`: PKI actions (`generate_csr`, `issue_certificate`; requires `x509-cert`, `der`, `sha1`, `rsa`, `p256`) //! - `default`: all features enabled //! //! # Usage @@ -44,6 +44,7 @@ mod params; pub mod attestation; #[cfg(feature = "crypto")] pub mod crypto; +pub mod entropy; #[cfg(feature = "pki")] pub mod pki; #[cfg(feature = "verification")] @@ -59,6 +60,7 @@ pub use backend::{MockBackend, create_backend, default_backend_factory}; pub use attestation::AttestAction; #[cfg(feature = "crypto")] pub use crypto::{ExportPublicAction, GenerateKeypairAction, UnwrapKeyAction, WrapKeyAction}; +pub use entropy::GatherEntropyAction; #[cfg(feature = "pki")] pub use pki::{GenerateCsrAction, IssueCertificateAction}; #[cfg(feature = "verification")] @@ -92,6 +94,10 @@ pub fn register_stdlib(registry: &mut ActionRegistry) { registry.register(Arc::new(AttestAction)); } + // Human-entropy gathering has no optional dependencies, so it is always + // available rather than gated behind a feature. + registry.register(Arc::new(GatherEntropyAction)); + #[cfg(feature = "crypto")] { registry.register(Arc::new(GenerateKeypairAction)); diff --git a/crates/rite-stdlib/src/params.rs b/crates/rite-stdlib/src/params.rs index 8fa0799..229b100 100644 --- a/crates/rite-stdlib/src/params.rs +++ b/crates/rite-stdlib/src/params.rs @@ -125,6 +125,16 @@ pub struct AttestParams { pub statement: Option, } +/// Params for `gather_entropy` action. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct GatherEntropyParams { + /// Instruction shown to the participant describing how to produce the + /// random value. Defaults to a generic dice suggestion; override per + /// ceremony to mandate a specific method. + #[serde(default)] + pub instruction: Option, +} + /// Params for `generate_keypair` action. #[cfg(feature = "crypto")] #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/crates/rite-stdlib/src/pki/issue_certificate.rs b/crates/rite-stdlib/src/pki/issue_certificate.rs index 11e016e..c1a0eae 100644 --- a/crates/rite-stdlib/src/pki/issue_certificate.rs +++ b/crates/rite-stdlib/src/pki/issue_certificate.rs @@ -170,8 +170,13 @@ impl Action for IssueCertificateAction { .map_err(|e| ActionError::Failed(format!("Invalid issuer CN: {e}")))? }; - let serial = build_serial() - .map_err(|e| ActionError::Failed(format!("Failed to generate serial number: {e}")))?; + // Draw the serial from the ceremony entropy source rather than an + // unrecorded RNG, so it is auditable and re-derivable by `rite verify`. + // Nine bytes keep the serial comfortably above the 64-bit guidance even + // after the sign bit is cleared. + let serial_bytes = reporter.draw("cert-serial", 9)?; + let serial = build_serial(&serial_bytes) + .map_err(|e| ActionError::Failed(format!("Failed to build serial number: {e}")))?; let validity = build_validity(validity_days) .map_err(|e| ActionError::Failed(format!("Failed to build validity period: {e}")))?; @@ -534,13 +539,19 @@ fn parse_certificate(bytes: &[u8]) -> Result { } } -/// Build a random 8-byte positive serial number. -fn build_serial() -> Result { - let value = rand::random::() & 0x7FFF_FFFF_FFFF_FFFFu64 | 0x0000_0000_0000_0001u64; - let bytes = value.to_be_bytes(); - let start = bytes.iter().position(|&b| b != 0).unwrap_or(0); - let serial_bytes = bytes.get(start..).unwrap_or(&bytes); - SerialNumber::new(serial_bytes) +/// Build a DER-safe positive serial number from source-drawn bytes. +/// +/// The bytes come from the ceremony entropy source, so the serial is auditable +/// and re-derivable rather than minted from an unrecorded RNG. The top bit of +/// the leading byte is cleared to keep the DER INTEGER positive, and the +/// leading byte is forced non-zero so the serial keeps its full width (>= 64 +/// bits, per CA/Browser Forum guidance) instead of collapsing to a shorter one. +fn build_serial(bytes: &[u8]) -> Result { + let mut serial_bytes = bytes.to_vec(); + if let Some(first) = serial_bytes.first_mut() { + *first = (*first & 0x7F) | 0x40; + } + SerialNumber::new(&serial_bytes) } fn build_validity(validity_days: u32) -> Result { diff --git a/crates/rite/src/verify.rs b/crates/rite/src/verify.rs index 48bd056..f48c76d 100644 --- a/crates/rite/src/verify.rs +++ b/crates/rite/src/verify.rs @@ -3,7 +3,7 @@ use std::path::PathBuf; use clap::Args as ClapArgs; -use rite_runtime::{VerifyError, verify_step_fact_transcript}; +use rite_runtime::{VerifyError, read_verified_transcript, verify_entropy}; #[derive(ClapArgs, Debug)] pub struct Args { @@ -19,12 +19,29 @@ pub fn run(args: Args) { (args.file, None) }; - match verify_step_fact_transcript(&transcript_path) { - Ok(verified) => { + match read_verified_transcript(&transcript_path) { + Ok(loaded) => { + // The hash chain is intact. Now re-derive the entropy source so + // every recorded random value is proven to come from the recorded + // seed, not cherry-picked. + let entropy = match verify_entropy(&loaded.facts) { + Ok(entropy) => entropy, + Err(err) => { + eprintln!("Verification failed: {err}"); + std::process::exit(1); + } + }; + println!("Transcript verified."); - println!(" Facts: {}", verified.fact_count); - println!(" Fingerprint: {}", verified.fingerprint); - if !verified.terminated { + println!(" Facts: {}", loaded.facts.len()); + println!(" Fingerprint: {}", loaded.fingerprint); + if let Some(scheme) = entropy.derivation { + println!( + " Entropy: {} value(s) re-derived, {} contribution(s) folded ({scheme})", + entropy.values_verified, entropy.contributions, + ); + } + if !loaded.terminated { eprintln!( " Warning: transcript is truncated, no CeremonyCompleted or \ CeremonyFailed fact at the end." diff --git a/docs/development/entropy-and-verification.md b/docs/development/entropy-and-verification.md new file mode 100644 index 0000000..318a625 --- /dev/null +++ b/docs/development/entropy-and-verification.md @@ -0,0 +1,112 @@ +# Ceremony entropy and verifiable randomness + +Every random value a ceremony consumes (a certificate serial, a nonce, a +challenge) is drawn from one auditable source and recorded in the transcript, +so `rite verify` can re-derive it and confirm it was not cherry-picked. This +document specifies the `rite-kdf/v1` derivation so an independent verifier, in +any language, can reproduce every value from the transcript alone. + +## Two kinds of randomness + +- **Key-material entropy** is *not* ours to generate. Backends own the bytes + that become a private key; the tool records that a key was generated and its + fingerprint, never the entropy behind it. +- **Protocol values** (serials, nonces, challenges) must be fresh and + unpredictable but are *not secret*. These are what the source derives, + records, and lets `rite verify` re-check. + +## The source + +A ceremony holds one `CeremonyRandom` source, seeded once at the start from the +host OS RNG (`m`). Participants may then fold their own entropy into it with the +`gather_entropy` action (for example, by rolling physical dice). Each fold +advances an *epoch*. Any value is drawn from whichever epoch seed is current +when the draw happens. + +```mermaid +flowchart LR + m["m (OS RNG)"] -->|Extract| s0["seed_0"] + h1["h_1 (dice)"] -->|Extract| s1["seed_1"] + s0 --> s1 + s0 -->|Expand path| v0["value @ epoch 0"] + s1 -->|Expand path| v1["value @ epoch 1"] +``` + +Folding uses the prior seed as the HMAC key and the contribution as the +message, so a weak or empty contribution can never reduce strength below the +machine seed; an unpredictable one only adds. This is why any operator input is +safe to accept. + +## `rite-kdf/v1` + +All steps use HKDF-SHA-256 (RFC 5869). The scheme is identified by the +`derivation` tag on the `entropy_seeded` fact. A verifier treats the tag as a +selector among schemes it already trusts and **rejects any tag it does not +recognise** (it is never an instruction to trust an arbitrary algorithm). + +```text +seed_0 = HKDF-Extract(salt = "rite/seed/v1", IKM = m) +seed_{k+1} = HKDF-Extract(salt = seed_k, IKM = utf8(h_k)) +path = "//" +value(path) = HKDF-Expand(seed_epoch, info = "rite/nonce/v1/" || path, len) +``` + +Byte encodings, fixed so verifiers agree: + +- `m` and each drawn value are recorded as **lowercase hex**. +- Each contribution `h_k` is recorded as its **verbatim UTF-8 string** and fed + to HKDF-Extract as those UTF-8 bytes. +- The salt constants and the `info` prefix are their **ASCII bytes**, with no + trailing NUL. +- The `path` is its **ASCII string**; the `info` is the prefix concatenated + with the path, with no separator beyond the prefix's trailing `/`. +- `` is base-10 with no leading zeros; `` is the ceremony step id; + `` is the drawing action's label for the value (for example, + `cert-serial`). + +A purpose is drawn at most once per step, so `(epoch, step, purpose)` uniquely +identifies a value and no counter is needed. Drawing the same `(step, purpose)` +twice is rejected (it would reuse the value). An action that needs several +values of one kind indexes the purpose itself (for example, `share-1`, +`share-2`). + +## Transcript facts + +Three `StepFact` variants record the source; all carry lowercase-hex bytes +where bytes appear: + +- `entropy_seeded` — emitted once by the runner at ceremony start. Fields: + `m`, `source` (provenance label, e.g. `os`), `derivation`. +- `entropy_contributed` — emitted by a `gather_entropy` step. Fields: `step`, + `epoch` (the index produced by this fold), `contribution` (verbatim). +- `entropy_drawn` — emitted on every draw. Fields: `step`, `path`, `len`, + `value`. + +## Verification + +`rite verify` first checks the SHA-256 hash chain, then re-derives the source: + +1. Read `entropy_seeded`; reject an unknown `derivation`; rebuild `seed_0` from + the recorded `m`. +2. Walk the facts in chain order, folding each `entropy_contributed` into the + running seed. +3. For each `entropy_drawn`, recompute `HKDF-Expand(seed_epoch, info, len)` from + the recorded path and confirm it equals the recorded value. + +A tampered seed, contribution, path, or value fails the check. Because the +transcript is hash-chained, a naive byte edit is caught by the chain first; the +re-derivation additionally defeats a fully re-chained forgery whose values are +not genuinely seed-derived. + +## Dry runs + +A dry run seeds the source from a fixed, clearly labelled sentinel (`source: +dry-run`) so a re-derived dry-run value can never be mistaken for one +produced under real entropy. + +## Worked example + +`examples/entropy/dice.rite.yaml` folds a dice roll into the seed and then +issues a certificate, so the certificate serial is drawn from a seed the +operator helped shape. Running it and then `rite verify` re-derives the seed, +folds the dice contribution, and confirms the serial. diff --git a/examples/entropy/dice.rite.yaml b/examples/entropy/dice.rite.yaml new file mode 100644 index 0000000..73236bb --- /dev/null +++ b/examples/entropy/dice.rite.yaml @@ -0,0 +1,57 @@ +version: "0.2" +name: "Dice Entropy" +description: | + Showcase verifiable ceremony randomness. A participant folds a physical dice + roll into the ceremony seed, then a self-signed certificate is issued whose + serial number is drawn from that seed. `rite verify` later re-derives the + seed, the dice contribution, and the serial from the transcript alone, so no + one has to trust the machine's randomness on its own. + +backends: + openssl: + provider: openssl + +output: + certificate: + type: certificate + description: "Certificate whose serial number is drawn from the seed" + +roles: + operator: + name: "Operator" + person: "Alice Smith" + +sections: + ceremony: + name: "Witnessed Entropy" + role: ${role.operator} + steps: + roll_dice: + action: gather_entropy + with: + instruction: "Roll a physical die 10 times and type the sequence (for example: 3 1 6 4 2 5 2 6 1 4)." + generate_key: + action: generate_keypair + backend: openssl + with: + algorithm: ECDSA-P256 + key_usage: + - key_cert_sign + creates: keypair + generate_request: + action: generate_csr + backend: openssl + reads: + signing_key: ${artifact.keypair} + with: + subject: "CN=Example,O=Example Org" + creates: csr + issue_cert: + action: issue_certificate + backend: openssl + reads: + signing_key: ${artifact.keypair} + csr: ${artifact.csr} + with: + profile: root_ca + creates: certificate