diff --git a/CHANGELOG.md b/CHANGELOG.md index d9475b62..89ca196f 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 3650d653..8d84ca7e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -730,9 +730,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.57" +version = "1.2.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" +checksum = "e1e928d4b69e3077709075a938a05ffbedfa53a84c8f766efbf8220bb1ff60e1" dependencies = [ "find-msvc-tools", "jobserver", @@ -884,9 +884,9 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "cmake" -version = "0.1.57" +version = "0.1.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" dependencies = [ "cc", ] @@ -1641,17 +1641,17 @@ dependencies = [ [[package]] name = "dom_query" -version = "0.25.1" +version = "0.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d9c2e7f1d22d0f2ce07626d259b8a55f4a47cb0938d4006dd8ae037f17d585e" +checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89" dependencies = [ "bit-set", "cssparser 0.36.0", "foldhash 0.2.0", - "html5ever 0.36.1", + "html5ever 0.38.0", "precomputed-hash", - "selectors 0.35.0", - "tendril", + "selectors 0.36.1", + "tendril 0.5.0", ] [[package]] @@ -1739,9 +1739,9 @@ checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" [[package]] name = "embed-resource" -version = "3.0.7" +version = "3.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47ec73ddcf6b7f23173d5c3c5a32b5507dc0a734de7730aa14abc5d5e296bb5f" +checksum = "63a1d0de4f2249aa0ff5884d7080814f446bb241a559af6c170a41e878ed2d45" dependencies = [ "cc", "memchr", @@ -2651,12 +2651,12 @@ dependencies = [ [[package]] name = "html5ever" -version = "0.36.1" +version = "0.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6452c4751a24e1b99c3260d505eaeee76a050573e61f30ac2c924ddc7236f01e" +checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2" dependencies = [ "log", - "markup5ever 0.36.1", + "markup5ever 0.38.0", ] [[package]] @@ -3019,9 +3019,9 @@ dependencies = [ [[package]] name = "instability" -version = "0.3.11" +version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "357b7205c6cd18dd2c86ed312d1e70add149aea98e7ef72b9fdf0270e555c11d" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" dependencies = [ "darling", "indoc", @@ -3038,9 +3038,9 @@ checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" [[package]] name = "iri-string" -version = "0.7.10" +version = "0.7.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" +checksum = "d8e7418f59cc01c88316161279a7f665217ae316b388e58a0d10e29f54f1e5eb" dependencies = [ "memchr", "serde", @@ -3091,9 +3091,9 @@ dependencies = [ [[package]] name = "itoa" -version = "1.0.17" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "ittapi" @@ -3147,7 +3147,7 @@ dependencies = [ "cesu8", "cfg-if", "combine", - "jni-sys", + "jni-sys 0.3.1", "log", "thiserror 1.0.69", "walkdir", @@ -3156,9 +3156,31 @@ dependencies = [ [[package]] name = "jni-sys" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.117", +] [[package]] name = "jobserver" @@ -3338,9 +3360,9 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.14" +version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" +checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08" dependencies = [ "bitflags 2.11.0", "libc", @@ -3456,17 +3478,17 @@ dependencies = [ "phf_codegen 0.11.3", "string_cache 0.8.9", "string_cache_codegen 0.5.4", - "tendril", + "tendril 0.4.3", ] [[package]] name = "markup5ever" -version = "0.36.1" +version = "0.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c3294c4d74d0742910f8c7b466f44dda9eb2d5742c1e430138df290a1e8451c" +checksum = "8983d30f2915feeaaab2d6babdd6bc7e9ed1a00b66b5e6d74df19aa9c0e91862" dependencies = [ "log", - "tendril", + "tendril 0.5.0", "web_atoms", ] @@ -3560,9 +3582,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", "log", @@ -3642,7 +3664,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" dependencies = [ "bitflags 2.11.0", - "jni-sys", + "jni-sys 0.3.1", "log", "ndk-sys", "num_enum", @@ -3662,7 +3684,7 @@ version = "0.6.0+11769913" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" dependencies = [ - "jni-sys", + "jni-sys 0.3.1", ] [[package]] @@ -3740,9 +3762,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" [[package]] name = "num-traits" @@ -3957,6 +3979,7 @@ dependencies = [ name = "openfang-api" version = "0.5.5" dependencies = [ + "argon2", "async-trait", "axum", "base64 0.22.1", @@ -3976,6 +3999,7 @@ dependencies = [ "openfang-skills", "openfang-types", "openfang-wire", + "rand 0.8.5", "reqwest 0.12.28", "serde", "serde_json", @@ -4943,7 +4967,7 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit 0.25.4+spec-1.1.0", + "toml_edit 0.25.8+spec-1.1.0", ] [[package]] @@ -5161,9 +5185,9 @@ dependencies = [ [[package]] name = "quoted_printable" -version = "0.5.1" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "640c9bd8497b02465aeef5375144c26062e0dcd5939dfcbb0f5db76cb8c17c73" +checksum = "478e0585659a122aa407eb7e3c0e1fa51b1d8a870038bd29f0cf4a8551eea972" [[package]] name = "r-efi" @@ -5584,9 +5608,9 @@ dependencies = [ [[package]] name = "rmcp" -version = "1.2.0" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba6b9d2f0efe2258b23767f1f9e0054cfbcac9c2d6f81a031214143096d7864f" +checksum = "2231b2c085b371c01bc90c0e6c1cab8834711b6394533375bdbf870b0166d419" dependencies = [ "async-trait", "chrono", @@ -5980,9 +6004,9 @@ dependencies = [ [[package]] name = "selectors" -version = "0.35.0" +version = "0.36.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93fdfed56cd634f04fe8b9ddf947ae3dc493483e819593d2ba17df9ad05db8b2" +checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" dependencies = [ "bitflags 2.11.0", "cssparser 0.36.0", @@ -6106,9 +6130,9 @@ dependencies = [ [[package]] name = "serde_spanned" -version = "1.0.4" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" +checksum = "876ac351060d4f882bb1032b6369eb0aef79ad9df1ea8bc404874d8cc3d0cd98" dependencies = [ "serde_core", ] @@ -6317,9 +6341,9 @@ dependencies = [ [[package]] name = "simd-adler32" -version = "0.3.8" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" [[package]] name = "siphasher" @@ -6643,9 +6667,9 @@ dependencies = [ [[package]] name = "tao" -version = "0.34.6" +version = "0.34.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e06d52c379e63da659a483a958110bbde891695a0ecb53e48cc7786d5eda7bb" +checksum = "9103edf55f2da3c82aea4c7fab7c4241032bfeea0e71fa557d98e00e7ce7cc20" dependencies = [ "bitflags 2.11.0", "block2", @@ -7138,6 +7162,16 @@ dependencies = [ "utf-8", ] +[[package]] +name = "tendril" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4790fc369d5a530f4b544b094e31388b9b3a37c0f4652ade4505945f5660d24" +dependencies = [ + "new_debug_unreachable", + "utf-8", +] + [[package]] name = "termcolor" version = "1.4.1" @@ -7384,7 +7418,7 @@ checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" dependencies = [ "indexmap 2.13.0", "serde_core", - "serde_spanned 1.0.4", + "serde_spanned 1.1.0", "toml_datetime 0.7.5+spec-1.1.0", "toml_parser", "toml_writer", @@ -7411,9 +7445,9 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "1.0.0+spec-1.1.0" +version = "1.1.0+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32c2555c699578a4f59f0cc68e5116c8d7cabbd45e1409b989d4be085b53f13e" +checksum = "97251a7c317e03ad83774a8752a7e81fb6067740609f75ea2b585b569a59198f" dependencies = [ "serde_core", ] @@ -7444,30 +7478,30 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.4+spec-1.1.0" +version = "0.25.8+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7193cbd0ce53dc966037f54351dbbcf0d5a642c7f0038c382ef9e677ce8c13f2" +checksum = "16bff38f1d86c47f9ff0647e6838d7bb362522bdf44006c7068c2b1e606f1f3c" dependencies = [ "indexmap 2.13.0", - "toml_datetime 1.0.0+spec-1.1.0", + "toml_datetime 1.1.0+spec-1.1.0", "toml_parser", - "winnow 0.7.15", + "winnow 1.0.0", ] [[package]] name = "toml_parser" -version = "1.0.9+spec-1.1.0" +version = "1.1.0+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4" +checksum = "2334f11ee363607eb04df9b8fc8a13ca1715a72ba8662a26ac285c98aabb4011" dependencies = [ - "winnow 0.7.15", + "winnow 1.0.0", ] [[package]] name = "toml_writer" -version = "1.0.6+spec-1.1.0" +version = "1.1.0+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" +checksum = "d282ade6016312faf3e41e57ebbba0c073e4056dab1232ab1cb624199648f8ed" [[package]] name = "tower" @@ -7745,9 +7779,9 @@ checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-segmentation" -version = "1.12.0" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" [[package]] name = "unicode-truncate" @@ -7851,9 +7885,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.22.0" +version = "1.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" +checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" dependencies = [ "getrandom 0.4.2", "js-sys", @@ -9094,6 +9128,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "winnow" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8" +dependencies = [ + "memchr", +] + [[package]] name = "winreg" version = "0.10.1" @@ -9227,9 +9270,9 @@ checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" [[package]] name = "wry" -version = "0.54.3" +version = "0.54.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a24eda84b5d488f99344e54b807138896cee8df0b2d16c793f1f6b80e6d8df1f" +checksum = "e5a8135d8676225e5744de000d4dff5a082501bf7db6a1c1495034f8c314edbc" dependencies = [ "base64 0.22.1", "block2", @@ -9413,18 +9456,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.42" +version = "0.8.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2578b716f8a7a858b7f02d5bd870c14bf4ddbbcf3a4c05414ba6503640505e3" +checksum = "efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.42" +version = "0.8.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e6cc098ea4d3bd6246687de65af3f920c430e236bee1e3bf2e441463f08a02f" +checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89" dependencies = [ "proc-macro2", "quote", diff --git a/crates/openfang-api/Cargo.toml b/crates/openfang-api/Cargo.toml index 6d4e582f..fcc3e008 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 da548e3c..cd78c8b5 100644 --- a/crates/openfang-api/src/server.rs +++ b/crates/openfang-api/src/server.rs @@ -104,6 +104,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 ec7d7db5..6c0dbeb8 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 104286a8..6d9f6375 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. @@ -1057,6 +1066,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), @@ -5985,6 +5997,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 49afb074..f290bbf8 100644 --- a/crates/openfang-types/src/config.rs +++ b/crates/openfang-types/src/config.rs @@ -1161,7 +1161,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 99e488ff..eb45f4f6 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 0f21ae0a..4eb76a45 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:** -``` -ai.yourdomain.com { - basicauth { - username $2a$14$YOUR_HASHED_PASSWORD - } - reverse_proxy localhost:4200 -} +```toml +[auth] +enabled = true +username = "admin" +password_hash = "$argon2id$..." # see below ``` -Generate a password hash: `caddy hash-password` +Generate the password hash: + +```bash +openfang auth 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?