From cc2c9a2973178a1ca956692b09790ecd7ac3e1e3 Mon Sep 17 00:00:00 2001 From: Hare Date: Mon, 1 Jun 2026 07:07:39 +0900 Subject: [PATCH] secrets: add local key store --- Cargo.lock | 14 + Cargo.toml | 2 + crates/insomnia/src/main.rs | 18 +- crates/manifest/src/config.rs | 13 +- crates/manifest/src/lib.rs | 8 +- crates/manifest/src/model.rs | 26 +- crates/manifest/src/profile.rs | 4 +- crates/pod/src/spawn/tool.rs | 1 - crates/provider/Cargo.toml | 1 + crates/provider/README.md | 4 +- crates/provider/src/catalog.rs | 36 +-- crates/provider/src/lib.rs | 146 +++++---- crates/secrets/Cargo.toml | 14 + crates/secrets/src/lib.rs | 493 +++++++++++++++++++++++++++++++ crates/tools/Cargo.toml | 1 + crates/tools/src/web.rs | 100 ++++++- crates/tui/Cargo.toml | 1 + crates/tui/src/keys.rs | 406 +++++++++++++++++++++++++ crates/tui/src/lib.rs | 1 + docs/architecture.md | 2 +- docs/environment.md | 33 ++- docs/manifest-profiles.md | 2 +- docs/manifest.toml | 16 +- resources/profiles/default.lua | 2 +- resources/providers/builtin.toml | 4 +- 25 files changed, 1197 insertions(+), 151 deletions(-) create mode 100644 crates/secrets/Cargo.toml create mode 100644 crates/secrets/src/lib.rs create mode 100644 crates/tui/src/keys.rs diff --git a/Cargo.lock b/Cargo.lock index a15dc817..ec29f7c9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2473,6 +2473,7 @@ dependencies = [ "llm-worker", "manifest", "reqwest", + "secrets", "serde", "serde_json", "serial_test", @@ -3047,6 +3048,17 @@ version = "3.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "490dcfcbfef26be6800d11870ff2df8774fa6e86d047e3e8c8a76b25655e41ca" +[[package]] +name = "secrets" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "sha2 0.11.0", + "tempfile", + "thiserror 2.0.18", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -3783,6 +3795,7 @@ dependencies = [ "markup5ever_rcdom", "reqwest", "schemars", + "secrets", "serde", "serde_json", "sha2 0.11.0", @@ -3932,6 +3945,7 @@ dependencies = [ "protocol", "pulldown-cmark", "ratatui", + "secrets", "serde", "serde_json", "session-store", diff --git a/Cargo.toml b/Cargo.toml index 6717b04f..a4340c73 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ members = [ "crates/llm-worker", "crates/llm-worker-macros", "crates/session-store", + "crates/secrets", "crates/manifest", "crates/pod", "crates/insomnia", @@ -41,6 +42,7 @@ protocol = { path = "crates/protocol" } provider = { path = "crates/provider" } session-metrics = { path = "crates/session-metrics" } session-store = { path = "crates/session-store" } +secrets = { path = "crates/secrets" } tools = { path = "crates/tools" } tui = { path = "crates/tui" } diff --git a/crates/insomnia/src/main.rs b/crates/insomnia/src/main.rs index 544fe4bc..ac1918e8 100644 --- a/crates/insomnia/src/main.rs +++ b/crates/insomnia/src/main.rs @@ -15,6 +15,7 @@ enum Mode { MemoryLintHelp, MemoryLint(LintCliOptions), PodRuntime(Vec), + Keys, Tui(LaunchMode), } @@ -58,6 +59,7 @@ async fn main() -> ExitCode { } }, Mode::PodRuntime(args) => pod::entrypoint::run_cli_from("insomnia pod", args).await, + Mode::Keys => tui::keys::launch().await, Mode::Tui(mode) => { let runtime_command = match PodRuntimeCommand::resolve() { Ok(command) => command, @@ -96,6 +98,12 @@ fn parse_args_slice(args: &[String]) -> Result { match args[0].as_str() { "--help" | "-h" => return Ok(Mode::Help), "pod" => return Ok(Mode::PodRuntime(args[1..].to_vec())), + "keys" => { + if args.len() != 1 { + return Err(ParseError("insomnia keys does not accept arguments".into())); + } + return Ok(Mode::Keys); + } "memory" if args.get(1).map(String::as_str) == Some("lint") => { let lint_args = &args[2..]; if lint_args.iter().any(|arg| arg == "--help" || arg == "-h") { @@ -314,7 +322,7 @@ fn parse_session_id(value: &str) -> Result { fn print_help() { println!( - "insomnia\n\nUsage:\n insomnia [OPTIONS] [POD_NAME]\n insomnia pod [POD_OPTIONS]\n insomnia memory lint [OPTIONS]\n\nOptions:\n -r, --resume Open the Pod picker and resume/attach a Pod\n --multi Open the multi-Pod dashboard\n --pod Attach/restore/create a Pod by name\n --socket Attach to a specific Pod socket with --pod\n --session Resume a specific session segment\n --profile Start a fresh Pod from a profile\n -h, --help Print help\n" + "insomnia\n\nUsage:\n insomnia [OPTIONS] [POD_NAME]\n insomnia keys\n insomnia pod [POD_OPTIONS]\n insomnia memory lint [OPTIONS]\n\nOptions:\n -r, --resume Open the Pod picker and resume/attach a Pod\n --multi Open the multi-Pod dashboard\n --pod Attach/restore/create a Pod by name\n --socket Attach to a specific Pod socket with --pod\n --session Resume a specific session segment\n --profile Start a fresh Pod from a profile\n -h, --help Print help\n" ); } @@ -378,6 +386,14 @@ mod tests { } } + #[test] + fn parse_keys_subcommand() { + match parse_args_from(["keys"]).unwrap() { + Mode::Keys => {} + _ => panic!("expected Keys mode"), + } + } + #[test] fn parse_literal_pod_name_still_available_with_flag() { match parse_args_from(["--pod", "pod"]).unwrap() { diff --git a/crates/manifest/src/config.rs b/crates/manifest/src/config.rs index c29f37e5..01b397da 100644 --- a/crates/manifest/src/config.rs +++ b/crates/manifest/src/config.rs @@ -331,7 +331,7 @@ impl crate::WebSearchConfig { Self { enabled: upper.enabled.or(self.enabled), provider: upper.provider.or(self.provider), - api_key_env: upper.api_key_env.or(self.api_key_env), + api_key_secret: upper.api_key_secret.or(self.api_key_secret), timeout_secs: upper.timeout_secs.or(self.timeout_secs), base_url: upper.base_url.or(self.base_url), country: upper.country.or(self.country), @@ -517,7 +517,7 @@ fn ensure_absolute(field: &'static str, path: &Path) -> Result<(), ResolveError> } } -/// `AuthRef::ApiKey { file, .. }` が相対パスのとき `base` を前置する。 +/// `AuthRef::ApiKey { file }` が相対パスのとき `base` を前置する。 fn resolve_auth_file(auth: &mut Option, base: &Path) { if let Some(AuthRef::ApiKey { file: Some(p), .. }) = auth.as_mut() { *p = join_if_relative(base, p); @@ -692,10 +692,7 @@ mod tests { } fn api_key_file_auth(path: PathBuf) -> AuthRef { - AuthRef::ApiKey { - env: None, - file: Some(path), - } + AuthRef::ApiKey { file: Some(path) } } fn minimal_valid() -> PodManifestConfig { @@ -1089,7 +1086,7 @@ mod tests { }), web: Some(WebConfig { search: Some(crate::WebSearchConfig { - api_key_env: Some("LOWER_BRAVE_KEY".into()), + api_key_secret: Some("web/brave/lower".into()), timeout_secs: Some(12), ..Default::default() }), @@ -1118,7 +1115,7 @@ mod tests { assert_eq!(c.prune_protected_tokens, Some(5_000)); let search = merged.web.unwrap().search.unwrap(); assert_eq!(search.timeout_secs, Some(3)); - assert_eq!(search.api_key_env.as_deref(), Some("LOWER_BRAVE_KEY")); + assert_eq!(search.api_key_secret.as_deref(), Some("web/brave/lower")); } #[test] diff --git a/crates/manifest/src/lib.rs b/crates/manifest/src/lib.rs index bd47f46c..1540c39f 100644 --- a/crates/manifest/src/lib.rs +++ b/crates/manifest/src/lib.rs @@ -122,10 +122,10 @@ pub struct WebSearchConfig { pub enabled: Option, #[serde(default)] pub provider: Option, - /// Environment variable that stores the provider API key. Raw secrets do - /// not belong in manifest files. + /// Local secret-store id for the provider API key. Raw secrets do not + /// belong in manifest files. #[serde(default)] - pub api_key_env: Option, + pub api_key_secret: Option, /// Request timeout in seconds. Tool implementation applies a safe default /// when this is omitted. #[serde(default)] @@ -651,7 +651,7 @@ permission = "write" #[test] fn parse_web_config() { let toml = format!( - "{}\n[web]\nenabled = true\n\n[web.search]\nprovider = \"brave\"\napi_key_env = \"BRAVE_SEARCH_API_KEY\"\ntimeout_secs = 12\n\n[web.fetch]\ntimeout_secs = 7\nredirect_limit = 3\nmax_response_bytes = 12345\nmax_output_bytes = 2048\n", + "{}\n[web]\nenabled = true\n\n[web.search]\nprovider = \"brave\"\napi_key_secret = \"web/brave/default\"\ntimeout_secs = 12\n\n[web.fetch]\ntimeout_secs = 7\nredirect_limit = 3\nmax_response_bytes = 12345\nmax_output_bytes = 2048\n", MINIMAL_REQUIRED ); let manifest = PodManifest::from_toml(&toml).unwrap(); diff --git a/crates/manifest/src/model.rs b/crates/manifest/src/model.rs index 3b29a565..691f8657 100644 --- a/crates/manifest/src/model.rs +++ b/crates/manifest/src/model.rs @@ -97,7 +97,7 @@ pub enum SchemeKind { /// 認証の参照。 /// -/// 実際のトークン値の解決(env / file 読取、OAuth refresh 等)は +/// 実際のトークン値の解決(local secret store / file 読取、OAuth refresh 等)は /// `crates/provider` で行う。ここはあくまで「どこから取るか」の宣言。 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] #[serde(tag = "kind", rename_all = "snake_case")] @@ -105,11 +105,10 @@ pub enum AuthRef { /// 認証不要(ローカル Ollama 等) #[default] None, - /// API key。env / file のいずれか(両方指定された場合は env が優先) + /// API key file reference. Prefer [`AuthRef::SecretRef`] for normal + /// provider credentials; this remains an explicit file source for low-level + /// manifests and tests. ApiKey { - /// 環境変数名。未指定のときは scheme ごとの既定(`INSOMNIA_API_KEY_*`) - #[serde(default)] - env: Option, /// key を書き込んだファイル(絶対パス) #[serde(default)] file: Option, @@ -117,8 +116,8 @@ pub enum AuthRef { /// ChatGPT OAuth(`~/.codex/auth.json`)。実装は `llm-auth-codex-oauth` チケット #[serde(rename = "codex_oauth")] CodexOAuth, - /// Typed secret-store reference. The profile resolver preserves this - /// reference verbatim; secret-store lookup/decryption is intentionally a + /// Typed local secret-store reference. The profile resolver preserves this + /// reference verbatim; secret-store lookup/deobfuscation is intentionally a /// later consumer-boundary concern. #[serde(rename = "secret_ref")] SecretRef { @@ -126,16 +125,3 @@ pub enum AuthRef { ref_: String, }, } - -impl SchemeKind { - /// 既定の環境変数名(`INSOMNIA_API_KEY_*`)。 - /// - /// `AuthRef::ApiKey { env: None, .. }` の env 未指定時に使う。 - pub fn default_env_var(self) -> &'static str { - match self { - Self::Anthropic => "INSOMNIA_API_KEY_ANTHROPIC", - Self::OpenaiChat | Self::OpenaiResponses => "INSOMNIA_API_KEY_OPENAI", - Self::Gemini => "INSOMNIA_API_KEY_GEMINI", - } - } -} diff --git a/crates/manifest/src/profile.rs b/crates/manifest/src/profile.rs index 3937fd4f..d4df68a6 100644 --- a/crates/manifest/src/profile.rs +++ b/crates/manifest/src/profile.rs @@ -1038,9 +1038,7 @@ fn reject_absolute_auth_file( auth: &Option, field: &'static str, ) -> Result<(), ProfileError> { - if let Some(AuthRef::ApiKey { - file: Some(file), .. - }) = auth + if let Some(AuthRef::ApiKey { file: Some(file) }) = auth && file.is_absolute() { return Err(ProfileError::InvalidProfile(format!( diff --git a/crates/pod/src/spawn/tool.rs b/crates/pod/src/spawn/tool.rs index aaf85d59..520332cb 100644 --- a/crates/pod/src/spawn/tool.rs +++ b/crates/pod/src/spawn/tool.rs @@ -991,7 +991,6 @@ return profile { base_url: Some("https://example.test".into()), model_id: Some("claude-sonnet-4".into()), auth: Some(AuthRef::ApiKey { - env: None, file: Some(PathBuf::from("/etc/keys/anthropic")), }), ..Default::default() diff --git a/crates/provider/Cargo.toml b/crates/provider/Cargo.toml index 8ae8939c..9f049af3 100644 --- a/crates/provider/Cargo.toml +++ b/crates/provider/Cargo.toml @@ -10,6 +10,7 @@ base64 = "0.22.1" chrono = { version = "0.4", default-features = false, features = ["serde", "clock"] } llm-worker = { workspace = true } manifest = { workspace = true } +secrets = { workspace = true } reqwest = { version = "0.13", features = ["json", "native-tls"] } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } diff --git a/crates/provider/README.md b/crates/provider/README.md index 42edaa19..a87c86ec 100644 --- a/crates/provider/README.md +++ b/crates/provider/README.md @@ -1,6 +1,6 @@ # provider -マニフェストの `ModelManifest` から適切な `LlmClient`(`HttpTransport`)を構築するファクトリクレート。プロバイダ / モデルカタログの解決、API キーの環境変数 / ファイル解決、scheme ↔ auth の整合検証を担う。 +マニフェストの `ModelManifest` から適切な `LlmClient`(`HttpTransport`)を構築するファクトリクレート。プロバイダ / モデルカタログの解決、API キーの local secret store / 明示ファイル解決、scheme ↔ auth の整合検証を担う。 ## 公開型 @@ -14,7 +14,7 @@ - プロバイダ / モデルカタログの builtin (`resources/{providers,models}/builtin.toml`) と user override (`$XDG_CONFIG_HOME/insomnia/{providers,models}.toml`) の解決 - `ModelManifest` の ref 形を `(provider, model_id)` に split し、`ModelConfig` へ展開 -- `AuthRef::ApiKey` を `ResolvedAuth::ApiKey` に解決(env → file の優先順位) +- `AuthRef::SecretRef` / `AuthRef::ApiKey` を `ResolvedAuth::ApiKey` に解決(通常は local secret store、低レベル manifest では明示ファイルも可) - `AuthRef::None` / `AuthRef::CodexOAuth` の解決 - `Scheme::required_auth()` と `ResolvedAuth` の妥当性検証(非対応組合せは構築エラー) - capability は manifest 明示 > model catalog > provider.default_capability > `Scheme::default_capability()` の順で解決 diff --git a/crates/provider/src/catalog.rs b/crates/provider/src/catalog.rs index 8ad7e4b8..be965d9f 100644 --- a/crates/provider/src/catalog.rs +++ b/crates/provider/src/catalog.rs @@ -72,10 +72,15 @@ pub enum ResolveError { pub enum AuthHint { /// 認証不要(ローカル Ollama 等) None, - /// API key。`env` が指定されていれば UI はその env 名を提示する - ApiKey { - #[serde(default)] - env: Option, + /// API key file reference. Normal credential configuration should prefer + /// [`AuthHint::SecretRef`] so plaintext credentials stay out of manifests. + ApiKey, + /// Local secret-store reference. The catalog/profile explicitly chooses the + /// logical key id; the secret store itself has no provider semantics. + #[serde(rename = "secret_ref")] + SecretRef { + #[serde(rename = "ref")] + ref_: String, }, /// ChatGPT OAuth(`~/.codex/auth.json`) #[serde(rename = "codex_oauth")] @@ -153,16 +158,12 @@ struct ModelCatalogFile { model: Vec, } -/// `auth_hint` に対応する [`AuthRef`] のひな型を返す。env / file は -/// マニフェスト側で override 可能なので、ここでは hint そのままを -/// 反映した最小形だけを返す(`AuthRef::ApiKey { env: hint_env, file: None }`)。 +/// `auth_hint` に対応する [`AuthRef`] のひな型を返す。 fn auth_hint_to_ref(hint: &AuthHint) -> AuthRef { match hint { AuthHint::None => AuthRef::None, - AuthHint::ApiKey { env } => AuthRef::ApiKey { - env: env.clone(), - file: None, - }, + AuthHint::ApiKey => AuthRef::ApiKey { file: None }, + AuthHint::SecretRef { ref_ } => AuthRef::SecretRef { ref_: ref_.clone() }, AuthHint::CodexOAuth => AuthRef::CodexOAuth, } } @@ -415,11 +416,10 @@ mod tests { assert_eq!(cfg.model_id, "claude-sonnet-4-6"); assert_eq!(cfg.base_url.as_deref(), Some("https://api.anthropic.com")); match cfg.auth { - AuthRef::ApiKey { env, file } => { - assert_eq!(env.as_deref(), Some("INSOMNIA_API_KEY_ANTHROPIC")); - assert!(file.is_none()); + AuthRef::SecretRef { ref_ } => { + assert_eq!(ref_, "providers/anthropic/default"); } - _ => panic!("expected ApiKey auth from provider hint"), + _ => panic!("expected SecretRef auth from provider hint"), } assert!( cfg.capability.is_some(), @@ -493,15 +493,13 @@ mod tests { let manifest = ModelManifest { ref_: Some("anthropic/claude-sonnet-4-6".into()), auth: Some(AuthRef::ApiKey { - env: None, file: Some(PathBuf::from("/tmp/sk-ant")), }), ..Default::default() }; let cfg = resolve_with_catalogs(&manifest, &providers, &models).unwrap(); match cfg.auth { - AuthRef::ApiKey { env, file } => { - assert!(env.is_none()); + AuthRef::ApiKey { file } => { assert_eq!(file.as_deref(), Some(Path::new("/tmp/sk-ant"))); } _ => panic!("override auth should win"), @@ -555,7 +553,6 @@ mod tests { scheme: Some(SchemeKind::Anthropic), model_id: Some("claude-sonnet-4-6".into()), auth: Some(AuthRef::ApiKey { - env: None, file: Some(PathBuf::from("/tmp/sk")), }), ..Default::default() @@ -575,7 +572,6 @@ mod tests { scheme: Some(SchemeKind::Anthropic), model_id: Some("claude-sonnet-4-6".into()), auth: Some(AuthRef::ApiKey { - env: None, file: Some(PathBuf::from("/tmp/sk")), }), context_window: Some(777_000), diff --git a/crates/provider/src/lib.rs b/crates/provider/src/lib.rs index 8f44bf11..6787d38e 100644 --- a/crates/provider/src/lib.rs +++ b/crates/provider/src/lib.rs @@ -4,7 +4,7 @@ //! 段階: //! 1. `ModelManifest` を [`catalog::resolve_model_manifest`] で //! カタログ込み [`ModelConfig`] に解決(ref → 展開 / inline → 検証) -//! 2. `AuthRef` を環境変数 / ファイルから解決して [`ResolvedAuth`] に +//! 2. `AuthRef` を local secret store / ファイルから解決して [`ResolvedAuth`] に //! 3. `scheme.required_auth()` と解決値を照合(非対応組合せは構築エラー) //! 4. `ModelCapability` は manifest 明示 > model catalog > provider //! default_capability > scheme 既定 の順でフォールバック(上位 3 段は @@ -30,6 +30,7 @@ use llm_worker::llm_client::{ }; use manifest::{AuthRef, ModelManifest, SchemeKind}; +use secrets::{SecretStore, SecretValue}; pub use catalog::{ModelConfig, ResolveError as CatalogResolveError}; @@ -42,6 +43,13 @@ pub enum ProviderError { #[error("API key not provided for scheme {scheme:?}")] ApiKeyMissing { scheme: SchemeKind }, + #[error("failed to resolve secret `{id}`: {source}")] + SecretStore { + id: String, + #[source] + source: secrets::Error, + }, + #[error("scheme {scheme:?} does not support this auth")] AuthMismatch { scheme: SchemeKind }, @@ -53,21 +61,38 @@ pub enum ProviderError { } /// `AuthRef` をランタイムで使える [`ResolvedAuth`] に解決する。 -/// -/// 解決順: -/// 1. `AuthRef::ApiKey { env, .. }` で env が指定されていればその変数を参照 -/// 2. そうでなければ scheme 既定の環境変数 (`SchemeKind::default_env_var`) -/// 3. それでも無ければ `file` を読む(絶対パスのみ) fn resolve_auth(scheme: SchemeKind, auth: &AuthRef) -> Result { + let resolver = DefaultSecretResolver; + resolve_auth_with_resolver(scheme, auth, &resolver) +} + +trait SecretResolver { + fn get_secret(&self, id: &str) -> Result; +} + +struct DefaultSecretResolver; + +impl SecretResolver for DefaultSecretResolver { + fn get_secret(&self, id: &str) -> Result { + let data_dir = manifest::paths::data_dir().ok_or_else(|| secrets::Error::Read { + path: std::path::PathBuf::from(""), + source: std::io::Error::new( + std::io::ErrorKind::NotFound, + "could not determine insomnia data directory", + ), + })?; + SecretStore::new(data_dir).get(id) + } +} + +fn resolve_auth_with_resolver( + scheme: SchemeKind, + auth: &AuthRef, + resolver: &dyn SecretResolver, +) -> Result { match auth { AuthRef::None => Ok(ResolvedAuth::None), - AuthRef::ApiKey { env, file } => { - let env_name = env.as_deref().unwrap_or(scheme.default_env_var()); - if let Ok(val) = std::env::var(env_name) - && !val.is_empty() - { - return Ok(ResolvedAuth::ApiKey(val)); - } + AuthRef::ApiKey { file } => { if let Some(path) = file { if !path.is_absolute() { return Err(ProviderError::Config(format!( @@ -90,9 +115,15 @@ fn resolve_auth(scheme: SchemeKind, auth: &AuthRef) -> Result Err(ProviderError::Config(format!( - "secret store references are not implemented yet: {ref_}" - ))), + AuthRef::SecretRef { ref_ } => { + let value = resolver + .get_secret(ref_) + .map_err(|source| ProviderError::SecretStore { + id: ref_.clone(), + source, + })?; + Ok(ResolvedAuth::ApiKey(value.into_string())) + } } } @@ -179,15 +210,24 @@ mod tests { use std::io::Write; use std::path::PathBuf; + struct TestSecrets(std::collections::BTreeMap); + + impl SecretResolver for TestSecrets { + fn get_secret(&self, id: &str) -> Result { + self.0 + .get(id) + .cloned() + .map(SecretValue::new) + .ok_or_else(|| secrets::Error::NotFound { id: id.to_string() }) + } + } + fn anthropic_config() -> ModelConfig { ModelConfig { scheme: SchemeKind::Anthropic, base_url: None, model_id: "claude-sonnet-4-20250514".into(), - auth: AuthRef::ApiKey { - env: None, - file: None, - }, + auth: AuthRef::ApiKey { file: None }, capability: None, context_window: 200_000, max_context_window: None, @@ -195,18 +235,41 @@ mod tests { } #[test] - #[serial] - fn resolve_from_env() { - let env_name = SchemeKind::Anthropic.default_env_var(); - unsafe { std::env::set_var(env_name, "sk-from-env") }; - let auth = resolve_auth(SchemeKind::Anthropic, &anthropic_config().auth).unwrap(); - unsafe { std::env::remove_var(env_name) }; + fn resolve_from_secret_ref() { + let resolver = TestSecrets(std::collections::BTreeMap::from([( + "providers/anthropic/default".to_string(), + "sk-from-store".to_string(), + )])); + let auth = resolve_auth_with_resolver( + SchemeKind::Anthropic, + &AuthRef::SecretRef { + ref_: "providers/anthropic/default".into(), + }, + &resolver, + ) + .unwrap(); match auth { - ResolvedAuth::ApiKey(k) => assert_eq!(k, "sk-from-env"), + ResolvedAuth::ApiKey(k) => assert_eq!(k, "sk-from-store"), _ => panic!("expected ApiKey"), } } + #[test] + fn missing_secret_names_only_id() { + let resolver = TestSecrets(Default::default()); + let err = resolve_auth_with_resolver( + SchemeKind::Anthropic, + &AuthRef::SecretRef { + ref_: "providers/anthropic/missing".into(), + }, + &resolver, + ) + .unwrap_err(); + let message = err.to_string(); + assert!(message.contains("providers/anthropic/missing")); + assert!(!message.contains("sk-")); + } + #[test] fn resolve_from_file() { let dir = tempfile::tempdir().unwrap(); @@ -217,7 +280,6 @@ mod tests { } let config = ModelConfig { auth: AuthRef::ApiKey { - env: Some("INSOMNIA_API_KEY_NONEXISTENT".into()), file: Some(key_path), }, ..anthropic_config() @@ -229,36 +291,10 @@ mod tests { } } - #[test] - #[serial] - fn env_takes_precedence_over_file() { - let dir = tempfile::tempdir().unwrap(); - let key_path = dir.path().join("key.txt"); - std::fs::write(&key_path, "sk-from-file").unwrap(); - - let env_name = SchemeKind::Anthropic.default_env_var(); - unsafe { std::env::set_var(env_name, "sk-from-env") }; - - let config = ModelConfig { - auth: AuthRef::ApiKey { - env: None, - file: Some(key_path), - }, - ..anthropic_config() - }; - let auth = resolve_auth(config.scheme, &config.auth).unwrap(); - unsafe { std::env::remove_var(env_name) }; - match auth { - ResolvedAuth::ApiKey(k) => assert_eq!(k, "sk-from-env"), - _ => panic!("expected ApiKey"), - } - } - #[test] fn relative_auth_file_is_rejected() { let config = ModelConfig { auth: AuthRef::ApiKey { - env: Some("INSOMNIA_API_KEY_NONEXISTENT".into()), file: Some(PathBuf::from("keys/anthropic")), }, ..anthropic_config() @@ -270,8 +306,6 @@ mod tests { #[test] #[serial] fn missing_key_returns_api_key_missing() { - let env_name = SchemeKind::Anthropic.default_env_var(); - unsafe { std::env::remove_var(env_name) }; let result = build_client_from_config(&anthropic_config()); assert!(matches!(result, Err(ProviderError::ApiKeyMissing { .. }))); } diff --git a/crates/secrets/Cargo.toml b/crates/secrets/Cargo.toml new file mode 100644 index 00000000..2d8e5df7 --- /dev/null +++ b/crates/secrets/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "secrets" +version = "0.1.0" +edition.workspace = true +license.workspace = true + +[dependencies] +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +sha2 = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } diff --git a/crates/secrets/src/lib.rs b/crates/secrets/src/lib.rs new file mode 100644 index 00000000..a41f79ee --- /dev/null +++ b/crates/secrets/src/lib.rs @@ -0,0 +1,493 @@ +use std::collections::BTreeMap; +use std::fmt; +use std::fs::{self, OpenOptions}; +use std::io::Write as _; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +const STORE_VERSION: u32 = 1; +const KEY_LEN: usize = 32; +const TAG_LEN: usize = 32; +const MAX_ID_LEN: usize = 128; +static NONCE_COUNTER: AtomicU64 = AtomicU64::new(0); + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("secret id is empty")] + EmptyId, + #[error("secret id `{id}` is too long (max {max} bytes)")] + IdTooLong { id: String, max: usize }, + #[error("secret id `{0}` contains unsupported characters")] + UnsupportedIdChars(String), + #[error("secret id `{0}` must not be absolute or contain traversal components")] + UnsafeId(String), + #[error("failed to read secret store {}: {source}", .path.display())] + Read { + path: PathBuf, + #[source] + source: std::io::Error, + }, + #[error("failed to parse secret store {}: {source}", .path.display())] + Parse { + path: PathBuf, + #[source] + source: serde_json::Error, + }, + #[error("unsupported secret store version {version} in {}", .path.display())] + UnsupportedVersion { path: PathBuf, version: u32 }, + #[error("failed to decode secret `{id}`")] + Decode { id: String }, + #[error("secret `{id}` was not found")] + NotFound { id: String }, + #[error("failed to create secret store directory {}: {source}", .path.display())] + CreateDir { + path: PathBuf, + #[source] + source: std::io::Error, + }, + #[error("failed to write secret store {}: {source}", .path.display())] + Write { + path: PathBuf, + #[source] + source: std::io::Error, + }, +} + +pub type Result = std::result::Result; + +#[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub struct SecretId(String); + +impl SecretId { + pub fn parse(value: impl Into) -> Result { + let value = value.into(); + validate_id(&value)?; + Ok(Self(value)) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Debug for SecretId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_tuple("SecretId").field(&self.0).finish() + } +} + +impl fmt::Display for SecretId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +#[derive(Clone, Eq, PartialEq)] +pub struct SecretValue(String); + +impl SecretValue { + pub fn new(value: impl Into) -> Self { + Self(value.into()) + } + + pub fn expose_secret(&self) -> &str { + &self.0 + } + + pub fn into_string(self) -> String { + self.0 + } +} + +impl fmt::Debug for SecretValue { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("SecretValue([redacted])") + } +} + +#[derive(Debug, Clone)] +pub struct SecretStore { + path: PathBuf, + key: [u8; KEY_LEN], +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +struct StoreFile { + version: u32, + #[serde(default)] + entries: BTreeMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct Entry { + nonce: String, + ciphertext: String, + tag: String, +} + +impl SecretStore { + pub fn new(data_dir: impl AsRef) -> Self { + let data_dir = data_dir.as_ref(); + let path = data_dir.join("secrets").join("store.json"); + Self::at_path_with_key(path, derive_key(data_dir)) + } + + pub fn at_path_for_tests(path: impl AsRef) -> Self { + let path = path.as_ref().to_path_buf(); + Self::at_path_with_key( + path.clone(), + derive_key(path.parent().unwrap_or(Path::new(""))), + ) + } + + pub fn at_path_with_key(path: PathBuf, key: [u8; KEY_LEN]) -> Self { + Self { path, key } + } + + pub fn path(&self) -> &Path { + &self.path + } + + pub fn list_ids(&self) -> Result> { + let file = self.load()?; + file.entries.into_keys().map(SecretId::parse).collect() + } + + pub fn get(&self, id: &str) -> Result { + let id = SecretId::parse(id.to_string())?; + let file = self.load()?; + let entry = file + .entries + .get(id.as_str()) + .ok_or_else(|| Error::NotFound { id: id.to_string() })?; + let plaintext = decrypt_entry(&self.key, &id, entry)?; + Ok(SecretValue::new( + String::from_utf8(plaintext).map_err(|_| Error::Decode { id: id.to_string() })?, + )) + } + + pub fn set(&self, id: &str, value: SecretValue) -> Result<()> { + let id = SecretId::parse(id.to_string())?; + let mut file = self.load()?; + file.entries.insert( + id.to_string(), + encrypt_entry(&self.key, &id, value.expose_secret().as_bytes()), + ); + self.save(&file) + } + + pub fn delete(&self, id: &str) -> Result { + let id = SecretId::parse(id.to_string())?; + let mut file = self.load()?; + let removed = file.entries.remove(id.as_str()).is_some(); + if removed { + self.save(&file)?; + } + Ok(removed) + } + + fn load(&self) -> Result { + match fs::read_to_string(&self.path) { + Ok(text) => { + let file: StoreFile = + serde_json::from_str(&text).map_err(|source| Error::Parse { + path: self.path.clone(), + source, + })?; + if file.version != STORE_VERSION { + return Err(Error::UnsupportedVersion { + path: self.path.clone(), + version: file.version, + }); + } + Ok(file) + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(StoreFile { + version: STORE_VERSION, + entries: BTreeMap::new(), + }), + Err(source) => Err(Error::Read { + path: self.path.clone(), + source, + }), + } + } + + fn save(&self, file: &StoreFile) -> Result<()> { + let parent = self.path.parent().unwrap_or(Path::new(".")); + fs::create_dir_all(parent).map_err(|source| Error::CreateDir { + path: parent.to_path_buf(), + source, + })?; + let data = serde_json::to_vec_pretty(file).map_err(|source| Error::Write { + path: self.path.clone(), + source: std::io::Error::other(source), + })?; + let tmp = self.temp_path(); + { + let mut fh = OpenOptions::new() + .create_new(true) + .write(true) + .open(&tmp) + .map_err(|source| Error::Write { + path: tmp.clone(), + source, + })?; + fh.write_all(&data).map_err(|source| Error::Write { + path: tmp.clone(), + source, + })?; + fh.sync_all().map_err(|source| Error::Write { + path: tmp.clone(), + source, + })?; + } + fs::rename(&tmp, &self.path).map_err(|source| Error::Write { + path: self.path.clone(), + source, + })?; + if let Ok(dir) = fs::File::open(parent) { + let _ = dir.sync_all(); + } + Ok(()) + } + + fn temp_path(&self) -> PathBuf { + let suffix = NONCE_COUNTER.fetch_add(1, Ordering::Relaxed); + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + let file_name = self + .path + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or("store.json"); + self.path.with_file_name(format!( + ".{file_name}.{}.{}.{}.tmp", + std::process::id(), + now, + suffix + )) + } +} + +pub fn validate_id(id: &str) -> Result<()> { + if id.is_empty() { + return Err(Error::EmptyId); + } + if id.len() > MAX_ID_LEN { + return Err(Error::IdTooLong { + id: id.to_string(), + max: MAX_ID_LEN, + }); + } + if id.starts_with('/') || id.starts_with('~') || id.contains("//") { + return Err(Error::UnsafeId(id.to_string())); + } + for component in id.split('/') { + if component.is_empty() || component == "." || component == ".." { + return Err(Error::UnsafeId(id.to_string())); + } + } + if !id + .bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-' | b'/')) + { + return Err(Error::UnsupportedIdChars(id.to_string())); + } + Ok(()) +} + +fn derive_key(data_dir: &Path) -> [u8; KEY_LEN] { + let mut hasher = Sha256::new(); + hasher.update(b"insomnia local secret store obfuscation key v1"); + hasher.update(data_dir.as_os_str().as_encoded_bytes()); + hasher.finalize().into() +} + +fn encrypt_entry(key: &[u8; KEY_LEN], id: &SecretId, plaintext: &[u8]) -> Entry { + let nonce = make_nonce(id.as_str(), plaintext); + let ciphertext = xor_stream(key, &nonce, plaintext); + let tag = tag(key, id.as_str(), &nonce, &ciphertext); + Entry { + nonce: hex_encode(&nonce), + ciphertext: hex_encode(&ciphertext), + tag: hex_encode(&tag), + } +} + +fn decrypt_entry(key: &[u8; KEY_LEN], id: &SecretId, entry: &Entry) -> Result> { + let nonce = hex_decode(&entry.nonce).map_err(|_| Error::Decode { id: id.to_string() })?; + let ciphertext = + hex_decode(&entry.ciphertext).map_err(|_| Error::Decode { id: id.to_string() })?; + let actual_tag = hex_decode(&entry.tag).map_err(|_| Error::Decode { id: id.to_string() })?; + let expected = tag(key, id.as_str(), &nonce, &ciphertext); + if actual_tag.as_slice() != expected { + return Err(Error::Decode { id: id.to_string() }); + } + Ok(xor_stream(key, &nonce, &ciphertext)) +} + +fn make_nonce(id: &str, plaintext: &[u8]) -> Vec { + let mut hasher = Sha256::new(); + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + hasher.update(b"insomnia nonce v1"); + hasher.update(now.to_le_bytes()); + hasher.update(std::process::id().to_le_bytes()); + hasher.update(NONCE_COUNTER.fetch_add(1, Ordering::Relaxed).to_le_bytes()); + hasher.update(id.as_bytes()); + hasher.update(plaintext); + hasher.finalize()[..16].to_vec() +} + +fn xor_stream(key: &[u8; KEY_LEN], nonce: &[u8], input: &[u8]) -> Vec { + let mut out = Vec::with_capacity(input.len()); + let mut counter = 0u64; + for chunk in input.chunks(KEY_LEN) { + let mut hasher = Sha256::new(); + hasher.update(b"insomnia secret keystream v1"); + hasher.update(key); + hasher.update(nonce); + hasher.update(counter.to_le_bytes()); + let block = hasher.finalize(); + for (b, k) in chunk.iter().zip(block.iter()) { + out.push(b ^ k); + } + counter += 1; + } + out +} + +fn tag(key: &[u8; KEY_LEN], id: &str, nonce: &[u8], ciphertext: &[u8]) -> [u8; TAG_LEN] { + let mut hasher = Sha256::new(); + hasher.update(b"insomnia secret tag v1"); + hasher.update(key); + hasher.update(id.as_bytes()); + hasher.update(nonce); + hasher.update(ciphertext); + hasher.finalize().into() +} + +fn hex_encode(bytes: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut out = String::with_capacity(bytes.len() * 2); + for b in bytes { + out.push(HEX[(b >> 4) as usize] as char); + out.push(HEX[(b & 0x0f) as usize] as char); + } + out +} + +fn hex_decode(s: &str) -> std::result::Result, ()> { + if !s.len().is_multiple_of(2) { + return Err(()); + } + let mut out = Vec::with_capacity(s.len() / 2); + let bytes = s.as_bytes(); + for pair in bytes.chunks_exact(2) { + let high = hex_value(pair[0])?; + let low = hex_value(pair[1])?; + out.push((high << 4) | low); + } + Ok(out) +} + +fn hex_value(b: u8) -> std::result::Result { + match b { + b'0'..=b'9' => Ok(b - b'0'), + b'a'..=b'f' => Ok(b - b'a' + 10), + b'A'..=b'F' => Ok(b - b'A' + 10), + _ => Err(()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_store() -> (tempfile::TempDir, SecretStore) { + let dir = tempfile::tempdir().unwrap(); + let store = + SecretStore::at_path_with_key(dir.path().join("secrets/store.json"), [7u8; KEY_LEN]); + (dir, store) + } + + #[test] + fn roundtrip_list_delete() { + let (_dir, store) = test_store(); + store + .set("anthropic/default", SecretValue::new("sk-test-secret")) + .unwrap(); + assert_eq!( + store.get("anthropic/default").unwrap().expose_secret(), + "sk-test-secret" + ); + assert_eq!(store.list_ids().unwrap()[0].as_str(), "anthropic/default"); + assert!(store.delete("anthropic/default").unwrap()); + assert!(matches!( + store.get("anthropic/default"), + Err(Error::NotFound { .. }) + )); + } + + #[test] + fn invalid_ids_are_rejected() { + for id in ["", "/abs", "../x", "x/../y", "x//y", "x y", "x\ny", "~home"] { + assert!(SecretId::parse(id).is_err(), "{id:?} should be invalid"); + } + assert!(SecretId::parse("a".repeat(MAX_ID_LEN + 1)).is_err()); + assert!(SecretId::parse("web/brave.default-1").is_ok()); + } + + #[test] + fn corrupted_store_fails_closed() { + let (dir, store) = test_store(); + store + .set("web/brave", SecretValue::new("secret-value")) + .unwrap(); + let path = dir.path().join("secrets/store.json"); + let mut file: StoreFile = + serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap(); + let entry = file.entries.get_mut("web/brave").unwrap(); + let replacement = if entry.ciphertext.starts_with('0') { + "1" + } else { + "0" + }; + entry.ciphertext.replace_range(0..1, replacement); + fs::write(&path, serde_json::to_string_pretty(&file).unwrap()).unwrap(); + assert!(matches!(store.get("web/brave"), Err(Error::Decode { id }) if id == "web/brave")); + } + + #[test] + fn plaintext_is_not_written_to_disk_or_debug() { + let (dir, store) = test_store(); + let value = SecretValue::new("sk-plain-must-not-appear"); + assert!(!format!("{value:?}").contains("sk-plain")); + store.set("provider/test", value).unwrap(); + let text = fs::read_to_string(dir.path().join("secrets/store.json")).unwrap(); + assert!(!text.contains("sk-plain-must-not-appear")); + assert!(text.contains("provider/test")); + } + + #[test] + fn wrong_key_or_tamper_fails_decode() { + let (dir, store) = test_store(); + store + .set("provider/test", SecretValue::new("secret-value")) + .unwrap(); + let wrong = + SecretStore::at_path_with_key(dir.path().join("secrets/store.json"), [9u8; KEY_LEN]); + assert!( + matches!(wrong.get("provider/test"), Err(Error::Decode { id }) if id == "provider/test") + ); + } +} diff --git a/crates/tools/Cargo.toml b/crates/tools/Cargo.toml index 702caeca..6331af67 100644 --- a/crates/tools/Cargo.toml +++ b/crates/tools/Cargo.toml @@ -14,6 +14,7 @@ ignore = "0.4.25" html5ever = "0.26" llm-worker = { workspace = true } manifest = { workspace = true } +secrets = { workspace = true } markup5ever_rcdom = "0.2" reqwest = { version = "0.13", default-features = false, features = ["json", "native-tls"] } schemars = { workspace = true } diff --git a/crates/tools/src/web.rs b/crates/tools/src/web.rs index d0304ed7..de97d71e 100644 --- a/crates/tools/src/web.rs +++ b/crates/tools/src/web.rs @@ -12,6 +12,7 @@ use markup5ever_rcdom::{Handle, NodeData, RcDom}; use reqwest::header::{CONTENT_LENGTH, CONTENT_TYPE, HeaderMap, LOCATION}; use reqwest::{Client, Url}; use schemars::JsonSchema; +use secrets::SecretStore; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use tokio::net::lookup_host; @@ -36,6 +37,7 @@ const WEB_FETCH_TRUNCATION_MARKER: &str = "\n[truncated]"; pub struct WebTools { config: Option, client: Client, + secret_store: Option, } impl WebTools { @@ -45,7 +47,25 @@ impl WebTools { .user_agent("insomnia-web-tools/0.1") .build() .expect("static reqwest client configuration is valid"); - Self { config, client } + let secret_store = manifest::paths::data_dir().map(SecretStore::new); + Self { + config, + client, + secret_store, + } + } + + #[cfg(test)] + fn with_client_and_secret_store( + config: Option, + client: Client, + secret_store: Option, + ) -> Self { + Self { + config, + client, + secret_store, + } } fn global_enabled(&self) -> bool { @@ -153,11 +173,19 @@ impl WebTools { match cfg.provider.ok_or_else(|| { disabled_error( "WebSearch", - "set web.search.provider = \"brave\" and web.search.api_key_env", + "set web.search.provider = \"brave\" and web.search.api_key_secret", ) })? { WebSearchProvider::Brave => { - brave_search(&self.client, cfg, &input.query, limit, offset).await + brave_search( + &self.client, + cfg, + self.secret_store.as_ref(), + &input.query, + limit, + offset, + ) + .await } } } @@ -213,28 +241,35 @@ pub fn web_fetch_tool(tools: WebTools) -> ToolDefinition { async fn brave_search( client: &Client, cfg: &WebSearchConfig, + secret_store: Option<&SecretStore>, query: &str, limit: usize, offset: usize, ) -> Result { - let api_key_env = cfg.api_key_env.as_ref().ok_or_else(|| { + let api_key_secret = cfg.api_key_secret.as_ref().ok_or_else(|| { disabled_error( "WebSearch", - "set web.search.api_key_env to an environment variable containing the Brave API key", + "set web.search.api_key_secret to the insomnia keys secret id for the Brave API key", ) })?; - let api_key = std::env::var(api_key_env).map_err(|_| { + let store = secret_store.ok_or_else(|| { + ToolError::ExecutionFailed( + "WebSearch provider is configured but the local secret store path is unavailable" + .into(), + ) + })?; + let api_key = store.get(api_key_secret).map_err(|err| { ToolError::ExecutionFailed(format!( - "WebSearch provider is configured but environment variable {api_key_env} is not set" + "WebSearch provider is configured but secret `{api_key_secret}` could not be resolved: {err}" )) })?; - if api_key.trim().is_empty() { + if api_key.expose_secret().trim().is_empty() { return Err(ToolError::ExecutionFailed(format!( - "WebSearch provider is configured but environment variable {api_key_env} is empty" + "WebSearch provider is configured but secret `{api_key_secret}` is empty" ))); } - brave_search_with_api_key(client, cfg, &api_key, query, limit, offset).await + brave_search_with_api_key(client, cfg, api_key.expose_secret(), query, limit, offset).await } async fn brave_search_with_api_key( @@ -1709,7 +1744,7 @@ mod tests { WebSearchConfig { enabled: Some(true), provider: Some(WebSearchProvider::Brave), - api_key_env: None, + api_key_secret: Some("web/brave/test".into()), timeout_secs: Some(2), base_url: Some(base_url), ..Default::default() @@ -2037,6 +2072,49 @@ mod tests { assert_eq!(value.get("redirects").unwrap().as_array().unwrap().len(), 1); } + #[tokio::test] + async fn searches_brave_with_secret_ref() { + let response = "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\r\n{\"web\":{\"results\":[{\"title\":\"Example\",\"url\":\"https://example.com\",\"description\":\"Snippet\"}]}}"; + let (addr, captured) = serve_once_capture(response).await; + let dir = tempfile::tempdir().unwrap(); + let store = SecretStore::at_path_for_tests(dir.path().join("secrets/store.json")); + store + .set( + "web/brave/test", + secrets::SecretValue::new("test-secret-ref"), + ) + .unwrap(); + let tools = WebTools::with_client_and_secret_store( + Some(WebConfig { + enabled: Some(true), + allow_private_addresses: Some(true), + search: Some(brave_search_config(format!("http://{addr}/search"))), + fetch: None, + }), + Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .build() + .unwrap(), + Some(store), + ); + let result = tools + .run_search(WebSearchInput { + query: "insomnia".into(), + limit: Some(1), + offset: Some(0), + }) + .await + .unwrap(); + let value: Value = serde_json::from_str(result.content.as_deref().unwrap()).unwrap(); + let request = captured.lock().await.clone().unwrap(); + assert!( + request + .to_ascii_lowercase() + .contains("x-subscription-token: test-secret-ref\r\n") + ); + assert_eq!(value["results"][0]["title"], "Example"); + } + #[tokio::test] async fn searches_brave_with_bounded_output() { let response = "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\r\n{\"web\":{\"results\":[{\"title\":\"Example\",\"url\":\"https://example.com\",\"description\":\"Snippet\",\"extra_snippets\":[\"Extra\"],\"language\":\"en\"}]}}"; diff --git a/crates/tui/Cargo.toml b/crates/tui/Cargo.toml index 54063634..28ee5c62 100644 --- a/crates/tui/Cargo.toml +++ b/crates/tui/Cargo.toml @@ -15,6 +15,7 @@ unicode-width = "0.2.2" uuid = { workspace = true } toml = { workspace = true } manifest = { workspace = true } +secrets = { workspace = true } session-store = { workspace = true } pod-store = { workspace = true } pod-registry = { workspace = true } diff --git a/crates/tui/src/keys.rs b/crates/tui/src/keys.rs new file mode 100644 index 00000000..1571d739 --- /dev/null +++ b/crates/tui/src/keys.rs @@ -0,0 +1,406 @@ +use std::io::{self, Stdout}; +use std::process::ExitCode; +use std::time::Duration; + +use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers}; +use crossterm::execute; +use crossterm::terminal::{ + EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode, +}; +use ratatui::Terminal; +use ratatui::backend::CrosstermBackend; +use ratatui::layout::{Constraint, Direction, Layout}; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap}; +use secrets::{SecretStore, SecretValue}; + +#[derive(Debug, Clone, PartialEq, Eq)] +enum Mode { + Normal, + EditingId, + EditingValue { id: String }, + ConfirmDelete { id: String }, +} + +#[derive(Clone)] +struct KeysApp { + ids: Vec, + selected: usize, + mode: Mode, + input: String, + notice: String, + quit: bool, +} + +impl std::fmt::Debug for KeysApp { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let input = match self.mode { + Mode::EditingValue { .. } => "[redacted]".to_string(), + _ => self.input.clone(), + }; + f.debug_struct("KeysApp") + .field("ids", &self.ids) + .field("selected", &self.selected) + .field("mode", &self.mode) + .field("input", &input) + .field("notice", &self.notice) + .field("quit", &self.quit) + .finish() + } +} + +impl KeysApp { + fn new(ids: Vec) -> Self { + let mut app = Self { + ids, + selected: 0, + mode: Mode::Normal, + input: String::new(), + notice: String::new(), + quit: false, + }; + app.clamp_selection(); + app + } + + fn refresh(&mut self, ids: Vec) { + self.ids = ids; + self.clamp_selection(); + } + + fn selected_id(&self) -> Option<&str> { + self.ids.get(self.selected).map(String::as_str) + } + + fn clamp_selection(&mut self) { + if self.ids.is_empty() { + self.selected = 0; + } else if self.selected >= self.ids.len() { + self.selected = self.ids.len() - 1; + } + } + + fn handle_key(&mut self, code: KeyCode, modifiers: KeyModifiers) -> Action { + if modifiers.contains(KeyModifiers::CONTROL) && code == KeyCode::Char('c') { + self.quit = true; + return Action::Quit; + } + match self.mode.clone() { + Mode::Normal => self.handle_normal(code), + Mode::EditingId => self.handle_editing_id(code), + Mode::EditingValue { id } => self.handle_editing_value(code, id), + Mode::ConfirmDelete { id } => self.handle_confirm_delete(code, id), + } + } + + fn handle_normal(&mut self, code: KeyCode) -> Action { + match code { + KeyCode::Char('q') | KeyCode::Esc => { + self.quit = true; + Action::Quit + } + KeyCode::Char('a') => { + self.input.clear(); + self.notice = "Enter secret id, then Enter".into(); + self.mode = Mode::EditingId; + Action::None + } + KeyCode::Char('d') => { + if let Some(id) = self.selected_id() { + self.mode = Mode::ConfirmDelete { id: id.to_string() }; + self.notice = "Delete selected secret? y/N".into(); + } else { + self.notice = "No key selected".into(); + } + Action::None + } + KeyCode::Down | KeyCode::Char('j') => { + if !self.ids.is_empty() { + self.selected = (self.selected + 1).min(self.ids.len() - 1); + } + Action::None + } + KeyCode::Up | KeyCode::Char('k') => { + self.selected = self.selected.saturating_sub(1); + Action::None + } + _ => Action::None, + } + } + + fn handle_editing_id(&mut self, code: KeyCode) -> Action { + match code { + KeyCode::Esc => { + self.mode = Mode::Normal; + self.input.clear(); + self.notice = "Add cancelled".into(); + Action::None + } + KeyCode::Enter => { + let id = self.input.trim().to_string(); + if id.is_empty() { + self.notice = "Secret id must not be empty".into(); + return Action::None; + } + self.input.clear(); + self.notice = "Enter secret value; input is masked".into(); + self.mode = Mode::EditingValue { id }; + Action::None + } + KeyCode::Backspace => { + self.input.pop(); + Action::None + } + KeyCode::Char(c) if !c.is_control() => { + self.input.push(c); + Action::None + } + _ => Action::None, + } + } + + fn handle_editing_value(&mut self, code: KeyCode, id: String) -> Action { + match code { + KeyCode::Esc => { + self.mode = Mode::Normal; + self.input.clear(); + self.notice = "Add cancelled".into(); + Action::None + } + KeyCode::Enter => { + let value = std::mem::take(&mut self.input); + self.mode = Mode::Normal; + Action::Set { id, value } + } + KeyCode::Backspace => { + self.input.pop(); + Action::None + } + KeyCode::Char(c) if !c.is_control() => { + self.input.push(c); + Action::None + } + _ => Action::None, + } + } + + fn handle_confirm_delete(&mut self, code: KeyCode, id: String) -> Action { + self.mode = Mode::Normal; + match code { + KeyCode::Char('y') | KeyCode::Char('Y') => Action::Delete { id }, + _ => { + self.notice = "Delete cancelled".into(); + Action::None + } + } + } +} + +#[derive(Clone, PartialEq, Eq)] +enum Action { + None, + Set { id: String, value: String }, + Delete { id: String }, + Quit, +} + +impl std::fmt::Debug for Action { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::None => f.write_str("None"), + Self::Set { id, .. } => f + .debug_struct("Set") + .field("id", id) + .field("value", &"[redacted]") + .finish(), + Self::Delete { id } => f.debug_struct("Delete").field("id", id).finish(), + Self::Quit => f.write_str("Quit"), + } + } +} + +pub async fn launch() -> ExitCode { + let data_dir = match manifest::paths::data_dir() { + Some(path) => path, + None => { + eprintln!("insomnia keys: could not determine insomnia data directory"); + return ExitCode::FAILURE; + } + }; + match run(SecretStore::new(data_dir)) { + Ok(()) => ExitCode::SUCCESS, + Err(err) => { + eprintln!("insomnia keys: {err}"); + ExitCode::FAILURE + } + } +} + +type UiResult = Result>; + +fn run(store: SecretStore) -> UiResult<()> { + enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen, crossterm::cursor::Hide)?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + let result = run_loop(&mut terminal, store); + let mut stdout = io::stdout(); + let _ = execute!(stdout, crossterm::cursor::Show, LeaveAlternateScreen); + let _ = disable_raw_mode(); + result +} + +fn run_loop(terminal: &mut Terminal>, store: SecretStore) -> UiResult<()> { + let mut app = KeysApp::new(load_ids(&store)?); + loop { + terminal.draw(|frame| draw(frame, &app))?; + if app.quit { + return Ok(()); + } + if event::poll(Duration::from_millis(200))? { + let Event::Key(key) = event::read()? else { + continue; + }; + if key.kind != KeyEventKind::Press { + continue; + } + match app.handle_key(key.code, key.modifiers) { + Action::None => {} + Action::Quit => return Ok(()), + Action::Set { id, value } => match store.set(&id, SecretValue::new(value)) { + Ok(()) => { + app.refresh(load_ids(&store)?); + app.notice = format!("Saved `{id}`"); + } + Err(err) => { + app.notice = format!("Save failed for `{id}`: {err}"); + } + }, + Action::Delete { id } => match store.delete(&id) { + Ok(true) => { + app.refresh(load_ids(&store)?); + app.notice = format!("Deleted `{id}`"); + } + Ok(false) => app.notice = format!("Secret `{id}` was already absent"), + Err(err) => app.notice = format!("Delete failed for `{id}`: {err}"), + }, + } + } + } +} + +fn load_ids(store: &SecretStore) -> UiResult> { + Ok(store + .list_ids()? + .into_iter() + .map(|id| id.as_str().to_string()) + .collect()) +} + +fn draw(frame: &mut ratatui::Frame<'_>, app: &KeysApp) { + let area = frame.area(); + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), + Constraint::Min(4), + Constraint::Length(5), + ]) + .split(area); + + let title = Paragraph::new("Local secret keys (values are never displayed)").block( + Block::default() + .borders(Borders::ALL) + .title("insomnia keys"), + ); + frame.render_widget(title, chunks[0]); + + let items: Vec> = if app.ids.is_empty() { + vec![ListItem::new(Line::from(Span::raw("No keys stored")))] + } else { + app.ids + .iter() + .map(|id| ListItem::new(Line::from(Span::raw(id.clone())))) + .collect() + }; + let list = List::new(items) + .block(Block::default().borders(Borders::ALL).title("Key ids")) + .highlight_style(Style::default().add_modifier(Modifier::REVERSED)); + let mut state = ListState::default(); + if !app.ids.is_empty() { + state.select(Some(app.selected)); + } + frame.render_stateful_widget(list, chunks[1], &mut state); + + let input_line = match &app.mode { + Mode::Normal => "a add/set d delete ↑/↓ select q quit".to_string(), + Mode::EditingId => format!("Secret id: {}", app.input), + Mode::EditingValue { id } => format!( + "Value for `{id}`: {}", + "•".repeat(app.input.chars().count()) + ), + Mode::ConfirmDelete { id } => format!("Delete `{id}`? y/N"), + }; + let help = Paragraph::new(vec![ + Line::from(input_line), + Line::from(app.notice.clone()), + Line::from("Protection is local obfuscation at rest, not a system keychain."), + ]) + .block(Block::default().borders(Borders::ALL).title("Actions")) + .wrap(Wrap { trim: true }); + frame.render_widget(Clear, chunks[2]); + frame.render_widget(help, chunks[2]); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn model_masks_value_and_emits_set_action() { + let mut app = KeysApp::new(vec![]); + assert_eq!( + app.handle_key(KeyCode::Char('a'), KeyModifiers::NONE), + Action::None + ); + for c in "web/brave/test".chars() { + app.handle_key(KeyCode::Char(c), KeyModifiers::NONE); + } + assert_eq!( + app.handle_key(KeyCode::Enter, KeyModifiers::NONE), + Action::None + ); + assert!(matches!(app.mode, Mode::EditingValue { .. })); + for c in "secret-value".chars() { + app.handle_key(KeyCode::Char(c), KeyModifiers::NONE); + } + assert_eq!(app.input, "secret-value"); + let action = app.handle_key(KeyCode::Enter, KeyModifiers::NONE); + assert_eq!( + action, + Action::Set { + id: "web/brave/test".into(), + value: "secret-value".into() + } + ); + assert!(!format!("{app:?}").contains("secret-value")); + } + + #[test] + fn model_confirms_delete() { + let mut app = KeysApp::new(vec!["providers/anthropic/default".into()]); + assert_eq!( + app.handle_key(KeyCode::Char('d'), KeyModifiers::NONE), + Action::None + ); + let action = app.handle_key(KeyCode::Char('y'), KeyModifiers::NONE); + assert_eq!( + action, + Action::Delete { + id: "providers/anthropic/default".into() + } + ); + } +} diff --git a/crates/tui/src/lib.rs b/crates/tui/src/lib.rs index 1aa1eff7..a126ce2f 100644 --- a/crates/tui/src/lib.rs +++ b/crates/tui/src/lib.rs @@ -3,6 +3,7 @@ mod block; mod cache; mod command; mod input; +pub mod keys; mod markdown; mod multi_pod; mod picker; diff --git a/docs/architecture.md b/docs/architecture.md index 9a916f33..0ec958fb 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -147,6 +147,6 @@ Pod が操作できるファイルパスの制御。 | Task | `TaskCreate`, `TaskUpdate`, `TaskList`, `TaskGet` | セッション内の短期 task 状態管理 | | Memory / Knowledge | `MemoryQuery`, `MemoryRead`, `MemoryWrite`, `MemoryEdit`, `MemoryDelete`, `KnowledgeQuery` | manifest の memory 設定が有効な時に登録される durable memory / knowledge 操作 | | Pod orchestration | `SpawnPod`, `SendToPod`, `ReadPodOutput`, `StopPod`, `ListPods`, `RestorePod` | child / visible Pod の起動・通信・停止・一覧・復元 | -| Web | `WebSearch`, `WebFetch` | manifest/env で明示設定された provider 経由の bounded web access | +| Web | `WebSearch`, `WebFetch` | manifest で明示設定された provider と local secret-store reference 経由の bounded web access | すべての tool call は manifest tool permission と scope/policy のチェックを通る。ファイル write scope、Pod delegation、memory layout、web provider 設定はそれぞれ別の authority を持ち、UI 表示だけで権限を広げない。 diff --git a/docs/environment.md b/docs/environment.md index 592ffded..71102afd 100644 --- a/docs/environment.md +++ b/docs/environment.md @@ -40,21 +40,32 @@ Builtin profiles and catalogs are embedded in the binary at build time. User/pro ## Credential と外部 auth -Provider credential は、現在は manifest / profile / catalog の設定から env var 名を明示的に参照できる。ただし、これは移行互換のための現状であり、長期的な supported configuration path ではない。`manifest-profile-encrypted-secrets` で encrypted secret store と typed secret reference を導入し、credential env var 依存は削除する方針である。 +Provider API key と WebSearch credential は、通常の runtime では環境変数から読まない。`insomnia keys` で local secret store に論理 id を追加し、profile / manifest / provider catalog / web config がその id を明示的に参照する。 -これは「ambient な provider 自動発見」ではなく、設定で選んだ環境変数名を読む仕組みである。通常 runtime が `.env` を暗黙に load することもない。 +```toml +[[models]] +id = "anthropic/claude-sonnet-4" +scheme = "anthropic" +model = "claude-sonnet-4-20250514" +auth = { kind = "secret_ref", ref = "providers/anthropic/default" } + +[web] +enabled = true + +[web.search] +provider = "brave" +api_key_secret = "web/brave/default" +``` + +Store の user-visible schema は `id -> value` だけであり、store は provider 名や kind を解釈しない。自動的な provider-name-to-secret-id lookup は行わず、設定が secret id を選ぶ。 + +On-disk store は `/secrets/store.json`。secret value は軽量な obfuscation と integrity check で plaintext config / log / terminal output に出にくくするが、OS keychain や passphrase vault ではない。data directory と source code を読める local user に対する強い保護は主張しない。 | 変数 / pattern | 用途 | 備考 | | --- | --- | --- | -| `INSOMNIA_API_KEY_ANTHROPIC` | custom env が指定されていない場合の Anthropic API key の default env 名。 | provider auth resolution で使う。 | -| `INSOMNIA_API_KEY_OPENAI` | OpenAI / OpenAI Responses API key の default env 名。 | provider auth resolution で使う。 | -| `INSOMNIA_API_KEY_GEMINI` | Gemini API key の default env 名。 | provider auth resolution で使う。 | -| `INSOMNIA_API_KEY_OPENROUTER` | builtin OpenRouter provider の auth hint。 | bundled provider catalog 由来。 | -| custom `model.auth.env` value | manifest / profile ごとの API key env 名。 | 明示的な config が変数名を選ぶ。`auth.env` と `auth.file` が両方ある場合は env が優先される。 | -| `BRAVE_SEARCH_API_KEY` または custom `web.search.api_key_env` | Brave WebSearch key。 | WebSearch は configured env 名だけを読み、missing / empty の場合は fail closed する。 | -| `CODEX_HOME` | Codex OAuth `auth.json` の場所。 | 外部互換用の入力。fallback は `$HOME/.codex`。 | +| `CODEX_HOME` | Codex OAuth `auth.json` の場所。 | 外部互換用の入力。fallback は `$HOME/.codex`。Codex OAuth は local secret store とは別の structured integration のまま維持する。 | -Credential env var は interoperability のために現時点では残っているが、長期的に望ましい secret mechanism ではない。現時点では適切なら `auth.file` を優先し、今後は typed secret reference へ寄せる。credential UX のために implicit `.env` loading を追加しないこと。project secret を漏らしやすく、profile ごとの credential model とも相性が悪い。 +通常 runtime が `.env` を暗黙に load することはない。credential UX のために implicit `.env` loading を追加しないこと。 ## Development-only escape hatches @@ -83,7 +94,7 @@ Credential env var は interoperability のために現時点では残ってい 1. fallback / precedence の test は、process environment を読ませず、直接入力を渡せる小さな pure helper で検証する。 2. path resolution は `manifest::paths` に集約し、path precedence rule を別の場所で重複実装しない。 3. test が process environment を変更するのは、process env から読む thin wrapper 自体を検証する場合や、subprocess isolation に必要な場合に限る。 -4. credential source は resolved config 上で明示し、process-env convention を増やすより typed secret reference へ寄せる。encrypted secret store 導入時に credential env var 依存を削除する。 +4. credential source は resolved config 上で明示し、process-env convention を増やすより typed secret reference を使う。 5. fallback env は独立した設定項目として増やさず、対応する main key の解決順として文書化する。 6. 空の env value は、変数 category に応じて unset / invalid のどちらとして扱うかを一貫させ、新しい supported variable を追加する場合は挙動を文書化する。 7. 外部 process integration が env inheritance / filtering を必要とする場合は、ambient な inherited process state に頼らず、明示的な policy boundary として設計する。 diff --git a/docs/manifest-profiles.md b/docs/manifest-profiles.md index 40c64662..eef35bd3 100644 --- a/docs/manifest-profiles.md +++ b/docs/manifest-profiles.md @@ -2,7 +2,7 @@ Profiles are reusable Lua-authored recipes for generating an Insomnia runtime manifest. The Rust resolver evaluates a selected `.lua` profile in-process, validates that it is Profile-shaped rather than a complete Manifest, then binds runtime values such as Pod name and concrete scope to produce the persisted `PodManifest` snapshot. -Profiles are intentionally not authority-bearing manifests. `pod.name`, concrete `scope.allow` / `scope.deny`, runtime directories, sockets, active session state, and raw secret material do not belong in reusable profiles. Use `--manifest` when you need the explicit low-level complete Manifest escape hatch. +Profiles are intentionally not authority-bearing manifests. `pod.name`, concrete `scope.allow` / `scope.deny`, runtime directories, sockets, active session state, and raw secret material do not belong in reusable profiles. Use `insomnia keys` to store provider/WebSearch credentials, then reference explicit secret ids such as `auth = { kind = "secret_ref", ref = "providers/anthropic/default" }` or `web.search.api_key_secret = "web/brave/default"`. Use `--manifest` when you need the explicit low-level complete Manifest escape hatch. ## Minimal profile diff --git a/docs/manifest.toml b/docs/manifest.toml index 5f6de3ab..f94cb580 100644 --- a/docs/manifest.toml +++ b/docs/manifest.toml @@ -63,18 +63,16 @@ ref = "anthropic/claude-sonnet-4-6" # base_url = "https://api.anthropic.com" # 任意 (ref 未指定時は実質必須)。デフォルト: なし。 -# kind の値: "none" | "api_key" | "codex_oauth" +# kind の値: "none" | "secret_ref" | "api_key" | "codex_oauth" # - "none" … 認証不要 (ローカル Ollama 等) -# - "api_key" … env / file のいずれかで key を渡す。両方指定なら env 優先。 -# env 未指定時は scheme ごとの既定環境変数: -# Anthropic -> INSOMNIA_API_KEY_ANTHROPIC -# OpenaiChat / OpenaiResponses -> INSOMNIA_API_KEY_OPENAI -# Gemini -> INSOMNIA_API_KEY_GEMINI -# file 指定時、相対パスは manifest base 起点で解決。 +# - "secret_ref" … `insomnia keys` の local secret store から key を読む。 +# `ref` はユーザー設定が明示的に選ぶ論理 secret id。 +# store は id -> value のみを持ち、provider 種別を解釈しない。 +# - "api_key" … 明示ファイルから key を読む低レベル形式。通常は +# `secret_ref` を使う。file 指定時、相対パスは manifest base 起点で解決。 # - "codex_oauth" … ChatGPT OAuth (`~/.codex/auth.json`)。追加フィールドなし。 # auth = { kind = "none" } -# auth = { kind = "api_key" } # env のみ既定使用 -# auth = { kind = "api_key", env = "MY_ANTHROPIC_KEY" } +# auth = { kind = "secret_ref", ref = "providers/anthropic/default" } # auth = { kind = "api_key", file = "./sk-ant.local" } # auth = { kind = "codex_oauth" } diff --git a/resources/profiles/default.lua b/resources/profiles/default.lua index 04dfee62..e5ed5b56 100644 --- a/resources/profiles/default.lua +++ b/resources/profiles/default.lua @@ -36,7 +36,7 @@ return profile { enabled = true, search = { provider = "brave", - api_key_env = "BRAVE_SEARCH_API_KEY", + api_key_secret = "web/brave/default", }, }, } diff --git a/resources/providers/builtin.toml b/resources/providers/builtin.toml index b79811bb..03682c6c 100644 --- a/resources/providers/builtin.toml +++ b/resources/providers/builtin.toml @@ -3,7 +3,7 @@ id = "anthropic" display_name = "Anthropic" scheme = "anthropic" base_url = "https://api.anthropic.com" -auth_hint = { kind = "api_key", env = "INSOMNIA_API_KEY_ANTHROPIC" } +auth_hint = { kind = "secret_ref", ref = "providers/anthropic/default" } default_capability = { tool_calling = "parallel", structured_output = "json_schema", reasoning = "budget_tokens", vision = true, prompt_caching = { kind = "explicit", max_breakpoints = 4 } } default_context_window = 200000 @@ -29,6 +29,6 @@ id = "openrouter" display_name = "OpenRouter" scheme = "openai_chat" base_url = "https://openrouter.ai/api/v1" -auth_hint = { kind = "api_key", env = "INSOMNIA_API_KEY_OPENROUTER" } +auth_hint = { kind = "secret_ref", ref = "providers/openrouter/default" } default_capability = { tool_calling = "parallel", structured_output = "json_schema", vision = true, prompt_caching = { kind = "auto" } } default_context_window = 200000