Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions crates/git-credential-nostr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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` |
Expand Down
26 changes: 22 additions & 4 deletions crates/git-credential-nostr/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> {
std::env::var(name).ok().filter(|v| !v.is_empty())
}

fn load_key() -> Result<String, String> {
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}"))?;
Expand Down
84 changes: 84 additions & 0 deletions crates/git-credential-nostr/tests/integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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}"
);
}
2 changes: 1 addition & 1 deletion docs/remote-agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down