WIP - ActixWeb multi-tenant blog and newsletter API server. Originally forked from LukeMathWalker/zero-to-production.
1use aes_gcm::aead::{Aead, KeyInit, OsRng};
2use aes_gcm::{AeadCore, Aes256Gcm, Key, Nonce};
3use anyhow::Context;
4use base64::Engine;
5use base64::engine::general_purpose::STANDARD;
6use captcha::Captcha;
7use captcha::filters::{Dots, Grid, Noise, Wave};
8use secrecy::{ExposeSecret, SecretString};
9use serde::{Deserialize, Serialize};
10
11#[derive(Deserialize, Debug)]
12pub struct Base64Challenger {
13 pub base64_image: String,
14 answer: String,
15 secret: SecretString,
16}
17
18impl Base64Challenger {
19 pub fn new(secret: SecretString) -> Result<Self, anyhow::Error> {
20 if secret.expose_secret().len() != 32 {
21 anyhow::bail!("Secret must be 32 bytes in length.")
22 }
23
24 let mut captcha = Captcha::new();
25 captcha
26 .add_chars(6)
27 .view(320, 120)
28 .set_color([23, 23, 23])
29 .apply_filter(Dots::new(10))
30 .apply_filter(Wave::new(1.5, 40.0))
31 .apply_filter(Noise::new(0.1))
32 .apply_filter(Grid::new(20, 10));
33 let answer = captcha.chars_as_string();
34 let base64_image = captcha
35 .as_base64()
36 .context("Failed to generate base64 string from image.")?;
37
38 Ok(Self {
39 answer,
40 base64_image,
41 secret,
42 })
43 }
44
45 pub fn encrypt(&self) -> Result<String, anyhow::Error> {
46 // Create cipher from 32-byte key. (Panics if key is not 32 bytes)
47 let key = Key::<Aes256Gcm>::from_slice(self.secret.expose_secret().as_bytes());
48 let cipher = Aes256Gcm::new(key);
49 // Nonce is a 12-byte value.
50 let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
51 let ciphertext = match cipher.encrypt(&nonce, self.answer.as_bytes()) {
52 Ok(cipher) => cipher,
53 Err(_) => anyhow::bail!("Failed to encrypt challenge."),
54 };
55
56 // Base64 encode nonce + ciphertext for output.
57 let mut out = Vec::with_capacity(nonce.as_slice().len() + ciphertext.len());
58 out.extend_from_slice(nonce.as_slice());
59 out.extend_from_slice(&ciphertext);
60
61 Ok(STANDARD.encode(out))
62 }
63
64 pub fn decrypt(encoded: &str, secret: SecretString) -> Result<String, anyhow::Error> {
65 let data = STANDARD
66 .decode(encoded)
67 .context("Failed to base64 decode challenge.")?;
68
69 if data.len() < 12 {
70 anyhow::bail!("Ciphertext is too short.")
71 }
72
73 // Split nonce and ciphertext for decryption.
74 let (nonce_bytes, ciphertext) = data.split_at(12);
75 let nonce = Nonce::from_slice(nonce_bytes);
76 let key = Key::<Aes256Gcm>::from_slice(secret.expose_secret().as_bytes());
77 let cipher = Aes256Gcm::new(key);
78
79 match cipher.decrypt(nonce, ciphertext.as_ref()) {
80 Ok(bytes_vec) => {
81 String::from_utf8(bytes_vec).context("Failed to convert answer to string.")
82 }
83 Err(_) => anyhow::bail!("Failed to decrypt answer."),
84 }
85 }
86
87 pub fn verify(
88 encoded: &str,
89 answer: String,
90 secret: SecretString,
91 ) -> Result<(), anyhow::Error> {
92 if Self::decrypt(encoded, secret)? == answer {
93 Ok(())
94 } else {
95 anyhow::bail!("Incorrect answer.")
96 }
97 }
98}
99
100#[derive(Serialize, Debug)]
101pub struct CaptchaResponse {
102 pub challenge_image: String,
103 pub challenge: String,
104}
105
106#[cfg(test)]
107mod tests {
108 use crate::challenge::Base64Challenger;
109 use claims::{assert_err, assert_ok};
110 use secrecy::SecretString;
111
112 #[test]
113 fn secret_must_be_32_bytes_in_length() {
114 let secret = SecretString::from("W81lMp7E1J0569L2Z1ERpeX8XDiYn11");
115 let challenge = Base64Challenger::new(secret);
116
117 assert_err!(challenge);
118 }
119
120 #[test]
121 fn can_create_challenge_image() {
122 let secret = SecretString::from("w8ar9i496zulwEayDG828Y67i09IfwWC");
123 let challenge = Base64Challenger::new(secret);
124
125 assert_ok!(challenge);
126 }
127
128 #[test]
129 fn can_encrypt_challenge() {
130 let secret = SecretString::from("njE17BV5QLYO82V3UWoa22ZwwdiD40l2");
131 let challenge = Base64Challenger::new(secret).expect("Creating challenge.");
132
133 assert_ok!(challenge.encrypt());
134 }
135
136 #[test]
137 fn can_decrypt_challenge() {
138 let secret = SecretString::from("tNuS550e9os25IFZxw518GlNSK3ouiY1");
139 let challenge = Base64Challenger::new(secret).expect("Creating challenge.");
140 let encrypted = challenge.encrypt().unwrap();
141 let decrypted = Base64Challenger::decrypt(&encrypted, challenge.secret).unwrap();
142
143 assert_eq!(challenge.answer, decrypted);
144 }
145
146 #[test]
147 fn can_verify_correct_answer() {
148 let secret = SecretString::from("7LphV05vqV3oxYj831j97H3vs2g5wP89");
149 let challenge = Base64Challenger::new(secret).expect("Creating challenge.");
150 let encrypted = challenge.encrypt().unwrap();
151
152 assert_ok!(Base64Challenger::verify(
153 &encrypted,
154 challenge.answer,
155 challenge.secret
156 ));
157 }
158
159 #[test]
160 fn can_reject_incorrect_answer() {
161 let secret = SecretString::from("Zh20YpU56L5Ces0VffGl31rb2Km4k7Gr");
162 let challenge = Base64Challenger::new(secret).expect("Creating challenge.");
163 let encrypted = challenge.encrypt().unwrap();
164
165 assert_err!(Base64Challenger::verify(
166 &encrypted,
167 String::from("badanswer"),
168 challenge.secret
169 ));
170 }
171}