diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 868f926..57cb0d1 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -66,3 +66,13 @@ hmac = "0.12" ed25519-dalek = { version = "2", features = ["pkcs8", "rand_core"] } # HTTP mock server for balance_commands fetch_price tests (Issue #155). mockito = "1.6" + +[features] +# Dev-only escape hatch: when enabled, the SR_DEV_EDITION env var forces the +# resolved edition (free|base|premium) so the three license tiers can be tested +# without real license files. MUST stay out of `default` and of any release +# feature set — gating this on debug_assertions instead would let a custom +# release build honor the env var and become a Premium backdoor (CWE-489). +# Usage: cargo test --features dev-override, or `tauri dev` with +# `-- --features dev-override`. +dev-override = [] diff --git a/src-tauri/src/commands/entitlements.rs b/src-tauri/src/commands/entitlements.rs index 3183553..3067cd4 100644 --- a/src-tauri/src/commands/entitlements.rs +++ b/src-tauri/src/commands/entitlements.rs @@ -1,8 +1,15 @@ // Centralized feature → tier mapping for license entitlements. // -// This module is the single source of truth for which features are gated by which tier. -// To change what is gated where, modify FEATURE_TIERS only — never sprinkle edition checks -// throughout the codebase. +// This module is the single source of truth for which features are gated by which tier +// on the Rust side. To change what is gated where, modify FEATURE_TIERS only — never +// sprinkle edition checks throughout the codebase. +// +// Since the tier-gating work (#297-#301), UI-level gates (budget, adjustments, +// reports-advanced, multi-profile, balance) live in the TS matrix +// `src/shared/entitlements.ts` (soft-paywall, UI-only enforcement). This table +// only keeps the features actually checked through `check_entitlement` — the +// JWT `features[]` array is a namespace shared with that TS layer, so keys are +// kebab-case on both sides. /// Editions, ordered from least to most privileged. pub const EDITION_FREE: &str = "free"; @@ -10,17 +17,11 @@ pub const EDITION_BASE: &str = "base"; pub const EDITION_PREMIUM: &str = "premium"; /// Maps feature name → list of editions allowed to use it. -/// A feature absent from this list is denied for all editions. -const FEATURE_TIERS: &[(&str, &[&str])] = &[ - // auto-update is temporarily open to FREE until the license server (issue #49) - // is live. Re-gate to [BASE, PREMIUM] once paid activation works end-to-end. - ("auto-update", &[EDITION_FREE, EDITION_BASE, EDITION_PREMIUM]), - ("web-sync", &[EDITION_PREMIUM]), - ("cloud-backup", &[EDITION_PREMIUM]), - ("advanced-reports", &[EDITION_PREMIUM]), -]; +/// A feature absent from this list is denied for all editions, unless the +/// license carries it in its signed `features[]` override (see [`is_entitled`]). +const FEATURE_TIERS: &[(&str, &[&str])] = &[("auto-update", &[EDITION_BASE, EDITION_PREMIUM])]; -/// Pure check: does `edition` grant access to `feature`? +/// Pure check: does `edition` grant access to `feature` via the static matrix? pub fn is_feature_allowed(feature: &str, edition: &str) -> bool { FEATURE_TIERS .iter() @@ -29,10 +30,28 @@ pub fn is_feature_allowed(feature: &str, edition: &str) -> bool { .unwrap_or(false) } +/// Static matrix check OR signed per-license `features[]` override. +/// +/// Fail-closed in Free (CWE-863): a `license.key` copied onto another machine +/// resolves to "free" through the machine-binding path, which already drops the +/// signed features (see `license_commands::current_entitlements`). As defense +/// in depth we also refuse the override here whenever the edition is free, so +/// signed features can never rescue a downgraded license. +pub fn is_entitled(feature: &str, edition: &str, features: &[String]) -> bool { + if edition == EDITION_FREE { + return is_feature_allowed(feature, edition); + } + is_feature_allowed(feature, edition) || features.iter().any(|f| f == feature) +} + +/// Tauri command: is `feature` available right now? Edition AND signed +/// per-license feature overrides are resolved through the same machine-binding +/// path (`license_commands::current_entitlements`), then combined by +/// [`is_entitled`]. #[tauri::command] pub fn check_entitlement(app: tauri::AppHandle, feature: String) -> Result { - let edition = crate::commands::license_commands::current_edition(&app); - Ok(is_feature_allowed(&feature, &edition)) + let (edition, features) = crate::commands::license_commands::current_entitlements(&app); + Ok(is_entitled(&feature, &edition, &features)) } #[cfg(test)] @@ -40,9 +59,8 @@ mod tests { use super::*; #[test] - fn free_allows_auto_update_temporarily() { - // Temporary: auto-update is open to FREE until the license server is live. - assert!(is_feature_allowed("auto-update", EDITION_FREE)); + fn free_denied_auto_update() { + assert!(!is_feature_allowed("auto-update", EDITION_FREE)); } #[test] @@ -51,20 +69,43 @@ mod tests { } #[test] - fn premium_unlocks_everything() { + fn premium_unlocks_auto_update() { assert!(is_feature_allowed("auto-update", EDITION_PREMIUM)); - assert!(is_feature_allowed("web-sync", EDITION_PREMIUM)); - assert!(is_feature_allowed("cloud-backup", EDITION_PREMIUM)); - } - - #[test] - fn base_does_not_unlock_premium_features() { - assert!(!is_feature_allowed("web-sync", EDITION_BASE)); - assert!(!is_feature_allowed("cloud-backup", EDITION_BASE)); } #[test] fn unknown_feature_denied() { assert!(!is_feature_allowed("nonexistent", EDITION_PREMIUM)); } + + #[test] + fn matrix_grants_without_override() { + assert!(is_entitled("auto-update", EDITION_BASE, &[])); + assert!(is_entitled("auto-update", EDITION_PREMIUM, &[])); + } + + #[test] + fn unlisted_feature_denied_without_override() { + // "balance" lives in the TS matrix only — absent from FEATURE_TIERS, + // so the static path denies it for every edition. + assert!(!is_entitled("balance", EDITION_BASE, &[])); + assert!(!is_entitled("balance", EDITION_PREMIUM, &[])); + } + + #[test] + fn override_grants_unlisted_feature_for_paid_editions() { + let features = vec!["balance".to_string()]; + assert!(is_entitled("balance", EDITION_BASE, &features)); + assert!(is_entitled("balance", EDITION_PREMIUM, &features)); + } + + #[test] + fn override_ignored_in_free() { + // CWE-863: signed features[] must never rescue a license downgraded to + // free (copied key / machine mismatch) — not even for a feature that a + // paid edition would get from the static matrix. + let features = vec!["balance".to_string(), "auto-update".to_string()]; + assert!(!is_entitled("balance", EDITION_FREE, &features)); + assert!(!is_entitled("auto-update", EDITION_FREE, &features)); + } } diff --git a/src-tauri/src/commands/license_commands.rs b/src-tauri/src/commands/license_commands.rs index ae7f5a2..7d18a56 100644 --- a/src-tauri/src/commands/license_commands.rs +++ b/src-tauri/src/commands/license_commands.rs @@ -221,51 +221,117 @@ pub fn get_edition(app: tauri::AppHandle) -> Result { Ok(current_edition(&app)) } -/// Internal helper used by `entitlements::check_entitlement`. Never returns an error — any -/// failure resolves to "free" so feature gates fail closed. -/// -/// Priority: Premium (via Compte Maximus with active subscription) > Base (offline license) > Free. -pub(crate) fn current_edition(app: &tauri::AppHandle) -> String { - // Check Compte Maximus subscription first — Premium overrides Base - if let Some(edition) = check_account_edition(app) { - if edition == EDITION_PREMIUM { - return edition; - } - } - - let Ok(path) = license_path(app) else { - return EDITION_FREE.to_string(); - }; - if !path.exists() { - return EDITION_FREE.to_string(); - } - let Ok(key) = fs::read_to_string(&path) else { - return EDITION_FREE.to_string(); - }; - let Ok(decoding_key) = embedded_decoding_key() else { - return EDITION_FREE.to_string(); - }; - let Ok(info) = validate_with_key(&key, &decoding_key) else { - return EDITION_FREE.to_string(); - }; - - // If an activation token exists, it must match the local machine. A missing token is - // accepted (graceful pre-activation). - if let Ok(activation_path) = activation_path(app) { - if activation_path.exists() { - let Ok(token) = fs::read_to_string(&activation_path) else { - return EDITION_FREE.to_string(); - }; - let Ok(local_id) = machine_id_internal() else { - return EDITION_FREE.to_string(); - }; - if validate_activation_with_key(&token, &local_id, &decoding_key).is_err() { - return EDITION_FREE.to_string(); +/// Dev-only edition override, compiled in ONLY under the `dev-override` Cargo +/// feature (off by default and absent from any release feature set). Gating on +/// `debug_assertions` instead would be CWE-489: a custom release build could +/// flip it on and the env var would become a Premium backdoor. When compiled +/// in, `SR_DEV_EDITION` forces the resolved edition (free|base|premium) so the +/// three tiers can be tested without real licenses; unrecognized values are +/// ignored and resolution falls through to the normal path. +fn dev_override_edition() -> Option { + #[cfg(feature = "dev-override")] + { + if let Ok(edition) = std::env::var("SR_DEV_EDITION") { + if edition == EDITION_FREE || edition == EDITION_BASE || edition == EDITION_PREMIUM { + return Some(edition); } } } + None +} - info.edition +/// Pure resolution of `(edition, signed features)` from license material. +/// +/// Single choke point for the machine-binding rule: every downgrade path +/// returns `("free", [])`, so the signed `features[]` of a copied license can +/// never be honored once the edition is downgraded (CWE-863). Separated from +/// the fs/AppHandle plumbing so tests can exercise it with in-memory keys. +fn resolve_license_entitlements( + license_key: &str, + activation_token: Option<&str>, + local_machine_id: &str, + decoding_key: &DecodingKey, +) -> (String, Vec) { + let Ok(info) = validate_with_key(license_key, decoding_key) else { + return (EDITION_FREE.to_string(), Vec::new()); + }; + // If an activation token exists, it must match the local machine. A missing + // token is accepted (graceful pre-activation state). + if let Some(token) = activation_token { + if validate_activation_with_key(token, local_machine_id, decoding_key).is_err() { + return (EDITION_FREE.to_string(), Vec::new()); + } + } + (info.edition, info.features) +} + +/// Internal helper used by `entitlements::check_entitlement`. Resolves the +/// effective edition AND the signed per-license feature overrides through the +/// SAME machine-binding path. Never returns an error — any failure resolves to +/// `("free", [])` so feature gates fail closed, features included (CWE-863: a +/// copied `license.key` must not keep its signed `features[]` once downgraded). +/// +/// Priority: dev override (dev-override builds only) > Premium (via Compte +/// Maximus with active subscription) > Base (offline license) > Free. +pub(crate) fn current_entitlements(app: &tauri::AppHandle) -> (String, Vec) { + // Dev-only tier testing — compiled out of normal builds (see the helper). + if let Some(edition) = dev_override_edition() { + return (edition, Vec::new()); + } + + // Check Compte Maximus subscription first — Premium overrides Base. This + // path never reads the license JWT, so it carries no signed features. + if let Some(edition) = check_account_edition(app) { + if edition == EDITION_PREMIUM { + return (edition, Vec::new()); + } + } + + let free = || (EDITION_FREE.to_string(), Vec::new()); + + let Ok(path) = license_path(app) else { + return free(); + }; + if !path.exists() { + return free(); + } + let Ok(key) = fs::read_to_string(&path) else { + return free(); + }; + let Ok(decoding_key) = embedded_decoding_key() else { + return free(); + }; + + // Read the activation token when present. An unreadable token or machine + // id resolves to free, matching the strict posture of `current_edition` + // before this refactor. + let mut activation_token: Option = None; + let mut local_machine_id = String::new(); + if let Ok(act_path) = activation_path(app) { + if act_path.exists() { + let Ok(token) = fs::read_to_string(&act_path) else { + return free(); + }; + let Ok(local_id) = machine_id_internal() else { + return free(); + }; + activation_token = Some(token); + local_machine_id = local_id; + } + } + + resolve_license_entitlements( + &key, + activation_token.as_deref(), + &local_machine_id, + &decoding_key, + ) +} + +/// Edition-only view of [`current_entitlements`], used by `get_edition` and any +/// caller that does not need the feature overrides. +pub(crate) fn current_edition(app: &tauri::AppHandle) -> String { + current_entitlements(app).0 } /// Read the HMAC-verified account cache to check for an active Premium @@ -677,4 +743,118 @@ mod tests { // Sanity check that the production PEM constant is well-formed. assert!(embedded_decoding_key().is_ok()); } + + // === Entitlements resolution (edition + signed features, machine binding) ================= + + fn base_license_with_features(enc: &EncodingKey, features: Vec) -> String { + let claims = LicenseClaims { + sub: "user@example.com".to_string(), + iss: "lacompagniemaximus.com".to_string(), + iat: now(), + exp: now() + 86400, + edition: EDITION_BASE.to_string(), + features, + machine_limit: 3, + }; + let jwt = make_token(enc, &claims); + format!("{}{}", KEY_PREFIX_BASE, jwt) + } + + fn activation_for(enc: &EncodingKey, machine_id: &str) -> String { + let claims = ActivationClaims { + sub: "license-id".to_string(), + iat: now(), + exp: now() + 86400, + machine_id: machine_id.to_string(), + }; + make_token(enc, &claims) + } + + #[test] + fn machine_match_keeps_edition_and_features() { + let (enc, dec) = default_keys(); + let key = base_license_with_features(&enc, vec!["balance".to_string()]); + let token = activation_for(&enc, "machine-A"); + let (edition, features) = + resolve_license_entitlements(&key, Some(&token), "machine-A", &dec); + assert_eq!(edition, EDITION_BASE); + assert_eq!(features, vec!["balance".to_string()]); + } + + #[test] + fn machine_mismatch_downgrades_to_free_and_drops_features() { + // CWE-863: a copied license.key + activation.token still carries its + // signed features[] — they must vanish together with the downgrade. + let (enc, dec) = default_keys(); + let key = base_license_with_features(&enc, vec!["balance".to_string()]); + let token = activation_for(&enc, "machine-A"); + let (edition, features) = + resolve_license_entitlements(&key, Some(&token), "machine-B", &dec); + assert_eq!(edition, EDITION_FREE); + assert!( + features.is_empty(), + "signed features must not survive the machine-binding downgrade" + ); + } + + #[test] + fn missing_activation_token_keeps_edition_and_features() { + // Graceful pre-activation state: no token yet, the license still counts. + let (enc, dec) = default_keys(); + let key = base_license_with_features(&enc, vec!["balance".to_string()]); + let (edition, features) = resolve_license_entitlements(&key, None, "machine-A", &dec); + assert_eq!(edition, EDITION_BASE); + assert_eq!(features, vec!["balance".to_string()]); + } + + #[test] + fn invalid_license_resolves_free_without_features() { + let (_enc, dec) = default_keys(); + let (edition, features) = + resolve_license_entitlements("SR-BASE-not.a.jwt", None, "machine-A", &dec); + assert_eq!(edition, EDITION_FREE); + assert!(features.is_empty()); + } + + // === Dev override ========================================================================= + + /// The dev override must be dead code in normal builds: even with + /// SR_DEV_EDITION set, nothing reads it when the `dev-override` Cargo + /// feature is off (CWE-489 — a release binary must never honor the env + /// var). Env-var note: under this feature-off build NO code path reads + /// SR_DEV_EDITION, so setting it here cannot race with parallel tests. + #[cfg(not(feature = "dev-override"))] + #[test] + fn sr_dev_edition_has_no_effect_when_feature_off() { + std::env::set_var("SR_DEV_EDITION", "premium"); + assert_eq!(dev_override_edition(), None); + std::env::remove_var("SR_DEV_EDITION"); + } + + // Companion coverage for `cargo test --features dev-override` (not part of + // the normal CI run). SR_DEV_EDITION is process-global and cargo test runs + // tests on parallel threads, so every env manipulation serializes on a lock. + #[cfg(feature = "dev-override")] + mod dev_override_on { + use super::super::*; + use std::sync::Mutex; + + static ENV_LOCK: Mutex<()> = Mutex::new(()); + + #[test] + fn sr_dev_edition_forces_edition() { + let _guard = ENV_LOCK.lock().unwrap(); + std::env::set_var("SR_DEV_EDITION", "premium"); + assert_eq!(dev_override_edition(), Some("premium".to_string())); + std::env::remove_var("SR_DEV_EDITION"); + } + + #[test] + fn sr_dev_edition_unknown_value_ignored() { + let _guard = ENV_LOCK.lock().unwrap(); + std::env::set_var("SR_DEV_EDITION", "enterprise"); + assert_eq!(dev_override_edition(), None); + std::env::remove_var("SR_DEV_EDITION"); + } + } }