"Remove the key" on the License card removes license.key, then the activation token, and keeps license.check, so a revoked key removed then entered again stays Free. Removing the key stops the revocation check, which until now only storing another key could stop. When this machine is activated with a valid key, its slot is freed on the license server first (remove_license with release_slot). A release that fails removes nothing; on network, server, rate_limit or invalid_response the card asks whether to remove the key anyway, the slot then staying occupied. no_active_activation, license_not_found and license_revoked (recorded for the key sent) mean the slot is free. - Rust: remove_license / remove_license_in, release_this_machine_slot, this_machine_token_id, and the shared remove_activation_token_in helper, now also used by store_license_in and the deactivation. - Front: removeLicense service; performRemoveLicense in useLicenseMachines (confirmations by state, error table, in-app log lines); removing and removeError in the machines reducer, which lock key entry, Activate and Deactivate; the button for any stored key, valid or revoked. - Docs: user guide, in-app tips, architecture, ADR 0017 amendment, changelogs. Resolves #370 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
3864 lines
168 KiB
Rust
3864 lines
168 KiB
Rust
// License validation, storage and reading for the Base/Premium editions.
|
|
//
|
|
// Architecture:
|
|
// - License key = "SR-BASE-<JWT>" or "SR-PREMIUM-<JWT>", 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<String>,
|
|
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<String>,
|
|
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<PathBuf, String> {
|
|
app.path()
|
|
.app_data_dir()
|
|
.map_err(|e| format!("Cannot get app data dir: {}", e))
|
|
}
|
|
|
|
fn license_path(app: &tauri::AppHandle) -> Result<PathBuf, String> {
|
|
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, LicenseError> {
|
|
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<LicenseInfo, LicenseError> {
|
|
let jwt = strip_prefix(key)?;
|
|
let validation = strict_validation();
|
|
|
|
let data = decode::<LicenseClaims>(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<String, String> {
|
|
let validation = strict_validation();
|
|
|
|
let data = decode::<ActivationClaims>(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<LicenseInfo, String> {
|
|
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<LicenseInfo, String> {
|
|
store_license_impl(&app, &key).map_err(|e| license_error_to_string(&e))
|
|
}
|
|
|
|
fn store_license_impl(app: &tauri::AppHandle, key: &str) -> Result<LicenseInfo, LicenseError> {
|
|
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<LicenseInfo, LicenseError> {
|
|
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<Option<LicenseInfo>, 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<Option<LicenseInfo>, 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<String, String> {
|
|
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<String> {
|
|
#[cfg(feature = "dev-override")]
|
|
{
|
|
if let Ok(edition) = std::env::var("SR_DEV_EDITION") {
|
|
if edition == EDITION_FREE || edition == EDITION_BASE || edition == EDITION_PREMIUM {
|
|
return Some(edition);
|
|
}
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
/// 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<String>) {
|
|
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<String>) {
|
|
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<String>) {
|
|
// Dev-only tier testing — compiled out of normal builds (see the helper).
|
|
if let Some(edition) = dev_override_edition() {
|
|
return (edition, Vec::new());
|
|
}
|
|
|
|
// Check Compte Maximus subscription first — Premium overrides Base. This
|
|
// path never reads the license JWT, so it carries no signed features.
|
|
if let Some(edition) = check_account_edition(app) {
|
|
if edition == EDITION_PREMIUM {
|
|
return (edition, Vec::new());
|
|
}
|
|
}
|
|
|
|
let free = || (EDITION_FREE.to_string(), Vec::new());
|
|
|
|
let Ok(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<String> {
|
|
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<LicenseCheck> {
|
|
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<T>(
|
|
dir: &Path,
|
|
license_key: &str,
|
|
result: Result<T, LicenseError>,
|
|
now: i64,
|
|
) -> Result<T, LicenseError> {
|
|
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<Sha256>;
|
|
|
|
/// 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<String, LicenseError> {
|
|
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<Self, LicenseError> {
|
|
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<String, LicenseError> {
|
|
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, LicenseError> {
|
|
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<serde_json::Value, LicenseError> {
|
|
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::<serde_json::Value>()
|
|
.await
|
|
.map_err(|_| LicenseError::InvalidResponse)
|
|
} else {
|
|
let body = resp
|
|
.json::<serde_json::Value>()
|
|
.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<String, LicenseError> {
|
|
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<serde_json::Value, LicenseError> {
|
|
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<Vec<MachineInfo>, 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<Vec<MachineInfo>, 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::<MachineInfo>(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<Option<String>, 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<String>,
|
|
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<MachineInfo>, ids: Option<&MachineIds>) -> Vec<MachineInfo> {
|
|
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<Vec<MachineInfo>, 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<Vec<MachineInfo>, 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<String> {
|
|
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<Vec<MachineInfo>, 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<Vec<MachineInfo>, 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<ActivationStatus, String> {
|
|
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<String> {
|
|
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<RevocationCheck, String> {
|
|
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<u8> {
|
|
// 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<T: serde::Serialize>(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<String>,
|
|
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>) -> 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<MachineInfo>| -> 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<u8>, &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<serde_json::Value> {
|
|
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<u8>)> = 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, "<html>Service Unavailable</html>", Unreachable, false, true),
|
|
(200, "<html>OK</html>", 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<String>, Option<String>, 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<String>)> = 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, "<html>Service Unavailable</html>", 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::<String>));
|
|
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::<serde_json::Value>(&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::<serde_json::Value>(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::<serde_json::Value>(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, "<html>Bad Gateway</html>", 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<u8>)> {
|
|
let mut entries: Vec<(String, Vec<u8>)> = 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::<serde_json::Value>(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, "<html>Bad Gateway</html>", LicenseError::Server { status: 502 }),
|
|
(
|
|
429,
|
|
r#"{"error":{"code":"rate_limit_exceeded","message":"x","retry_after":42}}"#,
|
|
LicenseError::RateLimit { retry_after_s: 42 },
|
|
),
|
|
(200, "<html>OK</html>", 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<String>, 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("<html>OK</html>")
|
|
.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("<html><body><h1>502 Bad Gateway</h1></body></html>")
|
|
.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("<html>Service Unavailable</html>")
|
|
.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
|
|
);
|
|
}
|
|
}
|