diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d31d5bb0d..ab6091cc8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -57,6 +57,14 @@ jobs: - name: Rust Cache uses: swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2.8.0 + - name: Set up Python for cross-verification tests + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: '3.x' + + - name: Install Python Keystone for cross-verification tests + run: pip install keystone + - name: Run tests (unit) run: cargo nextest run diff --git a/Cargo.lock b/Cargo.lock index d7a118a72..de2b35ed6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3969,6 +3969,7 @@ dependencies = [ "regex-syntax", "reqwest 0.13.4", "rstest", + "scrypt", "sea-orm", "sea-orm-migration", "secrecy", @@ -3977,6 +3978,7 @@ dependencies = [ "serde_urlencoded", "sha2 0.11.0", "spiffe", + "subtle", "tempfile", "thiserror 2.0.18", "tokio", @@ -4830,6 +4832,17 @@ dependencies = [ "regex", ] +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "paste" version = "1.0.15" @@ -4863,6 +4876,16 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest 0.10.7", + "hmac 0.12.1", +] + [[package]] name = "peel-off" version = "0.1.1" @@ -6026,6 +6049,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "salsa20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" +dependencies = [ + "cipher 0.4.4", +] + [[package]] name = "same-file" version = "1.0.6" @@ -6074,6 +6106,18 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "scrypt" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" +dependencies = [ + "password-hash", + "pbkdf2", + "salsa20", + "sha2 0.10.9", +] + [[package]] name = "sea-bae" version = "0.2.1" diff --git a/crates/config/src/identity.rs b/crates/config/src/identity.rs index c1345e6d2..8061da592 100644 --- a/crates/config/src/identity.rs +++ b/crates/config/src/identity.rs @@ -68,6 +68,12 @@ pub enum PasswordHashingAlgo { /// Bcrypt. #[default] Bcrypt, + /// Bcrypt combined with SHA256. + BcryptSha256, + /// PBKDF2 with SHA512. + Pbkdf2Sha512, + /// Scrypt. + Scrypt, // #[cfg(test)] /// None. Should not be used outside of testing where expected value is /// necessary. diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index caddf8056..406b888c0 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -46,6 +46,11 @@ tracing.workspace = true url = { workspace = true, features = ["serde"] } uuid = { workspace = true, features = ["v4"] } validator.workspace = true +# pbkdf2 crate removed: PBKDF2-HMAC-SHA512 is implemented directly in +# password_hashing.rs using the workspace hmac/sha2 to avoid a +# digest v0.10 vs v0.11 version conflict (pbkdf2 v0.12 uses sha2 v0.10). +scrypt = "0.11" +subtle = "2.6.1" [dev-dependencies] config = { workspace = true } @@ -53,6 +58,10 @@ httpmock = { version = "0.8", features = ["http2"] } mockall.workspace = true openstack-keystone-core-types = { workspace = true, features = ["mock"] } rstest.workspace = true +# `process` is test-only: the env-gated cross-verify tests shell out to +# tools/cross_verify.py. Kept out of the main `tokio` dep to avoid pulling +# process-spawning machinery into the production build. +tokio = { workspace = true, features = ["process"] } tracing-test = { workspace = true, features = ["no-env-filter"] } url.workspace = true diff --git a/crates/core/src/common/password_hashing.rs b/crates/core/src/common/password_hashing.rs deleted file mode 100644 index 7981769ff..000000000 --- a/crates/core/src/common/password_hashing.rs +++ /dev/null @@ -1,324 +0,0 @@ -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// -// SPDX-License-Identifier: Apache-2.0 - -use std::cmp::max; -use std::str; -use thiserror::Error; -use tokio::task; -use tracing::warn; - -use openstack_keystone_config::{Config, PasswordHashingAlgo}; - -/// Password hashing related errors. -#[derive(Error, Debug)] -pub enum PasswordHashError { - /// Bcrypt error. - #[error(transparent)] - BCrypt { - /// The source of the error. - #[from] - source: bcrypt::BcryptError, - }, - - /// Async task join error. - #[error(transparent)] - Join { - /// The source of the error. - #[from] - source: tokio::task::JoinError, - }, - - /// Non UTF8 data. - #[error(transparent)] - Utf8 { - /// The source of the error. - #[from] - source: str::Utf8Error, - }, -} - -/// Verify the password length and truncate if necessary. -/// -/// # Parameters -/// - `password`: The password bytes. -/// - `max_length`: The maximum allowed length. -/// -/// # Returns -/// - `&[u8]` - The password bytes, truncated if they exceeded `max_length`. -fn verify_length_and_trunc_password(password: &[u8], max_length: usize) -> &[u8] { - if password.len() > max_length { - warn!("Truncating password to the specified value"); - return &password[..max_length]; - } - password -} - -/// Generate a dummy password hash matching the configured algorithm. -/// -/// Used for timing attack prevention: when a user is not found, a dummy hash -/// is generated and verified against the provided password, so the response -/// time is approximately the same as when the user exists but the password is -/// wrong. -/// -/// # Parameters -/// - `conf`: The service configuration. -/// -/// # Returns -/// - `Ok(String)` - A dummy hash string matching the configured algorithm. -/// - `Err(PasswordHashError)` - If hash generation failed. -pub async fn generate_dummy_hash(conf: &Config) -> Result { - match conf.identity.password_hashing_algorithm { - PasswordHashingAlgo::Bcrypt => { - let rounds = conf.identity.password_hash_rounds.unwrap_or(12); - // bcrypt dummy hash: "$2b$XX$" + 53 random base64 chars - // Generate a dummy hash with a random salt by hashing a random string - // with matching rounds, so verify_password takes the same time - let dummy_password = rand::random::<[u8; 16]>(); - let hash = - task::spawn_blocking(move || bcrypt::hash(dummy_password, rounds as u32)).await??; - Ok(hash) - } - PasswordHashingAlgo::None => { - let dummy: [u8; 32] = rand::random(); - Ok(dummy - .map(|b| b % 95 + 32_u8) - .into_iter() - .map(|b| b as char) - .collect()) - } - } -} - -/// Calculate password hash with the configuration defaults. -/// -/// # Parameters -/// - `conf`: The service configuration. -/// - `password`: The password to hash. -/// -/// # Returns -/// - `Ok(String)` - The hashed password. -/// - `Err(PasswordHashError)` - If hashing failed. -pub async fn hash_password>( - conf: &Config, - password: S, -) -> Result { - match conf.identity.password_hashing_algorithm { - PasswordHashingAlgo::Bcrypt => { - let password_bytes = verify_length_and_trunc_password( - password.as_ref(), - max(conf.identity.max_password_length, 72), - ) - .to_owned(); - let rounds = conf.identity.password_hash_rounds.unwrap_or(12); - let hash = - task::spawn_blocking(move || bcrypt::hash(password_bytes, rounds as u32)).await??; - Ok(hash) - } - //#[cfg(test)] - PasswordHashingAlgo::None => Ok(str::from_utf8(password.as_ref())?.to_string()), - } -} - -/// Verify the password matches the hashed value. -/// -/// # Parameters -/// - `conf`: The service configuration. -/// - `password`: The password to verify. -/// - `hash`: The hash to compare against. -/// -/// # Returns -/// - `Ok(bool)` - True if the password matches the hash, false otherwise. -/// - `Err(PasswordHashError)` - If verification failed. -pub async fn verify_password, H: AsRef>( - conf: &Config, - password: P, - hash: H, -) -> Result { - match conf.identity.password_hashing_algorithm { - PasswordHashingAlgo::Bcrypt => { - let password_bytes = verify_length_and_trunc_password( - password.as_ref(), - max(conf.identity.max_password_length, 72), - ) - .to_owned(); - let password_hash = hash.as_ref().to_string(); - // Do not block the main thread with a definitely long running call. - match task::spawn_blocking(move || bcrypt::verify(password_bytes, &password_hash)) - .await? - { - Ok(res) => Ok(res), - Err(bcrypt::BcryptError::InvalidHash(..)) => { - // InvalidHash error contain the hash itself. We do not want to log it. - warn!("Bcrypt hash verification error: bad hash"); - Ok(false) - } - other => { - warn!("Bcrypt hash verification error: {other:?}"); - Ok(false) - } - } - } - //#[cfg(test)] - PasswordHashingAlgo::None => Ok(str::from_utf8(password.as_ref())?.eq(hash.as_ref())), - } -} - -#[cfg(test)] -mod tests { - use super::*; - use rand::distr::{Alphanumeric, SampleString}; - use tracing_test::traced_test; - - #[test] - fn test_verify_length_and_trunc_password() { - assert_eq!( - b"abcdefg", - verify_length_and_trunc_password("abcdefg".as_bytes(), 70) - ); - assert_eq!( - b"abcd", - verify_length_and_trunc_password("abcdefg".as_bytes(), 4) - ); - // In UTF8 bytes a single unicode is taking 3 bytes already - assert_eq!( - b"\xE2\x98\x81a", - verify_length_and_trunc_password("☁abcdefg".as_bytes(), 4) - ); - } - - #[tokio::test] - #[traced_test] - async fn test_hash_bcrypt() { - let builder = config::Config::builder() - .set_override("auth.methods", "") - .unwrap() - .set_override("database.connection", "dummy") - .unwrap(); - let conf: Config = Config::try_from(builder).expect("can build a valid config"); - let pass = "abcdefg"; - let hashed = hash_password(&conf, &pass).await.unwrap(); - assert!(!logs_contain(pass)); - assert!(!logs_contain(&hashed)); - } - - #[tokio::test] - #[traced_test] - async fn test_roundtrip_bcrypt() { - let builder = config::Config::builder() - .set_override("auth.methods", "") - .unwrap() - .set_override("database.connection", "dummy") - .unwrap(); - let conf: Config = Config::try_from(builder).expect("can build a valid config"); - let pass = "abcdefg"; - let hashed = hash_password(&conf, &pass).await.unwrap(); - assert!(verify_password(&conf, &pass, &hashed).await.unwrap()); - assert!(!logs_contain(pass)); - assert!(!logs_contain(&hashed)); - } - - #[tokio::test] - #[traced_test] - async fn test_roundtrip_bcrypt_longer_than_72() { - let builder = config::Config::builder() - .set_override("auth.methods", "") - .unwrap() - .set_override("database.connection", "dummy") - .unwrap(); - let conf: Config = Config::try_from(builder).expect("can build a valid config"); - let pass = Alphanumeric.sample_string(&mut rand::rng(), 80); - let hashed = hash_password(&conf, &pass).await.unwrap(); - assert!(verify_password(&conf, &pass, &hashed).await.unwrap()); - assert!(!logs_contain(&pass)); - assert!(!logs_contain(&hashed)); - } - - #[tokio::test] - #[traced_test] - async fn test_roundtrip_bcrypt_mismatch() { - let builder = config::Config::builder() - .set_override("auth.methods", "") - .unwrap() - .set_override("database.connection", "dummy") - .unwrap(); - let conf: Config = Config::try_from(builder).expect("can build a valid config"); - let pass = Alphanumeric.sample_string(&mut rand::rng(), 80); - let hashed = hash_password(&conf, "other password").await.unwrap(); - assert!(!verify_password(&conf, &pass, &hashed).await.unwrap()); - assert!(!logs_contain(&pass)); - assert!(!logs_contain(&hashed)); - } - - #[tokio::test] - #[traced_test] - async fn test_roundtrip_bcrypt_bad_hash() { - let builder = config::Config::builder() - .set_override("auth.methods", "") - .unwrap() - .set_override("database.connection", "dummy") - .unwrap(); - let conf: Config = Config::try_from(builder).expect("can build a valid config"); - let pass = Alphanumeric.sample_string(&mut rand::rng(), 80); - assert!(!verify_password(&conf, &pass, "foobar").await.unwrap()); - assert!(!logs_contain("foobar")); - assert!(!logs_contain(&pass)); - } - - #[tokio::test] - #[traced_test] - async fn test_generate_and_verify_dummy_hash_bcrypt() { - let builder = config::Config::builder() - .set_override("auth.methods", "") - .unwrap() - .set_override("database.connection", "dummy") - .unwrap(); - let conf: Config = Config::try_from(builder).expect("can build a valid config"); - let dummy_hash = generate_dummy_hash(&conf).await.unwrap(); - // Dummy hash should be a valid bcrypt hash (starts with $2b$) - assert!( - dummy_hash.starts_with("$2b$"), - "Dummy hash should be a valid bcrypt hash" - ); - // Verify should return false for any random password - let pass = Alphanumeric.sample_string(&mut rand::rng(), 32); - let result = verify_password(&conf, &pass, &dummy_hash).await.unwrap(); - // Result should be false (password doesn't match dummy hash) - assert!(!result, "Dummy hash should not match random password"); - assert!(!logs_contain(&pass)); - assert!(!logs_contain(&dummy_hash)); - } - - #[tokio::test] - #[traced_test] - async fn test_generate_dummy_hash_none() { - let builder = config::Config::builder() - .set_override("auth.methods", "") - .unwrap() - .set_override("database.connection", "dummy") - .unwrap() - .set_override("identity.password_hashing_algorithm", "None") - .unwrap(); - let conf: Config = Config::try_from(builder).expect("can build a valid config"); - let dummy_hash = generate_dummy_hash(&conf).await.unwrap(); - // Dummy hash should be a non-empty string - assert!(!dummy_hash.is_empty(), "Dummy hash should not be empty"); - // Verify should return false for any random password - let pass = Alphanumeric.sample_string(&mut rand::rng(), 32); - let result = verify_password(&conf, &pass, &dummy_hash).await.unwrap(); - // Result should almost certainly be false (random password unlikely to match) - assert!(!result, "Dummy hash should not match random password"); - assert!(!logs_contain(&pass)); - assert!(!logs_contain(&dummy_hash)); - } -} diff --git a/crates/core/src/common/password_hashing/bcrypt.rs b/crates/core/src/common/password_hashing/bcrypt.rs new file mode 100644 index 000000000..d1110e93e --- /dev/null +++ b/crates/core/src/common/password_hashing/bcrypt.rs @@ -0,0 +1,118 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +//! Bcrypt hasher - mirrors `keystone/common/password_hashers/bcrypt.py::Bcrypt`. + +use openstack_keystone_config::Config; +use tokio::task; + +use super::{PasswordHashError, PasswordHasher}; + +/// Plain bcrypt. The `bcrypt` crate path is referenced absolutely (`::bcrypt`) +/// throughout this module so it is never confused with the module's own name. +pub(super) struct BcryptHasher; + +impl PasswordHasher for BcryptHasher { + async fn hash(&self, conf: &Config, password: &[u8]) -> Result { + let password_bytes = password.to_vec(); + let rounds = conf.identity.password_hash_rounds.unwrap_or(12); + // bcrypt::hash is CPU-bound; run off the async executor. + let hash = + task::spawn_blocking(move || ::bcrypt::hash(password_bytes, rounds as u32)).await??; + Ok(hash) + } + + async fn verify( + &self, + _conf: &Config, + password: &[u8], + hash: &str, + ) -> Result { + let password_bytes = password.to_vec(); + let hash_str = hash.to_string(); + match task::spawn_blocking(move || ::bcrypt::verify(password_bytes, &hash_str)).await? { + Ok(res) => Ok(res), + // A malformed hash string is not a fatal error - it just means + // the stored value is not a valid bcrypt hash and cannot match. + Err(::bcrypt::BcryptError::InvalidHash(_)) => Ok(false), + Err(e) => Err(PasswordHashError::from(e)), + } + } +} + +#[cfg(test)] +mod tests { + use super::super::tests::mock_config; + use super::super::{hash_password, verify_password}; + use openstack_keystone_config::PasswordHashingAlgo; + use rand::distr::{Alphanumeric, SampleString}; + use tracing_test::traced_test; + + #[tokio::test] + async fn test_bcrypt_matches_keystone_python_ascii_password() { + let conf = mock_config(PasswordHashingAlgo::Bcrypt, 255); + let python_hash = "$2b$12$0DJQbRXGHzPsBrwGt/DebuerSmDAslUjtPYtph84hMimE3XiK9K4e"; + + assert!( + verify_password(&conf, "password123", python_hash) + .await + .unwrap(), + "Rust Bcrypt verification rejected a real Keystone Python Bcrypt hash" + ); + } + + #[tokio::test] + async fn test_bcrypt_matches_keystone_python_truncates_at_72_bytes() { + // Generated from a 72-byte password (Python's own caller-side + // truncation already applied before hashing). Feeding the full, + // *untruncated* 73-byte password into Rust's verify_password must + // still succeed, proving Rust's own truncation lines up with + // Python's at the exact same 72-byte boundary. + let conf = mock_config(PasswordHashingAlgo::Bcrypt, 255); + let python_hash = "$2b$12$WzlPV/xopC8EI12Uz6kak.Edrg/n6QqM71MXoxegUUPxr.F52Hpsi"; + let untruncated_73_byte_password = "A".repeat(73); + + assert!( + verify_password(&conf, &untruncated_73_byte_password, python_hash) + .await + .unwrap(), + "Rust Bcrypt did not truncate at the same 72-byte boundary as Keystone Python" + ); + } + + #[tokio::test] + #[traced_test] + async fn test_roundtrip_bcrypt() { + let conf = mock_config(PasswordHashingAlgo::Bcrypt, 255); + let pass = "abcdefg"; + let hashed = hash_password(&conf, &pass).await.unwrap(); + + assert!(verify_password(&conf, &pass, &hashed).await.unwrap()); + assert!( + !verify_password(&conf, "wrong_password", &hashed) + .await + .unwrap() + ); + assert!(!logs_contain(pass)); + assert!(!logs_contain(&hashed)); + } + + #[tokio::test] + #[traced_test] + async fn test_roundtrip_bcrypt_bad_hash() { + let conf = mock_config(PasswordHashingAlgo::Bcrypt, 255); + let pass = Alphanumeric.sample_string(&mut rand::rng(), 80); + assert!(!verify_password(&conf, &pass, "foobar").await.unwrap()); + } +} diff --git a/crates/core/src/common/password_hashing/bcrypt_sha256.rs b/crates/core/src/common/password_hashing/bcrypt_sha256.rs new file mode 100644 index 000000000..160bb44ec --- /dev/null +++ b/crates/core/src/common/password_hashing/bcrypt_sha256.rs @@ -0,0 +1,320 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +//! Bcrypt-SHA256 hasher - mirrors `bcrypt.py::Bcrypt_sha256`. +//! +//! Bcrypt silently truncates inputs at 72 bytes, so this variant first reduces +//! the password to a fixed-size HMAC-SHA256 digest (keyed by the salt) and +//! feeds that digest to bcrypt. The full password therefore always +//! contributes, regardless of length. + +use base64::{Engine as _, engine::general_purpose::STANDARD}; +// KeyInit provides new_from_slice; Mac provides update/finalize. +use hmac::{Hmac, KeyInit, Mac}; +use openstack_keystone_config::Config; +use tokio::task; +use tracing::debug; + +use super::{PasswordHashError, PasswordHasher, generate_salt}; + +type HmacSha256 = Hmac; + +/// Length in characters of a bcrypt-encoded (Radix64) salt string. +const BCRYPT_SALT_LEN: usize = 22; + +/// Length in bytes of the bcrypt checksum/digest segment of a formatted +/// bcrypt hash string (the trailing portion after the salt). +const BCRYPT_CHECKSUM_LEN: usize = 31; + +/// Length of the bcrypt prefix `$2b$NN$` (ident + 2-digit cost factor, +/// each `$`-delimited). +const BCRYPT_PREFIX_LEN: usize = 7; + +/// Total length of a complete `$2b$`-format bcrypt hash string: +/// prefix + salt + checksum. (This is where a bare `60` would otherwise +/// show up unexplained.) +const BCRYPT_FULL_HASH_LEN: usize = BCRYPT_PREFIX_LEN + BCRYPT_SALT_LEN + BCRYPT_CHECKSUM_LEN; + +/// Cost factor used solely to derive the canonical Radix64 encoding of a +/// freshly generated random salt (see [`BcryptSha256Hasher::hash`]). +/// This is bcrypt's minimum permitted cost factor, so the extra bcrypt call +/// it requires is negligible compared to the real hash computed afterwards +/// at the configured `rounds`. +const BCRYPT_SALT_ENCODING_COST: u32 = 4; + +pub(super) struct BcryptSha256Hasher; + +impl PasswordHasher for BcryptSha256Hasher { + async fn hash(&self, conf: &Config, password: &[u8]) -> Result { + // mirrors keystone/common/password_hashers/bcrypt.py::Bcrypt_sha256.hash(): + // salt_with_opts = bcrypt.gensalt(rounds) + // salt = salt_with_opts[-22:] + // hmac_digest = base64.b64encode(hmac.digest(salt, password, "sha256")) + // hashed = bcrypt.hashpw(hmac_digest, salt_with_opts) + // digest = hashed[-31:] + // return f"$bcrypt-sha256$v=2,t=2b,r={rounds}${salt}${digest}" + let password_bytes = password.to_vec(); + let rounds = conf.identity.password_hash_rounds.unwrap_or(12); + + let hash = task::spawn_blocking(move || { + // Two values are derived from the same 16 random salt bytes: + // (a) the raw bytes themselves, used directly as the salt for + // the real bcrypt hash computed below; and + // (b) their canonical 22-char Radix64 encoding, used both as + // the HMAC-SHA256 key and embedded in the output record. + // + // (b) must be exactly the encoding bcrypt itself would use, so it + // is taken from the bcrypt crate's own salt-formatting logic via a + // throwaway hash at the algorithm's minimum cost factor + // (BCRYPT_SALT_ENCODING_COST). Only the salt string from that call + // is kept; its hash output is discarded. + // + // Hand-rolling the encoding (picking 22 random alphabet chars) is + // a trap: a bcrypt salt carries 128 bits across 22 chars, so the + // last char has only 4 meaningful bits - just 4 of the 64 alphabet + // chars are canonical there. A non-canonical salt gets silently + // re-canonicalized by bcrypt when the hash is computed, so the salt + // string embedded in the record would stop matching the salt used + // as the HMAC key. Letting the bcrypt crate produce it sidesteps + // this entirely. + let raw_salt = generate_salt(); + let salt_encoder = + ::bcrypt::hash_with_salt(b"unused", BCRYPT_SALT_ENCODING_COST, raw_salt)?; + let salt_str = salt_encoder.get_salt(); + + // HMAC-SHA256 keyed by the salt bytes, over the password, encoded + // with standard PADDED base64 (Python's `base64.b64encode`, not a + // padding-stripped variant). This must match verify()'s encoding + // byte-for-byte or no hash either path produces is verifiable by + // the other. + let hmac_res = compute_hmac_sha256(salt_str.as_bytes(), &password_bytes)?; + let hmac_digest_b64 = STANDARD.encode(hmac_res); + + // Hash using the real raw salt and the HMAC-derived intermediate password. + let final_bcrypt = + ::bcrypt::hash_with_salt(hmac_digest_b64.as_bytes(), rounds as u32, raw_salt)?; + let full_bcrypt_str = final_bcrypt.format_for_version(::bcrypt::Version::TwoB); + debug_assert_eq!( + full_bcrypt_str.len(), + BCRYPT_FULL_HASH_LEN, + "bcrypt crate's 2b format string length changed unexpectedly" + ); + + // Extract the exact trailing bcrypt signature digest. + let digest_str = &full_bcrypt_str[full_bcrypt_str.len() - BCRYPT_CHECKSUM_LEN..]; + + Ok::(format!( + "$bcrypt-sha256$v=2,t=2b,r={}${}${}", + rounds, salt_str, digest_str + )) + }) + .await??; + Ok(hash) + } + + async fn verify( + &self, + _conf: &Config, + password: &[u8], + hash: &str, + ) -> Result { + // mirrors keystone/common/password_hashers/bcrypt.py::Bcrypt_sha256.verify() + // exactly: there is no "version" concept in the real implementation. + // It always HMACs (never falls back to a plain digest), and it does not + // even look at `v=`, it just scans every comma-delimited param for `t=` + // and `r=` and ignores anything else. An earlier revision of this module + // had a second, version-gated code path that computed a plain SHA-256 + // digest (no HMAC) for records without `v=2`. That path was based on a + // Passlib-only format Keystone's own implementation never produces or + // reads, and has been removed. + let password_bytes = password.to_vec(); + let hash_str = hash.to_string(); + + // Split on '$': ["", "bcrypt-sha256", "v=2,t=2b,r=12", salt, digest] + let parts: Vec<&str> = hash_str.split('$').collect(); + if parts.len() != 5 { + debug!("Malformed BcryptSha256 record encountered"); + return Ok(false); + } + + let options = parts[2].to_string(); + let salt = parts[3].to_string(); + let checksum_part = parts[4].to_string(); + + if salt.len() != BCRYPT_SALT_LEN || checksum_part.len() != BCRYPT_CHECKSUM_LEN { + return Ok(false); + } + + // Parse t= (bcrypt ident) and r= (cost rounds) from the options field. + let mut bcrypt_type = "2b".to_string(); + let mut rounds = None; + for opt in options.split(',') { + if let Some(val) = opt.strip_prefix("t=") { + bcrypt_type = val.to_string(); + } else if let Some(val) = opt.strip_prefix("r=") { + rounds = Some(val.parse::().map_err(|_| { + PasswordHashError::CryptoHash("Invalid BcryptSha256 cost factor".into()) + })?); + } + } + + let rounds = rounds.ok_or_else(|| { + PasswordHashError::CryptoHash("Missing rounds parameter in BcryptSha256 record".into()) + })?; + + match task::spawn_blocking(move || { + // Reconstruct the bcrypt hash string for checkpw. + // Python does: new_salt = f"${opts['t']}${opts['r']}${salt}" + // then bcrypt.checkpw(hmac_digest, f"{new_salt}{digest}".encode("ascii")). + // + // The {:02} zero-pad is deliberate and must NOT be dropped to + // literally mirror Python's f-string: the Rust `bcrypt` crate's + // parser requires the cost field to be exactly two digits, whereas + // Python's `int`-formatted `r=` lets the underlying libbcrypt accept + // a single digit. The stored digest was originally computed against + // a salt from `bcrypt.gensalt(rounds)`, which always embeds a + // 2-digit zero-padded cost (e.g. "05"), so "05", not "5", is the + // cost the digest actually corresponds to. For realistic round + // counts (>= 10) the two forms are identical anyway. + let reconstructed_hash = + format!("${}${:02}${}{}", bcrypt_type, rounds, salt, checksum_part); + + // Re-derive the HMAC digest using the embedded salt. + let hmac_res = compute_hmac_sha256(salt.as_bytes(), &password_bytes)?; + let intermediate_b64 = STANDARD.encode(hmac_res); + + ::bcrypt::verify(intermediate_b64, &reconstructed_hash).map_err(PasswordHashError::from) + }) + .await? + { + Ok(res) => Ok(res), + Err(e) => Err(e), + } + } +} + +/// HMAC-SHA256 keyed by `salt`, over `password`. Returns raw 32-byte digest. +/// +/// Both `hash` and `verify` must use this function so the intermediate digest +/// is identical on both paths. +fn compute_hmac_sha256(salt: &[u8], password: &[u8]) -> Result<[u8; 32], PasswordHashError> { + let mut mac = HmacSha256::new_from_slice(salt).map_err(|e| { + PasswordHashError::CryptoHash(format!("HMAC key initialization error: {e}")) + })?; + mac.update(password); + let result = mac.finalize().into_bytes(); + let mut bytes = [0u8; 32]; + bytes.copy_from_slice(&result); + Ok(bytes) +} + +#[cfg(test)] +mod tests { + use super::super::tests::mock_config; + use super::super::{hash_password, verify_password}; + use openstack_keystone_config::PasswordHashingAlgo; + + #[tokio::test] + async fn test_bcrypt_sha256_matches_keystone_python_ascii_password() { + let conf = mock_config(PasswordHashingAlgo::BcryptSha256, 255); + let python_hash = + "$bcrypt-sha256$v=2,t=2b,r=12$dWWyn1sALNWeny6KjQhSUu$iOmfSpzioo6ThZbSXwWYSZAQcGlba/q"; + + assert!( + verify_password(&conf, "password123", python_hash) + .await + .unwrap(), + "Rust BcryptSha256 verification rejected a real Keystone Python BcryptSha256 hash" + ); + } + + #[tokio::test] + async fn test_bcrypt_sha256_matches_keystone_python_does_not_truncate_at_72_bytes() { + // Unlike plain Bcrypt, BcryptSha256 is *not* capped at 72 bytes by + // password_hashing.py - it HMACs the full password to a fixed-size + // digest before bcrypt ever sees it. This hash was generated from + // the full, untruncated 73-byte password. + let conf = mock_config(PasswordHashingAlgo::BcryptSha256, 255); + let python_hash = + "$bcrypt-sha256$v=2,t=2b,r=12$BL2N.GYa/h9LYksj.qtsb.$oUw7Xg.rbmPt8aXB2z544HOIQMrQbZ6"; + let full_73_byte_password = "A".repeat(73); + + assert!( + verify_password(&conf, &full_73_byte_password, python_hash) + .await + .unwrap(), + "Rust BcryptSha256 must not truncate at 72 bytes - that would diverge from Keystone Python" + ); + } + + #[tokio::test] + async fn test_bcrypt_sha256_matches_keystone_python_utf8_password() { + let conf = mock_config(PasswordHashingAlgo::BcryptSha256, 255); + let python_hash = + "$bcrypt-sha256$v=2,t=2b,r=12$eP5KRHawhX/K86TK3IOLoO$fpoVOwh9QvOLy1Y9GKxMkf.RnkfO60."; + + assert!( + verify_password(&conf, "🚀-rocket-password", python_hash) + .await + .unwrap(), + "Rust BcryptSha256 verification rejected a real Keystone Python hash of a UTF-8 password" + ); + } + + #[tokio::test] + async fn test_bcrypt_sha256_rejects_wrong_password() { + let conf = mock_config(PasswordHashingAlgo::BcryptSha256, 255); + let hash = hash_password(&conf, "correct_password").await.unwrap(); + + let result = verify_password(&conf, "wrong_password", &hash) + .await + .unwrap(); + assert!( + !result, + "BcryptSha256 incorrectly accepted a wrong password" + ); + } + + #[tokio::test] + async fn test_bcrypt_sha256_malformed_hash_returns_false_or_err() { + let conf = mock_config(PasswordHashingAlgo::BcryptSha256, 255); + + for malformed in [ + "$bcrypt-sha256$v=2,t=2b,r=12$shortsalt", // too few '$'-delimited segments + "$bcrypt-sha256$", // nearly empty + "not-even-a-hash-string", // no '$' at all + ] { + let result = verify_password(&conf, "anything", malformed).await; + assert!( + matches!(result, Ok(false)) || result.is_err(), + "Malformed hash `{malformed}` should fail safely, not panic" + ); + } + } + + #[tokio::test] + async fn test_verify_bcrypt_sha256_roundtrip() { + let conf = mock_config(PasswordHashingAlgo::BcryptSha256, 255); + let password = b"password123"; + let generated_hash = hash_password(&conf, password).await.unwrap(); + + let is_valid = verify_password(&conf, password, &generated_hash) + .await + .expect("Verification function failed"); + assert!( + is_valid, + "BcryptSha256 dynamically generated hash failed to verify against itself" + ); + } +} diff --git a/crates/core/src/common/password_hashing/mod.rs b/crates/core/src/common/password_hashing/mod.rs new file mode 100644 index 000000000..28491f12c --- /dev/null +++ b/crates/core/src/common/password_hashing/mod.rs @@ -0,0 +1,613 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +//! Password hashing with cross-compatibility to Python Keystone. +//! +//! Each algorithm lives in its own submodule mirroring the class layout in +//! `keystone/common/password_hashers/`, so the implementation and its unit +//! tests stay small and focused: +//! +//! - [`bcrypt`] - `bcrypt.py::Bcrypt` +//! - [`bcrypt_sha256`] - `bcrypt.py::Bcrypt_sha256` +//! - [`scrypt`] - `scrypt.py::Scrypt` +//! - [`pbkdf2`] - `pbkdf2.py::Sha512` +//! - [`plaintext`] - Rust-only extension, no Python counterpart +//! +//! This module holds the shared infrastructure: the [`PasswordHasher`] trait +//! every hasher implements, the dispatch in [`hash_password`] / +//! [`verify_password`], the dummy-hash cache, and the salt generator. + +use std::collections::HashMap; +use std::sync::OnceLock; +use thiserror::Error; +use tracing::debug; + +use rand::distr::{Alphanumeric, SampleString}; + +use openstack_keystone_config::{Config, PasswordHashingAlgo}; + +mod bcrypt; +mod bcrypt_sha256; +mod pbkdf2; +mod plaintext; +mod scrypt; + +use bcrypt::BcryptHasher; +use bcrypt_sha256::BcryptSha256Hasher; +use pbkdf2::Pbkdf2Sha512Hasher; +use plaintext::PlaintextHasher; +use scrypt::ScryptHasher; + +/// Number of random bytes used for a password salt. +/// +/// Keystone hardcodes a 16-byte salt for scrypt/pbkdf2 and bcrypt's salt is +/// likewise 16 bytes, so a single constant covers every hasher here. +const SALT_BYTES: usize = 16; + +/// Generate cryptographically random salt bytes. +/// +/// Centralized so the random source is a single-point change: some +/// deployments require a specific CSPRNG, and keeping salt generation in one +/// place means that requirement can be satisfied without touching every +/// individual hasher. +fn generate_salt() -> [u8; SALT_BYTES] { + rand::random() +} + +/// Password hashing related errors. +#[derive(Error, Debug)] +pub enum PasswordHashError { + /// Bcrypt error. + #[error(transparent)] + BCrypt { + // Absolute path: `bcrypt` alone would resolve to the sibling + // submodule of the same name, not the extern crate. + #[from] + source: ::bcrypt::BcryptError, + }, + + /// Crypto password-hash crate error (handles scrypt/pbkdf2 formatting). + #[error("Password hashing framework error: {0}")] + CryptoHash(String), + + /// Async task join error. + #[error(transparent)] + Join { + #[from] + source: tokio::task::JoinError, + }, +} + +// --------------------------------------------------------------------------- +// PasswordHasher trait - mirrors the hash()/verify() static-method pair +// every Python hasher class in keystone.common.password_hashers implements. +// Dispatch is always static (no dyn), so async fn in traits is safe here. +// --------------------------------------------------------------------------- + +trait PasswordHasher { + async fn hash(&self, conf: &Config, password: &[u8]) -> Result; + async fn verify( + &self, + conf: &Config, + password: &[u8], + hash: &str, + ) -> Result; +} + +// --------------------------------------------------------------------------- +// Shared helpers +// --------------------------------------------------------------------------- + +/// Verify the password length against algorithm constraints and truncate if necessary. +/// +/// Mirrors Keystone's own `password_hashing.py::verify_length_and_trunc_password`: +/// only `Bcrypt` is capped at 72 bytes (bcrypt's native input limit); every +/// other algorithm is left at the full `config_max_length`, since +/// `BcryptSha256`, `Scrypt` and `Pbkdf2Sha512` all first reduce the +/// password to a fixed-size digest/key and never hit bcrypt's 72-byte +/// limit directly. +fn verify_length_and_trunc_password<'a>( + password: &'a [u8], + algo: &PasswordHashingAlgo, + config_max_length: usize, +) -> &'a [u8] { + assert!(config_max_length > 0, "max_password_length must be > 0"); + let max_length = match algo { + PasswordHashingAlgo::Bcrypt => std::cmp::min(config_max_length, 72), + _ => config_max_length, + }; + + if password.len() > max_length { + debug!("Truncating password to the specified value"); + return &password[..max_length]; + } + password +} + +/// Determine which algorithm produced `hash` by inspecting its prefix. +/// +/// Falls back to `configured` for unrecognized prefixes (e.g. plaintext +/// stored by `PlaintextHasher`, or truly unknown formats). +fn detect_algo(hash: &str, configured: &PasswordHashingAlgo) -> PasswordHashingAlgo { + if hash.starts_with("$2b$") || hash.starts_with("$2a$") || hash.starts_with("$2y$") { + PasswordHashingAlgo::Bcrypt + } else if hash.starts_with("$bcrypt-sha256$") { + PasswordHashingAlgo::BcryptSha256 + } else if hash.starts_with("$scrypt$") { + PasswordHashingAlgo::Scrypt + } else if hash.starts_with("$pbkdf2-sha512$") { + PasswordHashingAlgo::Pbkdf2Sha512 + } else { + configured.clone() + } +} + +// --------------------------------------------------------------------------- +// Dummy-hash cache +// --------------------------------------------------------------------------- + +/// Safe dynamic thread-safe cache to handle runtime configuration reloads +/// and prevent timing side-channel attacks across different configurations. +/// +/// Uses `RwLock` rather than a sharded map (e.g. `DashMap`): key +/// cardinality here is bounded by the number of distinct +/// `(algorithm, rounds)` pairs in use, which is tiny (almost always 1, at +/// most a handful across a config-reload transition), so write-lock +/// contention on the shared map is not a practical concern. +struct DynamicDummyCache { + map: tokio::sync::RwLock>, +} + +static DUMMY_HASH_CACHE: OnceLock = OnceLock::new(); + +/// Internal helper to retrieve the global cache instance. +fn get_dummy_cache() -> &'static DynamicDummyCache { + DUMMY_HASH_CACHE.get_or_init(|| DynamicDummyCache { + map: tokio::sync::RwLock::new(HashMap::new()), + }) +} + +// --------------------------------------------------------------------------- +// Public API - signatures unchanged; callers outside this module are unaffected. +// --------------------------------------------------------------------------- + +/// Gets a cached dummy hash matching the precise parameters of the current +/// configuration profile, preventing timing side-channel variations. +pub async fn get_or_init_dummy_hash(conf: &Config) -> Result { + let algo = &conf.identity.password_hashing_algorithm; + let rounds = conf.identity.password_hash_rounds.unwrap_or(12); + let cache_key = format!("{:?}-{}", algo, rounds); + + let cache = get_dummy_cache(); + + // Fast path: read lock only. + { + let read_guard = cache.map.read().await; + if let Some(cached_hash) = read_guard.get(&cache_key) { + return Ok(cached_hash.clone()); + } + } + + // Compute outside the lock to avoid stalling other callers. + let new_hash = generate_dummy_hash(conf).await?; + + // Double-checked locking: re-check under write lock. If another concurrent + // caller already populated this key, keep their value. + let mut write_guard = cache.map.write().await; + if let Some(existing) = write_guard.get(&cache_key) { + return Ok(existing.clone()); + } + write_guard.insert(cache_key, new_hash.clone()); + + Ok(new_hash) +} + +/// Clear all cached dummy hashes. +/// +/// Call this whenever the Keystone configuration reloads (e.g. via +/// `ConfigManager::notify_tx`) so that stale `(algorithm, rounds)` entries +/// are not served after a config change. See `crates/keystone/src/bin/keystone.rs` +/// for the wiring. +pub async fn reset_dummy_hash_cache() { + get_dummy_cache().map.write().await.clear(); +} + +/// Generate a dummy password hash matching the configured algorithm. +pub async fn generate_dummy_hash(conf: &Config) -> Result { + // Uniformly distributed dummy password. Avoids the modulo bias of + // `byte % 95`, which skews the low end of the printable-ASCII range; + // not security-relevant on its own (this value is never authenticated + // against real input), but there's no reason to introduce non-uniform + // output when a uniform sampler is one call away. + let dummy_password: String = Alphanumeric.sample_string(&mut rand::rng(), 32); + + hash_password(conf, dummy_password).await +} + +/// Calculate password hash with the configuration defaults. +pub async fn hash_password>( + conf: &Config, + password: S, +) -> Result { + // Truncation uses the *configured* algorithm, not any algorithm detected + // from an existing hash string. This is the correct behaviour during + // algorithm migrations: a user whose hash is in the old format and whose + // password is longer than the new algorithm's limit must be truncated + // consistently with what Python Keystone would do. + let truncated = verify_length_and_trunc_password( + password.as_ref(), + &conf.identity.password_hashing_algorithm, + conf.identity.max_password_length, + ); + + match conf.identity.password_hashing_algorithm { + PasswordHashingAlgo::Bcrypt => BcryptHasher.hash(conf, truncated).await, + PasswordHashingAlgo::BcryptSha256 => BcryptSha256Hasher.hash(conf, truncated).await, + PasswordHashingAlgo::Pbkdf2Sha512 => Pbkdf2Sha512Hasher.hash(conf, truncated).await, + PasswordHashingAlgo::Scrypt => ScryptHasher.hash(conf, truncated).await, + PasswordHashingAlgo::None => PlaintextHasher.hash(conf, truncated).await, + } +} + +/// Verify the password matches the hashed value. +pub async fn verify_password, H: AsRef>( + conf: &Config, + password: P, + hash: H, +) -> Result { + let hash_str = hash.as_ref(); + + // Detect algorithm from the hash prefix for dispatch, but truncate using + // the *configured* algorithm. These two may differ during an algorithm + // migration (e.g. config changed to bcrypt_sha256, but user has an old + // bcrypt hash). Truncating by detected algo instead of configured algo + // would produce wrong results: a user with a >72-byte password whose + // old bcrypt hash was computed from the first 72 bytes would fail + // verification once the config switches to a non-truncating algorithm. + let detected = detect_algo(hash_str, &conf.identity.password_hashing_algorithm); + + let truncated = verify_length_and_trunc_password( + password.as_ref(), + &conf.identity.password_hashing_algorithm, + conf.identity.max_password_length, + ); + + match detected { + PasswordHashingAlgo::Bcrypt => BcryptHasher.verify(conf, truncated, hash_str).await, + PasswordHashingAlgo::BcryptSha256 => { + BcryptSha256Hasher.verify(conf, truncated, hash_str).await + } + PasswordHashingAlgo::Pbkdf2Sha512 => { + Pbkdf2Sha512Hasher.verify(conf, truncated, hash_str).await + } + PasswordHashingAlgo::Scrypt => ScryptHasher.verify(conf, truncated, hash_str).await, + PasswordHashingAlgo::None => PlaintextHasher.verify(conf, truncated, hash_str).await, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use openstack_keystone_config::PasswordHashingAlgo; + use rand::distr::{Alphanumeric, SampleString}; + use tracing_test::traced_test; + + pub(super) const TEST_PASSWORD: &str = "openstack123"; + + pub(super) fn mock_config( + algo: openstack_keystone_config::PasswordHashingAlgo, + max_len: usize, + ) -> Config { + let mut conf = Config::default(); + conf.identity.password_hashing_algorithm = algo; + conf.identity.password_hash_rounds = Some(12); + conf.identity.max_password_length = max_len; + conf + } + + // --- Core truncation & schema alignment tests --- + + #[test] + fn test_verify_length_and_trunc_password() { + let algo = PasswordHashingAlgo::Bcrypt; + assert_eq!( + b"abcdefg", + verify_length_and_trunc_password("abcdefg".as_bytes(), &algo, 70) + ); + assert_eq!( + b"abcd", + verify_length_and_trunc_password("abcdefg".as_bytes(), &algo, 4) + ); + assert_eq!( + b"\xE2\x98\x81a", + verify_length_and_trunc_password("☁abcdefg".as_bytes(), &algo, 4) + ); + } + + #[test] + fn test_verify_length_and_trunc_password_boundary() { + // Non-Bcrypt algorithm: no implicit 72-byte cap applies, so the only + // bound in play is `max_length` itself. + let algo = PasswordHashingAlgo::Pbkdf2Sha512; + let max_length = 10; + + let exactly_max = "a".repeat(max_length); + assert_eq!( + exactly_max.as_bytes(), + verify_length_and_trunc_password(exactly_max.as_bytes(), &algo, max_length), + "A password exactly at max_length must not be truncated" + ); + + let one_over = "a".repeat(max_length + 1); + let truncated = verify_length_and_trunc_password(one_over.as_bytes(), &algo, max_length); + assert_eq!( + truncated.len(), + max_length, + "A password one byte over max_length must be truncated to exactly max_length" + ); + } + + #[test] + #[should_panic(expected = "max_password_length must be > 0")] + fn test_zero_max_password_length_panics() { + let algo = PasswordHashingAlgo::Bcrypt; + // A max_password_length of 0 must never silently truncate every + // password to an empty (and therefore identical) value. This must + // panic loudly at config-validation time rather than allow a + // mass-auth-bypass to ship. + let _ = verify_length_and_trunc_password(b"anything", &algo, 0); + } + + #[tokio::test] + async fn test_truncation_behavior_differences() { + let long_password = "A".repeat(100); + let truncated_72_password = "A".repeat(72); + + // 1. Standard Bcrypt Truncation Check + let conf_bcrypt = mock_config(PasswordHashingAlgo::Bcrypt, 255); + let hash_bcrypt = hash_password(&conf_bcrypt, &long_password).await.unwrap(); + let is_valid_bcrypt = verify_password(&conf_bcrypt, &truncated_72_password, &hash_bcrypt) + .await + .unwrap(); + assert!( + is_valid_bcrypt, + "Bcrypt failed to truncate a 100-byte password to 72 bytes." + ); + + // 2. BcryptSha256 Arbitrary Length Check + let conf_bcrypt_sha256 = mock_config(PasswordHashingAlgo::BcryptSha256, 255); + let hash_bcrypt_sha256 = hash_password(&conf_bcrypt_sha256, &long_password) + .await + .unwrap(); + let is_valid_substring = verify_password( + &conf_bcrypt_sha256, + &truncated_72_password, + &hash_bcrypt_sha256, + ) + .await + .unwrap(); + assert!( + !is_valid_substring, + "BcryptSha256 incorrectly truncated the password to 72 bytes!" + ); + + let is_valid_full = + verify_password(&conf_bcrypt_sha256, &long_password, &hash_bcrypt_sha256) + .await + .unwrap(); + assert!( + is_valid_full, + "BcryptSha256 failed to verify the full 100-byte password sequence." + ); + } + + // --- Side-channel attack mitigation tests (dummy cache) --- + + #[tokio::test] + #[traced_test] + async fn test_generate_and_verify_dummy_hash_bcrypt() { + let conf = mock_config(PasswordHashingAlgo::Bcrypt, 255); + let dummy_hash = generate_dummy_hash(&conf).await.unwrap(); + + assert!( + dummy_hash.starts_with("$2b$"), + "Dummy hash should be a valid bcrypt hash" + ); + + let pass = Alphanumeric.sample_string(&mut rand::rng(), 32); + let result = verify_password(&conf, &pass, &dummy_hash).await.unwrap(); + + assert!( + !result, + "Dummy hash should not match a random password assignment" + ); + } + + #[tokio::test] + #[traced_test] + async fn test_generate_dummy_hash_none() { + let conf = mock_config(PasswordHashingAlgo::None, 255); + let dummy_hash = generate_dummy_hash(&conf).await.unwrap(); + + assert!(!dummy_hash.is_empty(), "Dummy hash should not be empty"); + + let pass = Alphanumeric.sample_string(&mut rand::rng(), 32); + let result = verify_password(&conf, &pass, &dummy_hash).await.unwrap(); + assert!(!result); + } + + #[tokio::test] + async fn test_dummy_hash_is_actually_cached() { + let conf = mock_config(PasswordHashingAlgo::Bcrypt, 255); + + let first_fetch = get_or_init_dummy_hash(&conf).await.unwrap(); + let second_fetch = get_or_init_dummy_hash(&conf).await.unwrap(); + + assert_eq!( + first_fetch, second_fetch, + "The dynamic dummy cache failed to preserve the identical hash reference string across runs!" + ); + } + + #[tokio::test] + async fn test_dummy_cache_concurrent_cold_start() { + // Regression test: N concurrent callers racing on a cold cache key + // must all observe the *same* cached hash, not each independently + // compute and overwrite it with a different value. + // + // Uses password_hash_rounds=4 (bcrypt minimum) to build a cache key + // ("Bcrypt-4") that no other test in this module uses, ensuring the + // cache is genuinely cold regardless of test execution order. + let mut conf = mock_config(PasswordHashingAlgo::Bcrypt, 255); + conf.identity.password_hash_rounds = Some(4); // unique key: "Bcrypt-4" + let conf = std::sync::Arc::new(conf); + + let mut handles = Vec::new(); + for _ in 0..8 { + let conf = conf.clone(); + handles.push(tokio::spawn(async move { + get_or_init_dummy_hash(&conf).await.unwrap() + })); + } + + let mut results = Vec::new(); + for handle in handles { + results.push(handle.await.unwrap()); + } + + let first = &results[0]; + assert!( + results.iter().all(|hash| hash == first), + "Concurrent cold-start callers must all observe the same cached dummy hash" + ); + } + + #[tokio::test] + async fn test_reset_dummy_hash_cache() { + let conf = mock_config(PasswordHashingAlgo::Bcrypt, 255); + + // Populate the cache first. + let before = get_or_init_dummy_hash(&conf).await.unwrap(); + + // Reset should clear the cache so the next call recomputes. + reset_dummy_hash_cache().await; + + // After reset, cache is cold; the new hash may differ (bcrypt is randomized). + // We just need to confirm the call succeeds and the cache is re-populated. + let after = get_or_init_dummy_hash(&conf).await.unwrap(); + assert!( + after.starts_with("$2b$"), + "After cache reset, dummy hash should still be a valid bcrypt hash" + ); + // The two hashes are likely different (random salt), but we can't assert + // inequality deterministically. Just verify both are structurally valid. + let _ = before; + } + + // --- Bidirectional cross-verification against the real Python hashers --- + // + // These tests close the loop the per-algorithm KAT vectors only check one + // way: the KATs prove Rust can *verify* a Python-produced hash; the tests + // below prove Python can *verify* a Rust-produced hash. They shell out to + // tools/cross_verify.py and require `pip install keystone`. They skip + // silently when `import keystone` is unavailable (the common case in local + // dev without a Python install). To run locally: + // + // pip install keystone + // cargo test -p openstack-keystone-core -- cross_verify + + /// Run a Rust-produced hash through tools/cross_verify.py against the Python + /// hashers. Returns the script's exit code (0 = verified, 1 = rejected, + /// 2 = error). Returns `None` when the `keystone` Python package is not + /// installed, so the caller can skip. + async fn python_cross_verify(algo_name: &str, password: &str, hash: &str) -> Option { + // Skip if `import keystone` fails — no Python Keystone installed. + let importable = tokio::process::Command::new("python") + .args(["-c", "import keystone"]) + .status() + .await + .map(|s| s.success()) + .unwrap_or(false); + if !importable { + return None; + } + + // cross_verify.py lives in /tools; this crate is /crates/core. + let script = + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tools/cross_verify.py"); + + let status = tokio::process::Command::new("python") + .arg(script) + .arg(algo_name) + .arg(password) + .arg(hash) + .status() + .await + .expect("failed to spawn python cross_verify.py"); + + Some(status.code().unwrap_or(2)) + } + + #[tokio::test] + async fn test_cross_verify_bcrypt() { + let conf = mock_config(PasswordHashingAlgo::Bcrypt, 255); + let password = "openstack123"; + let hash = hash_password(&conf, password).await.unwrap(); + + match python_cross_verify("bcrypt", password, &hash).await { + None => return, // no Python checkout configured - skip + Some(0) => {} + Some(code) => panic!("Python rejected Rust bcrypt hash (exit {code}): {hash}"), + } + } + + #[tokio::test] + async fn test_cross_verify_bcrypt_sha256() { + let conf = mock_config(PasswordHashingAlgo::BcryptSha256, 255); + let password = "openstack123"; + let hash = hash_password(&conf, password).await.unwrap(); + + match python_cross_verify("bcrypt_sha256", password, &hash).await { + None => return, + Some(0) => {} + Some(code) => panic!("Python rejected Rust bcrypt_sha256 hash (exit {code}): {hash}"), + } + } + + #[tokio::test] + async fn test_cross_verify_scrypt() { + let conf = mock_config(PasswordHashingAlgo::Scrypt, 255); + let password = "openstack123"; + let hash = hash_password(&conf, password).await.unwrap(); + + match python_cross_verify("scrypt", password, &hash).await { + None => return, + Some(0) => {} + Some(code) => panic!("Python rejected Rust scrypt hash (exit {code}): {hash}"), + } + } + + #[tokio::test] + async fn test_cross_verify_pbkdf2_sha512() { + let conf = mock_config(PasswordHashingAlgo::Pbkdf2Sha512, 255); + let password = "openstack123"; + let hash = hash_password(&conf, password).await.unwrap(); + + match python_cross_verify("pbkdf2_sha512", password, &hash).await { + None => return, + Some(0) => {} + Some(code) => panic!("Python rejected Rust pbkdf2_sha512 hash (exit {code}): {hash}"), + } + } +} diff --git a/crates/core/src/common/password_hashing/pbkdf2.rs b/crates/core/src/common/password_hashing/pbkdf2.rs new file mode 100644 index 000000000..ceb32c186 --- /dev/null +++ b/crates/core/src/common/password_hashing/pbkdf2.rs @@ -0,0 +1,213 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +//! PBKDF2-HMAC-SHA512 hasher - mirrors `pbkdf2.py::Sha512`. +//! +//! The pbkdf2 crate (v0.12) uses sha2 v0.10 / digest v0.10 internally, which is +//! a different type family from the workspace's sha2 v0.11 / hmac v0.13. To +//! avoid the resulting incompatible trait bounds, the algorithm is implemented +//! directly here on top of the workspace HMAC. + +use base64::{Engine as _, engine::general_purpose::STANDARD_NO_PAD}; +// KeyInit provides new_from_slice; Mac provides update/finalize. +use hmac::{Hmac, KeyInit, Mac}; +use openstack_keystone_config::Config; +use subtle::ConstantTimeEq; +use tokio::task; + +use super::{PasswordHashError, PasswordHasher, generate_salt}; + +type HmacSha512 = Hmac; + +/// Length in bytes of a SHA-512 digest, used by the PBKDF2-SHA512 checksum. +const SHA512_OUTPUT_BYTES: usize = 64; + +pub(super) struct Pbkdf2Sha512Hasher; + +impl PasswordHasher for Pbkdf2Sha512Hasher { + async fn hash(&self, conf: &Config, password: &[u8]) -> Result { + // mirrors keystone/common/password_hashers/pbkdf2.py::Sha512.hash() + // Wire format: $pbkdf2-sha512$$$ + // salt and digest are standard base64 no-pad (binascii.b2a_base64().rstrip("=\n")). + let password_bytes = password.to_vec(); + let rounds = conf.identity.password_hash_rounds.unwrap_or(25000); + let hash = task::spawn_blocking(move || { + let salt = generate_salt(); + let mut digest = [0u8; SHA512_OUTPUT_BYTES]; + pbkdf2_hmac_sha512(&password_bytes, &salt, rounds as u32, &mut digest)?; + let salt_str = STANDARD_NO_PAD.encode(salt); + let digest_str = STANDARD_NO_PAD.encode(digest); + Ok::(format!( + "$pbkdf2-sha512${rounds}${salt_str}${digest_str}" + )) + }) + .await??; + Ok(hash) + } + + async fn verify( + &self, + _conf: &Config, + password: &[u8], + hash: &str, + ) -> Result { + // mirrors keystone/common/password_hashers/pbkdf2.py::Sha512.verify() + // Parses the embedded rounds from the hash string. + // replace('.', "+") handles old Passlib-era hashes that used '.' instead of '+'. + let password_bytes = password.to_vec(); + let hash_str = hash.to_string(); + let res = task::spawn_blocking(move || { + // Split "$pbkdf2-sha512$$$" on '$': + // parts = ["", "pbkdf2-sha512", rounds_str, salt_b64, digest_b64] + let parts: Vec<&str> = hash_str.split('$').collect(); + if parts.len() != 5 || parts[1] != "pbkdf2-sha512" { + return Err(PasswordHashError::CryptoHash( + "Unrecognized PBKDF2 hash format".into(), + )); + } + + let rounds: u32 = parts[2].parse().map_err(|_| { + PasswordHashError::CryptoHash( + "Invalid PBKDF2 rounds configuration parameter".into(), + ) + })?; + + let salt_str = parts[3].replace('.', "+"); + let digest_str = parts[4].replace('.', "+"); + + let salt = STANDARD_NO_PAD + .decode(salt_str.as_bytes()) + .map_err(|_| PasswordHashError::CryptoHash("Invalid PBKDF2 salt".into()))?; + + let expected_digest = STANDARD_NO_PAD.decode(digest_str.as_bytes()).map_err(|_| { + PasswordHashError::CryptoHash("Invalid PBKDF2 digest encoding".into()) + })?; + + if expected_digest.len() != SHA512_OUTPUT_BYTES { + return Err(PasswordHashError::CryptoHash( + "Invalid PBKDF2-SHA512 checksum buffer bounds".into(), + )); + } + + let mut computed_digest = [0u8; SHA512_OUTPUT_BYTES]; + pbkdf2_hmac_sha512(&password_bytes, &salt, rounds, &mut computed_digest)?; + + Ok(computed_digest + .as_slice() + .ct_eq(expected_digest.as_slice()) + .into()) + }) + .await??; + Ok(res) + } +} + +/// PBKDF2-HMAC-SHA512 using the workspace's hmac v0.13 + sha2 v0.11. +/// +/// Output is exactly SHA512_OUTPUT_BYTES (64), i.e. one PBKDF2 block. Follows +/// RFC 2898 section 5.2 directly. +fn pbkdf2_hmac_sha512( + password: &[u8], + salt: &[u8], + iterations: u32, + output: &mut [u8; SHA512_OUTPUT_BYTES], +) -> Result<(), PasswordHashError> { + // U1 = HMAC(password, salt || INT(1)) + let mut u = { + let mut mac = HmacSha512::new_from_slice(password) + .map_err(|e| PasswordHashError::CryptoHash(format!("PBKDF2 HMAC init: {e}")))?; + mac.update(salt); + // Block index is big-endian 4-byte integer, starting at 1. + mac.update(&1u32.to_be_bytes()); + let result = mac.finalize().into_bytes(); + let mut arr = [0u8; SHA512_OUTPUT_BYTES]; + arr.copy_from_slice(&result); + arr + }; + + // Seed the output with U1, then XOR in U2..Uc. + output.copy_from_slice(&u); + + for _ in 1..iterations { + let mut mac = HmacSha512::new_from_slice(password) + .map_err(|e| PasswordHashError::CryptoHash(format!("PBKDF2 HMAC iter: {e}")))?; + mac.update(&u); + let result = mac.finalize().into_bytes(); + let mut next_u = [0u8; SHA512_OUTPUT_BYTES]; + next_u.copy_from_slice(&result); + u = next_u; + // XOR accumulate: DK[i] ^= Uc[i] + for (a, b) in output.iter_mut().zip(u.iter()) { + *a ^= b; + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::super::tests::{TEST_PASSWORD, mock_config}; + use super::super::{hash_password, verify_password}; + use openstack_keystone_config::PasswordHashingAlgo; + + #[tokio::test] + async fn test_pbkdf2_sha512_matches_keystone_python_hash() { + let conf = mock_config(PasswordHashingAlgo::Pbkdf2Sha512, 255); + let python_hash = "$pbkdf2-sha512$25000$z1PryJDTkFQEQN/E5K0nLQ$CzQ9XdgqUzdTOTjUSRGMN9r9O7WQmiyUl4fVA2jwJpB6zSXEonqw9Jfg4WImljlZ7fRPPFXmZZVdVhnCTJZymg"; + + assert!( + verify_password(&conf, TEST_PASSWORD, python_hash) + .await + .unwrap(), + "Rust PBKDF2-SHA512 verification rejected a real Keystone Python PBKDF2-SHA512 hash" + ); + } + + #[tokio::test] + async fn test_pbkdf2_roundtrip_default_rounds() { + let mut conf = mock_config(PasswordHashingAlgo::Pbkdf2Sha512, 255); + conf.identity.password_hash_rounds = None; // exercise the default (25000) + let password = "pbkdf2_roundtrip_password"; + + let hashed = hash_password(&conf, password).await.unwrap(); + assert!( + hashed.starts_with("$pbkdf2-sha512$25000$"), + "PBKDF2 hash should embed the default round count" + ); + assert!( + verify_password(&conf, password, &hashed).await.unwrap(), + "PBKDF2 roundtrip failed with default rounds" + ); + } + + #[tokio::test] + async fn test_pbkdf2_roundtrip_non_default_rounds() { + // Exercises the case where password_hash_rounds is explicitly configured. + // This is the scenario where bugs in reading the config field hide. + let mut conf = mock_config(PasswordHashingAlgo::Pbkdf2Sha512, 255); + conf.identity.password_hash_rounds = Some(10000); + let password = "pbkdf2_custom_rounds"; + + let hashed = hash_password(&conf, password).await.unwrap(); + assert!( + hashed.starts_with("$pbkdf2-sha512$10000$"), + "PBKDF2 hash must embed the configured round count, not the default" + ); + assert!( + verify_password(&conf, password, &hashed).await.unwrap(), + "PBKDF2 roundtrip failed with non-default rounds" + ); + } +} diff --git a/crates/core/src/common/password_hashing/plaintext.rs b/crates/core/src/common/password_hashing/plaintext.rs new file mode 100644 index 000000000..a30164ca6 --- /dev/null +++ b/crates/core/src/common/password_hashing/plaintext.rs @@ -0,0 +1,84 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +//! Plaintext "hasher" - a Rust-only extension with no Python counterpart in +//! Keystone's `SUPPORTED_HASHERS`. Stores passwords in plaintext; must never +//! be used in production. Selected via `PasswordHashingAlgo::None`. + +use openstack_keystone_config::Config; +use subtle::ConstantTimeEq; +use tracing::warn; + +use super::{PasswordHashError, PasswordHasher}; + +pub(super) struct PlaintextHasher; + +impl PasswordHasher for PlaintextHasher { + async fn hash(&self, _conf: &Config, password: &[u8]) -> Result { + warn!( + "PasswordHashingAlgo::None is active - passwords are stored and compared in plaintext" + ); + // Reject invalid UTF-8 outright to prevent collisions from lossy conversion. + String::from_utf8(password.to_vec()) + .map_err(|_| PasswordHashError::CryptoHash("Invalid UTF-8 sequence in password".into())) + } + + async fn verify( + &self, + _conf: &Config, + password: &[u8], + hash: &str, + ) -> Result { + Ok(password.ct_eq(hash.as_bytes()).into()) + } +} + +#[cfg(test)] +mod tests { + use super::super::tests::mock_config; + use super::super::{hash_password, verify_password}; + use openstack_keystone_config::PasswordHashingAlgo; + + #[tokio::test] + async fn test_none_algorithm_hash_then_verify_roundtrip() { + let conf = mock_config(PasswordHashingAlgo::None, 255); + let password = "plaintext_password"; + + let hashed = hash_password(&conf, password).await.unwrap(); + assert_eq!( + hashed, password, + "None algorithm must store the password unchanged" + ); + + assert!(verify_password(&conf, password, &hashed).await.unwrap()); + assert!( + !verify_password(&conf, "wrong_password", &hashed) + .await + .unwrap() + ); + } + + #[tokio::test] + async fn test_reject_invalid_utf8() { + let conf = mock_config(PasswordHashingAlgo::None, 72); + let invalid_utf8_password = b"bad\xFFpassword"; + + // Ensure our strict UTF-8 validation safely rejects invalid sequence vulnerabilities + let hash_result = hash_password(&conf, invalid_utf8_password).await; + assert!( + hash_result.is_err(), + "None algorithm should reject invalid UTF-8 strings during hash generation" + ); + } +} diff --git a/crates/core/src/common/password_hashing/scrypt.rs b/crates/core/src/common/password_hashing/scrypt.rs new file mode 100644 index 000000000..28e7b9112 --- /dev/null +++ b/crates/core/src/common/password_hashing/scrypt.rs @@ -0,0 +1,161 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +//! Scrypt hasher - mirrors `keystone/common/password_hashers/scrypt.py::Scrypt`. + +use base64::{Engine as _, engine::general_purpose::STANDARD_NO_PAD}; +use openstack_keystone_config::Config; +use subtle::ConstantTimeEq; +use tokio::task; + +use super::{PasswordHashError, PasswordHasher, generate_salt}; + +pub(super) struct ScryptHasher; + +impl PasswordHasher for ScryptHasher { + async fn hash(&self, _conf: &Config, password: &[u8]) -> Result { + // mirrors keystone/common/password_hashers/scrypt.py::Scrypt.hash() + // Python hardcodes: n=2**16 (ln=16), r=8, p=1, salt_size=16, output=32 bytes. + // scrypt_block_size / scrypt_parallelism / salt_bytesize config fields are + // not yet in IdentityProvider - use Keystone's own defaults until they are added. + let password_bytes = password.to_vec(); + let hash = task::spawn_blocking(move || { + let salt = generate_salt(); + // Params::new(log_n, r, p, output_len): ln=16 means n=2^16=65536. + let params = ::scrypt::Params::new(16, 8, 1, 32) + .map_err(|e| PasswordHashError::CryptoHash(e.to_string()))?; + let mut digest = vec![0u8; 32]; + ::scrypt::scrypt(&password_bytes, &salt, ¶ms, &mut digest) + .map_err(|e| PasswordHashError::CryptoHash(e.to_string()))?; + // Python uses binascii.b2a_base64(x).rstrip(b"=\n") - standard base64 no-pad. + let salt_str = STANDARD_NO_PAD.encode(salt); + let digest_str = STANDARD_NO_PAD.encode(&digest); + Ok::(format!( + "$scrypt$ln=16,r=8,p=1${salt_str}${digest_str}" + )) + }) + .await??; + Ok(hash) + } + + async fn verify( + &self, + _conf: &Config, + password: &[u8], + hash: &str, + ) -> Result { + // mirrors keystone/common/password_hashers/scrypt.py::Scrypt.verify() + // Parses the embedded ln/r/p params from the hash string so this path + // correctly verifies both hashes produced by this hasher (ln=16) and any + // old hashes with different params. + let password_bytes = password.to_vec(); + let hash_str = hash.to_string(); + let res = task::spawn_blocking(move || -> Result { + // Strip leading '$', split on '$': ["scrypt", "ln=N,r=R,p=P", salt_b64, digest_b64] + let parts: Vec<&str> = hash_str[1..].split('$').collect(); + if parts.len() != 4 { + return Ok(false); + } + let (params_str, salt_b64, digest_b64) = (parts[1], parts[2], parts[3]); + + // Parse ln/r/p with Keystone defaults as fallback. + let (mut ln, mut r, mut p) = (16u8, 8u32, 1u32); + for seg in params_str.split(',') { + if let Some(v) = seg.strip_prefix("ln=") { + ln = v + .parse() + .map_err(|_| PasswordHashError::CryptoHash("Invalid scrypt ln".into()))?; + } else if let Some(v) = seg.strip_prefix("r=") { + r = v + .parse() + .map_err(|_| PasswordHashError::CryptoHash("Invalid scrypt r".into()))?; + } else if let Some(v) = seg.strip_prefix("p=") { + p = v + .parse() + .map_err(|_| PasswordHashError::CryptoHash("Invalid scrypt p".into()))?; + } + } + + // replace('.', "+") handles old Passlib-era hashes that used '.' in place of '+'. + let salt = STANDARD_NO_PAD + .decode(salt_b64.replace('.', "+").as_bytes()) + .map_err(|_| PasswordHashError::CryptoHash("Invalid scrypt salt".into()))?; + let expected = STANDARD_NO_PAD + .decode(digest_b64.replace('.', "+").as_bytes()) + .map_err(|_| PasswordHashError::CryptoHash("Invalid scrypt digest".into()))?; + + let params = ::scrypt::Params::new(ln, r, p, expected.len()) + .map_err(|e| PasswordHashError::CryptoHash(e.to_string()))?; + let mut computed = vec![0u8; expected.len()]; + ::scrypt::scrypt(&password_bytes, &salt, ¶ms, &mut computed) + .map_err(|e| PasswordHashError::CryptoHash(e.to_string()))?; + + Ok(computed.as_slice().ct_eq(expected.as_slice()).into()) + }) + .await??; + Ok(res) + } +} + +#[cfg(test)] +mod tests { + use super::super::tests::{TEST_PASSWORD, mock_config}; + use super::super::{hash_password, verify_password}; + use openstack_keystone_config::PasswordHashingAlgo; + + #[tokio::test] + async fn test_scrypt_matches_keystone_python_hash() { + // Intentional algorithm mismatch in config: exercises auto-detection by + // hash prefix in verify_password. + let conf = mock_config(PasswordHashingAlgo::Bcrypt, 255); + let python_hash = "$scrypt$ln=16,r=8,p=1$Gx7wZNue5sPNsfTOmI4YNg$umTMUw1tH3HhQBqHUG9tEr7x6RxfyVgNty/COb+m1IM"; + + assert!( + verify_password(&conf, TEST_PASSWORD, python_hash) + .await + .unwrap(), + "Rust Scrypt verification rejected a real Keystone Python Scrypt hash" + ); + } + + #[tokio::test] + async fn test_scrypt_hash_then_verify_roundtrip() { + let conf = mock_config(PasswordHashingAlgo::Scrypt, 255); + let password = "scrypt_roundtrip_password"; + + let hashed = hash_password(&conf, password).await.unwrap(); + assert!( + verify_password(&conf, password, &hashed).await.unwrap(), + "Scrypt hash_password output failed to verify against the same password" + ); + assert!( + !verify_password(&conf, "wrong_password", &hashed) + .await + .unwrap(), + "Scrypt verification incorrectly accepted a wrong password" + ); + } + + #[tokio::test] + async fn test_scrypt_hash_format_matches_python() { + // Verify the wire format prefix matches what Python emits: + // $scrypt$ln=16,r=8,p=1$$ + let conf = mock_config(PasswordHashingAlgo::Scrypt, 255); + let hashed = hash_password(&conf, "any_password").await.unwrap(); + assert!( + hashed.starts_with("$scrypt$ln=16,r=8,p=1$"), + "Scrypt hash format must match Python Keystone's prefix; got: {hashed}" + ); + } +} diff --git a/crates/identity-driver-sql/src/authenticate.rs b/crates/identity-driver-sql/src/authenticate.rs index 5c03b1615..b1291f903 100644 --- a/crates/identity-driver-sql/src/authenticate.rs +++ b/crates/identity-driver-sql/src/authenticate.rs @@ -74,10 +74,12 @@ pub async fn authenticate_by_password( // attacker from distinguishing between "user not found" and "wrong // password" via timing analysis. if !user_found { - let dummy_hash = password_hashing::generate_dummy_hash(config) + // Fetch the pre-calculated dummy hash instantly from the cache + let dummy_hash = password_hashing::get_or_init_dummy_hash(config) .await .map_err(IdentityProviderError::password_hash)?; - let _ = password_hashing::verify_password(config, &auth.password, &dummy_hash) + + let _ = password_hashing::verify_password(config, &auth.password, dummy_hash) .await .map_err(IdentityProviderError::password_hash)?; return Err(AuthenticationError::UserNameOrPasswordWrong.into()); diff --git a/crates/keystone/src/bin/keystone.rs b/crates/keystone/src/bin/keystone.rs index dd69ab89a..30cfea17a 100644 --- a/crates/keystone/src/bin/keystone.rs +++ b/crates/keystone/src/bin/keystone.rs @@ -244,6 +244,16 @@ async fn main() -> Result<(), Report> { spawn(cleanup(cloned_token, shared_state.clone())); + // Reset the dummy-password-hash cache whenever the configuration is + // hot-reloaded. The cache is keyed by (algorithm, rounds); if the operator + // changes `password_hashing_algorithm` or `password_hash_rounds` at runtime, + // stale entries would otherwise keep being served and reintroduce the very + // timing side-channel the dummy hash exists to close. + spawn(reset_dummy_hash_on_reload( + token.clone(), + shared_state.clone(), + )); + shared_state .event_dispatcher .subscribe(Arc::new(ApplicationCredentialHook::new( @@ -548,6 +558,48 @@ async fn cleanup(cancel: CancellationToken, state: ServiceState) { } } +/// Clear the dummy-password-hash cache on every configuration reload. +/// +/// Subscribes to `ConfigManager::notify_tx` — the broadcast channel the config +/// watcher fires `()` on after each successful `Config::load_all()`. On every +/// notification we drop all cached `(algorithm, rounds)` dummy hashes so the +/// next authentication of a non-existent user recomputes one matching the new +/// configuration. A lagged receiver (more than the channel's 16-slot buffer of +/// reloads occurred between ticks) is treated like a normal reload: we still +/// clear the cache, which is always safe. +async fn reset_dummy_hash_on_reload(cancel: CancellationToken, state: ServiceState) { + let mut reload_rx = state.config_manager.notify_tx.subscribe(); + loop { + tokio::select! { + recv = reload_rx.recv() => { + match recv { + Ok(()) => { + debug!("Configuration reloaded; clearing dummy-hash cache"); + openstack_keystone_core::common::password_hashing::reset_dummy_hash_cache() + .await; + } + Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => { + // We fell behind the reload stream; the cache may hold + // entries for a superseded config. Clearing is the safe + // response regardless of how many ticks we missed. + warn!(skipped, "Lagged behind config reloads; clearing dummy-hash cache"); + openstack_keystone_core::common::password_hashing::reset_dummy_hash_cache() + .await; + } + Err(tokio::sync::broadcast::error::RecvError::Closed) => { + // Sender dropped — config manager is gone, nothing more to do. + break; + } + } + } + () = cancel.cancelled() => { + info!("Cancellation requested. Stopping dummy-hash reset task."); + break; + } + } + } +} + /// Install shutdown and interrupt signal handler. async fn shutdown_signal(state: ServiceState) { let ctrl_c = async { diff --git a/tools/cross_verify.py b/tools/cross_verify.py new file mode 100644 index 000000000..33519a16b --- /dev/null +++ b/tools/cross_verify.py @@ -0,0 +1,70 @@ +"""Verify a Rust-produced hash against the real (non-Passlib) Keystone hasher +classes. + +Usage: + cross_verify.py + +Exit codes: + 0 — verified + 1 — rejected (hash is structurally valid but password does not match) + 2 — error (unknown algorithm, malformed hash, import failure, etc.) + +Run from a real Keystone Python checkout so the imports resolve: + cd ~/Projects/openstack/keystone + python tools/cross_verify.py bcrypt "openstack123" "" + +Algorithms: bcrypt, bcrypt_sha256, scrypt, pbkdf2_sha512 +""" + +import os +import sys + +# When invoked as `python /abs/path/cross_verify.py`, Python sets sys.path[0] +# to the script's directory, not the working directory. Insert cwd explicitly +# so that `import keystone` finds the checkout the caller placed us in via +# current_dir (or by cd-ing before running the script directly). +sys.path.insert(0, os.getcwd()) + + +def main() -> int: + if len(sys.argv) != 4: + print( + f"Usage: {sys.argv[0]} ", + file=sys.stderr, + ) + return 2 + + algo, password, hashed = sys.argv[1], sys.argv[2].encode("utf-8"), sys.argv[3] + + # Import after arg-check so usage errors surface before slow Keystone imports. + try: + from keystone.common.password_hashers import bcrypt, pbkdf2, scrypt + except ImportError as exc: + print( + f"Import failed — run from a real Keystone checkout: {exc}", + file=sys.stderr, + ) + return 2 + + hashers = { + "bcrypt": bcrypt.Bcrypt, + "bcrypt_sha256": bcrypt.Bcrypt_sha256, + "scrypt": scrypt.Scrypt, + "pbkdf2_sha512": pbkdf2.Sha512, + } + + hasher = hashers.get(algo) + if hasher is None: + print(f"Unknown algorithm: {algo!r}. Known: {list(hashers)}", file=sys.stderr) + return 2 + + try: + return 0 if hasher.verify(password, hashed) else 1 + except Exception as exc: + # Surfaces real Keystone's own error behaviour for malformed hashes. + print(f"verify raised: {exc}", file=sys.stderr) + return 2 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/generate_kats.py b/tools/generate_kats.py new file mode 100644 index 000000000..338b12635 --- /dev/null +++ b/tools/generate_kats.py @@ -0,0 +1,74 @@ +"""Known-Answer Test (KAT) vector generator for password_hashing.rs. + +Produces JSON-encoded hash strings by calling the real (non-Passlib) Keystone +Python hasher classes, applying the same pre-dispatch truncation policy that +Rust's verify_length_and_trunc_password() implements. Output is hand-copied +into Rust #[tokio::test] KAT cases in crates/core/src/common/password_hashing.rs. + +Must be run from a real Keystone Python checkout so the imports resolve: + cd ~/Projects/openstack/keystone + python tools/generate_kats.py + +Do NOT use Passlib for generation — Keystone has not used Passlib for over a +year and the wire formats differ. +""" + +import json +import sys + +# 1. Initialize Oslo Config BEFORE importing Keystone modules +from oslo_config import cfg +CONF = cfg.CONF + +# Register the exact options Keystone's password hashers look for +identity_opts = [ + cfg.StrOpt('password_hashing_algorithm', default='bcrypt'), + cfg.IntOpt('password_hash_rounds', default=12), +] +CONF.register_opts(identity_opts, group='identity') + +# 2. Now import the native Keystone hashers safely +from keystone.common.password_hashers import bcrypt as bcrypt_mod +from keystone.common.password_hashers import pbkdf2 as pbkdf2_mod +from keystone.common.password_hashers import scrypt as scrypt_mod + +# Mirrors Rust's verify_length_and_trunc_password() in password_hashing.rs. +# Must be applied to every password before hashing so the vectors match what +# real hash_password() persists. The 72-byte and 73-byte boundary cases are +# the critical ones: Bcrypt truncates there, all other algorithms do not. +BCRYPT_MAX_LENGTH = 72 + +def truncate(pwd: bytes, alg_name: str, max_password_length: int = 4096) -> bytes: + max_length = ( + BCRYPT_MAX_LENGTH + if alg_name == "Bcrypt" and max_password_length > BCRYPT_MAX_LENGTH + else max_password_length + ) + return pwd[:max_length] if len(pwd) > max_length else pwd + + +def generate(): + pwd = b"openstack123" + + # Map algorithm names to the exact hasher classes the maintainer provided. + hashers = { + "Bcrypt": bcrypt_mod.Bcrypt, + "BcryptSha256": bcrypt_mod.Bcrypt_sha256, + "Pbkdf2Sha512": pbkdf2_mod.Sha512, + "Scrypt": scrypt_mod.Scrypt, + } + + results = {} + for name, hasher_cls in hashers.items(): + try: + # Apply the same truncation Rust applies before dispatch. + truncated = truncate(pwd, name) + results[name] = hasher_cls.hash(truncated) + except Exception as e: + print(f"Error executing {name}: {e}", file=sys.stderr) + + print(json.dumps(results, indent=2)) + + +if __name__ == "__main__": + generate()