#![doc = include_str!("../README.md")]
use std::error::Error;
use std::fmt;
use std::fmt::{Alignment, Display, Formatter, Write};
use base58::{FromBase58, FromBase58Error, ToBase58};
use sha2::Digest;
pub const HRI_MAX_LEN: usize = 8;
pub const CHUNK_POSITIONS_32: [u8; 5] = [6, 8, 8, 8, 8];
pub const CHUNK_POSITIONS_32CHECKSUM: [u8; 5] = [7, 9, 9, 9, 9];
pub const CHUNKING_32: Option<Chunking> = Some(Chunking::new(&CHUNK_POSITIONS_32, '-'));
pub const CHUNKING_32CHECKSUM: Option<Chunking> =
Some(Chunking::new(&CHUNK_POSITIONS_32CHECKSUM, '-'));
#[derive(Copy, Clone, Ord, PartialOrd, Eq, PartialEq, Hash, Debug)]
pub enum MnemonicCase {
Pascal,
Kebab,
Snake,
}
#[derive(Copy, Clone, Ord, PartialOrd, Eq, PartialEq, Hash, Debug)]
pub struct Chunking {
positions: &'static [u8],
separator: char,
}
impl Chunking {
pub const fn new(positions: &'static [u8], separator: char) -> Self {
Chunking {
positions,
separator,
}
}
}
#[derive(Clone, Ord, PartialOrd, Eq, PartialEq, Hash, Debug)]
pub struct Baid58<const LEN: usize> {
hri: &'static str,
chunking: Option<Chunking>,
payload: [u8; LEN],
}
impl<const LEN: usize> Baid58<LEN> {
fn new(hri: &'static str, payload: [u8; LEN], chunking: Option<Chunking>) -> Self {
debug_assert!(hri.len() <= HRI_MAX_LEN, "HRI is too long");
debug_assert!(LEN > HRI_MAX_LEN, "Baid58 id must be at least 9 bytes");
#[cfg(debug_assertions)]
if let Some(chunking) = chunking {
debug_assert!(!chunking.positions.is_empty());
let sum = chunking.positions.iter().sum::<u8>() as usize;
debug_assert!(sum <= LEN * 138 / 100 + 1, "invalid Baid58 separator positions");
}
Self {
hri,
chunking,
payload,
}
}
pub fn with(hri: &'static str, payload: [u8; LEN]) -> Self { Self::new(hri, payload, None) }
pub fn with_chunks(
hri: &'static str,
payload: [u8; LEN],
chunk_pos: &'static [u8],
chunk_sep: char,
) -> Self {
let chunking = Chunking {
positions: chunk_pos,
separator: chunk_sep,
};
Self::new(hri, payload, Some(chunking))
}
pub const fn human_identifier(&self) -> &'static str { self.hri }
pub fn checksum(&self) -> u32 {
let key = blake3::Hasher::new().update(self.hri.as_bytes()).finalize();
let mut hasher = blake3::Hasher::new_keyed(key.as_bytes());
hasher.update(&self.payload);
let blake = *hasher.finalize().as_bytes();
let key = sha2::Sha256::digest(self.hri.as_bytes());
let mut sha = sha2::Sha256::new_with_prefix(key);
sha.update(&self.payload);
let sha = sha.finalize();
u32::from_le_bytes([blake[0], blake[1], sha[0], sha[1]])
}
pub fn mnemonic(&self) -> String { self.mnemonic_with_case(MnemonicCase::Kebab) }
pub fn mnemonic_with_case(&self, case: MnemonicCase) -> String {
let mn = mnemonic::to_string(self.checksum().to_le_bytes());
match case {
MnemonicCase::Pascal => {
let mut res = String::with_capacity(mn.len());
for s in mn.split('-') {
res.push_str((s[0..1].to_uppercase() + &s[1..]).as_str());
}
res
}
MnemonicCase::Kebab => mn,
MnemonicCase::Snake => mn.replace('-', "_"),
}
}
}
impl<const LEN: usize> Display for Baid58<LEN> {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
#[derive(Copy, Clone, Ord, PartialOrd, Eq, PartialEq, Hash, Debug)]
enum Mnemo {
None,
Prefix(MnemonicCase),
Suffix,
Mixin,
}
#[derive(Clone, Ord, PartialOrd, Eq, PartialEq, Hash, Debug)]
enum Hrp {
None,
Prefix(String),
Suffix(String),
Ext,
}
let mut mnemo = if f.alternate() {
Mnemo::Suffix
} else if f.sign_aware_zero_pad() {
Mnemo::Prefix(MnemonicCase::Pascal)
} else if f.sign_minus() {
Mnemo::Prefix(MnemonicCase::Kebab)
} else if f.sign_plus() {
Mnemo::Prefix(MnemonicCase::Snake)
} else {
Mnemo::None
};
let fill = (0..=f.width().unwrap_or_default()).map(|_| f.fill()).collect();
let hrp = match f.align() {
None if f.precision() == Some(0) => Hrp::Ext,
None if f.precision() == Some(1) || f.precision() == Some(3) => {
mnemo = Mnemo::Mixin;
Hrp::None
}
None => Hrp::None,
Some(Alignment::Center) if mnemo == Mnemo::None => {
mnemo = Mnemo::Mixin;
Hrp::Prefix(fill)
}
Some(Alignment::Left) | Some(Alignment::Center) => Hrp::Prefix(fill),
Some(Alignment::Right) => {
mnemo = Mnemo::Mixin;
Hrp::Suffix(fill)
}
};
if let Hrp::Prefix(ref prefix) = hrp {
f.write_str(self.hri)?;
f.write_str(prefix)?;
}
if let Mnemo::Prefix(prefix) = mnemo {
f.write_str(&self.clone().mnemonic_with_case(prefix))?;
match prefix {
MnemonicCase::Pascal => f.write_str("0")?,
MnemonicCase::Kebab => f.write_str("-")?,
MnemonicCase::Snake => f.write_str("_")?,
}
}
let s = if mnemo == Mnemo::Mixin {
let mut p = self.payload.to_vec();
p.extend(self.checksum().to_le_bytes());
p.to_base58()
} else {
self.payload.to_base58()
};
match (self.chunking, f.precision()) {
(Some(chunking), Some(2 | 3)) => {
let mut iter = s.chars();
for len in chunking.positions {
for ch in iter.by_ref().take(*len as usize) {
f.write_char(ch)?;
}
if !iter.as_str().is_empty() {
f.write_char(chunking.separator)?;
}
}
for ch in iter {
f.write_char(ch)?;
}
}
_ => {
f.write_str(&s)?;
}
}
if let Mnemo::Suffix = mnemo {
write!(f, "#{}", &self.clone().mnemonic_with_case(MnemonicCase::Kebab))?;
}
if let Hrp::Suffix(ref suffix) = hrp {
f.write_str(suffix)?;
f.write_str(self.hri)?;
} else if let Hrp::Ext = hrp {
write!(f, ".{}", self.hri)?;
}
Ok(())
}
}
#[derive(Clone, Eq, PartialEq, Hash, Debug, Ord, PartialOrd)]
pub enum Baid58ParseError {
InvalidHri {
expected: &'static str,
found: String,
},
InvalidLen {
expected: usize,
found: usize,
},
InvalidMnemonic(String),
InvalidChecksumLen(usize),
ChecksumMismatch {
expected: u32,
present: u32,
},
ValueTooShort(usize),
NonValueTooLong(usize),
ValueAbsent(String),
InvalidBase58Character(char, usize),
InvalidBase58Length,
InvalidChunking,
Unparsable(String),
}
impl From<FromBase58Error> for Baid58ParseError {
fn from(value: FromBase58Error) -> Self {
match value {
FromBase58Error::InvalidBase58Character(c, pos) => {
Baid58ParseError::InvalidBase58Character(c, pos)
}
FromBase58Error::InvalidBase58Length => Baid58ParseError::InvalidBase58Length,
}
}
}
impl Display for Baid58ParseError {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
match self {
Baid58ParseError::InvalidHri { expected, found } => write!(
f,
"type requires '{expected}' as a Baid58 human-readable identifier, while \
'{found}' was provided"
),
Baid58ParseError::InvalidLen { expected, found } => write!(
f,
"type requires {expected} data bytes for aa Baid58 representation, while \
'{found}' was provided"
),
Baid58ParseError::ValueTooShort(len) => write!(
f,
"Baid58 value must be longer than 8 characters, while only {len} chars were used \
for the value"
),
Baid58ParseError::NonValueTooLong(len) => write!(
f,
"at least one of non-value components in Baid58 string has length {len} which is \
more than allowed 8 characters"
),
Baid58ParseError::ValueAbsent(s) => {
write!(f, "Baid58 string {s} has no identifiable value component")
}
Baid58ParseError::InvalidBase58Character(c, pos) => {
write!(f, "invalid Base58 character '{c}' at {pos} position in Baid58 value")
}
Baid58ParseError::InvalidBase58Length => {
f.write_str("invalid length of the Base58 encoded value")
}
Baid58ParseError::Unparsable(s) => write!(f, "non-parsable Baid58 string '{s}'"),
Baid58ParseError::InvalidMnemonic(m) => {
write!(f, "invalid Baid58 mnemonic string '{m}'")
}
Baid58ParseError::ChecksumMismatch { expected, present } => {
write!(f, "invalid Baid58 checksum: expected {expected:#x}, found {present:#x}")
}
Baid58ParseError::InvalidChunking => {
write!(f, "invalid Baid58 chunking")
}
Baid58ParseError::InvalidChecksumLen(len) => {
write!(f, "invalid Baid58 checksum length: expected 4 bytes while found {len}")
}
}
}
}
impl Error for Baid58ParseError {}
#[derive(Copy, Clone, Eq, PartialEq, Hash, Debug)]
pub struct Baid58HriError {
pub expected: &'static str,
pub found: &'static str,
}
impl Display for Baid58HriError {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
let Baid58HriError { expected, found } = self;
write!(
f,
"type requires '{expected}' as a Baid58 human-readable identifier, while '{found}' \
was provided"
)
}
}
impl Error for Baid58HriError {}
pub trait FromBaid58<const LEN: usize>: ToBaid58<LEN> + From<[u8; LEN]> {
fn from_baid58_str(s: &str) -> Result<Self, Baid58ParseError> {
let mut prev: Option<char> = None;
let mut count = 0;
let filtered = s
.chars()
.filter_map(|c| {
let is_separator = !c.is_ascii_alphanumeric() || c == '0';
if is_separator {
count += 1;
}
if Some(c) == prev && is_separator {
None
} else {
prev = Some(c);
prev
}
})
.collect::<String>();
let mut payload: Option<[u8; LEN]> = None;
let mut prefix = vec![];
let mut suffix = vec![];
let mut cursor = &mut prefix;
let mut checksum: Option<u32> = None;
for component in filtered.split(|c: char| !c.is_ascii_alphanumeric() || c == '0') {
if component.len() > LEN {
if payload.is_some() {
return Err(Baid58ParseError::NonValueTooLong(component.len()));
}
let value = component.from_base58()?;
let len = value.len();
match len {
x if x == LEN => {}
x if x == LEN + 4 => {
let mut c = [0u8; 4];
c.copy_from_slice(&value[LEN..]);
checksum = Some(u32::from_le_bytes(c));
}
_ => {
return Err(Baid58ParseError::InvalidLen {
expected: LEN,
found: len,
})
}
}
payload = Some([0u8; LEN]);
if let Some(p) = payload.as_mut() {
p.copy_from_slice(&value[..LEN])
}
cursor = &mut suffix;
} else if count == 0 {
return Err(Baid58ParseError::ValueTooShort(component.len()));
} else {
cursor.push(component)
}
}
let mut hri: Option<&str> = None;
let mut mnemonic = vec![];
match (prefix.len(), suffix.len()) {
(0, 0) => {}
(3 | 4, 0) => {
hri = prefix.first().copied();
mnemonic.extend(&prefix[1..])
}
(0, 3 | 4) => {
mnemonic.extend(&suffix[..3]);
hri = suffix.get(4).copied();
}
(2, 0) => {
hri = Some(prefix[0]);
mnemonic.push(prefix[1]);
}
(1, 0) if prefix[0].len() > HRI_MAX_LEN => {
mnemonic.extend(prefix);
}
(1, 0 | 3..) => {
hri = prefix.pop();
mnemonic.extend(suffix);
}
(0 | 3.., 1) => {
mnemonic.extend(prefix);
hri = suffix.pop();
}
_ => return Err(Baid58ParseError::Unparsable(s.to_owned())),
}
if matches!(hri, Some(hri) if hri != Self::HRI) {
return Err(Baid58ParseError::InvalidHri {
expected: Self::HRI,
found: hri.unwrap().to_owned(),
});
}
let baid58 = Baid58 {
hri: Self::HRI,
chunking: Self::CHUNKING,
payload: payload.ok_or(Baid58ParseError::ValueAbsent(s.to_owned()))?,
};
let mnemonic = match mnemonic.len() {
0 => String::new(),
3 => mnemonic.join("-"),
1 if mnemonic[0].contains('-') => mnemonic[0].to_string(),
1 if mnemonic[0].contains('_') => mnemonic[0].replace('-', "_"),
1 => mnemonic[0]
.chars()
.flat_map(|c| {
if c.is_ascii_uppercase() {
vec!['-', c.to_ascii_lowercase()].into_iter()
} else {
vec![c].into_iter()
}
})
.collect(),
_ => return Err(Baid58ParseError::InvalidMnemonic(mnemonic.join("-"))),
};
if !mnemonic.is_empty() {
let mut checksum = Vec::<u8>::with_capacity(4);
mnemonic::decode(&mnemonic, &mut checksum)
.map_err(|_| Baid58ParseError::InvalidMnemonic(mnemonic))?;
if checksum.len() != 4 {
return Err(Baid58ParseError::InvalidChecksumLen(checksum.len()));
}
let checksum = u32::from_le_bytes([checksum[0], checksum[1], checksum[2], checksum[3]]);
if baid58.checksum() != checksum {
return Err(Baid58ParseError::ChecksumMismatch {
expected: baid58.checksum(),
present: checksum,
});
}
}
if let Some(checksum) = checksum {
if baid58.checksum() != checksum {
return Err(Baid58ParseError::ChecksumMismatch {
expected: baid58.checksum(),
present: checksum,
});
}
}
Ok(Self::from_baid58(baid58).expect("HRI is checked"))
}
fn check_baid58_chunking(mut s: &str, prefix_sep: char, suffix_sep: char) -> bool {
if let Some(mut pos) = s.chars().position(|c| c == prefix_sep) {
pos = pos.saturating_add(1);
if pos >= s.len() {
return false;
}
s = &s[pos..]
};
let Some(chunking) = Self::CHUNKING else {
return false;
};
let count =
s.chars().take_while(|c| *c != suffix_sep).filter(|c| *c == chunking.separator).count();
if count != chunking.positions.len() {
return false;
}
let mut offset = s
.chars()
.take_while(|c| *c != suffix_sep)
.position(|c| c == prefix_sep)
.map(|p| p + 1)
.unwrap_or_default();
for pos in chunking.positions {
offset += *pos as usize;
if s.as_bytes()[offset] != chunking.separator as u8 {
return false;
}
offset = offset.saturating_add(1);
}
true
}
fn from_baid58_chunked_str(
s: &str,
prefix_sep: char,
suffix_sep: char,
) -> Result<Self, Baid58ParseError> {
let chunking = Self::CHUNKING
.expect("FromBaid58::from_baid58_chunked_str must be used only on chunked types");
if !Self::check_baid58_chunking(s, prefix_sep, suffix_sep) {
return Err(Baid58ParseError::InvalidChunking);
}
let prefix = format!("{}{prefix_sep}", Self::HRI);
let s = s.trim_start_matches(&prefix);
let s = prefix.chars().chain(
s.chars()
.take_while(|c| *c != suffix_sep)
.filter(|c| *c != chunking.separator)
.chain(s.chars().skip_while(|c| *c != suffix_sep)),
);
Self::from_baid58_str(&s.collect::<String>())
}
fn from_baid58_maybe_chunked_str(
s: &str,
prefix_sep: char,
suffix_sep: char,
) -> Result<Self, Baid58ParseError> {
if Self::check_baid58_chunking(s, prefix_sep, suffix_sep) {
Self::from_baid58_chunked_str(s, prefix_sep, suffix_sep)
} else {
Self::from_baid58_str(s)
}
}
fn from_baid58(baid: Baid58<LEN>) -> Result<Self, Baid58HriError> {
if baid.hri != Self::HRI {
Err(Baid58HriError {
expected: Self::HRI,
found: baid.hri,
})
} else {
Ok(Self::from(baid.payload))
}
}
}
pub trait ToBaid58<const LEN: usize> {
const HRI: &'static str;
const CHUNKING: Option<Chunking> = None;
fn to_baid58_payload(&self) -> [u8; LEN];
fn to_baid58(&self) -> Baid58<LEN> {
Baid58::new(Self::HRI, self.to_baid58_payload(), Self::CHUNKING)
}
fn to_baid58_string(&self) -> String { self.to_baid58().to_string() }
}
#[cfg(test)]
mod test {
use std::str::FromStr;
use super::*;
#[derive(Copy, Clone, Ord, PartialOrd, Eq, PartialEq, Hash, Debug)]
struct Id([u8; 32]);
impl Id {
pub fn new(s: &str) -> Id {
let hash = blake3::Hasher::new().update(s.as_bytes()).finalize();
Id(*hash.as_bytes())
}
}
impl From<[u8; 32]> for Id {
fn from(value: [u8; 32]) -> Self { Id(value) }
}
impl ToBaid58<32> for Id {
const HRI: &'static str = "id";
const CHUNKING: Option<Chunking> = CHUNKING_32;
fn to_baid58_payload(&self) -> [u8; 32] { self.0 }
}
impl FromBaid58<32> for Id {}
impl Display for Id {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
let mut baid = self.to_baid58();
match f.precision() {
Some(2) => {}
Some(3) => {
baid.chunking.as_mut().map(|c| c.positions = &CHUNK_POSITIONS_32CHECKSUM);
}
_ => baid.chunking = None,
}
Display::fmt(&baid, f)
}
}
impl FromStr for Id {
type Err = Baid58ParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Id::from_baid58_maybe_chunked_str(&s, ':', '#')
}
}
#[test]
#[should_panic]
fn invalid_chunking() {
#[derive(Copy, Clone, Ord, PartialOrd, Eq, PartialEq, Hash, Debug, Default)]
struct Id([u8; 32]);
impl From<[u8; 32]> for Id {
fn from(value: [u8; 32]) -> Self { Id(value) }
}
impl ToBaid58<32> for Id {
const HRI: &'static str = "id";
const CHUNKING: Option<Chunking> = Some(Chunking::new(&[7, 9, 9, 9, 9, 9], '-'));
fn to_baid58_payload(&self) -> [u8; 32] { self.0 }
}
format!("{:.2}", Id::default().to_baid58());
}
#[test]
fn display() {
let id = Id::new("some information");
assert_eq!(&format!("{id}"), "FWyisKGdBG31ddiNaUjnHi6tW8eYvnVW3T4zWtLhRDHs");
assert_eq!(&format!("{id:.0}"), "FWyisKGdBG31ddiNaUjnHi6tW8eYvnVW3T4zWtLhRDHs.id");
assert_eq!(&format!("{id:.1}"), "2dzcCoX9c65gi1GoJ1LFzb5FcQ9pAc8o3Pj8TpcH2mkAdMLCpP");
assert_eq!(&format!("{id:.2}"), "FWyisK-GdBG31dd-iNaUjnHi-6tW8eYvn-VW3T4zWt-LhRDHs");
assert_eq!(&format!("{id:.3}"), "2dzcCoX-9c65gi1Go-J1LFzb5Fc-Q9pAc8o3P-j8TpcH2mk-AdMLCpP");
assert_eq!(&format!("{id::<}"), "id:FWyisKGdBG31ddiNaUjnHi6tW8eYvnVW3T4zWtLhRDHs");
assert_eq!(&format!("{id::^}"), "id:2dzcCoX9c65gi1GoJ1LFzb5FcQ9pAc8o3Pj8TpcH2mkAdMLCpP");
assert_eq!(
&format!("{id:#}"),
"FWyisKGdBG31ddiNaUjnHi6tW8eYvnVW3T4zWtLhRDHs#escape-cadet-swim"
);
assert_eq!(
&format!("{id::^#}"),
"id:FWyisKGdBG31ddiNaUjnHi6tW8eYvnVW3T4zWtLhRDHs#escape-cadet-swim"
);
assert_eq!(
&format!("{id:-.0}"),
"escape-cadet-swim-FWyisKGdBG31ddiNaUjnHi6tW8eYvnVW3T4zWtLhRDHs.id"
);
assert_eq!(
&format!("{id:<0}"),
"id EscapeCadetSwim0FWyisKGdBG31ddiNaUjnHi6tW8eYvnVW3T4zWtLhRDHs"
);
assert_eq!(
&format!("{id:_<+}"),
"id_escape_cadet_swim_FWyisKGdBG31ddiNaUjnHi6tW8eYvnVW3T4zWtLhRDHs"
);
}
#[test]
fn from_str() {
let id = Id::new("some information");
assert_eq!(Id::from_str("FWyisKGdBG31ddiNaUjnHi6tW8eYvnVW3T4zWtLhRDHs").unwrap(), id);
assert_eq!(
Id::from_str("id:FWyisK-GdBG31dd-iNaUjnHi-6tW8eYvn-VW3T4zWt-LhRDHs").unwrap(),
id
);
assert_eq!(Id::from_str("2dzcCoX9c65gi1GoJ1LFzb5FcQ9pAc8o3Pj8TpcH2mkAdMLCpP").unwrap(), id);
assert_eq!(
Id::from_str("id:2dzcCoX9c65gi1GoJ1LFzb5FcQ9pAc8o3Pj8TpcH2mkAdMLCpP").unwrap(),
id
);
assert_eq!(
Id::from_str("FWyisKGdBG31ddiNaUjnHi6tW8eYvnVW3T4zWtLhRDHs#escape-cadet-swim").unwrap(),
id
);
assert_eq!(Id::from_str("FWyisKGdBG31ddiNaUjnHi6tW8eYvnVW3T4zWtLhRDHs.id").unwrap(), id);
assert_eq!(
Id::from_str("id:FWyisKGdBG31ddiNaUjnHi6tW8eYvnVW3T4zWtLhRDHs#escape-cadet-swim")
.unwrap(),
id
);
assert_eq!(
Id::from_str("id:FWyisK-GdBG31dd-iNaUjnHi-6tW8eYvn-VW3T4zWt-LhRDHs#escape-cadet-swim")
.unwrap(),
id
);
assert_eq!(
Id::from_str("escape-cadet-swim-FWyisKGdBG31ddiNaUjnHi6tW8eYvnVW3T4zWtLhRDHs.id")
.unwrap(),
id
);
assert_eq!(
Id::from_str("EscapeCadetSwim0FWyisKGdBG31ddiNaUjnHi6tW8eYvnVW3T4zWtLhRDHs").unwrap(),
id
);
assert_eq!(
Id::from_str("id EscapeCadetSwim0FWyisKGdBG31ddiNaUjnHi6tW8eYvnVW3T4zWtLhRDHs")
.unwrap(),
id
);
assert_eq!(
Id::from_str("id_escape_cadet_swim_FWyisKGdBG31ddiNaUjnHi6tW8eYvnVW3T4zWtLhRDHs")
.unwrap(),
id
);
}
#[test]
#[ignore]
fn attack() {
use std::sync::{Arc, Mutex};
let id = Id::new("some information");
let mut handles = vec![];
let failures = Arc::new(Mutex::new(vec![]));
for x in 0..24 {
let f = failures.clone();
handles.push(std::thread::spawn(move || {
let id = id.to_baid58();
for salt in 0..0x4000000 {
let av = Id::new(&format!("attack using salt {x} {salt}")).to_baid58();
if id.checksum() == av.checksum() {
f.lock()
.unwrap()
.push(format!("successful bruteforce attack on round {salt:#x}"));
}
}
}));
}
for handle in handles {
handle.join().ok();
}
assert!(failures.lock().unwrap().is_empty(), "Attacks succeeded:\n{failures:#?}");
}
}