1use algorithm::AltchaAlgorithm;
20use error::Error;
21use jiff::Timestamp;
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<Timestamp>,
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_PARAM),
116 expire_value.as_second().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_PARAM)) {
212 let expire_timestamp: i64 = expire_str.parse()?;
213 let expire = Timestamp::from_second(expire_timestamp).map_err(|_| {
214 Error::ParseExpire(format!("Failed to parse timestamp {}", expire_timestamp))
215 })?;
216 let now_time = Timestamp::now();
217 if expire < now_time {
218 return Err(Error::VerificationFailedExpired(format!(
219 "expired {}",
220 expire - now_time
221 )));
222 }
223 }
224 }
225
226 let options = ChallengeOptions {
227 algorithm: Some(payload.algorithm),
228 max_number: None,
229 salt_length: None,
230 hmac_key,
231 salt: Some(payload.salt.clone()),
232 number: Some(payload.number),
233 expires: None,
234 params: None,
235 };
236 let expected_challenge = create_challenge(options)?;
237 if expected_challenge.challenge != payload.challenge {
238 return Err(Error::VerificationMismatchChallenge(format!(
239 "mismatch expected challenge {} != {}",
240 expected_challenge.challenge, payload.challenge
241 )));
242 }
243 if expected_challenge.signature != payload.signature {
244 return Err(Error::VerificationMismatchSignature(format!(
245 "mismatch expected signature {} != {}",
246 expected_challenge.signature, payload.signature
247 )));
248 }
249 Ok(())
250}
251
252pub fn solve_challenge(
280 challenge: &str,
281 salt: &str,
282 algorithm: Option<AltchaAlgorithm>,
283 max_number: Option<u64>,
284 start: u64,
285) -> Result<u64, Error> {
286 let selected_algorithm = algorithm.unwrap_or(DEFAULT_ALGORITHM);
287 let selected_max_number = max_number.unwrap_or(DEFAULT_MAX_NUMBER);
288
289 for n in start..selected_max_number + 1 {
290 let current_try = String::from(salt) + n.to_string().as_str();
291 let hash_hex_value = utils::hash_function(&selected_algorithm, current_try.as_str());
292 if hash_hex_value.eq(challenge) {
293 return Ok(n);
294 }
295 }
296 Err(Error::SolveChallengeMaxNumberReached(format!(
297 "maximum iterations reached {}",
298 selected_max_number
299 )))
300}
301
302const EXPIRES_PARAM: &str = "expires";
303
304#[cfg(test)]
305mod tests {
306 use super::*;
307 use jiff::ToSpan;
308
309 #[test]
310 #[cfg(feature = "json")]
311 fn test_verify_solution() {
312 let data = r#"
313 {"algorithm":"SHA-256","challenge":"aa9c8ec8057413dd8220e21e15ab54b095fb3c840601d44c39bece8d9df34529","number":971813,"salt":"3065d108b2314f5ecc7e1207","signature":"d6c436288f1979f298f6532cea31db9e84e6338f4f58a9e00cb4105abbd11397","took":9417}"#.to_string();
314 verify_json_solution(&data, &"super-secret".to_string(), true).expect("should be ok");
315 }
316
317 #[test]
318 #[cfg(feature = "json")]
319 fn test_challenge() {
320 let challenge = create_challenge(ChallengeOptions {
321 algorithm: None,
322 max_number: None,
323 number: None,
324 salt: None,
325 hmac_key: "super-secret",
326 params: None,
327 expires: Some(Timestamp::now().checked_add(5_i64.minutes()).unwrap()),
328 salt_length: None,
329 })
330 .expect("should be ok");
331 let res = solve_challenge(&challenge.challenge, &challenge.salt, None, None, 0)
332 .expect("need to be solved");
333 let payload = Payload {
334 algorithm: challenge.algorithm,
335 challenge: challenge.challenge,
336 number: res,
337 salt: challenge.salt,
338 signature: challenge.signature,
339 took: None,
340 };
341 let string_payload = serde_json::to_string(&payload).unwrap();
342 verify_json_solution(&string_payload, "super-secret", true).expect("should be ok");
343 }
344
345
346 #[test]
347 #[cfg(feature = "json")]
348 fn test_create_json_challenge_sha512() {
349 let challenge_json = create_json_challenge(ChallengeOptions {
350 algorithm: Some(AltchaAlgorithm::Sha512),
351 max_number: Some(100000),
352 number: Some(33333),
353 salt: Some(String::from("blubb")),
354 hmac_key: "some_key",
355 expires: Some(Timestamp::from_second(1715526541).unwrap()),
356 ..Default::default()
357 })
358 .expect("should be ok");
359 assert_eq!(
360 challenge_json,
361 r#"{"algorithm":"SHA-512","challenge":"30af91a2099af3b64f91f271aeec65c144f8fe9efe0f42d4207a984350ecda2f72ef8fb7f55bc7125f173b0de9160f95c17c65e23dd1da30626f0aed50f4bf88","maxnumber":100000,"salt":"blubb?expires=1715526541&","signature":"12cc3c3c04622fee8d2ea729b5b825dc25344969c07957384f825f7576778df4f6a8fa76ef8d0ec47ca15206c574293613b2dd46f22b24009a974805c91062de"}"#
362 );
363 }
364
365 #[test]
366 fn test_create_challenge_wrong_input() {
367 let challenge = create_challenge(ChallengeOptions {
368 max_number: Some(222),
369 number: Some(100000),
370 hmac_key: "my_key",
371 ..Default::default()
372 });
373 assert!(challenge.is_err());
374 }
375}