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:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@shade/sdk",
|
||||
"version": "4.10.0",
|
||||
"version": "4.11.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export { createShade } from './create-shade.js';
|
||||
export { Shade } from './shade.js';
|
||||
export { Shade, ShadeStream } from './shade.js';
|
||||
export type {
|
||||
ShadeUploadOptions,
|
||||
ShadeWebRtcConfig,
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { ShadeEnvelope, StorageProvider } from '@shade/core';
|
||||
import type { ShadeEnvelope, StorageProvider, RatchetMessage } from '@shade/core';
|
||||
import {
|
||||
ShadeSessionManager,
|
||||
ShadeEventEmitter,
|
||||
NoSessionError,
|
||||
StreamRatchet,
|
||||
StreamHandshakeError,
|
||||
} from '@shade/core';
|
||||
import {
|
||||
FingerprintGateRegistry,
|
||||
@@ -18,7 +20,16 @@ import {
|
||||
type CreateEncryptStreamOptions,
|
||||
type CreateDecryptStreamOptions,
|
||||
} from '@shade/crypto-web';
|
||||
import { encodeEnvelope, decodeEnvelope, inspectEnvelopeType } from '@shade/proto';
|
||||
import {
|
||||
encodeEnvelope,
|
||||
decodeEnvelope,
|
||||
inspectEnvelopeType,
|
||||
encodeStreamOpen,
|
||||
encodeStreamOpenAck,
|
||||
decodeStreamHandshake,
|
||||
encodeStreamFrame,
|
||||
decodeStreamFrame,
|
||||
} from '@shade/proto';
|
||||
import { ShadeFetchTransport, type KTVerifierOptions } from '@shade/transport';
|
||||
import { LightWitness } from '@shade/key-transparency';
|
||||
import type { SignedTreeHead, STHWire } from '@shade/key-transparency';
|
||||
@@ -1510,6 +1521,97 @@ export class Shade {
|
||||
await this.storage.pruneStreamStates(olderThan);
|
||||
}
|
||||
|
||||
// ─── Streaming sub-sessions (V4.11) ────────────────────────
|
||||
|
||||
/**
|
||||
* Open a long-lived streaming Double-Ratchet sub-session to an
|
||||
* already-known peer, for wrapping individual frames on a
|
||||
* bidirectional, often server-heavy channel (e.g. a console-log
|
||||
* WebSocket) with the same confidentiality / forward-secrecy /
|
||||
* replay guarantees as the HTTP `send`/`receive` path.
|
||||
*
|
||||
* This is the **initiator** half. Like the rest of the SDK it is
|
||||
* transport-agnostic: it produces handshake/frame bytes you put on
|
||||
* your WebSocket, and consumes the bytes you receive from it.
|
||||
*
|
||||
* ```ts
|
||||
* 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(line))); // outbound frame
|
||||
* onLog(await stream.open(inboundBytes)); // inbound frame
|
||||
* await stream.close(); // on ws close
|
||||
* ```
|
||||
*
|
||||
* Independence (R5): this never touches the stored parent session,
|
||||
* its prekeys, or the per-peer `send`/`receive` queues — it runs
|
||||
* concurrently against the same peer. The ratchet lives only in
|
||||
* memory and is zeroized by {@link ShadeStream.close}; a dropped
|
||||
* connection is re-opened with a fresh `openStream`, never resumed
|
||||
* (persisting per-frame ratchet secrets would defeat forward
|
||||
* secrecy).
|
||||
*
|
||||
* Note (Double-Ratchet semantics): a responder cannot `seal` until
|
||||
* it has `open`ed at least one frame from the initiator (standard
|
||||
* Signal behaviour). For a server-heavy stream either make the bursty
|
||||
* sender the initiator, or have the initiator send one priming frame
|
||||
* right after the handshake.
|
||||
*
|
||||
* Requires an established parent session; one is auto-established
|
||||
* (same path as {@link send}) if missing.
|
||||
*/
|
||||
async openStream(peerAddress: string): Promise<ShadeStream> {
|
||||
if (!this.initialized) throw new Error('Not initialized');
|
||||
let begun;
|
||||
try {
|
||||
begun = await this.manager.beginStream(peerAddress);
|
||||
} catch (err) {
|
||||
if (!(err instanceof NoSessionError)) throw err;
|
||||
await this.ensureSession(peerAddress);
|
||||
begun = await this.manager.beginStream(peerAddress);
|
||||
}
|
||||
return new ShadeStream({
|
||||
peer: peerAddress,
|
||||
role: 'initiator',
|
||||
streamId: begun.streamId,
|
||||
events: this.events,
|
||||
handshakeOut: encodeStreamOpen(begun.streamId, begun.ephemeralPublicKey),
|
||||
complete: begun.complete,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept an inbound stream — the **responder** half. Feed it the
|
||||
* peer's `STREAM_OPEN` bytes (route by {@link inspectEnvelopeType}
|
||||
* `=== 'stream-open'`). The returned stream is immediately usable for
|
||||
* `open()`; send `handshakeFrame()` (the `STREAM_OPEN_ACK`) back over
|
||||
* the transport so the initiator can complete its side.
|
||||
*/
|
||||
async acceptStream(peerAddress: string, openBytes: Uint8Array): Promise<ShadeStream> {
|
||||
if (!this.initialized) throw new Error('Not initialized');
|
||||
const hs = decodeStreamHandshake(openBytes);
|
||||
if (hs.kind !== 'open') {
|
||||
throw new StreamHandshakeError(`expected STREAM_OPEN, got ${hs.kind}`);
|
||||
}
|
||||
let accepted;
|
||||
try {
|
||||
accepted = await this.manager.acceptStream(peerAddress, hs.streamId, hs.ephemeralPub);
|
||||
} catch (err) {
|
||||
if (!(err instanceof NoSessionError)) throw err;
|
||||
await this.ensureSession(peerAddress);
|
||||
accepted = await this.manager.acceptStream(peerAddress, hs.streamId, hs.ephemeralPub);
|
||||
}
|
||||
return new ShadeStream({
|
||||
peer: peerAddress,
|
||||
role: 'responder',
|
||||
streamId: hs.streamId,
|
||||
events: this.events,
|
||||
handshakeOut: encodeStreamOpenAck(hs.streamId, accepted.ephemeralPublicKey),
|
||||
ratchet: accepted.stream,
|
||||
});
|
||||
}
|
||||
|
||||
private async ensureSession(address: string): Promise<void> {
|
||||
// Deduplicate concurrent establishment requests
|
||||
const existing = this.establishing.get(address);
|
||||
@@ -1532,6 +1634,158 @@ export class Shade {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── ShadeStream (V4.11) ─────────────────────────────────────
|
||||
|
||||
interface ShadeStreamInit {
|
||||
peer: string;
|
||||
role: 'initiator' | 'responder';
|
||||
streamId: Uint8Array;
|
||||
events: ShadeEventEmitter;
|
||||
/** Bytes to put on the wire for our half of the handshake. */
|
||||
handshakeOut: Uint8Array;
|
||||
/** Initiator only: continuation that derives the ratchet from the ACK. */
|
||||
complete?: (peerEphemeralPub: Uint8Array) => Promise<StreamRatchet>;
|
||||
/** Responder only: ratchet is ready at accept time. */
|
||||
ratchet?: StreamRatchet;
|
||||
}
|
||||
|
||||
function streamIdsEqual(a: Uint8Array, b: Uint8Array): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* A live streaming Double-Ratchet sub-session. Transport-agnostic: it
|
||||
* emits/consumes wire bytes, the caller owns the WebSocket (or any
|
||||
* other ordered frame transport).
|
||||
*
|
||||
* Lifecycle:
|
||||
* - **initiator**: `handshakeFrame()` → `STREAM_OPEN`; after the peer's
|
||||
* `STREAM_OPEN_ACK` arrives call `handleHandshake(ack)`; then
|
||||
* `seal`/`open`.
|
||||
* - **responder**: usable immediately; `handshakeFrame()` →
|
||||
* `STREAM_OPEN_ACK` to send back; `open` the initiator's first frame
|
||||
* before `seal` (standard Double-Ratchet ordering).
|
||||
*/
|
||||
export class ShadeStream {
|
||||
private readonly _streamId: Uint8Array;
|
||||
private readonly _peer: string;
|
||||
private readonly _role: 'initiator' | 'responder';
|
||||
private readonly events: ShadeEventEmitter;
|
||||
private readonly handshakeOut: Uint8Array;
|
||||
private readonly complete?: (peerEphemeralPub: Uint8Array) => Promise<StreamRatchet>;
|
||||
private ratchet: StreamRatchet | null;
|
||||
private state: 'await-ack' | 'open' | 'closed';
|
||||
|
||||
constructor(init: ShadeStreamInit) {
|
||||
this._streamId = init.streamId;
|
||||
this._peer = init.peer;
|
||||
this._role = init.role;
|
||||
this.events = init.events;
|
||||
this.handshakeOut = init.handshakeOut;
|
||||
if (init.role === 'initiator') {
|
||||
if (init.complete) this.complete = init.complete;
|
||||
this.ratchet = null;
|
||||
this.state = 'await-ack';
|
||||
} else {
|
||||
this.ratchet = init.ratchet ?? null;
|
||||
this.state = 'open';
|
||||
}
|
||||
}
|
||||
|
||||
/** Peer address this stream is bound to. */
|
||||
get peer(): string {
|
||||
return this._peer;
|
||||
}
|
||||
|
||||
/** Which half of the handshake this end performed. */
|
||||
get role(): 'initiator' | 'responder' {
|
||||
return this._role;
|
||||
}
|
||||
|
||||
/** Lowercase-hex stream id (stable for the stream's lifetime). */
|
||||
get streamId(): string {
|
||||
return Array.from(this._streamId, (b) => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
/** True once the ratchet is established and not yet closed. */
|
||||
get isOpen(): boolean {
|
||||
return this.state === 'open' && this.ratchet !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The bytes for our half of the handshake to put on the transport
|
||||
* (`STREAM_OPEN` for an initiator, `STREAM_OPEN_ACK` for a responder).
|
||||
* Stable; safe to read once and send.
|
||||
*/
|
||||
handshakeFrame(): Uint8Array {
|
||||
return this.handshakeOut;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initiator only: consume the peer's `STREAM_OPEN_ACK` and derive the
|
||||
* ratchet. Idempotent-safe to call exactly once; throws if called on
|
||||
* a responder, out of order, or with a mismatched streamId.
|
||||
*/
|
||||
async handleHandshake(ackBytes: Uint8Array): Promise<void> {
|
||||
if (this._role !== 'initiator') {
|
||||
throw new StreamHandshakeError('handleHandshake is initiator-only');
|
||||
}
|
||||
if (this.state !== 'await-ack' || !this.complete) {
|
||||
throw new StreamHandshakeError('handshake already completed or stream closed');
|
||||
}
|
||||
const hs = decodeStreamHandshake(ackBytes);
|
||||
if (hs.kind !== 'open-ack') {
|
||||
throw new StreamHandshakeError(`expected STREAM_OPEN_ACK, got ${hs.kind}`);
|
||||
}
|
||||
if (!streamIdsEqual(hs.streamId, this._streamId)) {
|
||||
throw new StreamHandshakeError('STREAM_OPEN_ACK streamId mismatch');
|
||||
}
|
||||
this.ratchet = await this.complete(hs.ephemeralPub);
|
||||
this.state = 'open';
|
||||
}
|
||||
|
||||
/**
|
||||
* Seal one logical frame. Returns `STREAM_FRAME` wire bytes — put
|
||||
* exactly one in one WS frame. Advances the sending chain one step.
|
||||
*/
|
||||
async seal(plaintext: Uint8Array): Promise<Uint8Array> {
|
||||
if (!this.ratchet || this.state !== 'open') {
|
||||
throw new StreamHandshakeError('stream not open (complete the handshake first)');
|
||||
}
|
||||
const msg = await this.ratchet.seal(plaintext);
|
||||
return encodeStreamFrame(this._streamId, msg);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open one inbound `STREAM_FRAME`. Correct and memory-bounded across
|
||||
* long one-directional bursts; replays / counter-rewinds are rejected
|
||||
* by the underlying ratchet.
|
||||
*/
|
||||
async open(wire: Uint8Array): Promise<Uint8Array> {
|
||||
if (!this.ratchet || this.state !== 'open') {
|
||||
throw new StreamHandshakeError('stream not open (complete the handshake first)');
|
||||
}
|
||||
const frame: { streamId: Uint8Array; message: RatchetMessage } = decodeStreamFrame(wire);
|
||||
if (!streamIdsEqual(frame.streamId, this._streamId)) {
|
||||
throw new StreamHandshakeError('STREAM_FRAME streamId mismatch');
|
||||
}
|
||||
return this.ratchet.open(frame.message);
|
||||
}
|
||||
|
||||
/** Zeroize and drop the ratchet. Idempotent. */
|
||||
async close(): Promise<void> {
|
||||
if (this.state === 'closed') return;
|
||||
this.state = 'closed';
|
||||
if (this.ratchet) {
|
||||
await this.ratchet.close();
|
||||
this.ratchet = null;
|
||||
}
|
||||
this.events.emit('stream.closed', { address: this._peer });
|
||||
}
|
||||
}
|
||||
|
||||
function bytesToBase64Std(bytes: Uint8Array): string {
|
||||
let bin = '';
|
||||
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]!);
|
||||
|
||||
Reference in New Issue
Block a user