1use algorithm::AltchaAlgorithm;
20use chrono::{DateTime, Utc};
21use error::Error;
22use serde::{Deserialize, Serialize};
23use utils::ParamsMapType;
24
25pub mod algorithm;
27pub mod error;
29mod utils;
30
31pub const DEFAULT_MAX_NUMBER: u64 = 1000000;
32pub const DEFAULT_SALT_LENGTH: usize = 12;
33pub const DEFAULT_ALGORITHM: AltchaAlgorithm = AltchaAlgorithm::Sha256;
34
35#[derive(Debug, Clone, Default)]
37pub struct ChallengeOptions<'a> {
38 pub algorithm: Option<AltchaAlgorithm>,
39 pub max_number: Option<u64>,
40 pub salt_length: Option<usize>,
41 pub hmac_key: &'a str,
42 pub salt: Option<String>,
43 pub number: Option<u64>,
44 pub expires: Option<DateTime<Utc>>,
45 pub params: Option<ParamsMapType>,
46}
47
48#[derive(Debug, Clone, Deserialize, Serialize)]
50pub struct Challenge {
51 pub algorithm: AltchaAlgorithm,
52 pub challenge: String,
53 pub maxnumber: u64,
54 pub salt: String,
55 pub signature: String,
56}
57
58#[derive(Debug, Clone, Deserialize, Serialize)]
60pub struct Payload {
61 pub algorithm: AltchaAlgorithm,
62 pub challenge: String,
63 pub number: u64,
64 pub salt: String,
65 pub signature: String,
66 pub took: Option<u32>,
67}
68
69pub fn create_challenge(options: ChallengeOptions) -> Result<Challenge, Error> {
91 let algorithm = options.algorithm.unwrap_or(DEFAULT_ALGORITHM);
92 let max_number = options.max_number.unwrap_or(DEFAULT_MAX_NUMBER);
93 let salt_length = options.salt_length.unwrap_or(DEFAULT_SALT_LENGTH);
94
95 let salt = options.salt.unwrap_or_else(|| {
96 base16ct::lower::encode_string(utils::random_bytes(salt_length).as_slice())
97 });
98
99 if options.number.is_some_and(|number| number > max_number) {
100 return Err(Error::WrongChallengeInput(format!(
101 "number exceeds max_number {} > {}",
102 options.number.unwrap(),
103 max_number
104 )));
105 }
106 let number = match options.number {
107 Some(n) => n,
108 None => utils::random_int(max_number)?,
109 };
110
111 let (mut salt, mut salt_params) = utils::extract_salt_params(salt.as_str());
112
113 if let Some(expire_value) = options.expires {
114 salt_params.insert(
115 String::from(EXPIRES_PRAM),
116 expire_value.timestamp().to_string(),
117 );
118 }
119 if let Some(params) = options.params {
120 salt_params.extend(params);
121 }
122
123 if !salt_params.is_empty() {
124 salt += format!("?{}", utils::generate_url_from_salt_params(&salt_params)).as_str();
125 }
126
127 let salt_with_number = salt.clone() + number.to_string().as_str();
128 let challenge = utils::hash_function(&algorithm, salt_with_number.as_str());
129 let signature = utils::hmac_function(&algorithm, &challenge, options.hmac_key);
130
131 Ok(Challenge {
132 algorithm,
133 challenge,
134 maxnumber: max_number,
135 salt,
136 signature,
137 })
138}
139#[cfg(feature = "json")]
162pub fn create_json_challenge(options: ChallengeOptions) -> Result<String, Error> {
163 let challenge = create_challenge(options)?;
164 Ok(serde_json::to_string(&challenge)?)
165}
166#[cfg(feature = "json")]
189pub fn verify_json_solution(
190 payload: &str,
191 hmac_key: &str,
192 check_expire: bool,
193) -> Result<(), Error> {
194 let payload_decoded: Payload = serde_json::from_str(payload)?;
195 verify_solution(&payload_decoded, hmac_key, check_expire)
196}
197
198pub fn verify_solution(payload: &Payload, hmac_key: &str, check_expire: bool) -> Result<(), Error> {
208 let (_, salt_params) = utils::extract_salt_params(&payload.salt);
209
210 if check_expire {
211 if let Some(expire_str) = salt_params.get(&String::from(EXPIRES_PRAM)) {
212 let expire_timestamp: i64 = expire_str.parse()?;
213 let Some(expire) = DateTime::from_timestamp(expire_timestamp, 0) else {
214 return Err(Error::ParseExpire(format!(
215 "Failed to parse timestamp {}",
216 expire_timestamp
217 )));
218 };
219 let now_time: DateTime<Utc> = Utc::now();
220 if expire < now_time {
221 return Err(Error::VerificationFailedExpired(format!(
222 "expired {}",
223 expire - now_time
224 )));
225 }
226 }
227 }
228
229 let options = ChallengeOptions {
230 algorithm: Some(payload.algorithm),
231 max_number: None,
232 salt_length: None,
233 hmac_key,
234 salt: Some(payload.salt.clone()),
235 number: Some(payload.number),
236 expires: None,
237 params: None,
238 };
239 let expected_challenge = create_challenge(options)?;
240 if expected_challenge.challenge != payload.challenge {
241 return Err(Error::VerificationMismatchChallenge(format!(
242 "mismatch expected challenge {} != {}",
243 expected_challenge.challenge, payload.challenge
244 )));
245 }
246 if expected_challenge.signature != payload.signature {
247 return Err(Error::VerificationMismatchSignature(format!(
248 "mismatch expected signature {} != {}",
249 expected_challenge.signature, payload.signature
250 )));
251 }
252 Ok(())
253}
254
255pub fn solve_challenge(
283 challenge: &str,
284 salt: &str,
285 algorithm: Option<AltchaAlgorithm>,
286 max_number: Option<u64>,
287 start: u64,
288) -> Result<u64, Error> {
289 let selected_algorithm = algorithm.unwrap_or(DEFAULT_ALGORITHM);
290 let selected_max_number = max_number.unwrap_or(DEFAULT_MAX_NUMBER);
291
292 for n in start..selected_max_number + 1 {
293 let current_try = String::from(salt) + n.to_string().as_str();
294 let hash_hex_value = utils::hash_function(&selected_algorithm, current_try.as_str());
295 if hash_hex_value.eq(challenge) {
296 return Ok(n);
297 }
298 }
299 Err(Error::SolveChallengeMaxNumberReached(format!(
300 "maximum iterations reached {}",
301 selected_max_number
302 )))
303}
304
305const EXPIRES_PRAM: &str = "expires";
306
307#[cfg(test)]
308mod tests {
309 use super::*;
310
311 #[test]
312 #[cfg(feature = "json")]
313 fn test_verify_solution() {
314 let data = r#"
315 {"algorithm":"SHA-256","challenge":"aa9c8ec8057413dd8220e21e15ab54b095fb3c840601d44c39bece8d9df34529","number":971813,"salt":"3065d108b2314f5ecc7e1207","signature":"d6c436288f1979f298f6532cea31db9e84e6338f4f58a9e00cb4105abbd11397","took":9417}"#.to_string();
316 verify_json_solution(&data, &"super-secret".to_string(), true).expect("should be ok");
317 }
318
319 #[test]
320 #[cfg(feature = "json")]
321 fn test_challenge() {
322 let challenge = create_challenge(ChallengeOptions {
323 algorithm: None,
324 max_number: None,
325 number: None,
326 salt: None,
327 hmac_key: "super-secret",
328 params: None,
329 expires: Some(Utc::now() + chrono::TimeDelta::minutes(1)),
330 salt_length: None,
331 })
332 .expect("should be ok");
333 let res = solve_challenge(&challenge.challenge, &challenge.salt, None, None, 0)
334 .expect("need to be solved");
335 let payload = Payload {
336 algorithm: challenge.algorithm,
337 challenge: challenge.challenge,
338 number: res,
339 salt: challenge.salt,
340 signature: challenge.signature,
341 took: None,
342 };
343 let string_payload = serde_json::to_string(&payload).unwrap();
344 verify_json_solution(&string_payload, "super-secret", true).expect("should be ok");
345 }
346
347 #[test]
348 #[cfg(feature = "json")]
349 fn test_create_json_challenge() {
350 let challenge_json = create_json_challenge(ChallengeOptions {
351 algorithm: Some(AltchaAlgorithm::Sha1),
352 max_number: Some(100000),
353 number: Some(22222),
354 salt: Some(String::from("blabla")),
355 hmac_key: "my_key",
356 expires: Some(DateTime::from_timestamp(1715526540, 0).unwrap()),
357 ..Default::default()
358 })
359 .expect("should be ok");
360 assert_eq!(
361 challenge_json,
362 r#"{"algorithm":"SHA-1","challenge":"864412db92050e02c89e7e623c773491e8495990","maxnumber":100000,"salt":"blabla?expires=1715526540","signature":"2e66edb70874996e94430c62ac6e2815a092718d"}"#
363 );
364 }
365
366 #[test]
367 fn test_create_challenge_wrong_input() {
368 let challenge = create_challenge(ChallengeOptions {
369 max_number: Some(222),
370 number: Some(100000),
371 hmac_key: "my_key",
372 ..Default::default()
373 });
374 assert!(challenge.is_err());
375 }
376}