From 99fef19a6bff4e263010541ae65b0260f72e2991 Mon Sep 17 00:00:00 2001 From: le king fu Date: Thu, 9 Apr 2026 08:49:43 -0400 Subject: [PATCH 1/3] feat: add license validation and entitlements (Rust) (#46) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces the offline license infrastructure for the Base/Premium editions. - jsonwebtoken (EdDSA) verifies license JWTs against an embedded Ed25519 public key. The exp claim is mandatory (CWE-613) and is enforced via Validation::set_required_spec_claims. - Activation tokens (server-issued, machine-bound) prevent license.key copying between machines. Storage is wired up; the actual issuance flow ships with Issue #49. - get_edition() fails closed to "free" when the license is missing, invalid, expired, or activated for a different machine. - New commands/entitlements module centralizes feature → tier mapping so Issue #48 (and any future gate) reads from a single source of truth. - machine-uid provides the cross-platform machine identifier; OS reinstall invalidates the activation token by design. - Tests cover happy path, expiry, wrong-key signature, malformed JWT, unknown edition, and machine_id matching for activation tokens. The embedded PUBLIC_KEY_PEM is the RFC 8410 §10.3 test vector, clearly labelled as a development placeholder; replacing it with the production public key is a release-time task. --- src-tauri/Cargo.toml | 4 + src-tauri/src/commands/entitlements.rs | 67 ++++ src-tauri/src/commands/license_commands.rs | 426 +++++++++++++++++++++ src-tauri/src/commands/mod.rs | 8 +- src-tauri/src/lib.rs | 7 + 5 files changed, 510 insertions(+), 2 deletions(-) create mode 100644 src-tauri/src/commands/entitlements.rs create mode 100644 src-tauri/src/commands/license_commands.rs diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 041010c..a3c8615 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -35,4 +35,8 @@ walkdir = "2" aes-gcm = "0.10" argon2 = "0.5" rand = "0.8" +jsonwebtoken = "9" +machine-uid = "0.5" +[dev-dependencies] +ed25519-dalek = { version = "2", features = ["pkcs8", "pem", "rand_core"] } diff --git a/src-tauri/src/commands/entitlements.rs b/src-tauri/src/commands/entitlements.rs new file mode 100644 index 0000000..c5137dc --- /dev/null +++ b/src-tauri/src/commands/entitlements.rs @@ -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 { + 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)); + } +} diff --git a/src-tauri/src/commands/license_commands.rs b/src-tauri/src/commands/license_commands.rs new file mode 100644 index 0000000..af794ad --- /dev/null +++ b/src-tauri/src/commands/license_commands.rs @@ -0,0 +1,426 @@ +// License validation, storage and reading for the Base/Premium editions. +// +// Architecture: +// - License key = "SR-BASE-" or "SR-PREMIUM-", JWT signed Ed25519 by the server +// - Activation token = separate JWT, also signed by the server, 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, + 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, + machine_limit: u32, +} + +/// Claims embedded in the activation token JWT (server-signed, machine-bound). +#[derive(Debug, Clone, Serialize, Deserialize)] +struct ActivationClaims { + sub: String, // license id or hash + iat: i64, + exp: i64, + machine_id: String, +} + +fn app_data_dir(app: &tauri::AppHandle) -> Result { + app.path() + .app_data_dir() + .map_err(|e| format!("Cannot get app data dir: {}", e)) +} + +fn license_path(app: &tauri::AppHandle) -> Result { + Ok(app_data_dir(app)?.join(LICENSE_FILE)) +} + +fn activation_path(app: &tauri::AppHandle) -> Result { + 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 { + 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::(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::(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 { + 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 { + 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, 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 { + 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 { + machine_id_internal() +} + +fn machine_id_internal() -> Result { + 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"); + } +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index e4f4300..5ecb666 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,7 +1,11 @@ -pub mod fs_commands; +pub mod entitlements; pub mod export_import_commands; +pub mod fs_commands; +pub mod license_commands; pub mod profile_commands; -pub use fs_commands::*; +pub use entitlements::*; pub use export_import_commands::*; +pub use fs_commands::*; +pub use license_commands::*; pub use profile_commands::*; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 401ced3..9c7b1c9 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -114,6 +114,13 @@ pub fn run() { commands::hash_pin, commands::verify_pin, 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!()) .expect("error while running tauri application"); From 69e136cab00b49befee0f60aee2fb3845bc7eeac Mon Sep 17 00:00:00 2001 From: le king fu Date: Thu, 9 Apr 2026 10:59:12 -0400 Subject: [PATCH 2/3] fix(rust): use DER-built keys in license tests, drop ed25519-dalek pem feature cargo CI flagged: `unresolved import ed25519_dalek::pkcs8::LineEnding`. The `LineEnding` re-export path varies between pkcs8/spki/der versions, so the test code that called `to_pkcs8_pem(LineEnding::LF)` won't compile against the dependency tree we get with ed25519-dalek 2.2 + pkcs8 0.10. Fix: - Drop the `pem` feature from the ed25519-dalek dev-dependency. - In tests, build the PKCS#8 v1 PrivateKeyInfo and SubjectPublicKeyInfo DER blobs manually from the raw 32-byte Ed25519 seed/public key. The Ed25519 layout is fixed (16-byte prefix + 32-byte key) so this is short and stable. - Pass the resulting DER bytes to `EncodingKey::from_ed_der` / `DecodingKey::from_ed_der`. Refactor: - Extract `strict_validation()` and `embedded_decoding_key()` helpers so the validation config (mandatory exp/iat for CWE-613) lives in one place and production callers all share the same DecodingKey constructor. - `validate_with_key` and `validate_activation_with_key` now take a `&DecodingKey` instead of raw PEM bytes; production builds the key once via `embedded_decoding_key()`. - New canary test `embedded_public_key_pem_parses` fails fast if the embedded PEM constant ever becomes malformed. --- src-tauri/Cargo.toml | 6 +- src-tauri/src/commands/license_commands.rs | 190 +++++++++++++-------- 2 files changed, 121 insertions(+), 75 deletions(-) diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index a3c8615..336e9c0 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -39,4 +39,8 @@ jsonwebtoken = "9" machine-uid = "0.5" [dev-dependencies] -ed25519-dalek = { version = "2", features = ["pkcs8", "pem", "rand_core"] } +# Used in license_commands.rs tests to sign test JWTs. We avoid the `pem` +# feature because the `LineEnding` re-export path varies between versions +# of pkcs8/spki; building the PKCS#8 DER manually is stable and trivial +# for Ed25519. +ed25519-dalek = { version = "2", features = ["pkcs8", "rand_core"] } diff --git a/src-tauri/src/commands/license_commands.rs b/src-tauri/src/commands/license_commands.rs index af794ad..7e27b3d 100644 --- a/src-tauri/src/commands/license_commands.rs +++ b/src-tauri/src/commands/license_commands.rs @@ -95,24 +95,31 @@ fn strip_prefix(key: &str) -> Result<&str, String> { 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 { - let jwt = strip_prefix(key)?; - - let decoding_key = DecodingKey::from_ed_pem(public_key_pem) - .map_err(|e| format!("Invalid public key: {}", e))?; - +/// 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); - // 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"]); + validation +} - let data = decode::(jwt, &decoding_key, &validation) +/// Build the production `DecodingKey` from the embedded PEM constant. +fn embedded_decoding_key() -> Result { + DecodingKey::from_ed_pem(PUBLIC_KEY_PEM.as_bytes()) + .map_err(|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 { + let jwt = strip_prefix(key)?; + let validation = strict_validation(); + + let data = decode::(jwt, decoding_key, &validation) .map_err(|e| format!("Invalid license: {}", e))?; let claims = data.claims; @@ -135,17 +142,11 @@ fn validate_with_key(key: &str, public_key_pem: &[u8]) -> Result Result<(), String> { - let decoding_key = DecodingKey::from_ed_pem(public_key_pem) - .map_err(|e| format!("Invalid public key: {}", e))?; + let validation = strict_validation(); - let mut validation = Validation::new(Algorithm::EdDSA); - validation.validate_exp = true; - validation.leeway = 0; - validation.set_required_spec_claims(&["exp", "iat"]); - - let data = decode::(token.trim(), &decoding_key, &validation) + let data = decode::(token.trim(), decoding_key, &validation) .map_err(|e| format!("Invalid activation token: {}", e))?; if data.claims.machine_id != local_machine_id { @@ -160,14 +161,16 @@ fn validate_activation_with_key( /// before the user confirms storage. #[tauri::command] pub fn validate_license_key(key: String) -> Result { - validate_with_key(&key, PUBLIC_KEY_PEM.as_bytes()) + 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 { - let info = validate_with_key(&key, PUBLIC_KEY_PEM.as_bytes())?; + 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))?; @@ -181,7 +184,8 @@ pub fn store_license(app: tauri::AppHandle, key: String) -> Result Result<(), String> { let local_id = machine_id_internal()?; - validate_activation_with_key(&token, &local_id, PUBLIC_KEY_PEM.as_bytes())?; + 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))?; @@ -198,7 +202,10 @@ pub fn read_license(app: tauri::AppHandle) -> Result, String 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()) + 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. @@ -227,7 +234,10 @@ pub(crate) fn current_edition(app: &tauri::AppHandle) -> 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 { + 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(); }; @@ -241,8 +251,7 @@ pub(crate) fn current_edition(app: &tauri::AppHandle) -> 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() - { + if validate_activation_with_key(&token, &local_id, &decoding_key).is_err() { return EDITION_FREE.to_string(); } } @@ -267,31 +276,64 @@ fn machine_id_internal() -> Result { #[cfg(test)] mod tests { use super::*; - use ed25519_dalek::pkcs8::{EncodePrivateKey, EncodePublicKey, LineEnding}; use ed25519_dalek::SigningKey; use jsonwebtoken::{encode, EncodingKey, Header}; + // === Manual DER encoders for Ed25519 keys ================================================= + // 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: prefix + 32-byte raw key. + + /// Wrap a 32-byte Ed25519 seed in a PKCS#8 v1 PrivateKeyInfo DER blob. + fn ed25519_pkcs8_private_der(seed: &[u8; 32]) -> Vec { + // SEQUENCE(46) { + // INTEGER(1) 0 // version v1 + // SEQUENCE(5) { + // OID(3) 1.3.101.112 // Ed25519 + // } + // OCTET STRING(34) { + // OCTET STRING(32) <32 bytes> + // } + // } + let mut der = vec![ + 0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, + 0x04, 0x20, + ]; + der.extend_from_slice(seed); + der + } + + /// Wrap a 32-byte Ed25519 public key in a SubjectPublicKeyInfo DER blob. + fn ed25519_spki_public_der(pubkey: &[u8; 32]) -> Vec { + // SEQUENCE(42) { + // SEQUENCE(5) { OID(3) 1.3.101.112 } + // BIT STRING(33) { 00 <32 bytes> } + // } + let mut der = vec![ + 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00, + ]; + der.extend_from_slice(pubkey); + der + } + /// Build a deterministic test keypair so signed tokens are reproducible across runs. - fn test_keypair() -> (String, String) { - let seed = [42u8; 32]; + /// Returns (private_der, decoding_key_for_verification). + fn test_keys(seed: [u8; 32]) -> (EncodingKey, DecodingKey) { 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) + let pubkey_bytes = signing_key.verifying_key().to_bytes(); + let priv_der = ed25519_pkcs8_private_der(&seed); + let pub_der = ed25519_spki_public_der(&pubkey_bytes); + let encoding_key = EncodingKey::from_ed_der(&priv_der); + let decoding_key = DecodingKey::from_ed_der(&pub_der); + (encoding_key, decoding_key) } - 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 default_keys() -> (EncodingKey, DecodingKey) { + test_keys([42u8; 32]) } - 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 make_token(encoding_key: &EncodingKey, claims: &T) -> String { + encode(&Header::new(Algorithm::EdDSA), claims, encoding_key).unwrap() } fn now() -> i64 { @@ -303,14 +345,14 @@ mod tests { #[test] fn rejects_key_without_prefix() { - let (_priv, public_pem) = test_keypair(); - let result = validate_with_key("nonsense", public_pem.as_bytes()); + let (_enc, dec) = default_keys(); + let result = validate_with_key("nonsense", &dec); assert!(result.is_err()); } #[test] fn accepts_well_formed_base_license() { - let (private_pem, public_pem) = test_keypair(); + let (enc, dec) = default_keys(); let claims = LicenseClaims { sub: "user@example.com".to_string(), iss: "lacompagniemaximus.com".to_string(), @@ -320,9 +362,9 @@ mod tests { features: vec!["auto-update".to_string()], machine_limit: 3, }; - let jwt = make_token(&private_pem, &claims); + let jwt = make_token(&enc, &claims); let key = format!("{}{}", KEY_PREFIX_BASE, jwt); - let info = validate_with_key(&key, public_pem.as_bytes()).unwrap(); + 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); @@ -330,7 +372,7 @@ mod tests { #[test] fn rejects_expired_license() { - let (private_pem, public_pem) = test_keypair(); + let (enc, dec) = default_keys(); let claims = LicenseClaims { sub: "user@example.com".to_string(), iss: "lacompagniemaximus.com".to_string(), @@ -340,22 +382,16 @@ mod tests { features: vec![], machine_limit: 3, }; - let jwt = make_token(&private_pem, &claims); + let jwt = make_token(&enc, &claims); let key = format!("{}{}", KEY_PREFIX_BASE, jwt); - let result = validate_with_key(&key, public_pem.as_bytes()); + 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 (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 (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(), @@ -365,23 +401,23 @@ mod tests { features: vec![], machine_limit: 3, }; - let jwt = make_token(&private_pem, &claims); + let jwt = make_token(&enc_signer, &claims); let key = format!("{}{}", KEY_PREFIX_BASE, jwt); - let result = validate_with_key(&key, other_public.as_bytes()); + let result = validate_with_key(&key, &dec_other); assert!(result.is_err(), "wrong-key signature must be rejected"); } #[test] fn rejects_corrupted_jwt() { - let (_priv, public_pem) = test_keypair(); + let (_enc, dec) = default_keys(); let key = format!("{}not.a.real.jwt", KEY_PREFIX_BASE); - let result = validate_with_key(&key, public_pem.as_bytes()); + let result = validate_with_key(&key, &dec); assert!(result.is_err()); } #[test] fn rejects_unknown_edition() { - let (private_pem, public_pem) = test_keypair(); + let (enc, dec) = default_keys(); let claims = LicenseClaims { sub: "user@example.com".to_string(), iss: "lacompagniemaximus.com".to_string(), @@ -391,36 +427,42 @@ mod tests { features: vec![], machine_limit: 3, }; - let jwt = make_token(&private_pem, &claims); + let jwt = make_token(&enc, &claims); let key = format!("{}{}", KEY_PREFIX_BASE, jwt); - let result = validate_with_key(&key, public_pem.as_bytes()); + let result = validate_with_key(&key, &dec); assert!(result.is_err()); } #[test] fn activation_token_matches_machine() { - let (private_pem, public_pem) = test_keypair(); + 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_activation_token(&private_pem, &claims); - assert!(validate_activation_with_key(&token, "this-machine", public_pem.as_bytes()).is_ok()); + 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 (private_pem, public_pem) = test_keypair(); + 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_activation_token(&private_pem, &claims); - let result = validate_activation_with_key(&token, "machine-B", public_pem.as_bytes()); + 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()); + } } From 2e9df1c0b91fa8af301adf4d460faeb176a6215b Mon Sep 17 00:00:00 2001 From: le king fu Date: Thu, 9 Apr 2026 11:12:10 -0400 Subject: [PATCH 3/3] fix(rust): pass raw public key bytes to DecodingKey::from_ed_der MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previous test refactor wrapped both keys in their respective DER envelopes. CI surfaced the asymmetry: jsonwebtoken's two from_ed_der constructors expect different inputs. - EncodingKey::from_ed_der → PKCS#8 v1 wrapped (ring's Ed25519KeyPair::from_pkcs8 path). The 16-byte prefix + 32-byte seed blob is correct. - DecodingKey::from_ed_der → raw 32-byte public key. Internally it becomes ring's UnparsedPublicKey::new(&ED25519, key_bytes), which takes the bare bytes, NOT a SubjectPublicKeyInfo wrapper. The test was building an SPKI DER for the public key, so verification saw a malformed key and failed every signature with InvalidSignature (`accepts_well_formed_base_license` and `activation_token_matches_machine`). Drop the SPKI helper, pass `signing_key.verifying_key().to_bytes()` straight into DecodingKey::from_ed_der. Inline doc-comment captures the asymmetry so the next person doesn't fall in the same hole. --- src-tauri/src/commands/license_commands.rs | 28 ++++++++-------------- 1 file changed, 10 insertions(+), 18 deletions(-) diff --git a/src-tauri/src/commands/license_commands.rs b/src-tauri/src/commands/license_commands.rs index 7e27b3d..8e94862 100644 --- a/src-tauri/src/commands/license_commands.rs +++ b/src-tauri/src/commands/license_commands.rs @@ -279,10 +279,16 @@ mod tests { use ed25519_dalek::SigningKey; use jsonwebtoken::{encode, EncodingKey, Header}; - // === Manual DER encoders for Ed25519 keys ================================================= + // === 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: prefix + 32-byte raw key. + // 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 { @@ -303,28 +309,14 @@ mod tests { der } - /// Wrap a 32-byte Ed25519 public key in a SubjectPublicKeyInfo DER blob. - fn ed25519_spki_public_der(pubkey: &[u8; 32]) -> Vec { - // SEQUENCE(42) { - // SEQUENCE(5) { OID(3) 1.3.101.112 } - // BIT STRING(33) { 00 <32 bytes> } - // } - let mut der = vec![ - 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00, - ]; - der.extend_from_slice(pubkey); - der - } - /// Build a deterministic test keypair so signed tokens are reproducible across runs. - /// Returns (private_der, decoding_key_for_verification). 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 pub_der = ed25519_spki_public_der(&pubkey_bytes); let encoding_key = EncodingKey::from_ed_der(&priv_der); - let decoding_key = DecodingKey::from_ed_der(&pub_der); + // Raw 32-byte public key (NOT SubjectPublicKeyInfo) — see note above. + let decoding_key = DecodingKey::from_ed_der(&pubkey_bytes); (encoding_key, decoding_key) }