Compare commits
2 commits
b684c88d2b
...
cf31666c35
| Author | SHA1 | Date | |
|---|---|---|---|
| cf31666c35 | |||
|
|
2d7d1e05d2 |
6 changed files with 320 additions and 34 deletions
1
src-tauri/Cargo.lock
generated
1
src-tauri/Cargo.lock
generated
|
|
@ -4430,6 +4430,7 @@ dependencies = [
|
|||
"base64 0.22.1",
|
||||
"ed25519-dalek",
|
||||
"encoding_rs",
|
||||
"hmac",
|
||||
"hostname",
|
||||
"jsonwebtoken",
|
||||
"keyring",
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
292
src-tauri/src/commands/account_cache.rs
Normal file
292
src-tauri/src/commands/account_cache.rs
Normal file
|
|
@ -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": "<base64>"}`. 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<Sha256>;
|
||||
|
||||
#[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<Option<AccountInfo>, 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::<SignedAccount>(&raw) {
|
||||
return Ok(Some(envelope.data));
|
||||
}
|
||||
if let Ok(flat) = serde_json::from_str::<AccountInfo>(&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<Option<AccountInfo>, 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::<SignedAccount>(&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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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<AccountInfo, St
|
|||
if !resp.status().is_success() {
|
||||
// Clear stored tokens on refresh failure.
|
||||
let _ = token_store::delete(&app);
|
||||
let dir = auth_dir(&app)?;
|
||||
let _ = fs::remove_file(dir.join(ACCOUNT_FILE));
|
||||
let _ = account_cache::delete(&app);
|
||||
return Err("Session expired, please sign in again".to_string());
|
||||
}
|
||||
|
||||
|
|
@ -222,34 +220,27 @@ pub async fn refresh_auth_token(app: tauri::AppHandle) -> Result<AccountInfo, St
|
|||
token_store::save(&app, &new_tokens)?;
|
||||
|
||||
let account = fetch_userinfo(&endpoint, &new_access).await?;
|
||||
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)?;
|
||||
account_cache::save(&app, &account)?;
|
||||
|
||||
Ok(account)
|
||||
}
|
||||
|
||||
/// Read cached account info without network call.
|
||||
/// Read cached account info without network call. Used for UI display
|
||||
/// only — accepts both signed (v0.8+) and legacy (v0.7.x) payloads so
|
||||
/// upgraded users still see their name/email immediately. The license
|
||||
/// gating path uses `account_cache::load_verified` instead.
|
||||
#[tauri::command]
|
||||
pub fn get_account_info(app: tauri::AppHandle) -> Result<Option<AccountInfo>, 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(())
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<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()?;
|
||||
let account = super::account_cache::load_verified(app).ok().flatten()?;
|
||||
match account.subscription_status.as_deref() {
|
||||
Some("active") => Some(EDITION_PREMIUM.to_string()),
|
||||
_ => None,
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
pub mod account_cache;
|
||||
pub mod auth_commands;
|
||||
pub mod entitlements;
|
||||
pub mod export_import_commands;
|
||||
|
|
|
|||
Loading…
Reference in a new issue