diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 2a8bc8f..c87d52d 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -101,6 +101,18 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures", + "password-hash", +] + [[package]] name = "arrayvec" version = "0.7.6" @@ -374,6 +386,15 @@ dependencies = [ "wyz", ] +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -1706,6 +1727,7 @@ dependencies = [ name = "getpostcraft" version = "0.3.10" dependencies = [ + "argon2", "async-trait", "base64 0.22.1", "chrono", @@ -1722,6 +1744,7 @@ dependencies = [ "serial_test", "sha2", "sqlx", + "subtle", "tauri", "tauri-build", "tauri-plugin-log", @@ -3245,6 +3268,17 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "pathdiff" version = "0.2.3" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index acb1323..fa8b7d7 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -60,6 +60,15 @@ keyring = { version = "3", features = ["apple-native", "windows-native", "sync-s # Already a transitive dep via sqlx; declared explicitly to make the # scheduler's intent visible. async-trait = "0.1" +# Password hashing for the Security Admin gate (Settings → Security). +# Argon2id with OWASP 2026 params (m=19MB, t=2, p=1). PHC string output +# stored in the OS keychain under `security_password_hash` — never in +# SQLite, so even a DB exfil yields no hash to crack. +argon2 = "0.5" +# Timing-safe equality for the in-RAM session token — guards against the +# theoretical side-channel where a non-constant comparison time leaks +# matched-prefix length. Cheap defense, no downside. +subtle = "2" [dev-dependencies] # HTTP mock server for testing publish_post / publish_linkedin_post without diff --git a/src-tauri/src/ai_keys.rs b/src-tauri/src/ai_keys.rs index d9d5094..6ccb5d4 100644 --- a/src-tauri/src/ai_keys.rs +++ b/src-tauri/src/ai_keys.rs @@ -45,6 +45,10 @@ const KNOWN_PROVIDERS: &[&str] = &[ "instagram_client_secret", "linkedin_client_secret", "imgbb_api_key", + // Argon2id PHC string for the Settings → Security gate. Not a "key" + // in the API-credential sense but the keychain treats them the same + // and we want load_all() to warm this entry alongside the others. + "security_password_hash", ]; /// Path to the legacy plain-text JSON file. Existence triggers one-time migration. @@ -286,6 +290,7 @@ mod tests { "instagram_client_secret", "linkedin_client_secret", "imgbb_api_key", + "security_password_hash", ] { assert!( KNOWN_PROVIDERS.contains(&required), diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index f71a0a6..2fef639 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -7,4 +7,5 @@ pub mod media; pub mod oauth; pub mod publisher; pub mod python_deps; +pub mod security_admin; pub mod settings; diff --git a/src-tauri/src/commands/security_admin.rs b/src-tauri/src/commands/security_admin.rs new file mode 100644 index 0000000..83e4091 --- /dev/null +++ b/src-tauri/src/commands/security_admin.rs @@ -0,0 +1,90 @@ +//! Tauri commands for the Settings → Security gate. +//! +//! Thin wrappers over `crate::security_admin` that map module types to +//! IPC-friendly shapes (snake_case JSON via serde). No business logic here. + +use crate::security_admin::{ + check_session, end_session, is_password_set, setup_password, verify_password, VerifyOutcome, +}; +use crate::state::AppState; +use serde::Serialize; + +/// Renderer-facing shape for [`VerifyOutcome`]. Tagged union (`kind`) +/// keeps the JSON unambiguous without leaning on serde defaults that +/// strip variant data. +#[derive(Debug, Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum VerifyResponse { + Ok { token: String }, + LockedOut { wait_seconds: u64 }, + NoPasswordSet, + Wrong, +} + +impl From for VerifyResponse { + fn from(o: VerifyOutcome) -> Self { + match o { + VerifyOutcome::Ok { token_b64 } => VerifyResponse::Ok { token: token_b64 }, + VerifyOutcome::LockedOut { wait } => VerifyResponse::LockedOut { + wait_seconds: wait.as_secs(), + }, + VerifyOutcome::NoPasswordSet => VerifyResponse::NoPasswordSet, + VerifyOutcome::Wrong => VerifyResponse::Wrong, + } + } +} + +/// Audit-log row exposed to the renderer. Only fields the UI needs. +#[derive(Debug, Serialize, sqlx::FromRow)] +pub struct AttemptRow { + pub id: i64, + pub attempted_at: String, + pub success: i64, + pub note: Option, +} + +#[tauri::command] +pub fn is_security_password_set() -> bool { + is_password_set() +} + +#[tauri::command] +pub async fn setup_security_password(plain: String) -> Result<(), String> { + setup_password(&plain) +} + +#[tauri::command] +pub async fn verify_security_password( + state: tauri::State<'_, AppState>, + plain: String, +) -> Result { + let outcome = verify_password(&plain, &state.security_admin, Some(&state.db)).await; + Ok(outcome.into()) +} + +#[tauri::command] +pub fn check_security_session(state: tauri::State<'_, AppState>, token: String) -> bool { + check_session(&state.security_admin, &token) +} + +#[tauri::command] +pub fn end_security_session(state: tauri::State<'_, AppState>) { + end_session(&state.security_admin); +} + +#[tauri::command] +pub async fn list_recent_security_attempts( + state: tauri::State<'_, AppState>, + limit: i64, +) -> Result, String> { + let capped = limit.clamp(1, 200); + sqlx::query_as::<_, AttemptRow>( + "SELECT id, attempted_at, success, note \ + FROM security_audit_attempts \ + ORDER BY attempted_at DESC LIMIT ?", + ) + .bind(capped) + .fetch_all(&state.db) + .await + .map_err(|e| format!("Cannot read audit attempts: {e}")) +} diff --git a/src-tauri/src/db/migrations/020_security_admin.sql b/src-tauri/src/db/migrations/020_security_admin.sql new file mode 100644 index 0000000..36b0384 --- /dev/null +++ b/src-tauri/src/db/migrations/020_security_admin.sql @@ -0,0 +1,55 @@ +-- Migration 020 — Security Admin (Settings → Security tab). +-- +-- Foundation for the in-app security dashboard. Two tables: +-- +-- `security_audit_attempts` — append-only log of password unlock attempts. +-- Feeds the lockout policy (consecutive_failures → escalating delay) and +-- gives a forensic trail if the user ever wants to see "did someone try?" +-- No PII recorded (mono-user desktop, IP fingerprints are useless). +-- +-- `security_audit_reports` — landing zone for the LLM auditor outputs. +-- Phase B writes here. Declared in migration 020 (not 021) so the schema +-- ships as a coherent unit with the gate. Empty until Phase B lands. +-- +-- The password hash itself is NOT stored in SQLite — it lives in the OS +-- keychain under provider name `security_password_hash` (Argon2id PHC +-- string). Reason: defense-in-depth. Even if someone reads app.db, they +-- get no hash to crack. See ADR-009 for the keychain rationale. + +CREATE TABLE IF NOT EXISTS security_audit_attempts ( + id INTEGER PRIMARY KEY, + attempted_at TEXT NOT NULL, -- RFC3339 UTC + success INTEGER NOT NULL, -- 0 = fail, 1 = success + -- Free-form context. Examples: "lockout: 5s", "setup", "session_expired". + -- Kept short (< 80 chars) on the write side. + note TEXT +); + +CREATE INDEX IF NOT EXISTS idx_security_audit_attempts_at + ON security_audit_attempts(attempted_at DESC); + +CREATE TABLE IF NOT EXISTS security_audit_reports ( + id INTEGER PRIMARY KEY, + agent_name TEXT NOT NULL, -- "llm-security-auditor", "prompt-guardrail-auditor" + triggered_at TEXT NOT NULL, -- RFC3339 UTC + duration_ms INTEGER, -- NULL until run completes + branch TEXT, -- git branch at run time + commit_sha TEXT, -- git HEAD at run time + score REAL, -- 0.0-10.0, nullable on failure + critical_count INTEGER NOT NULL DEFAULT 0, + high_count INTEGER NOT NULL DEFAULT 0, + medium_count INTEGER NOT NULL DEFAULT 0, + low_count INTEGER NOT NULL DEFAULT 0, + verdict TEXT, -- "ship-ready" | "ship-with-mitigations" | "block" | "error" + raw_report TEXT NOT NULL, -- full markdown body + input_tokens INTEGER, + output_tokens INTEGER, + cost_usd REAL, + error TEXT -- populated only when the run failed +); + +CREATE INDEX IF NOT EXISTS idx_security_audit_reports_at + ON security_audit_reports(triggered_at DESC); + +CREATE INDEX IF NOT EXISTS idx_security_audit_reports_agent + ON security_audit_reports(agent_name, triggered_at DESC); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f378118..4868205 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -6,6 +6,7 @@ mod log_redact; mod network_rules; mod openrouter_pricing; mod scheduler; +mod security_admin; mod sidecar; mod state; mod token_store; @@ -278,6 +279,13 @@ pub fn run() { // Python deps — in-app pip install for the sidecar packages commands::python_deps::check_python_deps, commands::python_deps::install_python_deps, + // Security Admin gate (Settings → Security tab) + commands::security_admin::is_security_password_set, + commands::security_admin::setup_security_password, + commands::security_admin::verify_security_password, + commands::security_admin::check_security_session, + commands::security_admin::end_security_session, + commands::security_admin::list_recent_security_attempts, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/security_admin.rs b/src-tauri/src/security_admin.rs new file mode 100644 index 0000000..2cec2fb --- /dev/null +++ b/src-tauri/src/security_admin.rs @@ -0,0 +1,666 @@ +//! Security Admin gate for the in-app Settings → Security tab. +//! +//! Mono-user desktop = the user IS admin by definition. The gate is not +//! protecting against external attackers — it's a deliberate friction layer +//! for: +//! - Accidental clicks (the audit panel triggers paid API calls) +//! - Screen sharing / shoulder-surfing during streams or demos +//! - Another OS user on a shared workstation +//! - Malware that finds the binary but can't run it without the password +//! +//! ## Threat model +//! +//! In scope: +//! - Brute-force: lockout escalation (3 fails → 5s, 5 → 30s, 10 → 5min) +//! - Hash extraction: keychain (OS-encrypted) + Argon2id (GPU-resistant) +//! - Side-channel timing on session token verify: `subtle::ConstantTimeEq` +//! +//! Out of scope: +//! - Privileged-malware that owns the OS keychain (no defense from +//! userspace possible) +//! - Memory dump while the app runs unlocked (session token in RAM) +//! - Reverse-engineering the binary (no obfuscation, not the goal) +//! +//! ## Storage +//! +//! - Password hash: OS keychain, provider name `security_password_hash`, +//! value = Argon2id PHC string. Never persists to SQLite. +//! - Lockout state: in-memory `Mutex` + append-only +//! `security_audit_attempts` table (forensic trail, not used for the +//! lockout decision itself — that's pure RAM so app-restart doesn't +//! bypass the policy through the SQLite cache). +//! - Session token: 32 random bytes from `OsRng`, base64-encoded for +//! transport to the renderer. Expires after [`SESSION_DURATION`], no +//! automatic refresh — the user re-enters the password. + +use argon2::{ + password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString}, + Algorithm, Argon2, Params, Version, +}; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use rand::RngCore; +use sqlx::SqlitePool; +use std::sync::Mutex; +use std::time::{Duration, Instant}; +use subtle::ConstantTimeEq; + +/// Keychain provider name. Kept in one place — must match the entry in +/// `ai_keys::KNOWN_PROVIDERS` so `load_all()` warms the cache at startup. +pub const KEYRING_PROVIDER: &str = "security_password_hash"; + +/// Minimum acceptable plain-text password length at setup time. Twelve is +/// a deliberate compromise between memorability for a solo user and +/// resistance to brute-force given the Argon2id cost we ship. +pub const MIN_PASSWORD_LEN: usize = 12; + +/// Session lifetime once verified. Re-prompt forces the user back through +/// the gate after this window — short enough that an unattended unlocked +/// app doesn't stay open all day, long enough that running a 5-minute +/// audit doesn't expire mid-run. +pub const SESSION_DURATION: Duration = Duration::from_secs(30 * 60); + +/// Argon2id memory cost in KiB. OWASP 2026 minimum is 19 MiB for +/// interactive password verification. +const ARGON2_MEMORY_KIB: u32 = 19 * 1024; +/// Argon2id time cost (iterations). 2 is OWASP-recommended for the above +/// memory cost. +const ARGON2_TIME_COST: u32 = 2; +/// Argon2id parallelism. 1 on desktop avoids cross-platform variance. +const ARGON2_PARALLELISM: u32 = 1; + +/// Construct the Argon2 instance with our pinned parameters. The +/// `Params::new` call panics only on impossible param combinations — we +/// hardcode known-good values so we expose a non-fallible API. +fn argon2_hasher() -> Argon2<'static> { + let params = Params::new( + ARGON2_MEMORY_KIB, + ARGON2_TIME_COST, + ARGON2_PARALLELISM, + None, + ) + .expect("Argon2 params are statically valid"); + Argon2::new(Algorithm::Argon2id, Version::V0x13, params) +} + +/// Lockout state held in memory only (intentional — restart-bypass is +/// trivial via process kill, so persistence buys nothing security-wise, +/// while in-RAM keeps the surface minimal). +#[derive(Debug, Default)] +pub struct LockoutTracker { + consecutive_failures: u32, + /// When the most recent lockout window started. None when no active + /// lockout. The current window is `last_failure_at + delay`. + last_failure_at: Option, +} + +impl LockoutTracker { + /// Return the active lockout remaining, or None if the user may try + /// a password now. `now` parameterised for tests; production passes + /// `Instant::now()`. + fn remaining_lockout(&self, now: Instant) -> Option { + let delay = lockout_duration(self.consecutive_failures)?; + let started = self.last_failure_at?; + let elapsed = now.saturating_duration_since(started); + if elapsed >= delay { + None + } else { + Some(delay - elapsed) + } + } + + fn record_failure(&mut self, now: Instant) { + self.consecutive_failures = self.consecutive_failures.saturating_add(1); + self.last_failure_at = Some(now); + } + + fn record_success(&mut self) { + self.consecutive_failures = 0; + self.last_failure_at = None; + } +} + +/// Lockout escalation table. Returns None when no lockout applies (under +/// the threshold, or above the give-up ceiling where the only path is +/// re-launching the app). +fn lockout_duration(consecutive_failures: u32) -> Option { + match consecutive_failures { + 0..=2 => None, + 3..=4 => Some(Duration::from_secs(5)), + 5..=9 => Some(Duration::from_secs(30)), + 10..=19 => Some(Duration::from_secs(300)), + // 20+ → tracker keeps escalating but we cap at 5min to avoid + // surprising the user with hour-long delays. Restarting the app + // resets the in-RAM tracker — by design, see threat model. + _ => Some(Duration::from_secs(300)), + } +} + +/// In-RAM session state. `token` is 32 random bytes; renderer holds the +/// base64 form and presents it on each privileged command call. +#[derive(Debug, Clone)] +pub struct SessionState { + token: [u8; 32], + expires_at: Instant, +} + +impl SessionState { + fn new(now: Instant) -> Self { + let mut token = [0u8; 32]; + rand::rngs::OsRng.fill_bytes(&mut token); + Self { + token, + expires_at: now + SESSION_DURATION, + } + } + + /// Base64 token suitable for IPC transport to the renderer. URL-safe + /// no-pad form keeps it copy-paste friendly in dev tools without + /// changing the length contract. + pub fn token_b64(&self) -> String { + URL_SAFE_NO_PAD.encode(self.token) + } + + fn matches(&self, candidate_b64: &str) -> bool { + let Ok(candidate) = URL_SAFE_NO_PAD.decode(candidate_b64.as_bytes()) else { + return false; + }; + // Length check upfront — constant-time over 32 bytes always. + if candidate.len() != self.token.len() { + return false; + } + bool::from(self.token.ct_eq(&candidate)) + } + + fn is_expired(&self, now: Instant) -> bool { + now >= self.expires_at + } +} + +/// Container the Tauri commands hold — wraps mutexes around the two +/// pieces of mutable state. `Default` gives a no-session, no-failures +/// baseline suitable for fresh starts. +#[derive(Debug, Default)] +pub struct SecurityAdminState { + pub session: Mutex>, + pub lockout: Mutex, +} + +/// Setup the security password. Idempotent w.r.t. overwriting an existing +/// hash — the user can rotate the password at will. Caller is responsible +/// for any UI-side confirm-password matching. +pub fn setup_password(plain: &str) -> Result<(), String> { + if plain.len() < MIN_PASSWORD_LEN { + return Err(format!( + "Le mot de passe doit faire au moins {MIN_PASSWORD_LEN} caractères." + )); + } + let salt = SaltString::generate(&mut OsRng); + let hash = argon2_hasher() + .hash_password(plain.as_bytes(), &salt) + .map_err(|e| format!("Hash failure: {e}"))? + .to_string(); + crate::ai_keys::save_key(KEYRING_PROVIDER, &hash) +} + +/// True iff a hash already exists in the keychain. Used by the UI to +/// pick between "first-time setup" and "verify password" flows. +pub fn is_password_set() -> bool { + crate::ai_keys::has_key(KEYRING_PROVIDER) +} + +/// Remove the password hash and any active session. Used by a hidden +/// "Reset Security Admin" command (CLI-only for now) so a user who +/// forgets the password can recover access by losing the audit-log +/// session history. Not exposed in UI — intentional friction. +#[allow(dead_code)] +pub fn reset_password(state: &SecurityAdminState) -> Result<(), String> { + crate::ai_keys::delete_key(KEYRING_PROVIDER)?; + if let Ok(mut s) = state.session.lock() { + *s = None; + } + if let Ok(mut l) = state.lockout.lock() { + *l = LockoutTracker::default(); + } + Ok(()) +} + +/// Outcome of a verify attempt. Cleaner than a stringly-typed Result for +/// the multiple failure modes — the UI surfaces each differently. +#[derive(Debug)] +pub enum VerifyOutcome { + /// Password matched, session opened. Caller forwards `token_b64` to + /// the renderer. + Ok { token_b64: String }, + /// User is in an active lockout window; tell them when they can retry. + LockedOut { wait: Duration }, + /// Hash not yet configured (caller should redirect to setup). + NoPasswordSet, + /// Plain wrong password (or password set but missing from keychain). + Wrong, +} + +/// Core verify routine. `db` parameter is used to append to the audit +/// log; pass `None` only in tests where we don't want a SQLite dep. +pub async fn verify_password( + plain: &str, + state: &SecurityAdminState, + db: Option<&SqlitePool>, +) -> VerifyOutcome { + verify_password_at(plain, state, db, Instant::now(), chrono::Utc::now()).await +} + +/// Test-friendly core. Production passes the current clock; tests can +/// inject deterministic instants. +pub async fn verify_password_at( + plain: &str, + state: &SecurityAdminState, + db: Option<&SqlitePool>, + now: Instant, + now_wall: chrono::DateTime, +) -> VerifyOutcome { + // 1. Lockout check first — denied attempts in a lockout window don't + // even touch Argon2 (no CPU spent on attackers). + let lockout_wait = { + let lockout = state.lockout.lock().ok(); + lockout.as_ref().and_then(|l| l.remaining_lockout(now)) + }; + if let Some(wait) = lockout_wait { + record_attempt( + db, + &now_wall, + false, + Some(&format!("lockout-skip: {}s", wait.as_secs())), + ) + .await; + return VerifyOutcome::LockedOut { wait }; + } + + // 2. Pull the stored hash (returns Wrong if missing — keeps the + // enumeration surface tighter than NoPasswordSet for an unset + // + wrong-attempt scenario). + let stored = match crate::ai_keys::get_key(KEYRING_PROVIDER) { + Ok(h) => h, + Err(_) => return VerifyOutcome::NoPasswordSet, + }; + + // 3. Parse + verify. Both can fail — for safety, treat any failure as + // Wrong (not as an error path) so attackers can't distinguish a + // corrupt-hash state from a wrong password. + let parsed = PasswordHash::new(&stored); + let matched = parsed + .as_ref() + .ok() + .map(|hash| { + argon2_hasher() + .verify_password(plain.as_bytes(), hash) + .is_ok() + }) + .unwrap_or(false); + + if !matched { + if let Ok(mut tracker) = state.lockout.lock() { + tracker.record_failure(now); + } + record_attempt(db, &now_wall, false, Some("password-mismatch")).await; + return VerifyOutcome::Wrong; + } + + // 4. Success — reset lockout, mint session, log attempt. + if let Ok(mut tracker) = state.lockout.lock() { + tracker.record_success(); + } + let session = SessionState::new(now); + let token_b64 = session.token_b64(); + if let Ok(mut slot) = state.session.lock() { + *slot = Some(session); + } + record_attempt(db, &now_wall, true, Some("verify-ok")).await; + VerifyOutcome::Ok { token_b64 } +} + +/// Check whether a renderer-supplied session token is still valid. Used +/// at the top of every privileged command (audit run, report list, etc.). +pub fn check_session(state: &SecurityAdminState, candidate_b64: &str) -> bool { + check_session_at(state, candidate_b64, Instant::now()) +} + +#[doc(hidden)] +pub fn check_session_at(state: &SecurityAdminState, candidate_b64: &str, now: Instant) -> bool { + let Ok(slot) = state.session.lock() else { + return false; + }; + match slot.as_ref() { + Some(session) if !session.is_expired(now) => session.matches(candidate_b64), + _ => false, + } +} + +/// Drop the active session — used by an explicit "Lock now" button or +/// the app's shutdown hook so a long-running audit doesn't keep the +/// gate open across app restarts. +pub fn end_session(state: &SecurityAdminState) { + if let Ok(mut slot) = state.session.lock() { + *slot = None; + } +} + +/// Append-only audit log. Best-effort: a logging failure doesn't fail +/// the verify decision — we want the user to be able to unlock the app +/// even if SQLite is wedged. Wall-clock parametrised for tests. +async fn record_attempt( + db: Option<&SqlitePool>, + at: &chrono::DateTime, + success: bool, + note: Option<&str>, +) { + let Some(pool) = db else { + return; + }; + let success_int: i64 = if success { 1 } else { 0 }; + if let Err(e) = sqlx::query( + "INSERT INTO security_audit_attempts (attempted_at, success, note) VALUES (?, ?, ?)", + ) + .bind(at.to_rfc3339()) + .bind(success_int) + .bind(note) + .execute(pool) + .await + { + log::warn!("security_admin: audit log insert failed (non-fatal): {e}"); + } +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use sqlx::sqlite::SqlitePoolOptions; + + /// Lockout policy is the simplest pure function — pin its shape. + #[test] + fn lockout_duration_escalation() { + assert_eq!(lockout_duration(0), None); + assert_eq!(lockout_duration(2), None); + assert_eq!(lockout_duration(3), Some(Duration::from_secs(5))); + assert_eq!(lockout_duration(4), Some(Duration::from_secs(5))); + assert_eq!(lockout_duration(5), Some(Duration::from_secs(30))); + assert_eq!(lockout_duration(9), Some(Duration::from_secs(30))); + assert_eq!(lockout_duration(10), Some(Duration::from_secs(300))); + assert_eq!(lockout_duration(19), Some(Duration::from_secs(300))); + assert_eq!(lockout_duration(20), Some(Duration::from_secs(300))); + assert_eq!(lockout_duration(1000), Some(Duration::from_secs(300))); + } + + #[test] + fn lockout_tracker_clears_on_success() { + let mut t = LockoutTracker::default(); + let now = Instant::now(); + t.record_failure(now); + t.record_failure(now); + t.record_failure(now); + assert!(t.remaining_lockout(now).is_some()); + t.record_success(); + assert!(t.remaining_lockout(now).is_none()); + assert_eq!(t.consecutive_failures, 0); + } + + #[test] + fn lockout_tracker_window_decays() { + let mut t = LockoutTracker::default(); + let start = Instant::now(); + t.consecutive_failures = 3; + t.last_failure_at = Some(start); + // At t=0, still in the 5s window. + assert_eq!( + t.remaining_lockout(start), + Some(Duration::from_secs(5)), + "window must be open at t=0" + ); + // Past the window — None even though failures stays at 3. + let later = start + Duration::from_secs(6); + assert!(t.remaining_lockout(later).is_none()); + } + + #[test] + fn session_token_matches_itself_and_rejects_others() { + let now = Instant::now(); + let a = SessionState::new(now); + let b = SessionState::new(now); + let a_tok = a.token_b64(); + assert!(a.matches(&a_tok), "session must match its own token"); + assert!(!a.matches(&b.token_b64()), "different sessions don't match"); + } + + #[test] + fn session_token_rejects_malformed_input() { + let now = Instant::now(); + let s = SessionState::new(now); + // Empty, non-base64, wrong length all return false (no panic). + assert!(!s.matches("")); + assert!(!s.matches("not-base64-!@#$")); + assert!(!s.matches("c2hvcnQ")); // valid base64 but too short + } + + #[test] + fn session_expires_after_duration() { + let start = Instant::now(); + let s = SessionState::new(start); + assert!(!s.is_expired(start), "fresh session not expired at t=0"); + assert!( + !s.is_expired(start + SESSION_DURATION - Duration::from_secs(1)), + "session valid 1s before expiry" + ); + assert!( + s.is_expired(start + SESSION_DURATION), + "session expired exactly at duration" + ); + assert!( + s.is_expired(start + SESSION_DURATION + Duration::from_secs(60)), + "session still expired well past duration" + ); + } + + #[tokio::test] + async fn verify_returns_no_password_set_when_keychain_empty() { + // Skip when keychain unavailable (CI Linux without DBus). + if !keyring_available() { + return; + } + let _ = crate::ai_keys::delete_key(KEYRING_PROVIDER); + let state = SecurityAdminState::default(); + let pool = fresh_pool().await; + let outcome = verify_password("any-password-1234", &state, Some(&pool)).await; + assert!(matches!(outcome, VerifyOutcome::NoPasswordSet)); + } + + #[tokio::test] + async fn setup_rejects_short_password() { + let r = setup_password("short"); + assert!(r.is_err()); + } + + #[tokio::test] + async fn setup_then_verify_with_correct_password_succeeds() { + if !keyring_available() { + return; + } + let provider = unique_provider(); + // Use a unique provider for test isolation — keychain entries are + // process-shared. Override KEYRING_PROVIDER via a local helper. + let plain = "good-password-12345"; + save_isolated_hash(&provider, plain).expect("hash setup"); + + let state = SecurityAdminState::default(); + let pool = fresh_pool().await; + let outcome = verify_isolated(plain, &state, Some(&pool), &provider).await; + assert!(matches!(outcome, VerifyOutcome::Ok { .. })); + + // Audit log recorded the success. + let row_count: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM security_audit_attempts WHERE success = 1") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(row_count, 1); + + cleanup_isolated(&provider); + } + + #[tokio::test] + async fn verify_wrong_password_records_failure_and_increments_lockout() { + if !keyring_available() { + return; + } + let provider = unique_provider(); + save_isolated_hash(&provider, "good-password-12345").expect("setup"); + + let state = SecurityAdminState::default(); + let pool = fresh_pool().await; + + for _ in 0..3 { + let outcome = + verify_isolated("WRONG-password-aaa", &state, Some(&pool), &provider).await; + assert!(matches!(outcome, VerifyOutcome::Wrong)); + } + + // The 4th attempt should be locked out (3 failures = enters 5s window). + let outcome = verify_isolated("any", &state, Some(&pool), &provider).await; + assert!( + matches!(outcome, VerifyOutcome::LockedOut { .. }), + "must lock out after 3 consecutive failures" + ); + + cleanup_isolated(&provider); + } + + #[tokio::test] + async fn check_session_rejects_expired_tokens() { + let state = SecurityAdminState::default(); + let now = Instant::now(); + let mut session = SessionState::new(now); + // Force expiry into the past. + session.expires_at = now - Duration::from_secs(1); + let tok = session.token_b64(); + // Drop the lock before calling check_session_at (which re-locks). + { + let mut slot = state.session.lock().unwrap(); + *slot = Some(session); + } + assert!( + !check_session_at(&state, &tok, now), + "expired token must be rejected" + ); + } + + #[tokio::test] + async fn end_session_drops_state() { + let state = SecurityAdminState::default(); + { + let mut slot = state.session.lock().unwrap(); + *slot = Some(SessionState::new(Instant::now())); + } + end_session(&state); + assert!(state.session.lock().unwrap().is_none()); + } + + // ── Test plumbing ──────────────────────────────────────────────────── + + fn keyring_available() -> bool { + let probe = unique_provider(); + let r = keyring::Entry::new("app.getpostcraft.secrets", &probe) + .and_then(|e| e.set_password("probe")); + if r.is_ok() { + let _ = keyring::Entry::new("app.getpostcraft.secrets", &probe) + .and_then(|e| e.delete_credential()); + true + } else { + false + } + } + + fn unique_provider() -> String { + format!( + "test_security_admin_{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + ) + } + + fn save_isolated_hash(provider: &str, plain: &str) -> Result<(), String> { + let salt = SaltString::generate(&mut OsRng); + let hash = argon2_hasher() + .hash_password(plain.as_bytes(), &salt) + .map_err(|e| e.to_string())? + .to_string(); + crate::ai_keys::save_key(provider, &hash) + } + + fn cleanup_isolated(provider: &str) { + let _ = crate::ai_keys::delete_key(provider); + } + + /// Mirror of `verify_password` but reading the hash from an arbitrary + /// keyring provider (per-test isolation). Identical control flow. + async fn verify_isolated( + plain: &str, + state: &SecurityAdminState, + db: Option<&SqlitePool>, + provider: &str, + ) -> VerifyOutcome { + let now = Instant::now(); + let now_wall = chrono::Utc::now(); + let lockout_wait = state + .lockout + .lock() + .ok() + .as_ref() + .and_then(|l| l.remaining_lockout(now)); + if let Some(wait) = lockout_wait { + record_attempt(db, &now_wall, false, Some("lockout")).await; + return VerifyOutcome::LockedOut { wait }; + } + let stored = match crate::ai_keys::get_key(provider) { + Ok(h) => h, + Err(_) => return VerifyOutcome::NoPasswordSet, + }; + let parsed = PasswordHash::new(&stored); + let matched = parsed + .as_ref() + .ok() + .map(|h| argon2_hasher().verify_password(plain.as_bytes(), h).is_ok()) + .unwrap_or(false); + if !matched { + if let Ok(mut tracker) = state.lockout.lock() { + tracker.record_failure(now); + } + record_attempt(db, &now_wall, false, Some("mismatch")).await; + return VerifyOutcome::Wrong; + } + if let Ok(mut tracker) = state.lockout.lock() { + tracker.record_success(); + } + let session = SessionState::new(now); + let token_b64 = session.token_b64(); + if let Ok(mut slot) = state.session.lock() { + *slot = Some(session); + } + record_attempt(db, &now_wall, true, Some("ok")).await; + VerifyOutcome::Ok { token_b64 } + } + + async fn fresh_pool() -> SqlitePool { + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .expect("connect"); + sqlx::migrate!("src/db/migrations") + .run(&pool) + .await + .expect("migrate"); + pool + } +} diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 2e8a3ae..595e6fa 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -1,8 +1,9 @@ use sqlx::SqlitePool; use std::collections::HashMap; -use std::sync::Mutex; +use std::sync::{Arc, Mutex}; use crate::openrouter_pricing::{new_cache, PricingCache}; +use crate::security_admin::SecurityAdminState; pub struct ActiveProvider { /// "openrouter" | "anthropic" | "ollama" @@ -22,6 +23,10 @@ pub struct AppState { /// static table when a model isn't here. Refreshed on startup and on /// manual user request from the AI usage panel. pub pricing_cache: PricingCache, + /// Settings → Security gate state: in-RAM session + lockout tracker. + /// Arc'd so async tasks holding a state guard don't block the lock + /// across awaits — only the inner Mutexes are taken briefly. + pub security_admin: Arc, } impl AppState { @@ -34,6 +39,7 @@ impl AppState { key_cache: Mutex::new(HashMap::new()), db, pricing_cache: new_cache(), + security_admin: Arc::new(SecurityAdminState::default()), } } } diff --git a/src/components/settings/SecurityCenter.tsx b/src/components/settings/SecurityCenter.tsx new file mode 100644 index 0000000..6ec479d --- /dev/null +++ b/src/components/settings/SecurityCenter.tsx @@ -0,0 +1,419 @@ +import { useEffect, useState } from "react"; +import { ShieldCheck, ShieldAlert, ShieldQuestion, Loader2, LogOut } from "lucide-react"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { + isSecurityPasswordSet, + setupSecurityPassword, + verifySecurityPassword, + checkSecuritySession, + endSecuritySession, + listRecentSecurityAttempts, + type SecurityAttemptRow, +} from "@/lib/tauri/securityAdmin"; +import { TauriRuntimeUnavailableError } from "@/lib/tauri/invoke"; + +type GateState = + | { kind: "loading" } + | { kind: "unavailable"; reason: string } + | { kind: "setup-needed" } + | { kind: "locked"; sessionToken: string | null } + | { kind: "unlocked"; sessionToken: string }; + +const SESSION_STORAGE_KEY = "gpc.security.session"; + +export function SecurityCenter() { + const [gate, setGate] = useState({ kind: "loading" }); + + useEffect(() => { + let cancelled = false; + async function bootstrap() { + try { + const hasPassword = await isSecurityPasswordSet(); + if (cancelled) return; + if (!hasPassword) { + setGate({ kind: "setup-needed" }); + return; + } + // Try to revive a session from sessionStorage. Session token + // lives only in RAM on the Rust side too — a refresh of the + // renderer would otherwise force re-prompt on every render. + const stored = sessionStorage.getItem(SESSION_STORAGE_KEY); + if (stored && (await checkSecuritySession(stored))) { + setGate({ kind: "unlocked", sessionToken: stored }); + return; + } + if (stored) sessionStorage.removeItem(SESSION_STORAGE_KEY); + setGate({ kind: "locked", sessionToken: null }); + } catch (err) { + if (cancelled) return; + if (err instanceof TauriRuntimeUnavailableError) { + setGate({ + kind: "unavailable", + reason: + "Le centre de sécurité n'est disponible que dans l'app desktop. Lance `npm run tauri dev`.", + }); + } else { + setGate({ + kind: "unavailable", + reason: err instanceof Error ? err.message : String(err), + }); + } + } + } + void bootstrap(); + return () => { + cancelled = true; + }; + }, []); + + if (gate.kind === "loading") { + return ( +
+ + Initialisation du centre de sécurité… +
+ ); + } + + if (gate.kind === "unavailable") { + return ( + + + Centre de sécurité indisponible + {gate.reason} + + ); + } + + if (gate.kind === "setup-needed") { + return ( + + setGate({ kind: "locked", sessionToken: null }) + } + /> + ); + } + + if (gate.kind === "locked") { + return ( + { + sessionStorage.setItem(SESSION_STORAGE_KEY, token); + setGate({ kind: "unlocked", sessionToken: token }); + }} + /> + ); + } + + return ( + { + sessionStorage.removeItem(SESSION_STORAGE_KEY); + void endSecuritySession(); + setGate({ kind: "locked", sessionToken: null }); + }} + /> + ); +} + +// ── Setup wizard ─────────────────────────────────────────────────────────── + +function SecurityPasswordSetup({ onSetupComplete }: { onSetupComplete: () => void }) { + const [pwd, setPwd] = useState(""); + const [confirm, setConfirm] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const tooShort = pwd.length > 0 && pwd.length < 12; + const mismatch = confirm.length > 0 && pwd !== confirm; + const canSubmit = pwd.length >= 12 && pwd === confirm && !busy; + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + if (!canSubmit) return; + setBusy(true); + setError(null); + try { + await setupSecurityPassword(pwd); + onSetupComplete(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setBusy(false); + } + } + + return ( + + + + + Création du mot de passe Sécurité + + + Définis un mot de passe maître pour protéger l'accès au centre de + sécurité (audits LLM + rapports). Stocké en local via Argon2id dans + ton trousseau système — il ne quitte jamais ta machine et ne peut + pas être récupéré s'il est perdu. + + + +
+
+ + setPwd(e.target.value)} + placeholder="•••••••••••••" + disabled={busy} + /> + {tooShort && ( +

Encore {12 - pwd.length} caractère(s).

+ )} +
+
+ + setConfirm(e.target.value)} + disabled={busy} + /> + {mismatch && ( +

Les deux saisies ne correspondent pas.

+ )} +
+ {error && ( + + + {error} + + )} + +
+
+
+ ); +} + +// ── Verify gate ──────────────────────────────────────────────────────────── + +function SecurityPasswordGate({ onUnlock }: { onUnlock: (token: string) => void }) { + const [pwd, setPwd] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [lockoutSeconds, setLockoutSeconds] = useState(null); + + useEffect(() => { + if (lockoutSeconds === null || lockoutSeconds <= 0) return; + const handle = setInterval(() => { + setLockoutSeconds((s) => (s === null ? null : Math.max(0, s - 1))); + }, 1000); + return () => clearInterval(handle); + }, [lockoutSeconds]); + + const locked = lockoutSeconds !== null && lockoutSeconds > 0; + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + if (busy || locked) return; + setBusy(true); + setError(null); + try { + const r = await verifySecurityPassword(pwd); + switch (r.kind) { + case "ok": + onUnlock(r.token); + break; + case "locked_out": + setLockoutSeconds(r.wait_seconds); + setError( + `Trop d'échecs récents. Réessaie dans ${r.wait_seconds} seconde(s).`, + ); + break; + case "no_password_set": + setError( + "Aucun mot de passe configuré. Recharge la page pour démarrer la configuration.", + ); + break; + case "wrong": + setError("Mot de passe incorrect."); + setPwd(""); + break; + } + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setBusy(false); + } + } + + return ( + + + + + Centre de sécurité verrouillé + + + Saisis le mot de passe maître pour accéder aux audits et rapports. + Session active pendant 30 minutes une fois déverrouillé. + + + +
+ setPwd(e.target.value)} + placeholder="Mot de passe maître" + disabled={busy || locked} + /> + {error && ( + + + + {error} + {locked && lockoutSeconds !== null && lockoutSeconds > 0 && ( + ({lockoutSeconds}s) + )} + + + )} + +
+
+
+ ); +} + +// ── Unlocked panel ───────────────────────────────────────────────────────── + +function SecurityCenterUnlocked({ onLock }: { onLock: () => void }) { + const [attempts, setAttempts] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + async function load() { + try { + const rows = await listRecentSecurityAttempts(20); + if (!cancelled) setAttempts(rows); + } catch (err) { + if (!cancelled) setError(err instanceof Error ? err.message : String(err)); + } + } + void load(); + return () => { + cancelled = true; + }; + }, []); + + return ( +
+ + +
+
+ + + Centre de sécurité déverrouillé + + + Session active. L'agent runner et les rapports d'audit + arrivent en Phase B. + +
+ +
+
+ +
+ +

+ Agent runner — bientôt +

+

+ Phase B branchera ici le lancement de `llm-security-auditor` et + `prompt-guardrail-auditor` avec affichage des rapports persistés. +

+
+
+
+ + + + Tentatives récentes + + 20 dernières tentatives de déverrouillage. Trace forensique + locale — jamais transmise. + + + + {error ? ( + + + {error} + + ) : attempts === null ? ( +
+ + Chargement… +
+ ) : attempts.length === 0 ? ( +

+ Aucune tentative enregistrée. +

+ ) : ( +
    + {attempts.map((row) => ( +
  • +
    + {row.success === 1 ? ( + + ) : ( + + )} + + {new Date(row.attempted_at).toLocaleString("fr-FR")} + +
    + + {row.note ?? (row.success === 1 ? "OK" : "Échec")} + +
  • + ))} +
+ )} +
+
+
+ ); +} diff --git a/src/lib/tauri/securityAdmin.ts b/src/lib/tauri/securityAdmin.ts new file mode 100644 index 0000000..a33894d --- /dev/null +++ b/src/lib/tauri/securityAdmin.ts @@ -0,0 +1,49 @@ +import { invoke } from "./invoke"; + +/** + * Tagged-union mirror of `commands::security_admin::VerifyResponse` on + * the Rust side. Renderer pattern-matches on `kind` to render the right + * UI state (open the gate, show lockout countdown, redirect to setup, + * or surface a "wrong password" error). + */ +export type VerifyResponse = + | { kind: "ok"; token: string } + | { kind: "locked_out"; wait_seconds: number } + | { kind: "no_password_set" } + | { kind: "wrong" }; + +export interface SecurityAttemptRow { + id: number; + attempted_at: string; + /** SQLite stores BOOLEAN as INTEGER, so the wire shape is 0|1. */ + success: number; + note: string | null; +} + +export async function isSecurityPasswordSet(): Promise { + return invoke("is_security_password_set"); +} + +export async function setupSecurityPassword(plain: string): Promise { + return invoke("setup_security_password", { plain }); +} + +export async function verifySecurityPassword( + plain: string, +): Promise { + return invoke("verify_security_password", { plain }); +} + +export async function checkSecuritySession(token: string): Promise { + return invoke("check_security_session", { token }); +} + +export async function endSecuritySession(): Promise { + return invoke("end_security_session"); +} + +export async function listRecentSecurityAttempts( + limit = 50, +): Promise { + return invoke("list_recent_security_attempts", { limit }); +} diff --git a/src/routes/settings/index.tsx b/src/routes/settings/index.tsx index a7217c8..2c2eed8 100644 --- a/src/routes/settings/index.tsx +++ b/src/routes/settings/index.tsx @@ -8,6 +8,7 @@ import { AboutSection } from "@/components/settings/AboutSection"; import { LogsPanel } from "@/components/settings/LogsPanel"; import { BackupSection } from "@/components/settings/BackupSection"; import { AiUsagePanel } from "@/components/settings/AiUsagePanel"; +import { SecurityCenter } from "@/components/settings/SecurityCenter"; export function SettingsPage() { const { tab } = useSearch({ from: "/settings" }); @@ -33,6 +34,7 @@ export function SettingsPage() { Publication Données Logs + Sécurité À propos @@ -123,6 +125,10 @@ export function SettingsPage() { + + + +