docs(adr): 094 — per-identity channel cap as DoS defense
ADR-076 framed the per-connection max_channels=256 cap as the DoS defense, but a peer can open an unbounded number of transport connections, so a per-connection cap bounds a connection's reassembly-buffer cost, not a peer's total channels. The only coherent unit for a channel DoS defense is the identity. ADR-094 records the corrected design: a ChannelLifecyclePolicy trait in channels-call (where the identity is already on OperationContext), consulted by the channel/open handler (after AccessControl::check, before allocation) and the channel/close handler (after the drain completes). Default is PerIdentityChannelPolicy::new(256) — 256 per PeerId across all the peer's connections, shared via Arc across every channels connection a peer accepts. The cap is a peer concern (not hub-specific), symmetric (both sides enforce), and lives in channels-call because the channels layer is auth-blind by design (ADR-075) — that is what makes it WASM-compatible, transport-agnostic, and ALPN-blind. For the hub-relay path (ADR-079), the spoke sees the hub as the direct caller (ADR-032 — forwarded_for is metadata, not authority, for the cap as for AccessControl::check), so the spoke caps the hub, not the browser. A spoke serving a high-fan-out hub sets the hub peer's cap higher via with_per_identity_caps — the spoke's own policy, not the hub's. Recursive channels do not bypass the cap (the same policy can be wired into the inner ChannelOperations). ADR-076 is amended: the per-connection max_channels is reframed as a per-connection memory bound (still returns channel:too_many_channels when hit), the "DoS defense summary" table is removed, and the "per-connection, not per-peer" line (the channels layer confessing a hole and hoping the layer above would fill it) is corrected. Spec docs updated to reference ADR-094: channel-operations.md gains a "Per-identity channel cap" section (trait, default, enforcement point, relay consequence, recursion); channels-adapter.md adds the policy check as step 3 of the channel/open handler and the decrement in channel/close; hub README adds the channel_policy field on Hub, the with_channel_policy builder, a dedicated subsection, and the inbound-peer-vs-hub-as-caller distinction; channels/README.md adds ADR-094 to the Applicable ADRs table and a 9th Key Design Principle; docs/architecture/README.md adds a Current State note and the ADR table row.
This commit is contained in:
1 parent
762d9c7bd2
commit
0fcd5bc322
7 files changed
+789
-51
No files matched your search
@@ -5,7 +5,54 @@
|
||||
Accepted (amended 2026-07-18 by ADR-093 — backpressure is per-`channel_id`,
|
||||
not per-`(channel_id, stream_type)`; the channels layer has one reassembly
|
||||
buffer per channel, yielding a `BiStream` — see "Amendment (ADR-093,
|
||||
2026-07-18)" below)
|
||||
2026-07-18)" below; **amended 2026-07-19 by ADR-094 — the per-connection
|
||||
`max_channels = 256` is reframed as a per-connection memory bound, not a
|
||||
DoS defense; the per-identity DoS defense lives in `channels-call` via
|
||||
`ChannelLifecyclePolicy` — see "Amendment (ADR-094, 2026-07-19)" below**)
|
||||
|
||||
## Amendment (ADR-094, 2026-07-19)
|
||||
|
||||
The per-connection `max_channels = 256` cap is **reframed as a
|
||||
per-connection memory bound**, not a DoS defense. A single peer can
|
||||
open an unbounded number of transport connections, so a per-connection
|
||||
cap is not a per-peer DoS defense — it is a bound on one connection's
|
||||
reassembly-buffer cost. The per-identity DoS defense (256 per
|
||||
`PeerId`, enforced in `channels-call` via `ChannelLifecyclePolicy`)
|
||||
is documented in [ADR-094](094-per-identity-channel-cap.md).
|
||||
|
||||
What changes in this ADR:
|
||||
|
||||
1. **§"Maximum channels per connection: 256 default"** — the cap stays
|
||||
at 256, but its role is reframed. It is a per-connection memory
|
||||
bound (limits one connection's reassembly-buffer cost regardless of
|
||||
policy), not the DoS defense against an authenticated peer. The
|
||||
per-identity DoS defense is the `ChannelLifecyclePolicy`
|
||||
consultation in the `channel/open` handler (ADR-094).
|
||||
2. **§"DoS defense summary"** — the table is **removed**. It framed
|
||||
the per-connection cap as the DoS defense, which it is not. ADR-094
|
||||
§2 contains the corrected per-identity DoS defense summary.
|
||||
3. **The "per-connection, not per-peer — a peer can open more channels
|
||||
on a second connection" line** — this was the channels layer
|
||||
confessing a hole and hoping the layer above it would fill it. The
|
||||
line is **corrected** to state that the per-connection cap is a
|
||||
memory bound, and that the per-identity cap is the DoS defense
|
||||
(ADR-094). A peer that opens a second connection gets a second
|
||||
per-connection memory bound; it does **not** get a second
|
||||
per-identity quota — the `ChannelLifecyclePolicy` is shared across
|
||||
connections.
|
||||
|
||||
What stays:
|
||||
|
||||
- The 256 default and the `max_channels` field on `ChannelManager`
|
||||
(still returns `channel:too_many_channels` when hit — the
|
||||
per-identity policy returns the same error code, so an over-cap
|
||||
peer sees the same error either way).
|
||||
- The bounded-buffer backpressure decision (DP-5) — unchanged.
|
||||
- The channel-ID reuse decision (monotonic `next_id` with
|
||||
wrap-around) — unchanged.
|
||||
- The drain-before-reuse invariant — unchanged, and the
|
||||
`channel/close` handler now also calls
|
||||
`ChannelLifecyclePolicy::on_close` at this point (ADR-094 §3).
|
||||
|
||||
## Amendment (ADR-093, 2026-07-18)
|
||||
|
||||
@@ -88,36 +135,47 @@ default `max_channels` of 256, the `u32` space is effectively unlimited
|
||||
which time old channels are long drained. **The "reuse" in OQ-CH-04 is
|
||||
satisfied by the wrap-around, not by a free-list.**
|
||||
|
||||
### Maximum channels per connection: 256 default (OQ-CH-05/06)
|
||||
### Maximum channels per connection: 256 default (OQ-CH-05/06 — memory bound)
|
||||
|
||||
The `channel_id` is `u32` — the wire format supports ~4 billion channels.
|
||||
The practical limit is memory (reassembly buffers per channel) and the
|
||||
transport's flow control.
|
||||
|
||||
**Default per-connection channel limit: 256** (`max_channels` field on
|
||||
`ChannelManager`, configurable). This is the DoS defense (OQ-CH-06): an
|
||||
authenticated peer that opens many channels and never reads from them is
|
||||
bounded by `max_channels × buffer_cap` = 256 × 1 MiB = 256 MiB worst case.
|
||||
Bounded buffers (DP-5) limit the damage per channel; the connection cap
|
||||
limits the number of channels. Defense in depth.
|
||||
`ChannelManager`, configurable). This is a **per-connection memory
|
||||
bound**: it limits one connection's reassembly-buffer cost (256 × 1 MiB
|
||||
= 256 MiB worst case per connection) regardless of policy. It composes
|
||||
with the per-identity DoS defense (ADR-094) but is not itself a DoS
|
||||
defense — a peer can open an unbounded number of transport connections,
|
||||
so a per-connection cap cannot bound a peer's total channels. The
|
||||
per-identity DoS defense (256 per `PeerId`, enforced in `channels-call`
|
||||
via `ChannelLifecyclePolicy`) is documented in
|
||||
[ADR-094](094-per-identity-channel-cap.md).
|
||||
|
||||
Exceeding the limit returns `channel:too_many_channels` (ADR-073 error
|
||||
codes). The limit is per-connection, not per-peer — a peer can open more
|
||||
channels on a second connection.
|
||||
Exceeding the per-connection limit returns `channel:too_many_channels`
|
||||
(ADR-073 error codes) — the same error code the per-identity policy
|
||||
returns when the per-identity cap is hit. An over-cap peer sees the
|
||||
same error either way; which cap fired first is an implementation
|
||||
detail. The limit is per-connection as a memory bound; the per-identity
|
||||
cap (ADR-094) is what bounds a peer's total channels across all its
|
||||
connections.
|
||||
|
||||
### DoS defense summary (OQ-CH-06)
|
||||
|
||||
| Layer | Mechanism | Default |
|
||||
|-------|-----------|---------|
|
||||
| Per-channel | Bounded reassembly buffer (stop reading when full) | 1 MiB per `(channel_id, stream_type)` |
|
||||
| Per-connection | Channel count cap | 256 channels |
|
||||
| Per-peer | Auth (`AccessControl::check` on `channel/open`) | Assembly-layer policy |
|
||||
The DoS defense against an authenticated peer opening many channels is
|
||||
the **per-identity cap** enforced in `channels-call` via
|
||||
`ChannelLifecyclePolicy` — documented in
|
||||
[ADR-094](094-per-identity-channel-cap.md). A per-connection cap
|
||||
cannot be the DoS defense because a peer can open an unbounded number
|
||||
of transport connections; the unit that must be bounded is the
|
||||
identity, not the connection.
|
||||
|
||||
An authenticated peer that opens 256 channels and never reads from them
|
||||
consumes at most 256 MiB of reassembly buffers — bounded, not unbounded.
|
||||
The assembly layer's `AccessControl` policy can further restrict
|
||||
`channel/open` (e.g., `required_scopes: ["channel:open:alknet/tty"]`) to
|
||||
limit who can open channels at all.
|
||||
The per-connection `max_channels = 256` (this ADR) is a **memory
|
||||
bound** that limits one connection's reassembly-buffer cost. It
|
||||
composes with the per-identity cap as defense-in-depth (the
|
||||
`NoCap` policy path still has the per-connection memory bound), but
|
||||
it is not the security boundary. See ADR-094 §2 for the corrected
|
||||
DoS defense summary.
|
||||
|
||||
## Consequences
|
||||
|
||||
@@ -125,18 +183,21 @@ limit who can open channels at all.
|
||||
- Bounded-buffer backpressure is validated by the POC (1 MiB test, no
|
||||
deadlock, no cross-channel blocking). The decision is made, not hedged.
|
||||
- The 256-channel default cap with 1 MiB buffers gives a bounded 256 MiB
|
||||
worst-case memory per connection — a clear DoS ceiling, not an open-ended
|
||||
one.
|
||||
worst-case memory per connection — a clear per-connection memory
|
||||
ceiling, not an open-ended one. The per-identity DoS ceiling (256 per
|
||||
`PeerId` across all the peer's connections) is documented in ADR-094.
|
||||
- Monotonic `next_id` with wrap-around avoids free-list drain-tracking
|
||||
complexity while still satisfying ID reuse (on wrap, after ~16.7M
|
||||
channels).
|
||||
|
||||
**Negative:**
|
||||
- The 256-channel default may be too low for a hub with many concurrent
|
||||
browser sessions each opening multiple channels. The cap is configurable
|
||||
per `ChannelManager`; the hub assembly layer may set it higher for
|
||||
deployments with many concurrent sessions. This is a deployment-time
|
||||
decision, not an architecture decision.
|
||||
- The 256-channel per-connection cap may be too low for a hub with many
|
||||
concurrent browser sessions each opening multiple channels. The cap
|
||||
is configurable per `ChannelManager`; the hub deployment may set it
|
||||
higher for deployments with many concurrent sessions. This is a
|
||||
deployment-time decision, not an architecture decision. (The
|
||||
per-identity cap in ADR-094 is the DoS-relevant bound; the
|
||||
per-connection cap is a memory backstop.)
|
||||
- Bounded-buffer backpressure does not eliminate head-of-line blocking — it
|
||||
bounds the memory cost. A slow consumer still stalls its own channel's
|
||||
demux reads. For the intended use cases (TTY, SSH, tunnels) this is
|
||||
@@ -157,8 +218,19 @@ doesn't change the wire format, so even that reversal is feasible.
|
||||
by ADR-093)
|
||||
- ADR-093: channels pure channel multiplexing (amends this ADR —
|
||||
per-channel reassembly buffer, not per-`(channel_id, stream_type)`)
|
||||
- ADR-073: channel lifecycle operations (`channel:too_many_channels` error)
|
||||
- ADR-075: ChannelManager (`buffer_cap`, `max_channels`, `next_id` fields)
|
||||
- ADR-094: per-identity channel cap as DoS defense (amends this ADR —
|
||||
the per-connection `max_channels = 256` is reframed as a per-connection
|
||||
memory bound, not a DoS defense; the per-identity DoS defense lives in
|
||||
`channels-call` via `ChannelLifecyclePolicy`)
|
||||
- ADR-073: channel lifecycle operations (`channel:too_many_channels`
|
||||
error; the `channel/open` and `channel/close` handlers that gain the
|
||||
`ChannelLifecyclePolicy` consultation)
|
||||
- ADR-075: ChannelManager (`buffer_cap`, `max_channels`, `next_id`
|
||||
fields; the auth-blindness that forces the per-identity cap into
|
||||
`channels-call`, not `channels-core`)
|
||||
- ADR-032: forwarded-for identity (why the spoke caps the hub, not the
|
||||
browser — `forwarded_for` is metadata, not authority, for the cap as
|
||||
for `AccessControl::check`)
|
||||
- `docs/research/alknet-channels/poc-summary.md` §POC Target 1 (backpressure
|
||||
validation), §POC Target 3 (1 MiB tunnel test)
|
||||
- `docs/research/alknet-channels/phase-0-findings.md` §DP-5, §OQ-CH-03/04/
|
||||
|
||||
Reference in new issue
Block a user