feat(shared): implement core clipboard cleaning and license signature validation

This commit is contained in:
Stevan Freeborn
2026-07-23 11:58:30 -05:00
parent a6481370fa
commit 3b873f41a7
6 changed files with 604 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
tab_spaces = 2
+14
View File
@@ -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 }
+218
View File
@@ -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);
}
}
+3
View File
@@ -0,0 +1,3 @@
pub mod cleanser;
pub mod licensing;
pub mod types;
+306
View File
@@ -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"
);
}
}
+62
View File
@@ -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,
}