From 53de09881f869dae57e11c6c6a3eee320257e3c7 Mon Sep 17 00:00:00 2001 From: amanning3390 Date: Sat, 1 Aug 2026 18:44:59 -0500 Subject: [PATCH 1/4] fix(nip-oa): accept raw Nostr tag form in parse_json_array MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit parse_json_array only accepted well-formed JSON arrays, so parse_auth_tag / verify_auth_tag rejected BUZZ_AUTH_TAG values stored in the raw Nostr tag form ([auth,hex,,hex]) — the unquoted, comma-delimited serialization used inside Nostr events and commonly written to .env files and shell variables. This forced every consumer (buzz-acp harness, agent shells sourcing BUZZ_AUTH_TAG from .env) to re-quote the value into JSON before the CLI would accept it, or the CLI failed with 'BUZZ_AUTH_TAG is malformed: invalid JSON'. Add a fallback in parse_json_array: when strict JSON parsing fails and the trimmed input is bracket-delimited, split on commas and treat each field as a string (empty field -> empty string, matching the JSON form ["auth","hex","","hex"]). Well-formed JSON still takes the fast path; only non-JSON bracketed input triggers the fallback. This is the lowest layer, so all consumers (parse_auth_tag, verify_auth_tag, the CLI, buzz-acp) benefit from one change. Tests: 3 new (raw form with conditions, raw form with empty conditions, raw form with whitespace) + all 21 existing nip_oa tests pass. Signed-off-by: amanning3390 --- crates/buzz-sdk/src/nip_oa.rs | 72 +++++++++++++++++++++++++++++++---- 1 file changed, 65 insertions(+), 7 deletions(-) diff --git a/crates/buzz-sdk/src/nip_oa.rs b/crates/buzz-sdk/src/nip_oa.rs index 2dff81bcf7..3536fbc6f9 100644 --- a/crates/buzz-sdk/src/nip_oa.rs +++ b/crates/buzz-sdk/src/nip_oa.rs @@ -122,14 +122,34 @@ fn is_lowercase_hex(c: char) -> bool { } fn parse_json_array(s: &str) -> Result, SdkError> { - let v: Value = serde_json::from_str(s) - .map_err(|e| SdkError::InvalidInput(format!("invalid JSON: {e}")))?; - match v { - Value::Array(arr) => Ok(arr), - _ => Err(SdkError::InvalidInput( - "auth tag must be a JSON array".into(), - )), + let trimmed = s.trim(); + // Fast path: well-formed JSON array. + if let Ok(Value::Array(arr)) = serde_json::from_str::(trimmed) { + return Ok(arr); + } + + // Fallback: the raw Nostr tag form, e.g. `[auth,deadbeef,,a1b2...]`. + // + // This is how an `auth` tag serializes inside a Nostr event (unquoted, + // comma-delimited) and how `.env` files and shell variables commonly + // carry `BUZZ_AUTH_TAG`. Accept it so consumers don't have to re-quote + // the value into JSON before calling `parse_auth_tag` / `verify_auth_tag`. + // An empty field (`,,`) parses to an empty string, matching the tag's + // JSON form `["auth","hex","","hex"]`. + if trimmed.starts_with('[') && trimmed.ends_with(']') { + let inner = &trimmed[1..trimmed.len() - 1]; + let arr: Vec = inner + .split(',') + .map(|part| Value::String(part.trim().to_owned())) + .collect(); + if !arr.is_empty() { + return Ok(arr); + } } + + Err(SdkError::InvalidInput(format!( + "invalid JSON: expected array, got {trimmed:?}" + ))) } /// Compute a NIP-OA `auth` tag authorizing `agent_pubkey` under `conditions`. @@ -426,6 +446,44 @@ mod tests { assert_eq!(slice[3], "a".repeat(128)); } + /// parse_auth_tag accepts the raw Nostr tag form `[auth,hex,,hex]` + /// (unquoted, comma-delimited), which is how an `auth` tag serializes + /// inside a Nostr event and how `.env` files commonly store it. + #[test] + fn test_parse_auth_tag_raw_nostr_form() { + let sig_hex = "a".repeat(128); + + // Raw form with non-empty conditions. + let raw = format!("[auth,{OWNER_PUBKEY_HEX},{CONDITIONS},{sig_hex}]"); + let tag = parse_auth_tag(&raw).expect("raw Nostr form must parse"); + let slice = tag.as_slice(); + assert_eq!(slice[0], "auth"); + assert_eq!(slice[1], OWNER_PUBKEY_HEX); + assert_eq!(slice[2], CONDITIONS); + assert_eq!(slice[3], sig_hex); + + // Raw form with empty conditions (`,,`). + let raw_empty = format!("[auth,{OWNER_PUBKEY_HEX},,{sig_hex}]"); + let tag = parse_auth_tag(&raw_empty).expect("raw form with empty conditions must parse"); + let slice = tag.as_slice(); + assert_eq!(slice[0], "auth"); + assert_eq!(slice[1], OWNER_PUBKEY_HEX); + assert_eq!(slice[2], ""); // empty conditions field + assert_eq!(slice[3], sig_hex); + } + + /// parse_auth_tag accepts the raw form with surrounding whitespace + /// (common when read from shell variables / `.env`). + #[test] + fn test_parse_auth_tag_raw_form_with_whitespace() { + let sig_hex = "a".repeat(128); + let raw = format!(" [auth, {OWNER_PUBKEY_HEX} , {CONDITIONS}, {sig_hex}] \n"); + let tag = parse_auth_tag(&raw).expect("raw form with whitespace must parse"); + let slice = tag.as_slice(); + assert_eq!(slice[0], "auth"); + assert_eq!(slice[1], OWNER_PUBKEY_HEX); + } + /// Various malformed inputs to parse_auth_tag must return errors. #[test] fn test_parse_auth_tag_malformed() { From 6d574fe4a7c62acf24dba7b09d438550a10e67f5 Mon Sep 17 00:00:00 2001 From: amanning3390 Date: Sat, 1 Aug 2026 18:51:57 -0500 Subject: [PATCH 2/4] fix(cli): canonicalize BUZZ_AUTH_TAG to JSON before sending x-auth-tag header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CLI stored the raw BUZZ_AUTH_TAG input string and sent it verbatim as the x-auth-tag header value (client.rs:618). When the tag was in the raw Nostr form ([auth,hex,,hex]), the relay's verify_auth_tag — which expects JSON — rejected it with 403 relay_membership_required, even though the CLI had parsed it locally. Add canonicalize_auth_tag in buzz-sdk: parse either form (JSON or raw), re-serialize to canonical JSON. The CLI now canonicalizes the tag before storing it as auth_tag_json, so the header is always valid JSON regardless of input form. This closes the loop with the parse_json_array fallback: local parse + wire canonicalization means the raw form works end-to-end. Signed-off-by: amanning3390 --- crates/buzz-cli/src/lib.rs | 7 ++++- crates/buzz-sdk/src/nip_oa.rs | 52 +++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index 0726406d29..93d35cdaf7 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -1798,7 +1798,12 @@ async fn run(cli: Cli) -> Result<(), CliError> { keys.public_key().to_hex() )) })?; - (Some(tag), Some(json.clone())) + // Canonicalize to JSON so the `x-auth-tag` header we send is valid + // regardless of whether the input was JSON or the raw Nostr form. + let canonical = buzz_sdk::nip_oa::canonicalize_auth_tag(json).map_err(|e| { + CliError::Auth(format!("BUZZ_AUTH_TAG canonicalization failed: {e}")) + })?; + (Some(tag), Some(canonical)) } _ => (None, None), }; diff --git a/crates/buzz-sdk/src/nip_oa.rs b/crates/buzz-sdk/src/nip_oa.rs index 3536fbc6f9..a54e18e5c5 100644 --- a/crates/buzz-sdk/src/nip_oa.rs +++ b/crates/buzz-sdk/src/nip_oa.rs @@ -318,6 +318,33 @@ pub fn parse_auth_tag(json_str: &str) -> Result { .map_err(|e| SdkError::InvalidInput(format!("failed to construct Tag: {e}"))) } +/// Normalize an `auth` tag into its canonical JSON serialization. +/// +/// Accepts either the JSON array form `["auth","hex","","hex"]` or the raw +/// Nostr tag form `[auth,hex,,hex]` (see [`parse_auth_tag`]) and returns the +/// canonical JSON array string. This is the form that should be sent over the +/// wire (e.g. as the CLI's `x-auth-tag` header) so that a relay's +/// [`verify_auth_tag`] — which expects JSON — accepts it regardless of how the +/// caller stored the tag locally. +/// +/// # Errors +/// +/// Returns [`SdkError::InvalidInput`] if the input is not a valid 4-element +/// `auth` tag in either form. +pub fn canonicalize_auth_tag(input: &str) -> Result { + let arr = parse_json_array(input)?; + if arr.len() != 4 { + return Err(SdkError::InvalidInput(format!( + "auth tag must have 4 elements, got {}", + arr.len() + ))); + } + // Re-serialize as canonical JSON. parse_auth_tag / verify_auth_tag validate + // the contents; here we only normalize the container shape. + Ok(serde_json::to_string(&arr) + .map_err(|e| SdkError::InvalidInput(format!("canonical serialization failed: {e}")))?) +} + #[cfg(test)] mod tests { use super::*; @@ -484,6 +511,31 @@ mod tests { assert_eq!(slice[1], OWNER_PUBKEY_HEX); } + /// canonicalize_auth_tag normalizes the raw Nostr form to canonical JSON, + /// so the `x-auth-tag` header is valid JSON regardless of input form. + #[test] + fn test_canonicalize_auth_tag_raw_to_json() { + let sig_hex = "a".repeat(128); + + // Raw form with empty conditions (`,,`) -> canonical JSON with "". + let raw = format!("[auth,{OWNER_PUBKEY_HEX},,{sig_hex}]"); + let canonical = canonicalize_auth_tag(&raw).expect("raw form must canonicalize"); + let reparsed: Vec = + serde_json::from_str(&canonical).expect("canonical output must be valid JSON"); + assert_eq!(reparsed, vec!["auth", OWNER_PUBKEY_HEX, "", &sig_hex]); + + // JSON input passes through unchanged (modulo canonical spacing). + let json_in = + serde_json::json!(["auth", OWNER_PUBKEY_HEX, CONDITIONS, &sig_hex]).to_string(); + let canonical = canonicalize_auth_tag(&json_in).expect("JSON form must canonicalize"); + let reparsed: Vec = + serde_json::from_str(&canonical).expect("canonical output must be valid JSON"); + assert_eq!( + reparsed, + vec!["auth", OWNER_PUBKEY_HEX, CONDITIONS, &sig_hex] + ); + } + /// Various malformed inputs to parse_auth_tag must return errors. #[test] fn test_parse_auth_tag_malformed() { From 88471dd668c633deae2ef0ae5e2658e1dd70b9d7 Mon Sep 17 00:00:00 2001 From: amanning3390 Date: Sat, 1 Aug 2026 19:00:49 -0500 Subject: [PATCH 3/4] fix(git): parse raw NIP-OA tag form in git-credential-nostr and git-sign-nostr MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address Codex review P2: git-credential-nostr::load_auth_tag and git-sign-nostr::load_auth_tag parsed BUZZ_AUTH_TAG with serde_json::from_str directly, so the raw Nostr tag form ([auth,hex,,hex]) newly accepted by the SDK and CLI still failed for Git fetch/push/sign operations. Add a parse helper in each crate that mirrors the SDK's parse_json_array behavior: JSON fast path, then a raw-form fallback that splits on commas. No new dependencies — each git crate keeps its existing dep set. Also fix a clippy needless_question_mark warning in canonicalize_auth_tag. Test: new test_load_auth_tag_accepts_raw_nostr_form covers raw form with empty conditions, conditions, and whitespace in git-sign-nostr. Note: test_parse_envelope_rejects_invalid_oa_pubkey is a pre-existing failure on upstream/main (unrelated to this change) — confirmed by running it with these commits stashed. Signed-off-by: amanning3390 --- crates/buzz-sdk/src/nip_oa.rs | 4 +- crates/git-credential-nostr/src/lib.rs | 29 +++++++++- crates/git-sign-nostr/src/lib.rs | 78 ++++++++++++++++++++++++-- 3 files changed, 101 insertions(+), 10 deletions(-) diff --git a/crates/buzz-sdk/src/nip_oa.rs b/crates/buzz-sdk/src/nip_oa.rs index a54e18e5c5..6906054d7e 100644 --- a/crates/buzz-sdk/src/nip_oa.rs +++ b/crates/buzz-sdk/src/nip_oa.rs @@ -341,8 +341,8 @@ pub fn canonicalize_auth_tag(input: &str) -> Result { } // Re-serialize as canonical JSON. parse_auth_tag / verify_auth_tag validate // the contents; here we only normalize the container shape. - Ok(serde_json::to_string(&arr) - .map_err(|e| SdkError::InvalidInput(format!("canonical serialization failed: {e}")))?) + serde_json::to_string(&arr) + .map_err(|e| SdkError::InvalidInput(format!("canonical serialization failed: {e}"))) } #[cfg(test)] diff --git a/crates/git-credential-nostr/src/lib.rs b/crates/git-credential-nostr/src/lib.rs index b51443600d..484196a06d 100644 --- a/crates/git-credential-nostr/src/lib.rs +++ b/crates/git-credential-nostr/src/lib.rs @@ -82,8 +82,7 @@ fn load_auth_tag() -> Result, String> { .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}"))?; + let parts: Vec = parse_auth_tag_parts(&value)?; if parts.len() != 4 || parts.first().map(String::as_str) != Some("auth") { return Err( "invalid NIP-OA auth tag: expected [auth, owner, conditions, signature]" @@ -95,6 +94,32 @@ fn load_auth_tag() -> Result, String> { .transpose() } +/// Parse a NIP-OA `auth` tag into its component strings. +/// +/// Accepts both the JSON array form (`["auth","hex","","hex"]`) and the raw +/// Nostr tag form (`[auth,hex,,hex]`) — the unquoted, comma-delimited +/// serialization used inside Nostr events and commonly stored in `.env` files. +/// Matches the SDK's `parse_json_array` behavior so Git auth consumers accept +/// the same `BUZZ_AUTH_TAG` values as the CLI. +fn parse_auth_tag_parts(input: &str) -> Result, String> { + let trimmed = input.trim(); + // Fast path: well-formed JSON array. + if let Ok(parts) = serde_json::from_str::>(trimmed) { + return Ok(parts); + } + // Fallback: raw Nostr tag form [auth,hex,,hex]. + if trimmed.starts_with('[') && trimmed.ends_with(']') { + let inner = &trimmed[1..trimmed.len() - 1]; + let parts: Vec = inner.split(',').map(|p| p.trim().to_owned()).collect(); + if !parts.is_empty() { + return Ok(parts); + } + } + Err(format!( + "invalid NIP-OA auth tag: expected JSON array or raw tag form, got {trimmed:?}" + )) +} + #[derive(Default)] struct CredRequest { has_authtype_capability: bool, diff --git a/crates/git-sign-nostr/src/lib.rs b/crates/git-sign-nostr/src/lib.rs index d316711200..78dff37003 100644 --- a/crates/git-sign-nostr/src/lib.rs +++ b/crates/git-sign-nostr/src/lib.rs @@ -487,12 +487,12 @@ fn load_auth_tag() -> Result, Error> { ))); } - // Parse: ["auth", "", "", ""] - let arr: serde_json::Value = serde_json::from_str(&json_str) - .map_err(|e| Error::Fatal(format!("BUZZ_AUTH_TAG is not valid JSON: {e}")))?; - let arr = arr - .as_array() - .ok_or_else(|| Error::Fatal("BUZZ_AUTH_TAG must be a JSON array".to_string()))?; + // Parse: [\"auth\", \"\", \"\", \"\"] + // Accept both the JSON array form and the raw Nostr tag form + // ([auth,hex,,hex]) so this consumer matches the SDK's parse_json_array + // behavior and the CLI. + let arr = parse_auth_tag_array(&json_str) + .map_err(|e| Error::Fatal(format!("BUZZ_AUTH_TAG is not a valid auth tag: {e}")))?; if arr.len() != 4 { return Err(Error::Fatal( "BUZZ_AUTH_TAG must have exactly 4 elements".to_string(), @@ -548,6 +548,35 @@ fn load_auth_tag() -> Result, Error> { Ok(Some((owner, conditions, sig))) } +/// Parse a NIP-OA `auth` tag into a JSON array of values. +/// +/// Accepts both the JSON array form (`["auth","hex","","hex"]`) and the raw +/// Nostr tag form (`[auth,hex,,hex]`) — the unquoted, comma-delimited +/// serialization used inside Nostr events and commonly stored in `.env` files. +/// Matches the SDK's `parse_json_array` behavior so Git signing accepts the +/// same `BUZZ_AUTH_TAG` values as the CLI. +fn parse_auth_tag_array(input: &str) -> Result, String> { + let trimmed = input.trim(); + // Fast path: well-formed JSON array. + if let Ok(serde_json::Value::Array(arr)) = serde_json::from_str::(trimmed) { + return Ok(arr); + } + // Fallback: raw Nostr tag form [auth,hex,,hex]. + if trimmed.starts_with('[') && trimmed.ends_with(']') { + let inner = &trimmed[1..trimmed.len() - 1]; + let arr: Vec = inner + .split(',') + .map(|p| serde_json::Value::String(p.trim().to_owned())) + .collect(); + if !arr.is_empty() { + return Ok(arr); + } + } + Err(format!( + "expected JSON array or raw tag form, got {trimmed:?}" + )) +} + /// Validate NIP-OA conditions string with structural parsing. /// /// Grammar (empty string is valid): @@ -2009,6 +2038,43 @@ Initial commit" ); } + /// load_auth_tag accepts the raw Nostr tag form `[auth,hex,,hex]`, + /// matching the SDK's parse_json_array fallback and the CLI. + #[test] + fn test_load_auth_tag_accepts_raw_nostr_form() { + let owner = "a".repeat(64); + let sig = "b".repeat(128); + + // Raw form with empty conditions (`,,`). + std::env::set_var("BUZZ_AUTH_TAG", format!("[auth,{owner},,{sig}]")); + let result = load_auth_tag(); + assert!( + matches!(result, Ok(Some(_))), + "raw Nostr tag form with empty conditions should be accepted" + ); + + // Raw form with conditions. + std::env::set_var("BUZZ_AUTH_TAG", format!("[auth,{owner},kind=9,{sig}]")); + let result = load_auth_tag(); + assert!( + matches!(result, Ok(Some(_))), + "raw Nostr tag form with conditions should be accepted" + ); + + // Raw form with surrounding whitespace. + std::env::set_var( + "BUZZ_AUTH_TAG", + format!(" [auth, {owner} , kind=9, {sig}] \n"), + ); + let result = load_auth_tag(); + assert!( + matches!(result, Ok(Some(_))), + "raw Nostr tag form with whitespace should be accepted" + ); + + std::env::remove_var("BUZZ_AUTH_TAG"); + } + /// Helper: sign a payload and return the armored signature fn sign_payload(secret_hex: &str, payload: &[u8], t: u64) -> String { let keypair = Keypair::from_seckey_str(SECP256K1, secret_hex).unwrap(); From 0b74eee24df1baf62ba39e4f6d9e0a1dc129ac24 Mon Sep 17 00:00:00 2001 From: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> Date: Sat, 1 Aug 2026 21:11:08 -0400 Subject: [PATCH 4/4] refactor(nip-oa): move raw-tag leniency to the CLI config edge; keep SDK and wire grammar strict MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up to the previous three commits, keeping their intent (accept the hand-authored unquoted BUZZ_AUTH_TAG shorthand) while narrowing where the leniency lives: - Revert buzz-sdk parse_json_array to strict JSON. verify_auth_tag is the relay's x-auth-tag entry point (extract_nip_oa_owner and the bridge/api handlers), so the lenient grammar had widened the public wire format; probing that entry point base-vs-head showed raw form going rejected->accepted. Wire grammar is JSON per NIP-GS; it stays strict. - Remove canonicalize_auth_tag. The CLI now derives the wire string from the already parsed-and-verified Tag via serde_json::to_string(tag.as_slice()) — the same shape buzz-acp's RestClient has always used — instead of re-parsing unverified input. This also removes the helper whose docs promised full validation but whose body only checked element count. - Revert the hand-copied fallback parsers in git-credential-nostr and git-sign-nostr. The copies already diverged from the SDK (Vec vs Value fast paths) and NIP-GS specifies the JSON form for these consumers. Reverting git-sign-nostr also removes the second BUZZ_AUTH_TAG-mutating test that raced the existing one under the default parallel test runner. - Add normalize_auth_tag_input in buzz-cli: a small, explicit raw->JSON rewrite applied only to hand-authored configuration input, before the unchanged strict parse/verify path. Valid JSON passes through untouched; unrecognizable input is left for the strict parser to reject with an error about the original bytes. Net: BUZZ_AUTH_TAG in raw form still works end-to-end via the CLI (verified live against a relay with a real delegated credential, plus JSON control and fail-closed garbage control), and the SDK, relay, and git binaries keep the strict grammar they had before this PR. Tests: buzz-cli 274, buzz-sdk 241, git-credential-nostr 8, buzz-relay 835 lib tests pass; git-sign-nostr suite no longer flakes under the default runner (8/8 repeat runs; the one remaining failure is test_parse_envelope_rejects_invalid_oa_pubkey, which fails identically on base ac4fa13b8 on macOS and is unrelated). fmt and clippy clean. Co-authored-by: Tyler <109685178+tlongwell-block@users.noreply.github.com> Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> Signed-off-by: Tyler <109685178+tlongwell-block@users.noreply.github.com> Signed-off-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> --- crates/buzz-cli/src/lib.rs | 102 ++++++++++++++++++-- crates/buzz-sdk/src/nip_oa.rs | 124 ++----------------------- crates/git-credential-nostr/src/lib.rs | 29 +----- crates/git-sign-nostr/src/lib.rs | 78 ++-------------- 4 files changed, 109 insertions(+), 224 deletions(-) diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index 93d35cdaf7..0860f9dae6 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -1768,6 +1768,41 @@ pub enum ModerationCmd { }, } +/// Normalize hand-authored `BUZZ_AUTH_TAG` input to strict JSON. +/// +/// `.env` files and shell exports sometimes carry the tag in the unquoted +/// shorthand `[auth,,,]` (quotes dropped by hand). +/// When the input is not valid JSON but is bracket-delimited, rewrite it as +/// a JSON array of the comma-separated fields (an empty field `,,` becomes +/// `""`, matching the canonical form `["auth","hex","","hex"]`). +/// +/// This is presentation-layer leniency at the configuration edge only: the +/// output is always fed through the SDK's strict `parse_auth_tag` / +/// `verify_auth_tag`, which enforce structure, hex, the conditions grammar, +/// and the BIP-340 signature. Inputs that are already valid JSON — or not +/// recognizable as the shorthand — are returned unchanged so the strict +/// parser reports the error on the original bytes. +fn normalize_auth_tag_input(input: &str) -> String { + let trimmed = input.trim(); + if serde_json::from_str::(trimmed).is_ok() { + return trimmed.to_owned(); + } + if trimmed.starts_with('[') && trimmed.ends_with(']') { + let fields: Vec<&str> = trimmed[1..trimmed.len() - 1] + .split(',') + .map(str::trim) + .collect(); + // Only a plausible 4-field auth tag is rewritten; anything else is + // passed through untouched for the strict parser to reject with an + // error that references the caller's original input. + if fields.len() == 4 && !fields.iter().any(|f| f.contains('"')) { + // serde_json cannot fail serializing a Vec<&str>. + return serde_json::to_string(&fields).expect("string array serializes"); + } + } + trimmed.to_owned() +} + async fn run(cli: Cli) -> Result<(), CliError> { let relay_url = client::normalize_relay_url(&cli.relay); @@ -1788,21 +1823,27 @@ async fn run(cli: Cli) -> Result<(), CliError> { .map_err(|e| CliError::Key(format!("invalid BUZZ_PRIVATE_KEY: {e}")))?; // NIP-OA: parse and verify the auth tag if provided. + // + // `BUZZ_AUTH_TAG` is hand-authored configuration, so the unquoted raw + // shorthand `[auth,hex,,hex]` is normalized to JSON here — at this input + // edge only. The SDK grammar and the `x-auth-tag` wire format stay strict + // JSON; all validation and signature verification happen on the strict + // path below, unchanged. let (auth_tag, auth_tag_json) = match cli.auth_tag { - Some(ref json) if !json.is_empty() => { - let tag = buzz_sdk::nip_oa::parse_auth_tag(json) + Some(ref input) if !input.is_empty() => { + let json = normalize_auth_tag_input(input); + let tag = buzz_sdk::nip_oa::parse_auth_tag(&json) .map_err(|e| CliError::Auth(format!("BUZZ_AUTH_TAG is malformed: {e}")))?; - buzz_sdk::nip_oa::verify_auth_tag(json, &keys.public_key()).map_err(|e| { + buzz_sdk::nip_oa::verify_auth_tag(&json, &keys.public_key()).map_err(|e| { CliError::Auth(format!( "BUZZ_AUTH_TAG verification failed for pubkey {}: {e}", keys.public_key().to_hex() )) })?; - // Canonicalize to JSON so the `x-auth-tag` header we send is valid - // regardless of whether the input was JSON or the raw Nostr form. - let canonical = buzz_sdk::nip_oa::canonicalize_auth_tag(json).map_err(|e| { - CliError::Auth(format!("BUZZ_AUTH_TAG canonicalization failed: {e}")) - })?; + // Canonical wire form derives from the parsed-and-verified tag + // (same shape as buzz-acp's RestClient), never from raw input. + let canonical = serde_json::to_string(tag.as_slice()) + .map_err(|e| CliError::Auth(format!("BUZZ_AUTH_TAG serialization failed: {e}")))?; (Some(tag), Some(canonical)) } _ => (None, None), @@ -1840,6 +1881,51 @@ mod tests { use super::*; use clap::CommandFactory; + /// Raw shorthand `[auth,hex,,hex]` normalizes to strict JSON; the empty + /// conditions field becomes `""`. + #[test] + fn normalize_auth_tag_raw_shorthand() { + let owner = "a".repeat(64); + let sig = "b".repeat(128); + + let raw = format!("[auth,{owner},,{sig}]"); + let json = normalize_auth_tag_input(&raw); + let parsed: Vec = serde_json::from_str(&json).expect("output must be JSON"); + assert_eq!(parsed, vec!["auth", &owner, "", &sig]); + + // With conditions and surrounding whitespace (shell/.env artifacts). + let raw = format!(" [auth, {owner} , kind=9, {sig}] \n"); + let json = normalize_auth_tag_input(&raw); + let parsed: Vec = serde_json::from_str(&json).expect("output must be JSON"); + assert_eq!(parsed, vec!["auth", &owner, "kind=9", &sig]); + } + + /// Valid JSON input passes through byte-identical (modulo outer trim) — + /// the normalizer must never rewrite well-formed input. + #[test] + fn normalize_auth_tag_json_passthrough() { + let owner = "a".repeat(64); + let sig = "b".repeat(128); + let json_in = serde_json::json!(["auth", owner, "kind=9", sig]).to_string(); + assert_eq!(normalize_auth_tag_input(&json_in), json_in); + } + + /// Inputs that are neither JSON nor a plausible 4-field shorthand pass + /// through unchanged, so the strict parser rejects the original bytes. + #[test] + fn normalize_auth_tag_leaves_garbage_untouched() { + for garbage in [ + "not a tag", + "[auth,too,few]", + "[a,b,c,d,e]", + r#"[auth,"quoted",x,y]"#, // quote chars => not the shorthand + "[]", + "{\"auth\":1}", + ] { + assert_eq!(normalize_auth_tag_input(garbage), garbage.trim()); + } + } + /// Smoke test: CLI definition is valid and parseable. #[test] fn cli_definition_is_valid() { diff --git a/crates/buzz-sdk/src/nip_oa.rs b/crates/buzz-sdk/src/nip_oa.rs index 6906054d7e..2dff81bcf7 100644 --- a/crates/buzz-sdk/src/nip_oa.rs +++ b/crates/buzz-sdk/src/nip_oa.rs @@ -122,34 +122,14 @@ fn is_lowercase_hex(c: char) -> bool { } fn parse_json_array(s: &str) -> Result, SdkError> { - let trimmed = s.trim(); - // Fast path: well-formed JSON array. - if let Ok(Value::Array(arr)) = serde_json::from_str::(trimmed) { - return Ok(arr); - } - - // Fallback: the raw Nostr tag form, e.g. `[auth,deadbeef,,a1b2...]`. - // - // This is how an `auth` tag serializes inside a Nostr event (unquoted, - // comma-delimited) and how `.env` files and shell variables commonly - // carry `BUZZ_AUTH_TAG`. Accept it so consumers don't have to re-quote - // the value into JSON before calling `parse_auth_tag` / `verify_auth_tag`. - // An empty field (`,,`) parses to an empty string, matching the tag's - // JSON form `["auth","hex","","hex"]`. - if trimmed.starts_with('[') && trimmed.ends_with(']') { - let inner = &trimmed[1..trimmed.len() - 1]; - let arr: Vec = inner - .split(',') - .map(|part| Value::String(part.trim().to_owned())) - .collect(); - if !arr.is_empty() { - return Ok(arr); - } + let v: Value = serde_json::from_str(s) + .map_err(|e| SdkError::InvalidInput(format!("invalid JSON: {e}")))?; + match v { + Value::Array(arr) => Ok(arr), + _ => Err(SdkError::InvalidInput( + "auth tag must be a JSON array".into(), + )), } - - Err(SdkError::InvalidInput(format!( - "invalid JSON: expected array, got {trimmed:?}" - ))) } /// Compute a NIP-OA `auth` tag authorizing `agent_pubkey` under `conditions`. @@ -318,33 +298,6 @@ pub fn parse_auth_tag(json_str: &str) -> Result { .map_err(|e| SdkError::InvalidInput(format!("failed to construct Tag: {e}"))) } -/// Normalize an `auth` tag into its canonical JSON serialization. -/// -/// Accepts either the JSON array form `["auth","hex","","hex"]` or the raw -/// Nostr tag form `[auth,hex,,hex]` (see [`parse_auth_tag`]) and returns the -/// canonical JSON array string. This is the form that should be sent over the -/// wire (e.g. as the CLI's `x-auth-tag` header) so that a relay's -/// [`verify_auth_tag`] — which expects JSON — accepts it regardless of how the -/// caller stored the tag locally. -/// -/// # Errors -/// -/// Returns [`SdkError::InvalidInput`] if the input is not a valid 4-element -/// `auth` tag in either form. -pub fn canonicalize_auth_tag(input: &str) -> Result { - let arr = parse_json_array(input)?; - if arr.len() != 4 { - return Err(SdkError::InvalidInput(format!( - "auth tag must have 4 elements, got {}", - arr.len() - ))); - } - // Re-serialize as canonical JSON. parse_auth_tag / verify_auth_tag validate - // the contents; here we only normalize the container shape. - serde_json::to_string(&arr) - .map_err(|e| SdkError::InvalidInput(format!("canonical serialization failed: {e}"))) -} - #[cfg(test)] mod tests { use super::*; @@ -473,69 +426,6 @@ mod tests { assert_eq!(slice[3], "a".repeat(128)); } - /// parse_auth_tag accepts the raw Nostr tag form `[auth,hex,,hex]` - /// (unquoted, comma-delimited), which is how an `auth` tag serializes - /// inside a Nostr event and how `.env` files commonly store it. - #[test] - fn test_parse_auth_tag_raw_nostr_form() { - let sig_hex = "a".repeat(128); - - // Raw form with non-empty conditions. - let raw = format!("[auth,{OWNER_PUBKEY_HEX},{CONDITIONS},{sig_hex}]"); - let tag = parse_auth_tag(&raw).expect("raw Nostr form must parse"); - let slice = tag.as_slice(); - assert_eq!(slice[0], "auth"); - assert_eq!(slice[1], OWNER_PUBKEY_HEX); - assert_eq!(slice[2], CONDITIONS); - assert_eq!(slice[3], sig_hex); - - // Raw form with empty conditions (`,,`). - let raw_empty = format!("[auth,{OWNER_PUBKEY_HEX},,{sig_hex}]"); - let tag = parse_auth_tag(&raw_empty).expect("raw form with empty conditions must parse"); - let slice = tag.as_slice(); - assert_eq!(slice[0], "auth"); - assert_eq!(slice[1], OWNER_PUBKEY_HEX); - assert_eq!(slice[2], ""); // empty conditions field - assert_eq!(slice[3], sig_hex); - } - - /// parse_auth_tag accepts the raw form with surrounding whitespace - /// (common when read from shell variables / `.env`). - #[test] - fn test_parse_auth_tag_raw_form_with_whitespace() { - let sig_hex = "a".repeat(128); - let raw = format!(" [auth, {OWNER_PUBKEY_HEX} , {CONDITIONS}, {sig_hex}] \n"); - let tag = parse_auth_tag(&raw).expect("raw form with whitespace must parse"); - let slice = tag.as_slice(); - assert_eq!(slice[0], "auth"); - assert_eq!(slice[1], OWNER_PUBKEY_HEX); - } - - /// canonicalize_auth_tag normalizes the raw Nostr form to canonical JSON, - /// so the `x-auth-tag` header is valid JSON regardless of input form. - #[test] - fn test_canonicalize_auth_tag_raw_to_json() { - let sig_hex = "a".repeat(128); - - // Raw form with empty conditions (`,,`) -> canonical JSON with "". - let raw = format!("[auth,{OWNER_PUBKEY_HEX},,{sig_hex}]"); - let canonical = canonicalize_auth_tag(&raw).expect("raw form must canonicalize"); - let reparsed: Vec = - serde_json::from_str(&canonical).expect("canonical output must be valid JSON"); - assert_eq!(reparsed, vec!["auth", OWNER_PUBKEY_HEX, "", &sig_hex]); - - // JSON input passes through unchanged (modulo canonical spacing). - let json_in = - serde_json::json!(["auth", OWNER_PUBKEY_HEX, CONDITIONS, &sig_hex]).to_string(); - let canonical = canonicalize_auth_tag(&json_in).expect("JSON form must canonicalize"); - let reparsed: Vec = - serde_json::from_str(&canonical).expect("canonical output must be valid JSON"); - assert_eq!( - reparsed, - vec!["auth", OWNER_PUBKEY_HEX, CONDITIONS, &sig_hex] - ); - } - /// Various malformed inputs to parse_auth_tag must return errors. #[test] fn test_parse_auth_tag_malformed() { diff --git a/crates/git-credential-nostr/src/lib.rs b/crates/git-credential-nostr/src/lib.rs index 484196a06d..b51443600d 100644 --- a/crates/git-credential-nostr/src/lib.rs +++ b/crates/git-credential-nostr/src/lib.rs @@ -82,7 +82,8 @@ fn load_auth_tag() -> Result, String> { .or_else(|| git_config("nostr.authtag")); raw.map(|value| { - let parts: Vec = parse_auth_tag_parts(&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]" @@ -94,32 +95,6 @@ fn load_auth_tag() -> Result, String> { .transpose() } -/// Parse a NIP-OA `auth` tag into its component strings. -/// -/// Accepts both the JSON array form (`["auth","hex","","hex"]`) and the raw -/// Nostr tag form (`[auth,hex,,hex]`) — the unquoted, comma-delimited -/// serialization used inside Nostr events and commonly stored in `.env` files. -/// Matches the SDK's `parse_json_array` behavior so Git auth consumers accept -/// the same `BUZZ_AUTH_TAG` values as the CLI. -fn parse_auth_tag_parts(input: &str) -> Result, String> { - let trimmed = input.trim(); - // Fast path: well-formed JSON array. - if let Ok(parts) = serde_json::from_str::>(trimmed) { - return Ok(parts); - } - // Fallback: raw Nostr tag form [auth,hex,,hex]. - if trimmed.starts_with('[') && trimmed.ends_with(']') { - let inner = &trimmed[1..trimmed.len() - 1]; - let parts: Vec = inner.split(',').map(|p| p.trim().to_owned()).collect(); - if !parts.is_empty() { - return Ok(parts); - } - } - Err(format!( - "invalid NIP-OA auth tag: expected JSON array or raw tag form, got {trimmed:?}" - )) -} - #[derive(Default)] struct CredRequest { has_authtype_capability: bool, diff --git a/crates/git-sign-nostr/src/lib.rs b/crates/git-sign-nostr/src/lib.rs index 78dff37003..d316711200 100644 --- a/crates/git-sign-nostr/src/lib.rs +++ b/crates/git-sign-nostr/src/lib.rs @@ -487,12 +487,12 @@ fn load_auth_tag() -> Result, Error> { ))); } - // Parse: [\"auth\", \"\", \"\", \"\"] - // Accept both the JSON array form and the raw Nostr tag form - // ([auth,hex,,hex]) so this consumer matches the SDK's parse_json_array - // behavior and the CLI. - let arr = parse_auth_tag_array(&json_str) - .map_err(|e| Error::Fatal(format!("BUZZ_AUTH_TAG is not a valid auth tag: {e}")))?; + // Parse: ["auth", "", "", ""] + let arr: serde_json::Value = serde_json::from_str(&json_str) + .map_err(|e| Error::Fatal(format!("BUZZ_AUTH_TAG is not valid JSON: {e}")))?; + let arr = arr + .as_array() + .ok_or_else(|| Error::Fatal("BUZZ_AUTH_TAG must be a JSON array".to_string()))?; if arr.len() != 4 { return Err(Error::Fatal( "BUZZ_AUTH_TAG must have exactly 4 elements".to_string(), @@ -548,35 +548,6 @@ fn load_auth_tag() -> Result, Error> { Ok(Some((owner, conditions, sig))) } -/// Parse a NIP-OA `auth` tag into a JSON array of values. -/// -/// Accepts both the JSON array form (`["auth","hex","","hex"]`) and the raw -/// Nostr tag form (`[auth,hex,,hex]`) — the unquoted, comma-delimited -/// serialization used inside Nostr events and commonly stored in `.env` files. -/// Matches the SDK's `parse_json_array` behavior so Git signing accepts the -/// same `BUZZ_AUTH_TAG` values as the CLI. -fn parse_auth_tag_array(input: &str) -> Result, String> { - let trimmed = input.trim(); - // Fast path: well-formed JSON array. - if let Ok(serde_json::Value::Array(arr)) = serde_json::from_str::(trimmed) { - return Ok(arr); - } - // Fallback: raw Nostr tag form [auth,hex,,hex]. - if trimmed.starts_with('[') && trimmed.ends_with(']') { - let inner = &trimmed[1..trimmed.len() - 1]; - let arr: Vec = inner - .split(',') - .map(|p| serde_json::Value::String(p.trim().to_owned())) - .collect(); - if !arr.is_empty() { - return Ok(arr); - } - } - Err(format!( - "expected JSON array or raw tag form, got {trimmed:?}" - )) -} - /// Validate NIP-OA conditions string with structural parsing. /// /// Grammar (empty string is valid): @@ -2038,43 +2009,6 @@ Initial commit" ); } - /// load_auth_tag accepts the raw Nostr tag form `[auth,hex,,hex]`, - /// matching the SDK's parse_json_array fallback and the CLI. - #[test] - fn test_load_auth_tag_accepts_raw_nostr_form() { - let owner = "a".repeat(64); - let sig = "b".repeat(128); - - // Raw form with empty conditions (`,,`). - std::env::set_var("BUZZ_AUTH_TAG", format!("[auth,{owner},,{sig}]")); - let result = load_auth_tag(); - assert!( - matches!(result, Ok(Some(_))), - "raw Nostr tag form with empty conditions should be accepted" - ); - - // Raw form with conditions. - std::env::set_var("BUZZ_AUTH_TAG", format!("[auth,{owner},kind=9,{sig}]")); - let result = load_auth_tag(); - assert!( - matches!(result, Ok(Some(_))), - "raw Nostr tag form with conditions should be accepted" - ); - - // Raw form with surrounding whitespace. - std::env::set_var( - "BUZZ_AUTH_TAG", - format!(" [auth, {owner} , kind=9, {sig}] \n"), - ); - let result = load_auth_tag(); - assert!( - matches!(result, Ok(Some(_))), - "raw Nostr tag form with whitespace should be accepted" - ); - - std::env::remove_var("BUZZ_AUTH_TAG"); - } - /// Helper: sign a payload and return the armored signature fn sign_payload(secret_hex: &str, payload: &[u8], t: u64) -> String { let keypair = Keypair::from_seckey_str(SECP256K1, secret_hex).unwrap();