#![doc = include_str!("../README.md")]
use std::{
net::{IpAddr, Ipv4Addr, Ipv6Addr},
time::Duration,
};
use base64::{Engine, prelude::BASE64_URL_SAFE_NO_PAD};
use num_bigint::BigUint;
use reqwest::Client;
use serde::Serialize;
const CHALLENGE_SIZE: usize = 397;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DuckityClient {
domain: String,
}
impl DuckityClient {
pub fn new() -> Self {
Self {
domain: "quack.duckity.dev".to_string(),
}
}
pub fn with_domain(domain: impl ToString) -> Self {
Self {
domain: domain.to_string(),
}
}
pub async fn get_challenge(
&self,
app_id: impl ToString,
profile_code: impl ToString,
) -> Result<Challenge, DuckityError> {
let payload = ChallengeRequestPayload {
profile: profile_code.to_string(),
};
let response = Client::new()
.post(format!(
"https://{}/v1/challenges/{}",
self.domain,
app_id.to_string()
))
.json(&payload)
.timeout(Duration::from_secs(10))
.send()
.await?;
if response.status().is_success() {
let bytes = response.bytes().await?;
let challenge = Challenge::decode(&bytes)?;
Ok(challenge)
} else {
let error_response: ErrorResponse = response.json().await?;
Err(DuckityError::ApiError(
error_response.title,
error_response.message,
))
}
}
pub async fn validate_challenge(
&self,
app_id: impl ToString,
app_secret: impl ToString,
profile_code: impl ToString,
solution: String,
client_ip: IpAddr,
) -> Result<(), DuckityError> {
let payload = ValidationRequest {
token: solution,
ip: client_ip,
profile: profile_code.to_string(),
};
let response = Client::new()
.post(format!(
"https://{}/v1/challenges/{}/validate",
self.domain,
app_id.to_string()
))
.json(&payload)
.timeout(Duration::from_secs(10))
.bearer_auth(app_secret.to_string())
.send()
.await?;
if response.status().is_success() {
Ok(())
} else {
let error_response: ErrorResponse = response.json().await?;
Err(DuckityError::ApiError(
error_response.title,
error_response.message,
))
}
}
}
impl Default for DuckityClient {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, thiserror::Error)]
pub enum DuckityError {
#[error("An error occurred with the Duckity client while making an HTTP request: {0}")]
RequestFailed(#[from] reqwest::Error),
#[error(
"An error occurred while decoding the challenge. Did the API return a valid response? {0}"
)]
DecodingFailed(&'static str),
#[error("An API error occurred: {0}: {1}")]
ApiError(String, String),
}
#[derive(Serialize)]
struct ChallengeRequestPayload {
profile: String,
}
pub struct Challenge(Vec<u8>);
impl Challenge {
pub fn decode(data: &[u8]) -> Result<Self, DuckityError> {
if data.len() != CHALLENGE_SIZE {
return Err(DuckityError::DecodingFailed(
"The challenge size in bytes was not the expected byte size.",
));
}
Ok(Self(data.to_vec()))
}
pub fn x(&self) -> BigUint {
BigUint::from_bytes_be(&self.0[32..64])
}
pub fn p(&self) -> BigUint {
BigUint::from_bytes_be(&self.0[64..320])
}
pub fn t(&self) -> u32 {
u32::from_be_bytes(self.0[320..324].try_into().unwrap())
}
pub fn ip(&self) -> Result<IpAddr, DuckityError> {
let client_ip_bytes = &self.0[340..357];
match client_ip_bytes[0] {
4 => {
let octets: [u8; 4] = client_ip_bytes[1..5].try_into().expect("The slice had an incorrect length for challenge's IPv4 bytes (expected 4 bytes, but it wasn't 4 bytes)");
Ok(IpAddr::V4(Ipv4Addr::from(octets)))
}
6 => {
let octets: [u8; 16] = client_ip_bytes[1..17].try_into().expect("The slice had an incorrect length for challenge's IPv6 bytes (expected 16 bytes, but it wasn't 16 bytes)");
Ok(IpAddr::V6(Ipv6Addr::from(octets)))
}
_ => Err(DuckityError::DecodingFailed(
"The challenge contained an invalid IP address version. Only IPv4 and IPv6 are supported.",
)),
}
}
pub fn solve(&self) -> Solution<'_> {
let x = self.x();
let p = self.p();
let t = self.t();
let mut y = x;
for _ in 0..t {
let e = (&p + (BigUint::ZERO + 1u8)) >> 2; y = y.modpow(&e, &p);
}
Solution(self, y)
}
}
pub struct Solution<'a>(&'a Challenge, BigUint);
impl Solution<'_> {
pub fn encode(&self) -> String {
let mut buf = Vec::with_capacity(CHALLENGE_SIZE + 256);
buf.extend_from_slice(&self.0.0);
buf.extend_from_slice(&self.1.to_bytes_be());
BASE64_URL_SAFE_NO_PAD.encode(buf)
}
pub fn raw_size(&self) -> usize {
self.0.0.len() + self.1.to_bytes_be().len()
}
}
#[derive(serde::Serialize)]
struct ValidationRequest {
token: String,
ip: IpAddr,
profile: String,
}
#[derive(serde::Deserialize)]
struct ErrorResponse {
title: String,
message: String,
}