9dfa06ffee
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
Signed-off-by: cls_宁波本机 <908705107@qq.com>
1111 lines
72 KiB
Markdown
1111 lines
72 KiB
Markdown
# Multi-Tenant Buzz Relay: A Formal Specification
|
||
|
||
`draft`
|
||
|
||
## Abstract
|
||
|
||
This document specifies the data and authorization model that lets one shared
|
||
Postgres instance, served by N stateless relay processes, host M independent
|
||
**communities** without one community observing or acting on another, and gives
|
||
a formal proof of its safety properties. It proves two families of property:
|
||
**isolation** — a community is *non-interfering* with every other community
|
||
across the relay's logical interface (query results, authorization decisions,
|
||
emitted errors, and audit-chain contents) — and **authorization soundness** — no
|
||
credential, signature, or forged event lets an actor cross a community boundary.
|
||
|
||
Today a Buzz relay *process* is the security boundary: one `DATABASE_URL`, one
|
||
relay keypair, one relay-global `relay_members` table, with `channel_id` (the
|
||
`h` tag) as the only sub-relay locality. The model proven here demotes the relay
|
||
process to stateless compute and elevates a new **community** entity to the
|
||
tenant/security boundary, carried as a `community_id` on every scoped row. That
|
||
move collapses a process-level boundary into a row-level one. The contribution of
|
||
this document is the formal characterization that the collapse loses nothing —
|
||
proven *relative to* explicitly stated axioms about Postgres row-level security,
|
||
Schnorr/NIP-98, a collision-resistant hash, and the relay's own
|
||
`channel_id → community_id` resolution.
|
||
|
||
The architecture is not novel as a *pattern*: row-level multi-tenancy with a
|
||
discriminator column and row-level security (RLS) is established practice (see
|
||
§Prior Art). The contribution is the **formal treatment** — stating tenant
|
||
isolation as non-interference encoded as a label-flow invariant (not a
|
||
`WHERE community_id = $1` predicate), mechanizing it (TLA+ for the
|
||
concurrency/serving model, Tamarin for the authorization protocol under a
|
||
Dolev-Yao adversary), and gating every invariant on a mutation test so the proof
|
||
is non-vacuous.
|
||
|
||
## Scope and Non-Goals
|
||
|
||
This specification proves **safety** ("nothing bad happens"). It deliberately
|
||
does **not** prove:
|
||
|
||
- **Liveness or performance.** That a query meets a latency budget, or that a hot
|
||
partition does not throttle, is empirical — characterized by the perf rig, not
|
||
by theorem.
|
||
- **Postgres's internal correctness.** RLS enforcement, MVCC snapshot isolation,
|
||
and `ON CONFLICT DO NOTHING` semantics are trusted and stated as axioms
|
||
(§Axioms). We prove our *composition* on top of them; we do not reprove them.
|
||
- **Cryptographic primitives.** Schnorr signature unforgeability (BIP-340), the
|
||
NIP-98 request binding, and second-preimage resistance of the event-id hash are
|
||
the Tamarin model's equational theory, not reproven.
|
||
- **Physical-resource isolation.** Communities share an id space, time
|
||
partitions, a connection pool, and a CPU. The proof covers the *logical*
|
||
interface; bandwidth-limited physical channels are a named, explicit carve-out
|
||
(§Isolation Boundary, class C1).
|
||
- **Above-the-interface client leakage.** The proof boundary is the relay's
|
||
observational interface. If a client (a multi-tenant UI, an NIP-19 `nevent`
|
||
share, a screenshot, a leaked log) surfaces a user's own event ids from
|
||
community A while that user is also a member of B, the user then holds an A-id
|
||
out-of-band and can probe the existence oracle from a B connection. The
|
||
composite-index closure (A-RLS-5) means the probe still reveals nothing — B's
|
||
write at that id is a fresh `(community_id, id)` key — but we name this surface
|
||
explicitly: closing it for any *weaker* index shape is above the interface and
|
||
is the client's obligation, not the relay's.
|
||
|
||
Stating this boundary is part of the claim. "Provably isolated" without naming
|
||
the trust boundary does not survive scrutiny; "isolation is machine-checkable
|
||
relative to these stated axioms, with every shared logical channel either closed
|
||
in-model or closed by a named axiom" does.
|
||
|
||
## System Model
|
||
|
||
A **community** `C` is the tenant/security boundary. It owns: a set of channels,
|
||
a membership relation, a signing keypair, a token namespace, workflows, an audit
|
||
hash chain, and the messages scoped to it. A community is a durable row in a
|
||
`communities` table; creating one is an INSERT, never DDL.
|
||
|
||
The shared store holds three tiers:
|
||
|
||
- One **canonical message log** `L`: an append-only table keyed by
|
||
`(community_id, created_at, id)`. Every message carries the `community_id` of
|
||
the community it belongs to. Append is idempotent
|
||
(`ON CONFLICT (community_id, created_at, id) DO NOTHING`).
|
||
- A **tenant-scoped control plane**: relational, ACID tables — `channels`,
|
||
`channel_members`, `api_tokens`, `workflows`, audit entries — each carrying
|
||
`community_id`, kept relational because authorization needs synchronous current
|
||
state.
|
||
- **Disposable projections**: mentions, thread metadata, reactions, full-text
|
||
search — each `community_id`-keyed, rebuildable from `L`, never authoritative.
|
||
|
||
A **relay process** is stateless compute. It owns no community data; any process
|
||
can serve any community, and N processes share the store.
|
||
|
||
A **connection** is bound to an **actor** (a pubkey, authenticated via NIP-42 on
|
||
WebSocket or via a NIP-98-minted bearer token on REST). Every connection
|
||
operation is evaluated under a **TenantContext** `⟨community_id, actor⟩`. The
|
||
`community_id` is **resolved by the relay**, never read from the client-supplied
|
||
`h` tag or claimed community. For a **channel-bearing** operation it is the
|
||
community of the channel the operation names (`resolve : channel_id →
|
||
community_id`, an indexed lookup the relay owns under the same transaction
|
||
snapshot as the operation), and the connection's **host** must agree with it —
|
||
an A-host presenting a B-channel event is rejected fail-closed, never acted on as
|
||
B. For a **channel-less** operation (profiles, DMs,
|
||
long-form, status, read-state, lists — no `h` tag) it is the community bound to
|
||
the connection's **host** at establishment (`resolve_host : host → community_id`,
|
||
lifting today's per-relay URL identity up to the community); an unmapped host
|
||
binds to no community and the connection is rejected fail-closed. The composed
|
||
resolver is `ResolveTenant(req, event)` (see P-RESOLVE-HOST).
|
||
|
||
Two operation classes act on the store:
|
||
|
||
- **Serve(ctx, q)** — a read (REQ / REST GET, including direct `ids` lookup,
|
||
`#e`/`#a` tag filters, metadata/member discovery, and projection reads). Returns
|
||
rows and derived results matching `q`, confined to `ctx.community_id`.
|
||
- **Accept(ctx, e)** — a write (EVENT / REST POST). Appends `e` to `L` (or mutates
|
||
control-plane state) under `ctx.community_id`, after an authorization decision
|
||
over current control-plane state.
|
||
|
||
A community is either **allowlisted** or **open**. An allowlisted community admits
|
||
actors only via a signed NIP-43 member list (§Authorization, S7); an **open**
|
||
community (one with no member-pubkey allowlist) auto-registers any authenticated
|
||
npub on AUTH — but the registration is stamped to the **host-resolved** community,
|
||
never a client-claimed one, so "open" widens *who* may join, never *which*
|
||
community they join. Two further control-plane writes are first-class: **channel
|
||
creation** stamps a fresh channel atomically from `HostCommunity[host]` (the client
|
||
supplies no community id, and the stamp is immutable thereafter), and **no-`#h`
|
||
reads** — the kinds-only feed read and the `#e`-only aux read (reactions, edits,
|
||
deletes, thread metadata) — resolve their community from the connection's host and
|
||
are gated on host-community admission like every other channel-less operation.
|
||
These surfaces are modeled, not asserted: see §Isolation (I5) and
|
||
§Authorization (S5/S8).
|
||
|
||
**The resolved `community_id` is the sole tenant authority.** The `h` tag on a
|
||
wire event is a *routing hint* a client asserts; it is never the commit point of
|
||
tenancy. This is the **confused-deputy** hazard (Hardy 1988): the relay holds
|
||
broad authority over a shared DB, and a client supplies an ambient name; if the
|
||
relay acts on its broad authority under the client's name, the client escapes its
|
||
community. The defense is capability discipline — authority is bound to the
|
||
*resolved object* `(community_id, channel_id, capabilities)`, never to a
|
||
caller-supplied tag. The model treats the `h` tag as adversary-controlled and
|
||
proves it is not load-bearing (Theorem I2 / S1).
|
||
|
||
## Isolation Boundary
|
||
|
||
Tenant isolation is stated as **non-interference**: for any two executions equal
|
||
on community B's inputs and initial B-visible state, B's observable outputs are
|
||
equal regardless of community-A-only actions (Goguen–Meseguer 1982; the
|
||
concurrent variant is observational determinism). A `WHERE community_id = $1`
|
||
row-return invariant is only *one projection* of this theorem — it implies
|
||
nothing about timing, errors, uniqueness collisions, projection rebuild, or the
|
||
auth gate. Two execution traces cannot be expressed directly in TLA+; the
|
||
standard tractable encoding is a **label-flow invariant**: every state element
|
||
(message row, membership, projection cell, in-flight query, emitted error, audit
|
||
entry) carries the community label it originated from, and the single-run safety
|
||
invariant is *"no high-labeled value ever flows into a low-labeled observation."*
|
||
This encoding forces enumeration of every state element's label, which is what
|
||
catches the projection-rebuild and error-surface channels that a predicate hides.
|
||
|
||
Shared channels split into two classes:
|
||
|
||
**(C1) Bandwidth-limited physical channels — declared, out of scope.** Buffer
|
||
cache, autovacuum, planner statistics, partition right-edge throughput, and
|
||
connection-pool tail latency are shared. A co-tenant can measure these as timing;
|
||
the channel is bandwidth-bounded and orthogonal to the threat model
|
||
(cross-tenant data leak, privilege escalation, audit forgery). We declare this
|
||
class as git-on-s3 declares physical pack pruning: named, with a deferred future
|
||
bandwidth bound. **We do not claim timing non-interference.**
|
||
|
||
**(C2) Logical channels — in scope, enumerated, each closed.** These are *not*
|
||
carve-outs; a B-scoped connection can observe them at the interface, so each must
|
||
be closed in-model or by a named axiom:
|
||
|
||
1. **Event-id existence oracle.** `INSERT … ON CONFLICT DO NOTHING` on the
|
||
content-hash id: a B-writer observing zero rows affected learns *some* tenant
|
||
wrote that id. Closed by **A-RLS-5** (§Axioms): the uniqueness constraint is
|
||
composite over `(community_id, …, id)`, so a B-scoped write at an id A already
|
||
holds gets a *fresh* key, not a conflict — B's rows-affected count is a
|
||
function of B's own state alone, never A's. **A_HASH** is the *supporting*
|
||
axiom: it additionally rules out the adversarial-search variant (B cannot
|
||
*find* a fresh event hashing to a chosen id). Note the residual: A_HASH says
|
||
nothing about ids B already *knows* out-of-band (NIP-19 `nevent` shares,
|
||
multi-tenant client UIs that surface a user's own ids across communities) —
|
||
that exposure is closed by the composite index, not the hash, and any
|
||
above-the-interface client surface that leaks a user's A-ids while they are
|
||
also in B is a named residual in §Scope and Non-Goals, not a relay-closed channel.
|
||
2. **Constraint-violation error surface.** Postgres errors can leak constraint
|
||
names, conflicting tuples, and columns. Closed by a fixed **sanitized error
|
||
alphabet** and the structural obligation that the relay emits only errors from
|
||
that alphabet (an implementation code-fence, proven relative to it).
|
||
3. **Projection rebuild path.** A rebuild touches every community's events by
|
||
construction. Closed by the invariant that rebuild writes server-side
|
||
projection tables only and **never serves rows** to a tenant-scoped
|
||
connection; a tenant query concurrent with a rebuild sees its own rows or none.
|
||
4. **Unauthenticated global surface.** The NIP-11 relay information document at
|
||
`/` is unauthenticated and tenant-unscoped by construction; no B-scoped
|
||
connection, no `c.scope`, no label exists, so the labeling invariant does not
|
||
reach it. Closed by a **typed-input code-fence**: the doc-build function
|
||
consumes only relay-static configuration types — no database handle, no tenant
|
||
context, no audit service. Today `RelayInfo::build`
|
||
(`crates/buzz-relay/src/nip11.rs:122`) takes only static inputs and
|
||
`nip11_facts` (`:176`) reads only `state.config`/`state.relay_keypair`, so the
|
||
surface is clean — but by *current code*, not by the proof; adding a
|
||
`total_events` counter is one `&PgPool` argument away and the labeling
|
||
invariant catches none of it. This is the same enforcement class as the Σ_err
|
||
alphabet (C2.2) — a typed constraint at a seam, lintable over `build`'s
|
||
signature — but disjoint: Σ_err governs *what symbols leave on authenticated
|
||
paths*, C2.4 governs *what state populates unauthenticated paths*. Any future
|
||
unauthenticated relay-level endpoint (NIP-66 monitoring, health probes that
|
||
expose counters) lives under C2.4 by default.
|
||
|
||
The numeric COUNT (NIP-45) and EOSE cardinality channels are deliberately *not*
|
||
on this list: they are closed by the same label propagation as event rows (a
|
||
count is `|{B-labeled rows matching the filter}|`), so they belong in the typed
|
||
interface, not as distinct C2 mechanisms. The C2 list is the index of *distinct
|
||
closure mechanisms* — A_HASH, the Σ_err alphabet, the rebuild behavioral
|
||
invariant, and the C2.4 typed-input fence — not the index of channels.
|
||
|
||
**(C3) Historical writes after revocation — declared, out of scope.** The
|
||
admission fence (I5, `Inv_AdmissionFence`) governs **current** capability: it
|
||
proves that no membership or channel-less read capability survives for an actor
|
||
not currently admitted to that community. Revocation (`RevokeMember`) removes the
|
||
current `admittedMembers` row and therefore the capability, but it does *not*
|
||
relabel or delete rows the actor wrote while admitted — those historical writes
|
||
retain their original community label and remain present. This is sound and
|
||
intended: the property we mechanize is "current membership and read capability
|
||
track current admission," not "writes are retroactively un-admitted." We declare
|
||
this as C1 declares physical timing: named, with retroactive-write redaction left
|
||
to an operator data-lifecycle surface outside the isolation model. **We do not
|
||
claim historical writes are revoked when a member is revoked.**
|
||
|
||
### The typed observational interface
|
||
|
||
The non-interference theorem is stated *over an interface*: the exclusive set of
|
||
observations a **B-scoped connection** (one whose *resolved* community is B) can
|
||
make. Enumerating this set is load-bearing — a `WHERE community_id = $1` invariant
|
||
silently omits cardinality, error, status-code, and global-document channels.
|
||
**Any observation not in this set is either C1 (declared) or a model violation.
|
||
There is no third category.** Each entry below names its code seam so the TLA+
|
||
model, the Tamarin model, and the red-team audit reference the same surface.
|
||
|
||
**O.WS — WebSocket transport** (`crates/buzz-relay/src/protocol.rs:180-215`). The
|
||
relay emits exactly these client-bound messages:
|
||
|
||
- **`O.WS.EVENT(sub_id, event)`** — a delivered Nostr event. Its `content` is
|
||
high-labeled at the row's community; `e`/`p`/`q` tag references inherit the
|
||
row's label (they may *name* globally-existing ids, but the row reaches B only
|
||
if B-labeled).
|
||
- **`O.WS.EOSE(sub_id)`** — end-of-stored-events. The *count* of preceding events
|
||
is the cardinality of B-visible rows matching the filter; it must be a function
|
||
only of B-labeled state.
|
||
- **`O.WS.OK(event_id, accepted, message)`** — write ack. `event_id` echoes the
|
||
submission (benign); `accepted` is a function of (validity, signature, resolved
|
||
scope, dedup) over B-labeled state only; `message` is drawn from the sanitized
|
||
alphabet `Σ_err` (the C2.2 seam — the current `String` type admits any value).
|
||
- **`O.WS.NOTICE` / `O.WS.CLOSED`** — out-of-band and sub-termination strings;
|
||
same `Σ_err` constraint (`connection.rs:307,326`).
|
||
- **`O.WS.AUTH(challenge)`** — NIP-42 challenge; a fresh nonce, function of relay
|
||
randomness only, never of any tenant's writes.
|
||
- **`O.WS.COUNT(sub_id, n)`** — NIP-45 count (`protocol.rs:213`). `n` is a numeric
|
||
channel: even under row confinement, a count touching non-B rows leaks A's
|
||
cardinality. The rule: `n` is the count of B-labeled rows matching the filter,
|
||
full stop.
|
||
|
||
**O.REST — HTTP API surface.**
|
||
|
||
- **`O.REST.BODY`** — JSON response: row content, projection results, and audit
|
||
entries (`crates/buzz-audit/src/service.rs:get_entries`) must all be B-labeled.
|
||
- **`O.REST.META`** — status code, headers, structured error envelope. The status
|
||
code is itself observable: `IngestError::{Rejected,AuthFailed,Internal}` →
|
||
`400/401|403/500` (`handlers/ingest.rs:138-146`) must be a function of
|
||
{request, B-labeled state}, never of A's state.
|
||
|
||
**O.AUTH — auth verdict.** The Boolean "did this pass the gate," observable via
|
||
`O.WS.OK.accepted` and `O.REST.META.status`. It is a function of (submitted
|
||
credentials, server-side resolution `channel_id → community`, B-labeled
|
||
membership/token/policy state). The *claimed* community never appears in this
|
||
function — only the *resolved* one. (Theorem S1.)
|
||
|
||
**O.AUDIT — audit chain.** `get_entries(scope=B)` returns only B-chain entries;
|
||
`verify_chain(scope=B)` is decidable from B-labeled entries alone; compromise of
|
||
A's chain key does not affect B's. (Theorem S4.)
|
||
|
||
**O.NIP11 — relay info document (`/`).** Global and unauthenticated, so by
|
||
construction it *cannot* be tenant-labeled — therefore its content must be a
|
||
function of relay-static configuration only. `supported_nips` is fine;
|
||
`total_events` would be a cross-tenant leak.
|
||
|
||
Everything outside this set is **C1** (wall-clock latency, buffer-cache hit rate,
|
||
planner choice, autovacuum, partition right-edge throughput, pool saturation,
|
||
memory/fd/scheduler effects — declared, bandwidth-bounded) or **closed by axiom**
|
||
(the `INSERT … ON CONFLICT DO NOTHING` id-existence oracle at `event.rs:151`,
|
||
closed by A_HASH).
|
||
|
||
### Label-propagation rules
|
||
|
||
The labeling discipline that makes non-interference a *single-run* safety
|
||
invariant (every state element carries a community label; the invariant is "no
|
||
high-labeled value flows into a low observation"):
|
||
|
||
- **L1 — Source label.** Every event row carries `community_id`, set by the
|
||
server-side resolver at insert time via `ResolveTenant`. For a **channel-bearing**
|
||
event the label is `resolve(channel_id)`; for a **channel-less** event
|
||
(`kind:0` profiles, `1059` DMs, `30023`/`30174`/`30315`/`30078`, lists —
|
||
`channel_id = NULL`) the label is the connection's host-bound community
|
||
`resolve_host(connection.host)`, with the token stamp required to *agree* (never
|
||
to *supply* it). The `h` tag is **not** the label source, and neither is the
|
||
client-claimed community. (Resolution is a fence — see P-RESOLVE and
|
||
P-RESOLVE-HOST.)
|
||
- **L2 — Projection inheritance.** Each projection row (`event_mentions`,
|
||
`thread_metadata`, `reactions`, FTS) inherits its source event's label; rebuild
|
||
= replay of labeled source rows, so rebuilds preserve labels by construction.
|
||
- **L3 — Audit partitioning.** N independent chains, one per community label;
|
||
community-scoped writers only; no cross-chain reference, no global "latest" head.
|
||
- **L4 — Auth-verdict label.** The allow/deny verdict carries the **resolved**
|
||
community label, never the **claimed** one.
|
||
- **L5 — Token stamp.** A NIP-98 token has exactly one community stamp, assigned
|
||
at mint from the resolved channel set; a mint resolving to >1 community is
|
||
rejected fail-closed (S2). The token's label *is* its stamp.
|
||
- **L6 — Connection scope.** A connection has exactly one resolved community at a
|
||
time, **bound from its host** (`resolve_host(connection.host)`) at establishment
|
||
before any handler runs; re-scoping requires a new connection to a different
|
||
host; all its observations inherit that scope. An unmapped host binds to no
|
||
community and is rejected fail-closed (P-RESOLVE-HOST), never defaulted.
|
||
- **L7 — Error label.** A finite, statically-declared alphabet `Σ_err` governs the
|
||
*authenticated, tenant-scoped* WS error surface: every `O.WS.OK.message`,
|
||
`O.WS.NOTICE`, and `O.WS.CLOSED` is drawn from it (the 9 NIP-01-reachable
|
||
prefixes — `auth-required`, `restricted`, `invalid`, `duplicate`, `pow`,
|
||
`rate-limited`, `blocked`, `error`, `frame-too-large`). Emitting a non-`Σ_err`
|
||
string is a structural code violation (the C2.2 code-fence — a lint, not a model
|
||
property). Today `RelayError::Database(#[from] buzz_db::DbError)` (`error.rs:11`)
|
||
is the seam. The *unauthenticated/REST* error surface (`not-found`,
|
||
`bad-request`) is a **distinct fence** — C2.4's typed-input constraint, not
|
||
`Σ_err` — because it has no tenant scope and no label, so it sits outside the
|
||
labeling invariant entirely. One Rust enum may back both for ergonomics, but the
|
||
model treats them as two alphabets closed by two mechanisms.
|
||
- **L8 — No injection.** Per L7, A-labeled state cannot influence *which* `Σ_err`
|
||
symbol B observes.
|
||
|
||
In one line: *for every reachable state `s`, every B-scoped connection `c`, and
|
||
every observation `o ∈ O.* ∪ Σ_err` emitted to `c`, `o` is a deterministic
|
||
function of (B-labeled state in `s`, `c`'s request history, relay-static config);
|
||
no A-labeled element is an input to `o`.* This is what the TLA+ model encodes —
|
||
strictly stronger than row-equality, because it forces enumeration of every
|
||
observation channel above.
|
||
|
||
## Axioms
|
||
|
||
The proof holds *relative to* the following. Each is a documented property of
|
||
Postgres / the crypto primitives, and a testable assumption admitted per
|
||
deployment (§Conformance).
|
||
|
||
### Row-level security (the fail-closed backstop)
|
||
|
||
Postgres RLS is fail-closed **only** under specific configuration (PostgreSQL
|
||
manual, "Row Security Policies"). We state the configuration as obligations:
|
||
|
||
- **(A-RLS-1)** Every queryable tenant-bearing table has RLS enabled with a
|
||
restrictive policy `community_id = current_setting('app.community_id')::uuid`,
|
||
and no permissive policy that admits cross-tenant rows.
|
||
- **(A-RLS-2)** The relay's request role is non-superuser, `NOBYPASSRLS`, and not
|
||
the table owner unless `FORCE ROW LEVEL SECURITY` is set (owners and `BYPASSRLS`
|
||
roles bypass policies).
|
||
- **(A-RLS-3)** `app.community_id` is set transaction-locally (`SET LOCAL`) before
|
||
any query and cleared at transaction end. Pooled connections must not retain or
|
||
combine tenant context across requests.
|
||
- **(A-RLS-4)** `SECURITY DEFINER` and `leakproof`/user-defined functions in the
|
||
request path are audited as part of the trusted boundary: a `leakproof`
|
||
function may be evaluated *ahead of* the RLS check, and a `SECURITY DEFINER`
|
||
function can read data unavailable to the caller.
|
||
- **(A-RLS-5)** Uniqueness and foreign-key constraints include `community_id`, so
|
||
a conflict outcome or a dangling reference cannot reveal or reach another
|
||
community.
|
||
|
||
A query that fails to set `app.community_id` matches the policy predicate over
|
||
NULL → no rows, never all rows. This is what makes a missed *application*
|
||
predicate fail closed rather than leak (Theorem I4).
|
||
|
||
### Concurrency, crypto, and resolution
|
||
|
||
- **(P-APPEND)** `INSERT … ON CONFLICT (community_id, created_at, id) DO NOTHING`
|
||
commits a row iff no row with that key exists; concurrent appends are
|
||
serializable under MVCC; a committed row is never silently overwritten; a read
|
||
sees a consistent snapshot.
|
||
- **(P-SIG)** An actor cannot produce a valid Schnorr signature (BIP-340) for a
|
||
pubkey whose secret key it does not hold. A NIP-98 event's `u`/`method`/
|
||
`payload` tags bind it to exactly one HTTP request and are non-transferable to a
|
||
different request.
|
||
- **(P-RESOLVE)** `resolve : channel_id → community_id` is a total function over
|
||
existing channels, computed from control-plane state under the operation's
|
||
transaction snapshot. A channel belongs to exactly one community
|
||
(`channels.community_id` NOT NULL); resolution never returns a community a
|
||
channel does not belong to. **A channel's community is set at creation and never
|
||
reassigned: `channels.community_id` is immutable after insert.** Both mechanized
|
||
models encode this — Tamarin as the persistent `!ChannelCommunity` fact
|
||
(`MultiTenantAuth.spthy:51`, once-true-always-true), TLA+ as the
|
||
`ChannelCommunity` CONSTANT function (`MultiTenantRelay.tla:107`). Any future
|
||
re-tenanting would be a separate axiomatic admission with its own audit
|
||
discipline and re-verification of S1/S2 (and I1–I5).
|
||
- **(P-RESOLVE-HOST)** `resolve_host : host → community_id ∪ {⊥}` is the upstream
|
||
binding for **every** connection, lifting today's per-relay URL identity one
|
||
level up to the community. A connection's community is `resolve_host(host)`,
|
||
fixed at establishment; the URL the client connects to *is* the selector,
|
||
exactly as a relay URL is today. `ResolveTenant(req, event)` composes the two:
|
||
if the event has an `h` tag, require `resolve(h) = resolve_host(host)` (the
|
||
host/channel **agreement** fence — an A-host presenting a B-channel event is a
|
||
confused deputy on the host axis and is rejected fail-closed, never acted on as
|
||
B) and store that community; if it has none, store
|
||
`community_id = resolve_host(host), channel_id = NULL`. Two fences hold for both
|
||
paths. **Fail-closed:** a host/channel disagreement (incl. an unmapped host
|
||
resolving to `⊥`, which can never equal a real channel community) is rejected
|
||
generically (`auth-required`/`restricted`), never bound to a default tenant —
|
||
`resolve_host` is partial and the absence/disagreement of a binding is a reject,
|
||
not a fallback. **Host wins:** a NIP-98 token's community stamp (L5) must *agree
|
||
with* the host-derived community; a token that disagrees is rejected, so the
|
||
confused-deputy fence (I2) is intact with authority binding to the host-resolved
|
||
object. Tamarin encodes this as the persistent `!HostCommunity` fact
|
||
(`MultiTenantAuth.spthy`): the channel-less use rule fires only when token stamp
|
||
and host community coincide (witness `ChannelLessResolved`, lemma
|
||
`channelless_use_confined_to_host_community`), and the **channel-bearing** use
|
||
rule fires only when the channel mapping and the host community coincide (witness
|
||
`ChannelBearingResolved(tok, used_comm, host, host_comm)`, lemma
|
||
`channelbearing_use_agrees_with_host` asserting `used_comm = host_comm`). TLA+
|
||
encodes it as the `HostCommunity` resolver (with a `⊥` sentinel for unmapped
|
||
hosts) and an `Inv_HostBindingFence` invariant quantifying over **every** accepted
|
||
write — channel-bearing and channel-less — *and* every observable duplicate/no-op
|
||
outcome, that its stored community equals its originating host's mapping. The
|
||
duplicate/no-op path carries the same obligation because it is client-observable
|
||
write surface (the `Duplicate` result exposes the scoped existence/conflict rows):
|
||
an A-host presenting a B-channel id is fenced before any conflict lookup, so it
|
||
cannot learn whether that id exists in B. At N = 1 this is byte-identical to
|
||
today: one host → the one community, every connection lands there, nothing
|
||
client-observable changes.
|
||
- **(A_HASH)** The event id `sha256(canonical event)` is second-preimage
|
||
resistant: an actor cannot find a distinct event hashing to a chosen id. (NIP-01
|
||
already relies on this; we cite it the way git-on-s3 cites its CAS axiom.)
|
||
- **(P3)** *NIP-98 mint freshness.* A NIP-98 mint event (kind:27235) is accepted
|
||
at most once. The implementation enforces this with two checks: a `created_at`
|
||
within ±60s of server time (`buzz-auth/src/nip98.rs:77-83`,
|
||
`TIMESTAMP_TOLERANCE_SECS = 60`) **and** a seen-set keyed on event id
|
||
(`buzz-relay/src/api/bridge.rs::check_nip98_replay`), whose cache TTL (120s,
|
||
`state.rs:407`) is 2× the window so a mint valid at either edge stays tracked
|
||
for the full window. The Tamarin model abstracts the window as a fresh nonce on
|
||
`~time` (`MultiTenantAuth.spthy:91`), which over-approximates the
|
||
implementation by treating every mint as structurally unique; the spthy comment
|
||
at `:84-86` references this obligation as "P3."
|
||
|
||
P-RESOLVE is the load-bearing *application* assumption for channel-bearing events
|
||
and P-RESOLVE-HOST is its channel-less counterpart — together the fence the
|
||
`h`-tag and claimed-community adversary cannot circumvent. A-RLS-1..5 are the
|
||
load-bearing *backstop*.
|
||
|
||
## Safety Theorems
|
||
|
||
### Isolation (mechanized in TLA+)
|
||
|
||
- **NI (Non-interference, master).** For every reachable state and every B-scoped
|
||
observation, the observed value is a function only of B-labeled state — no
|
||
high-labeled value flows into a low-labeled observation. I1–I5 are the specific
|
||
flows it rules out, each independently mutation-tested non-vacuous.
|
||
- **I1 (Read confinement).** Every row a `Serve` returns — including direct-id and
|
||
`#e`/`#a` lookups — is `ctx.community`-labeled.
|
||
- **I2 (Resolution fence).** `ctx.community = resolve(channel_id)` for
|
||
channel-bearing events and `resolve_host(host)` for channel-less ones, never the
|
||
`h` tag, the claimed community, or the token stamp; an adversary `h = C' ≠
|
||
resolve = C` cannot widen what is served or accepted. The **host axis** is fenced
|
||
on both paths: a channel-less write over host A cannot land in community B, and a
|
||
channel-bearing op over host A on a B-channel is rejected rather than acted on as
|
||
B — including the **duplicate/no-op outcome**, so an A-host cannot use a B-channel
|
||
id-conflict result as a cross-tenant existence oracle (`Inv_HostBindingFence`
|
||
quantifies over accepted writes *and* recorded duplicates, making "default to C",
|
||
"A-host drives a B-channel insert", and "A-host probes a B-channel duplicate"
|
||
caught mutations, not invisible ones).
|
||
- **I3 (Write non-loss & no cross-contamination).** Every accepted append commits
|
||
under the resolved label and no other; no committed message is lost or
|
||
overwritten; two communities appending the same event id land as two rows under
|
||
distinct labels (cross-community id collision is not a write conflict).
|
||
- **I4 (Fail-closed backstop).** A dropped application predicate yields ∅ under
|
||
A-RLS, and NI still holds; removing the RLS guard makes the dropped predicate
|
||
produce a cross-label row — proving RLS load-bearing, not decorative.
|
||
- **I5 (Admission fence).** Channel membership and channel-less read capability
|
||
exist only for actors admitted to *that* community. The NIP-43 allowlist is the
|
||
`admittedMembers` relation keyed on `(community, actor)`; `AddMembership` and
|
||
every channel-less read are gated on `IsAdmitted(c, a)`, and `Inv_AdmissionFence`
|
||
quantifies over every membership *and* every recorded channel-less read,
|
||
requiring same-community admission on both — the channel-less branch additionally
|
||
binding `HostCommunity[host] = community`, so the host axis is fenced here too.
|
||
The same gate covers the **open-community** and **no-`#h`-read** surfaces: an
|
||
open community auto-registers an authenticated npub into the host-resolved
|
||
community (`AuthenticateOpenCommunity` recording an `authRegistration`), and the
|
||
kinds-only **feed read** (`ReadHostFeedRows`) and `#e`-only **aux read**
|
||
(`ReadHostAuxRows`) each record a witness only when the actor is `IsAdmitted` to
|
||
the host community — `Inv_AdmissionFence` quantifies over those witness sets too,
|
||
so an actor admitted only in B can neither open-register into A nor read A's
|
||
no-`#h` feed/aux. **Channel creation** (`CreateChannel`) stamps a fresh channel
|
||
from `HostCommunity[host]` and `Inv_ChannelCommunityImmutable` proves that stamp
|
||
is never re-labeled — creation is an in-relay analog of S2's
|
||
resolve-then-immutable discipline. The fence is about **current**
|
||
capability: it is mutation-tested non-vacuous by
|
||
M9 (re-keying the membership/read gate to any-community admission), which goes red
|
||
on both a membership trace and a channel-less-read trace, and by M10–M13 (the
|
||
open-AUTH, channel-create, feed-read, and aux-read stamp/gate mutations), each
|
||
confirmed red — proving an admit-into-A then act-in-B escape is caught rather than
|
||
invisible on every one of these surfaces. (See C3 for the explicit
|
||
historical-write carve-out.)
|
||
|
||
### Authorization soundness (mechanized in Tamarin, Dolev-Yao adversary)
|
||
|
||
- **S1 (Token confinement).** A token accepted for a B-resolved operation was
|
||
minted with stamped community B; a token stamped A never authorizes in B. A
|
||
*leaked* token authorizes within its own community (blast radius is not zero and
|
||
we do not pretend otherwise) but never another — containment, proven.
|
||
- **S2 (Mint integrity).** A token exists only as the output of a NIP-98 mint by
|
||
the holder of `owner_pubkey`'s key (P-SIG); it carries exactly one stamped
|
||
community; a mint whose channel set spans two communities yields no token.
|
||
S2's trace-level mint-rejection closure relies on P-RESOLVE's totality,
|
||
single-valuedness, **and immutability**: the Tamarin model encodes immutability
|
||
via persistent-fact semantics (`!ChannelCommunity`), without which a
|
||
retag-then-replay — reject a cross-community `req`, retag a channel, replay the
|
||
original mint bytes (same `req` hash) — would mint a token for a request S2
|
||
declares unmintable. This is the structural analog of A-RLS-5's
|
||
`UNIQUE (community_id, id)` clause for I1: both turn stable scope into the
|
||
disjointness witness.
|
||
- **S3 (Signing-key non-confusion + containment).** A community-B-signed system
|
||
event (NIP-29 `39000`/`39001`/`39002`) is never accepted as an authentic
|
||
community-A event, even when group ids collide; compromise of B's signing key
|
||
does not let the adversary forge A's events.
|
||
- **S4 (Audit-chain unforgeability + containment).** No splice, reorder, or forge
|
||
in community A's hash chain; compromise of B's chain does not break A's — N
|
||
independent chains, N independent guarantees.
|
||
- **S5 (Channel-less host confinement).** A channel-less authorization (profiles,
|
||
DMs, long-form, lists — no `h` tag) is confined to the community bound to the
|
||
connection's **host**, not the token's stamp: host wins. The token must agree
|
||
with the host community or the request is rejected; a B-stamped token presented
|
||
over an A-host never authorizes for B. This is I2's host counterpart, mechanized
|
||
as `channelless_use_confined_to_host_community`,
|
||
`channelless_token_agrees_with_host`, and `host_token_mismatch_not_authorized`.
|
||
- **S6 (Channel-bearing host/channel agreement).** A channel-*bearing*
|
||
authorization is confined to the community bound to the connection's **host**:
|
||
the host and the channel mapping must agree. An A-host presenting a B-channel
|
||
event never authorizes as B — the host axis of the confused-deputy fence, which
|
||
the prior model proved only on the channel axis (claimed-community ignored). This
|
||
closes the cross-tenant escape over a wildcard host route where the channel
|
||
mapping alone would have been authoritative. Mechanized as
|
||
`channelbearing_use_agrees_with_host` (the single-witness `ChannelBearingResolved`
|
||
fact asserting `used_comm = host_comm`).
|
||
- **S7 (NIP-43 admission confinement).** A community's member-list (NIP-43)
|
||
admission is confined to the community whose signing key signed it: B's signing
|
||
key can never admit a pubkey into A. Modeled as a parallel rule pair —
|
||
`Community_Signs_NIP43_MemberList` mints the signed list and
|
||
`Relay_Accepts_NIP43_MemberList` re-verifies the signature against
|
||
`!CommunitySigningKey(comm, sk)`, so `comm` is bound by unification to the
|
||
resolved community (the same confused-deputy discipline as the S5/S6 host
|
||
fence), emitting persistent `!Admitted(pk, comm)`.
|
||
`nip43_admission_confined_to_signing_community` proves the confinement; the
|
||
commented `MUTATION_Admit_Ignore_Community` (the dual of S6's
|
||
`MUTATION_Use_Token_Ignore_Host`) falsifies it, confirming the green is
|
||
non-vacuous. This is the authorization-world half of the same admission property
|
||
TLA+'s I5 proves in the in-relay world: `!Admitted(pk, comm)` /
|
||
`MemberAdmitted(pk, comm)` ⇔ `admittedMembers`/`IsAdmitted(c, a)` — one property,
|
||
two worlds (Tamarin proves the admission *event* per-community unforgeable, TLA+
|
||
proves the resulting capability in-relay scoped).
|
||
- **S8 (Open-community AUTH confinement).** When a community carries no NIP-43
|
||
member-pubkey allowlist it is **open**: any authenticated npub auto-registers on
|
||
AUTH. The registration is still confined to the **host-resolved** community —
|
||
`Authenticate_To_Open_Community` stamps the registration from the connection's
|
||
host binding, never a client-supplied selector, so "open" relaxes the *gate* on
|
||
membership without relaxing the *boundary* it lands in. Mechanized as
|
||
`open_auth_registration_confined_to_host_community` (a host-bound npub registers
|
||
only into its host's community), with the exists-trace witness
|
||
`executable_open_auth_registration` proving a legitimate open registration is
|
||
producible so the confinement lemma is non-vacuous. This is S5/S6's host-binding
|
||
discipline applied to the admission *event*: the same confused-deputy fence that
|
||
stops a B-stamped token authorizing over an A-host stops a B-host AUTH
|
||
registering into A. Its in-relay counterpart is I5's open-community branch
|
||
(`AuthenticateOpenCommunity`, mutation M10).
|
||
|
||
Each Tamarin lemma is paired with an exists-trace sanity lemma (the honest
|
||
protocol can run), the Tamarin analog of the mutation test.
|
||
|
||
**Verification status.** S1–S8 are **machine-verified green** on
|
||
Tamarin 1.12.0 / Maude 3.5.1 — the full selected run verifies all 32 lemmas in
|
||
~12s with zero `analyzed` failures. S1/S2: `token_confinement`,
|
||
`cross_community_use_attempts_are_not_authorized`, the two
|
||
`minted_*_channels_match_stamp` lemmas, `token_stamp_matches_mint`,
|
||
`cross_community_mint_yields_no_token_for_that_request`, and the
|
||
`leaked_token_blast_radius_contained` / `leaked_token_can_authorize_within_its_community`
|
||
containment pair, with `MUTATION_Use_Token_Claimed_Community` confirmed red
|
||
(`falsified — found trace`). S3:
|
||
`system_event_acceptance_requires_same_community_key_or_compromise` (21 steps) and
|
||
`other_community_key_compromise_does_not_authorize` (147 steps). S4:
|
||
`audit_append_advances_same_community_head` (2 steps) and
|
||
`cross_community_audit_splice_attempt_is_not_append` (1 step). S5 (channel-less
|
||
host confinement): `channelless_use_confined_to_host_community` (2 steps),
|
||
`channelless_token_agrees_with_host` (3 steps), and
|
||
`host_token_mismatch_not_authorized` (6 steps), each paired with an exists-trace
|
||
probe (`executable_host_bound`, `executable_channelless_use`,
|
||
`executable_host_token_mismatch_attempt`). The S5 mutation
|
||
`MUTATION_Use_Token_ChannelLess_Ignore_Host` (the relay reading the token's stamp
|
||
and ignoring the host binding — the B-token-on-A-host confused deputy) is
|
||
confirmed red: it falsifies `channelless_use_confined_to_host_community` in 3.3s
|
||
with a 13-step trace. Each safety lemma is
|
||
paired with a verified exists-trace sanity lemma, and the S3/S4 mutations are
|
||
confirmed red: the bad-accept-with-other-community-key mutation falsifies both S3
|
||
lemmas (5 / 16 steps) and the splice-as-append mutation falsifies the S4 splice
|
||
lemma (8 steps). S6 (channel-bearing host/channel agreement):
|
||
`channelbearing_use_agrees_with_host` (2 steps), with the
|
||
`MUTATION_Use_Token_Ignore_Host` mutation (the relay resolving a channel-bearing
|
||
op from the channel mapping while ignoring the host binding — the A-host-on-a-
|
||
B-channel confused deputy) confirmed red: it falsifies
|
||
`channelbearing_use_agrees_with_host` in 2.6s with a 14-step trace. S7 (NIP-43
|
||
admission confinement): `nip43_admission_confined_to_signing_community` (19 steps)
|
||
and `other_community_key_compromise_does_not_admit` (79 steps), with the
|
||
exists-trace probe `executable_member_admitted` (7 steps) proving a legitimate
|
||
admission is producible — so the confinement lemma is non-vacuous, not trivially
|
||
true over an unreachable premise. The S7 mutation `MUTATION_Admit_Ignore_Community`
|
||
(the relay minting `!Admitted` for a community other than the one whose key
|
||
signed — the admission-side confused deputy, the dual of S6's
|
||
`MUTATION_Use_Token_Ignore_Host`) is confirmed red: it falsifies
|
||
`nip43_admission_confined_to_signing_community` in 1.57s with a 7-step trace.
|
||
S8 (open-community AUTH confinement): `open_auth_registration_confined_to_host_community`
|
||
(2 steps), paired with the exists-trace witness `executable_open_auth_registration`
|
||
(5 steps) proving a legitimate open-community registration is producible, so the
|
||
confinement lemma is non-vacuous; its in-relay counterpart is the M10 open-AUTH
|
||
stamp mutation, confirmed red in TLA+ (a 2-state `Inv_AdmissionFence` violation).
|
||
|
||
The S5 confinement lemma was deliberately framed to keep its mutation
|
||
*cheaply* refutable. An earlier framing joined two action facts
|
||
(`ChannelLessAuthorized` ⋈ `HostBoundFor`) on a shared host; the proof verified,
|
||
but the *mutation refutation* did not terminate — Tamarin chased which
|
||
`HostBoundFor` instance applied for a given host across both the real and mutated
|
||
rules. The fix emits a single combined witness
|
||
`ChannelLessResolved(tok, used_comm, host, host_comm)` from the authorizing rule
|
||
(in the real rule both communities are the same variable), so the confinement
|
||
lemma is a single-fact assertion `used_comm = host_comm` and the mutation that
|
||
breaks it is a one-rule-instance counterexample. The proof dropped to 2 steps and
|
||
the mutation falsifies in 3.3s — the same "make the bad case structurally cheap to
|
||
exhibit" discipline as the S1 claimed-community mutation.
|
||
|
||
The S3/S4 round corrected one vacuity bug in the committed
|
||
`1e7fb042…aceaacf24` artifact: `other_community_key_compromise_does_not_authorize`
|
||
bound `Neq(commA, commB)` to the *same* timepoint as `CommunityKeyCompromised(commB)`,
|
||
but no rule emits `Neq` at the compromise point, so that premise was unsatisfiable —
|
||
the lemma verified vacuously and asserted nothing. (Independently confirmed: an
|
||
exists-trace probe of the old premise returns `no trace found`.) The fix decouples
|
||
the inequality onto a separate witness timepoint `#k`; a new exists-trace lemma
|
||
`executable_other_key_compromise_plus_system_accept` (16 steps, verified) proves the
|
||
corrected premise is satisfiable, so the 147-step proof is non-vacuous. This is the
|
||
same hygiene class as F1/F3/F4 — an artifact relying on a fact the model never makes
|
||
reachable — but caught inside a safety lemma's premise rather than a comment. That
|
||
fix predates this milestone's host-binding additions and is carried forward
|
||
unchanged in the current `.spthy`.
|
||
|
||
## Conformance
|
||
|
||
Each axiom is *admitted* per deployment, not assumed universally:
|
||
|
||
- **A-RLS-1..5** are admitted by a startup/CI assertion suite: enumerate every
|
||
tenant-bearing table and assert RLS enabled + restrictive policy present; assert
|
||
the request role is `NOBYPASSRLS` and non-owner-or-FORCE; assert no
|
||
`SECURITY DEFINER` function in the request path reads tenant tables without
|
||
re-establishing context; assert every unique/FK constraint includes
|
||
`community_id`. A failing assertion rejects the deployment.
|
||
- **P-RESOLVE** is admitted by the `channels.community_id NOT NULL` constraint
|
||
plus a test that `resolve` is read under the operation's snapshot, plus a
|
||
migration lint asserting `channels.community_id` is never mutated after insert
|
||
(no `UPDATE`/`ALTER`/drop-recreate). A failing lint rejects the deployment.
|
||
- **P-SIG / A_HASH** are the standard Nostr crypto assumptions; admitted by using
|
||
the audited libraries the rest of Buzz uses.
|
||
- **P3** is admitted by the NIP-98 handler enforcing *both* timestamp-range
|
||
validation and the seen-event-id check (`check_nip98_replay`) before any mint.
|
||
Two structural gates make the seen-set sound, and both are conformance checks
|
||
because the implementation is silent if either is violated:
|
||
1. **Capacity vs. rate.** The seen-set is bounded (capacity 10,000, TTL 120 s
|
||
= 2× the ±60 s window). It must satisfy `capacity ≥ peak NIP-98 RPS × 120 s`
|
||
(≈ 83 RPS sustained at the current capacity); above that, LRU eviction can
|
||
release an entry while its signed `created_at` is still inside the window,
|
||
and a replay slips through.
|
||
2. **Per-pod scope.** The seen-set is `Arc<AppState>`-scoped, not cross-pod, so
|
||
the same replayed event reaching two pods succeeds once on each. P3 therefore
|
||
requires *either* NIP-98 mints be pod-sticky on `event_id` *or* the seen-set
|
||
be shared across pods (e.g. Redis with the same atomic insert-if-absent
|
||
semantics and TTL ≥ 120 s). The chart default (`replicaCount: 1`) satisfies
|
||
this gate today; the shipped HA examples (`replicaCount: 3` in
|
||
`deploy/charts/buzz/examples/argocd-app.yaml:27` and
|
||
`deploy/charts/buzz/examples/flux-helmrelease.yaml:35`) are
|
||
P3-non-conforming as shipped unless the operator adds one of:
|
||
- **(a)** an ingress annotation hashing upstream selection on a header stable
|
||
across replays — `nginx.ingress.kubernetes.io/upstream-hash-by:
|
||
"$http_authorization"` works for today's NIP-98 HTTP path, since the signed
|
||
event rides in `Authorization: Nostr <base64>` (`bridge.rs:34-46`) and is
|
||
bit-identical across replays. Two caveats keep this from being the
|
||
recommended fix: it couples replay-stickiness to literal-byte-identity of
|
||
the auth header (any future header normalization — whitespace, casing,
|
||
base64 padding — silently breaks it), and it does not extend to any mint
|
||
path that moves off HTTP (a WS mint has no Authorization header to hash on).
|
||
- **(b)** a shared seen-set backed by a store with atomic insert-if-absent and
|
||
TTL ≥ 120 s (e.g. Redis, already present in the HA chart for git-pubsub).
|
||
**This is the recommended path** — no new infra surface and none of (a)'s
|
||
fragility.
|
||
|
||
A regression test asserts a replayed mint within the window yields a single
|
||
token under the deployment's routing/storage shape (and that the seen-set TTL
|
||
covers the full ±60 s window). A failing test or an unmet gate rejects the
|
||
deployment.
|
||
|
||
## Prior Art
|
||
|
||
The *pattern* (discriminator column + RLS) is established; the *formal treatment*
|
||
as label-flow non-interference is, to our knowledge, new for a Nostr relay.
|
||
|
||
- **Goguen & Meseguer, "Security Policies and Security Models" (IEEE S&P 1982)** —
|
||
the origin of non-interference; the theorem shape ("A's actions do not affect
|
||
B's observations"), with "community" for "security domain."
|
||
- **Sabelfeld & Myers, "Language-Based Information-Flow Security" (IEEE JSAC
|
||
2003)** — the canonical label-based IFC survey; its declassification discipline
|
||
is the model for our named C1 carve-out.
|
||
- **Jean Yang et al., "Precise, Dynamic Information Flow for Database-Backed
|
||
Applications" (arXiv:1507.03513, Jacqueline)** and **Parker, Vazou, Hicks,
|
||
"LWeb" (arXiv:1901.07665)** — the closest formal analogs: label-based per-row
|
||
policy over a real relational store with a *mechanized* non-interference proof.
|
||
They justify "RLS is a backstop axiom; the theorem is the composition."
|
||
- **Hardy, "The Confused Deputy" (ACM SIGOPS OSR 1988)** and **Miller et al.,
|
||
"Capability Myths Demolished" (HPL-2003-222)** — the resolution-as-capability
|
||
framing: bind authority to the resolved object, not the caller-supplied name.
|
||
- **NIP-29 (relay-based groups)** — confirms the relay is authoritative and group
|
||
ids are not globally unique security domains; supports per-community signing
|
||
keys and per-community audit chains, and motivates S3's "non-confusable even
|
||
when group ids collide."
|
||
- **`fiatjaf/relay29`** — empirical prior art: isolation logic lives across read
|
||
filters, direct-id lookups, metadata generation, in-memory state rebuilds, and
|
||
`previous`-tag validation, not just insert/select predicates. The reason
|
||
`Serve` must model the full observable surface, not just channel reads.
|
||
- **PostgREST / PostGraphile** — converge on the transaction-local-context fence
|
||
(A-RLS-3); real systems install request-local identity into the DB transaction
|
||
and let policies authorize. (See `RESEARCH/MULTITENANT_ISOLATION_PRIOR_ART.md`
|
||
for citations and local checkout line references.)
|
||
|
||
## Mechanized Verification
|
||
|
||
- **`docs/spec/MultiTenantRelay.tla` + `.cfg`** — the TLA+ isolation model. Run:
|
||
`java -cp tla2tools.jar tlc2.TLC -config MultiTenantRelay.cfg MultiTenantRelay.tla`.
|
||
On the core finite harness (2 communities × 4 channels, 2 message ids, 1 actor,
|
||
1 worker, 2 audit values, bounded observation set, symmetry over the permutable
|
||
model-value sets) TLC **completes exhaustively**: *Model checking completed. No
|
||
error has been found.* — 472,530,528 states generated, 16,226,016 distinct, 0 left
|
||
on queue, depth 13 (8 workers, ~5m). The distinct-state count grew from the
|
||
pre-host-binding baseline (4,350,464 → 5,091,328 with channel-less host binding →
|
||
5,621,760 with channel-bearing host/channel agreement → 9,232,992 with the
|
||
`admittedMembers` allowlist, `channelLessReads` capability rows, and the
|
||
`AdmitMember`/`RevokeMember` actions → 16,226,016 with the open-community AUTH
|
||
auto-registration, server-stamped channel creation, and the no-`#h` host
|
||
feed/aux read paths) precisely because the
|
||
channel-less write path, the fail-closed unmapped-host path, the
|
||
channel-bearing host/channel-agreement (and its fail-closed disagreement) path,
|
||
the admit/revoke/gated-membership/gated-read paths, and now the
|
||
open-AUTH/channel-create/feed-read/aux-read paths
|
||
are genuinely reachable — new behavior, not dead code. Threading the host through
|
||
the duplicate/no-op path adds reachable fail-closed transitions without new
|
||
distinct states: only the agreeing host can produce a
|
||
recorded duplicate, so the host on that path is fully determined; layering the
|
||
admission gate, then the open-AUTH/channel-create/feed/aux surfaces on top, is the
|
||
growth to the figures above (admit-then-act, revoke-then-act-fails, gated
|
||
reads/joins, open-community auto-registration, server-stamped creation, and the
|
||
two host-fenced no-`#h` read shapes multiply the reachable space). That each new
|
||
surface is reachable rather than dead is pinned by four intentionally-false
|
||
reachability probes (`Probe_OpenAuthRegistration_Unreachable`,
|
||
`Probe_CreatedChannel_Unreachable`, `Probe_HostFeedRead_Unreachable`,
|
||
`Probe_HostAuxRead_Unreachable`): each asserts the corresponding witness set stays
|
||
empty, so each must go red if its action fires — and all four do (open AUTH and
|
||
channel-create at 2 states; host feed and host aux at 3 states, via open AUTH then
|
||
read). A vacuously-passing new conjunct over an unfireable action is therefore
|
||
ruled out, not assumed. Non-vacuity of the
|
||
invariants themselves is shown by thirteen mutations (M1–M13), each
|
||
confirmed to produce a counterexample: substituting the unscoped direct-by-id
|
||
lookup (`UnscopedDirectIdRows`, the `get_accessible_channel_ids` landmine) →
|
||
`Safety` violated at depth 4; widening the sanitized-error label to all
|
||
communities (the raw-error leak) → `Safety` violated at depth 2; the
|
||
global-id conflict key (M3: `WriteDuplicate` keyed on `id` alone via
|
||
`GlobalConflictRows`, the missing-`community_id`-in-the-unique-index footgun)
|
||
→ `Safety` violated at depth 3, with a B-scoped `WriteResult` observation
|
||
carrying `labels |-> {commA}` (the existence-oracle leak C2.1 closes); the
|
||
host-default-tenant mutation (a channel-less write from an unmapped host landing
|
||
in a default community instead of failing closed) → `Inv_HostBindingFence`
|
||
violated at depth 2, the counterexample exhibiting `hostBad` writing into
|
||
`commA`; the **M8** host/channel-agreement mutation (`WriteInsert` dropping
|
||
the agreement fence so an A-host op on a B-channel is accepted) →
|
||
`Inv_HostBindingFence` violated by a 2-state trace (`Init → WriteInsert`); and the
|
||
**M8-duplicate** mutation (`WriteDuplicate` dropping the same fence so an A-host
|
||
can probe a B-channel id-conflict) → `Inv_HostBindingFence` violated by a 3-state
|
||
trace (`Init → WriteInsert → WriteDuplicate`), the counterexample exhibiting a
|
||
foreign-host duplicate record whose stored community ≠ its host's mapping (the
|
||
existence oracle the duplicate path would otherwise reopen); and the **M9**
|
||
global-allowlist mutation (re-keying the admission gate from same-community
|
||
`IsAdmitted(c, a)` to any-community `AdmittedInAnyCommunity(a)`) →
|
||
`Inv_AdmissionFence` violated in two surfaces: a 5-state membership trace
|
||
(`Init → WriteInsert → WriteInsert → AdmitMember(commA, alice) →
|
||
AddMembership(commB/chanB1, alice)`) where alice, admitted to A, joins B's
|
||
channel through the global hole; and a 4-state channel-less-read trace
|
||
(`Init → WriteInsert → AdmitMember(commB, alice) →
|
||
ReadMessageRows(commA, NoChannel, hostA)`). The two M9 variants prove both the
|
||
`AddMembership` gate and the channel-less-read gate are independently
|
||
load-bearing, not just one. The four newest surfaces — open-community AUTH
|
||
auto-registration, server-stamped channel creation, and the two no-`#h` host
|
||
read shapes (kinds-only feed, `#e`-only aux) — are each held by their own
|
||
confirmed-red mutation: the **M10** open-AUTH stamp mutation (the relay stamping
|
||
an open-community auto-registration into a default/claimed community instead of
|
||
`HostCommunity[host]`) → `Inv_AdmissionFence` violated by a 2-state trace
|
||
(`Init → AuthenticateOpenCommunity(hostB stamps commA)`), catching an
|
||
`authRegistration` whose host maps elsewhere; the **M11** channel-create stamp
|
||
mutation (a fresh channel stamped into a default/claimed community rather than
|
||
the host's) → `Inv_HostBindingFence`/`Inv_ChannelCommunityImmutable` violated by a
|
||
2-state trace (`Init → CreateChannel(hostB stamps commA)`); the **M12** feed
|
||
global-admission mutation (re-keying the `ReadHostFeedRows` admission guard from
|
||
same-community `IsAdmitted(c, a)` to relay-global `GloballyAdmitted(a)`) →
|
||
`Inv_AdmissionFence` violated by a 3-state trace
|
||
(`Init → AdmitMember(commB, alice) → ReadHostFeedRows(hostA)`), so an actor
|
||
admitted only in B cannot read A's no-`#h` feed; and the **M13** aux
|
||
global-admission mutation (the same guard re-key on `ReadHostAuxRows`) →
|
||
`Inv_AdmissionFence` violated by a 3-state trace
|
||
(`Init → AdmitMember(commB, alice) → ReadHostAuxRows(hostA)`). M10–M13 confirm the
|
||
open-AUTH/create/feed/aux fences are load-bearing, not decorative — the same
|
||
"every new conjunct earns a confirmed red" contract as M1–M9. (To reproduce M12/M13,
|
||
the substitution that trips `Inv_AdmissionFence` is the action's admission *guard*
|
||
(`IsAdmitted(c, a)` → `GloballyAdmitted(a)` in `ReadHostFeedRows`/`ReadHostAuxRows`),
|
||
not the row-set helper alone, since the invariant quantifies over the recorded
|
||
`feedReads`/`auxReads` witnesses rather than the returned row set — the `.tla`
|
||
helper comments call this out.) The host-fence and new-surface
|
||
figures above are counterexample **trace lengths** (the error-trace state count),
|
||
which unlike TLC's run-dependent "depth of complete graph search" total are
|
||
reproducible from the printed error trace. The
|
||
`h`-tag mutation is the same shape (I2). The config is deliberately a
|
||
fast non-vacuity harness, not the full deployment scale — widening workers,
|
||
actors, and ids explodes the space; symmetry + bounded observations keep the
|
||
core isolation surface exhaustively checkable.
|
||
- **`docs/spec/MultiTenantAuth.spthy`** — the Tamarin authorization model. Run:
|
||
`tamarin-prover --prove docs/spec/MultiTenantAuth.spthy`. All 32 lemmas (S1–S8)
|
||
verify green (Tamarin 1.12.0 / Maude 3.5.1, ~12 s) — each safety lemma paired with
|
||
a verified exists-trace sanity lemma, and the documented mutations
|
||
(`MUTATION_Use_Token_Claimed_Community` for S1, the S3 bad-accept and S4
|
||
splice-as-append mutations, `MUTATION_Use_Token_ChannelLess_Ignore_Host`
|
||
for S5's host fence, `MUTATION_Use_Token_Ignore_Host` for S6's channel-bearing
|
||
host/channel-agreement fence, and `MUTATION_Admit_Ignore_Community` for S7's
|
||
NIP-43 admission confinement) confirmed red. The 32 lemmas include the
|
||
open-community AUTH pair added with the host-scoped-open-auth surfaces:
|
||
`open_auth_registration_confined_to_host_community` (2 steps) proves an
|
||
open-community auto-registration commits to the host-resolved community and never
|
||
a client-claimed one, and its exists-trace witness
|
||
`executable_open_auth_registration` (5 steps) proves a legitimate open-community
|
||
registration is producible, so the confinement lemma is non-vacuous. See
|
||
§Authorization soundness for the
|
||
full lemma list, the S5/S6 single-witness framing, and the corrected
|
||
`other_community_key_compromise_does_not_authorize` vacuity fix.
|
||
|
||
**Machine-check hygiene.** S1–S8 lemmas close by two distinct shapes.
|
||
**Rule-shape closure** means the lemma's conclusion follows by unification on a
|
||
single rule's action multiset: `token_confinement`,
|
||
`audit_append_advances_same_community_head`,
|
||
`channelless_use_confined_to_host_community` (the S5 single-witness fact),
|
||
`channelbearing_use_agrees_with_host` (the S6 single-witness fact), and
|
||
the S2 supporting set
|
||
(`minted_token_channels_match_stamp`, `minted_request_channels_match_stamp`,
|
||
`token_stamp_matches_mint`). These are well-formedness guards on the model's
|
||
action labels; the substantive security claim is carried by the corresponding
|
||
rule design and mutation (for example, `MUTATION_Use_Token_Claimed_Community`
|
||
falsifies `token_confinement` when authorization is rewritten to use a claimed
|
||
community, `MUTATION_Use_Token_ChannelLess_Ignore_Host` falsifies
|
||
`channelless_use_confined_to_host_community` when the relay reads the token
|
||
stamp instead of the host binding, and `MUTATION_Use_Token_Ignore_Host`
|
||
falsifies `channelbearing_use_agrees_with_host` when the relay resolves a
|
||
channel-bearing op from the channel mapping while ignoring the host).
|
||
**Substantive closure** requires cross-rule reasoning over
|
||
persistent-fact invariance (`cross_community_mint_yields_no_token_for_that_request`,
|
||
`leaked_token_blast_radius_contained`,
|
||
`cross_community_use_attempts_are_not_authorized`), linear-fact lifecycle
|
||
(`cross_community_audit_splice_attempt_is_not_append`), or signed-preimage
|
||
unification (`system_event_acceptance_requires_same_community_key_or_compromise`).
|
||
Tamarin proves both kinds identically; the distinction is for reviewer hygiene,
|
||
not a weakened theorem claim. This paragraph is prose-only to preserve the
|
||
`.spthy` byte hash above.
|
||
|
||
## Implementation Correspondence
|
||
|
||
The model's obligations map to concrete code seams:
|
||
|
||
- **P-RESOLVE / I2** — `resolve(channel_id)` must be the *only* source of
|
||
`ctx.community_id`; the `h` tag is never written into tenancy. Today there is no
|
||
community layer; `channel_id` is the only locality.
|
||
- **P-RESOLVE (immutability) / S2** — `channels.community_id` must be immutable
|
||
after insert. No migration may `UPDATE channels SET community_id = …`,
|
||
`ALTER TABLE channels … community_id …`, or drop-and-recreate the column without
|
||
an explicit re-admission of P-RESOLVE and re-verification of S1/S2. This is the
|
||
load-bearing assumption behind S2's trace-level mint-rejection (a retag-then-
|
||
replay breaks it) and behind the TLA `ChannelCommunity` CONSTANT; it is
|
||
invisible to both the labeling invariant and the Tamarin lemmas (the proofs
|
||
would silently weaken, not fail), so it is enforced by a migration lint — the
|
||
same gate-on-the-migration class as the C2.1 composite-index and C2.4
|
||
`RelayInfo::build` signature lints.
|
||
- **I1 / I4** — every DB entry point takes `TenantContext` and `SET LOCAL
|
||
app.community_id`; the unscoped `get_accessible_channel_ids()`
|
||
(`crates/buzz-db/src/channel.rs:545-560`, which unions every open channel in the
|
||
DB) must not exist in any tenant-scoped path. RLS is the backstop.
|
||
- **C2.1 / A-RLS-5** — the message-uniqueness constraint must be composite over
|
||
`(community_id, …, id)`, never `UNIQUE (id)` alone. This is the closure for the
|
||
existence-oracle (M3 goes red at depth 3 under a global key). It is one bad
|
||
migration away from breaking and is invisible to the labeling invariant, so it
|
||
is enforced by the conformance schema assertion (§Conformance: "every unique/FK
|
||
constraint includes `community_id`") — the same gate-on-the-migration class as
|
||
the C2.4 `RelayInfo::build` signature lint.
|
||
- **S3 / S4** — the relay keypair becomes a per-community signing key
|
||
(`communities.signing_key`), distinct from relay-instance identity; the single
|
||
global audit chain (`crates/buzz-audit/src/service.rs`) becomes N per-community
|
||
chains `AuditEntry(community, seq, prev, hash)`.
|
||
- **P3 / S2** — the NIP-98 mint freshness obligation the Tamarin model abstracts
|
||
as a fresh `~time` nonce is carried by two code seams: the ±60s window in
|
||
`crates/buzz-auth/src/nip98.rs:77-83` and the event-id seen-set
|
||
`check_nip98_replay` in `crates/buzz-relay/src/api/bridge.rs:76-94`, called
|
||
before every mint (`bridge.rs:181`, `:254`, `:514`). The seen-set
|
||
(`state.nip98_seen`, `state.rs:249`/`:407`) is the structural analog of the
|
||
model's nonce: it makes a replayed mint within the window non-fresh, so the
|
||
implementation matches the "every mint is structurally unique" world the model
|
||
proves S2 in. This correspondence is deployment-conditional: today's in-process
|
||
moka cache carries P3 for the chart default (`replicaCount: 1`) and for any
|
||
deployment that routes all mints for the same event id to the same pod, but the
|
||
shipped HA examples (`replicaCount: 3`) do **not** carry P3 as shipped because
|
||
there is no sticky routing and no shared seen-set. HA conformance requires a
|
||
Redis/shared-store seen-set with atomic insert-if-absent and TTL ≥ 120 s
|
||
(recommended), or a header-stable sticky-routing layer — see §Conformance (P3)
|
||
for the two operator options and the caveats on the routing workaround.
|
||
- **C2.2** — the client-facing error path must map all DB errors to a fixed
|
||
sanitized alphabet; no `sqlx::Error::to_string()` reaches a tenant connection.
|
||
- **C2.4** — the NIP-11 builder `RelayInfo::build`
|
||
(`crates/buzz-relay/src/nip11.rs:122`) must keep its relay-static-only signature
|
||
(no `&PgPool`, no tenant context, no audit service); a signature lint enforces
|
||
the typed-input fence on the unauthenticated `/` surface.
|
||
- **P-RESOLVE-HOST / row-zero conformance** — every externally reachable
|
||
relay-global surface consumes the host-derived `TenantContext` before reading
|
||
or mutating tenant data. This is the implementation seam for NIP-11/community
|
||
relay identity, NIP-98/API-token REST calls, media upload/serve, git Smart
|
||
HTTP, workflow webhooks/schedules/manual triggers, search, presence, and Redis
|
||
fan-out. Tokens, signed NIP-98 `u` URLs, webhook ids, workflow ids, repo names,
|
||
media hashes, and event ids are subordinate names; none may select a community
|
||
that disagrees with the request host.
|
||
- **NIP-11 / S3** — tenant-observable relay identity is per-community. Static
|
||
software/version fields may be operator-global, but `self`/relay-signed group,
|
||
membership, audit, and system events use the community signing key. The
|
||
unauthenticated info path may reveal facts about the addressed host/community
|
||
only; unknown hosts fail closed generically rather than returning another
|
||
community's info document.
|
||
- **API tokens / P3** — `api_tokens` is a community-scoped namespace. Token hash
|
||
lookup, channel claims, scopes, revocation, and NIP-98 replay checks are
|
||
evaluated under `(community_id, token_hash/event_id)`. HA deployments require a
|
||
shared atomic seen-set keyed by community and NIP-98 event id, or an explicitly
|
||
admitted sticky/single-replica deployment; otherwise S2's freshness premise is
|
||
not carried in production.
|
||
- **Search / C2.1** — the Postgres FTS index (the `events.search_tsv` generated
|
||
column, backed by a GIN index) is shared infrastructure, not a shared result
|
||
space. Searchable rows carry `community_id`, and every search query filters by
|
||
`community_id` so the FTS predicate is BitmapAnd-ed with the community-leading
|
||
btree filters; a hit never crosses tenants and refetch by hit id is
|
||
`(community_id, event_id)`. The channel-less scope (`ChannelScope::ChannelLessOnly`,
|
||
formerly the `__global__` sentinel) means channel-less within one community,
|
||
never operator-global.
|
||
- **Redis / subscription refinement** — Redis pub/sub keys, presence keys, typing
|
||
keys, cache invalidation channels, and local-echo dedup labels include
|
||
community context in any shared multi-tenant deployment. The safe shape is
|
||
`buzz:{community}:channel:{channel_id}`,
|
||
`buzz:{community}:presence:{pubkey}`, and
|
||
`buzz:{community}:typing:{channel_id}`. The current unprefixed keys are
|
||
admissible only for the degenerate single-community deployment or physically
|
||
isolated Redis.
|
||
- **Media / Blossom** — raw blob bytes may remain content-addressed and
|
||
operator-deduplicated, but descriptors, upload authorization, quotas, audit
|
||
rows, and any future read policy are community-scoped. A media hash collision or
|
||
pre-existing blob in another community must not become an existence oracle via
|
||
metadata, status code, quota accounting, or audit output.
|
||
- **Git / NIP-34** — git Smart HTTP resolves the repository namespace from the
|
||
host-derived community before consulting owner/repo names, branch protection,
|
||
NIP-34 repo announcements, manifests, or object-store pointers. Pointer keys
|
||
include community (for example `repos/{community}/{owner}/{repo}/pointer`);
|
||
pack/object CAS may be shared only below community-scoped refs/manifests and
|
||
authorization metadata.
|
||
- **Workflows / system events** — workflow definitions, runs, approval hashes,
|
||
webhook/manual trigger routes, cron scheduling, and relay-signed workflow events
|
||
inherit `community_id`. A workflow id or approval token hash alone is never a
|
||
lookup key. Trigger evaluation sees events in the same community only, and
|
||
schedule coordination must preserve that label across pods.
|
||
- **Relay membership / pubkey admission** — relay membership, pubkey allowlist,
|
||
and archived identities are community-global admission facts. The portable
|
||
value is the pubkey; the stored membership/archive fact is
|
||
`(community_id, pubkey, ...)`. No deployment-global user gate is
|
||
tenant-observable unless it is modeled as a separate operator surface. This is
|
||
no longer asserted-only: the `(community_id, pubkey)` admission key and the
|
||
*absence* of a deployment-global gate are both mechanized. TLA+ carries the
|
||
allowlist as the `admittedMembers` relation
|
||
(`MultiTenantRelay.tla:149`), keyed on `[community, actor]`; `IsAdmitted(c, a)`
|
||
(`:317`) gates `AddMembership` and every channel-less read, and
|
||
`Inv_AdmissionFence` proves no membership or channel-less read capability
|
||
survives that is not same-community-admitted (Theorem I5). The
|
||
deployment-global gate is exactly mutation M9: replacing `IsAdmitted(c, a)`
|
||
with the any-community `AdmittedInAnyCommunity(a)` (`:324`) makes the model go
|
||
red — so admit-into-A-then-act-in-B is a *caught* escape, not an invisible one.
|
||
On the authorization side, NIP-43 member-list events are signed and accepted
|
||
per-community in Tamarin (`Community_Signs_NIP43_MemberList` /
|
||
`Relay_Accepts_NIP43_MemberList`, `MultiTenantAuth.spthy:403`/`:413`), and
|
||
`nip43_admission_confined_to_signing_community` proves B's signing key can
|
||
never admit a pubkey into A (Theorem S7).
|
||
|
||
### Subscription-pipeline abstraction
|
||
|
||
The mechanized models abstract one structural seam: the **subscription
|
||
pipeline** (`REQ → register → match → fan-out → access-filter → EVENT/EOSE`).
|
||
The TLA+ isolation model represents this pipeline as the synchronous `Read*`
|
||
actions, indexed by `(worker, actor, community, channel)`; it has no
|
||
`sub_id`, no `Register`, no `Match`, no `FanOut`, no `EOSE`, no filter state.
|
||
This is sound — the model proves `Inv_LabelPropagation` over the **aggregate**
|
||
row-set delivered to a B-scoped worker, and the prose observational interface
|
||
(§The typed observational interface) presents the same property over
|
||
**per-sub streams**. The refinement from aggregate to per-stream is *coarser
|
||
than the interface, not wrong* — but it is not mechanized, and it is closed
|
||
here, by code-fence and obligation, against the implementation.
|
||
|
||
**Governing rule.** Every observation kind enumerated in §The typed
|
||
observational interface must either (i) be discharged by a TLA+ invariant or
|
||
Tamarin lemma, or (ii) appear by name in this subsection with a code-fence
|
||
and a closure obligation. New observation kinds added to §The typed
|
||
observational interface require a new entry here in the same commit. This
|
||
rule is what surfaced F1 (A_HASH closure mis-attribution) and F2 (the
|
||
subscription-pipeline abstraction itself).
|
||
|
||
#### G1 — establishment (`crates/buzz-relay/src/handlers/req.rs:79-204`)
|
||
|
||
A `REQ` from a connection authenticated under pubkey *p* and token *t*
|
||
registers a subscription only after:
|
||
|
||
1. `accessible_channels ← get_accessible_channel_ids_cached(p)` (`:79`) —
|
||
the DB-derived UUID set the connection's pubkey is a member of.
|
||
2. If *t* carries a `channel_ids` claim, intersect with it (`:88-90`). This
|
||
is the one-token-one-community enforcement at the WS surface.
|
||
3. `extract_channel_id_from_filters(filters)` (`:92`, body at `:795-822`)
|
||
returns `Some(uuid)` **only if every filter pins the same `#h=<uuid>`**;
|
||
any mixed-`#h` or missing-`#h` filter yields `None`, routing the
|
||
subscription to the global indexes (tests at `:1045-1083`).
|
||
4. Channel-scoped path: if the returned `ch_id ∉ accessible_channels`,
|
||
re-confirm via `is_member` against the DB (`:112`); on `Ok(false)` or
|
||
`Err(_)` emit `CLOSED "restricted: …"` (`:127-132`).
|
||
5. Global path (`channel_id = None`): per-filter p/engram/author gates must
|
||
hold against *p* (`:144-167`); otherwise `CLOSED`.
|
||
6. Only then is `sub_registry.register_scoped(...)` called. Direct `register`
|
||
calls are confined to test setup; production subscription registration goes
|
||
through the community-scoped API in `req.rs`.
|
||
|
||
#### G2 — delivery (`crates/buzz-relay/src/handlers/event.rs:59-113`)
|
||
|
||
Every candidate from `sub_registry.fan_out` passes through
|
||
`filter_fanout_by_access` before any `send_to`. The function (`:59`) and its
|
||
doc comment (`:117-124`) state the invariant: *a registered subscription is
|
||
never sufficient for delivery — delivery always revalidates access on the
|
||
sending pod*. Three checks, in order:
|
||
|
||
- **Author-only kinds** (`:70-83`) — filter to recipients whose
|
||
`pubkey_for_conn` equals the event author.
|
||
- **Channel visibility** (`:85-97`) — `channel_visibility_cached(channel_id)`.
|
||
Non-private → pass through; `"private"` → continue. **Lookup error →
|
||
`return Vec::new()`** (`:91-96`): visibility short-circuit, fail-closed
|
||
for the whole fan-out. The cache discipline at `state.rs:560-568` caches
|
||
only `"private"`, so a stale entry can only over-restrict (≤10s), never
|
||
leak.
|
||
- **Membership** (`:99-111`) — `is_member_cached(channel_id, pubkey)` per
|
||
recipient; `Ok(false)` or `Err(_)` drops that recipient.
|
||
|
||
#### Non-mechanized obligations
|
||
|
||
The following obligations close the per-sub stream properties the TLA+
|
||
`Inv_LabelPropagation` does not reach. Each names its code-fence and the
|
||
gates (G1, G2) that carry the closure.
|
||
|
||
1. **EOSE cardinality.** The count of events preceding `O.WS.EOSE(sub_id)`
|
||
must equal `|{m ∈ messages : matches(m, F) ∧ m ∈ ResolvedScope(conn)}|`,
|
||
where `F` is the sub's declared filter set. Delivery: `req.rs:281`
|
||
(per-event `EVENT` send); EOSE emission: `req.rs:292`. Closure: G1
|
||
admits the subscription only with a `ResolvedScope(conn)`-consistent
|
||
filter set, and G2 drops any candidate not in `ResolvedScope(conn)` at
|
||
delivery; the EOSE count is therefore the sum of events that passed
|
||
both gates.
|
||
2. **EOSE → late-EVENT temporal pairing.** No `O.WS.EVENT(sub_id, …)`
|
||
delivered after the sub's EOSE may reveal state withheld by G2 during
|
||
the historical dump. Closure: G2 re-validates visibility and membership
|
||
on every live fan-out, against the same `ResolvedScope(conn)` predicate
|
||
used at EOSE time. The **primary closure is the visibility
|
||
short-circuit at `event.rs:91-96`** — a transient DB error during the
|
||
late-EVENT window returns an empty fan-out for the whole event, not a
|
||
relaxed predicate; the per-recipient membership branch at
|
||
`event.rs:107-110` is the secondary backstop.
|
||
3. **`sub_id` reuse and collisions.** The `sub_id` namespace is
|
||
**per-connection, not global**. Cross-connection collisions are
|
||
structurally impossible: `SubRegistry.subs` is keyed
|
||
`entry(conn_id).or_default().insert(sub_id, …)` (`subscription.rs:66-69`)
|
||
and every index entry stores `(conn_id, sub_id)`. Same-connection reuse
|
||
(`REQ` with `sub_id="x"` superseding a prior `sub_id="x"`) is closed by
|
||
`subscription.rs::register` calling `remove_subscription(conn_id, &sub_id)`
|
||
at `:64` before re-insert, and by the new subscription re-running G1
|
||
against the connection's current `ResolvedScope(conn)`.
|
||
|
||
## Summary
|
||
|
||
One shared Postgres, one canonical `community_id`-keyed message log, stateless
|
||
relay workers, a relational tenant-scoped control plane, and disposable
|
||
tenant-scoped projections — with isolation stated as label-flow non-interference
|
||
(TLA+), authorization soundness stated as trace lemmas under a Dolev-Yao
|
||
adversary (Tamarin), every shared logical channel enumerated and closed, and
|
||
every invariant mutation-tested. Safety is machine-checkable relative to the RLS,
|
||
crypto, and resolution axioms, each admitted per deployment by a conformance gate.
|