1use argon2::Argon2;
6#[cfg(feature = "generate-key")]
7use chacha20poly1305::Key;
8use rand::{Rng, SeedableRng};
9use rand_chacha::ChaCha12Rng;
10use rand_core::CryptoRngCore;
11use zeroize::Zeroizing;
12
13include!(concat!(env!("OUT_DIR"), "/wordlists_v0.rs"));
14
15const PASSPHRASE_SEPARATOR: &str = "-";
16const PASSPHRASE_WORD_COUNT: usize = 7;
17
18pub mod error;
19
20fn words_to_pac(words: &str, salt: &[u8]) -> String {
21 let mut digest = [0u8; 8];
22 Argon2::default()
23 .hash_password_into(words.as_ref(), salt, &mut digest)
24 .expect("");
25
26 format!("{:02}", u64::from_le_bytes(digest) % 100)
27}
28
29pub fn generate_passphrase<W>(
30 wordlist: W,
31 pac_salt: &[u8],
32 csprng: &mut impl CryptoRngCore,
33) -> Zeroizing<String>
34where
35 W: AsRef<[&'static str]>,
36{
37 let words = Zeroizing::new(
38 (0..PASSPHRASE_WORD_COUNT)
39 .map(|_| {
40 let roll = csprng.gen_range(0..wordlist.as_ref().len());
41 wordlist.as_ref()[roll]
42 })
43 .collect::<Vec<_>>()
44 .join(PASSPHRASE_SEPARATOR),
45 );
46
47 Zeroizing::new(format!("{}-{}", *words, words_to_pac(&words, pac_salt)))
48}
49
50pub fn validate_passphrase<W>(
51 passphrase: &str,
52 pac_salt: &[u8],
53 wordlist: W,
54) -> Result<(), error::PassphraseError>
55where
56 W: AsRef<[&'static str]>,
57{
58 let (words, pac) = passphrase
59 .rsplit_once("-")
60 .map(|(words, pac)| {
61 pac.parse::<u8>()
62 .map(|_| (words, pac))
63 .map_err(|_| error::PassphraseError::MissingPassphraseAuthenticationCode)
64 })
65 .unwrap_or(Err(error::PassphraseError::InvalidSegmentCount(-7)))?;
66 let (word_count, words_not_found) = words.split(PASSPHRASE_SEPARATOR).fold(
67 (0, Vec::with_capacity(PASSPHRASE_WORD_COUNT)),
68 |mut acc, word| {
69 acc.0 += 1;
70 if !wordlist.as_ref().contains(&word) {
71 acc.1.push(word);
72 }
73 acc
74 },
75 );
76 if word_count != PASSPHRASE_WORD_COUNT {
77 let word_count = isize::try_from(word_count)
78 .expect("Maximum passphrase word count should be less than 100");
79 #[allow(clippy::cast_possible_wrap)]
80 return Err(error::PassphraseError::InvalidSegmentCount(
81 word_count - PASSPHRASE_WORD_COUNT as isize,
82 ));
83 } else if !words_not_found.is_empty() {
84 return Err(error::PassphraseError::WordsNotFound(
85 words_not_found.iter().map(ToString::to_string).collect(),
86 ));
87 } else if pac != words_to_pac(words, pac_salt) {
88 return Err(error::PassphraseError::InvalidPassphraseAuthenticationCode);
89 }
90
91 Ok(())
92}
93
94fn hash(passphrase: &str, salt: &[u8]) -> Result<[u8; 32], error::PassphraseError> {
95 let mut digest = [0u8; 32];
96 Argon2::default().hash_password_into(passphrase.as_ref(), salt, &mut digest)?;
97 Ok(digest)
98}
99
100fn validate_and_hash<W>(
101 passphrase: &str,
102 pac_salt: &[u8],
103 salt: &[u8],
104 wordlist: W,
105) -> Result<[u8; 32], error::PassphraseError>
106where
107 W: AsRef<[&'static str]>,
108{
109 validate_passphrase(passphrase, pac_salt, wordlist).and(hash(passphrase, salt))
110}
111
112pub fn passphrase_seeded_rng<W>(
113 passphrase: &str,
114 pac_salt: &[u8],
115 salt: &[u8],
116 wordlist: W,
117) -> Result<ChaCha12Rng, error::PassphraseError>
118where
119 W: AsRef<[&'static str]>,
120{
121 Ok(ChaCha12Rng::from_seed(validate_and_hash(
122 passphrase, pac_salt, salt, wordlist,
123 )?))
124}
125
126#[cfg(feature = "generate-key")]
127pub fn passphrase_to_key<W>(
128 passphrase: &str,
129 pac_salt: &[u8],
130 salt: &[u8],
131 wordlist: W,
132) -> Result<Key, error::PassphraseError>
133where
134 W: AsRef<[&'static str]>,
135{
136 Ok(Key::from(validate_and_hash(
137 passphrase, pac_salt, salt, wordlist,
138 )?))
139}
140
141#[cfg(test)]
142mod test {
143 use super::*;
144 use reach_harness::seeded_rng;
145
146 #[test]
147 fn invalid_patterns() {
148 assert_eq!(
149 validate_passphrase("this is an invalid passphrase", b"pac-salt", WORDLIST_EN_V0),
150 Err(error::PassphraseError::InvalidSegmentCount(-7)),
151 );
152 assert_eq!(
153 validate_passphrase(
154 "robe-renovate-rubble-whenever-mushily-presuming-referable",
155 b"pac-salt",
156 WORDLIST_EN_V0
157 ),
158 Err(error::PassphraseError::MissingPassphraseAuthenticationCode),
159 );
160 assert_eq!(
161 validate_passphrase(
162 "robe-renovate-rubble-whenever-mushily-presuming--referable-01",
163 b"sac-salt",
164 WORDLIST_EN_V0
165 ),
166 Err(error::PassphraseError::InvalidSegmentCount(1)),
167 );
168 }
169
170 #[test]
171 fn invalid_self_authentication_code() {
172 assert_eq!(
173 validate_passphrase(
174 "unpaved-lure-synthesis-crewmate-truth-snazzy-unsafe-94",
175 b"sac-salt",
176 WORDLIST_EN_V0
177 ),
178 Err(error::PassphraseError::InvalidPassphraseAuthenticationCode),
179 );
180 }
181
182 #[test]
183 fn words_not_found() {
184 assert!(matches!(
185 validate_passphrase(
186 "robe-renovate-rubble-whenever-mushily-presuming-crosslegged-91",
187 b"sac-salt",
188 WORDLIST_EN_V0
189 ),
190 Err(error::PassphraseError::WordsNotFound(_))
191 ));
192 }
193
194 #[test]
195 fn generating_valid_passphrases() {
196 let mut seeded_rng = seeded_rng(0);
197 for _ in 0..5 {
198 let valid = generate_passphrase(WORDLIST_EN_V0, b"pac-salt", &mut seeded_rng);
199 assert!(validate_passphrase(&valid, b"pac-salt", WORDLIST_EN_V0).is_ok());
200 }
201 }
202
203 #[test]
204 fn included_wordlists_have_high_enough_entropy() {
205 let entropy_threshold = 90f64;
206 for (lang, wordlist) in WORDLISTS {
207 let estimated_entropy = PASSPHRASE_WORD_COUNT as f64 * (wordlist.len() as f64).log2();
208 assert!(
209 estimated_entropy > entropy_threshold,
210 "The estimated entropy for the {} wordlist is lower than {} bits: {}",
211 lang,
212 entropy_threshold,
213 estimated_entropy,
214 );
215 }
216 }
217
218 #[test]
219 fn no_words_in_included_wordlist_use_separator() {
220 for (lang, wordlist) in WORDLISTS {
221 for word in wordlist {
222 assert!(
223 !word.contains(PASSPHRASE_SEPARATOR),
224 "Word '{}' in the {} wordlist contains a hyphen",
225 word,
226 lang,
227 );
228 }
229 }
230 }
231
232 #[test]
233 fn no_words_in_included_wordlist_have_non_ascii_characters() {
234 for (lang, wordlist) in WORDLISTS {
235 for word in wordlist {
236 assert!(
237 word.is_ascii(),
238 "Word '{}' in the {} wordlist has a non-ASCII character",
239 word,
240 lang,
241 );
242 }
243 }
244 }
245
246 #[test]
247 fn no_duplicate_words_in_included_wordlists() {
248 use std::collections::HashSet;
251 use std::hash::Hash;
252 fn all_unique_elements<T>(iter: T) -> bool
253 where
254 T: IntoIterator,
255 T::Item: Eq + Hash,
256 {
257 let mut uniq = HashSet::new();
258 iter.into_iter().all(move |x| uniq.insert(x))
259 }
260
261 for (lang, wordlist) in WORDLISTS {
262 let mut lowercase_word_list = vec![];
263 for word in wordlist {
268 lowercase_word_list.push(word.to_ascii_lowercase().trim().to_owned());
269 }
270 assert!(
271 all_unique_elements(lowercase_word_list),
272 "{} wordlist has duplicate words",
273 lang,
274 );
275 }
276 }
277}