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
|
// The ±60s timestamp window + URL scoping + HTTPS transport provide sufficient
|
||||||
// replay protection for v1. Per-request signing requires protocol changes.
|
// replay protection for v1. Per-request signing requires protocol changes.
|
||||||
|
|
||||||
// Relay membership gate (NIP-43).
|
let event: nostr::Event = serde_json::from_str(&event_json)
|
||||||
let auth_tag = parts
|
.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
|
.headers
|
||||||
.get("x-auth-tag")
|
.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(
|
if crate::api::relay_members::enforce_relay_membership(
|
||||||
state,
|
state,
|
||||||
tenant.community(),
|
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 base64::Engine as _;
|
||||||
use nostr::nips::nip98::{HttpData, HttpMethod};
|
use nostr::nips::nip98::{HttpData, HttpMethod};
|
||||||
use nostr::types::Url;
|
use nostr::types::Url;
|
||||||
use nostr::{EventBuilder, Keys};
|
use nostr::{EventBuilder, Keys, Tag};
|
||||||
use zeroize::Zeroize;
|
use zeroize::Zeroize;
|
||||||
|
|
||||||
fn git_config(key: &str) -> Option<String> {
|
fn git_config(key: &str) -> Option<String> {
|
||||||
@@ -71,6 +71,30 @@ fn load_key() -> Result<String, String> {
|
|||||||
Ok(raw.trim().to_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)]
|
#[derive(Default)]
|
||||||
struct CredRequest {
|
struct CredRequest {
|
||||||
has_authtype_capability: bool,
|
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 parsed_url = Url::parse(&url).unwrap_or_else(|e| panic!("invalid URL {url:?}: {e}"));
|
||||||
let http_data = HttpData::new(parsed_url, method);
|
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,
|
Ok(e) => e,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("error: failed to sign NIP-98 event: {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())
|
cmd.stdin(Stdio::piped())
|
||||||
.stdout(Stdio::piped())
|
.stdout(Stdio::piped())
|
||||||
.stderr(Stdio::piped())
|
.stderr(Stdio::piped())
|
||||||
|
.current_dir(std::env::temp_dir())
|
||||||
.env_remove("NOSTR_PRIVATE_KEY")
|
.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("GIT_CONFIG_NOSYSTEM", "1")
|
||||||
.env("HOME", std::env::temp_dir());
|
.env("HOME", std::env::temp_dir());
|
||||||
for (k, v) in env_vars {
|
for (k, v) in env_vars {
|
||||||
@@ -116,6 +120,69 @@ fn happy_path() {
|
|||||||
assert!(event["tags"].is_array(), "event missing 'tags'");
|
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.
|
/// Old git (no `capability[]=authtype` in input) → empty line on stdout, exit 0.
|
||||||
#[test]
|
#[test]
|
||||||
fn old_git_no_authtype_capability() {
|
fn old_git_no_authtype_capability() {
|
||||||
@@ -264,7 +331,10 @@ fn bad_keyfile_permissions() {
|
|||||||
|
|
||||||
let out = run_helper(
|
let out = run_helper(
|
||||||
&valid_input(),
|
&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.
|
// Clean up regardless of outcome.
|
||||||
|
|||||||
Reference in New Issue
Block a user