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/proto",
|
||||
"version": "4.10.0",
|
||||
"version": "4.11.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
71
packages/shade-proto/tests/stream-wire.test.ts
Normal file
71
packages/shade-proto/tests/stream-wire.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user