diff --git a/crates/git-credential-nostr/README.md b/crates/git-credential-nostr/README.md index 827f8d55a3..cf56a56b27 100644 --- a/crates/git-credential-nostr/README.md +++ b/crates/git-credential-nostr/README.md @@ -30,14 +30,19 @@ That's it. Use git normally — `git clone`, `git push`, `git fetch`. ## CI / CD -Set `$NOSTR_PRIVATE_KEY` instead of a key file. The env var takes precedence -over `nostr.keyfile` and avoids touching the filesystem: +Set `$NOSTR_PRIVATE_KEY` or `$BUZZ_PRIVATE_KEY` instead of a key file. The +env vars take precedence over `nostr.keyfile` and avoid touching the +filesystem: ```bash export NOSTR_PRIVATE_KEY=nsec1... git clone https://relay.example.com/git/owner/repo.git ``` +`$BUZZ_PRIVATE_KEY` uses the same name as the rest of the Buzz ecosystem +(`buzz-cli`, the ACP/MCP shim, and `buzz-admin`). `$NOSTR_PRIVATE_KEY` is +still accepted for backward compatibility and wins when both are set. + ## How It Works When a Buzz git server returns `HTTP 401` with a @@ -61,7 +66,7 @@ git ──stdin──▶ git-credential-nostr ──stdout──▶ git | Error | Cause | Fix | |-------|-------|-----| -| `no nostr key configured` | Neither `$NOSTR_PRIVATE_KEY` nor `nostr.keyfile` is set | Follow the Setup steps above | +| `no nostr key configured` | None of `$NOSTR_PRIVATE_KEY`, `$BUZZ_PRIVATE_KEY`, or `nostr.keyfile` is set | Follow the Setup steps above | | `insecure permissions` | Key file is readable by group/others | `chmod 600 ~/.nostr/key` | | `method hint` | Server's `WWW-Authenticate` header is missing `method="..."` | Upgrade the Buzz server | | `useHttpPath` | `credential.useHttpPath` is not set | `git config --global credential.useHttpPath true` | diff --git a/crates/git-credential-nostr/src/lib.rs b/crates/git-credential-nostr/src/lib.rs index b51443600d..507b311e20 100644 --- a/crates/git-credential-nostr/src/lib.rs +++ b/crates/git-credential-nostr/src/lib.rs @@ -47,14 +47,32 @@ fn check_keyfile_permissions(path: &str) -> Result<(), String> { /// Max keyfile size — nsec1 is 63 bytes; hex keys are 64 bytes. 256 is generous. const MAX_KEYFILE_BYTES: u64 = 256; +fn non_empty_env(name: &str) -> Option { + std::env::var(name).ok().filter(|v| !v.is_empty()) +} + fn load_key() -> Result { - if let Ok(val) = std::env::var("NOSTR_PRIVATE_KEY") { - if !val.is_empty() { - return Ok(val); + let nostr = non_empty_env("NOSTR_PRIVATE_KEY"); + let buzz = non_empty_env("BUZZ_PRIVATE_KEY"); + + // If both identity env vars are set to different values, the older + // $NOSTR_PRIVATE_KEY wins for backward compatibility. Warn the caller so + // the silent wrong-identity trap described in the issue is at least audible. + if let (Some(n), Some(b)) = (&nostr, &buzz) { + if n != b { + eprintln!("warning: $BUZZ_PRIVATE_KEY is set but will not be used because $NOSTR_PRIVATE_KEY is also set to a different value"); } } + + if let Some(n) = nostr { + return Ok(n); + } + if let Some(b) = buzz { + return Ok(b); + } + let path = git_config("nostr.keyfile").ok_or_else(|| { - "no nostr key configured. Set $NOSTR_PRIVATE_KEY or git config nostr.keyfile".to_string() + "no nostr key configured. Set $NOSTR_PRIVATE_KEY, $BUZZ_PRIVATE_KEY, or git config nostr.keyfile".to_string() })?; check_keyfile_permissions(&path)?; let meta = std::fs::metadata(&path).map_err(|e| format!("cannot stat keyfile {path}: {e}"))?; diff --git a/crates/git-credential-nostr/tests/integration.rs b/crates/git-credential-nostr/tests/integration.rs index 6697b45f39..7ecc76f35e 100644 --- a/crates/git-credential-nostr/tests/integration.rs +++ b/crates/git-credential-nostr/tests/integration.rs @@ -20,6 +20,7 @@ fn run_helper(input: &str, env_vars: &[(&str, &str)]) -> std::process::Output { .stderr(Stdio::piped()) .current_dir(std::env::temp_dir()) .env_remove("NOSTR_PRIVATE_KEY") + .env_remove("BUZZ_PRIVATE_KEY") .env_remove("BUZZ_AUTH_TAG") .env_remove("GIT_CONFIG_COUNT") // Prevent git config on the test machine from supplying credentials. @@ -354,3 +355,86 @@ fn bad_keyfile_permissions() { "expected 'insecure permissions' in stderr, got:\n{stderr}" ); } + +/// The helper must accept `BUZZ_PRIVATE_KEY` as a fallback when `NOSTR_PRIVATE_KEY` +/// is not set, so users who export the ecosystem-standard env get the right +/// identity (regression for #4712). +#[test] +fn buzz_private_key_only() { + let nsec = fresh_nsec(); + let keys = Keys::parse(&nsec).expect("valid nsec"); + let out = run_helper(&valid_input(), &[("BUZZ_PRIVATE_KEY", &nsec)]); + + assert!( + out.status.success(), + "expected exit 0, got {:?}\nstderr: {}", + out.status.code(), + String::from_utf8_lossy(&out.stderr) + ); + + let stdout = String::from_utf8_lossy(&out.stdout); + let credential = stdout + .lines() + .find_map(|line| line.strip_prefix("credential=")) + .expect("credential output"); + let event_json = base64::engine::general_purpose::STANDARD + .decode(credential) + .expect("base64 credential"); + let event: nostr::Event = serde_json::from_slice(&event_json).expect("NIP-98 event"); + + assert!(event.verify().is_ok(), "event must verify"); + assert_eq!( + event.pubkey.to_hex(), + keys.public_key().to_hex(), + "helper must sign with the BUZZ_PRIVATE_KEY identity" + ); +} + +/// When both identity env vars are set to different values, `NOSTR_PRIVATE_KEY` +/// wins for backward compatibility and the helper warns that `BUZZ_PRIVATE_KEY` +/// is being ignored. +#[test] +fn both_envs_set_and_different_warns() { + let nostr_keys = Keys::generate(); + let buzz_keys = Keys::generate(); + let nostr_nsec = nostr_keys.secret_key().to_bech32().unwrap(); + let buzz_nsec = buzz_keys.secret_key().to_bech32().unwrap(); + + let out = run_helper( + &valid_input(), + &[ + ("NOSTR_PRIVATE_KEY", &nostr_nsec), + ("BUZZ_PRIVATE_KEY", &buzz_nsec), + ], + ); + + assert!( + out.status.success(), + "expected exit 0, got {:?}\nstderr: {}", + out.status.code(), + String::from_utf8_lossy(&out.stderr) + ); + + let stdout = String::from_utf8_lossy(&out.stdout); + let credential = stdout + .lines() + .find_map(|line| line.strip_prefix("credential=")) + .expect("credential output"); + let event_json = base64::engine::general_purpose::STANDARD + .decode(credential) + .expect("base64 credential"); + let event: nostr::Event = serde_json::from_slice(&event_json).expect("NIP-98 event"); + + assert!(event.verify().is_ok(), "event must verify"); + assert_eq!( + event.pubkey.to_hex(), + nostr_keys.public_key().to_hex(), + "NOSTR_PRIVATE_KEY must take precedence" + ); + + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("warning:"), + "expected a warning on stderr when BUZZ_PRIVATE_KEY is set but unused, got:\n{stderr}" + ); +} diff --git a/docs/remote-agents.md b/docs/remote-agents.md index 48ce85f5e5..893cc81cc5 100644 --- a/docs/remote-agents.md +++ b/docs/remote-agents.md @@ -1075,7 +1075,7 @@ individually: | source | env var | |---|---| | `relay_url` | `BUZZ_RELAY_URL` | -| `private_key_nsec` | `BUZZ_PRIVATE_KEY` and `NOSTR_PRIVATE_KEY` (the git helpers read the latter) | +| `private_key_nsec` | `BUZZ_PRIVATE_KEY` and `NOSTR_PRIVATE_KEY` (git helpers prefer `NOSTR_PRIVATE_KEY`, then fall back to `BUZZ_PRIVATE_KEY`) | | `auth_tag` | `BUZZ_AUTH_TAG` (omitted when null; then `launch.owner_pubkey` → `BUZZ_ACP_AGENT_OWNER` is REQUIRED — §Launch data owner rule) | | `launch.command` | `BUZZ_ACP_AGENT_COMMAND` — the *name*, resolved against the image's own PATH; never a forwarded host path | | `launch.args` | `BUZZ_ACP_AGENT_ARGS`, comma-joined |