diff --git a/Cargo.lock b/Cargo.lock index d494900..2d47c67 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -307,6 +307,12 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.11.1" @@ -476,6 +482,27 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" +[[package]] +name = "dbus" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ab69f03cc8c4340c9c8e315114e1658e6775a9b16a04357973aa21cec22b32e" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "dbus-secret-service" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6" +dependencies = [ + "dbus", + "zeroize", +] + [[package]] name = "digest" version = "0.10.7" @@ -1153,16 +1180,30 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.99" +version = "0.3.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" dependencies = [ "cfg-if", "futures-util", - "once_cell", "wasm-bindgen", ] +[[package]] +name = "keyring" +version = "3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" +dependencies = [ + "byteorder", + "dbus-secret-service", + "log", + "security-framework 2.11.1", + "security-framework 3.7.0", + "windows-sys 0.60.2", + "zeroize", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -1181,11 +1222,20 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "pkg-config", +] + [[package]] name = "libredox" -version = "0.1.17" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" +checksum = "28d0a00925a9f930d679b6789b721e3a7f9ed110f41b86d2497caa780c3a070a" dependencies = [ "libc", ] @@ -1321,6 +1371,7 @@ dependencies = [ "chrono", "dashmap", "dirs", + "keyring", "liter-llm", "mofa-kernel", "reqwest 0.12.28", @@ -1436,7 +1487,7 @@ dependencies = [ "openssl-probe", "openssl-sys", "schannel", - "security-framework", + "security-framework 3.7.0", "security-framework-sys", "tempfile", ] @@ -1491,9 +1542,9 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "openssl" -version = "0.10.80" +version = "0.10.81" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" dependencies = [ "bitflags", "cfg-if", @@ -1522,9 +1573,9 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "openssl-sys" -version = "0.9.116" +version = "0.9.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" dependencies = [ "cc", "libc", @@ -2002,7 +2053,7 @@ dependencies = [ "openssl-probe", "rustls-pki-types", "schannel", - "security-framework", + "security-framework 3.7.0", ] [[package]] @@ -2029,7 +2080,7 @@ dependencies = [ "rustls-native-certs", "rustls-platform-verifier-android", "rustls-webpki", - "security-framework", + "security-framework 3.7.0", "security-framework-sys", "webpki-root-certs", "windows-sys 0.61.2", @@ -2104,6 +2155,19 @@ dependencies = [ "zeroize", ] +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -2909,9 +2973,9 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.3+wasi-0.2.9" +version = "1.0.4+wasi-0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" dependencies = [ "wit-bindgen 0.57.1", ] @@ -2927,9 +2991,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.122" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" dependencies = [ "cfg-if", "once_cell", @@ -2941,9 +3005,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.72" +version = "0.4.77" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" +checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" dependencies = [ "js-sys", "wasm-bindgen", @@ -2951,9 +3015,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.122" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2961,9 +3025,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.122" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" dependencies = [ "bumpalo", "proc-macro2", @@ -2974,9 +3038,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.122" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" dependencies = [ "unicode-ident", ] @@ -3030,9 +3094,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.99" +version = "0.3.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" +checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" dependencies = [ "js-sys", "wasm-bindgen", @@ -3040,9 +3104,9 @@ dependencies = [ [[package]] name = "webpki-root-certs" -version = "1.0.9" +version = "1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +checksum = "0d46a5a140e6f7afeccd8eae97eff335163939eac8b929834875168b29b3d267" dependencies = [ "rustls-pki-types", ] @@ -3085,7 +3149,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "12342cb4d8e3b046f3d80effd474a7a02447231330ef77d71daa6fbc40681143" dependencies = [ "windows-core 0.57.0", - "windows-targets", + "windows-targets 0.52.6", ] [[package]] @@ -3097,7 +3161,7 @@ dependencies = [ "windows-implement 0.57.0", "windows-interface 0.57.0", "windows-result 0.1.2", - "windows-targets", + "windows-targets 0.52.6", ] [[package]] @@ -3180,7 +3244,7 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" dependencies = [ - "windows-targets", + "windows-targets 0.52.6", ] [[package]] @@ -3207,7 +3271,16 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", ] [[package]] @@ -3225,14 +3298,31 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", ] [[package]] @@ -3241,48 +3331,96 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + [[package]] name = "winnow" version = "0.7.15" @@ -3479,6 +3617,20 @@ name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] [[package]] name = "zerotrie" diff --git a/config.example.toml b/config.example.toml index 52a9882..41c32d7 100644 --- a/config.example.toml +++ b/config.example.toml @@ -68,12 +68,20 @@ cost_tier = "free" capability = "chat" reasoning_tier = "high" +# SECRETS (PLAT-04: 密钥仅存 OS 钥匙串) — three indirection forms keep keys +# out of this file; a bare string remains a literal key: +# api_key = "${OPENAI_API_KEY}" # environment variable +# api_key = "env:OPENAI_API_KEY" # same, terser +# api_key = "keychain:mofa/openai" # OS keychain entry, stored once via: +# mofa-engine keychain set --account mofa/openai (then paste, Enter) +# Works on macOS Keychain / Windows Credential Manager / Linux Secret Service. + # Cloud OpenAI-compatible backend. Models are declared explicitly. [[providers]] name = "openai" kind = "openai_compatible" base_url = "https://api.openai.com/v1" -api_key = "${OPENAI_API_KEY}" +api_key = "keychain:mofa/openai" # or "${OPENAI_API_KEY}" priority = 10 cost_tier = "high" # Optional token pricing (USD per 1000 tokens) for cost tracking in /metrics and diff --git a/mofa-engine-app/src/main.rs b/mofa-engine-app/src/main.rs index a75dc06..54ac1db 100644 --- a/mofa-engine-app/src/main.rs +++ b/mofa-engine-app/src/main.rs @@ -50,6 +50,25 @@ enum Command { Status(DaemonArgs), /// Re-run discovery on a running daemon. Refresh(DaemonArgs), + /// Store a provider secret in the OS keychain (PLAT-04). + KeychainSet { + /// Keychain account, e.g. `mofa/openai` (referenced as `keychain:mofa/openai`). + #[arg(long)] + account: String, + /// The secret; omit to read one line from stdin (keeps it out of shell history). + #[arg(long)] + value: Option, + }, + /// Read a keychain entry (prints the secret — for verification only). + KeychainGet { + #[arg(long)] + account: String, + }, + /// Delete a keychain entry (idempotent). + KeychainDelete { + #[arg(long)] + account: String, + }, /// Invoke a model on a running daemon. Invoke { #[command(flatten)] @@ -89,6 +108,39 @@ impl Cli { } Some(Command::Status(d)) => Self::print_json(Self::daemon_client(d).status().await), Some(Command::Refresh(d)) => Self::print_json(Self::daemon_client(d).refresh().await), + Some(Command::KeychainSet { account, value }) => { + let secret = match value { + Some(v) => v, + None => { + let mut line = String::new(); + std::io::stdin() + .read_line(&mut line) + .map_err(|e| anyhow::anyhow!("reading stdin: {e}"))?; + line.trim_end().to_string() + } + }; + if secret.is_empty() { + anyhow::bail!("refusing to store an empty secret for '{account}'"); + } + mofa_engine_core::secrets::store(&account, &secret) + .map_err(|e| anyhow::anyhow!(e))?; + println!("stored keychain:{account}"); + Ok(()) + } + Some(Command::KeychainGet { account }) => { + match mofa_engine_core::secrets::load(&account).map_err(|e| anyhow::anyhow!(e))? { + Some(secret) => { + println!("{secret}"); + Ok(()) + } + None => anyhow::bail!("keychain:{account} is not set"), + } + } + Some(Command::KeychainDelete { account }) => { + mofa_engine_core::secrets::delete(&account).map_err(|e| anyhow::anyhow!(e))?; + println!("deleted keychain:{account}"); + Ok(()) + } Some(Command::Invoke { daemon, capability, diff --git a/mofa-engine-core/Cargo.toml b/mofa-engine-core/Cargo.toml index 6cec2e7..3d9c984 100644 --- a/mofa-engine-core/Cargo.toml +++ b/mofa-engine-core/Cargo.toml @@ -29,3 +29,4 @@ tokio-stream = { workspace = true } # Vetted 1.11.0 for the initial engine landing. liter-llm = "=1.11.0" base64 = "0.23.0" +keyring = { version = "3", features = ["apple-native", "windows-native", "sync-secret-service"] } diff --git a/mofa-engine-core/src/config.rs b/mofa-engine-core/src/config.rs index f596717..68480a3 100644 --- a/mofa-engine-core/src/config.rs +++ b/mofa-engine-core/src/config.rs @@ -737,15 +737,31 @@ impl EngineConfig { } impl ProviderConfig { - /// Resolve a `${ENV_VAR}` reference to the environment variable's value, - /// passing any other string through unchanged. Lets a config keep secrets - /// (e.g. `api_key = "${OPENAI_API_KEY}"`) out of the file itself. + /// Resolve an indirect secret reference to its value, passing any other + /// string through unchanged. Three indirection forms keep secrets out of + /// the config file itself: + /// - `${VAR}` / `env:VAR` — an environment variable, and + /// - `keychain:ACCOUNT` — the OS keychain entry written by + /// `mofa-engine keychain set ACCOUNT` (PLAT-04: 密钥仅存 OS 钥匙串). + /// Anything else is a literal secret. Real API keys never start with + /// these prefixes, so this cannot accidentally shadow one. fn resolve_env_var(s: &str) -> Result { // Two accepted indirection forms resolve to an environment variable: // - `${VAR}` (shell-style), and // - `env:VAR` (the terser form used throughout our example configs). // Anything else is treated as a literal secret. A real API key never // matches these prefixes, so this cannot accidentally shadow one. + if let Some(account) = s.strip_prefix("keychain:") { + return crate::secrets::load(account).map_err(EngineError::Config).and_then( + |secret| { + secret.ok_or_else(|| { + EngineError::Config(format!( + "keychain entry '{account}' is not set (store it with: mofa-engine keychain set {account})" + )) + }) + }, + ); + } let var_name = s .strip_prefix("${") .and_then(|rest| rest.strip_suffix('}')) @@ -781,6 +797,27 @@ impl ProviderConfig { mod tests { use super::*; + #[test] + fn resolve_keychain_reference_via_mock_store() { + // Shared process-wide persistent stand-in (see secrets.rs). + crate::secrets::install_mock_store_once(); + crate::secrets::store("test/resolve", "sk-from-keychain").unwrap(); + assert_eq!( + ProviderConfig::resolve_env_var("keychain:test/resolve").unwrap(), + "sk-from-keychain" + ); + // A literal secret with a similar shape but no prefix passes through. + assert_eq!( + ProviderConfig::resolve_env_var("keychain-plain-key").unwrap(), + "keychain-plain-key" + ); + // Missing entry → honest error naming the fix command. + let err = ProviderConfig::resolve_env_var("keychain:test/missing").unwrap_err(); + let message = err.to_string(); + assert!(message.contains("keychain set"), "got: {message}"); + crate::secrets::delete("test/resolve").unwrap(); + } + #[test] fn resolve_env_var_plain() { assert_eq!( diff --git a/mofa-engine-core/src/lib.rs b/mofa-engine-core/src/lib.rs index 2336fd4..52b8c55 100644 --- a/mofa-engine-core/src/lib.rs +++ b/mofa-engine-core/src/lib.rs @@ -18,6 +18,7 @@ pub mod config; pub mod engine; pub mod preflight; pub mod quality_gate; +pub mod secrets; pub mod subscription; pub use config::EngineConfig; diff --git a/mofa-engine-core/src/secrets.rs b/mofa-engine-core/src/secrets.rs new file mode 100644 index 0000000..31dcd88 --- /dev/null +++ b/mofa-engine-core/src/secrets.rs @@ -0,0 +1,168 @@ +//! OS-keychain secret storage (PLAT-04: 密钥仅存 OS 钥匙串). +//! +//! Provider credentials can be referenced from config as +//! `api_key = "keychain:ACCOUNT"` — the file then holds only the account +//! name, and the secret itself lives in the operating system's credential +//! store (macOS Keychain, Windows Credential Manager, Linux Secret Service). +//! The [`crate::config`] loader resolves the reference at parse time, so the +//! rest of the engine only ever sees the real key in memory. +//! +//! When no OS credential store is available (headless Linux without a +//! secret service, minimal container images) every operation returns an +//! honest error and config loading fails loudly for keychain-referenced +//! keys — we never silently fall back to plaintext. + +/// The service name all MoFA entries live under in the OS credential store. +const SERVICE: &str = "mofa-engine"; + +// Tests need a PERSISTENT in-memory stand-in: keyring's own mock keeps the +// password inside each Entry (EntryOnly persistence), so a +// store→new-Entry→load round trip always reads back empty. This map mimics +// a real keychain instead: process-wide, keyed by account. +#[cfg(test)] +static MOCK_INSTALLED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); +#[cfg(test)] +static MOCK_MAP: std::sync::Mutex>> = + std::sync::Mutex::new(None); + +/// Store `secret` under `account`, replacing any previous value. +pub fn store(account: &str, secret: &str) -> Result<(), String> { + validate_account(account)?; + #[cfg(test)] + if mock_active() { + mock_map() + .get_or_insert_with(Default::default) + .insert(account.to_string(), secret.to_string()); + return Ok(()); + } + entry(account)? + .set_password(secret) + .map_err(|e| format!("keychain store '{account}' failed: {e}")) +} + +/// Load the secret stored under `account`; `None` when no entry exists. +pub fn load(account: &str) -> Result, String> { + validate_account(account)?; + #[cfg(test)] + if mock_active() { + return Ok(mock_map().as_ref().and_then(|m| m.get(account).cloned())); + } + match entry(account)?.get_password() { + Ok(secret) => Ok(Some(secret)), + Err(keyring::Error::NoEntry) => Ok(None), + Err(e) => Err(format!("keychain load '{account}' failed: {e}")), + } +} + +/// Delete the entry (idempotent: a missing entry is success). +pub fn delete(account: &str) -> Result<(), String> { + validate_account(account)?; + #[cfg(test)] + if mock_active() { + if let Some(map) = mock_map().as_mut() { + map.remove(account); + } + return Ok(()); + } + match entry(account)?.delete_credential() { + Ok(()) => Ok(()), + Err(keyring::Error::NoEntry) => Ok(()), + Err(e) => Err(format!("keychain delete '{account}' failed: {e}")), + } +} + +/// Whether an OS credential store is reachable at all (probe write+delete). +/// Always true under the test stand-in. +pub fn available() -> bool { + #[cfg(test)] + if mock_active() { + return true; + } + let probe = format!("mofa-probe-{}", uuid::Uuid::new_v4()); + match entry(&probe) { + Ok(e) => e + .set_password("probe") + .and_then(|_| e.delete_credential()) + .is_ok(), + Err(_) => false, + } +} + +fn validate_account(account: &str) -> Result<(), String> { + if account.trim().is_empty() { + return Err("keychain account must not be empty".into()); + } + Ok(()) +} + +fn entry(account: &str) -> Result { + keyring::Entry::new(SERVICE, account).map_err(|e| format!("OS keychain unavailable: {e}")) +} + +// ==================== test stand-in ==================== + +/// Install the persistent in-memory stand-in for the OS keychain, +/// process-wide. Tests run in parallel and share one map, mirroring a real +/// keychain's cross-entry persistence. +#[cfg(test)] +#[doc(hidden)] +pub fn install_mock_store_once() { + use std::sync::Once; + static INIT: Once = Once::new(); + INIT.call_once(|| { + MOCK_INSTALLED.store(true, std::sync::atomic::Ordering::SeqCst); + }); +} + +#[cfg(test)] +fn mock_active() -> bool { + MOCK_INSTALLED.load(std::sync::atomic::Ordering::SeqCst) +} + +#[cfg(test)] +fn mock_map() -> std::sync::MutexGuard<'static, Option>> { + MOCK_MAP.lock().unwrap_or_else(|e| e.into_inner()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn mock_store() { + install_mock_store_once(); + } + + #[test] + fn store_load_delete_round_trip() { + mock_store(); + store("test/openai", "sk-123").unwrap(); + assert_eq!(load("test/openai").unwrap().as_deref(), Some("sk-123")); + + // Overwrite replaces. + store("test/openai", "sk-456").unwrap(); + assert_eq!(load("test/openai").unwrap().as_deref(), Some("sk-456")); + + delete("test/openai").unwrap(); + assert_eq!(load("test/openai").unwrap(), None); + } + + #[test] + fn delete_is_idempotent_and_missing_loads_none() { + mock_store(); + assert_eq!(load("never/stored").unwrap(), None); + delete("never/stored").unwrap(); + } + + #[test] + fn empty_accounts_are_rejected() { + mock_store(); + assert!(store(" ", "x").is_err()); + assert!(load("").is_err()); + } + + #[test] + fn probe_reports_available_under_the_stand_in() { + mock_store(); + assert!(available()); + } +}