// 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()); } }