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)]
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 crate::audit::audit_diagnostic(
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 crate::audit::audit_diagnostic(
393 "tirith: warning: license key present but decode failed for license info",
394 );
395 LicenseInfo::default()
396 }),
397 None => LicenseInfo::default(),
398 }
399}
400
401#[derive(Debug, Clone, Copy, PartialEq, Eq)]
407pub enum KeyFormatStatus {
408 NoKey,
409 LegacyUnsigned,
411 LegacyInvalid,
413 SignedStructural,
416 Malformed,
418}
419
420pub fn key_format_status() -> KeyFormatStatus {
427 use base64::Engine;
428 match read_license_key() {
429 None => KeyFormatStatus::NoKey,
430 Some(k) => {
431 let trimmed = k.trim();
432 if let Some((left, right)) = trimmed.split_once('.') {
433 if left.is_empty() || right.is_empty() || right.contains('.') {
435 return KeyFormatStatus::Malformed;
436 }
437 let is_b64url = |s: &str| {
438 base64::engine::general_purpose::URL_SAFE_NO_PAD
439 .decode(s)
440 .is_ok()
441 || base64::engine::general_purpose::URL_SAFE.decode(s).is_ok()
442 };
443 if is_b64url(left) && is_b64url(right) {
444 KeyFormatStatus::SignedStructural
445 } else {
446 KeyFormatStatus::Malformed
447 }
448 } else {
449 let bytes = base64::engine::general_purpose::STANDARD
451 .decode(trimmed)
452 .or_else(|_| base64::engine::general_purpose::STANDARD_NO_PAD.decode(trimmed));
453 match bytes {
454 Ok(b) => {
455 if let Ok(v) = serde_json::from_slice::<serde_json::Value>(&b) {
456 if v.get("tier").and_then(|t| t.as_str()).is_some() {
457 KeyFormatStatus::LegacyUnsigned
458 } else {
459 KeyFormatStatus::LegacyInvalid
460 }
461 } else {
462 KeyFormatStatus::LegacyInvalid
463 }
464 }
465 Err(_) => KeyFormatStatus::LegacyInvalid,
466 }
467 }
468 }
469 }
470}
471
472fn read_license_key() -> Option<String> {
476 if let Ok(val) = std::env::var("TIRITH_LICENSE") {
478 let trimmed = val.trim().to_string();
479 if !trimmed.is_empty() {
480 return Some(trimmed);
481 }
482 }
483
484 let path = license_key_path()?;
486 match std::fs::read_to_string(&path) {
487 Ok(content) => {
488 let trimmed = content.trim().to_string();
489 if trimmed.is_empty() {
490 return None;
491 }
492 Some(trimmed)
493 }
494 Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
495 Err(e) => {
496 eprintln!(
497 "tirith: warning: cannot read license key {}: {e}",
498 path.display()
499 );
500 None
501 }
502 }
503}
504
505pub fn license_key_path() -> Option<PathBuf> {
507 let config = crate::policy::config_dir()?;
508 Some(config.join("license.key"))
509}
510
511pub fn validate_key_structure(token: &str) -> bool {
514 use base64::Engine;
515 let trimmed = token.trim();
516 if trimmed.is_empty() || trimmed.len() > MAX_TOKEN_LEN {
517 return false;
518 }
519 let Some((left, right)) = trimmed.split_once('.') else {
520 return false;
521 };
522 if left.is_empty() || right.is_empty() || right.contains('.') {
523 return false;
524 }
525 let is_b64url = |s: &str| {
526 base64::engine::general_purpose::URL_SAFE_NO_PAD
527 .decode(s)
528 .is_ok()
529 || base64::engine::general_purpose::URL_SAFE.decode(s).is_ok()
530 };
531 is_b64url(left) && is_b64url(right)
532}
533
534pub fn decode_and_validate_token(token: &str) -> Option<LicenseInfo> {
537 decode_license_info_at_with_mode(token, Utc::now(), ENFORCEMENT_MODE, KEYRING)
538}
539
540#[cfg(unix)]
545pub fn refresh_from_server(server_url: &str, api_key: &str) -> Result<String, String> {
546 crate::url_validate::validate_server_url(server_url)
547 .map_err(|reason| format!("invalid server URL: {reason}"))?;
548
549 let url = format!("{}/api/license/refresh", server_url.trim_end_matches('/'));
550 let client = reqwest::blocking::Client::builder()
551 .timeout(std::time::Duration::from_secs(30))
552 .build()
553 .map_err(|e| format!("HTTP client error: {e}"))?;
554 let resp = client
555 .post(&url)
556 .header("Authorization", format!("Bearer {api_key}"))
557 .send()
558 .map_err(|e| format!("Request failed: {e}"))?;
559 let status = resp.status();
560 if !status.is_success() {
561 let body = resp.text().unwrap_or_default();
562 return match status.as_u16() {
563 401 | 403 => Err("Authentication failed. Check your API key.".to_string()),
564 402 => Err("Subscription inactive. Renew at https://tirith.dev/account".to_string()),
565 _ => Err(format!("Server returned {status}: {body}")),
566 };
567 }
568 let token = resp
569 .text()
570 .map_err(|e| format!("Failed to read response: {e}"))?;
571 let trimmed = token.trim().to_string();
572 if trimmed.is_empty() {
573 return Err("Server returned empty token".to_string());
574 }
575 Ok(trimmed)
576}
577
578#[cfg(test)]
581mod tests {
582 use super::*;
583 use ed25519_dalek::SigningKey;
584 use rand_core::OsRng;
585
586 fn test_keypair() -> (SigningKey, [u8; 32]) {
589 let sk = SigningKey::generate(&mut OsRng);
590 let pk_bytes = sk.verifying_key().to_bytes();
591 (sk, pk_bytes)
592 }
593
594 fn test_keyring(pk: [u8; 32]) -> Vec<KeyEntry> {
595 vec![KeyEntry { kid: "k1", key: pk }]
596 }
597
598 fn make_signed_token(payload_json: &str, sk: &SigningKey) -> String {
599 use base64::Engine;
600 use ed25519_dalek::Signer;
601 let payload_bytes = payload_json.as_bytes();
602 let sig = sk.sign(payload_bytes);
603 let payload_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(payload_bytes);
604 let sig_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(sig.to_bytes());
605 format!("{payload_b64}.{sig_b64}")
606 }
607
608 fn make_payload(tier: &str, exp_ts: i64) -> String {
609 format!(
610 r#"{{"iss":"tirith.dev","aud":"tirith-cli","kid":"k1","tier":"{tier}","exp":{exp_ts}}}"#
611 )
612 }
613
614 fn make_full_payload(tier: &str, exp_ts: i64, org_id: &str, sso: &str, seats: u32) -> String {
615 format!(
616 r#"{{"iss":"tirith.dev","aud":"tirith-cli","kid":"k1","tier":"{tier}","exp":{exp_ts},"org_id":"{org_id}","sso_provider":"{sso}","seat_count":{seats}}}"#
617 )
618 }
619
620 fn future_ts() -> i64 {
621 4070908800
623 }
624
625 fn past_ts() -> i64 {
626 1577836800
628 }
629
630 fn now() -> DateTime<Utc> {
631 Utc::now()
632 }
633
634 fn make_key(tier: &str, exp: &str) -> String {
637 use base64::Engine;
638 let json = format!(r#"{{"tier":"{tier}","exp":"{exp}"}}"#);
639 base64::engine::general_purpose::STANDARD.encode(json.as_bytes())
640 }
641
642 fn make_key_no_exp(tier: &str) -> String {
643 use base64::Engine;
644 let json = format!(r#"{{"tier":"{tier}"}}"#);
645 base64::engine::general_purpose::STANDARD.encode(json.as_bytes())
646 }
647
648 fn make_team_sso_key(org_id: &str, sso_provider: &str) -> String {
649 use base64::Engine;
650 let json = format!(
651 r#"{{"tier":"team","exp":"2099-12-31","org_id":"{org_id}","sso_provider":"{sso_provider}","seat_count":50}}"#
652 );
653 base64::engine::general_purpose::STANDARD.encode(json.as_bytes())
654 }
655
656 #[test]
659 fn test_decode_pro() {
660 let key = make_key("pro", "2099-12-31");
661 assert_eq!(
662 decode_tier_at_with_mode(&key, now(), EnforcementMode::Legacy, KEYRING),
663 Some(Tier::Pro)
664 );
665 }
666
667 #[test]
668 fn test_decode_team() {
669 let key = make_key("team", "2099-12-31");
671 assert_eq!(
672 decode_tier_at_with_mode(&key, now(), EnforcementMode::Legacy, KEYRING),
673 Some(Tier::Pro)
674 );
675 }
676
677 #[test]
678 fn test_decode_enterprise() {
679 let key = make_key("enterprise", "2099-12-31");
681 assert_eq!(
682 decode_tier_at_with_mode(&key, now(), EnforcementMode::Legacy, KEYRING),
683 Some(Tier::Pro)
684 );
685 }
686
687 #[test]
688 fn test_decode_expired() {
689 let key = make_key("pro", "2020-01-01");
690 assert_eq!(
691 decode_tier_at_with_mode(&key, now(), EnforcementMode::Legacy, KEYRING),
692 None
693 );
694 }
695
696 #[test]
697 fn test_decode_no_expiry() {
698 let key = make_key_no_exp("pro");
700 assert_eq!(
701 decode_tier_at_with_mode(&key, now(), EnforcementMode::Legacy, KEYRING),
702 None
703 );
704 }
705
706 #[test]
707 fn test_decode_invalid_base64() {
708 assert_eq!(
709 decode_tier_at_with_mode("not-valid!!!", now(), EnforcementMode::Legacy, KEYRING),
710 None
711 );
712 }
713
714 #[test]
715 fn test_decode_invalid_json() {
716 use base64::Engine;
717 let key = base64::engine::general_purpose::STANDARD.encode(b"not json");
718 assert_eq!(
719 decode_tier_at_with_mode(&key, now(), EnforcementMode::Legacy, KEYRING),
720 None
721 );
722 }
723
724 #[test]
725 fn test_decode_missing_tier() {
726 use base64::Engine;
727 let key = base64::engine::general_purpose::STANDARD.encode(br#"{"exp":"2099-12-31"}"#);
728 assert_eq!(
729 decode_tier_at_with_mode(&key, now(), EnforcementMode::Legacy, KEYRING),
730 None
731 );
732 }
733
734 #[test]
735 fn test_decode_unknown_tier() {
736 let key = make_key("platinum", "2099-12-31");
737 assert_eq!(
738 decode_tier_at_with_mode(&key, now(), EnforcementMode::Legacy, KEYRING),
739 None
740 );
741 }
742
743 #[test]
744 fn test_decode_case_insensitive() {
745 let key = make_key("PRO", "2099-12-31");
746 assert_eq!(
747 decode_tier_at_with_mode(&key, now(), EnforcementMode::Legacy, KEYRING),
748 Some(Tier::Pro)
749 );
750 }
751
752 #[test]
753 fn test_current_tier_defaults_pro() {
754 let tier = current_tier();
755 assert_eq!(tier, Tier::Pro);
756 }
757
758 #[test]
759 fn test_tier_ordering() {
760 assert!(Tier::Community < Tier::Pro);
761 assert!(Tier::Pro < Tier::Team);
762 assert!(Tier::Team < Tier::Enterprise);
763 }
764
765 #[test]
766 fn test_tier_display() {
767 assert_eq!(format!("{}", Tier::Community), "Community");
768 assert_eq!(format!("{}", Tier::Pro), "Pro");
769 assert_eq!(format!("{}", Tier::Team), "Team");
770 assert_eq!(format!("{}", Tier::Enterprise), "Enterprise");
771 }
772
773 #[test]
774 fn test_decode_license_info_team_sso() {
775 let key = make_team_sso_key("org-acme-123", "okta");
777 let info = decode_license_info_at_with_mode(&key, now(), EnforcementMode::Legacy, KEYRING)
778 .unwrap();
779 assert_eq!(info.tier, Tier::Pro);
780 assert_eq!(info.org_id.as_deref(), Some("org-acme-123"));
781 assert_eq!(info.sso_provider.as_deref(), Some("okta"));
782 assert_eq!(info.seat_count, Some(50));
783 assert_eq!(info.expires.as_deref(), Some("2099-12-31"));
784 }
785
786 #[test]
787 fn test_decode_license_info_pro_no_sso() {
788 let key = make_key("pro", "2099-12-31");
789 let info = decode_license_info_at_with_mode(&key, now(), EnforcementMode::Legacy, KEYRING)
790 .unwrap();
791 assert_eq!(info.tier, Tier::Pro);
792 assert!(info.org_id.is_none());
793 assert!(info.sso_provider.is_none());
794 assert!(info.seat_count.is_none());
795 }
796
797 #[test]
798 fn test_decode_license_info_expired() {
799 use base64::Engine;
800 let json =
801 r#"{"tier":"team","exp":"2020-01-01","org_id":"org-123","sso_provider":"azure-ad"}"#;
802 let expired_key = base64::engine::general_purpose::STANDARD.encode(json.as_bytes());
803 assert!(decode_license_info_at_with_mode(
804 &expired_key,
805 now(),
806 EnforcementMode::Legacy,
807 KEYRING
808 )
809 .is_none());
810 }
811
812 #[test]
813 fn test_license_info_default() {
814 let info = LicenseInfo::default();
815 assert_eq!(info.tier, Tier::Pro);
816 assert!(info.org_id.is_none());
817 }
818
819 #[test]
822 fn test_signed_pro() {
823 let (sk, pk) = test_keypair();
824 let kr = test_keyring(pk);
825 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 Some(Tier::Pro)
829 );
830 }
831
832 #[test]
833 fn test_signed_team() {
834 let (sk, pk) = test_keypair();
835 let kr = test_keyring(pk);
836 let token = make_signed_token(&make_payload("team", future_ts()), &sk);
837 assert_eq!(
838 decode_tier_at_with_mode(&token, now(), EnforcementMode::SignedPreferred, &kr),
839 Some(Tier::Team)
840 );
841 }
842
843 #[test]
844 fn test_signed_enterprise() {
845 let (sk, pk) = test_keypair();
846 let kr = test_keyring(pk);
847 let token = make_signed_token(&make_payload("enterprise", future_ts()), &sk);
848 assert_eq!(
849 decode_tier_at_with_mode(&token, now(), EnforcementMode::SignedPreferred, &kr),
850 Some(Tier::Enterprise)
851 );
852 }
853
854 #[test]
855 fn test_signed_community() {
856 let (sk, pk) = test_keypair();
857 let kr = test_keyring(pk);
858 let token = make_signed_token(&make_payload("community", future_ts()), &sk);
859 assert_eq!(
860 decode_tier_at_with_mode(&token, now(), EnforcementMode::SignedPreferred, &kr),
861 Some(Tier::Community)
862 );
863 }
864
865 #[test]
868 fn test_signed_wrong_key() {
869 let (sk, _pk) = test_keypair();
870 let (_sk2, pk2) = test_keypair();
871 let kr = test_keyring(pk2); let token = make_signed_token(&make_payload("pro", future_ts()), &sk);
873 assert_eq!(
874 decode_tier_at_with_mode(&token, now(), EnforcementMode::SignedPreferred, &kr),
875 None
876 );
877 }
878
879 #[test]
880 fn test_signed_tampered_payload() {
881 let (sk, pk) = test_keypair();
882 let kr = test_keyring(pk);
883 let token = make_signed_token(&make_payload("pro", future_ts()), &sk);
884 let mut chars: Vec<char> = token.chars().collect();
886 chars[0] = if chars[0] == 'a' { 'b' } else { 'a' };
887 let tampered: String = chars.into_iter().collect();
888 assert_eq!(
889 decode_tier_at_with_mode(&tampered, now(), EnforcementMode::SignedPreferred, &kr),
890 None
891 );
892 }
893
894 #[test]
895 fn test_signed_tampered_signature() {
896 use base64::Engine;
897 let (sk, pk) = test_keypair();
898 let kr = test_keyring(pk);
899 let token = make_signed_token(&make_payload("pro", future_ts()), &sk);
900 let (payload_part, _sig_part) = token.split_once('.').unwrap();
901 let bad_sig = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([0u8; 64]);
903 let tampered = format!("{payload_part}.{bad_sig}");
904 assert_eq!(
905 decode_tier_at_with_mode(&tampered, now(), EnforcementMode::SignedPreferred, &kr),
906 None
907 );
908 }
909
910 #[test]
913 fn test_signed_wrong_iss() {
914 let (sk, pk) = test_keypair();
915 let kr = test_keyring(pk);
916 let payload = format!(
917 r#"{{"iss":"evil.com","aud":"tirith-cli","kid":"k1","tier":"pro","exp":{}}}"#,
918 future_ts()
919 );
920 let token = make_signed_token(&payload, &sk);
921 assert_eq!(
922 decode_tier_at_with_mode(&token, now(), EnforcementMode::SignedPreferred, &kr),
923 None
924 );
925 }
926
927 #[test]
928 fn test_signed_wrong_aud() {
929 let (sk, pk) = test_keypair();
930 let kr = test_keyring(pk);
931 let payload = format!(
932 r#"{{"iss":"tirith.dev","aud":"wrong-aud","kid":"k1","tier":"pro","exp":{}}}"#,
933 future_ts()
934 );
935 let token = make_signed_token(&payload, &sk);
936 assert_eq!(
937 decode_tier_at_with_mode(&token, now(), EnforcementMode::SignedPreferred, &kr),
938 None
939 );
940 }
941
942 #[test]
943 fn test_signed_expired() {
944 let (sk, pk) = test_keypair();
945 let kr = test_keyring(pk);
946 let token = make_signed_token(&make_payload("pro", past_ts()), &sk);
947 assert_eq!(
948 decode_tier_at_with_mode(&token, now(), EnforcementMode::SignedPreferred, &kr),
949 None
950 );
951 }
952
953 #[test]
954 fn test_signed_missing_exp() {
955 let (sk, pk) = test_keypair();
956 let kr = test_keyring(pk);
957 let payload = r#"{"iss":"tirith.dev","aud":"tirith-cli","kid":"k1","tier":"pro"}"#;
958 let token = make_signed_token(payload, &sk);
959 assert_eq!(
960 decode_tier_at_with_mode(&token, now(), EnforcementMode::SignedPreferred, &kr),
961 None
962 );
963 }
964
965 #[test]
966 fn test_signed_nbf_future() {
967 let (sk, pk) = test_keypair();
968 let kr = test_keyring(pk);
969 let far_future_nbf = future_ts() - 1000; let payload = format!(
971 r#"{{"iss":"tirith.dev","aud":"tirith-cli","kid":"k1","tier":"pro","exp":{},"nbf":{}}}"#,
972 future_ts(),
973 far_future_nbf
974 );
975 let token = make_signed_token(&payload, &sk);
976 assert_eq!(
977 decode_tier_at_with_mode(&token, now(), EnforcementMode::SignedPreferred, &kr),
978 None
979 );
980 }
981
982 #[test]
985 fn test_legacy_works_in_signed_preferred() {
986 let key = make_key("pro", "2099-12-31");
987 assert_eq!(
988 decode_tier_at_with_mode(&key, now(), EnforcementMode::SignedPreferred, KEYRING),
989 Some(Tier::Pro)
990 );
991 }
992
993 #[test]
994 fn test_legacy_rejected_in_signed_only() {
995 let key = make_key("pro", "2099-12-31");
996 assert_eq!(
997 decode_tier_at_with_mode(&key, now(), EnforcementMode::SignedOnly, KEYRING),
998 None
999 );
1000 }
1001
1002 #[test]
1005 fn test_signed_license_info_full() {
1006 let (sk, pk) = test_keypair();
1007 let kr = test_keyring(pk);
1008 let payload = make_full_payload("team", future_ts(), "org-acme-123", "okta", 50);
1009 let token = make_signed_token(&payload, &sk);
1010 let info =
1011 decode_license_info_at_with_mode(&token, now(), EnforcementMode::SignedPreferred, &kr)
1012 .unwrap();
1013 assert_eq!(info.tier, Tier::Team);
1014 assert_eq!(info.org_id.as_deref(), Some("org-acme-123"));
1015 assert_eq!(info.sso_provider.as_deref(), Some("okta"));
1016 assert_eq!(info.seat_count, Some(50));
1017 }
1018
1019 #[test]
1020 fn test_signed_license_info_expired() {
1021 let (sk, pk) = test_keypair();
1022 let kr = test_keyring(pk);
1023 let payload = make_full_payload("team", past_ts(), "org-123", "okta", 50);
1024 let token = make_signed_token(&payload, &sk);
1025 assert!(decode_license_info_at_with_mode(
1026 &token,
1027 now(),
1028 EnforcementMode::SignedPreferred,
1029 &kr,
1030 )
1031 .is_none());
1032 }
1033
1034 #[test]
1037 fn test_kid_correct() {
1038 let (sk, pk) = test_keypair();
1039 let kr = test_keyring(pk);
1040 let token = make_signed_token(&make_payload("pro", future_ts()), &sk);
1041 assert_eq!(
1042 decode_tier_at_with_mode(&token, now(), EnforcementMode::SignedPreferred, &kr),
1043 Some(Tier::Pro)
1044 );
1045 }
1046
1047 #[test]
1048 fn test_kid_mismatch() {
1049 let (sk, pk) = test_keypair();
1050 let kr = test_keyring(pk);
1051 let payload = format!(
1053 r#"{{"iss":"tirith.dev","aud":"tirith-cli","kid":"k99","tier":"pro","exp":{}}}"#,
1054 future_ts()
1055 );
1056 let token = make_signed_token(&payload, &sk);
1057 assert_eq!(
1058 decode_tier_at_with_mode(&token, now(), EnforcementMode::SignedPreferred, &kr),
1059 None
1060 );
1061 }
1062
1063 #[test]
1064 fn test_no_kid_tries_all() {
1065 let (sk, pk) = test_keypair();
1066 let kr = test_keyring(pk);
1067 let payload = format!(
1069 r#"{{"iss":"tirith.dev","aud":"tirith-cli","tier":"pro","exp":{}}}"#,
1070 future_ts()
1071 );
1072 let token = make_signed_token(&payload, &sk);
1073 assert_eq!(
1074 decode_tier_at_with_mode(&token, now(), EnforcementMode::SignedPreferred, &kr),
1075 Some(Tier::Pro)
1076 );
1077 }
1078
1079 #[test]
1082 fn test_parser_empty_segment_left() {
1083 let (_, pk) = test_keypair();
1084 let kr = test_keyring(pk);
1085 assert_eq!(
1086 decode_tier_at_with_mode(".sig", now(), EnforcementMode::SignedPreferred, &kr),
1087 None
1088 );
1089 }
1090
1091 #[test]
1092 fn test_parser_empty_segment_right() {
1093 let (_, pk) = test_keypair();
1094 let kr = test_keyring(pk);
1095 assert_eq!(
1096 decode_tier_at_with_mode("payload.", now(), EnforcementMode::SignedPreferred, &kr),
1097 None
1098 );
1099 }
1100
1101 #[test]
1102 fn test_parser_extra_segments() {
1103 let (_, pk) = test_keypair();
1104 let kr = test_keyring(pk);
1105 assert_eq!(
1106 decode_tier_at_with_mode("a.b.c", now(), EnforcementMode::SignedPreferred, &kr),
1107 None
1108 );
1109 }
1110
1111 #[test]
1112 fn test_parser_oversized_token() {
1113 let (sk, pk) = test_keypair();
1114 let kr = test_keyring(pk);
1115 let token = make_signed_token(&make_payload("pro", future_ts()), &sk);
1117 let oversized = format!("{token}{}", "A".repeat(MAX_TOKEN_LEN));
1118 assert_eq!(
1119 decode_tier_at_with_mode(&oversized, now(), EnforcementMode::SignedPreferred, &kr),
1120 None
1121 );
1122 }
1123
1124 #[test]
1125 fn test_parser_bad_nbf_type() {
1126 let (sk, pk) = test_keypair();
1127 let kr = test_keyring(pk);
1128 let payload = format!(
1130 r#"{{"iss":"tirith.dev","aud":"tirith-cli","kid":"k1","tier":"pro","exp":{},"nbf":"not-a-number"}}"#,
1131 future_ts()
1132 );
1133 let token = make_signed_token(&payload, &sk);
1134 assert_eq!(
1135 decode_tier_at_with_mode(&token, now(), EnforcementMode::SignedPreferred, &kr),
1136 None
1137 );
1138 }
1139
1140 #[test]
1141 fn test_parser_whitespace_only() {
1142 let (_, pk) = test_keypair();
1143 let kr = test_keyring(pk);
1144 assert_eq!(
1145 decode_tier_at_with_mode(" ", now(), EnforcementMode::SignedPreferred, &kr),
1146 None
1147 );
1148 }
1149
1150 #[test]
1151 fn test_parser_exp_exact_boundary() {
1152 let (sk, pk) = test_keypair();
1153 let kr = test_keyring(pk);
1154 let ts = now().timestamp();
1156 let token = make_signed_token(&make_payload("pro", ts), &sk);
1157 assert_eq!(
1158 decode_tier_at_with_mode(&token, now(), EnforcementMode::SignedPreferred, &kr),
1159 None
1160 );
1161 }
1162
1163 #[test]
1164 fn test_parser_nbf_exact_boundary() {
1165 let (sk, pk) = test_keypair();
1166 let kr = test_keyring(pk);
1167 let ts = now().timestamp();
1169 let payload = format!(
1170 r#"{{"iss":"tirith.dev","aud":"tirith-cli","kid":"k1","tier":"pro","exp":{},"nbf":{}}}"#,
1171 future_ts(),
1172 ts
1173 );
1174 let token = make_signed_token(&payload, &sk);
1175 assert_eq!(
1176 decode_tier_at_with_mode(&token, now(), EnforcementMode::SignedPreferred, &kr),
1177 Some(Tier::Pro)
1178 );
1179 }
1180
1181 #[test]
1182 fn test_parser_malformed_base64url() {
1183 let (_, pk) = test_keypair();
1184 let kr = test_keyring(pk);
1185 assert_eq!(
1186 decode_tier_at_with_mode(
1187 "not!valid!b64.also!not!valid",
1188 now(),
1189 EnforcementMode::SignedPreferred,
1190 &kr
1191 ),
1192 None
1193 );
1194 }
1195
1196 #[test]
1197 fn test_parser_padded_base64url_structural() {
1198 use base64::Engine;
1200 let payload = r#"{"iss":"tirith.dev","aud":"tirith-cli","tier":"pro","exp":9999999999}"#;
1201 let payload_b64 = base64::engine::general_purpose::URL_SAFE.encode(payload.as_bytes());
1202 let fake_sig_b64 = base64::engine::general_purpose::URL_SAFE.encode([0u8; 64]);
1203 let token = format!("{payload_b64}.{fake_sig_b64}");
1204 assert!(token.contains('='));
1206
1207 let _guard = crate::TEST_ENV_LOCK
1209 .lock()
1210 .unwrap_or_else(|e| e.into_inner());
1211 unsafe { std::env::set_var("TIRITH_LICENSE", &token) };
1212 let status = key_format_status();
1213 unsafe { std::env::remove_var("TIRITH_LICENSE") };
1214 assert_eq!(
1215 status,
1216 KeyFormatStatus::SignedStructural,
1217 "Padded base64url token should be recognized as SignedStructural"
1218 );
1219 }
1220
1221 #[test]
1224 fn test_enforcement_legacy_accepts_unsigned() {
1225 let key = make_key("pro", "2099-12-31");
1226 assert_eq!(
1227 decode_tier_at_with_mode(&key, now(), EnforcementMode::Legacy, KEYRING),
1228 Some(Tier::Pro)
1229 );
1230 }
1231
1232 #[test]
1233 fn test_enforcement_signed_only_rejects_unsigned() {
1234 let key = make_key("pro", "2099-12-31");
1235 assert_eq!(
1236 decode_tier_at_with_mode(&key, now(), EnforcementMode::SignedOnly, KEYRING),
1237 None
1238 );
1239 }
1240
1241 #[test]
1244 #[allow(clippy::const_is_empty)]
1245 fn test_keyring_non_empty() {
1246 #[allow(clippy::const_is_empty)]
1248 let not_empty = !KEYRING.is_empty();
1249 assert!(not_empty);
1250 }
1251
1252 #[test]
1253 fn test_keyring_no_duplicate_kids() {
1254 let mut kids: Vec<&str> = KEYRING.iter().map(|e| e.kid).collect();
1255 kids.sort();
1256 kids.dedup();
1257 assert_eq!(kids.len(), KEYRING.len(), "Duplicate kid values in KEYRING");
1258 }
1259
1260 #[test]
1261 fn test_keyring_all_keys_valid() {
1262 for entry in KEYRING {
1263 assert!(
1264 ed25519_dalek::VerifyingKey::from_bytes(&entry.key).is_ok(),
1265 "Invalid public key for kid {}",
1266 entry.kid
1267 );
1268 }
1269 }
1270
1271 #[test]
1274 #[ignore] fn enforcement_mode_matches_release_tag() {
1276 let tag = std::env::var("RELEASE_TAG").expect("RELEASE_TAG env var not set");
1277 let mode = match ENFORCEMENT_MODE {
1278 EnforcementMode::Legacy => "Legacy",
1279 EnforcementMode::SignedPreferred => "SignedPreferred",
1280 EnforcementMode::SignedOnly => "SignedOnly",
1281 };
1282
1283 let version = tag
1284 .strip_prefix('v')
1285 .unwrap_or_else(|| panic!("RELEASE_TAG must start with 'v', got: {tag}"));
1286 let parts: Vec<&str> = version.split('.').collect();
1287 assert!(
1288 parts.len() >= 2,
1289 "RELEASE_TAG must be semver (vX.Y.Z), got: {tag}"
1290 );
1291 let major: u32 = parts[0]
1292 .parse()
1293 .unwrap_or_else(|_| panic!("Invalid major version in {tag}"));
1294 let minor: u32 = parts[1]
1295 .parse()
1296 .unwrap_or_else(|_| panic!("Invalid minor version in {tag}"));
1297
1298 if major > 0 || minor >= 3 {
1299 assert_eq!(
1300 mode, "SignedOnly",
1301 "Release {tag} (>= v0.3) requires SignedOnly, found {mode}"
1302 );
1303 } else if minor == 2 {
1304 assert!(
1305 mode == "SignedPreferred" || mode == "SignedOnly",
1306 "Release {tag} (v0.2.x) requires SignedPreferred+, found {mode}"
1307 );
1308 } else if minor <= 1 {
1309 assert!(
1311 mode == "Legacy" || mode == "SignedPreferred",
1312 "Release {tag} (v0.1.x) should use Legacy or SignedPreferred, found {mode}"
1313 );
1314 }
1315 }
1316
1317 #[test]
1320 fn test_key_revocation_after_removal() {
1321 let (sk, pk) = test_keypair();
1323 let kr_with_key = test_keyring(pk);
1324 let token = make_signed_token(&make_payload("pro", future_ts()), &sk);
1325
1326 assert_eq!(
1328 decode_tier_at_with_mode(
1329 &token,
1330 now(),
1331 EnforcementMode::SignedPreferred,
1332 &kr_with_key
1333 ),
1334 Some(Tier::Pro)
1335 );
1336
1337 let kr_empty: Vec<KeyEntry> = vec![];
1339 assert_eq!(
1340 decode_tier_at_with_mode(&token, now(), EnforcementMode::SignedPreferred, &kr_empty),
1341 None,
1342 "Token must be rejected after signing key is removed from keyring"
1343 );
1344 }
1345
1346 #[test]
1349 fn test_multi_key_kid_directed_lookup() {
1350 let (sk1, pk1) = test_keypair();
1351 let (sk2, pk2) = test_keypair();
1352 let kr = vec![
1353 KeyEntry {
1354 kid: "k1",
1355 key: pk1,
1356 },
1357 KeyEntry {
1358 kid: "k2",
1359 key: pk2,
1360 },
1361 ];
1362
1363 let payload = format!(
1365 r#"{{"iss":"tirith.dev","aud":"tirith-cli","kid":"k2","tier":"team","exp":{}}}"#,
1366 future_ts()
1367 );
1368 let token = make_signed_token(&payload, &sk2);
1369 assert_eq!(
1370 decode_tier_at_with_mode(&token, now(), EnforcementMode::SignedPreferred, &kr),
1371 Some(Tier::Team)
1372 );
1373
1374 let token1 = make_signed_token(&make_payload("pro", future_ts()), &sk1);
1376 assert_eq!(
1377 decode_tier_at_with_mode(&token1, now(), EnforcementMode::SignedPreferred, &kr),
1378 Some(Tier::Pro)
1379 );
1380
1381 let wrong_kid_payload = format!(
1383 r#"{{"iss":"tirith.dev","aud":"tirith-cli","kid":"k2","tier":"pro","exp":{}}}"#,
1384 future_ts()
1385 );
1386 let wrong_kid_token = make_signed_token(&wrong_kid_payload, &sk1);
1387 assert_eq!(
1388 decode_tier_at_with_mode(
1389 &wrong_kid_token,
1390 now(),
1391 EnforcementMode::SignedPreferred,
1392 &kr
1393 ),
1394 None,
1395 "Token signed with k1 but kid=k2 must be rejected"
1396 );
1397 }
1398
1399 #[test]
1402 fn test_signed_missing_iss() {
1403 let (sk, pk) = test_keypair();
1404 let kr = test_keyring(pk);
1405 let payload = format!(
1406 r#"{{"aud":"tirith-cli","kid":"k1","tier":"pro","exp":{}}}"#,
1407 future_ts()
1408 );
1409 let token = make_signed_token(&payload, &sk);
1410 assert_eq!(
1411 decode_tier_at_with_mode(&token, now(), EnforcementMode::SignedPreferred, &kr),
1412 None,
1413 "Missing iss claim must be rejected"
1414 );
1415 }
1416
1417 #[test]
1418 fn test_signed_missing_aud() {
1419 let (sk, pk) = test_keypair();
1420 let kr = test_keyring(pk);
1421 let payload = format!(
1422 r#"{{"iss":"tirith.dev","kid":"k1","tier":"pro","exp":{}}}"#,
1423 future_ts()
1424 );
1425 let token = make_signed_token(&payload, &sk);
1426 assert_eq!(
1427 decode_tier_at_with_mode(&token, now(), EnforcementMode::SignedPreferred, &kr),
1428 None,
1429 "Missing aud claim must be rejected"
1430 );
1431 }
1432
1433 #[test]
1434 fn test_signed_exp_as_string_rejected() {
1435 let (sk, pk) = test_keypair();
1436 let kr = test_keyring(pk);
1437 let payload =
1439 r#"{"iss":"tirith.dev","aud":"tirith-cli","kid":"k1","tier":"pro","exp":"2099-12-31"}"#;
1440 let token = make_signed_token(payload, &sk);
1441 assert_eq!(
1442 decode_tier_at_with_mode(&token, now(), EnforcementMode::SignedPreferred, &kr),
1443 None,
1444 "Signed token with exp as string must be rejected"
1445 );
1446 }
1447
1448 #[test]
1449 fn test_empty_string_token() {
1450 let (_, pk) = test_keypair();
1451 let kr = test_keyring(pk);
1452 assert_eq!(
1453 decode_tier_at_with_mode("", now(), EnforcementMode::SignedPreferred, &kr),
1454 None
1455 );
1456 }
1457
1458 #[test]
1461 fn test_legacy_mode_accepts_signed() {
1462 let (sk, pk) = test_keypair();
1463 let kr = test_keyring(pk);
1464 let token = make_signed_token(&make_payload("pro", future_ts()), &sk);
1465 assert_eq!(
1466 decode_tier_at_with_mode(&token, now(), EnforcementMode::Legacy, &kr),
1467 Some(Tier::Pro),
1468 "Legacy mode should accept valid signed tokens"
1469 );
1470 }
1471
1472 #[test]
1473 fn test_signed_only_accepts_signed() {
1474 let (sk, pk) = test_keypair();
1475 let kr = test_keyring(pk);
1476 let token = make_signed_token(&make_payload("pro", future_ts()), &sk);
1477 assert_eq!(
1478 decode_tier_at_with_mode(&token, now(), EnforcementMode::SignedOnly, &kr),
1479 Some(Tier::Pro),
1480 "SignedOnly mode should accept valid signed tokens"
1481 );
1482 }
1483}