initial code

This commit is contained in:
jmic
2024-08-19 01:11:58 +02:00
parent ca68838258
commit b5de462a49
6 changed files with 385 additions and 0 deletions

1
.gitignore vendored
View File

@@ -2,6 +2,7 @@
# will have compiled files and executables
debug/
target/
.idea
# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html

19
Cargo.toml Normal file
View File

@@ -0,0 +1,19 @@
[package]
name = "altcha-lib-rs"
version = "0.1.0"
edition = "2021"
[dependencies]
chrono = "0.4.38"
rand = "0.9.0-alpha.2"
sha2 = "0.11.0-pre.4"
base16ct = { version = "0.2.0", features = ["alloc"] }
sha1 = "0.11.0-pre.4"
hmac = "0.13.0-pre.4"
serde = { version = "1.0.208", features = ["derive"] }
serde_json = { version = "1.0.125", optional = true }
[features]
default = ["json"]
json = ["serde_json"]

37
src/algorithm.rs Normal file
View File

@@ -0,0 +1,37 @@
use std::str::FromStr;
use serde::{Deserialize, Serialize};
use std::fmt::Display;
#[derive(Debug, Clone, Deserialize, Serialize)]
pub enum AltchaAlgorithm {
#[serde(rename="SHA-1")]
Sha1,
#[serde(rename="SHA-256")]
Sha256,
#[serde(rename="SHA-512")]
Sha512,
}
impl FromStr for AltchaAlgorithm {
type Err = ();
fn from_str(input: &str) -> Result<AltchaAlgorithm, Self::Err> {
match input {
"SHA-1" => Ok(AltchaAlgorithm::Sha1),
"SHA-256" => Ok(AltchaAlgorithm::Sha256),
"SHA-512" => Ok(AltchaAlgorithm::Sha512),
_ => Err(()),
}
}
}
impl Display for AltchaAlgorithm {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let str = match self {
AltchaAlgorithm::Sha1 => "SHA-1",
AltchaAlgorithm::Sha256 => "SHA-256",
AltchaAlgorithm::Sha512 => "SHA-512"
};
write!(f, "{}", str)
}
}

24
src/error.rs Normal file
View File

@@ -0,0 +1,24 @@
#[derive(Debug)]
pub enum Error {
ParseJson(serde_json::Error),
ParseInteger(std::num::ParseIntError),
ParseExpire(String),
VerificationFailedExpired(String),
VerificationMismatchChallenge(String),
VerificationMismatchSignature(String),
SolveChallengeMaxNumberReached(String),
WrongChallengeInput(String),
General(String)
}
impl From<serde_json::Error> for Error {
fn from(other: serde_json::Error) -> Self {
Self::ParseJson(other)
}
}
impl From<std::num::ParseIntError> for Error {
fn from(other: std::num::ParseIntError) -> Self {
Self::ParseInteger(other)
}
}

194
src/lib.rs Normal file
View File

@@ -0,0 +1,194 @@
use chrono::{DateTime, Utc};
use base16ct;
use serde::{Deserialize, Serialize};
use algorithm::AltchaAlgorithm;
use error::Error;
use utils::ParamsMapType;
mod algorithm;
mod error;
mod utils;
pub const DEFAULT_MAX_NUMBER: u64 = 1000000;
pub const DEFAULT_SALT_LENGTH: usize = 12;
pub const DEFAULT_ALGORITHM: AltchaAlgorithm = AltchaAlgorithm::Sha256;
#[derive(Debug, Clone, Default)]
pub struct ChallengeOptions<'a> {
algorithm: Option<AltchaAlgorithm>,
max_number: Option<u64>,
salt_length: Option<usize>,
hmac_key: &'a str,
salt: Option<String>,
number: Option<u64>,
expires: Option<DateTime<Utc>>,
params: Option<ParamsMapType>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Challenge {
algorithm: AltchaAlgorithm,
challenge: String,
maxnumber: u64,
salt: String,
signature: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Payload {
algorithm: AltchaAlgorithm,
challenge: String,
number: u64,
salt: String,
signature: String,
}
pub fn create_challenge(options: ChallengeOptions) -> Result<Challenge, Error> {
let algorithm = options.algorithm.unwrap_or(DEFAULT_ALGORITHM);
let max_number = options.max_number.unwrap_or(DEFAULT_MAX_NUMBER);
let salt_length = options.salt_length.unwrap_or(DEFAULT_SALT_LENGTH);
let salt = options.salt.unwrap_or_else(|| base16ct::lower::encode_string(utils::random_bytes(salt_length).as_slice()));
if options.number.is_some_and(|number| number > max_number) {
return Err(Error::WrongChallengeInput(format!("number exides max_number {} > {}", options.number.unwrap(), max_number)));
}
let number = options.number.unwrap_or_else(|| utils::random_int(max_number));
let (mut salt, mut salt_params) = utils::extract_salt_params(salt.as_str());
if let Some(expire_value) = options.expires{
salt_params.insert(String::from(EXPIRES_PRAM), expire_value.timestamp().to_string());
}
if let Some(params) = options.params {
salt_params.extend(params);
}
if !salt_params.is_empty() {
salt += format!("?{}", utils::generate_url_from_salt_params(&salt_params)).as_str();
}
let salt_with_number = salt.clone() + number.to_string().as_str();
let challenge = utils::hash_function(&algorithm, salt_with_number.as_str());
let signature = utils::hmac_function(&algorithm, &challenge, options.hmac_key);
Ok(Challenge{ algorithm, challenge, maxnumber: max_number, salt, signature })
}
#[cfg(feature = "json")]
pub fn create_json_challenge(options: ChallengeOptions) -> Result<String, Error> {
let challenge = create_challenge(options)?;
Ok(serde_json::to_string(&challenge)?)
}
#[cfg(feature = "json")]
pub fn verify_json_solution(payload: &str, hmac_key: &str, check_expire: bool) -> Result<(), Error> {
let payload_decoded: Payload = serde_json::from_str(payload)?;
verify_solution(payload_decoded, hmac_key, check_expire)
}
pub fn verify_solution(payload: Payload, hmac_key: &str, check_expire: bool) -> Result<(), Error> {
let (_, salt_params) = utils::extract_salt_params(&payload.salt);
if check_expire {
if let Some(expire_str) = salt_params.get(&String::from(EXPIRES_PRAM)) {
let expire_timestamp: i64 = expire_str.parse()?;
let Some(expire) = DateTime::from_timestamp(expire_timestamp, 0) else {
return Err(Error::ParseExpire(format!("Failed to parse timestamp {}", expire_timestamp)))
};
let now_time: DateTime<Utc> = Utc::now();
if expire < now_time{
return Err(Error::VerificationFailedExpired(format!("expired {}", expire - now_time)))
}
}
}
let options = ChallengeOptions {
algorithm: Some(payload.algorithm),
max_number: None,
salt_length: None,
hmac_key,
salt: Some(payload.salt.clone()),
number: Some(payload.number),
expires: None,
params: None,
};
let expected_challenge = create_challenge(options)?;
if expected_challenge.challenge != payload.challenge {
return Err(Error::VerificationMismatchChallenge(format!("mismatch expected challenge {} != {}", expected_challenge.challenge, payload.challenge)))
}
if expected_challenge.signature != payload.signature {
return Err(Error::VerificationMismatchSignature(format!("mismatch expected signature {} != {}", expected_challenge.signature, payload.signature)))
}
Ok(())
}
pub fn solve_challenge(challenge: &str, salt: &str, algorithm: Option<AltchaAlgorithm>, max_number: Option<u64>, start: u64) -> Result<u64, Error> {
let selected_algorithm = algorithm.unwrap_or(DEFAULT_ALGORITHM);
let selected_max_number = max_number.unwrap_or(DEFAULT_MAX_NUMBER);
for n in start..selected_max_number + 1 {
let current_try = String::from(salt) + n.to_string().as_str();
let hash_hex_value = utils::hash_function(&selected_algorithm, current_try.as_str());
if hash_hex_value.eq(challenge) {
return Ok(n);
}
}
Err(Error::SolveChallengeMaxNumberReached(format!("maximum iterations reached {}", selected_max_number)))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[cfg(feature = "json")]
fn test_verify_solution() {
let data = r#"
{
"algorithm": "SHA-512",
"challenge": "ca6dc405dbe2c4c35849eaf434cafa852eacd27e70494220ecef849bb4545b670ed3e6adecc27d95768c1d4985753307ed29ee0188800d7eb37ce76bbf0343cb",
"number": 1000,
"salt": "blablabla",
"signature": "b2e4f529389c32c3960438ab12409f298014e580b0c75a5ed6664c7a19e5ff1607ee2690c25f7977bbde126d677f0e18cfbb8487a7f4f06ab199fd27bfd26af1"
}"#.to_string();
verify_json_solution(&data, &"blabla".to_string(), true).expect("should be ok");
}
#[test]
#[cfg(feature = "json")]
fn test_challenge() {
let challenge = create_challenge(ChallengeOptions{algorithm: None, max_number: None, number: None, salt: None, hmac_key: "my_key", params: None, expires: Some(Utc::now()+chrono::TimeDelta::minutes(1)), salt_length: None}).expect("should be ok");
let res = solve_challenge(&challenge.challenge, &challenge.salt, None, None, 0).expect("need to be solved");
let payload = Payload {algorithm: challenge.algorithm, challenge: challenge.challenge, number: res, salt: challenge.salt, signature: challenge.signature };
let string_payload = serde_json::to_string(&payload).unwrap();
verify_json_solution(&string_payload, "my_key", true).expect("should be ok");
}
#[test]
#[cfg(feature = "json")]
fn test_create_json_challenge() {
let challenge_json = create_json_challenge(ChallengeOptions{
algorithm: Some(AltchaAlgorithm::Sha1),
max_number: Some(100000),
number: Some(22222),
salt: Some(String::from("blabla")),
hmac_key: "my_key",
expires: Some(DateTime::from_timestamp(1715526540, 0).unwrap()),
..Default::default()
}).expect("should be ok");
assert_eq!(challenge_json, r#"{"algorithm":"SHA-1","challenge":"864412db92050e02c89e7e623c773491e8495990","maxnumber":100000,"salt":"blabla?expires=1715526540","signature":"2e66edb70874996e94430c62ac6e2815a092718d"}"#);
}
#[test]
fn test_create_challenge_wrong_input() {
let challenge = create_challenge(ChallengeOptions{
max_number: Some(222),
number: Some(100000),
hmac_key: "my_key",
..Default::default()
});
assert!(challenge.is_err());
}
}
const EXPIRES_PRAM: &str = "expires";

110
src/utils.rs Normal file
View File

@@ -0,0 +1,110 @@
use rand::Rng;
use sha1::Sha1;
use sha2::{Sha256, Sha512};
use hmac::digest::Digest;
use hmac::{Hmac, KeyInit, Mac};
use std::collections::HashMap;
use crate::algorithm::AltchaAlgorithm;
type HmacSha1 = Hmac<Sha1>;
type HmacSha256 = Hmac<Sha256>;
type HmacSha512= Hmac<Sha512>;
pub type ParamsMapType = HashMap<String, String>;
pub fn random_bytes(len: usize) -> Vec<u8> {
let mut values: Vec<u8> = vec![0; len];
let mut rng = rand::thread_rng();
rng.fill(values.as_mut_slice());
values
}
pub fn random_int(max: u64) -> u64 {
let mut rng = rand::thread_rng();
let dist = rand::distr::Uniform::new_inclusive(0, max).unwrap();
rng.sample(&dist)
}
pub fn hash_function(altcha_algorithm: &AltchaAlgorithm, data: &str) -> String {
match altcha_algorithm {
AltchaAlgorithm::Sha1 => {
let hash = Sha1::digest(data);
base16ct::lower::encode_string(&hash)
}
AltchaAlgorithm::Sha256 => {
let hash = Sha256::digest(data);
base16ct::lower::encode_string(&hash)
}
AltchaAlgorithm::Sha512 => {
let hash = Sha512::digest(data);
base16ct::lower::encode_string(&hash)
}
}
}
pub fn hmac_function(altcha_algorithm: &AltchaAlgorithm, data: &str, key: &str) -> String {
match altcha_algorithm {
AltchaAlgorithm::Sha1 => {
let mut mac = HmacSha1::new_from_slice(key.as_bytes()).expect("HMAC can take key of any size");
mac.update(data.as_bytes());
let res = mac.finalize();
base16ct::lower::encode_string(res.as_bytes())
}
AltchaAlgorithm::Sha256 => {
let mut mac = HmacSha256::new_from_slice(key.as_bytes()).expect("HMAC can take key of any size");
mac.update(data.as_bytes());
let res = mac.finalize();
base16ct::lower::encode_string(res.as_bytes())
}
AltchaAlgorithm::Sha512 => {
let mut mac = HmacSha512::new_from_slice(key.as_bytes()).expect("HMAC can take key of any size");
mac.update(data.as_bytes());
let res = mac.finalize();
base16ct::lower::encode_string(res.as_bytes())
}
}
}
pub fn extract_salt_params(salt: &str) -> (String, ParamsMapType) {
let mut salt_params = ParamsMapType::new();
if !salt.contains("?") {
return (salt.to_string(), salt_params)
}
let (salt, salt_query) = salt.split_once("?").unwrap();
for parts in salt_query.split("&") {
let Some((key, value)) = parts.split_once("=") else { continue };
salt_params.insert(key.to_string(), value.to_string());
}
(salt.to_string(), salt_params)
}
pub fn generate_url_from_salt_params(params: &ParamsMapType) -> String {
params.into_iter().map(|(key, value)| {
key.to_owned() + "=" + value
}).reduce(|acc, e| {acc + "&" + e.as_str()}).unwrap()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_salt_params() {
let (salt, map) = extract_salt_params("mjsSEFiofesw432==?bla=test&jo=foo");
let mut expectation = ParamsMapType::new();
expectation.insert("bla".to_string(), "test".to_string());
expectation.insert("jo".to_string(), "foo".to_string());
assert_eq!(map, expectation);
assert_eq!(salt, "mjsSEFiofesw432==");
}
#[test]
fn test_generate_url_from_salt_params() {
let expectation_a = "bla=test&jo=foo".to_string();
let expectation_b = "jo=foo&bla=test".to_string();
let mut input = ParamsMapType::new();
input.insert("bla".to_string(), "test".to_string());
input.insert("jo".to_string(), "foo".to_string());
let res = generate_url_from_salt_params(&input);
assert!(res == expectation_a || res == expectation_b);
}
}