use super::PrivateKey;
use crate::error::{Error, Result};
use sha2::{Digest, Sha256, Sha512};
use zeroize::Zeroizing;
static BRAINKEY_WORDS: &str = include_str!("../../data/brainkey_words.txt");
pub const BRAINKEY_WORD_COUNT: usize = 49_744;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Role {
Owner,
Active,
Posting,
Memo,
}
impl Role {
pub fn as_str(&self) -> &'static str {
match self {
Role::Owner => "owner",
Role::Active => "active",
Role::Posting => "posting",
Role::Memo => "memo",
}
}
}
impl std::str::FromStr for Role {
type Err = Error;
fn from_str(s: &str) -> Result<Self> {
match s.to_ascii_lowercase().as_str() {
"owner" => Ok(Role::Owner),
"active" => Ok(Role::Active),
"posting" => Ok(Role::Posting),
"memo" => Ok(Role::Memo),
other => Err(Error::Unknown {
kind: "role",
name: other.to_string(),
}),
}
}
}
fn normalize(seed: &str) -> String {
seed.split_whitespace().collect::<Vec<_>>().join(" ")
}
pub struct PasswordKey {
account: String,
role: Role,
password: Zeroizing<String>,
}
impl std::fmt::Debug for PasswordKey {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PasswordKey")
.field("account", &self.account)
.field("role", &self.role)
.field("password", &"<redacted>")
.finish()
}
}
impl PasswordKey {
pub fn new(
account: &str,
role: Role,
password: &str,
i_understand_this_is_unstretched: bool,
) -> Result<Self> {
if !i_understand_this_is_unstretched {
return Err(Error::key(
"PasswordKey derives a key with a single unsalted SHA-256 and no work \
factor; pass `true` to acknowledge, or use an explicit WIF instead",
));
}
if password.is_empty() {
return Err(Error::key("password is empty"));
}
Ok(PasswordKey {
account: account.to_string(),
role,
password: Zeroizing::new(password.to_string()),
})
}
pub fn private_key(&self) -> Result<PrivateKey> {
let seed = Zeroizing::new(normalize(&format!(
"{}{}{}",
self.account,
self.role.as_str(),
*self.password
)));
let scalar = Zeroizing::new(<[u8; 32]>::from(Sha256::digest(seed.as_bytes())));
PrivateKey::from_bytes(&*scalar)
}
}
pub struct BrainKey {
phrase: Zeroizing<String>,
sequence: u32,
}
impl std::fmt::Debug for BrainKey {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("BrainKey")
.field("phrase", &"<redacted>")
.field("sequence", &self.sequence)
.finish()
}
}
impl BrainKey {
pub fn new(phrase: &str, sequence: u32) -> Result<Self> {
let normalized = normalize(phrase);
if normalized.is_empty() {
return Err(Error::key("brain key phrase is empty"));
}
Ok(BrainKey {
phrase: Zeroizing::new(normalized),
sequence,
})
}
pub fn phrase(&self) -> &str {
&self.phrase
}
pub fn sequence(&self) -> u32 {
self.sequence
}
pub fn next_sequence(&mut self) {
self.sequence = self.sequence.saturating_add(1);
}
pub fn private_key(&self) -> Result<PrivateKey> {
let encoded = Zeroizing::new(format!("{} {}", *self.phrase, self.sequence));
let outer = Zeroizing::new(<[u8; 64]>::from(Sha512::digest(encoded.as_bytes())));
let scalar = Zeroizing::new(<[u8; 32]>::from(Sha256::digest(*outer)));
PrivateKey::from_bytes(&*scalar)
}
pub fn blind_private_key(&self) -> Result<PrivateKey> {
let scalar = Zeroizing::new(<[u8; 32]>::from(Sha256::digest(self.phrase.as_bytes())));
PrivateKey::from_bytes(&*scalar)
}
pub fn suggest(word_count: usize) -> Result<String> {
use rand::RngCore;
if word_count < 12 {
return Err(Error::key(format!(
"a brain key of {word_count} words carries too little entropy; use at least 12"
)));
}
let words: Vec<&str> = BRAINKEY_WORDS.lines().collect();
if words.len() != BRAINKEY_WORD_COUNT {
return Err(Error::key(format!(
"brain key dictionary has {} words, expected {BRAINKEY_WORD_COUNT}",
words.len()
)));
}
let n = words.len() as u32;
let limit = u32::MAX - (u32::MAX % n) - (n - 1);
let mut rng = rand::rngs::OsRng;
let mut out = Vec::with_capacity(word_count);
for _ in 0..word_count {
let idx = loop {
let v = rng.next_u32();
if v < limit {
break (v % n) as usize;
}
};
out.push(words[idx].to_ascii_uppercase());
}
Ok(out.join(" "))
}
pub fn entropy_bits(word_count: usize) -> f64 {
(BRAINKEY_WORD_COUNT as f64).log2() * word_count as f64
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dictionary_is_the_expected_size() {
assert_eq!(BRAINKEY_WORDS.lines().count(), BRAINKEY_WORD_COUNT);
}
#[test]
fn dictionary_content_is_pinned() {
use sha2::{Digest, Sha256};
let digest = Sha256::digest(BRAINKEY_WORDS.as_bytes());
assert_eq!(
digest
.iter()
.map(|b| format!("{b:02x}"))
.collect::<String>(),
"79712a3a4f237913598f981ada879afc11fecd7f8e01052349a23682e74b06be",
"the brain-key dictionary has been modified; it must match beem's exactly"
);
let words: Vec<&str> = BRAINKEY_WORDS.lines().collect();
assert_eq!(words[0], "a");
assert!(words.contains(&"comb"));
assert!(words.contains(&"zymurgy"));
}
#[test]
fn normalization_matches_graphene() {
assert_eq!(normalize(" a \t b \n\n c "), "a b c");
assert_eq!(normalize("single"), "single");
assert_eq!(normalize(" "), "");
}
#[test]
fn password_key_is_deterministic_and_role_dependent() {
let posting = PasswordKey::new("alice", Role::Posting, "hunter2", true)
.unwrap()
.private_key()
.unwrap();
let active = PasswordKey::new("alice", Role::Active, "hunter2", true)
.unwrap()
.private_key()
.unwrap();
let again = PasswordKey::new("alice", Role::Posting, "hunter2", true)
.unwrap()
.private_key()
.unwrap();
assert_eq!(posting, again);
assert_ne!(posting, active);
}
#[test]
fn password_key_requires_the_acknowledgement() {
assert!(PasswordKey::new("alice", Role::Posting, "hunter2", false).is_err());
assert!(PasswordKey::new("alice", Role::Posting, "", true).is_err());
}
#[test]
fn brain_key_is_deterministic_across_sequences() {
let mut bk = BrainKey::new("SOME BRAIN KEY WORDS HERE", 0).unwrap();
let k0 = bk.private_key().unwrap();
bk.next_sequence();
let k1 = bk.private_key().unwrap();
assert_ne!(k0, k1);
assert_eq!(
BrainKey::new("SOME BRAIN KEY WORDS HERE", 0)
.unwrap()
.private_key()
.unwrap(),
k0
);
}
#[test]
fn brain_key_normalizes_whitespace() {
let a = BrainKey::new("ONE TWO\tTHREE", 0).unwrap();
let b = BrainKey::new(" ONE TWO THREE ", 0).unwrap();
assert_eq!(a.phrase(), b.phrase());
assert_eq!(a.private_key().unwrap(), b.private_key().unwrap());
}
#[test]
fn suggest_produces_the_requested_word_count() {
let s = BrainKey::suggest(16).unwrap();
assert_eq!(s.split(' ').count(), 16);
assert_eq!(s, s.to_uppercase());
assert_ne!(s, BrainKey::suggest(16).unwrap());
}
#[test]
fn suggest_refuses_low_entropy_word_counts() {
assert!(BrainKey::suggest(4).is_err());
assert!(BrainKey::suggest(11).is_err());
assert!(BrainKey::suggest(12).is_ok());
}
#[test]
fn suggest_never_goes_out_of_range() {
for _ in 0..20 {
let s = BrainKey::suggest(64).unwrap();
assert_eq!(s.split(' ').count(), 64);
}
}
#[test]
fn entropy_matches_the_dictionary() {
let bits = BrainKey::entropy_bits(16);
assert!(
(bits - 249.6).abs() < 0.5,
"16 words is about 249.6 bits, got {bits}"
);
}
}