KPS Protocol Specification (draft v0.2.1)

View on GitHub →

Status: draft for review. This document defines the wire-level and behavioural contract that every KPS implementation (Go, TypeScript, Rust, future Swift/Kotlin) must satisfy. The language libraries are implementations of this spec, not the spec itself. Where this document and a library disagree, this document is the intended source of truth and the library is a bug. The document version tracks the library releases (this draft, 0.2.1, pairs with the 0.2.x packages); the WebRTC wire version (§8 HELLO, currently 1) independently counts incompatible wire revisions.

KPS = Key Pinned Streams. A KPS endpoint is identified by a pinned self-signed certificate, not by a CA-signed domain name. KPS provides an authenticated, encrypted, multiplexed connection carrying unnamed reliable bidirectional byte streams, plus connection-level datagrams.


1. Terminology


2. Address format

<ip>:<udp-port>:<certhash>          # IPv4
[<ipv6>]:<udp-port>:<certhash>      # IPv6 (host bracketed)

The address identifies a UDP endpoint and a pinned server identity, nothing else. It MUST NOT encode stream names, application protocols, or transport selection. The same address is dialable by both transports; the dialer chooses the transport (§5.4). A listener binds dual-stack (one UDP socket serves both families), so one identity can be published at both a v4 and a v6 address — same certhash, same port. (Caveat: WebRTC over IPv6 needs the listener bound to a concrete v6 address for ICE candidate formation; QUIC works on the dual-stack wildcard.)

Future address formats (multiaddr-style, multiple certhashes, DNS names) are out of scope for v0.2 and explicitly deferred.


3. Certificate / key pinning

Pinning rules:

This v0.2 retains the libp2p webrtc-direct certhash encoding for ecosystem familiarity.


4. Connection model


5. Transports and demultiplexing

A KPS listener MUST accept both transports on the same UDP port behind the same address.

5.1 Packet demux (single UDP socket)

For each inbound UDP datagram:

  1. If its source address belongs to an established WebRTC connection, route it there (covers post-handshake DTLS/SCTP).
  2. Else if it is a STUN message (RFC 5389 magic cookie 0x2112A442 at bytes 4–7, leading two bits zero) → WebRTC path (a new or in-progress ICE handshake).
  3. Else → QUIC path: hand the datagram to the QUIC transport, which demultiplexes its own connections by connection ID and drops anything that is not a valid QUIC packet.

Equivalently: WebRTC is identified positively (STUN, or a known peer address); everything else is QUIC. This avoids fragile long-/short-header sniffing for established QUIC traffic.

5.2 WebRTC transport

KPS-over-WebRTC descends from libp2p webrtc-direct:

5.3 QUIC transport

5.4 Default transport selection


6. Stream semantics

A stream is an unnamed, bidirectional, reliable, ordered byte stream with no message boundaries. It models a subset of QUIC bidirectional streams.

6.1 Operations

There is deliberately no closeRead as the primary receive-side operation; receive-side termination is cancellation, expressed by cancelRead.

The public API MUST NOT expose: stream IDs, connection IDs, transport parameters, 0-RTT, migration, version negotiation, unidirectional streams (v0.2), or fine-grained flow-control knobs.

6.2 Stream mapping over WebRTC (internal framing)

SCTP data channels are reliable, ordered, and message-oriented, and offer no native half-close. To present a byte stream with QUIC-like lifecycle, KPS frames each data-channel message. This framing is internal to KPS and invisible to applications — applications see only a byte stream.

Each data-channel message is exactly one frame:

+--------+------------------------------+
| type=1 | payload (variable)           |
+--------+------------------------------+
type name payload meaning
0x00 DATA stream bytes append to the receiver's read buffer
0x01 FIN (empty) sender's write half finished → EOF after prior DATA
0x02 RESET uint32 error code sender aborted write half → receiver sees stream err
0x03 STOP_SENDING uint32 error code receiver cancelled read → sender should stop + reset
0x04 MAX_STREAM_DATA uint64 max offset flow-control credit for this stream's DATA (§6.5)

Write-half state machine (normative; each direction of a stream independently):

Write backpressure is governed by the §6.5 credit scheme, NOT by SCTP bufferedAmountbufferedAmount only measures data queued locally and says nothing about whether the remote application is reading. Implementations SHOULD still use bufferedAmount / bufferedAmountLowThreshold to bound their local send queue, but it is a resource limit, not the flow-control mechanism.

6.3 Stream mapping over QUIC

Direct mapping, no extra framing:

KPS QUIC
open stream open bidirectional stream
accept stream accept bidirectional stream
write / read stream write / read
closeWrite close send side (FIN)
cancelRead STOP_SENDING (CancelRead with code)
resetWrite RESET_STREAM (CancelWrite with code)
backpressure QUIC stream flow control

QUIC's native limits (MAX_STREAM_DATA, MAX_DATA, MAX_STREAMS) provide the same mechanisms the WebRTC mapping builds in §6.5. Implementations MUST ensure their QUIC transport is configured so that receive-window advancement is tied to application consumption and unread stream data and incoming stream counts remain bounded, consistent with §6.5. The WebRTC and QUIC numerical limits need not be identical; the observable semantics (§6.4) must be.

6.4 Interop requirement

A WebRTC client and a QUIC client talking to the same listener MUST observe identical stream semantics (EOF via closeWrite, error via resetWrite, stop-sending via cancelRead). The §6.2 framing exists precisely to make the WebRTC mapping behave like the QUIC mapping.

6.5 End-to-end flow control (WebRTC mapping)

SCTP has only an association-wide receive window, which the stacks drain eagerly regardless of application reads — and browsers expose no receive-side pressure API at all. Data channels alone therefore give a slow application no way to slow a fast sender. The WebRTC mapping closes this with QUIC-style credit at the KPS layer: it bounds KPS-managed unread stream data from conforming peers and detects peers that exceed the advertised limits. (Transport stacks may buffer additional bounded in-flight data before KPS can inspect it; the credit scheme cannot bound bytes a non-conforming peer puts into the transport before KPS observes and kills the connection.) This is part of WebRTC wire version 1 (§8 HELLO).

Three limits, all expressed as absolute (cumulative) values — monotone, idempotent, no wrap at 4 GiB:

limit bounds granted via initial value (HELLO)
stream data DATA payload bytes on one stream MAX_STREAM_DATA frame on that stream's channel initialMaxStreamData
connection data aggregate DATA payload bytes across all streams MAX_DATA control message (§8) initialMaxData
stream count cumulative application streams the peer may open MAX_STREAMS control message (§8) initialMaxStreams

Each limit governs one direction; the endpoint that receives grants credit for what the peer may send/open toward it. Initial values are receiver-chosen, announced in HELLO (§8), and MUST be honored as sent.

Sender rules:

Receiver rules:

Stream-count accounting (per direction, in transport events — not application acceptStream calls):

peerOpenedStreams    cumulative peer-initiated application channels observed
peerRetiredStreams   those that have reached their terminal state
advertisedMaxStreams initialMaxStreams + peerRetiredStreams

A peer-opened stream consumes a slot from the moment it is observed — including while it waits, unaccepted, in the incoming-stream queue (otherwise an application that stops accepting accumulates an unbounded queue).

Retirement is staged, and drainage is part of it — otherwise a peer could send one byte + FIN on each of many streams and reclaim its slots while the unread bytes (cheap in connection credit) pin an unbounded number of stream objects:

  1. A stream is wire-complete when both write halves have ended with a terminal frame on the wire: the local half by sending FIN or RESET, the peer's half by its FIN or RESET being received. cancelRead alone does NOT complete the read half — the peer's terminal frame must still arrive (a conforming peer answers STOP_SENDING with RESET), because that frame fixes the stream's final DATA offset for credit accounting.
  2. Once wire-complete, either endpoint MAY gracefully close the underlying data channel — legitimately even while the other endpoint still holds unread buffered data (drainage is not observable on the wire). An endpoint that is wire-complete and locally drained MUST initiate the close if the channel is still open, so retirement always makes progress.
  3. The stream is retired — and replacement MAX_STREAMS credit granted — only when it is wire-complete, its channel has closed, and all inbound DATA has been consumed by the application or explicitly discarded locally.

This makes initialMaxStreams a bound on live, unaccepted, or unread stream state, not merely on currently open SCTP channels.

Teardown accounting: stream frames are reliable and ordered, so both ends agree on a stream's final DATA offset when it ends with an ordered terminal frame (FIN or RESET) — no QUIC-style final-size signal is needed; bytes buffered but never delivered count as consumed and release connection credit. An application data channel that disappears while the connection is otherwise healthy and the stream was not wire-complete leaves connection-level credit ambiguous and MUST be treated as a connection failure (protocol-error), not tolerated per-stream. (A wire-complete stream's channel closing is the expected retirement step — and legitimate even if the closer cannot know whether this endpoint has drained; when channels are dying because the PeerConnection/SCTP association itself failed, the connection-level network-error path applies instead.)

Integers: all offsets, limits and counts are unsigned with a ceiling of MAX_OFFSET = 2^62 − 1 (QUIC's integer range). An initial limit or update above MAX_OFFSET is a protocol violation; a receiver's advertisements saturate at MAX_OFFSET; offset additions are overflow-checked. Arithmetic MUST be exact — JavaScript implementations MUST use BigInt (or equivalent), never rounding counters through floating point.

Recommended initial values: initialMaxStreamData 1 MiB, initialMaxData 8 MiB, initialMaxStreams 100. These trade throughput per stream against worst-case receiver memory per connection; exact values are a receiver policy, not a protocol constant. The steady-state bound on KPS-managed unread data is approximately initialMaxData plus per-message slack and the transport stack's own bounded in-flight buffering — not exactly initialMaxData. Bounds across connections (per-IP connection limits, using the connection's remote endpoint, §4) are application policy and out of scope here.

The reserved channels (§8) are not application streams: they are exempt from MAX_STREAMS and stream credit. Datagrams are bounded by their own drop-oldest receive queue (§7).


7. Datagrams (required)

KPS does not offer "unreliable streams." Unreliable delivery is modelled as connection-level datagrams, which every KPS connection MUST provide:

Datagram support is a conformance requirement for KPS transports, like reliable streams. Both v0.2 transports carry datagrams natively and a listener controls both ends, so there is no "unsupported" state — only a size limit. (A future reliable-only transport that could not carry datagrams would have to reintroduce a capability gate; v0.2 has none.)

There is a per-connection datagram size limit, but it is transport- and path-dependent, so KPS does not expose it as a fixed property. Instead an oversized send fails with a too-large error that reports the current limit (mirroring QUIC). As a rule of thumb, payloads up to ~1100 bytes are safe on every connection; larger payloads may or may not fit. Delivery is best-effort: a sent datagram may never arrive. Inbound datagrams arrive unsolicited, so they are delivered through a bounded buffer (drop-oldest when full), not a single racing receive.

API shape — flat send/receive methods on the connection, mirrored across languages (receive is pull-based: one datagram per call, from the bounded buffer):

// rejects with { code: 'too-large', maxDatagramPayloadSize } if over the limit
await conn.sendDatagram(bytes)
const p = await conn.receiveDatagram()  // next inbound datagram (bounded, drop-oldest)
err := conn.SendDatagram(p)            // *DatagramTooLargeError{MaxDatagramPayloadSize} if over the limit
p, err := conn.ReceiveDatagram(ctx)

Transport mappings:


8. Reserved transport internals

These are implementation details, not part of the public API, and MUST NOT surface as application streams or be relied upon by applications:


9. Error, reset and close behaviour (summary)

Event Read half of peer Write half of peer
local closeWrite EOF after buffered bytes unaffected
local resetWrite(code) stream error (with code) unaffected
local cancelRead(code) unaffected writes fail; should reset
local close(code) stream error (with code) writes fail; should reset
connection close (with code) all streams error/EOF all streams error
flow-control violation (§6.5) connection closed protocol-error — all streams error
certhash mismatch (dial time) dial fails; no connection

A connection close MAY carry an application error code, observable at the peer as the connection's close reason: QUIC CONNECTION_CLOSE and the WebRTC control channel's CONNECTION_CLOSE (§8) both convey it (best-effort — teardown may race delivery, as with QUIC's single-packet close). A code of 0/none is a clean close.

9.1 Error-code registry

The reset/cancel/close reason.code is one of the following canonical names. Each maps to a wire uint32 carried, on WebRTC, in the §6.2 RESET/ STOP_SENDING frames and the control channel's CONNECTION_CLOSE (§8); and, on QUIC, in RESET_STREAM / STOP_SENDING / CONNECTION_CLOSE application error codes. All implementations MUST use these values so they agree on the wire; an unknown received code maps to internal-error.

code (string) wire uint32 meaning
(none) 0 no specific reason
cancelled 1 operation cancelled by the local application
closed 2 normal close / graceful teardown
reset 3 write half aborted (resetWrite)
timeout 4 deadline/idle timeout
network-error 5 transport/connectivity failure
protocol-error 6 malformed or out-of-contract peer behaviour
unsupported 7 capability not supported
too-large 8 payload exceeds a limit (e.g. datagram maxSize)
queue-full 9 bounded inbound queue full; item rejected
permission-denied 10 refused by policy
internal-error 11 unspecified local failure; also the unknown-code sink

9.2 Local read/write results

The §9 table above is peer-observable behaviour (what the remote endpoint sees on the wire). The local result of a read or write when a stream terminates is defined here, so that a holder of only a stream — not the connection — can distinguish an unfinished stream from a completed one.

A read yields EOF if and only if the peer's write half finished via FIN; every other terminal condition yields an error carrying a reason code:

Local terminal condition local read local write
peer FIN received EOF (after buffered bytes) unaffected
peer RESET received error (peer's code) unaffected
peer STOP_SENDING received unaffected error (peer's code)
local cancelRead(code) error (cancelled/code) unaffected
local resetWrite(code) unaffected error (reset/code)
local close(code?) error (closed/code) error (closed/code)
connection closed locally error (closed) error (closed)
connection lost error (network-error) error (network-error)

The error's concrete representation is implementation- and transport-native (e.g. Go io.EOF for the EOF row vs. a *StreamError/ApplicationError; Rust Ok(0) vs. an io::Error; a rejected promise in JS) — this section constrains only EOF-vs-error and the accompanying reason code, not the language-level type. This matches native QUIC: quic-go's CancelRead/connection close surface a StreamError/ApplicationError, and quinn's stop/connection loss surface ReadError::ClosedStream/ConnectionLost (io::ErrorKind::NotConnected) — never a spurious clean EOF.

closed (§6.1) reports ok for an orderly local teardown (a close() with no code, or normal completion) and not-ok with the reason otherwise (peer reset, coded close, connection failure). Bilateral FIN is not required for a clean close() to report ok; it is the read operation that is unambiguously an error when it cannot be fulfilled and no FIN was seen.


10. Interop requirements

Conforming implementations MUST interoperate across these scenarios:

  1. A WebRTC client ↔ a KPS listener (including a browser-originated WebRTC client).
  2. A QUIC client ↔ a KPS listener.
  3. A WebRTC client and a QUIC client on the same listener UDP port.
  4. Multiple concurrent independent connections from one device.
  5. Multiple streams per connection.
  6. Stream EOF via closeWrite.
  7. Read cancellation via cancelRead.
  8. Write reset via resetWrite.
  9. Datagrams — send/receive over QUIC (DATAGRAM) and WebRTC (the reserved unreliable channel); oversize payloads rejected with too-large.
  10. End-to-end backpressure — a receiver that stops reading a stream blocks the peer's writes once the advertised windows are exhausted; resuming reads resumes the writer; receiver memory stays bounded throughout. Both transports.
  11. Stream-count limiting — opening streams beyond the advertised limit blocks a conforming opener until streams close; on WebRTC, a peer that violates a flow-control limit is closed with protocol-error.

See tests/interop/ for the executable matrix.