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

129 lines
6.5 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 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.
</content>
</invoke>