altcha_lib_rs/
lib.rs

1//! Community implementation of the Altcha library in Rust for your own server applications to create and validate challenges and responses.
2//!
3//! For more details about Altcha see <https://altcha.org/docs>
4
5// Copyright 2024 jmic
6//
7// Licensed under the Apache License, Version 2.0 (the "License");
8// you may not use this file except in compliance with the License.
9// You may obtain a copy of the License at
10//
11// http://www.apache.org/licenses/LICENSE-2.0
12//
13// Unless required by applicable law or agreed to in writing, software
14// distributed under the License is distributed on an "AS IS" BASIS,
15// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16// See the License for the specific language governing permissions and
17// limitations under the License.
18
19use algorithm::AltchaAlgorithm;
20use chrono::{DateTime, Utc};
21use error::Error;
22use serde::{Deserialize, Serialize};
23use utils::ParamsMapType;
24
25/// Algorithm options for the challenge
26pub mod algorithm;
27/// Errors
28pub 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/// ChallengeOptions defines the options for creating a challenge
36#[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/// Challenge defines the challenge send to the client
49#[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/// Payload defines the response from the client
59#[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
69/// Creates a challenge for the client to solve.
70///
71/// # Arguments
72///
73/// * `options`: ChallengeOptions defines the options for creating a challenge
74///
75/// returns: Result<Challenge, Error> The new challenge or error.
76///
77/// # Examples
78///
79/// ```
80/// use std::default;
81/// use chrono::Utc;
82/// use altcha_lib_rs::{create_challenge, ChallengeOptions};
83/// let challenge = create_challenge(
84///     ChallengeOptions{
85///         hmac_key: "super-secret",
86///         expires: Some(Utc::now()+chrono::TimeDelta::minutes(1)),
87///         ..Default::default()
88///  });
89/// ```
90pub 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/// Creates a challenge for the client to solve as a string containing a json.
140/// `features = ["json"]` must be enabled.
141///
142/// # Arguments
143///
144/// * `options`: ChallengeOptions defines the options for creating a challenge
145///
146/// returns: Result<String, Error> The new challenge formated as JSON string or error.
147///
148/// # Examples
149///
150/// ```
151/// use std::default;
152/// use chrono::Utc;
153/// use altcha_lib_rs::{create_challenge, create_json_challenge, ChallengeOptions};
154/// let challenge = create_json_challenge(
155///     ChallengeOptions{
156///         hmac_key: "super-secret",
157///         expires: Some(Utc::now()+chrono::TimeDelta::minutes(1)),
158///         ..Default::default()
159///  });
160/// ```
161#[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/// Verifies the json formated solution provided by the client.
167/// `features = ["json"]` must be enabled.
168///
169/// # Arguments
170///
171/// * `payload`: The json formated payload to verify.
172/// * `hmac_key`: The HMAC key used for verification.
173/// * `check_expire`: Whether to check if the challenge has expired.
174///
175/// returns: Result<(), Error> Whether the solution is valid.
176///
177/// # Examples
178///
179/// ```
180/// use altcha_lib_rs::verify_json_solution;
181/// let payload_str = r#"{
182///     "algorithm":"SHA-256","challenge":"aa9c8ec8057413dd8220e21e15ab54b095fb3c840601d44c39bece8d9df34529"
183///     ,"number":971813,"salt":"3065d108b2314f5ecc7e1207",
184///     "signature":"d6c436288f1979f298f6532cea31db9e84e6338f4f58a9e00cb4105abbd11397","took":9417
185/// }"#.to_string();
186/// let res =  verify_json_solution(&payload_str, &"super-secret".to_string(), true);
187/// ```
188#[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
198/// Verifies the solution provided by the client.
199///
200/// # Arguments
201///
202/// * `payload`: The payload to verify.
203/// * `hmac_key`: The HMAC key used for verification.
204/// * `check_expire`: Whether to check if the challenge has expired.
205///
206/// returns: Result<(), Error> Whether the solution is valid.
207pub 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
255/// Solves a challenge by brute force.
256/// Used for testing.
257///
258/// # Arguments
259///
260/// * `challenge`: The challenge to solve.
261/// * `salt`: The salt used in the challenge.
262/// * `algorithm`: The hash algorithm used. Optional.
263/// * `max_number`: The maximum number to try. Optional.
264/// * `start`: The starting number.
265///
266/// returns: Result<u64, Error> The solution or error.
267///
268/// # Examples
269///
270/// ```
271///  use chrono::Utc;
272///  use altcha_lib_rs::{create_challenge, solve_challenge, ChallengeOptions};
273///  let challenge = create_challenge(
274///     ChallengeOptions{
275///         hmac_key: "super-secret",
276///         expires: Some(Utc::now()+chrono::TimeDelta::minutes(1)),
277///         ..Default::default()
278///  }).expect("internal error");
279///  let res = solve_challenge(&challenge.challenge, &challenge.salt,
280///     Some(challenge.algorithm), Some(challenge.maxnumber), 0);
281/// ```
282pub 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}