jackailocal / src /license.rs
jackboy70's picture
Deploy: accurate lite-builder note
f25362a
Raw
History Blame Contribute Delete
11.8 kB
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();
}
}