diff --git a/Cargo.lock b/Cargo.lock index dc01c2b..341266e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2148,6 +2148,7 @@ dependencies = [ "secrecy 0.10.3", "serde_json", "tempfile", + "zeroize", ] [[package]] @@ -2308,6 +2309,7 @@ dependencies = [ "rite-model", "rite-runtime", "secrecy 0.10.3", + "zeroize", ] [[package]] diff --git a/README.md b/README.md index a560536..900ea64 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ Run it: ```sh rite check ceremony.rite.yaml # validate -rite script ceremony.rite.yaml # generate script +rite script ceremony.rite.yaml # generate script, and sheets for values written by hand rite run ceremony.rite.yaml # execute ``` diff --git a/crates/rite-ls/src/actions.rs b/crates/rite-ls/src/actions.rs index 1206138..d25b7bd 100644 --- a/crates/rite-ls/src/actions.rs +++ b/crates/rite-ls/src/actions.rs @@ -48,7 +48,7 @@ pub static ALL: &[ActionMeta] = &[ ActionMeta { name: "enter_secret", short: "A person types a secret the ceremony holds and never records", - long: "A person types a secret the ceremony holds and never records: a passphrase, a PIN. Echo is off, the artifact under `creates:` is wiped from memory when the run ends, and the transcript says only that a secret was entered at this step. A later step names it in `reads:`; `import_key` takes it as `passphrase:` to open an encrypted private key. Takes the same `format:`, `length:`, `min_length:` and `max_length:` as `enter_value`.", + long: "A person types a secret the ceremony holds and never records: a passphrase, a PIN. Echo is off, the artifact under `creates:` is wiped from memory when the run ends, and the transcript says only that a secret was entered at this step. A later step names it in `reads:`; `import_key` takes it as `passphrase:` to open an encrypted private key. Takes the same `format:`, `length:`, `min_length:` and `max_length:` as `enter_value`, and `format: paper32` besides, for a value written on a sheet: it is typed a row at a time, each row checked as it comes, a wrong character corrected and named, and the artifact is the decoded bytes.", }, ActionMeta { name: "generate_key", @@ -90,6 +90,16 @@ pub static ALL: &[ActionMeta] = &[ short: "Reconstruct a secret from its shares", long: "Reads `shares:`, a list of at least two, each a share of a set `split_secret` made or a share a custodian typed back. Each share records how many are needed and which one it is, so the step takes no `with:` and too few shares is an error before anything is computed. Shares that disagree on the threshold or the secret's length are rejected; shares from a different split of the same shape cannot be told apart, which is why a recovery ends by checking what came back. The result stays in memory and is erased when the run ends.", }, + ActionMeta { + name: "reveal", + short: "Show a value on screen for a person to write down, then withdraw it", + long: "Reads `value:`, a share (`${artifact.shares.share_N}`) or any byte artifact, and shows it in a window, once: the window says it is coming, shows it on Enter, and asks whether every row is written down before it leaves the screen. `message:` is required and says what the value is and what to do with it. `format:` is `paper32` (the default: rows of 28 base-32 characters and 4 of parity, from an alphabet without I, L, O and U; a wrong character in a row is corrected, two unreadable ones recovered) or `hex`. The transcript records that the value was shown and nothing of it. `rite script` prints a page for the step, in the worksheets beside the script, rows of boxes with the parity cells set apart; `note:` is printed on it, and `length:` (in bytes, and for a share the secret's length, without the share's three header bytes) gives it exactly the right rows; the step then refuses a value of another size. A dry run walks the step; a real headless run stops at it, since no one is there to write.", + }, + ActionMeta { + name: "enter_share", + short: "Type a share back from its sheet, row by row", + long: "Asks for the rows of a share written on a sheet `rite script` printed, one row at a time. Each row is checked as it is typed: in `paper32` (the default) a wrong character is corrected and named so the person checks the sheet, two unreadable ones (typed as `?`) are recovered, and a row that needs more is typed again. `format: hex` reads a sheet written in hex. Rows that are not a share are refused and asked for again. `message:` is required and says which sheet; `note:` is shown with the rows; `length:` (the secret's length in bytes) asks for exactly the right rows and refuses a share of another size. Creates a share that `combine_shares` reads as `${artifact.}`. The transcript records the share's index and which rows were repaired, never the rows. A headless run stops at it, since no one is there to type.", + }, ActionMeta { name: "export_public", short: "Export public key from keypair", diff --git a/crates/rite-model/src/display.rs b/crates/rite-model/src/display.rs new file mode 100644 index 0000000..f3dcbe3 --- /dev/null +++ b/crates/rite-model/src/display.rs @@ -0,0 +1,436 @@ +//! How a value is written out for a person: the encodings `reveal` shows, +//! and the shape each gives a sheet of paper. +//! +//! The shape is a property of the encoding and not of the value, so +//! `rite script` prints it before the ceremony runs: how many +//! characters make a row, how many of those are parity, how characters +//! group for the eye, what the alphabet is and what the encoding is +//! called. Only the number of rows depends on the value, and a sheet does +//! not need it when every row has the same shape. + +use std::fmt; +use std::str::FromStr; + +use serde::{Deserialize, Serialize}; +use zeroize::Zeroizing; + +use crate::paper32; + +/// An encoding a value is shown in. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +#[serde(rename_all = "snake_case")] +#[cfg_attr( + test, + schemars(description = "The encoding a value is written on paper in.") +)] +#[non_exhaustive] +pub enum RevealFormat { + /// Rows of 32 base-32 characters, 28 of data and 4 of parity, from an + /// alphabet without `I`, `L`, `O` and `U`; a wrong character in a row + /// is corrected, two unreadable ones recovered. The default. See + /// [`paper32`]. + #[cfg_attr( + test, + schemars( + description = "Rows of 32 base-32 characters, 28 of data and 4 of parity; a wrong \ + character in a row is corrected." + ) + )] + Paper32, + /// Two hexadecimal digits per byte, upper case, in rows of 32. + #[cfg_attr( + test, + schemars(description = "Two hexadecimal digits per byte, in rows of 32.") + )] + Hex, +} + +impl RevealFormat { + /// The name a ceremony writes. + pub fn as_str(self) -> &'static str { + match self { + RevealFormat::Paper32 => "paper32", + RevealFormat::Hex => "hex", + } + } + + /// The shape a sheet takes for this encoding. + pub fn layout(self) -> Layout { + match self { + RevealFormat::Paper32 => Layout { + format: self, + group: 4, + row: paper32::ROW_DATA, + parity: paper32::ROW_PARITY, + alphabet: "Digits and letters, never I, L, O or U; upper or lower case".to_string(), + algorithm: "Paper32: rows of 28 characters and 4 of parity, Reed-Solomon over \ + GF(32)" + .to_string(), + }, + RevealFormat::Hex => Layout { + format: self, + group: 4, + row: 32, + parity: 0, + alphabet: "Digits 0 to 9 and letters A to F".to_string(), + algorithm: "Hexadecimal, two characters per byte".to_string(), + }, + } + } + + /// The shape a `reveal` step gives from its definition: its format, or + /// the default. + pub fn layout_for(format: Option) -> Layout { + format.unwrap_or(RevealFormat::Paper32).layout() + } + + /// Rows a value of `bytes` bytes takes. + pub fn rows(self, bytes: usize) -> usize { + let layout = self.layout(); + self.characters(bytes) + .div_ceil(layout.row.saturating_add(layout.parity).max(1)) + } + + /// One row as typed, checked on its own: the characters it should be, + /// and the repair it took, in words. `row` is its number from 1, and + /// `more` says rows are known to follow it. + /// + /// Every row but the last is full, since a row's place in the value is + /// its position: a short row is the last one, and one with rows after + /// it is a row with characters missing. + /// + /// ``` + /// use rite_model::RevealFormat; + /// + /// let row = RevealFormat::Paper32.read_row("E9MQ 8S8P H8B", 1, false).unwrap(); + /// assert_eq!(row.text.as_str(), "E9MQ8S8PH8B"); + /// assert!(!row.full); + /// // A short row where more are to come has characters missing. + /// assert!(RevealFormat::Paper32.read_row("E9MQ8S8PH8B", 1, true).is_err()); + /// ``` + /// + /// # Errors + /// + /// What is wrong with the row, in words, for the person to fix. + pub fn read_row(self, text: &str, row: usize, more: bool) -> Result { + let read = self.read_one_row(text, row)?; + let full = self.layout().row.saturating_add(self.layout().parity); + let characters = read.text.chars().count(); + if more && characters < full { + return Err(format!( + "{characters} characters; every row but the last has {full}" + )); + } + Ok(TypedRow { + full: characters == full, + ..read + }) + } + + fn read_one_row(self, text: &str, row: usize) -> Result { + match self { + RevealFormat::Paper32 => paper32::read_row(text, row) + .map(|read| TypedRow { + text: read.text, + repair: read.repair.map(|r| r.to_string()), + full: false, + }) + .map_err(|e| e.to_string()), + RevealFormat::Hex => { + let digits = hex_digits(text)?; + let row_len = self.layout().row; + if digits.is_empty() { + return Err("nothing to read".to_string()); + } + if digits.len() > row_len { + return Err(format!( + "{} characters; a row has at most {row_len}", + digits.len() + )); + } + Ok(TypedRow { + text: digits, + repair: None, + full: false, + }) + } + } + } + + /// A whole value as typed, rows one after another: its bytes, and the + /// repairs it took, in words. + /// + /// # Errors + /// + /// What is wrong with the text, in words. + pub fn decode(self, text: &str) -> Result { + match self { + RevealFormat::Paper32 => paper32::decode(text) + .map(|decoded| TypedValue { + bytes: decoded.bytes, + repaired: decoded.repaired.iter().map(ToString::to_string).collect(), + }) + .map_err(|e| e.to_string()), + RevealFormat::Hex => { + let digits = hex_digits(text)?; + if digits.len() % 2 != 0 { + return Err(format!( + "{} hexadecimal digits; a byte is two", + digits.len() + )); + } + let bytes = digits + .as_bytes() + .chunks(2) + .map(|pair| { + std::str::from_utf8(pair) + .ok() + .and_then(|p| u8::from_str_radix(p, 16).ok()) + .unwrap_or(0) + }) + .collect(); + Ok(TypedValue { + bytes: Zeroizing::new(bytes), + repaired: Vec::new(), + }) + } + } + } + + /// Characters a value of `bytes` bytes takes, parity included. + pub fn characters(self, bytes: usize) -> usize { + match self { + RevealFormat::Paper32 => { + let symbols = bytes.saturating_mul(8).div_ceil(5); + let rows = symbols.div_ceil(paper32::ROW_DATA); + symbols.saturating_add(rows.saturating_mul(paper32::ROW_PARITY)) + } + RevealFormat::Hex => bytes.saturating_mul(2), + } + } +} + +impl fmt::Display for RevealFormat { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +/// A row [`RevealFormat::read_row`] read. +#[derive(Debug)] +pub struct TypedRow { + /// The row as it should read, upper case, without separators. + pub text: Zeroizing, + /// The repair it took, in words. + pub repair: Option, + /// Whether the row has every character a row can have. A row that + /// does not is the last one. + pub full: bool, +} + +/// A value [`RevealFormat::decode`] read. +#[derive(Debug)] +pub struct TypedValue { + /// The bytes, wiped when dropped. + pub bytes: Zeroizing>, + /// The repairs, in words, one per row that took one. + pub repaired: Vec, +} + +/// Hexadecimal digits in upper case, separators dropped. +fn hex_digits(text: &str) -> Result, String> { + let mut digits = Zeroizing::new(String::with_capacity(text.len())); + for (position, c) in text.chars().enumerate() { + match c { + ' ' | '-' | '|' | '\t' | '\n' | '\r' | '.' => {} + c if c.is_ascii_hexdigit() => digits.push(c.to_ascii_uppercase()), + _ => { + return Err(format!( + "character {} is not a hexadecimal digit (0 to 9, A to F)", + position.saturating_add(1) + )); + } + } + } + Ok(digits) +} + +/// A `format:` value that names no encoding this build shows. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UnknownRevealFormat(pub String); + +impl fmt::Display for UnknownRevealFormat { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "'{}' is not a format; use paper32 or hex", self.0) + } +} + +impl std::error::Error for UnknownRevealFormat {} + +impl FromStr for RevealFormat { + type Err = UnknownRevealFormat; + + fn from_str(s: &str) -> Result { + match s { + "paper32" => Ok(RevealFormat::Paper32), + "hex" => Ok(RevealFormat::Hex), + other => Err(UnknownRevealFormat(other.to_string())), + } + } +} + +/// The shape a written value takes: what a sheet pre-prints and how a +/// screen lays the characters out. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct Layout { + /// The encoding. + pub format: RevealFormat, + /// Characters per group, for the eye. + pub group: usize, + /// Data characters in a full row. + pub row: usize, + /// Parity characters closing every row; zero when there are none. + pub parity: usize, + /// What the characters can be, in words. + pub alphabet: String, + /// What the encoding is called, in small print. + pub algorithm: String, +} + +impl Default for Layout { + fn default() -> Self { + RevealFormat::Paper32.layout() + } +} + +impl Layout { + /// Split a written value into rows, each its data in groups and its + /// parity, for a screen to style each part. + pub fn rows<'a>(&self, text: &'a str) -> Vec> { + let row_len = self.row.saturating_add(self.parity).max(1); + text.as_bytes() + .chunks(row_len) + .map(|chunk| { + let line = std::str::from_utf8(chunk).unwrap_or_default(); + let data_len = line.len().saturating_sub(self.parity); + let (data, parity) = line.split_at(data_len.min(line.len())); + let groups = data + .as_bytes() + .chunks(self.group.max(1)) + .map(|g| std::str::from_utf8(g).unwrap_or_default()) + .collect(); + Row { groups, parity } + }) + .collect() + } +} + +/// One row of a written value. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Row<'a> { + /// The data, grouped. + pub groups: Vec<&'a str>, + /// The parity characters, possibly empty. + pub parity: &'a str, +} + +/// Bytes as upper-case hex. +pub fn hex_upper(bytes: &[u8]) -> String { + use std::fmt::Write as _; + bytes.iter().fold( + String::with_capacity(bytes.len().saturating_mul(2)), + |mut out, b| { + let _ = write!(out, "{b:02X}"); + out + }, + ) +} + +#[cfg(test)] +#[allow(clippy::indexing_slicing)] +mod tests { + use super::*; + + #[test] + fn a_row_is_checked_on_its_own_and_a_value_read_whole() { + // 30 bytes: a full row of 28 and 4, then 20 and 4. + let bytes = [0x5A; 30]; + let text = paper32::encode(&bytes); + let (first, second) = text.split_at(32); + let row = RevealFormat::Paper32.read_row(first, 1, true).unwrap(); + assert_eq!(row.text.as_str(), first); + assert!(row.repair.is_none()); + assert!(row.full); + + let mut slipped: Vec = second.to_ascii_lowercase().chars().collect(); + slipped[3] = if slipped[3] == 'a' { 'b' } else { 'a' }; + let slipped: String = slipped.iter().collect(); + let row = RevealFormat::Paper32.read_row(&slipped, 2, false).unwrap(); + assert_eq!(row.text.as_str(), second); + assert!(!row.full); + assert!(row.repair.unwrap().starts_with("row 2: character 4")); + assert!(RevealFormat::Paper32.read_row("ABCD", 1, false).is_err()); + // The short last row, where the sheet says more rows follow. + let refused = RevealFormat::Paper32.read_row(second, 1, true).unwrap_err(); + assert!( + refused.contains("every row but the last has 32"), + "{refused}" + ); + + let value = RevealFormat::Paper32 + .decode(&format!("{first}\n{slipped}")) + .unwrap(); + assert_eq!(*value.bytes, bytes); + assert_eq!(value.repaired.len(), 1); + + let hex = RevealFormat::Hex.read_row("de ad-be ef", 1, false).unwrap(); + assert_eq!(hex.text.as_str(), "DEADBEEF"); + assert!(!hex.full); + assert!(RevealFormat::Hex.read_row("DEAG", 1, false).is_err()); + assert!(RevealFormat::Hex.read_row("DEADBEEF", 1, true).is_err()); + assert_eq!( + *RevealFormat::Hex.decode("DEAD\nbeef").unwrap().bytes, + [0xDE, 0xAD, 0xBE, 0xEF] + ); + assert!(RevealFormat::Hex.decode("DEA").is_err()); + assert_eq!(RevealFormat::Paper32.rows(35), 2); + assert_eq!(RevealFormat::Hex.rows(35), 3); + } + + #[test] + fn a_paper32_string_splits_into_rows_of_groups_and_parity() { + let layout = RevealFormat::Paper32.layout(); + let text = paper32::encode(&[0x5A; 35]); + let rows = layout.rows(&text); + assert_eq!(rows.len(), 2); + assert_eq!(rows[0].groups.len(), 7); + assert!(rows[0].groups.iter().all(|g| g.len() == 4)); + assert_eq!(rows[0].parity.len(), 4); + assert_eq!(rows[1].parity.len(), 4); + assert_eq!(RevealFormat::Paper32.characters(35), 64); + assert_eq!(RevealFormat::Paper32.characters(32), 60); + assert_eq!(RevealFormat::Paper32.characters(1), 6); + } + + #[test] + fn hex_has_no_parity() { + let layout = RevealFormat::Hex.layout(); + let rows = layout.rows("DEADBEEF01"); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].groups, ["DEAD", "BEEF", "01"]); + assert_eq!(rows[0].parity, ""); + assert_eq!(hex_upper(&[0xde, 0xad]), "DEAD"); + assert_eq!(RevealFormat::Hex.characters(5), 10); + } + + #[test] + fn a_format_is_named_and_parsed() { + assert_eq!("paper32".parse(), Ok(RevealFormat::Paper32)); + assert_eq!(RevealFormat::Hex.to_string(), "hex"); + assert_eq!( + "words".parse::().unwrap_err().to_string(), + "'words' is not a format; use paper32 or hex" + ); + } +} diff --git a/crates/rite-model/src/lib.rs b/crates/rite-model/src/lib.rs index f9a7c3c..c7eaea7 100644 --- a/crates/rite-model/src/lib.rs +++ b/crates/rite-model/src/lib.rs @@ -22,9 +22,12 @@ pub mod bundle; mod canonical; pub mod commitment; mod digest; +pub mod display; pub mod expression; pub mod ir; + mod material; +pub mod paper32; pub mod params; pub mod safe_path; #[cfg(test)] @@ -52,8 +55,9 @@ pub use ir::{ SectionId, Step, StepId, StepInputs, SymbolTable, }; +pub use display::{Layout, RevealFormat, TypedRow, TypedValue, UnknownRevealFormat}; pub use transcript::{ ErrorClass, ErrorRecord, FACT_TYPES, FACT_VOCABULARY, Format, Level, PLACEHOLDER_LIMIT, Prompt, - ResponseRecord, StepFact, StepOutcome, TRANSCRIPT_FORMAT, TRANSCRIPT_SCHEMA, TranscriptHeader, - ValidatorSpec, compile_pattern, + ResponseRecord, Shown, StepFact, StepOutcome, TRANSCRIPT_FORMAT, TRANSCRIPT_SCHEMA, + TranscriptHeader, ValidatorSpec, compile_pattern, }; diff --git a/crates/rite-model/src/paper32.rs b/crates/rite-model/src/paper32.rs new file mode 100644 index 0000000..0381f4f --- /dev/null +++ b/crates/rite-model/src/paper32.rs @@ -0,0 +1,760 @@ +//! Paper32: bytes as rows of base-32 characters a person writes down and +//! types back, with parity that repairs a slip and refuses a row it cannot +//! repair. +//! +//! The alphabet is Crockford's base 32, `0-9` and the letters without +//! `I`, `L`, `O` and `U`, one character per five bits. The decoder takes +//! either case and reads `O` as `0` and `I` or `L` as `1`, since those are +//! what a hand writes for them; a `?` is a character the person could not +//! read, an erasure, and so is a `U`, which stands for one character but +//! not which. Spaces and separators between groups are ignored; any other +//! character is refused where it stands. +//! +//! The characters go in rows of 32: 28 of data and 4 of parity, the last +//! row shorter and still ending in its 4 parity characters. Each row is a +//! Reed-Solomon codeword over GF(32): the data characters fix a polynomial +//! of degree below their count, evaluated at the first points of a fixed +//! order of the 32 field elements, and the parity characters are that +//! polynomial at the last four points. Any four characters of a row +//! determine the other 28, so a row repairs one wrong character or two +//! unreadable ones and still has two characters of parity to check the +//! repair against. Beyond that it is refused by name: a row that cannot +//! be trusted is one the person reads again, not one the code guesses. +//! +//! A correct sheet decodes without any of this: drop the last four +//! characters of each row and read the rest as base 32. + +use std::fmt; + +use zeroize::Zeroizing; + +/// The alphabet, in five-bit order. +const ALPHABET: &[u8; 32] = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ"; + +/// Data characters in a full row. +pub const ROW_DATA: usize = 28; + +/// Parity characters closing every row. +pub const ROW_PARITY: usize = 4; + +/// Characters in a full row. +pub const ROW_LEN: usize = ROW_DATA + ROW_PARITY; + +/// Unreadable characters a row may recover, keeping two characters of +/// parity to check the recovery. One wrong character is the other case. +const MAX_ERASURES: usize = 2; + +// ── GF(32) ────────────────────────────────────────────────────────────────── + +/// The field polynomial, x^5 + x^2 + 1. +const POLY: u8 = 0b10_0101; + +/// Multiply in GF(32). +const fn gf_mul(a: u8, b: u8) -> u8 { + let mut a = a & 31; + let mut b = b & 31; + let mut product = 0u8; + while b != 0 { + if b & 1 == 1 { + product ^= a; + } + b >>= 1; + a <<= 1; + if a & 0b10_0000 != 0 { + a ^= POLY; + } + } + product & 31 +} + +/// The inverses in GF(32), found once by looking: the field is small. +#[allow(clippy::indexing_slicing)] // `a` is below 32 by the loop's bound +const INVERSES: [u8; 32] = { + let mut table = [0u8; 32]; + let mut a = 1u8; + while a < 32 { + let mut x = 1u8; + while x < 32 { + if gf_mul(a, x) == 1 { + table[a as usize] = x; + } + x += 1; + } + a += 1; + } + table +}; + +/// The inverse in GF(32); zero has none and gives zero. +fn gf_inv(a: u8) -> u8 { + INVERSES.get(usize::from(a & 31)).copied().unwrap_or(0) +} + +/// The value at `x` of the polynomial through `points`, by Lagrange. +fn interpolate(points: &[(u8, u8)], x: u8) -> u8 { + let mut result = 0u8; + for (i, &(xi, yi)) in points.iter().enumerate() { + let mut basis = 1u8; + for (j, &(xj, _)) in points.iter().enumerate() { + if i != j { + basis = gf_mul(basis, gf_mul(x ^ xj, gf_inv(xi ^ xj))); + } + } + result ^= gf_mul(yi, basis); + } + result +} + +// ── rows of paper32 ──────────────────────────────────────────────────────────────── + +/// The evaluation point of position `i` in a row: the field element `i`, +/// data at 0 to 27, parity at 28 to 31. +fn point(position: usize) -> u8 { + u8::try_from(position & 31).unwrap_or(0) +} + +/// The parity of `data`, the row's polynomial at the four parity points. +fn parity(data: &[u8]) -> [u8; ROW_PARITY] { + let points: Vec<(u8, u8)> = data + .iter() + .enumerate() + .map(|(i, &d)| (point(i), d)) + .collect(); + let mut out = [0u8; ROW_PARITY]; + for (k, slot) in out.iter_mut().enumerate() { + *slot = interpolate(&points, point(ROW_DATA.saturating_add(k))); + } + out +} + +/// Five-bit values of `bytes`, the last padded with zero bits. +fn to_symbols(bytes: &[u8]) -> Vec { + let mut out = Vec::with_capacity(bytes.len().saturating_mul(8).div_ceil(5)); + let mut acc: u32 = 0; + let mut bits: u32 = 0; + for &b in bytes { + acc = (acc << 8) | u32::from(b); + bits = bits.saturating_add(8); + while bits >= 5 { + bits = bits.saturating_sub(5); + out.push(u8::try_from((acc >> bits) & 31).unwrap_or(0)); + } + } + if bits > 0 { + out.push(u8::try_from((acc << (5u32.saturating_sub(bits))) & 31).unwrap_or(0)); + } + out +} + +/// Bytes from five-bit values; the padding must be shorter than a +/// character and zero. +fn from_symbols(symbols: &[u8]) -> Option> { + let mut out = Vec::with_capacity(symbols.len().saturating_mul(5) / 8); + let mut acc: u32 = 0; + let mut bits: u32 = 0; + for &s in symbols { + acc = (acc << 5) | u32::from(s & 31); + bits = bits.saturating_add(5); + while bits >= 8 { + bits = bits.saturating_sub(8); + out.push(u8::try_from((acc >> bits) & 0xff).unwrap_or(0)); + } + } + if bits >= 5 || (acc << (8u32.saturating_sub(bits))) & 0xff != 0 { + return None; + } + Some(out) +} + +/// Write bytes as rows, one string with no separators. Grouping for the +/// eye is the caller's, since it does not take part in the encoding. +/// +/// Four bytes are seven characters of data, the last padded with zero +/// bits, and four of parity: +/// +/// ``` +/// use rite_model::paper32; +/// +/// assert_eq!(paper32::encode(b"rite"), "E9MQ8S8PH8B"); +/// ``` +pub fn encode(bytes: &[u8]) -> String { + let symbols = Zeroizing::new(to_symbols(bytes)); + let mut out = String::with_capacity(symbols.len().saturating_add(ROW_LEN).saturating_mul(2)); + for data in symbols.chunks(ROW_DATA) { + for &s in data { + out.push(letter(s)); + } + for p in parity(data) { + out.push(letter(p)); + } + } + out +} + +fn letter(symbol: u8) -> char { + ALPHABET + .get(usize::from(symbol & 31)) + .map_or('0', |&b| char::from(b)) +} + +/// What one character of the text is. +enum Read { + /// A separator between groups: nothing. + Separator, + /// A character the person could not read. + Erasure, + /// A character of the alphabet, or one usually written for it. + Value(u8), + /// A character with no place here. + Unknown, +} + +fn read(c: char) -> Read { + let c = c.to_ascii_uppercase(); + match c { + ' ' | '-' | '|' | '\t' | '\n' | '\r' | '.' => Read::Separator, + // U is outside the alphabet but stands for one character, most + // often a V: unreadable, so the row still recovers it. + '?' | 'U' => Read::Erasure, + 'O' => Read::Value(0), + 'I' | 'L' => Read::Value(1), + _ => ALPHABET + .iter() + .position(|&x| char::from(x) == c) + .and_then(|p| u8::try_from(p).ok()) + .map_or(Read::Unknown, Read::Value), + } +} + +/// Read rows back. The result carries the bytes and what was repaired, +/// so the person can be told which rows needed it. +/// +/// # Errors +/// +/// A character outside the alphabet, a row that cannot be repaired (more +/// than one wrong character or two unreadable ones), a shape that is not +/// rows, or a length that is not bytes, each named. +/// +/// Case, separators and the letters a hand writes for digits are read +/// through; a wrong character is repaired and its row named: +/// +/// ``` +/// use rite_model::paper32; +/// +/// let clean = paper32::decode("e9mq 8s8 | ph8b").unwrap(); +/// assert_eq!(*clean.bytes, *b"rite"); +/// assert!(clean.repaired.is_empty()); +/// +/// // The fifth character miscopied: still "rite", with row 1 named. +/// let slipped = paper32::decode("E9MQ9S8PH8B").unwrap(); +/// assert_eq!(*slipped.bytes, *b"rite"); +/// assert_eq!(slipped.repaired[0].row, 1); +/// ``` +pub fn decode(text: &str) -> Result { + let mut symbols: Vec> = Vec::with_capacity(text.len()); + for (position, c) in text.chars().enumerate() { + match read(c) { + Read::Separator => {} + Read::Erasure => symbols.push(None), + Read::Value(v) => symbols.push(Some(v)), + Read::Unknown => return Err(RowError::Character { position }), + } + } + if symbols.is_empty() { + return Err(RowError::Empty); + } + let tail = symbols.len() % ROW_LEN; + if tail != 0 && tail <= ROW_PARITY { + return Err(RowError::Shape { + characters: symbols.len(), + }); + } + + let mut data = Zeroizing::new(Vec::with_capacity(symbols.len())); + let mut repaired = Vec::new(); + for (index, row) in symbols.chunks(ROW_LEN).enumerate() { + let row_number = index.saturating_add(1); + let (fixed, repair) = repair_row(row).ok_or(RowError::Row { + row: row_number, + unreadable: row.iter().filter(|s| s.is_none()).count(), + })?; + if let Some(repair) = repair { + repaired.push(Repair { + row: row_number, + kind: repair, + }); + } + data.extend_from_slice( + fixed + .get(..row.len().saturating_sub(ROW_PARITY)) + .unwrap_or(&[]), + ); + } + let bytes = from_symbols(&data).ok_or(RowError::Padding)?; + Ok(Decoded { + bytes: Zeroizing::new(bytes), + repaired, + }) +} + +/// One row as typed, read on its own, for a frontend that takes a value a +/// row at a time and says at once which row needs another look. [`decode`] +/// reads the whole value; this checks a row the same way. `row` is the +/// row's number from 1, for the repair and the error to name. +/// +/// # Errors +/// +/// A character outside the alphabet, a row too short or too long to be +/// one, or a row that cannot be repaired. +/// +/// Two characters the person could not read, typed as `?` or `U`, are +/// recovered: +/// +/// ``` +/// use rite_model::paper32; +/// +/// let row = paper32::read_row("E9?Q8S8PU8B", 1).unwrap(); +/// assert_eq!(row.text.as_str(), "E9MQ8S8PH8B"); +/// assert!(row.repair.is_some()); +/// ``` +pub fn read_row(text: &str, row: usize) -> Result { + let mut symbols: Vec> = Vec::with_capacity(ROW_LEN); + for (position, c) in text.chars().enumerate() { + match read(c) { + Read::Separator => {} + Read::Erasure => symbols.push(None), + Read::Value(v) => symbols.push(Some(v)), + Read::Unknown => return Err(RowError::Character { position }), + } + } + if symbols.is_empty() { + return Err(RowError::Empty); + } + if symbols.len() <= ROW_PARITY || symbols.len() > ROW_LEN { + return Err(RowError::Shape { + characters: symbols.len(), + }); + } + let (fixed, repair) = repair_row(&symbols).ok_or(RowError::Row { + row, + unreadable: symbols.iter().filter(|s| s.is_none()).count(), + })?; + Ok(RowRead { + text: Zeroizing::new(fixed.iter().map(|&s| letter(s)).collect()), + repair: repair.map(|kind| Repair { row, kind }), + }) +} + +/// What [`read_row`] read. +#[derive(Debug)] +pub struct RowRead { + /// The row as it should read, in the alphabet's own characters. + pub text: Zeroizing, + /// The repair it took, if any. + pub repair: Option, +} + +/// The row as it should read, and what it took: nothing, or one repair. +/// `None` when no single repair makes it check. +fn repair_row(row: &[Option]) -> Option<(Vec, Option)> { + let erased: Vec = row + .iter() + .enumerate() + .filter_map(|(i, s)| s.is_none().then_some(i)) + .collect(); + if erased.len() > MAX_ERASURES { + return None; + } + let data_len = row.len().saturating_sub(ROW_PARITY); + + // Fill the erasures from the readable characters, then check. + let filled = fill(row, &erased, data_len)?; + if checks(&filled, data_len) { + let repair = (!erased.is_empty()).then_some(RepairKind::Unreadable { + count: erased.len(), + }); + return Some((filled, repair)); + } + if !erased.is_empty() { + // An unreadable character and a wrong one are more than the row + // can vouch for. + return None; + } + + // One wrong character: the one position that, treated as unreadable, + // gives a row that checks. Two positions would mean the row is + // ambiguous, which a single wrong character never is. + let mut found: Option<(usize, Vec)> = None; + for position in 0..row.len() { + let candidate = fill(row, &[position], data_len)?; + if checks(&candidate, data_len) && candidate.get(position) != row.get(position)?.as_ref() { + if found.is_some() { + return None; + } + found = Some((position, candidate)); + } + } + let (position, fixed) = found?; + Some((fixed, Some(RepairKind::Wrong { position }))) +} + +/// The row with the given positions recomputed from the others: every +/// readable character is a point of the row's polynomial, and any +/// `data_len` of them determine it. +fn fill(row: &[Option], holes: &[usize], data_len: usize) -> Option> { + let known: Vec<(u8, u8)> = row + .iter() + .enumerate() + .filter(|(i, s)| s.is_some() && !holes.contains(i)) + .filter_map(|(i, s)| s.map(|v| (row_point(i, row.len()), v))) + .take(data_len) + .collect(); + if known.len() < data_len { + return None; + } + let mut out = Vec::with_capacity(row.len()); + for (i, s) in row.iter().enumerate() { + match s { + Some(v) if !holes.contains(&i) => out.push(*v), + _ => out.push(interpolate(&known, row_point(i, row.len()))), + } + } + Some(out) +} + +/// The evaluation point of position `i` in a row of `len` characters: the +/// data points from 0, the parity points from 28, whatever the row's +/// length. +fn row_point(i: usize, len: usize) -> u8 { + let data_len = len.saturating_sub(ROW_PARITY); + if i < data_len { + point(i) + } else { + point(ROW_DATA.saturating_add(i.saturating_sub(data_len))) + } +} + +/// Whether the row's parity is the parity of its data. +fn checks(row: &[u8], data_len: usize) -> bool { + let (data, given) = row.split_at(data_len.min(row.len())); + parity(data) == given +} + +/// What [`decode`] read. +#[derive(Debug)] +pub struct Decoded { + /// The bytes, wiped when dropped. + pub bytes: Zeroizing>, + /// The rows that needed repair, in order. + pub repaired: Vec, +} + +/// A repair made to one row. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Repair { + /// The row, from 1. + pub row: usize, + /// What was repaired. + pub kind: RepairKind, +} + +/// What a row needed. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub enum RepairKind { + /// One character was wrong, at this position in the row, from 0. + Wrong { + /// Position in the row. + position: usize, + }, + /// This many characters were unreadable and recovered. + Unreadable { + /// How many. + count: usize, + }, +} + +impl fmt::Display for Repair { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self.kind { + RepairKind::Wrong { position } => write!( + f, + "row {}: character {} was wrong and has been corrected; check it against the sheet", + self.row, + position.saturating_add(1) + ), + RepairKind::Unreadable { count } => write!( + f, + "row {}: {count} unreadable character{} recovered", + self.row, + if count == 1 { "" } else { "s" } + ), + } + } +} + +/// Why rows did not read back. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub enum RowError { + /// Nothing but separators. + Empty, + /// Not a digit, a letter, a `?` or a separator, at this position in the + /// text. + Character { + /// Zero-based position in the text as given. + position: usize, + }, + /// The character count is not rows: a last row must hold at least one + /// data character before its four of parity. + Shape { + /// Characters read. + characters: usize, + }, + /// A row with more wrong or unreadable characters than it can repair. + Row { + /// The row, from 1. + row: usize, + /// Characters in it marked unreadable. + unreadable: usize, + }, + /// The data does not end on a whole byte. + Padding, +} + +impl fmt::Display for RowError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Empty => write!(f, "nothing to read"), + Self::Character { position } => write!( + f, + "character {} is not a digit or a letter; write ? for one that cannot be read", + position.saturating_add(1) + ), + Self::Shape { characters } => write!( + f, + "{characters} characters do not make rows of 32; a last row has at least 5" + ), + Self::Row { row, unreadable } => match unreadable { + 0 => write!( + f, + "row {row} does not check and has more than one wrong character; read it \ + again from the sheet" + ), + 1 | 2 => write!( + f, + "row {row} has {unreadable} unreadable and still does not check, so another \ + character is wrong; a row recovers two unreadable or one wrong, not both; \ + read it again from the sheet" + ), + _ => write!( + f, + "row {row} has {unreadable} unreadable characters and recovers at most two; \ + read it again from the sheet" + ), + }, + Self::Padding => write!(f, "the characters do not end on a whole byte"), + } + } +} + +impl std::error::Error for RowError {} + +#[cfg(test)] +#[allow(clippy::indexing_slicing, clippy::arithmetic_side_effects)] +mod tests { + use super::*; + + #[test] + fn the_field_has_inverses_and_the_polynomial_is_primitive() { + for a in 1..32u8 { + assert_eq!(gf_mul(a, gf_inv(a)), 1, "{a}"); + } + // 2 generates the multiplicative group: 31 distinct powers. + let mut seen = std::collections::BTreeSet::new(); + let mut x = 1u8; + for _ in 0..31 { + seen.insert(x); + x = gf_mul(x, 2); + } + assert_eq!(seen.len(), 31); + assert_eq!(x, 1); + } + + #[test] + fn a_row_is_its_data_and_four_parity_characters() { + let text = encode(&[0u8; 35]); + assert_eq!(text.len(), 64); + assert_eq!(&text[..28], "0".repeat(28)); + assert_eq!(&text[28..32], "0000"); + let text = encode(&[0xFF; 35]); + assert_eq!(text.len(), 64); + assert!(text.chars().all(|c| ALPHABET.contains(&(c as u8)))); + } + + #[test] + fn bytes_round_trip_at_every_length() { + for len in 0..=100usize { + let bytes: Vec = (0..len) + .map(|i| u8::try_from(i.wrapping_mul(53).wrapping_add(7) & 0xff).unwrap()) + .collect(); + let text = encode(&bytes); + let rows = len.saturating_mul(8).div_ceil(5).div_ceil(ROW_DATA); + assert_eq!( + text.len(), + len.saturating_mul(8).div_ceil(5) + rows * ROW_PARITY, + "{len}" + ); + if len == 0 { + assert_eq!(decode(&text).unwrap_err(), RowError::Empty); + continue; + } + let decoded = decode(&text).unwrap_or_else(|e| panic!("{len}: {e}")); + assert_eq!(*decoded.bytes, bytes, "{len}"); + assert!(decoded.repaired.is_empty()); + } + } + + #[test] + fn case_confusions_and_separators_are_read_through() { + let bytes = b"a value to write"; + let text = encode(bytes); + let grouped: String = text + .to_ascii_lowercase() + .as_bytes() + .chunks(4) + .map(|g| std::str::from_utf8(g).unwrap()) + .collect::>() + .join(" "); + assert_eq!(*decode(&grouped).unwrap().bytes, bytes); + // O for 0, I and L for 1. + let confused = text.replace('0', "O").replace('1', "l"); + assert_eq!(*decode(&confused).unwrap().bytes, bytes); + // U is read as unreadable and recovered. + let mut chars: Vec = text.chars().collect(); + chars[0] = 'u'; + let with_u: String = chars.iter().collect(); + let decoded = decode(&with_u).unwrap(); + assert_eq!(*decoded.bytes, bytes); + assert_eq!( + decoded.repaired, + vec![Repair { + row: 1, + kind: RepairKind::Unreadable { count: 1 } + }] + ); + // Any other character is refused where it stands. + chars[0] = '#'; + let outside: String = chars.iter().collect(); + assert_eq!( + decode(&outside).unwrap_err(), + RowError::Character { position: 0 } + ); + } + + #[test] + fn one_wrong_character_anywhere_in_a_row_is_corrected_and_named() { + let bytes = [0x5A; 35]; + let text = encode(&bytes); + let chars: Vec = text.chars().collect(); + for position in 0..chars.len() { + let mut wrong = chars.clone(); + wrong[position] = if wrong[position] == 'Q' { 'R' } else { 'Q' }; + let wrong: String = wrong.into_iter().collect(); + let decoded = decode(&wrong).unwrap_or_else(|e| panic!("position {position}: {e}")); + assert_eq!(*decoded.bytes, bytes, "position {position}"); + assert_eq!( + decoded.repaired, + [Repair { + row: position / ROW_LEN + 1, + kind: RepairKind::Wrong { + position: position % ROW_LEN + } + }], + "position {position}" + ); + } + } + + #[test] + fn two_unreadable_characters_in_a_row_are_recovered() { + let bytes = b"thirty-two bytes of some secret!"; + let text = encode(bytes); + let mut chars: Vec = text.chars().collect(); + chars[3] = '?'; + chars[30] = '?'; + chars[40] = '?'; + let smudged: String = chars.iter().collect(); + let decoded = decode(&smudged).unwrap(); + assert_eq!(*decoded.bytes, bytes); + assert_eq!(decoded.repaired.len(), 2); + assert_eq!( + decoded.repaired[0].to_string(), + "row 1: 2 unreadable characters recovered" + ); + assert_eq!( + decoded.repaired[1], + Repair { + row: 2, + kind: RepairKind::Unreadable { count: 1 } + } + ); + } + + #[test] + fn a_row_beyond_repair_is_refused_by_name() { + let text = encode(&[0x33; 35]); + let mut chars: Vec = text.chars().collect(); + // Two wrong characters in row 2. + chars[35] = if chars[35] == 'A' { 'B' } else { 'A' }; + chars[50] = if chars[50] == 'A' { 'B' } else { 'A' }; + let wrong: String = chars.iter().collect(); + assert_eq!( + decode(&wrong).unwrap_err(), + RowError::Row { + row: 2, + unreadable: 0 + } + ); + // Three unreadable in row 1. + let mut chars: Vec = text.chars().collect(); + chars[0] = '?'; + chars[1] = '?'; + chars[2] = '?'; + let smudged: String = chars.iter().collect(); + assert_eq!( + decode(&smudged).unwrap_err(), + RowError::Row { + row: 1, + unreadable: 3 + } + ); + // One wrong and one unreadable in a row. + let mut chars: Vec = text.chars().collect(); + chars[5] = '?'; + chars[9] = if chars[9] == 'A' { 'B' } else { 'A' }; + let mixed: String = chars.iter().collect(); + let err = decode(&mixed).unwrap_err(); + assert_eq!( + err, + RowError::Row { + row: 1, + unreadable: 1 + } + ); + assert!(err.to_string().contains("not both")); + } + + #[test] + fn a_shape_that_is_not_rows_is_refused() { + assert_eq!( + decode("ABCD").unwrap_err(), + RowError::Shape { characters: 4 } + ); + assert_eq!( + decode(&"A".repeat(34)).unwrap_err(), + RowError::Shape { characters: 34 } + ); + assert_eq!(decode(" - ").unwrap_err(), RowError::Empty); + } +} diff --git a/crates/rite-model/src/params.rs b/crates/rite-model/src/params.rs index 119dce4..b3399f9 100644 --- a/crates/rite-model/src/params.rs +++ b/crates/rite-model/src/params.rs @@ -14,6 +14,7 @@ use serde::{Deserialize, Serialize}; +use crate::display::RevealFormat; use crate::transcript::{Format, ValidatorSpec, compile_pattern}; use crate::types::{ActionType, CertProfile, SharingScheme}; use rite_sdk::{KeyAlgorithm, KeyUsages, SignAlgorithm, WrapScheme}; @@ -107,6 +108,7 @@ pub fn check(action: ActionType, with: &serde_json::Value) -> Vec { } errors } + ActionType::Reveal | ActionType::EnterShare => reveal(with), ActionType::UnwrapKey | ActionType::ImportKey => { let mut errors = key_identity(with, "expect_key"); errors.extend(named_value(with, "algorithm", |name| { @@ -115,7 +117,18 @@ pub fn check(action: ActionType, with: &serde_json::Value) -> Vec { })); errors } - ActionType::EnterValue | ActionType::EnterSecret => entry_shape(with), + ActionType::EnterSecret => entry_shape(with), + ActionType::EnterValue => { + let mut errors = entry_shape(with); + if with.get("format").and_then(serde_json::Value::as_str) == Some("paper32") { + errors.push(ParamError { + message: "'paper32' is for a value from a sheet, which is a secret: use \ + enter_secret, or enter_share for a share" + .to_string(), + }); + } + errors + } ActionType::ClockCheck | ActionType::Confirm @@ -135,6 +148,24 @@ pub fn check(action: ActionType, with: &serde_json::Value) -> Vec { } } +/// `reveal` and `enter_share`: a format this build shows, and a length in +/// bytes. +fn reveal(with: &serde_json::Value) -> Vec { + let mut errors = named_value(with, "format", |name| { + name.parse::() + .map(|_| ()) + .map_err(|e| e.to_string()) + }); + if let Some(value) = with.get("length") + && value.as_u64().is_none_or(|n| n == 0) + { + errors.push(ParamError { + message: format!("'length' must be a positive integer of bytes, found {value}"), + }); + } + errors +} + /// A share count or threshold: an integer from 2 to the scheme's limit. /// /// One is not a split. An absent field is deferred, as everywhere here; a @@ -263,7 +294,8 @@ impl FormatSpec { }) } _ => Err(format!( - "'format' must name a format (text, digits, alphanumeric, hex, base64) or be \ + "'format' must name a format (text, digits, alphanumeric, hex, base64, \ + paper32) or be \ {{ pattern: \"...\" }}, found {value}" )), } @@ -615,6 +647,19 @@ mod tests { ); } + #[test] + fn reveal_checks_its_format_and_length() { + assert!( + check( + ActionType::Reveal, + &json!({"message": "Write this down", "format": "paper32", "length": 32}) + ) + .is_empty() + ); + assert!(sole(ActionType::Reveal, &json!({"format": "words"})).contains("not a format")); + assert!(sole(ActionType::Reveal, &json!({"length": 0})).contains("positive integer")); + } + #[test] fn split_secret_counts_are_bounded_by_the_scheme() { assert!( @@ -756,6 +801,16 @@ mod tests { .contains("'format' must name a format") ); assert!(sole(ActionType::EnterSecret, &json!({"length": 0})).contains("positive integer")); + assert!( + sole(ActionType::EnterValue, &json!({"format": "paper32"})).contains("enter_secret") + ); + assert!( + check( + ActionType::EnterSecret, + &json!({"format": "paper32", "length": 32}) + ) + .is_empty() + ); assert!( sole(ActionType::EnterSecret, &json!({"length": "six"})).contains("positive integer") ); diff --git a/crates/rite-model/src/transcript.rs b/crates/rite-model/src/transcript.rs index 03bf349..fecacf0 100644 --- a/crates/rite-model/src/transcript.rs +++ b/crates/rite-model/src/transcript.rs @@ -308,6 +308,17 @@ pub enum Format { /// Bytes as standard base64 with padding, as `openssl base64` writes it. #[cfg_attr(test, schemars(description = "Bytes as standard base64 with padding."))] Base64, + /// Bytes as paper32 rows, as `reveal` shows them and a sheet holds + /// them; typed a row at a time, a wrong character in a row corrected. + /// See [`crate::paper32`]. + #[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, } impl Format { @@ -316,7 +327,7 @@ impl Format { pub fn is_encoding(self) -> bool { match self { Format::Text | Format::Digits | Format::Alphanumeric => false, - Format::Hex | Format::Base64 => true, + Format::Hex | Format::Base64 | Format::Paper32 => true, } } @@ -324,7 +335,7 @@ impl Format { /// whatever decodes. fn accepts(self, c: char) -> bool { match self { - Format::Text | Format::Hex | Format::Base64 => true, + Format::Text | Format::Hex | Format::Base64 | Format::Paper32 => true, Format::Digits => c.is_ascii_digit(), Format::Alphanumeric => c.is_ascii_alphanumeric(), } @@ -347,6 +358,11 @@ impl Format { .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()), + // Rows and positions only: a paper32 refusal names where to look + // on the sheet, never a character. + Format::Paper32 => crate::paper32::decode(value) + .map(|decoded| decoded.bytes.to_vec()) + .map_err(|e| e.to_string()), Format::Text | Format::Digits | Format::Alphanumeric => { Err(format!("{} is text and does not decode", self.describe())) } @@ -362,6 +378,7 @@ impl Format { Format::Alphanumeric => "letters or digits", Format::Hex => "bytes as hex", Format::Base64 => "bytes as base64", + Format::Paper32 => "bytes as paper32 rows", } } @@ -382,6 +399,7 @@ impl Format { 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]), }) } } @@ -401,8 +419,10 @@ impl std::str::FromStr for Format { "alphanumeric" => Ok(Format::Alphanumeric), "hex" => Ok(Format::Hex), "base64" => Ok(Format::Base64), + "paper32" => Ok(Format::Paper32), other => Err(format!( - "unknown format '{other}': expected text, digits, alphanumeric, hex or base64" + "unknown format '{other}': expected text, digits, alphanumeric, hex, base64 \ + or paper32" )), } } @@ -588,6 +608,132 @@ pub enum Prompt { #[cfg_attr(test, schemars(description = "The hint shown, if any."))] hint: Option, }, + /// Show a value while the prompt is up, and withdraw it when the + /// person acknowledges. What was shown is never recorded: the + /// transcript carries the label and the acknowledgement. + #[cfg_attr( + test, + schemars( + description = "A value shown for a person to write down, withdrawn when they \ + acknowledge. The value is never recorded." + ) + )] + Reveal { + /// What the value is and what to do with it, shown above it. + #[cfg_attr(test, schemars(description = "The label shown above the value."))] + label: String, + /// The step's `note:`, the same text the sheet carries. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(test, schemars(description = "The note shown with the value, if any."))] + note: Option, + /// The value, laid out for writing down. Skipped when the prompt + /// is written to the transcript, and empty when one is read back. + #[serde(skip)] + shown: Shown, + }, + /// Rows of a value a person types from a sheet, one at a time, each + /// checked as it is entered. The answer is a secret: the transcript + /// carries the label and that an answer was given, never the rows. + #[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 { + /// What the value is, shown above the rows. + #[cfg_attr(test, schemars(description = "The label shown above the rows."))] + label: String, + /// The step's `note:`. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(test, schemars(description = "The note shown with the rows, if any."))] + note: Option, + /// The encoding the sheet was written in. + #[cfg_attr(test, schemars(description = "The encoding the sheet was written in."))] + format: crate::display::RevealFormat, + /// Rows expected, when the step knows the value's length; without + /// it a short row, or an empty row after full ones, ends the entry. + #[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, + /// The rule the rows as a whole must satisfy, which the runtime + /// checks and a rehearsal can build a stand-in for. `None` when the + /// step checks the value itself, as for a share, which no made-up + /// value is. + #[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, + }, +} + +impl Prompt { + /// The prompt as the transcript records it: the same, except that a + /// value shown for writing down is left out, so no copy of it travels + /// with the fact. + #[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(), + } + } +} + +/// A value on screen for a person to write down, with the shape that +/// lays it out. Wiped when dropped; printed as its size and format, never +/// its characters. +#[derive(Clone, Default)] +pub struct Shown { + text: zeroize::Zeroizing, + layout: crate::display::Layout, +} + +impl Shown { + /// A value and the shape to show it in. + pub fn new(text: String, layout: crate::display::Layout) -> Self { + Self { + text: zeroize::Zeroizing::new(text), + layout, + } + } + + /// The value as written. + pub fn text(&self) -> &str { + &self.text + } + + /// The shape it is shown in. + 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 + ) + } } /// Serializable, redacted form of a user response. diff --git a/crates/rite-model/src/types.rs b/crates/rite-model/src/types.rs index 008b4cf..6fffb13 100644 --- a/crates/rite-model/src/types.rs +++ b/crates/rite-model/src/types.rs @@ -85,6 +85,14 @@ pub enum ActionType { /// Use when a value must be verified against something external (physical label, /// document). Supports NATO phonetic alphabet and hex formatting. OralReadback, + /// Show a value on screen for a person to write down, then withdraw it. + /// + /// Reads `value:`, a share or any byte artifact, and shows it in a + /// chosen encoding while a prompt is up; on acknowledgement it is gone. + /// The transcript records that the value was shown and nothing of it. + /// `rite script` prints a page for the step, one box per character + /// in rows, the parity cells set apart. + Reveal, /// Capture machine information (hostname, CPU, OS) as evidence. /// /// Records device identity to prove which machine ran the ceremony. @@ -158,6 +166,15 @@ pub enum ActionType { /// The result stays in memory and is erased when the run ends, like the /// content `decrypt_data` opens. CombineShares, + /// Type a share back from its sheet, row by row. + /// + /// Each row is checked as it is typed: a wrong character is corrected + /// and named, two unreadable ones are recovered, and a row that needs + /// more is typed again. Rows that are not a share are refused before + /// the step ends. Creates a share `combine_shares` reads, named without + /// a `share_N` property. The transcript records the share's index and + /// which rows were repaired, never the rows. + EnterShare, /// Install key material the ceremony holds as a key of a named algorithm. /// /// `unwrap_key` without the decrypt. The bytes can be a material carried @@ -344,6 +361,16 @@ impl SharingScheme { SharingScheme::RiteSssV1 => 100, } } + + /// The length in bytes of one share of a secret of `secret_len` bytes, + /// as a sheet or a transport holds it: for `rite-sss/v1`, a version, a + /// threshold and an index in front of one byte per byte of the secret. + #[must_use] + pub fn share_len(self, secret_len: usize) -> usize { + match self { + SharingScheme::RiteSssV1 => secret_len.saturating_add(3), + } + } } impl fmt::Display for SharingScheme { @@ -486,6 +513,7 @@ impl ActionType { ActionType::Confirm, ActionType::CheckValue, ActionType::OralReadback, + ActionType::Reveal, ActionType::MachineInfo, ActionType::EnterValue, ActionType::EnterSecret, @@ -497,6 +525,7 @@ impl ActionType { ActionType::DecryptData, ActionType::SplitSecret, ActionType::CombineShares, + ActionType::EnterShare, ActionType::ExportPublic, ActionType::SignData, ActionType::VerifySignature, @@ -539,11 +568,13 @@ impl ActionType { | ActionType::Confirm | ActionType::CheckValue | ActionType::OralReadback + | ActionType::Reveal | ActionType::MachineInfo | ActionType::EnterValue | ActionType::EnterSecret | ActionType::Attest | ActionType::CombineShares + | ActionType::EnterShare | ActionType::GatherEntropy => BackendUsage::Unused, } } @@ -564,9 +595,12 @@ impl ActionType { // what it is lifting rather than the step guessing. ActionType::ImportKey => &["algorithm"], ActionType::SplitSecret => &["threshold", "shares"], - // The label is what the person sees at the keyboard, and there is - // no default that names what they are being asked for. - ActionType::EnterValue | ActionType::EnterSecret => &["message"], + // The label is what the person sees at the keyboard or on the + // sheet, and there is no default that names what it is. + ActionType::EnterValue + | ActionType::EnterSecret + | ActionType::Reveal + | ActionType::EnterShare => &["message"], ActionType::ClockCheck | ActionType::Confirm @@ -609,6 +643,7 @@ impl ActionType { ActionType::ClockCheck | ActionType::Confirm => &["message"], ActionType::CheckValue => &["actual", "expected", "message", "sensitive"], ActionType::OralReadback => &["value", "format", "characters", "message"], + ActionType::Reveal | ActionType::EnterShare => &["message", "format", "note", "length"], ActionType::MachineInfo => &[ "include_machine_id", "include_cpu", @@ -703,6 +738,7 @@ impl ActionType { lists: &[], }, ActionType::GenerateCsr => ReadsContract::required(&["signing_key"]), + ActionType::Reveal => ReadsContract::required(&["value"]), ActionType::ClockCheck | ActionType::Confirm @@ -718,7 +754,8 @@ impl ActionType { | ActionType::TpmAttest | ActionType::PivReadCertificate | ActionType::PivSign - | ActionType::YubikeyAttestSlot => ReadsContract::NONE, + | ActionType::YubikeyAttestSlot + | ActionType::EnterShare => ReadsContract::NONE, } } @@ -732,6 +769,7 @@ impl ActionType { ActionType::Confirm => "Confirm readiness or completion of a manual step.", ActionType::CheckValue => "Verify a value matches an expected result.", ActionType::OralReadback => "Read back a value aloud for verification.", + ActionType::Reveal => "Show a value for a person to write down.", ActionType::MachineInfo => "Record system and environment information.", ActionType::EnterValue => "Type a value the ceremony records.", ActionType::EnterSecret => "Type a secret the ceremony holds and never records.", @@ -756,6 +794,7 @@ impl ActionType { "Split a secret into shares a threshold of which reconstruct it." } ActionType::CombineShares => "Reconstruct a secret from its shares.", + ActionType::EnterShare => "Type a share back from its sheet.", } } } @@ -767,6 +806,7 @@ impl std::fmt::Display for ActionType { ActionType::Confirm => write!(f, "confirm"), ActionType::CheckValue => write!(f, "check_value"), ActionType::OralReadback => write!(f, "oral_readback"), + ActionType::Reveal => write!(f, "reveal"), ActionType::MachineInfo => write!(f, "machine_info"), ActionType::EnterValue => write!(f, "enter_value"), ActionType::EnterSecret => write!(f, "enter_secret"), @@ -778,6 +818,7 @@ impl std::fmt::Display for ActionType { ActionType::DecryptData => write!(f, "decrypt_data"), ActionType::SplitSecret => write!(f, "split_secret"), ActionType::CombineShares => write!(f, "combine_shares"), + ActionType::EnterShare => write!(f, "enter_share"), ActionType::ExportPublic => write!(f, "export_public"), ActionType::SignData => write!(f, "sign_data"), ActionType::VerifySignature => write!(f, "verify_signature"), @@ -979,6 +1020,7 @@ mod tests { (ActionType::Confirm, "\"confirm\""), (ActionType::CheckValue, "\"check_value\""), (ActionType::OralReadback, "\"oral_readback\""), + (ActionType::Reveal, "\"reveal\""), (ActionType::MachineInfo, "\"machine_info\""), (ActionType::GenerateKey, "\"generate_key\""), (ActionType::WrapKey, "\"wrap_key\""), @@ -988,6 +1030,7 @@ mod tests { (ActionType::DecryptData, "\"decrypt_data\""), (ActionType::SplitSecret, "\"split_secret\""), (ActionType::CombineShares, "\"combine_shares\""), + (ActionType::EnterShare, "\"enter_share\""), (ActionType::ExportPublic, "\"export_public\""), (ActionType::Attest, "\"attest\""), (ActionType::TpmAttest, "\"tpm_attest\""), @@ -1014,6 +1057,7 @@ mod tests { ActionType::Confirm, ActionType::CheckValue, ActionType::OralReadback, + ActionType::Reveal, ActionType::MachineInfo, ActionType::GenerateKey, ActionType::WrapKey, @@ -1158,6 +1202,7 @@ mod tests { | ActionType::Confirm | ActionType::CheckValue | ActionType::OralReadback + | ActionType::Reveal | ActionType::MachineInfo | ActionType::EnterValue | ActionType::EnterSecret @@ -1169,6 +1214,7 @@ mod tests { | ActionType::DecryptData | ActionType::SplitSecret | ActionType::CombineShares + | ActionType::EnterShare | ActionType::ExportPublic | ActionType::SignData | ActionType::VerifySignature diff --git a/crates/rite-render/src/engine.rs b/crates/rite-render/src/engine.rs index 475a5a4..59b3234 100644 --- a/crates/rite-render/src/engine.rs +++ b/crates/rite-render/src/engine.rs @@ -5,13 +5,14 @@ //! report cannot drift apart visually. use crate::report::ReportData; -use crate::view::{Branding, ReportView, ScriptView, render_prose_html}; +use crate::view::{Branding, ReportView, ScriptView, WorksheetsView, render_prose_html}; use minijinja::value::Value; use minijinja::{Environment, context}; use rite_model::Ceremony; const SCRIPT_TEMPLATE: &str = include_str!("../templates/script.html.jinja"); const REPORT_TEMPLATE: &str = include_str!("../templates/report.html.jinja"); +const WORKSHEETS_TEMPLATE: &str = include_str!("../templates/worksheets.html.jinja"); const FORMAL_CSS: &str = include_str!("../templates/themes/formal.css"); /// A built-in document theme. @@ -69,6 +70,7 @@ fn environment() -> Result, minijinja::Error> { }); env.add_template("script.html", SCRIPT_TEMPLATE)?; env.add_template("report.html", REPORT_TEMPLATE)?; + env.add_template("worksheets.html", WORKSHEETS_TEMPLATE)?; Ok(env) } @@ -93,6 +95,33 @@ pub fn render_script( }) } +/// Render the worksheets a ceremony's `reveal` steps call for: one page per +/// value a person writes down, printed once and kept with the value. +/// `None` when no step writes one. +/// +/// # Errors +/// +/// Returns a [`minijinja::Error`] if a template fails to compile or render. +pub fn render_worksheets( + ceremony: &Ceremony, + branding: &Branding, + theme: Theme, +) -> Result, minijinja::Error> { + let Some(view) = WorksheetsView::from_ceremony(ceremony) else { + return Ok(None); + }; + let env = environment()?; + let template = env.get_template("worksheets.html")?; + template + .render(context! { + worksheets => view, + branding => branding, + css => theme.css(), + theme => theme.as_str(), + }) + .map(Some) +} + /// Render a post-ceremony report to a self-contained HTML document. /// /// # Errors diff --git a/crates/rite-render/src/lib.rs b/crates/rite-render/src/lib.rs index 0dcdf8f..328829c 100644 --- a/crates/rite-render/src/lib.rs +++ b/crates/rite-render/src/lib.rs @@ -24,6 +24,6 @@ pub mod report; mod structure; mod view; -pub use engine::{Theme, render_report, render_script}; +pub use engine::{Theme, render_report, render_script, render_worksheets}; pub use structure::{ActGroup, ScriptStructure, SectionGroup, build_script_structure}; -pub use view::{Branding, ReportView, ScriptView, validate_accent}; +pub use view::{Branding, ReportView, ScriptView, WorksheetsView, validate_accent}; diff --git a/crates/rite-render/src/view.rs b/crates/rite-render/src/view.rs index f7b60e9..5efac96 100644 --- a/crates/rite-render/src/view.rs +++ b/crates/rite-render/src/view.rs @@ -17,8 +17,10 @@ use crate::structure::build_script_structure; use base64ct::{Base64, Encoding}; use chrono::{DateTime, Duration, Utc}; use minijinja::HtmlEscape; -use rite_model::expression::ExprValue; -use rite_model::{Ceremony, MaterialKind, ParamId, RoleId, Step}; +use rite_model::expression::{ExprValue, Literal}; +use rite_model::{ + ActionType, Ceremony, MaterialKind, ParamId, RevealFormat, RoleId, SharingScheme, Step, +}; use serde::Serialize; use std::collections::{HashMap, HashSet}; @@ -200,6 +202,85 @@ pub struct StepView { pub preconditions: Vec, /// Pre-built "Before step …" label for the preconditions box. pub precondition_label: Option, + /// The sheet a `reveal` step hands its value over on. + pub worksheet: Option, +} + +/// A sheet for a value written by hand: what the step says it is, and +/// rows of boxes for the characters with the parity cells set apart. +/// +/// The shape comes from the step's format, so it is known before the +/// ceremony runs. The rows are exact when the step gives a `length:`, and +/// a generous grid otherwise. +#[derive(Debug, Clone, Serialize)] +pub struct WorksheetView { + /// The step's `message:`, the heading of the sheet. + pub title: String, + /// The step's `note:`. + pub note: Option, + /// The rows, in writing order. + pub rows: Vec, + /// Whether the rows are exact or a grid to use as needed. + pub exact: bool, + /// The alphabet, in words. + pub alphabet: String, + /// The encoding's name. + pub algorithm: String, +} + +/// One row of boxes on a sheet. +#[derive(Debug, Clone, Serialize)] +pub struct WorksheetRow { + /// Box counts per group of data characters. + pub groups: Vec, + /// Boxes for the parity characters; zero when there are none. + pub parity: usize, +} + +/// The worksheets document: one page per value written by hand. +#[derive(Debug, Clone, Serialize)] +pub struct WorksheetsView { + /// Ceremony title. + pub title: String, + /// The ceremony date, when a parameter supplies one. + pub ceremony_date: Option, + /// One sheet per `reveal` step, in execution order. + pub sheets: Vec, +} + +/// One page of the worksheets document. +#[derive(Debug, Clone, Serialize)] +pub struct SheetView { + /// The sheet. + pub worksheet: WorksheetView, +} + +impl WorksheetsView { + /// The sheets a ceremony's `reveal` steps call for, or `None` when it + /// has none. + #[must_use] + pub fn from_ceremony(ceremony: &Ceremony) -> Option { + let script = ScriptView::from_ceremony(ceremony); + let sheets: Vec = script + .acts + .iter() + .flat_map(|act| &act.sections) + .flat_map(|section| §ion.steps) + .filter_map(|step| { + step.worksheet.as_ref().map(|worksheet| SheetView { + worksheet: worksheet.clone(), + }) + }) + .collect(); + if sheets.is_empty() { + return None; + } + Some(Self { + title: script.title, + ceremony_date: script.ceremony_date, + sheets, + }) + } } /// A declared output. @@ -409,7 +490,92 @@ fn step_view(step: &Step, resolved: &Ceremony, abbrevs: &HashMap role_abbrev, preconditions: step.preconditions.clone(), precondition_label, + worksheet: worksheet_view(step, resolved), + } +} + +/// Rows when the length is not known: enough for a share of a 64-byte +/// secret, 67 bytes, in either format (hex takes five rows of 32). +const OPEN_ROWS: usize = 5; + +/// The sheet for a `reveal` step, from its definition alone. +fn worksheet_view(step: &Step, resolved: &Ceremony) -> Option { + if step.action != ActionType::Reveal { + return None; } + let literal = |key: &str| step.with.get(key).and_then(ExprValue::as_literal_string); + let format = literal("format").and_then(|name| name.parse::().ok()); + let length = step.with.get("length").and_then(|v| match v { + ExprValue::Literal(Literal::Integer(n)) => usize::try_from(*n).ok(), + _ => None, + }); + + // A share is what a `split_secret` or an `enter_share` step created; + // the artifact's kind is not in the definition, but its producer is. + // `length:` counts the secret's bytes, and the sheet the share's. + let share = step + .reads_resolved + .as_ref() + .and_then(|inputs| inputs.get("value")) + .map(rite_model::ArtifactRef::artifact_id) + .is_some_and(|id| { + resolved + .execution_plan + .iter() + .filter(|s| s.creates.as_ref() == Some(&id)) + .any(|s| matches!(s.action, ActionType::SplitSecret | ActionType::EnterShare)) + }); + let layout = RevealFormat::layout_for(format); + let characters = length.map(|len| { + let bytes = if share { + SharingScheme::RiteSssV1.share_len(len) + } else { + len + }; + layout.format.characters(bytes) + }); + + // Rows of boxes: full rows, then whatever data is left with its own + // parity; or the open grid. + let row_len = layout.row.saturating_add(layout.parity).max(1); + let group = layout.group.max(1); + let row_of = |data: usize| { + let mut groups = Vec::new(); + let mut left = data; + while left > 0 { + let take = left.min(group); + groups.push(take); + left = left.saturating_sub(take); + } + WorksheetRow { + groups, + parity: layout.parity, + } + }; + let rows: Vec = match characters { + Some(total) => { + let mut rows = Vec::new(); + let mut left = total; + while left > 0 { + let take = left.min(row_len); + rows.push(row_of(take.saturating_sub(layout.parity))); + left = left.saturating_sub(take); + } + rows + } + None => (0..OPEN_ROWS).map(|_| row_of(layout.row)).collect(), + }; + + Some(WorksheetView { + title: literal("message") + .unwrap_or("Write the value shown on screen") + .to_string(), + note: literal("note").map(str::to_string), + rows, + exact: characters.is_some(), + alphabet: layout.alphabet, + algorithm: layout.algorithm, + }) } /// Resolve the human-readable instruction for a step. diff --git a/crates/rite-render/templates/themes/formal.css b/crates/rite-render/templates/themes/formal.css index 898b9fd..e0dc1f8 100644 --- a/crates/rite-render/templates/themes/formal.css +++ b/crates/rite-render/templates/themes/formal.css @@ -300,6 +300,59 @@ ul.checklist li .item-desc { color: var(--muted); } +/* === Worksheets: one sheet per page, printed once === */ +.sheet { + break-after: page; +} +.sheet:last-child { + break-after: auto; +} +.worksheet { + border: 1px solid var(--hair); + padding: 18px 20px; + margin: 24px 0; +} +.ws-note { + margin: 18px 0 0 0; + color: var(--muted); +} +.ws-line { + display: flex; + flex-wrap: nowrap; + align-items: center; + gap: 7px; + margin: 7px 0; +} +.ws-row-number { + width: 1.4em; + color: var(--muted); + font-size: 0.85em; + text-align: right; +} +.ws-group { + display: inline-flex; + gap: 2px; +} +.ws-box { + display: inline-block; + width: 0.95em; + height: 1.35em; + border: 1px solid #000; +} +.ws-parity { + margin-left: 6px; +} +.ws-parity .ws-box { + background: var(--tint); + -webkit-print-color-adjust: exact; + print-color-adjust: exact; +} +.ws-small { + margin: 8px 0 0 0; + color: var(--muted); + font-size: 0.8em; +} + /* === Duties === */ .duties-intro { color: var(--muted); @@ -503,7 +556,9 @@ code, .hash { .duty, .signature-block, .fingerprint-record, - .preconditions { + .preconditions, + .worksheet, + .sheet { break-inside: avoid; } /* Keep a section heading, and the intro line beneath it, with the first diff --git a/crates/rite-render/templates/worksheets.html.jinja b/crates/rite-render/templates/worksheets.html.jinja new file mode 100644 index 0000000..893d504 --- /dev/null +++ b/crates/rite-render/templates/worksheets.html.jinja @@ -0,0 +1,46 @@ + + + + + {{ worksheets.title }} — Worksheets + +{%- if branding.accent %} + +{%- endif %} + + +{%- for sheet in worksheets.sheets %} +
+
+{%- if branding.logo_data_uri %} + +{%- endif %} +{%- if branding.brand_name %} +

{{ branding.brand_name }}

+{%- endif %} +

{{ worksheets.title }}{% if worksheets.ceremony_date %}, {{ worksheets.ceremony_date }}{% endif %}

+

{{ sheet.worksheet.title }}

+
+{%- if sheet.worksheet.note %} +

{{ sheet.worksheet.note }}

+{%- endif %} +
+{%- for row in sheet.worksheet.rows %} +
+ {{ loop.index }} +{%- for n in row.groups %} + {% for i in range(n) %}{% endfor %} +{%- endfor %} +{%- if row.parity > 0 %} + {% for i in range(row.parity) %}{% endfor %} +{%- endif %} +
+{%- endfor %} +

{% if not sheet.worksheet.exact %}Use as many rows as the value has, each ending in its parity cells. {% endif %}{{ sheet.worksheet.alphabet }}. {{ sheet.worksheet.algorithm }}{% if sheet.worksheet.rows[0].parity > 0 %}; the shaded cells at the end of each row are its parity{% endif %}.

+
+
+{%- endfor %} + + diff --git a/crates/rite-render/tests/fixtures/reissue_a_sheet.rite.yaml b/crates/rite-render/tests/fixtures/reissue_a_sheet.rite.yaml new file mode 100644 index 0000000..eb83a44 --- /dev/null +++ b/crates/rite-render/tests/fixtures/reissue_a_sheet.rite.yaml @@ -0,0 +1,27 @@ +version: "0.3" +name: "Reissuing a Damaged Sheet" +description: "A share typed back from a worn sheet and shown again for a new one." + +roles: + custodian: + name: "Custodian" + +sections: + reissue: + name: "Reissue" + role: ${role.custodian} + steps: + type_the_old_sheet: + action: enter_share + with: + message: "Recovery share 2, worn sheet" + length: 32 + creates: share_2 + + write_the_new_sheet: + action: reveal + reads: + value: ${artifact.share_2} + with: + message: "Recovery share 2" + length: 32 diff --git a/crates/rite-render/tests/render.rs b/crates/rite-render/tests/render.rs index a20dcef..5d1c552 100644 --- a/crates/rite-render/tests/render.rs +++ b/crates/rite-render/tests/render.rs @@ -1,6 +1,8 @@ //! Integration tests for the templated renderers. -use rite_render::{Branding, Theme, render_report, render_script, validate_accent}; +use rite_render::{ + Branding, Theme, render_report, render_script, render_worksheets, validate_accent, +}; use std::path::PathBuf; fn resolve(rel: &str) -> rite_model::Ceremony { @@ -81,6 +83,48 @@ fn long_instructions_render_as_paragraphs_and_bullets() { assert!(!html.contains("- All wired network interfaces")); } +/// A `reveal` step gets a page in the worksheets document: rows of boxes, +/// one per character the person writes, the parity cells at the end of +/// each row, the note. The script itself carries no sheet. +#[test] +fn a_reveal_step_gets_a_worksheet_page_shaped_by_its_format() { + let ceremony = resolve("examples/showcase/split_and_combine.rite.yaml"); + let html = render_worksheets(&ceremony, &Branding::default(), Theme::Formal) + .unwrap() + .expect("the example writes a share by hand"); + assert!(html.contains("Recovery share 3")); + assert!(html.contains("Write in block capitals.")); + assert!(!html.contains("Step 2"), "the sheet names no step"); + // A 32-byte secret is a 35-byte share, 56 base-32 characters in two + // rows of 28, each with 4 of parity: 64 boxes, exact since the length + // was given. + assert_eq!(html.matches("").count(), 64); + assert_eq!(html.matches("
").count(), 2); + assert_eq!(html.matches("class=\"ws-group ws-parity\"").count(), 2); + assert!(!html.contains("Use as many rows")); + + let script = render_script(&ceremony, &Branding::default(), Theme::Formal).unwrap(); + assert!(!script.contains("")); + + let none = resolve("examples/showcase/encrypt_and_decrypt.rite.yaml"); + assert!( + render_worksheets(&none, &Branding::default(), Theme::Formal) + .unwrap() + .is_none() + ); +} + +/// A share typed back with `enter_share` is a share as much as one a +/// split made, so a sheet that shows it again has the share's boxes. +#[test] +fn a_typed_back_share_shown_again_gets_the_share_s_boxes() { + let ceremony = resolve("crates/rite-render/tests/fixtures/reissue_a_sheet.rite.yaml"); + let html = render_worksheets(&ceremony, &Branding::default(), Theme::Formal) + .unwrap() + .expect("the fixture writes a share by hand"); + assert_eq!(html.matches("").count(), 64); +} + #[test] fn report_snapshot() { let data = rite_render::report::build_report_data( diff --git a/crates/rite-render/tests/snapshots/render__report_empty.snap b/crates/rite-render/tests/snapshots/render__report_empty.snap index 078542e..ca7c9ea 100644 --- a/crates/rite-render/tests/snapshots/render__report_empty.snap +++ b/crates/rite-render/tests/snapshots/render__report_empty.snap @@ -310,6 +310,59 @@ ul.checklist li .item-desc { color: var(--muted); } +/* === Worksheets: one sheet per page, printed once === */ +.sheet { + break-after: page; +} +.sheet:last-child { + break-after: auto; +} +.worksheet { + border: 1px solid var(--hair); + padding: 18px 20px; + margin: 24px 0; +} +.ws-note { + margin: 18px 0 0 0; + color: var(--muted); +} +.ws-line { + display: flex; + flex-wrap: nowrap; + align-items: center; + gap: 7px; + margin: 7px 0; +} +.ws-row-number { + width: 1.4em; + color: var(--muted); + font-size: 0.85em; + text-align: right; +} +.ws-group { + display: inline-flex; + gap: 2px; +} +.ws-box { + display: inline-block; + width: 0.95em; + height: 1.35em; + border: 1px solid #000; +} +.ws-parity { + margin-left: 6px; +} +.ws-parity .ws-box { + background: var(--tint); + -webkit-print-color-adjust: exact; + print-color-adjust: exact; +} +.ws-small { + margin: 8px 0 0 0; + color: var(--muted); + font-size: 0.8em; +} + /* === Duties === */ .duties-intro { color: var(--muted); @@ -513,7 +566,9 @@ code, .hash { .duty, .signature-block, .fingerprint-record, - .preconditions { + .preconditions, + .worksheet, + .sheet { break-inside: avoid; } /* Keep a section heading, and the intro line beneath it, with the first diff --git a/crates/rite-render/tests/snapshots/render__script_demo.snap b/crates/rite-render/tests/snapshots/render__script_demo.snap index 35a1fb7..eb56da3 100644 --- a/crates/rite-render/tests/snapshots/render__script_demo.snap +++ b/crates/rite-render/tests/snapshots/render__script_demo.snap @@ -310,6 +310,59 @@ ul.checklist li .item-desc { color: var(--muted); } +/* === Worksheets: one sheet per page, printed once === */ +.sheet { + break-after: page; +} +.sheet:last-child { + break-after: auto; +} +.worksheet { + border: 1px solid var(--hair); + padding: 18px 20px; + margin: 24px 0; +} +.ws-note { + margin: 18px 0 0 0; + color: var(--muted); +} +.ws-line { + display: flex; + flex-wrap: nowrap; + align-items: center; + gap: 7px; + margin: 7px 0; +} +.ws-row-number { + width: 1.4em; + color: var(--muted); + font-size: 0.85em; + text-align: right; +} +.ws-group { + display: inline-flex; + gap: 2px; +} +.ws-box { + display: inline-block; + width: 0.95em; + height: 1.35em; + border: 1px solid #000; +} +.ws-parity { + margin-left: 6px; +} +.ws-parity .ws-box { + background: var(--tint); + -webkit-print-color-adjust: exact; + print-color-adjust: exact; +} +.ws-small { + margin: 8px 0 0 0; + color: var(--muted); + font-size: 0.8em; +} + /* === Duties === */ .duties-intro { color: var(--muted); @@ -513,7 +566,9 @@ code, .hash { .duty, .signature-block, .fingerprint-record, - .preconditions { + .preconditions, + .worksheet, + .sheet { break-inside: avoid; } /* Keep a section heading, and the intro line beneath it, with the first diff --git a/crates/rite-render/tests/snapshots/render__script_named_acts.snap b/crates/rite-render/tests/snapshots/render__script_named_acts.snap index dea6013..7d55545 100644 --- a/crates/rite-render/tests/snapshots/render__script_named_acts.snap +++ b/crates/rite-render/tests/snapshots/render__script_named_acts.snap @@ -310,6 +310,59 @@ ul.checklist li .item-desc { color: var(--muted); } +/* === Worksheets: one sheet per page, printed once === */ +.sheet { + break-after: page; +} +.sheet:last-child { + break-after: auto; +} +.worksheet { + border: 1px solid var(--hair); + padding: 18px 20px; + margin: 24px 0; +} +.ws-note { + margin: 18px 0 0 0; + color: var(--muted); +} +.ws-line { + display: flex; + flex-wrap: nowrap; + align-items: center; + gap: 7px; + margin: 7px 0; +} +.ws-row-number { + width: 1.4em; + color: var(--muted); + font-size: 0.85em; + text-align: right; +} +.ws-group { + display: inline-flex; + gap: 2px; +} +.ws-box { + display: inline-block; + width: 0.95em; + height: 1.35em; + border: 1px solid #000; +} +.ws-parity { + margin-left: 6px; +} +.ws-parity .ws-box { + background: var(--tint); + -webkit-print-color-adjust: exact; + print-color-adjust: exact; +} +.ws-small { + margin: 8px 0 0 0; + color: var(--muted); + font-size: 0.8em; +} + /* === Duties === */ .duties-intro { color: var(--muted); @@ -513,7 +566,9 @@ code, .hash { .duty, .signature-block, .fingerprint-record, - .preconditions { + .preconditions, + .worksheet, + .sheet { break-inside: avoid; } /* Keep a section heading, and the intro line beneath it, with the first diff --git a/crates/rite-runtime/src/reporter.rs b/crates/rite-runtime/src/reporter.rs index eee5415..6cb7948 100644 --- a/crates/rite-runtime/src/reporter.rs +++ b/crates/rite-runtime/src/reporter.rs @@ -423,6 +423,22 @@ impl<'a> Reporter<'a> { /// [`ReporterError::Disconnected`] if the frontend went away, or /// [`ReporterError::Transcript`] if the fact cannot be recorded. pub fn prompt(&mut self, prompt: &Prompt) -> Result { + self.prompt_checked(prompt, |_| Ok(())) + } + + /// [`prompt`](Self::prompt), with a check of the action's own after the + /// runtime's: a response it refuses is asked for again with the reason + /// attached, as a validator's refusal is. For a check only the action + /// can make, such as whether typed rows are a share. + /// + /// # Errors + /// + /// As [`prompt`](Self::prompt). + pub fn prompt_checked( + &mut self, + prompt: &Prompt, + mut check: impl FnMut(&Response) -> Result<(), String>, + ) -> Result { let step = self.current_step.clone(); let prompt_id = self.allocate_prompt_id(); let mut previous_rejection: Option = None; @@ -456,12 +472,12 @@ impl<'a> Reporter<'a> { // Stale response from a previous prompt, drop. continue; } - match validate(prompt, &response) { + match validate(prompt, &response).and_then(|()| check(&response)) { Ok(()) => { let record = response_to_record(&response); self.fact(StepFact::PromptAnswered { step: step.clone(), - prompt: prompt.clone(), + prompt: prompt.for_record(), response: record, })?; return Ok(response); @@ -532,12 +548,23 @@ fn response_to_record(response: &Response) -> ResponseRecord { fn validate(prompt: &Prompt, response: &Response) -> Result<(), String> { match (prompt, response) { (Prompt::Confirm { .. }, Response::Bool(_)) - | (Prompt::Continue { .. }, Response::Acknowledge) => Ok(()), + | (Prompt::Continue { .. } | Prompt::Reveal { .. }, Response::Acknowledge) + | ( + Prompt::EnterRows { + validator: None, .. + }, + Response::Secret(_), + ) => Ok(()), (Prompt::Text { validator, .. }, Response::Text(value)) => validator.check(value), - (Prompt::Secret { validator, .. }, Response::Secret(value)) => { - validator.check(value.expose_secret()) - } + ( + Prompt::Secret { validator, .. } + | Prompt::EnterRows { + validator: Some(validator), + .. + }, + Response::Secret(value), + ) => validator.check(value.expose_secret()), (Prompt::Literal { expected, .. }, Response::Text(value)) => { if value == expected { diff --git a/crates/rite-runtime/src/test_support.rs b/crates/rite-runtime/src/test_support.rs index ffdac56..d31f24d 100644 --- a/crates/rite-runtime/src/test_support.rs +++ b/crates/rite-runtime/src/test_support.rs @@ -19,15 +19,20 @@ use rite_model::{Sha256Digest, StepFact, TranscriptHeader}; /// Owns the channels and sink needed to build a [`Reporter`] for tests. /// -/// The matching event receiver and command sender are kept alive for the -/// harness's lifetime, so a reporter built from [`Self::reporter`] never -/// sees a spurious disconnect while emitting facts. +/// The matching event receiver is kept alive for the harness's lifetime, +/// so a reporter built from [`Self::reporter`] never sees a spurious +/// disconnect while emitting facts. Its commands are the ones queued when +/// it was built, and nothing more: a prompt with no answer left fails as a +/// disconnect rather than waiting for one that cannot come. pub struct ReporterHarness { sink: InMemorySink, event_tx: Sender, _event_rx: Receiver, cmd_tx: Sender, cmd_rx: Receiver, + /// The queued commands a reporter reads, from a channel whose sender + /// is already dropped. + step_rx: Receiver, next_response_id: u64, } @@ -43,6 +48,7 @@ impl ReporterHarness { _event_rx: event_rx, cmd_tx, cmd_rx, + step_rx: unbounded().1, next_response_id: 0, } } @@ -68,6 +74,17 @@ impl ReporterHarness { }); } + /// Pre-queue a second answer to the prompt the last queued response + /// answered, for when that one is refused: a refused prompt is asked + /// again under the same id. + pub fn enqueue_retry(&mut self, response: Response) { + let prompt_id = PromptId::new(self.next_response_id.wrapping_sub(1)); + let _ = self.cmd_tx.send(UiCommand::PromptResponse { + prompt_id, + response, + }); + } + /// Build a reporter scoped to the given step. The reporter borrows /// the harness for its lifetime. /// @@ -76,9 +93,16 @@ impl ReporterHarness { /// box. Tests that need a specific seed can call /// [`Reporter::seed_entropy`] again. pub fn reporter(&mut self, step: StepId) -> Reporter<'_> { + // What an earlier reporter left, then what was queued since, in a + // channel that ends once they are read. + let (tx, rx) = unbounded(); + for cmd in self.step_rx.try_iter().chain(self.cmd_rx.try_iter()) { + let _ = tx.send(cmd); + } + self.step_rx = rx; let mut reporter = Reporter::new( &self.event_tx, - &self.cmd_rx, + &self.step_rx, &mut self.sink, Arc::new(SystemClock), ); diff --git a/crates/rite-stdlib/src/entry/mod.rs b/crates/rite-stdlib/src/entry/mod.rs index 0a24f0b..c2adace 100644 --- a/crates/rite-stdlib/src/entry/mod.rs +++ b/crates/rite-stdlib/src/entry/mod.rs @@ -9,7 +9,7 @@ //! transcript. use rite_model::params::EntryShape; -use rite_model::{ActionType, Prompt, ValidatorSpec}; +use rite_model::{ActionType, Format, Prompt, RevealFormat, ValidatorSpec}; use rite_runtime::{ Action, ActionError, ArtifactValue, HandlerContext, Icon, Reporter, Response, StepInfo, StepResult, parse_params, @@ -39,6 +39,13 @@ impl Action for EnterValueAction { _backend: Option<&mut dyn Backend>, ) -> Result { let (label, validator) = entry(params)?; + if rows_of(&validator).is_some() { + return Err(ActionError::Failed( + "'paper32' is for a value from a sheet, which is a secret: use enter_secret, \ + or enter_share for a share" + .to_string(), + )); + } let response = reporter.prompt(&Prompt::Text { label, validator: validator.clone(), @@ -93,10 +100,22 @@ impl Action for EnterSecretAction { ) })?; let (label, validator) = entry(params)?; - let response = reporter.prompt(&Prompt::Secret { - label, - validator: validator.clone(), - })?; + // A value from a sheet is typed a row at a time, each row checked + // as it comes; anything else on one line with echo off. + let prompt = match rows_of(&validator) { + Some((format, rows)) => Prompt::EnterRows { + label, + note: None, + format, + rows, + validator: Some(validator.clone()), + }, + None => Prompt::Secret { + label, + validator: validator.clone(), + }, + }; + let response = reporter.prompt(&prompt)?; let Response::Secret(secret) = response else { return Err(ActionError::Failed( "expected a secret response for the entered secret".to_string(), @@ -136,6 +155,25 @@ fn entry(params: &serde_json::Value) -> Result<(String, ValidatorSpec), ActionEr Ok((label, validator)) } +/// The sheet encoding a rule reads rows of, and how many rows when the +/// length is exact; `None` for a value typed on one line. +fn rows_of(validator: &ValidatorSpec) -> Option<(RevealFormat, Option)> { + match validator { + ValidatorSpec::Format { + format: Format::Paper32, + min_length, + max_length, + } => { + let exact = min_length.filter(|&min| Some(min) == *max_length); + Some(( + RevealFormat::Paper32, + exact.map(|n| RevealFormat::Paper32.rows(n)), + )) + } + _ => None, + } +} + /// The bytes a typed value stands for under its rule, when the rule is an /// encoding; `None` when the value is its own canonical form. /// diff --git a/crates/rite-stdlib/src/lib.rs b/crates/rite-stdlib/src/lib.rs index 222bc5b..54c6f4a 100644 --- a/crates/rite-stdlib/src/lib.rs +++ b/crates/rite-stdlib/src/lib.rs @@ -8,7 +8,7 @@ //! - **Crypto**: `generate_key`, `export_public`, `wrap_key`, `unwrap_key`, //! `sign_data`, `verify_signature` //! - **PKI**: `generate_csr`, `issue_certificate` -//! - **Sharing**: `split_secret`, `combine_shares` +//! - **Sharing**: `split_secret`, `combine_shares`, `reveal`, `enter_share` //! //! # Backend integration //! @@ -82,7 +82,7 @@ pub use piv::YubikeyAttestSlotAction; pub use piv::{PivReadCertificateAction, PivSignAction}; #[cfg(feature = "pki")] pub use pki::{GenerateCsrAction, IssueCertificateAction}; -pub use sharing::{CombineSharesAction, SplitSecretAction}; +pub use sharing::{CombineSharesAction, EnterShareAction, RevealAction, SplitSecretAction}; #[cfg(feature = "verification")] pub use verification::{ CheckValueAction, ClockCheckAction, ConfirmAction, HostInfoScope, MachineInfoAction, @@ -123,7 +123,9 @@ pub fn register_stdlib(registry: &mut ActionRegistry) { // The arithmetic is dependency-free and the randomness comes from // whichever backend the step names, so sharing needs no feature either. registry.register(Arc::new(SplitSecretAction)); + registry.register(Arc::new(RevealAction)); registry.register(Arc::new(CombineSharesAction)); + registry.register(Arc::new(EnterShareAction)); #[cfg(feature = "crypto")] { diff --git a/crates/rite-stdlib/src/params.rs b/crates/rite-stdlib/src/params.rs index 608196d..aeb21a6 100644 --- a/crates/rite-stdlib/src/params.rs +++ b/crates/rite-stdlib/src/params.rs @@ -412,6 +412,45 @@ pub struct EncryptDataParams { pub scheme: Option, } +/// Params for `reveal` action. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct RevealParams { + /// What the value is and what to do with it, shown above it and + /// printed on the sheet. + pub message: String, + /// The encoding: `paper32` (the default) or `hex`. + #[serde(default)] + pub format: Option, + /// Text printed on the sheet: where it goes, who keeps it. + #[serde(default)] + pub note: Option, + /// The value's length in bytes, when the author knows it, so the sheet + /// has exactly the right number of boxes; a value of another size is + /// refused. For a share, the secret's length, not counting the three + /// bytes a share carries in front of it. + #[serde(default)] + pub length: Option, +} + +/// Params for `enter_share` action. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct EnterShareParams { + /// What the share is, shown above the rows: the sheet's heading. + pub message: String, + /// The encoding the sheet was written in: `paper32` (the default) or + /// `hex`. + #[serde(default)] + pub format: Option, + /// Shown with the rows: where the sheet came from, who types it. + #[serde(default)] + pub note: Option, + /// The secret's length in bytes, when the author knows it, so the + /// prompt asks for exactly the right rows; a share of another size is + /// refused. + #[serde(default)] + pub length: Option, +} + /// Params for `split_secret` action. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SplitSecretParams { @@ -610,6 +649,11 @@ mod schema_drift_tests { ActionType::VerifySignature, serde_keys(VerifySignatureParams::default()), ), + (ActionType::Reveal, serde_keys(RevealParams::default())), + ( + ActionType::EnterShare, + serde_keys(EnterShareParams::default()), + ), ( ActionType::SplitSecret, serde_keys(SplitSecretParams::default()), diff --git a/crates/rite-stdlib/src/sharing/enter_share.rs b/crates/rite-stdlib/src/sharing/enter_share.rs new file mode 100644 index 0000000..0dad504 --- /dev/null +++ b/crates/rite-stdlib/src/sharing/enter_share.rs @@ -0,0 +1,120 @@ +//! `enter_share` action: a share typed back from its sheet. +//! +//! The rows go through an [`Prompt::EnterRows`], which the frontend takes +//! one row at a time and checks as they come, so a miscopied character is +//! caught at the row it is in. The runtime receives the rows as a secret +//! and reads them again here, as a whole, into a share; rows that are not +//! one are asked for again. The transcript records the share's index and +//! which rows were repaired, never the rows. + +use rite_model::{ActionType, Prompt, RevealFormat, SharingScheme}; +use rite_runtime::{ + Action, ActionError, ArtifactValue, HandlerContext, Icon, Reporter, Response, Share, ShareSet, + StepInfo, StepResult, parse_params, +}; +use rite_sdk::Backend; +use secrecy::ExposeSecret; +use serde_json::json; + +use super::gf256; +use super::wire; +use crate::params::EnterShareParams; + +/// Ask for a share row by row, and hold it as a share a later step +/// combines. +pub struct EnterShareAction; + +impl Action for EnterShareAction { + fn action_type(&self) -> ActionType { + ActionType::EnterShare + } + + fn execute( + &self, + step: &StepInfo, + _ctx: &HandlerContext, + params: &serde_json::Value, + reporter: &mut Reporter<'_>, + _backend: Option<&mut dyn Backend>, + ) -> Result { + let typed: EnterShareParams = parse_params(params)?; + let Some(produces) = step.produces.clone() else { + return Err(ActionError::Failed( + "enter_share must create an artifact; a share no step can name is lost the \ + moment this step ends" + .into(), + )); + }; + let format = typed.format.unwrap_or(RevealFormat::Paper32); + let length = typed + .length + .map(|n| usize::try_from(n).unwrap_or(usize::MAX)); + + // The share is read inside the check, so a refusal goes back to the + // person with the reason, and the share that passes is kept rather + // than read twice. + let mut read: Option<(gf256::Share, Vec)> = None; + let prompt = Prompt::EnterRows { + label: typed.message.clone(), + note: typed.note.clone(), + format, + rows: length.map(|n| format.rows(SharingScheme::RiteSssV1.share_len(n))), + validator: None, + }; + let response = reporter.prompt_checked(&prompt, |response| { + let Response::Secret(text) = response else { + return Err("expected the rows of a share".to_string()); + }; + let value = format.decode(text.expose_secret())?; + let share = wire::decode(&value.bytes) + .map_err(|e| format!("these rows are not a share: {e}"))?; + if let Some(length) = length + && share.secret_len() != length + { + return Err(format!( + "this share is of a {}-byte secret, and the step says length: {length}; \ + check it is the right sheet", + share.secret_len() + )); + } + read = Some((share, value.repaired)); + Ok(()) + })?; + drop(response); + let (share, repaired) = read.ok_or_else(|| { + ActionError::Failed("the rows were accepted and no share was read".into()) + })?; + + for repair in &repaired { + reporter.log(Icon::Warning, format!("Repaired {repair}"))?; + } + let (threshold, index) = (share.threshold(), share.index()); + reporter.log( + Icon::Checkmark, + format!("Share {index} of a {threshold}-of-n split read"), + )?; + + // Which share, and where the sheet needed help: the rows the person + // should look at again. Nothing of the share itself. + reporter.backend_operation( + "enter_share", + json!({ "format": format.as_str() }), + json!({ + "scheme": gf256::FORMAT, + "threshold": threshold, + "index": index, + "repaired": repaired, + }), + None, + )?; + + let (threshold, index, y) = share.into_parts(); + let set = ShareSet::new(threshold, [Share::new(threshold, index, y)]); + reporter.log(Icon::Info, format!("Share stored as artifact '{produces}'"))?; + Ok(StepResult::completed_with_artifact( + format!("Share {index} typed back"), + produces, + ArtifactValue::Shares(set), + )) + } +} diff --git a/crates/rite-stdlib/src/sharing/mod.rs b/crates/rite-stdlib/src/sharing/mod.rs index d8d32a0..96b91aa 100644 --- a/crates/rite-stdlib/src/sharing/mod.rs +++ b/crates/rite-stdlib/src/sharing/mod.rs @@ -1,13 +1,20 @@ //! Secret sharing: split a secret across custodians and put it back together. //! -//! The arithmetic is in [`gf256`]. The rest is the ceremony side: which -//! backend supplies the randomness, what the transcript records, and the -//! refusal to let a share become anything but a `reads:` input. +//! The arithmetic is in [`gf256`]; a share's layout as bytes is [`wire`], +//! and as a string a person writes down, [`paper`]. The rest is the +//! ceremony side: which backend supplies the randomness, what the +//! transcript records, and the refusal to let a share become anything but a +//! `reads:` input. mod combine_shares; +mod enter_share; pub mod gf256; +pub mod paper; +mod reveal; mod split_secret; pub mod wire; pub use combine_shares::CombineSharesAction; +pub use enter_share::EnterShareAction; +pub use reveal::RevealAction; pub use split_secret::SplitSecretAction; diff --git a/crates/rite-stdlib/src/sharing/paper.rs b/crates/rite-stdlib/src/sharing/paper.rs new file mode 100644 index 0000000..452b6c6 --- /dev/null +++ b/crates/rite-stdlib/src/sharing/paper.rs @@ -0,0 +1,124 @@ +//! A share as rows of characters written on paper and typed back. +//! +//! The wire layout of the share, `[version][threshold][index] || y`, as +//! paper32 ([`rite_model::paper32`]): rows of 28 base-32 characters and 4 +//! of parity. A 32-byte secret gives two rows of 32 characters. A wrong +//! character in a row is corrected and named; two unreadable ones are +//! recovered; a row that needs more is refused by name, for the person to +//! read again from the sheet. +//! +//! The version byte is the wire container's, and it says what follows it, +//! so a later layout (one without the threshold, say) is a second version +//! read by the same decoder. What the sheet is labelled is printed on it, +//! not coded into the rows. + +use rite_model::paper32::{self, Repair}; +use zeroize::Zeroizing; + +use super::gf256::Share; +use super::wire::{self, WireError}; + +/// Write a share as rows, one string with no separators. +pub fn encode(share: &Share) -> String { + let bytes = Zeroizing::new(wire::encode(share)); + paper32::encode(&bytes) +} + +/// A share read back from rows a person typed, and what it took. +#[derive(Debug)] +pub struct Read { + /// The share. + pub share: Share, + /// The rows that needed repair, in order, for the person to check. + pub repaired: Vec, +} + +/// Read a share back from rows a person typed. +/// +/// # Errors +/// +/// Says what is wrong with the text and where, so the person can look +/// there and type again, or that the bytes inside are not a share. +pub fn decode(text: &str) -> Result { + let decoded = paper32::decode(text).map_err(PaperError::Text)?; + let share = wire::decode(&decoded.bytes).map_err(PaperError::Layout)?; + Ok(Read { + share, + repaired: decoded.repaired, + }) +} + +/// Why rows did not read as a share. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +#[non_exhaustive] +pub enum PaperError { + /// The rows are malformed, or one cannot be repaired. + #[error("{0}")] + Text(paper32::RowError), + /// The rows read, and the bytes inside are not a share. + #[error(transparent)] + Layout(WireError), +} + +#[cfg(test)] +#[allow(clippy::indexing_slicing)] +mod tests { + use super::*; + use crate::sharing::gf256::ShareError; + use rite_model::paper32::{RepairKind, RowError}; + + #[test] + fn a_share_round_trips_through_paper() { + let share = Share::new(2, 3, vec![0xB9, 0xFA, 0x07, 0xE1, 0x85]).unwrap(); + let text = encode(&share); + let read = decode(&text).unwrap(); + assert_eq!(read.share, share); + assert!(read.repaired.is_empty()); + assert_eq!(decode(&text.to_ascii_lowercase()).unwrap().share, share); + assert_eq!(decode(&format!(" {text}\n")).unwrap().share, share); + } + + #[test] + fn a_seed_share_is_two_rows_of_thirty_two() { + let share = Share::new(2, 1, vec![0x5A; 32]).unwrap(); + assert_eq!(encode(&share).len(), 64); + let long = Share::new(2, 1, vec![0x5A; 64]).unwrap(); + assert_eq!(decode(&encode(&long)).unwrap().share, long); + } + + #[test] + fn a_wrong_character_is_corrected_and_the_row_named() { + let share = Share::new(3, 2, vec![0x11; 16]).unwrap(); + let text = encode(&share); + let mut chars: Vec = text.chars().collect(); + chars[20] = if chars[20] == 'Q' { 'R' } else { 'Q' }; + let wrong: String = chars.iter().collect(); + let read = decode(&wrong).unwrap(); + assert_eq!(read.share, share); + assert_eq!(read.repaired.len(), 1); + assert_eq!(read.repaired[0].kind, RepairKind::Wrong { position: 20 }); + assert!( + read.repaired[0] + .to_string() + .starts_with("row 1: character 21 was wrong"), + "{}", + read.repaired[0] + ); + } + + #[test] + fn what_is_not_a_share_is_refused_by_name() { + // Well-formed rows whose bytes name threshold 1. + let text = paper32::encode(&[1u8, 1, 1, 0xAA]); + assert!(matches!( + decode(&text), + Err(PaperError::Layout(WireError::Share( + ShareError::ThresholdBelowTwo(1) + ))) + )); + assert!(matches!( + decode("ABCD"), + Err(PaperError::Text(RowError::Shape { characters: 4 })) + )); + } +} diff --git a/crates/rite-stdlib/src/sharing/reveal.rs b/crates/rite-stdlib/src/sharing/reveal.rs new file mode 100644 index 0000000..c6d64f8 --- /dev/null +++ b/crates/rite-stdlib/src/sharing/reveal.rs @@ -0,0 +1,119 @@ +//! `reveal` action: a value on screen for a person to write down, then +//! gone. +//! +//! The value reaches the step through `reads:`, borrowed, and leaves it +//! through a [`Prompt::Reveal`], which the frontend shows while the prompt +//! is up and withdraws on acknowledgement. Nothing of the value goes to a +//! log line, since a log line stays on screen for the next person, and +//! nothing goes to the transcript: the prompt fact carries the message and +//! the acknowledgement. What was shown is the definition's business, and +//! `rite script` prints the sheet for it. + +use rite_model::display::hex_upper; +use rite_model::paper32; +use rite_model::{ActionType, Prompt, RevealFormat, Shown}; +use rite_runtime::{ + Action, ActionError, ArtifactValue, HandlerContext, Icon, Reporter, Response, StepInfo, + StepResult, parse_params, resolve_artifact_bytes, resolve_share, +}; +use rite_sdk::Backend; +use zeroize::Zeroizing; + +use super::gf256; +use super::paper; +use crate::params::RevealParams; + +/// Show a value in a chosen encoding, and take it off the screen once the +/// person has written it down. +/// +/// A share is shown as the rows of its wire layout, any other byte +/// artifact as the rows of its bytes; `format: hex` shows either as hex. +pub struct RevealAction; + +impl Action for RevealAction { + fn action_type(&self) -> ActionType { + ActionType::Reveal + } + + fn execute( + &self, + step: &StepInfo, + ctx: &HandlerContext, + params: &serde_json::Value, + reporter: &mut Reporter<'_>, + _backend: Option<&mut dyn Backend>, + ) -> Result { + let typed: RevealParams = parse_params(params)?; + let input = step.required_named_input("value", "reveal")?; + let id = input.artifact_id(); + let name = input.display_name(); + + let layout = RevealFormat::layout_for(typed.format); + let is_share = matches!(ctx.artifacts.get(&id), Some(ArtifactValue::Shares(_))); + let text = if is_share { + let held = resolve_share(ctx.artifacts, &id, input.property()) + .map_err(|e| ActionError::Failed(format!("value '{name}': {e}")))?; + check_length(typed.length, held.y().len(), &name)?; + let share = gf256::Share::new(held.threshold(), held.index(), held.y().to_vec()) + .map_err(|e| ActionError::Failed(format!("value '{name}' is not a share: {e}")))?; + match layout.format { + RevealFormat::Paper32 => paper::encode(&share), + RevealFormat::Hex => { + let bytes = Zeroizing::new(super::wire::encode(&share)); + hex_upper(&bytes) + } + other => return Err(unsupported(other)), + } + } else { + let bytes = resolve_artifact_bytes(ctx.artifacts, &id, input.property()) + .map_err(|e| ActionError::Failed(format!("value '{name}': {e}")))?; + check_length(typed.length, bytes.len(), &name)?; + match layout.format { + RevealFormat::Paper32 => paper32::encode(bytes), + RevealFormat::Hex => hex_upper(bytes), + other => return Err(unsupported(other)), + } + }; + + let format = layout.format; + reporter.log( + Icon::Info, + format!("Showing '{name}' as {format}, for writing down"), + )?; + + let shown = Shown::new(text, layout); + match reporter.prompt(&Prompt::Reveal { + label: typed.message.clone(), + note: typed.note.clone(), + shown, + })? { + Response::Acknowledge => {} + _ => return Err(ActionError::Aborted), + } + + reporter.log(Icon::Checkmark, "Written down; no longer on screen")?; + Ok(StepResult::completed(format!( + "'{name}' shown as {format} and written down" + ))) + } +} + +/// The sheet was printed for `length:` bytes; a value of another size does +/// not fit it, and the person finds out at the last row. Refuse before +/// anything is shown. For a share, the length is the secret's, as the +/// sheet counts it. +fn check_length(declared: Option, actual: usize, name: &str) -> Result<(), ActionError> { + match declared { + Some(declared) if u64::try_from(actual).ok() != Some(declared) => { + Err(ActionError::Failed(format!( + "value '{name}' is {actual} bytes; the step says length: {declared}, and the \ + sheet was printed for that" + ))) + } + _ => Ok(()), + } +} + +fn unsupported(format: RevealFormat) -> ActionError { + ActionError::Failed(format!("This build does not show a value as {format}")) +} diff --git a/crates/rite-stdlib/src/sharing/split_secret.rs b/crates/rite-stdlib/src/sharing/split_secret.rs index ebe9e58..0ea9c9f 100644 --- a/crates/rite-stdlib/src/sharing/split_secret.rs +++ b/crates/rite-stdlib/src/sharing/split_secret.rs @@ -115,14 +115,6 @@ impl Action for SplitSecretAction { let message = format!("{}-of-{} shares made", typed.threshold, typed.shares); if let Some(produces) = &step.produces { - reporter.log( - Icon::Info, - format!( - "Shares stored as artifact '{produces}', reached as '{produces}.share_1' \ - through '{produces}.share_{}'", - typed.shares - ), - )?; // Each share's `y` moves out of the arithmetic's wiping buffer into // the artifact's, without a copy. let shares = set.into_shares().into_iter().map(|share| { diff --git a/crates/rite-stdlib/src/sharing/wire.rs b/crates/rite-stdlib/src/sharing/wire.rs index 13d3328..ba1787c 100644 --- a/crates/rite-stdlib/src/sharing/wire.rs +++ b/crates/rite-stdlib/src/sharing/wire.rs @@ -9,17 +9,16 @@ //! Minus the first two bytes, this is the plain TSS share of //! draft-mcgrew-tss-03 section 3, `index || y`. +use rite_model::SharingScheme; + use super::gf256::{Share, ShareError}; /// The version byte this layout starts with. const VERSION: u8 = 1; -/// Bytes in front of the `y` values: version, threshold, index. -const HEADER_LEN: usize = 3; - /// Lay a share out as bytes. pub fn encode(share: &Share) -> Vec { - let mut bytes = Vec::with_capacity(HEADER_LEN.saturating_add(share.secret_len())); + let mut bytes = Vec::with_capacity(SharingScheme::RiteSssV1.share_len(share.secret_len())); bytes.push(VERSION); bytes.push(share.threshold()); bytes.push(share.index()); @@ -62,6 +61,7 @@ mod tests { let share = Share::new(2, 3, vec![0xB9, 0xFA]).unwrap(); let bytes = encode(&share); assert_eq!(bytes, [1, 2, 3, 0xB9, 0xFA]); + assert_eq!(bytes.len(), SharingScheme::RiteSssV1.share_len(2)); assert_eq!(decode(&bytes).unwrap(), share); } diff --git a/crates/rite-stdlib/tests/actions.rs b/crates/rite-stdlib/tests/actions.rs index 0dcec13..23aef0a 100644 --- a/crates/rite-stdlib/tests/actions.rs +++ b/crates/rite-stdlib/tests/actions.rs @@ -13,12 +13,12 @@ use rite_runtime::{ test_support::ReporterHarness, }; use rite_sdk::{KeyAlgorithm, KeyPolicy, KeySpec, KeyStoreBackend}; -use rite_stdlib::sharing::{gf256, wire}; +use rite_stdlib::sharing::{gf256, paper, wire}; use rite_stdlib::{ AttestAction, CheckValueAction, ClockCheckAction, CombineSharesAction, ConfirmAction, - DecryptDataAction, EncryptDataAction, EnterSecretAction, EnterValueAction, ExportPublicAction, - GatherEntropyAction, ImportKeyAction, MachineInfoAction, MockBackend, OralReadbackAction, - SplitSecretAction, UnwrapKeyAction, WrapKeyAction, + DecryptDataAction, EncryptDataAction, EnterSecretAction, EnterShareAction, EnterValueAction, + ExportPublicAction, GatherEntropyAction, ImportKeyAction, MachineInfoAction, MockBackend, + OralReadbackAction, RevealAction, SplitSecretAction, UnwrapKeyAction, WrapKeyAction, }; use secrecy::{ExposeSecret, SecretBox, SecretString}; @@ -194,6 +194,89 @@ fn enter_secret_holds_the_secret_and_records_only_that_one_was_entered() { assert!(!serialized.contains("correct horse")); } +/// A secret from a sheet in paper32 is asked for row by row: the prompt +/// carries the row count and the rule, a slip in a row is repaired, a value +/// of another length is refused and asked for again, and the artifact is +/// the bytes. +#[test] +fn enter_secret_in_paper32_takes_rows_and_holds_the_bytes() { + let bytes = [0x5A_u8; 32]; + let right = rite_model::paper32::encode(&bytes); + let mut chars: Vec = right.chars().collect(); + let slip = chars.get_mut(5).unwrap(); + *slip = if *slip == 'A' { 'B' } else { 'A' }; + let slipped: String = chars.iter().collect(); + let mut harness = ReporterHarness::new(); + harness.enqueue_response(Response::Secret(SecretString::from( + rite_model::paper32::encode(&[0x5A; 16]), + ))); + harness.enqueue_retry(Response::Secret(SecretString::from(slipped))); + let state = make_state(); + let step = creating_step("key_in", "component"); + + let result = { + let ctx = state.handler_context(); + let mut reporter = harness.reporter(step.id.clone()); + EnterSecretAction + .execute( + &step, + &ctx, + &serde_json::json!({ + "message": "Key component from the sheet", + "format": "paper32", + "length": 32, + }), + &mut reporter, + None, + ) + .expect("the second answer is the right length") + }; + match produced(&result.artifacts, "component") { + ArtifactValue::Secret(secret) => assert_eq!(secret.expose_secret().as_slice(), bytes), + other => panic!("enter_secret must produce a Secret, got {other:?}"), + } + let prompt = harness + .facts() + .iter() + .find_map(|fact| match fact { + StepFact::PromptAnswered { prompt, .. } => Some(prompt.clone()), + _ => None, + }) + .expect("the prompt is recorded"); + match prompt { + Prompt::EnterRows { + rows, validator, .. + } => { + assert_eq!(rows, Some(2)); + assert!(validator.is_some()); + } + other => panic!("expected rows, got {other:?}"), + } + let serialized = serde_json::to_string(harness.facts()).unwrap(); + assert!(!serialized.contains(&right[..20]), "{serialized}"); +} + +/// Rows from a sheet are a secret, so `enter_value`, whose answer the +/// transcript carries, refuses them. +#[test] +fn enter_value_refuses_paper32() { + let mut harness = ReporterHarness::new(); + let state = make_state(); + let step = creating_step("key_in", "component"); + let ctx = state.handler_context(); + let mut reporter = harness.reporter(step.id.clone()); + let error = EnterValueAction + .execute( + &step, + &ctx, + &serde_json::json!({ "message": "Component", "format": "paper32" }), + &mut reporter, + None, + ) + .expect_err("a sheet's rows are a secret"); + assert!(error.to_string().contains("enter_secret"), "{error}"); +} + #[test] fn enter_secret_refuses_a_step_that_holds_the_secret_nowhere() { let mut harness = ReporterHarness::new(); @@ -1597,3 +1680,364 @@ fn split_secret_refuses_a_split_with_too_many_subsets_to_check() { .expect_err("rite-sss/v1 makes at most 100 shares"); assert!(error.to_string().contains("at most 100"), "{error}"); } + +/// A share on screen: the prompt carries the rows in their shape, the +/// fact carries the label and nothing of the value, and no log line +/// repeats it. +#[test] +fn reveal_shows_a_share_and_records_only_that_it_was_shown() { + let mut backend = MockBackend::new("mock".to_string(), "seed".to_string()); + let mut harness = ReporterHarness::new(); + let (state, shares) = split(&mut backend, &mut harness, &[0x42; 32], 2, 3); + let ArtifactValue::Shares(set) = &shares else { + panic!("split_secret must produce Shares"); + }; + let expected = { + let share = set.share(2).expect("share 2"); + let math = gf256::Share::new(share.threshold(), share.index(), share.y().to_vec()).unwrap(); + paper::encode(&math) + }; + let shares_id = ArtifactId::new("shares"); + let state = state.with_material(shares_id.clone(), shares); + + let map = HashMap::from([( + "value".to_string(), + NamedInput::One(ArtifactRef::Produced { + id: shares_id, + property: Some("share_2".to_string()), + }), + )]); + let step = StepInfo::new( + StepId::new("show"), + None, + None, + None, + Some(StepInputs::Named(map)), + ); + harness.enqueue_response(Response::Acknowledge); + let result = { + let ctx = state.handler_context(); + let mut reporter = harness.reporter(step.id.clone()); + RevealAction + .execute( + &step, + &ctx, + &serde_json::json!({ "message": "Write share 2 on the custodian's sheet" }), + &mut reporter, + None, + ) + .expect("reveal completes") + }; + assert!(result.artifacts.is_empty()); + + let recorded = harness + .facts() + .iter() + .find_map(|fact| match fact { + StepFact::PromptAnswered { prompt, .. } => Some(prompt.clone()), + _ => None, + }) + .expect("the prompt is recorded"); + match recorded { + Prompt::Reveal { label, shown, .. } => { + assert_eq!(label, "Write share 2 on the custodian's sheet"); + assert_eq!(shown.text(), "", "the value never travels with the fact"); + } + other => panic!("expected a reveal prompt, got {other:?}"), + } + let serialized = serde_json::to_string(harness.facts()).unwrap(); + assert!(!serialized.contains(&expected[..20]), "{serialized}"); + // A 32-byte secret is a 35-byte share: two rows of 32. + assert_eq!(expected.len(), 64); +} + +/// The sheet is printed for `length:` bytes before the run. A value of +/// another size is refused before it is shown, so nobody writes into a +/// sheet that cannot hold it. +#[test] +fn reveal_refuses_a_value_whose_length_is_not_the_declared_one() { + let mut backend = MockBackend::new("mock".to_string(), "seed".to_string()); + let mut harness = ReporterHarness::new(); + let (state, shares) = split(&mut backend, &mut harness, &[0x42; 32], 2, 3); + let shares_id = ArtifactId::new("shares"); + let state = state.with_material(shares_id.clone(), shares); + let map = HashMap::from([( + "value".to_string(), + NamedInput::One(ArtifactRef::Produced { + id: shares_id, + property: Some("share_2".to_string()), + }), + )]); + let step = StepInfo::new( + StepId::new("show"), + None, + None, + None, + Some(StepInputs::Named(map)), + ); + let run = |harness: &mut ReporterHarness, length: u64| { + let ctx = state.handler_context(); + let mut reporter = harness.reporter(step.id.clone()); + RevealAction.execute( + &step, + &ctx, + &serde_json::json!({ "message": "Write share 2", "length": length }), + &mut reporter, + None, + ) + }; + + // The length counts the secret's bytes, as the sheet does, not the + // share's three header bytes. + let err = run(&mut harness, 35).unwrap_err(); + assert!( + err.to_string() + .contains("value 'shares.share_2' is 32 bytes; the step says length: 35"), + "{err}" + ); + assert!( + harness + .facts() + .iter() + .all(|fact| !matches!(fact, StepFact::PromptAnswered { .. })), + "nothing was shown" + ); + + harness.enqueue_response(Response::Acknowledge); + run(&mut harness, 32).expect("the right length is shown"); +} + +/// Run `enter_share` with `with:`, answering its prompt from the queue, into +/// an artifact named `typed`. +fn enter_share( + state: &ExecutionState, + harness: &mut ReporterHarness, + with: &serde_json::Value, +) -> Result { + let step = StepInfo::new( + StepId::new("type_back"), + None, + None, + Some(ArtifactId::new("typed")), + None, + ); + let ctx = state.handler_context(); + let mut reporter = harness.reporter(step.id.clone()); + EnterShareAction.execute(&step, &ctx, with, &mut reporter, None) +} + +fn rows(text: String) -> Response { + Response::Secret(secrecy::SecretString::from(text)) +} + +/// The recovery's shape: a share typed from its sheet, with a slip in the +/// second row, reads back as the share, and combines with one held in +/// memory. The transcript says which share and which row was repaired, and +/// nothing of the rows. +#[test] +fn a_share_typed_back_with_a_slip_combines() { + let mut backend = MockBackend::new("mock".to_string(), "seed".to_string()); + let mut harness = ReporterHarness::new(); + let secret = b"32 bytes of wallet seed entropy!"; + let (state, shares) = split(&mut backend, &mut harness, secret, 2, 3); + let ArtifactValue::Shares(set) = &shares else { + panic!("split_secret must produce Shares"); + }; + let sheet = { + let share = set.share(3).expect("share 3"); + let math = gf256::Share::new(share.threshold(), share.index(), share.y().to_vec()).unwrap(); + paper::encode(&math) + }; + // Two rows as a person types them: grouped, lower case, one slip. + let mut chars: Vec = sheet.to_ascii_lowercase().chars().collect(); + let slip = chars.get_mut(40).unwrap(); + *slip = if *slip == 'a' { 'b' } else { 'a' }; + let typed: String = chars.iter().collect(); + let (row_1, row_2) = typed.split_at(32); + harness.enqueue_response(rows(format!("{row_1}\n{} {}", &row_2[..16], &row_2[16..]))); + + let result = enter_share( + &state, + &mut harness, + &serde_json::json!({ "message": "Recovery share 3", "length": 32 }), + ) + .expect("enter_share completes"); + + let outputs = harness + .facts() + .iter() + .find_map(|f| match f { + StepFact::BackendOperation { kind, outputs, .. } if kind == "enter_share" => { + Some(outputs.clone()) + } + _ => None, + }) + .expect("enter_share records an operation"); + assert_eq!(outputs.get("index").unwrap(), 3); + assert_eq!(outputs.get("threshold").unwrap(), 2); + let repaired = outputs.get("repaired").unwrap().as_array().unwrap(); + assert_eq!(repaired.len(), 1, "{outputs}"); + assert!( + repaired + .first() + .and_then(|r| r.as_str()) + .unwrap() + .starts_with("row 2:"), + "{outputs}" + ); + let serialized = serde_json::to_string(harness.facts()).unwrap(); + assert!(!serialized.contains(&sheet[..20]), "{serialized}"); + assert!(!serialized.contains(&row_1[..20]), "{serialized}"); + + let typed_id = ArtifactId::new("typed"); + let shares_id = ArtifactId::new("shares"); + let (_, typed) = result.artifacts.into_iter().next().expect("one artifact"); + let state = state + .with_material(typed_id.clone(), typed) + .with_material(shares_id.clone(), shares); + let combine = combine_step(&[(&typed_id, None), (&shares_id, Some("share_1"))]); + let recovered = { + let ctx = state.handler_context(); + let mut reporter = harness.reporter(combine.id.clone()); + CombineSharesAction + .execute(&combine, &ctx, &serde_json::json!({}), &mut reporter, None) + .expect("combine_shares completes") + }; + match produced(&recovered.artifacts, "recovered") { + ArtifactValue::Secret(bytes) => assert_eq!(bytes.expose_secret().as_slice(), secret), + other => panic!("combine_shares must produce Secret, got {other:?}"), + } +} + +/// Rows that are not a share, and a share of another size than the step +/// says, are refused and asked for again; the right sheet is taken, and +/// only that answer is recorded. +#[test] +fn rows_that_are_not_the_share_are_asked_for_again() { + let mut harness = ReporterHarness::new(); + let not_a_share = rite_model::paper32::encode(&[9; 20]); + let other_size = paper::encode(&gf256::Share::new(2, 1, vec![1; 16]).unwrap()); + let right = paper::encode(&gf256::Share::new(2, 1, vec![1; 32]).unwrap()); + harness.enqueue_response(rows(not_a_share)); + harness.enqueue_retry(rows(other_size)); + harness.enqueue_retry(rows(right)); + + let result = enter_share( + &make_state(), + &mut harness, + &serde_json::json!({ "message": "Recovery share 1", "length": 32 }), + ) + .expect("the third answer is the share"); + match produced(&result.artifacts, "typed") { + ArtifactValue::Shares(set) => assert_eq!((set.threshold(), set.count()), (2, 1)), + other => panic!("enter_share must produce Shares, got {other:?}"), + } + let answers = harness + .facts() + .iter() + .filter(|f| matches!(f, StepFact::PromptAnswered { .. })) + .count(); + assert_eq!(answers, 1); +} + +/// Shares 1 and 3 of a 2-of-3 split of `examples/showcase/test_data/secret.txt`, +/// as `examples/showcase/test_data/recovery_sheets.txt` prints them. +const SHEET_1: [&str; 2] = [ + "0410 2P86 N88E 54SN T4TH BE2N ZE9K | 4M2Q", + "BQB9 2YR2 X6S2 A3TG HDPG CPE3 GHE6 | PZTC", +]; +const SHEET_3: [&str; 2] = [ + "0410 6GYT 5XRD PV4K M8FZ J395 T9Y8 | 3Y81", + "7FJK XCAT XGH6 JSK4 5885 YQAJ S4T5 | 1E5Q", +]; +const SHEETS_SECRET: &[u8] = b"The safe combination: 31-07-52.\n"; + +/// Type both sheets back and combine them, and the facts the steps +/// recorded. `slip` changes one character of the first row of sheet 3. +fn recover_from_the_sheets(slip: Option) -> (Vec, Vec) { + let mut facts = Vec::new(); + let mut state = make_state(); + let mut sheet_3 = SHEET_3.map(str::to_string); + if let Some(at) = slip { + let mut chars: Vec = sheet_3[0].chars().collect(); + let c = chars.get_mut(at).unwrap(); + *c = if *c == 'A' { 'B' } else { 'A' }; + sheet_3[0] = chars.into_iter().collect(); + } + let sheets = [ + ("share_1", SHEET_1.join("\n")), + ("share_3", sheet_3.join("\n")), + ]; + for (name, typed) in sheets { + // A harness per step: each reporter numbers its prompts from the + // start. + let mut harness = ReporterHarness::new(); + harness.enqueue_response(rows(typed)); + let result = enter_share( + &state, + &mut harness, + &serde_json::json!({ "message": name, "length": 32 }), + ) + .expect("the sheet reads as a share"); + let (_, share) = result.artifacts.into_iter().next().expect("one artifact"); + state = state.with_material(ArtifactId::new(name), share); + facts.extend_from_slice(harness.facts()); + } + let mut harness = ReporterHarness::new(); + let (one, three) = (ArtifactId::new("share_1"), ArtifactId::new("share_3")); + let combine = combine_step(&[(&three, None), (&one, None)]); + let recovered = { + let ctx = state.handler_context(); + let mut reporter = harness.reporter(combine.id.clone()); + CombineSharesAction + .execute(&combine, &ctx, &serde_json::json!({}), &mut reporter, None) + .expect("combine_shares completes") + }; + let secret = match produced(&recovered.artifacts, "recovered") { + ArtifactValue::Secret(bytes) => bytes.expose_secret().clone(), + other => panic!("combine_shares must produce Secret, got {other:?}"), + }; + (secret, facts) +} + +/// A fixed vector for the paper format and the share layout: sheets printed +/// today must recover the same secret for as long as the format is +/// `rite-sss/v1` in paper32. A change to the alphabet, the bit order, the +/// parity, the row size or the share header fails here. +#[test] +fn the_published_sheets_recover_their_secret() { + let (secret, facts) = recover_from_the_sheets(None); + assert_eq!(secret, SHEETS_SECRET); + let repaired: Vec<_> = facts + .iter() + .filter_map(|f| match f { + StepFact::BackendOperation { kind, outputs, .. } if kind == "enter_share" => { + Some(outputs.get("repaired").unwrap().clone()) + } + _ => None, + }) + .collect(); + assert_eq!(repaired, [serde_json::json!([]), serde_json::json!([])]); + + // The example publishes these sheets and this secret. + let data = + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../examples/showcase/test_data"); + let published = std::fs::read_to_string(data.join("recovery_sheets.txt")).unwrap(); + for row in SHEET_1.iter().chain(&SHEET_3) { + assert!(published.contains(row), "recovery_sheets.txt lacks {row}"); + } + assert_eq!( + std::fs::read(data.join("secret.txt")).unwrap(), + SHEETS_SECRET + ); +} + +/// The same sheets with a character miscopied: the row is repaired and +/// named, and the secret is the same. +#[test] +fn the_published_sheets_recover_their_secret_through_a_slip() { + let (secret, facts) = recover_from_the_sheets(Some(7)); + assert_eq!(secret, SHEETS_SECRET); + let serialized = serde_json::to_string(&facts).unwrap(); + assert!(serialized.contains("row 1: character"), "{serialized}"); +} diff --git a/crates/rite-tui/Cargo.toml b/crates/rite-tui/Cargo.toml index e7a6890..c875d0f 100644 --- a/crates/rite-tui/Cargo.toml +++ b/crates/rite-tui/Cargo.toml @@ -19,6 +19,7 @@ ratatui = { workspace = true } crossterm = { workspace = true } crossbeam-channel = { workspace = true } secrecy = { workspace = true } +zeroize = { workspace = true } chrono = { workspace = true } [lints] diff --git a/crates/rite-tui/src/model.rs b/crates/rite-tui/src/model.rs index ad43071..bea6e71 100644 --- a/crates/rite-tui/src/model.rs +++ b/crates/rite-tui/src/model.rs @@ -329,4 +329,54 @@ pub struct PendingPrompt { pub input: String, /// Most recent rejection reason from the validator, if any. pub rejection: Option, + /// Where a value shown for writing down stands; unused by other + /// prompts. + pub reveal: RevealPhase, + /// Rows typed so far into a prompt that takes a value row by row; + /// unused by other prompts. + pub entry: RowEntry, +} + +/// A value typed row by row: the rows taken so far and what the last +/// check said. +#[derive(Debug, Clone, Default)] +pub struct RowEntry { + /// Rows taken, in order. + pub rows: Vec, + /// What the last row's check said, until the next one. + pub notice: Option, +} + +/// One row taken: as typed, which is what the runtime reads again, and as +/// it reads, which is what the screen shows. Both wiped when dropped. +#[derive(Debug, Clone)] +pub struct EnteredRow { + /// The row as the person typed it. + pub typed: zeroize::Zeroizing, + /// The row as it should read, repairs made. + pub reads: zeroize::Zeroizing, +} + +/// What a row's check said. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RowNotice { + /// The row was taken with a repair the person should check on the + /// sheet. + Repaired(String), + /// The row was not taken, and why. + Refused(String), +} + +/// A value to write down goes through three phases in its window: it is +/// announced, then shown, then shown with the question before it goes, +/// since it is not shown again. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum RevealPhase { + /// Announced: the window says what is coming, without the value. + #[default] + Ready, + /// The value is on screen. + Shown, + /// The value is on screen with the question: written down? + Confirm, } diff --git a/crates/rite-tui/src/msg.rs b/crates/rite-tui/src/msg.rs index f4a5065..56c2168 100644 --- a/crates/rite-tui/src/msg.rs +++ b/crates/rite-tui/src/msg.rs @@ -24,8 +24,9 @@ pub enum Msg { }, /// Timer tick for spinners and time-driven redraws. Tick, - /// Event from the runtime executor. - Exec(ExecEvent), + /// Event from the runtime executor. Boxed: it is far larger than the + /// other variants, and every message would otherwise be its size. + Exec(Box), /// Explicit quit signal. Quit, } diff --git a/crates/rite-tui/src/preview.rs b/crates/rite-tui/src/preview.rs index d0e2063..7e88599 100644 --- a/crates/rite-tui/src/preview.rs +++ b/crates/rite-tui/src/preview.rs @@ -18,7 +18,10 @@ use rite_runtime::{ MaterialOverviewKind, PromptId, SystemInfo, }; -use crate::model::{LogLine, Model, PendingPrompt, Screen, StepTab, StepView}; +use crate::model::{ + EnteredRow, LogLine, Model, PendingPrompt, RevealPhase, RowEntry, RowNotice, Screen, StepTab, + StepView, +}; use crate::view::view; /// Build a `DateTime` on the canonical preview date. @@ -93,6 +96,8 @@ fn sample_overview() -> Model { }, input: String::new(), rejection: None, + reveal: RevealPhase::Ready, + entry: RowEntry::default(), }); m } @@ -150,6 +155,8 @@ fn sample_with_prompt() -> Model { }, input: "Bob Jo".to_string(), rejection: None, + reveal: RevealPhase::Ready, + entry: RowEntry::default(), }); m } @@ -273,6 +280,8 @@ fn preview_confirm_prompt() { }, input: String::new(), rejection: None, + reveal: RevealPhase::Ready, + entry: RowEntry::default(), }); let out = render(&m, 100, 24); eprintln!("--- step / confirm prompt (100x24) ---\n{out}"); @@ -398,3 +407,87 @@ fn preview_completed() { let out = render(&m, 100, 24); eprintln!("--- completed (100x24) ---\n{out}"); } + +/// A share in its window, through its three phases: announced without the +/// value, shown with its rows numbered, then the question. +#[test] +fn preview_reveal_window() { + let value = "0410 67JM 1MR3 XT0S DGTW FBBV HP23 Q4A2 SQJH JE1Z 748W R4SB 8Y9M S20C 63T2 7JB9" + .replace(' ', ""); + let mut m = sample_running(); + m.pending_prompt = Some(PendingPrompt { + prompt_id: PromptId::new(3), + prompt: Prompt::Reveal { + label: "Recovery share 3".to_string(), + note: Some("Write in block capitals. The sheet goes in the envelope.".to_string()), + shown: rite_model::Shown::new(value, rite_model::RevealFormat::Paper32.layout()), + }, + input: String::new(), + rejection: None, + reveal: RevealPhase::Ready, + entry: RowEntry::default(), + }); + let ready = render(&m, 100, 24); + eprintln!("--- reveal / ready (100x24) ---\n{ready}"); + assert!(ready.contains("Recovery share 3")); + assert!(ready.contains("not written to the transcript")); + assert!(ready.contains("Write in block capitals.")); + assert!(!ready.contains("0410"), "announced, not shown"); + + for (phase, question) in [(RevealPhase::Shown, false), (RevealPhase::Confirm, true)] { + if let Some(p) = m.pending_prompt.as_mut() { + p.reveal = phase; + } + let out = render(&m, 100, 24); + eprintln!("--- reveal / {phase:?} (100x24) ---\n{out}"); + assert!(out.contains("1 0410 67JM 1MR3 XT0S DGTW FBBV HP23 │ Q4A2")); + assert!(out.contains("2 SQJH JE1Z 748W R4SB 8Y9M S20C 63T2 │ 7JB9")); + assert_eq!(out.contains("not shown again"), question); + } +} + +/// A share typed back: two rows of three, the second repaired, the third +/// being typed. +#[test] +fn preview_enter_rows_window() { + let rows = [ + "0410 67JM 1MR3 XT0S DGTW FBBV HP23 Q4A2", + "SQJH JE1Z 748W R4SB 8Y9M S20C 63T2 7JB9", + ]; + let mut m = sample_running(); + m.pending_prompt = Some(PendingPrompt { + prompt_id: PromptId::new(4), + prompt: Prompt::EnterRows { + label: "Recovery share 3".to_string(), + note: Some("Type from the sheet in the envelope.".to_string()), + format: rite_model::RevealFormat::Paper32, + rows: Some(3), + validator: None, + }, + input: "0410 67".to_string(), + rejection: None, + reveal: RevealPhase::Ready, + entry: RowEntry { + rows: rows + .iter() + .map(|r| { + let reads: String = r.chars().filter(|c| *c != ' ').collect(); + EnteredRow { + typed: zeroize::Zeroizing::new((*r).to_string()), + reads: zeroize::Zeroizing::new(reads), + } + }) + .collect(), + notice: Some(RowNotice::Repaired( + "row 2: character 7 was wrong and has been corrected; check it against the sheet" + .to_string(), + )), + }, + }); + let out = render(&m, 100, 24); + eprintln!("--- enter rows (100x24) ---\n{out}"); + assert!(out.contains("Recovery share 3")); + assert!(out.contains("1 0410 67JM 1MR3 XT0S DGTW FBBV HP23 │ Q4A2")); + assert!(out.contains("3 0410 67")); + assert!(out.contains("character 7 was wrong")); +} diff --git a/crates/rite-tui/src/runtime.rs b/crates/rite-tui/src/runtime.rs index 45fdb2f..83ab1b7 100644 --- a/crates/rite-tui/src/runtime.rs +++ b/crates/rite-tui/src/runtime.rs @@ -155,7 +155,7 @@ fn spawn_tick_thread(msg_tx: Sender) { fn spawn_exec_forwarder(event_rx: Receiver, msg_tx: Sender) { thread::spawn(move || { while let Ok(event) = event_rx.recv() { - if msg_tx.send(Msg::Exec(event)).is_err() { + if msg_tx.send(Msg::Exec(Box::new(event))).is_err() { return; } } diff --git a/crates/rite-tui/src/update.rs b/crates/rite-tui/src/update.rs index 2c74f0e..376b896 100644 --- a/crates/rite-tui/src/update.rs +++ b/crates/rite-tui/src/update.rs @@ -14,8 +14,8 @@ use rite_runtime::{ }; use crate::model::{ - DEVIATION_INPUT_MAX, DeviationView, LogLine, Model, PendingPrompt, RunningState, Screen, - StepTab, StepView, + DEVIATION_INPUT_MAX, DeviationView, EnteredRow, LogLine, Model, PendingPrompt, RevealPhase, + RowEntry, RowNotice, RunningState, Screen, StepTab, StepView, }; use crate::msg::{Cmd, Msg}; @@ -44,7 +44,7 @@ pub fn update(model: &mut Model, msg: Msg) -> Vec { // facts from a single step types out one log line at a time. // `Msg::Tick` drains the queue at `LOG_DRIP_TICKS` cadence. Msg::Exec(event) => { - model.pending_events.push_back(event); + model.pending_events.push_back(*event); Vec::new() } // The forwarder sends Msg::Quit when the executor's channel @@ -260,11 +260,18 @@ fn handle_abort_confirm_key(model: &mut Model, key: KeyEvent) -> Vec { /// /// Two-step shape (decide → apply) avoids holding a mutable borrow on /// `pending_prompt` across calls that need mutable access to the model. +#[derive(Clone, Copy)] enum PromptAction { SubmitBool(bool), SubmitText, SubmitSecret, SubmitAcknowledge, + Reveal(RevealPhase), + EnterRow { + format: rite_model::RevealFormat, + expected: Option, + }, + RowBackspace, PushChar(char), Backspace, Ignore, @@ -296,12 +303,32 @@ fn handle_prompt_key(model: &mut Model, key: KeyEvent) -> Vec { KeyCode::Enter | KeyCode::Char(' ') => Action::SubmitAcknowledge, _ => Action::Ignore, }, + // Enter moves on and only `y` at the question takes the value + // away: a stray key while writing must not. + Prompt::Reveal { .. } => match (pending.reveal, key.code) { + (RevealPhase::Ready, KeyCode::Enter) => Action::Reveal(RevealPhase::Shown), + (RevealPhase::Shown, KeyCode::Enter) => Action::Reveal(RevealPhase::Confirm), + (RevealPhase::Confirm, KeyCode::Char('y' | 'Y')) => Action::SubmitAcknowledge, + (RevealPhase::Confirm, KeyCode::Char('n' | 'N')) => { + Action::Reveal(RevealPhase::Shown) + } + _ => Action::Ignore, + }, Prompt::Text { .. } | Prompt::Literal { .. } => match key.code { KeyCode::Char(c) => Action::PushChar(c), KeyCode::Backspace => Action::Backspace, KeyCode::Enter => Action::SubmitText, _ => Action::Ignore, }, + Prompt::EnterRows { format, rows, .. } => match key.code { + KeyCode::Char(c) => Action::PushChar(c), + KeyCode::Backspace => Action::RowBackspace, + KeyCode::Enter => Action::EnterRow { + format: *format, + expected: *rows, + }, + _ => Action::Ignore, + }, Prompt::Secret { .. } => match key.code { KeyCode::Char(c) => Action::PushChar(c), KeyCode::Backspace => Action::Backspace, @@ -311,10 +338,21 @@ fn handle_prompt_key(model: &mut Model, key: KeyEvent) -> Vec { _ => Action::Ignore, } }; + apply_prompt_action(model, action) +} +/// Apply what a key decided for the pending prompt. +fn apply_prompt_action(model: &mut Model, action: PromptAction) -> Vec { + use PromptAction as Action; match action { Action::SubmitBool(b) => send_response(model, Response::Bool(b)), Action::SubmitAcknowledge => send_response(model, Response::Acknowledge), + Action::Reveal(phase) => { + if let Some(pending) = model.pending_prompt.as_mut() { + pending.reveal = phase; + } + Vec::new() + } Action::SubmitText => { let value = model .pending_prompt @@ -343,10 +381,69 @@ fn handle_prompt_key(model: &mut Model, key: KeyEvent) -> Vec { } Vec::new() } + Action::EnterRow { format, expected } => enter_row(model, format, expected), + Action::RowBackspace => { + if let Some(pending) = model.pending_prompt.as_mut() { + if pending.input.is_empty() { + // Back into the last row taken, to correct it. + if let Some(row) = pending.entry.rows.pop() { + pending.input = row.typed.to_string(); + } + pending.entry.notice = None; + } else { + pending.input.pop(); + } + } + Vec::new() + } Action::Ignore => Vec::new(), } } +/// Check the row being typed and take it, or say why not. The value goes +/// to the runtime when the last row is taken: the count the step gives, a +/// short row, or an empty row after full ones. +fn enter_row( + model: &mut Model, + format: rite_model::RevealFormat, + expected: Option, +) -> Vec { + let Some(pending) = model.pending_prompt.as_mut() else { + return Vec::new(); + }; + let done = if pending.input.trim().is_empty() { + expected.is_none() && !pending.entry.rows.is_empty() + } else { + let number = pending.entry.rows.len().saturating_add(1); + let more = expected.is_some_and(|rows| number < rows); + match format.read_row(&pending.input, number, more) { + Ok(read) => { + pending.entry.rows.push(EnteredRow { + typed: zeroize::Zeroizing::new(std::mem::take(&mut pending.input)), + reads: read.text, + }); + pending.entry.notice = read.repair.map(RowNotice::Repaired); + // A short row is the last one, whether or not the step + // said how many to expect. + !read.full || expected == Some(pending.entry.rows.len()) + } + Err(reason) => { + pending.entry.notice = Some(RowNotice::Refused(reason)); + false + } + } + }; + if !done { + return Vec::new(); + } + let rows = std::mem::take(&mut pending.entry.rows); + let text: Vec<&str> = rows.iter().map(|row| row.typed.as_str()).collect(); + send_response( + model, + Response::Secret(secrecy::SecretString::from(text.join("\n"))), + ) +} + fn send_response(model: &mut Model, response: Response) -> Vec { let Some(pending) = model.pending_prompt.take() else { return Vec::new(); @@ -393,6 +490,8 @@ fn install_prompt( prompt, input: String::new(), rejection, + reveal: RevealPhase::Ready, + entry: RowEntry::default(), }); } @@ -530,7 +629,7 @@ mod tests { /// Push an exec event and drain it from the drip queue with a tick /// so unit tests don't have to drive both messages explicitly. fn apply_exec(model: &mut Model, event: ExecEvent) -> Vec { - let _ = update(model, Msg::Exec(event)); + let _ = update(model, Msg::Exec(Box::new(event))); update(model, Msg::Tick) } @@ -874,6 +973,150 @@ mod tests { assert!(model.pending_prompt.is_none()); } + #[test] + fn a_reveal_is_announced_shown_then_taken_away_only_on_yes() { + let mut model = Model::new(); + model.screen = Screen::Step { + tab: StepTab::Ceremony, + }; + install_prompt( + &mut model, + PromptId::new(3), + Prompt::Reveal { + label: "share".to_string(), + note: None, + shown: rite_model::Shown::new( + "A".repeat(32), + rite_model::RevealFormat::Paper32.layout(), + ), + }, + None, + ); + let phase = |model: &Model| model.pending_prompt.as_ref().map(|p| p.reveal); + let press = |model: &mut Model, code| update(model, Msg::Key(key(code))); + + assert!(press(&mut model, KeyCode::Char(' ')).is_empty()); + assert_eq!(phase(&model), Some(RevealPhase::Ready)); + assert!(press(&mut model, KeyCode::Enter).is_empty()); + assert_eq!(phase(&model), Some(RevealPhase::Shown)); + assert!(press(&mut model, KeyCode::Char(' ')).is_empty()); + assert!(press(&mut model, KeyCode::Enter).is_empty()); + assert_eq!(phase(&model), Some(RevealPhase::Confirm)); + // Enter again is not a yes; n goes back to the value. + assert!(press(&mut model, KeyCode::Enter).is_empty()); + assert!(press(&mut model, KeyCode::Char('n')).is_empty()); + assert_eq!(phase(&model), Some(RevealPhase::Shown)); + assert!(press(&mut model, KeyCode::Enter).is_empty()); + let cmds = press(&mut model, KeyCode::Char('y')); + assert!(matches!( + cmds.as_slice(), + [Cmd::SendCommand(UiCommand::PromptResponse { + response: Response::Acknowledge, + .. + })] + )); + } + + /// Rows are taken one at a time: a row that does not read stays to be + /// typed again, Backspace on an empty row goes back into the last, and + /// the last expected row sends the rows as typed. + #[test] + fn rows_are_taken_one_at_a_time_and_sent_with_the_last() { + use secrecy::ExposeSecret; + let text = rite_model::paper32::encode(&[0x5A; 35]); + let (first, second) = text.split_at(32); + let mut model = Model::new(); + model.screen = Screen::Step { + tab: StepTab::Ceremony, + }; + install_prompt( + &mut model, + PromptId::new(5), + Prompt::EnterRows { + label: "share".to_string(), + note: None, + format: rite_model::RevealFormat::Paper32, + rows: Some(2), + validator: None, + }, + None, + ); + let typing = |model: &mut Model, text: &str| { + for c in text.chars() { + let _ = update(model, Msg::Key(key(KeyCode::Char(c)))); + } + update(model, Msg::Key(key(KeyCode::Enter))) + }; + let taken = |model: &Model| model.pending_prompt.as_ref().map(|p| p.entry.rows.len()); + + assert!(typing(&mut model, "ABCD").is_empty()); + assert_eq!(taken(&model), Some(0)); + assert!(matches!( + model + .pending_prompt + .as_ref() + .and_then(|p| p.entry.notice.clone()), + Some(RowNotice::Refused(_)) + )); + for _ in 0..4 { + let _ = update(&mut model, Msg::Key(key(KeyCode::Backspace))); + } + assert!(typing(&mut model, first).is_empty()); + assert_eq!(taken(&model), Some(1)); + // Back into row 1, and out again. + let _ = update(&mut model, Msg::Key(key(KeyCode::Backspace))); + assert_eq!(taken(&model), Some(0)); + assert!(update(&mut model, Msg::Key(key(KeyCode::Enter))).is_empty()); + assert_eq!(taken(&model), Some(1)); + + let cmds = typing(&mut model, second); + match cmds.as_slice() { + [ + Cmd::SendCommand(UiCommand::PromptResponse { + response: Response::Secret(rows), + .. + }), + ] => assert_eq!(rows.expose_secret(), format!("{first}\n{second}")), + _ => panic!("expected the rows as a secret"), + } + assert!(model.pending_prompt.is_none()); + } + + /// Every row but the last is full: with no count given, a short row + /// is the last one and sends the rows without an empty row after it. + #[test] + fn a_short_row_ends_an_entry_of_no_set_length() { + let text = rite_model::paper32::encode(&[0x5A; 30]); + let (first, second) = text.split_at(32); + let mut model = Model::new(); + model.screen = Screen::Step { + tab: StepTab::Ceremony, + }; + install_prompt( + &mut model, + PromptId::new(6), + Prompt::EnterRows { + label: "share".to_string(), + note: None, + format: rite_model::RevealFormat::Paper32, + rows: None, + validator: None, + }, + None, + ); + let typing = |model: &mut Model, text: &str| { + for c in text.chars() { + let _ = update(model, Msg::Key(key(KeyCode::Char(c)))); + } + update(model, Msg::Key(key(KeyCode::Enter))) + }; + assert!(typing(&mut model, first).is_empty()); + assert!(matches!( + typing(&mut model, second).as_slice(), + [Cmd::SendCommand(UiCommand::PromptResponse { .. })] + )); + } + #[test] fn text_prompt_collects_input_then_sends_on_enter() { let mut model = Model::new(); @@ -942,13 +1185,13 @@ mod tests { // drip buffer when Quit arrives. let _ = update( &mut model, - Msg::Exec(fact_event(StepFact::CeremonyCompleted {})), + Msg::Exec(Box::new(fact_event(StepFact::CeremonyCompleted {}))), ); let _ = update( &mut model, - Msg::Exec(ExecEvent::Finalized { + Msg::Exec(Box::new(ExecEvent::Finalized { fingerprint: "sha256:abc".to_string(), - }), + })), ); assert_eq!(model.pending_events.len(), 2); assert!(!model.screen.is_terminal()); @@ -988,7 +1231,7 @@ mod tests { text: "queued".to_string(), }; // Msg::Exec alone leaves the log empty and the queue non-empty. - let _ = update(&mut model, Msg::Exec(ExecEvent::Signal(signal))); + let _ = update(&mut model, Msg::Exec(Box::new(ExecEvent::Signal(signal)))); assert!(model.log.is_empty()); assert_eq!(model.pending_events.len(), 1); diff --git a/crates/rite-tui/src/view.rs b/crates/rite-tui/src/view.rs index 933ac33..66beca5 100644 --- a/crates/rite-tui/src/view.rs +++ b/crates/rite-tui/src/view.rs @@ -6,12 +6,12 @@ use ratatui::Frame; use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect}; use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span, Text}; -use ratatui::widgets::{Block, Borders, Paragraph, Wrap}; +use ratatui::widgets::{Block, Borders, Clear, Padding, Paragraph, Wrap}; use rite_model::Prompt; use rite_runtime::{Icon, MaterialOverview, MaterialOverviewKind}; -use crate::model::{LogLine, Model, Screen, StepTab}; +use crate::model::{LogLine, Model, PendingPrompt, RevealPhase, RowNotice, Screen, StepTab}; /// Shared color palette. Three muted tones plus the terminal-default /// text color: titles, borders, and the footer all step back so the @@ -45,6 +45,11 @@ mod theme { pub fn footer() -> Style { Style::default().fg(FOOTER) } + /// The parity characters of a value shown for writing down: set + /// apart from the data so the sheet's own parity cells are found. + pub fn parity() -> Style { + Style::default().fg(TITLE) + } } /// Spinner animation frames cycled by [`spinner_glyph`] for `Icon::Spinner`. @@ -288,12 +293,181 @@ fn render_ceremony(model: &Model, frame: &mut Frame<'_>, area: Rect) -> usize { .areas(area); let applied = render_ceremony_table(model, frame, logs_area); match &model.pending_prompt { + // A value to write down is in its own window; the prompt box stays + // empty rather than repeat its title. + Some(pending) if matches!(pending.prompt, Prompt::Reveal { .. }) => { + render_empty_prompt(frame, prompt_area); + render_reveal(pending, frame, area); + } + Some(pending) if matches!(pending.prompt, Prompt::EnterRows { .. }) => { + render_empty_prompt(frame, prompt_area); + render_enter_rows(pending, frame, area); + } Some(pending) => render_prompt(pending, frame, prompt_area), None => render_empty_prompt(frame, prompt_area), } applied } +/// Widest the reveal window grows: room for a row of the value with its +/// number and parity, and prose that wraps at a readable length. +const REVEAL_WIDTH: u16 = 72; + +/// A value to write down, in a window over the log so it reads as apart +/// from what is recorded, and closes when it is done. It is announced +/// first, then shown, then shown with the question before it goes. +fn render_reveal(pending: &PendingPrompt, frame: &mut Frame<'_>, area: Rect) { + let Prompt::Reveal { label, note, shown } = &pending.prompt else { + return; + }; + let mut lines = vec![Line::from("")]; + let note_line = |note: &String| { + Line::from(Span::styled( + note.clone(), + theme::text().add_modifier(Modifier::ITALIC), + )) + }; + let footer = |keys: &'static str| Line::from(Span::styled(keys, theme::footer())); + match pending.reveal { + RevealPhase::Ready => { + lines.push(Line::from( + "Shown once, in this window, and not written to the transcript. Have \ + the printed sheet and a pen ready, out of view of others.", + )); + if let Some(note) = note { + lines.push(Line::from("")); + lines.push(note_line(note)); + } + lines.push(Line::from("")); + lines.push(footer("Enter: show it · Esc: abort")); + } + RevealPhase::Shown => { + lines.extend(shown_lines(shown)); + lines.push(Line::from("")); + lines.push(footer("Enter: written down · Esc: abort")); + } + RevealPhase::Confirm => { + lines.extend(shown_lines(shown)); + lines.push(Line::from("")); + lines.push(Line::from( + "Every row written down and checked? It is not shown again.", + )); + lines.push(Line::from("")); + lines.push(footer("y: yes, close it · n: back to it")); + } + } + render_window(label, lines, frame, area); +} + +/// Rows of a value typed from a sheet, in the same window: the rows taken +/// so far as they read, the row being typed, and what the last check +/// said. +fn render_enter_rows(pending: &PendingPrompt, frame: &mut Frame<'_>, area: Rect) { + let Prompt::EnterRows { + label, + note, + format, + rows, + .. + } = &pending.prompt + else { + return; + }; + let layout = format.layout(); + let mut lines = vec![ + Line::from(""), + Line::from( + "Type each row from the sheet and press Enter; each row is checked as it \ + comes. Not written to the transcript.", + ), + ]; + if let Some(note) = note { + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + note.clone(), + theme::text().add_modifier(Modifier::ITALIC), + ))); + } + lines.push(Line::from("")); + for (n, row) in pending.entry.rows.iter().enumerate() { + if let Some(laid_out) = layout.rows(&row.reads).first() { + lines.push(row_line(n, laid_out)); + } + } + let current = pending.entry.rows.len(); + if rows.is_none_or(|expected| current < expected) { + lines.push(Line::from(vec![ + Span::styled( + format!("{:>3} ", current.saturating_add(1)), + theme::footer(), + ), + Span::styled( + format!("{}▏", pending.input), + theme::text().add_modifier(Modifier::BOLD), + ), + ])); + } + match &pending.entry.notice { + Some(RowNotice::Repaired(repair)) => { + lines.push(Line::from("")); + lines.push(Line::from(Span::styled(repair.clone(), theme::title()))); + } + Some(RowNotice::Refused(reason)) => { + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + format!("{reason}; type the row again"), + theme::text().add_modifier(Modifier::ITALIC), + ))); + } + None => {} + } + if let Some(reason) = &pending.rejection { + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + format!("{reason}; type the rows again"), + theme::text().add_modifier(Modifier::ITALIC), + ))); + } + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + if rows.is_some() { + "Enter: next row · Backspace: back · Esc: abort" + } else { + "Enter: next row, or done on an empty one · Esc: abort" + }, + theme::footer(), + ))); + render_window(label, lines, frame, area); +} + +/// A window over the log, titled, centred, as tall as its lines. +fn render_window(title: &str, lines: Vec>, frame: &mut Frame<'_>, area: Rect) { + let block = plain_block() + .padding(Padding::horizontal(1)) + .title(Line::from(Span::styled( + format!(" {title} "), + theme::title().add_modifier(Modifier::BOLD), + ))); + let paragraph = Paragraph::new(lines) + .wrap(Wrap { trim: false }) + .block(block); + + let width = area.width.saturating_sub(4).min(REVEAL_WIDTH); + let height = u16::try_from(paragraph.line_count(width)) + .unwrap_or(u16::MAX) + .min(area.height); + let window = Rect { + x: area.x.saturating_add(area.width.saturating_sub(width) / 2), + y: area + .y + .saturating_add(area.height.saturating_sub(height) / 2), + width, + height, + }; + frame.render_widget(Clear, window); + frame.render_widget(paragraph, window); +} + /// Height of the placeholder prompt box: one content line plus borders, /// matching a single-line prompt so the log area doesn't jump when most /// prompts appear. @@ -306,7 +480,7 @@ fn render_empty_prompt(frame: &mut Frame<'_>, area: Rect) { } /// Conservative fixed height for the prompt panel, including its border. -fn prompt_block_height(pending: &crate::model::PendingPrompt) -> u16 { +fn prompt_block_height(pending: &PendingPrompt) -> u16 { let content_lines: u16 = match &pending.prompt { Prompt::Text { .. } | Prompt::Literal { .. } | Prompt::Secret { .. } => 2, _ => 1, @@ -318,7 +492,7 @@ fn prompt_block_height(pending: &crate::model::PendingPrompt) -> u16 { .saturating_add(2) } -fn render_prompt(pending: &crate::model::PendingPrompt, frame: &mut Frame<'_>, area: Rect) { +fn render_prompt(pending: &PendingPrompt, frame: &mut Frame<'_>, area: Rect) { let body: Vec> = match &pending.prompt { Prompt::Confirm { question, .. } => vec![Line::from(question.clone())], Prompt::Continue { hint } => vec![Line::from( @@ -350,11 +524,46 @@ fn render_prompt(pending: &crate::model::PendingPrompt, frame: &mut Frame<'_>, a ); } +/// A value shown for writing down, one line per row: the row's number, +/// the data in groups, the parity set apart, as the sheet lays it out. +/// Owned spans, since the prompt outlives no borrow of the model here. +fn shown_lines(shown: &rite_model::Shown) -> Vec> { + shown + .layout() + .rows(shown.text()) + .iter() + .enumerate() + .map(|(n, row)| row_line(n, row)) + .collect() +} + +/// One row of a value, numbered from `n + 1`, in groups, the parity apart. +fn row_line(n: usize, row: &rite_model::display::Row<'_>) -> Line<'static> { + let mut spans = vec![Span::styled( + format!("{:>3} ", n.saturating_add(1)), + theme::footer(), + )]; + for (i, group) in row.groups.iter().enumerate() { + if i > 0 { + spans.push(Span::raw(" ")); + } + spans.push(Span::styled( + (*group).to_string(), + theme::text().add_modifier(Modifier::BOLD), + )); + } + if !row.parity.is_empty() { + spans.push(Span::styled(" │ ", theme::footer())); + spans.push(Span::styled(row.parity.to_string(), theme::parity())); + } + Line::from(spans) +} + /// Compose the prompt box title. `Prompt` proper is shown in the muted /// title color; the `[y/n]` hint (Confirm prompts) and the "(last /// attempt rejected)" suffix step further back into footer gray so the /// word `Prompt` reads as the heading and the rest as annotation. -fn prompt_title(pending: &crate::model::PendingPrompt) -> Line<'_> { +fn prompt_title(pending: &PendingPrompt) -> Line<'_> { let mut spans = vec![Span::styled("Prompt", theme::title())]; // The submit-key hint lives here (not the footer) so the footer stays a // constant width while prompts come and go. Confirm shows the y/n keys; diff --git a/crates/rite/Cargo.toml b/crates/rite/Cargo.toml index ae5969a..cc6d79a 100644 --- a/crates/rite/Cargo.toml +++ b/crates/rite/Cargo.toml @@ -47,6 +47,7 @@ clap_complete = { workspace = true } serde_json = { workspace = true } crossbeam-channel = { workspace = true } secrecy = { workspace = true } +zeroize = { workspace = true } rpassword = { workspace = true } [build-dependencies] diff --git a/crates/rite/src/console.rs b/crates/rite/src/console.rs index f61615d..ed4d9cf 100644 --- a/crates/rite/src/console.rs +++ b/crates/rite/src/console.rs @@ -205,6 +205,23 @@ fn read_response( let _ = read_line(stdin)?; Ok(Response::Acknowledge) } + // A plain terminal cannot take a line back once printed. The value + // is announced, shown in its rows, and acknowledged only on a yes; + // the person is told to clear the screen, since the TUI is the + // frontend that withdraws it. + Prompt::Reveal { label, note, shown } => { + read_reveal(stdin, stdout, label, note.as_deref(), shown) + } + // Rows from a sheet, one line each, each checked as it comes: a + // repair is said at once for the person to check on the sheet, and + // a row that does not read is asked for again. + Prompt::EnterRows { + label, + note, + format, + rows, + .. + } => read_rows(stdin, stdout, label, note.as_deref(), *format, *rows), // Unknown future variants: refuse with an io::Error so the // runtime sees the rejection and we don't silently misanswer. _ => Err(io::Error::other(format!( @@ -213,6 +230,108 @@ fn read_response( } } +fn read_reveal( + stdin: &mut R, + stdout: &mut W, + label: &str, + note: Option<&str>, + shown: &rite_model::Shown, +) -> io::Result { + writeln!(stdout, "{label}")?; + if let Some(note) = note { + writeln!(stdout, "{note}")?; + } + write!( + stdout, + "The value is shown once. Have the printed sheet and a pen ready, then \ + press Enter to show it. " + )?; + stdout.flush()?; + let _ = read_line(stdin)?; + for (n, row) in shown.layout().rows(shown.text()).iter().enumerate() { + let n = n.saturating_add(1); + if row.parity.is_empty() { + writeln!(stdout, " {n:>2} {}", row.groups.join(" "))?; + } else { + writeln!( + stdout, + " {n:>2} {} | {}", + row.groups.join(" "), + row.parity + )?; + } + } + loop { + write!( + stdout, + "Is every row written down and checked? It is not shown again [y/N] " + )?; + stdout.flush()?; + if matches!(read_line(stdin)?.trim(), "y" | "Y" | "yes" | "Yes") { + break; + } + } + writeln!( + stdout, + "This terminal keeps the value on screen; clear it now." + )?; + Ok(Response::Acknowledge) +} + +fn read_rows( + stdin: &mut R, + stdout: &mut W, + label: &str, + note: Option<&str>, + format: rite_model::RevealFormat, + rows: Option, +) -> io::Result { + writeln!(stdout, "{label}")?; + if let Some(note) = note { + writeln!(stdout, "{note}")?; + } + writeln!( + stdout, + "Type each row from the sheet and press Enter{}. Not written to the \ + transcript.", + if rows.is_some() { + "" + } else { + "; an empty row when done, if the last row is full" + } + )?; + let mut typed: Vec> = Vec::new(); + while rows.is_none_or(|expected| typed.len() < expected) { + let number = typed.len().saturating_add(1); + write!(stdout, " {number:>2} ")?; + stdout.flush()?; + let line = zeroize::Zeroizing::new(read_line(stdin)?); + let row = line.trim_end_matches(['\n', '\r']); + if row.trim().is_empty() { + if rows.is_none() && !typed.is_empty() { + break; + } + continue; + } + let more = rows.is_some_and(|expected| number < expected); + match format.read_row(row, number, more) { + Ok(read) => { + if let Some(repair) = read.repair { + writeln!(stdout, " {repair}")?; + } + typed.push(zeroize::Zeroizing::new(row.to_string())); + // A short row is the last one. + if !read.full { + break; + } + } + Err(reason) => writeln!(stdout, " {reason}; type the row again")?, + } + } + let text: Vec<&str> = typed.iter().map(|row| row.as_str()).collect(); + Ok(Response::Secret(SecretString::from(text.join("\n")))) +} + fn read_line(stdin: &mut R) -> io::Result { let mut buf = String::new(); let n = stdin.read_line(&mut buf)?; @@ -306,6 +425,67 @@ mod tests { assert!(matches!(resp, Response::Acknowledge)); } + /// A row that does not read is asked for again; the rows go back as + /// typed, one per line, and an empty row ends an entry of no set length. + #[test] + fn rows_are_checked_one_at_a_time() { + use secrecy::ExposeSecret; + let text = rite_model::paper32::encode(&[0x5A; 35]); + let (first, second) = text.split_at(32); + let input = format!("{first}\nABCD\n{second}\n\n"); + let mut stdin = std::io::Cursor::new(input.into_bytes()); + let mut stdout: Vec = Vec::new(); + let prompt = Prompt::EnterRows { + label: "Recovery share 3".to_string(), + note: None, + format: rite_model::RevealFormat::Paper32, + rows: None, + validator: None, + }; + let resp = read_response(&mut stdin, &mut stdout, &prompt).expect("response"); + let Response::Secret(rows) = resp else { + panic!("expected the rows as a secret"); + }; + assert_eq!(rows.expose_secret(), format!("{first}\n{second}")); + let shown = String::from_utf8(stdout).unwrap(); + assert!(shown.contains("type the row again"), "{shown}"); + } + + /// Every row but the last is full: a short row where more are due is + /// asked for again, and a short row ends an entry of no set length. + #[test] + fn a_short_row_is_the_last_one() { + use secrecy::ExposeSecret; + let text = rite_model::paper32::encode(&[0x5A; 30]); + let (first, second) = text.split_at(32); + let rows_of = |rows| Prompt::EnterRows { + label: "Recovery share 3".to_string(), + note: None, + format: rite_model::RevealFormat::Paper32, + rows, + validator: None, + }; + + let mut stdin = std::io::Cursor::new(format!("{second}\n{first}\n{second}\n").into_bytes()); + let mut stdout: Vec = Vec::new(); + let Response::Secret(rows) = + read_response(&mut stdin, &mut stdout, &rows_of(Some(2))).expect("response") + else { + panic!("expected the rows as a secret"); + }; + assert_eq!(rows.expose_secret(), format!("{first}\n{second}")); + let shown = String::from_utf8(stdout).unwrap(); + assert!(shown.contains("every row but the last has 32"), "{shown}"); + + let mut stdin = std::io::Cursor::new(format!("{first}\n{second}\n").into_bytes()); + let Response::Secret(rows) = + read_response(&mut stdin, &mut Vec::new(), &rows_of(None)).expect("response") + else { + panic!("expected the rows as a secret"); + }; + assert_eq!(rows.expose_secret(), format!("{first}\n{second}")); + } + #[test] fn await_prompt_drives_one_response_round_trip() { let (cmd_tx, cmd_rx) = unbounded::(); diff --git a/crates/rite/src/headless.rs b/crates/rite/src/headless.rs index 1c5fe72..9df277e 100644 --- a/crates/rite/src/headless.rs +++ b/crates/rite/src/headless.rs @@ -13,6 +13,9 @@ //! - `Literal`: type the expected string //! - `Text`: fail, the operator must answer in `--frontend=console` //! - `Secret`: fail, never auto-answered +//! - `Reveal`: acknowledge in a dry run, where the value is a placeholder +//! and nobody is meant to write it down; fail in a real run, where +//! showing it here would put a secret in a log with no one to read it //! //! All facts and signals are written to stderr so that stdout stays //! reserved for whatever the CLI invocation wants to emit (transcript @@ -45,7 +48,11 @@ const PLACEHOLDER_SECRET: &str = "placeholder-secret"; /// Returns an I/O error if stderr fails, or /// [`io::ErrorKind::InvalidInput`] when a prompt requires interactive /// input the defaults policy cannot satisfy (free-form text, secret). -pub fn run(cmd_tx: &Sender, event_rx: &Receiver) -> io::Result<()> { +pub fn run( + cmd_tx: &Sender, + event_rx: &Receiver, + rehearsal: bool, +) -> io::Result<()> { let stderr = io::stderr(); let mut stderr = stderr.lock(); @@ -59,7 +66,7 @@ pub fn run(cmd_tx: &Sender, event_rx: &Receiver) -> io::Re ExecEvent::AwaitPrompt { prompt_id, prompt, .. } => { - let response = default_response(&prompt)?; + let response = default_response(&prompt, rehearsal)?; if cmd_tx .send(UiCommand::PromptResponse { prompt_id, @@ -75,10 +82,43 @@ pub fn run(cmd_tx: &Sender, event_rx: &Receiver) -> io::Re Ok(()) } -fn default_response(prompt: &Prompt) -> io::Result { +fn default_response(prompt: &Prompt, rehearsal: bool) -> io::Result { match prompt { Prompt::Confirm { default, .. } => Ok(Response::Bool(default.unwrap_or(true))), Prompt::Continue { .. } => Ok(Response::Acknowledge), + // A value shown for writing down needs a person in front of the + // screen. A rehearsal walks the step; a real run stops here rather + // than print a secret to a log nobody is reading. + Prompt::Reveal { label, .. } => { + if rehearsal { + Ok(Response::Acknowledge) + } else { + Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!( + "'{label}' shows a value for someone to write down, and no one is \ + present in a headless run. Run with the TUI, or as a dry run." + ), + )) + } + } + // Rows come off a sheet only a person holds. A rehearsal answers + // with a stand-in where the rule allows one; a share has no rule a + // made-up value satisfies, so a rehearsal stops there as a run does. + Prompt::EnterRows { + label, validator, .. + } => match validator { + Some(validator) if rehearsal => placeholder(validator, PLACEHOLDER_SECRET) + .map(|value| Response::Secret(SecretString::from(value))) + .ok_or_else(|| cannot_answer("rows", label)), + _ => Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!( + "'{label}' asks for rows typed from a sheet, and no one is present in a \ + headless run. Run with the TUI or the console." + ), + )), + }, Prompt::Literal { expected, .. } => Ok(Response::Text(expected.clone())), // Free-form text: a placeholder that satisfies the prompt's rule // where one can be built. A pattern can't be answered generically, so @@ -161,30 +201,39 @@ mod tests { #[test] fn confirm_default_yes() { - let resp = default_response(&Prompt::Confirm { - question: "go?".to_string(), - default: None, - }) + let resp = default_response( + &Prompt::Confirm { + question: "go?".to_string(), + default: None, + }, + false, + ) .expect("response"); assert!(matches!(resp, Response::Bool(true))); } #[test] fn confirm_explicit_no_default_honored() { - let resp = default_response(&Prompt::Confirm { - question: "destructive?".to_string(), - default: Some(false), - }) + let resp = default_response( + &Prompt::Confirm { + question: "destructive?".to_string(), + default: Some(false), + }, + false, + ) .expect("response"); assert!(matches!(resp, Response::Bool(false))); } #[test] fn literal_returns_expected() { - let resp = default_response(&Prompt::Literal { - label: "type 'attest'".to_string(), - expected: "attest".to_string(), - }) + let resp = default_response( + &Prompt::Literal { + label: "type 'attest'".to_string(), + expected: "attest".to_string(), + }, + false, + ) .expect("response"); match resp { Response::Text(t) => assert_eq!(t, "attest"), @@ -194,16 +243,19 @@ mod tests { #[test] fn continue_is_acknowledged() { - let resp = default_response(&Prompt::Continue { hint: None }).expect("response"); + let resp = default_response(&Prompt::Continue { hint: None }, false).expect("response"); assert!(matches!(resp, Response::Acknowledge)); } #[test] fn unconstrained_text_prompt_gets_placeholder() { - let resp = default_response(&Prompt::Text { - label: "entropy".to_string(), - validator: rite_model::ValidatorSpec::NonEmpty, - }) + let resp = default_response( + &Prompt::Text { + label: "entropy".to_string(), + validator: rite_model::ValidatorSpec::NonEmpty, + }, + false, + ) .expect("response"); match resp { Response::Text(t) => assert_eq!(t, PLACEHOLDER_TEXT), @@ -213,20 +265,26 @@ mod tests { #[test] fn validated_text_prompt_fails_fast() { - let err = default_response(&Prompt::Text { - label: "serial".to_string(), - validator: rite_model::ValidatorSpec::Regex("[0-9]+".to_string()), - }) + let err = default_response( + &Prompt::Text { + label: "serial".to_string(), + validator: rite_model::ValidatorSpec::Regex("[0-9]+".to_string()), + }, + false, + ) .expect_err("should fail"); assert_eq!(err.kind(), io::ErrorKind::InvalidInput); } #[test] fn secret_prompt_gets_placeholder() { - let resp = default_response(&Prompt::Secret { - label: "pin".to_string(), - validator: rite_model::ValidatorSpec::NonEmpty, - }) + let resp = default_response( + &Prompt::Secret { + label: "pin".to_string(), + validator: rite_model::ValidatorSpec::NonEmpty, + }, + false, + ) .expect("response"); assert!(matches!(resp, Response::Secret(_))); } @@ -243,10 +301,13 @@ mod tests { min_length: min, max_length: max, }; - let resp = default_response(&Prompt::Secret { - label: "pin".to_string(), - validator: validator.clone(), - }) + let resp = default_response( + &Prompt::Secret { + label: "pin".to_string(), + validator: validator.clone(), + }, + false, + ) .expect("response"); let Response::Secret(value) = resp else { panic!("expected a secret"); @@ -257,34 +318,87 @@ mod tests { #[test] fn oversized_secret_prompt_fails_fast() { - let err = default_response(&Prompt::Secret { - label: "blob".to_string(), - validator: rite_model::ValidatorSpec::Format { - format: rite_model::Format::Text, - min_length: Some(rite_model::PLACEHOLDER_LIMIT + 1), - max_length: None, + let err = default_response( + &Prompt::Secret { + label: "blob".to_string(), + validator: rite_model::ValidatorSpec::Format { + format: rite_model::Format::Text, + min_length: Some(rite_model::PLACEHOLDER_LIMIT + 1), + max_length: None, + }, }, - }) + false, + ) .expect_err("a stand-in that size is not built"); assert_eq!(err.kind(), io::ErrorKind::InvalidInput); } #[test] fn patterned_secret_prompt_fails_fast() { - let err = default_response(&Prompt::Secret { - label: "pin".to_string(), - validator: rite_model::ValidatorSpec::Regex("[0-9]{6}".to_string()), - }) + let err = default_response( + &Prompt::Secret { + label: "pin".to_string(), + validator: rite_model::ValidatorSpec::Regex("[0-9]{6}".to_string()), + }, + false, + ) .expect_err("a placeholder cannot satisfy a pattern"); assert_eq!(err.kind(), io::ErrorKind::InvalidInput); } + /// A secret from a sheet gets a stand-in in a rehearsal; a share, which + /// has no rule a made-up value satisfies, stops the rehearsal too. + #[test] + fn rows_get_a_stand_in_in_a_rehearsal_only_when_a_rule_allows_one() { + let validator = rite_model::ValidatorSpec::Format { + format: rite_model::Format::Paper32, + min_length: Some(32), + max_length: Some(32), + }; + let secret = Prompt::EnterRows { + label: "component".to_string(), + note: None, + format: rite_model::RevealFormat::Paper32, + rows: Some(2), + validator: Some(validator.clone()), + }; + let Response::Secret(value) = default_response(&secret, true).expect("rehearsal") else { + panic!("expected rows as a secret"); + }; + assert!(validator.check(value.expose_secret()).is_ok()); + assert!(default_response(&secret, false).is_err()); + + let share = Prompt::EnterRows { + label: "share".to_string(), + note: None, + format: rite_model::RevealFormat::Paper32, + rows: Some(2), + validator: None, + }; + assert!(default_response(&share, true).is_err()); + } + + #[test] + fn a_reveal_is_acknowledged_in_a_rehearsal_and_refused_in_a_run() { + let prompt = Prompt::Reveal { + label: "Write down share 1".to_string(), + note: None, + shown: rite_model::Shown::default(), + }; + assert!(matches!( + default_response(&prompt, true).expect("rehearsal"), + Response::Acknowledge + )); + let err = default_response(&prompt, false).expect_err("no one present"); + assert!(err.to_string().contains("no one is present"), "{err}"); + } + #[test] fn run_replies_to_each_prompt_and_completes() { let (cmd_tx, cmd_rx) = unbounded::(); let (event_tx, event_rx) = unbounded::(); - let driver = std::thread::spawn(move || run(&cmd_tx, &event_rx)); + let driver = std::thread::spawn(move || run(&cmd_tx, &event_rx, false)); // Simulate a runtime: send a couple of facts and a Continue prompt. event_tx diff --git a/crates/rite/src/main.rs b/crates/rite/src/main.rs index d4f2a35..77a4941 100644 --- a/crates/rite/src/main.rs +++ b/crates/rite/src/main.rs @@ -100,7 +100,9 @@ enum Commands { /// Render a ceremony as a printable protocol /// /// Produces a self-contained HTML document that participants follow and - /// complete by hand during the ceremony. + /// complete by hand during the ceremony. When a step shows a value to + /// write down, also produces the worksheets: one page per such step, + /// with the encoding's fixed parts printed and a box for every character. #[cfg(feature = "render")] Script(script::Args), /// Render a post-ceremony report from a transcript diff --git a/crates/rite/src/run.rs b/crates/rite/src/run.rs index b4c08a4..f76be72 100644 --- a/crates/rite/src/run.rs +++ b/crates/rite/src/run.rs @@ -174,7 +174,7 @@ pub fn run(args: Args) { ); let exec_handle = std::thread::spawn(move || executor.run(&cmd_rx, &event_tx, sink)); - let frontend_result = run_frontend(frontend, &cmd_tx, event_rx); + let frontend_result = run_frontend(frontend, &cmd_tx, event_rx, args.dry_run); // Drop our cmd_tx so the executor's recv unblocks cleanly if the // frontend exits before the ceremony completes. @@ -264,12 +264,13 @@ fn run_frontend( frontend: Frontend, cmd_tx: &crossbeam_channel::Sender, event_rx: crossbeam_channel::Receiver, + rehearsal: bool, ) -> std::io::Result<()> { match frontend { #[cfg(feature = "tui")] Frontend::Tui => rite_tui::run(cmd_tx, event_rx), Frontend::Console => crate::console::run(cmd_tx, &event_rx), - Frontend::Headless => crate::headless::run(cmd_tx, &event_rx), + Frontend::Headless => crate::headless::run(cmd_tx, &event_rx, rehearsal), } } diff --git a/crates/rite/src/script.rs b/crates/rite/src/script.rs index 0e5166c..0442a28 100644 --- a/crates/rite/src/script.rs +++ b/crates/rite/src/script.rs @@ -1,11 +1,12 @@ -//! `rite script`: generate a printable HTML ceremony script. +//! `rite script`: generate a printable HTML ceremony script, and the sheets +//! for the values its steps have written by hand. use crate::common::{ BrandingArgs, InputArgs, ThemeArg, build_branding_or_exit, build_inputs_or_exit, default_output_path, resolve_or_exit, write_document, }; use clap::Args as ClapArgs; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; #[derive(ClapArgs, Debug)] #[command(after_long_help = crate::common::INPUT_ENV_HELP)] @@ -18,6 +19,14 @@ pub struct Args { /// the source. #[arg(long, short)] pub output: Option, + /// Output path of the worksheets, written when a step shows a value to + /// write down + /// + /// One page per such step, printed once and kept with the value. + /// Defaults to the script's path with `.worksheets.html`, or, when the + /// script goes to stdout, the ceremony file's. + #[arg(long, value_name = "PATH")] + pub worksheets: Option, /// Document theme #[arg(long, value_enum, default_value_t = ThemeArg::default())] pub theme: ThemeArg, @@ -37,7 +46,63 @@ pub fn run(args: &Args) { eprintln!("Failed to render script: {e}"); std::process::exit(1); }); + // Rendered before anything is written, so a failure leaves no script + // without its sheets. + let sheets = rite_render::render_worksheets(&resolved, &branding, args.theme.into()) + .unwrap_or_else(|e| { + eprintln!("Failed to render worksheets: {e}"); + std::process::exit(1); + }); let default = default_output_path(&args.file, "html"); write_document(&html, args.output.as_deref(), &default); + + if let Some(sheets) = sheets { + let path = args.worksheets.clone().unwrap_or_else(|| { + worksheets_path( + &args.file, + args.output.as_deref().filter(|p| *p != Path::new("-")), + ) + }); + write_document(&sheets, Some(&path), &path); + } +} + +/// Where the worksheets go by default: beside the script, named after it. +fn worksheets_path(ceremony: &Path, script: Option<&Path>) -> PathBuf { + match script { + Some(script) => { + let name = script + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or("ceremony"); + let stem = name.strip_suffix(".html").unwrap_or(name); + script.with_file_name(format!("{stem}.worksheets.html")) + } + None => default_output_path(ceremony, "worksheets.html"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn worksheets_sit_beside_the_script() { + assert_eq!( + worksheets_path(Path::new("c/split.rite.yaml"), None), + PathBuf::from("c/split.worksheets.html") + ); + assert_eq!( + worksheets_path( + Path::new("c/split.rite.yaml"), + Some(Path::new("out/day1.html")) + ), + PathBuf::from("out/day1.worksheets.html") + ); + assert_eq!( + worksheets_path(Path::new("split.rite.yaml"), Some(Path::new("out/day1"))), + PathBuf::from("out/day1.worksheets.html") + ); + } } diff --git a/crates/rite/tests/examples.rs b/crates/rite/tests/examples.rs index 3084176..2192851 100644 --- a/crates/rite/tests/examples.rs +++ b/crates/rite/tests/examples.rs @@ -77,12 +77,27 @@ fn examples_pass_check() { } } +/// Whether an example asks for something only a person at the keyboard +/// has, which a dry run cannot answer: the rows of a share typed from its +/// sheet. A made-up share would combine into a wrong secret and fail the +/// check after it, so the step stops a headless run, a dry run included. +/// These examples still pass `rite check`, and the sheets +/// `recover_from_paper` uses are typed back by +/// `the_published_sheets_recover_their_secret` in `rite-stdlib`. An answer +/// the author gives for a rehearsal, on the step, would let them run here +/// too. +fn needs_a_person(path: &Path) -> bool { + std::fs::read_to_string(path) + .expect("example is readable") + .contains("action: enter_share") +} + #[test] fn examples_complete_dry_run() { // One output root for all runs; each ceremony writes its own timestamped // subdirectory under it. The TempDir cleans everything up on drop. let out_root = tempfile::tempdir().expect("create output tempdir"); - for file in ceremonies() { + for file in ceremonies().into_iter().filter(|p| !needs_a_person(p)) { Command::cargo_bin("rite") .expect("rite binary builds") // --dry-run already forces the headless driver; --no-prompt keeps diff --git a/docs/development/testing.md b/docs/development/testing.md index e56dbb7..d8871f6 100644 --- a/docs/development/testing.md +++ b/docs/development/testing.md @@ -105,6 +105,11 @@ Everything under `examples/` is a test fixture. `crates/rite/tests/examples.rs` the mock backend. New examples are covered automatically; they are discovered, not enumerated. An example that stops resolving or running is a failed build, so examples cannot rot. +The one exception is an example with an `enter_share` step, which passes `rite check` and is left +out of the dry run: no made-up value is a share, so the step stops a headless run. Such an example +needs a test of its own that types its sheets back, as `crates/rite-stdlib/tests/actions.rs` does +for `recover_from_paper`. + ## What not to test These cost maintenance and catch nothing. Remove on sight; do not add. diff --git a/docs/schema/transcript.schema.json b/docs/schema/transcript.schema.json index 13e583e..f9758e1 100644 --- a/docs/schema/transcript.schema.json +++ b/docs/schema/transcript.schema.json @@ -137,6 +137,11 @@ "description": "Bytes as standard base64 with padding.", "type": "string", "const": "base64" + }, + { + "description": "Bytes as paper32 rows, typed a row at a time from a sheet, a wrong character in a row corrected.", + "type": "string", + "const": "paper32" } ] }, @@ -296,6 +301,81 @@ "required": [ "type" ] + }, + { + "description": "A value shown for a person to write down, withdrawn when they acknowledge. The value is never recorded.", + "type": "object", + "properties": { + "label": { + "description": "The label shown above the value.", + "type": "string" + }, + "note": { + "description": "The note shown with the value, if any.", + "type": [ + "string", + "null" + ] + }, + "type": { + "type": "string", + "const": "reveal" + } + }, + "required": [ + "type", + "label" + ] + }, + { + "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.", + "type": "object", + "properties": { + "format": { + "description": "The encoding the sheet was written in.", + "$ref": "#/$defs/RevealFormat" + }, + "label": { + "description": "The label shown above the rows.", + "type": "string" + }, + "note": { + "description": "The note shown with the rows, if any.", + "type": [ + "string", + "null" + ] + }, + "rows": { + "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.", + "type": [ + "integer", + "null" + ], + "maximum": 9007199254740991, + "minimum": 0 + }, + "type": { + "type": "string", + "const": "enter_rows" + }, + "validator": { + "description": "The check the whole value had to pass. Absent when the step checks the value itself, as for a share.", + "anyOf": [ + { + "$ref": "#/$defs/ValidatorSpec" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "type", + "label", + "format" + ] } ] }, @@ -366,6 +446,21 @@ } ] }, + "RevealFormat": { + "description": "The encoding a value is written on paper in.", + "oneOf": [ + { + "description": "Rows of 32 base-32 characters, 28 of data and 4 of parity; a wrong character in a row is corrected.", + "type": "string", + "const": "paper32" + }, + { + "description": "Two hexadecimal digits per byte, in rows of 32.", + "type": "string", + "const": "hex" + } + ] + }, "RoleId": { "description": "A role's identifier, as written in the ceremony.", "type": "string" diff --git a/docs/secret-sharing.md b/docs/secret-sharing.md new file mode 100644 index 0000000..6ae9c49 --- /dev/null +++ b/docs/secret-sharing.md @@ -0,0 +1,201 @@ +# Secret sharing + +`split_secret` turns a secret into shares, of which a chosen number put it +back together. `reveal` shows a share for its custodian to write down. +`combine_shares` rebuilds the secret from the shares that come back. Any +two of three custodians, say, can recover a wallet's recovery phrase; one +alone learns nothing. + +```yaml +split_the_seed: + action: split_secret + backend: openssl + reads: + secret: ${artifact.seed} + with: + threshold: 2 + shares: 3 + creates: shares + +hand_over_share_1: + action: reveal + role: custodian_1 + reads: + value: ${artifact.shares.share_1} + with: + message: "Share 1, for the first custodian's envelope" + length: 32 + +recover_the_seed: + action: combine_shares + reads: + shares: + - ${artifact.shares.share_3} + - ${artifact.shares.share_1} + creates: recovered_seed +``` + +`secret` is any artifact that resolves to bytes: a material carried into the +room, or a secret a person typed into an earlier step. `threshold` is how +many shares recover the secret and `shares` how many are made, each from 2 +to 100, with at least as many shares as the threshold. + +## What a split does before it finishes + +Every combination of `threshold` shares is put back together and compared +with the secret before the step completes. A share that leaves the room has +been shown to work with every other one. The check bounds the split: a +shape with more than 100,000 combinations (9-of-20, say) is refused, which +admits any split a room of custodians receives, up to 2-of-100, 3-of-85, +4-of-40, 5-of-26 or 8-of-16. `rite check` does not count combinations; a +dry run does, and a dry run is the way to find out. + +The randomness a split needs comes from the step's backend, which is why +`split_secret` names one. A biased draw would leak the secret, so the +backend answers for it the way it answers for a key it generates. The +arithmetic itself is Rite's, in one file meant to be read in full; see +[development/cryptographic-dependencies.md](development/cryptographic-dependencies.md) +for why that is the one primitive not taken from a provider. + +## What a share is + +A share is three things: how many shares recover the secret, which share +this is (1, 2, 3, and so on), and one value per byte of the secret. The +split creates one artifact holding every share, and a later step names one +as `${artifact.shares.share_2}` under its `reads:`. Nowhere else: a share +never appears in `with:`, in a `description:` or in a message, because an +expression makes a copy the run does not wipe and a message is recorded in +the transcript. A step that shows or combines a share borrows it and gives +it back. + +Shares are never written to a file. A file holding every share is the +secret under another name. What leaves the machine is one share at a time, +on paper, through `reveal`. + +## Showing a share + +`reveal` shows a value in a window of its own, once. The window first says +the value is coming, with the step's `note:`, and shows it when the person +presses Enter. Enter again asks whether every row is written down +and checked, since it is not shown again; only a yes closes the window and +takes the value off the screen. In a plain console the value stays in the +terminal, and the person is told to clear it. The transcript records that +the value was shown and nothing of what it was. `message` says what the +value is and what to do with it, and is the window's title and the sheet's +heading. + +A share is shown as rows of characters, 28 of data and 4 of parity each, +the last row shorter. The alphabet is digits and letters without `I`, +`L`, `O` and `U`, in either case, and when a share is typed back an `O` +reads as `0`, an `I` or `L` as `1`, and a `U` as a character that could not +be read. A 32-byte secret is two rows of 32. +The parity is Reed-Solomon over the row: one wrong character in a row is +corrected and the row named, so the person checks it against the sheet; +up to two characters the person cannot read, typed as `?`, are recovered; +a row that needs more is refused by name, to be read again from the sheet +rather than guessed at. The encoding is called paper32. + +`reveal` shows any byte artifact, not only a share, in the same rows; +`format: hex` shows either as two characters per byte instead. + +`rite script` prints a sheet for every `reveal` step, one page each, in a +document of its own beside the script, `.worksheets.html`, since a +sheet is printed once and kept with the value while the script is copied +for everyone in the room: rows of boxes in groups of four with the parity +cells shaded at the end of each row, the encoding named in small print and +the step's `note:` beneath the heading. Give `length:` (the value's size +in bytes, and for a share the secret's, as on `enter_share`) and the sheet +has exactly the right rows, and the step refuses a value of another size +before showing it; leave it out and the sheet has five rows to use as +needed. + +A dry run walks a `reveal` step. A real run without a screen, under +`--frontend headless`, stops at it: no one is there to write, and printing +a secret to a log serves nobody. + +## Typing a share back + +`enter_share` asks for a share from its sheet, one row at a time, and +creates a share artifact that `combine_shares` reads: + +```yaml +type_share_back: + action: enter_share + with: + message: "Recovery share 3" + length: 32 + creates: share_from_bag_3 +``` + +Each row is checked as it is typed. A wrong character is corrected and +the row named, so the person checks that character on the sheet; up to +two characters the person cannot read, typed as `?`, are recovered; a row +that needs more is typed again. Case, spaces and dashes do not matter. +When all the rows are in, rows that are not a share, from a different +sheet say, are refused and asked for again. + +`message` is required and says which sheet. `note:` is shown with the +rows. `length:` is the secret's length in bytes, as on the `reveal` step +that wrote the sheet: the prompt then asks for exactly the right rows, and +a share of another size is refused. Every row but the last is full, so a +short row is the last one; without `length:`, it ends the entry, and so +does an empty row after full ones. `format: hex` reads a sheet written in +hex. + +What is typed is never written to the transcript. The step records which +share came back, by its index, and which rows needed a repair. + +A run without a screen stops at the step, in a dry run as well: the rows +are on a sheet only a person holds. + +## Putting the secret back + +`combine_shares` takes its shares as a list, at least two, in any order: +shares an earlier step made in the same run, or shares custodians typed +back, each its own artifact. Each share knows how many are needed, so too +few is refused before anything is computed, and shares that disagree with +each other on that count or on the secret's length are refused as well. + +That is all the shares can say. Two shares from different splits of the +same shape combine into a wrong secret without complaint. A recovery +therefore ends by checking what came back against something it can see: +an address the wallet shows once the phrase is restored, a check value a +token prints, a `check_value` against a known digest in a drill. + +The recovered secret stays in memory and is dropped with the run, like +content `decrypt_data` opens. + +## What the transcript says + +For a split: the scheme, the threshold and the count, which artifact was +split, which backend supplied the randomness, and how many combinations +were checked. For a recovery: the scheme, the threshold, and which share +indexes went in. For a `reveal`: the message and the acknowledgement. +For an `enter_share`: the message, the share's index and threshold, and +the rows that were repaired. +Never a share, never the secret, not a digest of either and not the +secret's length, which is its shape and, for a passphrase, its character +count. + +## The scheme + +The scheme is named `rite-sss/v1` in the transcript so a recovery decades +from now knows what it is looking at. It is Shamir's secret sharing over +GF(2⁸) with the AES polynomial, one polynomial per byte of the secret, +shares evaluated at 1, 2, 3 and so on, and the secret at 0. That is the +construction of [draft-mcgrew-tss-03](https://datatracker.ietf.org/doc/html/draft-mcgrew-tss-03) +and of [SLIP-0039](https://github.com/satoshilabs/slips/blob/master/slip-0039.md), +and the field arithmetic of [FIPS 197](https://csrc.nist.gov/pubs/fips/197/final) +section 4.2; Rite's shares combine with any GF(256) implementation of it, +and the test suite checks the draft's vector and a split computed with +SLIP-0039's reference implementation. + +A share on paper is the share's bytes, `[version 1][threshold][index]` +followed by the per-byte values, as paper32: five bits per character in +the order `0123456789ABCDEFGHJKMNPQRSTVWXYZ`, rows of 28 characters each +followed by 4 of parity, the parity being the row's polynomial (through +its data at the field elements 0 to 27 of GF(32) with x⁵ + x² + 1) +evaluated at the elements 28 to 31. Decoding a correct sheet without Rite +needs none of that: drop the last four characters of each row, read the +rest as base 32, drop the first byte, read the threshold and index, hand +the rest to the arithmetic above. diff --git a/docs/transcript-format.md b/docs/transcript-format.md index 8bff160..401e207 100644 --- a/docs/transcript-format.md +++ b/docs/transcript-format.md @@ -109,8 +109,8 @@ fact type has a default level: signatures are public; wrapped keys, ciphertext and other content are restricted; opened content is confidential. -A secret value (a private key, a PIN, a passphrase, opened plaintext) is never recorded, at any -level. +A secret value (a private key, a PIN, a passphrase, opened plaintext, a share, a value shown for +writing down) is never recorded, at any level. ## Facts diff --git a/docs/typed-entry.md b/docs/typed-entry.md index 25e068f..a75ae72 100644 --- a/docs/typed-entry.md +++ b/docs/typed-entry.md @@ -85,6 +85,7 @@ rather than found later, and the rule is stated in the prompt before typing. | `alphanumeric` | ASCII letters and digits | the text | | `hex` | bytes, two digits per byte, either case | the decoded bytes | | `base64` | bytes, standard base64 with padding | the decoded bytes | +| `paper32` | bytes as rows from a sheet, `enter_secret` only | the decoded bytes | | `{ pattern: "..." }` | whatever the regular expression accepts, in full | the text | Each format has one canonical representation, and that is what the step @@ -123,10 +124,21 @@ echoed back by the message that refused it. The rule is part of the prompt, so it is recorded with it: the transcript says a six-digit secret was entered, which is what the definition already said. -Encodings with a checksum and a wordlist, `bech32m` and `bip39`, are not in -the vocabulary yet. They follow the same rule when they land: one canonical -form each, which for a BIP-39 phrase is the normalized words rather than the -entropy, since that is what a wallet takes. +`paper32` is the encoding `reveal` shows and `rite script` prints a sheet for, +described in [secret-sharing.md](secret-sharing.md). A value in it is typed a +row at a time rather than on one line, and each row is checked as it comes: a +wrong character is corrected and the row named, for the person to check on the +sheet, and up to two characters typed as `?` are recovered. With `length:` the +prompt asks for exactly the right rows; without it a short row, the last, or +an empty row after full ones ends the entry. It is for `enter_secret` only: a +value on a sheet is a secret, and `enter_value` would put it in the +transcript. A share is typed back with `enter_share` instead, which checks +that it is one. + +Encodings with a wordlist, such as `bip39`, are not in the vocabulary yet. +They follow the same rule when they land: one canonical form each, which for +a BIP-39 phrase is the normalized words rather than the entropy, since that +is what a wallet takes. ## Dry runs @@ -137,6 +149,10 @@ through a PIN or a passphrase step as it walks through any other. A pattern cannot be answered generically, and a step carrying one fails fast in a dry run rather than being refused and asked again without end. +A `paper32` secret gets a placeholder the same way, in a dry run only. A share +typed back with `enter_share` gets none, since no made-up value is a share, so +a dry run stops at that step. + The placeholder is never the real passphrase, so an `import_key` that needs one fails in a dry run. diff --git a/examples/showcase/README.md b/examples/showcase/README.md index dc9fa54..64008f0 100644 --- a/examples/showcase/README.md +++ b/examples/showcase/README.md @@ -136,11 +136,15 @@ held wiped in memory and, since no output names it, never written out. ### `split_and_combine.rite.yaml` — Splitting a Secret and Putting It Back -Splits a secret into three shares of which any two recover it, recovers it -from two, and compares the result against what went in. `split_secret` checks -every pair of shares before the step completes, so a share that leaves the -room has been shown to work. `combine_shares` takes its shares as a list, in -any order; each share knows how many are needed, so too few is refused. +Splits a secret into three shares of which any two recover it, hands one +over, recovers the secret from two, and compares the result against what +went in. `split_secret` checks every pair of shares before the step +completes, so a share that leaves the room has been shown to work. `reveal` +shows share 3 as rows of characters while its custodian copies it, then +takes it off the screen; `rite script` prints the sheet for that step +beside the script, rows of boxes with the parity cells shaded at the end of +each. `combine_shares` takes its shares as a list, in any order; each share +knows how many are needed, so too few is refused. What a share cannot tell is whether it belongs with the others: shares from two different splits give a wrong secret without complaint, which is why a @@ -149,4 +153,17 @@ hand; a real recovery checks something derived from the secret instead. Shares and the recovered secret stay in memory and are never written to a file. The transcript names the scheme, the threshold and which shares went -into the recovery, and nothing about the secret itself. +into the recovery, and nothing about the secret itself. A dry run walks the +`reveal` step; a real run needs a screen and a person. See +`docs/secret-sharing.md`. + +### `recover_from_paper.rite.yaml` — Recovering a Secret from Paper + +Two custodians type their shares back from paper, the shares recover the +secret, and the result is checked against a digest recorded at the split. +`enter_share` takes a sheet row by row and checks each row as it is typed: +a character copied wrong is corrected and its row named, a character that +cannot be read is typed as `?` and recovered, and a row that needs more is +typed again. The sheets are in `test_data/recovery_sheets.txt`; try +changing a character in one. The example needs someone at the keyboard, so +`rite run --dry-run` stops at the first sheet. diff --git a/examples/showcase/recover_from_paper.rite.yaml b/examples/showcase/recover_from_paper.rite.yaml new file mode 100644 index 0000000..a21e2b6 --- /dev/null +++ b/examples/showcase/recover_from_paper.rite.yaml @@ -0,0 +1,85 @@ +version: "0.3" +name: "Recovering a Secret from Paper" +description: | + Two custodians bring the sheets their shares were written on. Each sheet + is typed back, row by row, the two shares recover the secret, and the + result is checked against a digest recorded when the secret was split. + + Demonstrates enter_share and combine_shares. The sheets for this example + are in test_data/recovery_sheets.txt: shares 1 and 3 of a 2-of-3 split of + the secret split_and_combine uses. + +parameters: + expected_digest: + type: string + default: "17c130ea49ca769138601fbba7350589b67609bdcda7d8951bcd2800ccc79ac4" + description: | + SHA-256 of the secret, in hex, recorded when it was split and kept + apart from the shares. It says whether what comes back is the secret, + without anyone holding a copy of it. + +materials: + sheet_1: + type: physical + title: "Sheet for recovery share 1" + description: "Kept by the first custodian since the split." + sheet_3: + type: physical + title: "Sheet for recovery share 3" + description: "Kept by the third custodian since the split." + +roles: + officer: + name: "Ceremony officer" + person: "Alice Rivera" + +sections: + recover: + name: "Recover" + role: ${role.officer} + steps: + type_share_1_back: + action: enter_share + with: + message: "Recovery share 1" + note: "The first custodian reads from their sheet, or types it." + length: 32 + creates: share_1 + description: | + The sheet is typed back row by row. Each row is checked as it is + typed: a character copied wrong is corrected and named, for the + custodian to check on the sheet, and a row that cannot be repaired + is typed again. The transcript records which share came back and + which rows needed a repair, and nothing of the rows. + + type_share_3_back: + action: enter_share + with: + message: "Recovery share 3" + note: "The third custodian reads from their sheet, or types it." + length: 32 + creates: share_3 + + recover_the_secret: + action: combine_shares + reads: + shares: + - ${artifact.share_1} + - ${artifact.share_3} + creates: recovered + description: | + Two of the three are enough. Each share knows how many are needed + and which one it is, so the order does not matter. A share from a + different split gives a wrong secret without complaint, which is + why the next step checks the result. + + confirm_it_is_the_secret: + action: check_value + with: + message: "The recovered secret has the digest recorded at the split." + actual: ${artifact.recovered | sha256 | hex} + expected: ${param.expected_digest} + description: | + A wallet recovery checks the address the device shows once the + phrase is restored instead; the principle is the same: something + observable, recorded apart from the shares. diff --git a/examples/showcase/split_and_combine.rite.yaml b/examples/showcase/split_and_combine.rite.yaml index c9145c1..29bb323 100644 --- a/examples/showcase/split_and_combine.rite.yaml +++ b/examples/showcase/split_and_combine.rite.yaml @@ -4,8 +4,8 @@ description: | Split a secret into three shares, of which any two recover it. Then recover it from two of them and compare it against what went in. - Demonstrates split_secret and combine_shares. The split is Shamir secret - sharing, and the transcript names the scheme so the secret can be + Demonstrates split_secret, reveal and combine_shares. The split is Shamir + secret sharing, and the transcript names the scheme so the secret can be recovered without Rite. backends: @@ -44,6 +44,20 @@ sections: so a share that leaves the room has been shown to work. The shares stay in memory and are never written to a file. + hand_over_share_3: + action: reveal + reads: + value: ${artifact.shares.share_3} + with: + message: "Recovery share 3" + note: "Write in block capitals. The sheet goes in the envelope, the envelope in the safe." + length: 32 + description: | + Share 3 is shown on screen while the custodian copies it onto the + sheet printed for this step, then it is taken off the + screen. The transcript says the share was shown and nothing of + what it was. + recover_from_two_shares: action: combine_shares reads: diff --git a/examples/showcase/test_data/recovery_sheets.txt b/examples/showcase/test_data/recovery_sheets.txt new file mode 100644 index 0000000..a2da19b --- /dev/null +++ b/examples/showcase/test_data/recovery_sheets.txt @@ -0,0 +1,13 @@ +The two sheets recover_from_paper.rite.yaml asks for: shares 1 and 3 of a +2-of-3 split of secret.txt, in paper32. Each row is 28 characters of data +and, after the bar, 4 of parity. Type them in any case, with or without the +spaces; try changing a character or typing ? for one, and see the row +repaired. + +Recovery share 1 + 1 0410 2P86 N88E 54SN T4TH BE2N ZE9K | 4M2Q + 2 BQB9 2YR2 X6S2 A3TG HDPG CPE3 GHE6 | PZTC + +Recovery share 3 + 1 0410 6GYT 5XRD PV4K M8FZ J395 T9Y8 | 3Y81 + 2 7FJK XCAT XGH6 JSK4 5885 YQAJ S4T5 | 1E5Q diff --git a/examples/showcase/test_data/secret.txt b/examples/showcase/test_data/secret.txt index 29a8309..a4d069a 100644 --- a/examples/showcase/test_data/secret.txt +++ b/examples/showcase/test_data/secret.txt @@ -1 +1 @@ -The safe combination is 31-07-52. +The safe combination: 31-07-52.