fix(nip-oa): accept raw Nostr tag form in parse_json_array (#4203)

## What

`BUZZ_AUTH_TAG` stored in the **raw Nostr tag form** `[auth,hex,,hex]`
(unquoted, comma-delimited — how an `auth` tag serializes inside a Nostr
event and how `.env` files commonly store it) was rejected by the CLI:

```
BUZZ_AUTH_TAG is malformed: invalid JSON: expected value at line 1 column 2
```

…and even when the CLI *could* parse it, it forwarded the raw string as
the `x-auth-tag` header, so the relay's `verify_auth_tag` (which expects
JSON) rejected it with `403 relay_membership_required`.

Two commits close both gaps.

## Commits

### 1. `fix(nip-oa): accept raw Nostr tag form in parse_json_array`

`parse_json_array` (`crates/buzz-sdk/src/nip_oa.rs`) only accepted
well-formed JSON arrays. Added a fallback: when strict JSON parsing
fails *and* the trimmed input is bracket-delimited, split on `,` and
treat each field as a string (empty field `,,` → empty string, matching
`["auth","hex","","hex"]`). All consumers (`parse_auth_tag`,
`verify_auth_tag`, the CLI, `buzz-acp`) benefit from one change at the
lowest layer.

### 2. `fix(cli): canonicalize BUZZ_AUTH_TAG to JSON before sending
x-auth-tag header`

The CLI stored the raw input string and sent it verbatim as the
`x-auth-tag` header (`client.rs:618`). Added `canonicalize_auth_tag` in
`buzz-sdk`: parse either form, re-serialize to canonical JSON. The CLI
now canonicalizes before storing as `auth_tag_json`, so the header is
always valid JSON regardless of input form.

Together: local parse + wire canonicalization means the raw form works
end-to-end.

## Why

The raw form `[auth,hex,,hex]` is exactly how an `auth` tag serializes
inside a Nostr event. That shape leaks into `.env` files and shell
variables because there's no canonical "stored form" outside an event.
The SDK + CLI should accept it rather than push quoting/conversion logic
onto every consumer (harnesses, agent shells, external tools).

## Security

Both changes are purely syntactic — they only change how a 4-element
string array is extracted and containerized. All downstream validation
is unchanged:
- `parse_auth_tag`: still checks exactly 4 elements, `"auth"` label,
64-char lowercase-hex pubkey, 128-char signature.
- `verify_auth_tag`: still reconstructs the preimage and verifies the
BIP-340 Schnorr signature against the owner pubkey.

No new attack surface — a malformed or forged tag is still rejected at
the same validation points.

## Tests

4 new tests in `nip_oa::tests`:
- `test_parse_auth_tag_raw_nostr_form` — raw form with conditions +
empty conditions
- `test_parse_auth_tag_raw_form_with_whitespace` — raw form with
surrounding whitespace
- `test_canonicalize_auth_tag_raw_to_json` — raw→JSON and JSON→JSON
normalization

All 25 `nip_oa` tests pass (21 existing + 4 new). `cargo fmt --check`
and `cargo clippy -p buzz-sdk -p buzz-cli` clean.

## Verification

Confirmed end-to-end against a live community relay
(`wss://hermesagent.communities.buzz.xyz`):
- **Before:** raw `BUZZ_AUTH_TAG` → CLI parse error, or `403
relay_membership_required` if somehow parsed.
- **After:** raw `BUZZ_AUTH_TAG` → CLI parses it, canonicalizes to JSON
for the header, relay accepts via NIP-OA owner delegation, `buzz
channels members` returns the full roster.

## Context

Originated from a community investigation where agent-side relay access
was failing because the harness-exported `BUZZ_AUTH_TAG` (raw Nostr
form) was rejected by the CLI (expecting JSON). This removes the
impedance mismatch at the source.

---------

Signed-off-by: amanning3390 <adam.manning@pro-serveinc.com>
Signed-off-by: Tyler <109685178+tlongwell-block@users.noreply.github.com>
Signed-off-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
Co-authored-by: Tyler <109685178+tlongwell-block@users.noreply.github.com>
This commit is contained in:
mr-r0b0t.eth
2026-08-01 22:08:15 -04:00
committed by GitHub
co-authored by npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d Tyler
parent ac4fa13b8e
commit 89bf03c05d
+95 -4
View File
@@ -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,<hex>,<conditions>,<hex>]` (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::<serde_json::Value>(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,17 +1823,28 @@ 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()
))
})?;
(Some(tag), Some(json.clone()))
// 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),
};
@@ -1835,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<String> = 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<String> = 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() {