mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
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:
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}");
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user