use anyhow::{Context, Result};
use super::sas::WORDS;
const KEY_WORDS: usize = 32;
pub const PHRASE_WORDS: usize = KEY_WORDS + 1;
fn checksum(key: &[u8; 32]) -> u8 {
blake3::hash(key).as_bytes()[0]
}
pub fn encode(key: &[u8; 32]) -> String {
let mut words: Vec<&str> = key.iter().map(|b| WORDS[*b as usize]).collect();
words.push(WORDS[checksum(key) as usize]);
words.join(" ")
}
pub fn decode(phrase: &str) -> Result<[u8; 32]> {
let words: Vec<String> = phrase
.split_whitespace()
.map(|w| {
w.trim_matches(|c: char| !c.is_ascii_alphabetic())
.to_lowercase()
})
.filter(|w| !w.is_empty())
.collect();
anyhow::ensure!(
words.len() == PHRASE_WORDS,
"a recovery phrase is {PHRASE_WORDS} words; got {}",
words.len()
);
let mut key = [0u8; 32];
for (i, w) in words.iter().enumerate() {
let idx = WORDS
.iter()
.position(|c| *c == w.as_str())
.with_context(|| format!("word {} ('{w}') is not a recovery word", i + 1))?;
if i < KEY_WORDS {
key[i] = idx as u8;
} else {
anyhow::ensure!(
idx as u8 == checksum(&key),
"the recovery phrase's checksum word does not match — a word is mistyped or out of \
order. Importing anyway would restore a DIFFERENT identity, which looks exactly \
like every peer having forgotten you"
);
}
}
Ok(key)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_phrase_round_trips() {
for seed in [0u8, 1, 7, 255] {
let key = [seed; 32];
let phrase = encode(&key);
assert_eq!(
phrase.split_whitespace().count(),
PHRASE_WORDS,
"a phrase is one word per key byte plus a checksum"
);
assert_eq!(decode(&phrase).unwrap(), key);
}
let mut key = [0u8; 32];
for (i, b) in key.iter_mut().enumerate() {
*b = (i * 7 + 3) as u8;
}
assert_eq!(decode(&encode(&key)).unwrap(), key);
}
#[test]
fn transcription_is_forgiving_about_shape_and_not_about_content() {
let key = [42u8; 32];
let phrase = encode(&key);
let words: Vec<&str> = phrase.split_whitespace().collect();
let messy = format!(
" {}\n\t{} ",
words[..4].join(" ").to_uppercase(),
words[4..].join("\n")
);
assert_eq!(decode(&messy).unwrap(), key, "shape must not matter");
let numbered: String = words
.iter()
.enumerate()
.map(|(i, w)| format!("{}. {w},", i + 1))
.collect::<Vec<_>>()
.join("\n");
assert_eq!(
decode(&numbered).unwrap(),
key,
"a phrase copied out of a numbered, comma-separated list must decode"
);
assert_eq!(
decode(&words.join("; ")).unwrap(),
key,
"…and stray separators between words"
);
let mut bad = words.clone();
bad[6] = "zzzz-not-a-word";
let e = decode(&bad.join(" ")).unwrap_err();
assert!(
format!("{e:#}").contains("word 7"),
"the error must name the position so it is actionable against a written page: {e:#}"
);
assert!(decode(&words[..PHRASE_WORDS - 1].join(" ")).is_err());
assert!(decode(&format!("{phrase} abandon")).is_err());
assert!(decode("").is_err());
}
#[test]
fn a_mistyped_word_is_refused_rather_than_restoring_a_different_identity() {
let mut key = [0u8; 32];
for (i, b) in key.iter_mut().enumerate() {
*b = (i * 7 + 3) as u8;
}
let words: Vec<String> = encode(&key)
.split_whitespace()
.map(str::to_string)
.collect();
let mut swapped = words.clone();
swapped.swap(3, 11);
assert_ne!(
swapped, words,
"precondition: the swap actually changed the phrase"
);
let e = decode(&swapped.join(" ")).unwrap_err();
assert!(format!("{e:#}").contains("checksum"), "{e:#}");
let mut typo = words.clone();
typo[0] = WORDS[(key[0] as usize + 1) % WORDS.len()].to_string();
assert!(decode(&typo.join(" ")).is_err());
let mut tail = words.clone();
let last = tail.len() - 1;
let real = WORDS.iter().position(|w| *w == tail[last]).unwrap();
tail[last] = WORDS[(real + 1) % WORDS.len()].to_string();
assert!(decode(&tail.join(" ")).is_err());
}
#[test]
fn a_no_op_swap_still_decodes() {
let key = [5u8; 32];
let mut words: Vec<String> = encode(&key)
.split_whitespace()
.map(str::to_string)
.collect();
words.swap(2, 20);
assert_eq!(decode(&words.join(" ")).unwrap(), key);
}
}