1use base64::{Engine as _, engine::general_purpose::STANDARD};
71use ed25519_dalek::{Signature, Verifier, VerifyingKey};
72use serde::{Deserialize, Serialize};
73use std::path::{Path, PathBuf};
74
75#[derive(Debug, Clone)]
98pub struct LicenseConfig {
99 pub schema_version: &'static str,
103
104 pub public_key_base64: &'static str,
107
108 pub license_env_var: &'static str,
111
112 pub app_qualifier: &'static str,
115
116 pub app_org: &'static str,
119
120 pub app_name: &'static str,
123
124 pub license_filename: &'static str,
127}
128
129#[derive(Debug, Clone, Serialize, Deserialize)]
141pub struct LicensePayload {
142 pub schema: String,
144 pub license_id: String,
146 pub issued_to_org: String,
148 #[serde(default, skip_serializing_if = "Option::is_none")]
150 pub contact_email: Option<String>,
151 pub license_type: String,
153 pub scope: String,
155 pub perpetual: bool,
157 pub issued_at: String,
159}
160
161#[derive(Debug, Clone, Serialize, Deserialize)]
163pub struct LicenseFile {
164 #[serde(flatten)]
166 pub payload: LicensePayload,
167 pub signature: String,
169}
170
171#[derive(Debug, thiserror::Error)]
175pub enum LicenseError {
176 #[error(
178 "No license file found.\n\
179 \n\
180 Place your license file at one of:\n\
181 \t1. Pass the path via the CLI argument\n\
182 \t2. Set the license environment variable\n\
183 \t3. Platform config directory\n\
184 \t4. Legacy Unix path: ~/.config/<app>/license.json"
185 )]
186 NotFound,
187
188 #[error("Failed to read license file '{path}': {source}")]
190 ReadError {
191 path: PathBuf,
192 #[source]
193 source: std::io::Error,
194 },
195
196 #[error(
198 "Failed to parse license file as JSON: {0}\n\
199 \n\
200 Common causes:\n\
201 • The file was modified or truncated\n\
202 • The file was saved with a UTF-8 BOM\n\
203 • The file was downloaded as HTML instead of raw JSON"
204 )]
205 ParseError(#[from] serde_json::Error),
206
207 #[error(
209 "Invalid license schema: expected '{expected}', found '{found}'.\n\
210 This license was issued for a different version or application."
211 )]
212 InvalidSchema { expected: String, found: String },
213
214 #[error(
216 "License signature is invalid.\n\
217 The license file may have been tampered with or was not issued by Traitome."
218 )]
219 InvalidSignature,
220
221 #[error("Internal error — invalid embedded public key: {0}")]
223 InvalidPublicKey(String),
224
225 #[error("Invalid signature encoding in license file: {0}")]
227 InvalidSignatureEncoding(String),
228}
229
230pub fn verify_license(license: &LicenseFile, config: &LicenseConfig) -> Result<(), LicenseError> {
256 verify_license_with_key(license, config.public_key_base64, config.schema_version)
257}
258
259pub fn verify_license_with_key(
270 license: &LicenseFile,
271 pubkey_base64: &str,
272 schema_version: &str,
273) -> Result<(), LicenseError> {
274 if license.payload.schema != schema_version {
276 return Err(LicenseError::InvalidSchema {
277 expected: schema_version.to_string(),
278 found: license.payload.schema.clone(),
279 });
280 }
281
282 let pubkey_bytes = STANDARD
284 .decode(pubkey_base64)
285 .map_err(|e| LicenseError::InvalidPublicKey(e.to_string()))?;
286 let pubkey_array: [u8; 32] = pubkey_bytes
287 .try_into()
288 .map_err(|_| LicenseError::InvalidPublicKey("expected exactly 32 bytes".to_string()))?;
289 let verifying_key = VerifyingKey::from_bytes(&pubkey_array)
290 .map_err(|e| LicenseError::InvalidPublicKey(e.to_string()))?;
291
292 let sig_bytes = STANDARD
294 .decode(&license.signature)
295 .map_err(|e| LicenseError::InvalidSignatureEncoding(e.to_string()))?;
296 let sig_array: [u8; 64] = sig_bytes.try_into().map_err(|_| {
297 LicenseError::InvalidSignatureEncoding("expected exactly 64 bytes".to_string())
298 })?;
299 let signature = Signature::from_bytes(&sig_array);
300
301 let payload_bytes = serde_json::to_vec(&license.payload).map_err(LicenseError::ParseError)?;
303
304 verifying_key
306 .verify(&payload_bytes, &signature)
307 .map_err(|_| LicenseError::InvalidSignature)?;
308
309 Ok(())
310}
311
312pub fn find_license_path(cli_arg: Option<&Path>, config: &LicenseConfig) -> Option<PathBuf> {
323 if let Some(p) = cli_arg {
325 return Some(p.to_path_buf());
326 }
327
328 if let Ok(val) = std::env::var(config.license_env_var) {
330 let p = PathBuf::from(val.trim());
331 return Some(p);
332 }
333
334 default_license_candidates(config)
336 .into_iter()
337 .find(|candidate| candidate.exists())
338}
339
340pub fn load_and_verify(
351 cli_arg: Option<&Path>,
352 config: &LicenseConfig,
353) -> Result<LicenseFile, LicenseError> {
354 let path = find_license_path(cli_arg, config).ok_or(LicenseError::NotFound)?;
355
356 let contents = std::fs::read_to_string(&path).map_err(|e| LicenseError::ReadError {
357 path: path.clone(),
358 source: e,
359 })?;
360
361 let license: LicenseFile = serde_json::from_str(&contents)?;
362
363 verify_license(&license, config)?;
364
365 Ok(license)
366}
367
368fn legacy_unix_license_path(home_dir: Option<PathBuf>, config: &LicenseConfig) -> Option<PathBuf> {
371 home_dir.map(|home| {
372 home.join(".config")
373 .join(config.app_name)
374 .join(config.license_filename)
375 })
376}
377
378fn default_license_candidates(config: &LicenseConfig) -> Vec<PathBuf> {
379 let mut candidates: Vec<PathBuf> = Vec::new();
380
381 #[cfg(not(target_arch = "wasm32"))]
383 {
384 if let Some(dirs) =
385 directories::ProjectDirs::from(config.app_qualifier, config.app_org, config.app_name)
386 {
387 candidates.push(dirs.config_dir().join(config.license_filename));
388 }
389 }
390
391 let home_dir = std::env::var_os("HOME").map(PathBuf::from);
393 if let Some(path) = legacy_unix_license_path(home_dir, config)
394 && !candidates.contains(&path)
395 {
396 candidates.push(path);
397 }
398
399 candidates
400}
401
402#[cfg(test)]
405mod tests {
406 use super::*;
407 #[allow(unused_imports)]
408 use base64::{Engine as _, engine::general_purpose::STANDARD};
409 use ed25519_dalek::{Signer, SigningKey};
410 use rand::rngs::OsRng;
411 use std::sync::Mutex;
412
413 const TEST_SCHEMA: &str = "test-app-license-v1";
414
415 static ENV_MUTEX: Mutex<()> = Mutex::new(());
417
418 fn make_test_keypair() -> (SigningKey, String) {
419 let key = SigningKey::generate(&mut OsRng);
420 let pubkey_b64 = STANDARD.encode(key.verifying_key().as_bytes());
421 (key, pubkey_b64)
422 }
423
424 fn make_config(pubkey_b64: &'static str) -> LicenseConfig {
425 LicenseConfig {
426 schema_version: TEST_SCHEMA,
427 public_key_base64: pubkey_b64,
428 license_env_var: "TEST_APP_LICENSE",
429 app_qualifier: "io",
430 app_org: "testorg",
431 app_name: "test-app",
432 license_filename: "license.json",
433 }
434 }
435
436 fn make_license(key: &SigningKey, license_type: &str) -> LicenseFile {
437 let payload = LicensePayload {
438 schema: TEST_SCHEMA.to_string(),
439 license_id: "00000000-0000-0000-0000-000000000001".to_string(),
440 issued_to_org: "Test Organization".to_string(),
441 contact_email: Some("test@example.com".to_string()),
442 license_type: license_type.to_string(),
443 scope: "org".to_string(),
444 perpetual: true,
445 issued_at: "2025-01-01".to_string(),
446 };
447 let payload_bytes = serde_json::to_vec(&payload).unwrap();
448 let sig = key.sign(&payload_bytes);
449 LicenseFile {
450 payload,
451 signature: STANDARD.encode(sig.to_bytes()),
452 }
453 }
454
455 #[test]
456 fn test_verify_academic_license_ok() {
457 let (key, pubkey_b64) = make_test_keypair();
458 let license = make_license(&key, "academic");
459 assert!(verify_license_with_key(&license, &pubkey_b64, TEST_SCHEMA).is_ok());
460 }
461
462 #[test]
463 fn test_verify_commercial_license_ok() {
464 let (key, pubkey_b64) = make_test_keypair();
465 let license = make_license(&key, "commercial");
466 assert!(verify_license_with_key(&license, &pubkey_b64, TEST_SCHEMA).is_ok());
467 }
468
469 #[test]
470 fn test_verify_enterprise_license_ok() {
471 let (key, pubkey_b64) = make_test_keypair();
472 let license = make_license(&key, "enterprise");
473 assert!(verify_license_with_key(&license, &pubkey_b64, TEST_SCHEMA).is_ok());
474 }
475
476 #[test]
477 fn test_wrong_pubkey_fails() {
478 let (key, _) = make_test_keypair();
479 let (_, other_pubkey_b64) = make_test_keypair();
480 let license = make_license(&key, "academic");
481 let result = verify_license_with_key(&license, &other_pubkey_b64, TEST_SCHEMA);
482 assert!(
483 matches!(result, Err(LicenseError::InvalidSignature)),
484 "expected InvalidSignature, got: {result:?}"
485 );
486 }
487
488 #[test]
489 fn test_tampered_org_fails() {
490 let (key, pubkey_b64) = make_test_keypair();
491 let mut license = make_license(&key, "academic");
492 license.payload.issued_to_org = "Tampered Org".to_string();
493 let result = verify_license_with_key(&license, &pubkey_b64, TEST_SCHEMA);
494 assert!(
495 matches!(result, Err(LicenseError::InvalidSignature)),
496 "expected InvalidSignature after tampering, got: {result:?}"
497 );
498 }
499
500 #[test]
501 fn test_wrong_schema_fails() {
502 let (key, pubkey_b64) = make_test_keypair();
503 let mut license = make_license(&key, "academic");
504 license.payload.schema = "wrong-schema-v99".to_string();
505 let payload_bytes = serde_json::to_vec(&license.payload).unwrap();
506 let sig = key.sign(&payload_bytes);
507 license.signature = STANDARD.encode(sig.to_bytes());
508
509 let result = verify_license_with_key(&license, &pubkey_b64, TEST_SCHEMA);
510 assert!(
511 matches!(result, Err(LicenseError::InvalidSchema { .. })),
512 "expected InvalidSchema, got: {result:?}"
513 );
514 }
515
516 #[test]
517 fn test_invalid_public_key_base64_fails() {
518 let (key, _) = make_test_keypair();
519 let license = make_license(&key, "academic");
520 let result = verify_license_with_key(&license, "!!!not-valid-base64!!!", TEST_SCHEMA);
521 assert!(
522 matches!(result, Err(LicenseError::InvalidPublicKey(_))),
523 "expected InvalidPublicKey, got: {result:?}"
524 );
525 }
526
527 #[test]
528 fn test_invalid_public_key_wrong_length_fails() {
529 let (key, _) = make_test_keypair();
530 let license = make_license(&key, "academic");
531 let short_key = STANDARD.encode([0u8; 16]);
532 let result = verify_license_with_key(&license, &short_key, TEST_SCHEMA);
533 assert!(
534 matches!(result, Err(LicenseError::InvalidPublicKey(_))),
535 "expected InvalidPublicKey, got: {result:?}"
536 );
537 }
538
539 #[test]
540 fn test_invalid_signature_encoding_fails() {
541 let (key, pubkey_b64) = make_test_keypair();
542 let mut license = make_license(&key, "academic");
543 license.signature = "!!!not-valid-base64!!!".to_string();
544 let result = verify_license_with_key(&license, &pubkey_b64, TEST_SCHEMA);
545 assert!(
546 matches!(result, Err(LicenseError::InvalidSignatureEncoding(_))),
547 "expected InvalidSignatureEncoding, got: {result:?}"
548 );
549 }
550
551 #[test]
552 fn test_invalid_signature_wrong_length_fails() {
553 let (key, pubkey_b64) = make_test_keypair();
554 let mut license = make_license(&key, "academic");
555 license.signature = STANDARD.encode([0u8; 32]); let result = verify_license_with_key(&license, &pubkey_b64, TEST_SCHEMA);
557 assert!(
558 matches!(result, Err(LicenseError::InvalidSignatureEncoding(_))),
559 "expected InvalidSignatureEncoding, got: {result:?}"
560 );
561 }
562
563 #[test]
564 fn test_roundtrip_json_serialization() {
565 let (key, _) = make_test_keypair();
566 let license = make_license(&key, "commercial");
567 let json = serde_json::to_string_pretty(&license).unwrap();
568 let parsed: LicenseFile = serde_json::from_str(&json).unwrap();
569 assert_eq!(parsed.payload.license_id, license.payload.license_id);
570 assert_eq!(parsed.payload.license_type, license.payload.license_type);
571 assert_eq!(parsed.signature, license.signature);
572 }
573
574 #[test]
575 fn test_contact_email_optional_in_signing() {
576 let (key, pubkey_b64) = make_test_keypair();
577 let mut payload = LicensePayload {
578 schema: TEST_SCHEMA.to_string(),
579 license_id: "00000000-0000-0000-0000-000000000002".to_string(),
580 issued_to_org: "Acme Corp".to_string(),
581 contact_email: None,
582 license_type: "commercial".to_string(),
583 scope: "org".to_string(),
584 perpetual: true,
585 issued_at: "2025-06-01".to_string(),
586 };
587 let sig1 = {
588 let bytes = serde_json::to_vec(&payload).unwrap();
589 key.sign(&bytes)
590 };
591 let lic_no_email = LicenseFile {
592 payload: payload.clone(),
593 signature: STANDARD.encode(sig1.to_bytes()),
594 };
595 assert!(verify_license_with_key(&lic_no_email, &pubkey_b64, TEST_SCHEMA).is_ok());
596
597 payload.contact_email = Some("admin@acme.com".to_string());
598 let sig2 = {
599 let bytes = serde_json::to_vec(&payload).unwrap();
600 key.sign(&bytes)
601 };
602 let lic_with_email = LicenseFile {
603 payload,
604 signature: STANDARD.encode(sig2.to_bytes()),
605 };
606 assert!(verify_license_with_key(&lic_with_email, &pubkey_b64, TEST_SCHEMA).is_ok());
607
608 let lic_tampered = LicenseFile {
610 payload: lic_with_email.payload.clone(),
611 signature: lic_no_email.signature.clone(),
612 };
613 assert!(verify_license_with_key(&lic_tampered, &pubkey_b64, TEST_SCHEMA).is_err());
614 }
615
616 #[test]
617 fn test_find_license_path_from_env_var() {
618 let _guard = ENV_MUTEX.lock().unwrap();
619 let tmp = tempfile::NamedTempFile::new().unwrap();
620 let path = tmp.path().to_path_buf();
621 unsafe {
622 std::env::set_var("TEST_APP_LICENSE", path.to_str().unwrap());
623 }
624 let config = make_config("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=");
625 let found = find_license_path(None, &config);
626 assert_eq!(found.as_deref(), Some(path.as_path()));
627 unsafe {
628 std::env::remove_var("TEST_APP_LICENSE");
629 }
630 }
631
632 #[test]
633 fn test_find_license_path_cli_arg_takes_precedence() {
634 let _guard = ENV_MUTEX.lock().unwrap();
635 let cli_path = PathBuf::from("/nonexistent/cli-license.json");
636 let env_path = PathBuf::from("/nonexistent/env-license.json");
637 unsafe {
638 std::env::set_var("TEST_APP_LICENSE", env_path.to_str().unwrap());
639 }
640 let config = make_config("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=");
641 let found = find_license_path(Some(&cli_path), &config);
642 assert_eq!(found.as_deref(), Some(cli_path.as_path()));
643 unsafe {
644 std::env::remove_var("TEST_APP_LICENSE");
645 }
646 }
647
648 #[test]
649 fn test_load_and_verify_not_found() {
650 let config = make_config("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=");
651 let result = load_and_verify(
652 Some(Path::new("/nonexistent/oxo-license-test.json")),
653 &config,
654 );
655 assert!(result.is_err());
657 }
658
659 #[test]
660 fn test_load_and_verify_invalid_json() {
661 use std::io::Write;
662 let mut f = tempfile::NamedTempFile::new().unwrap();
663 f.write_all(b"not valid json {{{").unwrap();
664 let config = make_config("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=");
665 let result = load_and_verify(Some(f.path()), &config);
666 assert!(
667 matches!(result, Err(LicenseError::ParseError(_))),
668 "expected ParseError, got: {result:?}"
669 );
670 }
671
672 #[test]
673 fn test_load_and_verify_valid_license() {
674 let (key, pubkey_b64) = make_test_keypair();
675 let license = make_license(&key, "academic");
676 let json = serde_json::to_string_pretty(&license).unwrap();
677
678 use std::io::Write;
679 let mut f = tempfile::NamedTempFile::new().unwrap();
680 f.write_all(json.as_bytes()).unwrap();
681
682 let pubkey_static: &'static str = Box::leak(pubkey_b64.into_boxed_str());
684 let config = make_config(pubkey_static);
685 let result = load_and_verify(Some(f.path()), &config);
686 assert!(result.is_ok(), "expected Ok, got: {result:?}");
687 }
688}