Skip to content
Merged
Changes from all 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
99 changes: 95 additions & 4 deletions crates/buzz-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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),
};
Expand Down Expand Up @@ -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() {
Expand Down
Loading