Skip to main content

altcha_lib/
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 error::Error;
21use jiff::Timestamp;
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<Timestamp>,
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 jiff::{Timestamp, ToSpan};
82/// use altcha_lib::{create_challenge, ChallengeOptions};
83/// let challenge = create_challenge(
84///     ChallengeOptions{
85///         hmac_key: "super-secret",
86///         expires: Some(Timestamp::now().checked_add(5_i64.minutes()).unwrap()),
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_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/// 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 jiff::{Timestamp, ToSpan};
153/// use altcha_lib::{create_challenge, create_json_challenge, ChallengeOptions};
154/// let challenge = create_json_challenge(
155///     ChallengeOptions{
156///         hmac_key: "super-secret",
157///         expires: Some(Timestamp::now().checked_add(5_i64.minutes()).unwrap()),
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::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_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
252/// Solves a challenge by brute force.
253/// Used for testing.
254///
255/// # Arguments
256///
257/// * `challenge`: The challenge to solve.
258/// * `salt`: The salt used in the challenge.
259/// * `algorithm`: The hash algorithm used. Optional.
260/// * `max_number`: The maximum number to try. Optional.
261/// * `start`: The starting number.
262///
263/// returns: Result<u64, Error> The solution or error.
264///
265/// # Examples
266///
267/// ```
268///  use jiff::{Timestamp, ToSpan};
269///  use altcha_lib::{create_challenge, solve_challenge, ChallengeOptions};
270///  let challenge = create_challenge(
271///     ChallengeOptions{
272///         hmac_key: "super-secret",
273///         expires: Some(Timestamp::now().checked_add(5_i64.minutes()).unwrap()),
274///         ..Default::default()
275///  }).expect("internal error");
276///  let res = solve_challenge(&challenge.challenge, &challenge.salt,
277///     Some(challenge.algorithm), Some(challenge.maxnumber), 0);
278/// ```
279pub 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}