From d0fd9524e62703b656eb9bcca571b077660b0caf Mon Sep 17 00:00:00 2001 From: zelig Date: Mon, 3 Aug 2026 12:42:42 +0200 Subject: [PATCH 01/20] =?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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] =?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/20] 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/20] 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/20] 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/20] 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); From 5a5e0e9455985f8f70b48a61dfe6ff9870ad7abc Mon Sep 17 00:00:00 2001 From: zelig Date: Mon, 28 Sep 2026 14:45:57 +0200 Subject: [PATCH 15/20] swip-60 rev 9: the claim as a CLAIM service message on Broadcast; proto revision 12 Follows SWIP-74 rev 6: no Auth type; ServiceKind CLAIM = 1, ROSTER = 2, END_OF_STREAM = 3; ServiceMessage fields 1-4 (kind, index, challenge, overlay) are SWIP-74's, publishers is 5; Join.auth is the claim chunk's bytes; service SOCs and claims are recognised by id and kind, with the claim's acceptance rule scoped apart from the admin's service SOCs. Co-Authored-By: Claude Fable 5.1 --- SWIPs/assets/swip-60/bps.proto | 79 ++++++++++++++++++---------------- SWIPs/swip-60.md | 59 ++++++++++++++----------- 2 files changed, 75 insertions(+), 63 deletions(-) diff --git a/SWIPs/assets/swip-60/bps.proto b/SWIPs/assets/swip-60/bps.proto index e7cd4edc..217953ce 100644 --- a/SWIPs/assets/swip-60/bps.proto +++ b/SWIPs/assets/swip-60/bps.proto @@ -1,17 +1,19 @@ // Broadcast Pub/Sub (BPS) — protocol messages and types. // Spec: SWIP-60 (../../swip-60.md), extending the base wire of SWIP-74 (BPS-lite). // -// Revision 11 (2026-09-28), per Viktor — the claim carried as a chunk; Broadcast is -// the chunk data alone, the receiver forms the address. +// Revision 12 (2026-09-28), per Viktor — no Auth type: the claim is a Broadcast whose +// chunk payload is a service message of kind CLAIM; nothing on the wire is told apart +// by shape. // -// 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 +// SWIP-74 fixes the base: three frames (Join, Ack, Broadcast) and the two types they +// carry (CohortSpec; ServiceMessage as a chunk payload), 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 Broadcast frames. +// - the admin's service feed (ROSTER, END_OF_STREAM) is two more ServiceKinds. // 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. @@ -92,29 +94,26 @@ message CohortSpec { // claim settles the role. // --------------------------------------------------------------------------- -// 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: +// A publisher's claim on the stream it is sent on is a Broadcast whose chunk is a +// single-owner chunk it signs as any SOC, with // 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 +// payload = ServiceMessage{CLAIM, index, S, O_B} +// 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 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) -} +// O_B is the overlay of the broker the claiming node is connected to and `index` the +// publisher's cursor: its next message has a feed index >= index. The receiver +// derives the id from the topic and the expected address from `addr`, validates the +// chunk against it, decodes the payload and checks the challenge and overlay against +// its own. 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 Broadcast 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. // Peer -> broker: the first frame on a fresh stream. Creates the cohort if no live // cohort has this spec, attaches to it otherwise. @@ -125,7 +124,8 @@ message Join { // ALL and under implicit authorship the one every // publication is validated against, no claim; absent: a // spectator, and no challenge is issued - Auth auth = 3; // a returning publisher's claim, verified before any bound + bytes auth = 3; // a returning publisher's claim: the claim chunk's data, + // verified before any bound } enum Status { @@ -150,15 +150,15 @@ message Ack { // Broadcast — SOC-only is a protocol feature // --------------------------------------------------------------------------- -// 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 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: +// Every frame after the handshake: publisher -> broker a publication, a claim or a +// service message, 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). +// SWIP-65); a claim or a service SOC carries its full id (see ServiceKind). message Broadcast { bytes soc = 1; } @@ -183,15 +183,20 @@ message Broadcast { enum ServiceKind { SERVICE_KIND_UNSPECIFIED = 0; // invalid on the wire (see header note) - ROSTER = 1; // the full publisher set as of this index (not a delta) - END_OF_STREAM = 2; // the admin closes the cohort, attributably + CLAIM = 1; // SWIP-74: a publisher's claim on its stream (not on the feed) + 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. +// The payload of a claim or a service SOC. Fields 1-4 are SWIP-74's. message ServiceMessage { ServiceKind kind = 1; - uint64 index = 2; // this update's index on the service feed - repeated bytes publishers = 3; // set iff ROSTER: 20-byte eth addresses, the + uint64 index = 2; // CLAIM: the publisher's cursor; ROSTER and + // END_OF_STREAM: this update's index on the + // service feed + bytes challenge = 3; // CLAIM: S, as received in the Ack + bytes overlay = 4; // CLAIM: O_B, the broker's overlay + repeated bytes publishers = 5; // 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. diff --git a/SWIPs/swip-60.md b/SWIPs/swip-60.md index b334d68f..11e92f61 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 12, 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,7 +25,7 @@ protobuf: assets/swip-60/bps.proto (revision 11, 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, four frames and a claim handshake. This + publisher over a feed, one broker, one hop, three 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. @@ -225,20 +225,23 @@ 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 is an `Auth`: a single-owner chunk the publisher signs with the -key of `addr`, as it signs any SOC, whose +address joins. The claim is a `Broadcast` whose chunk is a single-owner chunk the +publisher signs with the key of `addr`, as it signs any SOC, whose ``` id = keccak256("bps-claim:v1" ‖ topic) owner = addr address = keccak256(id ‖ addr) -payload = S ‖ O_B ‖ index +payload = ServiceMessage{kind: CLAIM, index, challenge: S, overlay: O_B} ``` `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 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` +forms from the id and the declared `addr`, then decodes the payload and checks its kind, +its challenge against its own `S` and its overlay against its own. The claim is thus one +more service message — the one a publisher sends, where the roster and the end of stream +are the admin's — recognised like them by its id and its kind, never by its shape. 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 @@ -369,7 +372,7 @@ 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: Auth(SOC: id = H("bps-claim:v1" ‖ topic), owner = addr,
payload = S ‖ O_B ‖ index) — no reply + PN->>B: Broadcast(claim SOC: id = H("bps-claim:v1" ‖ topic), owner = addr,
payload = {CLAIM, index, S, O_B}) — no reply SN->>B: Join(CohortSpec) B-->>SN: Ack(OK) B->>SN: Broadcast(latest ROSTER) — the admin's word, relayed @@ -400,12 +403,13 @@ Messages are defined in [bps.proto](assets/swip-60/bps.proto). Framing notes: 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 `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`. + The three frames — `Join`, `Ack`, `Broadcast` — and the two types they carry, + `CohortSpec` and `ServiceMessage`, 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, and what a chunk is from its id and its payload's kind — a subscriber stream + sends only claims, one `Broadcast` each (and it claims again when a roster names it: a + verified claim for a not-yet-rostered address is not a violation), a publisher stream + sends publications and, if it is the admin's, service messages. - **`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 @@ -441,18 +445,20 @@ 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 an `Auth`, and if it is + 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 - `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 - 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. +- **Service messages** ride the same frame and are recognised before the content path, + by id and kind. A `Broadcast` on a stream bound to `admin` whose payload decodes as a + `ServiceMessage` of kind `ROSTER` or `END_OF_STREAM` 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. A `Broadcast` of kind `CLAIM` with id `keccak256("bps-claim:v1" ‖ topic)` is + a claim, on any stream, accepted as *The claim* above says — at the address formed from + the stream's declared `addr`. Under `FEED_TOPIC` a feed update is told from both by the + id slot alone — a bare index (24 leading zero bytes) against a full id — which is why a + SWIP-74 broker drops a service SOC 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 @@ -814,8 +820,9 @@ An implementation is conformant when: 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 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 + `keccak256(keccak256("bps-claim:v1" ‖ topic) ‖ addr)`, its payload a `CLAIM` service + message carrying 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 From 8f7e720a6cc8048e2443e1933a313d215036190a Mon Sep 17 00:00:00 2001 From: zelig Date: Thu, 1 Oct 2026 02:30:44 +0200 Subject: [PATCH 16/20] =?UTF-8?q?swip-60=20rev=2010:=20follows=20SWIP-74?= =?UTF-8?q?=20rev=207=20=E2=80=94=20random=20per-stream=20challenge,=20ses?= =?UTF-8?q?sion=20feed,=20pending=20streams;=20proto=20revision=2013?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every publication under explicit authorship and ALL begins with the stream's challenge; which streams may claim is decided before any cryptography (admin or rostered: pending; else spectator, frames dropped unverified); service SOCs carry the challenge in their payload under their full id and may be the admin's first frame. ServiceKind ROSTER=1, END_OF_STREAM=2; ServiceMessage{kind, index, challenge, publishers}. Co-Authored-By: Claude Fable 5.1 --- SWIPs/assets/swip-60/bps.proto | 127 +++++---- SWIPs/swip-60.md | 473 ++++++++++++++++++--------------- 2 files changed, 323 insertions(+), 277 deletions(-) diff --git a/SWIPs/assets/swip-60/bps.proto b/SWIPs/assets/swip-60/bps.proto index 217953ce..e8defe26 100644 --- a/SWIPs/assets/swip-60/bps.proto +++ b/SWIPs/assets/swip-60/bps.proto @@ -1,19 +1,20 @@ // Broadcast Pub/Sub (BPS) — protocol messages and types. // Spec: SWIP-60 (../../swip-60.md), extending the base wire of SWIP-74 (BPS-lite). // -// Revision 12 (2026-09-28), per Viktor — no Auth type: the claim is a Broadcast whose -// chunk payload is a service message of kind CLAIM; nothing on the wire is told apart -// by shape. +// Revision 13 (2026-10-01), per Viktor — the challenge is random per stream and salts +// the feed topic for the session; the first frame under it is the claim; no claim +// chunk, no CLAIM service message, no credential in Join; a stream declaring a +// publisher's address is pending, outside the fan-out bound, until it claims. // -// SWIP-74 fixes the base: three frames (Join, Ack, Broadcast) and the two types they -// carry (CohortSpec; ServiceMessage as a chunk payload), 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 +// SWIP-74 fixes the base: three frames (Join, Ack, Broadcast) and the one type they +// carry (CohortSpec), for a single publisher over a feed at one broker, one hop, with +// every publication signed under the challenge the broker issued for its stream, so +// that the first one claims the publisher role. 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) is two more ServiceKinds. +// - the admin's service feed (ROSTER, END_OF_STREAM) as a chunk payload, +// ServiceMessage. // 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. @@ -43,7 +44,8 @@ enum TopicBinding { 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 = keccak256(owner); any id, same PO constraint (MIC) - FEED_TOPIC = 4; // id = keccak256(topic || index); feed-update streams + FEED_TOPIC = 4; // a feed on the topic; on the wire the session feed: + // id = keccak256(keccak256(topic || challenge) || index) 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 @@ -81,8 +83,9 @@ message CohortSpec { bool history = 5; // deliver matching chunks from the local store 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. + // its first frame under its challenge recovers + // to the admin or a rostered address within the + // claim deadline. // Unset = open, so that a SWIP-74 spec, which // never sets it, reads as an open cohort. } @@ -91,41 +94,23 @@ message CohortSpec { // Stream establishment, stream name "pubsub/1.0.0" — one stream per // (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. +// first frame under the stream's challenge settles the role. // --------------------------------------------------------------------------- -// A publisher's claim on the stream it is sent on is a Broadcast whose chunk is a -// single-owner chunk it signs as any SOC, with -// id = keccak256("bps-claim:v1" || topic) never a feed id -// owner = addr address = keccak256(id || addr) -// payload = ServiceMessage{CLAIM, index, S, O_B} -// 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` the -// publisher's cursor: its next message has a feed index >= index. The receiver -// derives the id from the topic and the expected address from `addr`, validates the -// chunk against it, decodes the payload and checks the challenge and overlay against -// its own. 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 Broadcast 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. - // Peer -> broker: the first frame on a fresh stream. Creates the cohort if no live -// cohort has this spec, attaches to it otherwise. +// cohort has this spec, attaches to it otherwise. Nothing else is ever in it -- no +// cursor, no credential: the stream's role follows from what it sends after the +// Ack. 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 and under implicit authorship the one every - // publication is validated against, no claim; absent: a - // spectator, and no challenge is issued - bytes auth = 3; // a returning publisher's claim: the claim chunk's data, - // verified before any bound + bytes addr = 2; // 20 bytes, required: the stream's identity -- under + // explicit authorship the address whose first frame under + // the challenge claims the stream; the admin's or a + // rostered one makes the stream pending (outside the + // fan-out bound, receiving nothing until it claims), any + // other a spectator; under ALL and under implicit + // authorship the one every publication is validated + // against, no claim } enum Status { @@ -139,26 +124,35 @@ enum Status { // Broker -> peer, answering Join. A non-OK Ack ends the stream. The roster // reaches a newly attached stream as its first Broadcast (see ServiceKind) -- -// except the admin's own, and except under `closed`, where nothing is delivered +// except a pending one, 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 + bytes challenge = 2; // iff OK: exactly 24 bytes drawn at random for this + // stream, never all zero -- the salt every publication on + // it carries (SWIP-74); held for the stream's life, never + // persisted, never reused } // --------------------------------------------------------------------------- // Broadcast — SOC-only is a protocol feature // --------------------------------------------------------------------------- -// Every frame after the handshake: publisher -> broker a publication, a claim or a -// service message, 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: +// Every frame after the handshake: publisher -> broker a publication or, from the +// admin, a service message; 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 id and the owner it knows (the +// stream's claimed or declared address) and validates the chunk against it with +// the ordinary SOC code: // 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 claim or a service SOC carries its full id (see ServiceKind). +// A publication's id slot begins with the stream's challenge (SWIP-74, Frames): +// under FEED_TOPIC it is challenge (24) || index (8) and the signed id is +// keccak256(keccak256(topic || challenge) || index); under the other bindings, with +// explicit authorship or ALL, it is challenge (24) || 8 bytes of the publisher's +// choosing and the signed id is the slot itself. A pending stream's first frame +// under its challenge is its claim. A service SOC carries its full id and names the +// challenge in its payload (see ServiceKind); under implicit authorship the chunks +// are the binding's own and carry their own id. message Broadcast { bytes soc = 1; } @@ -168,12 +162,15 @@ message Broadcast { // // Service messages are ordinary SOCs on the ordinary path, owned by the admin: // -// owner = admin id = keccak256("bps-service:v1" || topic || index) +// owner = admin id = keccak256("bps-service:v1" || topic || challenge || index) // -// 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) +// travelling as Broadcast frames with their full 32-byte id, on the admin's +// publisher or pending stream, 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 and the challenge of the stream it was published on, so +// the id is verifiable without an out-of-band hint and the message is as fresh as +// a publication -- and a service SOC is told from a publication by that id alone: +// it never begins with a stream's challenge. 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 @@ -183,20 +180,16 @@ message Broadcast { enum ServiceKind { SERVICE_KIND_UNSPECIFIED = 0; // invalid on the wire (see header note) - CLAIM = 1; // SWIP-74: a publisher's claim on its stream (not on the feed) - ROSTER = 2; // the full publisher set as of this index (not a delta) - END_OF_STREAM = 3; // the admin closes the cohort, attributably + 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 claim or a service SOC. Fields 1-4 are SWIP-74's. +// The payload of a service SOC. message ServiceMessage { ServiceKind kind = 1; - uint64 index = 2; // CLAIM: the publisher's cursor; ROSTER and - // END_OF_STREAM: this update's index on the - // service feed - bytes challenge = 3; // CLAIM: S, as received in the Ack - bytes overlay = 4; // CLAIM: O_B, the broker's overlay - repeated bytes publishers = 5; // set iff ROSTER: 20-byte eth addresses, the + uint64 index = 2; // this update's index on the service feed + bytes challenge = 3; // the challenge of the admin's stream, as in the Ack + repeated bytes publishers = 4; // 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. diff --git a/SWIPs/swip-60.md b/SWIPs/swip-60.md index 11e92f61..7578fd0a 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 13, 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,7 +25,8 @@ protobuf: assets/swip-60/bps.proto (revision 12, 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 and a claim handshake. This + publisher over a feed, one broker, one hop, three frames and a per-stream challenge that + salts every publication, the first of which is the claim. 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. @@ -75,7 +76,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 — 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 | +| `closed` | bool, unset = open | set: no audience — every stream is admitted silent, receiving nothing, and is disconnected unless its first frame under the stream's challenge recovers to the admin or a rostered address 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 @@ -107,7 +108,7 @@ 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 -role of its own: it is simply an admin that never sends. +role of its own: it is simply an admin that publishes nothing but service messages. Binding semantics (dedup rule in parentheses): @@ -127,8 +128,10 @@ Binding semantics (dedup rule in parentheses): 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). +- **`FEED_TOPIC`** — a feed on the topic; feed-update streams, graffiti MIC. Under + implicit authorship the id is `keccak256(topic ‖ index)` and any owner qualifies (dedup + on chunk address); under explicit authorship the wire carries SWIP-74's session feed, + id = `keccak256(keccak256(topic ‖ challenge) ‖ index)`, and dedup is the cursor. 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 @@ -140,11 +143,12 @@ SOC** the binding fixes: | `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)` | +| `FEED_TOPIC` | feed | **many** | any owner on the feed's id `keccak256(topic ‖ index)` — a graffiti feed | | `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. +protocol work beyond carrying the stream's challenge in its first 24 bytes: its last 8 +bytes are **unconstrained**, and publishers MAY use them 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)**. @@ -156,9 +160,10 @@ 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 — 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). +answers `FULL` when it is exhausted — admitting a stream that declares the admin's, or a +rostered, address outside that limit as a pending stream, silent until it claims, 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 @@ -175,7 +180,7 @@ Everything the admin says about the cohort — that it exists, who may write to is over — travels as SOCs on a feed the admin owns: ``` -owner = admin id = keccak256("bps-service:v1" ‖ topic ‖ index) +owner = admin id = keccak256("bps-service:v1" ‖ topic ‖ challenge ‖ index) ``` | index | message | carries | @@ -185,13 +190,15 @@ owner = admin id = keccak256("bps-service:v1" ‖ topic ‖ index) 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: +Each service message carries its own index and the challenge of the stream it was +published on in its payload, so its id is verifiable without an out-of-band hint — and +it is as fresh as any publication: signed for one stream, the admin's, whose first frame +it may be. 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 claim is a signature over a challenge only this - broker could have issued for it, and every + is an address anyone can read, its stream is claimed by a frame signed under a + challenge that exists on that stream only, 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 @@ -205,102 +212,117 @@ without an out-of-band hint. Three properties follow, and each of them is the po 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 +attached stream that is not pending — declaring neither the admin's nor a rostered +`addr` — and not silent under `closed`, the broker 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 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*): - -``` -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 is a `Broadcast` whose chunk is a single-owner chunk the -publisher signs with the key of `addr`, as it signs any SOC, whose - -``` -id = keccak256("bps-claim:v1" ‖ topic) -owner = addr address = keccak256(id ‖ addr) -payload = ServiceMessage{kind: CLAIM, index, challenge: S, overlay: O_B} -``` - -`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 receiver verifies it with the ordinary SOC validation against the address it -forms from the id and the declared `addr`, then decodes the payload and checks its kind, -its challenge against its own `S` and its overlay against its own. The claim is thus one -more service message — the one a publisher sends, where the roster and the end of stream -are the admin's — recognised like them by its id and its kind, never by its shape. 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 -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 +#### The claim: the first frame under the stream's challenge + +The role of a stream under explicit authorship is settled by its **first frame under its +challenge**, exactly as in SWIP-74 (*Handshake*). With `Ack{OK}` the broker sends every +stream a **challenge**: exactly 24 bytes drawn at random for that stream, never all +zero, held for its life, never persisted and never reused. For the life of the stream +every publication on it carries the challenge in the clear as the first 24 bytes of its +chunk's `id` slot — under `FEED_TOPIC` the slot is `challenge ‖ index` and the signed id +is `keccak256(keccak256(topic ‖ challenge) ‖ index)`, the session feed of SWIP-74; under +the other bindings the slot is `challenge ‖ 8 bytes of the publisher's choosing` (a +sequence number, or zero) and is the signed id itself — and the admin's service SOCs +carry it in their payload, under their full id (*Wire protocol*). The broker requires +the slot to begin with its own challenge for that stream, or the payload to name it, +before it looks at a signature; a subscriber reconstructs the id from the slot and +verifies the signature and the owner as for any chunk, and does not need the challenge. +A chunk signed under the challenge is possible only for the key of the address it +recovers to, and only after the `Ack` — so on a stream that has not yet published, a +frame under the stream's challenge that validates as a single-owner chunk at the address +formed from the id and the stream's declared `addr` proves the key and the session at +once. That frame is the claim: the stream **upgrades** to a publisher stream, and the +frame is delivered like any publication or service message. There is **no reply**: the +publisher sends its first frame and its next back to back, and learns the outcome from +whether the stream survives. + +Which streams may claim is decided before any cryptography, by the declared `addr` +against `admin` and the **current roster**: a stream whose `addr` is the admin's or +currently rostered is **pending** — admitted outside the fan-out bound, receiving +nothing (SWIP-74, *Resource bounds*), until its first frame under its challenge upgrades +it or the claim deadline disconnects it, with a stream in a `closed` cohort likewise +silent; a stream whose `addr` is neither is a **spectator** within the bound, and every +frame it sends is dropped unverified and counted, not a violation — the peer may not have +seen the roster that names it, or a roster may be on its way — a broker MAY reset a +stream whose rate of such drops exceeds its policy, and the stream claims with its next +frame once a roster names it. A frame on a pending stream whose slot does not begin +with the stream's challenge is dropped and counted (`wrong_challenge`), as on a publisher +stream; one under the challenge that does not validate is a violation. A seat that +wants to be present before it plays has nothing contentless to send and claims with its +first update — or with a publisher-signed, undelivered service kind this SWIP does not +yet define **(?)**. + +What the 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; so would a claim signed over a challenge +the broker *derived* for the address rather than drew for the stream — whoever had +captured it could present it again once the publisher dropped. A challenge that exists +on one stream is answered on that stream or nowhere, and because every publication and +every service message answers it, none of them can be presented on another stream +either, at this broker or any other. What the challenge does *not* protect is history: replaying the admin's +signed updates of the channel's own feed 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 +Under `ALL` there is **no claim** and the challenge still salts: everybody attached may +publish, so a stream that declares an address is a publisher stream from its `Join`, and +every publication is held to that address — the SOC's address must hash to the declared +owner and its signature recover to it — and must begin with the stream's challenge, so +that a participant's captured messages cannot be replayed into the chat under its name +from another stream. Under **implicit authorship** there is no claim and no salt: the +chunks are the binding's own — a live MIC is the owner's storage chunks as they are +published — so they carry their own id, replay is what a store does, and a stream that +declares an address is a publisher stream from its `Join` whose every publication must +fit the binding's shape, with its owner 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 first frame under the challenge 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 +is — carrying the full `CohortSpec` and the address it publishes as, `addr`, and nothing +else: no cursor, no credential. 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. +genuine spec is a different cohort. The broker answers `Ack{OK, challenge}` — the +challenge drawn for this stream — 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**: +Then the stream's role, from its declared `addr` against `admin` and the **current +roster**, and from its first frame under its challenge: -| claim | `closed` unset | `closed` set | +| `addr`, and first frame | `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 | +| the admin's or rostered; none yet | a **pending stream**: outside the fan-out bound, receiving nothing, until it claims or the claim deadline disconnects it | the same | +| the admin's or rostered; under the stream's challenge, validating at the address formed from the id and `addr` | the stream is a **publisher stream**, and the frame is delivered | the same | +| the admin's or rostered; not under the stream's challenge | dropped and counted (`wrong_challenge`); pending still | the same | +| the admin's or rostered; under the challenge but not valid | violation: the stream is reset, the peer blocklisted per policy | the same | +| neither | a **spectator stream**, read-only, within the bound; every frame it sends is dropped unverified and counted; it claims with its next frame once a roster names it | a **silent stream**: attached, receiving nothing, until a roster names it and its next frame upgrades it, or the claim deadline disconnects it | 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 +who it fails to prove it is — and it is enforceable precisely because the first frame +under a stream's challenge is signed for that stream only, by the key the roster names — +or by whatever that key hands its challenge to, which is that key's business **(?)**. +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`. +changes. A **grant** takes effect when the granted peer publishes: on its current stream, +once it sees itself in the roster it is delivered, or on a new one. A **revocation** has two phases, and the boundary between them is the moment the reduced roster reaches subscribers: @@ -318,8 +340,8 @@ unknowing publisher into a violating one.** A broker that tore the stream down b 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). +itself is the protocol's existing one for a violation: the stream is reset and the peer +blocklisted per policy, as for a first frame that fails its claim. 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 @@ -330,10 +352,10 @@ disconnection they cannot attribute. - **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 + (`FULL`) — except that, as in SWIP-74, it admits **a `Join` declaring the admin's + address, or a rostered one, outside the fan-out bound as a pending stream**: attached, + receiving nothing, disconnected if it has not claimed within the claim deadline, and + bounded per peer connection and cohort; 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** — @@ -342,8 +364,8 @@ disconnection they cannot attribute. 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. + admin that publishes nothing but service messages is a **moderator**; no separate role + is needed, since being a publisher obliges nobody to publish. - **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 @@ -371,10 +393,10 @@ 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: Broadcast(claim SOC: id = H("bps-claim:v1" ‖ topic), owner = addr,
payload = {CLAIM, index, S, O_B}) — no reply - SN->>B: Join(CohortSpec) - B-->>SN: Ack(OK) + B-->>PN: Ack(OK, challenge) — 24 random bytes for this stream + PN->>B: Broadcast(first update: id slot = challenge ‖ index, signed by addr) — the claim, no reply + SN->>B: Join(CohortSpec, addr) + B-->>SN: Ack(OK, challenge) 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 @@ -399,17 +421,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, 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 + a fresh stream is **`Join`**, carrying the full `CohortSpec` and the address the stream + publishes as, nothing else; the broker answers with `Ack{status, challenge}`, and delivers the latest service SOC as the stream's first `Broadcast`, so the joiner verifies the roster against the admin rather than the broker. - The three frames — `Join`, `Ack`, `Broadcast` — and the two types they carry, - `CohortSpec` and `ServiceMessage`, 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, and what a chunk is from its id and its payload's kind — a subscriber stream - sends only claims, one `Broadcast` each (and it claims again when a roster names it: a - verified claim for a not-yet-rostered address is not a violation), a publisher stream - sends publications and, if it is the admin's, service messages. + The three frames — `Join`, `Ack`, `Broadcast` — and the type they carry, `CohortSpec`, + are SWIP-74's; this SWIP adds fields, values and the service message a chunk's payload + may carry, never frames. There is no envelope: what a frame is follows from the + stream's direction and role — a pending stream sends its claim, its first frame under + its challenge, a spectator stream sends nothing (what it sends is dropped unverified, + not punished: it claims once a roster names it), a publisher stream sends publications + and, if it is the admin's, service messages. - **`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 @@ -429,36 +451,46 @@ Messages are defined in [bps.proto](assets/swip-60/bps.proto). Framing notes: 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 + any address under `ALL` and `MNEMONIC` and under implicit `FEED_TOPIC` (attribution, + not restriction: the accepted trade-off), the owner the binding's shape fixes under + implicit `OWNER` and `ANCHOR`, 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 — 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 +- Broker validation on a `Broadcast`: it arrived on a publisher stream — claimed by its + first frame under its challenge, or declaring an address under `ALL` or implicit authorship — its id + slot begins with the stream's challenge (except under implicit authorship), and the + chunk validates as a SOC at the address the broker forms from the id and the stream's + address (under implicit authorship, from the binding's SOC shape), the PO constraint + holding where applicable. A slot that does not begin with the stream's challenge is + dropped and counted, not a violation (SWIP-74: `wrong_challenge`); invalid otherwise + ⇒ 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). + exceeds its policy. A frame on a pending stream is its claim: not under the stream's + challenge, it is dropped and counted (`wrong_challenge`); under it and not valid at the + address formed from its id and the declared `addr`, it is a protocol violation — + dropped, the stream reset, the peer blocklisted (SWIP-74). A frame on a spectator + stream — one whose `addr` is neither the admin's nor rostered — is dropped unverified + and counted; a broker MAY reset such a stream on rate. - **Service messages** ride the same frame and are recognised before the content path, - by id and kind. A `Broadcast` on a stream bound to `admin` whose payload decodes as a - `ServiceMessage` of kind `ROSTER` or `END_OF_STREAM` 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. A `Broadcast` of kind `CLAIM` with id `keccak256("bps-claim:v1" ‖ topic)` is - a claim, on any stream, accepted as *The claim* above says — at the address formed from - the stream's declared `addr`. Under `FEED_TOPIC` a feed update is told from both by the - id slot alone — a bare index (24 leading zero bytes) against a full id — which is why a - SWIP-74 broker drops a service SOC rather than punishing it. + by id and kind, in this order: a `Broadcast` on the admin's publisher or pending stream + whose payload decodes as a `ServiceMessage` of a defined kind, whose `payload.challenge` + is the stream's challenge and whose id slot equals + `keccak256("bps-service:v1" ‖ topic ‖ payload.challenge ‖ payload.index)` is a service + SOC — on a pending stream its claim — and takes the service path: it is accepted iff + it validates as a SOC under that id with owner `admin` and `payload.index` exceeds the + service feed's cursor (initially absent: index 0 is accepted); one that validates but + does not exceed the cursor is a retransmit, dropped and counted as one; one that does + not validate is invalid. Any other frame takes the publication path above. A service + SOC is told from a publication by its id slot alone — a full id, which never begins + with a stream's challenge — and a subscriber recomputes that id from the payload it + carries. That is why a SWIP-74 broker drops a service SOC as `wrong_challenge` 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 @@ -467,9 +499,11 @@ Messages are defined in [bps.proto](assets/swip-60/bps.proto). Framing notes: 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 + publisher feed — the lowest index it accepts next, set forward by every accepted + update, never back — and needs no window for it; the other bindings dedup on chunk + address within the bounded window, and the challenge in every id keeps a message of + one session out of every other. A publisher never reuses an index across sessions + (SWIP-74). What multihop's dual paths do to this is [SWIP-61](https://github.com/ethersphere/SWIPs/pull/105)'s business. ### API (WebSocket bridge) @@ -493,10 +527,11 @@ 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 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 | +| `addr` | `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 dApp signs every update under it client-side as it signs any SOC — 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 publish 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 +(`ROSTER` or `END_OF_STREAM`) as the next update on the service feed, carrying the +challenge of the admin's stream. 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. @@ -520,21 +555,25 @@ 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, +prefixed with it — for feed bindings under explicit authorship the prefix is +`challenge ‖ index`, the signed id being the session feed's +`keccak256(keccak256(topic ‖ challenge) ‖ index)` (SWIP-74); under implicit authorship the bare +index, the signed id being `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 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 **(?)**. +under explicit regimes with `ANCHOR` binding the id does no protocol work beyond the +challenge but is still signed over, so the frame is prefixed with `challenge ‖` the 8 +bytes the dApp chose — its sequence number, or zero. The node assembles the SOC, +validates it exactly as a broker would, and +publishes. There is no separate claim to sign: the node passes the session the +challenge, the dApp prefixes it to the id of every update, and the first update is the +claim **(?)**. 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: +its address, and its first publication under the challenge it is handed recovers to +`admin` ⇒ read–write; the spec creates the cohort: ``` wss://node:1633/pubsub/jam-tuesday?peer= @@ -550,10 +589,10 @@ wss://node:1633/pubsub/jam-tuesday?peer= 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) +fifth seat later without any of the above changing. Each seat becomes a publisher by its +first publication under the challenge issued for its stream; because the cohort is +`closed`, a seat receives nothing until that publication 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 @@ -572,12 +611,14 @@ 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 +Seat A joins and claims its stream with its first frame — the `ROSTER` that grants B, C +and D will do — and each of them becomes a +publisher by its first publication, accepted because it is signed under the stream's +challenge by an address on the roster the others 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 +enforceable because the first frame on a stream is signed under a challenge that +exists on that stream only. 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. @@ -590,7 +631,7 @@ 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. +and publishes, 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. @@ -615,9 +656,11 @@ 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 +No roster, no claim: 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 +hash and signature, message by message, never at join — and must begin with the stream's +challenge, so that nothing said in one session can be replayed into another under the +speaker's name. 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 — @@ -717,23 +760,23 @@ verifiable signed chunks — not to reimplement a mesh. **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 -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 +`admin` is a public address; its stream is claimed by a publication signed under a +challenge that exists on that stream only, 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 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 +**The publisher role takes the key, every time, and the session takes it again.** Every +publication under explicit authorship or `ALL` is signed under the challenge the broker +drew for the stream it travels on; under explicit authorship the first frame under it is +the claim, under `ALL` there is none. A third party cannot +obtain anything it could use: a captured publication — every subscriber has them — is a +valid chunk from the right key whose id begins with a challenge no other stream has, and +is refused on that check before any signature is looked at, on this broker after the +stream is gone, on another broker, on another cohort; a publication for another +address does not validate at the address formed from the declared `addr`. A challenge +forwarded by a relay the publisher was pointed at turns the relay into a transparent +hop for the publisher's own updates, which can withhold and not author. There is no +credential that outlives a stream. 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 @@ -742,11 +785,6 @@ 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'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 arrival at the address the broker forms from the stream's address (or, for an implicit @@ -756,8 +794,12 @@ the handshake decides only who is carried as a publisher.** **Audience control exists in exactly one form, and it is not confidentiality.** `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 +enforceable because the first frame on a stream is signed under a challenge that +exists on that stream only. It bounds *attendance at this broker* to holders of the +admin's and rostered keys — and to whatever sits between such a key and the broker: a +member pointed at a relay hands it its challenge, and the relay attends in its name, +which no wire check without the verifier's overlay in the signature can prevent +**(?)**. 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 @@ -778,8 +820,10 @@ 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, plus one extra stream per absent publisher -and its claim deadline — and, for the bindings that dedup on chunk address, bounds its +and reclaims idle cohorts — SWIP-74's bounds, with pending streams for the admin's and +rostered addresses outside the fan-out bound, silent until they claim or the claim +deadline passes, and a bound on streams per peer connection per cohort — 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. @@ -799,38 +843,44 @@ revocations are the service feed's business, and neither changes the cohort. 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 + per peer connection, streams per peer connection per cohort, the inactivity deadline, + the claim deadline on pending streams — plus publisher legitimacy, per-binding validation and dedup; -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; +2. a subscriber re-verifies every message end-to-end — reconstructing the id from the + slot where the binding hashes it, 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); 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 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 that is not the admin's, and not silent under `closed`, receives the - latest service SOC as its first `Broadcast` **(?)**; +6. the handshake is one `Join` carrying the full spec and the address the stream + publishes as, and nothing else, creating the cohort or attaching to it, keyed by the + spec's canonical serialisation; `Ack` is a status and, on `OK`, a challenge of 24 + bytes drawn at random for that stream, held for its life and never persisted or + reused; a newly attached stream that is not pending, and not silent under + `closed`, receives the 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 a single-owner chunk verified as SWIP-74 specifies — against - `keccak256(keccak256("bps-claim:v1" ‖ topic) ‖ addr)`, its payload a `CLAIM` service - message carrying 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 — - 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; + publishes from its `Join` with no claim and no salt, each message validated strictly + per the binding's SOC shape — and a present one authenticated by its first frame + under its stream's challenge and by its signature on every service message, both of + which MUST recover to it; +8. under explicit authorship every publication's id slot begins with the stream's + challenge — `challenge ‖ index` with the id `keccak256(keccak256(topic ‖ challenge) ‖ + index)` under `FEED_TOPIC`, `challenge ‖ 8 free bytes` as the id itself under the + other bindings — and a stream declaring the admin's or a rostered `addr` is pending, + outside the fan-out bound and receiving nothing, until its first frame under its + challenge, verified as SWIP-74 specifies at the address formed from the id and the + declared `addr`, upgrades it, no reply sent, or the claim deadline disconnects it; + a frame on a stream declaring any other `addr` is dropped unverified and counted; + under `ALL` there is no claim, every message begins with the stream's challenge and + is checked against the declared address; a `closed` cohort delivers nothing to a + stream before it claims — the only refusal for identity in the protocol; a service + SOC carries its full id `keccak256("bps-service:v1" ‖ topic ‖ challenge ‖ index)`, + the challenge and the index in its payload, and is accepted on the admin's publisher + or pending stream only; 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; @@ -842,16 +892,19 @@ An implementation is conformant when: 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. bps-multihop adds its control frames as messages of its own, +SWIP-74 broker refuses at the handshake every spec whose `binding` is not `FEED_TOPIC` or +whose `admin` is absent, and ignores the fields it does not define (`publishers`, +`history`, `closed`), serving such a spec as a live stream. It likewise cannot refuse 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 is dropped as `wrong_challenge` — its full id does not begin with the +stream's challenge — and a grantee, whose `addr` is not the admin's, is a subscriber +stream there, so its first publication is a violation that resets its stream; 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 -`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. +`Broadcast` frame, into which its `Publish` folds, and on the per-stream challenge, which +its attachment nodes issue; self-contained frames mean a change of stream model needs no +format change either. ## References From 0e4d835080a7b25a055403b3618d4112d9e7408d Mon Sep 17 00:00:00 2001 From: zelig Date: Thu, 1 Oct 2026 14:07:46 +0200 Subject: [PATCH 17/20] =?UTF-8?q?swip-60=20rev=2011:=20follows=20SWIP-74?= =?UTF-8?q?=20rev=208=20=E2=80=94=20service=20messages=20are=20kinds;=20pr?= =?UTF-8?q?oto=20revision=2014?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kind gains EOS and ROSTER, Roster is the payload of a ROSTER chunk; ServiceKind and ServiceMessage go. One id derivation under explicit authorship and ALL, each kind a feed of its own. The claim is the first valid frame (a publication, a ROSTER, an AUTH); unrostered members do not get to publish; an invalid chunk is a violation. Co-Authored-By: Claude Fable 5.1 --- SWIPs/assets/swip-60/bps.proto | 187 ++++++------ SWIPs/swip-60.md | 503 ++++++++++++++++++--------------- 2 files changed, 370 insertions(+), 320 deletions(-) diff --git a/SWIPs/assets/swip-60/bps.proto b/SWIPs/assets/swip-60/bps.proto index e8defe26..c1c922db 100644 --- a/SWIPs/assets/swip-60/bps.proto +++ b/SWIPs/assets/swip-60/bps.proto @@ -1,28 +1,29 @@ // Broadcast Pub/Sub (BPS) — protocol messages and types. // Spec: SWIP-60 (../../swip-60.md), extending the base wire of SWIP-74 (BPS-lite). // -// Revision 13 (2026-10-01), per Viktor — the challenge is random per stream and salts -// the feed topic for the session; the first frame under it is the claim; no claim -// chunk, no CLAIM service message, no credential in Join; a stream declaring a -// publisher's address is pending, outside the fan-out bound, until it claims. +// Revision 14 (2026-10-01), per Viktor — the chunk is an ordinary single-owner chunk +// with its full id; the frame carries what the id was derived from (kind, challenge, +// index) beside it; the challenge is 32 bytes; service messages are kinds, not +// payloads; AUTH is the empty claim; a chunk that does not validate disconnects. // -// SWIP-74 fixes the base: three frames (Join, Ack, Broadcast) and the one type they -// carry (CohortSpec), for a single publisher over a feed at one broker, one hop, with -// every publication signed under the challenge the broker issued for its stream, so -// that the first one claims the publisher role. This -// file adds what the full singlehop protocol needs and changes nothing SWIP-74 -// defines: +// SWIP-74 fixes the base: three frames (Join, Ack, Broadcast) and the two types they +// carry (CohortSpec, Kind with DATA and AUTH), for a single publisher over a feed at +// one broker, one hop, with every chunk signed under an id salted by the challenge +// the broker issued for its stream, so that the first valid frame claims the +// publisher role. 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) as a chunk payload, -// ServiceMessage. +// - Kind gains the admin's service kinds, EOS and ROSTER, and Roster is the +// payload of a ROSTER chunk. // 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. +// what a frame is follows from the stream's direction and role, and what a chunk is +// from the frame's kind. 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. +// reject messages carrying it -- PublisherRegime excepted, where unset is a value. // // Implementation: bee PR #5626. @@ -34,7 +35,7 @@ option go_package = "github.com/ethersphere/bee/v2/pkg/bps/pb"; // --------------------------------------------------------------------------- // 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). +// emitted). The roster is NOT here (see Kind). // --------------------------------------------------------------------------- // What the topic binds to (see SWIP-60: binding semantics). SWIP-74 defines @@ -44,10 +45,11 @@ enum TopicBinding { 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 = keccak256(owner); any id, same PO constraint (MIC) - FEED_TOPIC = 4; // a feed on the topic; on the wire the session feed: - // id = keccak256(keccak256(topic || challenge) || index) - MNEMONIC = 5; // the topic names the cohort and constrains nothing: any SOC - // from any owner qualifies (dedup on chunk address). What + FEED_TOPIC = 4; // a feed on the topic; on the wire the session feed (see + // Broadcast) + MNEMONIC = 5; // the topic names the cohort and constrains nothing: under + // implicit authorship any SOC from any owner qualifies (dedup + // on chunk address), under ALL any owner's session chunk. What // PublisherRegime.ALL needs -- authorship unrestricted, but // never unattributable, since every message is SOC-signed. } @@ -59,7 +61,8 @@ enum TopicBinding { // 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) + PUBLISHER_REGIME_UNSPECIFIED = 0; // unset: the admin and whoever its roster names + // -- the one zero value legitimate on the wire ALL = 1; // anyone attached; needs MNEMONIC binding } @@ -82,10 +85,11 @@ message CohortSpec { PublisherRegime publishers = 4; // ALL, or unset (see the enum) bool history = 5; // deliver matching chunks from the local store bool closed = 6; // no audience: every stream is admitted silent, - // receiving nothing, and is disconnected unless - // its first frame under its challenge recovers - // to the admin or a rostered address within the - // claim deadline. + // receiving nothing but a roster naming its addr, + // and is disconnected unless + // its first valid frame, from the admin's 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. } @@ -94,7 +98,7 @@ message CohortSpec { // Stream establishment, stream name "pubsub/1.0.0" — one stream per // (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 -// first frame under the stream's challenge settles the role. +// first valid Broadcast settles the role. // --------------------------------------------------------------------------- // Peer -> broker: the first frame on a fresh stream. Creates the cohort if no live @@ -104,13 +108,14 @@ message CohortSpec { message Join { CohortSpec cohort = 1; bytes addr = 2; // 20 bytes, required: the stream's identity -- under - // explicit authorship the address whose first frame under - // the challenge claims the stream; the admin's or a - // rostered one makes the stream pending (outside the - // fan-out bound, receiving nothing until it claims), any - // other a spectator; under ALL and under implicit - // authorship the one every publication is validated - // against, no claim + // explicit authorship the address whose first valid + // frame claims the stream; the admin's or a rostered + // one makes the stream pending (outside the fan-out + // bound, receiving nothing but the roster that names it + // until it claims), any other + // a spectator, which may not publish; under ALL and + // under implicit authorship the one every publication + // is validated against, no claim } enum Status { @@ -119,80 +124,76 @@ enum Status { FULL = 2; // a capacity bound (per cohort, per broker, per peer // connection); a singlehop broker refuses -- nothing // else - REJECTED = 3; // the SPEC is unacceptable: a value outside this SWIP + REJECTED = 3; // a spec value outside this SWIP, or a malformed Join + // (addr not 20 bytes) } -// Broker -> peer, answering Join. A non-OK Ack ends the stream. The roster -// reaches a newly attached stream as its first Broadcast (see ServiceKind) -- -// except a pending one, and except under `closed`, where nothing is delivered -// before the claim. +// Broker -> peer, answering Join. A non-OK Ack ends the stream. The latest roster +// is the first Broadcast on a stream when it enters a fan-out set -- at attach for +// a spectator, at upgrade for a pending or silent stream -- and reaches a pending +// or silent stream before that only if it names the stream's addr. message Ack { Status status = 1; - bytes challenge = 2; // iff OK: exactly 24 bytes drawn at random for this - // stream, never all zero -- the salt every publication on - // it carries (SWIP-74); held for the stream's life, never - // persisted, never reused + bytes challenge = 2; // iff OK: 32 bytes drawn at random for this stream -- the + // salt of its session feeds (SWIP-74); held for the + // stream's life, never persisted, never reused } // --------------------------------------------------------------------------- // Broadcast — SOC-only is a protocol feature // --------------------------------------------------------------------------- -// Every frame after the handshake: publisher -> broker a publication or, from the -// admin, a service message; 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 id and the owner it knows (the -// stream's claimed or declared address) and validates the chunk against it with -// the ordinary SOC code: -// soc = id (32) || signature (65) || span (8, LE) || payload (<= 4096) -// A publication's id slot begins with the stream's challenge (SWIP-74, Frames): -// under FEED_TOPIC it is challenge (24) || index (8) and the signed id is -// keccak256(keccak256(topic || challenge) || index); under the other bindings, with -// explicit authorship or ALL, it is challenge (24) || 8 bytes of the publisher's -// choosing and the signed id is the slot itself. A pending stream's first frame -// under its challenge is its claim. A service SOC carries its full id and names the -// challenge in its payload (see ServiceKind); under implicit authorship the chunks -// are the binding's own and carry their own id. -message Broadcast { - bytes soc = 1; +// What a chunk on this wire is. DATA and AUTH are SWIP-74's; EOS and ROSTER are +// the admin's control plane: ordinary SOCs on the ordinary path, owned by the +// admin, so a broker relays them and cannot author them, and a subscriber checks +// them with the same code as any broadcast. +enum Kind { + KIND_UNSPECIFIED = 0; // invalid on the wire (see header note) + DATA = 1; // a publication + AUTH = 2; // an empty chunk: claims the stream, says nothing, is + // never delivered + EOS = 3; // an empty chunk from the admin, at index 0: the channel + // is closed, attributably and for good + ROSTER = 4; // from the admin: the full publisher set as of this + // index (not a delta); payload = Roster } -// --------------------------------------------------------------------------- -// 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 || challenge || index) -// -// travelling as Broadcast frames with their full 32-byte id, on the admin's -// publisher or pending stream, 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 and the challenge of the stream it was published on, so -// the id is verifiable without an out-of-band hint and the message is as fresh as -// a publication -- and a service SOC is told from a publication by that id alone: -// it never begins with a stream's challenge. 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 = 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 ROSTER chunk. +message Roster { + repeated bytes publishers = 1; // 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. } -// The payload of a service SOC. -message ServiceMessage { - ServiceKind kind = 1; - uint64 index = 2; // this update's index on the service feed - bytes challenge = 3; // the challenge of the admin's stream, as in the Ack - repeated bytes publishers = 4; // 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. +// Every frame after the handshake: publisher -> broker a publication, a claim or, +// from the admin, a service message; broker -> peer a delivery of the same frame. +// The chunk is an ordinary single-owner chunk, travelling as its chunk data with +// its full id: +// soc = id (32) || signature (65) || span (8, LE) || payload (<= 4096) +// Under explicit authorship and under ALL the frame carries what that id was +// derived from (SWIP-74): +// prefix = (empty) kind == DATA +// = "bps-service:v1" || kind any other kind, kind as one byte +// topic_s = keccak256(prefix || topic || challenge) +// id = keccak256(topic_s || index) index as a uint64 big-endian +// and the receiver derives the id, requires the chunk's to equal it, forms the +// address the chunk must have from the id and the owner it knows (the stream's +// claimed or declared address; at a subscriber, the owner it recovers and admits) +// and validates the chunk against it with the ordinary SOC code. For EOS and +// ROSTER the owner is the admin, in every configuration; an AUTH is held to the +// stream's declared address and is never delivered. Each kind is a +// feed of its own with its own index sequence; under explicit authorship and ALL a +// publisher's DATA indices increase and are never reused, and every receiver keeps +// a cursor per publisher. Under implicit authorship there is +// no challenge: the chunks are the binding's own, `challenge` and `index` are +// unset, and the chunk's id is whatever the binding's SOC shape says. +message Broadcast { + bytes soc = 1; + Kind kind = 2; + bytes challenge = 3; // 32 bytes: the challenge of the stream the chunk was + // signed for + uint64 index = 4; // the chunk's index on its feed } // 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 7578fd0a..0c8dc687 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 14, 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: @@ -26,8 +26,8 @@ protobuf: assets/swip-60/bps.proto (revision 13, derived from SWIP-74's block). 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 and a per-stream challenge that - salts every publication, the first of which is the claim. This - SWIP adds cohort parameters, the admin's service feed and the Bee API on top of that + salts every chunk, the first valid one of which is the claim. This + SWIP adds cohort parameters, the admin's service kinds 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). @@ -76,7 +76,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 — every stream is admitted silent, receiving nothing, and is disconnected unless its first frame under the stream's challenge recovers to the admin or a rostered address within the claim deadline | +| `closed` | bool, unset = open | set: no audience — every stream is admitted silent, receiving nothing but a roster that names its address, and is disconnected unless its first valid frame, from the admin's 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 @@ -112,8 +112,9 @@ role of its own: it is simply an admin that publishes nothing but service messag 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 +- **`MNEMONIC`** — the topic constrains nothing: it names the cohort and no more. Under + implicit authorship any SOC from any owner qualifies (dedup on chunk address); under + `ALL`, any owner's session-feed chunk does. 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 @@ -121,7 +122,8 @@ Binding semantics (dedup rule in parentheses): under an application-level requirement: payloads are distinct, i.e. the application 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. + legitimacy is roster membership (below), and dedup is the per-publisher cursor (below); + the wrapped-CAC rule is implicit authorship's. - **`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 @@ -146,12 +148,18 @@ SOC** the binding fixes: | `FEED_TOPIC` | feed | **many** | any owner on the feed's id `keccak256(topic ‖ index)` — a graffiti feed | | `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 beyond carrying the stream's challenge in its first 24 bytes: its last 8 -bytes are **unconstrained**, and publishers MAY use them 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 and under `ALL` every publication is, whatever the binding, a +chunk of the publisher's **session feed** (SWIP-74): its id is +`keccak256(keccak256(topic ‖ challenge) ‖ index)`, with the challenge and the index in +the frame. The index does the same work for every binding: it increases, a publisher +MUST NOT reuse one across sessions, and the broker and every subscriber keep a **cursor +per publisher** — per `(topic, owner)`, never per stream — and treat a lower index as a +retransmit **(?)**. That, and not the chunk address — which the challenge makes +different on every stream — is the dedup rule under explicit authorship and `ALL`, and +it is what keeps an earlier session's messages from being delivered again as new. The +binding's own dedup rule and SOC shape apply under implicit authorship. 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 @@ -174,26 +182,34 @@ exception is the **end-of-stream** service message, by which an admin ends its o deliberately and *attributably* (below), and which is what distinguishes "over" from "the broker stopped relaying". -### The service feed: the admin's control plane +### The service feeds: 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: +Everything the admin says about the cohort — who may write to it, and that it is over — +travels as SOCs on feeds the admin owns, one per **kind** (SWIP-74, *The session feed*): ``` -owner = admin id = keccak256("bps-service:v1" ‖ topic ‖ challenge ‖ index) +owner = admin topic_s = keccak256("bps-service:v1" ‖ kind ‖ topic ‖ challenge) + id = keccak256(topic_s ‖ index) ``` -| index | message | carries | +| kind | index | carries | |---|---|---| -| `n` | **roster** | the full publisher set as of version `n` | -| last | **end-of-stream** | the cohort is closed by its admin | - -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 and the challenge of the stream it was -published on in its payload, so its id is verifiable without an out-of-band hint — and -it is as fresh as any publication: signed for one stream, the admin's, whose first frame -it may be. Three properties follow, and each of them is the point: +| `ROSTER` | `n`, sequential from 0 | the full publisher set as of version `n` (payload: `Roster`) | +| `EOS` | 0 | nothing: the channel is closed by its admin, for good | + +They are `Broadcast` frames like any other: the chunk an ordinary SOC with its full id, +the frame naming the kind, the challenge of the admin's stream and the index, from which +every receiver derives the id. The kind is not taken on trust — it is in the id's +preimage, so a roster cannot be passed off as an end of stream, nor either as a +publication — and each kind counts its own indices, so that a gap in the rosters is a +missing roster and nothing else **(?)** — the alternative being one service feed under +the bare prefix `"bps-service:v1"`, with one index sequence for all the admin's kinds +and the kind told some other way. A cohort whose admin has published no roster has the +admin as its only publisher. An admin MUST NOT reuse a roster index across sessions. +An **`EOS` ends the channel, not a session**: a subscriber compares a frame's challenge +with nothing, so the end of any session is as good to it as today's, and an admin that +means to publish again takes another topic **(?)**. +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` @@ -211,53 +227,48 @@ it may be. 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` is a status and a challenge, and the roster is the first delivery.** On every newly -attached stream that is not pending — declaring neither the admin's nor a rostered -`addr` — and not silent under `closed`, the broker -delivers the **latest service SOC** as the first `Broadcast` before any other **(?)**; a +**`Ack` is a status and a challenge, and the roster is the first delivery.** The +**latest `ROSTER`** is the first `Broadcast` on a stream at the moment it enters a +fan-out set — at attach for a spectator, at upgrade for a pending or a silent stream — +before any other **(?)**; and to a pending or silent stream whose `addr` it names it is +delivered at once, its one delivery before the claim, because that is the peer's cue: +silence means it is not named here. 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 +single message, and a cohort with no roster delivers nothing first — the admin alone may write. -#### The claim: the first frame under the stream's challenge - -The role of a stream under explicit authorship is settled by its **first frame under its -challenge**, exactly as in SWIP-74 (*Handshake*). With `Ack{OK}` the broker sends every -stream a **challenge**: exactly 24 bytes drawn at random for that stream, never all -zero, held for its life, never persisted and never reused. For the life of the stream -every publication on it carries the challenge in the clear as the first 24 bytes of its -chunk's `id` slot — under `FEED_TOPIC` the slot is `challenge ‖ index` and the signed id -is `keccak256(keccak256(topic ‖ challenge) ‖ index)`, the session feed of SWIP-74; under -the other bindings the slot is `challenge ‖ 8 bytes of the publisher's choosing` (a -sequence number, or zero) and is the signed id itself — and the admin's service SOCs -carry it in their payload, under their full id (*Wire protocol*). The broker requires -the slot to begin with its own challenge for that stream, or the payload to name it, -before it looks at a signature; a subscriber reconstructs the id from the slot and -verifies the signature and the owner as for any chunk, and does not need the challenge. -A chunk signed under the challenge is possible only for the key of the address it -recovers to, and only after the `Ack` — so on a stream that has not yet published, a -frame under the stream's challenge that validates as a single-owner chunk at the address -formed from the id and the stream's declared `addr` proves the key and the session at -once. That frame is the claim: the stream **upgrades** to a publisher stream, and the -frame is delivered like any publication or service message. There is **no reply**: the -publisher sends its first frame and its next back to back, and learns the outcome from -whether the stream survives. - -Which streams may claim is decided before any cryptography, by the declared `addr` -against `admin` and the **current roster**: a stream whose `addr` is the admin's or -currently rostered is **pending** — admitted outside the fan-out bound, receiving -nothing (SWIP-74, *Resource bounds*), until its first frame under its challenge upgrades -it or the claim deadline disconnects it, with a stream in a `closed` cohort likewise -silent; a stream whose `addr` is neither is a **spectator** within the bound, and every -frame it sends is dropped unverified and counted, not a violation — the peer may not have -seen the roster that names it, or a roster may be on its way — a broker MAY reset a -stream whose rate of such drops exceeds its policy, and the stream claims with its next -frame once a roster names it. A frame on a pending stream whose slot does not begin -with the stream's challenge is dropped and counted (`wrong_challenge`), as on a publisher -stream; one under the challenge that does not validate is a violation. A seat that -wants to be present before it plays has nothing contentless to send and claims with its -first update — or with a publisher-signed, undelivered service kind this SWIP does not -yet define **(?)**. +#### The claim: the first valid frame + +The role of a stream under explicit authorship is settled by its **first valid frame**, +exactly as in SWIP-74 (*Handshake*). With `Ack{OK}` the broker sends every stream a +**challenge**: 32 bytes drawn at random for that stream, held for its life, never +persisted and never reused. For the life of the stream every chunk sent on it is signed +under an id salted with that challenge, and its frame names the kind, the challenge and +the index the id was derived from. A chunk under the challenge is possible only for the +key of the address it validates for, and only after the `Ack` — so the first frame on a +stream whose `challenge` is the stream's and whose chunk has the derived id and validates +at the address formed from that id and the stream's declared `addr` proves the key and +the session at once. That frame is the claim: the stream **upgrades** to a publisher +stream. It can be a publication, a service message from the admin, or an **`AUTH`** — the empty +chunk of SWIP-74, for a seat that wants to be present before it plays and for a +moderator that never publishes. There is **no reply**: the publisher sends its first +frame and its next back to back, and learns the outcome from whether the stream +survives. + +Which streams may claim follows from the declared `addr` against `admin` and the +**current roster**. A stream whose `addr` is the admin's or currently rostered is +**pending** — admitted outside the fan-out bound, receiving nothing but the roster that +names it (SWIP-74, *Resource bounds*), until its first valid frame upgrades it or the +claim deadline disconnects it. +A stream whose `addr` is neither is a **spectator**: within the bound, read-only, and +with no chance to publish — any frame it sends is a protocol violation, as from a +subscriber stream in SWIP-74. When a roster the broker has accepted names a spectator's +address, that stream may claim. A peer other than the admin sends nothing on a stream +before the broker has delivered it, there, a roster naming its address — which the +broker does even on a pending or silent stream — so a conforming peer never publishes +ahead of the broker's roster. A chunk that does not validate +is a violation on any stream, and the stream is reset: one failed validation is all a +connection can cost the broker. What the 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 @@ -265,26 +276,27 @@ fan-out bound and its queue policy. A static signature would have been replayabl replayed one would have bought all of that; so would a claim signed over a challenge the broker *derived* for the address rather than drew for the stream — whoever had captured it could present it again once the publisher dropped. A challenge that exists -on one stream is answered on that stream or nowhere, and because every publication and -every service message answers it, none of them can be presented on another stream +on one stream is answered on that stream or nowhere, and because every chunk answers +it, none of them can be presented on another stream either, at this broker or any other. What the challenge does *not* protect is history: replaying the admin's signed updates of the channel's own feed to a late viewer is catching it up, not deceiving it (SWIP-74, *Security considerations*). Under `ALL` there is **no claim** and the challenge still salts: everybody attached may publish, so a stream that declares an address is a publisher stream from its `Join`, and -every publication is held to that address — the SOC's address must hash to the declared -owner and its signature recover to it — and must begin with the stream's challenge, so -that a participant's captured messages cannot be replayed into the chat under its name +every publication is held to that address — a chunk of the session feed its stream's +challenge gives, validating at the address formed from the id and the declared owner — +so that a participant's captured messages cannot be replayed into the chat under its name from another stream. Under **implicit authorship** there is no claim and no salt: the chunks are the binding's own — a live MIC is the owner's storage chunks as they are -published — so they carry their own id, replay is what a store does, and a stream that +published — so they carry their own id, the frame's `challenge` and `index` are unset, +replay is what a store does, and a stream that declares an address is a publisher stream from its `Join` whose every publication must fit the binding's shape, with its owner 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 first frame under the challenge settles the role +### The first frame settles the cohort; the first valid `Broadcast` settles the role A peer's cohort is fixed by its **first frame**, `Join` — the only handshake frame there is — carrying the full `CohortSpec` and the address it publishes as, `addr`, and nothing @@ -295,34 +307,37 @@ costs the broker a map entry until the inactivity deadline reclaims it. Cohorts 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, challenge}` — the challenge drawn for this stream — or `FULL`, or `REJECTED` for a spec value outside this -SWIP. +SWIP or an `addr` that is not 20 bytes. Then the stream's role, from its declared `addr` against `admin` and the **current -roster**, and from its first frame under its challenge: +roster**, and from its first frame: | `addr`, and first frame | `closed` unset | `closed` set | |---|---|---| -| the admin's or rostered; none yet | a **pending stream**: outside the fan-out bound, receiving nothing, until it claims or the claim deadline disconnects it | the same | -| the admin's or rostered; under the stream's challenge, validating at the address formed from the id and `addr` | the stream is a **publisher stream**, and the frame is delivered | the same | -| the admin's or rostered; not under the stream's challenge | dropped and counted (`wrong_challenge`); pending still | the same | -| the admin's or rostered; under the challenge but not valid | violation: the stream is reset, the peer blocklisted per policy | the same | -| neither | a **spectator stream**, read-only, within the bound; every frame it sends is dropped unverified and counted; it claims with its next frame once a roster names it | a **silent stream**: attached, receiving nothing, until a roster names it and its next frame upgrades it, or the claim deadline disconnects it | +| the admin's or rostered; none yet | a **pending stream**: outside the fan-out bound, receiving nothing but, if rostered, the roster that names it, until it claims or the claim deadline disconnects it | the same | +| the admin's or rostered; a valid frame under the stream's challenge | the stream is a **publisher stream**; a publication or a service message is delivered, an `AUTH` is not | the same | +| the admin's or rostered; a frame under another challenge | dropped and counted (`wrong_challenge`), the broker MAY reset the stream; pending still | the same | +| any; a chunk that does not validate | violation: the stream is reset, the peer blocklisted per policy | the same | +| neither; none | a **spectator stream**, read-only, within the bound; it may claim once a roster names its address | a **silent stream**: attached, receiving nothing, until a roster names its address and it claims, or the claim deadline disconnects it | +| neither; any frame | violation: the stream is reset, the peer blocklisted per policy | the same | 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 the first frame -under a stream's challenge is signed for that stream only, by the key the roster names — +who it fails to prove it is — and it is enforceable precisely because the first valid +frame on a stream is signed for that stream only, by the key the roster names — or by whatever that key hands its challenge to, which is that key's business **(?)**. Everywhere else -`REJECTED` means the *spec* is unacceptable — a value outside this SWIP — and `FULL` means +`REJECTED` means the *`Join`* is unacceptable — a spec value outside this SWIP, or a +malformed `addr` — 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 publishes: on its current stream, -once it sees itself in the roster it is delivered, or on a new one. +changes. A **grant** takes effect when the granted peer claims — with an `AUTH` or its +first publication — on its current stream, once it sees itself in the roster it is +delivered, or on a new one. A **revocation** has two phases, and the boundary between them is the moment the reduced roster reaches subscribers: @@ -332,8 +347,11 @@ roster reaches subscribers: 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. + every other subscriber, on the same feed. Nothing on this wire tells the broker when + the peer has read it, so the boundary is fixed by the broker: it enqueues the reduced + roster on the revoked stream first, and tolerates frames from it for a grace period + after the roster has been written there (RECOMMENDED 5 s **(?)**). Publishing after + that 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 @@ -341,7 +359,7 @@ publishing the reduced roster would be punishing a peer for a rule it had not be 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 for a violation: the stream is reset and the peer -blocklisted per policy, as for a first frame that fails its claim. +blocklisted per policy, as for a chunk that does not validate. 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 @@ -354,7 +372,7 @@ disconnection they cannot attribute. Enforces its own capacity. **At capacity it MUST answer `Join` with a refusal** (`FULL`) — except that, as in SWIP-74, it admits **a `Join` declaring the admin's address, or a rostered one, outside the fan-out bound as a pending stream**: attached, - receiving nothing, disconnected if it has not claimed within the claim deadline, and + receiving nothing but the roster that names it, disconnected if it has not claimed within the claim deadline, and bounded per peer connection and cohort; 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 @@ -393,19 +411,19 @@ 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, challenge) — 24 random bytes for this stream - PN->>B: Broadcast(first update: id slot = challenge ‖ index, signed by addr) — the claim, no reply + B-->>PN: Ack(OK, challenge) — 32 random bytes for this stream + PN->>B: Broadcast(AUTH or first update: kind, challenge, index, soc signed by addr) — the claim, no reply SN->>B: Join(CohortSpec, addr) B-->>SN: Ack(OK, challenge) 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: 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) + PN->>B: Broadcast(DATA, challenge, index, soc) + B->>B: validate: publisher stream, kind, challenge is the stream's, cursor,
chunk id == id derived from the frame, SOC valid at keccak256(id ‖ addr) - par fan-out to every stream of the cohort not bound to the publishing identity - B->>SN: Broadcast(soc) — every frame self-contained + par fan-out to every stream in a fan-out set not bound to the publishing identity + B->>SN: Broadcast(DATA, challenge, index, soc) — the accepted frame, unchanged SN->>SN: mux: one p2p stream → N WS sessions SN->>SD: WS: payload end @@ -423,74 +441,83 @@ Messages are defined in [bps.proto](assets/swip-60/bps.proto). Framing notes: protobuf-over-libp2p as bee protocols elsewhere. The first and only handshake frame on a fresh stream is **`Join`**, carrying the full `CohortSpec` and the address the stream publishes as, nothing else; the broker answers with - `Ack{status, challenge}`, and delivers the latest service SOC as the stream's first + `Ack{status, challenge}`, and delivers the latest `ROSTER` as the stream's first `Broadcast`, so the joiner verifies the roster against the admin rather than the broker. - The three frames — `Join`, `Ack`, `Broadcast` — and the type they carry, `CohortSpec`, - are SWIP-74's; this SWIP adds fields, values and the service message a chunk's payload - may carry, never frames. There is no envelope: what a frame is follows from the - stream's direction and role — a pending stream sends its claim, its first frame under - its challenge, a spectator stream sends nothing (what it sends is dropped unverified, - not punished: it claims once a roster names it), a publisher stream sends publications - and, if it is the admin's, service messages. + The three frames — `Join`, `Ack`, `Broadcast` — and the types they carry, `CohortSpec` + and `Kind`, are SWIP-74's; this SWIP adds fields, values and the `Roster` a `ROSTER` + chunk carries, never frames. There is no envelope: what a frame is follows from the + stream's direction and role, and what a chunk is from the frame's kind — a pending + stream sends its claim, its first valid frame; a spectator stream sends nothing, and + may claim once a roster names its address; a publisher stream sends publications and, + if it is the admin's, service messages. - **`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 data frame carries the - 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 — + and role typing, and match bee's protocol idiom. Every frame carries the chunk data + and what its id was derived from; at the broker it is checked against the stream's + challenge and an address formed from what the stream declared or claimed, and a + delivery is self-contained: a subscriber needs nothing of the stream to verify it. +- Every `Broadcast` carries the **chunk data** — an ordinary SOC with its full id — and + the kind, the challenge and the index its id was derived from (SWIP-74), and no + address. Every receiver derives the id from the frame and requires the chunk's to + equal it. **At the + broker** the receiver then forms the address from that 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 + authorship, where there is nothing to derive — 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 + the chunk's address (for `swarm-soc-fields`, and for dedup under implicit authorship), and accept iff that owner is admissible — the admin or a currently rostered address under explicit authorship, any address under `ALL` and `MNEMONIC` and under implicit `FEED_TOPIC` (attribution, not restriction: the accepted trade-off), the owner the binding's shape fixes under implicit `OWNER` and `ANCHOR`, any owner meeting the PO constraint under implicit - `SOC_ID`. There is no + `SOC_ID`. For `EOS` and `ROSTER` the owner is not recovered but forced: + the chunk MUST validate at `keccak256(id ‖ admin)`, in every configuration, and a + service chunk by any other owner is invalid; an `AUTH` is held, at the broker, to the + stream's declared address, and is never delivered. 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 + stream in a fan-out set 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 by its - first frame under its challenge, or declaring an address under `ALL` or implicit authorship — its id - slot begins with the stream's challenge (except under implicit authorship), and the - chunk validates as a SOC at the address the broker forms from the id and the stream's - address (under implicit authorship, from the binding's SOC shape), the PO constraint - holding where applicable. A slot that does not begin with the stream's challenge is - dropped and counted, not a violation (SWIP-74: `wrong_challenge`); invalid otherwise - ⇒ 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 pending stream is its claim: not under the stream's - challenge, it is dropped and counted (`wrong_challenge`); under it and not valid at the - address formed from its id and the declared `addr`, it is a protocol violation — - dropped, the stream reset, the peer blocklisted (SWIP-74). A frame on a spectator - stream — one whose `addr` is neither the admin's nor rostered — is dropped unverified - and counted; a broker MAY reset such a stream on rate. -- **Service messages** ride the same frame and are recognised before the content path, - by id and kind, in this order: a `Broadcast` on the admin's publisher or pending stream - whose payload decodes as a `ServiceMessage` of a defined kind, whose `payload.challenge` - is the stream's challenge and whose id slot equals - `keccak256("bps-service:v1" ‖ topic ‖ payload.challenge ‖ payload.index)` is a service - SOC — on a pending stream its claim — and takes the service path: it is accepted iff - it validates as a SOC under that id with owner `admin` and `payload.index` exceeds the - service feed's cursor (initially absent: index 0 is accepted); one that validates but - does not exceed the cursor is a retransmit, dropped and counted as one; one that does - not validate is invalid. Any other frame takes the publication path above. A service - SOC is told from a publication by its id slot alone — a full id, which never begins - with a stream's challenge — and a subscriber recomputes that id from the payload it - carries. That is why a SWIP-74 broker drops a service SOC as `wrong_challenge` rather - than punishing it. +- Broker validation on a `Broadcast`, in SWIP-74's order: it arrived on a publisher or + pending stream — one claimed or claimable by its `addr`, or declaring an address under + `ALL` or implicit authorship — and not on a spectator stream, from which any frame is + a violation; its `kind` is one the broker defines — otherwise it is dropped and counted + (`unknown_kind`), not a violation; its `challenge` is the stream's (except under + implicit authorship) — otherwise it is dropped and counted (`wrong_challenge`) and the + broker MAY reset the stream; it is not a **duplicate** per the dedup rule of its kind + and binding — otherwise it is dropped and counted as a retransmit, never as invalid: + an admin reconnecting after a reset legitimately resends (SWIP-74), and a broker MAY + reset a stream whose retransmit rate exceeds its policy; and the chunk has the id + derived from the frame and validates as a SOC at the address the broker forms from + that id and the stream's address (under implicit authorship, from the binding's SOC + shape, the PO constraint holding where applicable). A chunk that does not validate is + a protocol violation — dropped, the stream reset, the peer blocklisted (SWIP-74) — so + the signature check is paid at most once in vain per connection. On a pending stream + the duplicate check comes after validation, as in SWIP-74: a valid frame upgrades the + stream even if it is then dropped as a retransmit. +- **Service messages** are kinds, not payloads: an `EOS` or `ROSTER` frame is accepted + on the admin's publisher or pending stream only — on a pending stream it is the claim + — and from any other stream it is a violation. A `ROSTER` is accepted iff its `index` + is at least the roster cursor — the lowest roster index accepted next, initially 0, + set past every accepted one — and its payload decodes as a `Roster`; one below the + cursor is a retransmit, dropped and counted as one. The chunk is validated first: one that validates but whose payload does not + decode as a `Roster` of 20-byte entries is dropped and counted (`invalid_roster`), + claims a pending stream like any valid frame, and moves no cursor; a subscriber drops + it likewise. An `EOS` is accepted at index 0 only, and its chunk, like an `AUTH`'s, + has span 0 and no payload — anything else is a violation. On accepting an `EOS` the + broker delivers it to every stream in a fan-out set and then reclaims the cohort as at + the inactivity deadline; a subscriber that accepts one treats the channel as ended. + An `AUTH` is accepted on + any stream that may claim, upgrades it if it is pending, and is never delivered. A + SWIP-74 broker, which knows `DATA` and `AUTH` only, drops the admin's kinds as + `unknown_kind` rather than punishing them. - **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 @@ -498,11 +525,13 @@ Messages are defined in [bps.proto](assets/swip-60/bps.proto). Framing notes: 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 every accepted - update, never back — and needs no window for it; the other bindings dedup on chunk - address within the bounded window, and the challenge in every id keeps a message of - one session out of every other. A publisher never reuses an index across sessions + explicit authorship and under `ALL` the broker keeps SWIP-74's **cursor**, one per + publisher address per cohort — never per stream or per session feed; with the admin + as only publisher that is SWIP-74's one cursor per cohort — the lowest index it + accepts next, set forward by every accepted update, never back; the table of cursors + is bounded like any dedup state, and an evicted publisher's restarts. Under implicit + authorship the bindings dedup on chunk address (`ANCHOR`: on the wrapped CAC) within + the bounded window. A publisher never reuses an index across sessions (SWIP-74). What multihop's dual paths do to this is [SWIP-61](https://github.com/ethersphere/SWIPs/pull/105)'s business. @@ -527,10 +556,10 @@ 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` | `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 dApp signs every update under it client-side as it signs any SOC — 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 publish 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` | `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 dApp signs every chunk under it client-side as it signs any SOC — read–write from then on iff the address is the admin's or currently rostered, or the cohort is `ALL`; or the cohort is implicit, where nothing is salted and the dApp signs the binding's own chunks; a later roster naming the address is the cue to claim **(?)**. Absent, a spectator session on the node's shared subscriber stream, whose `Join` declares the node's own address **(?)**. 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, carrying the +(`ROSTER` or `EOS`) as the next chunk on its kind's feed, signed under the challenge of the admin's stream. 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. @@ -554,19 +583,17 @@ 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 under explicit authorship the prefix is -`challenge ‖ index`, the signed id being the session feed's -`keccak256(keccak256(topic ‖ challenge) ‖ index)` (SWIP-74); under implicit authorship the bare -index, the signed id being `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 protocol work beyond the -challenge but is still signed over, so the frame is prefixed with `challenge ‖` the 8 -bytes the dApp chose — its sequence number, or zero. The node assembles the SOC, -validates it exactly as a broker would, and +signed client-side (bee-js). Under explicit authorship and under `ALL` the frame is +prefixed with the kind and the index, the signed id being the session feed's +`keccak256(keccak256(topic ‖ challenge) ‖ index)` (SWIP-74) — the feed index under +`FEED_TOPIC`, the dApp's increasing sequence number under the other bindings; under implicit +authorship with a feed binding the prefix is the bare index, the signed id being +`keccak256(topic ‖ index)` (self-indexed feeds, +[SWIP-65](https://github.com/ethersphere/SWIPs/pull/106)). The node assembles the SOC +and the `Broadcast` around it, validates it exactly as a broker would, and publishes. There is no separate claim to sign: the node passes the session the -challenge, the dApp prefixes it to the id of every update, and the first update is the -claim **(?)**. +challenge, the dApp signs every chunk under the id it salts, and the first — an update, +or an empty `AUTH` — is the claim **(?)**. 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. @@ -591,8 +618,9 @@ Seats B–D are not named in this URL and never appear in a cohort parameter: A 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 its first publication under the challenge issued for its stream; because the cohort is -`closed`, a seat receives nothing until that publication 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) +`closed`, a seat receives nothing but the roster that names it until its first valid +frame — a publication or an `AUTH` — validates for its rostered address, and is +disconnected if that never comes. 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 @@ -617,7 +645,7 @@ publisher by its first publication, accepted because it is signed under the stre challenge by an address on the roster the others 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 the first frame on a stream is signed under a challenge that +enforceable because the first valid frame on a stream is signed under a challenge that exists on that stream only. 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 @@ -643,10 +671,11 @@ 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 +SWIP-74 peer is a conformant peer of it. The spec has the shape of a spectator-jam's — admin set, `publishers` and `closed` +unset: 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 +streamer ends it with an `EOS` 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. @@ -658,13 +687,13 @@ publishers: ALL history: false No roster, no claim: 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 — and must begin with the stream's -challenge, so that nothing said in one session can be replayed into another under the -speaker's name. The topic +hash and signature, message by message, never at join — and must be a chunk of the +session feed the stream's challenge gives, at an index above the speaker's last, so that +nothing said in one session can be replayed into another under the speaker's name. 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 +it cannot be, since everyone may write — but it still owns the `EOS` 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 @@ -678,7 +707,7 @@ 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, +is no admin, so no service feeds, 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 @@ -760,25 +789,37 @@ verifiable signed chunks — not to reimplement a mesh. **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 stream is claimed by a publication signed under a +`admin` is a public address; its stream is claimed by a chunk signed under a challenge that exists on that stream only, 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, and the session takes it again.** Every -publication under explicit authorship or `ALL` is signed under the challenge the broker -drew for the stream it travels on; under explicit authorship the first frame under it is -the claim, under `ALL` there is none. A third party cannot -obtain anything it could use: a captured publication — every subscriber has them — is a -valid chunk from the right key whose id begins with a challenge no other stream has, and -is refused on that check before any signature is looked at, on this broker after the -stream is gone, on another broker, on another cohort; a publication for another -address does not validate at the address formed from the declared `addr`. A challenge +chunk under explicit authorship or `ALL` is signed under an id salted with the challenge +the broker drew for the stream it travels on; under explicit authorship the first valid +frame is the claim, under `ALL` there is none. A third party cannot +obtain anything it could use: a captured frame — every subscriber has them — names a +challenge no other stream has and is dropped on that check before any signature is +looked at, on this broker after the stream is gone, on another broker, on another +cohort; with the receiving stream's challenge substituted, or its kind or index +altered, the derived id is no longer the chunk's, and the sender is disconnected; a +chunk for another address does not validate at the address formed from the declared +`addr`. A challenge forwarded by a relay the publisher was pointed at turns the relay into a transparent hop for the publisher's own updates, which can withhold and not author. There is no credential that outlives a stream. SWIP-74's *Security considerations* has the case-by-case table. +**The head of a feed is the broker's word.** Sequential indices make a gap visible; they +do not show that the latest roster a subscriber holds is the latest there is. A +subscriber keeps its roster cursor per `(topic, admin)` across streams and sessions and +refuses a roster below it, so it cannot be rolled back; a first-time subscriber +has no cursor, and a broker colluding with a revoked publisher can present that +publisher as current to first-time joiners until a later roster is delivered. The id +binds the topic and the owner, not the whole spec: an admin SHOULD NOT run two specs on +one topic, since a broker that carried one can deliver its rosters and its end of stream +into the other. + **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 @@ -794,7 +835,7 @@ the handshake decides only who is carried as a publisher.** **Audience control exists in exactly one form, and it is not confidentiality.** `closed` keeps a joiner outside the roster silent and then disconnects it, and is -enforceable because the first frame on a stream is signed under a challenge that +enforceable because the first valid frame on a stream is signed under a challenge that exists on that stream only. It bounds *attendance at this broker* to holders of the admin's and rostered keys — and to whatever sits between such a key and the broker: a member pointed at a relay hands it its challenge, and the relay attends in its name, @@ -808,8 +849,9 @@ business. A jam is private because it encrypts, not because it refuses spectator **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 +tolerated, with no penalty and no teardown, because it is not misbehaving. Once the reduced roster has +been written to the revoked stream and the grace period has passed, 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 @@ -820,12 +862,15 @@ 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, with pending streams for the admin's and +and reclaims idle cohorts — SWIP-74's bounds, the outbound queue per subscriber stream +among them, with pending streams for the admin's and rostered addresses outside the fan-out bound, silent until they claim or the claim -deadline passes, and a bound on streams per peer connection per cohort — 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 +deadline passes, a bound on streams per peer connection per cohort, and — since in this +SWIP a publisher stream also receives — a bound on the publisher streams one address may +hold in a cohort (RECOMMENDED 2 **(?)**; a claim beyond it resets the oldest) — and, for +implicit cohorts, bounds its +dedup window (see the horizon note above); under explicit authorship and `ALL` every +publisher has a cursor instead, in a bounded table. 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) @@ -836,7 +881,7 @@ policy SWIPs over this protocol's events and actions, no new frames), bandwidth history delivery mechanism (bps-history), implicit-publisher event sourcing (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. +revocations are the `ROSTER` feed's business, and neither changes the cohort. ## Conformance (definition of done) @@ -844,10 +889,11 @@ An implementation is conformant when: 1. a broker enforces SWIP-74's bounds — streams per cohort, cohorts per broker, cohorts per peer connection, streams per peer connection per cohort, the inactivity deadline, - the claim deadline on pending streams — plus publisher legitimacy, per-binding + the outbound queue per subscriber stream, the claim deadline on pending streams — + and bounds the publisher streams one address may hold — plus publisher legitimacy, per-binding validation and dedup; -2. a subscriber re-verifies every message end-to-end — reconstructing the id from the - slot where the binding hashes it, recovering the owner, forming the chunk's address, +2. a subscriber re-verifies every message end-to-end — deriving the id from the frame's + kind, challenge and index, 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 @@ -858,34 +904,38 @@ An implementation is conformant when: receive — with all signing on the client side (the node holds no publisher keys); 6. the handshake is one `Join` carrying the full spec and the address the stream publishes as, and nothing else, creating the cohort or attaching to it, keyed by the - spec's canonical serialisation; `Ack` is a status and, on `OK`, a challenge of 24 + spec's canonical serialisation; `Ack` is a status and, on `OK`, a challenge of 32 bytes drawn at random for that stream, held for its life and never persisted or - reused; a newly attached stream that is not pending, and not silent under - `closed`, receives the latest service SOC as its first `Broadcast` **(?)**; + reused; the latest `ROSTER` is the first `Broadcast` on a stream when it enters a + fan-out set — at attach for a spectator, at upgrade otherwise — and is delivered + before that to a pending or silent stream whose `addr` it names **(?)**; 7. an absent `admin` is treated as implicit authorship — a stream that declares an address publishes from its `Join` with no claim and no salt, each message validated strictly - per the binding's SOC shape — and a present one authenticated by its first frame - under its stream's challenge and by its signature on every service message, both of - which MUST recover to it; -8. under explicit authorship every publication's id slot begins with the stream's - challenge — `challenge ‖ index` with the id `keccak256(keccak256(topic ‖ challenge) ‖ - index)` under `FEED_TOPIC`, `challenge ‖ 8 free bytes` as the id itself under the - other bindings — and a stream declaring the admin's or a rostered `addr` is pending, - outside the fan-out bound and receiving nothing, until its first frame under its - challenge, verified as SWIP-74 specifies at the address formed from the id and the - declared `addr`, upgrades it, no reply sent, or the claim deadline disconnects it; - a frame on a stream declaring any other `addr` is dropped unverified and counted; - under `ALL` there is no claim, every message begins with the stream's challenge and - is checked against the declared address; a `closed` cohort delivers nothing to a - stream before it claims — the only refusal for identity in the protocol; a service - SOC carries its full id `keccak256("bps-service:v1" ‖ topic ‖ challenge ‖ index)`, - the challenge and the index in its payload, and is accepted on the admin's publisher - or pending stream only; + per the binding's SOC shape — and a present one authenticated by its first valid + frame under its stream's challenge and by its signature on every service message, + both of which MUST validate for it; +8. under explicit authorship and under `ALL` every chunk is an ordinary SOC whose id is + the one SWIP-74 derives from the frame's kind, challenge and index — + `keccak256(keccak256(prefix ‖ topic ‖ challenge) ‖ index)`, the prefix empty for + `DATA` and `"bps-service:v1" ‖ kind` otherwise — for every binding; a stream declaring + the admin's or a rostered `addr` is pending, outside the fan-out bound and receiving + nothing but the roster that names it, until its first valid frame — a publication, an `AUTH`, or from the admin a + service message — upgrades it, no reply sent, or the claim deadline disconnects it; + any frame from a stream declaring another `addr` is a violation, until a roster the + broker has accepted names that address; a chunk that does not validate is a violation + on any stream; a frame under another challenge is dropped and counted; under `ALL` + there is no claim, and every publication is checked against the declared address + under the stream's challenge; a `closed` cohort delivers nothing to a stream before + it claims except the roster that names its `addr` — the only refusal for identity in the protocol; `EOS` and `ROSTER` are + accepted on the admin's publisher or pending stream only, each kind a feed of its own; 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. + publisher's frames are **dropped and tolerated** until the reduced roster has been + written to its stream and a grace period has passed, and its connection is broken + only if it publishes **after** that point; +10. a subscriber takes the roster from the admin's `ROSTER` feed, never from the broker, + holds every service chunk to the admin's address, keeps its roster cursor and its + cursor per publisher across streams and sessions, refuses what is below them, + and treats an index gap in the roster feed as a liveness fault. ## Backwards compatibility @@ -896,15 +946,14 @@ SWIP-74 broker refuses at the handshake every spec whose `binding` is not `FEED_ whose `admin` is absent, and ignores the fields it does not define (`publishers`, `history`, `closed`), serving such a spec as a live stream. It likewise cannot refuse 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 is dropped as `wrong_challenge` — its full id does not begin with the -stream's challenge — and a grantee, whose `addr` is not the admin's, is a subscriber +stream: the roster is dropped as `unknown_kind` — a kind SWIP-74 does not define — and a +grantee, whose `addr` is not the admin's, is a subscriber stream there, so its first publication is a violation that resets its stream; 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 `Broadcast` frame, into which its `Publish` folds, and on the per-stream challenge, which -its attachment nodes issue; self-contained frames mean a change of stream model needs no -format change either. +its attachment nodes issue. ## References From 2ad7b10e4c3ce03431df526038ec704e658f3b1c Mon Sep 17 00:00:00 2001 From: zelig Date: Sat, 10 Oct 2026 01:33:26 +0200 Subject: [PATCH 18/20] =?UTF-8?q?swip-60=20rev=2012:=20follows=20SWIP-74?= =?UTF-8?q?=20rev=209=20=E2=80=94=20principal,=20identity,=20bps/1.0.0,=20?= =?UTF-8?q?auth=20deadline;=20proto=20revision=2015?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- SWIPs/assets/swip-60/bps.proto | 36 +++++----- SWIPs/swip-60.md | 121 +++++++++++++++++---------------- 2 files changed, 80 insertions(+), 77 deletions(-) diff --git a/SWIPs/assets/swip-60/bps.proto b/SWIPs/assets/swip-60/bps.proto index c1c922db..a213a1f5 100644 --- a/SWIPs/assets/swip-60/bps.proto +++ b/SWIPs/assets/swip-60/bps.proto @@ -1,6 +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 15 (2026-10-10): field names principal and identity, protocol bps/1.0.0, +// auth deadline — as the implementation (bee #5626) settled them. // Revision 14 (2026-10-01), per Viktor — the chunk is an ordinary single-owner chunk // with its full id; the frame carries what the id was derived from (kind, challenge, // index) beside it; the challenge is 32 bytes; service messages are kinds, not @@ -78,24 +80,25 @@ enum PublisherRegime { message CohortSpec { bytes topic = 1; // 32 bytes, meaning per binding TopicBinding binding = 2; - bytes admin = 3; // 20-byte eth address: the cohort's authority - // and always a member of its publisher set. + bytes principal = 3; // 20-byte eth address: the admin -- 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 = 4; // ALL, or unset (see the enum) bool history = 5; // deliver matching chunks from the local store bool closed = 6; // no audience: every stream is admitted silent, - // receiving nothing but a roster naming its addr, + // receiving nothing but a roster naming its identity, // and is disconnected unless // its first valid frame, from the admin's or a - // rostered address, arrives within the claim + // rostered address, arrives within the auth // deadline. // 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 +// Stream establishment, libp2p protocol /swarm/bps/1.0.0/bps (name bps, version +// 1.0.0, stream bps) — one stream per // (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 // first valid Broadcast settles the role. @@ -106,16 +109,15 @@ message CohortSpec { // cursor, no credential: the stream's role follows from what it sends after the // Ack. message Join { - CohortSpec cohort = 1; - bytes addr = 2; // 20 bytes, required: the stream's identity -- under - // explicit authorship the address whose first valid - // frame claims the stream; the admin's or a rostered - // one makes the stream pending (outside the fan-out - // bound, receiving nothing but the roster that names it - // until it claims), any other - // a spectator, which may not publish; under ALL and - // under implicit authorship the one every publication - // is validated against, no claim + CohortSpec cohort = 1; + bytes identity = 2; // 20 bytes, required: under explicit authorship the + // address whose first valid frame claims the stream; + // the principal's or a rostered one makes the stream + // pending (outside the fan-out bound, receiving nothing + // but the roster that names it until it claims), any + // other a spectator, which may not publish; under ALL + // and under implicit authorship the one every + // publication is validated against, no claim } enum Status { @@ -125,13 +127,13 @@ enum Status { // connection); a singlehop broker refuses -- nothing // else REJECTED = 3; // a spec value outside this SWIP, or a malformed Join - // (addr not 20 bytes) + // (identity not 20 bytes) } // Broker -> peer, answering Join. A non-OK Ack ends the stream. The latest roster // is the first Broadcast on a stream when it enters a fan-out set -- at attach for // a spectator, at upgrade for a pending or silent stream -- and reaches a pending -// or silent stream before that only if it names the stream's addr. +// or silent stream before that only if it names the stream's identity. message Ack { Status status = 1; bytes challenge = 2; // iff OK: 32 bytes drawn at random for this stream -- the diff --git a/SWIPs/swip-60.md b/SWIPs/swip-60.md index 0c8dc687..70fed85e 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 15, 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: @@ -20,7 +20,7 @@ protobuf: assets/swip-60/bps.proto (revision 14, derived from SWIP-74's block). **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 +- **Dev line**: implement one libp2p protocol (`bps/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). @@ -74,9 +74,9 @@ 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 | +| `principal` | eth address | the admin: 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 — every stream is admitted silent, receiving nothing but a roster that names its address, and is disconnected unless its first valid frame, from the admin's or a rostered address, arrives within the claim deadline | +| `closed` | bool, unset = open | set: no audience — every stream is admitted silent, receiving nothing but a roster that names its address, and is disconnected unless its first valid frame, from the admin's or a rostered address, arrives within the auth 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 @@ -89,7 +89,7 @@ subscriber verifies it against the admin's key rather than against the broker's #### The five configurations -| configuration | `admin` | `publishers` | `closed` | who may author | +| configuration | `principal` | `publishers` | `closed` | who may author | |---|---|---|---|---| | **jam** | set | unset | true | admin + current grantees; nobody else attends | | **spectator-jam** | set | unset | unset | admin + current grantees, before an audience | @@ -212,7 +212,7 @@ means to publish again takes another topic **(?)**. 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` + spec in its `Join`, so a broker cannot serve a peer a cohort it did not name; `principal` is an address anyone can read, its stream is claimed by a frame signed under a challenge that exists on that stream only, and every message and every service message carries its signature. Nothing in the handshake @@ -230,7 +230,7 @@ Three properties follow, and each of them is the point: **`Ack` is a status and a challenge, and the roster is the first delivery.** The **latest `ROSTER`** is the first `Broadcast` on a stream at the moment it enters a fan-out set — at attach for a spectator, at upgrade for a pending or a silent stream — -before any other **(?)**; and to a pending or silent stream whose `addr` it names it is +before any other **(?)**; and to a pending or silent stream whose `identity` it names it is delivered at once, its one delivery before the claim, because that is the peer's cue: silence means it is not named here. A joiner learns who may write from the admin, not from the broker, before it has received a @@ -247,7 +247,7 @@ under an id salted with that challenge, and its frame names the kind, the challe the index the id was derived from. A chunk under the challenge is possible only for the key of the address it validates for, and only after the `Ack` — so the first frame on a stream whose `challenge` is the stream's and whose chunk has the derived id and validates -at the address formed from that id and the stream's declared `addr` proves the key and +at the address formed from that id and the stream's declared `identity` proves the key and the session at once. That frame is the claim: the stream **upgrades** to a publisher stream. It can be a publication, a service message from the admin, or an **`AUTH`** — the empty chunk of SWIP-74, for a seat that wants to be present before it plays and for a @@ -255,12 +255,12 @@ moderator that never publishes. There is **no reply**: the publisher sends its f frame and its next back to back, and learns the outcome from whether the stream survives. -Which streams may claim follows from the declared `addr` against `admin` and the -**current roster**. A stream whose `addr` is the admin's or currently rostered is +Which streams may claim follows from the declared `identity` against `principal` and the +**current roster**. A stream whose `identity` is the admin's or currently rostered is **pending** — admitted outside the fan-out bound, receiving nothing but the roster that names it (SWIP-74, *Resource bounds*), until its first valid frame upgrades it or the -claim deadline disconnects it. -A stream whose `addr` is neither is a **spectator**: within the bound, read-only, and +auth deadline disconnects it. +A stream whose `identity` is neither is a **spectator**: within the bound, read-only, and with no chance to publish — any frame it sends is a protocol violation, as from a subscriber stream in SWIP-74. When a roster the broker has accepted names a spectator's address, that stream may claim. A peer other than the admin sends nothing on a stream @@ -299,7 +299,7 @@ the borrowed name; a node may join a chat as several identities, one stream each ### The first frame settles the cohort; the first valid `Broadcast` settles the role A peer's cohort is fixed by its **first frame**, `Join` — the only handshake frame there -is — carrying the full `CohortSpec` and the address it publishes as, `addr`, and nothing +is — carrying the full `CohortSpec` and the address it publishes as, `identity`, and nothing else: no cursor, no credential. 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 @@ -307,18 +307,18 @@ costs the broker a map entry until the inactivity deadline reclaims it. Cohorts 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, challenge}` — the challenge drawn for this stream — or `FULL`, or `REJECTED` for a spec value outside this -SWIP or an `addr` that is not 20 bytes. +SWIP or an `identity` that is not 20 bytes. -Then the stream's role, from its declared `addr` against `admin` and the **current +Then the stream's role, from its declared `identity` against `principal` and the **current roster**, and from its first frame: -| `addr`, and first frame | `closed` unset | `closed` set | +| `identity`, and first frame | `closed` unset | `closed` set | |---|---|---| -| the admin's or rostered; none yet | a **pending stream**: outside the fan-out bound, receiving nothing but, if rostered, the roster that names it, until it claims or the claim deadline disconnects it | the same | +| the admin's or rostered; none yet | a **pending stream**: outside the fan-out bound, receiving nothing but, if rostered, the roster that names it, until it claims or the auth deadline disconnects it | the same | | the admin's or rostered; a valid frame under the stream's challenge | the stream is a **publisher stream**; a publication or a service message is delivered, an `AUTH` is not | the same | | the admin's or rostered; a frame under another challenge | dropped and counted (`wrong_challenge`), the broker MAY reset the stream; pending still | the same | | any; a chunk that does not validate | violation: the stream is reset, the peer blocklisted per policy | the same | -| neither; none | a **spectator stream**, read-only, within the bound; it may claim once a roster names its address | a **silent stream**: attached, receiving nothing, until a roster names its address and it claims, or the claim deadline disconnects it | +| neither; none | a **spectator stream**, read-only, within the bound; it may claim once a roster names its address | a **silent stream**: attached, receiving nothing, until a roster names its address and it claims, or the auth deadline disconnects it | | neither; any frame | violation: the stream is reset, the peer blocklisted per policy | the same | Under `ALL` and implicit authorship the rows do not arise for a stream that declared an @@ -329,7 +329,7 @@ frame on a stream is signed for that stream only, by the key the roster names or by whatever that key hands its challenge to, which is that key's business **(?)**. Everywhere else `REJECTED` means the *`Join`* is unacceptable — a spec value outside this SWIP, or a -malformed `addr` — and `FULL` means +malformed `identity` — and `FULL` means capacity, nothing more. #### Grant and revocation @@ -372,7 +372,7 @@ disconnection they cannot attribute. Enforces its own capacity. **At capacity it MUST answer `Join` with a refusal** (`FULL`) — except that, as in SWIP-74, it admits **a `Join` declaring the admin's address, or a rostered one, outside the fan-out bound as a pending stream**: attached, - receiving nothing but the roster that names it, disconnected if it has not claimed within the claim deadline, and + receiving nothing but the roster that names it, disconnected if it has not claimed within the auth deadline, and bounded per peer connection and cohort; 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 @@ -409,18 +409,18 @@ sequenceDiagram participant SN as subscriber's bee node
(WS bridge + mux) participant SD as subscriber dApp(s) - PN->>B: Join(CohortSpec, addr) + PN->>B: Join(CohortSpec, identity) 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, challenge) — 32 random bytes for this stream - PN->>B: Broadcast(AUTH or first update: kind, challenge, index, soc signed by addr) — the claim, no reply - SN->>B: Join(CohortSpec, addr) + PN->>B: Broadcast(AUTH or first update: kind, challenge, index, soc signed by identity) — the claim, no reply + SN->>B: Join(CohortSpec, identity) B-->>SN: Ack(OK, challenge) 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: Broadcast(DATA, challenge, index, soc) - B->>B: validate: publisher stream, kind, challenge is the stream's, cursor,
chunk id == id derived from the frame, SOC valid at keccak256(id ‖ addr) + B->>B: validate: publisher stream, kind, challenge is the stream's, cursor,
chunk id == id derived from the frame, SOC valid at keccak256(id ‖ identity) par fan-out to every stream in a fan-out set not bound to the publishing identity B->>SN: Broadcast(DATA, challenge, index, soc) — the accepted frame, unchanged @@ -437,7 +437,8 @@ 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, identity), +- Transport: libp2p protocol `/swarm/bps/1.0.0/bps` (name `bps`, version `1.0.0`, stream + `bps`), 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` and the address the stream publishes as, nothing else; the broker answers with @@ -477,7 +478,7 @@ Messages are defined in [bps.proto](assets/swip-60/bps.proto). Framing notes: not restriction: the accepted trade-off), the owner the binding's shape fixes under implicit `OWNER` and `ANCHOR`, any owner meeting the PO constraint under implicit `SOC_ID`. For `EOS` and `ROSTER` the owner is not recovered but forced: - the chunk MUST validate at `keccak256(id ‖ admin)`, in every configuration, and a + the chunk MUST validate at `keccak256(id ‖ principal)`, in every configuration, and a service chunk by any other owner is invalid; an `AUTH` is held, at the broker, to the stream's declared address, and is never delivered. There is no handshake/data frame split. Deliveries go to every @@ -486,7 +487,7 @@ Messages are defined in [bps.proto](assets/swip-60/bps.proto). Framing notes: - 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`, in SWIP-74's order: it arrived on a publisher or - pending stream — one claimed or claimable by its `addr`, or declaring an address under + pending stream — one claimed or claimable by its `identity`, or declaring an address under `ALL` or implicit authorship — and not on a spectator stream, from which any frame is a violation; its `kind` is one the broker defines — otherwise it is dropped and counted (`unknown_kind`), not a violation; its `challenge` is the stream's (except under @@ -548,20 +549,20 @@ and `/moc/subscribe/{id}` endpoints are the storage-fed counterparts of the `OWN 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 +**`GET /bps/{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` | `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 dApp signs every chunk under it client-side as it signs any SOC — read–write from then on iff the address is the admin's or currently rostered, or the cohort is `ALL`; or the cohort is implicit, where nothing is salted and the dApp signs the binding's own chunks; a later roster naming the address is the cue to claim **(?)**. Absent, a spectator session on the node's shared subscriber stream, whose `Join` declares the node's own address **(?)**. The node holds no publisher keys, and the same key works from any node | +| `binding`, `principal`, `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. `principal` omitted ⇒ implicit authorship. No publisher list here — it is not part of the spec | +| `identity` | `Join.identity` | 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 dApp signs every chunk under it client-side as it signs any SOC — read–write from then on iff the address is the admin's or currently rostered, or the cohort is `ALL`; or the cohort is implicit, where nothing is salted and the dApp signs the binding's own chunks; a later roster naming the address is the cue to claim **(?)**. Absent, a spectator session on the node's shared subscriber stream, whose `Join` declares the node's own address **(?)**. 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 +**`POST /bps/{topic}/service`** — the admin's control plane: submits a service message (`ROSTER` or `EOS`) as the next chunk on its kind's feed, signed under the challenge of the admin's stream. The SOC is signed -client-side by the admin key; the node relays it on the cohort whose `admin` that key is. +client-side by the admin key; the node relays it on the cohort whose `principal` that key is. Granting or revoking a publisher is one call here and touches no cohort parameter. Headers: @@ -578,7 +579,7 @@ Headers: 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, +**`GET /bps/`** — 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 @@ -600,32 +601,32 @@ verification against the `CohortSpec` the session supplied — the spec the node **Worked API calls — the jam cohort** (see Configurations below). Seat A joins declaring its address, and its first publication under the challenge it is handed recovers to -`admin` ⇒ read–write; the spec creates the cohort: +`principal` ⇒ read–write; the spec creates the cohort: ``` -wss://node:1633/pubsub/jam-tuesday?peer= - &binding=anchor&admin=0xA…&closed=true&addr=0xA… +wss://node:1633/bps/jam-tuesday?peer= + &binding=anchor&principal=0xA…&closed=true&identity=0xA… ``` -Seats B–D join with the same spec and their own `addr`: +Seats B–D join with the same spec and their own `identity`: ``` -wss://node:1633/pubsub/jam-tuesday?peer= - &binding=anchor&admin=0xA…&closed=true&addr=0xB… +wss://node:1633/bps/jam-tuesday?peer= + &binding=anchor&principal=0xA…&closed=true&identity=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 +with a `POST /bps/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 its first publication under the challenge issued for its stream; because the cohort is `closed`, a seat receives nothing but the roster that names it until its first valid frame — a publication or an `AUTH` — validates for its rostered address, and is -disconnected if that never comes. The join URL minus `addr` is the complete out-of-band invite (spec + broker) +disconnected if that never comes. The join URL minus `identity` 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), +`?binding=owner`, no `principal` and no `identity` (the first creates, the rest attach), topic = `keccak256(owner)`, read-only, `swarm-soc-fields: identifier,payload`. ### Configurations (worked examples) @@ -635,7 +636,7 @@ 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… +binding: ANCHOR (topic = mnemonic anchor) principal: 0xA… closed: true history: false ``` @@ -643,7 +644,7 @@ Seat A joins and claims its stream with its first frame — the `ROSTER` that gr and D will do — and each of them becomes a publisher by its first publication, accepted because it is signed under the stream's challenge by an address on the roster the others can verify against A's key. A fifth peer -receives nothing and is disconnected when its claim deadline +receives nothing and is disconnected when its auth deadline passes — this is the one configuration in which a peer is refused for who it is, and it is enforceable because the first valid frame on a stream is signed under a challenge that exists on that stream only. A may grant a fifth seat, or revoke one, without the cohort spec changing @@ -654,7 +655,7 @@ encrypts payloads. **Spectator-jam** — the same, opened to an audience. ``` -binding: ANCHOR admin: 0xA… +binding: ANCHOR principal: 0xA… history: false ``` @@ -666,7 +667,7 @@ are legitimate without trusting the broker. **Live-stream** — single publisher, open audience. ``` -binding: FEED_TOPIC (sequential index) admin: the streamer +binding: FEED_TOPIC (sequential index) principal: the streamer history: false ``` @@ -681,7 +682,7 @@ streamer ends it with an `EOS` service message, which is what distinguishes **Group-chat** — anyone attached may speak. ``` -binding: MNEMONIC (the topic is just the cohort's name) admin: 0xA… +binding: MNEMONIC (the topic is just the cohort's name) principal: 0xA… publishers: ALL history: false ``` @@ -702,7 +703,7 @@ speak. Where a cohort wants no authorship guarantees at all, see "why not gossip **Implicit** — no admin, no roster, no authority. ``` -binding: OWNER (topic = keccak256(owner)) admin: absent +binding: OWNER (topic = keccak256(owner)) principal: absent history: false ``` @@ -789,7 +790,7 @@ verifiable signed chunks — not to reimplement a mesh. **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 stream is claimed by a chunk signed under a +`principal` is a public address; its stream is claimed by a chunk signed under a challenge that exists on that stream only, 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. @@ -804,7 +805,7 @@ looked at, on this broker after the stream is gone, on another broker, on anothe cohort; with the receiving stream's challenge substituted, or its kind or index altered, the derived id is no longer the chunk's, and the sender is disconnected; a chunk for another address does not validate at the address formed from the declared -`addr`. A challenge +`identity`. A challenge forwarded by a relay the publisher was pointed at turns the relay into a transparent hop for the publisher's own updates, which can withhold and not author. There is no credential that outlives a stream. SWIP-74's *Security @@ -864,7 +865,7 @@ its per-cohort stream count (`FULL`), the number of cohorts it will create and t one peer connection may hold (any peer can make it allocate a cohort simply by joining), and reclaims idle cohorts — SWIP-74's bounds, the outbound queue per subscriber stream among them, with pending streams for the admin's and -rostered addresses outside the fan-out bound, silent until they claim or the claim +rostered addresses outside the fan-out bound, silent until they claim or the auth deadline passes, a bound on streams per peer connection per cohort, and — since in this SWIP a publisher stream also receives — a bound on the publisher streams one address may hold in a cohort (RECOMMENDED 2 **(?)**; a claim beyond it resets the oldest) — and, for @@ -889,7 +890,7 @@ An implementation is conformant when: 1. a broker enforces SWIP-74's bounds — streams per cohort, cohorts per broker, cohorts per peer connection, streams per peer connection per cohort, the inactivity deadline, - the outbound queue per subscriber stream, the claim deadline on pending streams — + the outbound queue per subscriber stream, the auth deadline on pending streams — and bounds the publisher streams one address may hold — plus publisher legitimacy, per-binding validation and dedup; 2. a subscriber re-verifies every message end-to-end — deriving the id from the frame's @@ -908,8 +909,8 @@ An implementation is conformant when: bytes drawn at random for that stream, held for its life and never persisted or reused; the latest `ROSTER` is the first `Broadcast` on a stream when it enters a fan-out set — at attach for a spectator, at upgrade otherwise — and is delivered - before that to a pending or silent stream whose `addr` it names **(?)**; -7. an absent `admin` is treated as implicit authorship — a stream that declares an address + before that to a pending or silent stream whose `identity` it names **(?)**; +7. an absent `principal` is treated as implicit authorship — a stream that declares an address publishes from its `Join` with no claim and no salt, each message validated strictly per the binding's SOC shape — and a present one authenticated by its first valid frame under its stream's challenge and by its signature on every service message, @@ -918,15 +919,15 @@ An implementation is conformant when: the one SWIP-74 derives from the frame's kind, challenge and index — `keccak256(keccak256(prefix ‖ topic ‖ challenge) ‖ index)`, the prefix empty for `DATA` and `"bps-service:v1" ‖ kind` otherwise — for every binding; a stream declaring - the admin's or a rostered `addr` is pending, outside the fan-out bound and receiving + the admin's or a rostered `identity` is pending, outside the fan-out bound and receiving nothing but the roster that names it, until its first valid frame — a publication, an `AUTH`, or from the admin a - service message — upgrades it, no reply sent, or the claim deadline disconnects it; - any frame from a stream declaring another `addr` is a violation, until a roster the + service message — upgrades it, no reply sent, or the auth deadline disconnects it; + any frame from a stream declaring another `identity` is a violation, until a roster the broker has accepted names that address; a chunk that does not validate is a violation on any stream; a frame under another challenge is dropped and counted; under `ALL` there is no claim, and every publication is checked against the declared address under the stream's challenge; a `closed` cohort delivers nothing to a stream before - it claims except the roster that names its `addr` — the only refusal for identity in the protocol; `EOS` and `ROSTER` are + it claims except the roster that names its `identity` — the only refusal for identity in the protocol; `EOS` and `ROSTER` are accepted on the admin's publisher or pending stream only, each kind a feed of its own; 9. an admin grants and revokes by publishing `ROSTER` service messages; a revoked publisher's frames are **dropped and tolerated** until the reduced roster has been @@ -943,11 +944,11 @@ 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 whose `binding` is not `FEED_TOPIC` or -whose `admin` is absent, and ignores the fields it does not define (`publishers`, +whose `principal` is absent, and ignores the fields it does not define (`publishers`, `history`, `closed`), serving such a spec as a live stream. It likewise cannot refuse 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 is dropped as `unknown_kind` — a kind SWIP-74 does not define — and a -grantee, whose `addr` is not the admin's, is a subscriber +grantee, whose `identity` is not the admin's, is a subscriber stream there, so its first publication is a violation that resets its stream; 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 — From 88b468541f9b1527e1877b9c323c226e33a24ba8 Mon Sep 17 00:00:00 2001 From: zelig Date: Sat, 10 Oct 2026 11:23:27 +0200 Subject: [PATCH 19/20] =?UTF-8?q?swip-60=20rev=2013,=20proto=20rev=2016:?= =?UTF-8?q?=20follow=20SWIP-74=20rev=2010=20=E2=80=94=20the=20AUTH=20is=20?= =?UTF-8?q?the=20only=20auth?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pending streams (the admin's or a rostered identity) send nothing but their AUTH; a spectator or silent stream a roster names authenticates with its AUTH; EOS and ROSTER are accepted on the admin's publisher stream only; a grant takes effect at the grantee's AUTH; an AUTH on a publisher stream, under ALL included, is a heartbeat; the revocation rule covers any frame. Worked examples, state table, API notes, security summary and backwards compatibility reworded accordingly; "claim" reads "auth" throughout. Co-Authored-By: Claude Fable 5.1 --- SWIPs/assets/swip-60/bps.proto | 45 ++++---- SWIPs/swip-60.md | 199 ++++++++++++++++++--------------- 2 files changed, 132 insertions(+), 112 deletions(-) diff --git a/SWIPs/assets/swip-60/bps.proto b/SWIPs/assets/swip-60/bps.proto index a213a1f5..bb74ac31 100644 --- a/SWIPs/assets/swip-60/bps.proto +++ b/SWIPs/assets/swip-60/bps.proto @@ -1,18 +1,22 @@ // Broadcast Pub/Sub (BPS) — protocol messages and types. // Spec: SWIP-60 (../../swip-60.md), extending the base wire of SWIP-74 (BPS-lite). // +// Revision 16 (2026-10-10): the AUTH — the empty chunk at index 0 of the session's +// AUTH feed — is the only way a stream authenticates; a pending stream sends nothing +// else; an AUTH on a publisher stream is a heartbeat; "claim" reads "auth" throughout. // Revision 15 (2026-10-10): field names principal and identity, protocol bps/1.0.0, // auth deadline — as the implementation (bee #5626) settled them. // Revision 14 (2026-10-01), per Viktor — the chunk is an ordinary single-owner chunk // with its full id; the frame carries what the id was derived from (kind, challenge, // index) beside it; the challenge is 32 bytes; service messages are kinds, not -// payloads; AUTH is the empty claim; a chunk that does not validate disconnects. +// payloads; AUTH is the empty auth; a chunk that does not validate disconnects. // // SWIP-74 fixes the base: three frames (Join, Ack, Broadcast) and the two types they // carry (CohortSpec, Kind with DATA and AUTH), for a single publisher over a feed at // one broker, one hop, with every chunk signed under an id salted by the challenge -// the broker issued for its stream, so that the first valid frame claims the -// publisher role. This file adds what the full singlehop protocol needs and changes +// the broker issued for its stream, and the stream authenticated by its AUTH, the +// empty chunk at index 0 of the session's AUTH feed. This file adds what the full +// singlehop protocol needs and changes // nothing SWIP-74 defines: // - CohortSpec gains `publishers` (one value, ALL), `history` and `closed`; // - Kind gains the admin's service kinds, EOS and ROSTER, and Roster is the @@ -56,7 +60,7 @@ enum TopicBinding { // never unattributable, since every message is SOC-signed. } -// One value. Set: anyone attached may publish (group chat) -- no claim; a stream +// One value. Set: anyone attached may publish (group chat) -- no auth; 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 @@ -89,9 +93,8 @@ message CohortSpec { bool closed = 6; // no audience: every stream is admitted silent, // receiving nothing but a roster naming its identity, // and is disconnected unless - // its first valid frame, from the admin's or a - // rostered address, arrives within the auth - // deadline. + // its AUTH, for the admin's or a rostered + // address, arrives within the auth deadline. // Unset = open, so that a SWIP-74 spec, which // never sets it, reads as an open cohort. } @@ -101,23 +104,25 @@ message CohortSpec { // 1.0.0, stream bps) — one stream per // (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 -// first valid Broadcast settles the role. +// AUTH settles the role. // --------------------------------------------------------------------------- // Peer -> broker: the first frame on a fresh stream. Creates the cohort if no live // cohort has this spec, attaches to it otherwise. Nothing else is ever in it -- no -// cursor, no credential: the stream's role follows from what it sends after the -// Ack. +// cursor, no credential: the declared identity settles the stream's role -- pending, +// spectator or silent, or under ALL and implicit authorship a publisher at once -- and +// under explicit authorship only an AUTH makes a publisher stream. message Join { CohortSpec cohort = 1; bytes identity = 2; // 20 bytes, required: under explicit authorship the - // address whose first valid frame claims the stream; + // address whose AUTH authenticates the stream; // the principal's or a rostered one makes the stream // pending (outside the fan-out bound, receiving nothing - // but the roster that names it until it claims), any - // other a spectator, which may not publish; under ALL - // and under implicit authorship the one every - // publication is validated against, no claim + // but the roster that names it, sending nothing but its + // AUTH), any other a spectator, which may not publish + // until a roster names it and its AUTH upgrades it; + // under ALL and under implicit authorship the one every + // publication is validated against, no auth } enum Status { @@ -152,8 +157,10 @@ message Ack { enum Kind { KIND_UNSPECIFIED = 0; // invalid on the wire (see header note) DATA = 1; // a publication - AUTH = 2; // an empty chunk: claims the stream, says nothing, is - // never delivered + AUTH = 2; // the empty chunk at index 0 of the session's AUTH feed: + // authenticates a pending stream, or a spectator or silent + // stream once a roster names its address; a heartbeat on a + // publisher stream; never delivered EOS = 3; // an empty chunk from the admin, at index 0: the channel // is closed, attributably and for good ROSTER = 4; // from the admin: the full publisher set as of this @@ -168,7 +175,7 @@ message Roster { // it can verify. } -// Every frame after the handshake: publisher -> broker a publication, a claim or, +// Every frame after the handshake: publisher -> broker a publication, an AUTH or, // from the admin, a service message; broker -> peer a delivery of the same frame. // The chunk is an ordinary single-owner chunk, travelling as its chunk data with // its full id: @@ -181,7 +188,7 @@ message Roster { // id = keccak256(topic_s || index) index as a uint64 big-endian // and the receiver derives the id, requires the chunk's to equal it, forms the // address the chunk must have from the id and the owner it knows (the stream's -// claimed or declared address; at a subscriber, the owner it recovers and admits) +// authenticated or declared address; at a subscriber, the owner it recovers and admits) // and validates the chunk against it with the ordinary SOC code. For EOS and // ROSTER the owner is the admin, in every configuration; an AUTH is held to the // stream's declared address and is never delivered. Each kind is a diff --git a/SWIPs/swip-60.md b/SWIPs/swip-60.md index 70fed85e..a60bb8e6 100644 --- a/SWIPs/swip-60.md +++ b/SWIPs/swip-60.md @@ -12,7 +12,9 @@ created: 2026-08-03 +protobuf: assets/swip-60/bps.proto (revision 16, derived from SWIP-74's block). Rev 13 +follows SWIP-74 rev 10: the `AUTH` is the only way a stream authenticates, a pending +stream sends nothing else, and what earlier revisions called the claim is the auth. --> - **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: @@ -26,7 +28,8 @@ protobuf: assets/swip-60/bps.proto (revision 15, derived from SWIP-74's block). 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 and a per-stream challenge that - salts every chunk, the first valid one of which is the claim. This + salts every chunk, and an `AUTH` — the empty chunk at index 0 of the session's `AUTH` + feed — that authenticates the stream. This SWIP adds cohort parameters, the admin's service kinds 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. @@ -75,8 +78,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 | | `principal` | eth address | the admin: 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 — every stream is admitted silent, receiving nothing but a roster that names its address, and is disconnected unless its first valid frame, from the admin's or a rostered address, arrives within the auth deadline | +| `publishers` | `ALL` or unset | set: anyone attached may author — no auth; 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 — every stream is admitted silent, receiving nothing but a roster that names its address, and is disconnected unless its `AUTH`, for the admin's or a rostered address, arrives within the auth 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 @@ -169,14 +172,14 @@ 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 — admitting a stream that declares the admin's, or a -rostered, address outside that limit as a pending stream, silent until it claims, so that +rostered, address outside that limit as a pending stream, silent until it authenticates, 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 -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, +broker reclaims a cohort on which no publisher stream has had activity — a message or an +`AUTH` 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 @@ -213,7 +216,7 @@ 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; `principal` - is an address anyone can read, its stream is claimed by a frame signed under a + is an address anyone can read, its stream is authenticated by an `AUTH` signed under a challenge that exists on that stream only, and every message and every service message carries its signature. Nothing in the handshake needs to be trusted. @@ -231,49 +234,51 @@ Three properties follow, and each of them is the point: **latest `ROSTER`** is the first `Broadcast` on a stream at the moment it enters a fan-out set — at attach for a spectator, at upgrade for a pending or a silent stream — before any other **(?)**; and to a pending or silent stream whose `identity` it names it is -delivered at once, its one delivery before the claim, because that is the peer's cue: +delivered at once, its one delivery before the auth, because that is the peer's cue: silence means it is not named here. A joiner learns who may write from the admin, not from the broker, before it has received a single message, and a cohort with no roster delivers nothing first — the admin alone may write. -#### The claim: the first valid frame +#### The auth: the `AUTH` -The role of a stream under explicit authorship is settled by its **first valid frame**, -exactly as in SWIP-74 (*Handshake*). With `Ack{OK}` the broker sends every stream a +The role of a stream under explicit authorship is settled by its **`AUTH`**, exactly as +in SWIP-74 (*Handshake*). With `Ack{OK}` the broker sends every stream a **challenge**: 32 bytes drawn at random for that stream, held for its life, never persisted and never reused. For the life of the stream every chunk sent on it is signed under an id salted with that challenge, and its frame names the kind, the challenge and the index the id was derived from. A chunk under the challenge is possible only for the -key of the address it validates for, and only after the `Ack` — so the first frame on a -stream whose `challenge` is the stream's and whose chunk has the derived id and validates -at the address formed from that id and the stream's declared `identity` proves the key and -the session at once. That frame is the claim: the stream **upgrades** to a publisher -stream. It can be a publication, a service message from the admin, or an **`AUTH`** — the empty -chunk of SWIP-74, for a seat that wants to be present before it plays and for a -moderator that never publishes. There is **no reply**: the publisher sends its first -frame and its next back to back, and learns the outcome from whether the stream -survives. - -Which streams may claim follows from the declared `identity` against `principal` and the +key of the address it validates for, and only after the `Ack` — so an **`AUTH`** — the +empty chunk at index 0 of the session's `AUTH` feed, SWIP-74's — whose `challenge` is +the stream's and whose chunk has the derived id and validates at the address formed from +that id and the stream's declared `identity` proves the key and the session at once. +That is the auth: the stream **upgrades** to a publisher stream, and may send nothing +before it — a publication or a service message from a pending stream is a violation, as +from a spectator. There is **no reply**: the publisher sends its `AUTH` and its first +frame back to back, and learns the outcome from whether the stream survives. On a +publisher stream the `AUTH` is a heartbeat — it counts as activity and does nothing +else — which is how a seat that wants to be present before it plays, or a moderator +that never publishes, holds its cohort past the inactivity deadline. + +Which streams may authenticate follows from the declared `identity` against `principal` and the **current roster**. A stream whose `identity` is the admin's or currently rostered is **pending** — admitted outside the fan-out bound, receiving nothing but the roster that -names it (SWIP-74, *Resource bounds*), until its first valid frame upgrades it or the +names it (SWIP-74, *Resource bounds*), until its `AUTH` upgrades it or the auth deadline disconnects it. A stream whose `identity` is neither is a **spectator**: within the bound, read-only, and with no chance to publish — any frame it sends is a protocol violation, as from a subscriber stream in SWIP-74. When a roster the broker has accepted names a spectator's -address, that stream may claim. A peer other than the admin sends nothing on a stream +address, that stream may authenticate. A peer other than the admin sends nothing on a stream before the broker has delivered it, there, a roster naming its address — which the broker does even on a pending or silent stream — so a conforming peer never publishes ahead of the broker's roster. A chunk that does not validate is a violation on any stream, and the stream is reset: one failed validation is all a connection can cost the broker. -What the claim proves is an **identity**, and identity is what this protocol hands out +What the auth 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; so would a claim signed over a challenge +replayed one would have bought all of that; so would an auth signed over a challenge the broker *derived* for the address rather than drew for the stream — whoever had captured it could present it again once the publisher dropped. A challenge that exists on one stream is answered on that stream or nowhere, and because every chunk answers @@ -282,12 +287,12 @@ either, at this broker or any other. What the challenge does *not* protect is hi signed updates of the channel's own feed to a late viewer is catching it up, not deceiving it (SWIP-74, *Security considerations*). -Under `ALL` there is **no claim** and the challenge still salts: everybody attached may +Under `ALL` there is **no auth** and the challenge still salts: everybody attached may publish, so a stream that declares an address is a publisher stream from its `Join`, and every publication is held to that address — a chunk of the session feed its stream's challenge gives, validating at the address formed from the id and the declared owner — so that a participant's captured messages cannot be replayed into the chat under its name -from another stream. Under **implicit authorship** there is no claim and no salt: the +from another stream. Under **implicit authorship** there is no auth and no salt: the chunks are the binding's own — a live MIC is the owner's storage chunks as they are published — so they carry their own id, the frame's `challenge` and `index` are unset, replay is what a store does, and a stream that @@ -296,7 +301,7 @@ fit the binding's shape, with its owner the declared one where the shape fixes o 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 first valid `Broadcast` settles the role +### The first frame settles the cohort; the `AUTH` settles the role A peer's cohort is fixed by its **first frame**, `Join` — the only handshake frame there is — carrying the full `CohortSpec` and the address it publishes as, `identity`, and nothing @@ -314,18 +319,19 @@ roster**, and from its first frame: | `identity`, and first frame | `closed` unset | `closed` set | |---|---|---| -| the admin's or rostered; none yet | a **pending stream**: outside the fan-out bound, receiving nothing but, if rostered, the roster that names it, until it claims or the auth deadline disconnects it | the same | -| the admin's or rostered; a valid frame under the stream's challenge | the stream is a **publisher stream**; a publication or a service message is delivered, an `AUTH` is not | the same | -| the admin's or rostered; a frame under another challenge | dropped and counted (`wrong_challenge`), the broker MAY reset the stream; pending still | the same | +| the admin's or rostered; none yet | a **pending stream**: outside the fan-out bound, receiving nothing but, if rostered, the roster that names it, until it authenticates or the auth deadline disconnects it | the same | +| the admin's or rostered; a valid `AUTH` under the stream's challenge | the stream is a **publisher stream**; the `AUTH` is not delivered, every publication or service message after it is | the same | +| the admin's or rostered; a publication or a service message before its `AUTH` | violation: the stream is reset, the peer blocklisted per policy | the same | +| the admin's or rostered; an `AUTH` under another challenge | dropped and counted (`wrong_challenge`), the broker MAY reset the stream; pending still | the same | | any; a chunk that does not validate | violation: the stream is reset, the peer blocklisted per policy | the same | -| neither; none | a **spectator stream**, read-only, within the bound; it may claim once a roster names its address | a **silent stream**: attached, receiving nothing, until a roster names its address and it claims, or the auth deadline disconnects it | +| neither; none | a **spectator stream**, read-only, within the bound; it may authenticate once a roster names its address | a **silent stream**: attached, receiving nothing, until a roster names its address and it authenticates, or the auth deadline disconnects it | | neither; any frame | violation: the stream is reset, the peer blocklisted per policy | the same | 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 the first valid -frame on a stream is signed for that stream only, by the key the roster names — +who it fails to prove it is — and it is enforceable precisely because the `AUTH` on a +stream is signed for that stream only, by the key the roster names — or by whatever that key hands its challenge to, which is that key's business **(?)**. Everywhere else `REJECTED` means the *`Join`* is unacceptable — a spec value outside this SWIP, or a @@ -335,9 +341,9 @@ 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 — with an `AUTH` or its -first publication — on its current stream, once it sees itself in the roster it is -delivered, or on a new one. +changes. A **grant** takes effect when the granted peer authenticates — with its `AUTH` — +on its current stream, once it sees itself in the roster it is delivered, or on a new +one. A **revocation** has two phases, and the boundary between them is the moment the reduced roster reaches subscribers: @@ -350,8 +356,9 @@ roster reaches subscribers: every other subscriber, on the same feed. Nothing on this wire tells the broker when the peer has read it, so the boundary is fixed by the broker: it enqueues the reduced roster on the revoked stream first, and tolerates frames from it for a grace period - after the roster has been written there (RECOMMENDED 5 s **(?)**). Publishing after - that is a **protocol violation**, and the broker MUST break the connection. + after the roster has been written there (RECOMMENDED 5 s **(?)**). Any frame after + that — a publication, a service message or an `AUTH` — 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 @@ -372,7 +379,7 @@ disconnection they cannot attribute. Enforces its own capacity. **At capacity it MUST answer `Join` with a refusal** (`FULL`) — except that, as in SWIP-74, it admits **a `Join` declaring the admin's address, or a rostered one, outside the fan-out bound as a pending stream**: attached, - receiving nothing but the roster that names it, disconnected if it has not claimed within the auth deadline, and + receiving nothing but the roster that names it, disconnected if it has not authenticated within the auth deadline, and bounded per peer connection and cohort; 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 @@ -412,7 +419,7 @@ sequenceDiagram PN->>B: Join(CohortSpec, identity) 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, challenge) — 32 random bytes for this stream - PN->>B: Broadcast(AUTH or first update: kind, challenge, index, soc signed by identity) — the claim, no reply + PN->>B: Broadcast(AUTH: the empty chunk at index 0 of the session's AUTH feed, signed by identity) — the auth, no reply SN->>B: Join(CohortSpec, identity) B-->>SN: Ack(OK, challenge) B->>SN: Broadcast(latest ROSTER) — the admin's word, relayed @@ -448,9 +455,9 @@ Messages are defined in [bps.proto](assets/swip-60/bps.proto). Framing notes: and `Kind`, are SWIP-74's; this SWIP adds fields, values and the `Roster` a `ROSTER` chunk carries, never frames. There is no envelope: what a frame is follows from the stream's direction and role, and what a chunk is from the frame's kind — a pending - stream sends its claim, its first valid frame; a spectator stream sends nothing, and - may claim once a roster names its address; a publisher stream sends publications and, - if it is the admin's, service messages. + stream sends its `AUTH` and nothing else; a spectator stream sends nothing, and + may authenticate once a roster names its address; a publisher stream sends + publications, heartbeats and, if it is the admin's, service messages. - **`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 @@ -459,14 +466,14 @@ Messages are defined in [bps.proto](assets/swip-60/bps.proto). Framing notes: - **Stream model rationale**: per-cohort streams give per-cohort flow control, teardown and role typing, and match bee's protocol idiom. Every frame carries the chunk data and what its id was derived from; at the broker it is checked against the stream's - challenge and an address formed from what the stream declared or claimed, and a + challenge and an address formed from what the stream declared or authenticated, and a delivery is self-contained: a subscriber needs nothing of the stream to verify it. - Every `Broadcast` carries the **chunk data** — an ordinary SOC with its full id — and the kind, the challenge and the index its id was derived from (SWIP-74), and no address. Every receiver derives the id from the frame and requires the chunk's to equal it. **At the broker** the receiver then forms the address from that id and the owner it knows — - the stream's claimed or declared address, or the binding's SOC shape under implicit + the stream's authenticated or declared address, or the binding's SOC shape under implicit authorship, where there is nothing to derive — 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 @@ -486,9 +493,11 @@ Messages are defined in [bps.proto](assets/swip-60/bps.proto). Framing notes: 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`, in SWIP-74's order: it arrived on a publisher or - pending stream — one claimed or claimable by its `identity`, or declaring an address under - `ALL` or implicit authorship — and not on a spectator stream, from which any frame is +- Broker validation on a `Broadcast`, in SWIP-74's order: it arrived on a publisher + stream — one authenticated by its `identity`, or declaring an address under `ALL` or + implicit authorship — or is an `AUTH` on a stream that may authenticate: a pending one, + or a spectator or silent one whose address a roster the broker has accepted names; any + other frame, from a spectator, silent or pending stream, is a violation; its `kind` is one the broker defines — otherwise it is dropped and counted (`unknown_kind`), not a violation; its `challenge` is the stream's (except under implicit authorship) — otherwise it is dropped and counted (`wrong_challenge`) and the @@ -500,23 +509,24 @@ Messages are defined in [bps.proto](assets/swip-60/bps.proto). Framing notes: that id and the stream's address (under implicit authorship, from the binding's SOC shape, the PO constraint holding where applicable). A chunk that does not validate is a protocol violation — dropped, the stream reset, the peer blocklisted (SWIP-74) — so - the signature check is paid at most once in vain per connection. On a pending stream - the duplicate check comes after validation, as in SWIP-74: a valid frame upgrades the - stream even if it is then dropped as a retransmit. + the signature check is paid at most once in vain per connection. Nothing but an + `AUTH` reaches validation on a pending stream, so the checks run in one order on + every stream, as in SWIP-74. - **Service messages** are kinds, not payloads: an `EOS` or `ROSTER` frame is accepted - on the admin's publisher or pending stream only — on a pending stream it is the claim - — and from any other stream it is a violation. A `ROSTER` is accepted iff its `index` + on the admin's publisher stream only — from any other stream, the admin's pending one + included, it is a violation. A `ROSTER` is accepted iff its `index` is at least the roster cursor — the lowest roster index accepted next, initially 0, set past every accepted one — and its payload decodes as a `Roster`; one below the - cursor is a retransmit, dropped and counted as one. The chunk is validated first: one that validates but whose payload does not - decode as a `Roster` of 20-byte entries is dropped and counted (`invalid_roster`), - claims a pending stream like any valid frame, and moves no cursor; a subscriber drops - it likewise. An `EOS` is accepted at index 0 only, and its chunk, like an `AUTH`'s, + cursor is a retransmit, dropped and counted as one. The chunk is validated before its payload is read: one that validates but whose payload does not + decode as a `Roster` of 20-byte entries is dropped and counted (`invalid_roster`) and + moves no cursor; a subscriber drops it likewise. An `EOS` is accepted at index 0 only, and its chunk, like an `AUTH`'s, has span 0 and no payload — anything else is a violation. On accepting an `EOS` the broker delivers it to every stream in a fan-out set and then reclaims the cohort as at the inactivity deadline; a subscriber that accepts one treats the channel as ended. - An `AUTH` is accepted on - any stream that may claim, upgrades it if it is pending, and is never delivered. A + An `AUTH` — the empty chunk at index 0 of the session's `AUTH` feed, the one frame a + pending stream may send — is accepted on a pending stream, and on a spectator or silent + stream a roster names, which it upgrades to a publisher stream, and on a publisher + stream — under `ALL` included — where it is a heartbeat; it is never delivered. A SWIP-74 broker, which knows `DATA` and `AUTH` only, drops the admin's kinds as `unknown_kind` rather than punishing them. - **The dedup *horizon* is implementation-defined, but it MUST be bounded**: the binding @@ -557,7 +567,7 @@ topics). Query parameters: |---|---|---| | `peer` | — | broker underlay multiaddr; required until broker discovery exists (bps-broker-discovery) — early deployments configure it | | `binding`, `principal`, `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. `principal` omitted ⇒ implicit authorship. No publisher list here — it is not part of the spec | -| `identity` | `Join.identity` | 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 dApp signs every chunk under it client-side as it signs any SOC — read–write from then on iff the address is the admin's or currently rostered, or the cohort is `ALL`; or the cohort is implicit, where nothing is salted and the dApp signs the binding's own chunks; a later roster naming the address is the cue to claim **(?)**. Absent, a spectator session on the node's shared subscriber stream, whose `Join` declares the node's own address **(?)**. The node holds no publisher keys, and the same key works from any node | +| `identity` | `Join.identity` | 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 dApp signs every chunk under it client-side as it signs any SOC — read–write once its `AUTH` validates iff the address is the admin's or currently rostered, and from the `Ack` if the cohort is `ALL`; or the cohort is implicit, where nothing is salted and the dApp signs the binding's own chunks; a later roster naming the address is the cue to authenticate **(?)**. Absent, a spectator session on the node's shared subscriber stream, whose `Join` declares the node's own address **(?)**. The node holds no publisher keys, and the same key works from any node | **`POST /bps/{topic}/service`** — the admin's control plane: submits a service message (`ROSTER` or `EOS`) as the next chunk on its kind's feed, signed under the @@ -592,15 +602,15 @@ authorship with a feed binding the prefix is the bare index, the signed id being `keccak256(topic ‖ index)` (self-indexed feeds, [SWIP-65](https://github.com/ethersphere/SWIPs/pull/106)). The node assembles the SOC and the `Broadcast` around it, validates it exactly as a broker would, and -publishes. There is no separate claim to sign: the node passes the session the -challenge, the dApp signs every chunk under the id it salts, and the first — an update, -or an empty `AUTH` — is the claim **(?)**. +publishes. The auth is one more chunk to sign: the node passes the session the +challenge, the dApp signs the `AUTH` — the empty chunk at index 0 of the session's `AUTH` +feed — and then every update under the id it salts **(?)**. 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, and its first publication under the challenge it is handed recovers to +its address, and its `AUTH` under the challenge it is handed validates for `principal` ⇒ read–write; the spec creates the cohort: ``` @@ -618,9 +628,9 @@ wss://node:1633/bps/jam-tuesday?peer= Seats B–D are not named in this URL and never appear in a cohort parameter: A grants them with a `POST /bps/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 its -first publication under the challenge issued for its stream; because the cohort is -`closed`, a seat receives nothing but the roster that names it until its first valid -frame — a publication or an `AUTH` — validates for its rostered address, and is +`AUTH` under the challenge issued for its stream; because the cohort is +`closed`, a seat receives nothing but the roster that names it until its `AUTH` +validates for its rostered address, and is disconnected if that never comes. The join URL minus `identity` 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. @@ -640,13 +650,13 @@ binding: ANCHOR (topic = mnemonic anchor) principal: 0xA… closed: true history: false ``` -Seat A joins and claims its stream with its first frame — the `ROSTER` that grants B, C -and D will do — and each of them becomes a -publisher by its first publication, accepted because it is signed under the stream's +Seat A joins, authenticates its stream with its `AUTH` and publishes the `ROSTER` that +grants B, C and D; each of them becomes a +publisher by its own `AUTH`, accepted because it is signed under the stream's challenge by an address on the roster the others can verify against A's key. A fifth peer receives nothing and is disconnected when its auth deadline passes — this is the one configuration in which a peer is refused for who it is, and it is -enforceable because the first valid frame on a stream is signed under a challenge that +enforceable because the `AUTH` on a stream is signed under a challenge that exists on that stream only. 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 @@ -660,7 +670,8 @@ history: false ``` Identical authorship, but an unrecognised joiner is admitted read-only instead of refused — -and publishes, on the stream it already holds, when a later roster names it. +and, when a later roster names it, authenticates with its `AUTH` on the stream it +already holds and publishes from then on. The audience verifies the roster from the admin's feed, so it knows exactly whose messages are legitimate without trusting the broker. @@ -686,7 +697,7 @@ binding: MNEMONIC (the topic is just the cohort's name) principal: 0xA… publishers: ALL history: false ``` -No roster, no claim: each stream declares the address it +No roster, no auth: 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 — and must be a chunk of the session feed the stream's challenge gives, at an index above the speaker's last, so that @@ -790,15 +801,15 @@ verifiable signed chunks — not to reimplement a mesh. **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. -`principal` is a public address; its stream is claimed by a chunk signed under a +`principal` is a public address; its stream is authenticated by an `AUTH` signed under a challenge that exists on that stream only, 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, and the session takes it again.** Every chunk under explicit authorship or `ALL` is signed under an id salted with the challenge -the broker drew for the stream it travels on; under explicit authorship the first valid -frame is the claim, under `ALL` there is none. A third party cannot +the broker drew for the stream it travels on; under explicit authorship the `AUTH` +authenticates the stream, under `ALL` nothing does. A third party cannot obtain anything it could use: a captured frame — every subscriber has them — names a challenge no other stream has and is dropped on that check before any signature is looked at, on this broker after the stream is gone, on another broker, on another @@ -832,11 +843,12 @@ gains nothing by it beyond what its key already signs: every message is validate 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.** +the `AUTH` — or, under `ALL` and implicit authorship, the declared `identity` — decides +only who is carried as a publisher.** **Audience control exists in exactly one form, and it is not confidentiality.** `closed` keeps a joiner outside the roster silent and then disconnects it, and is -enforceable because the first valid frame on a stream is signed under a challenge that +enforceable because the `AUTH` on a stream is signed under a challenge that exists on that stream only. It bounds *attendance at this broker* to holders of the admin's and rostered keys — and to whatever sits between such a key and the broker: a member pointed at a relay hands it its challenge, and the relay attends in its name, @@ -865,10 +877,10 @@ its per-cohort stream count (`FULL`), the number of cohorts it will create and t one peer connection may hold (any peer can make it allocate a cohort simply by joining), and reclaims idle cohorts — SWIP-74's bounds, the outbound queue per subscriber stream among them, with pending streams for the admin's and -rostered addresses outside the fan-out bound, silent until they claim or the auth +rostered addresses outside the fan-out bound, silent until they authenticate or the auth deadline passes, a bound on streams per peer connection per cohort, and — since in this SWIP a publisher stream also receives — a bound on the publisher streams one address may -hold in a cohort (RECOMMENDED 2 **(?)**; a claim beyond it resets the oldest) — and, for +hold in a cohort (RECOMMENDED 2 **(?)**; an auth beyond it resets the oldest) — and, for implicit cohorts, bounds its dedup window (see the horizon note above); under explicit authorship and `ALL` every publisher has a cursor instead, in a bounded table. The bounded dedup window admits replay of an evicted message by an @@ -911,28 +923,29 @@ An implementation is conformant when: fan-out set — at attach for a spectator, at upgrade otherwise — and is delivered before that to a pending or silent stream whose `identity` it names **(?)**; 7. an absent `principal` is treated as implicit authorship — a stream that declares an address - publishes from its `Join` with no claim and no salt, each message validated strictly - per the binding's SOC shape — and a present one authenticated by its first valid - frame under its stream's challenge and by its signature on every service message, + publishes from its `Join` with no auth and no salt, each message validated strictly + per the binding's SOC shape — and a present one authenticated by its `AUTH` under its + stream's challenge and by its signature on every service message, both of which MUST validate for it; 8. under explicit authorship and under `ALL` every chunk is an ordinary SOC whose id is the one SWIP-74 derives from the frame's kind, challenge and index — `keccak256(keccak256(prefix ‖ topic ‖ challenge) ‖ index)`, the prefix empty for `DATA` and `"bps-service:v1" ‖ kind` otherwise — for every binding; a stream declaring the admin's or a rostered `identity` is pending, outside the fan-out bound and receiving - nothing but the roster that names it, until its first valid frame — a publication, an `AUTH`, or from the admin a - service message — upgrades it, no reply sent, or the auth deadline disconnects it; + nothing but the roster that names it, until its `AUTH` — the empty chunk at index 0 of + the session's `AUTH` feed, the one frame it may send — upgrades it, no reply sent, or + the auth deadline disconnects it; any frame from a stream declaring another `identity` is a violation, until a roster the broker has accepted names that address; a chunk that does not validate is a violation on any stream; a frame under another challenge is dropped and counted; under `ALL` - there is no claim, and every publication is checked against the declared address + there is no auth, and every publication is checked against the declared address under the stream's challenge; a `closed` cohort delivers nothing to a stream before - it claims except the roster that names its `identity` — the only refusal for identity in the protocol; `EOS` and `ROSTER` are - accepted on the admin's publisher or pending stream only, each kind a feed of its own; + it authenticates except the roster that names its `identity` — the only refusal for identity in the protocol; `EOS` and `ROSTER` are + accepted on the admin's publisher stream only, each kind a feed of its own; 9. an admin grants and revokes by publishing `ROSTER` service messages; a revoked publisher's frames are **dropped and tolerated** until the reduced roster has been written to its stream and a grace period has passed, and its connection is broken - only if it publishes **after** that point; + only if it sends any frame **after** that point; 10. a subscriber takes the roster from the admin's `ROSTER` feed, never from the broker, holds every service chunk to the admin's address, keeps its roster cursor and its cursor per publisher across streams and sessions, refuses what is below them, @@ -949,7 +962,7 @@ whose `principal` is absent, and ignores the fields it does not define (`publish feed-topic cohort whose admin later publishes a roster — the spec is the same — and it serves that as a live stream: the roster is dropped as `unknown_kind` — a kind SWIP-74 does not define — and a grantee, whose `identity` is not the admin's, is a subscriber -stream there, so its first publication is a violation that resets its stream; an admin +stream there, so its `AUTH`, the first frame it sends, is a violation that resets its stream; 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 From 430eeae92d8e2b1a1d8b633fc6637de0f0c4e223 Mon Sep 17 00:00:00 2001 From: zelig Date: Sat, 10 Oct 2026 11:39:59 +0200 Subject: [PATCH 20/20] swip-60 rev 13: glossary, outcomes table, replay table, id-derivation figure Terms at the head of the specification (SWIP-74's, plus this SWIP's: admin, explicit and implicit authorship, ALL, binding, spectator and silent streams, roster, service messages, EOS, grant and revocation, closed, history, dApp); an Outcomes subsection under Wire protocol with every outcome a frame can have, its counter and what the sender sees; a replay table in Security considerations extending SWIP-74's; the id-derivation figure with the service kinds (assets/swip-60/id-derivation.svg). Co-Authored-By: Claude Fable 5.1 --- SWIPs/assets/swip-60/id-derivation.svg | 71 ++++++++++++++++ SWIPs/swip-60.md | 113 ++++++++++++++++++++++++- 2 files changed, 183 insertions(+), 1 deletion(-) create mode 100644 SWIPs/assets/swip-60/id-derivation.svg diff --git a/SWIPs/assets/swip-60/id-derivation.svg b/SWIPs/assets/swip-60/id-derivation.svg new file mode 100644 index 00000000..40ebf615 --- /dev/null +++ b/SWIPs/assets/swip-60/id-derivation.svg @@ -0,0 +1,71 @@ + + + +How a chunk's id is derived from the frame, and where it is validated + +Broadcast (the frame) + +kind +1 byte, enum + +challenge +32 bytes, the stream's + +index +8 bytes, big-endian + +soc +the chunk, 105 + payload + +CohortSpec (from Join) + +topic +32 bytes + +principal +20 bytes, eth address + +1 prefix <- kind +DATA -> (empty) +AUTH -> "bps-service:v1" || 0x02 +EOS -> "bps-service:v1" || 0x03 +ROSTER -> "bps-service:v1" || 0x04 + +2 topic_s = keccak256( prefix || topic || challenge ) +the session feed of this kind: the topic salted with the challenge the broker drew for the stream; one feed per kind + +3 id = keccak256( topic_s || index ) +index as 8 bytes big-endian: the AUTH is at index 0 of its feed, a DATA update at its feed index + +4 the chunk must carry that id: soc = id || signature || span || payload + +id 32 + +signature 65 + +span 8, LE + +payload <= 4096 (AUTH: span 0, no payload) +the receiver derives the id from the frame and requires soc[0:32] to equal it: +kind, challenge and index are not trusted, they are bound through the id's preimage + +5 address = keccak256( id || principal ) validate the SOC at this address +BMT(span || payload) = wrapped address; the signature over keccak256(id || wrapped) recovers an owner; +keccak256(id || owner) must equal the address: the owner is forced to the principal, not read from the key + + + + + + + + + + +Nothing in the frame is signed. The kind, the challenge and the index are bound because the id the chunk +is signed under is derived from them: change any one and the derived id is not the chunk's, so the frame +is refused before the signature is checked. +SWIP-60: for EOS and ROSTER the owner is the admin in every configuration; under ALL it is the stream's declared +address; under implicit authorship there is no challenge, the frame's challenge and index are unset, and the id is +whatever the binding's SOC shape says. + \ No newline at end of file diff --git a/SWIPs/swip-60.md b/SWIPs/swip-60.md index a60bb8e6..55e42721 100644 --- a/SWIPs/swip-60.md +++ b/SWIPs/swip-60.md @@ -14,7 +14,8 @@ monolithic PubSub SWIP (ethersphere/SWIPs PR #93) into work-package-sized SWIPs. the base wire of SWIP-74 (BPS-lite, PR #111) and changes nothing in it. Companion protobuf: assets/swip-60/bps.proto (revision 16, derived from SWIP-74's block). Rev 13 follows SWIP-74 rev 10: the `AUTH` is the only way a stream authenticates, a pending -stream sends nothing else, and what earlier revisions called the claim is the auth. --> +stream sends nothing else, and what earlier revisions called the claim is the auth; a +glossary, an outcomes table, a replay table and a figure of the id derivation are added. --> - **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: @@ -57,6 +58,52 @@ changing the semantics defined here. ## Specification +### Terms + +The base terms are SWIP-74's, repeated here so that this SWIP reads on its own; the +rows after **admin** are this SWIP's additions. + +| term | meaning | +|---|---| +| **channel** | what an application streams on: a topic and, under explicit authorship, its admin's address, independent of any broker | +| **cohort** | the live state of one channel at one broker: the streams attached under one spec, created by the first `Join` that names the spec, reclaimed by inactivity or ended by an `EOS` | +| **cohort spec**, `CohortSpec` | `{topic, binding, principal?, publishers?, closed?, history?}`: the cohort's identity, immutable, carried in full in every `Join` | +| **topic** | 32 bytes: what the binding binds to | +| **broker** | the full node every stream of the cohort is attached to: it validates, keeps the cursors and fans out; it can withhold, never forge | +| **peer** | a node connected to the broker; one peer connection may hold several streams | +| **stream** | one libp2p stream `/swarm/bps/1.0.0/bps` from a peer to the broker, for one cohort and one identity: the unit of role, flow control and teardown | +| **identity** | the address a stream declares in `Join`: the one it publishes as, if it may | +| **frame** | one protobuf message on a stream: `Join`, `Ack` or `Broadcast`; what a frame is follows from the stream's direction and role, never from an envelope | +| **handshake** | the first frame each way: `Join{cohort, identity}` from the peer, `Ack{status, challenge}` from the broker | +| **challenge** | 32 bytes the broker draws at random for each stream and sends in the `Ack`: it salts the session feed and is never persisted or reused | +| **single-owner chunk**, SOC | `id (32) ‖ signature (65) ‖ span (8) ‖ payload (≤ 4096)`, stored and validated at `keccak256(id ‖ owner)`; every chunk on this wire is one | +| **kind** | what a chunk is: `DATA`, `AUTH`, `EOS` or `ROSTER`; carried in the frame, it selects the feed's prefix | +| **session feed** | the channel's feed salted for one stream: topic `keccak256(prefix ‖ topic ‖ challenge)`, one feed per kind, each with its own index sequence; the chunk id is `keccak256(topic_s ‖ index)` (SWIP-74) | +| **index** | the chunk's position on its feed: 8 bytes big-endian in the frame and in the id's preimage | +| **update**, publication | a `DATA` frame from a publisher stream | +| **auth** | the act and the frame that authenticate a stream: the **`AUTH`**, an empty SOC at index 0 of the session's `AUTH` feed, which upgrades a stream that may authenticate and is never delivered | +| **heartbeat** | an `AUTH` on a publisher stream: counts as activity, does nothing else | +| **delivery** | an accepted frame forwarded unchanged — chunk, kind, challenge, index — to every stream in a fan-out set not bound to the publishing identity | +| **fan-out set** | the streams of a cohort that receive deliveries, bounded per cohort | +| **cursor** | the lowest index accepted next: at the broker one per publisher and one for the roster feed, at a subscriber the same, kept across streams | +| **retransmit** | a frame below its cursor: dropped and counted, not a violation | +| **violation** | a frame a stream may not send, or a chunk that does not validate: dropped, counted, the stream reset, the peer blocklisted | +| **inactivity deadline**, **auth deadline** | how long a cohort survives without an accepted frame; how long a pending or silent stream has to send its `AUTH` | +| **principal**, **admin** | the address in the spec: under explicit authorship the **admin** — the cohort's authority, the only writer of the service feeds, always a publisher | +| **explicit authorship** | `principal` set: the admin and the addresses its roster names may publish; every such stream is authenticated by its `AUTH` and every chunk salted by its stream's challenge | +| **implicit authorship** | `principal` absent: who may publish follows from the binding's SOC shape; no auth, no salt, dedup on chunk address | +| **`ALL`** | the one `publishers` value: anyone attached may publish; a stream declaring an address is a publisher stream from its `Join`, no auth, every chunk still salted and held to the declared address | +| **binding** | what the topic binds to — `MNEMONIC`, `ANCHOR`, `SOC_ID`, `OWNER`, `FEED_TOPIC` — which fixes which SOCs qualify under implicit authorship and the dedup rule there | +| **roles** | **subscriber stream** — receives, sends nothing (SWIP-74). **Pending stream** — declared the admin's or a rostered address, not yet authenticated: receives nothing but the roster that names it, sends nothing but its `AUTH`, admitted outside the fan-out bound. **Publisher stream** — authenticated, or declared under `ALL` or implicit authorship: sends publications, heartbeats and, if it is the admin's, service messages. **Spectator stream** — under explicit authorship, an identity neither the admin's nor rostered: within the bound, receives, sends nothing, may authenticate once a roster names its address. **Silent stream** — a spectator in a `closed` cohort: receives nothing but a roster naming it, disconnected at the auth deadline unless it authenticates | +| **roster** | the admin's complete publisher set, published as a `ROSTER` chunk on the admin's roster feed; the latest one the broker holds is the first delivery on every stream entering a fan-out set | +| **rostered** | named by the latest roster the broker has accepted | +| **service message**, service feed | the admin's control plane: `EOS` and `ROSTER`, each a kind with its own session feed, the owner forced to the admin | +| **`EOS`** | end of stream: the empty chunk at index 0 of the admin's `EOS` feed; ends the channel, attributably and for good | +| **grant**, **revocation** | the admin adding or removing an address by publishing the next roster: a grant takes effect at the grantee's `AUTH`, a revocation once the reduced roster has reached the revoked stream and a grace period has passed | +| **`closed`** | no audience: every stream is silent until its `AUTH` validates for the admin's or a rostered address | +| **`history`** | deliver matching chunks from the local store on attach (mechanism in bps-history) | +| **dApp**, bridge | the application behind the Bee API's WebSocket endpoints; the node joins, validates and relays on its behalf and holds no key | + ### The contract Per topic-cohort: @@ -546,6 +593,42 @@ Messages are defined in [bps.proto](assets/swip-60/bps.proto). Framing notes: (SWIP-74). What multihop's dual paths do to this is [SWIP-61](https://github.com/ethersphere/SWIPs/pull/105)'s business. +#### Outcomes + +![How a chunk's id is derived from the frame, and where it is validated](assets/swip-60/id-derivation.svg) + +Every frame the broker receives ends in exactly one of the outcomes below — SWIP-74's, +plus this SWIP's. The wire carries no error frame: a peer learns what it learns from the +`Ack` status, from its stream being reset, or from nothing happening, and a reset says +nothing about its cause; the broker tells causes apart in its counters. + +| what arrived | from | outcome | counter | the sender sees | +|---|---|---|---|---| +| a `Join` with a spec value outside this SWIP or an `identity` that is not 20 bytes | anyone | `Ack{REJECTED}`, stream closed | — | `REJECTED` | +| a `Join` at a capacity bound | anyone but the admin's or a rostered identity | `Ack{FULL}`, stream closed | — | `FULL` | +| a `Join` declaring the admin's or a rostered identity when the fan-out set is full | that address's node | admitted as a pending stream outside the bound | — | `OK` and a challenge | +| a `Join` beyond the publisher streams one address may hold | a publisher's node | admitted; the oldest of that address's streams is reset at the new one's `AUTH` (?) | — | `OK`; the old stream is reset | +| any `Broadcast` | a subscriber stream, or a spectator or silent stream no roster names | violation: dropped, stream reset, peer blocklisted | `wrong_stream` | the stream is reset | +| anything but an `AUTH` | a pending stream, or a spectator or silent stream a roster names | violation | `wrong_stream` | the stream is reset | +| an `EOS` or `ROSTER` | any stream but the admin's publisher stream | violation | `wrong_stream` | the stream is reset | +| a kind this SWIP does not define | a publisher stream | dropped | `unknown_kind` | nothing | +| a frame whose challenge is not the stream's | a publisher stream, or an `AUTH` on a stream that may authenticate | dropped; the broker MAY reset the stream | `wrong_challenge` | nothing, or a reset | +| a `DATA` below the publisher's cursor, or a `ROSTER` below the roster cursor | the admin's or a publisher stream | dropped | `retransmit` | nothing | +| a chunk whose id is not the one derived from the frame, or that does not validate at the address formed from the id and the owner the broker holds the stream to; an `AUTH` or `EOS` with a payload or not at index 0; an index of 2⁶⁴−1 | a stream that may send that kind | violation | `invalid_soc` | the stream is reset | +| a `ROSTER` that validates but whose payload does not decode as a `Roster` | the admin's publisher stream | dropped; no cursor moves | `invalid_roster` | nothing | +| a frame from a revoked publisher before the reduced roster has reached its stream and the grace period has passed | a revoked publisher stream | dropped and tolerated | `revoked` | nothing | +| a frame from a revoked publisher after that point | the same | violation | `wrong_stream` | the stream is reset | +| no `AUTH` within the auth deadline | a pending or silent stream | stream reset, peer blocklisted for a short time | `auth_timeout` | the stream is reset | +| a valid `AUTH` | a stream that may authenticate | the stream upgrades to a publisher stream and, if it was not in the fan-out set, receives the latest roster first; activity | — | nothing | +| a valid `AUTH` | a publisher stream | activity, nothing else | — | nothing | +| a valid `DATA` at or above its publisher's cursor | a publisher stream | cursor set past it; delivered to every stream in the fan-out set not bound to the publishing identity | — | nothing | +| a valid `ROSTER` at or above the roster cursor | the admin's publisher stream | roster cursor set past it; the roster replaces the broker's; delivered as any publication, and at once to a pending or silent stream it names | — | nothing | +| a valid `EOS` | the admin's publisher stream | delivered to the fan-out set, then the cohort is reclaimed and every stream reset | — | the stream is reset | +| a delivery a stream cannot take — its outbound queue is full | the broker, towards a receiving stream | that stream is reset | `queue_reset` | the stream is reset | +| no accepted frame for the inactivity deadline | — | the cohort is reclaimed, every stream in it reset | — | the stream is reset | +| a delivery of an `AUTH`, of a kind it does not know, or below its cursor | the broker, at a subscriber | dropped by the subscriber | — | — | +| a delivery that does not validate, or whose owner the roster does not admit | the broker, at a subscriber | the subscriber resets the stream | — | — | + ### API (WebSocket bridge) One endpoint pair on the Bee API. Endpoint shape follows bee @@ -872,6 +955,34 @@ 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. +**Replay, case by case.** What can be captured and presented again, by whom, where, with +what result, and what stops it — SWIP-74's table extended by this SWIP's rows: + +| replayed | by whom | where | result | stopped by | +|---|---|---|---|---| +| a session's frames as they were, the `AUTH` included | anyone who received them — every subscriber, the bridging node, a relay, the broker itself | any other stream: another node, the same node reconnecting, this broker after a restart or a reclaim, another broker, another cohort | on a publisher stream dropped before any signature check (`wrong_challenge`); from a subscriber, spectator or silent stream, or as anything but an `AUTH` from a pending one, a violation | the frame's challenge is not the receiving stream's | +| a session's chunks with the receiving stream's challenge put in the frame | the same | the same | a violation: the stream reset, the peer blocklisted | the challenge is in the id's preimage: the derived id is not the chunk's | +| a frame with its kind, challenge or index altered | the broker, towards subscribers | a subscriber | refused | the same: all three are in the id's preimage | +| an `AUTH` passed off as an `EOS` — both empty, both at index 0 — or any kind as another | the broker | a subscriber | refused | the kind byte is in the prefix (?) | +| an earlier session's update, at an index the publisher reused | a broker that carried it | a subscriber, in place of the later session's update | delivered: a subscriber cannot tell sessions apart | the publisher never reusing an index; then it is below every returning subscriber's cursor | +| an earlier session's `ROSTER` | a broker that carried it | a subscriber | dropped below the roster cursor; a first-time subscriber has no cursor and can be shown an old roster as the latest | roster indices never reused; the roster cursor kept per `(topic, admin)` across streams; the residual stated | +| an earlier session's `EOS` | a broker that carried it | a subscriber of a later run on the same channel | accepted — and true: the channel is ended | an `EOS` ends the channel, not a session; an admin that publishes again takes another topic (?) | +| an earlier session's messages under a non-feed binding | a broker that carried it | a subscriber | dropped as retransmits | under explicit authorship and `ALL` the index works for every binding: a cursor per publisher, no index reuse | +| a service chunk signed by anyone but the admin — the broker's own key, a rostered seat | the broker | a subscriber | refused | for every kind but `DATA` the owner is forced to the admin | +| rosters or an `EOS` of another spec on the same topic and admin | a broker that carried it | a subscriber | accepted | nothing on the wire: the id binds topic and owner, not the spec — an admin SHOULD NOT run two specs on one topic | +| the challenge, by joining with the admin's or a rostered address | anyone | this broker | harmless: a pending stream that receives nothing but the roster, until the auth deadline, then a blocklist | the `AUTH`, not the challenge, is the credential | +| the challenge, forwarded | a relay or impostor broker the publisher was pointed at | the honest broker | the publisher's own frames reach the honest broker as the publisher's: the relay is a transparent hop that can withhold, not author — in a `closed` cohort it attends in the member's name (?) | nothing needed for authorship; `closed` against relays is flagged | +| a chunk of the channel's own feed, `keccak256(topic ‖ index)` | anyone holding it | any stream | a violation | no challenge gives the unsalted topic | +| the admin's queued updates of its previous session | the admin's own node, reconnecting | its new stream | before the `AUTH` a violation (`wrong_stream`), after it dropped (`wrong_challenge`), the broker MAY reset | the publisher re-signs its queue and sends nothing before its `AUTH` | +| a publisher's updates on its own stream | the publisher's node | this session | dropped as retransmits | the cursor | +| a publisher's updates from two nodes | the publisher | this broker | valid on each stream — two challenges, two signatures per update — delivered once | the cursor | +| the session feed, from storage | anyone who saved it | first-time viewers | history: genuine updates in order | nothing needed; returning viewers keep their cursor per publisher | +| a `Join` as the admin or a rostered address, from throwaway peer ids | a Sybil | a full cohort | a pending stream each: no deliveries but the roster, then the auth deadline | pending streams are outside the fan-out bound; streams per connection per cohort are bounded | +| any frame | an unrostered member | its spectator or silent stream | a violation | unrostered streams never publish; the roster is delivered before an `AUTH` is accepted | +| a revoked publisher's frames | the revoked peer | its publisher stream | tolerated and dropped until the reduced roster has reached it and the grace period has passed; then a violation | revocation is announced before it is enforced | +| a participant's captured messages, under `ALL` | another participant | its own stream | a violation | every chunk is salted by its stream's challenge and held to the declared address | +| a stored chunk, under implicit authorship | anyone | any stream | accepted once | what a store does; the binding's dedup rule, within its horizon | + **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),