| 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<String>, |
| #[serde(default)] |
| pub customer_company: Option<String>, |
| pub issued_at: DateTime<Utc>, |
| #[serde(default)] |
| pub expires_at: Option<DateTime<Utc>>, |
| #[serde(default)] |
| pub max_devices: Option<u32>, |
| #[serde(default)] |
| pub features: Vec<String>, |
| #[serde(default)] |
| pub notes: Option<String>, |
| } |
|
|
| #[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<String>, |
| pub payload: Option<LicensePayload>, |
| } |
|
|
| 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<LicenseStatus> { |
| 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<String>, |
| customer_company: Option<String>, |
| expires_at: Option<DateTime<Utc>>, |
| max_devices: Option<u32>, |
| features: Vec<String>, |
| notes: Option<String>, |
| ) -> Result<LicensePayload> { |
| 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<LicenseStatus> { |
| 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<LicensePayload> { |
| 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<Option<VerifyingKey>> { |
| 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<VerifyingKey> { |
| 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(); |
| } |
| } |
|
|