Protect API keys with native credential storage

- Move Windows storage to DPAPI-backed local app data
- Keep non-Windows storage on the native credential store
- Update docs and UI copy to describe the new behavior
This commit is contained in:
stan44 2026-04-05 17:09:38 -05:00
parent 3a051c8012
commit 20b7c909d6
8 changed files with 666 additions and 27 deletions

492
DEV-CHEATSHEET.md Normal file
View File

@ -0,0 +1,492 @@
# Dev Cheat Sheet
Concise command reference for day-to-day work across Git, Python, Rust, Node, Tauri, and .NET.
Use this as a practical command sheet, not as a full tutorial.
## Table of Contents 📑
- [Setup](#setup)
- [Git](#git)
- [PAQ-Next (Advanced Compression)](#paq-next-advanced-compression)
- [Python](#python)
- [Node and npm](#node-and-npm)
- [Rust](#rust)
- [Tauri v2](#tauri-v2)
- [Dotnet and CSharp](#dotnet-and-csharp)
- [Common Output Folder Pattern](#common-output-folder-pattern)
- [Docker](#docker)
- [Useful Cleanup Commands](#useful-cleanup-commands)
- [Recommended Habits](#recommended-habits)
- [Workflows and SDT DevTool](#workflows-and-sdt-devtool)
## Setup
### Setup w64devkit for C++ (PAQ-Next)
```powershell
$env:PATH = "f:\w64devkit\w64devkit\bin;" + $env:PATH
```
Adds GCC/G++ and Unix tools to the current PowerShell session.
### Probe toolchain availability
```powershell
python scripts/diag.py probe --tool dotnet --json
python scripts/diag.py probe --tool python --json
python scripts/diag.py probe --tool node --json
python scripts/diag.py probe --tool npm --json
python scripts/diag.py probe --tool cargo --json
python scripts/diag.py probe --tool tauri --json
python scripts/diag.py probe --tool git --json
```
Checks if required tools are in the current path.
### Shell bootstrap (doctor)
```powershell
python scripts/dev_shell.py doctor
```
Analyzes the environment and reports missing components or configuration errors.
[Back to Top](#dev-cheat-sheet)
## Git
### Check where you are
```powershell
git status --short --branch
git remote -v
git branch -vv
# See which files are ignored by git
git clean -nd
```
### Stashing
```powershell
git stash push -m "Description"
git stash pop
git stash drop # Discard
git stash clear # Wipe all stashes
```
### Remotes & Pushing - THE FLAGS
```powershell
# Safer 'force push' (fails if remote has moved)
git push --force-with-lease
# Push all branches and all tags at once
git push --all
git push --tags
# Delete a remote branch
git push origin --delete branch-name
# Set upstream for current branch (first push)
git push -u origin branch-name
```
### History & Analysis
```powershell
# Graphical log in terminal
git log --graph --oneline --decorate --all
# Search commit messages
git log --grep="bug fix"
# Find which commit introduced a string
git log -S "secret_key"
# See changes per line (last change for every line)
git blame file_path.py
```
### Git Repository Cleanup
```powershell
# Fast cleanup of untracked files
git clean -fd
# Prune stale remote tracking branches
git fetch origin --prune
```
 
[🔼 Back to Top](#dev-cheat-sheet)
## PAQ-Next (Advanced Compression)
### Compile PAQ-Next (C++)
```powershell
# Add toolchain first (if not in path)
$env:PATH = "f:\w64devkit\w64devkit\bin;" + $env:PATH
# Build from root of src-paq-next/
g++ -O3 -mavx2 -Iinclude cli/main.cpp src/archiver.cpp -o paq_next_cli.exe
```
Compiles with AVX2 optimizations and include-path support.
### Run PAQ-Next Compression
```powershell
# Compress a folder
.\paq_next_cli.exe c <source_folder>
# Compress a folder to a specific file
.\paq_next_cli.exe c <source_folder> <output_name>.paq
# Extract an archive
.\paq_next_cli.exe x <archive>.paq <destination_folder>
```
[Back to Top](#dev-cheat-sheet)
## Python
### Package Management - THE FLAGS
```powershell
# Install from requirements with upgrades
pip install -r requirements.txt --upgrade
# Install without saving to cache (Great for CI/Low disk)
pip install -r requirements.txt --no-cache-dir
# Target a specific directory
pip install -t ./lib <package>
# Search local packages for a string
pip list | Select-String "crypto"
# See everything about a package
pip show <package-name>
```
### Quality & Performance
```powershell
# Parallel testing
pip install pytest-xdist
pytest -n auto # Use all CPU cores
# Benchmark a script
python -m cProfile -s time script.py
```
&nbsp;
[🔼 Back to Top](#dev-cheat-sheet)
## Node and npm
### Dependency Management - THE FLAGS
```powershell
# Force install (when versions conflict)
npm install --force
# Ignore peer dependency errors (Common in React)
npm install --legacy-peer-deps
# Production only (Skip devDependencies)
npm install --production
# Deep security audit and auto-fix
npm audit fix --force
```
### Scripts & Performance
```powershell
# Build with specific log level
npm run build --loglevel silent
# Pass custom flags directly to a script
npm run dev -- --host 0.0.0.0 --port 3000
```
&nbsp;
[🔼 Back to Top](#dev-cheat-sheet)
## Rust
### Essential Workflow - THE FLAGS
```powershell
# Fast check (Skip code generation)
cargo check --all-targets --all-features
# Build with specific features enabled
cargo build --features "feature-a,feature-b"
# Build without default features
cargo build --no-default-features
# Cross-compilation (Requires target in toolchain)
cargo build --target x86_64-apple-darwin
cargo build --target aarch64-unknown-linux-gnu
```
### Diagnostics & Performance
```powershell
# See exactly why a build is slow
cargo build --timings
# Run specific test by name/substring
cargo test test_function_name
# Open local docs for all dependencies
cargo doc --open --no-deps
```
&nbsp;
[🔼 Back to Top](#dev-cheat-sheet)
## Tauri v2
### Initialize Mobile (REQUIRED for first run)
```powershell
# This creates the android/ios project folders in src-tauri/gen
npm run tauri android init
npm run tauri ios init
```
### Desktop Builds - THE FLAGS
```powershell
# Fast build (Binary only)
npm run tauri build -- --no-bundle
# Explicit config file (Great for multi-env)
npm run tauri build -- --config path/to/config.json
# Verbose output for debugging packaging
npm run tauri build -- --verbose
```
### Mobile Builds
```powershell
# Debug mode on Android device (or Emulator)
npm run tauri android dev
# Production APK/AAB generation
npm run tauri android build -- --target aarch64
```
### Troubleshooting
```powershell
# System diagnostic report
npm run tauri info
```
&nbsp;
[🔼 Back to Top](#dev-cheat-sheet)
### Frontend-only build
```powershell
npm run build
```
Builds the web frontend assets (the `dist/` folder) without launching or packaging the desktop app.
## Dotnet and CSharp
### Restore & Build - THE FLAGS
```powershell
# Restore from specific NuGet source
dotnet restore --source https://api.nuget.org/v3/index.json
# Build without restoring (Great for build script repetition)
dotnet build --no-restore
# Build with minimal output (Keep it clean)
dotnet build --verbosity quiet # or minimal, normal, detailed, diagnostic
```
### Run & Watch
```powershell
# Run with custom environment variables
$env:ASPNETCORE_ENVIRONMENT="Development"; dotnet run
# Watch with specific project filter
dotnet watch --project Project.csproj
```
### Advanced Publishing
```powershell
# Single-File for Linux (Self-Contained)
dotnet publish -c Release -r linux-x64 --self-contained true `
-p:PublishSingleFile=true `
-p:IncludeNativeLibrariesForSelfExtract=true `
-o ./output/linux-x64-single
# Ready-To-Run (R2R) - Fast startup at the cost of size
dotnet publish -c Release -o ./output -p:PublishReadyToRun=true
```
### Tools
```powershell
# Install a dotnet tool globally
dotnet tool install -g <tool-name>
```
&nbsp;
[🔼 Back to Top](#dev-cheat-sheet)
## Common Output Folder Pattern
Use explicit output folders so results are easy to find and easy to clean up.
```powershell
./output/
./output/win-x64/
./output/win-x64-single/
./output/linux-x64/
./output/linux-x64-single/
```
[Back to Top](#dev-cheat-sheet)
## Docker
### Build & Run
```powershell
docker build -t app-name .
docker run -p 8080:80 app-name
```
### Docker System Cleanup
```powershell
docker system prune -a # Wipe everything unused
docker container prune
docker image prune
```
[Back to Top](#dev-cheat-sheet)
## Useful Cleanup Commands
### Remove Node artifacts
```powershell
Remove-Item -Recurse -Force .\node_modules
Remove-Item -Recurse -Force .\dist
```
### Remove Rust / Tauri artifacts
```powershell
Remove-Item -Recurse -Force .\src-tauri\target
```
### Remove .NET artifacts (Deep clean)
```powershell
Get-ChildItem -Include bin,obj,output -Recurse | Remove-Item -Recurse -Force
```
### Remove Python artifacts
```powershell
Remove-Item -Recurse -Force .\.venv
Get-ChildItem -Include __pycache__ -Recurse | Remove-Item -Recurse -Force
```
[Back to Top](#dev-cheat-sheet)
## Recommended Habits
Before any risky Git action:
```powershell
git status --short --branch
git branch backup/pre-risk
git tag pre-risk-YYYY-MM-DD
git fetch origin --prune
```
Before publishing builds:
```powershell
npm run build
cargo test
dotnet test
```
Adjust to the stack used by the current project.
[Back to Top](#dev-cheat-sheet)
## Workflows and SDT DevTool
### Build / Run SDT (DevTool)
```powershell
# Quick build
python scripts/dotnet-min.py build
# Standard run
dotnet run --project DevTool.csproj
```
### Route & Workflow Verification
```powershell
# Static check
python scripts/verify-workflow-routes.py --project-root .
# Headless execution test
python scripts/verify-workflow-routes.py --project-root . --workflow build --workflow tauri --execute --env-profile dev
```
### Build & Sync Output
```powershell
# Publish web assets + app
python scripts/publish-app.py --target web
# Publish specific projects
python scripts/publish-sidecar.py --project path/to/sidecar.csproj
python scripts/publish-webgateway.py --project path/to/gateway.csproj
# Sync all results to central output/
python scripts/sync-output.py
```
### Database Migration Gate
```powershell
python scripts/migration-gate.py
```
### NuGet / Node Cleanup
```powershell
# NuGet cache export
python scripts/nuget-export-cache.py --output-zip cache-export.zip
# Clean node_modules safely
python scripts/npm-clean.py --working-dir .
```
[Back to Top](#dev-cheat-sheet)

View File

@ -7,7 +7,7 @@ Thisper is a typing-first communication translator. It rewrites raw text into cl
- Desktop text-to-text rewrite UI with diff review
- Rewrite modes: `Preserve Voice`, `Clean`, `Readable`, `Formal`, `Concise`
- Gemini-backed cloud rewrite provider with typed model selection
- Secure Gemini API key storage through the system credential store
- Secure Gemini API key storage through native platform protection
- Global cross-app rewrite shortcut: `Ctrl + Alt + R`
- System tray support with background operation
- Non-destructive clipboard replacement flow: selected text is only replaced after the full rewrite succeeds
@ -62,7 +62,10 @@ If no API key is configured, Thisper brings the main window forward instead of a
### Credentials
Use the in-app Settings dialog to save the Gemini API key into your system credential store.
Use the in-app Settings dialog to save the Gemini API key using native platform protection.
- Windows: DPAPI-encrypted local app storage tied to your Windows user account
- Other supported desktop platforms: native credential store backend
For development, Thisper also accepts:

View File

@ -49,7 +49,7 @@
<div class="field">
<label for="api-key-input">Gemini API Key</label>
<input type="password" id="api-key-input" placeholder="Paste your API key here...">
<p class="hint">Stored in your system credential store. Thisper never writes the key to a plaintext file.</p>
<p class="hint">Stored securely using native platform protection. On Windows, Thisper uses DPAPI-encrypted app data. The key is never written in plaintext.</p>
</div>
<div class="modal-actions">
<button id="save-settings-btn" class="primary-btn">Save Settings</button>

12
src-tauri/Cargo.lock generated
View File

@ -4529,6 +4529,7 @@ dependencies = [
"tauri-plugin-clipboard-manager",
"tauri-plugin-global-shortcut",
"tauri-plugin-single-instance",
"windows-dpapi",
]
[[package]]
@ -5487,6 +5488,17 @@ dependencies = [
"windows-strings 0.5.1",
]
[[package]]
name = "windows-dpapi"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2981752d6f11bdcab4db52be8ad5c0e6a6d4d6d566764b3058cc1ee473e6479e"
dependencies = [
"anyhow",
"log",
"winapi",
]
[[package]]
name = "windows-future"
version = "0.2.1"

View File

@ -32,6 +32,9 @@ futures-util = "0.3.32"
keyring = "3.6.3"
regex = "1"
[target.'cfg(target_os = "windows")'.dependencies]
windows-dpapi = "0.2.0"
[profile.release]
panic = "abort"
opt-level = "s"

View File

@ -0,0 +1,134 @@
use tauri::AppHandle;
const KEYRING_SERVICE: &str = "thisper";
const KEYRING_ACCOUNT: &str = "gemini_api_key";
pub fn load_api_key(app: &AppHandle) -> Result<Option<String>, String> {
backend::load_api_key(app)
}
pub fn save_api_key(app: &AppHandle, key: &str) -> Result<(), String> {
backend::save_api_key(app, key)
}
pub fn storage_description() -> &'static str {
backend::storage_description()
}
#[cfg(target_os = "windows")]
mod backend {
use super::{KEYRING_ACCOUNT, KEYRING_SERVICE};
use std::fs;
use std::path::PathBuf;
use tauri::{AppHandle, Manager};
use windows_dpapi::{Scope, decrypt_data, encrypt_data};
const API_KEY_FILE: &str = "gemini_api_key.dpapi";
fn encrypted_file_path(app: &AppHandle) -> Result<PathBuf, String> {
let dir = app
.path()
.app_local_data_dir()
.map_err(|err| format!("Failed to resolve local app data directory: {err}"))?;
Ok(dir.join("credentials").join(API_KEY_FILE))
}
fn load_legacy_keyring_value() -> Result<Option<String>, String> {
let entry = keyring::Entry::new(KEYRING_SERVICE, KEYRING_ACCOUNT)
.map_err(|err| format!("Failed to access legacy credential store: {err}"))?;
match entry.get_password() {
Ok(password) if !password.trim().is_empty() => Ok(Some(password)),
Ok(_) => Ok(None),
Err(keyring::Error::NoEntry) => Ok(None),
Err(err) => Err(format!("Failed to read legacy credential store: {err}")),
}
}
fn delete_legacy_keyring_value() {
if let Ok(entry) = keyring::Entry::new(KEYRING_SERVICE, KEYRING_ACCOUNT) {
let _ = entry.delete_credential();
}
}
pub fn load_api_key(app: &AppHandle) -> Result<Option<String>, String> {
let path = encrypted_file_path(app)?;
if path.exists() {
let encrypted = fs::read(&path)
.map_err(|err| format!("Failed to read encrypted API key: {err}"))?;
if encrypted.is_empty() {
return Ok(None);
}
let decrypted = decrypt_data(&encrypted, Scope::User, None)
.map_err(|err| format!("Failed to decrypt API key: {err}"))?;
let key = String::from_utf8(decrypted)
.map_err(|err| format!("Failed to decode decrypted API key: {err}"))?;
if key.trim().is_empty() {
return Ok(None);
}
return Ok(Some(key));
}
if let Some(legacy_key) = load_legacy_keyring_value()? {
save_api_key(app, &legacy_key)?;
delete_legacy_keyring_value();
return Ok(Some(legacy_key));
}
Ok(None)
}
pub fn save_api_key(app: &AppHandle, key: &str) -> Result<(), String> {
let path = encrypted_file_path(app)?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|err| format!("Failed to create credential directory: {err}"))?;
}
let encrypted = encrypt_data(key.as_bytes(), Scope::User, None)
.map_err(|err| format!("Failed to encrypt API key with Windows DPAPI: {err}"))?;
fs::write(&path, encrypted)
.map_err(|err| format!("Failed to write encrypted API key: {err}"))?;
Ok(())
}
pub fn storage_description() -> &'static str {
"Stored securely using Windows DPAPI in Thisper's local app data directory. The key is encrypted for your Windows user account and is never written in plaintext."
}
}
#[cfg(not(target_os = "windows"))]
mod backend {
use super::{KEYRING_ACCOUNT, KEYRING_SERVICE};
use tauri::AppHandle;
pub fn load_api_key(_app: &AppHandle) -> Result<Option<String>, String> {
let entry = keyring::Entry::new(KEYRING_SERVICE, KEYRING_ACCOUNT)
.map_err(|err| format!("Failed to access native credential storage: {err}"))?;
match entry.get_password() {
Ok(password) if !password.trim().is_empty() => Ok(Some(password)),
Ok(_) => Ok(None),
Err(keyring::Error::NoEntry) => Ok(None),
Err(err) => Err(format!("Failed to read native credential storage: {err}")),
}
}
pub fn save_api_key(_app: &AppHandle, key: &str) -> Result<(), String> {
let entry = keyring::Entry::new(KEYRING_SERVICE, KEYRING_ACCOUNT)
.map_err(|err| format!("Failed to access native credential storage: {err}"))?;
entry
.set_password(key)
.map_err(|err| format!("Failed to save API key securely: {err}"))?;
Ok(())
}
pub fn storage_description() -> &'static str {
"Stored securely using the native platform credential store. Thisper never writes the key in plaintext."
}
}

View File

@ -1,3 +1,4 @@
pub mod credentials;
pub mod gemini;
pub mod translator;
@ -17,8 +18,6 @@ use tauri::{AppHandle, Emitter, Manager, State, WebviewWindow, WindowEvent};
use tauri_plugin_clipboard_manager::ClipboardExt;
use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut};
const KEYRING_SERVICE: &str = "thisper";
const KEYRING_ACCOUNT: &str = "gemini_api_key";
const MAIN_WINDOW_LABEL: &str = "main";
const TRAY_ID: &str = "main-tray";
const MENU_SHOW_ID: &str = "show";
@ -69,17 +68,6 @@ struct RuntimeState {
last_metrics: Option<RewriteMetrics>,
}
fn load_saved_api_key() -> Result<Option<String>, String> {
let entry = keyring::Entry::new(KEYRING_SERVICE, KEYRING_ACCOUNT)
.map_err(|err| format!("Failed to access credential store: {err}"))?;
match entry.get_password() {
Ok(password) if !password.trim().is_empty() => Ok(Some(password)),
Ok(_) => Ok(None),
Err(keyring::Error::NoEntry) => Ok(None),
Err(err) => Err(format!("Failed to read credential store: {err}")),
}
}
fn api_key_configured(state: &AppState) -> bool {
let key = state.api_key.lock().unwrap();
!key.trim().is_empty() || std::env::var("GEMINI_API_KEY").is_ok()
@ -332,11 +320,7 @@ async fn save_api_key(
app: AppHandle,
state: State<'_, AppState>,
) -> Result<(), String> {
let entry = keyring::Entry::new(KEYRING_SERVICE, KEYRING_ACCOUNT)
.map_err(|err| format!("Failed to access credential store: {err}"))?;
entry
.set_password(&key)
.map_err(|err| format!("Failed to save API key securely: {err}"))?;
credentials::save_api_key(&app, &key)?;
{
let mut key_state = state.api_key.lock().unwrap();
@ -423,11 +407,7 @@ async fn run_clipboard_rewrite(app: AppHandle) -> Result<(), String> {
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let initial_key = load_saved_api_key()
.ok()
.flatten()
.or_else(|| std::env::var("GEMINI_API_KEY").ok())
.unwrap_or_default();
let initial_key = std::env::var("GEMINI_API_KEY").unwrap_or_default();
let provider =
Box::new(gemini::GeminiProvider::new().expect("Gemini provider initialization failed"))
@ -474,6 +454,21 @@ pub fn run() {
attach_window_lifecycle(&app.handle()).map_err(std::io::Error::other)?;
app.global_shortcut().register(shortcut_clone.clone())?;
match credentials::load_api_key(&app.handle()) {
Ok(Some(saved_key)) => {
let state: State<'_, AppState> = app.state();
let mut api_key = state.api_key.lock().unwrap();
*api_key = saved_key;
}
Ok(None) => {}
Err(err) => {
eprintln!("Credential storage load error: {err}");
let state: State<'_, AppState> = app.state();
let mut runtime = state.runtime.lock().unwrap();
runtime.last_error = Some(err);
}
}
{
let state: State<'_, AppState> = app.state();
let mut runtime = state.runtime.lock().unwrap();

View File

@ -161,7 +161,7 @@ saveSettingsBtn.addEventListener("click", async () => {
settingsModal.classList.add("hidden");
apiKeyInput.value = "";
await refreshRuntimeStatus();
alert("API key saved to your system credential store.");
alert("API key saved using native platform protection.");
} catch (err) {
alert("Error saving API key: " + err);
} finally {