2026-06-26 11:15:59 -04:00
|
|
|
|
# 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.
|
2026-06-29 12:39:02 -04:00
|
|
|
|
- **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.
|
2026-06-26 11:15:59 -04:00
|
|
|
|
- **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`.
|
2026-07-16 00:10:29 +10:00
|
|
|
|
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`.
|
2026-06-26 11:15:59 -04:00
|
|
|
|
|
|
|
|
|
|
#### 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.
|