Skip to content

Commit 5751349

Browse files
authored
Allow applying unicode normalisation to passwords before hashing (#4599)
2 parents c4cd7a9 + 40cb052 commit 5751349

File tree

18 files changed

+128
-56
lines changed

18 files changed

+128
-56
lines changed

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/cli/src/commands/manage.rs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ use mas_storage_pg::{DatabaseError, PgRepository};
3131
use rand::{RngCore, SeedableRng};
3232
use sqlx::{Acquire, types::Uuid};
3333
use tracing::{error, info, info_span, warn};
34+
use zeroize::Zeroizing;
3435

3536
use crate::util::{
3637
database_connection_from_config, homeserver_connection_from_config,
@@ -210,7 +211,7 @@ impl Options {
210211
return Ok(ExitCode::from(1));
211212
}
212213

213-
let password = password.into_bytes().into();
214+
let password = Zeroizing::new(password);
214215

215216
let (version, hashed_password) = password_manager.hash(&mut rng, password).await?;
216217

@@ -566,7 +567,7 @@ impl Options {
566567

567568
// Hash the password if it's provided
568569
let hashed_password = if let Some(password) = password {
569-
let password = password.into_bytes().into();
570+
let password = Zeroizing::new(password);
570571
Some(password_manager.hash(&mut rng, password).await?)
571572
} else {
572573
None
@@ -641,7 +642,7 @@ impl Options {
641642
.interact()
642643
})
643644
.await??;
644-
let password = password.into_bytes().into();
645+
let password = Zeroizing::new(password);
645646
req.hashed_password =
646647
Some(password_manager.hash(&mut rng, password).await?);
647648
}

crates/cli/src/util.rs

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -36,20 +36,24 @@ pub async fn password_manager_from_config(
3636
return Ok(PasswordManager::disabled());
3737
}
3838

39-
let schemes = config
40-
.load()
41-
.await?
42-
.into_iter()
43-
.map(|(version, algorithm, cost, secret)| {
39+
let schemes = config.load().await?.into_iter().map(
40+
|(version, algorithm, cost, secret, unicode_normalization)| {
4441
use mas_handlers::passwords::Hasher;
4542
let hasher = match algorithm {
46-
mas_config::PasswordAlgorithm::Pbkdf2 => Hasher::pbkdf2(secret),
47-
mas_config::PasswordAlgorithm::Bcrypt => Hasher::bcrypt(cost, secret),
48-
mas_config::PasswordAlgorithm::Argon2id => Hasher::argon2id(secret),
43+
mas_config::PasswordAlgorithm::Pbkdf2 => {
44+
Hasher::pbkdf2(secret, unicode_normalization)
45+
}
46+
mas_config::PasswordAlgorithm::Bcrypt => {
47+
Hasher::bcrypt(cost, secret, unicode_normalization)
48+
}
49+
mas_config::PasswordAlgorithm::Argon2id => {
50+
Hasher::argon2id(secret, unicode_normalization)
51+
}
4952
};
5053

5154
(version, hasher)
52-
});
55+
},
56+
);
5357

5458
PasswordManager::new(config.minimum_complexity(), schemes)
5559
}
@@ -492,7 +496,7 @@ mod tests {
492496
#[tokio::test]
493497
async fn test_password_manager_from_config() {
494498
let mut rng = rand_chacha::ChaChaRng::seed_from_u64(42);
495-
let password = Zeroizing::new(b"hunter2".to_vec());
499+
let password = Zeroizing::new("hunter2".to_owned());
496500

497501
// Test a valid, enabled config
498502
let config = serde_json::from_value(serde_json::json!({

crates/config/src/sections/passwords.rs

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ fn default_schemes() -> Vec<HashingScheme> {
2020
cost: None,
2121
secret: None,
2222
secret_file: None,
23+
unicode_normalization: false,
2324
}]
2425
}
2526

@@ -124,7 +125,7 @@ impl PasswordsConfig {
124125
/// not be read.
125126
pub async fn load(
126127
&self,
127-
) -> Result<Vec<(u16, Algorithm, Option<u32>, Option<Vec<u8>>)>, anyhow::Error> {
128+
) -> Result<Vec<(u16, Algorithm, Option<u32>, Option<Vec<u8>>, bool)>, anyhow::Error> {
128129
let mut schemes: Vec<&HashingScheme> = self.schemes.iter().collect();
129130
schemes.sort_unstable_by_key(|a| Reverse(a.version));
130131
schemes.dedup_by_key(|a| a.version);
@@ -151,13 +152,24 @@ impl PasswordsConfig {
151152
(None, None) => None,
152153
};
153154

154-
mapped_result.push((scheme.version, scheme.algorithm, scheme.cost, secret));
155+
mapped_result.push((
156+
scheme.version,
157+
scheme.algorithm,
158+
scheme.cost,
159+
secret,
160+
scheme.unicode_normalization,
161+
));
155162
}
156163

157164
Ok(mapped_result)
158165
}
159166
}
160167

168+
#[allow(clippy::trivially_copy_pass_by_ref)]
169+
const fn is_default_false(value: &bool) -> bool {
170+
!*value
171+
}
172+
161173
/// Parameters for a password hashing scheme
162174
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
163175
pub struct HashingScheme {
@@ -168,6 +180,14 @@ pub struct HashingScheme {
168180
/// The hashing algorithm to use
169181
pub algorithm: Algorithm,
170182

183+
/// Whether to apply Unicode normalization to the password before hashing
184+
///
185+
/// Defaults to `false`, and generally recommended to stay false. This is
186+
/// although recommended when importing password hashs from Synapse, as it
187+
/// applies an NFKC normalization to the password before hashing it.
188+
#[serde(default, skip_serializing_if = "is_default_false")]
189+
pub unicode_normalization: bool,
190+
171191
/// Cost for the bcrypt algorithm
172192
#[serde(skip_serializing_if = "Option::is_none")]
173193
#[schemars(default = "default_bcrypt_cost")]

crates/handlers/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ chrono.workspace = true
7474
elliptic-curve.workspace = true
7575
hex.workspace = true
7676
governor.workspace = true
77+
icu_normalizer = "1.5.0"
7778
indexmap.workspace = true
7879
pkcs8.workspace = true
7980
psl = "2.1.111"

crates/handlers/src/admin/v1/users/set_password.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ pub async fn handler(
122122
return Err(RouteError::PasswordTooWeak);
123123
}
124124

125-
let password = Zeroizing::new(params.password.into_bytes());
125+
let password = Zeroizing::new(params.password);
126126
let (version, hashed_password) = password_manager
127127
.hash(&mut rng, password)
128128
.await
@@ -184,7 +184,7 @@ mod tests {
184184
// Check that the user now has a password
185185
let mut repo = state.repository().await.unwrap();
186186
let user_password = repo.user_password().active(&user).await.unwrap().unwrap();
187-
let password = Zeroizing::new(b"this is a good enough password".to_vec());
187+
let password = Zeroizing::new(String::from("this is a good enough password"));
188188
state
189189
.password_manager
190190
.verify(
@@ -243,7 +243,7 @@ mod tests {
243243
// Check that the user now has a password
244244
let mut repo = state.repository().await.unwrap();
245245
let user_password = repo.user_password().active(&user).await.unwrap().unwrap();
246-
let password = Zeroizing::new(b"password".to_vec());
246+
let password = Zeroizing::new("password".to_owned());
247247
state
248248
.password_manager
249249
.verify(

crates/handlers/src/compat/login.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -574,7 +574,7 @@ async fn user_password_login(
574574
.ok_or(RouteError::NoPassword)?;
575575

576576
// Verify the password
577-
let password = Zeroizing::new(password.into_bytes());
577+
let password = Zeroizing::new(password);
578578

579579
let new_password_hash = password_manager
580580
.verify_and_upgrade(
@@ -787,7 +787,7 @@ mod tests {
787787
.unwrap();
788788
let (version, hash) = state
789789
.password_manager
790-
.hash(&mut rng, Zeroizing::new(password.as_bytes().to_vec()))
790+
.hash(&mut rng, Zeroizing::new(password.to_owned()))
791791
.await
792792
.unwrap();
793793

crates/handlers/src/graphql/mutations/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ async fn verify_password_if_needed(
7676
return Ok(false);
7777
};
7878

79-
let password = Zeroizing::new(password.into_bytes());
79+
let password = Zeroizing::new(password);
8080

8181
let res = password_manager
8282
.verify(

crates/handlers/src/graphql/mutations/user.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -742,7 +742,7 @@ impl UserMutations {
742742
if let Err(_err) = password_manager
743743
.verify(
744744
active_password.version,
745-
Zeroizing::new(current_password_attempt.into_bytes()),
745+
Zeroizing::new(current_password_attempt),
746746
active_password.hashed_password,
747747
)
748748
.await
@@ -754,7 +754,7 @@ impl UserMutations {
754754
}
755755

756756
let (new_password_version, new_password_hash) = password_manager
757-
.hash(state.rng(), Zeroizing::new(input.new_password.into_bytes()))
757+
.hash(state.rng(), Zeroizing::new(input.new_password))
758758
.await?;
759759

760760
repo.user_password()
@@ -849,7 +849,7 @@ impl UserMutations {
849849
}
850850

851851
let (new_password_version, new_password_hash) = password_manager
852-
.hash(state.rng(), Zeroizing::new(input.new_password.into_bytes()))
852+
.hash(state.rng(), Zeroizing::new(input.new_password))
853853
.await?;
854854

855855
repo.user_password()

crates/handlers/src/oauth2/introspection.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -844,7 +844,7 @@ mod tests {
844844

845845
let (version, hashed_password) = state
846846
.password_manager
847-
.hash(&mut state.rng(), Zeroizing::new(b"password".to_vec()))
847+
.hash(&mut state.rng(), Zeroizing::new("password".to_owned()))
848848
.await
849849
.unwrap();
850850

0 commit comments

Comments
 (0)