use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use serde_json::json;
use crate::types::{AstraGuardError, LicenseDetails, LicenseResponse, Result, UpdateCheckResponse};
const RESPONSE_FRESHNESS_MS: i64 = 120_000;
pub struct AstraGuardClient {
http: reqwest::Client,
api_url: String,
product_id: String,
auth_key: Option<Vec<u8>>,
auto_enforce_security: bool,
cloud_config: Arc<Mutex<HashMap<String, String>>>,
last_license_key: Arc<Mutex<Option<String>>>,
}
impl AstraGuardClient {
pub fn new(api_url: impl Into<String>, product_id: impl Into<String>) -> Result<Self> {
let http = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.user_agent(concat!("AstraGuardSDK-Rust/", env!("CARGO_PKG_VERSION")))
.build()
.map_err(|e| AstraGuardError::Build(e.to_string()))?;
Ok(Self {
http,
api_url: api_url.into().trim_end_matches('/').to_string(),
product_id: product_id.into(),
auth_key: None,
auto_enforce_security: false,
cloud_config: Arc::new(Mutex::new(HashMap::new())),
last_license_key: Arc::new(Mutex::new(None)),
})
}
pub fn with_response_auth(mut self, base64_key: &str) -> Result<Self> {
use base64::Engine;
let decoded = base64::engine::general_purpose::STANDARD
.decode(base64_key)
.map_err(|e| {
AstraGuardError::Build(format!("invalid response-auth key (not base64): {e}"))
})?;
if decoded.is_empty() {
return Err(AstraGuardError::Build(
"invalid response-auth key (decoded to empty)".to_string(),
));
}
self.auth_key = Some(decoded);
Ok(self)
}
pub fn set_auto_enforce_security(&mut self, enabled: bool) {
self.auto_enforce_security = enabled;
}
#[must_use]
pub fn get_machine_id() -> String {
crate::hwid::get()
}
pub fn cloud_config(&self, key: &str) -> Option<String> {
self.cloud_config
.lock()
.unwrap_or_else(|e| e.into_inner())
.get(key)
.cloned()
}
#[must_use = "check `details.raw.valid` to gate access"]
pub async fn verify_license(&self, license_key: &str) -> Result<LicenseDetails> {
let hwid = crate::hwid::get();
if self.auto_enforce_security {
crate::security::anti_debug::start_background_monitor();
if crate::security::anti_vm::is_virtual_machine() {
return Err(AstraGuardError::LicenseRejected(
"virtual machine detected".to_string(),
));
}
}
let resp = self.call_validate(license_key, &hwid).await;
match resp {
Ok(lic) => {
if lic.valid {
crate::cache::save(license_key, &hwid, &self.product_id, &lic);
*self
.last_license_key
.lock()
.unwrap_or_else(|e| e.into_inner()) = Some(license_key.to_string());
let mut cc = self.cloud_config.lock().unwrap_or_else(|e| e.into_inner());
*cc = lic.variables.clone();
Ok(parse_details(lic, license_key, false))
} else {
let reason = lic
.reason
.clone()
.unwrap_or_else(|| "invalid_license".to_string());
Err(AstraGuardError::LicenseRejected(reason))
}
}
Err(AstraGuardError::Network(_)) | Err(AstraGuardError::ServerError { .. }) => {
if let Some(cached) = crate::cache::load(license_key, &hwid, &self.product_id) {
return Ok(parse_details(cached, license_key, true));
}
resp.map(|_| unreachable!())
}
Err(e) => Err(e),
}
}
pub async fn verify_or_callback<F>(&self, license_key: &str, on_invalid: Option<F>)
where
F: Fn(),
{
match self.verify_license(license_key).await {
Ok(_) => {}
Err(e) => {
eprintln!("[AstraGuard] license check failed: {e}");
if let Some(f) = on_invalid.as_ref() {
f();
}
}
}
}
pub async fn activate(&self, license_key: &str) -> Result<LicenseResponse> {
let hwid = crate::hwid::get();
let nonce = generate_nonce();
let body = json!({
"key": license_key,
"hwid": hwid,
"productId": self.product_id,
"nonce": nonce,
"debugDetected": crate::security::anti_debug::is_attached(),
});
let resp = self
.http
.post(format!("{}/activate", self.api_url))
.json(&body)
.send()
.await?;
let status = resp.status().as_u16();
let text = resp.text().await?;
if !(200..300).contains(&(status as usize)) {
return Err(AstraGuardError::ServerError { status, body: text });
}
let mut lic: LicenseResponse = serde_json::from_str(&text)?;
if let Some(success) = lic.success {
lic.valid = success;
}
self.verify_response(&lic, &nonce)?;
Ok(lic)
}
pub fn start_heartbeat<F>(&self, interval_secs: u64, on_invalid: Option<F>)
where
F: Fn() + Send + 'static,
{
let http = self.http.clone();
let api_url = self.api_url.clone();
let product_id = self.product_id.clone();
let auth_key = self.auth_key.clone();
let last_key = Arc::clone(&self.last_license_key);
tokio::spawn(async move {
let mut interval = tokio::time::interval(std::time::Duration::from_secs(interval_secs));
loop {
interval.tick().await;
let key = {
let guard = last_key.lock().unwrap_or_else(|e| e.into_inner());
guard.clone()
};
let Some(license_key) = key else { continue };
let hwid = crate::hwid::get();
let nonce = generate_nonce();
let body = json!({
"key": license_key,
"hwid": hwid,
"productId": product_id,
"nonce": nonce,
});
let Ok(resp) = http
.post(format!("{}/validate", api_url))
.json(&body)
.send()
.await
else {
continue;
};
let Ok(text) = resp.text().await else {
continue;
};
let Ok(lic) = serde_json::from_str::<LicenseResponse>(&text) else {
continue;
};
if let Some(ref key_bytes) = auth_key {
if !verify_response_token(key_bytes, &lic, &nonce, &product_id) {
if let Some(f) = on_invalid.as_ref() {
f();
}
continue;
}
}
if !lic.valid {
if let Some(f) = on_invalid.as_ref() {
f();
}
}
}
});
}
pub async fn check_for_updates(&self, current_version: &str) -> Result<UpdateCheckResponse> {
let encoded = percent_encode(current_version);
let url = format!(
"{}/products/{}/check-update?version={}",
self.api_url, self.product_id, encoded
);
let resp = self.http.get(&url).send().await?;
let status = resp.status().as_u16();
let text = resp.text().await?;
if !(200..300).contains(&(status as usize)) {
return Err(AstraGuardError::ServerError { status, body: text });
}
Ok(serde_json::from_str(&text)?)
}
async fn call_validate(&self, license_key: &str, hwid: &str) -> Result<LicenseResponse> {
let nonce = generate_nonce();
let body = json!({
"key": license_key,
"hwid": hwid,
"productId": self.product_id,
"nonce": nonce,
"debugDetected": crate::security::anti_debug::is_attached(),
});
let resp = self
.http
.post(format!("{}/validate", self.api_url))
.json(&body)
.send()
.await?;
let status = resp.status().as_u16();
let text = resp.text().await?;
if !(200..300).contains(&(status as usize)) {
return Err(AstraGuardError::ServerError { status, body: text });
}
let lic: LicenseResponse = serde_json::from_str(&text)?;
self.verify_response(&lic, &nonce)?;
Ok(lic)
}
fn verify_response(&self, lic: &LicenseResponse, expected_nonce: &str) -> Result<()> {
let Some(ref key_bytes) = self.auth_key else {
return Err(AstraGuardError::ResponseAuthNotConfigured);
};
if !verify_response_token(key_bytes, lic, expected_nonce, &self.product_id) {
return Err(AstraGuardError::SignatureMismatch);
}
Ok(())
}
}
fn generate_nonce() -> String {
use rand::RngCore;
let mut buf = [0u8; 32];
rand::rngs::OsRng.fill_bytes(&mut buf);
hex::encode(buf)
}
fn verify_response_token(
key: &[u8],
lic: &LicenseResponse,
expected_nonce: &str,
product_id: &str,
) -> bool {
use hmac::{Hmac, Mac};
use sha2::Sha256;
let (Some(rt), Some(rts), Some(rn)) = (lic.rt.as_ref(), lic.rts, lic.rn.as_ref()) else {
return false; };
if rn != expected_nonce {
return false;
}
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0);
if (now_ms - rts).abs() > RESPONSE_FRESHNESS_MS {
return false;
}
let payload = format!(
"{}|{}|{}|{}",
expected_nonce,
rts,
if lic.valid { "1" } else { "0" },
product_id
);
let Ok(expected_rt) = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, rt)
else {
return false;
};
type HmacSha256 = Hmac<Sha256>;
let Ok(mut mac) = HmacSha256::new_from_slice(key) else {
return false;
};
mac.update(payload.as_bytes());
mac.verify_slice(&expected_rt).is_ok()
}
fn parse_details(lic: LicenseResponse, key: &str, is_offline: bool) -> LicenseDetails {
use std::time::{Duration, UNIX_EPOCH};
let expires_at = lic.expires_at.as_deref().and_then(|s| {
crate::cache::chrono_like_parse_pub(s).map(|secs| UNIX_EPOCH + Duration::from_secs(secs))
});
LicenseDetails {
raw: lic,
license_key: key.to_string(),
expires_at,
is_offline,
}
}
fn percent_encode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.bytes() {
if b.is_ascii_alphanumeric() || b == b'-' || b == b'_' || b == b'.' || b == b'~' {
out.push(b as char);
} else {
out.push('%');
out.push(
char::from_digit((b >> 4) as u32, 16)
.unwrap_or('0')
.to_ascii_uppercase(),
);
out.push(
char::from_digit((b & 0xF) as u32, 16)
.unwrap_or('0')
.to_ascii_uppercase(),
);
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use hmac::{Hmac, Mac};
use sha2::Sha256;
const KEY: &[u8] = b"test-response-auth-key-32-bytes!";
const PID: &str = "prod-123";
fn mint(nonce: &str, rts: i64, valid: bool) -> String {
let payload = format!(
"{}|{}|{}|{}",
nonce,
rts,
if valid { "1" } else { "0" },
PID
);
let mut mac = Hmac::<Sha256>::new_from_slice(KEY).unwrap();
mac.update(payload.as_bytes());
base64::Engine::encode(
&base64::engine::general_purpose::STANDARD,
mac.finalize().into_bytes(),
)
}
fn now_ms() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis() as i64
}
fn resp(
rt: Option<String>,
rts: Option<i64>,
rn: Option<String>,
valid: bool,
) -> LicenseResponse {
LicenseResponse {
valid,
rt,
rts,
rn,
..Default::default()
}
}
#[test]
fn accepts_a_fresh_genuine_token() {
let nonce = "a".repeat(64);
let rts = now_ms();
let rt = mint(&nonce, rts, true);
let lic = resp(Some(rt), Some(rts), Some(nonce.clone()), true);
assert!(verify_response_token(KEY, &lic, &nonce, PID));
}
#[test]
fn rejects_replay_with_a_different_request_nonce() {
let captured_nonce = "a".repeat(64);
let rts = now_ms();
let rt = mint(&captured_nonce, rts, true);
let lic = resp(Some(rt), Some(rts), Some(captured_nonce), true);
let our_nonce = "b".repeat(64);
assert!(!verify_response_token(KEY, &lic, &our_nonce, PID));
}
#[test]
fn rejects_a_stale_token() {
let nonce = "c".repeat(64);
let rts = now_ms() - (RESPONSE_FRESHNESS_MS + 5_000); let rt = mint(&nonce, rts, true);
let lic = resp(Some(rt), Some(rts), Some(nonce.clone()), true);
assert!(!verify_response_token(KEY, &lic, &nonce, PID));
}
#[test]
fn rejects_a_flipped_valid_flag() {
let nonce = "d".repeat(64);
let rts = now_ms();
let rt = mint(&nonce, rts, false); let lic = resp(Some(rt), Some(rts), Some(nonce.clone()), true); assert!(!verify_response_token(KEY, &lic, &nonce, PID));
}
#[test]
fn rejects_a_missing_token() {
let nonce = "e".repeat(64);
let lic = resp(None, None, None, true);
assert!(!verify_response_token(KEY, &lic, &nonce, PID));
}
#[test]
fn nonce_is_64_hex_chars_and_unique() {
let a = generate_nonce();
let b = generate_nonce();
assert_eq!(a.len(), 64);
assert!(a.chars().all(|c| c.is_ascii_hexdigit()));
assert_ne!(a, b, "each request must get a distinct nonce");
}
#[test]
fn activate_response_shape_has_no_valid_field_only_success() {
let raw = r#"{"success":true,"message":"Already activated","license":{"id":"984dac15-17c5-4399-a7ff-5338c6c60462","machineId":"7dd3d45a3b7d7ee39f9e8e0c99419c2e","expiresAt":"","features":[],"issuedAt":"","revokedAt":"","signature":"sig","userId":"admin","deviceLimit":1},"signature":"sig","rt":"rt","rts":1783768883093,"rn":"nonce"}"#;
let lic: LicenseResponse =
serde_json::from_str(raw).expect("must deserialize despite missing `valid`");
assert!(!lic.valid);
assert_eq!(lic.success, Some(true));
}
}