Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 9 additions & 0 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions src-tauri/src/ai_keys.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -286,6 +290,7 @@ mod tests {
"instagram_client_secret",
"linkedin_client_secret",
"imgbb_api_key",
"security_password_hash",
] {
assert!(
KNOWN_PROVIDERS.contains(&required),
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,5 @@ pub mod media;
pub mod oauth;
pub mod publisher;
pub mod python_deps;
pub mod security_admin;
pub mod settings;
90 changes: 90 additions & 0 deletions src-tauri/src/commands/security_admin.rs
Original file line number Diff line number Diff line change
@@ -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<VerifyOutcome> 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<String>,
}

#[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<VerifyResponse, String> {
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<Vec<AttemptRow>, 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}"))
}
55 changes: 55 additions & 0 deletions src-tauri/src/db/migrations/020_security_admin.sql
Original file line number Diff line number Diff line change
@@ -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);
8 changes: 8 additions & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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");
Expand Down
Loading
Loading