Compare commits
2 commits
6d67ab8935
...
174c07de51
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
174c07de51 | ||
|
|
a9eacc8b9a |
12 changed files with 559 additions and 5 deletions
|
|
@ -2,6 +2,9 @@
|
||||||
|
|
||||||
## [Non publié]
|
## [Non publié]
|
||||||
|
|
||||||
|
### Modifié
|
||||||
|
- Les mises à jour automatiques sont maintenant réservées à l'édition Base ; l'édition Gratuite affiche un message invitant à activer une licence (#48)
|
||||||
|
|
||||||
## [0.6.7] - 2026-03-29
|
## [0.6.7] - 2026-03-29
|
||||||
|
|
||||||
### Modifié
|
### Modifié
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,9 @@
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Automatic updates are now gated behind the Base edition entitlement; the Free edition shows an upgrade hint instead of fetching updates (#48)
|
||||||
|
|
||||||
## [0.6.7] - 2026-03-29
|
## [0.6.7] - 2026-03-29
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
|
||||||
|
|
@ -35,4 +35,8 @@ walkdir = "2"
|
||||||
aes-gcm = "0.10"
|
aes-gcm = "0.10"
|
||||||
argon2 = "0.5"
|
argon2 = "0.5"
|
||||||
rand = "0.8"
|
rand = "0.8"
|
||||||
|
jsonwebtoken = "9"
|
||||||
|
machine-uid = "0.5"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
ed25519-dalek = { version = "2", features = ["pkcs8", "pem", "rand_core"] }
|
||||||
|
|
|
||||||
67
src-tauri/src/commands/entitlements.rs
Normal file
67
src-tauri/src/commands/entitlements.rs
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
// Centralized feature → tier mapping for license entitlements.
|
||||||
|
//
|
||||||
|
// This module is the single source of truth for which features are gated by which tier.
|
||||||
|
// To change what is gated where, modify FEATURE_TIERS only — never sprinkle edition checks
|
||||||
|
// throughout the codebase.
|
||||||
|
|
||||||
|
/// Editions, ordered from least to most privileged.
|
||||||
|
pub const EDITION_FREE: &str = "free";
|
||||||
|
pub const EDITION_BASE: &str = "base";
|
||||||
|
pub const EDITION_PREMIUM: &str = "premium";
|
||||||
|
|
||||||
|
/// Maps feature name → list of editions allowed to use it.
|
||||||
|
/// A feature absent from this list is denied for all editions.
|
||||||
|
const FEATURE_TIERS: &[(&str, &[&str])] = &[
|
||||||
|
("auto-update", &[EDITION_BASE, EDITION_PREMIUM]),
|
||||||
|
("web-sync", &[EDITION_PREMIUM]),
|
||||||
|
("cloud-backup", &[EDITION_PREMIUM]),
|
||||||
|
("advanced-reports", &[EDITION_PREMIUM]),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Pure check: does `edition` grant access to `feature`?
|
||||||
|
pub fn is_feature_allowed(feature: &str, edition: &str) -> bool {
|
||||||
|
FEATURE_TIERS
|
||||||
|
.iter()
|
||||||
|
.find(|(name, _)| *name == feature)
|
||||||
|
.map(|(_, tiers)| tiers.contains(&edition))
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn check_entitlement(app: tauri::AppHandle, feature: String) -> Result<bool, String> {
|
||||||
|
let edition = crate::commands::license_commands::current_edition(&app);
|
||||||
|
Ok(is_feature_allowed(&feature, &edition))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn free_blocks_auto_update() {
|
||||||
|
assert!(!is_feature_allowed("auto-update", EDITION_FREE));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn base_unlocks_auto_update() {
|
||||||
|
assert!(is_feature_allowed("auto-update", EDITION_BASE));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn premium_unlocks_everything() {
|
||||||
|
assert!(is_feature_allowed("auto-update", EDITION_PREMIUM));
|
||||||
|
assert!(is_feature_allowed("web-sync", EDITION_PREMIUM));
|
||||||
|
assert!(is_feature_allowed("cloud-backup", EDITION_PREMIUM));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn base_does_not_unlock_premium_features() {
|
||||||
|
assert!(!is_feature_allowed("web-sync", EDITION_BASE));
|
||||||
|
assert!(!is_feature_allowed("cloud-backup", EDITION_BASE));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unknown_feature_denied() {
|
||||||
|
assert!(!is_feature_allowed("nonexistent", EDITION_PREMIUM));
|
||||||
|
}
|
||||||
|
}
|
||||||
426
src-tauri/src/commands/license_commands.rs
Normal file
426
src-tauri/src/commands/license_commands.rs
Normal file
|
|
@ -0,0 +1,426 @@
|
||||||
|
// 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, binds the license to a machine
|
||||||
|
// (machine_id claim must match the local machine_id). Without it, a copied license.key would
|
||||||
|
// work on any machine. Activation tokens are issued by the server in a separate flow (Issue #49).
|
||||||
|
// - Both files live in app_data_dir/ — license.key and activation.token
|
||||||
|
// - get_edition() returns "free" unless BOTH license JWT is valid (signature + exp) AND
|
||||||
|
// either there is no activation token (graceful pre-activation state) OR the activation token
|
||||||
|
// matches the local machine_id.
|
||||||
|
//
|
||||||
|
// CWE-613: every license JWT MUST carry an `exp` claim. We reject licenses without it.
|
||||||
|
|
||||||
|
use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use tauri::Manager;
|
||||||
|
|
||||||
|
use super::entitlements::{EDITION_BASE, EDITION_FREE, EDITION_PREMIUM};
|
||||||
|
|
||||||
|
// Ed25519 public key for license verification.
|
||||||
|
//
|
||||||
|
// IMPORTANT: this PEM is a development placeholder taken from RFC 8410 §10.3 test vectors.
|
||||||
|
// The matching private key is publicly known, so any license signed with it offers no real
|
||||||
|
// protection. Replace this constant with the production public key before shipping a paid
|
||||||
|
// release. The corresponding private key MUST live only on the license server (Issue #49).
|
||||||
|
const PUBLIC_KEY_PEM: &str = "-----BEGIN PUBLIC KEY-----\n\
|
||||||
|
MCowBQYDK2VwAyEAGb9ECWmEzf6FQbrBZ9w7lshQhqowtrbLDFw4rXAxZuE=\n\
|
||||||
|
-----END PUBLIC KEY-----\n";
|
||||||
|
|
||||||
|
const LICENSE_FILE: &str = "license.key";
|
||||||
|
const ACTIVATION_FILE: &str = "activation.token";
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn activation_path(app: &tauri::AppHandle) -> Result<PathBuf, String> {
|
||||||
|
Ok(app_data_dir(app)?.join(ACTIVATION_FILE))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Strip the human-readable prefix and return the bare JWT.
|
||||||
|
fn strip_prefix(key: &str) -> Result<&str, String> {
|
||||||
|
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("License key must start with SR-BASE- or SR-PREMIUM-".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure validation: decode the JWT, verify signature against `public_key_pem`, 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, public_key_pem: &[u8]) -> Result<LicenseInfo, String> {
|
||||||
|
let jwt = strip_prefix(key)?;
|
||||||
|
|
||||||
|
let decoding_key = DecodingKey::from_ed_pem(public_key_pem)
|
||||||
|
.map_err(|e| format!("Invalid public key: {}", e))?;
|
||||||
|
|
||||||
|
let mut validation = Validation::new(Algorithm::EdDSA);
|
||||||
|
// jsonwebtoken validates `exp` by default; assert this explicitly so a future config
|
||||||
|
// change cannot silently disable it (CWE-613).
|
||||||
|
validation.validate_exp = true;
|
||||||
|
validation.leeway = 0;
|
||||||
|
validation.set_required_spec_claims(&["exp", "iat"]);
|
||||||
|
|
||||||
|
let data = decode::<LicenseClaims>(jwt, &decoding_key, &validation)
|
||||||
|
.map_err(|e| format!("Invalid license: {}", e))?;
|
||||||
|
|
||||||
|
let claims = data.claims;
|
||||||
|
if claims.edition != EDITION_BASE && claims.edition != EDITION_PREMIUM {
|
||||||
|
return Err(format!("Unknown edition '{}'", claims.edition));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(LicenseInfo {
|
||||||
|
edition: claims.edition,
|
||||||
|
email: claims.sub,
|
||||||
|
features: claims.features,
|
||||||
|
machine_limit: claims.machine_limit,
|
||||||
|
issued_at: claims.iat,
|
||||||
|
expires_at: claims.exp,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate an activation token against the local machine. The token must be signed by the
|
||||||
|
/// license server and its `machine_id` claim must match the local machine identifier.
|
||||||
|
fn validate_activation_with_key(
|
||||||
|
token: &str,
|
||||||
|
local_machine_id: &str,
|
||||||
|
public_key_pem: &[u8],
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let decoding_key = DecodingKey::from_ed_pem(public_key_pem)
|
||||||
|
.map_err(|e| format!("Invalid public key: {}", e))?;
|
||||||
|
|
||||||
|
let mut validation = Validation::new(Algorithm::EdDSA);
|
||||||
|
validation.validate_exp = true;
|
||||||
|
validation.leeway = 0;
|
||||||
|
validation.set_required_spec_claims(&["exp", "iat"]);
|
||||||
|
|
||||||
|
let data = decode::<ActivationClaims>(token.trim(), &decoding_key, &validation)
|
||||||
|
.map_err(|e| format!("Invalid activation token: {}", e))?;
|
||||||
|
|
||||||
|
if data.claims.machine_id != local_machine_id {
|
||||||
|
return Err("Activation token belongs to a different machine".to_string());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Tauri commands ===========================================================================
|
||||||
|
|
||||||
|
/// Validate a license key without persisting it. Used by the UI to give immediate feedback
|
||||||
|
/// before the user confirms storage.
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn validate_license_key(key: String) -> Result<LicenseInfo, String> {
|
||||||
|
validate_with_key(&key, PUBLIC_KEY_PEM.as_bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persist a previously-validated license key to disk. The activation token (machine binding)
|
||||||
|
/// is stored separately by [`store_activation_token`] once the server has issued one.
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn store_license(app: tauri::AppHandle, key: String) -> Result<LicenseInfo, String> {
|
||||||
|
let info = validate_with_key(&key, PUBLIC_KEY_PEM.as_bytes())?;
|
||||||
|
let path = license_path(&app)?;
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent).map_err(|e| format!("Cannot create app data dir: {}", e))?;
|
||||||
|
}
|
||||||
|
fs::write(&path, key.trim()).map_err(|e| format!("Cannot write license file: {}", e))?;
|
||||||
|
Ok(info)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persist a server-issued activation token (machine binding). The token is opaque to the
|
||||||
|
/// caller — it must validate against the local machine_id to be considered active.
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn store_activation_token(app: tauri::AppHandle, token: String) -> Result<(), String> {
|
||||||
|
let local_id = machine_id_internal()?;
|
||||||
|
validate_activation_with_key(&token, &local_id, PUBLIC_KEY_PEM.as_bytes())?;
|
||||||
|
let path = activation_path(&app)?;
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent).map_err(|e| format!("Cannot create app data dir: {}", e))?;
|
||||||
|
}
|
||||||
|
fs::write(&path, token.trim()).map_err(|e| format!("Cannot write activation file: {}", e))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read the stored license without revalidating. Returns `None` when no license is present.
|
||||||
|
/// The returned info is only structurally decoded — call [`get_edition`] for the gating value.
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn read_license(app: tauri::AppHandle) -> Result<Option<LicenseInfo>, String> {
|
||||||
|
let path = license_path(&app)?;
|
||||||
|
if !path.exists() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let key = fs::read_to_string(&path).map_err(|e| format!("Cannot read license file: {}", e))?;
|
||||||
|
Ok(validate_with_key(&key, PUBLIC_KEY_PEM.as_bytes()).ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the active edition (`"free"`, `"base"`, or `"premium"`) for use by feature gates.
|
||||||
|
///
|
||||||
|
/// Returns "free" when:
|
||||||
|
/// - no license is stored,
|
||||||
|
/// - the license JWT is invalid or expired,
|
||||||
|
/// - an activation token exists but does not match this machine.
|
||||||
|
///
|
||||||
|
/// Note: a missing activation token is treated as a graceful pre-activation state and does
|
||||||
|
/// NOT downgrade the edition. Server-side activation happens later (Issue #53).
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_edition(app: tauri::AppHandle) -> Result<String, String> {
|
||||||
|
Ok(current_edition(&app))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Internal helper used by `entitlements::check_entitlement`. Never returns an error — any
|
||||||
|
/// failure resolves to "free" so feature gates fail closed.
|
||||||
|
pub(crate) fn current_edition(app: &tauri::AppHandle) -> String {
|
||||||
|
let Ok(path) = license_path(app) else {
|
||||||
|
return EDITION_FREE.to_string();
|
||||||
|
};
|
||||||
|
if !path.exists() {
|
||||||
|
return EDITION_FREE.to_string();
|
||||||
|
}
|
||||||
|
let Ok(key) = fs::read_to_string(&path) else {
|
||||||
|
return EDITION_FREE.to_string();
|
||||||
|
};
|
||||||
|
let Ok(info) = validate_with_key(&key, PUBLIC_KEY_PEM.as_bytes()) else {
|
||||||
|
return EDITION_FREE.to_string();
|
||||||
|
};
|
||||||
|
|
||||||
|
// If an activation token exists, it must match the local machine. A missing token is
|
||||||
|
// accepted (graceful pre-activation).
|
||||||
|
if let Ok(activation_path) = activation_path(app) {
|
||||||
|
if activation_path.exists() {
|
||||||
|
let Ok(token) = fs::read_to_string(&activation_path) else {
|
||||||
|
return EDITION_FREE.to_string();
|
||||||
|
};
|
||||||
|
let Ok(local_id) = machine_id_internal() else {
|
||||||
|
return EDITION_FREE.to_string();
|
||||||
|
};
|
||||||
|
if validate_activation_with_key(&token, &local_id, PUBLIC_KEY_PEM.as_bytes()).is_err()
|
||||||
|
{
|
||||||
|
return EDITION_FREE.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
info.edition
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cross-platform machine identifier. Stable across reboots; will change after an OS reinstall
|
||||||
|
/// or hardware migration, in which case the user must re-activate (handled in Issue #53).
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_machine_id() -> Result<String, String> {
|
||||||
|
machine_id_internal()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn machine_id_internal() -> Result<String, String> {
|
||||||
|
machine_uid::get().map_err(|e| format!("Cannot read machine id: {}", e))
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Tests ====================================================================================
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use ed25519_dalek::pkcs8::{EncodePrivateKey, EncodePublicKey, LineEnding};
|
||||||
|
use ed25519_dalek::SigningKey;
|
||||||
|
use jsonwebtoken::{encode, EncodingKey, Header};
|
||||||
|
|
||||||
|
/// Build a deterministic test keypair so signed tokens are reproducible across runs.
|
||||||
|
fn test_keypair() -> (String, String) {
|
||||||
|
let seed = [42u8; 32];
|
||||||
|
let signing_key = SigningKey::from_bytes(&seed);
|
||||||
|
let verifying_key = signing_key.verifying_key();
|
||||||
|
let private_pem = signing_key
|
||||||
|
.to_pkcs8_pem(LineEnding::LF)
|
||||||
|
.unwrap()
|
||||||
|
.to_string();
|
||||||
|
let public_pem = verifying_key.to_public_key_pem(LineEnding::LF).unwrap();
|
||||||
|
(private_pem, public_pem)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_token(private_pem: &str, claims: &LicenseClaims) -> String {
|
||||||
|
let key = EncodingKey::from_ed_pem(private_pem.as_bytes()).unwrap();
|
||||||
|
encode(&Header::new(Algorithm::EdDSA), claims, &key).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_activation_token(private_pem: &str, claims: &ActivationClaims) -> String {
|
||||||
|
let key = EncodingKey::from_ed_pem(private_pem.as_bytes()).unwrap();
|
||||||
|
encode(&Header::new(Algorithm::EdDSA), claims, &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 (_priv, public_pem) = test_keypair();
|
||||||
|
let result = validate_with_key("nonsense", public_pem.as_bytes());
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn accepts_well_formed_base_license() {
|
||||||
|
let (private_pem, public_pem) = test_keypair();
|
||||||
|
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(&private_pem, &claims);
|
||||||
|
let key = format!("{}{}", KEY_PREFIX_BASE, jwt);
|
||||||
|
let info = validate_with_key(&key, public_pem.as_bytes()).unwrap();
|
||||||
|
assert_eq!(info.edition, EDITION_BASE);
|
||||||
|
assert_eq!(info.email, "user@example.com");
|
||||||
|
assert_eq!(info.machine_limit, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_expired_license() {
|
||||||
|
let (private_pem, public_pem) = test_keypair();
|
||||||
|
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(&private_pem, &claims);
|
||||||
|
let key = format!("{}{}", KEY_PREFIX_BASE, jwt);
|
||||||
|
let result = validate_with_key(&key, public_pem.as_bytes());
|
||||||
|
assert!(result.is_err(), "expired license must be rejected");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_license_signed_with_wrong_key() {
|
||||||
|
let (private_pem, _public_pem) = test_keypair();
|
||||||
|
// Generate a different keypair to verify with
|
||||||
|
let other_seed = [7u8; 32];
|
||||||
|
let other_signing = SigningKey::from_bytes(&other_seed);
|
||||||
|
let other_public = other_signing
|
||||||
|
.verifying_key()
|
||||||
|
.to_public_key_pem(LineEnding::LF)
|
||||||
|
.unwrap();
|
||||||
|
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(&private_pem, &claims);
|
||||||
|
let key = format!("{}{}", KEY_PREFIX_BASE, jwt);
|
||||||
|
let result = validate_with_key(&key, other_public.as_bytes());
|
||||||
|
assert!(result.is_err(), "wrong-key signature must be rejected");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_corrupted_jwt() {
|
||||||
|
let (_priv, public_pem) = test_keypair();
|
||||||
|
let key = format!("{}not.a.real.jwt", KEY_PREFIX_BASE);
|
||||||
|
let result = validate_with_key(&key, public_pem.as_bytes());
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_unknown_edition() {
|
||||||
|
let (private_pem, public_pem) = test_keypair();
|
||||||
|
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(&private_pem, &claims);
|
||||||
|
let key = format!("{}{}", KEY_PREFIX_BASE, jwt);
|
||||||
|
let result = validate_with_key(&key, public_pem.as_bytes());
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn activation_token_matches_machine() {
|
||||||
|
let (private_pem, public_pem) = test_keypair();
|
||||||
|
let claims = ActivationClaims {
|
||||||
|
sub: "license-id".to_string(),
|
||||||
|
iat: now(),
|
||||||
|
exp: now() + 86400,
|
||||||
|
machine_id: "this-machine".to_string(),
|
||||||
|
};
|
||||||
|
let token = make_activation_token(&private_pem, &claims);
|
||||||
|
assert!(validate_activation_with_key(&token, "this-machine", public_pem.as_bytes()).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn activation_token_rejects_other_machine() {
|
||||||
|
let (private_pem, public_pem) = test_keypair();
|
||||||
|
let claims = ActivationClaims {
|
||||||
|
sub: "license-id".to_string(),
|
||||||
|
iat: now(),
|
||||||
|
exp: now() + 86400,
|
||||||
|
machine_id: "machine-A".to_string(),
|
||||||
|
};
|
||||||
|
let token = make_activation_token(&private_pem, &claims);
|
||||||
|
let result = validate_activation_with_key(&token, "machine-B", public_pem.as_bytes());
|
||||||
|
assert!(result.is_err(), "copied activation token must be rejected");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,11 @@
|
||||||
pub mod fs_commands;
|
pub mod entitlements;
|
||||||
pub mod export_import_commands;
|
pub mod export_import_commands;
|
||||||
|
pub mod fs_commands;
|
||||||
|
pub mod license_commands;
|
||||||
pub mod profile_commands;
|
pub mod profile_commands;
|
||||||
|
|
||||||
pub use fs_commands::*;
|
pub use entitlements::*;
|
||||||
pub use export_import_commands::*;
|
pub use export_import_commands::*;
|
||||||
|
pub use fs_commands::*;
|
||||||
|
pub use license_commands::*;
|
||||||
pub use profile_commands::*;
|
pub use profile_commands::*;
|
||||||
|
|
|
||||||
|
|
@ -114,6 +114,13 @@ pub fn run() {
|
||||||
commands::hash_pin,
|
commands::hash_pin,
|
||||||
commands::verify_pin,
|
commands::verify_pin,
|
||||||
commands::repair_migrations,
|
commands::repair_migrations,
|
||||||
|
commands::validate_license_key,
|
||||||
|
commands::store_license,
|
||||||
|
commands::store_activation_token,
|
||||||
|
commands::read_license,
|
||||||
|
commands::get_edition,
|
||||||
|
commands::get_machine_id,
|
||||||
|
commands::check_entitlement,
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { AlertTriangle, ChevronDown, ChevronUp, RefreshCw, Download, Mail, Bug } from "lucide-react";
|
import { AlertTriangle, ChevronDown, ChevronUp, RefreshCw, Download, Mail, Bug } from "lucide-react";
|
||||||
import { check } from "@tauri-apps/plugin-updater";
|
import { check } from "@tauri-apps/plugin-updater";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
interface ErrorPageProps {
|
interface ErrorPageProps {
|
||||||
error?: string;
|
error?: string;
|
||||||
|
|
@ -10,7 +11,7 @@ interface ErrorPageProps {
|
||||||
export default function ErrorPage({ error }: ErrorPageProps) {
|
export default function ErrorPage({ error }: ErrorPageProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [showDetails, setShowDetails] = useState(false);
|
const [showDetails, setShowDetails] = useState(false);
|
||||||
const [updateStatus, setUpdateStatus] = useState<"idle" | "checking" | "available" | "upToDate" | "error">("idle");
|
const [updateStatus, setUpdateStatus] = useState<"idle" | "checking" | "available" | "upToDate" | "notEntitled" | "error">("idle");
|
||||||
const [updateVersion, setUpdateVersion] = useState<string | null>(null);
|
const [updateVersion, setUpdateVersion] = useState<string | null>(null);
|
||||||
const [updateError, setUpdateError] = useState<string | null>(null);
|
const [updateError, setUpdateError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
|
@ -18,6 +19,13 @@ export default function ErrorPage({ error }: ErrorPageProps) {
|
||||||
setUpdateStatus("checking");
|
setUpdateStatus("checking");
|
||||||
setUpdateError(null);
|
setUpdateError(null);
|
||||||
try {
|
try {
|
||||||
|
const allowed = await invoke<boolean>("check_entitlement", {
|
||||||
|
feature: "auto-update",
|
||||||
|
});
|
||||||
|
if (!allowed) {
|
||||||
|
setUpdateStatus("notEntitled");
|
||||||
|
return;
|
||||||
|
}
|
||||||
const update = await check();
|
const update = await check();
|
||||||
if (update) {
|
if (update) {
|
||||||
setUpdateStatus("available");
|
setUpdateStatus("available");
|
||||||
|
|
@ -89,6 +97,11 @@ export default function ErrorPage({ error }: ErrorPageProps) {
|
||||||
{t("error.upToDate")}
|
{t("error.upToDate")}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
{updateStatus === "notEntitled" && (
|
||||||
|
<p className="text-sm text-[var(--muted-foreground)]">
|
||||||
|
{t("error.updateNotEntitled")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
{updateStatus === "error" && updateError && (
|
{updateStatus === "error" && updateError && (
|
||||||
<p className="text-sm text-[var(--destructive)]">{updateError}</p>
|
<p className="text-sm text-[var(--destructive)]">{updateError}</p>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useReducer, useCallback, useRef } from "react";
|
import { useReducer, useCallback, useRef } from "react";
|
||||||
import { check, type Update } from "@tauri-apps/plugin-updater";
|
import { check, type Update } from "@tauri-apps/plugin-updater";
|
||||||
import { relaunch } from "@tauri-apps/plugin-process";
|
import { relaunch } from "@tauri-apps/plugin-process";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
type UpdateStatus =
|
type UpdateStatus =
|
||||||
| "idle"
|
| "idle"
|
||||||
|
|
@ -10,6 +11,7 @@ type UpdateStatus =
|
||||||
| "downloading"
|
| "downloading"
|
||||||
| "readyToInstall"
|
| "readyToInstall"
|
||||||
| "installing"
|
| "installing"
|
||||||
|
| "notEntitled"
|
||||||
| "error";
|
| "error";
|
||||||
|
|
||||||
interface UpdaterState {
|
interface UpdaterState {
|
||||||
|
|
@ -29,6 +31,7 @@ type UpdaterAction =
|
||||||
| { type: "DOWNLOAD_PROGRESS"; downloaded: number; contentLength: number | null }
|
| { type: "DOWNLOAD_PROGRESS"; downloaded: number; contentLength: number | null }
|
||||||
| { type: "READY_TO_INSTALL" }
|
| { type: "READY_TO_INSTALL" }
|
||||||
| { type: "INSTALLING" }
|
| { type: "INSTALLING" }
|
||||||
|
| { type: "NOT_ENTITLED" }
|
||||||
| { type: "ERROR"; error: string };
|
| { type: "ERROR"; error: string };
|
||||||
|
|
||||||
const initialState: UpdaterState = {
|
const initialState: UpdaterState = {
|
||||||
|
|
@ -56,6 +59,8 @@ function reducer(state: UpdaterState, action: UpdaterAction): UpdaterState {
|
||||||
return { ...state, status: "readyToInstall", error: null };
|
return { ...state, status: "readyToInstall", error: null };
|
||||||
case "INSTALLING":
|
case "INSTALLING":
|
||||||
return { ...state, status: "installing", error: null };
|
return { ...state, status: "installing", error: null };
|
||||||
|
case "NOT_ENTITLED":
|
||||||
|
return { ...state, status: "notEntitled", error: null };
|
||||||
case "ERROR":
|
case "ERROR":
|
||||||
return { ...state, status: "error", error: action.error };
|
return { ...state, status: "error", error: action.error };
|
||||||
}
|
}
|
||||||
|
|
@ -68,6 +73,16 @@ export function useUpdater() {
|
||||||
const checkForUpdate = useCallback(async () => {
|
const checkForUpdate = useCallback(async () => {
|
||||||
dispatch({ type: "CHECK_START" });
|
dispatch({ type: "CHECK_START" });
|
||||||
try {
|
try {
|
||||||
|
// Auto-updates are gated behind the entitlements module (Issue #46/#48).
|
||||||
|
// The check is centralized server-side via `check_entitlement` so the
|
||||||
|
// tier→feature mapping lives in one place.
|
||||||
|
const allowed = await invoke<boolean>("check_entitlement", {
|
||||||
|
feature: "auto-update",
|
||||||
|
});
|
||||||
|
if (!allowed) {
|
||||||
|
dispatch({ type: "NOT_ENTITLED" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
const update = await check();
|
const update = await check();
|
||||||
if (update) {
|
if (update) {
|
||||||
updateRef.current = update;
|
updateRef.current = update;
|
||||||
|
|
|
||||||
|
|
@ -436,7 +436,8 @@
|
||||||
"installing": "Installing...",
|
"installing": "Installing...",
|
||||||
"error": "Update failed",
|
"error": "Update failed",
|
||||||
"retryButton": "Retry",
|
"retryButton": "Retry",
|
||||||
"releaseNotes": "What's New"
|
"releaseNotes": "What's New",
|
||||||
|
"notEntitled": "Automatic updates are available with the Base edition. Activate a license to enable them."
|
||||||
},
|
},
|
||||||
"dataManagement": {
|
"dataManagement": {
|
||||||
"title": "Data Management",
|
"title": "Data Management",
|
||||||
|
|
@ -827,6 +828,7 @@
|
||||||
"checkUpdate": "Check for updates",
|
"checkUpdate": "Check for updates",
|
||||||
"updateAvailable": "Update available: v{{version}}",
|
"updateAvailable": "Update available: v{{version}}",
|
||||||
"upToDate": "The application is up to date",
|
"upToDate": "The application is up to date",
|
||||||
|
"updateNotEntitled": "Automatic updates are available with the Base edition.",
|
||||||
"contactUs": "Contact us",
|
"contactUs": "Contact us",
|
||||||
"contactEmail": "Send an email to",
|
"contactEmail": "Send an email to",
|
||||||
"reportIssue": "Report an issue"
|
"reportIssue": "Report an issue"
|
||||||
|
|
|
||||||
|
|
@ -436,7 +436,8 @@
|
||||||
"installing": "Installation en cours...",
|
"installing": "Installation en cours...",
|
||||||
"error": "Erreur lors de la mise à jour",
|
"error": "Erreur lors de la mise à jour",
|
||||||
"retryButton": "Réessayer",
|
"retryButton": "Réessayer",
|
||||||
"releaseNotes": "Nouveautés"
|
"releaseNotes": "Nouveautés",
|
||||||
|
"notEntitled": "Les mises à jour automatiques sont disponibles avec l'édition Base. Activez une licence pour les utiliser."
|
||||||
},
|
},
|
||||||
"dataManagement": {
|
"dataManagement": {
|
||||||
"title": "Gestion des données",
|
"title": "Gestion des données",
|
||||||
|
|
@ -827,6 +828,7 @@
|
||||||
"checkUpdate": "Vérifier les mises à jour",
|
"checkUpdate": "Vérifier les mises à jour",
|
||||||
"updateAvailable": "Mise à jour disponible : v{{version}}",
|
"updateAvailable": "Mise à jour disponible : v{{version}}",
|
||||||
"upToDate": "L'application est à jour",
|
"upToDate": "L'application est à jour",
|
||||||
|
"updateNotEntitled": "Les mises à jour automatiques sont disponibles avec l'édition Base.",
|
||||||
"contactUs": "Nous contacter",
|
"contactUs": "Nous contacter",
|
||||||
"contactEmail": "Envoyez un email à",
|
"contactEmail": "Envoyez un email à",
|
||||||
"reportIssue": "Signaler un problème"
|
"reportIssue": "Signaler un problème"
|
||||||
|
|
|
||||||
|
|
@ -155,6 +155,14 @@ export default function SettingsPage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* not entitled (free edition) */}
|
||||||
|
{state.status === "notEntitled" && (
|
||||||
|
<div className="flex items-start gap-2 text-sm text-[var(--muted-foreground)]">
|
||||||
|
<AlertCircle size={16} className="mt-0.5 shrink-0" />
|
||||||
|
<p>{t("settings.updates.notEntitled")}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* up to date */}
|
{/* up to date */}
|
||||||
{state.status === "upToDate" && (
|
{state.status === "upToDate" && (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue