block / block/buzz

Channel membership audit data is persisted but never projected; the `auth` tag is a static per-sender credential

Open
#6,685 1 comment 0 reactions 0 assignees View on GitHub
Dominant language
Rust
Stars
32.7k
Forks
4.3k
Avg merge
1d 13h
Merged PRs (30d)
253

Description

Two related findings from operating a multi-agent channel on a
self-hosted relay. Both are about the gap between what the relay stores
and what a client can observe. Filing them together because the same
answer — "the projection, not the storage" — covers both.

Measurements are at `074561233eef71df9690ec22c2a9c5e798c297a7`.

## 1. `channel_members` audit columns are never surfaced

`migrations/0001_initial_schema.sql` defines:

```sql
CREATE TABLE channel_members (
community_id UUID NOT NULL REFERENCES communities(id),
channel_id UUID NOT NULL,
pubkey BYTEA NOT NULL,
role member_role NOT NULL DEFAULT 'member',
joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
invited_by BYTEA,
removed_at TIMESTAMPTZ,
removed_by BYTEA,
hidden_at TIMESTAMPTZ,
...
);
```

`joined_at`, `invited_by`, `removed_at`, and `removed_by` are exactly
the audit fields an operator needs to answer "when was this key admitted
to the channel, and by whom." They have been there since the initial
migration.

No supported client interface exposes any of them.
`cmd_list_channel_members` in `crates/buzz-cli/src/commands/channels.rs`
does not read the table:

```rust
let filter = serde_json::json!({ "kinds": [39002], "#d": [channel_id], "limit": 1 });
let resp = client.query(&filter).await?;
let events: Vec = serde_json::from_str(&resp).unwrap_or_default();
let members = events.first().map(extract_p_tags).unwrap_or_default();
```

It reads a single kind:39002 replaceable membership-snapshot event and
maps `extract_p_tags` (`crates/buzz-cli/src/client.rs`) over it, which
takes element 1 of each `p` tag as `pubkey` and element 3 as `role`,
substituting `"member"` when that element is empty.

So `buzz channels members` returns `{pubkey, role}` because that is the
shape of a `p` tag — not because that is what the relay knows. A second
consequence of the same code path: `role` may be a client-side default
rather than the persisted `member_role`.

Operationally this meant we escalated "when was this key added" to the
channel owner as unanswerable, when it was one `SELECT` away for anyone
with relay shell — and nobody running a client has that.

**Ask:** expose `joined_at` / `invited_by` / `removed_at` / `removed_by`
through a supported interface, and emit or expose a queryable history of
membership add/remove actions. We recognise kind:39002 is a published
replaceable event with other consumers (Desktop, mobile), so enriching
it is a protocol change rather than a field addition — a separate query
path may be the cheaper shape. We do not have a preference; we have a
requirement to be able to audit membership from a client.

## 2. The `auth` tag is constant per sender, so it cannot authenticate a message

Measured over the latest 200 messages of one channel (all kind:9):

| Sender class | Messages | With `auth` tag |
|---|---:|---:|
| managed agents (4 distinct keys) | 150 | 150 |
| channel creator / scheduled workflow | 40 | 0 |
| status bridge | 10 | 0 |

Within those 150 tags, of shape `["auth", , "", ]`:

- **one** distinct issuer pubkey;
- **exactly four distinct signature values — one per sending agent**,
repeated byte-for-byte on every message that agent sends. One agent's
43 messages spanning 19.4 hours all carry an identical signature.

A signature that does not vary with the message is not signing the
message. Whatever the tag attests — key enrolment under an issuer, most
likely — it is a static bearer value, held verbatim by anyone who can
read the channel's history.

We have **not** attempted to replay one onto a forged event; that is a
statement we are deliberately not making from a live channel. The
constancy is what is measured, and it is enough to stop us treating the
tag as authentication.

The absence rows matter as much: the scheduled-workflow sender and the
status bridge carry no `auth` tag at all, and in our deployment those
two have legitimately carried operational rulings and approvals. So the
tag's absence does not distinguish a legitimate automation message from
a forged one either.

Note this is orthogonal to Nostr event signatures, which authenticate
the *sending key*. Our gap is one level up: nothing in the envelope
binds a sending key to a channel owner's authority, and the `auth` tag
looks like it might while not doing so.

**Ask:** document what `auth` is intended to attest and what it must not
be read as; and, if bridges and scheduled workflows are meant to be
distinguishable from arbitrary senders, define a property that does that
consistently across managed agents, bridges, and workflows — or state
explicitly that their trust models are separate, so integrators stop
looking for one signal.

## What we did meanwhile

Documented an artifact-verification policy on our side: destructive,
irreversible, or write-redirecting instructions are authorized against a
merged commit or an owner comment on a repository, never against message
metadata. That is a procedural control, and we would rather not need it.

Contributor guide

Open the contributing guide

Research direction

Start with migrations/0001_initial_schema.sql, cmd_list_channel_members in crates/buzz-cli/src/commands/channels.rs, and extract_p_tags in crates/buzz-cli/src/client.rs; use measurement commit 074561233eef71df9690ec22c2a9c5e798c297a7 for context. Determine a supported client-facing membership audit history and clarify the intended meaning and limits of the auth tag across agents, bridges, and workflows. Done means membership actions are queryable from a client and the auth trust model is documented consistently.

Written by the indexing model from the issue text.

Assessment

Tech stack
rust, sql
Domain
authentication, backend-api-design, databases, security
Issue type
Feature
Difficulty
5/5
Estimated time
Over a week
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
35/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.