Self-referential p tag is stripped at signing: channels remove-member/add-member targeting your own key always fails with 400 missing p tag
- Dominant language
- Rust
- Stars
- 32.7k
- Forks
- 4.3k
- Avg merge
- 1d 13h
- Merged PRs (30d)
- 253
Description
## Summary
A `p` tag whose value equals the **signing key's own pubkey** is dropped between the SDK builder and the wire. The builder constructs it correctly; the signed event that reaches the relay does not contain it.
This makes every self-targeted channel-membership operation impossible to express:
- `buzz channels remove-member --pubkey ` → relay replies `400 invalid: missing p tag`
- `buzz channels add-member --pubkey --role ` → same shape (self-add is explicitly permitted relay-side, so this path is unreachable in practice)
The relay is behaving correctly — `extract_p_tag` returns `None` because the event genuinely has no `p` tag. The loss happens client-side.
Confirmed on `main` @ `7fc0cc82`, and reproduced with a clean rebuild of `buzz-cli` from that commit (byte-identical behaviour), so this is not a stale-binary artifact.
## Reproduction
Point `BUZZ_RELAY_URL` at any local HTTP listener that logs the POST body — `submit_event` POSTs the signed event JSON to `{relay_url}/events`, so the exact bytes are directly observable without a relay.
```bash
BUZZ_RELAY_URL=http://127.0.0.1:9999 \
BUZZ_PRIVATE_KEY= \
buzz channels remove-member --channel --pubkey # self
BUZZ_RELAY_URL=http://127.0.0.1:9999 \
BUZZ_PRIVATE_KEY= \
buzz channels remove-member --channel --pubkey # non-self
```
Captured `tags` arrays, same command, only the target differs:
```
kind 9001, target = SOME OTHER KEY
"tags":[["h",""],["p",""]] <- p tag present
kind 9001, target = THE SIGNER'S OWN KEY
"tags":[["h",""]] <- p tag absent
```
Same pattern on kind 9000:
```
kind 9000, target = SOME OTHER KEY
"tags":[["h",""],["p",""],["role","member"]]
kind 9000, target = THE SIGNER'S OWN KEY
"tags":[["h",""],["role","member"]] <- p tag absent
```
## Why the tests don't catch it
`crates/buzz-sdk/src/builders.rs:582-592` `build_remove_member` does construct both tags:
```rust
let tags = vec![
tag(&["h", &channel_id.to_string()])?,
tag(&["p", &target_pubkey.to_ascii_lowercase()])?,
];
Ok(EventBuilder::new(Kind::Custom(9001), "").tags(tags))
```
Its unit test `remove_member_happy_path` (builders.rs:2377-2383) asserts `has_tag(&ev, "p", pubkey)` and passes — but only because the target pubkey it uses is **not** the key it signs with. The self-referential case is untested, and that is the only case that fails.
I have not pinned the exact stripping site. `BuzzClient::sign_event` (`crates/buzz-cli/src/client.rs:588-613`) only injects the optional auth tag and counts auth tags, so the drop appears to be inside `EventBuilder::sign_with_keys`. Reporting the observable rather than guessing the internals.
## Impact
Any actor removing itself from a channel via the CLI is blocked. That includes the legitimate "an agent/identity should relinquish standing capability in a channel it no longer belongs in" case, which is exactly the ownership-hygiene operation #2928 discusses.
## Workaround
`buzz channels leave --channel ` (NIP-29 kind 9022). It carries only the `h` tag so nothing can be stripped, and the relay's own last-owner guard (`side_effects.rs:654-671`) still protects against orphaning the channel. Note it is a *self-only* primitive, so it does not cover the general case.
## Suggested fix
Either preserve self-referential `p` tags through signing, or — if the strip is deliberate upstream behaviour — have `build_remove_member` / `build_add_member` detect `target == signer` and fail client-side with a clear message pointing at `channels leave`, rather than emitting an event the relay must reject.
Adding a signer-is-target case to the builder tests would catch regressions here.
## Related but distinct
#3032 (threaded replies omit the parent author's `p` tag) is the same *symptom class* but a different mechanism: there the `p` tag is never constructed, being sourced only from explicit `@mentions`. Here the tag is constructed and then lost.
Contributor guide
Research direction
Start with crates/buzz-sdk/src/builders.rs:582-592 and its remove_member_happy_path test at lines 2377-2383, then inspect BuzzClient::sign_event in crates/buzz-cli/src/client.rs:588-613 and EventBuilder::sign_with_keys. Reproduce using the local HTTP listener and compare self-targeted and non-self tags. Done means the intended self-target behavior is established and covered by a signer-is-target regression test.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- rust
- Domain
- backend-api-design, cli, testing-qa
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 55/100