From 78d778aaa97d3951f1c49b94d3ec11b9e7de2eb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lomig=20Me=CC=81gard?= Date: Sun, 27 Sep 2026 22:23:52 +0200 Subject: [PATCH] feat: shares and secrets written on paper, shown once and typed back reveal shows a share or any byte artifact for a person to write down. A window over the log says the value is coming and that it is not written to the transcript, shows it on Enter with its rows numbered as on the sheet, and closes only on a yes to "every row written down and checked?". The prompt is recorded without the value and the fact says only that it was shown. The console asks the same way and says to clear the terminal; a headless run acknowledges in a dry run and stops otherwise. The encoding is paper32, in rite_model::paper32: rows of 28 base-32 characters and 4 of parity, the alphabet without I, L, O and U, each row a Reed-Solomon codeword over GF(32) evaluated at all 32 field elements. One wrong character in a row is corrected and the row named, two unreadable ones (typed as ? or U) are recovered, and a row that needs more is refused by name. A correct sheet decodes by dropping the last four characters of each row. A share is its wire bytes in these rows, 64 characters for a 32-byte secret. format: hex shows two characters per byte instead. rite script writes the sheets beside the script, in .worksheets.html or where --worksheets says, one page per reveal step: the ceremony in the header, the step's message and note, rows of boxes in cells of four with the parity cells shaded, the encoding in small print. length: makes the rows exact, and the step refuses a value of another size before showing it. enter_share types a share back from its sheet. A new prompt, EnterRows, takes a value row by row; the TUI and the console check each row as it is typed, so a slip is corrected and its row named at once. Every row but the last is full, so a short row where more are due is refused and a short row ends an entry of no set length. Rows that are not a share, or a share of another size than length: says, are asked for again through the reporter's new prompt_checked. The share is held as a set of one, which combine_shares names without a property. The transcript records the share's index and the rows repaired, never the rows. enter_secret takes format: paper32 through the same prompt, which carries the step's rule so the runtime checks it and a rehearsal can build a stand-in; enter_value refuses paper32. split_secret no longer logs how its shares are named. The split_and_combine example splits a 32-byte secret and hands share 3 over on paper; recover_from_paper recovers a secret from two fixed sheets typed back and checks it against a digest. Examples with an enter_share step are left out of the dry-run test, since no made-up value is a share; instead a test types the published sheets back through enter_share and combine_shares and must get secret.txt, which pins the alphabet, bit order, parity, row size and share header to fixed text. ReporterHarness hands a reporter only the answers queued when it was built, so a prompt left without one fails instead of waiting forever. docs/secret-sharing.md and docs/typed-entry.md cover the actions, the paper format and the sheet, and the transcript schema covers the two new prompts. --- Cargo.lock | 2 + README.md | 2 +- crates/rite-ls/src/actions.rs | 12 +- crates/rite-model/src/display.rs | 436 ++++++++++ crates/rite-model/src/lib.rs | 8 +- crates/rite-model/src/paper32.rs | 760 ++++++++++++++++++ crates/rite-model/src/params.rs | 59 +- crates/rite-model/src/transcript.rs | 152 +++- crates/rite-model/src/types.rs | 54 +- crates/rite-render/src/engine.rs | 31 +- crates/rite-render/src/lib.rs | 4 +- crates/rite-render/src/view.rs | 170 +++- .../rite-render/templates/themes/formal.css | 57 +- .../templates/worksheets.html.jinja | 46 ++ .../tests/fixtures/reissue_a_sheet.rite.yaml | 27 + crates/rite-render/tests/render.rs | 46 +- .../tests/snapshots/render__report_empty.snap | 57 +- .../tests/snapshots/render__script_demo.snap | 57 +- .../snapshots/render__script_named_acts.snap | 57 +- crates/rite-runtime/src/reporter.rs | 39 +- crates/rite-runtime/src/test_support.rs | 32 +- crates/rite-stdlib/src/entry/mod.rs | 48 +- crates/rite-stdlib/src/lib.rs | 6 +- crates/rite-stdlib/src/params.rs | 44 + crates/rite-stdlib/src/sharing/enter_share.rs | 120 +++ crates/rite-stdlib/src/sharing/mod.rs | 13 +- crates/rite-stdlib/src/sharing/paper.rs | 124 +++ crates/rite-stdlib/src/sharing/reveal.rs | 119 +++ .../rite-stdlib/src/sharing/split_secret.rs | 8 - crates/rite-stdlib/src/sharing/wire.rs | 8 +- crates/rite-stdlib/tests/actions.rs | 452 ++++++++++- crates/rite-tui/Cargo.toml | 1 + crates/rite-tui/src/model.rs | 50 ++ crates/rite-tui/src/msg.rs | 5 +- crates/rite-tui/src/preview.rs | 95 ++- crates/rite-tui/src/runtime.rs | 2 +- crates/rite-tui/src/update.rs | 259 +++++- crates/rite-tui/src/view.rs | 219 ++++- crates/rite/Cargo.toml | 1 + crates/rite/src/console.rs | 180 +++++ crates/rite/src/headless.rs | 202 ++++- crates/rite/src/main.rs | 4 +- crates/rite/src/run.rs | 5 +- crates/rite/src/script.rs | 69 +- crates/rite/tests/examples.rs | 17 +- docs/development/testing.md | 5 + docs/schema/transcript.schema.json | 95 +++ docs/secret-sharing.md | 201 +++++ docs/transcript-format.md | 4 +- docs/typed-entry.md | 24 +- examples/showcase/README.md | 29 +- .../showcase/recover_from_paper.rite.yaml | 85 ++ examples/showcase/split_and_combine.rite.yaml | 18 +- .../showcase/test_data/recovery_sheets.txt | 13 + examples/showcase/test_data/secret.txt | 2 +- 55 files changed, 4494 insertions(+), 141 deletions(-) create mode 100644 crates/rite-model/src/display.rs create mode 100644 crates/rite-model/src/paper32.rs create mode 100644 crates/rite-render/templates/worksheets.html.jinja create mode 100644 crates/rite-render/tests/fixtures/reissue_a_sheet.rite.yaml create mode 100644 crates/rite-stdlib/src/sharing/enter_share.rs create mode 100644 crates/rite-stdlib/src/sharing/paper.rs create mode 100644 crates/rite-stdlib/src/sharing/reveal.rs create mode 100644 docs/secret-sharing.md create mode 100644 examples/showcase/recover_from_paper.rite.yaml create mode 100644 examples/showcase/test_data/recovery_sheets.txt 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.