- Add auth_commands.rs: OAuth2 PKCE flow (start_oauth, handle_auth_callback, refresh_auth_token, get_account_info, check_subscription_status, logout) - Add deep-link handler in lib.rs for simpl-resultat://auth/callback - Add AccountCard.tsx + useAuth hook + authService.ts - Add machine activation commands (activate, deactivate, list, get_activation_status) - Extend LicenseCard with machine management UI - get_edition() now checks account subscription for Premium detection - Daily subscription status check (refresh token if last check > 24h) - Configure CSP for API/auth endpoints - Configure tauri-plugin-deep-link for desktop - Update i18n (FR/EN), changelogs, and architecture docs Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
679 lines
24 KiB
Rust
679 lines
24 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, 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.
|
|
//
|
|
// Production key generated 2026-04-10. The corresponding private key lives ONLY
|
|
// on the license server (Issue #49) as env var ED25519_PRIVATE_KEY_PEM.
|
|
const PUBLIC_KEY_PEM: &str = "-----BEGIN PUBLIC KEY-----\n\
|
|
MCowBQYDK2VwAyEAZKoo8eeiSdpxBIVTQXemggOGRUX0+xpiqtOYZfAFeuM=\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())
|
|
}
|
|
|
|
/// 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, String> {
|
|
DecodingKey::from_ed_pem(PUBLIC_KEY_PEM.as_bytes())
|
|
.map_err(|e| format!("Invalid public key: {}", e))
|
|
}
|
|
|
|
/// 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, String> {
|
|
let jwt = strip_prefix(key)?;
|
|
let validation = strict_validation();
|
|
|
|
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,
|
|
decoding_key: &DecodingKey,
|
|
) -> Result<(), String> {
|
|
let validation = strict_validation();
|
|
|
|
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> {
|
|
let decoding_key = embedded_decoding_key()?;
|
|
validate_with_key(&key, &decoding_key)
|
|
}
|
|
|
|
/// 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 decoding_key = embedded_decoding_key()?;
|
|
let info = validate_with_key(&key, &decoding_key)?;
|
|
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()?;
|
|
let decoding_key = embedded_decoding_key()?;
|
|
validate_activation_with_key(&token, &local_id, &decoding_key)?;
|
|
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))?;
|
|
let Ok(decoding_key) = embedded_decoding_key() else {
|
|
return Ok(None);
|
|
};
|
|
Ok(validate_with_key(&key, &decoding_key).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.
|
|
///
|
|
/// Priority: Premium (via Compte Maximus with active subscription) > Base (offline license) > Free.
|
|
pub(crate) fn current_edition(app: &tauri::AppHandle) -> String {
|
|
// Check Compte Maximus subscription first — Premium overrides Base
|
|
if let Some(edition) = check_account_edition(app) {
|
|
if edition == EDITION_PREMIUM {
|
|
return edition;
|
|
}
|
|
}
|
|
|
|
let Ok(path) = license_path(app) else {
|
|
return EDITION_FREE.to_string();
|
|
};
|
|
if !path.exists() {
|
|
return EDITION_FREE.to_string();
|
|
}
|
|
let Ok(key) = fs::read_to_string(&path) else {
|
|
return EDITION_FREE.to_string();
|
|
};
|
|
let Ok(decoding_key) = embedded_decoding_key() else {
|
|
return EDITION_FREE.to_string();
|
|
};
|
|
let Ok(info) = validate_with_key(&key, &decoding_key) else {
|
|
return EDITION_FREE.to_string();
|
|
};
|
|
|
|
// If an activation token exists, it must match the local machine. A missing token is
|
|
// accepted (graceful pre-activation).
|
|
if let Ok(activation_path) = activation_path(app) {
|
|
if activation_path.exists() {
|
|
let Ok(token) = fs::read_to_string(&activation_path) else {
|
|
return EDITION_FREE.to_string();
|
|
};
|
|
let Ok(local_id) = machine_id_internal() else {
|
|
return EDITION_FREE.to_string();
|
|
};
|
|
if validate_activation_with_key(&token, &local_id, &decoding_key).is_err() {
|
|
return EDITION_FREE.to_string();
|
|
}
|
|
}
|
|
}
|
|
|
|
info.edition
|
|
}
|
|
|
|
/// Read the cached account.json to check for an active Premium subscription.
|
|
/// Returns None if no account file exists or the file is invalid.
|
|
fn check_account_edition(app: &tauri::AppHandle) -> Option<String> {
|
|
let dir = app_data_dir(app).ok()?.join("auth");
|
|
let account_path = dir.join("account.json");
|
|
if !account_path.exists() {
|
|
return None;
|
|
}
|
|
let raw = fs::read_to_string(&account_path).ok()?;
|
|
let account: super::auth_commands::AccountInfo = serde_json::from_str(&raw).ok()?;
|
|
match account.subscription_status.as_deref() {
|
|
Some("active") => Some(EDITION_PREMIUM.to_string()),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// 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))
|
|
}
|
|
|
|
// License server API base URL. Overridable via SIMPL_API_URL env var for development.
|
|
fn api_base_url() -> String {
|
|
std::env::var("SIMPL_API_URL")
|
|
.unwrap_or_else(|_| "https://api.lacompagniemaximus.com".to_string())
|
|
}
|
|
|
|
/// Machine info returned by the license server.
|
|
#[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,
|
|
}
|
|
|
|
/// Activation status for display in the UI.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ActivationStatus {
|
|
pub is_activated: bool,
|
|
pub machine_id: String,
|
|
}
|
|
|
|
/// Activate this machine with the license server. Reads the stored license key, sends
|
|
/// the machine_id to the API, and stores the returned activation token.
|
|
#[tauri::command]
|
|
pub async fn activate_machine(app: tauri::AppHandle) -> Result<(), String> {
|
|
let key_path = license_path(&app)?;
|
|
if !key_path.exists() {
|
|
return Err("No license key stored".to_string());
|
|
}
|
|
let license_key =
|
|
fs::read_to_string(&key_path).map_err(|e| format!("Cannot read license: {}", e))?;
|
|
let machine_id = machine_id_internal()?;
|
|
let machine_name = hostname::get()
|
|
.ok()
|
|
.and_then(|h| h.into_string().ok());
|
|
|
|
let url = format!("{}/licenses/activate", api_base_url());
|
|
let client = reqwest::Client::new();
|
|
|
|
let mut body = serde_json::json!({
|
|
"license_key": license_key.trim(),
|
|
"machine_id": machine_id,
|
|
});
|
|
if let Some(name) = machine_name {
|
|
body["machine_name"] = serde_json::Value::String(name);
|
|
}
|
|
|
|
let resp = client
|
|
.post(&url)
|
|
.json(&body)
|
|
.timeout(std::time::Duration::from_secs(15))
|
|
.send()
|
|
.await
|
|
.map_err(|e| format!("Cannot reach license server: {}", e))?;
|
|
|
|
let status = resp.status();
|
|
let resp_body: serde_json::Value = resp
|
|
.json()
|
|
.await
|
|
.map_err(|e| format!("Invalid server response: {}", e))?;
|
|
|
|
if !status.is_success() {
|
|
let error = resp_body["error"]
|
|
.as_str()
|
|
.unwrap_or("Activation failed");
|
|
return Err(error.to_string());
|
|
}
|
|
|
|
let token = resp_body["activation_token"]
|
|
.as_str()
|
|
.ok_or("Server did not return an activation token")?;
|
|
|
|
// store_activation_token validates the token against local machine_id before writing
|
|
store_activation_token(app, token.to_string())?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Deactivate a machine on the license server, freeing a slot.
|
|
#[tauri::command]
|
|
pub async fn deactivate_machine(
|
|
app: tauri::AppHandle,
|
|
machine_id: String,
|
|
) -> Result<(), String> {
|
|
let key_path = license_path(&app)?;
|
|
if !key_path.exists() {
|
|
return Err("No license key stored".to_string());
|
|
}
|
|
let license_key =
|
|
fs::read_to_string(&key_path).map_err(|e| format!("Cannot read license: {}", e))?;
|
|
|
|
let url = format!("{}/licenses/deactivate", api_base_url());
|
|
let client = reqwest::Client::new();
|
|
let resp = client
|
|
.post(&url)
|
|
.json(&serde_json::json!({
|
|
"license_key": license_key.trim(),
|
|
"machine_id": machine_id,
|
|
}))
|
|
.timeout(std::time::Duration::from_secs(15))
|
|
.send()
|
|
.await
|
|
.map_err(|e| format!("Cannot reach license server: {}", e))?;
|
|
|
|
let status = resp.status();
|
|
if !status.is_success() {
|
|
let resp_body: serde_json::Value = resp
|
|
.json()
|
|
.await
|
|
.unwrap_or(serde_json::json!({"error": "Deactivation failed"}));
|
|
let error = resp_body["error"].as_str().unwrap_or("Deactivation failed");
|
|
return Err(error.to_string());
|
|
}
|
|
|
|
// If deactivating this machine, remove the local activation token
|
|
let local_id = machine_id_internal()?;
|
|
if machine_id == local_id {
|
|
let act_path = activation_path(&app)?;
|
|
if act_path.exists() {
|
|
let _ = fs::remove_file(&act_path);
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// List all machines currently activated for the stored license.
|
|
#[tauri::command]
|
|
pub async fn list_activated_machines(app: tauri::AppHandle) -> Result<Vec<MachineInfo>, String> {
|
|
let key_path = license_path(&app)?;
|
|
if !key_path.exists() {
|
|
return Ok(vec![]);
|
|
}
|
|
let license_key =
|
|
fs::read_to_string(&key_path).map_err(|e| format!("Cannot read license: {}", e))?;
|
|
|
|
let url = format!("{}/licenses/verify", api_base_url());
|
|
let client = reqwest::Client::new();
|
|
let resp = client
|
|
.post(&url)
|
|
.json(&serde_json::json!({
|
|
"license_key": license_key.trim(),
|
|
}))
|
|
.timeout(std::time::Duration::from_secs(15))
|
|
.send()
|
|
.await
|
|
.map_err(|e| format!("Cannot reach license server: {}", e))?;
|
|
|
|
if !resp.status().is_success() {
|
|
return Err("Cannot verify license".to_string());
|
|
}
|
|
|
|
let body: serde_json::Value = resp
|
|
.json()
|
|
.await
|
|
.map_err(|e| format!("Invalid server response: {}", e))?;
|
|
|
|
// The verify endpoint returns machines in the response when valid
|
|
let machines = body["machines"]
|
|
.as_array()
|
|
.map(|arr| {
|
|
arr.iter()
|
|
.filter_map(|m| serde_json::from_value::<MachineInfo>(m.clone()).ok())
|
|
.collect()
|
|
})
|
|
.unwrap_or_default();
|
|
|
|
Ok(machines)
|
|
}
|
|
|
|
/// Check the local activation status without contacting the server.
|
|
#[tauri::command]
|
|
pub fn get_activation_status(app: tauri::AppHandle) -> Result<ActivationStatus, String> {
|
|
let machine_id = machine_id_internal()?;
|
|
let act_path = activation_path(&app)?;
|
|
|
|
let is_activated = if act_path.exists() {
|
|
if let Ok(token) = fs::read_to_string(&act_path) {
|
|
if let Ok(decoding_key) = embedded_decoding_key() {
|
|
validate_activation_with_key(&token, &machine_id, &decoding_key).is_ok()
|
|
} else {
|
|
false
|
|
}
|
|
} else {
|
|
false
|
|
}
|
|
} else {
|
|
false
|
|
};
|
|
|
|
Ok(ActivationStatus {
|
|
is_activated,
|
|
machine_id,
|
|
})
|
|
}
|
|
|
|
// === 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!(result.is_err());
|
|
}
|
|
|
|
#[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 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!(result.is_err(), "expired license must be rejected");
|
|
}
|
|
|
|
#[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!(result.is_err(), "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!(result.is_err());
|
|
}
|
|
|
|
#[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!(result.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn activation_token_matches_machine() {
|
|
let (enc, dec) = default_keys();
|
|
let claims = ActivationClaims {
|
|
sub: "license-id".to_string(),
|
|
iat: now(),
|
|
exp: now() + 86400,
|
|
machine_id: "this-machine".to_string(),
|
|
};
|
|
let token = make_token(&enc, &claims);
|
|
assert!(validate_activation_with_key(&token, "this-machine", &dec).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn activation_token_rejects_other_machine() {
|
|
let (enc, dec) = default_keys();
|
|
let claims = ActivationClaims {
|
|
sub: "license-id".to_string(),
|
|
iat: now(),
|
|
exp: now() + 86400,
|
|
machine_id: "machine-A".to_string(),
|
|
};
|
|
let token = make_token(&enc, &claims);
|
|
let result = validate_activation_with_key(&token, "machine-B", &dec);
|
|
assert!(result.is_err(), "copied activation token must be rejected");
|
|
}
|
|
|
|
#[test]
|
|
fn embedded_public_key_pem_parses() {
|
|
// Sanity check that the production PEM constant is well-formed.
|
|
assert!(embedded_decoding_key().is_ok());
|
|
}
|
|
}
|