Skip to main content

sofie_identity/
password.rs

1use std::fmt;
2use std::num::NonZeroU32;
3use std::sync::{Arc, LazyLock};
4
5use argon2::password_hash::phc::{self, Output, ParamsString, Salt};
6use argon2::{
7    ARGON2ID_IDENT, Algorithm, Argon2, Params, PasswordHasher, PasswordVerifier, Version,
8};
9use ring::pbkdf2::{self, PBKDF2_HMAC_SHA256};
10use sofie::core::encoding::base64::{self, STANDARD};
11use sofie::core::{Cast, ColumnReader, ColumnSpec, Error, Measured, Result, SqlValue};
12use sofie::serde::{Deserialize, Deserializer};
13use sofie::values::{Control, Input, Present};
14use tokio::sync::Semaphore;
15use unicode_normalization::UnicodeNormalization;
16
17/// OWASP's first choice for Argon2id: 19 MiB, two passes, one lane.
18const PARAMS: Params = Params::DEFAULT;
19const VERSION: Version = Version::V0x13;
20const SALT_LENGTH: usize = 16;
21const HASH_LENGTH: usize = 32;
22const PBKDF2_SCHEME: &str = "pbkdf2-sha256";
23const MINIMUM_CHARACTERS: usize = 12;
24const MAXIMUM_CHARACTERS: usize = 128;
25
26/// Each Argon2id hash holds 19 MiB while it runs, so at most one per core runs at once.
27static HASHING: LazyLock<Arc<Semaphore>> = LazyLock::new(|| {
28    Arc::new(Semaphore::new(
29        std::thread::available_parallelism().map_or(1, std::num::NonZero::get),
30    ))
31});
32
33async fn limited<T: Send + 'static>(
34    limit: Arc<Semaphore>,
35    work: impl FnOnce() -> Result<T> + Send + 'static,
36) -> Result<T> {
37    let permit = limit
38        .acquire_owned()
39        .await
40        .map_err(|error| Error::internal("waiting to hash a password", error))?;
41    // A dropped request cannot stop a blocking task, so the permit lives as long as the hash.
42    tokio::task::spawn_blocking(move || {
43        let _permit = permit;
44        work()
45    })
46    .await
47    .map_err(|error| Error::internal("hashing a password", error))?
48}
49
50fn argon2id() -> Argon2<'static> {
51    Argon2::new(Algorithm::Argon2id, VERSION, PARAMS)
52}
53
54/// Never printed or serialized, only hashed or checked; kept in NFC, as NIST SP 800-63B asks.
55pub struct Password {
56    text: String,
57    /// As typed when not NFC, which a hash stored before normalization was of, to be replaced.
58    typed: Option<String>,
59}
60
61#[derive(Clone, Copy, Debug, PartialEq, Eq)]
62pub enum PasswordCheck {
63    Wrong,
64    Right,
65    RightAsTyped,
66}
67
68impl PasswordCheck {
69    #[must_use]
70    pub fn is_right(self) -> bool {
71        self != Self::Wrong
72    }
73}
74
75impl Password {
76    pub fn parse(typed: String) -> Result<Self> {
77        let password = Self::entered(typed);
78        let characters = password.text.chars().count();
79        if characters < MINIMUM_CHARACTERS {
80            return Err(Error::invalid("password", "must be at least 12 characters"));
81        }
82        if characters > MAXIMUM_CHARACTERS {
83            return Err(Error::invalid("password", "must be at most 128 characters"));
84        }
85        Ok(password)
86    }
87
88    /// Without the length policy, for checking one already chosen: one chosen under an older
89    /// policy, or counted before NFC, must still match.
90    #[must_use]
91    pub fn entered(typed: String) -> Self {
92        let text: String = typed.nfc().collect();
93        let typed = (typed != text).then_some(typed);
94        Self { text, typed }
95    }
96
97    /// Without a hash it checks a fixed one, and a non-NFC password both ways always, so an
98    /// unknown account takes as long.
99    pub async fn check(&self, hash: Option<&PasswordHash>) -> Result<PasswordCheck> {
100        let hash = match hash {
101            Some(hash) => hash.clone(),
102            None => PasswordHash::unmatchable()?,
103        };
104        let text = self.text.clone();
105        let typed = self.typed.clone();
106        limited(Arc::clone(&HASHING), move || {
107            let right = hash.verify(&text)?;
108            let right_as_typed = match &typed {
109                Some(typed) => hash.verify(typed)?,
110                None => false,
111            };
112            Ok(match (right, right_as_typed) {
113                (true, _) => PasswordCheck::Right,
114                (false, true) => PasswordCheck::RightAsTyped,
115                (false, false) => PasswordCheck::Wrong,
116            })
117        })
118        .await
119    }
120}
121
122impl fmt::Debug for Password {
123    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
124        formatter.write_str("Password(…)")
125    }
126}
127
128impl Present for Password {
129    const INPUT: Input = Input::new(Control::Password);
130    const SEARCH: sofie::values::Search = sofie::values::Search::Skipped;
131
132    fn csv(&self) -> String {
133        "***".to_owned()
134    }
135}
136
137/// So an input may tighten the length a password needs with `#[min]` and `#[max]`.
138impl Measured for Password {
139    const TEXT: bool = true;
140
141    fn size(&self) -> Option<i64> {
142        self.text.size()
143    }
144
145    fn is_blank(&self) -> bool {
146        self.text.is_blank()
147    }
148}
149
150impl<'de> Deserialize<'de> for Password {
151    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
152        let text = String::deserialize(deserializer)?;
153        Self::parse(text).map_err(Error::into_deserialize_error)
154    }
155}
156
157/// Written as Argon2id in the PHC string format, `$argon2id$v=19$m=19456,t=2,p=1$<salt>$<hash>`.
158/// `pbkdf2-sha256$<iterations>$<salt>$<hash>`, written before, is still read and checked.
159#[derive(Clone)]
160pub struct PasswordHash {
161    scheme: Scheme,
162}
163
164#[derive(Clone)]
165enum Scheme {
166    /// Boxed, as it is ten times the size of the other.
167    Argon2id(Box<phc::PasswordHash>),
168    Pbkdf2 {
169        iterations: NonZeroU32,
170        salt: [u8; SALT_LENGTH],
171        hash: [u8; HASH_LENGTH],
172    },
173}
174
175impl PasswordHash {
176    pub async fn new(password: &Password) -> Result<Self> {
177        let mut salt = [0; SALT_LENGTH];
178        sofie::core::random::fill(&mut salt)
179            .map_err(|error| Error::internal("drawing a password's salt", error))?;
180        let text = password.text.clone();
181        limited(Arc::clone(&HASHING), move || {
182            let hash = argon2id()
183                .hash_password_with_salt(text.as_bytes(), &salt)
184                .map_err(|error| Error::internal("hashing a password with Argon2id", error))?;
185            Ok(Self {
186                scheme: Scheme::Argon2id(Box::new(hash)),
187            })
188        })
189        .await
190    }
191
192    /// Never matches: no password derives to all zero bytes. Checking it costs what checking
193    /// a new hash does.
194    pub(crate) fn unmatchable() -> Result<Self> {
195        let malformed = |error| Error::internal("building the unmatchable password hash", error);
196        let hash = phc::PasswordHash {
197            algorithm: ARGON2ID_IDENT,
198            version: Some(VERSION.into()),
199            params: ParamsString::try_from(&PARAMS).map_err(malformed)?,
200            salt: Some(Salt::new(&[0; SALT_LENGTH]).map_err(|error| malformed(error.into()))?),
201            hash: Some(Output::new(&[0; HASH_LENGTH]).map_err(|error| malformed(error.into()))?),
202        };
203        Ok(Self {
204            scheme: Scheme::Argon2id(Box::new(hash)),
205        })
206    }
207
208    /// Whether it was written by an older scheme or weaker parameters, so a sign-in that proved
209    /// its password should store a new one.
210    #[must_use]
211    pub fn needs_rehash(&self) -> bool {
212        match &self.scheme {
213            Scheme::Argon2id(hash) => {
214                Params::try_from(&hash.params).map_or(true, |params| !is_current(&params))
215            }
216            Scheme::Pbkdf2 { .. } => true,
217        }
218    }
219
220    /// Compared in constant time.
221    fn verify(&self, password: &str) -> Result<bool> {
222        match &self.scheme {
223            Scheme::Argon2id(hash) => {
224                match argon2id().verify_password(password.as_bytes(), hash.as_ref()) {
225                    Ok(()) => Ok(true),
226                    Err(argon2::password_hash::Error::PasswordInvalid) => Ok(false),
227                    Err(error) => Err(Error::internal("checking a password with Argon2id", error)),
228                }
229            }
230            Scheme::Pbkdf2 {
231                iterations,
232                salt,
233                hash,
234            } => Ok(pbkdf2::verify(
235                PBKDF2_HMAC_SHA256,
236                *iterations,
237                salt,
238                password.as_bytes(),
239                hash,
240            )
241            .is_ok()),
242        }
243    }
244
245    fn to_text(&self) -> String {
246        match &self.scheme {
247            Scheme::Argon2id(hash) => hash.to_string(),
248            Scheme::Pbkdf2 {
249                iterations,
250                salt,
251                hash,
252            } => format!(
253                "{PBKDF2_SCHEME}${iterations}${}${}",
254                base64::encode(&STANDARD, salt),
255                base64::encode(&STANDARD, hash)
256            ),
257        }
258    }
259}
260
261fn is_current(params: &Params) -> bool {
262    params.m_cost() >= PARAMS.m_cost()
263        && params.t_cost() >= PARAMS.t_cost()
264        && params.p_cost() >= PARAMS.p_cost()
265}
266
267/// Checked whole here, so checking a password later fails only on a wrong password.
268fn argon2id_from_text(text: &str) -> Option<Scheme> {
269    let hash = phc::PasswordHash::new(text).ok()?;
270    if hash.algorithm != ARGON2ID_IDENT || hash.version != Some(VERSION.into()) {
271        return None;
272    }
273    Params::try_from(&hash).ok()?;
274    hash.salt?;
275    hash.hash?;
276    Some(Scheme::Argon2id(Box::new(hash)))
277}
278
279fn pbkdf2_from_text(text: &str) -> Option<Scheme> {
280    let parts: Vec<&str> = text.split('$').collect();
281    let [PBKDF2_SCHEME, iterations, salt, hash] = parts.as_slice() else {
282        return None;
283    };
284    Some(Scheme::Pbkdf2 {
285        iterations: iterations.parse().ok()?,
286        salt: base64::decode(&STANDARD, salt)?.try_into().ok()?,
287        hash: base64::decode(&STANDARD, hash)?.try_into().ok()?,
288    })
289}
290
291impl fmt::Debug for PasswordHash {
292    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
293        formatter.write_str("PasswordHash(…)")
294    }
295}
296
297impl Cast for PasswordHash {
298    const COLUMNS: &'static [ColumnSpec] = String::COLUMNS;
299    const SENSITIVE: bool = true;
300
301    fn encode(&self, out: &mut Vec<SqlValue>) {
302        out.push(SqlValue::Text(self.to_text()));
303    }
304
305    fn decode(row: &mut dyn ColumnReader) -> Result<Self> {
306        let text = row.text()?;
307        let scheme = if text.starts_with('$') {
308            argon2id_from_text(text)
309        } else {
310            pbkdf2_from_text(text)
311        };
312        scheme.map(|scheme| Self { scheme }).ok_or_else(|| {
313            Error::internal_message(
314                "a stored password hash is neither Argon2id in the PHC string format nor pbkdf2-sha256",
315            )
316        })
317    }
318}
319
320#[cfg(test)]
321mod tests {
322    use sofie::core::RowReader;
323
324    use super::*;
325
326    const ARGON2ID_PREFIX: &str = "$argon2id$v=19$m=19456,t=2,p=1$";
327
328    #[tokio::test]
329    async fn a_dropped_request_keeps_its_hashing_permit_until_the_hash_ends() {
330        let limit = Arc::new(Semaphore::new(1));
331        let (started, has_started) = std::sync::mpsc::channel();
332        let (finish, may_finish) = std::sync::mpsc::channel::<()>();
333        let request = tokio::spawn(limited(Arc::clone(&limit), move || {
334            started.send(()).unwrap();
335            may_finish.recv().unwrap();
336            Ok(())
337        }));
338        tokio::task::spawn_blocking(move || has_started.recv().unwrap())
339            .await
340            .unwrap();
341
342        request.abort();
343        assert!(request.await.unwrap_err().is_cancelled());
344        assert_eq!(limit.available_permits(), 0);
345
346        finish.send(()).unwrap();
347        drop(limit.acquire().await.unwrap());
348    }
349
350    fn password(text: &str) -> Password {
351        Password::parse(text.to_owned()).unwrap()
352    }
353
354    fn stored(text: &str) -> Result<PasswordHash> {
355        let values = [SqlValue::Text(text.to_owned())];
356        PasswordHash::decode(&mut RowReader::new(&values))
357    }
358
359    fn column(hash: &PasswordHash) -> String {
360        let mut column = Vec::new();
361        hash.encode(&mut column);
362        let [SqlValue::Text(text)] = column.as_slice() else {
363            panic!("one text column");
364        };
365        text.clone()
366    }
367
368    fn pbkdf2_text(password: &str) -> String {
369        let salt = [7; SALT_LENGTH];
370        let mut hash = [0; HASH_LENGTH];
371        let iterations = NonZeroU32::new(600_000).unwrap();
372        pbkdf2::derive(
373            PBKDF2_HMAC_SHA256,
374            iterations,
375            &salt,
376            password.as_bytes(),
377            &mut hash,
378        );
379        format!(
380            "pbkdf2-sha256${iterations}${}${}",
381            base64::encode(&STANDARD, &salt),
382            base64::encode(&STANDARD, &hash)
383        )
384    }
385
386    #[test]
387    fn a_password_is_measured_in_characters_like_text() {
388        let measured = password("çorrect horse battery");
389
390        assert_eq!(measured.size(), Some(21));
391        assert!(!measured.is_blank());
392        assert!(password(&" ".repeat(12)).is_blank());
393    }
394
395    #[test]
396    fn new_hashes_take_owasp_s_argon2id_parameters() {
397        assert_eq!(
398            (PARAMS.m_cost(), PARAMS.t_cost(), PARAMS.p_cost()),
399            (19 * 1024, 2, 1)
400        );
401    }
402
403    #[tokio::test]
404    async fn a_new_hash_is_argon2id_and_matches_its_password_and_no_other() {
405        let hash = PasswordHash::new(&password("correct horse battery"))
406            .await
407            .unwrap();
408
409        assert!(
410            column(&hash).starts_with(ARGON2ID_PREFIX),
411            "{}",
412            column(&hash)
413        );
414        assert!(!hash.needs_rehash());
415        assert!(
416            password("correct horse battery")
417                .check(Some(&hash))
418                .await
419                .unwrap()
420                .is_right()
421        );
422        assert!(
423            !password("correct horse batterY")
424                .check(Some(&hash))
425                .await
426                .unwrap()
427                .is_right()
428        );
429    }
430
431    #[tokio::test]
432    async fn an_old_pbkdf2_hash_still_matches_only_its_password_and_asks_for_a_rehash() {
433        let text = pbkdf2_text("correct horse battery");
434        let old = stored(&text).unwrap();
435
436        assert!(old.needs_rehash());
437        assert_eq!(column(&old), text, "read back as it was written");
438        assert!(
439            password("correct horse battery")
440                .check(Some(&old))
441                .await
442                .unwrap()
443                .is_right()
444        );
445        assert!(
446            !password("correct horse batterY")
447                .check(Some(&old))
448                .await
449                .unwrap()
450                .is_right()
451        );
452    }
453
454    #[test]
455    fn an_argon2id_hash_weaker_than_today_s_asks_for_a_rehash() {
456        let weaker = stored(&format!(
457            "$argon2id$v=19$m=8192,t=2,p=1${}${}",
458            "AAAAAAAAAAAAAAAAAAAAAA",
459            "A".repeat(43)
460        ))
461        .unwrap();
462
463        assert!(weaker.needs_rehash());
464    }
465
466    #[tokio::test]
467    async fn without_a_hash_nothing_matches_at_the_cost_of_a_new_hash() {
468        let unmatchable = PasswordHash::unmatchable().unwrap();
469
470        assert!(column(&unmatchable).starts_with(ARGON2ID_PREFIX));
471        assert!(!unmatchable.needs_rehash());
472        assert!(
473            !password("correct horse battery")
474                .check(None)
475                .await
476                .unwrap()
477                .is_right()
478        );
479    }
480
481    #[tokio::test]
482    async fn a_hash_survives_its_column_and_never_prints() {
483        let hash = PasswordHash::new(&password("correct horse battery"))
484            .await
485            .unwrap();
486
487        let decoded = stored(&column(&hash)).unwrap();
488
489        assert!(
490            password("correct horse battery")
491                .check(Some(&decoded))
492                .await
493                .unwrap()
494                .is_right()
495        );
496        assert_eq!(format!("{decoded:?}"), "PasswordHash(…)");
497    }
498
499    #[test]
500    fn a_malformed_stored_hash_is_refused_without_quoting_it() {
501        for text in [
502            "pbkdf2-sha256$600000$c2FsdA",
503            "md5$1$AAAAAAAAAAAAAAAAAAAAAA$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
504            "pbkdf2-sha256$0$AAAAAAAAAAAAAAAAAAAAAA$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
505            "$argon2i$v=19$m=19456,t=2,p=1$AAAAAAAAAAAAAAAAAAAAAA$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
506            "$argon2id$v=16$m=19456,t=2,p=1$AAAAAAAAAAAAAAAAAAAAAA$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
507            "$argon2id$v=19$m=19456,t=2,p=1$AAAAAAAAAAAAAAAAAAAAAA",
508            "$argon2id$v=19$m=1,t=2,p=1$AAAAAAAAAAAAAAAAAAAAAA$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
509        ] {
510            let error = stored(text).unwrap_err();
511            assert!(!error.to_string().contains(text), "{error}");
512        }
513    }
514
515    #[test]
516    fn a_password_has_twelve_to_128_characters_counted_not_bytes() {
517        assert!(Password::parse("ü".repeat(11)).is_err());
518        assert!(Password::parse("ü".repeat(12)).is_ok());
519        assert!(Password::parse("ü".repeat(128)).is_ok());
520        assert!(Password::parse("a".repeat(129)).is_err());
521    }
522
523    #[test]
524    fn a_password_is_read_from_input_and_never_printed() {
525        let read: Password = sofie::serde_json::from_str("\"correct horse battery\"").unwrap();
526        assert_eq!(format!("{read:?}"), "Password(…)");
527
528        let error = sofie::serde_json::from_str::<Password>("\"too short\"").unwrap_err();
529        assert!(!error.to_string().contains("too short"), "{error}");
530        assert!(error.to_string().contains("at least 12"), "{error}");
531    }
532
533    #[tokio::test]
534    async fn a_password_typed_decomposed_matches_its_composed_hash() {
535        let hash = PasswordHash::new(&password("çorrect horse battery"))
536            .await
537            .unwrap();
538
539        let check = password("c\u{327}orrect horse battery")
540            .check(Some(&hash))
541            .await
542            .unwrap();
543
544        assert_eq!(check, PasswordCheck::Right);
545    }
546
547    #[tokio::test]
548    async fn a_hash_of_a_decomposed_password_still_matches_and_asks_to_be_replaced() {
549        let decomposed = "c\u{327}orrect horse battery";
550        let before_normalization = stored(&pbkdf2_text(decomposed)).unwrap();
551
552        let check = password(decomposed)
553            .check(Some(&before_normalization))
554            .await
555            .unwrap();
556        let other = password("c\u{327}orrect horse batterY")
557            .check(Some(&before_normalization))
558            .await
559            .unwrap();
560
561        assert_eq!(check, PasswordCheck::RightAsTyped);
562        assert_eq!(other, PasswordCheck::Wrong);
563    }
564
565    #[test]
566    fn a_password_is_counted_after_nfc() {
567        assert!(Password::parse("u\u{308}".repeat(12)).is_ok());
568        assert!(Password::parse("u\u{308}".repeat(11)).is_err());
569        assert!(Password::parse("u\u{308}".repeat(128)).is_ok());
570    }
571}