// License validation, storage and reading for the Base/Premium editions. // // Architecture: // - License key = "SR-BASE-" or "SR-PREMIUM-", JWT signed Ed25519 by the server // - Activation token = separate JWT, also signed by the server, that binds the license to a // machine ON THE SERVER (machine_id claim, machine limit). Issued by `/v1/licenses/activate` // (Issue #49); the market-price route requires it. // - Both files live in app_data_dir/ — license.key and activation.token // - The edition comes from the license key alone: get_edition() returns "free" unless the // license JWT is valid (signature + exp). The activation token plays no part (#347, D12). // Until #347 a token that did not validate for this machine dropped the edition to free: // that protected nothing (a key copied without its token kept its edition) and locked out // users whose 2-year token had expired or came from a restored backup (#360). The token now // only drives the "Activated" state of the License card and the price calls, which the // server verifies (ADR 0017, amendment for #347). // - The machine is named to the server by an identifier DERIVED from the operating system's // machine id (HMAC-SHA256 under a fixed application key), never by that id itself (#350, // ADR 0020). A token or a server row from before the derivation carries the raw id: both // forms are recognized locally, only the derived one is ever sent by an activation. // - A key the license server revoked resolves to free (#365). The server is asked at app // start and after a key is entered (#373), one check at a time, at most once per 24 h and // only when a key is stored (`check_license_revocation`); its answer is kept in // license.check, next to the key, as a digest of the key, the time of the last attempt and // the verdict. Fail-open: an unreachable server changes nothing. // - The stored key can be removed (#370, `remove_license`): license.key, then the activation // token, after this machine's slot is freed on the server when asked. license.check stays, // so a revoked key removed then entered again stays revoked. // // CWE-613: every license JWT MUST carry an `exp` claim. We reject licenses without it. use hmac::{Hmac, Mac}; use jsonwebtoken::errors::ErrorKind; use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation}; use serde::{Deserialize, Serialize}; use sha2::Sha256; use std::fs; use std::path::{Path, PathBuf}; use tauri::Manager; use super::entitlements::{EDITION_BASE, EDITION_FREE, EDITION_PREMIUM}; // Ed25519 public key for license verification. // // Production key generated 2026-04-25 alongside the maximus-api scaffold. // The matching private key lives ONLY on the license server as env var // ED25519_PRIVATE_KEY_PEM (see maximus-api/.env on Coolify). const PUBLIC_KEY_PEM: &str = "-----BEGIN PUBLIC KEY-----\n\ MCowBQYDK2VwAyEAmUTcl7xjt01uc2FhPgvP0at0I/Pie0JLh73AApNy+o8=\n\ -----END PUBLIC KEY-----\n"; const LICENSE_FILE: &str = "license.key"; const ACTIVATION_FILE: &str = "activation.token"; /// State of the online revocation check for the stored key (#365). const CHECK_FILE: &str = "license.check"; /// Shortest time between two automatic attempts of the revocation check (#365, D1). const CHECK_INTERVAL_SECS: i64 = 86_400; const KEY_PREFIX_BASE: &str = "SR-BASE-"; const KEY_PREFIX_PREMIUM: &str = "SR-PREMIUM-"; /// Decoded license metadata exposed to the frontend. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LicenseInfo { pub edition: String, pub email: String, pub features: Vec, pub machine_limit: u32, pub issued_at: i64, pub expires_at: i64, /// The license server answered "revoked" for this key and nothing lifted it since /// (#365). Read from the state file of the data directory: always `false` out of a /// validation that reads no file. #[serde(default)] pub revoked: bool, } /// Claims embedded in the license JWT (signed by the license server). #[derive(Debug, Clone, Serialize, Deserialize)] struct LicenseClaims { sub: String, // email iss: String, iat: i64, exp: i64, // mandatory — see CWE-613 edition: String, #[serde(default)] features: Vec, machine_limit: u32, } /// Claims embedded in the activation token JWT (server-signed, machine-bound). #[derive(Debug, Clone, Serialize, Deserialize)] struct ActivationClaims { sub: String, // license id or hash iat: i64, exp: i64, machine_id: String, } fn app_data_dir(app: &tauri::AppHandle) -> Result { app.path() .app_data_dir() .map_err(|e| format!("Cannot get app data dir: {}", e)) } fn license_path(app: &tauri::AppHandle) -> Result { Ok(app_data_dir(app)?.join(LICENSE_FILE)) } /// Strip the human-readable prefix and return the bare JWT. fn strip_prefix(key: &str) -> Result<&str, LicenseError> { let trimmed = key.trim(); if let Some(jwt) = trimmed.strip_prefix(KEY_PREFIX_BASE) { return Ok(jwt); } if let Some(jwt) = trimmed.strip_prefix(KEY_PREFIX_PREMIUM) { return Ok(jwt); } Err(LicenseError::InvalidPrefix) } /// Build a `Validation` with `exp` and `iat` mandatory. Assertions are explicit so a future /// config change cannot silently disable expiry checking (CWE-613). fn strict_validation() -> Validation { let mut validation = Validation::new(Algorithm::EdDSA); validation.validate_exp = true; validation.leeway = 0; validation.set_required_spec_claims(&["exp", "iat"]); validation } /// Build the production `DecodingKey` from the embedded PEM constant. fn embedded_decoding_key() -> Result { DecodingKey::from_ed_pem(PUBLIC_KEY_PEM.as_bytes()).map_err(|_| LicenseError::Internal) } /// Name a `jsonwebtoken` failure for the user. An expired key and a key signed by another /// key get their own message; anything else (unreadable JWT, missing claim, unexpected /// algorithm) reads as a malformed key. fn license_error_from_jwt(err: &jsonwebtoken::errors::Error) -> LicenseError { match err.kind() { ErrorKind::ExpiredSignature => LicenseError::LicenseExpired, ErrorKind::InvalidSignature => LicenseError::InvalidSignature, _ => LicenseError::Malformed, } } /// Pure validation: decode the JWT, verify signature with the provided key, ensure the /// edition claim is one we recognize. Returns `LicenseInfo` on success. /// /// Separated from the Tauri command so tests can pass their own key. fn validate_with_key(key: &str, decoding_key: &DecodingKey) -> Result { let jwt = strip_prefix(key)?; let validation = strict_validation(); let data = decode::(jwt, decoding_key, &validation) .map_err(|e| license_error_from_jwt(&e))?; let claims = data.claims; if claims.edition != EDITION_BASE && claims.edition != EDITION_PREMIUM { return Err(LicenseError::UnknownEdition); } Ok(LicenseInfo { edition: claims.edition, email: claims.sub, features: claims.features, machine_limit: claims.machine_limit, issued_at: claims.iat, expires_at: claims.exp, revoked: false, }) } /// Machine identifier an activation token was issued for, once the token is known to be /// signed by the license server and unexpired. fn activation_token_machine_id(token: &str, decoding_key: &DecodingKey) -> Result { let validation = strict_validation(); let data = decode::(token.trim(), decoding_key, &validation) .map_err(|e| format!("Invalid activation token: {}", e))?; Ok(data.claims.machine_id) } /// Validate an activation token against the local machine. The token must be signed by the /// license server, unexpired, and issued for this machine: under its derived identifier, or /// under the raw one for an activation made before the derivation (ADR 0020, D1). fn validate_activation_with_key( token: &str, ids: &MachineIds, decoding_key: &DecodingKey, ) -> Result<(), String> { let machine_id = activation_token_machine_id(token, decoding_key)?; if !ids.names_this_machine(&machine_id) { return Err("Activation token belongs to a different machine".to_string()); } Ok(()) } // === Tauri commands =========================================================================== /// Validate a license key without persisting it. Errors cross the boundary as a serialized /// [`LicenseError`]. #[tauri::command] pub fn validate_license_key(key: String) -> Result { embedded_decoding_key() .and_then(|decoding_key| validate_with_key(&key, &decoding_key)) .map_err(|e| license_error_to_string(&e)) } /// Validate then persist a license key. The activation token (machine binding on the server) /// is stored separately by [`store_activation_token`] once the server has issued one. Errors /// cross the boundary as a serialized [`LicenseError`]. #[tauri::command] pub fn store_license(app: tauri::AppHandle, key: String) -> Result { store_license_impl(&app, &key).map_err(|e| license_error_to_string(&e)) } fn store_license_impl(app: &tauri::AppHandle, key: &str) -> Result { let dir = app_data_dir(app).map_err(|_| LicenseError::Storage)?; let decoding_key = embedded_decoding_key()?; store_license_in(&dir, key, &decoding_key) } /// Validate `key`, then write it to `dir/license.key`. A refused key writes nothing. A /// different key drops the activation token of the previous one. The info returned says /// whether the key is already known as revoked (#365): entering a revoked key again does /// not bring its edition back. Kept apart from the Tauri command, without `AppHandle`, so /// tests run it in a temporary directory. fn store_license_in( dir: &Path, key: &str, decoding_key: &DecodingKey, ) -> Result { let info = validate_with_key(key, decoding_key)?; let key = key.trim(); fs::create_dir_all(dir).map_err(|_| LicenseError::Storage)?; // A token belongs to the key it was issued for, and the client cannot tell which: its // `sub` is the license id, the key carries the email. Kept across a key change, it would // show this machine as activated while the price server authenticates the previous // license (#347 review). The same key entered again keeps its token. let previous = fs::read_to_string(dir.join(LICENSE_FILE)).ok(); if previous.as_deref().map(str::trim) != Some(key) { remove_activation_token_in(dir)?; } fs::write(dir.join(LICENSE_FILE), key).map_err(|_| LicenseError::Storage)?; Ok(LicenseInfo { revoked: is_revoked_in(dir, key), ..info }) } /// Delete `dir/activation.token`. A missing token is not an error; any other failure is /// `Storage`. Shared by the entry of another key, a deactivation that freed this machine's /// slot and the removal of the key (#370); meant for the stale token of #367 next. pub(crate) fn remove_activation_token_in(dir: &Path) -> Result<(), LicenseError> { match fs::remove_file(dir.join(ACTIVATION_FILE)) { Ok(()) => Ok(()), Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), Err(_) => Err(LicenseError::Storage), } } /// Persist a server-issued activation token (machine binding). The token is opaque to the /// caller — it must have been issued for this machine's derived identifier to be stored. /// Errors cross the boundary as a serialized [`LicenseError`]. #[tauri::command] pub fn store_activation_token(app: tauri::AppHandle, token: String) -> Result<(), String> { persist_activation_token(&app, &token).map_err(|e| license_error_to_string(&e)) } /// [`store_activation_token_in`] for the app data directory and this machine. fn persist_activation_token(app: &tauri::AppHandle, token: &str) -> Result<(), LicenseError> { let ids = local_machine_ids()?; let decoding_key = embedded_decoding_key()?; let dir = app_data_dir(app).map_err(|_| LicenseError::Storage)?; store_activation_token_in(&dir, token, &ids, &decoding_key) } /// Validate then write the activation token the server has just issued. It must carry the /// identifier this version sends, the derived one: the raw one is only accepted from a token /// already on disk (ADR 0020). A token that fails validation came from the server, hence /// `InvalidResponse`; a failed write is `Storage`. Kept apart from `AppHandle` so tests run /// it in a temporary directory. fn store_activation_token_in( dir: &Path, token: &str, ids: &MachineIds, decoding_key: &DecodingKey, ) -> Result<(), LicenseError> { match activation_token_machine_id(token, decoding_key) { Ok(machine_id) if machine_id == ids.derived => {} _ => return Err(LicenseError::InvalidResponse), } fs::create_dir_all(dir).map_err(|_| LicenseError::Storage)?; fs::write(dir.join(ACTIVATION_FILE), token.trim()).map_err(|_| LicenseError::Storage) } /// Read and validate the stored license. Returns `None` when no license is stored or when the /// stored key no longer validates (the edition then resolves to free). Errors cross the /// boundary as a serialized [`LicenseError`]. #[tauri::command] pub fn read_license(app: tauri::AppHandle) -> Result, String> { let dir = app_data_dir(&app).map_err(|_| license_error_to_string(&LicenseError::Storage))?; let Ok(decoding_key) = embedded_decoding_key() else { return Ok(None); }; read_license_in(&dir, &decoding_key).map_err(|e| license_error_to_string(&e)) } /// Read `dir/license.key`: `None` when the file is missing or the key does not validate, /// `Storage` when the file exists but cannot be read. The info says whether the license /// server is known to have revoked the key (#365). fn read_license_in(dir: &Path, decoding_key: &DecodingKey) -> Result, LicenseError> { let path = dir.join(LICENSE_FILE); if !path.exists() { return Ok(None); } let key = fs::read_to_string(&path).map_err(|_| LicenseError::Storage)?; Ok(validate_with_key(&key, decoding_key) .ok() .map(|info| LicenseInfo { revoked: is_revoked_in(dir, &key), ..info })) } /// Returns the active edition (`"free"`, `"base"`, or `"premium"`) for use by feature gates. /// /// Returns "free" when no license is stored, or when the stored license JWT is invalid or /// expired. The activation token plays no part (#347, D12): it binds the key to a machine on /// the server, and only the price calls require it. #[tauri::command] pub fn get_edition(app: tauri::AppHandle) -> Result { Ok(current_edition(&app)) } /// 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 } /// Pure resolution of `(edition, signed features)` from the license key alone. /// /// Single choke point of the local gating: a valid, unexpired key gives its edition and its /// signed `features[]`; any validation failure gives `("free", [])`, features included, so a /// key that no longer validates never keeps its overrides (CWE-863). The activation token is /// deliberately not an input (#347, D12): it used to downgrade a valid key whenever it did not /// validate for this machine, which protected nothing (a key copied without its token kept its /// edition) and locked out users whose token had expired or came from a restored backup. fn resolve_license_entitlements(license_key: &str, decoding_key: &DecodingKey) -> (String, Vec) { match validate_with_key(license_key, decoding_key) { Ok(info) => (info.edition, info.features), Err(_) => (EDITION_FREE.to_string(), Vec::new()), } } /// Resolve the entitlements from the files of `dir` (the app data directory in production). /// Reads `license.key` and the revocation state of that key: `activation.token` is never /// read here, whatever it contains. A missing or unreadable key file resolves to /// `("free", [])`, and so does a key the license server revoked (#365), signed features /// included. Kept apart from `AppHandle` so tests exercise the decision on real files in a /// temporary directory. fn entitlements_from_dir(dir: &Path, decoding_key: &DecodingKey) -> (String, Vec) { let Ok(key) = fs::read_to_string(dir.join(LICENSE_FILE)) else { return (EDITION_FREE.to_string(), Vec::new()); }; if is_revoked_in(dir, &key) { return (EDITION_FREE.to_string(), Vec::new()); } resolve_license_entitlements(&key, decoding_key) } /// Internal helper used by `entitlements::check_entitlement`. Resolves the effective edition /// AND the signed per-license feature overrides together. Never returns an error — any failure /// resolves to `("free", [])` so feature gates fail closed, features included (CWE-863). /// /// Priority: dev override (dev-override builds only) > Premium (via Compte Maximus with active /// subscription) > the license key (offline), unless the license server revoked it (#365) > /// Free. The activation token is not consulted (#347, D12). 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(dir) = app_data_dir(app) else { return free(); }; let Ok(decoding_key) = embedded_decoding_key() else { return free(); }; entitlements_from_dir(&dir, &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 /// subscription. Legacy unsigned caches (from v0.7.x) and tampered /// payloads return None — Premium features stay locked until the user /// re-authenticates or the next token refresh re-signs the cache. /// /// This is intentional: before HMAC signing, any local process could /// write `{"subscription_status": "active"}` to account.json and /// bypass the paywall. Fail-closed is the correct posture here. fn check_account_edition(app: &tauri::AppHandle) -> Option { let account = super::account_cache::load_verified(app).ok().flatten()?; match account.subscription_status.as_deref() { Some("active") => Some(EDITION_PREMIUM.to_string()), _ => None, } } // === Online revocation check (#365, ADR 0017 amendment) ======================================== /// What the license server last said about a key, and when it was last asked. Stored as /// JSON in `license.check`, next to the key it is about. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] struct LicenseCheck { /// SHA-256 of the key the state is about, in hex. Never the key itself: this file must /// not become a second copy of it. key_sha256: String, /// Unix time of the last attempt to reach the server, successful or not. attempted_at: i64, /// The server answered "revoked" for this key, and no "valid" answer lifted it since. revoked: bool, } /// SHA-256 of the trimmed key, as 64 lowercase hex characters. fn key_digest(key: &str) -> String { use sha2::Digest; Sha256::digest(key.trim().as_bytes()) .iter() .map(|b| format!("{:02x}", b)) .collect() } /// State recorded for `key`, if any. A missing or unreadable file, invalid JSON, or a state /// about another key all read as "no state": the key is not known as revoked, and a check /// is due. A corrupt file must never downgrade a paying user. fn read_license_check(dir: &Path, key: &str) -> Option { let raw = fs::read_to_string(dir.join(CHECK_FILE)).ok()?; let check: LicenseCheck = serde_json::from_str(&raw).ok()?; (check.key_sha256 == key_digest(key)).then_some(check) } fn write_license_check(dir: &Path, check: &LicenseCheck) -> Result<(), LicenseError> { let raw = serde_json::to_string(check).map_err(|_| LicenseError::Storage)?; fs::create_dir_all(dir).map_err(|_| LicenseError::Storage)?; fs::write(dir.join(CHECK_FILE), raw).map_err(|_| LicenseError::Storage) } /// Whether the license server is known to have revoked `key`. fn is_revoked_in(dir: &Path, key: &str) -> bool { read_license_check(dir, key).is_some_and(|check| check.revoked) } /// Record a "revoked" answer for the key a command has just sent (D4): whichever command /// learns it, the state is the same one the check writes. Any other result passes through /// untouched. The key is the one that was sent, never one read again afterwards. fn note_revocation( dir: &Path, license_key: &str, result: Result, now: i64, ) -> Result { if matches!(result, Err(LicenseError::LicenseRevoked)) { // Best effort: the refusal itself still reaches the user. let _ = write_license_check( dir, &LicenseCheck { key_sha256: key_digest(license_key), attempted_at: now, revoked: true, }, ); } result } /// How a revocation check ended. The front writes it to the in-app log. #[derive(Debug, Clone, Copy, PartialEq, Serialize)] #[serde(rename_all = "snake_case")] pub enum RevocationOutcome { /// No key is stored, or the stored key does not validate locally: nothing was sent. NoLicense, /// The last attempt is less than 24 h old: nothing was sent. NotDue, /// The server knows the key as valid. Valid, /// The server answered "revoked". Revoked, /// The server could not be reached, or answered with an error: nothing changed. Unreachable, /// The server answered something the client does not act on (`not_found`, `expired`). Unknown, /// The attempt could not be written, so nothing was sent; or the server's verdict could /// not be, so nothing changed. Storage, } /// Result of [`check_license_revocation`]: how the check ended, and whether the stored key /// is known as revoked afterwards. #[derive(Debug, Clone, PartialEq, Serialize)] pub struct RevocationCheck { pub outcome: RevocationOutcome, pub revoked: bool, } /// What a verify answer says about the key itself. enum VerifyVerdict { Valid, Revoked, Other, } /// Read `valid` and `reason` only: the check must not depend on the machine list, so a /// valid answer lifts a revocation even without `machines`. fn verdict_from_verify(body: &serde_json::Value) -> VerifyVerdict { match body.get("valid").and_then(|v| v.as_bool()) { Some(true) => VerifyVerdict::Valid, Some(false) if body.get("reason").and_then(|v| v.as_str()) == Some("revoked") => { VerifyVerdict::Revoked } _ => VerifyVerdict::Other, } } /// Whether an automatic attempt is due: no state for the key, a last attempt at least 24 h /// old, or one dated in the future (clock moved back). Checked arithmetic: a corrupt /// `attempted_at` must neither panic nor block the check for ever. fn revocation_check_due(state: Option<&LicenseCheck>, now: i64) -> bool { match state { None => true, Some(check) => now .checked_sub(check.attempted_at) .map_or(true, |age| !(0..CHECK_INTERVAL_SECS).contains(&age)), } } /// The revocation check, without `AppHandle` so tests run it in a temporary directory and /// against a mock server (#365). The command runs it through [`check_revocation_in_turn`], /// one check at a time (#373). /// /// - Nothing is sent without a stored key that validates locally, nor when the last attempt /// is less than 24 h old (D1). /// - The attempt is written before the request leaves: a second call, or a failed one, still /// counts, so "at most one attempt per 24 h" holds whatever happens next. /// - Fail-open (D3): only a "valid" answer lifts the state and only a "revoked" answer sets /// it. Any other answer, and any error, leaves it as it was. async fn check_revocation_in( dir: &Path, api_base: &str, decoding_key: &DecodingKey, now: i64, ) -> RevocationCheck { let no_license = RevocationCheck { outcome: RevocationOutcome::NoLicense, revoked: false, }; let Ok(key) = fs::read_to_string(dir.join(LICENSE_FILE)) else { return no_license; }; if validate_with_key(&key, decoding_key).is_err() { return no_license; } let state = read_license_check(dir, &key); let was_revoked = state.as_ref().is_some_and(|check| check.revoked); let unchanged = |outcome| RevocationCheck { outcome, revoked: was_revoked, }; if !revocation_check_due(state.as_ref(), now) { return unchanged(RevocationOutcome::NotDue); } let mut check = LicenseCheck { key_sha256: key_digest(&key), attempted_at: now, revoked: was_revoked, }; if write_license_check(dir, &check).is_err() { return unchanged(RevocationOutcome::Storage); } let outcome = match verify_body(api_base, &key).await { Ok(body) => match verdict_from_verify(&body) { VerifyVerdict::Valid => RevocationOutcome::Valid, VerifyVerdict::Revoked => RevocationOutcome::Revoked, VerifyVerdict::Other => RevocationOutcome::Unknown, }, // The same verdict, should the server ever say it through the error envelope. Err(LicenseError::LicenseRevoked) => RevocationOutcome::Revoked, Err(LicenseError::LicenseNotFound | LicenseError::LicenseExpired) => { RevocationOutcome::Unknown } Err(_) => RevocationOutcome::Unreachable, }; check.revoked = match outcome { RevocationOutcome::Valid => false, RevocationOutcome::Revoked => true, _ => return unchanged(outcome), }; if check.revoked != was_revoked && write_license_check(dir, &check).is_err() { return unchanged(RevocationOutcome::Storage); } RevocationCheck { outcome, revoked: check.revoked, } } /// One revocation check at a time (#373). Since a key entered in session is checked too, two /// checks can overlap: the one at start and the one of a key entered before it ran, or the /// checks of two keys entered in a row. The 24 h guard reads the state then writes the /// attempt, and a verdict rewrites `license.check`, which holds one key: side by side, both /// checks could send, or one could overwrite what the other recorded. The command takes this /// turn over the whole check, request included, so a check that waited reads the state the /// previous one left. static REVOCATION_CHECK_TURN: tauri::async_runtime::Mutex<()> = tauri::async_runtime::Mutex::const_new(()); /// [`check_revocation_in`] once `turn` is free, held until the check is over (#373): the /// command passes [`REVOCATION_CHECK_TURN`]. The time is read when the turn comes, not when the /// check is called: read before the wait, it could date the attempt of a check that ran /// meanwhile in the future, and a check is due then. Kept apart from `AppHandle`, and from the /// static, so tests run checks side by side against a mock server, each test with its own turn. async fn check_revocation_in_turn( turn: &tauri::async_runtime::Mutex<()>, dir: &Path, api_base: &str, decoding_key: &DecodingKey, now: impl FnOnce() -> i64, ) -> RevocationCheck { let _turn = turn.lock().await; check_revocation_in(dir, api_base, decoding_key, now()).await } fn unix_now() -> i64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|elapsed| elapsed.as_secs() as i64) .unwrap_or(0) } // === Machine identifier (#350, ADR 0020) ====================================================== type HmacSha256 = Hmac; /// Fixed, application-specific key of the machine identifier derivation. Not a secret (this /// repository is public): it makes the identifier specific to this application, as /// machine-id(5) asks. NEVER change it, nor the derivation: the license server records every /// activation under an identifier derived with this key, and another key would orphan them /// all. Two reference vectors pin both in the tests. const MACHINE_ID_KEY: &[u8] = b"com.simpl.resultat/license-machine-id/v1"; /// Derive the identifier sent to the license server from the operating system's machine id: /// HMAC-SHA256 under [`MACHINE_ID_KEY`], as 64 lowercase hex characters. The OS id is /// confidential and must not be exposed on the network (machine-id(5)); it cannot be /// recovered from the derived one. /// /// An empty id is refused, never hashed: every machine without a machine id would otherwise /// share one derived identifier, hence one slot of a license. fn derive_machine_id(raw: &str) -> Result { let raw = raw.trim(); if raw.is_empty() { return Err(LicenseError::Internal); } let mut mac = HmacSha256::new_from_slice(MACHINE_ID_KEY).map_err(|_| LicenseError::Internal)?; mac.update(raw.as_bytes()); Ok(mac .finalize() .into_bytes() .iter() .map(|b| format!("{:02x}", b)) .collect()) } /// The two identifiers the license server may know this machine under. No `Debug` on /// purpose: the raw one must not end up in a log. struct MachineIds { /// Derived identifier: the only one this version sends. derived: String, /// Operating system's machine id, which versions up to 0.15.0 sent as is. Never sent /// again: kept to recognize an activation made by one of those versions (ADR 0020, D1). raw: String, } impl MachineIds { /// Both identifiers of the machine whose OS id is `raw`. `Internal` for an empty id. fn from_raw(raw: &str) -> Result { let raw = raw.trim(); Ok(Self { derived: derive_machine_id(raw)?, raw: raw.to_string(), }) } /// Whether `machine_id`, as a server row or a token carries it, names this machine. Both /// forms count (ADR 0020, D3): a row or a token from before the derivation carries the /// raw one, and the server may rehash its rows some day. fn names_this_machine(&self, machine_id: &str) -> bool { machine_id == self.derived || machine_id == self.raw } } /// Operating system's machine id (`/etc/machine-id` on Linux, `MachineGuid` on Windows). /// Stable across reboots; it changes after an OS reinstall or a hardware migration. The /// edition does not depend on it (#347, D12): after such a change the License card shows /// this machine as not activated, and "Activate" binds it again on the server, which the /// price calls require. It never leaves this module as is: see [`derive_machine_id`]. fn raw_machine_id() -> Result { machine_uid::get().map_err(|_| LicenseError::Internal) } /// Identifiers of this machine. `Internal` when the OS id is unreadable or empty. fn local_machine_ids() -> Result { MachineIds::from_raw(&raw_machine_id()?) } // License server API base URL. Overridable via MAXIMUS_API_URL for development. fn api_base_url() -> String { std::env::var("MAXIMUS_API_URL") .unwrap_or_else(|_| "https://api.lacompagniemaximus.com".to_string()) } /// Product sent with every license server call. The `/v1/licenses/*` routes accept it /// as optional and otherwise infer it from the key prefix (maximus-api#48). const PRODUCT: &str = "simpl-resultat"; /// Wait assumed when a 429 carries no usable `retry_after` (the server window is 60 s). const DEFAULT_RETRY_AFTER_S: u64 = 60; /// Typed error of the license commands, serialized as JSON to cross the Tauri boundary — /// same pattern as `FetchPriceError` in `balance_commands.rs`: `{"code":"license_revoked"}`, /// `{"code":"rate_limit","retry_after_s":42}`, `{"code":"server","status":502}`. The front /// parses it with `parseLicenseError` (`licenseService.ts`) and maps each code to a /// `license.errors.*` message. Issue #270 (server-side refusals); #347 (local key-entry /// errors, which reuse `LicenseExpired` and `Storage`). #[derive(Debug, Serialize, Clone, PartialEq)] #[serde(tag = "code", rename_all = "snake_case")] pub enum LicenseError { LicenseNotFound, LicenseRevoked, /// Refused by the server, or the key's own `exp` has passed. LicenseExpired, MachineLimitReached, NoActiveActivation, InvalidInput, /// The key does not start with `SR-BASE-` or `SR-PREMIUM-`. InvalidPrefix, /// The key was not signed by the license server. InvalidSignature, /// The key's JWT cannot be read: bad encoding, missing claim, unexpected algorithm. Malformed, /// The key names an edition this version does not know. UnknownEdition, RateLimit { retry_after_s: u64 }, /// The request could not be sent or timed out. Network, /// Unreadable 2xx body, missing `activation_token`, or a token refused locally. InvalidResponse, /// Any other non-2xx answer, or an unknown code. Server { status: u16 }, NoLicenseStored, /// Reading or writing the license / activation files. Storage, /// Unreadable machine id or invalid embedded public key. Internal, } /// Serialize a `LicenseError` to the stable JSON string returned across the Tauri boundary. fn license_error_to_string(err: &LicenseError) -> String { serde_json::to_string(err).unwrap_or_else(|_| r#"{"code":"internal"}"#.to_string()) } /// Map a non-2xx answer to a `LicenseError` from the `/v1` envelope /// `{ "error": { "code", "message" } }`. Only `error.code` decides: a 404 is /// `license_not_found` or `no_active_activation`, never "missing route". The status is /// used for 429 and for bodies without a known code (e.g. an HTML 502 page). fn error_from_response(status: u16, body: &serde_json::Value) -> LicenseError { let code = body.pointer("/error/code").and_then(|v| v.as_str()); match code { Some("license_not_found") => LicenseError::LicenseNotFound, Some("license_revoked") => LicenseError::LicenseRevoked, Some("license_expired") => LicenseError::LicenseExpired, Some("machine_limit_reached") => LicenseError::MachineLimitReached, Some("no_active_activation") => LicenseError::NoActiveActivation, Some("invalid_input") => LicenseError::InvalidInput, _ if status == 429 || code == Some("rate_limit_exceeded") => { let retry_after_s = body .pointer("/error/retry_after") .and_then(|v| v.as_u64()) .unwrap_or(DEFAULT_RETRY_AFTER_S); LicenseError::RateLimit { retry_after_s } } _ => LicenseError::Server { status }, } } /// POST a JSON body to the license server. The body of a non-2xx answer is read /// leniently (it may be an HTML error page) before the status decides. async fn post_license_api( api_base: &str, path: &str, body: &serde_json::Value, ) -> Result { let resp = reqwest::Client::new() .post(format!("{}{}", api_base, path)) .json(body) .timeout(std::time::Duration::from_secs(15)) .send() .await .map_err(|_| LicenseError::Network)?; let status = resp.status(); if status.is_success() { resp.json::() .await .map_err(|_| LicenseError::InvalidResponse) } else { let body = resp .json::() .await .unwrap_or(serde_json::Value::Null); Err(error_from_response(status.as_u16(), &body)) } } /// Body of an activation request. `machine_name` is omitted, never sent as `null`, when /// the host name is unreadable: the server's schema (`z.string().optional()`) answers /// `400 invalid_input` to a null. fn activate_body(license_key: &str, machine_id: &str, machine_name: Option<&str>) -> serde_json::Value { let mut body = serde_json::json!({ "product": PRODUCT, "license_key": license_key.trim(), "machine_id": machine_id, }); if let Some(name) = machine_name { body["machine_name"] = serde_json::Value::String(name.to_string()); } body } /// Activation call, without `AppHandle` nor disk access so tests can point it at a mock /// server. Returns the activation token issued for `machine_id`. async fn activate_inner( api_base: &str, license_key: &str, machine_id: &str, machine_name: Option<&str>, ) -> Result { let body = activate_body(license_key, machine_id, machine_name); let resp = post_license_api(api_base, "/v1/licenses/activate", &body).await?; resp["activation_token"] .as_str() .map(str::to_string) .ok_or(LicenseError::InvalidResponse) } /// Activation of the machine whose OS id is `raw_machine_id`, as the command performs it: /// the identifier sent is the derived one, never the raw one (ADR 0020), and the token the /// server issues for it is stored. The derivation sits here, next to the call, so that an /// empty id stops before any request. Kept apart from `AppHandle` so tests run it against /// a mock server and a temporary directory. async fn activate_and_store_in( dir: &Path, api_base: &str, license_key: &str, raw_machine_id: &str, machine_name: Option<&str>, decoding_key: &DecodingKey, now: i64, ) -> Result<(), LicenseError> { let ids = MachineIds::from_raw(raw_machine_id)?; let answer = activate_inner(api_base, license_key, &ids.derived, machine_name).await; let token = note_revocation(dir, license_key, answer, now)?; store_activation_token_in(dir, &token, &ids, decoding_key) } /// Deactivation call (frees the slot of `machine_id`). Returns `NoActiveActivation` as /// the server says it; [`deactivate_idempotent`] decides what that means for the user. async fn deactivate_inner( api_base: &str, license_key: &str, machine_id: &str, ) -> Result<(), LicenseError> { let body = serde_json::json!({ "product": PRODUCT, "license_key": license_key.trim(), "machine_id": machine_id, }); post_license_api(api_base, "/v1/licenses/deactivate", &body) .await .map(|_| ()) } /// Verify call, raw answer. The request carries the product and the key, nothing else: it /// is also what the revocation check sends on its own, at app start (#365) and after a key /// is entered (#373). async fn verify_body(api_base: &str, license_key: &str) -> Result { let body = serde_json::json!({ "product": PRODUCT, "license_key": license_key.trim(), }); post_license_api(api_base, "/v1/licenses/verify", &body).await } /// Verify call: the machines activated for the key. async fn verify_inner(api_base: &str, license_key: &str) -> Result, LicenseError> { machines_from_verify(&verify_body(api_base, license_key).await?) } /// Read a verify answer. The server answers 200 in every business case: /// `{valid:false, reason}` for an unknown, revoked or expired key, `{valid:true, /// machines, ...}` otherwise. Before #270 a `valid:false` answer had no `machines` and /// read as an empty list, so a revoked key showed "no machine activated". fn machines_from_verify(body: &serde_json::Value) -> Result, LicenseError> { match body.get("valid").and_then(|v| v.as_bool()) { Some(false) => Err(match body.get("reason").and_then(|v| v.as_str()) { Some("not_found") => LicenseError::LicenseNotFound, Some("revoked") => LicenseError::LicenseRevoked, Some("expired") => LicenseError::LicenseExpired, _ => LicenseError::Server { status: 200 }, }), Some(true) => { let machines = body .get("machines") .and_then(|v| v.as_array()) .ok_or(LicenseError::InvalidResponse)?; Ok(machines .iter() .filter_map(|m| serde_json::from_value::(m.clone()).ok()) .collect()) } None => Err(LicenseError::InvalidResponse), } } /// Deactivation as the command performs it: idempotent, since `no_active_activation` /// means the slot is already free, which is the state the user asked for. Kept apart /// from the Tauri command, without `AppHandle`, so tests run it against a mock server. async fn deactivate_idempotent( api_base: &str, license_key: &str, machine_id: &str, ) -> Result<(), LicenseError> { match deactivate_inner(api_base, license_key, machine_id).await { Err(LicenseError::NoActiveActivation) => Ok(()), other => other, } } /// Stored license key for a server call, `None` when no key is stored. fn read_stored_key(app: &tauri::AppHandle) -> Result, LicenseError> { let path = license_path(app).map_err(|_| LicenseError::Storage)?; if !path.exists() { return Ok(None); } fs::read_to_string(&path) .map(Some) .map_err(|_| LicenseError::Storage) } /// Machine info returned by the license server, plus the local `is_this_machine` flag. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MachineInfo { pub machine_id: String, pub machine_name: Option, pub activated_at: String, pub last_seen_at: String, /// Whether the row names this machine. Set locally by [`flag_this_machine`], never read /// from the server's answer. #[serde(skip_deserializing)] pub is_this_machine: bool, } /// Activation status for display in the UI. It carries no machine identifier: the webview /// gets the flagged machine list instead (ADR 0020). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ActivationStatus { pub is_activated: bool, } /// Flag the rows that name this machine, under either identifier (ADR 0020, D3). `ids` is /// `None` when the OS id is unreadable: no row is flagged then, and the list still loads. fn flag_this_machine(mut machines: Vec, ids: Option<&MachineIds>) -> Vec { for machine in &mut machines { machine.is_this_machine = ids.is_some_and(|ids| ids.names_this_machine(&machine.machine_id)); } machines } /// Machines activated for the key, as the command returns them: the server's list with /// this machine flagged. Kept apart from `AppHandle` so tests run it against a mock server. async fn list_machines_inner( api_base: &str, license_key: &str, ids: Option<&MachineIds>, ) -> Result, LicenseError> { let machines = verify_inner(api_base, license_key).await?; Ok(flag_this_machine(machines, ids)) } /// [`list_machines_inner`] as the command performs it: a "revoked" answer is recorded for /// the key that was sent (#365, D4). async fn list_machines_in( dir: &Path, api_base: &str, license_key: &str, ids: Option<&MachineIds>, now: i64, ) -> Result, LicenseError> { let answer = list_machines_inner(api_base, license_key, ids).await; note_revocation(dir, license_key, answer, now) } /// Deactivation as the command performs it, without `AppHandle`: the idempotent call, a /// "revoked" answer recorded for the key that was sent (#365, D4), then the local token /// dropped if it stood for the freed slot. async fn deactivate_in( dir: &Path, api_base: &str, license_key: &str, machine_id: &str, ids: Option<&MachineIds>, decoding_key: &DecodingKey, now: i64, ) -> Result<(), LicenseError> { let answer = deactivate_idempotent(api_base, license_key, machine_id).await; note_revocation(dir, license_key, answer, now)?; drop_token_of_deactivated_machine_in(dir, machine_id, ids, decoding_key); Ok(()) } /// Activate this machine with the license server. Reads the stored license key, sends /// the derived machine identifier to the API, and stores the returned activation token. /// Errors cross the boundary as a serialized [`LicenseError`]. #[tauri::command] pub async fn activate_machine(app: tauri::AppHandle) -> Result<(), String> { activate_machine_impl(&app) .await .map_err(|e| license_error_to_string(&e)) } async fn activate_machine_impl(app: &tauri::AppHandle) -> Result<(), LicenseError> { let license_key = read_stored_key(app)?.ok_or(LicenseError::NoLicenseStored)?; let os_machine_id = raw_machine_id()?; let machine_name = hostname::get().ok().and_then(|h| h.into_string().ok()); let dir = app_data_dir(app).map_err(|_| LicenseError::Storage)?; let decoding_key = embedded_decoding_key()?; activate_and_store_in( &dir, &api_base_url(), &license_key, &os_machine_id, machine_name.as_deref(), &decoding_key, unix_now(), ) .await } /// Deactivate a machine on the license server, freeing a slot. Idempotent: a machine the /// server no longer counts as active is treated as deactivated. Errors cross the /// boundary as a serialized [`LicenseError`]. #[tauri::command] pub async fn deactivate_machine( app: tauri::AppHandle, machine_id: String, ) -> Result<(), String> { deactivate_machine_impl(&app, &machine_id) .await .map_err(|e| license_error_to_string(&e)) } async fn deactivate_machine_impl(app: &tauri::AppHandle, machine_id: &str) -> Result<(), LicenseError> { let license_key = read_stored_key(app)?.ok_or(LicenseError::NoLicenseStored)?; let dir = app_data_dir(app).map_err(|_| LicenseError::Storage)?; let decoding_key = embedded_decoding_key()?; // Best effort on the OS id: unreadable, the token only goes with a row it was issued for. let ids = local_machine_ids().ok(); deactivate_in( &dir, &api_base_url(), &license_key, machine_id, ids.as_ref(), &decoding_key, unix_now(), ) .await } /// Identifier the stored activation token was issued for, when that token is signed by the /// license server and unexpired. `None` for a missing, unreadable, expired or forged token. fn stored_token_machine_id(dir: &Path, decoding_key: &DecodingKey) -> Option { let token = fs::read_to_string(dir.join(ACTIVATION_FILE)).ok()?; activation_token_machine_id(&token, decoding_key).ok() } /// After the server has freed the slot of `deactivated`, drop the local activation token if /// it stood for that slot (ADR 0020). `ids` is `None` when the OS id is unreadable. Kept /// apart from `AppHandle` so tests run it in a temporary directory. fn drop_token_of_deactivated_machine_in( dir: &Path, deactivated: &str, ids: Option<&MachineIds>, decoding_key: &DecodingKey, ) { let token_id = stored_token_machine_id(dir, decoding_key); let stood_for_it = match ids { // A row of this machine: the token goes with it. One exception: the old raw row is // deactivated while the token stands for the derived one, which stays active. Some(ids) if ids.names_this_machine(deactivated) => { !(deactivated == ids.raw && token_id.as_deref() == Some(ids.derived.as_str())) } // Another machine's row, or an unreadable OS id: only a token issued for that row. _ => token_id.as_deref() == Some(deactivated), }; if stood_for_it { let _ = remove_activation_token_in(dir); } } /// List all machines currently activated for the stored license (empty when no key is /// stored). Errors cross the boundary as a serialized [`LicenseError`]. #[tauri::command] pub async fn list_activated_machines(app: tauri::AppHandle) -> Result, String> { list_activated_machines_impl(&app) .await .map_err(|e| license_error_to_string(&e)) } async fn list_activated_machines_impl(app: &tauri::AppHandle) -> Result, LicenseError> { let Some(license_key) = read_stored_key(app)? else { return Ok(vec![]); }; let dir = app_data_dir(app).map_err(|_| LicenseError::Storage)?; // Best effort on the OS id: unreadable, it leaves every row unflagged. let ids = local_machine_ids().ok(); list_machines_in(&dir, &api_base_url(), &license_key, ids.as_ref(), unix_now()).await } /// Check the local activation status without contacting the server. Strict on purpose: an /// expired token, or one issued for another machine, reads as not activated, so the License /// card offers "Activate" (the price calls need a token the server accepts). A machine whose /// OS id is unreadable cannot be matched to a token and reads as not activated too. The /// edition does not depend on this status (#347, D12). #[tauri::command] pub fn get_activation_status(app: tauri::AppHandle) -> Result { let dir = app_data_dir(&app)?; let is_activated = match (local_machine_ids(), embedded_decoding_key()) { (Ok(ids), Ok(decoding_key)) => activation_status_in(&dir, &ids, &decoding_key), _ => false, }; Ok(ActivationStatus { is_activated }) } /// Whether `dir/activation.token` activates this machine: signed by the license server, /// not expired, and issued for one of this machine's identifiers (the raw one for a token /// from before the derivation, ADR 0020). A missing, unreadable, expired or foreign token /// reads as not activated, which is what brings back "Activate" (#360). Kept apart from /// `AppHandle` so tests run it in a temporary directory. fn activation_status_in(dir: &Path, ids: &MachineIds, decoding_key: &DecodingKey) -> bool { let Ok(token) = fs::read_to_string(dir.join(ACTIVATION_FILE)) else { return false; }; validate_activation_with_key(&token, ids, decoding_key).is_ok() } // === Removal of the stored key (#370) ========================================================== /// Remove the stored license key from this computer (#370). `license.key` goes first, which is /// what stops the revocation check from sending it, then the activation token; `license.check` /// stays. With `release_slot`, this machine's slot is freed on the license server first (D2). /// Errors cross the boundary as a serialized [`LicenseError`]: a release that failed removes /// nothing, and when the server could not be reached or answered badly the front asks whether /// to remove the key anyway, then calls again without `release_slot`. #[tauri::command] pub async fn remove_license(app: tauri::AppHandle, release_slot: bool) -> Result<(), String> { remove_license_impl(&app, release_slot) .await .map_err(|e| license_error_to_string(&e)) } async fn remove_license_impl(app: &tauri::AppHandle, release_slot: bool) -> Result<(), LicenseError> { let dir = app_data_dir(app).map_err(|_| LicenseError::Storage)?; let decoding_key = embedded_decoding_key()?; // Best effort on the OS id: unreadable, no token can stand for this machine, and the // removal stays local (the card reads "Not activated" then, too). let ids = local_machine_ids().ok(); remove_license_in( &dir, &api_base_url(), release_slot, ids.as_ref(), &decoding_key, unix_now(), ) .await } /// The removal, without `AppHandle` so tests run it in a temporary directory and against a /// mock server. /// /// - With `release_slot`, [`release_this_machine_slot`] runs first. Its error is returned as /// is and nothing is removed. /// - `license.key`, then `activation.token`, each through a deletion that tolerates a missing /// file. The first failure stops there with `Storage`: the token never goes while the key /// stays. /// - `license.check` stays. It is indexed by the key's digest, so it weighs on no other key, /// and dropping it would give a revoked key, removed then entered again, its edition back /// until the next check (decision 1 of #370). /// - Without a key nothing is sent, nothing is written, and the call succeeds: a second /// removal is a no-op, apart from a token a first one could not delete. async fn remove_license_in( dir: &Path, api_base: &str, release_slot: bool, ids: Option<&MachineIds>, decoding_key: &DecodingKey, now: i64, ) -> Result<(), LicenseError> { if release_slot { release_this_machine_slot(dir, api_base, ids, decoding_key, now).await?; } match fs::remove_file(dir.join(LICENSE_FILE)) { Ok(()) => {} Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} Err(_) => return Err(LicenseError::Storage), } remove_activation_token_in(dir) } /// Free this machine's slot on the license server before its key is removed (#370, D2). /// /// Nothing is sent without a stored key, for a key known as revoked (the server freed its /// slots when it revoked it), nor without a token that activates this machine /// ([`this_machine_token_id`]): as far as the client knows, there is no slot of this machine /// to free then. The deactivation carries the token's own identifier, the raw one for an /// activation made before #350, and the stored key. /// /// `no_active_activation`, `license_not_found` and `license_revoked` (recorded for the key /// sent, #365 D4) mean the slot is free: the removal goes on. Any other error is returned, /// `network`, `server`, `rate_limit` and `invalid_response` among them. A key file that exists /// but cannot be read is `Storage`: the release was asked and cannot be made. async fn release_this_machine_slot( dir: &Path, api_base: &str, ids: Option<&MachineIds>, decoding_key: &DecodingKey, now: i64, ) -> Result<(), LicenseError> { let key = match fs::read_to_string(dir.join(LICENSE_FILE)) { Ok(key) => key, Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()), Err(_) => return Err(LicenseError::Storage), }; if is_revoked_in(dir, &key) { return Ok(()); } let Some(machine_id) = this_machine_token_id(dir, ids, decoding_key) else { return Ok(()); }; // Not `deactivate_in`: it drops the token as soon as the slot is freed, and a key that // then failed to go would stay without its token. let answer = deactivate_idempotent(api_base, &key, &machine_id).await; match note_revocation(dir, &key, answer, now) { Ok(()) | Err(LicenseError::LicenseNotFound | LicenseError::LicenseRevoked) => Ok(()), Err(e) => Err(e), } } /// Identifier of the stored activation token when that token activates this machine: signed /// by the license server, unexpired, and issued under one of this machine's identifiers, the /// rule of [`activation_status_in`] that gives the card its "Activated" state. `None` for a /// missing, unreadable, expired or forged token, for another machine's token, and without a /// readable OS id. fn this_machine_token_id( dir: &Path, ids: Option<&MachineIds>, decoding_key: &DecodingKey, ) -> Option { let ids = ids?; stored_token_machine_id(dir, decoding_key).filter(|id| ids.names_this_machine(id)) } /// Ask the license server whether the stored key was revoked (#365). The front calls it at /// app start and after a key is entered (#373); the checks run one at a time /// ([`check_revocation_in_turn`]), and the 24 h guard and the fail-open rules live in /// [`check_revocation_in`]. It is the one request the app makes on its own: nothing leaves /// without a stored key, and the request carries the product and the key only. Never an /// error for the user: the outcome says what happened, for the in-app log. #[tauri::command] pub async fn check_license_revocation(app: tauri::AppHandle) -> Result { let dir = app_data_dir(&app)?; let decoding_key = embedded_decoding_key().map_err(|e| license_error_to_string(&e))?; Ok(check_revocation_in_turn( &REVOCATION_CHECK_TURN, &dir, &api_base_url(), &decoding_key, unix_now, ) .await) } // === Tests ==================================================================================== #[cfg(test)] mod tests { use super::*; use ed25519_dalek::SigningKey; use jsonwebtoken::{encode, EncodingKey, Header}; // === Manual DER encoder for the Ed25519 private key ======================================= // We avoid the `pem` feature on `ed25519-dalek` because the `LineEnding` re-export path // varies across `pkcs8`/`spki`/`der` versions. The Ed25519 PKCS#8 v1 byte layout is fixed // and trivial: 16-byte prefix + 32-byte raw seed. // // Note the asymmetry in jsonwebtoken's API: // - `EncodingKey::from_ed_der` expects a PKCS#8-wrapped private key (passed to ring's // `Ed25519KeyPair::from_pkcs8`). // - `DecodingKey::from_ed_der` expects the *raw* 32-byte public key (passed to ring's // `UnparsedPublicKey::new` which takes raw bytes, not a SubjectPublicKeyInfo). /// Wrap a 32-byte Ed25519 seed in a PKCS#8 v1 PrivateKeyInfo DER blob. fn ed25519_pkcs8_private_der(seed: &[u8; 32]) -> Vec { // SEQUENCE(46) { // INTEGER(1) 0 // version v1 // SEQUENCE(5) { // OID(3) 1.3.101.112 // Ed25519 // } // OCTET STRING(34) { // OCTET STRING(32) <32 bytes> // } // } let mut der = vec![ 0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20, ]; der.extend_from_slice(seed); der } /// Build a deterministic test keypair so signed tokens are reproducible across runs. fn test_keys(seed: [u8; 32]) -> (EncodingKey, DecodingKey) { let signing_key = SigningKey::from_bytes(&seed); let pubkey_bytes = signing_key.verifying_key().to_bytes(); let priv_der = ed25519_pkcs8_private_der(&seed); let encoding_key = EncodingKey::from_ed_der(&priv_der); // Raw 32-byte public key (NOT SubjectPublicKeyInfo) — see note above. let decoding_key = DecodingKey::from_ed_der(&pubkey_bytes); (encoding_key, decoding_key) } fn default_keys() -> (EncodingKey, DecodingKey) { test_keys([42u8; 32]) } fn make_token(encoding_key: &EncodingKey, claims: &T) -> String { encode(&Header::new(Algorithm::EdDSA), claims, encoding_key).unwrap() } fn now() -> i64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_secs() as i64 } #[test] fn rejects_key_without_prefix() { let (_enc, dec) = default_keys(); let result = validate_with_key("nonsense", &dec); assert_eq!(result.unwrap_err(), LicenseError::InvalidPrefix); } #[test] fn accepts_well_formed_base_license() { let (enc, dec) = default_keys(); 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: vec!["auto-update".to_string()], machine_limit: 3, }; let jwt = make_token(&enc, &claims); let key = format!("{}{}", KEY_PREFIX_BASE, jwt); let info = validate_with_key(&key, &dec).unwrap(); assert_eq!(info.edition, EDITION_BASE); assert_eq!(info.email, "user@example.com"); assert_eq!(info.machine_limit, 3); } #[test] fn accepts_hundred_year_license() { // Keys issued since maximus-api#63 last 36525 days: their `exp` crosses 2^32 (#363). let (enc, dec) = default_keys(); let exp = now() + 36525 * 86400; assert!(exp > u32::MAX as i64, "a 100-year key must cross 2^32 to test the boundary"); let claims = LicenseClaims { sub: "user@example.com".to_string(), iss: "lacompagniemaximus.com".to_string(), iat: now(), exp, edition: EDITION_BASE.to_string(), features: vec![], machine_limit: 3, }; let key = format!("{}{}", KEY_PREFIX_BASE, make_token(&enc, &claims)); let info = validate_with_key(&key, &dec).unwrap(); assert_eq!(info.expires_at, exp); } #[test] fn rejects_expired_license() { let (enc, dec) = default_keys(); let claims = LicenseClaims { sub: "user@example.com".to_string(), iss: "lacompagniemaximus.com".to_string(), iat: now() - 1000, exp: now() - 100, edition: EDITION_BASE.to_string(), features: vec![], machine_limit: 3, }; let jwt = make_token(&enc, &claims); let key = format!("{}{}", KEY_PREFIX_BASE, jwt); let result = validate_with_key(&key, &dec); assert_eq!(result.unwrap_err(), LicenseError::LicenseExpired, "expired license must be rejected"); } #[test] fn rejects_license_without_exp() { // CWE-613: a key without `exp` never validates, however well it is signed. #[derive(Serialize)] struct ClaimsWithoutExp { sub: String, iss: String, iat: i64, edition: String, features: Vec, machine_limit: u32, } let (enc, dec) = default_keys(); let claims = ClaimsWithoutExp { sub: "user@example.com".to_string(), iss: "lacompagniemaximus.com".to_string(), iat: now(), edition: EDITION_BASE.to_string(), features: vec![], machine_limit: 3, }; let key = format!("{}{}", KEY_PREFIX_BASE, make_token(&enc, &claims)); assert_eq!(validate_with_key(&key, &dec).unwrap_err(), LicenseError::Malformed); } #[test] fn rejects_license_signed_with_wrong_key() { let (enc_signer, _dec_signer) = default_keys(); let (_enc_other, dec_other) = test_keys([7u8; 32]); 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: vec![], machine_limit: 3, }; let jwt = make_token(&enc_signer, &claims); let key = format!("{}{}", KEY_PREFIX_BASE, jwt); let result = validate_with_key(&key, &dec_other); assert_eq!( result.unwrap_err(), LicenseError::InvalidSignature, "wrong-key signature must be rejected" ); } #[test] fn rejects_corrupted_jwt() { let (_enc, dec) = default_keys(); let key = format!("{}not.a.real.jwt", KEY_PREFIX_BASE); let result = validate_with_key(&key, &dec); assert_eq!(result.unwrap_err(), LicenseError::Malformed); } #[test] fn a_key_damaged_while_copying_can_read_as_invalid_signature() { // jsonwebtoken checks the signature over `header.payload` before it decodes the // payload: a space slipped into the payload reads as a signature failure. The // `invalidSignature` message therefore sends the user back to the copy first. let (enc, dec) = default_keys(); let key = base_license_with_features(&enc, vec![]); let payload_start = key.find('.').unwrap() + 1; let damaged = format!("{} {}", &key[..payload_start + 4], &key[payload_start + 4..]); assert_eq!(validate_with_key(&damaged, &dec).unwrap_err(), LicenseError::InvalidSignature); } #[test] fn rejects_unknown_edition() { let (enc, dec) = default_keys(); let claims = LicenseClaims { sub: "user@example.com".to_string(), iss: "lacompagniemaximus.com".to_string(), iat: now(), exp: now() + 86400, edition: "enterprise".to_string(), features: vec![], machine_limit: 3, }; let jwt = make_token(&enc, &claims); let key = format!("{}{}", KEY_PREFIX_BASE, jwt); let result = validate_with_key(&key, &dec); assert_eq!(result.unwrap_err(), LicenseError::UnknownEdition); } // The machine the tests run "on", and another one: an OS id and the identifier derived // from it. These pairs are the two reference vectors of ADR 0020, written out as literals // so that no test can pass by comparing the derivation to itself. const THIS_RAW: &str = "0123456789abcdef0123456789abcdef"; const THIS_DERIVED: &str = "4f8817b37072a3a1e6ef014f34c46835ba715b6f2f97afee1fc680d7fc170db1"; const OTHER_RAW: &str = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"; const OTHER_DERIVED: &str = "3b9579ec36b3daf37a2aaab0abe2315fa4a9f07200ae568c5aaa614663037d9d"; /// A fixed clock for the functions that take the time as an argument. const NOW: i64 = 1_800_000_000; fn this_machine() -> MachineIds { MachineIds::from_raw(THIS_RAW).unwrap() } #[test] fn activation_token_matches_machine() { let (enc, dec) = default_keys(); let token = activation_for(&enc, THIS_DERIVED); assert!(validate_activation_with_key(&token, &this_machine(), &dec).is_ok()); } #[test] fn activation_token_from_before_the_derivation_still_matches() { // ADR 0020, D1: versions up to 0.15.0 sent the OS id as is, and their token carries // it. A machine activated by one of them stays activated. let (enc, dec) = default_keys(); let token = activation_for(&enc, THIS_RAW); assert!(validate_activation_with_key(&token, &this_machine(), &dec).is_ok()); } #[test] fn activation_token_rejects_other_machine() { let (enc, dec) = default_keys(); for other in [OTHER_RAW, OTHER_DERIVED] { let token = activation_for(&enc, other); let result = validate_activation_with_key(&token, &this_machine(), &dec); assert!(result.is_err(), "copied activation token must be rejected ({})", other); } } #[test] fn activation_token_rejects_expired() { // The token stays strictly validated where it decides something: the "Activated" // state of the card and the token written after an activation (#347, D12). The raw // form gets no leniency. let (enc, dec) = default_keys(); for id in [THIS_DERIVED, THIS_RAW] { let token = expired_activation_for(&enc, id); assert!( validate_activation_with_key(&token, &this_machine(), &dec).is_err(), "an expired token must not read as activated ({})", id ); } } #[test] fn embedded_public_key_pem_parses() { // Sanity check that the production PEM constant is well-formed. assert!(embedded_decoding_key().is_ok()); } // === Entitlements resolution: the license key alone (#347, D12) ============================ 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 expired_base_license(enc: &EncodingKey) -> String { let claims = LicenseClaims { sub: "user@example.com".to_string(), iss: "lacompagniemaximus.com".to_string(), iat: now() - 1000, exp: now() - 100, edition: EDITION_BASE.to_string(), features: vec!["balance".to_string()], machine_limit: 3, }; format!("{}{}", KEY_PREFIX_BASE, make_token(enc, &claims)) } 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) } /// A token as the server signs it (2 years), issued long enough ago to have expired. fn expired_activation_for(enc: &EncodingKey, machine_id: &str) -> String { let claims = ActivationClaims { sub: "license-id".to_string(), iat: now() - 3 * 365 * 86400, exp: now() - 100, machine_id: machine_id.to_string(), }; make_token(enc, &claims) } #[test] fn invalid_license_resolves_free_without_features() { let (_enc, dec) = default_keys(); let (edition, features) = resolve_license_entitlements("SR-BASE-not.a.jwt", &dec); assert_eq!(edition, EDITION_FREE); assert!(features.is_empty()); } #[test] fn expired_license_resolves_free_without_features() { let (enc, dec) = default_keys(); let (edition, features) = resolve_license_entitlements(&expired_base_license(&enc), &dec); assert_eq!(edition, EDITION_FREE); assert!(features.is_empty(), "signed features must not survive an expired key"); } /// A data directory of its own for each test (test name + pid: cargo runs tests on /// parallel threads), removed when the test ends, failed assertion included. struct TestDir(PathBuf); impl TestDir { fn new(name: &str) -> Self { let dir = std::env::temp_dir().join(format!( "simpl-resultat-license-test-{}-{}", name, std::process::id() )); let _ = fs::remove_dir_all(&dir); fs::create_dir_all(&dir).unwrap(); TestDir(dir) } fn write(&self, file: &str, contents: impl AsRef<[u8]>) { fs::write(self.0.join(file), contents).unwrap(); } } impl Drop for TestDir { fn drop(&mut self) { let _ = fs::remove_dir_all(&self.0); } } /// Bytes that are not UTF-8: `read_to_string` fails on them. const NOT_UTF8: [u8; 4] = [0xff, 0xfe, 0x00, 0x80]; #[test] fn key_alone_keeps_edition_and_features() { let (enc, dec) = default_keys(); let dir = TestDir::new("key_alone"); dir.write(LICENSE_FILE, base_license_with_features(&enc, vec!["balance".to_string()])); let (edition, features) = entitlements_from_dir(&dir.0, &dec); assert_eq!(edition, EDITION_BASE); assert_eq!(features, vec!["balance".to_string()]); } #[test] fn expired_token_of_this_machine_keeps_edition_and_features() { // #347: the server signs the token for 2 years, the key lasts longer. let (enc, dec) = default_keys(); let dir = TestDir::new("expired_token"); dir.write(LICENSE_FILE, base_license_with_features(&enc, vec!["balance".to_string()])); dir.write(ACTIVATION_FILE, expired_activation_for(&enc, "this-machine")); let (edition, features) = entitlements_from_dir(&dir.0, &dec); assert_eq!(edition, EDITION_BASE); assert_eq!(features, vec!["balance".to_string()]); } #[test] fn token_of_another_machine_keeps_edition_and_features() { // #360: data restored after a reinstall, or copied to a new computer. let (enc, dec) = default_keys(); let dir = TestDir::new("other_machine_token"); dir.write(LICENSE_FILE, base_license_with_features(&enc, vec!["balance".to_string()])); dir.write(ACTIVATION_FILE, activation_for(&enc, "another-machine")); let (edition, features) = entitlements_from_dir(&dir.0, &dec); assert_eq!(edition, EDITION_BASE); assert_eq!(features, vec!["balance".to_string()]); } #[test] fn unreadable_token_keeps_edition_and_features() { let (enc, dec) = default_keys(); let dir = TestDir::new("unreadable_token"); dir.write(LICENSE_FILE, base_license_with_features(&enc, vec!["balance".to_string()])); dir.write(ACTIVATION_FILE, NOT_UTF8); let (edition, features) = entitlements_from_dir(&dir.0, &dec); assert_eq!(edition, EDITION_BASE); assert_eq!(features, vec!["balance".to_string()]); } #[test] fn expired_key_resolves_free_even_with_a_valid_token() { // A token never upgrades an edition: the key alone decides. let (enc, dec) = default_keys(); let dir = TestDir::new("expired_key_valid_token"); dir.write(LICENSE_FILE, expired_base_license(&enc)); dir.write(ACTIVATION_FILE, activation_for(&enc, "this-machine")); let (edition, features) = entitlements_from_dir(&dir.0, &dec); assert_eq!(edition, EDITION_FREE); assert!(features.is_empty()); } #[test] fn missing_key_file_resolves_free() { let (enc, dec) = default_keys(); let dir = TestDir::new("missing_key"); dir.write(ACTIVATION_FILE, activation_for(&enc, "this-machine")); let (edition, features) = entitlements_from_dir(&dir.0, &dec); assert_eq!(edition, EDITION_FREE); assert!(features.is_empty()); } // === Key storage and reading: errors named at the source (#347) ============================= #[test] fn store_refuses_a_bad_prefix_without_writing() { let (_enc, dec) = default_keys(); let dir = TestDir::new("store_bad_prefix"); let result = store_license_in(&dir.0, "BASE-not-a-key", &dec); assert_eq!(result.unwrap_err(), LicenseError::InvalidPrefix); assert!(!dir.0.join(LICENSE_FILE).exists(), "a refused key must not be written"); } #[test] fn store_writes_the_trimmed_key() { let (enc, dec) = default_keys(); let dir = TestDir::new("store_trimmed"); let key = base_license_with_features(&enc, vec![]); let info = store_license_in(&dir.0, &format!(" {}\n", key), &dec).unwrap(); assert_eq!(info.edition, EDITION_BASE); assert_eq!(fs::read_to_string(dir.0.join(LICENSE_FILE)).unwrap(), key); } #[test] fn store_reports_storage_when_the_directory_cannot_be_created() { let (enc, dec) = default_keys(); let dir = TestDir::new("store_blocked"); dir.write("blocker", "a file where the data directory should go"); let target = dir.0.join("blocker").join("app-data"); let result = store_license_in(&target, &base_license_with_features(&enc, vec![]), &dec); assert_eq!(result.unwrap_err(), LicenseError::Storage); } #[test] fn read_reports_storage_for_an_unreadable_key_file() { let (_enc, dec) = default_keys(); let dir = TestDir::new("read_unreadable"); dir.write(LICENSE_FILE, NOT_UTF8); assert_eq!(read_license_in(&dir.0, &dec).unwrap_err(), LicenseError::Storage); } #[test] fn read_returns_none_for_a_missing_or_invalid_key() { let (_enc, dec) = default_keys(); let dir = TestDir::new("read_none"); assert!(read_license_in(&dir.0, &dec).unwrap().is_none(), "no file, no license"); dir.write(LICENSE_FILE, "SR-BASE-not.a.jwt"); assert!(read_license_in(&dir.0, &dec).unwrap().is_none(), "an invalid key reads as none"); } #[test] fn store_drops_the_token_of_a_previous_key() { // Rebuy after a refund, reissue after a revocation, Base to Premium: the old token // would name the previous license to the price server (#347 review). let (enc, dec) = default_keys(); let dir = TestDir::new("store_new_key"); dir.write(LICENSE_FILE, base_license_with_features(&enc, vec!["balance".to_string()])); dir.write(ACTIVATION_FILE, activation_for(&enc, "this-machine")); let new_key = base_license_with_features(&enc, vec![]); store_license_in(&dir.0, &new_key, &dec).unwrap(); assert!(!dir.0.join(ACTIVATION_FILE).exists(), "the previous key's token must go"); assert_eq!(fs::read_to_string(dir.0.join(LICENSE_FILE)).unwrap(), new_key); } #[test] fn store_keeps_the_token_when_the_same_key_is_entered_again() { let (enc, dec) = default_keys(); let dir = TestDir::new("store_same_key"); let key = base_license_with_features(&enc, vec![]); dir.write(LICENSE_FILE, &key); dir.write(ACTIVATION_FILE, activation_for(&enc, "this-machine")); store_license_in(&dir.0, &format!(" {}\n", key), &dec).unwrap(); assert!(dir.0.join(ACTIVATION_FILE).exists(), "the same key keeps its activation"); } #[test] fn store_drops_a_token_left_without_a_key() { let (enc, dec) = default_keys(); let dir = TestDir::new("store_orphan_token"); dir.write(ACTIVATION_FILE, activation_for(&enc, "this-machine")); store_license_in(&dir.0, &base_license_with_features(&enc, vec![]), &dec).unwrap(); assert!(!dir.0.join(ACTIVATION_FILE).exists()); } #[test] fn a_refused_key_leaves_the_stored_key_and_token_alone() { let (enc, dec) = default_keys(); let dir = TestDir::new("store_refused_keeps_all"); let key = base_license_with_features(&enc, vec![]); dir.write(LICENSE_FILE, &key); dir.write(ACTIVATION_FILE, activation_for(&enc, "this-machine")); let result = store_license_in(&dir.0, "BASE-not-a-key", &dec); assert_eq!(result.unwrap_err(), LicenseError::InvalidPrefix); assert!(dir.0.join(ACTIVATION_FILE).exists()); assert_eq!(fs::read_to_string(dir.0.join(LICENSE_FILE)).unwrap(), key); } // === Activation status: the decision behind "Not activated" / "Activate" (#347, #360) ====== #[test] fn activation_status_accepts_a_token_of_this_machine() { let (enc, dec) = default_keys(); let dir = TestDir::new("status_this_machine"); dir.write(ACTIVATION_FILE, activation_for(&enc, THIS_DERIVED)); assert!(activation_status_in(&dir.0, &this_machine(), &dec)); } #[test] fn activation_status_accepts_a_token_from_before_the_derivation() { // ADR 0020, D1: updating the app must not turn "Activated" into "Activate". let (enc, dec) = default_keys(); let dir = TestDir::new("status_raw_token"); dir.write(ACTIVATION_FILE, activation_for(&enc, THIS_RAW)); assert!(activation_status_in(&dir.0, &this_machine(), &dec)); } #[test] fn activation_status_refuses_a_missing_token() { let (_enc, dec) = default_keys(); let dir = TestDir::new("status_missing"); assert!(!activation_status_in(&dir.0, &this_machine(), &dec)); } #[test] fn activation_status_refuses_an_expired_token() { let (enc, dec) = default_keys(); for id in [THIS_DERIVED, THIS_RAW] { let dir = TestDir::new("status_expired"); dir.write(ACTIVATION_FILE, expired_activation_for(&enc, id)); assert!(!activation_status_in(&dir.0, &this_machine(), &dec), "claim {}", id); } } #[test] fn activation_status_refuses_a_token_of_another_machine() { let (enc, dec) = default_keys(); for other in [OTHER_RAW, OTHER_DERIVED] { let dir = TestDir::new("status_other_machine"); dir.write(ACTIVATION_FILE, activation_for(&enc, other)); assert!(!activation_status_in(&dir.0, &this_machine(), &dec), "claim {}", other); } } #[test] fn activation_status_refuses_a_token_signed_by_another_key() { let (_enc, dec) = default_keys(); let (other_enc, _other_dec) = test_keys([7u8; 32]); let dir = TestDir::new("status_other_key"); dir.write(ACTIVATION_FILE, activation_for(&other_enc, THIS_DERIVED)); assert!(!activation_status_in(&dir.0, &this_machine(), &dec)); } #[test] fn activation_status_refuses_an_unreadable_token() { let (_enc, dec) = default_keys(); let dir = TestDir::new("status_unreadable"); dir.write(ACTIVATION_FILE, NOT_UTF8); assert!(!activation_status_in(&dir.0, &this_machine(), &dec)); } // === Machine identifier: derivation and transition (#350, ADR 0020) ======================== #[test] fn derivation_matches_the_reference_vectors() { // Frozen: the license server records every activation under these identifiers, and // maximus-api reuses the same vectors. A red test here means the key or the formula // moved, and every existing activation with it. assert_eq!(derive_machine_id(THIS_RAW), Ok(THIS_DERIVED.to_string())); assert_eq!(derive_machine_id(OTHER_RAW), Ok(OTHER_DERIVED.to_string())); } #[test] fn derived_identifier_is_64_lowercase_hex_and_ignores_surrounding_whitespace() { let derived = derive_machine_id(" 0123456789abcdef0123456789abcdef\n").unwrap(); assert_eq!(derived, THIS_DERIVED, "the OS id is trimmed before the derivation"); assert_eq!(derived.len(), 64); assert!(derived.chars().all(|c| matches!(c, '0'..='9' | 'a'..='f'))); } #[test] fn an_empty_os_id_is_refused_not_hashed() { // Hashed, it would give every machine without a machine id the same identifier. for raw in ["", " ", "\n"] { assert_eq!(derive_machine_id(raw), Err(LicenseError::Internal), "raw {:?}", raw); assert_eq!( MachineIds::from_raw(raw).err(), Some(LicenseError::Internal), "raw {:?}", raw ); } } #[test] fn machine_ids_hold_both_forms_and_name_only_this_machine() { let ids = MachineIds::from_raw(&format!(" {}\n", THIS_RAW)).unwrap(); assert_eq!(ids.derived, THIS_DERIVED); assert_eq!(ids.raw, THIS_RAW); assert!(ids.names_this_machine(THIS_DERIVED)); assert!(ids.names_this_machine(THIS_RAW)); assert!(!ids.names_this_machine(OTHER_RAW)); assert!(!ids.names_this_machine(OTHER_DERIVED)); } #[test] fn a_fresh_token_must_carry_the_derived_identifier() { let (enc, dec) = default_keys(); let dir = TestDir::new("fresh_token_derived"); let token = activation_for(&enc, THIS_DERIVED); store_activation_token_in(&dir.0, &format!(" {}\n", token), &this_machine(), &dec).unwrap(); assert_eq!(fs::read_to_string(dir.0.join(ACTIVATION_FILE)).unwrap(), token); } #[test] fn a_fresh_token_for_any_other_identifier_is_refused_and_not_written() { // The raw id is accepted from a token already on disk only: the server answers a // request that carried the derived one. let (enc, dec) = default_keys(); for id in [THIS_RAW, OTHER_RAW, OTHER_DERIVED] { let dir = TestDir::new("fresh_token_refused"); let result = store_activation_token_in(&dir.0, &activation_for(&enc, id), &this_machine(), &dec); assert_eq!(result, Err(LicenseError::InvalidResponse), "claim {}", id); assert!(!dir.0.join(ACTIVATION_FILE).exists(), "claim {}", id); } let dir = TestDir::new("fresh_token_expired"); let expired = expired_activation_for(&enc, THIS_DERIVED); assert_eq!( store_activation_token_in(&dir.0, &expired, &this_machine(), &dec), Err(LicenseError::InvalidResponse) ); } /// Body of an activation answer carrying `token`. fn activation_answer(token: &str) -> String { serde_json::json!({ "activation_token": token }).to_string() } #[tokio::test] async fn activation_sends_the_derived_identifier_never_the_raw_one() { // The decision point of #350. The only mock declared wants the derived identifier in // `machine_id` and refuses any body where the OS id appears, in whatever field: a // request carrying it gets mockito's 501 and this test fails. let (enc, dec) = default_keys(); let dir = TestDir::new("activate_sends_derived"); let token = activation_for(&enc, THIS_DERIVED); let mut server = mockito::Server::new_async().await; let derived_only = server .mock("POST", "/v1/licenses/activate") .match_body(mockito::Matcher::PartialJson(serde_json::json!({ "product": "simpl-resultat", "license_key": "SR-BASE-k", "machine_id": THIS_DERIVED, "machine_name": "laptop", }))) .match_request(|request| { !request.utf8_lossy_body().map(|body| body.contains(THIS_RAW)).unwrap_or(true) }) .with_status(200) .with_header("content-type", "application/json") .with_body(activation_answer(&token)) .create_async() .await; activate_and_store_in(&dir.0, &server.url(), "SR-BASE-k", THIS_RAW, Some("laptop"), &dec, NOW) .await .expect("activation"); derived_only.assert_async().await; assert_eq!(fs::read_to_string(dir.0.join(ACTIVATION_FILE)).unwrap(), token); } #[tokio::test] async fn an_empty_os_id_stops_the_activation_before_any_request() { let (_enc, dec) = default_keys(); for raw in ["", " \n"] { let dir = TestDir::new("activate_empty_id"); let mut server = mockito::Server::new_async().await; let any_request = server .mock("POST", mockito::Matcher::Any) .expect(0) .create_async() .await; let result = activate_and_store_in(&dir.0, &server.url(), "SR-BASE-k", raw, None, &dec, NOW).await; assert_eq!(result, Err(LicenseError::Internal), "raw {:?}", raw); any_request.assert_async().await; assert!(!dir.0.join(ACTIVATION_FILE).exists(), "raw {:?}", raw); } } #[tokio::test] async fn activation_refuses_a_token_issued_for_another_identifier() { // Here the OS id echoed back, as a server answering for a version up to 0.15.0 would. let (enc, dec) = default_keys(); let dir = TestDir::new("activate_wrong_token"); let mut server = mockito::Server::new_async().await; let _m = server .mock("POST", "/v1/licenses/activate") .with_status(200) .with_header("content-type", "application/json") .with_body(activation_answer(&activation_for(&enc, THIS_RAW))) .create_async() .await; let result = activate_and_store_in(&dir.0, &server.url(), "SR-BASE-k", THIS_RAW, None, &dec, NOW).await; assert_eq!(result, Err(LicenseError::InvalidResponse)); assert!(!dir.0.join(ACTIVATION_FILE).exists()); } /// A row of the machine list as the server sends it. fn listed_machine(machine_id: &str) -> serde_json::Value { serde_json::json!({ "machine_id": machine_id, "machine_name": "laptop", "activated_at": "2026-09-01T00:00:00.000Z", "last_seen_at": "2026-09-20T00:00:00.000Z", }) } #[tokio::test] async fn the_machine_list_flags_this_machine_under_either_identifier() { // ADR 0020, D3: a row activated before the derivation carries the OS id, and the // server may rehash its rows some day. The flag is never read from the answer: the // foreign row below claims it and loses it. let mut foreign = listed_machine(OTHER_RAW); foreign["is_this_machine"] = serde_json::Value::Bool(true); let answer = serde_json::json!({ "valid": true, "machine_limit": 3, "machines": [listed_machine(THIS_DERIVED), listed_machine(THIS_RAW), foreign, listed_machine(OTHER_DERIVED)], }); let mut server = mockito::Server::new_async().await; let listing = server .mock("POST", "/v1/licenses/verify") .with_status(200) .with_header("content-type", "application/json") .with_body(answer.to_string()) .expect(2) .create_async() .await; let flags = |machines: Vec| -> Vec<(String, bool)> { machines.into_iter().map(|m| (m.machine_id, m.is_this_machine)).collect() }; let expected = |this: bool| { vec![ (THIS_DERIVED.to_string(), this), (THIS_RAW.to_string(), this), (OTHER_RAW.to_string(), false), (OTHER_DERIVED.to_string(), false), ] }; let ids = this_machine(); let listed = list_machines_inner(&server.url(), "SR-BASE-k", Some(&ids)).await.unwrap(); assert_eq!(flags(listed), expected(true)); // Unreadable OS id: the list still loads, and no row is flagged. let unflagged = list_machines_inner(&server.url(), "SR-BASE-k", None).await.unwrap(); assert_eq!(flags(unflagged), expected(false)); listing.assert_async().await; } #[test] fn the_webview_gets_the_flag_and_no_identifier_of_its_own() { // The wire shape `licenseService.ts` reads: a flagged row carries `is_this_machine`, // and the activation status is the single field `is_activated`. let row: MachineInfo = serde_json::from_value(listed_machine(THIS_RAW)).unwrap(); let flagged = flag_this_machine(vec![row], Some(&this_machine())); assert_eq!( serde_json::to_value(&flagged[0]).unwrap(), serde_json::json!({ "machine_id": THIS_RAW, "machine_name": "laptop", "activated_at": "2026-09-01T00:00:00.000Z", "last_seen_at": "2026-09-20T00:00:00.000Z", "is_this_machine": true, }) ); assert_eq!( serde_json::to_value(ActivationStatus { is_activated: true }).unwrap(), serde_json::json!({ "is_activated": true }) ); } #[test] fn deactivation_drops_the_token_that_stood_for_the_freed_slot() { let (enc, dec) = default_keys(); let ids = this_machine(); // (identifier of the stored token, identifier deactivated, token kept, case) let cases = [ (THIS_RAW, THIS_RAW, false, "raw token, raw row of this machine"), (THIS_DERIVED, THIS_DERIVED, false, "derived token, derived row of this machine"), (THIS_DERIVED, THIS_RAW, true, "derived token, old raw row of this machine"), (THIS_RAW, THIS_DERIVED, false, "raw token, row rehashed by the server"), (THIS_RAW, OTHER_RAW, true, "raw token, row of another machine"), (THIS_DERIVED, OTHER_DERIVED, true, "derived token, row of another machine"), (THIS_DERIVED, OTHER_RAW, true, "derived token, raw row of another machine"), (OTHER_RAW, OTHER_RAW, false, "token of another machine, that machine's row"), (OTHER_RAW, OTHER_DERIVED, true, "token of another machine, a different row"), // Data restored from another computer (#360): the foreign token goes with any // row of this machine, as it did before the derivation. (OTHER_RAW, THIS_RAW, false, "token of another machine, raw row of this machine"), (OTHER_DERIVED, THIS_DERIVED, false, "token of another machine, derived row of this machine"), ]; for (token_id, deactivated, kept, case) in cases { let dir = TestDir::new("deactivation_token"); dir.write(ACTIVATION_FILE, activation_for(&enc, token_id)); drop_token_of_deactivated_machine_in(&dir.0, deactivated, Some(&ids), &dec); assert_eq!(dir.0.join(ACTIVATION_FILE).exists(), kept, "{}", case); } } #[test] fn deactivating_this_machine_drops_a_token_that_no_longer_validates() { // Expired, forged or unreadable, the file stands for no other activation: it goes // with this machine's row, as it did before the derivation. let (enc, dec) = default_keys(); let (other_enc, _other_dec) = test_keys([7u8; 32]); let ids = this_machine(); let stale: Vec<(Vec, &str)> = vec![ (expired_activation_for(&enc, THIS_RAW).into_bytes(), "expired raw token"), (expired_activation_for(&enc, THIS_DERIVED).into_bytes(), "expired derived token"), (activation_for(&other_enc, THIS_DERIVED).into_bytes(), "token signed by another key"), (NOT_UTF8.to_vec(), "unreadable file"), ]; for (contents, case) in stale { for deactivated in [THIS_RAW, THIS_DERIVED] { let dir = TestDir::new("deactivation_stale_token"); dir.write(ACTIVATION_FILE, &contents); drop_token_of_deactivated_machine_in(&dir.0, deactivated, Some(&ids), &dec); assert!(!dir.0.join(ACTIVATION_FILE).exists(), "{}, row {}", case, deactivated); } // Another machine's row never touches it. let dir = TestDir::new("deactivation_stale_token"); dir.write(ACTIVATION_FILE, &contents); drop_token_of_deactivated_machine_in(&dir.0, OTHER_RAW, Some(&ids), &dec); assert!(dir.0.join(ACTIVATION_FILE).exists(), "{}, row of another machine", case); } } #[test] fn without_a_readable_os_id_only_the_token_s_own_identifier_decides() { let (enc, dec) = default_keys(); let cases = [ (THIS_RAW, THIS_RAW, false), (THIS_DERIVED, THIS_DERIVED, false), (THIS_RAW, THIS_DERIVED, true), (THIS_DERIVED, OTHER_RAW, true), ]; for (token_id, deactivated, kept) in cases { let dir = TestDir::new("deactivation_no_os_id"); dir.write(ACTIVATION_FILE, activation_for(&enc, token_id)); drop_token_of_deactivated_machine_in(&dir.0, deactivated, None, &dec); assert_eq!( dir.0.join(ACTIVATION_FILE).exists(), kept, "token {}, row {}", token_id, deactivated ); } } #[test] fn deactivation_without_a_stored_token_writes_nothing() { let (_enc, dec) = default_keys(); let dir = TestDir::new("deactivation_no_token"); drop_token_of_deactivated_machine_in(&dir.0, THIS_DERIVED, Some(&this_machine()), &dec); assert_eq!(fs::read_dir(&dir.0).unwrap().count(), 0); } // === Online revocation check (#365) ======================================================== /// A literal key and its SHA-256, computed outside this code: they pin the state format. const LITERAL_KEY: &str = "SR-BASE-header.payload.signature"; const LITERAL_KEY_SHA256: &str = "f4753f0b6f2031a066ec9d8da6aaa3eeeda05258614372d7b4c772a9bdb489c9"; /// Content of a state file about `key`. fn check_state(key: &str, attempted_at: i64, revoked: bool) -> String { serde_json::json!({ "key_sha256": key_digest(key), "attempted_at": attempted_at, "revoked": revoked, }) .to_string() } /// The state file as stored, if it is readable JSON. fn stored_state(dir: &TestDir) -> Option { let raw = fs::read_to_string(dir.0.join(CHECK_FILE)).ok()?; serde_json::from_str(&raw).ok() } /// A verify route answering `status` with `body`, expecting `hits` requests. async fn verify_route( server: &mut mockito::ServerGuard, status: usize, body: &str, hits: usize, ) -> mockito::Mock { server .mock("POST", "/v1/licenses/verify") .with_status(status) .with_header("content-type", "application/json") .with_body(body) .expect(hits) .create_async() .await } const REVOKED_ENVELOPE: &str = r#"{"error":{"code":"license_revoked","message":"License has been revoked"}}"#; #[test] fn the_state_names_the_key_by_its_sha256_never_by_itself() { assert_eq!(key_digest(LITERAL_KEY), LITERAL_KEY_SHA256); assert_eq!(key_digest(&format!(" {}\n", LITERAL_KEY)), LITERAL_KEY_SHA256); let dir = TestDir::new("state_format"); write_license_check( &dir.0, &LicenseCheck { key_sha256: key_digest(LITERAL_KEY), attempted_at: NOW, revoked: true, }, ) .unwrap(); assert_eq!( stored_state(&dir), Some(serde_json::json!({ "key_sha256": LITERAL_KEY_SHA256, "attempted_at": NOW, "revoked": true, })) ); let raw = fs::read_to_string(dir.0.join(CHECK_FILE)).unwrap(); assert!(!raw.contains("header.payload"), "the key must not be in the state file"); } #[test] fn a_revoked_key_resolves_free_without_features() { let (enc, dec) = default_keys(); let dir = TestDir::new("revoked_resolves_free"); let key = base_license_with_features(&enc, vec!["balance".to_string()]); dir.write(LICENSE_FILE, &key); // Not revoked: the key's edition, its features, and automatic updates. dir.write(CHECK_FILE, check_state(&key, NOW, false)); let (edition, features) = entitlements_from_dir(&dir.0, &dec); assert_eq!(edition, EDITION_BASE); assert_eq!(features, vec!["balance".to_string()]); assert!(crate::commands::entitlements::is_entitled("auto-update", &edition, &features)); // Revoked: Free, signed features dropped, automatic updates refused. dir.write(CHECK_FILE, check_state(&key, NOW, true)); let (edition, features) = entitlements_from_dir(&dir.0, &dec); assert_eq!(edition, EDITION_FREE); assert!(features.is_empty(), "signed features must not survive a revocation"); assert!(!crate::commands::entitlements::is_entitled("auto-update", &edition, &features)); assert!(!crate::commands::entitlements::is_entitled("balance", &edition, &features)); } #[test] fn a_state_that_is_not_about_this_key_changes_nothing() { // Another key bought after a refund, or a damaged file: a paying user stays paying. let (enc, dec) = default_keys(); let key = base_license_with_features(&enc, vec!["balance".to_string()]); let other_key = base_license_with_features(&enc, vec![]); let states: Vec<(&str, Vec)> = vec![ ("revoked state of another key", check_state(&other_key, NOW, true).into_bytes()), ("unreadable file", NOT_UTF8.to_vec()), ("invalid JSON", b"{not json".to_vec()), ("JSON of another shape", br#"{"revoked":true}"#.to_vec()), ]; for (case, contents) in states { let dir = TestDir::new("state_not_about_this_key"); dir.write(LICENSE_FILE, &key); dir.write(CHECK_FILE, &contents); let (edition, features) = entitlements_from_dir(&dir.0, &dec); assert_eq!(edition, EDITION_BASE, "{}", case); assert_eq!(features, vec!["balance".to_string()], "{}", case); assert!(!read_license_in(&dir.0, &dec).unwrap().unwrap().revoked, "{}", case); } } #[test] fn reading_and_storing_a_key_report_its_revoked_state() { let (enc, dec) = default_keys(); let dir = TestDir::new("info_revoked"); let key = base_license_with_features(&enc, vec![]); dir.write(LICENSE_FILE, &key); assert!(!read_license_in(&dir.0, &dec).unwrap().unwrap().revoked, "no state, not revoked"); dir.write(CHECK_FILE, check_state(&key, NOW, true)); let info = read_license_in(&dir.0, &dec).unwrap().unwrap(); assert!(info.revoked); assert_eq!(info.edition, EDITION_BASE, "the info still describes the key itself"); assert_eq!(serde_json::to_value(&info).unwrap()["revoked"], true); // The same key entered again does not come back clean. assert!(store_license_in(&dir.0, &key, &dec).unwrap().revoked); assert_eq!(entitlements_from_dir(&dir.0, &dec).0, EDITION_FREE); // Another key does: the state is not about it. let other_key = base_license_with_features(&enc, vec!["balance".to_string()]); assert!(!store_license_in(&dir.0, &other_key, &dec).unwrap().revoked); assert_eq!(entitlements_from_dir(&dir.0, &dec).0, EDITION_BASE); } #[test] fn the_check_result_serializes_as_the_front_reads_it() { let rows = [ (RevocationOutcome::NoLicense, "no_license"), (RevocationOutcome::NotDue, "not_due"), (RevocationOutcome::Valid, "valid"), (RevocationOutcome::Revoked, "revoked"), (RevocationOutcome::Unreachable, "unreachable"), (RevocationOutcome::Unknown, "unknown"), (RevocationOutcome::Storage, "storage"), ]; for (outcome, wire) in rows { assert_eq!( serde_json::to_value(RevocationCheck { outcome, revoked: true }).unwrap(), serde_json::json!({ "outcome": wire, "revoked": true }) ); } } #[tokio::test] async fn the_check_moves_the_state_on_a_verdict_and_on_nothing_else() { // D3, fail-open: only "valid" lifts and only "revoked" sets. Every other answer and // every error leaves a clean key clean and a revoked key revoked. use RevocationOutcome::{Revoked, Unknown, Unreachable, Valid}; let (enc, dec) = default_keys(); let key = base_license_with_features(&enc, vec![]); // (status, body, outcome, revoked after a clean start, revoked after a revoked start) let rows: Vec<(usize, &str, RevocationOutcome, bool, bool)> = vec![ (200, r#"{"valid":false,"reason":"revoked"}"#, Revoked, true, true), (403, REVOKED_ENVELOPE, Revoked, true, true), (200, r#"{"valid":true,"machines":[]}"#, Valid, false, false), (200, r#"{"valid":true}"#, Valid, false, false), (200, r#"{"valid":false,"reason":"not_found"}"#, Unknown, false, true), (200, r#"{"valid":false,"reason":"expired"}"#, Unknown, false, true), (200, r#"{"valid":false,"reason":"suspended"}"#, Unknown, false, true), (200, r#"{"machines":[]}"#, Unknown, false, true), (404, r#"{"error":{"code":"license_not_found","message":"x"}}"#, Unknown, false, true), (400, r#"{"error":{"code":"invalid_input","message":"x"}}"#, Unreachable, false, true), ( 429, r#"{"error":{"code":"rate_limit_exceeded","message":"x","retry_after":42}}"#, Unreachable, false, true, ), (503, "Service Unavailable", Unreachable, false, true), (200, "OK", Unreachable, false, true), ]; for (status, body, outcome, after_clean, after_revoked) in rows { for (was_revoked, expected) in [(false, after_clean), (true, after_revoked)] { let case = format!("{} {} from revoked={}", status, body, was_revoked); let dir = TestDir::new("check_outcomes"); dir.write(LICENSE_FILE, &key); dir.write(CHECK_FILE, check_state(&key, NOW - CHECK_INTERVAL_SECS, was_revoked)); let mut server = mockito::Server::new_async().await; let route = verify_route(&mut server, status, body, 1).await; let result = check_revocation_in(&dir.0, &server.url(), &dec, NOW).await; assert_eq!(result, RevocationCheck { outcome, revoked: expected }, "{}", case); route.assert_async().await; assert_eq!( stored_state(&dir), Some(serde_json::json!({ "key_sha256": key_digest(&key), "attempted_at": NOW, "revoked": expected, })), "{}", case ); let edition = entitlements_from_dir(&dir.0, &dec).0; assert_eq!(edition, if expected { EDITION_FREE } else { EDITION_BASE }, "{}", case); } } } #[tokio::test] async fn an_unreachable_server_changes_nothing() { // Port 1 on loopback: nothing listens. Offline, a legitimate user is never // downgraded, and a key already known as revoked stays revoked. let (enc, dec) = default_keys(); let key = base_license_with_features(&enc, vec![]); for was_revoked in [false, true] { let dir = TestDir::new("check_offline"); dir.write(LICENSE_FILE, &key); dir.write(CHECK_FILE, check_state(&key, NOW - CHECK_INTERVAL_SECS, was_revoked)); let result = check_revocation_in(&dir.0, "http://127.0.0.1:1", &dec, NOW).await; assert_eq!( result, RevocationCheck { outcome: RevocationOutcome::Unreachable, revoked: was_revoked } ); assert_eq!(is_revoked_in(&dir.0, &key), was_revoked); } } #[tokio::test] async fn nothing_is_sent_without_a_valid_stored_key_nor_before_24_hours() { use RevocationOutcome::{NoLicense, NotDue}; let (enc, dec) = default_keys(); let key = base_license_with_features(&enc, vec![]); // (case, key file, state file, outcome, revoked) let cases: Vec<(&str, Option, Option, RevocationOutcome, bool)> = vec![ ("no key stored", None, None, NoLicense, false), ("a stored key that does not validate", Some("SR-BASE-not.a.jwt".into()), None, NoLicense, false), ("an expired stored key", Some(expired_base_license(&enc)), None, NoLicense, false), ("an attempt in this very second", Some(key.clone()), Some(check_state(&key, NOW, false)), NotDue, false), // 24 h written out, here and in the next test: rows built from the constant // would follow it wherever it goes. ( "an attempt 24 h minus one second ago, key revoked", Some(key.clone()), Some(check_state(&key, NOW - 86_399, true)), NotDue, true, ), ]; for (case, key_file, state_file, outcome, revoked) in cases { let dir = TestDir::new("check_not_sent"); if let Some(contents) = &key_file { dir.write(LICENSE_FILE, contents); } if let Some(contents) = &state_file { dir.write(CHECK_FILE, contents); } let mut server = mockito::Server::new_async().await; let any_request = server .mock("POST", mockito::Matcher::Any) .expect(0) .create_async() .await; let result = check_revocation_in(&dir.0, &server.url(), &dec, NOW).await; assert_eq!(result, RevocationCheck { outcome, revoked }, "{}", case); any_request.assert_async().await; assert_eq!( fs::read_to_string(dir.0.join(CHECK_FILE)).ok(), state_file, "{}: the state file must be left as it was", case ); } } #[tokio::test] async fn an_attempt_is_due_without_state_after_24_hours_or_with_an_unusable_date() { let (enc, dec) = default_keys(); let key = base_license_with_features(&enc, vec![]); let other_key = base_license_with_features(&enc, vec!["balance".to_string()]); let states: Vec<(&str, Option)> = vec![ ("no state", None), ("an attempt exactly 24 h ago", Some(check_state(&key, NOW - 86_400, false))), ("an attempt ten days ago", Some(check_state(&key, NOW - 10 * CHECK_INTERVAL_SECS, false))), ("a fresh state about another key", Some(check_state(&other_key, NOW, true))), ("an attempt dated in the future", Some(check_state(&key, NOW + 1, false))), ("an attempt dated at i64::MAX", Some(check_state(&key, i64::MAX, false))), ("an attempt dated at i64::MIN", Some(check_state(&key, i64::MIN, false))), ("invalid JSON", Some("{not json".to_string())), ]; for (case, state_file) in states { let dir = TestDir::new("check_due"); dir.write(LICENSE_FILE, &key); if let Some(contents) = &state_file { dir.write(CHECK_FILE, contents); } let mut server = mockito::Server::new_async().await; let route = verify_route(&mut server, 200, r#"{"valid":true,"machines":[]}"#, 1).await; let result = check_revocation_in(&dir.0, &server.url(), &dec, NOW).await; assert_eq!( result, RevocationCheck { outcome: RevocationOutcome::Valid, revoked: false }, "{}", case ); route.assert_async().await; assert_eq!( stored_state(&dir), Some(serde_json::json!({ "key_sha256": key_digest(&key), "attempted_at": NOW, "revoked": false, })), "{}", case ); } } #[tokio::test] async fn a_failed_attempt_still_counts_for_24_hours() { // AC4: one attempt per 24 h whatever its result. The attempt is written before the // request leaves, so the second call below sends nothing; a day later one is due. let (enc, dec) = default_keys(); let key = base_license_with_features(&enc, vec![]); let dir = TestDir::new("check_failed_attempt"); dir.write(LICENSE_FILE, &key); let mut server = mockito::Server::new_async().await; let failing = verify_route(&mut server, 503, "Service Unavailable", 1).await; let first = check_revocation_in(&dir.0, &server.url(), &dec, NOW).await; assert_eq!(first.outcome, RevocationOutcome::Unreachable); assert_eq!(stored_state(&dir).unwrap()["attempted_at"], NOW); let second = check_revocation_in(&dir.0, &server.url(), &dec, NOW + 60).await; assert_eq!(second.outcome, RevocationOutcome::NotDue); failing.assert_async().await; let mut next_day = mockito::Server::new_async().await; let route = verify_route(&mut next_day, 200, r#"{"valid":false,"reason":"revoked"}"#, 1).await; let third = check_revocation_in(&dir.0, &next_day.url(), &dec, NOW + CHECK_INTERVAL_SECS).await; assert_eq!(third, RevocationCheck { outcome: RevocationOutcome::Revoked, revoked: true }); route.assert_async().await; } #[tokio::test] async fn an_attempt_that_cannot_be_written_sends_nothing() { // Bound (2) holds in a data directory where the state cannot be written: the attempt // is not recorded, so nothing is sent, or every start would send a request (review // of #368). let (enc, dec) = default_keys(); let key = base_license_with_features(&enc, vec![]); let dir = TestDir::new("check_unwritable"); dir.write(LICENSE_FILE, &key); // A directory in place of the state file: it reads as "no state" and cannot be written. fs::create_dir(dir.0.join(CHECK_FILE)).unwrap(); let mut server = mockito::Server::new_async().await; let any_request = server.mock("POST", mockito::Matcher::Any).expect(0).create_async().await; let result = check_revocation_in(&dir.0, &server.url(), &dec, NOW).await; assert_eq!(result, RevocationCheck { outcome: RevocationOutcome::Storage, revoked: false }); any_request.assert_async().await; } #[tokio::test] async fn a_verdict_that_cannot_be_written_is_not_reported_as_applied() { // The other `storage` outcome: the request left and the server answered "revoked", // but the verdict could not be written. Nothing on disk says the key is revoked, so // the check does not say it either. let (enc, dec) = default_keys(); let key = base_license_with_features(&enc, vec![]); let dir = TestDir::new("check_verdict_unwritable"); dir.write(LICENSE_FILE, &key); let state_path = dir.0.join(CHECK_FILE); let mut server = mockito::Server::new_async().await; let route = server .mock("POST", "/v1/licenses/verify") .match_request(move |_request| { // The attempt is on disk by now: a directory in its place makes the verdict // that follows unwritable. let _ = fs::remove_file(&state_path); let _ = fs::create_dir(&state_path); true }) .with_status(200) .with_header("content-type", "application/json") .with_body(r#"{"valid":false,"reason":"revoked"}"#) .expect(1) .create_async() .await; let result = check_revocation_in(&dir.0, &server.url(), &dec, NOW).await; assert_eq!(result, RevocationCheck { outcome: RevocationOutcome::Storage, revoked: false }); route.assert_async().await; assert!(!is_revoked_in(&dir.0, &key)); assert_eq!(entitlements_from_dir(&dir.0, &dec).0, EDITION_BASE); } #[tokio::test] async fn the_attempt_is_on_disk_before_the_request_reaches_the_server() { // D1: written before the request leaves, so a call started meanwhile sends nothing // and an app closed mid-request has still used its attempt. Read from the server's // side: a write made after the answer would pass every other test. let (enc, dec) = default_keys(); let key = base_license_with_features(&enc, vec![]); let dir = TestDir::new("check_attempt_first"); dir.write(LICENSE_FILE, &key); dir.write(CHECK_FILE, check_state(&key, NOW - 86_400, true)); let state_path = dir.0.join(CHECK_FILE); let seen = std::sync::Arc::new(std::sync::Mutex::new(None::)); let seen_by_server = seen.clone(); let mut server = mockito::Server::new_async().await; let route = server .mock("POST", "/v1/licenses/verify") .match_request(move |_request| { *seen_by_server.lock().unwrap() = fs::read_to_string(&state_path).ok(); true }) .with_status(200) .with_header("content-type", "application/json") .with_body(r#"{"valid":true,"machines":[]}"#) .expect(1) .create_async() .await; let result = check_revocation_in(&dir.0, &server.url(), &dec, NOW).await; assert_eq!(result, RevocationCheck { outcome: RevocationOutcome::Valid, revoked: false }); route.assert_async().await; // While the request was in flight: this call's attempt, and the verdict not moved yet. let during = seen.lock().unwrap().clone().expect("a state file during the request"); assert_eq!( serde_json::from_str::(&during).unwrap(), serde_json::json!({ "key_sha256": key_digest(&key), "attempted_at": NOW, "revoked": true, }) ); assert!(!is_revoked_in(&dir.0, &key), "the valid answer lifts it afterwards"); } #[tokio::test] async fn the_check_sends_the_product_and_the_key_and_nothing_else() { // The one request the app makes on its own: no machine identifier, no host name, no // usage data. A body with any other field matches no mock and gets mockito's 501. let (enc, dec) = default_keys(); let key = base_license_with_features(&enc, vec![]); let dir = TestDir::new("check_request_body"); dir.write(LICENSE_FILE, format!(" {}\n", key)); let sent_key = key.clone(); let mut server = mockito::Server::new_async().await; let route = server .mock("POST", "/v1/licenses/verify") .match_request(move |request| { let Ok(raw) = request.body() else { return false; }; let Ok(serde_json::Value::Object(fields)) = serde_json::from_slice::(raw) else { return false; }; let mut names: Vec<&str> = fields.keys().map(String::as_str).collect(); names.sort_unstable(); names == ["license_key", "product"] && fields["product"] == "simpl-resultat" && fields["license_key"] == sent_key.as_str() }) .with_status(200) .with_header("content-type", "application/json") .with_body(r#"{"valid":true,"machines":[]}"#) .expect(1) .create_async() .await; let result = check_revocation_in(&dir.0, &server.url(), &dec, NOW).await; assert_eq!(result.outcome, RevocationOutcome::Valid); route.assert_async().await; let raw_state = fs::read_to_string(dir.0.join(CHECK_FILE)).unwrap(); assert!(!raw_state.contains(&key), "the key must not be in the state file"); } // === One check at a time (#373) =========================================================== // // Each test has its own turn, standing for `REVOCATION_CHECK_TURN`: tests run on parallel // threads, and a turn shared between them would decide which check goes first. `join!` is // `biased`: its first branch is polled first, every time, so it is the check that starts // first. /// A turn of its own for one test. fn new_turn() -> tauri::async_runtime::Mutex<()> { tauri::async_runtime::Mutex::new(()) } #[tokio::test] async fn two_checks_side_by_side_send_one_request() { // A key entered before the first ready state starts the check twice, from the entry // and from the start. The second waits for the first one's verdict and sends nothing. // Without the turn it would run during the first one's request and read its attempt // only: `revoked: false`. That is what this test sees of a missing turn on one thread; // the double send itself needs two threads between the read and the write of the // state, a window without an await point that no test here can hold open. let (enc, dec) = default_keys(); let key = base_license_with_features(&enc, vec![]); let dir = TestDir::new("check_side_by_side"); dir.write(LICENSE_FILE, &key); let mut server = mockito::Server::new_async().await; let route = verify_route(&mut server, 200, r#"{"valid":false,"reason":"revoked"}"#, 1).await; let url = server.url(); let turn = new_turn(); let (first, second) = tokio::join!( biased; check_revocation_in_turn(&turn, &dir.0, &url, &dec, || NOW), check_revocation_in_turn(&turn, &dir.0, &url, &dec, || NOW), ); assert_eq!(first, RevocationCheck { outcome: RevocationOutcome::Revoked, revoked: true }); assert_eq!(second, RevocationCheck { outcome: RevocationOutcome::NotDue, revoked: true }); route.assert_async().await; } #[tokio::test] async fn the_check_of_a_key_entered_meanwhile_waits_for_the_verdict_in_flight() { // The check of the stored key is in flight when another key is entered, and that // key's check starts at once. `license.check` holds one key: side by side, the first // verdict would land on the second key's attempt, and the second key would be checked // again at the next start, within 24 h. let (enc, dec) = default_keys(); let refunded_key = base_license_with_features(&enc, vec![]); let new_key = base_license_with_features(&enc, vec!["balance".to_string()]); let dir = TestDir::new("check_key_entered_meanwhile"); dir.write(LICENSE_FILE, &refunded_key); let revoked_key = refunded_key.clone(); let mut server = mockito::Server::new_async().await; let route = server .mock("POST", "/v1/licenses/verify") .with_status(200) .with_header("content-type", "application/json") .with_body_from_request(move |request| { let sent = request .body() .ok() .and_then(|raw| serde_json::from_slice::(raw).ok()) .and_then(|body| body["license_key"].as_str().map(str::to_string)); if sent.as_deref() == Some(revoked_key.as_str()) { br#"{"valid":false,"reason":"revoked"}"#.to_vec() } else { br#"{"valid":true,"machines":[]}"#.to_vec() } }) .expect(2) .create_async() .await; let url = server.url(); let turn = new_turn(); let (first, second) = tokio::join!( biased; check_revocation_in_turn(&turn, &dir.0, &url, &dec, || NOW), async { store_license_in(&dir.0, &new_key, &dec).unwrap(); check_revocation_in_turn(&turn, &dir.0, &url, &dec, || NOW).await }, ); assert_eq!(first, RevocationCheck { outcome: RevocationOutcome::Revoked, revoked: true }); assert_eq!(second, RevocationCheck { outcome: RevocationOutcome::Valid, revoked: false }); assert_eq!( stored_state(&dir), Some(serde_json::json!({ "key_sha256": key_digest(&new_key), "attempted_at": NOW, "revoked": false, })) ); // The next start, the same day: the new key's attempt counts, nothing leaves. let next_start = check_revocation_in_turn(&turn, &dir.0, &url, &dec, || NOW + 60).await; assert_eq!(next_start, RevocationCheck { outcome: RevocationOutcome::NotDue, revoked: false }); route.assert_async().await; } #[tokio::test] async fn a_check_reads_the_time_once_its_turn_has_come() { // A check that waited for the one running finds that one's attempt on disk, written a // second after this check was called. Read before the wait, the time would date that // attempt in the future, a check would be due, and a second request would leave. let (enc, dec) = default_keys(); let key = base_license_with_features(&enc, vec![]); let dir = TestDir::new("check_time_after_turn"); dir.write(LICENSE_FILE, &key); let clock = std::sync::atomic::AtomicI64::new(NOW); let mut server = mockito::Server::new_async().await; let any_request = server.mock("POST", mockito::Matcher::Any).expect(0).create_async().await; let url = server.url(); let turn = new_turn(); let running = turn.lock().await; let (dir_ref, clock_ref, key_ref) = (&dir, &clock, &key); let (result, ()) = tokio::join!( biased; check_revocation_in_turn(&turn, &dir.0, &url, &dec, || { clock.load(std::sync::atomic::Ordering::SeqCst) }), async move { // What the running check leaves before it ends: its attempt, a second later. dir_ref.write(CHECK_FILE, check_state(key_ref, NOW + 1, false)); clock_ref.store(NOW + 1, std::sync::atomic::Ordering::SeqCst); drop(running); }, ); assert_eq!(result, RevocationCheck { outcome: RevocationOutcome::NotDue, revoked: false }); any_request.assert_async().await; } #[tokio::test] async fn a_revoked_answer_to_any_license_command_is_recorded_for_the_key_it_sent() { // D4: activation, list and deactivation all learn it the same way the check does. let (enc, dec) = default_keys(); let key = base_license_with_features(&enc, vec![]); let ids = this_machine(); let recorded = |dir: &TestDir| { assert!(is_revoked_in(&dir.0, &key)); assert_eq!(stored_state(dir).unwrap()["attempted_at"], NOW); assert_eq!(entitlements_from_dir(&dir.0, &dec).0, EDITION_FREE); }; let dir = TestDir::new("revoked_by_activation"); dir.write(LICENSE_FILE, &key); let mut server = mockito::Server::new_async().await; let _activate = server .mock("POST", "/v1/licenses/activate") .with_status(403) .with_header("content-type", "application/json") .with_body(REVOKED_ENVELOPE) .create_async() .await; let result = activate_and_store_in(&dir.0, &server.url(), &key, THIS_RAW, None, &dec, NOW).await; assert_eq!(result, Err(LicenseError::LicenseRevoked)); recorded(&dir); let dir = TestDir::new("revoked_by_list"); dir.write(LICENSE_FILE, &key); let mut server = mockito::Server::new_async().await; let _verify = verify_route(&mut server, 200, r#"{"valid":false,"reason":"revoked"}"#, 1).await; let result = list_machines_in(&dir.0, &server.url(), &key, Some(&ids), NOW).await; assert_eq!(result.map(|machines| machines.len()), Err(LicenseError::LicenseRevoked)); recorded(&dir); let dir = TestDir::new("revoked_by_deactivation"); dir.write(LICENSE_FILE, &key); dir.write(ACTIVATION_FILE, activation_for(&enc, THIS_DERIVED)); let mut server = mockito::Server::new_async().await; let _deactivate = server .mock("POST", "/v1/licenses/deactivate") .with_status(403) .with_header("content-type", "application/json") .with_body(REVOKED_ENVELOPE) .create_async() .await; let result = deactivate_in(&dir.0, &server.url(), &key, THIS_DERIVED, Some(&ids), &dec, NOW).await; assert_eq!(result, Err(LicenseError::LicenseRevoked)); recorded(&dir); assert!(dir.0.join(ACTIVATION_FILE).exists(), "a refused deactivation frees no slot"); } #[tokio::test] async fn the_revocation_is_recorded_for_the_key_that_was_sent_not_the_one_on_disk() { // A key replaced while the request was in flight: the answer is about the old one. let (enc, dec) = default_keys(); let sent_key = base_license_with_features(&enc, vec![]); let stored_key = base_license_with_features(&enc, vec!["balance".to_string()]); let dir = TestDir::new("revoked_for_the_sent_key"); dir.write(LICENSE_FILE, &stored_key); let mut server = mockito::Server::new_async().await; let _verify = verify_route(&mut server, 200, r#"{"valid":false,"reason":"revoked"}"#, 1).await; let result = list_machines_in(&dir.0, &server.url(), &sent_key, None, NOW).await; assert_eq!(result.map(|machines| machines.len()), Err(LicenseError::LicenseRevoked)); assert!(is_revoked_in(&dir.0, &sent_key)); assert!(!is_revoked_in(&dir.0, &stored_key)); assert_eq!(entitlements_from_dir(&dir.0, &dec).0, EDITION_BASE); } #[tokio::test] async fn only_a_revoked_answer_is_recorded_by_the_license_commands() { let (enc, dec) = default_keys(); let key = base_license_with_features(&enc, vec![]); let refusals: Vec<(usize, &str, LicenseError)> = vec![ ( 404, r#"{"error":{"code":"license_not_found","message":"x"}}"#, LicenseError::LicenseNotFound, ), ( 403, r#"{"error":{"code":"license_expired","message":"x"}}"#, LicenseError::LicenseExpired, ), ( 409, r#"{"error":{"code":"machine_limit_reached","message":"x"},"machines":[]}"#, LicenseError::MachineLimitReached, ), (502, "Bad Gateway", LicenseError::Server { status: 502 }), ]; for (status, body, expected) in refusals { let dir = TestDir::new("not_recorded"); dir.write(LICENSE_FILE, &key); let mut server = mockito::Server::new_async().await; let _activate = server .mock("POST", "/v1/licenses/activate") .with_status(status) .with_header("content-type", "application/json") .with_body(body) .create_async() .await; let result = activate_and_store_in(&dir.0, &server.url(), &key, THIS_RAW, None, &dec, NOW).await; assert_eq!(result, Err(expected), "status {}", status); assert!(!dir.0.join(CHECK_FILE).exists(), "status {}: nothing to record", status); } // A list that loads and a deactivation that succeeds record nothing either: only the // check writes a "valid" verdict. let dir = TestDir::new("not_recorded_on_success"); dir.write(LICENSE_FILE, &key); let mut server = mockito::Server::new_async().await; let _verify = verify_route(&mut server, 200, r#"{"valid":true,"machines":[]}"#, 1).await; let _deactivate = server .mock("POST", "/v1/licenses/deactivate") .with_status(200) .with_header("content-type", "application/json") .with_body(r#"{"ok":true}"#) .create_async() .await; assert_eq!( list_machines_in(&dir.0, &server.url(), &key, None, NOW).await.map(|m| m.len()), Ok(0) ); assert_eq!( deactivate_in(&dir.0, &server.url(), &key, OTHER_RAW, None, &dec, NOW).await, Ok(()) ); assert!(!dir.0.join(CHECK_FILE).exists()); } // === Removal of the stored key (#370) ====================================================== /// A data directory as a removal finds it: a stored key, this machine's activation token, /// a state file about the key, and a file that is none of them. fn removal_dir(name: &str, enc: &EncodingKey, key: &str) -> TestDir { let dir = TestDir::new(name); dir.write(LICENSE_FILE, key); dir.write(ACTIVATION_FILE, activation_for(enc, THIS_DERIVED)); dir.write(CHECK_FILE, check_state(key, NOW - 60, false)); dir.write("profiles.json", "{}"); dir } /// Every entry of the directory with its contents (empty for a sub-directory), by name. fn snapshot(dir: &TestDir) -> Vec<(String, Vec)> { let mut entries: Vec<(String, Vec)> = fs::read_dir(&dir.0) .unwrap() .map(|entry| { let entry = entry.unwrap(); let contents = fs::read(entry.path()).unwrap_or_default(); (entry.file_name().into_string().unwrap(), contents) }) .collect(); entries.sort(); entries } /// A server that answers nothing: any request it receives fails the test. async fn no_request_expected(server: &mut mockito::ServerGuard) -> mockito::Mock { server.mock("POST", mockito::Matcher::Any).expect(0).create_async().await } /// A deactivation route answering `status` with `body`, expecting one request. async fn deactivate_route(server: &mut mockito::ServerGuard, status: usize, body: &str) -> mockito::Mock { server .mock("POST", "/v1/licenses/deactivate") .with_status(status) .with_header("content-type", "application/json") .with_body(body) .expect(1) .create_async() .await } #[test] fn the_token_removal_tolerates_a_missing_token_and_names_any_other_failure() { let dir = TestDir::new("remove_token_helper"); assert_eq!(remove_activation_token_in(&dir.0), Ok(()), "no token to remove"); assert_eq!(remove_activation_token_in(&dir.0.join("missing")), Ok(()), "no directory"); dir.write(ACTIVATION_FILE, "a token"); assert_eq!(remove_activation_token_in(&dir.0), Ok(())); assert!(!dir.0.join(ACTIVATION_FILE).exists()); // A directory in place of the token: it cannot be deleted as a file. fs::create_dir(dir.0.join(ACTIVATION_FILE)).unwrap(); assert_eq!(remove_activation_token_in(&dir.0), Err(LicenseError::Storage)); } #[tokio::test] async fn a_removal_drops_the_key_and_its_token_and_leaves_everything_else() { let (enc, dec) = default_keys(); let key = base_license_with_features(&enc, vec!["balance".to_string()]); let dir = removal_dir("remove_files", &enc, &key); let state = fs::read(dir.0.join(CHECK_FILE)).unwrap(); let mut server = mockito::Server::new_async().await; let any_request = no_request_expected(&mut server).await; let result = remove_license_in(&dir.0, &server.url(), false, Some(&this_machine()), &dec, NOW).await; assert_eq!(result, Ok(())); any_request.assert_async().await; assert_eq!( snapshot(&dir), vec![(CHECK_FILE.to_string(), state), ("profiles.json".to_string(), b"{}".to_vec())] ); } #[tokio::test] async fn after_a_removal_the_edition_is_free_and_the_check_sends_nothing() { // The last attempt is a day old: a key left on disk would be sent right away. let (enc, dec) = default_keys(); let key = base_license_with_features(&enc, vec!["balance".to_string()]); let dir = removal_dir("remove_then_check", &enc, &key); dir.write(CHECK_FILE, check_state(&key, NOW - 86_400, false)); let mut server = mockito::Server::new_async().await; let any_request = no_request_expected(&mut server).await; remove_license_in(&dir.0, &server.url(), false, Some(&this_machine()), &dec, NOW) .await .unwrap(); assert_eq!(entitlements_from_dir(&dir.0, &dec), (EDITION_FREE.to_string(), Vec::new())); assert_eq!( check_revocation_in(&dir.0, &server.url(), &dec, NOW).await, RevocationCheck { outcome: RevocationOutcome::NoLicense, revoked: false } ); any_request.assert_async().await; } #[tokio::test] async fn a_revoked_key_removed_then_entered_again_stays_free() { // Decision 1: `license.check` stays. Dropped, it would give the key its edition back // until the next check, a way around the revocation open to anyone through a button. let (enc, dec) = default_keys(); let key = base_license_with_features(&enc, vec!["balance".to_string()]); let dir = removal_dir("remove_revoked_then_enter", &enc, &key); dir.write(CHECK_FILE, check_state(&key, NOW, true)); let mut server = mockito::Server::new_async().await; let any_request = no_request_expected(&mut server).await; remove_license_in(&dir.0, &server.url(), true, Some(&this_machine()), &dec, NOW) .await .unwrap(); assert!(store_license_in(&dir.0, &key, &dec).unwrap().revoked); assert_eq!(entitlements_from_dir(&dir.0, &dec), (EDITION_FREE.to_string(), Vec::new())); any_request.assert_async().await; } #[tokio::test] async fn a_removal_without_a_key_succeeds_and_writes_nothing() { let (enc, dec) = default_keys(); let key = base_license_with_features(&enc, vec![]); let mut server = mockito::Server::new_async().await; let any_request = no_request_expected(&mut server).await; for release_slot in [false, true] { // The state of a key removed earlier, and nothing else. let dir = TestDir::new("remove_no_key"); dir.write(CHECK_FILE, check_state(&key, NOW, true)); let before = snapshot(&dir); let result = remove_license_in(&dir.0, &server.url(), release_slot, Some(&this_machine()), &dec, NOW).await; assert_eq!(result, Ok(()), "release_slot {}", release_slot); assert_eq!(snapshot(&dir), before, "release_slot {}", release_slot); // A data directory that does not exist is not created. let missing = dir.0.join("app-data"); let result = remove_license_in(&missing, &server.url(), release_slot, Some(&this_machine()), &dec, NOW).await; assert_eq!(result, Ok(()), "release_slot {}", release_slot); assert!(!missing.exists(), "release_slot {}", release_slot); } any_request.assert_async().await; } #[tokio::test] async fn a_removal_without_a_key_still_drops_a_token_left_behind() { // What a removal whose token could not be deleted leaves: the next one completes it. let (enc, dec) = default_keys(); let dir = TestDir::new("remove_orphan_token"); dir.write(ACTIVATION_FILE, activation_for(&enc, THIS_DERIVED)); let mut server = mockito::Server::new_async().await; let any_request = no_request_expected(&mut server).await; let result = remove_license_in(&dir.0, &server.url(), true, Some(&this_machine()), &dec, NOW).await; assert_eq!(result, Ok(())); assert!(!dir.0.join(ACTIVATION_FILE).exists()); any_request.assert_async().await; } #[tokio::test] async fn a_key_that_cannot_be_deleted_is_storage_and_its_token_stays() { // Never the token gone while the key stays: the card would read "Not activated" next // to a key the user still has. let (enc, dec) = default_keys(); let dir = TestDir::new("remove_key_blocked"); // A directory in place of the key file: it cannot be deleted as a file. fs::create_dir(dir.0.join(LICENSE_FILE)).unwrap(); let token = activation_for(&enc, THIS_DERIVED); dir.write(ACTIVATION_FILE, &token); let mut server = mockito::Server::new_async().await; let any_request = no_request_expected(&mut server).await; let result = remove_license_in(&dir.0, &server.url(), false, Some(&this_machine()), &dec, NOW).await; assert_eq!(result, Err(LicenseError::Storage)); assert_eq!(fs::read_to_string(dir.0.join(ACTIVATION_FILE)).unwrap(), token); any_request.assert_async().await; } #[tokio::test] async fn a_token_that_cannot_be_deleted_is_storage_once_the_key_is_gone() { // The key goes first: it is what the revocation check sends. let (enc, dec) = default_keys(); let dir = TestDir::new("remove_token_blocked"); dir.write(LICENSE_FILE, base_license_with_features(&enc, vec![])); fs::create_dir(dir.0.join(ACTIVATION_FILE)).unwrap(); let mut server = mockito::Server::new_async().await; let any_request = no_request_expected(&mut server).await; let result = remove_license_in(&dir.0, &server.url(), false, Some(&this_machine()), &dec, NOW).await; assert_eq!(result, Err(LicenseError::Storage)); assert!(!dir.0.join(LICENSE_FILE).exists()); any_request.assert_async().await; } #[tokio::test] async fn the_release_carries_the_token_s_identifier_before_anything_is_removed() { // D2: this machine's slot is freed first, under the identifier its token was issued // for, the raw one for an activation made before #350. Read from the server's side, // while the request is in flight: the key and the token are still on disk. let (enc, dec) = default_keys(); let key = base_license_with_features(&enc, vec![]); for token_id in [THIS_DERIVED, THIS_RAW] { let dir = removal_dir("remove_release", &enc, &key); dir.write(ACTIVATION_FILE, activation_for(&enc, token_id)); let (key_path, token_path) = (dir.0.join(LICENSE_FILE), dir.0.join(ACTIVATION_FILE)); let seen = std::sync::Arc::new(std::sync::Mutex::new(None::<(bool, bool)>)); let seen_by_server = seen.clone(); let sent_key = key.clone(); let mut server = mockito::Server::new_async().await; let route = server .mock("POST", "/v1/licenses/deactivate") .match_request(move |request| { *seen_by_server.lock().unwrap() = Some((key_path.exists(), token_path.exists())); let Ok(raw) = request.body() else { return false; }; serde_json::from_slice::(raw).ok() == Some(serde_json::json!({ "product": "simpl-resultat", "license_key": sent_key, "machine_id": token_id, })) }) .with_status(200) .with_header("content-type", "application/json") .with_body(r#"{"ok":true}"#) .expect(1) .create_async() .await; let result = remove_license_in(&dir.0, &server.url(), true, Some(&this_machine()), &dec, NOW).await; assert_eq!(result, Ok(()), "token {}", token_id); route.assert_async().await; assert_eq!(*seen.lock().unwrap(), Some((true, true)), "token {}", token_id); assert!(!dir.0.join(LICENSE_FILE).exists(), "token {}", token_id); assert!(!dir.0.join(ACTIVATION_FILE).exists(), "token {}", token_id); } } #[tokio::test] async fn a_key_that_cannot_be_deleted_after_the_release_keeps_its_token() { // The release must not drop the token itself, as a deactivation from the machine list // does: the key that then fails to go would stay without it. let (enc, dec) = default_keys(); let key = base_license_with_features(&enc, vec![]); let dir = removal_dir("remove_released_key_blocked", &enc, &key); let token = fs::read_to_string(dir.0.join(ACTIVATION_FILE)).unwrap(); let key_path = dir.0.join(LICENSE_FILE); let mut server = mockito::Server::new_async().await; let route = server .mock("POST", "/v1/licenses/deactivate") .match_request(move |_request| { // The key has been read by now: a directory in its place cannot be deleted. let _ = fs::remove_file(&key_path); let _ = fs::create_dir(&key_path); true }) .with_status(200) .with_header("content-type", "application/json") .with_body(r#"{"ok":true}"#) .expect(1) .create_async() .await; let result = remove_license_in(&dir.0, &server.url(), true, Some(&this_machine()), &dec, NOW).await; assert_eq!(result, Err(LicenseError::Storage)); route.assert_async().await; assert_eq!(fs::read_to_string(dir.0.join(ACTIVATION_FILE)).unwrap(), token); } #[tokio::test] async fn a_slot_already_free_lets_the_removal_go_on() { // Error table, first row: the server no longer counts this machine, whatever the // reason, and a revocation is recorded for the key that was sent (#365, D4). let (enc, dec) = default_keys(); let key = base_license_with_features(&enc, vec![]); let rows: Vec<(usize, &str, bool)> = vec![ (404, r#"{"error":{"code":"no_active_activation","message":"x"}}"#, false), (404, r#"{"error":{"code":"license_not_found","message":"x"}}"#, false), (403, REVOKED_ENVELOPE, true), ]; for (status, body, revoked) in rows { let dir = removal_dir("remove_slot_free", &enc, &key); let mut server = mockito::Server::new_async().await; let route = deactivate_route(&mut server, status, body).await; let result = remove_license_in(&dir.0, &server.url(), true, Some(&this_machine()), &dec, NOW).await; assert_eq!(result, Ok(()), "{} {}", status, body); route.assert_async().await; assert!(!dir.0.join(LICENSE_FILE).exists(), "{} {}", status, body); assert!(!dir.0.join(ACTIVATION_FILE).exists(), "{} {}", status, body); let expected_state = if revoked { serde_json::json!({ "key_sha256": key_digest(&key), "attempted_at": NOW, "revoked": true }) } else { serde_json::json!({ "key_sha256": key_digest(&key), "attempted_at": NOW - 60, "revoked": false }) }; assert_eq!(stored_state(&dir), Some(expected_state), "{} {}", status, body); } } #[tokio::test] async fn a_release_that_fails_removes_nothing() { // Error table, second row: the front asks whether to remove the key anyway. Any code // outside the table is returned the same way (decision log of #370). let (enc, dec) = default_keys(); let key = base_license_with_features(&enc, vec![]); let rows: Vec<(usize, &str, LicenseError)> = vec![ (502, "Bad Gateway", LicenseError::Server { status: 502 }), ( 429, r#"{"error":{"code":"rate_limit_exceeded","message":"x","retry_after":42}}"#, LicenseError::RateLimit { retry_after_s: 42 }, ), (200, "OK", LicenseError::InvalidResponse), (400, r#"{"error":{"code":"invalid_input","message":"x"}}"#, LicenseError::InvalidInput), ]; for (status, body, expected) in rows { let dir = removal_dir("remove_release_failed", &enc, &key); let before = snapshot(&dir); let mut server = mockito::Server::new_async().await; let route = deactivate_route(&mut server, status, body).await; let result = remove_license_in(&dir.0, &server.url(), true, Some(&this_machine()), &dec, NOW).await; assert_eq!(result, Err(expected), "{} {}", status, body); route.assert_async().await; assert_eq!(snapshot(&dir), before, "{} {}", status, body); } // Offline. Port 1 on loopback: nothing listens. let dir = removal_dir("remove_release_offline", &enc, &key); let before = snapshot(&dir); let result = remove_license_in(&dir.0, "http://127.0.0.1:1", true, Some(&this_machine()), &dec, NOW).await; assert_eq!(result, Err(LicenseError::Network)); assert_eq!(snapshot(&dir), before); } #[tokio::test] async fn the_release_sends_nothing_without_a_slot_of_this_machine_to_free() { // Error table, third row: `release_slot` is ignored, the removal stays local. let (enc, dec) = default_keys(); let (other_enc, _other_dec) = test_keys([7u8; 32]); let key = base_license_with_features(&enc, vec![]); let machine = this_machine(); // (case, release_slot, token file, key known as revoked, OS id readable) let cases: Vec<(&str, bool, Option, bool, bool)> = vec![ ("release not asked", false, Some(activation_for(&enc, THIS_DERIVED)), false, true), ("no token", true, None, false, true), ("expired token of this machine", true, Some(expired_activation_for(&enc, THIS_DERIVED)), false, true), ("token of another machine", true, Some(activation_for(&enc, OTHER_DERIVED)), false, true), ("token signed by another key", true, Some(activation_for(&other_enc, THIS_DERIVED)), false, true), ("unreadable OS id", true, Some(activation_for(&enc, THIS_DERIVED)), false, false), ("key known as revoked", true, Some(activation_for(&enc, THIS_DERIVED)), true, true), ]; for (case, release_slot, token, revoked, readable_os_id) in cases { let dir = TestDir::new("remove_nothing_sent"); dir.write(LICENSE_FILE, &key); if let Some(token) = &token { dir.write(ACTIVATION_FILE, token); } dir.write(CHECK_FILE, check_state(&key, NOW - 60, revoked)); let mut server = mockito::Server::new_async().await; let any_request = no_request_expected(&mut server).await; let ids = readable_os_id.then_some(&machine); let result = remove_license_in(&dir.0, &server.url(), release_slot, ids, &dec, NOW).await; assert_eq!(result, Ok(()), "{}", case); any_request.assert_async().await; assert!(!dir.0.join(LICENSE_FILE).exists(), "{}", case); assert!(!dir.0.join(ACTIVATION_FILE).exists(), "{}", case); } } #[tokio::test] async fn a_release_with_an_unreadable_key_is_storage_and_removes_nothing() { // The release was asked and cannot be made: nothing goes. Without it, the key is not // read at all, and it goes. let (enc, dec) = default_keys(); let dir = TestDir::new("remove_unreadable_key"); dir.write(LICENSE_FILE, NOT_UTF8); dir.write(ACTIVATION_FILE, activation_for(&enc, THIS_DERIVED)); let before = snapshot(&dir); let mut server = mockito::Server::new_async().await; let any_request = no_request_expected(&mut server).await; let released = remove_license_in(&dir.0, &server.url(), true, Some(&this_machine()), &dec, NOW).await; assert_eq!(released, Err(LicenseError::Storage)); assert_eq!(snapshot(&dir), before); let local = remove_license_in(&dir.0, &server.url(), false, Some(&this_machine()), &dec, NOW).await; assert_eq!(local, Ok(())); assert_eq!(snapshot(&dir), Vec::new()); any_request.assert_async().await; } // === 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"); } } } // === License server calls (issue #270) ========================================================= // // Pure mapping functions are tested with tables; the HTTP layer with an in-process mockito // server passed as `api_base` (same strategy as `fetch_price_inner`). A request that matches // no declared mock gets mockito's 501, so a client still calling a legacy `/licenses/*` path // fails these tests. #[cfg(test)] mod license_server_tests { use super::*; use mockito::Matcher; use serde_json::json; /// JSON the front receives for an error. fn wire(err: &LicenseError) -> serde_json::Value { serde_json::from_str(&license_error_to_string(err)).unwrap() } #[test] fn error_codes_serialize_like_fetch_price_error() { assert_eq!(wire(&LicenseError::LicenseNotFound), json!({"code": "license_not_found"})); assert_eq!( wire(&LicenseError::RateLimit { retry_after_s: 42 }), json!({"code": "rate_limit", "retry_after_s": 42}) ); assert_eq!( wire(&LicenseError::Server { status: 502 }), json!({"code": "server", "status": 502}) ); assert_eq!(wire(&LicenseError::NoLicenseStored), json!({"code": "no_license_stored"})); // Local key-entry errors (#347), read by the same `parseLicenseError`. assert_eq!(wire(&LicenseError::InvalidPrefix), json!({"code": "invalid_prefix"})); assert_eq!(wire(&LicenseError::InvalidSignature), json!({"code": "invalid_signature"})); assert_eq!(wire(&LicenseError::Malformed), json!({"code": "malformed"})); assert_eq!(wire(&LicenseError::UnknownEdition), json!({"code": "unknown_edition"})); } #[test] fn every_envelope_refusal_maps_to_its_code() { let envelope = |code: &str| json!({"error": {"code": code, "message": "x"}}); let rows: Vec<(u16, serde_json::Value, serde_json::Value)> = vec![ (404, envelope("license_not_found"), json!({"code": "license_not_found"})), (403, envelope("license_revoked"), json!({"code": "license_revoked"})), (403, envelope("license_expired"), json!({"code": "license_expired"})), ( 409, json!({"error": {"code": "machine_limit_reached", "message": "Machine limit reached (3)"}, "machines": [{"machine_id": "a", "machine_name": null, "activated_at": "2026-09-01T00:00:00Z", "last_seen_at": "2026-09-01T00:00:00Z"}]}), json!({"code": "machine_limit_reached"}), ), (404, envelope("no_active_activation"), json!({"code": "no_active_activation"})), // A missing route (maximus-api answers `{"error":{"code":"not_found"}}`) or the // proxy's 404 page during a redeploy: a server error, never "key not found". (404, envelope("not_found"), json!({"code": "server", "status": 404})), (404, serde_json::Value::Null, json!({"code": "server", "status": 404})), ( 400, json!({"error": {"code": "invalid_input", "message": "Invalid input"}, "details": {"fieldErrors": {}}}), json!({"code": "invalid_input"}), ), ( 429, json!({"error": {"code": "rate_limit_exceeded", "message": "slow down", "retry_after": 42}}), json!({"code": "rate_limit", "retry_after_s": 42}), ), ( 429, json!({"error": {"code": "rate_limit_exceeded", "message": "slow down"}}), json!({"code": "rate_limit", "retry_after_s": 60}), ), (418, envelope("not_a_known_code"), json!({"code": "server", "status": 418})), (502, serde_json::Value::Null, json!({"code": "server", "status": 502})), ]; for (status, body, expected) in rows { assert_eq!( wire(&error_from_response(status, &body)), expected, "status {} body {}", status, body ); } } #[test] fn verify_answers_map_to_machines_or_codes() { let machines = json!([{"machine_id": "a", "machine_name": "laptop", "activated_at": "2026-09-01T00:00:00Z", "last_seen_at": "2026-09-20T00:00:00Z"}]); let refusals = vec![ (json!({"valid": false, "reason": "not_found"}), json!({"code": "license_not_found"})), (json!({"valid": false, "reason": "revoked"}), json!({"code": "license_revoked"})), (json!({"valid": false, "reason": "expired"}), json!({"code": "license_expired"})), (json!({"valid": false, "reason": "suspended"}), json!({"code": "server", "status": 200})), (json!({"machines": machines}), json!({"code": "invalid_response"})), (json!({"valid": true}), json!({"code": "invalid_response"})), ]; for (body, expected) in refusals { let err = machines_from_verify(&body).expect_err(&body.to_string()); assert_eq!(wire(&err), expected, "body {}", body); } let listed = machines_from_verify(&json!({"valid": true, "machine_limit": 3, "machines": machines})) .expect("valid answer"); assert_eq!(listed.len(), 1); assert_eq!(listed[0].machine_id, "a"); assert_eq!(listed[0].machine_name.as_deref(), Some("laptop")); } #[tokio::test] async fn deactivation_treats_an_already_free_slot_as_done() { let mut server = mockito::Server::new_async().await; let _m = server .mock("POST", "/v1/licenses/deactivate") .with_status(404) .with_header("content-type", "application/json") .with_body(r#"{"error":{"code":"no_active_activation","message":"No active activation found for this machine"}}"#) .create_async() .await; assert_eq!(deactivate_idempotent(&server.url(), "SR-BASE-k", "machine-2").await, Ok(())); // The route's other 404: an unknown key must stay an error, never "deactivated" // (the command would also delete this machine's token). Only error.code decides. let mut unknown = mockito::Server::new_async().await; let _u = unknown .mock("POST", "/v1/licenses/deactivate") .with_status(404) .with_header("content-type", "application/json") .with_body(r#"{"error":{"code":"license_not_found","message":"License not found"}}"#) .create_async() .await; assert_eq!( deactivate_idempotent(&unknown.url(), "SR-BASE-k", "machine-2").await, Err(LicenseError::LicenseNotFound) ); } #[test] fn activate_body_omits_an_unknown_machine_name() { let body = activate_body("SR-BASE-k", "machine-1", None); assert!(body.get("machine_name").is_none(), "body {}", body); assert_eq!(body["product"], "simpl-resultat"); assert_eq!( activate_body("SR-BASE-k", "machine-1", Some("laptop"))["machine_name"], "laptop" ); } #[tokio::test] async fn deactivate_with_an_unreadable_success_body_is_an_invalid_response() { // A 2xx the client cannot read proves nothing: counting it as a success would // make the command delete this machine's local token for nothing. let mut server = mockito::Server::new_async().await; let _m = server .mock("POST", "/v1/licenses/deactivate") .with_status(200) .with_header("content-type", "text/html") .with_body("OK") .create_async() .await; assert_eq!( deactivate_inner(&server.url(), "SR-BASE-k", "machine-2").await, Err(LicenseError::InvalidResponse) ); } #[tokio::test] async fn activate_posts_the_trimmed_key_and_product_to_v1() { let mut server = mockito::Server::new_async().await; let m = server .mock("POST", "/v1/licenses/activate") .match_body(Matcher::PartialJson(json!({ "product": "simpl-resultat", "license_key": "SR-BASE-abc.def.ghi", "machine_id": "machine-1", "machine_name": "laptop", }))) .with_status(200) .with_header("content-type", "application/json") .with_body(r#"{"activation_token":"tok-123"}"#) .create_async() .await; let token = activate_inner(&server.url(), " SR-BASE-abc.def.ghi \n", "machine-1", Some("laptop")) .await .expect("activation token"); assert_eq!(token, "tok-123"); m.assert_async().await; } #[tokio::test] async fn activate_reads_the_v1_error_envelope() { let cases = vec![ (404, r#"{"error":{"code":"license_not_found","message":"License not found"}}"#, LicenseError::LicenseNotFound), (403, r#"{"error":{"code":"license_revoked","message":"License has been revoked"}}"#, LicenseError::LicenseRevoked), ( 409, r#"{"error":{"code":"machine_limit_reached","message":"Machine limit reached (3)"},"machines":[]}"#, LicenseError::MachineLimitReached, ), ]; for (status, body, expected) in cases { let mut server = mockito::Server::new_async().await; let _m = server .mock("POST", "/v1/licenses/activate") .with_status(status) .with_header("content-type", "application/json") .with_body(body) .create_async() .await; let err = activate_inner(&server.url(), "SR-BASE-k", "m", None) .await .expect_err("refusal"); assert_eq!(err, expected, "status {}", status); } } #[tokio::test] async fn activate_html_error_page_is_a_server_error() { let mut server = mockito::Server::new_async().await; let _m = server .mock("POST", "/v1/licenses/activate") .with_status(502) .with_header("content-type", "text/html") .with_body("

502 Bad Gateway

") .create_async() .await; let err = activate_inner(&server.url(), "SR-BASE-k", "m", None) .await .expect_err("502"); assert_eq!(err, LicenseError::Server { status: 502 }); } #[tokio::test] async fn activate_without_a_token_is_an_invalid_response() { let mut server = mockito::Server::new_async().await; let _m = server .mock("POST", "/v1/licenses/activate") .with_status(200) .with_header("content-type", "application/json") .with_body(r#"{"ok":true}"#) .create_async() .await; let err = activate_inner(&server.url(), "SR-BASE-k", "m", None) .await .expect_err("no token"); assert_eq!(err, LicenseError::InvalidResponse); } #[tokio::test] async fn deactivate_posts_key_machine_and_product_to_v1() { let mut server = mockito::Server::new_async().await; let m = server .mock("POST", "/v1/licenses/deactivate") .match_body(Matcher::PartialJson(json!({ "product": "simpl-resultat", "license_key": "SR-BASE-k", "machine_id": "machine-2", }))) .with_status(200) .with_header("content-type", "application/json") .with_body(r#"{"ok":true}"#) .create_async() .await; deactivate_inner(&server.url(), "SR-BASE-k\n", "machine-2") .await .expect("deactivated"); m.assert_async().await; } #[tokio::test] async fn deactivate_reports_no_active_activation() { let mut server = mockito::Server::new_async().await; let _m = server .mock("POST", "/v1/licenses/deactivate") .with_status(404) .with_header("content-type", "application/json") .with_body(r#"{"error":{"code":"no_active_activation","message":"No active activation found for this machine"}}"#) .create_async() .await; let err = deactivate_inner(&server.url(), "SR-BASE-k", "machine-2") .await .expect_err("already free"); assert_eq!(err, LicenseError::NoActiveActivation); } #[tokio::test] async fn verify_posts_product_to_v1_and_lists_machines() { let mut server = mockito::Server::new_async().await; let m = server .mock("POST", "/v1/licenses/verify") .match_body(Matcher::PartialJson(json!({ "product": "simpl-resultat", "license_key": "SR-BASE-k", }))) .with_status(200) .with_header("content-type", "application/json") .with_body( r#"{"valid":true,"edition":"base","email":"x@example.com","machine_limit":3,"active_machines":1, "machines":[{"machine_id":"a","machine_name":null,"activated_at":"2026-09-01T00:00:00.000Z","last_seen_at":"2026-09-20T00:00:00.000Z"}], "expires_at":"2036-09-09T00:00:00.000Z"}"#, ) .create_async() .await; let machines = verify_inner(&server.url(), "SR-BASE-k").await.expect("machines"); assert_eq!(machines.len(), 1); assert_eq!(machines[0].machine_id, "a"); m.assert_async().await; } #[tokio::test] async fn verify_revoked_key_is_a_refusal_not_an_empty_list() { let mut server = mockito::Server::new_async().await; let _m = server .mock("POST", "/v1/licenses/verify") .with_status(200) .with_header("content-type", "application/json") .with_body(r#"{"valid":false,"reason":"revoked"}"#) .create_async() .await; let err = verify_inner(&server.url(), "SR-BASE-k").await.expect_err("revoked"); assert_eq!(err, LicenseError::LicenseRevoked); } #[tokio::test] async fn verify_rate_limit_and_html_outage() { let mut server = mockito::Server::new_async().await; let _m = server .mock("POST", "/v1/licenses/verify") .with_status(429) .with_header("content-type", "application/json") .with_body(r#"{"error":{"code":"rate_limit_exceeded","message":"Too many requests","retry_after":42}}"#) .create_async() .await; assert_eq!( verify_inner(&server.url(), "SR-BASE-k").await.expect_err("429"), LicenseError::RateLimit { retry_after_s: 42 } ); let mut outage = mockito::Server::new_async().await; let _o = outage .mock("POST", "/v1/licenses/verify") .with_status(503) .with_header("content-type", "text/html") .with_body("Service Unavailable") .create_async() .await; assert_eq!( verify_inner(&outage.url(), "SR-BASE-k").await.expect_err("503"), LicenseError::Server { status: 503 } ); } /// Regression for #270: a server that only knows the `/v1` routes. Before the fix the /// client called `/licenses/*`; here that would get mockito's 501 and fail. #[tokio::test] async fn all_three_calls_work_against_v1_only_routes() { let mut server = mockito::Server::new_async().await; let a = server .mock("POST", "/v1/licenses/activate") .match_body(Matcher::PartialJson(json!({"product": "simpl-resultat"}))) .with_status(200) .with_header("content-type", "application/json") .with_body(r#"{"activation_token":"t"}"#) .create_async() .await; let v = server .mock("POST", "/v1/licenses/verify") .match_body(Matcher::PartialJson(json!({"product": "simpl-resultat"}))) .with_status(200) .with_header("content-type", "application/json") .with_body(r#"{"valid":true,"machines":[]}"#) .create_async() .await; let d = server .mock("POST", "/v1/licenses/deactivate") .match_body(Matcher::PartialJson(json!({"product": "simpl-resultat"}))) .with_status(200) .with_header("content-type", "application/json") .with_body(r#"{"ok":true}"#) .create_async() .await; let base = server.url(); assert_eq!(activate_inner(&base, "SR-BASE-k", "m", None).await, Ok("t".to_string())); assert_eq!(verify_inner(&base, "SR-BASE-k").await.map(|m| m.len()), Ok(0)); assert_eq!(deactivate_inner(&base, "SR-BASE-k", "m").await, Ok(())); a.assert_async().await; v.assert_async().await; d.assert_async().await; } #[tokio::test] async fn unreachable_server_is_a_network_error() { // Port 1 on loopback: nothing listens, the connection is refused at once. assert_eq!( verify_inner("http://127.0.0.1:1", "SR-BASE-k").await.expect_err("refused"), LicenseError::Network ); } }