Skip to content
Merged
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
14 changes: 11 additions & 3 deletions crates/buzz-relay/src/api/git/transport.rs
Original file line number Diff line number Diff line change
Expand Up @@ -185,11 +185,19 @@ impl axum::extract::FromRequestParts<Arc<AppState>> 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(),
Expand Down
55 changes: 55 additions & 0 deletions crates/buzz-relay/src/handlers/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -374,3 +374,58 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc<ConnectionState>, 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<Tag>) -> 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);
}
}
40 changes: 38 additions & 2 deletions crates/git-credential-nostr/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> {
Expand Down Expand Up @@ -71,6 +71,30 @@ fn load_key() -> Result<String, String> {
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<Option<Tag>, 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<String> =
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,
Expand Down Expand Up @@ -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}");
Expand Down
74 changes: 72 additions & 2 deletions crates/git-credential-nostr/tests/integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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::<Vec<String>>(&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() {
Expand Down Expand Up @@ -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.
Expand Down
Loading