Skip to main content

openstack_keystone_core/common/
password_hashing.rs

1// Licensed under the Apache License, Version 2.0 (the "License");
2// you may not use this file except in compliance with the License.
3// You may obtain a copy of the License at
4//
5//     http://www.apache.org/licenses/LICENSE-2.0
6//
7// Unless required by applicable law or agreed to in writing, software
8// distributed under the License is distributed on an "AS IS" BASIS,
9// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
10// See the License for the specific language governing permissions and
11// limitations under the License.
12//
13// SPDX-License-Identifier: Apache-2.0
14
15use std::cmp::max;
16use std::str;
17use thiserror::Error;
18use tokio::task;
19use tracing::warn;
20
21use crate::config::{Config, PasswordHashingAlgo};
22
23/// Password hashing related errors.
24#[derive(Error, Debug)]
25pub enum PasswordHashError {
26    /// Bcrypt error.
27    #[error(transparent)]
28    BCrypt {
29        /// The source of the error.
30        #[from]
31        source: bcrypt::BcryptError,
32    },
33
34    /// Async task join error.
35    #[error(transparent)]
36    Join {
37        /// The source of the error.
38        #[from]
39        source: tokio::task::JoinError,
40    },
41
42    /// Non UTF8 data.
43    #[error(transparent)]
44    Utf8 {
45        /// The source of the error.
46        #[from]
47        source: str::Utf8Error,
48    },
49}
50
51fn verify_length_and_trunc_password(password: &[u8], max_length: usize) -> &[u8] {
52    if password.len() > max_length {
53        warn!("Truncating password to the specified value");
54        return &password[..max_length];
55    }
56    password
57}
58
59/// Calculate password hash with the configuration defaults.
60pub async fn hash_password<S: AsRef<[u8]>>(
61    conf: &Config,
62    password: S,
63) -> Result<String, PasswordHashError> {
64    match conf.identity.password_hashing_algorithm {
65        PasswordHashingAlgo::Bcrypt => {
66            let password_bytes = verify_length_and_trunc_password(
67                password.as_ref(),
68                max(conf.identity.max_password_length, 72),
69            )
70            .to_owned();
71            let rounds = conf.identity.password_hash_rounds.unwrap_or(12);
72            let hash =
73                task::spawn_blocking(move || bcrypt::hash(password_bytes, rounds as u32)).await??;
74            Ok(hash)
75        }
76        //#[cfg(test)]
77        PasswordHashingAlgo::None => Ok(str::from_utf8(password.as_ref())?.to_string()),
78    }
79}
80
81/// Verify the password matches the hashed value.
82pub async fn verify_password<P: AsRef<[u8]>, H: AsRef<str>>(
83    conf: &Config,
84    password: P,
85    hash: H,
86) -> Result<bool, PasswordHashError> {
87    match conf.identity.password_hashing_algorithm {
88        PasswordHashingAlgo::Bcrypt => {
89            let password_bytes = verify_length_and_trunc_password(
90                password.as_ref(),
91                max(conf.identity.max_password_length, 72),
92            )
93            .to_owned();
94            let password_hash = hash.as_ref().to_string();
95            // Do not block the main thread with a definitely long running call.
96            match task::spawn_blocking(move || bcrypt::verify(password_bytes, &password_hash))
97                .await?
98            {
99                Ok(res) => Ok(res),
100                Err(bcrypt::BcryptError::InvalidHash(..)) => {
101                    // InvalidHash error contain the hash itself. We do not want to log it.
102                    warn!("Bcrypt hash verification error: bad hash");
103                    Ok(false)
104                }
105                other => {
106                    warn!("Bcrypt hash verification error: {other:?}");
107                    Ok(false)
108                }
109            }
110        }
111        //#[cfg(test)]
112        PasswordHashingAlgo::None => Ok(str::from_utf8(password.as_ref())?.eq(hash.as_ref())),
113    }
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119    use rand::distr::{Alphanumeric, SampleString};
120    use tracing_test::traced_test;
121
122    #[test]
123    fn test_verify_length_and_trunc_password() {
124        assert_eq!(
125            b"abcdefg",
126            verify_length_and_trunc_password("abcdefg".as_bytes(), 70)
127        );
128        assert_eq!(
129            b"abcd",
130            verify_length_and_trunc_password("abcdefg".as_bytes(), 4)
131        );
132        // In UTF8 bytes a single unicode is taking 3 bytes already
133        assert_eq!(
134            b"\xE2\x98\x81a",
135            verify_length_and_trunc_password("☁abcdefg".as_bytes(), 4)
136        );
137    }
138
139    #[tokio::test]
140    #[traced_test]
141    async fn test_hash_bcrypt() {
142        let builder = config::Config::builder()
143            .set_override("auth.methods", "")
144            .unwrap()
145            .set_override("database.connection", "dummy")
146            .unwrap();
147        let conf: Config = Config::try_from(builder).expect("can build a valid config");
148        let pass = "abcdefg";
149        let hashed = hash_password(&conf, &pass).await.unwrap();
150        assert!(!logs_contain(pass));
151        assert!(!logs_contain(&hashed));
152    }
153
154    #[tokio::test]
155    #[traced_test]
156    async fn test_roundtrip_bcrypt() {
157        let builder = config::Config::builder()
158            .set_override("auth.methods", "")
159            .unwrap()
160            .set_override("database.connection", "dummy")
161            .unwrap();
162        let conf: Config = Config::try_from(builder).expect("can build a valid config");
163        let pass = "abcdefg";
164        let hashed = hash_password(&conf, &pass).await.unwrap();
165        assert!(verify_password(&conf, &pass, &hashed).await.unwrap());
166        assert!(!logs_contain(pass));
167        assert!(!logs_contain(&hashed));
168    }
169
170    #[tokio::test]
171    #[traced_test]
172    async fn test_roundtrip_bcrypt_longer_than_72() {
173        let builder = config::Config::builder()
174            .set_override("auth.methods", "")
175            .unwrap()
176            .set_override("database.connection", "dummy")
177            .unwrap();
178        let conf: Config = Config::try_from(builder).expect("can build a valid config");
179        let pass = Alphanumeric.sample_string(&mut rand::rng(), 80);
180        let hashed = hash_password(&conf, &pass).await.unwrap();
181        assert!(verify_password(&conf, &pass, &hashed).await.unwrap());
182        assert!(!logs_contain(&pass));
183        assert!(!logs_contain(&hashed));
184    }
185
186    #[tokio::test]
187    #[traced_test]
188    async fn test_roundtrip_bcrypt_mismatch() {
189        let builder = config::Config::builder()
190            .set_override("auth.methods", "")
191            .unwrap()
192            .set_override("database.connection", "dummy")
193            .unwrap();
194        let conf: Config = Config::try_from(builder).expect("can build a valid config");
195        let pass = Alphanumeric.sample_string(&mut rand::rng(), 80);
196        let hashed = hash_password(&conf, "other password").await.unwrap();
197        assert!(!verify_password(&conf, &pass, &hashed).await.unwrap());
198        assert!(!logs_contain(&pass));
199        assert!(!logs_contain(&hashed));
200    }
201
202    #[tokio::test]
203    #[traced_test]
204    async fn test_roundtrip_bcrypt_bad_hash() {
205        let builder = config::Config::builder()
206            .set_override("auth.methods", "")
207            .unwrap()
208            .set_override("database.connection", "dummy")
209            .unwrap();
210        let conf: Config = Config::try_from(builder).expect("can build a valid config");
211        let pass = Alphanumeric.sample_string(&mut rand::rng(), 80);
212        assert!(!verify_password(&conf, &pass, "foobar").await.unwrap());
213        assert!(!logs_contain("foobar"));
214        assert!(!logs_contain(&pass));
215    }
216}