diff --git a/crates/buzz-relay/src/api/git/transport.rs b/crates/buzz-relay/src/api/git/transport.rs index 3e147394fc..7bc28c2257 100644 --- a/crates/buzz-relay/src/api/git/transport.rs +++ b/crates/buzz-relay/src/api/git/transport.rs @@ -185,11 +185,19 @@ impl axum::extract::FromRequestParts> for GitAuth { // The ±60s timestamp window + URL scoping + HTTPS transport provide sufficient // replay protection for v1. Per-request signing requires protocol changes. - // Relay membership gate (NIP-43). - let auth_tag = parts + let event: nostr::Event = serde_json::from_str(&event_json) + .map_err(|_| (StatusCode::UNAUTHORIZED, "invalid auth event").into_response())?; + + // Relay membership gate (NIP-43). Git cannot carry a standalone + // x-auth-tag header through the credential-helper protocol, so agents + // attach their NIP-OA attestation to the signed NIP-98 event, matching + // the WebSocket NIP-42 flow. + let event_auth_tag = crate::handlers::auth::extract_auth_tag_json(&event); + let header_auth_tag = parts .headers .get("x-auth-tag") - .and_then(|v| v.to_str().ok()); + .and_then(|value| value.to_str().ok()); + let auth_tag = event_auth_tag.as_deref().or(header_auth_tag); if crate::api::relay_members::enforce_relay_membership( state, tenant.community(), diff --git a/crates/buzz-relay/src/handlers/auth.rs b/crates/buzz-relay/src/handlers/auth.rs index 7f419d843d..481d9f4b50 100644 --- a/crates/buzz-relay/src/handlers/auth.rs +++ b/crates/buzz-relay/src/handlers/auth.rs @@ -374,3 +374,58 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: } } } + +#[cfg(test)] +mod tests { + use super::extract_auth_tag_json; + use nostr::{EventBuilder, Keys, Kind, Tag}; + + /// Build a signed NIP-98 (kind 27235) event carrying the given tags. The + /// `auth` tag lives inside the signed event exactly as the git and + /// WebSocket auth paths receive it. + fn signed_event_with_tags(tags: Vec) -> nostr::Event { + EventBuilder::new(Kind::HttpAuth, "") + .tags(tags) + .sign_with_keys(&Keys::generate()) + .expect("sign auth event") + } + + /// A single `auth` tag is extracted verbatim as its JSON-array string — + /// this is the exact value fed to `verify_auth_tag` on the git path. + #[test] + fn single_auth_tag_extracted_verbatim() { + let owner = Keys::generate().public_key().to_hex(); + let sig = "00".repeat(64); + let event = signed_event_with_tags(vec![ + Tag::parse(["u", "https://relay/git/x/y"]).unwrap(), + Tag::parse(["auth", owner.as_str(), "", sig.as_str()]).unwrap(), + ]); + + let extracted = extract_auth_tag_json(&event).expect("auth tag present"); + let expected = serde_json::to_string(&["auth", owner.as_str(), "", sig.as_str()]).unwrap(); + assert_eq!(extracted, expected); + } + + /// No `auth` tag → `None` (the direct-member path, tag absent). + #[test] + fn no_auth_tag_returns_none() { + let event = + signed_event_with_tags(vec![Tag::parse(["u", "https://relay/git/x/y"]).unwrap()]); + assert_eq!(extract_auth_tag_json(&event), None); + } + + /// More than one `auth` tag → `None`. Per NIP-OA, an ambiguous set of + /// attestations is treated as no valid attestation (fail-closed), so a + /// second forged tag cannot smuggle an alternate delegation past the gate. + #[test] + fn duplicate_auth_tags_return_none() { + let a = Keys::generate().public_key().to_hex(); + let b = Keys::generate().public_key().to_hex(); + let sig = "00".repeat(64); + let event = signed_event_with_tags(vec![ + Tag::parse(["auth", a.as_str(), "", sig.as_str()]).unwrap(), + Tag::parse(["auth", b.as_str(), "", sig.as_str()]).unwrap(), + ]); + assert_eq!(extract_auth_tag_json(&event), None); + } +} diff --git a/crates/git-credential-nostr/src/lib.rs b/crates/git-credential-nostr/src/lib.rs index c35f495d5e..b51443600d 100644 --- a/crates/git-credential-nostr/src/lib.rs +++ b/crates/git-credential-nostr/src/lib.rs @@ -10,7 +10,7 @@ use std::io::{self, BufRead, Write}; use base64::Engine as _; use nostr::nips::nip98::{HttpData, HttpMethod}; use nostr::types::Url; -use nostr::{EventBuilder, Keys}; +use nostr::{EventBuilder, Keys, Tag}; use zeroize::Zeroize; fn git_config(key: &str) -> Option { @@ -71,6 +71,30 @@ fn load_key() -> Result { Ok(raw.trim().to_string()) } +/// Load the NIP-OA owner attestation injected by Buzz Desktop/ACP. +/// +/// The tag must be part of the signed NIP-98 event: Git's credential protocol +/// can return an Authorization value, but it cannot add a separate HTTP header. +fn load_auth_tag() -> Result, String> { + let raw = std::env::var("BUZZ_AUTH_TAG") + .ok() + .filter(|value| !value.is_empty()) + .or_else(|| git_config("nostr.authtag")); + + raw.map(|value| { + let parts: Vec = + serde_json::from_str(&value).map_err(|e| format!("invalid NIP-OA auth tag: {e}"))?; + if parts.len() != 4 || parts.first().map(String::as_str) != Some("auth") { + return Err( + "invalid NIP-OA auth tag: expected [auth, owner, conditions, signature]" + .to_string(), + ); + } + Tag::parse(parts).map_err(|e| format!("invalid NIP-OA auth tag: {e}")) + }) + .transpose() +} + #[derive(Default)] struct CredRequest { has_authtype_capability: bool, @@ -201,7 +225,19 @@ pub fn run() -> i32 { let parsed_url = Url::parse(&url).unwrap_or_else(|e| panic!("invalid URL {url:?}: {e}")); let http_data = HttpData::new(parsed_url, method); - let event = match EventBuilder::http_auth(http_data).sign_with_keys(&keys) { + let auth_tag = match load_auth_tag() { + Ok(tag) => tag, + Err(e) => { + eprintln!("error: {e}"); + return 1; + } + }; + let builder = EventBuilder::http_auth(http_data); + let builder = match auth_tag { + Some(tag) => builder.tag(tag), + None => builder, + }; + let event = match builder.sign_with_keys(&keys) { Ok(e) => e, Err(e) => { eprintln!("error: failed to sign NIP-98 event: {e}"); diff --git a/crates/git-credential-nostr/tests/integration.rs b/crates/git-credential-nostr/tests/integration.rs index a33de83bcd..6697b45f39 100644 --- a/crates/git-credential-nostr/tests/integration.rs +++ b/crates/git-credential-nostr/tests/integration.rs @@ -18,8 +18,12 @@ fn run_helper(input: &str, env_vars: &[(&str, &str)]) -> std::process::Output { cmd.stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) + .current_dir(std::env::temp_dir()) .env_remove("NOSTR_PRIVATE_KEY") - // Prevent git config on the test machine from supplying a keyfile. + .env_remove("BUZZ_AUTH_TAG") + .env_remove("GIT_CONFIG_COUNT") + // Prevent git config on the test machine from supplying credentials. + .env("GIT_CONFIG_GLOBAL", "/dev/null") .env("GIT_CONFIG_NOSYSTEM", "1") .env("HOME", std::env::temp_dir()); for (k, v) in env_vars { @@ -116,6 +120,69 @@ fn happy_path() { assert!(event["tags"].is_array(), "event missing 'tags'"); } +/// A Buzz-managed agent must carry its NIP-OA owner attestation inside the +/// signed NIP-98 event so the relay can admit it through the owner's membership. +#[test] +fn includes_nip_oa_auth_tag_in_signed_event() { + let agent_keys = Keys::generate(); + let owner_keys = Keys::generate(); + let nsec = agent_keys.secret_key().to_bech32().unwrap(); + let auth_tag = serde_json::to_string(&[ + "auth", + owner_keys.public_key().to_hex().as_str(), + "", + &"00".repeat(64), + ]) + .expect("serialize auth tag"); + + let out = run_helper( + &valid_input(), + &[("NOSTR_PRIVATE_KEY", &nsec), ("BUZZ_AUTH_TAG", &auth_tag)], + ); + assert!( + out.status.success(), + "helper failed: {}", + 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(), + "auth tag must be covered by the event signature" + ); + assert!(event.tags.iter().any(|tag| tag.as_slice() + == [ + "auth", + owner_keys.public_key().to_hex().as_str(), + "", + serde_json::from_str::>(&auth_tag).unwrap()[3].as_str(), + ])); +} + +/// A configured but malformed owner attestation must fail closed rather than +/// silently authenticating the agent without delegation. +#[test] +fn malformed_nip_oa_auth_tag_fails_closed() { + let nsec = fresh_nsec(); + let out = run_helper( + &valid_input(), + &[("NOSTR_PRIVATE_KEY", &nsec), ("BUZZ_AUTH_TAG", "not-json")], + ); + + assert_eq!(out.status.code(), Some(1)); + assert!(String::from_utf8_lossy(&out.stderr).contains("invalid NIP-OA auth tag")); + assert!(!String::from_utf8_lossy(&out.stdout).contains("credential=")); +} + /// Old git (no `capability[]=authtype` in input) → empty line on stdout, exit 0. #[test] fn old_git_no_authtype_capability() { @@ -264,7 +331,10 @@ fn bad_keyfile_permissions() { let out = run_helper( &valid_input(), - &[("HOME", git_config_dir.to_str().unwrap())], + &[ + ("HOME", git_config_dir.to_str().unwrap()), + ("GIT_CONFIG_GLOBAL", git_config_file.to_str().unwrap()), + ], ); // Clean up regardless of outcome.