Skip to main content

tirith_core/
license.rs

1use std::path::PathBuf;
2
3use chrono::{DateTime, Utc};
4
5/// Product tier levels.
6#[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/// Extended license information parsed from a license token.
16#[derive(Debug, Clone, Default)]
17pub struct LicenseInfo {
18    pub tier: Tier,
19    /// Organization ID (typically present on Team+ SSO-provisioned keys).
20    pub org_id: Option<String>,
21    /// SSO provider used for provisioning (e.g., "okta", "azure-ad").
22    pub sso_provider: Option<String>,
23    /// Expiry date (ISO 8601 for legacy, Unix timestamp for signed).
24    pub expires: Option<String>,
25    /// Seat count for the organization (Team+).
26    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/// Controls whether unsigned (legacy) tokens are accepted.
41///
42/// - `Legacy`: both signed and unsigned accepted (development/testing)
43/// - `SignedPreferred`: both accepted, but `tirith doctor` warns on unsigned (v0.2.x transition)
44/// - `SignedOnly`: unsigned tokens rejected in official v0.3.0+ builds
45#[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
60// Key rotation: generate a new Ed25519 keypair offline, append it here with
61// the next kid, and stash the private key in your secret manager. See
62// docs/threat-model.md for details.
63const 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
82/// Maximum token length accepted before any parsing — DoS guard.
83const MAX_TOKEN_LEN: usize = 8192;
84
85/// Extract tier from a parsed JSON payload.
86fn 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
97/// Extract full LicenseInfo from a parsed JSON payload.
98fn 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    // Legacy tokens store `exp` as ISO 8601, signed tokens as Unix timestamp.
119    // Normalize to string for display.
120    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
135/// Decode a legacy unsigned base64 JSON payload, checking expiry against `now`.
136fn decode_legacy_payload(key: &str, now: DateTime<Utc>) -> Option<serde_json::Value> {
137    use base64::Engine;
138
139    let trimmed = key.trim();
140
141    // Same DoS size gate as the signed path.
142    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    // Legacy `exp` is an ISO 8601 date; comparison is inclusive (valid on the exp date).
154    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            // All tokens must carry an expiration date.
171            return None;
172        }
173    }
174
175    Some(payload)
176}
177
178/// Decode tier from a legacy unsigned key.
179fn 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    // Unsigned tokens are capped at Pro; Team/Enterprise require signed tokens.
183    Some(match tier {
184        Tier::Team | Tier::Enterprise => Tier::Pro,
185        other => other,
186    })
187}
188
189/// Decode full license info from a legacy unsigned key.
190fn 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    // Unsigned tokens are capped at Pro.
194    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
201/// Decode and verify a signed token: `base64url(payload_json).base64url(ed25519_sig)`.
202///
203/// Returns the parsed payload JSON on success, None on any failure.
204fn 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    // Accept base64url with or without padding.
224    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    // With kid: look up a specific key. Without kid: try every key; first hit wins.
238    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    // exp is required on signed tokens and must be an i64 Unix timestamp.
262    // Wrong type → None (fail-closed), and comparison is exclusive.
263    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    // nbf is optional; if present, it must be i64 (fail-closed on wrong type)
272    // and the comparison is inclusive.
273    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
283/// Core dispatch: try signed first (if `.` present), then legacy based on mode.
284///
285/// Note: dispatch is one-way routing — a dot means signed format. In
286/// `SignedPreferred` mode, if a dot-containing token fails signed verification,
287/// we do NOT fall back to legacy (a dot is never valid legacy base64).
288fn 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
305/// Core dispatch for license info.
306fn 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
324/// Decode tier at a specific time (uses compile-time ENFORCEMENT_MODE and KEYRING).
325fn decode_tier_at(key: &str, now: DateTime<Utc>) -> Option<Tier> {
326    decode_tier_at_with_mode(key, now, ENFORCEMENT_MODE, KEYRING)
327}
328
329/// Decode license info at a specific time.
330fn 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
334/// Determine the current license tier.
335///
336/// Resolution order:
337/// 1. `TIRITH_LICENSE` env var (raw key)
338/// 2. `~/.config/tirith/license.key` file
339/// 3. Fallback: `Tier::Pro`
340///
341/// Tier verification uses Ed25519-signed tokens. Legacy unsigned tokens were
342/// accepted during the v0.2.x transition period but are rejected in v0.3.0+.
343/// Runtime feature gating has been collapsed to an always-on Pro baseline;
344/// tier parsing remains for compatibility with existing tokens.
345///
346/// Invalid, expired, or missing keys silently fall back to Pro
347/// (no panic, no error exit).
348pub 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
360/// Get extended license information including org_id and SSO provider.
361pub 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/// Reports the structural format of the installed license key.
374///
375/// Lightweight structural check only — does NOT verify signatures or
376/// validate claims. Intended for `tirith doctor` diagnostics.
377#[derive(Debug, Clone, Copy, PartialEq, Eq)]
378pub enum KeyFormatStatus {
379    NoKey,
380    /// No `.` separator, valid base64, decodes to JSON with "tier" field.
381    LegacyUnsigned,
382    /// No `.` separator, not valid base64 or missing "tier" field.
383    LegacyInvalid,
384    /// Has exactly one `.` with two non-empty base64url segments.
385    /// Signature/claims may still be invalid — this is structural only.
386    SignedStructural,
387    /// Has `.` but structure is wrong (empty segments, multiple dots).
388    Malformed,
389}
390
391/// Check the structural format of the installed license key.
392///
393/// NOTE: `SignedStructural` only means the token has the right shape
394/// (two non-empty base64url segments separated by a dot). The signature,
395/// claims (iss, aud, exp), and key validity are NOT verified here.
396/// Use `current_tier()` for full verification.
397pub 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                // Signed format: exactly one dot, both segments non-empty and valid base64url.
405                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                // Legacy: must be valid base64 AND decode to JSON with a "tier" field.
421                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
443/// Read the raw license key string from env or config file.
444fn 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
472/// Path to the license key file.
473pub fn license_key_path() -> Option<PathBuf> {
474    let config = crate::policy::config_dir()?;
475    Some(config.join("license.key"))
476}
477
478/// Validate that a token has the signed token structure: exactly one `.` separator
479/// with both parts being valid base64url.
480pub 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
501/// Decode a signed token using the compile-time KEYRING and ENFORCEMENT_MODE.
502/// Returns the LicenseInfo on success (valid signature, claims, and not expired).
503pub 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/// Refresh the license token from a remote policy server.
508///
509/// POSTs to `{server_url}/api/license/refresh` with Bearer auth and returns
510/// the raw token string on success.
511#[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 // 2099-01-01 00:00:00 UTC
585    }
586
587    fn past_ts() -> i64 {
588        1577836800 // 2020-01-01 00:00:00 UTC
589    }
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        // Legacy unsigned tokens are capped at Pro.
627        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        // Legacy unsigned tokens are capped at Pro.
637        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        // Tokens without `exp` are rejected.
656        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        // Legacy unsigned tokens are capped at Pro.
733        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); // Wrong key in keyring.
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            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        // Flip the first char of the payload segment.
838        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        // Replace the signature with zero bytes.
855        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        // Still in the far future relative to now.
921        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        // "k99" is not in the test keyring.
998        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        // No kid field — decoder should try every key in the ring.
1014        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        // Pad a valid token past MAX_TOKEN_LEN to exercise the DoS gate.
1060        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        // nbf as a string (not i64) must fail closed.
1073        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        // exp comparison is exclusive, so exp == now means expired.
1099        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        // nbf comparison is inclusive, so nbf == now is valid.
1112        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        // Padded base64url must still parse as SignedStructural.
1143        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        // Compile-time assert above covers this too, but test it anyway.
1185        #[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    /// Release CI runs this explicitly to make sure the compiled enforcement
1210    /// mode matches the release tag's semver expectations.
1211    #[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            // v0.1.x is the transition period: Legacy or SignedPreferred are OK.
1248            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        // Removing a key from the keyring must invalidate any token it signed.
1258        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        // kid="k2" signed with sk2 → resolved directly via kid lookup.
1296        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        // kid="k2" but actually signed with sk1 → verification fails, rejected.
1313        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        // Signed tokens require `exp` as an i64 Unix timestamp, not an ISO string.
1367        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}