From 3b873f41a7f13fcf1e0ec5162fc43ddc9f02f9fd Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Thu, 23 Jul 2026 11:58:30 -0500 Subject: [PATCH] feat(shared): implement core clipboard cleaning and license signature validation --- shared/.rustfmt.toml | 1 + shared/Cargo.toml | 14 ++ shared/src/cleanser.rs | 218 ++++++++++++++++++++++++++++ shared/src/lib.rs | 3 + shared/src/licensing.rs | 306 ++++++++++++++++++++++++++++++++++++++++ shared/src/types.rs | 62 ++++++++ 6 files changed, 604 insertions(+) create mode 100644 shared/.rustfmt.toml create mode 100644 shared/Cargo.toml create mode 100644 shared/src/cleanser.rs create mode 100644 shared/src/lib.rs create mode 100644 shared/src/licensing.rs create mode 100644 shared/src/types.rs diff --git a/shared/.rustfmt.toml b/shared/.rustfmt.toml new file mode 100644 index 0000000..b196eaa --- /dev/null +++ b/shared/.rustfmt.toml @@ -0,0 +1 @@ +tab_spaces = 2 diff --git a/shared/Cargo.toml b/shared/Cargo.toml new file mode 100644 index 0000000..fc81e8c --- /dev/null +++ b/shared/Cargo.toml @@ -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 } diff --git a/shared/src/cleanser.rs b/shared/src/cleanser.rs new file mode 100644 index 0000000..cee71aa --- /dev/null +++ b/shared/src/cleanser.rs @@ -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::() + .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 = 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); + } +} diff --git a/shared/src/lib.rs b/shared/src/lib.rs new file mode 100644 index 0000000..32c9250 --- /dev/null +++ b/shared/src/lib.rs @@ -0,0 +1,3 @@ +pub mod cleanser; +pub mod licensing; +pub mod types; diff --git a/shared/src/licensing.rs b/shared/src/licensing.rs new file mode 100644 index 0000000..3ed1bf2 --- /dev/null +++ b/shared/src/licensing.rs @@ -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 = 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 { + 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 { + 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" + ); + } +} diff --git a/shared/src/types.rs b/shared/src/types.rs new file mode 100644 index 0000000..39f49dc --- /dev/null +++ b/shared/src/types.rs @@ -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, +} + +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, +} + +#[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, + #[serde(skip_serializing_if = "Option::is_none")] + pub expires_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub device_count: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LicenseCapabilities { + pub device_limit: Option, + pub expires_at: Option, + pub is_active: bool, +}