Introduce a new token_store module that persists OAuth tokens in the OS keychain (Credential Manager on Windows, Secret Service on Linux through sync-secret-service + crypto-rust, both pure-Rust backends). - Keychain service name matches the Tauri bundle identifier (com.simpl.resultat) so credentials are scoped to the real app identity. - Transparent migration on first load: a legacy tokens.json is copied into the keychain, then zeroed and unlinked before removal to reduce refresh-token recoverability from unallocated disk blocks. - Store-mode flag (keychain|file) persisted next to the auth dir. After a successful keychain write the store refuses to silently downgrade to the file fallback, so a subsequent failure forces re-authentication instead of leaking plaintext. - New get_token_store_mode command exposes the current mode to the frontend so a settings banner can warn users running on the file fallback. - auth_commands.rs refactored: all tokens.json read/write/delete paths go through token_store; check_subscription_status now uses token_store::load().is_some() to trigger migration even when the 24h throttle would early-return. Refs #66 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
393 lines
14 KiB
Rust
393 lines
14 KiB
Rust
// OAuth token storage abstraction.
|
|
//
|
|
// This module centralises how OAuth2 tokens are persisted. It tries the OS
|
|
// keychain first (Credential Manager on Windows, Secret Service on Linux
|
|
// via libdbus) and falls back to a restricted file on disk if the keychain
|
|
// is unavailable.
|
|
//
|
|
// Security properties:
|
|
// - The keychain service name matches the Tauri bundle identifier
|
|
// (com.simpl.resultat) so OS tools and future macOS builds can scope
|
|
// credentials correctly.
|
|
// - A `store_mode` flag is persisted next to the fallback file. Once the
|
|
// keychain has been used successfully, the store refuses to silently
|
|
// downgrade to the file fallback: a subsequent keychain failure is
|
|
// surfaced as an error so the caller can force re-authentication
|
|
// instead of leaving the user with undetected plaintext tokens.
|
|
// - Migration from an existing `tokens.json` file zeros the file contents
|
|
// and fsyncs before unlinking, reducing the window where the refresh
|
|
// token is recoverable from unallocated disk blocks.
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
use std::fs;
|
|
use std::io::Write;
|
|
use std::path::{Path, PathBuf};
|
|
use tauri::Manager;
|
|
use zeroize::Zeroize;
|
|
|
|
// Keychain identifiers. The service name matches tauri.conf.json's
|
|
// `identifier` so credentials are scoped to the real app identity.
|
|
const KEYCHAIN_SERVICE: &str = "com.simpl.resultat";
|
|
const KEYCHAIN_USER_TOKENS: &str = "oauth-tokens";
|
|
|
|
pub(crate) const AUTH_DIR: &str = "auth";
|
|
const TOKENS_FILE: &str = "tokens.json";
|
|
const STORE_MODE_FILE: &str = "store_mode";
|
|
|
|
/// Where token material currently lives. Exposed via a Tauri command so
|
|
/// the frontend can display a security banner when the app has fallen
|
|
/// back to the file store.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "lowercase")]
|
|
pub enum StoreMode {
|
|
Keychain,
|
|
File,
|
|
}
|
|
|
|
impl StoreMode {
|
|
fn as_str(&self) -> &'static str {
|
|
match self {
|
|
StoreMode::Keychain => "keychain",
|
|
StoreMode::File => "file",
|
|
}
|
|
}
|
|
|
|
fn parse(raw: &str) -> Option<Self> {
|
|
match raw.trim() {
|
|
"keychain" => Some(StoreMode::Keychain),
|
|
"file" => Some(StoreMode::File),
|
|
_ => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Serialised OAuth token bundle. Owned by `token_store` because this is
|
|
/// the only module that should reach for the persisted bytes.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct StoredTokens {
|
|
pub access_token: String,
|
|
pub refresh_token: Option<String>,
|
|
pub id_token: Option<String>,
|
|
pub expires_at: i64,
|
|
}
|
|
|
|
/// Resolve `<app_data_dir>/auth/`, creating it if needed. Shared with
|
|
/// auth_commands.rs which still writes non-secret files (account info,
|
|
/// last-check timestamp) in the same directory.
|
|
pub(crate) fn auth_dir(app: &tauri::AppHandle) -> Result<PathBuf, String> {
|
|
let dir = app
|
|
.path()
|
|
.app_data_dir()
|
|
.map_err(|e| format!("Cannot get app data dir: {}", e))?
|
|
.join(AUTH_DIR);
|
|
if !dir.exists() {
|
|
fs::create_dir_all(&dir).map_err(|e| format!("Cannot create auth dir: {}", e))?;
|
|
}
|
|
Ok(dir)
|
|
}
|
|
|
|
/// Write a file with 0600 permissions on Unix. Windows has no cheap
|
|
/// equivalent here; callers that rely on this function for secrets should
|
|
/// treat the Windows path as a last-resort fallback and surface the
|
|
/// degraded state to the user (see StoreMode).
|
|
pub(crate) fn write_restricted(path: &Path, contents: &str) -> Result<(), String> {
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::OpenOptionsExt;
|
|
let mut file = fs::OpenOptions::new()
|
|
.write(true)
|
|
.create(true)
|
|
.truncate(true)
|
|
.mode(0o600)
|
|
.open(path)
|
|
.map_err(|e| format!("Cannot write {}: {}", path.display(), e))?;
|
|
file.write_all(contents.as_bytes())
|
|
.map_err(|e| format!("Cannot write {}: {}", path.display(), e))?;
|
|
file.sync_all()
|
|
.map_err(|e| format!("Cannot fsync {}: {}", path.display(), e))?;
|
|
}
|
|
#[cfg(not(unix))]
|
|
{
|
|
fs::write(path, contents)
|
|
.map_err(|e| format!("Cannot write {}: {}", path.display(), e))?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) fn chrono_now() -> i64 {
|
|
std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.unwrap_or_default()
|
|
.as_secs() as i64
|
|
}
|
|
|
|
fn read_store_mode(dir: &Path) -> Option<StoreMode> {
|
|
let path = dir.join(STORE_MODE_FILE);
|
|
let raw = fs::read_to_string(&path).ok()?;
|
|
StoreMode::parse(&raw)
|
|
}
|
|
|
|
fn write_store_mode(dir: &Path, mode: StoreMode) -> Result<(), String> {
|
|
write_restricted(&dir.join(STORE_MODE_FILE), mode.as_str())
|
|
}
|
|
|
|
/// Overwrite the file contents with zeros, fsync, then unlink. Best-effort
|
|
/// mitigation against recovery of the refresh token from unallocated
|
|
/// blocks on copy-on-write filesystems where a plain unlink leaves the
|
|
/// ciphertext recoverable. Not a substitute for proper disk encryption.
|
|
fn zero_and_delete(path: &Path) -> Result<(), String> {
|
|
if !path.exists() {
|
|
return Ok(());
|
|
}
|
|
let len = fs::metadata(path)
|
|
.map(|m| m.len() as usize)
|
|
.unwrap_or(0)
|
|
.max(512);
|
|
let mut zeros = vec![0u8; len];
|
|
{
|
|
let mut f = fs::OpenOptions::new()
|
|
.write(true)
|
|
.truncate(false)
|
|
.open(path)
|
|
.map_err(|e| format!("Cannot open {} for wipe: {}", path.display(), e))?;
|
|
f.write_all(&zeros)
|
|
.map_err(|e| format!("Cannot zero {}: {}", path.display(), e))?;
|
|
f.sync_all()
|
|
.map_err(|e| format!("Cannot fsync {}: {}", path.display(), e))?;
|
|
}
|
|
zeros.zeroize();
|
|
fs::remove_file(path).map_err(|e| format!("Cannot remove {}: {}", path.display(), e))
|
|
}
|
|
|
|
fn tokens_to_json(tokens: &StoredTokens) -> Result<String, String> {
|
|
serde_json::to_string(tokens).map_err(|e| format!("Serialize error: {}", e))
|
|
}
|
|
|
|
fn tokens_from_json(raw: &str) -> Result<StoredTokens, String> {
|
|
serde_json::from_str(raw).map_err(|e| format!("Invalid tokens payload: {}", e))
|
|
}
|
|
|
|
fn keychain_entry() -> Result<keyring::Entry, keyring::Error> {
|
|
keyring::Entry::new(KEYCHAIN_SERVICE, KEYCHAIN_USER_TOKENS)
|
|
}
|
|
|
|
fn keychain_save(json: &str) -> Result<(), keyring::Error> {
|
|
keychain_entry()?.set_password(json)
|
|
}
|
|
|
|
fn keychain_load() -> Result<Option<String>, keyring::Error> {
|
|
match keychain_entry()?.get_password() {
|
|
Ok(v) => Ok(Some(v)),
|
|
Err(keyring::Error::NoEntry) => Ok(None),
|
|
Err(e) => Err(e),
|
|
}
|
|
}
|
|
|
|
fn keychain_delete() -> Result<(), keyring::Error> {
|
|
match keychain_entry()?.delete_credential() {
|
|
Ok(()) => Ok(()),
|
|
Err(keyring::Error::NoEntry) => Ok(()),
|
|
Err(e) => Err(e),
|
|
}
|
|
}
|
|
|
|
/// Persist the current OAuth token bundle.
|
|
///
|
|
/// Tries the OS keychain first. If the keychain write fails AND the
|
|
/// persisted `store_mode` flag shows the keychain has worked before, the
|
|
/// caller receives an error instead of a silent downgrade — this
|
|
/// prevents a hostile local process from forcing the app into the
|
|
/// weaker file-fallback path. On a fresh install (no prior flag), the
|
|
/// fallback is allowed but recorded so subsequent calls know the app is
|
|
/// running in degraded mode.
|
|
pub fn save(app: &tauri::AppHandle, tokens: &StoredTokens) -> Result<(), String> {
|
|
let json = tokens_to_json(tokens)?;
|
|
let dir = auth_dir(app)?;
|
|
let previous_mode = read_store_mode(&dir);
|
|
|
|
match keychain_save(&json) {
|
|
Ok(()) => {
|
|
// Keychain succeeded. Clean up any residual file from a prior
|
|
// fallback or migration source so nothing stays readable.
|
|
let residual = dir.join(TOKENS_FILE);
|
|
if residual.exists() {
|
|
let _ = zero_and_delete(&residual);
|
|
}
|
|
write_store_mode(&dir, StoreMode::Keychain)?;
|
|
Ok(())
|
|
}
|
|
Err(err) => {
|
|
if previous_mode == Some(StoreMode::Keychain) {
|
|
// Refuse to downgrade after a prior success — surface the
|
|
// failure so the caller can force re-auth instead of
|
|
// silently leaking tokens to disk.
|
|
return Err(format!(
|
|
"Keychain unavailable after prior success — refusing to downgrade. \
|
|
Original error: {}",
|
|
err
|
|
));
|
|
}
|
|
eprintln!(
|
|
"token_store: keychain unavailable, falling back to file store ({})",
|
|
err
|
|
);
|
|
write_restricted(&dir.join(TOKENS_FILE), &json)?;
|
|
write_store_mode(&dir, StoreMode::File)?;
|
|
Ok(())
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Load the current OAuth token bundle.
|
|
///
|
|
/// Tries the keychain first. If the keychain is empty but a legacy
|
|
/// `tokens.json` file exists, this is a first-run migration: the tokens
|
|
/// are copied into the keychain, the file is zeroed and unlinked, and
|
|
/// `store_mode` is updated. If the keychain itself is unreachable, the
|
|
/// function falls back to reading the file — unless the `store_mode`
|
|
/// flag indicates the keychain has worked before, in which case it
|
|
/// returns an error to force re-auth.
|
|
pub fn load(app: &tauri::AppHandle) -> Result<Option<StoredTokens>, String> {
|
|
let dir = auth_dir(app)?;
|
|
let previous_mode = read_store_mode(&dir);
|
|
let residual = dir.join(TOKENS_FILE);
|
|
|
|
match keychain_load() {
|
|
Ok(Some(raw)) => {
|
|
let tokens = tokens_from_json(&raw)?;
|
|
// Defensive: if a leftover file is still around (e.g. a prior
|
|
// crash between keychain write and file delete), clean it up.
|
|
if residual.exists() {
|
|
let _ = zero_and_delete(&residual);
|
|
}
|
|
if previous_mode != Some(StoreMode::Keychain) {
|
|
write_store_mode(&dir, StoreMode::Keychain)?;
|
|
}
|
|
Ok(Some(tokens))
|
|
}
|
|
Ok(None) => {
|
|
// Keychain reachable but empty. Migrate from a legacy file if
|
|
// one exists, otherwise report no stored session.
|
|
if residual.exists() {
|
|
let raw = fs::read_to_string(&residual)
|
|
.map_err(|e| format!("Cannot read {}: {}", residual.display(), e))?;
|
|
let tokens = tokens_from_json(&raw)?;
|
|
// Push into keychain and wipe the file. If the keychain
|
|
// push fails here, we keep the file rather than losing
|
|
// the user's session.
|
|
match keychain_save(&raw) {
|
|
Ok(()) => {
|
|
let _ = zero_and_delete(&residual);
|
|
write_store_mode(&dir, StoreMode::Keychain)?;
|
|
}
|
|
Err(e) => {
|
|
eprintln!("token_store: migration to keychain failed ({})", e);
|
|
write_store_mode(&dir, StoreMode::File)?;
|
|
}
|
|
}
|
|
Ok(Some(tokens))
|
|
} else {
|
|
Ok(None)
|
|
}
|
|
}
|
|
Err(err) => {
|
|
if previous_mode == Some(StoreMode::Keychain) {
|
|
return Err(format!(
|
|
"Keychain unavailable after prior success: {}",
|
|
err
|
|
));
|
|
}
|
|
// No prior keychain success: honour the file fallback if any.
|
|
eprintln!(
|
|
"token_store: keychain unreachable, using file fallback ({})",
|
|
err
|
|
);
|
|
if residual.exists() {
|
|
let raw = fs::read_to_string(&residual)
|
|
.map_err(|e| format!("Cannot read {}: {}", residual.display(), e))?;
|
|
write_store_mode(&dir, StoreMode::File)?;
|
|
Ok(Some(tokens_from_json(&raw)?))
|
|
} else {
|
|
Ok(None)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Delete the stored tokens from both the keychain and the file
|
|
/// fallback. Both deletions are best-effort and ignore "no entry"
|
|
/// errors to stay idempotent.
|
|
pub fn delete(app: &tauri::AppHandle) -> Result<(), String> {
|
|
let dir = auth_dir(app)?;
|
|
if let Err(err) = keychain_delete() {
|
|
eprintln!("token_store: keychain delete failed ({})", err);
|
|
}
|
|
let residual = dir.join(TOKENS_FILE);
|
|
if residual.exists() {
|
|
let _ = zero_and_delete(&residual);
|
|
}
|
|
// Leave the store_mode flag alone: it still describes what the app
|
|
// should trust the next time `save` is called.
|
|
Ok(())
|
|
}
|
|
|
|
/// Current store mode, derived from the persisted flag. Returns `None`
|
|
/// if no tokens have ever been written (no flag file).
|
|
pub fn store_mode(app: &tauri::AppHandle) -> Result<Option<StoreMode>, String> {
|
|
let dir = auth_dir(app)?;
|
|
Ok(read_store_mode(&dir))
|
|
}
|
|
|
|
/// Tauri command: expose the current store mode to the frontend.
|
|
/// Returns `"keychain"`, `"file"`, or `null` if the app has no stored
|
|
/// session yet. Used by the settings UI to show a security banner when
|
|
/// the fallback is active.
|
|
#[tauri::command]
|
|
pub fn get_token_store_mode(app: tauri::AppHandle) -> Result<Option<String>, String> {
|
|
Ok(store_mode(&app)?.map(|m| m.as_str().to_string()))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn store_mode_roundtrip() {
|
|
assert_eq!(StoreMode::parse("keychain"), Some(StoreMode::Keychain));
|
|
assert_eq!(StoreMode::parse("file"), Some(StoreMode::File));
|
|
assert_eq!(StoreMode::parse("other"), None);
|
|
assert_eq!(StoreMode::Keychain.as_str(), "keychain");
|
|
assert_eq!(StoreMode::File.as_str(), "file");
|
|
}
|
|
|
|
#[test]
|
|
fn stored_tokens_serde_roundtrip() {
|
|
let tokens = StoredTokens {
|
|
access_token: "at".into(),
|
|
refresh_token: Some("rt".into()),
|
|
id_token: Some("it".into()),
|
|
expires_at: 42,
|
|
};
|
|
let json = tokens_to_json(&tokens).unwrap();
|
|
let decoded = tokens_from_json(&json).unwrap();
|
|
assert_eq!(decoded.access_token, "at");
|
|
assert_eq!(decoded.refresh_token.as_deref(), Some("rt"));
|
|
assert_eq!(decoded.id_token.as_deref(), Some("it"));
|
|
assert_eq!(decoded.expires_at, 42);
|
|
}
|
|
|
|
#[test]
|
|
fn zero_and_delete_removes_file() {
|
|
use std::io::Write as _;
|
|
let tmp = std::env::temp_dir().join(format!(
|
|
"simpl-resultat-token-store-test-{}",
|
|
std::process::id()
|
|
));
|
|
let mut f = fs::File::create(&tmp).unwrap();
|
|
f.write_all(b"sensitive").unwrap();
|
|
drop(f);
|
|
assert!(tmp.exists());
|
|
zero_and_delete(&tmp).unwrap();
|
|
assert!(!tmp.exists());
|
|
}
|
|
}
|