Skip to main content

salvo_csrf/
bcrypt_cipher.rs

1use base64::engine::general_purpose::URL_SAFE_NO_PAD;
2use base64::Engine;
3
4use super::CsrfCipher;
5
6/// CSRF protection implementation that uses bcrypt.
7#[derive(Debug, Clone)]
8pub struct BcryptCipher {
9    cost: u32,
10    token_size: usize,
11}
12impl Default for BcryptCipher {
13    fn default() -> Self {
14        Self::new()
15    }
16}
17
18impl BcryptCipher {
19    /// Create a new `BcryptCipher`.
20    #[inline]
21    #[must_use]
22    pub fn new() -> Self {
23        Self {
24            cost: 8,
25            token_size: 32,
26        }
27    }
28
29    /// Sets the length of the token.
30    #[inline]
31    #[must_use]
32    pub fn token_size(mut self, token_size: usize) -> Self {
33        assert!((8..=72).contains(&token_size), "length must be between 8 and 72");
34        self.token_size = token_size;
35        self
36    }
37
38    /// Sets the cost for bcrypt.
39    #[inline]
40    #[must_use]
41    pub fn cost(mut self, cost: u32) -> Self {
42        assert!((4..=31).contains(&cost), "cost must be between 4 and 31");
43        self.cost = cost;
44        self
45    }
46}
47
48impl CsrfCipher for BcryptCipher {
49    fn verify(&self, token: &str, proof: &str) -> bool {
50        // Decode the token, using a dummy value if decoding fails to prevent timing attacks.
51        // This ensures bcrypt::verify is always called with consistent timing.
52        let token_bytes = URL_SAFE_NO_PAD
53            .decode(token.as_bytes())
54            .unwrap_or_else(|_| vec![0u8; self.token_size]);
55
56        // bcrypt hashes only use the `./0-9A-Za-z$` alphabet; `generate` rewrites
57        // `/` to `_` for URL safety, so the inverse here is just `_` -> `/`.
58        let proof = proof.replace('_', "/");
59
60        // Always perform bcrypt verification to maintain constant time
61        bcrypt::verify(&token_bytes, &proof).unwrap_or(false)
62    }
63    fn generate(&self) -> (String, String) {
64        let token = self.random_bytes(self.token_size);
65        // bcrypt output never contains `+`; only `/` needs URL-safe rewriting.
66        let proof = bcrypt::hash(&token, self.cost)
67            .expect("bcrypt hash failed")
68            .replace('/', "_");
69
70        (URL_SAFE_NO_PAD.encode(token), proof)
71    }
72}
73
74#[cfg(test)]
75mod tests {
76    use base64::engine::general_purpose::URL_SAFE_NO_PAD;
77    use base64::Engine;
78
79    use super::*;
80
81    #[test]
82    fn test_bcrypt_cipher_new() {
83        let cipher = BcryptCipher::new();
84        assert_eq!(cipher.cost, 8);
85        assert_eq!(cipher.token_size, 32);
86    }
87
88    #[test]
89    fn test_bcrypt_cipher_with_token_size() {
90        let cipher = BcryptCipher::new().token_size(16);
91        assert_eq!(cipher.token_size, 16);
92    }
93
94    #[test]
95    fn test_bcrypt_cipher_with_min_token_size() {
96        let cipher = BcryptCipher::new().token_size(8);
97        assert_eq!(cipher.token_size, 8);
98    }
99
100    #[test]
101    #[should_panic(expected = "length must be between 8 and 72")]
102    fn test_bcrypt_cipher_with_too_small_token_size() {
103        let _ = BcryptCipher::new().token_size(7);
104    }
105
106    #[test]
107    #[should_panic(expected = "length must be between 8 and 72")]
108    fn test_bcrypt_cipher_with_too_large_token_size() {
109        let _ = BcryptCipher::new().token_size(73);
110    }
111
112    #[test]
113    fn test_bcrypt_cipher_with_cost() {
114        let cipher = BcryptCipher::new().cost(10);
115        assert_eq!(cipher.cost, 10);
116    }
117
118    #[test]
119    #[should_panic(expected = "cost must be between 4 and 31")]
120    fn test_bcrypt_cipher_with_invalid_cost() {
121        let _ = BcryptCipher::new().cost(32);
122    }
123
124    #[test]
125    fn test_bcrypt_cipher_verify_and_generate() {
126        let cipher = BcryptCipher::new();
127        let (token, proof) = cipher.generate();
128        assert!(cipher.verify(&token, &proof));
129    }
130
131    #[test]
132    fn test_bcrypt_cipher_verify_invalid_token() {
133        let cipher = BcryptCipher::new();
134        let (token, proof) = cipher.generate();
135        let invalid_token = URL_SAFE_NO_PAD.encode(vec![0; token.len()]);
136        assert!(!cipher.verify(&invalid_token, &proof));
137    }
138}