diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 02d4552..11dfad4 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -4430,6 +4430,7 @@ dependencies = [ "base64 0.22.1", "ed25519-dalek", "encoding_rs", + "hmac", "hostname", "jsonwebtoken", "keyring", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index bd1a7f2..02994f2 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -51,6 +51,7 @@ base64 = "0.22" # on Linux (libdbus-1-3 is present on every desktop Linux at runtime). keyring = { version = "3.6", default-features = false, features = ["sync-secret-service", "crypto-rust", "windows-native"] } zeroize = "1" +hmac = "0.12" [dev-dependencies] # Used in license_commands.rs tests to sign test JWTs. We avoid the `pem` diff --git a/src-tauri/src/commands/account_cache.rs b/src-tauri/src/commands/account_cache.rs new file mode 100644 index 0000000..c57abd2 --- /dev/null +++ b/src-tauri/src/commands/account_cache.rs @@ -0,0 +1,292 @@ +// Integrity-protected cache for cached account info. +// +// The user's subscription tier is used by `license_commands::current_edition` +// to unlock Premium features. Until this module, the subscription_status +// claim was read directly from a plaintext `account.json` on disk, which +// meant any local process (malware, nosy user, curl) could write +// `{"subscription_status": "active"}` and bypass the paywall without +// ever touching the Logto session. This module closes that trap. +// +// Approach: an HMAC-SHA256 signature is computed over the serialized +// AccountInfo bytes using a per-install key stored in the OS keychain +// (via the `keyring` crate, same backend as token_store). The signed +// payload is wrapped as `{"data": {...}, "sig": ""}`. On read, +// verification requires the same key; tampering with either `data` or +// `sig` invalidates the cache. +// +// Trust chain: +// - Key lives in the keychain, scoped to service "com.simpl.resultat", +// user "account-hmac-key". Compromising it requires compromising the +// keychain, which is the existing trust boundary for OAuth tokens. +// - A legacy unsigned `account.json` (from v0.7.x) is still readable +// for display purposes (email, name, picture), but the gating path +// uses `load_verified` which returns None for legacy payloads — +// Premium features stay locked until the next token refresh rewrites +// the file with a signature. + +use hmac::{Hmac, Mac}; +use serde::{Deserialize, Serialize}; +use sha2::Sha256; +use std::fs; + +use super::auth_commands::AccountInfo; +use super::token_store::{auth_dir, write_restricted}; + +const KEYCHAIN_SERVICE: &str = "com.simpl.resultat"; +const KEYCHAIN_USER_HMAC: &str = "account-hmac-key"; +const ACCOUNT_FILE: &str = "account.json"; + +type HmacSha256 = Hmac; + +#[derive(Debug, Serialize, Deserialize)] +struct SignedAccount { + data: AccountInfo, + sig: String, +} + +/// Read the HMAC key from the keychain, creating a fresh random key on +/// first use. The key is never persisted to disk — if the keychain is +/// unreachable, the whole cache signing/verification path falls back +/// to "not signed" which means gating stays locked until the keychain +/// is available again. This is intentional (fail-closed). +fn get_or_create_key() -> Result<[u8; 32], String> { + let entry = keyring::Entry::new(KEYCHAIN_SERVICE, KEYCHAIN_USER_HMAC) + .map_err(|e| format!("Keychain entry init failed: {}", e))?; + match entry.get_password() { + Ok(b64) => decode_key(&b64), + Err(keyring::Error::NoEntry) => { + use rand::RngCore; + let mut key = [0u8; 32]; + rand::thread_rng().fill_bytes(&mut key); + let encoded = encode_key(&key); + entry + .set_password(&encoded) + .map_err(|e| format!("Keychain HMAC key write failed: {}", e))?; + Ok(key) + } + Err(e) => Err(format!("Keychain HMAC key read failed: {}", e)), + } +} + +fn encode_key(key: &[u8]) -> String { + use base64::{engine::general_purpose::STANDARD, Engine}; + STANDARD.encode(key) +} + +fn decode_key(raw: &str) -> Result<[u8; 32], String> { + use base64::{engine::general_purpose::STANDARD, Engine}; + let bytes = STANDARD + .decode(raw.trim()) + .map_err(|e| format!("Invalid HMAC key encoding: {}", e))?; + if bytes.len() != 32 { + return Err(format!( + "HMAC key must be 32 bytes, got {}", + bytes.len() + )); + } + let mut arr = [0u8; 32]; + arr.copy_from_slice(&bytes); + Ok(arr) +} + +fn sign(key: &[u8; 32], payload: &[u8]) -> String { + use base64::{engine::general_purpose::STANDARD, Engine}; + let mut mac = HmacSha256::new_from_slice(key).expect("HMAC can take any key length"); + mac.update(payload); + STANDARD.encode(mac.finalize().into_bytes()) +} + +fn verify(key: &[u8; 32], payload: &[u8], sig_b64: &str) -> bool { + use base64::{engine::general_purpose::STANDARD, Engine}; + let Ok(sig_bytes) = STANDARD.decode(sig_b64) else { + return false; + }; + let Ok(mut mac) = HmacSha256::new_from_slice(key) else { + return false; + }; + mac.update(payload); + mac.verify_slice(&sig_bytes).is_ok() +} + +/// Write the account cache as `{"data": {...}, "sig": "..."}`. The key +/// is fetched from (or created in) the keychain. Writes fall back to an +/// unsigned legacy-shape payload only when the keychain is unreachable +/// — this keeps the UI functional on keychain-less systems but means +/// the gating path will refuse to grant Premium until the keychain +/// comes back. +pub fn save(app: &tauri::AppHandle, account: &AccountInfo) -> Result<(), String> { + let dir = auth_dir(app)?; + let path = dir.join(ACCOUNT_FILE); + + match get_or_create_key() { + Ok(key) => { + // Serialise the AccountInfo alone (compact, deterministic + // for a given struct layout), sign the bytes, then re-wrap + // into the signed envelope. We write the signed envelope + // as pretty-printed JSON for readability in the audit log. + let data_bytes = + serde_json::to_vec(account).map_err(|e| format!("Serialize error: {}", e))?; + let sig = sign(&key, &data_bytes); + let envelope = SignedAccount { + data: account.clone(), + sig, + }; + let json = serde_json::to_string_pretty(&envelope) + .map_err(|e| format!("Serialize envelope error: {}", e))?; + write_restricted(&path, &json) + } + Err(err) => { + eprintln!( + "account_cache: keychain HMAC key unavailable, writing unsigned legacy payload ({})", + err + ); + // Fallback: unsigned payload. UI still works, but + // `load_verified` will reject this file for gating. + let json = serde_json::to_string_pretty(account) + .map_err(|e| format!("Serialize error: {}", e))?; + write_restricted(&path, &json) + } + } +} + +/// Load the cached account for **display purposes**. Accepts both the +/// new signed envelope and legacy plaintext AccountInfo files. Does +/// NOT verify the signature — suitable for showing the user's name / +/// email / picture in the UI, but never for gating decisions. +pub fn load_unverified(app: &tauri::AppHandle) -> Result, String> { + let dir = auth_dir(app)?; + let path = dir.join(ACCOUNT_FILE); + if !path.exists() { + return Ok(None); + } + let raw = fs::read_to_string(&path).map_err(|e| format!("Cannot read account: {}", e))?; + + // Prefer the signed envelope shape; fall back to legacy flat + // AccountInfo so upgraded users see their account info immediately + // rather than a blank card until the next token refresh. + if let Ok(envelope) = serde_json::from_str::(&raw) { + return Ok(Some(envelope.data)); + } + if let Ok(flat) = serde_json::from_str::(&raw) { + return Ok(Some(flat)); + } + Err("Invalid account cache payload".to_string()) +} + +/// Load the cached account and verify the HMAC signature. Used by the +/// license gating path (`current_edition`). Returns Ok(None) when: +/// - no cache exists, +/// - the cache is in legacy unsigned shape (pre-v0.8 or post-fallback), +/// - the keychain HMAC key is unreachable, +/// - the signature does not verify. +/// +/// Any of these states must cause Premium features to stay locked — +/// never accept an unverifiable payload for a gating decision. +pub fn load_verified(app: &tauri::AppHandle) -> Result, String> { + let dir = auth_dir(app)?; + let path = dir.join(ACCOUNT_FILE); + if !path.exists() { + return Ok(None); + } + let raw = fs::read_to_string(&path).map_err(|e| format!("Cannot read account: {}", e))?; + + // Only signed envelopes are acceptable here. Legacy flat payloads + // are treated as "no verified account". + let Ok(envelope) = serde_json::from_str::(&raw) else { + return Ok(None); + }; + let Ok(key) = get_or_create_key() else { + return Ok(None); + }; + let data_bytes = match serde_json::to_vec(&envelope.data) { + Ok(v) => v, + Err(_) => return Ok(None), + }; + if !verify(&key, &data_bytes, &envelope.sig) { + return Ok(None); + } + Ok(Some(envelope.data)) +} + +/// Delete the cached account file AND the keychain HMAC key. Called on +/// logout so the next login generates a fresh key bound to the new +/// session. +pub fn delete(app: &tauri::AppHandle) -> Result<(), String> { + let dir = auth_dir(app)?; + let path = dir.join(ACCOUNT_FILE); + if path.exists() { + let _ = fs::remove_file(&path); + } + if let Ok(entry) = keyring::Entry::new(KEYCHAIN_SERVICE, KEYCHAIN_USER_HMAC) { + let _ = entry.delete_credential(); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_account() -> AccountInfo { + AccountInfo { + email: "user@example.com".into(), + name: Some("Test User".into()), + picture: None, + subscription_status: Some("active".into()), + } + } + + #[test] + fn sign_then_verify_same_key() { + let key = [7u8; 32]; + let payload = b"hello world"; + let sig = sign(&key, payload); + assert!(verify(&key, payload, &sig)); + } + + #[test] + fn verify_rejects_tampered_payload() { + let key = [7u8; 32]; + let sig = sign(&key, b"original"); + assert!(!verify(&key, b"tampered", &sig)); + } + + #[test] + fn verify_rejects_wrong_key() { + let key_a = [7u8; 32]; + let key_b = [8u8; 32]; + let sig = sign(&key_a, b"payload"); + assert!(!verify(&key_b, b"payload", &sig)); + } + + #[test] + fn envelope_roundtrip_serde() { + let account = sample_account(); + let key = [3u8; 32]; + let data = serde_json::to_vec(&account).unwrap(); + let sig = sign(&key, &data); + let env = SignedAccount { + data: account.clone(), + sig, + }; + let json = serde_json::to_string(&env).unwrap(); + let decoded: SignedAccount = serde_json::from_str(&json).unwrap(); + let decoded_data = serde_json::to_vec(&decoded.data).unwrap(); + assert!(verify(&key, &decoded_data, &decoded.sig)); + } + + #[test] + fn encode_decode_key_roundtrip() { + let key = [42u8; 32]; + let encoded = encode_key(&key); + let decoded = decode_key(&encoded).unwrap(); + assert_eq!(decoded, key); + } + + #[test] + fn decode_key_rejects_wrong_length() { + use base64::{engine::general_purpose::STANDARD, Engine}; + let short = STANDARD.encode([1u8; 16]); + assert!(decode_key(&short).is_err()); + } +} diff --git a/src-tauri/src/commands/auth_commands.rs b/src-tauri/src/commands/auth_commands.rs index 7c75c9b..212ef19 100644 --- a/src-tauri/src/commands/auth_commands.rs +++ b/src-tauri/src/commands/auth_commands.rs @@ -19,6 +19,7 @@ use std::fs; use std::sync::Mutex; use tauri::Manager; +use super::account_cache; use super::token_store::{ self, auth_dir, chrono_now, write_restricted, StoredTokens, }; @@ -35,7 +36,6 @@ fn logto_app_id() -> String { } const REDIRECT_URI: &str = "simpl-resultat://auth/callback"; -const ACCOUNT_FILE: &str = "account.json"; const LAST_CHECK_FILE: &str = "last_check"; const CHECK_INTERVAL_SECS: i64 = 86400; // 24 hours @@ -158,11 +158,10 @@ pub async fn handle_auth_callback(app: tauri::AppHandle, code: String) -> Result // Fetch user info let account = fetch_userinfo(&endpoint, &access_token).await?; - // Store account info (non-secret; stays in the restricted file store). - let dir = auth_dir(&app)?; - let account_json = - serde_json::to_string_pretty(&account).map_err(|e| format!("Serialize error: {}", e))?; - write_restricted(&dir.join(ACCOUNT_FILE), &account_json)?; + // Store account info with an HMAC signature so the license gating + // path can trust the cached subscription_status without re-calling + // Logto on every entitlement check. + account_cache::save(&app, &account)?; Ok(account) } @@ -195,8 +194,7 @@ pub async fn refresh_auth_token(app: tauri::AppHandle) -> Result Result Result, String> { - let dir = auth_dir(&app)?; - let path = dir.join(ACCOUNT_FILE); - if !path.exists() { - return Ok(None); - } - let raw = fs::read_to_string(&path).map_err(|e| format!("Cannot read account: {}", e))?; - let account: AccountInfo = - serde_json::from_str(&raw).map_err(|e| format!("Invalid account file: {}", e))?; - Ok(Some(account)) + account_cache::load_unverified(&app) } -/// Log out: clear all stored tokens and account info. +/// Log out: clear all stored tokens and account info, including the +/// HMAC key so the next session starts with a fresh cryptographic +/// anchor. #[tauri::command] pub fn logout(app: tauri::AppHandle) -> Result<(), String> { token_store::delete(&app)?; - let dir = auth_dir(&app)?; - let _ = fs::remove_file(dir.join(ACCOUNT_FILE)); + account_cache::delete(&app)?; Ok(()) } diff --git a/src-tauri/src/commands/license_commands.rs b/src-tauri/src/commands/license_commands.rs index 8092a73..8d9bf14 100644 --- a/src-tauri/src/commands/license_commands.rs +++ b/src-tauri/src/commands/license_commands.rs @@ -267,16 +267,16 @@ pub(crate) fn current_edition(app: &tauri::AppHandle) -> 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. +/// Read the HMAC-verified account cache to check for an active Premium +/// subscription. Legacy unsigned caches (from v0.7.x) and tampered +/// payloads return None — Premium features stay locked until the user +/// re-authenticates or the next token refresh re-signs the cache. +/// +/// This is intentional: before HMAC signing, any local process could +/// write `{"subscription_status": "active"}` to account.json and +/// bypass the paywall. Fail-closed is the correct posture here. fn check_account_edition(app: &tauri::AppHandle) -> Option { - let 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()?; + let account = super::account_cache::load_verified(app).ok().flatten()?; match account.subscription_status.as_deref() { Some("active") => Some(EDITION_PREMIUM.to_string()), _ => None, diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 0cea39e..45c17a1 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,3 +1,4 @@ +pub mod account_cache; pub mod auth_commands; pub mod entitlements; pub mod export_import_commands;