release(v4.11.0): streaming Double-Ratchet sub-sessions (ShadeStream)
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
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
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>
This commit is contained in:
128
docs/streaming-sessions.md
Normal file
128
docs/streaming-sessions.md
Normal file
@@ -0,0 +1,128 @@
|
||||
# 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.
|
||||
</content>
|
||||
</invoke>
|
||||
Reference in New Issue
Block a user