use std::fmt;
use std::str::FromStr;
use serde::{Deserialize, Serialize};
use zeroize::Zeroizing;
use crate::{bip39, paper32};
#[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 {
#[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,
#[cfg_attr(
test,
schemars(description = "Two hexadecimal digits per byte, in rows of 32.")
)]
Hex,
#[cfg_attr(
test,
schemars(
description = "Words from the BIP-39 English list, one per row, for a wallet's \
recovery phrase."
)
)]
Bip39,
}
impl RevealFormat {
pub fn as_str(self) -> &'static str {
match self {
RevealFormat::Paper32 => "paper32",
RevealFormat::Hex => "hex",
RevealFormat::Bip39 => "bip39",
}
}
pub fn is_words(self) -> bool {
match self {
RevealFormat::Paper32 | RevealFormat::Hex => false,
RevealFormat::Bip39 => true,
}
}
pub fn unit(self) -> &'static str {
match self {
RevealFormat::Paper32 | RevealFormat::Hex => "bytes",
RevealFormat::Bip39 => "words",
}
}
pub fn length_of(self, bytes: usize) -> usize {
match self {
RevealFormat::Paper32 | RevealFormat::Hex => bytes,
RevealFormat::Bip39 => bip39::words_for(bytes),
}
}
pub fn bytes_for(self, length: usize) -> usize {
match self {
RevealFormat::Paper32 | RevealFormat::Hex => length,
RevealFormat::Bip39 => bip39::bytes_for(length),
}
}
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(),
},
RevealFormat::Bip39 => Layout {
format: self,
group: bip39::WORD_MAX,
row: bip39::WORD_MAX,
parity: 0,
alphabet: "Words from the BIP-39 English list, one per row; the first four \
letters are enough"
.to_string(),
algorithm: "BIP-39, English: 11 bits per word, the last word carrying a \
SHA-256 checksum"
.to_string(),
},
}
}
pub fn layout_for(format: Option<Self>) -> Layout {
format.unwrap_or(RevealFormat::Paper32).layout()
}
pub fn rows(self, bytes: usize) -> usize {
let layout = self.layout();
self.characters(bytes)
.div_ceil(layout.row.saturating_add(layout.parity).max(1))
}
pub fn read_row(self, text: &str, row: usize, more: bool) -> Result<TypedRow, String> {
let read = self.read_one_row(text, row)?;
if self.is_words() {
return Ok(read);
}
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<TypedRow, String> {
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,
})
}
RevealFormat::Bip39 => bip39::read_word(text, row)
.map(|text| TypedRow {
text,
repair: None,
full: true,
})
.map_err(|e| e.to_string()),
}
}
pub fn decode(self, text: &str) -> Result<TypedValue, String> {
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(),
})
}
RevealFormat::Bip39 => bip39::decode(text)
.map(|decoded| TypedValue {
bytes: decoded.bytes,
repaired: Vec::new(),
})
.map_err(|e| e.to_string()),
}
}
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),
RevealFormat::Bip39 => bip39::words_for(bytes).saturating_mul(bip39::WORD_MAX),
}
}
}
impl fmt::Display for RevealFormat {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug)]
pub struct TypedRow {
pub text: Zeroizing<String>,
pub repair: Option<String>,
pub full: bool,
}
#[derive(Debug)]
pub struct TypedValue {
pub bytes: Zeroizing<Vec<u8>>,
pub repaired: Vec<String>,
}
fn hex_digits(text: &str) -> Result<Zeroizing<String>, 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)
}
#[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, hex or bip39", self.0)
}
}
impl std::error::Error for UnknownRevealFormat {}
impl FromStr for RevealFormat {
type Err = UnknownRevealFormat;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"paper32" => Ok(RevealFormat::Paper32),
"hex" => Ok(RevealFormat::Hex),
"bip39" => Ok(RevealFormat::Bip39),
other => Err(UnknownRevealFormat(other.to_string())),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Layout {
pub format: RevealFormat,
pub group: usize,
pub row: usize,
pub parity: usize,
pub alphabet: String,
pub algorithm: String,
}
impl Default for Layout {
fn default() -> Self {
RevealFormat::Paper32.layout()
}
}
impl Layout {
pub fn rows<'a>(&self, text: &'a str) -> Vec<Row<'a>> {
if self.format.is_words() {
return text
.split_whitespace()
.map(|word| Row {
groups: vec![word],
parity: "",
})
.collect();
}
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()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Row<'a> {
pub groups: Vec<&'a str>,
pub parity: &'a str,
}
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() {
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<char> = 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());
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::<RevealFormat>().unwrap_err().to_string(),
"'words' is not a format; use paper32, hex or bip39"
);
assert_eq!("bip39".parse(), Ok(RevealFormat::Bip39));
}
#[test]
fn a_phrase_is_a_word_per_row() {
let phrase = bip39::encode(&[0u8; 32]).unwrap();
let rows = RevealFormat::Bip39.layout().rows(&phrase);
assert_eq!(rows.len(), 24);
assert_eq!(rows[23].groups, ["art"]);
assert_eq!(rows[0].parity, "");
assert_eq!(RevealFormat::Bip39.rows(32), 24);
assert_eq!(RevealFormat::Bip39.rows(16), 12);
assert_eq!(RevealFormat::Bip39.length_of(32), 24);
assert_eq!(RevealFormat::Bip39.bytes_for(24), 32);
let row = RevealFormat::Bip39.read_row("ART", 24, false).unwrap();
assert_eq!(row.text.as_str(), "art");
assert!(row.full);
assert!(RevealFormat::Bip39.read_row("zoo", 1, true).unwrap().full);
assert!(RevealFormat::Bip39.read_row("abandonx", 1, true).is_err());
let typed: Vec<&str> = phrase.split(' ').collect();
assert_eq!(
*RevealFormat::Bip39.decode(&typed.join("\n")).unwrap().bytes,
[0u8; 32]
);
}
}