use base64::{Engine as _, engine::general_purpose::STANDARD};
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone)]
pub struct LicenseConfig {
pub schema_version: &'static str,
pub public_key_base64: &'static str,
pub license_env_var: &'static str,
pub app_qualifier: &'static str,
pub app_org: &'static str,
pub app_name: &'static str,
pub license_filename: &'static str,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LicensePayload {
pub schema: String,
pub license_id: String,
pub issued_to_org: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub contact_email: Option<String>,
pub license_type: String,
pub scope: String,
pub perpetual: bool,
pub issued_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LicenseFile {
#[serde(flatten)]
pub payload: LicensePayload,
pub signature: String,
}
#[derive(Debug, thiserror::Error)]
pub enum LicenseError {
#[error(
"No license file found.\n\
\n\
Place your license file at one of:\n\
\t1. Pass the path via the CLI argument\n\
\t2. Set the license environment variable\n\
\t3. Platform config directory\n\
\t4. Legacy Unix path: ~/.config/<app>/license.json"
)]
NotFound,
#[error("Failed to read license file '{path}': {source}")]
ReadError {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error(
"Failed to parse license file as JSON: {0}\n\
\n\
Common causes:\n\
• The file was modified or truncated\n\
• The file was saved with a UTF-8 BOM\n\
• The file was downloaded as HTML instead of raw JSON"
)]
ParseError(#[from] serde_json::Error),
#[error(
"Invalid license schema: expected '{expected}', found '{found}'.\n\
This license was issued for a different version or application."
)]
InvalidSchema { expected: String, found: String },
#[error(
"License signature is invalid.\n\
The license file may have been tampered with or was not issued by Traitome."
)]
InvalidSignature,
#[error("Internal error — invalid embedded public key: {0}")]
InvalidPublicKey(String),
#[error("Invalid signature encoding in license file: {0}")]
InvalidSignatureEncoding(String),
}
pub fn verify_license(license: &LicenseFile, config: &LicenseConfig) -> Result<(), LicenseError> {
verify_license_with_key(license, config.public_key_base64, config.schema_version)
}
pub fn verify_license_with_key(
license: &LicenseFile,
pubkey_base64: &str,
schema_version: &str,
) -> Result<(), LicenseError> {
if license.payload.schema != schema_version {
return Err(LicenseError::InvalidSchema {
expected: schema_version.to_string(),
found: license.payload.schema.clone(),
});
}
let pubkey_bytes = STANDARD
.decode(pubkey_base64)
.map_err(|e| LicenseError::InvalidPublicKey(e.to_string()))?;
let pubkey_array: [u8; 32] = pubkey_bytes
.try_into()
.map_err(|_| LicenseError::InvalidPublicKey("expected exactly 32 bytes".to_string()))?;
let verifying_key = VerifyingKey::from_bytes(&pubkey_array)
.map_err(|e| LicenseError::InvalidPublicKey(e.to_string()))?;
let sig_bytes = STANDARD
.decode(&license.signature)
.map_err(|e| LicenseError::InvalidSignatureEncoding(e.to_string()))?;
let sig_array: [u8; 64] = sig_bytes.try_into().map_err(|_| {
LicenseError::InvalidSignatureEncoding("expected exactly 64 bytes".to_string())
})?;
let signature = Signature::from_bytes(&sig_array);
let payload_bytes = serde_json::to_vec(&license.payload).map_err(LicenseError::ParseError)?;
verifying_key
.verify(&payload_bytes, &signature)
.map_err(|_| LicenseError::InvalidSignature)?;
Ok(())
}
pub fn find_license_path(cli_arg: Option<&Path>, config: &LicenseConfig) -> Option<PathBuf> {
if let Some(p) = cli_arg {
return Some(p.to_path_buf());
}
if let Ok(val) = std::env::var(config.license_env_var) {
let p = PathBuf::from(val.trim());
return Some(p);
}
default_license_candidates(config)
.into_iter()
.find(|candidate| candidate.exists())
}
pub fn load_and_verify(
cli_arg: Option<&Path>,
config: &LicenseConfig,
) -> Result<LicenseFile, LicenseError> {
let path = find_license_path(cli_arg, config).ok_or(LicenseError::NotFound)?;
let contents = std::fs::read_to_string(&path).map_err(|e| LicenseError::ReadError {
path: path.clone(),
source: e,
})?;
let license: LicenseFile = serde_json::from_str(&contents)?;
verify_license(&license, config)?;
Ok(license)
}
fn legacy_unix_license_path(home_dir: Option<PathBuf>, config: &LicenseConfig) -> Option<PathBuf> {
home_dir.map(|home| {
home.join(".config")
.join(config.app_name)
.join(config.license_filename)
})
}
fn default_license_candidates(config: &LicenseConfig) -> Vec<PathBuf> {
let mut candidates: Vec<PathBuf> = Vec::new();
#[cfg(not(target_arch = "wasm32"))]
{
if let Some(dirs) =
directories::ProjectDirs::from(config.app_qualifier, config.app_org, config.app_name)
{
candidates.push(dirs.config_dir().join(config.license_filename));
}
}
let home_dir = std::env::var_os("HOME").map(PathBuf::from);
if let Some(path) = legacy_unix_license_path(home_dir, config)
&& !candidates.contains(&path)
{
candidates.push(path);
}
candidates
}
#[cfg(test)]
mod tests {
use super::*;
#[allow(unused_imports)]
use base64::{Engine as _, engine::general_purpose::STANDARD};
use ed25519_dalek::{Signer, SigningKey};
use rand::rngs::OsRng;
use std::sync::Mutex;
const TEST_SCHEMA: &str = "test-app-license-v1";
static ENV_MUTEX: Mutex<()> = Mutex::new(());
fn make_test_keypair() -> (SigningKey, String) {
let key = SigningKey::generate(&mut OsRng);
let pubkey_b64 = STANDARD.encode(key.verifying_key().as_bytes());
(key, pubkey_b64)
}
fn make_config(pubkey_b64: &'static str) -> LicenseConfig {
LicenseConfig {
schema_version: TEST_SCHEMA,
public_key_base64: pubkey_b64,
license_env_var: "TEST_APP_LICENSE",
app_qualifier: "io",
app_org: "testorg",
app_name: "test-app",
license_filename: "license.json",
}
}
fn make_license(key: &SigningKey, license_type: &str) -> LicenseFile {
let payload = LicensePayload {
schema: TEST_SCHEMA.to_string(),
license_id: "00000000-0000-0000-0000-000000000001".to_string(),
issued_to_org: "Test Organization".to_string(),
contact_email: Some("test@example.com".to_string()),
license_type: license_type.to_string(),
scope: "org".to_string(),
perpetual: true,
issued_at: "2025-01-01".to_string(),
};
let payload_bytes = serde_json::to_vec(&payload).unwrap();
let sig = key.sign(&payload_bytes);
LicenseFile {
payload,
signature: STANDARD.encode(sig.to_bytes()),
}
}
#[test]
fn test_verify_academic_license_ok() {
let (key, pubkey_b64) = make_test_keypair();
let license = make_license(&key, "academic");
assert!(verify_license_with_key(&license, &pubkey_b64, TEST_SCHEMA).is_ok());
}
#[test]
fn test_verify_commercial_license_ok() {
let (key, pubkey_b64) = make_test_keypair();
let license = make_license(&key, "commercial");
assert!(verify_license_with_key(&license, &pubkey_b64, TEST_SCHEMA).is_ok());
}
#[test]
fn test_verify_enterprise_license_ok() {
let (key, pubkey_b64) = make_test_keypair();
let license = make_license(&key, "enterprise");
assert!(verify_license_with_key(&license, &pubkey_b64, TEST_SCHEMA).is_ok());
}
#[test]
fn test_wrong_pubkey_fails() {
let (key, _) = make_test_keypair();
let (_, other_pubkey_b64) = make_test_keypair();
let license = make_license(&key, "academic");
let result = verify_license_with_key(&license, &other_pubkey_b64, TEST_SCHEMA);
assert!(
matches!(result, Err(LicenseError::InvalidSignature)),
"expected InvalidSignature, got: {result:?}"
);
}
#[test]
fn test_tampered_org_fails() {
let (key, pubkey_b64) = make_test_keypair();
let mut license = make_license(&key, "academic");
license.payload.issued_to_org = "Tampered Org".to_string();
let result = verify_license_with_key(&license, &pubkey_b64, TEST_SCHEMA);
assert!(
matches!(result, Err(LicenseError::InvalidSignature)),
"expected InvalidSignature after tampering, got: {result:?}"
);
}
#[test]
fn test_wrong_schema_fails() {
let (key, pubkey_b64) = make_test_keypair();
let mut license = make_license(&key, "academic");
license.payload.schema = "wrong-schema-v99".to_string();
let payload_bytes = serde_json::to_vec(&license.payload).unwrap();
let sig = key.sign(&payload_bytes);
license.signature = STANDARD.encode(sig.to_bytes());
let result = verify_license_with_key(&license, &pubkey_b64, TEST_SCHEMA);
assert!(
matches!(result, Err(LicenseError::InvalidSchema { .. })),
"expected InvalidSchema, got: {result:?}"
);
}
#[test]
fn test_invalid_public_key_base64_fails() {
let (key, _) = make_test_keypair();
let license = make_license(&key, "academic");
let result = verify_license_with_key(&license, "!!!not-valid-base64!!!", TEST_SCHEMA);
assert!(
matches!(result, Err(LicenseError::InvalidPublicKey(_))),
"expected InvalidPublicKey, got: {result:?}"
);
}
#[test]
fn test_invalid_public_key_wrong_length_fails() {
let (key, _) = make_test_keypair();
let license = make_license(&key, "academic");
let short_key = STANDARD.encode([0u8; 16]);
let result = verify_license_with_key(&license, &short_key, TEST_SCHEMA);
assert!(
matches!(result, Err(LicenseError::InvalidPublicKey(_))),
"expected InvalidPublicKey, got: {result:?}"
);
}
#[test]
fn test_invalid_signature_encoding_fails() {
let (key, pubkey_b64) = make_test_keypair();
let mut license = make_license(&key, "academic");
license.signature = "!!!not-valid-base64!!!".to_string();
let result = verify_license_with_key(&license, &pubkey_b64, TEST_SCHEMA);
assert!(
matches!(result, Err(LicenseError::InvalidSignatureEncoding(_))),
"expected InvalidSignatureEncoding, got: {result:?}"
);
}
#[test]
fn test_invalid_signature_wrong_length_fails() {
let (key, pubkey_b64) = make_test_keypair();
let mut license = make_license(&key, "academic");
license.signature = STANDARD.encode([0u8; 32]); let result = verify_license_with_key(&license, &pubkey_b64, TEST_SCHEMA);
assert!(
matches!(result, Err(LicenseError::InvalidSignatureEncoding(_))),
"expected InvalidSignatureEncoding, got: {result:?}"
);
}
#[test]
fn test_roundtrip_json_serialization() {
let (key, _) = make_test_keypair();
let license = make_license(&key, "commercial");
let json = serde_json::to_string_pretty(&license).unwrap();
let parsed: LicenseFile = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.payload.license_id, license.payload.license_id);
assert_eq!(parsed.payload.license_type, license.payload.license_type);
assert_eq!(parsed.signature, license.signature);
}
#[test]
fn test_contact_email_optional_in_signing() {
let (key, pubkey_b64) = make_test_keypair();
let mut payload = LicensePayload {
schema: TEST_SCHEMA.to_string(),
license_id: "00000000-0000-0000-0000-000000000002".to_string(),
issued_to_org: "Acme Corp".to_string(),
contact_email: None,
license_type: "commercial".to_string(),
scope: "org".to_string(),
perpetual: true,
issued_at: "2025-06-01".to_string(),
};
let sig1 = {
let bytes = serde_json::to_vec(&payload).unwrap();
key.sign(&bytes)
};
let lic_no_email = LicenseFile {
payload: payload.clone(),
signature: STANDARD.encode(sig1.to_bytes()),
};
assert!(verify_license_with_key(&lic_no_email, &pubkey_b64, TEST_SCHEMA).is_ok());
payload.contact_email = Some("admin@acme.com".to_string());
let sig2 = {
let bytes = serde_json::to_vec(&payload).unwrap();
key.sign(&bytes)
};
let lic_with_email = LicenseFile {
payload,
signature: STANDARD.encode(sig2.to_bytes()),
};
assert!(verify_license_with_key(&lic_with_email, &pubkey_b64, TEST_SCHEMA).is_ok());
let lic_tampered = LicenseFile {
payload: lic_with_email.payload.clone(),
signature: lic_no_email.signature.clone(),
};
assert!(verify_license_with_key(&lic_tampered, &pubkey_b64, TEST_SCHEMA).is_err());
}
#[test]
fn test_find_license_path_from_env_var() {
let _guard = ENV_MUTEX.lock().unwrap();
let tmp = tempfile::NamedTempFile::new().unwrap();
let path = tmp.path().to_path_buf();
unsafe {
std::env::set_var("TEST_APP_LICENSE", path.to_str().unwrap());
}
let config = make_config("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=");
let found = find_license_path(None, &config);
assert_eq!(found.as_deref(), Some(path.as_path()));
unsafe {
std::env::remove_var("TEST_APP_LICENSE");
}
}
#[test]
fn test_find_license_path_cli_arg_takes_precedence() {
let _guard = ENV_MUTEX.lock().unwrap();
let cli_path = PathBuf::from("/nonexistent/cli-license.json");
let env_path = PathBuf::from("/nonexistent/env-license.json");
unsafe {
std::env::set_var("TEST_APP_LICENSE", env_path.to_str().unwrap());
}
let config = make_config("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=");
let found = find_license_path(Some(&cli_path), &config);
assert_eq!(found.as_deref(), Some(cli_path.as_path()));
unsafe {
std::env::remove_var("TEST_APP_LICENSE");
}
}
#[test]
fn test_load_and_verify_not_found() {
let config = make_config("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=");
let result = load_and_verify(
Some(Path::new("/nonexistent/oxo-license-test.json")),
&config,
);
assert!(result.is_err());
}
#[test]
fn test_load_and_verify_invalid_json() {
use std::io::Write;
let mut f = tempfile::NamedTempFile::new().unwrap();
f.write_all(b"not valid json {{{").unwrap();
let config = make_config("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=");
let result = load_and_verify(Some(f.path()), &config);
assert!(
matches!(result, Err(LicenseError::ParseError(_))),
"expected ParseError, got: {result:?}"
);
}
#[test]
fn test_load_and_verify_valid_license() {
let (key, pubkey_b64) = make_test_keypair();
let license = make_license(&key, "academic");
let json = serde_json::to_string_pretty(&license).unwrap();
use std::io::Write;
let mut f = tempfile::NamedTempFile::new().unwrap();
f.write_all(json.as_bytes()).unwrap();
let pubkey_static: &'static str = Box::leak(pubkey_b64.into_boxed_str());
let config = make_config(pubkey_static);
let result = load_and_verify(Some(f.path()), &config);
assert!(result.is_ok(), "expected Ok, got: {result:?}");
}
}