diff --git a/Cargo.lock b/Cargo.lock index b5b2605a4..e7195fb0c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -965,10 +965,12 @@ dependencies = [ "base64", "buzz-cli", "buzz-core", + "buzz-sdk", "git-credential-nostr", "git-sign-nostr", "ignore", "image", + "keyring", "nix 0.31.3", "nostr", "reqwest 0.13.4", diff --git a/crates/buzz-dev-mcp/Cargo.toml b/crates/buzz-dev-mcp/Cargo.toml index 8b711b806..0f5381bdc 100644 --- a/crates/buzz-dev-mcp/Cargo.toml +++ b/crates/buzz-dev-mcp/Cargo.toml @@ -15,6 +15,7 @@ path = "src/main.rs" [dependencies] buzz-cli = { path = "../buzz-cli" } +buzz-sdk = { workspace = true } git-credential-nostr = { path = "../git-credential-nostr" } git-sign-nostr = { path = "../git-sign-nostr" } nostr = { workspace = true } @@ -43,6 +44,9 @@ buzz-core = { workspace = true } [target.'cfg(unix)'.dependencies] nix = { version = "0.31", default-features = false, features = ["signal", "process"] } +[target.'cfg(target_os = "macos")'.dependencies] +keyring = { version = "3.6.3", default-features = false, features = ["apple-native", "vendored"] } + # Windows Job Object APIs for the shell tool's timeout kill path: terminating a # job kills the bash child AND every MSYS grandchild it forked, the Windows # analogue of the Unix killpg above. windows-sys 0.61 is already workspace- diff --git a/crates/buzz-dev-mcp/src/lib.rs b/crates/buzz-dev-mcp/src/lib.rs index cc4725468..8a11b8eaa 100644 --- a/crates/buzz-dev-mcp/src/lib.rs +++ b/crates/buzz-dev-mcp/src/lib.rs @@ -11,6 +11,7 @@ use std::path::Path; use std::sync::Arc; mod paths; +mod provision_agent; mod read_file; mod rg; mod shell; @@ -150,6 +151,12 @@ pub fn run() -> Result<(), Box> { "tree" => std::process::exit(tree::run(std::env::args().skip(1).collect())), "git-credential-nostr" => std::process::exit(git_credential_nostr::run()), "git-sign-nostr" => std::process::exit(git_sign_nostr::run()), + "buzz-provision-agent" => { + std::process::exit(provision_agent::run(std::env::args().skip(1))) + } + "buzz-dev-mcp" if std::env::args().nth(1).as_deref() == Some("provision-agent") => { + std::process::exit(provision_agent::run(std::env::args().skip(2))) + } _ => {} } diff --git a/crates/buzz-dev-mcp/src/provision_agent.rs b/crates/buzz-dev-mcp/src/provision_agent.rs new file mode 100644 index 000000000..01e381041 --- /dev/null +++ b/crates/buzz-dev-mcp/src/provision_agent.rs @@ -0,0 +1,162 @@ +//! Local-only Buzz agent identity and NIP-OA provisioning. +//! +//! The owner key comes from Buzz Desktop's macOS Keychain item. It is never +//! accepted through argv or the environment. Only the generated agent key and +//! signed auth tag are printed. + +use std::io::{BufRead, Write}; + +use nostr::{Keys, ToBech32}; +#[cfg(any(target_os = "macos", test))] +use serde::Deserialize; +use zeroize::Zeroize; + +#[cfg(target_os = "macos")] +const KEYCHAIN_SERVICE: &str = "buzz-desktop"; +#[cfg(target_os = "macos")] +const KEYCHAIN_ACCOUNT: &str = "secrets"; + +#[cfg(any(target_os = "macos", test))] +#[derive(Deserialize)] +struct KeychainBlob { + identity: String, +} + +pub fn run(args: I) -> i32 +where + I: IntoIterator, +{ + match run_inner(args, std::io::stdin().lock(), &mut std::io::stdout()) { + Ok(()) => 0, + Err(error) => { + eprintln!("{}", serde_json::json!({"error": error})); + 1 + } + } +} + +fn run_inner(args: I, mut input: R, output: &mut W) -> Result<(), String> +where + I: IntoIterator, + R: BufRead, + W: Write, +{ + let mut agent_from_stdin = false; + for arg in args { + match arg.as_str() { + "--agent-private-key-stdin" if !agent_from_stdin => agent_from_stdin = true, + _ => return Err(format!("unknown or duplicate argument: {arg}")), + } + } + + let owner = load_owner_keys()?; + let agent = if agent_from_stdin { + let mut value = String::new(); + input + .read_line(&mut value) + .map_err(|error| format!("failed to read agent key from stdin: {error}"))?; + let agent = Keys::parse(value.trim()) + .map_err(|error| format!("invalid agent key from stdin: {error}")); + value.zeroize(); + agent? + } else { + Keys::generate() + }; + + write_provisioned_identity(&owner, &agent, output) +} + +#[cfg(target_os = "macos")] +fn load_owner_keys() -> Result { + let entry = keyring::Entry::new(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT) + .map_err(|error| format!("failed to open Buzz Desktop Keychain entry: {error}"))?; + let mut raw = entry + .get_password() + .map_err(|error| format!("failed to read Buzz Desktop Keychain entry: {error}"))?; + let blob: Result = serde_json::from_str(&raw); + raw.zeroize(); + let mut blob = blob.map_err(|error| format!("invalid Buzz Desktop Keychain blob: {error}"))?; + let owner = Keys::parse(blob.identity.trim()) + .map_err(|error| format!("invalid owner key in Buzz Desktop Keychain: {error}")); + blob.identity.zeroize(); + owner +} + +#[cfg(not(target_os = "macos"))] +fn load_owner_keys() -> Result { + Err("Buzz Desktop Keychain provisioning is supported only on macOS".to_string()) +} + +fn write_provisioned_identity( + owner: &Keys, + agent: &Keys, + output: &mut W, +) -> Result<(), String> { + let auth_tag = buzz_sdk::nip_oa::compute_auth_tag(owner, &agent.public_key(), "") + .map_err(|error| format!("failed to compute owner auth tag: {error}"))?; + let agent_nsec = agent + .secret_key() + .to_bech32() + .map_err(|error| format!("failed to encode agent private key: {error}"))?; + + serde_json::to_writer( + &mut *output, + &serde_json::json!({ + "agent_private_key_nsec": agent_nsec, + "agent_pubkey": agent.public_key().to_hex(), + "owner_pubkey": owner.public_key().to_hex(), + "auth_tag": auth_tag, + }), + ) + .map_err(|error| format!("failed to serialize provisioned identity: {error}"))?; + output + .write_all(b"\n") + .map_err(|error| format!("failed to write provisioned identity: {error}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn owner() -> Keys { + Keys::parse("0000000000000000000000000000000000000000000000000000000000000001").unwrap() + } + + #[test] + fn provisioned_identity_has_verifiable_owner_tag() { + let owner = owner(); + let agent = Keys::generate(); + let owner_secret = owner.secret_key().to_secret_hex(); + let mut output = Vec::new(); + + write_provisioned_identity(&owner, &agent, &mut output).unwrap(); + + let value: serde_json::Value = serde_json::from_slice(&output).unwrap(); + let agent_nsec = value["agent_private_key_nsec"].as_str().unwrap(); + let parsed_agent = Keys::parse(agent_nsec).unwrap(); + let auth_tag = value["auth_tag"].as_str().unwrap(); + let verified_owner = + buzz_sdk::nip_oa::verify_auth_tag(auth_tag, &parsed_agent.public_key()) + .expect("generated auth tag must verify"); + + assert_eq!(parsed_agent.public_key(), agent.public_key()); + assert_eq!(value["owner_pubkey"], owner.public_key().to_hex()); + assert_eq!(verified_owner, owner.public_key()); + assert!(!String::from_utf8(output).unwrap().contains(&owner_secret)); + } + + #[test] + fn existing_agent_stdin_parser_rejects_unknown_arguments_before_keychain_read() { + let mut output = Vec::new(); + let error = run_inner(["--wrong".to_string()], "".as_bytes(), &mut output).unwrap_err(); + assert_eq!(error, "unknown or duplicate argument: --wrong"); + assert!(output.is_empty()); + } + + #[test] + fn keychain_blob_ignores_other_secret_entries() { + let raw = r#"{"identity":"owner","agent:abc":"must-not-be-retained"}"#; + let blob: KeychainBlob = serde_json::from_str(raw).unwrap(); + assert_eq!(blob.identity, "owner"); + } +} diff --git a/crates/buzz-dev-mcp/src/shim.rs b/crates/buzz-dev-mcp/src/shim.rs index 36a07a18c..d100ced38 100644 --- a/crates/buzz-dev-mcp/src/shim.rs +++ b/crates/buzz-dev-mcp/src/shim.rs @@ -35,6 +35,7 @@ impl Shim { "buzz", "git-credential-nostr", "git-sign-nostr", + "buzz-provision-agent", ] { symlink(&self_exe, &dir.path().join(name))?; } diff --git a/crates/sprig/src/main.rs b/crates/sprig/src/main.rs index 672a5a5f3..8cda51b6e 100644 --- a/crates/sprig/src/main.rs +++ b/crates/sprig/src/main.rs @@ -37,7 +37,7 @@ fn dispatch() -> Result<(), String> { } }, // buzz-dev-mcp also handles its own multicall names: rg, tree, - // buzz, git-credential-nostr, and git-sign-nostr. + // buzz, git-credential-nostr, git-sign-nostr, and buzz-provision-agent. _ => buzz_dev_mcp::run().map_err(|e| e.to_string()), } } @@ -47,7 +47,7 @@ fn print_usage() { "Sprig — all-in-one Buzz ACP harness, agent, and developer MCP\n\n\ Sprig is a multicall binary. Invoke it through one of the personality names:\n\n\ buzz-acp ACP harness\n buzz-agent ACP-compliant agent\n buzz-dev-mcp Developer MCP server\n\n\ -Developer MCP helper names are also supported: rg, tree, buzz, git-credential-nostr, git-sign-nostr.\n\n\ +Developer MCP helper names are also supported: rg, tree, buzz, git-credential-nostr, git-sign-nostr, buzz-provision-agent.\n\n\ Installers can create links with:\n ln -s sprig buzz-acp\n ln -s sprig buzz-agent\n ln -s sprig buzz-dev-mcp" ); } diff --git a/scripts/build-sprig.sh b/scripts/build-sprig.sh index 77ee6e583..88b4dfd52 100755 --- a/scripts/build-sprig.sh +++ b/scripts/build-sprig.sh @@ -6,7 +6,8 @@ # buzz-acp link to sprig (ACP harness) # buzz-agent link to sprig (ACP-compliant agent) # buzz-dev-mcp link to sprig (developer MCP server; also dispatches -# rg/tree/buzz/git-credential-nostr/git-sign-nostr) +# rg/tree/buzz/git-credential-nostr/git-sign-nostr/ +# buzz-provision-agent) # # Usage: # ./scripts/build-sprig.sh [version] [target] @@ -145,7 +146,7 @@ Commands: - `buzz-agent` — ACP-compliant agent (spawns MCP servers, calls LLMs). - `buzz-dev-mcp` — Developer MCP server (shell, str_replace, todo) and multicall entrypoint for `rg`, `tree`, `buzz`, `git-credential-nostr`, - `git-sign-nostr`. + `git-sign-nostr`, and `buzz-provision-agent`. See `sprig.json` for SHA-256s, sizes, target, and source git SHA.