Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
7 changes: 6 additions & 1 deletion crates/buzz-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
};
Expand Down
124 changes: 117 additions & 7 deletions crates/buzz-sdk/src/nip_oa.rs
Original file line number Diff line number Diff line change
Expand Up @@ -122,14 +122,34 @@ fn is_lowercase_hex(c: char) -> bool {
}

fn parse_json_array(s: &str) -> Result<Vec<Value>, 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();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Add the required DCO sign-off

The reviewed commit object has no Signed-off-by trailer, so the repository's required DCO check will fail and prevent this change from merging; recreate or rebase the commit with --signoff.

AGENTS.md reference: AGENTS.md:L111-L111

Useful? React with 👍 / 👎.

// Fast path: well-formed JSON array.
if let Ok(Value::Array(arr)) = serde_json::from_str::<Value>(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<Value> = inner
.split(',')
.map(|part| Value::String(part.trim().to_owned()))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Parse the raw form in Git auth consumers

When BUZZ_AUTH_TAG uses the newly accepted raw form, agent Git operations still reject it locally: git-credential-nostr::load_auth_tag calls serde_json::from_str at crates/git-credential-nostr/src/lib.rs:84-86, and git-sign-nostr::load_auth_tag does the same at crates/git-sign-nostr/src/lib.rs:490-492. These tools receive the same harness-injected environment variable, so fetch/push and signing fail before reaching the relay even though the CLI now accepts the credential. Fresh evidence in this target is that only the CLI transport was canonicalized; either canonicalize the injected value centrally or teach both Git parsers the same raw syntax.

Useful? React with 👍 / 👎.

.collect();
if !arr.is_empty() {
return Ok(arr);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Canonicalize raw tags before HTTP transport

When the newly supported raw form is supplied to buzz, parsing succeeds here, but run() still stores the original string in BuzzClient::auth_tag_json, which with_auth_tag() sends verbatim as x-auth-tag. During a client-first or rolling upgrade, relays predating this parser change still require JSON and reject every membership-scoped HTTP operation even though the CLI accepted and verified the tag locally; carry a canonical JSON serialization of arr for transport instead of the raw input.

Useful? React with 👍 / 👎.

}
}

Err(SdkError::InvalidInput(format!(
"invalid JSON: expected array, got {trimmed:?}"
)))
}

/// Compute a NIP-OA `auth` tag authorizing `agent_pubkey` under `conditions`.
Expand Down Expand Up @@ -298,6 +318,33 @@ pub fn parse_auth_tag(json_str: &str) -> Result<Tag, SdkError> {
.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<String, SdkError> {
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::*;
Expand Down Expand Up @@ -426,6 +473,69 @@ 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<String> =
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<String> =
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() {
Expand Down