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