reach_passphrase/
lib.rs

1// SPDX-FileCopyrightText: 2023—2025 eaon <eaon@posteo.net>
2// SPDX-FileCopyrightText: 2024 Sam Schlinkert <sschlinkert@gmail.com>
3// SPDX-License-Identifier: EUPL-1.2
4
5use 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        // First, declare a helper function that checks if a iterator
249        // has all unique elements
250        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            // Make a Vector of all words in the wordlist
264            // in lowercase and with whitespace trimmed to catch
265            // what we might think of as "fuzzy" duplicate words, as well
266            // as exact duplicate words.
267            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}