# Streaming Double-Ratchet sub-sessions (V4.11) `ShadeStream` wraps individual frames on a long-lived, high-frequency, often one-directional channel (e.g. a server→client console-log WebSocket) in an **independent** Double Ratchet derived from — but never mutating — an already-established parent Shade session. This is the answer to Vyvern FR `shade-ws-streaming-ratchet.md`. It is a first-class API, *not* the "documented contract that `send`/`receive` is safe per-frame" fallback: the Double-Ratchet crypto was already safe for that access pattern, but the `send`/`receive` wrapper layer was not (per-frame keystore writes; a shared per-peer mutex and a single stored session row coupling the stream to the HTTP path). `ShadeStream` keeps the proven ratchet and fixes the wrapper. ## API Transport-agnostic, exactly like `send`/`receive`: it emits/consumes wire bytes; you own the WebSocket. ```ts // Initiator (the side that calls openStream) const stream = await shade.openStream(peerAddr); ws.send(stream.handshakeFrame()); // → STREAM_OPEN // first inbound WS frame is the peer's STREAM_OPEN_ACK: await stream.handleHandshake(ackBytes); // stream now usable ws.send(await stream.seal(utf8(logLine))); // outbound frame onLog(await stream.open(inboundBytes)); // inbound frame await stream.close(); // on ws close/error // Responder const stream = await shade.acceptStream(peerAddr, openBytes); // usable now ws.send(stream.handshakeFrame()); // → STREAM_OPEN_ACK // open()/seal() as above ``` Route inbound bytes with `inspectEnvelopeType()`: `'stream-open' | 'stream-open-ack' | 'stream-frame'`. ## Seeding (no prekey-server round trip) The stream root key is derived from an identity-bound **3-DH** exchange — the X3DH pattern minus signed/one-time prekeys, because the peer's identity is *already* mutually pinned by the parent session's TOFU. Two ephemerals are exchanged inside the transport (`STREAM_OPEN` / `STREAM_OPEN_ACK`); no prekey server is involved. ``` slotA = DH(initiatorEphemeral, responderIdentity) — authenticates responder slotB = DH(initiatorIdentity, responderEphemeral) — authenticates initiator slotC = DH(initiatorEphemeral, responderEphemeral) — ephemeral forward secrecy SK = HKDF(ikm = slotA‖slotB‖slotC, salt = streamId, info = "ShadeStream/v1") ``` Both peers compute the identical three scalars regardless of role. `SK` then bootstraps a textbook Double Ratchet by handing the responder's ephemeral to `initSenderSession`/`initReceiverSession` exactly the way X3DH hands its signed prekey to the ratchet — so `ratchetEncrypt`/`ratchetDecrypt` and every guarantee they carry apply unchanged. ## Security contract (answers FR R1–R7) - **R1 — same properties as `send`/`receive`.** Each frame is one `ratchetEncrypt`/`ratchetDecrypt` over the *same* crypto as the HTTP path: AES-256-GCM confidentiality, per-frame forward secrecy via the one-way HMAC chain-key KDF with in-place zeroize of the spent chain key, and replay/rewind rejection (a re-delivered or counter-rewound frame fails closed). The handshake is mutually authenticated against the identities the parent session already pinned. - **R2 — one-directional resilience.** A long server→client burst with no client traffic only advances the symmetric sending chain (no DH step until the peer replies — standard Double Ratchet). Forward secrecy holds per frame in this regime. Over an ordered transport (WebSocket/TCP) zero keys are skipped per frame. - **R3 — bounded memory.** Out-of-order arrivals are capped by the ratchet's `MAX_SKIP` (1000) and `MAX_CACHED_SKIPPED_KEYS` (2000) with oldest-key eviction. In-order delivery retains nothing. Verified to stay at zero retained keys across a 5000-frame burst. - **R4 — browser parity.** Identical API and guarantees in the browser SDK: `ShadeStream` is on the same `Shade` class over the same `CryptoProvider` (`SubtleCryptoProvider`), so the IndexedDB-backed build behaves identically to the `sqlite:` server build. No storage is touched at all (see R7), so the keystore backend is irrelevant. - **R5 — independent lifecycle.** The stream ratchet is derived without reading or writing the stored parent `SessionState`, runs on its own private op-mutex (not the per-peer `send`/`receive` queues), and is zeroized on `close()`. Opening, using for thousands of frames, and closing a stream leaves the parent session byte-identical; the HTTP path keeps working concurrently against the same peer. Each `openStream` gets a fresh `streamId` and an independent root, so concurrent streams to one peer never share key material. - **R6 — wire framing.** `@shade/proto` defines `STREAM_OPEN` (0x31), `STREAM_OPEN_ACK` (0x32), `STREAM_FRAME` (0x33). A `STREAM_FRAME` carries one Double-Ratchet message via the exact ratchet inner codec the HTTP path uses. One sealed logical frame ⇒ one self-delimiting wire frame ⇒ one WS text/binary frame. - **R7 — performance.** The ratchet lives **only in memory and is never persisted**. There is therefore *zero* per-frame storage I/O — the per-frame cost is exactly the symmetric KDF + one AES-GCM, the same primitives the HTTP path runs. This is strictly better than the "doubled CPU" the Vyvern roadmap budgeted, because the dominant cost the naive `send`/`receive`-per-frame approach would have paid (a `saveSession` keystore write per frame) is eliminated, not doubled. Not persisting is also a *security* property, not a shortcut: writing evolving per-frame ratchet secrets to disk would defeat forward secrecy. A dropped/reconnected stream is re-opened with a fresh handshake, never resumed. ## Double-Ratchet ordering note A responder cannot `seal()` until it has `open()`ed at least one frame from the initiator (standard Signal behaviour — the responder has no sending chain until the first DH step). For a server-heavy stream either make the bursty data sender the **initiator**, or have the initiator send one priming frame immediately after the handshake. ## Tests - `packages/shade-core/tests/stream.test.ts` — handshake agreement, frame round-trips, 5000-frame one-directional burst (bounded skipped keys + forward-secrecy zeroize), parent-session independence (R5), replay/rewind rejection, mutual authentication against pinned identities, `close()` zeroize/idempotence. - `packages/shade-proto/tests/stream-wire.test.ts` — wire round-trips and type-tag/length rejection for all three stream frame kinds.