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// ─── Enforcement mode ───────────────────────────────────────────────
41
42/// Controls whether unsigned (legacy) tokens are accepted.
43///
44/// - `Legacy`: both signed and unsigned accepted (development/testing)
45/// - `SignedPreferred`: both accepted, but `tirith doctor` warns on unsigned (v0.2.x transition)
46/// - `SignedOnly`: unsigned tokens rejected → Community (v0.3.0+ paid release)
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48#[allow(dead_code)] // All variants used in tests; Legacy/SignedOnly used in future releases
49enum EnforcementMode {
50    Legacy,
51    SignedPreferred,
52    SignedOnly,
53}
54
55const ENFORCEMENT_MODE: EnforcementMode = EnforcementMode::SignedOnly;
56
57// ─── Keyring (Ed25519 public keys) ─────────────────────────────────
58
59struct KeyEntry {
60    kid: &'static str,
61    key: [u8; 32],
62}
63
64// To rotate keys: generate a new Ed25519 keypair offline, add the public key
65// here as a new KeyEntry with the next kid ("k2", etc.), and store the private
66// key in your secret manager. See docs/threat-model.md for details.
67const 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
84// Compile-time: keyring must never be empty.
85const _: () = assert!(!KEYRING.is_empty());
86
87/// Maximum token length before any parsing (DoS resistance).
88const MAX_TOKEN_LEN: usize = 8192;
89
90// ─── Shared helpers ─────────────────────────────────────────────────
91
92/// Extract tier from a parsed JSON payload.
93fn 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
104/// Extract full LicenseInfo from a parsed JSON payload.
105fn 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    // For legacy tokens, exp is ISO 8601 string. For signed, it's a Unix timestamp.
126    // Store as string either way for display purposes.
127    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
142// ─── Legacy (unsigned) token decoding ───────────────────────────────
143
144/// Decode a legacy unsigned base64 JSON payload, checking expiry against `now`.
145fn decode_legacy_payload(key: &str, now: DateTime<Utc>) -> Option<serde_json::Value> {
146    use base64::Engine;
147
148    let trimmed = key.trim();
149
150    // Size gate (same as signed path — DoS resistance)
151    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    // Check expiry (ISO 8601 date string, inclusive — valid on exp date)
163    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            // Missing exp: reject — all tokens must have an expiration date
180            return None;
181        }
182    }
183
184    Some(payload)
185}
186
187/// Decode tier from a legacy unsigned key.
188fn 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    // Unsigned tokens capped at Pro — Team/Enterprise require signed tokens
192    Some(match tier {
193        Tier::Team | Tier::Enterprise => Tier::Pro,
194        other => other,
195    })
196}
197
198/// Decode full license info from a legacy unsigned key.
199fn 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    // Unsigned tokens capped at Pro
203    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
210// ─── Signed token decoding (Ed25519) ────────────────────────────────
211
212/// Decode and verify a signed token: `base64url(payload_json).base64url(ed25519_sig)`.
213///
214/// Returns the parsed payload JSON on success, None on any failure.
215fn 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    // Trim for parity with legacy path
224    let token = token.trim();
225
226    // Size gate (before any parsing)
227    if token.len() > MAX_TOKEN_LEN {
228        return None;
229    }
230
231    // Split into exactly two segments
232    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    // Decode payload bytes (base64url, with or without padding)
238    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    // Decode signature bytes
244    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    // Parse payload to get kid for key lookup
251    let payload: serde_json::Value = serde_json::from_slice(&payload_bytes).ok()?;
252
253    // Key lookup: kid present → find in keyring; absent → try all
254    let kid = payload.get("kid").and_then(|v| v.as_str());
255    let verified = if let Some(kid_val) = kid {
256        // Specific key requested
257        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        // No kid — try all keys, first success wins
264        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    // Validate issuer
272    if payload.get("iss").and_then(|v| v.as_str()) != Some("tirith.dev") {
273        return None;
274    }
275
276    // Validate audience
277    if payload.get("aud").and_then(|v| v.as_str()) != Some("tirith-cli") {
278        return None;
279    }
280
281    // Validate exp (required for signed tokens, must be i64 Unix timestamp)
282    let exp = match payload.get("exp") {
283        Some(v) => v.as_i64()?, // Wrong type (not i64) → None
284        None => return None,
285    };
286    // Exclusive: expired when now >= exp
287    if now.timestamp() >= exp {
288        return None;
289    }
290
291    // Validate nbf (optional, but fail-closed on wrong type)
292    if let Some(nbf_val) = payload.get("nbf") {
293        let nbf = nbf_val.as_i64()?; // Present but wrong type → None (fail-closed)
294                                     // Inclusive: valid when now >= nbf
295        if now.timestamp() < nbf {
296            return None;
297        }
298    }
299
300    Some(payload)
301}
302
303// ─── Dispatch (mode-aware) ──────────────────────────────────────────
304
305/// Core dispatch: try signed first (if `.` present), then legacy based on mode.
306///
307/// Note: dispatch is one-way routing — a dot means signed format. In
308/// `SignedPreferred` mode, if a dot-containing token fails signed verification,
309/// we do NOT fall back to legacy (a dot is never valid legacy base64).
310fn 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        // Dot present → signed format (no fallback to legacy — dot is invalid in standard base64)
318        let payload = decode_signed_token(key, keyring, now)?;
319        return tier_from_payload(&payload);
320    }
321
322    // No dot → legacy format
323    if mode == EnforcementMode::SignedOnly {
324        return None;
325    }
326    decode_tier_legacy(key, now)
327}
328
329/// Core dispatch for license info.
330fn 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
348// ─── Clock-injectable wrappers ──────────────────────────────────────
349
350/// Decode tier at a specific time (uses compile-time ENFORCEMENT_MODE and KEYRING).
351fn decode_tier_at(key: &str, now: DateTime<Utc>) -> Option<Tier> {
352    decode_tier_at_with_mode(key, now, ENFORCEMENT_MODE, KEYRING)
353}
354
355/// Decode license info at a specific time.
356fn 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
360// ─── Public API (unchanged signatures) ──────────────────────────────
361
362/// Determine the current license tier.
363///
364/// Resolution order:
365/// 1. `TIRITH_LICENSE` env var (raw key)
366/// 2. `~/.config/tirith/license.key` file
367/// 3. Fallback: `Tier::Pro`
368///
369/// Tier verification uses Ed25519-signed tokens. Legacy unsigned tokens are
370/// accepted during the transition period (v0.2.x) but will be rejected in
371/// v0.3.0+. Runtime feature gating has been collapsed to an always-on Pro
372/// baseline; tier parsing remains for compatibility with existing tokens.
373///
374/// Invalid, expired, or missing keys silently fall back to Pro
375/// (no panic, no error exit).
376pub 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
388/// Get extended license information including org_id and SSO provider.
389pub 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// ─── Key format diagnostics (for tirith doctor) ─────────────────────
402
403/// Reports the structural format of the installed license key.
404/// Does NOT verify signatures or validate claims — this is a
405/// lightweight structural check for `tirith doctor` diagnostics.
406#[derive(Debug, Clone, Copy, PartialEq, Eq)]
407pub enum KeyFormatStatus {
408    NoKey,
409    /// No `.` separator, valid base64, decodes to JSON with "tier" field.
410    LegacyUnsigned,
411    /// No `.` separator, not valid base64 or missing "tier" field.
412    LegacyInvalid,
413    /// Has exactly one `.` with two non-empty base64url segments.
414    /// Signature/claims may still be invalid — this is structural only.
415    SignedStructural,
416    /// Has `.` but structure is wrong (empty segments, multiple dots).
417    Malformed,
418}
419
420/// Check the structural format of the installed license key.
421///
422/// NOTE: `SignedStructural` only means the token has the right shape
423/// (two non-empty base64url segments separated by a dot). The signature,
424/// claims (iss, aud, exp), and key validity are NOT verified here.
425/// Use `current_tier()` for full verification.
426pub 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                // Signed format: exactly one dot, both segments non-empty and valid base64url
434                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                // Legacy: must be valid base64 AND decode to valid JSON with "tier" field
450                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
472// ─── Internal helpers ───────────────────────────────────────────────
473
474/// Read the raw license key string from env or file.
475fn read_license_key() -> Option<String> {
476    // 1. Environment variable
477    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    // 2. Config file
485    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
505/// Path to the license key file.
506pub fn license_key_path() -> Option<PathBuf> {
507    let config = crate::policy::config_dir()?;
508    Some(config.join("license.key"))
509}
510
511/// Validate that a token has the signed token structure: exactly one `.` separator
512/// with both parts being valid base64url.
513pub 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
534/// Decode a signed token using the compile-time KEYRING and ENFORCEMENT_MODE.
535/// Returns the LicenseInfo on success (valid signature, claims, and not expired).
536pub 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/// Refresh the license token from a remote policy server.
541///
542/// POSTs to `{server_url}/api/license/refresh` with Bearer auth and returns
543/// the raw token string on success.
544#[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// ─── Tests ──────────────────────────────────────────────────────────
579
580#[cfg(test)]
581mod tests {
582    use super::*;
583    use ed25519_dalek::SigningKey;
584    use rand_core::OsRng;
585
586    // ── Test helpers ────────────────────────────────────────────────
587
588    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        // 2099-01-01 00:00:00 UTC
622        4070908800
623    }
624
625    fn past_ts() -> i64 {
626        // 2020-01-01 00:00:00 UTC
627        1577836800
628    }
629
630    fn now() -> DateTime<Utc> {
631        Utc::now()
632    }
633
634    // ── Legacy helpers (for existing tests) ─────────────────────────
635
636    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    // ── Original tests (unchanged behavior) ─────────────────────────
657
658    #[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        // Legacy unsigned tokens capped at Pro (M1 fix)
670        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        // Legacy unsigned tokens capped at Pro (M1 fix)
680        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        // Legacy tokens without exp are now rejected (L4 fix)
699        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        // Legacy unsigned tokens capped at Pro (M1 fix)
776        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    // ── Signed token: happy path ────────────────────────────────────
820
821    #[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    // ── Signed token: signature verification ────────────────────────
866
867    #[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); // Wrong key in keyring
872        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        // Tamper: change first char of payload segment
885        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        // Replace signature with garbage
902        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    // ── Signed token: claims validation ─────────────────────────────
911
912    #[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; // Still in the far future relative to now
970        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    // ── Signed token: legacy compat ─────────────────────────────────
983
984    #[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    // ── Signed token: license info ──────────────────────────────────
1003
1004    #[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    // ── Key rotation (kid) ──────────────────────────────────────────
1035
1036    #[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        // Use kid "k99" which is not in the keyring
1052        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        // No kid field
1068        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    // ── Parser hardening ────────────────────────────────────────────
1080
1081    #[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        // Create a valid token then pad it beyond MAX_TOKEN_LEN
1116        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        // nbf is a string instead of i64 → fail-closed
1129        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        // exp == now → should be expired (exclusive)
1155        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        // nbf == now → should be valid (inclusive)
1168        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        // Padded base64url should still be recognized as SignedStructural by key_format_status
1199        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        // Contains padding ('='), but should still parse structurally
1205        assert!(token.contains('='));
1206
1207        // Thread-safe env-var mutation
1208        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    // ── Enforcement mode ────────────────────────────────────────────
1222
1223    #[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    // ── Keyring invariants ──────────────────────────────────────────
1242
1243    #[test]
1244    #[allow(clippy::const_is_empty)]
1245    fn test_keyring_non_empty() {
1246        // Also enforced at compile time (line 76), but belt-and-suspenders
1247        #[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    // ── CI release guard ────────────────────────────────────────────
1272
1273    #[test]
1274    #[ignore] // Only run explicitly during release CI
1275    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            // v0.1.x: Legacy or SignedPreferred acceptable (transition period)
1310            assert!(
1311                mode == "Legacy" || mode == "SignedPreferred",
1312                "Release {tag} (v0.1.x) should use Legacy or SignedPreferred, found {mode}"
1313            );
1314        }
1315    }
1316
1317    // ── Key revocation ───────────────────────────────────────────────
1318
1319    #[test]
1320    fn test_key_revocation_after_removal() {
1321        // A token signed with key "k1" must be rejected when k1 is removed from keyring
1322        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        // Valid with key present
1327        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        // Revoked: empty keyring (key removed)
1338        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    // ── Multi-key keyring ────────────────────────────────────────────
1347
1348    #[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        // Token signed with k2, kid="k2" → should find k2 directly
1364        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        // Token signed with k1, kid="k1" → should find k1
1375        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        // Token signed with k1 but kid="k2" → wrong key, must reject
1382        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    // ── Missing claims ───────────────────────────────────────────────
1400
1401    #[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        // exp as ISO string instead of Unix timestamp → must reject
1438        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    // ── Enforcement mode coverage ────────────────────────────────────
1459
1460    #[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}