feat(gating): auto-update Base+ + features[] override + dev-override (Rust) #307

Closed
maximus wants to merge 1 commit from issue-301-rust-entitlements into issue-300-multi-profile-gate
3 changed files with 299 additions and 68 deletions
Showing only changes of commit 17833cf942 - Show all commits

View file

@ -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 = []

View file

@ -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<bool, String> {
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));
}
}

View file

@ -221,51 +221,117 @@ pub fn get_edition(app: tauri::AppHandle) -> Result<String, String> {
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<String> {
#[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<String>) {
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<String>) {
// 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<String> = 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>) -> 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");
}
}
}