use base64::{Engine as _, engine::general_purpose::STANDARD};
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
pub const SCHEMA_VERSION: &str = "oxo-call-license-v1";
pub const EMBEDDED_PUBLIC_KEY_BASE64: &str = "SOTbyPWS8fSF+XS9dqEg9cFyag0wPO/YMA5LhI4PXw4=";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum LicenseType {
Academic,
Commercial,
}
impl std::fmt::Display for LicenseType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
LicenseType::Academic => write!(f, "academic"),
LicenseType::Commercial => write!(f, "commercial"),
}
}
}
#[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: LicenseType,
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\
Academic use is free but requires a signed license file.\n\
\n\
• Apply for an academic license : https://github.com/Traitome/oxo-call#license\n\
• Purchase a commercial license : license@traitome.com\n\
\n\
Once you have a license file, place it at one of:\n\
\t1. Pass --license <path> on the command line\n\
\t2. Set OXO_CALL_LICENSE=<path> in your environment\n\
\t3. Platform config dir (macOS example):\n\
\t ~/Library/Application Support/io.traitome.oxo-call/license.oxo.json\n\
\t4. Legacy Unix fallback:\n\
\t ~/.config/oxo-call/license.oxo.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 Ensure the file is a valid oxo-call license."
)]
ParseError(serde_json::Error),
#[error(
"Invalid license schema: expected '{expected}', found '{found}'.\n\
This license was issued for a different version of oxo-call."
)]
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.\n\
Please contact license@traitome.com to obtain a valid license."
)]
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) -> Result<(), LicenseError> {
verify_license_with_key(license, EMBEDDED_PUBLIC_KEY_BASE64)
}
pub fn verify_license_with_key(
license: &LicenseFile,
pubkey_base64: &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(())
}
fn legacy_unix_license_path_from_home(home_dir: Option<PathBuf>) -> Option<PathBuf> {
home_dir.map(|home| home.join(".config/oxo-call/license.oxo.json"))
}
fn default_license_candidates_from(
projectdirs_path: Option<PathBuf>,
home_dir: Option<PathBuf>,
) -> Vec<PathBuf> {
let mut candidates = Vec::new();
if let Some(path) = projectdirs_path {
candidates.push(path);
}
if let Some(path) = legacy_unix_license_path_from_home(home_dir)
&& !candidates.contains(&path)
{
candidates.push(path);
}
candidates
}
fn default_license_candidates() -> Vec<PathBuf> {
#[cfg(not(target_arch = "wasm32"))]
let projectdirs_path = directories::ProjectDirs::from("io", "traitome", "oxo-call")
.map(|dirs| dirs.config_dir().join("license.oxo.json"));
#[cfg(target_arch = "wasm32")]
let projectdirs_path: Option<PathBuf> = None;
let home_dir = std::env::var_os("HOME").map(PathBuf::from);
default_license_candidates_from(projectdirs_path, home_dir)
}
pub fn find_license_path(cli_path: Option<&Path>) -> Option<PathBuf> {
if let Some(p) = cli_path {
return Some(p.to_path_buf());
}
if let Ok(p) = std::env::var("OXO_CALL_LICENSE") {
return Some(PathBuf::from(p));
}
let candidates = default_license_candidates();
candidates
.iter()
.find(|path| path.exists())
.cloned()
.or_else(|| candidates.into_iter().next())
}
pub fn load_and_verify(cli_path: Option<&Path>) -> Result<LicenseFile, LicenseError> {
let path = find_license_path(cli_path).ok_or(LicenseError::NotFound)?;
if !path.exists() {
return Err(LicenseError::NotFound);
}
let content = std::fs::read_to_string(&path).map_err(|e| LicenseError::ReadError {
path: path.clone(),
source: e,
})?;
let license: LicenseFile = serde_json::from_str(&content).map_err(LicenseError::ParseError)?;
verify_license(&license)?;
Ok(license)
}
pub const LICENSE_INFO: &str = r#"
oxo-call License Information
═════════════════════════════
License model: Dual license (Academic free / Commercial per-org)
Licensor: Traitome (https://github.com/Traitome)
Product: oxo-call
PERMITTED USES
──────────────
Academic / research / education — free, requires a signed academic license file
Personal non-commercial — free, requires a signed academic license file
Commercial / production use — requires a purchased commercial license (per org)
REQUIREMENTS FOR ALL USERS
───────────────────────────
• A valid signed license file must be present before running any core commands.
• License files are issued by Traitome and verified offline using Ed25519 signatures.
• Academic licenses are free; apply at: https://github.com/Traitome/oxo-call#license
• Commercial licenses are per-organization, one-time fee; contact: license@traitome.com
HOW TO PLACE YOUR LICENSE FILE
────────────────────────────────
Option 1 — CLI flag: oxo-call --license /path/to/license.oxo.json <command>
Option 2 — Environment var: export OXO_CALL_LICENSE=/path/to/license.oxo.json
Option 3 — Default location:
macOS default: ~/Library/Application Support/io.traitome.oxo-call/license.oxo.json
Legacy Unix: ~/.config/oxo-call/license.oxo.json
Windows: %APPDATA%\oxo-call\license.oxo.json
LICENSE VERIFICATION
─────────────────────
Run: oxo-call license verify
This prints the license holder, type, and issue date without running any tool.
Full license texts: LICENSE-ACADEMIC | LICENSE-COMMERCIAL
"#;
#[cfg(test)]
pub mod tests {
use super::*;
use ed25519_dalek::{Signer, SigningKey};
pub fn make_test_keypair() -> (SigningKey, String) {
let seed: [u8; 32] = [
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24,
25, 26, 27, 28, 29, 30, 31, 32,
];
let signing_key = SigningKey::from_bytes(&seed);
let pubkey_b64 = STANDARD.encode(signing_key.verifying_key().as_bytes());
(signing_key, pubkey_b64)
}
pub fn make_license(signing_key: &SigningKey, license_type: LicenseType) -> LicenseFile {
let payload = LicensePayload {
schema: SCHEMA_VERSION.to_string(),
license_id: "00000000-0000-0000-0000-000000000001".to_string(),
issued_to_org: "Test University".to_string(),
contact_email: None,
license_type,
scope: "org".to_string(),
perpetual: true,
issued_at: "2025-01-01".to_string(),
};
let payload_bytes = serde_json::to_vec(&payload).unwrap();
let signature = signing_key.sign(&payload_bytes);
let signature_b64 = STANDARD.encode(signature.to_bytes());
LicenseFile {
payload,
signature: signature_b64,
}
}
#[test]
fn test_valid_academic_license_passes() {
let (key, pubkey_b64) = make_test_keypair();
let license = make_license(&key, LicenseType::Academic);
assert!(verify_license_with_key(&license, &pubkey_b64).is_ok());
}
#[test]
fn test_valid_commercial_license_passes() {
let (key, pubkey_b64) = make_test_keypair();
let license = make_license(&key, LicenseType::Commercial);
assert!(verify_license_with_key(&license, &pubkey_b64).is_ok());
}
#[test]
fn test_tampered_signature_fails() {
let (key, pubkey_b64) = make_test_keypair();
let mut license = make_license(&key, LicenseType::Academic);
license.signature = STANDARD.encode([0u8; 64]);
let err = verify_license_with_key(&license, &pubkey_b64).unwrap_err();
assert!(
matches!(err, LicenseError::InvalidSignature),
"expected InvalidSignature, got: {err}"
);
}
#[test]
fn test_tampered_field_fails() {
let (key, pubkey_b64) = make_test_keypair();
let mut license = make_license(&key, LicenseType::Academic);
license.payload.issued_to_org = "Attacker Corp".to_string();
let err = verify_license_with_key(&license, &pubkey_b64).unwrap_err();
assert!(
matches!(err, LicenseError::InvalidSignature),
"expected InvalidSignature, got: {err}"
);
}
#[test]
fn test_wrong_schema_fails() {
let (key, pubkey_b64) = make_test_keypair();
let mut license = make_license(&key, LicenseType::Academic);
license.payload.schema = "oxo-call-license-v0".to_string();
let payload_bytes = serde_json::to_vec(&license.payload).unwrap();
let signature = key.sign(&payload_bytes);
license.signature = STANDARD.encode(signature.to_bytes());
let err = verify_license_with_key(&license, &pubkey_b64).unwrap_err();
assert!(
matches!(err, LicenseError::InvalidSchema { .. }),
"expected InvalidSchema, got: {err}"
);
}
#[test]
fn test_no_license_path_returns_not_found() {
let path = Path::new("/tmp/oxo-call-nonexistent-license-test.json");
let err = load_and_verify(Some(path)).unwrap_err();
assert!(
matches!(err, LicenseError::NotFound),
"expected NotFound, got: {err}"
);
}
#[test]
fn test_invalid_json_returns_parse_error() {
use std::io::Write;
let mut f = tempfile::NamedTempFile::new().unwrap();
f.write_all(b"not valid json {{{").unwrap();
let path = f.path().to_path_buf();
let err = load_and_verify(Some(&path)).unwrap_err();
assert!(
matches!(err, LicenseError::ParseError(_)),
"expected ParseError, got: {err}"
);
}
#[test]
fn test_roundtrip_json_serialization() {
let (key, _) = make_test_keypair();
let license = make_license(&key, LicenseType::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: SCHEMA_VERSION.to_string(),
license_id: "00000000-0000-0000-0000-000000000002".to_string(),
issued_to_org: "Acme Corp".to_string(),
contact_email: None,
license_type: LicenseType::Commercial,
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).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).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).is_err());
}
#[test]
fn test_default_license_candidates_include_legacy_unix_fallback() {
let candidates = default_license_candidates_from(
Some(PathBuf::from(
"/Users/example/Library/Application Support/io.traitome.oxo-call/license.oxo.json",
)),
Some(PathBuf::from("/Users/example")),
);
assert_eq!(
candidates,
vec![
PathBuf::from(
"/Users/example/Library/Application Support/io.traitome.oxo-call/license.oxo.json",
),
PathBuf::from("/Users/example/.config/oxo-call/license.oxo.json"),
]
);
}
#[test]
fn test_default_license_candidates_deduplicate_same_path() {
let path = PathBuf::from("/home/example/.config/oxo-call/license.oxo.json");
let candidates = default_license_candidates_from(
Some(path.clone()),
Some(PathBuf::from("/home/example")),
);
assert_eq!(candidates, vec![path]);
}
}