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