From d0fd9524e62703b656eb9bcca571b077660b0caf Mon Sep 17 00:00:00 2001 From: zelig Date: Mon, 3 Aug 2026 12:42:42 +0200 Subject: [PATCH 01/14] =?UTF-8?q?add=20SWIP-60:=20BPS=20singlehop=20?= =?UTF-8?q?=E2=80=94=20brokered=20broadcast=20pub/sub,=20base=20protocol?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Base SWIP of the Broadcast Pub/Sub (BPS) family — the decomposition of the monolithic PubSub SWIP (PR #93) into work-package-sized SWIPs. Companion wire spec: assets/swip-60/bps.proto (singlehop concrete, multihop control frames reserved). Co-Authored-By: Claude Fable 5 --- SWIPs/assets/swip-60/bps.proto | 113 +++++++++++++++ SWIPs/swip-60.md | 242 +++++++++++++++++++++++++++++++++ 2 files changed, 355 insertions(+) create mode 100644 SWIPs/assets/swip-60/bps.proto create mode 100644 SWIPs/swip-60.md diff --git a/SWIPs/assets/swip-60/bps.proto b/SWIPs/assets/swip-60/bps.proto new file mode 100644 index 00000000..36381999 --- /dev/null +++ b/SWIPs/assets/swip-60/bps.proto @@ -0,0 +1,113 @@ +// Broadcast Pub/Sub (BPS) — protocol messages and types. +// Spec: SWIP-60 (../../swip-60.md). +// +// Deliberately incomplete as of 2026-08-02: the singlehop (depth = 1) subset is +// concrete; multihop control-plane messages are named but reserved. The existing +// implementation (bee PR #5435) uses hand-rolled byte framing with the same +// semantics; this file is the normative description of the message structure, +// and — bee protocols being protobuf-over-libp2p elsewhere — the candidate +// replacement framing. + +syntax = "proto3"; +package bps; + +option go_package = "github.com/ethersphere/bee/v2/pkg/bps/pb"; + +// --------------------------------------------------------------------------- +// Cohort genesis — the primitive decisions whose combinations are the "modes" +// --------------------------------------------------------------------------- + +// What the topic binds to (see epic: "What does the topic bind to?"). +enum TopicBinding { + TOPIC_BINDING_UNSPECIFIED = 0; + ANCHOR = 1; // topic = full SOC/GSOC address; dedup on the wrapped CAC + SOC_ID = 2; // topic = SOC id; any owner with PO(addr, anchor) >= po_min + OWNER = 3; // topic = SOC owner; any id with PO(addr, anchor) >= po_min (MIC) + FEED_TOPIC = 4; // id = keccak256(topic ‖ index); graffiti MIC / feed streams +} + +// Who may author (see epic: genesis dimensions). +enum PublisherRegime { + PUBLISHER_REGIME_UNSPECIFIED = 0; + EXPLICIT_SINGLE = 1; // opener is admin and sole publisher (live streaming) + EXPLICIT_LIST = 2; // admin dictates who the other publishers are + IMPLICIT = 3; // authorship implied by the topic binding (PO constraint) + ALL = 4; // every peer publishes (gossipsub-equivalent cohort) +} + +// The (partial) decisions fixed the moment the first full node is contacted. +message CohortSpec { + bytes topic = 1; // 32 bytes, meaning per binding + TopicBinding binding = 2; + PublisherRegime publishers = 3; + bool history = 4; // deliver matching chunks from the local store + bytes admin = 5; // 20-byte eth address; set iff EXPLICIT_* + uint32 po_min = 6; // proximity order for implicit bindings (default 16) + uint32 cap = 7; // max direct streams the broker accepts for this topic (0 = broker default) + bool closed = 8; // no audience: subscribers restricted to the publisher list +} + +// --------------------------------------------------------------------------- +// Stream establishment (client -> broker), stream name "pubsub/1.0.0" +// --------------------------------------------------------------------------- + +enum Role { + ROLE_UNSPECIFIED = 0; + SUBSCRIBER = 1; + PUBLISHER = 2; // implies direct connection to the broker (necessary, not sufficient) +} + +message Connect { + CohortSpec cohort = 1; + Role role = 2; + PublisherAuth auth = 3; // present iff role == PUBLISHER +} + +message PublisherAuth { + bytes owner = 1; // 20-byte eth address of the SOC owner key + bytes id = 2; // 32-byte SOC id, when the binding fixes it +} + +// --------------------------------------------------------------------------- +// Messages — SOC-only is a protocol feature +// --------------------------------------------------------------------------- + +// A full single-owner chunk in transit. +message Soc { + bytes id = 1; // 32 bytes + bytes owner = 2; // 20 bytes (recoverable from signature; explicit for cheap filtering) + bytes signature = 3; // 65 bytes + bytes span = 4; // 8 bytes LE + bytes payload = 5; // wrapped-CAC data, <= 4096 bytes +} + +// Publisher -> broker. No type prefix needed: the stream's role was declared at Connect. +message Publish { + Soc soc = 1; +} + +// Broker -> subscriber: exactly one of the following per frame. +message Broadcast { + oneof frame { + Soc handshake = 1; // first frame on a stream: full SOC identity + DataFrame data = 2; // subsequent frames: signature ‖ span ‖ payload only + Ping ping = 3; // keepalive; parent measures RTT off the echo + } +} + +message DataFrame { + bytes signature = 1; + bytes span = 2; + bytes payload = 3; +} + +message Ping {} + +// --------------------------------------------------------------------------- +// Multihop control plane — RESERVED, named to fix intent (not final for AFM) +// --------------------------------------------------------------------------- +// message Beacon {} // child -> parent capacity/score summary (0xFE) +// message Reparent {} // parent -> child: REPARENT{to, gateway?} (0xFD) +// message Expect {} // parent -> relay: EXPECT{children} (0xFC) +// message DcutrSignal {} // via circuit relay (0xFB) +// message SwapProposal {} // promotion swap propose/ack (0xFA) diff --git a/SWIPs/swip-60.md b/SWIPs/swip-60.md new file mode 100644 index 00000000..56243765 --- /dev/null +++ b/SWIPs/swip-60.md @@ -0,0 +1,242 @@ +--- +SWIP: 60 +title: BPS singlehop — brokered broadcast pub/sub, base protocol +author: Viktor Trón (@zelig), Viktor Tóth (@nugaon) +discussions-to: https://discord.gg/Q6BvSkCv +status: Draft +type: Standards Track (Networking) +created: 2026-08-03 +--- + + + +- **Business line**: real-time topic streams for dApps without storing chunks or polling — + enough on its own for small closed collaboration cohorts (collaborative remix editing, a + strudel livecoding session, multiparty games) and basic single-publisher limited-audience + live streaming. +- **Dev line**: implement one libp2p protocol (`pubsub/1.0.0`, messages in + [bps.proto](assets/swip-60/bps.proto)) plus a WebSocket bridge on the Bee API; done when + a broker, publishers and subscribers interoperate per the conformance section. Groundwork + exists in bee [#5435](https://github.com/ethersphere/bee/pull/5435). +- Bandwidth-incentive integration is a separate SWIP (bps-bw-incentives). +- Broker discovery integration is from a separate SWIP (bps-broker-discovery, building on + [SWIP-58 MEX](https://github.com/ethersphere/SWIPs/pull/103)). + +## Simple Summary + +A real-time messaging protocol: WebSocket clients publish and subscribe to topic streams +through Bee nodes. One full node per topic acts as **broker**, re-broadcasting each message +over direct, long-lived p2p streams to at most **cap** connected peers. Messages are +single-owner chunks, so every subscriber verifies authorship end-to-end; the broker can +withhold, never forge. + +## Motivation + +Swarm's event primitives (GSOC, PSS) require full-node operation; light clients can only +poll storage. BPS singlehop is the smallest protocol that fixes this: one broker, direct +streams, authenticated messages, an explicit connection cap. Everything larger — multihop +trees, adaptive reorganisation, incentives, discovery — is layered on top by later SWIPs +without changing the semantics defined here. + +## Specification + +### The contract + +Per topic-cohort: + +- messages come from **publishers, and publishers only**; +- they arrive at **all subscribers**. + +### Cohort genesis: the parameters + +A cohort is fully described by a `CohortSpec` ([bps.proto](assets/swip-60/bps.proto)), +fixed the moment the first peer contacts a BPS-speaking full node with a topic. There is +no mode enum; **modes are combinations of these parameters**. + +| parameter | values | meaning | +|---|---|---| +| `topic` | 32 bytes | interpreted per `binding` | +| `binding` | `ANCHOR` / `SOC_ID` / `OWNER` / `FEED_TOPIC` | what the topic binds to; fixes which SOCs qualify as messages and the dedup rule | +| `publishers` | `EXPLICIT_SINGLE` / `EXPLICIT_LIST` / `IMPLICIT` / `ALL` | who may author | +| `admin` | eth address | set iff explicit publishers; may extend the publisher list, nothing more | +| `history` | bool | deliver matching chunks already in the local store (mechanism in bps-history; a singlehop broker MAY refuse) | +| `po_min` | uint (default 16) | proximity constraint for implicit bindings: `PO(socAddr, anchor) ≥ po_min` | +| `cap` | uint | **max direct streams the broker accepts for this topic**; 0 = broker's default | +| `closed` | bool | no audience: subscribers are restricted to the publisher list (all and only publishers subscribe) | + +Binding semantics (dedup rule in parentheses): + +- **`ANCHOR`** — topic = full SOC/GSOC address; all messages share one address (dedup on + the wrapped CAC). +- **`SOC_ID`** — topic = SOC id; any owner with `PO(socAddr(id, owner), anchor) ≥ po_min` + qualifies (dedup on chunk address). +- **`OWNER`** — topic = SOC owner; any id under the same PO constraint — MIC semantics + (dedup on chunk address). +- **`FEED_TOPIC`** — id = `keccak256(topic ‖ index)`; feed-update streams, graffiti MIC + (dedup on chunk address). + +### Roles and the cap + +- **Broker**: the first full node contacted; root of the (here, depth = 1) multicast tree. + Accepts at most `cap` concurrent streams for the topic. **At cap it MUST answer a + `Connect` with a refusal** (`FULL`); referral to another attachment point is reserved + for bps-multihop — a singlehop-only broker simply refuses. +- **Publisher**: sends and receives. MUST be directly connected to the broker; direct + connection is necessary, not sufficient — with explicit publishers, the admin's list + decides. +- **Subscriber**: receives only. Does not exist in `closed` cohorts. + +### Information flow + +```mermaid +sequenceDiagram + autonumber + participant PD as publisher dApp + participant PN as publisher's bee node
(WS bridge) + participant B as broker
(root, full node) + participant SN as subscriber's bee node
(WS bridge + mux) + participant SD as subscriber dApp(s) + + Note over B: cohort open: topic set,
genesis parameters fixed + SN->>B: Connect(CohortSpec, SUBSCRIBER) + PN->>B: Connect(CohortSpec, PUBLISHER, auth) + Note over PN,B: publisher ⇒ direct connection to broker
(necessary, not sufficient — admin's list decides) + + loop keepalive (30 s) + B->>SN: Ping + SN-->>B: echo (RTT measured by parent) + end + + PD->>PN: WS: payload + PN->>B: Publish(SOC) + B->>B: validate: SOC sig ⊨ topic binding
(+ dedup per binding) + + par fan-out to every subscriber stream + B->>SN: Broadcast: handshake frame (full SOC identity, first) /
data frame (sig ‖ span ‖ payload, after) + SN->>SN: mux: one p2p stream → N WS sessions + SN->>SD: WS: payload + and publisher's own subscription (if subscriber too) + B->>PN: Broadcast + PN->>PD: WS: payload + end +``` + +The broadcast is **end-to-end authenticated**: every subscriber re-verifies the SOC +signature against the topic binding regardless of path. + +### Wire protocol + +Messages are defined in [bps.proto](assets/swip-60/bps.proto). Framing notes: + +- Transport: libp2p stream `pubsub/1.0.0`, one stream per (peer, topic); `Connect` as the + first message (protobuf-over-libp2p, as bee protocols elsewhere) — bee #5435 + currently uses stream headers. +- Frame-type byte split: service frames grow downward from `0xFF` (ping `0xFF`; multihop + control frames `0xFE`… reserved), data frames grow upward from `0x00` — no collision. +- Broker→subscriber: first frame per stream is the **handshake** frame carrying full SOC + identity (id, owner); subsequent **data** frames carry `sig ‖ span ‖ payload` only. +- Publisher→broker frames carry no type prefix: the stream's role was declared at + `Connect`. +- Broker validation on `Publish`: SOC signature verifies against the topic binding, PO + constraint holds where applicable, sender is a legitimate publisher, message is not a + duplicate per the binding's dedup rule. Invalid ⇒ drop; repeated invalid ⇒ disconnect + (blocklisting policy). + +### API (WebSocket bridge) + +WS clients see raw mode payloads only; all p2p framing is transparent. One p2p stream is +muxed to N local WS sessions per topic. Endpoint shape per bee +[#5435](https://github.com/ethersphere/bee/pull/5435). + +### Configurations (worked examples) + +Modes are rows over the parameters; two normative examples: + +**The 4-seat jam cohort** — collaborative remix editing, a strudel livecoding session, a +multiparty game. + +``` +binding: ANCHOR (topic = mnemonic anchor) publishers: EXPLICIT_LIST (admin + ≤3) +closed: true (all and only publishers subscribe) cap: 4 history: false +``` + +Every seat sends and receives; there is no audience; a fifth `Connect` gets `FULL`. + +**Basic live streaming** — single publisher, open audience: + +``` +binding: FEED_TOPIC (sequential index) publishers: EXPLICIT_SINGLE +closed: false cap: broker default history: false +``` + +### The modes — enumerated as combinations of dimension choices + +Known use cases attach here; each mode is nothing more than a row — a combination of +publisher/subscriber info, topic match type, and history. (`+/−` = both configurations +meaningful.) + +| # of pubs | pubs implicit? | subscribers | topic / anchor match | history | use case | +|---|---|---|---|---|---| +| 1 | — | all | feed topic, index sequential | — | live video streaming | +| any | — | all | feed topic, index sequential | — | live videoconference | +| — | + | all | feed topic | +/— | tags, adverts; private co-authoring | +| all | — | all | topic a mere mnemonic of the cohort | +/— | gossip cohort for multi-party / group chat | +| any | + | all | anchor (ephemeral GSOC) | +/— | anythread comments / troll-box | +| any | + | all | ID = `keccak256(topic ‖ index)` | +/— | following one or more feeds | +| — | + | all | feed special, mined index | +/— | following graffiti soc | + +The audience is bounded by the broker's cap; scaling past it is bps-multihop's business. + +Rows requiring implicit publishers or history are specified in bps-implicit-publisher and +bps-history respectively. + +## Rationale: why not gossipsub + +libp2p ships gossipsub, a battle-tested mesh multicast. BPS builds its own protocol +because gossipsub's core mechanisms — flooding to a random mesh, IHAVE/IWANT +pull-recovery — are exactly what an incentivised network rejects: **no node wants to pay +for a message it did not ask for.** That one economic fact dissolves gossipsub's +machinery: metered edges mean no redundant paths and no transport-level duplicates; a +cohort's `CohortSpec` scopes every session; authentication is structural (SOC-signed +against the topic binding), so brokers and relays forward without being trusted — an +intermediate can withhold, never forge; and withholding is a liveness fault recoverable +by re-pointing or relocating the topic. Multihop forwarding (bps-multihop) adds capacity +without reintroducing flooding: every edge still pays upstream, every node still receives +only its topic's stream. + +## Out of scope (deliberately) + +Multihop relaying and referral (bps-multihop), reorganisation policies (SWATCH, SPORE — +policy SWIPs over this protocol's events and actions, no new frames), bandwidth incentives +(bps-bw-incentives), broker discovery (SWIP-58 MEX; early deployments hardcode brokers), +history delivery mechanism (bps-history), implicit-publisher event sourcing +(bps-implicit-publisher). + +## Conformance (definition of done) + +An implementation is conformant when: + +1. a broker enforces cap, publisher legitimacy, per-binding validation and dedup; +2. a subscriber re-verifies every message end-to-end and detects (only) liveness faults; +3. the two worked configurations above interoperate across independent implementations + against the frames in [bps.proto](assets/swip-60/bps.proto); +4. a `FULL` refusal is issued at cap — and nothing else is (no referral). + +## Backwards compatibility + +New protocol; no existing behaviour changes. Frame-byte split reserves the service range +so bps-multihop extends without version bump. + +## References + +Wire: [bps.proto](assets/swip-60/bps.proto) · origin: +[PR #93](https://github.com/ethersphere/SWIPs/pull/93) "Add: pubsub" · broker discovery: +[SWIP-58 MEX, PR #103](https://github.com/ethersphere/SWIPs/pull/103) · implementation: +bee [#5435](https://github.com/ethersphere/bee/pull/5435), bee-js +[#1151](https://github.com/ethersphere/bee-js/pull/1151) + +## Copyright + +Copyright and related rights waived via [CC0](https://creativecommons.org/publicdomain/zero/1.0/). From 25f6f084e2cfe93151fe5dd9dbd903793c41fc2e Mon Sep 17 00:00:00 2001 From: zelig Date: Wed, 5 Aug 2026 01:11:26 +0200 Subject: [PATCH 02/14] swip-60: revision 2 after acud's review - Connect split into Open (opener fixes CohortSpec) / Subscribe (topic only, no cohort metadata); broker Ack echoes the spec to subscribers for end-to-end verification; Role enum gone - broker capacity removed from CohortSpec: broker-side policy, not a cohort parameter; jam-cohort seat bound now = genesis publisher list - EXPLICIT_LIST mechanics specified: repeated publisher_list fixed at genesis; dynamic grants/revocations deferred (out of scope) - every frame carries the full SOC: handshake/data split dropped; stream-model rationale added (per-topic streams, mux-migration safe) - Ping dropped: liveness/RTT are transport concerns - *_UNSPECIFIED enum zero values documented as invalid on the wire Co-Authored-By: Claude Fable 5 --- SWIPs/assets/swip-60/bps.proto | 128 +++++++++++++++++++-------------- SWIPs/swip-60.md | 94 +++++++++++++----------- 2 files changed, 130 insertions(+), 92 deletions(-) diff --git a/SWIPs/assets/swip-60/bps.proto b/SWIPs/assets/swip-60/bps.proto index 36381999..db495975 100644 --- a/SWIPs/assets/swip-60/bps.proto +++ b/SWIPs/assets/swip-60/bps.proto @@ -1,12 +1,21 @@ // Broadcast Pub/Sub (BPS) — protocol messages and types. // Spec: SWIP-60 (../../swip-60.md). // -// Deliberately incomplete as of 2026-08-02: the singlehop (depth = 1) subset is -// concrete; multihop control-plane messages are named but reserved. The existing -// implementation (bee PR #5435) uses hand-rolled byte framing with the same -// semantics; this file is the normative description of the message structure, -// and — bee protocols being protobuf-over-libp2p elsewhere — the candidate -// replacement framing. +// Revision 2 (2026-08-05), after review on PR #104: Connect split into +// Open/Subscribe (subscribers carry no cohort metadata), broker capacity +// removed from CohortSpec (it is broker-side policy, not a cohort parameter), +// Ping dropped (liveness/RTT are transport concerns), and every frame carries +// the full SOC (no handshake/data split). Field numbers renumbered — the +// draft has no deployed compatibility surface. +// +// Enum zero values (*_UNSPECIFIED): proto3 requires a zero value; it is +// deliberately NOT a legitimate wire value. It exists so that an unset field +// is detectable and no implementation can silently rely on a default. +// Receivers MUST reject messages carrying it. +// +// The singlehop (depth = 1) subset is concrete; multihop control-plane +// messages are reserved. Implementation groundwork: bee PR #5435 +// (hand-rolled byte framing with the same semantics). syntax = "proto3"; package bps; @@ -17,50 +26,59 @@ option go_package = "github.com/ethersphere/bee/v2/pkg/bps/pb"; // Cohort genesis — the primitive decisions whose combinations are the "modes" // --------------------------------------------------------------------------- -// What the topic binds to (see epic: "What does the topic bind to?"). +// What the topic binds to (see SWIP-60: binding semantics). enum TopicBinding { - TOPIC_BINDING_UNSPECIFIED = 0; + TOPIC_BINDING_UNSPECIFIED = 0; // invalid on the wire (see header note) ANCHOR = 1; // topic = full SOC/GSOC address; dedup on the wrapped CAC SOC_ID = 2; // topic = SOC id; any owner with PO(addr, anchor) >= po_min OWNER = 3; // topic = SOC owner; any id with PO(addr, anchor) >= po_min (MIC) FEED_TOPIC = 4; // id = keccak256(topic ‖ index); graffiti MIC / feed streams } -// Who may author (see epic: genesis dimensions). +// Who may author. enum PublisherRegime { - PUBLISHER_REGIME_UNSPECIFIED = 0; - EXPLICIT_SINGLE = 1; // opener is admin and sole publisher (live streaming) - EXPLICIT_LIST = 2; // admin dictates who the other publishers are + PUBLISHER_REGIME_UNSPECIFIED = 0; // invalid on the wire (see header note) + EXPLICIT_SINGLE = 1; // opener is the sole publisher (live streaming) + EXPLICIT_LIST = 2; // set fixed at genesis: admin + publisher_list + // (dynamic grants/revocations: later revision) IMPLICIT = 3; // authorship implied by the topic binding (PO constraint) ALL = 4; // every peer publishes (gossipsub-equivalent cohort) } -// The (partial) decisions fixed the moment the first full node is contacted. +// Fixed by the cohort's opener; immutable for the cohort's lifetime. +// NOTE: broker capacity is NOT a cohort parameter — a cohort cannot dictate a +// remote node's connection count. Each broker enforces its own per-topic +// stream limit and answers FULL when it is exhausted. message CohortSpec { - bytes topic = 1; // 32 bytes, meaning per binding - TopicBinding binding = 2; - PublisherRegime publishers = 3; - bool history = 4; // deliver matching chunks from the local store - bytes admin = 5; // 20-byte eth address; set iff EXPLICIT_* - uint32 po_min = 6; // proximity order for implicit bindings (default 16) - uint32 cap = 7; // max direct streams the broker accepts for this topic (0 = broker default) - bool closed = 8; // no audience: subscribers restricted to the publisher list + bytes topic = 1; // 32 bytes, meaning per binding + TopicBinding binding = 2; + PublisherRegime publishers = 3; + bool history = 4; // deliver matching chunks from the local store + bytes admin = 5; // 20-byte eth address; set iff EXPLICIT_* + repeated bytes publisher_list = 6; // 20-byte eth addresses, excl. admin; + // set iff EXPLICIT_LIST + uint32 po_min = 7; // proximity order for implicit bindings (default 16) + bool closed = 8; // no audience: subscribers restricted to the publishers } // --------------------------------------------------------------------------- -// Stream establishment (client -> broker), stream name "pubsub/1.0.0" +// Stream establishment, stream name "pubsub/1.0.0" — one stream per (peer, topic). +// The first message on a fresh stream is Open (fixes a new cohort) or +// Subscribe (joins an existing one); the broker answers with Ack. // --------------------------------------------------------------------------- -enum Role { - ROLE_UNSPECIFIED = 0; - SUBSCRIBER = 1; - PUBLISHER = 2; // implies direct connection to the broker (necessary, not sufficient) +// Opener -> broker: the one peer that fixes the cohort. +message Open { + CohortSpec cohort = 1; + PublisherAuth auth = 2; // present iff the opener publishes (explicit regimes) } -message Connect { - CohortSpec cohort = 1; - Role role = 2; - PublisherAuth auth = 3; // present iff role == PUBLISHER +// Joiner -> broker: names the topic — nothing more. Subscribers carry no +// cohort metadata; auth is present iff the joiner publishes (publishers +// connect directly to the broker). +message Subscribe { + bytes topic = 1; // 32 bytes + PublisherAuth auth = 2; // present iff publisher } message PublisherAuth { @@ -68,11 +86,30 @@ message PublisherAuth { bytes id = 2; // 32-byte SOC id, when the binding fixes it } +// Broker -> peer, answering Open or Subscribe. The echoed CohortSpec lets a +// subscriber verify every message end-to-end against the topic binding. +message Ack { + Status status = 1; + CohortSpec cohort = 2; // set iff status == OK +} + +enum Status { + STATUS_UNSPECIFIED = 0; // invalid on the wire (see header note) + OK = 1; + FULL = 2; // broker at its per-topic capacity; + // a singlehop broker refuses — nothing else + UNKNOWN_TOPIC = 3; // Subscribe for a topic the broker does not serve + REJECTED = 4; // e.g. publisher not on the list, invalid auth, + // non-publisher Subscribe on a closed cohort +} + // --------------------------------------------------------------------------- // Messages — SOC-only is a protocol feature // --------------------------------------------------------------------------- -// A full single-owner chunk in transit. +// A full single-owner chunk in transit. Every frame is self-contained: no +// per-stream handshake state, and no format change if the stream model +// evolves (e.g. topic-muxed streams later). message Soc { bytes id = 1; // 32 bytes bytes owner = 2; // 20 bytes (recoverable from signature; explicit for cheap filtering) @@ -81,33 +118,20 @@ message Soc { bytes payload = 5; // wrapped-CAC data, <= 4096 bytes } -// Publisher -> broker. No type prefix needed: the stream's role was declared at Connect. +// Publisher -> broker. message Publish { Soc soc = 1; } -// Broker -> subscriber: exactly one of the following per frame. +// Broker -> subscriber. message Broadcast { oneof frame { - Soc handshake = 1; // first frame on a stream: full SOC identity - DataFrame data = 2; // subsequent frames: signature ‖ span ‖ payload only - Ping ping = 3; // keepalive; parent measures RTT off the echo + Soc soc = 1; + // 2–15 reserved: multihop control plane (Beacon, Reparent, Expect, + // DcutrSignal, SwapProposal) — named to fix intent, not final. } } -message DataFrame { - bytes signature = 1; - bytes span = 2; - bytes payload = 3; -} - -message Ping {} - -// --------------------------------------------------------------------------- -// Multihop control plane — RESERVED, named to fix intent (not final for AFM) -// --------------------------------------------------------------------------- -// message Beacon {} // child -> parent capacity/score summary (0xFE) -// message Reparent {} // parent -> child: REPARENT{to, gateway?} (0xFD) -// message Expect {} // parent -> relay: EXPECT{children} (0xFC) -// message DcutrSignal {} // via circuit relay (0xFB) -// message SwapProposal {} // promotion swap propose/ack (0xFA) +// Keepalive / RTT: none at the BPS level. Liveness is the transport's job +// (libp2p), and latency metrics for reorganisation policies (SWATCH) are +// sourced there as well. diff --git a/SWIPs/swip-60.md b/SWIPs/swip-60.md index 56243765..0b28bbca 100644 --- a/SWIPs/swip-60.md +++ b/SWIPs/swip-60.md @@ -60,11 +60,14 @@ no mode enum; **modes are combinations of these parameters**. | `topic` | 32 bytes | interpreted per `binding` | | `binding` | `ANCHOR` / `SOC_ID` / `OWNER` / `FEED_TOPIC` | what the topic binds to; fixes which SOCs qualify as messages and the dedup rule | | `publishers` | `EXPLICIT_SINGLE` / `EXPLICIT_LIST` / `IMPLICIT` / `ALL` | who may author | -| `admin` | eth address | set iff explicit publishers; may extend the publisher list, nothing more | +| `admin` + `publisher_list` | eth addresses | set iff explicit publishers; with `EXPLICIT_LIST` the full publisher set is **fixed at genesis** (dynamic grants/revocations are deferred to a later revision) | | `history` | bool | deliver matching chunks already in the local store (mechanism in bps-history; a singlehop broker MAY refuse) | | `po_min` | uint (default 16) | proximity constraint for implicit bindings: `PO(socAddr, anchor) ≥ po_min` | -| `cap` | uint | **max direct streams the broker accepts for this topic**; 0 = broker's default | -| `closed` | bool | no audience: subscribers are restricted to the publisher list (all and only publishers subscribe) | +| `closed` | bool | no audience: subscribers are restricted to the publisher set (all and only publishers subscribe) | + +Broker **capacity is deliberately not a cohort parameter**: a cohort cannot dictate a +remote node's connection count. Each broker enforces its own per-topic stream limit and +answers `FULL` when it is exhausted. Binding semantics (dedup rule in parentheses): @@ -77,16 +80,20 @@ Binding semantics (dedup rule in parentheses): - **`FEED_TOPIC`** — id = `keccak256(topic ‖ index)`; feed-update streams, graffiti MIC (dedup on chunk address). -### Roles and the cap +### Roles and capacity - **Broker**: the first full node contacted; root of the (here, depth = 1) multicast tree. - Accepts at most `cap` concurrent streams for the topic. **At cap it MUST answer a - `Connect` with a refusal** (`FULL`); referral to another attachment point is reserved - for bps-multihop — a singlehop-only broker simply refuses. + Enforces its own per-topic capacity. **At capacity it MUST answer `Open`/`Subscribe` + with a refusal** (`FULL`); referral to another attachment point is reserved for + bps-multihop — a singlehop-only broker simply refuses. +- **Opener**: the one peer that fixes the `CohortSpec` (`Open`); with explicit publisher + regimes the opener publishes. - **Publisher**: sends and receives. MUST be directly connected to the broker; direct - connection is necessary, not sufficient — with explicit publishers, the admin's list + connection is necessary, not sufficient — with explicit publishers, the genesis list decides. -- **Subscriber**: receives only. Does not exist in `closed` cohorts. +- **Subscriber**: receives only; joins by naming the topic (`Subscribe`) and carries no + cohort metadata — the broker echoes the `CohortSpec` back so every message can be + verified end-to-end. Does not exist in `closed` cohorts. ### Information flow @@ -99,26 +106,23 @@ sequenceDiagram participant SN as subscriber's bee node
(WS bridge + mux) participant SD as subscriber dApp(s) - Note over B: cohort open: topic set,
genesis parameters fixed - SN->>B: Connect(CohortSpec, SUBSCRIBER) - PN->>B: Connect(CohortSpec, PUBLISHER, auth) - Note over PN,B: publisher ⇒ direct connection to broker
(necessary, not sufficient — admin's list decides) - - loop keepalive (30 s) - B->>SN: Ping - SN-->>B: echo (RTT measured by parent) - end + PN->>B: Open(CohortSpec, auth) + Note over PN,B: opener fixes the cohort; publisher ⇒
direct connection to broker + B-->>PN: Ack(OK) + SN->>B: Subscribe(topic) + B-->>SN: Ack(OK, CohortSpec) + Note over B,SN: echoed spec ⇒ subscriber verifies
every message end-to-end PD->>PN: WS: payload PN->>B: Publish(SOC) B->>B: validate: SOC sig ⊨ topic binding
(+ dedup per binding) par fan-out to every subscriber stream - B->>SN: Broadcast: handshake frame (full SOC identity, first) /
data frame (sig ‖ span ‖ payload, after) + B->>SN: Broadcast(SOC) — every frame self-contained SN->>SN: mux: one p2p stream → N WS sessions SN->>SD: WS: payload and publisher's own subscription (if subscriber too) - B->>PN: Broadcast + B->>PN: Broadcast(SOC) PN->>PD: WS: payload end ``` @@ -130,15 +134,18 @@ signature against the topic binding regardless of path. Messages are defined in [bps.proto](assets/swip-60/bps.proto). Framing notes: -- Transport: libp2p stream `pubsub/1.0.0`, one stream per (peer, topic); `Connect` as the - first message (protobuf-over-libp2p, as bee protocols elsewhere) — bee #5435 - currently uses stream headers. -- Frame-type byte split: service frames grow downward from `0xFF` (ping `0xFF`; multihop - control frames `0xFE`… reserved), data frames grow upward from `0x00` — no collision. -- Broker→subscriber: first frame per stream is the **handshake** frame carrying full SOC - identity (id, owner); subsequent **data** frames carry `sig ‖ span ‖ payload` only. -- Publisher→broker frames carry no type prefix: the stream's role was declared at - `Connect`. +- Transport: libp2p stream `pubsub/1.0.0`, one stream per (peer, topic), + protobuf-over-libp2p as bee protocols elsewhere. The first message on a fresh stream is + `Open` (fixes a new cohort) or `Subscribe` (joins one — topic only, no cohort + metadata); the broker answers with `Ack`, echoing the `CohortSpec` to subscribers. +- **Stream model rationale**: per-topic streams give per-cohort flow control, teardown + and role typing, and match bee's protocol idiom. Because every frame carries the full + SOC (self-contained, no per-stream handshake state), a later move to topic-muxed + streams requires no format change. +- Every `Broadcast` frame carries the **full SOC** (id, owner, signature, span, payload); + there is no handshake/data frame split. +- No BPS-level keepalive or RTT probing: liveness is the transport's job, and latency + metrics for reorganisation policies are sourced there too. - Broker validation on `Publish`: SOC signature verifies against the topic binding, PO constraint holds where applicable, sender is a legitimate publisher, message is not a duplicate per the binding's dedup rule. Invalid ⇒ drop; repeated invalid ⇒ disconnect @@ -158,17 +165,18 @@ Modes are rows over the parameters; two normative examples: multiparty game. ``` -binding: ANCHOR (topic = mnemonic anchor) publishers: EXPLICIT_LIST (admin + ≤3) -closed: true (all and only publishers subscribe) cap: 4 history: false +binding: ANCHOR (topic = mnemonic anchor) publishers: EXPLICIT_LIST (admin + 3) +closed: true (all and only publishers subscribe) history: false ``` -Every seat sends and receives; there is no audience; a fifth `Connect` gets `FULL`. +Every seat sends and receives; there is no audience; the genesis list **is** the seat +bound — a fifth peer's `Subscribe` gets `REJECTED`. **Basic live streaming** — single publisher, open audience: ``` binding: FEED_TOPIC (sequential index) publishers: EXPLICIT_SINGLE -closed: false cap: broker default history: false +closed: false history: false ``` ### The modes — enumerated as combinations of dimension choices @@ -187,7 +195,8 @@ meaningful.) | any | + | all | ID = `keccak256(topic ‖ index)` | +/— | following one or more feeds | | — | + | all | feed special, mined index | +/— | following graffiti soc | -The audience is bounded by the broker's cap; scaling past it is bps-multihop's business. +The audience is bounded by the broker's capacity; scaling past it is bps-multihop's +business. Rows requiring implicit publishers or history are specified in bps-implicit-publisher and bps-history respectively. @@ -212,22 +221,27 @@ Multihop relaying and referral (bps-multihop), reorganisation policies (SWATCH, policy SWIPs over this protocol's events and actions, no new frames), bandwidth incentives (bps-bw-incentives), broker discovery (SWIP-58 MEX; early deployments hardcode brokers), history delivery mechanism (bps-history), implicit-publisher event sourcing -(bps-implicit-publisher). +(bps-implicit-publisher), and **dynamic publisher-list changes** — grants/revocations +after genesis are deferred to a later revision; the `EXPLICIT_LIST` set is fixed at +`Open`. ## Conformance (definition of done) An implementation is conformant when: -1. a broker enforces cap, publisher legitimacy, per-binding validation and dedup; -2. a subscriber re-verifies every message end-to-end and detects (only) liveness faults; +1. a broker enforces its per-topic capacity, publisher legitimacy, per-binding validation + and dedup; +2. a subscriber re-verifies every message end-to-end (against the `Ack`-echoed + `CohortSpec`) and detects (only) liveness faults; 3. the two worked configurations above interoperate across independent implementations against the frames in [bps.proto](assets/swip-60/bps.proto); -4. a `FULL` refusal is issued at cap — and nothing else is (no referral). +4. a `FULL` refusal is issued at capacity — and nothing else is (no referral). ## Backwards compatibility -New protocol; no existing behaviour changes. Frame-byte split reserves the service range -so bps-multihop extends without version bump. +New protocol; no existing behaviour changes. Reserved `Broadcast` frame fields hold the +multihop control plane, so bps-multihop extends without a version bump; self-contained +frames mean a change of stream model needs no format change either. ## References From 77f60889cd84aac328141c6ab25c41201bf9547b Mon Sep 17 00:00:00 2001 From: zelig Date: Wed, 5 Aug 2026 01:19:47 +0200 Subject: [PATCH 03/14] swip-60: cap wording in summary/motivation follows capacity change Co-Authored-By: Claude Fable 5 --- SWIPs/swip-60.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/SWIPs/swip-60.md b/SWIPs/swip-60.md index 0b28bbca..bb7956df 100644 --- a/SWIPs/swip-60.md +++ b/SWIPs/swip-60.md @@ -28,7 +28,7 @@ assets/swip-60/bps.proto. --> A real-time messaging protocol: WebSocket clients publish and subscribe to topic streams through Bee nodes. One full node per topic acts as **broker**, re-broadcasting each message -over direct, long-lived p2p streams to at most **cap** connected peers. Messages are +over direct, long-lived p2p streams to a capacity-bounded set of connected peers. Messages are single-owner chunks, so every subscriber verifies authorship end-to-end; the broker can withhold, never forge. @@ -36,7 +36,7 @@ withhold, never forge. Swarm's event primitives (GSOC, PSS) require full-node operation; light clients can only poll storage. BPS singlehop is the smallest protocol that fixes this: one broker, direct -streams, authenticated messages, an explicit connection cap. Everything larger — multihop +streams, authenticated messages, an explicit capacity bound. Everything larger — multihop trees, adaptive reorganisation, incentives, discovery — is layered on top by later SWIPs without changing the semantics defined here. From 74812864a31ab78d2dc370ee6659f8509e4e5296 Mon Sep 17 00:00:00 2001 From: zelig Date: Wed, 5 Aug 2026 05:19:44 +0200 Subject: [PATCH 04/14] swip-60: MEX renumbered SWIP-58 -> SWIP-59 Co-Authored-By: Claude Fable 5 --- SWIPs/swip-60.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/SWIPs/swip-60.md b/SWIPs/swip-60.md index bb7956df..9a3fb3ba 100644 --- a/SWIPs/swip-60.md +++ b/SWIPs/swip-60.md @@ -22,7 +22,7 @@ assets/swip-60/bps.proto. --> exists in bee [#5435](https://github.com/ethersphere/bee/pull/5435). - Bandwidth-incentive integration is a separate SWIP (bps-bw-incentives). - Broker discovery integration is from a separate SWIP (bps-broker-discovery, building on - [SWIP-58 MEX](https://github.com/ethersphere/SWIPs/pull/103)). + [SWIP-59 MEX](https://github.com/ethersphere/SWIPs/pull/103)). ## Simple Summary @@ -219,7 +219,7 @@ only its topic's stream. Multihop relaying and referral (bps-multihop), reorganisation policies (SWATCH, SPORE — policy SWIPs over this protocol's events and actions, no new frames), bandwidth incentives -(bps-bw-incentives), broker discovery (SWIP-58 MEX; early deployments hardcode brokers), +(bps-bw-incentives), broker discovery (SWIP-59 MEX; early deployments hardcode brokers), history delivery mechanism (bps-history), implicit-publisher event sourcing (bps-implicit-publisher), and **dynamic publisher-list changes** — grants/revocations after genesis are deferred to a later revision; the `EXPLICIT_LIST` set is fixed at @@ -247,7 +247,7 @@ frames mean a change of stream model needs no format change either. Wire: [bps.proto](assets/swip-60/bps.proto) · origin: [PR #93](https://github.com/ethersphere/SWIPs/pull/93) "Add: pubsub" · broker discovery: -[SWIP-58 MEX, PR #103](https://github.com/ethersphere/SWIPs/pull/103) · implementation: +[SWIP-59 MEX, PR #103](https://github.com/ethersphere/SWIPs/pull/103) · implementation: bee [#5435](https://github.com/ethersphere/bee/pull/5435), bee-js [#1151](https://github.com/ethersphere/bee-js/pull/1151) From 22e83255e22c5a684e43987a8e29296456359cfb Mon Sep 17 00:00:00 2001 From: zelig Date: Fri, 7 Aug 2026 12:12:30 +0200 Subject: [PATCH 05/14] swip-60 rev 3: specify the API (WebSocket bridge); po_min -> protocol constant PO_MIN Co-Authored-By: Claude Fable 5 --- SWIPs/assets/swip-60/bps.proto | 10 ++++-- SWIPs/swip-60.md | 60 ++++++++++++++++++++++++++++++---- 2 files changed, 61 insertions(+), 9 deletions(-) diff --git a/SWIPs/assets/swip-60/bps.proto b/SWIPs/assets/swip-60/bps.proto index db495975..7384870b 100644 --- a/SWIPs/assets/swip-60/bps.proto +++ b/SWIPs/assets/swip-60/bps.proto @@ -30,8 +30,8 @@ option go_package = "github.com/ethersphere/bee/v2/pkg/bps/pb"; enum TopicBinding { TOPIC_BINDING_UNSPECIFIED = 0; // invalid on the wire (see header note) ANCHOR = 1; // topic = full SOC/GSOC address; dedup on the wrapped CAC - SOC_ID = 2; // topic = SOC id; any owner with PO(addr, anchor) >= po_min - OWNER = 3; // topic = SOC owner; any id with PO(addr, anchor) >= po_min (MIC) + SOC_ID = 2; // topic = SOC id; any owner with PO(addr, anchor) >= PO_MIN + OWNER = 3; // topic = SOC owner; any id with PO(addr, anchor) >= PO_MIN (MIC) FEED_TOPIC = 4; // id = keccak256(topic ‖ index); graffiti MIC / feed streams } @@ -49,6 +49,10 @@ enum PublisherRegime { // NOTE: broker capacity is NOT a cohort parameter — a cohort cannot dictate a // remote node's connection count. Each broker enforces its own per-topic // stream limit and answers FULL when it is exhausted. +// NOTE: the proximity constraint for implicit bindings is a protocol +// constant, PO_MIN = 16 — not a cohort parameter (a proto3 unset uint32 is +// indistinguishable from 0, which would silently disable the constraint; +// and no use case varies it). message CohortSpec { bytes topic = 1; // 32 bytes, meaning per binding TopicBinding binding = 2; @@ -57,7 +61,7 @@ message CohortSpec { bytes admin = 5; // 20-byte eth address; set iff EXPLICIT_* repeated bytes publisher_list = 6; // 20-byte eth addresses, excl. admin; // set iff EXPLICIT_LIST - uint32 po_min = 7; // proximity order for implicit bindings (default 16) + reserved 7; // was po_min — now protocol constant PO_MIN bool closed = 8; // no audience: subscribers restricted to the publishers } diff --git a/SWIPs/swip-60.md b/SWIPs/swip-60.md index 9a3fb3ba..dd01beb0 100644 --- a/SWIPs/swip-60.md +++ b/SWIPs/swip-60.md @@ -62,9 +62,13 @@ no mode enum; **modes are combinations of these parameters**. | `publishers` | `EXPLICIT_SINGLE` / `EXPLICIT_LIST` / `IMPLICIT` / `ALL` | who may author | | `admin` + `publisher_list` | eth addresses | set iff explicit publishers; with `EXPLICIT_LIST` the full publisher set is **fixed at genesis** (dynamic grants/revocations are deferred to a later revision) | | `history` | bool | deliver matching chunks already in the local store (mechanism in bps-history; a singlehop broker MAY refuse) | -| `po_min` | uint (default 16) | proximity constraint for implicit bindings: `PO(socAddr, anchor) ≥ po_min` | | `closed` | bool | no audience: subscribers are restricted to the publisher set (all and only publishers subscribe) | +The proximity constraint for implicit bindings is a **protocol constant**, not a cohort +parameter: `PO_MIN = 16`. (Making it a parameter invited proto3's unset-equals-0 +footgun — an omitted value silently disabling the constraint — and no use case varies +it.) + Broker **capacity is deliberately not a cohort parameter**: a cohort cannot dictate a remote node's connection count. Each broker enforces its own per-topic stream limit and answers `FULL` when it is exhausted. @@ -73,7 +77,7 @@ Binding semantics (dedup rule in parentheses): - **`ANCHOR`** — topic = full SOC/GSOC address; all messages share one address (dedup on the wrapped CAC). -- **`SOC_ID`** — topic = SOC id; any owner with `PO(socAddr(id, owner), anchor) ≥ po_min` +- **`SOC_ID`** — topic = SOC id; any owner with `PO(socAddr(id, owner), anchor) ≥ PO_MIN` qualifies (dedup on chunk address). - **`OWNER`** — topic = SOC owner; any id under the same PO constraint — MIC semantics (dedup on chunk address). @@ -153,9 +157,51 @@ Messages are defined in [bps.proto](assets/swip-60/bps.proto). Framing notes: ### API (WebSocket bridge) -WS clients see raw mode payloads only; all p2p framing is transparent. One p2p stream is -muxed to N local WS sessions per topic. Endpoint shape per bee -[#5435](https://github.com/ethersphere/bee/pull/5435). +One endpoint pair on the Bee API. Endpoint shape follows bee +[#5435](https://github.com/ethersphere/bee/pull/5435), generalised from its single +hardcoded mode to the full parameter space; serialization conventions follow the SOC +subscription family — GSOC/MIC/MOC (bee +[#5486](https://github.com/ethersphere/bee/pull/5486), +[#5497](https://github.com/ethersphere/bee/pull/5497)) — whose `/mic/subscribe/{owner}` +and `/moc/subscribe/{id}` endpoints are the storage-fed counterparts of the `OWNER` and +`SOC_ID` bindings, so a dApp switches between stored and live feeds without +reformatting. All p2p framing is transparent to WS clients; one p2p stream is muxed to +N local WS sessions per topic. + +**`GET /pubsub/{topic}`** — upgrades to a WebSocket session on the topic. `{topic}` is +the 32-byte topic hex-encoded, or an arbitrary string hashed to 32 bytes (mnemonic +topics). Query parameters: + +| parameter | maps to | meaning | +|---|---|---| +| `peer` | — | broker underlay multiaddr; required until broker discovery exists (bps-broker-discovery) — early deployments configure it | +| `binding`, `publishers`, `admin`, `publisher-list`, `closed`, `history` | `CohortSpec` | **presence of cohort parameters makes the session the opener**: the node sends `Open` with the assembled spec; absence makes it a joiner: the node sends `Subscribe(topic)` and learns the spec from the `Ack` echo | +| `owner` (+ `id` where the binding does not fix it) | `PublisherAuth` | **presence makes the session a publisher** (read–write); absence, a subscriber (read-only) | + +Headers: + +- `swarm-keep-alive` (seconds, default 60): ping period of the **local WS link only** — + not to be confused with the p2p layer, which has no keepalive. +- `swarm-soc-fields` (per bee [#5497](https://github.com/ethersphere/bee/pull/5497)): + comma-separated SOC fields serialized per outbound message — `address`, + `recoveredPubKey`, `identifier`, `signature`, `wrappedAddress`, `span`, `payload`; + default `payload`. This is how dApps on implicit-binding streams (`OWNER`, `SOC_ID`, + feed) attribute messages — no BPS-specific frame format. +- `swarm-cache-wrapped-chunk` (per bee + [#5497](https://github.com/ethersphere/bee/pull/5497)): when true, the wrapped chunk + of every incoming message is stored in the local cache, resolvable through the bytes + endpoint — for streams whose messages reference content larger than one chunk. + +**`GET /pubsub/`** — lists the node's active topics: topic address, cohort parameters, +own role (broker / subscriber), connected peers. + +**Signing — the key-holding rule.** Message signing is the dApp's business: **the node +never holds publisher keys**. Inbound (publisher → node): `sig ‖ span ‖ payload`, +signed client-side (bee-js); where the binding does not fix the SOC id (e.g. +`FEED_TOPIC` with a moving index), the frame is prefixed with the id: +`id ‖ sig ‖ span ‖ payload` **(?)**. The node assembles the SOC, validates it exactly +as a broker would, and publishes. End-to-end verification against the `Ack`-echoed +`CohortSpec` is performed by the local node — node and dApp are one trust domain. ### Configurations (worked examples) @@ -235,7 +281,9 @@ An implementation is conformant when: `CohortSpec`) and detects (only) liveness faults; 3. the two worked configurations above interoperate across independent implementations against the frames in [bps.proto](assets/swip-60/bps.proto); -4. a `FULL` refusal is issued at capacity — and nothing else is (no referral). +4. a `FULL` refusal is issued at capacity — and nothing else is (no referral); +5. the WS bridge round-trips both worked configurations end to end — open, publish, + subscribe — with all signing on the client side (the node holds no publisher keys). ## Backwards compatibility From 4ea5c9ed580f47ef9278a61bc282b23885b46fc6 Mon Sep 17 00:00:00 2001 From: zelig Date: Sat, 8 Aug 2026 05:30:56 +0200 Subject: [PATCH 06/14] swip-60: OWNER topic = keccak256(owner); idempotent Open; id unconstrained under explicit regimes (SWIP-65 pointer); worked API calls Co-Authored-By: Claude Fable 5 --- SWIPs/swip-60.md | 51 +++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 44 insertions(+), 7 deletions(-) diff --git a/SWIPs/swip-60.md b/SWIPs/swip-60.md index dd01beb0..dde35196 100644 --- a/SWIPs/swip-60.md +++ b/SWIPs/swip-60.md @@ -79,11 +79,20 @@ Binding semantics (dedup rule in parentheses): the wrapped CAC). - **`SOC_ID`** — topic = SOC id; any owner with `PO(socAddr(id, owner), anchor) ≥ PO_MIN` qualifies (dedup on chunk address). -- **`OWNER`** — topic = SOC owner; any id under the same PO constraint — MIC semantics - (dedup on chunk address). +- **`OWNER`** — topic = `keccak256(owner)`; any id under the same PO constraint — MIC + semantics (dedup on chunk address). The broker never inverts the hash: it recovers + the owner from the SOC signature and checks `keccak256(owner) == topic`; the topic + doubles as the PO anchor. - **`FEED_TOPIC`** — id = `keccak256(topic ‖ index)`; feed-update streams, graffiti MIC (dedup on chunk address). +Under **explicit publisher regimes**, legitimacy is list membership, not proximity — +the PO constraint does not apply — and where dedup is on the wrapped CAC (`ANCHOR`), +the SOC id does no protocol work: it is **unconstrained**, and publishers MAY use it as +a plain sequence number. The full sequential construction — signed as a feed update, +carried as a bare index, making missed updates detectable and recoverable — is +**self-indexed feeds, SWIP-65 (forthcoming)**. + ### Roles and capacity - **Broker**: the first full node contacted; root of the (here, depth = 1) multicast tree. @@ -142,6 +151,10 @@ Messages are defined in [bps.proto](assets/swip-60/bps.proto). Framing notes: protobuf-over-libp2p as bee protocols elsewhere. The first message on a fresh stream is `Open` (fixes a new cohort) or `Subscribe` (joins one — topic only, no cohort metadata); the broker answers with `Ack`, echoing the `CohortSpec` to subscribers. +- **`Open` is idempotent**: naming an already-open topic with an **identical** spec is + equivalent to `Subscribe`; with a mismatched spec it is answered `REJECTED`. + Implicit-publisher cohorts rely on this — the first subscriber is the opener, so a + client need not know whether it is first. - **Stream model rationale**: per-topic streams give per-cohort flow control, teardown and role typing, and match bee's protocol idiom. Because every frame carries the full SOC (self-contained, no per-stream handshake state), a later move to topic-muxed @@ -197,11 +210,35 @@ own role (broker / subscriber), connected peers. **Signing — the key-holding rule.** Message signing is the dApp's business: **the node never holds publisher keys**. Inbound (publisher → node): `sig ‖ span ‖ payload`, -signed client-side (bee-js); where the binding does not fix the SOC id (e.g. -`FEED_TOPIC` with a moving index), the frame is prefixed with the id: -`id ‖ sig ‖ span ‖ payload` **(?)**. The node assembles the SOC, validates it exactly -as a broker would, and publishes. End-to-end verification against the `Ack`-echoed -`CohortSpec` is performed by the local node — node and dApp are one trust domain. +signed client-side (bee-js). Where the binding does not fix the SOC id, the frame is +prefixed with it — for feed bindings the prefix is the bare index, the signed id being +the feed id `keccak256(topic ‖ index)` (self-indexed feeds, SWIP-65 forthcoming); +under explicit regimes with `ANCHOR` binding the id does no work and there is no +prefix. The node assembles the SOC, validates it exactly as a broker would, and +publishes. End-to-end verification against the `Ack`-echoed `CohortSpec` is performed +by the local node — node and dApp are one trust domain. + +**Worked API calls — the jam cohort** (see Configurations below). Seat A opens — cohort +parameters present ⇒ `Open`, `owner` present ⇒ read–write: + +``` +wss://node:1633/pubsub/jam-tuesday?peer= + &binding=anchor&publishers=list&closed=true + &admin=0xA…&publisher-list=0xB…,0xC…,0xD…&owner=0xA… +``` + +Seats B–D join — no cohort parameters ⇒ `Subscribe`, spec learned from the `Ack` echo: + +``` +wss://node:1633/pubsub/jam-tuesday?peer=&owner=0xB… +``` + +The join URL minus `owner` is the complete out-of-band invite (topic mnemonic + broker) +until broker discovery exists. A fifth peer's `Subscribe` gets `REJECTED`. A live MIC — +all SOCs of one owner, the light-client twin of `/mic/subscribe/{owner}` — is the +implicit case: first subscriber opens with +`?binding=owner&publishers=implicit` (idempotent `Open`), topic = `keccak256(owner)`, +read-only, `swarm-soc-fields: identifier,payload`. ### Configurations (worked examples) From 98e89183ebfecb02428726a1dc40db18f73b5b52 Mon Sep 17 00:00:00 2001 From: zelig Date: Sun, 9 Aug 2026 10:34:50 +0200 Subject: [PATCH 07/14] swip-60: ANCHOR dedup soundness note; SWIP-65 links Wrapped-CAC dedup under ANCHOR guards against unsolicited republication of old SOCs, and is sound only if the application guarantees distinct payloads - i.e. includes some index in the payload (per the SWIP-65 discussion: without self-indexing the sequence requirement moves above the protocol, unspecified). The two 'SWIP-65 (forthcoming)' anchors now link PR #106. Co-Authored-By: Claude Fable 5 --- SWIPs/swip-60.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/SWIPs/swip-60.md b/SWIPs/swip-60.md index dde35196..f6f3f836 100644 --- a/SWIPs/swip-60.md +++ b/SWIPs/swip-60.md @@ -76,7 +76,9 @@ answers `FULL` when it is exhausted. Binding semantics (dedup rule in parentheses): - **`ANCHOR`** — topic = full SOC/GSOC address; all messages share one address (dedup on - the wrapped CAC). + the wrapped CAC — the guard against unsolicited republication of old SOCs, sound only + under an application-level requirement: payloads are distinct, i.e. the application + includes some index in the payload). - **`SOC_ID`** — topic = SOC id; any owner with `PO(socAddr(id, owner), anchor) ≥ PO_MIN` qualifies (dedup on chunk address). - **`OWNER`** — topic = `keccak256(owner)`; any id under the same PO constraint — MIC @@ -91,7 +93,7 @@ the PO constraint does not apply — and where dedup is on the wrapped CAC (`ANC the SOC id does no protocol work: it is **unconstrained**, and publishers MAY use it as a plain sequence number. The full sequential construction — signed as a feed update, carried as a bare index, making missed updates detectable and recoverable — is -**self-indexed feeds, SWIP-65 (forthcoming)**. +**self-indexed feeds, [SWIP-65](https://github.com/ethersphere/SWIPs/pull/106)**. ### Roles and capacity @@ -212,7 +214,8 @@ own role (broker / subscriber), connected peers. never holds publisher keys**. Inbound (publisher → node): `sig ‖ span ‖ payload`, signed client-side (bee-js). Where the binding does not fix the SOC id, the frame is prefixed with it — for feed bindings the prefix is the bare index, the signed id being -the feed id `keccak256(topic ‖ index)` (self-indexed feeds, SWIP-65 forthcoming); +the feed id `keccak256(topic ‖ index)` (self-indexed feeds, +[SWIP-65](https://github.com/ethersphere/SWIPs/pull/106)); under explicit regimes with `ANCHOR` binding the id does no work and there is no prefix. The node assembles the SOC, validates it exactly as a broker would, and publishes. End-to-end verification against the `Ack`-echoed `CohortSpec` is performed From 10df5e9c98f0e3584fa44fcd8852236b1ed976fc Mon Sep 17 00:00:00 2001 From: zelig Date: Sat, 29 Aug 2026 13:32:11 +0200 Subject: [PATCH 08/14] swip-60 rev 4: five cohort configurations, an admin control plane, proved Auth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Revision after implementation feedback from the bee prototype (acud, PR #104 comment of 2026-08-17) and a restructuring pass. ## Cohort spec carries immutable policy; the roster does not Five configurations, distinguished by three fields: jam admin GRANTED spectators:false spectator-jam admin GRANTED spectators:true live-stream admin ADMIN_ONLY spectators:true group-chat admin ALL spectators:true implicit no admin SOC shape decides - New binding `MNEMONIC`: the topic names the cohort and constrains nothing — any SOC from any owner. This is what ALL needs; authorship there is unrestricted but never unattributable, since every message is still SOC-signed, so a group chat knows who said what without an authorised set to check against. - `publishers` = ADMIN_ONLY / GRANTED / ALL. ADMIN_ONLY is an immutable promise ("this stream will never have a second author"), not a roster that happens to be empty. - `spectators` replaces the previous `closed` and is enforceable, because Auth is recovered rather than asserted. It is the only refusal for identity in the protocol; openers MUST set it true under ALL and implicit, where every attached peer is already a potential author. - The admin is always in the publisher set. A non-publishing moderator is just an admin that never sends — being a publisher obliges nobody to publish. - `publisher_list`, `po_min` and `closed` are reserved in CohortSpec. ## The service feed — the admin's control plane owner = admin id = keccak256("bps-service:v1" || topic || index) index 0 GENESIS the CohortSpec, signed by the admin index n ROSTER the full publisher set at version n last END_OF_STREAM the admin closes the cohort, attributably The roster is dynamic and the spec is immutable, so the roster cannot live in it; grantee identities are also not public the way an admin's is. A feed rather than one constant-id slot: overwriting in place would make a stale roster undetectable, reintroducing forging-by-omission at the one point that decides who may write. Sequential indices make gaps visible, so withholding stays a liveness fault (SWIP-65 carries the construction). Ack now delivers the echoed CohortSpec, the admin-signed genesis SOC and the latest service SOC with its index, so a joiner verifies the cohort and its roster against the admin rather than the broker. END_OF_STREAM separates "over" from "the broker stopped relaying". Revocation is two-phase, and the boundary is the moment the reduced roster reaches subscribers. Before it the revoked peer cannot know, so its frames are dropped and TOLERATED — no penalty, no teardown, because it is not misbehaving. After it the peer has been told on the same feed as everyone else, so publishing is a protocol violation and the connection is broken. The announcement is what converts an unknowing publisher into a violating one: disconnecting first would punish a peer for a rule it had not been given, and never publishing the roster leaves the violation unable to begin at all, which is an ordinary visible withholding fault. It also makes the revocation legible to the rest of the cohort, which learns why a publisher fell silent from an admin-signed message rather than from an unattributable disconnection. ## Wire - `Open` and `Subscribe` wrapped in a `Hello` envelope. As bare frames they are byte-indistinguishable (length-delimited field 1 + optional Auth in field 2) and proto3's permissive unmarshalling makes a wrong guess succeed silently, misread the frame, and answer with a Status describing the wrong problem. (acud, finding 1.) - `Auth` carries a signature and no address: owner = ecrecover over H("bps-join:v1" || topic || admin), so identity and proof arrive in one operation and the handshake stays one frame each way. No libp2p peer id in the preimage — binding to the node would weld the publishing identity to the node holding the stream and leak an eth-identity/peer-id link on every join. The preimage is therefore static and replayable, which costs nothing: a replayed role is worthless without the signing key. The "bps-join:v1" separator keeps the join-signature space disjoint from the SOC-signature space the same keys serve. (acud, finding 2.) - Dedup horizon: implementation-defined but MUST be bounded; replay of an evicted message by a legitimate publisher is the accepted consequence. - Cohort lifetime: broker-side, not tied to the opener, reclaimable when unattached — except by END_OF_STREAM, which is attributable. - A conformant broker bounds how many cohorts it will create; `Open` is otherwise an unbounded allocation primitive. (acud, finding 3.) ## Prose New "Security considerations": the admin and the cohort are authenticated by the genesis message; the publisher role is proved, not asserted; defence in depth is the real guarantee, so no challenge round trip; audience control exists only as `spectators` and is not confidentiality — BPS offers none at any layer, and a bounded audience is payload encryption, application-side. "Why not gossipsub" gains both halves of the trade: rootward-then-leafward carries each edge exactly once, so a single-parented tree needs no duplicate suppression at all and beats a mesh on closely knit topologies — and the concession that a genuinely gossip-shaped use case should just use libp2p gossipsub. Publishers' direct attachment to the broker is now stated as a consequence of depth = 1 rather than a protocol invariant: bps-multihop forwards Publish rootward from the leaves, which is what lets an everyone-publishes cohort outgrow one broker. (SWIP-61 needs the matching change.) API: `publishers`/`spectators` query parameters, no publisher list, `auth` replacing `owner`, and POST /pubsub/{topic}/service for the admin's grants, revocations and close. Co-Authored-By: Claude Opus 5 --- SWIPs/.Rhistory | 0 SWIPs/assets/swip-60/bps.proto | 198 ++++++++++--- SWIPs/swip-60.md | 525 +++++++++++++++++++++++++++------ 3 files changed, 590 insertions(+), 133 deletions(-) create mode 100644 SWIPs/.Rhistory diff --git a/SWIPs/.Rhistory b/SWIPs/.Rhistory new file mode 100644 index 00000000..e69de29b diff --git a/SWIPs/assets/swip-60/bps.proto b/SWIPs/assets/swip-60/bps.proto index 7384870b..052da6bf 100644 --- a/SWIPs/assets/swip-60/bps.proto +++ b/SWIPs/assets/swip-60/bps.proto @@ -1,12 +1,21 @@ // Broadcast Pub/Sub (BPS) — protocol messages and types. // Spec: SWIP-60 (../../swip-60.md). // -// Revision 2 (2026-08-05), after review on PR #104: Connect split into -// Open/Subscribe (subscribers carry no cohort metadata), broker capacity -// removed from CohortSpec (it is broker-side policy, not a cohort parameter), -// Ping dropped (liveness/RTT are transport concerns), and every frame carries -// the full SOC (no handshake/data split). Field numbers renumbered — the -// draft has no deployed compatibility surface. +// Revision 7 (2026-08-25), per Viktor — the control plane splits out. +// +// The publisher roster leaves the CohortSpec: it is dynamic, the spec is +// immutable, and grantee identities are not public the way an admin's is. It +// travels instead as admin-signed SERVICE MESSAGES on a feed the admin owns, +// so that a subscriber verifies who may write against the admin's key rather +// than the broker's word, and so that gaps in the roster history are visible. +// What remains in the spec is immutable policy: admin, publisher regime, +// whether spectators are admitted. +// +// Earlier revisions of this draft, for the record: Open/Subscribe were wrapped +// in a Hello envelope (as bare frames they are indistinguishable on the wire, +// and proto3's permissive unmarshalling makes a wrong guess succeed silently); +// Auth became a recovered signature rather than an asserted address; `closed` +// was removed in favour of joining deciding a role. // // Enum zero values (*_UNSPECIFIED): proto3 requires a zero value; it is // deliberately NOT a legitimate wire value. It exists so that an unset field @@ -23,7 +32,7 @@ package bps; option go_package = "github.com/ethersphere/bee/v2/pkg/bps/pb"; // --------------------------------------------------------------------------- -// Cohort genesis — the primitive decisions whose combinations are the "modes" +// Cohort genesis — immutable policy. The roster is NOT here (see ServiceKind). // --------------------------------------------------------------------------- // What the topic binds to (see SWIP-60: binding semantics). @@ -31,80 +40,173 @@ enum TopicBinding { TOPIC_BINDING_UNSPECIFIED = 0; // invalid on the wire (see header note) ANCHOR = 1; // topic = full SOC/GSOC address; dedup on the wrapped CAC SOC_ID = 2; // topic = SOC id; any owner with PO(addr, anchor) >= PO_MIN - OWNER = 3; // topic = SOC owner; any id with PO(addr, anchor) >= PO_MIN (MIC) - FEED_TOPIC = 4; // id = keccak256(topic ‖ index); graffiti MIC / feed streams + OWNER = 3; // topic = keccak256(owner); any id, same PO constraint (MIC) + FEED_TOPIC = 4; // id = keccak256(topic || index); feed-update streams + MNEMONIC = 5; // the topic names the cohort and constrains nothing: any SOC + // from any owner qualifies (dedup on chunk address). What + // PublisherRegime.ALL needs -- authorship unrestricted, but + // never unattributable, since every message is SOC-signed. + // APPENDED, not inserted: 1-4 keep the numbering the bee + // prototype already implements. } -// Who may author. +// Who may author, when the cohort has an admin. With no admin the cohort is +// implicit: authorship follows the binding's SOC shape and this does not apply. enum PublisherRegime { PUBLISHER_REGIME_UNSPECIFIED = 0; // invalid on the wire (see header note) - EXPLICIT_SINGLE = 1; // opener is the sole publisher (live streaming) - EXPLICIT_LIST = 2; // set fixed at genesis: admin + publisher_list - // (dynamic grants/revocations: later revision) - IMPLICIT = 3; // authorship implied by the topic binding (PO constraint) - ALL = 4; // every peer publishes (gossipsub-equivalent cohort) + ADMIN_ONLY = 1; // the admin alone, for the cohort's whole life (live stream) + GRANTED = 2; // the admin plus whoever the current roster names (jam) + ALL = 3; // anyone attached; needs MNEMONIC binding (group chat) } // Fixed by the cohort's opener; immutable for the cohort's lifetime. -// NOTE: broker capacity is NOT a cohort parameter — a cohort cannot dictate a +// NOTE: broker capacity is NOT a cohort parameter -- a cohort cannot dictate a // remote node's connection count. Each broker enforces its own per-topic // stream limit and answers FULL when it is exhausted. -// NOTE: the proximity constraint for implicit bindings is a protocol -// constant, PO_MIN = 16 — not a cohort parameter (a proto3 unset uint32 is -// indistinguishable from 0, which would silently disable the constraint; -// and no use case varies it). +// NOTE: the proximity constraint for implicit bindings is a protocol constant, +// PO_MIN = 16 -- not a cohort parameter (a proto3 unset uint32 is +// indistinguishable from 0, which would silently disable the constraint; and +// no use case varies it). message CohortSpec { - bytes topic = 1; // 32 bytes, meaning per binding - TopicBinding binding = 2; - PublisherRegime publishers = 3; - bool history = 4; // deliver matching chunks from the local store - bytes admin = 5; // 20-byte eth address; set iff EXPLICIT_* - repeated bytes publisher_list = 6; // 20-byte eth addresses, excl. admin; - // set iff EXPLICIT_LIST - reserved 7; // was po_min — now protocol constant PO_MIN - bool closed = 8; // no audience: subscribers restricted to the publishers + bytes topic = 1; // 32 bytes, meaning per binding + TopicBinding binding = 2; + bytes admin = 5; // 20-byte eth address: the opener, the + // cohort's authority, and always a member of + // its publisher set. Absent (length 0) => + // implicit authorship, and `publishers` and + // `spectators` do not apply. Length is the + // discriminator, so absent and set are + // intrinsically distinguishable. + PublisherRegime publishers = 3; // set iff admin is set + bool spectators = 9; // may peers outside the publisher set join? + // Real only under ADMIN_ONLY and GRANTED; + // under ALL and implicit authorship every + // attached peer is already a potential + // author, so openers MUST set it true. + bool history = 4; // deliver matching chunks from the local store + reserved 6, 7, 8; + // 6 was `publisher_list` -- now dynamic, carried as ServiceKind.ROSTER; + // 7 was `po_min` -- now the protocol constant PO_MIN; + // 8 was `closed` -- superseded by `spectators`, which is enforceable + // now that Auth is recovered rather than asserted. +} + +// --------------------------------------------------------------------------- +// The service feed — the admin's control plane. +// +// Service messages are ordinary SOCs on the ordinary path, owned by the admin: +// +// owner = admin id = keccak256("bps-service:v1" || topic || index) +// +// so a broker relays them and cannot author them, and a subscriber checks them +// with the same code as any broadcast. Sequential indices (SWIP-65 self-indexed +// feeds) make gaps visible: a single constant-id slot overwritten in place +// would make a stale roster undetectable, reintroducing forging-by-omission at +// the one point that decides who may write. +// --------------------------------------------------------------------------- + +enum ServiceKind { + SERVICE_KIND_UNSPECIFIED = 0; // invalid on the wire (see header note) + GENESIS = 1; // index 0: the CohortSpec, signed by the admin. Proves the + // cohort was opened by the address it names. + ROSTER = 2; // the full publisher set as of this index (not a delta) + END_OF_STREAM = 3; // the admin closes the cohort, attributably +} + +// The payload of a service SOC. +message ServiceMessage { + ServiceKind kind = 1; + CohortSpec spec = 2; // set iff GENESIS + repeated bytes publishers = 3; // set iff ROSTER: 20-byte eth addresses, the + // complete set excl. admin (who is always a + // publisher). Full state, not a delta, so a + // reader needs only the latest it can verify. } // --------------------------------------------------------------------------- // Stream establishment, stream name "pubsub/1.0.0" — one stream per (peer, topic). -// The first message on a fresh stream is Open (fixes a new cohort) or -// Subscribe (joins an existing one); the broker answers with Ack. +// The first message on a fresh stream is Hello, carrying Open (fixes a new +// cohort) or Subscribe (joins an existing one); the broker answers with Ack. +// The first frame settles the peer's role. // --------------------------------------------------------------------------- -// Opener -> broker: the one peer that fixes the cohort. +// Peer -> broker: the first frame on a fresh stream. +// +// The envelope is load-bearing. As bare frames, Open and Subscribe are +// indistinguishable: both encode as a length-delimited field 1 followed by an +// optional Auth in field 2. proto3 unmarshalling is permissive, so a receiver +// that guesses wrong does not fail -- it silently succeeds and misreads the +// frame, then answers with a Status that describes the wrong problem. +message Hello { + oneof handshake { + Open open = 1; + Subscribe subscribe = 2; + } +} + +// Opener -> broker: the admin, fixing the cohort. The broker recovers the +// address from `auth` and checks it against cohort.admin before accepting. message Open { - CohortSpec cohort = 1; - PublisherAuth auth = 2; // present iff the opener publishes (explicit regimes) + CohortSpec cohort = 1; + Auth auth = 2; // required iff cohort.admin is set } -// Joiner -> broker: names the topic — nothing more. Subscribers carry no -// cohort metadata; auth is present iff the joiner publishes (publishers -// connect directly to the broker). +// Joiner -> broker: names the topic — nothing more. Joiners carry no cohort +// metadata; auth is present iff the joiner claims a publisher role. message Subscribe { - bytes topic = 1; // 32 bytes - PublisherAuth auth = 2; // present iff publisher + bytes topic = 1; // 32 bytes + Auth auth = 2; } -message PublisherAuth { - bytes owner = 1; // 20-byte eth address of the SOC owner key - bytes id = 2; // 32-byte SOC id, when the binding fixes it +// Proved, not asserted -- and in one operation: ecrecover yields the owner +// address AND proves possession of its key, so no challenge round trip. +// +// owner = ecrecover( H("bps-join:v1" || topic || admin), signature ) +// +// The preimage is deliberately static and free of any node identity. Signing +// over the libp2p peer id would make this unreplayable, but would weld the +// publishing identity to the node holding the stream: the key could not be used +// from a second node without re-signing, and every join would link an eth +// identity to a peer id for anyone watching. An owner's identity is its own. +// +// The accepted consequence: a static preimage is replayable. It costs nothing, +// because a replayed role is worthless -- the replayer cannot sign, so its +// frames are dropped at Publish. Auth spares the broker from carrying peers +// whose frames could only ever be dropped; authorship rests on the message +// signature, never on the handshake. +// +// "bps-join:v1" is load-bearing: the same secp256k1 keys sign SOCs over +// (id || wrappedAddress), and the separator is what stops a join signature from +// ever being reinterpreted as a chunk signature, or the reverse. +message Auth { + bytes signature = 1; // 65 bytes + bytes id = 2; // 32-byte SOC id, where the binding does not fix it } // Broker -> peer, answering Open or Subscribe. The echoed CohortSpec lets a -// subscriber verify every message end-to-end against the topic binding. +// subscriber verify every message end-to-end against the topic binding; the two +// service SOCs let it verify the cohort and the roster against the ADMIN, +// rather than taking the broker's word for either. message Ack { - Status status = 1; - CohortSpec cohort = 2; // set iff status == OK + Status status = 1; + CohortSpec cohort = 2; // set iff status == OK + Soc genesis = 3; // service feed index 0, iff the cohort has an admin + Soc service = 4; // latest service SOC (may equal genesis) + uint64 index = 5; // its feed index, so gaps are visible } enum Status { STATUS_UNSPECIFIED = 0; // invalid on the wire (see header note) OK = 1; FULL = 2; // broker at its per-topic capacity; - // a singlehop broker refuses — nothing else + // a singlehop broker refuses -- nothing else UNKNOWN_TOPIC = 3; // Subscribe for a topic the broker does not serve - REJECTED = 4; // e.g. publisher not on the list, invalid auth, - // non-publisher Subscribe on a closed cohort + REJECTED = 4; // the SPEC is unacceptable -- e.g. Open naming an + // already-open topic with a mismatched CohortSpec, or + // an Auth that does not recover to cohort.admin. + // Also the answer to a non-publisher Subscribe when + // spectators == false -- the ONLY case in which a + // peer is refused for who it is. } // --------------------------------------------------------------------------- diff --git a/SWIPs/swip-60.md b/SWIPs/swip-60.md index f6f3f836..97182c13 100644 --- a/SWIPs/swip-60.md +++ b/SWIPs/swip-60.md @@ -13,9 +13,11 @@ PubSub SWIP (ethersphere/SWIPs PR #93) into work-package-sized SWIPs. Companion assets/swip-60/bps.proto. --> - **Business line**: real-time topic streams for dApps without storing chunks or polling — - enough on its own for small closed collaboration cohorts (collaborative remix editing, a - strudel livecoding session, multiparty games) and basic single-publisher limited-audience - live streaming. + enough on its own for the five cohort shapes it defines: **jam** (a closed set of authors: + collaborative remix editing, a strudel livecoding session, a multiparty game), + **spectator-jam** (the same before an audience), **live-stream** (one author, an audience), + **group-chat** (everyone speaks) and **implicit** (a live feed with no authority at all). + An admin grants and revokes authors while a cohort runs, without redefining it. - **Dev line**: implement one libp2p protocol (`pubsub/1.0.0`, messages in [bps.proto](assets/swip-60/bps.proto)) plus a WebSocket bridge on the Bee API; done when a broker, publishers and subscribers interoperate per the conformance section. Groundwork @@ -51,30 +53,53 @@ Per topic-cohort: ### Cohort genesis: the parameters -A cohort is fully described by a `CohortSpec` ([bps.proto](assets/swip-60/bps.proto)), -fixed the moment the first peer contacts a BPS-speaking full node with a topic. There is -no mode enum; **modes are combinations of these parameters**. +A cohort is fully described by a `CohortSpec` ([bps.proto](assets/swip-60/bps.proto)), fixed +the moment the first peer contacts a BPS-speaking full node with a topic, and **immutable for +the cohort's lifetime**. There is no mode enum; **modes are combinations of these +parameters**. | parameter | values | meaning | |---|---|---| | `topic` | 32 bytes | interpreted per `binding` | -| `binding` | `ANCHOR` / `SOC_ID` / `OWNER` / `FEED_TOPIC` | what the topic binds to; fixes which SOCs qualify as messages and the dedup rule | -| `publishers` | `EXPLICIT_SINGLE` / `EXPLICIT_LIST` / `IMPLICIT` / `ALL` | who may author | -| `admin` + `publisher_list` | eth addresses | set iff explicit publishers; with `EXPLICIT_LIST` the full publisher set is **fixed at genesis** (dynamic grants/revocations are deferred to a later revision) | +| `binding` | `MNEMONIC` / `ANCHOR` / `SOC_ID` / `OWNER` / `FEED_TOPIC` | what the topic binds to; fixes which SOCs qualify as messages and the dedup rule | +| `admin` | eth address | the opener, the cohort's authority, and a member of its publisher set. **Absent ⇒ implicit authorship**, and the two fields below do not apply | +| `publishers` | `ADMIN_ONLY` / `GRANTED` / `ALL` | who may author besides the admin | +| `spectators` | bool | whether peers outside the publisher set may join | | `history` | bool | deliver matching chunks already in the local store (mechanism in bps-history; a singlehop broker MAY refuse) | -| `closed` | bool | no audience: subscribers are restricted to the publisher set (all and only publishers subscribe) | -The proximity constraint for implicit bindings is a **protocol constant**, not a cohort -parameter: `PO_MIN = 16`. (Making it a parameter invited proto3's unset-equals-0 -footgun — an omitted value silently disabling the constraint — and no use case varies -it.) - -Broker **capacity is deliberately not a cohort parameter**: a cohort cannot dictate a -remote node's connection count. Each broker enforces its own per-topic stream limit and -answers `FULL` when it is exhausted. +**The publisher list is deliberately not here.** It is dynamic — an admin grants and revokes +while the cohort runs — and this spec is immutable, so it cannot live in it without making +every roster change a new cohort. It is also not public in the way the rest of the spec is: +the owner of a stream, or of a co-edited file, is naturally known to its subscribers, but the +other grantees are not. The roster therefore travels as **admin-signed service messages on a +feed of its own** (below), where it changes without the cohort changing, and where a +subscriber verifies it against the admin's key rather than against the broker's word. + +#### The five configurations + +| configuration | `admin` | `publishers` | `spectators` | who may author | +|---|---|---|---|---| +| **jam** | set | `GRANTED` | false | admin + current grantees; nobody else attends | +| **spectator-jam** | set | `GRANTED` | true | admin + current grantees, before an audience | +| **live-stream** | set | `ADMIN_ONLY` | true | the admin alone, before an audience | +| **group-chat** | set | `ALL` | true | anyone attached — each peer signs its own SOCs | +| **implicit** | absent | — | true | whoever the binding's SOC shape admits | + +`spectators` does real work only in the `GRANTED` and `ADMIN_ONLY` rows — which is exactly +the audience / no-audience distinction. Under `ALL` and under implicit authorship every +attached peer is already a potential author, so excluding non-publishers excludes nobody; +openers MUST set it true there. + +**The admin is always in the publisher set**, and being a publisher obliges nobody to +publish — no peer waits on another — so a practically non-publishing **moderator** needs no +role of its own: it is simply an admin that never sends. Binding semantics (dedup rule in parentheses): +- **`MNEMONIC`** — the topic constrains nothing: it names the cohort and no more. Any SOC + from any owner qualifies (dedup on chunk address). This is what `ALL` needs. Authorship is + unrestricted but never *unattributable*: every message is still SOC-signed, so a group chat + knows exactly who said what without there being an authorised set to check it against. - **`ANCHOR`** — topic = full SOC/GSOC address; all messages share one address (dedup on the wrapped CAC — the guard against unsolicited republication of old SOCs, sound only under an application-level requirement: payloads are distinct, i.e. the application @@ -88,27 +113,179 @@ Binding semantics (dedup rule in parentheses): - **`FEED_TOPIC`** — id = `keccak256(topic ‖ index)`; feed-update streams, graffiti MIC (dedup on chunk address). -Under **explicit publisher regimes**, legitimacy is list membership, not proximity — -the PO constraint does not apply — and where dedup is on the wrapped CAC (`ANCHOR`), -the SOC id does no protocol work: it is **unconstrained**, and publishers MAY use it as -a plain sequence number. The full sequential construction — signed as a feed update, -carried as a bare index, making missed updates detectable and recoverable — is -**self-indexed feeds, [SWIP-65](https://github.com/ethersphere/SWIPs/pull/106)**. +Under **explicit authorship** legitimacy is membership of the current roster, not proximity: +the PO constraint does not apply. Under **implicit authorship** nothing is checked against a +roster — there is none, and no admin either — and authorship is decided by **the shape of the +SOC** the binding fixes: + +| binding | SOC shape | implicit publishers | who qualifies | +|---|---|---|---| +| `MNEMONIC` | any | **any** | anyone; the cohort has no authority and no roster | +| `ANCHOR` | GSOC | **one** | the holder of the shared GSOC key — one address, one identity | +| `OWNER` | MIC | **one** | the owner the topic names (`topic = keccak256(owner)`); the id varies | +| `FEED_TOPIC` | feed | **one** | the feed's owner; the id is `keccak256(topic ‖ index)` | +| `SOC_ID` | MOC | **many** | any owner that mines `PO(socAddr(id, owner), anchor) ≥ PO_MIN`; the id is fixed, the owner varies | + +Where authorship is explicit and dedup is on the wrapped CAC (`ANCHOR`), the SOC id does no +protocol work: it is **unconstrained**, and publishers MAY use it as a plain sequence number. +The full sequential construction — signed as a feed update, carried as a bare index, making +missed updates detectable and recoverable — is **self-indexed feeds, +[SWIP-65](https://github.com/ethersphere/SWIPs/pull/106)**. + +The proximity constraint for implicit bindings is a **protocol constant**, not a cohort +parameter: `PO_MIN = 16`. (Making it a parameter invited proto3's unset-equals-0 +footgun — an omitted value silently disabling the constraint — and no use case varies +it.) + +Broker **capacity is deliberately not a cohort parameter**: a cohort cannot dictate a +remote node's connection count. Each broker enforces its own per-topic stream limit and +answers `FULL` when it is exhausted. + +**Cohort lifetime** is broker-side in the same way, with one exception. A cohort lives for as +long as its broker keeps serving the topic; it is not tied to its opener, and a broker MAY +reclaim a cohort that has no attached streams, which is unobservable beyond a later +`Subscribe` being answered `UNKNOWN_TOPIC`. The exception is the **end-of-stream** service +message, by which an admin ends its own cohort deliberately and *attributably* (below). + +### The service feed: the admin's control plane + +Everything the admin says about the cohort — that it exists, who may write to it, and that it +is over — travels as SOCs on a feed the admin owns: + +``` +owner = admin id = keccak256("bps-service:v1" ‖ topic ‖ index) +``` + +| index | message | carries | +|---|---|---| +| `0` | **genesis** | the `CohortSpec`, signed by the admin | +| `n` | **roster** | the full publisher set as of version `n` | +| last | **end-of-stream** | the cohort is closed by its admin | + +Three properties follow, and each of them is the point: + +- **The admin is authenticated, and so is the spec.** A broker cannot invent a cohort in + somebody's name: `admin` is an address anyone can read, and index 0 is that address's own + signature over the spec it is claimed to have opened. Nothing else in the handshake needs + to be trusted. +- **It is a feed, not a single mutable slot.** The obvious alternative — one constant-id SOC + overwritten in place — makes a stale roster **undetectable**, which would reintroduce + forging-by-omission at the one point that decides who may write. Sequential indices make + gaps visible, so withholding stays a *liveness* fault like every other withholding in this + protocol, and **self-indexing** feeds ([SWIP-65](https://github.com/ethersphere/SWIPs/pull/106)) + carry the construction. +- **The roster is verified end-to-end, like every message.** Service messages are ordinary + SOCs on the ordinary path — storable, re-fetchable, and checked with the same code as any + broadcast. A broker relays them; it cannot author them. + +**`Ack` therefore carries the genesis SOC and the latest service SOC** (with its index) +alongside the echoed `CohortSpec`. A joiner learns who may write from the admin, not from the +broker, before it has received a single message. + +#### `Auth`: recovered, not asserted, and not tied to a node + +`Auth` carries **a signature and no address**: the owner is the ecrecover output, so +presenting it is possession of a key, not a claim about one — identity and proof arrive in +the same operation and the handshake stays one frame each way, with no challenge round trip. + +``` +owner = ecrecover( H( "bps-join:v1" ‖ topic ‖ admin ), signature ) +``` + +The preimage is deliberately **static, and free of any node identity**. Signing over the +libp2p peer id would make the credential unreplayable, but at the cost of welding the +publishing identity to the node holding the stream: the same key could not be used from a +second node without re-signing, and every join would link an eth identity to a peer id for +anyone watching. Neither is acceptable — an owner's identity is its own, not its node's. + +The consequence, taken deliberately: a static preimage is **replayable**. It costs nothing, +because a replayed role is worthless — the replayer cannot sign, so every frame it sends is +dropped at `Publish`. What `Auth` buys is that the broker need not carry peers whose frames +could only ever be dropped; **authorship rests on the message signature, never on the +handshake.** + +The **`"bps-join:v1"` domain separator is load-bearing**. These are the same secp256k1 keys +that sign SOCs, over the preimage `id ‖ wrappedAddress`. Without separation a join signature +could be reinterpreted as a chunk signature, or a chunk signature coaxed out of a peer and +replayed as a join. The prefix makes the two preimage spaces disjoint by construction. + +Under implicit authorship there is no `Auth` at all: the SOC itself is the credential, and +its shape is checked at `Publish`. + +### The first frame settles the role + +A peer's role is fixed by its **first frame**, before any data flows: + +- the **admin** sends `Open`, carrying the `CohortSpec` and its `Auth`. The broker recovers + the address, checks it against `CohortSpec.admin`, and stores the genesis service SOC; +- everyone else sends `Subscribe`, optionally carrying `Auth`. The broker recovers the + address and matches it against the **current roster**: + +| outcome | `spectators: true` | `spectators: false` | +|---|---|---| +| recovered address is in the roster | joins as **publisher** | joins as **publisher** | +| no match, or no `Auth` | joins as **spectator**, read-only | `REJECTED` | + +`spectators: false` is the only configuration in which a peer is turned away for *who it is*, +and it is enforceable precisely because `Auth` is recovered rather than asserted. Everywhere +else `REJECTED` means the *spec* is unacceptable — an `Open` naming an already-open topic with +a mismatched spec — and `FULL` means capacity, nothing more. + +#### Grant and revocation + +An admin changes the roster by publishing the next service message; the cohort spec never +changes. A **grant** takes effect for the granted peer on its next join, or immediately if it +is already attached as a spectator. + +A **revocation** has two phases, and the boundary between them is the moment the reduced +roster reaches subscribers: + +1. **Before it is published**, the revoked peer has no way to know it has been revoked — + nothing has told it. Its `Publish` frames are therefore **dropped and tolerated**: + silently ignored, no penalty, the connection untouched. There is nothing else a broker can + honestly do, because the peer is not misbehaving. +2. **After it is published**, the peer has been told — it receives the service message like + every other subscriber, on the same feed. Publishing from that point is a **protocol + violation**, and the broker MUST break the connection. + +The announcement is therefore not only for the audience's benefit: **it is what converts an +unknowing publisher into a violating one.** A broker that tore the stream down before +publishing the reduced roster would be punishing a peer for a rule it had not been given; a +broker that never publishes it leaves everyone — the revokee included — in a state where the +violation can never begin, which is an ordinary, visible withholding fault. The penalty +itself is the protocol's existing one: repeated invalid frames end the connection +(blocklisting policy). + +Announcing first also makes the revocation legible to everyone else: subscribers learn *why* +a publisher fell silent from an admin-signed message rather than inferring it from a +disconnection they cannot attribute. + ### Roles and capacity - **Broker**: the first full node contacted; root of the (here, depth = 1) multicast tree. Enforces its own per-topic capacity. **At capacity it MUST answer `Open`/`Subscribe` with a refusal** (`FULL`); referral to another attachment point is reserved for - bps-multihop — a singlehop-only broker simply refuses. -- **Opener**: the one peer that fixes the `CohortSpec` (`Open`); with explicit publisher - regimes the opener publishes. -- **Publisher**: sends and receives. MUST be directly connected to the broker; direct - connection is necessary, not sufficient — with explicit publishers, the genesis list - decides. -- **Subscriber**: receives only; joins by naming the topic (`Subscribe`) and carries no - cohort metadata — the broker echoes the `CohortSpec` back so every message can be - verified end-to-end. Does not exist in `closed` cohorts. + bps-multihop — a singlehop-only broker simply refuses. Because `Open` is an allocation + primitive available to any peer, a conformant broker also bounds **how many cohorts it + will create**, not only the streams within one; the two limits are independent policy. +- **Admin = opener**: the one peer that fixes the `CohortSpec` (`Open`), always a member of + the publisher set, and the cohort's only authority: it grants, revokes and ends, each by + publishing a service message. Its address is public in the spec — as a stream's or a + co-edited file's owner naturally is — while its grantees' are not. An admin that never + sends is a **moderator**; no separate role is needed, since being a publisher obliges + nobody to publish. +- **Publisher**: sends and receives. At depth = 1 every peer is attached to the broker, so + publishers are too — this is a **consequence of singlehop, not a protocol invariant**. + bps-multihop lifts it by forwarding `Publish` rootward as well as `Broadcast` leafward, + so a publisher may sit several hops out; that is what lets an everyone-publishes cohort + grow past one broker's capacity. Attachment is in any case necessary, not sufficient — + under explicit authorship, the current roster decides. +- **Spectator**: receives only; joins by naming the topic (`Subscribe`) and carries no + cohort metadata — the broker echoes the `CohortSpec` and the admin's service SOCs back, so + the cohort, its roster and every message are verified end-to-end. Every peer receives, so + publishing is the *additional* capability and this role is what remains without it; a + cohort with `spectators: false` has none. ### Information flow @@ -121,11 +298,11 @@ sequenceDiagram participant SN as subscriber's bee node
(WS bridge + mux) participant SD as subscriber dApp(s) - PN->>B: Open(CohortSpec, auth) - Note over PN,B: opener fixes the cohort; publisher ⇒
direct connection to broker + PN->>B: Hello(Open(CohortSpec, Auth)) + Note over PN,B: opener fixes the cohort and is its admin
at depth = 1 every publisher is attached to the broker B-->>PN: Ack(OK) - SN->>B: Subscribe(topic) - B-->>SN: Ack(OK, CohortSpec) + SN->>B: Hello(Subscribe(topic, Auth?)) + B-->>SN: Ack(OK, CohortSpec, genesis SOC, latest ROSTER) Note over B,SN: echoed spec ⇒ subscriber verifies
every message end-to-end PD->>PN: WS: payload @@ -151,8 +328,17 @@ Messages are defined in [bps.proto](assets/swip-60/bps.proto). Framing notes: - Transport: libp2p stream `pubsub/1.0.0`, one stream per (peer, topic), protobuf-over-libp2p as bee protocols elsewhere. The first message on a fresh stream is - `Open` (fixes a new cohort) or `Subscribe` (joins one — topic only, no cohort - metadata); the broker answers with `Ack`, echoing the `CohortSpec` to subscribers. + **`Hello`**, carrying either `Open` (fixes a new cohort) or `Subscribe` (joins one — + topic only, no cohort metadata); the broker answers with `Ack`, carrying the echoed + `CohortSpec` together with the admin-signed genesis SOC and the latest service SOC, so + the joiner verifies the cohort and its roster against the admin rather than the broker. +- **Why the `Hello` envelope**: as bare frames, `Open` and `Subscribe` are + indistinguishable on the wire — both are a length-delimited field 1 followed by an + optional `Auth` in field 2 — and proto3's permissive unmarshalling means a + receiver that guesses wrong does not fail: it succeeds and misreads the frame, then + rejects it for an unrelated reason with a misleading `Status`. The `oneof` makes the + choice explicit at no cost. (The alternative — two libp2p protocol ids — needs no proto + change but splits the one-stream-per-(peer, topic) model across two stream names.) - **`Open` is idempotent**: naming an already-open topic with an **identical** spec is equivalent to `Subscribe`; with a mismatched spec it is answered `REJECTED`. Implicit-publisher cohorts rely on this — the first subscriber is the opener, so a @@ -169,6 +355,13 @@ Messages are defined in [bps.proto](assets/swip-60/bps.proto). Framing notes: constraint holds where applicable, sender is a legitimate publisher, message is not a duplicate per the binding's dedup rule. Invalid ⇒ drop; repeated invalid ⇒ disconnect (blocklisting policy). +- **The dedup *horizon* is implementation-defined, but it MUST be bounded**: the binding + fixes what counts as a duplicate, not how far back the broker remembers, and an + unbounded seen-set is a memory-exhaustion vector. A broker keeps a bounded window over + recent message identifiers; the accepted consequence is that a legitimate publisher can + overrun that window and replay an evicted message. Applications that cannot tolerate + replay carry their own sequencing — which the sequential construction of + [SWIP-65](https://github.com/ethersphere/SWIPs/pull/106) gives for free. ### API (WebSocket bridge) @@ -190,8 +383,13 @@ topics). Query parameters: | parameter | maps to | meaning | |---|---|---| | `peer` | — | broker underlay multiaddr; required until broker discovery exists (bps-broker-discovery) — early deployments configure it | -| `binding`, `publishers`, `admin`, `publisher-list`, `closed`, `history` | `CohortSpec` | **presence of cohort parameters makes the session the opener**: the node sends `Open` with the assembled spec; absence makes it a joiner: the node sends `Subscribe(topic)` and learns the spec from the `Ack` echo | -| `owner` (+ `id` where the binding does not fix it) | `PublisherAuth` | **presence makes the session a publisher** (read–write); absence, a subscriber (read-only) | +| `binding`, `admin`, `publishers`, `spectators`, `history` | `CohortSpec` | **presence of cohort parameters makes the session the opener**: the node sends `Open` with the assembled spec; absence makes it a joiner: the node sends `Subscribe(topic)` and learns the spec from the `Ack` echo. `admin` omitted ⇒ implicit authorship. No publisher list here — it is not part of the spec | +| `auth` (+ `id` where the binding does not fix it) | `Auth` | 65-byte join signature over `H("bps-join:v1" ‖ topic ‖ admin)`. **Presence claims a publisher role** (read–write); absence, a spectator (read-only). Signed client-side, like every other signature here — the node holds no publisher keys, and the signature is over no node identity, so the same key works from any node | + +**`POST /pubsub/{topic}/service`** — the admin's control plane: submits a service message +(`ROSTER` or `END_OF_STREAM`) as the next update on the service feed. The SOC is signed +client-side by the admin key; the node relays it. Granting or revoking a publisher is one +call here and touches no cohort parameter. Headers: @@ -226,66 +424,131 @@ parameters present ⇒ `Open`, `owner` present ⇒ read–write: ``` wss://node:1633/pubsub/jam-tuesday?peer= - &binding=anchor&publishers=list&closed=true - &admin=0xA…&publisher-list=0xB…,0xC…,0xD…&owner=0xA… + &binding=anchor&admin=0xA…&publishers=granted&spectators=false&auth=0x3f2a… ``` Seats B–D join — no cohort parameters ⇒ `Subscribe`, spec learned from the `Ack` echo: ``` -wss://node:1633/pubsub/jam-tuesday?peer=&owner=0xB… +wss://node:1633/pubsub/jam-tuesday?peer=&auth=0x9c14… ``` -The join URL minus `owner` is the complete out-of-band invite (topic mnemonic + broker) -until broker discovery exists. A fifth peer's `Subscribe` gets `REJECTED`. A live MIC — -all SOCs of one owner, the light-client twin of `/mic/subscribe/{owner}` — is the -implicit case: first subscriber opens with -`?binding=owner&publishers=implicit` (idempotent `Open`), topic = `keccak256(owner)`, -read-only, `swarm-soc-fields: identifier,payload`. +Seats B–D are not named in this URL and never appear in a cohort parameter: A grants them +with a `POST /pubsub/jam-tuesday/service` carrying a `ROSTER` message, and can revoke or add a +fifth seat later without any of the above changing. Each seat is sorted into the publisher +role by the address recovered from its `auth`; because `spectators` is false, a peer with no +listed key is `REJECTED` rather than admitted read-only. The join URL minus `auth` is the +complete out-of-band invite (topic mnemonic + broker) until broker discovery exists — and it +is genuinely an invite: only a holder of a rostered key can turn it into a session at all. +A live MIC — all SOCs of one owner, the light-client twin +of `/mic/subscribe/{owner}` — is the implicit case: first subscriber opens with +`?binding=owner`, no `admin` and no `auth` (idempotent `Open`), +topic = `keccak256(owner)`, read-only, `swarm-soc-fields: identifier,payload`. ### Configurations (worked examples) -Modes are rows over the parameters; two normative examples: +The five configurations, as `CohortSpec` rows. + +**Jam** — a 4-seat collaborative remix edit, a strudel livecoding session, a multiparty game. + +``` +binding: ANCHOR (topic = mnemonic anchor) admin: 0xA… +publishers: GRANTED spectators: false history: false +``` + +Seat A opens; B, C and D are granted by a `ROSTER` service message, and each is sorted into +the publisher role on joining because the address recovered from its `Auth` is on the roster +it can verify against A's key. A fifth peer is `REJECTED` — this is the one configuration in +which a peer is refused for who it is, and it is enforceable because `Auth` is recovered, not +asserted. A may grant a fifth seat, or revoke one, without the cohort spec changing at all. +Confidentiality is still not on offer: the broker holds plaintext, and a jam that needs it +encrypts payloads. + +**Spectator-jam** — the same, opened to an audience. + +``` +binding: ANCHOR admin: 0xA… +publishers: GRANTED spectators: true history: false +``` + +Identical authorship, but an unrecognised joiner is admitted read-only instead of refused. +The audience verifies the roster from the admin's feed, so it knows exactly whose messages +are legitimate without trusting the broker. -**The 4-seat jam cohort** — collaborative remix editing, a strudel livecoding session, a -multiparty game. +**Live-stream** — single publisher, open audience. ``` -binding: ANCHOR (topic = mnemonic anchor) publishers: EXPLICIT_LIST (admin + 3) -closed: true (all and only publishers subscribe) history: false +binding: FEED_TOPIC (sequential index) admin: the streamer +publishers: ADMIN_ONLY spectators: true history: false ``` -Every seat sends and receives; there is no audience; the genesis list **is** the seat -bound — a fifth peer's `Subscribe` gets `REJECTED`. +`ADMIN_ONLY` is an immutable promise, not merely an empty roster: this stream will never have +a second author, and a subscriber knows that from genesis rather than from the roster +happening to be empty so far. The streamer ends it with an `END_OF_STREAM` service message, +which is what distinguishes "over" from "the broker stopped relaying". -**Basic live streaming** — single publisher, open audience: +**Group-chat** — anyone attached may speak. ``` -binding: FEED_TOPIC (sequential index) publishers: EXPLICIT_SINGLE -closed: false history: false +binding: MNEMONIC (the topic is just the cohort's name) admin: 0xA… +publishers: ALL spectators: true history: false ``` +No roster, no `Auth`, no constraint on the SOCs: each peer signs and sends its own. The topic +binds nothing — it names the cohort, and that is all it does. Authorship is unrestricted but +never *unattributable*: every message is SOC-signed, so the chat knows exactly who said what +without there being an authorised set to check against. The admin here is not a gatekeeper — +it cannot be, since everyone may write — but it still owns the service feed, so it can end +the cohort. This is the row that outgrows a single broker fastest, and the one +[SWIP-61](https://github.com/ethersphere/SWIPs/pull/105) exists to scale: with `Publish` +forwarded from the leaves towards the root, a member need not be attached to the broker to +speak. Where a cohort wants no authorship guarantees at all, see "why not gossipsub". + +**Implicit** — no admin, no roster, no authority. + +``` +binding: OWNER (topic = keccak256(owner)) admin: absent +history: false +``` + +A live MIC: all SOCs of one owner, the light-client twin of `/mic/subscribe/{owner}`. There +is no admin, so no service feed, no grants and no end-of-stream — nothing to authenticate, +because **the chunk carries its own legitimacy** and the binding's SOC shape is the whole +check. `SOC_ID` gives the multi-author version of this (MOC: id fixed, each publisher mining +its own owner into the anchor neighbourhood — own-identity writers, as in +[SWIP-66](https://github.com/ethersphere/SWIPs/pull/107)), and `MNEMONIC` the unconstrained +one, which is group-chat minus the authority to end it. + ### The modes — enumerated as combinations of dimension choices Known use cases attach here; each mode is nothing more than a row — a combination of publisher/subscriber info, topic match type, and history. (`+/−` = both configurations meaningful.) -| # of pubs | pubs implicit? | subscribers | topic / anchor match | history | use case | -|---|---|---|---|---|---| -| 1 | — | all | feed topic, index sequential | — | live video streaming | -| any | — | all | feed topic, index sequential | — | live videoconference | -| — | + | all | feed topic | +/— | tags, adverts; private co-authoring | -| all | — | all | topic a mere mnemonic of the cohort | +/— | gossip cohort for multi-party / group chat | -| any | + | all | anchor (ephemeral GSOC) | +/— | anythread comments / troll-box | -| any | + | all | ID = `keccak256(topic ‖ index)` | +/— | following one or more feeds | -| — | + | all | feed special, mined index | +/— | following graffiti soc | - -The audience is bounded by the broker's capacity; scaling past it is bps-multihop's -business. - -Rows requiring implicit publishers or history are specified in bps-implicit-publisher and -bps-history respectively. +| configuration | binding | spectators | history | use case | +|---|---|---|---|---| +| live-stream | feed topic, index sequential | + | — | live video streaming | +| spectator-jam | feed topic, index sequential | + | — | live videoconference | +| jam | anchor | — | +/— | private co-authoring, remix editing | +| group-chat | mnemonic — no constraint | + | +/— | multi-party / group chat | +| implicit | anchor (ephemeral GSOC) | + | +/— | anythread comments / troll-box | +| implicit | id fixed, owner mined (MOC) | + | +/— | own-identity writers on a shared id | +| implicit | id = `keccak256(topic ‖ index)` | + | +/— | following one or more feeds | +| implicit | feed special, mined index | + | +/— | following graffiti soc | +| implicit | owner (MIC) | + | +/— | tags, adverts | + +At depth = 1 the broker's capacity bounds **both** directions: the audience by its stream +count, and — since every publisher is attached to it — the publisher count too. Scaling +either past one broker is bps-multihop's business +([SWIP-61](https://github.com/ethersphere/SWIPs/pull/105)), which forwards `Publish` +rootward as well as `Broadcast` leafward. The everyone-publishes rows above — group chat, +videoconference, troll-box — are the ones that need it. + +The implicit rows and history are specified in bps-implicit-publisher and bps-history +respectively — with the split that **this** SWIP fixes *who* an implicit publisher is (the +binding-to-SOC-shape table above, and the cardinality that follows from it), because that is +validation the broker cannot operate without, while bps-implicit-publisher keeps the +event-sourcing mechanism built on top. ## Rationale: why not gossipsub @@ -299,7 +562,85 @@ against the topic binding), so brokers and relays forward without being trusted intermediate can withhold, never forge; and withholding is a liveness fault recoverable by re-pointing or relocating the topic. Multihop forwarding (bps-multihop) adds capacity without reintroducing flooding: every edge still pays upstream, every node still receives -only its topic's stream. +only its topic's stream — and publishing from depth > 1 is metered the same way, priced by +depth (bps-bw-incentives). + +**And in the happy case the tree wins on traffic, not only on trust.** A publish in a +multihop cohort travels **rootward** from wherever it originates and then **leafward** to +everyone: each edge carries the message **exactly once**. A single-parented tree therefore +needs no duplicate suppression at all — no seen-set, no IHAVE/IWANT pull-recovery, no +mesh-degree multiplier applied at every hop. Gossipsub pays D copies per node by +construction and recovers the remainder by asking. Where the tree is well matched to the +underlay — a **closely knit topology**, peers whose tree edges are also their short paths — +rootward-then-leafward is simply the cheaper delivery, and a publisher sitting at depth d +pays those d hops once, on the way up. Duplicates in BPS are a deliberate purchase rather +than a structural cost: dual parenting in +[SWIP-61](https://github.com/ethersphere/SWIPs/pull/105) buys withholding-masking with a +second copy, and that is the case in which the dedup horizon above earns its keep. + +**The concession.** Where an application genuinely wants *gossip* — a large symmetric +cohort with no publisher structure, every member a source, message-level flooding the +point, and no interest in who signed what — **libp2p gossipsub is the better tool and the +application should simply use it.** BPS is not trying to win that comparison. It earns its +keep where the cohort has shape: authorship that is structurally authenticated (SOC-signed +against the topic binding, verifiable regardless of path, so an intermediate can withhold +but never forge), edges that are bounded and metered, messages that are chunks and so +re-fetchable from storage, and a `CohortSpec` that states who may write. The implicit cohort +exists for symmetric groups that want *those* properties — a group chat whose messages are +verifiable signed chunks — not to reimplement a mesh. + +## Security considerations + +**The admin is authenticated, and so is the cohort.** `admin` is a public address, and the +genesis service message is that address's own signature over the spec it is claimed to have +opened. A broker therefore cannot invent a cohort in somebody's name, nor serve a spec its +admin never signed. Nothing else in the handshake needs to be trusted, because the roster +arrives the same way — signed by the admin, on a feed whose gaps are visible. + +**The publisher role is proved, not asserted.** `Auth` carries a signature and no address: +the owner is recovered from it, so presenting it is possession of a key. The preimage is +static and carries **no node identity** — deliberately. Binding it to the libp2p peer id +would make it unreplayable, but would weld the publishing identity to the node holding the +stream: the key could not be used from a second node without re-signing, and every join would +link an eth identity to a peer id for anyone watching. An owner's identity is its own, not +its node's. + +The accepted consequence: a static preimage is **replayable**, and a replayed role is +worthless. Which is the deeper point — + +**Defence in depth is the real guarantee.** Even a peer that obtains the publisher role gains +nothing by it: every message is validated at `Publish` against the SOC signature and the +current roster (or, for an implicit cohort, the binding's SOC shape). `Auth` spares the +broker from carrying peers whose frames could only ever be dropped; **authorship rests on the +message signature, never on the handshake.** A **challenge round trip** is therefore not +specified: it would cost a frame in an otherwise one-each-way establishment to harden a +credential that grants nothing on its own. + +**Audience control exists in exactly one form, and it is not confidentiality.** +`spectators: false` refuses a joiner outside the roster, and is enforceable because `Auth` is +recovered rather than asserted. It bounds *attendance at this broker*, nothing more. **BPS +provides no confidentiality at any layer**: the broker sees every message in plaintext, and so +does everyone it admits. Applications needing a bounded audience **encrypt payloads** — SOC +wrapping is orthogonal to payload encryption, and key distribution is the application's +business. A jam is private because it encrypts, not because it refuses spectators. + +**Revocation is announced before it is enforced, and the announcement is what makes +enforcement legitimate.** Between an admin's revocation and the reduced roster reaching +subscribers, the revoked peer cannot know its status has changed: its frames are dropped and +tolerated, with no penalty and no teardown, because it is not misbehaving. Once the roster is +published the peer has been told — on the same feed as everyone else — so publishing after +that is a protocol violation and the connection is broken. A broker that disconnected first +would be punishing a peer for a rule it had not been given; a broker that never publishes the +roster leaves the violation unable to begin at all, which is an ordinary, visible withholding +fault. Announcing first also makes the revocation legible to the rest of the cohort, which +learns *why* a publisher fell silent from an admin-signed message rather than from an +unattributable disconnection. + +**Resource bounds are broker policy, and all three are required.** A conformant broker +bounds its per-topic stream count (`FULL`), the number of cohorts it will create (`Open` is +otherwise an unbounded allocation primitive for any peer), and its dedup window (see the +horizon note above). The bounded dedup window admits replay of an evicted message by an +already-legitimate publisher: a cohort-internal nuisance, not a break of authorship. ## Out of scope (deliberately) @@ -307,9 +648,9 @@ Multihop relaying and referral (bps-multihop), reorganisation policies (SWATCH, policy SWIPs over this protocol's events and actions, no new frames), bandwidth incentives (bps-bw-incentives), broker discovery (SWIP-59 MEX; early deployments hardcode brokers), history delivery mechanism (bps-history), implicit-publisher event sourcing -(bps-implicit-publisher), and **dynamic publisher-list changes** — grants/revocations -after genesis are deferred to a later revision; the `EXPLICIT_LIST` set is fixed at -`Open`. +(bps-implicit-publisher), and **confidentiality of any kind** — encrypt payloads, see +Security considerations. Dynamic publisher lists are **no longer out of scope**: grants and +revocations are the service feed's business, and neither changes the cohort. ## Conformance (definition of done) @@ -317,13 +658,27 @@ An implementation is conformant when: 1. a broker enforces its per-topic capacity, publisher legitimacy, per-binding validation and dedup; -2. a subscriber re-verifies every message end-to-end (against the `Ack`-echoed - `CohortSpec`) and detects (only) liveness faults; -3. the two worked configurations above interoperate across independent implementations - against the frames in [bps.proto](assets/swip-60/bps.proto); +2. a subscriber re-verifies every message end-to-end — against the `Ack`-echoed + `CohortSpec`, itself checked against the admin-signed genesis SOC — and detects (only) + liveness faults; +3. the **five** configurations above — jam, spectator-jam, live-stream, group-chat and + implicit — interoperate across independent implementations against the frames in + [bps.proto](assets/swip-60/bps.proto); 4. a `FULL` refusal is issued at capacity — and nothing else is (no referral); -5. the WS bridge round-trips both worked configurations end to end — open, publish, - subscribe — with all signing on the client side (the node holds no publisher keys). +5. the WS bridge round-trips each worked configuration end to end — open, publish, + subscribe — with all signing on the client side (the node holds no publisher keys); +6. the handshake is read from the `Hello` envelope, never guessed from the frame body; +7. an absent `admin` is treated as implicit authorship — validated strictly per the + binding's SOC shape — and a present one authenticated by the genesis service message, + whose signature MUST recover to it; +8. `Auth` is verified by recovery over `H("bps-join:v1" ‖ topic ‖ admin)`, and a joiner + outside the roster is admitted read-only where `spectators` is true and `REJECTED` where + it is false — the only refusal for identity in the protocol; +9. an admin grants and revokes by publishing `ROSTER` service messages; a revoked + publisher's frames are **dropped and tolerated** until the reduced roster is published, + and its connection is broken only if it publishes **after** that point; +10. a subscriber takes the roster from the admin's service feed, never from the broker, and + treats an index gap in that feed as a liveness fault. ## Backwards compatibility From 87f6b714fbb807fea83c4b9488876abf5d2a52d3 Mon Sep 17 00:00:00 2001 From: zelig Date: Sun, 30 Aug 2026 07:31:04 +0200 Subject: [PATCH 09/14] swip-60: split type/category per SWIP-0 SWIP-0 specifies `type: Standards Track` with the subcategory in a separate `category:` header (one of Core / Networking / Interface), as swip-19 and swip-20 do. This file carried the category inside `type:`, which is the only form in the repo and may break tooling that parses the front matter. Co-Authored-By: Claude Opus 5 --- SWIPs/swip-60.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/SWIPs/swip-60.md b/SWIPs/swip-60.md index 97182c13..a47eea35 100644 --- a/SWIPs/swip-60.md +++ b/SWIPs/swip-60.md @@ -4,7 +4,8 @@ title: BPS singlehop — brokered broadcast pub/sub, base protocol author: Viktor Trón (@zelig), Viktor Tóth (@nugaon) discussions-to: https://discord.gg/Q6BvSkCv status: Draft -type: Standards Track (Networking) +type: Standards Track +category: Networking created: 2026-08-03 --- From 7a59348ad894aba94bb8d34572afc0a61cc8ad9c Mon Sep 17 00:00:00 2001 From: zelig Date: Tue, 22 Sep 2026 16:53:41 +0200 Subject: [PATCH 10/14] =?UTF-8?q?swip-60=20rev=205:=20extend=20SWIP-74's?= =?UTF-8?q?=20wire=20=E2=80=94=20Join,=20status-only=20Ack,=20no=20GENESIS?= =?UTF-8?q?,=20regime=20=3D=20ALL,=20closed,=20opaque=20chunk?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SWIP-60 now extends the base wire of SWIP-74 (BPS-lite, PR #111) and changes nothing in it. bps.proto is revision 8, derived from SWIP-74's block. - one handshake frame, Join{CohortSpec, Auth?}; Hello/Open/Subscribe gone; cohorts keyed by the whole spec (create-or-attach, no UNKNOWN_TOPIC, squatting a topic under a wrong admin obtains nothing) - Ack is a status; the latest service SOC is delivered as the first Message on every newly attached stream not bound to the admin (marked open) - GENESIS gone: the spec is in every Join; the service feed starts at index 0 with the first ROSTER or END_OF_STREAM, and each service message carries its index - publisher regime reduced to the single value ALL; unset = the admin and whoever its roster ever names; ADMIN_ONLY and GRANTED gone; live-stream and spectator-jam are one spec - spectators (field 9) reverted to closed (field 8, unset = open audience) - one Message{soc} frame both directions, chunk as opaque chunk data; Publish, Broadcast and the field-level Soc gone; deliveries to every stream not bound to the publishing identity - Auth bound to the stream; a spectator carrying an identity is promoted in place when a roster names it; the admin's Join is admitted past the per-cohort bound - broker validation restated: duplicates are retransmits, never invalid; service SOCs recognised by id before the content path; a message from a non-publishing stream is a violation - the feed cursor stays SWIP-74's stricter special case; a full broker dedups on chunk address (several publisher feeds, SWIP-61 reordering) - ANCHOR under explicit authorship: no address check, topic is a rendezvous - capacity, streams and limits per cohort, not per topic; SWIP-74's bounds - API: spec parameters on every session, auth binds an identity, worked URLs updated, closed replaces spectators - conformance items 1, 2, 5, 6, 7, 8 updated; title, motivation, security and backwards compatibility aligned; SWIP-61 to be re-based on the Message frame Co-Authored-By: Claude Fable 5.1 --- SWIPs/assets/swip-60/bps.proto | 284 ++++++++++----------- SWIPs/swip-60.md | 445 +++++++++++++++++++-------------- 2 files changed, 391 insertions(+), 338 deletions(-) diff --git a/SWIPs/assets/swip-60/bps.proto b/SWIPs/assets/swip-60/bps.proto index 052da6bf..e47efeb9 100644 --- a/SWIPs/assets/swip-60/bps.proto +++ b/SWIPs/assets/swip-60/bps.proto @@ -1,30 +1,34 @@ // Broadcast Pub/Sub (BPS) — protocol messages and types. -// Spec: SWIP-60 (../../swip-60.md). +// Spec: SWIP-60 (../../swip-60.md), extending the base wire of SWIP-74 (BPS-lite). // -// Revision 7 (2026-08-25), per Viktor — the control plane splits out. +// Revision 8 (2026-09-22), per Viktor — derived from SWIP-74's block. // -// The publisher roster leaves the CohortSpec: it is dynamic, the spec is -// immutable, and grantee identities are not public the way an admin's is. It -// travels instead as admin-signed SERVICE MESSAGES on a feed the admin owns, -// so that a subscriber verifies who may write against the admin's key rather -// than the broker's word, and so that gaps in the roster history are visible. -// What remains in the spec is immutable policy: admin, publisher regime, -// whether spectators are admitted. +// SWIP-74 fixes the base: three frames (Join, Ack, Message) and the two types they +// carry (CohortSpec, Auth), for a single publisher over a feed at one broker, one +// hop. This file adds what the full singlehop protocol needs and changes nothing +// SWIP-74 defines: +// - CohortSpec gains `publishers` (one value, ALL), `history` and `closed`; +// - Auth gains `id`, for the binding that does not fix it; +// - the admin's service feed (ROSTER, END_OF_STREAM) travels as Message frames; +// - Message reserves the multihop control plane (SWIP-61). // -// Earlier revisions of this draft, for the record: Open/Subscribe were wrapped -// in a Hello envelope (as bare frames they are indistinguishable on the wire, -// and proto3's permissive unmarshalling makes a wrong guess succeed silently); -// Auth became a recovered signature rather than an asserted address; `closed` -// was removed in favour of joining deciding a role. +// Gone with this revision, for the record: the Hello envelope, Open and Subscribe +// (one Join carrying the spec; cohorts keyed by the whole spec); GENESIS (with the +// spec in every Join it had nothing left to prove) and the Ack echo (Ack is a +// status); PublisherRegime's ADMIN_ONLY and GRANTED (a cohort is multi-publisher +// iff its admin ever publishes a roster, so nobody needs to know in advance); +// `spectators`, whose proto3 zero read an unset flag as a closed cohort — `closed` +// is back, unset = open audience; the field-level Soc message (the chunk travels +// as opaque chunk data). Earlier: Auth became a recovered signature rather than an +// asserted address; the roster left the spec for the service feed (rev 7). // // Enum zero values (*_UNSPECIFIED): proto3 requires a zero value; it is -// deliberately NOT a legitimate wire value. It exists so that an unset field -// is detectable and no implementation can silently rely on a default. -// Receivers MUST reject messages carrying it. +// deliberately NOT a legitimate wire value. It exists so that an unset field is +// detectable and no implementation can silently rely on a default. Receivers MUST +// reject messages carrying it. // -// The singlehop (depth = 1) subset is concrete; multihop control-plane -// messages are reserved. Implementation groundwork: bee PR #5435 -// (hand-rolled byte framing with the same semantics). +// Implementation groundwork: bee PR #5435 (hand-rolled byte framing with the same +// semantics). syntax = "proto3"; package bps; @@ -32,10 +36,12 @@ package bps; option go_package = "github.com/ethersphere/bee/v2/pkg/bps/pb"; // --------------------------------------------------------------------------- -// Cohort genesis — immutable policy. The roster is NOT here (see ServiceKind). +// Cohort genesis — immutable policy, and the cohort's identity. The roster is +// NOT here (see ServiceKind). // --------------------------------------------------------------------------- -// What the topic binds to (see SWIP-60: binding semantics). +// What the topic binds to (see SWIP-60: binding semantics). SWIP-74 defines +// FEED_TOPIC alone; the numbers are shared. enum TopicBinding { TOPIC_BINDING_UNSPECIFIED = 0; // invalid on the wire (see header note) ANCHOR = 1; // topic = full SOC/GSOC address; dedup on the wrapped CAC @@ -46,23 +52,24 @@ enum TopicBinding { // from any owner qualifies (dedup on chunk address). What // PublisherRegime.ALL needs -- authorship unrestricted, but // never unattributable, since every message is SOC-signed. - // APPENDED, not inserted: 1-4 keep the numbering the bee - // prototype already implements. } -// Who may author, when the cohort has an admin. With no admin the cohort is -// implicit: authorship follows the binding's SOC shape and this does not apply. +// One value. Set: anyone attached may publish (group chat). Unset, with an +// admin: the admin publishes, and whoever its roster ever names -- a cohort is +// multi-publisher iff a ROSTER is ever published, and nobody needs to know in +// advance. With no admin the cohort is implicit: authorship follows the +// binding's SOC shape and this does not apply. enum PublisherRegime { PUBLISHER_REGIME_UNSPECIFIED = 0; // invalid on the wire (see header note) - ADMIN_ONLY = 1; // the admin alone, for the cohort's whole life (live stream) - GRANTED = 2; // the admin plus whoever the current roster names (jam) - ALL = 3; // anyone attached; needs MNEMONIC binding (group chat) + ALL = 3; // anyone attached; needs MNEMONIC binding + reserved 1, 2; // were ADMIN_ONLY, GRANTED: the roster decides, not the spec } -// Fixed by the cohort's opener; immutable for the cohort's lifetime. +// Fixed by whoever joins first; immutable; keyed as a whole -- two specs that +// differ in any field are two cohorts, even on one topic. // NOTE: broker capacity is NOT a cohort parameter -- a cohort cannot dictate a -// remote node's connection count. Each broker enforces its own per-topic -// stream limit and answers FULL when it is exhausted. +// remote node's connection count. Each broker enforces its own bounds and +// answers FULL when one is exhausted. // NOTE: the proximity constraint for implicit bindings is a protocol constant, // PO_MIN = 16 -- not a cohort parameter (a proto3 unset uint32 is // indistinguishable from 0, which would silently disable the constraint; and @@ -70,110 +77,50 @@ enum PublisherRegime { message CohortSpec { bytes topic = 1; // 32 bytes, meaning per binding TopicBinding binding = 2; - bytes admin = 5; // 20-byte eth address: the opener, the - // cohort's authority, and always a member of - // its publisher set. Absent (length 0) => - // implicit authorship, and `publishers` and - // `spectators` do not apply. Length is the - // discriminator, so absent and set are - // intrinsically distinguishable. - PublisherRegime publishers = 3; // set iff admin is set - bool spectators = 9; // may peers outside the publisher set join? - // Real only under ADMIN_ONLY and GRANTED; - // under ALL and implicit authorship every - // attached peer is already a potential - // author, so openers MUST set it true. + bytes admin = 5; // 20-byte eth address: the cohort's authority + // and always a member of its publisher set. + // Absent (length 0) => implicit authorship, + // and `publishers`, `closed` do not apply. + PublisherRegime publishers = 3; // ALL, or unset (see the enum) bool history = 4; // deliver matching chunks from the local store - reserved 6, 7, 8; + bool closed = 8; // no audience: a joiner whose identity is not + // the admin's or on the roster is REJECTED. + // Unset = open, which is why this is `closed` + // and not `spectators`: a lite spec never sets + // it and must read as an open cohort. + reserved 6, 7, 9; // 6 was `publisher_list` -- now dynamic, carried as ServiceKind.ROSTER; // 7 was `po_min` -- now the protocol constant PO_MIN; - // 8 was `closed` -- superseded by `spectators`, which is enforceable - // now that Auth is recovered rather than asserted. + // 9 was `spectators` -- inverted polarity of `closed`; never reuse. } // --------------------------------------------------------------------------- -// The service feed — the admin's control plane. -// -// Service messages are ordinary SOCs on the ordinary path, owned by the admin: -// -// owner = admin id = keccak256("bps-service:v1" || topic || index) -// -// so a broker relays them and cannot author them, and a subscriber checks them -// with the same code as any broadcast. Sequential indices (SWIP-65 self-indexed -// feeds) make gaps visible: a single constant-id slot overwritten in place -// would make a stale roster undetectable, reintroducing forging-by-omission at -// the one point that decides who may write. -// --------------------------------------------------------------------------- - -enum ServiceKind { - SERVICE_KIND_UNSPECIFIED = 0; // invalid on the wire (see header note) - GENESIS = 1; // index 0: the CohortSpec, signed by the admin. Proves the - // cohort was opened by the address it names. - ROSTER = 2; // the full publisher set as of this index (not a delta) - END_OF_STREAM = 3; // the admin closes the cohort, attributably -} - -// The payload of a service SOC. -message ServiceMessage { - ServiceKind kind = 1; - CohortSpec spec = 2; // set iff GENESIS - repeated bytes publishers = 3; // set iff ROSTER: 20-byte eth addresses, the - // complete set excl. admin (who is always a - // publisher). Full state, not a delta, so a - // reader needs only the latest it can verify. -} - -// --------------------------------------------------------------------------- -// Stream establishment, stream name "pubsub/1.0.0" — one stream per (peer, topic). -// The first message on a fresh stream is Hello, carrying Open (fixes a new -// cohort) or Subscribe (joins an existing one); the broker answers with Ack. -// The first frame settles the peer's role. +// Stream establishment, stream name "pubsub/1.0.0" — one stream per +// (peer, cohort). The first and only handshake frame on a fresh stream is Join, +// carrying the full CohortSpec: it creates the cohort if no live cohort has +// this spec and attaches to it otherwise. The broker answers with Ack. The first +// frame settles the peer's role. // --------------------------------------------------------------------------- -// Peer -> broker: the first frame on a fresh stream. -// -// The envelope is load-bearing. As bare frames, Open and Subscribe are -// indistinguishable: both encode as a length-delimited field 1 followed by an -// optional Auth in field 2. proto3 unmarshalling is permissive, so a receiver -// that guesses wrong does not fail -- it silently succeeds and misreads the -// frame, then answers with a Status that describes the wrong problem. -message Hello { - oneof handshake { - Open open = 1; - Subscribe subscribe = 2; - } -} - -// Opener -> broker: the admin, fixing the cohort. The broker recovers the -// address from `auth` and checks it against cohort.admin before accepting. -message Open { - CohortSpec cohort = 1; - Auth auth = 2; // required iff cohort.admin is set -} - -// Joiner -> broker: names the topic — nothing more. Joiners carry no cohort -// metadata; auth is present iff the joiner claims a publisher role. -message Subscribe { - bytes topic = 1; // 32 bytes - Auth auth = 2; -} - // Proved, not asserted -- and in one operation: ecrecover yields the owner // address AND proves possession of its key, so no challenge round trip. // // owner = ecrecover( H("bps-join:v1" || topic || admin), signature ) // +// The identity is bound to the STREAM this frame arrives on, not to the peer +// connection: one node may carry different identities on different cohorts. +// // The preimage is deliberately static and free of any node identity. Signing // over the libp2p peer id would make this unreplayable, but would weld the // publishing identity to the node holding the stream: the key could not be used // from a second node without re-signing, and every join would link an eth // identity to a peer id for anyone watching. An owner's identity is its own. // -// The accepted consequence: a static preimage is replayable. It costs nothing, -// because a replayed role is worthless -- the replayer cannot sign, so its -// frames are dropped at Publish. Auth spares the broker from carrying peers -// whose frames could only ever be dropped; authorship rests on the message -// signature, never on the handshake. +// The accepted consequence: a static preimage is replayable. It costs little, +// because a replayed role can publish only what its owner already signed -- +// see SWIP-74, Security considerations. Auth spares the broker from carrying +// peers whose frames could only ever be dropped; authorship rests on the +// message signature, never on the handshake. // // "bps-join:v1" is load-bearing: the same secp256k1 keys sign SOCs over // (id || wrappedAddress), and the separator is what stops a join signature from @@ -183,59 +130,86 @@ message Auth { bytes id = 2; // 32-byte SOC id, where the binding does not fix it } -// Broker -> peer, answering Open or Subscribe. The echoed CohortSpec lets a -// subscriber verify every message end-to-end against the topic binding; the two -// service SOCs let it verify the cohort and the roster against the ADMIN, -// rather than taking the broker's word for either. -message Ack { - Status status = 1; - CohortSpec cohort = 2; // set iff status == OK - Soc genesis = 3; // service feed index 0, iff the cohort has an admin - Soc service = 4; // latest service SOC (may equal genesis) - uint64 index = 5; // its feed index, so gaps are visible +// Peer -> broker: the first and only handshake frame. `auth` binds an identity +// to this stream; absent, the stream has none and is read-only (a spectator). +message Join { + CohortSpec cohort = 1; + Auth auth = 2; } enum Status { STATUS_UNSPECIFIED = 0; // invalid on the wire (see header note) OK = 1; - FULL = 2; // broker at its per-topic capacity; - // a singlehop broker refuses -- nothing else - UNKNOWN_TOPIC = 3; // Subscribe for a topic the broker does not serve - REJECTED = 4; // the SPEC is unacceptable -- e.g. Open naming an - // already-open topic with a mismatched CohortSpec, or - // an Auth that does not recover to cohort.admin. - // Also the answer to a non-publisher Subscribe when - // spectators == false -- the ONLY case in which a - // peer is refused for who it is. + FULL = 2; // a capacity bound (per cohort, per broker, per peer + // connection); a singlehop broker refuses -- nothing + // else + REJECTED = 4; // the SPEC is unacceptable -- a value outside this + // SWIP or a reserved field set -- or, under `closed`, + // a joiner whose identity is not the admin's or on the + // roster: the ONLY case in which a peer is refused for + // who it is + reserved 3; // was UNKNOWN_TOPIC: cannot occur, Join creates +} + +// Broker -> peer, answering Join. Status only: the joiner brought the spec, and +// the roster reaches it as the first Message on the stream (see ServiceKind; +// an open point in SWIP-60 -- the alternative is to carry it here in 3-5). +message Ack { + Status status = 1; + reserved 2, 3, 4, 5; // were the spec echo, genesis, service, index } // --------------------------------------------------------------------------- // Messages — SOC-only is a protocol feature // --------------------------------------------------------------------------- -// A full single-owner chunk in transit. Every frame is self-contained: no -// per-stream handshake state, and no format change if the stream model -// evolves (e.g. topic-muxed streams later). -message Soc { - bytes id = 1; // 32 bytes - bytes owner = 2; // 20 bytes (recoverable from signature; explicit for cheap filtering) - bytes signature = 3; // 65 bytes - bytes span = 4; // 8 bytes LE - bytes payload = 5; // wrapped-CAC data, <= 4096 bytes +// Both directions after the handshake: publisher -> broker is a publication, +// broker -> peer a delivery of the same bytes. The single-owner chunk travels +// as its stored chunk data, opaque to the protocol and validated by the ordinary +// SOC code: +// id (32) || signature (65) || span (8, LE) || payload (<= 4096) +// Under FEED_TOPIC a feed update's id slot carries the bare index (SWIP-74, +// SWIP-65); a service SOC carries its full id (see ServiceKind). +message Message { + bytes soc = 1; + reserved 2 to 15; // multihop control plane (SWIP-61): Reparent, Probe, + // Candidates, ... -- a singlehop peer never sends them } -// Publisher -> broker. -message Publish { - Soc soc = 1; +// --------------------------------------------------------------------------- +// The service feed — the admin's control plane. +// +// Service messages are ordinary SOCs on the ordinary path, owned by the admin: +// +// owner = admin id = keccak256("bps-service:v1" || topic || index) +// +// travelling as Message frames with their full 32-byte id, so a broker relays +// them and cannot author them, and a subscriber checks them with the same code +// as any broadcast. The payload carries its own index, so the id is verifiable +// without an out-of-band hint. Sequential indices (SWIP-65 self-indexed feeds) +// make gaps visible: a single constant-id slot overwritten in place would make +// a stale roster undetectable, reintroducing forging-by-omission at the one +// point that decides who may write. The feed starts at index 0 with the first +// ROSTER or END_OF_STREAM; a cohort whose admin has published nothing has an +// empty service feed, and the admin alone may write. +// --------------------------------------------------------------------------- + +enum ServiceKind { + SERVICE_KIND_UNSPECIFIED = 0; // invalid on the wire (see header note) + ROSTER = 2; // the full publisher set as of this index (not a delta) + END_OF_STREAM = 3; // the admin closes the cohort, attributably + reserved 1; // was GENESIS: the spec is in every Join now } -// Broker -> subscriber. -message Broadcast { - oneof frame { - Soc soc = 1; - // 2–15 reserved: multihop control plane (Beacon, Reparent, Expect, - // DcutrSignal, SwapProposal) — named to fix intent, not final. - } +// The payload of a service SOC. +message ServiceMessage { + ServiceKind kind = 1; + uint64 index = 4; // this update's index on the service feed + repeated bytes publishers = 3; // set iff ROSTER: 20-byte eth addresses, the + // complete set excl. admin (who is always a + // publisher). Full state, not a delta, so a + // reader needs only the latest it can verify. + reserved 2; // was `spec` (GENESIS) } // Keepalive / RTT: none at the BPS level. Liveness is the transport's job diff --git a/SWIPs/swip-60.md b/SWIPs/swip-60.md index a47eea35..4f7b87d4 100644 --- a/SWIPs/swip-60.md +++ b/SWIPs/swip-60.md @@ -1,6 +1,6 @@ --- SWIP: 60 -title: BPS singlehop — brokered broadcast pub/sub, base protocol +title: BPS singlehop — brokered broadcast pub/sub, the full singlehop protocol author: Viktor Trón (@zelig), Viktor Tóth (@nugaon) discussions-to: https://discord.gg/Q6BvSkCv status: Draft @@ -9,9 +9,10 @@ category: Networking created: 2026-08-03 --- - + - **Business line**: real-time topic streams for dApps without storing chunks or polling — enough on its own for the five cohort shapes it defines: **jam** (a closed set of authors: @@ -23,6 +24,10 @@ assets/swip-60/bps.proto. --> [bps.proto](assets/swip-60/bps.proto)) plus a WebSocket bridge on the Bee API; done when a broker, publishers and subscribers interoperate per the conformance section. Groundwork exists in bee [#5435](https://github.com/ethersphere/bee/pull/5435). +- **Base**: [SWIP-74 BPS-lite](https://github.com/ethersphere/SWIPs/pull/111) — one + publisher over a feed, one broker, one hop, three frames. This SWIP adds cohort + parameters, the admin's service feed and the Bee API on top of that wire and never + changes it: a SWIP-74 peer is a conformant peer of the live-stream configuration below. - Bandwidth-incentive integration is a separate SWIP (bps-bw-incentives). - Broker discovery integration is from a separate SWIP (bps-broker-discovery, building on [SWIP-59 MEX](https://github.com/ethersphere/SWIPs/pull/103)). @@ -30,7 +35,7 @@ assets/swip-60/bps.proto. --> ## Simple Summary A real-time messaging protocol: WebSocket clients publish and subscribe to topic streams -through Bee nodes. One full node per topic acts as **broker**, re-broadcasting each message +through Bee nodes. One full node per cohort acts as **broker**, re-broadcasting each message over direct, long-lived p2p streams to a capacity-bounded set of connected peers. Messages are single-owner chunks, so every subscriber verifies authorship end-to-end; the broker can withhold, never forge. @@ -38,10 +43,12 @@ withhold, never forge. ## Motivation Swarm's event primitives (GSOC, PSS) require full-node operation; light clients can only -poll storage. BPS singlehop is the smallest protocol that fixes this: one broker, direct -streams, authenticated messages, an explicit capacity bound. Everything larger — multihop -trees, adaptive reorganisation, incentives, discovery — is layered on top by later SWIPs -without changing the semantics defined here. +poll storage. [SWIP-74](https://github.com/ethersphere/SWIPs/pull/111) is the smallest +protocol that fixes this for one publisher; BPS singlehop is the smallest that fixes it for +every cohort shape, on the same wire: one broker, direct streams, authenticated messages, +an admin's control plane, an explicit capacity bound. Everything larger — multihop trees, +adaptive reorganisation, incentives, discovery — is layered on top by later SWIPs without +changing the semantics defined here. ## Specification @@ -55,17 +62,18 @@ Per topic-cohort: ### Cohort genesis: the parameters A cohort is fully described by a `CohortSpec` ([bps.proto](assets/swip-60/bps.proto)), fixed -the moment the first peer contacts a BPS-speaking full node with a topic, and **immutable for -the cohort's lifetime**. There is no mode enum; **modes are combinations of these -parameters**. +the moment the first peer brings it to a BPS-speaking full node, and **immutable for the +cohort's lifetime**. **The spec is the cohort's identity**: every joiner carries it, and +two specs that differ in any field are two cohorts, even on one topic. There is no mode +enum; **modes are combinations of these parameters**. | parameter | values | meaning | |---|---|---| | `topic` | 32 bytes | interpreted per `binding` | | `binding` | `MNEMONIC` / `ANCHOR` / `SOC_ID` / `OWNER` / `FEED_TOPIC` | what the topic binds to; fixes which SOCs qualify as messages and the dedup rule | -| `admin` | eth address | the opener, the cohort's authority, and a member of its publisher set. **Absent ⇒ implicit authorship**, and the two fields below do not apply | -| `publishers` | `ADMIN_ONLY` / `GRANTED` / `ALL` | who may author besides the admin | -| `spectators` | bool | whether peers outside the publisher set may join | +| `admin` | eth address | the cohort's authority and a member of its publisher set — not necessarily the first to join. **Absent ⇒ implicit authorship**, and the two fields below do not apply | +| `publishers` | `ALL` or unset | set: anyone attached may author. Unset: the admin, and whoever its roster ever names — **a cohort is multi-publisher iff its admin ever publishes a roster**, and nobody needs to know in advance | +| `closed` | bool, unset = open | set: no audience — a joiner whose identity is not the admin's or on the roster is refused | | `history` | bool | deliver matching chunks already in the local store (mechanism in bps-history; a singlehop broker MAY refuse) | **The publisher list is deliberately not here.** It is dynamic — an admin grants and revokes @@ -78,18 +86,22 @@ subscriber verifies it against the admin's key rather than against the broker's #### The five configurations -| configuration | `admin` | `publishers` | `spectators` | who may author | +| configuration | `admin` | `publishers` | `closed` | who may author | |---|---|---|---|---| -| **jam** | set | `GRANTED` | false | admin + current grantees; nobody else attends | -| **spectator-jam** | set | `GRANTED` | true | admin + current grantees, before an audience | -| **live-stream** | set | `ADMIN_ONLY` | true | the admin alone, before an audience | -| **group-chat** | set | `ALL` | true | anyone attached — each peer signs its own SOCs | -| **implicit** | absent | — | true | whoever the binding's SOC shape admits | - -`spectators` does real work only in the `GRANTED` and `ADMIN_ONLY` rows — which is exactly -the audience / no-audience distinction. Under `ALL` and under implicit authorship every -attached peer is already a potential author, so excluding non-publishers excludes nobody; -openers MUST set it true there. +| **jam** | set | unset | true | admin + current grantees; nobody else attends | +| **spectator-jam** | set | unset | unset | admin + current grantees, before an audience | +| **live-stream** | set | unset | unset | the admin alone, before an audience — **SWIP-74's cohort**: a spectator-jam whose admin never publishes a roster | +| **group-chat** | set | `ALL` | unset | anyone attached — each peer signs its own SOCs | +| **implicit** | absent | — | unset | whoever the binding's SOC shape admits | + +Live-stream and spectator-jam are one spec: nothing in it promises a single author in +advance, and nothing needs to — the audience verifies every message against the admin's +key and the roster it has seen, and a roster that never comes is a stream with one +author. `closed` does real work only where a roster decides authorship — the jam rows, +which is exactly the audience / no-audience distinction. Under `ALL` and under implicit +authorship every attached peer is already a potential author, so excluding non-publishers +excludes nobody; a spec MUST leave it unset there — a broker answers a `closed` `ALL` or +implicit spec with `REJECTED`. **The admin is always in the publisher set**, and being a publisher obliges nobody to publish — no peer waits on another — so a practically non-publishing **moderator** needs no @@ -104,7 +116,9 @@ Binding semantics (dedup rule in parentheses): - **`ANCHOR`** — topic = full SOC/GSOC address; all messages share one address (dedup on the wrapped CAC — the guard against unsolicited republication of old SOCs, sound only under an application-level requirement: payloads are distinct, i.e. the application - includes some index in the payload). + includes some index in the payload). **Under explicit authorship the address check does + not apply**: several owners cannot share one SOC address, so the topic is a rendezvous, + legitimacy is roster membership (below), and only the wrapped-CAC dedup remains. - **`SOC_ID`** — topic = SOC id; any owner with `PO(socAddr(id, owner), anchor) ≥ PO_MIN` qualifies (dedup on chunk address). - **`OWNER`** — topic = `keccak256(owner)`; any id under the same PO constraint — MIC @@ -139,14 +153,18 @@ footgun — an omitted value silently disabling the constraint — and no use ca it.) Broker **capacity is deliberately not a cohort parameter**: a cohort cannot dictate a -remote node's connection count. Each broker enforces its own per-topic stream limit and -answers `FULL` when it is exhausted. - -**Cohort lifetime** is broker-side in the same way, with one exception. A cohort lives for as -long as its broker keeps serving the topic; it is not tied to its opener, and a broker MAY -reclaim a cohort that has no attached streams, which is unobservable beyond a later -`Subscribe` being answered `UNKNOWN_TOPIC`. The exception is the **end-of-stream** service -message, by which an admin ends its own cohort deliberately and *attributably* (below). +remote node's connection count. Each broker enforces its own per-cohort stream limit and +answers `FULL` when it is exhausted — to a `Join` without a publisher's `Auth`: the +audience cannot lock the admin, or a rostered publisher, out of its own cohort. + +**Cohort lifetime** is broker-side in the same way, with one exception. A cohort is not +tied to whoever joined first, nor to its admin's stream: it ends by **inactivity** — the +broker reclaims a cohort on which no publisher stream has had a message accepted for its +inactivity deadline, and MAY reclaim one with no attached streams at once (SWIP-74, +*Resource bounds*) — which is unobservable beyond a fresh cohort on the next `Join`. The +exception is the **end-of-stream** service message, by which an admin ends its own cohort +deliberately and *attributably* (below), and which is what distinguishes "over" from "the +broker stopped relaying". ### The service feed: the admin's control plane @@ -159,16 +177,19 @@ owner = admin id = keccak256("bps-service:v1" ‖ topic ‖ index) | index | message | carries | |---|---|---| -| `0` | **genesis** | the `CohortSpec`, signed by the admin | | `n` | **roster** | the full publisher set as of version `n` | | last | **end-of-stream** | the cohort is closed by its admin | -Three properties follow, and each of them is the point: +The feed starts at index 0 with the first roster or the end of stream; a cohort whose +admin has published nothing has an empty service feed, and the admin alone may write. +Each service message carries its own index in the payload, so its id is verifiable +without an out-of-band hint. Three properties follow, and each of them is the point: -- **The admin is authenticated, and so is the spec.** A broker cannot invent a cohort in - somebody's name: `admin` is an address anyone can read, and index 0 is that address's own - signature over the spec it is claimed to have opened. Nothing else in the handshake needs - to be trusted. +- **The spec is nobody's word, and the admin is authenticated.** Every joiner brings the + spec in its `Join`, so a broker cannot serve a peer a cohort it did not name; `admin` + is an address anyone can read, its `Auth` at join is a recovered signature, and every + message and every service message carries its signature. Nothing in the handshake + needs to be trusted. - **It is a feed, not a single mutable slot.** The obvious alternative — one constant-id SOC overwritten in place — makes a stale roster **undetectable**, which would reintroduce forging-by-omission at the one point that decides who may write. Sequential indices make @@ -179,9 +200,11 @@ Three properties follow, and each of them is the point: SOCs on the ordinary path — storable, re-fetchable, and checked with the same code as any broadcast. A broker relays them; it cannot author them. -**`Ack` therefore carries the genesis SOC and the latest service SOC** (with its index) -alongside the echoed `CohortSpec`. A joiner learns who may write from the admin, not from the -broker, before it has received a single message. +**`Ack` is a status, and the roster is the first delivery.** On every newly attached +stream not bound to the admin's identity the broker delivers the **latest service SOC** as +the first `Message` before any other **(?)**; a joiner learns who may write from the +admin, not from the broker, before it has received a single message, and a cohort with an +empty service feed delivers nothing first — the admin alone may write. #### `Auth`: recovered, not asserted, and not tied to a node @@ -199,10 +222,11 @@ publishing identity to the node holding the stream: the same key could not be us second node without re-signing, and every join would link an eth identity to a peer id for anyone watching. Neither is acceptable — an owner's identity is its own, not its node's. -The consequence, taken deliberately: a static preimage is **replayable**. It costs nothing, -because a replayed role is worthless — the replayer cannot sign, so every frame it sends is -dropped at `Publish`. What `Auth` buys is that the broker need not carry peers whose frames -could only ever be dropped; **authorship rests on the message signature, never on the +The consequence, taken deliberately: a static preimage is **replayable**. It costs little: +a replayed role can publish only what its owner already signed, which the binding's dedup +refuses within a cohort's life and the subscriber's own cursor catches across one (Security +considerations). What `Auth` buys is that the broker need not carry peers whose frames could +only ever be dropped; **authorship rests on the message signature, never on the handshake.** The **`"bps-join:v1"` domain separator is load-bearing**. These are the same secp256k1 keys @@ -210,39 +234,48 @@ that sign SOCs, over the preimage `id ‖ wrappedAddress`. Without separation a could be reinterpreted as a chunk signature, or a chunk signature coaxed out of a peer and replayed as a join. The prefix makes the two preimage spaces disjoint by construction. -Under implicit authorship there is no `Auth` at all: the SOC itself is the credential, and -its shape is checked at `Publish`. +The identity `Auth` proves is **bound to the stream it arrives on, not to the peer +connection**: one node may carry different identities on different cohorts, and a stream +without `Auth` has none. Under implicit authorship there is no `Auth` at all: the SOC +itself is the credential, and its shape is checked on every `Message`. ### The first frame settles the role -A peer's role is fixed by its **first frame**, before any data flows: +A peer's role is fixed by its **first frame**, `Join` — the only handshake frame there is +— carrying the full `CohortSpec` and, if the peer claims an identity, an `Auth`. The +broker compares the spec with its live cohorts: **no match → the cohort is created** with +the joiner attached; **match → the joiner is attached**. Anyone whose `Join` is accepted +may create — in an open cohort that includes a spectator arriving before the admin, and a +cohort costs the broker a map entry until the inactivity deadline reclaims it; under +`closed` a `REJECTED` `Join` creates nothing, so the admin's is the first accepted one. +Cohorts are keyed by the **whole spec**, so pre-creating a topic under a wrong admin +squats nothing — the genuine spec is a different cohort. -- the **admin** sends `Open`, carrying the `CohortSpec` and its `Auth`. The broker recovers - the address, checks it against `CohortSpec.admin`, and stores the genesis service SOC; -- everyone else sends `Subscribe`, optionally carrying `Auth`. The broker recovers the - address and matches it against the **current roster**: +Then the stream's role, from the address `Auth` recovers, matched against `admin` and the +**current roster**: -| outcome | `spectators: true` | `spectators: false` | +| outcome | `closed` unset | `closed` set | |---|---|---| -| recovered address is in the roster | joins as **publisher** | joins as **publisher** | -| no match, or no `Auth` | joins as **spectator**, read-only | `REJECTED` | +| recovered address is the admin's, or in the roster | joins as **publisher** | joins as **publisher** | +| no match, or no `Auth` | joins as **spectator**, read-only — the identity, if any, stays bound to the stream and a later roster naming it promotes the stream in place | `REJECTED` | -`spectators: false` is the only configuration in which a peer is turned away for *who it is*, -and it is enforceable precisely because `Auth` is recovered rather than asserted. Everywhere -else `REJECTED` means the *spec* is unacceptable — an `Open` naming an already-open topic with -a mismatched spec — and `FULL` means capacity, nothing more. +`closed` is the only configuration in which a peer is turned away for *who it is*, and it +is enforceable precisely because `Auth` is recovered rather than asserted. Everywhere else +`REJECTED` means the *spec* is unacceptable — a value outside this SWIP, or a reserved +field set — and `FULL` means capacity, nothing more. #### Grant and revocation An admin changes the roster by publishing the next service message; the cohort spec never changes. A **grant** takes effect for the granted peer on its next join, or immediately if it -is already attached as a spectator. +is already attached as a spectator whose `Join` carried its `Auth` — the identity is bound to +the stream, so the stream is promoted in place. A **revocation** has two phases, and the boundary between them is the moment the reduced roster reaches subscribers: 1. **Before it is published**, the revoked peer has no way to know it has been revoked — - nothing has told it. Its `Publish` frames are therefore **dropped and tolerated**: + nothing has told it. Its `Message` frames are therefore **dropped and tolerated**: silently ignored, no penalty, the connection untouched. There is nothing else a broker can honestly do, because the peer is not misbehaving. 2. **After it is published**, the peer has been told — it receives the service message like @@ -265,28 +298,32 @@ disconnection they cannot attribute. ### Roles and capacity - **Broker**: the first full node contacted; root of the (here, depth = 1) multicast tree. - Enforces its own per-topic capacity. **At capacity it MUST answer `Open`/`Subscribe` - with a refusal** (`FULL`); referral to another attachment point is reserved for - bps-multihop — a singlehop-only broker simply refuses. Because `Open` is an allocation - primitive available to any peer, a conformant broker also bounds **how many cohorts it - will create**, not only the streams within one; the two limits are independent policy. -- **Admin = opener**: the one peer that fixes the `CohortSpec` (`Open`), always a member of - the publisher set, and the cohort's only authority: it grants, revokes and ends, each by - publishing a service message. Its address is public in the spec — as a stream's or a - co-edited file's owner naturally is — while its grantees' are not. An admin that never - sends is a **moderator**; no separate role is needed, since being a publisher obliges - nobody to publish. -- **Publisher**: sends and receives. At depth = 1 every peer is attached to the broker, so - publishers are too — this is a **consequence of singlehop, not a protocol invariant**. - bps-multihop lifts it by forwarding `Publish` rootward as well as `Broadcast` leafward, - so a publisher may sit several hops out; that is what lets an everyone-publishes cohort - grow past one broker's capacity. Attachment is in any case necessary, not sufficient — - under explicit authorship, the current roster decides. -- **Spectator**: receives only; joins by naming the topic (`Subscribe`) and carries no - cohort metadata — the broker echoes the `CohortSpec` and the admin's service SOCs back, so - the cohort, its roster and every message are verified end-to-end. Every peer receives, so - publishing is the *additional* capability and this role is what remains without it; a - cohort with `spectators: false` has none. + Enforces its own capacity. **At capacity it MUST answer `Join` with a refusal** + (`FULL`) — for the per-cohort stream bound, a `Join` whose `Auth` recovers to the admin + or to a rostered publisher is admitted past it, as in SWIP-74; referral to another + attachment point is reserved for bps-multihop — a singlehop-only broker simply refuses. Because any peer can make a broker allocate a + cohort simply by joining, a conformant broker also bounds **how many cohorts it will + create** and **how many one peer connection may hold**, and **reclaims idle ones** — + SWIP-74's bounds, all independent policy. +- **Admin**: the address the spec names — not necessarily the first to join — always a + member of the publisher set, and the cohort's only authority: it grants, revokes and + ends, each by publishing a service message. Its address is public in the spec — as a + stream's or a co-edited file's owner naturally is — while its grantees' are not. An + admin that never sends is a **moderator**; no separate role is needed, since being a + publisher obliges nobody to publish. +- **Publisher**: sends and receives — every `Message` of the cohort except its own, on + any of its streams. At + depth = 1 every peer is attached to the broker, so publishers are too — this is a + **consequence of singlehop, not a protocol invariant**. bps-multihop lifts it by + forwarding `Message` frames rootward as well as leafward, so a publisher may sit + several hops out; that is what lets an everyone-publishes cohort grow past one + broker's capacity. Attachment is in any case necessary, not sufficient — under + explicit authorship, the current roster decides. +- **Spectator**: receives only; joins with the same `Join` as everyone, carrying the + spec it was invited with, and the broker delivers the latest roster as its first + frame, so the cohort, its roster and every message are verified end-to-end. Every + peer receives, so publishing is the *additional* capability and this role is what + remains without it; a `closed` cohort has none. ### Information flow @@ -299,25 +336,24 @@ sequenceDiagram participant SN as subscriber's bee node
(WS bridge + mux) participant SD as subscriber dApp(s) - PN->>B: Hello(Open(CohortSpec, Auth)) - Note over PN,B: opener fixes the cohort and is its admin
at depth = 1 every publisher is attached to the broker + PN->>B: Join(CohortSpec, Auth) + Note over PN,B: the spec is the cohort's identity: the first Join creates it,
later ones attach; at depth = 1 every publisher is attached to the broker B-->>PN: Ack(OK) - SN->>B: Hello(Subscribe(topic, Auth?)) - B-->>SN: Ack(OK, CohortSpec, genesis SOC, latest ROSTER) - Note over B,SN: echoed spec ⇒ subscriber verifies
every message end-to-end + SN->>B: Join(CohortSpec, Auth?) + B-->>SN: Ack(OK) + B->>SN: Message(latest ROSTER) — the admin's word, relayed + Note over B,SN: spec in hand + admin-signed roster ⇒
subscriber verifies every message end-to-end PD->>PN: WS: payload - PN->>B: Publish(SOC) + PN->>B: Message(SOC) B->>B: validate: SOC sig ⊨ topic binding
(+ dedup per binding) - par fan-out to every subscriber stream - B->>SN: Broadcast(SOC) — every frame self-contained + par fan-out to every stream of the cohort not bound to the publishing identity + B->>SN: Message(SOC) — every frame self-contained SN->>SN: mux: one p2p stream → N WS sessions SN->>SD: WS: payload - and publisher's own subscription (if subscriber too) - B->>PN: Broadcast(SOC) - PN->>PD: WS: payload end + Note over B,PN: other publishers' streams receive it the same way;
the author's own never do ``` The broadcast is **end-to-end authenticated**: every subscriber re-verifies the SOC @@ -327,42 +363,59 @@ signature against the topic binding regardless of path. Messages are defined in [bps.proto](assets/swip-60/bps.proto). Framing notes: -- Transport: libp2p stream `pubsub/1.0.0`, one stream per (peer, topic), - protobuf-over-libp2p as bee protocols elsewhere. The first message on a fresh stream is - **`Hello`**, carrying either `Open` (fixes a new cohort) or `Subscribe` (joins one — - topic only, no cohort metadata); the broker answers with `Ack`, carrying the echoed - `CohortSpec` together with the admin-signed genesis SOC and the latest service SOC, so - the joiner verifies the cohort and its roster against the admin rather than the broker. -- **Why the `Hello` envelope**: as bare frames, `Open` and `Subscribe` are - indistinguishable on the wire — both are a length-delimited field 1 followed by an - optional `Auth` in field 2 — and proto3's permissive unmarshalling means a - receiver that guesses wrong does not fail: it succeeds and misreads the frame, then - rejects it for an unrelated reason with a misleading `Status`. The `oneof` makes the - choice explicit at no cost. (The alternative — two libp2p protocol ids — needs no proto - change but splits the one-stream-per-(peer, topic) model across two stream names.) -- **`Open` is idempotent**: naming an already-open topic with an **identical** spec is - equivalent to `Subscribe`; with a mismatched spec it is answered `REJECTED`. - Implicit-publisher cohorts rely on this — the first subscriber is the opener, so a - client need not know whether it is first. -- **Stream model rationale**: per-topic streams give per-cohort flow control, teardown +- Transport: libp2p stream `pubsub/1.0.0`, one stream per (peer, cohort), + protobuf-over-libp2p as bee protocols elsewhere. The first and only handshake frame on + a fresh stream is **`Join`**, carrying the full `CohortSpec` and optionally an `Auth`; + the broker answers with `Ack{status}`, and delivers the latest service SOC as the + stream's first `Message`, so the joiner verifies the roster against the admin rather + than the broker. The three frames — `Join`, `Ack`, `Message` — and the two types they + carry are SWIP-74's; this SWIP adds fields and values, never frames. +- **`Join` creates or attaches**, keyed by the whole spec: a spec no live cohort has + creates one, a byte-identical spec attaches, and there is no "unknown topic". + Implicit-publisher cohorts rely on this — the first subscriber creates, so a client + need not know whether it is first — and so does every audience member arriving before + its admin. +- **Stream model rationale**: per-cohort streams give per-cohort flow control, teardown and role typing, and match bee's protocol idiom. Because every frame carries the full SOC (self-contained, no per-stream handshake state), a later move to topic-muxed streams requires no format change. -- Every `Broadcast` frame carries the **full SOC** (id, owner, signature, span, payload); - there is no handshake/data frame split. +- Every `Message` carries the **full chunk** as opaque chunk data (SWIP-74), validated by + the ordinary SOC code; there is no handshake/data frame split. Deliveries go to every + stream of the cohort except those bound to the publishing identity: a publisher never + receives its own messages back, on whichever of its streams it sent them (SWIP-74). - No BPS-level keepalive or RTT probing: liveness is the transport's job, and latency metrics for reorganisation policies are sourced there too. -- Broker validation on `Publish`: SOC signature verifies against the topic binding, PO - constraint holds where applicable, sender is a legitimate publisher, message is not a - duplicate per the binding's dedup rule. Invalid ⇒ drop; repeated invalid ⇒ disconnect - (blocklisting policy). +- Broker validation on a `Message`: SOC signature verifies against the topic binding, PO + constraint holds where applicable, the stream's identity is a legitimate publisher (the + admin or a rostered address; anyone under `ALL`; the binding's SOC shape under implicit + authorship). Invalid ⇒ drop and count; repeated invalid ⇒ disconnect (blocklisting + policy). A message that passes and is a **duplicate** per the binding's dedup rule is + dropped and counted as a retransmit, never as invalid — an admin reconnecting after a + reset legitimately resends (SWIP-74); a broker MAY reset a stream whose retransmit rate + exceeds its policy. A `Message` on a stream whose identity may not publish — none, or one + neither the admin's nor rostered — is a protocol violation: dropped, the stream reset, + the peer blocklisted (SWIP-74). +- **Service messages** ride the same frame and are recognised before the content path: a + `Message` on a stream bound to `admin` whose payload decodes as a `ServiceMessage` and + whose id equals `keccak256("bps-service:v1" ‖ topic ‖ payload.index)` is a service SOC. + It is accepted iff it validates as a SOC under that id, its owner is `admin`, and + `payload.index` exceeds the service feed's cursor (initially absent: index 0 is + accepted); otherwise it is invalid. Under `FEED_TOPIC` the two paths are told apart by + the id slot alone — a feed update carries a bare index (24 leading zero bytes), a + service SOC its full id — which is why a SWIP-74 broker drops the latter rather than + punishing it. - **The dedup *horizon* is implementation-defined, but it MUST be bounded**: the binding fixes what counts as a duplicate, not how far back the broker remembers, and an unbounded seen-set is a memory-exhaustion vector. A broker keeps a bounded window over recent message identifiers; the accepted consequence is that a legitimate publisher can overrun that window and replay an evicted message. Applications that cannot tolerate replay carry their own sequencing — which the sequential construction of - [SWIP-65](https://github.com/ethersphere/SWIPs/pull/106) gives for free. + [SWIP-65](https://github.com/ethersphere/SWIPs/pull/106) gives for free. A SWIP-74 + broker is stricter on its one configuration — a per-cohort **cursor**, `index > cursor`, + no window at all — which this SWIP does not adopt: with several publisher feeds on one + topic, and with the reordering of [SWIP-61](https://github.com/ethersphere/SWIPs/pull/105), + a full broker dedups on chunk address and passes retransmits through, and the subscriber's + own cursor does the rest. ### API (WebSocket bridge) @@ -384,13 +437,13 @@ topics). Query parameters: | parameter | maps to | meaning | |---|---|---| | `peer` | — | broker underlay multiaddr; required until broker discovery exists (bps-broker-discovery) — early deployments configure it | -| `binding`, `admin`, `publishers`, `spectators`, `history` | `CohortSpec` | **presence of cohort parameters makes the session the opener**: the node sends `Open` with the assembled spec; absence makes it a joiner: the node sends `Subscribe(topic)` and learns the spec from the `Ack` echo. `admin` omitted ⇒ implicit authorship. No publisher list here — it is not part of the spec | -| `auth` (+ `id` where the binding does not fix it) | `Auth` | 65-byte join signature over `H("bps-join:v1" ‖ topic ‖ admin)`. **Presence claims a publisher role** (read–write); absence, a spectator (read-only). Signed client-side, like every other signature here — the node holds no publisher keys, and the signature is over no node identity, so the same key works from any node | +| `binding`, `admin`, `publishers`, `closed`, `history` | `CohortSpec` | **the spec, on every session**: `binding` always, the others where the spec sets them — the spec is the cohort's identity and the invite carries it; the node sends `Join` with the assembled spec, which creates or attaches, and keys its sessions by the whole spec, not the topic. `admin` omitted ⇒ implicit authorship. No publisher list here — it is not part of the spec | +| `auth` (+ `id` where the binding does not fix it) | `Auth` | 65-byte join signature over `H("bps-join:v1" ‖ topic ‖ admin)`. **Binds an identity to the session's stream**: read–write iff that identity is the admin's or currently rostered (or the cohort is `ALL`), read-only otherwise and promoted in place when a later roster names it; absent, a spectator with no identity. Signed client-side, like every other signature here — the node holds no publisher keys, and the signature is over no node identity, so the same key works from any node | **`POST /pubsub/{topic}/service`** — the admin's control plane: submits a service message (`ROSTER` or `END_OF_STREAM`) as the next update on the service feed. The SOC is signed -client-side by the admin key; the node relays it. Granting or revoking a publisher is one -call here and touches no cohort parameter. +client-side by the admin key; the node relays it on the cohort whose `admin` that key is. +Granting or revoking a publisher is one call here and touches no cohort parameter. Headers: @@ -417,33 +470,36 @@ the feed id `keccak256(topic ‖ index)` (self-indexed feeds, [SWIP-65](https://github.com/ethersphere/SWIPs/pull/106)); under explicit regimes with `ANCHOR` binding the id does no work and there is no prefix. The node assembles the SOC, validates it exactly as a broker would, and -publishes. End-to-end verification against the `Ack`-echoed `CohortSpec` is performed -by the local node — node and dApp are one trust domain. +publishes. End-to-end verification against the `CohortSpec` the session supplied — the +spec the node sent in `Join` — is performed by the local node — node and dApp are one +trust domain. -**Worked API calls — the jam cohort** (see Configurations below). Seat A opens — cohort -parameters present ⇒ `Open`, `owner` present ⇒ read–write: +**Worked API calls — the jam cohort** (see Configurations below). Seat A joins — its +`auth` recovers to `admin` ⇒ read–write; the spec creates the cohort, since under `closed` +nobody else's `Join` is accepted before A's: ``` wss://node:1633/pubsub/jam-tuesday?peer= - &binding=anchor&admin=0xA…&publishers=granted&spectators=false&auth=0x3f2a… + &binding=anchor&admin=0xA…&closed=true&auth=0x3f2a… ``` -Seats B–D join — no cohort parameters ⇒ `Subscribe`, spec learned from the `Ack` echo: +Seats B–D join with the same spec and their own `auth`: ``` -wss://node:1633/pubsub/jam-tuesday?peer=&auth=0x9c14… +wss://node:1633/pubsub/jam-tuesday?peer= + &binding=anchor&admin=0xA…&closed=true&auth=0x9c14… ``` Seats B–D are not named in this URL and never appear in a cohort parameter: A grants them with a `POST /pubsub/jam-tuesday/service` carrying a `ROSTER` message, and can revoke or add a fifth seat later without any of the above changing. Each seat is sorted into the publisher -role by the address recovered from its `auth`; because `spectators` is false, a peer with no +role by the address recovered from its `auth`; because the cohort is `closed`, a peer with no listed key is `REJECTED` rather than admitted read-only. The join URL minus `auth` is the -complete out-of-band invite (topic mnemonic + broker) until broker discovery exists — and it -is genuinely an invite: only a holder of a rostered key can turn it into a session at all. +complete out-of-band invite (spec + broker) until broker discovery exists — and it is +genuinely an invite: only a holder of a rostered key can turn it into a session at all. A live MIC — all SOCs of one owner, the light-client twin -of `/mic/subscribe/{owner}` — is the implicit case: first subscriber opens with -`?binding=owner`, no `admin` and no `auth` (idempotent `Open`), +of `/mic/subscribe/{owner}` — is the implicit case: every subscriber joins with +`?binding=owner`, no `admin` and no `auth` (the first creates, the rest attach), topic = `keccak256(owner)`, read-only, `swarm-soc-fields: identifier,payload`. ### Configurations (worked examples) @@ -454,10 +510,10 @@ The five configurations, as `CohortSpec` rows. ``` binding: ANCHOR (topic = mnemonic anchor) admin: 0xA… -publishers: GRANTED spectators: false history: false +closed: true history: false ``` -Seat A opens; B, C and D are granted by a `ROSTER` service message, and each is sorted into +Seat A joins; B, C and D are granted by a `ROSTER` service message, and each is sorted into the publisher role on joining because the address recovered from its `Auth` is on the roster it can verify against A's key. A fifth peer is `REJECTED` — this is the one configuration in which a peer is refused for who it is, and it is enforceable because `Auth` is recovered, not @@ -469,10 +525,11 @@ encrypts payloads. ``` binding: ANCHOR admin: 0xA… -publishers: GRANTED spectators: true history: false +history: false ``` -Identical authorship, but an unrecognised joiner is admitted read-only instead of refused. +Identical authorship, but an unrecognised joiner is admitted read-only instead of refused — +and, if its `Join` carried an `Auth`, promoted in place when a later roster names it. The audience verifies the roster from the admin's feed, so it knows exactly whose messages are legitimate without trusting the broker. @@ -480,19 +537,21 @@ are legitimate without trusting the broker. ``` binding: FEED_TOPIC (sequential index) admin: the streamer -publishers: ADMIN_ONLY spectators: true history: false +history: false ``` -`ADMIN_ONLY` is an immutable promise, not merely an empty roster: this stream will never have -a second author, and a subscriber knows that from genesis rather than from the roster -happening to be empty so far. The streamer ends it with an `END_OF_STREAM` service message, -which is what distinguishes "over" from "the broker stopped relaying". +This is [SWIP-74](https://github.com/ethersphere/SWIPs/pull/111)'s cohort exactly, and a +SWIP-74 peer is a conformant peer of it. The spec is the same as a spectator-jam's: the +streamer simply never publishes a roster, so it stays the only author, and the audience +verifies every message against its key regardless. What this SWIP adds is the end: the +streamer ends it with an `END_OF_STREAM` service message, which is what distinguishes +"over" from "the broker stopped relaying" — and from SWIP-74's inactivity reclaim. **Group-chat** — anyone attached may speak. ``` binding: MNEMONIC (the topic is just the cohort's name) admin: 0xA… -publishers: ALL spectators: true history: false +publishers: ALL history: false ``` No roster, no `Auth`, no constraint on the SOCs: each peer signs and sends its own. The topic @@ -501,7 +560,7 @@ never *unattributable*: every message is SOC-signed, so the chat knows exactly w without there being an authorised set to check against. The admin here is not a gatekeeper — it cannot be, since everyone may write — but it still owns the service feed, so it can end the cohort. This is the row that outgrows a single broker fastest, and the one -[SWIP-61](https://github.com/ethersphere/SWIPs/pull/105) exists to scale: with `Publish` +[SWIP-61](https://github.com/ethersphere/SWIPs/pull/105) exists to scale: with publications forwarded from the leaves towards the root, a member need not be attached to the broker to speak. Where a cohort wants no authorship guarantees at all, see "why not gossipsub". @@ -526,7 +585,7 @@ Known use cases attach here; each mode is nothing more than a row — a combinat publisher/subscriber info, topic match type, and history. (`+/−` = both configurations meaningful.) -| configuration | binding | spectators | history | use case | +| configuration | binding | audience (`closed` unset) | history | use case | |---|---|---|---|---| | live-stream | feed topic, index sequential | + | — | live video streaming | | spectator-jam | feed topic, index sequential | + | — | live videoconference | @@ -541,8 +600,8 @@ meaningful.) At depth = 1 the broker's capacity bounds **both** directions: the audience by its stream count, and — since every publisher is attached to it — the publisher count too. Scaling either past one broker is bps-multihop's business -([SWIP-61](https://github.com/ethersphere/SWIPs/pull/105)), which forwards `Publish` -rootward as well as `Broadcast` leafward. The everyone-publishes rows above — group chat, +([SWIP-61](https://github.com/ethersphere/SWIPs/pull/105)), which forwards `Message` +frames rootward as well as leafward. The everyone-publishes rows above — group chat, videoconference, troll-box — are the ones that need it. The implicit rows and history are specified in bps-implicit-publisher and bps-history @@ -592,11 +651,13 @@ verifiable signed chunks — not to reimplement a mesh. ## Security considerations -**The admin is authenticated, and so is the cohort.** `admin` is a public address, and the -genesis service message is that address's own signature over the spec it is claimed to have -opened. A broker therefore cannot invent a cohort in somebody's name, nor serve a spec its -admin never signed. Nothing else in the handshake needs to be trusted, because the roster -arrives the same way — signed by the admin, on a feed whose gaps are visible. +**The spec is nobody's word, and the admin is authenticated.** Every joiner carries the +spec in its `Join`, so a broker cannot serve a peer a cohort it did not name, and a +cohort somebody else pre-creates under a wrong admin is simply a different cohort. +`admin` is a public address; its `Auth` at join is a recovered signature, and every +message and every roster it publishes carries its signature. Nothing else in the +handshake needs to be trusted, because the roster arrives the same way — signed by the +admin, on a feed whose gaps are visible. **The publisher role is proved, not asserted.** `Auth` carries a signature and no address: the owner is recovered from it, so presenting it is possession of a key. The preimage is @@ -606,11 +667,13 @@ stream: the key could not be used from a second node without re-signing, and eve link an eth identity to a peer id for anyone watching. An owner's identity is its own, not its node's. -The accepted consequence: a static preimage is **replayable**, and a replayed role is -worthless. Which is the deeper point — +The accepted consequence: a static preimage is **replayable**, and a replayed role can +publish only what its owner already signed — worthless within a cohort's life, where the +binding's dedup refuses it, and a matter for the subscriber's own cursor across cohorts +(SWIP-74, *Security considerations*). Which is the deeper point — **Defence in depth is the real guarantee.** Even a peer that obtains the publisher role gains -nothing by it: every message is validated at `Publish` against the SOC signature and the +nothing by it: every message is validated on arrival against the SOC signature and the current roster (or, for an implicit cohort, the binding's SOC shape). `Auth` spares the broker from carrying peers whose frames could only ever be dropped; **authorship rests on the message signature, never on the handshake.** A **challenge round trip** is therefore not @@ -618,7 +681,7 @@ specified: it would cost a frame in an otherwise one-each-way establishment to h credential that grants nothing on its own. **Audience control exists in exactly one form, and it is not confidentiality.** -`spectators: false` refuses a joiner outside the roster, and is enforceable because `Auth` is +`closed` refuses a joiner outside the roster, and is enforceable because `Auth` is recovered rather than asserted. It bounds *attendance at this broker*, nothing more. **BPS provides no confidentiality at any layer**: the broker sees every message in plaintext, and so does everyone it admits. Applications needing a bounded audience **encrypt payloads** — SOC @@ -637,11 +700,13 @@ fault. Announcing first also makes the revocation legible to the rest of the coh learns *why* a publisher fell silent from an admin-signed message rather than from an unattributable disconnection. -**Resource bounds are broker policy, and all three are required.** A conformant broker -bounds its per-topic stream count (`FULL`), the number of cohorts it will create (`Open` is -otherwise an unbounded allocation primitive for any peer), and its dedup window (see the -horizon note above). The bounded dedup window admits replay of an evicted message by an -already-legitimate publisher: a cohort-internal nuisance, not a break of authorship. +**Resource bounds are broker policy, and all are required.** A conformant broker bounds +its per-cohort stream count (`FULL`), the number of cohorts it will create and the number +one peer connection may hold (any peer can make it allocate a cohort simply by joining), +and reclaims idle cohorts — SWIP-74's bounds — and bounds its dedup window (see the +horizon note above), which SWIP-74's one configuration replaces with a cursor. The bounded +dedup window admits replay of an evicted message by an already-legitimate publisher: a +cohort-internal nuisance, not a break of authorship. ## Out of scope (deliberately) @@ -657,24 +722,28 @@ revocations are the service feed's business, and neither changes the cohort. An implementation is conformant when: -1. a broker enforces its per-topic capacity, publisher legitimacy, per-binding validation - and dedup; -2. a subscriber re-verifies every message end-to-end — against the `Ack`-echoed - `CohortSpec`, itself checked against the admin-signed genesis SOC — and detects (only) - liveness faults; +1. a broker enforces SWIP-74's bounds — streams per cohort, cohorts per broker, cohorts + per peer connection, the inactivity deadline — plus publisher legitimacy, per-binding + validation and dedup; +2. a subscriber re-verifies every message end-to-end — against the `CohortSpec` it + joined with and the admin-signed roster it received — and detects (only) liveness + faults; 3. the **five** configurations above — jam, spectator-jam, live-stream, group-chat and implicit — interoperate across independent implementations against the frames in [bps.proto](assets/swip-60/bps.proto); 4. a `FULL` refusal is issued at capacity — and nothing else is (no referral); -5. the WS bridge round-trips each worked configuration end to end — open, publish, - subscribe — with all signing on the client side (the node holds no publisher keys); -6. the handshake is read from the `Hello` envelope, never guessed from the frame body; +5. the WS bridge round-trips each worked configuration end to end — join, publish, + receive — with all signing on the client side (the node holds no publisher keys); +6. the handshake is one `Join` carrying the full spec, creating the cohort or attaching + to it, keyed by the whole spec; `Ack` is a status; a newly attached stream receives + the latest service SOC as its first `Message` **(?)**; 7. an absent `admin` is treated as implicit authorship — validated strictly per the - binding's SOC shape — and a present one authenticated by the genesis service message, - whose signature MUST recover to it; -8. `Auth` is verified by recovery over `H("bps-join:v1" ‖ topic ‖ admin)`, and a joiner - outside the roster is admitted read-only where `spectators` is true and `REJECTED` where - it is false — the only refusal for identity in the protocol; + binding's SOC shape — and a present one authenticated by its `Auth` at join and by its + signature on every service message, both of which MUST recover to it; +8. `Auth` is verified by recovery over `H("bps-join:v1" ‖ topic ‖ admin)`, the identity + is bound to the stream, and a joiner outside the roster is admitted read-only where the + cohort is not `closed` — promoted in place if a later roster names it — and `REJECTED` + where it is — the only refusal for identity in the protocol; 9. an admin grants and revokes by publishing `ROSTER` service messages; a revoked publisher's frames are **dropped and tolerated** until the reduced roster is published, and its connection is broken only if it publishes **after** that point; @@ -683,13 +752,23 @@ An implementation is conformant when: ## Backwards compatibility -New protocol; no existing behaviour changes. Reserved `Broadcast` frame fields hold the -multihop control plane, so bps-multihop extends without a version bump; self-contained -frames mean a change of stream model needs no format change either. +New protocol; no existing behaviour changes. This SWIP extends the wire of +[SWIP-74](https://github.com/ethersphere/SWIPs/pull/111) and changes nothing in it: a +SWIP-74 peer at a full broker is a conformant peer of the live-stream configuration, and a +SWIP-74 broker refuses at the handshake every spec that differs from +`{topic, FEED_TOPIC, admin}`. The one thing it cannot refuse there is a feed-topic cohort +whose admin later publishes a roster — the spec is the same — and it serves that as a live +stream: the roster and the grantees' updates are dropped as invalid, so an admin that wants +a roster needs a full broker. Reserved `Message` fields hold the multihop control plane, +so bps-multihop extends without a version bump — +[SWIP-61](https://github.com/ethersphere/SWIPs/pull/105) is to be re-based on the `Message` +frame, a rename of its `Publish`/`Broadcast`; self-contained frames mean a change of stream +model needs no format change either. ## References -Wire: [bps.proto](assets/swip-60/bps.proto) · origin: +Wire: [bps.proto](assets/swip-60/bps.proto) · base: +[SWIP-74 BPS-lite, PR #111](https://github.com/ethersphere/SWIPs/pull/111) · origin: [PR #93](https://github.com/ethersphere/SWIPs/pull/93) "Add: pubsub" · broker discovery: [SWIP-59 MEX, PR #103](https://github.com/ethersphere/SWIPs/pull/103) · implementation: bee [#5435](https://github.com/ethersphere/bee/pull/5435), bee-js From 8e770b1faeef111ffd803227b5af577d6889dbe8 Mon Sep 17 00:00:00 2001 From: zelig Date: Thu, 24 Sep 2026 16:24:18 +0200 Subject: [PATCH 11/14] swip-60 rev 6: the claim handshake from SWIP-74 rev 3; proto revision 9 - Auth-in-Join gone: addr declared in Join, challenge in Ack, Claim{addr, index, auth} signed over S || O_B || index; a returning publisher claims in the Join; no reply - the first frame settles the cohort, the claim settles the role; closed = silent until the claim recovers to a rostered address, disconnected otherwise - promotion by an explicit claim when named in a roster; nothing bound ahead - ALL: no claim; the declared address is validated by every message's hash and signature - feed publishers under explicit authorship keep SWIP-74's cursor per publisher feed; other bindings keep the bounded window - one extra stream per absent legitimate publisher, with a claim deadline - API: auth -> addr, the bridge relays the challenge and the signature (?) - security rewritten; transport precondition normative - proto rev 9: Auth{r,s,v}, Claim, Join{cohort, addr, claim}, Ack{status, challenge}, Message{address, data}; CohortSpec renumbered after SWIP-74's fields; no reserved statements Co-Authored-By: Claude Fable 5.1 --- SWIPs/assets/swip-60/bps.proto | 172 +++++----- SWIPs/swip-60.md | 597 +++++---------------------------- 2 files changed, 153 insertions(+), 616 deletions(-) diff --git a/SWIPs/assets/swip-60/bps.proto b/SWIPs/assets/swip-60/bps.proto index e47efeb9..db7571e6 100644 --- a/SWIPs/assets/swip-60/bps.proto +++ b/SWIPs/assets/swip-60/bps.proto @@ -1,34 +1,25 @@ // Broadcast Pub/Sub (BPS) — protocol messages and types. // Spec: SWIP-60 (../../swip-60.md), extending the base wire of SWIP-74 (BPS-lite). // -// Revision 8 (2026-09-22), per Viktor — derived from SWIP-74's block. +// Revision 9 (2026-09-24), per Viktor — the claim handshake. // -// SWIP-74 fixes the base: three frames (Join, Ack, Message) and the two types they -// carry (CohortSpec, Auth), for a single publisher over a feed at one broker, one -// hop. This file adds what the full singlehop protocol needs and changes nothing -// SWIP-74 defines: +// SWIP-74 fixes the base: four frames (Join, Ack, Claim, Message) and the two types +// they carry (CohortSpec, Auth), for a single publisher over a feed at one broker, one +// hop, with the publisher role claimed by signing a broker-derived challenge. This +// file adds what the full singlehop protocol needs and changes nothing SWIP-74 +// defines: // - CohortSpec gains `publishers` (one value, ALL), `history` and `closed`; -// - Auth gains `id`, for the binding that does not fix it; -// - the admin's service feed (ROSTER, END_OF_STREAM) travels as Message frames; -// - Message reserves the multihop control plane (SWIP-61). -// -// Gone with this revision, for the record: the Hello envelope, Open and Subscribe -// (one Join carrying the spec; cohorts keyed by the whole spec); GENESIS (with the -// spec in every Join it had nothing left to prove) and the Ack echo (Ack is a -// status); PublisherRegime's ADMIN_ONLY and GRANTED (a cohort is multi-publisher -// iff its admin ever publishes a roster, so nobody needs to know in advance); -// `spectators`, whose proto3 zero read an unset flag as a closed cohort — `closed` -// is back, unset = open audience; the field-level Soc message (the chunk travels -// as opaque chunk data). Earlier: Auth became a recovered signature rather than an -// asserted address; the roster left the spec for the service feed (rev 7). +// - the admin's service feed (ROSTER, END_OF_STREAM) travels as Message frames. +// Field numbers follow SWIP-74's; the added fields come after. There is no envelope: +// what a frame is follows from the stream's direction and role. Multihop (SWIP-61) +// adds its control frames as messages of its own. // // Enum zero values (*_UNSPECIFIED): proto3 requires a zero value; it is // deliberately NOT a legitimate wire value. It exists so that an unset field is // detectable and no implementation can silently rely on a default. Receivers MUST // reject messages carrying it. // -// Implementation groundwork: bee PR #5435 (hand-rolled byte framing with the same -// semantics). +// Implementation: bee PR #5626. syntax = "proto3"; package bps; @@ -36,8 +27,9 @@ package bps; option go_package = "github.com/ethersphere/bee/v2/pkg/bps/pb"; // --------------------------------------------------------------------------- -// Cohort genesis — immutable policy, and the cohort's identity. The roster is -// NOT here (see ServiceKind). +// Cohort genesis — immutable policy, and the cohort's identity: cohorts are keyed +// by the spec's canonical serialisation (fields in number order, unset fields not +// emitted). The roster is NOT here (see ServiceKind). // --------------------------------------------------------------------------- // What the topic binds to (see SWIP-60: binding semantics). SWIP-74 defines @@ -54,15 +46,15 @@ enum TopicBinding { // never unattributable, since every message is SOC-signed. } -// One value. Set: anyone attached may publish (group chat). Unset, with an -// admin: the admin publishes, and whoever its roster ever names -- a cohort is -// multi-publisher iff a ROSTER is ever published, and nobody needs to know in -// advance. With no admin the cohort is implicit: authorship follows the -// binding's SOC shape and this does not apply. +// One value. Set: anyone attached may publish (group chat) -- no claim; a stream +// declares the address it publishes as, and every message it sends is validated +// against it. Unset, with an admin: the admin publishes, and whoever its roster +// ever names -- a cohort is multi-publisher iff a ROSTER is ever published, and +// nobody needs to know in advance. With no admin the cohort is implicit: +// authorship follows the binding's SOC shape and this does not apply. enum PublisherRegime { PUBLISHER_REGIME_UNSPECIFIED = 0; // invalid on the wire (see header note) - ALL = 3; // anyone attached; needs MNEMONIC binding - reserved 1, 2; // were ADMIN_ONLY, GRANTED: the roster decides, not the spec + ALL = 1; // anyone attached; needs MNEMONIC binding } // Fixed by whoever joins first; immutable; keyed as a whole -- two specs that @@ -77,64 +69,63 @@ enum PublisherRegime { message CohortSpec { bytes topic = 1; // 32 bytes, meaning per binding TopicBinding binding = 2; - bytes admin = 5; // 20-byte eth address: the cohort's authority + bytes admin = 3; // 20-byte eth address: the cohort's authority // and always a member of its publisher set. // Absent (length 0) => implicit authorship, // and `publishers`, `closed` do not apply. - PublisherRegime publishers = 3; // ALL, or unset (see the enum) - bool history = 4; // deliver matching chunks from the local store - bool closed = 8; // no audience: a joiner whose identity is not - // the admin's or on the roster is REJECTED. - // Unset = open, which is why this is `closed` - // and not `spectators`: a lite spec never sets - // it and must read as an open cohort. - reserved 6, 7, 9; - // 6 was `publisher_list` -- now dynamic, carried as ServiceKind.ROSTER; - // 7 was `po_min` -- now the protocol constant PO_MIN; - // 9 was `spectators` -- inverted polarity of `closed`; never reuse. + PublisherRegime publishers = 4; // ALL, or unset (see the enum) + bool history = 5; // deliver matching chunks from the local store + bool closed = 6; // no audience: a joiner receives nothing until + // its claim recovers to the admin or a rostered + // address, and is disconnected otherwise. Unset + // = open, so that a SWIP-74 spec, which never + // sets it, reads as an open cohort. } // --------------------------------------------------------------------------- // Stream establishment, stream name "pubsub/1.0.0" — one stream per -// (peer, cohort). The first and only handshake frame on a fresh stream is Join, -// carrying the full CohortSpec: it creates the cohort if no live cohort has -// this spec and attaches to it otherwise. The broker answers with Ack. The first -// frame settles the peer's role. +// (peer, cohort, identity). The first and only handshake frame on a fresh stream +// is Join; the broker answers with Ack. The first frame settles the cohort; the +// claim settles the role. // --------------------------------------------------------------------------- -// Proved, not asserted -- and in one operation: ecrecover yields the owner -// address AND proves possession of its key, so no challenge round trip. -// -// owner = ecrecover( H("bps-join:v1" || topic || admin), signature ) -// -// The identity is bound to the STREAM this frame arrives on, not to the peer -// connection: one node may carry different identities on different cohorts. -// -// The preimage is deliberately static and free of any node identity. Signing -// over the libp2p peer id would make this unreplayable, but would weld the -// publishing identity to the node holding the stream: the key could not be used -// from a second node without re-signing, and every join would link an eth -// identity to a peer id for anyone watching. An owner's identity is its own. -// -// The accepted consequence: a static preimage is replayable. It costs little, -// because a replayed role can publish only what its owner already signed -- -// see SWIP-74, Security considerations. Auth spares the broker from carrying -// peers whose frames could only ever be dropped; authorship rests on the -// message signature, never on the handshake. -// -// "bps-join:v1" is load-bearing: the same secp256k1 keys sign SOCs over -// (id || wrappedAddress), and the separator is what stops a join signature from -// ever being reinterpreted as a chunk signature, or the reverse. +// A secp256k1 signature, as a SOC's: r || s || v. message Auth { - bytes signature = 1; // 65 bytes - bytes id = 2; // 32-byte SOC id, where the binding does not fix it + bytes r = 1; // 32 bytes + bytes s = 2; // 32 bytes + uint32 v = 3; // 27 or 28 +} + +// A publisher's claim on the stream it is sent on. `auth` signs +// keccak256("bps-claim:v1" || S || O_B || index) +// with the key of `addr`, where +// S_C = a secret drawn once at broker boot, never persisted +// S_s = keccak256(Marshal(spec)) the cohort's key +// S_c = keccak256(S_C || S_s) the cohort's secret +// S = keccak256(S_C || S_c || addr) the challenge for addr on this cohort +// O_B is the overlay of the broker the claiming node is connected to and `index` +// (eight bytes big-endian) the publisher's cursor: its next message has a feed +// index >= index. The broker stores nothing and recomputes S at claim time; S is +// the same for an address on a cohort for as long as the broker runs, from any +// node. Sent inside Join by a peer that already holds S, or as the next frame +// after Ack by one that has just received it -- or has just seen itself named in +// a roster. No reply: the outcome is whether the stream survives the publication +// that follows. Under ALL there is no claim. +message Claim { + bytes addr = 1; // 20 bytes: the address claimed; equals Join.addr + uint64 index = 2; // the publisher's cursor: its next message has an index >= this + Auth auth = 3; } -// Peer -> broker: the first and only handshake frame. `auth` binds an identity -// to this stream; absent, the stream has none and is read-only (a spectator). +// Peer -> broker: the first frame on a fresh stream. Creates the cohort if no live +// cohort has this spec, attaches to it otherwise. message Join { CohortSpec cohort = 1; - Auth auth = 2; + bytes addr = 2; // 20 bytes: the address this stream will publish as -- + // under explicit authorship the one it will claim, under + // ALL the one every publication is validated against; + // absent: a spectator, and no challenge is issued + Claim claim = 3; // a returning publisher's claim, verified before any bound } enum Status { @@ -143,20 +134,14 @@ enum Status { FULL = 2; // a capacity bound (per cohort, per broker, per peer // connection); a singlehop broker refuses -- nothing // else - REJECTED = 4; // the SPEC is unacceptable -- a value outside this - // SWIP or a reserved field set -- or, under `closed`, - // a joiner whose identity is not the admin's or on the - // roster: the ONLY case in which a peer is refused for - // who it is - reserved 3; // was UNKNOWN_TOPIC: cannot occur, Join creates + REJECTED = 3; // the SPEC is unacceptable: a value outside this SWIP } -// Broker -> peer, answering Join. Status only: the joiner brought the spec, and -// the roster reaches it as the first Message on the stream (see ServiceKind; -// an open point in SWIP-60 -- the alternative is to carry it here in 3-5). +// Broker -> peer, answering Join. A non-OK Ack ends the stream. The roster +// reaches a newly attached stream as its first Message (see ServiceKind). message Ack { - Status status = 1; - reserved 2, 3, 4, 5; // were the spec echo, genesis, service, index + Status status = 1; + bytes challenge = 2; // S, iff status == OK and addr was declared } // --------------------------------------------------------------------------- @@ -165,15 +150,14 @@ message Ack { // Both directions after the handshake: publisher -> broker is a publication, // broker -> peer a delivery of the same bytes. The single-owner chunk travels -// as its stored chunk data, opaque to the protocol and validated by the ordinary -// SOC code: -// id (32) || signature (65) || span (8, LE) || payload (<= 4096) +// whole, address and data, opaque to the protocol and validated by the ordinary +// SOC code once the id slot has been rewritten as SWIP-74's Frames section says: +// data = id (32) || signature (65) || span (8, LE) || payload (<= 4096) // Under FEED_TOPIC a feed update's id slot carries the bare index (SWIP-74, // SWIP-65); a service SOC carries its full id (see ServiceKind). message Message { - bytes soc = 1; - reserved 2 to 15; // multihop control plane (SWIP-61): Reparent, Probe, - // Candidates, ... -- a singlehop peer never sends them + bytes address = 1; // 32 bytes: the SOC address, keccak256(id || owner) + bytes data = 2; } // --------------------------------------------------------------------------- @@ -196,20 +180,18 @@ message Message { enum ServiceKind { SERVICE_KIND_UNSPECIFIED = 0; // invalid on the wire (see header note) - ROSTER = 2; // the full publisher set as of this index (not a delta) - END_OF_STREAM = 3; // the admin closes the cohort, attributably - reserved 1; // was GENESIS: the spec is in every Join now + ROSTER = 1; // the full publisher set as of this index (not a delta) + END_OF_STREAM = 2; // the admin closes the cohort, attributably } // The payload of a service SOC. message ServiceMessage { ServiceKind kind = 1; - uint64 index = 4; // this update's index on the service feed + uint64 index = 2; // this update's index on the service feed repeated bytes publishers = 3; // set iff ROSTER: 20-byte eth addresses, the // complete set excl. admin (who is always a // publisher). Full state, not a delta, so a // reader needs only the latest it can verify. - reserved 2; // was `spec` (GENESIS) } // Keepalive / RTT: none at the BPS level. Liveness is the transport's job diff --git a/SWIPs/swip-60.md b/SWIPs/swip-60.md index 4f7b87d4..12ad7cd6 100644 --- a/SWIPs/swip-60.md +++ b/SWIPs/swip-60.md @@ -12,7 +12,7 @@ created: 2026-08-03 +protobuf: assets/swip-60/bps.proto (revision 9, derived from SWIP-74's block). --> - **Business line**: real-time topic streams for dApps without storing chunks or polling — enough on its own for the five cohort shapes it defines: **jam** (a closed set of authors: @@ -25,9 +25,10 @@ protobuf: assets/swip-60/bps.proto (revision 8, derived from SWIP-74's block). - a broker, publishers and subscribers interoperate per the conformance section. Groundwork exists in bee [#5435](https://github.com/ethersphere/bee/pull/5435). - **Base**: [SWIP-74 BPS-lite](https://github.com/ethersphere/SWIPs/pull/111) — one - publisher over a feed, one broker, one hop, three frames. This SWIP adds cohort - parameters, the admin's service feed and the Bee API on top of that wire and never - changes it: a SWIP-74 peer is a conformant peer of the live-stream configuration below. + publisher over a feed, one broker, one hop, four frames and a claim handshake. This + SWIP adds cohort parameters, the admin's service feed and the Bee API on top of that + wire and never changes it: a SWIP-74 peer is a conformant peer of the live-stream + configuration below. - Bandwidth-incentive integration is a separate SWIP (bps-bw-incentives). - Broker discovery integration is from a separate SWIP (bps-broker-discovery, building on [SWIP-59 MEX](https://github.com/ethersphere/SWIPs/pull/103)). @@ -63,8 +64,9 @@ Per topic-cohort: A cohort is fully described by a `CohortSpec` ([bps.proto](assets/swip-60/bps.proto)), fixed the moment the first peer brings it to a BPS-speaking full node, and **immutable for the -cohort's lifetime**. **The spec is the cohort's identity**: every joiner carries it, and -two specs that differ in any field are two cohorts, even on one topic. There is no mode +cohort's lifetime**. **The spec is the cohort's identity**: every joiner carries it, cohorts +are keyed by its canonical serialisation (SWIP-74), and two specs that differ in any field +are two cohorts, even on one topic. There is no mode enum; **modes are combinations of these parameters**. | parameter | values | meaning | @@ -72,8 +74,8 @@ enum; **modes are combinations of these parameters**. | `topic` | 32 bytes | interpreted per `binding` | | `binding` | `MNEMONIC` / `ANCHOR` / `SOC_ID` / `OWNER` / `FEED_TOPIC` | what the topic binds to; fixes which SOCs qualify as messages and the dedup rule | | `admin` | eth address | the cohort's authority and a member of its publisher set — not necessarily the first to join. **Absent ⇒ implicit authorship**, and the two fields below do not apply | -| `publishers` | `ALL` or unset | set: anyone attached may author. Unset: the admin, and whoever its roster ever names — **a cohort is multi-publisher iff its admin ever publishes a roster**, and nobody needs to know in advance | -| `closed` | bool, unset = open | set: no audience — a joiner whose identity is not the admin's or on the roster is refused | +| `publishers` | `ALL` or unset | set: anyone attached may author — no claim; a stream declares the address it publishes as, and every message it sends is validated against it. Unset: the admin, and whoever its roster ever names — **a cohort is multi-publisher iff its admin ever publishes a roster**, and nobody needs to know in advance | +| `closed` | bool, unset = open | set: no audience — a joiner receives nothing until its claim recovers to the admin or a rostered address, and is disconnected otherwise | | `history` | bool | deliver matching chunks already in the local store (mechanism in bps-history; a singlehop broker MAY refuse) | **The publisher list is deliberately not here.** It is dynamic — an admin grants and revokes @@ -154,8 +156,9 @@ it.) Broker **capacity is deliberately not a cohort parameter**: a cohort cannot dictate a remote node's connection count. Each broker enforces its own per-cohort stream limit and -answers `FULL` when it is exhausted — to a `Join` without a publisher's `Auth`: the -audience cannot lock the admin, or a rostered publisher, out of its own cohort. +answers `FULL` when it is exhausted — admitting one extra stream for each legitimate +publisher that is absent, so that the audience cannot lock the admin, or a rostered +publisher, out of its own cohort (SWIP-74). **Cohort lifetime** is broker-side in the same way, with one exception. A cohort is not tied to whoever joined first, nor to its admin's stream: it ends by **inactivity** — the @@ -185,504 +188,48 @@ admin has published nothing has an empty service feed, and the admin alone may w Each service message carries its own index in the payload, so its id is verifiable without an out-of-band hint. Three properties follow, and each of them is the point: -- **The spec is nobody's word, and the admin is authenticated.** Every joiner brings the - spec in its `Join`, so a broker cannot serve a peer a cohort it did not name; `admin` - is an address anyone can read, its `Auth` at join is a recovered signature, and every - message and every service message carries its signature. Nothing in the handshake - needs to be trusted. -- **It is a feed, not a single mutable slot.** The obvious alternative — one constant-id SOC - overwritten in place — makes a stale roster **undetectable**, which would reintroduce - forging-by-omission at the one point that decides who may write. Sequential indices make - gaps visible, so withholding stays a *liveness* fault like every other withholding in this - protocol, and **self-indexing** feeds ([SWIP-65](https://github.com/ethersphere/SWIPs/pull/106)) - carry the construction. -- **The roster is verified end-to-end, like every message.** Service messages are ordinary - SOCs on the ordinary path — storable, re-fetchable, and checked with the same code as any - broadcast. A broker relays them; it cannot author them. - -**`Ack` is a status, and the roster is the first delivery.** On every newly attached -stream not bound to the admin's identity the broker delivers the **latest service SOC** as -the first `Message` before any other **(?)**; a joiner learns who may write from the -admin, not from the broker, before it has received a single message, and a cohort with an -empty service feed delivers nothing first — the admin alone may write. - -#### `Auth`: recovered, not asserted, and not tied to a node - -`Auth` carries **a signature and no address**: the owner is the ecrecover output, so -presenting it is possession of a key, not a claim about one — identity and proof arrive in -the same operation and the handshake stays one frame each way, with no challenge round trip. - -``` -owner = ecrecover( H( "bps-join:v1" ‖ topic ‖ admin ), signature ) -``` - -The preimage is deliberately **static, and free of any node identity**. Signing over the -libp2p peer id would make the credential unreplayable, but at the cost of welding the -publishing identity to the node holding the stream: the same key could not be used from a -second node without re-signing, and every join would link an eth identity to a peer id for -anyone watching. Neither is acceptable — an owner's identity is its own, not its node's. - -The consequence, taken deliberately: a static preimage is **replayable**. It costs little: -a replayed role can publish only what its owner already signed, which the binding's dedup -refuses within a cohort's life and the subscriber's own cursor catches across one (Security -considerations). What `Auth` buys is that the broker need not carry peers whose frames could -only ever be dropped; **authorship rests on the message signature, never on the -handshake.** - -The **`"bps-join:v1"` domain separator is load-bearing**. These are the same secp256k1 keys -that sign SOCs, over the preimage `id ‖ wrappedAddress`. Without separation a join signature -could be reinterpreted as a chunk signature, or a chunk signature coaxed out of a peer and -replayed as a join. The prefix makes the two preimage spaces disjoint by construction. - -The identity `Auth` proves is **bound to the stream it arrives on, not to the peer -connection**: one node may carry different identities on different cohorts, and a stream -without `Auth` has none. Under implicit authorship there is no `Auth` at all: the SOC -itself is the credential, and its shape is checked on every `Message`. - -### The first frame settles the role - -A peer's role is fixed by its **first frame**, `Join` — the only handshake frame there is -— carrying the full `CohortSpec` and, if the peer claims an identity, an `Auth`. The -broker compares the spec with its live cohorts: **no match → the cohort is created** with -the joiner attached; **match → the joiner is attached**. Anyone whose `Join` is accepted -may create — in an open cohort that includes a spectator arriving before the admin, and a -cohort costs the broker a map entry until the inactivity deadline reclaims it; under -`closed` a `REJECTED` `Join` creates nothing, so the admin's is the first accepted one. -Cohorts are keyed by the **whole spec**, so pre-creating a topic under a wrong admin -squats nothing — the genuine spec is a different cohort. - -Then the stream's role, from the address `Auth` recovers, matched against `admin` and the -**current roster**: - -| outcome | `closed` unset | `closed` set | -|---|---|---| -| recovered address is the admin's, or in the roster | joins as **publisher** | joins as **publisher** | -| no match, or no `Auth` | joins as **spectator**, read-only — the identity, if any, stays bound to the stream and a later roster naming it promotes the stream in place | `REJECTED` | - -`closed` is the only configuration in which a peer is turned away for *who it is*, and it -is enforceable precisely because `Auth` is recovered rather than asserted. Everywhere else -`REJECTED` means the *spec* is unacceptable — a value outside this SWIP, or a reserved -field set — and `FULL` means capacity, nothing more. - -#### Grant and revocation - -An admin changes the roster by publishing the next service message; the cohort spec never -changes. A **grant** takes effect for the granted peer on its next join, or immediately if it -is already attached as a spectator whose `Join` carried its `Auth` — the identity is bound to -the stream, so the stream is promoted in place. - -A **revocation** has two phases, and the boundary between them is the moment the reduced -roster reaches subscribers: - -1. **Before it is published**, the revoked peer has no way to know it has been revoked — - nothing has told it. Its `Message` frames are therefore **dropped and tolerated**: - silently ignored, no penalty, the connection untouched. There is nothing else a broker can - honestly do, because the peer is not misbehaving. -2. **After it is published**, the peer has been told — it receives the service message like - every other subscriber, on the same feed. Publishing from that point is a **protocol - violation**, and the broker MUST break the connection. - -The announcement is therefore not only for the audience's benefit: **it is what converts an -unknowing publisher into a violating one.** A broker that tore the stream down before -publishing the reduced roster would be punishing a peer for a rule it had not been given; a -broker that never publishes it leaves everyone — the revokee included — in a state where the -violation can never begin, which is an ordinary, visible withholding fault. The penalty -itself is the protocol's existing one: repeated invalid frames end the connection -(blocklisting policy). - -Announcing first also makes the revocation legible to everyone else: subscribers learn *why* -a publisher fell silent from an admin-signed message rather than inferring it from a -disconnection they cannot attribute. - - -### Roles and capacity - -- **Broker**: the first full node contacted; root of the (here, depth = 1) multicast tree. - Enforces its own capacity. **At capacity it MUST answer `Join` with a refusal** - (`FULL`) — for the per-cohort stream bound, a `Join` whose `Auth` recovers to the admin - or to a rostered publisher is admitted past it, as in SWIP-74; referral to another - attachment point is reserved for bps-multihop — a singlehop-only broker simply refuses. Because any peer can make a broker allocate a - cohort simply by joining, a conformant broker also bounds **how many cohorts it will - create** and **how many one peer connection may hold**, and **reclaims idle ones** — - SWIP-74's bounds, all independent policy. -- **Admin**: the address the spec names — not necessarily the first to join — always a - member of the publisher set, and the cohort's only authority: it grants, revokes and - ends, each by publishing a service message. Its address is public in the spec — as a - stream's or a co-edited file's owner naturally is — while its grantees' are not. An - admin that never sends is a **moderator**; no separate role is needed, since being a - publisher obliges nobody to publish. -- **Publisher**: sends and receives — every `Message` of the cohort except its own, on - any of its streams. At - depth = 1 every peer is attached to the broker, so publishers are too — this is a - **consequence of singlehop, not a protocol invariant**. bps-multihop lifts it by - forwarding `Message` frames rootward as well as leafward, so a publisher may sit - several hops out; that is what lets an everyone-publishes cohort grow past one - broker's capacity. Attachment is in any case necessary, not sufficient — under - explicit authorship, the current roster decides. -- **Spectator**: receives only; joins with the same `Join` as everyone, carrying the - spec it was invited with, and the broker delivers the latest roster as its first - frame, so the cohort, its roster and every message are verified end-to-end. Every - peer receives, so publishing is the *additional* capability and this role is what - remains without it; a `closed` cohort has none. - -### Information flow - -```mermaid -sequenceDiagram - autonumber - participant PD as publisher dApp - participant PN as publisher's bee node
(WS bridge) - participant B as broker
(root, full node) - participant SN as subscriber's bee node
(WS bridge + mux) - participant SD as subscriber dApp(s) - - PN->>B: Join(CohortSpec, Auth) - Note over PN,B: the spec is the cohort's identity: the first Join creates it,
later ones attach; at depth = 1 every publisher is attached to the broker - B-->>PN: Ack(OK) - SN->>B: Join(CohortSpec, Auth?) - B-->>SN: Ack(OK) - B->>SN: Message(latest ROSTER) — the admin's word, relayed - Note over B,SN: spec in hand + admin-signed roster ⇒
subscriber verifies every message end-to-end - - PD->>PN: WS: payload - PN->>B: Message(SOC) - B->>B: validate: SOC sig ⊨ topic binding
(+ dedup per binding) - - par fan-out to every stream of the cohort not bound to the publishing identity - B->>SN: Message(SOC) — every frame self-contained - SN->>SN: mux: one p2p stream → N WS sessions - SN->>SD: WS: payload - end - Note over B,PN: other publishers' streams receive it the same way;
the author's own never do -``` - -The broadcast is **end-to-end authenticated**: every subscriber re-verifies the SOC -signature against the topic binding regardless of path. - -### Wire protocol - -Messages are defined in [bps.proto](assets/swip-60/bps.proto). Framing notes: - -- Transport: libp2p stream `pubsub/1.0.0`, one stream per (peer, cohort), - protobuf-over-libp2p as bee protocols elsewhere. The first and only handshake frame on - a fresh stream is **`Join`**, carrying the full `CohortSpec` and optionally an `Auth`; - the broker answers with `Ack{status}`, and delivers the latest service SOC as the - stream's first `Message`, so the joiner verifies the roster against the admin rather - than the broker. The three frames — `Join`, `Ack`, `Message` — and the two types they - carry are SWIP-74's; this SWIP adds fields and values, never frames. -- **`Join` creates or attaches**, keyed by the whole spec: a spec no live cohort has - creates one, a byte-identical spec attaches, and there is no "unknown topic". - Implicit-publisher cohorts rely on this — the first subscriber creates, so a client - need not know whether it is first — and so does every audience member arriving before - its admin. -- **Stream model rationale**: per-cohort streams give per-cohort flow control, teardown - and role typing, and match bee's protocol idiom. Because every frame carries the full - SOC (self-contained, no per-stream handshake state), a later move to topic-muxed - streams requires no format change. -- Every `Message` carries the **full chunk** as opaque chunk data (SWIP-74), validated by - the ordinary SOC code; there is no handshake/data frame split. Deliveries go to every - stream of the cohort except those bound to the publishing identity: a publisher never - receives its own messages back, on whichever of its streams it sent them (SWIP-74). -- No BPS-level keepalive or RTT probing: liveness is the transport's job, and latency - metrics for reorganisation policies are sourced there too. -- Broker validation on a `Message`: SOC signature verifies against the topic binding, PO - constraint holds where applicable, the stream's identity is a legitimate publisher (the - admin or a rostered address; anyone under `ALL`; the binding's SOC shape under implicit - authorship). Invalid ⇒ drop and count; repeated invalid ⇒ disconnect (blocklisting - policy). A message that passes and is a **duplicate** per the binding's dedup rule is - dropped and counted as a retransmit, never as invalid — an admin reconnecting after a - reset legitimately resends (SWIP-74); a broker MAY reset a stream whose retransmit rate - exceeds its policy. A `Message` on a stream whose identity may not publish — none, or one - neither the admin's nor rostered — is a protocol violation: dropped, the stream reset, - the peer blocklisted (SWIP-74). -- **Service messages** ride the same frame and are recognised before the content path: a - `Message` on a stream bound to `admin` whose payload decodes as a `ServiceMessage` and - whose id equals `keccak256("bps-service:v1" ‖ topic ‖ payload.index)` is a service SOC. - It is accepted iff it validates as a SOC under that id, its owner is `admin`, and - `payload.index` exceeds the service feed's cursor (initially absent: index 0 is - accepted); otherwise it is invalid. Under `FEED_TOPIC` the two paths are told apart by - the id slot alone — a feed update carries a bare index (24 leading zero bytes), a - service SOC its full id — which is why a SWIP-74 broker drops the latter rather than - punishing it. -- **The dedup *horizon* is implementation-defined, but it MUST be bounded**: the binding - fixes what counts as a duplicate, not how far back the broker remembers, and an - unbounded seen-set is a memory-exhaustion vector. A broker keeps a bounded window over - recent message identifiers; the accepted consequence is that a legitimate publisher can - overrun that window and replay an evicted message. Applications that cannot tolerate - replay carry their own sequencing — which the sequential construction of - [SWIP-65](https://github.com/ethersphere/SWIPs/pull/106) gives for free. A SWIP-74 - broker is stricter on its one configuration — a per-cohort **cursor**, `index > cursor`, - no window at all — which this SWIP does not adopt: with several publisher feeds on one - topic, and with the reordering of [SWIP-61](https://github.com/ethersphere/SWIPs/pull/105), - a full broker dedups on chunk address and passes retransmits through, and the subscriber's - own cursor does the rest. - -### API (WebSocket bridge) - -One endpoint pair on the Bee API. Endpoint shape follows bee -[#5435](https://github.com/ethersphere/bee/pull/5435), generalised from its single -hardcoded mode to the full parameter space; serialization conventions follow the SOC -subscription family — GSOC/MIC/MOC (bee -[#5486](https://github.com/ethersphere/bee/pull/5486), -[#5497](https://github.com/ethersphere/bee/pull/5497)) — whose `/mic/subscribe/{owner}` -and `/moc/subscribe/{id}` endpoints are the storage-fed counterparts of the `OWNER` and -`SOC_ID` bindings, so a dApp switches between stored and live feeds without -reformatting. All p2p framing is transparent to WS clients; one p2p stream is muxed to -N local WS sessions per topic. - -**`GET /pubsub/{topic}`** — upgrades to a WebSocket session on the topic. `{topic}` is -the 32-byte topic hex-encoded, or an arbitrary string hashed to 32 bytes (mnemonic -topics). Query parameters: - -| parameter | maps to | meaning | -|---|---|---| -| `peer` | — | broker underlay multiaddr; required until broker discovery exists (bps-broker-discovery) — early deployments configure it | -| `binding`, `admin`, `publishers`, `closed`, `history` | `CohortSpec` | **the spec, on every session**: `binding` always, the others where the spec sets them — the spec is the cohort's identity and the invite carries it; the node sends `Join` with the assembled spec, which creates or attaches, and keys its sessions by the whole spec, not the topic. `admin` omitted ⇒ implicit authorship. No publisher list here — it is not part of the spec | -| `auth` (+ `id` where the binding does not fix it) | `Auth` | 65-byte join signature over `H("bps-join:v1" ‖ topic ‖ admin)`. **Binds an identity to the session's stream**: read–write iff that identity is the admin's or currently rostered (or the cohort is `ALL`), read-only otherwise and promoted in place when a later roster names it; absent, a spectator with no identity. Signed client-side, like every other signature here — the node holds no publisher keys, and the signature is over no node identity, so the same key works from any node | - -**`POST /pubsub/{topic}/service`** — the admin's control plane: submits a service message -(`ROSTER` or `END_OF_STREAM`) as the next update on the service feed. The SOC is signed -client-side by the admin key; the node relays it on the cohort whose `admin` that key is. -Granting or revoking a publisher is one call here and touches no cohort parameter. - -Headers: - -- `swarm-keep-alive` (seconds, default 60): ping period of the **local WS link only** — - not to be confused with the p2p layer, which has no keepalive. -- `swarm-soc-fields` (per bee [#5497](https://github.com/ethersphere/bee/pull/5497)): - comma-separated SOC fields serialized per outbound message — `address`, - `recoveredPubKey`, `identifier`, `signature`, `wrappedAddress`, `span`, `payload`; - default `payload`. This is how dApps on implicit-binding streams (`OWNER`, `SOC_ID`, - feed) attribute messages — no BPS-specific frame format. -- `swarm-cache-wrapped-chunk` (per bee - [#5497](https://github.com/ethersphere/bee/pull/5497)): when true, the wrapped chunk - of every incoming message is stored in the local cache, resolvable through the bytes - endpoint — for streams whose messages reference content larger than one chunk. - -**`GET /pubsub/`** — lists the node's active topics: topic address, cohort parameters, -own role (broker / subscriber), connected peers. - -**Signing — the key-holding rule.** Message signing is the dApp's business: **the node -never holds publisher keys**. Inbound (publisher → node): `sig ‖ span ‖ payload`, -signed client-side (bee-js). Where the binding does not fix the SOC id, the frame is -prefixed with it — for feed bindings the prefix is the bare index, the signed id being -the feed id `keccak256(topic ‖ index)` (self-indexed feeds, -[SWIP-65](https://github.com/ethersphere/SWIPs/pull/106)); -under explicit regimes with `ANCHOR` binding the id does no work and there is no -prefix. The node assembles the SOC, validates it exactly as a broker would, and -publishes. End-to-end verification against the `CohortSpec` the session supplied — the -spec the node sent in `Join` — is performed by the local node — node and dApp are one -trust domain. - -**Worked API calls — the jam cohort** (see Configurations below). Seat A joins — its -`auth` recovers to `admin` ⇒ read–write; the spec creates the cohort, since under `closed` -nobody else's `Join` is accepted before A's: - -``` -wss://node:1633/pubsub/jam-tuesday?peer= - &binding=anchor&admin=0xA…&closed=true&auth=0x3f2a… -``` - -Seats B–D join with the same spec and their own `auth`: - -``` -wss://node:1633/pubsub/jam-tuesday?peer= - &binding=anchor&admin=0xA…&closed=true&auth=0x9c14… -``` - -Seats B–D are not named in this URL and never appear in a cohort parameter: A grants them -with a `POST /pubsub/jam-tuesday/service` carrying a `ROSTER` message, and can revoke or add a -fifth seat later without any of the above changing. Each seat is sorted into the publisher -role by the address recovered from its `auth`; because the cohort is `closed`, a peer with no -listed key is `REJECTED` rather than admitted read-only. The join URL minus `auth` is the -complete out-of-band invite (spec + broker) until broker discovery exists — and it is -genuinely an invite: only a holder of a rostered key can turn it into a session at all. -A live MIC — all SOCs of one owner, the light-client twin -of `/mic/subscribe/{owner}` — is the implicit case: every subscriber joins with -`?binding=owner`, no `admin` and no `auth` (the first creates, the rest attach), -topic = `keccak256(owner)`, read-only, `swarm-soc-fields: identifier,payload`. - -### Configurations (worked examples) - -The five configurations, as `CohortSpec` rows. - -**Jam** — a 4-seat collaborative remix edit, a strudel livecoding session, a multiparty game. - -``` -binding: ANCHOR (topic = mnemonic anchor) admin: 0xA… -closed: true history: false -``` - -Seat A joins; B, C and D are granted by a `ROSTER` service message, and each is sorted into -the publisher role on joining because the address recovered from its `Auth` is on the roster -it can verify against A's key. A fifth peer is `REJECTED` — this is the one configuration in -which a peer is refused for who it is, and it is enforceable because `Auth` is recovered, not -asserted. A may grant a fifth seat, or revoke one, without the cohort spec changing at all. -Confidentiality is still not on offer: the broker holds plaintext, and a jam that needs it -encrypts payloads. - -**Spectator-jam** — the same, opened to an audience. - -``` -binding: ANCHOR admin: 0xA… -history: false -``` - -Identical authorship, but an unrecognised joiner is admitted read-only instead of refused — -and, if its `Join` carried an `Auth`, promoted in place when a later roster names it. -The audience verifies the roster from the admin's feed, so it knows exactly whose messages -are legitimate without trusting the broker. - -**Live-stream** — single publisher, open audience. - -``` -binding: FEED_TOPIC (sequential index) admin: the streamer -history: false -``` - -This is [SWIP-74](https://github.com/ethersphere/SWIPs/pull/111)'s cohort exactly, and a -SWIP-74 peer is a conformant peer of it. The spec is the same as a spectator-jam's: the -streamer simply never publishes a roster, so it stays the only author, and the audience -verifies every message against its key regardless. What this SWIP adds is the end: the -streamer ends it with an `END_OF_STREAM` service message, which is what distinguishes -"over" from "the broker stopped relaying" — and from SWIP-74's inactivity reclaim. - -**Group-chat** — anyone attached may speak. - -``` -binding: MNEMONIC (the topic is just the cohort's name) admin: 0xA… -publishers: ALL history: false -``` - -No roster, no `Auth`, no constraint on the SOCs: each peer signs and sends its own. The topic -binds nothing — it names the cohort, and that is all it does. Authorship is unrestricted but -never *unattributable*: every message is SOC-signed, so the chat knows exactly who said what -without there being an authorised set to check against. The admin here is not a gatekeeper — -it cannot be, since everyone may write — but it still owns the service feed, so it can end -the cohort. This is the row that outgrows a single broker fastest, and the one -[SWIP-61](https://github.com/ethersphere/SWIPs/pull/105) exists to scale: with publications -forwarded from the leaves towards the root, a member need not be attached to the broker to -speak. Where a cohort wants no authorship guarantees at all, see "why not gossipsub". - -**Implicit** — no admin, no roster, no authority. - -``` -binding: OWNER (topic = keccak256(owner)) admin: absent -history: false -``` - -A live MIC: all SOCs of one owner, the light-client twin of `/mic/subscribe/{owner}`. There -is no admin, so no service feed, no grants and no end-of-stream — nothing to authenticate, -because **the chunk carries its own legitimacy** and the binding's SOC shape is the whole -check. `SOC_ID` gives the multi-author version of this (MOC: id fixed, each publisher mining -its own owner into the anchor neighbourhood — own-identity writers, as in -[SWIP-66](https://github.com/ethersphere/SWIPs/pull/107)), and `MNEMONIC` the unconstrained -one, which is group-chat minus the authority to end it. - -### The modes — enumerated as combinations of dimension choices - -Known use cases attach here; each mode is nothing more than a row — a combination of -publisher/subscriber info, topic match type, and history. (`+/−` = both configurations -meaningful.) - -| configuration | binding | audience (`closed` unset) | history | use case | -|---|---|---|---|---| -| live-stream | feed topic, index sequential | + | — | live video streaming | -| spectator-jam | feed topic, index sequential | + | — | live videoconference | -| jam | anchor | — | +/— | private co-authoring, remix editing | -| group-chat | mnemonic — no constraint | + | +/— | multi-party / group chat | -| implicit | anchor (ephemeral GSOC) | + | +/— | anythread comments / troll-box | -| implicit | id fixed, owner mined (MOC) | + | +/— | own-identity writers on a shared id | -| implicit | id = `keccak256(topic ‖ index)` | + | +/— | following one or more feeds | -| implicit | feed special, mined index | + | +/— | following graffiti soc | -| implicit | owner (MIC) | + | +/— | tags, adverts | - -At depth = 1 the broker's capacity bounds **both** directions: the audience by its stream -count, and — since every publisher is attached to it — the publisher count too. Scaling -either past one broker is bps-multihop's business -([SWIP-61](https://github.com/ethersphere/SWIPs/pull/105)), which forwards `Message` -frames rootward as well as leafward. The everyone-publishes rows above — group chat, -videoconference, troll-box — are the ones that need it. - -The implicit rows and history are specified in bps-implicit-publisher and bps-history -respectively — with the split that **this** SWIP fixes *who* an implicit publisher is (the -binding-to-SOC-shape table above, and the cardinality that follows from it), because that is -validation the broker cannot operate without, while bps-implicit-publisher keeps the -event-sourcing mechanism built on top. - -## Rationale: why not gossipsub - -libp2p ships gossipsub, a battle-tested mesh multicast. BPS builds its own protocol -because gossipsub's core mechanisms — flooding to a random mesh, IHAVE/IWANT -pull-recovery — are exactly what an incentivised network rejects: **no node wants to pay -for a message it did not ask for.** That one economic fact dissolves gossipsub's -machinery: metered edges mean no redundant paths and no transport-level duplicates; a -cohort's `CohortSpec` scopes every session; authentication is structural (SOC-signed -against the topic binding), so brokers and relays forward without being trusted — an -intermediate can withhold, never forge; and withholding is a liveness fault recoverable -by re-pointing or relocating the topic. Multihop forwarding (bps-multihop) adds capacity -without reintroducing flooding: every edge still pays upstream, every node still receives -only its topic's stream — and publishing from depth > 1 is metered the same way, priced by -depth (bps-bw-incentives). - -**And in the happy case the tree wins on traffic, not only on trust.** A publish in a -multihop cohort travels **rootward** from wherever it originates and then **leafward** to -everyone: each edge carries the message **exactly once**. A single-parented tree therefore -needs no duplicate suppression at all — no seen-set, no IHAVE/IWANT pull-recovery, no -mesh-degree multiplier applied at every hop. Gossipsub pays D copies per node by -construction and recovers the remainder by asking. Where the tree is well matched to the -underlay — a **closely knit topology**, peers whose tree edges are also their short paths — -rootward-then-leafward is simply the cheaper delivery, and a publisher sitting at depth d -pays those d hops once, on the way up. Duplicates in BPS are a deliberate purchase rather -than a structural cost: dual parenting in -[SWIP-61](https://github.com/ethersphere/SWIPs/pull/105) buys withholding-masking with a -second copy, and that is the case in which the dedup horizon above earns its keep. - -**The concession.** Where an application genuinely wants *gossip* — a large symmetric -cohort with no publisher structure, every member a source, message-level flooding the -point, and no interest in who signed what — **libp2p gossipsub is the better tool and the -application should simply use it.** BPS is not trying to win that comparison. It earns its -keep where the cohort has shape: authorship that is structurally authenticated (SOC-signed -against the topic binding, verifiable regardless of path, so an intermediate can withhold -but never forge), edges that are bounded and metered, messages that are chunks and so -re-fetchable from storage, and a `CohortSpec` that states who may write. The implicit cohort -exists for symmetric groups that want *those* properties — a group chat whose messages are -verifiable signed chunks — not to reimplement a mesh. - -## Security considerations - -**The spec is nobody's word, and the admin is authenticated.** Every joiner carries the +- **The spec is nobody's word, and the admin is authenticated.** Every joiner carries the spec in its `Join`, so a broker cannot serve a peer a cohort it did not name, and a cohort somebody else pre-creates under a wrong admin is simply a different cohort. -`admin` is a public address; its `Auth` at join is a recovered signature, and every -message and every roster it publishes carries its signature. Nothing else in the -handshake needs to be trusted, because the roster arrives the same way — signed by the -admin, on a feed whose gaps are visible. - -**The publisher role is proved, not asserted.** `Auth` carries a signature and no address: -the owner is recovered from it, so presenting it is possession of a key. The preimage is -static and carries **no node identity** — deliberately. Binding it to the libp2p peer id -would make it unreplayable, but would weld the publishing identity to the node holding the -stream: the key could not be used from a second node without re-signing, and every join would -link an eth identity to a peer id for anyone watching. An owner's identity is its own, not -its node's. - -The accepted consequence: a static preimage is **replayable**, and a replayed role can -publish only what its owner already signed — worthless within a cohort's life, where the -binding's dedup refuses it, and a matter for the subscriber's own cursor across cohorts -(SWIP-74, *Security considerations*). Which is the deeper point — - -**Defence in depth is the real guarantee.** Even a peer that obtains the publisher role gains -nothing by it: every message is validated on arrival against the SOC signature and the -current roster (or, for an implicit cohort, the binding's SOC shape). `Auth` spares the -broker from carrying peers whose frames could only ever be dropped; **authorship rests on the -message signature, never on the handshake.** A **challenge round trip** is therefore not -specified: it would cost a frame in an otherwise one-each-way establishment to harden a -credential that grants nothing on its own. +`admin` is a public address; its claim is a signature over a challenge only this broker +could have issued for it, and every message and every roster it publishes carries its +signature. Nothing else in the handshake needs to be trusted, because the roster arrives +the same way — signed by the admin, on a feed whose gaps are visible. + +**The publisher role takes the key, every time.** A claim signs a challenge derived from a +broker secret, the cohort and the address, together with the verifier's overlay and the +publisher's cursor. A third party cannot obtain a claim (it travels on the encrypted +stream to the broker and nowhere else); one captured elsewhere recovers to some other +address here (`S` differs per broker, per restart and per cohort, and `O_B` names the +verifier); a challenge forwarded by a relay the publisher was pointed at yields a +signature over the relay's overlay, which the honest broker refuses; a claim for another +address, or with a changed cursor, does not recover to the address it names. What can be +replayed is the identity's own claim, by the node that bridged it, at this broker, until it +restarts — and that node held the identity's stream anyway. SWIP-74's *Security +considerations* has the case-by-case table. + +**History is not a break.** A broker or relay that carried a cohort can deliver the admin's +signed updates to a late viewer after a reclaim; those are genuine updates in order, and +the viewer is caught up, not deceived. Freshness is the feed's business — the subscriber's +cursor per `(topic, admin)`, the timestamp key of +[SWIP-65](https://github.com/ethersphere/SWIPs/pull/106) — not the handshake's. + +**The transport precondition.** The claim rests on `O_B` being the overlay the publisher's +node is actually connected to, and on the broker knowing the peer it is talking to. A BPS +node MUST verify, in the p2p handshake, that a peer's signed address record names the +connection's authenticated peer ID; a record that is merely self-consistent can be +presented by anyone who has seen it. + +**Defence in depth is the real guarantee.** Even a stream that obtains the publisher role +gains nothing by it beyond what its key already signs: every message is validated on +arrival against the SOC signature, its address, and the stream's address (or, for an +implicit cohort, the binding's SOC shape). **Authorship rests on the message signature; +the handshake decides only who is carried as a publisher.** **Audience control exists in exactly one form, and it is not confidentiality.** -`closed` refuses a joiner outside the roster, and is enforceable because `Auth` is -recovered rather than asserted. It bounds *attendance at this broker*, nothing more. **BPS +`closed` keeps a joiner outside the roster silent and then disconnects it, and is +enforceable because a claim is signed over a challenge only this broker could have issued +for that address. It bounds *attendance at this broker*, nothing more. **BPS provides no confidentiality at any layer**: the broker sees every message in plaintext, and so does everyone it admits. Applications needing a bounded audience **encrypt payloads** — SOC wrapping is orthogonal to payload encryption, and key distribution is the application's @@ -703,10 +250,11 @@ unattributable disconnection. **Resource bounds are broker policy, and all are required.** A conformant broker bounds its per-cohort stream count (`FULL`), the number of cohorts it will create and the number one peer connection may hold (any peer can make it allocate a cohort simply by joining), -and reclaims idle cohorts — SWIP-74's bounds — and bounds its dedup window (see the -horizon note above), which SWIP-74's one configuration replaces with a cursor. The bounded -dedup window admits replay of an evicted message by an already-legitimate publisher: a -cohort-internal nuisance, not a break of authorship. +and reclaims idle cohorts — SWIP-74's bounds, plus one extra stream per absent publisher +and its claim deadline — and, for the bindings that dedup on chunk address, bounds its +dedup window (see the horizon note above); feed publishers under explicit authorship have +a cursor instead. The bounded dedup window admits replay of an evicted message by an +already-legitimate publisher: a cohort-internal nuisance, not a break of authorship. ## Out of scope (deliberately) @@ -734,16 +282,22 @@ An implementation is conformant when: 4. a `FULL` refusal is issued at capacity — and nothing else is (no referral); 5. the WS bridge round-trips each worked configuration end to end — join, publish, receive — with all signing on the client side (the node holds no publisher keys); -6. the handshake is one `Join` carrying the full spec, creating the cohort or attaching - to it, keyed by the whole spec; `Ack` is a status; a newly attached stream receives - the latest service SOC as its first `Message` **(?)**; +6. the handshake is one `Join` carrying the full spec and the address the stream will + publish as, creating the cohort or attaching to it, keyed by the spec's canonical + serialisation; `Ack` is a status and, for a declared address, the challenge; a newly + attached stream not bound to the admin receives the latest service SOC as its first + `Message` **(?)**; 7. an absent `admin` is treated as implicit authorship — validated strictly per the - binding's SOC shape — and a present one authenticated by its `Auth` at join and by its - signature on every service message, both of which MUST recover to it; -8. `Auth` is verified by recovery over `H("bps-join:v1" ‖ topic ‖ admin)`, the identity - is bound to the stream, and a joiner outside the roster is admitted read-only where the - cohort is not `closed` — promoted in place if a later roster names it — and `REJECTED` - where it is — the only refusal for identity in the protocol; + binding's SOC shape — and a present one authenticated by its claim and by its signature + on every service message, both of which MUST recover to it; +8. a claim is verified over `keccak256("bps-claim:v1" ‖ S ‖ O_B ‖ index)` with `S` + derived as SWIP-74 specifies, the signer equal to the declared address, and the address + the admin's or rostered — under `ALL` there is no claim and every message is checked + against the declared address; a rostered claim upgrades the stream and sets its cursor, + no reply is sent; a joiner without a claim is a spectator where the cohort is not + `closed`, and silent until it claims where it is — the only refusal for identity in the + protocol; the node verifies in the p2p handshake that a peer's signed address record + names the connection's authenticated peer ID; 9. an admin grants and revokes by publishing `ROSTER` service messages; a revoked publisher's frames are **dropped and tolerated** until the reduced roster is published, and its connection is broken only if it publishes **after** that point; @@ -759,11 +313,12 @@ SWIP-74 broker refuses at the handshake every spec that differs from `{topic, FEED_TOPIC, admin}`. The one thing it cannot refuse there is a feed-topic cohort whose admin later publishes a roster — the spec is the same — and it serves that as a live stream: the roster and the grantees' updates are dropped as invalid, so an admin that wants -a roster needs a full broker. Reserved `Message` fields hold the multihop control plane, -so bps-multihop extends without a version bump — +a roster needs a full broker. bps-multihop adds its control frames as messages of its own, +so it extends without a version bump — [SWIP-61](https://github.com/ethersphere/SWIPs/pull/105) is to be re-based on the `Message` -frame, a rename of its `Publish`/`Broadcast`; self-contained frames mean a change of stream -model needs no format change either. +frame, a rename of its `Publish`/`Broadcast`, and on the claim, which it forwards +rootward; self-contained frames mean a change of stream model needs no format change +either. ## References From 5ebfb18fb72cc6ced52fbb0ba40819470b0a57ff Mon Sep 17 00:00:00 2001 From: zelig Date: Thu, 24 Sep 2026 16:40:36 +0200 Subject: [PATCH 12/14] swip-60 rev 6 fix: restore the sections the rev 6 commit deleted by a bad anchor; closed and implicit rules The rev 6 edit script anchored the Security rewrite on a sentence that also occurs in the service-feed section and cut everything between them (the claim section, the role table, grant/revocation, roles, information flow, wire protocol, API, configurations, modes, the gossipsub rationale). Rebuilt from rev 5 with the same amendments, correctly anchored. Also: implicit authorship declares addr and needs no claim (proto comment, claim section, conformance 7); closed cohorts get an explicit trigger (silent, disconnected unless a legitimate claim arrives within the claim deadline; the roster is not delivered before the claim); a claim in the Join that does not verify is treated as absent; the signing convention is marked (?) for the author. Co-Authored-By: Claude Fable 5.1 --- SWIPs/assets/swip-60/bps.proto | 26 +- SWIPs/swip-60.md | 539 +++++++++++++++++++++++++++++++-- 2 files changed, 536 insertions(+), 29 deletions(-) diff --git a/SWIPs/assets/swip-60/bps.proto b/SWIPs/assets/swip-60/bps.proto index db7571e6..8cbf82c7 100644 --- a/SWIPs/assets/swip-60/bps.proto +++ b/SWIPs/assets/swip-60/bps.proto @@ -75,11 +75,12 @@ message CohortSpec { // and `publishers`, `closed` do not apply. PublisherRegime publishers = 4; // ALL, or unset (see the enum) bool history = 5; // deliver matching chunks from the local store - bool closed = 6; // no audience: a joiner receives nothing until - // its claim recovers to the admin or a rostered - // address, and is disconnected otherwise. Unset - // = open, so that a SWIP-74 spec, which never - // sets it, reads as an open cohort. + bool closed = 6; // no audience: every stream is admitted silent, + // receiving nothing, and is disconnected unless + // a claim recovering to the admin or a rostered + // address arrives within the claim deadline. + // Unset = open, so that a SWIP-74 spec, which + // never sets it, reads as an open cohort. } // --------------------------------------------------------------------------- @@ -97,8 +98,8 @@ message Auth { } // A publisher's claim on the stream it is sent on. `auth` signs -// keccak256("bps-claim:v1" || S || O_B || index) -// with the key of `addr`, where +// "bps-claim:v1" || S || O_B || index +// with the key of `addr`, in the same convention as a SOC signature (?), where // S_C = a secret drawn once at broker boot, never persisted // S_s = keccak256(Marshal(spec)) the cohort's key // S_c = keccak256(S_C || S_s) the cohort's secret @@ -122,9 +123,10 @@ message Claim { message Join { CohortSpec cohort = 1; bytes addr = 2; // 20 bytes: the address this stream will publish as -- - // under explicit authorship the one it will claim, under - // ALL the one every publication is validated against; - // absent: a spectator, and no challenge is issued + // under explicit authorship the one it will claim; under + // ALL and under implicit authorship the one every + // publication is validated against, no claim; absent: a + // spectator, and no challenge is issued Claim claim = 3; // a returning publisher's claim, verified before any bound } @@ -138,7 +140,9 @@ enum Status { } // Broker -> peer, answering Join. A non-OK Ack ends the stream. The roster -// reaches a newly attached stream as its first Message (see ServiceKind). +// reaches a newly attached stream as its first Message (see ServiceKind) -- +// except the admin's own, and except under `closed`, where nothing is delivered +// before the claim. message Ack { Status status = 1; bytes challenge = 2; // S, iff status == OK and addr was declared diff --git a/SWIPs/swip-60.md b/SWIPs/swip-60.md index 12ad7cd6..9b0bf01e 100644 --- a/SWIPs/swip-60.md +++ b/SWIPs/swip-60.md @@ -75,7 +75,7 @@ enum; **modes are combinations of these parameters**. | `binding` | `MNEMONIC` / `ANCHOR` / `SOC_ID` / `OWNER` / `FEED_TOPIC` | what the topic binds to; fixes which SOCs qualify as messages and the dedup rule | | `admin` | eth address | the cohort's authority and a member of its publisher set — not necessarily the first to join. **Absent ⇒ implicit authorship**, and the two fields below do not apply | | `publishers` | `ALL` or unset | set: anyone attached may author — no claim; a stream declares the address it publishes as, and every message it sends is validated against it. Unset: the admin, and whoever its roster ever names — **a cohort is multi-publisher iff its admin ever publishes a roster**, and nobody needs to know in advance | -| `closed` | bool, unset = open | set: no audience — a joiner receives nothing until its claim recovers to the admin or a rostered address, and is disconnected otherwise | +| `closed` | bool, unset = open | set: no audience — every stream is admitted silent, receiving nothing, and is disconnected unless a claim recovering to the admin or a rostered address arrives within the claim deadline | | `history` | bool | deliver matching chunks already in the local store (mechanism in bps-history; a singlehop broker MAY refuse) | **The publisher list is deliberately not here.** It is dynamic — an admin grants and revokes @@ -188,7 +188,508 @@ admin has published nothing has an empty service feed, and the admin alone may w Each service message carries its own index in the payload, so its id is verifiable without an out-of-band hint. Three properties follow, and each of them is the point: -- **The spec is nobody's word, and the admin is authenticated.** Every joiner carries the +- **The spec is nobody's word, and the admin is authenticated.** Every joiner brings the + spec in its `Join`, so a broker cannot serve a peer a cohort it did not name; `admin` + is an address anyone can read, its claim is a signature over a challenge only this + broker could have issued for it, and every + message and every service message carries its signature. Nothing in the handshake + needs to be trusted. +- **It is a feed, not a single mutable slot.** The obvious alternative — one constant-id SOC + overwritten in place — makes a stale roster **undetectable**, which would reintroduce + forging-by-omission at the one point that decides who may write. Sequential indices make + gaps visible, so withholding stays a *liveness* fault like every other withholding in this + protocol, and **self-indexing** feeds ([SWIP-65](https://github.com/ethersphere/SWIPs/pull/106)) + carry the construction. +- **The roster is verified end-to-end, like every message.** Service messages are ordinary + SOCs on the ordinary path — storable, re-fetchable, and checked with the same code as any + broadcast. A broker relays them; it cannot author them. + +**`Ack` is a status and a challenge, and the roster is the first delivery.** On every newly +attached stream that is not the admin's, and not silent under `closed`, the broker +delivers the **latest service SOC** as the first `Message` before any other **(?)**; a +joiner learns who may write from the admin, not from the broker, before it has received a +single message, and a cohort with an empty service feed delivers nothing first — the admin +alone may write. + +#### The claim: a challenge, signed + +A publisher proves its key by signing a **challenge** the broker derives for the address +it declared in `Join` (SWIP-74, *Handshake*): + +``` +S_C = a secret drawn once at broker boot, never persisted +S_s = keccak256(Marshal(spec)) the cohort's key +S_c = keccak256(S_C ‖ S_s) the cohort's secret +S = keccak256(S_C ‖ S_c ‖ addr) the challenge for addr on this cohort +``` + +The broker stores nothing — it recomputes `S` whenever a claim arrives — and `S` is the +same for an address on a cohort for as long as the broker runs, from whichever node the +address joins. The claim signs, with the key of `addr` and in the same convention as a +SOC signature **(?)**, + +``` +"bps-claim:v1" ‖ S ‖ O_B ‖ index +``` + +`O_B` being the overlay of the broker the claiming node is connected to and `index` the +publisher's cursor — the claim that its next message will have a feed index of at least +`index`. The domain separator keeps the signature disjoint from SOC signatures, which +these same keys produce over `id ‖ wrappedAddress`; `S` binds it to this broker, this +cohort and this address; `O_B` binds it to the verifier; `index` is signed so that a +replayed claim moves no cursor. A claim travels inside `Join` (a returning publisher, from +any node) or as the next frame after `Ack` (a peer that has just received `S` — or one +that has just seen itself named in a roster). There is **no reply**: the publisher sends +its claim and its first publication together, and learns the outcome from whether the +stream survives. + +What a claim proves is an **identity**, and identity is what this protocol hands out +privileges by: attendance at a `closed` cohort, a rostered seat, exemption from the fan-out +bound and its queue policy. A static signature would have been replayable, and a replayed +one would have bought all of that; a challenge only this broker could have issued, signed +together with the verifier's overlay, is worth exactly the key. What it does *not* protect +is history: replaying the admin's signed updates to a late viewer is catching it up, not +deceiving it (SWIP-74, *Security considerations*). + +Under `ALL`, and under **implicit authorship**, there is **no claim**: everybody who fits +may publish, so a stream that declares an address is a publisher stream from its `Join`, +and the declaration is proven by every publication — under `ALL` the SOC's address must +hash to the declared owner and its signature recover to it; under implicit authorship the +SOC must fit the binding's shape, and its owner be the declared one where the shape fixes +one. A replayed `Join` buys entry to a group chat, which anyone has, and not one message +under the borrowed name; a node may join a chat as several identities, one stream each. + +### The first frame settles the cohort; the claim settles the role + +A peer's cohort is fixed by its **first frame**, `Join` — the only handshake frame there +is — carrying the full `CohortSpec`, the address it will publish as (`addr`, if any), and +a returning publisher's claim. The broker compares the spec with its live cohorts: **no +match → the cohort is created** with the joiner attached; **match → the joiner is +attached**. Anyone may create, including a spectator arriving before the admin; a cohort +costs the broker a map entry until the inactivity deadline reclaims it. Cohorts are keyed +by the **whole spec**, so pre-creating a topic under a wrong admin squats nothing — the +genuine spec is a different cohort. The broker answers `Ack{OK, S}` — the challenge for +`addr`, if one was declared — or `FULL`, or `REJECTED` for a spec value outside this SWIP. + +Then the stream's role, from the claim — in the `Join`, or as the stream's next frame — +matched against `admin` and the **current roster**: + +| claim | `closed` unset | `closed` set | +|---|---|---| +| recovers to `addr`, and `addr` is the admin's or in the roster | the stream is a **publisher stream** | the stream is a **publisher stream** | +| none yet | a **spectator stream**, read-only; a later claim upgrades it | a **silent stream**: attached, receiving nothing, until a claim upgrades it or the claim deadline disconnects it — a `Join` without `addr` included | +| recovers to `addr`, but `addr` is not yet in the roster | a spectator stream still; it claims again when the roster names it | silent still, until the roster names it or the deadline passes | +| in the `Join`, and does not verify | treated as absent: `Ack{OK, S}`, no penalty — the broker cannot tell a stale claim from a wrong one | the same | +| after the `Ack`, and does not verify | violation: the stream is reset | violation: the stream is reset | + +Under `ALL` and implicit authorship the rows do not arise for a stream that declared an +address: it is a publisher stream at once, and the check moves onto every message. `closed` +is the only configuration in which a peer is turned away for *who it is* — or rather for +who it fails to prove it is — and it is enforceable precisely because a claim is signed +over a challenge only this broker could have issued for that address. Everywhere else +`REJECTED` means the *spec* is unacceptable — a value outside this SWIP — and `FULL` means +capacity, nothing more. + +#### Grant and revocation + +An admin changes the roster by publishing the next service message; the cohort spec never +changes. A **grant** takes effect when the granted peer claims: on its current stream, once +it sees itself in the roster it is delivered, or in its next `Join`. + +A **revocation** has two phases, and the boundary between them is the moment the reduced +roster reaches subscribers: + +1. **Before it is published**, the revoked peer has no way to know it has been revoked — + nothing has told it. Its `Message` frames are therefore **dropped and tolerated**: + silently ignored, no penalty, the connection untouched. There is nothing else a broker can + honestly do, because the peer is not misbehaving. +2. **After it is published**, the peer has been told — it receives the service message like + every other subscriber, on the same feed. Publishing from that point is a **protocol + violation**, and the broker MUST break the connection. + +The announcement is therefore not only for the audience's benefit: **it is what converts an +unknowing publisher into a violating one.** A broker that tore the stream down before +publishing the reduced roster would be punishing a peer for a rule it had not been given; a +broker that never publishes it leaves everyone — the revokee included — in a state where the +violation can never begin, which is an ordinary, visible withholding fault. The penalty +itself is the protocol's existing one: repeated invalid frames end the connection +(blocklisting policy). + +Announcing first also makes the revocation legible to everyone else: subscribers learn *why* +a publisher fell silent from an admin-signed message rather than inferring it from a +disconnection they cannot attribute. + + +### Roles and capacity + +- **Broker**: the first full node contacted; root of the (here, depth = 1) multicast tree. + Enforces its own capacity. **At capacity it MUST answer `Join` with a refusal** + (`FULL`) — except that, as in SWIP-74, it admits **one extra stream over the fan-out + bound for every legitimate publisher that is absent**: a `Join` declaring the admin's + address, or a rostered one, whose publisher stream does not exist, is admitted and + disconnected if it has not claimed within the claim deadline; referral to another + attachment point is reserved for bps-multihop — a singlehop-only broker simply refuses. Because any peer can make a broker allocate a + cohort simply by joining, a conformant broker also bounds **how many cohorts it will + create** and **how many one peer connection may hold**, and **reclaims idle ones** — + SWIP-74's bounds, all independent policy. +- **Admin**: the address the spec names — not necessarily the first to join — always a + member of the publisher set, and the cohort's only authority: it grants, revokes and + ends, each by publishing a service message. Its address is public in the spec — as a + stream's or a co-edited file's owner naturally is — while its grantees' are not. An + admin that never sends is a **moderator**; no separate role is needed, since being a + publisher obliges nobody to publish. +- **Publisher**: sends and receives — every `Message` of the cohort except its own, on + any of its streams. At + depth = 1 every peer is attached to the broker, so publishers are too — this is a + **consequence of singlehop, not a protocol invariant**. bps-multihop lifts it by + forwarding `Message` frames rootward as well as leafward, so a publisher may sit + several hops out; that is what lets an everyone-publishes cohort grow past one + broker's capacity. Attachment is in any case necessary, not sufficient — under + explicit authorship, the current roster decides. +- **Spectator**: receives only; joins with the same `Join` as everyone, carrying the + spec it was invited with, and the broker delivers the latest roster as its first + frame, so the cohort, its roster and every message are verified end-to-end. Every + peer receives, so publishing is the *additional* capability and this role is what + remains without it; a `closed` cohort has none. + +### Information flow + +```mermaid +sequenceDiagram + autonumber + participant PD as publisher dApp + participant PN as publisher's bee node
(WS bridge) + participant B as broker
(root, full node) + participant SN as subscriber's bee node
(WS bridge + mux) + participant SD as subscriber dApp(s) + + PN->>B: Join(CohortSpec, addr) + Note over PN,B: the spec is the cohort's identity: the first Join creates it,
later ones attach; at depth = 1 every publisher is attached to the broker + B-->>PN: Ack(OK, S) — S derived for addr, nothing stored + PN->>B: Claim(addr, index, Sig(S ‖ O_B ‖ index)) — no reply + SN->>B: Join(CohortSpec) + B-->>SN: Ack(OK) + B->>SN: Message(latest ROSTER) — the admin's word, relayed + Note over B,SN: spec in hand + admin-signed roster ⇒
subscriber verifies every message end-to-end + + PD->>PN: WS: payload + PN->>B: Message(address, data) + B->>B: validate: publisher stream, SOC ⊨ topic binding,
owner = claimed addr (+ cursor / dedup per binding) + + par fan-out to every stream of the cohort not bound to the publishing identity + B->>SN: Message(address, data) — every frame self-contained + SN->>SN: mux: one p2p stream → N WS sessions + SN->>SD: WS: payload + end + Note over B,PN: other publishers' streams receive it the same way;
the author's own never do +``` + +The broadcast is **end-to-end authenticated**: every subscriber re-verifies the SOC +signature against the topic binding regardless of path. + +### Wire protocol + +Messages are defined in [bps.proto](assets/swip-60/bps.proto). Framing notes: + +- Transport: libp2p stream `pubsub/1.0.0`, one stream per (peer, cohort), + protobuf-over-libp2p as bee protocols elsewhere. The first and only handshake frame on + a fresh stream is **`Join`**, carrying the full `CohortSpec`, the address the stream + will publish as, and a returning publisher's claim; the broker answers with + `Ack{status, challenge}`, and delivers the latest service SOC as the stream's first + `Message`, so the joiner verifies the roster against the admin rather than the broker. + The four frames — `Join`, `Ack`, `Claim`, `Message` — and the two types they carry, + `CohortSpec` and `Auth`, are SWIP-74's; this SWIP adds fields and values, never frames. + There is no envelope: what a frame is follows from the stream's direction and role — + a subscriber stream sends at most one `Claim`, a publisher stream sends `Message`. +- **`Join` creates or attaches**, keyed by the whole spec: a spec no live cohort has + creates one, a byte-identical spec attaches, and there is no "unknown topic". + Implicit-publisher cohorts rely on this — the first subscriber creates, so a client + need not know whether it is first — and so does every audience member arriving before + its admin. +- **Stream model rationale**: per-cohort streams give per-cohort flow control, teardown + and role typing, and match bee's protocol idiom. Because every frame carries the full + SOC (self-contained, no per-stream handshake state), a later move to topic-muxed + streams requires no format change. +- Every `Message` carries the **whole chunk**, address and data (SWIP-74), validated by + the ordinary SOC code; there is no handshake/data frame split. Deliveries go to every + stream of the cohort except those bound to the publishing identity: a publisher never + receives its own messages back, on whichever of its streams it sent them (SWIP-74). +- No BPS-level keepalive or RTT probing: liveness is the transport's job, and latency + metrics for reorganisation policies are sourced there too. +- Broker validation on a `Message`: it arrived on a publisher stream — claimed for its + address, or declaring one under `ALL` or implicit authorship — the chunk validates as a + SOC under the topic binding with the owner hashing to its address, the PO constraint + holds where applicable, and the owner is the stream's address (or fits the binding's SOC + shape under implicit authorship). Invalid ⇒ drop and count; repeated invalid ⇒ disconnect (blocklisting + policy). A message that passes and is a **duplicate** per the binding's dedup rule is + dropped and counted as a retransmit, never as invalid — an admin reconnecting after a + reset legitimately resends (SWIP-74); a broker MAY reset a stream whose retransmit rate + exceeds its policy. A frame on a subscriber stream is read as a `Claim`, and if it is + not a valid one it is a protocol violation: dropped, the stream reset, the peer + blocklisted (SWIP-74). +- **Service messages** ride the same frame and are recognised before the content path: a + `Message` on a stream bound to `admin` whose payload decodes as a `ServiceMessage` and + whose id equals `keccak256("bps-service:v1" ‖ topic ‖ payload.index)` is a service SOC. + It is accepted iff it validates as a SOC under that id, its owner is `admin`, and + `payload.index` exceeds the service feed's cursor (initially absent: index 0 is + accepted); otherwise it is invalid. Under `FEED_TOPIC` the two paths are told apart by + the id slot alone — a feed update carries a bare index (24 leading zero bytes), a + service SOC its full id — which is why a SWIP-74 broker drops the latter rather than + punishing it. +- **The dedup *horizon* is implementation-defined, but it MUST be bounded**: the binding + fixes what counts as a duplicate, not how far back the broker remembers, and an + unbounded seen-set is a memory-exhaustion vector. A broker keeps a bounded window over + recent message identifiers; the accepted consequence is that a legitimate publisher can + overrun that window and replay an evicted message. Applications that cannot tolerate + replay carry their own sequencing — which the sequential construction of + [SWIP-65](https://github.com/ethersphere/SWIPs/pull/106) gives for free. Under + `FEED_TOPIC` with explicit authorship the broker keeps SWIP-74's **cursor**, one per + publisher feed — the lowest index it accepts next, set forward by the publisher's claim, + never back — and needs no window for it; the other bindings dedup on chunk address + within the bounded window. What multihop's dual paths do to this is + [SWIP-61](https://github.com/ethersphere/SWIPs/pull/105)'s business. + +### API (WebSocket bridge) + +One endpoint pair on the Bee API. Endpoint shape follows bee +[#5435](https://github.com/ethersphere/bee/pull/5435), generalised from its single +hardcoded mode to the full parameter space; serialization conventions follow the SOC +subscription family — GSOC/MIC/MOC (bee +[#5486](https://github.com/ethersphere/bee/pull/5486), +[#5497](https://github.com/ethersphere/bee/pull/5497)) — whose `/mic/subscribe/{owner}` +and `/moc/subscribe/{id}` endpoints are the storage-fed counterparts of the `OWNER` and +`SOC_ID` bindings, so a dApp switches between stored and live feeds without +reformatting. All p2p framing is transparent to WS clients; one p2p stream is muxed to +N local WS sessions per topic. + +**`GET /pubsub/{topic}`** — upgrades to a WebSocket session on the topic. `{topic}` is +the 32-byte topic hex-encoded, or an arbitrary string hashed to 32 bytes (mnemonic +topics). Query parameters: + +| parameter | maps to | meaning | +|---|---|---| +| `peer` | — | broker underlay multiaddr; required until broker discovery exists (bps-broker-discovery) — early deployments configure it | +| `binding`, `admin`, `publishers`, `closed`, `history` | `CohortSpec` | **the spec, on every session**: `binding` always, the others where the spec sets them — the spec is the cohort's identity and the invite carries it; the node sends `Join` with the assembled spec, which creates or attaches, and keys its sessions by the whole spec, not the topic. `admin` omitted ⇒ implicit authorship. No publisher list here — it is not part of the spec | +| `addr` (+ `id` where the binding does not fix it) | `Join.addr` | the address the session publishes as. **Opens a stream of its own for that identity** and declares it; the node then hands the session the challenge from the `Ack` and the broker's overlay, the dApp signs the claim client-side, and the node sends it — read–write from then on iff the address is the admin's or currently rostered, or the cohort is `ALL`; a later roster naming the address is the cue to claim again **(?)**. Absent, a spectator session on the node's shared subscriber stream. The node holds no publisher keys, and the same key works from any node | + +**`POST /pubsub/{topic}/service`** — the admin's control plane: submits a service message +(`ROSTER` or `END_OF_STREAM`) as the next update on the service feed. The SOC is signed +client-side by the admin key; the node relays it on the cohort whose `admin` that key is. +Granting or revoking a publisher is one call here and touches no cohort parameter. + +Headers: + +- `swarm-keep-alive` (seconds, default 60): ping period of the **local WS link only** — + not to be confused with the p2p layer, which has no keepalive. +- `swarm-soc-fields` (per bee [#5497](https://github.com/ethersphere/bee/pull/5497)): + comma-separated SOC fields serialized per outbound message — `address`, + `recoveredPubKey`, `identifier`, `signature`, `wrappedAddress`, `span`, `payload`; + default `payload`. This is how dApps on implicit-binding streams (`OWNER`, `SOC_ID`, + feed) attribute messages — no BPS-specific frame format. +- `swarm-cache-wrapped-chunk` (per bee + [#5497](https://github.com/ethersphere/bee/pull/5497)): when true, the wrapped chunk + of every incoming message is stored in the local cache, resolvable through the bytes + endpoint — for streams whose messages reference content larger than one chunk. + +**`GET /pubsub/`** — lists the node's active topics: topic address, cohort parameters, +own role (broker / subscriber), connected peers. + +**Signing — the key-holding rule.** Message signing is the dApp's business: **the node +never holds publisher keys**. Inbound (publisher → node): `sig ‖ span ‖ payload`, +signed client-side (bee-js). Where the binding does not fix the SOC id, the frame is +prefixed with it — for feed bindings the prefix is the bare index, the signed id being +the feed id `keccak256(topic ‖ index)` (self-indexed feeds, +[SWIP-65](https://github.com/ethersphere/SWIPs/pull/106)); +under explicit regimes with `ANCHOR` binding the id does no work and there is no +prefix. The node assembles the SOC, validates it exactly as a broker would, and +publishes. The claim is signed the same way: the node passes the challenge, its broker's +overlay and the session's cursor to the dApp and relays the signature **(?)**. End-to-end +verification against the `CohortSpec` the session supplied — the spec the node sent in +`Join` — is performed by the local node — node and dApp are one trust domain. + +**Worked API calls — the jam cohort** (see Configurations below). Seat A joins declaring +its address, signs the challenge it is handed, and its claim recovers to `admin` ⇒ +read–write; the spec creates the cohort: + +``` +wss://node:1633/pubsub/jam-tuesday?peer= + &binding=anchor&admin=0xA…&closed=true&addr=0xA… +``` + +Seats B–D join with the same spec and their own `addr`: + +``` +wss://node:1633/pubsub/jam-tuesday?peer= + &binding=anchor&admin=0xA…&closed=true&addr=0xB… +``` + +Seats B–D are not named in this URL and never appear in a cohort parameter: A grants them +with a `POST /pubsub/jam-tuesday/service` carrying a `ROSTER` message, and can revoke or add a +fifth seat later without any of the above changing. Each seat becomes a publisher by the +claim it signs over the challenge issued for its address; because the cohort is `closed`, a +seat receives nothing until its claim recovers to a rostered key, and is disconnected if it +never does. The join URL minus `addr` is the complete out-of-band invite (spec + broker) +until broker discovery exists — and it is genuinely an invite: only a holder of a rostered +key can turn it into a session at all. +A live MIC — all SOCs of one owner, the light-client twin +of `/mic/subscribe/{owner}` — is the implicit case: every subscriber joins with +`?binding=owner`, no `admin` and no `addr` (the first creates, the rest attach), +topic = `keccak256(owner)`, read-only, `swarm-soc-fields: identifier,payload`. + +### Configurations (worked examples) + +The five configurations, as `CohortSpec` rows. + +**Jam** — a 4-seat collaborative remix edit, a strudel livecoding session, a multiparty game. + +``` +binding: ANCHOR (topic = mnemonic anchor) admin: 0xA… +closed: true history: false +``` + +Seat A joins; B, C and D are granted by a `ROSTER` service message, and each becomes a +publisher by the claim it signs, accepted because its address is on the roster it can verify +against A's key. A fifth peer receives nothing and is disconnected when its claim deadline +passes — this is the one configuration in which a peer is refused for who it is, and it is +enforceable because a claim is signed over a challenge only this broker could have issued +for that address. A may grant a fifth seat, or revoke one, without the cohort spec changing +at all. +Confidentiality is still not on offer: the broker holds plaintext, and a jam that needs it +encrypts payloads. + +**Spectator-jam** — the same, opened to an audience. + +``` +binding: ANCHOR admin: 0xA… +history: false +``` + +Identical authorship, but an unrecognised joiner is admitted read-only instead of refused — +and claims, on the stream it already holds, when a later roster names it. +The audience verifies the roster from the admin's feed, so it knows exactly whose messages +are legitimate without trusting the broker. + +**Live-stream** — single publisher, open audience. + +``` +binding: FEED_TOPIC (sequential index) admin: the streamer +history: false +``` + +This is [SWIP-74](https://github.com/ethersphere/SWIPs/pull/111)'s cohort exactly, and a +SWIP-74 peer is a conformant peer of it. The spec is the same as a spectator-jam's: the +streamer simply never publishes a roster, so it stays the only author, and the audience +verifies every message against its key regardless. What this SWIP adds is the end: the +streamer ends it with an `END_OF_STREAM` service message, which is what distinguishes +"over" from "the broker stopped relaying" — and from SWIP-74's inactivity reclaim. + +**Group-chat** — anyone attached may speak. + +``` +binding: MNEMONIC (the topic is just the cohort's name) admin: 0xA… +publishers: ALL history: false +``` + +No roster, no claim, no constraint on the SOCs: each stream declares the address it +publishes as, and every message it sends must be that address's own — proven by the SOC's +hash and signature, message by message, never at join. The topic +binds nothing — it names the cohort, and that is all it does. Authorship is unrestricted but +never *unattributable*: every message is SOC-signed, so the chat knows exactly who said what +without there being an authorised set to check against. The admin here is not a gatekeeper — +it cannot be, since everyone may write — but it still owns the service feed, so it can end +the cohort. This is the row that outgrows a single broker fastest, and the one +[SWIP-61](https://github.com/ethersphere/SWIPs/pull/105) exists to scale: with publications +forwarded from the leaves towards the root, a member need not be attached to the broker to +speak. Where a cohort wants no authorship guarantees at all, see "why not gossipsub". + +**Implicit** — no admin, no roster, no authority. + +``` +binding: OWNER (topic = keccak256(owner)) admin: absent +history: false +``` + +A live MIC: all SOCs of one owner, the light-client twin of `/mic/subscribe/{owner}`. There +is no admin, so no service feed, no grants and no end-of-stream — nothing to authenticate, +because **the chunk carries its own legitimacy** and the binding's SOC shape is the whole +check. `SOC_ID` gives the multi-author version of this (MOC: id fixed, each publisher mining +its own owner into the anchor neighbourhood — own-identity writers, as in +[SWIP-66](https://github.com/ethersphere/SWIPs/pull/107)), and `MNEMONIC` the unconstrained +one, which is group-chat minus the authority to end it. + +### The modes — enumerated as combinations of dimension choices + +Known use cases attach here; each mode is nothing more than a row — a combination of +publisher/subscriber info, topic match type, and history. (`+/−` = both configurations +meaningful.) + +| configuration | binding | audience (`closed` unset) | history | use case | +|---|---|---|---|---| +| live-stream | feed topic, index sequential | + | — | live video streaming | +| spectator-jam | feed topic, index sequential | + | — | live videoconference | +| jam | anchor | — | +/— | private co-authoring, remix editing | +| group-chat | mnemonic — no constraint | + | +/— | multi-party / group chat | +| implicit | anchor (ephemeral GSOC) | + | +/— | anythread comments / troll-box | +| implicit | id fixed, owner mined (MOC) | + | +/— | own-identity writers on a shared id | +| implicit | id = `keccak256(topic ‖ index)` | + | +/— | following one or more feeds | +| implicit | feed special, mined index | + | +/— | following graffiti soc | +| implicit | owner (MIC) | + | +/— | tags, adverts | + +At depth = 1 the broker's capacity bounds **both** directions: the audience by its stream +count, and — since every publisher is attached to it — the publisher count too. Scaling +either past one broker is bps-multihop's business +([SWIP-61](https://github.com/ethersphere/SWIPs/pull/105)), which forwards `Message` +frames rootward as well as leafward. The everyone-publishes rows above — group chat, +videoconference, troll-box — are the ones that need it. + +The implicit rows and history are specified in bps-implicit-publisher and bps-history +respectively — with the split that **this** SWIP fixes *who* an implicit publisher is (the +binding-to-SOC-shape table above, and the cardinality that follows from it), because that is +validation the broker cannot operate without, while bps-implicit-publisher keeps the +event-sourcing mechanism built on top. + +## Rationale: why not gossipsub + +libp2p ships gossipsub, a battle-tested mesh multicast. BPS builds its own protocol +because gossipsub's core mechanisms — flooding to a random mesh, IHAVE/IWANT +pull-recovery — are exactly what an incentivised network rejects: **no node wants to pay +for a message it did not ask for.** That one economic fact dissolves gossipsub's +machinery: metered edges mean no redundant paths and no transport-level duplicates; a +cohort's `CohortSpec` scopes every session; authentication is structural (SOC-signed +against the topic binding), so brokers and relays forward without being trusted — an +intermediate can withhold, never forge; and withholding is a liveness fault recoverable +by re-pointing or relocating the topic. Multihop forwarding (bps-multihop) adds capacity +without reintroducing flooding: every edge still pays upstream, every node still receives +only its topic's stream — and publishing from depth > 1 is metered the same way, priced by +depth (bps-bw-incentives). + +**And in the happy case the tree wins on traffic, not only on trust.** A publish in a +multihop cohort travels **rootward** from wherever it originates and then **leafward** to +everyone: each edge carries the message **exactly once**. A single-parented tree therefore +needs no duplicate suppression at all — no seen-set, no IHAVE/IWANT pull-recovery, no +mesh-degree multiplier applied at every hop. Gossipsub pays D copies per node by +construction and recovers the remainder by asking. Where the tree is well matched to the +underlay — a **closely knit topology**, peers whose tree edges are also their short paths — +rootward-then-leafward is simply the cheaper delivery, and a publisher sitting at depth d +pays those d hops once, on the way up. Duplicates in BPS are a deliberate purchase rather +than a structural cost: dual parenting in +[SWIP-61](https://github.com/ethersphere/SWIPs/pull/105) buys withholding-masking with a +second copy, and that is the case in which the dedup horizon above earns its keep. + +**The concession.** Where an application genuinely wants *gossip* — a large symmetric +cohort with no publisher structure, every member a source, message-level flooding the +point, and no interest in who signed what — **libp2p gossipsub is the better tool and the +application should simply use it.** BPS is not trying to win that comparison. It earns its +keep where the cohort has shape: authorship that is structurally authenticated (SOC-signed +against the topic binding, verifiable regardless of path, so an intermediate can withhold +but never forge), edges that are bounded and metered, messages that are chunks and so +re-fetchable from storage, and a `CohortSpec` that states who may write. The implicit cohort +exists for symmetric groups that want *those* properties — a group chat whose messages are +verifiable signed chunks — not to reimplement a mesh. + +## Security considerations + +**The spec is nobody's word, and the admin is authenticated.** Every joiner carries the spec in its `Join`, so a broker cannot serve a peer a cohort it did not name, and a cohort somebody else pre-creates under a wrong admin is simply a different cohort. `admin` is a public address; its claim is a signature over a challenge only this broker @@ -214,11 +715,10 @@ the viewer is caught up, not deceived. Freshness is the feed's business — the cursor per `(topic, admin)`, the timestamp key of [SWIP-65](https://github.com/ethersphere/SWIPs/pull/106) — not the handshake's. -**The transport precondition.** The claim rests on `O_B` being the overlay the publisher's -node is actually connected to, and on the broker knowing the peer it is talking to. A BPS -node MUST verify, in the p2p handshake, that a peer's signed address record names the -connection's authenticated peer ID; a record that is merely self-consistent can be -presented by anyone who has seen it. +**The transport precondition.** The claim's binding to the verifier rests on `O_B` being +the overlay the publisher's node is actually connected to. A BPS node MUST verify, in the +p2p handshake, that a peer's signed address record names the connection's authenticated +peer ID; a record that is merely self-consistent can be presented by anyone who has seen it. **Defence in depth is the real guarantee.** Even a stream that obtains the publisher role gains nothing by it beyond what its key already signs: every message is validated on @@ -285,19 +785,22 @@ An implementation is conformant when: 6. the handshake is one `Join` carrying the full spec and the address the stream will publish as, creating the cohort or attaching to it, keyed by the spec's canonical serialisation; `Ack` is a status and, for a declared address, the challenge; a newly - attached stream not bound to the admin receives the latest service SOC as its first - `Message` **(?)**; -7. an absent `admin` is treated as implicit authorship — validated strictly per the + attached stream that is not the admin's, and not silent under `closed`, receives the + latest service SOC as its first `Message` **(?)**; +7. an absent `admin` is treated as implicit authorship — a stream that declares an address + publishes from its `Join` with no claim, each message validated strictly per the binding's SOC shape — and a present one authenticated by its claim and by its signature on every service message, both of which MUST recover to it; -8. a claim is verified over `keccak256("bps-claim:v1" ‖ S ‖ O_B ‖ index)` with `S` - derived as SWIP-74 specifies, the signer equal to the declared address, and the address - the admin's or rostered — under `ALL` there is no claim and every message is checked - against the declared address; a rostered claim upgrades the stream and sets its cursor, - no reply is sent; a joiner without a claim is a spectator where the cohort is not - `closed`, and silent until it claims where it is — the only refusal for identity in the - protocol; the node verifies in the p2p handshake that a peer's signed address record - names the connection's authenticated peer ID; +8. a claim is verified over `"bps-claim:v1" ‖ S ‖ O_B ‖ index` with `S` derived as SWIP-74 + specifies, the signer equal to the declared address, and the address the admin's or + rostered — under `ALL` there is no claim and every message is checked against the + declared address; a rostered claim upgrades the stream and sets its cursor, no reply is + sent; a claim in the `Join` that does not verify is treated as absent; a joiner without + a claim is a spectator where the cohort is not `closed`, and silent where it is — + disconnected unless a claim recovering to the admin or a rostered address arrives within + the claim deadline — the only refusal for identity in the protocol; the node verifies in + the p2p handshake that a peer's signed address record names the connection's + authenticated peer ID; 9. an admin grants and revokes by publishing `ROSTER` service messages; a revoked publisher's frames are **dropped and tolerated** until the reduced roster is published, and its connection is broken only if it publishes **after** that point; From be251a5d18411d7868df3a98f648f79f21566943 Mon Sep 17 00:00:00 2001 From: zelig Date: Sun, 27 Sep 2026 13:33:22 +0200 Subject: [PATCH 13/14] swip-60 rev 7: the claim as an Auth chunk; Broadcast; proto revision 10 Follows SWIP-74 rev 4: Auth{soc} (id keccak256("bps-claim:v1" || topic), owner addr, payload S || O_B || index) verified by the ordinary SOC code; Join{cohort, addr, auth}; Message renamed Broadcast, both directions, the service feed rides it; a subscriber stream sends one Auth per claim and claims again when a roster names it; one stream per (peer, cohort, identity); security reworded for the chunk; conformance 8 follows. Co-Authored-By: Claude Fable 5.1 --- SWIPs/assets/swip-60/bps.proto | 57 ++++++++--------- SWIPs/swip-60.md | 114 ++++++++++++++++++--------------- 2 files changed, 89 insertions(+), 82 deletions(-) diff --git a/SWIPs/assets/swip-60/bps.proto b/SWIPs/assets/swip-60/bps.proto index 8cbf82c7..38598f1e 100644 --- a/SWIPs/assets/swip-60/bps.proto +++ b/SWIPs/assets/swip-60/bps.proto @@ -1,15 +1,16 @@ // Broadcast Pub/Sub (BPS) — protocol messages and types. // Spec: SWIP-60 (../../swip-60.md), extending the base wire of SWIP-74 (BPS-lite). // -// Revision 9 (2026-09-24), per Viktor — the claim handshake. +// Revision 10 (2026-09-27), per Viktor — the claim carried as a chunk; Broadcast. // -// SWIP-74 fixes the base: four frames (Join, Ack, Claim, Message) and the two types -// they carry (CohortSpec, Auth), for a single publisher over a feed at one broker, one -// hop, with the publisher role claimed by signing a broker-derived challenge. This +// SWIP-74 fixes the base: four frames (Join, Ack, Auth, Broadcast) and the one type +// they carry (CohortSpec), for a single publisher over a feed at one broker, one hop, +// with the publisher role claimed by signing a broker-derived challenge — as a +// single-owner chunk, verified by the ordinary SOC code. This // file adds what the full singlehop protocol needs and changes nothing SWIP-74 // defines: // - CohortSpec gains `publishers` (one value, ALL), `history` and `closed`; -// - the admin's service feed (ROSTER, END_OF_STREAM) travels as Message frames. +// - the admin's service feed (ROSTER, END_OF_STREAM) travels as Broadcast frames. // Field numbers follow SWIP-74's; the added fields come after. There is no envelope: // what a frame is follows from the stream's direction and role. Multihop (SWIP-61) // adds its control frames as messages of its own. @@ -90,32 +91,28 @@ message CohortSpec { // claim settles the role. // --------------------------------------------------------------------------- -// A secp256k1 signature, as a SOC's: r || s || v. -message Auth { - bytes r = 1; // 32 bytes - bytes s = 2; // 32 bytes - uint32 v = 3; // 27 or 28 -} - -// A publisher's claim on the stream it is sent on. `auth` signs -// "bps-claim:v1" || S || O_B || index -// with the key of `addr`, in the same convention as a SOC signature (?), where +// A publisher's claim on the stream it is sent on, carried as a single-owner chunk +// so that the ordinary SOC validation verifies it in one call: +// id = keccak256("bps-claim:v1" || topic) never a feed id +// owner = addr address = keccak256(id || addr) +// payload = S || O_B || index 32 + 32 + 8 bytes +// signed as any SOC is, with the key of `addr`, where // S_C = a secret drawn once at broker boot, never persisted // S_s = keccak256(Marshal(spec)) the cohort's key // S_c = keccak256(S_C || S_s) the cohort's secret // S = keccak256(S_C || S_c || addr) the challenge for addr on this cohort // O_B is the overlay of the broker the claiming node is connected to and `index` // (eight bytes big-endian) the publisher's cursor: its next message has a feed -// index >= index. The broker stores nothing and recomputes S at claim time; S is -// the same for an address on a cohort for as long as the broker runs, from any -// node. Sent inside Join by a peer that already holds S, or as the next frame -// after Ack by one that has just received it -- or has just seen itself named in -// a roster. No reply: the outcome is whether the stream survives the publication -// that follows. Under ALL there is no claim. -message Claim { - bytes addr = 1; // 20 bytes: the address claimed; equals Join.addr - uint64 index = 2; // the publisher's cursor: its next message has an index >= this - Auth auth = 3; +// index >= index. The receiver derives the id from the topic and the expected +// address from `addr`, validates the chunk against it, and checks the payload +// against its own S and overlay. The broker stores nothing; S is the same for an +// address on a cohort for as long as the broker runs, from any node. Sent inside +// Join by a peer that already holds S, or as the next frame after Ack by one that +// has just received it -- or has just seen itself named in a roster. No reply: the +// outcome is whether the stream survives the publication that follows. Under ALL +// and under implicit authorship there is no claim. +message Auth { + bytes soc = 1; // chunk data: id (32) || signature (65) || span (8, LE) || payload (72) } // Peer -> broker: the first frame on a fresh stream. Creates the cohort if no live @@ -127,7 +124,7 @@ message Join { // ALL and under implicit authorship the one every // publication is validated against, no claim; absent: a // spectator, and no challenge is issued - Claim claim = 3; // a returning publisher's claim, verified before any bound + Auth auth = 3; // a returning publisher's claim, verified before any bound } enum Status { @@ -140,7 +137,7 @@ enum Status { } // Broker -> peer, answering Join. A non-OK Ack ends the stream. The roster -// reaches a newly attached stream as its first Message (see ServiceKind) -- +// reaches a newly attached stream as its first Broadcast (see ServiceKind) -- // except the admin's own, and except under `closed`, where nothing is delivered // before the claim. message Ack { @@ -149,7 +146,7 @@ message Ack { } // --------------------------------------------------------------------------- -// Messages — SOC-only is a protocol feature +// Broadcast — SOC-only is a protocol feature // --------------------------------------------------------------------------- // Both directions after the handshake: publisher -> broker is a publication, @@ -159,7 +156,7 @@ message Ack { // data = id (32) || signature (65) || span (8, LE) || payload (<= 4096) // Under FEED_TOPIC a feed update's id slot carries the bare index (SWIP-74, // SWIP-65); a service SOC carries its full id (see ServiceKind). -message Message { +message Broadcast { bytes address = 1; // 32 bytes: the SOC address, keccak256(id || owner) bytes data = 2; } @@ -171,7 +168,7 @@ message Message { // // owner = admin id = keccak256("bps-service:v1" || topic || index) // -// travelling as Message frames with their full 32-byte id, so a broker relays +// travelling as Broadcast frames with their full 32-byte id, so a broker relays // them and cannot author them, and a subscriber checks them with the same code // as any broadcast. The payload carries its own index, so the id is verifiable // without an out-of-band hint. Sequential indices (SWIP-65 self-indexed feeds) diff --git a/SWIPs/swip-60.md b/SWIPs/swip-60.md index 9b0bf01e..4ca81d3b 100644 --- a/SWIPs/swip-60.md +++ b/SWIPs/swip-60.md @@ -12,7 +12,7 @@ created: 2026-08-03 +protobuf: assets/swip-60/bps.proto (revision 10, derived from SWIP-74's block). --> - **Business line**: real-time topic streams for dApps without storing chunks or polling — enough on its own for the five cohort shapes it defines: **jam** (a closed set of authors: @@ -206,12 +206,12 @@ without an out-of-band hint. Three properties follow, and each of them is the po **`Ack` is a status and a challenge, and the roster is the first delivery.** On every newly attached stream that is not the admin's, and not silent under `closed`, the broker -delivers the **latest service SOC** as the first `Message` before any other **(?)**; a +delivers the **latest service SOC** as the first `Broadcast` before any other **(?)**; a joiner learns who may write from the admin, not from the broker, before it has received a single message, and a cohort with an empty service feed delivers nothing first — the admin alone may write. -#### The claim: a challenge, signed +#### The claim: a challenge, signed as a chunk A publisher proves its key by signing a **challenge** the broker derives for the address it declared in `Join` (SWIP-74, *Handshake*): @@ -225,23 +225,26 @@ S = keccak256(S_C ‖ S_c ‖ addr) the challenge for addr on this The broker stores nothing — it recomputes `S` whenever a claim arrives — and `S` is the same for an address on a cohort for as long as the broker runs, from whichever node the -address joins. The claim signs, with the key of `addr` and in the same convention as a -SOC signature **(?)**, +address joins. The claim is an `Auth`: a single-owner chunk the publisher signs with the +key of `addr`, as it signs any SOC, whose ``` -"bps-claim:v1" ‖ S ‖ O_B ‖ index +id = keccak256("bps-claim:v1" ‖ topic) +owner = addr address = keccak256(id ‖ addr) +payload = S ‖ O_B ‖ index ``` `O_B` being the overlay of the broker the claiming node is connected to and `index` the publisher's cursor — the claim that its next message will have a feed index of at least -`index`. The domain separator keeps the signature disjoint from SOC signatures, which -these same keys produce over `id ‖ wrappedAddress`; `S` binds it to this broker, this -cohort and this address; `O_B` binds it to the verifier; `index` is signed so that a -replayed claim moves no cursor. A claim travels inside `Join` (a returning publisher, from -any node) or as the next frame after `Ack` (a peer that has just received `S` — or one -that has just seen itself named in a roster). There is **no reply**: the publisher sends -its claim and its first publication together, and learns the outcome from whether the -stream survives. +`index`. The receiver verifies it with the ordinary SOC validation against the address it +forms from the id and the declared `addr`, then checks the payload against its own `S` +and overlay. The separator in the id keeps a claim from ever being a feed update; `S` +binds it to this broker, this cohort and this address; `O_B` binds it to the verifier; +`index` is signed so that a replayed claim moves no cursor. A claim travels inside `Join` +(a returning publisher, from any node) or as the next frame after `Ack` (a peer that has +just received `S` — or one that has just seen itself named in a roster). There is **no +reply**: the publisher sends its claim and its first publication together, and learns the +outcome from whether the stream survives. What a claim proves is an **identity**, and identity is what this protocol hands out privileges by: attendance at a `closed` cohort, a rostered seat, exemption from the fan-out @@ -300,7 +303,7 @@ A **revocation** has two phases, and the boundary between them is the moment the roster reaches subscribers: 1. **Before it is published**, the revoked peer has no way to know it has been revoked — - nothing has told it. Its `Message` frames are therefore **dropped and tolerated**: + nothing has told it. Its `Broadcast` frames are therefore **dropped and tolerated**: silently ignored, no penalty, the connection untouched. There is nothing else a broker can honestly do, because the peer is not misbehaving. 2. **After it is published**, the peer has been told — it receives the service message like @@ -338,11 +341,11 @@ disconnection they cannot attribute. stream's or a co-edited file's owner naturally is — while its grantees' are not. An admin that never sends is a **moderator**; no separate role is needed, since being a publisher obliges nobody to publish. -- **Publisher**: sends and receives — every `Message` of the cohort except its own, on +- **Publisher**: sends and receives — every `Broadcast` of the cohort except its own, on any of its streams. At depth = 1 every peer is attached to the broker, so publishers are too — this is a **consequence of singlehop, not a protocol invariant**. bps-multihop lifts it by - forwarding `Message` frames rootward as well as leafward, so a publisher may sit + forwarding `Broadcast` frames rootward as well as leafward, so a publisher may sit several hops out; that is what lets an everyone-publishes cohort grow past one broker's capacity. Attachment is in any case necessary, not sufficient — under explicit authorship, the current roster decides. @@ -366,18 +369,18 @@ sequenceDiagram PN->>B: Join(CohortSpec, addr) Note over PN,B: the spec is the cohort's identity: the first Join creates it,
later ones attach; at depth = 1 every publisher is attached to the broker B-->>PN: Ack(OK, S) — S derived for addr, nothing stored - PN->>B: Claim(addr, index, Sig(S ‖ O_B ‖ index)) — no reply + PN->>B: Auth(SOC: id = H("bps-claim:v1" ‖ topic), owner = addr,
payload = S ‖ O_B ‖ index) — no reply SN->>B: Join(CohortSpec) B-->>SN: Ack(OK) - B->>SN: Message(latest ROSTER) — the admin's word, relayed + B->>SN: Broadcast(latest ROSTER) — the admin's word, relayed Note over B,SN: spec in hand + admin-signed roster ⇒
subscriber verifies every message end-to-end PD->>PN: WS: payload - PN->>B: Message(address, data) + PN->>B: Broadcast(address, data) B->>B: validate: publisher stream, SOC ⊨ topic binding,
owner = claimed addr (+ cursor / dedup per binding) par fan-out to every stream of the cohort not bound to the publishing identity - B->>SN: Message(address, data) — every frame self-contained + B->>SN: Broadcast(address, data) — every frame self-contained SN->>SN: mux: one p2p stream → N WS sessions SN->>SD: WS: payload end @@ -391,32 +394,35 @@ signature against the topic binding regardless of path. Messages are defined in [bps.proto](assets/swip-60/bps.proto). Framing notes: -- Transport: libp2p stream `pubsub/1.0.0`, one stream per (peer, cohort), +- Transport: libp2p stream `pubsub/1.0.0`, one stream per (peer, cohort, identity), protobuf-over-libp2p as bee protocols elsewhere. The first and only handshake frame on a fresh stream is **`Join`**, carrying the full `CohortSpec`, the address the stream will publish as, and a returning publisher's claim; the broker answers with `Ack{status, challenge}`, and delivers the latest service SOC as the stream's first - `Message`, so the joiner verifies the roster against the admin rather than the broker. - The four frames — `Join`, `Ack`, `Claim`, `Message` — and the two types they carry, - `CohortSpec` and `Auth`, are SWIP-74's; this SWIP adds fields and values, never frames. - There is no envelope: what a frame is follows from the stream's direction and role — - a subscriber stream sends at most one `Claim`, a publisher stream sends `Message`. + `Broadcast`, so the joiner verifies the roster against the admin rather than the broker. + The four frames — `Join`, `Ack`, `Auth`, `Broadcast` — and the one type they carry, + `CohortSpec`, are SWIP-74's; this SWIP adds fields and values, never frames. There is + no envelope: what a frame is follows from the stream's direction and role — a + subscriber stream sends only `Auth` frames, one per claim (and it claims again when a + roster names it: a verified `Auth` for a not-yet-rostered address is not a violation), + a publisher stream sends `Broadcast`. - **`Join` creates or attaches**, keyed by the whole spec: a spec no live cohort has creates one, a byte-identical spec attaches, and there is no "unknown topic". Implicit-publisher cohorts rely on this — the first subscriber creates, so a client need not know whether it is first — and so does every audience member arriving before its admin. - **Stream model rationale**: per-cohort streams give per-cohort flow control, teardown - and role typing, and match bee's protocol idiom. Because every frame carries the full - SOC (self-contained, no per-stream handshake state), a later move to topic-muxed - streams requires no format change. -- Every `Message` carries the **whole chunk**, address and data (SWIP-74), validated by + and role typing, and match bee's protocol idiom. Because every data frame carries the + whole chunk (self-contained, no per-stream handshake state), a later move to + topic-muxed streams requires no format change; the `Auth` chunk is the one frame bound + to its stream by construction — it is verified against the address that stream declared. +- Every `Broadcast` carries the **whole chunk**, address and data (SWIP-74), validated by the ordinary SOC code; there is no handshake/data frame split. Deliveries go to every stream of the cohort except those bound to the publishing identity: a publisher never receives its own messages back, on whichever of its streams it sent them (SWIP-74). - No BPS-level keepalive or RTT probing: liveness is the transport's job, and latency metrics for reorganisation policies are sourced there too. -- Broker validation on a `Message`: it arrived on a publisher stream — claimed for its +- Broker validation on a `Broadcast`: it arrived on a publisher stream — claimed for its address, or declaring one under `ALL` or implicit authorship — the chunk validates as a SOC under the topic binding with the owner hashing to its address, the PO constraint holds where applicable, and the owner is the stream's address (or fits the binding's SOC @@ -424,11 +430,11 @@ Messages are defined in [bps.proto](assets/swip-60/bps.proto). Framing notes: policy). A message that passes and is a **duplicate** per the binding's dedup rule is dropped and counted as a retransmit, never as invalid — an admin reconnecting after a reset legitimately resends (SWIP-74); a broker MAY reset a stream whose retransmit rate - exceeds its policy. A frame on a subscriber stream is read as a `Claim`, and if it is + exceeds its policy. A frame on a subscriber stream is read as an `Auth`, and if it is not a valid one it is a protocol violation: dropped, the stream reset, the peer blocklisted (SWIP-74). - **Service messages** ride the same frame and are recognised before the content path: a - `Message` on a stream bound to `admin` whose payload decodes as a `ServiceMessage` and + `Broadcast` on a stream bound to `admin` whose payload decodes as a `ServiceMessage` and whose id equals `keccak256("bps-service:v1" ‖ topic ‖ payload.index)` is a service SOC. It is accepted iff it validates as a SOC under that id, its owner is `admin`, and `payload.index` exceeds the service feed's cursor (initially absent: index 0 is @@ -470,7 +476,7 @@ topics). Query parameters: |---|---|---| | `peer` | — | broker underlay multiaddr; required until broker discovery exists (bps-broker-discovery) — early deployments configure it | | `binding`, `admin`, `publishers`, `closed`, `history` | `CohortSpec` | **the spec, on every session**: `binding` always, the others where the spec sets them — the spec is the cohort's identity and the invite carries it; the node sends `Join` with the assembled spec, which creates or attaches, and keys its sessions by the whole spec, not the topic. `admin` omitted ⇒ implicit authorship. No publisher list here — it is not part of the spec | -| `addr` (+ `id` where the binding does not fix it) | `Join.addr` | the address the session publishes as. **Opens a stream of its own for that identity** and declares it; the node then hands the session the challenge from the `Ack` and the broker's overlay, the dApp signs the claim client-side, and the node sends it — read–write from then on iff the address is the admin's or currently rostered, or the cohort is `ALL`; a later roster naming the address is the cue to claim again **(?)**. Absent, a spectator session on the node's shared subscriber stream. The node holds no publisher keys, and the same key works from any node | +| `addr` (+ `id` where the binding does not fix it) | `Join.addr` | the address the session publishes as. **Opens a stream of its own for that identity** and declares it; the node then hands the session the challenge from the `Ack` and the broker's overlay, the dApp signs the claim chunk client-side as it signs any SOC, and the node sends it — read–write from then on iff the address is the admin's or currently rostered, or the cohort is `ALL`; a later roster naming the address is the cue to claim again **(?)**. Absent, a spectator session on the node's shared subscriber stream. The node holds no publisher keys, and the same key works from any node | **`POST /pubsub/{topic}/service`** — the admin's control plane: submits a service message (`ROSTER` or `END_OF_STREAM`) as the next update on the service feed. The SOC is signed @@ -502,8 +508,9 @@ the feed id `keccak256(topic ‖ index)` (self-indexed feeds, [SWIP-65](https://github.com/ethersphere/SWIPs/pull/106)); under explicit regimes with `ANCHOR` binding the id does no work and there is no prefix. The node assembles the SOC, validates it exactly as a broker would, and -publishes. The claim is signed the same way: the node passes the challenge, its broker's -overlay and the session's cursor to the dApp and relays the signature **(?)**. End-to-end +publishes. The claim is a SOC the dApp signs like any other: the node passes it the +challenge, its broker's overlay and the session's cursor, and relays the chunk **(?)**. +End-to-end verification against the `CohortSpec` the session supplied — the spec the node sent in `Join` — is performed by the local node — node and dApp are one trust domain. @@ -638,7 +645,7 @@ meaningful.) At depth = 1 the broker's capacity bounds **both** directions: the audience by its stream count, and — since every publisher is attached to it — the publisher count too. Scaling either past one broker is bps-multihop's business -([SWIP-61](https://github.com/ethersphere/SWIPs/pull/105)), which forwards `Message` +([SWIP-61](https://github.com/ethersphere/SWIPs/pull/105)), which forwards `Broadcast` frames rootward as well as leafward. The everyone-publishes rows above — group chat, videoconference, troll-box — are the ones that need it. @@ -697,14 +704,16 @@ could have issued for it, and every message and every roster it publishes carrie signature. Nothing else in the handshake needs to be trusted, because the roster arrives the same way — signed by the admin, on a feed whose gaps are visible. -**The publisher role takes the key, every time.** A claim signs a challenge derived from a -broker secret, the cohort and the address, together with the verifier's overlay and the -publisher's cursor. A third party cannot obtain a claim (it travels on the encrypted -stream to the broker and nowhere else); one captured elsewhere recovers to some other -address here (`S` differs per broker, per restart and per cohort, and `O_B` names the -verifier); a challenge forwarded by a relay the publisher was pointed at yields a -signature over the relay's overlay, which the honest broker refuses; a claim for another -address, or with a changed cursor, does not recover to the address it names. What can be +**The publisher role takes the key, every time.** A claim is a chunk signed over a +challenge derived from a broker secret, the cohort and the address, together with the +verifier's overlay and the publisher's cursor. A third party cannot obtain a claim (it travels on the encrypted +stream to the broker and nowhere else); one captured elsewhere is a valid chunk from the +right key whose payload is not this broker's `S` and overlay, and is refused on that +check (`S` differs per broker, per restart and per cohort; `O_B` names the verifier); a +challenge forwarded by a relay the publisher was pointed at yields a payload naming the +relay's overlay, which the honest broker refuses; a claim for another address does not +validate at the address formed from the declared `addr`, and one with a changed cursor +no longer validates at all. What can be replayed is the identity's own claim, by the node that bridged it, at this broker, until it restarts — and that node held the identity's stream anyway. SWIP-74's *Security considerations* has the case-by-case table. @@ -786,14 +795,15 @@ An implementation is conformant when: publish as, creating the cohort or attaching to it, keyed by the spec's canonical serialisation; `Ack` is a status and, for a declared address, the challenge; a newly attached stream that is not the admin's, and not silent under `closed`, receives the - latest service SOC as its first `Message` **(?)**; + latest service SOC as its first `Broadcast` **(?)**; 7. an absent `admin` is treated as implicit authorship — a stream that declares an address publishes from its `Join` with no claim, each message validated strictly per the binding's SOC shape — and a present one authenticated by its claim and by its signature on every service message, both of which MUST recover to it; -8. a claim is verified over `"bps-claim:v1" ‖ S ‖ O_B ‖ index` with `S` derived as SWIP-74 - specifies, the signer equal to the declared address, and the address the admin's or - rostered — under `ALL` there is no claim and every message is checked against the +8. a claim is a single-owner chunk verified as SWIP-74 specifies — against + `keccak256(keccak256("bps-claim:v1" ‖ topic) ‖ addr)`, its payload the broker's `S`, + its overlay and the cursor — with `addr` the admin's or rostered — under `ALL` there is + no claim and every message is checked against the declared address; a rostered claim upgrades the stream and sets its cursor, no reply is sent; a claim in the `Join` that does not verify is treated as absent; a joiner without a claim is a spectator where the cohort is not `closed`, and silent where it is — @@ -818,8 +828,8 @@ whose admin later publishes a roster — the spec is the same — and it serves stream: the roster and the grantees' updates are dropped as invalid, so an admin that wants a roster needs a full broker. bps-multihop adds its control frames as messages of its own, so it extends without a version bump — -[SWIP-61](https://github.com/ethersphere/SWIPs/pull/105) is to be re-based on the `Message` -frame, a rename of its `Publish`/`Broadcast`, and on the claim, which it forwards +[SWIP-61](https://github.com/ethersphere/SWIPs/pull/105) is to be re-based on the +`Broadcast` frame, into which its `Publish` folds, and on the claim, which it forwards rootward; self-contained frames mean a change of stream model needs no format change either. From 0b51ad421a70ccbefa85cf45d412b9bbd1eb2fc8 Mon Sep 17 00:00:00 2001 From: zelig Date: Mon, 28 Sep 2026 10:43:05 +0200 Subject: [PATCH 14/14] swip-60 rev 8: Broadcast{soc}, no address; the subscriber's rule; proto revision 11 Follows SWIP-74 rev 5. At the broker the address is formed from the binding's id and the stream's claimed or declared address (or the binding's SOC shape under implicit authorship). At a subscriber, which does not see the originating stream and in a multi-publisher cohort knows a set of admissible owners, the rule is stated: recover the owner, form the chunk's address, admit the owner per configuration. Under explicit ANCHOR the WS frame is prefixed with the signed id (sequence number or zero). Co-Authored-By: Claude Fable 5.1 --- SWIPs/assets/swip-60/bps.proto | 16 +++++----- SWIPs/swip-60.md | 53 +++++++++++++++++++++------------- 2 files changed, 42 insertions(+), 27 deletions(-) diff --git a/SWIPs/assets/swip-60/bps.proto b/SWIPs/assets/swip-60/bps.proto index 38598f1e..e7cd4edc 100644 --- a/SWIPs/assets/swip-60/bps.proto +++ b/SWIPs/assets/swip-60/bps.proto @@ -1,7 +1,8 @@ // Broadcast Pub/Sub (BPS) — protocol messages and types. // Spec: SWIP-60 (../../swip-60.md), extending the base wire of SWIP-74 (BPS-lite). // -// Revision 10 (2026-09-27), per Viktor — the claim carried as a chunk; Broadcast. +// Revision 11 (2026-09-28), per Viktor — the claim carried as a chunk; Broadcast is +// the chunk data alone, the receiver forms the address. // // SWIP-74 fixes the base: four frames (Join, Ack, Auth, Broadcast) and the one type // they carry (CohortSpec), for a single publisher over a feed at one broker, one hop, @@ -150,15 +151,16 @@ message Ack { // --------------------------------------------------------------------------- // Both directions after the handshake: publisher -> broker is a publication, -// broker -> peer a delivery of the same bytes. The single-owner chunk travels -// whole, address and data, opaque to the protocol and validated by the ordinary -// SOC code once the id slot has been rewritten as SWIP-74's Frames section says: -// data = id (32) || signature (65) || span (8, LE) || payload (<= 4096) +// broker -> peer a delivery of the same bytes. The single-owner chunk travels as +// its chunk data, opaque to the protocol; the receiver forms the address it must +// have from the binding's id and the owner it knows (the stream's claimed or +// declared address) and validates the chunk against it with the ordinary SOC code +// once the id slot has been rewritten as SWIP-74's Frames section says: +// soc = id (32) || signature (65) || span (8, LE) || payload (<= 4096) // Under FEED_TOPIC a feed update's id slot carries the bare index (SWIP-74, // SWIP-65); a service SOC carries its full id (see ServiceKind). message Broadcast { - bytes address = 1; // 32 bytes: the SOC address, keccak256(id || owner) - bytes data = 2; + bytes soc = 1; } // --------------------------------------------------------------------------- diff --git a/SWIPs/swip-60.md b/SWIPs/swip-60.md index 4ca81d3b..b334d68f 100644 --- a/SWIPs/swip-60.md +++ b/SWIPs/swip-60.md @@ -12,7 +12,7 @@ created: 2026-08-03 +protobuf: assets/swip-60/bps.proto (revision 11, derived from SWIP-74's block). --> - **Business line**: real-time topic streams for dApps without storing chunks or polling — enough on its own for the five cohort shapes it defines: **jam** (a closed set of authors: @@ -376,11 +376,11 @@ sequenceDiagram Note over B,SN: spec in hand + admin-signed roster ⇒
subscriber verifies every message end-to-end PD->>PN: WS: payload - PN->>B: Broadcast(address, data) - B->>B: validate: publisher stream, SOC ⊨ topic binding,
owner = claimed addr (+ cursor / dedup per binding) + PN->>B: Broadcast(soc) + B->>B: validate: publisher stream, SOC at the address formed from
the binding's id and the stream's addr (+ cursor / dedup per binding) par fan-out to every stream of the cohort not bound to the publishing identity - B->>SN: Broadcast(address, data) — every frame self-contained + B->>SN: Broadcast(soc) — every frame self-contained SN->>SN: mux: one p2p stream → N WS sessions SN->>SD: WS: payload end @@ -413,20 +413,31 @@ Messages are defined in [bps.proto](assets/swip-60/bps.proto). Framing notes: its admin. - **Stream model rationale**: per-cohort streams give per-cohort flow control, teardown and role typing, and match bee's protocol idiom. Because every data frame carries the - whole chunk (self-contained, no per-stream handshake state), a later move to - topic-muxed streams requires no format change; the `Auth` chunk is the one frame bound - to its stream by construction — it is verified against the address that stream declared. -- Every `Broadcast` carries the **whole chunk**, address and data (SWIP-74), validated by - the ordinary SOC code; there is no handshake/data frame split. Deliveries go to every + chunk data (self-contained, no per-stream handshake state), a later move to + topic-muxed streams requires no format change; at the broker every frame is verified + against an address formed from what its stream declared or claimed. +- Every `Broadcast` carries the **chunk data** (SWIP-74) and no address. **At the + broker** the receiver forms the address from the binding's id and the owner it knows — + the stream's claimed or declared address, or the binding's SOC shape under implicit + authorship — and validates the chunk against it with the ordinary SOC code. **At a + subscriber**, which does not see which stream a delivery came from and in a + multi-publisher cohort knows a *set* of admissible owners, the rule is: recover the + owner from the signature over `id ‖ wrappedAddress`, form `keccak256(id ‖ owner)` as + the chunk's address (for dedup and for `swarm-soc-fields`), and accept iff that owner + is admissible — the admin or a currently rostered address under explicit authorship, + any address under `ALL` and `MNEMONIC` (attribution, not restriction: the accepted + trade-off), the owner the binding's shape fixes under implicit `OWNER`, `ANCHOR` and + `FEED_TOPIC`, any owner meeting the PO constraint under implicit `SOC_ID`. There is no + handshake/data frame split. Deliveries go to every stream of the cohort except those bound to the publishing identity: a publisher never receives its own messages back, on whichever of its streams it sent them (SWIP-74). - No BPS-level keepalive or RTT probing: liveness is the transport's job, and latency metrics for reorganisation policies are sourced there too. - Broker validation on a `Broadcast`: it arrived on a publisher stream — claimed for its - address, or declaring one under `ALL` or implicit authorship — the chunk validates as a - SOC under the topic binding with the owner hashing to its address, the PO constraint - holds where applicable, and the owner is the stream's address (or fits the binding's SOC - shape under implicit authorship). Invalid ⇒ drop and count; repeated invalid ⇒ disconnect (blocklisting + address, or declaring one under `ALL` or implicit authorship — and the chunk validates as + a SOC at the address the broker forms from the binding's id and the stream's address + (under implicit authorship, from the binding's SOC shape), the PO constraint holding + where applicable. Invalid ⇒ drop and count; repeated invalid ⇒ disconnect (blocklisting policy). A message that passes and is a **duplicate** per the binding's dedup rule is dropped and counted as a retransmit, never as invalid — an admin reconnecting after a reset legitimately resends (SWIP-74); a broker MAY reset a stream whose retransmit rate @@ -506,8 +517,9 @@ signed client-side (bee-js). Where the binding does not fix the SOC id, the fram prefixed with it — for feed bindings the prefix is the bare index, the signed id being the feed id `keccak256(topic ‖ index)` (self-indexed feeds, [SWIP-65](https://github.com/ethersphere/SWIPs/pull/106)); -under explicit regimes with `ANCHOR` binding the id does no work and there is no -prefix. The node assembles the SOC, validates it exactly as a broker would, and +under explicit regimes with `ANCHOR` binding the id does no protocol work but is still +signed over, so the frame is prefixed with the 32-byte id the dApp chose — its sequence +number, or zero. The node assembles the SOC, validates it exactly as a broker would, and publishes. The claim is a SOC the dApp signs like any other: the node passes it the challenge, its broker's overlay and the session's cursor, and relays the chunk **(?)**. End-to-end @@ -731,8 +743,9 @@ peer ID; a record that is merely self-consistent can be presented by anyone who **Defence in depth is the real guarantee.** Even a stream that obtains the publisher role gains nothing by it beyond what its key already signs: every message is validated on -arrival against the SOC signature, its address, and the stream's address (or, for an -implicit cohort, the binding's SOC shape). **Authorship rests on the message signature; +arrival at the address the broker forms from the stream's address (or, for an implicit +cohort, the binding's SOC shape), and again by every subscriber against the set of +owners the cohort admits. **Authorship rests on the message signature; the handshake decides only who is carried as a publisher.** **Audience control exists in exactly one form, and it is not confidentiality.** @@ -782,9 +795,9 @@ An implementation is conformant when: 1. a broker enforces SWIP-74's bounds — streams per cohort, cohorts per broker, cohorts per peer connection, the inactivity deadline — plus publisher legitimacy, per-binding validation and dedup; -2. a subscriber re-verifies every message end-to-end — against the `CohortSpec` it - joined with and the admin-signed roster it received — and detects (only) liveness - faults; +2. a subscriber re-verifies every message end-to-end — recovering the owner, forming + the chunk's address, and admitting the owner against the `CohortSpec` it joined with + and the admin-signed roster it received — and detects (only) liveness faults; 3. the **five** configurations above — jam, spectator-jam, live-stream, group-chat and implicit — interoperate across independent implementations against the frames in [bps.proto](assets/swip-60/bps.proto);