use anyhow::{Context, Result}; use base64::{engine::general_purpose::STANDARD as B64, Engine as _}; use chrono::{DateTime, Utc}; use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; use rand::rngs::OsRng; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use std::path::{Path, PathBuf}; pub const LICENSE_RELATIVE_PATH: &str = "license/license.json"; pub const PUBLIC_KEY_RELATIVE_PATH: &str = "config/license-public-key.txt"; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LicensePayload { pub license_id: String, pub product: String, pub edition: String, pub customer_name: String, #[serde(default)] pub customer_email: Option, #[serde(default)] pub customer_company: Option, pub issued_at: DateTime, #[serde(default)] pub expires_at: Option>, #[serde(default)] pub max_devices: Option, #[serde(default)] pub features: Vec, #[serde(default)] pub notes: Option, } #[derive(Debug, Serialize, Deserialize)] struct LicenseFile { payload_b64: String, signature_b64: String, } #[derive(Debug, Clone)] pub struct LicenseStatus { pub state: &'static str, pub reason: Option, pub payload: Option, } impl LicenseStatus { pub fn to_json(&self) -> Value { json!({ "state": self.state, "licensed": self.state == "licensed", "reason": self.reason, "license": self.payload.as_ref().map(|p| json!({ "license_id": p.license_id, "product": p.product, "edition": p.edition, "customer_name": p.customer_name, "customer_company": p.customer_company, "issued_at": p.issued_at.to_rfc3339(), "expires_at": p.expires_at.map(|d| d.to_rfc3339()), "features": p.features, })), }) } } pub fn status(root: &Path) -> LicenseStatus { let verifying_key = match load_public_key(root) { Ok(Some(key)) => key, Ok(None) => { return LicenseStatus { state: "no_public_key", reason: Some(format!("missing {PUBLIC_KEY_RELATIVE_PATH}")), payload: None, } } Err(err) => { return LicenseStatus { state: "no_public_key", reason: Some(err.to_string()), payload: None, } } }; let license_path = root.join(LICENSE_RELATIVE_PATH); if !license_path.exists() { return LicenseStatus { state: "unlicensed", reason: Some("no license file installed".to_string()), payload: None, }; } let text = match std::fs::read_to_string(&license_path) { Ok(text) => text, Err(err) => { return LicenseStatus { state: "invalid", reason: Some(format!("cannot read license file: {err}")), payload: None, } } }; match verify_text(&text, &verifying_key) { Ok(payload) => evaluate_payload(payload), Err(err) => LicenseStatus { state: "invalid", reason: Some(err.to_string()), payload: None, }, } } pub fn install(root: &Path, content: &str) -> Result { let verifying_key = load_public_key(root)? .with_context(|| format!("missing {PUBLIC_KEY_RELATIVE_PATH}"))?; let payload = verify_text(content, &verifying_key)?; let status = evaluate_payload(payload); if status.state == "invalid" { anyhow::bail!( "license rejected: {}", status.reason.as_deref().unwrap_or("unknown reason") ); } let license_path = root.join(LICENSE_RELATIVE_PATH); if let Some(parent) = license_path.parent() { std::fs::create_dir_all(parent)?; } std::fs::write(&license_path, content)?; Ok(status) } pub fn keygen(out_dir: &Path) -> Result<(PathBuf, PathBuf)> { std::fs::create_dir_all(out_dir)?; let signing_key = SigningKey::generate(&mut OsRng); let private_path = out_dir.join("license-signing-key.txt"); let public_path = out_dir.join("license-public-key.txt"); std::fs::write(&private_path, B64.encode(signing_key.to_bytes()))?; std::fs::write( &public_path, B64.encode(signing_key.verifying_key().to_bytes()), )?; Ok((private_path, public_path)) } #[allow(clippy::too_many_arguments)] pub fn issue( signing_key_path: &Path, out_path: &Path, product: &str, edition: &str, customer_name: &str, customer_email: Option, customer_company: Option, expires_at: Option>, max_devices: Option, features: Vec, notes: Option, ) -> Result { let key_text = std::fs::read_to_string(signing_key_path) .with_context(|| format!("cannot read signing key {}", signing_key_path.display()))?; let key_bytes: [u8; 32] = B64 .decode(key_text.trim()) .context("signing key is not valid base64")? .try_into() .map_err(|_| anyhow::anyhow!("signing key must be 32 bytes"))?; let signing_key = SigningKey::from_bytes(&key_bytes); let payload = LicensePayload { license_id: uuid::Uuid::new_v4().to_string(), product: product.to_string(), edition: edition.to_string(), customer_name: customer_name.to_string(), customer_email, customer_company, issued_at: Utc::now(), expires_at, max_devices, features, notes, }; let payload_bytes = serde_json::to_vec(&payload)?; let signature = signing_key.sign(&payload_bytes); let file = LicenseFile { payload_b64: B64.encode(&payload_bytes), signature_b64: B64.encode(signature.to_bytes()), }; if let Some(parent) = out_path.parent() { if !parent.as_os_str().is_empty() { std::fs::create_dir_all(parent)?; } } std::fs::write(out_path, serde_json::to_string_pretty(&file)?)?; Ok(payload) } pub fn verify_path(license_path: &Path, public_key_path: &Path) -> Result { let key_text = std::fs::read_to_string(public_key_path) .with_context(|| format!("cannot read public key {}", public_key_path.display()))?; let verifying_key = parse_public_key(&key_text)?; let text = std::fs::read_to_string(license_path) .with_context(|| format!("cannot read license {}", license_path.display()))?; let payload = verify_text(&text, &verifying_key)?; Ok(evaluate_payload(payload)) } fn evaluate_payload(payload: LicensePayload) -> LicenseStatus { if let Some(expires_at) = payload.expires_at { if Utc::now() > expires_at { return LicenseStatus { state: "expired", reason: Some(format!("license expired {}", expires_at.to_rfc3339())), payload: Some(payload), }; } } LicenseStatus { state: "licensed", reason: None, payload: Some(payload), } } fn verify_text(text: &str, verifying_key: &VerifyingKey) -> Result { let file: LicenseFile = serde_json::from_str(text).context("license file is not valid JSON")?; let payload_bytes = B64 .decode(file.payload_b64.trim()) .context("license payload is not valid base64")?; let signature_bytes: [u8; 64] = B64 .decode(file.signature_b64.trim()) .context("license signature is not valid base64")? .try_into() .map_err(|_| anyhow::anyhow!("license signature must be 64 bytes"))?; let signature = Signature::from_bytes(&signature_bytes); verifying_key .verify(&payload_bytes, &signature) .map_err(|_| anyhow::anyhow!("license signature does not match"))?; serde_json::from_slice(&payload_bytes).context("license payload is not valid JSON") } fn load_public_key(root: &Path) -> Result> { let path = root.join(PUBLIC_KEY_RELATIVE_PATH); if !path.exists() { return Ok(None); } let text = std::fs::read_to_string(&path) .with_context(|| format!("cannot read public key {}", path.display()))?; Ok(Some(parse_public_key(&text)?)) } fn parse_public_key(text: &str) -> Result { let bytes: [u8; 32] = B64 .decode(text.trim()) .context("public key is not valid base64")? .try_into() .map_err(|_| anyhow::anyhow!("public key must be 32 bytes"))?; VerifyingKey::from_bytes(&bytes).context("public key is not a valid Ed25519 key") } #[cfg(test)] mod tests { use super::*; #[test] fn issued_license_verifies_and_round_trips() { let dir = std::env::temp_dir().join(format!("jkl-license-test-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let (private_path, public_path) = keygen(&dir).unwrap(); let license_path = dir.join("license.json"); issue( &private_path, &license_path, "JackAILocal", "pro", "Test Customer", Some("test@example.com".to_string()), None, None, Some(3), vec!["chat".to_string()], None, ) .unwrap(); let status = verify_path(&license_path, &public_path).unwrap(); assert_eq!(status.state, "licensed"); assert_eq!(status.payload.unwrap().customer_name, "Test Customer"); std::fs::remove_dir_all(&dir).ok(); } #[test] fn tampered_license_is_rejected() { let dir = std::env::temp_dir().join(format!("jkl-license-tamper-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let (private_path, public_path) = keygen(&dir).unwrap(); let license_path = dir.join("license.json"); issue( &private_path, &license_path, "JackAILocal", "personal", "Honest Customer", None, None, None, None, vec![], None, ) .unwrap(); let text = std::fs::read_to_string(&license_path).unwrap(); let mut file: LicenseFile = serde_json::from_str(&text).unwrap(); let mut payload: LicensePayload = serde_json::from_slice(&B64.decode(&file.payload_b64).unwrap()).unwrap(); payload.edition = "whitelabel".to_string(); file.payload_b64 = B64.encode(serde_json::to_vec(&payload).unwrap()); std::fs::write(&license_path, serde_json::to_string(&file).unwrap()).unwrap(); assert!(verify_path(&license_path, &public_path).is_err()); std::fs::remove_dir_all(&dir).ok(); } #[test] fn expired_license_reports_expired() { let dir = std::env::temp_dir().join(format!("jkl-license-exp-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let (private_path, public_path) = keygen(&dir).unwrap(); let license_path = dir.join("license.json"); issue( &private_path, &license_path, "JackAILocal", "pro", "Late Customer", None, None, Some(Utc::now() - chrono::Duration::days(1)), None, vec![], None, ) .unwrap(); let status = verify_path(&license_path, &public_path).unwrap(); assert_eq!(status.state, "expired"); std::fs::remove_dir_all(&dir).ok(); } }