Simpl-Resultat/src-tauri/src/commands/account_cache.rs
le king fu 2d7d1e05d2
All checks were successful
PR Check / rust (push) Successful in 26m11s
PR Check / frontend (push) Successful in 2m20s
PR Check / rust (pull_request) Successful in 22m22s
PR Check / frontend (pull_request) Successful in 2m18s
feat: HMAC-sign cached account info to close subscription tampering (#80)
Before this change, `license_commands::check_account_edition` read
`account.json` directly and granted Premium when `subscription_status`
was `"active"`. Any local process could write that JSON and bypass
the paywall without ever touching the Logto session.

Introduce `account_cache` with:
- `save(app, &AccountInfo)` — signs the serialised AccountInfo with
  HMAC-SHA256 and writes a `{"data", "sig"}` envelope. The 32-byte
  key lives in the OS keychain (service `com.simpl.resultat`, user
  `account-hmac-key`) alongside the OAuth tokens from #78.
- `load_unverified` — accepts both signed and legacy payloads for UI
  display (name, email, picture). The license path must never use
  this.
- `load_verified` — requires a valid HMAC signature; returns None for
  legacy payloads, missing keychain, tampered data. Used by
  `check_account_edition` so Premium stays locked until the next
  token refresh re-signs the cache.
- `delete` — wipes both the file and the keychain key on logout so
  the next session generates a fresh cryptographic anchor.

`auth_commands::handle_auth_callback` and `refresh_auth_token` now
call `account_cache::save` instead of writing the file directly.
`logout` clears both stores. `get_account_info` delegates to
`load_unverified` so upgraded users see their profile immediately.

Trust boundary: the HMAC key lives in the keychain and shares its
security model with the OAuth tokens. If the keychain is unreachable,
the gating path refuses to grant Premium (fail-closed), which matches
the store_mode policy introduced in #78.

Refs #66, CWE-345

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 08:07:47 -04:00

292 lines
11 KiB
Rust

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