From 6d6b29fdc607904c0b39264a27c1ffc016d93e92 Mon Sep 17 00:00:00 2001 From: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co> Date: Thu, 25 Jun 2026 11:39:03 -0400 Subject: [PATCH 01/11] docs: multi-tenant relay isolation spec + mechanized proofs Spec and formal proof for moving Buzz to multi-tenant: one shared Postgres, N stateless relay workers, M communities, with cross-community isolation stated as label-flow non-interference and authorization soundness stated as Dolev-Yao trace lemmas. House-style matching git-on-s3 and NIP-AB. Artifacts: - docs/multi-tenant-relay.md prose spec: threat model, typed observational interface, label-propagation rules (L1-L8), axioms (A-RLS-1..5, P-APPEND, P-SIG, P-RESOLVE incl. community immutability, A_HASH, P3), safety theorems (I1-I4 isolation, S1-S4 authorization), per-deployment conformance gates, and an Implementation Correspondence section mapping every obligation to a code seam (incl. the G1/G2 subscription-pipeline abstraction). - docs/spec/MultiTenantRelay.tla + .cfg TLA+ isolation model. Exhaustive green: 102,742,532 generated / 4,350,464 distinct / depth 13. Non-vacuity by three confirmed-red mutations: unscoped-id read (depth 4), raw-error leak (depth 2), and M3 global-conflict key (depth 3). - docs/spec/MultiTenantAuth.spthy Tamarin authorization model. S1/S2 verified green (Tamarin 1.12.0 / Maude 3.5.1); S3/S4 authored, not yet verified. - docs/spec/.gitignore scopes out TLC scratch output. Red-teamed by Sami (A_HASH/A-RLS-5 closure, P-RESOLVE immutability), Quinn (SanitizedErrors carrier, subscription-pipeline correspondence), Max (M3 mutation, P3 HA escalation). S3/S4 remain the next proof round. Co-authored-by: Tyler Longwell Signed-off-by: Tyler Longwell --- docs/multi-tenant-relay.md | 728 ++++++++++++++++++++++++++++++++ docs/spec/.gitignore | 5 + docs/spec/MultiTenantAuth.spthy | 410 ++++++++++++++++++ docs/spec/MultiTenantRelay.cfg | 23 + docs/spec/MultiTenantRelay.tla | 550 ++++++++++++++++++++++++ 5 files changed, 1716 insertions(+) create mode 100644 docs/multi-tenant-relay.md create mode 100644 docs/spec/.gitignore create mode 100644 docs/spec/MultiTenantAuth.spthy create mode 100644 docs/spec/MultiTenantRelay.cfg create mode 100644 docs/spec/MultiTenantRelay.tla diff --git a/docs/multi-tenant-relay.md b/docs/multi-tenant-relay.md new file mode 100644 index 000000000..a5b798110 --- /dev/null +++ b/docs/multi-tenant-relay.md @@ -0,0 +1,728 @@ +# 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** from the channel the operation names +(`resolve : channel_id → community_id`, an indexed lookup the relay owns under +the same transaction snapshot as the operation). It is **never** read from the +client-supplied `h` tag. + +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. + +**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. + +### 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 from `resolve(channel_id)` at insert time. The `h` tag is + **not** the label source. (Resolution is a fence.) +- **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; re-scoping requires re-auth; all its observations inherit that scope. +- **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:85`). Any future + re-tenanting would be a separate axiomatic admission with its own audit + discipline and re-verification of S1/S2 (and I1–I4). +- **(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 — the fence the `h`-tag +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–I4 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)`, never the `h` + tag; an adversary `h = C' ≠ resolve = C` cannot widen what is served or + accepted. +- **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. + +### 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. + +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 (this draft).** S1 and S2 are **machine-verified green** on +Tamarin 1.12.0 / Maude 3.5.1 (`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), each with its exists-trace sanity lemma also verified, and the +`MUTATION_Use_Token_Claimed_Community` mutation confirmed red +(`falsified — found trace`). S3 and S4 lemmas are **authored in the model but not +yet verified**; this draft claims S1/S2 as the proven milestone and tracks S3/S4 +as the next proof round. The committed `.spthy` is byte-identical +(SHA-256 `1e7fb042…aceaacf24`) to the artifact behind the green S1/S2 run. + +## 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`-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 ` (`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.* — 102,742,532 states generated, 4,350,464 distinct, 0 left + on queue, depth 13 (~5 min 45 s, single-threaded). Non-vacuity is shown by three mutations, 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; and 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 + `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`. S1/S2 lemmas verify + green (Tamarin 1.12.0 / Maude 3.5.1) — each paired with a verified exists-trace + sanity lemma, and the commented `MUTATION_Use_Token_Claimed_Community` (authorize + from a client-supplied tag) confirmed to falsify `token_confinement` when + uncommented. S3/S4 lemmas are authored but not yet verified (see §Authorization + soundness). The committed file is SHA-256 `1e7fb042…aceaacf24`. + + **Machine-check hygiene.** S1–S4 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`, 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). **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. + +### 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=`**; + 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(conn_id, sub_id, filters, channel_id)` + called (`:202-204`). `rg -U -n "sub_registry\s*\n?\s*\.register\(" crates/buzz-relay/src` + (`-U` is required — the prod call splits `.sub_registry` and `.register(` + across `:203-204`, so the single-line pattern would miss it) returns + exactly three sites: `req.rs:204` is the **sole** production caller; the + two others (`mesh_signaling.rs:550`, `event.rs:1058`) are inside + `#[cfg(test)]` modules. + +#### 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. diff --git a/docs/spec/.gitignore b/docs/spec/.gitignore new file mode 100644 index 000000000..7c046b669 --- /dev/null +++ b/docs/spec/.gitignore @@ -0,0 +1,5 @@ +# TLC model-checker scratch output (fingerprint/state dirs, per-run). +# Generated by `tlc` runs of MultiTenantRelay.tla; not part of the artifact. +states/ +*.st +*.fp diff --git a/docs/spec/MultiTenantAuth.spthy b/docs/spec/MultiTenantAuth.spthy new file mode 100644 index 000000000..e3ea1e2bf --- /dev/null +++ b/docs/spec/MultiTenantAuth.spthy @@ -0,0 +1,410 @@ +theory MultiTenantAuth +begin + +builtins: signing, hashing + +// ============================================================================ +// Multi-tenant relay auth/key/audit model (draft skeleton) +// ============================================================================ +// +// This model covers the symbolic security surface for the multi-tenant relay: +// NIP-98 minting, stamped bearer-token use, per-community signing keys, and +// independent per-community audit chains. It intentionally follows the house +// style of crates/buzz-core/src/pairing/NIP-AB.spthy: explicit adversary/leak +// rules, action facts for theorem statements, and reachability / anti-vacuity +// lemmas near the bottom. +// +// Final theorem wording is expected to be tightened by the prose contract in +// docs/multi-tenant-relay.md. Until then these lemmas are the intended shape, +// not the final public statement. + +// Tamarin has no primitive != in lemma conclusions; model inequality through an +// action fact guarded by a global restriction. Rules emit Neq(x,y) only at the +// comparison point relevant to the counterexample. +restriction Inequality: + "All x #i. Neq(x, x) @ i ==> F" + +restriction Equality: + "All x y #i. Eq(x, y) @ i ==> x = y" + +// ============================================================================ +// Setup: communities, channels, clients +// ============================================================================ + +rule Create_Community: + [ Fr(~comm), Fr(~sk_comm) ] + --[ + CommunityCreated(~comm, pk(~sk_comm)) + ]-> + [ + !Community(~comm), + !CommunitySigningKey(~comm, ~sk_comm), + AuditHead(~comm, 'genesis') + ] + +rule Register_Channel: + [ !Community(comm), Fr(~chan) ] + --[ + ChannelRegistered(~chan, comm) + ]-> + [ + !ChannelCommunity(~chan, comm), + Out(~chan) + ] + +rule Register_Client: + [ Fr(~sk_client) ] + --[ + ClientRegistered(pk(~sk_client)) + ]-> + [ + !ClientPublic(pk(~sk_client)), + !ClientSecret(pk(~sk_client), ~sk_client), + Out(pk(~sk_client)) + ] + +rule Compromise_Client_Key: + [ !ClientSecret(client, sk) ] + --[ + ClientKeyCompromised(client) + ]-> + [ Out(sk) ] + +// ============================================================================ +// NIP-98 minting +// ============================================================================ + +// A single wire constructor models all mint requests. The requested channel set +// is bounded to two slots for model finiteness; a one-channel mint is represented +// as (chanA = chanB). This avoids proving S2 only for a special "multi" shape: +// acceptance vs rejection is forced solely by server-side resolution of the +// requested channels, not by which constructor the client chose. +// +// The client signs a kind:27235 event binding URL, method, payload hash, +// freshness bucket, and the full requested channel set. Freshness is abstracted +// as a relay-accepted time bucket; exact ±60s wall-clock arithmetic is a prose +// / implementation axiom under P3. +rule Client_Sends_NIP98_Mint: + [ !ClientSecret(client, sk), + !ChannelCommunity(chanA, commA), + !ChannelCommunity(chanB, commB), + Fr(~url), Fr(~body), Fr(~time) ] + --[ + NIP98MintRequested(h(< client, ~url, h(~body), ~time, chanA, chanB >), + client, chanA, commA, chanB, commB) + ]-> + [ + Out( + < 'nip98_mint', + client, + ~url, + 'POST', + h(~body), + ~time, + chanA, + chanB, + sign(< 'kind27235', client, ~url, 'POST', h(~body), ~time, chanA, chanB >, sk) + > + ) + ] + +// Successful mint: both requested channels resolve to the same community. The +// stamped community is a fact on the token term (`!Token(tok, client, comm)`) and +// each requested channel is recorded as resolving to that stamp. +rule Relay_Mints_Token_All_Channels_Same_Community: + [ In( + < 'nip98_mint', + client, + url, + 'POST', + payload_hash, + time, + chanA, + chanB, + sig + > + ), + !ClientPublic(client), + !ChannelCommunity(chanA, comm), + !ChannelCommunity(chanB, comm), + Fr(~tok) + ] + --[ + Eq(verify(sig, < 'kind27235', client, url, 'POST', payload_hash, time, chanA, chanB >, client), true), + AllResolveSame(h(< client, url, payload_hash, time, chanA, chanB >), comm, chanA, chanB), + NIP98Accepted(h(< client, url, payload_hash, time, chanA, chanB >), client, comm, chanA), + NIP98Accepted(h(< client, url, payload_hash, time, chanA, chanB >), client, comm, chanB), + TokenMinted(~tok, client, comm), + TokenMintedForRequest(~tok, h(< client, url, payload_hash, time, chanA, chanB >), client, comm), + TokenStamped(~tok, comm), + MintChannel(~tok, chanA, comm), + MintChannel(~tok, chanB, comm), + RequestChannel(h(< client, url, payload_hash, time, chanA, chanB >), chanA, comm), + RequestChannel(h(< client, url, payload_hash, time, chanA, chanB >), chanB, comm) + ]-> + [ + !Token(~tok, client, comm), + Out(~tok) + ] + +// Failed mint: the same wire constructor, same signed shape, but the server-side +// resolver finds two different communities. This emits a rejection witness and +// produces no token. S2 is therefore about resolution, not about the client +// selecting a special "cross-community" event type. +rule Relay_Rejects_Mint_Channels_Resolve_Differently: + [ In( + < 'nip98_mint', + client, + url, + 'POST', + payload_hash, + time, + chanA, + chanB, + sig + > + ), + !ClientPublic(client), + !ChannelCommunity(chanA, commA), + !ChannelCommunity(chanB, commB) + ] + --[ + Eq(verify(sig, < 'kind27235', client, url, 'POST', payload_hash, time, chanA, chanB >, client), true), + Neq(commA, commB), + ChannelsResolveDifferently(h(< client, url, payload_hash, time, chanA, chanB >), commA, commB, chanA, chanB), + CrossCommunityMintRejected(h(< client, url, payload_hash, time, chanA, chanB >), client, commA, commB, chanA, chanB) + ]-> + [ ] + +rule Leak_Token: + [ !Token(tok, client, comm) ] + --[ + TokenLeaked(tok, client, comm) + ]-> + [ Out(tok) ] + +// ============================================================================ +// Token use +// ============================================================================ + +// Token use resolves the target community server-side from the requested channel. +// There is intentionally no client-supplied community or h-tag in this rule. +rule Use_Token: + [ In(tok), !Token(tok, client, comm), !ChannelCommunity(chan, comm) ] + --[ + ActionAuthorized(tok, client, comm, chan), + TokenUsedForCommunity(tok, comm) + ]-> + [ ] + +// Non-vacuity mutation for S1 (DO NOT ENABLE in the real model): this is the +// tempting confused-deputy bug where the relay authorizes from a client-supplied +// claimed community / h-tag rather than from `!ChannelCommunity(chan, comm)`. +// +// rule MUTATION_Use_Token_Claimed_Community: +// [ In(< tok, claimed_comm >), !Token(tok, client, minted_comm) ] +// --[ +// Neq(minted_comm, claimed_comm), +// ActionAuthorized(tok, client, claimed_comm, 'attacker-chosen-channel'), +// TokenUsedForCommunity(tok, claimed_comm) +// ]-> +// [ ] +// +// Expected mutation result: `token_confinement` goes red with a trace containing +// TokenMinted(tok, client, minted_comm) and ActionAuthorized(..., claimed_comm, +// ...) under Neq(minted_comm, claimed_comm). Confirmed by uncommenting this +// rule and running `tamarin-prover --prove=token_confinement`: falsified with a +// 15-step trace on Tamarin 1.12.0 / Maude 3.5.1. + +// Probe rule: the adversary can try to use a token against a channel in another +// community; the real model records the attempt but does not authorize it. +rule Probe_Cross_Community_Token_Use: + [ In(tok), !Token(tok, client, minted_comm), !ChannelCommunity(chan, resolved_comm) ] + --[ + Neq(minted_comm, resolved_comm), + CrossCommunityUseAttempt(tok, client, minted_comm, resolved_comm, chan) + ]-> + [ ] + +// ============================================================================ +// Per-community signing keys +// ============================================================================ +// +// NIP-29 grounding: relay-signed `39000`/`39001`/`39002` discovery/system events +// are community-scoped even when group ids collide. The signed preimage commits +// to (event kind, community id, group id, payload), so a B-key-signed metadata, +// admin-list, or member-list event cannot be replayed as an A event. + +rule Community_Signs_NIP29_System_Event: + [ !CommunitySigningKey(comm, sk), Fr(~group), Fr(~payload) ] + --[ + SystemEventSigned(comm, '39000', ~group, h(~payload)), + SystemEventSigned(comm, '39001', ~group, h(~payload)), + SystemEventSigned(comm, '39002', ~group, h(~payload)) + ]-> + [ + Out(< 'system_event', '39000', comm, ~group, h(~payload), + sign(< 'system_event', '39000', comm, ~group, h(~payload) >, sk) >), + Out(< 'system_event', '39001', comm, ~group, h(~payload), + sign(< 'system_event', '39001', comm, ~group, h(~payload) >, sk) >), + Out(< 'system_event', '39002', comm, ~group, h(~payload), + sign(< 'system_event', '39002', comm, ~group, h(~payload) >, sk) >) + ] + +rule Relay_Accepts_System_Event: + [ In(< 'system_event', kind, comm, group, msg, + sign(< 'system_event', kind, comm, group, msg >, sk) >), + !CommunitySigningKey(comm, sk) + ] + --[ + SystemEventAccepted(comm, kind, group, msg) + ]-> + [ ] + +rule Compromise_Community_Signing_Key: + [ !CommunitySigningKey(comm, sk) ] + --[ + CommunityKeyCompromised(comm) + ]-> + [ Out(sk) ] + +// ============================================================================ +// Independent per-community audit chains +// ============================================================================ +// +// Target shape, not today's implementation: current `buzz-audit` has one global +// chain (`buzz-audit/src/service.rs` reads the latest global hash). Multi-tenant +// safety requires N independent community-labeled heads so the spec's +// Implementation Correspondence section can track replacing the global chain. + +rule Append_Audit: + [ AuditHead(comm, prev), Fr(~seq), Fr(~entry) ] + --[ + AuditEntryCreated(comm, ~seq, prev, h(< 'audit', comm, ~seq, prev, ~entry >)), + AuditAppended(comm, prev, h(< 'audit', comm, ~seq, prev, ~entry >)), + AuditHeadAdvanced(comm, prev, h(< 'audit', comm, ~seq, prev, ~entry >)) + ]-> + [ + AuditHead(comm, h(< 'audit', comm, ~seq, prev, ~entry >)), + Out(h(< 'audit', comm, ~seq, prev, ~entry >)) + ] + +rule Probe_Audit_Cross_Community_Splice: + [ AuditHead(commA, prevA), AuditHead(commB, prevB), Fr(~seq), Fr(~entry) ] + --[ + Neq(commA, commB), + CrossCommunityAuditSpliceAttempt(commA, commB, prevA, prevB, h(< 'audit', commA, ~seq, prevB, ~entry >)) + ]-> + [ ] + +// ============================================================================ +// Draft security lemmas +// ============================================================================ + +lemma executable_core_flow: + exists-trace + "Ex tok client comm chan #i #j. + TokenMinted(tok, client, comm) @ i + & ActionAuthorized(tok, client, comm, chan) @ j + & #i < #j" + +lemma executable_cross_community_mint_rejection: + exists-trace + "Ex req client commA commB chanA chanB #i. + CrossCommunityMintRejected(req, client, commA, commB, chanA, chanB) @ i" + +// S1: token use is confined to the token's stamped community. This remains true +// even when `Leak_Token` makes the bearer token known to the adversary. +lemma token_confinement: + "All tok client minted_comm used_comm chan #i #j. + TokenMinted(tok, client, minted_comm) @ i + & ActionAuthorized(tok, client, used_comm, chan) @ j + ==> minted_comm = used_comm" + +lemma leaked_token_blast_radius_contained: + "All tok client minted_comm used_comm chan #i #j. + TokenLeaked(tok, client, minted_comm) @ i + & ActionAuthorized(tok, client, used_comm, chan) @ j + ==> minted_comm = used_comm" + +lemma cross_community_use_attempts_are_not_authorized: + "All tok client minted_comm resolved_comm chan #i. + CrossCommunityUseAttempt(tok, client, minted_comm, resolved_comm, chan) @ i + ==> not (Ex #j. ActionAuthorized(tok, client, resolved_comm, chan) @ j)" + +// S2: every minted token has exactly one stamped community, and every requested +// channel recorded for that mint resolved to that stamp. +lemma minted_token_channels_match_stamp: + "All tok client comm chan chan_comm #i #j. + TokenMinted(tok, client, comm) @ i + & MintChannel(tok, chan, chan_comm) @ j + ==> comm = chan_comm" + +lemma minted_request_channels_match_stamp: + "All tok req client comm chan chan_comm #i #j #k. + TokenMintedForRequest(tok, req, client, comm) @ i + & RequestChannel(req, chan, chan_comm) @ j + & TokenStamped(tok, comm) @ k + ==> comm = chan_comm" + +lemma token_stamp_matches_mint: + "All tok client comm stamp #i #j. + TokenMinted(tok, client, comm) @ i + & TokenStamped(tok, stamp) @ j + ==> comm = stamp" + +lemma cross_community_mint_yields_no_token_for_that_request: + "All req client commA commB chanA chanB #i. + CrossCommunityMintRejected(req, client, commA, commB, chanA, chanB) @ i + ==> not (Ex tok comm #j. TokenMintedForRequest(tok, req, client, comm) @ j)" + +// S3 shape: accepting an event for community A requires A's signing key, unless +// A's signing key has been compromised. Compromise of another community's key is +// not sufficient because the signed preimage includes the community id. +lemma system_event_acceptance_requires_same_community_key_or_compromise: + "All comm kind group msg #i. + SystemEventAccepted(comm, kind, group, msg) @ i + ==> (Ex #j. SystemEventSigned(comm, kind, group, msg) @ j & #j < #i) + | (Ex #k. CommunityKeyCompromised(comm) @ k & #k < #i)" + +lemma other_community_key_compromise_does_not_authorize: + "All commA commB kind group msg #i #j. + CommunityKeyCompromised(commB) @ i + & SystemEventAccepted(commA, kind, group, msg) @ j + & Neq(commA, commB) @ i + ==> (Ex #k. SystemEventSigned(commA, kind, group, msg) @ k & #k < #j) + | (Ex #l. CommunityKeyCompromised(commA) @ l & #l < #j)" + +// S4 shape: every audit append advances a head for the same community and the +// next hash binds that community id, so another community's head cannot be used +// as a splice without changing the hash/preimage. +lemma audit_append_advances_same_community_head: + "All comm prev next #i. + AuditAppended(comm, prev, next) @ i + ==> AuditHeadAdvanced(comm, prev, next) @ i" + +lemma cross_community_audit_splice_attempt_is_not_append: + "All commA commB prevA prevB forged #i. + CrossCommunityAuditSpliceAttempt(commA, commB, prevA, prevB, forged) @ i + ==> not (Ex #j. AuditAppended(commA, prevB, forged) @ j)" + +// Reachability / anti-vacuity probes. +lemma executable_token_leak: + exists-trace + "Ex tok client comm #i. TokenLeaked(tok, client, comm) @ i" + +lemma leaked_token_can_authorize_within_its_community: + exists-trace + "Ex tok client comm chan #i #j. + TokenLeaked(tok, client, comm) @ i + & ActionAuthorized(tok, client, comm, chan) @ j" + +lemma executable_signing_key_compromise: + exists-trace + "Ex comm #i. CommunityKeyCompromised(comm) @ i" + +lemma executable_audit_append: + exists-trace + "Ex comm prev next #i. AuditAppended(comm, prev, next) @ i" + +end diff --git a/docs/spec/MultiTenantRelay.cfg b/docs/spec/MultiTenantRelay.cfg new file mode 100644 index 000000000..03b7a0f72 --- /dev/null +++ b/docs/spec/MultiTenantRelay.cfg @@ -0,0 +1,23 @@ +\* TLC model-check config for the draft MultiTenantRelay model. +\* Run: +\* java -cp ~/.buzz/.scratch/tla2tools.jar tlc2.TLC -config MultiTenantRelay.cfg MultiTenantRelay.tla +SPECIFICATION Spec + +CONSTANTS + Communities = {commA, commB} + Channels = {chanA1, chanA2, chanB1, chanB2} + Actors = {alice} + Workers = {relay1} + MsgIds = {msg1, msg2} + AuditVals = {audit0, audit1} + CommA = commA + CommB = commB + ChanA1 = chanA1 + ChanA2 = chanA2 + ChanB1 = chanB1 + ChanB2 = chanB2 + SanitizedErrors = {"auth-required", "restricted", "invalid", "duplicate", "pow", "rate-limited", "blocked", "error", "frame-too-large"} + +INVARIANT Safety +CONSTRAINT BoundedObservations +SYMMETRY Symmetry diff --git a/docs/spec/MultiTenantRelay.tla b/docs/spec/MultiTenantRelay.tla new file mode 100644 index 000000000..cc23c1848 --- /dev/null +++ b/docs/spec/MultiTenantRelay.tla @@ -0,0 +1,550 @@ +-------------------------- MODULE MultiTenantRelay -------------------------- +(***************************************************************************) +(* Formal model of Buzz's proposed multi-tenant relay/database isolation. *) +(* *) +(* This is the TLA+ half of the multi-tenant relay proof. It models N *) +(* stateless relay workers over one shared Postgres database containing a *) +(* community_id-keyed canonical message log, tenant-scoped control-plane *) +(* state, and rebuildable projections. *) +(* *) +(* The master proof obligation is NOT merely "no row with the wrong *) +(* community_id is returned." The theorem contract is non-interference *) +(* encoded as a label/taint invariant: every state element and every *) +(* observation carries the community labels that influence it; no value *) +(* labeled outside a connection's resolved community may flow into that *) +(* connection's typed observational interface. *) +(* *) +(* C1 carve-out (not modeled as a security theorem): bandwidth-limited *) +(* physical resource timing channels such as buffer cache, autovacuum, *) +(* planner stats, hot partition tails, and connection-pool latency. *) +(* *) +(* C2 channels modeled here and closed by invariants: *) +(* - event-id existence oracle: write conflict checks are scoped by *) +(* (community, id), not global id; cross-community same-id writes do *) +(* not suppress each other. A_HASH covers adversarial preimage probing. *) +(* - constraint/error surface: the relay emits only a fixed sanitized *) +(* error alphabet; sanitized error observations are relay-static and *) +(* carry no tenant label. *) +(* - projection rebuild: rebuild touches all communities internally but *) +(* emits no tenant observation; tenant reads see only own projection *) +(* rows (or a subset/none during rebuild). *) +(* *) +(* Source grounding from today's Buzz: *) +(* - migrations/0001_initial_schema.sql: events, channels, *) +(* channel_members, event_mentions, thread_metadata, reactions, *) +(* workflows, api_tokens, relay_members. *) +(* - crates/buzz-db/src/event.rs: EventQuery has channel_id/channel_ids *) +(* but no community_id; inserts use ON CONFLICT DO NOTHING. *) +(* - crates/buzz-db/src/channel.rs: get_accessible_channel_ids currently *) +(* unions all open channels in the DB; that unscoped variant is the *) +(* explicit I1 mutation. *) +(* - crates/buzz-relay/src/state.rs: process-global AppState/caches today. *) +(* *) +(* Mutation tests to keep non-vacuous: *) +(* M1: ReadScoped uses UnscopedAccessible(actor) instead of *) +(* ScopedAccessible(community, actor) -> Inv_NonInterference breaks. *) +(* M2: WriteInsert/AuthCheck use claimedCommunity/h tag instead of *) +(* ChannelCommunity[channel] -> resolution-fence invariants break. *) +(* M3: WriteDuplicate conflict on id only (GlobalConflictRows + guard *) +(* conflicts # {}), not (community,id) -> cross-community suppression *) +(* labels a B write result with A and Inv_NonInterference breaks. *) +(* Confirmed red: Safety violated at depth 3 (see GlobalConflictRows). *) +(* M4: ReadForgotPredicateWithRLS returns candidates without RLSRows -> *) +(* Inv_NonInterference breaks. *) +(* M5: Projection rebuild emits an observation or projection reads ignore *) +(* row labels -> Inv_NonInterference breaks. *) +(* M6: Error observation carries raw/high labels or a value outside the *) +(* sanitized alphabet -> Inv_SanitizedErrors/NI breaks. *) +(* M7: Direct ids lookup ignores ctx and resolves scope from the row/global *) +(* id index -> a B-scoped observation can carry an A-labeled row. *) +(***************************************************************************) +EXTENDS FiniteSets, Naturals, TLC + +CONSTANTS + Communities, \* finite set of community ids + Channels, \* finite set of channel ids + Actors, \* finite set of pubkeys/actors + Workers, \* finite set of relay worker/process ids + MsgIds, \* finite set of event ids (model bound) + AuditVals, \* finite set of audit head values (model bound) + CommA, \* model value: first community in TLC config + CommB, \* model value: second community in TLC config + ChanA1, \* model value: community-A channel in TLC config + ChanA2, \* model value: community-A channel in TLC config + ChanB1, \* model value: community-B channel in TLC config + ChanB2, \* model value: community-B channel in TLC config + SanitizedErrors \* fixed WebSocket-reachable sanitized error alphabet + +ObsKinds == {"ResultRows", "WriteResult", "SanitizedError", "AuditHead", "AuthVerdict"} +MaxObservations == 2 +WriteResults == {"Inserted", "Duplicate", "None"} +AuthVerdicts == {"Allow", "Deny", "None"} +NoError == "NoError" +NoAudit == "NoAudit" + +ChannelCommunity == [ch \in Channels |-> IF ch \in {ChanA1, ChanA2} THEN CommA ELSE CommB] + +Symmetry == + Permutations(Actors) \cup + Permutations(Workers) \cup + Permutations(MsgIds) \cup + Permutations(AuditVals) + +VARIABLES + messages, \* set of canonical message rows (source="message") + projections, \* set of rebuildable projection rows (source="projection") + memberships, \* tenant-scoped active channel membership rows + openChannels, \* set of open/public channel ids + auditHeads, \* function: community -> current audit head + observations, \* typed outputs visible to tenant-scoped clients + acceptedWrites, \* write requests that inserted a new message row + duplicateWrites, \* write requests that no-op'd on scoped conflict + queryFaults \* fail-closed query-layer faults (e.g. no TenantContext) + +vars == <> + +DataRows == [ + id : MsgIds, + community : Communities, + channel : Channels, + author : Actors, + source : {"message", "projection"} +] + +MessageRows == {r \in DataRows : r.source = "message"} +ProjectionRows == {r \in DataRows : r.source = "projection"} + +MembershipRows == [ + community : Communities, + channel : Channels, + actor : Actors +] + +AcceptedWriteRows == [ + worker : Workers, + id : MsgIds, + community : Communities, + channel : Channels, + author : Actors, + claimedCommunity : Communities +] + +DuplicateWriteRows == [ + worker : Workers, + id : MsgIds, + community : Communities, + channel : Channels, + author : Actors, + claimedCommunity : Communities +] + +Observations == [ + worker : Workers, + actor : Actors, + community : Communities, \* resolved/request TenantContext community + channel : Channels, \* target channel for channel-scoped ops + kind : ObsKinds, + labels : SUBSET Communities, \* taint labels influencing this observation + rows : SUBSET DataRows, \* row/projection dependencies, if any + error : SanitizedErrors \cup {NoError}, + result : WriteResults, + verdict : AuthVerdicts, + audit : AuditVals \cup {NoAudit} +] + +QueryFaultRows == [ + worker : Workers, + actor : Actors, + community : Communities, + reason : {"missing_tenant_context"} +] + +MessageRow(id, c, ch, a) == + [id |-> id, community |-> c, channel |-> ch, author |-> a, source |-> "message"] + +ProjectionRow(m) == + [id |-> m.id, community |-> m.community, channel |-> m.channel, + author |-> m.author, source |-> "projection"] + +RowLabels(rows) == {r.community : r \in rows} + +MessageKeys == {[community |-> m.community, id |-> m.id] : m \in messages} + +ScopedConflictRows(c, id) == {m \in messages : m.community = c /\ m.id = id} +\* Intentionally-bad global conflict set for mutation M3 (the missing- +\* community_id-in-the-unique-index footgun, i.e. UNIQUE(id) instead of +\* UNIQUE(community_id,...,id)). To run M3: in WriteDuplicate substitute +\* conflicts == ScopedConflictRows(real, id) +\* and change the duplicate guard from key \in MessageKeys to conflicts # {} +\* (a global index fires the dup branch whenever the id exists in ANY community). +\* Confirmed red: Invariant Safety violated at depth 3, with a B-scoped +\* WriteResult observation carrying labels |-> {commA} (the C2.1 existence-oracle +\* leak). Closure is A-RLS-5 (composite index), with A_HASH as supporting axiom. +GlobalConflictRows(id) == {m \in messages : m.id = id} + +DerivedProjectionRows == {ProjectionRow(m) : m \in messages} + +TypeOK == + /\ Communities # {} + /\ Channels # {} + /\ Actors # {} + /\ Workers # {} + /\ MsgIds # {} + /\ AuditVals # {} + /\ ChannelCommunity \in [Channels -> Communities] + /\ CommA \in Communities + /\ CommB \in Communities + /\ CommA # CommB + /\ {ChanA1, ChanA2, ChanB1, ChanB2} \subseteq Channels + /\ ChanA1 # ChanB1 + /\ SanitizedErrors # {} + /\ NoError \notin SanitizedErrors + /\ NoAudit \notin AuditVals + /\ messages \subseteq MessageRows + /\ projections \subseteq ProjectionRows + /\ projections \subseteq DerivedProjectionRows + /\ memberships \subseteq MembershipRows + /\ openChannels \subseteq Channels + /\ auditHeads \in [Communities -> AuditVals] + /\ observations \subseteq Observations + /\ acceptedWrites \subseteq AcceptedWriteRows + /\ duplicateWrites \subseteq DuplicateWriteRows + /\ queryFaults \subseteq QueryFaultRows + \* Tenant-scoped control plane: a membership row's community agrees with + \* the server-owned channel -> community mapping. + /\ \A m \in memberships : m.community = ChannelCommunity[m.channel] + \* Message/projection rows are stamped with the server-owned channel mapping. + /\ \A m \in messages : m.community = ChannelCommunity[m.channel] + /\ \A p \in projections : p.community = ChannelCommunity[p.channel] + +Init == + /\ messages = {} + /\ projections = {} + /\ memberships = {} + /\ openChannels = {} + /\ auditHeads \in [Communities -> AuditVals] + /\ observations = {} + /\ acceptedWrites = {} + /\ duplicateWrites = {} + /\ queryFaults = {} + +ScopedAccessible(community, actor) == + {ch \in Channels : + /\ ChannelCommunity[ch] = community + /\ (ch \in openChannels \/ + [community |-> community, channel |-> ch, actor |-> actor] + \in memberships)} + +\* Intentionally-bad operator matching today's shared-DB landmine: open channels +\* are global, not scoped by TenantContext. The correct spec does not call this; +\* substitute it into ReadScoped/ReadProjectionRows for mutation M1. +UnscopedAccessible(actor) == + {ch \in Channels : + ch \in openChannels \/ + \E c \in Communities : + [community |-> c, channel |-> ch, actor |-> actor] \in memberships} + +VisibleMessageRows(community, actor) == + {m \in messages : + /\ m.community = community + /\ m.channel \in ScopedAccessible(community, actor)} + +VisibleProjectionRows(community, actor) == + {p \in projections : + /\ p.community = community + /\ p.channel \in ScopedAccessible(community, actor)} + +VisibleDirectIdRows(community, actor, id) == + {m \in messages : + /\ m.id = id + /\ m.community = community + /\ m.channel \in ScopedAccessible(community, actor)} + +\* Intentionally-bad direct lookup mutation: answer by global id first and trust +\* the row's own community as scope. Substitute this into ReadByIdRows for M7. +UnscopedDirectIdRows(actor, id) == + {m \in messages : + /\ m.id = id + /\ m.channel \in UnscopedAccessible(actor)} + +RLSRows(community, rows) == {r \in rows : r.community = community} + +WriteInsert(w) == + /\ Cardinality(observations) < MaxObservations + /\ \E id \in MsgIds, ch \in Channels, a \in Actors, claimed \in Communities : + LET real == ChannelCommunity[ch] + key == [community |-> real, id |-> id] + row == MessageRow(id, real, ch, a) + obs == [worker |-> w, actor |-> a, community |-> real, channel |-> ch, + kind |-> "WriteResult", labels |-> {real}, rows |-> {row}, + error |-> NoError, result |-> "Inserted", verdict |-> "None", audit |-> NoAudit] + wr == [worker |-> w, id |-> id, community |-> real, + channel |-> ch, author |-> a, claimedCommunity |-> claimed] + IN + /\ key \notin MessageKeys + /\ messages' = messages \cup {row} + /\ observations' = observations \cup {obs} + /\ acceptedWrites' = acceptedWrites \cup {wr} + /\ UNCHANGED <> + +WriteDuplicate(w) == + /\ Cardinality(observations) < MaxObservations + /\ \E id \in MsgIds, ch \in Channels, a \in Actors, claimed \in Communities : + LET real == ChannelCommunity[ch] + key == [community |-> real, id |-> id] + conflicts == ScopedConflictRows(real, id) + obs == [worker |-> w, actor |-> a, community |-> real, channel |-> ch, + kind |-> "WriteResult", labels |-> RowLabels(conflicts), rows |-> conflicts, + error |-> NoError, result |-> "Duplicate", verdict |-> "None", audit |-> NoAudit] + wr == [worker |-> w, id |-> id, community |-> real, + channel |-> ch, author |-> a, claimedCommunity |-> claimed] + IN + /\ key \in MessageKeys + /\ messages' = messages + /\ observations' = observations \cup {obs} + /\ duplicateWrites' = duplicateWrites \cup {wr} + /\ UNCHANGED <> + +ReadMessageRows(w) == + /\ Cardinality(observations) < MaxObservations + /\ \E c \in Communities, a \in Actors, ch \in Channels : + LET rows == VisibleMessageRows(c, a) + obs == [worker |-> w, actor |-> a, community |-> c, channel |-> ch, + kind |-> "ResultRows", labels |-> RowLabels(rows), rows |-> rows, + error |-> NoError, result |-> "None", verdict |-> "None", audit |-> NoAudit] + IN + /\ observations' = observations \cup {obs} + /\ UNCHANGED <> + +ReadProjectionRows(w) == + /\ Cardinality(observations) < MaxObservations + /\ \E c \in Communities, a \in Actors, ch \in Channels : + \E rows \in SUBSET VisibleProjectionRows(c, a) : + LET obs == [worker |-> w, actor |-> a, community |-> c, channel |-> ch, + kind |-> "ResultRows", labels |-> RowLabels(rows), rows |-> rows, + error |-> NoError, result |-> "None", verdict |-> "None", audit |-> NoAudit] + IN + /\ observations' = observations \cup {obs} + /\ UNCHANGED <> + +ReadByIdRows(w) == + /\ Cardinality(observations) < MaxObservations + /\ \E c \in Communities, a \in Actors, ch \in Channels, id \in MsgIds : + LET rows == VisibleDirectIdRows(c, a, id) + obs == [worker |-> w, actor |-> a, community |-> c, channel |-> ch, + kind |-> "ResultRows", labels |-> RowLabels(rows), rows |-> rows, + error |-> NoError, result |-> "None", verdict |-> "None", audit |-> NoAudit] + IN + /\ observations' = observations \cup {obs} + /\ UNCHANGED <> + +\* Explicit community predicate was accidentally omitted, but the transaction is +\* inside TenantContext and Postgres RLS applies the community fence. +ReadForgotPredicateWithRLS(w) == + /\ Cardinality(observations) < MaxObservations + /\ \E c \in Communities, a \in Actors, ch \in Channels : + LET candidates == {m \in messages : m.channel \in ScopedAccessible(c, a)} + rows == RLSRows(c, candidates) + obs == [worker |-> w, actor |-> a, community |-> c, channel |-> ch, + kind |-> "ResultRows", labels |-> RowLabels(rows), rows |-> rows, + error |-> NoError, result |-> "None", verdict |-> "None", audit |-> NoAudit] + IN + /\ observations' = observations \cup {obs} + /\ UNCHANGED <> + +\* If the query does not establish TenantContext at all, RLS must fail closed. +ReadNoTenantContext(w) == + /\ Cardinality(observations) < MaxObservations + /\ \E c \in Communities, a \in Actors, ch \in Channels : + LET obs == [worker |-> w, actor |-> a, community |-> c, channel |-> ch, + kind |-> "ResultRows", labels |-> {}, rows |-> {}, + error |-> NoError, result |-> "None", verdict |-> "None", audit |-> NoAudit] + fault == [worker |-> w, actor |-> a, community |-> c, + reason |-> "missing_tenant_context"] + IN + /\ observations' = observations \cup {obs} + /\ queryFaults' = queryFaults \cup {fault} + /\ UNCHANGED <> + +SanitizedError(w) == + /\ Cardinality(observations) < MaxObservations + /\ \E c \in Communities, a \in Actors, ch \in Channels, e \in SanitizedErrors : + LET obs == [worker |-> w, actor |-> a, community |-> c, channel |-> ch, + kind |-> "SanitizedError", labels |-> {}, rows |-> {}, + error |-> e, result |-> "None", verdict |-> "None", audit |-> NoAudit] + IN + /\ observations' = observations \cup {obs} + /\ UNCHANGED <> + +AuthCheck(w) == + /\ Cardinality(observations) < MaxObservations + /\ \E ch \in Channels, a \in Actors, claimed \in Communities : + LET real == ChannelCommunity[ch] + allowed == ch \in ScopedAccessible(real, a) + verdict == IF allowed THEN "Allow" ELSE "Deny" + obs == [worker |-> w, actor |-> a, community |-> real, channel |-> ch, + kind |-> "AuthVerdict", labels |-> {real}, rows |-> {}, + error |-> NoError, result |-> "None", verdict |-> verdict, audit |-> NoAudit] + IN + /\ observations' = observations \cup {obs} + /\ UNCHANGED <> + +AppendAudit(w) == + \E c \in Communities, newHead \in AuditVals : + /\ auditHeads' = [auditHeads EXCEPT ![c] = newHead] + /\ UNCHANGED <> + +ObserveAuditHead(w) == + /\ Cardinality(observations) < MaxObservations + /\ \E c \in Communities, a \in Actors, ch \in Channels : + LET obs == [worker |-> w, actor |-> a, community |-> c, channel |-> ch, + kind |-> "AuditHead", labels |-> {c}, rows |-> {}, + error |-> NoError, result |-> "None", verdict |-> "None", audit |-> auditHeads[c]] + IN + /\ observations' = observations \cup {obs} + /\ UNCHANGED <> + +\* Projection rebuild is privileged internal work. It may touch all communities +\* and may leave projections temporarily partial, but it emits no observation. +RebuildProjections(w) == + \E rebuilt \in SUBSET DerivedProjectionRows : + /\ projections' = rebuilt + /\ UNCHANGED <> + +AddMembership(w) == + \E ch \in Channels, a \in Actors : + LET c == ChannelCommunity[ch] + row == [community |-> c, channel |-> ch, actor |-> a] + IN + /\ memberships' = memberships \cup {row} + /\ UNCHANGED <> + +RemoveMembership(w) == + \E ch \in Channels, a \in Actors : + LET c == ChannelCommunity[ch] + row == [community |-> c, channel |-> ch, actor |-> a] + IN + /\ memberships' = memberships \ {row} + /\ UNCHANGED <> + +OpenChannel(w) == + \E ch \in Channels : + /\ openChannels' = openChannels \cup {ch} + /\ UNCHANGED <> + +CloseChannel(w) == + \E ch \in Channels : + /\ openChannels' = openChannels \ {ch} + /\ UNCHANGED <> + +Next == + \E w \in Workers : + \/ WriteInsert(w) + \/ WriteDuplicate(w) + \/ ReadMessageRows(w) + \/ ReadProjectionRows(w) + \/ ReadByIdRows(w) + \/ ReadForgotPredicateWithRLS(w) + \/ ReadNoTenantContext(w) + \/ SanitizedError(w) + \/ AuthCheck(w) + \/ AppendAudit(w) + \/ ObserveAuditHead(w) + \/ RebuildProjections(w) + \/ AddMembership(w) + \/ RemoveMembership(w) + \/ OpenChannel(w) + \/ CloseChannel(w) + +BoundedObservations == Cardinality(observations) <= MaxObservations + +Spec == Init /\ [][Next]_vars + +------------------------------------------------------------------------------ +\* SAFETY PROPERTIES + +\* NI (master): no observation scoped to community C may be influenced by a row, +\* projection, audit head, auth decision, write-conflict source, or error source +\* labeled outside C. This is the single-run label/taint encoding of the +\* two-execution non-interference theorem. +Inv_NonInterference == + \A o \in observations : o.labels \subseteq {o.community} + +\* Label propagation: observation labels are not arbitrary annotations; they are +\* derived from the dependencies each observation can reveal. +Inv_LabelPropagation == + \A o \in observations : + /\ (o.kind \in {"ResultRows", "WriteResult"} => o.labels = RowLabels(o.rows)) + /\ (o.kind = "SanitizedError" => o.labels = {} /\ o.error \in SanitizedErrors) + /\ (o.kind = "AuditHead" => o.labels = {o.community} /\ o.audit \in AuditVals) + /\ (o.kind = "AuthVerdict" => o.labels = {ChannelCommunity[o.channel]} /\ o.community = ChannelCommunity[o.channel]) + +\* I1 read confinement follows from NI + label propagation, but is kept as a +\* legible mutation target for the current get_accessible_channel_ids landmine. +Inv_ReadConfinement == + \A o \in observations : + o.kind = "ResultRows" => \A r \in o.rows : r.community = o.community + +\* I2 resolution fence: persisted messages and write/auth observations are +\* labeled by the server-owned channel->community mapping, not by a client h tag. +Inv_ResolutionFence == + /\ \A m \in messages : m.community = ChannelCommunity[m.channel] + /\ \A w \in acceptedWrites : w.community = ChannelCommunity[w.channel] + /\ \A w \in duplicateWrites : w.community = ChannelCommunity[w.channel] + /\ \A o \in observations : + o.kind \in {"WriteResult", "AuthVerdict"} => o.community = ChannelCommunity[o.channel] + +\* I3a append persistence: every accepted append remains present in the shared log. +Inv_AcceptedWritesPersist == + \A w \in acceptedWrites : MessageRow(w.id, w.community, w.channel, w.author) \in messages + +\* I3b scoped idempotence: ids are unique within a community, not globally. This +\* permits two different communities to store the same content hash while avoiding +\* the event-id existence oracle as a cross-tenant write-conflict channel. +Inv_MessageKeyUnique == + \A m1, m2 \in messages : + (m1.community = m2.community /\ m1.id = m2.id) => (m1 = m2) + +\* I4 fail-closed backstop: missing TenantContext serves no rows. Dropped SQL +\* predicates inside a valid TenantContext are covered by RLSRows and NI. +Inv_NoTenantContextFailsClosed == + \A o \in observations : + (o.kind = "ResultRows" /\ o.labels = {}) => o.rows = {} + +\* Projection rows are derived-only and inherit the source event label. +Inv_ProjectionDerived == projections \subseteq DerivedProjectionRows + +\* Sanitized error alphabet is the only client-visible error surface in scope. +Inv_SanitizedErrors == + \A o \in observations : + o.kind = "SanitizedError" => o.error \in SanitizedErrors + +Safety == + /\ TypeOK + /\ Inv_NonInterference + /\ Inv_LabelPropagation + /\ Inv_ReadConfinement + /\ Inv_ResolutionFence + /\ Inv_AcceptedWritesPersist + /\ Inv_MessageKeyUnique + /\ Inv_NoTenantContextFailsClosed + /\ Inv_ProjectionDerived + /\ Inv_SanitizedErrors +============================================================================= From aaaea516a2deca4b60e049f4256ee6070965d79f Mon Sep 17 00:00:00 2001 From: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co> Date: Thu, 25 Jun 2026 11:58:01 -0400 Subject: [PATCH 02/11] docs(multi-tenant-relay): verify S3/S4 green, fix vacuous compromise lemma S3/S4 Tamarin lemmas now verify green (Tamarin 1.12.0 / Maude 3.5.1): all 20 lemmas pass in 5.89s, each safety lemma paired with a verified exists-trace sanity lemma, S3/S4 mutations confirmed red. Fixes a vacuity bug in the prior committed artifact (1e7fb042): the lemma 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 and the lemma verified vacuously. The fix decouples the inequality onto a separate witness timepoint; a new exists-trace lemma executable_other_key_compromise_plus_system_accept proves the corrected premise is satisfiable, so the 126-step proof is non-vacuous. Independently confirmed: an exists-trace probe of the OLD premise returns 'no trace found'. .spthy SHA-256 advances 1e7fb042..d29192a39aceaacf24 -> 0ad53184..d644b94d85. Bug found and fixed by Mari on the S3/S4 follow-up lane; verified independently (full suite re-run + bug-probe + all three mutations reproduced) before fold. Co-authored-by: Mari <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Tyler Longwell Signed-off-by: Tyler Longwell --- docs/multi-tenant-relay.md | 48 +++++++++++++++++++++++---------- docs/spec/MultiTenantAuth.spthy | 24 ++++++++++++++--- 2 files changed, 54 insertions(+), 18 deletions(-) diff --git a/docs/multi-tenant-relay.md b/docs/multi-tenant-relay.md index a5b798110..055c8dcde 100644 --- a/docs/multi-tenant-relay.md +++ b/docs/multi-tenant-relay.md @@ -416,18 +416,37 @@ adversary cannot circumvent. A-RLS-1..5 are the load-bearing *backstop*. 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 (this draft).** S1 and S2 are **machine-verified green** on -Tamarin 1.12.0 / Maude 3.5.1 (`token_confinement`, +**Verification status.** S1–S4 are **machine-verified green** on +Tamarin 1.12.0 / Maude 3.5.1 — the full selected run verifies all 20 lemmas in +5.89s 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), each with its exists-trace sanity lemma also verified, and the -`MUTATION_Use_Token_Claimed_Community` mutation confirmed red -(`falsified — found trace`). S3 and S4 lemmas are **authored in the model but not -yet verified**; this draft claims S1/S2 as the proven milestone and tracks S3/S4 -as the next proof round. The committed `.spthy` is byte-identical -(SHA-256 `1e7fb042…aceaacf24`) to the artifact behind the green S1/S2 run. +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` (126 steps). S4: +`audit_append_advances_same_community_head` (2 steps) and +`cross_community_audit_splice_attempt_is_not_append` (1 step). 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). + +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 126-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. The +committed `.spthy` for this milestone is byte-identical (SHA-256 +`0ad53184…d644b94d85`) to the artifact behind the green S1–S4 run. ## Conformance @@ -536,12 +555,13 @@ as label-flow non-interference is, to our knowledge, new for a Nostr relay. 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`. S1/S2 lemmas verify - green (Tamarin 1.12.0 / Maude 3.5.1) — each paired with a verified exists-trace - sanity lemma, and the commented `MUTATION_Use_Token_Claimed_Community` (authorize - from a client-supplied tag) confirmed to falsify `token_confinement` when - uncommented. S3/S4 lemmas are authored but not yet verified (see §Authorization - soundness). The committed file is SHA-256 `1e7fb042…aceaacf24`. + `tamarin-prover --prove docs/spec/MultiTenantAuth.spthy`. All 20 lemmas (S1–S4) + verify green (Tamarin 1.12.0 / Maude 3.5.1, 5.89s) — each safety lemma paired with + a verified exists-trace sanity lemma, and the documented mutations + (`MUTATION_Use_Token_Claimed_Community` for S1, plus the S3 bad-accept and S4 + splice-as-append mutations) confirmed red. See §Authorization soundness for the + full lemma list and the corrected `other_community_key_compromise_does_not_authorize` + vacuity fix. The committed file is SHA-256 `0ad53184…d644b94d85`. **Machine-check hygiene.** S1–S4 lemmas close by two distinct shapes. **Rule-shape closure** means the lemma's conclusion follows by unification on a diff --git a/docs/spec/MultiTenantAuth.spthy b/docs/spec/MultiTenantAuth.spthy index e3ea1e2bf..40a688a99 100644 --- a/docs/spec/MultiTenantAuth.spthy +++ b/docs/spec/MultiTenantAuth.spthy @@ -368,12 +368,12 @@ lemma system_event_acceptance_requires_same_community_key_or_compromise: | (Ex #k. CommunityKeyCompromised(comm) @ k & #k < #i)" lemma other_community_key_compromise_does_not_authorize: - "All commA commB kind group msg #i #j. + "All commA commB kind group msg #i #j #k. CommunityKeyCompromised(commB) @ i & SystemEventAccepted(commA, kind, group, msg) @ j - & Neq(commA, commB) @ i - ==> (Ex #k. SystemEventSigned(commA, kind, group, msg) @ k & #k < #j) - | (Ex #l. CommunityKeyCompromised(commA) @ l & #l < #j)" + & Neq(commA, commB) @ k + ==> (Ex #l. SystemEventSigned(commA, kind, group, msg) @ l & #l < #j) + | (Ex #m. CommunityKeyCompromised(commA) @ m & #m < #j)" // S4 shape: every audit append advances a head for the same community and the // next hash binds that community id, so another community's head cannot be used @@ -399,6 +399,22 @@ lemma leaked_token_can_authorize_within_its_community: TokenLeaked(tok, client, comm) @ i & ActionAuthorized(tok, client, comm, chan) @ j" +lemma executable_system_event_acceptance: + exists-trace + "Ex comm kind group msg #i. SystemEventAccepted(comm, kind, group, msg) @ i" + +lemma executable_other_key_compromise_plus_system_accept: + exists-trace + "Ex commA commB kind group msg #i #j #k. + CommunityKeyCompromised(commB) @ i + & SystemEventAccepted(commA, kind, group, msg) @ j + & Neq(commA, commB) @ k" + +lemma executable_cross_community_audit_splice_attempt: + exists-trace + "Ex commA commB prevA prevB forged #i. + CrossCommunityAuditSpliceAttempt(commA, commB, prevA, prevB, forged) @ i" + lemma executable_signing_key_compromise: exists-trace "Ex comm #i. CommunityKeyCompromised(comm) @ i" From c6f6fef64352405f7f892746c6720c140578498f Mon Sep 17 00:00:00 2001 From: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co> Date: Thu, 25 Jun 2026 14:57:53 -0400 Subject: [PATCH 03/11] docs(multi-tenant): mechanize universal host-binding fence for tenancy Strengthen the multi-tenant isolation proofs so the row-zero contract (URL/host is authoritative for every request) is mechanized, not just asserted in prose. Model (docs/spec): - Thread the originating host through every write/read/auth/token-use action. ResolveTenant resolves the community from the host for channel-less ops and from the server-owned channel mapping for channel-bearing ops, cross-checked against the host community. - WriteInsert and WriteDuplicate now fail closed when the host community disagrees with the resolved channel community, so no record can carry a host that does not map to its stored community (the existence-oracle / duplicate-no-op surface is covered, not just accepted inserts). - Inv_HostBindingFence covers both accepted and duplicate write outcomes. - Tamarin: channel-bearing token use must agree with host; a B-stamped token over an A-host request is not authorized. Removed the vacuous premise; the host-binding lemmas are non-vacuous. - Remove the NoHost sentinel entirely (host : Hosts everywhere). Verification (this tree): TLC 138,717,188 generated / 5,621,760 distinct / 0 errors / depth 13; Tamarin 27/27 verified, 0 falsified. Host-fence mutations confirmed red (insert 2-state, duplicate 3-state traces). Docs: relay spec updated for P-RESOLVE-HOST and the duplicate fence; VISION docs describe community as the proven tenant boundary at N=1. Co-authored-by: Tyler Longwell Signed-off-by: Tyler Longwell --- VISION.md | 12 +- VISION_MESH.md | 6 +- VISION_SOVEREIGN.md | 21 ++- docs/multi-tenant-relay.md | 265 +++++++++++++++++++++++++----- docs/spec/.gitignore | 1 + docs/spec/MultiTenantAuth.spthy | 172 +++++++++++++++++++- docs/spec/MultiTenantRelay.cfg | 6 + docs/spec/MultiTenantRelay.tla | 279 +++++++++++++++++++++++++++----- 8 files changed, 670 insertions(+), 92 deletions(-) diff --git a/VISION.md b/VISION.md index 065417371..e532954ad 100644 --- a/VISION.md +++ b/VISION.md @@ -6,7 +6,7 @@ The platform made it possible. The agent made it happen. Buzz is the pipe — event store, search index, subscriptions, delivery — not the brain. Humans and agents bring the intelligence. Buzz gives them a shared space to use it. -One relay is your entire workspace. Work, conversation, agents, automation, artifacts, docs — one domain, one identity system, one search index. `myproject.com` in a browser shows your repos. `git clone repoa.myproject.com` works. Open the Buzz app and you're in the channels where the work happens. No GitHub. No Discord. No stitching five services together. The project lives in one place, and that place is yours. See [VISION_SOVEREIGN.md](VISION_SOVEREIGN.md) for the full picture. +One community is your entire workspace. Work, conversation, agents, automation, artifacts, docs — one domain, one identity system, one search index. `myproject.com` in a browser shows your repos. `git clone repoa.myproject.com` works. Open the Buzz app and you're in the channels where the work happens. No GitHub. No Discord. No stitching five services together. The project lives in one place, and that place is yours. Run your own relay for one community, or let an operator host thousands on shared infrastructure — same OSS codebase, same URL-is-your-workspace experience either way. See [VISION_SOVEREIGN.md](VISION_SOVEREIGN.md) for the full picture. --- @@ -47,6 +47,16 @@ Guests (investors, reporters, partners) get a scoped token with membership in sp --- +## Communities + +A **community** is the tenant boundary: one workspace, one URL, one isolated world of channels, members, profiles, DMs, repos, and search. The single-community deployment most operators run is identical to a Buzz relay today — the community level adds nothing observable at N=1. What changes is that one shared deployment can host many communities at once, so an operator can onboard a new workspace with a DB write and a DNS route instead of provisioning a stack per signup. + +- **The URL is the community.** `myproject.com` is authoritative — exactly as a relay URL is today, lifted one level up. Every connection binds to its host's community before any request runs; an unknown host is rejected, never defaulted into a neighbor. +- **Isolation is the boundary, not a filter.** Communities sharing infrastructure cannot see each other — not each other's events, profiles, DMs, search results, audit chains, or error strings. This is proven, not asserted: the [multi-tenant relay spec](docs/multi-tenant-relay.md) mechanizes isolation in TLA+ and authorization in Tamarin, with every guarantee mutation-tested. +- **Identity is portable, profiles are per-community.** Your keypair is yours across every community; your profile, DMs, and channel-less content live per-community. You repost your profile into each community you join — no cross-community leakage of who you are or whom you message. + +--- + ## The Protocol [Nostr NIP-01](https://github.com/nostr-protocol/nips/blob/master/01.md) on the wire. Every action — a message, a reaction, a workflow step, a profile update — is a cryptographically signed event: diff --git a/VISION_MESH.md b/VISION_MESH.md index 43d5b11cd..d1354d50f 100644 --- a/VISION_MESH.md +++ b/VISION_MESH.md @@ -2,9 +2,9 @@ > A small team runs their project on one Buzz relay. Three of them have GPUs that sit idle most of the day — a gaming PC, a laptop, a workstation under a desk. One flips a toggle: *Share compute.* The others point their agents at it. Now the whole team's coding agents answer from a capable model running on hardware they already own. No API keys. No cloud bill. Every prompt runs inside the relay community they already chose to trust. -A Buzz relay is a trust group. The people in it already know each other — that shared membership is a decision they've already made. Buzz Mesh turns that decision into shared AI compute: the idle GPUs scattered across your community become one pool, usable by every agent on the relay, gated by the membership you already have. And because the pool is many machines, not one, the community can run models larger and more capable than any one member could load alone. More intelligence becomes reachable when the group works as a group. +A Buzz community is a trust group. The people in it already know each other — that shared membership is a decision they've already made. Buzz Mesh turns that decision into shared AI compute: the idle GPUs scattered across your community become one pool, usable by every agent in the community, gated by the membership you already have. And because the pool is many machines, not one, the community can run models larger and more capable than any one member could load alone. More intelligence becomes reachable when the group works as a group. -Nothing here is new on its own. Pooling GPUs across machines is solved. Nostr identity is solved. Relay-gated membership is how Buzz already works. The insight is that the tool that pools the GPUs already speaks the same protocol Buzz is built on — so the mesh's admission gate and your relay's membership gate are the same gate. +Nothing here is new on its own. Pooling GPUs across machines is solved. Nostr identity is solved. Community-gated membership is how Buzz already works. The insight is that the tool that pools the GPUs already speaks the same protocol Buzz is built on — so the mesh's admission gate and your community's membership gate are the same gate. The boundary is the community, never the deployment: a community on shared infrastructure pools only its own members' compute, and a co-tenant community can't find it, join it, or serve to it. Each piece is boring. The combination is the thing. @@ -34,7 +34,7 @@ This is why it matters most for agents. An agent on your relay isn't reaching ou ## Honest Costs -Your prompts go to people, not a vendor. For a trust group that's a feature — far better than handing them to a stranger's cloud — but it is a different promise than "your data never leaves your machine," and the consent screen says so plainly. A relay is only as private as its membership is trustworthy. +Your prompts go to people, not a vendor. For a trust group that's a feature — far better than handing them to a stranger's cloud — but it is a different promise than "your data never leaves your machine," and the consent screen says so plainly. A community is only as private as its membership is trustworthy. The mesh is opt-in, so it's only as capable as your community's participation. A relay where nobody shares compute has an empty mesh. That's the right default — you give willingly or not at all — but the value compounds with how many people turn it on. diff --git a/VISION_SOVEREIGN.md b/VISION_SOVEREIGN.md index dc5d5d187..77362b3f7 100644 --- a/VISION_SOVEREIGN.md +++ b/VISION_SOVEREIGN.md @@ -56,10 +56,16 @@ chat at discord.gg/..." Everything is at `myproject.com`. That's where everythin is. Not every project needs to run its own relay. Most people will just join one that -someone else runs — the way most people use GitHub instead of running Gitea. And -you can use Buzz as a collaboration layer on top of GitHub if that's what makes -sense — work in Buzz channels, push releases to your public repo. The sovereign -setup is the full version. But the tools work at every level of commitment. +someone else runs — the way most people use GitHub instead of running Gitea. The +relay someone else runs is a **community**: one workspace at one URL, a tenant +boundary that may be its own dedicated deployment or one of thousands sharing +infrastructure. Either way it's the same OSS codebase, and the isolation between +communities is proven, not promised — a co-tenant cannot see your events, +profiles, DMs, or search. Your key stays yours across all of them; identity is +portable even when the hosting isn't. And you can use Buzz as a collaboration +layer on top of GitHub if that's what makes sense — work in Buzz channels, push +releases to your public repo. The sovereign setup is the full version. But the +tools work at every level of commitment. --- @@ -202,9 +208,10 @@ relay, point your domain at it, and you're back. The project continues. You run infrastructure. A server, a domain, a relay. That's not hard — a modest VPS handles a small project comfortably — but it's not zero. Someone has to keep it running. Someone has to handle backups. Someone has to deal with the 3 AM alert -when the disk fills up. Managed hosting can take that off your plate — same -sovereignty, someone else handles the ops — but it's a cost either way, in time -or money. Worth knowing before you start. +when the disk fills up. Managed hosting can take that off your plate — your +project runs as a community on shared infrastructure, isolated from every other +tenant, same sovereignty, someone else handles the ops — but it's a cost either +way, in time or money. Worth knowing before you start. Key management is harder than "sign in with Google." Losing your private key means losing your identity. There's no "forgot password" flow, no support ticket to file, diff --git a/docs/multi-tenant-relay.md b/docs/multi-tenant-relay.md index 055c8dcde..d014bd89d 100644 --- a/docs/multi-tenant-relay.md +++ b/docs/multi-tenant-relay.md @@ -92,10 +92,18 @@ 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** from the channel the operation names -(`resolve : channel_id → community_id`, an indexed lookup the relay owns under -the same transaction snapshot as the operation). It is **never** read from the -client-supplied `h` tag. +`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: @@ -261,8 +269,14 @@ 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 from `resolve(channel_id)` at insert time. The `h` tag is - **not** the label source. (Resolution is a fence.) + 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. @@ -274,7 +288,10 @@ high-labeled value flows into a low observation"): 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; re-scoping requires re-auth; all its observations inherit that scope. + 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 @@ -350,6 +367,40 @@ predicate fail closed rather than leak (Theorem I4). `ChannelCommunity` CONSTANT function (`MultiTenantRelay.tla:85`). Any future re-tenanting would be a separate axiomatic admission with its own audit discipline and re-verification of S1/S2 (and I1–I4). +- **(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.) @@ -364,8 +415,10 @@ predicate fail closed rather than leak (Theorem I4). 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 — the fence the `h`-tag -adversary cannot circumvent. A-RLS-1..5 are the load-bearing *backstop*. +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 @@ -377,9 +430,17 @@ adversary cannot circumvent. A-RLS-1..5 are the load-bearing *backstop*. 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)`, never the `h` - tag; an adversary `h = C' ≠ resolve = C` cannot widen what is served or - accepted. +- **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 @@ -412,13 +473,29 @@ adversary cannot circumvent. A-RLS-1..5 are the load-bearing *backstop*. - **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`). 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–S4 are **machine-verified green** on -Tamarin 1.12.0 / Maude 3.5.1 — the full selected run verifies all 20 lemmas in -5.89s with zero `analyzed` failures. S1/S2: `token_confinement`, +**Verification status.** S1–S6 are **machine-verified green** on +Tamarin 1.12.0 / Maude 3.5.1 — the full selected run verifies all 27 lemmas in +~8s 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 @@ -426,13 +503,40 @@ Tamarin 1.12.0 / Maude 3.5.1 — the full selected run verifies all 20 lemmas in 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` (126 steps). S4: +`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). Each safety lemma is +`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). +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. + +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` @@ -442,11 +546,11 @@ 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 126-step proof is non-vacuous. This is 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. The -committed `.spthy` for this milestone is byte-identical (SHA-256 -`0ad53184…d644b94d85`) to the artifact behind the green S1–S4 run. +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 @@ -540,39 +644,72 @@ as label-flow non-interference is, to our knowledge, new for a Nostr relay. 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.* — 102,742,532 states generated, 4,350,464 distinct, 0 left - on queue, depth 13 (~5 min 45 s, single-threaded). Non-vacuity is shown by three mutations, each + error has been found.* — 138,717,188 states generated, 5,621,760 distinct, 0 left + on queue, depth 13 (16 workers, ~40 s). 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) precisely because the + channel-less write path, the fail-closed unmapped-host path, and the + channel-bearing host/channel-agreement (and its fail-closed disagreement) path + are genuinely reachable — new behavior, not dead code. Threading the host through + the duplicate/no-op path adds reachable fail-closed transitions (138.7M generated, + up from 136.3M) without new distinct states: only the agreeing host can produce a + recorded duplicate, so the host on that path is fully determined. Non-vacuity is + shown by six mutations, 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; and the + 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 + 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). The two host-fence + 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 20 lemmas (S1–S4) - verify green (Tamarin 1.12.0 / Maude 3.5.1, 5.89s) — each safety lemma paired with + `tamarin-prover --prove docs/spec/MultiTenantAuth.spthy`. All 27 lemmas (S1–S6) + verify green (Tamarin 1.12.0 / Maude 3.5.1, ~8 s) — each safety lemma paired with a verified exists-trace sanity lemma, and the documented mutations - (`MUTATION_Use_Token_Claimed_Community` for S1, plus the S3 bad-accept and S4 - splice-as-append mutations) confirmed red. See §Authorization soundness for the - full lemma list and the corrected `other_community_key_compromise_does_not_authorize` - vacuity fix. The committed file is SHA-256 `0ad53184…d644b94d85`. - - **Machine-check hygiene.** S1–S4 lemmas close by two distinct shapes. + (`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, and `MUTATION_Use_Token_Ignore_Host` for S6's channel-bearing + host/channel-agreement fence) confirmed red. 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–S6 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`, and the S2 supporting set + `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). **Substantive closure** requires cross-rule reasoning over + 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 @@ -636,6 +773,62 @@ The model's obligations map to concrete code seams: (`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** — Typesense is shared infrastructure, not a shared result + space. Indexed documents carry `community_id`; document ids are collision-safe + across communities (for example `community_id:event_id`) or every upsert/delete + path includes a community filter. All query `filter_by` strings include + `community_id`, and Postgres refetch by hit id is `(community_id, event_id)`. + The existing `__global__` channel 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. ### Subscription-pipeline abstraction diff --git a/docs/spec/.gitignore b/docs/spec/.gitignore index 7c046b669..56d400a91 100644 --- a/docs/spec/.gitignore +++ b/docs/spec/.gitignore @@ -3,3 +3,4 @@ states/ *.st *.fp +tla2tools.jar diff --git a/docs/spec/MultiTenantAuth.spthy b/docs/spec/MultiTenantAuth.spthy index 40a688a99..f09000c00 100644 --- a/docs/spec/MultiTenantAuth.spthy +++ b/docs/spec/MultiTenantAuth.spthy @@ -189,14 +189,46 @@ rule Leak_Token: // Token use resolves the target community server-side from the requested channel. // There is intentionally no client-supplied community or h-tag in this rule. +// The connection's HOST is *also* authoritative: the rule only fires when the +// host's bound community equals the channel's resolved community, so an A-host +// presenting a B-channel-bearing request cannot authorize (the confused-deputy +// fence on the host axis, mirroring the channel-less case). The combined witness +// ChannelBearingResolved(tok, used_comm, host, host_comm) is emitted by this SAME +// rule firing so the agreement lemma is a single-fact assertion -- no second-fact +// lookup, so the M8 mutation falsifies in one rule instance. rule Use_Token: - [ In(tok), !Token(tok, client, comm), !ChannelCommunity(chan, comm) ] + [ In(tok), !Token(tok, client, comm), !ChannelCommunity(chan, comm), + !HostCommunity(host, comm) ] --[ ActionAuthorized(tok, client, comm, chan), + HostBoundFor(host, comm), + ChannelBearingResolved(tok, comm, host, comm), TokenUsedForCommunity(tok, comm) ]-> [ ] +// Non-vacuity mutation M8 (DO NOT ENABLE in the real model): the relay authorizes +// a channel-bearing op from the channel mapping while ignoring the host binding, +// so an A-host can drive a B-channel op (host/channel disagreement accepted). +// +// rule MUTATION_Use_Token_Ignore_Host: +// [ In(tok), !Token(tok, client, comm), !ChannelCommunity(chan, comm), +// !HostCommunity(host, host_comm) ] +// --[ +// Neq(comm, host_comm), +// ActionAuthorized(tok, client, comm, chan), +// HostBoundFor(host, host_comm), +// ChannelBearingResolved(tok, comm, host, host_comm), +// TokenUsedForCommunity(tok, comm) +// ]-> +// [ ] +// +// Expected mutation result: `channelbearing_use_agrees_with_host` goes red. The +// lemma reads a SINGLE ChannelBearingResolved(tok, used, host, host_comm) fact and +// asserts used = host_comm; the mutation emits used = comm, host_comm under +// Neq(comm, host_comm), so the counterexample is one rule instance. Confirmed: +// falsified with a 14-step trace on Tamarin 1.12.0 / Maude 3.5.1. + // Non-vacuity mutation for S1 (DO NOT ENABLE in the real model): this is the // tempting confused-deputy bug where the relay authorizes from a client-supplied // claimed community / h-tag rather than from `!ChannelCommunity(chan, comm)`. @@ -226,6 +258,85 @@ rule Probe_Cross_Community_Token_Use: ]-> [ ] +// ============================================================================ +// Host -> community binding (P-RESOLVE-HOST) and channel-less token use +// ============================================================================ +// +// Channel-less operations (kind:0 profiles, 1059 DMs, 30023/30174/30315/30078, +// lists) carry no h tag, so the community cannot be resolved from a channel. +// Per Tyler's ruling, the connection's HOST is authoritative for the community, +// exactly as a relay URL is authoritative for a relay today, lifted one level up. +// A host binds to exactly one community; an unmapped host has no binding and so +// no channel-less op can resolve (fail-closed -- modeled by the absence of a +// !HostCommunity fact, so Use_Token_ChannelLess simply cannot fire). + +rule Bind_Host: + [ !Community(comm), Fr(~host) ] + --[ + HostBound(~host, comm) + ]-> + [ + !HostCommunity(~host, comm), + Out(~host) + ] + +// Channel-less token use. The target community is resolved server-side from the +// connection's host, NOT from a client-supplied community/h tag and NOT from the +// token's stamp. The token must AGREE with the host-derived community: the rule +// only fires when !Token(tok, client, comm) and !HostCommunity(host, comm) share +// the same comm. Host wins; a token stamped for a different community cannot +// authorize here (see Probe_Host_Token_Mismatch). This is the confused-deputy +// fence (I2) lifted from channel to host. The HostBoundFor action witnesses the +// host's binding at the authorization point so the confinement lemma can join on +// the (single-source) host binding rather than reconstructing adversary state. +rule Use_Token_ChannelLess: + [ In(tok), !Token(tok, client, comm), !HostCommunity(host, comm) ] + --[ + ChannelLessAuthorized(tok, client, comm, host), + HostBoundFor(host, comm), + // Single combined witness: the community actually used (1st arg) alongside + // the host's resolved community (3rd arg), emitted by the SAME rule firing. + // In the real rule both are `comm` (host wins), so the confinement lemma is + // a single-fact assertion -- no second-fact lookup, no source ambiguity, so + // the mutation that breaks the equality falsifies in one rule instance. + ChannelLessResolved(tok, comm, host, comm), + TokenUsedForCommunity(tok, comm) + ]-> + [ ] + +// Non-vacuity mutation for S1-host (DO NOT ENABLE in the real model): the relay +// authorizes a channel-less op from the token's stamp while ignoring the host +// binding, so a B-stamped token authorizes on an A-host. +// +// rule MUTATION_Use_Token_ChannelLess_Ignore_Host: +// [ In(tok), !Token(tok, client, minted_comm), !HostCommunity(host, host_comm) ] +// --[ +// Neq(minted_comm, host_comm), +// ChannelLessAuthorized(tok, client, minted_comm, host), +// HostBoundFor(host, host_comm), +// ChannelLessResolved(tok, minted_comm, host, host_comm), +// TokenUsedForCommunity(tok, minted_comm) +// ]-> +// [ ] +// +// Expected mutation result: `channelless_use_confined_to_host_community` goes red. +// The confinement lemma reads a SINGLE ChannelLessResolved(tok, used, host, +// host_comm) fact and asserts used = host_comm; the mutation emits that fact with +// used = minted_comm, host_comm = host_comm under Neq(minted_comm, host_comm), so +// the counterexample is one rule instance with no second-fact lookup or adversary +// reconstruction. Confirmed: falsified fast on Tamarin 1.12.0. + +// Probe rule: the adversary presents a token stamped for one community over a +// connection whose host is bound to a different community. The real model records +// the attempt but does not authorize it (host wins / token must agree with host). +rule Probe_Host_Token_Mismatch: + [ In(tok), !Token(tok, client, minted_comm), !HostCommunity(host, host_comm) ] + --[ + Neq(minted_comm, host_comm), + HostTokenMismatchAttempt(tok, client, minted_comm, host_comm, host) + ]-> + [ ] + // ============================================================================ // Per-community signing keys // ============================================================================ @@ -332,6 +443,50 @@ lemma cross_community_use_attempts_are_not_authorized: CrossCommunityUseAttempt(tok, client, minted_comm, resolved_comm, chan) @ i ==> not (Ex #j. ActionAuthorized(tok, client, resolved_comm, chan) @ j)" +// S1-host: a channel-less authorization is confined to the community bound to the +// connection's HOST. The lemma reads a single ChannelLessResolved(tok, used_comm, +// host, host_comm) fact -- emitted by the authorizing rule and carrying both the +// community actually used and the host's resolved community -- and asserts they +// are equal. A single-fact assertion means a counterexample is one rule instance, +// not a multi-fact join or adversary reconstruction. Host wins over the token's +// stamp: enabling MUTATION_Use_Token_ChannelLess_Ignore_Host falsifies this fast. +lemma channelless_use_confined_to_host_community: + "All tok used_comm host host_comm #i. + ChannelLessResolved(tok, used_comm, host, host_comm) @ i + ==> used_comm = host_comm" + +// S1-host (channel-bearing): a channel-BEARING authorization is confined to the +// community bound to the connection's HOST -- the host axis of the confused-deputy +// fence. Today the relay resolves a channel-bearing op's community from the h tag +// (the channel mapping) alone; this lemma proves that the host must ALSO agree, so +// an A-host presenting a B-channel-bearing request cannot authorize as B. Like the +// channel-less case it reads a single ChannelBearingResolved(tok, used_comm, host, +// host_comm) fact, so a counterexample is one rule instance. Enabling +// MUTATION_Use_Token_Ignore_Host (which accepts host/channel disagreement) +// falsifies this fast. +lemma channelbearing_use_agrees_with_host: + "All tok used_comm host host_comm #i. + ChannelBearingResolved(tok, used_comm, host, host_comm) @ i + ==> used_comm = host_comm" + +// The token presented for a channel-less op must agree with the host-derived +// community: the real rule only fires when the token's stamp equals the host's +// community, so any recorded channel-less authorization carries a token whose +// mint stamp matches the used community. +lemma channelless_token_agrees_with_host: + "All tok client used_comm host minted_comm #i #j. + ChannelLessAuthorized(tok, client, used_comm, host) @ i + & TokenMinted(tok, client, minted_comm) @ j + ==> used_comm = minted_comm" + +// A token stamped for one community presented over a host bound to a different +// community (the host/token mismatch) is never channel-less authorized for the +// token's stamped community over that host. +lemma host_token_mismatch_not_authorized: + "All tok client minted_comm host_comm host #i. + HostTokenMismatchAttempt(tok, client, minted_comm, host_comm, host) @ i + ==> not (Ex #j. ChannelLessAuthorized(tok, client, minted_comm, host) @ j)" + // S2: every minted token has exactly one stamped community, and every requested // channel recorded for that mint resolved to that stamp. lemma minted_token_channels_match_stamp: @@ -423,4 +578,19 @@ lemma executable_audit_append: exists-trace "Ex comm prev next #i. AuditAppended(comm, prev, next) @ i" +// Host-binding reachability probes (anti-vacuity for the S1-host lemmas). +lemma executable_host_bound: + exists-trace + "Ex host comm #i. HostBound(host, comm) @ i" + +lemma executable_channelless_use: + exists-trace + "Ex tok client comm host #i. + ChannelLessAuthorized(tok, client, comm, host) @ i" + +lemma executable_host_token_mismatch_attempt: + exists-trace + "Ex tok client minted_comm host_comm host #i. + HostTokenMismatchAttempt(tok, client, minted_comm, host_comm, host) @ i" + end diff --git a/docs/spec/MultiTenantRelay.cfg b/docs/spec/MultiTenantRelay.cfg index 03b7a0f72..f33ee8589 100644 --- a/docs/spec/MultiTenantRelay.cfg +++ b/docs/spec/MultiTenantRelay.cfg @@ -6,6 +6,7 @@ SPECIFICATION Spec CONSTANTS Communities = {commA, commB} Channels = {chanA1, chanA2, chanB1, chanB2} + Hosts = {hostA, hostB, hostBad} Actors = {alice} Workers = {relay1} MsgIds = {msg1, msg2} @@ -16,6 +17,11 @@ CONSTANTS ChanA2 = chanA2 ChanB1 = chanB1 ChanB2 = chanB2 + HostA = hostA + HostB = hostB + HostBad = hostBad + NoChannel = noChannel + NoCommunity = noCommunity SanitizedErrors = {"auth-required", "restricted", "invalid", "duplicate", "pow", "rate-limited", "blocked", "error", "frame-too-large"} INVARIANT Safety diff --git a/docs/spec/MultiTenantRelay.tla b/docs/spec/MultiTenantRelay.tla index cc23c1848..033e8860a 100644 --- a/docs/spec/MultiTenantRelay.tla +++ b/docs/spec/MultiTenantRelay.tla @@ -57,12 +57,22 @@ (* sanitized alphabet -> Inv_SanitizedErrors/NI breaks. *) (* M7: Direct ids lookup ignores ctx and resolves scope from the row/global *) (* id index -> a B-scoped observation can carry an A-labeled row. *) +(* M8: WriteInsert/AuthCheck/WriteDuplicate drop the host/channel agreement *) +(* fence (accept/report when HostCommunity[host] # ChannelCommunity[ch]) ->*) +(* an A-host op (insert, authz, OR duplicate-probe) on a B-channel is *) +(* accepted/allowed/reported -> Inv_HostBindingFence breaks (an accepted *) +(* write or recorded duplicate carries a host whose mapping # its stamp). *) +(* Confirmed red: Inv_HostBindingFence violated by a 2-state trace *) +(* (Init -> WriteInsert) for the insert path and a 3-state trace *) +(* (Init -> WriteInsert -> WriteDuplicate) for the duplicate path. (Trace *) +(* length, not TLC's run-dependent "depth of complete graph search" line.) *) (***************************************************************************) EXTENDS FiniteSets, Naturals, TLC CONSTANTS Communities, \* finite set of community ids Channels, \* finite set of channel ids + Hosts, \* finite set of connection hostnames/URLs (the community selector) Actors, \* finite set of pubkeys/actors Workers, \* finite set of relay worker/process ids MsgIds, \* finite set of event ids (model bound) @@ -73,6 +83,11 @@ CONSTANTS ChanA2, \* model value: community-A channel in TLC config ChanB1, \* model value: community-B channel in TLC config ChanB2, \* model value: community-B channel in TLC config + HostA, \* model value: host bound to community A + HostB, \* model value: host bound to community B + HostBad, \* model value: unmapped host (resolves to NoCommunity) + NoChannel, \* model value: sentinel channel for channel-less events + NoCommunity, \* model value: sentinel for an unmapped host (fail-closed) SanitizedErrors \* fixed WebSocket-reachable sanitized error alphabet ObsKinds == {"ResultRows", "WriteResult", "SanitizedError", "AuditHead", "AuthVerdict"} @@ -84,6 +99,36 @@ NoAudit == "NoAudit" ChannelCommunity == [ch \in Channels |-> IF ch \in {ChanA1, ChanA2} THEN CommA ELSE CommB] +\* resolve_host (P-RESOLVE-HOST): the connection's host is authoritative for the +\* community, exactly as a relay URL is authoritative for the relay today, lifted +\* one level up to community. A host maps to exactly one community, or to the +\* NoCommunity sentinel (an unmapped/unknown host) -> the connection fails closed +\* and no channel-less write may derive a community from it. This is the upstream +\* of ResolveTenant: ctx.community is *derived* from the host, never free-chosen. +HostCommunity == [h \in Hosts |-> + CASE h = HostA -> CommA + [] h = HostB -> CommB + [] OTHER -> NoCommunity] + +\* ResolveTenant: the single resolver generalizing P-RESOLVE / L1 over both +\* channel-bearing and channel-less events. For a channel-bearing op the +\* community is the server-owned channel mapping (and an h-cross-check requires it +\* to agree with the host community, see WriteInsert). For a channel-less op +\* (channel = NoChannel) the community is the host-derived community. +ResolveTenant(host, ch) == + IF ch = NoChannel THEN HostCommunity[host] ELSE ChannelCommunity[ch] + +\* Channel-or-sentinel: data rows and write/observation records may be channel- +\* less (channel = NoChannel), in which case the community comes from the host. +ChannelsExt == Channels \cup {NoChannel} + +\* Every accepted write and recorded duplicate stamps its real originating host +\* (host \in Hosts): channel-less writes resolve the community from the host, and +\* channel-bearing writes/duplicates stamp the host on agreement (fail-closed on +\* disagreement, so no record carries a host that does not map to its community). +\* This lets Inv_HostBindingFence check that every record's stored community equals +\* its host's mapping -- i.e. the binding is enforced, not defaulted. + Symmetry == Permutations(Actors) \cup Permutations(Workers) \cup @@ -107,7 +152,7 @@ vars == < Communities] + /\ HostCommunity \in [Hosts -> Communities \cup {NoCommunity}] /\ CommA \in Communities /\ CommB \in Communities /\ CommA # CommB /\ {ChanA1, ChanA2, ChanB1, ChanB2} \subseteq Channels /\ ChanA1 # ChanB1 + /\ {HostA, HostB, HostBad} \subseteq Hosts + /\ NoChannel \notin Channels + /\ NoCommunity \notin Communities + /\ HostCommunity[HostBad] = NoCommunity /\ SanitizedErrors # {} /\ NoError \notin SanitizedErrors /\ NoAudit \notin AuditVals @@ -212,11 +264,18 @@ TypeOK == /\ duplicateWrites \subseteq DuplicateWriteRows /\ queryFaults \subseteq QueryFaultRows \* Tenant-scoped control plane: a membership row's community agrees with - \* the server-owned channel -> community mapping. + \* the server-owned channel -> community mapping. Memberships are always + \* channel-scoped (no channel-less memberships). /\ \A m \in memberships : m.community = ChannelCommunity[m.channel] - \* Message/projection rows are stamped with the server-owned channel mapping. - /\ \A m \in messages : m.community = ChannelCommunity[m.channel] - /\ \A p \in projections : p.community = ChannelCommunity[p.channel] + \* Message/projection rows are stamped with the resolved community: the + \* server-owned channel mapping for channel-bearing rows, and a real + \* (never-sentinel) community for channel-less rows (host-resolved at write). + /\ \A m \in messages : + IF m.channel = NoChannel THEN m.community \in Communities + ELSE m.community = ChannelCommunity[m.channel] + /\ \A p \in projections : + IF p.channel = NoChannel THEN p.community \in Communities + ELSE p.community = ChannelCommunity[p.channel] Init == /\ messages = {} @@ -270,43 +329,133 @@ UnscopedDirectIdRows(actor, id) == RLSRows(community, rows) == {r \in rows : r.community = community} +\* Channel-bearing write. The community is resolved server-side from the h tag +\* (real == ChannelCommunity[ch]), never the claimed community. But the host is +\* *also* authoritative: an A-host connection presenting a B-channel event is a +\* confused deputy on the host axis (URL-is-community is the admission boundary), +\* so the op fails closed unless HostCommunity[host] = ChannelCommunity[ch]. +\* Disagreement (incl. an unmapped HostBad whose mapping is NoCommunity, which can +\* never equal a real channel community) writes nothing, emits a sanitized error, +\* and records a query fault. Never acts as the channel's community. WriteInsert(w) == /\ Cardinality(observations) < MaxObservations - /\ \E id \in MsgIds, ch \in Channels, a \in Actors, claimed \in Communities : + /\ \E id \in MsgIds, ch \in Channels, host \in Hosts, a \in Actors, claimed \in Communities : LET real == ChannelCommunity[ch] - key == [community |-> real, id |-> id] - row == MessageRow(id, real, ch, a) - obs == [worker |-> w, actor |-> a, community |-> real, channel |-> ch, - kind |-> "WriteResult", labels |-> {real}, rows |-> {row}, - error |-> NoError, result |-> "Inserted", verdict |-> "None", audit |-> NoAudit] - wr == [worker |-> w, id |-> id, community |-> real, - channel |-> ch, author |-> a, claimedCommunity |-> claimed] IN - /\ key \notin MessageKeys - /\ messages' = messages \cup {row} - /\ observations' = observations \cup {obs} - /\ acceptedWrites' = acceptedWrites \cup {wr} - /\ UNCHANGED <> - + IF HostCommunity[host] # real + THEN \* fail-closed: host/channel disagreement (A-host + B-channel). + LET obs == [worker |-> w, actor |-> a, community |-> claimed, + channel |-> ch, kind |-> "SanitizedError", + labels |-> {}, rows |-> {}, error |-> "restricted", + result |-> "None", verdict |-> "None", audit |-> NoAudit] + fault == [worker |-> w, actor |-> a, community |-> claimed, + reason |-> "missing_tenant_context"] + IN + /\ observations' = observations \cup {obs} + /\ queryFaults' = queryFaults \cup {fault} + /\ UNCHANGED <> + ELSE \* host agrees with the channel mapping: accept, stamp the host. + LET key == [community |-> real, id |-> id] + row == MessageRow(id, real, ch, a) + obs == [worker |-> w, actor |-> a, community |-> real, channel |-> ch, + kind |-> "WriteResult", labels |-> {real}, rows |-> {row}, + error |-> NoError, result |-> "Inserted", verdict |-> "None", audit |-> NoAudit] + wr == [worker |-> w, id |-> id, community |-> real, + channel |-> ch, host |-> host, author |-> a, claimedCommunity |-> claimed] + IN + /\ key \notin MessageKeys + /\ messages' = messages \cup {row} + /\ observations' = observations \cup {obs} + /\ acceptedWrites' = acceptedWrites \cup {wr} + /\ UNCHANGED <> + +\* Channel-less write (kind:0 profiles, 1059 DMs, 30023/30174/30315/30078, lists). +\* There is no h tag to resolve, so the community is derived from the connection's +\* host (P-RESOLVE-HOST / ResolveTenant): the row is stamped channel = NoChannel, +\* community = HostCommunity[host]. The claimed community is adversary-controlled +\* and ignored -- host wins (the confused-deputy fence, lifted to the host). +\* An unmapped host (HostCommunity[host] = NoCommunity) fails closed: no row is +\* written, the connection sees only a sanitized error, and a query fault is +\* recorded. Never a default tenant. +WriteInsertGlobal(w) == + /\ Cardinality(observations) < MaxObservations + /\ \E id \in MsgIds, host \in Hosts, a \in Actors, claimed \in Communities : + LET resolved == HostCommunity[host] + IN + IF resolved = NoCommunity + THEN \* fail-closed: unmapped/unknown host, write nothing. + LET obs == [worker |-> w, actor |-> a, community |-> claimed, + channel |-> NoChannel, kind |-> "SanitizedError", + labels |-> {}, rows |-> {}, error |-> "restricted", + result |-> "None", verdict |-> "None", audit |-> NoAudit] + fault == [worker |-> w, actor |-> a, community |-> claimed, + reason |-> "missing_tenant_context"] + IN + /\ observations' = observations \cup {obs} + /\ queryFaults' = queryFaults \cup {fault} + /\ UNCHANGED <> + ELSE \* mapped host: stamp the host-resolved community, channel-less. + LET key == [community |-> resolved, id |-> id] + row == [id |-> id, community |-> resolved, channel |-> NoChannel, + author |-> a, source |-> "message"] + obs == [worker |-> w, actor |-> a, community |-> resolved, + channel |-> NoChannel, kind |-> "WriteResult", + labels |-> {resolved}, rows |-> {row}, error |-> NoError, + result |-> "Inserted", verdict |-> "None", audit |-> NoAudit] + wr == [worker |-> w, id |-> id, community |-> resolved, + channel |-> NoChannel, host |-> host, author |-> a, claimedCommunity |-> claimed] + IN + /\ key \notin MessageKeys + /\ messages' = messages \cup {row} + /\ observations' = observations \cup {obs} + /\ acceptedWrites' = acceptedWrites \cup {wr} + /\ UNCHANGED <> + +\* Duplicate / no-op write outcome (scoped conflict on (community_id, id)). This +\* is client-observable write surface -- the "Duplicate" WriteResult exposes the +\* scoped existence/conflict rows -- so it carries the SAME host-axis obligation as +\* WriteInsert. The community is resolved server-side from the h tag (real == +\* ChannelCommunity[ch]); the host is *also* authoritative. An A-host presenting a +\* B-channel id is a confused deputy on the host axis: it must NOT learn whether +\* that id already exists in B. So the op fails closed (sanitized error + query +\* fault, no Duplicate result, nothing recorded) unless HostCommunity[host] = real. WriteDuplicate(w) == /\ Cardinality(observations) < MaxObservations - /\ \E id \in MsgIds, ch \in Channels, a \in Actors, claimed \in Communities : + /\ \E id \in MsgIds, ch \in Channels, host \in Hosts, a \in Actors, claimed \in Communities : LET real == ChannelCommunity[ch] - key == [community |-> real, id |-> id] - conflicts == ScopedConflictRows(real, id) - obs == [worker |-> w, actor |-> a, community |-> real, channel |-> ch, - kind |-> "WriteResult", labels |-> RowLabels(conflicts), rows |-> conflicts, - error |-> NoError, result |-> "Duplicate", verdict |-> "None", audit |-> NoAudit] - wr == [worker |-> w, id |-> id, community |-> real, - channel |-> ch, author |-> a, claimedCommunity |-> claimed] IN - /\ key \in MessageKeys - /\ messages' = messages - /\ observations' = observations \cup {obs} - /\ duplicateWrites' = duplicateWrites \cup {wr} - /\ UNCHANGED <> + IF HostCommunity[host] # real + THEN \* fail-closed: host/channel disagreement (A-host + B-channel). + LET obs == [worker |-> w, actor |-> a, community |-> claimed, + channel |-> ch, kind |-> "SanitizedError", + labels |-> {}, rows |-> {}, error |-> "restricted", + result |-> "None", verdict |-> "None", audit |-> NoAudit] + fault == [worker |-> w, actor |-> a, community |-> claimed, + reason |-> "missing_tenant_context"] + IN + /\ observations' = observations \cup {obs} + /\ queryFaults' = queryFaults \cup {fault} + /\ UNCHANGED <> + ELSE \* host agrees with the channel mapping: report the scoped duplicate. + LET key == [community |-> real, id |-> id] + conflicts == ScopedConflictRows(real, id) + obs == [worker |-> w, actor |-> a, community |-> real, channel |-> ch, + kind |-> "WriteResult", labels |-> RowLabels(conflicts), rows |-> conflicts, + error |-> NoError, result |-> "Duplicate", verdict |-> "None", audit |-> NoAudit] + wr == [worker |-> w, id |-> id, community |-> real, + channel |-> ch, host |-> host, author |-> a, claimedCommunity |-> claimed] + IN + /\ key \in MessageKeys + /\ messages' = messages + /\ observations' = observations \cup {obs} + /\ duplicateWrites' = duplicateWrites \cup {wr} + /\ UNCHANGED <> ReadMessageRows(w) == /\ Cardinality(observations) < MaxObservations @@ -385,11 +534,16 @@ SanitizedError(w) == /\ UNCHANGED <> +\* Channel-bearing authorization. Community resolves from the channel mapping; +\* the host is also authoritative. Host/channel disagreement (A-host + B-channel) +\* is denied -- the host axis of the confused-deputy fence. Even if the actor is +\* a member of the B-channel, an A-host connection cannot drive a B-channel verdict. AuthCheck(w) == /\ Cardinality(observations) < MaxObservations - /\ \E ch \in Channels, a \in Actors, claimed \in Communities : + /\ \E ch \in Channels, host \in Hosts, a \in Actors, claimed \in Communities : LET real == ChannelCommunity[ch] - allowed == ch \in ScopedAccessible(real, a) + hostAgrees == HostCommunity[host] = real + allowed == hostAgrees /\ ch \in ScopedAccessible(real, a) verdict == IF allowed THEN "Allow" ELSE "Deny" obs == [worker |-> w, actor |-> a, community |-> real, channel |-> ch, kind |-> "AuthVerdict", labels |-> {real}, rows |-> {}, @@ -457,6 +611,7 @@ CloseChannel(w) == Next == \E w \in Workers : \/ WriteInsert(w) + \/ WriteInsertGlobal(w) \/ WriteDuplicate(w) \/ ReadMessageRows(w) \/ ReadProjectionRows(w) @@ -503,13 +658,48 @@ Inv_ReadConfinement == o.kind = "ResultRows" => \A r \in o.rows : r.community = o.community \* I2 resolution fence: persisted messages and write/auth observations are -\* labeled by the server-owned channel->community mapping, not by a client h tag. +\* labeled by the *resolved* community, never a client-supplied claim. For a +\* channel-bearing op the resolver is the server-owned channel->community mapping +\* (the h tag is not the source). For a channel-less op (channel = NoChannel) the +\* resolver is the connection's host (P-RESOLVE-HOST): the stamped community is a +\* real community derived from the host, never the NoCommunity sentinel and never +\* the adversary-controlled claimedCommunity. At N=1 (one host -> one community) +\* the two branches coincide. Inv_ResolutionFence == - /\ \A m \in messages : m.community = ChannelCommunity[m.channel] - /\ \A w \in acceptedWrites : w.community = ChannelCommunity[w.channel] - /\ \A w \in duplicateWrites : w.community = ChannelCommunity[w.channel] + /\ \A m \in messages : + IF m.channel = NoChannel THEN m.community \in Communities + ELSE m.community = ChannelCommunity[m.channel] + /\ \A w \in acceptedWrites : + IF w.channel = NoChannel THEN w.community \in Communities + ELSE w.community = ChannelCommunity[w.channel] + /\ \A w \in duplicateWrites : + IF w.channel = NoChannel THEN w.community \in Communities + ELSE w.community = ChannelCommunity[w.channel] /\ \A o \in observations : - o.kind \in {"WriteResult", "AuthVerdict"} => o.community = ChannelCommunity[o.channel] + o.kind \in {"WriteResult", "AuthVerdict"} => + (IF o.channel = NoChannel THEN o.community \in Communities + ELSE o.community = ChannelCommunity[o.channel]) + +\* I2-host fail-closed binding: EVERY accepted write -- channel-bearing or +\* channel-less -- AND every observable duplicate/no-op outcome carries the host it +\* came in on, and that host's mapping is a real community equal to the write's +\* stored community (P-RESOLVE-HOST). For channel-less writes this is the +\* host->community derivation; for channel-bearing writes and duplicate/no-op +\* outcomes it is the host/channel agreement fence (HostCommunity[host] = +\* ChannelCommunity[ch]). This makes BOTH "an unmapped host defaults to a tenant" +\* and "an A-host can drive a B-channel op (insert OR duplicate-probe)" CAUGHT +\* mutations: the fail-closed branches of WriteInsert/WriteInsertGlobal/WriteDuplicate +\* write/record nothing on disagreement, so no accepted write and no recorded +\* duplicate can carry a host whose mapping is NoCommunity or disagrees with the stamp. +Inv_HostBindingFence == + /\ \A w \in acceptedWrites : + /\ w.host \in Hosts + /\ HostCommunity[w.host] \in Communities + /\ w.community = HostCommunity[w.host] + /\ \A w \in duplicateWrites : + /\ w.host \in Hosts + /\ HostCommunity[w.host] \in Communities + /\ w.community = HostCommunity[w.host] \* I3a append persistence: every accepted append remains present in the shared log. Inv_AcceptedWritesPersist == @@ -542,6 +732,7 @@ Safety == /\ Inv_LabelPropagation /\ Inv_ReadConfinement /\ Inv_ResolutionFence + /\ Inv_HostBindingFence /\ Inv_AcceptedWritesPersist /\ Inv_MessageKeyUnique /\ Inv_NoTenantContextFailsClosed From 2498983d329126bc0b3a45770e31f28ebd7a59a7 Mon Sep 17 00:00:00 2001 From: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co> Date: Thu, 25 Jun 2026 14:58:20 -0400 Subject: [PATCH 04/11] docs(multi-tenant): add conformance table and community semantics to root docs Add docs/multi-tenant-conformance.md mapping each relay-global surface (membership, NIP-98 replay, Typesense scoping, Redis keys, workflows, NIP-11, git/NIP-34 seams) to its source behavior and the model's treatment, anchored on the frozen host-binding contract. Update README/NOSTR/ARCHITECTURE/VISION_AGENT/VISION_PROJECTS to describe community as the tenant boundary while preserving all N=1 client wire/API behavior promises (community adds nothing observable at N=1). Co-authored-by: Tyler Longwell Signed-off-by: Tyler Longwell --- ARCHITECTURE.md | 44 +++++++++++++------ NOSTR.md | 31 ++++++++++--- README.md | 12 ++++-- VISION_AGENT.md | 8 ++++ VISION_PROJECTS.md | 8 ++-- docs/multi-tenant-conformance.md | 74 ++++++++++++++++++++++++++++++++ 6 files changed, 150 insertions(+), 27 deletions(-) create mode 100644 docs/multi-tenant-conformance.md diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index e041df6ba..8ce1d5937 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -6,6 +6,14 @@ Buzz is a self-hosted team communication platform built on the Nostr protocol (N The relay is the single source of truth. All reads and writes flow through it. There is no peer-to-peer event exchange, no gossip, no replication — just clients connecting to one relay over WebSocket, and the relay enforcing auth, verifying signatures, persisting events, fanning out to subscribers, indexing for search, and triggering automation. +A Buzz **community** is the tenant-visible workspace selected by the request host. +The self-hosted default remains one host, one relay process, one implicit +community. Multi-community deployments move that semantic boundary one level up: +`req.community = resolve_host(connection.host)` is established before AUTH, +EVENT, REQ, REST, media, git, search, workflow, or pub/sub handling. Unknown +hosts fail closed, and NIP-98/API-token stamps must agree with the host-derived +community rather than overriding it. + Buzz is a Rust monorepo, licensed Apache 2.0 under Block, Inc. --- @@ -87,7 +95,7 @@ buzz-admin (operator CLI: relay membership + key generation) buzz-test-client (integration test harness + manual CLI) ``` -**Key architectural principle:** The relay is the single source of truth. `buzz-relay` orchestrates all subsystems by calling them directly — it imports `buzz-db`, `buzz-auth`, `buzz-pubsub`, `buzz-search`, `buzz-audit`, and `buzz-workflow`. However, those subsystems are isolated from each other: `buzz-workflow` never calls `buzz-pubsub`, `buzz-search` never calls `buzz-db`, etc. Cross-subsystem coordination happens only through the relay. `buzz-proxy` connects to the relay as a WebSocket client and translates NIP-28 events between standard Nostr clients and the Buzz relay. +**Key architectural principle:** The relay is the single source of truth. `buzz-relay` orchestrates all subsystems by calling them directly — it imports `buzz-db`, `buzz-auth`, `buzz-pubsub`, `buzz-search`, `buzz-audit`, and `buzz-workflow`. However, those subsystems are isolated from each other: `buzz-workflow` never calls `buzz-pubsub`, `buzz-search` never calls `buzz-db`, etc. Cross-subsystem coordination happens only through the relay. `buzz-proxy` connects to the relay as a WebSocket client and translates NIP-28 events between standard Nostr clients and the Buzz relay. In multi-community mode, the relay also owns propagation of `TenantContext`; service crates should receive community-scoped inputs rather than independently deriving tenancy from client-controlled event tags. --- @@ -159,6 +167,16 @@ Max frame size: 65,536 bytes. Max subscriptions per connection: 1024. Max histor Every WebSocket connection follows this exact sequence: +### Step 0: Community Binding + +The server resolves `TenantContext` from the request host before any handler can +observe tenant data. The URL/domain is authoritative for the community, matching +today's "the relay URL is the workspace" behavior. In single-community mode the +configured host maps to the default community. In multi-community mode, an +unknown or unmapped host rejects generically and never falls through to a default +tenant. Client-supplied `#h` tags are still channel identifiers; they must resolve +to a channel inside the host-derived community. + ### Step 1: Semaphore Acquire `state.conn_semaphore.try_acquire_owned()` — if the relay is at connection capacity, the connection is rejected immediately before any data is read. The permit is held for the entire connection lifetime and dropped on cleanup. @@ -414,7 +432,7 @@ All database access. Uses `sqlx::query()` (runtime, not compile-time macros) — ### buzz-pubsub — Redis Pub/Sub, Presence, Typing -Manages Redis pub/sub fan-out, presence tracking, and typing indicators. +Manages Redis pub/sub fan-out, presence tracking, and typing indicators. In multi-community mode all tenant-visible keys are prefixed or otherwise partitioned by community (`buzz:{community}:...`) so channel fan-out, presence, typing, and cache invalidation cannot cross hosts. **Architecture:** @@ -446,7 +464,7 @@ EXPIRE buzz:typing:{channel_id} 60 ### buzz-search — Typesense Integration -Full-text search via Typesense. All HTTP calls use `reqwest` with `X-TYPESENSE-API-KEY`. +Full-text search via Typesense. All HTTP calls use `reqwest` with `X-TYPESENSE-API-KEY`. In multi-community mode, indexed documents and every query filter include `community_id`; the shared Typesense collection is infrastructure, not a cross-community result space. **Collection schema (7 fields):** `id`, `content`, `kind` (int32), `pubkey` (facet), `channel_id` (facet, optional), `created_at` (int64, default sort), `tags_flat` (string[]). @@ -466,7 +484,7 @@ Full-text search via Typesense. All HTTP calls use `reqwest` with `X-TYPESENSE-A Tamper-evident append-only log with SHA-256 hash chaining. -**Hash chain:** each entry stores `prev_hash` (hash of the previous entry). `verify_chain()` walks entries and recomputes hashes to detect tampering. Genesis entry uses `GENESIS_HASH` (64 zeros). +**Hash chain:** each entry stores `prev_hash` (hash of the previous entry). In multi-community mode audit heads/chains are per-community; operator metrics may aggregate, but tenant-readable audit verification walks one community chain. `verify_chain()` walks entries and recomputes hashes to detect tampering. Genesis entry uses `GENESIS_HASH` (64 zeros). **Hash covers:** seq (big-endian bytes), timestamp (RFC3339), event_id, event_kind (big-endian), actor_pubkey, action string, channel_id (16 bytes or 16 zero bytes if None), canonical metadata JSON (BTreeMap for deterministic key ordering), prev_hash. @@ -480,7 +498,7 @@ Tamper-evident append-only log with SHA-256 hash chaining. ### buzz-workflow — YAML-as-Code Automation Engine -Parses, validates, and executes channel-scoped workflow definitions. +Parses, validates, and executes channel-scoped workflow definitions. In multi-community mode workflow definitions, runs, approvals, webhook routes, and schedules inherit the host-derived community and evaluate triggers only against events in that community. **Workflow definition structure:** ```yaml @@ -824,26 +842,26 @@ Docker Compose provides the full local development stack. All services include h | Table | Purpose | |-------|---------| -| `events` | All stored Nostr events; monthly range-partitioned by `PARTITION BY RANGE` on `created_at` | -| `channels` | Channel records (type, visibility, canvas, topic) | +| `events` | All stored Nostr events; monthly range-partitioned by `PARTITION BY RANGE` on `created_at`; multi-community mode keys every tenant-visible event by `community_id` | +| `channels` | Channel records (type, visibility, canvas, topic); `community_id` is immutable after creation in multi-community mode | | `channel_members` | Membership with roles; soft-delete via `removed_at` | -| `workflows` | Workflow definitions (YAML stored as canonical JSON) | +| `workflows` | Workflow definitions (YAML stored as canonical JSON); scoped by community in multi-community mode | | `workflow_runs` | Execution records with trigger context and trace | | `workflow_approvals` | Approval gates (token stored as SHA-256 hash) | -| `audit_log` | Hash-chain audit entries | +| `audit_log` | Hash-chain audit entries; per-community chain/head in multi-community mode | | `delivery_log` | Delivery tracking (partitioned; Rust module pending) | ### Redis Key Patterns | Pattern | Type | TTL | Purpose | |---------|------|-----|---------| -| `buzz:channel:{uuid}` | Pub/Sub channel | — | Event fan-out | -| `buzz:presence:{pubkey_hex}` | String | 90s | Online/away status | -| `buzz:typing:{channel_uuid}` | Sorted Set | 60s | Active typers (5s window) | +| `buzz:channel:{uuid}` | Pub/Sub channel | — | Event fan-out (single-community form; shared multi-community Redis must use `buzz:{community}:channel:{uuid}` or equivalent) | +| `buzz:presence:{pubkey_hex}` | String | 90s | Online/away status (single-community form; shared multi-community Redis must scope by community) | +| `buzz:typing:{channel_uuid}` | Sorted Set | 60s | Active typers (5s window; shared multi-community Redis must scope by community) | ### Typesense Collection -Single collection (`events` by default, configurable via `TYPESENSE_COLLECTION`). Schema: `id`, `content`, `kind` (int32), `pubkey` (facet), `channel_id` (facet, optional), `created_at` (int64, default sort), `tags_flat` (string[]). +Single collection (`events` by default, configurable via `TYPESENSE_COLLECTION`). Schema today: `id`, `content`, `kind` (int32), `pubkey` (facet), `channel_id` (facet, optional), `created_at` (int64, default sort), `tags_flat` (string[]). Multi-community mode adds faceted `community_id` and either prefixes document IDs with community or makes all upsert/delete/refetch paths carry community context. --- diff --git a/NOSTR.md b/NOSTR.md index ddea5c686..93a8e2753 100644 --- a/NOSTR.md +++ b/NOSTR.md @@ -14,6 +14,23 @@ clients and don't have company credentials. Both paths require NIP-42 authentication. +## Community scope + +Buzz treats the relay URL/domain as authoritative for the community. Today's +single-relay deployment has exactly one community behind that URL, so existing +NIP-29/NIP-28 clients keep using the same WebSocket URL, event kinds, tags, and +REST/media/git paths. In a multi-community deployment, each community is reached +by its own domain or subdomain; the backend resolves the community from the host +before handling AUTH, EVENT, REQ, REST, media, git, search, or workflow traffic. + +The Nostr wire format does not grow a tenant tag. Client-supplied `#h` tags still +name channels/groups and are checked against the host-derived community. Events +without `#h` — profiles, gift-wrapped DMs, membership notifications, lists, +status, long-form notes, workflow/system events, and other "global" streams — are +global only inside the connected community. A pubkey can join multiple +communities and repost its profile in each one; DMs and profiles do not inherit +across community domains. + --- ## Path 1: NIP-29 Direct @@ -55,15 +72,15 @@ PGPASSWORD=buzz_dev psql -h localhost -U buzz -d buzz -c \ | **Group metadata (kind:39000)** | ✅ | Relay-signed; always `d`, `name`, `closed` tags; `about` only if description non-empty; `private` if applicable; `hidden` for DM channels | | **Group admins (kind:39001)** | ✅ | Relay-signed; `d` tag + `p` tags with roles (`owner`, `admin`) | | **Group members (kind:39002)** | ✅ | Relay-signed; `d` tag + `p` tags for all members | -| **Membership notifications** | ✅ | kind:44100 (added) / kind:44101 (removed); relay-signed, global scope | -| **Presence (kind:20001)** | ✅ | Ephemeral; arbitrary status string (truncated to 128 chars); writes to Redis (`set_presence`/`clear_presence` on `"offline"`), then fan-out to local subscribers | +| **Membership notifications** | ✅ | kind:44100 (added) / kind:44101 (removed); relay-signed, community-global scope (`channel_id=None` inside the connected community) | +| **Presence (kind:20001)** | ✅ | Ephemeral; arbitrary status string (truncated to 128 chars); writes to Redis (`set_presence`/`clear_presence` on `"offline"`), then fan-out to local subscribers. In multi-community mode presence is scoped to the connected community. | | **Typing indicators (kind:20002)** | ✅ | Ephemeral, not stored; published via Redis pub/sub (multi-node capable unlike presence fan-out) | | **NIP-42 authentication** | ✅ | Proactive challenge; optional pubkey allowlist | | **NIP-11 relay info** | ✅ | `GET /` with `Accept: application/nostr+json` | | **Blossom media** | ✅ | `PUT /media/upload` (BUD-02), `GET /media/{sha256}.{ext}` (BUD-01) | | **NIP-50 search** | ✅ | One-shot search REQs: `{"search":"query","kinds":[9],"#h":[""]}` → relevance-sorted results → EOSE. Not registered as persistent subscriptions. | | **NIP-10 threads** | ✅ | WS-submitted replies with `["e","","","reply"]` tags create `thread_metadata` atomically. Visible in REST thread queries. Unknown parents rejected. | -| **NIP-17 DMs (gift wrap)** | ✅ | kind:1059 accepted with ephemeral signing keys. Stored globally (channel_id=None). Delivered via `#p`-filtered subscriptions. Not indexed in search. | +| **NIP-17 DMs (gift wrap)** | ✅ | kind:1059 accepted with ephemeral signing keys. Stored community-globally (`channel_id=None` inside the connected community). Delivered via `#p`-filtered subscriptions. Not indexed in search. | | **DM discovery** | ✅ | DM creation emits kind:39000 (with `hidden` tag) + kind:44100 membership notifications. NIP-29 clients discover DMs via standard group discovery flow. | | **Join request (kind:9021)** | ✅ | Open channels only. Adds member, emits system message + group discovery events + kind:44100 membership notification. Private channels rejected at ingest. | | **Edits (kind:40003)** | ⚠️ | Works on the wire but Buzz-only — no standard NIP-29 client renders these | @@ -126,10 +143,10 @@ The relay emits relay-signed notifications when members are added or removed: | Kind | Meaning | Tags | Scope | |------|---------|------|-------| -| **44100** | Member added | `p` = target pubkey, `h` = channel UUID | Global | -| **44101** | Member removed | `p` = target pubkey, `h` = channel UUID | Global | +| **44100** | Member added | `p` = target pubkey, `h` = channel UUID | Community-global | +| **44101** | Member removed | `p` = target pubkey, `h` = channel UUID | Community-global | -Stored globally (`channel_id = None`) so agents and clients can subscribe without knowing channel +Stored community-globally (`channel_id = None` inside the connected community) so agents and clients can subscribe without knowing channel UUIDs in advance. Client-submitted kind:44100/44101 events are rejected — only the relay keypair may sign these. @@ -474,7 +491,7 @@ is dual-sourced: local snapshot metadata plus upstream edit events (kind:40003 ## Relay Membership (NIP-43) When `BUZZ_REQUIRE_RELAY_MEMBERSHIP=true`, every authenticated connection is checked against the -`relay_members` table. Only pubkeys with a row in that table may use the relay. The relay owner +`relay_members` table. In today's single-community deployment this is the relay-wide member list; in multi-community mode the same rule is scoped to the host-derived community. Only pubkeys with a row for that community may use that community. The relay owner is bootstrapped automatically from `RELAY_OWNER_PUBKEY` on startup. ### CLI: Managing Members diff --git a/README.md b/README.md index d32707907..acb0c7ce6 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,12 @@ Buzz is a self-hostable workspace where humans and AI agents share the same rooms. +A Buzz **community** is the workspace a user reaches by URL. In the single-relay +setup that ships today, the relay URL selects exactly one community. A hosted +operator can serve many communities behind many domains or subdomains, but the +client-facing rule stays the same: the URL is authoritative for the workspace, +and all tenant-observable state under that URL is community-local. + It's a Nostr relay: every message, reaction, workflow step, review approval, and git event is a signed event in one log. Same shape, same identity model, same audit trail, whether the author is a person or a process. In practice it feels like a team workspace. Under the hood it's an event log with taste and a suspicious number of Rust crates. @@ -70,9 +76,9 @@ Yes, it's another AI-adjacent developer tool. We're sorry. The difference is wha ## Why Buzz is better -One relay. One identity model. One event log. Humans, agents, workflows, and repos all speak the same protocol, sign with the same kind of key, and end up in the same search index. +One community. One identity model. One event log. Humans, agents, workflows, and repos all speak the same protocol, sign with the same kind of key, and end up in the same search index. In the default self-hosted deployment, one relay hosts one community; in a hosted multi-tenant deployment, each community keeps that same semantic boundary even when the backend shares Postgres, Redis, Typesense, and object storage. -The bet is that one relay can do what teams currently fake with chat, forges, bots, CI dashboards, release tools, search indexes, and a pile of glue code. Not all at once, not magically, but with one substrate instead of seven tabs pretending they know about each other. +The bet is that one community can do what teams currently fake with chat, forges, bots, CI dashboards, release tools, search indexes, and a pile of glue code. Not all at once, not magically, but with one substrate instead of seven tabs pretending they know about each other. Agents are part of the room, not haunted cron jobs. @@ -162,7 +168,7 @@ A Rust workspace of focused crates. Single source of truth: the relay. See [ARCH **Core protocol** — `buzz-core` (zero-I/O types, NIP-01 filters, Schnorr verify) · `buzz-relay` (Axum WS + REST) -**Services** — `buzz-db` (Postgres) · `buzz-auth` (NIP-42/98 Schnorr auth, rate limiting) · `buzz-pubsub` (Redis, presence, typing) · `buzz-search` (Typesense) · `buzz-audit` (hash-chain log) +**Services** — `buzz-db` (Postgres) · `buzz-auth` (NIP-42/98 Schnorr auth, rate limiting) · `buzz-pubsub` (Redis, presence, typing) · `buzz-search` (Typesense) · `buzz-audit` (hash-chain log). Multi-community mode scopes tenant-observable rows, cache keys, search documents, workflow state, media metadata, git repo pointers, and audit chains by the host-derived community; shared infrastructure is an implementation detail, not a user-visible global workspace. **Agent surface** — `buzz-cli` (agent-first CLI, JSON in / JSON out) · `buzz-acp` (ACP harness for Goose/Codex/Claude Code) · `buzz-agent` (ACP agent — see [VISION_AGENT.md](VISION_AGENT.md)) · `buzz-dev-mcp` (shell + file-edit tools) · `buzz-workflow` (YAML automation) · `buzz-persona` (agent persona packs) diff --git a/VISION_AGENT.md b/VISION_AGENT.md index 5efdc33e6..2b2597c8d 100644 --- a/VISION_AGENT.md +++ b/VISION_AGENT.md @@ -16,6 +16,13 @@ Two binaries, two protocols, no coupling between them. Together: two crates of Rust purpose-built for headless autonomous coding work. +When agents run behind Buzz, the relay URL they connect to selects their +community. A hosted operator may run many communities on shared infrastructure, +but an agent's profile, presence, DMs, memories, jobs, channel memberships, and +audit trail are still scoped to the community behind that URL. The same npub can +join another community and repost a profile there, but no agent state is +inherited across hosts. + ## Why We Built Our Own **Auditability.** A senior engineer can read both binaries in a sitting. There are no abstractions reserved for future flexibility. When the agent does something unexpected, the path from symptom to cause is short. @@ -57,6 +64,7 @@ Two pipes. Two protocols. Each session gets its own MCP server instances — ful - Multiple concurrent sessions in one process — each with independent MCP servers, history, and context (configurable cap, default 8) - Ten agents in parallel behind Buzz, each with their own MCP configuration +- The same agent key can participate in multiple Buzz communities while keeping membership, jobs, DMs, profile, and presence community-local - Any ACP client gets a coding agent without a custom adapter - Any MCP server gets a capable caller without a custom adapter - A codebase small enough to fork, modify, and understand in a day — two crates, no coupling between them diff --git a/VISION_PROJECTS.md b/VISION_PROJECTS.md index 65e637269..a44d7e05f 100644 --- a/VISION_PROJECTS.md +++ b/VISION_PROJECTS.md @@ -4,7 +4,7 @@ > > Bug report to merged patch. One place. One search index. One identity system. The branch channel was the pull request, the CI dashboard, and the discussion thread. -This document is the software-forge slice of the broader Buzz platform. [VISION.md](VISION.md) covers the platform. [VISION_SOVEREIGN.md](VISION_SOVEREIGN.md) covers the sovereign relay story — one domain, one relay, one project. This doc zooms in on what it looks like when that relay hosts code. +This document is the software-forge slice of the broader Buzz platform. [VISION.md](VISION.md) covers the platform. [VISION_SOVEREIGN.md](VISION_SOVEREIGN.md) covers the sovereign relay story — one domain, one relay, one project. This doc zooms in on what it looks like when that relay hosts code. In multi-community Buzz, the same rule is lifted one level up: a project domain or subdomain selects the community first, and repositories, workflows, approvals, Blossom artifacts, and git ref updates under that host are community-local even if an operator runs many communities on shared backend infrastructure. --- @@ -12,7 +12,7 @@ This document is the software-forge slice of the broader Buzz platform. [VISION. A project lives on the relay. `myproject.com` in a browser shows the project home. Click a repo and you're at `repoa.myproject.com` — README rendered, file tree navigable, code syntax-highlighted, clone URL at the top. The same URL serves HTML to a browser and git protocol to `git clone`. Content negotiation. One URL, two audiences. -Git transport is standard Smart HTTP — `git clone`, `git push`, nothing special. Your npub signs pushes. Same domain, same auth, same identity as everything else on the relay. +Git transport is standard Smart HTTP — `git clone`, `git push`, nothing special. Your npub signs pushes. Same domain, same auth, same identity as everything else on the relay. The host in the clone/push URL is also the community selector: the same `owner/repo` name may exist in two communities without sharing refs, branch protections, workflow runs, approvals, or repo announcements. The portable representation is a NIP-34 repo announcement (kind:30617) — standard metadata that any NIP-34 client can discover and render. Buzz extends it with `buzz-` prefixed tags for channel binding and visibility: @@ -105,7 +105,7 @@ The approval event is signed by the maintainer's npub. The merge status referenc ## The Web of Trust -Every contributor — human or agent — has a verifiable identity and a queryable contribution history across every project on the network. +Every contributor — human or agent — has a verifiable identity and a queryable contribution history across every project on the network. Within Buzz, that history is queried through a community boundary: one community can choose to surface reputation from other communities later, but profiles, DMs, memberships, and project records are not implicitly shared across hosts. A new contributor submits a patch. Before you read the code: @@ -125,7 +125,7 @@ Workflows orchestrate. Agents perform the compute. The relay is the message bus, A push to a branch channel triggers the CI workflow. The workflow engine coordinates the steps — build, test, lint. Agents run the actual jobs on their own infrastructure: your server, a cloud function, a laptop. Results post back to the branch channel alongside the conversation. -Workflows live in the repo (`.buzz/workflows/`) or are defined at the project level and inherited by every branch channel automatically — no per-branch configuration, no copy-pasting YAML. +Workflows live in the repo (`.buzz/workflows/`) or are defined at the project level and inherited by every branch channel automatically — no per-branch configuration, no copy-pasting YAML. Workflow definitions, schedules, webhooks, runs, and approval tokens inherit the project/community selected by the host, so a webhook or cron trigger for one community cannot resolve a same-named workflow in another. ```yaml name: CI diff --git a/docs/multi-tenant-conformance.md b/docs/multi-tenant-conformance.md new file mode 100644 index 000000000..d7c6cfc22 --- /dev/null +++ b/docs/multi-tenant-conformance.md @@ -0,0 +1,74 @@ +# Multi-tenant Conformance Checklist + +This document is the source-vs-model checklist for adding first-class communities +without changing the observed behavior of a single-community Buzz deployment. + +The compatibility rule is: **today's Buzz is one implicit community selected by +its relay URL**. Multi-tenant Buzz makes that selector explicit at the backend +boundary while preserving the Nostr wire format, existing REST paths, channel +UUIDs, event shapes, media URLs, git Smart HTTP behavior, workflow behavior, and +CLI/Desktop/MCP expectations when `N = 1`. + +## Row zero: request community binding + +Every external request starts with exactly one community: + +> `req.community = resolve_host(connection.host)`, bound at connection +> establishment, before any WebSocket `EVENT`/`REQ`, REST handler, media handler, +> git transport handler, webhook handler, workflow side effect, search query, or +> pub/sub fan-out path observes tenant data. + +Conformance obligations: + +- The URL host is the authoritative community selector. This preserves today's + "the relay URL is the thing I connected to" semantic while lifting it one + level up from relay process to community. +- Unknown or unmapped hosts fail closed with a generic rejection; they never fall + through to a default tenant. +- NIP-98/API-token/community stamps may narrow or authenticate authority, but + they never override the host-derived community. A token whose community stamp + disagrees with `req.community` is rejected. +- A client-supplied `h` tag is adversarial input. If present, it must resolve to + a channel inside `req.community`; if absent, the event is channel-less but still + community-scoped as `community_id = req.community`. +- The single-community deployment is the degenerate case: one configured host + resolves to the one default community, so existing clients observe the same + behavior. + +## Conformance table + +| Surface | Today's observable behavior | Tenant source | Community-global vs operator-global | Required DB/index/RLS scope | Auth/fan-out/search effects | Single-community compatibility check | Open decision/test | +|---|---|---|---|---|---|---|---| +| Row zero: host binding | A user connects to one relay URL and all state they can observe belongs to that relay. | `resolve_host(connection.host)` before handler entry. | Community-global selector; operator only manages the host map. | `communities(host, id, signing_key, …)`; every scoped table references immutable `community_id`. | All auth, event, REST, media, git, search, pub/sub, and workflow paths consume `TenantContext`; host/token mismatch rejects generically. | One host maps to the default community; no client-visible protocol field changes. | Add model/prose gate that `ctx.community` is derived from host, not supplied by the client. | +| NIP-11 relay info and relay `self` | `GET /`/`/info` returns one relay info document; `RelayInfo::build` advertises static NIPs and a stable relay signing pubkey when configured. | Host-derived community for community-specific facts; no DB lookup from unauthenticated global state unless explicitly through `TenantContext`. | NIP-11 is community-global. Operator-global software/version may be shared; relay `self` for group/system/audit signing is per-community. | `communities.signing_key` or equivalent per-community signing material; no platform-global `self` for tenant-observable system events. | Unauthenticated reads must not become enumeration oracles for other communities. NIP-43 advertisement reflects membership enforcement for that community only. | One community returns the same JSON except for values already configured today. | Signature/static-input lint remains: `RelayInfo::build` must not grow unscoped DB/search/audit inputs. | +| API tokens and NIP-98 replay | API/NIP-98 clients authenticate REST/media/git; API tokens may carry scopes and channel IDs; NIP-98 replay uses an in-process seen-set today. | Host-derived community plus token's stamped community; stamps must agree. | Community-global token namespace; operator-global only for deployment health/secrets. | `api_tokens` gains `community_id`; token hash uniqueness and lookup are `(community_id, token_hash)` or the token cryptographically embeds community and lookup verifies both. Channel claims must reference channels in the same community. | Replay seen-set key is `(community_id, event_id)` in shared HA storage or equivalent sticky routing; NIP-98 `u` URL host must match `req.community`. | Existing single-community tokens continue to authorize the same scopes/channels after backfill to default community. | HA gate: Redis/shared seen-set with atomic insert-if-absent and TTL ≥ replay window, or documented single-replica/sticky alternative. | +| Relay membership, pubkey allowlist, archived identities | `relay_members`, `pubkey_allowlist`, and `archived_identities` are relay-global gates over pubkeys. | Host-derived community for tenant access; operator context only for platform administration. | Community-global membership/allowlist/archive by default. Operator-global only for explicit platform ops tables that are never tenant-observable. | Add `community_id` to these tables; primary/unique keys become `(community_id, pubkey)` and indexes include `community_id`. | Membership errors remain generic. NIP-OA owner checks test owner membership in the same community. Identity archive requests cannot hide/archive a key in another community. | One default community preserves today's closed/open relay behavior and admin CLI semantics after commands target the default community. | Decide any future operator-global super-admin surface separately; do not reuse tenant membership tables for it. | +| Users, profiles, NIP-05, and user search | Kind:0 updates sync a `users` row; NIP-05 handles are unique; `/api/users/search` searches display name/NIP-05/pubkey. | Channel-less events use `req.community`; NIP-05 domain is the connected community host. | Community-global. Same pubkey can have one profile per community; users repost kind:0 in each community they join. | `users` gains `community_id`; keys/uniques are `(community_id, pubkey)`, `(community_id, lower(nip05_handle))`, and `(community_id, okta_user_id)` where applicable. Profile event replacement is scoped by `(community_id, pubkey)`. | Search and batch profile reads include `community_id`; NIP-05 lookup only resolves handles for the requested host/community. No cross-community profile inheritance. | Existing users backfill into the default community; profile APIs and CLI output stay unchanged. | Add tests for same pubkey with different profile/NIP-05 in two communities and for NIP-05 same local part on two hosts. | +| Channel-less global events and DMs | Events with `channel_id = NULL` include profiles, DMs, lists, status, long-form, engrams, membership notifications, workflow commands, and repo announcements; global subscriptions use p/kind gates. | `req.community` when no `h` tag is present. | Community-global. "Global" means visible across channels inside one community, never across communities. DMs are per-community. | `events`, `event_mentions`, replaceable/NIP-33 indexes, reactions, thread metadata, feed tables, and direct ID lookup helpers include `community_id`. NIP-33 uniqueness is `(community_id, kind, pubkey, d_tag)`. | `REQ`, `/query`, `/count`, feed, direct `GET /api/events/{id}`, deletes, reactions, and thread lookups filter by community before event id/pubkey/kind matching. | Single-community global subscriptions and DMs still behave as today. | Regression tests for same event id/d-tag/pubkey in two communities and for DM `#p` not cross-delivering. | +| Channels and channel membership | `channel_id` (`h` tag) is the only locality boundary; channels, membership, canvas, topic, DMs, NIP-29 discovery are channel-scoped. | `resolve(h)` must equal `req.community`; channel creation writes `community_id = req.community`. | Community-global channel namespace; channel-local for channel content. | `channels`, `channel_members`, canvas/topic/DM participant hashes, NIP-29 group ids, and channel indexes include `community_id`. `channels.community_id` is immutable. | Mixed-community or unknown `h` tags reject generically. Open-channel discovery lists only channels in the community. | Existing channel UUIDs and `h` tags remain valid after default-community backfill. | Migration lint forbids channel re-tenanting except through an explicitly modeled admission path. | +| Workflows, runs, approvals, webhooks, schedules | Workflows are channel-scoped or project/channel-global; triggers fire on matching stored events; schedule/webhook/manual triggers create runs; approval tokens are hashed. | Workflow definition's community from `req.community` at create/update; webhook/schedule/manual routes resolve workflow id inside host-derived community. | Community-global workflow namespace; runs/approvals inherit workflow community. | `workflows`, `workflow_runs`, `workflow_approvals` include `community_id`; workflow id/token hash lookups are scoped; trigger event ids are scoped. | Trigger evaluation only sees events in the same community. Webhook URLs include host-derived community; approval token grants cannot act on another community's same hash/id. | Existing workflow APIs and YAML remain unchanged in default community. | Add tests for identical workflow UUID/approval token hash in different communities and schedule execution isolation. | +| Search / Typesense | One collection (`events`) indexes `id`, `content`, `kind`, `pubkey`, optional `channel_id`, `created_at`, `tags_flat`; channel-less docs use `__global__`; relay refetches canonical events from Postgres by hit id. | Search query carries `req.community`; indexed documents carry `community_id`. | Community-global search results; operator-global collection infrastructure may be shared. | Typesense schema adds faceted `community_id`; document id is collision-safe (`community_id:event_id`) or deletes/upserts always include a community filter. Postgres refetch uses `(community_id, event_id)`. | Every `filter_by` includes `community_id:=…` plus channel scope. `__global__` means channel-less within the community, not platform global. | One community can use the same collection and produce the same search results. | Reindex gate required; tests for same event id/content in A and B, deletion in A not deleting B. | +| Redis pub/sub, presence, typing, and cache invalidation | Event fan-out uses `buzz:channel:{uuid}`; presence uses `buzz:presence:{pubkey}`; typing uses `buzz:typing:{channel_id}`; cache invalidation uses `buzz:cache-invalidate`. | Pub/sub calls receive `TenantContext` and derive keys from `community_id` plus channel/pubkey. | Pub/sub and presence are community-global; Redis deployment is operator-global shared infrastructure. | Redis keys include community: `buzz:{community}:channel:{uuid}`, `buzz:{community}:presence:{pubkey}`, `buzz:{community}:typing:{channel_id}`, and community-aware cache invalidation payloads/channels. | Cross-node fan-out must not deliver events to subscriptions in another community. Same pubkey can be online/away differently in two communities. Cache drops only affect same-community membership/visibility caches unless explicitly all-community operator maintenance. | Single-community can preserve existing key names only if deployment is isolated; shared multi-tenant Redis must use the prefixed form. | Add tests for same pubkey presence in two communities and same channel UUID collision in two communities. | +| Media / Blossom / S3 | Authenticated uploads return content-addressed descriptors; public `GET/HEAD /media/{sha256.ext}` serves blobs; upload audit has `channel_id = None`. | Upload request host provides `req.community`; Blossom/NIP-98 auth URL host must agree. | Blob CAS bytes may be operator-global shared storage; metadata, authorization, quotas, audit, and visibility are community-global. | Media metadata/audit rows include `community_id`; if object keys stay SHA-addressed, any per-community policy lives outside the raw blob key. | Upload/read authorization uses community context. Shared hash bytes are allowed only as dedup/storage optimization; metadata/errors must not reveal another community's private upload. | Existing media URLs keep working for default community. | Decide whether unauthenticated blob `GET` remains intentionally public; if not, reads need host-scoped auth/visibility checks. | +| Git hosting / NIP-34 / object storage | Smart HTTP at `/git/{owner}/{repo}` hydrates from S3 object pointers; NIP-34 repo announcements use `d=repo-id`; pointer key is `repos/{owner}/{repo}/pointer`; git push emits kind:30618. | Git HTTP host gives `req.community`; NIP-98 URL and repo announcement community must agree. | Community-global repo namespace and NIP-34 state; pack/manifests CAS objects may be operator-global if pointers are scoped. | Pointer/name keys include community, e.g. `repos/{community}/{owner}/{repo}/pointer`; NIP-34 replaceable coords include `community_id`; any repo-name registry is `(community_id, owner, repo)` or `(community_id, repo)` per product rule. | Clone/push/read policy resolves repo and branch protections only inside the host community. Git hook policy callback carries community and rejects mismatches. | Existing clone URLs and repo ids work under the default community; object-store migration can move pointers under default prefix without changing git clients. | Add tests for same owner/repo in two communities and push in A not advancing B pointer. | +| Mesh, agents, ACP/MCP, and CLI | Agents/CLI connect to a relay URL and use WS/REST; mesh/pairing/presence/status events are regular signed relay events. | The relay URL/host configured in the agent/CLI session selects community. | Agent membership, persona/profile, presence, jobs, memory events, and mesh status are community-global unless a future operator mesh plane is explicitly separate. | Any persisted agent profile/job/mesh status rows/events use `community_id`; Redis/presence/search keys follow the same community scoping. | A portable key may join multiple communities, but memberships, DMs, profiles, jobs, and presence do not bleed across them. | Existing `BUZZ_RELAY_URL` continues to select the one default community. | Add CLI/ACP smoke tests against two hosts using same key with different memberships/profile. | +| Audit log and observability | One hash-chain audit log records event/channel/auth/media actions; errors are sanitized before reaching clients. | Every tenant-observable audit entry is labeled with `req.community` or inherited community from the object being acted on. | Community-global audit chains; operator metrics/log aggregation may be platform-global only if tenant labels are bounded and access-controlled. | `audit_log` key/sequence/head includes `community_id`; error/audit projection tables include `community_id`; uniqueness is `(community_id, seq)` and `(community_id, hash)` as appropriate. | Audit reads verify only one community chain. Error strings must not include cross-community IDs, constraint names, or existence facts. | Single-community audit verification still traverses one chain. | Eva owns model edits here; infra lane must ensure media/git/token/search rows emit community-labeled audit entries. | + +## Migration gates + +Before multi-tenant mode is admitted, the implementation must have automated gates +for these classes of mistakes: + +1. Every tenant-scoped table has `community_id`, RLS policy, and no unique/FK + constraint that can be observed across tenants unless explicitly admitted as + operator-global. +2. Every direct lookup by event id, token hash, workflow id, approval token, + repo pointer/name, media hash metadata, pubkey profile, or channel id also + carries community context or first resolves the object under community. +3. Every cache/search/pubsub/object-store key that can affect tenant-visible + observations includes community context, except for deliberately shared CAS + byte storage whose authorization metadata is community-scoped. +4. Every externally reachable handler obtains `TenantContext` from host binding + before reading request body data that can cause tenant effects. +5. N=1 conformance tests prove existing clients do not need new tags, paths, + event fields, CLI flags, or protocol messages to keep current behavior. From 642a155f9de63967c008cfe350c1a20e37a99513 Mon Sep 17 00:00:00 2001 From: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta Date: Thu, 25 Jun 2026 16:28:10 -0400 Subject: [PATCH 05/11] docs(mt): mechanize community admission in TLA model Co-authored-by: Tyler Longwell Signed-off-by: Tyler Longwell --- docs/spec/MultiTenantRelay.tla | 215 ++++++++++++++++++++++++--------- 1 file changed, 159 insertions(+), 56 deletions(-) diff --git a/docs/spec/MultiTenantRelay.tla b/docs/spec/MultiTenantRelay.tla index 033e8860a..5c30abde0 100644 --- a/docs/spec/MultiTenantRelay.tla +++ b/docs/spec/MultiTenantRelay.tla @@ -66,6 +66,13 @@ (* (Init -> WriteInsert) for the insert path and a 3-state trace *) (* (Init -> WriteInsert -> WriteDuplicate) for the duplicate path. (Trace *) (* length, not TLC's run-dependent "depth of complete graph search" line.) *) +(* M9: NIP-43 community admission keyed globally (admit-if-member-of-ANY- *) +(* community) instead of by (community, actor) -> Inv_AdmissionFence *) +(* breaks when a B-admitted actor joins or channel-less-reads in A. *) +(* Confirmed red: 5-state membership trace (Init -> WriteInsert -> *) +(* WriteInsert -> AdmitMember(commA, alice) -> AddMembership(commB)) and *) +(* 4-state channel-less read trace (Init -> WriteInsert -> *) +(* AdmitMember(commB, alice) -> ReadMessageRows(commA, NoChannel, hostA)). *) (***************************************************************************) EXTENDS FiniteSets, Naturals, TLC @@ -139,6 +146,8 @@ VARIABLES messages, \* set of canonical message rows (source="message") projections, \* set of rebuildable projection rows (source="projection") memberships, \* tenant-scoped active channel membership rows + admittedMembers, \* NIP-43 community member-npub allowlist rows + channelLessReads, \* successful channel-less read admissions (current capabilities) openChannels, \* set of open/public channel ids auditHeads, \* function: community -> current audit head observations, \* typed outputs visible to tenant-scoped clients @@ -146,8 +155,9 @@ VARIABLES duplicateWrites, \* write requests that no-op'd on scoped conflict queryFaults \* fail-closed query-layer faults (e.g. no TenantContext) -vars == <> +vars == <> DataRows == [ id : MsgIds, @@ -166,6 +176,18 @@ MembershipRows == [ actor : Actors ] +AdmissionRows == [ + community : Communities, + actor : Actors +] + +ChannelLessReadRows == [ + worker : Workers, + community : Communities, + host : Hosts, + actor : Actors +] + AcceptedWriteRows == [ worker : Workers, id : MsgIds, @@ -257,6 +279,8 @@ TypeOK == /\ projections \subseteq ProjectionRows /\ projections \subseteq DerivedProjectionRows /\ memberships \subseteq MembershipRows + /\ admittedMembers \subseteq AdmissionRows + /\ channelLessReads \subseteq ChannelLessReadRows /\ openChannels \subseteq Channels /\ auditHeads \in [Communities -> AuditVals] /\ observations \subseteq Observations @@ -281,6 +305,8 @@ Init == /\ messages = {} /\ projections = {} /\ memberships = {} + /\ admittedMembers = {} + /\ channelLessReads = {} /\ openChannels = {} /\ auditHeads \in [Communities -> AuditVals] /\ observations = {} @@ -288,6 +314,15 @@ Init == /\ duplicateWrites = {} /\ queryFaults = {} +IsAdmitted(community, actor) == + [community |-> community, actor |-> actor] \in admittedMembers + +\* Intentionally-bad admission helper for mutation M9: treats the NIP-43 member +\* allowlist as relay-global by dropping community from the key. Substitute this +\* for IsAdmitted in AddMembership / channel-less reads to reproduce M9. +GloballyAdmitted(actor) == + \E c \in Communities : [community |-> c, actor |-> actor] \in admittedMembers + ScopedAccessible(community, actor) == {ch \in Channels : /\ ChannelCommunity[ch] = community @@ -304,21 +339,30 @@ UnscopedAccessible(actor) == \E c \in Communities : [community |-> c, channel |-> ch, actor |-> actor] \in memberships} -VisibleMessageRows(community, actor) == +VisibleMessageRows(community, actor, targetChannel) == {m \in messages : /\ m.community = community - /\ m.channel \in ScopedAccessible(community, actor)} + /\ IF targetChannel = NoChannel + THEN /\ m.channel = NoChannel + /\ IsAdmitted(community, actor) + ELSE m.channel \in ScopedAccessible(community, actor)} -VisibleProjectionRows(community, actor) == +VisibleProjectionRows(community, actor, targetChannel) == {p \in projections : /\ p.community = community - /\ p.channel \in ScopedAccessible(community, actor)} + /\ IF targetChannel = NoChannel + THEN /\ p.channel = NoChannel + /\ IsAdmitted(community, actor) + ELSE p.channel \in ScopedAccessible(community, actor)} -VisibleDirectIdRows(community, actor, id) == +VisibleDirectIdRows(community, actor, id, targetChannel) == {m \in messages : /\ m.id = id /\ m.community = community - /\ m.channel \in ScopedAccessible(community, actor)} + /\ IF targetChannel = NoChannel + THEN /\ m.channel = NoChannel + /\ IsAdmitted(community, actor) + ELSE m.channel \in ScopedAccessible(community, actor)} \* Intentionally-bad direct lookup mutation: answer by global id first and trust \* the row's own community as scope. Substitute this into ReadByIdRows for M7. @@ -353,8 +397,8 @@ WriteInsert(w) == IN /\ observations' = observations \cup {obs} /\ queryFaults' = queryFaults \cup {fault} - /\ UNCHANGED <> + /\ UNCHANGED <> ELSE \* host agrees with the channel mapping: accept, stamp the host. LET key == [community |-> real, id |-> id] row == MessageRow(id, real, ch, a) @@ -368,8 +412,8 @@ WriteInsert(w) == /\ messages' = messages \cup {row} /\ observations' = observations \cup {obs} /\ acceptedWrites' = acceptedWrites \cup {wr} - /\ UNCHANGED <> + /\ UNCHANGED <> \* Channel-less write (kind:0 profiles, 1059 DMs, 30023/30174/30315/30078, lists). \* There is no h tag to resolve, so the community is derived from the connection's @@ -384,8 +428,8 @@ WriteInsertGlobal(w) == /\ \E id \in MsgIds, host \in Hosts, a \in Actors, claimed \in Communities : LET resolved == HostCommunity[host] IN - IF resolved = NoCommunity - THEN \* fail-closed: unmapped/unknown host, write nothing. + IF resolved = NoCommunity \/ ~IsAdmitted(resolved, a) + THEN \* fail-closed: unmapped/unknown host or community admission miss. LET obs == [worker |-> w, actor |-> a, community |-> claimed, channel |-> NoChannel, kind |-> "SanitizedError", labels |-> {}, rows |-> {}, error |-> "restricted", @@ -395,8 +439,8 @@ WriteInsertGlobal(w) == IN /\ observations' = observations \cup {obs} /\ queryFaults' = queryFaults \cup {fault} - /\ UNCHANGED <> + /\ UNCHANGED <> ELSE \* mapped host: stamp the host-resolved community, channel-less. LET key == [community |-> resolved, id |-> id] row == [id |-> id, community |-> resolved, channel |-> NoChannel, @@ -412,8 +456,8 @@ WriteInsertGlobal(w) == /\ messages' = messages \cup {row} /\ observations' = observations \cup {obs} /\ acceptedWrites' = acceptedWrites \cup {wr} - /\ UNCHANGED <> + /\ UNCHANGED <> \* Duplicate / no-op write outcome (scoped conflict on (community_id, id)). This \* is client-observable write surface -- the "Duplicate" WriteResult exposes the @@ -439,8 +483,8 @@ WriteDuplicate(w) == IN /\ observations' = observations \cup {obs} /\ queryFaults' = queryFaults \cup {fault} - /\ UNCHANGED <> + /\ UNCHANGED <> ELSE \* host agrees with the channel mapping: report the scoped duplicate. LET key == [community |-> real, id |-> id] conflicts == ScopedConflictRows(real, id) @@ -454,44 +498,65 @@ WriteDuplicate(w) == /\ messages' = messages /\ observations' = observations \cup {obs} /\ duplicateWrites' = duplicateWrites \cup {wr} - /\ UNCHANGED <> + /\ UNCHANGED <> ReadMessageRows(w) == /\ Cardinality(observations) < MaxObservations - /\ \E c \in Communities, a \in Actors, ch \in Channels : - LET rows == VisibleMessageRows(c, a) + /\ \E c \in Communities, a \in Actors, ch \in ChannelsExt, host \in Hosts : + LET rows == VisibleMessageRows(c, a, ch) obs == [worker |-> w, actor |-> a, community |-> c, channel |-> ch, kind |-> "ResultRows", labels |-> RowLabels(rows), rows |-> rows, error |-> NoError, result |-> "None", verdict |-> "None", audit |-> NoAudit] + read == [worker |-> w, community |-> c, host |-> host, actor |-> a] IN + /\ IF ch = NoChannel + THEN /\ HostCommunity[host] = c + /\ IsAdmitted(c, a) + /\ channelLessReads' = channelLessReads \cup {read} + ELSE channelLessReads' = channelLessReads /\ observations' = observations \cup {obs} - /\ UNCHANGED <> + /\ UNCHANGED <> ReadProjectionRows(w) == /\ Cardinality(observations) < MaxObservations - /\ \E c \in Communities, a \in Actors, ch \in Channels : - \E rows \in SUBSET VisibleProjectionRows(c, a) : + /\ \E c \in Communities, a \in Actors, ch \in ChannelsExt, host \in Hosts : + \E rows \in SUBSET VisibleProjectionRows(c, a, ch) : LET obs == [worker |-> w, actor |-> a, community |-> c, channel |-> ch, kind |-> "ResultRows", labels |-> RowLabels(rows), rows |-> rows, error |-> NoError, result |-> "None", verdict |-> "None", audit |-> NoAudit] + read == [worker |-> w, community |-> c, host |-> host, actor |-> a] IN + /\ IF ch = NoChannel + THEN /\ HostCommunity[host] = c + /\ IsAdmitted(c, a) + /\ channelLessReads' = channelLessReads \cup {read} + ELSE channelLessReads' = channelLessReads /\ observations' = observations \cup {obs} - /\ UNCHANGED <> + /\ UNCHANGED <> ReadByIdRows(w) == /\ Cardinality(observations) < MaxObservations - /\ \E c \in Communities, a \in Actors, ch \in Channels, id \in MsgIds : - LET rows == VisibleDirectIdRows(c, a, id) + /\ \E c \in Communities, a \in Actors, ch \in ChannelsExt, host \in Hosts, id \in MsgIds : + LET rows == VisibleDirectIdRows(c, a, id, ch) obs == [worker |-> w, actor |-> a, community |-> c, channel |-> ch, kind |-> "ResultRows", labels |-> RowLabels(rows), rows |-> rows, error |-> NoError, result |-> "None", verdict |-> "None", audit |-> NoAudit] + read == [worker |-> w, community |-> c, host |-> host, actor |-> a] IN + /\ IF ch = NoChannel + THEN /\ HostCommunity[host] = c + /\ IsAdmitted(c, a) + /\ channelLessReads' = channelLessReads \cup {read} + ELSE channelLessReads' = channelLessReads /\ observations' = observations \cup {obs} - /\ UNCHANGED <> + /\ UNCHANGED <> \* Explicit community predicate was accidentally omitted, but the transaction is \* inside TenantContext and Postgres RLS applies the community fence. @@ -505,8 +570,8 @@ ReadForgotPredicateWithRLS(w) == error |-> NoError, result |-> "None", verdict |-> "None", audit |-> NoAudit] IN /\ observations' = observations \cup {obs} - /\ UNCHANGED <> + /\ UNCHANGED <> \* If the query does not establish TenantContext at all, RLS must fail closed. ReadNoTenantContext(w) == @@ -520,8 +585,8 @@ ReadNoTenantContext(w) == IN /\ observations' = observations \cup {obs} /\ queryFaults' = queryFaults \cup {fault} - /\ UNCHANGED <> + /\ UNCHANGED <> SanitizedError(w) == /\ Cardinality(observations) < MaxObservations @@ -531,8 +596,8 @@ SanitizedError(w) == error |-> e, result |-> "None", verdict |-> "None", audit |-> NoAudit] IN /\ observations' = observations \cup {obs} - /\ UNCHANGED <> + /\ UNCHANGED <> \* Channel-bearing authorization. Community resolves from the channel mapping; \* the host is also authoritative. Host/channel disagreement (A-host + B-channel) @@ -550,14 +615,14 @@ AuthCheck(w) == error |-> NoError, result |-> "None", verdict |-> verdict, audit |-> NoAudit] IN /\ observations' = observations \cup {obs} - /\ UNCHANGED <> + /\ UNCHANGED <> AppendAudit(w) == \E c \in Communities, newHead \in AuditVals : /\ auditHeads' = [auditHeads EXCEPT ![c] = newHead] - /\ UNCHANGED <> + /\ UNCHANGED <> ObserveAuditHead(w) == /\ Cardinality(observations) < MaxObservations @@ -567,25 +632,46 @@ ObserveAuditHead(w) == error |-> NoError, result |-> "None", verdict |-> "None", audit |-> auditHeads[c]] IN /\ observations' = observations \cup {obs} - /\ UNCHANGED <> + /\ UNCHANGED <> \* Projection rebuild is privileged internal work. It may touch all communities \* and may leave projections temporarily partial, but it emits no observation. RebuildProjections(w) == \E rebuilt \in SUBSET DerivedProjectionRows : /\ projections' = rebuilt - /\ UNCHANGED <> + /\ UNCHANGED <> + +AdmitMember(w) == + \E c \in Communities, a \in Actors : + LET row == [community |-> c, actor |-> a] + IN + /\ admittedMembers' = admittedMembers \cup {row} + /\ UNCHANGED <> + +RevokeMember(w) == + \E c \in Communities, a \in Actors : + LET row == [community |-> c, actor |-> a] + IN + /\ admittedMembers' = admittedMembers \ {row} + /\ memberships' = {m \in memberships : ~(m.community = c /\ m.actor = a)} + /\ channelLessReads' = {r \in channelLessReads : ~(r.community = c /\ r.actor = a)} + /\ UNCHANGED <> AddMembership(w) == \E ch \in Channels, a \in Actors : LET c == ChannelCommunity[ch] row == [community |-> c, channel |-> ch, actor |-> a] IN + /\ IsAdmitted(c, a) /\ memberships' = memberships \cup {row} - /\ UNCHANGED <> + /\ UNCHANGED <> RemoveMembership(w) == \E ch \in Channels, a \in Actors : @@ -593,20 +679,21 @@ RemoveMembership(w) == row == [community |-> c, channel |-> ch, actor |-> a] IN /\ memberships' = memberships \ {row} - /\ UNCHANGED <> + /\ UNCHANGED <> OpenChannel(w) == \E ch \in Channels : /\ openChannels' = openChannels \cup {ch} - /\ UNCHANGED <> + /\ UNCHANGED <> CloseChannel(w) == \E ch \in Channels : /\ openChannels' = openChannels \ {ch} - /\ UNCHANGED <> + /\ UNCHANGED <> Next == \E w \in Workers : @@ -623,6 +710,8 @@ Next == \/ AppendAudit(w) \/ ObserveAuditHead(w) \/ RebuildProjections(w) + \/ AdmitMember(w) + \/ RevokeMember(w) \/ AddMembership(w) \/ RemoveMembership(w) \/ OpenChannel(w) @@ -701,6 +790,19 @@ Inv_HostBindingFence == /\ HostCommunity[w.host] \in Communities /\ w.community = HostCommunity[w.host] +\* I2-admission fence: NIP-43 member-npub admission is scoped by community, not +\* relay-global pubkey. Active channel memberships and current channel-less read +\* capabilities may only exist when the actor is admitted in that same community; +\* channel-less read capabilities also carry the host that resolved that community. +\* This records the observable admission capability, not historical message rows: +\* revoking admission removes current memberships/reads but does not relabel rows +\* already written while admitted. +Inv_AdmissionFence == + /\ \A m \in memberships : IsAdmitted(m.community, m.actor) + /\ \A r \in channelLessReads : + /\ IsAdmitted(r.community, r.actor) + /\ HostCommunity[r.host] = r.community + \* I3a append persistence: every accepted append remains present in the shared log. Inv_AcceptedWritesPersist == \A w \in acceptedWrites : MessageRow(w.id, w.community, w.channel, w.author) \in messages @@ -733,6 +835,7 @@ Safety == /\ Inv_ReadConfinement /\ Inv_ResolutionFence /\ Inv_HostBindingFence + /\ Inv_AdmissionFence /\ Inv_AcceptedWritesPersist /\ Inv_MessageKeyUnique /\ Inv_NoTenantContextFailsClosed From 46ee7570ee8411c032c1fe178653461c16675f4d Mon Sep 17 00:00:00 2001 From: npub1jmc9dt2lyvzu3h0kxlwxt5zg4fxp9476awyxw6gwxn72g6cw7exqs64whm <96f056ad5f2305c8ddf637dc65d048aa4c12d7daeb8867690e34fca46b0ef64c@sprout-oss.stage.blox.sqprod.co> Date: Thu, 25 Jun 2026 16:28:09 -0400 Subject: [PATCH 06/11] docs(multi-tenant): mechanize NIP-43 admission and tighten audit-splice probe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the Tamarin half of Tyler's option-A model extension: NIP-43 community member-npub allowlist admission is now modeled as a derived effect of accepting a relay-signed member-list event, not just as the signing-replay property already covered by `system_event_acceptance_requires_same_community_ key_or_compromise`. The TLA+ counterpart (`admittedMembers` relation + `AdmitMember` action) is owned by Max on this branch; the two semantics agree on "B can't admit into A" — one property witnessed in two model worlds. Changes ------- `docs/spec/MultiTenantAuth.spthy`: 1. Add `Community_Signs_NIP43_MemberList` and `Relay_Accepts_NIP43_MemberList` rules. The signed preimage binds (`'member_list', comm, group, pk`); acceptance re-verifies the signature against `!CommunitySigningKey(comm, sk)`, which binds `comm` to the resolved community, never the claimed one — same confused-deputy discipline as `Use_Token`'s host fence. The accepted event emits the persistent fact `!Admitted(pk, comm)` plus the action fact `MemberAdmitted(pk, comm)`. 2. Add lemma `nip43_admission_confined_to_signing_community`: every `MemberAdmitted(pk, comm)` requires either a prior `MemberListSigned(comm, _, pk)` or a prior compromise of `comm`'s signing key. This is the load-bearing cross-community claim — B's key cannot mint an admission into A. 3. Add sibling lemma `other_community_key_compromise_does_not_admit` (the parallel of `other_community_key_compromise_does_not_authorize` at line 525). Compromise of community B's signing key never suffices to admit a pubkey into a different community A. 4. Add commented-out `MUTATION_Admit_Ignore_Community` red witness: the exact dual of `MUTATION_Use_Token_Ignore_Host` (lines 213-225). Rebinds the admission community to a fresh `~other_comm` so a B-signed list admits into A. Toggling the mutation (and commenting the real rule) falsifies `nip43_admission_confined_to_signing_community` at 7 steps. 5. Add `executable_member_admitted` anti-vacuity probe: a trace exists in which a member-list event is signed and accepted, so the new lemma's left-hand side is reachable. 6. Tighten `Probe_Audit_Cross_Community_Splice` (line 403) to restore both consumed `AuditHead` facts in its output multiset (PR review obs-2). The probe models an *attempt*, not a chain consumption. Soundness of `cross_community_audit_splice_attempt_is_not_append` does not depend on this (no rule emits `AuditAppended` from the attempt), but tightening the trace shape matches reality and prevents head-erasure from blocking subsequent appends in the same execution. Verification ------------ Full suite (`tamarin-prover --prove`) on the corrected theory: 30/30 lemmas verified, processing time 10.12s. - 27 baseline lemmas: same step counts as the 2498983d baseline. - `nip43_admission_confined_to_signing_community`: verified (19 steps). - `other_community_key_compromise_does_not_admit`: verified (79 steps). - `executable_member_admitted`: verified (7 steps). - `cross_community_audit_splice_attempt_is_not_append` still verified at 1 step (audit-splice restore changes the output multiset but not the action facts the lemma quantifies over). Mutation discipline (`MUTATION_Admit_Ignore_Community` enabled, real acceptance rule commented): `nip43_admission_confined_to_signing_community` (all-traces): falsified - found trace (7 steps), 1.57s. The mutation produces `MemberAdmitted(pk, ~other_comm)` without any preceding `MemberListSigned(~other_comm, _, pk)` or compromise of `~other_comm`'s key, falsifying the lemma's right-hand disjunction. This confirms the green proof above asserts a real property. Cross-lane alignment -------------------- Quinn's `!Admitted(pk, comm)` Tamarin fact and Max's `admittedMembers` TLA+ relation describe the same admission semantic: a pubkey admitted to a community by a signing-key-authorized member-list event. The Tamarin side proves the admission event itself is per-community unforgeable; Max's TLA+ side proves the in-relay scoping (a B-admitted actor cannot act in A). Eva folds the cross-lane correspondence into the spec prose. Co-authored-by: Tyler Longwell Signed-off-by: Tyler Longwell --- docs/spec/MultiTenantAuth.spthy | 118 +++++++++++++++++++++++++++++++- 1 file changed, 117 insertions(+), 1 deletion(-) diff --git a/docs/spec/MultiTenantAuth.spthy b/docs/spec/MultiTenantAuth.spthy index f09000c00..ddc9e0287 100644 --- a/docs/spec/MultiTenantAuth.spthy +++ b/docs/spec/MultiTenantAuth.spthy @@ -379,6 +379,75 @@ rule Compromise_Community_Signing_Key: ]-> [ Out(sk) ] +// ============================================================================ +// NIP-43 community member-npub allowlist admission +// ============================================================================ +// +// NIP-43 grounding: a relay-signed member-list event names pubkeys that are +// admitted to a community. The signed preimage commits to (community id, +// group id, pubkey), so a B-key-signed member-list event cannot mint an +// admission into community A even under group-id collision. Acceptance is +// gated by the same key-binding discipline as Relay_Accepts_System_Event: +// the signature is verified against `!CommunitySigningKey(comm, sk)`, which +// binds `comm` to the resolved community at acceptance time, never the +// claimed one (same confused-deputy discipline as Use_Token's host fence). +// +// `!Admitted(pk, comm)` is the persistent fact a downstream layer would +// consult to decide whether a pubkey is admitted to a community; the TLA+ +// counterpart is `admittedMembers ⊆ (Communities × Actors)` populated by an +// `AdmitMember(w)` action. The cross-lane claim is one property witnessed in +// two model worlds: TLA+ proves the in-relay scoping (a B-admitted actor +// cannot act in A); Tamarin proves the admission event itself is +// per-community unforgeable (B's key cannot mint an admission into A). + +rule Community_Signs_NIP43_MemberList: + [ !CommunitySigningKey(comm, sk), Fr(~group), !ClientPublic(pk) ] + --[ + MemberListSigned(comm, ~group, pk) + ]-> + [ + Out(< 'member_list', comm, ~group, pk, + sign(< 'member_list', comm, ~group, pk >, sk) >) + ] + +rule Relay_Accepts_NIP43_MemberList: + [ In(< 'member_list', comm, group, pk, + sign(< 'member_list', comm, group, pk >, sk) >), + !CommunitySigningKey(comm, sk) + ] + --[ + MemberAdmitted(pk, comm) + ]-> + [ !Admitted(pk, comm) ] + +// MUTATION_Admit_Ignore_Community (commented red witness): +// Re-bind the admission community to a fresh variable so a B-signed +// member-list event mints `!Admitted(pk, ~other_comm)` for a community +// whose key did not sign it. This is the exact dual of +// `MUTATION_Use_Token_Ignore_Host` (213-225): the rule fires with +// `Neq(comm, ~other_comm)` and emits an admission into a community whose +// signing key never authorized the event. Toggling this rule on (and +// commenting out `Relay_Accepts_NIP43_MemberList` above) falsifies +// `nip43_admission_confined_to_signing_community` below: a fresh +// `~other_comm` cannot have either signed the list (different community) +// or had its key compromised in a way that authorized this admission, so +// the lemma's right-hand disjunction is unsatisfiable. +// +// rule MUTATION_Admit_Ignore_Community: +// [ In(< 'member_list', comm, group, pk, +// sign(< 'member_list', comm, group, pk >, sk) >), +// !CommunitySigningKey(comm, sk), +// Fr(~other_comm) +// ] +// --[ +// Neq(comm, ~other_comm), +// MemberAdmitted(pk, ~other_comm) +// ]-> +// [ !Admitted(pk, ~other_comm) ] +// +// Expected mutation result: `nip43_admission_confined_to_signing_community` +// goes red. + // ============================================================================ // Independent per-community audit chains // ============================================================================ @@ -406,7 +475,18 @@ rule Probe_Audit_Cross_Community_Splice: Neq(commA, commB), CrossCommunityAuditSpliceAttempt(commA, commB, prevA, prevB, h(< 'audit', commA, ~seq, prevB, ~entry >)) ]-> - [ ] + [ + // Restore both heads unchanged: the probe models an *attempt* that does + // not advance either chain. Without restoring, a successful probe firing + // would erase both heads from the trace, preventing any further audit + // appends in the same execution. Soundness of + // `cross_community_audit_splice_attempt_is_not_append` does not depend + // on this (no rule emits `AuditAppended` from this attempt), but + // tightening the model so the attempt does not consume the chains makes + // the trace shape match reality. + AuditHead(commA, prevA), + AuditHead(commB, prevB) + ] // ============================================================================ // Draft security lemmas @@ -530,6 +610,35 @@ lemma other_community_key_compromise_does_not_authorize: ==> (Ex #l. SystemEventSigned(commA, kind, group, msg) @ l & #l < #j) | (Ex #m. CommunityKeyCompromised(commA) @ m & #m < #j)" +// S5 shape: every NIP-43 admission of `pk` into community A requires either +// (a) a `MemberListSigned(A, _, pk)` event preceding the admission, or +// (b) A's signing key was compromised before the admission. Since acceptance +// in `Relay_Accepts_NIP43_MemberList` re-verifies the signature against +// `!CommunitySigningKey(comm, sk)` (binding `comm` at acceptance, not at +// claim), the admission community is forced to be the same community whose +// key signed the list event. This is the load-bearing cross-community claim +// for community-scoped member-npub allowlists: B's key cannot mint an +// admission into A. +lemma nip43_admission_confined_to_signing_community: + "All pk comm #i. + MemberAdmitted(pk, comm) @ i + ==> (Ex group #j. MemberListSigned(comm, group, pk) @ j & #j < #i) + | (Ex #k. CommunityKeyCompromised(comm) @ k & #k < #i)" + +// Sibling to `other_community_key_compromise_does_not_authorize`: compromise +// of community B's signing key never suffices to admit a pubkey into a +// different community A. The signed preimage of a member-list event binds +// the community id, so B's compromise yields no admission for A — A must +// either have signed the list for `pk` itself or had its own key +// compromised. +lemma other_community_key_compromise_does_not_admit: + "All commA commB pk #i #j #k. + CommunityKeyCompromised(commB) @ i + & MemberAdmitted(pk, commA) @ j + & Neq(commA, commB) @ k + ==> (Ex group #l. MemberListSigned(commA, group, pk) @ l & #l < #j) + | (Ex #m. CommunityKeyCompromised(commA) @ m & #m < #j)" + // S4 shape: every audit append advances a head for the same community and the // next hash binds that community id, so another community's head cannot be used // as a splice without changing the hash/preimage. @@ -593,4 +702,11 @@ lemma executable_host_token_mismatch_attempt: "Ex tok client minted_comm host_comm host #i. HostTokenMismatchAttempt(tok, client, minted_comm, host_comm, host) @ i" +// Anti-vacuity probe for nip43_admission_confined_to_signing_community: there +// must be a trace in which a member-list event is signed and accepted into +// the admitting community, so the lemma's left-hand side is reachable. +lemma executable_member_admitted: + exists-trace + "Ex pk comm #i. MemberAdmitted(pk, comm) @ i" + end From 5f60e2eaf2bba32674e052c8d6d85e1f22db6feb Mon Sep 17 00:00:00 2001 From: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co> Date: Thu, 25 Jun 2026 16:46:00 -0400 Subject: [PATCH 07/11] docs(multi-tenant): fold I5/S7 admission proofs into spec prose Mechanize the NIP-43 member-npub allowlist in the spec narrative now that the TLA+ (admittedMembers/Inv_AdmissionFence, M9) and Tamarin (S7 NIP-43 admission confinement) lanes have landed and been independently verified. - I5 (admission fence) added to the isolation theorems; S7 (NIP-43 admission confinement) added to authorization soundness, with the cross-lane seam (!Admitted/MemberAdmitted <-> admittedMembers/IsAdmitted) named explicitly. - M9 mutation entry (membership + channel-less-read variants) added; mutation count six -> seven. - C3 historical-writes-after-revocation carve-out added, parallel to C1/C2. - Stale counts reconciled: 27 lemmas (S1-S6) -> 30 (S1-S7); TLA+ run stats 138.7M/5.62M/depth-13 -> 265.1M/9.23M/depth-17; I1-I4 -> I1-I5; fixed a stale ChannelCommunity citation (tla:85 -> tla:107). Co-authored-by: Tyler Longwell Signed-off-by: Tyler Longwell --- docs/multi-tenant-relay.md | 124 ++++++++++++++++++++++++++++++------- 1 file changed, 103 insertions(+), 21 deletions(-) diff --git a/docs/multi-tenant-relay.md b/docs/multi-tenant-relay.md index d014bd89d..e99e8fa14 100644 --- a/docs/multi-tenant-relay.md +++ b/docs/multi-tenant-relay.md @@ -199,6 +199,19 @@ 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 @@ -364,9 +377,9 @@ predicate fail closed rather than leak (Theorem I4). 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:85`). Any future + `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–I4). + 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)`, @@ -426,7 +439,7 @@ load-bearing *backstop*. - **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–I4 are the specific + 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. @@ -448,6 +461,18 @@ load-bearing *backstop*. - **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 fence is about **current** capability: it is mutation-tested non-vacuous by + M9 (re-keying the gate to any-community admission), which goes red on both a + membership trace and a channel-less-read trace, proving an admit-into-A then + act-in-B escape is caught rather than invisible. (See C3 for the explicit + historical-write carve-out.) ### Authorization soundness (mechanized in Tamarin, Dolev-Yao adversary) @@ -489,13 +514,29 @@ load-bearing *backstop*. 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). 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–S6 are **machine-verified green** on -Tamarin 1.12.0 / Maude 3.5.1 — the full selected run verifies all 27 lemmas in -~8s with zero `analyzed` failures. S1/S2: `token_confinement`, +**Verification status.** S1–S7 are **machine-verified green** on +Tamarin 1.12.0 / Maude 3.5.1 — the full selected run verifies all 30 lemmas in +~18s 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 @@ -523,7 +564,16 @@ lemma (8 steps). S6 (channel-bearing host/channel agreement): `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. +`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. The S5 confinement lemma was deliberately framed to keep its mutation *cheaply* refutable. An earlier framing joined two action facts @@ -644,17 +694,23 @@ as label-flow non-interference is, to our knowledge, new for a Nostr relay. 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.* — 138,717,188 states generated, 5,621,760 distinct, 0 left - on queue, depth 13 (16 workers, ~40 s). The distinct-state count grew from the + error has been found.* — 265,122,788 states generated, 9,232,992 distinct, 0 left + on queue, depth 17 (16 workers, ~1m23). 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) precisely because the - channel-less write path, the fail-closed unmapped-host path, and the - channel-bearing host/channel-agreement (and its fail-closed disagreement) path + 5,621,760 with channel-bearing host/channel agreement → 9,232,992 with the + `admittedMembers` allowlist, `channelLessReads` capability rows, and the + `AdmitMember`/`RevokeMember` actions) 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, + and now the admit/revoke/gated-membership/gated-read paths are genuinely reachable — new behavior, not dead code. Threading the host through the duplicate/no-op path adds reachable fail-closed transitions (138.7M generated, up from 136.3M) without new distinct states: only the agreeing host can produce a - recorded duplicate, so the host on that path is fully determined. Non-vacuity is - shown by six mutations, each + recorded duplicate, so the host on that path is fully determined; layering the + admission gate on top is the +91% generated / +64% distinct growth to the figures + above (admit-then-act, revoke-then-act-fails, and gated reads/joins multiply the + reachable space). Non-vacuity is + shown by seven mutations, 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 @@ -673,7 +729,17 @@ as label-flow non-interference is, to our knowledge, new for a Nostr relay. 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). The two host-fence + 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 two host-fence 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 @@ -682,17 +748,18 @@ as label-flow non-interference is, to our knowledge, new for a Nostr relay. 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 27 lemmas (S1–S6) - verify green (Tamarin 1.12.0 / Maude 3.5.1, ~8 s) — each safety lemma paired with + `tamarin-prover --prove docs/spec/MultiTenantAuth.spthy`. All 30 lemmas (S1–S7) + verify green (Tamarin 1.12.0 / Maude 3.5.1, ~18 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, and `MUTATION_Use_Token_Ignore_Host` for S6's channel-bearing - host/channel-agreement fence) confirmed red. See §Authorization soundness for the + 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. 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–S6 lemmas close by two distinct shapes. + **Machine-check hygiene.** S1–S7 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`, @@ -828,7 +895,22 @@ The model's obligations map to concrete code seams: 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. + 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 From 222682658c8571908d6321b31495b44a4bebb1c8 Mon Sep 17 00:00:00 2001 From: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta Date: Thu, 25 Jun 2026 21:09:57 -0400 Subject: [PATCH 08/11] docs(multi-tenant): model host-scoped open auth deltas Co-authored-by: Tyler Longwell Signed-off-by: Tyler Longwell --- docs/spec/MultiTenantAuth.spthy | 36 +++ docs/spec/MultiTenantRelay.cfg | 7 +- docs/spec/MultiTenantRelay.tla | 434 ++++++++++++++++++++++++++------ 3 files changed, 405 insertions(+), 72 deletions(-) diff --git a/docs/spec/MultiTenantAuth.spthy b/docs/spec/MultiTenantAuth.spthy index ddc9e0287..02a0a94a9 100644 --- a/docs/spec/MultiTenantAuth.spthy +++ b/docs/spec/MultiTenantAuth.spthy @@ -337,6 +337,30 @@ rule Probe_Host_Token_Mismatch: ]-> [ ] + +// Open-community AUTH auto-registration. A community with no NIP-43 member +// pubkey allowlist admits any authenticated npub, but still only into the +// community resolved from the connection host. This is a separate admission +// source from NIP-43 member-list signing: NIP-43 admissions emit +// `MemberAdmitted`; open AUTH emits `OpenCommunityAutoRegistered`. Both mint the +// same downstream `!Admitted(pk, comm)` fact, so later read/write checks stay +// literal admission checks rather than read-path carve-outs. +rule Mark_Open_Community: + [ !Community(comm) ] + --[ + OpenCommunityEnabled(comm) + ]-> + [ !OpenCommunity(comm) ] + +rule Authenticate_To_Open_Community: + [ !ClientPublic(pk), !HostCommunity(host, comm), !OpenCommunity(comm) ] + --[ + OpenCommunityAutoRegistered(pk, comm, host), + HostBoundFor(host, comm), + OpenRegistrationResolved(pk, comm, host, comm) + ]-> + [ !Admitted(pk, comm) ] + // ============================================================================ // Per-community signing keys // ============================================================================ @@ -567,6 +591,14 @@ lemma host_token_mismatch_not_authorized: HostTokenMismatchAttempt(tok, client, minted_comm, host_comm, host) @ i ==> not (Ex #j. ChannelLessAuthorized(tok, client, minted_comm, host) @ j)" +// Open-community auto-registration is host-confined: the registered community is +// exactly the community bound to the connection host. There is no client-supplied +// community selector in the rule. +lemma open_auth_registration_confined_to_host_community: + "All pk registered_comm host host_comm #i. + OpenRegistrationResolved(pk, registered_comm, host, host_comm) @ i + ==> registered_comm = host_comm" + // S2: every minted token has exactly one stamped community, and every requested // channel recorded for that mint resolved to that stamp. lemma minted_token_channels_match_stamp: @@ -709,4 +741,8 @@ lemma executable_member_admitted: exists-trace "Ex pk comm #i. MemberAdmitted(pk, comm) @ i" +lemma executable_open_auth_registration: + exists-trace + "Ex pk comm host #i. OpenCommunityAutoRegistered(pk, comm, host) @ i" + end diff --git a/docs/spec/MultiTenantRelay.cfg b/docs/spec/MultiTenantRelay.cfg index f33ee8589..7ca63f61a 100644 --- a/docs/spec/MultiTenantRelay.cfg +++ b/docs/spec/MultiTenantRelay.cfg @@ -5,11 +5,11 @@ SPECIFICATION Spec CONSTANTS Communities = {commA, commB} - Channels = {chanA1, chanA2, chanB1, chanB2} + Channels = {chanA1, chanA2, chanB1, chanB2, chanFresh} Hosts = {hostA, hostB, hostBad} Actors = {alice} Workers = {relay1} - MsgIds = {msg1, msg2} + MsgIds = {msg1} AuditVals = {audit0, audit1} CommA = commA CommB = commB @@ -17,13 +17,16 @@ CONSTANTS ChanA2 = chanA2 ChanB1 = chanB1 ChanB2 = chanB2 + ChanFresh = chanFresh HostA = hostA HostB = hostB HostBad = hostBad NoChannel = noChannel NoCommunity = noCommunity + OpenCommunities = {commA} SanitizedErrors = {"auth-required", "restricted", "invalid", "duplicate", "pow", "rate-limited", "blocked", "error", "frame-too-large"} INVARIANT Safety CONSTRAINT BoundedObservations +CONSTRAINT BoundedWitnesses SYMMETRY Symmetry diff --git a/docs/spec/MultiTenantRelay.tla b/docs/spec/MultiTenantRelay.tla index 5c30abde0..d45e54e82 100644 --- a/docs/spec/MultiTenantRelay.tla +++ b/docs/spec/MultiTenantRelay.tla @@ -44,7 +44,7 @@ (* M1: ReadScoped uses UnscopedAccessible(actor) instead of *) (* ScopedAccessible(community, actor) -> Inv_NonInterference breaks. *) (* M2: WriteInsert/AuthCheck use claimedCommunity/h tag instead of *) -(* ChannelCommunity[channel] -> resolution-fence invariants break. *) +(* ChannelCommunity(channel) -> resolution-fence invariants break. *) (* M3: WriteDuplicate conflict on id only (GlobalConflictRows + guard *) (* conflicts # {}), not (community,id) -> cross-community suppression *) (* labels a B write result with A and Inv_NonInterference breaks. *) @@ -58,7 +58,7 @@ (* M7: Direct ids lookup ignores ctx and resolves scope from the row/global *) (* id index -> a B-scoped observation can carry an A-labeled row. *) (* M8: WriteInsert/AuthCheck/WriteDuplicate drop the host/channel agreement *) -(* fence (accept/report when HostCommunity[host] # ChannelCommunity[ch]) ->*) +(* fence (accept/report when HostCommunity[host] # ChannelCommunity(ch)) ->*) (* an A-host op (insert, authz, OR duplicate-probe) on a B-channel is *) (* accepted/allowed/reported -> Inv_HostBindingFence breaks (an accepted *) (* write or recorded duplicate carries a host whose mapping # its stamp). *) @@ -73,6 +73,22 @@ (* WriteInsert -> AdmitMember(commA, alice) -> AddMembership(commB)) and *) (* 4-state channel-less read trace (Init -> WriteInsert -> *) (* AdmitMember(commB, alice) -> ReadMessageRows(commA, NoChannel, hostA)). *) +(* M10: open-community AUTH admission stamps a default/claimed community *) +(* instead of HostCommunity(host) -> Inv_AdmissionFence catches an *) +(* authRegistration whose host maps elsewhere. Confirmed red: 2-state *) +(* trace (Init -> AuthenticateOpenCommunity(hostB stamps commA)). *) +(* M11: channel creation stamps a default/claimed community instead of *) +(* HostCommunity(host) -> Inv_HostBindingFence / *) +(* Inv_ChannelCommunityImmutable catches the fresh channel's bad owner. *) +(* Confirmed red: 2-state trace (Init -> CreateChannel(hostB stamps commA)).*) +(* M12: no-#h host feed admission is relay-global (GloballyAdmitted(actor)) *) +(* instead of by host community -> Inv_AdmissionFence catches a feed *) +(* read in A by an actor admitted only in B. Confirmed red: 3-state *) +(* trace (Init -> AdmitMember(commB, alice) -> ReadHostFeedRows(hostA)). *) +(* M13: no-#h #e-only aux admission is relay-global (GloballyAdmitted(actor)) *) +(* instead of by host community -> Inv_AdmissionFence catches an aux *) +(* read in A by an actor admitted only in B. Confirmed red: 3-state *) +(* trace (Init -> AdmitMember(commB, alice) -> ReadHostAuxRows(hostA)). *) (***************************************************************************) EXTENDS FiniteSets, Naturals, TLC @@ -90,11 +106,13 @@ CONSTANTS ChanA2, \* model value: community-A channel in TLC config ChanB1, \* model value: community-B channel in TLC config ChanB2, \* model value: community-B channel in TLC config + ChanFresh, \* model value: initially-unregistered channel in TLC config HostA, \* model value: host bound to community A HostB, \* model value: host bound to community B HostBad, \* model value: unmapped host (resolves to NoCommunity) NoChannel, \* model value: sentinel channel for channel-less events NoCommunity, \* model value: sentinel for an unmapped host (fail-closed) + OpenCommunities, \* communities with no NIP-43 member pubkey allowlist SanitizedErrors \* fixed WebSocket-reachable sanitized error alphabet ObsKinds == {"ResultRows", "WriteResult", "SanitizedError", "AuditHead", "AuthVerdict"} @@ -104,7 +122,10 @@ AuthVerdicts == {"Allow", "Deny", "None"} NoError == "NoError" NoAudit == "NoAudit" -ChannelCommunity == [ch \in Channels |-> IF ch \in {ChanA1, ChanA2} THEN CommA ELSE CommB] +InitialChannelOwners == [ch \in Channels |-> + CASE ch \in {ChanA1, ChanA2} -> CommA + [] ch \in {ChanB1, ChanB2} -> CommB + [] OTHER -> NoCommunity] \* resolve_host (P-RESOLVE-HOST): the connection's host is authoritative for the \* community, exactly as a relay URL is authoritative for the relay today, lifted @@ -117,14 +138,6 @@ HostCommunity == [h \in Hosts |-> [] h = HostB -> CommB [] OTHER -> NoCommunity] -\* ResolveTenant: the single resolver generalizing P-RESOLVE / L1 over both -\* channel-bearing and channel-less events. For a channel-bearing op the -\* community is the server-owned channel mapping (and an h-cross-check requires it -\* to agree with the host community, see WriteInsert). For a channel-less op -\* (channel = NoChannel) the community is the host-derived community. -ResolveTenant(host, ch) == - IF ch = NoChannel THEN HostCommunity[host] ELSE ChannelCommunity[ch] - \* Channel-or-sentinel: data rows and write/observation records may be channel- \* less (channel = NoChannel), in which case the community comes from the host. ChannelsExt == Channels \cup {NoChannel} @@ -148,17 +161,38 @@ VARIABLES memberships, \* tenant-scoped active channel membership rows admittedMembers, \* NIP-43 community member-npub allowlist rows channelLessReads, \* successful channel-less read admissions (current capabilities) + authRegistrations, \* open-community AUTH auto-registration witnesses + feedReads, \* no-#h kinds-only feed read witnesses + auxReads, \* no-#h #e-only aux read witnesses openChannels, \* set of open/public channel ids auditHeads, \* function: community -> current audit head observations, \* typed outputs visible to tenant-scoped clients acceptedWrites, \* write requests that inserted a new message row duplicateWrites, \* write requests that no-op'd on scoped conflict + createdChannels, \* channel-create witnesses stamped from HostCommunity[host] queryFaults \* fail-closed query-layer faults (e.g. no TenantContext) -vars == <> +ChannelCommunity(ch) == + IF InitialChannelOwners[ch] \in Communities THEN InitialChannelOwners[ch] + ELSE IF \E created \in createdChannels : created.channel = ch + THEN (CHOOSE created \in createdChannels : created.channel = ch).community + ELSE NoCommunity + +ChannelRegistered(ch) == ChannelCommunity(ch) \in Communities + +\* ResolveTenant: the single resolver generalizing P-RESOLVE / L1 over both +\* channel-bearing and channel-less events. For a channel-bearing op the +\* community is the server-owned channel mapping (and an h-cross-check requires it +\* to agree with the host community, see WriteInsert). For a channel-less op +\* (channel = NoChannel) the community is the host-derived community. +ResolveTenant(host, ch) == + IF ch = NoChannel THEN HostCommunity[host] ELSE ChannelCommunity(ch) + DataRows == [ id : MsgIds, community : Communities, @@ -188,6 +222,36 @@ ChannelLessReadRows == [ actor : Actors ] +AuthRegistrationRows == [ + worker : Workers, + community : Communities, + host : Hosts, + actor : Actors +] + +FeedReadRows == [ + worker : Workers, + community : Communities, + host : Hosts, + actor : Actors +] + +AuxReadRows == [ + worker : Workers, + community : Communities, + host : Hosts, + actor : Actors, + id : MsgIds +] + +CreatedChannelRows == [ + worker : Workers, + community : Communities, + channel : Channels, + host : Hosts, + actor : Actors +] + AcceptedWriteRows == [ worker : Workers, id : MsgIds, @@ -261,12 +325,15 @@ TypeOK == /\ Workers # {} /\ MsgIds # {} /\ AuditVals # {} - /\ ChannelCommunity \in [Channels -> Communities] + /\ InitialChannelOwners \in [Channels -> Communities \cup {NoCommunity}] /\ HostCommunity \in [Hosts -> Communities \cup {NoCommunity}] + /\ OpenCommunities \subseteq Communities /\ CommA \in Communities /\ CommB \in Communities /\ CommA # CommB - /\ {ChanA1, ChanA2, ChanB1, ChanB2} \subseteq Channels + /\ {ChanA1, ChanA2, ChanB1, ChanB2, ChanFresh} \subseteq Channels + /\ Cardinality({ChanA1, ChanA2, ChanB1, ChanB2, ChanFresh}) = 5 + /\ InitialChannelOwners[ChanFresh] = NoCommunity /\ ChanA1 # ChanB1 /\ {HostA, HostB, HostBad} \subseteq Hosts /\ NoChannel \notin Channels @@ -281,25 +348,33 @@ TypeOK == /\ memberships \subseteq MembershipRows /\ admittedMembers \subseteq AdmissionRows /\ channelLessReads \subseteq ChannelLessReadRows + /\ authRegistrations \subseteq AuthRegistrationRows + /\ feedReads \subseteq FeedReadRows + /\ auxReads \subseteq AuxReadRows /\ openChannels \subseteq Channels + /\ openChannels \subseteq {ch \in Channels : ChannelRegistered(ch)} /\ auditHeads \in [Communities -> AuditVals] /\ observations \subseteq Observations /\ acceptedWrites \subseteq AcceptedWriteRows /\ duplicateWrites \subseteq DuplicateWriteRows + /\ createdChannels \subseteq CreatedChannelRows /\ queryFaults \subseteq QueryFaultRows \* Tenant-scoped control plane: a membership row's community agrees with \* the server-owned channel -> community mapping. Memberships are always \* channel-scoped (no channel-less memberships). - /\ \A m \in memberships : m.community = ChannelCommunity[m.channel] + /\ \A m \in memberships : /\ ChannelRegistered(m.channel) + /\ m.community = ChannelCommunity(m.channel) \* Message/projection rows are stamped with the resolved community: the \* server-owned channel mapping for channel-bearing rows, and a real \* (never-sentinel) community for channel-less rows (host-resolved at write). /\ \A m \in messages : IF m.channel = NoChannel THEN m.community \in Communities - ELSE m.community = ChannelCommunity[m.channel] + ELSE /\ ChannelRegistered(m.channel) + /\ m.community = ChannelCommunity(m.channel) /\ \A p \in projections : IF p.channel = NoChannel THEN p.community \in Communities - ELSE p.community = ChannelCommunity[p.channel] + ELSE /\ ChannelRegistered(p.channel) + /\ p.community = ChannelCommunity(p.channel) Init == /\ messages = {} @@ -307,11 +382,15 @@ Init == /\ memberships = {} /\ admittedMembers = {} /\ channelLessReads = {} + /\ authRegistrations = {} + /\ feedReads = {} + /\ auxReads = {} /\ openChannels = {} /\ auditHeads \in [Communities -> AuditVals] /\ observations = {} /\ acceptedWrites = {} /\ duplicateWrites = {} + /\ createdChannels = {} /\ queryFaults = {} IsAdmitted(community, actor) == @@ -323,9 +402,22 @@ IsAdmitted(community, actor) == GloballyAdmitted(actor) == \E c \in Communities : [community |-> c, actor |-> actor] \in admittedMembers +\* Open-community AUTH mutation M10 helper: stamp every open AUTH registration +\* into CommA, ignoring the authoritative host. To reproduce M10, substitute +\* DefaultOpenAuthCommunity(host) for HostCommunity[host] when building row/reg +\* in AuthenticateOpenCommunity; HostB then records a commA registration and +\* Inv_AdmissionFence fails in a 2-state trace. +DefaultOpenAuthCommunity(host) == CommA + +\* Channel-creation mutation M11 helper: stamp every fresh channel into CommA, +\* ignoring the authoritative host. To reproduce M11, substitute +\* DefaultCreatedChannelCommunity(host) for HostCommunity[host] in CreateChannel; +\* HostB then creates a commA-owned channel and the host/immutability fences fail. +DefaultCreatedChannelCommunity(host) == CommA + ScopedAccessible(community, actor) == {ch \in Channels : - /\ ChannelCommunity[ch] = community + /\ ChannelCommunity(ch) = community /\ (ch \in openChannels \/ [community |-> community, channel |-> ch, actor |-> actor] \in memberships)} @@ -364,6 +456,40 @@ VisibleDirectIdRows(community, actor, id, targetChannel) == /\ IsAdmitted(community, actor) ELSE m.channel \in ScopedAccessible(community, actor)} +VisibleHostFeedRows(community, actor) == + {m \in messages : + /\ m.community = community + /\ IsAdmitted(community, actor) + /\ (m.channel = NoChannel \/ m.channel \in ScopedAccessible(community, actor))} + +\* Intentionally-bad host feed helper for mutation M12: answers from the host +\* community but treats admission as relay-global. Substitute this into +\* ReadHostFeedRows; after AdmitMember(commB, actor), a HostA feed read is recorded +\* for commA and Inv_AdmissionFence fails. +VisibleHostFeedRows_GlobalAdmission(community, actor) == + {m \in messages : + /\ m.community = community + /\ GloballyAdmitted(actor) + /\ (m.channel = NoChannel \/ m.channel \in ScopedAccessible(community, actor))} + +VisibleHostAuxRows(community, actor, id) == + {m \in messages : + /\ m.id = id + /\ m.community = community + /\ IsAdmitted(community, actor) + /\ (m.channel = NoChannel \/ m.channel \in ScopedAccessible(community, actor))} + +\* Intentionally-bad host aux helper for mutation M13: answers from the host +\* community but treats admission as relay-global. Substitute this into +\* ReadHostAuxRows; after AdmitMember(commB, actor), a HostA aux read is recorded +\* for commA and Inv_AdmissionFence fails. +VisibleHostAuxRows_GlobalAdmission(community, actor, id) == + {m \in messages : + /\ m.id = id + /\ m.community = community + /\ GloballyAdmitted(actor) + /\ (m.channel = NoChannel \/ m.channel \in ScopedAccessible(community, actor))} + \* Intentionally-bad direct lookup mutation: answer by global id first and trust \* the row's own community as scope. Substitute this into ReadByIdRows for M7. UnscopedDirectIdRows(actor, id) == @@ -374,20 +500,20 @@ UnscopedDirectIdRows(actor, id) == RLSRows(community, rows) == {r \in rows : r.community = community} \* Channel-bearing write. The community is resolved server-side from the h tag -\* (real == ChannelCommunity[ch]), never the claimed community. But the host is +\* (real == ChannelCommunity(ch)), never the claimed community. But the host is \* *also* authoritative: an A-host connection presenting a B-channel event is a \* confused deputy on the host axis (URL-is-community is the admission boundary), -\* so the op fails closed unless HostCommunity[host] = ChannelCommunity[ch]. +\* so the op fails closed unless HostCommunity[host] = ChannelCommunity(ch). \* Disagreement (incl. an unmapped HostBad whose mapping is NoCommunity, which can \* never equal a real channel community) writes nothing, emits a sanitized error, \* and records a query fault. Never acts as the channel's community. WriteInsert(w) == /\ Cardinality(observations) < MaxObservations /\ \E id \in MsgIds, ch \in Channels, host \in Hosts, a \in Actors, claimed \in Communities : - LET real == ChannelCommunity[ch] + LET real == ChannelCommunity(ch) IN - IF HostCommunity[host] # real - THEN \* fail-closed: host/channel disagreement (A-host + B-channel). + IF ~(real \in Communities) \/ HostCommunity[host] # real + THEN \* fail-closed: unknown channel or host/channel disagreement (A-host + B-channel). LET obs == [worker |-> w, actor |-> a, community |-> claimed, channel |-> ch, kind |-> "SanitizedError", labels |-> {}, rows |-> {}, error |-> "restricted", @@ -397,8 +523,10 @@ WriteInsert(w) == IN /\ observations' = observations \cup {obs} /\ queryFaults' = queryFaults \cup {fault} - /\ UNCHANGED <> + /\ UNCHANGED <> ELSE \* host agrees with the channel mapping: accept, stamp the host. LET key == [community |-> real, id |-> id] row == MessageRow(id, real, ch, a) @@ -413,7 +541,8 @@ WriteInsert(w) == /\ observations' = observations \cup {obs} /\ acceptedWrites' = acceptedWrites \cup {wr} /\ UNCHANGED <> + authRegistrations, feedReads, auxReads, openChannels, auditHeads, + duplicateWrites, createdChannels, queryFaults>> \* Channel-less write (kind:0 profiles, 1059 DMs, 30023/30174/30315/30078, lists). \* There is no h tag to resolve, so the community is derived from the connection's @@ -439,8 +568,10 @@ WriteInsertGlobal(w) == IN /\ observations' = observations \cup {obs} /\ queryFaults' = queryFaults \cup {fault} - /\ UNCHANGED <> + /\ UNCHANGED <> ELSE \* mapped host: stamp the host-resolved community, channel-less. LET key == [community |-> resolved, id |-> id] row == [id |-> id, community |-> resolved, channel |-> NoChannel, @@ -457,23 +588,24 @@ WriteInsertGlobal(w) == /\ observations' = observations \cup {obs} /\ acceptedWrites' = acceptedWrites \cup {wr} /\ UNCHANGED <> + authRegistrations, feedReads, auxReads, openChannels, auditHeads, + duplicateWrites, createdChannels, queryFaults>> \* Duplicate / no-op write outcome (scoped conflict on (community_id, id)). This \* is client-observable write surface -- the "Duplicate" WriteResult exposes the \* scoped existence/conflict rows -- so it carries the SAME host-axis obligation as \* WriteInsert. The community is resolved server-side from the h tag (real == -\* ChannelCommunity[ch]); the host is *also* authoritative. An A-host presenting a +\* ChannelCommunity(ch)); the host is *also* authoritative. An A-host presenting a \* B-channel id is a confused deputy on the host axis: it must NOT learn whether \* that id already exists in B. So the op fails closed (sanitized error + query \* fault, no Duplicate result, nothing recorded) unless HostCommunity[host] = real. WriteDuplicate(w) == /\ Cardinality(observations) < MaxObservations /\ \E id \in MsgIds, ch \in Channels, host \in Hosts, a \in Actors, claimed \in Communities : - LET real == ChannelCommunity[ch] + LET real == ChannelCommunity(ch) IN - IF HostCommunity[host] # real - THEN \* fail-closed: host/channel disagreement (A-host + B-channel). + IF ~(real \in Communities) \/ HostCommunity[host] # real + THEN \* fail-closed: unknown channel or host/channel disagreement (A-host + B-channel). LET obs == [worker |-> w, actor |-> a, community |-> claimed, channel |-> ch, kind |-> "SanitizedError", labels |-> {}, rows |-> {}, error |-> "restricted", @@ -483,8 +615,10 @@ WriteDuplicate(w) == IN /\ observations' = observations \cup {obs} /\ queryFaults' = queryFaults \cup {fault} - /\ UNCHANGED <> + /\ UNCHANGED <> ELSE \* host agrees with the channel mapping: report the scoped duplicate. LET key == [community |-> real, id |-> id] conflicts == ScopedConflictRows(real, id) @@ -499,7 +633,8 @@ WriteDuplicate(w) == /\ observations' = observations \cup {obs} /\ duplicateWrites' = duplicateWrites \cup {wr} /\ UNCHANGED <> + authRegistrations, feedReads, auxReads, openChannels, auditHeads, + acceptedWrites, createdChannels, queryFaults>> ReadMessageRows(w) == /\ Cardinality(observations) < MaxObservations @@ -517,8 +652,8 @@ ReadMessageRows(w) == ELSE channelLessReads' = channelLessReads /\ observations' = observations \cup {obs} /\ UNCHANGED <> + authRegistrations, feedReads, auxReads, openChannels, auditHeads, + acceptedWrites, duplicateWrites, createdChannels, queryFaults>> ReadProjectionRows(w) == /\ Cardinality(observations) < MaxObservations @@ -536,8 +671,8 @@ ReadProjectionRows(w) == ELSE channelLessReads' = channelLessReads /\ observations' = observations \cup {obs} /\ UNCHANGED <> + authRegistrations, feedReads, auxReads, openChannels, auditHeads, + acceptedWrites, duplicateWrites, createdChannels, queryFaults>> ReadByIdRows(w) == /\ Cardinality(observations) < MaxObservations @@ -555,8 +690,54 @@ ReadByIdRows(w) == ELSE channelLessReads' = channelLessReads /\ observations' = observations \cup {obs} /\ UNCHANGED <> + authRegistrations, feedReads, auxReads, openChannels, auditHeads, + acceptedWrites, duplicateWrites, createdChannels, queryFaults>> + +\* No-#h kinds-only feed/global read. The client sends no community metadata; the +\* relay derives the community from the connection host and fans out across only +\* channel-less rows plus accessible channels inside that same host community. +ReadHostFeedRows(w) == + /\ Cardinality(observations) < MaxObservations + /\ \E host \in Hosts, a \in Actors : + LET c == HostCommunity[host] + IN + /\ c \in Communities + /\ IsAdmitted(c, a) + /\ [worker |-> w, community |-> c, host |-> host, actor |-> a] \notin feedReads + /\ LET rows == VisibleHostFeedRows(c, a) + obs == [worker |-> w, actor |-> a, community |-> c, channel |-> NoChannel, + kind |-> "ResultRows", labels |-> RowLabels(rows), rows |-> rows, + error |-> NoError, result |-> "None", verdict |-> "None", audit |-> NoAudit] + read == [worker |-> w, community |-> c, host |-> host, actor |-> a] + IN + /\ observations' = observations \cup {obs} + /\ feedReads' = feedReads \cup {read} + /\ UNCHANGED <> + +\* No-#h #e-only aux lookup (reactions/edits/deletes/thread metadata shape). It +\* resolves by id only after applying the host-community and accessible-channel +\* fences, so same event id in another community is invisible. +ReadHostAuxRows(w) == + /\ Cardinality(observations) < MaxObservations + /\ \E host \in Hosts, a \in Actors, id \in MsgIds : + LET c == HostCommunity[host] + IN + /\ c \in Communities + /\ IsAdmitted(c, a) + /\ [worker |-> w, community |-> c, host |-> host, actor |-> a, id |-> id] \notin auxReads + /\ LET rows == VisibleHostAuxRows(c, a, id) + obs == [worker |-> w, actor |-> a, community |-> c, channel |-> NoChannel, + kind |-> "ResultRows", labels |-> RowLabels(rows), rows |-> rows, + error |-> NoError, result |-> "None", verdict |-> "None", audit |-> NoAudit] + read == [worker |-> w, community |-> c, host |-> host, actor |-> a, id |-> id] + IN + /\ observations' = observations \cup {obs} + /\ auxReads' = auxReads \cup {read} + /\ UNCHANGED <> \* Explicit community predicate was accidentally omitted, but the transaction is \* inside TenantContext and Postgres RLS applies the community fence. @@ -571,7 +752,8 @@ ReadForgotPredicateWithRLS(w) == IN /\ observations' = observations \cup {obs} /\ UNCHANGED <> + authRegistrations, feedReads, auxReads, openChannels, auditHeads, + acceptedWrites, duplicateWrites, createdChannels, queryFaults>> \* If the query does not establish TenantContext at all, RLS must fail closed. ReadNoTenantContext(w) == @@ -586,7 +768,8 @@ ReadNoTenantContext(w) == /\ observations' = observations \cup {obs} /\ queryFaults' = queryFaults \cup {fault} /\ UNCHANGED <> + authRegistrations, feedReads, auxReads, openChannels, auditHeads, + acceptedWrites, duplicateWrites, createdChannels>> SanitizedError(w) == /\ Cardinality(observations) < MaxObservations @@ -597,7 +780,8 @@ SanitizedError(w) == IN /\ observations' = observations \cup {obs} /\ UNCHANGED <> + authRegistrations, feedReads, auxReads, openChannels, auditHeads, + acceptedWrites, duplicateWrites, createdChannels, queryFaults>> \* Channel-bearing authorization. Community resolves from the channel mapping; \* the host is also authoritative. Host/channel disagreement (A-host + B-channel) @@ -606,23 +790,27 @@ SanitizedError(w) == AuthCheck(w) == /\ Cardinality(observations) < MaxObservations /\ \E ch \in Channels, host \in Hosts, a \in Actors, claimed \in Communities : - LET real == ChannelCommunity[ch] - hostAgrees == HostCommunity[host] = real + LET real == ChannelCommunity(ch) + hostAgrees == real \in Communities /\ HostCommunity[host] = real allowed == hostAgrees /\ ch \in ScopedAccessible(real, a) verdict == IF allowed THEN "Allow" ELSE "Deny" obs == [worker |-> w, actor |-> a, community |-> real, channel |-> ch, kind |-> "AuthVerdict", labels |-> {real}, rows |-> {}, error |-> NoError, result |-> "None", verdict |-> verdict, audit |-> NoAudit] IN + /\ real \in Communities /\ observations' = observations \cup {obs} /\ UNCHANGED <> + authRegistrations, feedReads, auxReads, openChannels, auditHeads, + acceptedWrites, duplicateWrites, createdChannels, queryFaults>> AppendAudit(w) == \E c \in Communities, newHead \in AuditVals : + /\ newHead # auditHeads[c] /\ auditHeads' = [auditHeads EXCEPT ![c] = newHead] /\ UNCHANGED <> + authRegistrations, feedReads, auxReads, openChannels, observations, + acceptedWrites, duplicateWrites, createdChannels, queryFaults>> ObserveAuditHead(w) == /\ Cardinality(observations) < MaxObservations @@ -633,7 +821,8 @@ ObserveAuditHead(w) == IN /\ observations' = observations \cup {obs} /\ UNCHANGED <> + authRegistrations, feedReads, auxReads, openChannels, auditHeads, + acceptedWrites, duplicateWrites, createdChannels, queryFaults>> \* Projection rebuild is privileged internal work. It may touch all communities \* and may leave projections temporarily partial, but it emits no observation. @@ -641,7 +830,41 @@ RebuildProjections(w) == \E rebuilt \in SUBSET DerivedProjectionRows : /\ projections' = rebuilt /\ UNCHANGED <> + authRegistrations, feedReads, auxReads, openChannels, auditHeads, observations, + acceptedWrites, duplicateWrites, createdChannels, queryFaults>> + +\* AUTH to an allowlist-less/open community auto-registers the authenticated npub +\* in that host-derived community. Later reads/writes still check IsAdmitted; the +\* row exists because AUTH inserted it, not because the read path bypassed admission. +AuthenticateOpenCommunity(w) == + \E host \in Hosts, a \in Actors : + LET c == HostCommunity[host] + row == [community |-> c, actor |-> a] + reg == [worker |-> w, community |-> c, host |-> host, actor |-> a] + IN + /\ c \in OpenCommunities + /\ row \notin admittedMembers + /\ admittedMembers' = admittedMembers \cup {row} + /\ authRegistrations' = authRegistrations \cup {reg} + /\ UNCHANGED <> + +\* Channel creation is host-bound: a fresh channel has no community until the +\* relay accepts kind:9007 on a mapped host, then stamps HostCommunity[host] +\* atomically. The client sends no community id/tag. +CreateChannel(w) == + \E ch \in Channels, host \in Hosts, a \in Actors : + LET c == HostCommunity[host] + created == [worker |-> w, community |-> c, channel |-> ch, host |-> host, actor |-> a] + IN + /\ c \in Communities + /\ ChannelCommunity(ch) = NoCommunity + /\ ~\E old \in createdChannels : old.channel = ch + /\ createdChannels' = createdChannels \cup {created} + /\ UNCHANGED <> AdmitMember(w) == \E c \in Communities, a \in Actors : @@ -649,8 +872,8 @@ AdmitMember(w) == IN /\ admittedMembers' = admittedMembers \cup {row} /\ UNCHANGED <> + authRegistrations, feedReads, auxReads, openChannels, auditHeads, + observations, acceptedWrites, duplicateWrites, createdChannels, queryFaults>> RevokeMember(w) == \E c \in Communities, a \in Actors : @@ -659,41 +882,49 @@ RevokeMember(w) == /\ admittedMembers' = admittedMembers \ {row} /\ memberships' = {m \in memberships : ~(m.community = c /\ m.actor = a)} /\ channelLessReads' = {r \in channelLessReads : ~(r.community = c /\ r.actor = a)} - /\ UNCHANGED <> + /\ feedReads' = {r \in feedReads : ~(r.community = c /\ r.actor = a)} + /\ auxReads' = {r \in auxReads : ~(r.community = c /\ r.actor = a)} + /\ UNCHANGED <> AddMembership(w) == \E ch \in Channels, a \in Actors : - LET c == ChannelCommunity[ch] + LET c == ChannelCommunity(ch) row == [community |-> c, channel |-> ch, actor |-> a] IN + /\ c \in Communities /\ IsAdmitted(c, a) /\ memberships' = memberships \cup {row} /\ UNCHANGED <> + authRegistrations, feedReads, auxReads, openChannels, auditHeads, + observations, acceptedWrites, duplicateWrites, createdChannels, queryFaults>> RemoveMembership(w) == \E ch \in Channels, a \in Actors : - LET c == ChannelCommunity[ch] + LET c == ChannelCommunity(ch) row == [community |-> c, channel |-> ch, actor |-> a] IN + /\ c \in Communities /\ memberships' = memberships \ {row} /\ UNCHANGED <> + authRegistrations, feedReads, auxReads, openChannels, auditHeads, + observations, acceptedWrites, duplicateWrites, createdChannels, queryFaults>> OpenChannel(w) == \E ch \in Channels : + /\ ChannelRegistered(ch) /\ openChannels' = openChannels \cup {ch} /\ UNCHANGED <> + authRegistrations, feedReads, auxReads, auditHeads, observations, + acceptedWrites, duplicateWrites, createdChannels, queryFaults>> CloseChannel(w) == \E ch \in Channels : /\ openChannels' = openChannels \ {ch} /\ UNCHANGED <> + authRegistrations, feedReads, auxReads, auditHeads, observations, + acceptedWrites, duplicateWrites, createdChannels, queryFaults>> Next == \E w \in Workers : @@ -703,6 +934,8 @@ Next == \/ ReadMessageRows(w) \/ ReadProjectionRows(w) \/ ReadByIdRows(w) + \/ ReadHostFeedRows(w) + \/ ReadHostAuxRows(w) \/ ReadForgotPredicateWithRLS(w) \/ ReadNoTenantContext(w) \/ SanitizedError(w) @@ -710,6 +943,8 @@ Next == \/ AppendAudit(w) \/ ObserveAuditHead(w) \/ RebuildProjections(w) + \/ AuthenticateOpenCommunity(w) + \/ CreateChannel(w) \/ AdmitMember(w) \/ RevokeMember(w) \/ AddMembership(w) @@ -719,6 +954,21 @@ Next == BoundedObservations == Cardinality(observations) <= MaxObservations +BoundedWitnesses == + /\ Cardinality(messages) <= 1 + /\ Cardinality(projections) <= 1 + /\ Cardinality(memberships) <= 1 + /\ Cardinality(admittedMembers) <= 2 + /\ Cardinality(channelLessReads) <= 1 + /\ Cardinality(authRegistrations) <= 1 + /\ Cardinality(feedReads) <= 1 + /\ Cardinality(auxReads) <= 1 + /\ Cardinality(openChannels) <= 1 + /\ Cardinality(acceptedWrites) <= 1 + /\ Cardinality(duplicateWrites) <= 1 + /\ Cardinality(createdChannels) <= 1 + /\ Cardinality(queryFaults) <= 1 + Spec == Init /\ [][Next]_vars ------------------------------------------------------------------------------ @@ -738,7 +988,7 @@ Inv_LabelPropagation == /\ (o.kind \in {"ResultRows", "WriteResult"} => o.labels = RowLabels(o.rows)) /\ (o.kind = "SanitizedError" => o.labels = {} /\ o.error \in SanitizedErrors) /\ (o.kind = "AuditHead" => o.labels = {o.community} /\ o.audit \in AuditVals) - /\ (o.kind = "AuthVerdict" => o.labels = {ChannelCommunity[o.channel]} /\ o.community = ChannelCommunity[o.channel]) + /\ (o.kind = "AuthVerdict" => o.labels = {ChannelCommunity(o.channel)} /\ o.community = ChannelCommunity(o.channel)) \* I1 read confinement follows from NI + label propagation, but is kept as a \* legible mutation target for the current get_accessible_channel_ids landmine. @@ -757,17 +1007,18 @@ Inv_ReadConfinement == Inv_ResolutionFence == /\ \A m \in messages : IF m.channel = NoChannel THEN m.community \in Communities - ELSE m.community = ChannelCommunity[m.channel] + ELSE /\ ChannelRegistered(m.channel) + /\ m.community = ChannelCommunity(m.channel) /\ \A w \in acceptedWrites : IF w.channel = NoChannel THEN w.community \in Communities - ELSE w.community = ChannelCommunity[w.channel] + ELSE w.community = ChannelCommunity(w.channel) /\ \A w \in duplicateWrites : IF w.channel = NoChannel THEN w.community \in Communities - ELSE w.community = ChannelCommunity[w.channel] + ELSE w.community = ChannelCommunity(w.channel) /\ \A o \in observations : o.kind \in {"WriteResult", "AuthVerdict"} => (IF o.channel = NoChannel THEN o.community \in Communities - ELSE o.community = ChannelCommunity[o.channel]) + ELSE o.community = ChannelCommunity(o.channel)) \* I2-host fail-closed binding: EVERY accepted write -- channel-bearing or \* channel-less -- AND every observable duplicate/no-op outcome carries the host it @@ -775,7 +1026,7 @@ Inv_ResolutionFence == \* stored community (P-RESOLVE-HOST). For channel-less writes this is the \* host->community derivation; for channel-bearing writes and duplicate/no-op \* outcomes it is the host/channel agreement fence (HostCommunity[host] = -\* ChannelCommunity[ch]). This makes BOTH "an unmapped host defaults to a tenant" +\* ChannelCommunity(ch)). This makes BOTH "an unmapped host defaults to a tenant" \* and "an A-host can drive a B-channel op (insert OR duplicate-probe)" CAUGHT \* mutations: the fail-closed branches of WriteInsert/WriteInsertGlobal/WriteDuplicate \* write/record nothing on disagreement, so no accepted write and no recorded @@ -789,6 +1040,22 @@ Inv_HostBindingFence == /\ w.host \in Hosts /\ HostCommunity[w.host] \in Communities /\ w.community = HostCommunity[w.host] + /\ \A ch \in createdChannels : + /\ ch.host \in Hosts + /\ HostCommunity[ch.host] \in Communities + /\ ch.community = HostCommunity[ch.host] + /\ ChannelCommunity(ch.channel) = ch.community + +\* Channel-community immutability: a runtime-created channel may be stamped at +\* most once, and all durable ownership rows for a channel agree on the same +\* host-resolved community. This makes the old constant-function immutability +\* guarantee explicit now that fresh channel creation is modeled as state. +Inv_ChannelCommunityImmutable == + /\ \A c1, c2 \in createdChannels : + c1.channel = c2.channel => c1.community = c2.community + /\ \A created \in createdChannels : + /\ InitialChannelOwners[created.channel] = NoCommunity + /\ created.community = HostCommunity[created.host] \* I2-admission fence: NIP-43 member-npub admission is scoped by community, not \* relay-global pubkey. Active channel memberships and current channel-less read @@ -802,6 +1069,32 @@ Inv_AdmissionFence == /\ \A r \in channelLessReads : /\ IsAdmitted(r.community, r.actor) /\ HostCommunity[r.host] = r.community + /\ \A r \in authRegistrations : + /\ r.community \in OpenCommunities + /\ HostCommunity[r.host] = r.community + /\ \A r \in feedReads : + /\ IsAdmitted(r.community, r.actor) + /\ HostCommunity[r.host] = r.community + /\ \A r \in auxReads : + /\ IsAdmitted(r.community, r.actor) + /\ HostCommunity[r.host] = r.community + +\* TLA-side anti-vacuity / reachability witnesses for surfaces that could +\* otherwise pass only because their actions never fire. These are intentionally +\* false invariants: checking one ad hoc with INVARIANT (plus the normal +\* constraints) must produce a short counterexample trace. If it stays green, the +\* corresponding action is unreachable and the safety proof is vacuous there. +Probe_OpenAuthRegistration_Unreachable == + authRegistrations = {} + +Probe_CreatedChannel_Unreachable == + createdChannels = {} + +Probe_HostFeedRead_Unreachable == + feedReads = {} + +Probe_HostAuxRead_Unreachable == + auxReads = {} \* I3a append persistence: every accepted append remains present in the shared log. Inv_AcceptedWritesPersist == @@ -835,6 +1128,7 @@ Safety == /\ Inv_ReadConfinement /\ Inv_ResolutionFence /\ Inv_HostBindingFence + /\ Inv_ChannelCommunityImmutable /\ Inv_AdmissionFence /\ Inv_AcceptedWritesPersist /\ Inv_MessageKeyUnique From 2d6563dfb53c6650a6ab650a26a989c3ca1a4a52 Mon Sep 17 00:00:00 2001 From: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co> Date: Fri, 26 Jun 2026 10:15:11 -0400 Subject: [PATCH 09/11] docs(multi-tenant): fold open-auth/channel-create/feed-aux surfaces into spec prose Folds the model's host-scoped open-auth deltas (commit on this branch) into docs/multi-tenant-relay.md so the prose no longer lags what the model proves: - TLC stats refreshed to the current cfg: 472,530,528 generated / 16,226,016 distinct / depth 13 (was 265.1M / 9.23M / depth 17), with the state-growth narrative extended through the open-AUTH/channel-create/feed/aux surfaces. - Adds the four TLA+ reachability probes (Probe_OpenAuthRegistration_Unreachable, Probe_CreatedChannel_Unreachable, Probe_HostFeedRead_Unreachable, Probe_HostAuxRead_Unreachable) and mutations M10-M13 (open-AUTH stamp, channel-create stamp, feed global-admission, aux global-admission), each with its confirmed-red trace length. - Tamarin count 30 -> 32 lemmas; adds S8 (open-community AUTH confinement) with open_auth_registration_confined_to_host_community + its exists-trace witness. - System Model gains allowlisted-vs-open communities, server-stamped channel creation, and no-#h feed/aux reads as first-class modeled surfaces; I5 and the verification section extended to name them. S-range refs S1-S7 -> S1-S8. Prose-only: model file byte hashes (.tla/.cfg/.spthy) are untouched, preserving the .spthy-hash discipline the doc relies on. Re-verified on this tree's model files (unchanged from 22268265): TLC green 472.5M/16.23M/depth 13/0 errors; Tamarin 32/32 verified, 0 falsified. Spot-checked M12 red by hand (3-state trace). Co-authored-by: Tyler Longwell Signed-off-by: Tyler Longwell --- docs/multi-tenant-relay.md | 134 +++++++++++++++++++++++++++++++------ 1 file changed, 112 insertions(+), 22 deletions(-) diff --git a/docs/multi-tenant-relay.md b/docs/multi-tenant-relay.md index e99e8fa14..8b7f5a651 100644 --- a/docs/multi-tenant-relay.md +++ b/docs/multi-tenant-relay.md @@ -114,6 +114,20 @@ Two operation classes act on the store: 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 @@ -468,10 +482,23 @@ load-bearing *backstop*. 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 fence is about **current** capability: it is mutation-tested non-vacuous by - M9 (re-keying the gate to any-community admission), which goes red on both a - membership trace and a channel-less-read trace, proving an admit-into-A then - act-in-B escape is caught rather than invisible. (See C3 for the explicit + 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) @@ -530,13 +557,27 @@ load-bearing *backstop*. `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–S7 are **machine-verified green** on -Tamarin 1.12.0 / Maude 3.5.1 — the full selected run verifies all 30 lemmas in -~18s with zero `analyzed` failures. S1/S2: `token_confinement`, +**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 @@ -574,6 +615,11 @@ true over an unreachable premise. The S7 mutation `MUTATION_Admit_Ignore_Communi 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 @@ -694,23 +740,35 @@ as label-flow non-interference is, to our knowledge, new for a Nostr relay. 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.* — 265,122,788 states generated, 9,232,992 distinct, 0 left - on queue, depth 17 (16 workers, ~1m23). The distinct-state count grew from the + 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) precisely because 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, - and now the admit/revoke/gated-membership/gated-read paths + 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 (138.7M generated, - up from 136.3M) without new distinct states: only the agreeing host can produce a + 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 on top is the +91% generated / +64% distinct growth to the figures - above (admit-then-act, revoke-then-act-fails, and gated reads/joins multiply the - reachable space). Non-vacuity is - shown by seven mutations, each + 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 @@ -739,7 +797,32 @@ as label-flow non-interference is, to our knowledge, new for a Nostr relay. (`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 two host-fence + 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. (The `.tla` header's + M12/M13 helper comment points the substitution at the row-set helper + `VisibleHostFeedRows_GlobalAdmission`; the substitution that actually trips + `Inv_AdmissionFence` is the action's admission *guard*, since the invariant + quantifies over the recorded `feedReads`/`auxReads` witnesses, not the returned + row set.) 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 @@ -748,18 +831,25 @@ as label-flow non-interference is, to our knowledge, new for a Nostr relay. 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 30 lemmas (S1–S7) - verify green (Tamarin 1.12.0 / Maude 3.5.1, ~18 s) — each safety lemma paired with + `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. See §Authorization soundness for the + 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–S7 lemmas close by two distinct shapes. + **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`, From 43957ab062c927f4ab7b313a8aa3404c0d1c0998 Mon Sep 17 00:00:00 2001 From: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta Date: Fri, 26 Jun 2026 10:17:13 -0400 Subject: [PATCH 10/11] docs(multi-tenant): clarify feed aux mutation recipe Co-authored-by: Tyler Longwell Signed-off-by: Tyler Longwell --- docs/spec/MultiTenantRelay.tla | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/docs/spec/MultiTenantRelay.tla b/docs/spec/MultiTenantRelay.tla index d45e54e82..007100529 100644 --- a/docs/spec/MultiTenantRelay.tla +++ b/docs/spec/MultiTenantRelay.tla @@ -463,9 +463,11 @@ VisibleHostFeedRows(community, actor) == /\ (m.channel = NoChannel \/ m.channel \in ScopedAccessible(community, actor))} \* Intentionally-bad host feed helper for mutation M12: answers from the host -\* community but treats admission as relay-global. Substitute this into -\* ReadHostFeedRows; after AdmitMember(commB, actor), a HostA feed read is recorded -\* for commA and Inv_AdmissionFence fails. +\* community but treats admission as relay-global. To reproduce the red mutation, +\* change ReadHostFeedRows' admission guard from IsAdmitted(c, a) to +\* GloballyAdmitted(a) and optionally use this row helper; after an actor is +\* admitted only in B, a HostA feed read is recorded for commA and +\* Inv_AdmissionFence fails. VisibleHostFeedRows_GlobalAdmission(community, actor) == {m \in messages : /\ m.community = community @@ -480,9 +482,11 @@ VisibleHostAuxRows(community, actor, id) == /\ (m.channel = NoChannel \/ m.channel \in ScopedAccessible(community, actor))} \* Intentionally-bad host aux helper for mutation M13: answers from the host -\* community but treats admission as relay-global. Substitute this into -\* ReadHostAuxRows; after AdmitMember(commB, actor), a HostA aux read is recorded -\* for commA and Inv_AdmissionFence fails. +\* community but treats admission as relay-global. To reproduce the red mutation, +\* change ReadHostAuxRows' admission guard from IsAdmitted(c, a) to +\* GloballyAdmitted(a) and optionally use this row helper; after an actor is +\* admitted only in B, a HostA aux read is recorded for commA and +\* Inv_AdmissionFence fails. VisibleHostAuxRows_GlobalAdmission(community, actor, id) == {m \in messages : /\ m.id = id From b0a90eb271cd83fe49eee865583a1ccd9371b59b Mon Sep 17 00:00:00 2001 From: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co> Date: Fri, 26 Jun 2026 10:18:52 -0400 Subject: [PATCH 11/11] docs(multi-tenant): align M12/M13 recipe note with fixed helper comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Max's prior commit fixed the .tla M12/M13 helper comments to point the red substitution at the action's admission guard. This updates the prose's parenthetical to match — it now states the reproduction recipe (guard IsAdmitted(c,a) -> GloballyAdmitted(a)) as fact rather than flagging the header as wrong. Prose-only; model file hashes untouched. Co-authored-by: Tyler Longwell Signed-off-by: Tyler Longwell --- docs/multi-tenant-relay.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/multi-tenant-relay.md b/docs/multi-tenant-relay.md index 8b7f5a651..aa514735a 100644 --- a/docs/multi-tenant-relay.md +++ b/docs/multi-tenant-relay.md @@ -817,12 +817,12 @@ as label-flow non-interference is, to our knowledge, new for a Nostr relay. `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. (The `.tla` header's - M12/M13 helper comment points the substitution at the row-set helper - `VisibleHostFeedRows_GlobalAdmission`; the substitution that actually trips - `Inv_AdmissionFence` is the action's admission *guard*, since the invariant - quantifies over the recorded `feedReads`/`auxReads` witnesses, not the returned - row set.) The host-fence and new-surface + "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