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