astraguard 1.1.7

Official AstraGuard SDK - license validation, HWID binding, anti-debug, and offline cache for Rust applications
Documentation
//! Local AES-256-GCM encrypted offline cache for license responses.
//!
//! Cache entries are stored in the platform data directory under
//! `AstraGuard/cache/<hash>.ag`. The AES key is derived from the HWID and
//! product ID using HMAC-SHA256  -  fast and collision-resistant for this use
//! case (these inputs are not attacker-controlled secrets, so PBKDF2 key
//! stretching is not needed).

use aes_gcm::{
    aead::{Aead, KeyInit},
    Aes256Gcm, Key, Nonce,
};
use rand::RngCore;
use serde::{Deserialize, Serialize};
use std::time::{SystemTime, UNIX_EPOCH};

use crate::types::LicenseResponse;

/// How long a cache entry is considered valid after its `validated_at`
/// timestamp. Acts as the offline grace period.
pub const GRACE_PERIOD_HOURS: u64 = 24;

#[derive(Serialize, Deserialize)]
struct CacheEntry {
    license_key: String,
    hwid: String,
    product_id: String,
    validated_at: u64,
    expires_at: Option<u64>,
    response_json: String,
    version: u8,
}

fn cache_path(product_id: &str, hwid: &str) -> Option<std::path::PathBuf> {
    use sha2::Digest;
    let input = format!("{}:{}", product_id, hwid);
    let hash = sha2::Sha256::digest(input.as_bytes());
    let hex = hex::encode(&hash[..8]);

    let base = dirs::data_local_dir()?;
    let dir = base.join("AstraGuard").join("cache");
    std::fs::create_dir_all(&dir).ok()?;
    Some(dir.join(format!("{}.ag", hex)))
}

/// Derive a 32-byte AES key from the HWID and product ID.
///
/// Uses HMAC-SHA256 with a fixed context prefix so the key space is separate
/// from any other HMAC usage in the SDK.
fn derive_key(hwid: &str, product_id: &str) -> [u8; 32] {
    use hmac::{Hmac, Mac};
    use sha2::Sha256;

    type HmacSha256 = Hmac<Sha256>;
    let mut mac = <HmacSha256 as Mac>::new_from_slice(b"astraguard-cache-v2")
        .expect("HMAC accepts any key length");
    mac.update(hwid.as_bytes());
    mac.update(b":");
    mac.update(product_id.as_bytes());
    mac.finalize().into_bytes().into()
}

/// Save a license response to the encrypted local cache.
pub fn save(license_key: &str, hwid: &str, product_id: &str, response: &LicenseResponse) {
    let Ok(json) = serde_json::to_string(response) else {
        return;
    };
    let now = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap_or_default()
        .as_secs();

    let expires_ts = response
        .expires_at
        .as_deref()
        .and_then(chrono_like_parse_pub);

    let entry = CacheEntry {
        license_key: license_key.to_string(),
        hwid: hwid.to_string(),
        product_id: product_id.to_string(),
        validated_at: now,
        expires_at: expires_ts,
        response_json: json,
        version: 1,
    };

    let Ok(plaintext) = serde_json::to_vec(&entry) else {
        return;
    };
    let key_bytes = derive_key(hwid, product_id);
    let key = Key::<Aes256Gcm>::from_slice(&key_bytes);
    let cipher = Aes256Gcm::new(key);

    let mut nonce_bytes = [0u8; 12];
    rand::thread_rng().fill_bytes(&mut nonce_bytes);
    let nonce = Nonce::from_slice(&nonce_bytes);

    let Ok(ciphertext) = cipher.encrypt(nonce, plaintext.as_ref()) else {
        return;
    };

    let mut blob = Vec::with_capacity(12 + ciphertext.len());
    blob.extend_from_slice(&nonce_bytes);
    blob.extend_from_slice(&ciphertext);

    if let Some(path) = cache_path(product_id, hwid) {
        let _ = std::fs::write(path, blob);
    }
}

/// Load and decrypt a cache entry. Returns `None` if missing, corrupt, or
/// expired (either by license expiry or grace period).
pub fn load(license_key: &str, hwid: &str, product_id: &str) -> Option<LicenseResponse> {
    let path = cache_path(product_id, hwid)?;
    let blob = std::fs::read(&path).ok()?;
    if blob.len() < 13 {
        return None;
    }

    let nonce = Nonce::from_slice(&blob[..12]);
    let ciphertext = &blob[12..];

    let key_bytes = derive_key(hwid, product_id);
    let key = Key::<Aes256Gcm>::from_slice(&key_bytes);
    let cipher = Aes256Gcm::new(key);

    let plaintext = cipher.decrypt(nonce, ciphertext).ok()?;
    let entry: CacheEntry = serde_json::from_slice(&plaintext).ok()?;

    if entry.hwid != hwid || entry.product_id != product_id || entry.license_key != license_key {
        return None;
    }

    let now = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap_or_default()
        .as_secs();

    if let Some(exp) = entry.expires_at {
        if exp < now {
            return None;
        }
    }

    let age_secs = now.saturating_sub(entry.validated_at);
    if age_secs > GRACE_PERIOD_HOURS * 3600 {
        return None;
    }

    let mut resp: LicenseResponse = serde_json::from_str(&entry.response_json).ok()?;
    resp.is_offline_cache = Some(true);
    Some(resp)
}

/// Delete the cache entry for this product/HWID combination.
#[allow(dead_code)]
pub fn invalidate(product_id: &str, hwid: &str) {
    if let Some(path) = cache_path(product_id, hwid) {
        let _ = std::fs::remove_file(path);
    }
}

/// Parse an ISO 8601 string (e.g. `"2026-12-31T00:00:00Z"`) into a Unix
/// timestamp in seconds.  Returns `None` on any parse failure.
///
/// Public so `client.rs` can reuse it for `LicenseDetails::expires_at`.
pub fn chrono_like_parse_pub(s: &str) -> Option<u64> {
    // Strip trailing 'Z' and fractional seconds before parsing.
    let s = s.trim_end_matches('Z');
    let s = s.split('.').next().unwrap_or(s); // strip ".123" fractional part

    let parts: Vec<&str> = s.splitn(2, 'T').collect();
    if parts.len() != 2 {
        return None;
    }
    let date_parts: Vec<u32> = parts[0].split('-').filter_map(|p| p.parse().ok()).collect();
    let time_parts: Vec<u32> = parts[1]
        .split(':')
        .filter_map(|p| p.parse::<f64>().ok().map(|v| v as u32))
        .collect();
    if date_parts.len() < 3 || time_parts.len() < 3 {
        return None;
    }

    let (y, m, d) = (
        date_parts[0] as i64,
        date_parts[1] as i64,
        date_parts[2] as i64,
    );
    let (h, min, sec) = (
        time_parts[0] as i64,
        time_parts[1] as i64,
        time_parts[2] as i64,
    );

    let days = days_from_ymd(y, m, d)?;
    let ts = days * 86400 + h * 3600 + min * 60 + sec;
    if ts < 0 {
        return None;
    }
    Some(ts as u64)
}

fn days_from_ymd(y: i64, m: i64, d: i64) -> Option<i64> {
    if !(1..=12).contains(&m) || !(1..=31).contains(&d) {
        return None;
    }
    let era = if m <= 2 { y - 1 } else { y };
    let yoe = era.rem_euclid(400);
    let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1;
    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
    Some(era / 400 * 146097 + doe - 719468)
}