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:
54
CHANGELOG.md
54
CHANGELOG.md
@@ -5,6 +5,60 @@ All notable changes to Shade are documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [4.11.0] — 2026-05-15 — Streaming Double-Ratchet sub-sessions
|
||||||
|
|
||||||
|
Answers Vyvern FR `shade-ws-streaming-ratchet.md` (the last Phase-2
|
||||||
|
blocker) 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 (a `saveSession` keystore write per frame; a shared
|
||||||
|
per-peer mutex + single stored session row coupling any reuse to the
|
||||||
|
HTTP path). `ShadeStream` keeps the proven ratchet and fixes the
|
||||||
|
wrapper.
|
||||||
|
|
||||||
|
**`@shade/core`**
|
||||||
|
|
||||||
|
- New `stream.ts`: `deriveStreamRootKey` (identity-bound 3-DH —
|
||||||
|
X3DH-minus-prekeys, no prekey-server round trip; mutually
|
||||||
|
authenticated against the parent session's already-pinned
|
||||||
|
identities), `bootstrapStreamSession` (reuses
|
||||||
|
`initSenderSession`/`initReceiverSession` verbatim), and
|
||||||
|
`StreamRatchet` — an in-memory `seal`/`open`/`close` holder on its
|
||||||
|
own op-mutex, **never persisted**, zeroized on close.
|
||||||
|
- `ShadeSessionManager.beginStream` / `acceptStream` custody the
|
||||||
|
identity keys for the handshake without exposing private material;
|
||||||
|
both require an established parent session (no first-contact).
|
||||||
|
- New `StreamClosedError` / `StreamHandshakeError`; `stream.opened` /
|
||||||
|
`stream.closed` events.
|
||||||
|
|
||||||
|
**`@shade/proto`**
|
||||||
|
|
||||||
|
- Wire types `STREAM_OPEN` (0x31), `STREAM_OPEN_ACK` (0x32),
|
||||||
|
`STREAM_FRAME` (0x33) with encode/decode + `inspectEnvelopeType`
|
||||||
|
extension. A `STREAM_FRAME` carries one ratchet message via the exact
|
||||||
|
inner codec the HTTP path uses — one sealed frame ⇒ one WS frame.
|
||||||
|
|
||||||
|
**`@shade/sdk`**
|
||||||
|
|
||||||
|
- `Shade.openStream(peer)` / `Shade.acceptStream(peer, openBytes)`
|
||||||
|
returning `ShadeStream` (`handshakeFrame` / `handleHandshake` /
|
||||||
|
`seal` / `open` / `close`). Transport-agnostic like `send`/`receive`;
|
||||||
|
auto-establishes the parent session if missing. Independent of the
|
||||||
|
per-peer encrypt/decrypt queues and the stored parent session (R5).
|
||||||
|
Identical on the `sqlite:` server build and the IndexedDB browser
|
||||||
|
build (R4) — it touches no storage at all.
|
||||||
|
|
||||||
|
**Security / perf**
|
||||||
|
|
||||||
|
- Per-frame cost is exactly one symmetric KDF + one AES-GCM (no
|
||||||
|
keystore I/O) — strictly better than the budgeted "doubled CPU".
|
||||||
|
In-memory-only is a forward-secrecy property, not a shortcut; a
|
||||||
|
dropped stream is re-opened, never resumed.
|
||||||
|
- New `docs/streaming-sessions.md` (full R1–R7 contract); SECURITY.md
|
||||||
|
threat-matrix rows added with tests
|
||||||
|
(`packages/shade-core/tests/stream.test.ts`,
|
||||||
|
`packages/shade-proto/tests/stream-wire.test.ts`).
|
||||||
|
|
||||||
## [Unreleased — 2026-05-09] — Android: V4.9/V4.10 ports + KeystoreStorage adapter
|
## [Unreleased — 2026-05-09] — Android: V4.9/V4.10 ports + KeystoreStorage adapter
|
||||||
|
|
||||||
The Kotlin side of the v4.10 cross-host approval routing FR. With this
|
The Kotlin side of the v4.10 cross-host approval routing FR. With this
|
||||||
|
|||||||
@@ -100,6 +100,9 @@ matrix row in the same change.
|
|||||||
| § 1 Network attacker — replay window | ±5 min `signedAt` enforcement | `packages/shade-server/tests/server.test.ts` (`"rejects registration with stale signedAt"`) |
|
| § 1 Network attacker — replay window | ±5 min `signedAt` enforcement | `packages/shade-server/tests/server.test.ts` (`"rejects registration with stale signedAt"`) |
|
||||||
| § 1 Network attacker — header AAD | Ratchet headers bound to ciphertext | `packages/shade-core/tests/ratchet.test.ts`, `packages/shade-streams/tests/tamper.test.ts`, `packages/shade-streams/tests/aead.test.ts` |
|
| § 1 Network attacker — header AAD | Ratchet headers bound to ciphertext | `packages/shade-core/tests/ratchet.test.ts`, `packages/shade-streams/tests/tamper.test.ts`, `packages/shade-streams/tests/aead.test.ts` |
|
||||||
| § 1 Network attacker — forward secrecy | DH ratchet step + chain-key zeroize | `packages/shade-core/tests/ratchet.test.ts`, `packages/shade-crypto-web/tests/hardening.test.ts` |
|
| § 1 Network attacker — forward secrecy | DH ratchet step + chain-key zeroize | `packages/shade-core/tests/ratchet.test.ts`, `packages/shade-crypto-web/tests/hardening.test.ts` |
|
||||||
|
| § 1 Network attacker — streaming sub-session FS/replay (V4.11) | Per-frame Double-Ratchet `seal`/`open`; counter-rewind & replay rejected; in-memory-only (never persisted) | `packages/shade-core/tests/stream.test.ts` (`"R1: replayed / rewound frame is rejected"`, `"R2/R3: long one-directional burst stays correct and memory-bounded"`) |
|
||||||
|
| § 1 Network attacker — streaming handshake auth (V4.11) | Identity-bound 3-DH against parent-session-pinned identities | `packages/shade-core/tests/stream.test.ts` (`"handshake is mutually authenticated against pinned identities"`) |
|
||||||
|
| § 3 Endpoint compromise — streaming sub-session isolation (V4.11) | Stream ratchet derived without touching the stored parent session; zeroized on close | `packages/shade-core/tests/stream.test.ts` (`"R5: opening/using/closing a stream never touches the parent session"`, `"close() zeroizes and blocks further use; idempotent"`) |
|
||||||
| § 2 Compromised prekey server — public-only storage | Prekey store never accepts a private key | `packages/shade-server/tests/server.test.ts`, `packages/shade-storage-sqlite/tests/sqlite-prekey-store.test.ts` |
|
| § 2 Compromised prekey server — public-only storage | Prekey store never accepts a private key | `packages/shade-server/tests/server.test.ts`, `packages/shade-storage-sqlite/tests/sqlite-prekey-store.test.ts` |
|
||||||
| § 2 Compromised prekey server — signed replenish/delete | Per-identity Ed25519 signature | `packages/shade-server/tests/server.test.ts` |
|
| § 2 Compromised prekey server — signed replenish/delete | Per-identity Ed25519 signature | `packages/shade-server/tests/server.test.ts` |
|
||||||
| § 2 Compromised prekey server — fake-bundle detection | Out-of-band fingerprint comparison | `packages/shade-core/tests/fingerprint-session.test.ts` |
|
| § 2 Compromised prekey server — fake-bundle detection | Out-of-band fingerprint comparison | `packages/shade-core/tests/fingerprint-session.test.ts` |
|
||||||
|
|||||||
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>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/cli",
|
"name": "@shade/cli",
|
||||||
"version": "4.10.0",
|
"version": "4.11.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/cli.ts",
|
"main": "src/cli.ts",
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/core",
|
"name": "@shade/core",
|
||||||
"version": "4.10.0",
|
"version": "4.11.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -107,6 +107,31 @@ export class FingerprintNotVerifiedError extends ShadeError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thrown when `seal()` / `open()` is called on a {@link StreamRatchet}
|
||||||
|
* that has already been torn down via `close()`. The stream's ratchet
|
||||||
|
* secrets have been zeroized and cannot be revived — open a fresh
|
||||||
|
* stream instead.
|
||||||
|
*/
|
||||||
|
export class StreamClosedError extends ShadeError {
|
||||||
|
constructor(message = 'Stream is closed') {
|
||||||
|
super('SHADE_STREAM_CLOSED', message);
|
||||||
|
this.name = 'StreamClosedError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thrown when a stream handshake frame is malformed, arrives in the
|
||||||
|
* wrong order, or references a streamId that does not match the stream
|
||||||
|
* it was fed to.
|
||||||
|
*/
|
||||||
|
export class StreamHandshakeError extends ShadeError {
|
||||||
|
constructor(message = 'Stream handshake failed') {
|
||||||
|
super('SHADE_STREAM_HANDSHAKE', message);
|
||||||
|
this.name = 'StreamHandshakeError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Infrastructure Errors ───────────────────────────────────
|
// ─── Infrastructure Errors ───────────────────────────────────
|
||||||
|
|
||||||
export class NetworkError extends ShadeError {
|
export class NetworkError extends ShadeError {
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ export interface ShadeEventMap {
|
|||||||
'signed_prekey.rotated': { oldKeyId: number; newKeyId: number };
|
'signed_prekey.rotated': { oldKeyId: number; newKeyId: number };
|
||||||
'trust.pinned': { address: string; identityKeyHash: string };
|
'trust.pinned': { address: string; identityKeyHash: string };
|
||||||
'trust.changed': { address: string; oldKeyHash: string; newKeyHash: string };
|
'trust.changed': { address: string; oldKeyHash: string; newKeyHash: string };
|
||||||
|
/** V4.11 — a streaming sub-ratchet handshake completed. */
|
||||||
|
'stream.opened': { address: string; role: 'initiator' | 'responder' };
|
||||||
|
/** V4.11 — a streaming sub-ratchet was torn down and zeroized. */
|
||||||
|
'stream.closed': { address: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ShadeEventName = keyof ShadeEventMap;
|
export type ShadeEventName = keyof ShadeEventMap;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export * from './keys.js';
|
|||||||
export * from './errors.js';
|
export * from './errors.js';
|
||||||
export * from './x3dh.js';
|
export * from './x3dh.js';
|
||||||
export * from './ratchet.js';
|
export * from './ratchet.js';
|
||||||
|
export * from './stream.js';
|
||||||
export { ShadeSessionManager, GRACE_PERIOD_MS } from './session.js';
|
export { ShadeSessionManager, GRACE_PERIOD_MS } from './session.js';
|
||||||
export * from './serialization.js';
|
export * from './serialization.js';
|
||||||
export * from './fingerprint.js';
|
export * from './fingerprint.js';
|
||||||
|
|||||||
@@ -23,7 +23,14 @@ import {
|
|||||||
ratchetEncrypt,
|
ratchetEncrypt,
|
||||||
ratchetDecrypt,
|
ratchetDecrypt,
|
||||||
} from './ratchet.js';
|
} from './ratchet.js';
|
||||||
import { NoSessionError } from './errors.js';
|
import {
|
||||||
|
deriveStreamRootKey,
|
||||||
|
bootstrapStreamSession,
|
||||||
|
StreamRatchet,
|
||||||
|
STREAM_ID_BYTES,
|
||||||
|
STREAM_EPHEMERAL_BYTES,
|
||||||
|
} from './stream.js';
|
||||||
|
import { NoSessionError, StreamHandshakeError } from './errors.js';
|
||||||
import { computeFingerprint, shortFingerprint } from './fingerprint.js';
|
import { computeFingerprint, shortFingerprint } from './fingerprint.js';
|
||||||
import { ShadeEventEmitter, shortHash } from './events.js';
|
import { ShadeEventEmitter, shortHash } from './events.js';
|
||||||
import {
|
import {
|
||||||
@@ -626,6 +633,121 @@ export class ShadeSessionManager {
|
|||||||
|
|
||||||
return dec.decode(plaintext);
|
return dec.decode(plaintext);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Streaming sub-sessions (V4.11) ────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the peer's pinned identity X25519 key for a stream
|
||||||
|
* handshake. Requires an *already established* parent session — the
|
||||||
|
* stream is explicitly a "second channel on a known peer", never a
|
||||||
|
* first contact (so it needs no prekey-server round trip and inherits
|
||||||
|
* the parent's TOFU pin).
|
||||||
|
*/
|
||||||
|
private async streamIdentityMaterial(
|
||||||
|
address: string,
|
||||||
|
): Promise<{ selfIdentityDHPriv: Uint8Array; peerIdentityDHPub: Uint8Array }> {
|
||||||
|
if (!this.identity) throw new Error('Not initialized');
|
||||||
|
const session = await this.storage.getSession(address);
|
||||||
|
if (!session) throw new NoSessionError(address);
|
||||||
|
return {
|
||||||
|
selfIdentityDHPriv: this.identity.dhPrivateKey,
|
||||||
|
peerIdentityDHPub: session.remoteIdentityKey,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initiator side of a stream handshake. Generates the streamId and
|
||||||
|
* this side's ephemeral, and returns a `complete` continuation that
|
||||||
|
* derives the sub-ratchet once the responder's ephemeral arrives in
|
||||||
|
* the `STREAM_OPEN_ACK`.
|
||||||
|
*
|
||||||
|
* Touches neither the stored parent session nor the per-peer op
|
||||||
|
* queues (R5).
|
||||||
|
*/
|
||||||
|
async beginStream(address: string): Promise<{
|
||||||
|
streamId: Uint8Array;
|
||||||
|
ephemeralPublicKey: Uint8Array;
|
||||||
|
complete: (peerEphemeralPub: Uint8Array) => Promise<StreamRatchet>;
|
||||||
|
}> {
|
||||||
|
const { selfIdentityDHPriv, peerIdentityDHPub } =
|
||||||
|
await this.streamIdentityMaterial(address);
|
||||||
|
const streamId = this.crypto.randomBytes(STREAM_ID_BYTES);
|
||||||
|
const ephemeral = await this.crypto.generateX25519KeyPair();
|
||||||
|
|
||||||
|
const complete = async (peerEphemeralPub: Uint8Array): Promise<StreamRatchet> => {
|
||||||
|
if (peerEphemeralPub.length !== STREAM_EPHEMERAL_BYTES) {
|
||||||
|
throw new StreamHandshakeError(
|
||||||
|
`responder ephemeral must be ${STREAM_EPHEMERAL_BYTES} bytes`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const sk = await deriveStreamRootKey(
|
||||||
|
this.crypto,
|
||||||
|
'initiator',
|
||||||
|
streamId,
|
||||||
|
selfIdentityDHPriv,
|
||||||
|
peerIdentityDHPub,
|
||||||
|
ephemeral.privateKey,
|
||||||
|
peerEphemeralPub,
|
||||||
|
);
|
||||||
|
const session = await bootstrapStreamSession(this.crypto, 'initiator', sk, peerIdentityDHPub, {
|
||||||
|
publicKey: peerEphemeralPub,
|
||||||
|
privateKey: new Uint8Array(0),
|
||||||
|
});
|
||||||
|
this.crypto.zeroize(sk);
|
||||||
|
this.crypto.zeroize(ephemeral.privateKey);
|
||||||
|
this.events?.emit('stream.opened', { address, role: 'initiator' });
|
||||||
|
return new StreamRatchet(this.crypto, session, streamId);
|
||||||
|
};
|
||||||
|
|
||||||
|
return { streamId, ephemeralPublicKey: ephemeral.publicKey, complete };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Responder side of a stream handshake. Given the initiator's
|
||||||
|
* `STREAM_OPEN` (its streamId + ephemeral), derives the sub-ratchet
|
||||||
|
* immediately and returns this side's ephemeral for the
|
||||||
|
* `STREAM_OPEN_ACK`.
|
||||||
|
*/
|
||||||
|
async acceptStream(
|
||||||
|
address: string,
|
||||||
|
streamId: Uint8Array,
|
||||||
|
initiatorEphemeralPub: Uint8Array,
|
||||||
|
): Promise<{ ephemeralPublicKey: Uint8Array; stream: StreamRatchet }> {
|
||||||
|
if (streamId.length !== STREAM_ID_BYTES) {
|
||||||
|
throw new StreamHandshakeError(`streamId must be ${STREAM_ID_BYTES} bytes`);
|
||||||
|
}
|
||||||
|
if (initiatorEphemeralPub.length !== STREAM_EPHEMERAL_BYTES) {
|
||||||
|
throw new StreamHandshakeError(
|
||||||
|
`initiator ephemeral must be ${STREAM_EPHEMERAL_BYTES} bytes`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const { selfIdentityDHPriv, peerIdentityDHPub } =
|
||||||
|
await this.streamIdentityMaterial(address);
|
||||||
|
const ephemeral = await this.crypto.generateX25519KeyPair();
|
||||||
|
|
||||||
|
const sk = await deriveStreamRootKey(
|
||||||
|
this.crypto,
|
||||||
|
'responder',
|
||||||
|
streamId,
|
||||||
|
selfIdentityDHPriv,
|
||||||
|
peerIdentityDHPub,
|
||||||
|
ephemeral.privateKey,
|
||||||
|
initiatorEphemeralPub,
|
||||||
|
);
|
||||||
|
const session = await bootstrapStreamSession(
|
||||||
|
this.crypto,
|
||||||
|
'responder',
|
||||||
|
sk,
|
||||||
|
peerIdentityDHPub,
|
||||||
|
ephemeral,
|
||||||
|
);
|
||||||
|
this.crypto.zeroize(sk);
|
||||||
|
this.events?.emit('stream.opened', { address, role: 'responder' });
|
||||||
|
return {
|
||||||
|
ephemeralPublicKey: ephemeral.publicKey,
|
||||||
|
stream: new StreamRatchet(this.crypto, session, streamId),
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function arraysEqual(a: Uint8Array, b: Uint8Array): boolean {
|
function arraysEqual(a: Uint8Array, b: Uint8Array): boolean {
|
||||||
|
|||||||
233
packages/shade-core/src/stream.ts
Normal file
233
packages/shade-core/src/stream.ts
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
import type { CryptoProvider } from './crypto.js';
|
||||||
|
import type { KeyPair, RatchetMessage, SessionState } from './types.js';
|
||||||
|
import {
|
||||||
|
initSenderSession,
|
||||||
|
initReceiverSession,
|
||||||
|
ratchetEncrypt,
|
||||||
|
ratchetDecrypt,
|
||||||
|
} from './ratchet.js';
|
||||||
|
import { StreamClosedError } from './errors.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Streaming Double-Ratchet sub-sessions (V4.11).
|
||||||
|
*
|
||||||
|
* Wraps a long-lived, high-frequency, often-one-directional channel
|
||||||
|
* (e.g. a server→client WebSocket log burst) in an *independent* Double
|
||||||
|
* Ratchet that is derived from — but never mutates — an already
|
||||||
|
* established parent Shade session.
|
||||||
|
*
|
||||||
|
* Why a sub-ratchet rather than reusing `ShadeSessionManager`:
|
||||||
|
*
|
||||||
|
* - **Independence (R5).** A stream gets its own root key, chains, DH
|
||||||
|
* ratchet and op-mutex. Opening/closing it never touches the stored
|
||||||
|
* parent `SessionState` nor serialises against the HTTP send/receive
|
||||||
|
* queue.
|
||||||
|
* - **Performance (R7).** The stream ratchet lives only in memory and
|
||||||
|
* is *never* written to the keystore. There is therefore zero
|
||||||
|
* per-frame storage I/O — the cost is purely the symmetric KDF +
|
||||||
|
* AES-GCM, the same primitives the HTTP path uses.
|
||||||
|
* - **Forward secrecy.** Not persisting the evolving ratchet state is
|
||||||
|
* a feature, not a shortcut: writing per-frame secrets to disk would
|
||||||
|
* actively defeat the forward-secrecy guarantee. A dropped/reconnected
|
||||||
|
* stream is re-opened with a fresh handshake, not resumed.
|
||||||
|
*
|
||||||
|
* ## Seeding (no prekey-server round trip)
|
||||||
|
*
|
||||||
|
* The stream root key is derived from an identity-bound 3-DH exchange —
|
||||||
|
* the X3DH pattern minus the signed / one-time prekeys, because the
|
||||||
|
* peer's identity is *already* mutually pinned by the parent session's
|
||||||
|
* TOFU. Two ephemeral keys are exchanged inside the transport itself
|
||||||
|
* (`STREAM_OPEN` / `STREAM_OPEN_ACK`); no prekey server is involved.
|
||||||
|
*
|
||||||
|
* slotA = DH(initiatorEphemeral, responderIdentity) — auth of responder
|
||||||
|
* slotB = DH(initiatorIdentity, responderEphemeral) — auth of initiator
|
||||||
|
* slotC = DH(initiatorEphemeral, responderEphemeral) — ephemeral FS
|
||||||
|
*
|
||||||
|
* SK = HKDF(ikm = slotA‖slotB‖slotC, salt = streamId, info = "ShadeStream/v1")
|
||||||
|
*
|
||||||
|
* Both peers compute the identical three scalars regardless of role, so
|
||||||
|
* `SK` agrees. An attacker lacking the responder's identity private key
|
||||||
|
* cannot form slotA; one lacking the initiator's cannot form slotB —
|
||||||
|
* the handshake is therefore mutually authenticated against the same
|
||||||
|
* identities the parent session already trusts.
|
||||||
|
*
|
||||||
|
* `SK` then bootstraps a textbook Double Ratchet by handing the
|
||||||
|
* responder's ephemeral to {@link initSenderSession} /
|
||||||
|
* {@link initReceiverSession} exactly the way X3DH hands its signed
|
||||||
|
* prekey to the ratchet — so `ratchetEncrypt` / `ratchetDecrypt` (and
|
||||||
|
* thus every R1–R3 guarantee they already carry) apply unchanged.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type StreamRole = 'initiator' | 'responder';
|
||||||
|
|
||||||
|
/** Stream identifier length (bytes). 128 bits of collision resistance. */
|
||||||
|
export const STREAM_ID_BYTES = 16;
|
||||||
|
|
||||||
|
/** Ephemeral X25519 public-key length carried in the handshake. */
|
||||||
|
export const STREAM_EPHEMERAL_BYTES = 32;
|
||||||
|
|
||||||
|
const STREAM_KDF_INFO = new TextEncoder().encode('ShadeStream/v1');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive the stream's independent root key from the identity-bound 3-DH
|
||||||
|
* exchange. Pure: never reads or mutates any `SessionState`.
|
||||||
|
*
|
||||||
|
* @param role which end of the handshake we are
|
||||||
|
* @param streamId 16-byte stream id (HKDF salt; binds the
|
||||||
|
* derivation so two concurrent streams to the
|
||||||
|
* same peer never share a root key)
|
||||||
|
* @param selfIdentityDHPriv our long-term identity X25519 private key
|
||||||
|
* @param peerIdentityDHPub peer's pinned identity X25519 public key
|
||||||
|
* (the value the parent session pinned)
|
||||||
|
* @param selfEphemeralPriv our per-stream ephemeral X25519 private key
|
||||||
|
* @param peerEphemeralPub peer's per-stream ephemeral X25519 public key
|
||||||
|
*/
|
||||||
|
export async function deriveStreamRootKey(
|
||||||
|
crypto: CryptoProvider,
|
||||||
|
role: StreamRole,
|
||||||
|
streamId: Uint8Array,
|
||||||
|
selfIdentityDHPriv: Uint8Array,
|
||||||
|
peerIdentityDHPub: Uint8Array,
|
||||||
|
selfEphemeralPriv: Uint8Array,
|
||||||
|
peerEphemeralPub: Uint8Array,
|
||||||
|
): Promise<Uint8Array> {
|
||||||
|
// Each slot is pinned to a fixed semantic (not to local role) so both
|
||||||
|
// sides feed HKDF the identical ikm:
|
||||||
|
// slotA = DH(initiatorEphemeral, responderIdentity)
|
||||||
|
// slotB = DH(initiatorIdentity, responderEphemeral)
|
||||||
|
// slotC = DH(initiatorEphemeral, responderEphemeral)
|
||||||
|
let slotA: Uint8Array;
|
||||||
|
let slotB: Uint8Array;
|
||||||
|
let slotC: Uint8Array;
|
||||||
|
if (role === 'initiator') {
|
||||||
|
slotA = await crypto.x25519(selfEphemeralPriv, peerIdentityDHPub);
|
||||||
|
slotB = await crypto.x25519(selfIdentityDHPriv, peerEphemeralPub);
|
||||||
|
slotC = await crypto.x25519(selfEphemeralPriv, peerEphemeralPub);
|
||||||
|
} else {
|
||||||
|
slotA = await crypto.x25519(selfIdentityDHPriv, peerEphemeralPub);
|
||||||
|
slotB = await crypto.x25519(selfEphemeralPriv, peerIdentityDHPub);
|
||||||
|
slotC = await crypto.x25519(selfEphemeralPriv, peerEphemeralPub);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ikm = new Uint8Array(96);
|
||||||
|
ikm.set(slotA, 0);
|
||||||
|
ikm.set(slotB, 32);
|
||||||
|
ikm.set(slotC, 64);
|
||||||
|
const sk = await crypto.hkdf(ikm, streamId, STREAM_KDF_INFO, 32);
|
||||||
|
|
||||||
|
crypto.zeroize(slotA);
|
||||||
|
crypto.zeroize(slotB);
|
||||||
|
crypto.zeroize(slotC);
|
||||||
|
crypto.zeroize(ikm);
|
||||||
|
return sk;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bootstrap a fresh Double Ratchet `SessionState` from the derived
|
||||||
|
* stream root key. The responder's ephemeral plays exactly the role
|
||||||
|
* X3DH's signed prekey plays in {@link initSenderSession} /
|
||||||
|
* {@link initReceiverSession}, so the ratchet handoff is identical to
|
||||||
|
* the proven HTTP path.
|
||||||
|
*
|
||||||
|
* On the initiator only `responderEphemeral.publicKey` is needed; the
|
||||||
|
* responder must pass its full ephemeral keypair.
|
||||||
|
*
|
||||||
|
* `peerIdentityDHPub` is recorded as the session's `remoteIdentityKey`
|
||||||
|
* so stream fingerprints stay meaningful and consistent with the parent.
|
||||||
|
*/
|
||||||
|
export async function bootstrapStreamSession(
|
||||||
|
crypto: CryptoProvider,
|
||||||
|
role: StreamRole,
|
||||||
|
sk: Uint8Array,
|
||||||
|
peerIdentityDHPub: Uint8Array,
|
||||||
|
responderEphemeral: KeyPair,
|
||||||
|
): Promise<SessionState> {
|
||||||
|
if (role === 'initiator') {
|
||||||
|
// initSenderSession derives a fresh root via kdfRootKey and does not
|
||||||
|
// retain `sk`, so the caller may safely zeroize it afterwards.
|
||||||
|
return initSenderSession(crypto, sk, peerIdentityDHPub, responderEphemeral.publicKey);
|
||||||
|
}
|
||||||
|
// initReceiverSession stores the root key BY REFERENCE. Hand it an
|
||||||
|
// independent copy so the caller zeroizing its `sk` scratch buffer
|
||||||
|
// can't wipe the live session root.
|
||||||
|
return initReceiverSession(new Uint8Array(sk), peerIdentityDHPub, responderEphemeral);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zeroize every secret a stream session holds, then drop the chains. */
|
||||||
|
function zeroizeSession(crypto: CryptoProvider, s: SessionState): void {
|
||||||
|
crypto.zeroize(s.rootKey);
|
||||||
|
if (s.sendChain.chainKey.length > 0) crypto.zeroize(s.sendChain.chainKey);
|
||||||
|
if (s.receiveChain && s.receiveChain.chainKey.length > 0) {
|
||||||
|
crypto.zeroize(s.receiveChain.chainKey);
|
||||||
|
}
|
||||||
|
if (s.dhSend.privateKey.length > 0) crypto.zeroize(s.dhSend.privateKey);
|
||||||
|
for (const mk of s.skippedKeys.values()) crypto.zeroize(mk);
|
||||||
|
s.skippedKeys.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-memory holder for a stream's Double Ratchet. Serialises its own
|
||||||
|
* `seal`/`open`/`close` on a private promise chain (independent of the
|
||||||
|
* SDK's per-peer encrypt/decrypt queues — R5) so per-frame ratchet
|
||||||
|
* mutations never interleave, while staying fully concurrent with the
|
||||||
|
* parent session and with other streams.
|
||||||
|
*
|
||||||
|
* Never persisted: the ratchet exists only for the lifetime of the
|
||||||
|
* stream and is zeroized on `close()`.
|
||||||
|
*/
|
||||||
|
export class StreamRatchet {
|
||||||
|
private session: SessionState | null;
|
||||||
|
private opChain: Promise<unknown> = Promise.resolve();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly crypto: CryptoProvider,
|
||||||
|
session: SessionState,
|
||||||
|
/** 16-byte stream id this ratchet is bound to. */
|
||||||
|
public readonly streamId: Uint8Array,
|
||||||
|
) {
|
||||||
|
this.session = session;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True once {@link close} has run; `seal`/`open` will throw. */
|
||||||
|
get closed(): boolean {
|
||||||
|
return this.session === null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private run<T>(fn: (s: SessionState) => Promise<T>): Promise<T> {
|
||||||
|
const next = this.opChain.catch(() => undefined).then(() => {
|
||||||
|
if (!this.session) throw new StreamClosedError();
|
||||||
|
return fn(this.session);
|
||||||
|
});
|
||||||
|
// Keep a never-rejecting tail so a failed frame doesn't poison the
|
||||||
|
// next one (a single bad inbound frame must not wedge the stream).
|
||||||
|
this.opChain = next.catch(() => undefined);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wrap one logical frame. Advances the sending chain by one step. */
|
||||||
|
seal(plaintext: Uint8Array): Promise<RatchetMessage> {
|
||||||
|
return this.run((s) => ratchetEncrypt(this.crypto, s, plaintext));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unwrap one inbound frame. Correct and memory-bounded across long
|
||||||
|
* one-directional runs from the peer: ordered transport delivery
|
||||||
|
* skips zero keys per frame, and out-of-order arrivals are still
|
||||||
|
* capped by the ratchet's `MAX_SKIP` / `MAX_CACHED_SKIPPED_KEYS`.
|
||||||
|
*/
|
||||||
|
open(message: RatchetMessage): Promise<Uint8Array> {
|
||||||
|
return this.run((s) => ratchetDecrypt(this.crypto, s, message));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zeroize and drop the ratchet. Idempotent. */
|
||||||
|
close(): Promise<void> {
|
||||||
|
return this.opChain
|
||||||
|
.catch(() => undefined)
|
||||||
|
.then(() => {
|
||||||
|
if (this.session) {
|
||||||
|
zeroizeSession(this.crypto, this.session);
|
||||||
|
this.session = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
176
packages/shade-core/tests/stream.test.ts
Normal file
176
packages/shade-core/tests/stream.test.ts
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
import { describe, test, expect, beforeEach } from 'bun:test';
|
||||||
|
import { SubtleCryptoProvider, MemoryStorage } from '@shade/crypto-web';
|
||||||
|
import {
|
||||||
|
ShadeSessionManager,
|
||||||
|
StreamRatchet,
|
||||||
|
StreamClosedError,
|
||||||
|
DecryptionError,
|
||||||
|
} from '../src/index.js';
|
||||||
|
|
||||||
|
const crypto = new SubtleCryptoProvider();
|
||||||
|
const enc = new TextEncoder();
|
||||||
|
const dec = new TextDecoder();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Establish a *bidirectional* parent session: Alice→Bob X3DH, then one
|
||||||
|
* Alice→Bob message Bob decrypts so Bob also has a session for 'alice'.
|
||||||
|
* Both sides then hold the peer's pinned identity DH key — the input the
|
||||||
|
* stream handshake derives from.
|
||||||
|
*/
|
||||||
|
async function bidirectionalPair() {
|
||||||
|
const aliceStorage = new MemoryStorage();
|
||||||
|
const bobStorage = new MemoryStorage();
|
||||||
|
const alice = new ShadeSessionManager(crypto, aliceStorage);
|
||||||
|
const bob = new ShadeSessionManager(crypto, bobStorage);
|
||||||
|
await alice.initialize();
|
||||||
|
await bob.initialize();
|
||||||
|
|
||||||
|
const otpks = await bob.generateOneTimePreKeys(4);
|
||||||
|
const bundle = await bob.createPreKeyBundle();
|
||||||
|
bundle.oneTimePreKey = { keyId: otpks[0]!.keyId, publicKey: otpks[0]!.keyPair.publicKey };
|
||||||
|
await alice.initSessionFromBundle('bob', bundle);
|
||||||
|
|
||||||
|
const hello = await alice.encrypt('bob', 'parent-hello');
|
||||||
|
expect(await bob.decrypt('alice', hello)).toBe('parent-hello');
|
||||||
|
|
||||||
|
return { alice, bob, aliceStorage, bobStorage };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Run the full STREAM_OPEN / STREAM_OPEN_ACK handshake between managers. */
|
||||||
|
async function openStreamPair(alice: ShadeSessionManager, bob: ShadeSessionManager) {
|
||||||
|
const begun = await alice.beginStream('bob'); // initiator
|
||||||
|
const accepted = await bob.acceptStream('alice', begun.streamId, begun.ephemeralPublicKey);
|
||||||
|
const aliceStream = await begun.complete(accepted.ephemeralPublicKey);
|
||||||
|
return { aliceStream, bobStream: accepted.stream, streamId: begun.streamId };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('streaming sub-ratchet (V4.11)', () => {
|
||||||
|
let alice: ShadeSessionManager;
|
||||||
|
let bob: ShadeSessionManager;
|
||||||
|
let aliceStorage: MemoryStorage;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
({ alice, bob, aliceStorage } = await bidirectionalPair());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('both sides derive the same stream root (round-trips frames)', async () => {
|
||||||
|
const { aliceStream, bobStream } = await openStreamPair(alice, bob);
|
||||||
|
|
||||||
|
// Initiator → responder (first frame triggers responder DH step).
|
||||||
|
const f1 = await aliceStream.seal(enc.encode('log line 1'));
|
||||||
|
expect(dec.decode(await bobStream.open(f1))).toBe('log line 1');
|
||||||
|
|
||||||
|
// Responder → initiator (now responder may seal).
|
||||||
|
const r1 = await bobStream.seal(enc.encode('command-response 1'));
|
||||||
|
expect(dec.decode(await aliceStream.open(r1))).toBe('command-response 1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('two streams to the same peer get independent roots', async () => {
|
||||||
|
const s1 = await openStreamPair(alice, bob);
|
||||||
|
const s2 = await openStreamPair(alice, bob);
|
||||||
|
expect(s1.streamId).not.toEqual(s2.streamId);
|
||||||
|
|
||||||
|
const a = await s1.aliceStream.seal(enc.encode('on stream 1'));
|
||||||
|
// A frame from stream 1 must not decrypt on stream 2's ratchet.
|
||||||
|
await expect(s2.bobStream.open(a)).rejects.toBeInstanceOf(DecryptionError);
|
||||||
|
// …but does on its own.
|
||||||
|
expect(dec.decode(await s1.bobStream.open(a))).toBe('on stream 1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('R2/R3: long one-directional burst stays correct and memory-bounded', async () => {
|
||||||
|
const { aliceStream, bobStream } = await openStreamPair(alice, bob);
|
||||||
|
const N = 5000;
|
||||||
|
|
||||||
|
// Capture a live receive-chain key buffer to prove forward secrecy:
|
||||||
|
// ratchetDecrypt zeroizes the previous chain key in place.
|
||||||
|
await bobStream.open(await aliceStream.seal(enc.encode('frame-0')));
|
||||||
|
const bobSession = (bobStream as unknown as { session: { receiveChain: { chainKey: Uint8Array }; skippedKeys: Map<string, Uint8Array> } }).session;
|
||||||
|
const staleChainKey = bobSession.receiveChain.chainKey;
|
||||||
|
const staleCopy = staleChainKey.slice();
|
||||||
|
expect(staleCopy.some((b) => b !== 0)).toBe(true);
|
||||||
|
|
||||||
|
for (let i = 1; i < N; i++) {
|
||||||
|
const wire = await aliceStream.seal(enc.encode(`frame-${i}`));
|
||||||
|
expect(dec.decode(await bobStream.open(wire))).toBe(`frame-${i}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// In-order delivery ⇒ zero skipped keys retained across 5k frames.
|
||||||
|
expect(bobSession.skippedKeys.size).toBe(0);
|
||||||
|
// The chain key in use at frame 0 was overwritten (forward secrecy).
|
||||||
|
expect(staleChainKey.every((b) => b === 0)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('R5: opening/using/closing a stream never touches the parent session', async () => {
|
||||||
|
const before = await aliceStorage.getSession('bob');
|
||||||
|
const snapshot = JSON.stringify({
|
||||||
|
root: Array.from(before!.rootKey),
|
||||||
|
sendCtr: before!.sendChain.counter,
|
||||||
|
prevCtr: before!.previousSendCounter,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { aliceStream, bobStream } = await openStreamPair(alice, bob);
|
||||||
|
for (let i = 0; i < 200; i++) {
|
||||||
|
await bobStream.open(await aliceStream.seal(enc.encode(`x${i}`)));
|
||||||
|
}
|
||||||
|
await aliceStream.close();
|
||||||
|
await bobStream.close();
|
||||||
|
|
||||||
|
const after = await aliceStorage.getSession('bob');
|
||||||
|
expect(
|
||||||
|
JSON.stringify({
|
||||||
|
root: Array.from(after!.rootKey),
|
||||||
|
sendCtr: after!.sendChain.counter,
|
||||||
|
prevCtr: after!.previousSendCounter,
|
||||||
|
}),
|
||||||
|
).toBe(snapshot);
|
||||||
|
|
||||||
|
// Parent HTTP path still works after the stream lifecycle.
|
||||||
|
const env = await alice.encrypt('bob', 'after-stream');
|
||||||
|
expect(await bob.decrypt('alice', env)).toBe('after-stream');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('R1: replayed / rewound frame is rejected', async () => {
|
||||||
|
const { aliceStream, bobStream } = await openStreamPair(alice, bob);
|
||||||
|
const f1 = await aliceStream.seal(enc.encode('once'));
|
||||||
|
expect(dec.decode(await bobStream.open(f1))).toBe('once');
|
||||||
|
// Re-delivering the exact same sealed frame must fail.
|
||||||
|
await expect(bobStream.open(f1)).rejects.toBeInstanceOf(DecryptionError);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('close() zeroizes and blocks further use; idempotent', async () => {
|
||||||
|
const { aliceStream, bobStream } = await openStreamPair(alice, bob);
|
||||||
|
await aliceStream.close();
|
||||||
|
await aliceStream.close(); // idempotent
|
||||||
|
expect(aliceStream.closed).toBe(true);
|
||||||
|
await expect(aliceStream.seal(enc.encode('nope'))).rejects.toBeInstanceOf(
|
||||||
|
StreamClosedError,
|
||||||
|
);
|
||||||
|
// The peer end is unaffected by our local close.
|
||||||
|
expect(bobStream.closed).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handshake is mutually authenticated against pinned identities', async () => {
|
||||||
|
// A third party (mallory) with its own identity cannot stand in for
|
||||||
|
// bob: alice derives against bob's pinned identity key, so a
|
||||||
|
// handshake completed with mallory's ephemeral yields a different
|
||||||
|
// root and frames fail to open.
|
||||||
|
const mStorage = new MemoryStorage();
|
||||||
|
const mallory = new ShadeSessionManager(crypto, mStorage);
|
||||||
|
await mallory.initialize();
|
||||||
|
// Give mallory a parent session label so acceptStream has identity
|
||||||
|
// material, but pinned to the WRONG (alice) identity vs what alice
|
||||||
|
// pinned for 'bob'.
|
||||||
|
const otpks = await mallory.generateOneTimePreKeys(2);
|
||||||
|
const mb = await mallory.createPreKeyBundle();
|
||||||
|
mb.oneTimePreKey = { keyId: otpks[0]!.keyId, publicKey: otpks[0]!.keyPair.publicKey };
|
||||||
|
await alice.initSessionFromBundle('mallory', mb);
|
||||||
|
const helo = await alice.encrypt('mallory', 'hi');
|
||||||
|
await mallory.decrypt('alice', helo);
|
||||||
|
|
||||||
|
const begun = await alice.beginStream('bob');
|
||||||
|
const mAccept = await mallory.acceptStream('alice', begun.streamId, begun.ephemeralPublicKey);
|
||||||
|
const aliceStream = await begun.complete(mAccept.ephemeralPublicKey);
|
||||||
|
const frame = await aliceStream.seal(enc.encode('secret'));
|
||||||
|
await expect(mAccept.stream.open(frame)).rejects.toBeInstanceOf(DecryptionError);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/crypto-web",
|
"name": "@shade/crypto-web",
|
||||||
"version": "4.10.0",
|
"version": "4.11.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/dashboard",
|
"name": "@shade/dashboard",
|
||||||
"version": "4.10.0",
|
"version": "4.11.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/files",
|
"name": "@shade/files",
|
||||||
"version": "4.10.0",
|
"version": "4.11.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/inbox-server",
|
"name": "@shade/inbox-server",
|
||||||
"version": "4.10.0",
|
"version": "4.11.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/inbox",
|
"name": "@shade/inbox",
|
||||||
"version": "4.10.0",
|
"version": "4.11.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/key-transparency",
|
"name": "@shade/key-transparency",
|
||||||
"version": "4.10.0",
|
"version": "4.11.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/keychain",
|
"name": "@shade/keychain",
|
||||||
"version": "4.10.0",
|
"version": "4.11.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/observability",
|
"name": "@shade/observability",
|
||||||
"version": "4.10.0",
|
"version": "4.11.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/observer",
|
"name": "@shade/observer",
|
||||||
"version": "4.10.0",
|
"version": "4.11.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/proto",
|
"name": "@shade/proto",
|
||||||
"version": "4.10.0",
|
"version": "4.11.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -8,7 +8,20 @@ export {
|
|||||||
encodeBroadcast,
|
encodeBroadcast,
|
||||||
decodeBroadcast,
|
decodeBroadcast,
|
||||||
inspectEnvelopeType,
|
inspectEnvelopeType,
|
||||||
|
encodeStreamOpen,
|
||||||
|
encodeStreamOpenAck,
|
||||||
|
decodeStreamHandshake,
|
||||||
|
encodeStreamFrame,
|
||||||
|
decodeStreamFrame,
|
||||||
TYPE_STREAM_CHUNK,
|
TYPE_STREAM_CHUNK,
|
||||||
TYPE_BROADCAST,
|
TYPE_BROADCAST,
|
||||||
|
TYPE_STREAM_OPEN,
|
||||||
|
TYPE_STREAM_OPEN_ACK,
|
||||||
|
TYPE_STREAM_FRAME,
|
||||||
|
} from './wire.js';
|
||||||
|
export type {
|
||||||
|
StreamChunkWire,
|
||||||
|
BroadcastWire,
|
||||||
|
StreamHandshakeWire,
|
||||||
|
StreamFrameWire,
|
||||||
} from './wire.js';
|
} from './wire.js';
|
||||||
export type { StreamChunkWire, BroadcastWire } from './wire.js';
|
|
||||||
|
|||||||
@@ -25,6 +25,13 @@ const TYPE_PREKEY = 0x01;
|
|||||||
const TYPE_RATCHET = 0x02;
|
const TYPE_RATCHET = 0x02;
|
||||||
export const TYPE_STREAM_CHUNK = 0x11;
|
export const TYPE_STREAM_CHUNK = 0x11;
|
||||||
export const TYPE_BROADCAST = 0x21;
|
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 ──────────────────────────────────────
|
// ─── Stream chunk types ──────────────────────────────────────
|
||||||
|
|
||||||
@@ -258,7 +265,15 @@ export function decodeStreamChunk(data: Uint8Array): StreamChunkWire {
|
|||||||
*/
|
*/
|
||||||
export function inspectEnvelopeType(
|
export function inspectEnvelopeType(
|
||||||
data: Uint8Array,
|
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';
|
if (data.length < 2 || data[0] !== VERSION) return 'unknown';
|
||||||
switch (data[1]) {
|
switch (data[1]) {
|
||||||
case TYPE_PREKEY:
|
case TYPE_PREKEY:
|
||||||
@@ -269,11 +284,122 @@ export function inspectEnvelopeType(
|
|||||||
return 'stream-chunk';
|
return 'stream-chunk';
|
||||||
case TYPE_BROADCAST:
|
case TYPE_BROADCAST:
|
||||||
return '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:
|
default:
|
||||||
return 'unknown';
|
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) ───────────────────────────────────
|
// ─── Broadcast wire (V4.6) ───────────────────────────────────
|
||||||
|
|
||||||
const BROADCAST_NONCE_BYTES = 12;
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/recovery",
|
"name": "@shade/recovery",
|
||||||
"version": "4.10.0",
|
"version": "4.11.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/sdk",
|
"name": "@shade/sdk",
|
||||||
"version": "4.10.0",
|
"version": "4.11.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
export { createShade } from './create-shade.js';
|
export { createShade } from './create-shade.js';
|
||||||
export { Shade } from './shade.js';
|
export { Shade, ShadeStream } from './shade.js';
|
||||||
export type {
|
export type {
|
||||||
ShadeUploadOptions,
|
ShadeUploadOptions,
|
||||||
ShadeWebRtcConfig,
|
ShadeWebRtcConfig,
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import type { ShadeEnvelope, StorageProvider } from '@shade/core';
|
import type { ShadeEnvelope, StorageProvider, RatchetMessage } from '@shade/core';
|
||||||
import {
|
import {
|
||||||
ShadeSessionManager,
|
ShadeSessionManager,
|
||||||
ShadeEventEmitter,
|
ShadeEventEmitter,
|
||||||
NoSessionError,
|
NoSessionError,
|
||||||
|
StreamRatchet,
|
||||||
|
StreamHandshakeError,
|
||||||
} from '@shade/core';
|
} from '@shade/core';
|
||||||
import {
|
import {
|
||||||
FingerprintGateRegistry,
|
FingerprintGateRegistry,
|
||||||
@@ -18,7 +20,16 @@ import {
|
|||||||
type CreateEncryptStreamOptions,
|
type CreateEncryptStreamOptions,
|
||||||
type CreateDecryptStreamOptions,
|
type CreateDecryptStreamOptions,
|
||||||
} from '@shade/crypto-web';
|
} 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 { ShadeFetchTransport, type KTVerifierOptions } from '@shade/transport';
|
||||||
import { LightWitness } from '@shade/key-transparency';
|
import { LightWitness } from '@shade/key-transparency';
|
||||||
import type { SignedTreeHead, STHWire } from '@shade/key-transparency';
|
import type { SignedTreeHead, STHWire } from '@shade/key-transparency';
|
||||||
@@ -1510,6 +1521,97 @@ export class Shade {
|
|||||||
await this.storage.pruneStreamStates(olderThan);
|
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> {
|
private async ensureSession(address: string): Promise<void> {
|
||||||
// Deduplicate concurrent establishment requests
|
// Deduplicate concurrent establishment requests
|
||||||
const existing = this.establishing.get(address);
|
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 {
|
function bytesToBase64Std(bytes: Uint8Array): string {
|
||||||
let bin = '';
|
let bin = '';
|
||||||
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]!);
|
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]!);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/server",
|
"name": "@shade/server",
|
||||||
"version": "4.10.0",
|
"version": "4.11.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/storage-encrypted",
|
"name": "@shade/storage-encrypted",
|
||||||
"version": "4.10.0",
|
"version": "4.11.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/storage-indexeddb",
|
"name": "@shade/storage-indexeddb",
|
||||||
"version": "4.10.0",
|
"version": "4.11.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/storage-postgres",
|
"name": "@shade/storage-postgres",
|
||||||
"version": "4.10.0",
|
"version": "4.11.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/storage-sqlite",
|
"name": "@shade/storage-sqlite",
|
||||||
"version": "4.10.0",
|
"version": "4.11.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/streams",
|
"name": "@shade/streams",
|
||||||
"version": "4.10.0",
|
"version": "4.11.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/transfer",
|
"name": "@shade/transfer",
|
||||||
"version": "4.10.0",
|
"version": "4.11.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/transport-bridge",
|
"name": "@shade/transport-bridge",
|
||||||
"version": "4.10.0",
|
"version": "4.11.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/transport-webrtc",
|
"name": "@shade/transport-webrtc",
|
||||||
"version": "4.10.0",
|
"version": "4.11.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/transport",
|
"name": "@shade/transport",
|
||||||
"version": "4.10.0",
|
"version": "4.11.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@shade/widgets",
|
"name": "@shade/widgets",
|
||||||
"version": "4.10.0",
|
"version": "4.11.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
|||||||
Reference in New Issue
Block a user