From ab07ad40c1c1f654b9513d4bfe60116771da55fe Mon Sep 17 00:00:00 2001 From: acud <12988138+acud@users.noreply.github.com> Date: Mon, 14 Sep 2026 18:16:20 -0600 Subject: [PATCH 1/5] =?UTF-8?q?Add=20SWIP-draft:=20BPS-lite=20=E2=80=94=20?= =?UTF-8?q?single-publisher=20brokered=20broadcast?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A standalone single-publisher profile of the design space SWIP-60 (PR #104) covers: one broker, one publisher, N subscribers, one hop. A cohort is a topic and a topic is a SOC address, so authorship needs no credential, no publisher list and no authenticated handshake — the ANCHOR address check re-establishes it from the signature on every message. The publisher role is bound to the opening stream and transfers on re-Open; the cohort's lifetime is that stream, which removes orphan cohorts and bounds Open as an allocation primitive. Specified standalone rather than as a profile of SWIP-60, with the protobuf inline and no compatibility claimed in either direction. It answers three questions SWIP-60 leaves open (dedup horizon, cohort lifetime, Open as an unbounded allocation primitive) and drops `closed` rather than restating a confidentiality claim an unauthenticated handshake cannot support. The two specs share the pubsub/1.0.0 protocol id, so the handshake carries the compatibility story: SWIP-60's field numbers are reserved, not reused, which is what lets a BPS-lite broker refuse a full-spec Open outright instead of silently decoding it with fields dropped. Note the one divergence that can fail silently for implementers: BPS-lite's ANCHOR applies its address constraint unconditionally, where SWIP-60 relaxes it under an explicit publisher regime. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01E99KGsCW7dyKudy2WbpRco --- SWIPs/swip-draft_bps-lite.md | 469 +++++++++++++++++++++++++++++++++++ 1 file changed, 469 insertions(+) create mode 100644 SWIPs/swip-draft_bps-lite.md diff --git a/SWIPs/swip-draft_bps-lite.md b/SWIPs/swip-draft_bps-lite.md new file mode 100644 index 00000000..775b7252 --- /dev/null +++ b/SWIPs/swip-draft_bps-lite.md @@ -0,0 +1,469 @@ +--- +SWIP: +title: BPS-lite — single-publisher brokered broadcast +author: acud (@acud) +discussions-to: https://discord.gg/Q6BvSkCv +status: Draft +type: Standards Track +category: Networking +created: 2026-09-14 +--- + + + +## Simple Summary + +One broker, one publisher, many subscribers, one hop. A publisher opens a topic on a +broker and pushes single-owner chunks; everyone else connects and reads. Nothing else. + +## Abstract + +BPS-lite is a real-time broadcast protocol in which a cohort is a topic and a topic is +a single-owner chunk address. The peer that opens a cohort is its publisher for as long +as that stream lives; every other peer is a read-only subscriber. Authorship needs no +credential, no publisher list and no handshake authentication: under the `ANCHOR` +binding the topic already names the only key that can produce a chunk hashing to it, so +authorship is re-established from the signature on every message. The cohort's lifetime +is its publisher's stream, which removes orphan cohorts and bounds `Open` as an +allocation primitive. + +It is a deliberate subset of the design space [SWIP-60][swip60] covers, specified as +its own document rather than as a profile: the wire format below is complete, and where +BPS-lite differs from SWIP-60 — most importantly in making `ANCHOR`'s address +constraint unconditional — BPS-lite's rule governs. No compatibility is claimed in +either direction. + +## Motivation + +SWIP-60 spans five cohort shapes, four topic bindings, four publisher regimes, an admin +control plane and a history flag. That parameter space is the right target for the +reference implementation and the wrong one for a second, independent implementation: +most of it is untested surface for anyone whose application is one author broadcasting +to an audience — a live feed, a price ticker, a game's server-authoritative state, a +log tail. + +BPS-lite names the single point in that space which needs none of the machinery, and +shows that pinning it collapses the spec rather than merely constraining it. Three +questions SWIP-60 leaves open — the dedup horizon, cohort lifetime, and `Open` as an +unbounded allocation primitive — are answered here, and one property SWIP-60 claims +(`closed` as confidentiality over an unauthenticated handshake) is dropped rather than +restated. The result is a target roughly a tenth the size, which a second team can +implement and conformance-test without tracking the full spec's revisions. + +## Specification + +The key words MUST, MUST NOT, REQUIRED, SHOULD, RECOMMENDED and MAY are to be +interpreted as described in RFC 2119. + +### Roles and topology + +One broker node. One publisher peer. N subscriber peers. Every peer dials the broker +directly over a single libp2p stream per (peer, topic), protocol id +`pubsub/1.0.0` — the same id SWIP-60 uses, shared deliberately: see *Backwards +compatibility*. No relaying, no referral, no broker discovery: depth is 1 by +construction. + +The publisher and the broker are distinct peers. Brokering for oneself is out of scope. + +**The peer that opened a cohort is its publisher for as long as that stream lives.** +The role is a property of the stream, not of a field in the spec: subscriber streams are +read-only and the broker MUST NOT read `Publish` frames from them. + +### Wire format + +This section is normative and complete: BPS-lite is defined by the messages below and +by nothing else. No import, no companion `.proto`, no field inherited by reference. +Reserved field numbers are SWIP-60's, kept reserved so that a frame from a full-spec +implementation is refused rather than silently misread as a valid BPS-lite frame. + +```proto +// BPS-lite — single-publisher brokered broadcast. Complete wire format. +// +// 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 any message carrying one. + +syntax = "proto3"; + +package bps; + +// ---------------------------------------------------------------- cohort + +// What the topic binds to. BPS-lite has exactly one binding: the topic is the +// full SOC address. Unlike SWIP-60 the constraint is unconditional — there is +// no publisher regime that can relax it. +enum TopicBinding { + TOPIC_BINDING_UNSPECIFIED = 0; // invalid on the wire + ANCHOR = 1; // topic = SOC address; dedup on the wrapped CAC +} + +// Fixed by the cohort's opener; immutable for the cohort's lifetime. +// A cohort is a topic: there is no publisher field, because under ANCHOR the +// topic already names the only key that can author for it. +message CohortSpec { + bytes topic = 1; // 32B: the SOC address, keccak256(id || owner) + TopicBinding binding = 2; // must be ANCHOR + reserved 3, 4, 5, 6, 7, 8; // SWIP-60: publishers, history, admin, + // publisher_list, po_min, closed +} + +// ------------------------------------------------------------- handshake + +// Opener -> broker: the one peer that fixes the cohort. The stream carrying +// this frame is the cohort's publisher for as long as it lives. +message Open { + CohortSpec cohort = 1; + reserved 2; // SWIP-60: auth. BPS-lite authenticates nothing at + // handshake time; authorship is checked per message. + bool loopback = 3; // deliver the cohort's own Broadcast frames back +} + +// Joiner -> broker: names the topic — nothing more. Subscriber streams are +// read-only; there is no role to claim here. +message Subscribe { + bytes topic = 1; // 32 bytes + reserved 2; // SWIP-60: auth. A frame carrying it is REJECTED. +} + +// Peer -> broker: the first frame on a fresh stream. Open and Subscribe are +// otherwise indistinguishable on the wire — both encode as a single +// length-delimited field 1, and proto3's permissive unmarshalling makes a +// wrong guess succeed silently. +message Hello { + oneof handshake { + Open open = 1; + Subscribe subscribe = 2; + } +} + +enum Status { + STATUS_UNSPECIFIED = 0; // invalid on the wire + OK = 1; + FULL = 2; // broker at a capacity limit: subscribers, or cohorts + UNKNOWN_TOPIC = 3; // Subscribe for a topic with no live publisher + REJECTED = 4; // unacceptable spec, or a reserved field set +} + +// Broker -> peer, answering Open or Subscribe. One per stream; a non-OK Ack +// ends the stream. +message Ack { + Status status = 1; + CohortSpec cohort = 2; // set iff status == OK +} + +// --------------------------------------------------------------- traffic + +// A full single-owner chunk in transit. Every frame is self-contained. +message Soc { + bytes id = 1; // 32 bytes + bytes owner = 2; // 20 bytes + bytes signature = 3; // 65 bytes + bytes span = 4; // 8 bytes LE + bytes payload = 5; // wrapped-CAC data, <= 4096 bytes +} + +// Publisher -> broker. Accepted only on the stream that opened the cohort. +message Publish { + Soc soc = 1; +} + +// Broker -> peer. +message Broadcast { + oneof frame { + Soc soc = 1; + // 2-15 reserved in SWIP-60 for the multihop control plane; BPS-lite is + // singlehop and defines none of them. + } +} +``` + +Everything after the handshake is direction-typed and needs no envelope: peer to +broker is always `Publish`, broker to peer is always `Broadcast`. + +A cohort spec carries two live fields. There is no publisher field, no publisher +list, no publisher regime, no `history`, no `closed`, no `PO_MIN`. Per-topic capacity +is broker policy and is not on the wire at all. + +`Open` carries no credential: nothing at handshake time can prove control of a key, +so BPS-lite does not ask. `Subscribe` carrying the reserved field 2 is `REJECTED` — +there is no second publisher to authenticate, and no role to claim at handshake time. + +### Handshake + +Publisher-first. The publisher fixes the cohort; subscribers join it. + +``` +publisher broker + | Hello{Open{spec, loopback}} | + |------------------------------------->| validate spec; create cohort; + | | this stream is the publisher + | Ack{OK, spec} | + |<-------------------------------------| + | Publish{soc} ... | + | Broadcast{soc} ... | iff loopback was requested + |<-------------------------------------| + +subscriber broker + | Hello{Subscribe{topic}} | + |------------------------------------->| UNKNOWN_TOPIC if no publisher has opened + | Ack{OK, spec} | + |<-------------------------------------| + | Broadcast{soc} ... | +``` + +Rules: + +- A non-`OK` `Ack` ends the stream. On `OK` the stream is retained. +- A `Subscribe` carrying reserved field 2 (SWIP-60's `auth`) is `REJECTED`. +- **`Open` on a live topic with an identical spec transfers the publisher role to the + new stream**: the broker answers `OK`, retains the cohort and its dedup window, and + resets the previous publisher stream. A publisher recovering from a dropped + connection therefore needs no distinct reattach frame, and a stale half-open stream + closing later is not the current publisher stream and has no effect. An `Open` with + a different spec for a live topic is `REJECTED`. +- A subscriber arriving before the publisher gets `UNKNOWN_TOPIC` and retries. The + broker holds no pre-registration state. +- A peer receiving `UNKNOWN_TOPIC` or `FULL` MUST wait before retrying: randomized + exponential backoff from 1s to 30s, full jitter. A broker MAY reset a stream from a + peer that retries faster. Without this, every publisher restart resets N subscriber + streams at once and they all return immediately. +- The publisher's own stream receives the cohort's `Broadcast` frames only if it asked + for them (`Open.loopback`, default false). See *Capacity and slow peers* for why it + is opt-in. + +### Message validation + +A `Publish` is accepted iff, in order: + +1. it arrived on the stream that opened the cohort; +2. the `Soc` decodes and its signature recovers an owner; +3. the SOC address, `keccak256(id || owner)`, equals `spec.topic` — the `ANCHOR` rule; +4. the wrapped CAC's BMT root matches its payload; +5. the CAC address is not in the cohort's dedup window. + +Failures 1–4 are protocol violations: the message is dropped, counted per peer, and +exposed through the implementation's invalid-message hook. Failure 1 additionally +resets the offending stream. No blocklisting is mandated. Failure 5 is ordinary — a +retransmitting publisher — and is counted separately. + +Note what is *not* here: no comparison of the recovered owner against a declared +publisher. Under `ANCHOR` the topic is a SOC address, so step 3 already constrains the +owner — producing a SOC that hashes to `spec.topic` under a different key is a keccak +preimage. An owner check would be a second test that can only fail where step 3 has +already failed. + +On acceptance the broker enqueues a `Broadcast` on every subscriber stream in the +cohort, and on the publisher's own stream if it requested loopback. + +### Cohort lifetime + +**A cohort exists exactly as long as its current publisher's stream.** When that +stream closes or resets, the broker destroys the cohort and resets every subscriber +stream. Subscribers reconnect and receive `UNKNOWN_TOPIC` until the publisher returns. +A stream that has been superseded by a role transfer is not the current publisher +stream: its later close is a no-op. + +This is the largest simplification the single-publisher restriction buys. It removes +orphan cohorts and any reclamation policy, and it bounds `Open` as an allocation +primitive: a peer can hold only as many live cohorts as it holds open streams. + +### Dedup + +A bounded LRU of accepted CAC addresses, per cohort. Implementations MUST bound it; +1024 entries is RECOMMENDED. A role transfer retains the window. + +Replay is prevented by the stream-role rule, not by the window: a captured `Broadcast` +re-`Publish`ed by an observer carries the genuine publisher's signature and passes +steps 2–4, but is refused at step 1 because it arrives on a subscriber stream — +whatever its age. The window bounds duplicate suppression for a retransmitting +publisher only, and 1024 is chosen against that benign case. + +### Capacity and slow peers + +Per-topic subscriber capacity is broker policy, not a cohort parameter. At the limit +the broker answers `FULL` and nothing else; referral is bps-multihop's business. +A broker MUST also bound the number of live cohorts it will create; the lifetime rule +above makes that bound enforceable, and an `Open` at that bound is answered `FULL`. + +Each retained subscriber stream has a bounded outbound queue (64 frames RECOMMENDED) +drained by a single writer. A full queue resets that stream. The broker has no +delivery obligation: withholding is a recoverable liveness fault, not a correctness +failure, and blocking fan-out on one slow subscriber would punish the cohort. + +The publisher's stream is exempt from that reset. It is the cohort's lifetime, so a +policy that is merely lossy for a subscriber is fatal for everyone: a publisher that +does not drain its own loopback echo — a fast publisher, a single-threaded client, a +bridge applying backpressure — would otherwise destroy its own cohort. A full queue on +the publisher's stream drops the frame and increments a counter. Loopback is opt-in for +the same reason. + +Loopback is an echo of *accepted* messages, not delivery confirmation: rejected +`Publish` frames are dropped silently and produce no frame at all, so a publisher +cannot distinguish rejection from delivery by watching its echo. An acknowledged write +path would be an `Ack`-per-`Publish` design, which BPS-lite does not have. + +### Status codes + +| condition | status | +|---|---| +| handshake accepted | `OK` | +| broker at a capacity limit — per-topic subscribers for `Subscribe`, live cohorts for `Open` | `FULL` | +| `Subscribe` for a topic with no live publisher | `UNKNOWN_TOPIC` | +| unacceptable spec, differing spec on re-`Open`, a reserved field set | `REJECTED` | + +A `STATUS_UNSPECIFIED` or any other zero enum value received on the wire is rejected: +the proto's zero values are deliberately not legitimate wire values. + +## Rationale + +### Authorship without a credential + +BPS-lite has no publisher field and no handshake credential. A cohort's topic is a SOC +address; the only key that can produce a SOC hashing to it is the one that owns it. +Authorship is a property of the topic, established on the write path by step 3 and +re-checked on every message, and the handshake asserts nothing. + +Two consequences worth stating. Anyone may `Open` any topic, including a topic whose +key they do not hold — but such a cohort can never accept a message, and the genuine +publisher's `Open` takes the role from them under the transfer rule above, so squatting +costs the squatter a stream and buys nothing. And BPS-lite makes no confidentiality +claim: broadcasts are readable by any subscriber, audience control is the application's +business, and SWIP-60's `closed` flag — which does make such a claim on an +unauthenticated handshake — is deliberately absent. + +### Differences from SWIP-60 + +Non-normative, for readers arriving from the full spec. Three things differ: + +- **No `PublisherRegime`, and no publisher set.** SWIP-60's `publishers`, `admin`, + `publisher_list`, `history` and `closed` have no counterpart. `CohortSpec` is + `{topic, binding}`. +- **`ANCHOR`'s address constraint is unconditional.** SWIP-60 relaxes it under an + explicit publisher regime, where the topic becomes a rendezvous and legitimacy comes + from list membership instead. BPS-lite has no list, so the constraint always applies. + This is the trap for anyone reusing SWIP-60's `anchorBinding` unmodified: its + explicit-regime early return would leave a BPS-lite cohort with no address check at + all. +- **The publisher role is bound to the opening stream**, and transfers on re-`Open`. + SWIP-60 leaves cohort lifetime and reattachment unspecified. + +The first two are what make the spec small; the third is what makes it operable. Each +is a divergence, not a restriction: a SWIP-60 implementation pointed at a BPS-lite +cohort would accept messages BPS-lite refuses, and a SWIP-60 client's `Open` carries +fields BPS-lite reserves. Hence a separate document — but not a separate protocol id. +BPS-lite and SWIP-60 are two specifications of one protocol, `pubsub/1.0.0`, and a peer +dials it without knowing which it will meet. The first frame settles that: a BPS-lite +broker answers a SWIP-60 `Open` with `REJECTED`, because every field the fuller spec +adds is a field this one reserves. Refusal at the handshake is the compatibility story, +and it is why those numbers are reserved rather than reused. + +### Why the publisher field is absent + +Under `ANCHOR` the topic is `keccak256(id || owner)`. Comparing a message's recovered +owner against a declared publisher is then a test that can only fail where the address +check has already failed, short of a keccak preimage. Carrying the field would cost a +20-byte field, a validation step, an owner comparison on re-`Open`, and — because the +field is asserted and never proved — a paragraph explaining what it does not mean. The +topic does the work; the field is deleted. + +### Why loopback is opt-in + +The publisher's stream is the cohort's lifetime, so the queue-overflow policy that is +merely lossy for a subscriber is fatal for the whole cohort. A publisher that does not +drain its own echo would destroy what it is publishing to. Making the echo opt-in and +exempting that stream from the reset removes a failure mode that has no analogue on the +subscriber side. + +## Backwards compatibility + +BPS-lite introduces no incompatibility with any deployed protocol: Swarm has no +broadcast pub/sub in production. + +It shares the `pubsub/1.0.0` protocol id with [SWIP-60][swip60] while **not** being +wire-compatible with it. That is a deliberate choice, and it puts the whole weight of +the compatibility story on the first frame of a stream: + +- A SWIP-60 `Open` sets `CohortSpec` fields 3–8 and `Open` field 2, all of which + BPS-lite reserves, so it is `REJECTED` rather than silently decoded with those fields + dropped — which is what proto3's permissive unmarshalling would otherwise do, leaving + a peer that asked for a five-author closed cohort holding an open single-publisher + one. The reserved numbers are what make the refusal reliable, and they are the reason + a BPS-lite broker MUST reject a frame carrying them rather than ignore it. +- A BPS-lite `Open` is a valid SWIP-60 `Open` — `{topic, binding}` with everything else + unset. A SWIP-60 broker would accept it, and then not enforce the address constraint + (see the next bullet). A BPS-lite publisher therefore gets weaker guarantees from a + full-spec broker than from a conformant one, silently. Applications that depend on + the `ANCHOR` rule should treat an unexpected `Ack` shape as a mismatch. +- BPS-lite's `ANCHOR` applies its address constraint unconditionally; SWIP-60 relaxes + it under an explicit publisher regime. An implementation reusing SWIP-60 `ANCHOR` + code unmodified would perform no address check at all under BPS-lite, which is the + one silent failure this divergence can cause. It is called out again in *Differences + from SWIP-60* above. + +Should BPS-lite and SWIP-60 later converge, the reserved field numbers leave the +migration path open: a future revision can un-reserve them with their SWIP-60 meanings +intact. + +## Test cases + +An implementation is BPS-lite conformant if it exhibits all of: + +1. `Open` with `binding != ANCHOR` → `REJECTED`. +2. `Open` carrying a reserved field, or a `CohortSpec` carrying one → `REJECTED`. +3. `Subscribe` carrying reserved field 2 → `REJECTED`. +4. `Subscribe` before any `Open` → `UNKNOWN_TOPIC`. +5. Re-`Open` on a live topic with an identical spec → `OK`; the prior publisher stream + is reset; the cohort and its dedup window survive. A differing spec → `REJECTED`. +6. A superseded publisher stream closing after a role transfer → cohort unaffected. +7. Current publisher stream closes → every subscriber stream reset, topic subsequently + `UNKNOWN_TOPIC`. +8. `Publish` whose SOC address is not `spec.topic` → dropped and counted. +9. `Publish` on a subscriber stream → dropped, counted, and that stream reset. +10. A duplicate CAC within the dedup window → dropped, counted separately. +11. Subscriber arriving at per-topic capacity → `FULL`, with no referral frame. `Open` + at the live-cohort bound → `FULL`. +12. Publisher stream outbound queue full → frame dropped, stream retained, cohort alive. +13. `Open` without `loopback` → the publisher receives no `Broadcast` frames. + +A broker MUST expose per-cohort counters for the silent-drop outcomes — +`invalid_address`, `invalid_signature`, `invalid_cac`, `wrong_stream`, `duplicate`, +`queue_dropped`. Points 8–10 and 12 are unobservable from the wire without them, so the +counters are part of the conformance surface rather than an implementation detail. + +## Implementation + +Groundwork exists in the bee prototype `pkg/bps` ([bee #5435][bee5435]), written against +SWIP-60. It is a superset of this spec in most places and diverges in one: + +`pkg/bps` is a superset of this spec in some places and diverges in one: it implements +`ANCHOR` and `FEED_TOPIC` bindings, `EXPLICIT_SINGLE` and `EXPLICIT_LIST` regimes, the +`Hello` envelope and a WS bridge. Most of the work is subtraction: + +| file | under BPS-lite | +|---|---| +| `publisher.go` | `authorizePublisher` goes entirely — there is no publisher to authorize against, only the topic | +| `binding.go` | `anchorBinding` only; the registry stays as the extension seam with one entry. `qualifies` **loses its explicit-regime early return**: the address check becomes unconditional | +| `cohort.go` | `Publishers`, `sortedCopy`, the regime switch in `ValidateSpec` and the publisher-list branch of `SpecEqual` go; `ValidateSpec` checks two fields | +| `broker.go` | `admit` sets the publisher role from the `Open` path rather than from `auth != nil`; the `PublisherAuth`-is-not-a-credential commentary goes with it | +| `broker.go` | gains a `publisher *peerStream` on the cohort: teardown fires only when the *current* publisher stream closes, and re-`Open` transfers it | +| `broker.go` | gains the queue-reset exemption for the publisher's stream | + +Two behaviors are new — the lifetime rule and role transfer. Everything else is removal. + +[swip60]: https://github.com/ethersphere/SWIPs/pull/104 +[bee5435]: https://github.com/ethersphere/bee/pull/5435 + +## References + +Parent design space: [SWIP-60 "BPS singlehop — brokered broadcast pub/sub, base +protocol", PR #104][swip60] · origin: +[PR #93](https://github.com/ethersphere/SWIPs/pull/93) "Add: pubsub" · implementation: +bee [#5435][bee5435] + +## Copyright + +Copyright and related rights waived via [CC0](https://creativecommons.org/publicdomain/zero/1.0/). From 52444a3f9610679614368e824a3706b5ae3f66d9 Mon Sep 17 00:00:00 2001 From: zelig Date: Tue, 22 Sep 2026 16:12:37 +0200 Subject: [PATCH 2/5] =?UTF-8?q?SWIP-074:=20BPS-lite=20=E2=80=94=20rewrite?= =?UTF-8?q?=20as=20the=20base=20of=20the=20BPS=20family,=20single=20publis?= =?UTF-8?q?her=20over=20a=20feed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrite of the first draft after review. BPS-lite is now a strict subset/base of SWIP-60 rather than a divergent profile: the live-stream configuration with its wire reduced to what it needs. - cohort spec = {topic, FEED_TOPIC, admin}, and the spec is the cohort's identity - one handshake frame, Join{CohortSpec, Auth?}: create-or-attach, anyone may do either; no Hello/Open/Subscribe, no UNKNOWN_TOPIC - Auth binds the identity to the stream, not the connection; a stream whose Auth recovers to admin is a publisher stream, every other stream is read-only - Ack{status} only: OK / FULL / REJECTED - one Message{soc} frame both directions; the chunk as opaque chunk data with the bare index in the id slot (SWIP-65 carriage) - validation: publisher stream, index > cursor, valid SOC under keccak256(topic||index), owner == admin; no dedup window, gaps allowed, retransmits counted not punished - no loopback: deliveries to subscriber streams only - lifetime by inactivity only — no service messages, no end-of-stream - broker bounds: subscribers/cohort, cohorts/broker, cohorts/peer connection, inactivity deadline, per-stream queue - no history, no bandwidth incentive, no Bee API, one hop, one mode; implementation section removed - "Relation to SWIP-60" lists the amendments SWIP-60 needs to extend this wire (Join, cohorts keyed by spec, no GENESIS, regime reduced to ALL, spectators polarity, opaque chunk) and the one-line SWIP-65 exception - number assigned: 74; file renamed to swip-74.md One open point remains, marked (?): a second stream authenticating as the admin while one is live. Co-Authored-By: Claude Fable 5.1 --- SWIPs/swip-74.md | 474 +++++++++++++++++++++++++++++++++++ SWIPs/swip-draft_bps-lite.md | 469 ---------------------------------- 2 files changed, 474 insertions(+), 469 deletions(-) create mode 100644 SWIPs/swip-74.md delete mode 100644 SWIPs/swip-draft_bps-lite.md diff --git a/SWIPs/swip-74.md b/SWIPs/swip-74.md new file mode 100644 index 00000000..83ac8eee --- /dev/null +++ b/SWIPs/swip-74.md @@ -0,0 +1,474 @@ +--- +SWIP: 74 +title: BPS-lite — single-publisher live stream over a feed, one broker, one hop +author: Elad Nachmias (@acud), Viktor Trón (@zelig) +discussions-to: https://discord.gg/Q6BvSkCv +status: Draft +type: Standards Track +category: Networking +created: 2026-09-14 +--- + + + +- **Business line**: a live stream on Swarm — one author, an audience, real time, no + storage round trip and no polling: video/audio streaming, a price ticker, a game's + server-authoritative state, a log tail. The stream is a feed, so the same updates can + later be persisted and replayed from storage by anyone who missed the live run. +- **Dev line**: implement one libp2p protocol, `pubsub/1.0.0`, with the three frames + below — one broker, direct streams, one authenticated publisher, read-only + subscribers — and nothing else: **no history, no bandwidth incentive, no service + messages, no Bee API, one hop, one mode.** Done when a broker, a publisher and + subscribers from independent implementations interoperate per the conformance + section. Everything the family adds — service messages and the Bee API, multiple + publishers, self-indexed feeds, multihop — extends this wire without changing it. +- **DISC change**: NO. A new p2p protocol surface; no storage, retrieval or incentive + change. + +## Simple Summary + +BPS-lite is a real-time broadcast protocol: a kind of push notification on a *channel* +that anyone can subscribe to and one participant can publish on. Each channel defines +its own cohort — a set of nodes connected through a unique central broadcaster node, the +**broker**; every node in the cohort is directly connected to the broker, hence +*singlehop*. A channel is identified by its spec — a feed topic and the address of its +**admin**. Anyone joins by sending that spec; the admin also sends a signature that binds +its identity to the stream, and has the exclusive right to publish. Its messages are +single-owner chunks that are the higher-index updates of the feed the topic names, +carried with their bare index; the broker accepts each one iff its index exceeds the +channel's cursor, it validates as a single-owner chunk under the feed's id and its owner +is the admin, and delivers it unchanged to every subscriber stream — never to a +publisher's. All other joiners are subscribers and verify the same way, end to end: the +broker can withhold, never forge. A channel ends when it goes idle. + +## Motivation + +SWIP-60 spans several cohort configurations, five topic bindings, a roster control plane +on a service feed, a history flag and the Bee API. That is the right target for the +reference implementation and too much for a second, independent one whose application +is one author broadcasting to an audience. BPS-lite names that one configuration and +specifies only what it needs, so that a second team can implement and conformance-test +it from three frames — and so that it stays the **base** of the family: nothing here is +a divergence to reconcile later, because the fuller protocol extends these frames. + +One publisher over a feed also removes what the fuller protocol has to carry: a dedup +window (the feed cursor replaces it), a roster and the service feed that carries it (there +is nothing to announce), a publisher regime (there is one publisher), and any question of +who may write (the admin, proved by its signature on every message). + +## Specification + +The key words MUST, MUST NOT, REQUIRED, SHOULD, RECOMMENDED and MAY are to be interpreted +as described in RFC 2119. Terms — cohort (a channel's set of nodes), broker, admin, subscriber — are SWIP-60's. + +### The contract + +Per cohort: messages come from **the admin, and the admin only**; they arrive at **all +subscribers**; they are **feed updates, in increasing index order**. + +### Topology and roles + +- **Broker**: one full node per cohort. Every peer holds one direct libp2p stream per + (peer, cohort) to it, protocol id `pubsub/1.0.0`. Depth is 1 by construction: no + relaying, no referral. Broker discovery is out of scope; deployments configure the + broker. +- **Admin = the publisher**: the address named in the cohort spec. It is the only + identity whose messages are accepted. It need not be first to join, and the cohort + does not end when its stream goes away. +- **Subscriber**: joins by naming the cohort, receives, never sends. A message from a + subscriber stream is a protocol violation. +- **Publisher and broker are distinct nodes**; brokering for oneself is out of scope. + +### The cohort spec + +Three fields, no others: + +| field | value | +|---|---| +| `topic` | 32 bytes: the feed topic | +| `binding` | `FEED_TOPIC`: the signed id of every message is `keccak256(topic ‖ index)` | +| `admin` | 20-byte address: the publisher | + +**The spec is the cohort's identity.** Two peers naming byte-identical specs at a broker +are in the same cohort; a spec that differs in any field names a different one. The +invite for a stream is therefore the spec plus the broker: `topic`, `admin`, broker +address. No publisher regime, no roster, no spectator flag, no history flag, no +proximity constant: a BPS-lite broker answers any spec outside this table, and any +reserved field set, with `REJECTED`. Capacity is not in the spec: a cohort cannot +dictate a remote node's connection count. + +### Wire format + +Normative and complete. Field and enum numbers are SWIP-60's where a counterpart exists, +so that the fuller protocol extends these messages without renumbering; numbers it uses +and this SWIP does not are reserved, never reused. An unset enum (`*_UNSPECIFIED`, the +proto3 zero) is not a legitimate wire value: receivers MUST reject a message carrying one. + +```proto +syntax = "proto3"; +package bps; + +// What the topic binds to. One binding. +enum TopicBinding { + TOPIC_BINDING_UNSPECIFIED = 0; // invalid on the wire + FEED_TOPIC = 4; // signed id = keccak256(topic || index). SWIP-60's + // number; 1, 2, 3, 5 are SWIP-60's other bindings +} + +// The cohort's identity. Fixed by whoever joins first; immutable. +message CohortSpec { + bytes topic = 1; // 32 bytes + TopicBinding binding = 2; // FEED_TOPIC + bytes admin = 5; // 20 bytes, required + reserved 3, 4, 6, 7, 8, 9; // SWIP-60: publishers, history, (unused), (unused), + // (unused), spectators — none apply here +} + +// Proof of an identity, bound to the stream it arrives on: +// owner = ecrecover( keccak256("bps-join:v1" || topic || admin), signature ) +// The preimage is static and carries no node identity (SWIP-60): the same key works +// from any node, and a join never links an address to a peer id. It is therefore +// replayable, and a replayed identity is worthless: authorship rests on the message +// signature, never on the handshake. +message Auth { + bytes signature = 1; // 65 bytes + reserved 2; // SWIP-60: id, for bindings that do not fix it +} + +// Peer -> broker: the first and only handshake frame on a fresh stream. Creates the +// cohort if no live cohort has this spec, attaches to it otherwise. `auth` binds an +// identity to THIS stream; absent, the stream has none and is read-only. +message Join { + CohortSpec cohort = 1; + Auth auth = 2; +} + +enum Status { + STATUS_UNSPECIFIED = 0; // invalid on the wire + OK = 1; + FULL = 2; // a capacity bound (see Resource bounds) + REJECTED = 4; // spec outside this SWIP, or a reserved field set + reserved 3; // SWIP-60: UNKNOWN_TOPIC — cannot occur: Join creates +} + +// Broker -> peer, answering Join. Status only. A non-OK Ack ends the stream. +message Ack { + Status status = 1; + reserved 2, 3, 4, 5; // SWIP-60: spec echo and service SOCs — nothing to echo +} + +// Both directions after the handshake: admin -> broker is a publication, broker -> +// subscriber 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 +// once the `id` slot has been rewritten as the Frames section says: +// id (32) || signature (65) || span (8, LE) || payload (<= 4096) +message Message { + bytes soc = 1; +} +``` + +Three frames — `Join`, `Ack`, `Message` — and the two types they carry, `CohortSpec` and +`Auth`. There is no envelope around `Join` — it is the only first frame there is — and no +separate publish and broadcast types: a broker pushes to subscribers, a non-broker does +not. Fields this SWIP does not define are ignored on receipt, as proto3 does; a `Message` +whose `soc` is empty is invalid. + +### Handshake: `Join` + +Every peer sends `Join` as the first frame on a fresh stream, carrying the full +`CohortSpec` and, if it claims an identity, an `Auth`. + +The broker compares the spec against its live cohorts: **no match → the cohort is +created** with the joiner attached; **match → the joiner is attached** to it. Anyone may +create, including a subscriber arriving before the admin: a publisher-less cohort costs +the broker one map entry until the inactivity deadline reclaims it, and can accept no +message. There is nothing to pre-register and no "unknown topic". + +Then the stream's role, from `Auth`: + +| `Auth` | the stream is | +|---|---| +| absent | a **subscriber stream**, no identity | +| recovers to `spec.admin` | a **publisher stream** | +| recovers to any other address | a **subscriber stream** carrying that identity — no effect in this SWIP | + +**The identity is bound to the stream, not to the peer connection.** One stream per +(peer, cohort); a node may present different identities on different streams, and a +stream without `Auth` has none. **(?)** What if a second stream authenticates as the +admin while a first one is live — the admin from two nodes, or reconnecting before its +old stream is torn down? This draft admits both as publisher streams and lets the +per-cohort cursor arbitrate (neither can publish an index the other already has); the +alternatives are that the new stream supersedes the old (reset), or that the second is +`REJECTED`. + +The broker answers `Ack{status}`: `OK`, `FULL` at a capacity bound, `REJECTED` for a spec +outside this SWIP or a reserved field set. A non-`OK` `Ack` ends the stream; on `OK` the +stream is retained. A peer whose stream was refused or reset MUST back off before +rejoining (randomised exponential, 1 s to 30 s, full jitter); a broker MAY reset a stream +that retries faster. Without this every reclaim or reset of a cohort brings its whole +audience back at once. + +### Frames + +After the handshake there is one frame, `Message`, and it is direction-typed: on a +publisher stream towards the broker it is a publication; from the broker on a subscriber +stream it is a delivery. A delivery is the accepted publication's bytes, unchanged. + +**The `id` field carries the bare index.** A feed update's signed id is +`keccak256(topic ‖ index)`, `index` a uint64 big-endian in 8 bytes. On the wire the +32-byte `id` slot of the chunk data holds that index left-padded with 24 zero bytes, and +the receiver reconstructs the signed id from the cohort's topic (the carriage of +[SWIP-65](https://github.com/ethersphere/SWIPs/pull/106)). A `Message` whose `id` slot +does not have 24 leading zero bytes is not a feed update: a broker drops it as invalid; a +subscriber drops it without counting it as a violation. + +### Validation: the feed cursor + +The broker keeps one **cursor** per cohort — the highest index accepted so far, initially +none. A `Message` arriving at the broker is accepted iff, in order: + +1. it arrived on a **publisher stream** — on any other stream it is a protocol + violation: the frame is dropped, the stream reset and the peer blocklisted per the + node's policy; +2. its `id` slot is a bare index `n` and **`n > cursor`**; +3. with the id substituted by `keccak256(topic ‖ n)` the chunk **validates as a + single-owner chunk**: the wrapped chunk's BMT address matches `span ‖ payload`, and + the signature over `id ‖ wrappedAddress` recovers an owner; +4. the recovered owner is **`spec.admin`**. + +On acceptance the broker sets `cursor := n` and enqueues the frame on **every subscriber +stream** in the cohort. Publisher streams receive nothing: a publisher never gets its own +messages back, on whichever of its streams they were sent. + +Failures of 3 and 4 are invalid frames: dropped and counted; repeated invalid frames end +the connection (blocklisting policy). A frame failing 2 with `n ≤ cursor` is the one +benign failure — a retransmit, which an admin reconnecting after a reset legitimately +sends when it does not know what the broker last accepted — and is counted separately; a +broker MAY reset a publisher stream whose retransmit rate exceeds its policy, since the +cursor check is cheap and precedes the signature check. A frame failing 2 because the +`id` slot is not a bare index is invalid. + +The cursor is initially **absent, not zero**: index 0 is the first update of every feed +and MUST be accepted on a fresh cohort. + +**There is no dedup window.** The cursor is total: a message is either beyond the last +accepted index or it is not, and there is no eviction and no replay hole. **Gaps are +allowed** at the broker (`n > cursor + 1`): they are the publisher's business, and +SWIP-65 makes them detectable and recoverable at the subscriber. This is stricter than +SWIP-65's general carriage rule, under which a broker does not enforce monotonicity; the +restriction is sound here because one publisher on one hop admits no legitimate +reordering. + +Subscribers re-verify every delivery exactly as the broker does (steps 2–4, against the +spec they joined with and their own cursor), end to end, whatever the broker did. + +### Lifetime: inactivity + +**A cohort ends by inactivity, and by nothing else.** A cohort on which no publisher +stream has had a message accepted for the **inactivity deadline** is reclaimed: the +broker resets every stream in it and forgets it. There is no end-of-stream signal in +BPS-lite — that is a service message, and this SWIP has none; an application that needs +"over" to be distinguishable from "paused" sends it in its last update, or waits for the +service feed the family adds. + +A publisher stream going away — closed or reset — is therefore not an end: the cohort +and its cursor persist, subscribers stay attached, and the admin rejoins with `Auth` and +resumes at `n > cursor`. Likewise a subscriber-only cohort waits for its admin until the +deadline reclaims it. A cohort with **no attached streams** MAY be reclaimed at once — +nothing observes the difference beyond a fresh cursor on the next `Join`. + +After a cohort is forgotten a later `Join` creates a fresh one with an empty cursor. +Subscribers keep their own cursor across that, so a stale republication is caught at the +edge, not at the broker. + +### Resource bounds + +All broker policy, none on the wire. The first four bounds are REQUIRED, with the values +given RECOMMENDED where a value is given; the last two are MAY: + +| bound | answer | recommended | +|---|---|---| +| subscriber streams per cohort | `FULL` to the next `Join` without an admin `Auth` — the audience cannot lock the admin out of its own cohort | implementation-defined | +| live cohorts per broker | `FULL` to a cohort-creating `Join` | implementation-defined | +| **cohorts per peer connection** — a peer cannot flood the broker with bogus cohorts while keeping one legitimate stream open | `FULL` | 16 | +| **inactivity deadline** — reclaims a cohort, see *Lifetime* | reset | 10 min | +| outbound queue per subscriber stream, one writer; a full queue resets that stream | reset | 64 frames | +| a peer connection with no live stream | MAY be disconnected at the transport | — | +| a cohort with no attached streams | MAY be reclaimed at once | — | + +Any peer can make a broker allocate a cohort simply by joining, which is why the second, +third and fourth bounds exist together: a cohort costs a map entry, a peer can hold only +a bounded number of them, and none survives idleness. The broker has no delivery +obligation: a reset for a full queue is a recoverable liveness fault, and blocking +fan-out on one slow subscriber would punish the cohort. + +### Relation to SWIP-60 + +BPS-lite is the **base** of the BPS family and SWIP-60 is its full singlehop protocol. +The relation is subset, and it is kept in one direction only — **later revisions extend +this wire, they never change it**: + +- a BPS-lite peer sends only frames the full protocol accepts, and a BPS-lite broker + accepts only what this SWIP defines; everything the full protocol adds to the + handshake arrives in fields this SWIP reserves, so a full-protocol `Join` at a + BPS-lite broker is `REJECTED` at the handshake rather than silently misread, and + everything it adds to `Message` arrives in fields this SWIP does not define and + ignores; +- a BPS-lite publisher and subscriber at a full broker are conformant peers of a + single-publisher live-stream cohort; the full broker's additions (service messages, + further `Ack` fields) reach them as frames they drop and fields they ignore; +- validation here is stricter, never looser: the cursor refuses out-of-order + retransmits a full broker may pass through; no frame a BPS-lite broker accepts is one + a full broker refuses. + +SWIP-60 is to be amended to match — its author's to-do, listed here so that the subset +claim is checkable: + +- `Hello`/`Open`/`Subscribe` collapse into `Join` carrying the spec, and **cohorts are + keyed by the whole spec, not by the topic**: a `Join` whose topic is live under a + different spec creates a second cohort rather than being `REJECTED`, so a squatter + who pre-creates `(topic, wrong admin)` obtains nothing; +- `GENESIS` goes (with the spec in `Join` it has nothing left to prove) and `Ack` echoes + nothing; +- the publisher regime shrinks to a single value, `ALL` — anyone attached may publish; + absent, the admin publishes and whoever its roster ever names, so a cohort is + multi-publisher iff its admin publishes a roster, and nobody needs to know in advance; +- **`spectators` (field 9) goes, or reverts to a `closed` flag whose unset value means an + open audience** — as a proto3 bool whose unset value is *false*, it reads a lite + spec, which never sets it, as a closed cohort, and a full broker would refuse every + lite subscriber; +- the chunk travels as opaque chunk data. + +And one line in SWIP-65: its carriage rule, under which a broker does not enforce index +monotonicity, gets the exception this SWIP's cursor is. + +## Rationale + +**Why a feed, not an anchor.** The first draft bound the topic to a single SOC address +(`ANCHOR`), so that authorship followed from the topic and no publisher field was +needed. That collapses the wrong thing: one address for every message means messages +are told apart only by their wrapped payload, dedup needs a window, order is invisible, +and nothing connects the live stream to storage. Binding to a feed keeps the publisher +explicit — one address in the spec, one owner check per message — and buys order, gap +detection, replay-freedom and persistence for the same money: the stream *is* a feed, +live. + +**Why `Join` carries the spec, and there is only `Join`.** A joiner that arrives with a +topic and learns the spec from the broker has to be told the spec by someone it then +has to verify. For a live stream the spec is a topic and an address; the invite that +names the broker names them too. Once every joiner carries the spec, the spec is the +cohort's identity, the broker is never trusted about it, `Ack` is a status, the +first-joiner-creates rule is the natural one — and a second handshake type has nothing +to do. + +**Why the identity is on the stream.** `Auth` is in `Join`, and what it proves is bound +to the stream it arrives on. A peer connection is a node; a stream is a (node, cohort) +pair; the identity that matters is the key that signs the messages on that stream. Binding +it there is what lets one node carry different identities on different cohorts, and what +later lets a subscriber stream be promoted to a publisher stream in place, without a +rejoin, when the admin grants it. + +**Why no loopback.** A publisher knows what it published. Echoing it costs a frame per +message on the stream whose back-pressure matters most, and buys no confirmation: a +rejected frame produces no echo either. + +**Why the cursor, not a window.** A bounded seen-set is a memory bound with a replay +hole at its edge, and it exists because the general protocol admits many publishers and +duplicate paths. One publisher on one hop has neither. The feed index is a total order, +and "greater than the last" is both the dedup rule and the memory bound. + +**Why inactivity, and only inactivity.** An end that is *signed* by the admin is a service +message, which this SWIP does not have; an end that is *inferred* from the admin's stream +turns every transport hiccup into a stream-ending event for the whole audience. So there +is no end: a cohort is a map entry that lives while it is used and is reclaimed when it is +not. What that gives up — "over" versus "paused" — is the application's to carry until the +service feed arrives. + +## Security considerations + +Those of SWIP-60, restricted to its live-stream configuration (admin set, the admin +alone publishes, open audience). + +**The publisher role is proved, not asserted**: `Auth` recovers to `admin`, and it grants +nothing the admin's key has not already granted. Its preimage is static, so it is +replayable: a peer that captured the admin's `Join` can obtain a publisher stream, but +can send on it only what the admin already signed. Within one cohort's life the cursor +refuses that (every captured index is at or below it); **across a reclaim or at another +broker the cursor starts empty, and the base protocol does not distinguish a replay of +the admin's history from the admin's history**. Freshness is therefore a subscriber-side +property: a subscriber SHOULD keep its cursor per `(topic, admin)` across rejoins, and an +application that needs freshness for a first-time viewer carries it in the payload +(SWIP-65's timestamp key does). Fan-out of a replayed history is bounded by what was +captured and by the inactivity deadline. + +**No confidentiality**: the broker and every subscriber see plaintext; applications +encrypt payloads. **The broker withholds, never forges**, and a withheld update is +visible as a gap in the index. **No end signal**: a broker can end a cohort for its +audience by resetting their streams, which is withholding, nothing more. **Resource +bounds are policy and the four capacity bounds are required** (above); the cursor +removes the dedup-window bound and its edge. A subscriber that publishes is a protocol +violation and is blocklisted; a squatted cohort is one map entry for one inactivity +deadline, and a peer can hold only a bounded number of them. + +## Conformance (definition of done) + +An implementation is BPS-lite conformant when: + +1. a broker accepts a `Join` whose spec is `{topic, FEED_TOPIC, admin}` and answers any + other binding, an absent `admin`, or any reserved field set with `REJECTED`; +2. a `Join` for a spec with no live cohort creates it, whoever sends it; a `Join` with a + byte-identical spec attaches to it; the broker never sends status 3; +3. `Auth` is verified by recovery over `keccak256("bps-join:v1" ‖ topic ‖ admin)`; a + stream whose `Auth` recovers to `admin` is a publisher stream; any other stream is a + subscriber stream; the identity is a property of the stream; +4. `Ack` carries a status and nothing else; a non-`OK` `Ack` ends the stream; +5. a `Message` on a subscriber stream is dropped, the stream reset, the peer blocklisted; +6. a `Message` on a publisher stream is accepted iff its bare index exceeds the cursor, + it validates as a single-owner chunk under `keccak256(topic ‖ index)`, and its owner + is `admin`; accepted frames advance the cursor and are delivered unchanged to every + subscriber stream in the cohort, and to no publisher stream; +7. `n ≤ cursor` is counted as a retransmit, not a violation, and there is no other + dedup state; gaps are accepted; index 0 is accepted on a fresh cohort; +8. a cohort with no accepted message for the inactivity deadline is reclaimed, every + stream in it reset; a publisher stream going away does not end the cohort; +9. the capacity bounds are enforced, `FULL` is issued at capacity and nothing else is; +10. a subscriber re-verifies every delivery against the spec it joined with and its own + cursor; +11. a BPS-lite publisher and subscriber interoperate with a full SWIP-60 broker on a + single-publisher cohort once SWIP-60 is amended per *Relation to SWIP-60*. + +A broker MUST expose per-cohort counters for the silent outcomes — `invalid_index`, +`invalid_soc`, `wrong_owner`, `wrong_stream`, `retransmit`, `queue_reset` — since +items 6, 7 and 9 are unobservable from the wire without them. + +## Out of scope (deliberately) + +Service messages of any kind (roster, end of stream) and the Bee API bridge; multiple +publishers and the promotion of a subscriber stream to a publisher stream; the +self-indexed payload construction, gap recovery and persistence of +[SWIP-65](https://github.com/ethersphere/SWIPs/pull/106); multihop +([SWIP-61](https://github.com/ethersphere/SWIPs/pull/105)); history; bandwidth +incentives; broker discovery ([SWIP-59](https://github.com/ethersphere/SWIPs/pull/103)); +confidentiality of any kind. Each extends this SWIP's wire without changing it. + +## Backwards compatibility + +New protocol; no existing behaviour changes. Every message and field number here is +kept by the fuller protocol, which extends by adding fields and messages and never by +changing these; a BPS-lite peer ignores fields it does not define. + +## References + +Full singlehop protocol: [SWIP-60, PR #104](https://github.com/ethersphere/SWIPs/pull/104) +· carriage: [SWIP-65 self-indexed feeds, PR #106](https://github.com/ethersphere/SWIPs/pull/106) +· first draft of this SWIP: [PR #111](https://github.com/ethersphere/SWIPs/pull/111) +· origin: [PR #93](https://github.com/ethersphere/SWIPs/pull/93) "Add: pubsub" +· implementation groundwork: bee [#5435](https://github.com/ethersphere/bee/pull/5435) + +## Copyright + +Copyright and related rights waived via [CC0](https://creativecommons.org/publicdomain/zero/1.0/). diff --git a/SWIPs/swip-draft_bps-lite.md b/SWIPs/swip-draft_bps-lite.md deleted file mode 100644 index 775b7252..00000000 --- a/SWIPs/swip-draft_bps-lite.md +++ /dev/null @@ -1,469 +0,0 @@ ---- -SWIP: -title: BPS-lite — single-publisher brokered broadcast -author: acud (@acud) -discussions-to: https://discord.gg/Q6BvSkCv -status: Draft -type: Standards Track -category: Networking -created: 2026-09-14 ---- - - - -## Simple Summary - -One broker, one publisher, many subscribers, one hop. A publisher opens a topic on a -broker and pushes single-owner chunks; everyone else connects and reads. Nothing else. - -## Abstract - -BPS-lite is a real-time broadcast protocol in which a cohort is a topic and a topic is -a single-owner chunk address. The peer that opens a cohort is its publisher for as long -as that stream lives; every other peer is a read-only subscriber. Authorship needs no -credential, no publisher list and no handshake authentication: under the `ANCHOR` -binding the topic already names the only key that can produce a chunk hashing to it, so -authorship is re-established from the signature on every message. The cohort's lifetime -is its publisher's stream, which removes orphan cohorts and bounds `Open` as an -allocation primitive. - -It is a deliberate subset of the design space [SWIP-60][swip60] covers, specified as -its own document rather than as a profile: the wire format below is complete, and where -BPS-lite differs from SWIP-60 — most importantly in making `ANCHOR`'s address -constraint unconditional — BPS-lite's rule governs. No compatibility is claimed in -either direction. - -## Motivation - -SWIP-60 spans five cohort shapes, four topic bindings, four publisher regimes, an admin -control plane and a history flag. That parameter space is the right target for the -reference implementation and the wrong one for a second, independent implementation: -most of it is untested surface for anyone whose application is one author broadcasting -to an audience — a live feed, a price ticker, a game's server-authoritative state, a -log tail. - -BPS-lite names the single point in that space which needs none of the machinery, and -shows that pinning it collapses the spec rather than merely constraining it. Three -questions SWIP-60 leaves open — the dedup horizon, cohort lifetime, and `Open` as an -unbounded allocation primitive — are answered here, and one property SWIP-60 claims -(`closed` as confidentiality over an unauthenticated handshake) is dropped rather than -restated. The result is a target roughly a tenth the size, which a second team can -implement and conformance-test without tracking the full spec's revisions. - -## Specification - -The key words MUST, MUST NOT, REQUIRED, SHOULD, RECOMMENDED and MAY are to be -interpreted as described in RFC 2119. - -### Roles and topology - -One broker node. One publisher peer. N subscriber peers. Every peer dials the broker -directly over a single libp2p stream per (peer, topic), protocol id -`pubsub/1.0.0` — the same id SWIP-60 uses, shared deliberately: see *Backwards -compatibility*. No relaying, no referral, no broker discovery: depth is 1 by -construction. - -The publisher and the broker are distinct peers. Brokering for oneself is out of scope. - -**The peer that opened a cohort is its publisher for as long as that stream lives.** -The role is a property of the stream, not of a field in the spec: subscriber streams are -read-only and the broker MUST NOT read `Publish` frames from them. - -### Wire format - -This section is normative and complete: BPS-lite is defined by the messages below and -by nothing else. No import, no companion `.proto`, no field inherited by reference. -Reserved field numbers are SWIP-60's, kept reserved so that a frame from a full-spec -implementation is refused rather than silently misread as a valid BPS-lite frame. - -```proto -// BPS-lite — single-publisher brokered broadcast. Complete wire format. -// -// 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 any message carrying one. - -syntax = "proto3"; - -package bps; - -// ---------------------------------------------------------------- cohort - -// What the topic binds to. BPS-lite has exactly one binding: the topic is the -// full SOC address. Unlike SWIP-60 the constraint is unconditional — there is -// no publisher regime that can relax it. -enum TopicBinding { - TOPIC_BINDING_UNSPECIFIED = 0; // invalid on the wire - ANCHOR = 1; // topic = SOC address; dedup on the wrapped CAC -} - -// Fixed by the cohort's opener; immutable for the cohort's lifetime. -// A cohort is a topic: there is no publisher field, because under ANCHOR the -// topic already names the only key that can author for it. -message CohortSpec { - bytes topic = 1; // 32B: the SOC address, keccak256(id || owner) - TopicBinding binding = 2; // must be ANCHOR - reserved 3, 4, 5, 6, 7, 8; // SWIP-60: publishers, history, admin, - // publisher_list, po_min, closed -} - -// ------------------------------------------------------------- handshake - -// Opener -> broker: the one peer that fixes the cohort. The stream carrying -// this frame is the cohort's publisher for as long as it lives. -message Open { - CohortSpec cohort = 1; - reserved 2; // SWIP-60: auth. BPS-lite authenticates nothing at - // handshake time; authorship is checked per message. - bool loopback = 3; // deliver the cohort's own Broadcast frames back -} - -// Joiner -> broker: names the topic — nothing more. Subscriber streams are -// read-only; there is no role to claim here. -message Subscribe { - bytes topic = 1; // 32 bytes - reserved 2; // SWIP-60: auth. A frame carrying it is REJECTED. -} - -// Peer -> broker: the first frame on a fresh stream. Open and Subscribe are -// otherwise indistinguishable on the wire — both encode as a single -// length-delimited field 1, and proto3's permissive unmarshalling makes a -// wrong guess succeed silently. -message Hello { - oneof handshake { - Open open = 1; - Subscribe subscribe = 2; - } -} - -enum Status { - STATUS_UNSPECIFIED = 0; // invalid on the wire - OK = 1; - FULL = 2; // broker at a capacity limit: subscribers, or cohorts - UNKNOWN_TOPIC = 3; // Subscribe for a topic with no live publisher - REJECTED = 4; // unacceptable spec, or a reserved field set -} - -// Broker -> peer, answering Open or Subscribe. One per stream; a non-OK Ack -// ends the stream. -message Ack { - Status status = 1; - CohortSpec cohort = 2; // set iff status == OK -} - -// --------------------------------------------------------------- traffic - -// A full single-owner chunk in transit. Every frame is self-contained. -message Soc { - bytes id = 1; // 32 bytes - bytes owner = 2; // 20 bytes - bytes signature = 3; // 65 bytes - bytes span = 4; // 8 bytes LE - bytes payload = 5; // wrapped-CAC data, <= 4096 bytes -} - -// Publisher -> broker. Accepted only on the stream that opened the cohort. -message Publish { - Soc soc = 1; -} - -// Broker -> peer. -message Broadcast { - oneof frame { - Soc soc = 1; - // 2-15 reserved in SWIP-60 for the multihop control plane; BPS-lite is - // singlehop and defines none of them. - } -} -``` - -Everything after the handshake is direction-typed and needs no envelope: peer to -broker is always `Publish`, broker to peer is always `Broadcast`. - -A cohort spec carries two live fields. There is no publisher field, no publisher -list, no publisher regime, no `history`, no `closed`, no `PO_MIN`. Per-topic capacity -is broker policy and is not on the wire at all. - -`Open` carries no credential: nothing at handshake time can prove control of a key, -so BPS-lite does not ask. `Subscribe` carrying the reserved field 2 is `REJECTED` — -there is no second publisher to authenticate, and no role to claim at handshake time. - -### Handshake - -Publisher-first. The publisher fixes the cohort; subscribers join it. - -``` -publisher broker - | Hello{Open{spec, loopback}} | - |------------------------------------->| validate spec; create cohort; - | | this stream is the publisher - | Ack{OK, spec} | - |<-------------------------------------| - | Publish{soc} ... | - | Broadcast{soc} ... | iff loopback was requested - |<-------------------------------------| - -subscriber broker - | Hello{Subscribe{topic}} | - |------------------------------------->| UNKNOWN_TOPIC if no publisher has opened - | Ack{OK, spec} | - |<-------------------------------------| - | Broadcast{soc} ... | -``` - -Rules: - -- A non-`OK` `Ack` ends the stream. On `OK` the stream is retained. -- A `Subscribe` carrying reserved field 2 (SWIP-60's `auth`) is `REJECTED`. -- **`Open` on a live topic with an identical spec transfers the publisher role to the - new stream**: the broker answers `OK`, retains the cohort and its dedup window, and - resets the previous publisher stream. A publisher recovering from a dropped - connection therefore needs no distinct reattach frame, and a stale half-open stream - closing later is not the current publisher stream and has no effect. An `Open` with - a different spec for a live topic is `REJECTED`. -- A subscriber arriving before the publisher gets `UNKNOWN_TOPIC` and retries. The - broker holds no pre-registration state. -- A peer receiving `UNKNOWN_TOPIC` or `FULL` MUST wait before retrying: randomized - exponential backoff from 1s to 30s, full jitter. A broker MAY reset a stream from a - peer that retries faster. Without this, every publisher restart resets N subscriber - streams at once and they all return immediately. -- The publisher's own stream receives the cohort's `Broadcast` frames only if it asked - for them (`Open.loopback`, default false). See *Capacity and slow peers* for why it - is opt-in. - -### Message validation - -A `Publish` is accepted iff, in order: - -1. it arrived on the stream that opened the cohort; -2. the `Soc` decodes and its signature recovers an owner; -3. the SOC address, `keccak256(id || owner)`, equals `spec.topic` — the `ANCHOR` rule; -4. the wrapped CAC's BMT root matches its payload; -5. the CAC address is not in the cohort's dedup window. - -Failures 1–4 are protocol violations: the message is dropped, counted per peer, and -exposed through the implementation's invalid-message hook. Failure 1 additionally -resets the offending stream. No blocklisting is mandated. Failure 5 is ordinary — a -retransmitting publisher — and is counted separately. - -Note what is *not* here: no comparison of the recovered owner against a declared -publisher. Under `ANCHOR` the topic is a SOC address, so step 3 already constrains the -owner — producing a SOC that hashes to `spec.topic` under a different key is a keccak -preimage. An owner check would be a second test that can only fail where step 3 has -already failed. - -On acceptance the broker enqueues a `Broadcast` on every subscriber stream in the -cohort, and on the publisher's own stream if it requested loopback. - -### Cohort lifetime - -**A cohort exists exactly as long as its current publisher's stream.** When that -stream closes or resets, the broker destroys the cohort and resets every subscriber -stream. Subscribers reconnect and receive `UNKNOWN_TOPIC` until the publisher returns. -A stream that has been superseded by a role transfer is not the current publisher -stream: its later close is a no-op. - -This is the largest simplification the single-publisher restriction buys. It removes -orphan cohorts and any reclamation policy, and it bounds `Open` as an allocation -primitive: a peer can hold only as many live cohorts as it holds open streams. - -### Dedup - -A bounded LRU of accepted CAC addresses, per cohort. Implementations MUST bound it; -1024 entries is RECOMMENDED. A role transfer retains the window. - -Replay is prevented by the stream-role rule, not by the window: a captured `Broadcast` -re-`Publish`ed by an observer carries the genuine publisher's signature and passes -steps 2–4, but is refused at step 1 because it arrives on a subscriber stream — -whatever its age. The window bounds duplicate suppression for a retransmitting -publisher only, and 1024 is chosen against that benign case. - -### Capacity and slow peers - -Per-topic subscriber capacity is broker policy, not a cohort parameter. At the limit -the broker answers `FULL` and nothing else; referral is bps-multihop's business. -A broker MUST also bound the number of live cohorts it will create; the lifetime rule -above makes that bound enforceable, and an `Open` at that bound is answered `FULL`. - -Each retained subscriber stream has a bounded outbound queue (64 frames RECOMMENDED) -drained by a single writer. A full queue resets that stream. The broker has no -delivery obligation: withholding is a recoverable liveness fault, not a correctness -failure, and blocking fan-out on one slow subscriber would punish the cohort. - -The publisher's stream is exempt from that reset. It is the cohort's lifetime, so a -policy that is merely lossy for a subscriber is fatal for everyone: a publisher that -does not drain its own loopback echo — a fast publisher, a single-threaded client, a -bridge applying backpressure — would otherwise destroy its own cohort. A full queue on -the publisher's stream drops the frame and increments a counter. Loopback is opt-in for -the same reason. - -Loopback is an echo of *accepted* messages, not delivery confirmation: rejected -`Publish` frames are dropped silently and produce no frame at all, so a publisher -cannot distinguish rejection from delivery by watching its echo. An acknowledged write -path would be an `Ack`-per-`Publish` design, which BPS-lite does not have. - -### Status codes - -| condition | status | -|---|---| -| handshake accepted | `OK` | -| broker at a capacity limit — per-topic subscribers for `Subscribe`, live cohorts for `Open` | `FULL` | -| `Subscribe` for a topic with no live publisher | `UNKNOWN_TOPIC` | -| unacceptable spec, differing spec on re-`Open`, a reserved field set | `REJECTED` | - -A `STATUS_UNSPECIFIED` or any other zero enum value received on the wire is rejected: -the proto's zero values are deliberately not legitimate wire values. - -## Rationale - -### Authorship without a credential - -BPS-lite has no publisher field and no handshake credential. A cohort's topic is a SOC -address; the only key that can produce a SOC hashing to it is the one that owns it. -Authorship is a property of the topic, established on the write path by step 3 and -re-checked on every message, and the handshake asserts nothing. - -Two consequences worth stating. Anyone may `Open` any topic, including a topic whose -key they do not hold — but such a cohort can never accept a message, and the genuine -publisher's `Open` takes the role from them under the transfer rule above, so squatting -costs the squatter a stream and buys nothing. And BPS-lite makes no confidentiality -claim: broadcasts are readable by any subscriber, audience control is the application's -business, and SWIP-60's `closed` flag — which does make such a claim on an -unauthenticated handshake — is deliberately absent. - -### Differences from SWIP-60 - -Non-normative, for readers arriving from the full spec. Three things differ: - -- **No `PublisherRegime`, and no publisher set.** SWIP-60's `publishers`, `admin`, - `publisher_list`, `history` and `closed` have no counterpart. `CohortSpec` is - `{topic, binding}`. -- **`ANCHOR`'s address constraint is unconditional.** SWIP-60 relaxes it under an - explicit publisher regime, where the topic becomes a rendezvous and legitimacy comes - from list membership instead. BPS-lite has no list, so the constraint always applies. - This is the trap for anyone reusing SWIP-60's `anchorBinding` unmodified: its - explicit-regime early return would leave a BPS-lite cohort with no address check at - all. -- **The publisher role is bound to the opening stream**, and transfers on re-`Open`. - SWIP-60 leaves cohort lifetime and reattachment unspecified. - -The first two are what make the spec small; the third is what makes it operable. Each -is a divergence, not a restriction: a SWIP-60 implementation pointed at a BPS-lite -cohort would accept messages BPS-lite refuses, and a SWIP-60 client's `Open` carries -fields BPS-lite reserves. Hence a separate document — but not a separate protocol id. -BPS-lite and SWIP-60 are two specifications of one protocol, `pubsub/1.0.0`, and a peer -dials it without knowing which it will meet. The first frame settles that: a BPS-lite -broker answers a SWIP-60 `Open` with `REJECTED`, because every field the fuller spec -adds is a field this one reserves. Refusal at the handshake is the compatibility story, -and it is why those numbers are reserved rather than reused. - -### Why the publisher field is absent - -Under `ANCHOR` the topic is `keccak256(id || owner)`. Comparing a message's recovered -owner against a declared publisher is then a test that can only fail where the address -check has already failed, short of a keccak preimage. Carrying the field would cost a -20-byte field, a validation step, an owner comparison on re-`Open`, and — because the -field is asserted and never proved — a paragraph explaining what it does not mean. The -topic does the work; the field is deleted. - -### Why loopback is opt-in - -The publisher's stream is the cohort's lifetime, so the queue-overflow policy that is -merely lossy for a subscriber is fatal for the whole cohort. A publisher that does not -drain its own echo would destroy what it is publishing to. Making the echo opt-in and -exempting that stream from the reset removes a failure mode that has no analogue on the -subscriber side. - -## Backwards compatibility - -BPS-lite introduces no incompatibility with any deployed protocol: Swarm has no -broadcast pub/sub in production. - -It shares the `pubsub/1.0.0` protocol id with [SWIP-60][swip60] while **not** being -wire-compatible with it. That is a deliberate choice, and it puts the whole weight of -the compatibility story on the first frame of a stream: - -- A SWIP-60 `Open` sets `CohortSpec` fields 3–8 and `Open` field 2, all of which - BPS-lite reserves, so it is `REJECTED` rather than silently decoded with those fields - dropped — which is what proto3's permissive unmarshalling would otherwise do, leaving - a peer that asked for a five-author closed cohort holding an open single-publisher - one. The reserved numbers are what make the refusal reliable, and they are the reason - a BPS-lite broker MUST reject a frame carrying them rather than ignore it. -- A BPS-lite `Open` is a valid SWIP-60 `Open` — `{topic, binding}` with everything else - unset. A SWIP-60 broker would accept it, and then not enforce the address constraint - (see the next bullet). A BPS-lite publisher therefore gets weaker guarantees from a - full-spec broker than from a conformant one, silently. Applications that depend on - the `ANCHOR` rule should treat an unexpected `Ack` shape as a mismatch. -- BPS-lite's `ANCHOR` applies its address constraint unconditionally; SWIP-60 relaxes - it under an explicit publisher regime. An implementation reusing SWIP-60 `ANCHOR` - code unmodified would perform no address check at all under BPS-lite, which is the - one silent failure this divergence can cause. It is called out again in *Differences - from SWIP-60* above. - -Should BPS-lite and SWIP-60 later converge, the reserved field numbers leave the -migration path open: a future revision can un-reserve them with their SWIP-60 meanings -intact. - -## Test cases - -An implementation is BPS-lite conformant if it exhibits all of: - -1. `Open` with `binding != ANCHOR` → `REJECTED`. -2. `Open` carrying a reserved field, or a `CohortSpec` carrying one → `REJECTED`. -3. `Subscribe` carrying reserved field 2 → `REJECTED`. -4. `Subscribe` before any `Open` → `UNKNOWN_TOPIC`. -5. Re-`Open` on a live topic with an identical spec → `OK`; the prior publisher stream - is reset; the cohort and its dedup window survive. A differing spec → `REJECTED`. -6. A superseded publisher stream closing after a role transfer → cohort unaffected. -7. Current publisher stream closes → every subscriber stream reset, topic subsequently - `UNKNOWN_TOPIC`. -8. `Publish` whose SOC address is not `spec.topic` → dropped and counted. -9. `Publish` on a subscriber stream → dropped, counted, and that stream reset. -10. A duplicate CAC within the dedup window → dropped, counted separately. -11. Subscriber arriving at per-topic capacity → `FULL`, with no referral frame. `Open` - at the live-cohort bound → `FULL`. -12. Publisher stream outbound queue full → frame dropped, stream retained, cohort alive. -13. `Open` without `loopback` → the publisher receives no `Broadcast` frames. - -A broker MUST expose per-cohort counters for the silent-drop outcomes — -`invalid_address`, `invalid_signature`, `invalid_cac`, `wrong_stream`, `duplicate`, -`queue_dropped`. Points 8–10 and 12 are unobservable from the wire without them, so the -counters are part of the conformance surface rather than an implementation detail. - -## Implementation - -Groundwork exists in the bee prototype `pkg/bps` ([bee #5435][bee5435]), written against -SWIP-60. It is a superset of this spec in most places and diverges in one: - -`pkg/bps` is a superset of this spec in some places and diverges in one: it implements -`ANCHOR` and `FEED_TOPIC` bindings, `EXPLICIT_SINGLE` and `EXPLICIT_LIST` regimes, the -`Hello` envelope and a WS bridge. Most of the work is subtraction: - -| file | under BPS-lite | -|---|---| -| `publisher.go` | `authorizePublisher` goes entirely — there is no publisher to authorize against, only the topic | -| `binding.go` | `anchorBinding` only; the registry stays as the extension seam with one entry. `qualifies` **loses its explicit-regime early return**: the address check becomes unconditional | -| `cohort.go` | `Publishers`, `sortedCopy`, the regime switch in `ValidateSpec` and the publisher-list branch of `SpecEqual` go; `ValidateSpec` checks two fields | -| `broker.go` | `admit` sets the publisher role from the `Open` path rather than from `auth != nil`; the `PublisherAuth`-is-not-a-credential commentary goes with it | -| `broker.go` | gains a `publisher *peerStream` on the cohort: teardown fires only when the *current* publisher stream closes, and re-`Open` transfers it | -| `broker.go` | gains the queue-reset exemption for the publisher's stream | - -Two behaviors are new — the lifetime rule and role transfer. Everything else is removal. - -[swip60]: https://github.com/ethersphere/SWIPs/pull/104 -[bee5435]: https://github.com/ethersphere/bee/pull/5435 - -## References - -Parent design space: [SWIP-60 "BPS singlehop — brokered broadcast pub/sub, base -protocol", PR #104][swip60] · origin: -[PR #93](https://github.com/ethersphere/SWIPs/pull/93) "Add: pubsub" · implementation: -bee [#5435][bee5435] - -## Copyright - -Copyright and related rights waived via [CC0](https://creativecommons.org/publicdomain/zero/1.0/). From f42b99a904b812bd0129fbc1eb6d55c3fa6b6a44 Mon Sep 17 00:00:00 2001 From: zelig Date: Tue, 22 Sep 2026 16:18:39 +0200 Subject: [PATCH 3/5] =?UTF-8?q?swip-74:=20resolve=20the=20last=20open=20po?= =?UTF-8?q?int=20=E2=80=94=20concurrent=20admin=20streams=20are=20both=20p?= =?UTF-8?q?ublisher=20streams?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A second stream authenticating as the admin while one is live is admitted as a publisher stream too; the per-cohort cursor arbitrates. No supersede rule, no refusal. No (?) marks remain. Co-Authored-By: Claude Fable 5.1 --- SWIPs/swip-74.md | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/SWIPs/swip-74.md b/SWIPs/swip-74.md index 83ac8eee..9e2cb8f2 100644 --- a/SWIPs/swip-74.md +++ b/SWIPs/swip-74.md @@ -13,7 +13,7 @@ created: 2026-09-14 PR #111 after review: no history, no bandwidth incentive, no service messages, singlehop only, one mode only — an explicit single publisher (live video streaming). SWIP-60 (PR #104) is the full singlehop protocol and is to be amended to extend this wire rather -than the other way round; see "Relation to SWIP-60". Open points are marked (?). --> +than the other way round; see "Relation to SWIP-60". --> - **Business line**: a live stream on Swarm — one author, an audience, real time, no storage round trip and no polling: video/audio streaming, a price ticker, a game's @@ -198,12 +198,10 @@ Then the stream's role, from `Auth`: **The identity is bound to the stream, not to the peer connection.** One stream per (peer, cohort); a node may present different identities on different streams, and a -stream without `Auth` has none. **(?)** What if a second stream authenticates as the -admin while a first one is live — the admin from two nodes, or reconnecting before its -old stream is torn down? This draft admits both as publisher streams and lets the -per-cohort cursor arbitrate (neither can publish an index the other already has); the -alternatives are that the new stream supersedes the old (reset), or that the second is -`REJECTED`. +stream without `Auth` has none. A second stream MAY authenticate as the admin while a +first one is live — the admin from two nodes, or reconnecting before its old stream is +torn down: both are publisher streams, and the per-cohort cursor arbitrates, since +neither can publish an index the other already has. No supersede rule, no refusal. The broker answers `Ack{status}`: `OK`, `FULL` at a capacity bound, `REJECTED` for a spec outside this SWIP or a reserved field set. A non-`OK` `Ack` ends the stream; on `OK` the From b68092fdaedd11ae3f1c43afb697319a4a014f4c Mon Sep 17 00:00:00 2001 From: zelig Date: Thu, 24 Sep 2026 16:24:16 +0200 Subject: [PATCH 4/5] swip-74 rev 3: the publisher role is claimed by signing a broker-derived challenge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Join{CohortSpec, addr, claim?}: a peer that means to publish declares the address it will publish as; a returning publisher claims in the Join - Ack{status, challenge}: S = H(S_C || S_c || addr), S_C a boot secret never persisted, S_c = H(S_C || H(Marshal(spec))) — nothing stored, recomputed at claim time, the same S for an address on a cohort from any node - Claim{addr, index, auth}: auth = {r, s, v} over H("bps-claim:v1" || S || O_B || index); O_B the broker's overlay (against challenge forwarding), index the publisher's cursor, signed; no reply - Message{address, data}: the whole chunk, so the ordinary SOC validation is non-vacuous; no envelope — what a frame is follows from the stream's role - cursor restated as the lowest index accepted next, initially 0; a claim sets it to max(cursor, index) - one extra stream over the fan-out bound while the admin is absent, with a claim deadline - security: what the claim protects (identity, not history), the case-by-case replay table, the transport precondition made normative - no reserved numbers; unknown fields ignored; conformance and counters updated Co-Authored-By: Claude Fable 5.1 --- SWIPs/swip-74.md | 515 ++++++++++++++++++++++++++++------------------- 1 file changed, 306 insertions(+), 209 deletions(-) diff --git a/SWIPs/swip-74.md b/SWIPs/swip-74.md index 9e2cb8f2..7f2937f4 100644 --- a/SWIPs/swip-74.md +++ b/SWIPs/swip-74.md @@ -11,21 +11,24 @@ created: 2026-09-14 +only, one mode only — an explicit single publisher (live video streaming). Rev 3: the +publisher role is claimed by signing a broker-issued challenge (the first draft had a +challenge; rev 2 wrongly replaced it with a static signature). SWIP-60 (PR #104) is the +full singlehop protocol and extends this wire without changing it; see "Relation to +SWIP-60". Open points are marked (?). --> - **Business line**: a live stream on Swarm — one author, an audience, real time, no storage round trip and no polling: video/audio streaming, a price ticker, a game's server-authoritative state, a log tail. The stream is a feed, so the same updates can later be persisted and replayed from storage by anyone who missed the live run. -- **Dev line**: implement one libp2p protocol, `pubsub/1.0.0`, with the three frames - below — one broker, direct streams, one authenticated publisher, read-only - subscribers — and nothing else: **no history, no bandwidth incentive, no service - messages, no Bee API, one hop, one mode.** Done when a broker, a publisher and - subscribers from independent implementations interoperate per the conformance - section. Everything the family adds — service messages and the Bee API, multiple - publishers, self-indexed feeds, multihop — extends this wire without changing it. +- **Dev line**: implement one libp2p protocol, `pubsub/1.0.0`, with the four frames + below — one broker, direct streams, one publisher that proves its key by signing a + challenge, read-only subscribers — and nothing else: **no history, no bandwidth + incentive, no service messages, no Bee API, one hop, one mode.** Done when a broker, a + publisher and subscribers from independent implementations interoperate per the + conformance section. Everything the family adds — service messages and the Bee API, + multiple publishers, self-indexed feeds, multihop — extends this wire without changing + it. - **DISC change**: NO. A new p2p protocol surface; no storage, retrieval or incentive change. @@ -36,12 +39,13 @@ that anyone can subscribe to and one participant can publish on. Each channel de its own cohort — a set of nodes connected through a unique central broadcaster node, the **broker**; every node in the cohort is directly connected to the broker, hence *singlehop*. A channel is identified by its spec — a feed topic and the address of its -**admin**. Anyone joins by sending that spec; the admin also sends a signature that binds -its identity to the stream, and has the exclusive right to publish. Its messages are -single-owner chunks that are the higher-index updates of the feed the topic names, -carried with their bare index; the broker accepts each one iff its index exceeds the -channel's cursor, it validates as a single-owner chunk under the feed's id and its owner -is the admin, and delivers it unchanged to every subscriber stream — never to a +**admin**. Anyone joins by sending that spec, and a peer that means to publish also names +the address it will publish as; the broker answers with a **challenge** it derives from a +secret it never stores, and the publisher claims its stream by signing it. Its messages +are single-owner chunks that are the higher-index updates of the feed the topic names, +carried with their bare index; the broker accepts each one iff its index is at least the +channel's cursor, the chunk validates as a single-owner chunk under the feed's id, and its +owner is the admin, and delivers it unchanged to every subscriber stream — never to a publisher's. All other joiners are subscribers and verify the same way, end to end: the broker can withhold, never forge. A channel ends when it goes idle. @@ -52,7 +56,7 @@ on a service feed, a history flag and the Bee API. That is the right target for reference implementation and too much for a second, independent one whose application is one author broadcasting to an audience. BPS-lite names that one configuration and specifies only what it needs, so that a second team can implement and conformance-test -it from three frames — and so that it stays the **base** of the family: nothing here is +it from four frames — and so that it stays the **base** of the family: nothing here is a divergence to reconcile later, because the fuller protocol extends these frames. One publisher over a feed also removes what the fuller protocol has to carry: a dedup @@ -63,7 +67,8 @@ who may write (the admin, proved by its signature on every message). ## Specification The key words MUST, MUST NOT, REQUIRED, SHOULD, RECOMMENDED and MAY are to be interpreted -as described in RFC 2119. Terms — cohort (a channel's set of nodes), broker, admin, subscriber — are SWIP-60's. +as described in RFC 2119. Terms — cohort (a channel's set of nodes), broker, admin, +subscriber — are SWIP-60's. ### The contract @@ -73,14 +78,14 @@ subscribers**; they are **feed updates, in increasing index order**. ### Topology and roles - **Broker**: one full node per cohort. Every peer holds one direct libp2p stream per - (peer, cohort) to it, protocol id `pubsub/1.0.0`. Depth is 1 by construction: no - relaying, no referral. Broker discovery is out of scope; deployments configure the + (peer, cohort, identity) to it, protocol id `pubsub/1.0.0`. Depth is 1 by construction: + no relaying, no referral. Broker discovery is out of scope; deployments configure the broker. - **Admin = the publisher**: the address named in the cohort spec. It is the only - identity whose messages are accepted. It need not be first to join, and the cohort - does not end when its stream goes away. -- **Subscriber**: joins by naming the cohort, receives, never sends. A message from a - subscriber stream is a protocol violation. + identity whose messages are accepted. It need not be first to join; the cohort does + not end when its stream goes away; and it may publish from any node, or from two. +- **Subscriber**: joins by naming the cohort, receives, never publishes. A publication + from a subscriber stream is a protocol violation. - **Publisher and broker are distinct nodes**; brokering for oneself is out of scope. ### The cohort spec @@ -93,20 +98,22 @@ Three fields, no others: | `binding` | `FEED_TOPIC`: the signed id of every message is `keccak256(topic ‖ index)` | | `admin` | 20-byte address: the publisher | -**The spec is the cohort's identity.** Two peers naming byte-identical specs at a broker -are in the same cohort; a spec that differs in any field names a different one. The -invite for a stream is therefore the spec plus the broker: `topic`, `admin`, broker -address. No publisher regime, no roster, no spectator flag, no history flag, no -proximity constant: a BPS-lite broker answers any spec outside this table, and any -reserved field set, with `REJECTED`. Capacity is not in the spec: a cohort cannot -dictate a remote node's connection count. +**The spec is the cohort's identity.** It has a **canonical serialisation** — +`Marshal(spec)`: fields in number order, unset fields not emitted — and cohorts are keyed +by it: two peers naming byte-identical specs at a broker are in the same cohort; a spec +that differs in any field names a different one. The invite for a stream is therefore the +spec plus the broker: `topic`, `admin`, broker address. No publisher regime, no roster, no +audience flag, no history flag, no proximity constant: a BPS-lite broker answers any +value outside this table with `REJECTED`, and ignores fields it does not define, as +proto3 does. Capacity is not in the spec: a cohort cannot dictate a remote node's +connection count. ### Wire format -Normative and complete. Field and enum numbers are SWIP-60's where a counterpart exists, -so that the fuller protocol extends these messages without renumbering; numbers it uses -and this SWIP does not are reserved, never reused. An unset enum (`*_UNSPECIFIED`, the -proto3 zero) is not a legitimate wire value: receivers MUST reject a message carrying one. +Normative and complete. Field and enum numbers are shared with SWIP-60, which extends +these messages by adding fields, values and nothing else. An unset enum +(`*_UNSPECIFIED`, the proto3 zero) is not a legitimate wire value: receivers MUST reject +a message carrying one. ```proto syntax = "proto3"; @@ -115,72 +122,81 @@ package bps; // What the topic binds to. One binding. enum TopicBinding { TOPIC_BINDING_UNSPECIFIED = 0; // invalid on the wire - FEED_TOPIC = 4; // signed id = keccak256(topic || index). SWIP-60's - // number; 1, 2, 3, 5 are SWIP-60's other bindings + FEED_TOPIC = 4; // signed id = keccak256(topic || index) } -// The cohort's identity. Fixed by whoever joins first; immutable. +// The cohort's identity. Fixed by whoever joins first; immutable; keyed by its +// canonical serialisation. message CohortSpec { bytes topic = 1; // 32 bytes TopicBinding binding = 2; // FEED_TOPIC - bytes admin = 5; // 20 bytes, required - reserved 3, 4, 6, 7, 8, 9; // SWIP-60: publishers, history, (unused), (unused), - // (unused), spectators — none apply here + bytes admin = 3; // 20 bytes, required } -// Proof of an identity, bound to the stream it arrives on: -// owner = ecrecover( keccak256("bps-join:v1" || topic || admin), signature ) -// The preimage is static and carries no node identity (SWIP-60): the same key works -// from any node, and a join never links an address to a peer id. It is therefore -// replayable, and a replayed identity is worthless: authorship rests on the message -// signature, never on the handshake. +// A secp256k1 signature, as a SOC's: r || s || v. message Auth { - bytes signature = 1; // 65 bytes - reserved 2; // SWIP-60: id, for bindings that do not fix it + bytes r = 1; // 32 bytes + bytes s = 2; // 32 bytes + uint32 v = 3; // 27 or 28 } -// Peer -> broker: the first and only handshake frame on a fresh stream. Creates the -// cohort if no live cohort has this spec, attaches to it otherwise. `auth` binds an -// identity to THIS stream; absent, the stream has none and is read-only. +// 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`: S the challenge the broker issued for `addr` on this +// cohort, O_B the overlay of the broker the claiming node is connected to, `index` +// eight bytes big-endian. Sent inside Join by a peer that already holds S, or as the +// next frame after Ack by one that has just received it. +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 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; absent: + // a subscriber, and no challenge is issued + Claim claim = 3; // a returning publisher's claim, verified before any bound } enum Status { STATUS_UNSPECIFIED = 0; // invalid on the wire OK = 1; FULL = 2; // a capacity bound (see Resource bounds) - REJECTED = 4; // spec outside this SWIP, or a reserved field set - reserved 3; // SWIP-60: UNKNOWN_TOPIC — cannot occur: Join creates + REJECTED = 3; // a spec value outside this SWIP } -// Broker -> peer, answering Join. Status only. A non-OK Ack ends the stream. +// Broker -> peer, answering Join. A non-OK Ack ends the stream. message Ack { - Status status = 1; - reserved 2, 3, 4, 5; // SWIP-60: spec echo and service SOCs — nothing to echo + Status status = 1; + bytes challenge = 2; // S, iff status == OK and addr was declared } -// Both directions after the handshake: admin -> broker is a publication, broker -> -// subscriber 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 -// once the `id` slot has been rewritten as the Frames section says: -// id (32) || signature (65) || span (8, LE) || payload (<= 4096) +// Both directions after the handshake: publisher -> broker is a publication, broker +// -> subscriber 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 the Frames section says: +// data = id (32) || signature (65) || span (8, LE) || payload (<= 4096) message Message { - bytes soc = 1; + bytes address = 1; // 32 bytes: the SOC address, keccak256(id || owner) + bytes data = 2; } ``` -Three frames — `Join`, `Ack`, `Message` — and the two types they carry, `CohortSpec` and -`Auth`. There is no envelope around `Join` — it is the only first frame there is — and no -separate publish and broadcast types: a broker pushes to subscribers, a non-broker does -not. Fields this SWIP does not define are ignored on receipt, as proto3 does; a `Message` -whose `soc` is empty is invalid. +Four frames — `Join`, `Ack`, `Claim`, `Message` — and the two types they carry, +`CohortSpec` and `Auth`. There is no envelope: **what a frame is follows from the +stream's direction and role.** The first peer-to-broker frame is a `Join` and the first +broker-to-peer frame an `Ack`; after that a broker sends only `Message`, a subscriber +stream sends only a `Claim`, and a publisher stream sends only `Message`. A frame that +does not parse as what its stream may send is invalid. -### Handshake: `Join` +### Handshake: `Join`, the challenge, the claim Every peer sends `Join` as the first frame on a fresh stream, carrying the full -`CohortSpec` and, if it claims an identity, an `Auth`. +`CohortSpec`; a peer that means to publish also declares the address it will publish as, +in `addr` — an assertion, proved only by the signature that follows. The broker compares the spec against its live cohorts: **no match → the cohort is created** with the joiner attached; **match → the joiner is attached** to it. Anyone may @@ -188,78 +204,119 @@ create, including a subscriber arriving before the admin: a publisher-less cohor the broker one map entry until the inactivity deadline reclaims it, and can accept no message. There is nothing to pre-register and no "unknown topic". -Then the stream's role, from `Auth`: +**The challenge.** For a `Join` that declares `addr`, the broker derives -| `Auth` | the stream is | -|---|---| -| absent | a **subscriber stream**, no identity | -| recovers to `spec.admin` | a **publisher stream** | -| recovers to any other address | a **subscriber stream** carrying that identity — no effect in this SWIP | - -**The identity is bound to the stream, not to the peer connection.** One stream per -(peer, cohort); a node may present different identities on different streams, and a -stream without `Auth` has none. A second stream MAY authenticate as the admin while a -first one is live — the admin from two nodes, or reconnecting before its old stream is -torn down: both are publisher streams, and the per-cohort cursor arbitrates, since -neither can publish an index the other already has. No supersede rule, no refusal. - -The broker answers `Ack{status}`: `OK`, `FULL` at a capacity bound, `REJECTED` for a spec -outside this SWIP or a reserved field set. A non-`OK` `Ack` ends the stream; on `OK` the -stream is retained. A peer whose stream was refused or reset MUST back off before -rejoining (randomised exponential, 1 s to 30 s, full jitter); a broker MAY reset a stream -that retries faster. Without this every reclaim or reset of a cohort brings its whole -audience back at once. +``` +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 +``` + +and answers `Ack{OK, S}`. The broker stores nothing: it recomputes `S` from its boot +secret, the spec and the address whenever a claim arrives. `S` is therefore the same for +an address on a cohort for as long as the broker runs, from whichever node the address +joins, and differs at every other broker and after every restart. It goes to the joiner +over the encrypted stream and is useful only to the key of `addr`; anyone may obtain it +by declaring the address, and gains nothing by it. + +**The claim.** A publisher claims its stream by signing, with the key of `addr`, + +``` +keccak256("bps-claim:v1" ‖ S ‖ O_B ‖ index) +``` + +where `O_B` is the overlay of the broker the claiming node is connected to and `index` +is 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 and +any other protocol's; `S` binds it to this broker, this cohort and this address; `O_B` +binds it to the verifier, which `S` cannot, because `S` is opaque to the signer (see +Security considerations); `index` is signed so that a replayed claim moves no cursor. +The claim is sent in one of two places: + +- **in `Join`**, by a peer that already holds `S` for this address at this broker — a + reconnecting admin, from the same node or another: the broker verifies it before any + bound is applied, and a valid claim makes the stream a publisher stream from its first + frame. A stale claim — the broker has restarted, `S` has changed — is treated as + absent: `Ack{OK, S}` with the new `S`, no penalty; +- **as the next frame after `Ack`**, a `Claim`, by a peer that has just received `S`. + +The broker recovers the signer and checks that it equals `addr` (ecrecover never fails, +it returns *some* address, which is why the address is declared and compared) and that +`addr` is the cohort's admin — the one address that may publish here. Then the stream +**upgrades** to a **publisher stream**: it leaves the fan-out set, and the cohort's cursor +becomes `max(cursor, index)`. There is **no reply**: a publisher sends its claim and its +first publication back to back, and stream ordering guarantees the broker handles the +claim first; a claim that did not upgrade makes the publication that follows a violation, +and the reset is the answer. Anything else — a signature that does not recover to `addr`, +an `addr` that is not the admin, a second claim on a stream — is a protocol violation: +dropped, counted (`invalid_claim`), the stream reset, the peer blocklisted per the node's +policy. Several streams MAY be claimed for the admin at once — the admin from two nodes, +or reconnecting before its old stream is torn down: each is a publisher stream, and the +cursor arbitrates. + +**The rest of the handshake.** `Ack{OK}` without a challenge answers a `Join` that +declared no address: the stream is a **subscriber stream**. `FULL` answers a `Join` at a +capacity bound, `REJECTED` one whose spec has a value outside this SWIP. A non-`OK` `Ack` +ends the stream; on `OK` it is retained. A peer whose stream was refused or reset MUST +back off before rejoining (randomised exponential, 1 s to 30 s, full jitter); a broker +MAY reset a stream that retries faster. Without this every reclaim or reset of a cohort +brings its whole audience back at once. ### Frames -After the handshake there is one frame, `Message`, and it is direction-typed: on a -publisher stream towards the broker it is a publication; from the broker on a subscriber -stream it is a delivery. A delivery is the accepted publication's bytes, unchanged. +After the handshake a broker sends `Message` and nothing else, to subscriber streams; a +publisher stream sends `Message` and nothing else; a subscriber stream sends at most one +`Claim`. A delivery is the accepted publication's bytes, unchanged. + +**The frame is a whole chunk.** `address` is the SOC address and `data` the chunk data, +so that the ordinary SOC validation applies: without the address it would be vacuous, +since recovering a signer always yields *an* address, and with it a bad signature fails +because the recovered owner no longer hashes to the address. -**The `id` field carries the bare index.** A feed update's signed id is +**The `id` slot carries the bare index.** A feed update's signed id is `keccak256(topic ‖ index)`, `index` a uint64 big-endian in 8 bytes. On the wire the -32-byte `id` slot of the chunk data holds that index left-padded with 24 zero bytes, and -the receiver reconstructs the signed id from the cohort's topic (the carriage of +32-byte `id` slot of `data` holds that index left-padded with 24 zero bytes, and the +receiver reconstructs the signed id from the cohort's topic (the carriage of [SWIP-65](https://github.com/ethersphere/SWIPs/pull/106)). A `Message` whose `id` slot does not have 24 leading zero bytes is not a feed update: a broker drops it as invalid; a subscriber drops it without counting it as a violation. ### Validation: the feed cursor -The broker keeps one **cursor** per cohort — the highest index accepted so far, initially -none. A `Message` arriving at the broker is accepted iff, in order: +The broker keeps one **cursor** per cohort: **the lowest index it will accept next**, +initially 0 — index 0 is the first update of every feed. A `Message` arriving at the +broker is accepted iff, in order: -1. it arrived on a **publisher stream** — on any other stream it is a protocol - violation: the frame is dropped, the stream reset and the peer blocklisted per the - node's policy; -2. its `id` slot is a bare index `n` and **`n > cursor`**; -3. with the id substituted by `keccak256(topic ‖ n)` the chunk **validates as a - single-owner chunk**: the wrapped chunk's BMT address matches `span ‖ payload`, and - the signature over `id ‖ wrappedAddress` recovers an owner; -4. the recovered owner is **`spec.admin`**. +1. it arrived on a **publisher stream** — on a subscriber stream the frame 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 per the node's policy; +2. its `id` slot is a bare index `n` and **`n ≥ cursor`**; +3. with the `id` slot rewritten to `keccak256(topic ‖ n)` the chunk **validates as a + single-owner chunk**: the wrapped chunk's BMT address matches `span ‖ payload`, the + signature over `id ‖ wrappedAddress` recovers an owner, and `keccak256(id ‖ owner)` + equals `address`; +4. `address` equals `keccak256(id ‖ admin)` — the owner is the admin. -On acceptance the broker sets `cursor := n` and enqueues the frame on **every subscriber -stream** in the cohort. Publisher streams receive nothing: a publisher never gets its own -messages back, on whichever of its streams they were sent. +On acceptance the broker sets `cursor := n + 1` and enqueues the frame on **every +subscriber stream** in the cohort. Publisher streams receive nothing: a publisher never +gets its own messages back, on whichever of its streams they were sent. Failures of 3 and 4 are invalid frames: dropped and counted; repeated invalid frames end -the connection (blocklisting policy). A frame failing 2 with `n ≤ cursor` is the one +the connection (blocklisting policy). A frame failing 2 with `n < cursor` is the one benign failure — a retransmit, which an admin reconnecting after a reset legitimately sends when it does not know what the broker last accepted — and is counted separately; a broker MAY reset a publisher stream whose retransmit rate exceeds its policy, since the cursor check is cheap and precedes the signature check. A frame failing 2 because the `id` slot is not a bare index is invalid. -The cursor is initially **absent, not zero**: index 0 is the first update of every feed -and MUST be accepted on a fresh cohort. - -**There is no dedup window.** The cursor is total: a message is either beyond the last -accepted index or it is not, and there is no eviction and no replay hole. **Gaps are -allowed** at the broker (`n > cursor + 1`): they are the publisher's business, and -SWIP-65 makes them detectable and recoverable at the subscriber. This is stricter than -SWIP-65's general carriage rule, under which a broker does not enforce monotonicity; the -restriction is sound here because one publisher on one hop admits no legitimate -reordering. +**There is no dedup window.** The cursor is total: a message is either at or beyond the +next expected index or it is not, and there is no eviction and no edge. **Gaps are +allowed** (`n > cursor`): they are the publisher's business, and SWIP-65 makes them +detectable and recoverable at the subscriber. A claim's `index` moves the cursor forward +to what the admin has published elsewhere, never back. This is stricter than SWIP-65's +general carriage rule, under which a broker does not enforce monotonicity; the restriction +is sound here because one publisher on one hop admits no legitimate reordering. Subscribers re-verify every delivery exactly as the broker does (steps 2–4, against the spec they joined with and their own cursor), end to end, whatever the broker did. @@ -274,12 +331,14 @@ BPS-lite — that is a service message, and this SWIP has none; an application t service feed the family adds. A publisher stream going away — closed or reset — is therefore not an end: the cohort -and its cursor persist, subscribers stay attached, and the admin rejoins with `Auth` and -resumes at `n > cursor`. Likewise a subscriber-only cohort waits for its admin until the -deadline reclaims it. A cohort with **no attached streams** MAY be reclaimed at once — -nothing observes the difference beyond a fresh cursor on the next `Join`. - -After a cohort is forgotten a later `Join` creates a fresh one with an empty cursor. +and its cursor persist, subscribers stay attached, and the admin rejoins, claiming in its +`Join` with the `S` it holds and the cursor it knows. Likewise a subscriber-only cohort +waits for its admin until the deadline reclaims it. A cohort with **no attached streams** +MAY be reclaimed at once — nothing observes the difference beyond a fresh cursor on the +next `Join`. + +After a cohort is forgotten a later `Join` creates a fresh one with the cursor at 0 — +and the same `S`, since the broker derives it, so the admin's claim still holds. Subscribers keep their own cursor across that, so a stale republication is caught at the edge, not at the broker. @@ -290,7 +349,7 @@ given RECOMMENDED where a value is given; the last two are MAY: | bound | answer | recommended | |---|---|---| -| subscriber streams per cohort | `FULL` to the next `Join` without an admin `Auth` — the audience cannot lock the admin out of its own cohort | implementation-defined | +| subscriber streams per cohort — the fan-out set | `FULL` to the next `Join` — except **one extra stream while the admin is absent**: a `Join` declaring the admin's `addr` when no publisher stream exists is admitted over the bound, and disconnected, with a short blocklist, if it has not claimed within the **claim deadline** | implementation-defined; claim deadline 30 s, long enough for a wallet prompt (?) | | live cohorts per broker | `FULL` to a cohort-creating `Join` | implementation-defined | | **cohorts per peer connection** — a peer cannot flood the broker with bogus cohorts while keeping one legitimate stream open | `FULL` | 16 | | **inactivity deadline** — reclaims a cohort, see *Lifetime* | reset | 10 min | @@ -300,9 +359,12 @@ given RECOMMENDED where a value is given; the last two are MAY: Any peer can make a broker allocate a cohort simply by joining, which is why the second, third and fourth bounds exist together: a cohort costs a map entry, a peer can hold only -a bounded number of them, and none survives idleness. The broker has no delivery -obligation: a reset for a full queue is a recoverable liveness fault, and blocking -fan-out on one slow subscriber would punish the cohort. +a bounded number of them, and none survives idleness. The extra stream is what keeps an +audience that fills the cohort while the admin is away from locking the admin out: an +unclaimed admin is a fan-out channel like any other, and its slot exists exactly while it +is absent; a returning admin that claims in its `Join` never needs it. The broker has no +delivery obligation: a reset for a full queue is a recoverable liveness fault, and +blocking fan-out on one slow subscriber would punish the cohort. ### Relation to SWIP-60 @@ -311,38 +373,22 @@ The relation is subset, and it is kept in one direction only — **later revisio this wire, they never change it**: - a BPS-lite peer sends only frames the full protocol accepts, and a BPS-lite broker - accepts only what this SWIP defines; everything the full protocol adds to the - handshake arrives in fields this SWIP reserves, so a full-protocol `Join` at a - BPS-lite broker is `REJECTED` at the handshake rather than silently misread, and - everything it adds to `Message` arrives in fields this SWIP does not define and - ignores; + accepts only what this SWIP defines: a spec value outside this SWIP is `REJECTED`, a + field this SWIP does not define is ignored, so a fuller spec is served as the lite spec + its known fields spell — a feed-topic cohort whose admin later publishes a roster is, + at a BPS-lite broker, a live stream whose roster and grantees' updates are dropped as + invalid; an admin that wants a roster needs a full broker; - a BPS-lite publisher and subscriber at a full broker are conformant peers of a - single-publisher live-stream cohort; the full broker's additions (service messages, - further `Ack` fields) reach them as frames they drop and fields they ignore; + single-publisher live-stream cohort; the full broker's additions (service messages) + reach them as frames they drop; - validation here is stricter, never looser: the cursor refuses out-of-order - retransmits a full broker may pass through; no frame a BPS-lite broker accepts is one - a full broker refuses. - -SWIP-60 is to be amended to match — its author's to-do, listed here so that the subset -claim is checkable: - -- `Hello`/`Open`/`Subscribe` collapse into `Join` carrying the spec, and **cohorts are - keyed by the whole spec, not by the topic**: a `Join` whose topic is live under a - different spec creates a second cohort rather than being `REJECTED`, so a squatter - who pre-creates `(topic, wrong admin)` obtains nothing; -- `GENESIS` goes (with the spec in `Join` it has nothing left to prove) and `Ack` echoes - nothing; -- the publisher regime shrinks to a single value, `ALL` — anyone attached may publish; - absent, the admin publishes and whoever its roster ever names, so a cohort is - multi-publisher iff its admin publishes a roster, and nobody needs to know in advance; -- **`spectators` (field 9) goes, or reverts to a `closed` flag whose unset value means an - open audience** — as a proto3 bool whose unset value is *false*, it reads a lite - spec, which never sets it, as a closed cohort, and a full broker would refuse every - lite subscriber; -- the chunk travels as opaque chunk data. - -And one line in SWIP-65: its carriage rule, under which a broker does not enforce index -monotonicity, gets the exception this SWIP's cursor is. + retransmits; no frame a BPS-lite broker accepts is one a full broker refuses. + +What SWIP-60 adds on this wire: cohort parameters (`publishers`, `history`, `closed`), +the admin's service feed (roster, end of stream) as `Message` frames, a claim by any +rostered address and none at all under `ALL`, one extra stream per absent rostered +publisher, and the Bee API. What it presumes of the transport, this SWIP states below as +a precondition. ## Rationale @@ -359,25 +405,49 @@ live. topic and learns the spec from the broker has to be told the spec by someone it then has to verify. For a live stream the spec is a topic and an address; the invite that names the broker names them too. Once every joiner carries the spec, the spec is the -cohort's identity, the broker is never trusted about it, `Ack` is a status, the -first-joiner-creates rule is the natural one — and a second handshake type has nothing -to do. - -**Why the identity is on the stream.** `Auth` is in `Join`, and what it proves is bound -to the stream it arrives on. A peer connection is a node; a stream is a (node, cohort) -pair; the identity that matters is the key that signs the messages on that stream. Binding -it there is what lets one node carry different identities on different cohorts, and what -later lets a subscriber stream be promoted to a publisher stream in place, without a -rejoin, when the admin grants it. +cohort's identity, the broker is never trusted about it, `Ack` is a status and a +challenge, and the first-joiner-creates rule is the natural one. + +**Why a challenge, and what it protects.** A static signature — over the topic and the +admin, say — would be replayable by design, and the argument that a replayed role can +publish only what its owner already signed is true and beside the point: replaying the +admin's signed updates is *history*, and a first-time viewer receiving them from the +beginning is caught up, not deceived. What a replayed signature buys is an **identity**: +a stream the broker treats as the admin's — exempt from the fan-out bound and its queue +policy, entitled to the extra slot, and in SWIP-60 admitted to a `closed` cohort and +promoted by a roster. A challenge only this broker could have issued, for this address, +signed together with the verifier's overlay, is what makes the identity worth exactly +the key. Deriving it from a boot secret and the spec is what lets the broker issue it +without storing it, and lets an address keep its claim from any node and across +reclaims. + +**Why the identity is on the stream, and the address is declared.** A peer connection is +a node; a stream is a (node, cohort) pair; the identity that matters is the key that +signs the messages on that stream. Declaring the address in `Join` lets the broker derive +the challenge for it, and gives the extra slot only to a stream that says it is the +admin's. Binding the identity to the stream is what lets one node carry different +identities on different streams — and, in SWIP-60, what lets a subscriber stream be +upgraded by a claim when the admin grants it. + +**Why the frame is a whole chunk.** The ordinary SOC validation checks that the signer +hashes to the address. Without the address on the frame there is nothing to hash to, and +a broker that only recovered a signer would accept any signature at all. + +**Why no reply to a claim.** After the handshake each direction carries one frame type, +and the wire has no envelope. A reply would be a second broker-to-peer type on a stream +that carries deliveries, with nothing to tell them apart. It is also unnecessary: streams +are ordered, so a publisher sends its claim and its first publication together and learns +the outcome from whether the stream survives. **Why no loopback.** A publisher knows what it published. Echoing it costs a frame per message on the stream whose back-pressure matters most, and buys no confirmation: a rejected frame produces no echo either. -**Why the cursor, not a window.** A bounded seen-set is a memory bound with a replay -hole at its edge, and it exists because the general protocol admits many publishers and +**Why the cursor, not a window.** A bounded seen-set is a memory bound with a replay hole +at its edge, and it exists because the general protocol admits many publishers and duplicate paths. One publisher on one hop has neither. The feed index is a total order, -and "greater than the last" is both the dedup rule and the memory bound. +and "at least the next" is both the dedup rule and the memory bound — and the claim can +declare it, because a signed lower bound is exactly what a reconnecting publisher knows. **Why inactivity, and only inactivity.** An end that is *signed* by the admin is a service message, which this SWIP does not have; an end that is *inferred* from the admin's stream @@ -391,17 +461,33 @@ service feed arrives. Those of SWIP-60, restricted to its live-stream configuration (admin set, the admin alone publishes, open audience). -**The publisher role is proved, not asserted**: `Auth` recovers to `admin`, and it grants -nothing the admin's key has not already granted. Its preimage is static, so it is -replayable: a peer that captured the admin's `Join` can obtain a publisher stream, but -can send on it only what the admin already signed. Within one cohort's life the cursor -refuses that (every captured index is at or below it); **across a reclaim or at another -broker the cursor starts empty, and the base protocol does not distinguish a replay of -the admin's history from the admin's history**. Freshness is therefore a subscriber-side -property: a subscriber SHOULD keep its cursor per `(topic, admin)` across rejoins, and an -application that needs freshness for a first-time viewer carries it in the payload -(SWIP-65's timestamp key does). Fan-out of a replayed history is bounded by what was -captured and by the inactivity deadline. +**The publisher role takes the key, every time.** A claim is a signature over a challenge +that only this broker could have issued for this address, together with the verifier's +overlay and the publisher's cursor. What can be captured and replayed, by whom, and what +stops it: + +| replayed | by whom | where | result | stopped by | +|---|---|---|---|---| +| the claim signature | a third party or another subscriber | anywhere | cannot obtain it | it travels on the encrypted stream to the broker and nowhere else | +| the claim signature | the node that bridged the admin (holds it, not the key) | this broker, while it runs, from any node | accepted — it upgrades | nothing, by design: the identity continues from another node; that node held the admin's stream anyway and can publish only what the key signed | +| the claim signature | the same node | this broker after a restart, or another broker, or another cohort here | refused: recovers to some other address | `S_C` drawn at boot and never persisted; `O_B` names the verifier; the spec is in `S` | +| the claim signature | anyone | for another address | refused | the address is in `S`, and the signer must equal the declared address | +| the claim with a changed `index` | anyone holding one | anywhere | refused | `index` is inside the signed preimage | +| the challenge, forwarded | a relay or impostor broker the publisher was pointed at: it fetches `S` from the honest broker, hands it over, forwards the signature | the honest broker | refused | the publisher signs the overlay it is talking to, the relay's, and the honest broker checks its own | +| the claim signature as a SOC signature, or the reverse | anyone | anywhere | refused | the domain separator against the SOC's `id ‖ wrappedAddress` | +| the challenge | anyone, by declaring the admin's address | this broker | harmless: `S` proves nothing without the key; the declaration buys the extra slot until the claim deadline, then a blocklist | the claim, not `S`, is the credential | +| the admin's publications | a subscriber or third party | this broker, cohort alive | violation: reset, blocklist | a publication is accepted on a publisher stream only, and that takes the key | +| the admin's publications | the admin's own node, or the admin | this broker, cohort alive | dropped as retransmits, counted | the cursor | +| the admin's publications | the admin's node, or a broker that carried the cohort | after a reclaim, or at another broker, to first-time viewers | delivered — **history**, genuine updates in order, a late viewer caught up | nothing needed; returning viewers keep their cursor per `(topic, admin)`, first-time freshness is the payload's (SWIP-65) | +| a `Join` | anyone | anywhere | attaches or creates, as any join does — no privilege | nothing needed; the bounds and the inactivity deadline | + +**The transport precondition.** The forwarding row above rests on `O_B` being the overlay +the publisher's node is actually connected to, and the claim rests 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 — signed by the overlay's key but not tied to the connection — can +be presented by anyone who has seen it. (bee's handshake verifies the record and not the +binding as of this writing; the check is one comparison.) **No confidentiality**: the broker and every subscriber see plaintext; applications encrypt payloads. **The broker withholds, never forges**, and a withheld update is @@ -417,47 +503,57 @@ deadline, and a peer can hold only a bounded number of them. An implementation is BPS-lite conformant when: 1. a broker accepts a `Join` whose spec is `{topic, FEED_TOPIC, admin}` and answers any - other binding, an absent `admin`, or any reserved field set with `REJECTED`; + other binding or an absent `admin` with `REJECTED`, ignoring fields it does not define; 2. a `Join` for a spec with no live cohort creates it, whoever sends it; a `Join` with a - byte-identical spec attaches to it; the broker never sends status 3; -3. `Auth` is verified by recovery over `keccak256("bps-join:v1" ‖ topic ‖ admin)`; a - stream whose `Auth` recovers to `admin` is a publisher stream; any other stream is a - subscriber stream; the identity is a property of the stream; -4. `Ack` carries a status and nothing else; a non-`OK` `Ack` ends the stream; -5. a `Message` on a subscriber stream is dropped, the stream reset, the peer blocklisted; -6. a `Message` on a publisher stream is accepted iff its bare index exceeds the cursor, - it validates as a single-owner chunk under `keccak256(topic ‖ index)`, and its owner - is `admin`; accepted frames advance the cursor and are delivered unchanged to every - subscriber stream in the cohort, and to no publisher stream; -7. `n ≤ cursor` is counted as a retransmit, not a violation, and there is no other - dedup state; gaps are accepted; index 0 is accepted on a fresh cohort; + byte-identical canonical serialisation attaches to it; no status other than `OK`, + `FULL` and `REJECTED` is ever sent; +3. the challenge is derived as specified from a boot secret, the canonical spec and the + declared address, issued iff an address was declared, and nothing is stored for it; +4. a claim — in `Join`, or as a subscriber stream's next frame — is verified over + `keccak256("bps-claim:v1" ‖ S ‖ O_B ‖ index)`: the signer equals `addr` and `addr` is + the admin; the stream then becomes a publisher stream and the cursor becomes + `max(cursor, index)`; no reply is sent; a claim in `Join` is verified before any bound; + a stale claim in `Join` is treated as absent; any other claim is a violation; +5. a frame on a subscriber stream that is not a valid claim is dropped, the stream reset, + the peer blocklisted; +6. a `Message` on a publisher stream is accepted iff its bare index is at least the + cursor, the chunk validates as a single-owner chunk under `keccak256(topic ‖ index)` + with the owner hashing to `address`, and `address` is `keccak256(id ‖ admin)`; accepted + frames set the cursor past their index and are delivered unchanged to every subscriber + stream in the cohort, and to no publisher stream; +7. `n < cursor` is counted as a retransmit, not a violation, and there is no other dedup + state; gaps are accepted; index 0 is accepted on a fresh cohort; 8. a cohort with no accepted message for the inactivity deadline is reclaimed, every stream in it reset; a publisher stream going away does not end the cohort; 9. the capacity bounds are enforced, `FULL` is issued at capacity and nothing else is; + one extra stream is admitted for a `Join` declaring the admin's address while no + publisher stream exists, and disconnected if it has not claimed within the claim + deadline; 10. a subscriber re-verifies every delivery against the spec it joined with and its own cursor; -11. a BPS-lite publisher and subscriber interoperate with a full SWIP-60 broker on a - single-publisher cohort once SWIP-60 is amended per *Relation to SWIP-60*. +11. the node verifies in the p2p handshake that a peer's signed address record names the + connection's authenticated peer ID; +12. a BPS-lite publisher and subscriber interoperate with a full SWIP-60 broker on a + single-publisher cohort. A broker MUST expose per-cohort counters for the silent outcomes — `invalid_index`, -`invalid_soc`, `wrong_owner`, `wrong_stream`, `retransmit`, `queue_reset` — since -items 6, 7 and 9 are unobservable from the wire without them. +`invalid_soc`, `wrong_owner`, `wrong_stream`, `invalid_claim`, `retransmit`, +`queue_reset` — since items 5–7 are unobservable from the wire without them. ## Out of scope (deliberately) Service messages of any kind (roster, end of stream) and the Bee API bridge; multiple -publishers and the promotion of a subscriber stream to a publisher stream; the -self-indexed payload construction, gap recovery and persistence of -[SWIP-65](https://github.com/ethersphere/SWIPs/pull/106); multihop -([SWIP-61](https://github.com/ethersphere/SWIPs/pull/105)); history; bandwidth +publishers and the claim by a rostered address; the self-indexed payload construction, +gap recovery and persistence of [SWIP-65](https://github.com/ethersphere/SWIPs/pull/106); +multihop ([SWIP-61](https://github.com/ethersphere/SWIPs/pull/105)); history; bandwidth incentives; broker discovery ([SWIP-59](https://github.com/ethersphere/SWIPs/pull/103)); confidentiality of any kind. Each extends this SWIP's wire without changing it. ## Backwards compatibility New protocol; no existing behaviour changes. Every message and field number here is -kept by the fuller protocol, which extends by adding fields and messages and never by -changing these; a BPS-lite peer ignores fields it does not define. +kept by the fuller protocol, which extends by adding fields, values and messages and +never by changing these; a BPS-lite peer ignores fields it does not define. ## References @@ -465,7 +561,8 @@ Full singlehop protocol: [SWIP-60, PR #104](https://github.com/ethersphere/SWIPs · carriage: [SWIP-65 self-indexed feeds, PR #106](https://github.com/ethersphere/SWIPs/pull/106) · first draft of this SWIP: [PR #111](https://github.com/ethersphere/SWIPs/pull/111) · origin: [PR #93](https://github.com/ethersphere/SWIPs/pull/93) "Add: pubsub" -· implementation groundwork: bee [#5435](https://github.com/ethersphere/bee/pull/5435) +· implementation: bee [#5626](https://github.com/ethersphere/bee/pull/5626) (groundwork: +bee [#5597](https://github.com/ethersphere/bee/pull/5597), [#5435](https://github.com/ethersphere/bee/pull/5435)) ## Copyright From 1436edbf1bdc4b521bfe719975b275dd1e20c058 Mon Sep 17 00:00:00 2001 From: zelig Date: Thu, 24 Sep 2026 16:41:19 +0200 Subject: [PATCH 5/5] swip-74 rev 3 fixes: signing convention marked, stale-vs-wrong in-Join claims, counters mapped, bounds counted - the claim's signing convention (SOC's EIP-191 prefixed digest) stated and marked (?) - a claim in the Join that does not verify is treated as absent (the broker cannot tell stale from wrong); the peer notices by the S in the Ack; only a post-Ack Claim that fails is a violation; a Claim on a publisher stream is read as a Message - counters: wrong_stream vs invalid_claim defined; claim_timeout added - "first five bounds REQUIRED"; Security counts the bounds the same way - the transport precondition names only what it rests on (O_B) Co-Authored-By: Claude Fable 5.1 --- SWIPs/swip-74.md | 63 ++++++++++++++++++++++++++++-------------------- 1 file changed, 37 insertions(+), 26 deletions(-) diff --git a/SWIPs/swip-74.md b/SWIPs/swip-74.md index 7f2937f4..4dbebef5 100644 --- a/SWIPs/swip-74.md +++ b/SWIPs/swip-74.md @@ -140,9 +140,10 @@ message Auth { 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`: S the challenge the broker issued for `addr` on this +// A publisher's claim on the stream it is sent on. `auth` signs the bytes +// "bps-claim:v1" || S || O_B || index +// with the key of `addr`, in the same convention as a SOC signature (the EIP-191 +// prefixed digest bee and bee-js use) (?): S the challenge the broker issued for `addr` on this // cohort, O_B the overlay of the broker the claiming node is connected to, `index` // eight bytes big-endian. Sent inside Join by a peer that already holds S, or as the // next frame after Ack by one that has just received it. @@ -220,10 +221,12 @@ joins, and differs at every other broker and after every restart. It goes to the over the encrypted stream and is useful only to the key of `addr`; anyone may obtain it by declaring the address, and gains nothing by it. -**The claim.** A publisher claims its stream by signing, with the key of `addr`, +**The claim.** A publisher claims its stream by signing, with the key of `addr` and in +the same convention as a SOC signature — the EIP-191 prefixed digest bee and bee-js use +**(?)** — the bytes ``` -keccak256("bps-claim:v1" ‖ S ‖ O_B ‖ index) +"bps-claim:v1" ‖ S ‖ O_B ‖ index ``` where `O_B` is the overlay of the broker the claiming node is connected to and `index` @@ -237,8 +240,11 @@ The claim is sent in one of two places: - **in `Join`**, by a peer that already holds `S` for this address at this broker — a reconnecting admin, from the same node or another: the broker verifies it before any bound is applied, and a valid claim makes the stream a publisher stream from its first - frame. A stale claim — the broker has restarted, `S` has changed — is treated as - absent: `Ack{OK, S}` with the new `S`, no penalty; + frame. A claim in the `Join` that does not verify — stale because the broker has + restarted and `S` has changed, or simply wrong, or naming an `addr` other than the + `Join`'s: the broker cannot tell these apart — is treated as absent: `Ack{OK, S}` with + the current `S`, no penalty. The peer sees that its claim was not taken when the `S` in + the `Ack` differs from the one it signed, and claims again after the `Ack`; - **as the next frame after `Ack`**, a `Claim`, by a peer that has just received `S`. The broker recovers the signer and checks that it equals `addr` (ecrecover never fails, @@ -248,10 +254,11 @@ it returns *some* address, which is why the address is declared and compared) an becomes `max(cursor, index)`. There is **no reply**: a publisher sends its claim and its first publication back to back, and stream ordering guarantees the broker handles the claim first; a claim that did not upgrade makes the publication that follows a violation, -and the reset is the answer. Anything else — a signature that does not recover to `addr`, -an `addr` that is not the admin, a second claim on a stream — is a protocol violation: -dropped, counted (`invalid_claim`), the stream reset, the peer blocklisted per the node's -policy. Several streams MAY be claimed for the admin at once — the admin from two nodes, +and the reset is the answer. A `Claim` after the `Ack` that does not verify — a signature +that does not recover to `addr`, or an `addr` that is not the admin — is a protocol +violation: dropped, counted (`invalid_claim`), the stream reset, the peer blocklisted per +the node's policy; a `Claim` sent on a publisher stream is read as a `Message` and fails as +one. Several streams MAY be claimed for the admin at once — the admin from two nodes, or reconnecting before its old stream is torn down: each is a publisher stream, and the cursor arbitrates. @@ -289,8 +296,9 @@ initially 0 — index 0 is the first update of every feed. A `Message` arriving broker is accepted iff, in order: 1. it arrived on a **publisher stream** — on a subscriber stream the frame 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 per the node's policy; + `Claim`, and if it is not a valid one it is a protocol violation: dropped, counted + (`wrong_stream` if it does not even parse as a `Claim`, `invalid_claim` if it does and + fails), the stream reset, the peer blocklisted per the node's policy; 2. its `id` slot is a bare index `n` and **`n ≥ cursor`**; 3. with the `id` slot rewritten to `keccak256(topic ‖ n)` the chunk **validates as a single-owner chunk**: the wrapped chunk's BMT address matches `span ‖ payload`, the @@ -344,12 +352,12 @@ edge, not at the broker. ### Resource bounds -All broker policy, none on the wire. The first four bounds are REQUIRED, with the values +All broker policy, none on the wire. The first five bounds are REQUIRED, with the values given RECOMMENDED where a value is given; the last two are MAY: | bound | answer | recommended | |---|---|---| -| subscriber streams per cohort — the fan-out set | `FULL` to the next `Join` — except **one extra stream while the admin is absent**: a `Join` declaring the admin's `addr` when no publisher stream exists is admitted over the bound, and disconnected, with a short blocklist, if it has not claimed within the **claim deadline** | implementation-defined; claim deadline 30 s, long enough for a wallet prompt (?) | +| subscriber streams per cohort — the fan-out set | `FULL` to the next `Join` — except **one extra stream while the admin is absent**: a `Join` declaring the admin's `addr` when no publisher stream exists is admitted over the bound, and disconnected, with a short blocklist and counted (`claim_timeout`), if it has not claimed within the **claim deadline** | implementation-defined; claim deadline 30 s, long enough for a wallet prompt (?) | | live cohorts per broker | `FULL` to a cohort-creating `Join` | implementation-defined | | **cohorts per peer connection** — a peer cannot flood the broker with bogus cohorts while keeping one legitimate stream open | `FULL` | 16 | | **inactivity deadline** — reclaims a cohort, see *Lifetime* | reset | 10 min | @@ -470,7 +478,7 @@ stops it: |---|---|---|---|---| | the claim signature | a third party or another subscriber | anywhere | cannot obtain it | it travels on the encrypted stream to the broker and nowhere else | | the claim signature | the node that bridged the admin (holds it, not the key) | this broker, while it runs, from any node | accepted — it upgrades | nothing, by design: the identity continues from another node; that node held the admin's stream anyway and can publish only what the key signed | -| the claim signature | the same node | this broker after a restart, or another broker, or another cohort here | refused: recovers to some other address | `S_C` drawn at boot and never persisted; `O_B` names the verifier; the spec is in `S` | +| the claim signature | the same node | this broker after a restart, or another broker, or another cohort here | not accepted: it recovers to some other address — in a `Join` it is treated as absent and a fresh `S` issued, after an `Ack` it is a violation | `S_C` drawn at boot and never persisted; `O_B` names the verifier; the spec is in `S` | | the claim signature | anyone | for another address | refused | the address is in `S`, and the signer must equal the declared address | | the claim with a changed `index` | anyone holding one | anywhere | refused | `index` is inside the signed preimage | | the challenge, forwarded | a relay or impostor broker the publisher was pointed at: it fetches `S` from the honest broker, hands it over, forwards the signature | the honest broker | refused | the publisher signs the overlay it is talking to, the relay's, and the honest broker checks its own | @@ -482,8 +490,7 @@ stops it: | a `Join` | anyone | anywhere | attaches or creates, as any join does — no privilege | nothing needed; the bounds and the inactivity deadline | **The transport precondition.** The forwarding row above rests on `O_B` being the overlay -the publisher's node is actually connected to, and the claim rests on the broker knowing -the peer it is talking to. A BPS node MUST verify, in the p2p handshake, that a peer's +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 — signed by the overlay's key but not tied to the connection — can be presented by anyone who has seen it. (bee's handshake verifies the record and not the @@ -493,7 +500,8 @@ binding as of this writing; the check is one comparison.) encrypt payloads. **The broker withholds, never forges**, and a withheld update is visible as a gap in the index. **No end signal**: a broker can end a cohort for its audience by resetting their streams, which is withholding, nothing more. **Resource -bounds are policy and the four capacity bounds are required** (above); the cursor +bounds are policy; the three capacity bounds, the inactivity deadline and the queue bound +are required** (above); the cursor removes the dedup-window bound and its edge. A subscriber that publishes is a protocol violation and is blocklisted; a squatted cohort is one map entry for one inactivity deadline, and a peer can hold only a bounded number of them. @@ -509,11 +517,12 @@ An implementation is BPS-lite conformant when: `FULL` and `REJECTED` is ever sent; 3. the challenge is derived as specified from a boot secret, the canonical spec and the declared address, issued iff an address was declared, and nothing is stored for it; -4. a claim — in `Join`, or as a subscriber stream's next frame — is verified over - `keccak256("bps-claim:v1" ‖ S ‖ O_B ‖ index)`: the signer equals `addr` and `addr` is - the admin; the stream then becomes a publisher stream and the cursor becomes - `max(cursor, index)`; no reply is sent; a claim in `Join` is verified before any bound; - a stale claim in `Join` is treated as absent; any other claim is a violation; +4. a claim — in `Join`, or as a subscriber stream's next frame — is verified over the + bytes `"bps-claim:v1" ‖ S ‖ O_B ‖ index` in the SOC signing convention: the signer + equals `addr` and `addr` is the admin; the stream then becomes a publisher stream and + the cursor becomes `max(cursor, index)`; no reply is sent; a claim in `Join` is verified + before any bound, and treated as absent if it does not verify; a `Claim` after the + `Ack` that does not verify is a violation; 5. a frame on a subscriber stream that is not a valid claim is dropped, the stream reset, the peer blocklisted; 6. a `Message` on a publisher stream is accepted iff its bare index is at least the @@ -537,8 +546,10 @@ An implementation is BPS-lite conformant when: single-publisher cohort. A broker MUST expose per-cohort counters for the silent outcomes — `invalid_index`, -`invalid_soc`, `wrong_owner`, `wrong_stream`, `invalid_claim`, `retransmit`, -`queue_reset` — since items 5–7 are unobservable from the wire without them. +`invalid_soc`, `wrong_owner`, `wrong_stream` (a subscriber-stream frame that is not a +`Claim`), `invalid_claim` (a `Claim` that fails), `claim_timeout` (an extra stream +disconnected at the claim deadline), `retransmit`, `queue_reset` — since items 5–7 and 9 +are unobservable from the wire without them. ## Out of scope (deliberately)