1use std::path::PathBuf;
2
3use chrono::{DateTime, Utc};
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
7pub enum Tier {
8 Community,
9 #[default]
10 Pro,
11 Team,
12 Enterprise,
13}
14
15#[derive(Debug, Clone, Default)]
17pub struct LicenseInfo {
18 pub tier: Tier,
19 pub org_id: Option<String>,
21 pub sso_provider: Option<String>,
23 pub expires: Option<String>,
25 pub seat_count: Option<u32>,
27}
28
29impl std::fmt::Display for Tier {
30 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
31 match self {
32 Tier::Community => write!(f, "Community"),
33 Tier::Pro => write!(f, "Pro"),
34 Tier::Team => write!(f, "Team"),
35 Tier::Enterprise => write!(f, "Enterprise"),
36 }
37 }
38}
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46#[allow(dead_code)]
47enum EnforcementMode {
48 Legacy,
49 SignedPreferred,
50 SignedOnly,
51}
52
53const ENFORCEMENT_MODE: EnforcementMode = EnforcementMode::SignedOnly;
54
55struct KeyEntry {
56 kid: &'static str,
57 key: [u8; 32],
58}
59
60const KEYRING: &[KeyEntry] = &[
64 KeyEntry {
65 kid: "k1",
66 key: [
67 111, 227, 28, 151, 67, 117, 194, 85, 167, 179, 224, 109, 45, 172, 183, 106, 78, 3, 55,
68 72, 57, 216, 160, 134, 78, 190, 54, 236, 190, 16, 22, 9,
69 ],
70 },
71 KeyEntry {
72 kid: "k2",
73 key: [
74 141, 30, 243, 157, 5, 88, 251, 150, 7, 123, 244, 84, 164, 1, 186, 200, 23, 1, 149, 246,
75 53, 6, 251, 131, 104, 197, 106, 24, 188, 149, 137, 237,
76 ],
77 },
78];
79
80const _: () = assert!(!KEYRING.is_empty());
81
82const MAX_TOKEN_LEN: usize = 8192;
84
85fn tier_from_payload(payload: &serde_json::Value) -> Option<Tier> {
87 let tier_str = payload.get("tier").and_then(|v| v.as_str())?;
88 match tier_str.to_lowercase().as_str() {
89 "pro" => Some(Tier::Pro),
90 "team" => Some(Tier::Team),
91 "enterprise" => Some(Tier::Enterprise),
92 "community" => Some(Tier::Community),
93 _ => None,
94 }
95}
96
97fn license_info_from_payload(payload: &serde_json::Value, tier: Tier) -> LicenseInfo {
99 let org_id = payload
100 .get("org_id")
101 .and_then(|v| v.as_str())
102 .map(String::from);
103 let sso_provider = payload
104 .get("sso_provider")
105 .and_then(|v| v.as_str())
106 .map(String::from);
107 let seat_count = payload
108 .get("seat_count")
109 .and_then(|v| v.as_u64())
110 .and_then(|v| match u32::try_from(v) {
111 Ok(n) => Some(n),
112 Err(_) => {
113 eprintln!("tirith: warning: seat_count {v} exceeds u32 range, ignoring");
114 None
115 }
116 });
117
118 let expires = payload.get("exp").and_then(|v| {
121 v.as_str()
122 .map(|s| s.to_string())
123 .or_else(|| v.as_i64().map(|n| n.to_string()))
124 });
125
126 LicenseInfo {
127 tier,
128 org_id,
129 sso_provider,
130 expires,
131 seat_count,
132 }
133}
134
135fn decode_legacy_payload(key: &str, now: DateTime<Utc>) -> Option<serde_json::Value> {
137 use base64::Engine;
138
139 let trimmed = key.trim();
140
141 if trimmed.len() > MAX_TOKEN_LEN {
143 return None;
144 }
145
146 let bytes = base64::engine::general_purpose::STANDARD
147 .decode(trimmed)
148 .or_else(|_| base64::engine::general_purpose::STANDARD_NO_PAD.decode(trimmed))
149 .ok()?;
150
151 let payload: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
152
153 match payload.get("exp").and_then(|v| v.as_str()) {
155 Some(exp_str) => match chrono::NaiveDate::parse_from_str(exp_str, "%Y-%m-%d") {
156 Ok(exp_date) => {
157 let today = now.date_naive();
158 if today > exp_date {
159 return None;
160 }
161 }
162 Err(_) => {
163 eprintln!(
164 "tirith: warning: legacy license has unparseable exp date '{exp_str}', rejecting"
165 );
166 return None;
167 }
168 },
169 None => {
170 return None;
172 }
173 }
174
175 Some(payload)
176}
177
178fn decode_tier_legacy(key: &str, now: DateTime<Utc>) -> Option<Tier> {
180 let payload = decode_legacy_payload(key, now)?;
181 let tier = tier_from_payload(&payload)?;
182 Some(match tier {
184 Tier::Team | Tier::Enterprise => Tier::Pro,
185 other => other,
186 })
187}
188
189fn decode_license_info_legacy(key: &str, now: DateTime<Utc>) -> Option<LicenseInfo> {
191 let payload = decode_legacy_payload(key, now)?;
192 let tier = tier_from_payload(&payload)?;
193 let tier = match tier {
195 Tier::Team | Tier::Enterprise => Tier::Pro,
196 other => other,
197 };
198 Some(license_info_from_payload(&payload, tier))
199}
200
201fn decode_signed_token(
205 token: &str,
206 keyring: &[KeyEntry],
207 now: DateTime<Utc>,
208) -> Option<serde_json::Value> {
209 use base64::Engine;
210 use ed25519_dalek::{Signature, VerifyingKey};
211
212 let token = token.trim();
213
214 if token.len() > MAX_TOKEN_LEN {
215 return None;
216 }
217
218 let (payload_b64, sig_b64) = token.split_once('.')?;
219 if payload_b64.is_empty() || sig_b64.is_empty() || sig_b64.contains('.') {
220 return None;
221 }
222
223 let payload_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
225 .decode(payload_b64)
226 .or_else(|_| base64::engine::general_purpose::URL_SAFE.decode(payload_b64))
227 .ok()?;
228
229 let sig_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
230 .decode(sig_b64)
231 .or_else(|_| base64::engine::general_purpose::URL_SAFE.decode(sig_b64))
232 .ok()?;
233 let signature = Signature::from_slice(&sig_bytes).ok()?;
234
235 let payload: serde_json::Value = serde_json::from_slice(&payload_bytes).ok()?;
236
237 let kid = payload.get("kid").and_then(|v| v.as_str());
239 let verified = if let Some(kid_val) = kid {
240 let entry = keyring.iter().find(|e| e.kid == kid_val)?;
241 let vk = VerifyingKey::from_bytes(&entry.key).ok()?;
242 vk.verify_strict(&payload_bytes, &signature)
243 .ok()
244 .map(|_| ())
245 } else {
246 keyring.iter().find_map(|entry| {
247 let vk = VerifyingKey::from_bytes(&entry.key).ok()?;
248 vk.verify_strict(&payload_bytes, &signature).ok()
249 })
250 };
251 verified?;
252
253 if payload.get("iss").and_then(|v| v.as_str()) != Some("tirith.dev") {
254 return None;
255 }
256
257 if payload.get("aud").and_then(|v| v.as_str()) != Some("tirith-cli") {
258 return None;
259 }
260
261 let exp = match payload.get("exp") {
264 Some(v) => v.as_i64()?,
265 None => return None,
266 };
267 if now.timestamp() >= exp {
268 return None;
269 }
270
271 if let Some(nbf_val) = payload.get("nbf") {
274 let nbf = nbf_val.as_i64()?;
275 if now.timestamp() < nbf {
276 return None;
277 }
278 }
279
280 Some(payload)
281}
282
283fn decode_tier_at_with_mode(
289 key: &str,
290 now: DateTime<Utc>,
291 mode: EnforcementMode,
292 keyring: &[KeyEntry],
293) -> Option<Tier> {
294 if key.contains('.') {
295 let payload = decode_signed_token(key, keyring, now)?;
296 return tier_from_payload(&payload);
297 }
298
299 if mode == EnforcementMode::SignedOnly {
300 return None;
301 }
302 decode_tier_legacy(key, now)
303}
304
305fn decode_license_info_at_with_mode(
307 key: &str,
308 now: DateTime<Utc>,
309 mode: EnforcementMode,
310 keyring: &[KeyEntry],
311) -> Option<LicenseInfo> {
312 if key.contains('.') {
313 let payload = decode_signed_token(key, keyring, now)?;
314 let tier = tier_from_payload(&payload)?;
315 return Some(license_info_from_payload(&payload, tier));
316 }
317
318 if mode == EnforcementMode::SignedOnly {
319 return None;
320 }
321 decode_license_info_legacy(key, now)
322}
323
324fn decode_tier_at(key: &str, now: DateTime<Utc>) -> Option<Tier> {
326 decode_tier_at_with_mode(key, now, ENFORCEMENT_MODE, KEYRING)
327}
328
329fn decode_license_info_at(key: &str, now: DateTime<Utc>) -> Option<LicenseInfo> {
331 decode_license_info_at_with_mode(key, now, ENFORCEMENT_MODE, KEYRING)
332}
333
334pub fn current_tier() -> Tier {
349 match read_license_key() {
350 Some(k) => decode_tier_at(&k, Utc::now()).unwrap_or_else(|| {
351 crate::audit::audit_diagnostic(
352 "tirith: warning: license key present but decode failed, falling back to Pro",
353 );
354 Tier::Pro
355 }),
356 None => Tier::Pro,
357 }
358}
359
360pub fn license_info() -> LicenseInfo {
362 match read_license_key() {
363 Some(k) => decode_license_info_at(&k, Utc::now()).unwrap_or_else(|| {
364 crate::audit::audit_diagnostic(
365 "tirith: warning: license key present but decode failed for license info",
366 );
367 LicenseInfo::default()
368 }),
369 None => LicenseInfo::default(),
370 }
371}
372
373#[derive(Debug, Clone, Copy, PartialEq, Eq)]
378pub enum KeyFormatStatus {
379 NoKey,
380 LegacyUnsigned,
382 LegacyInvalid,
384 SignedStructural,
387 Malformed,
389}
390
391pub fn key_format_status() -> KeyFormatStatus {
398 use base64::Engine;
399 match read_license_key() {
400 None => KeyFormatStatus::NoKey,
401 Some(k) => {
402 let trimmed = k.trim();
403 if let Some((left, right)) = trimmed.split_once('.') {
404 if left.is_empty() || right.is_empty() || right.contains('.') {
406 return KeyFormatStatus::Malformed;
407 }
408 let is_b64url = |s: &str| {
409 base64::engine::general_purpose::URL_SAFE_NO_PAD
410 .decode(s)
411 .is_ok()
412 || base64::engine::general_purpose::URL_SAFE.decode(s).is_ok()
413 };
414 if is_b64url(left) && is_b64url(right) {
415 KeyFormatStatus::SignedStructural
416 } else {
417 KeyFormatStatus::Malformed
418 }
419 } else {
420 let bytes = base64::engine::general_purpose::STANDARD
422 .decode(trimmed)
423 .or_else(|_| base64::engine::general_purpose::STANDARD_NO_PAD.decode(trimmed));
424 match bytes {
425 Ok(b) => {
426 if let Ok(v) = serde_json::from_slice::<serde_json::Value>(&b) {
427 if v.get("tier").and_then(|t| t.as_str()).is_some() {
428 KeyFormatStatus::LegacyUnsigned
429 } else {
430 KeyFormatStatus::LegacyInvalid
431 }
432 } else {
433 KeyFormatStatus::LegacyInvalid
434 }
435 }
436 Err(_) => KeyFormatStatus::LegacyInvalid,
437 }
438 }
439 }
440 }
441}
442
443fn read_license_key() -> Option<String> {
445 if let Ok(val) = std::env::var("TIRITH_LICENSE") {
446 let trimmed = val.trim().to_string();
447 if !trimmed.is_empty() {
448 return Some(trimmed);
449 }
450 }
451
452 let path = license_key_path()?;
453 match std::fs::read_to_string(&path) {
454 Ok(content) => {
455 let trimmed = content.trim().to_string();
456 if trimmed.is_empty() {
457 return None;
458 }
459 Some(trimmed)
460 }
461 Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
462 Err(e) => {
463 eprintln!(
464 "tirith: warning: cannot read license key {}: {e}",
465 path.display()
466 );
467 None
468 }
469 }
470}
471
472pub fn license_key_path() -> Option<PathBuf> {
474 let config = crate::policy::config_dir()?;
475 Some(config.join("license.key"))
476}
477
478pub fn validate_key_structure(token: &str) -> bool {
481 use base64::Engine;
482 let trimmed = token.trim();
483 if trimmed.is_empty() || trimmed.len() > MAX_TOKEN_LEN {
484 return false;
485 }
486 let Some((left, right)) = trimmed.split_once('.') else {
487 return false;
488 };
489 if left.is_empty() || right.is_empty() || right.contains('.') {
490 return false;
491 }
492 let is_b64url = |s: &str| {
493 base64::engine::general_purpose::URL_SAFE_NO_PAD
494 .decode(s)
495 .is_ok()
496 || base64::engine::general_purpose::URL_SAFE.decode(s).is_ok()
497 };
498 is_b64url(left) && is_b64url(right)
499}
500
501pub fn decode_and_validate_token(token: &str) -> Option<LicenseInfo> {
504 decode_license_info_at_with_mode(token, Utc::now(), ENFORCEMENT_MODE, KEYRING)
505}
506
507#[cfg(unix)]
512pub fn refresh_from_server(server_url: &str, api_key: &str) -> Result<String, String> {
513 crate::url_validate::validate_server_url(server_url)
514 .map_err(|reason| format!("invalid server URL: {reason}"))?;
515
516 let url = format!("{}/api/license/refresh", server_url.trim_end_matches('/'));
517 let client = reqwest::blocking::Client::builder()
518 .timeout(std::time::Duration::from_secs(30))
519 .build()
520 .map_err(|e| format!("HTTP client error: {e}"))?;
521 let resp = client
522 .post(&url)
523 .header("Authorization", format!("Bearer {api_key}"))
524 .send()
525 .map_err(|e| format!("Request failed: {e}"))?;
526 let status = resp.status();
527 if !status.is_success() {
528 let body = resp.text().unwrap_or_default();
529 return match status.as_u16() {
530 401 | 403 => Err("Authentication failed. Check your API key.".to_string()),
531 402 => Err("Subscription inactive. Renew at https://tirith.dev/account".to_string()),
532 _ => Err(format!("Server returned {status}: {body}")),
533 };
534 }
535 let token = resp
536 .text()
537 .map_err(|e| format!("Failed to read response: {e}"))?;
538 let trimmed = token.trim().to_string();
539 if trimmed.is_empty() {
540 return Err("Server returned empty token".to_string());
541 }
542 Ok(trimmed)
543}
544
545#[cfg(test)]
546mod tests {
547 use super::*;
548 use ed25519_dalek::SigningKey;
549 use rand_core::OsRng;
550
551 fn test_keypair() -> (SigningKey, [u8; 32]) {
552 let sk = SigningKey::generate(&mut OsRng);
553 let pk_bytes = sk.verifying_key().to_bytes();
554 (sk, pk_bytes)
555 }
556
557 fn test_keyring(pk: [u8; 32]) -> Vec<KeyEntry> {
558 vec![KeyEntry { kid: "k1", key: pk }]
559 }
560
561 fn make_signed_token(payload_json: &str, sk: &SigningKey) -> String {
562 use base64::Engine;
563 use ed25519_dalek::Signer;
564 let payload_bytes = payload_json.as_bytes();
565 let sig = sk.sign(payload_bytes);
566 let payload_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(payload_bytes);
567 let sig_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(sig.to_bytes());
568 format!("{payload_b64}.{sig_b64}")
569 }
570
571 fn make_payload(tier: &str, exp_ts: i64) -> String {
572 format!(
573 r#"{{"iss":"tirith.dev","aud":"tirith-cli","kid":"k1","tier":"{tier}","exp":{exp_ts}}}"#
574 )
575 }
576
577 fn make_full_payload(tier: &str, exp_ts: i64, org_id: &str, sso: &str, seats: u32) -> String {
578 format!(
579 r#"{{"iss":"tirith.dev","aud":"tirith-cli","kid":"k1","tier":"{tier}","exp":{exp_ts},"org_id":"{org_id}","sso_provider":"{sso}","seat_count":{seats}}}"#
580 )
581 }
582
583 fn future_ts() -> i64 {
584 4070908800 }
586
587 fn past_ts() -> i64 {
588 1577836800 }
590
591 fn now() -> DateTime<Utc> {
592 Utc::now()
593 }
594
595 fn make_key(tier: &str, exp: &str) -> String {
596 use base64::Engine;
597 let json = format!(r#"{{"tier":"{tier}","exp":"{exp}"}}"#);
598 base64::engine::general_purpose::STANDARD.encode(json.as_bytes())
599 }
600
601 fn make_key_no_exp(tier: &str) -> String {
602 use base64::Engine;
603 let json = format!(r#"{{"tier":"{tier}"}}"#);
604 base64::engine::general_purpose::STANDARD.encode(json.as_bytes())
605 }
606
607 fn make_team_sso_key(org_id: &str, sso_provider: &str) -> String {
608 use base64::Engine;
609 let json = format!(
610 r#"{{"tier":"team","exp":"2099-12-31","org_id":"{org_id}","sso_provider":"{sso_provider}","seat_count":50}}"#
611 );
612 base64::engine::general_purpose::STANDARD.encode(json.as_bytes())
613 }
614
615 #[test]
616 fn test_decode_pro() {
617 let key = make_key("pro", "2099-12-31");
618 assert_eq!(
619 decode_tier_at_with_mode(&key, now(), EnforcementMode::Legacy, KEYRING),
620 Some(Tier::Pro)
621 );
622 }
623
624 #[test]
625 fn test_decode_team() {
626 let key = make_key("team", "2099-12-31");
628 assert_eq!(
629 decode_tier_at_with_mode(&key, now(), EnforcementMode::Legacy, KEYRING),
630 Some(Tier::Pro)
631 );
632 }
633
634 #[test]
635 fn test_decode_enterprise() {
636 let key = make_key("enterprise", "2099-12-31");
638 assert_eq!(
639 decode_tier_at_with_mode(&key, now(), EnforcementMode::Legacy, KEYRING),
640 Some(Tier::Pro)
641 );
642 }
643
644 #[test]
645 fn test_decode_expired() {
646 let key = make_key("pro", "2020-01-01");
647 assert_eq!(
648 decode_tier_at_with_mode(&key, now(), EnforcementMode::Legacy, KEYRING),
649 None
650 );
651 }
652
653 #[test]
654 fn test_decode_no_expiry() {
655 let key = make_key_no_exp("pro");
657 assert_eq!(
658 decode_tier_at_with_mode(&key, now(), EnforcementMode::Legacy, KEYRING),
659 None
660 );
661 }
662
663 #[test]
664 fn test_decode_invalid_base64() {
665 assert_eq!(
666 decode_tier_at_with_mode("not-valid!!!", now(), EnforcementMode::Legacy, KEYRING),
667 None
668 );
669 }
670
671 #[test]
672 fn test_decode_invalid_json() {
673 use base64::Engine;
674 let key = base64::engine::general_purpose::STANDARD.encode(b"not json");
675 assert_eq!(
676 decode_tier_at_with_mode(&key, now(), EnforcementMode::Legacy, KEYRING),
677 None
678 );
679 }
680
681 #[test]
682 fn test_decode_missing_tier() {
683 use base64::Engine;
684 let key = base64::engine::general_purpose::STANDARD.encode(br#"{"exp":"2099-12-31"}"#);
685 assert_eq!(
686 decode_tier_at_with_mode(&key, now(), EnforcementMode::Legacy, KEYRING),
687 None
688 );
689 }
690
691 #[test]
692 fn test_decode_unknown_tier() {
693 let key = make_key("platinum", "2099-12-31");
694 assert_eq!(
695 decode_tier_at_with_mode(&key, now(), EnforcementMode::Legacy, KEYRING),
696 None
697 );
698 }
699
700 #[test]
701 fn test_decode_case_insensitive() {
702 let key = make_key("PRO", "2099-12-31");
703 assert_eq!(
704 decode_tier_at_with_mode(&key, now(), EnforcementMode::Legacy, KEYRING),
705 Some(Tier::Pro)
706 );
707 }
708
709 #[test]
710 fn test_current_tier_defaults_pro() {
711 let tier = current_tier();
712 assert_eq!(tier, Tier::Pro);
713 }
714
715 #[test]
716 fn test_tier_ordering() {
717 assert!(Tier::Community < Tier::Pro);
718 assert!(Tier::Pro < Tier::Team);
719 assert!(Tier::Team < Tier::Enterprise);
720 }
721
722 #[test]
723 fn test_tier_display() {
724 assert_eq!(format!("{}", Tier::Community), "Community");
725 assert_eq!(format!("{}", Tier::Pro), "Pro");
726 assert_eq!(format!("{}", Tier::Team), "Team");
727 assert_eq!(format!("{}", Tier::Enterprise), "Enterprise");
728 }
729
730 #[test]
731 fn test_decode_license_info_team_sso() {
732 let key = make_team_sso_key("org-acme-123", "okta");
734 let info = decode_license_info_at_with_mode(&key, now(), EnforcementMode::Legacy, KEYRING)
735 .unwrap();
736 assert_eq!(info.tier, Tier::Pro);
737 assert_eq!(info.org_id.as_deref(), Some("org-acme-123"));
738 assert_eq!(info.sso_provider.as_deref(), Some("okta"));
739 assert_eq!(info.seat_count, Some(50));
740 assert_eq!(info.expires.as_deref(), Some("2099-12-31"));
741 }
742
743 #[test]
744 fn test_decode_license_info_pro_no_sso() {
745 let key = make_key("pro", "2099-12-31");
746 let info = decode_license_info_at_with_mode(&key, now(), EnforcementMode::Legacy, KEYRING)
747 .unwrap();
748 assert_eq!(info.tier, Tier::Pro);
749 assert!(info.org_id.is_none());
750 assert!(info.sso_provider.is_none());
751 assert!(info.seat_count.is_none());
752 }
753
754 #[test]
755 fn test_decode_license_info_expired() {
756 use base64::Engine;
757 let json =
758 r#"{"tier":"team","exp":"2020-01-01","org_id":"org-123","sso_provider":"azure-ad"}"#;
759 let expired_key = base64::engine::general_purpose::STANDARD.encode(json.as_bytes());
760 assert!(decode_license_info_at_with_mode(
761 &expired_key,
762 now(),
763 EnforcementMode::Legacy,
764 KEYRING
765 )
766 .is_none());
767 }
768
769 #[test]
770 fn test_license_info_default() {
771 let info = LicenseInfo::default();
772 assert_eq!(info.tier, Tier::Pro);
773 assert!(info.org_id.is_none());
774 }
775
776 #[test]
777 fn test_signed_pro() {
778 let (sk, pk) = test_keypair();
779 let kr = test_keyring(pk);
780 let token = make_signed_token(&make_payload("pro", future_ts()), &sk);
781 assert_eq!(
782 decode_tier_at_with_mode(&token, now(), EnforcementMode::SignedPreferred, &kr),
783 Some(Tier::Pro)
784 );
785 }
786
787 #[test]
788 fn test_signed_team() {
789 let (sk, pk) = test_keypair();
790 let kr = test_keyring(pk);
791 let token = make_signed_token(&make_payload("team", future_ts()), &sk);
792 assert_eq!(
793 decode_tier_at_with_mode(&token, now(), EnforcementMode::SignedPreferred, &kr),
794 Some(Tier::Team)
795 );
796 }
797
798 #[test]
799 fn test_signed_enterprise() {
800 let (sk, pk) = test_keypair();
801 let kr = test_keyring(pk);
802 let token = make_signed_token(&make_payload("enterprise", future_ts()), &sk);
803 assert_eq!(
804 decode_tier_at_with_mode(&token, now(), EnforcementMode::SignedPreferred, &kr),
805 Some(Tier::Enterprise)
806 );
807 }
808
809 #[test]
810 fn test_signed_community() {
811 let (sk, pk) = test_keypair();
812 let kr = test_keyring(pk);
813 let token = make_signed_token(&make_payload("community", future_ts()), &sk);
814 assert_eq!(
815 decode_tier_at_with_mode(&token, now(), EnforcementMode::SignedPreferred, &kr),
816 Some(Tier::Community)
817 );
818 }
819
820 #[test]
821 fn test_signed_wrong_key() {
822 let (sk, _pk) = test_keypair();
823 let (_sk2, pk2) = test_keypair();
824 let kr = test_keyring(pk2); let token = make_signed_token(&make_payload("pro", future_ts()), &sk);
826 assert_eq!(
827 decode_tier_at_with_mode(&token, now(), EnforcementMode::SignedPreferred, &kr),
828 None
829 );
830 }
831
832 #[test]
833 fn test_signed_tampered_payload() {
834 let (sk, pk) = test_keypair();
835 let kr = test_keyring(pk);
836 let token = make_signed_token(&make_payload("pro", future_ts()), &sk);
837 let mut chars: Vec<char> = token.chars().collect();
839 chars[0] = if chars[0] == 'a' { 'b' } else { 'a' };
840 let tampered: String = chars.into_iter().collect();
841 assert_eq!(
842 decode_tier_at_with_mode(&tampered, now(), EnforcementMode::SignedPreferred, &kr),
843 None
844 );
845 }
846
847 #[test]
848 fn test_signed_tampered_signature() {
849 use base64::Engine;
850 let (sk, pk) = test_keypair();
851 let kr = test_keyring(pk);
852 let token = make_signed_token(&make_payload("pro", future_ts()), &sk);
853 let (payload_part, _sig_part) = token.split_once('.').unwrap();
854 let bad_sig = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([0u8; 64]);
856 let tampered = format!("{payload_part}.{bad_sig}");
857 assert_eq!(
858 decode_tier_at_with_mode(&tampered, now(), EnforcementMode::SignedPreferred, &kr),
859 None
860 );
861 }
862
863 #[test]
864 fn test_signed_wrong_iss() {
865 let (sk, pk) = test_keypair();
866 let kr = test_keyring(pk);
867 let payload = format!(
868 r#"{{"iss":"evil.com","aud":"tirith-cli","kid":"k1","tier":"pro","exp":{}}}"#,
869 future_ts()
870 );
871 let token = make_signed_token(&payload, &sk);
872 assert_eq!(
873 decode_tier_at_with_mode(&token, now(), EnforcementMode::SignedPreferred, &kr),
874 None
875 );
876 }
877
878 #[test]
879 fn test_signed_wrong_aud() {
880 let (sk, pk) = test_keypair();
881 let kr = test_keyring(pk);
882 let payload = format!(
883 r#"{{"iss":"tirith.dev","aud":"wrong-aud","kid":"k1","tier":"pro","exp":{}}}"#,
884 future_ts()
885 );
886 let token = make_signed_token(&payload, &sk);
887 assert_eq!(
888 decode_tier_at_with_mode(&token, now(), EnforcementMode::SignedPreferred, &kr),
889 None
890 );
891 }
892
893 #[test]
894 fn test_signed_expired() {
895 let (sk, pk) = test_keypair();
896 let kr = test_keyring(pk);
897 let token = make_signed_token(&make_payload("pro", past_ts()), &sk);
898 assert_eq!(
899 decode_tier_at_with_mode(&token, now(), EnforcementMode::SignedPreferred, &kr),
900 None
901 );
902 }
903
904 #[test]
905 fn test_signed_missing_exp() {
906 let (sk, pk) = test_keypair();
907 let kr = test_keyring(pk);
908 let payload = r#"{"iss":"tirith.dev","aud":"tirith-cli","kid":"k1","tier":"pro"}"#;
909 let token = make_signed_token(payload, &sk);
910 assert_eq!(
911 decode_tier_at_with_mode(&token, now(), EnforcementMode::SignedPreferred, &kr),
912 None
913 );
914 }
915
916 #[test]
917 fn test_signed_nbf_future() {
918 let (sk, pk) = test_keypair();
919 let kr = test_keyring(pk);
920 let far_future_nbf = future_ts() - 1000;
922 let payload = format!(
923 r#"{{"iss":"tirith.dev","aud":"tirith-cli","kid":"k1","tier":"pro","exp":{},"nbf":{}}}"#,
924 future_ts(),
925 far_future_nbf
926 );
927 let token = make_signed_token(&payload, &sk);
928 assert_eq!(
929 decode_tier_at_with_mode(&token, now(), EnforcementMode::SignedPreferred, &kr),
930 None
931 );
932 }
933
934 #[test]
935 fn test_legacy_works_in_signed_preferred() {
936 let key = make_key("pro", "2099-12-31");
937 assert_eq!(
938 decode_tier_at_with_mode(&key, now(), EnforcementMode::SignedPreferred, KEYRING),
939 Some(Tier::Pro)
940 );
941 }
942
943 #[test]
944 fn test_legacy_rejected_in_signed_only() {
945 let key = make_key("pro", "2099-12-31");
946 assert_eq!(
947 decode_tier_at_with_mode(&key, now(), EnforcementMode::SignedOnly, KEYRING),
948 None
949 );
950 }
951
952 #[test]
953 fn test_signed_license_info_full() {
954 let (sk, pk) = test_keypair();
955 let kr = test_keyring(pk);
956 let payload = make_full_payload("team", future_ts(), "org-acme-123", "okta", 50);
957 let token = make_signed_token(&payload, &sk);
958 let info =
959 decode_license_info_at_with_mode(&token, now(), EnforcementMode::SignedPreferred, &kr)
960 .unwrap();
961 assert_eq!(info.tier, Tier::Team);
962 assert_eq!(info.org_id.as_deref(), Some("org-acme-123"));
963 assert_eq!(info.sso_provider.as_deref(), Some("okta"));
964 assert_eq!(info.seat_count, Some(50));
965 }
966
967 #[test]
968 fn test_signed_license_info_expired() {
969 let (sk, pk) = test_keypair();
970 let kr = test_keyring(pk);
971 let payload = make_full_payload("team", past_ts(), "org-123", "okta", 50);
972 let token = make_signed_token(&payload, &sk);
973 assert!(decode_license_info_at_with_mode(
974 &token,
975 now(),
976 EnforcementMode::SignedPreferred,
977 &kr,
978 )
979 .is_none());
980 }
981
982 #[test]
983 fn test_kid_correct() {
984 let (sk, pk) = test_keypair();
985 let kr = test_keyring(pk);
986 let token = make_signed_token(&make_payload("pro", future_ts()), &sk);
987 assert_eq!(
988 decode_tier_at_with_mode(&token, now(), EnforcementMode::SignedPreferred, &kr),
989 Some(Tier::Pro)
990 );
991 }
992
993 #[test]
994 fn test_kid_mismatch() {
995 let (sk, pk) = test_keypair();
996 let kr = test_keyring(pk);
997 let payload = format!(
999 r#"{{"iss":"tirith.dev","aud":"tirith-cli","kid":"k99","tier":"pro","exp":{}}}"#,
1000 future_ts()
1001 );
1002 let token = make_signed_token(&payload, &sk);
1003 assert_eq!(
1004 decode_tier_at_with_mode(&token, now(), EnforcementMode::SignedPreferred, &kr),
1005 None
1006 );
1007 }
1008
1009 #[test]
1010 fn test_no_kid_tries_all() {
1011 let (sk, pk) = test_keypair();
1012 let kr = test_keyring(pk);
1013 let payload = format!(
1015 r#"{{"iss":"tirith.dev","aud":"tirith-cli","tier":"pro","exp":{}}}"#,
1016 future_ts()
1017 );
1018 let token = make_signed_token(&payload, &sk);
1019 assert_eq!(
1020 decode_tier_at_with_mode(&token, now(), EnforcementMode::SignedPreferred, &kr),
1021 Some(Tier::Pro)
1022 );
1023 }
1024
1025 #[test]
1026 fn test_parser_empty_segment_left() {
1027 let (_, pk) = test_keypair();
1028 let kr = test_keyring(pk);
1029 assert_eq!(
1030 decode_tier_at_with_mode(".sig", now(), EnforcementMode::SignedPreferred, &kr),
1031 None
1032 );
1033 }
1034
1035 #[test]
1036 fn test_parser_empty_segment_right() {
1037 let (_, pk) = test_keypair();
1038 let kr = test_keyring(pk);
1039 assert_eq!(
1040 decode_tier_at_with_mode("payload.", now(), EnforcementMode::SignedPreferred, &kr),
1041 None
1042 );
1043 }
1044
1045 #[test]
1046 fn test_parser_extra_segments() {
1047 let (_, pk) = test_keypair();
1048 let kr = test_keyring(pk);
1049 assert_eq!(
1050 decode_tier_at_with_mode("a.b.c", now(), EnforcementMode::SignedPreferred, &kr),
1051 None
1052 );
1053 }
1054
1055 #[test]
1056 fn test_parser_oversized_token() {
1057 let (sk, pk) = test_keypair();
1058 let kr = test_keyring(pk);
1059 let token = make_signed_token(&make_payload("pro", future_ts()), &sk);
1061 let oversized = format!("{token}{}", "A".repeat(MAX_TOKEN_LEN));
1062 assert_eq!(
1063 decode_tier_at_with_mode(&oversized, now(), EnforcementMode::SignedPreferred, &kr),
1064 None
1065 );
1066 }
1067
1068 #[test]
1069 fn test_parser_bad_nbf_type() {
1070 let (sk, pk) = test_keypair();
1071 let kr = test_keyring(pk);
1072 let payload = format!(
1074 r#"{{"iss":"tirith.dev","aud":"tirith-cli","kid":"k1","tier":"pro","exp":{},"nbf":"not-a-number"}}"#,
1075 future_ts()
1076 );
1077 let token = make_signed_token(&payload, &sk);
1078 assert_eq!(
1079 decode_tier_at_with_mode(&token, now(), EnforcementMode::SignedPreferred, &kr),
1080 None
1081 );
1082 }
1083
1084 #[test]
1085 fn test_parser_whitespace_only() {
1086 let (_, pk) = test_keypair();
1087 let kr = test_keyring(pk);
1088 assert_eq!(
1089 decode_tier_at_with_mode(" ", now(), EnforcementMode::SignedPreferred, &kr),
1090 None
1091 );
1092 }
1093
1094 #[test]
1095 fn test_parser_exp_exact_boundary() {
1096 let (sk, pk) = test_keypair();
1097 let kr = test_keyring(pk);
1098 let ts = now().timestamp();
1100 let token = make_signed_token(&make_payload("pro", ts), &sk);
1101 assert_eq!(
1102 decode_tier_at_with_mode(&token, now(), EnforcementMode::SignedPreferred, &kr),
1103 None
1104 );
1105 }
1106
1107 #[test]
1108 fn test_parser_nbf_exact_boundary() {
1109 let (sk, pk) = test_keypair();
1110 let kr = test_keyring(pk);
1111 let ts = now().timestamp();
1113 let payload = format!(
1114 r#"{{"iss":"tirith.dev","aud":"tirith-cli","kid":"k1","tier":"pro","exp":{},"nbf":{}}}"#,
1115 future_ts(),
1116 ts
1117 );
1118 let token = make_signed_token(&payload, &sk);
1119 assert_eq!(
1120 decode_tier_at_with_mode(&token, now(), EnforcementMode::SignedPreferred, &kr),
1121 Some(Tier::Pro)
1122 );
1123 }
1124
1125 #[test]
1126 fn test_parser_malformed_base64url() {
1127 let (_, pk) = test_keypair();
1128 let kr = test_keyring(pk);
1129 assert_eq!(
1130 decode_tier_at_with_mode(
1131 "not!valid!b64.also!not!valid",
1132 now(),
1133 EnforcementMode::SignedPreferred,
1134 &kr
1135 ),
1136 None
1137 );
1138 }
1139
1140 #[test]
1141 fn test_parser_padded_base64url_structural() {
1142 use base64::Engine;
1144 let payload = r#"{"iss":"tirith.dev","aud":"tirith-cli","tier":"pro","exp":9999999999}"#;
1145 let payload_b64 = base64::engine::general_purpose::URL_SAFE.encode(payload.as_bytes());
1146 let fake_sig_b64 = base64::engine::general_purpose::URL_SAFE.encode([0u8; 64]);
1147 let token = format!("{payload_b64}.{fake_sig_b64}");
1148 assert!(token.contains('='));
1149
1150 let _guard = crate::TEST_ENV_LOCK
1151 .lock()
1152 .unwrap_or_else(|e| e.into_inner());
1153 unsafe { std::env::set_var("TIRITH_LICENSE", &token) };
1154 let status = key_format_status();
1155 unsafe { std::env::remove_var("TIRITH_LICENSE") };
1156 assert_eq!(
1157 status,
1158 KeyFormatStatus::SignedStructural,
1159 "Padded base64url token should be recognized as SignedStructural"
1160 );
1161 }
1162
1163 #[test]
1164 fn test_enforcement_legacy_accepts_unsigned() {
1165 let key = make_key("pro", "2099-12-31");
1166 assert_eq!(
1167 decode_tier_at_with_mode(&key, now(), EnforcementMode::Legacy, KEYRING),
1168 Some(Tier::Pro)
1169 );
1170 }
1171
1172 #[test]
1173 fn test_enforcement_signed_only_rejects_unsigned() {
1174 let key = make_key("pro", "2099-12-31");
1175 assert_eq!(
1176 decode_tier_at_with_mode(&key, now(), EnforcementMode::SignedOnly, KEYRING),
1177 None
1178 );
1179 }
1180
1181 #[test]
1182 #[allow(clippy::const_is_empty)]
1183 fn test_keyring_non_empty() {
1184 #[allow(clippy::const_is_empty)]
1186 let not_empty = !KEYRING.is_empty();
1187 assert!(not_empty);
1188 }
1189
1190 #[test]
1191 fn test_keyring_no_duplicate_kids() {
1192 let mut kids: Vec<&str> = KEYRING.iter().map(|e| e.kid).collect();
1193 kids.sort();
1194 kids.dedup();
1195 assert_eq!(kids.len(), KEYRING.len(), "Duplicate kid values in KEYRING");
1196 }
1197
1198 #[test]
1199 fn test_keyring_all_keys_valid() {
1200 for entry in KEYRING {
1201 assert!(
1202 ed25519_dalek::VerifyingKey::from_bytes(&entry.key).is_ok(),
1203 "Invalid public key for kid {}",
1204 entry.kid
1205 );
1206 }
1207 }
1208
1209 #[test]
1212 #[ignore]
1213 fn enforcement_mode_matches_release_tag() {
1214 let tag = std::env::var("RELEASE_TAG").expect("RELEASE_TAG env var not set");
1215 let mode = match ENFORCEMENT_MODE {
1216 EnforcementMode::Legacy => "Legacy",
1217 EnforcementMode::SignedPreferred => "SignedPreferred",
1218 EnforcementMode::SignedOnly => "SignedOnly",
1219 };
1220
1221 let version = tag
1222 .strip_prefix('v')
1223 .unwrap_or_else(|| panic!("RELEASE_TAG must start with 'v', got: {tag}"));
1224 let parts: Vec<&str> = version.split('.').collect();
1225 assert!(
1226 parts.len() >= 2,
1227 "RELEASE_TAG must be semver (vX.Y.Z), got: {tag}"
1228 );
1229 let major: u32 = parts[0]
1230 .parse()
1231 .unwrap_or_else(|_| panic!("Invalid major version in {tag}"));
1232 let minor: u32 = parts[1]
1233 .parse()
1234 .unwrap_or_else(|_| panic!("Invalid minor version in {tag}"));
1235
1236 if major > 0 || minor >= 3 {
1237 assert_eq!(
1238 mode, "SignedOnly",
1239 "Release {tag} (>= v0.3) requires SignedOnly, found {mode}"
1240 );
1241 } else if minor == 2 {
1242 assert!(
1243 mode == "SignedPreferred" || mode == "SignedOnly",
1244 "Release {tag} (v0.2.x) requires SignedPreferred+, found {mode}"
1245 );
1246 } else if minor <= 1 {
1247 assert!(
1249 mode == "Legacy" || mode == "SignedPreferred",
1250 "Release {tag} (v0.1.x) should use Legacy or SignedPreferred, found {mode}"
1251 );
1252 }
1253 }
1254
1255 #[test]
1256 fn test_key_revocation_after_removal() {
1257 let (sk, pk) = test_keypair();
1259 let kr_with_key = test_keyring(pk);
1260 let token = make_signed_token(&make_payload("pro", future_ts()), &sk);
1261
1262 assert_eq!(
1263 decode_tier_at_with_mode(
1264 &token,
1265 now(),
1266 EnforcementMode::SignedPreferred,
1267 &kr_with_key
1268 ),
1269 Some(Tier::Pro)
1270 );
1271
1272 let kr_empty: Vec<KeyEntry> = vec![];
1273 assert_eq!(
1274 decode_tier_at_with_mode(&token, now(), EnforcementMode::SignedPreferred, &kr_empty),
1275 None,
1276 "Token must be rejected after signing key is removed from keyring"
1277 );
1278 }
1279
1280 #[test]
1281 fn test_multi_key_kid_directed_lookup() {
1282 let (sk1, pk1) = test_keypair();
1283 let (sk2, pk2) = test_keypair();
1284 let kr = vec![
1285 KeyEntry {
1286 kid: "k1",
1287 key: pk1,
1288 },
1289 KeyEntry {
1290 kid: "k2",
1291 key: pk2,
1292 },
1293 ];
1294
1295 let payload = format!(
1297 r#"{{"iss":"tirith.dev","aud":"tirith-cli","kid":"k2","tier":"team","exp":{}}}"#,
1298 future_ts()
1299 );
1300 let token = make_signed_token(&payload, &sk2);
1301 assert_eq!(
1302 decode_tier_at_with_mode(&token, now(), EnforcementMode::SignedPreferred, &kr),
1303 Some(Tier::Team)
1304 );
1305
1306 let token1 = make_signed_token(&make_payload("pro", future_ts()), &sk1);
1307 assert_eq!(
1308 decode_tier_at_with_mode(&token1, now(), EnforcementMode::SignedPreferred, &kr),
1309 Some(Tier::Pro)
1310 );
1311
1312 let wrong_kid_payload = format!(
1314 r#"{{"iss":"tirith.dev","aud":"tirith-cli","kid":"k2","tier":"pro","exp":{}}}"#,
1315 future_ts()
1316 );
1317 let wrong_kid_token = make_signed_token(&wrong_kid_payload, &sk1);
1318 assert_eq!(
1319 decode_tier_at_with_mode(
1320 &wrong_kid_token,
1321 now(),
1322 EnforcementMode::SignedPreferred,
1323 &kr
1324 ),
1325 None,
1326 "Token signed with k1 but kid=k2 must be rejected"
1327 );
1328 }
1329
1330 #[test]
1331 fn test_signed_missing_iss() {
1332 let (sk, pk) = test_keypair();
1333 let kr = test_keyring(pk);
1334 let payload = format!(
1335 r#"{{"aud":"tirith-cli","kid":"k1","tier":"pro","exp":{}}}"#,
1336 future_ts()
1337 );
1338 let token = make_signed_token(&payload, &sk);
1339 assert_eq!(
1340 decode_tier_at_with_mode(&token, now(), EnforcementMode::SignedPreferred, &kr),
1341 None,
1342 "Missing iss claim must be rejected"
1343 );
1344 }
1345
1346 #[test]
1347 fn test_signed_missing_aud() {
1348 let (sk, pk) = test_keypair();
1349 let kr = test_keyring(pk);
1350 let payload = format!(
1351 r#"{{"iss":"tirith.dev","kid":"k1","tier":"pro","exp":{}}}"#,
1352 future_ts()
1353 );
1354 let token = make_signed_token(&payload, &sk);
1355 assert_eq!(
1356 decode_tier_at_with_mode(&token, now(), EnforcementMode::SignedPreferred, &kr),
1357 None,
1358 "Missing aud claim must be rejected"
1359 );
1360 }
1361
1362 #[test]
1363 fn test_signed_exp_as_string_rejected() {
1364 let (sk, pk) = test_keypair();
1365 let kr = test_keyring(pk);
1366 let payload =
1368 r#"{"iss":"tirith.dev","aud":"tirith-cli","kid":"k1","tier":"pro","exp":"2099-12-31"}"#;
1369 let token = make_signed_token(payload, &sk);
1370 assert_eq!(
1371 decode_tier_at_with_mode(&token, now(), EnforcementMode::SignedPreferred, &kr),
1372 None,
1373 "Signed token with exp as string must be rejected"
1374 );
1375 }
1376
1377 #[test]
1378 fn test_empty_string_token() {
1379 let (_, pk) = test_keypair();
1380 let kr = test_keyring(pk);
1381 assert_eq!(
1382 decode_tier_at_with_mode("", now(), EnforcementMode::SignedPreferred, &kr),
1383 None
1384 );
1385 }
1386
1387 #[test]
1388 fn test_legacy_mode_accepts_signed() {
1389 let (sk, pk) = test_keypair();
1390 let kr = test_keyring(pk);
1391 let token = make_signed_token(&make_payload("pro", future_ts()), &sk);
1392 assert_eq!(
1393 decode_tier_at_with_mode(&token, now(), EnforcementMode::Legacy, &kr),
1394 Some(Tier::Pro),
1395 "Legacy mode should accept valid signed tokens"
1396 );
1397 }
1398
1399 #[test]
1400 fn test_signed_only_accepts_signed() {
1401 let (sk, pk) = test_keypair();
1402 let kr = test_keyring(pk);
1403 let token = make_signed_token(&make_payload("pro", future_ts()), &sk);
1404 assert_eq!(
1405 decode_tier_at_with_mode(&token, now(), EnforcementMode::SignedOnly, &kr),
1406 Some(Tier::Pro),
1407 "SignedOnly mode should accept valid signed tokens"
1408 );
1409 }
1410}