Skip to main content

rite_model/
display.rs

1//! How a value is written out for a person: the encodings `reveal` shows,
2//! and the shape each gives a sheet of paper.
3//!
4//! The shape is a property of the encoding and not of the value, so
5//! `rite script` prints it before the ceremony runs: how many
6//! characters make a row, how many of those are parity, how characters
7//! group for the eye, what the alphabet is and what the encoding is
8//! called. Only the number of rows depends on the value, and a sheet does
9//! not need it when every row has the same shape.
10//!
11//! `bip39` is words rather than characters: one word per row, numbered
12//! as a wallet asks for them, so the rows are the length.
13
14use std::fmt;
15use std::str::FromStr;
16
17use serde::{Deserialize, Serialize};
18use zeroize::Zeroizing;
19
20use crate::types::Vocabulary;
21use crate::{bip39, paper32};
22
23/// An encoding a value is shown in.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
25#[serde(rename_all = "snake_case")]
26#[schemars(description = "The encoding a value is written on paper in.")]
27#[non_exhaustive]
28pub enum RevealFormat {
29    /// Rows of 32 base-32 characters, 28 of data and 4 of parity, from an
30    /// alphabet without `I`, `L`, `O` and `U`; a wrong character in a row
31    /// is corrected, two unreadable ones recovered. The default. See
32    /// [`paper32`].
33    #[schemars(
34        description = "Rows of 32 base-32 characters, 28 of data and 4 of parity; a wrong \
35            character in a row is corrected."
36    )]
37    Paper32,
38    /// Two hexadecimal digits per byte, upper case, in rows of 32.
39    #[schemars(description = "Two hexadecimal digits per byte, in rows of 32.")]
40    Hex,
41    /// Words from the BIP-39 English list, one per row, as a hardware
42    /// wallet shows its recovery phrase and takes it back. For entropy of
43    /// 16 to 32 bytes in steps of 4. See [`bip39`].
44    #[schemars(
45        description = "Words from the BIP-39 English list, one per row, for a wallet's \
46            recovery phrase."
47    )]
48    Bip39,
49}
50
51crate::types::vocabulary!(RevealFormat {
52    Paper32 => "paper32",
53    Hex => "hex",
54    Bip39 => "bip39",
55});
56
57impl RevealFormat {
58    /// Whether the value is words, one per row, rather than characters.
59    pub fn is_words(self) -> bool {
60        match self {
61            RevealFormat::Paper32 | RevealFormat::Hex => false,
62            RevealFormat::Bip39 => true,
63        }
64    }
65
66    /// What `length:` counts in this format, as a person reads it.
67    pub fn unit(self) -> &'static str {
68        match self {
69            RevealFormat::Paper32 | RevealFormat::Hex => "bytes",
70            RevealFormat::Bip39 => "words",
71        }
72    }
73
74    /// A value of `bytes` bytes in this format's unit: bytes, or the words
75    /// of a phrase.
76    pub fn length_of(self, bytes: usize) -> usize {
77        match self {
78            RevealFormat::Paper32 | RevealFormat::Hex => bytes,
79            RevealFormat::Bip39 => bip39::words_for(bytes),
80        }
81    }
82
83    /// The bytes a `length:` in this format's unit stands for.
84    pub fn bytes_for(self, length: usize) -> usize {
85        match self {
86            RevealFormat::Paper32 | RevealFormat::Hex => length,
87            RevealFormat::Bip39 => bip39::bytes_for(length),
88        }
89    }
90
91    /// The shape a sheet takes for this encoding.
92    pub fn layout(self) -> Layout {
93        match self {
94            RevealFormat::Paper32 => Layout {
95                format: self,
96                group: 4,
97                row: paper32::ROW_DATA,
98                parity: paper32::ROW_PARITY,
99                alphabet: "Digits and letters, never I, L, O or U; upper or lower case".to_string(),
100                algorithm: "Paper32: rows of 28 characters and 4 of parity, Reed-Solomon over \
101                            GF(32)"
102                    .to_string(),
103            },
104            RevealFormat::Hex => Layout {
105                format: self,
106                group: 4,
107                row: 32,
108                parity: 0,
109                alphabet: "Digits 0 to 9 and letters A to F".to_string(),
110                algorithm: "Hexadecimal, two characters per byte".to_string(),
111            },
112            RevealFormat::Bip39 => Layout {
113                format: self,
114                group: bip39::WORD_MAX,
115                row: bip39::WORD_MAX,
116                parity: 0,
117                alphabet: "Words from the BIP-39 English list, one per row; the first four \
118                           letters are enough"
119                    .to_string(),
120                algorithm: "BIP-39, English: 11 bits per word, the last word carrying a \
121                            SHA-256 checksum"
122                    .to_string(),
123            },
124        }
125    }
126
127    /// The shape a `reveal` step gives from its definition: its format, or
128    /// the default.
129    pub fn layout_for(format: Option<Self>) -> Layout {
130        format.unwrap_or(RevealFormat::Paper32).layout()
131    }
132
133    /// Rows a value of `bytes` bytes takes.
134    pub fn rows(self, bytes: usize) -> usize {
135        let layout = self.layout();
136        self.characters(bytes)
137            .div_ceil(layout.row.saturating_add(layout.parity).max(1))
138    }
139
140    /// One row as typed, checked on its own: the characters it should be,
141    /// and the repair it took, in words. `row` is its number from 1, and
142    /// `more` says rows are known to follow it.
143    ///
144    /// Every row but the last is full, since a row's place in the value is
145    /// its position: a short row is the last one, and one with rows after
146    /// it is a row with characters missing.
147    ///
148    /// ```
149    /// use rite_model::RevealFormat;
150    ///
151    /// let row = RevealFormat::Paper32.read_row("E9MQ 8S8P H8B", 1, false).unwrap();
152    /// assert_eq!(row.text.as_str(), "E9MQ8S8PH8B");
153    /// assert!(!row.full);
154    /// // A short row where more are to come has characters missing.
155    /// assert!(RevealFormat::Paper32.read_row("E9MQ8S8PH8B", 1, true).is_err());
156    /// ```
157    ///
158    /// # Errors
159    ///
160    /// What is wrong with the row, in words, for the person to fix.
161    pub fn read_row(self, text: &str, row: usize, more: bool) -> Result<TypedRow, String> {
162        let read = self.read_one_row(text, row)?;
163        // A word is a whole row however long it is.
164        if self.is_words() {
165            return Ok(read);
166        }
167        let full = self.layout().row.saturating_add(self.layout().parity);
168        let characters = read.text.chars().count();
169        if more && characters < full {
170            return Err(format!(
171                "{characters} characters; every row but the last has {full}"
172            ));
173        }
174        Ok(TypedRow {
175            full: characters == full,
176            ..read
177        })
178    }
179
180    fn read_one_row(self, text: &str, row: usize) -> Result<TypedRow, String> {
181        match self {
182            RevealFormat::Paper32 => paper32::read_row(text, row)
183                .map(|read| TypedRow {
184                    text: read.text,
185                    repair: read.repair.map(|r| r.to_string()),
186                    full: false,
187                })
188                .map_err(|e| e.to_string()),
189            RevealFormat::Hex => {
190                let digits = hex_digits(text)?;
191                let row_len = self.layout().row;
192                if digits.is_empty() {
193                    return Err("nothing to read".to_string());
194                }
195                if digits.len() > row_len {
196                    return Err(format!(
197                        "{} characters; a row has at most {row_len}",
198                        digits.len()
199                    ));
200                }
201                Ok(TypedRow {
202                    text: digits,
203                    repair: None,
204                    full: false,
205                })
206            }
207            RevealFormat::Bip39 => bip39::read_word(text, row)
208                .map(|text| TypedRow {
209                    text,
210                    repair: None,
211                    full: true,
212                })
213                .map_err(|e| e.to_string()),
214        }
215    }
216
217    /// A whole value as typed, rows one after another: its bytes, and the
218    /// repairs it took, in words.
219    ///
220    /// # Errors
221    ///
222    /// What is wrong with the text, in words.
223    pub fn decode(self, text: &str) -> Result<TypedValue, String> {
224        match self {
225            RevealFormat::Paper32 => paper32::decode(text)
226                .map(|decoded| TypedValue {
227                    bytes: decoded.bytes,
228                    repaired: decoded.repaired.iter().map(ToString::to_string).collect(),
229                })
230                .map_err(|e| e.to_string()),
231            RevealFormat::Hex => {
232                let digits = hex_digits(text)?;
233                if digits.len() % 2 != 0 {
234                    return Err(format!(
235                        "{} hexadecimal digits; a byte is two",
236                        digits.len()
237                    ));
238                }
239                let bytes = digits
240                    .as_bytes()
241                    .chunks(2)
242                    .map(|pair| {
243                        std::str::from_utf8(pair)
244                            .ok()
245                            .and_then(|p| u8::from_str_radix(p, 16).ok())
246                            .unwrap_or(0)
247                    })
248                    .collect();
249                Ok(TypedValue {
250                    bytes: Zeroizing::new(bytes),
251                    repaired: Vec::new(),
252                })
253            }
254            RevealFormat::Bip39 => bip39::decode(text)
255                .map(|decoded| TypedValue {
256                    bytes: decoded.bytes,
257                    repaired: Vec::new(),
258                })
259                .map_err(|e| e.to_string()),
260        }
261    }
262
263    /// Characters a value of `bytes` bytes takes, parity included.
264    pub fn characters(self, bytes: usize) -> usize {
265        match self {
266            RevealFormat::Paper32 => {
267                let symbols = bytes.saturating_mul(8).div_ceil(5);
268                let rows = symbols.div_ceil(paper32::ROW_DATA);
269                symbols.saturating_add(rows.saturating_mul(paper32::ROW_PARITY))
270            }
271            RevealFormat::Hex => bytes.saturating_mul(2),
272            // A row of boxes per word, as long as the longest word.
273            RevealFormat::Bip39 => bip39::words_for(bytes).saturating_mul(bip39::WORD_MAX),
274        }
275    }
276}
277
278impl fmt::Display for RevealFormat {
279    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
280        f.write_str(self.as_str())
281    }
282}
283
284/// A row [`RevealFormat::read_row`] read.
285#[derive(Debug)]
286pub struct TypedRow {
287    /// The row as it should read, upper case, without separators.
288    pub text: Zeroizing<String>,
289    /// The repair it took, in words.
290    pub repair: Option<String>,
291    /// Whether the row has every character a row can have. A row that
292    /// does not is the last one.
293    pub full: bool,
294}
295
296/// A value [`RevealFormat::decode`] read.
297#[derive(Debug)]
298pub struct TypedValue {
299    /// The bytes, wiped when dropped.
300    pub bytes: Zeroizing<Vec<u8>>,
301    /// The repairs, in words, one per row that took one.
302    pub repaired: Vec<String>,
303}
304
305/// Hexadecimal digits in upper case, separators dropped.
306fn hex_digits(text: &str) -> Result<Zeroizing<String>, String> {
307    let mut digits = Zeroizing::new(String::with_capacity(text.len()));
308    for (position, c) in text.chars().enumerate() {
309        match c {
310            ' ' | '-' | '|' | '\t' | '\n' | '\r' | '.' => {}
311            c if c.is_ascii_hexdigit() => digits.push(c.to_ascii_uppercase()),
312            _ => {
313                return Err(format!(
314                    "character {} is not a hexadecimal digit (0 to 9, A to F)",
315                    position.saturating_add(1)
316                ));
317            }
318        }
319    }
320    Ok(digits)
321}
322
323/// A `format:` value that names no encoding this build shows.
324#[derive(Debug, Clone, PartialEq, Eq)]
325pub struct UnknownRevealFormat(pub String);
326
327impl fmt::Display for UnknownRevealFormat {
328    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
329        write!(
330            f,
331            "'{}' is not a format; use {}",
332            self.0,
333            crate::types::either(RevealFormat::ALL.iter().map(|format| format.as_str()))
334        )
335    }
336}
337
338impl std::error::Error for UnknownRevealFormat {}
339
340impl FromStr for RevealFormat {
341    type Err = UnknownRevealFormat;
342
343    fn from_str(s: &str) -> Result<Self, Self::Err> {
344        Self::from_name(s).ok_or_else(|| UnknownRevealFormat(s.to_string()))
345    }
346}
347
348/// The shape a written value takes: what a sheet pre-prints and how a
349/// screen lays the characters out.
350#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
351pub struct Layout {
352    /// The encoding.
353    pub format: RevealFormat,
354    /// Characters per group, for the eye.
355    pub group: usize,
356    /// Data characters in a full row.
357    pub row: usize,
358    /// Parity characters closing every row; zero when there are none.
359    pub parity: usize,
360    /// What the characters can be, in words.
361    pub alphabet: String,
362    /// What the encoding is called, in small print.
363    pub algorithm: String,
364}
365
366impl Default for Layout {
367    fn default() -> Self {
368        RevealFormat::Paper32.layout()
369    }
370}
371
372impl Layout {
373    /// Split a written value into rows, each its data in groups and its
374    /// parity, for a screen to style each part. Words are a row each.
375    pub fn rows<'a>(&self, text: &'a str) -> Vec<Row<'a>> {
376        if self.format.is_words() {
377            return text
378                .split_whitespace()
379                .map(|word| Row {
380                    groups: vec![word],
381                    parity: "",
382                })
383                .collect();
384        }
385        let row_len = self.row.saturating_add(self.parity).max(1);
386        text.as_bytes()
387            .chunks(row_len)
388            .map(|chunk| {
389                let line = std::str::from_utf8(chunk).unwrap_or_default();
390                let data_len = line.len().saturating_sub(self.parity);
391                let (data, parity) = line.split_at(data_len.min(line.len()));
392                let groups = data
393                    .as_bytes()
394                    .chunks(self.group.max(1))
395                    .map(|g| std::str::from_utf8(g).unwrap_or_default())
396                    .collect();
397                Row { groups, parity }
398            })
399            .collect()
400    }
401}
402
403/// One row of a written value.
404#[derive(Debug, Clone, PartialEq, Eq)]
405pub struct Row<'a> {
406    /// The data, grouped.
407    pub groups: Vec<&'a str>,
408    /// The parity characters, possibly empty.
409    pub parity: &'a str,
410}
411
412/// Bytes as upper-case hex.
413pub fn hex_upper(bytes: &[u8]) -> String {
414    use std::fmt::Write as _;
415    bytes.iter().fold(
416        String::with_capacity(bytes.len().saturating_mul(2)),
417        |mut out, b| {
418            let _ = write!(out, "{b:02X}");
419            out
420        },
421    )
422}
423
424#[cfg(test)]
425#[allow(clippy::indexing_slicing)]
426mod tests {
427    use super::*;
428
429    #[test]
430    fn a_row_is_checked_on_its_own_and_a_value_read_whole() {
431        // 30 bytes: a full row of 28 and 4, then 20 and 4.
432        let bytes = [0x5A; 30];
433        let text = paper32::encode(&bytes);
434        let (first, second) = text.split_at(32);
435        let row = RevealFormat::Paper32.read_row(first, 1, true).unwrap();
436        assert_eq!(row.text.as_str(), first);
437        assert!(row.repair.is_none());
438        assert!(row.full);
439
440        let mut slipped: Vec<char> = second.to_ascii_lowercase().chars().collect();
441        slipped[3] = if slipped[3] == 'a' { 'b' } else { 'a' };
442        let slipped: String = slipped.iter().collect();
443        let row = RevealFormat::Paper32.read_row(&slipped, 2, false).unwrap();
444        assert_eq!(row.text.as_str(), second);
445        assert!(!row.full);
446        assert!(row.repair.unwrap().starts_with("row 2: character 4"));
447        assert!(RevealFormat::Paper32.read_row("ABCD", 1, false).is_err());
448        // The short last row, where the sheet says more rows follow.
449        let refused = RevealFormat::Paper32.read_row(second, 1, true).unwrap_err();
450        assert!(
451            refused.contains("every row but the last has 32"),
452            "{refused}"
453        );
454
455        let value = RevealFormat::Paper32
456            .decode(&format!("{first}\n{slipped}"))
457            .unwrap();
458        assert_eq!(*value.bytes, bytes);
459        assert_eq!(value.repaired.len(), 1);
460
461        let hex = RevealFormat::Hex.read_row("de ad-be ef", 1, false).unwrap();
462        assert_eq!(hex.text.as_str(), "DEADBEEF");
463        assert!(!hex.full);
464        assert!(RevealFormat::Hex.read_row("DEAG", 1, false).is_err());
465        assert!(RevealFormat::Hex.read_row("DEADBEEF", 1, true).is_err());
466        assert_eq!(
467            *RevealFormat::Hex.decode("DEAD\nbeef").unwrap().bytes,
468            [0xDE, 0xAD, 0xBE, 0xEF]
469        );
470        assert!(RevealFormat::Hex.decode("DEA").is_err());
471        assert_eq!(RevealFormat::Paper32.rows(35), 2);
472        assert_eq!(RevealFormat::Hex.rows(35), 3);
473    }
474
475    #[test]
476    fn a_paper32_string_splits_into_rows_of_groups_and_parity() {
477        let layout = RevealFormat::Paper32.layout();
478        let text = paper32::encode(&[0x5A; 35]);
479        let rows = layout.rows(&text);
480        assert_eq!(rows.len(), 2);
481        assert_eq!(rows[0].groups.len(), 7);
482        assert!(rows[0].groups.iter().all(|g| g.len() == 4));
483        assert_eq!(rows[0].parity.len(), 4);
484        assert_eq!(rows[1].parity.len(), 4);
485        assert_eq!(RevealFormat::Paper32.characters(35), 64);
486        assert_eq!(RevealFormat::Paper32.characters(32), 60);
487        assert_eq!(RevealFormat::Paper32.characters(1), 6);
488    }
489
490    #[test]
491    fn hex_has_no_parity() {
492        let layout = RevealFormat::Hex.layout();
493        let rows = layout.rows("DEADBEEF01");
494        assert_eq!(rows.len(), 1);
495        assert_eq!(rows[0].groups, ["DEAD", "BEEF", "01"]);
496        assert_eq!(rows[0].parity, "");
497        assert_eq!(hex_upper(&[0xde, 0xad]), "DEAD");
498        assert_eq!(RevealFormat::Hex.characters(5), 10);
499    }
500
501    #[test]
502    fn a_format_is_named_and_parsed() {
503        assert_eq!("paper32".parse(), Ok(RevealFormat::Paper32));
504        assert_eq!(RevealFormat::Hex.to_string(), "hex");
505        assert_eq!(
506            "words".parse::<RevealFormat>().unwrap_err().to_string(),
507            "'words' is not a format; use paper32, hex or bip39"
508        );
509        assert_eq!("bip39".parse(), Ok(RevealFormat::Bip39));
510    }
511
512    #[test]
513    fn a_phrase_is_a_word_per_row() {
514        let phrase = bip39::encode(&[0u8; 32]).unwrap();
515        let rows = RevealFormat::Bip39.layout().rows(&phrase);
516        assert_eq!(rows.len(), 24);
517        assert_eq!(rows[23].groups, ["art"]);
518        assert_eq!(rows[0].parity, "");
519        assert_eq!(RevealFormat::Bip39.rows(32), 24);
520        assert_eq!(RevealFormat::Bip39.rows(16), 12);
521        assert_eq!(RevealFormat::Bip39.length_of(32), 24);
522        assert_eq!(RevealFormat::Bip39.bytes_for(24), 32);
523
524        // A short word is a whole row, and the count says where the phrase ends.
525        let row = RevealFormat::Bip39.read_row("ART", 24, false).unwrap();
526        assert_eq!(row.text.as_str(), "art");
527        assert!(row.full);
528        assert!(RevealFormat::Bip39.read_row("zoo", 1, true).unwrap().full);
529        assert!(RevealFormat::Bip39.read_row("abandonx", 1, true).is_err());
530        let typed: Vec<&str> = phrase.split(' ').collect();
531        assert_eq!(
532            *RevealFormat::Bip39.decode(&typed.join("\n")).unwrap().bytes,
533            [0u8; 32]
534        );
535    }
536}