feat(shared): implement core clipboard cleaning and license signature validation
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
tab_spaces = 2
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
[package]
|
||||||
|
name = "shared"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition.workspace = true
|
||||||
|
authors.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
serde = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
ed25519-dalek = { workspace = true }
|
||||||
|
base64 = { workspace = true }
|
||||||
|
sha2 = { workspace = true }
|
||||||
|
url = { workspace = true }
|
||||||
|
log = { workspace = true }
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
use crate::types::CleanConfig;
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
|
pub fn clean_whitespace(input: &str) -> String {
|
||||||
|
input
|
||||||
|
.chars()
|
||||||
|
.filter(|&c| c != '\u{200b}' && c != '\x00')
|
||||||
|
.collect::<String>()
|
||||||
|
.trim()
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn fix_typography(input: &str) -> String {
|
||||||
|
let mut result = String::with_capacity(input.len());
|
||||||
|
|
||||||
|
for c in input.chars() {
|
||||||
|
match c {
|
||||||
|
'\u{201c}' | '\u{201d}' => result.push('"'),
|
||||||
|
'\u{2018}' | '\u{2019}' => result.push('\''),
|
||||||
|
'\u{2014}' => result.push_str("--"),
|
||||||
|
'\u{2013}' => result.push('-'),
|
||||||
|
_ => result.push(c),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_markdown_line(line: &str) -> bool {
|
||||||
|
let trimmed = line.trim_start();
|
||||||
|
|
||||||
|
trimmed.starts_with("- ")
|
||||||
|
|| trimmed.starts_with("* ")
|
||||||
|
|| trimmed.starts_with("#")
|
||||||
|
|| (trimmed.len() > 2
|
||||||
|
&& trimmed.chars().next().unwrap().is_ascii_digit()
|
||||||
|
&& trimmed.contains(". "))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn normalize_newlines(input: &str) -> String {
|
||||||
|
let normalized = input.replace("\r\n", "\n");
|
||||||
|
let without_hyphens = normalized.replace("-\n", "");
|
||||||
|
let lines: Vec<&str> = without_hyphens.lines().collect();
|
||||||
|
|
||||||
|
let mut result = String::new();
|
||||||
|
|
||||||
|
for i in 0..lines.len() {
|
||||||
|
let current = lines[i];
|
||||||
|
result.push_str(current);
|
||||||
|
|
||||||
|
if i < lines.len() - 1 {
|
||||||
|
let next = lines[i + 1];
|
||||||
|
|
||||||
|
if current.is_empty()
|
||||||
|
|| next.is_empty()
|
||||||
|
|| is_markdown_line(next)
|
||||||
|
|| is_markdown_line(current)
|
||||||
|
{
|
||||||
|
result.push('\n');
|
||||||
|
} else {
|
||||||
|
result.push(' ');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn scrub_url_trackers(input: &str, custom_params: &[String]) -> String {
|
||||||
|
let mut result = String::new();
|
||||||
|
let mut current_idx = 0;
|
||||||
|
let chars: Vec<char> = input.chars().collect();
|
||||||
|
|
||||||
|
while current_idx < chars.len() {
|
||||||
|
if (current_idx + 7 <= chars.len()
|
||||||
|
&& chars[current_idx..current_idx + 7] == ['h', 't', 't', 'p', ':', '/', '/'])
|
||||||
|
|| (current_idx + 8 <= chars.len()
|
||||||
|
&& chars[current_idx..current_idx + 8] == ['h', 't', 't', 'p', 's', ':', '/', '/'])
|
||||||
|
{
|
||||||
|
let start = current_idx;
|
||||||
|
|
||||||
|
while current_idx < chars.len() && !chars[current_idx].is_whitespace() {
|
||||||
|
current_idx += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let url_str: String = chars[start..current_idx].iter().collect();
|
||||||
|
|
||||||
|
if let Ok(mut url) = Url::parse(&url_str) {
|
||||||
|
if url.query().is_some() {
|
||||||
|
let query_pairs: Vec<(String, String)> = url
|
||||||
|
.query_pairs()
|
||||||
|
.map(|(k, v)| (k.into_owned(), v.into_owned()))
|
||||||
|
.filter(|(k, _)| {
|
||||||
|
let kl = k.to_lowercase();
|
||||||
|
!kl.starts_with("utm_")
|
||||||
|
&& kl != "gclid"
|
||||||
|
&& kl != "fbclid"
|
||||||
|
&& kl != "si"
|
||||||
|
&& !custom_params.iter().any(|p| p.to_lowercase() == kl)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
url.set_query(None);
|
||||||
|
|
||||||
|
if !query_pairs.is_empty() {
|
||||||
|
let mut serializer = url.query_pairs_mut();
|
||||||
|
|
||||||
|
for (k, v) in query_pairs {
|
||||||
|
serializer.append_pair(&k, &v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.push_str(url.as_str());
|
||||||
|
} else {
|
||||||
|
result.push_str(&url_str);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
result.push(chars[current_idx]);
|
||||||
|
current_idx += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sanitize_text(input: &str, config: &CleanConfig) -> String {
|
||||||
|
let mut text = clean_whitespace(input);
|
||||||
|
|
||||||
|
if config.fix_quotes {
|
||||||
|
text = fix_typography(&text);
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.fix_newlines {
|
||||||
|
text = normalize_newlines(&text);
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.strip_utms {
|
||||||
|
text = scrub_url_trackers(&text, &config.custom_params);
|
||||||
|
}
|
||||||
|
|
||||||
|
text
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_whitespace() {
|
||||||
|
let input = "\u{200b} hello\x00world\t \n ";
|
||||||
|
let result = clean_whitespace(input);
|
||||||
|
assert_eq!(result, "helloworld");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_typography() {
|
||||||
|
let input = "\u{201c}hello\u{201d} \u{2018}world\u{2019}\u{2014}dash\u{2013}en";
|
||||||
|
let result = fix_typography(input);
|
||||||
|
assert_eq!(result, "\"hello\" 'world'--dash-en");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_newlines() {
|
||||||
|
let input = "inno-\nvation is\ngreat.\n\nParagraph 2.\n- Item 1\n- Item 2";
|
||||||
|
let result = normalize_newlines(input);
|
||||||
|
let expected = "innovation is great.\n\nParagraph 2.\n- Item 1\n- Item 2";
|
||||||
|
assert_eq!(result, expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_url_scrubber() {
|
||||||
|
let input =
|
||||||
|
"Check https://example.com/path?utm_source=twitter&id=1024&gclid=123#frag and simple text.";
|
||||||
|
let result = scrub_url_trackers(input, &[]);
|
||||||
|
let expected = "Check https://example.com/path?id=1024#frag and simple text.";
|
||||||
|
assert_eq!(result, expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_url_scrubber_custom_params() {
|
||||||
|
let input = "https://example.com/page?ref=home&tracking_id=abc&id=99";
|
||||||
|
let result = scrub_url_trackers(input, &["ref".to_string(), "tracking_id".to_string()]);
|
||||||
|
let expected = "https://example.com/page?id=99";
|
||||||
|
assert_eq!(result, expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_url_scrubber_custom_params_only() {
|
||||||
|
let input = "https://example.com/page?campaign=summer&fbclid=xyz&token=secret";
|
||||||
|
let result = scrub_url_trackers(input, &["campaign".to_string()]);
|
||||||
|
let expected = "https://example.com/page?token=secret";
|
||||||
|
assert_eq!(result, expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_url_scrubber_custom_params_case_insensitive() {
|
||||||
|
let input = "https://example.com/page?Ref=home&REF=other&id=1";
|
||||||
|
let result = scrub_url_trackers(input, &["ref".to_string()]);
|
||||||
|
let expected = "https://example.com/page?id=1";
|
||||||
|
assert_eq!(result, expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_master_pipeline() {
|
||||||
|
let config = CleanConfig {
|
||||||
|
strip_utms: true,
|
||||||
|
fix_quotes: true,
|
||||||
|
fix_newlines: true,
|
||||||
|
..CleanConfig::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let input = "\u{201c}inno-\nvation\u{201d} on https://example.com?utm_source=test&id=123";
|
||||||
|
let result = sanitize_text(input, &config);
|
||||||
|
let expected = "\"innovation\" on https://example.com/?id=123";
|
||||||
|
assert_eq!(result, expected);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
pub mod cleanser;
|
||||||
|
pub mod licensing;
|
||||||
|
pub mod types;
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
use base64::{engine::general_purpose::STANDARD, Engine as _};
|
||||||
|
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
|
use crate::types::{LicensePayload, LicensingProfiles, Profile};
|
||||||
|
|
||||||
|
const PROFILES_JSON: &str = include_str!("../../licensing_profiles.json");
|
||||||
|
|
||||||
|
static ACTIVE_PROFILE: OnceLock<Profile> = OnceLock::new();
|
||||||
|
|
||||||
|
pub fn get_active_profile() -> &'static Profile {
|
||||||
|
ACTIVE_PROFILE.get_or_init(|| {
|
||||||
|
let profiles: LicensingProfiles = serde_json::from_str(PROFILES_JSON)
|
||||||
|
.expect("Failed to parse compile-time licensing_profiles.json");
|
||||||
|
|
||||||
|
if cfg!(test) {
|
||||||
|
profiles.testing
|
||||||
|
} else if cfg!(debug_assertions) {
|
||||||
|
profiles.development
|
||||||
|
} else {
|
||||||
|
profiles.production
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
static PUBLIC_KEY_CACHE: OnceLock<[u8; 32]> = OnceLock::new();
|
||||||
|
|
||||||
|
pub fn decode_public_key_hex(hex: &str) -> [u8; 32] {
|
||||||
|
let hex = hex.trim();
|
||||||
|
|
||||||
|
if hex.len() != 64 {
|
||||||
|
panic!("Hex public key must be exactly 64 characters");
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut bytes = [0u8; 32];
|
||||||
|
|
||||||
|
for i in 0..32 {
|
||||||
|
let byte_str = &hex[i * 2..i * 2 + 2];
|
||||||
|
bytes[i] = u8::from_str_radix(byte_str, 16).expect("Invalid hex in public key");
|
||||||
|
}
|
||||||
|
|
||||||
|
bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_public_key_bytes() -> [u8; 32] {
|
||||||
|
if let Some(cached) = PUBLIC_KEY_CACHE.get() {
|
||||||
|
return *cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
let bytes = decode_public_key_hex(&get_active_profile().public_key_hex);
|
||||||
|
let _ = PUBLIC_KEY_CACHE.set(bytes);
|
||||||
|
|
||||||
|
bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_all_valid_key_bytes() -> Vec<[u8; 32]> {
|
||||||
|
let profile = get_active_profile();
|
||||||
|
let mut keys = vec![decode_public_key_hex(&profile.public_key_hex)];
|
||||||
|
|
||||||
|
for hex in &profile.deprecated_public_keys {
|
||||||
|
keys.push(decode_public_key_hex(hex));
|
||||||
|
}
|
||||||
|
|
||||||
|
keys
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_activation_url() -> &'static str {
|
||||||
|
&get_active_profile().activation_url
|
||||||
|
}
|
||||||
|
|
||||||
|
static LICENSE_ACTIVE: AtomicBool = AtomicBool::new(false);
|
||||||
|
|
||||||
|
pub fn set_license_active(active: bool) {
|
||||||
|
LICENSE_ACTIVE.store(active, Ordering::Relaxed);
|
||||||
|
log::info!("License active cache set to {}", active);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_license_active() -> bool {
|
||||||
|
LICENSE_ACTIVE.load(Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn verify_license_with_key(
|
||||||
|
license_token: &str,
|
||||||
|
public_key_bytes: &[u8],
|
||||||
|
current_hwid: &str,
|
||||||
|
) -> Result<LicensePayload, String> {
|
||||||
|
let parts: Vec<&str> = license_token.trim().split('.').collect();
|
||||||
|
|
||||||
|
if parts.len() != 2 {
|
||||||
|
return Err("Invalid license token format. Expected PAYLOAD.SIGNATURE".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload_b64 = parts[0];
|
||||||
|
let signature_b64 = parts[1];
|
||||||
|
|
||||||
|
let payload_bytes = STANDARD
|
||||||
|
.decode(payload_b64)
|
||||||
|
.map_err(|e| format!("Failed to decode payload: {}", e))?;
|
||||||
|
|
||||||
|
let payload_str =
|
||||||
|
String::from_utf8(payload_bytes).map_err(|e| format!("Invalid UTF-8 in payload: {}", e))?;
|
||||||
|
|
||||||
|
let payload: LicensePayload = serde_json::from_str(&payload_str)
|
||||||
|
.map_err(|e| format!("Failed to parse payload JSON: {}", e))?;
|
||||||
|
|
||||||
|
let signature_bytes = STANDARD
|
||||||
|
.decode(signature_b64)
|
||||||
|
.map_err(|e| format!("Failed to decode signature: {}", e))?;
|
||||||
|
|
||||||
|
if signature_bytes.len() != 64 {
|
||||||
|
return Err("Signature must be 64 bytes".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let sig_arr: [u8; 64] = signature_bytes
|
||||||
|
.try_into()
|
||||||
|
.map_err(|_| "Signature convert failed".to_string())?;
|
||||||
|
|
||||||
|
let signature = Signature::from_bytes(&sig_arr);
|
||||||
|
|
||||||
|
let pub_key_arr: [u8; 32] = public_key_bytes
|
||||||
|
.try_into()
|
||||||
|
.map_err(|_| "Invalid public key length".to_string())?;
|
||||||
|
|
||||||
|
let verifying_key =
|
||||||
|
VerifyingKey::from_bytes(&pub_key_arr).map_err(|e| format!("Invalid verifying key: {}", e))?;
|
||||||
|
|
||||||
|
verifying_key
|
||||||
|
.verify(payload_b64.as_bytes(), &signature)
|
||||||
|
.map_err(|e| format!("Signature verification failed: {}", e))?;
|
||||||
|
|
||||||
|
if payload.hwid != current_hwid {
|
||||||
|
return Err("License does not match this machine's Hardware ID".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
if payload.product_id != "cleancopy" {
|
||||||
|
return Err("License is not valid for this product".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn verify_license_with_any_key(
|
||||||
|
license_token: &str,
|
||||||
|
public_keys: &[[u8; 32]],
|
||||||
|
current_hwid: &str,
|
||||||
|
) -> Result<LicensePayload, String> {
|
||||||
|
let mut last_err = String::new();
|
||||||
|
|
||||||
|
for key in public_keys {
|
||||||
|
match verify_license_with_key(license_token, key, current_hwid) {
|
||||||
|
Ok(payload) => return Ok(payload),
|
||||||
|
Err(e) => last_err = e,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(last_err)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use base64::{engine::general_purpose::STANDARD, Engine as _};
|
||||||
|
use ed25519_dalek::Signer;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_offline_validation() {
|
||||||
|
let secret = [42u8; 32];
|
||||||
|
let signing_key = ed25519_dalek::SigningKey::from_bytes(&secret);
|
||||||
|
let verifying_key = signing_key.verifying_key();
|
||||||
|
let pub_key_bytes = verifying_key.to_bytes();
|
||||||
|
|
||||||
|
let dummy_hwid = "test-machine-hwid-abc-123".to_string();
|
||||||
|
let payload = LicensePayload {
|
||||||
|
product_id: "cleancopy".to_string(),
|
||||||
|
license_key: "pi_12345".to_string(),
|
||||||
|
hwid: dummy_hwid.clone(),
|
||||||
|
max_devices: None,
|
||||||
|
expires_at: None,
|
||||||
|
device_count: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let payload_str = serde_json::to_string(&payload).unwrap();
|
||||||
|
let payload_b64 = STANDARD.encode(payload_str.as_bytes());
|
||||||
|
|
||||||
|
let signature = signing_key.sign(payload_b64.as_bytes());
|
||||||
|
let signature_b64 = STANDARD.encode(signature.to_bytes());
|
||||||
|
|
||||||
|
let license_token = format!("{}.{}", payload_b64, signature_b64);
|
||||||
|
|
||||||
|
let verify_res = verify_license_with_key(&license_token, &pub_key_bytes, &dummy_hwid);
|
||||||
|
assert!(verify_res.is_ok());
|
||||||
|
|
||||||
|
let verify_res_wrong_hwid =
|
||||||
|
verify_license_with_key(&license_token, &pub_key_bytes, "other-machine-hwid");
|
||||||
|
assert!(verify_res_wrong_hwid.is_err());
|
||||||
|
|
||||||
|
let tampered_license = format!("{}.{}", payload_b64, STANDARD.encode([0u8; 64]));
|
||||||
|
let verify_res_tampered =
|
||||||
|
verify_license_with_key(&tampered_license, &pub_key_bytes, &dummy_hwid);
|
||||||
|
assert!(verify_res_tampered.is_err());
|
||||||
|
|
||||||
|
let wrong_payload = LicensePayload {
|
||||||
|
product_id: "other_app".to_string(),
|
||||||
|
license_key: "pi_12345".to_string(),
|
||||||
|
hwid: dummy_hwid.clone(),
|
||||||
|
max_devices: None,
|
||||||
|
expires_at: None,
|
||||||
|
device_count: None,
|
||||||
|
};
|
||||||
|
let wrong_payload_str = serde_json::to_string(&wrong_payload).unwrap();
|
||||||
|
let wrong_payload_b64 = STANDARD.encode(wrong_payload_str.as_bytes());
|
||||||
|
let wrong_signature = signing_key.sign(wrong_payload_b64.as_bytes());
|
||||||
|
let wrong_signature_b64 = STANDARD.encode(wrong_signature.to_bytes());
|
||||||
|
let wrong_token = format!("{}.{}", wrong_payload_b64, wrong_signature_b64);
|
||||||
|
|
||||||
|
let verify_res_wrong_product =
|
||||||
|
verify_license_with_key(&wrong_token, &pub_key_bytes, &dummy_hwid);
|
||||||
|
assert!(verify_res_wrong_product.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_profile_configuration_loading() {
|
||||||
|
let active = get_active_profile();
|
||||||
|
assert!(!active.activation_url.is_empty());
|
||||||
|
assert_eq!(active.public_key_hex.len(), 64);
|
||||||
|
|
||||||
|
let bytes = get_public_key_bytes();
|
||||||
|
assert_eq!(bytes.len(), 32);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_deprecated_key_verification() {
|
||||||
|
let old_secret = [11u8; 32];
|
||||||
|
let new_secret = [22u8; 32];
|
||||||
|
let old_signing_key = ed25519_dalek::SigningKey::from_bytes(&old_secret);
|
||||||
|
let new_signing_key = ed25519_dalek::SigningKey::from_bytes(&new_secret);
|
||||||
|
let old_pub_key = old_signing_key.verifying_key().to_bytes();
|
||||||
|
let new_pub_key = new_signing_key.verifying_key().to_bytes();
|
||||||
|
|
||||||
|
let dummy_hwid = "test-machine-hwid-deprecated".to_string();
|
||||||
|
let payload = LicensePayload {
|
||||||
|
product_id: "cleancopy".to_string(),
|
||||||
|
license_key: "pi_99999".to_string(),
|
||||||
|
hwid: dummy_hwid.clone(),
|
||||||
|
max_devices: None,
|
||||||
|
expires_at: None,
|
||||||
|
device_count: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let payload_str = serde_json::to_string(&payload).unwrap();
|
||||||
|
let payload_b64 = STANDARD.encode(payload_str.as_bytes());
|
||||||
|
|
||||||
|
let signature = old_signing_key.sign(payload_b64.as_bytes());
|
||||||
|
let signature_b64 = STANDARD.encode(signature.to_bytes());
|
||||||
|
let token = format!("{}.{}", payload_b64, signature_b64);
|
||||||
|
|
||||||
|
let keys_only_new = vec![new_pub_key];
|
||||||
|
assert!(
|
||||||
|
verify_license_with_any_key(&token, &keys_only_new, &dummy_hwid).is_err(),
|
||||||
|
"Should fail when only new key is available"
|
||||||
|
);
|
||||||
|
|
||||||
|
let keys_with_old = vec![new_pub_key, old_pub_key];
|
||||||
|
assert!(
|
||||||
|
verify_license_with_any_key(&token, &keys_with_old, &dummy_hwid).is_ok(),
|
||||||
|
"Should succeed when deprecated key is in the list"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_offline_token_format_verification() {
|
||||||
|
let secret = [77u8; 32];
|
||||||
|
let signing_key = ed25519_dalek::SigningKey::from_bytes(&secret);
|
||||||
|
let verifying_key = signing_key.verifying_key();
|
||||||
|
let pub_key_bytes = verifying_key.to_bytes();
|
||||||
|
|
||||||
|
let dummy_hwid = "test-offline-token-hwid".to_string();
|
||||||
|
let payload = LicensePayload {
|
||||||
|
product_id: "cleancopy".to_string(),
|
||||||
|
license_key: "manual_activation".to_string(),
|
||||||
|
hwid: dummy_hwid.clone(),
|
||||||
|
max_devices: None,
|
||||||
|
expires_at: None,
|
||||||
|
device_count: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let payload_str = serde_json::to_string(&payload).unwrap();
|
||||||
|
let payload_b64 = STANDARD.encode(payload_str.as_bytes());
|
||||||
|
let signature = signing_key.sign(payload_b64.as_bytes());
|
||||||
|
let signature_b64 = STANDARD.encode(signature.to_bytes());
|
||||||
|
let token = format!("{}.{}", payload_b64, signature_b64);
|
||||||
|
|
||||||
|
assert!(token.contains('.'), "Token must contain period separator");
|
||||||
|
|
||||||
|
let keys = vec![pub_key_bytes];
|
||||||
|
let result = verify_license_with_any_key(&token, &keys, &dummy_hwid);
|
||||||
|
assert!(result.is_ok(), "Offline token should verify successfully");
|
||||||
|
|
||||||
|
let result_wrong_hwid = verify_license_with_any_key(&token, &keys, "wrong-hwid");
|
||||||
|
assert!(
|
||||||
|
result_wrong_hwid.is_err(),
|
||||||
|
"Offline token should reject wrong HWID"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct CleanConfig {
|
||||||
|
pub strip_utms: bool,
|
||||||
|
pub fix_quotes: bool,
|
||||||
|
pub fix_newlines: bool,
|
||||||
|
pub play_sound: bool,
|
||||||
|
pub autostart: bool,
|
||||||
|
pub hotkey: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub custom_params: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for CleanConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
strip_utms: true,
|
||||||
|
fix_quotes: true,
|
||||||
|
fix_newlines: true,
|
||||||
|
play_sound: true,
|
||||||
|
autostart: false,
|
||||||
|
hotkey: "Alt+Shift+C".to_string(),
|
||||||
|
custom_params: vec![],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct Profile {
|
||||||
|
pub activation_url: String,
|
||||||
|
pub public_key_hex: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub deprecated_public_keys: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct LicensingProfiles {
|
||||||
|
pub development: Profile,
|
||||||
|
pub testing: Profile,
|
||||||
|
pub production: Profile,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct LicensePayload {
|
||||||
|
pub product_id: String,
|
||||||
|
pub license_key: String,
|
||||||
|
pub hwid: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub max_devices: Option<u32>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub expires_at: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub device_count: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct LicenseCapabilities {
|
||||||
|
pub device_limit: Option<u32>,
|
||||||
|
pub expires_at: Option<String>,
|
||||||
|
pub is_active: bool,
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user