Files
Shade/docs/streaming-sessions.md
Sterister 037f994572
Some checks failed
Cross-platform vectors / TypeScript vectors (bun) (push) Has been cancelled
Cross-platform vectors / Kotlin vectors (gradle) (push) Has been cancelled
Test / test (push) Has been cancelled
Docker build and publish / docker (push) Has been cancelled
Publish / publish (push) Has been cancelled
release(v4.11.0): streaming Double-Ratchet sub-sessions (ShadeStream)
Answers Vyvern FR shade-ws-streaming-ratchet.md with a first-class
streaming-session API rather than the documented-contract fallback.
The Double-Ratchet crypto was already safe for high-frequency
one-directional use; the send/receive wrapper was not (per-frame
saveSession keystore write; shared per-peer mutex + single stored
session row coupling reuse to the HTTP path).

- @shade/core: stream.ts — identity-bound 3-DH seeding (X3DH-minus-
  prekeys, no prekey-server round trip, mutually authenticated against
  the parent session's pinned identities), bootstrapStreamSession
  reusing init{Sender,Receiver}Session verbatim, in-memory-only
  StreamRatchet (own op-mutex, never persisted, zeroized on close).
  beginStream/acceptStream on ShadeSessionManager; Stream{Closed,
  Handshake}Error; stream.opened/closed events.
- @shade/proto: STREAM_OPEN/OPEN_ACK/FRAME wire (0x31/0x32/0x33),
  additive; inspectEnvelopeType extended.
- @shade/sdk: Shade.openStream/acceptStream → ShadeStream
  (handshakeFrame/handleHandshake/seal/open/close), transport-
  agnostic, independent of encrypt/decrypt queues + parent session,
  identical server (sqlite:) and browser (IndexedDB) — touches no
  storage.
- Tests: 5000-frame one-directional burst (bounded skipped keys + FS
  zeroize), parent-session independence, replay/rewind rejection,
  mutual-auth, proto wire round-trips. Full suite green (1159 pass).
- docs/streaming-sessions.md (R1–R7 contract); SECURITY.md matrix rows.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 11:29:09 +02:00

6.5 KiB
Raw Blame History

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.

// 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 R1R7)

  • 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.