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

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:
2026-05-15 11:29:09 +02:00
parent 188c3db56a
commit 037f994572
39 changed files with 1241 additions and 31 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "@shade/proto",
"version": "4.10.0",
"version": "4.11.0",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",

View File

@@ -8,7 +8,20 @@ export {
encodeBroadcast,
decodeBroadcast,
inspectEnvelopeType,
encodeStreamOpen,
encodeStreamOpenAck,
decodeStreamHandshake,
encodeStreamFrame,
decodeStreamFrame,
TYPE_STREAM_CHUNK,
TYPE_BROADCAST,
TYPE_STREAM_OPEN,
TYPE_STREAM_OPEN_ACK,
TYPE_STREAM_FRAME,
} from './wire.js';
export type {
StreamChunkWire,
BroadcastWire,
StreamHandshakeWire,
StreamFrameWire,
} from './wire.js';
export type { StreamChunkWire, BroadcastWire } from './wire.js';

View File

@@ -25,6 +25,13 @@ const TYPE_PREKEY = 0x01;
const TYPE_RATCHET = 0x02;
export const TYPE_STREAM_CHUNK = 0x11;
export const TYPE_BROADCAST = 0x21;
// V4.11 — streaming Double-Ratchet sub-session (long-lived WS channels).
export const TYPE_STREAM_OPEN = 0x31;
export const TYPE_STREAM_OPEN_ACK = 0x32;
export const TYPE_STREAM_FRAME = 0x33;
const STREAM_SESSION_ID_BYTES = 16;
const STREAM_EPHEMERAL_BYTES = 32;
// ─── Stream chunk types ──────────────────────────────────────
@@ -258,7 +265,15 @@ export function decodeStreamChunk(data: Uint8Array): StreamChunkWire {
*/
export function inspectEnvelopeType(
data: Uint8Array,
): 'prekey' | 'ratchet' | 'stream-chunk' | 'broadcast' | 'unknown' {
):
| 'prekey'
| 'ratchet'
| 'stream-chunk'
| 'broadcast'
| 'stream-open'
| 'stream-open-ack'
| 'stream-frame'
| 'unknown' {
if (data.length < 2 || data[0] !== VERSION) return 'unknown';
switch (data[1]) {
case TYPE_PREKEY:
@@ -269,11 +284,122 @@ export function inspectEnvelopeType(
return 'stream-chunk';
case TYPE_BROADCAST:
return 'broadcast';
case TYPE_STREAM_OPEN:
return 'stream-open';
case TYPE_STREAM_OPEN_ACK:
return 'stream-open-ack';
case TYPE_STREAM_FRAME:
return 'stream-frame';
default:
return 'unknown';
}
}
// ─── Stream sub-session wire (V4.11) ─────────────────────────
/**
* A decoded stream handshake frame (`STREAM_OPEN` / `STREAM_OPEN_ACK`).
* Both share the layout `[version][type][streamId:16][ephemeralPub:32]`.
*/
export interface StreamHandshakeWire {
kind: 'open' | 'open-ack';
streamId: Uint8Array; // 16 bytes
ephemeralPub: Uint8Array; // 32 bytes (X25519)
}
/**
* A decoded sealed stream frame (`STREAM_FRAME`): a streamId plus an
* embedded Double-Ratchet message. One sealed logical frame ⇒ exactly
* one of these ⇒ one WS text/binary frame.
*/
export interface StreamFrameWire {
streamId: Uint8Array; // 16 bytes
message: RatchetMessage;
}
function encodeStreamHandshake(
type: number,
streamId: Uint8Array,
ephemeralPub: Uint8Array,
): Uint8Array {
if (streamId.length !== STREAM_SESSION_ID_BYTES) {
throw new Error(`streamId must be ${STREAM_SESSION_ID_BYTES} bytes`);
}
if (ephemeralPub.length !== STREAM_EPHEMERAL_BYTES) {
throw new Error(`ephemeralPub must be ${STREAM_EPHEMERAL_BYTES} bytes`);
}
const out = new Uint8Array(2 + STREAM_SESSION_ID_BYTES + STREAM_EPHEMERAL_BYTES);
out[0] = VERSION;
out[1] = type;
out.set(streamId, 2);
out.set(ephemeralPub, 2 + STREAM_SESSION_ID_BYTES);
return out;
}
/** Encode the initiator's `STREAM_OPEN` (streamId + initiator ephemeral). */
export function encodeStreamOpen(streamId: Uint8Array, ephemeralPub: Uint8Array): Uint8Array {
return encodeStreamHandshake(TYPE_STREAM_OPEN, streamId, ephemeralPub);
}
/** Encode the responder's `STREAM_OPEN_ACK` (streamId + responder ephemeral). */
export function encodeStreamOpenAck(streamId: Uint8Array, ephemeralPub: Uint8Array): Uint8Array {
return encodeStreamHandshake(TYPE_STREAM_OPEN_ACK, streamId, ephemeralPub);
}
/** Decode either handshake frame. Throws on wrong type / bad length. */
export function decodeStreamHandshake(data: Uint8Array): StreamHandshakeWire {
const expected = 2 + STREAM_SESSION_ID_BYTES + STREAM_EPHEMERAL_BYTES;
if (data.length !== expected) {
throw new Error(`stream handshake must be ${expected} bytes, got ${data.length}`);
}
if (data[0] !== VERSION) throw new Error(`Unknown version: ${data[0]}`);
let kind: 'open' | 'open-ack';
if (data[1] === TYPE_STREAM_OPEN) kind = 'open';
else if (data[1] === TYPE_STREAM_OPEN_ACK) kind = 'open-ack';
else throw new Error(`Not a stream handshake: type=${data[1]}`);
return {
kind,
streamId: data.slice(2, 2 + STREAM_SESSION_ID_BYTES),
ephemeralPub: data.slice(
2 + STREAM_SESSION_ID_BYTES,
2 + STREAM_SESSION_ID_BYTES + STREAM_EPHEMERAL_BYTES,
),
};
}
/**
* Encode a sealed stream frame: `[version][0x33][streamId:16][ratchet…]`.
* Reuses the exact ratchet-message inner codec the HTTP path uses, so a
* stream frame carries the same Double-Ratchet header + AEAD payload.
*/
export function encodeStreamFrame(streamId: Uint8Array, msg: RatchetMessage): Uint8Array {
if (streamId.length !== STREAM_SESSION_ID_BYTES) {
throw new Error(`streamId must be ${STREAM_SESSION_ID_BYTES} bytes`);
}
const inner = encodeRatchetMessageInner(msg);
const out = new Uint8Array(2 + STREAM_SESSION_ID_BYTES + inner.length);
out[0] = VERSION;
out[1] = TYPE_STREAM_FRAME;
out.set(streamId, 2);
out.set(inner, 2 + STREAM_SESSION_ID_BYTES);
return out;
}
/** Decode a sealed stream frame. Throws on wrong type / truncation. */
export function decodeStreamFrame(data: Uint8Array): StreamFrameWire {
const minSize = 2 + STREAM_SESSION_ID_BYTES;
if (data.length < minSize) {
throw new Error(`stream-frame too short: ${data.length} < ${minSize}`);
}
if (data[0] !== VERSION) throw new Error(`Unknown version: ${data[0]}`);
if (data[1] !== TYPE_STREAM_FRAME) {
throw new Error(`Not a stream-frame: type=${data[1]}`);
}
const streamId = data.slice(2, 2 + STREAM_SESSION_ID_BYTES);
const message = decodeRatchetMessageInner(data, 2 + STREAM_SESSION_ID_BYTES).value;
return { streamId, message };
}
// ─── Broadcast wire (V4.6) ───────────────────────────────────
const BROADCAST_NONCE_BYTES = 12;

View File

@@ -0,0 +1,71 @@
import { describe, test, expect } from 'bun:test';
import {
encodeStreamOpen,
encodeStreamOpenAck,
decodeStreamHandshake,
encodeStreamFrame,
decodeStreamFrame,
inspectEnvelopeType,
} from '../src/index.js';
import type { RatchetMessage } from '@shade/core';
function randBytes(n: number): Uint8Array {
const buf = new Uint8Array(n);
crypto.getRandomValues(buf);
return buf;
}
function makeRatchetMessage(): RatchetMessage {
return {
dhPublicKey: randBytes(32),
previousCounter: 3,
counter: 9001,
ciphertext: randBytes(128),
nonce: randBytes(12),
};
}
describe('Stream sub-session wire (V4.11)', () => {
test('STREAM_OPEN round-trips and inspects', () => {
const sid = randBytes(16);
const eph = randBytes(32);
const bytes = encodeStreamOpen(sid, eph);
expect(inspectEnvelopeType(bytes)).toBe('stream-open');
const hs = decodeStreamHandshake(bytes);
expect(hs.kind).toBe('open');
expect(hs.streamId).toEqual(sid);
expect(hs.ephemeralPub).toEqual(eph);
});
test('STREAM_OPEN_ACK round-trips and inspects', () => {
const sid = randBytes(16);
const eph = randBytes(32);
const bytes = encodeStreamOpenAck(sid, eph);
expect(inspectEnvelopeType(bytes)).toBe('stream-open-ack');
const hs = decodeStreamHandshake(bytes);
expect(hs.kind).toBe('open-ack');
expect(hs.streamId).toEqual(sid);
expect(hs.ephemeralPub).toEqual(eph);
});
test('STREAM_FRAME carries a full ratchet message verbatim', () => {
const sid = randBytes(16);
const msg = makeRatchetMessage();
const bytes = encodeStreamFrame(sid, msg);
expect(inspectEnvelopeType(bytes)).toBe('stream-frame');
const decoded = decodeStreamFrame(bytes);
expect(decoded.streamId).toEqual(sid);
expect(decoded.message.dhPublicKey).toEqual(msg.dhPublicKey);
expect(decoded.message.previousCounter).toBe(msg.previousCounter);
expect(decoded.message.counter).toBe(msg.counter);
expect(decoded.message.ciphertext).toEqual(msg.ciphertext);
expect(decoded.message.nonce).toEqual(msg.nonce);
});
test('rejects wrong sizes and wrong type tags', () => {
expect(() => encodeStreamOpen(randBytes(15), randBytes(32))).toThrow();
expect(() => encodeStreamOpen(randBytes(16), randBytes(31))).toThrow();
expect(() => decodeStreamHandshake(encodeStreamFrame(randBytes(16), makeRatchetMessage()))).toThrow();
expect(() => decodeStreamFrame(encodeStreamOpen(randBytes(16), randBytes(32)))).toThrow();
});
});