diff --git a/CHANGELOG.md b/CHANGELOG.md index d9475b629..89ca196f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ All notable changes to OpenFang will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Changed + +- **BREAKING:** Dashboard password hashing switched from SHA256 to Argon2id. Existing `password_hash` values in `config.toml` must be regenerated with `openfang auth hash-password`. Only affects users with `[auth] enabled = true`. + +### Fixed + +- Dashboard passwords were hashed with plain SHA256 (no salt), making them vulnerable to rainbow table and GPU-accelerated brute force attacks. Now uses Argon2id with random salts. + ## [0.1.0] - 2026-02-24 ### Added diff --git a/Cargo.lock b/Cargo.lock index 1c74397aa..a904ff173 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3811,8 +3811,9 @@ dependencies = [ [[package]] name = "openfang-api" -version = "0.4.8" +version = "0.4.9" dependencies = [ + "argon2", "async-trait", "axum", "base64 0.22.1", @@ -3832,6 +3833,7 @@ dependencies = [ "openfang-skills", "openfang-types", "openfang-wire", + "rand 0.8.5", "reqwest 0.12.28", "serde", "serde_json", @@ -3851,7 +3853,7 @@ dependencies = [ [[package]] name = "openfang-channels" -version = "0.4.8" +version = "0.4.9" dependencies = [ "aes", "async-trait", @@ -3888,7 +3890,7 @@ dependencies = [ [[package]] name = "openfang-cli" -version = "0.4.8" +version = "0.4.9" dependencies = [ "clap", "clap_complete", @@ -3915,7 +3917,7 @@ dependencies = [ [[package]] name = "openfang-desktop" -version = "0.4.8" +version = "0.4.9" dependencies = [ "axum", "open", @@ -3941,7 +3943,7 @@ dependencies = [ [[package]] name = "openfang-extensions" -version = "0.4.8" +version = "0.4.9" dependencies = [ "aes-gcm", "argon2", @@ -3969,7 +3971,7 @@ dependencies = [ [[package]] name = "openfang-hands" -version = "0.4.8" +version = "0.4.9" dependencies = [ "chrono", "dashmap", @@ -3986,7 +3988,7 @@ dependencies = [ [[package]] name = "openfang-kernel" -version = "0.4.8" +version = "0.4.9" dependencies = [ "async-trait", "chrono", @@ -4024,7 +4026,7 @@ dependencies = [ [[package]] name = "openfang-memory" -version = "0.4.8" +version = "0.4.9" dependencies = [ "async-trait", "chrono", @@ -4043,7 +4045,7 @@ dependencies = [ [[package]] name = "openfang-migrate" -version = "0.4.8" +version = "0.4.9" dependencies = [ "chrono", "dirs 6.0.0", @@ -4062,7 +4064,7 @@ dependencies = [ [[package]] name = "openfang-runtime" -version = "0.4.8" +version = "0.4.9" dependencies = [ "anyhow", "async-trait", @@ -4096,7 +4098,7 @@ dependencies = [ [[package]] name = "openfang-skills" -version = "0.4.8" +version = "0.4.9" dependencies = [ "chrono", "hex", @@ -4119,7 +4121,7 @@ dependencies = [ [[package]] name = "openfang-types" -version = "0.4.8" +version = "0.4.9" dependencies = [ "async-trait", "chrono", @@ -4138,7 +4140,7 @@ dependencies = [ [[package]] name = "openfang-wire" -version = "0.4.8" +version = "0.4.9" dependencies = [ "async-trait", "chrono", @@ -8814,7 +8816,7 @@ checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" [[package]] name = "xtask" -version = "0.4.8" +version = "0.4.9" [[package]] name = "yoke" diff --git a/crates/openfang-api/Cargo.toml b/crates/openfang-api/Cargo.toml index 6d4e582f9..fcc3e0088 100644 --- a/crates/openfang-api/Cargo.toml +++ b/crates/openfang-api/Cargo.toml @@ -38,6 +38,8 @@ hmac = { workspace = true } hex = { workspace = true } socket2 = { workspace = true } reqwest = { workspace = true } +argon2 = { workspace = true } +rand = { workspace = true } [dev-dependencies] tokio-test = { workspace = true } diff --git a/crates/openfang-api/src/server.rs b/crates/openfang-api/src/server.rs index b6980928c..a581f83c7 100644 --- a/crates/openfang-api/src/server.rs +++ b/crates/openfang-api/src/server.rs @@ -103,6 +103,15 @@ pub async fn build_router( .allow_headers(tower_http::cors::Any) }; + // Warn if dashboard auth is enabled but the password hash is not Argon2id. + let ph = &state.kernel.config.auth.password_hash; + if state.kernel.config.auth.enabled && !ph.is_empty() && !ph.starts_with("$argon2") { + tracing::warn!( + "Dashboard auth password_hash is not in Argon2id format. \ + Login will fail. Regenerate with: openfang auth hash-password" + ); + } + // Trim whitespace so `api_key = ""` or `api_key = " "` both disable auth. let api_key = state.kernel.config.api_key.trim().to_string(); let auth_state = crate::middleware::AuthState { diff --git a/crates/openfang-api/src/session_auth.rs b/crates/openfang-api/src/session_auth.rs index ec7d7db5e..6c0dbeb83 100644 --- a/crates/openfang-api/src/session_auth.rs +++ b/crates/openfang-api/src/session_auth.rs @@ -55,20 +55,27 @@ pub fn verify_session_token(token: &str, secret: &str) -> Option { } } -/// Hash a password with SHA256 for config storage. +/// Hash a password with Argon2id for config storage. +/// +/// Returns a PHC-format string (e.g. `$argon2id$v=19$m=19456,t=2,p=1$...`). pub fn hash_password(password: &str) -> String { - use sha2::Digest; - hex::encode(Sha256::digest(password.as_bytes())) + use argon2::{password_hash::SaltString, Argon2, PasswordHasher}; + let salt = SaltString::generate(&mut rand::thread_rng()); + Argon2::default() + .hash_password(password.as_bytes(), &salt) + .expect("Argon2 hashing should not fail with valid inputs") + .to_string() } -/// Verify a password against a stored SHA256 hash (constant-time). +/// Verify a password against a stored Argon2id hash (PHC string format). pub fn verify_password(password: &str, stored_hash: &str) -> bool { - let computed = hash_password(password); - use subtle::ConstantTimeEq; - if computed.len() != stored_hash.len() { + use argon2::{password_hash::PasswordHash, Argon2, PasswordVerifier}; + let Ok(parsed) = PasswordHash::new(stored_hash) else { return false; - } - computed.as_bytes().ct_eq(stored_hash.as_bytes()).into() + }; + Argon2::default() + .verify_password(password.as_bytes(), &parsed) + .is_ok() } #[cfg(test)] @@ -78,10 +85,31 @@ mod tests { #[test] fn test_hash_and_verify_password() { let hash = hash_password("secret123"); + assert!( + hash.starts_with("$argon2id$"), + "should produce Argon2id PHC string" + ); assert!(verify_password("secret123", &hash)); assert!(!verify_password("wrong", &hash)); } + #[test] + fn test_hash_produces_unique_salts() { + let h1 = hash_password("same"); + let h2 = hash_password("same"); + assert_ne!(h1, h2, "each hash should use a unique salt"); + assert!(verify_password("same", &h1)); + assert!(verify_password("same", &h2)); + } + + #[test] + fn test_rejects_non_argon2_hash() { + // A plain SHA256 hex string should no longer be accepted. + use sha2::Digest; + let sha256_hash = hex::encode(sha2::Sha256::digest(b"password")); + assert!(!verify_password("password", &sha256_hash)); + } + #[test] fn test_create_and_verify_token() { let token = create_session_token("admin", "my-secret", 1); @@ -103,7 +131,14 @@ mod tests { } #[test] - fn test_password_hash_length_mismatch() { + fn test_rejects_garbage_input() { assert!(!verify_password("x", "short")); + assert!(!verify_password("x", "")); + } + + #[test] + fn test_verify_malformed_argon2_hash() { + // Starts with $argon2 but is not a valid PHC string. + assert!(!verify_password("x", "$argon2id$garbage")); } } diff --git a/crates/openfang-cli/src/main.rs b/crates/openfang-cli/src/main.rs index a09e012de..f5242ad4e 100644 --- a/crates/openfang-cli/src/main.rs +++ b/crates/openfang-cli/src/main.rs @@ -237,6 +237,9 @@ enum Commands { #[arg(long)] json: bool, }, + /// Dashboard authentication [*]. + #[command(subcommand)] + Auth(AuthCommands), /// Security tools and audit trail [*]. #[command(subcommand)] Security(SecurityCommands), @@ -679,6 +682,12 @@ enum CronCommands { }, } +#[derive(Subcommand)] +enum AuthCommands { + /// Generate an Argon2id password hash for dashboard authentication. + HashPassword, +} + #[derive(Subcommand)] enum SecurityCommands { /// Show security status summary. @@ -1041,6 +1050,9 @@ fn main() { Some(Commands::Sessions { agent, json }) => cmd_sessions(agent.as_deref(), json), Some(Commands::Logs { lines, follow }) => cmd_logs(lines, follow), Some(Commands::Health { json }) => cmd_health(json), + Some(Commands::Auth(sub)) => match sub { + AuthCommands::HashPassword => cmd_auth_hash_password(), + }, Some(Commands::Security(sub)) => match sub { SecurityCommands::Status { json } => cmd_security_status(json), SecurityCommands::Audit { limit, json } => cmd_security_audit(limit, json), @@ -5860,6 +5872,28 @@ fn cmd_health(json: bool) { } } +fn cmd_auth_hash_password() { + let password = prompt_input("Enter password: "); + if password.is_empty() { + ui::error("Empty password."); + std::process::exit(1); + } + let confirm = prompt_input("Confirm password: "); + if password != confirm { + ui::error("Passwords do not match."); + std::process::exit(1); + } + let hash = openfang_api::session_auth::hash_password(&password); + println!(); + ui::success("Argon2id hash generated. Add this to your config.toml:"); + println!(); + println!(" [auth]"); + println!(" enabled = true"); + println!(" password_hash = \"{}\"", hash); + println!(); + ui::hint("Restart the daemon after updating config.toml"); +} + fn cmd_security_status(json: bool) { let base = require_daemon("security status"); let client = daemon_client(); diff --git a/crates/openfang-types/src/config.rs b/crates/openfang-types/src/config.rs index 71748eb02..4510d05d6 100644 --- a/crates/openfang-types/src/config.rs +++ b/crates/openfang-types/src/config.rs @@ -1109,7 +1109,7 @@ pub struct AuthConfig { pub enabled: bool, /// Admin username. pub username: String, - /// SHA256 hash of the password (hex-encoded). + /// Argon2id password hash (PHC string format). /// Generate with: openfang auth hash-password pub password_hash: String, /// Session token lifetime in hours (default: 168 = 7 days). diff --git a/docs/configuration.md b/docs/configuration.md index 5e1195ad8..170dd4179 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -318,6 +318,37 @@ shared_secret = "my-cluster-secret" --- +### `[auth]` + +Configures dashboard login with username/password authentication. Disabled by default. + +```toml +[auth] +enabled = true +username = "admin" +password_hash = "$argon2id$v=19$m=19456,t=2,p=1$..." # generate with: openfang auth hash-password +session_ttl_hours = 168 +``` + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `enabled` | bool | `false` | Enable username/password authentication for the dashboard. | +| `username` | string | `"admin"` | Admin username. | +| `password_hash` | string | `""` (empty) | Argon2id password hash in PHC string format. Generate with `openfang auth hash-password`. | +| `session_ttl_hours` | u64 | `168` (7 days) | Session token lifetime in hours. | + +**Generating a password hash:** + +```bash +openfang auth hash-password +``` + +This prompts for a password and outputs an Argon2id PHC string to paste into `config.toml`. + +> **Breaking change (v0.5.0):** Password hashes must be in Argon2id format. Older SHA256 hex hashes from versions prior to v0.5.0 are no longer accepted. Re-run `openfang auth hash-password` to generate a new hash. + +--- + ### `[web]` Configures web search and web fetch capabilities used by agent tools. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 0f21ae0af..4eb76a45b 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -583,19 +583,24 @@ docker run -d --name openfang \ ### How do I protect the dashboard with a password? -OpenFang doesn't have built-in login. Use a reverse proxy with basic auth: +OpenFang has built-in dashboard authentication. Enable it in `~/.openfang/config.toml`: -**Caddy example:** +```toml +[auth] +enabled = true +username = "admin" +password_hash = "$argon2id$..." # see below ``` -ai.yourdomain.com { - basicauth { - username $2a$14$YOUR_HASHED_PASSWORD - } - reverse_proxy localhost:4200 -} + +Generate the password hash: + +```bash +openfang auth hash-password ``` -Generate a password hash: `caddy hash-password` +Paste the output into the `password_hash` field and restart the daemon. + +For public-facing deployments, you should also place a reverse proxy (Caddy, nginx) in front for TLS termination. ### How do I configure the embedding model for memory?