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
17const 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
26static 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 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
54pub struct Password {
56 text: String,
57 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 #[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 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
137impl 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#[derive(Clone)]
160pub struct PasswordHash {
161 scheme: Scheme,
162}
163
164#[derive(Clone)]
165enum Scheme {
166 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 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 #[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(¶ms))
215 }
216 Scheme::Pbkdf2 { .. } => true,
217 }
218 }
219
220 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
267fn 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}