use std::collections::BTreeMap;
use base64ct::Encoding as _;
use serde::{Deserialize, Serialize};
use zeroize::Zeroizing;
use crate::Sha256Digest;
use crate::ir::{ActId, MaterialId, ParamId, RoleId, StepId};
pub const TRANSCRIPT_FORMAT: u32 = 0;
pub const FACT_VOCABULARY: u32 = 0;
pub const TRANSCRIPT_SCHEMA: &str = concat!(
"https://ritely.io/schemas/",
env!("CARGO_PKG_VERSION"),
"/transcript.schema.json"
);
#[non_exhaustive]
#[cfg_attr(test, derive(schemars::JsonSchema))]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(
test,
schemars(
description = "What the file is. A reader checks `rite_transcript` before reading \
anything else, and refuses a version it does not know."
)
)]
pub struct TranscriptHeader {
#[cfg_attr(
test,
schemars(description = "Version of the line format and the chain rule.")
)]
pub rite_transcript: u32,
#[cfg_attr(
test,
schemars(
description = "Version of the fact vocabulary: which fact types and fields the \
transcript uses."
)
)]
pub vocabulary: u32,
#[cfg_attr(
test,
schemars(
description = "The program and version that wrote the transcript, as it reports \
itself. Informational: a program cannot vouch for its own identity."
)
)]
pub producer: String,
#[cfg_attr(
test,
schemars(
description = "A random identifier of this run: 32 lowercase hex digits.",
extend("pattern" = "^[0-9a-f]{32}$")
)
)]
pub run_id: String,
#[cfg_attr(
test,
schemars(
description = "Whether the run was a dry run. A dry-run transcript is a rehearsal \
record, never evidence of a ceremony."
)
)]
pub dry_run: bool,
#[cfg_attr(
test,
schemars(
description = "Every level the transcript uses, by name. It holds public, \
restricted and confidential at their fixed values, and any other level the ceremony \
declares. No two names share a value."
)
)]
pub levels: BTreeMap<String, Level>,
}
impl TranscriptHeader {
#[must_use]
pub fn new(producer: &str, run_id: &str, dry_run: bool) -> Self {
Self {
rite_transcript: TRANSCRIPT_FORMAT,
vocabulary: FACT_VOCABULARY,
producer: producer.to_string(),
run_id: run_id.to_string(),
dry_run,
levels: Level::BUILT_IN
.iter()
.map(|(name, level)| ((*name).to_string(), *level))
.collect(),
}
}
#[must_use]
pub fn declares(&self, level: Level) -> bool {
self.levels.values().any(|declared| *declared == level)
}
#[must_use]
pub fn level(&self, name_or_value: &str) -> Option<Level> {
if let Some(level) = self.levels.get(name_or_value) {
return Some(*level);
}
let level = Level::new(name_or_value.parse().ok()?);
self.declares(level).then_some(level)
}
}
#[cfg_attr(test, derive(schemars::JsonSchema))]
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(transparent)]
#[cfg_attr(
test,
schemars(
description = "The confidentiality level of a line: who may see its fact. Levels are \
integers ordered from the widest audience to the narrowest, so a disclosure up to a level \
withholds every line above it. Three levels are built in at fixed values: public (10, \
anyone), restricted (20, auditors under agreement) and confidential (30, the ceremony's \
own organisation). Every level a transcript uses is declared in its header."
)
)]
pub struct Level(u32);
impl Level {
pub const PUBLIC: Level = Level(10);
pub const RESTRICTED: Level = Level(20);
pub const CONFIDENTIAL: Level = Level(30);
pub const BUILT_IN: [(&'static str, Level); 3] = [
("public", Level::PUBLIC),
("restricted", Level::RESTRICTED),
("confidential", Level::CONFIDENTIAL),
];
#[must_use]
pub const fn new(value: u32) -> Self {
Self(value)
}
#[must_use]
pub const fn value(self) -> u32 {
self.0
}
}
impl std::fmt::Display for Level {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match Level::BUILT_IN.iter().find(|(_, level)| level == self) {
Some((name, _)) => f.write_str(name),
None => write!(f, "level {}", self.0),
}
}
}
#[non_exhaustive]
#[cfg_attr(test, derive(schemars::JsonSchema))]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
#[cfg_attr(test, schemars(description = "The check an answer had to pass."))]
pub enum ValidatorSpec {
#[cfg_attr(test, schemars(description = "The answer is not empty or blank."))]
NonEmpty,
#[cfg_attr(
test,
schemars(description = "The answer matches a regular expression in full.")
)]
Regex(#[cfg_attr(test, schemars(description = "The regular expression."))] String),
#[cfg_attr(
test,
schemars(description = "The answer passes a check the program defines, by name.")
)]
Predefined(
#[cfg_attr(
test,
schemars(description = "The name of the check, such as serial_number.")
)]
String,
),
#[cfg_attr(
test,
schemars(
description = "The answer is a value of one format, with a length within \
bounds, counted in characters for text and in bytes for an \
encoding."
)
)]
Format {
#[cfg_attr(test, schemars(description = "What kind of value the answer is."))]
format: Format,
#[cfg_attr(test, schemars(description = "The fewest units accepted, if bounded."))]
min_length: Option<usize>,
#[cfg_attr(test, schemars(description = "The most units accepted, if bounded."))]
max_length: Option<usize>,
},
}
#[non_exhaustive]
#[cfg_attr(test, derive(schemars::JsonSchema))]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[cfg_attr(test, schemars(description = "What kind of value a person types."))]
pub enum Format {
#[cfg_attr(test, schemars(description = "Anything the person can type."))]
Text,
#[cfg_attr(test, schemars(description = "Decimal digits only."))]
Digits,
#[cfg_attr(test, schemars(description = "ASCII letters and digits."))]
Alphanumeric,
#[cfg_attr(
test,
schemars(description = "Bytes as hexadecimal, in either case, two digits per byte.")
)]
Hex,
#[cfg_attr(test, schemars(description = "Bytes as standard base64 with padding."))]
Base64,
#[cfg_attr(
test,
schemars(
description = "Bytes as paper32 rows, typed a row at a time from a sheet, a wrong \
character in a row corrected."
)
)]
Paper32,
#[cfg_attr(
test,
schemars(
description = "A BIP-39 recovery phrase in the English list, typed a word at a \
time; the value is its entropy, and a length counts words."
)
)]
Bip39,
}
impl Format {
#[must_use]
pub fn is_encoding(self) -> bool {
match self {
Format::Text | Format::Digits | Format::Alphanumeric => false,
Format::Hex | Format::Base64 | Format::Paper32 | Format::Bip39 => true,
}
}
#[must_use]
pub fn rows(self) -> Option<crate::RevealFormat> {
match self {
Format::Paper32 => Some(crate::RevealFormat::Paper32),
Format::Bip39 => Some(crate::RevealFormat::Bip39),
Format::Text | Format::Digits | Format::Alphanumeric | Format::Hex | Format::Base64 => {
None
}
}
}
#[must_use]
pub fn length_of(self, bytes: usize) -> usize {
self.rows().map_or(bytes, |rows| rows.length_of(bytes))
}
fn accepts(self, c: char) -> bool {
match self {
Format::Text | Format::Hex | Format::Base64 | Format::Paper32 | Format::Bip39 => true,
Format::Digits => c.is_ascii_digit(),
Format::Alphanumeric => c.is_ascii_alphanumeric(),
}
}
pub fn decode(self, value: &str) -> Result<Vec<u8>, String> {
let compact: Zeroizing<String> =
Zeroizing::new(value.chars().filter(|c| !c.is_whitespace()).collect());
match self {
Format::Hex => base16ct::mixed::decode_vec(compact.as_bytes())
.map_err(|_| "value must be hex, two digits per byte".to_string()),
Format::Base64 => base64ct::Base64::decode_vec(&compact)
.map_err(|_| "value must be standard base64, with padding".to_string()),
Format::Paper32 => crate::paper32::decode(value)
.map(|mut decoded| std::mem::take(&mut *decoded.bytes))
.map_err(|e| e.to_string()),
Format::Bip39 => crate::bip39::decode(value)
.map(|mut decoded| std::mem::take(&mut *decoded.bytes))
.map_err(|e| e.to_string()),
Format::Text | Format::Digits | Format::Alphanumeric => {
Err(format!("{} is text and does not decode", self.describe()))
}
}
}
#[must_use]
pub fn describe(self) -> &'static str {
match self {
Format::Text => "characters",
Format::Digits => "digits",
Format::Alphanumeric => "letters or digits",
Format::Hex => "bytes as hex",
Format::Base64 => "bytes as base64",
Format::Paper32 => "bytes as paper32 rows",
Format::Bip39 => "BIP-39 words",
}
}
#[must_use]
pub fn placeholder(self, length: usize) -> Option<String> {
if length > PLACEHOLDER_LIMIT {
return None;
}
Some(match self {
Format::Text | Format::Alphanumeric => "x".repeat(length),
Format::Digits => "0".repeat(length),
Format::Hex => base16ct::lower::encode_string(&vec![0u8; length]),
Format::Base64 => base64ct::Base64::encode_string(&vec![0u8; length]),
Format::Paper32 => crate::paper32::encode(&vec![0u8; length]),
Format::Bip39 => {
let words = crate::bip39::phrase_lengths().find(|&n| n >= length)?;
crate::bip39::encode(&vec![0u8; crate::bip39::bytes_for(words)])
.ok()?
.to_string()
}
})
}
}
pub const PLACEHOLDER_LIMIT: usize = 64 * 1024;
impl std::str::FromStr for Format {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"text" => Ok(Format::Text),
"digits" => Ok(Format::Digits),
"alphanumeric" => Ok(Format::Alphanumeric),
"hex" => Ok(Format::Hex),
"base64" => Ok(Format::Base64),
"paper32" => Ok(Format::Paper32),
"bip39" => Ok(Format::Bip39),
other => Err(format!(
"unknown format '{other}': expected text, digits, alphanumeric, hex, base64, \
paper32 or bip39"
)),
}
}
}
impl ValidatorSpec {
pub fn check(&self, value: &str) -> Result<(), String> {
match self {
ValidatorSpec::NonEmpty => {
if value.trim().is_empty() {
Err("value must not be empty".to_string())
} else {
Ok(())
}
}
ValidatorSpec::Regex(pattern) => {
let regex = compile_pattern(pattern)?;
if regex.is_match(value) {
Ok(())
} else {
Err(format!("value must match {pattern}"))
}
}
ValidatorSpec::Predefined(name) => Err(format!("unknown validator: {name}")),
ValidatorSpec::Format {
format,
min_length,
max_length,
} => {
let hint = self.hint().unwrap_or_default();
if value.trim().is_empty() {
return Err("value must not be empty".to_string());
}
let length = if format.is_encoding() {
format.length_of(Zeroizing::new(format.decode(value)?).len())
} else {
if !value.chars().all(|c| format.accepts(c)) {
return Err(format!("value must be {hint}"));
}
value.chars().count()
};
if min_length.is_some_and(|min| length < min)
|| max_length.is_some_and(|max| length > max)
{
return Err(format!("value must be {hint}, this is {length}"));
}
Ok(())
}
}
}
#[must_use]
pub fn hint(&self) -> Option<String> {
match self {
ValidatorSpec::NonEmpty | ValidatorSpec::Predefined(_) => None,
ValidatorSpec::Regex(pattern) => Some(format!("matching {pattern}")),
ValidatorSpec::Format {
format,
min_length,
max_length,
} => {
let what = format.describe();
Some(match (min_length, max_length) {
(Some(min), Some(max)) if min == max => format!("{min} {what}"),
(Some(min), Some(max)) => format!("{min} to {max} {what}"),
(Some(min), None) => format!("at least {min} {what}"),
(None, Some(max)) => format!("at most {max} {what}"),
(None, None) if format.is_encoding() => what.to_string(),
(None, None) => format!("{what} only"),
})
}
}
}
}
pub fn compile_pattern(pattern: &str) -> Result<regex_lite::Regex, String> {
regex_lite::Regex::new(&format!("^(?:{pattern})$"))
.map_err(|e| format!("invalid pattern '{pattern}': {e}"))
}
#[non_exhaustive]
#[cfg_attr(test, derive(schemars::JsonSchema))]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
#[cfg_attr(test, schemars(description = "A prompt as it was shown."))]
pub enum Prompt {
#[cfg_attr(test, schemars(description = "A yes or no question."))]
Confirm {
#[cfg_attr(test, schemars(description = "The question."))]
question: String,
#[cfg_attr(
test,
schemars(
description = "The answer given when the person confirms without choosing, if \
any."
)
)]
default: Option<bool>,
},
#[cfg_attr(
test,
schemars(description = "A request for free text, checked before it is accepted.")
)]
Text {
#[cfg_attr(test, schemars(description = "The label shown."))]
label: String,
#[cfg_attr(test, schemars(description = "The check the answer had to pass."))]
validator: ValidatorSpec,
},
#[cfg_attr(
test,
schemars(
description = "A request for a secret, such as a PIN. The answer is never \
recorded."
)
)]
Secret {
#[cfg_attr(test, schemars(description = "The label shown."))]
label: String,
#[cfg_attr(test, schemars(description = "The check the answer had to pass."))]
validator: ValidatorSpec,
},
#[cfg_attr(
test,
schemars(
description = "A request to type a given text exactly, such as a confirmation \
phrase."
)
)]
Literal {
#[cfg_attr(test, schemars(description = "The label shown."))]
label: String,
#[cfg_attr(test, schemars(description = "The text that had to be typed."))]
expected: String,
},
#[cfg_attr(
test,
schemars(description = "A pause until the person is ready to continue.")
)]
Continue {
#[cfg_attr(test, schemars(description = "The hint shown, if any."))]
hint: Option<String>,
},
#[cfg_attr(
test,
schemars(
description = "A value shown for a person to write down, withdrawn when they \
acknowledge. The value is never recorded."
)
)]
Reveal {
#[cfg_attr(test, schemars(description = "The label shown above the value."))]
label: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(test, schemars(description = "The note shown with the value, if any."))]
note: Option<String>,
#[serde(skip)]
shown: Shown,
},
#[cfg_attr(
test,
schemars(
description = "A request for a value typed row by row from a sheet, each row \
checked as it is entered. The answer is never recorded."
)
)]
EnterRows {
#[cfg_attr(test, schemars(description = "The label shown above the rows."))]
label: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(test, schemars(description = "The note shown with the rows, if any."))]
note: Option<String>,
#[cfg_attr(test, schemars(description = "The encoding the sheet was written in."))]
format: crate::display::RevealFormat,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(
test,
schemars(
description = "The number of rows asked for, when the value's length is known. \
Without it a short row, or an empty row after full ones, ends the entry."
)
)]
rows: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(
test,
schemars(
description = "The check the whole value had to pass. Absent when the step \
checks the value itself, as for a share."
)
)]
validator: Option<ValidatorSpec>,
},
}
impl Prompt {
#[must_use]
pub fn for_record(&self) -> Self {
match self {
Prompt::Reveal { label, note, .. } => Prompt::Reveal {
label: label.clone(),
note: note.clone(),
shown: Shown::default(),
},
other => other.clone(),
}
}
}
#[derive(Clone, Default)]
pub struct Shown {
text: zeroize::Zeroizing<String>,
layout: crate::display::Layout,
}
impl Shown {
pub fn new(text: String, layout: crate::display::Layout) -> Self {
Self {
text: zeroize::Zeroizing::new(text),
layout,
}
}
pub fn text(&self) -> &str {
&self.text
}
pub fn layout(&self) -> &crate::display::Layout {
&self.layout
}
}
impl std::fmt::Debug for Shown {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"Shown({} characters, {})",
self.text.len(),
self.layout.format
)
}
}
#[non_exhaustive]
#[cfg_attr(test, derive(schemars::JsonSchema))]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
#[cfg_attr(
test,
schemars(
description = "The answer to a prompt, as recorded. A secret answer is never \
recorded, not even as a digest."
)
)]
pub enum ResponseRecord {
#[cfg_attr(test, schemars(description = "A yes or no answer."))]
Bool {
#[cfg_attr(test, schemars(description = "The answer."))]
value: bool,
},
#[cfg_attr(test, schemars(description = "A free-text answer."))]
Text {
#[cfg_attr(test, schemars(description = "The answer, as typed."))]
value: String,
},
#[cfg_attr(
test,
schemars(
description = "A secret was entered. Only the fact that it was entered is \
recorded."
)
)]
SecretRedacted {},
#[cfg_attr(test, schemars(description = "The person continued past a pause."))]
Acknowledged,
}
#[non_exhaustive]
#[cfg_attr(test, derive(schemars::JsonSchema))]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[cfg_attr(test, schemars(description = "What kind of bad outcome this was."))]
pub enum ErrorClass {
#[cfg_attr(
test,
schemars(
description = "Something around the ceremony was not ready, such as a device not \
connected, and the step's work did not happen."
)
)]
Environmental,
#[cfg_attr(
test,
schemars(
description = "The ceremony's own logic concluded badly, such as a verification \
that did not match or a refused attestation."
)
)]
Procedural,
#[cfg_attr(
test,
schemars(
description = "The run itself cannot be trusted to continue, or the ceremony \
definition is broken."
)
)]
Integrity,
#[cfg_attr(
test,
schemars(description = "An operator chose to stop the ceremony.")
)]
Abort,
}
#[non_exhaustive]
#[cfg_attr(test, derive(schemars::JsonSchema))]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(
test,
schemars(
description = "What went wrong: a class for audit, a stable kind, and a message for \
people."
)
)]
pub struct ErrorRecord {
#[cfg_attr(test, schemars(description = "The kind of bad outcome, for audit."))]
pub class: ErrorClass,
#[cfg_attr(
test,
schemars(description = "A stable label for the error, such as aborted or step_failed.")
)]
pub kind: String,
#[cfg_attr(test, schemars(description = "A description of the error for people."))]
pub message: String,
}
impl ErrorRecord {
pub fn new(class: ErrorClass, kind: impl Into<String>, message: impl Into<String>) -> Self {
Self {
class,
kind: kind.into(),
message: message.into(),
}
}
}
#[non_exhaustive]
#[cfg_attr(test, derive(schemars::JsonSchema))]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "status", rename_all = "snake_case")]
#[cfg_attr(test, schemars(description = "How a step ended."))]
pub enum StepOutcome {
#[cfg_attr(test, schemars(description = "The step did its work."))]
Completed {
#[cfg_attr(
test,
schemars(description = "A short description of what the step did.")
)]
message: String,
},
}
#[non_exhaustive]
#[cfg_attr(test, derive(schemars::JsonSchema))]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
#[cfg_attr(
test,
schemars(
description = "One recorded fact, identified by its `type`. Each fact type has a \
fixed set of fields in a given vocabulary."
)
)]
pub enum StepFact {
#[cfg_attr(test, schemars(description = "The ceremony started."))]
CeremonyStarted {
#[cfg_attr(test, schemars(description = "The ceremony's name."))]
name: String,
#[cfg_attr(
test,
schemars(
description = "Digest of the ceremony definition file the run was resolved \
from. The inputs of the run are recorded as their own facts, so this digest covers \
the definition only."
)
)]
template: Sha256Digest,
},
#[cfg_attr(
test,
schemars(
description = "A role the ceremony defines. Recorded once per role at the start, \
whether or not anyone is assigned to it; later facts name the role by its identifier."
)
)]
RoleDeclared {
#[cfg_attr(test, schemars(description = "The role."))]
role: RoleId,
#[cfg_attr(test, schemars(description = "The role's display name."))]
name: String,
},
#[cfg_attr(
test,
schemars(
description = "A person was assigned to a role for this run. One fact per \
assignment, so each can be withheld on its own."
)
)]
RoleAssigned {
#[cfg_attr(test, schemars(description = "The role."))]
role: RoleId,
#[cfg_attr(
test,
schemars(
description = "The person, as supplied when the run started. Recorded as \
given: nothing in the transcript proves who the person is."
)
)]
person: String,
},
#[cfg_attr(
test,
schemars(description = "A parameter took its value for this run, supplied or defaulted.")
)]
ParameterBound {
#[cfg_attr(test, schemars(description = "The parameter."))]
name: ParamId,
#[cfg_attr(test, schemars(description = "The value, as the ceremony used it."))]
value: serde_json::Value,
},
#[cfg_attr(
test,
schemars(
description = "A material was loaded at the start of the run. The digest of a \
digital material is a separate fact, material_digest, so the two can be disclosed to \
different audiences."
)
)]
MaterialLoaded {
#[cfg_attr(test, schemars(description = "The material."))]
name: MaterialId,
#[serde(skip_serializing_if = "Option::is_none")]
#[cfg_attr(
test,
schemars(
description = "The identifier of a physical or pre-provisioned material, such \
as a serial number, when one was supplied."
)
)]
identifier: Option<String>,
},
#[cfg_attr(
test,
schemars(description = "The digest of a digital material's bytes, as loaded.")
)]
MaterialDigest {
#[cfg_attr(test, schemars(description = "The material."))]
name: MaterialId,
#[cfg_attr(test, schemars(description = "The digest of the material's bytes."))]
digest: Sha256Digest,
},
#[cfg_attr(
test,
schemars(
description = "A backend was used for the first time in this run. Recorded once \
per backend, before its first operation; operations name the backend and do not repeat \
its identity."
)
)]
BackendBound {
#[cfg_attr(test, schemars(description = "The backend's name in the ceremony."))]
name: String,
#[cfg_attr(
test,
schemars(description = "The kind of backend, such as openssl, yubikey or pkcs11.")
)]
provider: String,
#[cfg_attr(
test,
schemars(
description = "The identity the backend reports for itself, such as a device \
serial number and firmware version."
)
)]
identity: String,
},
#[cfg_attr(
test,
schemars(description = "A description of the machine the ceremony ran on.")
)]
MachineInfoRecorded {
#[cfg_attr(test, schemars(description = "The step that recorded it."))]
step: StepId,
#[cfg_attr(
test,
schemars(
description = "The parts of the machine description the step was asked to \
record."
)
)]
info: serde_json::Value,
},
#[cfg_attr(
test,
schemars(description = "An act, a named part of the ceremony, started.")
)]
ActStarted {
#[cfg_attr(test, schemars(description = "The act."))]
id: ActId,
#[cfg_attr(
test,
schemars(description = "The act's label, as written in the ceremony.")
)]
label: String,
},
#[cfg_attr(test, schemars(description = "A step started."))]
StepStarted {
#[cfg_attr(test, schemars(description = "The step."))]
id: StepId,
#[cfg_attr(
test,
schemars(description = "The step's label, as written in the ceremony.")
)]
label: String,
#[cfg_attr(
test,
schemars(
description = "The role responsible for the step. Its display name is on the \
role's role_declared fact."
)
)]
role: RoleId,
},
#[cfg_attr(
test,
schemars(description = "Someone answered a prompt, and the answer was accepted.")
)]
PromptAnswered {
#[serde(skip_serializing_if = "Option::is_none")]
#[cfg_attr(
test,
schemars(
description = "The step that asked, or absent for a prompt asked outside any \
step."
)
)]
step: Option<StepId>,
#[cfg_attr(test, schemars(description = "The prompt, as shown."))]
prompt: Prompt,
#[cfg_attr(test, schemars(description = "The answer, as recorded."))]
response: ResponseRecord,
},
#[cfg_attr(
test,
schemars(
description = "A backend performed an operation. The inputs and outputs are \
specific to the kind of operation."
)
)]
BackendOperation {
#[cfg_attr(test, schemars(description = "The step the operation ran in."))]
step: StepId,
#[cfg_attr(
test,
schemars(description = "The kind of operation, such as generate_key or sign_data.")
)]
kind: String,
#[serde(skip_serializing_if = "Option::is_none")]
#[cfg_attr(
test,
schemars(
description = "The backend the step ran with, by the name the ceremony gives it. Its identity is on that backend's backend_bound fact. Absent for an operation done in software."
)
)]
backend: Option<String>,
#[cfg_attr(
test,
schemars(
description = "What the operation was given: parameters and references to \
artifacts or materials."
)
)]
inputs: serde_json::Value,
#[cfg_attr(
test,
schemars(description = "What the operation produced: artifact names and digests.")
)]
outputs: serde_json::Value,
#[cfg_attr(
test,
schemars(
description = "A fingerprint of the material the operation produced, when it \
has one."
)
)]
fingerprint: Option<String>,
},
#[cfg_attr(
test,
schemars(
description = "A key held outside the ceremony received a wrapped key. A separate \
fact from the operation, so the method of a wrap can be published while the recipient \
stays withheld."
)
)]
WrapRecipientRecorded {
#[cfg_attr(test, schemars(description = "The step that wrapped the key."))]
step: StepId,
#[cfg_attr(
test,
schemars(
description = "The artifact or material the recipient's key came from, by its \
name in the ceremony."
)
)]
source: String,
#[cfg_attr(
test,
schemars(
description = "The digest of the recipient's public key, as DER-encoded \
SubjectPublicKeyInfo.",
with = "Sha256Digest"
)
)]
fingerprint: String,
#[cfg_attr(
test,
schemars(
description = "Whether the ceremony named this fingerprint in advance. When \
it did, the key received was checked against it; otherwise the fingerprint records \
whatever key arrived."
)
)]
declared: bool,
},
#[cfg_attr(test, schemars(description = "A person made an attestation."))]
AttestationRecorded {
#[cfg_attr(test, schemars(description = "The step the attestation was made in."))]
step: StepId,
#[cfg_attr(test, schemars(description = "The role that made the attestation."))]
role: RoleId,
#[cfg_attr(test, schemars(description = "The statement, word for word."))]
statement: String,
},
#[cfg_attr(
test,
schemars(description = "An artifact was written to the run's artifacts directory.")
)]
ArtifactWritten {
#[cfg_attr(test, schemars(description = "The step that produced the artifact."))]
step: StepId,
#[cfg_attr(
test,
schemars(description = "The artifact's name, as declared in the ceremony.")
)]
name: String,
#[cfg_attr(
test,
schemars(
description = "The file name in the artifacts directory: a single path \
component."
)
)]
file: String,
#[serde(skip_serializing_if = "Option::is_none")]
#[cfg_attr(
test,
schemars(
description = "The digest of the file's bytes. Absent for content a step \
opened, since a digest of a secret can be tested against guesses."
)
)]
digest: Option<Sha256Digest>,
},
#[cfg_attr(test, schemars(description = "An operator recorded a deviation."))]
DeviationRecorded {
#[serde(skip_serializing_if = "Option::is_none")]
#[cfg_attr(
test,
schemars(
description = "The step during which the deviation was recorded, or absent \
outside any step."
)
)]
step: Option<StepId>,
#[cfg_attr(test, schemars(description = "The deviation, word for word."))]
text: String,
},
#[cfg_attr(
test,
schemars(
description = "One attempt at a step failed. Recorded per attempt: a step that is \
retried and then succeeds has this fact for each failed attempt, then step_completed."
)
)]
StepAttemptFailed {
#[cfg_attr(test, schemars(description = "The step."))]
step: StepId,
#[cfg_attr(
test,
schemars(description = "The attempt's number within the step, starting at 1.")
)]
attempt: u32,
#[cfg_attr(test, schemars(description = "What went wrong in this attempt."))]
error: ErrorRecord,
},
#[cfg_attr(test, schemars(description = "A step finished."))]
StepCompleted {
#[cfg_attr(test, schemars(description = "The step."))]
id: StepId,
#[cfg_attr(test, schemars(description = "How the step ended."))]
outcome: StepOutcome,
},
#[cfg_attr(
test,
schemars(
description = "The ceremony finished. It is the last line of a transcript whose \
run completed."
)
)]
CeremonyCompleted {},
#[cfg_attr(
test,
schemars(
description = "The ceremony failed or was aborted. It is the last line of a \
transcript whose run did not complete."
)
)]
CeremonyFailed {
#[cfg_attr(test, schemars(description = "What went wrong."))]
error: ErrorRecord,
},
#[cfg_attr(
test,
schemars(
description = "The ceremony's entropy source was seeded with machine randomness. \
Recorded once, at the start, so every value drawn from the source can be derived again \
from the transcript."
)
)]
EntropySeeded {
#[cfg_attr(
test,
schemars(
description = "The machine randomness, as lowercase hex.",
extend("pattern" = "^(?:[0-9a-f]{2})+$")
)
)]
m: String,
#[cfg_attr(
test,
schemars(description = "Where the machine randomness came from, such as os.")
)]
source: String,
#[cfg_attr(
test,
schemars(
description = "The derivation scheme, such as rite-kdf/v1. A verifier refuses \
a scheme it does not know."
)
)]
derivation: String,
},
#[cfg_attr(
test,
schemars(
description = "A person added their own randomness to the entropy source, \
starting a new epoch."
)
)]
EntropyContributed {
#[cfg_attr(test, schemars(description = "The step the contribution was made in."))]
step: StepId,
#[cfg_attr(
test,
schemars(description = "The epoch this contribution starts, counting from 1.")
)]
epoch: u32,
#[cfg_attr(
test,
schemars(
description = "The contribution, word for word, mixed into the source as \
UTF-8."
)
)]
contribution: String,
},
#[cfg_attr(
test,
schemars(
description = "A value was drawn from the entropy source, such as a nonce, a \
certificate serial number or a challenge. A verifier derives it again from the seed, \
the contributions and the path."
)
)]
EntropyDrawn {
#[cfg_attr(test, schemars(description = "The step that drew the value."))]
step: StepId,
#[cfg_attr(
test,
schemars(description = "The derivation path: `<epoch>/<step>/<purpose>`.")
)]
path: String,
#[cfg_attr(
test,
schemars(
description = "The value, as lowercase hex. Its length is the number of bytes \
drawn.",
extend("pattern" = "^(?:[0-9a-f]{2})*$")
)
)]
value: String,
},
}
pub const FACT_TYPES: &[&str] = &[
"ceremony_started",
"role_declared",
"role_assigned",
"parameter_bound",
"material_loaded",
"material_digest",
"backend_bound",
"machine_info_recorded",
"act_started",
"step_started",
"prompt_answered",
"backend_operation",
"wrap_recipient_recorded",
"attestation_recorded",
"artifact_written",
"deviation_recorded",
"step_attempt_failed",
"step_completed",
"ceremony_completed",
"ceremony_failed",
"entropy_seeded",
"entropy_contributed",
"entropy_drawn",
];
impl StepFact {
#[must_use]
pub fn default_level(&self) -> Level {
match self {
StepFact::CeremonyStarted { .. }
| StepFact::RoleDeclared { .. }
| StepFact::ActStarted { .. }
| StepFact::StepStarted { .. }
| StepFact::BackendOperation { .. }
| StepFact::ArtifactWritten { .. }
| StepFact::AttestationRecorded { .. }
| StepFact::StepCompleted { .. }
| StepFact::CeremonyCompleted {}
| StepFact::CeremonyFailed { .. }
| StepFact::EntropySeeded { .. }
| StepFact::EntropyContributed { .. }
| StepFact::EntropyDrawn { .. } => Level::PUBLIC,
StepFact::ParameterBound { .. }
| StepFact::MaterialLoaded { .. }
| StepFact::BackendBound { .. }
| StepFact::PromptAnswered { .. }
| StepFact::WrapRecipientRecorded { .. }
| StepFact::StepAttemptFailed { .. }
| StepFact::DeviationRecorded { .. } => Level::RESTRICTED,
StepFact::RoleAssigned { .. }
| StepFact::MaterialDigest { .. }
| StepFact::MachineInfoRecorded { .. } => Level::CONFIDENTIAL,
}
}
#[must_use]
pub fn is_side_effect(&self) -> bool {
match self {
StepFact::BackendOperation { .. }
| StepFact::AttestationRecorded { .. }
| StepFact::ArtifactWritten { .. }
| StepFact::EntropyContributed { .. }
| StepFact::EntropyDrawn { .. } => true,
StepFact::CeremonyStarted { .. }
| StepFact::RoleDeclared { .. }
| StepFact::RoleAssigned { .. }
| StepFact::ParameterBound { .. }
| StepFact::MaterialLoaded { .. }
| StepFact::MaterialDigest { .. }
| StepFact::BackendBound { .. }
| StepFact::MachineInfoRecorded { .. }
| StepFact::ActStarted { .. }
| StepFact::StepStarted { .. }
| StepFact::PromptAnswered { .. }
| StepFact::WrapRecipientRecorded { .. }
| StepFact::DeviationRecorded { .. }
| StepFact::StepAttemptFailed { .. }
| StepFact::StepCompleted { .. }
| StepFact::CeremonyCompleted {}
| StepFact::CeremonyFailed { .. }
| StepFact::EntropySeeded { .. } => false,
}
}
}
#[cfg(test)]
mod validator_tests {
use super::*;
fn shape(format: Format, min: usize, max: usize) -> ValidatorSpec {
ValidatorSpec::Format {
format,
min_length: Some(min),
max_length: Some(max),
}
}
#[test]
fn a_text_format_checks_characters_and_length() {
let pin = shape(Format::Digits, 6, 8);
assert!(pin.check("123456").is_ok());
assert!(pin.check("12345678").is_ok());
assert!(pin.check("12345").unwrap_err().contains("this is 5"));
assert!(pin.check("123456789").unwrap_err().contains("this is 9"));
assert!(pin.check("12345a").unwrap_err().contains("digits"));
assert!(shape(Format::Alphanumeric, 1, 4).check("ab12").is_ok());
assert!(shape(Format::Alphanumeric, 1, 4).check("ab-1").is_err());
assert!(shape(Format::Text, 1, 4).check("ab-1").is_ok());
assert!(
ValidatorSpec::Format {
format: Format::Text,
min_length: None,
max_length: None,
}
.check(" ")
.is_err(),
"a value is still something"
);
}
#[test]
fn a_pattern_must_match_the_whole_value() {
let digits = ValidatorSpec::Regex("[0-9]+".to_string());
assert!(digits.check("123").is_ok());
assert!(digits.check("abc123").is_err());
assert!(digits.check("123abc").is_err());
let either = ValidatorSpec::Regex("yes|no".to_string());
assert!(either.check("no").is_ok());
assert!(either.check("nope").is_err());
}
#[test]
fn a_refusal_names_the_rule_and_not_the_value() {
let err = shape(Format::Digits, 6, 6).check("hunter2").unwrap_err();
assert!(!err.contains("hunter2"));
let err = ValidatorSpec::Regex("[0-9]+".to_string())
.check("hunter2")
.unwrap_err();
assert!(!err.contains("hunter2"));
}
#[test]
fn a_hint_reads_as_a_person_would_say_it() {
assert_eq!(shape(Format::Digits, 6, 6).hint().unwrap(), "6 digits");
assert_eq!(
shape(Format::Text, 6, 8).hint().unwrap(),
"6 to 8 characters"
);
assert_eq!(
ValidatorSpec::Format {
format: Format::Hex,
min_length: Some(32),
max_length: None,
}
.hint()
.unwrap(),
"at least 32 bytes as hex"
);
assert_eq!(
ValidatorSpec::Format {
format: Format::Alphanumeric,
min_length: None,
max_length: None,
}
.hint()
.unwrap(),
"letters or digits only"
);
assert_eq!(
ValidatorSpec::Format {
format: Format::Base64,
min_length: None,
max_length: None,
}
.hint()
.unwrap(),
"bytes as base64"
);
assert!(ValidatorSpec::NonEmpty.hint().is_none());
}
#[test]
fn an_encoding_decodes_and_counts_bytes() {
let key = shape(Format::Hex, 4, 4);
assert!(key.check("deadbeef").is_ok());
assert!(key.check("DE AD be ef").is_ok());
assert!(key.check("deadbe").unwrap_err().contains("this is 3"));
assert!(key.check("deadbeefx").unwrap_err().contains("hex"));
assert_eq!(Format::Hex.decode("DE AD").unwrap(), vec![0xde, 0xad]);
let b64 = ValidatorSpec::Format {
format: Format::Base64,
min_length: None,
max_length: None,
};
assert!(b64.check("aGVsbG8=").is_ok());
assert!(b64.check("aGVsbG8").is_err(), "padding is required");
assert_eq!(Format::Base64.decode("aGVs\nbG8=").unwrap(), b"hello");
assert!(Format::Digits.decode("12").is_err(), "text does not decode");
}
#[test]
fn a_placeholder_satisfies_its_own_format() {
for format in [
Format::Text,
Format::Digits,
Format::Alphanumeric,
Format::Hex,
Format::Base64,
] {
let stand_in = format.placeholder(32).unwrap();
assert!(shape(format, 32, 32).check(&stand_in).is_ok());
}
assert!(Format::Text.placeholder(PLACEHOLDER_LIMIT + 1).is_none());
}
}
#[cfg(test)]
mod schema_snapshot_tests {
use super::*;
use serde_json::json;
fn assert_json(fact: &StepFact, expected: &serde_json::Value) {
let actual = serde_json::to_value(fact).expect("serialize StepFact");
assert_eq!(&actual, expected, "wire-format drift for {fact:?}");
let tag = actual
.get("type")
.and_then(serde_json::Value::as_str)
.expect("a type tag");
assert!(FACT_TYPES.contains(&tag), "{tag} missing from FACT_TYPES");
crate::schema::assert_fact_matches_schema(&actual);
}
#[test]
fn a_level_is_named_or_numbered_and_must_be_declared() {
let header = TranscriptHeader::new("rite test", "0", false);
assert_eq!(header.level("public"), Some(Level::PUBLIC));
assert_eq!(header.level("20"), Some(Level::RESTRICTED));
assert_eq!(header.level("15"), None);
assert_eq!(header.level("partners"), None);
}
#[test]
fn levels_are_ordered_integers() {
assert!(Level::PUBLIC < Level::RESTRICTED);
assert!(Level::RESTRICTED < Level::CONFIDENTIAL);
assert!(Level::new(15) > Level::PUBLIC && Level::new(15) < Level::RESTRICTED);
assert_eq!(
serde_json::to_value(Level::CONFIDENTIAL).expect("serialize"),
json!(30)
);
assert_eq!(Level::RESTRICTED.to_string(), "restricted");
assert_eq!(Level::new(15).to_string(), "level 15");
}
#[test]
fn ceremony_started() {
assert_json(
&StepFact::CeremonyStarted {
name: "Root CA".to_string(),
template: Sha256Digest::of(b"ceremony"),
},
&json!({
"type": "ceremony_started",
"name": "Root CA",
"template": Sha256Digest::of(b"ceremony").as_str(),
}),
);
}
#[test]
fn header() {
let header = TranscriptHeader::new("rite 0.6.0", &"0".repeat(32), false);
assert_eq!(
serde_json::to_value(&header).expect("serialize header"),
json!({
"rite_transcript": 0,
"vocabulary": 0,
"producer": "rite 0.6.0",
"run_id": "0".repeat(32),
"dry_run": false,
"levels": { "public": 10, "restricted": 20, "confidential": 30 },
}),
);
}
#[test]
fn role_assigned() {
assert_json(
&StepFact::RoleAssigned {
role: RoleId::new("crypto_officer"),
person: "Alice Rivera".to_string(),
},
&json!({
"type": "role_assigned",
"role": "crypto_officer",
"person": "Alice Rivera",
}),
);
}
#[test]
fn parameter_bound() {
assert_json(
&StepFact::ParameterBound {
name: ParamId::new("validity_days"),
value: json!(3650),
},
&json!({
"type": "parameter_bound",
"name": "validity_days",
"value": 3650,
}),
);
}
#[test]
fn material_loaded() {
assert_json(
&StepFact::MaterialLoaded {
name: MaterialId::new("usb_drive"),
identifier: Some("SN-1234".to_string()),
},
&json!({
"type": "material_loaded",
"name": "usb_drive",
"identifier": "SN-1234",
}),
);
assert_json(
&StepFact::MaterialLoaded {
name: MaterialId::new("csr"),
identifier: None,
},
&json!({ "type": "material_loaded", "name": "csr" }),
);
}
#[test]
fn material_digest() {
assert_json(
&StepFact::MaterialDigest {
name: MaterialId::new("csr"),
digest: Sha256Digest::of(b"csr"),
},
&json!({
"type": "material_digest",
"name": "csr",
"digest": Sha256Digest::of(b"csr").as_str(),
}),
);
}
#[test]
fn backend_bound() {
assert_json(
&StepFact::BackendBound {
name: "token".to_string(),
provider: "yubikey".to_string(),
identity: "yubikey-serial=12345678+firmware=5.7.1".to_string(),
},
&json!({
"type": "backend_bound",
"name": "token",
"provider": "yubikey",
"identity": "yubikey-serial=12345678+firmware=5.7.1",
}),
);
}
#[test]
fn machine_info_recorded() {
assert_json(
&StepFact::MachineInfoRecorded {
step: StepId::new("capture"),
info: json!({ "arch": "x86_64", "hostname": null }),
},
&json!({
"type": "machine_info_recorded",
"step": "capture",
"info": { "arch": "x86_64", "hostname": null },
}),
);
}
#[test]
fn act_started() {
assert_json(
&StepFact::ActStarted {
id: ActId::new("setup"),
label: "Setup".to_string(),
},
&json!({
"type": "act_started",
"id": "setup",
"label": "Setup",
}),
);
}
#[test]
fn step_started() {
assert_json(
&StepFact::StepStarted {
id: StepId::new("s1"),
label: "2.1".to_string(),
role: RoleId::new("crypto_officer"),
},
&json!({
"type": "step_started",
"id": "s1",
"label": "2.1",
"role": "crypto_officer",
}),
);
}
#[test]
fn role_declared() {
assert_json(
&StepFact::RoleDeclared {
role: RoleId::new("crypto_officer"),
name: "Crypto Officer".to_string(),
},
&json!({
"type": "role_declared",
"role": "crypto_officer",
"name": "Crypto Officer",
}),
);
}
#[test]
fn prompt_answered_confirm_bool() {
assert_json(
&StepFact::PromptAnswered {
step: Some(StepId::new("s1")),
prompt: Prompt::Confirm {
question: "Proceed?".to_string(),
default: Some(true),
},
response: ResponseRecord::Bool { value: true },
},
&json!({
"type": "prompt_answered",
"step": "s1",
"prompt": { "type": "confirm", "question": "Proceed?", "default": true },
"response": { "type": "bool", "value": true },
}),
);
}
#[test]
fn prompt_answered_text_nonempty() {
assert_json(
&StepFact::PromptAnswered {
step: Some(StepId::new("s1")),
prompt: Prompt::Text {
label: "Name".to_string(),
validator: ValidatorSpec::NonEmpty,
},
response: ResponseRecord::Text {
value: "Alice".to_string(),
},
},
&json!({
"type": "prompt_answered",
"step": "s1",
"prompt": {
"type": "text",
"label": "Name",
"validator": { "kind": "non_empty" },
},
"response": { "type": "text", "value": "Alice" },
}),
);
}
#[test]
fn prompt_answered_text_regex() {
assert_json(
&StepFact::PromptAnswered {
step: Some(StepId::new("s1")),
prompt: Prompt::Text {
label: "SN".to_string(),
validator: ValidatorSpec::Regex(r"^[A-Z0-9]+$".to_string()),
},
response: ResponseRecord::Text {
value: "AB12".to_string(),
},
},
&json!({
"type": "prompt_answered",
"step": "s1",
"prompt": {
"type": "text",
"label": "SN",
"validator": { "kind": "regex", "value": "^[A-Z0-9]+$" },
},
"response": { "type": "text", "value": "AB12" },
}),
);
}
#[test]
fn prompt_answered_text_predefined() {
assert_json(
&StepFact::PromptAnswered {
step: Some(StepId::new("s1")),
prompt: Prompt::Text {
label: "SN".to_string(),
validator: ValidatorSpec::Predefined("serial_number".to_string()),
},
response: ResponseRecord::Text {
value: "ABCD".to_string(),
},
},
&json!({
"type": "prompt_answered",
"step": "s1",
"prompt": {
"type": "text",
"label": "SN",
"validator": { "kind": "predefined", "value": "serial_number" },
},
"response": { "type": "text", "value": "ABCD" },
}),
);
}
#[test]
fn prompt_answered_secret_redacted() {
assert_json(
&StepFact::PromptAnswered {
step: Some(StepId::new("s1")),
prompt: Prompt::Secret {
label: "PIN".to_string(),
validator: ValidatorSpec::NonEmpty,
},
response: ResponseRecord::SecretRedacted {},
},
&json!({
"type": "prompt_answered",
"step": "s1",
"prompt": {
"type": "secret",
"label": "PIN",
"validator": { "kind": "non_empty" },
},
"response": { "type": "secret_redacted" },
}),
);
}
#[test]
fn prompt_answered_literal_text() {
assert_json(
&StepFact::PromptAnswered {
step: Some(StepId::new("s1")),
prompt: Prompt::Literal {
label: "Type 'attest'".to_string(),
expected: "attest".to_string(),
},
response: ResponseRecord::Text {
value: "attest".to_string(),
},
},
&json!({
"type": "prompt_answered",
"step": "s1",
"prompt": { "type": "literal", "label": "Type 'attest'", "expected": "attest" },
"response": { "type": "text", "value": "attest" },
}),
);
}
#[test]
fn prompt_answered_continue_acknowledged() {
assert_json(
&StepFact::PromptAnswered {
step: Some(StepId::new("s1")),
prompt: Prompt::Continue {
hint: Some("Press Enter".to_string()),
},
response: ResponseRecord::Acknowledged,
},
&json!({
"type": "prompt_answered",
"step": "s1",
"prompt": { "type": "continue", "hint": "Press Enter" },
"response": { "type": "acknowledged" },
}),
);
}
#[test]
fn backend_operation() {
assert_json(
&StepFact::BackendOperation {
step: StepId::new("s1"),
kind: "generate_key".to_string(),
backend: Some("hsm".to_string()),
inputs: json!({ "algorithm": "rsa", "bits": 4096 }),
outputs: json!({ "key_id": "k1" }),
fingerprint: Some("sha256:deadbeef".to_string()),
},
&json!({
"type": "backend_operation",
"step": "s1",
"kind": "generate_key",
"backend": "hsm",
"inputs": { "algorithm": "rsa", "bits": 4096 },
"outputs": { "key_id": "k1" },
"fingerprint": "sha256:deadbeef",
}),
);
}
#[test]
fn wrap_recipient_recorded() {
let fingerprint = Sha256Digest::of(b"escrow spki").to_string();
assert_json(
&StepFact::WrapRecipientRecorded {
step: StepId::new("s1"),
source: "escrow_pubkey".to_string(),
fingerprint: fingerprint.clone(),
declared: true,
},
&json!({
"type": "wrap_recipient_recorded",
"step": "s1",
"source": "escrow_pubkey",
"fingerprint": fingerprint,
"declared": true,
}),
);
}
#[test]
fn attestation_recorded() {
assert_json(
&StepFact::AttestationRecorded {
step: StepId::new("s1"),
role: RoleId::new("crypto_officer"),
statement: "I confirm.".to_string(),
},
&json!({
"type": "attestation_recorded",
"step": "s1",
"role": "crypto_officer",
"statement": "I confirm.",
}),
);
}
#[test]
fn artifact_written() {
assert_json(
&StepFact::ArtifactWritten {
step: StepId::new("s1"),
name: "root_cert".to_string(),
file: "root_cert.crt".to_string(),
digest: Some(Sha256Digest::of(b"cert")),
},
&json!({
"type": "artifact_written",
"step": "s1",
"name": "root_cert",
"file": "root_cert.crt",
"digest": Sha256Digest::of(b"cert").as_str(),
}),
);
}
#[test]
fn artifact_written_secret_has_no_digest() {
assert_json(
&StepFact::ArtifactWritten {
step: StepId::new("s1"),
name: "opened".to_string(),
file: "opened.bin".to_string(),
digest: None,
},
&json!({
"type": "artifact_written",
"step": "s1",
"name": "opened",
"file": "opened.bin",
}),
);
}
#[test]
fn deviation_recorded() {
assert_json(
&StepFact::DeviationRecorded {
step: Some(StepId::new("s1")),
text: "phone rang".to_string(),
},
&json!({
"type": "deviation_recorded",
"step": "s1",
"text": "phone rang",
}),
);
}
#[test]
fn step_completed_completed() {
assert_json(
&StepFact::StepCompleted {
id: StepId::new("s1"),
outcome: StepOutcome::Completed {
message: "done".to_string(),
},
},
&json!({
"type": "step_completed",
"id": "s1",
"outcome": { "status": "completed", "message": "done" },
}),
);
}
#[test]
fn ceremony_completed() {
assert_json(
&StepFact::CeremonyCompleted {},
&json!({
"type": "ceremony_completed",
}),
);
}
#[test]
fn ceremony_failed() {
assert_json(
&StepFact::CeremonyFailed {
error: ErrorRecord::new(
ErrorClass::Abort,
"aborted",
"ceremony aborted by operator",
),
},
&json!({
"type": "ceremony_failed",
"error": {
"class": "abort",
"kind": "aborted",
"message": "ceremony aborted by operator",
},
}),
);
}
#[test]
fn step_attempt_failed() {
assert_json(
&StepFact::StepAttemptFailed {
step: StepId::new("import_key"),
attempt: 1,
error: ErrorRecord::new(
ErrorClass::Environmental,
"backend_error",
"Token not present",
),
},
&json!({
"type": "step_attempt_failed",
"step": "import_key",
"attempt": 1,
"error": {
"class": "environmental",
"kind": "backend_error",
"message": "Token not present",
},
}),
);
}
#[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(),
value: "aabbccddeeff00112233".to_string(),
},
&json!({
"type": "entropy_drawn",
"step": "issue",
"path": "0/issue/cert-serial",
"value": "aabbccddeeff00112233",
}),
);
}
}