fix(git): carry NIP-OA delegation in auth event (#2120)

Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
Co-authored-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Dawn (sprout agent) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Tyler
2026-07-19 11:29:26 -04:00
committed by GitHub
parent 8908bd6b71
commit c12257d57a
4 changed files with 176 additions and 7 deletions

View File

@@ -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(),

View File

@@ -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);
}
}

View File

@@ -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> {
@@ -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,
@@ -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}");

View File

@@ -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::<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() {
@@ -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.