1use std::fmt;
15use std::str::FromStr;
16
17use serde::{Deserialize, Serialize};
18use zeroize::Zeroizing;
19
20use crate::{bip39, paper32};
21
22#[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 #[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 #[cfg_attr(
46 test,
47 schemars(description = "Two hexadecimal digits per byte, in rows of 32.")
48 )]
49 Hex,
50 #[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 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 pub fn is_words(self) -> bool {
75 match self {
76 RevealFormat::Paper32 | RevealFormat::Hex => false,
77 RevealFormat::Bip39 => true,
78 }
79 }
80
81 pub fn unit(self) -> &'static str {
83 match self {
84 RevealFormat::Paper32 | RevealFormat::Hex => "bytes",
85 RevealFormat::Bip39 => "words",
86 }
87 }
88
89 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 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 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 pub fn layout_for(format: Option<Self>) -> Layout {
145 format.unwrap_or(RevealFormat::Paper32).layout()
146 }
147
148 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 pub fn read_row(self, text: &str, row: usize, more: bool) -> Result<TypedRow, String> {
177 let read = self.read_one_row(text, row)?;
178 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 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 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 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#[derive(Debug)]
301pub struct TypedRow {
302 pub text: Zeroizing<String>,
304 pub repair: Option<String>,
306 pub full: bool,
309}
310
311#[derive(Debug)]
313pub struct TypedValue {
314 pub bytes: Zeroizing<Vec<u8>>,
316 pub repaired: Vec<String>,
318}
319
320fn 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#[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#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
366pub struct Layout {
367 pub format: RevealFormat,
369 pub group: usize,
371 pub row: usize,
373 pub parity: usize,
375 pub alphabet: String,
377 pub algorithm: String,
379}
380
381impl Default for Layout {
382 fn default() -> Self {
383 RevealFormat::Paper32.layout()
384 }
385}
386
387impl Layout {
388 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#[derive(Debug, Clone, PartialEq, Eq)]
420pub struct Row<'a> {
421 pub groups: Vec<&'a str>,
423 pub parity: &'a str,
425}
426
427pub 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 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 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 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}